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