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.
This commit is contained in:
can1357
2026-06-06 15:15:54 +02:00
parent 6d99f2e740
commit 126f4e8dcf
11 changed files with 804 additions and 186 deletions
@@ -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),
];
}
}
@@ -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<void> {
const stats = this.ctx.session.getSessionStats();
const premiumRequests =
@@ -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<OAuthProvider>([
"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();
@@ -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<void> {
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();
}
+1 -1
View File
@@ -222,7 +222,6 @@ export interface InteractiveModeContext {
// Command handling
handleExportCommand(text: string): Promise<void>;
handleShareCommand(): Promise<void>;
handleCopyCommand(sub?: string): void;
handleTodoCommand(args: string): Promise<void>;
handleSessionCommand(): Promise<void>;
handleJobsCommand(): Promise<void>;
@@ -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<void>;
@@ -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;
}