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.
This commit is contained in:
Mario Zechner 2026-01-02 01:11:06 +01:00
parent 91c52de8be
commit d51770a63d
11 changed files with 208 additions and 8 deletions

View file

@ -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"

View file

@ -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");
},
});
}

View file

@ -95,6 +95,7 @@ function createNoOpUIContext(): HookUIContext {
custom: async () => undefined as never,
setEditorText: () => {},
getEditorText: () => "",
editor: async () => undefined,
get theme() {
return theme;
},

View file

@ -52,6 +52,7 @@ const noOpUIContext: HookUIContext = {
custom: async () => undefined as never,
setEditorText: () => {},
getEditorText: () => "",
editor: async () => undefined,
get theme() {
return theme;
},

View file

@ -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<string | undefined>;
/**
* Get the current theme for styling text with ANSI codes.
* Use theme.fg() and theme.bg() to style status text.

View file

@ -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();
}
}
}

View file

@ -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") {

View file

@ -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<string, LoadedCustomTool>;
@ -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<string | undefined> {
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.
*/

View file

@ -152,6 +152,25 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
return "";
},
async editor(title: string, prefill?: string): Promise<string | undefined> {
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;
},

View file

@ -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;

View file

@ -113,6 +113,7 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => {
custom: async () => undefined as never,
setEditorText: () => {},
getEditorText: () => "",
editor: async () => undefined,
get theme() {
return theme;
},