@pellux/goodvibes-tui 0.27.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 (143) hide show
  1. package/CHANGELOG.md +124 -0
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/src/core/context-usage.ts +53 -0
  5. package/src/core/conversation.ts +4 -6
  6. package/src/core/session-recovery.ts +2 -0
  7. package/src/core/turn-event-wiring.ts +1 -0
  8. package/src/input/commands/cost-runtime.ts +49 -0
  9. package/src/input/commands/eval.ts +4 -3
  10. package/src/input/commands/operator-runtime.ts +5 -1
  11. package/src/input/commands.ts +2 -0
  12. package/src/input/handler-feed-routes.ts +60 -26
  13. package/src/input/handler-feed.ts +17 -8
  14. package/src/input/handler-picker-routes.ts +18 -13
  15. package/src/input/handler-shortcuts.ts +51 -0
  16. package/src/input/handler-ui-state.ts +4 -0
  17. package/src/input/handler.ts +43 -6
  18. package/src/input/keybindings.ts +53 -8
  19. package/src/input/model-picker.ts +5 -0
  20. package/src/input/panel-integration-actions.ts +117 -1
  21. package/src/main.ts +5 -2
  22. package/src/panels/agent-inspector-panel.ts +139 -45
  23. package/src/panels/agent-inspector-shared.ts +147 -3
  24. package/src/panels/approval-panel.ts +204 -102
  25. package/src/panels/automation-control-panel.ts +370 -103
  26. package/src/panels/base-panel.ts +26 -2
  27. package/src/panels/builtin/agent.ts +28 -37
  28. package/src/panels/builtin/development.ts +40 -32
  29. package/src/panels/builtin/knowledge.ts +15 -6
  30. package/src/panels/builtin/operations.ts +178 -117
  31. package/src/panels/builtin/session.ts +35 -9
  32. package/src/panels/builtin/shared.ts +98 -6
  33. package/src/panels/cockpit-panel.ts +253 -75
  34. package/src/panels/cockpit-read-model.ts +2 -1
  35. package/src/panels/communication-panel.ts +159 -56
  36. package/src/panels/confirm-state.ts +8 -1
  37. package/src/panels/control-plane-panel.ts +354 -95
  38. package/src/panels/cost-tracker-panel.ts +243 -57
  39. package/src/panels/debug-panel.ts +347 -159
  40. package/src/panels/diff-panel.ts +257 -82
  41. package/src/panels/docs-panel.ts +166 -64
  42. package/src/panels/eval-panel.ts +336 -109
  43. package/src/panels/expandable-list-panel.ts +189 -0
  44. package/src/panels/file-explorer-panel.ts +238 -159
  45. package/src/panels/file-preview-panel.ts +141 -59
  46. package/src/panels/git-panel.ts +360 -191
  47. package/src/panels/hooks-panel.ts +181 -19
  48. package/src/panels/incident-review-panel.ts +252 -43
  49. package/src/panels/index.ts +0 -4
  50. package/src/panels/intelligence-panel.ts +310 -103
  51. package/src/panels/knowledge-graph-panel.ts +480 -58
  52. package/src/panels/local-auth-panel.ts +251 -33
  53. package/src/panels/marketplace-panel.ts +219 -33
  54. package/src/panels/memory-panel.ts +83 -81
  55. package/src/panels/ops-control-panel.ts +211 -32
  56. package/src/panels/ops-strategy-panel.ts +131 -45
  57. package/src/panels/orchestration-panel.ts +259 -67
  58. package/src/panels/panel-list-panel.ts +184 -136
  59. package/src/panels/panel-manager.ts +120 -13
  60. package/src/panels/plan-dashboard-panel.ts +507 -74
  61. package/src/panels/plugins-panel.ts +227 -34
  62. package/src/panels/policy-panel.ts +264 -55
  63. package/src/panels/polish-core.ts +162 -0
  64. package/src/panels/polish-tables.ts +258 -0
  65. package/src/panels/polish.ts +67 -149
  66. package/src/panels/project-planning-answer-actions.ts +134 -0
  67. package/src/panels/project-planning-panel.ts +84 -113
  68. package/src/panels/provider-health-panel.ts +438 -480
  69. package/src/panels/provider-health-routes.ts +203 -0
  70. package/src/panels/provider-health-tracker.ts +194 -6
  71. package/src/panels/provider-health-views.ts +560 -0
  72. package/src/panels/qr-panel.ts +136 -38
  73. package/src/panels/remote-panel.ts +123 -38
  74. package/src/panels/routes-panel.ts +87 -24
  75. package/src/panels/sandbox-panel.ts +232 -65
  76. package/src/panels/scrollable-list-panel.ts +143 -145
  77. package/src/panels/security-panel.ts +204 -52
  78. package/src/panels/services-panel.ts +174 -76
  79. package/src/panels/session-browser-panel.ts +101 -6
  80. package/src/panels/session-maintenance.ts +4 -122
  81. package/src/panels/settings-sync-panel.ts +346 -68
  82. package/src/panels/skills-panel.ts +157 -89
  83. package/src/panels/subscription-panel.ts +105 -34
  84. package/src/panels/symbol-outline-panel.ts +287 -154
  85. package/src/panels/system-messages-panel.ts +172 -38
  86. package/src/panels/tasks-panel.ts +348 -134
  87. package/src/panels/thinking-panel.ts +66 -32
  88. package/src/panels/token-budget-panel.ts +233 -46
  89. package/src/panels/tool-inspector-panel.ts +170 -54
  90. package/src/panels/types.ts +65 -4
  91. package/src/panels/work-plan-panel.ts +393 -39
  92. package/src/panels/worktree-panel.ts +239 -61
  93. package/src/panels/wrfc-panel-format.ts +133 -0
  94. package/src/panels/wrfc-panel.ts +146 -150
  95. package/src/renderer/compaction-preview.ts +2 -1
  96. package/src/renderer/compositor.ts +46 -43
  97. package/src/renderer/conversation-overlays.ts +25 -11
  98. package/src/renderer/footer-tips.ts +41 -0
  99. package/src/renderer/fullscreen-primitives.ts +22 -16
  100. package/src/renderer/help-overlay.ts +91 -14
  101. package/src/renderer/hint-grammar.ts +52 -0
  102. package/src/renderer/layout.ts +7 -7
  103. package/src/renderer/modal-factory.ts +23 -15
  104. package/src/renderer/model-picker-overlay.ts +30 -11
  105. package/src/renderer/model-workspace.ts +2 -3
  106. package/src/renderer/overlay-box.ts +16 -10
  107. package/src/renderer/panel-composite.ts +7 -20
  108. package/src/renderer/panel-workspace-bar.ts +14 -8
  109. package/src/renderer/process-indicator.ts +3 -1
  110. package/src/renderer/progress.ts +8 -6
  111. package/src/renderer/search-overlay.ts +27 -6
  112. package/src/renderer/session-picker-modal.ts +6 -4
  113. package/src/renderer/settings-modal.ts +70 -10
  114. package/src/renderer/shell-surface.ts +41 -15
  115. package/src/renderer/status-glyphs.ts +11 -9
  116. package/src/renderer/tab-strip.ts +148 -34
  117. package/src/renderer/theme.ts +60 -3
  118. package/src/renderer/ui-factory.ts +45 -36
  119. package/src/renderer/ui-primitives.ts +23 -2
  120. package/src/runtime/bootstrap-shell.ts +35 -18
  121. package/src/runtime/diagnostics/panels/index.ts +0 -3
  122. package/src/runtime/ui/model-picker/health-enrichment.ts +3 -0
  123. package/src/runtime/ui/model-picker/types.ts +4 -0
  124. package/src/shell/ui-openers.ts +14 -22
  125. package/src/utils/format-duration.ts +55 -0
  126. package/src/utils/format-number.ts +71 -0
  127. package/src/utils/splash-lines.ts +44 -3
  128. package/src/version.ts +1 -1
  129. package/src/work-plans/work-plan-store.ts +13 -0
  130. package/src/panels/agent-logs-panel.ts +0 -666
  131. package/src/panels/agent-logs-shared.ts +0 -129
  132. package/src/panels/context-visualizer-panel.ts +0 -214
  133. package/src/panels/forensics-panel.ts +0 -364
  134. package/src/panels/panel-picker.ts +0 -106
  135. package/src/panels/provider-account-snapshot.ts +0 -259
  136. package/src/panels/provider-accounts-panel.ts +0 -218
  137. package/src/panels/provider-stats-panel.ts +0 -366
  138. package/src/panels/schedule-panel.ts +0 -342
  139. package/src/panels/watchers-panel.ts +0 -193
  140. package/src/renderer/panel-picker-overlay.ts +0 -202
  141. package/src/renderer/panel-tab-bar.ts +0 -69
  142. package/src/runtime/diagnostics/panels/panel-resources.ts +0 -118
  143. package/src/runtime/diagnostics/panels/policy.ts +0 -177
