From ea29f72dc0acc311cf231a6ef1ebfb0dbf2220f6 Mon Sep 17 00:00:00 2001 From: roboomp Date: Sat, 18 Jul 2026 00:12:08 +0000 Subject: [PATCH] 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 --- packages/coding-agent/CHANGELOG.md | 1 + .../src/modes/utils/ui-helpers.ts | 1 + .../test/input-controller-skill-queue.test.ts | 25 +++- packages/tui/CHANGELOG.md | 4 + packages/tui/src/tui.ts | 20 ++- packages/tui/test/component-render.test.ts | 114 ++++++++++++++++++ 6 files changed, 157 insertions(+), 8 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index ad20be261..67ad4c070 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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)). diff --git a/packages/coding-agent/src/modes/utils/ui-helpers.ts b/packages/coding-agent/src/modes/utils/ui-helpers.ts index 0839fb0a2..2b8d7079d 100644 --- a/packages/coding-agent/src/modes/utils/ui-helpers.ts +++ b/packages/coding-agent/src/modes/utils/ui-helpers.ts @@ -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 { diff --git a/packages/coding-agent/test/input-controller-skill-queue.test.ts b/packages/coding-agent/test/input-controller-skill-queue.test.ts index 9c0beb7e9..240c0ae0f 100644 --- a/packages/coding-agent/test/input-controller-skill-queue.test.ts +++ b/packages/coding-agent/test/input-controller-skill-queue.test.ts @@ -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(), } 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; diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 7d7e0c430..545d79a76 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -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 diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index 63b74dbd0..a90e3d124 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -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(); + } } } diff --git a/packages/tui/test/component-render.test.ts b/packages/tui/test/component-render.test.ts index 598be48cf..9544f888f 100644 --- a/packages/tui/test/component-render.test.ts +++ b/packages/tui/test/component-render.test.ts @@ -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);