@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,982 @@
1
+ /**
2
+ * Interactive picker for browsing and activating presets.
3
+ *
4
+ * Owns the `ctx.ui.custom` state machine that drives the picker dialog;
5
+ * it does NOT own persistence, scope/rank filtering, card formatting, or
6
+ * the activation side effects (the `onActivate` callback is injected by
7
+ * the caller).
8
+ */
9
+ import { getActive } from "../activation/active-state.js";
10
+ import type { ApplyResult } from "../activation/apply.js";
11
+ import { clearReturning, renderClearSummary } from "../activation/clear.js";
12
+ import { detectDriftReasons } from "../activation/drift.js";
13
+ import { surfaceWarnings } from "../commands/presets/notify.js";
14
+ import { formatStatusBody } from "../commands/presets/status.js";
15
+ import {
16
+ deleteNeedsHotkeyReload,
17
+ recordReloadPromptDeclined,
18
+ } from "../hotkey-reload-baseline.js";
19
+ import {
20
+ addPreset,
21
+ loadAll,
22
+ removePreset,
23
+ reorderWithinScope,
24
+ } from "../store/api.js";
25
+ import type { LoadedPreset, Preset } from "../types.js";
26
+ import { openConfirm } from "./confirm.js";
27
+ import { openEditor } from "./editor.js";
28
+ import type { ScopeFilter } from "./filter.js";
29
+ import { centerText, frameLine, frameSegment, padToWidth } from "./frame.js";
30
+ import { openInfoDialog } from "./info-dialog.js";
31
+ import {
32
+ ACTIVATE_LABEL,
33
+ ACTIVATION_FAILED_TITLE,
34
+ CLEAR_LABEL,
35
+ CLOSE_LABEL,
36
+ CURSOR_LABEL,
37
+ DELETE_LABEL,
38
+ DUPLICATE_LABEL,
39
+ EDIT_LABEL,
40
+ FILTER_LABEL,
41
+ LIST_LABEL,
42
+ MOVE_LABEL,
43
+ NEW_LABEL,
44
+ REORDER_LABEL,
45
+ STATUS_ACTION_LABEL,
46
+ STATUS_DIALOG_TITLE,
47
+ } from "./labels.js";
48
+ import {
49
+ cycleScope as cyclePickerScope,
50
+ initialPickerState,
51
+ moveSelection as movePickerSelection,
52
+ preserveSelectionOrFirst as preservePickerSelectionOrFirst,
53
+ selectedPreset as selectedPickerPreset,
54
+ selectedPresetKey as selectedPickerPresetKey,
55
+ setFocusMode as setPickerFocusMode,
56
+ visiblePresets as visiblePickerPresets,
57
+ type PickerFocusMode,
58
+ type PickerState,
59
+ } from "./picker-state.js";
60
+ import { confirmReload, reloadAfterOverlayClose } from "./reload-prompt.js";
61
+ import { presetCard } from "./widgets.js";
62
+ import type {
63
+ ExtensionAPI,
64
+ ExtensionCommandContext,
65
+ ExtensionUIContext,
66
+ Theme,
67
+ } from "@mariozechner/pi-coding-agent";
68
+ import {
69
+ decodeKittyPrintable,
70
+ Input,
71
+ Key,
72
+ matchesKey,
73
+ truncateToWidth,
74
+ visibleWidth,
75
+ type Component,
76
+ type Focusable,
77
+ type OverlayHandle,
78
+ type Terminal,
79
+ } from "@mariozechner/pi-tui";
80
+
81
+ export interface PickerOptions {
82
+ inheritedTools?: readonly string[];
83
+ /**
84
+ * Optional fixed page size override. When omitted (the default) the picker
85
+ * derives page size dynamically from the terminal height. Retained for
86
+ * future tests / specialty callers; production callers should leave it
87
+ * unset so the layout responds to terminal resizes.
88
+ */
89
+ pageSize?: number;
90
+ /**
91
+ * Activation callback. Returns `{ ok: true }` to close the picker, or
92
+ * `{ ok: false, reason }` to keep it open and surface the refusal in an
93
+ * overlay-appropriate dialog.
94
+ */
95
+ onActivate(preset: LoadedPreset): Promise<ApplyResult>;
96
+ pi?: ExtensionAPI;
97
+ }
98
+
99
+ export interface PickerResult {
100
+ activated?: LoadedPreset;
101
+ }
102
+
103
+ /**
104
+ * Average rendered card height used only before the first render, when no
105
+ * actual packed page size has been measured yet. Once rendered, the picker
106
+ * uses greedy actual-height card packing and remembers the most recent
107
+ * rendered card count for page navigation / selection visibility.
108
+ */
109
+ const FALLBACK_AVG_CARD_LINES = 7;
110
+ /**
111
+ * Lines consumed by chrome (top border + filter row + rule + rule + footer +
112
+ * bottom border). Subtracted from the overlay's available height to get the
113
+ * card-rendering budget.
114
+ */
115
+ const CHROME_LINES = 6;
116
+ /** Fallback page size when the terminal height is unknown or absurdly small. */
117
+ const MIN_PAGE_SIZE = 1;
118
+
119
+ class PresetPickerComponent implements Component, Focusable {
120
+ private _focused = false;
121
+ private state: PickerState = initialPickerState();
122
+ private readonly filterInput = new Input();
123
+ private cachedVisible?: { key: string; presets: readonly LoadedPreset[] };
124
+ private overlayHandle: OverlayHandle | undefined;
125
+ private renderedPageSize: number | undefined;
126
+ private resolved = false;
127
+ private applying = false;
128
+ /**
129
+ * Memoized drift reasons for the currently-active preset.
130
+ *
131
+ * Recomputed when the loaded presets change (`refreshPresets`); within a
132
+ * single render pass the reasons are stable, so we don't re-run
133
+ * `detectDriftReasons` on every keystroke or scroll. The picker is opened
134
+ * within a single agent turn, so the cached snapshot on the active state
135
+ * cannot move under us between renders.
136
+ */
137
+ private driftReasonsCache:
138
+ | { reasons: readonly string[]; signature: string }
139
+ | undefined;
140
+
141
+ constructor(
142
+ private allPresets: LoadedPreset[],
143
+ private readonly ctx: ExtensionCommandContext,
144
+ private readonly pi: ExtensionAPI | undefined,
145
+ private readonly ui: Pick<ExtensionUIContext, "notify">,
146
+ private readonly theme: Theme,
147
+ private readonly terminal: Pick<Terminal, "rows">,
148
+ private readonly fixedPageSize: number | undefined,
149
+ private inheritedTools: readonly string[],
150
+ private readonly onActivate: (preset: LoadedPreset) => Promise<ApplyResult>,
151
+ private readonly done: (result: PickerResult | undefined) => void,
152
+ private readonly requestRender: () => void,
153
+ ) {}
154
+
155
+ get focused(): boolean {
156
+ return this._focused;
157
+ }
158
+
159
+ set focused(value: boolean) {
160
+ this._focused = value;
161
+ this.syncFilterFocus();
162
+ }
163
+
164
+ handleInput(input: string): void {
165
+ // Ignore further input while an activation is in flight so a held Enter
166
+ // doesn't queue duplicate apply calls.
167
+ if (this.applying) return;
168
+
169
+ // Defensive Kitty CSI-u normalization: pi-tui currently doesn't request
170
+ // CSI-u for plain printable keys (flag 1 alone leaves them as raw chars),
171
+ // but future flag bumps or unusual layouts may wrap them. Normalize so
172
+ // `===` checks below stay correct in either world.
173
+ const printable = decodeKittyPrintable(input);
174
+ const normalized = printable ?? input;
175
+
176
+ if (this.state.focusMode === "filter") {
177
+ this.handleFilterInput(input);
178
+
179
+ return;
180
+ }
181
+
182
+ if (matchesKey(input, Key.up)) {
183
+ this.moveSelection(-1);
184
+ } else if (matchesKey(input, Key.down)) {
185
+ this.moveSelection(1);
186
+ } else if (matchesKey(input, Key.pageUp)) {
187
+ this.moveSelection(-this.pageSize, { wrap: false });
188
+ } else if (matchesKey(input, Key.pageDown)) {
189
+ this.moveSelection(this.pageSize, { wrap: false });
190
+ } else if (matchesKey(input, Key.left)) {
191
+ this.cycleScope(-1);
192
+ } else if (matchesKey(input, Key.right)) {
193
+ this.cycleScope(1);
194
+ } else if (matchesKey(input, Key.enter)) {
195
+ void this.activateSelection();
196
+ } else if (matchesKey(input, Key.escape)) {
197
+ this.finish(undefined);
198
+ } else if (matchesKey(input, Key.ctrl(Key.up))) {
199
+ void this.reorderSelection(-1);
200
+ } else if (matchesKey(input, Key.ctrl(Key.down))) {
201
+ void this.reorderSelection(1);
202
+ } else if (normalized === "/") {
203
+ this.setFocusMode("filter");
204
+ } else if (normalized === "n") {
205
+ void this.openNewFromPicker();
206
+ } else if (normalized === "e") {
207
+ void this.openEditorForSelection();
208
+ } else if (normalized === "d") {
209
+ void this.duplicateSelection();
210
+ } else if (normalized === "x") {
211
+ void this.deleteSelection();
212
+ } else if (normalized === "c") {
213
+ void this.clearActivePreset();
214
+ } else if (normalized === "s") {
215
+ void this.showActiveStatus();
216
+ }
217
+ }
218
+
219
+ invalidate(): void {}
220
+
221
+ setOverlayHandle(handle: OverlayHandle): void {
222
+ this.overlayHandle = handle;
223
+ }
224
+
225
+ render(width: number): string[] {
226
+ const frameWidth = Math.max(2, width);
227
+
228
+ return [
229
+ this.renderTopBorder(frameWidth),
230
+ frameLine(this.renderFilterContent(frameWidth), frameWidth),
231
+ this.renderRule(frameWidth),
232
+ ...this.renderList(frameWidth),
233
+ this.renderRule(frameWidth),
234
+ frameLine(this.renderFooterContent(), frameWidth),
235
+ this.renderBottomBorder(frameWidth),
236
+ ];
237
+ }
238
+
239
+ private async activateSelection(): Promise<void> {
240
+ const preset = selectedPickerPreset(
241
+ this.state,
242
+ this.allPresets,
243
+ this.filterInput.getValue(),
244
+ );
245
+
246
+ if (!preset) return;
247
+
248
+ this.applying = true;
249
+
250
+ try {
251
+ const result = await this.onActivate(preset);
252
+
253
+ if (result.ok) {
254
+ this.finish({ activated: preset });
255
+ } else {
256
+ await this.runWithHiddenOverlay(() =>
257
+ openInfoDialog(this.ctx, {
258
+ body: result.reason,
259
+ title: ACTIVATION_FAILED_TITLE,
260
+ tone: "error",
261
+ }),
262
+ );
263
+ }
264
+ } finally {
265
+ this.applying = false;
266
+ }
267
+ }
268
+
269
+ private cycleScope(direction: -1 | 1): void {
270
+ this.state = cyclePickerScope(
271
+ this.state,
272
+ this.allPresets,
273
+ this.filterInput.getValue(),
274
+ direction,
275
+ this.pageSize,
276
+ );
277
+ this.invalidateVisible();
278
+ }
279
+
280
+ private async clearActivePreset(): Promise<void> {
281
+ if (!this.pi) {
282
+ await this.showUnavailableDialog("Clear Unavailable");
283
+
284
+ return;
285
+ }
286
+
287
+ const confirmed = await this.runWithHiddenOverlay(() =>
288
+ openConfirm(
289
+ this.ctx,
290
+ "Clear active preset?",
291
+ "Clear the active preset and restore managed settings?",
292
+ ),
293
+ );
294
+
295
+ if (!confirmed) return;
296
+
297
+ const result = await clearReturning(this.ctx, this.pi);
298
+
299
+ if (result) {
300
+ await this.runWithHiddenOverlay(() =>
301
+ openInfoDialog(this.ctx, {
302
+ body: renderClearSummary(result.name, result.parts, this.theme),
303
+ title: "Preset Cleared",
304
+ tone: "info",
305
+ }),
306
+ );
307
+ }
308
+
309
+ await this.refreshPresets();
310
+ }
311
+
312
+ private async showActiveStatus(): Promise<void> {
313
+ if (!this.pi) {
314
+ await this.showUnavailableDialog("Status Unavailable");
315
+
316
+ return;
317
+ }
318
+
319
+ const result = await formatStatusBody(this.ctx, this.pi);
320
+
321
+ await this.runWithHiddenOverlay(() =>
322
+ openInfoDialog(this.ctx, {
323
+ body: withWarnings(result.body, result.warnings),
324
+ title: STATUS_DIALOG_TITLE,
325
+ tone: result.severity,
326
+ }),
327
+ );
328
+ }
329
+
330
+ private async showUnavailableDialog(title: string): Promise<void> {
331
+ await this.runWithHiddenOverlay(() =>
332
+ openInfoDialog(this.ctx, {
333
+ body: "This action is unavailable because the Pi API was not provided.",
334
+ title,
335
+ tone: "warning",
336
+ }),
337
+ );
338
+ }
339
+
340
+ private async deleteSelection(): Promise<void> {
341
+ await this.confirmAndActOnSelection(
342
+ (preset) => ({
343
+ title: `Delete '${preset.name}'?`,
344
+ message: `Remove preset "${preset.name}" from ${preset.scope} scope?`,
345
+ }),
346
+ async (preset) => {
347
+ const result = await removePreset(preset.name, preset.scope, this.ctx);
348
+
349
+ if (!result.ok) {
350
+ this.ui.notify(result.reason, "error");
351
+
352
+ return;
353
+ }
354
+
355
+ if (deleteNeedsHotkeyReload(preset)) {
356
+ const reloadRequested = await this.runWithHiddenOverlay(() =>
357
+ confirmReload(this.ctx),
358
+ );
359
+
360
+ if (reloadRequested) {
361
+ this.finish(undefined);
362
+ reloadAfterOverlayClose(this.ctx);
363
+
364
+ return;
365
+ }
366
+
367
+ recordReloadPromptDeclined(preset, undefined);
368
+ }
369
+
370
+ await this.refreshPresets(loadedPresetKey(preset));
371
+ },
372
+ );
373
+ }
374
+
375
+ private async duplicateSelection(): Promise<void> {
376
+ await this.confirmAndActOnSelection(
377
+ (preset) => ({
378
+ title: `Duplicate '${preset.name}'?`,
379
+ message: `Create a copy of "${preset.name}" in ${preset.scope} scope?`,
380
+ }),
381
+ async (preset) => {
382
+ const scopedNames = this.allPresets
383
+ .filter((candidate) => candidate.scope === preset.scope)
384
+ .map((candidate) => candidate.name);
385
+ const copyName = uniqueCopyName(preset.name, scopedNames);
386
+ const copy = serializeForCopy(preset, copyName);
387
+ // Route through the canonical CRUD primitive so any future
388
+ // invariant checks added to addPreset apply here too. The preset
389
+ // is appended at the end of the scope; the reorderWithinScope
390
+ // call below moves it immediately after its source.
391
+ const added = await addPreset(copy, preset.scope, this.ctx);
392
+
393
+ if (!added.ok) {
394
+ this.ui.notify(added.reason, "error");
395
+
396
+ return;
397
+ }
398
+
399
+ const sourceIndex = scopedNames.indexOf(preset.name);
400
+ const reordered = [...scopedNames];
401
+
402
+ reordered.splice(Math.max(0, sourceIndex + 1), 0, copyName);
403
+ await reorderWithinScope(preset.scope, reordered, this.ctx);
404
+ await this.refreshPresets(`${preset.scope}:${copyName}`);
405
+ },
406
+ );
407
+ }
408
+
409
+ /**
410
+ * Shared confirm-then-act wrapper for CRUD action keys that operate on
411
+ * the currently-selected preset (delete, duplicate). Resolves the
412
+ * selection, opens the confirm dialog with the caller-supplied copy,
413
+ * and invokes `action(preset)` on yes. A no-op on empty selection or
414
+ * cancelled confirm so each call site stays flat.
415
+ */
416
+ private async confirmAndActOnSelection(
417
+ messages: (preset: LoadedPreset) => { title: string; message: string },
418
+ action: (preset: LoadedPreset) => Promise<void>,
419
+ ): Promise<void> {
420
+ const preset = this.currentSelection();
421
+
422
+ if (!preset) return;
423
+
424
+ const { title, message } = messages(preset);
425
+ const confirmed = await this.runWithHiddenOverlay(() =>
426
+ openConfirm(this.ctx, title, message),
427
+ );
428
+
429
+ if (!confirmed) return;
430
+
431
+ await action(preset);
432
+ }
433
+
434
+ private currentSelection(): LoadedPreset | undefined {
435
+ return selectedPickerPreset(
436
+ this.state,
437
+ this.allPresets,
438
+ this.filterInput.getValue(),
439
+ );
440
+ }
441
+
442
+ /**
443
+ * Memoized drift-reason lookup for the currently-active preset.
444
+ *
445
+ * Keyed on the active state's identity (`scope:name:dirty`) so a tools
446
+ * toggle or a scope change invalidates the cache, but a filter keystroke
447
+ * or page scroll does not. The compared snapshot lives on `active.declared`
448
+ * — no disk I/O.
449
+ */
450
+ private computeDriftReasons(
451
+ active: NonNullable<ReturnType<typeof getActive>>,
452
+ pi: ExtensionAPI,
453
+ ): readonly string[] {
454
+ const signature = `${active.scope}:${active.name}:${active.dirty ? "1" : "0"}`;
455
+
456
+ if (this.driftReasonsCache?.signature === signature) {
457
+ return this.driftReasonsCache.reasons;
458
+ }
459
+
460
+ const reasons = detectDriftReasons(active.declared, pi, this.ctx);
461
+
462
+ this.driftReasonsCache = { reasons, signature };
463
+
464
+ return reasons;
465
+ }
466
+
467
+ private async openNewFromPicker(): Promise<void> {
468
+ await this.openEditorAndDispatch(undefined);
469
+ }
470
+
471
+ private async openEditorForSelection(): Promise<void> {
472
+ const preset = this.currentSelection();
473
+
474
+ if (!preset) return;
475
+
476
+ await this.openEditorAndDispatch(preset);
477
+ }
478
+
479
+ /**
480
+ * Shared wrapper for the two editor-entry actions (new, edit-selected).
481
+ * Hides the picker overlay, opens the editor seeded with either an
482
+ * existing preset or `undefined` (new-preset defaults), and routes the
483
+ * result: a `saved` payload refreshes the list with the new selection
484
+ * focused; a `tested` payload closes the picker and reports the
485
+ * candidate preset as `activated` so the outer notification surface
486
+ * names the right preset.
487
+ */
488
+ private async openEditorAndDispatch(
489
+ preset: LoadedPreset | undefined,
490
+ ): Promise<void> {
491
+ const result = await this.runWithHiddenOverlay(() =>
492
+ openEditor(this.ctx, preset, {
493
+ onReloadRequested: () => {
494
+ this.finish(undefined);
495
+ reloadAfterOverlayClose(this.ctx);
496
+ },
497
+ onTest: (candidate) =>
498
+ this.onActivate({
499
+ ...candidate,
500
+ unavailable: undefined,
501
+ }),
502
+ pi: this.pi,
503
+ presets: this.allPresets,
504
+ }),
505
+ );
506
+
507
+ if (result?.saved) {
508
+ if (result.reloadRequested) return;
509
+
510
+ await this.refreshPresets(loadedPresetKey(result.saved));
511
+ }
512
+
513
+ if (result?.tested) this.finish({ activated: result.tested });
514
+ }
515
+
516
+ private async runWithHiddenOverlay<T>(fn: () => Promise<T>): Promise<T> {
517
+ this.overlayHandle?.setHidden(true);
518
+
519
+ try {
520
+ return await fn();
521
+ } finally {
522
+ this.restoreOverlay();
523
+ }
524
+ }
525
+
526
+ private restoreOverlay(): void {
527
+ this.overlayHandle?.setHidden(false);
528
+ this.overlayHandle?.focus();
529
+ this.requestRender();
530
+ }
531
+
532
+ private async refreshPresets(selectionKey?: string): Promise<void> {
533
+ const { presets, warnings } = await loadAll(this.ctx);
534
+
535
+ surfaceWarnings(this.ctx, warnings);
536
+ this.allPresets = presets;
537
+ this.inheritedTools = this.pi?.getActiveTools() ?? this.inheritedTools;
538
+ this.invalidateVisible();
539
+ this.driftReasonsCache = undefined;
540
+ this.state = preservePickerSelectionOrFirst(
541
+ this.state,
542
+ this.allPresets,
543
+ this.filterInput.getValue(),
544
+ selectionKey ??
545
+ selectedPickerPresetKey(
546
+ this.state,
547
+ this.allPresets,
548
+ this.filterInput.getValue(),
549
+ ),
550
+ this.pageSize,
551
+ );
552
+ this.requestRender();
553
+ }
554
+
555
+ private async reorderSelection(direction: -1 | 1): Promise<void> {
556
+ const preset = this.currentSelection();
557
+
558
+ if (!preset) return;
559
+
560
+ const scopedPresets = this.allPresets.filter(
561
+ (candidate) => candidate.scope === preset.scope,
562
+ );
563
+ const index = scopedPresets.findIndex(
564
+ (candidate) => candidate.name === preset.name,
565
+ );
566
+ const nextIndex = index + direction;
567
+
568
+ if (index < 0 || nextIndex < 0 || nextIndex >= scopedPresets.length) return;
569
+
570
+ const ordered = [...scopedPresets];
571
+ const current = ordered[index];
572
+ const next = ordered[nextIndex];
573
+
574
+ if (!current || !next) return;
575
+
576
+ ordered[index] = next;
577
+ ordered[nextIndex] = current;
578
+ await reorderWithinScope(
579
+ preset.scope,
580
+ ordered.map((candidate) => candidate.name),
581
+ this.ctx,
582
+ );
583
+ await this.refreshPresets(loadedPresetKey(preset));
584
+ }
585
+
586
+ /** Idempotent resolver — guards against double-resolve from rapid Enter. */
587
+ private finish(result: PickerResult | undefined): void {
588
+ if (this.resolved) return;
589
+ this.resolved = true;
590
+ this.done(result);
591
+ }
592
+
593
+ private handleFilterInput(input: string): void {
594
+ if (matchesKey(input, Key.escape)) {
595
+ this.setFocusMode("list");
596
+
597
+ return;
598
+ }
599
+
600
+ if (matchesKey(input, Key.enter)) {
601
+ this.setFocusMode("list");
602
+
603
+ return;
604
+ }
605
+
606
+ // Navigation keys stay live in filter mode so users can type-then-arrow
607
+ // without needing to escape back to the list first.
608
+ if (matchesKey(input, Key.up)) {
609
+ this.moveSelection(-1);
610
+
611
+ return;
612
+ }
613
+
614
+ if (matchesKey(input, Key.down)) {
615
+ this.moveSelection(1);
616
+
617
+ return;
618
+ }
619
+
620
+ if (matchesKey(input, Key.pageUp)) {
621
+ this.moveSelection(-this.pageSize, { wrap: false });
622
+
623
+ return;
624
+ }
625
+
626
+ if (matchesKey(input, Key.pageDown)) {
627
+ this.moveSelection(this.pageSize, { wrap: false });
628
+
629
+ return;
630
+ }
631
+
632
+ const previousQuery = this.filterInput.getValue();
633
+ const previousSelection = selectedPickerPresetKey(
634
+ this.state,
635
+ this.allPresets,
636
+ previousQuery,
637
+ );
638
+
639
+ this.filterInput.handleInput(input);
640
+
641
+ if (this.filterInput.getValue() !== previousQuery) {
642
+ this.invalidateVisible();
643
+ this.state = preservePickerSelectionOrFirst(
644
+ this.state,
645
+ this.allPresets,
646
+ this.filterInput.getValue(),
647
+ previousSelection,
648
+ this.pageSize,
649
+ );
650
+ }
651
+ }
652
+
653
+ private invalidateVisible(): void {
654
+ this.cachedVisible = undefined;
655
+ }
656
+
657
+ private moveSelection(
658
+ delta: number,
659
+ options: { wrap: boolean } = { wrap: true },
660
+ ): void {
661
+ this.state = movePickerSelection(
662
+ this.state,
663
+ this.allPresets,
664
+ this.filterInput.getValue(),
665
+ delta,
666
+ this.pageSize,
667
+ options,
668
+ );
669
+ }
670
+
671
+ /** Rows available for list cards after overlay chrome is accounted for. */
672
+ private listLineBudget(): number {
673
+ // The overlay clamps height to 80% of terminal rows; we mirror that
674
+ // here so the card packer doesn't pretend the entire terminal is ours.
675
+ return Math.max(
676
+ MIN_PAGE_SIZE,
677
+ Math.floor(this.terminal.rows * 0.8) - CHROME_LINES,
678
+ );
679
+ }
680
+
681
+ /**
682
+ * Page size in cards. Fixed via the constructor option (tests/specialty
683
+ * callers) or learned from the last render's greedy actual-height packing.
684
+ * Before the first render we use a conservative fallback estimate so page
685
+ * navigation still behaves sensibly during the initial input/render cycle.
686
+ */
687
+ private get pageSize(): number {
688
+ if (this.fixedPageSize !== undefined) {
689
+ return Math.max(MIN_PAGE_SIZE, this.fixedPageSize);
690
+ }
691
+
692
+ if (this.renderedPageSize !== undefined) {
693
+ return Math.max(MIN_PAGE_SIZE, this.renderedPageSize);
694
+ }
695
+
696
+ const cardSpace = this.listLineBudget();
697
+
698
+ return Math.max(
699
+ MIN_PAGE_SIZE,
700
+ Math.floor(cardSpace / FALLBACK_AVG_CARD_LINES),
701
+ );
702
+ }
703
+
704
+ private renderBottomBorder(width: number): string {
705
+ return frameSegment("└", "─", "┘", width);
706
+ }
707
+
708
+ private renderFilterContent(width: number): string {
709
+ const label = this.theme.fg("muted", " Filter: ");
710
+ const inputWidth = Math.max(1, width - 2 - visibleWidth(label));
711
+ const query = this.filterInput.getValue();
712
+
713
+ if (this.state.focusMode !== "filter" && query.length === 0) {
714
+ return `${label}${this.theme.fg("dim", "Type to filter.")}`;
715
+ }
716
+
717
+ const inputLine = this.filterInput.render(inputWidth)[0] ?? "";
718
+
719
+ return `${label}${inputLine}`;
720
+ }
721
+
722
+ private renderFooterContent(): string {
723
+ const noMatches = this.visiblePresets().length === 0;
724
+ const activateHint = noMatches
725
+ ? `⏎ ${ACTIVATE_LABEL} (no matches)`
726
+ : `⏎ ${ACTIVATE_LABEL}`;
727
+ const footer =
728
+ this.state.focusMode === "filter"
729
+ ? `${activateHint} · Esc ${LIST_LABEL} · ←/→ ${CURSOR_LABEL} · ↑/↓ ${MOVE_LABEL} · PgUp/PgDn`
730
+ : `${activateHint} · n ${NEW_LABEL} · e ${EDIT_LABEL} · d ${DUPLICATE_LABEL} · x ${DELETE_LABEL} · c ${CLEAR_LABEL} · s ${STATUS_ACTION_LABEL} · Ctrl+↑/↓ ${REORDER_LABEL} · / ${FILTER_LABEL} · Esc ${CLOSE_LABEL}`;
731
+
732
+ return this.theme.fg("dim", ` ${footer}`);
733
+ }
734
+
735
+ private renderList(width: number): string[] {
736
+ const visiblePresets = this.visiblePresets();
737
+
738
+ if (visiblePresets.length === 0) {
739
+ this.renderedPageSize = undefined;
740
+
741
+ return [
742
+ frameLine("", width),
743
+ frameLine(
744
+ centerText(
745
+ this.theme.fg("warning", "No matching presets"),
746
+ width - 2,
747
+ ),
748
+ width,
749
+ ),
750
+ frameLine("", width),
751
+ ];
752
+ }
753
+
754
+ const active = getActive();
755
+ const lines: string[] = [];
756
+ const lineBudget = this.listLineBudget();
757
+ let renderedCards = 0;
758
+
759
+ for (
760
+ let absoluteIndex = this.state.scrollOffset;
761
+ absoluteIndex < visiblePresets.length;
762
+ absoluteIndex++
763
+ ) {
764
+ if (this.fixedPageSize !== undefined && renderedCards >= this.pageSize) {
765
+ break;
766
+ }
767
+
768
+ const preset = visiblePresets[absoluteIndex];
769
+
770
+ if (!preset) continue;
771
+
772
+ const isActive =
773
+ active?.name === preset.name && active.scope === preset.scope;
774
+ const driftReasons =
775
+ isActive && active?.dirty && this.pi
776
+ ? this.computeDriftReasons(active, this.pi)
777
+ : undefined;
778
+ const card = presetCard(preset, this.theme, {
779
+ active: isActive,
780
+ ...(isActive && active?.dirty ? { dirty: true } : {}),
781
+ ...(driftReasons ? { driftReasons } : {}),
782
+ inheritedTools: this.inheritedTools,
783
+ selected: absoluteIndex === this.state.selectedIndex,
784
+ showShadowed: this.state.scopeFilter === "all",
785
+ });
786
+ const cardLines = card.render(width - 2);
787
+ const separatorCost = renderedCards > 0 ? 1 : 0;
788
+ const nextCost = separatorCost + cardLines.length;
789
+
790
+ if (renderedCards > 0 && lines.length + nextCost > lineBudget) break;
791
+
792
+ if (separatorCost > 0) lines.push(frameLine("", width));
793
+
794
+ for (const cardLine of cardLines) {
795
+ lines.push(frameLine(cardLine, width));
796
+ }
797
+
798
+ renderedCards++;
799
+ }
800
+
801
+ this.renderedPageSize = Math.max(MIN_PAGE_SIZE, renderedCards);
802
+
803
+ return lines;
804
+ }
805
+
806
+ private renderRule(width: number): string {
807
+ return frameSegment("├", "─", "┤", width);
808
+ }
809
+
810
+ private renderTopBorder(width: number): string {
811
+ if (width <= 2) return truncateToWidth("┌┐", width, "");
812
+
813
+ const title = this.theme.fg("accent", this.theme.bold("Presets Plus"));
814
+ const scope = this.theme.fg(
815
+ "muted",
816
+ `Scope: ${formatScopeFilter(this.state.scopeFilter)}`,
817
+ );
818
+ const left = `─ ${title} `;
819
+ const right = ` ${scope} ─`;
820
+ const fillWidth = Math.max(
821
+ 0,
822
+ width - 2 - visibleWidth(left) - visibleWidth(right),
823
+ );
824
+ const content = `${left}${"─".repeat(fillWidth)}${right}`;
825
+
826
+ // Use `─` as the truncation suffix so the top border stays clean even
827
+ // when the terminal is narrower than the title + scope label.
828
+ return `┌${padToWidth(content, width - 2, "─", "─")}┐`;
829
+ }
830
+
831
+ private setFocusMode(focusMode: PickerFocusMode): void {
832
+ this.state = setPickerFocusMode(this.state, focusMode);
833
+ this.syncFilterFocus();
834
+ }
835
+
836
+ private syncFilterFocus(): void {
837
+ this.filterInput.focused =
838
+ this._focused && this.state.focusMode === "filter";
839
+ }
840
+
841
+ private visiblePresets(): readonly LoadedPreset[] {
842
+ const cacheKey = `${this.state.scopeFilter}|${this.filterInput.getValue()}`;
843
+
844
+ if (this.cachedVisible?.key === cacheKey) {
845
+ return this.cachedVisible.presets;
846
+ }
847
+
848
+ const presets = visiblePickerPresets(
849
+ this.state,
850
+ this.allPresets,
851
+ this.filterInput.getValue(),
852
+ );
853
+
854
+ this.cachedVisible = { key: cacheKey, presets };
855
+
856
+ return presets;
857
+ }
858
+ }
859
+
860
+ /** Open the preset picker and resolve once the user closes it. */
861
+ export async function openPicker(
862
+ ctx: ExtensionCommandContext,
863
+ options: PickerOptions,
864
+ ): Promise<PickerResult | undefined> {
865
+ const { presets, warnings } = await loadAll(ctx);
866
+
867
+ surfaceWarnings(ctx, warnings);
868
+
869
+ const inheritedTools = options.inheritedTools ?? [];
870
+ let currentPicker: PresetPickerComponent | undefined;
871
+
872
+ return ctx.ui.custom<PickerResult | undefined>(
873
+ (tui, theme, _keybindings, done) => {
874
+ const picker = new PresetPickerComponent(
875
+ presets,
876
+ ctx,
877
+ options.pi,
878
+ ctx.ui,
879
+ theme,
880
+ tui.terminal,
881
+ options.pageSize,
882
+ inheritedTools,
883
+ (preset) => options.onActivate(preset),
884
+ done,
885
+ () => tui.requestRender(),
886
+ );
887
+
888
+ currentPicker = picker;
889
+
890
+ return {
891
+ get focused() {
892
+ return picker.focused;
893
+ },
894
+ set focused(value: boolean) {
895
+ picker.focused = value;
896
+ },
897
+ handleInput(input: string): void {
898
+ picker.handleInput(input);
899
+ tui.requestRender();
900
+ },
901
+ invalidate(): void {
902
+ picker.invalidate();
903
+ },
904
+ render(width: number): string[] {
905
+ return picker.render(width);
906
+ },
907
+ };
908
+ },
909
+ {
910
+ onHandle: (handle) => currentPicker?.setOverlayHandle(handle),
911
+ overlay: true,
912
+ overlayOptions: {
913
+ anchor: "center",
914
+ margin: 1,
915
+ maxHeight: "80%",
916
+ minWidth: 64,
917
+ width: "80%",
918
+ },
919
+ },
920
+ );
921
+ }
922
+
923
+ function formatScopeFilter(scopeFilter: ScopeFilter): string {
924
+ switch (scopeFilter) {
925
+ case "all":
926
+ return "All";
927
+ case "user":
928
+ return "User only";
929
+ case "project":
930
+ return "Project only";
931
+ }
932
+ }
933
+
934
+ function loadedPresetKey(preset: Pick<LoadedPreset, "name" | "scope">): string {
935
+ return `${preset.scope}:${preset.name}`;
936
+ }
937
+
938
+ function serializeForCopy(preset: LoadedPreset, name: string): Preset {
939
+ const copy: Preset = {
940
+ model: preset.model,
941
+ name,
942
+ provider: preset.provider,
943
+ };
944
+
945
+ if (preset.thinkingLevel !== undefined)
946
+ copy.thinkingLevel = preset.thinkingLevel;
947
+ if (preset.tools !== undefined) copy.tools = [...preset.tools];
948
+ if (preset.instructions !== undefined)
949
+ copy.instructions = preset.instructions;
950
+ if (preset.order !== undefined) copy.order = preset.order;
951
+
952
+ return copy;
953
+ }
954
+
955
+ function uniqueCopyName(
956
+ name: string,
957
+ existingNames: readonly string[],
958
+ ): string {
959
+ const existing = new Set(existingNames);
960
+ const base = `${name}-copy`;
961
+
962
+ if (!existing.has(base)) return base;
963
+
964
+ for (let suffix = 2; suffix < Number.MAX_SAFE_INTEGER; suffix++) {
965
+ const candidate = `${base}-${suffix}`;
966
+
967
+ if (!existing.has(candidate)) return candidate;
968
+ }
969
+
970
+ return `${base}-${Date.now().toString(36)}`;
971
+ }
972
+
973
+ function withWarnings(body: string, warnings: readonly string[]): string {
974
+ if (warnings.length === 0) return body;
975
+
976
+ return [
977
+ `warnings:`,
978
+ ...warnings.map((warning) => `- ${warning}`),
979
+ "",
980
+ body,
981
+ ].join("\n");
982
+ }