@sherif-fanous/pi-presets-plus 0.1.0

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.
Files changed (45) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/LICENSE +21 -0
  3. package/README.md +67 -0
  4. package/package.json +74 -0
  5. package/src/activation/active-state.ts +25 -0
  6. package/src/activation/apply.ts +236 -0
  7. package/src/activation/baseline.ts +32 -0
  8. package/src/activation/clear.ts +434 -0
  9. package/src/activation/dirty.ts +69 -0
  10. package/src/activation/drift-handlers.ts +71 -0
  11. package/src/activation/drift.ts +77 -0
  12. package/src/activation/same-set.ts +32 -0
  13. package/src/activation/state-matches.ts +29 -0
  14. package/src/activation/thinking.ts +54 -0
  15. package/src/commands/presets/clear.ts +18 -0
  16. package/src/commands/presets/index.ts +9 -0
  17. package/src/commands/presets/notify.ts +22 -0
  18. package/src/commands/presets/reload.ts +28 -0
  19. package/src/commands/presets/router.ts +139 -0
  20. package/src/commands/presets/status.ts +262 -0
  21. package/src/flag.ts +88 -0
  22. package/src/hotkey-conflicts.ts +136 -0
  23. package/src/hotkey-reload-baseline.ts +112 -0
  24. package/src/hotkeys.ts +104 -0
  25. package/src/index.ts +171 -0
  26. package/src/messages.ts +34 -0
  27. package/src/store/api.ts +262 -0
  28. package/src/store/load.ts +175 -0
  29. package/src/store/merge.ts +69 -0
  30. package/src/store/paths.ts +38 -0
  31. package/src/store/save.ts +75 -0
  32. package/src/store/validate.ts +195 -0
  33. package/src/types.ts +169 -0
  34. package/src/ui/confirm.ts +126 -0
  35. package/src/ui/editor.ts +1617 -0
  36. package/src/ui/filter.ts +79 -0
  37. package/src/ui/frame.ts +109 -0
  38. package/src/ui/hotkey-input.ts +242 -0
  39. package/src/ui/info-dialog.ts +118 -0
  40. package/src/ui/labels.ts +51 -0
  41. package/src/ui/picker-state.ts +151 -0
  42. package/src/ui/picker.ts +982 -0
  43. package/src/ui/reload-prompt.ts +59 -0
  44. package/src/ui/status.ts +55 -0
  45. package/src/ui/widgets.ts +274 -0
