fix(tui): kept slash autocomplete repainting

Allowed live input renders to bypass unknown Windows viewport deferral while preserving the default scrollback protection for background mutations.

Fixes #1550
This commit is contained in:
roboomp
2026-05-30 17:46:04 +00:00
parent 464314e3a2
commit 119bb0009c
5 changed files with 115 additions and 11 deletions
+4
View File
@@ -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
@@ -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) {
+4
View File
@@ -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
+28 -9
View File
@@ -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 {
@@ -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<void> {
await new Promise<void>(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;
}
});
});