@pellux/goodvibes-tui 0.20.3 → 0.22.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 (142) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/README.md +23 -2
  3. package/docs/foundation-artifacts/operator-contract.json +78 -1
  4. package/package.json +4 -2
  5. package/src/audio/spoken-turn-controller.ts +31 -1
  6. package/src/audio/spoken-turn-wiring.ts +26 -4
  7. package/src/cli/bundle-command.ts +1 -1
  8. package/src/cli/completions/generate.ts +658 -0
  9. package/src/cli/config-overrides.ts +68 -0
  10. package/src/cli/entrypoint.ts +6 -0
  11. package/src/cli/help.ts +4 -2
  12. package/src/cli/management-commands.ts +1 -1
  13. package/src/cli/management.ts +1 -8
  14. package/src/cli/parser.ts +31 -18
  15. package/src/cli/service-command.ts +1 -1
  16. package/src/cli/surface-command.ts +1 -1
  17. package/src/cli/tui-startup.ts +72 -10
  18. package/src/cli/types.ts +14 -3
  19. package/src/cli-flags.ts +1 -0
  20. package/src/config/atomic-write.ts +70 -0
  21. package/src/config/goodvibes-home-audit.ts +2 -0
  22. package/src/config/read-versioned.ts +115 -0
  23. package/src/core/context-auto-compact.ts +77 -0
  24. package/src/core/conversation-rendering.ts +49 -15
  25. package/src/core/conversation.ts +101 -16
  26. package/src/core/format-user-error.ts +192 -0
  27. package/src/core/stream-event-wiring.ts +144 -0
  28. package/src/core/stream-stall-watchdog.ts +103 -0
  29. package/src/core/system-message-router.ts +5 -1
  30. package/src/core/turn-event-wiring.ts +124 -0
  31. package/src/daemon/cli.ts +5 -0
  32. package/src/export/cost-utils.ts +71 -0
  33. package/src/export/gist-uploader.ts +136 -0
  34. package/src/input/command-registry.ts +32 -1
  35. package/src/input/commands/control-room-runtime.ts +10 -10
  36. package/src/input/commands/experience-runtime.ts +5 -4
  37. package/src/input/commands/knowledge.ts +1 -1
  38. package/src/input/commands/local-auth-runtime.ts +27 -5
  39. package/src/input/commands/local-setup.ts +4 -6
  40. package/src/input/commands/memory-product-runtime.ts +8 -6
  41. package/src/input/commands/operator-panel-runtime.ts +1 -1
  42. package/src/input/commands/operator-runtime.ts +3 -10
  43. package/src/input/commands/{integration-runtime.ts → plugin-runtime.ts} +1 -1
  44. package/src/input/commands/provider.ts +57 -3
  45. package/src/input/commands/recall-review.ts +26 -2
  46. package/src/input/commands/services-runtime.ts +2 -2
  47. package/src/input/commands/session-workflow.ts +8 -16
  48. package/src/input/commands/session.ts +70 -20
  49. package/src/input/commands/share-runtime.ts +99 -12
  50. package/src/input/commands/tts-runtime.ts +30 -4
  51. package/src/input/commands.ts +2 -4
  52. package/src/input/delete-key-policy.ts +46 -0
  53. package/src/input/feed-context-factory.ts +2 -0
  54. package/src/input/handler-feed.ts +3 -0
  55. package/src/input/handler-interactions.ts +2 -15
  56. package/src/input/handler-modal-routes.ts +128 -12
  57. package/src/input/handler-modal-token-routes.ts +22 -5
  58. package/src/input/handler-onboarding-cloudflare.ts +1 -1
  59. package/src/input/handler-onboarding.ts +73 -69
  60. package/src/input/handler-types.ts +163 -0
  61. package/src/input/handler.ts +6 -2
  62. package/src/input/input-history.ts +76 -6
  63. package/src/input/model-picker-filter.ts +265 -0
  64. package/src/input/model-picker-items.ts +208 -0
  65. package/src/input/model-picker.ts +92 -325
  66. package/src/input/onboarding/handler-onboarding-routes.ts +7 -2
  67. package/src/input/onboarding/onboarding-verification-helpers.ts +76 -0
  68. package/src/input/onboarding/onboarding-wizard-apply.ts +14 -4
  69. package/src/input/onboarding/onboarding-wizard-cloudflare-step.ts +16 -2
  70. package/src/input/onboarding/onboarding-wizard-cloudflare.ts +8 -8
  71. package/src/input/onboarding/onboarding-wizard-external-surface-extra-specs.ts +1 -1
  72. package/src/input/onboarding/onboarding-wizard-external-surfaces.ts +2 -29
  73. package/src/input/onboarding/onboarding-wizard-rules.ts +28 -28
  74. package/src/input/onboarding/onboarding-wizard-state.ts +20 -20
  75. package/src/input/onboarding/onboarding-wizard-steps.ts +24 -25
  76. package/src/input/onboarding/onboarding-wizard-types.ts +145 -3
  77. package/src/input/onboarding/onboarding-wizard-validation.ts +77 -0
  78. package/src/input/onboarding/onboarding-wizard.ts +3 -3
  79. package/src/input/settings-modal-behavior.ts +5 -0
  80. package/src/input/settings-modal-data.ts +378 -0
  81. package/src/input/settings-modal-mutations.ts +157 -0
  82. package/src/input/settings-modal-reset.ts +154 -0
  83. package/src/input/settings-modal.ts +236 -232
  84. package/src/main.ts +93 -85
  85. package/src/panels/agent-inspector-panel.ts +120 -18
  86. package/src/panels/agent-inspector-shared.ts +29 -0
  87. package/src/panels/builtin/agent.ts +4 -1
  88. package/src/panels/builtin/development.ts +5 -1
  89. package/src/panels/builtin/knowledge.ts +14 -13
  90. package/src/panels/builtin/operations.ts +22 -1
  91. package/src/panels/builtin/shared.ts +7 -0
  92. package/src/panels/cockpit-panel.ts +123 -3
  93. package/src/panels/cockpit-read-model.ts +232 -0
  94. package/src/panels/confirm-state.ts +27 -12
  95. package/src/panels/cost-tracker-panel.ts +23 -67
  96. package/src/panels/eval-panel.ts +10 -9
  97. package/src/panels/index.ts +1 -1
  98. package/src/panels/knowledge-graph-panel.ts +84 -0
  99. package/src/panels/local-auth-panel.ts +124 -4
  100. package/src/panels/memory-panel.ts +370 -40
  101. package/src/panels/project-planning-panel.ts +42 -4
  102. package/src/panels/search-focus.ts +11 -5
  103. package/src/panels/session-maintenance.ts +66 -15
  104. package/src/panels/subscription-panel.ts +33 -25
  105. package/src/panels/types.ts +28 -1
  106. package/src/panels/wrfc-panel.ts +224 -41
  107. package/src/renderer/agent-detail-modal.ts +118 -13
  108. package/src/renderer/code-block.ts +10 -2
  109. package/src/renderer/compositor.ts +18 -4
  110. package/src/renderer/context-inspector.ts +1 -5
  111. package/src/renderer/context-status-hint.ts +54 -0
  112. package/src/renderer/diff.ts +94 -21
  113. package/src/renderer/markdown.ts +29 -13
  114. package/src/renderer/settings-modal-helpers.ts +1 -1
  115. package/src/renderer/settings-modal.ts +90 -10
  116. package/src/renderer/shell-surface.ts +10 -0
  117. package/src/renderer/syntax-highlighter.ts +10 -3
  118. package/src/renderer/term-caps.ts +318 -0
  119. package/src/renderer/theme.ts +158 -0
  120. package/src/renderer/tool-call.ts +12 -2
  121. package/src/renderer/ui-factory.ts +50 -6
  122. package/src/runtime/bootstrap-command-context.ts +1 -0
  123. package/src/runtime/bootstrap-command-parts.ts +18 -0
  124. package/src/runtime/bootstrap-core.ts +145 -13
  125. package/src/runtime/bootstrap-shell.ts +11 -0
  126. package/src/runtime/bootstrap.ts +9 -0
  127. package/src/runtime/onboarding/apply.ts +4 -6
  128. package/src/runtime/onboarding/index.ts +1 -0
  129. package/src/runtime/onboarding/markers.ts +42 -49
  130. package/src/runtime/onboarding/progress.ts +148 -0
  131. package/src/runtime/onboarding/state.ts +133 -55
  132. package/src/runtime/onboarding/types.ts +20 -0
  133. package/src/runtime/services.ts +27 -1
  134. package/src/runtime/wrfc-persistence.ts +237 -0
  135. package/src/shell/blocking-input.ts +20 -5
  136. package/src/tools/wrfc-agent-guard.ts +64 -3
  137. package/src/utils/format-elapsed.ts +30 -0
  138. package/src/utils/terminal-width.ts +45 -0
  139. package/src/version.ts +1 -1
  140. package/src/work-plans/work-plan-store.ts +4 -6
  141. package/src/panels/knowledge-panel.ts +0 -345
  142. 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 durationMs < 1000 ? `${durationMs}ms` : `${(durationMs / 1000).toFixed(1)}s`;
