@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
@@ -10,6 +10,24 @@ import { getOverlaySurfaceMetrics, getStableOverlayContentRows } from './overlay
10
10
  import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
11
11
  import { handleConfirmInput, type ConfirmState } from '../panels/confirm-state.ts';
12
12
  import { AGENT_TERMINAL_STATUSES as MODAL_TERMINAL_STATUSES, AGENT_STALL_THRESHOLD_MS as MODAL_STALL_THRESHOLD_MS } from '../panels/agent-inspector-shared.ts';
13
+ import { UI_TONES } from './ui-primitives.ts';
14
+
15
+ /**
16
+ * Agent-detail-modal neon accent palette — WRFC/streaming panel-native hues
17
+ * with no UI_TONES.state/accent role. Preserved byte-exact as named
18
+ * accents per WO-207b (2026-07-02-renderer-plan.md decisions: "Agent-detail
19
+ * neon palette: preserved byte-exact as named accents, zero visual change").
20
+ */
21
+ const AGENT_DETAIL_NEON = {
22
+ /** Addendum note + streaming content text. */
23
+ mint: '#aaffee',
24
+ /** Unsatisfied WRFC findings + agent error text. */
25
+ bad: '#ff6666',
26
+ /** Satisfied WRFC findings. */
27
+ good: '#44ff88',
28
+ /** Progress line + streaming label. */
29
+ accent: '#00ffcc',
30
+ } as const;
13
31
 
14
32
  // ─── Constants ────────────────────────────────────────────────────────────────
15
33
 
@@ -19,6 +37,17 @@ const AGENT_ID_DISPLAY_LENGTH = 16;
19
37
  // MODAL_TERMINAL_STATUSES and MODAL_STALL_THRESHOLD_MS are re-exported aliases
20
38
  // from agent-inspector-shared.ts (imported above alongside ConfirmState).
21
39
 
40
+ /**
41
+ * formatStalledLabel — the "[STALLED — N+ min no activity]" suffix appended
42
+ * to the Status line for a stalled agent. The minute count is derived from
43
+ * the stall threshold (in ms) rather than hardcoded, so the label can never
44
+ * drift out of sync with the actual threshold that decided isStalled.
45
+ */
46
+ export function formatStalledLabel(thresholdMs: number): string {
47
+ const minutes = Math.floor(thresholdMs / 60000);
48
+ return ` [STALLED — ${minutes}+ min no activity]`;
49
+ }
50
+
22
51
  export interface AgentDetailModalDeps {
23
52
  readonly agentManager: Pick<AgentManager, 'getStatus' | 'list'>;
24
53
  readonly agentMessageBus: Pick<AgentMessageBus, 'getMessages'>;
@@ -248,7 +277,7 @@ export function renderAgentDetailModal(
248
277
  sections.push({ type: 'text', content: `Template : ${rec.template}` });
249
278
  sections.push({ type: 'text', content: `Model : ${modelStr}` });
250
279
  const isStalled = !MODAL_TERMINAL_STATUSES.has(rec.status) && (now - rec.startedAt) >= MODAL_STALL_THRESHOLD_MS;
251
- sections.push({ type: 'text', content: `Status : ${rec.status}${isStalled ? ' [STALLED — 5+ min no activity]' : ''}` });
280
+ sections.push({ type: 'text', content: `Status : ${rec.status}${isStalled ? formatStalledLabel(MODAL_STALL_THRESHOLD_MS) : ''}` });
252
281
  sections.push({ type: 'text', content: `Duration : ${formatElapsed(elapsedMs)}` });
253
282
  sections.push({ type: 'separator' });
254
283
 
@@ -271,7 +300,7 @@ export function renderAgentDetailModal(
271
300
  sections.push({
272
301
  type: 'text',
273
302
  content: 'Addendum : yes (WRFC constraint layer injected)',
274
- style: { fg: '#aaffee' },
303
+ style: { fg: AGENT_DETAIL_NEON.mint },
275
304
  });
276
305
  }
277
306
 
@@ -301,7 +330,7 @@ export function renderAgentDetailModal(
301
330
  sections.push({
302
331
  type: 'text',
303
332
  content: `Findings : ${findings.length} checked, ${unsatisfied.length} unsatisfied`,
304
- style: { fg: unsatisfied.length > 0 ? '#ff6666' : '#44ff88' },
333
+ style: { fg: unsatisfied.length > 0 ? AGENT_DETAIL_NEON.bad : AGENT_DETAIL_NEON.good },
305
334
  });
306
335
  }
307
336
  }
@@ -316,7 +345,7 @@ export function renderAgentDetailModal(
316
345
  sections.push({
317
346
  type: 'text',
318
347
  content: `Progress: ${rec.progress}`,
319
- style: { fg: '#00ffcc' },
348
+ style: { fg: AGENT_DETAIL_NEON.accent },
320
349
  });
321
350
  }
322
351
 
@@ -326,7 +355,7 @@ export function renderAgentDetailModal(
326
355
  sections.push({
327
356
  type: 'text',
328
357
  content: `Error: ${rec.error}`,
329
- style: { fg: '#ff6666' },
358
+ style: { fg: AGENT_DETAIL_NEON.bad },
330
359
  });
331
360
  }
332
361
 
@@ -393,7 +422,7 @@ export function renderAgentDetailModal(
393
422
  content: truncated
394
423
  ? `Streaming (last ${STREAMING_MAX_CHARS} of ${content.length} chars \u2191 scroll for more):`
395
424
  : 'Streaming:',
396
- style: { fg: '#00ffcc', dim: true },
425
+ style: { fg: AGENT_DETAIL_NEON.accent, dim: true },
397
426
  });
