Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/trace-viewer-chrome.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion docs/guides/dev-tui.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
16 changes: 11 additions & 5 deletions packages/eve/src/cli/dev/tui/traces/trace-view.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -113,15 +113,19 @@ 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,
height: 30,
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", () => {
Expand Down Expand Up @@ -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);
});

Expand Down
64 changes: 53 additions & 11 deletions packages/eve/src/cli/dev/tui/traces/trace-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)),
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -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),
];
Expand Down
15 changes: 8 additions & 7 deletions packages/eve/src/cli/dev/tui/traces/trace-viewer-session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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();
Expand Down
51 changes: 40 additions & 11 deletions packages/eve/src/cli/dev/tui/traces/trace-viewer-state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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({
Expand All @@ -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({
Expand All @@ -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 = {
Expand All @@ -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({
Expand All @@ -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({
Expand All @@ -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({
Expand All @@ -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();
Expand Down
20 changes: 16 additions & 4 deletions packages/eve/src/cli/dev/tui/traces/trace-viewer-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 };
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down
Loading
Loading