@pellux/goodvibes-tui 1.7.0 → 1.9.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 (137) hide show
  1. package/README.md +10 -9
  2. package/docs/foundation-artifacts/operator-contract.json +4045 -1763
  3. package/package.json +2 -2
  4. package/src/audio/spoken-turn-controller.ts +12 -2
  5. package/src/audio/spoken-turn-wiring.ts +2 -1
  6. package/src/cli/help.ts +8 -1
  7. package/src/cli/service-posture.ts +2 -1
  8. package/src/cli/status.ts +2 -1
  9. package/src/cli/surface-command.ts +2 -2
  10. package/src/core/composer-state.ts +11 -3
  11. package/src/core/conversation-line-cache.ts +27 -3
  12. package/src/core/conversation-rendering.ts +71 -14
  13. package/src/core/stream-event-wiring.ts +20 -1
  14. package/src/core/system-message-noise.ts +87 -0
  15. package/src/core/system-message-router.ts +68 -1
  16. package/src/core/turn-cancellation.ts +7 -2
  17. package/src/core/turn-event-wiring.ts +10 -2
  18. package/src/daemon/cli.ts +29 -2
  19. package/src/daemon/handlers/register.ts +8 -1
  20. package/src/daemon/service-commands.ts +329 -0
  21. package/src/input/autocomplete.ts +27 -1
  22. package/src/input/command-registry.ts +46 -4
  23. package/src/input/commands/codebase-runtime.ts +46 -6
  24. package/src/input/commands/config.ts +43 -3
  25. package/src/input/commands/health-runtime.ts +9 -1
  26. package/src/input/commands/memory.ts +68 -35
  27. package/src/input/commands/operator-panel-runtime.ts +31 -7
  28. package/src/input/commands/planning-runtime.ts +95 -6
  29. package/src/input/commands/qrcode-runtime.ts +25 -5
  30. package/src/input/commands/remote-runtime-setup.ts +5 -3
  31. package/src/input/commands/session-content.ts +20 -9
  32. package/src/input/commands/settings-sync-runtime.ts +15 -3
  33. package/src/input/commands/shell-core.ts +10 -1
  34. package/src/input/commands/workstream-runtime.ts +168 -18
  35. package/src/input/config-modal-types.ts +15 -1
  36. package/src/input/config-modal.ts +227 -12
  37. package/src/input/feed-context-factory.ts +3 -0
  38. package/src/input/handler-command-route.ts +10 -3
  39. package/src/input/handler-content-actions.ts +17 -2
  40. package/src/input/handler-feed-routes.ts +43 -121
  41. package/src/input/handler-feed.ts +32 -4
  42. package/src/input/handler-modal-routes.ts +78 -13
  43. package/src/input/handler-modal-stack.ts +11 -2
  44. package/src/input/handler-onboarding-daemon-adopt.ts +149 -0
  45. package/src/input/handler-onboarding.ts +71 -59
  46. package/src/input/handler-picker-routes.ts +15 -8
  47. package/src/input/handler-shortcuts.ts +59 -9
  48. package/src/input/handler.ts +6 -1
  49. package/src/input/keybindings.ts +6 -5
  50. package/src/input/model-picker.ts +19 -2
  51. package/src/input/onboarding/onboarding-runtime-status.ts +10 -1
  52. package/src/input/onboarding/onboarding-wizard-apply.ts +8 -1
  53. package/src/input/onboarding/onboarding-wizard-constants.ts +4 -1
  54. package/src/input/onboarding/onboarding-wizard-network-adopt.ts +136 -0
  55. package/src/input/onboarding/onboarding-wizard-steps.ts +9 -6
  56. package/src/input/onboarding/onboarding-wizard-types.ts +3 -1
  57. package/src/input/panel-mouse-geometry.ts +97 -0
  58. package/src/input/panel-paste-flood-guard.ts +86 -0
  59. package/src/input/selection-modal.ts +11 -0
  60. package/src/input/session-picker-modal.ts +44 -4
  61. package/src/input/settings-modal-data.ts +51 -2
  62. package/src/input/settings-modal-types.ts +4 -2
  63. package/src/main.ts +28 -27
  64. package/src/panels/builtin/shared.ts +9 -3
  65. package/src/panels/fleet-deep-link.ts +31 -0
  66. package/src/panels/fleet-panel-format.ts +62 -0
  67. package/src/panels/fleet-panel-worktree-detail.ts +48 -0
  68. package/src/panels/fleet-panel.ts +89 -131
  69. package/src/panels/fleet-read-model.ts +43 -0
  70. package/src/panels/fleet-steer.ts +35 -2
  71. package/src/panels/fleet-stop.ts +29 -1
  72. package/src/panels/modals/keybindings-modal.ts +16 -1
  73. package/src/panels/modals/modal-theme.ts +35 -29
  74. package/src/panels/modals/pairing-modal.ts +25 -6
  75. package/src/panels/modals/planning-modal.ts +43 -21
  76. package/src/panels/modals/work-plan-modal.ts +28 -0
  77. package/src/panels/panel-manager.ts +15 -4
  78. package/src/panels/polish-core.ts +38 -25
  79. package/src/panels/project-planning-answer-actions.ts +8 -13
  80. package/src/panels/types.ts +24 -0
  81. package/src/permissions/prompt.ts +160 -13
  82. package/src/renderer/autocomplete-overlay.ts +28 -2
  83. package/src/renderer/compositor.ts +2 -3
  84. package/src/renderer/config-modal.ts +19 -5
  85. package/src/renderer/footer-tips.ts +5 -1
  86. package/src/renderer/fullscreen-primitives.ts +32 -22
  87. package/src/renderer/git-status.ts +3 -1
  88. package/src/renderer/layout.ts +0 -4
  89. package/src/renderer/markdown.ts +7 -3
  90. package/src/renderer/modal-factory.ts +25 -20
  91. package/src/renderer/model-workspace.ts +18 -3
  92. package/src/renderer/overlay-box.ts +21 -17
  93. package/src/renderer/process-indicator.ts +14 -3
  94. package/src/renderer/selection-modal-overlay.ts +6 -1
  95. package/src/renderer/session-picker-modal.ts +196 -3
  96. package/src/renderer/settings-modal-helpers.ts +2 -0
  97. package/src/renderer/settings-modal.ts +7 -0
  98. package/src/renderer/shell-surface.ts +8 -1
  99. package/src/renderer/status-glyphs.ts +14 -15
  100. package/src/renderer/system-message.ts +15 -3
  101. package/src/renderer/terminal-bg-probe.ts +339 -0
  102. package/src/renderer/terminal-escapes.ts +20 -0
  103. package/src/renderer/theme-mode-config.ts +67 -0
  104. package/src/renderer/theme.ts +91 -1
  105. package/src/renderer/thinking.ts +11 -3
  106. package/src/renderer/tool-call.ts +15 -9
  107. package/src/renderer/tool-result-summary.ts +148 -0
  108. package/src/renderer/turn-injection.ts +22 -3
  109. package/src/renderer/ui-factory.ts +154 -85
  110. package/src/renderer/ui-primitives.ts +30 -129
  111. package/src/runtime/bootstrap-command-context.ts +6 -0
  112. package/src/runtime/bootstrap-command-parts.ts +8 -4
  113. package/src/runtime/bootstrap-core.ts +33 -11
  114. package/src/runtime/bootstrap-hook-bridge.ts +7 -0
  115. package/src/runtime/bootstrap-shell.ts +19 -1
  116. package/src/runtime/bootstrap.ts +118 -5
  117. package/src/runtime/code-index-services.ts +25 -2
  118. package/src/runtime/legacy-daemon-migration.ts +516 -0
  119. package/src/runtime/memory-fold.ts +26 -0
  120. package/src/runtime/onboarding/derivation.ts +7 -2
  121. package/src/runtime/onboarding/snapshot.ts +27 -1
  122. package/src/runtime/onboarding/types.ts +31 -1
  123. package/src/runtime/operator-token-cleanup.ts +82 -1
  124. package/src/runtime/orchestrator-core-services.ts +10 -0
  125. package/src/runtime/resume-notice.ts +209 -0
  126. package/src/runtime/services.ts +12 -8
  127. package/src/runtime/session-inbound-inputs.ts +252 -0
  128. package/src/runtime/session-spine-transport.ts +64 -0
  129. package/src/runtime/terminal-output-guard.ts +15 -8
  130. package/src/runtime/ui-services.ts +19 -3
  131. package/src/runtime/workstream-services.ts +160 -28
  132. package/src/runtime/wrfc-persistence.ts +124 -17
  133. package/src/shell/blocking-input.ts +46 -3
  134. package/src/shell/recovery-input-helpers.ts +170 -1
  135. package/src/shell/ui-openers.ts +42 -9
  136. package/src/utils/terminal-width.ts +52 -0
  137. package/src/version.ts +1 -1
