@pellux/goodvibes-tui 0.28.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (144) hide show
  1. package/CHANGELOG.md +133 -0
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/src/core/conversation-line-cache.ts +432 -0
  5. package/src/core/conversation-rendering.ts +11 -3
  6. package/src/core/conversation.ts +50 -4
  7. package/src/input/commands/cost-runtime.ts +49 -0
  8. package/src/input/commands/operator-runtime.ts +5 -1
  9. package/src/input/commands.ts +2 -0
  10. package/src/input/handler-content-actions.ts +8 -3
  11. package/src/input/handler-feed-routes.ts +12 -35
  12. package/src/input/handler-feed.ts +4 -8
  13. package/src/input/handler-shortcuts.ts +51 -0
  14. package/src/input/handler.ts +43 -6
  15. package/src/input/input-history.ts +17 -4
  16. package/src/input/keybindings.ts +48 -8
  17. package/src/input/panel-integration-actions.ts +107 -1
  18. package/src/main.ts +19 -11
  19. package/src/panels/agent-inspector-panel.ts +125 -36
  20. package/src/panels/agent-inspector-shared.ts +144 -0
  21. package/src/panels/approval-panel.ts +67 -16
  22. package/src/panels/automation-control-panel.ts +368 -124
  23. package/src/panels/base-panel.ts +1 -1
  24. package/src/panels/builtin/agent.ts +28 -37
  25. package/src/panels/builtin/development.ts +40 -32
  26. package/src/panels/builtin/knowledge.ts +15 -6
  27. package/src/panels/builtin/operations.ts +178 -117
  28. package/src/panels/builtin/session.ts +35 -9
  29. package/src/panels/builtin/shared.ts +98 -6
  30. package/src/panels/cockpit-panel.ts +232 -73
  31. package/src/panels/communication-panel.ts +58 -20
  32. package/src/panels/confirm-state.ts +8 -1
  33. package/src/panels/control-plane-panel.ts +351 -118
  34. package/src/panels/cost-tracker-panel.ts +165 -7
  35. package/src/panels/debug-panel.ts +312 -159
  36. package/src/panels/diff-panel.ts +209 -57
  37. package/src/panels/docs-panel.ts +152 -66
  38. package/src/panels/eval-panel.ts +220 -42
  39. package/src/panels/file-explorer-panel.ts +202 -122
  40. package/src/panels/file-preview-panel.ts +132 -57
  41. package/src/panels/git-panel.ts +310 -129
  42. package/src/panels/hooks-panel.ts +150 -22
  43. package/src/panels/incident-review-panel.ts +223 -35
  44. package/src/panels/index.ts +0 -4
  45. package/src/panels/intelligence-panel.ts +212 -86
  46. package/src/panels/knowledge-graph-panel.ts +461 -101
  47. package/src/panels/local-auth-panel.ts +240 -28
  48. package/src/panels/marketplace-panel.ts +193 -26
  49. package/src/panels/memory-panel.ts +78 -77
  50. package/src/panels/ops-control-panel.ts +146 -29
  51. package/src/panels/ops-strategy-panel.ts +72 -4
  52. package/src/panels/orchestration-panel.ts +231 -69
  53. package/src/panels/panel-list-panel.ts +154 -131
  54. package/src/panels/panel-manager.ts +97 -9
  55. package/src/panels/plan-dashboard-panel.ts +333 -17
  56. package/src/panels/plugins-panel.ts +184 -29
  57. package/src/panels/policy-panel.ts +210 -38
  58. package/src/panels/polish-core.ts +7 -2
  59. package/src/panels/polish.ts +23 -4
  60. package/src/panels/project-planning-answer-actions.ts +134 -0
  61. package/src/panels/project-planning-panel.ts +62 -113
  62. package/src/panels/provider-health-panel.ts +434 -518
  63. package/src/panels/provider-health-routes.ts +203 -0
  64. package/src/panels/provider-health-tracker.ts +194 -6
  65. package/src/panels/provider-health-views.ts +560 -0
  66. package/src/panels/qr-panel.ts +116 -30
  67. package/src/panels/remote-panel.ts +114 -36
  68. package/src/panels/routes-panel.ts +63 -22
  69. package/src/panels/sandbox-panel.ts +174 -42
  70. package/src/panels/scrollable-list-panel.ts +19 -135
  71. package/src/panels/security-panel.ts +133 -33
  72. package/src/panels/services-panel.ts +116 -64
  73. package/src/panels/session-browser-panel.ts +73 -2
  74. package/src/panels/session-maintenance.ts +4 -122
  75. package/src/panels/settings-sync-panel.ts +335 -72
  76. package/src/panels/skills-panel.ts +157 -89
  77. package/src/panels/subscription-panel.ts +86 -33
  78. package/src/panels/symbol-outline-panel.ts +248 -108
  79. package/src/panels/system-messages-panel.ts +114 -13
  80. package/src/panels/tasks-panel.ts +326 -139
  81. package/src/panels/thinking-panel.ts +43 -10
  82. package/src/panels/token-budget-panel.ts +194 -18
  83. package/src/panels/tool-inspector-panel.ts +144 -34
  84. package/src/panels/types.ts +40 -4
  85. package/src/panels/work-plan-panel.ts +280 -17
  86. package/src/panels/worktree-panel.ts +175 -42
  87. package/src/panels/wrfc-panel.ts +116 -24
  88. package/src/renderer/agent-detail-modal.ts +37 -8
  89. package/src/renderer/code-block.ts +58 -28
  90. package/src/renderer/context-inspector.ts +3 -6
  91. package/src/renderer/conversation-overlays.ts +25 -11
  92. package/src/renderer/conversation-surface.ts +1 -21
  93. package/src/renderer/diff-view.ts +6 -4
  94. package/src/renderer/footer-tips.ts +41 -0
  95. package/src/renderer/fullscreen-primitives.ts +23 -17
  96. package/src/renderer/fullscreen-workspace.ts +2 -7
  97. package/src/renderer/help-overlay.ts +91 -14
  98. package/src/renderer/hint-grammar.ts +52 -0
  99. package/src/renderer/history-search-overlay.ts +10 -16
  100. package/src/renderer/layout.ts +7 -7
  101. package/src/renderer/markdown.ts +9 -1
  102. package/src/renderer/modal-factory.ts +23 -15
  103. package/src/renderer/model-picker-overlay.ts +8 -485
  104. package/src/renderer/model-workspace.ts +9 -24
  105. package/src/renderer/overlay-box.ts +23 -19
  106. package/src/renderer/process-indicator.ts +15 -25
  107. package/src/renderer/profile-picker-modal.ts +13 -14
  108. package/src/renderer/prompt-content-width.ts +16 -0
  109. package/src/renderer/search-overlay.ts +27 -6
  110. package/src/renderer/selection-modal-overlay.ts +3 -1
  111. package/src/renderer/semantic-diff.ts +1 -1
  112. package/src/renderer/session-picker-modal.ts +6 -4
  113. package/src/renderer/settings-modal-helpers.ts +10 -34
  114. package/src/renderer/settings-modal.ts +70 -10
  115. package/src/renderer/shell-surface.ts +54 -15
  116. package/src/renderer/status-glyphs.ts +11 -9
  117. package/src/renderer/surface-layout.ts +0 -12
  118. package/src/renderer/term-caps.ts +1 -1
  119. package/src/renderer/theme.ts +60 -3
  120. package/src/renderer/tool-call.ts +6 -20
  121. package/src/renderer/ui-factory.ts +48 -37
  122. package/src/renderer/ui-primitives.ts +41 -3
  123. package/src/runtime/bootstrap-shell.ts +35 -18
  124. package/src/runtime/diagnostics/panels/index.ts +0 -3
  125. package/src/runtime/render-scheduler.ts +80 -0
  126. package/src/shell/ui-openers.ts +14 -22
  127. package/src/utils/format-duration.ts +2 -2
  128. package/src/utils/splash-lines.ts +54 -5
  129. package/src/version.ts +1 -1
  130. package/src/work-plans/work-plan-store.ts +13 -0
  131. package/src/panels/agent-logs-panel.ts +0 -635
  132. package/src/panels/agent-logs-shared.ts +0 -129
  133. package/src/panels/context-visualizer-panel.ts +0 -238
  134. package/src/panels/forensics-panel.ts +0 -378
  135. package/src/panels/provider-account-snapshot.ts +0 -259
  136. package/src/panels/provider-accounts-panel.ts +0 -255
  137. package/src/panels/provider-stats-panel.ts +0 -391
  138. package/src/panels/schedule-panel.ts +0 -365
  139. package/src/panels/watchers-panel.ts +0 -213
  140. package/src/renderer/file-tree.ts +0 -153
  141. package/src/renderer/progress.ts +0 -99
  142. package/src/renderer/status-token.ts +0 -67
  143. package/src/runtime/diagnostics/panels/panel-resources.ts +0 -118
  144. package/src/runtime/diagnostics/panels/policy.ts +0 -177
