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-select-copy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"eve": patch
---

The `/traces` viewer supports drag-to-select: dragging with the mouse highlights text and releasing copies it to the clipboard (OSC 52 with tmux passthrough, plus the platform clipboard command) with a confirmation toast. Clicks now act on release so drags never toggle cards, and Esc cancels an in-flight selection.
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 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. 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.

## Display flags

Expand Down
29 changes: 29 additions & 0 deletions packages/eve/src/cli/dev/tui/clipboard.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";

import { clipboardCommand, clipboardSequence } from "#cli/dev/tui/clipboard.js";

describe("clipboardSequence", () => {
it("encodes the text as base64 in an OSC 52 write", () => {
expect(clipboardSequence("hello", {})).toBe(
`\x1b]52;c;${Buffer.from("hello").toString("base64")}\x07`,
);
});

it("wraps the sequence for tmux and screen passthrough", () => {
const inner = clipboardSequence("hi", {});
expect(clipboardSequence("hi", { TMUX: "/tmp/tmux-1000/default" })).toBe(
`\x1bPtmux;\x1b${inner}\x1b\\`,
);
expect(clipboardSequence("hi", { STY: "1234.pts-0" })).toBe(`\x1bPtmux;\x1b${inner}\x1b\\`);
});
});

describe("clipboardCommand", () => {
it("picks the platform clipboard tool", () => {
expect(clipboardCommand("darwin", {})).toEqual(["pbcopy"]);
expect(clipboardCommand("linux", {})).toEqual(["xclip", "-selection", "clipboard"]);
expect(clipboardCommand("linux", { WAYLAND_DISPLAY: "wayland-0" })).toEqual(["wl-copy"]);
expect(clipboardCommand("win32", {})).toEqual(["clip"]);
expect(clipboardCommand("freebsd", {})).toBeUndefined();
});
});
54 changes: 54 additions & 0 deletions packages/eve/src/cli/dev/tui/clipboard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/**
* Best-effort clipboard writes for the dev TUI: an OSC 52 escape sequence
* (which reaches the user's terminal even over SSH, with tmux/screen
* passthrough) plus the platform's native clipboard command as a fallback
* for terminals that reject OSC 52. Both paths fail silently — copying is
* a convenience, never a hard dependency.
*/

import { spawn } from "node:child_process";

/** The OSC 52 sequence for `text`, wrapped for tmux/screen when present. */
export function clipboardSequence(
text: string,
env: Readonly<Record<string, string | undefined>> = process.env,
): string {
const sequence = `\x1b]52;c;${Buffer.from(text).toString("base64")}\x07`;
const multiplexed = env.TMUX !== undefined || env.STY !== undefined;
return multiplexed ? `\x1bPtmux;\x1b${sequence}\x1b\\` : sequence;
}

/** The native clipboard command for a platform, or `undefined` when unknown. */
export function clipboardCommand(
platform: NodeJS.Platform,
env: Readonly<Record<string, string | undefined>> = process.env,
): readonly string[] | undefined {
if (platform === "darwin") return ["pbcopy"];
if (platform === "linux") {
return env.WAYLAND_DISPLAY !== undefined ? ["wl-copy"] : ["xclip", "-selection", "clipboard"];
}
if (platform === "win32") return ["clip"];
return undefined;
}

