@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
@@ -351,15 +351,28 @@ export class InputHistory {
351
351
  return redactedText;
352
352
  }
353
353
 
354
+ /**
355
+ * Entries persisted before CR normalization was deployed (registerPaste now
356
+ * converts the \r line separators terminals send in bracketed pastes to \n)
357
+ * may still carry literal \r bytes; launder them on load so history recall
358
+ * cannot reintroduce \r into the composer.
359
+ */
360
+ private launderLineBreaks(text: string): string {
361
+ return text.replace(/\r\n?/g, '\n');
362
+ }
363
+
354
364
  private normalizeStoredEntry(entry: unknown): StoredInputHistoryEntry | null {
355
- if (typeof entry === 'string') return entry;
365
+ if (typeof entry === 'string') return this.launderLineBreaks(entry);
356
366
  if (!entry || typeof entry !== 'object') return null;
357
367
  const record = entry as Record<string, unknown>;
358
368
  if (typeof record.text !== 'string') return null;
359
- const text = record.text.trim();
369
+ const text = this.launderLineBreaks(record.text).trim();
360
370
  if (!text) return null;
361
- if (typeof record.recallText === 'string' && record.recallText.trim() && record.recallText.trim() !== text) {
362
- return { text, recallText: record.recallText.trim() };
371
+ const recallText = typeof record.recallText === 'string'
372
+ ? this.launderLineBreaks(record.recallText).trim()
373
+ : '';
374
+ if (recallText && recallText !== text) {
375
+ return { text, recallText };
363
376
  }
364
377
  return text;
365
378
  }
package/src/main.ts CHANGED
@@ -24,6 +24,7 @@ import { GitStatusProvider } from './renderer/git-status.ts';
24
24
  import type { GitHeaderInfo } from './renderer/git-status.ts';
25
25
  import { createShellLayout } from './renderer/layout-engine.ts';
26
26
  import { buildShellFooter, estimateShellFooterHeight } from './renderer/shell-surface.ts';
27
+ import { computePromptContentWidth } from './renderer/prompt-content-width.ts';
27
28
  import { buildConversationViewport } from './renderer/conversation-layout.ts';
28
29
  import { applyConversationOverlays } from './renderer/conversation-overlays.ts';
29
30
  import { buildPanelCompositeData } from './renderer/panel-composite.ts';
@@ -53,6 +54,7 @@ import { wireSpokenTurnRuntime } from './audio/spoken-turn-wiring.ts';
53
54
  import { attachSpokenTurnModelRouting, createSpokenTurnInputOptions } from './audio/spoken-turn-model-routing.ts';
54
55
  import { allowTerminalWrite, installTuiTerminalOutputGuard } from './runtime/terminal-output-guard.ts';
55
56
  import { installProcessLifecycle } from './runtime/process-lifecycle.ts';
57
+ import { createRenderScheduler } from './runtime/render-scheduler.ts';
56
58
  import { buildCommandArgsHint } from './input/command-args-hint.ts';
57
59
  import { summarizeRunningAgents } from './renderer/process-summary.ts';
58
60
  import { formatUserFacingErrorLine } from './core/format-user-error.ts';
@@ -180,19 +182,19 @@ async function main() {
180
182
  activeToolName: undefined,
181
183
  };
182
184
 
183
- const getPromptContentWidth = () => {
184
- const w = stdout.columns || 80;
185
- const boxMargin = 2;
186
- const boxWidth = w - (boxMargin * 2);
187
- return boxWidth - 4 - 3; // minus padding (4) minus prefix width (3: ' > ')
188
- };
185
+ const getPromptContentWidth = () => computePromptContentWidth(stdout.columns);
189
186
 
190
187
  const getViewportHeight = (): number => {
191
188
  if (input.onboardingWizard.active) return stdout.rows || 24;
192
189
  const promptLines: number = input.getVisiblePromptLineCount(getPromptContentWidth());
193
190
  const currentModel = providerRegistry.getCurrentModel();
194
191
  const contextWindow = providerRegistry.getContextWindowForModel(currentModel);
195
- return (stdout.rows || 24) - 2 - estimateShellFooterHeight(promptLines, contextWindow);
192
+ const rows = stdout.rows || 24;
193
+ // Compact threshold must match buildShellFooter's `compact: height < 30`
194
+ // posture below — otherwise the cached-height fast path in
195
+ // estimateShellFooterHeight can answer a compact-vs-non-compact query
196
+ // with the wrong cached mode and throw the viewport math off by several rows.
197
+ return rows - 2 - estimateShellFooterHeight(promptLines, contextWindow, rows < 30);
196
198
  };
197
199
 
198
200
  const scroll = (delta: number) => {
@@ -223,7 +225,7 @@ async function main() {
223
225
  noAltScreen: cli.flags.noAltScreen,
224
226
  ansi: { CLEAR_SCREEN, ALT_SCREEN_EXIT, PASTE_DISABLE, KEYBOARD_EXT_DISABLE, MOUSE_DISABLE, CURSOR_SHOW },
225
227
  getInput: () => input,
226
- render: () => render(),
228
+ render: () => renderScheduler.flushNow(), // resize: synchronous immediate path
227
229
  getPromptContentWidth,
228
230
  getTerminalOutputGuard: () => terminalOutputGuard,
229
231
  buildSessionContinuityHints,
@@ -432,7 +434,7 @@ async function main() {
432
434
  lastSessionId: readLastSessionPointer({ workingDirectory: workingDir, homeDirectory, surfaceRoot: 'tui' }) ?? undefined,
433
435
  };
434
436
 
435
- const render = () => {
437
+ const renderNow = () => {
436
438
  const width = stdout.columns || 80;
437
439
  const height = stdout.rows || 24;
438
440
 
@@ -648,9 +650,11 @@ async function main() {
648
650
  panelWidth: panelComposite.panelWidth,
649
651
  });
650
652
  };
653
+ const renderScheduler = createRenderScheduler(renderNow); // WO-208 same-tick coalescer
654
+ const render = (): void => renderScheduler.schedule();
651
655
  const terminalOutputGuard = installTuiTerminalOutputGuard({ stdout, stderr: process.stderr, notify: (message) => { systemMessageRouter.low(message); render(); } });
652
656
 
653
- setRenderRequest(render);
657
+ setRenderRequest(renderNow); // bootstrap's 16ms coalescer calls the composite directly
654
658
  orchestratorRefs.requestRender = render;
655
659
  commandContext.renderRequest = render;
656
660
  wireShellUiOpeners({
@@ -3,7 +3,7 @@ import type { Panel, PanelCategory } from './types.ts';
3
3
  import type { ComponentResourceContract, ComponentHealthState } from '../runtime/perf/panel-contracts.ts';
4
4
  import type { ComponentHealthMonitor } from '../runtime/perf/panel-health-monitor.ts';
5
5
  import { UIFactory } from '../renderer/ui-factory.ts';
6
- import { SPINNER_FRAMES } from '../renderer/progress.ts';
6
+ import { SPINNER_FRAMES } from '../renderer/ui-primitives.ts';
7
7
  import { fitDisplay } from '../utils/terminal-width.ts';
8
8
 
9
9
  /** Canonical error-surface foreground (bad/red), kept out of the render body. */
@@ -6,7 +6,7 @@ import type { Line } from '../types/grid.ts';
6
6
  import { createStyledCell, createEmptyLine } from '../types/grid.ts';
7
7
  import { truncateDisplay, getDisplayWidth } from '../utils/terminal-width.ts';
8
8
  import { BasePanel } from './base-panel.ts';
9
- import { UI_TONES } from '../renderer/ui-primitives.ts';
9
+ import { UI_TONES, DIFF_TONES } from '../renderer/ui-primitives.ts';
10
10
  import { FilePreviewPanel } from './file-preview-panel.ts';
11
11
  import type { PanelIntegrationContext } from './types.ts';
12
12
  import { logger } from '@pellux/goodvibes-sdk/platform/utils';
@@ -30,14 +30,20 @@ import {
30
30
  // DEFAULT_PANEL_PALETTE.headerBg (WO-002: one title band everywhere).
31
31
  // ---------------------------------------------------------------------------
32
32
 
33
- const HUNK_BLUE = '#88aaff';
33
+ // Hunk blue is the shared DIFF_TONES token (WO-204) — diff-view.ts (conversation)
34
+ // and git-panel.ts's inline diff converge onto this file's pre-existing value.
35
+ const HUNK_BLUE: string = DIFF_TONES.hunk;
34
36
  // Context rows and line-number gutter use the shared theme's muted/dim
35
37
  // foreground tones rather than dedicated gray hex literals.
36
38
  const CONTEXT_GRAY = UI_TONES.fg.muted;
37
39
  const FILENAME_WHITE = '#ffffff';
38
- const ADD_GREEN = '#00ff88';
40
+ // Add/del text colors are the shared DIFF_TONES tokens, whose values ARE this
41
+ // panel's shipped colors — this panel is the reference diff look (diff-view
42
+ // was unwired dead code until WO-204, so "majority of surfaces" was a mirage);
43
+ // the conversation surface converges onto these, not the reverse.
44
+ const ADD_GREEN: string = DIFF_TONES.add;
39
45
  const ADD_BG = '#001a0d';
40
- const DEL_RED = '#ff4444';
46
+ const DEL_RED: string = DIFF_TONES.del;
41
47
  const DEL_BG = '#1a0000';
42
48
  const HUNK_BG = '#0a0a1a';
43
49
  const MARKER_GRAY = '#aaaaaa';
@@ -4,7 +4,7 @@ import { truncateDisplay, getDisplayWidth } from '../utils/terminal-width.ts';
4
4
  import { GitService } from '@pellux/goodvibes-sdk/platform/git';
5
5
  import { logger } from '@pellux/goodvibes-sdk/platform/utils';
6
6
  import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
7
- import { UI_TONES } from '../renderer/ui-primitives.ts';
7
+ import { UI_TONES, DIFF_TONES } from '../renderer/ui-primitives.ts';
8
8
  import {
9
9
  buildEmptyState,
10
10
  buildKeyboardHints,
@@ -77,7 +77,7 @@ const C = extendPalette(DEFAULT_PANEL_PALETTE, {
77
77
  commitAuthor: '244',
78
78
  selected: '#1c1c1c',
79
79
  selectedFg: '#ffffff',
80
- diffMeta: '#5f87ff',
80
+ diffMeta: DIFF_TONES.hunk, // WO-204: shared diff-hunk token, was a local literal
81
81
  diffNeutral: '250',
82
82
  // Reuses the existing workflow accent token rather than adding a new hex
83
83
  // literal (architecture gate ratchets the raw-hex-literal count).
@@ -10,6 +10,24 @@ import { getOverlaySurfaceMetrics, getStableOverlayContentRows } from './overlay
10
10
  import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
11
11
  import { handleConfirmInput, type ConfirmState } from '../panels/confirm-state.ts';
12
12
  import { AGENT_TERMINAL_STATUSES as MODAL_TERMINAL_STATUSES, AGENT_STALL_THRESHOLD_MS as MODAL_STALL_THRESHOLD_MS } 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,7 +277,7 @@ 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
 
@@ -271,7 +300,7 @@ export function renderAgentDetailModal(
271
300
  sections.push({
272
301
  type: 'text',
273
302
  content: 'Addendum : yes (WRFC constraint layer injected)',
274
- style: { fg: '#aaffee' },
303
+ style: { fg: AGENT_DETAIL_NEON.mint },
275
304
  });
276
305
  }
277
306
 
@@ -301,7 +330,7 @@ export function renderAgentDetailModal(
301
330
  sections.push({
302
331
  type: 'text',
303
332
  content: `Findings : ${findings.length} checked, ${unsatisfied.length} unsatisfied`,
304
- style: { fg: unsatisfied.length > 0 ? '#ff6666' : '#44ff88' },
333
+ style: { fg: unsatisfied.length > 0 ? AGENT_DETAIL_NEON.bad : AGENT_DETAIL_NEON.good },
305
334
  });
306
335
  }
307
336
  }
@@ -316,7 +345,7 @@ export function renderAgentDetailModal(
316
345
  sections.push({
317
346
  type: 'text',
318
347
  content: `Progress: ${rec.progress}`,
319
- style: { fg: '#00ffcc' },
348
+ style: { fg: AGENT_DETAIL_NEON.accent },
320
349
  });
321
350
  }
322
351
 
@@ -326,7 +355,7 @@ export function renderAgentDetailModal(
326
355
  sections.push({
327
356
  type: 'text',
328
357
  content: `Error: ${rec.error}`,
329
- style: { fg: '#ff6666' },
358
+ style: { fg: AGENT_DETAIL_NEON.bad },
330
359
  });
331
360
  }
332
361
 
@@ -393,7 +422,7 @@ export function renderAgentDetailModal(
393
422
  content: truncated
394
423
  ? `Streaming (last ${STREAMING_MAX_CHARS} of ${content.length} chars \u2191 scroll for more):`
395
424
  : 'Streaming:',
396
- style: { fg: '#00ffcc', dim: true },
425
+ style: { fg: AGENT_DETAIL_NEON.accent, dim: true },
397
426
  });
398
427
  // Split into display lines, capped at width for readability
399
428
  const maxLineWidth = Math.max(width - 10, 40);
@@ -403,7 +432,7 @@ export function renderAgentDetailModal(
403
432
  sections.push({
404
433
  type: 'text',
405
434
  content: ` ${trimmed}`,
406
- style: { fg: '#aaffee' },
435
+ style: { fg: AGENT_DETAIL_NEON.mint },
407
436
  });
408
437
  }
409
438
  }
@@ -415,7 +444,7 @@ export function renderAgentDetailModal(
415
444
  sections.push({
416
445
  type: 'text',
417
446
  content: `Cancel agent "${modal.confirmCancel.label}"?`,
418
- style: { fg: '#f59e0b' },
447
+ style: { fg: UI_TONES.state.warn },
419
448
  });
420
449
  sections.push({
421
450
  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 = '';