From f027ee5a6f8f62e556eb17b48210058dd2e82974 Mon Sep 17 00:00:00 2001 From: Chad Hietala Date: Thu, 30 Jul 2026 23:08:46 -0400 Subject: [PATCH] Polish the trace viewer chrome: padding, floating toast, drawer wheel The header becomes padding, title, padding and the footer gains a padding row above the key hints, so the frame no longer crowds the terminal edges or the cards. The copy toast turns into a floating top-right surface with a greyish-white bar down its left edge instead of inline header text, and the scroll wheel scrolls the attributes drawer when the pointer is over it. Signed-off-by: Chad Hietala --- .changeset/trace-viewer-chrome.md | 5 ++ docs/guides/dev-tui.md | 2 +- .../src/cli/dev/tui/traces/trace-view.test.ts | 16 +++-- .../eve/src/cli/dev/tui/traces/trace-view.ts | 64 +++++++++++++++---- .../tui/traces/trace-viewer-session.test.ts | 15 +++-- .../dev/tui/traces/trace-viewer-state.test.ts | 51 +++++++++++---- .../cli/dev/tui/traces/trace-viewer-state.ts | 20 ++++-- packages/eve/test/tui-client/tui-traces.ts | 13 ++-- 8 files changed, 141 insertions(+), 45 deletions(-) create mode 100644 .changeset/trace-viewer-chrome.md diff --git a/.changeset/trace-viewer-chrome.md b/.changeset/trace-viewer-chrome.md new file mode 100644 index 000000000..5ec01be14 --- /dev/null +++ b/.changeset/trace-viewer-chrome.md @@ -0,0 +1,5 @@ +--- +"eve": patch +--- + +The `/traces` viewer frame breathes: padding rows around the title and above the footer hints, the copy toast floats top-right as a small surface with a left edge bar, and the scroll wheel scrolls the attributes drawer when the pointer is over it. diff --git a/docs/guides/dev-tui.md b/docs/guides/dev-tui.md index d67684543..bf6225260 100644 --- a/docs/guides/dev-tui.md +++ b/docs/guides/dev-tui.md @@ -123,7 +123,7 @@ The viewer opens on your current session's trace (or the most recent one). It re | `Esc` | Close the panel (or unfocus it), otherwise close the viewer. | | `q` | Close the viewer. | -`←`/`→` or a mouse click expands a card to its full contents (the system prompt, long payloads) and collapses it back; cards that already fit have nothing to expand. The scroll wheel scrolls the conversation. Dragging with the mouse selects text — the selection highlights as you drag, and releasing copies it to your clipboard (a `✓ Copied to clipboard` toast confirms in the header); `Esc` cancels an in-flight selection. The attributes panel opens on the right and stays metadata-only — status, timing, ids, and the span's non-payload OTLP attributes — since the cards already carry the payloads. Model spans capture the system prompt, the prompt messages (rendered as a chat transcript), and the response (text, tool calls, finish reason); tool-call spans capture the call arguments and result, pretty-printed — each capped at 32 KB, with provider transport metadata stripped. When a conversation's messages exceed the cap, the oldest messages are dropped and the transcript notes how many were omitted. Set `EVE_TRACES_CONTENT=off` to stop capturing payload content. With no spans yet, the viewer shows an empty state and keeps watching; if tracing is disabled (`EVE_TRACES=off`) it says so instead. +`←`/`→` or a mouse click expands a card to its full contents (the system prompt, long payloads) and collapses it back; cards that already fit have nothing to expand. The scroll wheel scrolls the conversation (or the attributes panel when the pointer is over it). Dragging with the mouse selects text — the selection highlights as you drag, and releasing copies it to your clipboard with a `Copied to clipboard` toast in the top-right corner; `Esc` cancels an in-flight selection. The attributes panel opens on the right and stays metadata-only — status, timing, ids, and the span's non-payload OTLP attributes — since the cards already carry the payloads. Model spans capture the system prompt, the prompt messages (rendered as a chat transcript), and the response (text, tool calls, finish reason); tool-call spans capture the call arguments and result, pretty-printed — each capped at 32 KB, with provider transport metadata stripped. When a conversation's messages exceed the cap, the oldest messages are dropped and the transcript notes how many were omitted. Set `EVE_TRACES_CONTENT=off` to stop capturing payload content. With no spans yet, the viewer shows an empty state and keeps watching; if tracing is disabled (`EVE_TRACES=off`) it says so instead. ## Display flags diff --git a/packages/eve/src/cli/dev/tui/traces/trace-view.test.ts b/packages/eve/src/cli/dev/tui/traces/trace-view.test.ts index 2ca62ccb9..ab8608d19 100644 --- a/packages/eve/src/cli/dev/tui/traces/trace-view.test.ts +++ b/packages/eve/src/cli/dev/tui/traces/trace-view.test.ts @@ -91,7 +91,7 @@ function clockTimeOf(nanoseconds: bigint): string { describe("renderTraceViewer", () => { it("renders the header with full session id, start time, counts, and position", () => { const frame = render(viewerState(conversationSpans())); - const header = stripAnsi(frame.rows[0]!); + const header = stripAnsi(frame.rows[1]!); expect(header).toContain("traces"); expect(header).toContain("weather"); expect(header).toContain("session session-123"); @@ -113,7 +113,7 @@ describe("renderTraceViewer", () => { expect(selectedRows.length).toBeGreaterThan(0); }); - it("shows the copy toast in the header's top-right corner", () => { + it("shows the copy toast as a floating card below the title", () => { const state = viewerState(conversationSpans()); const frame = renderTraceViewer(state, { width: 100, @@ -121,7 +121,11 @@ describe("renderTraceViewer", () => { theme: THEME, toast: "Copied to clipboard", }); - expect(stripAnsi(frame.rows[0]!)).toContain("✓ Copied to clipboard"); + // Message row with the left bar, padding rows above and below. + expect(stripAnsi(frame.rows[3]!)).toContain("▌ Copied to clipboard"); + expect(stripAnsi(frame.rows[2]!)).toMatch(/▌ +$/u); + expect(stripAnsi(frame.rows[4]!)).toMatch(/▌ +$/u); + expect(stripAnsi(frame.rows[1]!)).not.toContain("Copied"); }); it("paints an active drag selection in reverse video", () => { @@ -305,9 +309,11 @@ describe("renderTraceViewer", () => { }); it("reports viewport metrics for key handling", () => { + // 30 rows minus three header rows (padding, title, padding) and the three-row + // footer (padding + hints + status). const frame = render(viewerState(conversationSpans()), 100, 30); - expect(frame.timelineViewportRows).toBe(27); - expect(frame.panelViewportRows).toBe(27); + expect(frame.timelineViewportRows).toBe(24); + expect(frame.panelViewportRows).toBe(24); expect(frame.panelTotalRows).toBe(0); }); diff --git a/packages/eve/src/cli/dev/tui/traces/trace-view.ts b/packages/eve/src/cli/dev/tui/traces/trace-view.ts index 25b38d44a..83abd9d06 100644 --- a/packages/eve/src/cli/dev/tui/traces/trace-view.ts +++ b/packages/eve/src/cli/dev/tui/traces/trace-view.ts @@ -22,10 +22,15 @@ import type { Theme } from "../theme.js"; import { formatAttributeContent } from "./trace-content.js"; import { renderConversationItem } from "./trace-conversation.js"; import type { TextSelectionRange, TraceViewerState } from "./trace-viewer-state.js"; -import { orderedTextSelection, selectedTraceViewerSpan } from "./trace-viewer-state.js"; - -const HEADER_ROWS = 1; -const FOOTER_ROWS = 2; +import { + TRACE_VIEWER_HEADER_ROWS, + orderedTextSelection, + selectedTraceViewerSpan, +} from "./trace-viewer-state.js"; + +const HEADER_ROWS = TRACE_VIEWER_HEADER_ROWS; +/** Footer: padding row, key hints, and the position/notice line. */ +const FOOTER_ROWS = 3; /** The body always keeps at least this many columns. */ const BODY_MIN_WIDTH = 20; const PANEL_MIN_WIDTH = 20; @@ -96,6 +101,7 @@ export function renderTraceViewer( } } rows.push(...renderFooter(state, width, theme)); + if (options.toast !== undefined) overlayToast(rows, options.toast, width, theme); return { rows: rows.map((row) => withViewerBase(row, width, theme)), @@ -128,6 +134,42 @@ function withViewerBase(row: string, width: number, theme: Theme): string { .replaceAll("\x1b[49m", VIEWER_BASE_REOPEN_BG)}${RESET_ALL}`; } +/** + * The toast floats over the frame's top-right corner as a small elevated + * surface: a greyish-white half-block bar down its left edge, a padding row + * above and below the message, and one column of canvas as right margin. + */ +const TOAST_SURFACE = "\x1b[48;2;36;36;36m"; +const TOAST_BAR = "\x1b[38;2;210;210;210m▌\x1b[39m"; +const TOAST_TEXT = "\x1b[97m"; + +function overlayToast(rows: string[], message: string, width: number, theme: Theme): void { + const text = stripTerminalControls(message); + const inner = ` ${text} `; + const innerWidth = visibleLength(inner); + const lines = theme.color + ? [ + `${TOAST_SURFACE}${TOAST_BAR}${" ".repeat(innerWidth)}\x1b[0m`, + `${TOAST_SURFACE}${TOAST_BAR}${TOAST_TEXT}${inner}\x1b[0m`, + `${TOAST_SURFACE}${TOAST_BAR}${" ".repeat(innerWidth)}\x1b[0m`, + ] + : [`▌${" ".repeat(innerWidth)}`, `▌${inner}`, `▌${" ".repeat(innerWidth)}`]; + // Below the title row, so the trace identity stays readable. + for (const [offset, line] of lines.entries()) { + const index = HEADER_ROWS - 1 + offset; + if (index >= rows.length) break; + rows[index] = overlayRight(rows[index]!, line, width, 1); + } +} + +/** Splices `segment` over the right end of `row`, keeping `margin` canvas columns. */ +function overlayRight(row: string, segment: string, width: number, margin: number): string { + const cut = Math.max(0, width - margin - visibleLength(segment)); + const rowWidth = visibleLength(row); + const padded = rowWidth < cut ? row + " ".repeat(cut - rowWidth) : row; + return `${sliceVisible(padded, cut)}\x1b[0m${segment}`; +} + /** Renders the attributes panel body for one span (name, facts, attributes). */ /** * Payload attributes the conversation cards already show; the drawer skips @@ -219,18 +261,16 @@ function renderHeader(state: TraceViewerState, options: RenderTraceViewerOptions const position = state.traces.length === 0 ? "" : colors.dim(`[${state.traceIndex + 1}/${state.traces.length}]`); const live = options.activeWindowEndNs !== undefined ? colors.green("● live") : ""; - const toast = - options.toast === undefined - ? "" - : colors.green(`${theme.unicode ? "✓ " : ""}${stripTerminalControls(options.toast)}`); - const right = [toast, live, position].filter((part) => part.length > 0).join(" "); + const right = [live, position].filter((part) => part.length > 0).join(" "); // The badge keeps its room: the title (full session id and all) clips first. const header = joinRight( - clipVisible(title, Math.max(0, width - visibleLength(right) - 1)), + ` ${clipVisible(title, Math.max(0, width - visibleLength(right) - 2))}`, right, width, ); - return [clipVisible(header, width)]; + // Padding rows above and below keep the title off the terminal edge + // and separated from the cards. + return ["", clipVisible(header, width), ""]; } /** The conversation flow: user/assistant/tool cards in turn order. */ @@ -378,7 +418,9 @@ function renderFooter(state: TraceViewerState, width: number, theme: Theme): str const position = itemCount === 0 ? "" : colors.dim(`item ${state.selectedRow + 1}/${itemCount}`); const notice = state.notice === undefined ? "" : colors.yellow(state.notice); const status = [notice, position].filter((part) => part.length > 0).join(colors.dim(" · ")); + // A padding row separates the cards from the hints. return [ + "", clipVisible(` ${colors.dim(hints)}`, width), clipVisible(status.length === 0 ? "" : ` ${status}`, width), ]; diff --git a/packages/eve/src/cli/dev/tui/traces/trace-viewer-session.test.ts b/packages/eve/src/cli/dev/tui/traces/trace-viewer-session.test.ts index 83c4a03cd..012fda7fd 100644 --- a/packages/eve/src/cli/dev/tui/traces/trace-viewer-session.test.ts +++ b/packages/eve/src/cli/dev/tui/traces/trace-viewer-session.test.ts @@ -112,10 +112,11 @@ describe("TraceViewerSession drag copy", () => { const last = frames[frames.length - 1]?.join("\n") ?? ""; if (!last.includes("copy me please")) throw new Error("no conversation frame yet"); }); - // Drag across the user card's text row: press, motion, release. - session.handleKey({ type: "mouse", action: "press", button: 0, x: 1, y: 6 }); - session.handleKey({ type: "mouse", action: "press", button: 32, x: 40, y: 6 }); - session.handleKey({ type: "mouse", action: "release", button: 0, x: 40, y: 6 }); + // Drag across the user card's text row (two header rows, then the + // card's band, title, band, and padding rows): press, motion, release. + session.handleKey({ type: "mouse", action: "press", button: 0, x: 1, y: 8 }); + session.handleKey({ type: "mouse", action: "press", button: 32, x: 40, y: 8 }); + session.handleKey({ type: "mouse", action: "release", button: 0, x: 40, y: 8 }); expect(copied).toHaveLength(1); expect(copied[0]).toContain("copy me please"); expect(frames[frames.length - 1]!.join("\n")).toContain("Copied to clipboard"); @@ -124,9 +125,9 @@ describe("TraceViewerSession drag copy", () => { // drawer starts right of the conversation's 46 columns, and detail // line 1 carries the selected span's name. session.handleKey({ type: "enter" }); - session.handleKey({ type: "mouse", action: "press", button: 0, x: 49, y: 3 }); - session.handleKey({ type: "mouse", action: "press", button: 32, x: 75, y: 3 }); - session.handleKey({ type: "mouse", action: "release", button: 0, x: 75, y: 3 }); + session.handleKey({ type: "mouse", action: "press", button: 0, x: 49, y: 5 }); + session.handleKey({ type: "mouse", action: "press", button: 32, x: 75, y: 5 }); + session.handleKey({ type: "mouse", action: "release", button: 0, x: 75, y: 5 }); expect(copied).toHaveLength(2); expect(copied[1]).toContain("agent.turn"); session.dispose(); diff --git a/packages/eve/src/cli/dev/tui/traces/trace-viewer-state.test.ts b/packages/eve/src/cli/dev/tui/traces/trace-viewer-state.test.ts index 9a5c87887..3f1badd46 100644 --- a/packages/eve/src/cli/dev/tui/traces/trace-viewer-state.test.ts +++ b/packages/eve/src/cli/dev/tui/traces/trace-viewer-state.test.ts @@ -180,9 +180,9 @@ describe("reduceTraceViewerKey", () => { }; const assistantIndex = state.conversationItems.findIndex((item) => item.kind === "assistant"); // Cards occupy 7 lines each (6 + separator); assistant starts at line - // assistantIndex*7. Click one row into it (y=2 is first body row, so - // bodyRow = clickY-2 maps to absolute line scrollRow+bodyRow). - const clickY = 2 + assistantIndex * 7 + 1; + // assistantIndex*7. Click one row into it (y=4 is first body row — three + // header rows — so bodyRow = clickY-4 maps to line scrollRow+bodyRow). + const clickY = 4 + assistantIndex * 7 + 1; const click = (): void => { state = reduceTraceViewerKey( state, @@ -221,7 +221,7 @@ describe("reduceTraceViewerKey", () => { // columns are relative to the drawer's content area. state = reduceTraceViewerKey( state, - { type: "mouse", action: "press", button: 0, x: 85, y: 3 }, + { type: "mouse", action: "press", button: 0, x: 85, y: 5 }, env, ).state; expect(state.textSelection).toEqual({ @@ -232,12 +232,12 @@ describe("reduceTraceViewerKey", () => { }); state = reduceTraceViewerKey( state, - { type: "mouse", action: "press", button: 32, x: 90, y: 5 }, + { type: "mouse", action: "press", button: 32, x: 90, y: 7 }, env, ).state; const result = reduceTraceViewerKey( state, - { type: "mouse", action: "release", button: 0, x: 90, y: 5 }, + { type: "mouse", action: "release", button: 0, x: 90, y: 7 }, env, ); expect(result.copySelection).toEqual({ @@ -254,12 +254,41 @@ describe("reduceTraceViewerKey", () => { const env = { ...ENV, conversationLineCounts: state.conversationItems.map(() => 6) }; state = reduceTraceViewerKey( state, - { type: "mouse", action: "press", button: 0, x: 85, y: 3 }, + { type: "mouse", action: "press", button: 0, x: 85, y: 5 }, env, ).state; expect(state.textSelection).toBeUndefined(); }); + it("scrolls the drawer with the wheel when the pointer is over it", () => { + let state = applyLoadedTrace(createTraceViewerState(), conversationTrace()); + const env = { ...ENV, conversationLineCounts: state.conversationItems.map(() => 6) }; + state = reduceTraceViewerKey(state, key("enter"), env).state; + expect(state.panelOpen).toBe(true); + // Pointer right of the conversation (contentWidth 80): the drawer scrolls. + state = reduceTraceViewerKey( + state, + { type: "mouse", action: "press", button: 65, x: 90, y: 5 }, + env, + ).state; + expect(state.panelScroll).toBe(3); + expect(state.scrollRow).toBe(0); + // Wheel back up clamps at the top. + state = reduceTraceViewerKey( + state, + { type: "mouse", action: "press", button: 64, x: 90, y: 5 }, + env, + ).state; + expect(state.panelScroll).toBe(0); + // Over the conversation, the wheel scrolls the cards as before. + state = reduceTraceViewerKey( + state, + { type: "mouse", action: "press", button: 65, x: 10, y: 5 }, + env, + ).state; + expect(state.panelScroll).toBe(0); + }); + it("copies a drag selection on release instead of clicking", () => { let state = applyLoadedTrace(createTraceViewerState(), conversationTrace({ longReply: true })); const env = { @@ -269,7 +298,7 @@ describe("reduceTraceViewerKey", () => { state = reduceTraceViewerKey( state, - { type: "mouse", action: "press", button: 0, x: 5, y: 3 }, + { type: "mouse", action: "press", button: 0, x: 5, y: 5 }, env, ).state; expect(state.textSelection).toEqual({ @@ -282,7 +311,7 @@ describe("reduceTraceViewerKey", () => { // Motion with the left button held (SGR button 32) extends the selection. state = reduceTraceViewerKey( state, - { type: "mouse", action: "press", button: 32, x: 20, y: 5 }, + { type: "mouse", action: "press", button: 32, x: 20, y: 7 }, env, ).state; expect(state.textSelection).toEqual({ @@ -294,7 +323,7 @@ describe("reduceTraceViewerKey", () => { const result = reduceTraceViewerKey( state, - { type: "mouse", action: "release", button: 0, x: 20, y: 5 }, + { type: "mouse", action: "release", button: 0, x: 20, y: 7 }, env, ); expect(result.copySelection).toEqual({ @@ -313,7 +342,7 @@ describe("reduceTraceViewerKey", () => { const env = { ...ENV, conversationLineCounts: state.conversationItems.map(() => 6) }; state = reduceTraceViewerKey( state, - { type: "mouse", action: "press", button: 0, x: 5, y: 3 }, + { type: "mouse", action: "press", button: 0, x: 5, y: 4 }, env, ).state; expect(state.textSelection).toBeDefined(); diff --git a/packages/eve/src/cli/dev/tui/traces/trace-viewer-state.ts b/packages/eve/src/cli/dev/tui/traces/trace-viewer-state.ts index a5ca85ada..6a20a6ca0 100644 --- a/packages/eve/src/cli/dev/tui/traces/trace-viewer-state.ts +++ b/packages/eve/src/cli/dev/tui/traces/trace-viewer-state.ts @@ -17,6 +17,13 @@ import { import type { TraceStoreEntry } from "#cli/dev/tui/traces/trace-store.js"; import type { TerminalKey } from "#cli/dev/tui/stream-format.js"; +/** + * Rows above the conversation body (padding, title, padding). Lives here + * rather than in the renderer because the mouse handlers need the same + * offset to map terminal rows to conversation lines. + */ +export const TRACE_VIEWER_HEADER_ROWS = 3; + export interface TraceViewerState { /** Stored traces, most recent activity first. */ readonly traces: readonly TraceStoreEntry[]; @@ -240,9 +247,14 @@ export function reduceTraceViewerKey( return { state: base, effect: "close" }; } case "mouse": { - // Scroll wheel (SGR button 64/65): scroll the conversation viewport. + // Scroll wheel (SGR button 64/65): over the open drawer it scrolls + // the drawer, otherwise the conversation viewport. if (key.action === "press" && (key.button === 64 || key.button === 65)) { const delta = key.button === 64 ? -3 : 3; + if (base.panelOpen && key.x - 1 >= environment.contentWidth) { + const panelScroll = Math.min(panelMaxScroll, Math.max(0, base.panelScroll + delta)); + return { state: { ...base, panelScroll } }; + } return { state: scrollConversation(base, delta, environment) }; } // Left press anchors a possible drag selection in whichever region the @@ -288,7 +300,7 @@ export function reduceTraceViewerKey( return { state: released, copySelection: selection }; } // Left click selects and expands/collapses cards. - const bodyIndex = key.y - 2; // one header row, 1-based coordinates + const bodyIndex = key.y - 1 - TRACE_VIEWER_HEADER_ROWS; // 1-based coordinates if (bodyIndex < 0) return { state: released }; const itemIndex = conversationItemAtBodyRow(released, environment, bodyIndex); if (itemIndex === undefined) return { state: released }; @@ -448,7 +460,7 @@ function conversationCellAt( ): TextSelectionPoint | undefined { const counts = environment.conversationLineCounts; if (counts === undefined || state.conversationItems.length === 0) return undefined; - const bodyIndex = y - 2; // one header row, 1-based coordinates + const bodyIndex = y - 1 - TRACE_VIEWER_HEADER_ROWS; // 1-based coordinates if (bodyIndex < 0 || bodyIndex >= environment.timelineViewportRows) return undefined; const column = x - 1; if (column < 0 || column >= environment.contentWidth) return undefined; @@ -471,7 +483,7 @@ function panelCellAt( y: number, ): TextSelectionPoint | undefined { if (!state.panelOpen || environment.panelTotalRows === 0) return undefined; - const bodyIndex = y - 2; // one header row, 1-based coordinates + const bodyIndex = y - 1 - TRACE_VIEWER_HEADER_ROWS; // 1-based coordinates if (bodyIndex < 0 || bodyIndex >= environment.panelViewportRows) return undefined; const column = x - 1 - (environment.contentWidth + 1); if (column < 0) return undefined; diff --git a/packages/eve/test/tui-client/tui-traces.ts b/packages/eve/test/tui-client/tui-traces.ts index c93073c09..863c10f44 100644 --- a/packages/eve/test/tui-client/tui-traces.ts +++ b/packages/eve/test/tui-client/tui-traces.ts @@ -211,18 +211,19 @@ void (async () => { // A mouse click toggles a card open too: an SGR press+release at row 3 // lands inside the first card (one header row, 1-based coordinates). // The click acts on release so drag selections never toggle cards. - input.send("\x1b[<0;10;3M"); - input.send("\x1b[<0;10;3m"); + input.send("\x1b[<0;10;4M"); + input.send("\x1b[<0;10;4m"); await screen.waitForText(SYSTEM_PROMPT_TAIL, 5_000); console.log(theme.muted("[tui-traces] mouse click expands a card")); input.left(); await screen.waitForText("Click to expand", 5_000); // Dragging (press, SGR button-32 motion, release) selects text and - // copies it, confirmed by the header toast. - input.send("\x1b[<0;3;3M"); - input.send("\x1b[<32;30;3M"); - input.send("\x1b[<0;30;3m"); + // copies it, confirmed by the toast. Row 4 is the first card's title + // row (two header rows, then the card's top band). + input.send("\x1b[<0;3;5M"); + input.send("\x1b[<32;30;5M"); + input.send("\x1b[<0;30;5m"); await screen.waitForText("Copied to clipboard", 5_000); console.log(theme.muted("[tui-traces] drag selection copies to the clipboard"));