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:
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user