@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.
- package/CHANGELOG.md +9 -0
- package/LICENSE +21 -0
- package/README.md +67 -0
- package/package.json +74 -0
- package/src/activation/active-state.ts +25 -0
- package/src/activation/apply.ts +236 -0
- package/src/activation/baseline.ts +32 -0
- package/src/activation/clear.ts +434 -0
- package/src/activation/dirty.ts +69 -0
- package/src/activation/drift-handlers.ts +71 -0
- package/src/activation/drift.ts +77 -0
- package/src/activation/same-set.ts +32 -0
- package/src/activation/state-matches.ts +29 -0
- package/src/activation/thinking.ts +54 -0
- package/src/commands/presets/clear.ts +18 -0
- package/src/commands/presets/index.ts +9 -0
- package/src/commands/presets/notify.ts +22 -0
- package/src/commands/presets/reload.ts +28 -0
- package/src/commands/presets/router.ts +139 -0
- package/src/commands/presets/status.ts +262 -0
- package/src/flag.ts +88 -0
- package/src/hotkey-conflicts.ts +136 -0
- package/src/hotkey-reload-baseline.ts +112 -0
- package/src/hotkeys.ts +104 -0
- package/src/index.ts +171 -0
- package/src/messages.ts +34 -0
- package/src/store/api.ts +262 -0
- package/src/store/load.ts +175 -0
- package/src/store/merge.ts +69 -0
- package/src/store/paths.ts +38 -0
- package/src/store/save.ts +75 -0
- package/src/store/validate.ts +195 -0
- package/src/types.ts +169 -0
- package/src/ui/confirm.ts +126 -0
- package/src/ui/editor.ts +1617 -0
- package/src/ui/filter.ts +79 -0
- package/src/ui/frame.ts +109 -0
- package/src/ui/hotkey-input.ts +242 -0
- package/src/ui/info-dialog.ts +118 -0
- package/src/ui/labels.ts +51 -0
- package/src/ui/picker-state.ts +151 -0
- package/src/ui/picker.ts +982 -0
- package/src/ui/reload-prompt.ts +59 -0
- package/src/ui/status.ts +55 -0
- package/src/ui/widgets.ts +274 -0
package/src/ui/picker.ts
ADDED
|
@@ -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
|
+
}
|