@@ -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
- }
@@ -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,22 +264,24 @@ 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
- if (composerMode) composerTokens.push({ text: ` ${GLYPHS.status.active} ${composerMode} `, fg: '#38bdf8', bold: true });
271
+ if (composerMode) composerTokens.push({ text: ` ${GLYPHS.status.active} ${composerMode} `, fg: UI_TONES.state.info, bold: true });
259
272
  if (composerPendingRisk && composerPendingRisk !== 'none') {
260
273
  const riskColor = composerPendingRisk === 'approval-wait'
261
- ? '#f59e0b'
274
+ ? UI_TONES.state.warn
262
275
  : composerPendingRisk === 'shell'
263
- ? '#ef4444'
276
+ ? UI_TONES.state.bad
264
277
  : composerPendingRisk === 'remote'
265
278
  ? '#a78bfa'
266
- : '#f59e0b';
279
+ : UI_TONES.state.warn;
267
280
  composerTokens.push({ text: ` risk:${composerPendingRisk} `, fg: riskColor, bold: true });
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);
@@ -356,14 +368,13 @@ export class UIFactory {
356
368
  for (const ch of dangerWarn) {
357
369
  if (col >= width) break;
358
370
  const cw = getDisplayWidth(ch);
359
- line[col] = { char: ch, fg: '#ef4444', bg: '', bold: true, dim: false, underline: false, italic: false, strikethrough: false };
371
+ line[col] = { char: ch, fg: UI_TONES.state.bad, bg: '', bold: true, dim: false, underline: false, italic: false, strikethrough: false };
360
372
  if (cw === 2 && col + 1 < width) line[col + 1] = { ...line[col], char: '' };
361
373
  col += cw;
362
374
  }
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)
@@ -419,7 +430,7 @@ export class UIFactory {
419
430
  const inTok = inputTokens ?? 0;
420
431
  const outTok = outputTokens ?? 0;
421
432
  segments.push({ text: ` in ${fmtNum(inTok)} `, fg: '243', dim: true });
422
- segments.push({ text: `out ${fmtNum(outTok)}`, fg: '#00ffff' });
433
+ segments.push({ text: `out ${fmtNum(outTok)}`, fg: UI_TONES.accent.brand });
423
434
  }
