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