@@ -0,0 +1,148 @@
1
+ /**
2
+ * summarizeToolResult — one-line human summaries for tool RESULTS, so the
3
+ * transcript (and the fleet attach view, which shares the same rendering seam
4
+ * in conversation-rendering.ts) shows "wrote haiku.txt (532 B)" instead of a
5
+ * raw `{"files_written":1,"bytes_written":532,...}` JSON blob. The full payload
6
+ * stays reachable behind the existing collapse/expand toggle — this only
7
+ * changes the collapsed preview line, never destroys data. (UX-B item 3.)
8
+ *
9
+ * Returning `null` means "no honest summary for this shape" — the caller then
10
+ * falls back to the previous raw-first-line preview, so an unrecognised tool or
11
+ * an unexpected payload is never mis-summarised.
12
+ */
13
+
14
+ /** Compact byte count: "87 B", "1.2 KB", "3.4 MB". */
15
+ function formatBytes(n: number): string {
16
+ if (!Number.isFinite(n) || n < 0) return '? B';
17
+ if (n < 1024) return `${n} B`;
18
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
19
+ return `${(n / (1024 * 1024)).toFixed(1)} MB`;
20
+ }
21
+
22
+ /** Compact duration: "820ms", "1.2s", "1m3s". */
23
+ function formatDuration(ms: number): string {
24
+ if (!Number.isFinite(ms) || ms < 0) return '?';
25
+ if (ms < 1000) return `${Math.round(ms)}ms`;
26
+ if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
27
+ const s = Math.round(ms / 1000);
28
+ return `${Math.floor(s / 60)}m${s % 60}s`;
29
+ }
30
+
31
+ /** Trailing filename for compact display (paths can be long/absolute). */
32
+ function baseName(p: string): string {
33
+ const cleaned = p.replace(/[/\\]+$/, '');
34
+ const idx = Math.max(cleaned.lastIndexOf('/'), cleaned.lastIndexOf('\\'));
35
+ return idx >= 0 ? cleaned.slice(idx + 1) : cleaned;
36
+ }
37
+
38
+ function asNumber(v: unknown): number | undefined {
39
+ return typeof v === 'number' && Number.isFinite(v) ? v : undefined;
40
+ }
41
+
42
+ function firstPath(files: unknown): string | undefined {
43
+ if (!Array.isArray(files) || files.length === 0) return undefined;
44
+ const first = files[0];
45
+ if (typeof first === 'string') return first;
46
+ if (first && typeof first === 'object') {
47
+ const rec = first as Record<string, unknown>;
48
+ if (typeof rec.path === 'string') return rec.path;
49
+ if (typeof rec.resolved_path === 'string') return rec.resolved_path;
50
+ }
51
+ return undefined;
52
+ }
53
+
54
+ /** write: { files_written, bytes_written, files?: [{path, bytes_written}], dry_run? } */
55
+ function summarizeWrite(obj: Record<string, unknown>): string | null {
56
+ const count = asNumber(obj.files_written);
57
+ const bytes = asNumber(obj.bytes_written);
58
+ if (count === undefined) return null;
59
+ const verb = obj.dry_run === true ? 'would write' : 'wrote';
60
+ const size = bytes !== undefined ? ` (${formatBytes(bytes)})` : '';
61
+ if (count === 1) {
62
+ const path = firstPath(obj.files);
63
+ return path ? `${verb} ${baseName(path)}${size}` : `${verb} 1 file${size}`;
64
+ }
65
+ return `${verb} ${count} files${size}`;
66
+ }
67
+
68
+ /** read: { summary: { files_read, total_lines }, files?: [{path, lineCount}] } */
69
+ function summarizeRead(obj: Record<string, unknown>): string | null {
70
+ const summary = (obj.summary && typeof obj.summary === 'object')
71
+ ? obj.summary as Record<string, unknown>
72
+ : obj;
73
+ const count = asNumber(summary.files_read);
74
+ const lines = asNumber(summary.total_lines);
75
+ if (count === undefined && lines === undefined) return null;
76
+ const lineText = lines !== undefined ? ` (${lines} line${lines === 1 ? '' : 's'})` : '';
77
+ if (count === 1) {
78
+ const path = firstPath(obj.files);
79
+ return path ? `read ${baseName(path)}${lineText}` : `read 1 file${lineText}`;
80
+ }
81
+ return `read ${count ?? '?'} files${lineText}`;
82
+ }
83
+
84
+ /** edit: { applied, failed, dry_run } or richer with a path. */
85
+ function summarizeEdit(obj: Record<string, unknown>): string | null {
86
+ const applied = asNumber(obj.applied);
87
+ if (applied === undefined) return null;
88
+ const failed = asNumber(obj.failed) ?? 0;
89
+ const verb = obj.dry_run === true ? 'would apply' : 'applied';
90
+ const path = typeof obj.path === 'string' ? obj.path : firstPath(obj.files);
91
+ const target = path ? ` to ${baseName(path)}` : '';
92
+ const failText = failed > 0 ? `, ${failed} failed` : '';
93
+ return `${verb} ${applied} edit${applied === 1 ? '' : 's'}${target}${failText}`;
94
+ }
95
+
96
+ /** A single exec command result. */
97
+ function summarizeExecOne(obj: Record<string, unknown>): string | null {
98
+ const exit = obj.exit_code;
99
+ const dur = asNumber(obj.duration_ms);
100
+ const durText = dur !== undefined ? ` · ${formatDuration(dur)}` : '';
101
+ if (obj.timed_out === true) return `timed out${durText}`;
102
+ if (obj.cancelled === true) return `cancelled${durText}`;
103
+ const stdout = typeof obj.stdout === 'string' ? obj.stdout : '';
104
+ const lineCount = stdout.length === 0
105
+ ? 0
106
+ : stdout.replace(/\n$/, '').split('\n').length;
107
+ const lineText = lineCount > 0 ? ` · ${lineCount} line${lineCount === 1 ? '' : 's'}` : '';
108
+ const code = typeof exit === 'number' ? String(exit) : '?';
109
+ return `exit ${code}${durText}${lineText}`;
110
+ }
111
+
112
+ /** exec: single { cmd, exit_code, ... } or multi { commands: [...], total }. */
113
+ function summarizeExec(obj: Record<string, unknown>): string | null {
114
+ if (Array.isArray(obj.commands)) {
115
+ const total = asNumber(obj.total) ?? obj.commands.length;
116
+ const failed = obj.commands.filter(
117
+ (c) => c && typeof c === 'object' && (c as Record<string, unknown>).success === false,
118
+ ).length;
119
+ return failed > 0
120
+ ? `${total} commands · ${failed} failed`
121
+ : `${total} commands · all ok`;
122
+ }
123
+ if ('exit_code' in obj || 'timed_out' in obj || 'cancelled' in obj) {
124
+ return summarizeExecOne(obj);
125
+ }
126
+ return null;
127
+ }
128
+
129
+ export function summarizeToolResult(toolName: string | undefined, content: string): string | null {
130
+ if (!toolName) return null;
131
+ const family = toolName.includes('__') ? toolName.split('__').pop()! : toolName;
132
+ let data: unknown;
133
+ try {
134
+ data = JSON.parse(content);
135
+ } catch {
136
+ return null;
137
+ }
138
+ if (data === null || typeof data !== 'object' || Array.isArray(data)) return null;
139
+ const obj = data as Record<string, unknown>;
140
+
141
+ switch (family) {
142
+ case 'write': return summarizeWrite(obj);
143
+ case 'read': return summarizeRead(obj);
144
+ case 'edit': return summarizeEdit(obj);
145
+ case 'exec': return summarizeExec(obj);
146
+ default: return null;
147
+ }
148
+ }
@@ -33,6 +33,24 @@ function fmtN(n: number): string {
33
33
  return n.toLocaleString();
34
34
  }
