@zigai/pi-mode 0.3.1 → 0.4.1

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