From b47d441e2b0d914d9dc3241345360962a24f9875 Mon Sep 17 00:00:00 2001 From: Chad Hietala Date: Thu, 30 Jul 2026 22:47:23 -0400 Subject: [PATCH 1/2] Drag-to-select text in the trace viewer with copy to clipboard Mouse reporting blocks native terminal selection, so the viewer now does it itself: dragging highlights the selected cells in reverse video and releasing copies the plain text to the clipboard (OSC 52 with tmux passthrough, plus the platform clipboard command), confirmed by a toast in the header's top-right corner. Clicks act on release now so drags never toggle cards, and Esc cancels an in-flight selection. Signed-off-by: Chad Hietala --- .changeset/trace-select-copy.md | 5 + docs/guides/dev-tui.md | 2 +- .../eve/src/cli/dev/tui/clipboard.test.ts | 29 +++++ packages/eve/src/cli/dev/tui/clipboard.ts | 54 +++++++++ .../eve/src/cli/dev/tui/terminal-renderer.ts | 2 + .../src/cli/dev/tui/traces/trace-view.test.ts | 46 +++++++- .../eve/src/cli/dev/tui/traces/trace-view.ts | 76 ++++++++++++- .../tui/traces/trace-viewer-session.test.ts | 48 ++++++++ .../dev/tui/traces/trace-viewer-session.ts | 38 ++++++- .../dev/tui/traces/trace-viewer-state.test.ts | 79 +++++++++++-- .../cli/dev/tui/traces/trace-viewer-state.ts | 105 ++++++++++++++++-- packages/eve/src/cli/ui/alt-screen.test.ts | 4 +- packages/eve/src/cli/ui/alt-screen.ts | 15 ++- packages/eve/test/tui-client/tui-traces.ts | 14 ++- 14 files changed, 485 insertions(+), 32 deletions(-) create mode 100644 .changeset/trace-select-copy.md create mode 100644 packages/eve/src/cli/dev/tui/clipboard.test.ts create mode 100644 packages/eve/src/cli/dev/tui/clipboard.ts diff --git a/.changeset/trace-select-copy.md b/.changeset/trace-select-copy.md new file mode 100644 index 000000000..6deef5910 --- /dev/null +++ b/.changeset/trace-select-copy.md @@ -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. diff --git a/docs/guides/dev-tui.md b/docs/guides/dev-tui.md index 5e6fd100b..d67684543 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 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 diff --git a/packages/eve/src/cli/dev/tui/clipboard.test.ts b/packages/eve/src/cli/dev/tui/clipboard.test.ts new file mode 100644 index 000000000..6254ee4ec --- /dev/null +++ b/packages/eve/src/cli/dev/tui/clipboard.test.ts @@ -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(); + }); +}); diff --git a/packages/eve/src/cli/dev/tui/clipboard.ts b/packages/eve/src/cli/dev/tui/clipboard.ts new file mode 100644 index 000000000..8644985f1 --- /dev/null +++ b/packages/eve/src/cli/dev/tui/clipboard.ts @@ -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> = 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> = 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. + } +} diff --git a/packages/eve/src/cli/dev/tui/terminal-renderer.ts b/packages/eve/src/cli/dev/tui/terminal-renderer.ts index 905a4ad0a..32624c49b 100644 --- a/packages/eve/src/cli/dev/tui/terminal-renderer.ts +++ b/packages/eve/src/cli/dev/tui/terminal-renderer.ts @@ -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"; @@ -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(); 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 6bda56b15..ae5a1dede 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 @@ -4,7 +4,7 @@ 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, renderSpanDetail, renderTraceViewer } from "./trace-view.js"; import type { TraceViewerState } from "./trace-viewer-state.js"; import { applyLoadedTrace, applyTraceList, createTraceViewerState } from "./trace-viewer-state.js"; @@ -108,6 +108,50 @@ 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, + }, + }); + 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, + }; + 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, + }); + expect(multi.split("\n").length).toBe(5); + }); + 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, {}); 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 eb2c98038..5c75aa0b8 100644 --- a/packages/eve/src/cli/dev/tui/traces/trace-view.ts +++ b/packages/eve/src/cli/dev/tui/traces/trace-view.ts @@ -9,6 +9,8 @@ import { formatElapsed } from "#cli/format-elapsed.js"; import { clipVisible, + sliceVisible, + stripAnsi, stripTerminalControls, visibleLength, wrapVisibleLine, @@ -19,8 +21,8 @@ import { describeLocalTraceSpan } from "#harness/local-trace-reader.js"; import type { Theme } from "../theme.js"; import { formatAttributeContent } from "./trace-content.js"; import { renderConversationItem } from "./trace-conversation.js"; -import type { TraceViewerState } from "./trace-viewer-state.js"; -import { selectedTraceViewerSpan } from "./trace-viewer-state.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; @@ -49,6 +51,8 @@ export interface RenderTraceViewerOptions { readonly activeWindowEndNs?: bigint; /** Tracing is disabled via `EVE_TRACES=off` (empty-state copy). */ readonly tracingDisabled?: boolean; + /** Transient confirmation shown in the header's top-right corner. */ + readonly toast?: string; } export function renderTraceViewer( @@ -219,7 +223,11 @@ 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 right = [live, position].filter((part) => part.length > 0).join(" "); + const toast = + options.toast === undefined + ? "" + : colors.green(`${theme.unicode ? "✓ " : ""}${stripTerminalControls(options.toast)}`); + const right = [toast, 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)), @@ -246,6 +254,15 @@ function renderConversation( } // Render all cards, then apply the line-level scroll offset so expanded // cards taller than the viewport can be scrolled through to the end. + const allLines = conversationLines(state, width, theme); + const visible = allLines.slice(state.scrollRow, state.scrollRow + bodyRows); + if (state.textSelection === undefined) return visible; + return visible.map((line, index) => + highlightSelection(line, state.scrollRow + index, state.textSelection!, width), + ); +} + +function conversationLines(state: TraceViewerState, width: number, theme: Theme): string[] { const allLines: string[] = []; for (let index = 0; index < state.conversationItems.length; index += 1) { allLines.push( @@ -259,7 +276,58 @@ function renderConversation( "", ); } - return allLines.slice(state.scrollRow, state.scrollRow + bodyRows); + return allLines; +} + +/** + * Plain text covered by a drag selection, extracted from the same rendered + * lines the user saw: partial first/last lines honor the columns, middle + * lines contribute in full, and each line loses its trailing card padding. + */ +export function conversationSelectionText( + state: TraceViewerState, + width: number, + theme: Theme, + selection: TextSelectionRange, +): string { + const { start, end } = orderedTextSelection(selection); + const lines = conversationLines(state, width, theme); + const parts: string[] = []; + for (let line = Math.max(0, start.line); line <= end.line && line < lines.length; line += 1) { + const text = stripAnsi(lines[line]!); + const from = line === start.line ? start.column : 0; + const to = line === end.line ? end.column + 1 : Number.POSITIVE_INFINITY; + const head = to === Number.POSITIVE_INFINITY ? text : sliceVisible(text, to); + const prefixLength = sliceVisible(text, from).length; + parts.push(head.slice(prefixLength).trimEnd()); + } + return parts.join("\n"); +} + +/** Paints the selected cells of one visible row in reverse video. */ +function highlightSelection( + row: string, + line: number, + selection: TextSelectionRange, + width: number, +): string { + const { start, end } = orderedTextSelection(selection); + if (line < start.line || line > end.line) return row; + const from = line === start.line ? start.column : 0; + const to = line === end.line ? end.column + 1 : width; + // Selection can extend past a row's painted cells; pad so the highlight + // shows the full extent the copy will cover. + const rowWidth = visibleLength(row); + const padded = rowWidth < to ? row + " ".repeat(to - rowWidth) : row; + const prefix = sliceVisible(padded, from); + const withSelection = sliceVisible(padded, to); + const middle = withSelection.slice(prefix.length); + const suffix = padded.slice(withSelection.length); + if (middle.length === 0) return row; + // Re-assert reverse video after any embedded reset so a card's internal + // style changes cannot end the highlight early. + const inverted = middle.replaceAll("\x1b[0m", "\x1b[0m\x1b[7m"); + return `${prefix}\x1b[7m${inverted}\x1b[27m${suffix}`; } function renderFooter(state: TraceViewerState, width: number, theme: Theme): string[] { 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 c711f0b80..6506b90e7 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 @@ -75,6 +75,54 @@ async function openViewer(store: TraceStore, sessionId?: string) { return frames[frames.length - 1]!.join("\n"); } +describe("TraceViewerSession drag copy", () => { + it("copies the dragged text and shows a toast", async () => { + const turn = span("a".repeat(16), "session-mine"); + const model: LocalTraceSpan = { + ...span("b".repeat(16), "session-mine"), + name: "ai.streamText.doStream", + parentSpanId: turn.spanId, + attributes: { + "ai.prompt.messages": JSON.stringify([{ role: "user", content: "copy me please" }]), + "ai.response.text": "reply", + }, + }; + const store = stubStore([ + { + endTimeNs: 1_000_000n, + sessionId: "session-mine", + sessionIds: ["session-mine"], + spans: [turn, model], + startTimeNs: 1_000_000n, + traceId: "c".repeat(32), + }, + ]); + const frames: string[][] = []; + const copied: string[] = []; + const session = new TraceViewerSession({ + appRoot: "/nowhere", + copyText: (text) => copied.push(text), + dimensions: () => ({ width: 80, height: 24 }), + paint: (rows) => frames.push([...rows]), + store, + theme: THEME, + }); + session.start(); + await vi.waitFor(() => { + 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 }); + expect(copied).toHaveLength(1); + expect(copied[0]).toContain("copy me please"); + expect(frames[frames.length - 1]!.join("\n")).toContain("Copied to clipboard"); + session.dispose(); + }); +}); + describe("TraceViewerSession session preference", () => { it("opens on the trace containing the current session, not the newest", async () => { // Trace ids are provider-generated, so the session's trace is found by diff --git a/packages/eve/src/cli/dev/tui/traces/trace-viewer-session.ts b/packages/eve/src/cli/dev/tui/traces/trace-viewer-session.ts index 5ed933c47..af27bbced 100644 --- a/packages/eve/src/cli/dev/tui/traces/trace-viewer-session.ts +++ b/packages/eve/src/cli/dev/tui/traces/trace-viewer-session.ts @@ -10,8 +10,8 @@ import type { Theme } from "../theme.js"; import type { TraceStore, TraceStoreEntry } from "./trace-store.js"; import { createTraceStore } from "./trace-store.js"; import { conversationItemLineCount } from "./trace-conversation.js"; -import { renderTraceViewer } from "./trace-view.js"; -import type { TraceViewerState } from "./trace-viewer-state.js"; +import { conversationSelectionText, renderTraceViewer } from "./trace-view.js"; +import type { TextSelectionRange, TraceViewerState } from "./trace-viewer-state.js"; import { applyLoadedTrace, applyTraceList, @@ -22,6 +22,8 @@ import { const POLL_INTERVAL_MS = 1_000; /** A trace whose last span landed this recently renders its window up to "now". */ const LIVE_WINDOW_MS = 3_000; +/** How long the copy confirmation stays in the header. */ +const TOAST_MS = 2_000; export interface TraceViewerOpenOptions { readonly appRoot: string; @@ -42,6 +44,8 @@ export interface TraceViewerSessionOptions extends TraceViewerOpenOptions { readonly dimensions: () => { readonly width: number; readonly height: number }; readonly tracingDisabled?: boolean; readonly store?: TraceStore; + /** Receives drag-selected text (wired to the clipboard by the renderer). */ + readonly copyText?: (text: string) => void; } export class TraceViewerSession { @@ -59,6 +63,8 @@ export class TraceViewerSession { */ #preferSessionId?: string; #pollTimer?: ReturnType; + #toast?: string; + #toastTimer?: ReturnType; #disposed = false; #refreshing = false; #refreshAgain = false; @@ -101,9 +107,10 @@ export class TraceViewerSession { this.#preferSessionId = undefined; } const hadTrace = this.#state.trace !== undefined; - const { state, effect } = reduceTraceViewerKey(this.#state, key, this.#metrics); + const { state, effect, copySelection } = reduceTraceViewerKey(this.#state, key, this.#metrics); this.#state = state; if (effect === "close") return "close"; + if (copySelection !== undefined) this.#copySelection(copySelection); this.repaint(); // A trace switch cleared the timeline — fetch the newly selected trace // immediately instead of leaving a Loading frame up for a full poll. @@ -120,6 +127,7 @@ export class TraceViewerSession { theme: this.#options.theme, tracingDisabled: this.#options.tracingDisabled, activeWindowEndNs: this.#activeWindowEndNs(), + toast: this.#toast, }); this.#metrics = { timelineViewportRows: frame.timelineViewportRows, @@ -144,6 +152,30 @@ export class TraceViewerSession { clearInterval(this.#pollTimer); this.#pollTimer = undefined; } + if (this.#toastTimer !== undefined) { + clearTimeout(this.#toastTimer); + this.#toastTimer = undefined; + } + } + + /** Extracts the dragged text, hands it to the clipboard, and confirms with a toast. */ + #copySelection(selection: TextSelectionRange): void { + const text = conversationSelectionText( + this.#state, + this.#metrics.contentWidth, + this.#options.theme, + selection, + ); + if (text.trim().length === 0) return; + this.#options.copyText?.(text); + this.#toast = "Copied to clipboard"; + if (this.#toastTimer !== undefined) clearTimeout(this.#toastTimer); + this.#toastTimer = setTimeout(() => { + this.#toastTimer = undefined; + this.#toast = undefined; + this.repaint(); + }, TOAST_MS); + this.#toastTimer.unref?.(); } async #refresh(): Promise { 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 b086a3a15..fd6a3311b 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 @@ -172,7 +172,7 @@ describe("reduceTraceViewerKey", () => { expect(state.expandedItems.has(assistantIndex)).toBe(false); }); - it("expands and collapses cards on mouse clicks", () => { + it("expands and collapses cards on mouse clicks (press + release in place)", () => { let state = applyLoadedTrace(createTraceViewerState(), conversationTrace({ longReply: true })); const env = { ...ENV, @@ -183,29 +183,92 @@ describe("reduceTraceViewerKey", () => { // 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; + const click = (): void => { + state = reduceTraceViewerKey( + state, + { type: "mouse", action: "press", button: 0, x: 10, y: clickY }, + env, + ).state; + state = reduceTraceViewerKey( + state, + { type: "mouse", action: "release", button: 0, x: 10, y: clickY }, + env, + ).state; + }; + click(); + expect(state.selectedRow).toBe(assistantIndex); + expect(state.expandedItems.has(assistantIndex)).toBe(true); + + click(); + expect(state.expandedItems.has(assistantIndex)).toBe(false); + + // A press alone (a drag may follow) is not a click. state = reduceTraceViewerKey( state, { type: "mouse", action: "press", button: 0, x: 10, y: clickY }, env, ).state; - expect(state.selectedRow).toBe(assistantIndex); - expect(state.expandedItems.has(assistantIndex)).toBe(true); + expect(state.expandedItems.has(assistantIndex)).toBe(false); + }); + + it("copies a drag selection on release instead of clicking", () => { + let state = applyLoadedTrace(createTraceViewerState(), conversationTrace({ longReply: true })); + const env = { + ...ENV, + conversationLineCounts: state.conversationItems.map(() => 6), + }; state = reduceTraceViewerKey( state, - { type: "mouse", action: "press", button: 0, x: 10, y: clickY }, + { type: "mouse", action: "press", button: 0, x: 5, y: 3 }, env, ).state; - expect(state.expandedItems.has(assistantIndex)).toBe(false); + expect(state.textSelection).toEqual({ + anchor: { line: 1, column: 4 }, + head: { line: 1, column: 4 }, + dragging: false, + }); - // A release is not a click. + // Motion with the left button held (SGR button 32) extends the selection. state = reduceTraceViewerKey( state, - { type: "mouse", action: "release", button: 0, x: 10, y: clickY }, + { type: "mouse", action: "press", button: 32, x: 20, y: 5 }, env, ).state; - expect(state.expandedItems.has(assistantIndex)).toBe(false); + expect(state.textSelection).toEqual({ + anchor: { line: 1, column: 4 }, + head: { line: 3, column: 19 }, + dragging: true, + }); + + const result = reduceTraceViewerKey( + state, + { type: "mouse", action: "release", button: 0, x: 20, y: 5 }, + env, + ); + expect(result.copySelection).toEqual({ + anchor: { line: 1, column: 4 }, + head: { line: 3, column: 19 }, + dragging: true, + }); + expect(result.state.textSelection).toBeUndefined(); + // The drag did not toggle any card. + expect(result.state.expandedItems.size).toBe(0); + }); + + it("escape clears an in-flight selection without closing the viewer", () => { + let state = applyLoadedTrace(createTraceViewerState(), conversationTrace()); + const env = { ...ENV, conversationLineCounts: state.conversationItems.map(() => 6) }; + state = reduceTraceViewerKey( + state, + { type: "mouse", action: "press", button: 0, x: 5, y: 3 }, + env, + ).state; + expect(state.textSelection).toBeDefined(); + const result = reduceTraceViewerKey(state, key("escape"), env); + expect(result.effect).toBeUndefined(); + expect(result.state.textSelection).toBeUndefined(); }); it("scrolls the viewport on mouse wheel events", () => { 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 31ec86972..b5a8b711a 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 @@ -38,6 +38,33 @@ export interface TraceViewerState { readonly panelScroll: number; /** One-line status for edge cases (trace pruned, tracing disabled, …). */ readonly notice?: string; + /** Mouse drag selection over the conversation, in absolute line/column cells. */ + readonly textSelection?: TextSelectionRange; +} + +/** One end of a text selection: conversation line index + 0-based column. */ +export interface TextSelectionPoint { + readonly line: number; + readonly column: number; +} + +/** A drag selection from anchor (press) to head (latest drag position). */ +export interface TextSelectionRange { + readonly anchor: TextSelectionPoint; + readonly head: TextSelectionPoint; + /** The pointer moved off the anchor cell — release copies instead of clicking. */ + readonly dragging: boolean; +} + +/** Selection endpoints ordered top-to-bottom for rendering and extraction. */ +export function orderedTextSelection(selection: TextSelectionRange): { + start: TextSelectionPoint; + end: TextSelectionPoint; +} { + const { anchor, head } = selection; + const anchorFirst = + anchor.line < head.line || (anchor.line === head.line && anchor.column <= head.column); + return anchorFirst ? { start: anchor, end: head } : { start: head, end: anchor }; } /** Viewport metrics the controller measures so scrolling math stays pure. */ @@ -61,6 +88,8 @@ export interface TraceViewerKeyEnvironment { export interface TraceViewerKeyResult { readonly state: TraceViewerState; readonly effect?: "close"; + /** A completed drag selection to copy — the controller extracts and copies the text. */ + readonly copySelection?: TextSelectionRange; } export function createTraceViewerState(): TraceViewerState { @@ -131,6 +160,7 @@ export function applyTraceList( panelOpen: false, panelFocus: false, panelScroll: 0, + textSelection: undefined, }; } @@ -193,6 +223,9 @@ export function reduceTraceViewerKey( switch (key.type) { case "escape": { + if (base.textSelection !== undefined) { + return { state: { ...base, textSelection: undefined } }; + } if (base.panelFocus) return { state: { ...base, panelFocus: false } }; if (base.panelOpen) return { state: { ...base, panelOpen: false, panelScroll: 0 } }; return { state: base, effect: "close" }; @@ -201,20 +234,48 @@ export function reduceTraceViewerKey( return { state: base, effect: "close" }; } case "mouse": { - if (key.action !== "press") return { state: base }; // Scroll wheel (SGR button 64/65): scroll the conversation viewport. - if (key.button === 64 || key.button === 65) { + if (key.action === "press" && (key.button === 64 || key.button === 65)) { const delta = key.button === 64 ? -3 : 3; return { state: scrollConversation(base, delta, environment) }; } - if (key.button !== 0) return { state: base }; + const point = conversationCellAt(base, environment, key.x, key.y); + // Left press anchors a possible drag selection; the click action (and + // any copy) waits for release so dragging never toggles cards. + if (key.action === "press" && key.button === 0) { + if (point === undefined) return { state: { ...base, textSelection: undefined } }; + return { + state: { + ...base, + textSelection: { anchor: point, head: point, dragging: false }, + }, + }; + } + // Motion with the left button held (SGR button 32) extends the selection. + if (key.action === "press" && key.button === 32) { + if (base.textSelection === undefined || point === undefined) return { state: base }; + const dragging = + base.textSelection.dragging || + point.line !== base.textSelection.anchor.line || + point.column !== base.textSelection.anchor.column; + return { + state: { ...base, textSelection: { ...base.textSelection, head: point, dragging } }, + }; + } + if (key.action !== "release" || key.button !== 0) return { state: base }; + const selection = base.textSelection; + const released: TraceViewerState = { ...base, textSelection: undefined }; + // A drag that moved copies the selection; a stationary click acts. + if (selection !== undefined && selection.dragging) { + return { state: released, copySelection: selection }; + } // Left click selects and expands/collapses cards. const bodyIndex = key.y - 2; // one header row, 1-based coordinates - if (bodyIndex < 0) return { state: base }; - const itemIndex = conversationItemAtBodyRow(base, environment, bodyIndex); - if (itemIndex === undefined) return { state: base }; - const item = base.conversationItems[itemIndex]; - const expandedItems = new Set(base.expandedItems); + if (bodyIndex < 0) return { state: released }; + const itemIndex = conversationItemAtBodyRow(released, environment, bodyIndex); + if (itemIndex === undefined) return { state: released }; + const item = released.conversationItems[itemIndex]; + const expandedItems = new Set(released.expandedItems); if ( item !== undefined && !expandedItems.has(itemIndex) && @@ -224,7 +285,7 @@ export function reduceTraceViewerKey( } else { expandedItems.delete(itemIndex); } - return { state: { ...base, selectedRow: itemIndex, expandedItems } }; + return { state: { ...released, selectedRow: itemIndex, expandedItems } }; } case "enter": { if (traceViewerItemCount(base) === 0) return { state: base }; @@ -310,6 +371,7 @@ function switchTrace(state: TraceViewerState, delta: number): TraceViewerState { panelOpen: false, panelFocus: false, panelScroll: 0, + textSelection: undefined, }; } @@ -354,6 +416,31 @@ function conversationScrollRow( return Math.max(0, cardEnd - viewportRows); } +/** + * Maps 1-based terminal coordinates to a conversation cell: absolute line + * (scroll offset applied) and 0-based column. Cells above the body, past + * the conversation's last line, or right of the cards (the details drawer) + * do not select. + */ +function conversationCellAt( + state: TraceViewerState, + environment: TraceViewerKeyEnvironment, + x: number, + y: number, +): 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 + if (bodyIndex < 0 || bodyIndex >= environment.timelineViewportRows) return undefined; + const column = x - 1; + if (column < 0 || column >= environment.contentWidth) return undefined; + let totalLines = 0; + for (const count of counts) totalLines += count + 1; + const line = state.scrollRow + bodyIndex; + if (line >= totalLines) return undefined; + return { column, line }; +} + /** Maps a click's body row to a card index, walking card heights + separators. */ function conversationItemAtBodyRow( state: TraceViewerState, diff --git a/packages/eve/src/cli/ui/alt-screen.test.ts b/packages/eve/src/cli/ui/alt-screen.test.ts index 32d26474b..fa731a158 100644 --- a/packages/eve/src/cli/ui/alt-screen.test.ts +++ b/packages/eve/src/cli/ui/alt-screen.test.ts @@ -21,12 +21,12 @@ describe("AltScreen", () => { const screen = new AltScreen(output); screen.enter(); screen.enter(); - expect(writes.join("")).toBe("\x1b[?1049h\x1b[?25l\x1b[?1000h\x1b[?1006h"); + expect(writes.join("")).toBe("\x1b[?1049h\x1b[?25l\x1b[?1002h\x1b[?1006h"); expect(screen.active).toBe(true); screen.exit(); screen.exit(); expect(writes.join("")).toBe( - "\x1b[?1049h\x1b[?25l\x1b[?1000h\x1b[?1006h\x1b[?1006l\x1b[?1000l\x1b[?25h\x1b[?1049l", + "\x1b[?1049h\x1b[?25l\x1b[?1002h\x1b[?1006h\x1b[?1006l\x1b[?1002l\x1b[?25h\x1b[?1049l", ); expect(screen.active).toBe(false); }); diff --git a/packages/eve/src/cli/ui/alt-screen.ts b/packages/eve/src/cli/ui/alt-screen.ts index 94962120a..ca7ee5ff6 100644 --- a/packages/eve/src/cli/ui/alt-screen.ts +++ b/packages/eve/src/cli/ui/alt-screen.ts @@ -14,8 +14,10 @@ const ESC = "\x1b"; const ALT_SCREEN_ON = `${ESC}[?1049h`; const ALT_SCREEN_OFF = `${ESC}[?1049l`; -const MOUSE_ON = `${ESC}[?1000h${ESC}[?1006h`; -const MOUSE_OFF = `${ESC}[?1006l${ESC}[?1000l`; +// 1002 (button-event tracking) reports motion while a button is held, which +// drag-to-select needs; 1006 upgrades coordinates to the SGR encoding. +const MOUSE_ON = `${ESC}[?1002h${ESC}[?1006h`; +const MOUSE_OFF = `${ESC}[?1006l${ESC}[?1002l`; const HIDE_CURSOR = `${ESC}[?25l`; const SHOW_CURSOR = `${ESC}[?25h`; const CLEAR_TO_END = `${ESC}[0J`; @@ -65,6 +67,15 @@ export class AltScreen { this.#write(`${SYNC_START}${CURSOR_HOME}${CLEAR_TO_END}${body}${SYNC_END}`); } + /** + * Writes an out-of-band sequence (e.g. an OSC 52 clipboard write) through + * the same captured write the paints use, bypassing foreign-output capture. + */ + writeRaw(chunk: string): void { + if (!this.#active) return; + this.#write(chunk); + } + /** Restores the main screen, cursor, and mouse reporting. */ exit(): void { if (!this.#active) return; diff --git a/packages/eve/test/tui-client/tui-traces.ts b/packages/eve/test/tui-client/tui-traces.ts index f49baafe7..c93073c09 100644 --- a/packages/eve/test/tui-client/tui-traces.ts +++ b/packages/eve/test/tui-client/tui-traces.ts @@ -208,14 +208,24 @@ void (async () => { } console.log(theme.muted("[tui-traces] arrow keys expand and collapse cards")); - // A mouse click toggles a card open too: an SGR press at row 3 lands - // inside the first card (one header row, 1-based coordinates). + // 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"); 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"); + await screen.waitForText("Copied to clipboard", 5_000); + console.log(theme.muted("[tui-traces] drag selection copies to the clipboard")); + // The details drawer stays metadata-only: the tool card carries the call // payload inline ("city" in its Input section), while the drawer lists // facts and metadata attributes — never the content keys. From 3cce0c4e31d4b15074c449db78ffdcdbcd3da781 Mon Sep 17 00:00:00 2001 From: Chad Hietala Date: Fri, 31 Jul 2026 09:01:03 -0400 Subject: [PATCH 2/2] Select and copy from the details drawer too Drag selection only mapped pointer cells onto the conversation, so a press over the open drawer never anchored and its text could not be copied. Selections now carry the region they started in: presses over the drawer anchor panel selections (drawer-scroll-aware lines, columns relative to the drawer content), motion stays in the starting region, the highlight paints on the drawer rows, and release extracts from the same detail lines the frame painted. Signed-off-by: Chad Hietala --- .../src/cli/dev/tui/traces/trace-view.test.ts | 44 ++++++++++- .../eve/src/cli/dev/tui/traces/trace-view.ts | 76 ++++++++++++++----- .../tui/traces/trace-viewer-session.test.ts | 10 +++ .../dev/tui/traces/trace-viewer-session.ts | 22 ++++-- .../dev/tui/traces/trace-viewer-state.test.ts | 51 +++++++++++++ .../cli/dev/tui/traces/trace-viewer-state.ts | 73 +++++++++++++++--- 6 files changed, 240 insertions(+), 36 deletions(-) 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 ae5a1dede..2ca62ccb9 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 @@ -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 { conversationSelectionText, 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"; @@ -125,6 +130,7 @@ describe("renderTraceViewer", () => { anchor: { line: 1, column: 2 }, head: { line: 2, column: 10 }, dragging: true, + region: "conversation" as const, }, }); const frame = render(state); @@ -139,6 +145,7 @@ describe("renderTraceViewer", () => { 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"); @@ -148,10 +155,45 @@ describe("renderTraceViewer", () => { 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, {}); 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 5c75aa0b8..25b38d44a 100644 --- a/packages/eve/src/cli/dev/tui/traces/trace-view.ts +++ b/packages/eve/src/cli/dev/tui/traces/trace-view.ts @@ -64,19 +64,7 @@ export function renderTraceViewer( const panelWidth = state.panelOpen ? panelWidthFor(width) : 0; const timelineWidth = Math.max(20, width - (panelWidth === 0 ? 0 : panelWidth + 1)); - const selected = selectedTraceViewerSpan(state); - // A panel that doesn't fit (very narrow terminal) isn't modeled as open — - // otherwise key handling would scroll content nobody can see. The drawer - // gets one row of top padding and one column of side padding. - const detailLines = - state.panelOpen && panelWidth > 0 && selected !== undefined - ? [ - "", - ...renderSpanDetail(selected, Math.max(16, panelWidth - 3), theme, { - excludeKeys: CONVERSATION_CONTENT_KEYS, - }).map((line) => ` ${line}`), - ] - : []; + const detailLines = panelDetailLines(state, panelWidth, theme); const panelViewportRows = bodyRows; const rows: string[] = []; @@ -95,7 +83,15 @@ export function renderTraceViewer( } // No divider: the base canvas separates the cards and drawer by itself. const separator = " "; - const panelLine = detailLines[state.panelScroll + index] ?? ""; + let panelLine = detailLines[state.panelScroll + index] ?? ""; + if (state.textSelection !== undefined && state.textSelection.region === "panel") { + panelLine = highlightSelection( + panelLine, + state.panelScroll + index, + state.textSelection, + panelWidth, + ); + } rows.push(joinPanels(left, timelineWidth, separator, panelLine, width)); } } @@ -256,7 +252,9 @@ function renderConversation( // cards taller than the viewport can be scrolled through to the end. const allLines = conversationLines(state, width, theme); const visible = allLines.slice(state.scrollRow, state.scrollRow + bodyRows); - if (state.textSelection === undefined) return visible; + if (state.textSelection === undefined || state.textSelection.region !== "conversation") { + return visible; + } return visible.map((line, index) => highlightSelection(line, state.scrollRow + index, state.textSelection!, width), ); @@ -280,9 +278,25 @@ function conversationLines(state: TraceViewerState, width: number, theme: Theme) } /** - * Plain text covered by a drag selection, extracted from the same rendered - * lines the user saw: partial first/last lines honor the columns, middle - * lines contribute in full, and each line loses its trailing card padding. + * The drawer's display lines for the selected span: one row of top padding + * and one column of side padding around the span detail. A panel that + * doesn't fit (very narrow terminal) isn't modeled as open — otherwise key + * handling would scroll content nobody can see. + */ +function panelDetailLines(state: TraceViewerState, panelWidth: number, theme: Theme): string[] { + const selected = selectedTraceViewerSpan(state); + if (!state.panelOpen || panelWidth <= 0 || selected === undefined) return []; + return [ + "", + ...renderSpanDetail(selected, Math.max(16, panelWidth - 3), theme, { + excludeKeys: CONVERSATION_CONTENT_KEYS, + }).map((line) => ` ${line}`), + ]; +} + +/** + * Plain text covered by a drag selection over the conversation, extracted + * from the same rendered lines the user saw. */ export function conversationSelectionText( state: TraceViewerState, @@ -290,8 +304,32 @@ export function conversationSelectionText( theme: Theme, selection: TextSelectionRange, ): string { + return selectionText(conversationLines(state, width, theme), selection); +} + +/** + * Plain text covered by a drag selection over the details drawer, extracted + * from the same detail lines the frame painted. `totalWidth` is the full + * terminal width — the drawer's width derives from it exactly as rendering + * does, so columns map to the same cells the user selected. + */ +export function panelSelectionText( + state: TraceViewerState, + totalWidth: number, + theme: Theme, + selection: TextSelectionRange, +): string { + const panelWidth = state.panelOpen ? panelWidthFor(totalWidth) : 0; + return selectionText(panelDetailLines(state, panelWidth, theme), selection); +} + +/** + * Slices the selected cells out of rendered lines: partial first/last lines + * honor the columns, middle lines contribute in full, and each line loses + * its trailing padding. + */ +function selectionText(lines: readonly string[], selection: TextSelectionRange): string { const { start, end } = orderedTextSelection(selection); - const lines = conversationLines(state, width, theme); const parts: string[] = []; for (let line = Math.max(0, start.line); line <= end.line && line < lines.length; line += 1) { const text = stripAnsi(lines[line]!); 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 6506b90e7..83c4a03cd 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 @@ -119,6 +119,16 @@ describe("TraceViewerSession drag copy", () => { expect(copied).toHaveLength(1); expect(copied[0]).toContain("copy me please"); expect(frames[frames.length - 1]!.join("\n")).toContain("Copied to clipboard"); + + // A drag over the open drawer copies drawer text: at width 80 the + // 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 }); + expect(copied).toHaveLength(2); + expect(copied[1]).toContain("agent.turn"); session.dispose(); }); }); diff --git a/packages/eve/src/cli/dev/tui/traces/trace-viewer-session.ts b/packages/eve/src/cli/dev/tui/traces/trace-viewer-session.ts index af27bbced..506fef434 100644 --- a/packages/eve/src/cli/dev/tui/traces/trace-viewer-session.ts +++ b/packages/eve/src/cli/dev/tui/traces/trace-viewer-session.ts @@ -10,7 +10,7 @@ import type { Theme } from "../theme.js"; import type { TraceStore, TraceStoreEntry } from "./trace-store.js"; import { createTraceStore } from "./trace-store.js"; import { conversationItemLineCount } from "./trace-conversation.js"; -import { conversationSelectionText, renderTraceViewer } from "./trace-view.js"; +import { conversationSelectionText, panelSelectionText, renderTraceViewer } from "./trace-view.js"; import type { TextSelectionRange, TraceViewerState } from "./trace-viewer-state.js"; import { applyLoadedTrace, @@ -160,12 +160,20 @@ export class TraceViewerSession { /** Extracts the dragged text, hands it to the clipboard, and confirms with a toast. */ #copySelection(selection: TextSelectionRange): void { - const text = conversationSelectionText( - this.#state, - this.#metrics.contentWidth, - this.#options.theme, - selection, - ); + const text = + selection.region === "panel" + ? panelSelectionText( + this.#state, + this.#options.dimensions().width, + this.#options.theme, + selection, + ) + : conversationSelectionText( + this.#state, + this.#metrics.contentWidth, + this.#options.theme, + selection, + ); if (text.trim().length === 0) return; this.#options.copyText?.(text); this.#toast = "Copied to clipboard"; 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 fd6a3311b..9a5c87887 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 @@ -212,6 +212,54 @@ describe("reduceTraceViewerKey", () => { expect(state.expandedItems.has(assistantIndex)).toBe(false); }); + it("selects and copies from the details drawer when the drag starts there", () => { + 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); + // Press right of the cards (contentWidth 80 + separator): drawer cell + // columns are relative to the drawer's content area. + state = reduceTraceViewerKey( + state, + { type: "mouse", action: "press", button: 0, x: 85, y: 3 }, + env, + ).state; + expect(state.textSelection).toEqual({ + anchor: { line: 1, column: 3 }, + head: { line: 1, column: 3 }, + dragging: false, + region: "panel", + }); + state = reduceTraceViewerKey( + state, + { type: "mouse", action: "press", button: 32, x: 90, y: 5 }, + env, + ).state; + const result = reduceTraceViewerKey( + state, + { type: "mouse", action: "release", button: 0, x: 90, y: 5 }, + env, + ); + expect(result.copySelection).toEqual({ + anchor: { line: 1, column: 3 }, + head: { line: 3, column: 8 }, + dragging: true, + region: "panel", + }); + expect(result.state.textSelection).toBeUndefined(); + }); + + it("does not anchor a selection right of the cards when the drawer is closed", () => { + let state = applyLoadedTrace(createTraceViewerState(), conversationTrace()); + const env = { ...ENV, conversationLineCounts: state.conversationItems.map(() => 6) }; + state = reduceTraceViewerKey( + state, + { type: "mouse", action: "press", button: 0, x: 85, y: 3 }, + env, + ).state; + expect(state.textSelection).toBeUndefined(); + }); + it("copies a drag selection on release instead of clicking", () => { let state = applyLoadedTrace(createTraceViewerState(), conversationTrace({ longReply: true })); const env = { @@ -228,6 +276,7 @@ describe("reduceTraceViewerKey", () => { anchor: { line: 1, column: 4 }, head: { line: 1, column: 4 }, dragging: false, + region: "conversation", }); // Motion with the left button held (SGR button 32) extends the selection. @@ -240,6 +289,7 @@ describe("reduceTraceViewerKey", () => { anchor: { line: 1, column: 4 }, head: { line: 3, column: 19 }, dragging: true, + region: "conversation", }); const result = reduceTraceViewerKey( @@ -251,6 +301,7 @@ describe("reduceTraceViewerKey", () => { anchor: { line: 1, column: 4 }, head: { line: 3, column: 19 }, dragging: true, + region: "conversation", }); expect(result.state.textSelection).toBeUndefined(); // The drag did not toggle any card. 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 b5a8b711a..a5ca85ada 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 @@ -38,11 +38,15 @@ export interface TraceViewerState { readonly panelScroll: number; /** One-line status for edge cases (trace pruned, tracing disabled, …). */ readonly notice?: string; - /** Mouse drag selection over the conversation, in absolute line/column cells. */ + /** Mouse drag selection over the conversation or drawer, in line/column cells. */ readonly textSelection?: TextSelectionRange; } -/** One end of a text selection: conversation line index + 0-based column. */ +/** + * One end of a text selection: line index + 0-based column, both relative + * to the selection's region — absolute conversation lines for the cards, + * absolute detail lines (and panel-content columns) for the drawer. + */ export interface TextSelectionPoint { readonly line: number; readonly column: number; @@ -54,6 +58,8 @@ export interface TextSelectionRange { readonly head: TextSelectionPoint; /** The pointer moved off the anchor cell — release copies instead of clicking. */ readonly dragging: boolean; + /** Where the drag started; the selection stays in that region. */ + readonly region: "conversation" | "panel"; } /** Selection endpoints ordered top-to-bottom for rendering and extraction. */ @@ -239,21 +245,33 @@ export function reduceTraceViewerKey( const delta = key.button === 64 ? -3 : 3; return { state: scrollConversation(base, delta, environment) }; } - const point = conversationCellAt(base, environment, key.x, key.y); - // Left press anchors a possible drag selection; the click action (and - // any copy) waits for release so dragging never toggles cards. + // Left press anchors a possible drag selection in whichever region the + // pointer sits over — the cards or the open drawer; the click action + // (and any copy) waits for release so dragging never toggles cards. if (key.action === "press" && key.button === 0) { - if (point === undefined) return { state: { ...base, textSelection: undefined } }; + const anchored = selectionCellAt(base, environment, key.x, key.y); + if (anchored === undefined) return { state: { ...base, textSelection: undefined } }; return { state: { ...base, - textSelection: { anchor: point, head: point, dragging: false }, + textSelection: { + anchor: anchored.point, + head: anchored.point, + dragging: false, + region: anchored.region, + }, }, }; } - // Motion with the left button held (SGR button 32) extends the selection. + // Motion with the left button held (SGR button 32) extends the + // selection within the region the drag started in. if (key.action === "press" && key.button === 32) { - if (base.textSelection === undefined || point === undefined) return { state: base }; + if (base.textSelection === undefined) return { state: base }; + const point = + base.textSelection.region === "panel" + ? panelCellAt(base, environment, key.x, key.y) + : conversationCellAt(base, environment, key.x, key.y); + if (point === undefined) return { state: base }; const dragging = base.textSelection.dragging || point.line !== base.textSelection.anchor.line || @@ -441,6 +459,43 @@ function conversationCellAt( return { column, line }; } +/** + * Maps 1-based terminal coordinates to a detail-drawer cell: absolute + * detail-line index (drawer scroll applied) and a column relative to the + * drawer's content (the area right of the conversation and its separator). + */ +function panelCellAt( + state: TraceViewerState, + environment: TraceViewerKeyEnvironment, + x: number, + y: number, +): TextSelectionPoint | undefined { + if (!state.panelOpen || environment.panelTotalRows === 0) return undefined; + const bodyIndex = y - 2; // one header row, 1-based coordinates + if (bodyIndex < 0 || bodyIndex >= environment.panelViewportRows) return undefined; + const column = x - 1 - (environment.contentWidth + 1); + if (column < 0) return undefined; + const line = state.panelScroll + bodyIndex; + if (line >= environment.panelTotalRows) return undefined; + return { column, line }; +} + +/** Resolves a press to the region under the pointer: cards first, then drawer. */ +function selectionCellAt( + state: TraceViewerState, + environment: TraceViewerKeyEnvironment, + x: number, + y: number, +): + | { readonly point: TextSelectionPoint; readonly region: TextSelectionRange["region"] } + | undefined { + const conversation = conversationCellAt(state, environment, x, y); + if (conversation !== undefined) return { point: conversation, region: "conversation" }; + const panel = panelCellAt(state, environment, x, y); + if (panel !== undefined) return { point: panel, region: "panel" }; + return undefined; +} + /** Maps a click's body row to a card index, walking card heights + separators. */ function conversationItemAtBodyRow( state: TraceViewerState,