@pellux/goodvibes-tui 0.29.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/CHANGELOG.md +69 -98
  2. package/README.md +16 -7
  3. package/docs/foundation-artifacts/operator-contract.json +1 -1
  4. package/package.json +2 -2
  5. package/src/cli/ensure-goodvibes-gitignore.ts +32 -0
  6. package/src/cli/entrypoint.ts +2 -0
  7. package/src/core/conversation-line-cache.ts +432 -0
  8. package/src/core/conversation-rendering.ts +11 -3
  9. package/src/core/conversation.ts +90 -4
  10. package/src/core/stream-event-wiring.ts +104 -2
  11. package/src/core/stream-stall-watchdog.ts +41 -17
  12. package/src/export/cost-utils.ts +90 -8
  13. package/src/input/command-registry.ts +16 -0
  14. package/src/input/commands/diff-runtime.ts +61 -30
  15. package/src/input/commands/session-content.ts +12 -0
  16. package/src/input/commands/session-workflow.ts +3 -0
  17. package/src/input/commands/share-runtime.ts +9 -2
  18. package/src/input/handler-content-actions.ts +30 -11
  19. package/src/input/handler-feed-routes.ts +63 -1
  20. package/src/input/handler-feed.ts +12 -0
  21. package/src/input/handler-interactions.ts +2 -0
  22. package/src/input/handler-types.ts +1 -0
  23. package/src/input/handler.ts +4 -0
  24. package/src/input/input-history.ts +17 -4
  25. package/src/main.ts +27 -23
  26. package/src/panels/agent-inspector-shared.ts +33 -10
  27. package/src/panels/base-panel.ts +1 -1
  28. package/src/panels/builtin/development.ts +1 -1
  29. package/src/panels/cockpit-read-model.ts +16 -11
  30. package/src/panels/cost-tracker-panel.ts +28 -5
  31. package/src/panels/debug-panel.ts +11 -5
  32. package/src/panels/diff-panel.ts +122 -22
  33. package/src/panels/docs-panel.ts +9 -0
  34. package/src/panels/file-explorer-panel.ts +9 -0
  35. package/src/panels/git-panel.ts +11 -11
  36. package/src/panels/knowledge-graph-panel.ts +9 -0
  37. package/src/panels/local-auth-panel.ts +10 -0
  38. package/src/panels/panel-list-panel.ts +9 -0
  39. package/src/panels/project-planning-panel.ts +10 -0
  40. package/src/panels/provider-health-tracker.ts +5 -1
  41. package/src/panels/provider-health-views.ts +8 -1
  42. package/src/panels/scrollable-list-panel.ts +9 -0
  43. package/src/panels/session-browser-panel.ts +9 -0
  44. package/src/panels/token-budget-panel.ts +5 -3
  45. package/src/panels/types.ts +13 -0
  46. package/src/panels/work-plan-panel.ts +10 -0
  47. package/src/renderer/agent-detail-modal.ts +42 -12
  48. package/src/renderer/code-block.ts +58 -28
  49. package/src/renderer/context-inspector.ts +3 -6
  50. package/src/renderer/conversation-surface.ts +1 -21
  51. package/src/renderer/diff-view.ts +6 -4
  52. package/src/renderer/fullscreen-primitives.ts +1 -1
  53. package/src/renderer/fullscreen-workspace.ts +2 -7
  54. package/src/renderer/hint-grammar.ts +1 -1
  55. package/src/renderer/history-search-overlay.ts +10 -16
  56. package/src/renderer/markdown.ts +9 -1
  57. package/src/renderer/model-picker-overlay.ts +8 -499
  58. package/src/renderer/model-workspace.ts +9 -24
  59. package/src/renderer/overlay-box.ts +7 -9
  60. package/src/renderer/process-indicator.ts +13 -25
  61. package/src/renderer/process-modal.ts +17 -2
  62. package/src/renderer/profile-picker-modal.ts +13 -14
  63. package/src/renderer/prompt-content-width.ts +16 -0
  64. package/src/renderer/selection-modal-overlay.ts +3 -1
  65. package/src/renderer/semantic-diff.ts +1 -1
  66. package/src/renderer/settings-modal-helpers.ts +10 -34
  67. package/src/renderer/shell-surface.ts +29 -9
  68. package/src/renderer/surface-layout.ts +0 -12
  69. package/src/renderer/term-caps.ts +1 -1
  70. package/src/renderer/tool-call.ts +6 -20
  71. package/src/renderer/ui-factory.ts +98 -14
  72. package/src/renderer/ui-primitives.ts +18 -1
  73. package/src/runtime/bootstrap-command-context.ts +3 -0
  74. package/src/runtime/bootstrap-command-parts.ts +3 -1
  75. package/src/runtime/bootstrap-core.ts +5 -0
  76. package/src/runtime/bootstrap-hook-bridge.ts +5 -0
  77. package/src/runtime/bootstrap-shell.ts +12 -1
  78. package/src/runtime/render-scheduler.ts +80 -0
  79. package/src/utils/splash-lines.ts +10 -2
  80. package/src/version.ts +1 -1
  81. package/src/renderer/file-tree.ts +0 -153
  82. package/src/renderer/progress.ts +0 -100
  83. package/src/renderer/status-token.ts +0 -67
