@bastani/atomic 0.6.6-1 → 0.6.7-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 (53) hide show
  1. package/README.md +22 -16
  2. package/dist/sdk/components/compact-switcher.d.ts.map +1 -1
  3. package/dist/sdk/components/connectors.d.ts +1 -0
  4. package/dist/sdk/components/connectors.d.ts.map +1 -1
  5. package/dist/sdk/components/edge.d.ts +1 -1
  6. package/dist/sdk/components/edge.d.ts.map +1 -1
  7. package/dist/sdk/components/graph-theme.d.ts.map +1 -1
  8. package/dist/sdk/components/header.d.ts.map +1 -1
  9. package/dist/sdk/components/node-card.d.ts.map +1 -1
  10. package/dist/sdk/components/orchestrator-panel.d.ts +7 -1
  11. package/dist/sdk/components/orchestrator-panel.d.ts.map +1 -1
  12. package/dist/sdk/components/renderer-background.d.ts +9 -0
  13. package/dist/sdk/components/renderer-background.d.ts.map +1 -0
  14. package/dist/sdk/components/session-graph-panel.d.ts.map +1 -1
  15. package/dist/sdk/components/statusline.d.ts.map +1 -1
  16. package/dist/sdk/components/tui-diagnostics.d.ts +56 -0
  17. package/dist/sdk/components/tui-diagnostics.d.ts.map +1 -0
  18. package/dist/sdk/components/workflow-picker-panel.d.ts +2 -1
  19. package/dist/sdk/components/workflow-picker-panel.d.ts.map +1 -1
  20. package/dist/sdk/providers/copilot.d.ts +3 -2
  21. package/dist/sdk/providers/copilot.d.ts.map +1 -1
  22. package/dist/sdk/runtime/executor.d.ts.map +1 -1
  23. package/dist/sdk/runtime/theme.d.ts +4 -0
  24. package/dist/sdk/runtime/theme.d.ts.map +1 -1
  25. package/dist/theme/colors.d.ts +2 -0
  26. package/dist/theme/colors.d.ts.map +1 -1
  27. package/package.json +2 -1
  28. package/src/cli.ts +3 -3
  29. package/src/commands/cli/chat/index.ts +10 -4
  30. package/src/commands/cli/management-commands.ts +4 -3
  31. package/src/commands/cli/session.test.ts +79 -6
  32. package/src/commands/cli/session.ts +65 -9
  33. package/src/completions/fish.ts +9 -3
  34. package/src/completions/powershell.ts +27 -3
  35. package/src/completions/zsh.ts +9 -2
  36. package/src/sdk/components/compact-switcher.tsx +10 -5
  37. package/src/sdk/components/connectors.ts +4 -0
  38. package/src/sdk/components/edge.tsx +5 -3
  39. package/src/sdk/components/graph-theme.ts +2 -3
  40. package/src/sdk/components/header.tsx +21 -9
  41. package/src/sdk/components/node-card.tsx +13 -7
  42. package/src/sdk/components/orchestrator-panel.tsx +47 -2
  43. package/src/sdk/components/renderer-background.ts +49 -0
  44. package/src/sdk/components/session-graph-panel.tsx +9 -2
  45. package/src/sdk/components/statusline.tsx +26 -22
  46. package/src/sdk/components/tui-diagnostics.ts +273 -0
  47. package/src/sdk/components/workflow-picker-panel.tsx +33 -22
  48. package/src/sdk/providers/copilot.ts +10 -4
  49. package/src/sdk/runtime/executor.ts +28 -1
  50. package/src/sdk/runtime/theme.ts +28 -36
  51. package/src/services/system/install-ui.ts +16 -17
  52. package/src/theme/colors.ts +14 -9
  53. package/src/theme/logo.ts +23 -12
@@ -14,20 +14,39 @@ import { StoreContext, ThemeContext, TmuxSessionContext } from "./orchestrator-p
14
14
  import type { PanelSession, PanelOptions, SessionData } from "./orchestrator-panel-types.ts";
