fix(tui): scoped stable-focus keystroke renders

Scoped ordinary input frames to the focused component while retaining a full compose when input moves focus. Explicitly repainted the coding-agent pending-message sibling and covered stable focus, wrapped growth, focus movement, and queue clearing.

Fixes #5928
This commit is contained in:
roboomp
2026-07-18 00:12:08 +00:00
parent 48241afcc4
commit ea29f72dc0
6 changed files with 157 additions and 8 deletions
+1
View File
@@ -22,6 +22,7 @@
### Fixed
- Fixed queued-message display updates being skipped by focused-editor keystroke frames by explicitly repainting the pending-message container ([#5928](https://github.com/can1357/oh-my-pi/issues/5928)).
- Fixed `xd://` mount notices triggering unsolicited model turns by deferring hidden notices until the next user prompt.
- Fixed `xd://` device tools appearing in the direct tool inventory and prompting invalid function calls ([#5797](https://github.com/can1357/oh-my-pi/issues/5797)).
- Fixed `history://` read selectors being treated as part of the agent id instead of paging the transcript ([#5806](https://github.com/can1357/oh-my-pi/issues/5806)).
@@ -746,6 +746,7 @@ export class UiHelpers {
const hintText = theme.fg("dim", ` ${theme.tree.hook} ${dequeueKey} to edit`);
this.ctx.pendingMessagesContainer.addChild(new TruncatedText(hintText, 1, 0));
}
this.ctx.ui.requestComponentRender(this.ctx.pendingMessagesContainer);
}
queueCompactionMessage(text: string, mode: "steer" | "followUp", images?: ImageContent[]): void {
@@ -651,11 +651,12 @@ function createStubInteractiveModeContextForUiHelpers(session: AgentSession) {
};
const pendingMessagesContainer = new Container();
const requestRender = vi.fn();
const requestComponentRender = vi.fn();
const updatePendingMessagesDisplay = vi.fn();
const ctx = {
editor,
ui: { requestRender },
ui: { requestRender, requestComponentRender },
pendingMessagesContainer,
session,
viewSession: session,
@@ -667,7 +668,7 @@ function createStubInteractiveModeContextForUiHelpers(session: AgentSession) {
locallySubmittedUserSignatures: new Set<string>(),
} as unknown as InteractiveModeContext;
return { ctx, editor, pendingMessagesContainer };
return { ctx, editor, pendingMessagesContainer, requestComponentRender };
}
describe("UiHelpers / InputController against derived queued custom display", () => {
@@ -704,6 +705,26 @@ describe("UiHelpers / InputController against derived queued custom display", ()
expect(rendered).not.toContain("Steer:");
});
it("requests the pending-container repaint after rebuilding and clearing it", async () => {
fixture = await createRealSession();
const { session } = fixture;
queueCustomSteer(session, "/skill:test-skill arg1 arg2");
const { ctx, pendingMessagesContainer, requestComponentRender } =
createStubInteractiveModeContextForUiHelpers(session);
const uiHelpers = new UiHelpers(ctx);
uiHelpers.updatePendingMessagesDisplay();
expect(pendingMessagesContainer.children.length).toBeGreaterThan(0);
expect(requestComponentRender).toHaveBeenNthCalledWith(1, pendingMessagesContainer);
session.clearQueue();
uiHelpers.updatePendingMessagesDisplay();
expect(pendingMessagesContainer.children).toHaveLength(0);
expect(requestComponentRender).toHaveBeenNthCalledWith(2, pendingMessagesContainer);
});
it("groups yield follow-ups under one heading", async () => {
fixture = await createRealSession();
const { session } = fixture;
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### 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)).
## [17.0.3] - 2026-07-17
### Fixed
+14 -6
View File
@@ -2368,15 +2368,23 @@ export class TUI extends Container {
}
}
// Pass input to focused component (including Ctrl+C)
// The focused component can decide how to handle Ctrl+C
if (this.#focusedComponent?.handleInput) {
// 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.
const focused = this.#focusedComponent;
if (focused?.handleInput) {
// Filter out key release events unless component opts in
if (isKeyRelease(data) && !this.#focusedComponent.wantsKeyRelease) {
if (isKeyRelease(data) && !focused.wantsKeyRelease) {
return;
}
this.#focusedComponent.handleInput(data);
this.requestRender();
focused.handleInput(data);
if (this.#focusedComponent === focused) {
this.requestComponentRender(focused);
} else {
this.requestRender();
}
}
}
+114
View File
@@ -2,12 +2,15 @@ import { describe, expect, it } from "bun:test";
import {
type Component,
Container,
Editor,
type Focusable,
type NativeScrollbackCommittedRows,
type NativeScrollbackLiveRegion,
type NativeScrollbackReplay,
TUI,
} from "@oh-my-pi/pi-tui";
import { StressRenderScheduler } from "./render-stress-scheduler";
import { defaultEditorTheme } from "./test-themes";
import { VirtualTerminal } from "./virtual-terminal";
// Behavioral tests for TUI.requestComponentRender: a component whose own
@@ -339,6 +342,117 @@ describe("TUI.requestComponentRender", () => {
});
});
describe("TUI keystroke-scoped render", () => {
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.addChild(transcript);
tui.addChild(editor);
tui.setFocus(editor);
try {
tui.start();
await scheduler.drain(term);
const transcriptRenders = transcript.renders;
term.sendInput("x");
await scheduler.drain(term);
expect(editor.getText()).toBe("x");
expect(transcript.renders).toBe(transcriptRenders);
expect(visible(term).some(row => row.includes("msg-0"))).toBe(true);
expect(visible(term).some(row => row.includes("x"))).toBe(true);
} finally {
tui.stop();
await term.flush();
}
});
it("keeps a correct viewport when a keystroke grows the editor by one wrapped row", 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"]);
const editor = new Editor(defaultEditorTheme);
// 34 chars fills the first content row at width 40; the next char wraps.
editor.setText("x".repeat(34));
tui.addChild(transcript);
tui.addChild(editor);
tui.setFocus(editor);
try {
tui.start();
await scheduler.drain(term);
expect(visible(term)).toEqual([
"msg-0",
"msg-1",
"+--------------------------------------+",
"+- xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx|-+",
]);
const transcriptRenders = transcript.renders;
term.sendInput("y");
await scheduler.drain(term);
expect(editor.getText()).toBe(`${"x".repeat(34)}y`);
expect(transcript.renders).toBe(transcriptRenders);
expect(visible(term)).toEqual([
"msg-0",
"msg-1",
"+--------------------------------------+",
"| xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx |",
"+- y| -+",
]);
} finally {
tui.stop();
await term.flush();
}
});
it("falls back to a full compose when handleInput moves focus", 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"]);
const nextFocus = new CountingLines(["selector"]);
const focusMover: Component & Focusable = {
focused: false,
invalidate() {},
render() {
return this.focused ? ["editor-focused"] : ["editor-idle"];
},
handleInput() {
tui.setFocus(nextFocus);
},
};
tui.addChild(transcript);
tui.addChild(focusMover);
tui.addChild(nextFocus);
tui.setFocus(focusMover);
try {
tui.start();
await scheduler.drain(term);
expect(visible(term)).toEqual(["msg-0", "editor-focused", "selector"]);
const transcriptRenders = transcript.renders;
term.sendInput("x");
await scheduler.drain(term);
expect(transcript.renders).toBeGreaterThan(transcriptRenders);
expect(visible(term)).toEqual(["msg-0", "editor-idle", "selector"]);
expect(tui.getFocused()).toBe(nextFocus);
} finally {
tui.stop();
await term.flush();
}
});
});
describe("TUI.requestDirectWrite", () => {
it("directly rewrites a visible unchanged-size root segment without a full render", async () => {
const term = new VirtualTerminal(40, 8, 1_000);