424
435
  const line = createEmptyLine(width);
425
436
  let col = 1;
@@ -459,7 +470,7 @@ export class UIFactory {
459
470
  if (px >= width) break;
460
471
  previewLine[px] = {
461
472
  char: ch,
462
- fg: '#38bdf8',
473
+ fg: UI_TONES.state.info,
463
474
  bg: '',
464
475
  bold: true,
465
476
  dim: false,
@@ -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,33 @@ 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
- 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;
132
+
133
+ /** Single spinner-frame source — layout.ts and progress.ts both re-export this. */
134
+ 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,
@@ -12,13 +12,10 @@ export type { InspectableDomain } from '@/runtime/index.ts';
12
12
  export { HealthPanel } from '@/runtime/index.ts';
13
13
  export { DivergencePanel } from '@/runtime/index.ts';
14
14
  export { ReplayPanel } from '@/runtime/index.ts';
15
- export { PolicyPanel } from './policy.ts';
16
- export type { PolicyPanelSnapshot } from './policy.ts';
17
15
  export { ToolContractsPanel } from '@/runtime/index.ts';
18
16
  export { TransportPanel } from '@/runtime/index.ts';
19
17
  export type { TransportPanelSnapshot } from '@/runtime/index.ts';
20
18
  export { OpsPanel } from './ops.ts';
21
19
  export type { OpsAuditEntry } from './ops.ts';
22
- export { PanelResourcesPanel } from './panel-resources.ts';
23
20
  export { SecurityPanel } from '@/runtime/index.ts';
24
21
  export type { SecurityPanelSnapshot } from '@/runtime/index.ts';
@@ -0,0 +1,80 @@
1
+ /**
2
+ * render-scheduler.ts — same-tick render coalescing for the TUI shell (WO-208).
3
+ *
4
+ * main.ts fans out its own direct render() calls across turn/stream/input wiring
5
+ * (~14 direct invocation contexts). Each one previously ran a full synchronous
6
+ * Compositor.composite() the instant it was called. When several fire within a
7
+ * single event-loop tick — a streaming burst is the canonical case — only the
8
+ * LAST frame is ever visible; the earlier composites are immediately overwritten.
9
+ * This scheduler collapses every schedule() call made within one tick into a
10
+ * single composite flushed on the microtask queue, so the tick produces exactly
11
+ * one frame. That frame is byte-identical to what the last synchronous render()
12
+ * would have produced, because the coalesced flush runs after all of the tick's
13
+ * synchronous state mutations, reading the same final state the last direct
14
+ * render() would have read.
15
+ *
16
+ * This is deliberately SEPARATE from bootstrap-core's requestRender(), which is a
17
+ * cross-tick, 16ms-throttled (~60fps) coalescer for the panel/input/runtime
18
+ * fan-out — that one caps repaint RATE across ticks. This one collapses the
19
+ * WITHIN-tick burst with no added latency: a microtask flushes at the tail of the
20
+ * current tick, before the event loop yields to I/O, so the frame still lands in
21
+ * the same turn it was requested in.
22
+ *
23
+ * flushNow() preserves a synchronous immediate path for callers that genuinely
24
+ * need the frame composited before they return. Terminal resize is the known
25
+ * case: the resize handler resets the compositor diff and must repaint against
26
+ * the new dimensions synchronously rather than waiting for the microtask.
27
+ * flushNow() also clears any pending coalesced flush, so a resize (or any
28
+ * immediate paint) followed by an already-queued microtask never double-composites.
29
+ */
30
+
31
+ export interface RenderScheduler {
32
+ /**
33
+ * Coalesced request: schedule a composite for the current tick. N calls within
34
+ * one tick collapse into exactly one composite, flushed on the microtask queue.
35
+ */
36
+ readonly schedule: () => void;
37
+ /**
38
+ * Immediate request: run the composite synchronously right now, and cancel any
39
+ * pending coalesced flush so the tick still composites only once. Use only where
40
+ * a caller genuinely requires synchronous output (terminal resize).
41
+ */
42
+ readonly flushNow: () => void;
43
+ }
44
+
45
+ /**
46
+ * Build a render scheduler around `renderNow` (the synchronous composite closure).
47
+ *
48
+ * `scheduleFlush` is the deferral primitive; it defaults to `queueMicrotask` so
49
+ * bursts coalesce within the current tick. Tests and benchmarks inject a manual
50
+ * queue to flush deterministically.
51
+ */
52
+ export function createRenderScheduler(
53
+ renderNow: () => void,
54
+ scheduleFlush: (flush: () => void) => void = queueMicrotask,
55
+ ): RenderScheduler {
56
+ let scheduled = false;
57
+
58
+ const flush = (): void => {
59
+ // A synchronous flushNow() (or a superseding immediate paint) before this
60
+ // microtask ran clears `scheduled`, turning this into a no-op so a single
61
+ // tick never composites twice.
62
+ if (!scheduled) return;
63
+ scheduled = false;
64
+ renderNow();
65
+ };
66
+
67
+ const schedule = (): void => {
68
+ if (scheduled) return;
69
+ scheduled = true;
70
+ scheduleFlush(flush);
71
+ };
72
+
73
+ const flushNow = (): void => {
74
+ // Satisfy any pending coalesced flush, then composite synchronously.
75
+ scheduled = false;
76
+ renderNow();
77
+ };
78
+
79
+ return { schedule, flushNow };
80
+ }
@@ -253,6 +253,7 @@ export function wireShellUiOpeners(options: WireShellUiOpenersOptions): void {
253
253
  if (!input.helpOverlayActive) input.modalOpened('help');
254
254
  input.helpOverlayActive = !input.helpOverlayActive;
255
255
  input.helpScrollOffset = 0;
256
+ render();
256
257
  };
257
258
 
258
259
  commandContext.openShortcutsOverlay = () => {
@@ -292,19 +293,16 @@ export function wireShellUiOpeners(options: WireShellUiOpenersOptions): void {
292
293
  };
293
294
 
294
295
  commandContext.openPanelPicker = () => {
295
- if (!panelManager.isVisible()) {
296
- if (panelManager.getAllOpen().length === 0) {
297
- try {
298
- panelManager.open('panel-list');
299
- } catch {
300
- // non-fatal
301
- }
302
- }
303
- panelManager.show();
304
- input.panelFocused = true;
305
- conversation.setSplashSuppressed(true);
296
+ // Focus ownership lives in PanelManager (focusTarget); read it there rather
297
+ // than tracking a parallel input.panelFocused flag. Toggle semantics: if the
298
+ // workspace is visible AND already focused, hide it; otherwise reveal and
299
+ // focus it (opening the panel list when nothing is open yet).
300
+ if (panelManager.isVisible() && panelManager.getFocusTarget() === 'panel') {
301
+ panelManager.hide();
302
+ panelManager.focusPrompt();
303
+ conversation.setSplashSuppressed(false);
306
304
  conversation.rebuildHistory();
307
- } else if (!input.panelFocused) {
305
+ } else {
308
306
  if (panelManager.getAllOpen().length === 0) {
309
307
  try {
310
308
  panelManager.open('panel-list');
@@ -313,26 +311,20 @@ export function wireShellUiOpeners(options: WireShellUiOpenersOptions): void {
313
311
  }
314
312
  }
315
313
  panelManager.show();
316
- input.panelFocused = true;
314
+ panelManager.focusPanels();
317
315
  conversation.setSplashSuppressed(true);
318
316
  conversation.rebuildHistory();
319
- } else {
320
- panelManager.hide();
321
- input.panelFocused = false;
322
- conversation.setSplashSuppressed(false);
323
- conversation.rebuildHistory();
324
317
  }
325
318
  render();
326
319
  };
327
320
 
328
321
  commandContext.focusPanels = () => {
329
- if (!panelManager.isVisible() || panelManager.getAllOpen().length === 0) return;
330
- input.panelFocused = true;
322
+ panelManager.focusPanels();
331
323
  render();
332
324
  };
333
325
 
334
326
  commandContext.focusPrompt = () => {
335
- input.panelFocused = false;
327
+ panelManager.focusPrompt();
336
328
  input.indicatorFocused = false;
337
329
  render();
338
330
  };
@@ -340,7 +332,7 @@ export function wireShellUiOpeners(options: WireShellUiOpenersOptions): void {
340
332
  commandContext.showPanel = (panelId, pane) => {
341
333
  panelManager.open(panelId, pane);
342
334
  panelManager.show();
343
- input.panelFocused = true;
335
+ panelManager.focusPanels();
344
336
  conversation.setSplashSuppressed(true);
345
337
  conversation.rebuildHistory();
346
338
  render();
@@ -10,7 +10,7 @@
10
10
  /**
11
11
  * Format a latency value in milliseconds with sub-second precision.
12
12
  *
13
- * Used by: debug-panel, provider-health-panel, provider-stats-panel
13
+ * Used by: debug-panel, provider-health-panel
14
14
  *
15
15
  * ms <= 0 → 'n/a'
16
16
  * ms >= 10000 → '12.3s' (one decimal)
@@ -42,7 +42,7 @@ export function formatDuration(ms: number): string {
42
42
  /**
43
43
  * Format a short eval/forensics duration; treats undefined as '?ms'.
44
44
  *
45
- * Used by: forensics-panel, eval-panel
45
+ * Used by: incident-review-panel, eval-panel
46
46
  *
47
47
  * undefined → '?ms'
48
48
  * ms < 1000 → '500ms'