feat: sync upstream pi-mono (386 commits)

从上游 badlogic/pi-mono 同步 386 个提交,手动解决所有冲突:

保留 SproutClaw 独有功能:
- RPC: reload 命令、bash 流式输出、get_extensions 命令
- settings-manager: showChangelogOnStartup 设置
- agent-session: turnIndex getter
- 移除 pi 版本检查通知(interactive-mode)
- skills 系统、启动脚本、自定义工具脚本

直接采用上游版本:
- packages/ai 模型列表及测试文件
- packages/coding-agent 文档
- 其余未冲突的全部上游代码

升级 @mistralai/mistralai 至 2.2.6(修复 promptCacheKey 类型错误)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-21 20:18:02 +08:00
409 changed files with 27402 additions and 6514 deletions

View File

@@ -1,7 +1,9 @@
import { homedir } from "node:os";
import * as path from "node:path";
import { type AutocompleteProvider, CombinedAutocompleteProvider, Container } from "@earendil-works/pi-tui";
import { type AutocompleteProvider, CombinedAutocompleteProvider } from "@earendil-works/pi-tui";
import { beforeAll, describe, expect, test, vi } from "vitest";
import { type Component, Container, type Focusable, TUI } from "../../tui/src/tui.ts";
import { VirtualTerminal } from "../../tui/test/virtual-terminal.ts";
import type { AutocompleteProviderFactory } from "../src/core/extensions/types.ts";
import type { SourceInfo } from "../src/core/source-info.ts";
import { InteractiveMode } from "../src/modes/interactive/interactive-mode.ts";
@@ -17,6 +19,41 @@ function renderAll(container: Container, width = 120): string {
return container.children.flatMap((child) => child.render(width)).join("\n");
}
class TestFocusableComponent implements Component, Focusable {
focused = false;
inputs: string[] = [];
private readonly label: string;
private text = "";
constructor(label: string) {
this.label = label;
}
handleInput(data: string): void {
this.inputs.push(data);
}
getText(): string {
return this.text;
}
setText(text: string): void {
this.text = text;
}
render(): string[] {
return [this.label];
}
invalidate(): void {}
}
async function flushTui(tui: TUI, terminal: VirtualTerminal): Promise<void> {
tui.requestRender(true);
await Promise.resolve();
await terminal.waitForRender();
}
function normalizeRenderedOutput(container: Container, width = 220): string {
return renderAll(container, width)
.replace(/\u001b\[[0-9;]*m/g, "")
@@ -114,6 +151,13 @@ describe("InteractiveMode.createExtensionUIContext setTheme", () => {
const fakeThis: any = {
session: { settingsManager },
settingsManager,
themeController: {
setThemeInstance: vi.fn(() => ({ success: true })),
setThemeName: vi.fn(() => {
fakeThis.ui.requestRender();
return { success: true };
}),
},
ui: { requestRender: vi.fn() },
};
@@ -121,6 +165,7 @@ describe("InteractiveMode.createExtensionUIContext setTheme", () => {
const result = uiContext.setTheme("light");
expect(result.success).toBe(true);
expect(fakeThis.themeController.setThemeName).toHaveBeenCalledWith("light");
expect(settingsManager.setTheme).toHaveBeenCalledWith("light");
expect(currentTheme).toBe("light");
expect(fakeThis.ui.requestRender).toHaveBeenCalledTimes(1);
@@ -136,6 +181,10 @@ describe("InteractiveMode.createExtensionUIContext setTheme", () => {
const fakeThis: any = {
session: { settingsManager },
settingsManager,
themeController: {
setThemeInstance: vi.fn(() => ({ success: true })),
setThemeName: vi.fn(() => ({ success: false, error: "Theme not found" })),
},
ui: { requestRender: vi.fn() },
};
@@ -143,11 +192,84 @@ describe("InteractiveMode.createExtensionUIContext setTheme", () => {
const result = uiContext.setTheme("__missing_theme__");
expect(result.success).toBe(false);
expect(fakeThis.themeController.setThemeName).toHaveBeenCalledWith("__missing_theme__");
expect(settingsManager.setTheme).not.toHaveBeenCalled();
expect(fakeThis.ui.requestRender).not.toHaveBeenCalled();
});
});
describe("InteractiveMode.showExtensionCustom", () => {
beforeAll(() => {
initTheme("dark");
});
test("overlay custom UI reclaims input after non-overlay custom UI closes", async () => {
const terminal = new VirtualTerminal(80, 24);
const ui = new TUI(terminal);
const editorContainer = new Container();
const editor = new TestFocusableComponent("EDITOR");
const palette = new TestFocusableComponent("PALETTE");
const overlay = new TestFocusableComponent("OVERLAY");
const replacement = new TestFocusableComponent("REPLACEMENT");
let closeOverlay: (value: string) => void = () => {
throw new Error("closeOverlay was not initialized");
};
let closeReplacement: (value: string) => void = () => {
throw new Error("closeReplacement was not initialized");
};
const fakeThis = {
editor,
editorContainer,
keybindings: {},
ui,
};
const showExtensionCustom = <T>(
factory: (tui: TUI, theme: unknown, keybindings: unknown, done: (result: T) => void) => Component,
options?: { overlay?: boolean },
): Promise<T> =>
(InteractiveMode as any).prototype.showExtensionCustom.call(fakeThis, factory, options) as Promise<T>;
editorContainer.addChild(editor);
ui.addChild(editorContainer);
ui.addChild(palette);
ui.setFocus(palette);
ui.start();
try {
const overlayPromise = showExtensionCustom<string>(
(_tui, _theme, _keybindings, done) => {
closeOverlay = done;
return overlay;
},
{ overlay: true },
);
await flushTui(ui, terminal);
expect(overlay.focused).toBe(true);
const replacementPromise = showExtensionCustom<string>((_tui, _theme, _keybindings, done) => {
closeReplacement = done;
return replacement;
});
await flushTui(ui, terminal);
expect(replacement.focused).toBe(true);
closeReplacement("done");
await replacementPromise;
await flushTui(ui, terminal);
terminal.sendInput("x");
await flushTui(ui, terminal);
expect(overlay.inputs).toEqual(["x"]);
expect(editor.inputs).toEqual([]);
expect(overlay.focused).toBe(true);
closeOverlay("closed");
await overlayPromise;
} finally {
ui.stop();
}
});
});
describe("InteractiveMode.createExtensionUIContext addAutocompleteProvider", () => {
test("stores wrapper factories and rebuilds autocomplete immediately", () => {
const wrapper: AutocompleteProviderFactory = (current) => current;
@@ -215,6 +337,89 @@ describe("InteractiveMode.setupAutocompleteProvider", () => {
expect(provider.shouldTriggerFileCompletion?.(["foo"], 0, 3)).toBe(true);
expect(calls).toEqual(["shouldTrigger:wrap2", "shouldTrigger:wrap1"]);
});
test("merges triggerCharacters from wrapper factories", () => {
const defaultEditor = { setAutocompleteProvider: vi.fn() };
const customEditor = { setAutocompleteProvider: vi.fn() };
const passThrough =
(triggerCharacters: string[]): AutocompleteProviderFactory =>
(current) => ({
triggerCharacters,
getSuggestions: (lines, cursorLine, cursorCol, options) =>
current.getSuggestions(lines, cursorLine, cursorCol, options),
applyCompletion: (lines, cursorLine, cursorCol, item, prefix) =>
current.applyCompletion(lines, cursorLine, cursorCol, item, prefix),
});
const fakeThis = {
createBaseAutocompleteProvider: () => new CombinedAutocompleteProvider([], "/tmp/project", undefined),
defaultEditor,
editor: customEditor,
autocompleteProviderWrappers: [passThrough(["$"]), passThrough(["!"])],
};
(
InteractiveMode as unknown as {
prototype: { setupAutocompleteProvider: (this: typeof fakeThis) => void };
}
).prototype.setupAutocompleteProvider.call(fakeThis);
const provider = defaultEditor.setAutocompleteProvider.mock.calls[0]?.[0] as AutocompleteProvider;
expect(provider.triggerCharacters).toEqual(["$", "!"]);
});
});
describe("InteractiveMode.createBaseAutocompleteProvider", () => {
test("matches model command arguments across provider/model order", async () => {
type TestModel = { id: string; provider: string; name: string };
type FakeInteractiveMode = {
session: {
scopedModels: Array<{ model: TestModel }>;
modelRegistry: { getAvailable: () => TestModel[] };
promptTemplates: [];
extensionRunner: { getRegisteredCommands: () => [] };
resourceLoader: { getSkills: () => { skills: [] } };
};
settingsManager: { getEnableSkillCommands: () => boolean };
skillCommands: Map<string, string>;
sessionManager: { getCwd: () => string };
fdPath: null;
};
const createBaseAutocompleteProvider = (
InteractiveMode as unknown as {
prototype: { createBaseAutocompleteProvider(this: FakeInteractiveMode): AutocompleteProvider };
}
).prototype.createBaseAutocompleteProvider;
const models = [
{ id: "gpt-5.2-codex", provider: "github-copilot", name: "GPT-5.2 Codex" },
{ id: "gpt-5.5", provider: "openai-codex", name: "GPT-5.5" },
];
const fakeThis: FakeInteractiveMode = {
session: {
scopedModels: [],
modelRegistry: { getAvailable: () => models },
promptTemplates: [],
extensionRunner: { getRegisteredCommands: () => [] },
resourceLoader: { getSkills: () => ({ skills: [] }) },
},
settingsManager: { getEnableSkillCommands: () => false },
skillCommands: new Map(),
sessionManager: { getCwd: () => "/tmp" },
fdPath: null,
};
const provider = createBaseAutocompleteProvider.call(fakeThis);
const line = "/model codexgpt";
const suggestions = await provider.getSuggestions([line], 0, line.length, {
signal: new AbortController().signal,
});
expect(suggestions?.items.map((item) => item.value)).toEqual([
"openai-codex/gpt-5.5",
"github-copilot/gpt-5.2-codex",
]);
});
});
describe("InteractiveMode.showLoadedResources", () => {