@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/editor.ts
ADDED
|
@@ -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
|
+
}
|