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