@aiwayds/dsh-tui-pi 0.26.0 → 0.28.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/lib/profile.js ADDED
@@ -0,0 +1,647 @@
1
+ /**
2
+ * `/profile-switch` + `/profile-cfg` — user model profiles: named snapshots
3
+ * of the whole model configuration (default model + think level, every
4
+ * subagent's model/thinking) that switch in one step between contexts like
5
+ * work and personal.
6
+ *
7
+ * Layers, all on the select-panel framework (src/panels.ts):
8
+ *
9
+ * 1. `/profile-switch` switcher (TablePanel) — one row per profile (name |
10
+ * default model | think | agents), Enter applies it to the live
11
+ * selection AND the agent markdown files, Esc backs out.
12
+ * 2. `/profile-cfg` manager (TablePanel) — the roster: n new, d delete
13
+ * (double-press confirm), Enter opens the profile's fields.
14
+ * 3. profile fields (FieldPanel) — model / think / agents rows plus the
15
+ * s save-current, r rename, v review shortcuts.
16
+ * 4. agents sub-table (TablePanel) — every discovered agent's recorded
17
+ * model/thinking; Enter picks through the SAME favorites/hidden model
18
+ * table /model uses (pickModel with the shared PanelHost), i sets
19
+ * explicit inherit, t edits think alone.
20
+ *
21
+ * Every edit persists to `$DSH_HOME/model-profiles.json` immediately
22
+ * (src/model-profiles.ts). Applying a profile writes the agent frontmatter
23
+ * files (updateAgentFrontmatter) and the default model through the same
24
+ * chain as /model: live bridge selection + persistDefaultModel.
25
+ */
26
+ import { agentsDir, listAgentFiles, updateAgentFrontmatter, } from "./agent-manager.js";
27
+ import { captureAgentsSnapshot, createProfile, deleteProfile, findProfile, formatProfileRoute, loadModelProfiles, modelProfilesPath, planAgentApply, profileReviewLines, readNearestProfilePin, removeProfilePin, renameProfile, saveModelProfiles, writeProfilePin, PROFILE_PIN_FILE, } from "./model-profiles.js";
28
+ import { join } from 'node:path';
29
+ import { persistDefaultModel } from "./session.js";
30
+ import { openEffortPicker, pickModel } from "./selectors.js";
31
+ import { EditField } from "./settings.js";
32
+ import { autoColumns, FieldPanel, PanelHost, TablePanel, ViewerPanel, } from "./panels.js";
33
+ import { ansiFg, BOLD, RESET } from "./theme/index.js";
34
+ import { clipToWidth } from "./text.js";
35
+ /** How long a `d` delete arm stays confirmable (double-press window). */
36
+ const DELETE_CONFIRM_MS = 2500;
37
+ /** The profile table columns: NAME/MODEL/THINK fit (capped), AGENTS right-aligned tail. */
38
+ function profileColumns(profiles, current) {
39
+ return autoColumns([
40
+ { key: 'name', title: 'Profile', cap: 18 },
41
+ { key: 'model', title: 'Default model', cap: 36 },
42
+ { key: 'think', title: 'Think', cap: 10 },
43
+ { key: 'agents', title: 'Agents', align: 'right' },
44
+ ], profiles, (profile, key) => profileCell(profile, key, current));
45
+ }
46
+ /** Cell text for one profile table row; the current profile gets the ● mark. */
47
+ function profileCell(profile, key, current) {
48
+ switch (key) {
49
+ case 'name': return current === profile.name ? `● ${profile.name}` : profile.name;
50
+ case 'model': return profile.defaultModel === undefined
51
+ ? '(not set)'
52
+ : `${profile.defaultModel.provider}/${profile.defaultModel.model}`;
53
+ case 'think': return profile.defaultModel?.reasoningEffort ?? 'default';
54
+ default: return String(Object.keys(profile.agents).length);
55
+ }
56
+ }
57
+ /** A profile's default route as a ModelSelection (the /model handler shape). */
58
+ function routeToSelection(route) {
59
+ return {
60
+ provider: route.provider,
61
+ model: route.model,
62
+ ...(route.reasoningEffort !== undefined
63
+ ? { reasoningEffort: route.reasoningEffort }
64
+ : {}),
65
+ };
66
+ }
67
+ /** Record what a live selection says into a plain storable route. */
68
+ function selectionToRoute(selection) {
69
+ return selection.reasoningEffort === undefined
70
+ ? { provider: selection.provider, model: selection.model }
71
+ : {
72
+ provider: selection.provider,
73
+ model: selection.model,
74
+ reasoningEffort: selection.reasoningEffort,
75
+ };
76
+ }
77
+ /**
78
+ * Apply `profile` end to end: default model + think level through the /model
79
+ * chain (live selection ref + persisted default; a profile without a default
80
+ * model leaves the current selection alone), every LISTED agent's
81
+ * frontmatter through the snapshot semantics (src/model-profiles.ts), then
82
+ * the current pointer and the store. Resolves the command summary text —
83
+ * failures ride along as ⚠ parts instead of aborting the rest.
84
+ */
85
+ async function applyProfile(ctx, path, doc, profile, deps) {
86
+ const parts = [];
87
+ if (profile.defaultModel !== undefined) {
88
+ const selection = routeToSelection(profile.defaultModel);
89
+ deps.setSelection(selection);
90
+ const persistError = await persistDefaultModel(ctx, selection);
91
+ if (persistError !== undefined)
92
+ parts.push(`⚠ default model not persisted: ${persistError}`);
93
+ }
94
+ const { agents } = listAgentFiles(agentsDir());
95
+ let updated = 0;
96
+ const failed = [];
97
+ for (const { agent, updates } of planAgentApply(profile, agents)) {
98
+ const error = updateAgentFrontmatter(agent.path, updates);
99
+ if (error === undefined) {
100
+ agent.meta.model = updates.model ?? undefined;
101
+ agent.meta.thinking = updates.thinking ?? undefined;
102
+ updated++;
103
+ }
104
+ else {
105
+ failed.push(agent.meta.name);
106
+ }
107
+ }
108
+ if (failed.length > 0)
109
+ parts.push(`⚠ agents failed: ${failed.join(', ')}`);
110
+ doc.current = profile.name;
111
+ const saveError = saveModelProfiles(path, doc);
112
+ if (saveError !== undefined)
113
+ parts.push(`⚠ profile not saved: ${saveError}`);
114
+ const modelText = profile.defaultModel !== undefined
115
+ ? formatProfileRoute(profile.defaultModel)
116
+ : 'model unchanged';
117
+ return [`Profile → ${profile.name} · ${modelText} · agents ${String(updated)} updated`, ...parts]
118
+ .filter(part => part !== '')
119
+ .join(' · ');
120
+ }
121
+ /**
122
+ * Open the `/profile-switch` switcher. Resolves the apply summary when a
123
+ * profile was switched, or `undefined` when cancelled / nothing applied.
124
+ *
125
+ * `p` binds the CWD to the cursor profile (writes `.dsh-profile`, see
126
+ * src/model-profiles.ts) so every NEW session in this tree auto-loads it;
127
+ * pressing `p` on the already-bound profile unpins it again — unless the pin
128
+ * lives in an ancestor or was hand-decorated, which is reported in the
129
+ * status line instead. Enter keeps its global meaning: apply now AND persist
130
+ * the global default.
131
+ */
132
+ export async function openProfileSwitcher(ctx, tui, theme, deps, restoreFocus) {
133
+ const path = modelProfilesPath();
134
+ const doc = loadModelProfiles(path);
135
+ const cwd = process.cwd();
136
+ let settle;
137
+ const host = new PanelHost(tui, theme, message => {
138
+ restoreFocus();
139
+ settle?.(`✘ failed to open the profile switcher: ${message}`);
140
+ });
141
+ return new Promise(resolve => {
142
+ settle = resolve;
143
+ // Enter can fire again while the async apply runs — resolve-once guard,
144
+ // the same settle discipline the pickers use.
145
+ let applying = false;
146
+ // Session-local status flash for the p-toggle outcome.
147
+ let pinStatus;
148
+ /** The nearest `.dsh-profile` binding for the cwd, resolved per render. */
149
+ const boundPin = () => readNearestProfilePin(cwd);
150
+ /**
151
+ * Show the table once through the host (a p-toggle swaps the whole panel:
152
+ * simplest way to refresh the `(dir)` markers, and PanelHost's
153
+ * show-new-then-hide-old keeps focus stable).
154
+ */
155
+ const showTable = () => {
156
+ const pinnedName = boundPin()?.name;
157
+ const table = new TablePanel(theme, {
158
+ title: `● Model profiles · ${PROFILE_PIN_FILE} here: ${pinnedName ?? 'none'}`,
159
+ columns: profileColumns(doc.profiles, doc.current),
160
+ rows: doc.profiles,
161
+ renderCell: (profile, column) => profileCell(profile, column.key, doc.current),
162
+ preselect: Math.max(0, doc.profiles.findIndex(profile => profile.name === doc.current)),
163
+ status: () => pinStatus,
164
+ onSelect: profile => { void switchTo(profile); },
165
+ onCancel: () => { host.close(); restoreFocus(); resolve(undefined); },
166
+ footer: '↑↓ navigate · Enter switch · p pin to this dir / unpin · Esc back',
167
+ shortcuts: { p: () => togglePin(table) },
168
+ });
169
+ host.open(table);
170
+ };
171
+ /** Bind/unbind the cwd to the cursor profile; re-show to refresh markers. */
172
+ const togglePin = (table) => {
173
+ const profile = table.selectedRow();
174
+ if (profile === undefined)
175
+ return;
176
+ const existing = boundPin();
177
+ if (existing !== undefined
178
+ && existing.name.toLowerCase() === profile.name.toLowerCase()
179
+ && existing.path === join(cwd, PROFILE_PIN_FILE)) {
180
+ const error = removeProfilePin(cwd, profile.name);
181
+ pinStatus = error === undefined ? `unpinned ${cwd}` : `✘ ${error}`;
182
+ }
183
+ else {
184
+ const error = writeProfilePin(cwd, profile.name);
185
+ if (error !== undefined)
186
+ pinStatus = `✘ ${error}`;
187
+ else {
188
+ const inheritedNote = existing !== undefined && existing.path !== join(cwd, PROFILE_PIN_FILE)
189
+ ? ` (overrides ancestor pin "${existing.name}")`
190
+ : '';
191
+ pinStatus = `pinned "${profile.name}" → .dsh-profile${inheritedNote}`;
192
+ }
193
+ }
194
+ showTable();
195
+ };
196
+ // First mount; a mount failure has already settled through the host's
197
+ // error path (this promise resolves there, everything below is no-op).
198
+ showTable();
199
+ const switchTo = async (profile) => {
200
+ if (applying)
201
+ return;
202
+ applying = true;
203
+ const summary = await applyProfile(ctx, path, doc, profile, deps);
204
+ host.close();
205
+ restoreFocus();
206
+ // If this tree is pinned to a DIFFERENT profile, say so — the global
207
+ // default moved but new sessions HERE will still auto-load the pin.
208
+ const pinned = readNearestProfilePin(cwd);
209
+ const note = pinned !== undefined && pinned.name.toLowerCase() !== profile.name.toLowerCase()
210
+ ? ` · note: ${PROFILE_PIN_FILE} pins "${pinned.name}" — new sessions here stay on it`
211
+ : '';
212
+ resolve(`${summary}${note}`);
213
+ };
214
+ });
215
+ }
216
+ /**
217
+ * Open the `/profile-cfg` manager. Resolves a summary when anything changed,
218
+ * or `undefined` when the manager closed untouched. `preselect` jumps
219
+ * straight into one profile's fields window.
220
+ */
221
+ export async function openProfileManager(ctx, tui, theme, deps, restoreFocus, preselect) {
222
+ const path = modelProfilesPath();
223
+ const doc = loadModelProfiles(path);
224
+ const changed = [];
225
+ let tableStatus;
226
+ let fieldsStatus;
227
+ let agentsStatus;
228
+ /** Armed double-press delete: profile name + when it was armed. */
229
+ let pendingDelete;
230
+ let pendingDeleteAt = 0;
231
+ let pendingPreselect = preselect;
232
+ let fieldsHandle;
233
+ let agentsHandle;
234
+ let agentsCursor;
235
+ /** Settles the manager promise (assigned inside the executor below). */
236
+ let settle;
237
+ const host = new PanelHost(tui, theme, message => {
238
+ restoreFocus();
239
+ settle?.(`✘ failed to open the profiles manager: ${message}`);
240
+ });
241
+ /** Persist the working doc; resolves a ✘ status line on failure. */
242
+ const persistDoc = () => {
243
+ const error = saveModelProfiles(path, doc);
244
+ return error === undefined ? undefined : `✘ could not save profiles: ${error}`;
245
+ };
246
+ /** Mark one profile as touched for the closing summary (first touch wins). */
247
+ const markChanged = (name) => {
248
+ if (!changed.includes(name))
249
+ changed.push(name);
250
+ };
251
+ return new Promise(resolve => {
252
+ settle = resolve;
253
+ const showTable = () => {
254
+ const preselectIndex = pendingPreselect === undefined
255
+ ? undefined
256
+ : doc.profiles.findIndex(profile => profile.name === pendingPreselect);
257
+ pendingPreselect = undefined;
258
+ const table = new TablePanel(theme, {
259
+ title: '● Model profiles — configure',
260
+ columns: profileColumns(doc.profiles, doc.current),
261
+ rows: doc.profiles,
262
+ renderCell: (profile, column) => profileCell(profile, column.key, doc.current),
263
+ preselect: preselectIndex !== undefined && preselectIndex >= 0 ? preselectIndex : undefined,
264
+ status: () => tableStatus,
265
+ footer: '↑↓ navigate · Enter open · n new · d delete · Esc back',
266
+ onSelect: profile => showFields(profile),
267
+ onCancel: closeManager,
268
+ shortcuts: {
269
+ n: showNew,
270
+ d: () => {
271
+ const profile = table.selectedRow();
272
+ if (profile === undefined)
273
+ return;
274
+ // Double-press confirm, time-boxed: the armed state must NOT
275
+ // rebuild the table (a rebuild resets the cursor to row 0, so
276
+ // the confirming press would target a different profile), and
277
+ // the window expires so a stale arm can never fire one press
278
+ // after the user navigated away and back.
279
+ const armed = pendingDelete === profile.name
280
+ && Date.now() - pendingDeleteAt < DELETE_CONFIRM_MS;
281
+ if (armed) {
282
+ pendingDelete = undefined;
283
+ const error = deleteProfile(doc, profile.name);
284
+ if (error !== undefined)
285
+ tableStatus = `✘ ${error}`;
286
+ else {
287
+ tableStatus = persistDoc() ?? `deleted profile "${profile.name}"`;
288
+ markChanged(`-${profile.name}`);
289
+ }
290
+ showTable();
291
+ }
292
+ else {
293
+ pendingDelete = profile.name;
294
+ pendingDeleteAt = Date.now();
295
+ tableStatus = `press d again to delete "${profile.name}"`;
296
+ tui.requestRender();
297
+ }
298
+ },
299
+ },
300
+ });
301
+ host.open(table);
302
+ };
303
+ const closeManager = () => {
304
+ host.close();
305
+ restoreFocus();
306
+ resolve(changed.length > 0 ? `Profiles updated: ${changed.join(', ')}` : undefined);
307
+ };
308
+ const showFields = (profile) => {
309
+ const content = [
310
+ `named model snapshot — switch with /profile-switch · applies ${agentsDir()}`,
311
+ ];
312
+ const agentsSet = Object.keys(profile.agents).length;
313
+ const fields = [
314
+ {
315
+ key: 'model',
316
+ value: profile.defaultModel === undefined
317
+ ? '(not set — switching keeps the current model)'
318
+ : formatProfileRoute(profile.defaultModel),
319
+ editable: true,
320
+ },
321
+ {
322
+ key: 'think',
323
+ value: profile.defaultModel?.reasoningEffort ?? '(provider default)',
324
+ editable: true,
325
+ },
326
+ { key: 'agents', value: `${String(agentsSet)} agent override(s) recorded`, editable: true },
327
+ ];
328
+ const view = new FieldPanel(theme, {
329
+ title: profileTitle(theme, profile, doc),
330
+ content,
331
+ fields,
332
+ status: () => fieldsStatus,
333
+ footer: '↑↓ field · Enter edit (m model · t think · a agents) · s save current · r rename · v review · Esc back',
334
+ shortcuts: {
335
+ m: () => void changeModel(profile),
336
+ t: () => void changeThink(profile),
337
+ a: () => showAgentsTable(profile),
338
+ s: () => saveFromCurrent(profile),
339
+ r: () => showRename(profile),
340
+ v: () => showReview(profile),
341
+ },
342
+ onEdit: index => {
343
+ if (index === 0)
344
+ void changeModel(profile);
345
+ else if (index === 1)
346
+ void changeThink(profile);
347
+ else
348
+ showAgentsTable(profile);
349
+ },
350
+ onCancel: () => showTable(),
351
+ });
352
+ fieldsHandle = host.open(view);
353
+ };
354
+ /** Commit path shared by model/think/agent edits: flash and re-show. */
355
+ const writeFields = (profile, status, message) => {
356
+ if (status !== undefined)
357
+ fieldsStatus = status;
358
+ else {
359
+ markChanged(profile.name);
360
+ fieldsStatus = message;
361
+ }
362
+ showFields(profile);
363
+ };
364
+ const changeModel = async (profile) => {
365
+ const current = profile.defaultModel === undefined ? undefined : routeToSelection(profile.defaultModel);
366
+ const picked = await pickModel(ctx, tui, theme, current, () => { }, host);
367
+ if (picked === undefined) {
368
+ showFields(profile);
369
+ return;
370
+ }
371
+ profile.defaultModel = selectionToRoute(picked);
372
+ const status = persistDoc();
373
+ writeFields(profile, status, `saved ${profile.name} default model → ${formatProfileRoute(profile.defaultModel)}`);
374
+ };
375
+ const changeThink = async (profile) => {
376
+ if (profile.defaultModel === undefined) {
377
+ fieldsStatus = '✘ no default model set — pick a model first (m)';
378
+ showFields(profile);
379
+ return;
380
+ }
381
+ const llm = ctx.get('llm');
382
+ if (llm === undefined) {
383
+ fieldsStatus = '✘ llm service unavailable';
384
+ showFields(profile);
385
+ return;
386
+ }
387
+ const { provider, model } = profile.defaultModel;
388
+ let efforts;
389
+ try {
390
+ efforts = (await llm.resolveModelInfo(provider, model)).reasoning?.efforts;
391
+ }
392
+ catch {
393
+ efforts = undefined;
394
+ }
395
+ if (efforts === undefined || efforts.length === 0) {
396
+ fieldsStatus = `✘ ${provider}/${model} exposes no think levels`;
397
+ showFields(profile);
398
+ return;
399
+ }
400
+ const currentEffort = profile.defaultModel.reasoningEffort;
401
+ const chosen = await openEffortPicker(tui, theme, efforts, currentEffort, () => { }, () => fieldsHandle?.hide());
402
+ if (chosen === undefined) {
403
+ showFields(profile);
404
+ return;
405
+ }
406
+ profile.defaultModel.reasoningEffort = chosen.effort === 'default' ? undefined : chosen.effort;
407
+ const status = persistDoc();
408
+ const thinkText = profile.defaultModel.reasoningEffort === undefined
409
+ ? 'provider default'
410
+ : `think ${String(profile.defaultModel.reasoningEffort)}`;
411
+ writeFields(profile, status, `saved ${profile.name} think → ${thinkText}`);
412
+ };
413
+ const saveFromCurrent = (profile) => {
414
+ const selection = deps.getSelection();
415
+ if (selection !== undefined)
416
+ profile.defaultModel = selectionToRoute(selection);
417
+ const { agents } = listAgentFiles(agentsDir());
418
+ profile.agents = captureAgentsSnapshot(agents);
419
+ const status = persistDoc();
420
+ const message = selection === undefined
421
+ ? `saved ${String(agents.length)} agent(s) into "${profile.name}" · no live model selection — default model kept`
422
+ : `saved current configuration into "${profile.name}" (model + ${String(agents.length)} agent(s))`;
423
+ writeFields(profile, status, message);
424
+ };
425
+ const showAgentsTable = (profile) => {
426
+ const { agents } = listAgentFiles(agentsDir());
427
+ const rows = agents.map(agent => ({ agent, entry: profile.agents[agent.meta.name] }));
428
+ const cell = (row, key) => {
429
+ switch (key) {
430
+ case 'name': return row.agent.meta.displayName ?? row.agent.meta.name;
431
+ case 'model': return row.entry?.model ?? (row.entry === undefined ? '(not saved)' : '(inherit)');
432
+ default: return row.entry?.thinking ?? 'inherit';
433
+ }
434
+ };
435
+ const table = new TablePanel(theme, {
436
+ title: `● Agents — ${profile.name}`,
437
+ columns: autoColumns([
438
+ { key: 'name', title: 'Agent', cap: 16 },
439
+ { key: 'model', title: 'Model', cap: 36 },
440
+ { key: 'thinking', title: 'Think', cap: 10 },
441
+ ], rows, cell),
442
+ rows,
443
+ renderCell: (row, column) => cell(row, column.key),
444
+ preselect: Math.max(0, rows.findIndex(row => row.agent.meta.name === agentsCursor)),
445
+ status: () => agentsStatus,
446
+ footer: '↑↓ navigate · Enter pick model (i inherit · t think) · Esc back',
447
+ onSelect: row => {
448
+ agentsCursor = row.agent.meta.name;
449
+ void pickAgentModel(profile, row);
450
+ },
451
+ onCancel: () => showFields(profile),
452
+ shortcuts: {
453
+ i: () => {
454
+ const row = table.selectedRow();
455
+ if (row === undefined)
456
+ return;
457
+ profile.agents[row.agent.meta.name] = {};
458
+ const status = persistDoc();
459
+ agentsStatus = status ?? `saved ${row.agent.meta.name} → inherit`;
460
+ if (status === undefined)
461
+ markChanged(profile.name);
462
+ showAgentsTable(profile);
463
+ },
464
+ t: () => {
465
+ const row = table.selectedRow();
466
+ if (row === undefined)
467
+ return;
468
+ void changeAgentThinking(profile, row);
469
+ },
470
+ },
471
+ });
472
+ agentsHandle = host.open(table);
473
+ };
474
+ /** Pick one agent's model + think through the full /model picker. */
475
+ const pickAgentModel = async (profile, row) => {
476
+ const current = row.entry?.model === undefined
477
+ ? undefined
478
+ : selectionFromRoute(row.entry.model, row.entry.thinking);
479
+ const picked = await pickModel(ctx, tui, theme, current, () => { }, host);
480
+ if (picked === undefined) {
481
+ showAgentsTable(profile);
482
+ return;
483
+ }
484
+ profile.agents[row.agent.meta.name] = picked.reasoningEffort === undefined
485
+ ? { model: `${picked.provider}/${picked.model}` }
486
+ : { model: `${picked.provider}/${picked.model}`, thinking: picked.reasoningEffort };
487
+ const status = persistDoc();
488
+ const entry = profile.agents[row.agent.meta.name];
489
+ const thinkText = entry.thinking === undefined ? '' : ` · think ${entry.thinking}`;
490
+ agentsStatus = status ?? `saved ${row.agent.meta.name} → ${entry.model ?? ''}${thinkText}`;
491
+ if (status === undefined)
492
+ markChanged(profile.name);
493
+ showAgentsTable(profile);
494
+ };
495
+ /** Edit one agent's think level alone (its recorded model must be set). */
496
+ const changeAgentThinking = async (profile, row) => {
497
+ const route = row.entry?.model;
498
+ if (route === undefined) {
499
+ agentsStatus = '✘ no model recorded — pick a model first (Enter)';
500
+ showAgentsTable(profile);
501
+ return;
502
+ }
503
+ const llm = ctx.get('llm');
504
+ if (llm === undefined) {
505
+ agentsStatus = '✘ llm service unavailable';
506
+ showAgentsTable(profile);
507
+ return;
508
+ }
509
+ const slash = route.indexOf('/');
510
+ if (slash <= 0) {
511
+ agentsStatus = `✘ cannot resolve think levels for ${route}`;
512
+ showAgentsTable(profile);
513
+ return;
514
+ }
515
+ let efforts;
516
+ try {
517
+ efforts = (await llm.resolveModelInfo(route.slice(0, slash), route.slice(slash + 1))).reasoning?.efforts;
518
+ }
519
+ catch {
520
+ efforts = undefined;
521
+ }
522
+ if (efforts === undefined || efforts.length === 0) {
523
+ agentsStatus = `✘ ${route} exposes no think levels`;
524
+ showAgentsTable(profile);
525
+ return;
526
+ }
527
+ const entry = profile.agents[row.agent.meta.name] ?? {};
528
+ const currentEffort = entry.thinking;
529
+ const chosen = await openEffortPicker(tui, theme, efforts, currentEffort, () => { }, () => agentsHandle?.hide());
530
+ if (chosen === undefined) {
531
+ showAgentsTable(profile);
532
+ return;
533
+ }
534
+ const next = chosen.effort === 'default' ? undefined : chosen.effort;
535
+ if (next === undefined)
536
+ delete entry.thinking;
537
+ else
538
+ entry.thinking = next;
539
+ profile.agents[row.agent.meta.name] = entry;
540
+ const status = persistDoc();
541
+ agentsStatus = status ?? `saved ${row.agent.meta.name} → think ${next ?? 'inherit'}`;
542
+ if (status === undefined)
543
+ markChanged(profile.name);
544
+ showAgentsTable(profile);
545
+ };
546
+ const showRename = (profile) => {
547
+ let committed = false;
548
+ const field = new EditField(tui, {
549
+ title: `Rename profile — ${profile.name}`,
550
+ subtitle: 'letters, digits, dash, space · must stay unique',
551
+ initial: profile.name,
552
+ parse: text => parseProfileName(doc, profile.name, text),
553
+ onCommit: async (parsed) => {
554
+ if (parsed.kind !== 'value')
555
+ return undefined;
556
+ const name = parsed.value;
557
+ const error = renameProfile(doc, profile.name, name);
558
+ if (error !== undefined)
559
+ return { error };
560
+ committed = true;
561
+ pendingPreselect = name;
562
+ return undefined;
563
+ },
564
+ onDone: () => {
565
+ if (committed) {
566
+ const status = persistDoc();
567
+ tableStatus = status ?? `renamed → ${profile.name}`;
568
+ if (status === undefined)
569
+ markChanged(profile.name);
570
+ }
571
+ showTable();
572
+ },
573
+ onError: message => { tableStatus = `✘ ${message}`; },
574
+ }, theme);
575
+ host.open(field);
576
+ };
577
+ const showNew = () => {
578
+ let committed = false;
579
+ const field = new EditField(tui, {
580
+ title: 'New model profile',
581
+ subtitle: 'a name like "work" or "personal" · configured next',
582
+ initial: '',
583
+ parse: text => parseProfileName(doc, '', text),
584
+ onCommit: async (parsed) => {
585
+ if (parsed.kind !== 'value')
586
+ return undefined;
587
+ const { profile, error } = createProfile(doc, parsed.value);
588
+ if (error !== undefined || profile === undefined)
589
+ return { error: error ?? 'create failed' };
590
+ committed = true;
591
+ pendingPreselect = profile.name;
592
+ return undefined;
593
+ },
594
+ onDone: () => {
595
+ if (committed) {
596
+ const status = persistDoc();
597
+ tableStatus = status ?? `created profile "${pendingPreselect}"`;
598
+ if (status === undefined && pendingPreselect !== undefined)
599
+ markChanged(`+${pendingPreselect}`);
600
+ }
601
+ showTable();
602
+ },
603
+ onError: message => { tableStatus = `✘ ${message}`; },
604
+ }, theme);
605
+ host.open(field);
606
+ };
607
+ const showReview = (profile) => {
608
+ const { agents } = listAgentFiles(agentsDir());
609
+ host.open(new ViewerPanel(theme, {
610
+ title: `ⓘ Model profile — ${profile.name}`,
611
+ lines: profileReviewLines(profile, agents, doc.current === profile.name),
612
+ footer: ' Esc to close',
613
+ onClose: () => showFields(profile),
614
+ }));
615
+ };
616
+ showTable();
617
+ });
618
+ }
619
+ /** The accent title line of the profile fields window (current profiles get the ● mark). */
620
+ function profileTitle(theme, profile, doc) {
621
+ const mark = doc.current === profile.name ? '● ' : '';
622
+ return ansiFg(theme.palette.accent) + BOLD + `${mark}${clipToWidth(profile.name, 100)}` + RESET;
623
+ }
624
+ /** Name-editor parse: trimmed non-empty, unique against the doc (case-insensitive). */
625
+ function parseProfileName(doc, currentName, text) {
626
+ const name = text.trim();
627
+ if (name === '')
628
+ return { kind: 'error', error: 'profile name must not be empty' };
629
+ if (name.length > 32)
630
+ return { kind: 'error', error: 'profile name must stay under 33 characters' };
631
+ if (name.toLowerCase() !== currentName.toLowerCase() && findProfile(doc, name) !== undefined) {
632
+ return { kind: 'error', error: `a profile named "${name}" already exists` };
633
+ }
634
+ return { kind: 'value', value: name };
635
+ }
636
+ /** An agent entry's `provider/model` route back into a picker preselection. */
637
+ function selectionFromRoute(route, thinking) {
638
+ const slash = route.indexOf('/');
639
+ if (slash <= 0)
640
+ return { provider: route, model: route };
641
+ return {
642
+ provider: route.slice(0, slash),
643
+ model: route.slice(slash + 1),
644
+ ...(thinking !== undefined ? { reasoningEffort: thinking } : {}),
645
+ };
646
+ }
647
+ //# sourceMappingURL=profile.js.map