@zigai/pi-mode 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,981 @@
1
+ import {
2
+ ModelSelectorComponent,
3
+ SettingsManager,
4
+ type ExtensionAPI,
5
+ type ExtensionContext,
6
+ } from "@earendil-works/pi-coding-agent";
7
+ import type { Api, Model } from "@earendil-works/pi-ai";
8
+ import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
9
+ import fs from "node:fs/promises";
10
+ import {
11
+ ALL_THINKING_LEVELS,
12
+ CUSTOM_MODE_NAME,
13
+ DEFAULT_MODE_ORDER,
14
+ MODE_UI_ADD,
15
+ MODE_UI_BACK,
16
+ MODE_UI_CONFIGURE,
17
+ MODE_UI_SHOW_NAME_OFF,
18
+ MODE_UI_SHOW_NAME_ON,
19
+ THINKING_UNSET_LABEL,
20
+ } from "./constants.ts";
21
+ import {
22
+ atomicWriteUtf8,
23
+ fileExists,
24
+ getGlobalModesPath,
25
+ getMtimeMs,
26
+ getProjectModesPath,
27
+ withFileLock,
28
+ } from "./storage.ts";
29
+ import { setShowModeName, shouldShowModeName } from "./settings.ts";
30
+ import type { ModeRuntime, ModesFile, ModesPatch, ModeSpec, ModeSpecPatch } from "./types.ts";
31
+
32
+ type ScopedModelItem = {
33
+ model: Model<Api>;
34
+ thinkingLevel?: string;
35
+ };
36
+
37
+ type ModeSpecJson = {
38
+ provider?: string;
39
+ modelId?: string;
40
+ thinkingLevel?: ThinkingLevel;
41
+ color?: ModeSpec["color"];
42
+ };
43
+
44
+ type ModesFileJson = {
45
+ currentMode?: string;
46
+ modes?: Record<string, ModeSpecJson>;
47
+ };
48
+
49
+ function cloneModesFile(file: ModesFile): ModesFile {
50
+ return JSON.parse(JSON.stringify(file)) as ModesFile;
51
+ }
52
+
53
+ function modeSpec(modes: Record<string, ModeSpec>, name: string): ModeSpec | undefined {
54
+ if (Object.hasOwn(modes, name)) return modes[name];
55
+ return undefined;
56
+ }
57
+
58
+ function computeModesPatch(
59
+ base: ModesFile,
60
+ next: ModesFile,
61
+ includeCurrentMode: boolean,
62
+ ): ModesPatch | null {
63
+ const patch: ModesPatch = {};
64
+
65
+ if (includeCurrentMode && base.currentMode !== next.currentMode) {
66
+ patch.currentMode = next.currentMode;
67
+ }
68
+
69
+ const keys = new Set([...Object.keys(base.modes), ...Object.keys(next.modes)]);
70
+ const modesPatch: Record<string, ModeSpecPatch | null> = {};
71
+
72
+ for (const key of keys) {
73
+ const before = base.modes[key];
74
+ const after = next.modes[key];
75
+
76
+ if (after === undefined) {
77
+ if (before !== undefined) modesPatch[key] = null;
78
+ continue;
79
+ }
80
+ if (before === undefined) {
81
+ modesPatch[key] = { ...after };
82
+ continue;
83
+ }
84
+
85
+ const diff: ModeSpecPatch = {};
86
+ if (before.provider !== after.provider) {
87
+ diff.provider = after.provider ?? null;
88
+ }
89
+ if (before.modelId !== after.modelId) {
90
+ diff.modelId = after.modelId ?? null;
91
+ }
92
+ if (before.thinkingLevel !== after.thinkingLevel) {
93
+ diff.thinkingLevel = after.thinkingLevel ?? null;
94
+ }
95
+ if (before.color !== after.color) {
96
+ diff.color = after.color ?? null;
97
+ }
98
+ if (Object.keys(diff).length > 0) {
99
+ modesPatch[key] = diff;
100
+ }
101
+ }
102
+
103
+ if (Object.keys(modesPatch).length > 0) {
104
+ patch.modes = modesPatch;
105
+ }
106
+
107
+ if (patch.modes === undefined && patch.currentMode === undefined) return null;
108
+ return patch;
109
+ }
110
+
111
+ function applyModesPatch(target: ModesFile, patch: ModesPatch): void {
112
+ if (patch.currentMode !== undefined) {
113
+ target.currentMode = patch.currentMode;
114
+ }
115
+
116
+ if (patch.modes === undefined) return;
117
+ for (const [mode, specPatch] of Object.entries(patch.modes)) {
118
+ if (specPatch === null) {
119
+ delete target.modes[mode];
120
+ continue;
121
+ }
122
+
123
+ const targetSpec = modeSpec(target.modes, mode) ?? {};
124
+ target.modes[mode] = targetSpec;
125
+ if ("provider" in specPatch) {
126
+ if (specPatch.provider === null || specPatch.provider === undefined) {
127
+ delete targetSpec.provider;
128
+ } else {
129
+ targetSpec.provider = specPatch.provider;
130
+ }
131
+ }
132
+ if ("modelId" in specPatch) {
133
+ if (specPatch.modelId === null || specPatch.modelId === undefined) {
134
+ delete targetSpec.modelId;
135
+ } else {
136
+ targetSpec.modelId = specPatch.modelId;
137
+ }
138
+ }
139
+ if ("thinkingLevel" in specPatch) {
140
+ if (specPatch.thinkingLevel === null || specPatch.thinkingLevel === undefined) {
141
+ delete targetSpec.thinkingLevel;
142
+ } else {
143
+ targetSpec.thinkingLevel = specPatch.thinkingLevel;
144
+ }
145
+ }
146
+ if ("color" in specPatch) {
147
+ if (specPatch.color === null || specPatch.color === undefined) {
148
+ delete targetSpec.color;
149
+ } else {
150
+ targetSpec.color = specPatch.color;
151
+ }
152
+ }
153
+ }
154
+ }
155
+
156
+ function normalizeThinkingLevel(level: ThinkingLevel | undefined): ThinkingLevel | undefined {
157
+ if (level === undefined) return undefined;
158
+ if (ALL_THINKING_LEVELS.includes(level)) {
159
+ return level;
160
+ }
161
+ return undefined;
162
+ }
163
+
164
+ function sanitizeModeSpec(spec: ModeSpecJson | undefined): ModeSpec {
165
+ if (spec === undefined) return {};
166
+
167
+ const sanitized: ModeSpec = {
168
+ thinkingLevel: normalizeThinkingLevel(spec.thinkingLevel),
169
+ };
170
+ if (spec.provider !== undefined) sanitized.provider = spec.provider;
171
+ if (spec.modelId !== undefined) sanitized.modelId = spec.modelId;
172
+ if (spec.color !== undefined) sanitized.color = spec.color;
173
+ return sanitized;
174
+ }
175
+
176
+ function createDefaultModes(ctx: ExtensionContext, pi: ExtensionAPI): ModesFile {
177
+ const currentModel = ctx.model;
178
+ const currentThinking = pi.getThinkingLevel();
179
+
180
+ const base: ModeSpec = {
181
+ provider: currentModel?.provider,
182
+ modelId: currentModel?.id,
183
+ thinkingLevel: currentThinking,
184
+ };
185
+
186
+ return {
187
+ version: 1,
188
+ currentMode: "default",
189
+ modes: {
190
+ default: { ...base },
191
+ fast: { ...base, thinkingLevel: "off" },
192
+ },
193
+ };
194
+ }
195
+
196
+ function ensureDefaultModeEntries(file: ModesFile, ctx: ExtensionContext, pi: ExtensionAPI): void {
197
+ for (const name of DEFAULT_MODE_ORDER) {
198
+ if (modeSpec(file.modes, name) === undefined) {
199
+ const defaults = createDefaultModes(ctx, pi);
200
+ file.modes[name] = defaults.modes[name]!;
201
+ }
202
+ }
203
+
204
+ if (file.currentMode === CUSTOM_MODE_NAME) {
205
+ file.currentMode = "";
206
+ }
207
+
208
+ if (
209
+ file.currentMode.length === 0 ||
210
+ !(file.currentMode in file.modes) ||
211
+ file.currentMode === CUSTOM_MODE_NAME
212
+ ) {
213
+ const first = Object.keys(file.modes).find((name) => name !== CUSTOM_MODE_NAME);
214
+ if (modeSpec(file.modes, "default") !== undefined) {
215
+ file.currentMode = "default";
216
+ } else if (first !== undefined && first.length > 0) {
217
+ file.currentMode = first;
218
+ } else {
219
+ file.currentMode = "default";
220
+ }
221
+ }
222
+ }
223
+
224
+ async function loadModesFile(
225
+ filePath: string,
226
+ ctx: ExtensionContext,
227
+ pi: ExtensionAPI,
228
+ ): Promise<ModesFile> {
229
+ try {
230
+ const raw = await fs.readFile(filePath, "utf8");
231
+ const parsed = JSON.parse(raw) as ModesFileJson;
232
+ const currentMode = parsed.currentMode ?? "default";
233
+ const modesRaw = parsed.modes ?? {};
234
+
235
+ const modes: Record<string, ModeSpec> = {};
236
+ for (const [key, value] of Object.entries(modesRaw)) {
237
+ modes[key] = sanitizeModeSpec(value);
238
+ }
239
+
240
+ const file: ModesFile = {
241
+ version: 1,
242
+ currentMode,
243
+ modes,
244
+ };
245
+ ensureDefaultModeEntries(file, ctx, pi);
246
+ return file;
247
+ } catch {
248
+ return createDefaultModes(ctx, pi);
249
+ }
250
+ }
251
+
252
+ async function saveModesFile(filePath: string, data: ModesFile): Promise<void> {
253
+ await atomicWriteUtf8(filePath, JSON.stringify(data, null, 2) + "\n");
254
+ }
255
+
256
+ function orderedModeNames(modes: Record<string, ModeSpec>): string[] {
257
+ return Object.keys(modes).filter((name) => name !== CUSTOM_MODE_NAME);
258
+ }
259
+
260
+ function formatModeLabel(mode: string): string {
261
+ return mode;
262
+ }
263
+
264
+ async function resolveModesPath(cwd: string): Promise<string> {
265
+ const projectPath = getProjectModesPath(cwd);
266
+ if (await fileExists(projectPath)) return projectPath;
267
+ return getGlobalModesPath();
268
+ }
269
+
270
+ function inferModeFromSelection(
271
+ ctx: ExtensionContext,
272
+ pi: ExtensionAPI,
273
+ data: ModesFile,
274
+ ): string | null {
275
+ const provider = ctx.model?.provider;
276
+ const modelId = ctx.model?.id;
277
+ const thinkingLevel = pi.getThinkingLevel();
278
+ if (
279
+ provider === undefined ||
280
+ provider.length === 0 ||
281
+ modelId === undefined ||
282
+ modelId.length === 0
283
+ ) {
284
+ return null;
285
+ }
286
+
287
+ const names = orderedModeNames(data.modes);
288
+ const supportsThinking = ctx.model?.reasoning === true;
289
+
290
+ if (supportsThinking) {
291
+ for (const name of names) {
292
+ const spec = modeSpec(data.modes, name);
293
+ if (spec === undefined) continue;
294
+ if (spec.provider !== provider || spec.modelId !== modelId) continue;
295
+ if ((spec.thinkingLevel ?? undefined) !== thinkingLevel) continue;
296
+ return name;
297
+ }
298
+ return null;
299
+ }
300
+
301
+ const candidates: string[] = [];
302
+ for (const name of names) {
303
+ const spec = modeSpec(data.modes, name);
304
+ if (spec === undefined) continue;
305
+ if (spec.provider !== provider || spec.modelId !== modelId) continue;
306
+ candidates.push(name);
307
+ }
308
+ if (candidates.length === 0) return null;
309
+
310
+ for (const name of candidates) {
311
+ const spec = modeSpec(data.modes, name);
312
+ if (spec === undefined) continue;
313
+ if ((spec.thinkingLevel ?? "off") === thinkingLevel) return name;
314
+ }
315
+
316
+ for (const name of candidates) {
317
+ const spec = modeSpec(data.modes, name);
318
+ if (spec === undefined) continue;
319
+ if (spec.thinkingLevel === undefined) return name;
320
+ }
321
+
322
+ return candidates[0] ?? null;
323
+ }
324
+
325
+ const runtime: ModeRuntime = {
326
+ filePath: "",
327
+ fileMtimeMs: null,
328
+ baseline: null,
329
+ data: { version: 1, currentMode: "default", modes: {} },
330
+ lastRealMode: "default",
331
+ currentMode: "default",
332
+ applying: false,
333
+ };
334
+
335
+ let requestEditorRender: (() => void) | undefined;
336
+ let customOverlay: ModeSpec | null = null;
337
+ let lastObservedModel: { provider?: string; modelId?: string } = {};
338
+
339
+ export function setRequestEditorRender(requestRender?: () => void): void {
340
+ requestEditorRender = requestRender;
341
+ }
342
+
343
+ export function getCurrentMode(): string {
344
+ return formatModeLabel(runtime.currentMode);
345
+ }
346
+
347
+ export function getModeBorderColor(
348
+ ctx: ExtensionContext,
349
+ pi: ExtensionAPI,
350
+ mode: string,
351
+ ): (text: string) => string {
352
+ const theme = ctx.ui.theme;
353
+ const spec = runtime.data.modes[mode];
354
+
355
+ if (spec?.color !== undefined && spec.color.length > 0) {
356
+ const color = spec.color;
357
+ try {
358
+ theme.getFgAnsi(color);
359
+ return (text: string) => theme.fg(color, text);
360
+ } catch {
361
+ // fall back to thinking-derived colors
362
+ }
363
+ }
364
+
365
+ return theme.getThinkingBorderColor(pi.getThinkingLevel());
366
+ }
367
+
368
+ async function ensureRuntime(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
369
+ const filePath = await resolveModesPath(ctx.cwd);
370
+
371
+ const mtimeMs = await getMtimeMs(filePath);
372
+ const filePathChanged = runtime.filePath !== filePath;
373
+ const fileChanged = filePathChanged || runtime.fileMtimeMs !== mtimeMs;
374
+
375
+ if (fileChanged) {
376
+ runtime.filePath = filePath;
377
+ runtime.fileMtimeMs = mtimeMs;
378
+
379
+ const loaded = await loadModesFile(filePath, ctx, pi);
380
+ ensureDefaultModeEntries(loaded, ctx, pi);
381
+ runtime.data = loaded;
382
+ runtime.baseline = cloneModesFile(runtime.data);
383
+
384
+ if (filePathChanged && runtime.currentMode !== CUSTOM_MODE_NAME) {
385
+ runtime.currentMode = runtime.data.currentMode;
386
+ runtime.lastRealMode = runtime.currentMode;
387
+ }
388
+ }
389
+
390
+ if (runtime.currentMode !== CUSTOM_MODE_NAME) {
391
+ if (runtime.currentMode.length === 0 || !(runtime.currentMode in runtime.data.modes)) {
392
+ runtime.currentMode = runtime.data.currentMode;
393
+ }
394
+ if (runtime.lastRealMode.length === 0 || !(runtime.lastRealMode in runtime.data.modes)) {
395
+ runtime.lastRealMode = runtime.currentMode;
396
+ }
397
+ }
398
+ }
399
+
400
+ async function persistRuntime(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
401
+ if (runtime.filePath.length === 0) return;
402
+
403
+ runtime.baseline ??= cloneModesFile(runtime.data);
404
+ const patch = computeModesPatch(runtime.baseline, runtime.data, false);
405
+ if (patch === null) return;
406
+
407
+ await withFileLock(runtime.filePath, async () => {
408
+ const latest = await loadModesFile(runtime.filePath, ctx, pi);
409
+ applyModesPatch(latest, patch);
410
+ ensureDefaultModeEntries(latest, ctx, pi);
411
+ await saveModesFile(runtime.filePath, latest);
412
+
413
+ runtime.data = latest;
414
+ runtime.baseline = cloneModesFile(latest);
415
+ runtime.fileMtimeMs = await getMtimeMs(runtime.filePath);
416
+ });
417
+ }
418
+
419
+ function getCurrentSelectionSpec(pi: ExtensionAPI): ModeSpec {
420
+ return {
421
+ provider: lastObservedModel.provider,
422
+ modelId: lastObservedModel.modelId,
423
+ thinkingLevel: pi.getThinkingLevel(),
424
+ };
425
+ }
426
+
427
+ async function storeSelectionIntoMode(
428
+ pi: ExtensionAPI,
429
+ ctx: ExtensionContext,
430
+ mode: string,
431
+ selection: ModeSpec,
432
+ ): Promise<void> {
433
+ if (mode === CUSTOM_MODE_NAME) return;
434
+
435
+ await ensureRuntime(pi, ctx);
436
+
437
+ const existingTarget = runtime.data.modes[mode] ?? {};
438
+ const next: ModeSpec = { ...existingTarget };
439
+
440
+ if (
441
+ selection.provider !== undefined &&
442
+ selection.provider.length > 0 &&
443
+ selection.modelId !== undefined &&
444
+ selection.modelId.length > 0
445
+ ) {
446
+ next.provider = selection.provider;
447
+ next.modelId = selection.modelId;
448
+ }
449
+ if (selection.thinkingLevel !== undefined) next.thinkingLevel = selection.thinkingLevel;
450
+
451
+ runtime.data.modes[mode] = next;
452
+ await persistRuntime(pi, ctx);
453
+ }
454
+
455
+ async function applyMode(pi: ExtensionAPI, ctx: ExtensionContext, mode: string): Promise<void> {
456
+ await ensureRuntime(pi, ctx);
457
+
458
+ if (mode === CUSTOM_MODE_NAME) {
459
+ runtime.currentMode = CUSTOM_MODE_NAME;
460
+ customOverlay = getCurrentSelectionSpec(pi);
461
+ if (ctx.hasUI) requestEditorRender?.();
462
+ return;
463
+ }
464
+
465
+ const spec = modeSpec(runtime.data.modes, mode);
466
+ if (spec === undefined) {
467
+ if (ctx.hasUI) {
468
+ ctx.ui.notify(`Unknown mode: ${mode}`, "warning");
469
+ }
470
+ return;
471
+ }
472
+
473
+ runtime.currentMode = mode;
474
+ runtime.lastRealMode = mode;
475
+ customOverlay = null;
476
+
477
+ runtime.applying = true;
478
+ let modelAppliedOk = true;
479
+ try {
480
+ if (
481
+ spec.provider !== undefined &&
482
+ spec.provider.length > 0 &&
483
+ spec.modelId !== undefined &&
484
+ spec.modelId.length > 0
485
+ ) {
486
+ const model = ctx.modelRegistry.find(spec.provider, spec.modelId);
487
+ if (model !== undefined) {
488
+ const ok = await pi.setModel(model);
489
+ modelAppliedOk = ok;
490
+ if (!ok && ctx.hasUI) {
491
+ ctx.ui.notify(
492
+ `No API key available for ${spec.provider}/${spec.modelId}`,
493
+ "warning",
494
+ );
495
+ }
496
+ } else {
497
+ modelAppliedOk = false;
498
+ if (ctx.hasUI) {
499
+ ctx.ui.notify(
500
+ `Mode "${mode}" references unknown model ${spec.provider}/${spec.modelId}`,
501
+ "warning",
502
+ );
503
+ }
504
+ }
505
+ }
506
+
507
+ if (spec.thinkingLevel !== undefined) {
508
+ pi.setThinkingLevel(spec.thinkingLevel);
509
+ }
510
+ } finally {
511
+ runtime.applying = false;
512
+ }
513
+
514
+ if (!modelAppliedOk) {
515
+ runtime.currentMode = CUSTOM_MODE_NAME;
516
+ customOverlay = getCurrentSelectionSpec(pi);
517
+ }
518
+
519
+ if (ctx.hasUI) {
520
+ requestEditorRender?.();
521
+ }
522
+ }
523
+
524
+ function isDefaultModeName(name: string): boolean {
525
+ return (DEFAULT_MODE_ORDER as readonly string[]).includes(name);
526
+ }
527
+
528
+ function isReservedModeName(name: string): boolean {
529
+ return (
530
+ name === CUSTOM_MODE_NAME ||
531
+ name === MODE_UI_CONFIGURE ||
532
+ name === MODE_UI_ADD ||
533
+ name === MODE_UI_BACK
534
+ );
535
+ }
536
+
537
+ function normalizeModeNameInput(name: string | undefined): string {
538
+ return (name ?? "").trim();
539
+ }
540
+
541
+ function validateModeNameOrError(
542
+ name: string,
543
+ existing: Record<string, ModeSpec>,
544
+ options?: { allowExisting?: boolean },
545
+ ): string | null {
546
+ if (name.length === 0) return "Mode name cannot be empty";
547
+ if (/\s/.test(name)) return "Mode name cannot contain whitespace";
548
+ if (isReservedModeName(name)) return `Mode name "${name}" is reserved`;
549
+ if (options?.allowExisting !== true && modeSpec(existing, name) !== undefined) {
550
+ return `Mode "${name}" already exists`;
551
+ }
552
+ return null;
553
+ }
554
+
555
+ async function pickThinkingLevelForModeUI(
556
+ ctx: ExtensionContext,
557
+ current: ThinkingLevel | undefined,
558
+ ): Promise<ThinkingLevel | null | undefined> {
559
+ if (!ctx.hasUI) return undefined;
560
+
561
+ const defaultValue = current ?? "off";
562
+ const options = [...ALL_THINKING_LEVELS, THINKING_UNSET_LABEL];
563
+ const ordered = [defaultValue, ...options.filter((value) => value !== defaultValue)];
564
+
565
+ const choice = await ctx.ui.select("Thinking level", ordered);
566
+ if (choice === undefined || choice.length === 0) return undefined;
567
+ if (choice === THINKING_UNSET_LABEL) return null;
568
+ if (ALL_THINKING_LEVELS.includes(choice as ThinkingLevel)) return choice as ThinkingLevel;
569
+ return undefined;
570
+ }
571
+
572
+ async function pickModelForModeUI(
573
+ ctx: ExtensionContext,
574
+ spec: ModeSpec,
575
+ ): Promise<{ provider: string; modelId: string } | undefined> {
576
+ if (!ctx.hasUI) return undefined;
577
+
578
+ const settingsManager = SettingsManager.inMemory();
579
+ let currentModel = ctx.model;
580
+ if (
581
+ spec.provider !== undefined &&
582
+ spec.provider.length > 0 &&
583
+ spec.modelId !== undefined &&
584
+ spec.modelId.length > 0
585
+ ) {
586
+ currentModel = ctx.modelRegistry.find(spec.provider, spec.modelId) ?? ctx.model;
587
+ }
588
+
589
+ const scopedModels: ScopedModelItem[] = [];
590
+
591
+ return ctx.ui.custom<{ provider: string; modelId: string } | undefined>(
592
+ (tui, _theme, _keybindings, done) => {
593
+ const selector = new ModelSelectorComponent(
594
+ tui,
595
+ currentModel,
596
+ settingsManager,
597
+ ctx.modelRegistry,
598
+ scopedModels,
599
+ (model) => done({ provider: model.provider, modelId: model.id }),
600
+ () => done(undefined),
601
+ );
602
+ return selector;
603
+ },
604
+ );
605
+ }
606
+
607
+ function renameModesRecord(
608
+ modes: Record<string, ModeSpec>,
609
+ oldName: string,
610
+ newName: string,
611
+ ): Record<string, ModeSpec> {
612
+ const renamed: Record<string, ModeSpec> = {};
613
+ for (const [key, value] of Object.entries(modes)) {
614
+ let targetKey = key;
615
+ if (key === oldName) {
616
+ targetKey = newName;
617
+ }
618
+ renamed[targetKey] = value;
619
+ }
620
+ return renamed;
621
+ }
622
+
623
+ async function renameModeUI(
624
+ pi: ExtensionAPI,
625
+ ctx: ExtensionContext,
626
+ oldName: string,
627
+ ): Promise<string | undefined> {
628
+ if (!ctx.hasUI) return undefined;
629
+
630
+ if (isDefaultModeName(oldName)) {
631
+ ctx.ui.notify(`Cannot rename default mode "${oldName}"`, "warning");
632
+ return oldName;
633
+ }
634
+
635
+ await ensureRuntime(pi, ctx);
636
+
637
+ while (true) {
638
+ const raw = await ctx.ui.input(`Rename mode "${oldName}"`, oldName);
639
+ if (raw === undefined) return undefined;
640
+
641
+ const newName = normalizeModeNameInput(raw);
642
+ if (newName.length === 0 || newName === oldName) return oldName;
643
+
644
+ const error = validateModeNameOrError(newName, runtime.data.modes);
645
+ if (error !== null) {
646
+ ctx.ui.notify(error, "warning");
647
+ continue;
648
+ }
649
+
650
+ runtime.data.modes = renameModesRecord(runtime.data.modes, oldName, newName);
651
+ await persistRuntime(pi, ctx);
652
+
653
+ if (runtime.currentMode === oldName) runtime.currentMode = newName;
654
+ if (runtime.lastRealMode === oldName) runtime.lastRealMode = newName;
655
+ requestEditorRender?.();
656
+
657
+ ctx.ui.notify(`Renamed "${oldName}" → "${newName}"`, "info");
658
+ return newName;
659
+ }
660
+ }
661
+
662
+ async function editModeUI(pi: ExtensionAPI, ctx: ExtensionContext, mode: string): Promise<void> {
663
+ if (!ctx.hasUI) return;
664
+
665
+ let modeName = mode;
666
+
667
+ while (true) {
668
+ await ensureRuntime(pi, ctx);
669
+ const spec = modeSpec(runtime.data.modes, modeName);
670
+ if (spec === undefined) return;
671
+
672
+ let modelLabel = "(no model)";
673
+ if (
674
+ spec.provider !== undefined &&
675
+ spec.provider.length > 0 &&
676
+ spec.modelId !== undefined &&
677
+ spec.modelId.length > 0
678
+ ) {
679
+ modelLabel = `${spec.provider}/${spec.modelId}`;
680
+ }
681
+ const thinkingLabel = spec.thinkingLevel ?? THINKING_UNSET_LABEL;
682
+
683
+ const actions = ["Change name", "Change model", "Change thinking level"];
684
+ if (!isDefaultModeName(modeName)) actions.push("Delete mode");
685
+ actions.push(MODE_UI_BACK);
686
+
687
+ const action = await ctx.ui.select(
688
+ `Edit mode "${modeName}" model: ${modelLabel} thinking: ${thinkingLabel}`,
689
+ actions,
690
+ );
691
+ if (action === undefined || action.length === 0 || action === MODE_UI_BACK) return;
692
+
693
+ if (action === "Change name") {
694
+ const renamed = await renameModeUI(pi, ctx, modeName);
695
+ if (renamed !== undefined && renamed.length > 0) modeName = renamed;
696
+ continue;
697
+ }
698
+
699
+ if (action === "Change model") {
700
+ const selected = await pickModelForModeUI(ctx, spec);
701
+ if (selected === undefined) continue;
702
+ spec.provider = selected.provider;
703
+ spec.modelId = selected.modelId;
704
+ runtime.data.modes[modeName] = spec;
705
+ await persistRuntime(pi, ctx);
706
+ ctx.ui.notify(`Updated model for "${modeName}"`, "info");
707
+
708
+ if (runtime.currentMode === modeName) {
709
+ await applyMode(pi, ctx, modeName);
710
+ }
711
+ continue;
712
+ }
713
+
714
+ if (action === "Change thinking level") {
715
+ const level = await pickThinkingLevelForModeUI(ctx, spec.thinkingLevel);
716
+ if (level === undefined) continue;
717
+
718
+ if (level === null) {
719
+ delete spec.thinkingLevel;
720
+ } else {
721
+ spec.thinkingLevel = level;
722
+ }
723
+
724
+ runtime.data.modes[modeName] = spec;
725
+ await persistRuntime(pi, ctx);
726
+ ctx.ui.notify(`Updated thinking level for "${modeName}"`, "info");
727
+
728
+ if (runtime.currentMode === modeName) {
729
+ await applyMode(pi, ctx, modeName);
730
+ }
731
+ continue;
732
+ }
733
+
734
+ if (action === "Delete mode") {
735
+ const ok = await ctx.ui.confirm("Delete mode", `Delete mode "${modeName}"?`);
736
+ if (ok !== true) continue;
737
+
738
+ delete runtime.data.modes[modeName];
739
+ await persistRuntime(pi, ctx);
740
+
741
+ if (runtime.currentMode === modeName) {
742
+ runtime.currentMode = CUSTOM_MODE_NAME;
743
+ customOverlay = getCurrentSelectionSpec(pi);
744
+ }
745
+ if (runtime.lastRealMode === modeName) {
746
+ runtime.lastRealMode = "default";
747
+ }
748
+ requestEditorRender?.();
749
+ ctx.ui.notify(`Deleted mode "${modeName}"`, "info");
750
+ return;
751
+ }
752
+ }
753
+ }
754
+
755
+ async function addModeUI(pi: ExtensionAPI, ctx: ExtensionContext): Promise<string | undefined> {
756
+ if (!ctx.hasUI) return undefined;
757
+ await ensureRuntime(pi, ctx);
758
+
759
+ while (true) {
760
+ const raw = await ctx.ui.input("New mode name", "e.g. docs, review, planning");
761
+ if (raw === undefined) return undefined;
762
+
763
+ const name = normalizeModeNameInput(raw);
764
+ const error = validateModeNameOrError(name, runtime.data.modes);
765
+ if (error !== null) {
766
+ ctx.ui.notify(error, "warning");
767
+ continue;
768
+ }
769
+
770
+ let selection = getCurrentSelectionSpec(pi);
771
+ if (customOverlay !== null) {
772
+ selection = customOverlay;
773
+ }
774
+ runtime.data.modes[name] = {
775
+ provider: selection.provider,
776
+ modelId: selection.modelId,
777
+ thinkingLevel: selection.thinkingLevel,
778
+ };
779
+ await persistRuntime(pi, ctx);
780
+ ctx.ui.notify(`Added mode "${name}"`, "info");
781
+ return name;
782
+ }
783
+ }
784
+
785
+ async function configureModesUI(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
786
+ if (!ctx.hasUI) return;
787
+
788
+ while (true) {
789
+ await ensureRuntime(pi, ctx);
790
+ const names = orderedModeNames(runtime.data.modes);
791
+ let showModeNameChoice = MODE_UI_SHOW_NAME_OFF;
792
+ if (shouldShowModeName()) {
793
+ showModeNameChoice = MODE_UI_SHOW_NAME_ON;
794
+ }
795
+ const choice = await ctx.ui.select("Configure modes", [
796
+ ...names,
797
+ MODE_UI_ADD,
798
+ showModeNameChoice,
799
+ MODE_UI_BACK,
800
+ ]);
801
+ if (choice === undefined || choice.length === 0 || choice === MODE_UI_BACK) return;
802
+
803
+ if (choice === MODE_UI_ADD) {
804
+ const created = await addModeUI(pi, ctx);
805
+ if (created !== undefined && created.length > 0) {
806
+ await editModeUI(pi, ctx, created);
807
+ }
808
+ continue;
809
+ }
810
+
811
+ if (choice === MODE_UI_SHOW_NAME_ON || choice === MODE_UI_SHOW_NAME_OFF) {
812
+ const next = !shouldShowModeName();
813
+ setShowModeName(next);
814
+ requestEditorRender?.();
815
+ let displayState = "disabled";
816
+ if (next) {
817
+ displayState = "enabled";
818
+ }
819
+ ctx.ui.notify(`Mode name display ${displayState}`, "info");
820
+ continue;
821
+ }
822
+
823
+ await editModeUI(pi, ctx, choice);
824
+ }
825
+ }
826
+
827
+ async function handleModeChoiceUI(
828
+ pi: ExtensionAPI,
829
+ ctx: ExtensionContext,
830
+ choice: string,
831
+ ): Promise<void> {
832
+ if (runtime.currentMode === CUSTOM_MODE_NAME && choice !== CUSTOM_MODE_NAME) {
833
+ const action = await ctx.ui.select(`Mode "${choice}"`, ["use", "store"]);
834
+ if (action === undefined || action.length === 0) return;
835
+
836
+ if (action === "use") {
837
+ await applyMode(pi, ctx, choice);
838
+ return;
839
+ }
840
+
841
+ let overlay = getCurrentSelectionSpec(pi);
842
+ if (customOverlay !== null) {
843
+ overlay = customOverlay;
844
+ }
845
+ await storeSelectionIntoMode(pi, ctx, choice, overlay);
846
+ await applyMode(pi, ctx, choice);
847
+ ctx.ui.notify(`Stored ${CUSTOM_MODE_NAME} into "${choice}"`, "info");
848
+ return;
849
+ }
850
+
851
+ await applyMode(pi, ctx, choice);
852
+ }
853
+
854
+ export async function selectModeUI(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
855
+ if (!ctx.hasUI) return;
856
+
857
+ while (true) {
858
+ await ensureRuntime(pi, ctx);
859
+ const names = orderedModeNames(runtime.data.modes);
860
+ const choice = await ctx.ui.select(`Mode (current: ${runtime.currentMode})`, [
861
+ ...names,
862
+ MODE_UI_CONFIGURE,
863
+ ]);
864
+ if (choice === undefined || choice.length === 0) return;
865
+
866
+ if (choice === MODE_UI_CONFIGURE) {
867
+ await configureModesUI(pi, ctx);
868
+ continue;
869
+ }
870
+
871
+ await handleModeChoiceUI(pi, ctx, choice);
872
+ return;
873
+ }
874
+ }
875
+
876
+ export async function cycleMode(
877
+ pi: ExtensionAPI,
878
+ ctx: ExtensionContext,
879
+ direction: 1 | -1 = 1,
880
+ ): Promise<void> {
881
+ if (!ctx.hasUI) return;
882
+ await ensureRuntime(pi, ctx);
883
+ const names = orderedModeNames(runtime.data.modes);
884
+ if (names.length === 0) return;
885
+
886
+ let baseMode = runtime.currentMode;
887
+ if (runtime.currentMode === CUSTOM_MODE_NAME) {
888
+ baseMode = runtime.lastRealMode;
889
+ }
890
+ const index = Math.max(0, names.indexOf(baseMode));
891
+ const next = names[(index + direction + names.length) % names.length] ?? names[0]!;
892
+ await applyMode(pi, ctx, next);
893
+ }
894
+
895
+ export async function handleModeCommand(
896
+ pi: ExtensionAPI,
897
+ ctx: ExtensionContext,
898
+ args: string,
899
+ ): Promise<void> {
900
+ const tokens = args
901
+ .split(/\s+/)
902
+ .map((value) => value.trim())
903
+ .filter(Boolean);
904
+
905
+ if (tokens.length === 0) {
906
+ await selectModeUI(pi, ctx);
907
+ return;
908
+ }
909
+
910
+ if (tokens[0] === "store") {
911
+ await ensureRuntime(pi, ctx);
912
+
913
+ let target = tokens[1];
914
+ if (target === undefined || target.length === 0) {
915
+ if (!ctx.hasUI) return;
916
+ const names = orderedModeNames(runtime.data.modes);
917
+ const selectedTarget = await ctx.ui.select("Store current selection into mode", names);
918
+ if (selectedTarget === undefined || selectedTarget.length === 0) return;
919
+ target = selectedTarget;
920
+ }
921
+
922
+ if (target === CUSTOM_MODE_NAME) {
923
+ if (ctx.hasUI) ctx.ui.notify(`Cannot store into "${CUSTOM_MODE_NAME}"`, "warning");
924
+ return;
925
+ }
926
+
927
+ let selection = getCurrentSelectionSpec(pi);
928
+ if (customOverlay !== null) {
929
+ selection = customOverlay;
930
+ }
931
+ await storeSelectionIntoMode(pi, ctx, target, selection);
932
+ if (ctx.hasUI) ctx.ui.notify(`Stored current selection into "${target}"`, "info");
933
+ return;
934
+ }
935
+
936
+ await applyMode(pi, ctx, tokens[0]!);
937
+ }
938
+
939
+ export async function handleSessionActivated(
940
+ pi: ExtensionAPI,
941
+ ctx: ExtensionContext,
942
+ ): Promise<void> {
943
+ lastObservedModel = { provider: ctx.model?.provider, modelId: ctx.model?.id };
944
+ await ensureRuntime(pi, ctx);
945
+ customOverlay = null;
946
+
947
+ const inferred = inferModeFromSelection(ctx, pi, runtime.data);
948
+ if (inferred !== null && inferred.length > 0) {
949
+ runtime.currentMode = inferred;
950
+ runtime.lastRealMode = inferred;
951
+ } else {
952
+ runtime.currentMode = CUSTOM_MODE_NAME;
953
+ customOverlay = getCurrentSelectionSpec(pi);
954
+ }
955
+ }
956
+
957
+ export async function handleModelSelect(
958
+ pi: ExtensionAPI,
959
+ ctx: ExtensionContext,
960
+ event: { model: { provider: string; id: string } },
961
+ ): Promise<void> {
962
+ lastObservedModel = { provider: event.model.provider, modelId: event.model.id };
963
+
964
+ if (runtime.applying) return;
965
+
966
+ await ensureRuntime(pi, ctx);
967
+ if (runtime.currentMode !== CUSTOM_MODE_NAME) {
968
+ runtime.lastRealMode = runtime.currentMode;
969
+ }
970
+ runtime.currentMode = CUSTOM_MODE_NAME;
971
+
972
+ customOverlay = {
973
+ provider: event.model.provider,
974
+ modelId: event.model.id,
975
+ thinkingLevel: pi.getThinkingLevel(),
976
+ };
977
+
978
+ if (ctx.hasUI) {
979
+ requestEditorRender?.();
980
+ }
981
+ }