@@ -1,504 +1,13 @@
1
- import { type Line } from '../types/grid.ts';
2
- import { fitDisplay, getDisplayWidth, truncateDisplay } from '../utils/terminal-width.ts';
3
- import { abbreviateCount } from '../utils/format-number.ts';
4
- import type { ModelPickerModal } from '../input/model-picker.ts';
5
- import { EFFORT_DESCRIPTIONS } from '@pellux/goodvibes-sdk/platform/providers';
6
- import { getQualityTier, getQualityTierFromScore } from '@pellux/goodvibes-sdk/platform/providers';
7
- import {
8
- createOverlayBoxLayout,
9
- createOverlayContentLine,
10
- createOverlayFilledBorderLine,
11
- DEFAULT_OVERLAY_PALETTE,
12
- OVERLAY_GLYPHS,
13
- putOverlayText,
14
- } from './overlay-box.ts';
15
- import { getOverlaySurfaceMetrics } from './overlay-viewport.ts';
16
- import { formatHints, type HintSpec } from './hint-grammar.ts';
17
-
18
- /** Format a context window number into a short human-readable string. */
19
- function fmtContext(n: number): string {
20
- return abbreviateCount(n, { rounding: 'round', decimals: 0 });
21
- }
22
-
23
- /** Title text per picker mode. */
24
- const MODE_TITLES: Record<string, string> = {
25
- model: 'Select Model',
26
- provider: 'Select Provider',
27
- effort: 'Select Effort Level',
28
- contextCap: 'Set Context Window',
29
- };
30
-
31
1
  /**
32
2
  * Number of fixed chrome lines in the model-picker overlay (title + search + divider + detail×2 + footer).
33
3
  * Used by callers to compute maxVisible item rows.
34
- */
35
- export const MODEL_PICKER_CHROME_LINES = 7;
36
-
37
- const renderCache = new WeakMap<ModelPickerModal, { key: string; lines: Line[] }>();
38
- const objectIds = new WeakMap<object, number>();
39
- let nextObjectId = 1;
40
-
41
- function putRowText(line: Line, startX: number, maxWidth: number, text: string, fg: string, bg = '', bold = false, dim = false): void {
42
- putOverlayText(line, startX, maxWidth, text, { fg, bg, bold, dim });
43
- }
44
-
45
- /**
46
- * Render the model picker modal as Line[] for overlay in the viewport.
47
- * Handles model, provider, and effort modes.
48
4
  *
49
- * @param maxVisible - Maximum number of item rows to show (controls the scroll window).
50
- * Derived from viewport height minus chrome lines. Defaults to 20.
5
+ * WO-206: this file used to also hold `renderModelPickerOverlay`, the
6
+ * overlay-style model-picker renderer. It was superseded by
7
+ * `renderModelWorkspace` (model-workspace.ts) — conversation-overlays.ts
8
+ * routes `input.modelPicker.active` there, not here — and had no remaining
9
+ * non-test import site, so it (and its dedicated unit/golden tests) were
10
+ * removed. This constant is still live: handler-picker-routes.ts uses it to
11
+ * size the current workspace's visible-row window.
51
12
  */
