@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
@@ -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
  }
@@ -85,7 +85,7 @@ function buildAgentLabel(rec: AgentRecord, deps: ProcessModalDeps): string {
85
85
  if (task.startsWith('WRFC Review Request')) {
86
86
  const thresholdMatch = task.match(/threshold is (\d+(?:\.\d+)?)/);
87
87
  const threshold = thresholdMatch ? thresholdMatch[1] : '9.9';
88
- const desc = truncateFirst(originalTask ?? 'review in progress', 50);
88
+ const desc = truncateFirst(originalTask ?? embeddedTaskDescription(task) ?? 'review in progress', 50);
89
89
  return `[Review] ${desc} (target: ${threshold}/10)`;
90
90
  }
91
91
 
@@ -96,7 +96,7 @@ function buildAgentLabel(rec: AgentRecord, deps: ProcessModalDeps): string {
96
96
  const toScore = scoreMatch ? scoreMatch[3] : '?';
97
97
  const attemptMatch = task.match(/Fix attempt:\s*(\d+)/);
98
98
  const attempt = attemptMatch ? attemptMatch[1] : '?';
99
- const desc = truncateFirst(originalTask ?? 'fix in progress', 45);
99
+ const desc = truncateFirst(originalTask ?? embeddedTaskDescription(task) ?? 'fix in progress', 45);
100
100
  // Show constraint count when the chain has constraints to target (SDK 0.23.0)
101
101
  const chain = rec.wrfcId ? safeGetChain(rec.wrfcId, deps) : null;
102
102
  const constraintCount = chain && (chain.constraints?.length ?? 0) > 0 ? chain.constraints?.length ?? 0 : 0;
@@ -441,6 +441,21 @@ function getChainTask(wrfcId: string | undefined, deps: Pick<ProcessModalDeps, '
441
441
  return safeGetChain(wrfcId, deps)?.task ?? null;
442
442
  }
443
443
 
444
+ /**
445
+ * Fallback for when the WRFC chain lookup comes back null (chain already
446
+ * completed/evicted, or wrfcId not populated on the record) — the record's
447
+ * own `rec.task` is seeded as `'WRFC Review Request\n<original task
448
+ * description>'` / `'WRFC Fix Request\n...'`, so the description is sitting
449
+ * right there even without the chain. Returns null (not the generic
450
+ * placeholder) when there's no second line to extract.
451
+ */
452
+ function embeddedTaskDescription(task: string): string | null {
453
+ const firstNewline = task.indexOf('\n');
454
+ if (firstNewline === -1) return null;
455
+ const desc = task.slice(firstNewline + 1).trim();
456
+ return desc.length > 0 ? desc : null;
457
+ }
458
+
444
459
  /** Truncate to first line, capped at max chars. */
445
460
  function truncateFirst(text: string, max: number): string {
446
461
  const line = text.split('\n')[0].trim();
@@ -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',
@@ -8,6 +8,13 @@ export interface ShellFooterBuildOptions {
8
8
  readonly promptLineCount: number;
9
9
  readonly promptCursorPos?: number;
10
10
  readonly promptFocused?: boolean;
11
+ /**
12
+ * True when the panel workspace owns keyboard focus. Only consulted as a
13
+ * fallback when `promptFocused` is not supplied — see buildShellFooter's
14
+ * default expression. Callers that already compute `promptFocused`
15
+ * themselves (main.ts does) do not need to also pass this.
16
+ */
17
+ readonly panelFocused?: boolean;
11
18
  readonly usage: { up: number; down: number };
12
19
  readonly showExitNotice: boolean;
13
20
  readonly lastCopyTime: number;
@@ -54,22 +61,35 @@ export interface ShellFooterBuildResult {
54
61
  const FOOTER_BASE_ROWS = 5;
55
62
  const CONTEXT_PROGRESS_ROWS = 1;
56
63
  const PROCESS_INDICATOR_ROWS = 1;
64
+ // Compact posture drops the context-info line (and never shows the context
65
+ // bar or process indicator), leaving just: prompt-box top border, bottom
66
+ // border, token-usage line, and the help/exit line.
67
+ const COMPACT_FOOTER_BASE_ROWS = 4;
57
68
 
58
69
  /**
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.
70
+ * Real height of the most recently rendered footer, tagged with the compact
71
+ * posture it was rendered under. estimateShellFooterHeight prefers this so
72
+ * the pre-prompt viewport math accounts for the composer posture block and
73
+ * context hint that the static formula cannot see. Reset to null before any
74
+ * footer has rendered (cold start), where the formula is exact for the
75
+ * common posture-free, hint-free case. The compact flag is part of the cache
76
+ * key: a cached non-compact height must never answer a compact query (and
77
+ * vice versa), since the two postures differ by several rows.
64
78
  */
65
- let lastRenderedFooterHeight: number | null = null;
79
+ let lastRenderedFooterHeight: { compact: boolean; height: number } | null = null;
66
80
 
67
81
  export function estimateShellFooterHeight(
68
82
  promptLineCount: number,
69
83
  contextWindow?: number,
84
+ compact = false,
70
85
  ): number {
71
- if (lastRenderedFooterHeight !== null) return lastRenderedFooterHeight;
86
+ if (lastRenderedFooterHeight !== null && lastRenderedFooterHeight.compact === compact) {
87
+ return lastRenderedFooterHeight.height;
88
+ }
72
89
  const safePromptLines = Math.max(1, promptLineCount);
90
+ if (compact) {
91
+ return COMPACT_FOOTER_BASE_ROWS + safePromptLines;
92
+ }
73
93
  const progressRows = contextWindow && contextWindow > 0 ? CONTEXT_PROGRESS_ROWS : 0;
74
94
  return FOOTER_BASE_ROWS + safePromptLines + progressRows + PROCESS_INDICATOR_ROWS;
75
95
  }
@@ -94,7 +114,7 @@ export function buildShellFooter(
94
114
  options.lastInputTokens,
95
115
  options.commandArgsHint,
96
116
  options.hitlMode,
97
- options.promptFocused ?? !options.indicatorFocused,
117
+ options.promptFocused ?? (!options.indicatorFocused && !options.panelFocused),
98
118
  options.composerMode,
99
119
  options.composerStatus,
100
120
  options.composerFlags,
@@ -119,6 +139,6 @@ export function buildShellFooter(
119
139
  lines.unshift(hintLine);
120
140
  }
121
141
  }
122
- lastRenderedFooterHeight = lines.length;
142
+ lastRenderedFooterHeight = { compact: options.compact ?? false, height: lines.length };
123
143
  return { lines, height: lines.length };
124
144
  }
@@ -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
- }
@@ -8,13 +8,36 @@ import { GLYPHS, UI_TONES } from './ui-primitives.ts';
8
8
  import { formatElapsed } from '../utils/format-elapsed.ts';
9
9
  import { abbreviateCount } from '../utils/format-number.ts';
10
10
  import { computeContextUsage } from '../core/context-usage.ts';
11
- import { calcSessionCost } from '../export/cost-utils.ts';
11
+ import { calcSessionCost, isModelPriced } from '../export/cost-utils.ts';
12
12
  import { buildFooterTip, isAgentActive } from './footer-tips.ts';
13
+ import type { StreamMetrics } from '../core/stream-event-wiring.ts';
13
14
 
14
15
  /** Number of frames before the animated gradient completes one full cycle. */
15
16
  const GRADIENT_CYCLE_FRAMES = 50;
16
17
  /** Number of frames before rotating to the next thinking phrase (~30 seconds at 80ms/frame). */
17
18
  const PHRASE_ROTATION_FRAMES = 375;
19
+ /**
20
+ * Ms since the last STREAM_DELTA before the whimsical phrase rotation freezes
21
+ * and createThinkingFragment shows an honest "stalled Ns" / "reconnecting"
22
+ * label instead. Deliberately much shorter than the 30s stream-stall-watchdog
23
+ * hint threshold (stream-stall-watchdog.ts) — that threshold gates a
24
+ * low-priority system message about a likely-dead connection; this one gates
25
+ * a cosmetic label so the UI stops claiming "Vibing..." within a couple of
26
+ * seconds of real silence, well before the stall is confirmed as a problem.
27
+ */
28
+ const THINKING_STALL_FREEZE_MS = 2_500;
29
+
30
+ /**
31
+ * Stall/reconnect state for the live thinking indicator, computed by the
32
+ * caller every render frame from streamMetrics (see stream-event-wiring.ts).
33
+ * `reconnect` is populated only once the SDK's STREAM_RETRY event fires
34
+ * (structurally consumed — absent from SDK 0.35.0's TurnEvent union today).
35
+ */
36
+ export interface ThinkingStallInfo {
37
+ /** Ms since the last STREAM_DELTA (or STREAM_START if none yet this turn). */
38
+ readonly msSinceLastDelta: number;
39
+ readonly reconnect?: { readonly attempt: number; readonly maxAttempts: number };
40
+ }
18
41
 
19
42
  /** Build the git segment string and its display width. Single source of truth for header layout. */
20
43
  function buildGitSegment(gitInfo: GitHeaderInfo): { text: string; width: number } {
@@ -268,15 +291,15 @@ export class UIFactory {
268
291
  // Suppressed in compact mode; the ctx-info line no longer duplicates these
269
292
  // tokens, so this block is the single home for mode/status/flags.
270
293
  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 });
294
+ if (composerMode) composerTokens.push({ text: ` ${GLYPHS.status.active} ${composerMode} `, fg: UI_TONES.state.info, bold: true });
272
295
  if (composerPendingRisk && composerPendingRisk !== 'none') {
273
296
  const riskColor = composerPendingRisk === 'approval-wait'
274
- ? '#f59e0b'
297
+ ? UI_TONES.state.warn
275
298
  : composerPendingRisk === 'shell'
276
- ? '#ef4444'
299
+ ? UI_TONES.state.bad
277
300
  : composerPendingRisk === 'remote'
278
301
  ? '#a78bfa'
279
- : '#f59e0b';
302
+ : UI_TONES.state.warn;
280
303
  composerTokens.push({ text: ` risk:${composerPendingRisk} `, fg: riskColor, bold: true });
281
304
  }
282
305
  if (composerStatus && composerStatus !== 'idle') composerTokens.push({ text: ` state:${composerStatus} `, fg: '244', dim: true });
@@ -313,7 +336,15 @@ export class UIFactory {
313
336
  const cw = u.cacheWrite ?? 0;
314
337
  const total = inp + out + cr + cw;
315
338
  const tokenSep = ` ${GLYPHS.navigation.pipeSeparator} `;
316
- const costSegment = model ? `${tokenSep}~$${fmtCost(calcSessionCost(inp, out, cr, cw, model))}` : '';
339
+ // 'n/a' (not 'unpriced') to stay compact in the single-line footer and
340
+ // match the existing "no priceable data" convention used elsewhere
341
+ // (cockpit-panel formatCost, agent-inspector-shared) — the footer has no
342
+ // room for a longer marker before truncation kicks in.
343
+ const costSegment = model
344
+ ? isModelPriced(model)
345
+ ? `${tokenSep}~$${fmtCost(calcSessionCost(inp, out, cr, cw, model))}`
346
+ : `${tokenSep}~n/a`
347
+ : '';
317
348
  const tokenLine = ` Token Usage [ Input: ${fmtNum(inp)}${tokenSep}Output: ${fmtNum(out)}${tokenSep}Cache Read: ${fmtNum(cr)}${tokenSep}Cache Write: ${fmtNum(cw)}${tokenSep}Total: ${fmtNum(total)}${costSegment} ]`;
318
349
  const copiedNotice = isRecentlyCopied ? ` [COPIED] ` : '';
319
350
  const statsLine = ' ' + tokenLine + ' '.repeat(Math.max(0, width - 4 - getDisplayWidth(tokenLine) - getDisplayWidth(copiedNotice))) + copiedNotice;
@@ -345,7 +376,11 @@ export class UIFactory {
345
376
  ctxParts.push(model + (provider ? ` (${provider})` : ''));
346
377
  }
347
378
  if (toolCount) ctxParts.push(`${toolCount} tools`);
348
- if (hitlMode) ctxParts.push(`hitl:${hitlMode}`);
379
+ // Labeled "notify" (not "hitl") — /mode (aliased /hitl) governs UX
380
+ // notification verbosity (quiet/balanced/operator), not tool
381
+ // auto-approval, so it must not share vocabulary with the DANGER MODE
382
+ // risk banner rendered a few lines below.
383
+ if (hitlMode) ctxParts.push(`notify:${hitlMode}`);
349
384
  const ctxLine = ' ' + ctxParts.join(` ${GLYPHS.navigation.pipeSeparator} `);
350
385
  lines.push(this.stringToLine(truncateDisplay(ctxLine, width), width, { fg: '240', dim: true }));
351
386
  }
@@ -368,7 +403,7 @@ export class UIFactory {
368
403
  for (const ch of dangerWarn) {
369
404
  if (col >= width) break;
370
405
  const cw = getDisplayWidth(ch);
371
- line[col] = { char: ch, fg: '#ef4444', bg: '', bold: true, dim: false, underline: false, italic: false, strikethrough: false };
406
+ line[col] = { char: ch, fg: UI_TONES.state.bad, bg: '', bold: true, dim: false, underline: false, italic: false, strikethrough: false };
372
407
  if (cw === 2 && col + 1 < width) line[col + 1] = { ...line[col], char: '' };
373
408
  col += cw;
374
409
  }
@@ -406,10 +441,59 @@ export class UIFactory {
406
441
  private static readonly THINK_GRADIENT_START = UI_TONES.accent.gradientStart;
407
442
  private static readonly THINK_GRADIENT_END = UI_TONES.accent.gradientEnd;
408
443
 
409
- public static createThinkingFragment(width: number, spinner: string, frame: number = 0, tokenSpeed?: number, toolPreview?: string, inputTokens?: number, outputTokens?: number, elapsedMs?: number, ttftMs?: number): Line[] {
410
- // Rotate phrase every ~30 seconds (frame ticks at 80ms, so ~375 frames)
411
- const phraseIndex = Math.floor(frame / PHRASE_ROTATION_FRAMES) % this.THINKING_PHRASES.length;
412
- const phrase = this.THINKING_PHRASES[phraseIndex];
444
+ /**
445
+ * Per-frame stall info from stream metrics computed from lastDeltaAtMs every render (not
446
+ * from any event) so it degrades gracefully with zero new SDK events. Undefined until the
447
+ * first delta clock exists this turn.
448
+ */
449
+ public static computeStallInfo(lastDeltaAtMs: number | undefined, reconnectAttempt: number | undefined, reconnectMaxAttempts: number | undefined, nowMs: number): ThinkingStallInfo | undefined {
450
+ if (lastDeltaAtMs === undefined) return undefined;
451
+ const reconnect = reconnectAttempt !== undefined && reconnectMaxAttempts !== undefined
452
+ ? { attempt: reconnectAttempt, maxAttempts: reconnectMaxAttempts }
453
+ : undefined;
454
+ return { msSinceLastDelta: nowMs - lastDeltaAtMs, reconnect };
455
+ }
456
+
457
+ /**
458
+ * Render-frame stall-info decision used at the main render loop's call
459
+ * site: suppress stall detection entirely while a tool is actively
460
+ * executing. lastDeltaAtMs only tracks STREAM_START/STREAM_DELTA and is
461
+ * never advanced during tool execution (the model isn't producing tokens
462
+ * then), so without this gate any tool call longer than
463
+ * THINKING_STALL_FREEZE_MS would make the thinking fragment print
464
+ * "Stalled Ns..." directly above the ticking "executing (Ns)" tool row — a
465
+ * false positive during ordinary tool execution (see stream-event-wiring.ts
466
+ * TOOL_EXECUTING/TOOL_SUCCEEDED/TOOL_FAILED/TOOL_CANCELLED handlers).
467
+ * Genuine no-delta silence while waiting on the provider — including the
468
+ * pre-first-token case, where lastDeltaAtMs is seeded at STREAM_START —
469
+ * still stall-detects normally here, since no tool is active then; that is
470
+ * the honest stall case this indicator exists for.
471
+ */
472
+ public static computeRenderStallInfo(
473
+ metrics: Pick<StreamMetrics, 'activeToolName' | 'lastDeltaAtMs' | 'reconnectAttempt' | 'reconnectMaxAttempts'>,
474
+ nowMs: number,
475
+ ): ThinkingStallInfo | undefined {
476
+ return metrics.activeToolName === undefined
477
+ ? this.computeStallInfo(metrics.lastDeltaAtMs, metrics.reconnectAttempt, metrics.reconnectMaxAttempts, nowMs)
478
+ : undefined;
479
+ }
480
+
481
+ public static createThinkingFragment(width: number, spinner: string, frame: number = 0, tokenSpeed?: number, toolPreview?: string, inputTokens?: number, outputTokens?: number, elapsedMs?: number, ttftMs?: number, stallInfo?: ThinkingStallInfo): Line[] {
482
+ // Freeze the whimsical phrase rotation once real silence has gone on
483
+ // long enough to be misleading (THINKING_STALL_FREEZE_MS), and show an
484
+ // honest label instead: the SDK's reconnect attempt/maxAttempts once
485
+ // STREAM_RETRY is available, else a plain elapsed-silence readout.
486
+ const isStalled = stallInfo !== undefined && stallInfo.msSinceLastDelta >= THINKING_STALL_FREEZE_MS;
487
+ let phrase: string;
488
+ if (stallInfo?.reconnect) {
489
+ phrase = `Reconnecting (attempt ${stallInfo.reconnect.attempt}/${stallInfo.reconnect.maxAttempts})...`;
490
+ } else if (isStalled) {
491
+ phrase = `Stalled ${Math.floor(stallInfo.msSinceLastDelta / 1000)}s...`;
492
+ } else {
493
+ // Rotate phrase every ~30 seconds (frame ticks at 80ms, so ~375 frames)
494
+ const phraseIndex = Math.floor(frame / PHRASE_ROTATION_FRAMES) % this.THINKING_PHRASES.length;
495
+ phrase = this.THINKING_PHRASES[phraseIndex];
496
+ }
413
497
  const speedSuffix = (tokenSpeed !== undefined && tokenSpeed > 0) ? ` (${Math.round(tokenSpeed)} tok/s)` : '';
414
498
  const elapsedSuffix = elapsedMs !== undefined ? ` (${formatElapsed(elapsedMs)})` : '';
415
499
  const ttftSuffix = (ttftMs !== undefined && ttftMs > 0) ? ` ttft:${ttftMs}ms` : '';
@@ -430,7 +514,7 @@ export class UIFactory {
430
514
  const inTok = inputTokens ?? 0;
431
515
  const outTok = outputTokens ?? 0;
432
516
  segments.push({ text: ` in ${fmtNum(inTok)} `, fg: '243', dim: true });
433
- segments.push({ text: `out ${fmtNum(outTok)}`, fg: '#00ffff' });
517
+ segments.push({ text: `out ${fmtNum(outTok)}`, fg: UI_TONES.accent.brand });
434
518
  }
435
519
  const line = createEmptyLine(width);
436
520
  let col = 1;
@@ -470,7 +554,7 @@ export class UIFactory {
470
554
  if (px >= width) break;
471
555
  previewLine[px] = {
472
556
  char: ch,
473
- fg: '#38bdf8',
557
+ fg: UI_TONES.state.info,
474
558
  bg: '',
475
559
  bold: true,
476
560
  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;