@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
@@ -9,7 +9,25 @@ import { logger } from '@pellux/goodvibes-sdk/platform/utils';
9
9
  import { getOverlaySurfaceMetrics, getStableOverlayContentRows } from './overlay-viewport.ts';
10
10
  import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
11
11
  import { handleConfirmInput, type ConfirmState } from '../panels/confirm-state.ts';
12
- import { AGENT_TERMINAL_STATUSES as MODAL_TERMINAL_STATUSES, AGENT_STALL_THRESHOLD_MS as MODAL_STALL_THRESHOLD_MS } from '../panels/agent-inspector-shared.ts';
12
+ import { AGENT_TERMINAL_STATUSES as MODAL_TERMINAL_STATUSES, AGENT_STALL_THRESHOLD_MS as MODAL_STALL_THRESHOLD_MS, hasReportedUsage } from '../panels/agent-inspector-shared.ts';
13
+ import { UI_TONES } from './ui-primitives.ts';
14
+
15
+ /**
16
+ * Agent-detail-modal neon accent palette — WRFC/streaming panel-native hues
17
+ * with no UI_TONES.state/accent role. Preserved byte-exact as named
18
+ * accents per WO-207b (2026-07-02-renderer-plan.md decisions: "Agent-detail
19
+ * neon palette: preserved byte-exact as named accents, zero visual change").
20
+ */
21
+ const AGENT_DETAIL_NEON = {
22
+ /** Addendum note + streaming content text. */
23
+ mint: '#aaffee',
24
+ /** Unsatisfied WRFC findings + agent error text. */
25
+ bad: '#ff6666',
26
+ /** Satisfied WRFC findings. */
27
+ good: '#44ff88',
28
+ /** Progress line + streaming label. */
29
+ accent: '#00ffcc',
30
+ } as const;
13
31
 
14
32
  // ─── Constants ────────────────────────────────────────────────────────────────
15
33
 
@@ -19,6 +37,17 @@ const AGENT_ID_DISPLAY_LENGTH = 16;
19
37
  // MODAL_TERMINAL_STATUSES and MODAL_STALL_THRESHOLD_MS are re-exported aliases
20
38
  // from agent-inspector-shared.ts (imported above alongside ConfirmState).
21
39
 