@@ -21,46 +21,148 @@ export interface TabStripStyle {
21
21
  readonly trailingFg?: string;
22
22
  }
23
23
 
24
+ /** Absolute column range a rendered tab occupies, for click hit-testing. */
25
+ export interface TabHitRegion {
26
+ /** Index into the original `tabs` array. */
27
+ readonly index: number;
28
+ /** First column (inclusive) the tab occupies on the line. */
29
+ readonly startCol: number;
30
+ /** Last column (exclusive) the tab occupies on the line. */
31
+ readonly endCol: number;
32
+ }
33
+
24
34
  export interface TabStripConfig {
25
35
  readonly width: number;
26
36
  readonly tabs: readonly TabStripItem[];
27
37
  readonly style: TabStripStyle;
28
38
  readonly prefixLabel?: string;
29
39
  readonly suffixLabel?: string;
40
+ /** Right-side overflow glyph (more tabs after the window). Default '›'. */
30
41
  readonly overflowIndicator?: string;
42
+ /** Left-side overflow glyph (more tabs before the window). Default '‹'. */
43
+ readonly overflowIndicatorLeft?: string;
44
+ /**
45
+ * Optional callback invoked with the absolute column ranges of each rendered
46
+ * tab. Used by the compositor to hit-test mouse clicks onto tabs. Additive —
47
+ * the function still returns a `Line` so existing callers are unaffected.
48
+ */
49
+ readonly onLayout?: (regions: readonly TabHitRegion[]) => void;
50
+ }
51
+
52
+ const SEPARATOR = '│';
53
+
54
+ interface WindowResult {
55
+ segments: StyledPanelSegment[];
56
+ /** Per-tab column ranges relative to the start of the tab block. */
57
+ regions: Array<{ index: number; startCol: number; endCol: number }>;
58
+ overflowLeft: boolean;
59
+ overflowRight: boolean;
31
60
  }
