@pellux/goodvibes-tui 0.29.0 → 1.0.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 (43) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/src/core/conversation-line-cache.ts +432 -0
  5. package/src/core/conversation-rendering.ts +11 -3
  6. package/src/core/conversation.ts +50 -4
  7. package/src/input/handler-content-actions.ts +8 -3
  8. package/src/input/input-history.ts +17 -4
  9. package/src/main.ts +14 -10
  10. package/src/panels/base-panel.ts +1 -1
  11. package/src/panels/diff-panel.ts +10 -4
  12. package/src/panels/git-panel.ts +2 -2
  13. package/src/renderer/agent-detail-modal.ts +37 -8
  14. package/src/renderer/code-block.ts +58 -28
  15. package/src/renderer/context-inspector.ts +3 -6
  16. package/src/renderer/conversation-surface.ts +1 -21
  17. package/src/renderer/diff-view.ts +6 -4
  18. package/src/renderer/fullscreen-primitives.ts +1 -1
  19. package/src/renderer/fullscreen-workspace.ts +2 -7
  20. package/src/renderer/hint-grammar.ts +1 -1
  21. package/src/renderer/history-search-overlay.ts +10 -16
  22. package/src/renderer/markdown.ts +9 -1
  23. package/src/renderer/model-picker-overlay.ts +8 -499
  24. package/src/renderer/model-workspace.ts +9 -24
  25. package/src/renderer/overlay-box.ts +7 -9
  26. package/src/renderer/process-indicator.ts +13 -25
  27. package/src/renderer/profile-picker-modal.ts +13 -14
  28. package/src/renderer/prompt-content-width.ts +16 -0
  29. package/src/renderer/selection-modal-overlay.ts +3 -1
  30. package/src/renderer/semantic-diff.ts +1 -1
  31. package/src/renderer/settings-modal-helpers.ts +10 -34
  32. package/src/renderer/shell-surface.ts +21 -8
  33. package/src/renderer/surface-layout.ts +0 -12
  34. package/src/renderer/term-caps.ts +1 -1
  35. package/src/renderer/tool-call.ts +6 -20
  36. package/src/renderer/ui-factory.ts +7 -7
  37. package/src/renderer/ui-primitives.ts +18 -1
  38. package/src/runtime/render-scheduler.ts +80 -0
  39. package/src/utils/splash-lines.ts +10 -2
  40. package/src/version.ts +1 -1
  41. package/src/renderer/file-tree.ts +0 -153
  42. package/src/renderer/progress.ts +0 -100
  43. package/src/renderer/status-token.ts +0 -67
@@ -13,14 +13,9 @@ import {
13
13
  } from './fullscreen-primitives.ts';
14
14
 
15
15
  export {
16
- borderLine,
17
16
  clamp,
18
- contentLine,
19
- fillRange,
20
- makeLine,
21
17
  padDisplay,
22
18
  stableWindow,
23
- writeText,
24
19
  } from './fullscreen-primitives.ts';
25
20
  export { FULLSCREEN_PALETTE as WORKSPACE_PALETTE } from './fullscreen-primitives.ts';
26
21
 
@@ -63,12 +58,12 @@ export interface FullscreenWorkspaceMetrics {
63
58
  readonly controlRows: number;
64
59
  }
65
60
 
