diff --git a/CHANGELOG.md b/CHANGELOG.md index 3617c3b..5ee5d8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,13 @@ All notable changes to the PiLot Studio for VS Code extension will be documented ### Added +- **System prompt file editing from the GUI** (Settings → Configuration Files): "Open SYSTEM.md" and "Open APPEND_SYSTEM.md" buttons create the file in PI's agent directory (default `~/.pi/agent`, configurable via `pi-agent.agentDir`) if missing and open it in a VS Code editor tab. SYSTEM.md replaces PI's default system prompt for new sessions (with a confirmation before first creation); APPEND_SYSTEM.md appends to it. A warning is shown in Settings when `pi-agent.systemPrompt` / `pi-agent.appendSystemPrompts` override these files. + +### Fixed + +- The `pi-agent.agentDir` setting was never applied; it now takes effect on extension activation (window reload required after changing it). +- Test launcher silently exiting without running tests on distro VS Code installs where `code` is a wrapper script. + - **PI Light Mode** toggle (Settings → Agent Behavior, or the `pi-agent.lightMode` setting) for running pi in a reduced mode ideal for local LLMs via llama.cpp — equivalent to `pi --no-skills --no-extensions --no-context-files --no-prompt-templates --no-themes --tools read,bash,edit,write`. Overrides the individual discovery toggles and restricts tools to `read`, `bash`, `edit`, `write` when the tool preset is `default`; toggling restarts the current session with its history preserved. ## [2.4.1] - 2026-09-08 diff --git a/package.json b/package.json index 860cddc..5d8c1ac 100644 --- a/package.json +++ b/package.json @@ -259,7 +259,7 @@ "type": "string", "default": "", "order": 2, - "markdownDescription": "Directory where pi stores its auth, models, sessions, and other agent files. Leave empty to use `~/.pi/agent`." + "markdownDescription": "Directory where pi stores its auth, models, sessions, and other agent files. Leave empty to use `~/.pi/agent`. Changes take effect after a window reload (applied at extension activation)." }, "pi-agent.sessionDir": { "type": "string", diff --git a/src/extension.ts b/src/extension.ts index 7010b31..e8ec009 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -6,6 +6,13 @@ import { startUpdateChecker } from "./update-checker.js"; export async function activate(context: vscode.ExtensionContext) { const config = vscode.workspace.getConfiguration("pi-agent"); + // pi's SDK reads the agent directory only from PI_CODING_AGENT_DIR, so + // bridge the setting into it (applied at activation; reload to apply). + const agentDir = config.get("agentDir", "").trim(); + if (agentDir) { + process.env.PI_CODING_AGENT_DIR = agentDir; + } + const provider = new PiAgentProvider(context, { defaultModel: config.get("defaultModel", "anthropic/claude-sonnet-4-5"), defaultProvider: config.get("defaultProvider", "anthropic"), diff --git a/src/message-handler.ts b/src/message-handler.ts index b1ae9cf..7bfa343 100644 --- a/src/message-handler.ts +++ b/src/message-handler.ts @@ -2,7 +2,16 @@ import * as vscode from "vscode"; import * as os from "node:os"; import * as path from "node:path"; import * as fs from "node:fs"; -import { type ProviderApi } from "./protocol/types.js"; +import { type ProviderApi, type ConfigFileKey } from "./protocol/types.js"; + +/** Keys openConfigFile accepts; must mirror ConfigFileKey. */ +const OPEN_CONFIG_FILES: readonly string[] = [ + "auth", + "models", + "settings", + "system-prompt", + "append-system-prompt", +] satisfies readonly ConfigFileKey[]; export class MessageHandler { constructor(private provider: ProviderApi) {} @@ -309,17 +318,17 @@ export class MessageHandler { case "openConfigFile": { const file = message.data?.file; - const allowed = ["auth", "models", "settings"]; - if (typeof file !== "string" || !allowed.includes(file)) { + if ( + typeof file !== "string" || + !OPEN_CONFIG_FILES.includes(file) + ) { result = { error: `Invalid config file: ${String(file)}`, }; break; } await this.withErrorReporting(() => - this.provider.openConfigFile( - file as "auth" | "models" | "settings", - ), + this.provider.openConfigFile(file as ConfigFileKey), ); result = { success: true }; break; @@ -374,6 +383,16 @@ export class MessageHandler { await this.provider.sendSkillsList(); break; + case "getSystemPromptOverrides": { + const overrides = this.provider.getSystemPromptOverrides(); + this.provider.webview?.postMessage({ + type: "system-prompt-overrides-changed", + data: overrides, + }); + result = overrides; + break; + } + case "getSkillDiscovery": { const enabled = this.provider.getSkillDiscovery(); this.provider.webview?.postMessage({ diff --git a/src/pi-agent-provider.ts b/src/pi-agent-provider.ts index 6ab5fc2..3cec496 100644 --- a/src/pi-agent-provider.ts +++ b/src/pi-agent-provider.ts @@ -18,6 +18,7 @@ import { type PromptTemplate, } from "@earendil-works/pi-coding-agent"; import { MessageHandler } from "./message-handler.js"; +import { type ConfigFileKey } from "./protocol/types.js"; import { VoiceManager } from "./voice-manager.js"; import { type ImageContent, @@ -48,6 +49,19 @@ import { serializeMessages } from "./message-serializer.js"; // THINKING_LEVELS is imported from ./model-registry-handler.js so the host and // webview share a single ordered source of truth for the thinking-level list. +// Key → filename → creation policy behind openConfigFile. +const CONFIG_FILES: Record< + ConfigFileKey, + { fileName: string; initialContent: string; confirmCreate?: boolean } +> = { + auth: { fileName: "auth.json", initialContent: "{}" }, + models: { fileName: "models.json", initialContent: "{}" }, + settings: { fileName: "settings.json", initialContent: "{}" }, + // Creating it empty replaces pi's built-in system prompt, so confirm. + "system-prompt": { fileName: "SYSTEM.md", initialContent: "", confirmCreate: true }, + "append-system-prompt": { fileName: "APPEND_SYSTEM.md", initialContent: "" }, +}; + // ── OAuth login interaction shapes ─────────────────────────────────────────── // Structural mirrors of @earendil-works/pi-ai's AuthPrompt/AuthEvent (the SDK // entry point does not re-export them). Keep in sync with pi-ai 0.84. @@ -91,6 +105,8 @@ export const piAgentProviderInternals = { createAgentSession, getAgentDir, unlinkFile: (path: string) => fs.unlink(path), + // Tests stub filesystem access through this seam; ESM namespaces are frozen. + existsFile: (path: string) => existsSync(path), mkdir: (dir: string, options?: { recursive?: boolean }) => fs.mkdir(dir, options), writeFile: ( @@ -2669,21 +2685,39 @@ this.modelRegistryHandler.invalidateCliModelIdsCache(); } } - async openConfigFile(file: "auth" | "models" | "settings"): Promise { + /** Open a PI config/prompt file in an editor tab, creating it if missing. */ + async openConfigFile(file: ConfigFileKey): Promise { if (!this.isInitialized) { await this.initialize(); } - const fileName = `${file}.json`; + const spec = CONFIG_FILES[file]; const agentDir = piAgentProviderInternals.getAgentDir(); - const filePath = path.join(agentDir, fileName); + const filePath = path.join(agentDir, spec.fileName); await piAgentProviderInternals.mkdir(agentDir, { recursive: true }); - // Write only if the file does not already exist (flag "wx"). - try { - await piAgentProviderInternals.writeFile(filePath, "{}", { flag: "wx" }); - } catch (error: unknown) { - if ((error as NodeJS.ErrnoException)?.code !== "EEXIST") { - throw error; + + if (!piAgentProviderInternals.existsFile(filePath)) { + if (spec.confirmCreate) { + const choice = await vscode.window.showInformationMessage( + `Creating ${spec.fileName} will replace pi's default system prompt for new sessions. Continue?`, + { modal: false }, + "Create", + ); + if (choice !== "Create") { + return; + } + } + // Write only if the file does not already exist. + try { + await piAgentProviderInternals.writeFile( + filePath, + spec.initialContent, + { flag: "wx" }, + ); + } catch (error: unknown) { + if ((error as NodeJS.ErrnoException)?.code !== "EEXIST") { + throw error; + } } } @@ -3097,6 +3131,23 @@ this.modelRegistryHandler.invalidateCliModelIdsCache(); return !config.get("disableSkillDiscovery", false); } + /** + * Which pi-agent.* settings override PI's SYSTEM.md / APPEND_SYSTEM.md + * discovery: when set, the resource loader never consults the files. + */ + getSystemPromptOverrides(): { systemPrompt: boolean; appendSystemPrompts: boolean } { + const config = vscode.workspace.getConfiguration("pi-agent"); + const systemPrompt = config.get("systemPrompt", null); + const appendSystemPrompts = config.get( + "appendSystemPrompts", + [], + ); + return { + systemPrompt: !!systemPrompt, + appendSystemPrompts: appendSystemPrompts.length > 0, + }; + } + setSkillDiscovery(enabled: boolean): void { const config = vscode.workspace.getConfiguration("pi-agent"); config diff --git a/src/protocol/types.ts b/src/protocol/types.ts index 0b02ba5..e29993e 100644 --- a/src/protocol/types.ts +++ b/src/protocol/types.ts @@ -15,6 +15,14 @@ export interface ProviderMessage { isError?: boolean; } +/** Keys accepted by openConfigFile; filenames/creation policy live in the provider. */ +export type ConfigFileKey = + | "auth" + | "models" + | "settings" + | "system-prompt" + | "append-system-prompt"; + // ── Provider API interface — used by MessageHandler to avoid importing // the full PiAgentProvider class (breaks circular dependency) ──────── @@ -115,7 +123,7 @@ export interface ProviderApi { api?: string; apiKey?: string; }): Promise>; - openConfigFile(file: "auth" | "models" | "settings"): Promise; + openConfigFile(file: ConfigFileKey): Promise; toggleFavorite(modelId: string, isFavorite: boolean): Promise; listSessions(): Promise< Array<{ @@ -163,6 +171,8 @@ export interface ProviderApi { sendSkillsList(): Promise; getSkillDiscovery(): boolean; setSkillDiscovery(enabled: boolean): void; + /** Whether pi-agent.* settings currently override SYSTEM.md / APPEND_SYSTEM.md. */ + getSystemPromptOverrides(): { systemPrompt: boolean; appendSystemPrompts: boolean }; setExtraSkillPaths(paths: string[]): Promise; getExtraSkillPaths(): string[]; getLightMode(): boolean; diff --git a/src/test/mocks/pi-sdk-mocks.ts b/src/test/mocks/pi-sdk-mocks.ts index 5ea5b3c..bceb69f 100644 --- a/src/test/mocks/pi-sdk-mocks.ts +++ b/src/test/mocks/pi-sdk-mocks.ts @@ -240,6 +240,7 @@ export function resetVscodeMocks(): void { (vscode.workspace.findFiles as any) = async () => []; (vscode.workspace.openTextDocument as any) = async () => ({ getText: () => "" }); (vscode.workspace.fs.createDirectory as any) = async () => {}; + (vscode.workspace.fs.readFile as any) = async () => new Uint8Array(); (vscode.workspace.fs.writeFile as any) = async () => {}; (vscode.workspace.onDidChangeConfiguration as any) = new (vscode.EventEmitter as any)().event; diff --git a/src/test/runTest.ts b/src/test/runTest.ts index 4df9ca6..e6fd186 100644 --- a/src/test/runTest.ts +++ b/src/test/runTest.ts @@ -1,4 +1,4 @@ -import { existsSync, mkdirSync, rmSync, writeFileSync } from 'fs'; +import { existsSync, mkdirSync, realpathSync, rmSync, writeFileSync } from 'fs'; import * as os from 'os'; import * as path from 'path'; import { pathToFileURL } from 'node:url'; @@ -37,9 +37,14 @@ function getVscodeExecutablePath(): string | undefined { } else { const linuxPaths = ['/usr/bin/code', '/usr/local/bin/code', '/snap/bin/code']; for (const exePath of linuxPaths) { - if (existsSync(exePath)) { - return exePath; - } + if (!existsSync(exePath)) continue; + // Distro `code` is a shell wrapper (…/bin/code) that re-forks the GUI via + // cli.js; the test runner then loses process ownership and the run exits 0 + // without ever starting mocha (observed flake). Prefer the real Electron + // binary in the resolved install dir when it exists. + const wrapper = realpathSync(exePath); + const electronBinary = path.join(path.dirname(path.dirname(wrapper)), 'code'); + return existsSync(electronBinary) ? electronBinary : exePath; } } diff --git a/src/test/suite/index.ts b/src/test/suite/index.ts index a3d76bb..08c8e67 100644 --- a/src/test/suite/index.ts +++ b/src/test/suite/index.ts @@ -59,6 +59,11 @@ function installVscodeFacade(): void { ((globalThis as any).__vscodeFacade as Record | undefined) ?? {}; const isPlainObject = (v: any): boolean => !!v && typeof v === 'object' && Object.getPrototypeOf(v) === Object.prototype; + // A frozen plain object cannot take mock writes either. VS Code 1.137 ships + // workspace.fs as exactly that (frozen, Object.prototype), so treat any + // non-extensible value as a real namespace: never copy it onto the facade + // by reference, always replace it with a fresh object. + const isMockable = (v: any): boolean => isPlainObject(v) && Object.isExtensible(v); // Nested API namespaces are composed explicitly below; copying them here // would put the REAL frozen namespaces (getter-only props on newer VS Code) // on the facade and break every mock assignment. @@ -90,7 +95,7 @@ function installVscodeFacade(): void { if ( !isCtor && typeof value === 'object' && - !isPlainObject(value) + !isMockable(value) ) { continue; } @@ -110,16 +115,16 @@ function installVscodeFacade(): void { }; assignProps(facade, real, false); const ensureObj = (parent: any, name: string): any => { - // Never keep a non-plain object (e.g. the real frozen API namespace): - // mocks must be able to assign arbitrary props on it. - if (!isPlainObject(parent[name])) { + // Never keep a real namespace (non-plain, or frozen like VS Code 1.137's + // workspace.fs): mocks must be able to assign arbitrary props on it. + if (!isMockable(parent[name])) { try { parent[name] = {}; } catch { /* unreplaceable: fall through */ } } - return isPlainObject(parent[name]) ? parent[name] : {}; + return isMockable(parent[name]) ? parent[name] : {}; }; assignProps(ensureObj(facade, 'window'), real.window, true); const ws = ensureObj(facade, 'workspace'); diff --git a/src/test/suite/realhost-openconfigfile.test.ts b/src/test/suite/realhost-openconfigfile.test.ts new file mode 100644 index 0000000..a7d7cb4 --- /dev/null +++ b/src/test/suite/realhost-openconfigfile.test.ts @@ -0,0 +1,133 @@ +import * as assert from "node:assert"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import * as vscode from "vscode"; +import { + PiAgentProvider, + piAgentProviderInternals, + type PiAgentConfig, +} from "../../pi-agent-provider.js"; +import { MessageHandler } from "../../message-handler.js"; + +// Real-host lane: exercises openConfigFile against the actual VS Code API +// (real dialogs auto-answered, real editor tabs, real filesystem) with the +// agent dir pointed at a temp folder so nothing touches ~/.pi/agent. + +const tempAgentDir = fs.mkdtempSync(path.join(os.tmpdir(), "pilot-agent-")); +process.env.PI_CODING_AGENT_DIR = tempAgentDir; + +suite("REALHOST openConfigFile", () => { + let provider: PiAgentProvider; + let handler: MessageHandler; + const recorded: { + infoMessages: Array<{ msg: string; choice: string | undefined }>; + shownDocs: unknown[]; + } = { + infoMessages: [], + shownDocs: [], + }; + let savedInfo: any; + let savedShow: any; + + suiteSetup(() => { + // The SDK must resolve the agent dir from the env bridge. + assert.strictEqual(piAgentProviderInternals.getAgentDir(), tempAgentDir); + + const config: PiAgentConfig = { + defaultModel: "anthropic/claude-sonnet-4-5", + defaultProvider: "anthropic", + autoContext: false, + maxTokens: 8192, + thinkingLevel: "medium", + }; + provider = new PiAgentProvider({ globalState: { get: () => undefined, update: async () => {} } } as any, config); + (provider as any).isInitialized = true; // skip real session bootstrap + handler = new MessageHandler(provider); + + // Auto-answer dialogs (a real user clicking "Create"), delegate the rest. + savedInfo = vscode.window.showInformationMessage; + savedShow = vscode.window.showTextDocument; + (vscode.window as any).showInformationMessage = async (msg: string, ...rest: any[]) => { + const choice = rest.find((r) => typeof r === "string"); + recorded.infoMessages.push({ msg, choice }); + return choice; + }; + (vscode.window as any).showTextDocument = async (doc: unknown, ...rest: any[]) => { + recorded.shownDocs.push(doc); + return savedShow.call(vscode.window, doc, ...rest); + }; + }); + + // Real host hands us documents with uri.fsPath; the plain-Node shim's docs + // have no resolvable path. Exact-path assertions apply only where the lane + // can resolve one; "a document was opened" is checked in both. + function lastShownPath(): string | undefined { + const doc = recorded.shownDocs.at(-1) as any; + if (typeof doc === "string") return doc; + return doc?.uri?.fsPath; + } + + suiteTeardown(async () => { + vscode.window.showInformationMessage = savedInfo; + vscode.window.showTextDocument = savedShow; + await vscode.commands.executeCommand("workbench.action.closeAllEditors"); + fs.rmSync(tempAgentDir, { recursive: true, force: true }); + }); + + test("append-system-prompt creates empty file and opens a real editor tab", async () => { + await handler.handle({ type: "openConfigFile", data: { file: "append-system-prompt" } } as any); + const file = path.join(tempAgentDir, "APPEND_SYSTEM.md"); + assert.ok(fs.existsSync(file), "APPEND_SYSTEM.md created on disk"); + assert.strictEqual(fs.readFileSync(file, "utf8"), "", "created empty"); + assert.ok(recorded.shownDocs.length > 0, "editor opened for the file"); + const shown = lastShownPath(); + if (shown !== undefined) assert.strictEqual(shown, file, "editor tab is the file"); + }); + + test("second click does not clobber user content", async () => { + const file = path.join(tempAgentDir, "APPEND_SYSTEM.md"); + fs.writeFileSync(file, "user rules", "utf8"); + await handler.handle({ type: "openConfigFile", data: { file: "append-system-prompt" } } as any); + assert.strictEqual(fs.readFileSync(file, "utf8"), "user rules", "content preserved"); + assert.strictEqual(recorded.infoMessages.length, 0, "no dialog for existing file"); + }); + + test("system-prompt confirms, then creates empty SYSTEM.md", async () => { + await handler.handle({ type: "openConfigFile", data: { file: "system-prompt" } } as any); + const file = path.join(tempAgentDir, "SYSTEM.md"); + assert.strictEqual(recorded.infoMessages.length, 1, "confirmation dialog shown"); + assert.ok(recorded.infoMessages[0].msg.includes("SYSTEM.md")); + assert.ok(fs.existsSync(file), "SYSTEM.md created after confirm"); + assert.strictEqual(fs.readFileSync(file, "utf8"), ""); + assert.ok(recorded.shownDocs.length > 0, "editor opened for the file"); + const shown = lastShownPath(); + if (shown !== undefined) assert.strictEqual(shown, file, "editor tab is the file"); + }); + + test("existing SYSTEM.md opens without dialog", async () => { + const before = recorded.infoMessages.length; + await handler.handle({ type: "openConfigFile", data: { file: "system-prompt" } } as any); + assert.strictEqual(recorded.infoMessages.length, before, "no second dialog"); + }); + + test("careless garbage key is rejected with no file and no editor", async () => { + const shownBefore = recorded.shownDocs.length; + const result = await handler.handle({ type: "openConfigFile", data: { file: "../escape" } } as any); + assert.ok((result as any).error, "garbage key rejected"); + assert.ok(!fs.existsSync(path.join(tempAgentDir, "escape")), "nothing created"); + assert.strictEqual(recorded.shownDocs.length, shownBefore, "no editor opened"); + }); + + test("SYSTEM.md cancel path leaves nothing behind", async () => { + fs.rmSync(path.join(tempAgentDir, "SYSTEM.md"), { force: true }); + (vscode.window as any).showInformationMessage = async (msg: string) => { + recorded.infoMessages.push({ msg, choice: undefined }); + return undefined; // user dismisses + }; + const shownBefore = recorded.shownDocs.length; + await handler.handle({ type: "openConfigFile", data: { file: "system-prompt" } } as any); + assert.ok(!fs.existsSync(path.join(tempAgentDir, "SYSTEM.md")), "no file on cancel"); + assert.strictEqual(recorded.shownDocs.length, shownBefore, "no tab on cancel"); + }); +}); diff --git a/src/test/suite/unit/message-handler.test.ts b/src/test/suite/unit/message-handler.test.ts index 0bf6905..1ed0f66 100644 --- a/src/test/suite/unit/message-handler.test.ts +++ b/src/test/suite/unit/message-handler.test.ts @@ -93,6 +93,10 @@ function createMockProvider(): { editMessage: () => undefined, getSkillDiscovery: () => false, setSkillDiscovery: () => undefined, + getSystemPromptOverrides: () => ({ + systemPrompt: false, + appendSystemPrompts: false, + }), setExtraSkillPaths: () => undefined, getExtraSkillPaths: () => [], sendSkillsList: () => undefined, @@ -756,6 +760,29 @@ suite("MessageHandler", () => { assert.strictEqual(msg.data.enabled, true); }); + test("getSystemPromptOverrides - sends system-prompt-overrides-changed", async () => { + provider.getSystemPromptOverrides = () => ({ + systemPrompt: true, + appendSystemPrompts: false, + }); + const result = await handler.handle({ + type: "getSystemPromptOverrides", + data: {}, + }); + assert.deepStrictEqual(result, { + systemPrompt: true, + appendSystemPrompts: false, + }); + const msg = webviewMessages.find( + (m: any) => m.type === "system-prompt-overrides-changed", + ); + assert.ok(msg); + assert.deepStrictEqual(msg.data, { + systemPrompt: true, + appendSystemPrompts: false, + }); + }); + test("setSkillDiscovery - returns success", async () => { let lastEnabled: boolean | undefined; provider.setSkillDiscovery = (enabled: boolean) => { @@ -1493,6 +1520,15 @@ suite("MessageHandler", () => { assert.strictEqual(result.success, true); }); + test("openConfigFile routes system-prompt file", async () => { + const result = await handler.handle({ + type: "openConfigFile", + data: { file: "system-prompt" }, + }); + assert.deepStrictEqual(provider.calls.openConfigFile[0], ["system-prompt"]); + assert.strictEqual(result.success, true); + }); + test("openConfigFile rejects invalid file", async () => { const result = await handler.handle({ type: "openConfigFile", diff --git a/src/test/suite/unit/pi-agent-provider.test.ts b/src/test/suite/unit/pi-agent-provider.test.ts index e53d132..2e580e5 100644 --- a/src/test/suite/unit/pi-agent-provider.test.ts +++ b/src/test/suite/unit/pi-agent-provider.test.ts @@ -1964,92 +1964,145 @@ suite("PiAgentProvider", () => { }); suite("openConfigFile", () => { - test("creates missing file with {} and opens document", async () => { - const provider = buildProvider(); - (provider as any).isInitialized = true; - - const writeCalls: any[] = []; - const openCalls: any[] = []; - const savedMkdir = piAgentProviderInternals.mkdir; - const savedWrite = piAgentProviderInternals.writeFile; - const savedOpen = vscode.workspace.openTextDocument; - const savedShow = vscode.window.showTextDocument; - + /** + * Stub the fs/vscode seams openConfigFile uses. `exists` controls + * existsFile, `infoChoice` the dialog answer. Returns recorded calls + * and a restore function. + */ + function stubOpenConfig(opts: { exists?: boolean; infoChoice?: string } = {}) { + const calls = { + writes: [] as Array<{ p: string; content: string }>, + opened: [] as string[], + prompts: 0, + }; + const saved = { + exists: piAgentProviderInternals.existsFile, + mkdir: piAgentProviderInternals.mkdir, + write: piAgentProviderInternals.writeFile, + open: vscode.workspace.openTextDocument, + show: vscode.window.showTextDocument, + info: vscode.window.showInformationMessage, + }; + piAgentProviderInternals.existsFile = () => opts.exists ?? false; piAgentProviderInternals.mkdir = async () => undefined; piAgentProviderInternals.writeFile = async ( p: string, content: string, - opts: any, + o: any, ) => { - assert.strictEqual(opts.flag, "wx"); - writeCalls.push({ p, content }); + assert.strictEqual(o.flag, "wx"); + calls.writes.push({ p, content }); }; vscode.workspace.openTextDocument = async (uri: any) => { - openCalls.push(uri.fsPath); + calls.opened.push(uri.fsPath); return {} as any; }; vscode.window.showTextDocument = (async () => {}) as any; + (vscode.window as any).showInformationMessage = async () => { + calls.prompts++; + return opts.infoChoice; + }; + return { + calls, + restore() { + piAgentProviderInternals.existsFile = saved.exists; + piAgentProviderInternals.mkdir = saved.mkdir; + piAgentProviderInternals.writeFile = saved.write; + vscode.workspace.openTextDocument = saved.open; + vscode.window.showTextDocument = saved.show; + vscode.window.showInformationMessage = saved.info; + }, + }; + } + function buildInitializedProvider() { + const provider = buildProvider(); + (provider as any).isInitialized = true; + return provider; + } + + test("creates missing file with {} and opens document", async () => { + const provider = buildInitializedProvider(); + const { calls, restore } = stubOpenConfig(); try { await provider["openConfigFile"]("models"); + assert.strictEqual(calls.writes.length, 1, "writeFile called once"); + assert.strictEqual(calls.writes[0].content, "{}"); + assert.ok(calls.writes[0].p.endsWith("models.json")); + assert.ok(calls.opened[0].endsWith("models.json"), "opens models.json"); + } finally { + restore(); + } + }); - assert.strictEqual(writeCalls.length, 1, "writeFile called once"); - assert.strictEqual(writeCalls[0].content, "{}"); - assert.ok( - writeCalls[0].p.endsWith("models.json"), - "path resolves to models.json", - ); - assert.ok( - openCalls[0].endsWith("models.json"), - "opens models.json", - ); + test("does not overwrite an existing file (EEXIST ignored)", async () => { + const provider = buildInitializedProvider(); + const { calls, restore } = stubOpenConfig(); + piAgentProviderInternals.writeFile = async () => { + const err: any = new Error("exists"); + err.code = "EEXIST"; + throw err; + }; + try { + await provider["openConfigFile"]("settings"); + // EEXIST was swallowed: the flow continued to open the document. + assert.strictEqual(calls.opened.length, 1); } finally { - piAgentProviderInternals.mkdir = savedMkdir; - piAgentProviderInternals.writeFile = savedWrite; - vscode.workspace.openTextDocument = savedOpen; - vscode.window.showTextDocument = savedShow; + restore(); } }); - test("does not overwrite an existing file", async () => { - const provider = buildProvider(); - (provider as any).isInitialized = true; + test("maps system-prompt to SYSTEM.md after confirmation", async () => { + const provider = buildInitializedProvider(); + const { calls, restore } = stubOpenConfig({ infoChoice: "Create" }); + try { + await provider["openConfigFile"]("system-prompt"); + assert.strictEqual(calls.prompts, 1, "warns before creating SYSTEM.md"); + assert.strictEqual(calls.writes.length, 1); + assert.strictEqual(calls.writes[0].content, ""); + assert.ok(calls.writes[0].p.endsWith("SYSTEM.md")); + assert.ok(calls.opened[0].endsWith("SYSTEM.md"), "opens SYSTEM.md"); + } finally { + restore(); + } + }); - let writeAttempted = false; - const savedMkdir = piAgentProviderInternals.mkdir; - const savedWrite = piAgentProviderInternals.writeFile; - const savedOpen = vscode.workspace.openTextDocument; - const savedShow = vscode.window.showTextDocument; + test("cancelling the SYSTEM.md prompt creates nothing and opens nothing", async () => { + const provider = buildInitializedProvider(); + const { calls, restore } = stubOpenConfig({ infoChoice: undefined }); + try { + await provider["openConfigFile"]("system-prompt"); + assert.strictEqual(calls.writes.length, 0, "no file created"); + assert.strictEqual(calls.opened.length, 0, "no document opened"); + } finally { + restore(); + } + }); - piAgentProviderInternals.mkdir = async () => undefined; - piAgentProviderInternals.writeFile = async () => { - writeAttempted = true; - }; - // openDocument rejects, simulating the file already existing when - // writeFile runs with flag "wx" (EEXIST) — implementation must ignore it. - vscode.workspace.openTextDocument = async (uri: any) => { - return uri as any; - }; - vscode.window.showTextDocument = (async () => {}) as any; + test("existing SYSTEM.md opens without prompting", async () => { + const provider = buildInitializedProvider(); + const { calls, restore } = stubOpenConfig({ exists: true, infoChoice: "Create" }); + try { + await provider["openConfigFile"]("system-prompt"); + assert.strictEqual(calls.prompts, 0, "no prompt for existing file"); + assert.strictEqual(calls.writes.length, 0, "not rewritten"); + assert.ok(calls.opened[0].endsWith("SYSTEM.md"), "opens SYSTEM.md"); + } finally { + restore(); + } + }); + test("maps append-system-prompt to APPEND_SYSTEM.md", async () => { + const provider = buildInitializedProvider(); + const { calls, restore } = stubOpenConfig(); try { - // Force an EEXIST error to exercise the guard branch. - piAgentProviderInternals.writeFile = async () => { - const err: any = new Error("exists"); - err.code = "EEXIST"; - throw err; - }; - await provider["openConfigFile"]("settings"); - assert.strictEqual( - writeAttempted, - false, - "no write attempted when EEXIST path used", - ); + await provider["openConfigFile"]("append-system-prompt"); + assert.strictEqual(calls.writes.length, 1); + assert.strictEqual(calls.writes[0].content, ""); + assert.ok(calls.writes[0].p.endsWith("APPEND_SYSTEM.md")); + assert.ok(calls.opened[0].endsWith("APPEND_SYSTEM.md")); } finally { - piAgentProviderInternals.mkdir = savedMkdir; - piAgentProviderInternals.writeFile = savedWrite; - vscode.workspace.openTextDocument = savedOpen; - vscode.window.showTextDocument = savedShow; + restore(); } }); }); diff --git a/src/webview/components/SettingsPanel.svelte b/src/webview/components/SettingsPanel.svelte index 6e90419..892db81 100644 --- a/src/webview/components/SettingsPanel.svelte +++ b/src/webview/components/SettingsPanel.svelte @@ -1,4 +1,5 @@
@@ -226,6 +266,34 @@ Open models.json
+