35
35
 
36
+ /**
37
+ * Annotate each injected id with its source (Stage B). Code-index hits get a
38
+ * ` [code]` tag; memory records are left bare so a memory-only line renders
39
+ * byte-identically to the pre-Stage-B output. `injectedSources` is parallel to
40
+ * `injectedIds`; a missing/short entry defaults to memory (no tag).
41
+ */
42
+ function labelInjectedIds(entry: TurnInjectionEntry): string {
43
+ const sources = entry.injectedSources ?? [];
44
+ return entry.injectedIds
45
+ .map((id, i) => (sources[i] === 'code-index' ? `${id} [code]` : id))
46
+ .join(', ');
47
+ }
48
+
49
+ /** Honest one-clause note when a wired code index was queried but injected nothing this turn. */
50
+ function codeSkipNote(entry: TurnInjectionEntry): string {
51
+ return entry.codeInjectionSkipped ? `, code skipped: ${entry.codeInjectionSkipped}` : '';
52
+ }
53
+
36
54
  /** Render a single TurnInjectionRecord as one readable line. */
37
55
  export function formatTurnInjectionEntry(entry: TurnInjectionEntry): string {
38
56
  const backendTag = entry.embeddingBackend === 'fallback-lexical' ? ' [lexical fallback]' : '';
@@ -42,7 +60,8 @@ export function formatTurnInjectionEntry(entry: TurnInjectionEntry): string {
42
60
  const reasonText = entry.reason === 'no records cleared relevance floor'
43
61
  ? 'nothing injected this turn — nothing cleared the relevance floor'
44
62
  : `nothing injected this turn — ${entry.reason ?? 'unknown reason'}`;
45
- return ` turn ${entry.turn}: ${reasonText}${backendTag} (considered ${entry.candidatesConsidered}, floor ${entry.relevanceFloor})`;
63
+ const codeConsidered = entry.codeCandidatesConsidered ? `, code considered ${entry.codeCandidatesConsidered}` : '';
64
+ return ` turn ${entry.turn}: ${reasonText}${backendTag} (considered ${entry.candidatesConsidered}${codeConsidered}, floor ${entry.relevanceFloor})${codeSkipNote(entry)}`;
46
65
  }
47
66
  const droppedStr = entry.droppedForBudget.length > 0
48
67
  ? `, dropped for budget: ${entry.droppedForBudget.join(', ')}`
@@ -52,8 +71,8 @@ export function formatTurnInjectionEntry(entry: TurnInjectionEntry): string {
52
71
  // (Wave-5 replay flagged the omission). Truncated to keep the line scannable.
53
72
  const queryStr = entry.query ? ` for ${JSON.stringify(truncateQuery(entry.query))}` : '';
54
73
  return (
55
- ` turn ${entry.turn}: injected ${entry.injectedIds.join(', ')}${queryStr} ` +
56
- `(~${fmtN(entry.tokenCost)}/${fmtN(entry.budgetTokens)} tok, floor ${entry.relevanceFloor})${droppedStr}${backendTag}`
74
+ ` turn ${entry.turn}: injected ${labelInjectedIds(entry)}${queryStr} ` +
75
+ `(~${fmtN(entry.tokenCost)}/${fmtN(entry.budgetTokens)} tok, floor ${entry.relevanceFloor})${droppedStr}${backendTag}${codeSkipNote(entry)}`
57
76
  );
58
77
  }
59
78
 
@@ -1,21 +1,21 @@
1
1
  import { type Line, type Cell, createEmptyLine, createEmptyCell } from '../types/grid.ts';
2
2
  import { LAYOUT } from './layout.ts';
3
3
  import { VERSION } from '../version.ts';
4
- import { fitDisplay, getDisplayWidth, truncateDisplay, wrapText, interpolateColor } from '../utils/terminal-width.ts';
4
+ import { fitDisplay, getDisplayWidth, truncateDisplay, wrapText, interpolateColor, joinPrioritizedSegments, type PrioritizedSegment } 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, UI_TONES } from './ui-primitives.ts';
7
+ import { GLYPHS } from './ui-primitives.ts';
8
+ import { activeUiTones } from './theme.ts';
8
9
  import { formatElapsed } from '../utils/format-elapsed.ts';