52
- export function renderModelPickerOverlay(
53
- picker: ModelPickerModal,
54
- width: number,
55
- maxVisible = 20,
56
- viewportHeight?: number,
57
- ): Line[] {
58
- const cacheKey = getRenderCacheKey(picker, width, maxVisible, viewportHeight);
59
- const cached = renderCache.get(picker);
60
- if (cached?.key === cacheKey) return cached.lines;
61
-
62
- const lines: Line[] = [];
63
- const metrics = getOverlaySurfaceMetrics(width, viewportHeight ?? 24, {
64
- chromeRows: MODEL_PICKER_CHROME_LINES,
65
- maxWidth: 72,
66
- minContentRows: 6,
67
- maxContentRows: Math.max(10, maxVisible),
68
- });
69
- const layout = createOverlayBoxLayout(width, metrics.margin, metrics.boxWidth);
70
- const contentW = layout.innerWidth;
71
- const borderFg = DEFAULT_OVERLAY_PALETTE.borderFg;
72
- const titleFg = DEFAULT_OVERLAY_PALETTE.titleFg;
73
- const bodyFg = DEFAULT_OVERLAY_PALETTE.bodyFg;
74
- const mutedFg = DEFAULT_OVERLAY_PALETTE.mutedFg;
75
- const selectedBg = DEFAULT_OVERLAY_PALETTE.selectedBg;
76
-
77
- // ── Title bar ───────────────────────────────────────────────────────────────────────
78
- const titleLine = createOverlayFilledBorderLine(width, layout, OVERLAY_GLYPHS.topLeft, OVERLAY_GLYPHS.horizontal, OVERLAY_GLYPHS.topRight, borderFg, DEFAULT_OVERLAY_PALETTE.titleBg);
79
- putRowText(
80
- titleLine,
81
- layout.margin + 2,
82
- layout.width - 4,
83
- truncateDisplay((MODE_TITLES[picker.mode] ?? MODE_TITLES.model).replace(/^─\s*/, '').trim(), layout.width - 4),
84
- titleFg,
85
- '',
86
- true,
87
- );
88
- lines.push(titleLine);
89
-
90
- // ── Search bar (model and provider modes) ────────────────────────────────────
91
- if (picker.mode === 'model' || picker.mode === 'provider') {
92
- const searchLine = createOverlayContentLine(width, layout, borderFg, DEFAULT_OVERLAY_PALETTE.inputBg);
93
- const searchPrefix = '/ ';
94
- const queryDisplay = picker.query + (picker.searchFocused ? OVERLAY_GLYPHS.cursor : '');
95
- let filterTag = '';
96
- let filterTagW = 0;
97
- if (picker.mode === 'model') {
98
- // Category filter indicator — model mode only
99
- const filterLabels: Record<string, string> = { all: 'All', free: 'Free', paid: 'Paid', subscription: 'Sub' };
100
- const filterLabel = filterLabels[picker.categoryFilter] ?? 'All';
101
- filterTag = `[${filterLabel}]`;
102
- filterTagW = getDisplayWidth(filterTag);
103
- }
104
- const maxQueryW = contentW - getDisplayWidth(searchPrefix) - filterTagW - (filterTagW > 0 ? 2 : 0);
105
- const queryTrunc = getDisplayWidth(queryDisplay) > maxQueryW
106
- ? truncateDisplay(queryDisplay, maxQueryW)
107
- : queryDisplay;
108
- let rowX = layout.margin + 2;
109
- putRowText(searchLine, rowX, getDisplayWidth(searchPrefix), searchPrefix, picker.searchFocused ? bodyFg : mutedFg);
110
- rowX += getDisplayWidth(searchPrefix);
111
- const queryAreaWidth = filterTag
112
- ? Math.max(0, contentW - getDisplayWidth(searchPrefix) - filterTagW - 1)
113
- : Math.max(0, contentW - getDisplayWidth(searchPrefix));
114
- putRowText(searchLine, rowX, queryAreaWidth, fitDisplay(queryTrunc, queryAreaWidth), picker.query.length > 0 || picker.searchFocused ? '#ffffff' : mutedFg);
115
- if (filterTag) {
116
- putRowText(
117
- searchLine,
118
- layout.margin + 2 + contentW - filterTagW,
119
- filterTagW,
120
- filterTag,
121
- mutedFg,
122
- );
123
- }
124
- lines.push(searchLine);
125
-
126
- // Thin divider under search bar
127
- lines.push(createOverlayFilledBorderLine(width, layout, OVERLAY_GLYPHS.teeLeft, OVERLAY_GLYPHS.horizontal, OVERLAY_GLYPHS.teeRight, borderFg, DEFAULT_OVERLAY_PALETTE.sectionBg));
128
- } else {
129
- lines.push(createOverlayContentLine(width, layout, borderFg, DEFAULT_OVERLAY_PALETTE.sectionBg));
130
- }
131
-
132
- if (picker.mode === 'model') {
133
- // ── Model list (grouped, with scroll window) ────────────────────────────────────
134
- const filtered = picker.getFilteredModels();
135
- if (filtered.length === 0) {
136
- const msg = picker.query.length > 0
137
- ? `No models match "${picker.query.length > 20 ? picker.query.slice(0, 20) + '...' : picker.query}"`
138
- : 'No models available';
139
- const noModels = createOverlayContentLine(width, layout, borderFg, DEFAULT_OVERLAY_PALETTE.bodyBg);
140
- putRowText(noModels, layout.margin + 2, contentW, fitDisplay(truncateDisplay(msg, contentW), contentW), '244', '', false, true);
141
- lines.push(noModels);
142
- } else {
143
- // Determine the visible slice [scrollOffset, scrollOffset + maxVisible)
144
- const scrollOffset = Math.max(0, Math.min(picker.scrollOffset, Math.max(0, filtered.length - maxVisible)));
145
- const visibleEnd = Math.min(filtered.length, scrollOffset + maxVisible);
146
- const visibleModels = filtered.slice(scrollOffset, visibleEnd);
147
-
148
- // Scroll indicators
149
- if (scrollOffset > 0) {
150
- const upHint = createOverlayContentLine(width, layout, borderFg, DEFAULT_OVERLAY_PALETTE.sectionBg);
151
- putRowText(upHint, layout.margin + 2, contentW, fitDisplay(`${OVERLAY_GLYPHS.moreAbove} ${scrollOffset} more above`, contentW), mutedFg, '', false, true);
152
- lines.push(upHint);
153
- }
154
-
155
- let lastGroupKey = '';
156
- // Track the absolute index for group header display
157
- // Use getModelGroupKey for synthetic sub-group support (Top Models / All Synthetic)
158
- for (let i = 0; i < visibleModels.length; i++) {
159
- const model = visibleModels[i];
160
- const absIdx = scrollOffset + i; // index into filtered[] for selectedIndex comparison
161
-
162
- // Group header — show when group key changes within the visible window
163
- // For the first visible item, always check if header is needed
164
- const groupKey = picker.getModelGroupKey(model);
165
- if (groupKey !== lastGroupKey) {
166
- const headerRow = createOverlayContentLine(width, layout, borderFg, DEFAULT_OVERLAY_PALETTE.sectionBg);
167
- putRowText(headerRow, layout.margin + 2, contentW, fitDisplay(`[${groupKey}]`, contentW), '#4488cc');
168
- lines.push(headerRow);
169
- lastGroupKey = groupKey;
170
- }
171
-
172
- const isSelected = absIdx === picker.selectedIndex;
173
- const indicator = isSelected ? `${OVERLAY_GLYPHS.selected} ` : ' ';
174
-
175
- // Pre-compute synthetic info once per model (avoid 3 separate lookups per frame)
176
- const synthInfo = model.provider === 'synthetic' ? picker.getSyntheticModelInfo(model.id) : null;
177
-
178
- // Quality tier badge: [S] / [A] / [B] / [C]
179
- let tier: string | null = null;
180
- if (model.provider === 'synthetic') {
181
- if (synthInfo?.bestCompositeScore != null) {
182
- tier = getQualityTierFromScore(synthInfo.bestCompositeScore);
183
- }
184
- } else {
185
- const bData = picker.getBenchmarkEntry(model);
186
- tier = bData ? getQualityTier(bData.benchmarks) : null;
187
- }
188
- const tierBadge = tier ? `[${tier}]` : ' ';
189
- // Pin marker: keep the Unicode star instead of ASCII fallback
190
- const pinStar = picker.pinnedIds.has(model.id) ? '★ ' : ' ';
191
- // Free badge: dot marker, not an asterisk
192
- const freeBadge = model.tier === 'free' ? '•' : ' ';
193
- // Provider count for synthetic models
194
- let providerCountStr = ' '; // 5 chars wide (fixed)
195
- if (synthInfo) {
196
- const countLabel = `(${synthInfo.keyedBackendCount}p)`;
197
- providerCountStr = countLabel.padEnd(5);
198
- }
199
-
200
- // Layout: indicator(2) + pin(2) + id(maxIdLen) + gap(2) + name(remaining) + provCount(5) + free(1) + tier(3)
201
- const maxIdLen = 20;
202
- const provCountW = 5;
203
- const badgesW = 3 + 1 + 2; // tierBadge(3) + freeBadge(1) + gap(2)
204
- const idStr = model.id.length > maxIdLen
205
- ? model.id.slice(0, maxIdLen - 3) + '...'
206
- : model.id.padEnd(maxIdLen);
207
- const remaining = contentW - maxIdLen - 4 - badgesW - 2 - provCountW; // 4 = indicator+pin, 2 = gap before name
208
- const nameStr = model.displayName.length > Math.max(0, remaining)
209
- ? model.displayName.slice(0, Math.max(0, remaining) - 3) + '...'
210
- : model.displayName.padEnd(Math.max(0, remaining));
211
-
212
- const row = createOverlayContentLine(width, layout, borderFg, isSelected ? selectedBg : DEFAULT_OVERLAY_PALETTE.bodyBg);
213
- let x = layout.margin + 2;
214
- const rowText = indicator + pinStar + idStr + ' ' + nameStr + providerCountStr + ' ' + freeBadge + tierBadge;
215
- putRowText(row, x, contentW, fitDisplay(truncateDisplay(rowText, contentW), contentW), isSelected ? titleFg : bodyFg, isSelected ? selectedBg : DEFAULT_OVERLAY_PALETTE.bodyBg, isSelected);
216
- lines.push(row);
217
- }
218
-
219
- if (visibleEnd < filtered.length) {
220
- const remaining2 = filtered.length - visibleEnd;
221
- const downHint = createOverlayContentLine(width, layout, borderFg, DEFAULT_OVERLAY_PALETTE.sectionBg);
222
- putRowText(downHint, layout.margin + 2, contentW, fitDisplay(`${OVERLAY_GLYPHS.moreBelow} ${remaining2} more below`, contentW), mutedFg, '', false, true);
223
- lines.push(downHint);
224
- }
225
- }
226
-
227
- // ── Divider ────────────────────────────────────────────────────────────────────
228
- lines.push(createOverlayFilledBorderLine(width, layout, OVERLAY_GLYPHS.teeLeft, OVERLAY_GLYPHS.horizontal, OVERLAY_GLYPHS.teeRight, borderFg, DEFAULT_OVERLAY_PALETTE.sectionBg));
229
-
230
- // ── Capability detail for selected model ────────────────────────────────────────────
231
- const selected = picker.getSelected();
232
- if (selected) {
233
- const providerLine = createOverlayContentLine(width, layout, borderFg, DEFAULT_OVERLAY_PALETTE.bodyBg);
234
- putRowText(providerLine, layout.margin + 2, contentW, fitDisplay(`Provider: ${selected.provider}`, contentW), '244');
235
- lines.push(providerLine);
236
-
237
- // Synthetic models show fallback ladder rungs instead of capabilities.
238
- const syntheticChain = selected.provider === 'synthetic' ? picker.getSyntheticChain(selected.id) : null;
239
- if (syntheticChain !== null && syntheticChain.length > 0) {
240
- const rung0 = syntheticChain[0];
241
- const rest = syntheticChain.length - 1;
242
- const rung0Base = ` 0. ${rung0.provider}/${rung0.model}`;
243
- const rung0Label = rest > 0 ? `${rung0Base} (+${rest} more)` : rung0Base;
244
- const chainLine0 = createOverlayContentLine(width, layout, borderFg, DEFAULT_OVERLAY_PALETTE.bodyBg);
245
- putRowText(chainLine0, layout.margin + 2, contentW, fitDisplay(truncateDisplay(rung0Label, contentW), contentW), '244');
246
- lines.push(chainLine0);
247
- } else {
248
- const caps = selected.capabilities ?? { reasoning: false, multimodal: false, toolCalling: false, codeEditing: false };
249
- const ctxStr = `Context: ${fmtContext(selected.contextWindow)}`;
250
- const capParts: string[] = [ctxStr];
251
- if (caps.reasoning) capParts.push('Reasoning: \u2713');
252
- if (caps.multimodal) capParts.push('Vision: \u2713');
253
- if (caps.toolCalling) capParts.push('Tools: \u2713');
254
- if (caps.codeEditing) capParts.push('Code: \u2713');
255
- const capText = capParts.join(' ');
256
- const capLine = createOverlayContentLine(width, layout, borderFg, DEFAULT_OVERLAY_PALETTE.bodyBg);
257
- putRowText(capLine, layout.margin + 2, contentW, fitDisplay(truncateDisplay(capText, contentW), contentW), '244');
258
- lines.push(capLine);
259
- }
260
- } else {
261
- lines.push(createOverlayContentLine(width, layout, borderFg, DEFAULT_OVERLAY_PALETTE.bodyBg));
262
- lines.push(createOverlayContentLine(width, layout, borderFg, DEFAULT_OVERLAY_PALETTE.bodyBg));
263
- }
264
- } else if (picker.mode === 'provider') {
265
- // ── Provider list (grouped: Popular / All Providers) ───────────────────────────────────
266
- const allProviderItems = picker.getItems(); // includes group headers
267
- const selectableCount = picker.getFilteredProviders().length;
268
- if (selectableCount === 0) {
269
- const msg = picker.query.length > 0
270
- ? `No providers match "${picker.query.length > 20 ? picker.query.slice(0, 20) + '...' : picker.query}"`
271
- : 'No providers available';
272
- const noProviders = createOverlayContentLine(width, layout, borderFg, DEFAULT_OVERLAY_PALETTE.bodyBg);
273
- putRowText(noProviders, layout.margin + 2, contentW, fitDisplay(truncateDisplay(msg, contentW), contentW), '244', '', false, true);
274
- lines.push(noProviders);
275
- } else {
276
- // Build the flat selectable index → item-list-index mapping for scroll tracking
277
- // scrollOffset / selectedIndex track selectable items only
278
- const providerScrollOffset = Math.max(0, Math.min(picker.scrollOffset, Math.max(0, selectableCount - maxVisible)));
279
- const providerVisibleEnd = Math.min(selectableCount, providerScrollOffset + maxVisible);
280
-
281
- // Scroll indicator — items above
282
- if (providerScrollOffset > 0) {
283
- const upHint = createOverlayContentLine(width, layout, borderFg, DEFAULT_OVERLAY_PALETTE.sectionBg);
284
- putRowText(upHint, layout.margin + 2, contentW, fitDisplay(`${OVERLAY_GLYPHS.moreAbove} ${providerScrollOffset} more above`, contentW), mutedFg, '', false, true);
285
- lines.push(upHint);
286
- }
287
-
288
- // Walk all provider items (headers + selectables), rendering only selectables
289
- // in [providerScrollOffset, providerVisibleEnd). Headers are shown when the
290
- // first selectable item in their group is visible.
291
- let selectableIdx = -1;
292
- let pendingHeader: string | null = null;
293
-
294
- for (const item of allProviderItems) {
295
- if (item.isGroupHeader) {
296
- pendingHeader = item.label;
297
- continue;
298
- }
299
- selectableIdx++;
300
- if (selectableIdx < providerScrollOffset) {
301
- pendingHeader = null; // group header passed, no longer pending
302
- continue;
303
- }
304
- if (selectableIdx >= providerVisibleEnd) break;
305
-
306
- // Emit pending group header before first visible item in the group
307
- if (pendingHeader !== null) {
308
- const headerRow = createOverlayContentLine(width, layout, borderFg, DEFAULT_OVERLAY_PALETTE.sectionBg);
309
- putRowText(headerRow, layout.margin + 2, contentW, fitDisplay(`[${pendingHeader}]`, contentW), '#4488cc');
310
- lines.push(headerRow);
311
- pendingHeader = null;
312
- }
313
-
314
- const isSelected = selectableIdx === picker.selectedIndex;
315
- const indicator = isSelected ? `${OVERLAY_GLYPHS.selected} ` : ' ';
316
- const checkmark = item.isConfigured ? '✓ ' : ' ';
317
- // configuredVia badge: right-aligned short label (env/sub/anon)
318
- const viaBadge = item.configuredVia === 'env' ? ' [env]'
319
- : item.configuredVia === 'secrets' ? ' [key]'
320
- : item.configuredVia === 'subscription' ? ' [sub]'
321
- : item.configuredVia === 'anonymous' ? ' [anon]'
322
- : '';
323
- const badgeW = viaBadge.length;
324
- const labelW = contentW - 2 - 2 - badgeW; // indicator(2) + checkmark(2) + badge
325
- const labelStr = item.label.length > labelW
326
- ? item.label.slice(0, labelW - 3) + '...'
327
- : item.label.padEnd(labelW);
328
- const row = createOverlayContentLine(width, layout, borderFg, isSelected ? selectedBg : DEFAULT_OVERLAY_PALETTE.bodyBg);
329
- const rowText = indicator + checkmark + labelStr + viaBadge;
330
- putRowText(row, layout.margin + 2, contentW, fitDisplay(truncateDisplay(rowText, contentW), contentW), isSelected ? titleFg : bodyFg, isSelected ? selectedBg : DEFAULT_OVERLAY_PALETTE.bodyBg, isSelected);
331
- lines.push(row);
332
- }
333
-
334
- // Scroll indicator — items below
335
- if (providerVisibleEnd < selectableCount) {
336
- const remaining2 = selectableCount - providerVisibleEnd;
337
- const downHint = createOverlayContentLine(width, layout, borderFg, DEFAULT_OVERLAY_PALETTE.sectionBg);
338
- putRowText(downHint, layout.margin + 2, contentW, fitDisplay(`${OVERLAY_GLYPHS.moreBelow} ${remaining2} more below`, contentW), mutedFg, '', false, true);
339
- lines.push(downHint);
340
- }
341
- }
342
-
343
- // ── Divider + hint ──────────────────────────────────────────────────────────────────
344
- lines.push(createOverlayFilledBorderLine(width, layout, OVERLAY_GLYPHS.teeLeft, OVERLAY_GLYPHS.horizontal, OVERLAY_GLYPHS.teeRight, borderFg, DEFAULT_OVERLAY_PALETTE.sectionBg));
345
- const hintLine = createOverlayContentLine(width, layout, borderFg, DEFAULT_OVERLAY_PALETTE.bodyBg);
346
- putRowText(hintLine, layout.margin + 2, contentW, fitDisplay('Select a provider to browse its models', contentW), '244');
347
- lines.push(hintLine);
348
- lines.push(createOverlayContentLine(width, layout, borderFg, DEFAULT_OVERLAY_PALETTE.bodyBg));
349
- } else if (picker.mode === 'contextCap') {
350
- // ── Context cap input ──────────────────────────────────────────────────────────────
351
- const capModel = picker.contextCapPendingModel;
352
- const modelName = capModel ? capModel.displayName : 'unknown';
353
- const currentCtx = capModel ? fmtContext(capModel.contextWindow) : '?';
354
- const provenance = capModel?.contextWindowProvenance ?? 'configured_cap';
355
-
356
- const promptLabel = 'Context window (tokens):';
357
- const cursorChar = OVERLAY_GLYPHS.cursor;
358
- const inputDisplay = picker.contextCapQuery + cursorChar;
359
- const promptRow = createOverlayContentLine(width, layout, borderFg, DEFAULT_OVERLAY_PALETTE.inputBg);
360
- putRowText(promptRow, layout.margin + 2, contentW, fitDisplay(`${promptLabel} ${inputDisplay}`, contentW), '#ffffff');
361
- lines.push(promptRow);
362
-
363
- if (picker.contextCapError) {
364
- const errRow = createOverlayContentLine(width, layout, borderFg, DEFAULT_OVERLAY_PALETTE.bodyBg);
365
- putRowText(errRow, layout.margin + 2, contentW, fitDisplay(picker.contextCapError, contentW), '#ff6666');
366
- lines.push(errRow);
367
- }
368
-
369
- lines.push(createOverlayContentLine(width, layout, borderFg, DEFAULT_OVERLAY_PALETTE.bodyBg));
370
-
371
- const hintText = `Leave blank to use default (current: ${currentCtx}, source: ${provenance})`;
372
- const hintTrunc = getDisplayWidth(hintText) > contentW
373
- ? hintText.slice(0, contentW - 3) + '...'
374
- : hintText;
375
- const hintRow = createOverlayContentLine(width, layout, borderFg, DEFAULT_OVERLAY_PALETTE.bodyBg);
376
- putRowText(hintRow, layout.margin + 2, contentW, fitDisplay(hintTrunc, contentW), '244', '', false, true);
377
- lines.push(hintRow);
378
-
379
- // Divider + model info
380
- lines.push(createOverlayFilledBorderLine(width, layout, OVERLAY_GLYPHS.teeLeft, OVERLAY_GLYPHS.horizontal, OVERLAY_GLYPHS.teeRight, borderFg, DEFAULT_OVERLAY_PALETTE.sectionBg));
381
- const modelInfoLine = createOverlayContentLine(width, layout, borderFg, DEFAULT_OVERLAY_PALETTE.bodyBg);
382
- putRowText(modelInfoLine, layout.margin + 2, contentW, fitDisplay(`Model: ${modelName}`, contentW), '244');
383
- lines.push(modelInfoLine);
384
- lines.push(createOverlayContentLine(width, layout, borderFg, DEFAULT_OVERLAY_PALETTE.bodyBg));
385
- } else {
386
- // ── Effort list ────────────────────────────────────────────────────────────────────────
387
- for (let i = 0; i < picker.effortLevels.length; i++) {
388
- const level = picker.effortLevels[i];
389
- const isSelected = i === picker.selectedIndex;
390
- const indicator = isSelected ? `${OVERLAY_GLYPHS.selected} ` : ' ';
391
- const desc = EFFORT_DESCRIPTIONS[level] ?? '';
392
- const labelW = 10;
393
- const labelStr = level.padEnd(labelW);
394
- const remaining = contentW - labelW - 4;
395
- const descStr = desc.length > remaining ? desc.slice(0, remaining - 3) + '...' : desc.padEnd(remaining);
396
- const row = createOverlayContentLine(width, layout, borderFg, isSelected ? selectedBg : DEFAULT_OVERLAY_PALETTE.bodyBg);
397
- const rowText = indicator + labelStr + ' ' + descStr;
398
- putRowText(row, layout.margin + 2, contentW, fitDisplay(truncateDisplay(rowText, contentW), contentW), isSelected ? titleFg : bodyFg, isSelected ? selectedBg : DEFAULT_OVERLAY_PALETTE.bodyBg, isSelected);
399
- lines.push(row);
400
- }
401
-
402
- // ── Divider + model context ──────────────────────────────────────────────────────
403
- lines.push(createOverlayFilledBorderLine(width, layout, OVERLAY_GLYPHS.teeLeft, OVERLAY_GLYPHS.horizontal, OVERLAY_GLYPHS.teeRight, borderFg, DEFAULT_OVERLAY_PALETTE.sectionBg));
404
- const modelName = picker.pendingModel ? picker.pendingModel.displayName : 'unknown';
405
- const modelLine = createOverlayContentLine(width, layout, borderFg, DEFAULT_OVERLAY_PALETTE.bodyBg);
406
- putRowText(modelLine, layout.margin + 2, contentW, fitDisplay(`Model: ${modelName}`, contentW), '244');
407
- lines.push(modelLine);
408
- lines.push(createOverlayContentLine(width, layout, borderFg, DEFAULT_OVERLAY_PALETTE.bodyBg));
409
- }
410
-
411
- // ── Bottom border with hints ─────────────────────────────────────────────────────────
412
- const filterLabelsFooter: Record<string, string> = { all: 'All', free: 'Free', paid: 'Paid', subscription: 'Sub' };
413
- const filterLabelFooter = filterLabelsFooter[picker.categoryFilter] ?? 'All';
414
- const groupByLabel = picker.groupBy ?? 'provider';
415
- const selectedModel = picker.mode === 'model' ? picker.getSelected() : null;
416
- const showContextCapHint = selectedModel != null && selectedModel.contextWindowProvenance !== undefined;
417
- let hints: string;
418
- if (picker.mode === 'model') {
419
- // The model-picker box caps at 72 cols, so the footer stays compact: the
420
- // list nav (Up/Down/Enter) is the obvious interaction and lives in the
421
- // /shortcuts overlay; the footer prioritizes the stateful controls. The
422
- // live filter/group value is folded into each key's verb so it stays
423
- // adjacent to its control and reads inside the width.
424
- const modelHints: HintSpec[] = [{ key: '/', verb: 'Search' }];
425
- if (showContextCapHint) modelHints.push({ key: 'Space', verb: 'Ctx' });
426
- modelHints.push(
427
- { key: 'Tab', verb: `Filter: ${filterLabelFooter}` },
428
- { key: 'G', verb: `Group: ${groupByLabel}` },
429
- { key: 'Esc', verb: 'Close' },
430
- );
431
- hints = formatHints(modelHints);
432
- } else if (picker.mode === 'contextCap') {
433
- hints = formatHints([{ key: 'Enter', verb: 'Confirm' }, { key: 'Esc', verb: 'Cancel' }]);
434
- } else {
435
- hints = formatHints([{ key: 'Up/Down', verb: 'Move' }, { key: 'Enter', verb: 'Select' }, { key: 'Esc', verb: 'Cancel' }]);
436
- }
437
- const footerLine = createOverlayFilledBorderLine(width, layout, OVERLAY_GLYPHS.bottomLeft, OVERLAY_GLYPHS.horizontal, OVERLAY_GLYPHS.bottomRight, borderFg, DEFAULT_OVERLAY_PALETTE.sectionBg);
438
- putRowText(footerLine, layout.margin + 2, contentW, fitDisplay(truncateDisplay(hints, contentW), contentW), mutedFg, '', false, true);
439
- lines.push(footerLine);
440
-
441
- renderCache.set(picker, { key: cacheKey, lines });
442
- return lines;
443
- }
444
-
445
- function getRenderCacheKey(
446
- picker: ModelPickerModal,
447
- width: number,
448
- maxVisible: number,
449
- viewportHeight: number | undefined,
450
- ): string {
451
- const base = [
452
- width,
453
- maxVisible,
454
- viewportHeight ?? '',
455
- picker.mode,
456
- picker.target,
457
- picker.query,
458
- picker.searchFocused ? 1 : 0,
459
- picker.selectedIndex,
460
- picker.scrollOffset,
461
- picker.categoryFilter,
462
- picker.capabilityFilter,
463
- picker.availableOnly ? 1 : 0,
464
- picker.benchmarkSort,
465
- picker.groupBy,
466
- keyForSet(picker.pinnedIds),
467
- keyForSet(picker.configuredProviders),
468
- ];
469
-
470
- if (picker.mode === 'model') {
471
- const filtered = picker.getFilteredModels();
472
- const selected = filtered[picker.selectedIndex];
473
- base.push(objectId(picker.models), objectId(filtered), filtered.length, selected?.registryKey ?? selected?.id ?? '');
474
- } else if (picker.mode === 'provider') {
475
- const filteredProviders = picker.getFilteredProviders();
476
- base.push(objectId(picker.providers), objectId(filteredProviders), filteredProviders.length, keyForMap(picker.configuredViaMap));
477
- } else if (picker.mode === 'effort') {
478
- base.push(objectId(picker.effortLevels), picker.effortLevels.join('\u001f'), picker.pendingModel?.registryKey ?? picker.pendingModel?.id ?? '');
479
- } else if (picker.mode === 'contextCap') {
480
- base.push(picker.contextCapQuery, picker.contextCapPendingModel?.registryKey ?? picker.contextCapPendingModel?.id ?? '', picker.contextCapError ?? '');
481
- }
482
-
483
- return base.join('\u001e');
484
- }
485
-
486
- function objectId(value: object): number {
487
- const existing = objectIds.get(value);
488
- if (existing !== undefined) return existing;
489
- const next = nextObjectId++;
490
- objectIds.set(value, next);
491
- return next;
492
- }
493
-
494
- function keyForSet(values: ReadonlySet<string>): string {
495
- return values.size === 0 ? '' : [...values].sort().join('\u001f');
496
- }
497
-
498
- function keyForMap(values: ReadonlyMap<string, string | undefined>): string {
499
- if (values.size === 0) return '';
500
- return [...values.entries()]
501
- .sort(([left], [right]) => left.localeCompare(right))
502
- .map(([key, value]) => `${key}\u001d${value ?? ''}`)
503
- .join('\u001f');
504
- }
13
+ export const MODEL_PICKER_CHROME_LINES = 7;
@@ -10,22 +10,22 @@ import type { ModelPickerModal } from '../input/model-picker.ts';
10
10
  import type { ModelPickerTargetInfo } from '../input/model-picker.ts';
