From 3eca0813bba664483b10669f28adb22e9593f7a3 Mon Sep 17 00:00:00 2001 From: roboomp Date: Sat, 18 Jul 2026 00:20:15 +0000 Subject: [PATCH] fix(tui): preserved callback-driven full renders Kept full-root rendering as the default after input and introduced an explicit host opt-in for stable-focus subtree repainting. The coding-agent default composer opts in, while extension-provided editors and other TUI consumers retain callback-safe full composition. --- .../src/modes/interactive-mode.ts | 2 + packages/tui/CHANGELOG.md | 2 +- packages/tui/src/tui.ts | 20 ++++++++-- packages/tui/test/component-render.test.ts | 40 +++++++++++++++++++ 4 files changed, 59 insertions(+), 5 deletions(-) diff --git a/packages/coding-agent/src/modes/interactive-mode.ts b/packages/coding-agent/src/modes/interactive-mode.ts index 3a1937eac..41712beea 100644 --- a/packages/coding-agent/src/modes/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive-mode.ts @@ -696,6 +696,7 @@ export class InteractiveMode implements InteractiveModeContext { this.errorBannerContainer = new AnchoredLiveContainer(); this.modelCycleContainer = new AnchoredLiveContainer(); this.editor = new CustomEditor(getEditorTheme()); + this.ui.enableScopedInputRender(this.editor); this.editor.setUseTerminalCursor(this.ui.getShowHardwareCursor()); this.editor.setImeSafeCursorLayout(settings.get("tui.imeSafeCursor")); this.editor.setAutocompleteMaxVisible(settings.get("autocompleteMaxVisible")); @@ -3794,6 +3795,7 @@ export class InteractiveMode implements InteractiveModeContext { const nextEditor = factory ? factory(this.ui, getEditorTheme(), this.keybindings) : new CustomEditor(getEditorTheme()); + if (!factory) this.ui.enableScopedInputRender(nextEditor); nextEditor.setUseTerminalCursor(this.ui.getShowHardwareCursor()); nextEditor.setImeSafeCursorLayout(this.settings.get("tui.imeSafeCursor")); diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 545d79a76..98866826e 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -4,7 +4,7 @@ ### Fixed -- Fixed ordinary focused-component keystrokes performing a full root compose by scoping stable-focus renders to that component while retaining full composition when input moves focus ([#5928](https://github.com/can1357/oh-my-pi/issues/5928)). +- Fixed ordinary coding-agent editor keystrokes performing a full root compose by adding an explicit stable-focus subtree-render opt-in while preserving full composition for callback-driven components and focus changes ([#5928](https://github.com/can1357/oh-my-pi/issues/5928)). ## [17.0.3] - 2026-07-17 diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index a90e3d124..a599756ec 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -1133,6 +1133,7 @@ export class TUI extends Container { // Target component -> containing root child, so animation-rate requests do // not re-walk a huge transcript subtree every frame. #componentRootCache = new WeakMap(); + #scopedInputRenderComponents = new WeakSet(); // Persistent prepared frame, row-aligned with #composedFrame. Entries store // normalized, width-fitted content rows without the per-line terminal @@ -1889,6 +1890,17 @@ export class TUI extends Container { this.#requestOrdinaryRender(); } + /** + * Opt `component` into subtree-only renders when input leaves focus stable. + * + * The host must explicitly request renders for every sibling mutated by the + * component's input callbacks. Components without this opt-in retain the + * legacy full-root render after input. + */ + enableScopedInputRender(component: Component): void { + this.#scopedInputRenderComponents.add(component); + } + /** * Schedule a render on behalf of `component` after a self-contained change * (spinner frame, blink) that cannot have affected any other component. @@ -2370,9 +2382,9 @@ export class TUI extends Container { // Pass input to focused component (including Ctrl+C). // The focused component can decide how to handle Ctrl+C. - // Ordinary keystrokes only dirty the focused subtree; handleInput may - // move focus (submit opening a selector) and the new surface is not in - // #componentRenderTargets, so fall back to a full frame then. + // Opted-in components only dirty their focused subtree. Unregistered + // components retain the legacy full compose because their callbacks may + // mutate siblings; focus changes also require the new surface to paint. const focused = this.#focusedComponent; if (focused?.handleInput) { // Filter out key release events unless component opts in @@ -2380,7 +2392,7 @@ export class TUI extends Container { return; } focused.handleInput(data); - if (this.#focusedComponent === focused) { + if (this.#focusedComponent === focused && this.#scopedInputRenderComponents.has(focused)) { this.requestComponentRender(focused); } else { this.requestRender(); diff --git a/packages/tui/test/component-render.test.ts b/packages/tui/test/component-render.test.ts index 9544f888f..8728b6dec 100644 --- a/packages/tui/test/component-render.test.ts +++ b/packages/tui/test/component-render.test.ts @@ -343,12 +343,50 @@ describe("TUI.requestComponentRender", () => { }); describe("TUI keystroke-scoped render", () => { + it("fully composes callback-driven sibling updates without explicit scoped opt-in", async () => { + const term = new VirtualTerminal(40, 8, 1_000); + const scheduler = new StressRenderScheduler(); + const tui = new TUI(term, undefined, { renderScheduler: scheduler }); + const status = new CountingLines(["status-idle"]); + const input: Component & Focusable = { + focused: false, + invalidate() {}, + render() { + const state = this.focused ? "focused" : "idle"; + return [`input-${state}`]; + }, + handleInput() { + status.set(["status-submitted"]); + }, + }; + tui.addChild(status); + tui.addChild(input); + tui.setFocus(input); + + try { + tui.start(); + await scheduler.drain(term); + const statusRenders = status.renders; + + term.sendInput("x"); + await scheduler.drain(term); + + expect(status.renders).toBeGreaterThan(statusRenders); + expect(visible(term)).toEqual(["status-submitted", "input-focused"]); + expect(tui.getFocused()).toBe(input); + } finally { + tui.stop(); + await term.flush(); + } + }); + it("does not re-render a quiet sibling transcript while typing in the focused editor", async () => { const term = new VirtualTerminal(40, 8, 1_000); const scheduler = new StressRenderScheduler(); const tui = new TUI(term, undefined, { renderScheduler: scheduler }); const transcript = new CountingLines(["msg-0", "msg-1", "msg-2"]); const editor = new Editor(defaultEditorTheme); + tui.enableScopedInputRender(editor); tui.addChild(transcript); tui.addChild(editor); tui.setFocus(editor); @@ -377,6 +415,7 @@ describe("TUI keystroke-scoped render", () => { const tui = new TUI(term, undefined, { renderScheduler: scheduler }); const transcript = new CountingLines(["msg-0", "msg-1"]); const editor = new Editor(defaultEditorTheme); + tui.enableScopedInputRender(editor); // 34 chars fills the first content row at width 40; the next char wraps. editor.setText("x".repeat(34)); tui.addChild(transcript); @@ -428,6 +467,7 @@ describe("TUI keystroke-scoped render", () => { tui.setFocus(nextFocus); }, }; + tui.enableScopedInputRender(focusMover); tui.addChild(transcript); tui.addChild(focusMover);