Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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 会在没有明确 TUI 配置时为下一次启动选择 `fullscreen`,避免 Pi `regular` TUI 的旧补全行残留;如果你明确选择 `regular`,OpenPI 会保留该选择并启用尽量安全的收缩清理。

```text
/openpi-setup
```
Expand Down
22 changes: 22 additions & 0 deletions SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,28 @@ 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 selects a TUI mode, it saves `fullscreen` as the safe default for
the next start and shows a one-time restart notice. This prevents old
slash-command autocomplete rows from making commands look duplicated.

If you explicitly select `regular`, OpenPI preserves that choice and enables
Pi's supported `clear-on-shrink` behavior as a best-effort mitigation.

If you prefer the alternate-screen renderer, Pi's `fullscreen` mode 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
133 changes: 133 additions & 0 deletions extensions/windows-tui-compatibility/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import type {
ExtensionAPI,
ExtensionContext,
} from "@earendil-works/pi-coding-agent";

type TuiMode = "regular" | "fullscreen";

interface TuiSettingsManager {
getGlobalSettings(): { tuiMode?: TuiMode };
getProjectSettings(): { tuiMode?: TuiMode };
setTuiMode?(mode: TuiMode): void;
flush?(): Promise<void>;
}

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 shouldPreferWindowsFullscreen(options: {
platform: NodeJS.Platform;
mode: ExtensionContext["mode"];
globalTuiMode?: TuiMode;
projectTuiMode?: TuiMode;
explicitCliTuiMode?: boolean;
}) {
return (
shouldInstallWindowsTuiCompatibility(options.platform, options.mode) &&
options.globalTuiMode === undefined &&
options.projectTuiMode === undefined &&
options.explicitCliTuiMode !== true
);
}

/**
* 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.
*/
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;

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") tui.setClearOnShrink(true);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Respect an explicit terminal.clearOnShrink preference

For a user who explicitly selects regular mode and terminal.clearOnShrink:false, mounting this widget unconditionally flips the live renderer to true while the stored/native setting remains false. I reproduced that mismatch with SettingsManager.inMemory and the widget factory. This overrides an existing Pi rendering preference (including users reducing redraws on slow terminals), despite preserving their explicit regular mode. Apply the fallback only when the user has not explicitly configured clear-on-shrink, or require an explicit opt-in, and add a regression for the false setting.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 4465e9d. The fallback is enabled only when neither global nor project settings explicitly defines terminal.clearOnShrink; an explicit false (or true) is left untouched. The focused regression covers the explicit false case.

return {
render: () => [],
invalidate() {},
};
},
{ placement: "belowEditor" },
);

if (!settingsManagerFactory) return;

try {
const settingsManager = await settingsManagerFactory(ctx.cwd);
const globalSettings = settingsManager.getGlobalSettings();
const projectSettings = settingsManager.getProjectSettings();
const explicitCliTuiMode = process.argv.some(
(arg) => arg === "--tui-mode" || arg.startsWith("--tui-mode="),
);

if (
!shouldPreferWindowsFullscreen({
platform,
mode: ctx.mode,
globalTuiMode: globalSettings.tuiMode,
projectTuiMode: projectSettings.tuiMode,
explicitCliTuiMode,
}) ||
settingsManager.setTuiMode === undefined
) {
return;
}

settingsManager.setTuiMode("fullscreen");

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Keep the Windows workaround from silently changing global Pi preferences

Every interactive Windows session without an explicit tuiMode reaches this write; there is no detection of stale rows. Pi SettingsManager.setTuiMode persists a global setting, so opening OpenPI in one workspace also changes subsequent Pi sessions in other workspaces. I reproduced this with the locked real SettingsManager and two temporary workspace paths sharing one agent directory. This exceeds a session-local rendering workaround and conflicts with the repository requirement to preserve Pi-owned preferences and side-effect-safe installation. Please make the persistent mode change an explicit user choice through the native Pi settings flow, or keep the mitigation scoped to the affected session.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 4465e9d. The extension no longer calls setTuiMode, flush, or any settings writer. It only applies the renderer workaround to the current interactive Windows session; persistent mode selection remains with Pi's native settings flow.

await settingsManager.flush?.();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Verify persistence before announcing that fullscreen was selected

The locked Pi SettingsManager records settings load/write errors internally; flush() waits for its queue but does not necessarily reject. With a malformed global settings.json, setTuiMode does not save, flush resolves, and this code still tells the user to restart to apply fullscreen. I reproduced this using the real SettingsManager: the invalid file remained unchanged, drainErrors() reported a global error, and the success/restart notification was emitted. Check the settings error/result and persisted readback before announcing success; report an actionable failure otherwise. The current mock cannot cover this behavior.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 4465e9d. The persistence path and success/restart notification were removed entirely, so a failed settings write can no longer be reported as successful. The extension now reads settings only and fails closed when the read reports errors.

ctx.ui.notify(
"OpenPI detected the Windows regular-TUI redraw issue and selected fullscreen mode for the next start. Restart Pi to apply it.",
"warning",
);
} catch {
// A settings write must never prevent the OpenPI session from starting.
}
});

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);
});
}
189 changes: 189 additions & 0 deletions tests/extensions/windows-tui-compatibility/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
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,
shouldInstallWindowsTuiCompatibility,
shouldPreferWindowsFullscreen,
} 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(
shouldPreferWindowsFullscreen({ platform: "win32", mode: "tui" }),
true,
);
assert.equal(
shouldPreferWindowsFullscreen({
platform: "win32",
mode: "tui",
globalTuiMode: "regular",
}),
false,
);
assert.equal(
shouldPreferWindowsFullscreen({
platform: "win32",
mode: "tui",
projectTuiMode: "fullscreen",
}),
false,
);
assert.equal(
shouldPreferWindowsFullscreen({
platform: "win32",
mode: "tui",
explicitCliTuiMode: true,
}),
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", () => {
const harness = createHarness("win32");
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("persists fullscreen only when no TUI mode is configured", async () => {
let selectedMode: string | undefined;
let flushCount = 0;
const unset = createHarness("win32", "tui", async () => ({
getGlobalSettings: () => ({}),
getProjectSettings: () => ({}),
setTuiMode: (mode: "regular" | "fullscreen") => {
selectedMode = mode;
},
flush: async () => {
flushCount += 1;
},
}));
await unset.emit("session_start");
assert.equal(selectedMode, "fullscreen");
assert.equal(flushCount, 1);
assert.match(unset.notifications[0] ?? "", /Restart Pi/);

const explicit = createHarness("win32", "tui", async () => ({
getGlobalSettings: () => ({ tuiMode: "regular" as const }),
getProjectSettings: () => ({}),
setTuiMode: () => {
throw new Error("must not override an explicit mode");
},
}));
await explicit.emit("session_start");
assert.deepEqual(explicit.notifications, []);
});

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