66
- export function drawVertical(line: Line, x: number, bg = ''): void {
61
+ function drawVertical(line: Line, x: number, bg = ''): void {
67
62
  if (x <= 0 || x >= line.length - 1) return;
68
63
  drawVerticalRule(line, x, WORKSPACE_PALETTE.border, bg);
69
64
  }
70
65
 
71
- export function drawHorizontalRange(line: Line, startX: number, endX: number, bg = ''): void {
66
+ function drawHorizontalRange(line: Line, startX: number, endX: number, bg = ''): void {
72
67
  drawHorizontalRule(line, Math.max(1, startX), Math.min(line.length - 2, endX), WORKSPACE_PALETTE.border, bg);
73
68
  }
74
69
 
@@ -28,7 +28,7 @@ function isEscapeHint(spec: HintSpec): boolean {
28
28
  }
29
29
 
30
30
  /** Render one hint as `[Key] Verb` (or `[Key]` when it has no verb). */
31
- export function formatHint(spec: HintSpec): string {
31
+ function formatHint(spec: HintSpec): string {
32
32
  return spec.verb ? `[${spec.key}] ${spec.verb}` : `[${spec.key}]`;
33
33
  }
34
34
 
@@ -1,27 +1,15 @@
1
1
  import type { Line } from '../types/grid.ts';
2
- import { getDisplayWidth } from '../utils/terminal-width.ts';
2
+ import { fitDisplay, getDisplayWidth } from '../utils/terminal-width.ts';
3
3
  import type { HistorySearch } from '../input/input-history.ts';
4
4
  import { createBottomBarLine, writeBottomBarText } from './bottom-bar.ts';
5
+ import { formatHints } from './hint-grammar.ts';
5
6
 
6
7
  /**
7
8
  * Truncate `text` to at most `maxWidth` display columns, then pad with spaces
8
9
  * to exactly `maxWidth` columns. CJK/emoji wide characters count as 2 columns.
9
10
  */
10
11
  function truncateToWidth(text: string, maxWidth: number): string {
11
- let usedWidth = 0;
12
- let result = '';
13
- let i = 0;
14
- while (i < text.length) {
15
- const code = text.codePointAt(i)!;
16
- const charLen = code > 0xFFFF ? 2 : 1;
17
- const charWidth = getDisplayWidth(text.slice(i, i + charLen));
18
- if (usedWidth + charWidth > maxWidth) break;
19
- result += text.slice(i, i + charLen);
20
- usedWidth += charWidth;
21
- i += charLen;
22
- }
23
- // Pad to exactly maxWidth columns with spaces
24
- return result + ' '.repeat(maxWidth - usedWidth);
12
+ return fitDisplay(text, maxWidth, '');
25
13
  }
26
14
 
27
15
  /**
@@ -49,7 +37,13 @@ export function renderHistorySearchOverlay(
49
37
 
50
38
  // Build the display string
51
39
  const label = prefix + queryPart;
52
- const full = truncateToWidth(label + matchText, width);
40
+ const hints = formatHints([
41
+ { key: 'Ctrl+R/↑', verb: 'Older' },
42
+ { key: 'Ctrl+S/↓', verb: 'Newer' },
43
+ { key: 'Enter', verb: 'Accept' },
44
+ { key: 'Esc', verb: 'Cancel' },
45
+ ]);
46
+ const full = truncateToWidth(`${label}${matchText} ${hints}`, width);
53
47
 
54
48
  const line = createBottomBarLine(width, { fg: '#000000', bg: '#00ffcc' });
55
49
  writeBottomBarText(line, 0, width, full, { fg: '#000000', bg: '#00ffcc' });
@@ -310,7 +310,15 @@ function renderTable(rows: string[], width: number, indent: number): Line[] {
310
310
  for (const ch of text) {
311
311
  const cw = getDisplayWidth(ch);
312
312
  if (w + cw > maxW) {
313
- // Truncate with ellipsis
313
+ // Truncate with ellipsis. If the last cell pushed is a wide-glyph
314
+ // placeholder (the empty second column of a 2-wide character),
315
+ // overwriting it in place would leave the wide glyph's first
316
+ // column dangling next to the ellipsis with no placeholder cell \u2014
317
+ // step back one more cell so the ellipsis replaces the actual
318
+ // glyph, not its placeholder.
319
+ if (cells.length > 0 && cells[cells.length - 1].char === '' && cells.length > 1) {
320
+ cells.pop();
321
+ }
314
322
  if (cells.length > 0) cells[cells.length - 1] = createStyledCell('\u2026', cells[cells.length - 1]);
315
323
  return cells;
316
324
  }
@@ -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;