9
10
  import { abbreviateCount } from '../utils/format-number.ts';
10
11
  import { computeContextUsage } from '../core/context-usage.ts';
11
12
  import { calcSessionCost, isModelPriced } from '../export/cost-utils.ts';
12
13
  import { buildFooterTip, isAgentActive } from './footer-tips.ts';
13
14
  import type { StreamMetrics } from '../core/stream-event-wiring.ts';
15
+ import { waitingPhrase, type WaitingState } from '@pellux/goodvibes-sdk/platform/presentation';
14
16
 
15
17
  /** Number of frames before the animated gradient completes one full cycle. */
16
18
  const GRADIENT_CYCLE_FRAMES = 50;
17
- /** Number of frames before rotating to the next thinking phrase (~30 seconds at 80ms/frame). */
18
- const PHRASE_ROTATION_FRAMES = 375;
19
19
  /**
20
20
  * Ms since the last STREAM_DELTA before the whimsical phrase rotation freezes
21
21
  * and createThinkingFragment shows an honest "stalled Ns" / "reconnecting"
@@ -72,13 +72,21 @@ function fmtCost(usd: number): string {
72
72
  * UIFactory - Generates standard UI fragments without needing Ink/React overhead.
73
73
  */
74
74
  export class UIFactory {
75
- public static createHeader(width: number, model: string, provider: string, title?: string, gitInfo?: GitHeaderInfo): Line[] {
75
+ // `version` defaults to the live build VERSION; tests pass a pinned fixture so
76
+ // golden snapshots don't break on every release bump (version-decoupled goldens,
77
+ // mirroring the splash fixture-version pattern).
78
+ public static createHeader(width: number, model: string, provider: string, title?: string, gitInfo?: GitHeaderInfo, version: string = VERSION): Line[] {
76
79
  const lines: Line[] = [];
77
- const CYAN = UI_TONES.accent.brand;
78
- const GREY = UI_TONES.fg.dim;
79
- const TITLE_COLOR = UI_TONES.fg.muted;
80
+ // Header/footer/thinking paint on the transparent terminal background, so
81
+ // they read chrome.* (light-terminal-aware) — NOT fg.*/state.*, which stay
82
+ // tuned for the opaque dark modal/panel boxes. Read live per render so a
83
+ // mode flip re-resolves without any module reload (see theme.ts).
84
+ const t = activeUiTones();
85
+ const CYAN = t.accent.brand;
86
+ const GREY = t.chrome.faint;
87
+ const TITLE_COLOR = t.chrome.label;
80
88
  const brand = ` GoodVibes `;
81
- const ver = `v${VERSION} `;
89
+ const ver = `v${version} `;
82
90
  const stats = ` ${model} `;
83
91
  const prov = `(${provider}) `;
84
92
  const line = createEmptyLine(width);
@@ -110,11 +118,11 @@ export class UIFactory {
110
118
  }
111
119
  // Build git info segment
112
120
  let gitStr = '';
113
- let gitFg: string = UI_TONES.fg.dim;
121
+ let gitFg: string = t.chrome.faint;
114
122
  if (gitInfo) {
115
123
  gitStr = buildGitSegment(gitInfo).text;
116
124
  if (gitInfo.dirty || gitInfo.ahead > 0 || gitInfo.behind > 0) {
117
- gitFg = UI_TONES.state.warn; // yellow when dirty or out-of-sync
125
+ gitFg = t.chrome.warn; // yellow when dirty or out-of-sync
118
126
  }
119
127
  }
120
128
  const rightSideText = stats + prov;
@@ -124,7 +132,7 @@ export class UIFactory {
124
132
  for (const char of stats) { if (rightX < width) line[rightX++] = { char, fg: CYAN, bg: '', bold: true, dim: false, underline: false, italic: false, strikethrough: false }; }
125
133
  for (const char of prov) { if (rightX < width) line[rightX++] = { char, fg: GREY, bg: '', bold: false, dim: true, underline: false, italic: false, strikethrough: false }; }
126
134
  lines.push(line);
127
- lines.push(this.stringToLine('━'.repeat(width), width, { fg: UI_TONES.fg.dim }));
135
+ lines.push(this.stringToLine('━'.repeat(width), width, { fg: t.chrome.faint }));
128
136
  return lines;
129
137
  }
130
138
 
@@ -134,12 +142,16 @@ export class UIFactory {
134
142
  */
135
143
  public static createMessageBar(
136
144
  width: number, text: string,
137
- bgColor: string = '#2a2a2a', textColor: string = UI_TONES.fg.secondary, prefixStr: string = ' › ',
145
+ bgColor: string = '#2a2a2a', textColor: string = activeUiTones().fg.secondary, prefixStr: string = ' › ',
138
146
  strikethrough = false
139
147
  ): Line[] {
148
+ // A historical user-message pill: it carries its own dark bodyBg, so its fg
149
+ // reads fg.secondary/state.reasoning (light-on-dark) — this is conversation
150
+ // content, not the transparent-terminal chrome, so it is not part of the
151
+ // light-terminal chrome flip.
140
152
  return renderConversationFragment(text, width, {
141
153
  prefix: prefixStr,
142
- prefixFg: UI_TONES.state.reasoning,
154
+ prefixFg: activeUiTones().state.reasoning,
143
155
  text: textColor,
144
156
  bodyBg: bgColor,
145
157
  strikethrough,
@@ -150,10 +162,11 @@ export class UIFactory {
150
162
  * createQueuedMessageFragment - Renders a dimmed message bar for queued prompts.
151
163
  */
152
164
  public static createQueuedMessageFragment(width: number, text: string): Line[] {
165
+ const t = activeUiTones();
153
166
  return renderConversationFragment(text, width, {
154
167
  prefix: ' (...) ',
155
- prefixFg: UI_TONES.state.reasoning,
156
- text: UI_TONES.fg.dim,
168
+ prefixFg: t.state.reasoning,
169
+ text: t.fg.dim,
157
170
  bodyBg: '#1a1a1a',
158
171
  dim: true,
159
172
  });
@@ -162,7 +175,7 @@ export class UIFactory {
162
175
  public static createFooter(
163
176
  width: number,
164
177
  prompt: string,
165
- usage: { up: number; down: number; max?: number },
178
+ usage: { up: number; down: number; max?: number; fleetCostUsd?: number | null },
166
179
  showExitNotice: boolean,
167
180
  lastCopyTime: number,
168
181
  model?: string,
@@ -182,6 +195,9 @@ export class UIFactory {
182
195
  composerFlags?: readonly string[],
183
196
  composerPendingRisk?: 'none' | 'approval-wait' | 'shell' | 'command' | 'remote',
184
197
  compact: boolean = false,
198
+ // S3d: honest cross-surface spine posture. Defined ONLY in adopted-daemon
199
+ // mode (undefined in embedded/local), so the segment is absent otherwise.
200
+ sessionSpineStatus?: 'online' | 'offline',
185
201
  ): Line[] {
186
202
  const lines: Line[] = [];
187
203
  const promptLines = prompt.split('\n');
@@ -245,6 +261,21 @@ export class UIFactory {
245
261
  }
246
262
  }
247
263
 
264
+ // UX-C focus visibility (item 1c): when the panel workspace owns focus
265
+ // and the composer is empty, name the state and the way back — the
266
+ // dimmed prompt alone (TEXT_COLOR/BG_COLOR above) told you nothing was
267
+ // wrong, but not why keystrokes weren't landing here.
268
+ if (!promptFocused && text === '' && i === 0) {
269
+ const hintText = 'panel focused — Esc returns';
270
+ const hintStartX = boxStartX + 2 + prefix.length;
271
+ let hx = hintStartX;
272
+ for (const ch of hintText) {
273
+ if (hx >= boxStartX + boxWidth - 2) break;
274
+ contentLine[hx] = { char: ch, fg: '244', bg: BG_COLOR, bold: false, dim: true, underline: false, italic: false, strikethrough: false };
275
+ hx++;
276
+ }
277
+ }
278
+
248
279
  // Overlay args hint: dim grey text after cursor on the last prompt line.
249
280
  // Only shown when a commandArgsHint is provided and cursor is at the end of input.
250
281
  if (commandArgsHint && i === promptLines.length - 1) {
@@ -261,10 +292,17 @@ export class UIFactory {
261
292
  if (cursorColOnLine >= text.length) {
262
293
  // Hint starts one cell after the cursor block
263
294
  const hintStartX = boxStartX + 2 + prefix.length + text.length + 1;
264
- const hintText = ' ' + commandArgsHint;
295
+ const hintLimit = boxStartX + boxWidth - 2; // exclusive right bound
296
+ const avail = Math.max(0, hintLimit - hintStartX);
297
+ // Clamp to the available width with an ellipsis so a long arg-spec
298
+ // (e.g. /marketplace) ends in "…" rather than a hard mid-word cut. (5b)
299
+ let hintText = ' ' + commandArgsHint;
300
+ if (hintText.length > avail) {
301
+ hintText = avail > 1 ? `${hintText.slice(0, avail - 1)}…` : hintText.slice(0, Math.max(0, avail));
302
+ }
265
303
  let hx = hintStartX;
266
304
  for (const ch of hintText) {
267
- if (hx >= boxStartX + boxWidth - 2) break;
305
+ if (hx >= hintLimit) break;
268
306
  contentLine[hx] = { char: ch, fg: '238', bg: BG_COLOR, bold: false, dim: true, underline: false, italic: false, strikethrough: false };
269
307
  hx++;
270
308
  }
@@ -290,16 +328,19 @@ export class UIFactory {
290
328
  // --- Composer posture block (mode / risk / status / flags) ------------
291
329
  // Suppressed in compact mode; the ctx-info line no longer duplicates these
292
330
  // tokens, so this block is the single home for mode/status/flags.
331
+ // Footer chrome paints on the transparent terminal bg → chrome.* accents
332
+ // (light-terminal-aware); state.info reads on both light and dark terminals.
333
+ const t = activeUiTones();
293
334
  const composerTokens: Array<{ text: string; fg: string; bold?: boolean; dim?: boolean }> = [];
294
- if (composerMode) composerTokens.push({ text: ` ${GLYPHS.status.active} ${composerMode} `, fg: UI_TONES.state.info, bold: true });
335
+ if (composerMode) composerTokens.push({ text: ` ${GLYPHS.status.active} ${composerMode} `, fg: t.state.info, bold: true });
295
336
  if (composerPendingRisk && composerPendingRisk !== 'none') {
296
337
  const riskColor = composerPendingRisk === 'approval-wait'
297
- ? UI_TONES.state.warn
338
+ ? t.chrome.warn
298
339
  : composerPendingRisk === 'shell'
299
- ? UI_TONES.state.bad
340
+ ? t.chrome.bad
300
341
  : composerPendingRisk === 'remote'
301
- ? '#a78bfa'
302
- : UI_TONES.state.warn;
342
+ ? t.chrome.remote
343
+ : t.chrome.warn;
303
344
  composerTokens.push({ text: ` risk:${composerPendingRisk} `, fg: riskColor, bold: true });
304
345
  }
305
346
  if (composerStatus && composerStatus !== 'idle') composerTokens.push({ text: ` state:${composerStatus} `, fg: '244', dim: true });
@@ -329,7 +370,7 @@ export class UIFactory {
329
370
  const isRecentlyCopied = Date.now() - lastCopyTime < 2000;
330
371
  // Token usage line + running ~$ cost estimate (derived from the usage
331
372
  // object and the active model via cost-utils).
332
- const u = usage as { input?: number; output?: number; cacheRead?: number; cacheWrite?: number; up?: number; down?: number };
373
+ const u = usage as { input?: number; output?: number; cacheRead?: number; cacheWrite?: number; up?: number; down?: number; fleetCostUsd?: number | null };
333
374
  const inp = u.input ?? u.up ?? 0;
334
375
  const out = u.output ?? u.down ?? 0;
335
376
  const cr = u.cacheRead ?? 0;
@@ -340,12 +381,29 @@ export class UIFactory {
340
381
  // match the existing "no priceable data" convention used elsewhere
341
382
  // (cockpit-panel formatCost, agent-inspector-shared) — the footer has no
342
383
  // room for a longer marker before truncation kicks in.
343
- const costSegment = model
384
+ const mainCostStr = model
344
385
  ? isModelPriced(model)
345
- ? `${tokenSep}~$${fmtCost(calcSessionCost(inp, out, cr, cw, model))}`
346
- : `${tokenSep}~n/a`
347
- : '';
348
- const tokenLine = ` Token Usage [ Input: ${fmtNum(inp)}${tokenSep}Output: ${fmtNum(out)}${tokenSep}Cache Read: ${fmtNum(cr)}${tokenSep}Cache Write: ${fmtNum(cw)}${tokenSep}Total: ${fmtNum(total)}${costSegment} ]`;
386
+ ? `~$${fmtCost(calcSessionCost(inp, out, cr, cw, model))}`
387
+ : '~n/a'
388
+ : null;
389
+ // Honest total = your main session + the delegated fleet. We show a SPLIT
390
+ // ("you ~$X · fleet ~$Y") rather than one summed figure: the two costs are
391
+ // tracked and attributed to different actors, and the split directly answers
392
+ // "where did the money go" — the exact confusion in the eval where the footer
393
+ // showed only the main session (~$0.046) while the fleet cost ~10x more
394
+ // ($0.446). The fleet segment appears only when there is a real fleet cost, so
395
+ // the idle single-session footer is unchanged.
396
+ const fleetCost = u.fleetCostUsd;
397
+ const hasFleetCost = typeof fleetCost === 'number' && fleetCost > 0;
398
+ const costSegment = hasFleetCost
399
+ ? `${tokenSep}you ${mainCostStr ?? '~n/a'} · fleet ~$${fmtCost(fleetCost)}`
400
+ : mainCostStr
401
+ ? `${tokenSep}${mainCostStr}`
402
+ : '';
403
+ // Input tokens are unknown until the first assistant usage lands (often the
404
+ // first tool turn). Show "—" rather than a false 0 during that window. (5c)
405
+ const inpDisplay = inp > 0 ? fmtNum(inp) : '—';
406
+ const tokenLine = ` Token Usage [ Input: ${inpDisplay}${tokenSep}Output: ${fmtNum(out)}${tokenSep}Cache Read: ${fmtNum(cr)}${tokenSep}Cache Write: ${fmtNum(cw)}${tokenSep}Total: ${fmtNum(total)}${costSegment} ]`;
349
407
  const copiedNotice = isRecentlyCopied ? ` [COPIED] ` : '';
350
408
  const statsLine = ' ' + tokenLine + ' '.repeat(Math.max(0, width - 4 - getDisplayWidth(tokenLine) - getDisplayWidth(copiedNotice))) + copiedNotice;
351
409
  lines.push(this.stringToLine(statsLine, width, { fg: isRecentlyCopied ? '81' : '244', bold: isRecentlyCopied }));
@@ -353,7 +411,9 @@ export class UIFactory {
353
411
  if (!compact && contextWindow && contextWindow > 0) {
354
412
  const ctxTokens = lastInputTokens ?? 0;
355
413
  const label = ' Context Usage: ';
356
- const suffix = ` [ ${fmtNum(ctxTokens)} / ${fmtNum(contextWindow)} ]`;
414
+ // "—" until the first input-token count is known, not a false 0. (5c)
415
+ const ctxDisplay = ctxTokens > 0 ? fmtNum(ctxTokens) : '—';
416
+ const suffix = ` [ ${ctxDisplay} / ${fmtNum(contextWindow)} ]`;
357
417
  const barWidth = Math.max(10, Math.min(30, width - getDisplayWidth(label) - getDisplayWidth(suffix) - 8));
358
418
  const ctxPct = computeContextUsage(ctxTokens, contextWindow).clampedRatio;
359
419
  // Clamp threshold to [0..1]; undefined/0 means no threshold marker.
@@ -370,19 +430,30 @@ export class UIFactory {
370
430
  const displayDir = workingDir && home && workingDir.startsWith(home)
371
431
  ? '~' + workingDir.slice(home.length)
372
432
  : workingDir ?? '';
373
- const ctxParts: string[] = [];
374
- if (displayDir) ctxParts.push(displayDir);
433
+ // Segments carry a survival priority (lower = survives longer) so that
434
+ // under width pressure whole low-value segments are dropped before any
435
+ // segment is character-truncated mid-word. cwd/model are the essential
436
+ // orientation segments (priority 0); the spine liveness marker is the
437
+ // daemon-honesty signal and must outlive the two decorative segments
438
+ // (tool count, notify mode) — it is ordered ahead of them too, so a
439
+ // narrow line drops "N tools"/"notify:x" whole before spine is at risk.
440
+ const ctxParts: PrioritizedSegment[] = [];
441
+ if (displayDir) ctxParts.push({ text: displayDir, priority: 0 });
375
442
  if (model) {
376
- ctxParts.push(model + (provider ? ` (${provider})` : ''));
443
+ ctxParts.push({ text: model + (provider ? ` (${provider})` : ''), priority: 0 });
377
444
  }
378
- if (toolCount) ctxParts.push(`${toolCount} tools`);
445
+ // S3d: cross-surface spine posture — plain words, no blame. Adopted mode only.
446
+ if (sessionSpineStatus) ctxParts.push({ text: `spine:${sessionSpineStatus}`, priority: 1 });
447
+ if (toolCount) ctxParts.push({ text: `${toolCount} tools`, priority: 2 });
379
448
  // Labeled "notify" (not "hitl") — /mode (aliased /hitl) governs UX
380
449
  // notification verbosity (quiet/balanced/operator), not tool
381
450
  // auto-approval, so it must not share vocabulary with the DANGER MODE
382
- // risk banner rendered a few lines below.
383
- if (hitlMode) ctxParts.push(`notify:${hitlMode}`);
384
- const ctxLine = ' ' + ctxParts.join(` ${GLYPHS.navigation.pipeSeparator} `);
385
- lines.push(this.stringToLine(truncateDisplay(ctxLine, width), width, { fg: '240', dim: true }));
451
+ // risk banner rendered a few lines below. Lowest priority: dropped first.
452
+ if (hitlMode) ctxParts.push({ text: `notify:${hitlMode}`, priority: 3 });
453
+ const sep = ` ${GLYPHS.navigation.pipeSeparator} `;
454
+ const ctxBody = joinPrioritizedSegments(ctxParts, sep, width - 3);
455
+ const ctxLine = ' ' + ctxBody;
456
+ lines.push(this.stringToLine(ctxLine, width, { fg: '240', dim: true }));
386
457
  }
387
458
  if (showExitNotice) {
388
459
  const notice = ` !!! Press Ctrl+C again to exit !!! `;
@@ -403,7 +474,7 @@ export class UIFactory {
403
474
  for (const ch of dangerWarn) {
404
475
  if (col >= width) break;
405
476
  const cw = getDisplayWidth(ch);
406
- line[col] = { char: ch, fg: UI_TONES.state.bad, bg: '', bold: true, dim: false, underline: false, italic: false, strikethrough: false };
477
+ line[col] = { char: ch, fg: t.chrome.bad, bg: '', bold: true, dim: false, underline: false, italic: false, strikethrough: false };
407
478
  if (cw === 2 && col + 1 < width) line[col + 1] = { ...line[col], char: '' };
408
479
  col += cw;
409
480
  }
@@ -413,33 +484,20 @@ export class UIFactory {
413
484
  return lines;
414
485
  }
415
486
 
416
- /** Rotating thinking phrases vaporwave / good vibes themed. */
417
- private static readonly THINKING_PHRASES = [
418
- 'Thinking...',
419
- 'Vibing...',
420
- 'Manifesting...',
421
- 'Channeling energy...',
422
- 'Tuning frequencies...',
423
- 'Riding the wave...',
424
- 'Aligning chakras...',
425
- 'Entering flow state...',
426
- 'Consulting the void...',
427
- 'Absorbing aesthetics...',
428
- 'Synthesizing vibes...',
429
- 'Transcending...',
430
- 'Dreaming in neon...',
431
- 'Parsing the cosmos...',
432
- 'Loading good vibes...',
433
- 'Meditating...',
434
- 'Catching a vibe...',
435
- 'Harmonizing...',
436
- 'Feeling it...',
437
- 'In the zone...',
438
- ];
487
+ // W6-P1: the rotating "thinking" phrase pool and the honest waiting-state
488
+ // wording (approval/reconnecting/pre-first-token/stalled/thinking) are no
489
+ // longer minted here. They come from the SDK presentation contract's
490
+ // waitingPhrase() (@pellux/goodvibes-sdk/platform/presentation, landed by
491
+ // W4-S1, already adopted by the agent in W4-R4) — see createThinkingFragment
492
+ // below. This renderer still decides WHICH state applies from its own
493
+ // stall/reconnect/approval signals (computeStallInfo/computeRenderStallInfo
494
+ // stay renderer-local per the extraction decision record); only the exact
495
+ // wording is shared.
439
496
 
440
- /** Gradient colors for thinking text — cyan to purple (matches splash). */
441
- private static readonly THINK_GRADIENT_START = UI_TONES.accent.gradientStart;
442
- private static readonly THINK_GRADIENT_END = UI_TONES.accent.gradientEnd;
497
+ // Gradient colors for thinking text — cyan→purple in dark, teal→purple in
498
+ // light (matches splash/brand). Read live per render inside
499
+ // createThinkingFragment via activeUiTones() rather than baked into a static
500
+ // field, so a mode flip re-resolves without a module reload.
443
501
 
444
502
  /**
445
503
  * Per-frame stall info from stream metrics — computed from lastDeltaAtMs every render (not
@@ -478,25 +536,36 @@ export class UIFactory {
478
536
  : undefined;
479
537
  }
480
538
 
481
- public static createThinkingFragment(width: number, spinner: string, frame: number = 0, tokenSpeed?: number, toolPreview?: string, inputTokens?: number, outputTokens?: number, elapsedMs?: number, ttftMs?: number, stallInfo?: ThinkingStallInfo): Line[] {
539
+ public static createThinkingFragment(width: number, spinner: string, frame: number = 0, tokenSpeed?: number, toolPreview?: string, inputTokens?: number, outputTokens?: number, elapsedMs?: number, ttftMs?: number, stallInfo?: ThinkingStallInfo, approvalPending?: boolean): Line[] {
540
+ // Live thinking row paints on the transparent terminal bg → read the
541
+ // mode-resolved chrome tones per render (gradient/brand/tool accents flip).
542
+ const tones = activeUiTones();
482
543
  // Freeze the whimsical phrase rotation once real silence has gone on
483
- // long enough to be misleading (THINKING_STALL_FREEZE_MS), and show an
484
- // honest label instead: the SDK's reconnect attempt/maxAttempts once
485
- // STREAM_RETRY is available, else a plain elapsed-silence readout.
544
+ // long enough to be misleading (THINKING_STALL_FREEZE_MS). Decide WHICH
545
+ // honest waiting state applies (renderer-local this signal computation
546
+ // stays here per the extraction decision record), then defer the exact
547
+ // wording to the SDK presentation contract's waitingPhrase() (W6-P1;
548
+ // mirrors the agent's W4-R4 adoption). Precedence matches the contract:
549
+ // approval > reconnecting > pre-first-token > stalled > thinking.
486
550
  const isStalled = stallInfo !== undefined && stallInfo.msSinceLastDelta >= THINKING_STALL_FREEZE_MS;
487
- let phrase: string;
488
- if (stallInfo?.reconnect) {
489
- phrase = `Reconnecting (attempt ${stallInfo.reconnect.attempt}/${stallInfo.reconnect.maxAttempts})...`;
490
- } else if (isStalled) {
491
- phrase = `Stalled ${Math.floor(stallInfo.msSinceLastDelta / 1000)}s...`;
492
- } else {
493
- // Rotate phrase every ~30 seconds (frame ticks at 80ms, so ~375 frames)
494
- const phraseIndex = Math.floor(frame / PHRASE_ROTATION_FRAMES) % this.THINKING_PHRASES.length;
495
- phrase = this.THINKING_PHRASES[phraseIndex];
496
- }
497
- const speedSuffix = (tokenSpeed !== undefined && tokenSpeed > 0) ? ` (${Math.round(tokenSpeed)} tok/s)` : '';
551
+ let state: WaitingState;
552
+ if (approvalPending) state = 'approval';
553
+ else if (stallInfo?.reconnect) state = 'reconnecting';
554
+ else if (isStalled && (outputTokens ?? 0) === 0) state = 'pre-first-token';
555
+ else if (isStalled) state = 'stalled';
556
+ else state = 'thinking';
557
+ const phrase = waitingPhrase(state, {
558
+ reconnectAttempt: stallInfo?.reconnect?.attempt,
559
+ reconnectMaxAttempts: stallInfo?.reconnect?.maxAttempts,
560
+ msSinceLastDelta: stallInfo?.msSinceLastDelta,
561
+ frame,
562
+ });
563
+ // Token-rate and time-to-first-token readouts are meaningless while waiting on the user, and a
564
+ // "tok/s" figure next to an approval prompt reads as if the model were still working — suppress
565
+ // them; keep the elapsed timer since "how long the approval has waited" is honest.
566
+ const speedSuffix = (!approvalPending && tokenSpeed !== undefined && tokenSpeed > 0) ? ` (${Math.round(tokenSpeed)} tok/s)` : '';
498
567
  const elapsedSuffix = elapsedMs !== undefined ? ` (${formatElapsed(elapsedMs)})` : '';
499
- const ttftSuffix = (ttftMs !== undefined && ttftMs > 0) ? ` ttft:${ttftMs}ms` : '';
568
+ const ttftSuffix = (!approvalPending && ttftMs !== undefined && ttftMs > 0) ? ` ttft:${ttftMs}ms` : '';
500
569
  const text = ` ${spinner} ${phrase}${speedSuffix}${elapsedSuffix}${ttftSuffix} `;
501
570
 
502
571
  const textWidth = Math.max(1, getDisplayWidth(text) - 1);
@@ -506,7 +575,7 @@ export class UIFactory {
506
575
  const gradientPos = raw <= 0.5 ? raw * 2 : (1 - raw) * 2;
507
576
  return {
508
577
  text: char,
509
- fg: interpolateColor(this.THINK_GRADIENT_START, this.THINK_GRADIENT_END, gradientPos),
578
+ fg: interpolateColor(tones.accent.gradientStart, tones.accent.gradientEnd, gradientPos),
510
579
  bold: true,
511
580
  };
512
581
  });
@@ -514,7 +583,7 @@ export class UIFactory {
514
583
  const inTok = inputTokens ?? 0;
515
584
  const outTok = outputTokens ?? 0;
516
585
  segments.push({ text: ` in ${fmtNum(inTok)} `, fg: '243', dim: true });
517
- segments.push({ text: `out ${fmtNum(outTok)}`, fg: UI_TONES.accent.brand });
586
+ segments.push({ text: `out ${fmtNum(outTok)}`, fg: tones.accent.brand });
518
587
  }
519
588
  const line = createEmptyLine(width);
520
589
  let col = 1;
@@ -554,7 +623,7 @@ export class UIFactory {
554
623
  if (px >= width) break;
555
624
  previewLine[px] = {
556
625
  char: ch,
557
- fg: UI_TONES.state.info,
626
+ fg: tones.state.info,
558
627
  bg: '',
559
628
  bold: true,
560
629
  dim: false,