diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 6f5ed99c4..f18f77396 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed slash-command autocomplete repaint requests so Windows Terminal sessions with unknown native viewport state keep updating the input box and candidate list. ([#1550](https://github.com/can1357/oh-my-pi/issues/1550)) + ## [15.6.0] - 2026-05-30 ### Added diff --git a/packages/coding-agent/src/modes/interactive-mode.ts b/packages/coding-agent/src/modes/interactive-mode.ts index 556bb7c5e..3570bc27b 100644 --- a/packages/coding-agent/src/modes/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive-mode.ts @@ -368,7 +368,7 @@ export class InteractiveMode implements InteractiveModeContext { this.ui.requestRender(true); }; this.editor.onAutocompleteUpdate = () => { - this.ui.requestRender(); + this.ui.requestRender(false, { allowUnknownViewportMutation: true }); }; this.#syncEditorMaxHeight(); this.#resizeHandler = () => { @@ -2259,7 +2259,7 @@ export class InteractiveMode implements InteractiveModeContext { this.ui.requestRender(true); }; nextEditor.onAutocompleteUpdate = () => { - this.ui.requestRender(); + this.ui.requestRender(false, { allowUnknownViewportMutation: true }); }; nextEditor.setMaxHeight(this.#computeEditorMaxHeight()); if (this.historyStorage) { diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 274703ce2..59da4806e 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed slash-command autocomplete repainting when a Windows Terminal session cannot report native scrollback position; live input renders can now bypass the unknown-viewport deferral without weakening background scrollback protection. ([#1550](https://github.com/can1357/oh-my-pi/issues/1550)) + ## [15.6.0] - 2026-05-30 ### Added diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index 9534019fb..7966b31de 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -84,6 +84,8 @@ export interface Focusable { export interface RenderRequestOptions { /** Clear terminal scrollback for intentional transcript replacement. */ clearScrollback?: boolean; + /** Render live UI edits even when Windows cannot report native scrollback position. */ + allowUnknownViewportMutation?: boolean; } /** Options for deferred native scrollback rebuild checkpoints. */ @@ -316,6 +318,7 @@ export class TUI extends Container { #nativeScrollbackDirty = false; #fullRedrawCount = 0; #clearScrollbackOnNextRender = false; + #allowUnknownViewportMutationOnNextRender = false; #hasEverRendered = false; #stopped = false; @@ -670,6 +673,7 @@ export class TUI extends Container { } requestRender(force = false, options?: RenderRequestOptions): void { + this.#allowUnknownViewportMutationOnNextRender ||= options?.allowUnknownViewportMutation === true; if (force) { this.#prepareForcedRender(options?.clearScrollback === true); this.#renderRequested = true; @@ -774,7 +778,7 @@ export class TUI extends Container { return; } this.#focusedComponent.handleInput(data); - this.requestRender(); + this.requestRender(false, { allowUnknownViewportMutation: true }); } } @@ -1138,11 +1142,19 @@ export class TUI extends Container { const prevHardwareCursorRow = this.#hardwareCursorRow; const widthChanged = this.#previousWidth > 0 && this.#previousWidth !== width; const heightChanged = this.#previousHeight > 0 && this.#previousHeight !== height; + const allowUnknownViewportMutation = this.#allowUnknownViewportMutationOnNextRender; + this.#allowUnknownViewportMutationOnNextRender = false; // 3. Classify intent. - const intent = this.#planRender(lines, widthChanged, heightChanged, prevViewportTop, height); + const intent = this.#planRender( + lines, + widthChanged, + heightChanged, + prevViewportTop, + height, + allowUnknownViewportMutation, + ); this.#logRedraw(intent, lines.length, height); - // 4. Execute. switch (intent.kind) { case "noop": @@ -1219,6 +1231,7 @@ export class TUI extends Container { heightChanged: boolean, prevViewportTop: number, height: number, + allowUnknownViewportMutation: boolean, ): RenderIntent { // Initial paint after start(): scrollback must keep its prior shell // content, but the viewport must be cleared so stale rows do not bleed @@ -1253,14 +1266,14 @@ export class TUI extends Container { !isMultiplexerSession() ) { if (widthChanged || heightChanged) { - if (this.#nativeViewportIsScrolled(this.#readNativeViewportAtBottom())) { + if (this.#nativeViewportIsScrolled(this.#readNativeViewportAtBottom(), allowUnknownViewportMutation)) { this.#markNativeScrollbackDirty(); return { kind: "deferredShrink", paddedLength: this.#previousLines.length }; } return { kind: "historyRebuild" }; } this.#markNativeScrollbackDirty(); - if (this.#nativeViewportIsScrolled(this.#readNativeViewportAtBottom())) { + if (this.#nativeViewportIsScrolled(this.#readNativeViewportAtBottom(), allowUnknownViewportMutation)) { return { kind: "deferredShrink", paddedLength: this.#previousLines.length }; } return { kind: "viewportRepaint" }; @@ -1292,7 +1305,7 @@ export class TUI extends Container { // through to the diff path so the append handler scrolls them into history. if (widthChanged) { if (diff.firstChanged < prevViewportTop) { - if (this.#nativeViewportIsScrolled(this.#readNativeViewportAtBottom())) { + if (this.#nativeViewportIsScrolled(this.#readNativeViewportAtBottom(), allowUnknownViewportMutation)) { this.#markNativeScrollbackDirty(); return { kind: "viewportRepaint" }; } @@ -1307,7 +1320,7 @@ export class TUI extends Container { const structuralMutation = newLines.length !== this.#previousLines.length || diff.firstChanged < prevViewportTop; if (!pureAppend && structuralMutation && !isMultiplexerSession()) { const nativeViewportAtBottom = this.#readNativeViewportAtBottom(); - if (this.#nativeViewportIsScrolled(nativeViewportAtBottom)) { + if (this.#nativeViewportIsScrolled(nativeViewportAtBottom, allowUnknownViewportMutation)) { this.#markNativeScrollbackDirty(); return { kind: "deferredMutation" }; } @@ -1430,8 +1443,14 @@ export class TUI extends Container { return this.terminal.isNativeViewportAtBottom?.(); } - #nativeViewportIsScrolled(nativeViewportAtBottom: boolean | undefined): boolean { - return nativeViewportAtBottom === false || (nativeViewportAtBottom === undefined && process.platform === "win32"); + #nativeViewportIsScrolled( + nativeViewportAtBottom: boolean | undefined, + allowUnknownViewportMutation = false, + ): boolean { + return ( + nativeViewportAtBottom === false || + (nativeViewportAtBottom === undefined && process.platform === "win32" && !allowUnknownViewportMutation) + ); } #nativeViewportIsAtBottom(nativeViewportAtBottom: boolean | undefined): boolean { diff --git a/packages/tui/test/slash-autocomplete-viewport.test.ts b/packages/tui/test/slash-autocomplete-viewport.test.ts new file mode 100644 index 000000000..cbbd61f9b --- /dev/null +++ b/packages/tui/test/slash-autocomplete-viewport.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "bun:test"; +import { Container, Editor, TUI } from "@oh-my-pi/pi-tui"; +import type { AutocompleteItem, AutocompleteProvider } from "@oh-my-pi/pi-tui/autocomplete"; +import { defaultEditorTheme } from "./test-themes"; +import { VirtualTerminal } from "./virtual-terminal"; + +class SlashProvider implements AutocompleteProvider { + async getSuggestions( + lines: string[], + cursorLine: number, + cursorCol: number, + ): Promise<{ items: AutocompleteItem[]; prefix: string } | null> { + const text = (lines[cursorLine] ?? "").slice(0, cursorCol); + if (!text.startsWith("/")) return null; + const prefix = text.slice(1).toLowerCase(); + const commands = ["model", "settings", "skill:semantic-compression", "status", "stats", "stop"]; + const items = commands + .filter(command => command.includes(prefix)) + .map(command => ({ value: command, label: command })); + return items.length > 0 ? { prefix: text, items } : null; + } + + applyCompletion(lines: string[], cursorLine: number, cursorCol: number, item: AutocompleteItem, prefix: string) { + const line = lines[cursorLine] ?? ""; + const next = [...lines]; + next[cursorLine] = `${line.slice(0, cursorCol - prefix.length)}/${item.value} ${line.slice(cursorCol)}`; + return { lines: next, cursorLine, cursorCol: item.value.length + 2 }; + } +} + +class UnknownViewportTerminal extends VirtualTerminal { + isNativeViewportAtBottom(): undefined { + return undefined; + } +} + +async function settle(term: VirtualTerminal): Promise { + await new Promise(resolve => process.nextTick(resolve)); + await Bun.sleep(120); + await term.flush(); +} + +describe("slash command autocomplete with unknown native viewport state", () => { + it("keeps repainting the editor while the autocomplete list changes height", async () => { + const originalPlatform = process.platform; + const originalWtSession = Bun.env.WT_SESSION; + Object.defineProperty(process, "platform", { configurable: true, value: "win32" }); + Bun.env.WT_SESSION = "wt-test"; + const term = new UnknownViewportTerminal(40, 8); + const tui = new TUI(term); + const root = new Container(); + root.addChild({ invalidate() {}, render: () => ["chat-0", "chat-1", "chat-2", "chat-3", "chat-4", "chat-5"] }); + const editor = new Editor(defaultEditorTheme); + editor.setAutocompleteProvider(new SlashProvider()); + editor.onAutocompleteUpdate = () => tui.requestRender(false, { allowUnknownViewportMutation: true }); + root.addChild(editor); + tui.addChild(root); + tui.setFocus(editor); + + try { + tui.start(); + await settle(term); + for (const char of "/model") { + term.sendInput(char); + await settle(term); + const viewport = term.getViewport().join("\n"); + expect(viewport).toContain(editor.getText()); + } + expect(editor.getText()).toBe("/model"); + } finally { + tui.stop(); + Object.defineProperty(process, "platform", { configurable: true, value: originalPlatform }); + if (originalWtSession === undefined) delete Bun.env.WT_SESSION; + else Bun.env.WT_SESSION = originalWtSession; + } + }); +});