179
+ return formatElapsed(durationMs);
176
180
  }
177
- return status === 'executing' ? '...' : '';
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 text = ` ${spinner} ${phrase}${speedSuffix} `;
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(label: string, pct: number, barWidth: number, lineWidth: number, suffix?: string): Line {
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
- const color = pct < 0.6 ? '82' : pct < 0.85 ? '220' : '196';
493
- const bar = GLYPHS.meter.filled.repeat(filled) + GLYPHS.meter.empty.repeat(barWidth - filled);
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 });
@@ -263,6 +263,7 @@ export function createBootstrapCommandContext(
263
263
  activatePlan,
264
264
  requestPermission,
265
265
  completeModelSelectionSideEffect,
266
+ localUserAuthManager,
266
267
  });
267
268
 
268
269
  return {
@@ -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 {
@@ -158,8 +160,10 @@ export function createBootstrapCommandActions(
158
160
  | 'openMcpWorkspace'
159
161
  | 'openSecurityPanel'
160
162
  | 'openKnowledgePanel'
163
+ | 'openMemoryPanel'
161
164
  | 'openRemotePanel'
162
165
  | 'openSubscriptionPanel'
166
+ | 'openLocalAuthMaskedEntry'
163
167
  > {
164
168
  const {
165
169
  providerRegistry,
@@ -172,6 +176,7 @@ export function createBootstrapCommandActions(
172
176
  activatePlan,
173
177
  requestPermission,
174
178
  completeModelSelectionSideEffect,
179
+ localUserAuthManager,
175
180
  } = options;
176
181
 
177
182
  const showPanel = (panelId: string, pane?: 'top' | 'bottom') => {
@@ -269,12 +274,25 @@ export function createBootstrapCommandActions(
269
274
  openKnowledgePanel: () => {
270
275
  showPanel('knowledge');
271
276
  },
277
+ openMemoryPanel: () => {
278
+ showPanel('memory');
279
+ },
272
280
  openRemotePanel: () => {
273
281
  showPanel('remote');
274
282
  },
275
283
  openSubscriptionPanel: () => {
276
284
  showPanel('subscription');
277
285
  },
286
+ openLocalAuthMaskedEntry: (kind, username) => {
287
+ showPanel('local-auth');
288
+ const panel = panelManager.getPanel('local-auth');
289
+ if (panel instanceof LocalAuthPanel && localUserAuthManager) {
290
+ panel.openMaskedEntry(kind, username, localUserAuthManager);
291
+ } else {
292
+ conversation.log('Masked entry unavailable: local auth is not configured in this session.', { fg: '#ef4444' });
293
+ requestRender();
294
+ }
295
+ },
278
296
  };
279
297
  }
280
298