@@ -0,0 +1,1617 @@
1
+ /**
2
+ * Custom TUI editor for creating, editing, and testing one preset.
3
+ *
4
+ * Owns form state, row-level keyboard handling, validation, and persistence
5
+ * orchestration for a single preset; it does NOT own picker list state,
6
+ * storage file parsing, or activation internals beyond the injected test
7
+ * callback.
8
+ */
9
+ import { getActive, setActive } from "../activation/active-state.js";
10
+ import { validThinkingLevels } from "../activation/thinking.js";
11
+ import {
12
+ recordReloadPromptDeclined,
13
+ saveNeedsHotkeyReload,
14
+ } from "../hotkey-reload-baseline.js";
15
+ import {
16
+ addPreset,
17
+ loadAll,
18
+ removePreset,
19
+ updatePreset,
20
+ } from "../store/api.js";
21
+ import type {
22
+ LoadedPreset,
23
+ Preset,
24
+ PresetScope,
25
+ ThinkingLevel,
26
+ } from "../types.js";
27
+ import { openConfirm } from "./confirm.js";
28
+ import { centerText, frameLine, frameSegment, padToWidth } from "./frame.js";
29
+ import {
30
+ findConflictingPreset,
31
+ isPiBuiltin,
32
+ parseHotkey,
33
+ } from "./hotkey-input.js";
34
+ import { openInfoDialog } from "./info-dialog.js";
35
+ import {
36
+ CANCEL_LABEL,
37
+ MODEL_LABEL,
38
+ MOVE_LABEL,
39
+ MOVE_PRESET_TITLE,
40
+ SAVE_LABEL,
41
+ TEST_LABEL,
42
+ THINKING_LABEL,
43
+ TOOLS_LABEL,
44
+ } from "./labels.js";
45
+ import { confirmReload, reloadAfterOverlayClose } from "./reload-prompt.js";
46
+ import type { Api, Model } from "@mariozechner/pi-ai";
47
+ import type {
48
+ ExtensionAPI,
49
+ ExtensionCommandContext,
50
+ Theme,
51
+ } from "@mariozechner/pi-coding-agent";
52
+ import {
53
+ decodeKittyPrintable,
54
+ Input,
55
+ Key,
56
+ matchesKey,
57
+ truncateToWidth,
58
+ type Component,
59
+ type Focusable,
60
+ type OverlayHandle,
61
+ } from "@mariozechner/pi-tui";
62
+
63
+ export interface EditorFormState {
64
+ hotkey: string;
65
+ instructions: string;
66
+ model: string;
67
+ name: string;
68
+ provider: string;
69
+ scope: PresetScope;
70
+ selectedTools: string[];
71
+ thinkingLevel: ThinkingLevel;
72
+ toolsMode: ToolsMode;
73
+ }
74
+
75
+ export interface EditorOptions {
76
+ pi?: Pick<
77
+ ExtensionAPI,
78
+ "appendEntry" | "getActiveTools" | "getAllTools" | "getThinkingLevel"
79
+ >;
80
+ /**
81
+ * Optional pre-loaded preset list. When provided the editor uses it
82
+ * verbatim for collision/conflict checks and skips the initial `loadAll`
83
+ * round-trip; callers that already keep a fresh in-memory list (the
84
+ * picker) avoid a redundant disk read. Standalone callers omit this and
85
+ * the editor falls back to `loadAll(ctx)`.
86
+ */
87
+ presets?: readonly LoadedPreset[];
88
+ onReloadRequested?(): void;
89
+ onTest?(preset: LoadedPreset): Promise<{ ok: boolean }>;
90
+ }
91
+
92
+ export interface EditorResult {
93
+ reloadRequested?: boolean;
94
+ saved?: LoadedPreset;
95
+ /**
96
+ * The synthetic candidate preset assembled from the form when the user
97
+ * pressed the Test button and activation succeeded. Carries enough
98
+ * identity for the picker's outer notification surface to name the
99
+ * right preset; never persisted to disk.
100
+ */
101
+ tested?: LoadedPreset;
102
+ }
103
+
104
+ interface EditorRowHelpEntry {
105
+ readonly body: readonly string[];
106
+ /**
107
+ * Extra paragraphs shown only when the editor is opened for an existing
108
+ * preset (`this.initialPreset !== undefined`). Lets us mention
109
+ * edit-only consequences (rename moves the file, scope-change moves the
110
+ * file) without cluttering the new-preset experience.
111
+ */
112
+ readonly editAddendum?: readonly string[];
113
+ readonly title: string;
114
+ }
115
+
116
+ interface ModelItem {
117
+ /**
118
+ * True when the model has a resolvable API key / auth configured.
119
+ * Unavailable models are still surfaced in the editor (so users editing
120
+ * a preset whose key was rotated away can still see and re-select their
121
+ * model) but are rendered with a dim `(no key)` suffix. Preset-level
122
+ * availability enforcement happens downstream at apply time via
123
+ * `computeAvailability`; this flag is purely a UI hint.
124
+ */
125
+ readonly available: boolean;
126
+ readonly id: string;
127
+ readonly model: Model<Api>;
128
+ readonly provider: string;
129
+ }
130
+
131
+ type ButtonAction = "cancel" | "save" | "test";
132
+
133
+ type EditorRowId =
134
+ | "buttons"
135
+ | "hotkey"
136
+ | "instructions"
137
+ | "model"
138
+ | "name"
139
+ | "provider"
140
+ | "scope"
141
+ | "thinking"
142
+ | "tools";
143
+
144
+ type FieldDiagnostic = {
145
+ message: string;
146
+ severity: "error" | "warning";
147
+ };
148
+
149
+ type ToolsMode = "preset" | "session";
150
+
151
+ type ValidationResult =
152
+ | { fieldDiagnostics: ReadonlyMap<EditorRowId, FieldDiagnostic>; ok: true }
153
+ | {
154
+ fieldDiagnostics: ReadonlyMap<EditorRowId, FieldDiagnostic>;
155
+ flowError?: string;
156
+ ok: false;
157
+ };
158
+
159
+ const EDITOR_ROW_HELP: Record<EditorRowId, EditorRowHelpEntry> = {
160
+ buttons: {
161
+ body: [
162
+ "Save writes this preset to disk after checking the values you entered.",
163
+ "Cancel closes the editor and discards any changes you made.",
164
+ "Test applies this preset to the current session without saving it \u2014 useful for trying things out.",
165
+ ],
166
+ title: "Actions",
167
+ },
168
+ hotkey: {
169
+ body: [
170
+ "Hotkeys let you switch to this preset with a single key combination.",
171
+ "Use Pi's format, like ctrl+shift+1 or ctrl+m. Leave it blank if you don't want a hotkey.",
172
+ "If your choice conflicts with another preset or a Pi built-in, you'll see a warning, but you can still save.",
173
+ ],
174
+ title: "Hotkey",
175
+ },
176
+ instructions: {
177
+ body: [
178
+ "Whatever you write here gets added to Pi's system prompt when this preset is active. It doesn't replace what Pi already has \u2014 it adds to it.",
179
+ "Use it to describe your project's conventions, the tone you want, or any rules Pi should follow.",
180
+ ],
181
+ title: "Prompt",
182
+ },
183
+ model: {
184
+ body: [
185
+ "Pick which model Pi should use whenever this preset is active.",
186
+ "Models marked (no key) don't have an API key set up yet, but you can still pick them \u2014 handy if you need to repair a preset whose key was removed.",
187
+ ],
188
+ title: "Model",
189
+ },
190
+ name: {
191
+ body: [
192
+ "Give your preset a short, memorable name.",
193
+ "Names need to be unique within their scope, so two user-scope presets can't share a name.",
194
+ ],
195
+ editAddendum: [
196
+ "If you rename this preset, its file is renamed automatically too.",
197
+ ],
198
+ title: "Name",
199
+ },
200
+ provider: {
201
+ body: [
202
+ "The provider is the service that hosts the model, like OpenAI or Anthropic.",
203
+ "Only providers Pi knows about show up here. Switching providers refreshes the model list.",
204
+ ],
205
+ title: "Provider",
206
+ },
207
+ scope: {
208
+ body: [
209
+ "User presets follow you everywhere \u2014 across every project on your machine. Project presets stay tied to this project, which makes them easy to share with collaborators.",
210
+ ],
211
+ editAddendum: [
212
+ "If you switch scope on an existing preset, its file moves to the new location.",
213
+ ],
214
+ title: "Scope",
215
+ },
216
+ thinking: {
217
+ body: [
218
+ "Thinking is how much extra reasoning effort Pi asks the model to spend. Higher levels can produce better answers but take longer and cost more.",
219
+ "Off means no extra reasoning. Some models support fewer levels than others.",
220
+ ],
221
+ title: "Thinking",
222
+ },
223
+ tools: {
224
+ body: [
225
+ "Tools are the abilities Pi has during a session \u2014 things like reading files, running commands, or searching the web.",
226
+ "Session means this preset uses whatever tools are active when you apply it.",
227
+ "Preset means this preset always uses the specific tools you pick here, no matter what's currently active.",
228
+ ],
229
+ title: "Tools",
230
+ },
231
+ };
232
+
233
+ const ALL_BUTTONS: readonly ButtonAction[] = ["save", "cancel", "test"];
234
+
235
+ /**
236
+ * Source-of-truth row order for the editor's focus chain.
237
+ *
238
+ * Exported so tests can iterate every row without depending on positional
239
+ * indices that would silently shift if the order changes here.
240
+ */
241
+ export const EDITOR_ROWS = [
242
+ "name",
243
+ "scope",
244
+ "provider",
245
+ "model",
246
+ "thinking",
247
+ "tools",
248
+ "instructions",
249
+ "hotkey",
250
+ "buttons",
251
+ ] as const satisfies readonly EditorRowId[];
252
+
253
+ const THINKING_LEVELS = [
254
+ "off",
255
+ "minimal",
256
+ "low",
257
+ "medium",
258
+ "high",
259
+ "xhigh",
260
+ ] as const satisfies readonly ThinkingLevel[];
261
+ const EDITOR_LABEL_WIDTH = 15;
262
+ const EMPTY_INPUT_PLACEHOLDER = "—";
263
+
264
+ class PresetEditorComponent implements Component, Focusable {
265
+ private actionInFlight = false;
266
+ private readonly buttonOrder: readonly ButtonAction[];
267
+ private buttonAction: ButtonAction = "save";
268
+ private fieldDiagnostics: Map<EditorRowId, FieldDiagnostic> = new Map();
269
+ private flowError: string | undefined;
270
+ private focusedRowIndex = 0;
271
+ private instructionsCursor = 0;
272
+ private overlayHandle: OverlayHandle | undefined;
273
+ private readonly nameInput = new Input();
274
+ private readonly hotkeyInput = new Input();
275
+ private resolved = false;
276
+ private toolIndex = 0;
277
+ private _focused = false;
278
+
279
+ constructor(
280
+ private readonly ctx: ExtensionCommandContext,
281
+ private readonly theme: Theme,
282
+ private readonly models: readonly ModelItem[],
283
+ private readonly allPresets: readonly LoadedPreset[],
284
+ private readonly allTools: readonly string[],
285
+ private readonly initialPreset: LoadedPreset | undefined,
286
+ private readonly options: EditorOptions,
287
+ private readonly done: (result: EditorResult | undefined) => void,
288
+ private readonly requestRender: () => void,
289
+ private state: EditorFormState = initialState(
290
+ initialPreset,
291
+ models,
292
+ options.pi?.getActiveTools() ?? [],
293
+ ),
294
+ ) {
295
+ this.buttonOrder = options.onTest
296
+ ? ALL_BUTTONS
297
+ : ALL_BUTTONS.filter((button) => button !== "test");
298
+ setInputValueCursorAtEnd(this.nameInput, this.state.name);
299
+ setInputValueCursorAtEnd(this.hotkeyInput, this.state.hotkey);
300
+ this.instructionsCursor = this.state.instructions.length;
301
+ // Note: we deliberately do NOT auto-snap thinking level on open. A
302
+ // preset whose declared level will clamp at apply time stays selected
303
+ // here so save-without-edit round-trips the original value; only
304
+ // user-driven model/provider changes mutate the selected level.
305
+ this.syncFocus();
306
+ }
307
+
308
+ get focused(): boolean {
309
+ return this._focused;
310
+ }
311
+
312
+ set focused(value: boolean) {
313
+ this._focused = value;
314
+ this.syncFocus();
315
+ }
316
+
317
+ handleInput(input: string): void {
318
+ if (this.actionInFlight) return;
319
+
320
+ if (matchesKey(input, Key.escape)) {
321
+ this.finish(undefined);
322
+
323
+ return;
324
+ }
325
+
326
+ // Audited pi-tui Input.handleInput, this editor's textarea handler, and
327
+ // the shortcut chain below: none bind F1, Ctrl+S, or Ctrl+T, so intercept
328
+ // before row delegation. Re-audit if pi-tui's Input changes its key map.
329
+ if (isEditorHelpKey(input)) {
330
+ void this.runAsync(() => this.openHelpForFocusedRow());
331
+
332
+ return;
333
+ }
334
+
335
+ if (matchesKey(input, Key.ctrl("s"))) {
336
+ this.activateButton("save");
337
+
338
+ return;
339
+ }
340
+
341
+ if (this.options.onTest !== undefined && matchesKey(input, Key.ctrl("t"))) {
342
+ this.activateButton("test");
343
+
344
+ return;
345
+ }
346
+
347
+ if (matchesKey(input, Key.tab) || matchesKey(input, Key.down)) {
348
+ this.moveFocus(1);
349
+
350
+ return;
351
+ }
352
+
353
+ if (matchesKey(input, Key.shift(Key.tab)) || matchesKey(input, Key.up)) {
354
+ this.moveFocus(-1);
355
+
356
+ return;
357
+ }
358
+
359
+ const row = this.currentRow();
360
+
361
+ switch (row) {
362
+ case "buttons":
363
+ this.handleButtonsInput(input);
364
+
365
+ break;
366
+ case "hotkey":
367
+ this.hotkeyInput.handleInput(input);
368
+ this.state = { ...this.state, hotkey: this.hotkeyInput.getValue() };
369
+ this.recomputeHotkeyDiagnostic();
370
+
371
+ break;
372
+ case "instructions":
373
+ this.handleInstructionsInput(input);
374
+
375
+ break;
376
+ case "model":
377
+ this.handleModelInput(input);
378
+
379
+ break;
380
+ case "name":
381
+ this.nameInput.handleInput(input);
382
+ this.state = { ...this.state, name: this.nameInput.getValue() };
383
+ this.clearFieldDiagnosticsFor("name");
384
+
385
+ break;
386
+ case "provider":
387
+ this.handleProviderInput(input);
388
+
389
+ break;
390
+ case "scope":
391
+ this.handleScopeInput(input);
392
+
393
+ break;
394
+ case "thinking":
395
+ this.handleThinkingInput(input);
396
+
397
+ break;
398
+ case "tools":
399
+ this.handleToolsInput(input);
400
+
401
+ break;
402
+ }
403
+ }
404
+
405
+ invalidate(): void {}
406
+
407
+ setOverlayHandle(handle: OverlayHandle): void {
408
+ this.overlayHandle = handle;
409
+ }
410
+
411
+ render(width: number): string[] {
412
+ const frameWidth = Math.max(2, width);
413
+ const bodyWidth = Math.max(1, frameWidth - 2);
414
+ const title = this.initialPreset
415
+ ? `Edit preset: ${this.initialPreset.name}`
416
+ : "New preset";
417
+ const lines = [
418
+ frameSegment("┌", "─", "┐", frameWidth),
419
+ frameLine(
420
+ centerText(this.theme.fg("accent", this.theme.bold(title)), bodyWidth),
421
+ frameWidth,
422
+ ),
423
+ frameLine("", frameWidth),
424
+ ...this.renderRows(bodyWidth).map((line) => frameLine(line, frameWidth)),
425
+ frameLine("", frameWidth),
426
+ frameLine(this.theme.fg("dim", this.renderFooterHint()), frameWidth),
427
+ frameSegment("└", "─", "┘", frameWidth),
428
+ ];
429
+
430
+ return lines.map((line) => truncateToWidth(line, frameWidth, ""));
431
+ }
432
+
433
+ private async confirm(title: string, message: string): Promise<boolean> {
434
+ return this.runWithHiddenOverlay(() =>
435
+ openConfirm(this.ctx, title, message),
436
+ );
437
+ }
438
+
439
+ private async promptReloadHidden(): Promise<boolean> {
440
+ return this.runWithHiddenOverlay(() => confirmReload(this.ctx));
441
+ }
442
+
443
+ private async openHelpForFocusedRow(): Promise<void> {
444
+ const entry = EDITOR_ROW_HELP[this.currentRow()];
445
+ // Edit-mode addenda surface consequences that only apply to existing
446
+ // presets (rename migrates the file, scope-change moves the file)
447
+ // without cluttering the new-preset experience.
448
+ const isEdit = this.initialPreset !== undefined;
449
+ const paragraphs = [
450
+ ...entry.body,
451
+ ...(isEdit ? (entry.editAddendum ?? []) : []),
452
+ ];
453
+
454
+ await this.runWithHiddenOverlay(() =>
455
+ openInfoDialog(this.ctx, {
456
+ body: paragraphs.join("\n\n"),
457
+ title: entry.title,
458
+ }),
459
+ );
460
+ }
461
+
462
+ private async runWithHiddenOverlay<T>(fn: () => Promise<T>): Promise<T> {
463
+ this.overlayHandle?.setHidden(true);
464
+
465
+ try {
466
+ return await fn();
467
+ } finally {
468
+ this.restoreOverlay();
469
+ }
470
+ }
471
+
472
+ private restoreOverlay(): void {
473
+ this.overlayHandle?.setHidden(false);
474
+ this.overlayHandle?.focus();
475
+ this.requestRender();
476
+ }
477
+
478
+ private currentModel(): Model<Api> | undefined {
479
+ return this.models.find(
480
+ (item) =>
481
+ item.provider === this.state.provider && item.id === this.state.model,
482
+ )?.model;
483
+ }
484
+
485
+ private currentRow(): EditorRowId {
486
+ return EDITOR_ROWS[this.focusedRowIndex] ?? "name";
487
+ }
488
+
489
+ private activateButton(action: ButtonAction): void {
490
+ void this.runAsync(() => this.executeButton(action));
491
+ }
492
+
493
+ private async executeButton(
494
+ action: ButtonAction = this.buttonAction,
495
+ ): Promise<void> {
496
+ switch (action) {
497
+ case "cancel":
498
+ this.finish(undefined);
499
+
500
+ break;
501
+ case "save":
502
+ await this.save();
503
+
504
+ break;
505
+ case "test":
506
+ await this.testPreset();
507
+
508
+ break;
509
+ }
510
+ }
511
+
512
+ private finish(result: EditorResult | undefined): void {
513
+ if (this.resolved) return;
514
+ this.resolved = true;
515
+ this.done(result);
516
+ }
517
+
518
+ private handleButtonsInput(input: string): void {
519
+ if (matchesKey(input, Key.left)) {
520
+ this.moveButton(-1);
521
+ } else if (matchesKey(input, Key.right)) {
522
+ this.moveButton(1);
523
+ } else if (matchesKey(input, Key.enter) || input === " ") {
524
+ this.activateButton(this.buttonAction);
525
+ }
526
+ }
527
+
528
+ private handleInstructionsInput(input: string): void {
529
+ if (matchesKey(input, Key.left)) {
530
+ this.instructionsCursor = Math.max(0, this.instructionsCursor - 1);
531
+
532
+ return;
533
+ }
534
+
535
+ if (matchesKey(input, Key.right)) {
536
+ this.instructionsCursor = Math.min(
537
+ this.state.instructions.length,
538
+ this.instructionsCursor + 1,
539
+ );
540
+
541
+ return;
542
+ }
543
+
544
+ if (matchesKey(input, Key.backspace)) {
545
+ if (this.instructionsCursor === 0) return;
546
+ this.state = {
547
+ ...this.state,
548
+ instructions: `${this.state.instructions.slice(0, this.instructionsCursor - 1)}${this.state.instructions.slice(this.instructionsCursor)}`,
549
+ };
550
+ this.instructionsCursor--;
551
+
552
+ return;
553
+ }
554
+
555
+ if (matchesKey(input, Key.enter)) {
556
+ this.insertInstructionsText("\n");
557
+
558
+ return;
559
+ }
560
+
561
+ const printable = decodeKittyPrintable(input) ?? input;
562
+
563
+ if (isPrintableText(printable)) this.insertInstructionsText(printable);
564
+ }
565
+
566
+ private handleModelInput(input: string): void {
567
+ if (!matchesKey(input, Key.left) && !matchesKey(input, Key.right)) return;
568
+
569
+ const providerModels = this.modelsForProvider(this.state.provider);
570
+ const currentIndex = providerModels.findIndex(
571
+ (item) => item.id === this.state.model,
572
+ );
573
+ const direction = matchesKey(input, Key.right) ? 1 : -1;
574
+ const nextIndex = wrapIndex(currentIndex, providerModels.length, direction);
575
+ const next = providerModels[nextIndex];
576
+
577
+ if (!next) return;
578
+
579
+ this.state = { ...this.state, model: next.id };
580
+ this.clearFieldDiagnosticsFor("model");
581
+ this.snapThinkingIfInvalid();
582
+ }
583
+
584
+ private handleProviderInput(input: string): void {
585
+ if (!matchesKey(input, Key.left) && !matchesKey(input, Key.right)) return;
586
+
587
+ const providers = this.providers();
588
+ const currentIndex = providers.indexOf(this.state.provider);
589
+ const direction = matchesKey(input, Key.right) ? 1 : -1;
590
+ const nextProvider =
591
+ providers[wrapIndex(currentIndex, providers.length, direction)];
592
+
593
+ if (!nextProvider) return;
594
+
595
+ const nextModel = this.modelsForProvider(nextProvider)[0];
596
+
597
+ this.state = {
598
+ ...this.state,
599
+ model: nextModel?.id ?? "",
600
+ provider: nextProvider,
601
+ };
602
+ this.clearFieldDiagnosticsFor("provider");
603
+ this.snapThinkingIfInvalid();
604
+ }
605
+
606
+ private handleScopeInput(input: string): void {
607
+ if (
608
+ matchesKey(input, Key.left) ||
609
+ matchesKey(input, Key.right) ||
610
+ input === " "
611
+ ) {
612
+ this.state = {
613
+ ...this.state,
614
+ scope: this.state.scope === "user" ? "project" : "user",
615
+ };
616
+ this.clearFieldDiagnosticsFor("scope");
617
+ }
618
+ }
619
+
620
+ private handleThinkingInput(input: string): void {
621
+ if (!matchesKey(input, Key.left) && !matchesKey(input, Key.right)) return;
622
+
623
+ const valid = validThinkingLevels(this.currentModel());
624
+ const selectable = THINKING_LEVELS.filter((level) => valid.includes(level));
625
+ const currentIndex = selectable.indexOf(this.state.thinkingLevel);
626
+ const direction = matchesKey(input, Key.right) ? 1 : -1;
627
+ const next =
628
+ selectable[wrapIndex(currentIndex, selectable.length, direction)];
629
+
630
+ if (next) this.state = { ...this.state, thinkingLevel: next };
631
+ }
632
+
633
+ private handleToolsInput(input: string): void {
634
+ if (matchesKey(input, Key.left)) {
635
+ if (this.state.toolsMode === "preset" && this.toolIndex === 0) {
636
+ this.state = { ...this.state, toolsMode: "session" };
637
+ } else {
638
+ this.toolIndex = Math.max(0, this.toolIndex - 1);
639
+ }
640
+ } else if (matchesKey(input, Key.right)) {
641
+ if (this.state.toolsMode === "session") {
642
+ this.enterPresetToolsMode();
643
+ } else {
644
+ this.toolIndex = Math.min(
645
+ Math.max(0, this.allTools.length - 1),
646
+ this.toolIndex + 1,
647
+ );
648
+ }
649
+ } else if (input === " ") {
650
+ if (this.state.toolsMode === "session") {
651
+ this.enterPresetToolsMode();
652
+ } else {
653
+ this.state = { ...this.state, toolsMode: "session" };
654
+ }
655
+ } else if (
656
+ matchesKey(input, Key.enter) &&
657
+ this.state.toolsMode === "preset"
658
+ ) {
659
+ const tool = this.allTools[this.toolIndex];
660
+
661
+ if (!tool) return;
662
+
663
+ const selected = new Set(this.state.selectedTools);
664
+
665
+ if (selected.has(tool)) {
666
+ selected.delete(tool);
667
+ } else {
668
+ selected.add(tool);
669
+ }
670
+
671
+ this.state = { ...this.state, selectedTools: [...selected] };
672
+ }
673
+ }
674
+
675
+ private enterPresetToolsMode(): void {
676
+ const selectedTools =
677
+ this.state.selectedTools.length > 0
678
+ ? this.state.selectedTools
679
+ : (this.options.pi?.getActiveTools() ?? []);
680
+
681
+ this.state = { ...this.state, selectedTools, toolsMode: "preset" };
682
+ this.toolIndex = 0;
683
+ }
684
+
685
+ private insertInstructionsText(text: string): void {
686
+ this.state = {
687
+ ...this.state,
688
+ instructions: `${this.state.instructions.slice(0, this.instructionsCursor)}${text}${this.state.instructions.slice(this.instructionsCursor)}`,
689
+ };
690
+ this.instructionsCursor += text.length;
691
+ }
692
+
693
+ /**
694
+ * Triggered after a user-driven model/provider change. If the chosen
695
+ * level is still valid for the new model, no-op; otherwise snap to
696
+ * `"off"`. Never called from the constructor — opening must not
697
+ * silently mutate the form.
698
+ */
699
+ private snapThinkingIfInvalid(): void {
700
+ this.state = snapThinkingSelection(this.state, this.currentModel());
701
+ }
702
+
703
+ private modelsForProvider(provider: string): readonly ModelItem[] {
704
+ return this.models.filter((item) => item.provider === provider);
705
+ }
706
+
707
+ private moveButton(direction: -1 | 1): void {
708
+ const currentIndex = this.buttonOrder.indexOf(this.buttonAction);
709
+ const next =
710
+ this.buttonOrder[
711
+ wrapIndex(currentIndex, this.buttonOrder.length, direction)
712
+ ];
713
+
714
+ if (next) this.buttonAction = next;
715
+ }
716
+
717
+ private moveFocus(direction: -1 | 1): void {
718
+ this.focusedRowIndex = wrapIndex(
719
+ this.focusedRowIndex,
720
+ EDITOR_ROWS.length,
721
+ direction,
722
+ );
723
+ this.syncFocus();
724
+ }
725
+
726
+ private providers(): string[] {
727
+ return [...new Set(this.models.map((item) => item.provider))];
728
+ }
729
+
730
+ private renderFooterHint(): string {
731
+ const tokens = [
732
+ `⇥/↑/↓ ${MOVE_LABEL}`,
733
+ "←/→ Change",
734
+ "Space Toggle",
735
+ "Enter Action",
736
+ "F1 Help",
737
+ "^S Save",
738
+ ];
739
+
740
+ if (this.options.onTest !== undefined) tokens.push("^T Test");
741
+
742
+ tokens.push("Esc Cancel");
743
+
744
+ return ` ${tokens.join(" · ")}`;
745
+ }
746
+
747
+ private renderRows(width: number): string[] {
748
+ const rows = [
749
+ ...this.renderNameRow(width),
750
+ ...this.withFieldDiagnostic(
751
+ renderChoiceRow(
752
+ this.theme,
753
+ "Scope",
754
+ ["user", "project"],
755
+ this.state.scope,
756
+ this.currentRow() === "scope",
757
+ ),
758
+ "scope",
759
+ ),
760
+ ...this.withFieldDiagnostic(
761
+ renderValueRow(
762
+ this.theme,
763
+ "Provider",
764
+ this.state.provider || "none",
765
+ this.currentRow() === "provider",
766
+ ),
767
+ "provider",
768
+ ),
769
+ ...this.withFieldDiagnostic(
770
+ renderValueRow(
771
+ this.theme,
772
+ MODEL_LABEL,
773
+ this.renderModelValue(),
774
+ this.currentRow() === "model",
775
+ ),
776
+ "model",
777
+ ),
778
+ ...this.renderThinkingRows(),
779
+ ...this.renderToolsRows(),
780
+ ...this.renderInstructionsRows(width),
781
+ ...this.renderHotkeyRow(width),
782
+ ...this.renderMessages(),
783
+ renderChoiceRow(
784
+ this.theme,
785
+ "Actions",
786
+ this.buttonOrder.map(formatButton),
787
+ formatButton(this.buttonAction),
788
+ this.currentRow() === "buttons",
789
+ ),
790
+ ];
791
+
792
+ return rows.map((line) => padToWidth(line, width));
793
+ }
794
+
795
+ private renderHotkeyRow(width: number): string[] {
796
+ return this.renderTextInputRow(
797
+ "Hotkey",
798
+ "hotkey",
799
+ this.hotkeyInput,
800
+ this.state.hotkey,
801
+ width,
802
+ );
803
+ }
804
+
805
+ private renderNameRow(width: number): string[] {
806
+ return this.renderTextInputRow(
807
+ "Name",
808
+ "name",
809
+ this.nameInput,
810
+ this.state.name,
811
+ width,
812
+ );
813
+ }
814
+
815
+ private renderTextInputRow(
816
+ label: string,
817
+ row: Extract<EditorRowId, "hotkey" | "name">,
818
+ input: Input,
819
+ text: string,
820
+ width: number,
821
+ ): string[] {
822
+ if (this.currentRow() === row) {
823
+ return this.withFieldDiagnostic(
824
+ renderValueRow(
825
+ this.theme,
826
+ label,
827
+ input.render(Math.max(1, width - 16))[0] ?? "",
828
+ true,
829
+ ),
830
+ row,
831
+ );
832
+ }
833
+
834
+ const value =
835
+ text.length > 0 ? text : this.theme.fg("dim", EMPTY_INPUT_PLACEHOLDER);
836
+
837
+ return this.withFieldDiagnostic(
838
+ renderValueRow(this.theme, label, value, false),
839
+ row,
840
+ );
841
+ }
842
+
843
+ private withFieldDiagnostic(line: string, row: EditorRowId): string[] {
844
+ const diagnostic = this.renderFieldDiagnostic(row);
845
+
846
+ return diagnostic ? [line, diagnostic] : [line];
847
+ }
848
+
849
+ private renderFieldDiagnostic(row: EditorRowId): string | undefined {
850
+ const diagnostic = this.fieldDiagnostics.get(row);
851
+
852
+ if (!diagnostic) return undefined;
853
+
854
+ const color = diagnostic.severity === "warning" ? "warning" : "error";
855
+
856
+ return this.theme.fg(color, ` ${diagnostic.message}`);
857
+ }
858
+
859
+ /**
860
+ * Render the Model row's right-hand value with an availability hint
861
+ * appended for unavailable entries. Mirrors the picker card's
862
+ * unavailable status row in intent but stays inline to keep the
863
+ * dropdown compact.
864
+ */
865
+ private renderModelValue(): string {
866
+ if (this.state.model.length === 0) return "none";
867
+
868
+ const item = this.models.find(
869
+ (candidate) =>
870
+ candidate.provider === this.state.provider &&
871
+ candidate.id === this.state.model,
872
+ );
873
+
874
+ if (!item) {
875
+ // Model id didn't resolve at all (e.g. preset references a provider
876
+ // not present in `models.json`). Mark it so the user isn't left
877
+ // staring at a seemingly-fine value.
878
+ return `${this.state.model} ${this.theme.fg("dim", "(unknown)")}`;
879
+ }
880
+
881
+ return item.available
882
+ ? this.state.model
883
+ : `${this.state.model} ${this.theme.fg("dim", "(no key)")}`;
884
+ }
885
+
886
+ private renderThinkingRows(): string[] {
887
+ const lines = renderThinkingRowsForState(
888
+ this.theme,
889
+ this.state,
890
+ this.currentModel(),
891
+ this.currentRow() === "thinking",
892
+ );
893
+ const error = this.renderFieldDiagnostic("thinking");
894
+
895
+ return error ? [...lines, error] : lines;
896
+ }
897
+
898
+ private renderToolsRows(): string[] {
899
+ // Tools-capability gating is intentionally out of scope until pi-ai exposes
900
+ // a supports-tools flag; see gate-thinking-levels-by-model-map.
901
+
902
+ // Labels pair with `formatToolsSummary` on the picker card so the
903
+ // editor and card share one vocabulary:
904
+ // session — session tools pass through at apply time (no `tools`
905
+ // field is persisted).
906
+ // preset — an explicit `tools: [...]` list is persisted and wins
907
+ // at apply time.
908
+ const sessionMarker = this.state.toolsMode === "session" ? "●" : "○";
909
+ const presetMarker = this.state.toolsMode === "preset" ? "●" : "○";
910
+ const mode = `${sessionMarker} session ${presetMarker} preset`;
911
+ const lines = [
912
+ renderValueRow(
913
+ this.theme,
914
+ TOOLS_LABEL,
915
+ mode,
916
+ this.currentRow() === "tools",
917
+ ),
918
+ ];
919
+
920
+ if (this.state.toolsMode === "session") {
921
+ // Explain the less-obvious mode inline; in `preset` mode the
922
+ // multi-toggle list below speaks for itself.
923
+ lines.push(
924
+ this.theme.fg("dim", " Session: inherits the active tool set."),
925
+ );
926
+ } else {
927
+ if (this.allTools.length === 0) {
928
+ lines.push(this.theme.fg("dim", " No tools available"));
929
+
930
+ return lines;
931
+ }
932
+
933
+ const selected = new Set(this.state.selectedTools);
934
+ const renderedTools = this.allTools.map((tool, toolIndex) => {
935
+ const marker = selected.has(tool) ? "x" : " ";
936
+ const text = `[${marker}] ${tool}`;
937
+
938
+ return toolIndex === this.toolIndex && this.currentRow() === "tools"
939
+ ? this.theme.fg("accent", text)
940
+ : text;
941
+ });
942
+
943
+ lines.push(` ${renderedTools.join(" ")}`);
944
+ }
945
+
946
+ const error = this.renderFieldDiagnostic("tools");
947
+
948
+ return error ? [...lines, error] : lines;
949
+ }
950
+
951
+ private renderInstructionsRows(width: number): string[] {
952
+ const preview =
953
+ this.state.instructions.length === 0
954
+ ? this.theme.fg("dim", EMPTY_INPUT_PLACEHOLDER)
955
+ : this.state.instructions.replaceAll("\n", " ↵ ");
956
+
957
+ return this.withFieldDiagnostic(
958
+ renderValueRow(
959
+ this.theme,
960
+ "Prompt",
961
+ truncateToWidth(preview, Math.max(1, width - 16), "…"),
962
+ this.currentRow() === "instructions",
963
+ ),
964
+ "instructions",
965
+ );
966
+ }
967
+
968
+ private renderMessages(): string[] {
969
+ const lines: string[] = [];
970
+
971
+ const hotkeyNotice = formatHotkeyReloadNotice(
972
+ this.initialPreset?.hotkey ?? "",
973
+ this.state.hotkey,
974
+ );
975
+
976
+ if (hotkeyNotice.length > 0) {
977
+ lines.push(...hotkeyNotice.map((line) => this.theme.fg("dim", line)));
978
+ }
979
+
980
+ if (this.flowError) {
981
+ lines.push(this.theme.fg("error", ` ${this.flowError}`));
982
+ }
983
+
984
+ return lines;
985
+ }
986
+
987
+ private async runAsync(fn: () => Promise<void>): Promise<void> {
988
+ this.actionInFlight = true;
989
+
990
+ try {
991
+ await fn();
992
+ } finally {
993
+ this.actionInFlight = false;
994
+ this.requestRender();
995
+ }
996
+ }
997
+
998
+ private async save(): Promise<void> {
999
+ this.clearValidationErrors();
1000
+
1001
+ const validation = this.validateForSave();
1002
+
1003
+ this.applyValidationDiagnostics(validation);
1004
+
1005
+ if (!validation.ok) return;
1006
+
1007
+ const next = buildPreset(this.state);
1008
+ const result = await this.persist(next);
1009
+
1010
+ if (!result.ok) {
1011
+ this.flowError = result.reason;
1012
+
1013
+ return;
1014
+ }
1015
+
1016
+ this.updateActiveAfterMoveOrRename(next);
1017
+
1018
+ const loaded = (await loadAll(this.ctx)).presets.find(
1019
+ (preset) =>
1020
+ preset.name === next.name && preset.scope === this.state.scope,
1021
+ );
1022
+
1023
+ const saved = loaded ?? { ...next, scope: this.state.scope };
1024
+
1025
+ if (saveNeedsHotkeyReload(this.initialPreset, saved)) {
1026
+ const reloadRequested = await this.promptReloadHidden();
1027
+
1028
+ this.finish({ reloadRequested, saved });
1029
+
1030
+ if (reloadRequested) {
1031
+ if (this.options.onReloadRequested) {
1032
+ this.options.onReloadRequested();
1033
+ } else {
1034
+ reloadAfterOverlayClose(this.ctx);
1035
+ }
1036
+ } else {
1037
+ recordReloadPromptDeclined(saved);
1038
+ }
1039
+
1040
+ return;
1041
+ }
1042
+
1043
+ this.finish({ saved });
1044
+ }
1045
+
1046
+ private async persist(
1047
+ next: Preset,
1048
+ ): Promise<{ ok: true } | { ok: false; reason: string }> {
1049
+ if (!this.initialPreset) return addPreset(next, this.state.scope, this.ctx);
1050
+
1051
+ if (this.initialPreset.scope === this.state.scope) {
1052
+ return updatePreset(
1053
+ this.initialPreset.name,
1054
+ this.state.scope,
1055
+ next,
1056
+ this.ctx,
1057
+ );
1058
+ }
1059
+
1060
+ const confirmed = await this.confirm(
1061
+ MOVE_PRESET_TITLE,
1062
+ `Move "${this.initialPreset.name}" from ${this.initialPreset.scope} to ${this.state.scope}? The old copy will be removed.`,
1063
+ );
1064
+
1065
+ if (!confirmed) return { ok: false, reason: "Move cancelled." };
1066
+
1067
+ const added = await addPreset(next, this.state.scope, this.ctx);
1068
+
1069
+ if (!added.ok) return added;
1070
+
1071
+ await removePreset(
1072
+ this.initialPreset.name,
1073
+ this.initialPreset.scope,
1074
+ this.ctx,
1075
+ );
1076
+
1077
+ return { ok: true };
1078
+ }
1079
+
1080
+ private async testPreset(): Promise<void> {
1081
+ this.clearValidationErrors();
1082
+
1083
+ if (this.options.onTest === undefined) {
1084
+ throw new Error("testPreset reached without a wired callback.");
1085
+ }
1086
+
1087
+ const validation = this.validateRequired();
1088
+
1089
+ this.applyValidationDiagnostics(validation);
1090
+
1091
+ if (!validation.ok) return;
1092
+
1093
+ const preset = buildPreset(this.state);
1094
+ const candidate: LoadedPreset = { ...preset, scope: this.state.scope };
1095
+ const result = await this.options.onTest(candidate);
1096
+
1097
+ if (result.ok) this.finish({ tested: candidate });
1098
+ }
1099
+
1100
+ /**
1101
+ * Keep the in-memory active-preset reference correct after a Save that
1102
+ * either renamed the active preset, moved it across scopes, or both.
1103
+ * Re-appending `presets-plus:active` is what makes the picker / status
1104
+ * surface refresh against the new identity on the next render.
1105
+ */
1106
+ private updateActiveAfterMoveOrRename(next: Preset): void {
1107
+ if (!this.initialPreset || !this.options.pi) return;
1108
+
1109
+ const active = getActive();
1110
+
1111
+ if (
1112
+ active?.name !== this.initialPreset.name ||
1113
+ active.scope !== this.initialPreset.scope
1114
+ ) {
1115
+ return;
1116
+ }
1117
+
1118
+ setActive({ ...active, name: next.name, scope: this.state.scope });
1119
+ this.options.pi.appendEntry("presets-plus:active", {
1120
+ name: next.name,
1121
+ scope: this.state.scope,
1122
+ });
1123
+ }
1124
+
1125
+ private recomputeHotkeyDiagnostic(): void {
1126
+ this.fieldDiagnostics.delete("hotkey");
1127
+ this.addHotkeyDiagnostic(this.fieldDiagnostics);
1128
+ }
1129
+
1130
+ private addHotkeyDiagnostic(
1131
+ fieldDiagnostics: Map<EditorRowId, FieldDiagnostic>,
1132
+ ): void {
1133
+ const hotkey = this.state.hotkey.trim();
1134
+
1135
+ if (hotkey.length === 0) return;
1136
+
1137
+ const parsed = parseHotkey(hotkey);
1138
+
1139
+ if (!parsed.ok) {
1140
+ fieldDiagnostics.set("hotkey", {
1141
+ message: parsed.reason,
1142
+ severity: "error",
1143
+ });
1144
+
1145
+ return;
1146
+ }
1147
+
1148
+ if (isPiBuiltin(parsed.parsed)) {
1149
+ fieldDiagnostics.set("hotkey", {
1150
+ message: hotkeyShadowsBuiltinWarning(parsed.parsed.normalized),
1151
+ severity: "warning",
1152
+ });
1153
+
1154
+ return;
1155
+ }
1156
+
1157
+ const conflict = findConflictingPreset(
1158
+ parsed.parsed,
1159
+ this.allPresets,
1160
+ this.initialPreset?.name,
1161
+ );
1162
+
1163
+ // v1 intentionally keeps first-match behavior for combined warning
1164
+ // conditions; see this change's design Risks / Trade-offs section.
1165
+ if (conflict) {
1166
+ fieldDiagnostics.set("hotkey", {
1167
+ message: hotkeyConflictWarning(parsed.parsed.normalized, conflict.name),
1168
+ severity: "warning",
1169
+ });
1170
+ }
1171
+ }
1172
+
1173
+ private validateForSave(): ValidationResult {
1174
+ const required = this.validateRequired();
1175
+ const fieldDiagnostics = new Map(required.fieldDiagnostics);
1176
+
1177
+ if (this.hasNameCollision()) {
1178
+ fieldDiagnostics.set("name", {
1179
+ message: `A preset named "${this.state.name.trim()}" already exists in ${this.state.scope}.`,
1180
+ severity: "error",
1181
+ });
1182
+ }
1183
+
1184
+ this.addHotkeyDiagnostic(fieldDiagnostics);
1185
+
1186
+ const hasError = [...fieldDiagnostics.values()].some(
1187
+ (diagnostic) => diagnostic.severity === "error",
1188
+ );
1189
+
1190
+ return { fieldDiagnostics, ok: !hasError };
1191
+ }
1192
+
1193
+ private hasNameCollision(): boolean {
1194
+ return this.allPresets.some((preset) => {
1195
+ if (preset.scope !== this.state.scope) return false;
1196
+ if (preset.name !== this.state.name.trim()) return false;
1197
+
1198
+ return !(
1199
+ this.initialPreset &&
1200
+ preset.name === this.initialPreset.name &&
1201
+ preset.scope === this.initialPreset.scope
1202
+ );
1203
+ });
1204
+ }
1205
+
1206
+ private validateRequired(): ValidationResult {
1207
+ const fieldDiagnostics = new Map<EditorRowId, FieldDiagnostic>();
1208
+
1209
+ if (this.state.name.trim().length === 0) {
1210
+ fieldDiagnostics.set("name", {
1211
+ message: "Name is required.",
1212
+ severity: "error",
1213
+ });
1214
+ }
1215
+
1216
+ if (this.state.provider.length === 0) {
1217
+ fieldDiagnostics.set("provider", {
1218
+ message: "Provider is required.",
1219
+ severity: "error",
1220
+ });
1221
+ }
1222
+
1223
+ if (this.state.model.length === 0) {
1224
+ fieldDiagnostics.set("model", {
1225
+ message: "Model is required.",
1226
+ severity: "error",
1227
+ });
1228
+ }
1229
+
1230
+ const hasError = fieldDiagnostics.size > 0;
1231
+
1232
+ return { fieldDiagnostics, ok: !hasError };
1233
+ }
1234
+
1235
+ /**
1236
+ * Apply row-level diagnostics from validation without clearing unrelated
1237
+ * flow-state errors. Validation currently does not produce flow errors, but
1238
+ * the union retains the field for future non-row failure paths.
1239
+ */
1240
+ private applyValidationDiagnostics(result: ValidationResult): void {
1241
+ this.fieldDiagnostics = new Map(result.fieldDiagnostics);
1242
+
1243
+ if (!result.ok && result.flowError !== undefined) {
1244
+ this.flowError = result.flowError;
1245
+ }
1246
+ }
1247
+
1248
+ private clearValidationErrors(): void {
1249
+ this.fieldDiagnostics.clear();
1250
+ this.flowError = undefined;
1251
+ }
1252
+
1253
+ private clearFieldDiagnosticsFor(row: EditorRowId): void {
1254
+ this.fieldDiagnostics.delete(row);
1255
+
1256
+ if (row === "scope") this.fieldDiagnostics.delete("name");
1257
+ if (row === "provider") this.fieldDiagnostics.delete("model");
1258
+ }
1259
+
1260
+ private syncFocus(): void {
1261
+ this.nameInput.focused = this._focused && this.currentRow() === "name";
1262
+ this.hotkeyInput.focused = this._focused && this.currentRow() === "hotkey";
1263
+ }
1264
+ }
1265
+
1266
+ /**
1267
+ * Pure helper: assemble a `Preset` from the form state, omitting
1268
+ * fields that should not appear in the on-disk shape (e.g. empty
1269
+ * instructions, empty hotkey, `session`-mode tools, `off` thinking).
1270
+ *
1271
+ * Exposed for tests; the editor instance calls this internally.
1272
+ */
1273
+ export function buildPreset(state: EditorFormState): Preset {
1274
+ const preset: Preset = {
1275
+ model: state.model,
1276
+ name: state.name.trim(),
1277
+ provider: state.provider,
1278
+ };
1279
+
1280
+ if (state.thinkingLevel !== "off") {
1281
+ preset.thinkingLevel = state.thinkingLevel;
1282
+ }
1283
+
1284
+ if (state.toolsMode === "preset") {
1285
+ preset.tools = [...state.selectedTools];
1286
+ }
1287
+
1288
+ const instructions = state.instructions.trim();
1289
+
1290
+ if (instructions.length > 0) preset.instructions = instructions;
1291
+
1292
+ const hotkey = state.hotkey.trim();
1293
+
1294
+ if (hotkey.length > 0) preset.hotkey = hotkey;
1295
+
1296
+ return preset;
1297
+ }
1298
+
1299
+ export function formatHotkeyReloadNotice(
1300
+ previousValue: string,
1301
+ nextValue: string,
1302
+ ): string[] {
1303
+ const previous = previousValue.trim();
1304
+ const next = nextValue.trim();
1305
+
1306
+ if (previous === next) return [];
1307
+
1308
+ if (previous.length === 0) {
1309
+ return [
1310
+ ` Hotkey added: ${next}.`,
1311
+ " Takes effect after /reload; no binding is active until then.",
1312
+ ];
1313
+ }
1314
+
1315
+ if (next.length === 0) {
1316
+ return [
1317
+ ` Hotkey removed (was: ${previous}).`,
1318
+ " Takes effect after /reload. The previous binding remains active until then.",
1319
+ ];
1320
+ }
1321
+
1322
+ return [
1323
+ ` Hotkey changed: ${previous} → ${next}.`,
1324
+ " Takes effect after /reload. The previous binding remains active until then.",
1325
+ ];
1326
+ }
1327
+
1328
+ /**
1329
+ * Pure helper: derive the editor's initial form state from an existing
1330
+ * preset (edit mode) or sensible defaults (new mode). For new presets
1331
+ * the thinking level defaults to `"off"` per the spec's "Open editor for
1332
+ * a new preset" scenario; the editor never reads the live session's
1333
+ * thinking level into a new preset's form because that would silently
1334
+ * couple a brand-new preset to whatever the user happened to be doing.
1335
+ *
1336
+ * `activeTools` seeds the tools row's pre-selection when the preset has
1337
+ * no `tools` field yet, per the spec's
1338
+ * "pre-checked from ... `pi.getActiveTools()` if the preset has no tools
1339
+ * yet" clause. This is purely a UI pre-check: while the user stays in
1340
+ * `session` mode the tools field is still omitted from the persisted
1341
+ * preset; the pre-fill only materializes if they toggle to `preset` mode
1342
+ * and save.
1343
+ */
1344
+ export function initialState(
1345
+ preset: LoadedPreset | undefined,
1346
+ models: readonly ModelItem[],
1347
+ activeTools: readonly string[] = [],
1348
+ ): EditorFormState {
1349
+ const firstModel = models[0];
1350
+
1351
+ return {
1352
+ hotkey: preset?.hotkey ?? "",
1353
+ instructions: preset?.instructions ?? "",
1354
+ model: preset?.model ?? firstModel?.id ?? "",
1355
+ name: preset?.name ?? "",
1356
+ provider: preset?.provider ?? firstModel?.provider ?? "",
1357
+ scope: preset?.scope ?? "user",
1358
+ selectedTools: preset?.tools ? [...preset.tools] : [...activeTools],
1359
+ thinkingLevel: preset?.thinkingLevel ?? "off",
1360
+ toolsMode: preset?.tools ? "preset" : "session",
1361
+ };
1362
+ }
1363
+
1364
+ export async function openEditor(
1365
+ ctx: ExtensionCommandContext,
1366
+ preset?: LoadedPreset,
1367
+ options: EditorOptions = {},
1368
+ ): Promise<EditorResult | undefined> {
1369
+ const presets = options.presets ?? (await loadAll(ctx)).presets;
1370
+ // Source all models (not just keyed ones) so a preset whose provider
1371
+ // lost its API key still appears in the dropdown; the Model row renders
1372
+ // unavailable entries dimmed with a `(no key)` suffix. The picker card
1373
+ // already surfaces per-preset `unavailable: "no-key"` at load time; the
1374
+ // editor matches that vocabulary rather than hiding models outright.
1375
+ const models = ctx.modelRegistry.getAll();
1376
+ const modelItems = models.map((model) => ({
1377
+ available: ctx.modelRegistry.hasConfiguredAuth(model),
1378
+ id: model.id,
1379
+ model,
1380
+ provider: model.provider,
1381
+ }));
1382
+ const allTools = options.pi?.getAllTools().map((tool) => tool.name) ?? [];
1383
+ let currentEditor: PresetEditorComponent | undefined;
1384
+
1385
+ return ctx.ui.custom<EditorResult | undefined>(
1386
+ (tui, theme, _keybindings, done) => {
1387
+ const editor = new PresetEditorComponent(
1388
+ ctx,
1389
+ theme,
1390
+ modelItems,
1391
+ presets,
1392
+ allTools,
1393
+ preset,
1394
+ options,
1395
+ done,
1396
+ () => tui.requestRender(),
1397
+ );
1398
+
1399
+ currentEditor = editor;
1400
+
1401
+ return editor;
1402
+ },
1403
+ {
1404
+ onHandle: (handle) => currentEditor?.setOverlayHandle(handle),
1405
+ overlay: true,
1406
+ overlayOptions: {
1407
+ anchor: "center",
1408
+ margin: 1,
1409
+ maxHeight: "90%",
1410
+ minWidth: 72,
1411
+ width: "90%",
1412
+ },
1413
+ },
1414
+ );
1415
+ }
1416
+
1417
+ /**
1418
+ * Exported solely to enable rendering-side regression coverage of the
1419
+ * no-notice contract without instantiating the interactive editor.
1420
+ */
1421
+ export function renderThinkingRowsForState(
1422
+ theme: Pick<Theme, "fg">,
1423
+ state: EditorFormState,
1424
+ model: Model<Api> | undefined,
1425
+ focused: boolean,
1426
+ ): string[] {
1427
+ const valid = validThinkingLevels(model);
1428
+ // Disabled options are conveyed by dim color alone (no " disabled"
1429
+ // suffix). The disabled-state legend below the row explains the
1430
+ // convention so screen-reader users still get a hint.
1431
+ const options = THINKING_LEVELS.map((level) => {
1432
+ const label = formatThinking(level);
1433
+ const rendered = valid.includes(level) ? label : theme.fg("dim", label);
1434
+
1435
+ return state.thinkingLevel === level ? `● ${rendered}` : `○ ${rendered}`;
1436
+ });
1437
+ const lines = [
1438
+ renderValueRow(theme, THINKING_LABEL, options.join(" "), focused),
1439
+ ];
1440
+
1441
+ if (valid.length < THINKING_LEVELS.length) {
1442
+ // Undefined models return the full set of valid levels, so this dimmed
1443
+ // branch can only fire when the model is defined; the reasoning flag is
1444
+ // therefore the complete branch condition.
1445
+ const message =
1446
+ model?.reasoning === false
1447
+ ? "This model does not support thinking."
1448
+ : "Dimmed levels are unavailable for this model.";
1449
+
1450
+ lines.push(theme.fg("dim", ` ${message}`));
1451
+ }
1452
+
1453
+ return lines;
1454
+ }
1455
+
1456
+ /**
1457
+ * Pure helper: consume the returned form state directly after a user-driven
1458
+ * model/provider change. If the selected level is still valid for the new
1459
+ * model, return the same state object as the explicit no-op signal;
1460
+ * otherwise snap the selection to `"off"`.
1461
+ */
1462
+ export function snapThinkingSelection(
1463
+ state: EditorFormState,
1464
+ model: Model<Api> | undefined,
1465
+ ): EditorFormState {
1466
+ if (validThinkingLevels(model).includes(state.thinkingLevel)) {
1467
+ return state;
1468
+ }
1469
+
1470
+ return { ...state, thinkingLevel: "off" };
1471
+ }
1472
+
1473
+ function formatButton(action: ButtonAction): string {
1474
+ switch (action) {
1475
+ case "cancel":
1476
+ return CANCEL_LABEL;
1477
+ case "save":
1478
+ return SAVE_LABEL;
1479
+ case "test":
1480
+ return TEST_LABEL;
1481
+ }
1482
+ }
1483
+
1484
+ function formatThinking(level: ThinkingLevel): string {
1485
+ return level;
1486
+ }
1487
+
1488
+ /**
1489
+ * Canonical Hotkey-conflict warning used by proactive recompute and the
1490
+ * Save-time validation backstop; keep wording aligned with the spec scenario.
1491
+ */
1492
+ function hotkeyConflictWarning(normalized: string, presetName: string): string {
1493
+ return `⚠ ${normalized} is already used by preset "${presetName}"; this preset's binding will be skipped.`;
1494
+ }
1495
+
1496
+ /**
1497
+ * Canonical Pi built-in shadow warning used by proactive recompute and the
1498
+ * Save-time validation backstop; keep wording aligned with the spec scenario.
1499
+ */
1500
+ function hotkeyShadowsBuiltinWarning(normalized: string): string {
1501
+ return `⚠ ${normalized} shadows a Pi built-in; saving will replace Pi's behavior for this key.`;
1502
+ }
1503
+
1504
+ function isEditorHelpKey(input: string): boolean {
1505
+ if (matchesKey(input, Key.f1)) return true;
1506
+
1507
+ // pi-tui's matchesKey for F-keys checks only the legacy table
1508
+ // (\x1bOP, \x1b[11~, \x1b[[A) and never falls through to the Kitty
1509
+ // matcher, so when pi-tui auto-enables Kitty's enhanced keyboard
1510
+ // protocol (terminal.js sends \x1b[>7u after the handshake) F1 in
1511
+ // its Kitty-protocol forms is silently dropped. Two such forms exist:
1512
+ //
1513
+ // 1. Legacy-with-event-info (observed in Ghostty: F1 press arrived as
1514
+ // `\x1b[1;1:1P`, release as `\x1b[1;1:3P`):
1515
+ // CSI 1 ; <mod> : <event> P
1516
+ // Final byte is the legacy SS3 letter (P for F1). The `:event`
1517
+ // subfield is added when the event-types flag is pushed.
1518
+ //
1519
+ // 2. Codepoint form (per the Kitty keyboard-protocol spec):
1520
+ // CSI 57364 ; <mod> : <event> u
1521
+ // Final byte is `u`; codepoint 57364 = F1, 57365 = F2, etc.
1522
+ //
1523
+ // Empirical evidence: a temporary `appendFileSync` instrumentation in
1524
+ // `handleInput` recorded what each terminal actually sends inside pi.
1525
+ // iTerm2 sent `\x1b[11~` (already covered by the legacy `Key.f1`
1526
+ // table); Ghostty sent the legacy-with-event-info `\x1b[1;1:1P`
1527
+ // variant; kitty/WezTerm/recent Alacritty are expected to use either
1528
+ // form. The six fallbacks below cover both encodings with and without
1529
+ // the modifier and event subfields.
1530
+ //
1531
+ // We match only press events (event subfield 1, or omitted). pi-tui's
1532
+ // isKeyRelease does not recognize `:3P` either, so F1 release leaks
1533
+ // through to the focused component; matching only press here means
1534
+ // the release falls through to row delegation and is harmlessly
1535
+ // ignored.
1536
+ //
1537
+ // Re-audit when pi-tui's matchesKey and isKeyRelease grow F-key
1538
+ // Kitty support; this workaround can then go away.
1539
+ return (
1540
+ input === "\x1b[1P" ||
1541
+ input === "\x1b[1;1P" ||
1542
+ input === "\x1b[1;1:1P" ||
1543
+ input === "\x1b[57364u" ||
1544
+ input === "\x1b[57364;1u" ||
1545
+ input === "\x1b[57364;1:1u"
1546
+ );
1547
+ }
1548
+
1549
+ function isPrintableText(text: string): boolean {
1550
+ if (text.length === 0) return false;
1551
+
1552
+ return [...text].every((char) => {
1553
+ const code = char.charCodeAt(0);
1554
+
1555
+ return code >= 32 && code !== 0x7f && !(code >= 0x80 && code <= 0x9f);
1556
+ });
1557
+ }
1558
+
1559
+ function renderChoiceRow(
1560
+ theme: Theme,
1561
+ label: string,
1562
+ options: readonly string[],
1563
+ selected: string,
1564
+ focused: boolean,
1565
+ ): string {
1566
+ const rendered = options
1567
+ .map((option) => (option === selected ? `● ${option}` : `○ ${option}`))
1568
+ .join(" ");
1569
+
1570
+ return renderValueRow(theme, label, rendered, focused);
1571
+ }
1572
+
1573
+ function renderValueRow(
1574
+ theme: Pick<Theme, "fg">,
1575
+ label: string,
1576
+ value: string,
1577
+ focused: boolean,
1578
+ ): string {
1579
+ const marker = focused ? theme.fg("accent", "▌") : " ";
1580
+ const paddedLabel = `${label}${" ".repeat(Math.max(0, EDITOR_LABEL_WIDTH - label.length))}`;
1581
+ const labelText = theme.fg("muted", paddedLabel);
1582
+ const renderedValue = focused ? theme.fg("accent", value) : value;
1583
+
1584
+ return `${marker} ${labelText}${renderedValue}`;
1585
+ }
1586
+
1587
+ /**
1588
+ * Seed a single-line `Input` with a pre-populated value while placing the
1589
+ * caret at the end of that text. Input.setValue() alone leaves the caret
1590
+ * at position 0 (it only clamps the existing caret), so opening the
1591
+ * editor for an existing preset would otherwise show the cursor stuck at
1592
+ * the start of the name / hotkey — an odd UX.
1593
+ *
1594
+ * Feeding the Input a legacy `End` sequence after setValue triggers
1595
+ * Input's own `tui.editor.cursorLineEnd` handler, which moves the caret
1596
+ * after the last grapheme without us needing to reach into private
1597
+ * state. `\x1b[F` is one of the sequences Input recognizes as End and
1598
+ * is unaffected by user keybinding overrides (the match path fires
1599
+ * before user-bindings resolution).
1600
+ */
1601
+ function setInputValueCursorAtEnd(input: Input, value: string): void {
1602
+ input.setValue(value);
1603
+
1604
+ if (value.length === 0) return;
1605
+
1606
+ input.handleInput("\x1b[F");
1607
+ }
1608
+
1609
+ function wrapIndex(
1610
+ currentIndex: number,
1611
+ length: number,
1612
+ direction: -1 | 1,
1613
+ ): number {
1614
+ if (length <= 0) return 0;
1615
+
1616
+ return (((currentIndex + direction) % length) + length) % length;
1617
+ }