15
15
  import { SessionGraphPanel } from "./session-graph-panel.tsx";
16
16
  import { ErrorBoundary } from "./error-boundary.tsx";
17
+ import {
18
+ requestRendererBackgroundRepaint,
19
+ resetRendererTerminalBackground,
20
+ setRendererBackground,
21
+ } from "./renderer-background.ts";
22
+ import { createTuiDiagnostics, type TuiDiagnostics } from "./tui-diagnostics.ts";
17
23
 
18
24
  export class OrchestratorPanel {
19
25
  private store: PanelStore;
20
26
  private renderer: CliRenderer;
21
27
  private destroyed = false;
28
+ private terminalBackgroundSynced: boolean;
29
+ private diagnostics: TuiDiagnostics | null = null;
30
+ private unsubscribeDiagnostics: (() => void) | null = null;
22
31
 
23
32
  private constructor(
24
33
  renderer: CliRenderer,
25
34
  store: PanelStore,
26
35
  graphTheme: GraphTheme,
27
36
  tmuxSession: string,
37
+ terminalBackgroundSynced: boolean,
28
38
  ) {
29
39
  this.renderer = renderer;
30
40
  this.store = store;
41
+ this.terminalBackgroundSynced = terminalBackgroundSynced;
42
+ this.diagnostics = createTuiDiagnostics({
43
+ renderer,
44
+ graphTheme,
45
+ getSnapshot: () => this.getDiagnosticSnapshot(),
46
+ });
47
+ this.unsubscribeDiagnostics = this.diagnostics
48
+ ? store.subscribe(() => this.diagnostics?.capture("store-update"))
49
+ : null;
31
50
 
32
51
  createRoot(renderer).render(
33
52
  <StoreContext.Provider value={store}>
@@ -56,6 +75,8 @@ export class OrchestratorPanel {
56
75
  </ThemeContext.Provider>
57
76
  </StoreContext.Provider>,
58
77
  );
78
+ requestRendererBackgroundRepaint(this.renderer);
79
+ this.diagnostics?.capture("post-mount");
59
80
  }
60
81
 
61
82
  /**
@@ -69,18 +90,20 @@ export class OrchestratorPanel {
69
90
  exitOnCtrlC: false,
70
91
  exitSignals: ["SIGTERM", "SIGQUIT", "SIGABRT", "SIGHUP", "SIGPIPE", "SIGBUS", "SIGFPE"],
71
92
  });
72
- return OrchestratorPanel.createWithRenderer(renderer, options);
93
+ return OrchestratorPanel.createWithRenderer(renderer, options, { syncTerminalBackground: true });
73
94
  }
74
95
 
75
96
  /** Create with an externally-provided renderer (e.g. a test renderer). */
76
97
  static createWithRenderer(
77
98
  renderer: CliRenderer,
78
99
  options: PanelOptions,
100
+ { syncTerminalBackground = false }: { syncTerminalBackground?: boolean } = {},
79
101
  ): OrchestratorPanel {
80
102
  const termTheme = resolveTheme(renderer.themeMode);
103
+ setRendererBackground(renderer, termTheme.bg, { syncTerminalDefault: syncTerminalBackground });
81
104
  const graphTheme = deriveGraphTheme(termTheme);
82
105
  const store = new PanelStore();
83
- return new OrchestratorPanel(renderer, store, graphTheme, options.tmuxSession);
106
+ return new OrchestratorPanel(renderer, store, graphTheme, options.tmuxSession, syncTerminalBackground);
84
107
  }
85
108
 
86
109
  /**
@@ -175,7 +198,15 @@ export class OrchestratorPanel {
175
198
  destroy(): void {
176
199
  if (this.destroyed) return;
177
200
  this.destroyed = true;
201
+ this.unsubscribeDiagnostics?.();
202
+ this.unsubscribeDiagnostics = null;
203
+ this.diagnostics?.capture("destroy");
204
+ this.diagnostics?.dispose();
205
+ this.diagnostics = null;
178
206
  try {
207
+ if (this.terminalBackgroundSynced) {
208
+ resetRendererTerminalBackground(this.renderer);
209
+ }
179
210
  this.renderer.destroy();
180
211
  } catch {}
181
212
  }
@@ -214,4 +245,18 @@ export class OrchestratorPanel {
214
245
  sessions: this.store.sessions,
215
246
  };
216
247
  }
248
+
249
+ private getDiagnosticSnapshot() {
250
+ return {
251
+ workflowName: this.store.workflowName,
252
+ agent: this.store.agent,
253
+ prompt: this.store.prompt,
254
+ fatalError: this.store.fatalError,
255
+ completionReached: this.store.completionReached,
256
+ sessions: this.store.sessions,
257
+ backgroundTaskCount: this.store.backgroundTaskCount,
258
+ viewMode: this.store.viewMode,
259
+ activeAgentId: this.store.activeAgentId,
260
+ };
261
+ }
217
262
  }
@@ -0,0 +1,49 @@
1
+ import type { CliRenderer } from "@opentui/core";
2
+
3
+ export function setRendererBackground(
4
+ renderer: CliRenderer,
5
+ color: string,
6
+ { syncTerminalDefault = false }: { syncTerminalDefault?: boolean } = {},
7
+ ): void {
8
+ renderer.setBackgroundColor(color);
9
+ if (syncTerminalDefault) {
10
+ process.stdout.write(wrapForTmuxIfNeeded(terminalBackgroundColorSequence(color)));
11
+ }
12
+ }
13
+
14
+ export function requestRendererBackgroundRepaint(renderer: CliRenderer): void {
15
+ // OpenTUI 0.1.103+ no longer syncs the renderer background to the terminal
16
+ // default via OSC 11. Force the next frame so blank cells with this background
17
+ // are emitted instead of being skipped as unchanged initial buffer contents.
18
+ Object.assign(renderer, { forceFullRepaintRequested: true });
19
+ renderer.requestRender();
20
+ }
21
+
22
+ export function resetRendererTerminalBackground(renderer: CliRenderer): void {
23
+ if (process.env.TMUX) {
24
+ process.stdout.write(wrapForTmuxIfNeeded("\x1b]111\x07"));
25
+ return;
26
+ }
27
+
28
+ renderer.resetTerminalBgColor();
29
+ }
30
+
31
+ export function terminalBackgroundColorSequence(color: string): string {
32
+ const match = /^#?([0-9a-f]{6})$/i.exec(color);
33
+ if (!match) {
34
+ throw new Error(`Cannot sync terminal background for non-hex color: ${color}`);
35
+ }
36
+
37
+ const hex = match[1]!;
38
+ return `\x1b]11;rgb:${hex.slice(0, 2)}/${hex.slice(2, 4)}/${hex.slice(4, 6)}\x07`;
39
+ }
40
+
41
+ export function wrapForTmuxIfNeeded(sequence: string): string {
42
+ if (!process.env.TMUX) return sequence;
43
+
44
+ let escaped = "";
45
+ for (const char of sequence) {
46
+ escaped += char === "\x1b" ? "\x1b\x1b" : char;
47
+ }
48
+ return `\x1bPtmux;${escaped}\x1b\\`;
49
+ }
@@ -433,9 +433,16 @@ export function SessionGraphPanel() {
433
433
  },
434
434
  }}
435
435
  >
436
- <box width={canvasW} height={canvasH} position="relative">
436
+ <box width={canvasW} height={canvasH} position="relative" backgroundColor={theme.background}>
437
437
  {/* Offset all content by padding to center the graph */}
438
- <box position="absolute" left={padX} top={padY} width={layout.width} height={layout.height}>
438
+ <box
439
+ position="absolute"
440
+ left={padX}
441
+ top={padY}
442
+ width={layout.width}
443
+ height={layout.height}
444
+ backgroundColor={theme.background}
445
+ >
439
446
  {/* Connectors (rendered behind nodes) */}
440
447
  {connectors.map((conn, i) => (
441
448
  <Edge key={`e${i}`} {...conn} />
@@ -15,17 +15,19 @@ export function Statusline({
15
15
  <box height={1} flexDirection="row" backgroundColor={theme.backgroundElement}>
16
16
  {/* Mode badge — always GRAPH since this bar is only visible in the orchestrator window */}
17
17
  <box backgroundColor={theme.primary} paddingLeft={1} paddingRight={1} alignItems="center">
18
- <text fg={theme.backgroundElement}>
19
- <strong>GRAPH</strong>
18
+ <text>
19
+ <span fg={theme.backgroundElement} bg={theme.primary}>
20
+ <strong>GRAPH</strong>
21
+ </span>
20
22
  </text>
21
23
  </box>
22
24
 
23
25
  {store.backgroundTaskCount > 0 ? (
24
- <box backgroundColor="transparent" paddingLeft={1} alignItems="center">
26
+ <box backgroundColor={theme.backgroundElement} paddingLeft={1} alignItems="center">
25
27
  <text>
26
- <span fg={theme.textDim}>{"\u00B7"} </span>
27
- <span fg={theme.warning}>{"\u25C6"} </span>
28
- <span fg={theme.textMuted}>
28
+ <span fg={theme.textDim} bg={theme.backgroundElement}>{"\u00B7"} </span>
29
+ <span fg={theme.warning} bg={theme.backgroundElement}>{"\u25C6"} </span>
30
+ <span fg={theme.textMuted} bg={theme.backgroundElement}>
29
31
  {store.backgroundTaskCount} background
30
32
  </span>
31
33
  </text>
@@ -37,25 +39,27 @@ export function Statusline({
37
39
  {/* Navigation hints — always graph-mode (tmux status bar handles attached-mode hints) */}
38
40
  <box paddingRight={2} alignItems="center">
39
41
  {attachMsg ? (
40
- <text fg={theme.text}>
41
- <strong>{attachMsg}</strong>
42
+ <text>
43
+ <span fg={theme.text} bg={theme.backgroundElement}>
44
+ <strong>{attachMsg}</strong>
45
+ </span>
42
46
  </text>
43
47
  ) : (
44
48
  <text>
45
- <span fg={theme.text}>{"\u2191\u2193\u2190\u2192"}</span>
46
- <span fg={theme.textMuted}> navigate</span>
47
- <span fg={theme.textDim}> {"\u00B7"} </span>
48
- <span fg={theme.text}>{"\u21B5"}</span>
49
- <span fg={theme.textMuted}> attach</span>
50
- <span fg={theme.textDim}> {"\u00B7"} </span>
51
- <span fg={theme.text}>/</span>
52
- <span fg={theme.textMuted}> stages</span>
53
- <span fg={theme.textDim}> {"\u00B7"} </span>
54
- <span fg={theme.text}>ctrl+b d</span>
55
- <span fg={theme.textMuted}> detach</span>
56
- <span fg={theme.textDim}> {"\u00B7"} </span>
57
- <span fg={theme.text}>q</span>
58
- <span fg={theme.textMuted}> quit</span>
49
+ <span fg={theme.text} bg={theme.backgroundElement}>{"\u2191\u2193\u2190\u2192"}</span>
50
+ <span fg={theme.textMuted} bg={theme.backgroundElement}> navigate</span>
51
+ <span fg={theme.textDim} bg={theme.backgroundElement}> {"\u00B7"} </span>
52
+ <span fg={theme.text} bg={theme.backgroundElement}>{"\u21B5"}</span>
53
+ <span fg={theme.textMuted} bg={theme.backgroundElement}> attach</span>
54
+ <span fg={theme.textDim} bg={theme.backgroundElement}> {"\u00B7"} </span>
55
+ <span fg={theme.text} bg={theme.backgroundElement}>/</span>
56
+ <span fg={theme.textMuted} bg={theme.backgroundElement}> stages</span>
57
+ <span fg={theme.textDim} bg={theme.backgroundElement}> {"\u00B7"} </span>
58
+ <span fg={theme.text} bg={theme.backgroundElement}>ctrl+b d</span>
59
+ <span fg={theme.textMuted} bg={theme.backgroundElement}> detach</span>
60
+ <span fg={theme.textDim} bg={theme.backgroundElement}> {"\u00B7"} </span>
61
+ <span fg={theme.text} bg={theme.backgroundElement}>q</span>
62
+ <span fg={theme.textMuted} bg={theme.backgroundElement}> quit</span>
59
63
  </text>
60
64
  )}
61
65
  </box>
@@ -0,0 +1,273 @@
1
+ import { mkdirSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { tmpdir } from "node:os";
4
+ import type { CliRenderer, OptimizedBuffer } from "@opentui/core";
5
+ import type { GraphTheme } from "./graph-theme.ts";
6
+ import type { SessionData } from "./orchestrator-panel-types.ts";
7
+
8
+ type BackgroundRun = {
9
+ x: number;
10
+ width: number;
11
+ color: string;
12
+ };
13
+
14
+ type BufferRowDiagnostic = {
15
+ y: number;
16
+ text: string;
17
+ backgrounds: BackgroundRun[];
18
+ };
19
+
20
+ type ColorCount = {
21
+ color: string;
22
+ count: number;
23
+ percent: number;
24
+ };
25
+
26
+ export type BufferDiagnostic = {
27
+ width: number;
28
+ height: number;
29
+ topBackgrounds: ColorCount[];
30
+ yellowHueCells: number;
31
+ yellowHueSamples: Array<{ x: number; y: number; color: string; char: string }>;
32
+ rows: BufferRowDiagnostic[];
33
+ };
34
+
35
+ export type WorkflowDiagnosticSnapshot = {
36
+ workflowName: string;
37
+ agent: string;
38
+ prompt: string;
39
+ fatalError: string | null;
40
+ completionReached: boolean;
41
+ sessions: readonly SessionData[];
42
+ backgroundTaskCount: number;
43
+ viewMode: string;
44
+ activeAgentId: string;
45
+ };
46
+
47
+ export type TuiDiagnostics = {
48
+ capture: (reason: string) => void;
49
+ dispose: () => void;
50
+ };
51
+
52
+ type TuiDiagnosticsOptions = {
53
+ renderer: CliRenderer;
54
+ graphTheme: GraphTheme;
55
+ getSnapshot: () => WorkflowDiagnosticSnapshot;
56
+ };
57
+
58
+ const DEFAULT_INTERVAL_MS = 1_000;
59
+ const DEFAULT_MAX_CAPTURES = 45;
60
+
61
+ export function isTuiDiagnosticsEnabled(): boolean {
62
+ const value = process.env.ATOMIC_TUI_DIAGNOSTICS;
63
+ return value === "1" || value === "true" || value === "yes";
64
+ }
65
+
66
+ export function createTuiDiagnostics({
67
+ renderer,
68
+ graphTheme,
69
+ getSnapshot,
70
+ }: TuiDiagnosticsOptions): TuiDiagnostics | null {
71
+ if (!isTuiDiagnosticsEnabled()) return null;
72
+
73
+ const directory = resolveDiagnosticsDirectory();
74
+ mkdirSync(directory, { recursive: true });
75
+
76
+ let sequence = 0;
77
+ let disposed = false;
78
+ const maxCaptures = readPositiveInt(process.env.ATOMIC_TUI_DIAGNOSTICS_MAX, DEFAULT_MAX_CAPTURES);
79
+ const intervalMs = readPositiveInt(process.env.ATOMIC_TUI_DIAGNOSTICS_INTERVAL_MS, DEFAULT_INTERVAL_MS);
80
+
81
+ writeJson(join(directory, "metadata.json"), {
82
+ directory,
83
+ pid: process.pid,
84
+ startedAt: new Date().toISOString(),
85
+ environment: diagnosticEnvironment(),
86
+ graphTheme,
87
+ });
88
+
89
+ const capture = (reason: string): void => {
90
+ if (disposed || sequence >= maxCaptures) return;
91
+ sequence++;
92
+ const timestamp = Date.now();
93
+ const payload = {
94
+ sequence,
95
+ reason,
96
+ capturedAt: new Date(timestamp).toISOString(),
97
+ environment: diagnosticEnvironment(),
98
+ renderer: {
99
+ width: renderer.width,
100
+ height: renderer.height,
101
+ terminalWidth: renderer.terminalWidth,
102
+ terminalHeight: renderer.terminalHeight,
103
+ themeMode: renderer.themeMode,
104
+ capabilities: cloneJson(renderer.capabilities),
105
+ },
106
+ graphTheme,
107
+ workflow: getSnapshot(),
108
+ currentRenderBuffer: summarizeBuffer(renderer.currentRenderBuffer),
109
+ nextRenderBuffer: summarizeBuffer(renderer.nextRenderBuffer),
110
+ };
111
+
112
+ writeJson(join(directory, `${String(sequence).padStart(4, "0")}-${sanitizeReason(reason)}.json`), payload);
113
+ writeJson(join(directory, "latest.json"), payload);
114
+
115
+ if (process.env.ATOMIC_TUI_DIAGNOSTICS_OPENTUI_DUMP === "1") {
116
+ renderer.dumpBuffers(timestamp);
117
+ renderer.dumpStdoutBuffer(timestamp);
118
+ }
119
+ };
120
+
121
+ const timer = setInterval(() => capture("interval"), intervalMs);
122
+ capture("created");
123
+
124
+ return {
125
+ capture,
126
+ dispose: () => {
127
+ if (disposed) return;
128
+ disposed = true;
129
+ clearInterval(timer);
130
+ writeJson(join(directory, "disposed.json"), {
131
+ disposedAt: new Date().toISOString(),
132
+ captures: sequence,
133
+ });
134
+ },
135
+ };
136
+ }
137
+
138
+ export function summarizeBuffer(buffer: OptimizedBuffer): BufferDiagnostic {
139
+ const raw = buffer.buffers;
140
+ const width = buffer.width;
141
+ const height = buffer.height;
142
+ const counts = new Map<string, number>();
143
+ const rows: BufferRowDiagnostic[] = [];
144
+ const yellowHueSamples: Array<{ x: number; y: number; color: string; char: string }> = [];
145
+ let yellowHueCells = 0;
146
+
147
+ for (let y = 0; y < height; y++) {
148
+ const backgrounds: BackgroundRun[] = [];
149
+ let text = "";
150
+ let currentColor = "";
151
+ let runStart = 0;
152
+
153
+ for (let x = 0; x < width; x++) {
154
+ const index = y * width + x;
155
+ const color = readColor(raw.bg, index);
156
+ const char = readChar(raw.char[index] ?? 0);
157
+ text += char;
158
+
159
+ counts.set(color, (counts.get(color) ?? 0) + 1);
160
+ if (isYellowHue(color)) {
161
+ yellowHueCells++;
162
+ if (yellowHueSamples.length < 40) {
163
+ yellowHueSamples.push({ x, y, color, char });
164
+ }
165
+ }
166
+
167
+ if (x === 0) {
168
+ currentColor = color;
169
+ runStart = 0;
170
+ } else if (color !== currentColor) {
171
+ backgrounds.push({ x: runStart, width: x - runStart, color: currentColor });
172
+ currentColor = color;
173
+ runStart = x;
174
+ }
175
+ }
176
+
177
+ if (width > 0) {
178
+ backgrounds.push({ x: runStart, width: width - runStart, color: currentColor });
179
+ }
180
+ rows.push({ y, text: text.trimEnd(), backgrounds });
181
+ }
182
+
183
+ const total = Math.max(1, width * height);
184
+ const topBackgrounds = Array.from(counts.entries())
185
+ .map(([color, count]) => ({
186
+ color,
187
+ count,
188
+ percent: Math.round((count / total) * 10_000) / 100,
189
+ }))
190
+ .sort((a, b) => b.count - a.count)
191
+ .slice(0, 16);
192
+
193
+ return {
194
+ width,
195
+ height,
196
+ topBackgrounds,
197
+ yellowHueCells,
198
+ yellowHueSamples,
199
+ rows,
200
+ };
201
+ }
202
+
203
+ function resolveDiagnosticsDirectory(): string {
204
+ const explicit = process.env.ATOMIC_TUI_DIAGNOSTICS_DIR;
205
+ if (explicit && explicit.trim() !== "") return explicit;
206
+ return join(tmpdir(), `atomic-tui-diagnostics-${process.pid}`);
207
+ }
208
+
209
+ function diagnosticEnvironment(): Record<string, string | null> {
210
+ return {
211
+ TERM: process.env.TERM ?? null,
212
+ TERM_PROGRAM: process.env.TERM_PROGRAM ?? null,
213
+ COLORTERM: process.env.COLORTERM ?? null,
214
+ TMUX: process.env.TMUX ?? null,
215
+ SSH_TTY: process.env.SSH_TTY ?? null,
216
+ };
217
+ }
218
+
219
+ function readPositiveInt(value: string | undefined, fallback: number): number {
220
+ if (value === undefined) return fallback;
221
+ const parsed = Number.parseInt(value, 10);
222
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
223
+ }
224
+
225
+ function writeJson(path: string, value: object): void {
226
+ writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`, "utf8");
227
+ }
228
+
229
+ function sanitizeReason(reason: string): string {
230
+ const sanitized = reason.replace(/[^a-z0-9_-]+/gi, "-").replace(/^-+|-+$/g, "");
231
+ return sanitized === "" ? "capture" : sanitized;
232
+ }
233
+
234
+ function readColor(buffer: Uint16Array, cellIndex: number): string {
235
+ const offset = cellIndex * 4;
236
+ return `#${toHex(buffer[offset] ?? 0)}${toHex(buffer[offset + 1] ?? 0)}${toHex(buffer[offset + 2] ?? 0)}`;
237
+ }
238
+
239
+ function toHex(value: number): string {
240
+ const clamped = Math.max(0, Math.min(255, value));
241
+ return clamped.toString(16).padStart(2, "0");
242
+ }
243
+
244
+ function readChar(codePoint: number): string {
245
+ if (codePoint <= 0 || codePoint > 0x10ffff) return " ";
246
+ return String.fromCodePoint(codePoint);
247
+ }
248
+
249
+ function isYellowHue(color: string): boolean {
250
+ const red = Number.parseInt(color.slice(1, 3), 16);
251
+ const green = Number.parseInt(color.slice(3, 5), 16);
252
+ const blue = Number.parseInt(color.slice(5, 7), 16);
253
+ const max = Math.max(red, green, blue);
254
+ const min = Math.min(red, green, blue);
255
+ if (max < 32 || max === min) return false;
256
+
257
+ const saturation = (max - min) / max;
258
+ if (saturation < 0.12) return false;
259
+
260
+ const hue =
261
+ max === red
262
+ ? ((green - blue) / (max - min)) * 60
263
+ : max === green
264
+ ? (2 + (blue - red) / (max - min)) * 60
265
+ : (4 + (red - green) / (max - min)) * 60;
266
+ const normalizedHue = hue < 0 ? hue + 360 : hue;
267
+ return normalizedHue >= 35 && normalizedHue <= 85;
268
+ }
269
+
270
+ function cloneJson(value: object | null): object | null {
271
+ if (value === null) return null;
272
+ return JSON.parse(JSON.stringify(value)) as object;
273
+ }