diff --git a/README.md b/README.md index 16c6b9e6..2889f608 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,9 @@ Skill 正文通过原生用户消息或工具结果进入正常 Session 历史 > [!IMPORTANT] > 默认安装是安静的:不改主题、不绑定 Provider 或模型、不开启下一步预测,也不执行 post-edit 命令。Capability discovery 默认 `explicit`;只有用户通过 `/openpi-setup` 选择 `adaptive` 后,模型才会常驻看到一个小型发现网关并可自主加载额外能力。 +> [!TIP] +> Windows 用户如果在输入 `/lo` 等斜杠命令时看到旧的补全行残留,OpenPI 会在当前交互式会话中为未显式配置 `terminal.clearOnShrink` 的 `regular` TUI 启用收缩清理。该兜底不会修改 Pi 的全局或项目偏好;显式的 `terminal.clearOnShrink` 值会被保留。需要永久选择 TUI 模式时,请使用 Pi 原生 `/settings`、`settings.json` 或 `--tui-mode`。 + ```text /openpi-setup ``` diff --git a/SETUP.md b/SETUP.md index 9fe26edb..be0c364f 100644 --- a/SETUP.md +++ b/SETUP.md @@ -14,6 +14,31 @@ pi install git:github.com/openpi-dev/openpi Pi installs the package dependencies automatically. Restart Pi or run `/reload` after installation. +### Windows terminal compatibility + +On Windows, OpenPI detects the stale autocomplete-row redraw problem in Pi's +`regular` (main-screen) renderer. When neither global nor project settings +explicitly sets `terminal.clearOnShrink`, it enables Pi's supported +`clear-on-shrink` behavior for the current renderer only. This prevents old +slash-command autocomplete rows from making commands look duplicated without +changing a global or project preference. + +An explicit `terminal.clearOnShrink` value is always preserved, including +`false`. OpenPI also leaves the selected `tuiMode` unchanged; use Pi's native +`/settings`, `settings.json`, or the `--tui-mode` flag when you want to choose +that mode explicitly. + +For example, Pi's alternate-screen renderer remains available from `/settings` +or `settings.json`: + +```json +{ + "tuiMode": "fullscreen" +} +``` + +The compatibility behavior is tracked in [openpi#407](https://github.com/openpi-dev/openpi/issues/407). + ## fd, rg, and read-only git tools The `file-search` extension registers `fd` and `rg` as model tools, and `git-read` registers `git_show`, `git_diff`, and `git_log` (read-only git inspection). They stay outside an ordinary parent turn until the user explicitly asks to use `fd`/`rg`/git history, or structured file search, or the model loads the `search` group through `openpi_load_tools`. Entering or restoring Plan Mode is a runtime-safety exception: it loads `search` for that Session so diff investigation can use the structured Git boundary. The gateway is shown after an explicit OpenPI-capability request, or remains visible when the user opts into adaptive discovery; child sessions may still receive these tools through the reviewed child-safe allowlist (the read-only git tools let reviewer/advisor subagents inspect diffs, which a bash-free tool boundary otherwise excludes). No setup is normally needed: at startup `fd`/`rg` silently use a system-installed binary (`fd`/`fdfind` and `rg`) when available, or an existing binary in the agent's private managed bin directory (`~/.pi/agent/bin`). Only when neither exists does it download an official release binary (macOS/Linux, arm64/x64, over HTTPS) into that directory — a persistent cache that survives package updates — and show a one-time notification. If your platform is unsupported, install `fd` and `rg` with your package manager and restart Pi. The git tools require a system `git`. diff --git a/extensions/windows-tui-compatibility/index.ts b/extensions/windows-tui-compatibility/index.ts new file mode 100644 index 00000000..ad040d6d --- /dev/null +++ b/extensions/windows-tui-compatibility/index.ts @@ -0,0 +1,135 @@ +import type { + ExtensionAPI, + ExtensionContext, +} from "@earendil-works/pi-coding-agent"; + +interface TuiSettings { + terminal?: { + clearOnShrink?: boolean; + }; +} + +interface TuiSettingsManager { + getGlobalSettings(): TuiSettings; + getProjectSettings(): TuiSettings; + drainErrors?(): readonly unknown[]; +} + +type TuiSettingsManagerFactory = ( + cwd: string, +) => TuiSettingsManager | Promise; + +const WIDGET_KEY = "openpi-windows-tui-compatibility"; + +/** + * The main-screen renderer can leave stale autocomplete rows on Windows. + * Keep the workaround limited to interactive Windows sessions so RPC/print + * users and non-Windows terminals are unaffected. + */ +export function shouldInstallWindowsTuiCompatibility( + platform: NodeJS.Platform, + mode: ExtensionContext["mode"], +) { + return platform === "win32" && mode === "tui"; +} + +export function shouldEnableWindowsClearOnShrink(options: { + platform: NodeJS.Platform; + mode: ExtensionContext["mode"]; + globalClearOnShrink?: boolean; + projectClearOnShrink?: boolean; +}) { + return ( + shouldInstallWindowsTuiCompatibility(options.platform, options.mode) && + options.globalClearOnShrink === undefined && + options.projectClearOnShrink === undefined + ); +} + +function readClearOnShrinkSettings(settingsManager: TuiSettingsManager) { + const globalSettings = settingsManager.getGlobalSettings(); + const projectSettings = settingsManager.getProjectSettings(); + return { + globalClearOnShrink: globalSettings.terminal?.clearOnShrink, + projectClearOnShrink: projectSettings.terminal?.clearOnShrink, + }; +} + +/** + * Register the Windows renderer workaround. + * + * Pi exposes the renderer to widget factories, but not as a direct property + * on ExtensionContext. The zero-height widget lets us apply the supported + * renderer setting without replacing OpenPI's header, footer, or editor. + * + * The renderer setting is deliberately session-local. Pi's SettingsManager + * setters persist global preferences, so this compatibility extension only + * reads the existing clear-on-shrink settings and never changes tuiMode or + * terminal preferences on the user's behalf. + */ +export function registerWindowsTuiCompatibility( + pi: ExtensionAPI, + platform: NodeJS.Platform, + settingsManagerFactory?: TuiSettingsManagerFactory, +) { + let activeUi: ExtensionContext["ui"] | undefined; + + const cleanup = () => { + const ui = activeUi; + activeUi = undefined; + try { + ui?.setWidget(WIDGET_KEY, undefined); + } catch { + // The renderer may already be gone during shutdown. + } + }; + + pi.on("session_start", async (_event, ctx) => { + cleanup(); + if (!shouldInstallWindowsTuiCompatibility(platform, ctx.mode)) return; + + let enableClearOnShrink = false; + if (settingsManagerFactory) { + try { + const settingsManager = await settingsManagerFactory(ctx.cwd); + const settings = readClearOnShrinkSettings(settingsManager); + const settingsErrors = settingsManager.drainErrors?.() ?? []; + enableClearOnShrink = + settingsErrors.length === 0 && + shouldEnableWindowsClearOnShrink({ + platform, + mode: ctx.mode, + ...settings, + }); + } catch { + // Settings reads must never prevent the OpenPI session from starting. + } + } + + activeUi = ctx.ui; + ctx.ui.setWidget( + WIDGET_KEY, + (tui) => { + // The renderer can be replaced at runtime when the user switches TUI + // modes, so apply this when the factory receives the active renderer. + if (tui.mode === "regular" && enableClearOnShrink) { + tui.setClearOnShrink(true); + } + return { + render: () => [], + invalidate() {}, + }; + }, + { placement: "belowEditor" }, + ); + }); + + pi.on("session_shutdown", cleanup); +} + +export default function windowsTuiCompatibility(pi: ExtensionAPI) { + registerWindowsTuiCompatibility(pi, process.platform, async (cwd) => { + const { SettingsManager } = await import("@earendil-works/pi-coding-agent"); + return SettingsManager.create(cwd); + }); +} diff --git a/tests/extensions/windows-tui-compatibility/index.test.ts b/tests/extensions/windows-tui-compatibility/index.test.ts new file mode 100644 index 00000000..4c95d34e --- /dev/null +++ b/tests/extensions/windows-tui-compatibility/index.test.ts @@ -0,0 +1,205 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import type { + ExtensionAPI, + ExtensionContext, +} from "@earendil-works/pi-coding-agent"; +import type { Component, TUI } from "@earendil-works/pi-tui"; +import { + registerWindowsTuiCompatibility, + shouldEnableWindowsClearOnShrink, + shouldInstallWindowsTuiCompatibility, +} from "../../../extensions/windows-tui-compatibility/index.ts"; + +type WidgetFactory = ( + tui: TUI, + theme: unknown, +) => Component & { dispose?(): void }; + +function createHarness( + platform: NodeJS.Platform, + mode: ExtensionContext["mode"] = "tui", + settingsManagerFactory?: Parameters< + typeof registerWindowsTuiCompatibility + >[2], +) { + const hooks = new Map< + string, + (event: unknown, ctx: ExtensionContext) => unknown + >(); + let widgetFactory: WidgetFactory | undefined; + let widgetCleared = false; + const notifications: string[] = []; + + const pi = { + on(event: string, handler: unknown) { + hooks.set( + event, + handler as (event: unknown, ctx: ExtensionContext) => unknown, + ); + }, + } as unknown as ExtensionAPI; + + const ctx = { + cwd: "C:\\project", + mode, + hasUI: mode === "tui", + ui: { + setWidget(_key: string, content: WidgetFactory | undefined) { + if (content) widgetFactory = content; + else { + widgetFactory = undefined; + widgetCleared = true; + } + }, + notify(message: string) { + notifications.push(message); + }, + }, + } as unknown as ExtensionContext; + + registerWindowsTuiCompatibility(pi, platform, settingsManagerFactory); + + return { + ctx, + emit(event: string) { + return hooks.get(event)?.({}, ctx); + }, + mount(tui: TUI) { + return widgetFactory?.(tui, {}); + }, + get widgetFactory() { + return widgetFactory; + }, + get widgetCleared() { + return widgetCleared; + }, + get notifications() { + return notifications; + }, + }; +} + +test("installs only for interactive Windows sessions", () => { + assert.equal(shouldInstallWindowsTuiCompatibility("win32", "tui"), true); + assert.equal(shouldInstallWindowsTuiCompatibility("linux", "tui"), false); + assert.equal(shouldInstallWindowsTuiCompatibility("win32", "rpc"), false); + + assert.equal( + shouldEnableWindowsClearOnShrink({ platform: "win32", mode: "tui" }), + true, + ); + assert.equal( + shouldEnableWindowsClearOnShrink({ + platform: "win32", + mode: "tui", + globalClearOnShrink: false, + }), + false, + ); + assert.equal( + shouldEnableWindowsClearOnShrink({ + platform: "win32", + mode: "tui", + globalClearOnShrink: true, + }), + false, + ); + assert.equal( + shouldEnableWindowsClearOnShrink({ + platform: "win32", + mode: "tui", + projectClearOnShrink: false, + }), + false, + ); + + const linux = createHarness("linux"); + linux.emit("session_start"); + assert.equal(linux.widgetFactory, undefined); +}); + +test("enables clear-on-shrink for regular TUI but not fullscreen", async () => { + const harness = createHarness("win32", "tui", async () => ({ + getGlobalSettings: () => ({}), + getProjectSettings: () => ({}), + })); + await harness.emit("session_start"); + + const clearOnShrink: boolean[] = []; + harness.mount({ + mode: "regular", + setClearOnShrink(enabled: boolean) { + clearOnShrink.push(enabled); + }, + requestRender(force?: boolean) { + assert.equal(force, undefined); + }, + } as TUI); + assert.deepEqual(clearOnShrink, [true]); + + clearOnShrink.length = 0; + harness.mount({ + mode: "fullscreen", + setClearOnShrink(enabled: boolean) { + clearOnShrink.push(enabled); + }, + requestRender(force?: boolean) { + assert.equal(force, undefined); + }, + } as TUI); + assert.deepEqual(clearOnShrink, []); +}); + +test("keeps the workaround session-local and respects explicit clear-on-shrink", async () => { + const writes: string[] = []; + const explicit = createHarness("win32", "tui", async () => ({ + getGlobalSettings: () => ({ terminal: { clearOnShrink: false } }), + getProjectSettings: () => ({}), + drainErrors: () => [], + })); + await explicit.emit("session_start"); + + const clearOnShrink: boolean[] = []; + explicit.mount({ + mode: "regular", + setClearOnShrink(enabled: boolean) { + writes.push("clear-on-shrink"); + clearOnShrink.push(enabled); + }, + requestRender() {}, + } as TUI); + + assert.deepEqual(clearOnShrink, []); + assert.deepEqual(explicit.notifications, []); + assert.deepEqual(writes, []); +}); + +test("fails closed when settings cannot be read", async () => { + const harness = createHarness("win32", "tui", async () => { + throw new Error("malformed settings"); + }); + await harness.emit("session_start"); + + const clearOnShrink: boolean[] = []; + harness.mount({ + mode: "regular", + setClearOnShrink(enabled: boolean) { + clearOnShrink.push(enabled); + }, + requestRender() {}, + } as TUI); + + assert.deepEqual(clearOnShrink, []); +}); + +test("cleans up the compatibility widget on shutdown", () => { + const harness = createHarness("win32"); + harness.emit("session_start"); + assert.ok(harness.widgetFactory); + + harness.emit("session_shutdown"); + + assert.equal(harness.widgetFactory, undefined); + assert.equal(harness.widgetCleared, true); +});