@pellux/goodvibes-tui 0.19.53 → 0.19.55

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +10 -13
  3. package/docs/foundation-artifacts/knowledge-store.sql +27 -0
  4. package/docs/foundation-artifacts/operator-contract.json +15736 -7265
  5. package/package.json +2 -2
  6. package/src/audio/spoken-turn-controller.ts +4 -1
  7. package/src/input/command-args-hint.ts +36 -0
  8. package/src/input/command-registry.ts +3 -1
  9. package/src/input/commands/config.ts +7 -521
  10. package/src/input/commands/knowledge.ts +111 -1
  11. package/src/input/commands/local-runtime.ts +0 -80
  12. package/src/input/commands/operator-runtime.ts +3 -3
  13. package/src/input/commands/planning-runtime.ts +83 -34
  14. package/src/input/commands/shell-core.ts +2 -34
  15. package/src/input/commands/tts-runtime.ts +1 -389
  16. package/src/input/commands.ts +0 -2
  17. package/src/input/handler-modal-routes.ts +61 -7
  18. package/src/input/handler-modal-token-routes.ts +1 -0
  19. package/src/input/handler-picker-routes.ts +50 -4
  20. package/src/input/model-picker-provider-filter.ts +28 -0
  21. package/src/input/model-picker-types.ts +12 -0
  22. package/src/input/model-picker.ts +65 -23
  23. package/src/input/selection-modal.ts +1 -1
  24. package/src/input/settings-modal-behavior.ts +2 -0
  25. package/src/input/settings-modal-subscriptions.ts +95 -0
  26. package/src/input/settings-modal-types.ts +50 -3
  27. package/src/input/settings-modal.ts +106 -134
  28. package/src/input/tts-settings-actions.ts +100 -0
  29. package/src/main.ts +50 -45
  30. package/src/panels/builtin/agent.ts +15 -0
  31. package/src/panels/builtin/shared.ts +17 -0
  32. package/src/panels/project-planning-panel.ts +370 -0
  33. package/src/planning/project-planning-coordinator.ts +249 -0
  34. package/src/renderer/compositor.ts +2 -1
  35. package/src/renderer/conversation-overlays.ts +4 -5
  36. package/src/renderer/model-workspace.ts +488 -0
  37. package/src/renderer/settings-modal-helpers.ts +16 -1
  38. package/src/renderer/settings-modal.ts +616 -716
  39. package/src/runtime/bootstrap-command-context.ts +6 -0
  40. package/src/runtime/bootstrap-command-parts.ts +5 -0
  41. package/src/runtime/bootstrap-shell.ts +2 -0
  42. package/src/runtime/services.ts +33 -2
  43. package/src/runtime/terminal-output-guard.ts +228 -0
  44. package/src/runtime/ui-services.ts +4 -0
  45. package/src/shell/ui-openers.ts +59 -3
  46. package/src/utils/clipboard.ts +2 -1
  47. package/src/version.ts +1 -1
  48. package/src/input/commands/permissions-runtime.ts +0 -104
@@ -4,7 +4,7 @@ import type { CommandRegistry } from '../input/command-registry.ts';
4
4
  import type { InputHandler } from '../input/handler.ts';
5
5
  import type { KeybindingsManager } from '../input/keybindings.ts';
6
6
  import { renderFilePickerOverlay } from './file-picker-overlay.ts';
7
- import { MODEL_PICKER_CHROME_LINES, renderModelPickerOverlay } from './model-picker-overlay.ts';
7
+ import { renderModelWorkspace } from './model-workspace.ts';
8
8
  import { renderSelectionModalOverlay } from './selection-modal-overlay.ts';
9
9
  import { renderSearchOverlay } from './search-overlay.ts';
10
10
  import { renderHistorySearchOverlay } from './history-search-overlay.ts';
@@ -50,9 +50,8 @@ export function applyConversationOverlays(
50
50
  }
51
51
 
52
52
  if (input.modelPicker.active) {
53
- const maxVisible = Math.max(5, viewportHeight - MODEL_PICKER_CHROME_LINES - 4);
54
- const lines = renderModelPickerOverlay(input.modelPicker, conversationWidth, maxVisible, viewportHeight);
55
- next = overlayViewportBottom(next, lines, conversationWidth, viewportHeight, bottomDockInset);
53
+ const lines = renderModelWorkspace(input.modelPicker, conversationWidth, viewportHeight);
54
+ next = replaceViewportWithOverlay(lines, conversationWidth, viewportHeight);
56
55
  }