/**
* Copies `text` to the clipboard: emits OSC 52 through `writeTerminal`
* (the same raw write the alt screen paints with) and pipes the text into
* the platform clipboard command in the background.
*/
export function copyTextToClipboard(text: string, writeTerminal: (chunk: string) => void): void {
writeTerminal(clipboardSequence(text));
const command = clipboardCommand(process.platform);
if (command === undefined) return;
try {
const child = spawn(command[0]!, command.slice(1), {
stdio: ["pipe", "ignore", "ignore"],
detached: false,
});
child.on("error", () => {});
child.stdin?.on("error", () => {});
child.stdin?.end(text);
} catch {
// Native clipboard is a fallback; OSC 52 already went out.
}
}
2 changes: 2 additions & 0 deletions packages/eve/src/cli/dev/tui/terminal-renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ import {
} from "./line-editor.js";
import { LiveRegion } from "#cli/ui/live-region.js";
import { AltScreen } from "#cli/ui/alt-screen.js";
import { copyTextToClipboard } from "./clipboard.js";
import type { TraceViewerOpenOptions, TraceViewerRenderer } from "./traces/trace-viewer-session.js";
import { TraceViewerSession } from "./traces/trace-viewer-session.js";
import { buildStatusLine } from "./status-line.js";
Expand Down Expand Up @@ -2669,6 +2670,7 @@ export class TerminalRenderer implements AgentTUIRenderer {
dimensions: () => ({ width: this.#width(), height: this.#height() }),
paint: (rows) => this.#altScreen.paint(rows, this.#height()),
tracingDisabled: process.env.EVE_TRACES === "off",
copyText: (text) => copyTextToClipboard(text, (chunk) => this.#altScreen.writeRaw(chunk)),
});
this.#traceView = session;
this.#altScreen.enter();
Expand Down
88 changes: 87 additions & 1 deletion packages/eve/src/cli/dev/tui/traces/trace-view.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,12 @@ import { stripAnsi, visibleLength } from "#cli/ui/terminal-text.js";
import type { LocalTrace, LocalTraceSpan } from "#harness/local-trace-reader.js";

import { createTheme } from "../theme.js";
import { renderSpanDetail, renderTraceViewer } from "./trace-view.js";
import {
conversationSelectionText,
panelSelectionText,
renderSpanDetail,
renderTraceViewer,
} from "./trace-view.js";
import type { TraceViewerState } from "./trace-viewer-state.js";
import { applyLoadedTrace, applyTraceList, createTraceViewerState } from "./trace-viewer-state.js";

Expand Down Expand Up @@ -108,6 +113,87 @@ describe("renderTraceViewer", () => {
expect(selectedRows.length).toBeGreaterThan(0);
});

it("shows the copy toast in the header's top-right corner", () => {
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");
});

it("paints an active drag selection in reverse video", () => {
const state = viewerState(conversationSpans(), {
textSelection: {
anchor: { line: 1, column: 2 },
head: { line: 2, column: 10 },
dragging: true,
region: "conversation" as const,
},
});
const frame = render(state);
const highlighted = frame.rows.filter((row) => row.includes("\x1b[7m"));
expect(highlighted.length).toBe(2);
});

it("extracts the dragged text from the rendered conversation", () => {
const state = viewerState(conversationSpans());
// The system card's title row is line 1 (" ▸ system" once rendered).
const selection = {
anchor: { line: 1, column: 0 },
head: { line: 1, column: 40 },
dragging: true,
region: "conversation" as const,
};
const text = conversationSelectionText(state, 100, THEME, selection);
expect(text).toContain("system");
expect(text).not.toContain("\x1b");
// A multi-line drag keeps line structure.
const multi = conversationSelectionText(state, 100, THEME, {
anchor: { line: 1, column: 0 },
head: { line: 5, column: 99 },
dragging: true,
region: "conversation" as const,
});
expect(multi.split("\n").length).toBe(5);
});

it("paints a drawer drag selection on the panel, not the cards", () => {
const state = viewerState(conversationSpans(), {
panelOpen: true,
textSelection: {
anchor: { line: 1, column: 0 },
head: { line: 3, column: 10 },
dragging: true,
region: "panel" as const,
},
});
const frame = render(state);
const highlighted = frame.rows.filter((row) => row.includes("\x1b[7m"));
expect(highlighted.length).toBe(3);
// The highlight sits in the drawer area (right of the conversation).
for (const row of highlighted) {
const before = row.slice(0, row.indexOf("\x1b[7m"));
expect(visibleLength(before)).toBeGreaterThanOrEqual(frame.contentWidth);
}
});

it("extracts the dragged text from the details drawer", () => {
const state = viewerState(conversationSpans(), { panelOpen: true });
// Detail line 1 is the selected span's name (line 0 is top padding).
const text = panelSelectionText(state, 100, THEME, {
anchor: { line: 1, column: 0 },
head: { line: 2, column: 40 },
dragging: true,
region: "panel" as const,
});
expect(text).toContain("ai.streamText.doStream");
expect(text).not.toContain("\x1b");
expect(text.split("\n").length).toBe(2);
});

it("marks error cards and shows the error status in the detail panel", () => {
const turn = span("a".repeat(16), "agent.turn", 0, 0);
const step = span("b".repeat(16), "agent.step", 10, 300, turn.spanId, {});
Expand Down
Loading
Loading