@pellux/goodvibes-tui 0.28.0 → 0.29.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 (118) hide show
  1. package/CHANGELOG.md +101 -0
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/src/input/commands/cost-runtime.ts +49 -0
  5. package/src/input/commands/operator-runtime.ts +5 -1
  6. package/src/input/commands.ts +2 -0
  7. package/src/input/handler-feed-routes.ts +12 -35
  8. package/src/input/handler-feed.ts +4 -8
  9. package/src/input/handler-shortcuts.ts +51 -0
  10. package/src/input/handler.ts +43 -6
  11. package/src/input/keybindings.ts +48 -8
  12. package/src/input/panel-integration-actions.ts +107 -1
  13. package/src/main.ts +5 -1
  14. package/src/panels/agent-inspector-panel.ts +125 -36
  15. package/src/panels/agent-inspector-shared.ts +144 -0
  16. package/src/panels/approval-panel.ts +67 -16
  17. package/src/panels/automation-control-panel.ts +368 -124
  18. package/src/panels/builtin/agent.ts +28 -37
  19. package/src/panels/builtin/development.ts +40 -32
  20. package/src/panels/builtin/knowledge.ts +15 -6
  21. package/src/panels/builtin/operations.ts +178 -117
  22. package/src/panels/builtin/session.ts +35 -9
  23. package/src/panels/builtin/shared.ts +98 -6
  24. package/src/panels/cockpit-panel.ts +232 -73
  25. package/src/panels/communication-panel.ts +58 -20
  26. package/src/panels/confirm-state.ts +8 -1
  27. package/src/panels/control-plane-panel.ts +351 -118
  28. package/src/panels/cost-tracker-panel.ts +165 -7
  29. package/src/panels/debug-panel.ts +312 -159
  30. package/src/panels/diff-panel.ts +203 -57
  31. package/src/panels/docs-panel.ts +152 -66
  32. package/src/panels/eval-panel.ts +220 -42
  33. package/src/panels/file-explorer-panel.ts +202 -122
  34. package/src/panels/file-preview-panel.ts +132 -57
  35. package/src/panels/git-panel.ts +309 -128
  36. package/src/panels/hooks-panel.ts +150 -22
  37. package/src/panels/incident-review-panel.ts +223 -35
  38. package/src/panels/index.ts +0 -4
  39. package/src/panels/intelligence-panel.ts +212 -86
  40. package/src/panels/knowledge-graph-panel.ts +461 -101
  41. package/src/panels/local-auth-panel.ts +240 -28
  42. package/src/panels/marketplace-panel.ts +193 -26
  43. package/src/panels/memory-panel.ts +78 -77
  44. package/src/panels/ops-control-panel.ts +146 -29
  45. package/src/panels/ops-strategy-panel.ts +72 -4
  46. package/src/panels/orchestration-panel.ts +231 -69
  47. package/src/panels/panel-list-panel.ts +154 -131
  48. package/src/panels/panel-manager.ts +97 -9
  49. package/src/panels/plan-dashboard-panel.ts +333 -17
  50. package/src/panels/plugins-panel.ts +184 -29
  51. package/src/panels/policy-panel.ts +210 -38
  52. package/src/panels/polish-core.ts +7 -2
  53. package/src/panels/polish.ts +23 -4
  54. package/src/panels/project-planning-answer-actions.ts +134 -0
  55. package/src/panels/project-planning-panel.ts +62 -113
  56. package/src/panels/provider-health-panel.ts +434 -518
  57. package/src/panels/provider-health-routes.ts +203 -0
  58. package/src/panels/provider-health-tracker.ts +194 -6
  59. package/src/panels/provider-health-views.ts +560 -0
  60. package/src/panels/qr-panel.ts +116 -30
  61. package/src/panels/remote-panel.ts +114 -36
  62. package/src/panels/routes-panel.ts +63 -22
  63. package/src/panels/sandbox-panel.ts +174 -42
  64. package/src/panels/scrollable-list-panel.ts +19 -135
  65. package/src/panels/security-panel.ts +133 -33
  66. package/src/panels/services-panel.ts +116 -64
  67. package/src/panels/session-browser-panel.ts +73 -2
  68. package/src/panels/session-maintenance.ts +4 -122
  69. package/src/panels/settings-sync-panel.ts +335 -72
  70. package/src/panels/skills-panel.ts +157 -89
  71. package/src/panels/subscription-panel.ts +86 -33
  72. package/src/panels/symbol-outline-panel.ts +248 -108
  73. package/src/panels/system-messages-panel.ts +114 -13
  74. package/src/panels/tasks-panel.ts +326 -139
  75. package/src/panels/thinking-panel.ts +43 -10
  76. package/src/panels/token-budget-panel.ts +194 -18
  77. package/src/panels/tool-inspector-panel.ts +144 -34
  78. package/src/panels/types.ts +40 -4
  79. package/src/panels/work-plan-panel.ts +280 -17
  80. package/src/panels/worktree-panel.ts +175 -42
  81. package/src/panels/wrfc-panel.ts +116 -24
  82. package/src/renderer/conversation-overlays.ts +25 -11
  83. package/src/renderer/footer-tips.ts +41 -0
  84. package/src/renderer/fullscreen-primitives.ts +22 -16
  85. package/src/renderer/help-overlay.ts +91 -14
  86. package/src/renderer/hint-grammar.ts +52 -0
  87. package/src/renderer/layout.ts +7 -7
  88. package/src/renderer/modal-factory.ts +23 -15
  89. package/src/renderer/model-picker-overlay.ts +21 -7
  90. package/src/renderer/overlay-box.ts +16 -10
  91. package/src/renderer/process-indicator.ts +3 -1
  92. package/src/renderer/progress.ts +5 -4
  93. package/src/renderer/search-overlay.ts +27 -6
  94. package/src/renderer/session-picker-modal.ts +6 -4
  95. package/src/renderer/settings-modal.ts +70 -10
  96. package/src/renderer/shell-surface.ts +41 -15
  97. package/src/renderer/status-glyphs.ts +11 -9
  98. package/src/renderer/theme.ts +60 -3
  99. package/src/renderer/ui-factory.ts +41 -30
  100. package/src/renderer/ui-primitives.ts +23 -2
  101. package/src/runtime/bootstrap-shell.ts +35 -18
  102. package/src/runtime/diagnostics/panels/index.ts +0 -3
  103. package/src/shell/ui-openers.ts +14 -22
  104. package/src/utils/format-duration.ts +2 -2
  105. package/src/utils/splash-lines.ts +44 -3
  106. package/src/version.ts +1 -1
  107. package/src/work-plans/work-plan-store.ts +13 -0
  108. package/src/panels/agent-logs-panel.ts +0 -635
  109. package/src/panels/agent-logs-shared.ts +0 -129
  110. package/src/panels/context-visualizer-panel.ts +0 -238
  111. package/src/panels/forensics-panel.ts +0 -378
  112. package/src/panels/provider-account-snapshot.ts +0 -259
  113. package/src/panels/provider-accounts-panel.ts +0 -255
  114. package/src/panels/provider-stats-panel.ts +0 -391
  115. package/src/panels/schedule-panel.ts +0 -365
  116. package/src/panels/watchers-panel.ts +0 -213
  117. package/src/runtime/diagnostics/panels/panel-resources.ts +0 -118
  118. package/src/runtime/diagnostics/panels/policy.ts +0 -177