32
61
 
62
+ /**
63
+ * Build the visible tab segments, windowed so the ACTIVE tab is always shown.
64
+ *
65
+ * Tabs are laid out left-to-right, but when they don't all fit we keep a
66
+ * contiguous window that contains the active tab and grow it outward (biased
67
+ * toward following tabs) until the budget is exhausted. Left/right overflow is
68
+ * reported so the caller can render `‹`/`›` indicators.
69
+ */
33
70
  function makeSegments(
34
71
  tabs: readonly TabStripItem[],
35
72
  style: TabStripStyle,
36
73
  maxWidth: number,
37
- ): { segments: StyledPanelSegment[]; hasOverflow: boolean } {
38
- const segments: StyledPanelSegment[] = [];
39
- let used = 0;
40
- let hasOverflow = false;
41
- for (const tab of tabs) {
42
- const separatorWidth = getDisplayWidth('│');
43
- const labelText = tab.active ? `[${tab.label}]` : tab.label;
44
- const labelWidth = getDisplayWidth(labelText);
45
- const entryWidth = separatorWidth + labelWidth + 1;
46
- if (used + entryWidth > maxWidth) {
47
- hasOverflow = true;
48
- break;
74
+ indicatorWidth: number,
75
+ ): WindowResult {
76
+ const empty: WindowResult = { segments: [], regions: [], overflowLeft: false, overflowRight: false };
77
+ if (tabs.length === 0 || maxWidth <= 0) return empty;
78
+
79
+ const sepWidth = getDisplayWidth(SEPARATOR);
80
+ // Per-tab entry width: leading separator + (bracketed if active) label + trailing space.
81
+ const labelText = (tab: TabStripItem) => (tab.active ? `[${tab.label}]` : tab.label);
82
+ const entryWidths = tabs.map((tab) => sepWidth + getDisplayWidth(labelText(tab)) + 1);
83
+
84
+ const activeIndex = Math.max(0, tabs.findIndex((t) => t.active));
85
+ const n = tabs.length;
86
+ const closing = sepWidth; // trailing │ after the last visible tab
87
+ const totalAll = entryWidths.reduce((a, b) => a + b, 0) + closing;
88
+
89
+ let start = activeIndex;
90
+ let end = activeIndex; // inclusive
91
+ let used = entryWidths[activeIndex] + closing;
92
+
93
+ if (totalAll <= maxWidth) {
94
+ // Everything fits — show all tabs, no windowing.
95
+ start = 0;
96
+ end = n - 1;
97
+ } else {
98
+ // Need windowing. Reserve room for indicators on whichever side still has
99
+ // hidden tabs; the reserve is recomputed each step so reaching an edge
100
+ // frees its indicator column.
101
+ const budget = maxWidth;
102
+ let leftDone = start === 0;
103
+ let rightDone = end === n - 1;
104
+ let preferRight = true;
105
+
106
+ const fits = (extra: number, nextStart: number, nextEnd: number) => {
107
+ const leftRes = nextStart > 0 ? indicatorWidth : 0;
108
+ const rightRes = nextEnd < n - 1 ? indicatorWidth : 0;
109
+ return used + extra + leftRes + rightRes <= budget;
110
+ };
111
+
112
+ while (!leftDone || !rightDone) {
113
+ const goRight = !rightDone && (preferRight || leftDone);
114
+ if (goRight) {
115
+ const ne = end + 1;
116
+ if (fits(entryWidths[ne], start, ne)) {
117
+ end = ne;
118
+ used += entryWidths[ne];
119
+ if (end === n - 1) rightDone = true;
120
+ preferRight = false;
121
+ } else {
122
+ rightDone = true;
123
+ }
124
+ } else if (!leftDone) {
125
+ const ns = start - 1;
126
+ if (fits(entryWidths[ns], ns, end)) {
127
+ start = ns;
128
+ used += entryWidths[ns];
129
+ if (start === 0) leftDone = true;
130
+ preferRight = true;
131
+ } else {
132
+ leftDone = true;
133
+ }
134
+ } else {
135
+ break;
136
+ }
49
137
  }
50
- segments.push({ text: '│', fg: style.separatorFg });
138
+ }
139
+
140
+ const segments: StyledPanelSegment[] = [];
141
+ const regions: Array<{ index: number; startCol: number; endCol: number }> = [];
142
+ let col = 0;
143
+ for (let i = start; i <= end; i++) {
144
+ const tab = tabs[i];
145
+ const text = labelText(tab);
146
+ const entryStart = col;
147
+ segments.push({ text: SEPARATOR, fg: style.separatorFg });
51
148
  segments.push({
52
- text: `${labelText} `,
149
+ text: `${text} `,
53
150
  fg: tab.active ? style.activeFg : style.inactiveFg,
54
151
  bg: tab.active ? style.activeBg : style.inactiveBg,
55
152
  bold: tab.active ? (style.activeBold ?? false) : false,
56
153
  });
57
- used += entryWidth;
154
+ col += entryWidths[i];
155
+ regions.push({ index: i, startCol: entryStart, endCol: col });
58
156
  }
157
+ // Closing separator.
158
+ segments.push({ text: SEPARATOR, fg: style.separatorFg });
59
159
 
60
- if (segments.length > 0 && used + getDisplayWidth('│') <= maxWidth) {
61
- segments.push({ text: '│', fg: style.separatorFg });
62
- }
63
- return { segments, hasOverflow };
160
+ return {
161
+ segments,
162
+ regions,
163
+ overflowLeft: start > 0,
164
+ overflowRight: end < n - 1,
165
+ };
64
166
  }
65
167
 
66
168
  export function renderTabStrip(config: TabStripConfig): Line {
@@ -70,18 +172,18 @@ export function renderTabStrip(config: TabStripConfig): Line {
70
172
  style,
71
173
  prefixLabel,
72
174
  suffixLabel,
73
- overflowIndicator = '›',
175
+ overflowIndicator = '›',
176
+ overflowIndicatorLeft = '‹',
177
+ onLayout,
74
178
  } = config;
75
179
 
180
+ const prefixWidth = prefixLabel ? getDisplayWidth(prefixLabel) + 1 : 0;
76
181
  const reservedSuffixWidth = suffixLabel ? getDisplayWidth(suffixLabel) : 0;
77
- const reservedOverflowWidth = getDisplayWidth(overflowIndicator);
78
- const maxTabWidth = Math.max(
79
- 0,
80
- width
81
- - (prefixLabel ? getDisplayWidth(prefixLabel) + 1 : 0)
82
- - (suffixLabel ? reservedSuffixWidth : 0)
83
- - reservedOverflowWidth,
182
+ const indicatorWidth = Math.max(
183
+ getDisplayWidth(overflowIndicator),
184
+ getDisplayWidth(overflowIndicatorLeft),
84
185
  );
186
+ const maxTabWidth = Math.max(0, width - prefixWidth - reservedSuffixWidth);
85
187
 
86
188
  const baseSegments: StyledPanelSegment[] = [];
87
189
  if (prefixLabel) {
@@ -94,15 +196,27 @@ export function renderTabStrip(config: TabStripConfig): Line {
94
196
  baseSegments.push({ text: ' ', fg: style.separatorFg });
95
197
  }
96
198
 
97
- const { segments: tabSegments, hasOverflow } = makeSegments(tabs, style, maxTabWidth);
199
+ const { segments: tabSegments, regions, overflowLeft, overflowRight } =
200
+ makeSegments(tabs, style, maxTabWidth, indicatorWidth);
201
+
202
+ const leftSegments: StyledPanelSegment[] = [];
203
+ if (overflowLeft) {
204
+ leftSegments.push({ text: overflowIndicatorLeft, fg: style.overflowFg ?? style.separatorFg });
205
+ }
206
+
98
207
  const suffixSegments: StyledPanelSegment[] = [];
99
- if (hasOverflow) suffixSegments.push({ text: overflowIndicator, fg: style.overflowFg ?? style.separatorFg });
208
+ if (overflowRight) {
209
+ suffixSegments.push({ text: overflowIndicator, fg: style.overflowFg ?? style.separatorFg });
210
+ }
100
211
  if (suffixLabel) {
101
- suffixSegments.push({
102
- text: suffixLabel,
103
- fg: style.trailingFg ?? style.inactiveFg,
104
- });
212
+ suffixSegments.push({ text: suffixLabel, fg: style.trailingFg ?? style.inactiveFg });
213
+ }
214
+
215
+ // Report absolute hit regions: prefix + optional left indicator, then tabs.
216
+ if (onLayout) {
217
+ const offset = prefixWidth + (overflowLeft ? getDisplayWidth(overflowIndicatorLeft) : 0);
218
+ onLayout(regions.map((r) => ({ index: r.index, startCol: offset + r.startCol, endCol: offset + r.endCol })));
105
219
  }
106
220
 
107
- return buildStyledPanelLine(width, [...baseSegments, ...tabSegments, ...suffixSegments]);
221
+ return buildStyledPanelLine(width, [...baseSegments, ...leftSegments, ...tabSegments, ...suffixSegments]);
108
222
  }
@@ -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,8 +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
+ import { abbreviateCount } from '../utils/format-number.ts';
10
+ import { computeContextUsage } from '../core/context-usage.ts';
11
+ import { calcSessionCost } from '../export/cost-utils.ts';
12
+ import { buildFooterTip, isAgentActive } from './footer-tips.ts';
9
13
 
10
14
  /** Number of frames before the animated gradient completes one full cycle. */
11
15
  const GRADIENT_CYCLE_FRAMES = 50;
@@ -30,11 +34,15 @@ function buildGitSegment(gitInfo: GitHeaderInfo): { text: string; width: number
30
34
 
31
35
  /** Format a number: up to 999, then 1.0k, 1.0M, 1.0B, 1.0T */
32
36
  function fmtNum(n: number): string {
33
- if (n < 1000) return String(n);
34
- if (n < 1_000_000) return (n / 1000).toFixed(1) + 'k';
35
- if (n < 1_000_000_000) return (n / 1_000_000).toFixed(1) + 'M';
36
- if (n < 1_000_000_000_000) return (n / 1_000_000_000).toFixed(1) + 'B';
37
- return (n / 1_000_000_000_000).toFixed(1) + 'T';
37
+ return abbreviateCount(n, { bSuffix: true });
38
+ }
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);
38
46
  }
39
47
 
40
48
  /**
@@ -43,9 +51,9 @@ function fmtNum(n: number): string {
43
51
  export class UIFactory {
44
52
  public static createHeader(width: number, model: string, provider: string, title?: string, gitInfo?: GitHeaderInfo): Line[] {
45
53
  const lines: Line[] = [];
46
- const CYAN = '#00ffff';
47
- const GREY = '244';
48
- 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;
49
57
  const brand = ` GoodVibes `;
50
58
  const ver = `v${VERSION} `;
51
59
  const stats = ` ${model} `;
@@ -79,11 +87,11 @@ export class UIFactory {
79
87
  }
80
88
  // Build git info segment
81
89
  let gitStr = '';
82
- let gitFg = '238';
90
+ let gitFg: string = UI_TONES.fg.dim;
83
91
  if (gitInfo) {
84
92
  gitStr = buildGitSegment(gitInfo).text;
85
93
  if (gitInfo.dirty || gitInfo.ahead > 0 || gitInfo.behind > 0) {
86
- gitFg = '220'; // yellow when dirty or out-of-sync
94
+ gitFg = UI_TONES.state.warn; // yellow when dirty or out-of-sync
87
95
  }
88
96
  }
89
97
  const rightSideText = stats + prov;
@@ -93,7 +101,7 @@ export class UIFactory {
93
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 }; }
94
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 }; }
95
103
  lines.push(line);
96
- lines.push(this.stringToLine('━'.repeat(width), width, { fg: '244' }));
104
+ lines.push(this.stringToLine('━'.repeat(width), width, { fg: UI_TONES.fg.dim }));
97
105
  return lines;
98
106
  }
99
107
 
@@ -103,12 +111,12 @@ export class UIFactory {
103
111
  */
104
112
  public static createMessageBar(
105
113
  width: number, text: string,
106
- bgColor = '#2a2a2a', textColor = '252', prefixStr = ' › ',
114
+ bgColor: string = '#2a2a2a', textColor: string = UI_TONES.fg.secondary, prefixStr: string = ' › ',
107
115
  strikethrough = false
108
116
  ): Line[] {
109
117
  return renderConversationFragment(text, width, {
110
118
  prefix: prefixStr,
111
- prefixFg: '135',
119
+ prefixFg: UI_TONES.state.reasoning,
112
120
  text: textColor,
113
121
  bodyBg: bgColor,
114
122
  strikethrough,
@@ -121,8 +129,8 @@ export class UIFactory {
121
129
  public static createQueuedMessageFragment(width: number, text: string): Line[] {
122
130
  return renderConversationFragment(text, width, {
123
131
  prefix: ' (...) ',
124
- prefixFg: '135',
125
- text: '240',
132
+ prefixFg: UI_TONES.state.reasoning,
133
+ text: UI_TONES.fg.dim,
126
134
  bodyBg: '#1a1a1a',
127
135
  dim: true,
128
136
  });
@@ -150,6 +158,7 @@ export class UIFactory {
150
158
  composerStatus?: string,
151
159
  composerFlags?: readonly string[],
152
160
  composerPendingRisk?: 'none' | 'approval-wait' | 'shell' | 'command' | 'remote',
161
+ compact: boolean = false,
153
162
  ): Line[] {
154
163
  const lines: Line[] = [];
155
164
  const promptLines = prompt.split('\n');
@@ -255,7 +264,9 @@ export class UIFactory {
255
264
  }
256
265
  }
257
266
  lines.push(bottomLine);
258
- 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.
259
270
  const composerTokens: Array<{ text: string; fg: string; bold?: boolean; dim?: boolean }> = [];
260
271
  if (composerMode) composerTokens.push({ text: ` ${GLYPHS.status.active} ${composerMode} `, fg: '#38bdf8', bold: true });
261
272
  if (composerPendingRisk && composerPendingRisk !== 'none') {
@@ -270,7 +281,7 @@ export class UIFactory {
270
281
  }
271
282
  if (composerStatus && composerStatus !== 'idle') composerTokens.push({ text: ` state:${composerStatus} `, fg: '244', dim: true });
272
283
  if (composerFlags && composerFlags.length > 0) composerTokens.push({ text: ` flags:${composerFlags.join(',')} `, fg: '244', dim: true });
273
- if (composerTokens.length > 0) {
284
+ if (!compact && composerTokens.length > 0) {
274
285
  const postureLine = createBaseLine();
275
286
  let px = 2;
276
287
  for (const token of composerTokens) {
@@ -291,10 +302,10 @@ export class UIFactory {
291
302
  if (px >= width) break;
292
303
  }
293
304
  lines.push(postureLine);
294
- lines.push(createBaseLine());
295
305
  }
296
306
  const isRecentlyCopied = Date.now() - lastCopyTime < 2000;
297
- // Token usage line
307
+ // Token usage line + running ~$ cost estimate (derived from the usage
308
+ // object and the active model via cost-utils).
298
309
  const u = usage as { input?: number; output?: number; cacheRead?: number; cacheWrite?: number; up?: number; down?: number };
299
310
  const inp = u.input ?? u.up ?? 0;
300
311
  const out = u.output ?? u.down ?? 0;
@@ -302,26 +313,28 @@ export class UIFactory {
302
313
  const cw = u.cacheWrite ?? 0;
303
314
  const total = inp + out + cr + cw;
304
315
  const tokenSep = ` ${GLYPHS.navigation.pipeSeparator} `;
305
- 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} ]`;
306
318
  const copiedNotice = isRecentlyCopied ? ` [COPIED] ` : '';
307
319
  const statsLine = ' ' + tokenLine + ' '.repeat(Math.max(0, width - 4 - getDisplayWidth(tokenLine) - getDisplayWidth(copiedNotice))) + copiedNotice;
308
320
  lines.push(this.stringToLine(statsLine, width, { fg: isRecentlyCopied ? '81' : '244', bold: isRecentlyCopied }));
309
- // Context usage progress bar
310
- if (contextWindow && contextWindow > 0) {
321
+ // Context usage progress bar — suppressed in compact mode.
322
+ if (!compact && contextWindow && contextWindow > 0) {
311
323
  const ctxTokens = lastInputTokens ?? 0;
312
324
  const label = ' Context Usage: ';
313
325
  const suffix = ` [ ${fmtNum(ctxTokens)} / ${fmtNum(contextWindow)} ]`;
314
326
  const barWidth = Math.max(10, Math.min(30, width - getDisplayWidth(label) - getDisplayWidth(suffix) - 8));
315
- const ctxPct = Math.min(1, ctxTokens / contextWindow);
327
+ const ctxPct = computeContextUsage(ctxTokens, contextWindow).clampedRatio;
316
328
  // Clamp threshold to [0..1]; undefined/0 means no threshold marker.
317
329
  const thresholdFraction = (compactThreshold !== undefined && compactThreshold > 0)
318
330
  ? Math.min(1, compactThreshold)
319
331
  : undefined;
320
- lines.push(createBaseLine());
321
332
  lines.push(this.createProgressBarLine(label, ctxPct, barWidth, width, suffix, thresholdFraction));
322
333
  }
323
- // Context info line (working dir, model+provider, tools)
324
- 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)) {
325
338
  const home = typeof process !== 'undefined' ? process.env.HOME ?? '' : '';
326
339
  const displayDir = workingDir && home && workingDir.startsWith(home)
327
340
  ? '~' + workingDir.slice(home.length)
@@ -333,19 +346,16 @@ export class UIFactory {
333
346
  }
334
347
  if (toolCount) ctxParts.push(`${toolCount} tools`);
335
348
  if (hitlMode) ctxParts.push(`hitl:${hitlMode}`);
336
- if (composerMode) ctxParts.push(`mode:${composerMode}`);
337
- if (composerStatus && composerStatus !== 'idle') ctxParts.push(`status:${composerStatus}`);
338
- if (composerFlags && composerFlags.length > 0) ctxParts.push(composerFlags.join(','));
339
349
  const ctxLine = ' ' + ctxParts.join(` ${GLYPHS.navigation.pipeSeparator} `);
340
- lines.push(createBaseLine());
341
350
  lines.push(this.stringToLine(truncateDisplay(ctxLine, width), width, { fg: '240', dim: true }));
342
- lines.push(createBaseLine());
343
351
  }
344
352
  if (showExitNotice) {
345
353
  const notice = ` !!! Press Ctrl+C again to exit !!! `;
346
354
  lines.push(this.stringToLine(fitDisplay(notice, width), width, { fg: '196', bold: true }));
347
355
  } else {
348
- 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) })} `;
349
359
  const dangerWarn = dangerMode ? `! DANGER MODE - ALL CHANGES AUTO-APPROVED ` : '';
350
360
  const helpW = getDisplayWidth(help);
351
361
  const dangerW = getDisplayWidth(dangerWarn);
@@ -365,7 +375,6 @@ export class UIFactory {
365
375
  }
366
376
  lines.push(line);
367
377
  }
368
- lines.push(createBaseLine());
369
378
  return lines;
370
379
  }
371
380
 
@@ -394,8 +403,8 @@ export class UIFactory {
394
403
  ];
395
404
 
396
405
  /** Gradient colors for thinking text — cyan to purple (matches splash). */
397
- private static readonly THINK_GRADIENT_START = '#00ffff';
398
- 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;
399
408
 
400
409
  public static createThinkingFragment(width: number, spinner: string, frame: number = 0, tokenSpeed?: number, toolPreview?: string, inputTokens?: number, outputTokens?: number, elapsedMs?: number, ttftMs?: number): Line[] {
401
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;
@@ -109,12 +109,41 @@ export function createBootstrapShell(options: BootstrapShellOptions): BootstrapS
109
109
  homeDirectory: services.homeDirectory,
110
110
  });
111
111
 
112
+ const foundationClients = createRuntimeFoundationClients({
113
+ runtimeServices: services,
114
+ tasksReadModel: uiServices.readModels.tasks,
115
+ taskManager,
116
+ opsControlPlane,
117
+ });
118
+ const {
119
+ directTransport,
120
+ hookApi,
121
+ knowledgeApi,
122
+ mcpApi,
123
+ opsApi,
124
+ providerApi,
125
+ } = foundationClients;
126
+ const planRuntime = createShellPlanRuntime({
127
+ adaptivePlanner: services.adaptivePlanner,
128
+ runtimeBus,
129
+ });
130
+
112
131
  const openAgentDetailRef: { fn: (agentId: string) => void } = { fn: (_agentId: string) => {} };
113
132
 
133
+ // WO-139: initial cost-budget alert threshold (USD; 0/unset = disabled).
134
+ // Once the session starts, the real control surface is the CostTrackerPanel
135
+ // itself — the in-panel 'b' key and /cost budget <usd> both call
136
+ // CostTrackerPanel.setBudgetThreshold() directly on the live panel instance.
137
+ const parsedBudgetThreshold = Number(process.env.GOODVIBES_COST_BUDGET_USD);
138
+ const initialCostBudgetThreshold = Number.isFinite(parsedBudgetThreshold) && parsedBudgetThreshold > 0
139
+ ? parsedBudgetThreshold
140
+ : 0;
141
+
114
142
  let commandContextRef: CommandContext | null = null;
115
143
  registerBuiltinPanels(services.panelManager, {
116
144
  configManager,
117
145
  getOrchestratorUsage: () => orchestrator.usage as { input: number; output: number; cacheRead: number; cacheWrite: number; model?: string },
146
+ budgetThreshold: initialCostBudgetThreshold,
118
147
  toolRegistry,
119
148
  providerRegistry: services.providerRegistry,
120
149
  contextWindow: services.providerRegistry.getContextWindowForModel(services.providerRegistry.getCurrentModel()),
@@ -153,6 +182,12 @@ export function createBootstrapShell(options: BootstrapShellOptions): BootstrapS
153
182
  mcpRegistry: services.mcpRegistry,
154
183
  openAgentDetail: (agentId: string) => openAgentDetailRef.fn(agentId),
155
184
  daemonHomeDir: join(services.homeDirectory, '.goodvibes', 'daemon'),
185
+ opsApi,
186
+ planRuntime,
187
+ watcherRegistry: services.watcherRegistry,
188
+ runtimeStore,
189
+ openPanel: (panelId: string) => { services.panelManager.open(panelId); },
190
+ knowledgeApi,
156
191
  });
157
192
  services.panelManager.prewarmRegistered();
158
193
 
@@ -170,29 +205,11 @@ export function createBootstrapShell(options: BootstrapShellOptions): BootstrapS
170
205
 
171
206
  const commandRegistry = new CommandRegistry();
172
207
  registerBuiltinCommands(commandRegistry);
173
- const foundationClients = createRuntimeFoundationClients({
174
- runtimeServices: services,
175
- tasksReadModel: uiServices.readModels.tasks,
176
- taskManager,
177
- opsControlPlane,
178
- });
179
- const {
180
- directTransport,
181
- hookApi,
182
- knowledgeApi,
183
- mcpApi,
184
- opsApi,
185
- providerApi,
186
- } = foundationClients;
187
208
  const remoteRuntime = createShellRemoteCommandService({
188
209
  readModels: uiServices.readModels,
189
210
  remoteRunnerRegistry: services.remoteRunnerRegistry,
190
211
  runtimeStore,
191
212
  });
192
- const planRuntime = createShellPlanRuntime({
193
- adaptivePlanner: services.adaptivePlanner,
194
- runtimeBus,
195
- });
196
213
 
197
214
  const commandContext: CommandContext = createBootstrapCommandContext({
198
215
  configManager,