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:
@@ -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"));
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user