40
+ /**
41
+ * formatStalledLabel — the "[STALLED — N+ min no activity]" suffix appended
42
+ * to the Status line for a stalled agent. The minute count is derived from
43
+ * the stall threshold (in ms) rather than hardcoded, so the label can never
44
+ * drift out of sync with the actual threshold that decided isStalled.
45
+ */
46
+ export function formatStalledLabel(thresholdMs: number): string {
47
+ const minutes = Math.floor(thresholdMs / 60000);
48
+ return ` [STALLED — ${minutes}+ min no activity]`;
49
+ }
50
+
22
51
  export interface AgentDetailModalDeps {
23
52
  readonly agentManager: Pick<AgentManager, 'getStatus' | 'list'>;
24
53
  readonly agentMessageBus: Pick<AgentMessageBus, 'getMessages'>;
@@ -248,16 +277,17 @@ export function renderAgentDetailModal(
248
277
  sections.push({ type: 'text', content: `Template : ${rec.template}` });
249
278
  sections.push({ type: 'text', content: `Model : ${modelStr}` });
250
279
  const isStalled = !MODAL_TERMINAL_STATUSES.has(rec.status) && (now - rec.startedAt) >= MODAL_STALL_THRESHOLD_MS;
251
- sections.push({ type: 'text', content: `Status : ${rec.status}${isStalled ? ' [STALLED — 5+ min no activity]' : ''}` });
280
+ sections.push({ type: 'text', content: `Status : ${rec.status}${isStalled ? formatStalledLabel(MODAL_STALL_THRESHOLD_MS) : ''}` });
252
281
  sections.push({ type: 'text', content: `Duration : ${formatElapsed(elapsedMs)}` });
253
282
  sections.push({ type: 'separator' });
254
283
 
255
284
  // Metrics
256
285
  sections.push({ type: 'text', content: `Tool calls : ${rec.toolCallCount}` });
257
- if (rec.usage) {
258
- const totalIn = rec.usage.inputTokens + (rec.usage.cacheReadTokens ?? 0) + (rec.usage.cacheWriteTokens ?? 0);
286
+ if (hasReportedUsage(rec.usage)) {
287
+ const usage = rec.usage;
288
+ const totalIn = usage.inputTokens + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0);
259
289
  sections.push({ type: 'text', content: `Tokens in : ${totalIn.toLocaleString()}` });
260
- sections.push({ type: 'text', content: `Tokens out : ${rec.usage.outputTokens.toLocaleString()}` });
290
+ sections.push({ type: 'text', content: `Tokens out : ${usage.outputTokens.toLocaleString()}` });
261
291
  } else {
262
292
  sections.push({
263
293
  type: 'text',
@@ -271,7 +301,7 @@ export function renderAgentDetailModal(
271
301
  sections.push({
272
302
  type: 'text',
273
303
  content: 'Addendum : yes (WRFC constraint layer injected)',
274
- style: { fg: '#aaffee' },
304
+ style: { fg: AGENT_DETAIL_NEON.mint },
275
305
  });
276
306
  }
277
307
 
@@ -301,7 +331,7 @@ export function renderAgentDetailModal(
301
331
  sections.push({
302
332
  type: 'text',
303
333
  content: `Findings : ${findings.length} checked, ${unsatisfied.length} unsatisfied`,
304
- style: { fg: unsatisfied.length > 0 ? '#ff6666' : '#44ff88' },
334
+ style: { fg: unsatisfied.length > 0 ? AGENT_DETAIL_NEON.bad : AGENT_DETAIL_NEON.good },
305
335
  });
306
336
  }
307
337
  }
@@ -316,7 +346,7 @@ export function renderAgentDetailModal(
316
346
  sections.push({
317
347
  type: 'text',
318
348
  content: `Progress: ${rec.progress}`,
319
- style: { fg: '#00ffcc' },
349
+ style: { fg: AGENT_DETAIL_NEON.accent },
320
350
  });
321
351
  }
322
352
 
@@ -326,7 +356,7 @@ export function renderAgentDetailModal(
326
356
  sections.push({
327
357
  type: 'text',
328
358
  content: `Error: ${rec.error}`,
329
- style: { fg: '#ff6666' },
359
+ style: { fg: AGENT_DETAIL_NEON.bad },
330
360
  });
331
361
  }
332
362
 
@@ -393,7 +423,7 @@ export function renderAgentDetailModal(
393
423
  content: truncated
394
424
  ? `Streaming (last ${STREAMING_MAX_CHARS} of ${content.length} chars \u2191 scroll for more):`
395
425
  : 'Streaming:',
396
- style: { fg: '#00ffcc', dim: true },
426
+ style: { fg: AGENT_DETAIL_NEON.accent, dim: true },
397
427
  });
398
428
  // Split into display lines, capped at width for readability
399
429
  const maxLineWidth = Math.max(width - 10, 40);
@@ -403,7 +433,7 @@ export function renderAgentDetailModal(
403
433
  sections.push({
404
434
  type: 'text',
405
435
  content: ` ${trimmed}`,
406
- style: { fg: '#aaffee' },
436
+ style: { fg: AGENT_DETAIL_NEON.mint },
407
437
  });
408
438
  }
409
439
  }
@@ -415,7 +445,7 @@ export function renderAgentDetailModal(
415
445
  sections.push({
416
446
  type: 'text',
417
447
  content: `Cancel agent "${modal.confirmCancel.label}"?`,
418
- style: { fg: '#f59e0b' },
448
+ style: { fg: UI_TONES.state.warn },
419
449
  });
420
450
  sections.push({
421
451
  type: 'text',
@@ -3,6 +3,32 @@ import { UIFactory } from './ui-factory.ts';
3
3
  import { getDisplayWidth } from '../utils/terminal-width.ts';
4
4
  import { LAYOUT } from './layout.ts';
5
5
  import { SyntaxHighlighter, type SyntaxToken as HLToken } from './syntax-highlighter.ts';
6
+ import { UI_TONES } from './ui-primitives.ts';
7
+
8
+ /**
9
+ * Regex-fallback tokenizer theme. VS Code Dark+ inspired, but the
10
+ * semantic-token hues (string/number/keyword/type/function/operator/
11
+ * property/comment) are pinned to the same values as the tree-sitter
12
+ * Vaporwave palette in syntax-highlighter.ts's TOKEN_STYLES map, so a code
13
+ * block that starts on the regex fallback and gets replaced by the async
14
+ * tree-sitter parse doesn't visibly shift color mid-stream (WO-207c).
15
+ * `accent` and `bg` are chrome (the language-label header bar and the body
16
+ * background), not syntax tokens, and keep their original VS Code Dark+
17
+ * values — folded here from two separate literals (one in the header, one
18
+ * duplicated on the footer line) into a single named source (WO-207b).
19
+ */
20
+ const FALLBACK_THEME = {
21
+ string: '#00ff88',
22
+ number: '#ffcc00',
23
+ keyword: '#d000ff',
24
+ type: '#ff6b9d',
25
+ function: UI_TONES.accent.brand,
26
+ operator: '#ffffff',
27
+ property: '#87ceeb',
28
+ comment: '#666666',
29
+ bg: '#0d0d0d',
30
+ accent: '#4ec9b0',
31
+ } as const;
6
32
 
7
33
  // ─── Language Keyword Maps ───────────────────────────────────────────────────
8
34
 
@@ -61,7 +87,7 @@ function tokenizeTsJs(line: string): SyntaxToken[] {
61
87
  while (i < line.length) {
62
88
  // Line comment
63
89
  if (line.slice(i, i + 2) === '//') {
64
- tokens.push({ text: line.slice(i), fg: '65', italic: true });
90
+ tokens.push({ text: line.slice(i), fg: FALLBACK_THEME.comment, italic: true });
65
91
  break;
66
92
  }
67
93
  // String (single, double, template)
@@ -72,7 +98,7 @@ function tokenizeTsJs(line: string): SyntaxToken[] {
72
98
  if (line[j] === '\\') j++;
73
99
  j++;
74
100
  }
75
- tokens.push({ text: line.slice(i, j + 1), fg: '#ce9178' });
101
+ tokens.push({ text: line.slice(i, j + 1), fg: FALLBACK_THEME.string });
76
102
  i = j + 1;
77
103
  continue;
78
104
  }
@@ -80,7 +106,7 @@ function tokenizeTsJs(line: string): SyntaxToken[] {
80
106
  if (/[0-9]/.test(line[i])) {
81
107
  let j = i;
82
108
  while (j < line.length && /[0-9._xXbBoO]/.test(line[j])) j++;
83
- tokens.push({ text: line.slice(i, j), fg: '#b5cea8' });
109
+ tokens.push({ text: line.slice(i, j), fg: FALLBACK_THEME.number });
84
110
  i = j;
85
111
  continue;
86
112
  }
@@ -90,11 +116,11 @@ function tokenizeTsJs(line: string): SyntaxToken[] {
90
116
  while (j < line.length && /[\w$]/.test(line[j])) j++;
91
117
  const word = line.slice(i, j);
92
118
  if (TS_JS_KEYWORDS.has(word)) {
93
- tokens.push({ text: word, fg: '#569cd6', bold: true });
119
+ tokens.push({ text: word, fg: FALLBACK_THEME.keyword, bold: true });
94
120
  } else if (TS_TYPES.has(word)) {
95
- tokens.push({ text: word, fg: '#4ec9b0' });
121
+ tokens.push({ text: word, fg: FALLBACK_THEME.type });
96
122
  } else if (line[j] === '(') {
97
- tokens.push({ text: word, fg: '#dcdcaa' });
123
+ tokens.push({ text: word, fg: FALLBACK_THEME.function });
98
124
  } else {
99
125
  tokens.push({ text: word, fg: '' });
100
126
  }
@@ -104,7 +130,7 @@ function tokenizeTsJs(line: string): SyntaxToken[] {
104
130
  // Operators and punctuation
105
131
  const ch = line[i];
106
132
  const isOp = '=<>!&|+-*/%^~?:'.includes(ch);
107
- tokens.push({ text: ch, fg: isOp ? '#d4d4d4' : '' });
133
+ tokens.push({ text: ch, fg: isOp ? FALLBACK_THEME.operator : '' });
108
134
  i++;
109
135
  }
110
136
 
@@ -117,21 +143,21 @@ function tokenizePython(line: string): SyntaxToken[] {
117
143
 
118
144
  while (i < line.length) {
119
145
  if (line[i] === '#') {
120
- tokens.push({ text: line.slice(i), fg: '65', italic: true });
146
+ tokens.push({ text: line.slice(i), fg: FALLBACK_THEME.comment, italic: true });
121
147
  break;
122
148
  }
123
149
  if (line[i] === '"' || line[i] === "'") {
124
150
  const q = line[i];
125
151
  let j = i + 1;
126
152
  while (j < line.length && line[j] !== q) { if (line[j] === '\\') j++; j++; }
127
- tokens.push({ text: line.slice(i, j + 1), fg: '#ce9178' });
153
+ tokens.push({ text: line.slice(i, j + 1), fg: FALLBACK_THEME.string });
128
154
  i = j + 1;
129
155
  continue;
130
156
  }
131
157
  if (/[0-9]/.test(line[i])) {
132
158
  let j = i;
133
159
  while (j < line.length && /[0-9._]/.test(line[j])) j++;
134
- tokens.push({ text: line.slice(i, j), fg: '#b5cea8' });
160
+ tokens.push({ text: line.slice(i, j), fg: FALLBACK_THEME.number });
135
161
  i = j;
136
162
  continue;
137
163
  }
@@ -140,11 +166,11 @@ function tokenizePython(line: string): SyntaxToken[] {
140
166
  while (j < line.length && /[\w]/.test(line[j])) j++;
141
167
  const word = line.slice(i, j);
142
168
  if (PYTHON_KEYWORDS.has(word)) {
143
- tokens.push({ text: word, fg: '#569cd6', bold: true });
169
+ tokens.push({ text: word, fg: FALLBACK_THEME.keyword, bold: true });
144
170
  } else if (/^[A-Z]/.test(word)) {
145
- tokens.push({ text: word, fg: '#4ec9b0' });
171
+ tokens.push({ text: word, fg: FALLBACK_THEME.type });
146
172
  } else if (line[j] === '(') {
147
- tokens.push({ text: word, fg: '#dcdcaa' });
173
+ tokens.push({ text: word, fg: FALLBACK_THEME.function });
148
174
  } else {
149
175
  tokens.push({ text: word, fg: '' });
150
176
  }
@@ -163,21 +189,21 @@ function tokenizeBash(line: string): SyntaxToken[] {
163
189
 
164
190
  while (i < line.length) {
165
191
  if (line[i] === '#') {
166
- tokens.push({ text: line.slice(i), fg: '65', italic: true });
192
+ tokens.push({ text: line.slice(i), fg: FALLBACK_THEME.comment, italic: true });
167
193
  break;
168
194
  }
169
195
  if (line[i] === '"' || line[i] === "'") {
170
196
  const q = line[i];
171
197
  let j = i + 1;
172
198
  while (j < line.length && line[j] !== q) { if (line[j] === '\\') j++; j++; }
173
- tokens.push({ text: line.slice(i, j + 1), fg: '#ce9178' });
199
+ tokens.push({ text: line.slice(i, j + 1), fg: FALLBACK_THEME.string });
174
200
  i = j + 1;
175
201
  continue;
176
202
  }
177
203
  if (line[i] === '$') {
178
204
  let j = i + 1;
179
205
  while (j < line.length && /[\w{}_]/.test(line[j])) j++;
180
- tokens.push({ text: line.slice(i, j), fg: '#9cdcfe' });
206
+ tokens.push({ text: line.slice(i, j), fg: FALLBACK_THEME.property });
181
207
  i = j;
182
208
  continue;
183
209
  }
@@ -186,7 +212,7 @@ function tokenizeBash(line: string): SyntaxToken[] {
186
212
  while (j < line.length && /[\w-]/.test(line[j])) j++;
187
213
  const word = line.slice(i, j);
188
214
  if (BASH_KEYWORDS.has(word)) {
189
- tokens.push({ text: word, fg: '#569cd6', bold: true });
215
+ tokens.push({ text: word, fg: FALLBACK_THEME.keyword, bold: true });
190
216
  } else {
191
217
  tokens.push({ text: word, fg: '' });
192
218
  }
@@ -211,9 +237,9 @@ function tokenizeJson(line: string): SyntaxToken[] {
211
237
  // JSON key: followed by :
212
238
  const rest = line.slice(j + 1).trimStart();
213
239
  if (rest.startsWith(':')) {
214
- tokens.push({ text: str, fg: '#9cdcfe' });
240
+ tokens.push({ text: str, fg: FALLBACK_THEME.property });
215
241
  } else {
216
- tokens.push({ text: str, fg: '#ce9178' });
242
+ tokens.push({ text: str, fg: FALLBACK_THEME.string });
217
243
  }
218
244
  i = j + 1;
219
245
  continue;
@@ -221,13 +247,13 @@ function tokenizeJson(line: string): SyntaxToken[] {
221
247
  if (/[0-9-]/.test(line[i])) {
222
248
  let j = i;
223
249
  while (j < line.length && /[0-9.eE+-]/.test(line[j])) j++;
224
- tokens.push({ text: line.slice(i, j), fg: '#b5cea8' });
250
+ tokens.push({ text: line.slice(i, j), fg: FALLBACK_THEME.number });
225
251
  i = j;
226
252
  continue;
227
253
  }
228
254
  const boolNull = ['true', 'false', 'null'].find(k => line.startsWith(k, i));
229
255
  if (boolNull) {
230
- tokens.push({ text: boolNull, fg: '#569cd6', bold: true });
256
+ tokens.push({ text: boolNull, fg: FALLBACK_THEME.keyword, bold: true });
231
257
  i += boolNull.length;
232
258
  continue;
233
259
  }
@@ -240,12 +266,12 @@ function tokenizeJson(line: string): SyntaxToken[] {
240
266
  function tokenizeYaml(line: string): SyntaxToken[] {
241
267
  const tokens: SyntaxToken[] = [];
242
268
  if (line.trimStart().startsWith('#')) {
243
- return [{ text: line, fg: '65', italic: true }];
269
+ return [{ text: line, fg: FALLBACK_THEME.comment, italic: true }];
244
270
  }
245
271
  const keyMatch = line.match(/^(\s*)([^:]+)(:)(\s*.*)/);
246
272
  if (keyMatch) {
247
273
  if (keyMatch[1]) tokens.push({ text: keyMatch[1], fg: '' });
248
- tokens.push({ text: keyMatch[2], fg: '#9cdcfe' });
274
+ tokens.push({ text: keyMatch[2], fg: FALLBACK_THEME.property });
249
275
  tokens.push({ text: keyMatch[3], fg: '244' });
250
276
  if (keyMatch[4]) {
251
277
  const val = keyMatch[4];
@@ -254,7 +280,7 @@ function tokenizeYaml(line: string): SyntaxToken[] {
254
280
  const isStr = /^['"]/.test(trimVal);
255
281
  const isBool = trimVal === 'true' || trimVal === 'false' || trimVal === 'null' || trimVal === 'yes' || trimVal === 'no';
256
282
  const isNum = /^-?[0-9]/.test(trimVal);
257
- const valFg = isStr ? '#ce9178' : isBool ? '#569cd6' : isNum ? '#b5cea8' : '';
283
+ const valFg = isStr ? FALLBACK_THEME.string : isBool ? FALLBACK_THEME.keyword : isNum ? FALLBACK_THEME.number : '';
258
284
  tokens.push({ text: val, fg: valFg });
259
285
  }
260
286
  return tokens;
@@ -292,7 +318,7 @@ export function renderCodeBlock(
292
318
  const showLineNumbers = opts.showLineNumbers ?? true;
293
319
  const lineNumW = showLineNumbers ? String(codeLines.length).length + 1 : 0; // e.g. "10 "
294
320
  const contentStartX = showLineNumbers ? leftMargin + lineNumW + 1 : leftMargin;
295
- const BG = '#0d0d0d';
321
+ const BG = FALLBACK_THEME.bg;
296
322
  const LINE_NUM_FG = '238';
297
323
  const effectiveWidth = width - LAYOUT.RIGHT_MARGIN;
298
324
 
@@ -320,7 +346,7 @@ export function renderCodeBlock(
320
346
  let hx = leftMargin;
321
347
  for (const ch of headerStr) {
322
348
  if (hx >= effectiveWidth) break;
323
- headerLine[hx] = createStyledCell(ch, { fg: '#1a1a1a', bg: '#4ec9b0', bold: true });
349
+ headerLine[hx] = createStyledCell(ch, { fg: '#1a1a1a', bg: FALLBACK_THEME.accent, bold: true });
324
350
  hx++;
325
351
  }
326
352
  lines.push(headerLine);
@@ -362,7 +388,11 @@ export function renderCodeBlock(
362
388
  continue;
363
389
  }
364
390
  line[cx] = createStyledCell(ch, { fg: token.fg, bg: BG, bold: token.bold, italic: token.italic });
365
- if (cw === 2 && cx + 1 < width) line[cx + 1] = { ...line[cx], char: '' };
391
+ // Bound the wide-glyph placeholder against the body's own right edge
392
+ // (effectiveWidth), not the full line width — otherwise a 2-column
393
+ // glyph landing on the last body column spills its placeholder cell
394
+ // into the reserved right-margin band the header/footer stop at.
395
+ if (cw === 2 && cx + 1 < effectiveWidth) line[cx + 1] = { ...line[cx], char: '' };
366
396
  cx += cw;
367
397
  }
368
398
  }
@@ -373,7 +403,7 @@ export function renderCodeBlock(
373
403
  // Footer line
374
404
  const footerLine = createEmptyLine(width);
375
405
  for (let fx = leftMargin; fx < effectiveWidth; fx++) {
376
- footerLine[fx] = createStyledCell(' ', { bg: '#0d0d0d' });
406
+ footerLine[fx] = createStyledCell(' ', { bg: BG });
377
407
  }
378
408
  lines.push(footerLine);
379
409
 
@@ -3,6 +3,7 @@ import { ModalFactory } from './modal-factory.ts';
3
3
  import type { ConversationManager } from '../core/conversation';
4
4
  import { getOverlayContentBudget, getOverlaySurfaceMetrics, getStableOverlayContentRows } from './overlay-viewport.ts';
5
5
  import { estimateTokens } from '@pellux/goodvibes-sdk/platform/core';
6
+ import { UI_TONES } from './ui-primitives.ts';
6
7
 
7
8
  // ─── ContextInspectorModal ────────────────────────────────────────────────────
8
9
 
@@ -84,7 +85,6 @@ export function renderContextInspector(
84
85
 
85
86
  const entries: MsgEntry[] = [];
86
87
  let totalTokens = 0;
87
- let largeCount = 0;
88
88
 
89
89
  for (const msg of messages) {
90
90
  const role = msg.role;
@@ -126,9 +126,6 @@ export function renderContextInspector(
126
126
  // ── Identify large consumers (>10%) ───────────────────────────────────────
127
127
 
128
128
  const largeThreshold = totalTokens * 0.10;
129
- for (const e of entries) {
130
- if (e.tokens > largeThreshold) largeCount++;
131
- }
132
129
 
133
130
  // ── Build sections ────────────────────────────────────────────────────────
134
131
 
@@ -148,7 +145,7 @@ export function renderContextInspector(
148
145
  sections.push({
149
146
  type: 'text',
150
147
  content: 'WARNING: context is 80%+ full. Run /compact to free space.',
151
- style: { fg: '#ff9900', bold: true },
148
+ style: { fg: UI_TONES.state.warn, bold: true },
152
149
  });
153
150
  }
154
151
 
@@ -181,7 +178,7 @@ export function renderContextInspector(
181
178
  sections.push({
182
179
  type: 'text',
183
180
  content: line,
184
- style: isLarge ? { fg: '#ffcc00', bold: true } : {},
181
+ style: isLarge ? { fg: UI_TONES.state.warn, bold: true } : {},
185
182
  });
186
183
  }
187
184
 
@@ -1,5 +1,5 @@
1
1
  import { type Line, createEmptyLine, createStyledCell } from '../types/grid.ts';
2
- import { getDisplayWidth, truncateDisplay, wrapText } from '../utils/terminal-width.ts';
2
+ import { getDisplayWidth, wrapText } from '../utils/terminal-width.ts';
3
3
  import { LAYOUT } from './layout.ts';
4
4
  import { GLYPHS } from './ui-primitives.ts';
5
5
 
@@ -185,26 +185,6 @@ export function renderConversationCollapsedFragment(
185
185
  });
186
186
  }
187
187
 
188
- export function renderConversationKeyValueRow(
189
- width: number,
190
- left: string,
191
- right: string,
192
- palette: {
193
- readonly leftFg: string;
194
- readonly rightFg: string;
195
- readonly dimFg?: string;
196
- readonly bg?: string;
197
- },
198
- ): Line {
199
- const line = createEmptyLine(width);
200
- const leftText = truncateDisplay(left, Math.max(1, width - LAYOUT.RIGHT_MARGIN - 8));
201
- const rightWidth = getDisplayWidth(right);
202
- const rightStart = Math.max(LAYOUT.LEFT_MARGIN + 1, width - LAYOUT.RIGHT_MARGIN - rightWidth);
203
- writeText(line, LAYOUT.LEFT_MARGIN, rightStart - 1, leftText, palette.leftFg, { bg: palette.bg });
204
- writeText(line, rightStart, width - LAYOUT.RIGHT_MARGIN, right, palette.rightFg, { bg: palette.bg, dim: palette.dimFg === palette.rightFg });
205
- return line;
206
- }
207
-
208
188
  export function renderConversationStatusLine(
209
189
  width: number,
210
190
  segments: readonly ConversationStatusSegment[],
@@ -1,10 +1,12 @@
1
1
  import { type Line, type Cell, createStyledCell } from '../types/grid.ts';
2
2
  import { UIFactory } from './ui-factory.ts';
3
3
  import { getDisplayWidth, padDisplayEnd } from '../utils/terminal-width.ts';
4
+ import { DIFF_TONES } from './ui-primitives.ts';
4
5
 
5
6
  /**
6
7
  * renderDiffView - Render a unified diff string as styled Line[].
7
- * '+' lines in green, '-' lines in red, '@@' hunks in cyan.
8
+ * '+' lines in green, '-' lines in red, '@@' hunks in blue (DIFF_TONES.hunk,
9
+ * shared with diff-panel.ts and git-panel.ts's inline diff).
8
10
  */
9
11
  export function renderDiffView(diffText: string, width: number, filename?: string): Line[] {
10
12
  const lines: Line[] = [];
@@ -34,7 +36,7 @@ export function renderDiffView(diffText: string, width: number, filename?: strin
34
36
  oldLineNo = parseInt(hunkMatch[1], 10) - 1;
35
37
  newLineNo = parseInt(hunkMatch[2], 10) - 1;
36
38
  }
37
- lines.push(makeStyledLine(raw, width, '#00bcd4', '#0f1f1f', false));
39
+ lines.push(makeStyledLine(raw, width, DIFF_TONES.hunk, '#0f1f1f', false));
38
40
  continue;
39
41
  }
40
42
 
@@ -49,7 +51,7 @@ export function renderDiffView(diffText: string, width: number, filename?: strin
49
51
  newLineNo++;
50
52
  const lineLabel = `${String(newLineNo).padStart(4)} `;
51
53
  const content = raw.slice(1);
52
- lines.push(makeGutterLine('+', lineLabel, content, width, '#22c55e', '#0a1a0a'));
54
+ lines.push(makeGutterLine('+', lineLabel, content, width, DIFF_TONES.add, '#0a1a0a'));
53
55
  continue;
54
56
  }
55
57
 
@@ -58,7 +60,7 @@ export function renderDiffView(diffText: string, width: number, filename?: strin
58
60
  oldLineNo++;
59
61
  const lineLabel = `${String(oldLineNo).padStart(4)} `;
60
62
  const content = raw.slice(1);
61
- lines.push(makeGutterLine('-', lineLabel, content, width, '#ef4444', '#1a0a0a'));
63
+ lines.push(makeGutterLine('-', lineLabel, content, width, DIFF_TONES.del, '#1a0a0a'));
62
64
  continue;
63
65
  }
64
66
 
@@ -107,7 +107,7 @@ export function drawHorizontalRule(line: Line, startX: number, endX: number, fg:
107
107
  }
108
108
  }
109
109
 
110
- export function clipDisplay(text: string, width: number): string {
110
+ function clipDisplay(text: string, width: number): string {
111
111
  if (width <= 0) return '';
112
112
  let used = 0;
113
113
  let output = '';
@@ -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
  }