@pellux/goodvibes-agent 1.5.5 → 1.5.6

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 (165) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/README.md +1 -1
  3. package/dist/package/main.js +94130 -79335
  4. package/docs/tools-and-commands.md +2 -1
  5. package/package.json +6 -3
  6. package/src/agent/autonomy-schedule-format.ts +1 -1
  7. package/src/agent/autonomy-schedule.ts +3 -3
  8. package/src/agent/calendar/calendar-oauth-service.ts +324 -0
  9. package/src/agent/calendar-subscription-registry.ts +497 -0
  10. package/src/agent/email/email-service.ts +57 -0
  11. package/src/agent/email/smtp-client.ts +30 -10
  12. package/src/agent/memory-prompt.ts +143 -12
  13. package/src/agent/operator-actions.ts +26 -22
  14. package/src/agent/prompt-context-receipts.ts +45 -18
  15. package/src/agent/reminder-schedule-format.ts +1 -1
  16. package/src/agent/reminder-schedule.ts +3 -3
  17. package/src/agent/routine-schedule-format.ts +2 -2
  18. package/src/agent/routine-schedule-promotion.ts +5 -5
  19. package/src/agent/routine-schedule-receipts.ts +2 -2
  20. package/src/agent/schedule-edit-format.ts +1 -1
  21. package/src/agent/schedule-edit.ts +5 -5
  22. package/src/agent/session-registration.ts +281 -0
  23. package/src/agent/vibe-file.ts +50 -0
  24. package/src/cli/local-library-command-shared.ts +146 -0
  25. package/src/cli/management.ts +2 -1
  26. package/src/cli/personas-command.ts +252 -0
  27. package/src/cli/resume-relaunch-notice.ts +151 -0
  28. package/src/cli/routines-command.ts +25 -2
  29. package/src/cli/service-posture.ts +3 -2
  30. package/src/cli/skill-bundle-command.ts +175 -0
  31. package/src/cli/skills-command.ts +309 -0
  32. package/src/cli/status.ts +43 -7
  33. package/src/cli/tui-startup.ts +24 -1
  34. package/src/config/agent-settings-policy.ts +0 -1
  35. package/src/config/secret-config.ts +4 -0
  36. package/src/core/conversation-rendering.ts +20 -11
  37. package/src/core/focus-tracker.ts +41 -0
  38. package/src/core/system-message-noise.ts +108 -0
  39. package/src/core/system-message-router.ts +54 -1
  40. package/src/core/thinking-overlay.ts +83 -0
  41. package/src/input/agent-workspace-access-command-editor-submission.ts +75 -108
  42. package/src/input/agent-workspace-access-command-editors.ts +108 -128
  43. package/src/input/agent-workspace-activation.ts +15 -0
  44. package/src/input/agent-workspace-basic-command-editor-submission.ts +292 -513
  45. package/src/input/agent-workspace-basic-command-editors.ts +433 -564
  46. package/src/input/agent-workspace-calendar-connect-editor.ts +116 -0
  47. package/src/input/agent-workspace-calendar-oauth-editor.ts +152 -0
  48. package/src/input/agent-workspace-calendar-subscribe-editor.ts +128 -0
  49. package/src/input/agent-workspace-categories.ts +7 -2
  50. package/src/input/agent-workspace-channel-command-editor-submission.ts +38 -52
  51. package/src/input/agent-workspace-channel-command-editors.ts +42 -46
  52. package/src/input/agent-workspace-command-editor-engine.ts +133 -0
  53. package/src/input/agent-workspace-command-editor.ts +4 -0
  54. package/src/input/agent-workspace-direct-editor-submission.ts +59 -0
  55. package/src/input/agent-workspace-email-connect-editor.ts +162 -0
  56. package/src/input/agent-workspace-knowledge-command-editor-submission.ts +52 -81
  57. package/src/input/agent-workspace-knowledge-command-editors.ts +71 -71
  58. package/src/input/agent-workspace-library-command-editor-submission.ts +10 -28
  59. package/src/input/agent-workspace-library-command-editors.ts +16 -2
  60. package/src/input/agent-workspace-live-counters.ts +55 -0
  61. package/src/input/agent-workspace-mcp-command-editor-submission.ts +60 -82
  62. package/src/input/agent-workspace-mcp-command-editors.ts +52 -0
  63. package/src/input/agent-workspace-media-command-editor-submission.ts +12 -39
  64. package/src/input/agent-workspace-media-command-editors.ts +10 -8
  65. package/src/input/agent-workspace-memory-command-editor-submission.ts +76 -151
  66. package/src/input/agent-workspace-memory-command-editors.ts +116 -141
  67. package/src/input/agent-workspace-operations-command-editor-submission.ts +131 -184
  68. package/src/input/agent-workspace-operations-command-editors.ts +150 -162
  69. package/src/input/agent-workspace-provider-command-editor-submission.ts +60 -106
  70. package/src/input/agent-workspace-provider-command-editors.ts +58 -68
  71. package/src/input/agent-workspace-search.ts +8 -1
  72. package/src/input/agent-workspace-session-command-editor-submission.ts +104 -132
  73. package/src/input/agent-workspace-session-command-editors.ts +160 -195
  74. package/src/input/agent-workspace-settings.ts +40 -1
  75. package/src/input/agent-workspace-skill-bundle-command-editor-submission.ts +55 -71
  76. package/src/input/agent-workspace-skill-bundle-command-editors.ts +65 -69
  77. package/src/input/agent-workspace-snapshot-builders.ts +431 -0
  78. package/src/input/agent-workspace-snapshot-config.ts +43 -0
  79. package/src/input/agent-workspace-snapshot.ts +198 -432
  80. package/src/input/agent-workspace-task-command-editor-submission.ts +34 -44
  81. package/src/input/agent-workspace-task-command-editors.ts +27 -28
  82. package/src/input/agent-workspace-types.ts +35 -1
  83. package/src/input/agent-workspace.ts +5 -14
  84. package/src/input/command-registry.ts +23 -1
  85. package/src/input/commands/calendar-connect-runtime.ts +226 -0
  86. package/src/input/commands/calendar-runtime.ts +110 -7
  87. package/src/input/commands/calendar-subscription-runtime.ts +225 -0
  88. package/src/input/commands/email-runtime.ts +100 -40
  89. package/src/input/commands/knowledge.ts +1 -1
  90. package/src/input/commands/network-scan-runtime.ts +75 -0
  91. package/src/input/commands/operator-actions-runtime.ts +9 -6
  92. package/src/input/commands/personas-runtime.ts +1 -1
  93. package/src/input/commands/schedule-runtime.ts +4 -4
  94. package/src/input/commands/session-content.ts +13 -1
  95. package/src/input/commands/session-workflow.ts +15 -20
  96. package/src/input/commands/session.ts +3 -1
  97. package/src/input/commands/shell-core.ts +20 -6
  98. package/src/input/commands.ts +2 -2
  99. package/src/input/delete-key-policy.ts +46 -0
  100. package/src/input/feed-context-factory.ts +10 -0
  101. package/src/input/handler-feed.ts +87 -0
  102. package/src/input/handler-modal-routes.ts +6 -1
  103. package/src/input/handler.ts +5 -0
  104. package/src/input/panel-paste-flood-guard.ts +94 -0
  105. package/src/input/settings-modal-types.ts +5 -3
  106. package/src/input/settings-modal.ts +21 -0
  107. package/src/main.ts +36 -36
  108. package/src/permissions/approval-posture.ts +141 -0
  109. package/src/renderer/agent-workspace-context-lines.ts +10 -2
  110. package/src/renderer/compositor.ts +27 -4
  111. package/src/renderer/diff.ts +61 -18
  112. package/src/renderer/fullscreen-primitives.ts +37 -18
  113. package/src/renderer/markdown.ts +20 -10
  114. package/src/renderer/modal-factory.ts +25 -15
  115. package/src/renderer/overlay-box.ts +23 -12
  116. package/src/renderer/process-indicator.ts +8 -3
  117. package/src/renderer/prompt-content-width.ts +16 -0
  118. package/src/renderer/settings-modal-helpers.ts +2 -0
  119. package/src/renderer/settings-modal.ts +2 -0
  120. package/src/renderer/startup-theme-probe.ts +35 -0
  121. package/src/renderer/status-glyphs.ts +11 -15
  122. package/src/renderer/system-message.ts +17 -2
  123. package/src/renderer/term-caps.ts +318 -0
  124. package/src/renderer/terminal-bg-probe.ts +373 -0
  125. package/src/renderer/terminal-escapes.ts +24 -0
  126. package/src/renderer/theme-mode-config.ts +87 -0
  127. package/src/renderer/theme.ts +241 -0
  128. package/src/renderer/thinking.ts +12 -3
  129. package/src/renderer/tool-call.ts +7 -3
  130. package/src/renderer/ui-factory.ts +92 -36
  131. package/src/renderer/ui-primitives.ts +33 -93
  132. package/src/runtime/bootstrap-core.ts +15 -0
  133. package/src/runtime/bootstrap-hook-bridge.ts +6 -0
  134. package/src/runtime/bootstrap-shell.ts +2 -0
  135. package/src/runtime/bootstrap.ts +68 -5
  136. package/src/runtime/calendar-boot-refresh.ts +105 -0
  137. package/src/runtime/lan-scan-consent.ts +253 -0
  138. package/src/runtime/services.ts +127 -2
  139. package/src/runtime/session-spine-rest-transport.ts +160 -0
  140. package/src/runtime/terminal-output-guard.ts +6 -1
  141. package/src/runtime/ui-services.ts +3 -0
  142. package/src/shell/agent-workspace-fullscreen.ts +5 -0
  143. package/src/shell/terminal-focus-mode.ts +120 -0
  144. package/src/shell/ui-openers.ts +13 -4
  145. package/src/tools/agent-harness-autonomy-intake.ts +5 -5
  146. package/src/tools/agent-harness-autonomy-queue.ts +1 -1
  147. package/src/tools/agent-harness-background-processes.ts +2 -1
  148. package/src/tools/agent-harness-metadata.ts +16 -8
  149. package/src/tools/agent-harness-operator-methods.ts +44 -8
  150. package/src/tools/agent-harness-personal-ops-discovery.ts +57 -10
  151. package/src/tools/agent-harness-personal-ops-lanes.ts +17 -5
  152. package/src/tools/agent-harness-personal-ops-operations.ts +7 -7
  153. package/src/tools/agent-harness-personal-ops-types.ts +6 -0
  154. package/src/tools/agent-harness-prompt-context.ts +26 -12
  155. package/src/tools/agent-harness-workspace-actions.ts +4 -0
  156. package/src/tools/agent-local-registry-args.ts +117 -0
  157. package/src/tools/agent-local-registry-memory.ts +227 -0
  158. package/src/tools/agent-local-registry-tool.ts +19 -237
  159. package/src/tools/agent-operator-briefing-tool.ts +2 -2
  160. package/src/tools/agent-operator-method-tool.ts +13 -0
  161. package/src/tools/agent-policy-explanation.ts +39 -4
  162. package/src/tools/agent-schedule-tool.ts +5 -5
  163. package/src/utils/terminal-width.ts +98 -1
  164. package/src/version.ts +1 -1
  165. package/src/cli/local-library-command.ts +0 -825
