main
1import type { ExtensionAPI, ExtensionContext, ModelSelectEvent, ThinkingLevel } from "@mariozechner/pi-coding-agent";
2import { CustomEditor, DynamicBorder, ModelSelectorComponent, SettingsManager } from "@mariozechner/pi-coding-agent";
3import { Container, type SelectItem, SelectList, Text, matchesKey, Key } from "@mariozechner/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 }
648}
649
650const MODE_UI_CONFIGURE = "Configure modes…";
651const MODE_UI_ADD = "Add mode…";
652const MODE_UI_BACK = "Back";
653
654const ALL_THINKING_LEVELS: ThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh"];
655const THINKING_UNSET_LABEL = "(don't change)";
656
657function isDefaultModeName(name: string): boolean {
658 return (DEFAULT_MODE_ORDER as readonly string[]).includes(name);
659}
660
661function isReservedModeName(name: string): boolean {
662 return name === CUSTOM_MODE_NAME || name === MODE_UI_CONFIGURE || name === MODE_UI_ADD || name === MODE_UI_BACK;
663}
664
665function normalizeModeNameInput(name: string | undefined): string {
666 return (name ?? "").trim();
667}
668
669function validateModeNameOrError(
670 name: string,
671 existing: Record<string, ModeSpec>,
672 opts?: { allowExisting?: boolean },
673): string | null {
674 if (!name) return "Mode name cannot be empty";
675 if (/\s/.test(name)) return "Mode name cannot contain whitespace";
676 if (isReservedModeName(name)) return `Mode name \"${name}\" is reserved`;
677 if (!opts?.allowExisting && existing[name]) return `Mode \"${name}\" already exists`;
678 return null;
679}
680
681async function handleModeChoiceUI(pi: ExtensionAPI, ctx: ExtensionContext, choice: string): Promise<void> {
682 // Special behavior: when we're in "custom" and select another mode,
683 // offer to either *use* it (switch) or *store* the current custom selection into it.
684 if (runtime.currentMode === CUSTOM_MODE_NAME && choice !== CUSTOM_MODE_NAME) {
685 const action = await ctx.ui.select(`Mode \"${choice}\"`, ["use", "store"]);
686 if (!action) return;
687
688 if (action === "use") {
689 await applyMode(pi, ctx, choice);
690 return;
691 }
692
693 // "store": overwrite target mode with the current overlay selection (keep target color if set)
694 await ensureRuntime(pi, ctx);
695 const overlay = customOverlay ?? getCurrentSelectionSpec(pi, ctx);
696 await storeSelectionIntoMode(pi, ctx, choice, overlay);
697 await applyMode(pi, ctx, choice);
698 ctx.ui.notify(`Stored ${CUSTOM_MODE_NAME} into \"${choice}\"`, "info");
699 return;
700 }
701
702 await applyMode(pi, ctx, choice);
703}
704
705async function selectModeUI(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
706 if (!ctx.hasUI) return;
707
708 while (true) {
709 await ensureRuntime(pi, ctx);
710 const names = orderedModeNames(runtime.data.modes);
711
712 const items: SelectItem[] = names.map((name) => {
713 const spec = runtime.data.modes[name];
714 const parts: string[] = [];
715 if (spec?.provider && spec?.modelId) parts.push(`${spec.provider}/${spec.modelId}`);
716 if (spec?.thinkingLevel) parts.push(`thinking: ${spec.thinkingLevel}`);
717 const current = name === runtime.currentMode ? " (current)" : "";
718 return {
719 value: name,
720 label: name + current,
721 description: parts.join(" "),
722 };
723 });
724 items.push({ value: MODE_UI_CONFIGURE, label: MODE_UI_CONFIGURE });
725
726 const choice = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
727 let filterText = "";
728 const container = new Container();
729 container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
730 const titleText = new Text(theme.fg("accent", theme.bold(`Mode (current: ${runtime.currentMode})`)), 1, 0);
731 container.addChild(titleText);
732
733 const filterDisplay = new Text(theme.fg("dim", "Type to filter..."), 1, 0);
734 container.addChild(filterDisplay);
735
736 const selectList = new SelectList(items, Math.min(items.length, 15), {
737 selectedPrefix: (t: string) => theme.fg("accent", t),
738 selectedText: (t: string) => theme.fg("accent", t),
739 description: (t: string) => theme.fg("muted", t),
740 scrollInfo: (t: string) => theme.fg("dim", t),
741 noMatch: (t: string) => theme.fg("warning", t),
742 });
743 selectList.onSelect = (item) => done(item.value);
744 selectList.onCancel = () => done(null);
745 container.addChild(selectList);
746
747 container.addChild(new Text(theme.fg("dim", "↑↓ navigate • enter select • esc cancel"), 1, 0));
748 container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
749
750 const updateFilterDisplay = () => {
751 if (filterText) {
752 filterDisplay.setText(theme.fg("accent", "Filter: ") + theme.fg("text", filterText) + theme.fg("dim", "▏"));
753 } else {
754 filterDisplay.setText(theme.fg("dim", "Type to filter..."));
755 }
756 };
757
758 return {
759 render: (w: number) => container.render(w),
760 invalidate: () => container.invalidate(),
761 handleInput: (data: string) => {
762 if (matchesKey(data, Key.backspace)) {
763 if (filterText.length > 0) {
764 filterText = filterText.slice(0, -1);
765 selectList.setFilter(filterText);
766 updateFilterDisplay();
767 }
768 } else if (data.length === 1 && data.charCodeAt(0) >= 32) {
769 // Printable character
770 filterText += data;
771 selectList.setFilter(filterText);
772 updateFilterDisplay();
773 } else {
774 selectList.handleInput(data);
775 }
776 tui.requestRender();
777 },
778 };
779 });
780
781 if (!choice) return;
782
783 if (choice === MODE_UI_CONFIGURE) {
784 await configureModesUI(pi, ctx);
785 continue;
786 }
787
788 await handleModeChoiceUI(pi, ctx, choice);
789 return;
790 }
791}
792
793async function configureModesUI(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
794 if (!ctx.hasUI) return;
795
796 while (true) {
797 await ensureRuntime(pi, ctx);
798 const names = orderedModeNames(runtime.data.modes);
799 const choice = await ctx.ui.select("Configure modes", [...names, MODE_UI_ADD, MODE_UI_BACK]);
800 if (!choice || choice === MODE_UI_BACK) return;
801
802 if (choice === MODE_UI_ADD) {
803 const created = await addModeUI(pi, ctx);
804 if (created) {
805 await editModeUI(pi, ctx, created);
806 }
807 continue;
808 }
809
810 await editModeUI(pi, ctx, choice);
811 }
812}
813
814async function addModeUI(pi: ExtensionAPI, ctx: ExtensionContext): Promise<string | undefined> {
815 if (!ctx.hasUI) return undefined;
816 await ensureRuntime(pi, ctx);
817
818 while (true) {
819 const raw = await ctx.ui.input("New mode name", "e.g. docs, review, planning");
820 if (raw === undefined) return undefined;
821
822 const name = normalizeModeNameInput(raw);
823 const err = validateModeNameOrError(name, runtime.data.modes);
824 if (err) {
825 ctx.ui.notify(err, "warning");
826 continue;
827 }
828
829 // Default new modes to the current selection so they behave as expected immediately.
830 const selection = customOverlay ?? getCurrentSelectionSpec(pi, ctx);
831 runtime.data.modes[name] = {
832 provider: selection.provider,
833 modelId: selection.modelId,
834 thinkingLevel: selection.thinkingLevel,
835 };
836 await persistRuntime(pi, ctx);
837 ctx.ui.notify(`Added mode \"${name}\"`, "info");
838 return name;
839 }
840}
841
842async function editModeUI(pi: ExtensionAPI, ctx: ExtensionContext, mode: string): Promise<void> {
843 if (!ctx.hasUI) return;
844
845 let modeName = mode;
846
847 while (true) {
848 await ensureRuntime(pi, ctx);
849 const spec = runtime.data.modes[modeName];
850 if (!spec) return;
851
852 const modelLabel = spec.provider && spec.modelId ? `${spec.provider}/${spec.modelId}` : "(no model)";
853 const thinkingLabel = spec.thinkingLevel ?? THINKING_UNSET_LABEL;
854
855 const actions = ["Change name", "Change model", "Change thinking level"];
856 if (!isDefaultModeName(modeName)) actions.push("Delete mode");
857 actions.push(MODE_UI_BACK);
858
859 const action = await ctx.ui.select(
860 `Edit mode \"${modeName}\" model: ${modelLabel} thinking: ${thinkingLabel}`,
861 actions,
862 );
863 if (!action || action === MODE_UI_BACK) return;
864
865 if (action === "Change name") {
866 const renamed = await renameModeUI(pi, ctx, modeName);
867 if (renamed) modeName = renamed;
868 continue;
869 }
870
871 if (action === "Change model") {
872 const selected = await pickModelForModeUI(ctx, spec);
873 if (!selected) continue;
874 spec.provider = selected.provider;
875 spec.modelId = selected.modelId;
876 runtime.data.modes[modeName] = spec;
877 await persistRuntime(pi, ctx);
878 ctx.ui.notify(`Updated model for \"${modeName}\"`, "info");
879
880 if (runtime.currentMode === modeName) {
881 await applyMode(pi, ctx, modeName);
882 }
883 continue;
884 }
885
886 if (action === "Change thinking level") {
887 const level = await pickThinkingLevelForModeUI(ctx, spec.thinkingLevel);
888 if (level === undefined) continue;
889
890 if (level === null) {
891 delete spec.thinkingLevel;
892 } else {
893 spec.thinkingLevel = level;
894 }
895
896 runtime.data.modes[modeName] = spec;
897 await persistRuntime(pi, ctx);
898 ctx.ui.notify(`Updated thinking level for \"${modeName}\"`, "info");
899
900 if (runtime.currentMode === modeName) {
901 await applyMode(pi, ctx, modeName);
902 }
903 continue;
904 }
905
906 if (action === "Delete mode") {
907 const ok = await ctx.ui.confirm("Delete mode", `Delete mode \"${modeName}\"?`);
908 if (!ok) continue;
909
910 delete runtime.data.modes[modeName];
911 await persistRuntime(pi, ctx);
912
913 if (runtime.currentMode === modeName) {
914 runtime.currentMode = CUSTOM_MODE_NAME;
915 customOverlay = getCurrentSelectionSpec(pi, ctx);
916 }
917 if (runtime.lastRealMode === modeName) {
918 runtime.lastRealMode = "default";
919 }
920 updateModeStatus(ctx);
921 requestEditorRender?.();
922 ctx.ui.notify(`Deleted mode \"${modeName}\"`, "info");
923 return;
924 }
925 }
926}
927
928function renameModesRecord(modes: Record<string, ModeSpec>, oldName: string, newName: string): Record<string, ModeSpec> {
929 const out: Record<string, ModeSpec> = {};
930 for (const [k, v] of Object.entries(modes)) {
931 if (k === oldName) out[newName] = v;
932 else out[k] = v;
933 }
934 return out;
935}
936
937async function renameModeUI(pi: ExtensionAPI, ctx: ExtensionContext, oldName: string): Promise<string | undefined> {
938 if (!ctx.hasUI) return undefined;
939
940 if (isDefaultModeName(oldName)) {
941 ctx.ui.notify(`Cannot rename default mode \"${oldName}\"`, "warning");
942 return oldName;
943 }
944
945 await ensureRuntime(pi, ctx);
946
947 while (true) {
948 const raw = await ctx.ui.input(`Rename mode \"${oldName}\"`, oldName);
949 if (raw === undefined) return undefined;
950
951 const newName = normalizeModeNameInput(raw);
952 if (!newName || newName === oldName) return oldName;
953
954 const err = validateModeNameOrError(newName, runtime.data.modes);
955 if (err) {
956 ctx.ui.notify(err, "warning");
957 continue;
958 }
959
960 runtime.data.modes = renameModesRecord(runtime.data.modes, oldName, newName);
961 await persistRuntime(pi, ctx);
962
963 if (runtime.currentMode === oldName) runtime.currentMode = newName;
964 if (runtime.lastRealMode === oldName) runtime.lastRealMode = newName;
965 updateModeStatus(ctx);
966 requestEditorRender?.();
967
968 ctx.ui.notify(`Renamed \"${oldName}\" → \"${newName}\"`, "info");
969 return newName;
970 }
971}
972
973async function pickModelForModeUI(
974 ctx: ExtensionContext,
975 spec: ModeSpec,
976): Promise<{ provider: string; modelId: string } | undefined> {
977 if (!ctx.hasUI) return undefined;
978
979 const settingsManager = SettingsManager.inMemory();
980 const currentModel = spec.provider && spec.modelId ? ctx.modelRegistry.find(spec.provider, spec.modelId) : ctx.model;
981
982 const scopedModels: Array<{ model: any; thinkingLevel: string }> = [];
983
984 return ctx.ui.custom<{ provider: string; modelId: string } | undefined>((tui, _theme, _keybindings, done) => {
985 const selector = new ModelSelectorComponent(
986 tui,
987 currentModel,
988 settingsManager,
989 ctx.modelRegistry as any,
990 scopedModels as any,
991 (model) => done({ provider: model.provider, modelId: model.id }),
992 () => done(undefined),
993 );
994 return selector;
995 });
996}
997
998async function pickThinkingLevelForModeUI(
999 ctx: ExtensionContext,
1000 current: ThinkingLevel | undefined,
1001): Promise<ThinkingLevel | null | undefined> {
1002 if (!ctx.hasUI) return undefined;
1003
1004 const defaultValue = current ?? "off";
1005 const options = [...ALL_THINKING_LEVELS, THINKING_UNSET_LABEL];
1006 // Prefer the current selection by ordering it first.
1007 const ordered = [defaultValue, ...options.filter((x) => x !== defaultValue)];
1008
1009 const choice = await ctx.ui.select("Thinking level", ordered);
1010 if (!choice) return undefined;
1011 if (choice === THINKING_UNSET_LABEL) return null;
1012 if (ALL_THINKING_LEVELS.includes(choice as ThinkingLevel)) return choice as ThinkingLevel;
1013 return undefined;
1014}
1015
1016async function cycleMode(pi: ExtensionAPI, ctx: ExtensionContext, direction: 1 | -1 = 1): Promise<void> {
1017 if (!ctx.hasUI) return;
1018 await ensureRuntime(pi, ctx);
1019 const names = orderedModeNames(runtime.data.modes);
1020 if (names.length === 0) return;
1021
1022 // If we're currently in the overlay mode, cycle relative to the last real mode.
1023 const baseMode = runtime.currentMode === CUSTOM_MODE_NAME ? runtime.lastRealMode : runtime.currentMode;
1024 const idx = Math.max(0, names.indexOf(baseMode));
1025 const next = names[(idx + direction + names.length) % names.length] ?? names[0]!;
1026 await applyMode(pi, ctx, next);
1027}
1028
1029// =============================================================================
1030// Prompt history
1031// =============================================================================
1032
1033const MAX_HISTORY_ENTRIES = 100;
1034const MAX_RECENT_PROMPTS = 30;
1035
1036interface PromptEntry {
1037 text: string;
1038 timestamp: number;
1039}
1040
1041class PromptEditor extends CustomEditor {
1042 public requestRenderNow(): void {
1043 this.tui.requestRender();
1044 }
1045}
1046
1047function extractText(content: Array<{ type: string; text?: string }>): string {
1048 return content
1049 .filter((item) => item.type === "text" && typeof item.text === "string")
1050 .map((item) => item.text ?? "")
1051 .join("")
1052 .trim();
1053}
1054
1055function collectUserPromptsFromEntries(entries: Array<any>): PromptEntry[] {
1056 const prompts: PromptEntry[] = [];
1057
1058 for (const entry of entries) {
1059 if (entry?.type !== "message") continue;
1060 const message = entry?.message;
1061 if (!message || message.role !== "user" || !Array.isArray(message.content)) continue;
1062 const text = extractText(message.content);
1063 if (!text) continue;
1064 const timestamp = Number(message.timestamp ?? entry.timestamp ?? Date.now());
1065 prompts.push({ text, timestamp });
1066 }
1067
1068 return prompts;
1069}
1070
1071function getSessionDirForCwd(cwd: string): string {
1072 const safePath = `--${cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`;
1073 return path.join(getGlobalAgentDir(), "sessions", safePath);
1074}
1075
1076async function readTail(filePath: string, maxBytes = 256 * 1024): Promise<string> {
1077 let fileHandle: fs.FileHandle | undefined;
1078 try {
1079 const stats = await fs.stat(filePath);
1080 const size = stats.size;
1081 const start = Math.max(0, size - maxBytes);
1082 const length = size - start;
1083 if (length <= 0) return "";
1084
1085 const buffer = Buffer.alloc(length);
1086 fileHandle = await fs.open(filePath, "r");
1087 const { bytesRead } = await fileHandle.read(buffer, 0, length, start);
1088 if (bytesRead === 0) return "";
1089 let chunk = buffer.subarray(0, bytesRead).toString("utf8");
1090 if (start > 0) {
1091 const firstNewline = chunk.indexOf("\n");
1092 if (firstNewline !== -1) {
1093 chunk = chunk.slice(firstNewline + 1);
1094 }
1095 }
1096 return chunk;
1097 } catch {
1098 return "";
1099 } finally {
1100 await fileHandle?.close();
1101 }
1102}
1103
1104async function loadPromptHistoryForCwd(cwd: string, excludeSessionFile?: string): Promise<PromptEntry[]> {
1105 const sessionDir = getSessionDirForCwd(path.resolve(cwd));
1106 const resolvedExclude = excludeSessionFile ? path.resolve(excludeSessionFile) : undefined;
1107 const prompts: PromptEntry[] = [];
1108
1109 let entries: Dirent[] = [];
1110 try {
1111 entries = await fs.readdir(sessionDir, { withFileTypes: true });
1112 } catch {
1113 return prompts;
1114 }
1115
1116 const files = await Promise.all(
1117 entries
1118 .filter((entry) => entry.isFile() && entry.name.endsWith(".jsonl"))
1119 .map(async (entry) => {
1120 const filePath = path.join(sessionDir, entry.name);
1121 try {
1122 const stats = await fs.stat(filePath);
1123 return { filePath, mtimeMs: stats.mtimeMs };
1124 } catch {
1125 return undefined;
1126 }
1127 }),
1128 );
1129
1130 const sortedFiles = files
1131 .filter((file): file is { filePath: string; mtimeMs: number } => Boolean(file))
1132 .sort((a, b) => b.mtimeMs - a.mtimeMs);
1133
1134 for (const file of sortedFiles) {
1135 if (resolvedExclude && path.resolve(file.filePath) === resolvedExclude) continue;
1136
1137 const tail = await readTail(file.filePath);
1138 if (!tail) continue;
1139 const lines = tail.split("\n").filter(Boolean);
1140 for (const line of lines) {
1141 let entry: any;
1142 try {
1143 entry = JSON.parse(line);
1144 } catch {
1145 continue;
1146 }
1147 if (entry?.type !== "message") continue;
1148 const message = entry?.message;
1149 if (!message || message.role !== "user" || !Array.isArray(message.content)) continue;
1150 const text = extractText(message.content);
1151 if (!text) continue;
1152 const timestamp = Number(message.timestamp ?? entry.timestamp ?? Date.now());
1153 prompts.push({ text, timestamp });
1154 if (prompts.length >= MAX_RECENT_PROMPTS) break;
1155 }
1156 if (prompts.length >= MAX_RECENT_PROMPTS) break;
1157 }
1158
1159 return prompts;
1160}
1161
1162function buildHistoryList(currentSession: PromptEntry[], previousSessions: PromptEntry[]): PromptEntry[] {
1163 const all = [...currentSession, ...previousSessions];
1164 all.sort((a, b) => a.timestamp - b.timestamp);
1165
1166 const seen = new Set<string>();
1167 const deduped: PromptEntry[] = [];
1168 for (const prompt of all) {
1169 const key = `${prompt.timestamp}:${prompt.text}`;
1170 if (seen.has(key)) continue;
1171 seen.add(key);
1172 deduped.push(prompt);
1173 }
1174
1175 return deduped.slice(-MAX_HISTORY_ENTRIES);
1176}
1177
1178// Overlay mode state ("custom"). Not selectable, not cycled into.
1179let customOverlay: ModeSpec | null = null;
1180
1181let loadCounter = 0;
1182
1183function historiesMatch(a: PromptEntry[], b: PromptEntry[]): boolean {
1184 if (a.length !== b.length) return false;
1185 for (let i = 0; i < a.length; i += 1) {
1186 if (a[i]?.text !== b[i]?.text || a[i]?.timestamp !== b[i]?.timestamp) return false;
1187 }
1188 return true;
1189}
1190
1191function setEditor(pi: ExtensionAPI, ctx: ExtensionContext, history: PromptEntry[]) {
1192 ctx.ui.setEditorComponent((tui, theme, keybindings) => {
1193 const editor = new PromptEditor(tui, theme, keybindings);
1194 requestEditorRender = () => editor.requestRenderNow();
1195 for (const prompt of history) {
1196 editor.addToHistory?.(prompt.text);
1197 }
1198 return editor;
1199 });
1200}
1201
1202function applyEditor(pi: ExtensionAPI, ctx: ExtensionContext) {
1203 if (!ctx.hasUI) return;
1204
1205 const sessionFile = ctx.sessionManager.getSessionFile();
1206 const currentEntries = ctx.sessionManager.getBranch();
1207 const currentPrompts = collectUserPromptsFromEntries(currentEntries);
1208 const immediateHistory = buildHistoryList(currentPrompts, []);
1209
1210 const currentLoad = ++loadCounter;
1211 const initialText = ctx.ui.getEditorText();
1212 setEditor(pi, ctx, immediateHistory);
1213
1214 void (async () => {
1215 const previousPrompts = await loadPromptHistoryForCwd(ctx.cwd, sessionFile ?? undefined);
1216 if (currentLoad !== loadCounter) return;
1217 if (ctx.ui.getEditorText() !== initialText) return;
1218 const history = buildHistoryList(currentPrompts, previousPrompts);
1219 if (historiesMatch(history, immediateHistory)) return;
1220 setEditor(pi, ctx, history);
1221 })();
1222}
1223
1224// =============================================================================
1225// Extension Export
1226// =============================================================================
1227
1228export default function (pi: ExtensionAPI) {
1229 // Register --start-mode CLI flag
1230 pi.registerFlag("start-mode", {
1231 description: "Start with a specific mode from modes.json",
1232 type: "string",
1233 default: "",
1234 });
1235
1236 pi.registerCommand("mode", {
1237 description: "Select prompt mode",
1238 handler: async (args, ctx) => {
1239 const tokens = args
1240 .split(/\s+/)
1241 .map((x) => x.trim())
1242 .filter(Boolean);
1243
1244 // /mode
1245 if (tokens.length === 0) {
1246 await selectModeUI(pi, ctx);
1247 return;
1248 }
1249
1250 // /mode store [name]
1251 if (tokens[0] === "store") {
1252 await ensureRuntime(pi, ctx);
1253
1254 let target = tokens[1];
1255 if (!target) {
1256 if (!ctx.hasUI) return;
1257 const names = orderedModeNames(runtime.data.modes);
1258 target = await ctx.ui.select("Store current selection into mode", names);
1259 if (!target) return;
1260 }
1261
1262 if (target === CUSTOM_MODE_NAME) {
1263 if (ctx.hasUI) ctx.ui.notify(`Cannot store into "${CUSTOM_MODE_NAME}"`, "warning");
1264 return;
1265 }
1266
1267 const selection = customOverlay ?? getCurrentSelectionSpec(pi, ctx);
1268 await storeSelectionIntoMode(pi, ctx, target, selection);
1269 if (ctx.hasUI) ctx.ui.notify(`Stored current selection into "${target}"`, "info");
1270 return;
1271 }
1272
1273 // /mode <name> — with fuzzy matching
1274 await ensureRuntime(pi, ctx);
1275 const query = tokens.join(" ").toLowerCase();
1276 const names = orderedModeNames(runtime.data.modes);
1277
1278 // Exact match first
1279 const exact = names.find((n) => n.toLowerCase() === query);
1280 if (exact) {
1281 await applyMode(pi, ctx, exact);
1282 return;
1283 }
1284
1285 // Prefix match
1286 const prefixMatches = names.filter((n) => n.toLowerCase().startsWith(query));
1287 if (prefixMatches.length === 1) {
1288 await applyMode(pi, ctx, prefixMatches[0]!);
1289 return;
1290 }
1291
1292 // Substring match
1293 const substringMatches = names.filter((n) => n.toLowerCase().includes(query));
1294 if (substringMatches.length === 1) {
1295 await applyMode(pi, ctx, substringMatches[0]!);
1296 return;
1297 }
1298
1299 // Fuzzy match: all query chars appear in order
1300 const fuzzyMatch = (name: string, q: string): boolean => {
1301 let qi = 0;
1302 for (let i = 0; i < name.length && qi < q.length; i++) {
1303 if (name[i] === q[qi]) qi++;
1304 }
1305 return qi === q.length;
1306 };
1307 const fuzzyMatches = names.filter((n) => fuzzyMatch(n.toLowerCase(), query));
1308 if (fuzzyMatches.length === 1) {
1309 await applyMode(pi, ctx, fuzzyMatches[0]!);
1310 return;
1311 }
1312
1313 // Multiple matches or no match
1314 if (fuzzyMatches.length > 1) {
1315 if (ctx.hasUI) {
1316 const choice = await ctx.ui.select(`Multiple modes match "${query}"`, fuzzyMatches);
1317 if (choice) await handleModeChoiceUI(pi, ctx, choice);
1318 }
1319 return;
1320 }
1321
1322 // No match at all — try original name
1323 await applyMode(pi, ctx, tokens[0]!);
1324 },
1325 });
1326
1327 pi.registerShortcut("ctrl+shift+m", {
1328 description: "Select prompt mode",
1329 handler: async (ctx) => {
1330 await selectModeUI(pi, ctx);
1331 },
1332 });
1333
1334 pi.registerShortcut("ctrl+space", {
1335 description: "Cycle prompt mode",
1336 handler: async (ctx) => {
1337 await cycleMode(pi, ctx, 1);
1338 },
1339 });
1340
1341 pi.on("session_start", async (_event, ctx) => {
1342 lastObservedModel = { provider: ctx.model?.provider, modelId: ctx.model?.id };
1343 await ensureRuntime(pi, ctx);
1344 customOverlay = null;
1345 installEditorBorderPatch();
1346
1347 // Check --start-mode CLI flag — apply mode ephemerally without
1348 // persisting provider/model to settings.json so subsequent launches
1349 // aren't affected.
1350 const startMode = pi.getFlag("start-mode") as string;
1351 if (startMode) {
1352 const settingsPath = path.join(getGlobalAgentDir(), "settings.json");
1353 const savedSettings = await fs.readFile(settingsPath, "utf8").catch(() => null);
1354 await applyMode(pi, ctx, startMode);
1355 // Restore settings.json so defaultProvider/defaultModel aren't changed
1356 if (savedSettings) {
1357 await fs.writeFile(settingsPath, savedSettings, "utf8");
1358 }
1359 return;
1360 }
1361
1362 const inferred = inferModeFromSelection(ctx, pi, runtime.data);
1363 if (inferred) {
1364 runtime.currentMode = inferred;
1365 runtime.lastRealMode = inferred;
1366 } else {
1367 // No exact match → treat as overlay.
1368 runtime.currentMode = CUSTOM_MODE_NAME;
1369 customOverlay = getCurrentSelectionSpec(pi, ctx);
1370 }
1371
1372 updateModeStatus(ctx);
1373 applyEditor(pi, ctx);
1374 });
1375
1376 pi.on("session_switch", async (_event, ctx) => {
1377 lastObservedModel = { provider: ctx.model?.provider, modelId: ctx.model?.id };
1378 await ensureRuntime(pi, ctx);
1379 customOverlay = null;
1380
1381 const inferred = inferModeFromSelection(ctx, pi, runtime.data);
1382 if (inferred) {
1383 runtime.currentMode = inferred;
1384 runtime.lastRealMode = inferred;
1385 } else {
1386 runtime.currentMode = CUSTOM_MODE_NAME;
1387 customOverlay = getCurrentSelectionSpec(pi, ctx);
1388 }
1389
1390 updateModeStatus(ctx);
1391 applyEditor(pi, ctx);
1392 });
1393
1394
1395 pi.on("model_select", async (event: ModelSelectEvent, ctx) => {
1396 // Always track the last observed model for overlay/store correctness.
1397 lastObservedModel = { provider: event.model.provider, modelId: event.model.id };
1398
1399 // Skip mode switching triggered by applyMode() itself, otherwise we'd jump to "custom"
1400 // while we are in the middle of applying a mode.
1401 if (runtime.applying) return;
1402
1403 // Manual model changes always go into the overlay "custom" mode.
1404 await ensureRuntime(pi, ctx);
1405 if (runtime.currentMode !== CUSTOM_MODE_NAME) {
1406 runtime.lastRealMode = runtime.currentMode;
1407 }
1408 runtime.currentMode = CUSTOM_MODE_NAME;
1409
1410 customOverlay = {
1411 provider: event.model.provider,
1412 modelId: event.model.id,
1413 thinkingLevel: pi.getThinkingLevel(),
1414 };
1415
1416 // Do not persist/select custom.
1417 if (ctx.hasUI) {
1418 updateModeStatus(ctx);
1419 requestEditorRender?.();
1420 }
1421 });
1422
1423}