@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.
- package/README.md +10 -9
- package/docs/foundation-artifacts/operator-contract.json +4045 -1763
- package/package.json +2 -2
- package/src/audio/spoken-turn-controller.ts +12 -2
- package/src/audio/spoken-turn-wiring.ts +2 -1
- package/src/cli/help.ts +8 -1
- package/src/cli/service-posture.ts +2 -1
- package/src/cli/status.ts +2 -1
- package/src/cli/surface-command.ts +2 -2
- package/src/core/composer-state.ts +11 -3
- package/src/core/conversation-line-cache.ts +27 -3
- package/src/core/conversation-rendering.ts +71 -14
- package/src/core/stream-event-wiring.ts +20 -1
- package/src/core/system-message-noise.ts +87 -0
- package/src/core/system-message-router.ts +68 -1
- package/src/core/turn-cancellation.ts +7 -2
- package/src/core/turn-event-wiring.ts +10 -2
- package/src/daemon/cli.ts +29 -2
- package/src/daemon/handlers/register.ts +8 -1
- package/src/daemon/service-commands.ts +329 -0
- package/src/input/autocomplete.ts +27 -1
- package/src/input/command-registry.ts +46 -4
- package/src/input/commands/codebase-runtime.ts +46 -6
- package/src/input/commands/config.ts +43 -3
- package/src/input/commands/health-runtime.ts +9 -1
- package/src/input/commands/memory.ts +68 -35
- package/src/input/commands/operator-panel-runtime.ts +31 -7
- package/src/input/commands/planning-runtime.ts +95 -6
- package/src/input/commands/qrcode-runtime.ts +25 -5
- package/src/input/commands/remote-runtime-setup.ts +5 -3
- package/src/input/commands/session-content.ts +20 -9
- package/src/input/commands/settings-sync-runtime.ts +15 -3
- package/src/input/commands/shell-core.ts +10 -1
- package/src/input/commands/workstream-runtime.ts +168 -18
- package/src/input/config-modal-types.ts +15 -1
- package/src/input/config-modal.ts +227 -12
- package/src/input/feed-context-factory.ts +3 -0
- package/src/input/handler-command-route.ts +10 -3
- package/src/input/handler-content-actions.ts +17 -2
- package/src/input/handler-feed-routes.ts +43 -121
- package/src/input/handler-feed.ts +32 -4
- package/src/input/handler-modal-routes.ts +78 -13
- package/src/input/handler-modal-stack.ts +11 -2
- package/src/input/handler-onboarding-daemon-adopt.ts +149 -0
- package/src/input/handler-onboarding.ts +71 -59
- package/src/input/handler-picker-routes.ts +15 -8
- package/src/input/handler-shortcuts.ts +59 -9
- package/src/input/handler.ts +6 -1
- package/src/input/keybindings.ts +6 -5
- package/src/input/model-picker.ts +19 -2
- package/src/input/onboarding/onboarding-runtime-status.ts +10 -1
- package/src/input/onboarding/onboarding-wizard-apply.ts +8 -1
- package/src/input/onboarding/onboarding-wizard-constants.ts +4 -1
- package/src/input/onboarding/onboarding-wizard-network-adopt.ts +136 -0
- package/src/input/onboarding/onboarding-wizard-steps.ts +9 -6
- package/src/input/onboarding/onboarding-wizard-types.ts +3 -1
- package/src/input/panel-mouse-geometry.ts +97 -0
- package/src/input/panel-paste-flood-guard.ts +86 -0
- package/src/input/selection-modal.ts +11 -0
- package/src/input/session-picker-modal.ts +44 -4
- package/src/input/settings-modal-data.ts +51 -2
- package/src/input/settings-modal-types.ts +4 -2
- package/src/main.ts +28 -27
- package/src/panels/builtin/shared.ts +9 -3
- package/src/panels/fleet-deep-link.ts +31 -0
- package/src/panels/fleet-panel-format.ts +62 -0
- package/src/panels/fleet-panel-worktree-detail.ts +48 -0
- package/src/panels/fleet-panel.ts +89 -131
- package/src/panels/fleet-read-model.ts +43 -0
- package/src/panels/fleet-steer.ts +35 -2
- package/src/panels/fleet-stop.ts +29 -1
- package/src/panels/modals/keybindings-modal.ts +16 -1
- package/src/panels/modals/modal-theme.ts +35 -29
- package/src/panels/modals/pairing-modal.ts +25 -6
- package/src/panels/modals/planning-modal.ts +43 -21
- package/src/panels/modals/work-plan-modal.ts +28 -0
- package/src/panels/panel-manager.ts +15 -4
- package/src/panels/polish-core.ts +38 -25
- package/src/panels/project-planning-answer-actions.ts +8 -13
- package/src/panels/types.ts +24 -0
- package/src/permissions/prompt.ts +160 -13
- package/src/renderer/autocomplete-overlay.ts +28 -2
- package/src/renderer/compositor.ts +2 -3
- package/src/renderer/config-modal.ts +19 -5
- package/src/renderer/footer-tips.ts +5 -1
- package/src/renderer/fullscreen-primitives.ts +32 -22
- package/src/renderer/git-status.ts +3 -1
- package/src/renderer/layout.ts +0 -4
- package/src/renderer/markdown.ts +7 -3
- package/src/renderer/modal-factory.ts +25 -20
- package/src/renderer/model-workspace.ts +18 -3
- package/src/renderer/overlay-box.ts +21 -17
- package/src/renderer/process-indicator.ts +14 -3
- package/src/renderer/selection-modal-overlay.ts +6 -1
- package/src/renderer/session-picker-modal.ts +196 -3
- package/src/renderer/settings-modal-helpers.ts +2 -0
- package/src/renderer/settings-modal.ts +7 -0
- package/src/renderer/shell-surface.ts +8 -1
- package/src/renderer/status-glyphs.ts +14 -15
- package/src/renderer/system-message.ts +15 -3
- package/src/renderer/terminal-bg-probe.ts +339 -0
- package/src/renderer/terminal-escapes.ts +20 -0
- package/src/renderer/theme-mode-config.ts +67 -0
- package/src/renderer/theme.ts +91 -1
- package/src/renderer/thinking.ts +11 -3
- package/src/renderer/tool-call.ts +15 -9
- package/src/renderer/tool-result-summary.ts +148 -0
- package/src/renderer/turn-injection.ts +22 -3
- package/src/renderer/ui-factory.ts +154 -85
- package/src/renderer/ui-primitives.ts +30 -129
- package/src/runtime/bootstrap-command-context.ts +6 -0
- package/src/runtime/bootstrap-command-parts.ts +8 -4
- package/src/runtime/bootstrap-core.ts +33 -11
- package/src/runtime/bootstrap-hook-bridge.ts +7 -0
- package/src/runtime/bootstrap-shell.ts +19 -1
- package/src/runtime/bootstrap.ts +118 -5
- package/src/runtime/code-index-services.ts +25 -2
- package/src/runtime/legacy-daemon-migration.ts +516 -0
- package/src/runtime/memory-fold.ts +26 -0
- package/src/runtime/onboarding/derivation.ts +7 -2
- package/src/runtime/onboarding/snapshot.ts +27 -1
- package/src/runtime/onboarding/types.ts +31 -1
- package/src/runtime/operator-token-cleanup.ts +82 -1
- package/src/runtime/orchestrator-core-services.ts +10 -0
- package/src/runtime/resume-notice.ts +209 -0
- package/src/runtime/services.ts +12 -8
- package/src/runtime/session-inbound-inputs.ts +252 -0
- package/src/runtime/session-spine-transport.ts +64 -0
- package/src/runtime/terminal-output-guard.ts +15 -8
- package/src/runtime/ui-services.ts +19 -3
- package/src/runtime/workstream-services.ts +160 -28
- package/src/runtime/wrfc-persistence.ts +124 -17
- package/src/shell/blocking-input.ts +46 -3
- package/src/shell/recovery-input-helpers.ts +170 -1
- package/src/shell/ui-openers.ts +42 -9
- package/src/utils/terminal-width.ts +52 -0
- 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
|
+
}
|
package/src/renderer/theme.ts
CHANGED
|
@@ -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
|
-
*
|
|
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
|
+
}
|
package/src/renderer/thinking.ts
CHANGED
|
@@ -1,14 +1,22 @@
|
|
|
1
1
|
import { type Line } from '../types/grid.ts';
|
|
2
|
-
import { BORDERS
|
|
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:
|
|
11
|
-
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 {
|
|
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:
|
|
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:
|
|
83
|
+
segments.push({ text: ' ', fg: t.fg.primary });
|
|
82
84
|
segments.push({
|
|
83
85
|
text: suffixDisplay,
|
|
84
|
-
fg: suffixText.startsWith('- ') ?
|
|
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' ?
|
|
176
|
-
: status === 'error' ?
|
|
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
|
|
207
|
+
line[col] = createStyledCell(icon, { fg: iconColor, bold: status === 'done' || status === 'error' });
|
|
202
208
|
}
|
|
203
209
|
col += 2; // icon + space
|
|
204
210
|
|