@@ -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
+ 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,373 @@
1
+ /**
2
+ * terminal-bg-probe — OSC 11 terminal-background detection for `auto` theme mode.
3
+ *
4
+ * On startup, when appearance is `auto` and stdout is a TTY, we ask the terminal
5
+ * for its background colour with an OSC 11 query and classify the reply as light
6
+ * or dark. Everything here is best-effort and conservative: any timeout, any
7
+ * unparseable reply, any ambiguity resolves to DARK, the historical default,
8
+ * which keeps headless/unsupported terminals byte-stable.
9
+ *
10
+ * Timing (render dark first, repaint once if light wins): the probe never blocks
11
+ * startup. The first frame renders in dark; if a light reply arrives within the
12
+ * timeout the caller flips the active mode to light and repaints ONCE. This adds
13
+ * zero startup latency and costs at most one dark→light repaint on genuinely
14
+ * light terminals.
15
+ *
16
+ * Stream safety (never corrupt the composer): the reply arrives on the same
17
+ * stdin the keyboard uses. filterInput() sits at the very front of the stdin
18
+ * handler. It passes every non-OSC-11 byte straight through — interleaved
19
+ * keystrokes reach the tokenizer untouched — and consumes ONLY a matched OSC 11
20
+ * reply (whole or split across chunks). On timeout, any buffered reply fragment
21
+ * is discarded rather than flushed, so partial/garbled bytes never leak into the
22
+ * input pipeline.
23
+ *
24
+ * tmux: when $TMUX is set the query is wrapped in the DCS passthrough envelope so
25
+ * the outer terminal sees it. This only elicits a reply if `allow-passthrough`
26
+ * is enabled; otherwise the query is swallowed and the timeout fallback (dark)
27
+ * covers it silently.
28
+ *
29
+ * W4-R2 adaptation (agent): the theme-application coupling is INJECTED rather
30
+ * than imported from theme.ts. R2 owns this module; R4 owns the theme system
31
+ * (theme.ts / theme-mode-config.ts) and wires installBackgroundThemeProbe with
32
+ * `applyThemeMode: setActiveThemeMode`. Keeping the coupling injected lets R2
33
+ * land the probe (and its stream-safety guarantees) before R4's theme.ts exists.
34
+ */
35
+
36
+ import type { ConfigKey, ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
37
+
38
+ /** Resolved appearance mode. R4's theme.ts converges on this literal union. */
39
+ export type ThemeMode = 'light' | 'dark';
40
+
41
+ /** The stored appearance preference (config `display.themeMode`). */
42
+ export type ThemeModeSetting = 'auto' | ThemeMode;
43
+
44
+ /** Config key backing the appearance/theme-mode preference. */
45
+ export const THEME_MODE_CONFIG_KEY = 'display.themeMode';
46
+
47
+ /** Default when unset: probe the terminal background on startup. */
48
+ export const THEME_MODE_DEFAULT: ThemeModeSetting = 'auto';
49
+
50
+ /** Narrow an unknown config value to a valid ThemeModeSetting, else the default. */
51
+ export function coerceThemeModeSetting(raw: unknown): ThemeModeSetting {
52
+ return raw === 'auto' || raw === 'dark' || raw === 'light' ? raw : THEME_MODE_DEFAULT;
53
+ }
54
+
55
+ /**
56
+ * Read the configured theme-mode preference. `display` exists in the SDK
57
+ * DEFAULT_CONFIG, so get() returns undefined for the absent field rather than
58
+ * throwing; any unexpected error resolves to the honest default.
59
+ */
60
+ export function resolveConfiguredThemeMode(
61
+ configManager: Pick<ConfigManager, 'get'>,
62
+ ): ThemeModeSetting {
63
+ try {
64
+ return coerceThemeModeSetting(configManager.get(THEME_MODE_CONFIG_KEY as ConfigKey));
65
+ } catch {
66
+ return THEME_MODE_DEFAULT;
67
+ }
68
+ }
69
+
70
+ /** OSC 11 "query background colour" request, ST-terminated. */
71
+ export const OSC11_QUERY = '\x1b]11;?\x1b\\';
72
+
73
+ /** The response prefix we scan for: ESC ] 1 1 ; */
74
+ const OSC11_PREFIX = '\x1b]11;';
75
+
76
+ /** Default probe window. Cooperating terminals reply in single-digit ms. */
77
+ export const DEFAULT_PROBE_TIMEOUT_MS = 150;
78
+
79
+ /** Luminance split point (normalized 0..1). At/above → light; below → dark. */
80
+ export const LUMINANCE_LIGHT_THRESHOLD = 0.5;
81
+
82
+ /** An 8-bit-per-channel colour parsed from an OSC 11 reply. */
83
+ export interface ProbeRgb {
84
+ readonly r: number;
85
+ readonly g: number;
86
+ readonly b: number;
87
+ }
88
+
89
+ // ---------------------------------------------------------------------------
90
+ // Pure parsing / classification (exercised directly by the fake-terminal harness)
91
+ // ---------------------------------------------------------------------------
92
+
93
+ /**
94
+ * Parse a single hex channel of `len` digits into an 8-bit value.
95
+ * Scales by the channel's own full-scale (16^len - 1) so `ffff`→255, `ff`→255,
96
+ * `80`→128, `8000`→128. Returns null on non-hex input.
97
+ */
98
+ function parseHexChannel(hex: string): number | null {
99
+ if (hex.length === 0 || !/^[0-9a-fA-F]+$/.test(hex)) return null;
100
+ const max = 16 ** hex.length - 1;
101
+ return Math.round((parseInt(hex, 16) / max) * 255);
102
+ }
103
+
104
+ /**
105
+ * Parse an OSC 11 colour spec into 8-bit RGB. Accepts:
106
+ * - `rgb:RRRR/GGGG/BBBB` and `rgb:RR/GG/BB` (1–4 hex digits per channel)
107
+ * - `rgba:R/G/B/A` (alpha ignored)
108
+ * - `#RRGGBB` and `#RRRRGGGGBBBB`
109
+ * Returns null for anything else (→ caller treats as dark).
110
+ */
111
+ export function parseColorSpec(spec: string): ProbeRgb | null {
112
+ const trimmed = spec.trim();
113
+
114
+ if (trimmed.startsWith('rgb:') || trimmed.startsWith('rgba:')) {
115
+ const body = trimmed.slice(trimmed.indexOf(':') + 1);
116
+ const parts = body.split('/');
117
+ if (parts.length < 3) return null;
118
+ const r = parseHexChannel(parts[0]!);
119
+ const g = parseHexChannel(parts[1]!);
120
+ const b = parseHexChannel(parts[2]!);
121
+ if (r === null || g === null || b === null) return null;
122
+ return { r, g, b };
123
+ }
124
+
125
+ if (trimmed.startsWith('#')) {
126
+ const hex = trimmed.slice(1);
127
+ if (hex.length !== 6 && hex.length !== 12) return null;
128
+ if (!/^[0-9a-fA-F]+$/.test(hex)) return null;
129
+ const per = hex.length / 3;
130
+ const r = parseHexChannel(hex.slice(0, per));
131
+ const g = parseHexChannel(hex.slice(per, per * 2));
132
+ const b = parseHexChannel(hex.slice(per * 2));
133
+ if (r === null || g === null || b === null) return null;
134
+ return { r, g, b };
135
+ }
136
+
137
+ return null;
138
+ }
139
+
140
+ /**
141
+ * Classify a background colour as 'light' or 'dark' by relative luminance.
142
+ * Uses the Rec. 601 luma weights (0.299/0.587/0.114) normalized to 0..1 and
143
+ * splits at LUMINANCE_LIGHT_THRESHOLD (0.5). Conservative by construction — the
144
+ * exact threshold only matters for mid-grey backgrounds, which are rare.
145
+ */
146
+ export function classifyBackgroundLuminance(rgb: ProbeRgb): ThemeMode {
147
+ const luma = (0.299 * rgb.r + 0.587 * rgb.g + 0.114 * rgb.b) / 255;
148
+ return luma >= LUMINANCE_LIGHT_THRESHOLD ? 'light' : 'dark';
149
+ }
150
+
151
+ /** Where a terminator starts and where the byte after it is, within a buffer. */
152
+ interface TerminatorSpan {
153
+ readonly start: number;
154
+ readonly end: number;
155
+ }
156
+
157
+ /**
158
+ * Find the OSC string terminator at/after `from`: BEL (\x07) or ST (ESC \).
159
+ * Returns null if no terminator is present yet (reply still arriving). A lone
160
+ * trailing ESC returns null (wait for the next byte to tell ST from a stray ESC);
161
+ * an ESC followed by a non-'\' byte is treated as the end boundary.
162
+ */
163
+ function findTerminator(buffer: string, from: number): TerminatorSpan | null {
164
+ for (let k = from; k < buffer.length; k++) {
165
+ const c = buffer[k];
166
+ if (c === '\x07') return { start: k, end: k + 1 };
167
+ if (c === '\x1b') {
168
+ if (k + 1 >= buffer.length) return null;
169
+ return buffer[k + 1] === '\\' ? { start: k, end: k + 2 } : { start: k, end: k + 1 };
170
+ }
171
+ }
172
+ return null;
173
+ }
174
+
175
+ /**
176
+ * Longest suffix of `buffer` that is a PROPER prefix of OSC11_PREFIX, used to
177
+ * hold back a partial reply-prefix split across chunk boundaries (e.g. a chunk
178
+ * ending in a bare ESC that might become `\x1b]11;`). Only consulted when the
179
+ * full prefix is not present in the buffer.
180
+ */
181
+ function trailingPrefixLen(buffer: string): number {
182
+ const maxLen = Math.min(OSC11_PREFIX.length - 1, buffer.length);
183
+ for (let len = maxLen; len >= 1; len--) {
184
+ if (buffer.endsWith(OSC11_PREFIX.slice(0, len))) return len;
185
+ }
186
+ return 0;
187
+ }
188
+
189
+ // ---------------------------------------------------------------------------
190
+ // TerminalBackgroundProbe — the stateful stream filter + timing
191
+ // ---------------------------------------------------------------------------
192
+
193
+ export interface ProbeResolution {
194
+ readonly mode: ThemeMode;
195
+ readonly reason: 'light-reply' | 'dark-reply' | 'unparseable' | 'timeout';
196
+ }
197
+
198
+ export interface TerminalBackgroundProbeOptions {
199
+ /** Called exactly once with the resolved mode + reason. */
200
+ readonly onResolve: (result: ProbeResolution) => void;
201
+ /** Probe window in ms (default DEFAULT_PROBE_TIMEOUT_MS). */
202
+ readonly timeoutMs?: number;
203
+ }
204
+
205
+ /**
206
+ * Stateful OSC 11 reply filter. feed() takes a raw stdin chunk and returns the
207
+ * bytes that should continue down the input pipeline (everything except a matched
208
+ * OSC 11 reply). Resolves once — on the first complete reply or on timeout.
209
+ */
210
+ export class TerminalBackgroundProbe {
211
+ /** True until resolved. filterInput() only calls feed() while active. */
212
+ public active = true;
213
+
214
+ private buffer = '';
215
+ private resolved = false;
216
+ private timer: ReturnType<typeof setTimeout> | null = null;
217
+ private readonly onResolve: (result: ProbeResolution) => void;
218
+ private readonly timeoutMs: number;
219
+
220
+ constructor(options: TerminalBackgroundProbeOptions) {
221
+ this.onResolve = options.onResolve;
222
+ this.timeoutMs = options.timeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS;
223
+ }
224
+
225
+ /** Arm the timeout fallback. Call after writing the query. */
226
+ startTimeout(): void {
227
+ this.timer = setTimeout(() => this.resolve('dark', 'timeout'), this.timeoutMs);
228
+ // Never keep the event loop alive for the probe timer.
229
+ (this.timer as unknown as { unref?: () => void }).unref?.();
230
+ }
231
+
232
+ /**
233
+ * Consume a raw stdin chunk. Returns the passthrough bytes (keystrokes and any
234
+ * non-OSC-11 sequences), having stripped and acted on any OSC 11 reply.
235
+ */
236
+ feed(chunk: string): string {
237
+ if (!this.active) return chunk;
238
+ this.buffer += chunk;
239
+ let out = '';
240
+
241
+ for (;;) {
242
+ const prefixIdx = this.buffer.indexOf(OSC11_PREFIX);
243
+ if (prefixIdx === -1) {
244
+ // No full reply prefix. Release everything except a possible partial
245
+ // prefix at the tail (held back to disambiguate next chunk).
246
+ const keep = trailingPrefixLen(this.buffer);
247
+ out += this.buffer.slice(0, this.buffer.length - keep);
248
+ this.buffer = keep > 0 ? this.buffer.slice(this.buffer.length - keep) : '';
249
+ break;
250
+ }
251
+
252
+ // Release bytes before the reply (interleaved keystrokes survive).
253
+ out += this.buffer.slice(0, prefixIdx);
254
+ const term = findTerminator(this.buffer, prefixIdx + OSC11_PREFIX.length);
255
+ if (term === null) {
256
+ // Incomplete reply: hold from the prefix onward, await more bytes.
257
+ this.buffer = this.buffer.slice(prefixIdx);
258
+ break;
259
+ }
260
+
261
+ // Complete reply. Capture the post-reply remainder BEFORE resolving
262
+ // (resolve() clears the buffer to discard reply residue, so any trailing
263
+ // keystrokes must be preserved here, not read back off this.buffer).
264
+ const spec = this.buffer.slice(prefixIdx + OSC11_PREFIX.length, term.start);
265
+ const remainder = this.buffer.slice(term.end);
266
+ const rgb = parseColorSpec(spec);
267
+ if (rgb === null) {
268
+ this.resolve('dark', 'unparseable');
269
+ } else {
270
+ const mode = classifyBackgroundLuminance(rgb);
271
+ this.resolve(mode, mode === 'light' ? 'light-reply' : 'dark-reply');
272
+ }
273
+ // Resolved (active is now false): forward the post-reply keystrokes and stop.
274
+ out += remainder;
275
+ break;
276
+ }
277
+
278
+ return out;
279
+ }
280
+
281
+ /** Resolve once; clears the timer and (on timeout) discards reply residue. */
282
+ private resolve(mode: ThemeMode, reason: ProbeResolution['reason']): void {
283
+ if (this.resolved) return;
284
+ this.resolved = true;
285
+ this.active = false;
286
+ if (this.timer !== null) {
287
+ clearTimeout(this.timer);
288
+ this.timer = null;
289
+ }
290
+ // Any bytes still buffered here are an incomplete/garbled reply fragment —
291
+ // discard them so they can never leak into the composer.
292
+ this.buffer = '';
293
+ this.onResolve({ mode, reason });
294
+ }
295
+ }
296
+
297
+ // ---------------------------------------------------------------------------
298
+ // tmux passthrough + install entry
299
+ // ---------------------------------------------------------------------------
300
+
301
+ /** Wrap a control sequence in the tmux DCS passthrough envelope (ESC doubled). */
302
+ export function wrapForTmuxPassthrough(seq: string): string {
303
+ return `\x1bPtmux;${seq.replace(/\x1b/g, '\x1b\x1b')}\x1b\\`;
304
+ }
305
+
306
+ /** The stdin filter the caller installs at the front of its data handler. */
307
+ export interface ThemeProbeHandle {
308
+ /** Filter a raw stdin chunk; returns the bytes to forward downstream. */
309
+ filterInput(chunk: string): string;
310
+ }
311
+
312
+ export interface InstallThemeProbeOptions {
313
+ readonly configManager: Pick<ConfigManager, 'get'>;
314
+ /**
315
+ * Applies the resolved appearance mode (R2 injection point — R4 passes its
316
+ * theme.ts setActiveThemeMode). Called before the first paint for a forced
317
+ * mode, or once on a light OSC 11 reply.
318
+ */
319
+ readonly applyThemeMode: (mode: ThemeMode) => void;
320
+ /** Whether stdout is a real TTY (probe only runs on a TTY in auto mode). */
321
+ readonly isTTY: boolean;
322
+ /** Process env (only TMUX is read). */
323
+ readonly env: Record<string, string | undefined>;
324
+ /** Writes the query bytes (caller wraps in its terminal-output guard). */
325
+ readonly writeQuery: (bytes: string) => void;
326
+ /** Called when a light reply wins within the window → repaint once. */
327
+ readonly requestRepaint: () => void;
328
+ /** Optional timeout override (tests). */
329
+ readonly timeoutMs?: number;
330
+ /** Optional resolution observer (tests / diagnostics). */
331
+ readonly onResolve?: (result: ProbeResolution) => void;
332
+ }
333
+
334
+ /**
335
+ * Read the appearance preference and set up the active theme mode:
336
+ * - dark/light → apply immediately (before first paint); no probe.
337
+ * - auto + TTY → leave dark, fire the OSC 11 query, and on a light reply apply
338
+ * light + repaint once.
339
+ * - auto + !TTY → stay dark (headless/piped; probe cannot run).
340
+ * Returns a handle whose filterInput() the caller wires at the front of its
341
+ * stdin data handler. For non-auto and non-TTY cases the filter is a passthrough.
342
+ */
343
+ export function installBackgroundThemeProbe(options: InstallThemeProbeOptions): ThemeProbeHandle {
344
+ const pref = resolveConfiguredThemeMode(options.configManager);
345
+
346
+ if (pref === 'dark' || pref === 'light') {
347
+ options.applyThemeMode(pref);
348
+ return { filterInput: (chunk) => chunk };
349
+ }
350
+
351
+ // pref === 'auto'
352
+ if (!options.isTTY) {
353
+ options.applyThemeMode('dark');
354
+ return { filterInput: (chunk) => chunk };
355
+ }
356
+
357
+ const probe = new TerminalBackgroundProbe({
358
+ timeoutMs: options.timeoutMs,
359
+ onResolve: (result) => {
360
+ if (result.mode === 'light') {
361
+ options.applyThemeMode('light');
362
+ options.requestRepaint();
363
+ }
364
+ options.onResolve?.(result);
365
+ },
366
+ });
367
+
368
+ const query = options.env['TMUX'] ? wrapForTmuxPassthrough(OSC11_QUERY) : OSC11_QUERY;
369
+ options.writeQuery(query);
370
+ probe.startTimeout();
371
+
372
+ return { filterInput: (chunk) => (probe.active ? probe.feed(chunk) : chunk) };
373
+ }