Merge PR #2950: feat(coding-agent): add copy affordance to completed /btw answers (@wolfiesch)
Adds 'c copy' to the completed /btw panel footer (alongside b branch / Esc dismiss), copying the sanitized visible answer to the clipboard. The copy shortcut is guarded by canCopyBtw + main-editor focus + empty editor.
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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.";
|
||||
|
||||
@@ -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<boolean> {
|
||||
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<boolean> {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -3828,6 +3828,14 @@ export class InteractiveMode implements InteractiveModeContext {
|
||||
return this.#btwController.handleBranch();
|
||||
}
|
||||
|
||||
canCopyBtw(): boolean {
|
||||
return this.#btwController.canCopy();
|
||||
}
|
||||
|
||||
handleBtwCopyKey(): Promise<boolean> {
|
||||
return this.#btwController.handleCopy();
|
||||
}
|
||||
|
||||
async handleBtwBranch(question: string, assistantMessage: AssistantMessage): Promise<void> {
|
||||
try {
|
||||
const result = await this.session.branchFromBtw(question, assistantMessage);
|
||||
|
||||
@@ -349,6 +349,8 @@ export interface InteractiveModeContext {
|
||||
handleBtwEscape(): boolean;
|
||||
handleBtwBranchKey(): Promise<boolean>;
|
||||
canBranchBtw(): boolean;
|
||||
canCopyBtw(): boolean;
|
||||
handleBtwCopyKey(): Promise<boolean>;
|
||||
handleBtwBranch(question: string, assistantMessage: AssistantMessage): Promise<void>;
|
||||
handleOmfgCommand(complaint: string): Promise<void>;
|
||||
hasActiveOmfg(): boolean;
|
||||
|
||||
@@ -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<string, string[]> = {
|
||||
@@ -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 };
|
||||
|
||||
@@ -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<RunEphemeralTurnResult>();
|
||||
@@ -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<RunEphemeralTurnResult>().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,
|
||||
|
||||
Reference in New Issue
Block a user