From 3387ede9dfee419ba1322a220bfda72c12caa762 Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Fri, 6 Nov 2026 16:02:21 -0500 Subject: [PATCH] fix(pi): show trace links in a single status line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit > 🤖 **This text was AI-generated.** The trace widget showed a full URL alongside a separate tracing status, adding clutter below the editor: ```text Braintrust trace https://www.braintrust.dev/... Braintrust: tracing ``` Replace both with one status that becomes clickable when a trace is available and Pi reports hyperlink support: ```text Braintrust: connected ↓ Braintrust trace ↗ ``` Use Pi TUI's getCapabilities() and hyperlink() helpers to respect terminal support and PI_HYPERLINKS=0. When hyperlinks are disabled or unsupported, keep Braintrust: connected without a URL or a misleading link icon. --- src/plugins/pi/content/package.json | 4 +- src/plugins/pi/content/pnpm-lock.yaml | 15 +++-- .../pi/content/src/daemon-adapter.test.ts | 62 +++++++++++++------ src/plugins/pi/content/src/index.ts | 16 ++--- 4 files changed, 65 insertions(+), 32 deletions(-) diff --git a/src/plugins/pi/content/package.json b/src/plugins/pi/content/package.json index 93938f85..9504cbca 100644 --- a/src/plugins/pi/content/package.json +++ b/src/plugins/pi/content/package.json @@ -45,13 +45,15 @@ }, "devDependencies": { "@earendil-works/pi-coding-agent": "^1.0.0", + "@earendil-works/pi-tui": "^1.0.0", "@types/node": "^26.6.4", "typescript": "^6.0.3", "vite-plus": "^1.4.0", "vitest": "5.0.5" }, "peerDependencies": { - "@earendil-works/pi-coding-agent": "*" + "@earendil-works/pi-coding-agent": "*", + "@earendil-works/pi-tui": "*" }, "packageManager": "pnpm@11.21.0", "pi": { diff --git a/src/plugins/pi/content/pnpm-lock.yaml b/src/plugins/pi/content/pnpm-lock.yaml index b09f472b..f5b95c62 100644 --- a/src/plugins/pi/content/pnpm-lock.yaml +++ b/src/plugins/pi/content/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: '@earendil-works/pi-coding-agent': specifier: ^1.0.0 version: 1.0.0(@aws-sdk/credential-provider-node@3.972.84)(@smithy/signature-v4@5.7.4)(ws@8.22.0) + '@earendil-works/pi-tui': + specifier: ^1.0.0 + version: 1.0.0 '@types/node': specifier: ^26.6.4 version: 26.6.4 @@ -34,7 +37,7 @@ importers: version: 1.4.0(@opentelemetry/api@1.9.0)(@types/node@26.6.4)(@vitest/ui@5.0.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.9.0) vitest: specifier: 5.0.5 - version: 5.0.5(@opentelemetry/api@1.9.0)(@types/node@26.6.4)(@vitest/browser-preview@5.0.5)(@vitest/ui@5.0.5(vitest@5.0.5))(@voidzero-dev/vite-plus-core@1.4.0(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.9.0)) + version: 5.0.5(@opentelemetry/api@1.9.0)(@types/node@26.6.4)(@vitest/browser-preview@5.0.5(@voidzero-dev/vite-plus-core@1.4.0(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.9.0))(vitest@5.0.5))(@vitest/ui@5.0.5)(@voidzero-dev/vite-plus-core@1.4.0(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.9.0)) packages: @@ -2231,7 +2234,7 @@ snapshots: '@testing-library/dom': 10.4.2 '@testing-library/user-event': 14.6.7(@testing-library/dom@10.4.2) '@vitest/browser': 5.0.5(@voidzero-dev/vite-plus-core@1.4.0(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.9.0))(vitest@5.0.5) - vitest: 5.0.5(@opentelemetry/api@1.9.0)(@types/node@26.6.4)(@vitest/browser-preview@5.0.5)(@vitest/ui@5.0.5(vitest@5.0.5))(@voidzero-dev/vite-plus-core@1.4.0(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.9.0)) + vitest: 5.0.5(@opentelemetry/api@1.9.0)(@types/node@26.6.4)(@vitest/browser-preview@5.0.5(@voidzero-dev/vite-plus-core@1.4.0(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.9.0))(vitest@5.0.5))(@vitest/ui@5.0.5)(@voidzero-dev/vite-plus-core@1.4.0(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.9.0)) transitivePeerDependencies: - bufferutil - msw @@ -2248,7 +2251,7 @@ snapshots: pngjs: 7.0.0 sirv: 3.0.2 tinyrainbow: 3.2.0 - vitest: 5.0.5(@opentelemetry/api@1.9.0)(@types/node@26.6.4)(@vitest/browser-preview@5.0.5)(@vitest/ui@5.0.5(vitest@5.0.5))(@voidzero-dev/vite-plus-core@1.4.0(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.9.0)) + vitest: 5.0.5(@opentelemetry/api@1.9.0)(@types/node@26.6.4)(@vitest/browser-preview@5.0.5(@voidzero-dev/vite-plus-core@1.4.0(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.9.0))(vitest@5.0.5))(@vitest/ui@5.0.5)(@voidzero-dev/vite-plus-core@1.4.0(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.9.0)) ws: 8.22.0 transitivePeerDependencies: - bufferutil @@ -2286,7 +2289,7 @@ snapshots: pathe: 2.0.3 sirv: 3.0.2 tinyrainbow: 3.2.0 - vitest: 5.0.5(@opentelemetry/api@1.9.0)(@types/node@26.6.4)(@vitest/browser-preview@5.0.5)(@vitest/ui@5.0.5(vitest@5.0.5))(@voidzero-dev/vite-plus-core@1.4.0(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.9.0)) + vitest: 5.0.5(@opentelemetry/api@1.9.0)(@types/node@26.6.4)(@vitest/browser-preview@5.0.5(@voidzero-dev/vite-plus-core@1.4.0(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.9.0))(vitest@5.0.5))(@vitest/ui@5.0.5)(@voidzero-dev/vite-plus-core@1.4.0(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.9.0)) '@vitest/utils@5.0.5': dependencies: @@ -2886,7 +2889,7 @@ snapshots: oxlint: 1.89.0(oxlint-tsgolint@7.0.2004)(vite-plus@1.4.0(@opentelemetry/api@1.9.0)(@types/node@26.6.4)(@vitest/ui@5.0.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.9.0)) oxlint-tsgolint: 7.0.2004 vite: '@voidzero-dev/vite-plus-core@1.4.0(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.9.0)' - vitest: 5.0.5(@opentelemetry/api@1.9.0)(@types/node@26.6.4)(@vitest/browser-preview@5.0.5)(@vitest/ui@5.0.5(vitest@5.0.5))(@voidzero-dev/vite-plus-core@1.4.0(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.9.0)) + vitest: 5.0.5(@opentelemetry/api@1.9.0)(@types/node@26.6.4)(@vitest/browser-preview@5.0.5(@voidzero-dev/vite-plus-core@1.4.0(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.9.0))(vitest@5.0.5))(@vitest/ui@5.0.5)(@voidzero-dev/vite-plus-core@1.4.0(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.9.0)) optionalDependencies: '@voidzero-dev/vite-plus-darwin-arm64': 1.4.0 '@voidzero-dev/vite-plus-darwin-x64': 1.4.0 @@ -2931,7 +2934,7 @@ snapshots: - vue-tsc - yaml - vitest@5.0.5(@opentelemetry/api@1.9.0)(@types/node@26.6.4)(@vitest/browser-preview@5.0.5)(@vitest/ui@5.0.5(vitest@5.0.5))(@voidzero-dev/vite-plus-core@1.4.0(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.9.0)): + vitest@5.0.5(@opentelemetry/api@1.9.0)(@types/node@26.6.4)(@vitest/browser-preview@5.0.5(@voidzero-dev/vite-plus-core@1.4.0(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.9.0))(vitest@5.0.5))(@vitest/ui@5.0.5)(@voidzero-dev/vite-plus-core@1.4.0(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.9.0)): dependencies: '@types/chai': 5.2.3 '@vitest/mocker': 5.0.5(@voidzero-dev/vite-plus-core@1.4.0(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.9.0)) diff --git a/src/plugins/pi/content/src/daemon-adapter.test.ts b/src/plugins/pi/content/src/daemon-adapter.test.ts index 7a49f1b5..e9732fd1 100644 --- a/src/plugins/pi/content/src/daemon-adapter.test.ts +++ b/src/plugins/pi/content/src/daemon-adapter.test.ts @@ -1,3 +1,4 @@ +import { hyperlink } from "@earendil-works/pi-tui"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const mockState = vi.hoisted(() => ({ @@ -5,6 +6,7 @@ const mockState = vi.hoisted(() => ({ flushes: [] as string[], closed: 0, claim: true, + hyperlinks: true, legacyContinuation: undefined as Record | undefined, legacyContinuationFor: vi.fn(), logGate: undefined as Promise | undefined, @@ -13,6 +15,11 @@ const mockState = vi.hoisted(() => ({ permalink: "https://www.braintrust.dev/trace/1" as string | undefined, })); +vi.mock("@earendil-works/pi-tui", async (importOriginal) => ({ + ...(await importOriginal()), + getCapabilities: () => ({ hyperlinks: mockState.hyperlinks }), +})); + vi.mock("./runtime/daemon-client.ts", () => ({ claimManagedTracingInstance: () => mockState.claim, DaemonClient: class { @@ -113,6 +120,7 @@ describe("Pi daemon adapter", () => { mockState.flushes.length = 0; mockState.closed = 0; mockState.claim = true; + mockState.hyperlinks = true; mockState.legacyContinuation = undefined; mockState.legacyContinuationFor.mockClear(); mockState.logGate = undefined; @@ -173,14 +181,14 @@ describe("Pi daemon adapter", () => { await handlers.get("session_shutdown")?.({ reason: "quit" }, ctx); expect(statuses.at(-1)).toEqual(["braintrust-tracing", undefined]); - expect(widgets.at(-1)).toEqual(["braintrust-trace-link", undefined]); + expect(widgets).toEqual([]); releaseStatus(); await Promise.resolve(); await Promise.resolve(); expect(statuses).toEqual([["braintrust-tracing", undefined]]); - expect(widgets).toEqual([["braintrust-trace-link", undefined]]); + expect(widgets).toEqual([]); }); it("forwards native events and keeps the trace-link UI", async () => { @@ -286,22 +294,45 @@ describe("Pi daemon adapter", () => { ), ).toEqual(["text_delta", "thinking_delta"]); expect(mockState.flushes).toHaveLength(0); - expect(widgets).toContainEqual([ - "braintrust-trace-link", - ["Braintrust trace", "https://www.braintrust.dev/trace/1"], - { placement: "belowEditor" }, + expect(statuses).toContainEqual([ + "braintrust-tracing", + hyperlink("Braintrust trace ↗", "https://www.braintrust.dev/trace/1"), ]); + expect(widgets).toEqual([]); expect(statuses.length).toBeGreaterThan(0); expect(mockState.closed).toBe(2); }); + it.each([true, false])( + "respects hyperlink capability %s without displaying the URL", + async (supported) => { + mockState.hyperlinks = supported; + const setStatus = vi.fn(); + const setWidget = vi.fn(); + const ctx = fakeContext({ setStatus, setWidget }); + const handlers = await loadExtension(); + await handlers.get("session_start")?.({ reason: "new" }, ctx); + const expectedLabel = supported + ? hyperlink("Braintrust trace ↗", mockState.permalink!) + : "Braintrust: connected"; + await vi.waitFor(() => + expect(setStatus).toHaveBeenCalledWith("braintrust-tracing", expectedLabel), + ); + expect(setWidget).not.toHaveBeenCalled(); + if (!supported) { + expect(setStatus.mock.calls.flat().join(" ")).not.toContain("https://"); + } + await handlers.get("session_shutdown")?.({ reason: "quit" }, ctx); + }, + ); + it("polls for the trace link until the daemon delivers the first turn", async () => { vi.useFakeTimers(); try { - const widgets: unknown[] = []; + const statuses: unknown[] = []; const ctx = fakeContext({ - setStatus: vi.fn(), - setWidget: (...args: unknown[]) => widgets.push(args), + setStatus: (...args: unknown[]) => statuses.push(args), + setWidget: vi.fn(), }); const handlers = await loadExtension(); @@ -310,18 +341,13 @@ describe("Pi daemon adapter", () => { await handlers.get("session_start")?.({ reason: "new" }, ctx); await handlers.get("agent_end")?.({ messages: [] }, ctx); await vi.advanceTimersByTimeAsync(10_000); - expect(widgets.at(-1)).toEqual([ - "braintrust-trace-link", - undefined, - { placement: "belowEditor" }, - ]); + expect(statuses.at(-1)).toEqual(["braintrust-tracing", "Braintrust: connected"]); mockState.permalink = "https://www.braintrust.dev/trace/1"; await vi.advanceTimersByTimeAsync(2_000); - expect(widgets.at(-1)).toEqual([ - "braintrust-trace-link", - ["Braintrust trace", "https://www.braintrust.dev/trace/1"], - { placement: "belowEditor" }, + expect(statuses.at(-1)).toEqual([ + "braintrust-tracing", + hyperlink("Braintrust trace ↗", "https://www.braintrust.dev/trace/1"), ]); // Once the link is shown, neither the timer nor later turns query again. diff --git a/src/plugins/pi/content/src/index.ts b/src/plugins/pi/content/src/index.ts index 2a1c38f0..0f835148 100644 --- a/src/plugins/pi/content/src/index.ts +++ b/src/plugins/pi/content/src/index.ts @@ -1,6 +1,7 @@ import { createHash, randomUUID } from "node:crypto"; import { resolve } from "node:path"; import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; +import { getCapabilities, hyperlink } from "@earendil-works/pi-tui"; import { loadConfig } from "./config.ts"; import { legacyContinuationFor, type LegacyContinuation } from "./legacy-session.ts"; import { runningPiPackage } from "./pi-package.ts"; @@ -8,7 +9,6 @@ import { claimManagedTracingInstance, DaemonClient } from "./runtime/daemon-clie import { EXTENSION_VERSION } from "./version.ts"; const STATUS_KEY = "braintrust-tracing"; -const WIDGET_KEY = "braintrust-trace-link"; const UI_STATUS_TIMEOUT_MS = 250; // The daemon uploads a turn after Pi reports agent_end: about a second later // with flush_on_turn_end, and when its 30 s batch window closes with @@ -133,15 +133,18 @@ export default function braintrustPiExtension(pi: ExtensionAPI): void { const daemonSession = status?.sessions.find( (session) => session.session_id === refreshSessionId, ); + const permalink = config.showTraceLink ? daemonSession?.permalink : undefined; + const traceLabel = "Braintrust trace ↗"; if (daemonSession?.last_error) { ctx.ui.setStatus(STATUS_KEY, `Braintrust: ${daemonSession.last_error}`); } else { - ctx.ui.setStatus(STATUS_KEY, "Braintrust: tracing"); + ctx.ui.setStatus( + STATUS_KEY, + permalink && getCapabilities().hyperlinks + ? hyperlink(traceLabel, permalink) + : "Braintrust: connected", + ); } - const permalink = config.showTraceLink ? daemonSession?.permalink : undefined; - ctx.ui.setWidget(WIDGET_KEY, permalink ? ["Braintrust trace", permalink] : undefined, { - placement: "belowEditor", - }); traceLinkShown = Boolean(permalink); }; @@ -285,7 +288,6 @@ export default function braintrustPiExtension(pi: ExtensionAPI): void { lastContext = undefined; if (ctx.hasUI) { ctx.ui.setStatus(STATUS_KEY, undefined); - ctx.ui.setWidget(WIDGET_KEY, undefined); } await client.close(); await statusClient.close();