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