@@ -25,18 +25,39 @@ export function renderSearchOverlay(
25
25
  const locked = manager.locked;
26
26
  const cursor = locked ? '' : '█';
27
27
  const queryDisplay = manager.query + cursor;
28
- const hints = locked
28
+ const label = ' Find: ';
29
+ const fullHints = locked
29
30
  ? ' [n/N] next/prev [jk] navigate [Bksp] edit [Esc] close'
30
31
  : ' [Enter/Tab] lock [Esc] close';
31
- const label = ' Find: ';
32
- const matchStr = matchCount ? ` ${matchCount}` : '';
32
+ const shortHints = locked ? ' [n/N] [Esc]' : ' [Esc]';
33
33
 
34
34
  // Build left portion: label + query (no match count — that gets separate styling)
35
35
  const leftPart = label + queryDisplay;
36
- const hintsW = getDisplayWidth(hints);
36
+
37
+ // Tiered degradation: as the terminal narrows, first shorten the hints, then
38
+ // drop them, then drop the match-count segment — so the query area (leftWidth)
39
+ // never collapses to zero or negative and the overlay never renders garbage.
40
+ const minLeft = getDisplayWidth(label) + 4; // label + a few query cells
41
+ let hints = fullHints;
42
+ let matchStr = matchCount ? ` ${matchCount}` : '';
43
+ const leftFor = (h: string, m: string) => width - getDisplayWidth(h) - getDisplayWidth(m) - 2;
44
+ let leftWidth = leftFor(hints, matchStr);
45
+ if (leftWidth < minLeft) {
46
+ hints = shortHints;
47
+ leftWidth = leftFor(hints, matchStr);
48
+ }
49
+ if (leftWidth < minLeft) {
50
+ hints = '';
51
+ leftWidth = leftFor(hints, matchStr);
52
+ }
53
+ if (leftWidth < minLeft && matchStr.length > 0) {
54
+ matchStr = '';
55
+ leftWidth = leftFor(hints, matchStr);
56
+ }
57
+ // Final clamp: at least one cell, never wider than the bar.
58
+ leftWidth = Math.max(1, Math.min(width, leftWidth));
59
+
37
60
  const matchStrW = getDisplayWidth(matchStr);
38
- // Available width for left content (query area)
39
- const leftWidth = width - hintsW - matchStrW - 2;
40
61
  const truncatedLeft = fitDisplay(
41
62
  getDisplayWidth(leftPart) > leftWidth ? truncateDisplay(leftPart, leftWidth) : leftPart,
42
63
  leftWidth,
@@ -55,10 +55,12 @@ export function renderSessionPickerModal(
55
55
  style: { fg: '240', dim: true },
56
56
  });
57
57
  } else {
58
- // Column widths: name(24) | timestamp(16) | messages(remaining)
59
- const nameW = 24;
60
- const tsW = 16;
61
- const msgW = Math.max(4, contentW - nameW - tsW - 4); // 4 = separators/spaces
58
+ // Proportional column widths that adapt to the modal's content width:
59
+ // timestamp ~22% (clamped 10..16), messages ~12% (clamped 4..8), and the
60
+ // name column absorbs the remainder so the row always fills contentW.
61
+ const tsW = Math.min(16, Math.max(10, Math.floor(contentW * 0.22)));
62
+ const msgW = Math.min(8, Math.max(4, Math.floor(contentW * 0.12)));
63
+ const nameW = Math.max(8, contentW - tsW - msgW - 4); // 4 = separators/spaces
62
64
 
63
65
  // Column header
64
66
  const nameHdr = fitDisplay('Name', nameW);
@@ -12,6 +12,7 @@ import { getDisplayWidth, wrapText } from '../utils/terminal-width.ts';
12
12
  import { CATEGORY_LABELS, describeUiRouting, formatValue, getSettingLabel, inferSubscriptionRouteReason, valueColor } from './settings-modal-helpers.ts';
13
13
  import { isSecretConfigKey } from '../config/secret-config.ts';
14
14
  import { GLYPHS } from './ui-primitives.ts';
15
+ import { formatHints, joinHints } from './hint-grammar.ts';
15
16
  import {
16
17
  clamp,
17
18
  getFullscreenWorkspaceMetrics,
@@ -540,24 +541,83 @@ function rowColorForSetting(modal: SettingsModal, rowText: string): string {
540
541
  }
541
542
 
542
543
  function footerText(modal: SettingsModal, width: number): string {
544
+ // Every branch speaks the shared hint grammar: bracketed [Key] Verb segments
545
+ // joined by the middle dot, with Esc sorted last. Leading words like
546
+ // "Focus settings" are state labels appended verbatim, not key hints.
543
547
  // Armed reset gate takes priority over all other footer states.
544
548
  if (modal.resetCategoryConfirm !== null || modal.resetAllConfirm !== null)
545
- return 'Reset armed · Enter/y confirm · Esc/n cancel';
546
- if (modal.searchFocused) return 'Search · type to filter · Up/Down navigate results · Enter select · Esc exit search';
547
- if (modal.editingMode) return 'Enter Confirm edit · Esc Cancel edit · text keys edit the selected field';
548
- if (modal.focusPane === 'categories') return 'Focus categories · Up/Down choose · Right/Enter settings · Tab pane · / search · Esc close';
549
- if (modal.currentCategory === 'subscriptions') return 'Focus settings · Up/Down provider · Left categories · Tab pane · / search · Enter review/sign out · Esc close';
550
- if (modal.currentCategory === 'mcp') return 'Focus settings · Up/Down server · Left categories · Tab pane · / search · Enter edit trust · Esc close';
551
- if (modal.currentCategory === 'flags') return 'Focus feature flags · Up/Down flag · Left categories · Tab pane · / search · Enter/Space toggle · Esc close';
549
+ return joinHints('Reset armed', formatHints([
550
+ { key: 'Enter/y', verb: 'confirm' },
551
+ { key: 'Esc/n', verb: 'cancel' },
552
+ ]));
553
+ if (modal.searchFocused)
554
+ return joinHints('Search', 'type to filter', formatHints([
555
+ { key: 'Up/Down', verb: 'Navigate' },
556
+ { key: 'Enter', verb: 'Select' },
557
+ { key: 'Esc', verb: 'Exit search' },
558
+ ]));
559
+ if (modal.editingMode)
560
+ return formatHints([
561
+ { key: 'Enter', verb: 'Confirm edit' },
562
+ { key: 'Esc', verb: 'Cancel edit' },
563
+ ]);
564
+ if (modal.focusPane === 'categories')
565
+ return joinHints('Focus categories', formatHints([
566
+ { key: 'Up/Down', verb: 'Choose' },
567
+ { key: 'Right/Enter', verb: 'Settings' },
568
+ { key: 'Tab', verb: 'Pane' },
569
+ { key: '/', verb: 'Search' },
570
+ { key: 'Esc', verb: 'Close' },
571
+ ]));
572
+ if (modal.currentCategory === 'subscriptions')
573
+ return joinHints('Focus settings', formatHints([
574
+ { key: 'Up/Down', verb: 'Provider' },
575
+ { key: 'Left', verb: 'Categories' },
576
+ { key: 'Tab', verb: 'Pane' },
577
+ { key: '/', verb: 'Search' },
578
+ { key: 'Enter', verb: 'Review/sign out' },
579
+ { key: 'Esc', verb: 'Close' },
580
+ ]));
581
+ if (modal.currentCategory === 'mcp')
582
+ return joinHints('Focus settings', formatHints([
583
+ { key: 'Up/Down', verb: 'Server' },
584
+ { key: 'Left', verb: 'Categories' },
585
+ { key: 'Tab', verb: 'Pane' },
586
+ { key: '/', verb: 'Search' },
587
+ { key: 'Enter', verb: 'Edit trust' },
588
+ { key: 'Esc', verb: 'Close' },
589
+ ]));
590
+ if (modal.currentCategory === 'flags')
591
+ return joinHints('Focus feature flags', formatHints([
592
+ { key: 'Up/Down', verb: 'Flag' },
593
+ { key: 'Left', verb: 'Categories' },
594
+ { key: 'Tab', verb: 'Pane' },
595
+ { key: '/', verb: 'Search' },
596
+ { key: 'Enter/Space', verb: 'Toggle' },
597
+ { key: 'Esc', verb: 'Close' },
598
+ ]));
552
599
  // Default settings pane: tier the reset affordances by available width.
553
600
  // W<80: minimal — only the most critical action survives.
554
601
  // W<160: compact but still shows both reset affordances.
555
602
  // W≥160: standard with all navigation tokens.
556
603
  if (width < 80)
557
- return 'R reset · Esc';
604
+ return formatHints([{ key: 'R', verb: 'reset' }, { key: 'Esc', verb: 'Close' }]);
558
605
  if (width < 160)
559
- return 'Up/Down · Enter/Space edit · ⇧R reset cat · ^⇧R reset all · Esc';
560
- return 'Focus settings · Up/Down setting · Left · Enter/Space edit/toggle · ⇧R reset cat · ^⇧R reset all · Esc close';
606
+ return formatHints([
607
+ { key: 'Up/Down', verb: 'Move' },
608
+ { key: 'Enter/Space', verb: 'Edit' },
609
+ { key: '⇧R', verb: 'reset cat' },
610
+ { key: '^⇧R', verb: 'reset all' },
611
+ { key: 'Esc', verb: 'Close' },
612
+ ]);
613
+ return joinHints('Focus settings', formatHints([
614
+ { key: 'Up/Down', verb: 'Setting' },
615
+ { key: 'Left', verb: 'Categories' },
616
+ { key: 'Enter/Space', verb: 'Edit/toggle' },
617
+ { key: '⇧R', verb: 'reset cat' },
618
+ { key: '^⇧R', verb: 'reset all' },
619
+ { key: 'Esc', verb: 'Close' },
620
+ ]));
561
621
  }
562
622
 
563
623
  export function renderSettingsModal(
@@ -34,6 +34,12 @@ export interface ShellFooterBuildOptions {
34
34
  * Rendered as a dim informational line above the prompt when non-null.
35
35
  */
36
36
  readonly contextStatusHint?: string | null;
37
+ /**
38
+ * Compact footer posture for short terminals (~<30 rows). Collapses the
39
+ * footer to its essentials (prompt box + token/cost line + help) and drops
40
+ * the process indicator, context bar, context-info line and posture block.
41
+ */
42
+ readonly compact?: boolean;
37
43
  }
38
44
 
39
45
  export interface ShellFooterBuildResult {
@@ -41,14 +47,28 @@ export interface ShellFooterBuildResult {
41
47
  readonly height: number;
42
48
  }
43
49
 
44
- const FOOTER_BASE_ROWS = 9;
45
- const CONTEXT_PROGRESS_ROWS = 2;
50
+ // Fixed rows that createFooter always emits (non-compact): prompt-box top
51
+ // border, prompt-box bottom border, token-usage line, context-info line, and
52
+ // the help/exit line. The composer posture block and context bar are counted
53
+ // separately (they are conditional).
54
+ const FOOTER_BASE_ROWS = 5;
55
+ const CONTEXT_PROGRESS_ROWS = 1;
46
56
  const PROCESS_INDICATOR_ROWS = 1;
47
57
 
58
+ /**
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.
64
+ */
65
+ let lastRenderedFooterHeight: number | null = null;
66
+
48
67
  export function estimateShellFooterHeight(
49
68
  promptLineCount: number,
50
69
  contextWindow?: number,
51
70
  ): number {
71
+ if (lastRenderedFooterHeight !== null) return lastRenderedFooterHeight;
52
72
  const safePromptLines = Math.max(1, promptLineCount);
53
73
  const progressRows = contextWindow && contextWindow > 0 ? CONTEXT_PROGRESS_ROWS : 0;
54
74
  return FOOTER_BASE_ROWS + safePromptLines + progressRows + PROCESS_INDICATOR_ROWS;
@@ -79,20 +99,26 @@ export function buildShellFooter(
79
99
  options.composerStatus,
80
100
  options.composerFlags,
81
101
  options.composerPendingRisk,
102
+ options.compact ?? false,
82
103
  );
83
- const processIndicator = renderProcessIndicator(
84
- options.width,
85
- options.runningAgentCount,
86
- options.runningProcessCount,
87
- options.indicatorFocused,
88
- options.runningAgentProgress,
89
- );
90
- const inputBoxRows = Math.max(1, options.promptLineCount) + 2;
91
- lines.splice(inputBoxRows, 0, ...processIndicator);
92
- // Passive context status hint — rendered as a dim informational line before the prompt.
93
- if (options.contextStatusHint) {
94
- const hintLine = UIFactory.stringToLine(options.contextStatusHint, options.width, { fg: '#64748b' });
95
- lines.unshift(hintLine);
104
+ // Compact posture drops the process indicator and context hint entirely so
105
+ // the footer fits within ~5 rows on short terminals.
106
+ if (!options.compact) {
107
+ const processIndicator = renderProcessIndicator(
108
+ options.width,
109
+ options.runningAgentCount,
110
+ options.runningProcessCount,
111
+ options.indicatorFocused,
112
+ options.runningAgentProgress,
113
+ );
114
+ const inputBoxRows = Math.max(1, options.promptLineCount) + 2;
115
+ lines.splice(inputBoxRows, 0, ...processIndicator);
116
+ // Passive context status hint — rendered as a dim informational line before the prompt.
117
+ if (options.contextStatusHint) {
118
+ const hintLine = UIFactory.stringToLine(options.contextStatusHint, options.width, { fg: '#64748b' });
119
+ lines.unshift(hintLine);
120
+ }
96
121
  }
122
+ lastRenderedFooterHeight = lines.length;
97
123
  return { lines, height: lines.length };
98
124
  }
@@ -4,18 +4,20 @@
4
4
  // Extracted as a neutral module so both status-token.ts and polish.ts can
5
5
  // import from here without creating a circular ESM dependency.
6
6
  //
7
- // Glyphs:
8
- // good ✓ (CHECK MARK U+2713)
9
- // warn ⚠ (WARNING SIGN U+26A0)
10
- // bad ✕ (MULTIPLICATION X U+2715)
11
- // info ○ (WHITE CIRCLE U+25CB)
7
+ // STATE_GLYPHS is an alias into GLYPHS.status (ui-primitives.ts) — the single
8
+ // glyph registry. Previously GLYPHS.status.info ('•') collided with
9
+ // GLYPHS.status.pending, and STATE_GLYPHS.info ('○') collided with
10
+ // GLYPHS.status.idle. GLYPHS.status.info is now '○' (this module's historical
11
+ // value) and idle uses a distinct glyph ('◌').
12
12
  // ---------------------------------------------------------------------------
13
13
 
14
+ import { GLYPHS } from './ui-primitives.ts';
15
+
14
16
  export type StatusState = 'good' | 'warn' | 'bad' | 'info';
15
17
 
16
18
  export const STATE_GLYPHS: Record<StatusState, string> = {
17
- good: '\u2713', // ✓
18
- warn: '\u26a0', // ⚠
19
- bad: '\u2715', // ✕
20
- info: '\u25cb', // ○
19
+ good: GLYPHS.status.success, // ✓
20
+ warn: GLYPHS.status.warn, // ⚠
21
+ bad: GLYPHS.status.failure, // ✕
22
+ info: GLYPHS.status.info, // ○
21
23
  };
@@ -14,8 +14,19 @@
14
14
  * IMPORTANT: inline code has NO background token. The bg:#1a1a1a hardcode
15
15
  * that previously existed caused a near-black box on light terminals.
16
16
  * Differentiate inline code via inlineCodeFg + bold only; bg inherits terminal.
17
+ *
18
+ * resolveUiTones(mode) is the sibling read path for CHROME tokens (UI_TONES
19
+ * in ui-primitives.ts — panel/modal/overlay/fullscreen backgrounds, borders,
20
+ * status colours). It composes the same ThemeMode dimension so that
21
+ * DEFAULT_PANEL_PALETTE, DEFAULT_STYLE, FULLSCREEN_PALETTE and
22
+ * DEFAULT_OVERLAY_PALETTE all read through ONE mode-resolved path instead of
23
+ * importing the static UI_TONES constant directly. Mode stays 'dark'
24
+ * everywhere until the terminal-bg-probe lands (see WO-001) — the light
25
+ * entry is type-complete, not a shipped light theme.
17
26
  */
18
27
 
28
+ import { UI_TONES } from './ui-primitives.ts';
29
+
19
30
  /** Background mode — dark is the safe default until terminal-bg-probe lands. */
20
31
  export type ThemeMode = 'dark' | 'light';
21
32
 
@@ -78,9 +89,9 @@ const DARK: ThemeTokens = {
78
89
  searchCurrentFg: '#000000',
79
90
  strikethrough: '244',
80
91
  blockquote: '244',
81
- assistantHeader: '#22d3ee',
82
- reasoningAccent: '#a855f7',
83
- toolAccent: '#38bdf8',
92
+ assistantHeader: UI_TONES.accent.control,
93
+ reasoningAccent: UI_TONES.state.reasoning,
94
+ toolAccent: UI_TONES.state.info,
84
95
  collapsedBodyBg: '#1a1a1a',
85
96
  checkboxChecked: '#22c55e',
86
97
  errorBarBg: '#3a1a1a',
@@ -156,3 +167,49 @@ Object.freeze(LIGHT);
156
167
  * Frozen — do not mutate.
157
168
  */
158
169
  export const DARK_THEME: Readonly<ThemeTokens> = DARK;
170
+
171
+ // ---------------------------------------------------------------------------
172
+ // Chrome tokens (UI_TONES) — mode-resolved sibling to resolveTheme().
173
+ //
174
+ // UI_TONES (ui-primitives.ts) is the dark entry. The light entry mirrors
175
+ // dark for every role that has no light-appropriate equivalent yet — the
176
+ // WO-001 deliverable is the mode dimension and single read path, not a
177
+ // shipped light chrome theme (see module doc comment above).
178
+ // ---------------------------------------------------------------------------
179
+
180
+ /** Recursively widen the `as const` literal leaves of UI_TONES to `string`
181
+ * so mode variants (e.g. UI_TONES_LIGHT) can assign different colour
182
+ * values without fighting TypeScript's literal-type inference. */
183
+ type DeepWidenToString<T> = T extends string ? string : { [K in keyof T]: DeepWidenToString<T[K]> };
184
+
185
+ export type UiToneTokens = DeepWidenToString<typeof UI_TONES>;
186
+
187
+ const UI_TONES_LIGHT: UiToneTokens = {
188
+ ...UI_TONES,
189
+ state: {
190
+ ...UI_TONES.state,
191
+ info: LIGHT.toolAccent,
192
+ reasoning: LIGHT.reasoningAccent,
193
+ },
194
+ accent: {
195
+ ...UI_TONES.accent,
196
+ brand: LIGHT.heading1,
197
+ gradientStart: LIGHT.heading1,
198
+ gradientEnd: LIGHT.reasoningAccent,
199
+ },
200
+ };
201
+
202
+ Object.freeze(UI_TONES_LIGHT.state);
203
+ Object.freeze(UI_TONES_LIGHT.accent);
204
+ Object.freeze(UI_TONES_LIGHT);
205
+
206
+ /**
207
+ * resolveUiTones — Return the chrome (panel/modal/overlay/fullscreen) token
208
+ * set for the given background mode. Single read path for UI_TONES; the
209
+ * 'dark' resolution is byte-identical to the UI_TONES constant.
210
+ *
211
+ * Call with 'dark' (the safe default) until terminal-bg-probe lands.
212
+ */
213
+ export function resolveUiTones(mode: ThemeMode): Readonly<UiToneTokens> {
214
+ return mode === 'light' ? UI_TONES_LIGHT : UI_TONES;
215
+ }
@@ -4,10 +4,12 @@ import { VERSION } from '../version.ts';
4
4
  import { fitDisplay, getDisplayWidth, truncateDisplay, wrapText, interpolateColor } from '../utils/terminal-width.ts';
5
5
  import type { GitHeaderInfo } from './git-status.ts';
6
6
  import { renderConversationFragment, renderConversationStatusLine, type ConversationStatusSegment } from './conversation-surface.ts';
7
- import { GLYPHS } from './ui-primitives.ts';
7
+ 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';
12
+ import { buildFooterTip, isAgentActive } from './footer-tips.ts';
11
13
 
12
14
  /** Number of frames before the animated gradient completes one full cycle. */
13
15
  const GRADIENT_CYCLE_FRAMES = 50;
@@ -35,15 +37,23 @@ function fmtNum(n: number): string {
35
37
  return abbreviateCount(n, { bSuffix: true });
36
38
  }
37
39
 
40
+ /** Format a running USD cost estimate with a precision that suits its magnitude. */
41
+ function fmtCost(usd: number): string {
42
+ if (!(usd > 0)) return '0.00';
43
+ if (usd < 0.01) return usd.toFixed(4);
44
+ if (usd < 1) return usd.toFixed(3);
45
+ return usd.toFixed(2);
46
+ }
47
+
38
48
  /**
39
49
  * UIFactory - Generates standard UI fragments without needing Ink/React overhead.
40
50
  */
41
51
  export class UIFactory {
42
52
  public static createHeader(width: number, model: string, provider: string, title?: string, gitInfo?: GitHeaderInfo): Line[] {
43
53
  const lines: Line[] = [];
44
- const CYAN = '#00ffff';
45
- const GREY = '244';
46
- const TITLE_COLOR = '250';
54
+ const CYAN = UI_TONES.accent.brand;
55
+ const GREY = UI_TONES.fg.dim;
56
+ const TITLE_COLOR = UI_TONES.fg.muted;
47
57
  const brand = ` GoodVibes `;
48
58
  const ver = `v${VERSION} `;
49
59
  const stats = ` ${model} `;
@@ -77,11 +87,11 @@ export class UIFactory {
77
87
  }
78
88
  // Build git info segment
79
89
  let gitStr = '';
80
- let gitFg = '238';
90
+ let gitFg: string = UI_TONES.fg.dim;
81
91
  if (gitInfo) {
82
92
  gitStr = buildGitSegment(gitInfo).text;
83
93
  if (gitInfo.dirty || gitInfo.ahead > 0 || gitInfo.behind > 0) {
84
- gitFg = '220'; // yellow when dirty or out-of-sync
94
+ gitFg = UI_TONES.state.warn; // yellow when dirty or out-of-sync
85
95
  }
86
96
  }
87
97
  const rightSideText = stats + prov;
@@ -91,7 +101,7 @@ export class UIFactory {
91
101
  for (const char of stats) { if (rightX < width) line[rightX++] = { char, fg: CYAN, bg: '', bold: true, dim: false, underline: false, italic: false, strikethrough: false }; }
92
102
  for (const char of prov) { if (rightX < width) line[rightX++] = { char, fg: GREY, bg: '', bold: false, dim: true, underline: false, italic: false, strikethrough: false }; }
93
103
  lines.push(line);
94
- lines.push(this.stringToLine('━'.repeat(width), width, { fg: '244' }));
104
+ lines.push(this.stringToLine('━'.repeat(width), width, { fg: UI_TONES.fg.dim }));
95
105
  return lines;
96
106
  }
97
107
 
@@ -101,12 +111,12 @@ export class UIFactory {
101
111
  */
102
112
  public static createMessageBar(
103
113
  width: number, text: string,
104
- bgColor = '#2a2a2a', textColor = '252', prefixStr = ' › ',
114
+ bgColor: string = '#2a2a2a', textColor: string = UI_TONES.fg.secondary, prefixStr: string = ' › ',
105
115
  strikethrough = false
106
116
  ): Line[] {
107
117
  return renderConversationFragment(text, width, {
108
118
  prefix: prefixStr,
109
- prefixFg: '135',
119
+ prefixFg: UI_TONES.state.reasoning,
110
120
  text: textColor,
111
121
  bodyBg: bgColor,
112
122
  strikethrough,
@@ -119,8 +129,8 @@ export class UIFactory {
119
129
  public static createQueuedMessageFragment(width: number, text: string): Line[] {
120
130
  return renderConversationFragment(text, width, {
121
131
  prefix: ' (...) ',
122
- prefixFg: '135',
123
- text: '240',
132
+ prefixFg: UI_TONES.state.reasoning,
133
+ text: UI_TONES.fg.dim,
124
134
  bodyBg: '#1a1a1a',
125
135
  dim: true,
126
136
  });
@@ -148,6 +158,7 @@ export class UIFactory {
148
158
  composerStatus?: string,
149
159
  composerFlags?: readonly string[],
150
160
  composerPendingRisk?: 'none' | 'approval-wait' | 'shell' | 'command' | 'remote',
161
+ compact: boolean = false,
151
162
  ): Line[] {
152
163
  const lines: Line[] = [];
153
164
  const promptLines = prompt.split('\n');
@@ -253,7 +264,9 @@ export class UIFactory {
253
264
  }
254
265
  }
255
266
  lines.push(bottomLine);
256
- lines.push(createBaseLine());
267
+ // --- Composer posture block (mode / risk / status / flags) ------------
268
+ // Suppressed in compact mode; the ctx-info line no longer duplicates these
269
+ // tokens, so this block is the single home for mode/status/flags.
257
270
  const composerTokens: Array<{ text: string; fg: string; bold?: boolean; dim?: boolean }> = [];
258
271
  if (composerMode) composerTokens.push({ text: ` ${GLYPHS.status.active} ${composerMode} `, fg: '#38bdf8', bold: true });
259
272
  if (composerPendingRisk && composerPendingRisk !== 'none') {
@@ -268,7 +281,7 @@ export class UIFactory {
268
281
  }
269
282
  if (composerStatus && composerStatus !== 'idle') composerTokens.push({ text: ` state:${composerStatus} `, fg: '244', dim: true });
270
283
  if (composerFlags && composerFlags.length > 0) composerTokens.push({ text: ` flags:${composerFlags.join(',')} `, fg: '244', dim: true });
271
- if (composerTokens.length > 0) {
284
+ if (!compact && composerTokens.length > 0) {
272
285
  const postureLine = createBaseLine();
273
286
  let px = 2;
274
287
  for (const token of composerTokens) {
@@ -289,10 +302,10 @@ export class UIFactory {
289
302
  if (px >= width) break;
290
303
  }
291
304
  lines.push(postureLine);
292
- lines.push(createBaseLine());
293
305
  }
294
306
  const isRecentlyCopied = Date.now() - lastCopyTime < 2000;
295
- // Token usage line
307
+ // Token usage line + running ~$ cost estimate (derived from the usage
308
+ // object and the active model via cost-utils).
296
309
  const u = usage as { input?: number; output?: number; cacheRead?: number; cacheWrite?: number; up?: number; down?: number };
297
310
  const inp = u.input ?? u.up ?? 0;
298
311
  const out = u.output ?? u.down ?? 0;
@@ -300,12 +313,13 @@ export class UIFactory {
300
313
  const cw = u.cacheWrite ?? 0;
301
314
  const total = inp + out + cr + cw;
302
315
  const tokenSep = ` ${GLYPHS.navigation.pipeSeparator} `;
303
- 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)} ]`;
316
+ const costSegment = model ? `${tokenSep}~$${fmtCost(calcSessionCost(inp, out, cr, cw, model))}` : '';
317
+ 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} ]`;
304
318
  const copiedNotice = isRecentlyCopied ? ` [COPIED] ` : '';
305
319
  const statsLine = ' ' + tokenLine + ' '.repeat(Math.max(0, width - 4 - getDisplayWidth(tokenLine) - getDisplayWidth(copiedNotice))) + copiedNotice;
306
320
  lines.push(this.stringToLine(statsLine, width, { fg: isRecentlyCopied ? '81' : '244', bold: isRecentlyCopied }));
307
- // Context usage progress bar
308
- if (contextWindow && contextWindow > 0) {
321
+ // Context usage progress bar — suppressed in compact mode.
322
+ if (!compact && contextWindow && contextWindow > 0) {
309
323
  const ctxTokens = lastInputTokens ?? 0;
310
324
  const label = ' Context Usage: ';
311
325
  const suffix = ` [ ${fmtNum(ctxTokens)} / ${fmtNum(contextWindow)} ]`;
@@ -315,11 +329,12 @@ export class UIFactory {
315
329
  const thresholdFraction = (compactThreshold !== undefined && compactThreshold > 0)
316
330
  ? Math.min(1, compactThreshold)
317
331
  : undefined;
318
- lines.push(createBaseLine());
319
332
  lines.push(this.createProgressBarLine(label, ctxPct, barWidth, width, suffix, thresholdFraction));
320
333
  }
321
- // Context info line (working dir, model+provider, tools)
322
- if (workingDir || model) {
334
+ // Context info line (working dir, model+provider, tools, hitl).
335
+ // Suppressed in compact mode. mode/status/flags are intentionally omitted —
336
+ // the posture block above owns them, so they are not duplicated here.
337
+ if (!compact && (workingDir || model)) {
323
338
  const home = typeof process !== 'undefined' ? process.env.HOME ?? '' : '';
324
339
  const displayDir = workingDir && home && workingDir.startsWith(home)
325
340
  ? '~' + workingDir.slice(home.length)
@@ -331,19 +346,16 @@ export class UIFactory {
331
346
  }
332
347
  if (toolCount) ctxParts.push(`${toolCount} tools`);
333
348
  if (hitlMode) ctxParts.push(`hitl:${hitlMode}`);
334
- if (composerMode) ctxParts.push(`mode:${composerMode}`);
335
- if (composerStatus && composerStatus !== 'idle') ctxParts.push(`status:${composerStatus}`);
336
- if (composerFlags && composerFlags.length > 0) ctxParts.push(composerFlags.join(','));
337
349
  const ctxLine = ' ' + ctxParts.join(` ${GLYPHS.navigation.pipeSeparator} `);
338
- lines.push(createBaseLine());
339
350
  lines.push(this.stringToLine(truncateDisplay(ctxLine, width), width, { fg: '240', dim: true }));
340
- lines.push(createBaseLine());
341
351
  }
342
352
  if (showExitNotice) {
343
353
  const notice = ` !!! Press Ctrl+C again to exit !!! `;
344
354
  lines.push(this.stringToLine(fitDisplay(notice, width), width, { fg: '196', bold: true }));
345
355
  } else {
346
- const help = ` /help for commands - Ctrl+C to quit `;
356
+ // Persistent discoverability tip. Rotates by context (agent-aware): the
357
+ // process-monitor tip leads while a turn is in flight. See footer-tips.ts.
358
+ const help = ` ${buildFooterTip({ agentActive: isAgentActive(composerStatus) })} `;
347
359
  const dangerWarn = dangerMode ? `! DANGER MODE - ALL CHANGES AUTO-APPROVED ` : '';
348
360
  const helpW = getDisplayWidth(help);
349
361
  const dangerW = getDisplayWidth(dangerWarn);
@@ -363,7 +375,6 @@ export class UIFactory {
363
375
  }
364
376
  lines.push(line);
365
377
  }
366
- lines.push(createBaseLine());
367
378
  return lines;
368
379
  }
369
380
 
@@ -392,8 +403,8 @@ export class UIFactory {
392
403
  ];
393
404
 
394
405
  /** Gradient colors for thinking text — cyan to purple (matches splash). */
395
- private static readonly THINK_GRADIENT_START = '#00ffff';
396
- private static readonly THINK_GRADIENT_END = '#d000ff';
406
+ private static readonly THINK_GRADIENT_START = UI_TONES.accent.gradientStart;
407
+ private static readonly THINK_GRADIENT_END = UI_TONES.accent.gradientEnd;
397
408
 
398
409
  public static createThinkingFragment(width: number, spinner: string, frame: number = 0, tokenSpeed?: number, toolPreview?: string, inputTokens?: number, outputTokens?: number, elapsedMs?: number, ttftMs?: number): Line[] {
399
410
  // Rotate phrase every ~30 seconds (frame ticks at 80ms, so ~375 frames)
@@ -35,8 +35,9 @@ export const GLYPHS = {
35
35
  failure: '✕',
36
36
  pending: '•',
37
37
  active: '●',
38
- idle: '',
39
- info: '',
38
+ idle: '',
39
+ info: '',
40
+ warn: '⚠',
40
41
  blocked: '⊘',
41
42
  skipped: '◇',
42
43
  review: '◈',
@@ -56,6 +57,11 @@ export const GLYPHS = {
56
57
  },
57
58
  } as const;
58
59
 
60
+ /**
61
+ * UI_TONES — the single chrome/color-token source for src/renderer and
62
+ * src/panels. Concrete (dark-mode) values; resolveUiTones(mode) in theme.ts
63
+ * is the single mode-resolved read path — this object is the 'dark' entry.
64
+ */
59
65
  export const UI_TONES = {
60
66
  fg: {
61
67
  primary: '#e2e8f0',
@@ -63,6 +69,8 @@ export const UI_TONES = {
63
69
  muted: '#94a3b8',
64
70
  dim: '#475569',
65
71
  inverse: '#0f172a',
72
+ /** Empty-state / placeholder foreground (formerly DEFAULT_PANEL_PALETTE.empty literal). */
73
+ empty: '#334155',
66
74
  },
67
75
  bg: {
68
76
  base: '#11131a',
@@ -75,6 +83,8 @@ export const UI_TONES = {
75
83
  warning: '#2b2116',
76
84
  error: '#2a161b',
77
85
  success: '#14241b',
86
+ /** Fullscreen/shell footer background. */
87
+ footer: '#111827',
78
88
  },
79
89
  state: {
80
90
  info: '#38bdf8',
@@ -83,6 +93,8 @@ export const UI_TONES = {
83
93
  bad: '#ef4444',
84
94
  blocked: '#f97316',
85
95
  active: '#60a5fa',
96
+ /** Single canonical reasoning/thinking purple (replaces #9945FF and ad-hoc purples). */
97
+ reasoning: '#a855f7',
86
98
  },
87
99
  accent: {
88
100
  browser: '#7dd3fc',
@@ -90,7 +102,16 @@ export const UI_TONES = {
90
102
  inspector: '#c4b5fd',
91
103
  workflow: '#fbbf24',
92
104
  conversation: '#93c5fd',
105
+ /** Neon brand accent — header/splash/thinking gradient only. */
106
+ brand: '#00ffff',
107
+ gradientStart: '#00ffff',
108
+ gradientEnd: '#d000ff',
93
109
  },
110
+ /** Canonical border stroke color for fullscreen/panel chrome. */
111
+ border: '#64748b',
94
112
  } as const;
95
113
 
96
114
  export type UiGlyphRegistry = typeof GLYPHS;
115
+
116
+ /** Single spinner-frame source — layout.ts and progress.ts both re-export this. */
117
+ export const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] as const;