11
11
  import type { Line } from '../types/grid.ts';
12
12
  import { createEmptyLine, createStyledCell } from '../types/grid.ts';
13
- import { getDisplayWidth, wrapText } from '../utils/terminal-width.ts';
13
+ import { fitDisplay, getDisplayWidth, wrapText } from '../utils/terminal-width.ts';
14
14
  import { abbreviateCount } from '../utils/format-number.ts';
15
15
  import { GLYPHS, UI_TONES } from './ui-primitives.ts';
16
16
 
17
17
  const PALETTE = {
18
- border: '#64748b',
18
+ border: UI_TONES.border,
19
19
  title: '#67e8f9',
20
- subtitle: '#93c5fd',
21
- text: '#e2e8f0',
22
- muted: '#94a3b8',
23
- dim: '#64748b',
24
- selectedBg: '#223049',
20
+ subtitle: UI_TONES.accent.conversation,
21
+ text: UI_TONES.fg.primary,
22
+ muted: UI_TONES.fg.muted,
23
+ dim: UI_TONES.border,
24
+ selectedBg: UI_TONES.bg.selected,
25
25
  targetBg: '#141b25',
26
26
  detailBg: '#121923',
27
27
  bodyBg: '#0f141d',
28
- footerBg: '#111827',
28
+ footerBg: UI_TONES.bg.footer,
29
29
  good: UI_TONES.state.good,
30
30
  warn: UI_TONES.state.warn,
31
31
  info: UI_TONES.state.info,
@@ -100,23 +100,8 @@ function drawVertical(line: Line, x: number, bg = ''): void {
100
100
  line[x] = createStyledCell(GLYPHS.frame.vertical, { fg: PALETTE.border, bg });
101
101
  }
102
102
 
103
- function clipDisplay(text: string, width: number): string {
104
- if (width <= 0) return '';
105
- let used = 0;
106
- let output = '';
107
- for (const ch of text) {
108
- const chWidth = getDisplayWidth(ch);
109
- if (chWidth <= 0) continue;
110
- if (used + chWidth > width) break;
111
- output += ch;
112
- used += chWidth;
113
- }
114
- return output;
115
- }
116
-
117
103
  function padDisplay(text: string, width: number): string {
118
- const clipped = clipDisplay(text, width);
119
- return clipped + ' '.repeat(Math.max(0, width - getDisplayWidth(clipped)));
104
+ return fitDisplay(text, width, '');
120
105
  }
121
106
 
122
107
  function stableWindow(total: number, selected: number, visible: number): { start: number; end: number } {
@@ -53,7 +53,13 @@ export function createOverlayBoxLayout(
53
53
  maxWidth: number,
54
54
  ): OverlayBoxLayout {
55
55
  const resolvedMaxWidth = getOverlayMaxWidth(terminalWidth, margin, maxWidth);
56
- const width = Math.max(20, Math.min(terminalWidth - margin * 2, resolvedMaxWidth));
56
+ // The 20-column floor exists so overlays stay legible on ordinary narrow
57
+ // terminals, but on a hostile-narrow terminal (terminalWidth smaller than
58
+ // margin*2 + 20) that floor alone can push the box wider than the real
59
+ // terminal, walking cells off the right edge of the line array. Clamp the
60
+ // floored result back down to what actually fits before returning it.
61
+ const availableWidth = Math.max(1, terminalWidth - margin * 2);
62
+ const width = Math.min(availableWidth, Math.max(20, Math.min(availableWidth, resolvedMaxWidth)));
57
63
  const contentWidth = width - 2;
58
64
  const innerWidth = contentWidth - 2;
59
65
  return { margin, width, contentWidth, innerWidth };
@@ -128,14 +134,6 @@ export function createOverlayFilledBorderLine(
128
134
  return line;
129
135
  }
130
136
 
131
- export function createOverlayFrameLine(
132
- terminalWidth: number,
133
- layout: OverlayBoxLayout,
134
- bg = DEFAULT_OVERLAY_PALETTE.bodyBg,
135
- ): Line {
136
- return createOverlayContentLine(terminalWidth, layout, DEFAULT_OVERLAY_PALETTE.borderFg, bg);
137
- }
138
-
139
137
  export const OVERLAY_GLYPHS = {
140
138
  topLeft: GLYPHS.frame.topLeft,
141
139
  topRight: GLYPHS.frame.topRight,