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:
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -392,17 +392,9 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
|
||||
},
|
||||
{
|
||||
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("");
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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: "<handoff-context>\n## Goal\nContinue\n</handoff-context>",
|
||||
});
|
||||
|
||||
controller.handleCopyCommand();
|
||||
|
||||
expect(copySpy).toHaveBeenCalledWith("<handoff-context>\n## Goal\nContinue\n</handoff-context>");
|
||||
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: "<handoff-context>\n## Goal\nContinue\n</handoff-context>",
|
||||
});
|
||||
|
||||
controller.handleCopyCommand();
|
||||
|
||||
expect(copySpy).not.toHaveBeenCalled();
|
||||
expect(showStatus).not.toHaveBeenCalled();
|
||||
expect(showError).toHaveBeenCalledWith("No agent messages to copy yet.");
|
||||
});
|
||||
});
|
||||
@@ -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>): 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<string, unknown> }>): 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: () => "<handoff>",
|
||||
}),
|
||||
);
|
||||
expect(byId(withMessages, "handoff")).toBeUndefined();
|
||||
|
||||
const fresh = buildCopyTargets(source({ getLastVisibleHandoffText: () => "<handoff>\nGoal" }));
|
||||
expect(byId(fresh, "handoff")?.content).toBe("<handoff>\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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user