Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand Down
25 changes: 25 additions & 0 deletions SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
135 changes: 135 additions & 0 deletions extensions/windows-tui-compatibility/index.ts
Original file line number Diff line number Diff line change
@@ -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<TuiSettingsManager>;

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);
});
}
205 changes: 205 additions & 0 deletions tests/extensions/windows-tui-compatibility/index.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
Loading