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.
This commit is contained in:
roboomp
2026-07-18 00:20:15 +00:00
parent ea29f72dc0
commit 3eca0813bb
4 changed files with 59 additions and 5 deletions
@@ -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"));
+1 -1
View File
@@ -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
+16 -4
View File
@@ -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<Component, Component>();
#scopedInputRenderComponents = new WeakSet<Component>();
// 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();
@@ -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);