mirror of
https://github.com/earendil-works/pi.git
synced 2026-06-18 15:54:04 +08:00
7b45c52807
closes #3147
326 lines
11 KiB
TypeScript
326 lines
11 KiB
TypeScript
import { homedir } from "node:os";
|
|
import * as path from "node:path";
|
|
import { Container } from "@mariozechner/pi-tui";
|
|
import { beforeAll, describe, expect, test, vi } from "vitest";
|
|
import { InteractiveMode } from "../src/modes/interactive/interactive-mode.js";
|
|
import { initTheme } from "../src/modes/interactive/theme/theme.js";
|
|
|
|
function renderLastLine(container: Container, width = 120): string {
|
|
const last = container.children[container.children.length - 1];
|
|
if (!last) return "";
|
|
return last.render(width).join("\n");
|
|
}
|
|
|
|
function renderAll(container: Container, width = 120): string {
|
|
return container.children.flatMap((child) => child.render(width)).join("\n");
|
|
}
|
|
|
|
describe("InteractiveMode.showStatus", () => {
|
|
beforeAll(() => {
|
|
// showStatus uses the global theme instance
|
|
initTheme("dark");
|
|
});
|
|
|
|
test("coalesces immediately-sequential status messages", () => {
|
|
const fakeThis: any = {
|
|
chatContainer: new Container(),
|
|
ui: { requestRender: vi.fn() },
|
|
lastStatusSpacer: undefined,
|
|
lastStatusText: undefined,
|
|
};
|
|
|
|
(InteractiveMode as any).prototype.showStatus.call(fakeThis, "STATUS_ONE");
|
|
expect(fakeThis.chatContainer.children).toHaveLength(2);
|
|
expect(renderLastLine(fakeThis.chatContainer)).toContain("STATUS_ONE");
|
|
|
|
(InteractiveMode as any).prototype.showStatus.call(fakeThis, "STATUS_TWO");
|
|
// second status updates the previous line instead of appending
|
|
expect(fakeThis.chatContainer.children).toHaveLength(2);
|
|
expect(renderLastLine(fakeThis.chatContainer)).toContain("STATUS_TWO");
|
|
expect(renderLastLine(fakeThis.chatContainer)).not.toContain("STATUS_ONE");
|
|
});
|
|
|
|
test("appends a new status line if something else was added in between", () => {
|
|
const fakeThis: any = {
|
|
chatContainer: new Container(),
|
|
ui: { requestRender: vi.fn() },
|
|
lastStatusSpacer: undefined,
|
|
lastStatusText: undefined,
|
|
};
|
|
|
|
(InteractiveMode as any).prototype.showStatus.call(fakeThis, "STATUS_ONE");
|
|
expect(fakeThis.chatContainer.children).toHaveLength(2);
|
|
|
|
// Something else gets added to the chat in between status updates
|
|
fakeThis.chatContainer.addChild({ render: () => ["OTHER"], invalidate: () => {} });
|
|
expect(fakeThis.chatContainer.children).toHaveLength(3);
|
|
|
|
(InteractiveMode as any).prototype.showStatus.call(fakeThis, "STATUS_TWO");
|
|
// adds spacer + text
|
|
expect(fakeThis.chatContainer.children).toHaveLength(5);
|
|
expect(renderLastLine(fakeThis.chatContainer)).toContain("STATUS_TWO");
|
|
});
|
|
});
|
|
|
|
describe("InteractiveMode.setToolsExpanded", () => {
|
|
test("applies expansion state to the active header and chat entries", () => {
|
|
const header = { setExpanded: vi.fn() };
|
|
const chatChild = { setExpanded: vi.fn() };
|
|
const fakeThis: any = {
|
|
toolOutputExpanded: false,
|
|
customHeader: undefined,
|
|
builtInHeader: header,
|
|
chatContainer: { children: [chatChild] },
|
|
ui: { requestRender: vi.fn() },
|
|
};
|
|
|
|
(InteractiveMode as any).prototype.setToolsExpanded.call(fakeThis, true);
|
|
|
|
expect(fakeThis.toolOutputExpanded).toBe(true);
|
|
expect(header.setExpanded).toHaveBeenCalledWith(true);
|
|
expect(chatChild.setExpanded).toHaveBeenCalledWith(true);
|
|
expect(fakeThis.ui.requestRender).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
describe("InteractiveMode.createExtensionUIContext setTheme", () => {
|
|
test("persists theme changes to settings manager", () => {
|
|
initTheme("dark");
|
|
|
|
let currentTheme = "dark";
|
|
const settingsManager = {
|
|
getTheme: vi.fn(() => currentTheme),
|
|
setTheme: vi.fn((theme: string) => {
|
|
currentTheme = theme;
|
|
}),
|
|
};
|
|
const fakeThis: any = {
|
|
session: { settingsManager },
|
|
settingsManager,
|
|
ui: { requestRender: vi.fn() },
|
|
};
|
|
|
|
const uiContext = (InteractiveMode as any).prototype.createExtensionUIContext.call(fakeThis);
|
|
const result = uiContext.setTheme("light");
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(settingsManager.setTheme).toHaveBeenCalledWith("light");
|
|
expect(currentTheme).toBe("light");
|
|
expect(fakeThis.ui.requestRender).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
test("does not persist invalid theme names", () => {
|
|
initTheme("dark");
|
|
|
|
const settingsManager = {
|
|
getTheme: vi.fn(() => "dark"),
|
|
setTheme: vi.fn(),
|
|
};
|
|
const fakeThis: any = {
|
|
session: { settingsManager },
|
|
settingsManager,
|
|
ui: { requestRender: vi.fn() },
|
|
};
|
|
|
|
const uiContext = (InteractiveMode as any).prototype.createExtensionUIContext.call(fakeThis);
|
|
const result = uiContext.setTheme("__missing_theme__");
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(settingsManager.setTheme).not.toHaveBeenCalled();
|
|
expect(fakeThis.ui.requestRender).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("InteractiveMode.showLoadedResources", () => {
|
|
beforeAll(() => {
|
|
initTheme("dark");
|
|
});
|
|
|
|
function createShowLoadedResourcesThis(options: {
|
|
quietStartup: boolean;
|
|
verbose?: boolean;
|
|
toolOutputExpanded?: boolean;
|
|
cwd?: string;
|
|
contextFiles?: Array<{ path: string; content?: string }>;
|
|
extensions?: Array<{ path: string }>;
|
|
skills?: Array<{ filePath: string; name: string }>;
|
|
skillDiagnostics?: Array<{ type: "warning" | "error" | "collision"; message: string }>;
|
|
}) {
|
|
const fakeThis: any = {
|
|
options: { verbose: options.verbose ?? false },
|
|
toolOutputExpanded: options.toolOutputExpanded ?? false,
|
|
chatContainer: new Container(),
|
|
settingsManager: {
|
|
getQuietStartup: () => options.quietStartup,
|
|
},
|
|
sessionManager: {
|
|
getCwd: () => options.cwd ?? "/tmp/project",
|
|
},
|
|
session: {
|
|
promptTemplates: [],
|
|
extensionRunner: undefined,
|
|
resourceLoader: {
|
|
getPathMetadata: () => new Map(),
|
|
getAgentsFiles: () => ({ agentsFiles: options.contextFiles ?? [] }),
|
|
getSkills: () => ({
|
|
skills: options.skills ?? [],
|
|
diagnostics: options.skillDiagnostics ?? [],
|
|
}),
|
|
getPrompts: () => ({ prompts: [], diagnostics: [] }),
|
|
getExtensions: () => ({ extensions: options.extensions ?? [], errors: [], runtime: {} }),
|
|
getThemes: () => ({ themes: [], diagnostics: [] }),
|
|
},
|
|
},
|
|
formatDisplayPath: (p: string) => (InteractiveMode as any).prototype.formatDisplayPath.call(fakeThis, p),
|
|
formatContextPath: (p: string) => (InteractiveMode as any).prototype.formatContextPath.call(fakeThis, p),
|
|
getStartupExpansionState: () => (InteractiveMode as any).prototype.getStartupExpansionState.call(fakeThis),
|
|
buildScopeGroups: () => [],
|
|
formatScopeGroups: () => "resource-list",
|
|
getShortPath: (p: string) => p,
|
|
formatDiagnostics: () => "diagnostics",
|
|
getBuiltInCommandConflictDiagnostics: () => [],
|
|
};
|
|
|
|
return fakeThis;
|
|
}
|
|
|
|
test("shows a compact resource listing by default", () => {
|
|
const fakeThis = createShowLoadedResourcesThis({
|
|
quietStartup: false,
|
|
skills: [{ filePath: "/tmp/skill/SKILL.md", name: "commit" }],
|
|
});
|
|
|
|
(InteractiveMode as any).prototype.showLoadedResources.call(fakeThis, {
|
|
force: false,
|
|
});
|
|
|
|
const output = renderAll(fakeThis.chatContainer);
|
|
expect(output).toContain("[Skills]");
|
|
expect(output).toContain("commit");
|
|
expect(output).not.toContain("resource-list");
|
|
});
|
|
|
|
test("shows full resource listing when expanded", () => {
|
|
const fakeThis = createShowLoadedResourcesThis({
|
|
quietStartup: false,
|
|
toolOutputExpanded: true,
|
|
skills: [{ filePath: "/tmp/skill/SKILL.md", name: "commit" }],
|
|
});
|
|
|
|
(InteractiveMode as any).prototype.showLoadedResources.call(fakeThis, {
|
|
force: false,
|
|
});
|
|
|
|
const output = renderAll(fakeThis.chatContainer);
|
|
expect(output).toContain("[Skills]");
|
|
expect(output).toContain("resource-list");
|
|
expect(output).not.toContain("commit");
|
|
});
|
|
|
|
test("shows full resource listing on verbose startup even when tool output is collapsed", () => {
|
|
const fakeThis = createShowLoadedResourcesThis({
|
|
quietStartup: true,
|
|
verbose: true,
|
|
toolOutputExpanded: false,
|
|
skills: [{ filePath: "/tmp/skill/SKILL.md", name: "commit" }],
|
|
});
|
|
|
|
(InteractiveMode as any).prototype.showLoadedResources.call(fakeThis, {
|
|
force: false,
|
|
});
|
|
|
|
const output = renderAll(fakeThis.chatContainer);
|
|
expect(output).toContain("[Skills]");
|
|
expect(output).toContain("resource-list");
|
|
expect(output).not.toContain("commit");
|
|
});
|
|
|
|
test("abbreviates extensions in compact listing", () => {
|
|
const fakeThis = createShowLoadedResourcesThis({
|
|
quietStartup: false,
|
|
extensions: [{ path: "/tmp/extensions/answer.ts" }, { path: "/tmp/extensions/btw.ts" }],
|
|
});
|
|
|
|
(InteractiveMode as any).prototype.showLoadedResources.call(fakeThis, {
|
|
force: false,
|
|
});
|
|
|
|
const output = renderAll(fakeThis.chatContainer);
|
|
expect(output).toContain("[Extensions]");
|
|
expect(output).toContain("answer.ts, btw.ts");
|
|
expect(output).not.toContain("extensions/answer.ts");
|
|
});
|
|
|
|
test("shows context paths relative to cwd while preserving full external paths", () => {
|
|
const home = homedir();
|
|
const cwd = path.join(home, "Development", "pi-mono");
|
|
const fakeThis = createShowLoadedResourcesThis({
|
|
quietStartup: false,
|
|
cwd,
|
|
contextFiles: [{ path: path.join(home, ".pi", "agent", "AGENTS.md") }, { path: path.join(cwd, "AGENTS.md") }],
|
|
});
|
|
|
|
(InteractiveMode as any).prototype.showLoadedResources.call(fakeThis, {
|
|
force: false,
|
|
});
|
|
|
|
const output = renderAll(fakeThis.chatContainer).replace(/\\/g, "/");
|
|
expect(output).toContain("[Context]");
|
|
expect(output).toContain("~/.pi/agent/AGENTS.md, AGENTS.md");
|
|
expect(output).not.toContain(`${cwd.replace(/\\/g, "/")}/AGENTS.md`);
|
|
});
|
|
|
|
test("shows full context paths when expanded", () => {
|
|
const home = homedir();
|
|
const cwd = path.join(home, "Development", "pi-mono");
|
|
const fakeThis = createShowLoadedResourcesThis({
|
|
quietStartup: false,
|
|
toolOutputExpanded: true,
|
|
cwd,
|
|
contextFiles: [{ path: path.join(home, ".pi", "agent", "AGENTS.md") }, { path: path.join(cwd, "AGENTS.md") }],
|
|
});
|
|
|
|
(InteractiveMode as any).prototype.showLoadedResources.call(fakeThis, {
|
|
force: false,
|
|
});
|
|
|
|
const output = renderAll(fakeThis.chatContainer).replace(/\\/g, "/");
|
|
expect(output).toContain("[Context]");
|
|
expect(output).toContain("~/.pi/agent/AGENTS.md");
|
|
expect(output).toContain("~/Development/pi-mono/AGENTS.md");
|
|
expect(output).not.toContain("~/.pi/agent/AGENTS.md, AGENTS.md");
|
|
});
|
|
|
|
test("does not show verbose listing on quiet startup during reload", () => {
|
|
const fakeThis = createShowLoadedResourcesThis({
|
|
quietStartup: true,
|
|
skills: [{ filePath: "/tmp/skill/SKILL.md", name: "commit" }],
|
|
});
|
|
|
|
(InteractiveMode as any).prototype.showLoadedResources.call(fakeThis, {
|
|
extensions: [{ path: "/tmp/ext/index.ts" }],
|
|
force: false,
|
|
showDiagnosticsWhenQuiet: true,
|
|
});
|
|
|
|
expect(fakeThis.chatContainer.children).toHaveLength(0);
|
|
});
|
|
|
|
test("still shows diagnostics on quiet startup when requested", () => {
|
|
const fakeThis = createShowLoadedResourcesThis({
|
|
quietStartup: true,
|
|
skills: [{ filePath: "/tmp/skill/SKILL.md", name: "commit" }],
|
|
skillDiagnostics: [{ type: "warning", message: "duplicate skill name" }],
|
|
});
|
|
|
|
(InteractiveMode as any).prototype.showLoadedResources.call(fakeThis, {
|
|
force: false,
|
|
showDiagnosticsWhenQuiet: true,
|
|
});
|
|
|
|
const output = renderAll(fakeThis.chatContainer);
|
|
expect(output).toContain("[Skill conflicts]");
|
|
expect(output).not.toContain("[Skills]");
|
|
});
|
|
});
|