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:
can1357
2026-06-20 23:56:24 +02:00
8 changed files with 240 additions and 13 deletions
@@ -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);
+2
View File
@@ -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;