@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.
- package/CHANGELOG.md +9 -0
- package/README.md +1 -1
- package/dist/package/main.js +94130 -79335
- package/docs/tools-and-commands.md +2 -1
- package/package.json +6 -3
- package/src/agent/autonomy-schedule-format.ts +1 -1
- package/src/agent/autonomy-schedule.ts +3 -3
- package/src/agent/calendar/calendar-oauth-service.ts +324 -0
- package/src/agent/calendar-subscription-registry.ts +497 -0
- package/src/agent/email/email-service.ts +57 -0
- package/src/agent/email/smtp-client.ts +30 -10
- package/src/agent/memory-prompt.ts +143 -12
- package/src/agent/operator-actions.ts +26 -22
- package/src/agent/prompt-context-receipts.ts +45 -18
- package/src/agent/reminder-schedule-format.ts +1 -1
- package/src/agent/reminder-schedule.ts +3 -3
- package/src/agent/routine-schedule-format.ts +2 -2
- package/src/agent/routine-schedule-promotion.ts +5 -5
- package/src/agent/routine-schedule-receipts.ts +2 -2
- package/src/agent/schedule-edit-format.ts +1 -1
- package/src/agent/schedule-edit.ts +5 -5
- package/src/agent/session-registration.ts +281 -0
- package/src/agent/vibe-file.ts +50 -0
- package/src/cli/local-library-command-shared.ts +146 -0
- package/src/cli/management.ts +2 -1
- package/src/cli/personas-command.ts +252 -0
- package/src/cli/resume-relaunch-notice.ts +151 -0
- package/src/cli/routines-command.ts +25 -2
- package/src/cli/service-posture.ts +3 -2
- package/src/cli/skill-bundle-command.ts +175 -0
- package/src/cli/skills-command.ts +309 -0
- package/src/cli/status.ts +43 -7
- package/src/cli/tui-startup.ts +24 -1
- package/src/config/agent-settings-policy.ts +0 -1
- package/src/config/secret-config.ts +4 -0
- package/src/core/conversation-rendering.ts +20 -11
- package/src/core/focus-tracker.ts +41 -0
- package/src/core/system-message-noise.ts +108 -0
- package/src/core/system-message-router.ts +54 -1
- package/src/core/thinking-overlay.ts +83 -0
- package/src/input/agent-workspace-access-command-editor-submission.ts +75 -108
- package/src/input/agent-workspace-access-command-editors.ts +108 -128
- package/src/input/agent-workspace-activation.ts +15 -0
- package/src/input/agent-workspace-basic-command-editor-submission.ts +292 -513
- package/src/input/agent-workspace-basic-command-editors.ts +433 -564
- package/src/input/agent-workspace-calendar-connect-editor.ts +116 -0
- package/src/input/agent-workspace-calendar-oauth-editor.ts +152 -0
- package/src/input/agent-workspace-calendar-subscribe-editor.ts +128 -0
- package/src/input/agent-workspace-categories.ts +7 -2
- package/src/input/agent-workspace-channel-command-editor-submission.ts +38 -52
- package/src/input/agent-workspace-channel-command-editors.ts +42 -46
- package/src/input/agent-workspace-command-editor-engine.ts +133 -0
- package/src/input/agent-workspace-command-editor.ts +4 -0
- package/src/input/agent-workspace-direct-editor-submission.ts +59 -0
- package/src/input/agent-workspace-email-connect-editor.ts +162 -0
- package/src/input/agent-workspace-knowledge-command-editor-submission.ts +52 -81
- package/src/input/agent-workspace-knowledge-command-editors.ts +71 -71
- package/src/input/agent-workspace-library-command-editor-submission.ts +10 -28
- package/src/input/agent-workspace-library-command-editors.ts +16 -2
- package/src/input/agent-workspace-live-counters.ts +55 -0
- package/src/input/agent-workspace-mcp-command-editor-submission.ts +60 -82
- package/src/input/agent-workspace-mcp-command-editors.ts +52 -0
- package/src/input/agent-workspace-media-command-editor-submission.ts +12 -39
- package/src/input/agent-workspace-media-command-editors.ts +10 -8
- package/src/input/agent-workspace-memory-command-editor-submission.ts +76 -151
- package/src/input/agent-workspace-memory-command-editors.ts +116 -141
- package/src/input/agent-workspace-operations-command-editor-submission.ts +131 -184
- package/src/input/agent-workspace-operations-command-editors.ts +150 -162
- package/src/input/agent-workspace-provider-command-editor-submission.ts +60 -106
- package/src/input/agent-workspace-provider-command-editors.ts +58 -68
- package/src/input/agent-workspace-search.ts +8 -1
- package/src/input/agent-workspace-session-command-editor-submission.ts +104 -132
- package/src/input/agent-workspace-session-command-editors.ts +160 -195
- package/src/input/agent-workspace-settings.ts +40 -1
- package/src/input/agent-workspace-skill-bundle-command-editor-submission.ts +55 -71
- package/src/input/agent-workspace-skill-bundle-command-editors.ts +65 -69
- package/src/input/agent-workspace-snapshot-builders.ts +431 -0
- package/src/input/agent-workspace-snapshot-config.ts +43 -0
- package/src/input/agent-workspace-snapshot.ts +198 -432
- package/src/input/agent-workspace-task-command-editor-submission.ts +34 -44
- package/src/input/agent-workspace-task-command-editors.ts +27 -28
- package/src/input/agent-workspace-types.ts +35 -1
- package/src/input/agent-workspace.ts +5 -14
- package/src/input/command-registry.ts +23 -1
- package/src/input/commands/calendar-connect-runtime.ts +226 -0
- package/src/input/commands/calendar-runtime.ts +110 -7
- package/src/input/commands/calendar-subscription-runtime.ts +225 -0
- package/src/input/commands/email-runtime.ts +100 -40
- package/src/input/commands/knowledge.ts +1 -1
- package/src/input/commands/network-scan-runtime.ts +75 -0
- package/src/input/commands/operator-actions-runtime.ts +9 -6
- package/src/input/commands/personas-runtime.ts +1 -1
- package/src/input/commands/schedule-runtime.ts +4 -4
- package/src/input/commands/session-content.ts +13 -1
- package/src/input/commands/session-workflow.ts +15 -20
- package/src/input/commands/session.ts +3 -1
- package/src/input/commands/shell-core.ts +20 -6
- package/src/input/commands.ts +2 -2
- package/src/input/delete-key-policy.ts +46 -0
- package/src/input/feed-context-factory.ts +10 -0
- package/src/input/handler-feed.ts +87 -0
- package/src/input/handler-modal-routes.ts +6 -1
- package/src/input/handler.ts +5 -0
- package/src/input/panel-paste-flood-guard.ts +94 -0
- package/src/input/settings-modal-types.ts +5 -3
- package/src/input/settings-modal.ts +21 -0
- package/src/main.ts +36 -36
- package/src/permissions/approval-posture.ts +141 -0
- package/src/renderer/agent-workspace-context-lines.ts +10 -2
- package/src/renderer/compositor.ts +27 -4
- package/src/renderer/diff.ts +61 -18
- package/src/renderer/fullscreen-primitives.ts +37 -18
- package/src/renderer/markdown.ts +20 -10
- package/src/renderer/modal-factory.ts +25 -15
- package/src/renderer/overlay-box.ts +23 -12
- package/src/renderer/process-indicator.ts +8 -3
- package/src/renderer/prompt-content-width.ts +16 -0
- package/src/renderer/settings-modal-helpers.ts +2 -0
- package/src/renderer/settings-modal.ts +2 -0
- package/src/renderer/startup-theme-probe.ts +35 -0
- package/src/renderer/status-glyphs.ts +11 -15
- package/src/renderer/system-message.ts +17 -2
- package/src/renderer/term-caps.ts +318 -0
- package/src/renderer/terminal-bg-probe.ts +373 -0
- package/src/renderer/terminal-escapes.ts +24 -0
- package/src/renderer/theme-mode-config.ts +87 -0
- package/src/renderer/theme.ts +241 -0
- package/src/renderer/thinking.ts +12 -3
- package/src/renderer/tool-call.ts +7 -3
- package/src/renderer/ui-factory.ts +92 -36
- package/src/renderer/ui-primitives.ts +33 -93
- package/src/runtime/bootstrap-core.ts +15 -0
- package/src/runtime/bootstrap-hook-bridge.ts +6 -0
- package/src/runtime/bootstrap-shell.ts +2 -0
- package/src/runtime/bootstrap.ts +68 -5
- package/src/runtime/calendar-boot-refresh.ts +105 -0
- package/src/runtime/lan-scan-consent.ts +253 -0
- package/src/runtime/services.ts +127 -2
- package/src/runtime/session-spine-rest-transport.ts +160 -0
- package/src/runtime/terminal-output-guard.ts +6 -1
- package/src/runtime/ui-services.ts +3 -0
- package/src/shell/agent-workspace-fullscreen.ts +5 -0
- package/src/shell/terminal-focus-mode.ts +120 -0
- package/src/shell/ui-openers.ts +13 -4
- package/src/tools/agent-harness-autonomy-intake.ts +5 -5
- package/src/tools/agent-harness-autonomy-queue.ts +1 -1
- package/src/tools/agent-harness-background-processes.ts +2 -1
- package/src/tools/agent-harness-metadata.ts +16 -8
- package/src/tools/agent-harness-operator-methods.ts +44 -8
- package/src/tools/agent-harness-personal-ops-discovery.ts +57 -10
- package/src/tools/agent-harness-personal-ops-lanes.ts +17 -5
- package/src/tools/agent-harness-personal-ops-operations.ts +7 -7
- package/src/tools/agent-harness-personal-ops-types.ts +6 -0
- package/src/tools/agent-harness-prompt-context.ts +26 -12
- package/src/tools/agent-harness-workspace-actions.ts +4 -0
- package/src/tools/agent-local-registry-args.ts +117 -0
- package/src/tools/agent-local-registry-memory.ts +227 -0
- package/src/tools/agent-local-registry-tool.ts +19 -237
- package/src/tools/agent-operator-briefing-tool.ts +2 -2
- package/src/tools/agent-operator-method-tool.ts +13 -0
- package/src/tools/agent-policy-explanation.ts +39 -4
- package/src/tools/agent-schedule-tool.ts +5 -5
- package/src/utils/terminal-width.ts +98 -1
- package/src/version.ts +1 -1
- 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
|
+
}
|