flake-update-20260505
1import type { ExtensionAPI, ExtensionContext, ModelSelectEvent, ThinkingLevel } from "@mariozechner/pi-coding-agent";
2import { CustomEditor, ModelSelectorComponent, SettingsManager } from "@mariozechner/pi-coding-agent";
3import path from "node:path";
4import os from "node:os";
5import fs from "node:fs/promises";
6import type { Dirent } from "node:fs";
7
8// =============================================================================
9// Modes
10// =============================================================================
11
12type ModeName = string;
13
14type ModeSpec = {
15 provider?: string;
16 modelId?: string;
17 thinkingLevel?: ThinkingLevel;
18 /**
19 * Optional theme color token to use for the editor border.
20 * If unset, the border color is derived from the (current) thinking level.
21 */
22 color?: string;
23};
24
25type ModesFile = {
26 version: 1;
27 currentMode: ModeName;
28 modes: Record<ModeName, ModeSpec>;
29};
30
31// Only "default" is a forced/built-in mode. Others are just initial suggestions and can be renamed/deleted.
32const DEFAULT_MODE_ORDER = ["default"] as const;
33const CUSTOM_MODE_NAME = "custom" as const;
34
35function expandUserPath(p: string): string {
36 if (p === "~") return os.homedir();
37 if (p.startsWith("~/")) return path.join(os.homedir(), p.slice(2));
38 return p;
39}
40
41function getGlobalAgentDir(): string {
42 // Mirror pi-coding-agent's getAgentDir() behavior (best-effort).
43 // For the canonical implementation see pi-mono/packages/coding-agent/src/config.ts
44 const env = process.env.PI_CODING_AGENT_DIR;
45 if (env) return expandUserPath(env);
46 return path.join(os.homedir(), ".pi", "agent");
47}
48
49function getGlobalModesPath(): string {
50 return path.join(getGlobalAgentDir(), "modes.json");
51}
52
53function getProjectModesPath(cwd: string): string {
54 return path.join(cwd, ".pi", "modes.json");
55}
56
57async function fileExists(p: string): Promise<boolean> {
58 try {
59 await fs.stat(p);
60 return true;
61 } catch {
62 return false;
63 }
64}
65
66async function ensureDirForFile(filePath: string): Promise<void> {
67 await fs.mkdir(path.dirname(filePath), { recursive: true });
68}
69
70async function getMtimeMs(p: string): Promise<number | null> {
71 try {
72 const st = await fs.stat(p);
73 return st.mtimeMs;
74 } catch {
75 return null;
76 }
77}
78
79function sleep(ms: number): Promise<void> {
80 return new Promise((resolve) => setTimeout(resolve, ms));
81}
82
83function getLockPathForFile(filePath: string): string {
84 // Lock file next to the json so it works across processes.
85 return `${filePath}.lock`;
86}
87
88async function withFileLock<T>(filePath: string, fn: () => Promise<T>): Promise<T> {
89 const lockPath = getLockPathForFile(filePath);
90 await ensureDirForFile(lockPath);
91
92 const start = Date.now();
93 while (true) {
94 try {
95 const handle = await fs.open(lockPath, "wx");
96 try {
97 // Best-effort metadata for debugging stale locks.
98 await handle.writeFile(
99 JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() }) + "\n",
100 "utf8"
101 );
102 } catch {
103 // ignore
104 }
105
106 try {
107 return await fn();
108 } finally {
109 await handle.close().catch(() => {});
110 await fs.unlink(lockPath).catch(() => {});
111 }
112 } catch (err: any) {
113 if (err?.code !== "EEXIST") throw err;
114
115 // If the lock looks stale (crash), break it.
116 try {
117 const st = await fs.stat(lockPath);
118 if (Date.now() - st.mtimeMs > 30_000) {
119 await fs.unlink(lockPath);
120 continue;
121 }
122 } catch {
123 // ignore
124 }
125
126 if (Date.now() - start > 5_000) {
127 // Don't hang the UI forever.
128 throw new Error(`Timed out waiting for lock: ${lockPath}`);
129 }
130 await sleep(40 + Math.random() * 80);
131 }
132 }
133}
134
135async function atomicWriteUtf8(filePath: string, content: string): Promise<void> {
136 await ensureDirForFile(filePath);
137
138 const dir = path.dirname(filePath);
139 const base = path.basename(filePath);
140 const tmpPath = path.join(dir, `.${base}.tmp.${process.pid}.${Math.random().toString(16).slice(2)}`);
141
142 await fs.writeFile(tmpPath, content, "utf8");
143
144 try {
145 // POSIX: atomic replace.
146 await fs.rename(tmpPath, filePath);
147 } catch (err: any) {
148 // Windows: rename can't overwrite.
149 if (err?.code === "EEXIST" || err?.code === "EPERM") {
150 await fs.unlink(filePath).catch(() => {});
151 await fs.rename(tmpPath, filePath);
152 } else {
153 // best-effort cleanup
154 await fs.unlink(tmpPath).catch(() => {});
155 throw err;
156 }
157 }
158}
159
160function cloneModesFile(file: ModesFile): ModesFile {
161 // JSON-based clone is fine here (small, plain data structure).
162 return JSON.parse(JSON.stringify(file)) as ModesFile;
163}
164
165type ModeSpecPatch = {
166 provider?: string | null;
167 modelId?: string | null;
168 thinkingLevel?: ThinkingLevel | null;
169 color?: string | null;
170};
171
172type ModesPatch = {
173 currentMode?: ModeName;
174 modes?: Record<ModeName, ModeSpecPatch | null>;
175};
176
177function computeModesPatch(base: ModesFile, next: ModesFile, includeCurrentMode: boolean): ModesPatch | null {
178 const patch: ModesPatch = {};
179
180 if (includeCurrentMode && base.currentMode !== next.currentMode) {
181 patch.currentMode = next.currentMode;
182 }
183
184 const keys = new Set([...Object.keys(base.modes), ...Object.keys(next.modes)]);
185 const modesPatch: Record<ModeName, ModeSpecPatch | null> = {};
186
187 for (const k of keys) {
188 const a = base.modes[k];
189 const b = next.modes[k];
190
191 if (!b) {
192 if (a) modesPatch[k] = null;
193 continue;
194 }
195 if (!a) {
196 modesPatch[k] = { ...b };
197 continue;
198 }
199
200 const diff: ModeSpecPatch = {};
201 const fields: (keyof ModeSpec)[] = ["provider", "modelId", "thinkingLevel", "color"];
202 for (const f of fields) {
203 const av = a[f];
204 const bv = b[f];
205 if (av !== bv) {
206 (diff as any)[f] = bv === undefined ? null : bv;
207 }
208 }
209 if (Object.keys(diff).length > 0) {
210 modesPatch[k] = diff;
211 }
212 }
213
214 if (Object.keys(modesPatch).length > 0) {
215 patch.modes = modesPatch;
216 }
217
218 if (!patch.modes && patch.currentMode === undefined) return null;
219 return patch;
220}
221
222function applyModesPatch(target: ModesFile, patch: ModesPatch): void {
223 if (patch.currentMode !== undefined) {
224 target.currentMode = patch.currentMode;
225 }
226
227 if (!patch.modes) return;
228 for (const [mode, specPatch] of Object.entries(patch.modes)) {
229 if (specPatch === null) {
230 delete target.modes[mode];
231 continue;
232 }
233
234 const targetSpec: Record<string, unknown> = ((target.modes[mode] ??= {}) as any) ?? {};
235 for (const [k, v] of Object.entries(specPatch)) {
236 if (v === null || v === undefined) {
237 delete targetSpec[k];
238 } else {
239 targetSpec[k] = v;
240 }
241 }
242 }
243}
244
245function normalizeThinkingLevel(level: unknown): ThinkingLevel | undefined {
246 if (typeof level !== "string") return undefined;
247 const v = level as ThinkingLevel;
248 // Keep the list local to avoid importing internal enums.
249 const allowed: ThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh"];
250 return allowed.includes(v) ? v : undefined;
251}
252
253function sanitizeModeSpec(spec: unknown): ModeSpec {
254 const obj = (spec && typeof spec === "object" ? spec : {}) as Record<string, unknown>;
255 return {
256 provider: typeof obj.provider === "string" ? obj.provider : undefined,
257 modelId: typeof obj.modelId === "string" ? obj.modelId : undefined,
258 thinkingLevel: normalizeThinkingLevel(obj.thinkingLevel),
259 color: typeof obj.color === "string" ? obj.color : undefined,
260 };
261}
262
263function createDefaultModes(ctx: ExtensionContext, pi: ExtensionAPI): ModesFile {
264 const currentModel = ctx.model;
265 const currentThinking = pi.getThinkingLevel();
266
267 const base: ModeSpec = {
268 provider: currentModel?.provider,
269 modelId: currentModel?.id,
270 thinkingLevel: currentThinking,
271 };
272
273 return {
274 version: 1,
275 currentMode: "default",
276 modes: {
277 // Forced default mode
278 default: { ...base },
279 // Convenience mode (user can delete/rename)
280 fast: { ...base, thinkingLevel: "off" },
281 },
282 };
283}
284
285function ensureDefaultModeEntries(file: ModesFile, ctx: ExtensionContext, pi: ExtensionAPI): void {
286 for (const name of DEFAULT_MODE_ORDER) {
287 if (!file.modes[name]) {
288 const defaults = createDefaultModes(ctx, pi);
289 file.modes[name] = defaults.modes[name];
290 }
291 }
292
293 // "custom" is an overlay mode; never treat it as a valid persisted current mode.
294 if (file.currentMode === CUSTOM_MODE_NAME) {
295 file.currentMode = "" as any;
296 }
297
298 if (!file.currentMode || !(file.currentMode in file.modes) || file.currentMode === CUSTOM_MODE_NAME) {
299 const first = Object.keys(file.modes).find((k) => k !== CUSTOM_MODE_NAME);
300 file.currentMode = file.modes.default ? "default" : first || "default";
301 }
302}
303
304async function loadModesFile(filePath: string, ctx: ExtensionContext, pi: ExtensionAPI): Promise<ModesFile> {
305 try {
306 const raw = await fs.readFile(filePath, "utf8");
307 const parsed = JSON.parse(raw) as Record<string, unknown>;
308 const currentMode = typeof parsed.currentMode === "string" ? parsed.currentMode : "default";
309 const modesRaw = parsed.modes && typeof parsed.modes === "object" ? (parsed.modes as Record<string, unknown>) : {};
310 const modes: Record<string, ModeSpec> = {};
311 for (const [k, v] of Object.entries(modesRaw)) {
312 modes[k] = sanitizeModeSpec(v);
313 }
314 const file: ModesFile = {
315 version: 1,
316 currentMode,
317 modes,
318 };
319 ensureDefaultModeEntries(file, ctx, pi);
320 return file;
321 } catch {
322 return createDefaultModes(ctx, pi);
323 }
324}
325
326async function saveModesFile(filePath: string, data: ModesFile): Promise<void> {
327 await atomicWriteUtf8(filePath, JSON.stringify(data, null, 2) + "\n");
328}
329
330function orderedModeNames(modes: Record<string, ModeSpec>): string[] {
331 // Preserve insertion order from the JSON file.
332 // Object key iteration order is stable in modern JS runtimes.
333 // NOTE: "custom" is an overlay mode and must not be selectable/persisted.
334 return Object.keys(modes).filter((name) => name !== CUSTOM_MODE_NAME);
335}
336
337/** Convert hex color (#rrggbb) to 24-bit ANSI foreground escape sequence */
338function hexToAnsi(hex: string): string {
339 const h = hex.replace("#", "");
340 const r = parseInt(h.substring(0, 2), 16);
341 const g = parseInt(h.substring(2, 4), 16);
342 const b = parseInt(h.substring(4, 6), 16);
343 return `\x1b[38;2;${r};${g};${b}m`;
344}
345
346/**
347 * Patch Editor.prototype.render so that every editor instance (file-picker,
348 * shell-completions, default, etc.) picks up the current mode's border color
349 * at render time. This avoids fighting with setEditorComponent ordering and
350 * pi's updateEditorBorderColor which we can't trigger from the extension API.
351 *
352 * On each render call, if the current mode has a custom color, we temporarily
353 * swap the editor's borderColor, call the original render, then restore it.
354 */
355let editorPatchInstalled = false;
356function installEditorBorderPatch(): void {
357 if (editorPatchInstalled) return;
358
359 // Get Editor.prototype via the CustomEditor import
360 const EditorProto = Object.getPrototypeOf(CustomEditor.prototype);
361 if (!EditorProto || typeof EditorProto.render !== "function") return;
362
363 const originalRender = EditorProto.render as (this: { borderColor: (text: string) => string }, width: number) => string[];
364
365 EditorProto.render = function patchedRender(this: { borderColor: (text: string) => string }, width: number): string[] {
366 const spec = runtime.data.modes[runtime.currentMode];
367 if (spec?.color && /^#[0-9a-fA-F]{6}$/.test(spec.color)) {
368 const ansi = hexToAnsi(spec.color);
369 const saved = this.borderColor;
370 this.borderColor = (text: string) => `${ansi}${text}\x1b[39m`;
371 const result = originalRender.call(this, width);
372 this.borderColor = saved;
373 return result;
374 }
375 return originalRender.call(this, width);
376 };
377
378 editorPatchInstalled = true;
379}
380
381async function resolveModesPath(cwd: string): Promise<string> {
382 const projectPath = getProjectModesPath(cwd);
383 if (await fileExists(projectPath)) return projectPath;
384 return getGlobalModesPath();
385}
386
387function inferModeFromSelection(ctx: ExtensionContext, pi: ExtensionAPI, data: ModesFile): string | null {
388 const provider = ctx.model?.provider;
389 const modelId = ctx.model?.id;
390 const thinkingLevel = pi.getThinkingLevel();
391 if (!provider || !modelId) return null;
392
393 // Only consider persisted/real modes (exclude the overlay "custom").
394 const names = orderedModeNames(data.modes);
395
396 const supportsThinking = Boolean(ctx.model?.reasoning);
397
398 // 1) If thinking is supported, require an exact match so modes can differ by thinking level.
399 if (supportsThinking) {
400 for (const name of names) {
401 const spec = data.modes[name];
402 if (!spec) continue;
403 if (spec.provider !== provider || spec.modelId !== modelId) continue;
404 if ((spec.thinkingLevel ?? undefined) !== thinkingLevel) continue;
405 return name;
406 }
407 return null;
408 }
409
410 // 2) If thinking is NOT supported by the model, the effective level will always be "off".
411 // In that case, treat thinkingLevel differences in modes.json as non-distinguishing.
412 const candidates: string[] = [];
413 for (const name of names) {
414 const spec = data.modes[name];
415 if (!spec) continue;
416 if (spec.provider !== provider || spec.modelId !== modelId) continue;
417 candidates.push(name);
418 }
419 if (candidates.length === 0) return null;
420
421 // Prefer a candidate that explicitly matches the effective thinking level.
422 for (const name of candidates) {
423 const spec = data.modes[name];
424 if (!spec) continue;
425 if ((spec.thinkingLevel ?? "off") === thinkingLevel) return name;
426 }
427
428 // Next prefer a candidate with no thinkingLevel configured.
429 for (const name of candidates) {
430 const spec = data.modes[name];
431 if (!spec) continue;
432 if (!spec.thinkingLevel) return name;
433 }
434
435 return candidates[0] ?? null;
436}
437
438type ModeRuntime = {
439 filePath: string;
440 fileMtimeMs: number | null;
441 /**
442 * Snapshot of what we last loaded/synced from disk. Used to compute patches so
443 * multiple running pi processes don't clobber each other's mode edits.
444 */
445 baseline: ModesFile | null;
446 data: ModesFile;
447
448 /**
449 * Last non-overlay mode. Used as cycle base while in the overlay "custom" mode.
450 */
451 lastRealMode: string;
452
453 /**
454 * The effective current mode. Can temporarily be "custom" (overlay),
455 * which is *not* persisted and not selectable via /mode.
456 */
457 currentMode: string;
458 // guard against feedback loops when we switch model ourselves
459 applying: boolean;
460};
461
462const runtime: ModeRuntime = {
463 filePath: "",
464 fileMtimeMs: null,
465 baseline: null,
466 data: { version: 1, currentMode: "default", modes: {} },
467 lastRealMode: "default",
468 currentMode: "default",
469 applying: false,
470};
471
472// Updated by setEditor() when the custom editor is instantiated.
473let requestEditorRender: (() => void) | undefined;
474
475// Update the mode status in the footer via ctx.ui.setStatus
476// Sets "mode" (name) and "mode-color" (hex color from modes.json)
477function updateModeStatus(ctx: ExtensionContext): void {
478 if (!ctx.hasUI) return;
479 const mode = runtime.currentMode;
480 if (mode && mode !== "default") {
481 const spec = runtime.data.modes[mode];
482 ctx.ui.setStatus("mode", mode);
483 ctx.ui.setStatus("mode-color", spec?.color || undefined);
484 } else {
485 ctx.ui.setStatus("mode", undefined);
486 ctx.ui.setStatus("mode-color", undefined);
487 }
488}
489
490async function ensureRuntime(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
491 const filePath = await resolveModesPath(ctx.cwd);
492
493 const mtimeMs = await getMtimeMs(filePath);
494 const filePathChanged = runtime.filePath !== filePath;
495 const fileChanged = filePathChanged || runtime.fileMtimeMs !== mtimeMs;
496
497 if (fileChanged) {
498 runtime.filePath = filePath;
499 runtime.fileMtimeMs = mtimeMs;
500
501 const loaded = await loadModesFile(filePath, ctx, pi);
502 // Normalize/ensure defaults *before* we snapshot baseline so later persistence
503 // only reflects explicit user actions ("store").
504 ensureDefaultModeEntries(loaded, ctx, pi);
505 runtime.data = loaded;
506 runtime.baseline = cloneModesFile(runtime.data);
507
508 // Reset overlay when switching projects.
509 if (filePathChanged && runtime.currentMode !== CUSTOM_MODE_NAME) {
510 runtime.currentMode = runtime.data.currentMode;
511 runtime.lastRealMode = runtime.currentMode;
512 }
513 }
514
515 // If we're not in the overlay "custom" mode, ensure currentMode is valid.
516 if (runtime.currentMode !== CUSTOM_MODE_NAME) {
517 if (!runtime.currentMode || !(runtime.currentMode in runtime.data.modes)) {
518 runtime.currentMode = runtime.data.currentMode;
519 }
520 if (!runtime.lastRealMode || !(runtime.lastRealMode in runtime.data.modes)) {
521 runtime.lastRealMode = runtime.currentMode;
522 }
523 }
524}
525
526async function persistRuntime(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
527 if (!runtime.filePath) return;
528
529 // Do not persist currentMode; multiple running pi sessions would fight over it.
530 // Instead we infer the mode on startup from the active model + thinking level.
531 runtime.baseline ??= cloneModesFile(runtime.data);
532 const patch = computeModesPatch(runtime.baseline, runtime.data, false);
533 if (!patch) return;
534
535 await withFileLock(runtime.filePath, async () => {
536 // Merge our local patch into the latest on disk to avoid clobbering other agents.
537 const latest = await loadModesFile(runtime.filePath, ctx, pi);
538 applyModesPatch(latest, patch);
539 ensureDefaultModeEntries(latest, ctx, pi);
540 await saveModesFile(runtime.filePath, latest);
541
542 runtime.data = latest;
543 runtime.baseline = cloneModesFile(latest);
544 runtime.fileMtimeMs = await getMtimeMs(runtime.filePath);
545 });
546}
547
548// We cannot reliably read the *current* model immediately after pi.setModel() in the same tick,
549// because ctx.model is a snapshot-ish view that is updated via the model_select event.
550// Track the last observed model ourselves and use it for overlays / storing.
551let lastObservedModel: { provider?: string; modelId?: string } = {};
552
553function getCurrentSelectionSpec(pi: ExtensionAPI, _ctx: ExtensionContext): ModeSpec {
554 return {
555 provider: lastObservedModel.provider,
556 modelId: lastObservedModel.modelId,
557 thinkingLevel: pi.getThinkingLevel(),
558 };
559}
560
561async function storeSelectionIntoMode(pi: ExtensionAPI, ctx: ExtensionContext, mode: string, selection: ModeSpec): Promise<void> {
562 // "custom" is an overlay; it is not persisted.
563 if (mode === CUSTOM_MODE_NAME) return;
564
565 await ensureRuntime(pi, ctx);
566
567 const existingTarget = runtime.data.modes[mode] ?? {};
568 const next: ModeSpec = { ...existingTarget };
569
570 // Only overwrite fields that we can actually observe.
571 if (selection.provider && selection.modelId) {
572 next.provider = selection.provider;
573 next.modelId = selection.modelId;
574 }
575 if (selection.thinkingLevel) next.thinkingLevel = selection.thinkingLevel;
576
577 runtime.data.modes[mode] = next;
578 await persistRuntime(pi, ctx);
579}
580
581async function applyMode(pi: ExtensionAPI, ctx: ExtensionContext, mode: string): Promise<void> {
582 await ensureRuntime(pi, ctx);
583
584 // "custom" is a runtime-only overlay mode.
585 if (mode === CUSTOM_MODE_NAME) {
586 runtime.currentMode = CUSTOM_MODE_NAME;
587 customOverlay = getCurrentSelectionSpec(pi, ctx);
588 if (ctx.hasUI) {
589 updateModeStatus(ctx);
590 requestEditorRender?.();
591 }
592 return;
593 }
594
595 const spec = runtime.data.modes[mode];
596 if (!spec) {
597 if (ctx.hasUI) {
598 ctx.ui.notify(`Unknown mode: ${mode}`, "warning");
599 }
600 return;
601 }
602
603 runtime.currentMode = mode;
604 runtime.lastRealMode = mode;
605 customOverlay = null;
606
607 runtime.applying = true;
608 let modelAppliedOk = true;
609 try {
610 // Apply model
611 if (spec.provider && spec.modelId) {
612 const m = ctx.modelRegistry.find(spec.provider, spec.modelId);
613 if (m) {
614 const ok = await pi.setModel(m);
615 modelAppliedOk = ok;
616 if (!ok && ctx.hasUI) {
617 ctx.ui.notify(`No API key available for ${spec.provider}/${spec.modelId}`, "warning");
618 }
619 } else {
620 modelAppliedOk = false;
621 if (ctx.hasUI) {
622 ctx.ui.notify(`Mode "${mode}" references unknown model ${spec.provider}/${spec.modelId}`, "warning");
623 }
624 }
625 }
626
627 // Apply thinking level
628 if (spec.thinkingLevel) {
629 pi.setThinkingLevel(spec.thinkingLevel);
630 }
631 } finally {
632 runtime.applying = false;
633 }
634
635 // If we couldn't apply the requested model (e.g. missing API key), switch to overlay.
636 // We do *not* treat thinking-level clamping as a failure: clamping is expected when
637 // switching between models with different thinking capabilities.
638 if (!modelAppliedOk) {
639 runtime.currentMode = CUSTOM_MODE_NAME;
640 customOverlay = getCurrentSelectionSpec(pi, ctx);
641 }
642
643 if (ctx.hasUI) {
644 updateModeStatus(ctx);
645 requestEditorRender?.();
646 }
647}
648
649const MODE_UI_CONFIGURE = "Configure modes…";
650const MODE_UI_ADD = "Add mode…";
651const MODE_UI_BACK = "Back";
652
653const ALL_THINKING_LEVELS: ThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh"];
654const THINKING_UNSET_LABEL = "(don't change)";
655
656function isDefaultModeName(name: string): boolean {
657 return (DEFAULT_MODE_ORDER as readonly string[]).includes(name);
658}
659
660function isReservedModeName(name: string): boolean {
661 return name === CUSTOM_MODE_NAME || name === MODE_UI_CONFIGURE || name === MODE_UI_ADD || name === MODE_UI_BACK;
662}
663
664function normalizeModeNameInput(name: string | undefined): string {
665 return (name ?? "").trim();
666}
667
668function validateModeNameOrError(
669 name: string,
670 existing: Record<string, ModeSpec>,
671 opts?: { allowExisting?: boolean },
672): string | null {
673 if (!name) return "Mode name cannot be empty";
674 if (/\s/.test(name)) return "Mode name cannot contain whitespace";
675 if (isReservedModeName(name)) return `Mode name \"${name}\" is reserved`;
676 if (!opts?.allowExisting && existing[name]) return `Mode \"${name}\" already exists`;
677 return null;
678}
679
680async function handleModeChoiceUI(pi: ExtensionAPI, ctx: ExtensionContext, choice: string): Promise<void> {
681 // Special behavior: when we're in "custom" and select another mode,
682 // offer to either *use* it (switch) or *store* the current custom selection into it.
683 if (runtime.currentMode === CUSTOM_MODE_NAME && choice !== CUSTOM_MODE_NAME) {
684 const action = await ctx.ui.select(`Mode \"${choice}\"`, ["use", "store"]);
685 if (!action) return;
686
687 if (action === "use") {
688 await applyMode(pi, ctx, choice);
689 return;
690 }
691
692 // "store": overwrite target mode with the current overlay selection (keep target color if set)
693 await ensureRuntime(pi, ctx);
694 const overlay = customOverlay ?? getCurrentSelectionSpec(pi, ctx);
695 await storeSelectionIntoMode(pi, ctx, choice, overlay);
696 await applyMode(pi, ctx, choice);
697 ctx.ui.notify(`Stored ${CUSTOM_MODE_NAME} into \"${choice}\"`, "info");
698 return;
699 }
700
701 await applyMode(pi, ctx, choice);
702}
703
704async function selectModeUI(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
705 if (!ctx.hasUI) return;
706
707 while (true) {
708 await ensureRuntime(pi, ctx);
709 const names = orderedModeNames(runtime.data.modes);
710 const choice = await ctx.ui.select(`Mode (current: ${runtime.currentMode})`, [...names, MODE_UI_CONFIGURE]);
711 if (!choice) return;
712
713 if (choice === MODE_UI_CONFIGURE) {
714 await configureModesUI(pi, ctx);
715 continue;
716 }
717
718 await handleModeChoiceUI(pi, ctx, choice);
719 return;
720 }
721}
722
723async function configureModesUI(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
724 if (!ctx.hasUI) return;
725
726 while (true) {
727 await ensureRuntime(pi, ctx);
728 const names = orderedModeNames(runtime.data.modes);
729 const choice = await ctx.ui.select("Configure modes", [...names, MODE_UI_ADD, MODE_UI_BACK]);
730 if (!choice || choice === MODE_UI_BACK) return;
731
732 if (choice === MODE_UI_ADD) {
733 const created = await addModeUI(pi, ctx);
734 if (created) {
735 await editModeUI(pi, ctx, created);
736 }
737 continue;
738 }
739
740 await editModeUI(pi, ctx, choice);
741 }
742}
743
744async function addModeUI(pi: ExtensionAPI, ctx: ExtensionContext): Promise<string | undefined> {
745 if (!ctx.hasUI) return undefined;
746 await ensureRuntime(pi, ctx);
747
748 while (true) {
749 const raw = await ctx.ui.input("New mode name", "e.g. docs, review, planning");
750 if (raw === undefined) return undefined;
751
752 const name = normalizeModeNameInput(raw);
753 const err = validateModeNameOrError(name, runtime.data.modes);
754 if (err) {
755 ctx.ui.notify(err, "warning");
756 continue;
757 }
758
759 // Default new modes to the current selection so they behave as expected immediately.
760 const selection = customOverlay ?? getCurrentSelectionSpec(pi, ctx);
761 runtime.data.modes[name] = {
762 provider: selection.provider,
763 modelId: selection.modelId,
764 thinkingLevel: selection.thinkingLevel,
765 };
766 await persistRuntime(pi, ctx);
767 ctx.ui.notify(`Added mode \"${name}\"`, "info");
768 return name;
769 }
770}
771
772async function editModeUI(pi: ExtensionAPI, ctx: ExtensionContext, mode: string): Promise<void> {
773 if (!ctx.hasUI) return;
774
775 let modeName = mode;
776
777 while (true) {
778 await ensureRuntime(pi, ctx);
779 const spec = runtime.data.modes[modeName];
780 if (!spec) return;
781
782 const modelLabel = spec.provider && spec.modelId ? `${spec.provider}/${spec.modelId}` : "(no model)";
783 const thinkingLabel = spec.thinkingLevel ?? THINKING_UNSET_LABEL;
784
785 const actions = ["Change name", "Change model", "Change thinking level"];
786 if (!isDefaultModeName(modeName)) actions.push("Delete mode");
787 actions.push(MODE_UI_BACK);
788
789 const action = await ctx.ui.select(
790 `Edit mode \"${modeName}\" model: ${modelLabel} thinking: ${thinkingLabel}`,
791 actions,
792 );
793 if (!action || action === MODE_UI_BACK) return;
794
795 if (action === "Change name") {
796 const renamed = await renameModeUI(pi, ctx, modeName);
797 if (renamed) modeName = renamed;
798 continue;
799 }
800
801 if (action === "Change model") {
802 const selected = await pickModelForModeUI(ctx, spec);
803 if (!selected) continue;
804 spec.provider = selected.provider;
805 spec.modelId = selected.modelId;
806 runtime.data.modes[modeName] = spec;
807 await persistRuntime(pi, ctx);
808 ctx.ui.notify(`Updated model for \"${modeName}\"`, "info");
809
810 if (runtime.currentMode === modeName) {
811 await applyMode(pi, ctx, modeName);
812 }
813 continue;
814 }
815
816 if (action === "Change thinking level") {
817 const level = await pickThinkingLevelForModeUI(ctx, spec.thinkingLevel);
818 if (level === undefined) continue;
819
820 if (level === null) {
821 delete spec.thinkingLevel;
822 } else {
823 spec.thinkingLevel = level;
824 }
825
826 runtime.data.modes[modeName] = spec;
827 await persistRuntime(pi, ctx);
828 ctx.ui.notify(`Updated thinking level for \"${modeName}\"`, "info");
829
830 if (runtime.currentMode === modeName) {
831 await applyMode(pi, ctx, modeName);
832 }
833 continue;
834 }
835
836 if (action === "Delete mode") {
837 const ok = await ctx.ui.confirm("Delete mode", `Delete mode \"${modeName}\"?`);
838 if (!ok) continue;
839
840 delete runtime.data.modes[modeName];
841 await persistRuntime(pi, ctx);
842
843 if (runtime.currentMode === modeName) {
844 runtime.currentMode = CUSTOM_MODE_NAME;
845 customOverlay = getCurrentSelectionSpec(pi, ctx);
846 }
847 if (runtime.lastRealMode === modeName) {
848 runtime.lastRealMode = "default";
849 }
850 updateModeStatus(ctx);
851 requestEditorRender?.();
852 ctx.ui.notify(`Deleted mode \"${modeName}\"`, "info");
853 return;
854 }
855 }
856}
857
858function renameModesRecord(modes: Record<string, ModeSpec>, oldName: string, newName: string): Record<string, ModeSpec> {
859 const out: Record<string, ModeSpec> = {};
860 for (const [k, v] of Object.entries(modes)) {
861 if (k === oldName) out[newName] = v;
862 else out[k] = v;
863 }
864 return out;
865}
866
867async function renameModeUI(pi: ExtensionAPI, ctx: ExtensionContext, oldName: string): Promise<string | undefined> {
868 if (!ctx.hasUI) return undefined;
869
870 if (isDefaultModeName(oldName)) {
871 ctx.ui.notify(`Cannot rename default mode \"${oldName}\"`, "warning");
872 return oldName;
873 }
874
875 await ensureRuntime(pi, ctx);
876
877 while (true) {
878 const raw = await ctx.ui.input(`Rename mode \"${oldName}\"`, oldName);
879 if (raw === undefined) return undefined;
880
881 const newName = normalizeModeNameInput(raw);
882 if (!newName || newName === oldName) return oldName;
883
884 const err = validateModeNameOrError(newName, runtime.data.modes);
885 if (err) {
886 ctx.ui.notify(err, "warning");
887 continue;
888 }
889
890 runtime.data.modes = renameModesRecord(runtime.data.modes, oldName, newName);
891 await persistRuntime(pi, ctx);
892
893 if (runtime.currentMode === oldName) runtime.currentMode = newName;
894 if (runtime.lastRealMode === oldName) runtime.lastRealMode = newName;
895 updateModeStatus(ctx);
896 requestEditorRender?.();
897
898 ctx.ui.notify(`Renamed \"${oldName}\" → \"${newName}\"`, "info");
899 return newName;
900 }
901}
902
903async function pickModelForModeUI(
904 ctx: ExtensionContext,
905 spec: ModeSpec,
906): Promise<{ provider: string; modelId: string } | undefined> {
907 if (!ctx.hasUI) return undefined;
908
909 const settingsManager = SettingsManager.inMemory();
910 const currentModel = spec.provider && spec.modelId ? ctx.modelRegistry.find(spec.provider, spec.modelId) : ctx.model;
911
912 const scopedModels: Array<{ model: any; thinkingLevel: string }> = [];
913
914 return ctx.ui.custom<{ provider: string; modelId: string } | undefined>((tui, _theme, _keybindings, done) => {
915 const selector = new ModelSelectorComponent(
916 tui,
917 currentModel,
918 settingsManager,
919 ctx.modelRegistry as any,
920 scopedModels as any,
921 (model) => done({ provider: model.provider, modelId: model.id }),
922 () => done(undefined),
923 );
924 return selector;
925 });
926}
927
928async function pickThinkingLevelForModeUI(
929 ctx: ExtensionContext,
930 current: ThinkingLevel | undefined,
931): Promise<ThinkingLevel | null | undefined> {
932 if (!ctx.hasUI) return undefined;
933
934 const defaultValue = current ?? "off";
935 const options = [...ALL_THINKING_LEVELS, THINKING_UNSET_LABEL];
936 // Prefer the current selection by ordering it first.
937 const ordered = [defaultValue, ...options.filter((x) => x !== defaultValue)];
938
939 const choice = await ctx.ui.select("Thinking level", ordered);
940 if (!choice) return undefined;
941 if (choice === THINKING_UNSET_LABEL) return null;
942 if (ALL_THINKING_LEVELS.includes(choice as ThinkingLevel)) return choice as ThinkingLevel;
943 return undefined;
944}
945
946async function cycleMode(pi: ExtensionAPI, ctx: ExtensionContext, direction: 1 | -1 = 1): Promise<void> {
947 if (!ctx.hasUI) return;
948 await ensureRuntime(pi, ctx);
949 const names = orderedModeNames(runtime.data.modes);
950 if (names.length === 0) return;
951
952 // If we're currently in the overlay mode, cycle relative to the last real mode.
953 const baseMode = runtime.currentMode === CUSTOM_MODE_NAME ? runtime.lastRealMode : runtime.currentMode;
954 const idx = Math.max(0, names.indexOf(baseMode));
955 const next = names[(idx + direction + names.length) % names.length] ?? names[0]!;
956 await applyMode(pi, ctx, next);
957}
958
959// =============================================================================
960// Prompt history
961// =============================================================================
962
963const MAX_HISTORY_ENTRIES = 100;
964const MAX_RECENT_PROMPTS = 30;
965
966interface PromptEntry {
967 text: string;
968 timestamp: number;
969}
970
971class PromptEditor extends CustomEditor {
972 public requestRenderNow(): void {
973 this.tui.requestRender();
974 }
975}
976
977function extractText(content: Array<{ type: string; text?: string }>): string {
978 return content
979 .filter((item) => item.type === "text" && typeof item.text === "string")
980 .map((item) => item.text ?? "")
981 .join("")
982 .trim();
983}
984
985function collectUserPromptsFromEntries(entries: Array<any>): PromptEntry[] {
986 const prompts: PromptEntry[] = [];
987
988 for (const entry of entries) {
989 if (entry?.type !== "message") continue;
990 const message = entry?.message;
991 if (!message || message.role !== "user" || !Array.isArray(message.content)) continue;
992 const text = extractText(message.content);
993 if (!text) continue;
994 const timestamp = Number(message.timestamp ?? entry.timestamp ?? Date.now());
995 prompts.push({ text, timestamp });
996 }
997
998 return prompts;
999}
1000
1001function getSessionDirForCwd(cwd: string): string {
1002 const safePath = `--${cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`;
1003 return path.join(getGlobalAgentDir(), "sessions", safePath);
1004}
1005
1006async function readTail(filePath: string, maxBytes = 256 * 1024): Promise<string> {
1007 let fileHandle: fs.FileHandle | undefined;
1008 try {
1009 const stats = await fs.stat(filePath);
1010 const size = stats.size;
1011 const start = Math.max(0, size - maxBytes);
1012 const length = size - start;
1013 if (length <= 0) return "";
1014
1015 const buffer = Buffer.alloc(length);
1016 fileHandle = await fs.open(filePath, "r");
1017 const { bytesRead } = await fileHandle.read(buffer, 0, length, start);
1018 if (bytesRead === 0) return "";
1019 let chunk = buffer.subarray(0, bytesRead).toString("utf8");
1020 if (start > 0) {
1021 const firstNewline = chunk.indexOf("\n");
1022 if (firstNewline !== -1) {
1023 chunk = chunk.slice(firstNewline + 1);
1024 }
1025 }
1026 return chunk;
1027 } catch {
1028 return "";
1029 } finally {
1030 await fileHandle?.close();
1031 }
1032}
1033
1034async function loadPromptHistoryForCwd(cwd: string, excludeSessionFile?: string): Promise<PromptEntry[]> {
1035 const sessionDir = getSessionDirForCwd(path.resolve(cwd));
1036 const resolvedExclude = excludeSessionFile ? path.resolve(excludeSessionFile) : undefined;
1037 const prompts: PromptEntry[] = [];
1038
1039 let entries: Dirent[] = [];
1040 try {
1041 entries = await fs.readdir(sessionDir, { withFileTypes: true });
1042 } catch {
1043 return prompts;
1044 }
1045
1046 const files = await Promise.all(
1047 entries
1048 .filter((entry) => entry.isFile() && entry.name.endsWith(".jsonl"))
1049 .map(async (entry) => {
1050 const filePath = path.join(sessionDir, entry.name);
1051 try {
1052 const stats = await fs.stat(filePath);
1053 return { filePath, mtimeMs: stats.mtimeMs };
1054 } catch {
1055 return undefined;
1056 }
1057 }),
1058 );
1059
1060 const sortedFiles = files
1061 .filter((file): file is { filePath: string; mtimeMs: number } => Boolean(file))
1062 .sort((a, b) => b.mtimeMs - a.mtimeMs);
1063
1064 for (const file of sortedFiles) {
1065 if (resolvedExclude && path.resolve(file.filePath) === resolvedExclude) continue;
1066
1067 const tail = await readTail(file.filePath);
1068 if (!tail) continue;
1069 const lines = tail.split("\n").filter(Boolean);
1070 for (const line of lines) {
1071 let entry: any;
1072 try {
1073 entry = JSON.parse(line);
1074 } catch {
1075 continue;
1076 }
1077 if (entry?.type !== "message") continue;
1078 const message = entry?.message;
1079 if (!message || message.role !== "user" || !Array.isArray(message.content)) continue;
1080 const text = extractText(message.content);
1081 if (!text) continue;
1082 const timestamp = Number(message.timestamp ?? entry.timestamp ?? Date.now());
1083 prompts.push({ text, timestamp });
1084 if (prompts.length >= MAX_RECENT_PROMPTS) break;
1085 }
1086 if (prompts.length >= MAX_RECENT_PROMPTS) break;
1087 }
1088
1089 return prompts;
1090}
1091
1092function buildHistoryList(currentSession: PromptEntry[], previousSessions: PromptEntry[]): PromptEntry[] {
1093 const all = [...currentSession, ...previousSessions];
1094 all.sort((a, b) => a.timestamp - b.timestamp);
1095
1096 const seen = new Set<string>();
1097 const deduped: PromptEntry[] = [];
1098 for (const prompt of all) {
1099 const key = `${prompt.timestamp}:${prompt.text}`;
1100 if (seen.has(key)) continue;
1101 seen.add(key);
1102 deduped.push(prompt);
1103 }
1104
1105 return deduped.slice(-MAX_HISTORY_ENTRIES);
1106}
1107
1108// Overlay mode state ("custom"). Not selectable, not cycled into.
1109let customOverlay: ModeSpec | null = null;
1110
1111let loadCounter = 0;
1112
1113function historiesMatch(a: PromptEntry[], b: PromptEntry[]): boolean {
1114 if (a.length !== b.length) return false;
1115 for (let i = 0; i < a.length; i += 1) {
1116 if (a[i]?.text !== b[i]?.text || a[i]?.timestamp !== b[i]?.timestamp) return false;
1117 }
1118 return true;
1119}
1120
1121function setEditor(pi: ExtensionAPI, ctx: ExtensionContext, history: PromptEntry[]) {
1122 ctx.ui.setEditorComponent((tui, theme, keybindings) => {
1123 const editor = new PromptEditor(tui, theme, keybindings);
1124 requestEditorRender = () => editor.requestRenderNow();
1125 for (const prompt of history) {
1126 editor.addToHistory?.(prompt.text);
1127 }
1128 return editor;
1129 });
1130}
1131
1132function applyEditor(pi: ExtensionAPI, ctx: ExtensionContext) {
1133 if (!ctx.hasUI) return;
1134
1135 const sessionFile = ctx.sessionManager.getSessionFile();
1136 const currentEntries = ctx.sessionManager.getBranch();
1137 const currentPrompts = collectUserPromptsFromEntries(currentEntries);
1138 const immediateHistory = buildHistoryList(currentPrompts, []);
1139
1140 const currentLoad = ++loadCounter;
1141 const initialText = ctx.ui.getEditorText();
1142 setEditor(pi, ctx, immediateHistory);
1143
1144 void (async () => {
1145 const previousPrompts = await loadPromptHistoryForCwd(ctx.cwd, sessionFile ?? undefined);
1146 if (currentLoad !== loadCounter) return;
1147 if (ctx.ui.getEditorText() !== initialText) return;
1148 const history = buildHistoryList(currentPrompts, previousPrompts);
1149 if (historiesMatch(history, immediateHistory)) return;
1150 setEditor(pi, ctx, history);
1151 })();
1152}
1153
1154// =============================================================================
1155// Extension Export
1156// =============================================================================
1157
1158export default function (pi: ExtensionAPI) {
1159 pi.registerCommand("mode", {
1160 description: "Select prompt mode",
1161 handler: async (args, ctx) => {
1162 const tokens = args
1163 .split(/\s+/)
1164 .map((x) => x.trim())
1165 .filter(Boolean);
1166
1167 // /mode
1168 if (tokens.length === 0) {
1169 await selectModeUI(pi, ctx);
1170 return;
1171 }
1172
1173 // /mode store [name]
1174 if (tokens[0] === "store") {
1175 await ensureRuntime(pi, ctx);
1176
1177 let target = tokens[1];
1178 if (!target) {
1179 if (!ctx.hasUI) return;
1180 const names = orderedModeNames(runtime.data.modes);
1181 target = await ctx.ui.select("Store current selection into mode", names);
1182 if (!target) return;
1183 }
1184
1185 if (target === CUSTOM_MODE_NAME) {
1186 if (ctx.hasUI) ctx.ui.notify(`Cannot store into "${CUSTOM_MODE_NAME}"`, "warning");
1187 return;
1188 }
1189
1190 const selection = customOverlay ?? getCurrentSelectionSpec(pi, ctx);
1191 await storeSelectionIntoMode(pi, ctx, target, selection);
1192 if (ctx.hasUI) ctx.ui.notify(`Stored current selection into "${target}"`, "info");
1193 return;
1194 }
1195
1196 // /mode <name>
1197 await applyMode(pi, ctx, tokens[0]!);
1198 },
1199 });
1200
1201 pi.registerShortcut("ctrl+shift+m", {
1202 description: "Select prompt mode",
1203 handler: async (ctx) => {
1204 await selectModeUI(pi, ctx);
1205 },
1206 });
1207
1208 pi.registerShortcut("ctrl+space", {
1209 description: "Cycle prompt mode",
1210 handler: async (ctx) => {
1211 await cycleMode(pi, ctx, 1);
1212 },
1213 });
1214
1215 pi.on("session_start", async (_event, ctx) => {
1216 lastObservedModel = { provider: ctx.model?.provider, modelId: ctx.model?.id };
1217 await ensureRuntime(pi, ctx);
1218 customOverlay = null;
1219 installEditorBorderPatch();
1220
1221 const inferred = inferModeFromSelection(ctx, pi, runtime.data);
1222 if (inferred) {
1223 runtime.currentMode = inferred;
1224 runtime.lastRealMode = inferred;
1225 } else {
1226 // No exact match → treat as overlay.
1227 runtime.currentMode = CUSTOM_MODE_NAME;
1228 customOverlay = getCurrentSelectionSpec(pi, ctx);
1229 }
1230
1231 updateModeStatus(ctx);
1232 applyEditor(pi, ctx);
1233 });
1234
1235 pi.on("session_switch", async (_event, ctx) => {
1236 lastObservedModel = { provider: ctx.model?.provider, modelId: ctx.model?.id };
1237 await ensureRuntime(pi, ctx);
1238 customOverlay = null;
1239
1240 const inferred = inferModeFromSelection(ctx, pi, runtime.data);
1241 if (inferred) {
1242 runtime.currentMode = inferred;
1243 runtime.lastRealMode = inferred;
1244 } else {
1245 runtime.currentMode = CUSTOM_MODE_NAME;
1246 customOverlay = getCurrentSelectionSpec(pi, ctx);
1247 }
1248
1249 updateModeStatus(ctx);
1250 applyEditor(pi, ctx);
1251 });
1252
1253
1254 pi.on("model_select", async (event: ModelSelectEvent, ctx) => {
1255 // Always track the last observed model for overlay/store correctness.
1256 lastObservedModel = { provider: event.model.provider, modelId: event.model.id };
1257
1258 // Skip mode switching triggered by applyMode() itself, otherwise we'd jump to "custom"
1259 // while we are in the middle of applying a mode.
1260 if (runtime.applying) return;
1261
1262 // Manual model changes always go into the overlay "custom" mode.
1263 await ensureRuntime(pi, ctx);
1264 if (runtime.currentMode !== CUSTOM_MODE_NAME) {
1265 runtime.lastRealMode = runtime.currentMode;
1266 }
1267 runtime.currentMode = CUSTOM_MODE_NAME;
1268
1269 customOverlay = {
1270 provider: event.model.provider,
1271 modelId: event.model.id,
1272 thinkingLevel: pi.getThinkingLevel(),
1273 };
1274
1275 // Do not persist/select custom.
1276 if (ctx.hasUI) {
1277 updateModeStatus(ctx);
1278 requestEditorRender?.();
1279 }
1280 });
1281
1282}