398
427
  // Split into display lines, capped at width for readability
399
428
  const maxLineWidth = Math.max(width - 10, 40);
@@ -403,7 +432,7 @@ export function renderAgentDetailModal(
403
432
  sections.push({
404
433
  type: 'text',
405
434
  content: ` ${trimmed}`,
406
- style: { fg: '#aaffee' },
435
+ style: { fg: AGENT_DETAIL_NEON.mint },
407
436
  });
408
437
  }
409
438
  }
@@ -415,7 +444,7 @@ export function renderAgentDetailModal(
415
444
  sections.push({
416
445
  type: 'text',
417
446
  content: `Cancel agent "${modal.confirmCancel.label}"?`,
418
- style: { fg: '#f59e0b' },
447
+ style: { fg: UI_TONES.state.warn },
419
448
  });
420
449
  sections.push({
421
450
  type: 'text',
@@ -3,6 +3,32 @@ import { UIFactory } from './ui-factory.ts';
3
3
  import { getDisplayWidth } from '../utils/terminal-width.ts';
4
4
  import { LAYOUT } from './layout.ts';
5
5
  import { SyntaxHighlighter, type SyntaxToken as HLToken } from './syntax-highlighter.ts';
6
+ import { UI_TONES } from './ui-primitives.ts';
7
+
8
+ /**
9
+ * Regex-fallback tokenizer theme. VS Code Dark+ inspired, but the
10
+ * semantic-token hues (string/number/keyword/type/function/operator/
11
+ * property/comment) are pinned to the same values as the tree-sitter
12
+ * Vaporwave palette in syntax-highlighter.ts's TOKEN_STYLES map, so a code
13
+ * block that starts on the regex fallback and gets replaced by the async
14
+ * tree-sitter parse doesn't visibly shift color mid-stream (WO-207c).
15
+ * `accent` and `bg` are chrome (the language-label header bar and the body
16
+ * background), not syntax tokens, and keep their original VS Code Dark+
17
+ * values — folded here from two separate literals (one in the header, one
18
+ * duplicated on the footer line) into a single named source (WO-207b).
19
+ */
20
+ const FALLBACK_THEME = {
21
+ string: '#00ff88',
22
+ number: '#ffcc00',
23
+ keyword: '#d000ff',
24
+ type: '#ff6b9d',
25
+ function: UI_TONES.accent.brand,
26
+ operator: '#ffffff',
27
+ property: '#87ceeb',
28
+ comment: '#666666',
29
+ bg: '#0d0d0d',
30
+ accent: '#4ec9b0',
31
+ } as const;
6
32
 
7
33
  // ─── Language Keyword Maps ───────────────────────────────────────────────────
8
34
 
@@ -61,7 +87,7 @@ function tokenizeTsJs(line: string): SyntaxToken[] {
61
87
  while (i < line.length) {
62
88
  // Line comment
63
89
  if (line.slice(i, i + 2) === '//') {
64
- tokens.push({ text: line.slice(i), fg: '65', italic: true });
90
+ tokens.push({ text: line.slice(i), fg: FALLBACK_THEME.comment, italic: true });
65
91
  break;
66
92
  }
67
93
  // String (single, double, template)
@@ -72,7 +98,7 @@ function tokenizeTsJs(line: string): SyntaxToken[] {
72
98
  if (line[j] === '\\') j++;
73
99
  j++;
74
100
  }
75
- tokens.push({ text: line.slice(i, j + 1), fg: '#ce9178' });
101
+ tokens.push({ text: line.slice(i, j + 1), fg: FALLBACK_THEME.string });
76
102
  i = j + 1;
77
103
  continue;
78
104
  }
@@ -80,7 +106,7 @@ function tokenizeTsJs(line: string): SyntaxToken[] {
80
106
  if (/[0-9]/.test(line[i])) {
81
107
  let j = i;
82
108
  while (j < line.length && /[0-9._xXbBoO]/.test(line[j])) j++;
83
- tokens.push({ text: line.slice(i, j), fg: '#b5cea8' });
109
+ tokens.push({ text: line.slice(i, j), fg: FALLBACK_THEME.number });
84
110
  i = j;
85
111
  continue;
86
112
  }
@@ -90,11 +116,11 @@ function tokenizeTsJs(line: string): SyntaxToken[] {
90
116
  while (j < line.length && /[\w$]/.test(line[j])) j++;
91
117
  const word = line.slice(i, j);
92
118
  if (TS_JS_KEYWORDS.has(word)) {
93
- tokens.push({ text: word, fg: '#569cd6', bold: true });
119
+ tokens.push({ text: word, fg: FALLBACK_THEME.keyword, bold: true });
94
120
  } else if (TS_TYPES.has(word)) {
95
- tokens.push({ text: word, fg: '#4ec9b0' });
121
+ tokens.push({ text: word, fg: FALLBACK_THEME.type });
96
122
  } else if (line[j] === '(') {
97
- tokens.push({ text: word, fg: '#dcdcaa' });
123
+ tokens.push({ text: word, fg: FALLBACK_THEME.function });
98
124
  } else {
99
125
  tokens.push({ text: word, fg: '' });
100
126
  }
@@ -104,7 +130,7 @@ function tokenizeTsJs(line: string): SyntaxToken[] {
104
130
  // Operators and punctuation
105
131
  const ch = line[i];
106
132
  const isOp = '=<>!&|+-*/%^~?:'.includes(ch);
107
- tokens.push({ text: ch, fg: isOp ? '#d4d4d4' : '' });
133
+ tokens.push({ text: ch, fg: isOp ? FALLBACK_THEME.operator : '' });
108
134
  i++;
109
135
  }
110
136
 
@@ -117,21 +143,21 @@ function tokenizePython(line: string): SyntaxToken[] {
117
143
 
118
144
  while (i < line.length) {
119
145
  if (line[i] === '#') {
120
- tokens.push({ text: line.slice(i), fg: '65', italic: true });
146
+ tokens.push({ text: line.slice(i), fg: FALLBACK_THEME.comment, italic: true });
121
147
  break;
122
148
  }
123
149
  if (line[i] === '"' || line[i] === "'") {
124
150
  const q = line[i];
125
151
  let j = i + 1;
126
152
  while (j < line.length && line[j] !== q) { if (line[j] === '\\') j++; j++; }
127
- tokens.push({ text: line.slice(i, j + 1), fg: '#ce9178' });
153
+ tokens.push({ text: line.slice(i, j + 1), fg: FALLBACK_THEME.string });
128
154
  i = j + 1;
129
155
  continue;
130
156
  }
131
157
  if (/[0-9]/.test(line[i])) {
132
158
  let j = i;
133
159
  while (j < line.length && /[0-9._]/.test(line[j])) j++;
134
- tokens.push({ text: line.slice(i, j), fg: '#b5cea8' });
160
+ tokens.push({ text: line.slice(i, j), fg: FALLBACK_THEME.number });
135
161
  i = j;
136
162
  continue;
137
163
  }
@@ -140,11 +166,11 @@ function tokenizePython(line: string): SyntaxToken[] {
140
166
  while (j < line.length && /[\w]/.test(line[j])) j++;
141
167
  const word = line.slice(i, j);
142
168
  if (PYTHON_KEYWORDS.has(word)) {
143
- tokens.push({ text: word, fg: '#569cd6', bold: true });
169
+ tokens.push({ text: word, fg: FALLBACK_THEME.keyword, bold: true });
144
170
  } else if (/^[A-Z]/.test(word)) {
145
- tokens.push({ text: word, fg: '#4ec9b0' });
171
+ tokens.push({ text: word, fg: FALLBACK_THEME.type });
146
172
  } else if (line[j] === '(') {
147
- tokens.push({ text: word, fg: '#dcdcaa' });
173
+ tokens.push({ text: word, fg: FALLBACK_THEME.function });
148
174
  } else {
149
175
  tokens.push({ text: word, fg: '' });
150
176
  }
@@ -163,21 +189,21 @@ function tokenizeBash(line: string): SyntaxToken[] {
163
189
 
164
190
  while (i < line.length) {
165
191
  if (line[i] === '#') {
166
- tokens.push({ text: line.slice(i), fg: '65', italic: true });
192
+ tokens.push({ text: line.slice(i), fg: FALLBACK_THEME.comment, italic: true });
167
193
  break;
168
194
  }
169
195
  if (line[i] === '"' || line[i] === "'") {
170
196
  const q = line[i];
171
197
  let j = i + 1;
172
198
  while (j < line.length && line[j] !== q) { if (line[j] === '\\') j++; j++; }
173
- tokens.push({ text: line.slice(i, j + 1), fg: '#ce9178' });
199
+ tokens.push({ text: line.slice(i, j + 1), fg: FALLBACK_THEME.string });
174
200
  i = j + 1;
175
201
  continue;
176
202
  }
177
203
  if (line[i] === '$') {
178
204
  let j = i + 1;
179
205
  while (j < line.length && /[\w{}_]/.test(line[j])) j++;
180
- tokens.push({ text: line.slice(i, j), fg: '#9cdcfe' });
206
+ tokens.push({ text: line.slice(i, j), fg: FALLBACK_THEME.property });
181
207
  i = j;
182
208
  continue;
183
209
  }
@@ -186,7 +212,7 @@ function tokenizeBash(line: string): SyntaxToken[] {
186
212
  while (j < line.length && /[\w-]/.test(line[j])) j++;
187
213
  const word = line.slice(i, j);
188
214
  if (BASH_KEYWORDS.has(word)) {
189
- tokens.push({ text: word, fg: '#569cd6', bold: true });
215
+ tokens.push({ text: word, fg: FALLBACK_THEME.keyword, bold: true });
190
216
  } else {
191
217
  tokens.push({ text: word, fg: '' });
192
218
  }
@@ -211,9 +237,9 @@ function tokenizeJson(line: string): SyntaxToken[] {
211
237
  // JSON key: followed by :
212
238
  const rest = line.slice(j + 1).trimStart();
213
239
  if (rest.startsWith(':')) {
214
- tokens.push({ text: str, fg: '#9cdcfe' });
240
+ tokens.push({ text: str, fg: FALLBACK_THEME.property });
215
241
  } else {
216
- tokens.push({ text: str, fg: '#ce9178' });
242
+ tokens.push({ text: str, fg: FALLBACK_THEME.string });
217
243
  }
218
244
  i = j + 1;
219
245
  continue;
@@ -221,13 +247,13 @@ function tokenizeJson(line: string): SyntaxToken[] {
221
247
  if (/[0-9-]/.test(line[i])) {
222
248
  let j = i;
223
249
  while (j < line.length && /[0-9.eE+-]/.test(line[j])) j++;
224
- tokens.push({ text: line.slice(i, j), fg: '#b5cea8' });
250
+ tokens.push({ text: line.slice(i, j), fg: FALLBACK_THEME.number });
225
251
  i = j;
226
252
  continue;
227
253
  }
228
254
  const boolNull = ['true', 'false', 'null'].find(k => line.startsWith(k, i));
229
255
  if (boolNull) {
230
- tokens.push({ text: boolNull, fg: '#569cd6', bold: true });
256
+ tokens.push({ text: boolNull, fg: FALLBACK_THEME.keyword, bold: true });
231
257
  i += boolNull.length;
232
258
  continue;
233
259
  }
@@ -240,12 +266,12 @@ function tokenizeJson(line: string): SyntaxToken[] {
240
266
  function tokenizeYaml(line: string): SyntaxToken[] {
241
267
  const tokens: SyntaxToken[] = [];
242
268
  if (line.trimStart().startsWith('#')) {
243
- return [{ text: line, fg: '65', italic: true }];
269
+ return [{ text: line, fg: FALLBACK_THEME.comment, italic: true }];
244
270
  }
245
271
  const keyMatch = line.match(/^(\s*)([^:]+)(:)(\s*.*)/);
246
272
  if (keyMatch) {
247
273
  if (keyMatch[1]) tokens.push({ text: keyMatch[1], fg: '' });
248
- tokens.push({ text: keyMatch[2], fg: '#9cdcfe' });
274
+ tokens.push({ text: keyMatch[2], fg: FALLBACK_THEME.property });
249
275
  tokens.push({ text: keyMatch[3], fg: '244' });
250
276
  if (keyMatch[4]) {
251
277
  const val = keyMatch[4];
@@ -254,7 +280,7 @@ function tokenizeYaml(line: string): SyntaxToken[] {
254
280
  const isStr = /^['"]/.test(trimVal);
255
281
  const isBool = trimVal === 'true' || trimVal === 'false' || trimVal === 'null' || trimVal === 'yes' || trimVal === 'no';
256
282
  const isNum = /^-?[0-9]/.test(trimVal);
257
- const valFg = isStr ? '#ce9178' : isBool ? '#569cd6' : isNum ? '#b5cea8' : '';
283
+ const valFg = isStr ? FALLBACK_THEME.string : isBool ? FALLBACK_THEME.keyword : isNum ? FALLBACK_THEME.number : '';
258
284
  tokens.push({ text: val, fg: valFg });
259
285
  }
260
286
  return tokens;
@@ -292,7 +318,7 @@ export function renderCodeBlock(
292
318
  const showLineNumbers = opts.showLineNumbers ?? true;
293
319
  const lineNumW = showLineNumbers ? String(codeLines.length).length + 1 : 0; // e.g. "10 "
294
320
  const contentStartX = showLineNumbers ? leftMargin + lineNumW + 1 : leftMargin;
295
- const BG = '#0d0d0d';
321
+ const BG = FALLBACK_THEME.bg;
296
322
  const LINE_NUM_FG = '238';
297
323
  const effectiveWidth = width - LAYOUT.RIGHT_MARGIN;
298
324
 
@@ -320,7 +346,7 @@ export function renderCodeBlock(
320
346
  let hx = leftMargin;
321
347
  for (const ch of headerStr) {
322
348
  if (hx >= effectiveWidth) break;
323
- headerLine[hx] = createStyledCell(ch, { fg: '#1a1a1a', bg: '#4ec9b0', bold: true });
349
+ headerLine[hx] = createStyledCell(ch, { fg: '#1a1a1a', bg: FALLBACK_THEME.accent, bold: true });
324
350
  hx++;
325
351
  }
326
352
  lines.push(headerLine);
@@ -362,7 +388,11 @@ export function renderCodeBlock(
362
388
  continue;
363
389
  }
364
390
  line[cx] = createStyledCell(ch, { fg: token.fg, bg: BG, bold: token.bold, italic: token.italic });
365
- if (cw === 2 && cx + 1 < width) line[cx + 1] = { ...line[cx], char: '' };
391
+ // Bound the wide-glyph placeholder against the body's own right edge
392
+ // (effectiveWidth), not the full line width — otherwise a 2-column
393
+ // glyph landing on the last body column spills its placeholder cell
394
+ // into the reserved right-margin band the header/footer stop at.
395
+ if (cw === 2 && cx + 1 < effectiveWidth) line[cx + 1] = { ...line[cx], char: '' };
366
396
  cx += cw;
367
397
  }
368
398
  }
@@ -373,7 +403,7 @@ export function renderCodeBlock(
373
403
  // Footer line
374
404
  const footerLine = createEmptyLine(width);
375
405
  for (let fx = leftMargin; fx < effectiveWidth; fx++) {
376
- footerLine[fx] = createStyledCell(' ', { bg: '#0d0d0d' });
406
+ footerLine[fx] = createStyledCell(' ', { bg: BG });
377
407
  }
378
408
  lines.push(footerLine);
379
409
 
@@ -3,6 +3,7 @@ import { ModalFactory } from './modal-factory.ts';
3
3
  import type { ConversationManager } from '../core/conversation';
4
4
  import { getOverlayContentBudget, getOverlaySurfaceMetrics, getStableOverlayContentRows } from './overlay-viewport.ts';
5
5
  import { estimateTokens } from '@pellux/goodvibes-sdk/platform/core';
6
+ import { UI_TONES } from './ui-primitives.ts';
6
7
 
7
8
  // ─── ContextInspectorModal ────────────────────────────────────────────────────
8
9
 
@@ -84,7 +85,6 @@ export function renderContextInspector(
84
85
 
85
86
  const entries: MsgEntry[] = [];
86
87
  let totalTokens = 0;
87
- let largeCount = 0;
88
88
 
89
89
  for (const msg of messages) {
90
90
  const role = msg.role;
@@ -126,9 +126,6 @@ export function renderContextInspector(
126
126
  // ── Identify large consumers (>10%) ───────────────────────────────────────
127
127
 
128
128
  const largeThreshold = totalTokens * 0.10;
129
- for (const e of entries) {
130
- if (e.tokens > largeThreshold) largeCount++;
131
- }
132
129
 
133
130
  // ── Build sections ────────────────────────────────────────────────────────
134
131
 
@@ -148,7 +145,7 @@ export function renderContextInspector(
148
145
  sections.push({
149
146
  type: 'text',
150
147
  content: 'WARNING: context is 80%+ full. Run /compact to free space.',
151
- style: { fg: '#ff9900', bold: true },
148
+ style: { fg: UI_TONES.state.warn, bold: true },
152
149
  });
153
150
  }
154
151
 
@@ -181,7 +178,7 @@ export function renderContextInspector(
181
178
  sections.push({
182
179
  type: 'text',
183
180
  content: line,
184
- style: isLarge ? { fg: '#ffcc00', bold: true } : {},
181
+ style: isLarge ? { fg: UI_TONES.state.warn, bold: true } : {},
185
182
  });
186
183
  }
187
184
 
@@ -40,12 +40,21 @@ export function applyConversationOverlays(
40
40
  let next = viewport;
41
41
  const bottomDockInset = 1 + (input.searchManager.active || input.historySearch.active ? 1 : 0);
42
42
 
43
+ // Overlay posture rule: workspaces (onboarding, model, settings, mcp, help,
44
+ // shortcuts) claim the full viewport and are mutually exclusive with docked
45
+ // pickers/modals. Once a fullscreen workspace claims the screen, no docked
46
+ // overlay is drawn on top of it — enforcing a single visible overlay even if
47
+ // stray state survives the handler's clearModalStack. The input layer keeps
48
+ // the modal stack to one entry; this is the renderer-side backstop.
49
+ let fullscreenClaimed = false;
50
+
43
51
  if (input.onboardingWizard.active) {
44
52
  const lines = renderOnboardingWizard(input.onboardingWizard, conversationWidth, viewportHeight);
45
53
  next = replaceViewportWithOverlay(lines, conversationWidth, viewportHeight);
54
+ fullscreenClaimed = true;
46
55
  }
47
56
 
48
- if (input.filePicker.active) {
57
+ if (!fullscreenClaimed && input.filePicker.active) {
49
58
  const lines = renderFilePickerOverlay(input.filePicker, conversationWidth, viewportHeight);
50
59
  next = overlayViewportBottom(next, lines, conversationWidth, viewportHeight, bottomDockInset);
51
60
  }
@@ -53,9 +62,10 @@ export function applyConversationOverlays(
53
62
  if (input.modelPicker.active) {
54
63
  const lines = renderModelWorkspace(input.modelPicker, conversationWidth, viewportHeight);
55
64
  next = replaceViewportWithOverlay(lines, conversationWidth, viewportHeight);
65
+ fullscreenClaimed = true;
56
66
  }
57
67
 
58
- if (input.selectionModal.active) {
68
+ if (!fullscreenClaimed && input.selectionModal.active) {
59
69
  const lines = renderSelectionModalOverlay(input.selectionModal, conversationWidth, viewportHeight);
60
70
  next = overlayViewportBottom(next, lines, conversationWidth, viewportHeight, bottomDockInset);
61
71
  }
@@ -68,22 +78,22 @@ export function applyConversationOverlays(
68
78
  next.push(...renderHistorySearchOverlay(input.historySearch, conversationWidth));
69
79
  }
70
80
 
71
- if (input.processModal.active) {
81
+ if (!fullscreenClaimed && input.processModal.active) {
72
82
  const lines = renderProcessModal(input.processModal, conversationWidth, viewportHeight);
73
83
  next = overlayViewportBottom(next, lines, conversationWidth, viewportHeight, bottomDockInset);
74
84
  }
75
85
 
76
- if (input.agentDetailModal.active) {
77
- const lines = renderAgentDetailModal(input.agentDetailModal, conversationWidth);
86
+ if (!fullscreenClaimed && input.agentDetailModal.active) {
87
+ const lines = renderAgentDetailModal(input.agentDetailModal, conversationWidth, viewportHeight);
78
88
  next = overlayViewportBottom(next, lines, conversationWidth, viewportHeight, bottomDockInset);
79
89
  }
80
90
 
81
- if (input.liveTailModal.active) {
91
+ if (!fullscreenClaimed && input.liveTailModal.active) {
82
92
  const lines = renderLiveTailModal(input.liveTailModal, conversationWidth, viewportHeight);
83
93
  next = overlayViewportBottom(next, lines, conversationWidth, viewportHeight, bottomDockInset);
84
94
  }
85
95
 
86
- if (input.contextInspectorModal.active) {
96
+ if (!fullscreenClaimed && input.contextInspectorModal.active) {
87
97
  const lines = renderContextInspector(conversation, conversationWidth, viewportHeight, contextWindow);
88
98
  next = overlayViewportBottom(next, lines, conversationWidth, viewportHeight, bottomDockInset);
89
99
  }
@@ -91,24 +101,26 @@ export function applyConversationOverlays(
91
101
  if (input.settingsModal.active) {
92
102
  const lines = renderSettingsModal(input.settingsModal, conversationWidth, viewportHeight);
93
103
  next = replaceViewportWithOverlay(lines, conversationWidth, viewportHeight);
104
+ fullscreenClaimed = true;
94
105
  }
95
106
 
96
107
  if (input.mcpWorkspace.active) {
97
108
  const lines = renderMcpWorkspace(input.mcpWorkspace, conversationWidth, viewportHeight);
98
109
  next = replaceViewportWithOverlay(lines, conversationWidth, viewportHeight);
110
+ fullscreenClaimed = true;
99
111
  }
100
112
 
101
- if (input.sessionPickerModal.active) {
113
+ if (!fullscreenClaimed && input.sessionPickerModal.active) {
102
114
  const lines = renderSessionPickerModal(input.sessionPickerModal, conversationWidth, viewportHeight);
103
115
  next = overlayViewportBottom(next, lines, conversationWidth, viewportHeight, bottomDockInset);
104
116
  }
105
117
 
106
- if (input.profilePickerModal.active) {
118
+ if (!fullscreenClaimed && input.profilePickerModal.active) {
107
119
  const lines = renderProfilePickerModal(input.profilePickerModal, conversationWidth, viewportHeight);
108
120
  next = overlayViewportBottom(next, lines, conversationWidth, viewportHeight, bottomDockInset);
109
121
  }
110
122
 
111
- if (input.bookmarkModal.active) {
123
+ if (!fullscreenClaimed && input.bookmarkModal.active) {
112
124
  const lines = renderBookmarkModal(input.bookmarkModal, conversationWidth, viewportHeight);
113
125
  next = overlayViewportBottom(next, lines, conversationWidth, viewportHeight, bottomDockInset);
114
126
  }
@@ -116,14 +128,16 @@ export function applyConversationOverlays(
116
128
  if (input.helpOverlayActive) {
117
129
  const lines = renderHelpOverlay(conversationWidth, keybindingsManager, commandRegistry.getAll(), input.helpScrollOffset, viewportHeight);
118
130
  next = replaceViewportWithOverlay(lines, conversationWidth, viewportHeight);
131
+ fullscreenClaimed = true;
119
132
  }
120
133
 
121
134
  if (input.shortcutsOverlayActive) {
122
135
  const lines = renderShortcutsOverlay(conversationWidth, keybindingsManager, input.shortcutsScrollOffset, viewportHeight);
123
136
  next = replaceViewportWithOverlay(lines, conversationWidth, viewportHeight);
137
+ fullscreenClaimed = true;
124
138
  }
125
139
 
126
- if (input.commandMode && input.autocomplete?.isActive) {
140
+ if (!fullscreenClaimed && input.commandMode && input.autocomplete?.isActive) {
127
141
  const lines = renderAutocompleteOverlay(input.autocomplete, conversationWidth, viewportHeight);
128
142
  if (lines.length > 0) {
129
143
  next = overlayViewportBottom(next, lines, conversationWidth, viewportHeight, bottomDockInset);
@@ -1,5 +1,5 @@
1
1
  import { type Line, createEmptyLine, createStyledCell } from '../types/grid.ts';
2
- import { getDisplayWidth, truncateDisplay, wrapText } from '../utils/terminal-width.ts';
2
+ import { getDisplayWidth, wrapText } from '../utils/terminal-width.ts';
3
3
  import { LAYOUT } from './layout.ts';
4
4
  import { GLYPHS } from './ui-primitives.ts';
5
5
 
@@ -185,26 +185,6 @@ export function renderConversationCollapsedFragment(
185
185
  });
186
186
  }
187
187
 
188
- export function renderConversationKeyValueRow(
189
- width: number,
190
- left: string,
191
- right: string,
192
- palette: {
193
- readonly leftFg: string;
194
- readonly rightFg: string;
195
- readonly dimFg?: string;
196
- readonly bg?: string;
197
- },
198
- ): Line {
199
- const line = createEmptyLine(width);
200
- const leftText = truncateDisplay(left, Math.max(1, width - LAYOUT.RIGHT_MARGIN - 8));
201
- const rightWidth = getDisplayWidth(right);
202
- const rightStart = Math.max(LAYOUT.LEFT_MARGIN + 1, width - LAYOUT.RIGHT_MARGIN - rightWidth);
203
- writeText(line, LAYOUT.LEFT_MARGIN, rightStart - 1, leftText, palette.leftFg, { bg: palette.bg });
204
- writeText(line, rightStart, width - LAYOUT.RIGHT_MARGIN, right, palette.rightFg, { bg: palette.bg, dim: palette.dimFg === palette.rightFg });
205
- return line;
206
- }
207
-
208
188
  export function renderConversationStatusLine(
209
189
  width: number,
210
190
  segments: readonly ConversationStatusSegment[],
@@ -1,10 +1,12 @@
1
1
  import { type Line, type Cell, createStyledCell } from '../types/grid.ts';
2
2
  import { UIFactory } from './ui-factory.ts';
3
3
  import { getDisplayWidth, padDisplayEnd } from '../utils/terminal-width.ts';
4
+ import { DIFF_TONES } from './ui-primitives.ts';
4
5
 
5
6
  /**
6
7
  * renderDiffView - Render a unified diff string as styled Line[].
7
- * '+' lines in green, '-' lines in red, '@@' hunks in cyan.
8
+ * '+' lines in green, '-' lines in red, '@@' hunks in blue (DIFF_TONES.hunk,
9
+ * shared with diff-panel.ts and git-panel.ts's inline diff).
8
10
  */
9
11
  export function renderDiffView(diffText: string, width: number, filename?: string): Line[] {
10
12
  const lines: Line[] = [];
@@ -34,7 +36,7 @@ export function renderDiffView(diffText: string, width: number, filename?: strin
34
36
  oldLineNo = parseInt(hunkMatch[1], 10) - 1;
35
37
  newLineNo = parseInt(hunkMatch[2], 10) - 1;
36
38
  }
37
- lines.push(makeStyledLine(raw, width, '#00bcd4', '#0f1f1f', false));
39
+ lines.push(makeStyledLine(raw, width, DIFF_TONES.hunk, '#0f1f1f', false));
38
40
  continue;
39
41
  }
40
42
 
@@ -49,7 +51,7 @@ export function renderDiffView(diffText: string, width: number, filename?: strin
49
51
  newLineNo++;
50
52
  const lineLabel = `${String(newLineNo).padStart(4)} `;
51
53
  const content = raw.slice(1);
52
- lines.push(makeGutterLine('+', lineLabel, content, width, '#22c55e', '#0a1a0a'));
54
+ lines.push(makeGutterLine('+', lineLabel, content, width, DIFF_TONES.add, '#0a1a0a'));
53
55
  continue;
54
56
  }
55
57
 
@@ -58,7 +60,7 @@ export function renderDiffView(diffText: string, width: number, filename?: strin
58
60
  oldLineNo++;
59
61
  const lineLabel = `${String(oldLineNo).padStart(4)} `;
60
62
  const content = raw.slice(1);
61
- lines.push(makeGutterLine('-', lineLabel, content, width, '#ef4444', '#1a0a0a'));
63
+ lines.push(makeGutterLine('-', lineLabel, content, width, DIFF_TONES.del, '#1a0a0a'));
62
64
  continue;
63
65
  }
64
66
 
@@ -0,0 +1,41 @@
1
+ /**
2
+ * footer-tips.ts — the persistent discoverability hint shown in the shell
3
+ * footer's tip slot.
4
+ *
5
+ * WO-151: instead of a single frozen "/help for commands" line, the footer
6
+ * surfaces a rotating set of the highest-value affordances (panels, process
7
+ * monitor, help, quit). Rotation is *contextual, not timed*: when an agent turn
8
+ * is actively running, the process-monitor tip is promoted to the front so the
9
+ * operator can jump to F2 while work is in flight. Selection is a pure function
10
+ * of context so the footer renders deterministically (golden frames stay
11
+ * stable) — no wall-clock input.
12
+ */
13
+
14
+ export interface FooterTipContext {
15
+ /** True while an agent turn is actively running (streaming / tools / hooks). */
16
+ readonly agentActive: boolean;
17
+ }
18
+
19
+ /** Composer status labels that mean an agent turn is actively in flight. */
20
+ const ACTIVE_TURN_STATUSES = new Set(['preflight', 'streaming', 'tools', 'post-hooks']);
21
+
22
+ /** Derive agent-active state from the composer status label passed to the footer. */
23
+ export function isAgentActive(composerStatus: string | undefined): boolean {
24
+ return composerStatus !== undefined && ACTIVE_TURN_STATUSES.has(composerStatus);
25
+ }
26
+
27
+ const TIP_PANELS = 'Ctrl+P panels';
28
+ const TIP_PROCESSES = 'F2 processes';
29
+ const TIP_HELP = '? help';
30
+ const TIP_QUIT = 'Ctrl+C quit';
31
+
32
+ /**
33
+ * Build the footer discoverability tip. Segments join with the shared middle
34
+ * dot. Agent-active state promotes the process-monitor tip to the front.
35
+ */
36
+ export function buildFooterTip(ctx: FooterTipContext): string {
37
+ const lead = ctx.agentActive
38
+ ? [TIP_PROCESSES, TIP_PANELS, TIP_HELP]
39
+ : [TIP_PANELS, TIP_PROCESSES, TIP_HELP];
40
+ return [...lead, TIP_QUIT].join(' · ');
41
+ }