57
56
 
58
57
  if (input.selectionModal.active) {
@@ -90,7 +89,7 @@ export function applyConversationOverlays(
90
89
 
91
90
  if (input.settingsModal.active) {
92
91
  const lines = renderSettingsModal(input.settingsModal, conversationWidth, viewportHeight);
93
- next = overlayViewportBottom(next, lines, conversationWidth, viewportHeight, bottomDockInset);
92
+ next = replaceViewportWithOverlay(lines, conversationWidth, viewportHeight);
94
93
  }
95
94
 
96
95
  if (input.sessionPickerModal.active) {
@@ -0,0 +1,488 @@
1
+ /**
2
+ * Fullscreen provider/model workspace.
3
+ *
4
+ * This keeps the model-picker data and commit behavior, but presents it as a
5
+ * stable workspace with explicit target slots instead of a compact overlay.
6
+ */
7
+
8
+ import type { ModelDefinition } from '@pellux/goodvibes-sdk/platform/providers/registry';
9
+ import type { ModelPickerModal } from '../input/model-picker.ts';
10
+ import type { ModelPickerTargetInfo } from '../input/model-picker.ts';
11
+ import type { Line } from '../types/grid.ts';
12
+ import { createEmptyLine, createStyledCell } from '../types/grid.ts';
13
+ import { getDisplayWidth, wrapText } from '../utils/terminal-width.ts';
14
+ import { GLYPHS, UI_TONES } from './ui-primitives.ts';
15
+
16
+ const PALETTE = {
17
+ border: '#64748b',
18
+ title: '#67e8f9',
19
+ subtitle: '#93c5fd',
20
+ text: '#e2e8f0',
21
+ muted: '#94a3b8',
22
+ dim: '#64748b',
23
+ selectedBg: '#223049',
24
+ targetBg: '#141b25',
25
+ detailBg: '#121923',
26
+ bodyBg: '#0f141d',
27
+ footerBg: '#111827',
28
+ good: UI_TONES.state.good,
29
+ warn: UI_TONES.state.warn,
30
+ info: UI_TONES.state.info,
31
+ };
32
+
33
+ const renderCache = new WeakMap<ModelPickerModal, { key: string; lines: Line[] }>();
34
+ const objectIds = new WeakMap<object, number>();
35
+ let nextObjectId = 1;
36
+
37
+ function clamp(value: number, min: number, max: number): number {
38
+ return Math.max(min, Math.min(max, value));
39
+ }
40
+
41
+ function fillRange(line: Line, startX: number, endX: number, bg: string): void {
42
+ for (let x = Math.max(0, startX); x <= Math.min(line.length - 1, endX); x += 1) {
43
+ const cell = line[x] ?? createStyledCell(' ');
44
+ line[x] = createStyledCell(cell.char, {
45
+ fg: cell.fg,
46
+ bg,
47
+ bold: cell.bold,
48
+ dim: cell.dim,
49
+ underline: cell.underline,
50
+ italic: cell.italic,
51
+ strikethrough: cell.strikethrough,
52
+ link: cell.link,
53
+ });
54
+ }
55
+ }
56
+
57
+ function makeLine(width: number, bg = ''): Line {
58
+ const line = createEmptyLine(width);
59
+ if (bg) fillRange(line, 0, width - 1, bg);
60
+ return line;
61
+ }
62
+
63
+ function writeText(line: Line, startX: number, maxWidth: number, text: string, style: Partial<Omit<Line[number], 'char'>> = {}): void {
64
+ let x = startX;
65
+ let used = 0;
66
+ for (const ch of text) {
67
+ const width = getDisplayWidth(ch);
68
+ if (width <= 0) continue;
69
+ if (used + width > maxWidth || x >= line.length) break;
70
+ line[x] = createStyledCell(ch, style);
71
+ if (width > 1 && x + 1 < line.length) {
72
+ line[x + 1] = createStyledCell(' ', style);
73
+ }
74
+ x += width;
75
+ used += width;
76
+ }
77
+ }
78
+
79
+ function borderLine(width: number, left: string, fill: string, right: string): Line {
80
+ const line = makeLine(width);
81
+ if (width <= 0) return line;
82
+ line[0] = createStyledCell(left, { fg: PALETTE.border });
83
+ for (let x = 1; x < width - 1; x += 1) {
84
+ line[x] = createStyledCell(fill, { fg: PALETTE.border });
85
+ }
86
+ if (width > 1) line[width - 1] = createStyledCell(right, { fg: PALETTE.border });
87
+ return line;
88
+ }
89
+
90
+ function contentLine(width: number, bg: string): Line {
91
+ const line = makeLine(width, bg);
92
+ if (width > 0) line[0] = createStyledCell(GLYPHS.frame.vertical, { fg: PALETTE.border });
93
+ if (width > 1) line[width - 1] = createStyledCell(GLYPHS.frame.vertical, { fg: PALETTE.border });
94
+ return line;
95
+ }
96
+
97
+ function drawVertical(line: Line, x: number, bg = ''): void {
98
+ if (x <= 0 || x >= line.length - 1) return;
99
+ line[x] = createStyledCell(GLYPHS.frame.vertical, { fg: PALETTE.border, bg });
100
+ }
101
+
102
+ function clipDisplay(text: string, width: number): string {
103
+ if (width <= 0) return '';
104
+ let used = 0;
105
+ let output = '';
106
+ for (const ch of text) {
107
+ const chWidth = getDisplayWidth(ch);
108
+ if (chWidth <= 0) continue;
109
+ if (used + chWidth > width) break;
110
+ output += ch;
111
+ used += chWidth;
112
+ }
113
+ return output;
114
+ }
115
+
116
+ function padDisplay(text: string, width: number): string {
117
+ const clipped = clipDisplay(text, width);
118
+ return clipped + ' '.repeat(Math.max(0, width - getDisplayWidth(clipped)));
119
+ }
120
+
121
+ function stableWindow(total: number, selected: number, visible: number): { start: number; end: number } {
122
+ if (total <= 0 || visible <= 0) return { start: 0, end: 0 };
123
+ const clamped = clamp(selected, 0, total - 1);
124
+ const half = Math.floor(visible / 2);
125
+ const maxStart = Math.max(0, total - visible);
126
+ const start = clamp(clamped - half, 0, maxStart);
127
+ return { start, end: Math.min(total, start + visible) };
128
+ }
129
+
130
+ function formatContext(value: number | undefined): string {
131
+ if (!value) return '-';
132
+ if (value >= 1_000_000) return `${Math.round(value / 1_000_000)}M`;
133
+ if (value >= 1000) return `${Math.round(value / 1000)}k`;
134
+ return String(value);
135
+ }
136
+
137
+ function modelKey(model: ModelDefinition): string {
138
+ return model.registryKey ?? `${model.provider}:${model.id}`;
139
+ }
140
+
141
+ function targetSummary(info: ModelPickerTargetInfo): string {
142
+ if (!info.enabled) return 'disabled';
143
+ const route = formatRoute(info.provider, info.model);
144
+ if (info.inherited) return `inherits ${route}`;
145
+ return route;
146
+ }
147
+
148
+ function selectedModel(picker: ModelPickerModal): ModelDefinition | null {
149
+ if (picker.mode !== 'model') return null;
150
+ return picker.getSelected();
151
+ }
152
+
153
+ function formatRoute(provider: string, model: string): string {
154
+ if (!provider && !model) return '(not set)';
155
+ if (!provider) return model;
156
+ if (!model) return provider;
157
+ return model.startsWith(`${provider}:`) ? model : `${provider}:${model}`;
158
+ }
159
+
160
+ function targetLabelFor(target: string): string {
161
+ if (target === 'helper') return 'Helper Model';
162
+ if (target === 'tool') return 'Tool LLM';
163
+ if (target === 'tts') return 'TTS LLM';
164
+ return 'Main Chat';
165
+ }
166
+
167
+ function detailLines(picker: ModelPickerModal, width: number): string[] {
168
+ const target = picker.getSelectedTargetInfo();
169
+ const targetLabel = target?.label ?? targetLabelFor(picker.target);
170
+ const targetState = target ? (target.enabled ? 'enabled' : 'disabled') : 'active';
171
+ const selected = selectedModel(picker);
172
+ const lines: string[] = [];
173
+ lines.push(`Target: ${targetLabel} (${targetState})`);
174
+ if (target) lines.push(`Current: ${targetSummary(target)}`);
175
+ if (picker.mode === 'provider') {
176
+ const provider = picker.getFilteredProviders()[picker.selectedIndex] ?? '';
177
+ lines.push(`Provider selection: choose a provider, then choose a model for ${targetLabel}.`);
178
+ if (provider) lines.push(`Selected provider: ${provider}`);
179
+ } else if (picker.mode === 'model') {
180
+ lines.push(`Model selection: choose the model to store for ${targetLabel}. Use filters to narrow large catalogs.`);
181
+ if (selected) {
182
+ const caps = selected.capabilities ?? {};
183
+ const capText = [
184
+ caps.reasoning ? 'reasoning' : '',
185
+ caps.multimodal ? 'vision' : '',
186
+ caps.toolCalling ? 'tools' : '',
187
+ caps.codeEditing ? 'code' : '',
188
+ ].filter(Boolean).join(', ') || 'standard';
189
+ lines.push(`Selected: ${modelKey(selected)} | ${selected.displayName} | context ${formatContext(selected.contextWindow)} | ${capText}`);
190
+ }
191
+ } else if (picker.mode === 'effort') {
192
+ lines.push(`Reasoning effort applies to the main chat model. Select the default effort for this model.`);
193
+ } else {
194
+ lines.push(`Context cap overrides the detected local-model context window for this selection.`);
195
+ }
196
+ const filterText = `Search: ${picker.query || '(none)'} | Price: ${picker.categoryFilter} | Capability: ${picker.capabilityFilter} | Group: ${picker.groupBy} | Available only: ${picker.availableOnly ? 'yes' : 'no'}`;
197
+ lines.push(filterText);
198
+ return lines.flatMap((line) => wrapText(line, Math.max(1, width)));
199
+ }
200
+
201
+ function renderTargets(picker: ModelPickerModal, line: Line, startX: number, width: number, rowIndex: number): void {
202
+ const info = picker.targetInfos[rowIndex];
203
+ if (!info) return;
204
+ const selected = rowIndex === picker.targetIndex;
205
+ const bg = selected ? PALETTE.selectedBg : PALETTE.targetBg;
206
+ fillRange(line, startX, startX + width - 1, bg);
207
+ const marker = selected ? (picker.focusPane === 'targets' ? GLYPHS.navigation.selected : '•') : ' ';
208
+ const state = info.enabled ? (info.inherited ? 'inherit' : 'set') : 'off';
209
+ writeText(line, startX + 1, width - 2, `${marker} ${info.label} (${state})`, {
210
+ fg: selected ? PALETTE.text : PALETTE.muted,
211
+ bg,
212
+ bold: selected,
213
+ });
214
+ }
215
+
216
+ function renderProviderRows(picker: ModelPickerModal, lines: Line[], rows: number, startX: number, width: number): void {
217
+ const providers = picker.getFilteredProviders();
218
+ const { start, end } = stableWindow(providers.length, picker.selectedIndex, rows);
219
+ const modelCounts = new Map<string, number>();
220
+ for (const model of picker.models) modelCounts.set(model.provider, (modelCounts.get(model.provider) ?? 0) + 1);
221
+ for (let visibleRow = 0; visibleRow < rows; visibleRow += 1) {
222
+ const absolute = start + visibleRow;
223
+ const provider = providers[absolute];
224
+ const line = lines[visibleRow]!;
225
+ if (!provider) continue;
226
+ const selected = absolute === picker.selectedIndex;
227
+ const bg = selected ? PALETTE.selectedBg : PALETTE.bodyBg;
228
+ fillRange(line, startX, startX + width - 1, bg);
229
+ const marker = selected ? (picker.focusPane === 'items' ? GLYPHS.navigation.selected : '•') : ' ';
230
+ const via = picker.configuredViaMap.get(provider) ?? (picker.configuredProviders.has(provider) ? 'configured' : 'not configured');
231
+ const count = String(modelCounts.get(provider) ?? 0);
232
+ writeText(line, startX + 1, width, padDisplay(marker, 2), { fg: PALETTE.text, bg, bold: selected });
233
+ writeText(line, startX + 3, Math.max(0, width - 36), padDisplay(provider, Math.max(0, width - 36)), { fg: selected ? PALETTE.text : PALETTE.muted, bg, bold: selected });
234
+ writeText(line, startX + Math.max(4, width - 31), 18, padDisplay(via, 18), { fg: picker.configuredProviders.has(provider) ? PALETTE.good : PALETTE.warn, bg });
235
+ writeText(line, startX + Math.max(4, width - 12), 10, padDisplay(`${count} models`, 10), { fg: PALETTE.dim, bg });
236
+ }
237
+ if (start > 0 && lines[0]) writeText(lines[0], startX + 1, width - 2, `${GLYPHS.navigation.moreAbove} ${start} more provider(s) above`, { fg: PALETTE.dim, bg: PALETTE.bodyBg, dim: true });
238
+ if (end < providers.length && lines[rows - 1]) writeText(lines[rows - 1]!, startX + 1, width - 2, `${GLYPHS.navigation.moreBelow} ${providers.length - end} more provider(s) below`, { fg: PALETTE.dim, bg: PALETTE.bodyBg, dim: true });
239
+ }
240
+
241
+ function renderModelRows(picker: ModelPickerModal, lines: Line[], rows: number, startX: number, width: number): void {
242
+ const models = picker.getFilteredModels();
243
+ const { start, end } = stableWindow(models.length, picker.selectedIndex, rows);
244
+ const providerW = clamp(Math.floor(width * 0.14), 10, 18);
245
+ const ctxW = 8;
246
+ const tierW = 8;
247
+ const capsW = 14;
248
+ const nameW = clamp(Math.floor(width * 0.28), 16, 36);
249
+ const keyW = Math.max(10, width - providerW - ctxW - tierW - capsW - nameW - 10);
250
+ for (let visibleRow = 0; visibleRow < rows; visibleRow += 1) {
251
+ const absolute = start + visibleRow;
252
+ const model = models[absolute];
253
+ const line = lines[visibleRow]!;
254
+ if (!model) continue;
255
+ const selected = absolute === picker.selectedIndex;
256
+ const bg = selected ? PALETTE.selectedBg : PALETTE.bodyBg;
257
+ fillRange(line, startX, startX + width - 1, bg);
258
+ const marker = selected ? (picker.focusPane === 'items' ? GLYPHS.navigation.selected : '•') : ' ';
259
+ const caps = model.capabilities ?? {};
260
+ const capText = [
261
+ caps.reasoning ? 'R' : '-',
262
+ caps.multimodal ? 'V' : '-',
263
+ caps.toolCalling ? 'T' : '-',
264
+ caps.codeEditing ? 'C' : '-',
265
+ ].join('');
266
+ let x = startX + 1;
267
+ writeText(line, x, 2, padDisplay(marker, 2), { fg: PALETTE.text, bg, bold: selected }); x += 2;
268
+ writeText(line, x, keyW, padDisplay(modelKey(model), keyW), { fg: selected ? PALETTE.text : PALETTE.muted, bg, bold: selected }); x += keyW + 1;
269
+ writeText(line, x, nameW, padDisplay(model.displayName, nameW), { fg: selected ? PALETTE.subtitle : PALETTE.text, bg }); x += nameW + 1;
270
+ writeText(line, x, providerW, padDisplay(model.provider, providerW), { fg: PALETTE.muted, bg }); x += providerW + 1;
271
+ writeText(line, x, ctxW, padDisplay(formatContext(model.contextWindow), ctxW), { fg: PALETTE.dim, bg }); x += ctxW + 1;
272
+ writeText(line, x, tierW, padDisplay(model.tier ?? 'paid', tierW), { fg: model.tier === 'free' ? PALETTE.good : PALETTE.dim, bg }); x += tierW + 1;
273
+ writeText(line, x, capsW, padDisplay(capText, capsW), { fg: PALETTE.info, bg });
274
+ }
275
+ if (start > 0 && lines[0]) writeText(lines[0], startX + 1, width - 2, `${GLYPHS.navigation.moreAbove} ${start} more model(s) above`, { fg: PALETTE.dim, bg: PALETTE.bodyBg, dim: true });
276
+ if (end < models.length && lines[rows - 1]) writeText(lines[rows - 1]!, startX + 1, width - 2, `${GLYPHS.navigation.moreBelow} ${models.length - end} more model(s) below`, { fg: PALETTE.dim, bg: PALETTE.bodyBg, dim: true });
277
+ }
278
+
279
+ function renderEffortRows(picker: ModelPickerModal, lines: Line[], rows: number, startX: number, width: number): void {
280
+ for (let row = 0; row < Math.min(rows, picker.effortLevels.length); row += 1) {
281
+ const effort = picker.effortLevels[row]!;
282
+ const selected = row === picker.selectedIndex;
283
+ const bg = selected ? PALETTE.selectedBg : PALETTE.bodyBg;
284
+ fillRange(lines[row]!, startX, startX + width - 1, bg);
285
+ const marker = selected ? GLYPHS.navigation.selected : ' ';
286
+ writeText(lines[row]!, startX + 1, width - 2, `${marker} ${effort}`, { fg: selected ? PALETTE.text : PALETTE.muted, bg, bold: selected });
287
+ }
288
+ }
289
+
290
+ function renderContextCapRows(picker: ModelPickerModal, lines: Line[], rows: number, startX: number, width: number): void {
291
+ if (rows <= 0) return;
292
+ const model = picker.contextCapPendingModel;
293
+ const input = picker.contextCapQuery.length > 0 ? picker.contextCapQuery : '(use detected context)';
294
+ const copy = [
295
+ `Model: ${model ? modelKey(model) : '(none)'}`,
296
+ `Detected context: ${formatContext(model?.contextWindow)}`,
297
+ `Override: ${input}`,
298
+ 'Type digits to set a cap. Enter confirms; Esc returns to the model list.',
299
+ ];
300
+ for (let row = 0; row < Math.min(rows, copy.length); row += 1) {
301
+ const line = lines[row]!;
302
+ fillRange(line, startX, startX + width - 1, PALETTE.bodyBg);
303
+ writeText(line, startX + 1, width - 2, copy[row]!, { fg: row === 2 ? PALETTE.title : PALETTE.text, bg: PALETTE.bodyBg, bold: row === 2 });
304
+ }
305
+ }
306
+
307
+ function writeTableHeader(line: Line, picker: ModelPickerModal, startX: number, width: number): void {
308
+ fillRange(line, startX, startX + width - 1, PALETTE.footerBg);
309
+ const style = { fg: PALETTE.muted, bg: PALETTE.footerBg, bold: true };
310
+ if (picker.mode === 'provider') {
311
+ writeText(line, startX + 1, width - 2, 'Provider Configuration Catalog', style);
312
+ return;
313
+ }
314
+ if (picker.mode === 'effort') {
315
+ writeText(line, startX + 1, width - 2, 'Reasoning effort Meaning', style);
316
+ return;
317
+ }
318
+ if (picker.mode === 'contextCap') {
319
+ writeText(line, startX + 1, width - 2, 'Context cap input', style);
320
+ return;
321
+ }
322
+ const providerW = clamp(Math.floor(width * 0.14), 10, 18);
323
+ const ctxW = 8;
324
+ const tierW = 8;
325
+ const capsW = 14;
326
+ const nameW = clamp(Math.floor(width * 0.28), 16, 36);
327
+ const keyW = Math.max(10, width - providerW - ctxW - tierW - capsW - nameW - 10);
328
+ let x = startX + 3;
329
+ writeText(line, x, keyW, padDisplay('Model key', keyW), style); x += keyW + 1;
330
+ writeText(line, x, nameW, padDisplay('Display name', nameW), style); x += nameW + 1;
331
+ writeText(line, x, providerW, padDisplay('Provider', providerW), style); x += providerW + 1;
332
+ writeText(line, x, ctxW, padDisplay('Context', ctxW), style); x += ctxW + 1;
333
+ writeText(line, x, tierW, padDisplay('Tier', tierW), style); x += tierW + 1;
334
+ writeText(line, x, capsW, padDisplay('Caps', capsW), style);
335
+ }
336
+
337
+ export function renderModelWorkspace(picker: ModelPickerModal, width: number, viewportHeight: number): Line[] {
338
+ const cacheKey = getRenderCacheKey(picker, width, viewportHeight);
339
+ const cached = renderCache.get(picker);
340
+ if (cached?.key === cacheKey) return cached.lines;
341
+
342
+ const safeWidth = Math.max(20, width);
343
+ const safeHeight = Math.max(12, viewportHeight);
344
+ const lines: Line[] = [];
345
+ const targetW = clamp(Math.round(safeWidth * 0.18), 24, 34);
346
+ const contentX = targetW + 1;
347
+ const contentW = Math.max(1, safeWidth - contentX - 1);
348
+
349
+ const top = borderLine(safeWidth, GLYPHS.frame.topLeft, GLYPHS.frame.horizontal, GLYPHS.frame.topRight);
350
+ writeText(top, 2, safeWidth - 4, ' Model Workspace / Providers And Models ', { fg: PALETTE.title, bold: true });
351
+ lines.push(top);
352
+
353
+ const header = contentLine(safeWidth, PALETTE.footerBg);
354
+ writeText(header, 2, targetW - 2, 'Targets', { fg: PALETTE.subtitle, bold: true, bg: PALETTE.footerBg });
355
+ drawVertical(header, targetW, PALETTE.footerBg);
356
+ const modeLabel = picker.mode === 'provider' ? 'Provider list' : picker.mode === 'model' ? 'Model list' : picker.mode === 'effort' ? 'Reasoning effort' : 'Context cap';
357
+ writeText(header, contentX + 1, contentW - 2, `${modeLabel} • ${picker.getItemCount()} item(s)`, { fg: PALETTE.subtitle, bold: true, bg: PALETTE.footerBg });
358
+ lines.push(header);
359
+
360
+ const sep = borderLine(safeWidth, GLYPHS.frame.teeLeft, GLYPHS.frame.horizontal, GLYPHS.frame.teeRight);
361
+ if (targetW > 0 && targetW < safeWidth - 1) sep[targetW] = createStyledCell(GLYPHS.frame.cross, { fg: PALETTE.border });
362
+ lines.push(sep);
363
+
364
+ const footerRows = 2;
365
+ const bodyRows = safeHeight - 3 - footerRows;
366
+ const maxDetailRows = Math.max(3, bodyRows - 2);
367
+ const minDetailRows = Math.min(6, maxDetailRows);
368
+ const detailRows = clamp(Math.round(bodyRows * 0.32), minDetailRows, maxDetailRows);
369
+ const listRows = Math.max(1, bodyRows - detailRows - 1);
370
+ const details = detailLines(picker, contentW - 2).slice(0, detailRows);
371
+
372
+ for (let row = 0; row < bodyRows; row += 1) {
373
+ const inDetail = row < detailRows;
374
+ const line = contentLine(safeWidth, inDetail ? PALETTE.detailBg : PALETTE.bodyBg);
375
+ fillRange(line, 1, targetW - 1, PALETTE.targetBg);
376
+ drawVertical(line, targetW, inDetail ? PALETTE.detailBg : PALETTE.bodyBg);
377
+ renderTargets(picker, line, 1, targetW - 1, row);
378
+ if (inDetail) {
379
+ const text = details[row] ?? '';
380
+ const fg = row === 0 ? PALETTE.title : row <= 2 ? PALETTE.text : PALETTE.muted;
381
+ writeText(line, contentX + 1, contentW - 2, text, { fg, bg: PALETTE.detailBg, bold: row === 0 });
382
+ }
383
+ lines.push(line);
384
+ }
385
+
386
+ const listStart = 3 + detailRows + 1;
387
+ if (listStart - 1 < lines.length) {
388
+ const divider = lines[listStart - 1]!;
389
+ writeTableHeader(divider, picker, contentX, contentW - 1);
390
+ }
391
+
392
+ const listLines = lines.slice(listStart, listStart + listRows);
393
+ if (picker.mode === 'provider') {
394
+ renderProviderRows(picker, listLines, listLines.length, contentX, contentW - 1);
395
+ } else if (picker.mode === 'model') {
396
+ renderModelRows(picker, listLines, listLines.length, contentX, contentW - 1);
397
+ } else if (picker.mode === 'contextCap') {
398
+ renderContextCapRows(picker, listLines, listLines.length, contentX, contentW - 1);
399
+ } else {
400
+ renderEffortRows(picker, listLines, listLines.length, contentX, contentW - 1);
401
+ }
402
+
403
+ const footer = contentLine(safeWidth, PALETTE.footerBg);
404
+ const targetHint = picker.focusPane === 'targets' ? 'Focus targets' : 'Focus list';
405
+ const searchHint = picker.searchFocused ? 'Typing filters search; Esc clears search' : '/ search';
406
+ const hints = `${targetHint} • Up/Down navigate • Left/Right pane • Enter select • ${searchHint} • Tab price • C caps • A available • B benchmark • G group • Esc close`;
407
+ writeText(footer, 2, safeWidth - 4, hints, { fg: PALETTE.muted, bg: PALETTE.footerBg });
408
+ lines.push(footer);
409
+ lines.push(borderLine(safeWidth, GLYPHS.frame.bottomLeft, GLYPHS.frame.horizontal, GLYPHS.frame.bottomRight));
410
+
411
+ while (lines.length < safeHeight) lines.push(makeLine(safeWidth));
412
+ const result = lines.slice(0, safeHeight);
413
+ renderCache.set(picker, { key: cacheKey, lines: result });
414
+ return result;
415
+ }
416
+
417
+ function getRenderCacheKey(picker: ModelPickerModal, width: number, viewportHeight: number): string {
418
+ const base: Array<string | number> = [
419
+ width,
420
+ viewportHeight,
421
+ picker.mode,
422
+ picker.target,
423
+ picker.focusPane,
424
+ picker.targetIndex,
425
+ picker.query,
426
+ picker.searchFocused ? 1 : 0,
427
+ picker.selectedIndex,
428
+ picker.scrollOffset,
429
+ picker.categoryFilter,
430
+ picker.capabilityFilter,
431
+ picker.availableOnly ? 1 : 0,
432
+ picker.benchmarkSort,
433
+ picker.groupBy,
434
+ keyForSet(picker.pinnedIds),
435
+ keyForSet(picker.configuredProviders),
436
+ keyForMap(picker.configuredViaMap),
437
+ keyForTargets(picker.targetInfos),
438
+ ];
439
+
440
+ if (picker.mode === 'model') {
441
+ const filtered = picker.getFilteredModels();
442
+ const selected = filtered[picker.selectedIndex];
443
+ base.push(objectId(picker.models), objectId(filtered), filtered.length, selected?.registryKey ?? selected?.id ?? '');
444
+ } else if (picker.mode === 'provider') {
445
+ const filteredProviders = picker.getFilteredProviders();
446
+ base.push(objectId(picker.providers), objectId(filteredProviders), filteredProviders.length);
447
+ } else if (picker.mode === 'effort') {
448
+ base.push(objectId(picker.effortLevels), picker.effortLevels.join('\u001f'), picker.pendingModel?.registryKey ?? picker.pendingModel?.id ?? '');
449
+ } else if (picker.mode === 'contextCap') {
450
+ base.push(picker.contextCapQuery, picker.contextCapPendingModel?.registryKey ?? picker.contextCapPendingModel?.id ?? '');
451
+ }
452
+
453
+ return base.join('\u001e');
454
+ }
455
+
456
+ function objectId(value: object): number {
457
+ const existing = objectIds.get(value);
458
+ if (existing !== undefined) return existing;
459
+ const next = nextObjectId++;
460
+ objectIds.set(value, next);
461
+ return next;
462
+ }
463
+
464
+ function keyForSet(values: ReadonlySet<string>): string {
465
+ return values.size === 0 ? '' : [...values].sort().join('\u001f');
466
+ }
467
+
468
+ function keyForMap(values: ReadonlyMap<string, string | undefined>): string {
469
+ if (values.size === 0) return '';
470
+ return [...values.entries()]
471
+ .sort(([left], [right]) => left.localeCompare(right))
472
+ .map(([key, value]) => `${key}\u001d${value ?? ''}`)
473
+ .join('\u001f');
474
+ }
475
+
476
+ function keyForTargets(values: readonly ModelPickerTargetInfo[]): string {
477
+ return values
478
+ .map((entry) => [
479
+ entry.target,
480
+ entry.label,
481
+ entry.description,
482
+ entry.provider,
483
+ entry.model,
484
+ entry.enabled ? 1 : 0,
485
+ entry.inherited ? 1 : 0,
486
+ ].join('\u001d'))
487
+ .join('\u001f');
488
+ }
@@ -82,13 +82,28 @@ export const CATEGORY_LABELS: Record<(typeof SETTINGS_CATEGORIES)[number], strin
82
82
  behavior: 'Behavior',
83
83
  storage: 'Storage',
84
84
  permissions: 'Permissions',
85
+ orchestration: 'Orchestration',
86
+ wrfc: 'WRFC',
87
+ helper: 'Helper',
88
+ tts: 'TTS',
89
+ service: 'Service',
90
+ controlPlane: 'Control Plane',
91
+ httpListener: 'HTTP Listener',
92
+ web: 'Web',
93
+ batch: 'Batch',
94
+ automation: 'Automation',
95
+ watchers: 'Watchers',
96
+ runtime: 'Runtime',
97
+ telemetry: 'Telemetry',
98
+ cache: 'Cache',
85
99
  mcp: 'MCP',
86
100
  sandbox: 'Sandbox',
87
101
  surfaces: 'Surfaces',
88
102
  cloudflare: 'Cloudflare',
103
+ release: 'Release',
89
104
  danger: 'Danger',
90
105
  tools: 'Tools',
91
- flags: 'Flags',
106
+ flags: 'Feature Flags',
92
107
  network: 'Network',
93
108
  };
94
109