mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 21:03:19 +00:00
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:
parent
91c52de8be
commit
d51770a63d
11 changed files with 208 additions and 8 deletions
|
|
@ -95,6 +95,7 @@ function createNoOpUIContext(): HookUIContext {
|
|||
custom: async () => undefined as never,
|
||||
setEditorText: () => {},
|
||||
getEditorText: () => "",
|
||||
editor: async () => undefined,
|
||||
get theme() {
|
||||
return theme;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ const noOpUIContext: HookUIContext = {
|
|||
custom: async () => undefined as never,
|
||||
setEditorText: () => {},
|
||||
getEditorText: () => "",
|
||||
editor: async () => undefined,
|
||||
get theme() {
|
||||
return theme;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue