From 126f4e8dcfd331bd47538bd7ed32e37a1e0bef8d Mon Sep 17 00:00:00 2001 From: can1357 Date: Sat, 6 Jun 2026 15:15:54 +0200 Subject: [PATCH] feat(coding-agent): replaced /copy subcommands with picker tree - Added a fullscreen /copy tree of recent assistant messages with nested code blocks and a live preview pane. - Removed the /copy last|code|all|cmd subcommands in favor of tree selection. - Extracted copy-target assembly into a testable util. --- packages/coding-agent/CHANGELOG.md | 5 + .../src/modes/components/copy-selector.ts | 249 ++++++++++++++++++ .../modes/controllers/command-controller.ts | 116 -------- .../modes/controllers/selector-controller.ts | 39 ++- .../src/modes/interactive-mode.ts | 8 +- packages/coding-agent/src/modes/types.ts | 2 +- .../src/modes/utils/copy-targets.ts | 218 +++++++++++++++ .../src/slash-commands/builtin-registry.ts | 14 +- .../modes/components/copy-selector.test.ts | 135 ++++++++++ .../modes/controllers/copy-command.test.ts | 53 ---- .../test/modes/utils/copy-targets.test.ts | 151 +++++++++++ 11 files changed, 804 insertions(+), 186 deletions(-) create mode 100644 packages/coding-agent/src/modes/components/copy-selector.ts create mode 100644 packages/coding-agent/src/modes/utils/copy-targets.ts create mode 100644 packages/coding-agent/test/modes/components/copy-selector.test.ts delete mode 100644 packages/coding-agent/test/modes/controllers/copy-command.test.ts create mode 100644 packages/coding-agent/test/modes/utils/copy-targets.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index d808662a1..53c52758d 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added - Added `timeout-pause` and `timeout-resume` eval bridge status events emitted around `agent()`/`llm()` operations +- Added a `/copy` picker: `/copy` now opens a fullscreen, outlined tree of recent assistant messages with their code blocks nested beneath (like `/tree`). Navigate freely with ↑↓, and Enter copies the highlighted node — a whole message, an individual code block, "All N blocks", or the most recent bash/eval command. A live preview pane shows the selected target, wrapping prose and syntax-highlighting code/commands. ### Changed @@ -22,6 +23,10 @@ - Fixed `task` renderer crashing the TUI with `TypeError: completeData?.map is not a function` when a subagent's `extractedToolData.yield` slot held a non-array value. `renderAgentResult` (and the live-progress sibling) cast the slot to `Array<{ data }>` and called `?.map`, but optional chaining short-circuits only on `null`/`undefined`, so a plain object made `.map` `undefined` and threw — taking down every `review` task render. Both sites now go through `normalizeYieldData`, which wraps a single object as a 1-element array and drops primitives ([#1987](https://github.com/can1357/oh-my-pi/issues/1987)) - Fixed `sdk-async-job-manager-singleton` tests flaking under the full parallel suite. The four `createAgentSession`-based cases ran on the default 5000ms per-test timeout, which two real session startups can exceed when `test:ts` saturates the machine across packages; on timeout the still-running test body and `afterEach` reset raced, surfacing a spurious "Unhandled error between tests" on the `AsyncJobManager.instance()` assertion. They now carry an explicit 60000ms timeout, matching the convention used by the other session-creating tests in this suite. +### Removed + +- Removed the `/copy last|code|all|cmd` subcommands; every copy target is now reachable by picking it in the `/copy` tree. + ## [15.9.5] - 2026-06-05 ### Added diff --git a/packages/coding-agent/src/modes/components/copy-selector.ts b/packages/coding-agent/src/modes/components/copy-selector.ts new file mode 100644 index 000000000..5628b1e67 --- /dev/null +++ b/packages/coding-agent/src/modes/components/copy-selector.ts @@ -0,0 +1,249 @@ +import { type Component, matchesKey, padding, Text, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui"; +import { replaceTabs } from "../../tools/render-utils"; +import { highlightCode, theme } from "../theme/theme"; +import type { CopyTarget } from "../utils/copy-targets"; +import { + matchesSelectCancel, + matchesSelectDown, + matchesSelectPageDown, + matchesSelectPageUp, + matchesSelectUp, +} from "../utils/keybinding-matchers"; +import { keyHint, rawKeyHint } from "./keybinding-hints"; + +/** Minimum rows reserved for the tree even on short terminals. */ +const MIN_TREE_ROWS = 3; +/** Fixed chrome rows: top border, two dividers, footer, bottom border. */ +const CHROME_ROWS = 5; + +export interface CopySelectorCallbacks { + /** A copy target was chosen — copy its `content`. */ + onPick: (target: CopyTarget) => void; + /** The picker was dismissed. */ + onCancel: () => void; +} + +interface FlatNode { + target: CopyTarget; + depth: number; + /** Last among its siblings (drives └─ vs ├─). */ + isLast: boolean; + /** Per-ancestor flag: does ancestor at that level have a following sibling? */ + ancestorHasNext: boolean[]; +} + +/** Pad or truncate a (possibly ANSI-styled) string to exactly `width` columns. */ +function fit(text: string, width: number): string { + if (width <= 0) return ""; + const w = visibleWidth(text); + if (w === width) return text; + if (w < width) return text + padding(width - w); + const cut = truncateToWidth(text, width); + const cw = visibleWidth(cut); + return cw < width ? cut + padding(width - cw) : cut; +} + +function paint(s: string): string { + return theme.fg("border", s); +} + +function topBorder(width: number, title: string): string { + const box = theme.boxSharp; + const inner = Math.max(0, width - 2); + if (!title) return paint(box.topLeft + box.horizontal.repeat(inner) + box.topRight); + const shown = truncateToWidth(` ${title} `, Math.max(0, inner - 2)); + const fillWidth = Math.max(0, inner - 1 - visibleWidth(shown)); + return ( + paint(box.topLeft + box.horizontal) + + theme.bold(theme.fg("accent", shown)) + + paint(box.horizontal.repeat(fillWidth) + box.topRight) + ); +} + +function divider(width: number): string { + const box = theme.boxSharp; + return paint(box.teeRight + box.horizontal.repeat(Math.max(0, width - 2)) + box.teeLeft); +} + +function bottomBorder(width: number): string { + const box = theme.boxSharp; + return paint(box.bottomLeft + box.horizontal.repeat(Math.max(0, width - 2)) + box.bottomRight); +} + +/** Wrap pre-styled content in vertical borders with single-column insets. */ +function row(content: string, width: number): string { + const box = theme.boxSharp; + return `${paint(box.vertical)} ${fit(content, Math.max(0, width - 4))} ${paint(box.vertical)}`; +} + +/** Render one tree connector as exactly three cells (e.g. "├─ ", "└─ ", "|--"). */ +function connectorCells(symbol: string): string { + const chars = Array.from(symbol); + return (chars[0] ?? " ") + (chars[1] ?? theme.tree.horizontal) + (chars[2] ?? " "); +} + +/** The 3-cell ancestor gutter: a vertical guide when the ancestor continues. */ +function gutterCells(hasNext: boolean): string { + return `${hasNext ? theme.tree.vertical : " "} `; +} + +/** + * Fullscreen `/copy` picker rendered as a `/tree`-style tree inside one + * outlined box: a title, the tree of copy targets (recent assistant messages + * with their code blocks nested beneath), a live preview of the highlighted + * node, and a keybinding footer. Every node copies its `content` on Enter. + */ +export class CopySelectorComponent implements Component { + #roots: CopyTarget[]; + #cursorId: string; + #treeRows = MIN_TREE_ROWS; + // Reused across renders to wrap preview content to the pane width. + #previewText = new Text("", 0, 0); + + constructor( + roots: CopyTarget[], + private readonly callbacks: CopySelectorCallbacks, + ) { + this.#roots = roots; + this.#cursorId = roots[0]?.id ?? ""; + } + + invalidate(): void {} + + #flatten(): FlatNode[] { + const out: FlatNode[] = []; + const walk = (nodes: CopyTarget[], depth: number, ancestorHasNext: boolean[]) => { + nodes.forEach((target, i) => { + const isLast = i === nodes.length - 1; + out.push({ target, depth, isLast, ancestorHasNext }); + if (target.children?.length) walk(target.children, depth + 1, [...ancestorHasNext, !isLast]); + }); + }; + walk(this.#roots, 0, []); + return out; + } + + handleInput(keyData: string): void { + if (matchesSelectCancel(keyData)) { + this.callbacks.onCancel(); + return; + } + + const flat = this.#flatten(); + if (flat.length === 0) return; + const idx = Math.max( + 0, + flat.findIndex(n => n.target.id === this.#cursorId), + ); + + if (matchesSelectUp(keyData)) { + this.#cursorId = flat[idx === 0 ? flat.length - 1 : idx - 1]!.target.id; + } else if (matchesSelectDown(keyData)) { + this.#cursorId = flat[idx === flat.length - 1 ? 0 : idx + 1]!.target.id; + } else if (matchesSelectPageUp(keyData)) { + this.#cursorId = flat[Math.max(0, idx - this.#treeRows)]!.target.id; + } else if (matchesSelectPageDown(keyData)) { + this.#cursorId = flat[Math.min(flat.length - 1, idx + this.#treeRows)]!.target.id; + } else if (matchesKey(keyData, "enter") || matchesKey(keyData, "return") || keyData === "\n") { + const target = flat[idx]!.target; + if (target.content !== undefined) this.callbacks.onPick(target); + } + } + + #renderTree(width: number, flat: FlatNode[], cursorIdx: number, rows: number): string[] { + const inner = Math.max(0, width - 4); + const start = Math.max(0, Math.min(cursorIdx - Math.floor(rows / 2), Math.max(0, flat.length - rows))); + const out: string[] = []; + for (let r = 0; r < rows; r++) { + const i = start + r; + const node = flat[i]; + if (!node) { + out.push(row("", width)); + continue; + } + const target = node.target; + const isSelected = i === cursorIdx; + + let prefix = ""; + for (let l = 0; l < node.depth - 1; l++) prefix += gutterCells(node.ancestorHasNext[l]!); + if (node.depth > 0) prefix += connectorCells(node.isLast ? theme.tree.last : theme.tree.branch); + + const cursor = isSelected ? "❯ " : " "; + const hint = target.hint ?? ""; + const hintWidth = hint ? visibleWidth(hint) + 2 : 0; + const used = visibleWidth(cursor) + visibleWidth(prefix); + const labelPlain = truncateToWidth(target.label, Math.max(1, inner - used - hintWidth)); + const left = isSelected + ? theme.fg("accent", cursor) + theme.fg("dim", prefix) + theme.bold(theme.fg("accent", labelPlain)) + : cursor + theme.fg("dim", prefix) + labelPlain; + const gap = Math.max(1, inner - used - visibleWidth(labelPlain) - visibleWidth(hint)); + out.push(row(left + padding(gap) + (hint ? theme.fg("dim", hint) : ""), width)); + } + return out; + } + + #renderPreview(width: number, target: CopyTarget | undefined, rows: number): string[] { + const out: string[] = []; + const hint = target?.hint; + out.push(row(theme.fg("dim", `Preview${hint ? ` · ${hint}` : ""}`), width)); + + const contentRows = rows - 1; + if (!target || contentRows <= 0) { + while (out.length < rows) out.push(row("", width)); + return out; + } + + // Code/command previews are syntax-highlighted; everything else is shown + // as plain text. Both are wrapped (not hard-truncated) to the pane width. + const isCode = target.language !== undefined; + const source = isCode + ? highlightCode(replaceTabs(target.preview), target.language).join("\n") + : replaceTabs(target.preview); + this.#previewText.setText(source); + const wrapped = this.#previewText.render(Math.max(1, width - 4)); + + const hasMore = wrapped.length > contentRows; + const visibleCount = hasMore ? contentRows - 1 : Math.min(wrapped.length, contentRows); + for (let k = 0; k < contentRows; k++) { + if (k < visibleCount) { + out.push(row(isCode ? wrapped[k]! : theme.fg("muted", wrapped[k]!), width)); + } else if (k === visibleCount && hasMore) { + out.push(row(theme.fg("dim", `… ${wrapped.length - visibleCount} more lines`), width)); + } else { + out.push(row("", width)); + } + } + return out; + } + + render(width: number): string[] { + const height = process.stdout.rows || 40; + const flat = this.#flatten(); + const cursorIdx = Math.max( + 0, + flat.findIndex(n => n.target.id === this.#cursorId), + ); + const selected = flat[cursorIdx]?.target; + + const available = Math.max(MIN_TREE_ROWS + 1, height - CHROME_ROWS); + const treeRows = Math.max(1, Math.min(flat.length, Math.floor(available / 2))); + this.#treeRows = treeRows; + const previewRows = Math.max(1, available - treeRows); + + const footer = [ + rawKeyHint("↑↓", "move"), + keyHint("tui.select.confirm", "copy"), + keyHint("tui.select.cancel", "quit"), + ].join(theme.fg("dim", " · ")); + + return [ + topBorder(width, "Copy to clipboard"), + ...this.#renderTree(width, flat, cursorIdx, treeRows), + divider(width), + ...this.#renderPreview(width, selected, previewRows), + divider(width), + row(footer, width), + bottomBorder(width), + ]; + } +} diff --git a/packages/coding-agent/src/modes/controllers/command-controller.ts b/packages/coding-agent/src/modes/controllers/command-controller.ts index 6cfce5c9e..f1f857ecb 100644 --- a/packages/coding-agent/src/modes/controllers/command-controller.ts +++ b/packages/coding-agent/src/modes/controllers/command-controller.ts @@ -6,7 +6,6 @@ import { getEnvApiKey, getProviderDetails, type ProviderDetails, - type ToolCall, type UsageLimit, type UsageReport, } from "@oh-my-pi/pi-ai"; @@ -239,121 +238,6 @@ export class CommandController { } } - handleCopyCommand(sub?: string) { - switch (sub) { - case "code": - return this.#copyCode(); - case "all": - return this.#copyAllCode(); - case "cmd": - return this.#copyLastCommand(); - case "last": - case undefined: - return this.#copyLastMessage(); - default: - this.ctx.showError(`Unknown subcommand: ${sub}. Use code, all, cmd, or last.`); - } - } - - #copyLastMessage() { - const assistantText = this.ctx.session.getLastAssistantText(); - if (assistantText) { - this.#doCopy(assistantText, "Copied last agent message to clipboard"); - return; - } - - if (!this.ctx.session.hasCopyCandidateAssistantMessage()) { - const handoffText = this.ctx.session.getLastVisibleHandoffText(); - if (handoffText) { - this.#doCopy(handoffText, "Copied handoff context to clipboard"); - return; - } - } - - this.ctx.showError("No agent messages to copy yet."); - } - - #copyCode() { - const text = this.ctx.session.getLastAssistantText(); - if (!text) { - this.ctx.showError("No agent messages to copy yet."); - return; - } - const matches = [...text.matchAll(/^```[^\n]*\n([\s\S]*?)^```/gm)]; - const lastMatch = matches.at(-1); - if (!lastMatch) { - this.ctx.showWarning("No code block found in the last agent message."); - return; - } - this.#doCopy(lastMatch[1].replace(/\n$/, ""), "Copied last code block to clipboard"); - } - - #copyAllCode() { - const text = this.ctx.session.getLastAssistantText(); - if (!text) { - this.ctx.showError("No agent messages to copy yet."); - return; - } - const matches = [...text.matchAll(/^```[^\n]*\n([\s\S]*?)^```/gm)]; - if (matches.length === 0) { - this.ctx.showWarning("No code blocks found in the last agent message."); - return; - } - const combined = matches.map(m => m[1].replace(/\n$/, "")).join("\n\n"); - this.#doCopy(combined, `Copied ${matches.length} code block${matches.length > 1 ? "s" : ""} to clipboard`); - } - - #extractEvalCode(args: unknown): string | undefined { - if (!args || typeof args !== "object") return undefined; - const cells = (args as { cells?: unknown }).cells; - if (!Array.isArray(cells)) return undefined; - - const codeBlocks: string[] = []; - for (const cell of cells) { - if (!cell || typeof cell !== "object") continue; - const code = (cell as { code?: unknown }).code; - if (typeof code === "string" && code.length > 0) { - codeBlocks.push(code); - } - } - - return codeBlocks.length > 0 ? codeBlocks.join("\n\n") : undefined; - } - - #copyLastCommand() { - const messages = this.ctx.session.messages; - // Walk backwards to find the last bash/eval tool call - for (let i = messages.length - 1; i >= 0; i--) { - const msg = messages[i]; - if (msg.role !== "assistant") continue; - const toolCalls = msg.content.filter((c): c is ToolCall => c.type === "toolCall"); - for (let j = toolCalls.length - 1; j >= 0; j--) { - const tc = toolCalls[j]; - if (tc.name === "bash" && typeof tc.arguments.command === "string") { - this.#doCopy(tc.arguments.command, "Copied last bash command to clipboard"); - return; - } - if (tc.name === "eval") { - const code = this.#extractEvalCode(tc.arguments); - if (code) { - this.#doCopy(code, "Copied last eval code to clipboard"); - return; - } - } - } - } - this.ctx.showWarning("No bash or eval command found in the conversation."); - } - - #doCopy(content: string, label: string) { - try { - copyToClipboard(content); - this.ctx.showStatus(label); - } catch (error) { - this.ctx.showError(error instanceof Error ? error.message : String(error)); - } - } - async handleSessionCommand(): Promise { const stats = this.ctx.session.getSessionStats(); const premiumRequests = diff --git a/packages/coding-agent/src/modes/controllers/selector-controller.ts b/packages/coding-agent/src/modes/controllers/selector-controller.ts index 7ba39df43..df8ae8d1b 100644 --- a/packages/coding-agent/src/modes/controllers/selector-controller.ts +++ b/packages/coding-agent/src/modes/controllers/selector-controller.ts @@ -37,9 +37,11 @@ import { setPreferredSearchProvider, } from "../../tools"; import { shortenPath } from "../../tools/render-utils"; +import { copyToClipboard } from "../../utils/clipboard"; import { setSessionTerminalTitle } from "../../utils/title-generator"; import { AgentDashboard } from "../components/agent-dashboard"; import { AssistantMessageComponent } from "../components/assistant-message"; +import { CopySelectorComponent } from "../components/copy-selector"; import { ExtensionDashboard } from "../components/extensions"; import { HistorySearchComponent } from "../components/history-search"; import { ModelSelectorComponent } from "../components/model-selector"; @@ -52,6 +54,8 @@ import { ToolExecutionComponent } from "../components/tool-execution"; import { TreeSelectorComponent } from "../components/tree-selector"; import { UserMessageSelectorComponent } from "../components/user-message-selector"; import type { SessionObserverRegistry } from "../session-observer-registry"; +import { computeContextBreakdown } from "../utils/context-usage"; +import { buildCopyTargets } from "../utils/copy-targets"; const CALLBACK_SERVER_PROVIDERS = new Set([ "anthropic", @@ -407,6 +411,7 @@ export class SelectorController { } showModelSelector(options?: { temporaryOnly?: boolean }): void { + const currentContextTokens = computeContextBreakdown(this.ctx.session).usedTokens; this.showSelector(done => { const selector = new ModelSelectorComponent( this.ctx.ui, @@ -470,7 +475,7 @@ export class SelectorController { done(); this.ctx.ui.requestRender(); }, - options, + { ...options, currentContextTokens }, ); return { component: selector, focus: selector }; }); @@ -598,6 +603,38 @@ export class SelectorController { }); } + showCopySelector(): void { + const targets = buildCopyTargets(this.ctx.session); + if (targets.length === 0) { + this.ctx.showStatus("Nothing to copy yet."); + return; + } + + let overlayHandle: OverlayHandle | undefined; + const done = () => { + overlayHandle?.hide(); + this.ctx.ui.requestRender(); + }; + const selector = new CopySelectorComponent(targets, { + onPick: target => { + done(); + if (target.content === undefined) return; + void copyToClipboard(target.content); + this.ctx.showStatus(target.copyMessage ?? "Copied to clipboard"); + }, + onCancel: done, + }); + + overlayHandle = this.ctx.ui.showOverlay(selector, { + anchor: "bottom-center", + width: "100%", + maxHeight: "100%", + margin: 0, + }); + this.ctx.ui.setFocus(selector); + this.ctx.ui.requestRender(); + } + showTreeSelector(): void { const tree = this.ctx.sessionManager.getTree(); const realLeafId = this.ctx.sessionManager.getLeafId(); diff --git a/packages/coding-agent/src/modes/interactive-mode.ts b/packages/coding-agent/src/modes/interactive-mode.ts index c1659f1ac..009ef43e1 100644 --- a/packages/coding-agent/src/modes/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive-mode.ts @@ -2700,10 +2700,6 @@ export class InteractiveMode implements InteractiveModeContext { return this.#commandController.handleShareCommand(); } - handleCopyCommand(sub?: string) { - return this.#commandController.handleCopyCommand(sub); - } - handleTodoCommand(args: string): Promise { return this.#todoCommandController.handleTodoCommand(args); } @@ -2936,6 +2932,10 @@ export class InteractiveMode implements InteractiveModeContext { this.#selectorController.showUserMessageSelector(); } + showCopySelector(): void { + this.#selectorController.showCopySelector(); + } + showTreeSelector(): void { this.#selectorController.showTreeSelector(); } diff --git a/packages/coding-agent/src/modes/types.ts b/packages/coding-agent/src/modes/types.ts index 9116494ff..2b38238a4 100644 --- a/packages/coding-agent/src/modes/types.ts +++ b/packages/coding-agent/src/modes/types.ts @@ -222,7 +222,6 @@ export interface InteractiveModeContext { // Command handling handleExportCommand(text: string): Promise; handleShareCommand(): Promise; - handleCopyCommand(sub?: string): void; handleTodoCommand(args: string): Promise; handleSessionCommand(): Promise; handleJobsCommand(): Promise; @@ -263,6 +262,7 @@ export interface InteractiveModeContext { showModelSelector(options?: { temporaryOnly?: boolean }): void; showPluginSelector(mode?: "install" | "uninstall"): void; showUserMessageSelector(): void; + showCopySelector(): void; showTreeSelector(): void; showSessionSelector(): void; handleResumeSession(sessionPath: string): Promise; diff --git a/packages/coding-agent/src/modes/utils/copy-targets.ts b/packages/coding-agent/src/modes/utils/copy-targets.ts new file mode 100644 index 000000000..acb72cd2f --- /dev/null +++ b/packages/coding-agent/src/modes/utils/copy-targets.ts @@ -0,0 +1,218 @@ +import type { AgentMessage } from "@oh-my-pi/pi-agent-core"; +import type { ToolCall } from "@oh-my-pi/pi-ai"; + +/** A fenced code block extracted from assistant markdown. */ +export interface CodeBlock { + /** Info string after the opening fence (language id), trimmed. */ + lang: string; + /** Block body with the trailing newline stripped. */ + code: string; +} + +/** The most recent runnable command found in the transcript. */ +export interface LastCommand { + kind: "bash" | "eval"; + code: string; + /** Highlight language: "bash" for bash, "python"/"javascript" for eval. */ + language: string; +} + +/** + * A node in the `/copy` picker tree. Leaves carry `content` (placed on the + * clipboard) plus `copyMessage` (the status shown afterwards); groups carry + * `children` to drill into. + */ +export interface CopyTarget { + /** Stable identifier (e.g. "msg:1", "msg:1:code:0", "msg:1:all", "cmd"). */ + id: string; + label: string; + /** Dim annotation: line/block counts, language, or tool name. */ + hint?: string; + /** Full text rendered in the preview pane. */ + preview: string; + /** Highlight language for code/command previews (undefined = plain/markdown). */ + language?: string; + /** Leaf: text copied to the clipboard. */ + content?: string; + /** Leaf: status message shown after copying. */ + copyMessage?: string; + /** Group: nested targets to drill into. */ + children?: CopyTarget[]; +} + +/** Minimal session surface needed to assemble copy targets (eases testing). */ +export interface CopySource { + readonly messages: readonly AgentMessage[]; + getLastVisibleHandoffText(): string | undefined; +} + +/** Cap on how many recent assistant messages the picker lists. */ +const MAX_MESSAGES = 50; + +const CODE_BLOCK_RE = /^```([^\n]*)\n([\s\S]*?)^```/gm; + +/** Extract fenced code blocks from assistant markdown, in document order. */ +export function extractCodeBlocks(text: string): CodeBlock[] { + const blocks: CodeBlock[] = []; + for (const match of text.matchAll(CODE_BLOCK_RE)) { + blocks.push({ lang: match[1].trim(), code: match[2].replace(/\n$/, "") }); + } + return blocks; +} + +function extractEvalCode(args: unknown): { code: string; language: string } | undefined { + if (!args || typeof args !== "object") return undefined; + const cells = (args as { cells?: unknown }).cells; + if (!Array.isArray(cells)) return undefined; + + const codeBlocks: string[] = []; + let language = "python"; + let languageResolved = false; + for (const cell of cells) { + if (!cell || typeof cell !== "object") continue; + const code = (cell as { code?: unknown }).code; + if (typeof code !== "string" || code.length === 0) continue; + codeBlocks.push(code); + if (!languageResolved) { + language = (cell as { language?: unknown }).language === "js" ? "javascript" : "python"; + languageResolved = true; + } + } + + return codeBlocks.length > 0 ? { code: codeBlocks.join("\n\n"), language } : undefined; +} + +/** Walk the transcript backwards for the most recent bash command or eval code. */ +export function extractLastCommand(messages: readonly AgentMessage[]): LastCommand | undefined { + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i]; + if (msg.role !== "assistant") continue; + const toolCalls = msg.content.filter((c): c is ToolCall => c.type === "toolCall"); + for (let j = toolCalls.length - 1; j >= 0; j--) { + const tc = toolCalls[j]; + if (tc.name === "bash" && typeof tc.arguments.command === "string") { + return { kind: "bash", code: tc.arguments.command, language: "bash" }; + } + if (tc.name === "eval") { + const evalResult = extractEvalCode(tc.arguments); + if (evalResult) return { kind: "eval", code: evalResult.code, language: evalResult.language }; + } + } + } + return undefined; +} + +/** Concatenated visible text of an assistant message, or undefined when empty. */ +function assistantText(msg: AgentMessage): string | undefined { + if (msg.role !== "assistant") return undefined; + let text = ""; + for (const content of msg.content) { + if (content.type === "text") text += content.text; + } + return text.trim() || undefined; +} + +function pluralLines(text: string): string { + const count = text.length === 0 ? 0 : text.split("\n").length; + return `${count} line${count === 1 ? "" : "s"}`; +} + +function blockHint(block: CodeBlock): string { + const lines = pluralLines(block.code); + return block.lang ? `${block.lang} · ${lines}` : lines; +} + +/** First non-empty line, whitespace-collapsed, used as a message label. */ +function firstLine(text: string): string { + for (const line of text.split("\n")) { + const trimmed = line.trim(); + if (trimmed) return trimmed.replace(/\s+/g, " "); + } + return text.trim().replace(/\s+/g, " "); +} + +/** Build the target node for one assistant message: a leaf when it has no code + * blocks, otherwise a group exposing the full message, each block, and "all". */ +function messageTarget(text: string, rank: number): CopyTarget { + const id = `msg:${rank}`; + const label = firstLine(text); + const blocks = extractCodeBlocks(text); + const hint = blocks.length > 0 ? `${pluralLines(text)} · ${blocks.length} code` : pluralLines(text); + const messageCopy = rank === 1 ? "Copied last message to clipboard" : "Copied message to clipboard"; + + if (blocks.length === 0) { + return { id, label, hint, preview: text, content: text, copyMessage: messageCopy }; + } + + // The message node itself copies the full message; its code blocks are + // child copy targets you can expand into. + const children: CopyTarget[] = blocks.map((block, j) => ({ + id: `${id}:code:${j}`, + label: `Block ${j + 1}`, + hint: blockHint(block), + preview: block.code, + language: block.lang || undefined, + content: block.code, + copyMessage: `Copied code block ${j + 1} to clipboard`, + })); + if (blocks.length > 1) { + const combined = blocks.map(b => b.code).join("\n\n"); + children.push({ + id: `${id}:all`, + label: `All ${blocks.length} blocks`, + hint: pluralLines(combined), + preview: combined, + content: combined, + copyMessage: `Copied ${blocks.length} code blocks to clipboard`, + }); + } + + return { id, label, hint, preview: text, content: text, copyMessage: messageCopy, children }; +} + +/** + * Assemble the unified `/copy` target tree: the recent assistant messages + * (most recent first, each drillable into its code blocks), a fresh-handoff + * fallback when no assistant message exists yet, and the most recent command. + */ +export function buildCopyTargets(source: CopySource): CopyTarget[] { + const targets: CopyTarget[] = []; + + let rank = 0; + for (let i = source.messages.length - 1; i >= 0 && rank < MAX_MESSAGES; i--) { + const text = assistantText(source.messages[i]); + if (!text) continue; + rank += 1; + targets.push(messageTarget(text, rank)); + } + + if (targets.length === 0) { + const handoff = source.getLastVisibleHandoffText(); + if (handoff) { + targets.push({ + id: "handoff", + label: "Handoff context", + hint: pluralLines(handoff), + preview: handoff, + content: handoff, + copyMessage: "Copied handoff context to clipboard", + }); + } + } + + const command = extractLastCommand(source.messages); + if (command) { + targets.push({ + id: "cmd", + label: command.kind === "bash" ? "Last bash command" : "Last eval code", + hint: command.kind, + preview: command.code, + language: command.language, + content: command.code, + copyMessage: + command.kind === "bash" ? "Copied last bash command to clipboard" : "Copied last eval code to clipboard", + }); + } + + return targets; +} diff --git a/packages/coding-agent/src/slash-commands/builtin-registry.ts b/packages/coding-agent/src/slash-commands/builtin-registry.ts index b5330eb9c..394491f8b 100644 --- a/packages/coding-agent/src/slash-commands/builtin-registry.ts +++ b/packages/coding-agent/src/slash-commands/builtin-registry.ts @@ -392,17 +392,9 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray = [ }, { name: "copy", - description: "Copy last agent message to clipboard", - subcommands: [ - { name: "last", description: "Copy full last agent message" }, - { name: "code", description: "Copy last code block" }, - { name: "all", description: "Copy all code blocks from last message" }, - { name: "cmd", description: "Copy last bash/python command" }, - ], - allowArgs: true, - handleTui: async (command, runtime) => { - const sub = command.args.trim().toLowerCase() || undefined; - await runtime.ctx.handleCopyCommand(sub); + description: "Pick text or code from the conversation to copy", + handleTui: (_command, runtime) => { + runtime.ctx.showCopySelector(); runtime.ctx.editor.setText(""); }, }, diff --git a/packages/coding-agent/test/modes/components/copy-selector.test.ts b/packages/coding-agent/test/modes/components/copy-selector.test.ts new file mode 100644 index 000000000..7b09f17be --- /dev/null +++ b/packages/coding-agent/test/modes/components/copy-selector.test.ts @@ -0,0 +1,135 @@ +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "bun:test"; +import { stripVTControlCharacters } from "node:util"; +import { KeybindingsManager } from "@oh-my-pi/pi-coding-agent/config/keybindings"; +import { CopySelectorComponent } from "@oh-my-pi/pi-coding-agent/modes/components/copy-selector"; +import { getThemeByName, setThemeInstance } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; +import type { CopyTarget } from "@oh-my-pi/pi-coding-agent/modes/utils/copy-targets"; +import { setKeybindings } from "@oh-my-pi/pi-tui"; + +const UP = "\x1b[A"; +const DOWN = "\x1b[B"; +const ENTER = "\n"; +const CANCEL = "\x07"; // ctrl+g, remapped to tui.select.cancel below + +let darkTheme = await getThemeByName("dark"); + +// Flatten order (always expanded): msg:1, Block 1, Block 2, msg:2. +function makeRoots(): CopyTarget[] { + return [ + { + id: "msg:1", + label: "Newest message", + hint: "5 lines · 2 code", + preview: "newest-preview-text", + content: "FULL_MESSAGE", + copyMessage: "Copied last message to clipboard", + children: [ + { + id: "msg:1:code:0", + label: "Block 1", + hint: "ts", + language: "ts", + preview: "alpha()", + content: "BLOCK0", + copyMessage: "Copied block 1", + }, + { + id: "msg:1:code:1", + label: "Block 2", + hint: "py", + language: "python", + preview: "beta()", + content: "BLOCK1", + copyMessage: "Copied block 2", + }, + ], + }, + { + id: "msg:2", + label: "Older message", + hint: "3 lines", + preview: "older-text", + content: "OLDER", + copyMessage: "Copied message", + }, + ]; +} + +function render(component: CopySelectorComponent): string { + return stripVTControlCharacters(component.render(80).join("\n")); +} + +describe("CopySelectorComponent", () => { + beforeAll(async () => { + darkTheme = await getThemeByName("dark"); + if (!darkTheme) throw new Error("Failed to load dark theme"); + }); + + beforeEach(() => { + setThemeInstance(darkTheme!); + setKeybindings(KeybindingsManager.inMemory({ "tui.select.cancel": "ctrl+g" })); + }); + + afterEach(() => { + setKeybindings(KeybindingsManager.inMemory()); + vi.restoreAllMocks(); + }); + + it("renders an outlined tree with code blocks nested under their message", () => { + const out = render(new CopySelectorComponent(makeRoots(), { onPick: vi.fn(), onCancel: vi.fn() })); + expect(out).toContain("┌"); + expect(out).toContain("│"); + expect(out).toContain("Copy to clipboard"); + // Messages and their nested blocks are all visible (always expanded), + // connected with /tree-style branch glyphs. + expect(out).toContain("Newest message"); + expect(out).toContain("Block 1"); + expect(out).toContain("Block 2"); + expect(out).toContain("Older message"); + expect(out).toMatch(/[├└]/); + }); + + it("copies the message node itself on Enter", () => { + const onPick = vi.fn(); + const component = new CopySelectorComponent(makeRoots(), { onPick, onCancel: vi.fn() }); + + component.handleInput(ENTER); // cursor starts on the message node + + expect(onPick).toHaveBeenCalledTimes(1); + expect(onPick.mock.calls[0]![0].content).toBe("FULL_MESSAGE"); + }); + + it("navigates into a nested code block and copies it", () => { + const onPick = vi.fn(); + const component = new CopySelectorComponent(makeRoots(), { onPick, onCancel: vi.fn() }); + + component.handleInput(DOWN); // onto "Block 1" + component.handleInput(ENTER); + + expect(onPick).toHaveBeenCalledTimes(1); + expect(onPick.mock.calls[0]![0].content).toBe("BLOCK0"); + }); + + it("traverses past nested blocks to the older message, with the preview tracking the cursor", () => { + const component = new CopySelectorComponent(makeRoots(), { onPick: vi.fn(), onCancel: vi.fn() }); + + component.handleInput(DOWN); // Block 1 + expect(render(component)).toContain("alpha()"); + + component.handleInput(DOWN); // Block 2 + component.handleInput(DOWN); // Older message + expect(render(component)).toContain("older-text"); + + component.handleInput(UP); // back onto Block 2 + expect(render(component)).toContain("beta()"); + }); + + it("quits on the cancel key", () => { + const onCancel = vi.fn(); + const component = new CopySelectorComponent(makeRoots(), { onPick: vi.fn(), onCancel }); + + component.handleInput(CANCEL); + + expect(onCancel).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/coding-agent/test/modes/controllers/copy-command.test.ts b/packages/coding-agent/test/modes/controllers/copy-command.test.ts deleted file mode 100644 index b87f4d178..000000000 --- a/packages/coding-agent/test/modes/controllers/copy-command.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "bun:test"; -import { CommandController } from "@oh-my-pi/pi-coding-agent/modes/controllers/command-controller"; -import type { InteractiveModeContext } from "@oh-my-pi/pi-coding-agent/modes/types"; -import * as native from "@oh-my-pi/pi-natives"; - -function createController(options: { assistantText?: string; hasAssistantMessage?: boolean; handoffText?: string }) { - const showStatus = vi.fn(); - const showError = vi.fn(); - const ctx = { - session: { - getLastAssistantText: () => options.assistantText, - hasCopyCandidateAssistantMessage: () => options.hasAssistantMessage ?? options.assistantText !== undefined, - getLastVisibleHandoffText: () => options.handoffText, - }, - showStatus, - showError, - } as unknown as InteractiveModeContext; - - return { controller: new CommandController(ctx), showStatus, showError }; -} - -describe("/copy command", () => { - afterEach(() => { - vi.restoreAllMocks(); - }); - - it("falls back to the fresh handoff context when no assistant message exists", () => { - const copySpy = vi.spyOn(native, "copyToClipboard").mockImplementation(() => undefined); - const { controller, showStatus, showError } = createController({ - handoffText: "\n## Goal\nContinue\n", - }); - - controller.handleCopyCommand(); - - expect(copySpy).toHaveBeenCalledWith("\n## Goal\nContinue\n"); - expect(showStatus).toHaveBeenCalledWith("Copied handoff context to clipboard"); - expect(showError).not.toHaveBeenCalled(); - }); - - it("does not fall back to stale handoff context after a textless assistant response", () => { - const copySpy = vi.spyOn(native, "copyToClipboard").mockImplementation(() => undefined); - const { controller, showStatus, showError } = createController({ - hasAssistantMessage: true, - handoffText: "\n## Goal\nContinue\n", - }); - - controller.handleCopyCommand(); - - expect(copySpy).not.toHaveBeenCalled(); - expect(showStatus).not.toHaveBeenCalled(); - expect(showError).toHaveBeenCalledWith("No agent messages to copy yet."); - }); -}); diff --git a/packages/coding-agent/test/modes/utils/copy-targets.test.ts b/packages/coding-agent/test/modes/utils/copy-targets.test.ts new file mode 100644 index 000000000..6d11c91b1 --- /dev/null +++ b/packages/coding-agent/test/modes/utils/copy-targets.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it } from "bun:test"; +import type { AgentMessage } from "@oh-my-pi/pi-agent-core"; +import { + buildCopyTargets, + type CopySource, + type CopyTarget, + extractCodeBlocks, + extractLastCommand, +} from "@oh-my-pi/pi-coding-agent/modes/utils/copy-targets"; + +function source(overrides: Partial): CopySource { + return { + messages: [], + getLastVisibleHandoffText: () => undefined, + ...overrides, + }; +} + +function byId(targets: CopyTarget[], id: string): CopyTarget | undefined { + return targets.find(t => t.id === id); +} + +function assistantText(text: string): AgentMessage { + return { role: "assistant", content: [{ type: "text", text }] } as unknown as AgentMessage; +} + +function assistantCalls(toolCalls: Array<{ name: string; arguments: Record }>): AgentMessage { + return { + role: "assistant", + content: toolCalls.map((tc, i) => ({ type: "toolCall", id: `tc-${i}`, name: tc.name, arguments: tc.arguments })), + } as unknown as AgentMessage; +} + +describe("extractCodeBlocks", () => { + it("captures the language id and strips the trailing newline", () => { + expect(extractCodeBlocks("intro\n```ts\nconst x = 1;\n```\ntail")).toEqual([ + { lang: "ts", code: "const x = 1;" }, + ]); + }); + + it("returns blocks in document order with empty lang for bare fences", () => { + const blocks = extractCodeBlocks("```\nplain\n```\n\n```py\nprint(1)\n```"); + expect(blocks.map(b => b.lang)).toEqual(["", "py"]); + expect(blocks.map(b => b.code)).toEqual(["plain", "print(1)"]); + }); +}); + +describe("extractLastCommand", () => { + it("returns the most recent bash command, walking backwards", () => { + const messages = [ + assistantCalls([{ name: "bash", arguments: { command: "echo old" } }]), + assistantCalls([{ name: "read", arguments: { path: "x" } }]), + assistantCalls([ + { name: "bash", arguments: { command: "echo a" } }, + { name: "bash", arguments: { command: "echo b" } }, + ]), + ] as unknown as AgentMessage[]; + expect(extractLastCommand(messages)).toEqual({ kind: "bash", code: "echo b", language: "bash" }); + }); + + it("joins eval cell code and reports the cell language", () => { + const py = [ + assistantCalls([ + { name: "eval", arguments: { cells: [{ language: "py", code: "print(1)" }, { code: "print(2)" }] } }, + ]), + ] as unknown as AgentMessage[]; + expect(extractLastCommand(py)).toEqual({ kind: "eval", code: "print(1)\n\nprint(2)", language: "python" }); + + const js = [ + assistantCalls([{ name: "eval", arguments: { cells: [{ language: "js", code: "log(1)" }] } }]), + ] as unknown as AgentMessage[]; + expect(extractLastCommand(js)?.language).toBe("javascript"); + }); +}); + +describe("buildCopyTargets", () => { + it("lists assistant messages most-recent-first, drilling code-bearing ones", () => { + const newer = "Newer message\n```ts\nconst a = 1;\n```\nand\n```py\nprint(2)\n```"; + const targets = buildCopyTargets( + source({ + messages: [assistantText("Older message"), assistantText(newer)] as unknown as AgentMessage[], + }), + ); + + // Newest first. + expect(targets[0]?.id).toBe("msg:1"); + expect(targets[0]?.label).toBe("Newer message"); + expect(targets[1]?.id).toBe("msg:2"); + + // The newer message is itself a copy target (full text) AND a tree node + // exposing each code block as a child copy target. + const group = targets[0]!; + expect(group.content).toBe(newer); + expect(group.children?.map(c => c.label)).toEqual(["Block 1", "Block 2", "All 2 blocks"]); + expect(group.children?.[0]?.content).toBe("const a = 1;"); + expect(group.children?.[0]?.language).toBe("ts"); // drives preview syntax highlighting + expect(group.children?.at(-1)?.content).toBe("const a = 1;\n\nprint(2)"); + + // The older, code-free message is a leaf that copies its full text. + expect(targets[1]?.children).toBeUndefined(); + expect(targets[1]?.content).toBe("Older message"); + }); + + it("exposes a single-block message as content plus one block child (no 'all')", () => { + const targets = buildCopyTargets( + source({ messages: [assistantText("Just one\n```js\nfoo();\n```")] as unknown as AgentMessage[] }), + ); + const msg = byId(targets, "msg:1"); + expect(msg?.content).toBe("Just one\n```js\nfoo();\n```"); + expect(msg?.children?.map(c => c.label)).toEqual(["Block 1"]); + }); + + it("skips tool-only assistant turns and non-assistant messages", () => { + const messages = [ + { role: "user", content: [{ type: "text", text: "hi" }] }, + assistantCalls([{ name: "read", arguments: { path: "x" } }]), + assistantText("real answer"), + ] as unknown as AgentMessage[]; + const targets = buildCopyTargets(source({ messages })); + expect(targets.filter(t => t.id.startsWith("msg:")).map(t => t.label)).toEqual(["real answer"]); + }); + + it("falls back to handoff context only when there are no assistant messages", () => { + const withMessages = buildCopyTargets( + source({ + messages: [assistantText("answer")] as unknown as AgentMessage[], + getLastVisibleHandoffText: () => "", + }), + ); + expect(byId(withMessages, "handoff")).toBeUndefined(); + + const fresh = buildCopyTargets(source({ getLastVisibleHandoffText: () => "\nGoal" })); + expect(byId(fresh, "handoff")?.content).toBe("\nGoal"); + expect(byId(fresh, "handoff")?.copyMessage).toBe("Copied handoff context to clipboard"); + }); + + it("appends the most recent command as a top-level leaf", () => { + const targets = buildCopyTargets( + source({ + messages: [ + assistantText("answer"), + assistantCalls([{ name: "bash", arguments: { command: "ls -la" } }]), + ] as unknown as AgentMessage[], + }), + ); + const cmd = byId(targets, "cmd"); + expect(cmd?.label).toBe("Last bash command"); + expect(cmd?.content).toBe("ls -la"); + expect(cmd?.language).toBe("bash"); + }); +});