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..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 { 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"; @@ -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, {}); 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..25b38d44a 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( @@ -60,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[] = []; @@ -91,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)); } } @@ -219,7 +219,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 +250,17 @@ 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 || state.textSelection.region !== "conversation") { + 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 +274,98 @@ function renderConversation( "", ); } - return allLines.slice(state.scrollRow, state.scrollRow + bodyRows); + return allLines; +} + +/** + * 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, + width: number, + 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 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..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 @@ -75,6 +75,64 @@ 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"); + + // 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(); + }); +}); + 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..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,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, panelSelectionText, 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,38 @@ 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 = + 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"; + 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..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 @@ -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,143 @@ 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("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: 10, y: clickY }, + { type: "mouse", action: "press", button: 0, x: 85, y: 3 }, env, ).state; - expect(state.expandedItems.has(assistantIndex)).toBe(false); + 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(); + }); - // A release is not a click. + 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: "release", button: 0, x: 10, y: clickY }, + { type: "mouse", action: "press", button: 0, x: 85, y: 3 }, env, ).state; - expect(state.expandedItems.has(assistantIndex)).toBe(false); + expect(state.textSelection).toBeUndefined(); + }); + + 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: 5, y: 3 }, + env, + ).state; + expect(state.textSelection).toEqual({ + 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. + state = reduceTraceViewerKey( + state, + { type: "mouse", action: "press", button: 32, x: 20, y: 5 }, + env, + ).state; + expect(state.textSelection).toEqual({ + anchor: { line: 1, column: 4 }, + head: { line: 3, column: 19 }, + dragging: true, + region: "conversation", + }); + + 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, + region: "conversation", + }); + 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..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,6 +38,39 @@ 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 or drawer, in line/column cells. */ + readonly textSelection?: TextSelectionRange; +} + +/** + * 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; +} + +/** 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; + /** Where the drag started; the selection stays in that region. */ + readonly region: "conversation" | "panel"; +} + +/** 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 +94,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 +166,7 @@ export function applyTraceList( panelOpen: false, panelFocus: false, panelScroll: 0, + textSelection: undefined, }; } @@ -193,6 +229,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 +240,60 @@ 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 }; + // 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) { + const anchored = selectionCellAt(base, environment, key.x, key.y); + if (anchored === undefined) return { state: { ...base, textSelection: undefined } }; + return { + state: { + ...base, + textSelection: { + anchor: anchored.point, + head: anchored.point, + dragging: false, + region: anchored.region, + }, + }, + }; + } + // 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) 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 || + 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 +303,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 +389,7 @@ function switchTrace(state: TraceViewerState, delta: number): TraceViewerState { panelOpen: false, panelFocus: false, panelScroll: 0, + textSelection: undefined, }; } @@ -354,6 +434,68 @@ 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 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, 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.