From d51770a63dd86e04def39a23b80bd00e9b4a360c Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Fri, 2 Jan 2026 01:11:06 +0100 Subject: [PATCH] fix(coding-agent): prevent full re-renders during write tool streaming Move line count from header to footer to avoid changing the first line during streaming, which was triggering full screen re-renders in the TUI's differential rendering logic. --- packages/coding-agent/docs/hooks.md | 7 +- .../coding-agent/examples/hooks/handoff.ts | 14 ++- .../src/core/custom-tools/loader.ts | 1 + .../coding-agent/src/core/hooks/runner.ts | 1 + packages/coding-agent/src/core/hooks/types.ts | 9 ++ .../interactive/components/hook-editor.ts | 118 ++++++++++++++++++ .../interactive/components/tool-execution.ts | 5 +- .../src/modes/interactive/interactive-mode.ts | 40 ++++++ .../coding-agent/src/modes/rpc/rpc-mode.ts | 19 +++ .../coding-agent/src/modes/rpc/rpc-types.ts | 1 + .../test/compaction-hooks.test.ts | 1 + 11 files changed, 208 insertions(+), 8 deletions(-) create mode 100644 packages/coding-agent/src/modes/interactive/components/hook-editor.ts diff --git a/packages/coding-agent/docs/hooks.md b/packages/coding-agent/docs/hooks.md index 50395e3f..9f5f9ed0 100644 --- a/packages/coding-agent/docs/hooks.md +++ b/packages/coding-agent/docs/hooks.md @@ -405,10 +405,15 @@ const choice = await ctx.ui.select("Pick one:", ["A", "B", "C"]); const ok = await ctx.ui.confirm("Delete?", "This cannot be undone"); // Returns true or false -// Text input +// Text input (single line) const name = await ctx.ui.input("Name:", "placeholder"); // Returns string or undefined if cancelled +// Multi-line editor (with Ctrl+G for external editor) +const text = await ctx.ui.editor("Edit prompt:", "prefilled text"); +// Returns edited text or undefined if cancelled (Escape) +// Ctrl+Enter to submit, Ctrl+G to open $VISUAL or $EDITOR + // Notification (non-blocking) ctx.ui.notify("Done!", "info"); // "info" | "warning" | "error" diff --git a/packages/coding-agent/examples/hooks/handoff.ts b/packages/coding-agent/examples/hooks/handoff.ts index 75d9b8d2..8817947f 100644 --- a/packages/coding-agent/examples/hooks/handoff.ts +++ b/packages/coding-agent/examples/hooks/handoff.ts @@ -124,6 +124,14 @@ export default function (pi: HookAPI) { return; } + // Let user edit the generated prompt + const editedPrompt = await ctx.ui.editor("Edit handoff prompt (ctrl+enter to submit, esc to cancel)", result); + + if (editedPrompt === undefined) { + ctx.ui.notify("Cancelled", "info"); + return; + } + // Create new session with parent tracking const newSessionResult = await ctx.newSession({ parentSession: currentSessionFile, @@ -134,9 +142,9 @@ export default function (pi: HookAPI) { return; } - // Set the generated prompt as a draft in the editor - ctx.ui.setEditorText(result); - ctx.ui.notify("Handoff ready. Review the prompt and submit when ready.", "info"); + // Set the edited prompt in the main editor for submission + ctx.ui.setEditorText(editedPrompt); + ctx.ui.notify("Handoff ready. Submit when ready.", "info"); }, }); } diff --git a/packages/coding-agent/src/core/custom-tools/loader.ts b/packages/coding-agent/src/core/custom-tools/loader.ts index ee81d603..be936c03 100644 --- a/packages/coding-agent/src/core/custom-tools/loader.ts +++ b/packages/coding-agent/src/core/custom-tools/loader.ts @@ -95,6 +95,7 @@ function createNoOpUIContext(): HookUIContext { custom: async () => undefined as never, setEditorText: () => {}, getEditorText: () => "", + editor: async () => undefined, get theme() { return theme; }, diff --git a/packages/coding-agent/src/core/hooks/runner.ts b/packages/coding-agent/src/core/hooks/runner.ts index d20bb170..56bb53a2 100644 --- a/packages/coding-agent/src/core/hooks/runner.ts +++ b/packages/coding-agent/src/core/hooks/runner.ts @@ -52,6 +52,7 @@ const noOpUIContext: HookUIContext = { custom: async () => undefined as never, setEditorText: () => {}, getEditorText: () => "", + editor: async () => undefined, get theme() { return theme; }, diff --git a/packages/coding-agent/src/core/hooks/types.ts b/packages/coding-agent/src/core/hooks/types.ts index a15f2e7d..e2317d2d 100644 --- a/packages/coding-agent/src/core/hooks/types.ts +++ b/packages/coding-agent/src/core/hooks/types.ts @@ -119,6 +119,15 @@ export interface HookUIContext { */ getEditorText(): string; + /** + * Show a multi-line editor for text editing. + * Supports Ctrl+G to open external editor ($VISUAL or $EDITOR). + * @param title - Title describing what is being edited + * @param prefill - Optional initial text + * @returns Edited text, or undefined if cancelled (Escape) + */ + editor(title: string, prefill?: string): Promise; + /** * Get the current theme for styling text with ANSI codes. * Use theme.fg() and theme.bg() to style status text. diff --git a/packages/coding-agent/src/modes/interactive/components/hook-editor.ts b/packages/coding-agent/src/modes/interactive/components/hook-editor.ts new file mode 100644 index 00000000..6efc67cd --- /dev/null +++ b/packages/coding-agent/src/modes/interactive/components/hook-editor.ts @@ -0,0 +1,118 @@ +/** + * Multi-line editor component for hooks. + * Supports Ctrl+G for external editor. + */ + +import { spawnSync } from "node:child_process"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { Container, Editor, isCtrlG, isEscape, Spacer, Text, type TUI } from "@mariozechner/pi-tui"; +import { getEditorTheme, theme } from "../theme/theme.js"; +import { DynamicBorder } from "./dynamic-border.js"; + +export class HookEditorComponent extends Container { + private editor: Editor; + private onSubmitCallback: (value: string) => void; + private onCancelCallback: () => void; + private tui: TUI; + + constructor( + tui: TUI, + title: string, + prefill: string | undefined, + onSubmit: (value: string) => void, + onCancel: () => void, + ) { + super(); + + this.tui = tui; + this.onSubmitCallback = onSubmit; + this.onCancelCallback = onCancel; + + // Add top border + this.addChild(new DynamicBorder()); + this.addChild(new Spacer(1)); + + // Add title + this.addChild(new Text(theme.fg("accent", title), 1, 0)); + this.addChild(new Spacer(1)); + + // Create editor + this.editor = new Editor(getEditorTheme()); + if (prefill) { + this.editor.setText(prefill); + } + this.addChild(this.editor); + + this.addChild(new Spacer(1)); + + // Add hint + const hasExternalEditor = !!(process.env.VISUAL || process.env.EDITOR); + const hint = hasExternalEditor + ? "ctrl+enter submit esc cancel ctrl+g external editor" + : "ctrl+enter submit esc cancel"; + this.addChild(new Text(theme.fg("dim", hint), 1, 0)); + + this.addChild(new Spacer(1)); + + // Add bottom border + this.addChild(new DynamicBorder()); + } + + handleInput(keyData: string): void { + // Ctrl+Enter to submit + if (keyData === "\x1b[13;5u" || keyData === "\x1b[27;5;13~") { + this.onSubmitCallback(this.editor.getText()); + return; + } + + // Escape to cancel + if (isEscape(keyData)) { + this.onCancelCallback(); + return; + } + + // Ctrl+G for external editor + if (isCtrlG(keyData)) { + this.openExternalEditor(); + return; + } + + // Forward to editor + this.editor.handleInput(keyData); + } + + private openExternalEditor(): void { + const editorCmd = process.env.VISUAL || process.env.EDITOR; + if (!editorCmd) { + return; + } + + const currentText = this.editor.getText(); + const tmpFile = path.join(os.tmpdir(), `pi-hook-editor-${Date.now()}.md`); + + try { + fs.writeFileSync(tmpFile, currentText, "utf-8"); + this.tui.stop(); + + const [editor, ...editorArgs] = editorCmd.split(" "); + const result = spawnSync(editor, [...editorArgs, tmpFile], { + stdio: "inherit", + }); + + if (result.status === 0) { + const newContent = fs.readFileSync(tmpFile, "utf-8").replace(/\n$/, ""); + this.editor.setText(newContent); + } + } finally { + try { + fs.unlinkSync(tmpFile); + } catch { + // Ignore cleanup errors + } + this.tui.start(); + this.tui.requestRender(); + } + } +} diff --git a/packages/coding-agent/src/modes/interactive/components/tool-execution.ts b/packages/coding-agent/src/modes/interactive/components/tool-execution.ts index 453d56ec..5e77e974 100644 --- a/packages/coding-agent/src/modes/interactive/components/tool-execution.ts +++ b/packages/coding-agent/src/modes/interactive/components/tool-execution.ts @@ -441,9 +441,6 @@ export class ToolExecutionComponent extends Container { theme.fg("toolTitle", theme.bold("write")) + " " + (path ? theme.fg("accent", path) : theme.fg("toolOutput", "...")); - if (totalLines > 10) { - text += ` (${totalLines} lines)`; - } if (fileContent) { const maxLines = this.expanded ? lines.length : 10; @@ -456,7 +453,7 @@ export class ToolExecutionComponent extends Container { .map((line: string) => (lang ? replaceTabs(line) : theme.fg("toolOutput", replaceTabs(line)))) .join("\n"); if (remaining > 0) { - text += theme.fg("toolOutput", `\n... (${remaining} more lines)`); + text += theme.fg("toolOutput", `\n... (${remaining} more lines, ${totalLines} total)`); } } } else if (this.toolName === "edit") { diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 663cc09a..536b0348 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -44,6 +44,7 @@ import { CompactionSummaryMessageComponent } from "./components/compaction-summa import { CustomEditor } from "./components/custom-editor.js"; import { DynamicBorder } from "./components/dynamic-border.js"; import { FooterComponent } from "./components/footer.js"; +import { HookEditorComponent } from "./components/hook-editor.js"; import { HookInputComponent } from "./components/hook-input.js"; import { HookMessageComponent } from "./components/hook-message.js"; import { HookSelectorComponent } from "./components/hook-selector.js"; @@ -132,6 +133,7 @@ export class InteractiveMode { // Hook UI state private hookSelector: HookSelectorComponent | undefined = undefined; private hookInput: HookInputComponent | undefined = undefined; + private hookEditor: HookEditorComponent | undefined = undefined; // Custom tools for custom rendering private customTools: Map; @@ -375,6 +377,7 @@ export class InteractiveMode { custom: (factory) => this.showHookCustom(factory), setEditorText: (text) => this.editor.setText(text), getEditorText: () => this.editor.getText(), + editor: (title, prefill) => this.showHookEditor(title, prefill), get theme() { return theme; }, @@ -624,6 +627,43 @@ export class InteractiveMode { this.ui.requestRender(); } + /** + * Show a multi-line editor for hooks (with Ctrl+G support). + */ + private showHookEditor(title: string, prefill?: string): Promise { + return new Promise((resolve) => { + this.hookEditor = new HookEditorComponent( + this.ui, + title, + prefill, + (value) => { + this.hideHookEditor(); + resolve(value); + }, + () => { + this.hideHookEditor(); + resolve(undefined); + }, + ); + + this.editorContainer.clear(); + this.editorContainer.addChild(this.hookEditor); + this.ui.setFocus(this.hookEditor); + this.ui.requestRender(); + }); + } + + /** + * Hide the hook editor. + */ + private hideHookEditor(): void { + this.editorContainer.clear(); + this.editorContainer.addChild(this.editor); + this.hookEditor = undefined; + this.ui.setFocus(this.editor); + this.ui.requestRender(); + } + /** * Show a notification for hooks. */ diff --git a/packages/coding-agent/src/modes/rpc/rpc-mode.ts b/packages/coding-agent/src/modes/rpc/rpc-mode.ts index 6d863230..84135753 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-mode.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-mode.ts @@ -152,6 +152,25 @@ export async function runRpcMode(session: AgentSession): Promise { return ""; }, + async editor(title: string, prefill?: string): Promise { + const id = crypto.randomUUID(); + return new Promise((resolve, reject) => { + pendingHookRequests.set(id, { + resolve: (response: RpcHookUIResponse) => { + if ("cancelled" in response && response.cancelled) { + resolve(undefined); + } else if ("value" in response) { + resolve(response.value); + } else { + resolve(undefined); + } + }, + reject, + }); + output({ type: "hook_ui_request", id, method: "editor", title, prefill } as RpcHookUIRequest); + }); + }, + get theme() { return theme; }, diff --git a/packages/coding-agent/src/modes/rpc/rpc-types.ts b/packages/coding-agent/src/modes/rpc/rpc-types.ts index 32d2feac..a58b86c9 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-types.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-types.ts @@ -175,6 +175,7 @@ export type RpcHookUIRequest = | { type: "hook_ui_request"; id: string; method: "select"; title: string; options: string[] } | { type: "hook_ui_request"; id: string; method: "confirm"; title: string; message: string } | { type: "hook_ui_request"; id: string; method: "input"; title: string; placeholder?: string } + | { type: "hook_ui_request"; id: string; method: "editor"; title: string; prefill?: string } | { type: "hook_ui_request"; id: string; diff --git a/packages/coding-agent/test/compaction-hooks.test.ts b/packages/coding-agent/test/compaction-hooks.test.ts index d5bcbb15..5d5a5130 100644 --- a/packages/coding-agent/test/compaction-hooks.test.ts +++ b/packages/coding-agent/test/compaction-hooks.test.ts @@ -113,6 +113,7 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => { custom: async () => undefined as never, setEditorText: () => {}, getEditorText: () => "", + editor: async () => undefined, get theme() { return theme; },