@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
@@ -10,22 +10,22 @@ import type { ModelPickerModal } from '../input/model-picker.ts';
10
10
  import type { ModelPickerTargetInfo } from '../input/model-picker.ts';
11
11
  import type { Line } from '../types/grid.ts';
12
12
  import { createEmptyLine, createStyledCell } from '../types/grid.ts';
13
- import { getDisplayWidth, wrapText } from '../utils/terminal-width.ts';
13
+ import { fitDisplay, getDisplayWidth, wrapText } from '../utils/terminal-width.ts';
14
14
  import { abbreviateCount } from '../utils/format-number.ts';
15
15
  import { GLYPHS, UI_TONES } from './ui-primitives.ts';
16
16
 
17
17
  const PALETTE = {
18
- border: '#64748b',
18
+ border: UI_TONES.border,
19
19
  title: '#67e8f9',
20
- subtitle: '#93c5fd',
21
- text: '#e2e8f0',
22
- muted: '#94a3b8',
23
- dim: '#64748b',
24
- selectedBg: '#223049',
20
+ subtitle: UI_TONES.accent.conversation,
21
+ text: UI_TONES.fg.primary,
22
+ muted: UI_TONES.fg.muted,
23
+ dim: UI_TONES.border,
24
+ selectedBg: UI_TONES.bg.selected,
25
25
  targetBg: '#141b25',
26
26
  detailBg: '#121923',
27
27
  bodyBg: '#0f141d',
28
- footerBg: '#111827',
28
+ footerBg: UI_TONES.bg.footer,
29
29
  good: UI_TONES.state.good,
30
30
  warn: UI_TONES.state.warn,
31
31
  info: UI_TONES.state.info,
@@ -100,23 +100,8 @@ function drawVertical(line: Line, x: number, bg = ''): void {
100
100
  line[x] = createStyledCell(GLYPHS.frame.vertical, { fg: PALETTE.border, bg });
101
101
  }
102
102
 
103
- function clipDisplay(text: string, width: number): string {
104
- if (width <= 0) return '';
105
- let used = 0;
106
- let output = '';
107
- for (const ch of text) {
108
- const chWidth = getDisplayWidth(ch);
109
- if (chWidth <= 0) continue;
110
- if (used + chWidth > width) break;
111
- output += ch;
112
- used += chWidth;
113
- }
114
- return output;
115
- }
116
-
117
103
  function padDisplay(text: string, width: number): string {
118
- const clipped = clipDisplay(text, width);
119
- return clipped + ' '.repeat(Math.max(0, width - getDisplayWidth(clipped)));
104
+ return fitDisplay(text, width, '');
120
105
  }
121
106
 
122
107
  function stableWindow(total: number, selected: number, visible: number): { start: number; end: number } {
@@ -53,7 +53,13 @@ export function createOverlayBoxLayout(
53
53
  maxWidth: number,
54
54
  ): OverlayBoxLayout {
55
55
  const resolvedMaxWidth = getOverlayMaxWidth(terminalWidth, margin, maxWidth);
56
- const width = Math.max(20, Math.min(terminalWidth - margin * 2, resolvedMaxWidth));
56
+ // The 20-column floor exists so overlays stay legible on ordinary narrow
57
+ // terminals, but on a hostile-narrow terminal (terminalWidth smaller than
58
+ // margin*2 + 20) that floor alone can push the box wider than the real
59
+ // terminal, walking cells off the right edge of the line array. Clamp the
60
+ // floored result back down to what actually fits before returning it.
61
+ const availableWidth = Math.max(1, terminalWidth - margin * 2);
62
+ const width = Math.min(availableWidth, Math.max(20, Math.min(availableWidth, resolvedMaxWidth)));
57
63
  const contentWidth = width - 2;
58
64
  const innerWidth = contentWidth - 2;
59
65
  return { margin, width, contentWidth, innerWidth };
@@ -128,14 +134,6 @@ export function createOverlayFilledBorderLine(
128
134
  return line;
129
135
  }
130
136
 
131
- export function createOverlayFrameLine(
132
- terminalWidth: number,
133
- layout: OverlayBoxLayout,
134
- bg = DEFAULT_OVERLAY_PALETTE.bodyBg,
135
- ): Line {
136
- return createOverlayContentLine(terminalWidth, layout, DEFAULT_OVERLAY_PALETTE.borderFg, bg);
137
- }
138
-
139
137
  export const OVERLAY_GLYPHS = {
140
138
  topLeft: GLYPHS.frame.topLeft,
141
139
  topRight: GLYPHS.frame.topRight,
@@ -1,28 +1,16 @@
1
1
  import { type Line } from '../types/grid.ts';
2
2
  import { UIFactory } from './ui-factory.ts';
3
- import { getDisplayWidth } from '../utils/terminal-width.ts';
4
- import { GLYPHS } from './ui-primitives.ts';
5
-
6
- /** Truncate a string to fit within maxWidth display columns. */
7
- function truncateToWidth(text: string, maxWidth: number): string {
8
- let width = 0;
9
- let i = 0;
10
- for (const char of text) {
11
- const cw = getDisplayWidth(char);
12
- if (width + cw > maxWidth) break;
13
- width += cw;
14
- i += char.length;
15
- }
16
- return text.slice(0, i);
17
- }
3
+ import { truncateDisplay } from '../utils/terminal-width.ts';
4
+ import { GLYPHS, UI_TONES } from './ui-primitives.ts';
5
+ import { formatHints } from './hint-grammar.ts';
18
6
 
19
7
  /**
20
8
  * renderProcessIndicator — shows a one-line summary of active background
21
9
  * processes below the input area.
22
10
  *
23
11
  * Dimmed when no processes are active, highlighted (cyan) when agents or
24
- * background exec processes are running. Includes an `Enter to view` hint
25
- * when active.
12
+ * background exec processes are running. Includes an `[Enter] View` hint
13
+ * (hint-grammar bracket form) when active.
26
14
  */
27
15
  export function renderProcessIndicator(
28
16
  width: number,
@@ -38,10 +26,10 @@ export function renderProcessIndicator(
38
26
  const renderFocusedStatus = (text: string): Line[] => {
39
27
  const bg = '#31506f';
40
28
  const fg = '#eefaff';
41
- const markerFg = '#7dd3fc';
29
+ const markerFg = UI_TONES.accent.browser;
42
30
  const line = UIFactory.stringToLine(' '.repeat(width), width, { fg: '238' });
43
31
  const prefix = `${GLYPHS.navigation.selected} `;
44
- const body = truncateToWidth(text, Math.max(0, width - 8));
32
+ const body = truncateDisplay(text, Math.max(0, width - 8), '');
45
33
  const highlighted = ` ${prefix}${body} `;
46
34
  const startX = 2;
47
35
  for (let i = 0; i < highlighted.length && startX + i < width - 2; i++) {
@@ -62,8 +50,8 @@ export function renderProcessIndicator(
62
50
  if (agentCount > 0) parts.push(`${agentCount} agent${agentCount !== 1 ? 's' : ''}`);
63
51
  if (toolCount > 0) parts.push(`${toolCount} tool${toolCount !== 1 ? 's' : ''} running`);
64
52
  const label = total === 0
65
- ? `No background processes ${GLYPHS.status.pending} back to input`
66
- : `${parts.join(` ${GLYPHS.navigation.pipeSeparator} `)} ${GLYPHS.status.pending} Enter to open ${GLYPHS.status.pending} back to input`;
53
+ ? `No background processes ${formatHints([{ key: 'Esc', verb: 'Back to input' }])}`
54
+ : `${parts.join(` ${GLYPHS.navigation.pipeSeparator} `)} ${formatHints([{ key: 'Enter', verb: 'Open' }, { key: 'Esc', verb: 'Back to input' }])}`;
67
55
  return renderFocusedStatus(label);
68
56
  }
69
57
 
@@ -83,16 +71,16 @@ export function renderProcessIndicator(
83
71
  /**
84
72
  * Number of columns reserved for the agent count label and hint text.
85
73
  * Breakdown: "bg: N agents" prefix (~15 chars) + " | " separator (~3)
86
- * + " Enter to view " hint (~17) + padding (~8) ≈ 43 chars.
74
+ * + " [Enter] View " hint (~16) + padding (~9) ≈ 43 chars.
87
75
  */
88
76
  const PROGRESS_RESERVED_CHARS = 43;
89
77
  const progressMaxLen = Math.max(0, width - PROGRESS_RESERVED_CHARS); // reserve space for count + hint
90
78
  // Truncate by display width (not JS string length) so wide/CJK glyphs in the
91
79
  // agent progress text don't overflow the reserved budget.
92
80
  const progressSuffix = agentProgress && progressMaxLen > 10
93
- ? ` | ${getDisplayWidth(agentProgress) > progressMaxLen ? truncateToWidth(agentProgress, Math.max(0, progressMaxLen - 3)) + '...' : agentProgress}`
81
+ ? ` | ${truncateDisplay(agentProgress, progressMaxLen, '...')}`
94
82
  : '';
95
83
  const label = `${parts.join(` ${GLYPHS.navigation.pipeSeparator} `)}${progressSuffix}`;
96
- const hint = ` ${GLYPHS.status.pending} Enter to view`;
97
- return renderPlainStatus(`${label}${hint}`, { fg: '#00ffff', bold: true });
84
+ const hint = ` ${formatHints([{ key: 'Enter', verb: 'View' }])}`;
85
+ return renderPlainStatus(`${label}${hint}`, { fg: UI_TONES.accent.brand, bold: true });
98
86
  }
@@ -3,7 +3,7 @@
3
3
  * using ModalFactory.
4
4
  *
5
5
  * Shows a list of saved profiles with:
6
- * - name, timestamp (formatted), settings preview
6
+ * - name, timestamp (formatted)
7
7
  * Footer hints: [Up/Down] Navigate [Enter] Load [d] Arm/Delete [s] Save current [Esc] Close
8
8
  */
9
9
 
@@ -55,18 +55,21 @@ export function renderProfilePickerModal(
55
55
  style: { fg: '240', dim: true },
56
56
  });
57
57
  } else {
58
- // Column widths: name(24) | timestamp(16) | preview(remaining)
59
- const nameW = 24;
60
- const tsW = 16;
61
- const previewW = Math.max(4, contentW - nameW - tsW - 4);
58
+ // Proportional column widths that adapt to the modal's content width:
59
+ // timestamp ~22% (clamped 10..16); the name column absorbs the remainder
60
+ // (ported from session-picker-modal.ts). Reserves 4 cols — 2 for the
61
+ // name/timestamp separator and 2 for the list row's selection indicator
62
+ // ("▸ ") that ModalFactory prepends outside the wrapped label — so the
63
+ // row never spills the timestamp's embedded space onto a wrapped line.
64
+ const tsW = Math.min(16, Math.max(10, Math.floor(contentW * 0.22)));
65
+ const nameW = Math.max(8, contentW - tsW - 4);
62
66
 
63
67
  // Column header
64
- const nameHdr = fitDisplay('Name', nameW);
65
- const tsHdr = fitDisplay('Saved', tsW);
66
- const previewHdr = fitDisplay('Settings', previewW);
68
+ const nameHdr = fitDisplay('Name', nameW);
69
+ const tsHdr = fitDisplay('Saved', tsW);
67
70
  sections.push({
68
71
  type: 'text',
69
- content: `${nameHdr} ${tsHdr} ${previewHdr}`,
72
+ content: `${nameHdr} ${tsHdr}`,
70
73
  style: { fg: '240', dim: true },
71
74
  });
72
75
  sections.push({ type: 'separator' });
@@ -79,11 +82,7 @@ export function renderProfilePickerModal(
79
82
 
80
83
  const tsStr = fitDisplay(formatTimestamp(prof.timestamp), tsW);
81
84
 
82
- // Read the profile file to get a preview of settings
83
- // (We only have name/timestamp in ProfileInfo, so show a placeholder)
84
- const preview = fitDisplay('(display/provider/behavior)', previewW);
85
-
86
- const label = `${nameStr} ${tsStr} ${preview}`;
85
+ const label = `${nameStr} ${tsStr}`;
87
86
  return { label, selected: isSelected };
88
87
  });
89
88
 
@@ -0,0 +1,16 @@
1
+ /**
2
+ * computePromptContentWidth — content width available for prompt text inside
3
+ * the footer input box, after the box margin, inner padding, and the ' > '
4
+ * prefix are subtracted.
5
+ *
6
+ * Floors at 1: on a hostile-narrow terminal the raw subtraction
7
+ * (boxWidth - 4 - 3) can go zero or negative, and a non-positive content
8
+ * width breaks downstream text-wrapping and cursor-position math (which
9
+ * assume at least one column to place a character in).
10
+ */
11
+ export function computePromptContentWidth(terminalColumns: number): number {
12
+ const w = terminalColumns || 80;
13
+ const boxMargin = 2;
14
+ const boxWidth = w - (boxMargin * 2);
15
+ return Math.max(1, boxWidth - 4 - 3); // minus padding (4) minus prefix width (3: ' > ')
16
+ }
@@ -196,10 +196,12 @@ export function renderSelectionModalOverlay(
196
196
  : selectedItem?.primaryAction === 'delete'
197
197
  ? '[Enter] Delete'
198
198
  : '[Enter] Select';
199
- let hints = `[Up/Down] Navigate ${primaryVerb} [Esc] Close`;
199
+ let hints = `[Up/Down] Navigate ${primaryVerb}`;
200
200
  if (modal.allowSearch) hints += ' [/] Search';
201
201
  if (selectedItem?.primaryAction === 'toggle' && !selectedItem.actions) hints += ' [Space] Toggle';
202
202
  if (selectedItem?.actions) hints += ` ${selectedItem.actions}`;
203
+ // hint-grammar: Esc is the conventional "way out" and always sorts last.
204
+ hints += ' [Esc] Close';
203
205
  putText(
204
206
  footerLine,
205
207
  layout.margin + 2,
@@ -328,7 +328,7 @@ const CHANGE_GLYPH: Record<ChangeKind, string> = {
328
328
  * + import ./utils { A, B }
329
329
  * ~ import ./types (+NewType, -OldType)
330
330
  */
331
- export function formatSemanticDiff(diff: SemanticDiff): string[] {
331
+ function formatSemanticDiff(diff: SemanticDiff): string[] {
332
332
  const lines: string[] = [];
333
333
 
334
334
  // Symbol changes first
@@ -7,6 +7,13 @@
7
7
  import type { SettingEntry, McpEntry, SubscriptionEntry } from '../input/settings-modal-types.ts';
8
8
  import { SETTINGS_CATEGORIES } from '../input/settings-modal-types.ts';
9
9
  import { isSecretConfigKey, isSecretReferenceValue } from '../config/secret-config.ts';
10
+ import { UI_TONES } from './ui-primitives.ts';
11
+
12
+ /**
13
+ * "Modified / active" accent for settings rows — cyan-green, no UI_TONES
14
+ * role matches it (WO-207b: preserved byte-exact as a named constant).
15
+ */
16
+ const SETTINGS_ACCENT = '#00ffcc';
10
17
 
11
18
  function maskSecretValue(value: string): string {
12
19
  if (value.length === 0) return '(empty)';
@@ -25,43 +32,12 @@ export function formatValue(entry: SettingEntry): string {
25
32
  }
26
33
 
27
34
  export function valueColor(entry: SettingEntry): string {
28
- if (!entry.isDefault) return '#00ffcc'; // cyan-green = modified
29
- return '244'; // dim = default
35
+ if (!entry.isDefault) return SETTINGS_ACCENT; // cyan-green = modified
36
+ return '244'; // dim = default
30
37
  }
31
38
 
32
- export function flagStateColor(state: string, killed: boolean): string {
33
- if (killed) return '#ef4444'; // red
34
- if (state === 'enabled') return '#00ffcc'; // cyan-green
35
- return '244'; // dim
36
- }
37
39
 
38
- export function mcpTrustColor(mode: McpEntry['trustMode']): string {
39
- switch (mode) {
40
- case 'allow-all':
41
- return '#ef4444';
42
- case 'ask-on-risk':
43
- return '#eab308';
44
- case 'constrained':
45
- return '#00ffcc';
46
- case 'blocked':
47
- return '244';
48
- default:
49
- return '244';
50
- }
51
- }
52
40
 
53
- export function subscriptionStateColor(state: SubscriptionEntry['state']): string {
54
- switch (state) {
55
- case 'active':
56
- return '#00ffcc';
57
- case 'pending':
58
- return '#eab308';
59
- case 'available':
60
- return '#38bdf8';
61
- default:
62
- return '244';
63
- }
64
- }
65
41
 
66
42
  export function inferSubscriptionRouteReason(entry: SubscriptionEntry): string | undefined {
67
43
  if (entry.routeReason?.trim()) return entry.routeReason;
@@ -107,7 +83,7 @@ export const CATEGORY_LABELS: Record<(typeof SETTINGS_CATEGORIES)[number], strin
107
83
  network: 'Network',
108
84
  };
109
85
 
110
- export const SETTING_LABELS: Partial<Record<string, string>> = {
86
+ const SETTING_LABELS: Partial<Record<string, string>> = {
111
87
  'ui.systemMessages': 'System Message Target',
112
88
  'ui.operationalMessages': 'Operational Message Target',
113
89
  'ui.wrfcMessages': 'WRFC Message Target',
@@ -54,22 +54,35 @@ export interface ShellFooterBuildResult {
54
54
  const FOOTER_BASE_ROWS = 5;
55
55
  const CONTEXT_PROGRESS_ROWS = 1;
56
56
  const PROCESS_INDICATOR_ROWS = 1;
57
+ // Compact posture drops the context-info line (and never shows the context
58
+ // bar or process indicator), leaving just: prompt-box top border, bottom
59
+ // border, token-usage line, and the help/exit line.
60
+ const COMPACT_FOOTER_BASE_ROWS = 4;
57
61
 
58
62
  /**
59
- * Real height of the most recently rendered footer. estimateShellFooterHeight
60
- * prefers this so the pre-prompt viewport math accounts for the composer
61
- * posture block and context hint that the static formula cannot see. Reset to
62
- * null before any footer has rendered (cold start), where the formula is exact
63
- * for the common posture-free, hint-free case.
63
+ * Real height of the most recently rendered footer, tagged with the compact
64
+ * posture it was rendered under. estimateShellFooterHeight prefers this so
65
+ * the pre-prompt viewport math accounts for the composer posture block and
66
+ * context hint that the static formula cannot see. Reset to null before any
67
+ * footer has rendered (cold start), where the formula is exact for the
68
+ * common posture-free, hint-free case. The compact flag is part of the cache
69
+ * key: a cached non-compact height must never answer a compact query (and
70
+ * vice versa), since the two postures differ by several rows.
64
71
  */
65
- let lastRenderedFooterHeight: number | null = null;
72
+ let lastRenderedFooterHeight: { compact: boolean; height: number } | null = null;
66
73
 
67
74
  export function estimateShellFooterHeight(
68
75
  promptLineCount: number,
69
76
  contextWindow?: number,
77
+ compact = false,
70
78
  ): number {
71
- if (lastRenderedFooterHeight !== null) return lastRenderedFooterHeight;
79
+ if (lastRenderedFooterHeight !== null && lastRenderedFooterHeight.compact === compact) {
80
+ return lastRenderedFooterHeight.height;
81
+ }
72
82
  const safePromptLines = Math.max(1, promptLineCount);
83
+ if (compact) {
84
+ return COMPACT_FOOTER_BASE_ROWS + safePromptLines;
85
+ }
73
86
  const progressRows = contextWindow && contextWindow > 0 ? CONTEXT_PROGRESS_ROWS : 0;
74
87
  return FOOTER_BASE_ROWS + safePromptLines + progressRows + PROCESS_INDICATOR_ROWS;
75
88
  }
@@ -119,6 +132,6 @@ export function buildShellFooter(
119
132
  lines.unshift(hintLine);
120
133
  }
121
134
  }
122
- lastRenderedFooterHeight = lines.length;
135
+ lastRenderedFooterHeight = { compact: options.compact ?? false, height: lines.length };
123
136
  return { lines, height: lines.length };
124
137
  }
@@ -87,15 +87,3 @@ export function getTrackedVisibleWindow(
87
87
  }
88
88
  return { start, end, count: end - start, total };
89
89
  }
90
-
91
- export function sliceVisibleWindow<T>(
92
- items: readonly T[],
93
- selectedIndex: number,
94
- visibleCount: number,
95
- ): { readonly items: readonly T[]; readonly window: VisibleWindow } {
96
- const window = getVisibleWindow(items.length, selectedIndex, visibleCount);
97
- return {
98
- items: items.slice(window.start, window.end),
99
- window,
100
- };
101
- }
@@ -223,7 +223,7 @@ export function nearestAnsi16Fg(r: number, g: number, b: number): number {
223
223
  * Convert an ANSI16 fg code to the corresponding bg code.
224
224
  * fg 30-37 → bg 40-47; fg 90-97 → bg 100-107.
225
225
  */
226
- export function ansi16FgToBg(fgCode: number): number {
226
+ function ansi16FgToBg(fgCode: number): number {
227
227
  // Both ranges (30-37 and 90-97) shift by +10 to reach their bg equivalents
228
228
  // (30-37 → 40-47, 90-97 → 100-107).
229
229
  return fgCode + 10;
@@ -4,6 +4,7 @@ import { getDisplayWidth, truncateDisplay } from '../utils/terminal-width.ts';
4
4
  import type { ToolCall } from '@pellux/goodvibes-sdk/platform/types';
5
5
  import { stripDangerousAnsi } from './ansi-sanitize.ts';
6
6
  import { formatElapsed } from '../utils/format-elapsed.ts';
7
+ import { UI_TONES } from './ui-primitives.ts';
7
8
 
8
9
  const TOOL_NAME_MIN_WIDTH = 8;
9
10
  const TOOL_NAME_MAX_WIDTH = 20;
@@ -73,14 +74,14 @@ function buildLeftSegments(
73
74
  segments.push({ text: toolNameDisplay, fg: '#00ffcc', bold: true });
74
75
  }
75
76
  if (keyArgDisplay) {
76
- segments.push({ text: ' ', fg: '#e2e8f0' });
77
+ segments.push({ text: ' ', fg: UI_TONES.fg.primary });
77
78
  segments.push({ text: keyArgDisplay, fg: '252' });
78
79
  }
79
80
  if (suffixDisplay) {
80
- segments.push({ text: ' ', fg: '#e2e8f0' });
81
+ segments.push({ text: ' ', fg: UI_TONES.fg.primary });
81
82
  segments.push({
82
83
  text: suffixDisplay,
83
- fg: suffixText.startsWith('- ') ? '#ef4444' : '244',
84
+ fg: suffixText.startsWith('- ') ? UI_TONES.state.bad : '244',
84
85
  dim: true,
85
86
  });
86
87
  }
@@ -171,8 +172,8 @@ export function renderToolCallBlock(
171
172
  const icon = status === 'done' ? TOOL_STATUS.SUCCESS_ICON
172
173
  : status === 'error' ? TOOL_STATUS.FAIL_ICON
173
174
  : TOOL_STATUS.SPINNER_FRAMES[(frameIndex ?? 0) % TOOL_STATUS.SPINNER_FRAMES.length];
174
- const iconColor = status === 'done' ? '#22c55e'
175
- : status === 'error' ? '#ef4444'
175
+ const iconColor = status === 'done' ? UI_TONES.state.good
176
+ : status === 'error' ? UI_TONES.state.bad
176
177
  : '244';
177
178
  const rightText = (() => {
178
179
  if (durationMs !== undefined && status === 'done') {
@@ -227,18 +228,3 @@ export function renderToolCallBlock(
227
228
 
228
229
  return [line];
229
230
  }
230
-
231
- /**
232
- * Render a list of tool calls. All calls are treated as completed (done).
233
- * Used for historical message rendering where status/timing is unavailable.
234
- */
235
- export function renderToolCallList(
236
- toolCalls: ToolCall[],
237
- width: number,
238
- ): Line[] {
239
- const lines: Line[] = [];
240
- for (const tc of toolCalls) {
241
- lines.push(...renderToolCallBlock(tc, 'done', undefined, width));
242
- }
243
- return lines;
244
- }
@@ -268,15 +268,15 @@ export class UIFactory {
268
268
  // Suppressed in compact mode; the ctx-info line no longer duplicates these
269
269
  // tokens, so this block is the single home for mode/status/flags.
270
270
  const composerTokens: Array<{ text: string; fg: string; bold?: boolean; dim?: boolean }> = [];
271
- if (composerMode) composerTokens.push({ text: ` ${GLYPHS.status.active} ${composerMode} `, fg: '#38bdf8', bold: true });
271
+ if (composerMode) composerTokens.push({ text: ` ${GLYPHS.status.active} ${composerMode} `, fg: UI_TONES.state.info, bold: true });
272
272
  if (composerPendingRisk && composerPendingRisk !== 'none') {
273
273
  const riskColor = composerPendingRisk === 'approval-wait'
274
- ? '#f59e0b'
274
+ ? UI_TONES.state.warn
275
275
  : composerPendingRisk === 'shell'
276
- ? '#ef4444'
276
+ ? UI_TONES.state.bad
277
277
  : composerPendingRisk === 'remote'
278
278
  ? '#a78bfa'
279
- : '#f59e0b';
279
+ : UI_TONES.state.warn;
280
280
  composerTokens.push({ text: ` risk:${composerPendingRisk} `, fg: riskColor, bold: true });
281
281
  }
282
282
  if (composerStatus && composerStatus !== 'idle') composerTokens.push({ text: ` state:${composerStatus} `, fg: '244', dim: true });
@@ -368,7 +368,7 @@ export class UIFactory {
368
368
  for (const ch of dangerWarn) {
369
369
  if (col >= width) break;
370
370
  const cw = getDisplayWidth(ch);
371
- line[col] = { char: ch, fg: '#ef4444', bg: '', bold: true, dim: false, underline: false, italic: false, strikethrough: false };
371
+ line[col] = { char: ch, fg: UI_TONES.state.bad, bg: '', bold: true, dim: false, underline: false, italic: false, strikethrough: false };
372
372
  if (cw === 2 && col + 1 < width) line[col + 1] = { ...line[col], char: '' };
373
373
  col += cw;
374
374
  }
@@ -430,7 +430,7 @@ export class UIFactory {
430
430
  const inTok = inputTokens ?? 0;
431
431
  const outTok = outputTokens ?? 0;
432
432
  segments.push({ text: ` in ${fmtNum(inTok)} `, fg: '243', dim: true });
433
- segments.push({ text: `out ${fmtNum(outTok)}`, fg: '#00ffff' });
433
+ segments.push({ text: `out ${fmtNum(outTok)}`, fg: UI_TONES.accent.brand });
434
434
  }
435
435
  const line = createEmptyLine(width);
436
436
  let col = 1;
@@ -470,7 +470,7 @@ export class UIFactory {
470
470
  if (px >= width) break;
471
471
  previewLine[px] = {
472
472
  char: ch,
473
- fg: '#38bdf8',
473
+ fg: UI_TONES.state.info,
474
474
  bg: '',
475
475
  bold: true,
476
476
  dim: false,
@@ -111,7 +111,24 @@ export const UI_TONES = {
111
111
  border: '#64748b',
112
112
  } as const;
113
113
 
114
- export type UiGlyphRegistry = typeof GLYPHS;
114
+ /**
115
+ * Diff surface accent color shared across the three diff-rendering surfaces —
116
+ * the conversation diff view (diff-view.ts), the file diff panel
117
+ * (diff-panel.ts), and the git panel's inline diff (git-panel.ts). Hunk-header
118
+ * blue has no matching UI_TONES.state/accent role, so WO-204 gives it one
119
+ * named token here (value converged on diff-panel.ts's pre-existing hunk
120
+ * color, the only one of the three surfaces that predates this token; add/del
121
+ * on all three surfaces already converge on UI_TONES.state.good/bad).
122
+ */
123
+ export const DIFF_TONES = {
124
+ // add/del carry diff-panel.ts's shipped colors: the diff panel is the only
125
+ // diff surface users have ever seen (diff-view.ts was unwired from v0.9.6
126
+ // until WO-204), so its look is the reference — the conversation surface
127
+ // adopts it, never the other way around.
128
+ add: '#00ff88',
129
+ del: '#ff4444',
130
+ hunk: '#88aaff',
131
+ } as const;
115
132
 
116
133
  /** Single spinner-frame source — layout.ts and progress.ts both re-export this. */
117
134
  export const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] as const;
@@ -0,0 +1,80 @@
1
+ /**
2
+ * render-scheduler.ts — same-tick render coalescing for the TUI shell (WO-208).
3
+ *
4
+ * main.ts fans out its own direct render() calls across turn/stream/input wiring
5
+ * (~14 direct invocation contexts). Each one previously ran a full synchronous
6
+ * Compositor.composite() the instant it was called. When several fire within a
7
+ * single event-loop tick — a streaming burst is the canonical case — only the
8
+ * LAST frame is ever visible; the earlier composites are immediately overwritten.
9
+ * This scheduler collapses every schedule() call made within one tick into a
10
+ * single composite flushed on the microtask queue, so the tick produces exactly
11
+ * one frame. That frame is byte-identical to what the last synchronous render()
12
+ * would have produced, because the coalesced flush runs after all of the tick's
13
+ * synchronous state mutations, reading the same final state the last direct
14
+ * render() would have read.
15
+ *
16
+ * This is deliberately SEPARATE from bootstrap-core's requestRender(), which is a
17
+ * cross-tick, 16ms-throttled (~60fps) coalescer for the panel/input/runtime
18
+ * fan-out — that one caps repaint RATE across ticks. This one collapses the
19
+ * WITHIN-tick burst with no added latency: a microtask flushes at the tail of the
20
+ * current tick, before the event loop yields to I/O, so the frame still lands in
21
+ * the same turn it was requested in.
22
+ *
23
+ * flushNow() preserves a synchronous immediate path for callers that genuinely
24
+ * need the frame composited before they return. Terminal resize is the known
25
+ * case: the resize handler resets the compositor diff and must repaint against
26
+ * the new dimensions synchronously rather than waiting for the microtask.
27
+ * flushNow() also clears any pending coalesced flush, so a resize (or any
28
+ * immediate paint) followed by an already-queued microtask never double-composites.
29
+ */
30
+
31
+ export interface RenderScheduler {
32
+ /**
33
+ * Coalesced request: schedule a composite for the current tick. N calls within
34
+ * one tick collapse into exactly one composite, flushed on the microtask queue.
35
+ */
36
+ readonly schedule: () => void;
37
+ /**
38
+ * Immediate request: run the composite synchronously right now, and cancel any
39
+ * pending coalesced flush so the tick still composites only once. Use only where
40
+ * a caller genuinely requires synchronous output (terminal resize).
41
+ */
42
+ readonly flushNow: () => void;
43
+ }
44
+
45
+ /**
46
+ * Build a render scheduler around `renderNow` (the synchronous composite closure).
47
+ *
48
+ * `scheduleFlush` is the deferral primitive; it defaults to `queueMicrotask` so
49
+ * bursts coalesce within the current tick. Tests and benchmarks inject a manual
50
+ * queue to flush deterministically.
51
+ */
52
+ export function createRenderScheduler(
53
+ renderNow: () => void,
54
+ scheduleFlush: (flush: () => void) => void = queueMicrotask,
55
+ ): RenderScheduler {
56
+ let scheduled = false;
57
+
58
+ const flush = (): void => {
59
+ // A synchronous flushNow() (or a superseding immediate paint) before this
60
+ // microtask ran clears `scheduled`, turning this into a no-op so a single
61
+ // tick never composites twice.
62
+ if (!scheduled) return;
63
+ scheduled = false;
64
+ renderNow();
65
+ };
66
+
67
+ const schedule = (): void => {
68
+ if (scheduled) return;
69
+ scheduled = true;
70
+ scheduleFlush(flush);
71
+ };
72
+
73
+ const flushNow = (): void => {
74
+ // Satisfy any pending coalesced flush, then composite synchronously.
75
+ scheduled = false;
76
+ renderNow();
77
+ };
78
+
79
+ return { schedule, flushNow };
80
+ }
@@ -20,7 +20,8 @@ const SEPARATOR = '━'.repeat(ART_W);
20
20
  */
21
21
  const TAGLINE = '[ good vibes ・ A I ・ いい雰囲気 ]';
22
22
 
23
- const VERSION_LINE = ` ✦ v${VERSION} █ terminal AI assistant █ 自動コード  ✦`;
23
+ const versionLine = (version: string) =>
24
+ ` ✦ v${version} █ terminal AI assistant █ 自動コード  ✦`;
24
25
 
25
26
  /** Fixed hint line — the three primary shell entry points. */
26
27
  const HINT_LINE = 'Ctrl+P panels / ? help / F2 processes';
@@ -35,6 +36,13 @@ export interface SplashOptions {
35
36
  * When present, the splash advertises a resume affordance.
36
37
  */
37
38
  lastSessionId?: string;
39
+ /**
40
+ * Version string rendered on the splash's version line. Defaults to the
41
+ * build VERSION; injectable so the golden-frame tests can pin a fixture —
42
+ * the version's display width shifts the line's centering, so goldens tied
43
+ * to the live VERSION break on every release bump (v1.0.0 release failure).
44
+ */
45
+ version?: string;
38
46
  }
39
47
 
40
48
  /** Collapse a $HOME-prefixed working directory to a leading `~`. */
@@ -54,7 +62,7 @@ export function getSplashLines(columns: number, opts: SplashOptions = {}): strin
54
62
  ...ART_LINES.map((line) => center(line, columns)),
55
63
  center(SEPARATOR, columns),
56
64
  center(TAGLINE, columns),
57
- center(VERSION_LINE, columns),
65
+ center(versionLine(opts.version ?? VERSION), columns),
58
66
  '',
59
67
  ];
60
68