@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,339 @@
1
+ /**
2
+ * terminal-bg-probe — OSC 11 terminal-background detection for `auto` theme mode.
3
+ *
4
+ * DEBT-2 / F5. On startup, when appearance is `auto` and stdout is a TTY, we ask
5
+ * the terminal for its background colour with an OSC 11 query and classify the
6
+ * reply as light or dark. Everything about this is best-effort and conservative:
7
+ * any timeout, any unparseable reply, any ambiguity resolves to DARK, which is
8
+ * the historical default and keeps headless/unsupported terminals byte-stable.
9
+ *
10
+ * Timing (Option B — render dark first, repaint once if light wins):
11
+ * The probe never blocks startup. The splash renders immediately in dark; if a
12
+ * light reply arrives within the timeout window the caller flips the active mode
13
+ * to light and repaints ONCE. Rationale vs. blocking the first paint until the
14
+ * reply/timeout: blocking would tax EVERY non-responding terminal (tmux without
15
+ * passthrough, CI, old SSH hosts, dumb terminals) with up to `timeoutMs` of blank
16
+ * screen on every launch — the common failure case. Option B adds zero startup
17
+ * latency and costs at most a single dark→light repaint on genuinely light
18
+ * terminals (rare for this audience, one frame).
19
+ *
20
+ * Stream safety (never corrupt the composer):
21
+ * The reply arrives on the same stdin the keyboard uses. filterInput() sits at
22
+ * the very front of the stdin handler (before blocking-input / paste / the
23
+ * tokenizer). It passes every non-OSC-11 byte straight through — interleaved
24
+ * keystrokes reach the tokenizer untouched — and consumes ONLY a matched OSC 11
25
+ * reply (whole or split across chunks). On timeout, any buffered reply fragment
26
+ * is discarded rather than flushed, so partial/garbled response bytes can never
27
+ * leak into the input pipeline.
28
+ *
29
+ * tmux:
30
+ * When $TMUX is set the query is wrapped in the tmux DCS passthrough envelope so
31
+ * the outer terminal sees it. This only elicits a reply if the user has
32
+ * `allow-passthrough` enabled; otherwise the query is swallowed and the timeout
33
+ * fallback (dark) covers it silently.
34
+ */
35
+
36
+ import { setActiveThemeMode, type ThemeMode } from './theme.ts';
37
+ import { resolveConfiguredThemeMode } from './theme-mode-config.ts';
38
+ import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
39
+
40
+ /** OSC 11 "query background colour" request, ST-terminated. */
41
+ export const OSC11_QUERY = '\x1b]11;?\x1b\\';
42
+
43
+ /** The response prefix we scan for: ESC ] 1 1 ; */
44
+ const OSC11_PREFIX = '\x1b]11;';
45
+
46
+ /** Default probe window. Cooperating terminals reply in single-digit ms. */
47
+ export const DEFAULT_PROBE_TIMEOUT_MS = 150;
48
+
49
+ /** Luminance split point (normalized 0..1). At/above → light; below → dark. */
50
+ export const LUMINANCE_LIGHT_THRESHOLD = 0.5;
51
+
52
+ /** An 8-bit-per-channel colour parsed from an OSC 11 reply. */
53
+ export interface ProbeRgb {
54
+ readonly r: number;
55
+ readonly g: number;
56
+ readonly b: number;
57
+ }
58
+
59
+ // ---------------------------------------------------------------------------
60
+ // Pure parsing / classification (exercised directly by the fake-terminal harness)
61
+ // ---------------------------------------------------------------------------
62
+
63
+ /**
64
+ * Parse a single hex channel of `len` digits into an 8-bit value.
65
+ * Scales by the channel's own full-scale (16^len - 1) so `ffff`→255, `ff`→255,
66
+ * `80`→128, `8000`→128. Returns null on non-hex input.
67
+ */
68
+ function parseHexChannel(hex: string): number | null {
69
+ if (hex.length === 0 || !/^[0-9a-fA-F]+$/.test(hex)) return null;
70
+ const max = 16 ** hex.length - 1;
71
+ return Math.round((parseInt(hex, 16) / max) * 255);
72
+ }
73
+
74
+ /**
75
+ * Parse an OSC 11 colour spec into 8-bit RGB. Accepts:
76
+ * - `rgb:RRRR/GGGG/BBBB` and `rgb:RR/GG/BB` (1–4 hex digits per channel)
77
+ * - `rgba:R/G/B/A` (alpha ignored)
78
+ * - `#RRGGBB` and `#RRRRGGGGBBBB`
79
+ * Returns null for anything else (→ caller treats as dark).
80
+ */
81
+ export function parseColorSpec(spec: string): ProbeRgb | null {
82
+ const trimmed = spec.trim();
83
+
84
+ if (trimmed.startsWith('rgb:') || trimmed.startsWith('rgba:')) {
85
+ const body = trimmed.slice(trimmed.indexOf(':') + 1);
86
+ const parts = body.split('/');
87
+ if (parts.length < 3) return null;
88
+ const r = parseHexChannel(parts[0]!);
89
+ const g = parseHexChannel(parts[1]!);
90
+ const b = parseHexChannel(parts[2]!);
91
+ if (r === null || g === null || b === null) return null;
92
+ return { r, g, b };
93
+ }
94
+
95
+ if (trimmed.startsWith('#')) {
96
+ const hex = trimmed.slice(1);
97
+ if (hex.length !== 6 && hex.length !== 12) return null;
98
+ if (!/^[0-9a-fA-F]+$/.test(hex)) return null;
99
+ const per = hex.length / 3;
100
+ const r = parseHexChannel(hex.slice(0, per));
101
+ const g = parseHexChannel(hex.slice(per, per * 2));
102
+ const b = parseHexChannel(hex.slice(per * 2));
103
+ if (r === null || g === null || b === null) return null;
104
+ return { r, g, b };
105
+ }
106
+
107
+ return null;
108
+ }
109
+
110
+ /**
111
+ * Classify a background colour as 'light' or 'dark' by relative luminance.
112
+ * Uses the Rec. 601 luma weights (0.299/0.587/0.114) normalized to 0..1 and
113
+ * splits at LUMINANCE_LIGHT_THRESHOLD (0.5): a background at least half as
114
+ * luminous as white reads as light. Conservative by construction — the exact
115
+ * threshold only matters for mid-grey backgrounds, which are rare.
116
+ */
117
+ export function classifyBackgroundLuminance(rgb: ProbeRgb): ThemeMode {
118
+ const luma = (0.299 * rgb.r + 0.587 * rgb.g + 0.114 * rgb.b) / 255;
119
+ return luma >= LUMINANCE_LIGHT_THRESHOLD ? 'light' : 'dark';
120
+ }
121
+
122
+ /** Where a terminator starts and where the byte after it is, within a buffer. */
123
+ interface TerminatorSpan {
124
+ readonly start: number;
125
+ readonly end: number;
126
+ }
127
+
128
+ /**
129
+ * Find the OSC string terminator at/after `from`: BEL (\x07) or ST (ESC \).
130
+ * Returns null if no terminator is present yet (reply still arriving). A lone
131
+ * trailing ESC returns null (wait for the next byte to tell ST from a stray ESC);
132
+ * an ESC followed by a non-'\' byte is treated as the end boundary (malformed
133
+ * terminator — the body before it is still parsed, the stray byte passes through).
134
+ */
135
+ function findTerminator(buffer: string, from: number): TerminatorSpan | null {
136
+ for (let k = from; k < buffer.length; k++) {
137
+ const c = buffer[k];
138
+ if (c === '\x07') return { start: k, end: k + 1 };
139
+ if (c === '\x1b') {
140
+ if (k + 1 >= buffer.length) return null;
141
+ return buffer[k + 1] === '\\' ? { start: k, end: k + 2 } : { start: k, end: k + 1 };
142
+ }
143
+ }
144
+ return null;
145
+ }
146
+
147
+ /**
148
+ * Longest suffix of `buffer` that is a PROPER prefix of OSC11_PREFIX, used to
149
+ * hold back a partial reply-prefix split across chunk boundaries (e.g. a chunk
150
+ * ending in a bare ESC that might become `\x1b]11;`). Only consulted when the
151
+ * full prefix is not present in the buffer.
152
+ */
153
+ function trailingPrefixLen(buffer: string): number {
154
+ const maxLen = Math.min(OSC11_PREFIX.length - 1, buffer.length);
155
+ for (let len = maxLen; len >= 1; len--) {
156
+ if (buffer.endsWith(OSC11_PREFIX.slice(0, len))) return len;
157
+ }
158
+ return 0;
159
+ }
160
+
161
+ // ---------------------------------------------------------------------------
162
+ // TerminalBackgroundProbe — the stateful stream filter + timing
163
+ // ---------------------------------------------------------------------------
164
+
165
+ export interface ProbeResolution {
166
+ readonly mode: ThemeMode;
167
+ readonly reason: 'light-reply' | 'dark-reply' | 'unparseable' | 'timeout';
168
+ }
169
+
170
+ export interface TerminalBackgroundProbeOptions {
171
+ /** Called exactly once with the resolved mode + reason. */
172
+ readonly onResolve: (result: ProbeResolution) => void;
173
+ /** Probe window in ms (default DEFAULT_PROBE_TIMEOUT_MS). */
174
+ readonly timeoutMs?: number;
175
+ }
176
+
177
+ /**
178
+ * Stateful OSC 11 reply filter. feed() takes a raw stdin chunk and returns the
179
+ * bytes that should continue down the input pipeline (everything except a matched
180
+ * OSC 11 reply). Resolves once — on the first complete reply or on timeout.
181
+ */
182
+ export class TerminalBackgroundProbe {
183
+ /** True until resolved. filterInput() only calls feed() while active. */
184
+ public active = true;
185
+
186
+ private buffer = '';
187
+ private resolved = false;
188
+ private timer: ReturnType<typeof setTimeout> | null = null;
189
+ private readonly onResolve: (result: ProbeResolution) => void;
190
+ private readonly timeoutMs: number;
191
+
192
+ constructor(options: TerminalBackgroundProbeOptions) {
193
+ this.onResolve = options.onResolve;
194
+ this.timeoutMs = options.timeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS;
195
+ }
196
+
197
+ /** Arm the timeout fallback. Call after writing the query. */
198
+ startTimeout(): void {
199
+ this.timer = setTimeout(() => this.resolve('dark', 'timeout'), this.timeoutMs);
200
+ // Never keep the event loop alive for the probe timer.
201
+ (this.timer as unknown as { unref?: () => void }).unref?.();
202
+ }
203
+
204
+ /**
205
+ * Consume a raw stdin chunk. Returns the passthrough bytes (keystrokes and any
206
+ * non-OSC-11 sequences), having stripped and acted on any OSC 11 reply.
207
+ */
208
+ feed(chunk: string): string {
209
+ if (!this.active) return chunk;
210
+ this.buffer += chunk;
211
+ let out = '';
212
+
213
+ for (;;) {
214
+ const prefixIdx = this.buffer.indexOf(OSC11_PREFIX);
215
+ if (prefixIdx === -1) {
216
+ // No full reply prefix. Release everything except a possible partial
217
+ // prefix at the tail (held back to disambiguate next chunk).
218
+ const keep = trailingPrefixLen(this.buffer);
219
+ out += this.buffer.slice(0, this.buffer.length - keep);
220
+ this.buffer = keep > 0 ? this.buffer.slice(this.buffer.length - keep) : '';
221
+ break;
222
+ }
223
+
224
+ // Release bytes before the reply (interleaved keystrokes survive).
225
+ out += this.buffer.slice(0, prefixIdx);
226
+ const term = findTerminator(this.buffer, prefixIdx + OSC11_PREFIX.length);
227
+ if (term === null) {
228
+ // Incomplete reply: hold from the prefix onward, await more bytes.
229
+ this.buffer = this.buffer.slice(prefixIdx);
230
+ break;
231
+ }
232
+
233
+ // Complete reply. Capture the post-reply remainder BEFORE resolving
234
+ // (resolve() clears the buffer to discard reply residue, so any trailing
235
+ // keystrokes must be preserved here, not read back off this.buffer).
236
+ const spec = this.buffer.slice(prefixIdx + OSC11_PREFIX.length, term.start);
237
+ const remainder = this.buffer.slice(term.end);
238
+ const rgb = parseColorSpec(spec);
239
+ if (rgb === null) {
240
+ this.resolve('dark', 'unparseable');
241
+ } else {
242
+ const mode = classifyBackgroundLuminance(rgb);
243
+ this.resolve(mode, mode === 'light' ? 'light-reply' : 'dark-reply');
244
+ }
245
+ // Resolved (active is now false): forward the post-reply keystrokes and stop.
246
+ out += remainder;
247
+ break;
248
+ }
249
+
250
+ return out;
251
+ }
252
+
253
+ /** Resolve once; clears the timer and (on timeout) discards reply residue. */
254
+ private resolve(mode: ThemeMode, reason: ProbeResolution['reason']): void {
255
+ if (this.resolved) return;
256
+ this.resolved = true;
257
+ this.active = false;
258
+ if (this.timer !== null) {
259
+ clearTimeout(this.timer);
260
+ this.timer = null;
261
+ }
262
+ // Any bytes still buffered here are an incomplete/garbled reply fragment —
263
+ // discard them so they can never leak into the composer.
264
+ this.buffer = '';
265
+ this.onResolve({ mode, reason });
266
+ }
267
+ }
268
+
269
+ // ---------------------------------------------------------------------------
270
+ // tmux passthrough + install entry
271
+ // ---------------------------------------------------------------------------
272
+
273
+ /** Wrap a control sequence in the tmux DCS passthrough envelope (ESC doubled). */
274
+ export function wrapForTmuxPassthrough(seq: string): string {
275
+ return `\x1bPtmux;${seq.replace(/\x1b/g, '\x1b\x1b')}\x1b\\`;
276
+ }
277
+
278
+ /** The stdin filter the caller installs at the front of its data handler. */
279
+ export interface ThemeProbeHandle {
280
+ /** Filter a raw stdin chunk; returns the bytes to forward downstream. */
281
+ filterInput(chunk: string): string;
282
+ }
283
+
284
+ export interface InstallThemeProbeOptions {
285
+ readonly configManager: Pick<ConfigManager, 'get'>;
286
+ /** Whether stdout is a real TTY (probe only runs on a TTY in auto mode). */
287
+ readonly isTTY: boolean;
288
+ /** Process env (only TMUX is read). */
289
+ readonly env: Record<string, string | undefined>;
290
+ /** Writes the query bytes (caller wraps in its terminal-output guard). */
291
+ readonly writeQuery: (bytes: string) => void;
292
+ /** Called when a light reply wins within the window → repaint once. */
293
+ readonly requestRepaint: () => void;
294
+ /** Optional timeout override (tests). */
295
+ readonly timeoutMs?: number;
296
+ /** Optional resolution observer (tests / diagnostics). */
297
+ readonly onResolve?: (result: ProbeResolution) => void;
298
+ }
299
+
300
+ /**
301
+ * Read the appearance preference and set up the active theme mode:
302
+ * - dark/light → force immediately (before first paint); no probe.
303
+ * - auto + TTY → leave dark, fire the OSC 11 query, and on a light reply flip
304
+ * to light + repaint once (Option B).
305
+ * - auto + !TTY → stay dark (headless/piped; probe cannot run).
306
+ * Returns a handle whose filterInput() the caller wires at the front of its
307
+ * stdin data handler. For non-auto and non-TTY cases the filter is a passthrough.
308
+ */
309
+ export function installBackgroundThemeProbe(options: InstallThemeProbeOptions): ThemeProbeHandle {
310
+ const pref = resolveConfiguredThemeMode(options.configManager);
311
+
312
+ if (pref === 'dark' || pref === 'light') {
313
+ setActiveThemeMode(pref);
314
+ return { filterInput: (chunk) => chunk };
315
+ }
316
+
317
+ // pref === 'auto'
318
+ if (!options.isTTY) {
319
+ setActiveThemeMode('dark');
320
+ return { filterInput: (chunk) => chunk };
321
+ }
322
+
323
+ const probe = new TerminalBackgroundProbe({
324
+ timeoutMs: options.timeoutMs,
325
+ onResolve: (result) => {
326
+ if (result.mode === 'light') {
327
+ setActiveThemeMode('light');
328
+ options.requestRepaint();
329
+ }
330
+ options.onResolve?.(result);
331
+ },
332
+ });
333
+
334
+ const query = options.env['TMUX'] ? wrapForTmuxPassthrough(OSC11_QUERY) : OSC11_QUERY;
335
+ options.writeQuery(query);
336
+ probe.startTimeout();
337
+
338
+ return { filterInput: (chunk) => (probe.active ? probe.feed(chunk) : chunk) };
339
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * terminal-escapes — the raw control sequences main.ts writes to enter/leave the
3
+ * TUI's terminal mode (alt screen, mouse, cursor, keyboard-extension, paste, and
4
+ * focus reporting). Extracted from main.ts as plain constants so the entry file
5
+ * stays within the source-file line-count gate; behaviour is unchanged.
6
+ */
7
+
8
+ export const ALT_SCREEN_ENTER = '\x1b[?1049h';
9
+ export const ALT_SCREEN_EXIT = '\x1b[?1049l';
10
+ export const MOUSE_ENABLE = '\x1b[?1000h\x1b[?1002h\x1b[?1006h';
11
+ export const MOUSE_DISABLE = '\x1b[?1006l\x1b[?1002l\x1b[?1000l';
12
+ export const CURSOR_HIDE = '\x1b[?25l';
13
+ export const CURSOR_SHOW = '\x1b[?25h';
14
+ export const CLEAR_SCREEN = '\x1b[2J\x1b[3J\x1b[H';
15
+ export const KEYBOARD_EXT_ENABLE = '\x1b[>4;2m' + '\x1b[?1u';
16
+ export const KEYBOARD_EXT_DISABLE = '\x1b[>4;0m' + '\x1b[?1l';
17
+ export const PASTE_ENABLE = '\x1b[?2004h';
18
+ export const PASTE_DISABLE = '\x1b[?2004l';
19
+ export const FOCUS_ENABLE = '\x1b[?1004h';
20
+ export const FOCUS_DISABLE = '\x1b[?1004l';
@@ -0,0 +1,67 @@
1
+ /**
2
+ * theme-mode-config — the appearance/theme-mode preference, TUI-side.
3
+ *
4
+ * The preference lives at the config key `display.themeMode` (auto | dark |
5
+ * light, default auto). It is stored under the existing SDK `display` section
6
+ * rather than a new `appearance` section on purpose: the SDK ConfigManager's
7
+ * resolvePath() throws for any dot-path whose top-level section is absent from
8
+ * DEFAULT_CONFIG, and adding an `appearance` section would require an SDK schema
9
+ * change (out of scope for this TUI-only work order). Storing under `display`
10
+ * reuses the same proven "TUI-local synthetic setting" pattern the codebase
11
+ * already uses for tts.speed / behavior.notifyAfterSeconds / storage.codeIndexEnabled:
12
+ * ConfigManager.setDynamic writes it to settings.json and get() reads it back,
13
+ * with zero SDK changes.
14
+ *
15
+ * This module is deliberately free of any terminal/probe state so both the
16
+ * settings-modal data layer (input) and the probe (renderer) can import it
17
+ * without pulling in the stateful probe class.
18
+ */
19
+
20
+ import type { ThemeModeSetting } from './theme.ts';
21
+ import type { ConfigKey, ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
22
+
23
+ /** Config key backing the appearance/theme-mode preference (see module doc). */
24
+ export const THEME_MODE_CONFIG_KEY = 'display.themeMode';
25
+
26
+ /** The three valid preference values, in settings-cycle order. */
27
+ export const THEME_MODE_VALUES: readonly ThemeModeSetting[] = ['auto', 'dark', 'light'];
28
+
29
+ /** Default when unset: probe the terminal background on startup. */
30
+ export const THEME_MODE_DEFAULT: ThemeModeSetting = 'auto';
31
+
32
+ /**
33
+ * Human-facing description for the settings-modal entry. States the honest
34
+ * timing contract: forced modes apply on next full paint immediately; `auto`
35
+ * only re-probes at startup, so switching TO auto takes effect next launch.
36
+ */
37
+ export const THEME_MODE_DESCRIPTION =
38
+ 'Terminal background theme. auto probes the terminal background colour once at '
39
+ + 'startup (OSC 11) and picks light or dark; dark/light force a fixed theme. '
40
+ + 'Forced modes take effect immediately; auto is only evaluated at startup, so '
41
+ + 'selecting auto takes effect on the next launch. Unreadable/unsupported '
42
+ + 'terminals fall back to dark. Scope (honest): transcript markdown, modal '
43
+ + 'accents, and the header/footer/thinking chrome all flip with this setting; '
44
+ + 'only the background colour itself follows your terminal.';
45
+
46
+ /** Narrow an unknown config value to a valid ThemeModeSetting, else the default. */
47
+ export function coerceThemeModeSetting(raw: unknown): ThemeModeSetting {
48
+ return raw === 'auto' || raw === 'dark' || raw === 'light' ? raw : THEME_MODE_DEFAULT;
49
+ }
50
+
51
+ /**
52
+ * Read the configured theme-mode preference. Reads the TUI-local key via a cast
53
+ * (it is not in the SDK ConfigKey union). Safe: the `display` section exists in
54
+ * DEFAULT_CONFIG, so get() returns undefined for the absent field rather than
55
+ * throwing, and we coerce undefined → 'auto'.
56
+ */
57
+ export function resolveConfiguredThemeMode(
58
+ configManager: Pick<ConfigManager, 'get'>,
59
+ ): ThemeModeSetting {
60
+ try {
61
+ // Cast key: display.themeMode is TUI-local, not in the SDK ConfigKey union.
62
+ return coerceThemeModeSetting(configManager.get(THEME_MODE_CONFIG_KEY as ConfigKey));
63
+ } catch {
64
+ // Defensive: any unexpected resolvePath/section error → honest default.
65
+ return THEME_MODE_DEFAULT;
66
+ }
67
+ }
@@ -184,6 +184,22 @@ type DeepWidenToString<T> = T extends string ? string : { [K in keyof T]: DeepWi
184
184
 
185
185
  export type UiToneTokens = DeepWidenToString<typeof UI_TONES>;
186
186
 
187
+ //
188
+ // chrome.* — persistent header/footer/thinking foregrounds that paint on the
189
+ // TRANSPARENT terminal background (see the chrome group's doc in
190
+ // ui-primitives.ts). Unlike fg.muted/fg.dim (which stay light for the opaque
191
+ // dark modal/panel boxes), these invert toward dark so they read on a light
192
+ // terminal. Contrast ratios below are against a white terminal (#ffffff),
193
+ // matching the discipline of the LIGHT ThemeTokens above:
194
+ // label: Slate-500 (#64748b) — header title; ~4.9:1 on #fff (matches modelNameDim)
195
+ // faint: Slate-400 (#94a3b8) — version/rule/clean-git; ~2.7:1 on #fff, deliberately
196
+ // faint (mirrors the low-contrast intent of the dark fg.dim role)
197
+ // warn: Amber-700 (#b45309) — dirty git / pending risk; ~5.0:1 on #fff (matches diffAccent)
198
+ // bad: Red-600 (#dc2626) — DANGER banner / shell risk (bold); ~5.3:1 on #fff
199
+ // good: Forest-700 (#15803d) — tool-call ✓ status; ~5.02:1 on #fff (matches checkboxChecked)
200
+ // remote: Violet-700 (#6d28d9) — risk:remote marker / plain status; ~7.10:1 on #fff,
201
+ // deliberately distinct from reasoningAccent (#7c3aed) so the remote-risk cue
202
+ // never reads as a reasoning accent on a light terminal
187
203
  const UI_TONES_LIGHT: UiToneTokens = {
188
204
  ...UI_TONES,
189
205
  state: {
@@ -197,10 +213,20 @@ const UI_TONES_LIGHT: UiToneTokens = {
197
213
  gradientStart: LIGHT.heading1,
198
214
  gradientEnd: LIGHT.reasoningAccent,
199
215
  },
216
+ chrome: {
217
+ ...UI_TONES.chrome,
218
+ label: '#64748b',
219
+ faint: '#94a3b8',
220
+ warn: '#b45309',
221
+ bad: '#dc2626',
222
+ good: '#15803d',
223
+ remote: '#6d28d9',
224
+ },
200
225
  };
201
226
 
202
227
  Object.freeze(UI_TONES_LIGHT.state);
203
228
  Object.freeze(UI_TONES_LIGHT.accent);
229
+ Object.freeze(UI_TONES_LIGHT.chrome);
204
230
  Object.freeze(UI_TONES_LIGHT);
205
231
 
206
232
  /**
@@ -208,8 +234,72 @@ Object.freeze(UI_TONES_LIGHT);
208
234
  * set for the given background mode. Single read path for UI_TONES; the
209
235
  * 'dark' resolution is byte-identical to the UI_TONES constant.
210
236
  *
211
- * Call with 'dark' (the safe default) until terminal-bg-probe lands.
237
+ * Prefer activeUiTones() at call sites resolveUiTones is the pure per-mode
238
+ * resolver underneath it.
212
239
  */
213
240
  export function resolveUiTones(mode: ThemeMode): Readonly<UiToneTokens> {
214
241
  return mode === 'light' ? UI_TONES_LIGHT : UI_TONES;
215
242
  }
243
+
244
+ // ===========================================================================
245
+ // Active-mode runtime (DEBT-2 / F5 terminal-bg-probe landing).
246
+ //
247
+ // The mode is decided ONCE at startup — from appearance config (display.themeMode
248
+ // forced dark/light) or the terminal-background probe (auto) — and is then stable
249
+ // for the session. Two read shapes exist because the two token layers are
250
+ // consumed differently:
251
+ //
252
+ // - Transcript tokens (ThemeTokens): read live per render via activeTheme(),
253
+ // so a dark→light repaint (auto mode, light wins within the probe window)
254
+ // re-resolves without any module reload.
255
+ //
256
+ // - Chrome tokens (UiToneTokens): baked into module-level palette CONSTANTS
257
+ // (DEFAULT_PANEL_PALETTE, DEFAULT_OVERLAY_PALETTE, FULLSCREEN_PALETTE,
258
+ // DEFAULT_STYLE) that hundreds of call sites read by reference. Those
259
+ // constants cannot be re-resolved per call without a rewrite, so each owner
260
+ // registers an in-place rebuild via registerThemeRefresh(); setActiveThemeMode
261
+ // runs every rebuild in registration order (base palettes before the
262
+ // extendPalette-derived panel palettes) so a single mode flip updates them
263
+ // all. The rebuild is fully reversible: light→dark restores byte-identical
264
+ // dark values (tests rely on this to keep the shared test process's default
265
+ // at dark).
266
+ // ===========================================================================
267
+
268
+ /** User-facing appearance preference: auto probes the terminal; dark/light force. */
269
+ export type ThemeModeSetting = 'auto' | 'dark' | 'light';
270
+
271
+ /** The resolved mode in effect for the current session. Dark is the safe default. */
272
+ let activeMode: ThemeMode = 'dark';
273
+
274
+ /** In-place palette rebuilders, run (in registration order) on every mode flip. */
275
+ const themeRefreshers: Array<() => void> = [];
276
+
277
+ /**
278
+ * Register an in-place palette rebuild to run whenever the active mode changes.
279
+ * Base-palette owners register at their own module-eval time (before any
280
+ * extendPalette-derived palette, which depends on the base), so refreshers run
281
+ * base-first — the ordering the extended palettes require to re-merge correctly.
282
+ */
283
+ export function registerThemeRefresh(rebuild: () => void): void {
284
+ themeRefreshers.push(rebuild);
285
+ }
286
+
287
+ /**
288
+ * Set the active background mode and rebuild every registered chrome palette
289
+ * in place. Idempotent and reversible. Callers: startup (forced mode or probe
290
+ * result) and the settings-modal change hook for forced modes.
291
+ */
292
+ export function setActiveThemeMode(mode: ThemeMode): void {
293
+ activeMode = mode;
294
+ for (const rebuild of themeRefreshers) rebuild();
295
+ }
296
+
297
+ /** Transcript tokens for the active mode — read live, per render. */
298
+ export function activeTheme(): Readonly<ThemeTokens> {
299
+ return resolveTheme(activeMode);
300
+ }
301
+
302
+ /** Chrome tokens for the active mode — used to build (and rebuild) palettes. */
303
+ export function activeUiTones(): Readonly<UiToneTokens> {
304
+ return resolveUiTones(activeMode);
305
+ }
@@ -1,14 +1,22 @@
1
1
  import { type Line } from '../types/grid.ts';
2
- import { BORDERS, COLORS } from './layout.ts';
2
+ import { BORDERS } from './layout.ts';
3
3
  import { renderConversationNotice } from './conversation-surface.ts';
4
+ import { activeUiTones } from './theme.ts';
4
5
 
5
6
  export function renderThinkingBlock(text: string, width: number): Line[] {
7
+ // Thinking notices paint the ▌ marker and italic body on the TRANSPARENT
8
+ // terminal background (renderConversationNotice passes no bodyBg), so both
9
+ // colors resolve per-render through activeUiTones() to stay legible on a
10
+ // light terminal. Dark values are byte-identical to the prior static reads
11
+ // (state.reasoning for the accent, chrome.faint — which equals fg.dim — for
12
+ // the body text).
13
+ const t = activeUiTones();
6
14
  return renderConversationNotice(
7
15
  text,
8
16
  width,
9
17
  {
10
- accent: BORDERS.THINKING.color,
11
- text: COLORS.DIM_TEXT,
18
+ accent: t.state.reasoning,
19
+ text: t.chrome.faint,
12
20
  dim: true,
13
21
  italic: true,
14
22
  },
@@ -4,7 +4,8 @@ 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
+ import { GLYPHS } from './ui-primitives.ts';
8
+ import { activeUiTones } from './theme.ts';
8
9
 
9
10
  const TOOL_NAME_MIN_WIDTH = 8;
10
11
  const TOOL_NAME_MAX_WIDTH = 20;
@@ -46,6 +47,7 @@ function buildLeftSegments(
46
47
  ): Array<{ text: string; fg: string; bold?: boolean; dim?: boolean }> {
47
48
  if (leftBudget <= 0) return [];
48
49
 
50
+ const t = activeUiTones();
49
51
  const segments: Array<{ text: string; fg: string; bold?: boolean; dim?: boolean }> = [];
50
52
  const suffixBudget = suffixText ? Math.min(Math.max(12, Math.floor(leftBudget * 0.3)), 20) : 0;
51
53
  const suffixDisplay = suffixBudget > 0 ? truncateDisplay(suffixText, suffixBudget) : '';
@@ -74,14 +76,14 @@ function buildLeftSegments(
74
76
  segments.push({ text: toolNameDisplay, fg: '#00ffcc', bold: true });
75
77
  }
76
78
  if (keyArgDisplay) {
77
- segments.push({ text: ' ', fg: UI_TONES.fg.primary });
79
+ segments.push({ text: ' ', fg: t.fg.primary });
78
80
  segments.push({ text: keyArgDisplay, fg: '252' });
79
81
  }
80
82
  if (suffixDisplay) {
81
- segments.push({ text: ' ', fg: UI_TONES.fg.primary });
83
+ segments.push({ text: ' ', fg: t.fg.primary });
82
84
  segments.push({
83
85
  text: suffixDisplay,
84
- fg: suffixText.startsWith('- ') ? UI_TONES.state.bad : '244',
86
+ fg: suffixText.startsWith('- ') ? t.chrome.bad : '244',
85
87
  dim: true,
86
88
  });
87
89
  }
@@ -155,7 +157,7 @@ function extractKeyArg(toolCall: ToolCall): string {
155
157
  */
156
158
  export function renderToolCallBlock(
157
159
  toolCall: ToolCall,
158
- status: 'executing' | 'done' | 'error',
160
+ status: 'executing' | 'done' | 'error' | 'pending',
159
161
  resultSummary: string | undefined,
160
162
  width: number,
161
163
  durationMs?: number,
@@ -164,16 +166,20 @@ export function renderToolCallBlock(
164
166
  startedAtMs?: number,
165
167
  ): Line[] {
166
168
  const line = createEmptyLine(width);
169
+ const t = activeUiTones();
167
170
  const margin = LAYOUT.LEFT_MARGIN;
168
171
  const rightMargin = LAYOUT.RIGHT_MARGIN;
169
172
  const contentEnd = width - rightMargin;
170
173
 
171
- // Status icon
174
+ // Status icon. 'pending' means the tool is still awaiting a decision
175
+ // (e.g. an approval prompt) and has NOT run yet — it uses the hollow idle
176
+ // glyph so a not-yet-run tool never shows the completed green ✓ (UX-B 2c).
172
177
  const icon = status === 'done' ? TOOL_STATUS.SUCCESS_ICON
173
178
  : status === 'error' ? TOOL_STATUS.FAIL_ICON
179
+ : status === 'pending' ? GLYPHS.status.idle
174
180
  : TOOL_STATUS.SPINNER_FRAMES[(frameIndex ?? 0) % TOOL_STATUS.SPINNER_FRAMES.length];
175
- const iconColor = status === 'done' ? UI_TONES.state.good
176
- : status === 'error' ? UI_TONES.state.bad
181
+ const iconColor = status === 'done' ? t.chrome.good
182
+ : status === 'error' ? t.chrome.bad
177
183
  : '244';
178
184
  const rightText = (() => {
179
185
  if (durationMs !== undefined && status === 'done') {
@@ -198,7 +204,7 @@ export function renderToolCallBlock(
198
204
  let col: number = leftStart;
199
205
 
200
206
  if (col < leftEndExclusive) {
201
- line[col] = createStyledCell(icon, { fg: iconColor, bold: status !== 'executing' });
207
+ line[col] = createStyledCell(icon, { fg: iconColor, bold: status === 'done' || status === 'error' });
202
208
  }
203
209
  col += 2; // icon + space
204
210