@pellux/goodvibes-tui 0.20.2 → 0.21.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/CHANGELOG.md +33 -0
- package/README.md +23 -2
- package/docs/foundation-artifacts/operator-contract.json +78 -1
- package/package.json +3 -2
- package/src/audio/spoken-turn-controller.ts +31 -1
- package/src/audio/spoken-turn-wiring.ts +26 -4
- package/src/cli/bundle-command.ts +1 -1
- package/src/cli/completions/generate.ts +662 -0
- package/src/cli/config-overrides.ts +68 -0
- package/src/cli/help.ts +4 -2
- package/src/cli/management-commands.ts +1 -1
- package/src/cli/management.ts +1 -8
- package/src/cli/parser.ts +14 -18
- package/src/cli/service-command.ts +1 -1
- package/src/cli/surface-command.ts +1 -1
- package/src/cli/tui-startup.ts +72 -10
- package/src/cli/types.ts +12 -3
- package/src/cli-flags.ts +1 -0
- package/src/config/atomic-write.ts +70 -0
- package/src/config/read-versioned.ts +115 -0
- package/src/core/conversation-rendering.ts +49 -15
- package/src/core/conversation.ts +101 -16
- package/src/core/format-user-error.ts +192 -0
- package/src/core/stream-event-wiring.ts +144 -0
- package/src/core/stream-stall-watchdog.ts +103 -0
- package/src/core/system-message-router.ts +5 -1
- package/src/export/cost-utils.ts +71 -0
- package/src/export/gist-uploader.ts +136 -0
- package/src/input/command-registry.ts +31 -1
- package/src/input/commands/control-room-runtime.ts +5 -5
- package/src/input/commands/experience-runtime.ts +5 -4
- package/src/input/commands/knowledge.ts +1 -1
- package/src/input/commands/local-auth-runtime.ts +27 -5
- package/src/input/commands/local-setup.ts +4 -6
- package/src/input/commands/memory-product-runtime.ts +8 -6
- package/src/input/commands/operator-panel-runtime.ts +1 -1
- package/src/input/commands/operator-runtime.ts +3 -10
- package/src/input/commands/platform-sandbox-qemu.ts +60 -16
- package/src/input/commands/{integration-runtime.ts → plugin-runtime.ts} +1 -1
- package/src/input/commands/recall-review.ts +26 -2
- package/src/input/commands/services-runtime.ts +2 -2
- package/src/input/commands/session-workflow.ts +3 -3
- package/src/input/commands/share-runtime.ts +99 -12
- package/src/input/commands/tts-runtime.ts +30 -4
- package/src/input/commands.ts +2 -2
- package/src/input/delete-key-policy.ts +46 -0
- package/src/input/feed-context-factory.ts +2 -0
- package/src/input/handler-feed.ts +3 -0
- package/src/input/handler-interactions.ts +2 -15
- package/src/input/handler-modal-routes.ts +91 -12
- package/src/input/handler-modal-token-routes.ts +3 -0
- package/src/input/handler-onboarding-cloudflare.ts +1 -1
- package/src/input/handler-onboarding.ts +55 -69
- package/src/input/handler-types.ts +163 -0
- package/src/input/handler.ts +5 -2
- package/src/input/input-history.ts +76 -6
- package/src/input/model-picker-filter.ts +265 -0
- package/src/input/model-picker-items.ts +208 -0
- package/src/input/model-picker.ts +92 -325
- package/src/input/onboarding/handler-onboarding-routes.ts +7 -2
- package/src/input/onboarding/onboarding-verification-helpers.ts +76 -0
- package/src/input/onboarding/onboarding-wizard-apply.ts +4 -4
- package/src/input/onboarding/onboarding-wizard-cloudflare-step.ts +2 -2
- package/src/input/onboarding/onboarding-wizard-cloudflare.ts +8 -8
- package/src/input/onboarding/onboarding-wizard-external-surface-extra-specs.ts +1 -1
- package/src/input/onboarding/onboarding-wizard-external-surfaces.ts +2 -29
- package/src/input/onboarding/onboarding-wizard-rules.ts +28 -28
- package/src/input/onboarding/onboarding-wizard-state.ts +20 -20
- package/src/input/onboarding/onboarding-wizard-steps.ts +18 -25
- package/src/input/onboarding/onboarding-wizard-types.ts +145 -3
- package/src/input/onboarding/onboarding-wizard.ts +3 -3
- package/src/input/settings-modal-data.ts +304 -0
- package/src/input/settings-modal-mutations.ts +154 -0
- package/src/input/settings-modal.ts +182 -220
- package/src/main.ts +57 -57
- package/src/panels/builtin/agent.ts +4 -1
- package/src/panels/builtin/development.ts +4 -1
- package/src/panels/confirm-state.ts +27 -12
- package/src/panels/cost-tracker-panel.ts +23 -67
- package/src/panels/eval-panel.ts +10 -9
- package/src/panels/knowledge-panel.ts +3 -5
- package/src/panels/local-auth-panel.ts +124 -4
- package/src/panels/project-planning-panel.ts +42 -4
- package/src/panels/search-focus.ts +11 -5
- package/src/panels/subscription-panel.ts +33 -25
- package/src/panels/types.ts +28 -1
- package/src/panels/wrfc-panel.ts +224 -41
- package/src/renderer/agent-detail-modal.ts +11 -10
- package/src/renderer/code-block.ts +10 -2
- package/src/renderer/compositor.ts +18 -4
- package/src/renderer/context-inspector.ts +1 -5
- package/src/renderer/diff.ts +94 -21
- package/src/renderer/markdown.ts +29 -13
- package/src/renderer/settings-modal-helpers.ts +1 -1
- package/src/renderer/settings-modal.ts +77 -8
- package/src/renderer/syntax-highlighter.ts +10 -3
- package/src/renderer/term-caps.ts +318 -0
- package/src/renderer/theme.ts +158 -0
- package/src/renderer/tool-call.ts +12 -2
- package/src/renderer/ui-factory.ts +50 -6
- package/src/runtime/bootstrap-command-context.ts +1 -0
- package/src/runtime/bootstrap-command-parts.ts +14 -0
- package/src/runtime/bootstrap-core.ts +121 -13
- package/src/runtime/bootstrap.ts +2 -0
- package/src/runtime/onboarding/apply.ts +4 -6
- package/src/runtime/onboarding/index.ts +1 -0
- package/src/runtime/onboarding/markers.ts +42 -49
- package/src/runtime/onboarding/progress.ts +148 -0
- package/src/runtime/onboarding/state.ts +133 -55
- package/src/runtime/onboarding/types.ts +20 -0
- package/src/runtime/sandbox-qemu-templates.ts +15 -0
- package/src/runtime/services.ts +21 -0
- package/src/runtime/wrfc-persistence.ts +237 -0
- package/src/shell/blocking-input.ts +20 -5
- package/src/tools/wrfc-agent-guard.ts +64 -3
- package/src/utils/format-elapsed.ts +30 -0
- package/src/utils/terminal-width.ts +45 -0
- package/src/version.ts +1 -1
- package/src/work-plans/work-plan-store.ts +4 -6
- package/src/planning/project-planning-coordinator.ts +0 -543
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* term-caps.ts — Terminal capability detection and color downsampling.
|
|
3
|
+
*
|
|
4
|
+
* Probes the terminal's color support level once at renderer init and exposes
|
|
5
|
+
* a `downsampleColor` function that maps hex/RGB color strings to the
|
|
6
|
+
* appropriate SGR parameter string for the detected capability level.
|
|
7
|
+
*
|
|
8
|
+
* Capability levels (in ascending order):
|
|
9
|
+
* none — NO_COLOR set or TERM=dumb; emit no SGR color sequences.
|
|
10
|
+
* basic16 — 16 ANSI colors (\x1b[30-37m / \x1b[90-97m / \x1b[40-47m).
|
|
11
|
+
* ansi256 — 256-color palette (\x1b[38;5;Nm).
|
|
12
|
+
* truecolor — 24-bit RGB (\x1b[38;2;R;G;Bm).
|
|
13
|
+
*
|
|
14
|
+
* References:
|
|
15
|
+
* - NO_COLOR spec: https://no-color.org/ (any non-empty value disables color)
|
|
16
|
+
* - TERM=dumb: conventional dumb-terminal indicator
|
|
17
|
+
* - getColorDepth(): Node.js WriteStream API returns 1/4/8/24
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export type ColorCapability = 'none' | 'basic16' | 'ansi256' | 'truecolor';
|
|
21
|
+
|
|
22
|
+
export interface TermColorCaps {
|
|
23
|
+
capability: ColorCapability;
|
|
24
|
+
/**
|
|
25
|
+
* Whether to emit DEC Synchronized Output (mode 2026) markers.
|
|
26
|
+
* True when capability != 'none' and TERM != 'dumb'.
|
|
27
|
+
*/
|
|
28
|
+
syncedOutput: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// Capability probe
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Probe terminal color capabilities from environment and the write stream.
|
|
37
|
+
* Call once at compositor/renderer construction time.
|
|
38
|
+
*
|
|
39
|
+
* @param stdout - The writable stream for terminal output (process.stdout or mock).
|
|
40
|
+
*/
|
|
41
|
+
export function probeTermCaps(stdout: NodeJS.WriteStream): TermColorCaps {
|
|
42
|
+
// NO_COLOR: any non-empty value disables color, per https://no-color.org/
|
|
43
|
+
const noColor = process.env['NO_COLOR'];
|
|
44
|
+
if (noColor !== undefined && noColor !== '') {
|
|
45
|
+
return { capability: 'none', syncedOutput: false };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const term = process.env['TERM'] ?? '';
|
|
49
|
+
if (term === 'dumb') {
|
|
50
|
+
return { capability: 'none', syncedOutput: false };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// getColorDepth() returns bit depth: 1=none, 4=basic16, 8=ansi256, 24=truecolor
|
|
54
|
+
const depth: number = typeof stdout.getColorDepth === 'function'
|
|
55
|
+
? stdout.getColorDepth()
|
|
56
|
+
: 1;
|
|
57
|
+
|
|
58
|
+
let capability: ColorCapability;
|
|
59
|
+
if (depth >= 24) {
|
|
60
|
+
capability = 'truecolor';
|
|
61
|
+
} else if (depth >= 8) {
|
|
62
|
+
capability = 'ansi256';
|
|
63
|
+
} else if (depth >= 4) {
|
|
64
|
+
capability = 'basic16';
|
|
65
|
+
} else {
|
|
66
|
+
capability = 'none';
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const syncedOutput = capability !== 'none';
|
|
70
|
+
return { capability, syncedOutput };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
// Color parsing helpers
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
/** Parse "#rrggbb" → [r, g, b]. Returns null for invalid input. */
|
|
78
|
+
function parseHex(hex: string): [number, number, number] | null {
|
|
79
|
+
if (hex.length === 7 && hex[0] === '#') {
|
|
80
|
+
const r = parseInt(hex.slice(1, 3), 16);
|
|
81
|
+
const g = parseInt(hex.slice(3, 5), 16);
|
|
82
|
+
const b = parseInt(hex.slice(5, 7), 16);
|
|
83
|
+
if (!isNaN(r) && !isNaN(g) && !isNaN(b)) return [r, g, b];
|
|
84
|
+
}
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Parse a sanitized color string in one of two forms:
|
|
90
|
+
* - "#rrggbb" → RGB tuple
|
|
91
|
+
* - "r;g;b" → RGB tuple (already decomposed by sanitizeColor)
|
|
92
|
+
* - "N" → null (already a palette index — pass through)
|
|
93
|
+
* Returns [r, g, b] or null (non-RGB / palette index).
|
|
94
|
+
*/
|
|
95
|
+
function parseRgbString(color: string): [number, number, number] | null {
|
|
96
|
+
if (color.startsWith('#')) return parseHex(color);
|
|
97
|
+
if (color.includes(';')) {
|
|
98
|
+
const parts = color.split(';');
|
|
99
|
+
if (parts.length === 3) {
|
|
100
|
+
const r = parseInt(parts[0]!, 10);
|
|
101
|
+
const g = parseInt(parts[1]!, 10);
|
|
102
|
+
const b = parseInt(parts[2]!, 10);
|
|
103
|
+
if (!isNaN(r) && !isNaN(g) && !isNaN(b)) return [r, g, b];
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
// 256-color cube math
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Map [r, g, b] (0-255 each) to the nearest xterm-256 palette index.
|
|
115
|
+
*
|
|
116
|
+
* The 256-color palette is structured as:
|
|
117
|
+
* 0-15: System colors (16 named colors) — we avoid these for predictability
|
|
118
|
+
* and instead target the 6×6×6 cube + grayscale ramp.
|
|
119
|
+
* 16-231: 6×6×6 color cube, index = 16 + 36*r6 + 6*g6 + b6
|
|
120
|
+
* where r6/g6/b6 ∈ 0-5 map via [0,95,135,175,215,255]
|
|
121
|
+
* 232-255: Grayscale ramp, index = 232 + round((v - 8) / 10)
|
|
122
|
+
* values: 8, 18, 28, ..., 238 (24 steps, step=10)
|
|
123
|
+
*/
|
|
124
|
+
const CUBE_STEPS = [0, 95, 135, 175, 215, 255] as const;
|
|
125
|
+
|
|
126
|
+
function nearestCubeStep(v: number): number {
|
|
127
|
+
let best = 0;
|
|
128
|
+
let bestDist = Math.abs(v - CUBE_STEPS[0]!);
|
|
129
|
+
for (let i = 1; i < CUBE_STEPS.length; i++) {
|
|
130
|
+
const dist = Math.abs(v - CUBE_STEPS[i]!);
|
|
131
|
+
if (dist < bestDist) { bestDist = dist; best = i; }
|
|
132
|
+
}
|
|
133
|
+
return best;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function cubeIndex(r: number, g: number, b: number): number {
|
|
137
|
+
const r6 = nearestCubeStep(r);
|
|
138
|
+
const g6 = nearestCubeStep(g);
|
|
139
|
+
const b6 = nearestCubeStep(b);
|
|
140
|
+
return 16 + 36 * r6 + 6 * g6 + b6;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function grayscaleIndex(v: number): number {
|
|
144
|
+
// Grayscale ramp: 232..255, values 8,18,28,...,238
|
|
145
|
+
// index 232 = value 8, index 255 = value 238, step 10
|
|
146
|
+
const clamped = Math.max(8, Math.min(238, v));
|
|
147
|
+
return 232 + Math.round((clamped - 8) / 10);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function sqDist(r1: number, g1: number, b1: number, r2: number, g2: number, b2: number): number {
|
|
151
|
+
return (r1 - r2) ** 2 + (g1 - g2) ** 2 + (b1 - b2) ** 2;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Find the nearest xterm-256 index for [r, g, b].
|
|
156
|
+
* Compares the nearest cube color vs the nearest grayscale color and picks best.
|
|
157
|
+
*/
|
|
158
|
+
export function nearestAnsi256(r: number, g: number, b: number): number {
|
|
159
|
+
const ci = cubeIndex(r, g, b);
|
|
160
|
+
const r6 = nearestCubeStep(r);
|
|
161
|
+
const g6 = nearestCubeStep(g);
|
|
162
|
+
const b6 = nearestCubeStep(b);
|
|
163
|
+
const cubeR = CUBE_STEPS[r6]!;
|
|
164
|
+
const cubeG = CUBE_STEPS[g6]!;
|
|
165
|
+
const cubeB = CUBE_STEPS[b6]!;
|
|
166
|
+
const cubeDist = sqDist(r, g, b, cubeR, cubeG, cubeB);
|
|
167
|
+
|
|
168
|
+
// Nearest grayscale step
|
|
169
|
+
const gray = Math.round(0.2126 * r + 0.7152 * g + 0.0722 * b);
|
|
170
|
+
const gi = grayscaleIndex(gray);
|
|
171
|
+
const grayVal = 8 + (gi - 232) * 10;
|
|
172
|
+
const grayDist = sqDist(r, g, b, grayVal, grayVal, grayVal);
|
|
173
|
+
|
|
174
|
+
return grayDist < cubeDist ? gi : ci;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
// 16-color nearest-color table
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Standard 16 ANSI colors. Each entry is [r, g, b, fgCode, bgCode].
|
|
183
|
+
* The fg code is the SGR parameter for foreground (30-37, 90-97);
|
|
184
|
+
* the bg code is 40 higher.
|
|
185
|
+
*
|
|
186
|
+
* These values approximate the most common terminal palettes (xterm defaults).
|
|
187
|
+
*/
|
|
188
|
+
const ANSI16_PALETTE: ReadonlyArray<readonly [number, number, number, number]> = [
|
|
189
|
+
// [r, g, b, SGR-fg-code]
|
|
190
|
+
[0, 0, 0, 30], // 0: black
|
|
191
|
+
[170, 0, 0, 31], // 1: red
|
|
192
|
+
[0, 170, 0, 32], // 2: green
|
|
193
|
+
[170, 85, 0, 33], // 3: yellow/brown
|
|
194
|
+
[0, 0, 170, 34], // 4: blue
|
|
195
|
+
[170, 0, 170, 35], // 5: magenta
|
|
196
|
+
[0, 170, 170, 36], // 6: cyan
|
|
197
|
+
[170, 170, 170, 37], // 7: light gray
|
|
198
|
+
[85, 85, 85, 90], // 8: dark gray (bright black)
|
|
199
|
+
[255, 85, 85, 91], // 9: bright red
|
|
200
|
+
[85, 255, 85, 92], // 10: bright green
|
|
201
|
+
[255, 255, 85, 93], // 11: bright yellow
|
|
202
|
+
[85, 85, 255, 94], // 12: bright blue
|
|
203
|
+
[255, 85, 255, 95], // 13: bright magenta
|
|
204
|
+
[85, 255, 255, 96], // 14: bright cyan
|
|
205
|
+
[255, 255, 255, 97], // 15: white
|
|
206
|
+
];
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Find the nearest ANSI 16-color SGR foreground code for [r, g, b].
|
|
210
|
+
* Returns a number like 31 (red fg), 92 (bright green fg), etc.
|
|
211
|
+
*/
|
|
212
|
+
export function nearestAnsi16Fg(r: number, g: number, b: number): number {
|
|
213
|
+
let bestCode = 37;
|
|
214
|
+
let bestDist = Infinity;
|
|
215
|
+
for (const [pr, pg, pb, code] of ANSI16_PALETTE) {
|
|
216
|
+
const d = sqDist(r, g, b, pr!, pg!, pb!);
|
|
217
|
+
if (d < bestDist) { bestDist = d; bestCode = code!; }
|
|
218
|
+
}
|
|
219
|
+
return bestCode;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Convert an ANSI16 fg code to the corresponding bg code.
|
|
224
|
+
* fg 30-37 → bg 40-47; fg 90-97 → bg 100-107.
|
|
225
|
+
*/
|
|
226
|
+
export function ansi16FgToBg(fgCode: number): number {
|
|
227
|
+
// Both ranges (30-37 and 90-97) shift by +10 to reach their bg equivalents
|
|
228
|
+
// (30-37 → 40-47, 90-97 → 100-107).
|
|
229
|
+
return fgCode + 10;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ---------------------------------------------------------------------------
|
|
233
|
+
// Public downsampler
|
|
234
|
+
// ---------------------------------------------------------------------------
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Downsample a color for the given capability.
|
|
238
|
+
*
|
|
239
|
+
* @param rawColor - A color string as seen in Cell.fg / Cell.bg, before
|
|
240
|
+
* sanitizeColor() decomposition. Supported forms:
|
|
241
|
+
* - "#rrggbb" hex
|
|
242
|
+
* - "r;g;b" pre-decomposed RGB (from sanitizeColor)
|
|
243
|
+
* - "N" already a palette index — returned as-is for ansi256/truecolor,
|
|
244
|
+
* or omitted for none
|
|
245
|
+
*
|
|
246
|
+
* @param caps - The probed terminal capabilities.
|
|
247
|
+
* @param role - 'fg' or 'bg' — determines which SGR range to use for basic16.
|
|
248
|
+
*
|
|
249
|
+
* @returns The SGR parameter string suitable for embedding in \x1b[38;2;...m
|
|
250
|
+
* (truecolor), \x1b[38;5;Nm (ansi256), \x1b[Nm (basic16 fg), etc.
|
|
251
|
+
* Returns null when capability is 'none' (caller should skip the sequence).
|
|
252
|
+
*
|
|
253
|
+
* Caller usage:
|
|
254
|
+
* const fg = downsampleColor(cell.fg, caps, 'fg');
|
|
255
|
+
* if (fg !== null) {
|
|
256
|
+
* const isRgb = fg.includes(';'); // truecolor path
|
|
257
|
+
* style += isRgb ? `\x1b[38;2;${fg}m` : `\x1b[38;5;${fg}m`;
|
|
258
|
+
* }
|
|
259
|
+
*
|
|
260
|
+
* For basic16 the caller must use a different SGR prefix — see applyStyles.
|
|
261
|
+
*/
|
|
262
|
+
export function downsampleColor(
|
|
263
|
+
rawColor: string,
|
|
264
|
+
caps: TermColorCaps,
|
|
265
|
+
role: 'fg' | 'bg',
|
|
266
|
+
): string | null {
|
|
267
|
+
if (!rawColor) return null;
|
|
268
|
+
if (caps.capability === 'none') return null;
|
|
269
|
+
|
|
270
|
+
const rgb = parseRgbString(rawColor);
|
|
271
|
+
|
|
272
|
+
if (caps.capability === 'truecolor') {
|
|
273
|
+
// Pass hex through as r;g;b decomposed, pass r;g;b through as-is
|
|
274
|
+
if (rgb) return `${rgb[0]};${rgb[1]};${rgb[2]}`;
|
|
275
|
+
// Already a palette index — emit as 256-color
|
|
276
|
+
return rawColor; // caller will use 38;5;N or 48;5;N
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (caps.capability === 'ansi256') {
|
|
280
|
+
if (rgb) return String(nearestAnsi256(rgb[0], rgb[1], rgb[2]));
|
|
281
|
+
// Already a palette index — pass through
|
|
282
|
+
return rawColor;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// basic16
|
|
286
|
+
if (rgb) {
|
|
287
|
+
const fgCode = nearestAnsi16Fg(rgb[0], rgb[1], rgb[2]);
|
|
288
|
+
if (role === 'fg') return String(fgCode);
|
|
289
|
+
// bg: shift by 10 (30→40, 90→100)
|
|
290
|
+
return String(ansi16FgToBg(fgCode));
|
|
291
|
+
}
|
|
292
|
+
// Palette index in basic16 mode: map 256-color index to the nearest 16-color.
|
|
293
|
+
// We don't have the RGB for arbitrary palette indices here; treat as empty
|
|
294
|
+
// (the caller will skip the sequence rather than emit garbage).
|
|
295
|
+
return null;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// ---------------------------------------------------------------------------
|
|
299
|
+
// DEC 2026 Synchronized Output helpers
|
|
300
|
+
// ---------------------------------------------------------------------------
|
|
301
|
+
|
|
302
|
+
/** DEC private mode 2026: begin synchronized update (suppress screen updates). */
|
|
303
|
+
export const SYNC_BEGIN = '\x1b[?2026h';
|
|
304
|
+
/** DEC private mode 2026: end synchronized update (flush to screen). */
|
|
305
|
+
export const SYNC_END = '\x1b[?2026l';
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Wrap a diff string in DEC 2026 synchronized-update markers if the
|
|
309
|
+
* terminal supports it.
|
|
310
|
+
*
|
|
311
|
+
* @param diff - The raw ANSI diff string.
|
|
312
|
+
* @param caps - The probed terminal capabilities.
|
|
313
|
+
* @returns The diff string, optionally wrapped.
|
|
314
|
+
*/
|
|
315
|
+
export function wrapSynced(diff: string, caps: TermColorCaps): string {
|
|
316
|
+
if (!diff || !caps.syncedOutput) return diff;
|
|
317
|
+
return `${SYNC_BEGIN}${diff}${SYNC_END}`;
|
|
318
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* theme.ts — Semantic colour token layer.
|
|
3
|
+
*
|
|
4
|
+
* Defines named tokens for every colour decision in the markdown/compositor/
|
|
5
|
+
* conversation-rendering pipeline, resolved to concrete hex or ANSI-256 values
|
|
6
|
+
* per background mode.
|
|
7
|
+
*
|
|
8
|
+
* Dark mode values are the historically used colours.
|
|
9
|
+
* Light mode values are defined now for correctness parity; they are consumed
|
|
10
|
+
* when background-detection (F5 / terminal-bg-probe) lands and passes the
|
|
11
|
+
* resolved mode down. Callers that do not yet have mode detection MUST call
|
|
12
|
+
* resolveTheme('dark') as the safe default.
|
|
13
|
+
*
|
|
14
|
+
* IMPORTANT: inline code has NO background token. The bg:#1a1a1a hardcode
|
|
15
|
+
* that previously existed caused a near-black box on light terminals.
|
|
16
|
+
* Differentiate inline code via inlineCodeFg + bold only; bg inherits terminal.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** Background mode — dark is the safe default until terminal-bg-probe lands. */
|
|
20
|
+
export type ThemeMode = 'dark' | 'light';
|
|
21
|
+
|
|
22
|
+
/** Resolved semantic colour tokens (concrete hex strings or ANSI-256 indices). */
|
|
23
|
+
export interface ThemeTokens {
|
|
24
|
+
/** H1 heading foreground + table header accent */
|
|
25
|
+
heading1: string;
|
|
26
|
+
/** H2 heading foreground */
|
|
27
|
+
heading2: string;
|
|
28
|
+
/** H3 heading foreground (ANSI-256 — falls back to nearest on ansi256 terminals) */
|
|
29
|
+
heading3: string;
|
|
30
|
+
/** Inline code foreground (bold is applied separately by caller) */
|
|
31
|
+
inlineCodeFg: string;
|
|
32
|
+
/** Hyperlink and bare-URL foreground */
|
|
33
|
+
link: string;
|
|
34
|
+
/** Non-current search match background */
|
|
35
|
+
searchMatchBg: string;
|
|
36
|
+
/** Non-current search match foreground */
|
|
37
|
+
searchMatchFg: string;
|
|
38
|
+
/** Current (focused) search match background */
|
|
39
|
+
searchCurrentBg: string;
|
|
40
|
+
/** Current (focused) search match foreground */
|
|
41
|
+
searchCurrentFg: string;
|
|
42
|
+
/** Strikethrough / muted text foreground */
|
|
43
|
+
strikethrough: string;
|
|
44
|
+
/** Blockquote / dim text foreground */
|
|
45
|
+
blockquote: string;
|
|
46
|
+
/** Assistant event-line marker + label accent */
|
|
47
|
+
assistantHeader: string;
|
|
48
|
+
/** Reasoning / thinking block accent */
|
|
49
|
+
reasoningAccent: string;
|
|
50
|
+
/** Tool call / active status accent (also diff/tool result label) */
|
|
51
|
+
toolAccent: string;
|
|
52
|
+
/** Collapsed-fragment body background (tool result preview bg) */
|
|
53
|
+
collapsedBodyBg: string;
|
|
54
|
+
/** Checked task-list checkbox foreground (✓ in green) */
|
|
55
|
+
checkboxChecked: string;
|
|
56
|
+
/** Error / cancelled message bar background */
|
|
57
|
+
errorBarBg: string;
|
|
58
|
+
/** Model name / provider dim label foreground */
|
|
59
|
+
modelNameDim: string;
|
|
60
|
+
/** Tool name foreground in tool-result event line */
|
|
61
|
+
toolNameFg: string;
|
|
62
|
+
/** Diff block accent — marker, label, and collapsed-prefix foreground */
|
|
63
|
+
diffAccent: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
// Dark palette
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
const DARK: ThemeTokens = {
|
|
70
|
+
heading1: '#00ffff',
|
|
71
|
+
heading2: '#00ffff',
|
|
72
|
+
heading3: '111',
|
|
73
|
+
inlineCodeFg: '#ffcc00',
|
|
74
|
+
link: '#00aaff',
|
|
75
|
+
searchMatchBg: '#806600',
|
|
76
|
+
searchMatchFg: '#ffffff',
|
|
77
|
+
searchCurrentBg: '#ffff00',
|
|
78
|
+
searchCurrentFg: '#000000',
|
|
79
|
+
strikethrough: '244',
|
|
80
|
+
blockquote: '244',
|
|
81
|
+
assistantHeader: '#22d3ee',
|
|
82
|
+
reasoningAccent: '#a855f7',
|
|
83
|
+
toolAccent: '#38bdf8',
|
|
84
|
+
collapsedBodyBg: '#1a1a1a',
|
|
85
|
+
checkboxChecked: '#22c55e',
|
|
86
|
+
errorBarBg: '#3a1a1a',
|
|
87
|
+
modelNameDim: '#94a3b8',
|
|
88
|
+
toolNameFg: '#e2e8f0',
|
|
89
|
+
diffAccent: '#f59e0b',
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
// Light palette
|
|
94
|
+
//
|
|
95
|
+
// Rationale per token:
|
|
96
|
+
// heading1/2: Deep teal (#0077aa) — readable on white/cream terminals
|
|
97
|
+
// heading3: ANSI-256 #244 equivalent on light bg → use 24 (dark cyan)
|
|
98
|
+
// inlineCodeFg: Dark orange (#b45309) — distinguishable without a box bg
|
|
99
|
+
// link: Standard blue (#0055cc) — matches browser convention
|
|
100
|
+
// searchMatchBg: Muted yellow (#ffe066) — visible on light bg
|
|
101
|
+
// searchMatchFg: Black (#000000)
|
|
102
|
+
// searchCurrentBg: Strong amber (#f59e0b) — current match is more vivid
|
|
103
|
+
// searchCurrentFg: Black (#000000)
|
|
104
|
+
// strikethrough: Medium gray (ANSI-256 244 stays; light terminals map it fine)
|
|
105
|
+
// blockquote: Dim blue-gray (ANSI-256 67)
|
|
106
|
+
// assistantHeader: Dark cyan (#0e7490)
|
|
107
|
+
// reasoningAccent: Dark purple (#7c3aed)
|
|
108
|
+
// toolAccent: Dark sky (#0369a1)
|
|
109
|
+
// collapsedBodyBg: Very light gray (#f3f4f6)
|
|
110
|
+
// checkboxChecked: Forest green (#15803d) — AA on white (contrast ~5.2:1 on #fff)
|
|
111
|
+
// errorBarBg: Soft rose (#fee2e2) — light error bar bg, legible text on top
|
|
112
|
+
// modelNameDim: Slate-500 (#64748b) — dim label; contrast ~4.6:1 on #fff
|
|
113
|
+
// toolNameFg: Slate-800 (#334155) — strong enough for tool names
|
|
114
|
+
// diffAccent: Amber-700 (#b45309) — darker amber, contrast ~4.7:1 on #fff
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
const LIGHT: ThemeTokens = {
|
|
117
|
+
heading1: '#0077aa',
|
|
118
|
+
heading2: '#0077aa',
|
|
119
|
+
heading3: '24',
|
|
120
|
+
inlineCodeFg: '#b45309',
|
|
121
|
+
link: '#0055cc',
|
|
122
|
+
searchMatchBg: '#ffe066',
|
|
123
|
+
searchMatchFg: '#000000',
|
|
124
|
+
searchCurrentBg: '#f59e0b',
|
|
125
|
+
searchCurrentFg: '#000000',
|
|
126
|
+
strikethrough: '244',
|
|
127
|
+
blockquote: '67',
|
|
128
|
+
assistantHeader: '#0e7490',
|
|
129
|
+
reasoningAccent: '#7c3aed',
|
|
130
|
+
toolAccent: '#0369a1',
|
|
131
|
+
collapsedBodyBg: '#f3f4f6',
|
|
132
|
+
checkboxChecked: '#15803d',
|
|
133
|
+
errorBarBg: '#fee2e2',
|
|
134
|
+
modelNameDim: '#64748b',
|
|
135
|
+
toolNameFg: '#334155',
|
|
136
|
+
diffAccent: '#b45309',
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* resolveTheme — Return the semantic token set for the given background mode.
|
|
141
|
+
*
|
|
142
|
+
* Call with 'dark' (the safe default) until terminal-bg-probe lands.
|
|
143
|
+
* The returned object is frozen; callers should not mutate it.
|
|
144
|
+
*/
|
|
145
|
+
export function resolveTheme(mode: ThemeMode): Readonly<ThemeTokens> {
|
|
146
|
+
return mode === 'light' ? LIGHT : DARK;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Freeze both palette objects so they are truly immutable at runtime,
|
|
150
|
+
// matching the Readonly<ThemeTokens> return type in the doc comment above.
|
|
151
|
+
Object.freeze(DARK);
|
|
152
|
+
Object.freeze(LIGHT);
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Default dark-mode token set, exported for convenience.
|
|
156
|
+
* Frozen — do not mutate.
|
|
157
|
+
*/
|
|
158
|
+
export const DARK_THEME: Readonly<ThemeTokens> = DARK;
|
|
@@ -3,6 +3,7 @@ import { LAYOUT, TOOL_STATUS } from './layout.ts';
|
|
|
3
3
|
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
|
+
import { formatElapsed } from '../utils/format-elapsed.ts';
|
|
6
7
|
|
|
7
8
|
const TOOL_NAME_MIN_WIDTH = 8;
|
|
8
9
|
const TOOL_NAME_MAX_WIDTH = 20;
|
|
@@ -148,6 +149,8 @@ function extractKeyArg(toolCall: ToolCall): string {
|
|
|
148
149
|
* @param width - Terminal width
|
|
149
150
|
* @param durationMs - Optional duration in milliseconds
|
|
150
151
|
* @param errorMsg - Optional error message for failed calls
|
|
152
|
+
* @param frameIndex - Spinner frame index for animated icon
|
|
153
|
+
* @param startedAtMs - Wall-clock ms when execution started; enables live elapsed timer
|
|
151
154
|
*/
|
|
152
155
|
export function renderToolCallBlock(
|
|
153
156
|
toolCall: ToolCall,
|
|
@@ -157,6 +160,7 @@ export function renderToolCallBlock(
|
|
|
157
160
|
durationMs?: number,
|
|
158
161
|
errorMsg?: string,
|
|
159
162
|
frameIndex?: number,
|
|
163
|
+
startedAtMs?: number,
|
|
160
164
|
): Line[] {
|
|
161
165
|
const line = createEmptyLine(width);
|
|
162
166
|
const margin = LAYOUT.LEFT_MARGIN;
|
|
@@ -172,9 +176,15 @@ export function renderToolCallBlock(
|
|
|
172
176
|
: '244';
|
|
173
177
|
const rightText = (() => {
|
|
174
178
|
if (durationMs !== undefined && status === 'done') {
|
|
175
|
-
return
|
|
179
|
+
return formatElapsed(durationMs);
|
|
176
180
|
}
|
|
177
|
-
|
|
181
|
+
if (status === 'executing') {
|
|
182
|
+
if (startedAtMs !== undefined) {
|
|
183
|
+
return formatElapsed(Date.now() - startedAtMs);
|
|
184
|
+
}
|
|
185
|
+
return '...';
|
|
186
|
+
}
|
|
187
|
+
return '';
|
|
178
188
|
})();
|
|
179
189
|
const rightWidth = getDisplayWidth(rightText);
|
|
180
190
|
const rightStart = rightText
|
|
@@ -5,6 +5,7 @@ import { fitDisplay, getDisplayWidth, truncateDisplay, wrapText, interpolateColo
|
|
|
5
5
|
import type { GitHeaderInfo } from './git-status.ts';
|
|
6
6
|
import { renderConversationFragment, renderConversationStatusLine, type ConversationStatusSegment } from './conversation-surface.ts';
|
|
7
7
|
import { GLYPHS } from './ui-primitives.ts';
|
|
8
|
+
import { formatElapsed } from '../utils/format-elapsed.ts';
|
|
8
9
|
|
|
9
10
|
/** Number of frames before the animated gradient completes one full cycle. */
|
|
10
11
|
const GRADIENT_CYCLE_FRAMES = 50;
|
|
@@ -301,8 +302,12 @@ export class UIFactory {
|
|
|
301
302
|
const suffix = ` [ ${fmtNum(ctxTokens)} / ${fmtNum(contextWindow)} ]`;
|
|
302
303
|
const barWidth = Math.max(10, Math.min(30, width - getDisplayWidth(label) - getDisplayWidth(suffix) - 8));
|
|
303
304
|
const ctxPct = Math.min(1, ctxTokens / contextWindow);
|
|
305
|
+
// Clamp threshold to [0..1]; undefined/0 means no threshold marker.
|
|
306
|
+
const thresholdFraction = (compactThreshold !== undefined && compactThreshold > 0)
|
|
307
|
+
? Math.min(1, compactThreshold)
|
|
308
|
+
: undefined;
|
|
304
309
|
lines.push(createBaseLine());
|
|
305
|
-
lines.push(this.createProgressBarLine(label, ctxPct, barWidth, width, suffix));
|
|
310
|
+
lines.push(this.createProgressBarLine(label, ctxPct, barWidth, width, suffix, thresholdFraction));
|
|
306
311
|
}
|
|
307
312
|
// Context info line (working dir, model+provider, tools)
|
|
308
313
|
if (workingDir || model) {
|
|
@@ -381,12 +386,14 @@ export class UIFactory {
|
|
|
381
386
|
private static readonly THINK_GRADIENT_START = '#00ffff';
|
|
382
387
|
private static readonly THINK_GRADIENT_END = '#d000ff';
|
|
383
388
|
|
|
384
|
-
public static createThinkingFragment(width: number, spinner: string, frame: number = 0, tokenSpeed?: number, toolPreview?: string, inputTokens?: number, outputTokens?: number): Line[] {
|
|
389
|
+
public static createThinkingFragment(width: number, spinner: string, frame: number = 0, tokenSpeed?: number, toolPreview?: string, inputTokens?: number, outputTokens?: number, elapsedMs?: number, ttftMs?: number): Line[] {
|
|
385
390
|
// Rotate phrase every ~30 seconds (frame ticks at 80ms, so ~375 frames)
|
|
386
391
|
const phraseIndex = Math.floor(frame / PHRASE_ROTATION_FRAMES) % this.THINKING_PHRASES.length;
|
|
387
392
|
const phrase = this.THINKING_PHRASES[phraseIndex];
|
|
388
393
|
const speedSuffix = (tokenSpeed !== undefined && tokenSpeed > 0) ? ` (${Math.round(tokenSpeed)} tok/s)` : '';
|
|
389
|
-
const
|
|
394
|
+
const elapsedSuffix = elapsedMs !== undefined ? ` (${formatElapsed(elapsedMs)})` : '';
|
|
395
|
+
const ttftSuffix = (ttftMs !== undefined && ttftMs > 0) ? ` ttft:${ttftMs}ms` : '';
|
|
396
|
+
const text = ` ${spinner} ${phrase}${speedSuffix}${elapsedSuffix}${ttftSuffix} `;
|
|
390
397
|
|
|
391
398
|
const textWidth = Math.max(1, getDisplayWidth(text) - 1);
|
|
392
399
|
const segments: ConversationStatusSegment[] = Array.from(text).map((char, index) => {
|
|
@@ -485,12 +492,49 @@ export class UIFactory {
|
|
|
485
492
|
* @param pct - Fill fraction 0..1
|
|
486
493
|
* @param barWidth - Number of bar characters
|
|
487
494
|
* @param lineWidth - Total terminal width to slice to
|
|
495
|
+
* @param suffix - Optional suffix appended after the percentage
|
|
496
|
+
* @param compactThreshold - Optional fraction [0..1] at which a threshold marker is drawn
|
|
497
|
+
* and the color switches from safe to at-threshold. When omitted, falls back to the
|
|
498
|
+
* legacy hardcoded 0.6/0.85 thresholds.
|
|
488
499
|
*/
|
|
489
|
-
private static createProgressBarLine(
|
|
500
|
+
private static createProgressBarLine(
|
|
501
|
+
label: string,
|
|
502
|
+
pct: number,
|
|
503
|
+
barWidth: number,
|
|
504
|
+
lineWidth: number,
|
|
505
|
+
suffix?: string,
|
|
506
|
+
compactThreshold?: number,
|
|
507
|
+
): Line {
|
|
490
508
|
const pctDisplay = Math.round(pct * 100);
|
|
491
509
|
const filled = Math.round(pct * barWidth);
|
|
492
|
-
|
|
493
|
-
|
|
510
|
+
|
|
511
|
+
// Color: when compactThreshold is provided, switch at the threshold;
|
|
512
|
+
// otherwise fall back to legacy hardcoded 0.6 (green) / 0.85 (yellow) / red.
|
|
513
|
+
let color: string;
|
|
514
|
+
if (compactThreshold !== undefined) {
|
|
515
|
+
color = pct < compactThreshold ? '82' : pct < 1.0 ? '220' : '196';
|
|
516
|
+
} else {
|
|
517
|
+
color = pct < 0.6 ? '82' : pct < 0.85 ? '220' : '196';
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// Build bar with optional threshold marker.
|
|
521
|
+
// The marker ('▸') is placed at the threshold column, replacing an empty cell.
|
|
522
|
+
const emptyChar = GLYPHS.meter.empty;
|
|
523
|
+
const filledChar = GLYPHS.meter.filled;
|
|
524
|
+
const thresholdCol = compactThreshold !== undefined
|
|
525
|
+
? Math.round(compactThreshold * barWidth)
|
|
526
|
+
: -1;
|
|
527
|
+
|
|
528
|
+
let bar = '';
|
|
529
|
+
for (let i = 0; i < barWidth; i++) {
|
|
530
|
+
if (i === thresholdCol && i >= filled) {
|
|
531
|
+
// Threshold marker sits in the empty region
|
|
532
|
+
bar += '▸'; // ▸
|
|
533
|
+
} else {
|
|
534
|
+
bar += i < filled ? filledChar : emptyChar;
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
494
538
|
const pctStr = ` ${pctDisplay}%`;
|
|
495
539
|
const full = label + bar + pctStr + (suffix ?? '');
|
|
496
540
|
return this.stringToLine(truncateDisplay(full, lineWidth), lineWidth, { fg: color, dim: true });
|
|
@@ -42,6 +42,7 @@ import type { PeerClient } from '@/runtime/index.ts';
|
|
|
42
42
|
import type { DirectTransport } from '@/runtime/index.ts';
|
|
43
43
|
import type { VoiceProviderRegistry, VoiceService } from '@pellux/goodvibes-sdk/platform/voice';
|
|
44
44
|
import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
|
|
45
|
+
import { LocalAuthPanel } from '../panels/local-auth-panel.ts';
|
|
45
46
|
|
|
46
47
|
export type BootstrapCommandSessionSection = CommandContext['session'];
|
|
47
48
|
export type BootstrapCommandProviderSection = CommandContext['provider'];
|
|
@@ -62,6 +63,7 @@ export interface BootstrapCommandActionOptions {
|
|
|
62
63
|
readonly activatePlan: (planId: string, task: string) => void;
|
|
63
64
|
readonly requestPermission: PermissionRequestHandler;
|
|
64
65
|
readonly completeModelSelectionSideEffect?: () => void;
|
|
66
|
+
readonly localUserAuthManager?: import('@pellux/goodvibes-sdk/platform/security').UserAuthManager;
|
|
65
67
|
}
|
|
66
68
|
|
|
67
69
|
export interface BootstrapCommandSectionOptions {
|
|
@@ -160,6 +162,7 @@ export function createBootstrapCommandActions(
|
|
|
160
162
|
| 'openKnowledgePanel'
|
|
161
163
|
| 'openRemotePanel'
|
|
162
164
|
| 'openSubscriptionPanel'
|
|
165
|
+
| 'openLocalAuthMaskedEntry'
|
|
163
166
|
> {
|
|
164
167
|
const {
|
|
165
168
|
providerRegistry,
|
|
@@ -172,6 +175,7 @@ export function createBootstrapCommandActions(
|
|
|
172
175
|
activatePlan,
|
|
173
176
|
requestPermission,
|
|
174
177
|
completeModelSelectionSideEffect,
|
|
178
|
+
localUserAuthManager,
|
|
175
179
|
} = options;
|
|
176
180
|
|
|
177
181
|
const showPanel = (panelId: string, pane?: 'top' | 'bottom') => {
|
|
@@ -275,6 +279,16 @@ export function createBootstrapCommandActions(
|
|
|
275
279
|
openSubscriptionPanel: () => {
|
|
276
280
|
showPanel('subscription');
|
|
277
281
|
},
|
|
282
|
+
openLocalAuthMaskedEntry: (kind, username) => {
|
|
283
|
+
showPanel('local-auth');
|
|
284
|
+
const panel = panelManager.getPanel('local-auth');
|
|
285
|
+
if (panel instanceof LocalAuthPanel && localUserAuthManager) {
|
|
286
|
+
panel.openMaskedEntry(kind, username, localUserAuthManager);
|
|
287
|
+
} else {
|
|
288
|
+
conversation.log('Masked entry unavailable: local auth is not configured in this session.', { fg: '#ef4444' });
|
|
289
|
+
requestRender();
|
|
290
|
+
}
|
|
291
|
+
},
|
|
278
292
|
};
|
|
279
293
|
}
|
|
280
294
|
|