diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 622e4a8b1..e3a2a5301 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -264,6 +264,9 @@ - Fixed `/model` in the TUI to open the model setup picker again, leaving `/switch` as the temporary session model switcher ([#2933](https://github.com/can1357/oh-my-pi/issues/2933)). - Fixed OpenCode Go sessions recording per-request cost history so `/usage` can show local cap utilization. ([#2942](https://github.com/can1357/oh-my-pi/issues/2942)) +### Added + +- Added a copy affordance for completed `/btw` answers so users can copy the visible side-answer text before branching or dismissing the panel. ## [16.0.6] - 2026-06-18 diff --git a/packages/coding-agent/src/modes/components/btw-panel.ts b/packages/coding-agent/src/modes/components/btw-panel.ts index a1cd3ed42..0c8ba38bf 100644 --- a/packages/coding-agent/src/modes/components/btw-panel.ts +++ b/packages/coding-agent/src/modes/components/btw-panel.ts @@ -16,6 +16,7 @@ export class BtwPanelComponent extends Container { #state: BtwPanelState = "running"; #answer = ""; #errorMessage: string | undefined; + #visibleAnswer = ""; #closed = false; constructor(options: BtwPanelComponentOptions) { @@ -28,12 +29,14 @@ export class BtwPanelComponent extends Container { appendText(delta: string): void { if (!delta || this.#closed) return; this.#answer += delta; + this.#visibleAnswer = replaceTabs(this.#answer).trim(); this.#rebuild(); } setAnswer(text: string): void { if (this.#closed) return; this.#answer = text; + this.#visibleAnswer = replaceTabs(text).trim(); this.#rebuild(); } @@ -59,7 +62,16 @@ export class BtwPanelComponent extends Container { } isBranchable(): boolean { - return this.#state === "complete" && this.#answer.trim().length > 0; + return this.isCopyable(); + } + + isCopyable(): boolean { + return this.#state === "complete" && this.#visibleAnswer.length > 0; + } + + getCopyText(): string | undefined { + if (!this.isCopyable()) return undefined; + return this.#visibleAnswer; } close(): void { @@ -89,7 +101,7 @@ export class BtwPanelComponent extends Container { case "running": return theme.fg("muted", "Esc cancel /btw"); case "complete": - return theme.fg("muted", this.isBranchable() ? "b branch · Esc dismiss" : "Esc dismiss"); + return theme.fg("muted", this.isCopyable() ? "c copy · b branch to chat · Esc dismiss" : "Esc dismiss"); case "aborted": return theme.fg("warning", `${theme.status.warning} Cancelled · Esc dismiss`); case "error": @@ -101,7 +113,7 @@ export class BtwPanelComponent extends Container { if (this.#state === "error") { return new Text(theme.fg("error", replaceTabs(this.#errorMessage ?? "Unknown error")), 1, 0); } - const text = replaceTabs(this.#answer).trim(); + const text = this.#visibleAnswer; if (!text) { const waiting = this.#state === "running" ? `${theme.status.pending} Waiting for response…` : "No text returned."; diff --git a/packages/coding-agent/src/modes/controllers/btw-controller.ts b/packages/coding-agent/src/modes/controllers/btw-controller.ts index 11ac6e615..5013a5f99 100644 --- a/packages/coding-agent/src/modes/controllers/btw-controller.ts +++ b/packages/coding-agent/src/modes/controllers/btw-controller.ts @@ -1,6 +1,7 @@ import type { AssistantMessage } from "@oh-my-pi/pi-ai"; import { prompt } from "@oh-my-pi/pi-utils"; import btwUserPrompt from "../../prompts/system/btw-user.md" with { type: "text" }; +import { copyToClipboard } from "../../utils/clipboard"; import { BtwPanelComponent } from "../components/btw-panel"; import type { InteractiveModeContext } from "../types"; @@ -39,6 +40,8 @@ export class BtwController { #lastAssistantMessage: AssistantMessage | undefined; #lastLeafId: string | null | undefined; #branchInFlight = false; + #lastCopyText: string | undefined; + #copyInFlight = false; constructor(private readonly ctx: InteractiveModeContext) {} @@ -58,6 +61,27 @@ export class BtwController { ); } + canCopy(): boolean { + return ( + !this.#copyInFlight && this.#activeRequest?.component.isCopyable() === true && this.#lastCopyText !== undefined + ); + } + + async handleCopy(): Promise { + if (!this.canCopy() || this.#lastCopyText === undefined) return false; + this.#copyInFlight = true; + try { + await copyToClipboard(this.#lastCopyText); + this.ctx.showStatus("Copied /btw answer to clipboard"); + return true; + } catch (error) { + this.ctx.showError(error instanceof Error ? error.message : String(error)); + return true; + } finally { + this.#copyInFlight = false; + } + } + async handleBranch(): Promise { if (!this.canBranch() || !this.#lastQuestion || !this.#lastAssistantMessage) return false; this.#branchInFlight = true; @@ -123,17 +147,17 @@ export class BtwController { if (!this.#isActiveRequest(request)) { return; } - if (replyText) { - request.component.setAnswer(replyText); - } + request.component.setAnswer(replyText); request.component.markComplete(); - if (request.component.isBranchable()) { + const copyText = request.component.getCopyText(); + if (copyText !== undefined) { this.#lastQuestion = request.question; this.#lastReplyText = replyText; + this.#lastCopyText = copyText; this.#lastAssistantMessage = assistantMessageWithReplyText(assistantMessage, replyText); this.#lastLeafId = request.leafId; } else { - this.#clearBranchState(); + this.#clearCompletedState(); } } catch (error) { if (!this.#isActiveRequest(request)) { @@ -151,7 +175,7 @@ export class BtwController { const request = this.#activeRequest; if (!request) return; this.#activeRequest = undefined; - this.#clearBranchState(); + this.#clearCompletedState(); if (options.abort) { request.abortController.abort(); } @@ -160,10 +184,11 @@ export class BtwController { this.ctx.ui.requestRender(); } - #clearBranchState(): void { + #clearCompletedState(): void { this.#lastQuestion = undefined; this.#lastReplyText = undefined; this.#lastAssistantMessage = undefined; + this.#lastCopyText = undefined; this.#lastLeafId = undefined; } diff --git a/packages/coding-agent/src/modes/controllers/input-controller.ts b/packages/coding-agent/src/modes/controllers/input-controller.ts index 3cf7cd320..0406a30e4 100644 --- a/packages/coding-agent/src/modes/controllers/input-controller.ts +++ b/packages/coding-agent/src/modes/controllers/input-controller.ts @@ -99,6 +99,7 @@ export class InputController { #enhancedPaste?: EnhancedPasteController; #focusedLeftTapListenerInstalled = false; #btwBranchListenerInstalled = false; + #btwCopyListenerInstalled = false; // Tap counter for the double-← gesture; reset whenever a quiet gap // (>= LEFT_DOUBLE_TAP_MAX_GAP_MS) starts a fresh sequence. See // #detectLeftDoubleTap. @@ -174,6 +175,17 @@ export class InputController { return { consume: true }; }); } + if (!this.#btwCopyListenerInstalled) { + this.#btwCopyListenerInstalled = true; + this.ctx.ui.addInputListener(data => { + if (!matchesKey(data, "c")) return undefined; + if (!this.ctx.canCopyBtw()) return undefined; + if (this.ctx.ui.getFocused() !== this.ctx.editor) return undefined; + if (this.ctx.editor.getText().trim()) return undefined; + void this.ctx.handleBtwCopyKey(); + return { consume: true }; + }); + } this.ctx.editor.onEscape = () => { // Active context maintenance owns Esc: auto/manual compaction, // handoff generation, and auto-retry backoff all advertise diff --git a/packages/coding-agent/src/modes/interactive-mode.ts b/packages/coding-agent/src/modes/interactive-mode.ts index 8b96524fe..03198b342 100644 --- a/packages/coding-agent/src/modes/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive-mode.ts @@ -3828,6 +3828,14 @@ export class InteractiveMode implements InteractiveModeContext { return this.#btwController.handleBranch(); } + canCopyBtw(): boolean { + return this.#btwController.canCopy(); + } + + handleBtwCopyKey(): Promise { + return this.#btwController.handleCopy(); + } + async handleBtwBranch(question: string, assistantMessage: AssistantMessage): Promise { try { const result = await this.session.branchFromBtw(question, assistantMessage); diff --git a/packages/coding-agent/src/modes/types.ts b/packages/coding-agent/src/modes/types.ts index 339c22080..ec1efcc1f 100644 --- a/packages/coding-agent/src/modes/types.ts +++ b/packages/coding-agent/src/modes/types.ts @@ -349,6 +349,8 @@ export interface InteractiveModeContext { handleBtwEscape(): boolean; handleBtwBranchKey(): Promise; canBranchBtw(): boolean; + canCopyBtw(): boolean; + handleBtwCopyKey(): Promise; handleBtwBranch(question: string, assistantMessage: AssistantMessage): Promise; handleOmfgCommand(complaint: string): Promise; hasActiveOmfg(): boolean; diff --git a/packages/coding-agent/test/input-controller-keybindings.test.ts b/packages/coding-agent/test/input-controller-keybindings.test.ts index 2f736cbf7..89fbc5edd 100644 --- a/packages/coding-agent/test/input-controller-keybindings.test.ts +++ b/packages/coding-agent/test/input-controller-keybindings.test.ts @@ -34,6 +34,21 @@ type FakeEditor = { imageLinks?: (string | undefined)[]; }; +type InputListenerResult = { consume: boolean } | undefined; +type InputListener = (data: string) => InputListenerResult; + +function dispatchInput(listeners: InputListener[], data: string): InputListenerResult { + for (const listener of listeners) { + const result = listener(data); + if (result) return result; + } + return undefined; +} + +function registeredInputListeners(addInputListener: Mock<(listener: InputListener) => void>): InputListener[] { + return addInputListener.mock.calls.map(call => call[0]); +} + async function createContext() { let editorText = ""; const keyMap: Record = { @@ -53,7 +68,10 @@ async function createContext() { const resetDisplay = vi.fn(); const showModelSelector = vi.fn(); const requestRender = vi.fn(); - const addInputListener = vi.fn(); + let focused: unknown; + const addInputListener = vi.fn((listener: InputListener) => { + void listener; + }); const addStartListener = vi.fn(); const terminalWrite = vi.fn(); const prompt = vi.fn(async () => {}); @@ -73,7 +91,9 @@ async function createContext() { }; const updatePendingMessagesDisplay = vi.fn(); const handleBtwBranchKey = vi.fn(async () => true); + const handleBtwCopyKey = vi.fn(async () => true); const canBranchBtw = vi.fn(() => false); + const canCopyBtw = vi.fn(() => false); const editor: FakeEditor = { setText(text: string) { editorText = text; @@ -89,6 +109,7 @@ async function createContext() { setCustomKeyHandler, clearCustomKeyHandlers, }; + focused = editor; const ctx = { editor: editor as unknown as InteractiveModeContext["editor"], ui: { @@ -96,6 +117,7 @@ async function createContext() { resetDisplay, addInputListener, addStartListener, + getFocused: vi.fn(() => focused), terminal: { write: terminalWrite }, } as unknown as InteractiveModeContext["ui"], loadingAnimation: undefined, @@ -156,6 +178,8 @@ async function createContext() { hasActiveBtw: vi.fn(() => false), handleBtwBranchKey, canBranchBtw, + canCopyBtw, + handleBtwCopyKey, showError: vi.fn(), showStatus: vi.fn(), } as unknown as InteractiveModeContext; @@ -165,6 +189,9 @@ async function createContext() { ctx, editor, customHandlers, + setFocused(target: unknown) { + focused = target; + }, spies: { setActionKeys, showModelSelector, @@ -177,6 +204,8 @@ async function createContext() { handleBtwBranchKey, addInputListener, canBranchBtw, + handleBtwCopyKey, + canCopyBtw, }, }; } @@ -329,6 +358,56 @@ describe("InputController keybinding setup", () => { expect(result).toBeUndefined(); expect(spies.handleBtwBranchKey).not.toHaveBeenCalled(); }); + + it("routes c to copy a copyable /btw panel when the editor is empty", async () => { + const { InputController, ctx, spies } = await createContext(); + (ctx.canCopyBtw as unknown as { mockReturnValue(value: boolean): void }).mockReturnValue(true); + const controller = new InputController(ctx); + + controller.setupKeyHandlers(); + const result = dispatchInput(registeredInputListeners(spies.addInputListener), "c"); + + expect(result).toEqual({ consume: true }); + expect(spies.handleBtwCopyKey).toHaveBeenCalledTimes(1); + }); + + it("lets c fall through while the editor has draft text", async () => { + const { InputController, ctx, editor, spies } = await createContext(); + (ctx.canCopyBtw as unknown as { mockReturnValue(value: boolean): void }).mockReturnValue(true); + editor.setText("continue this draft"); + const controller = new InputController(ctx); + + controller.setupKeyHandlers(); + const result = dispatchInput(registeredInputListeners(spies.addInputListener), "c"); + + expect(result).toBeUndefined(); + expect(spies.handleBtwCopyKey).not.toHaveBeenCalled(); + }); + + it("lets c fall through when /btw is not copyable", async () => { + const { InputController, ctx, spies } = await createContext(); + const controller = new InputController(ctx); + + controller.setupKeyHandlers(); + const result = dispatchInput(registeredInputListeners(spies.addInputListener), "c"); + + expect(result).toBeUndefined(); + expect(spies.handleBtwCopyKey).not.toHaveBeenCalled(); + }); + + it("lets c fall through while another input is focused", async () => { + const { InputController, ctx, setFocused, spies } = await createContext(); + (ctx.canCopyBtw as unknown as { mockReturnValue(value: boolean): void }).mockReturnValue(true); + setFocused({ pasteText: vi.fn() }); + const controller = new InputController(ctx); + + controller.setupKeyHandlers(); + const result = dispatchInput(registeredInputListeners(spies.addInputListener), "c"); + + expect(result).toBeUndefined(); + expect(spies.handleBtwCopyKey).not.toHaveBeenCalled(); + }); + it("empty Enter aborts the active stream when queued messages are pending", async () => { const { InputController, ctx, editor, spies } = await createContext(); const session = ctx.session as unknown as { isStreaming: boolean; queuedMessageCount: number }; diff --git a/packages/coding-agent/test/modes/controllers/btw-controller.test.ts b/packages/coding-agent/test/modes/controllers/btw-controller.test.ts index 5e1194e1e..4585aafea 100644 --- a/packages/coding-agent/test/modes/controllers/btw-controller.test.ts +++ b/packages/coding-agent/test/modes/controllers/btw-controller.test.ts @@ -1,10 +1,11 @@ -import { beforeAll, describe, expect, it, vi } from "bun:test"; +import { afterEach, beforeAll, describe, expect, it, vi } from "bun:test"; import type { AssistantMessage, Usage } from "@oh-my-pi/pi-ai"; import { BtwPanelComponent } from "@oh-my-pi/pi-coding-agent/modes/components/btw-panel"; import { BtwController } from "@oh-my-pi/pi-coding-agent/modes/controllers/btw-controller"; import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; import type { InteractiveModeContext } from "@oh-my-pi/pi-coding-agent/modes/types"; -import { Container, type TUI } from "@oh-my-pi/pi-tui"; +import * as clipboard from "@oh-my-pi/pi-coding-agent/utils/clipboard"; +import { Container, replaceTabs, type TUI } from "@oh-my-pi/pi-tui"; const usage: Usage = { input: 0, @@ -63,6 +64,9 @@ function makeCtx(session: InteractiveModeContext["session"], btwContainer = new }, } as unknown as InteractiveModeContext & { setTestLeafId(nextLeafId: string | null): void }; } +afterEach(() => { + vi.restoreAllMocks(); +}); beforeAll(async () => { await initTheme(); @@ -84,6 +88,19 @@ describe("BtwPanelComponent", () => { panel.setAnswer("Answer"); expect(panel.isBranchable()).toBe(true); }); + + it("advertises copy and branch actions after a complete non-empty answer", () => { + const ui = { requestRender: vi.fn(), requestComponentRender: vi.fn() } as unknown as TUI; + const panel = new BtwPanelComponent({ question: "Question?", tui: ui }); + + panel.setAnswer("Answer"); + panel.markComplete(); + + const rendered = Bun.stripANSI(panel.render(120).join("\n")); + expect(rendered).toContain("c copy"); + expect(rendered).toContain("b branch to chat"); + expect(rendered).toContain("Esc dismiss"); + }); }); describe("BtwController", () => { @@ -110,6 +127,25 @@ describe("BtwController", () => { expect(controller.hasActiveRequest()).toBe(true); }); + it("renders completed /btw answers with copy and branch affordances", async () => { + const runEphemeralTurn = vi.fn(async () => ({ + replyText: "Answer", + assistantMessage: createAssistantMessage("Answer"), + })); + const btwContainer = new Container(); + const ctx = makeCtx(makeFakeSession(runEphemeralTurn), btwContainer); + const controller = new BtwController(ctx); + + await controller.start("What changed?"); + await drainBtwRequest(); + + const panel = btwContainer.children[0] as BtwPanelComponent | undefined; + expect(panel).toBeDefined(); + const rendered = Bun.stripANSI(panel?.render(120).join("\n") ?? ""); + expect(rendered).toContain("c copy"); + expect(rendered).toContain("b branch to chat"); + }); + it("replaces a previous request by aborting it before issuing the next runEphemeralTurn", async () => { const signals: AbortSignal[] = []; const first = Promise.withResolvers(); @@ -306,6 +342,56 @@ describe("BtwController", () => { }); }); + it("copies the sanitized visible reply text after a complete non-empty reply", async () => { + const copySpy = vi.spyOn(clipboard, "copyToClipboard").mockResolvedValue(undefined); + const runEphemeralTurn = vi.fn(async (args: RunEphemeralTurnArgs) => { + args.onTextDelta?.("duplicate streaming draft"); + return { + replyText: " Visible\tanswer\n\nfrom /btw ", + assistantMessage: createAssistantMessage("raw assistant payload"), + }; + }); + const ctx = makeCtx(makeFakeSession(runEphemeralTurn)); + const controller = new BtwController(ctx); + + await controller.start("Question?"); + await drainBtwRequest(); + + expect(controller.canCopy()).toBe(true); + expect(await controller.handleCopy()).toBe(true); + expect(copySpy).toHaveBeenCalledWith(replaceTabs("Visible\tanswer\n\nfrom /btw")); + expect(ctx.showStatus).toHaveBeenCalledWith("Copied /btw answer to clipboard"); + }); + + it("does not copy running, empty, or errored /btw answers", async () => { + const copySpy = vi.spyOn(clipboard, "copyToClipboard").mockResolvedValue(undefined); + + const runningRun = vi.fn(async () => Promise.withResolvers().promise); + const runningController = new BtwController(makeCtx(makeFakeSession(runningRun))); + await runningController.start("Question?"); + expect(runningController.canCopy()).toBe(false); + expect(await runningController.handleCopy()).toBe(false); + runningController.dispose(); + + const emptyRun = vi.fn(async () => ({ replyText: " ", assistantMessage: createAssistantMessage(" ") })); + const emptyController = new BtwController(makeCtx(makeFakeSession(emptyRun))); + await emptyController.start("Question?"); + await drainBtwRequest(); + expect(emptyController.canCopy()).toBe(false); + expect(await emptyController.handleCopy()).toBe(false); + + const erroredRun = vi.fn(async () => { + throw new Error("boom"); + }); + const erroredController = new BtwController(makeCtx(makeFakeSession(erroredRun))); + await erroredController.start("Question?"); + await drainBtwRequest(); + expect(erroredController.canCopy()).toBe(false); + expect(await erroredController.handleCopy()).toBe(false); + + expect(copySpy).not.toHaveBeenCalled(); + }); + it("branches the sanitized reply text without native replay payload metadata", async () => { const providerPayload = { type: "openaiResponsesHistory" as const,