diff --git a/SETUP.md b/SETUP.md index c470aee5..bcd70fdc 100644 --- a/SETUP.md +++ b/SETUP.md @@ -64,6 +64,11 @@ Capability discovery defaults to `explicit`, preserving the zero-resident OpenPI OpenPI Web theme defaults to `system`; `light` and `dark` are explicit canonical setup choices, and the browser consumes them from each authoritative snapshot without writing a competing local preference. +On Windows, OpenPI enables Pi's `clearOnShrink` compatibility behavior for +the regular TUI so shrinking slash-command autocomplete lists do not leave +stale rows on screen. Fullscreen TUI keeps its configured behavior. The +native `PI_CLEAR_ON_SHRINK=0` setting remains an explicit opt-out. + Legacy `footerItems` is accepted and migrated at the input boundary, but new setup writes persist only canonical `footerLines`. Configurations written by this version are not guaranteed to retain their Footer layout when read by an older OpenPI version. diff --git a/extensions/windows-terminal-compat/index.ts b/extensions/windows-terminal-compat/index.ts new file mode 100644 index 00000000..263c6ce1 --- /dev/null +++ b/extensions/windows-terminal-compat/index.ts @@ -0,0 +1,86 @@ +import type { + ExtensionAPI, + ExtensionContext, +} from "@earendil-works/pi-coding-agent"; +import { + registerEditorLayer, + removeEditorLayer, +} from "../shared/editor-layers.ts"; + +export const WINDOWS_TERMINAL_COMPAT_LAYER = "windows-terminal-compat"; + +type CompatibleTui = { + mode: "regular" | "fullscreen"; + getClearOnShrink(): boolean; + setClearOnShrink(enabled: boolean): void; +}; + +export function shouldClearShrunkRows( + platform: NodeJS.Platform, + mode: "regular" | "fullscreen", + enabled = true, +) { + return enabled && platform === "win32" && mode === "regular"; +} + +export function applyWindowsTerminalCompatibility( + tui: CompatibleTui, + platform: NodeJS.Platform, + enabled = true, +) { + if ( + shouldClearShrunkRows(platform, tui.mode, enabled) && + !tui.getClearOnShrink() + ) { + tui.setClearOnShrink(true); + } +} + +function install(ctx: ExtensionContext, pi: ExtensionAPI) { + if (ctx.mode !== "tui") { + return; + } + + const windowsCompatEnabled = + process.platform === "win32" && process.env.PI_CLEAR_ON_SHRINK !== "0"; + let currentTui: CompatibleTui | undefined; + let lastMode: CompatibleTui["mode"] | undefined; + const removeInputListener = ctx.ui.onTerminalInput(() => { + if (currentTui && currentTui.mode !== lastMode) { + lastMode = currentTui.mode; + applyWindowsTerminalCompatibility( + currentTui, + process.platform, + windowsCompatEnabled, + ); + } + }); + registerEditorLayer(pi, ctx, { + id: WINDOWS_TERMINAL_COMPAT_LAYER, + order: 100, + wrap: (base, tui) => { + currentTui = tui; + lastMode = tui.mode; + // The regular renderer otherwise leaves rows behind when autocomplete + // shrinks. This is a terminal redraw compatibility setting, not an + // editor replacement, so all existing input behavior remains intact. + applyWindowsTerminalCompatibility( + tui, + process.platform, + windowsCompatEnabled, + ); + return base; + }, + }); + pi.on("session_shutdown", () => { + removeInputListener(); + currentTui = undefined; + }); +} + +export default function windowsTerminalCompat(pi: ExtensionAPI) { + pi.on("session_start", (_event, ctx) => install(ctx, pi)); + pi.on("session_shutdown", () => { + removeEditorLayer(pi, WINDOWS_TERMINAL_COMPAT_LAYER); + }); +} diff --git a/tests/extensions/windows-terminal-compat/index.test.ts b/tests/extensions/windows-terminal-compat/index.test.ts new file mode 100644 index 00000000..93ff2dbe --- /dev/null +++ b/tests/extensions/windows-terminal-compat/index.test.ts @@ -0,0 +1,127 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + applyWindowsTerminalCompatibility, + shouldClearShrunkRows, +} from "../../../extensions/windows-terminal-compat/index.ts"; + +test("enables shrink cleanup for the Windows regular renderer", () => { + assert.equal(shouldClearShrunkRows("win32", "regular"), true); + + let enabled: boolean | undefined; + applyWindowsTerminalCompatibility( + { + mode: "regular", + getClearOnShrink() { + return false; + }, + setClearOnShrink(value) { + enabled = value; + }, + }, + "win32", + ); + + assert.equal(enabled, true); +}); + +test("does not change non-Windows or fullscreen rendering", () => { + assert.equal(shouldClearShrunkRows("linux", "regular"), false); + assert.equal(shouldClearShrunkRows("win32", "fullscreen"), false); + + let calls = 0; + const tui = { + mode: "fullscreen" as const, + getClearOnShrink() { + return false; + }, + setClearOnShrink() { + calls += 1; + }, + }; + + applyWindowsTerminalCompatibility(tui, "win32"); + applyWindowsTerminalCompatibility({ ...tui, mode: "regular" }, "linux"); + + assert.equal(calls, 0); +}); + +test("supports an explicit environment opt-out", () => { + assert.equal(shouldClearShrunkRows("win32", "regular", false), false); + + let calls = 0; + applyWindowsTerminalCompatibility( + { + mode: "regular", + getClearOnShrink() { + return false; + }, + setClearOnShrink() { + calls += 1; + }, + }, + "win32", + false, + ); + + assert.equal(calls, 0); +}); + +test("preserves an explicit native false renderer setting", () => { + let calls = 0; + applyWindowsTerminalCompatibility( + { + mode: "regular", + getClearOnShrink() { + return false; + }, + setClearOnShrink() { + calls += 1; + }, + }, + "win32", + false, + ); + assert.equal(calls, 0); +}); + +test("reapplies compatibility after renderer replacement", () => { + let listener: ((data: string) => unknown) | undefined; + const remove = (value: (data: string) => unknown) => { + listener = value; + return () => { + listener = undefined; + }; + }; + let oldClearOnShrink = false; + const oldRenderer = { + mode: "fullscreen" as const, + getClearOnShrink: () => oldClearOnShrink, + setClearOnShrink: (value: boolean) => { + oldClearOnShrink = value; + }, + }; + let newClearOnShrink = false; + const newRenderer = { + mode: "regular" as const, + getClearOnShrink: () => newClearOnShrink, + setClearOnShrink: (value: boolean) => { + newClearOnShrink = value; + }, + }; + let current: { + mode: "regular" | "fullscreen"; + getClearOnShrink: () => boolean; + setClearOnShrink: (value: boolean) => void; + } = oldRenderer; + const unsubscribe = remove(() => { + applyWindowsTerminalCompatibility(current, "win32"); + }); + listener?.("input"); + assert.equal(oldClearOnShrink, false); + current = newRenderer; + listener?.("input"); + assert.equal(newClearOnShrink, true); + unsubscribe(); + assert.equal(listener, undefined); +});