+ Customize PI's system prompt. Applies to new sessions. +

+
+ + +
+

+ SYSTEM.md replaces PI's default system prompt; APPEND_SYSTEM.md appends + to it. Files live in PI's agent directory (default ~/.pi/agent). +

+ {#if overridingSettingNames.length > 0} + + {/if}
@@ -423,6 +491,18 @@ gap: var(--space-2); } + .config-hint { + font-size: var(--text-xs); + color: var(--color-text-muted); + margin-top: var(--space-2); + } + + .system-prompt-warning { + font-size: var(--text-xs); + color: var(--color-warning); + margin-top: var(--space-2); + } + .config-btn { padding: var(--space-2) var(--space-3); background: var(--color-surface); diff --git a/src/webview/types/index.ts b/src/webview/types/index.ts index bc6e959..5ae10c0 100644 --- a/src/webview/types/index.ts +++ b/src/webview/types/index.ts @@ -94,6 +94,14 @@ export interface VoiceModelDef { englishOnly: boolean; } +/** Keys for openConfigFile — webview mirror of the host's ConfigFileKey. */ +export type OpenConfigFileKey = + | "auth" + | "models" + | "settings" + | "system-prompt" + | "append-system-prompt"; + /** Thinking level enumeration */ export type ThinkingLevel = | "off"