@bastani/atomic 0.6.0 → 0.6.1

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.
@@ -4,25 +4,43 @@
4
4
  * tmux window. The executor splits each agent window after creation and
5
5
  * runs `atomic _footer --name <window-name>` in the bottom pane.
6
6
  *
7
- * Setting `exitOnCtrlC: false` suppresses OpenTUI's built-in signal
8
- * handling, so we install our own teardown path. In the tmux case the
9
- * closed pty raises SIGPIPE on the next render, which hits our handler.
10
- * The parent-liveness watchdog is a portable fallback for the orphan case
11
- * where no signal arrives (process gets reparented to init/unknown).
7
+ * The footer is rendered through OpenTUI's headless renderer and repainted
8
+ * into the footer pane. A normal CLI renderer must not be used here:
9
+ * terminal capability probes from a footer pane can be answered by the
10
+ * attached client and routed by tmux into the active agent pane as input.
12
11
  */
13
12
 
14
- import { useEffect } from "react";
15
- import { createCliRenderer } from "@opentui/core";
16
- import { createRoot, useRenderer } from "@opentui/react";
13
+ import {
14
+ getBaseAttributes,
15
+ TextAttributes,
16
+ type CapturedFrame,
17
+ type CapturedSpan,
18
+ } from "@opentui/core";
19
+ import { testRender } from "@opentui/react/test-utils";
20
+ import { act } from "react";
17
21
  import { resolveTheme } from "../../sdk/runtime/theme.ts";
18
22
  import {
19
23
  deriveGraphTheme,
20
- type GraphTheme,
21
24
  } from "../../sdk/components/graph-theme.ts";
22
25
  import { AttachedStatusline } from "../../sdk/components/attached-statusline.tsx";
23
26
  import type { AgentType } from "../../sdk/types.ts";
24
27
 
25
28
  const PARENT_WATCHDOG_MS = 2000;
29
+ const FOOTER_RENDER_INTERVAL_MS = 250;
30
+ const FOOTER_RENDER_ROWS = 1;
31
+ const CLEAR_LINE = "\r\x1b[2K";
32
+
33
+ type FooterOutput = {
34
+ columns?: number;
35
+ write(chunk: string): unknown;
36
+ };
37
+
38
+ type FooterRendererOptions = {
39
+ name: string;
40
+ agentType?: AgentType;
41
+ stdout?: FooterOutput;
42
+ onReady?: () => void;
43
+ };
26
44
 
27
45
  /**
28
46
  * Snapshot the parent PID at module load. `process.ppid` is cached in both
@@ -59,74 +77,172 @@ const EXIT_SIGNALS: NodeJS.Signals[] =
59
77
  ? ["SIGTERM", "SIGINT", "SIGBREAK", "SIGHUP"]
60
78
  : ["SIGHUP", "SIGTERM", "SIGINT", "SIGPIPE"];
61
79
 
62
- function FooterShell({
80
+ const ANSI_RESET = "\x1b[0m";
81
+
82
+ function ansiColor(kind: 38 | 48, spanColor: CapturedSpan["fg"]): string {
83
+ const [r, g, b] = spanColor.toInts();
84
+ return `${kind};2;${r};${g};${b}`;
85
+ }
86
+
87
+ function sanitizeText(text: string): string {
88
+ return text.replace(/[\x00-\x1f\x7f]/g, " ");
89
+ }
90
+
91
+ function spanToAnsi(span: CapturedSpan): string {
92
+ const attrs = getBaseAttributes(span.attributes);
93
+ const codes = [
94
+ ansiColor(38, span.fg),
95
+ ansiColor(48, span.bg),
96
+ ];
97
+
98
+ if ((attrs & TextAttributes.BOLD) !== 0) codes.unshift("1");
99
+ if ((attrs & TextAttributes.DIM) !== 0) codes.unshift("2");
100
+ if ((attrs & TextAttributes.ITALIC) !== 0) codes.unshift("3");
101
+ if ((attrs & TextAttributes.UNDERLINE) !== 0) codes.unshift("4");
102
+ if ((attrs & TextAttributes.INVERSE) !== 0) codes.unshift("7");
103
+
104
+ return `\x1b[${codes.join(";")}m${sanitizeText(span.text)}`;
105
+ }
106
+
107
+ function frameToAnsi(frame: CapturedFrame): string {
108
+ const lines = frame.lines.map((line) =>
109
+ line.spans.map(spanToAnsi).join("")
110
+ );
111
+ return `${lines.join("\n")}${ANSI_RESET}`;
112
+ }
113
+
114
+ async function createFooterTestRenderer({
63
115
  name,
64
- theme,
65
116
  agentType,
117
+ width = process.stdout.columns ?? 80,
66
118
  }: {
67
119
  name: string;
68
- theme: GraphTheme;
69
120
  agentType?: AgentType;
121
+ width?: number;
70
122
  }) {
71
- const renderer = useRenderer();
123
+ const theme = deriveGraphTheme(resolveTheme(null));
124
+ return await testRender(
125
+ <AttachedStatusline name={name} theme={theme} agentType={agentType} />,
126
+ {
127
+ width: Math.max(width, 1),
128
+ height: FOOTER_RENDER_ROWS,
129
+ exitOnCtrlC: false,
130
+ exitSignals: [],
131
+ clearOnShutdown: false,
132
+ useMouse: false,
133
+ useKittyKeyboard: null,
134
+ openConsoleOnError: false,
135
+ },
136
+ );
137
+ }
138
+
139
+ async function renderFooterSetupFrame(
140
+ testSetup: Awaited<ReturnType<typeof createFooterTestRenderer>>,
141
+ ): Promise<string> {
142
+ await act(async () => {
143
+ await testSetup.renderOnce();
144
+ });
145
+ return frameToAnsi(testSetup.captureSpans());
146
+ }
147
+
148
+ export async function renderFooterFrame({
149
+ name,
150
+ agentType,
151
+ width = process.stdout.columns ?? 80,
152
+ }: {
153
+ name: string;
154
+ agentType?: AgentType;
155
+ width?: number;
156
+ }): Promise<string> {
157
+ const testSetup = await createFooterTestRenderer({ name, agentType, width });
158
+ try {
159
+ return await renderFooterSetupFrame(testSetup);
160
+ } finally {
161
+ act(() => {
162
+ testSetup.renderer.destroy();
163
+ });
164
+ }
165
+ }
166
+
167
+ export async function runFooterRenderer({
168
+ name,
169
+ agentType,
170
+ stdout = process.stdout,
171
+ onReady,
172
+ }: FooterRendererOptions): Promise<void> {
173
+ const testSetup = await createFooterTestRenderer({
174
+ name,
175
+ agentType,
176
+ width: stdout.columns,
177
+ });
178
+ let currentWidth = Math.max(stdout.columns ?? 80, 1);
179
+ let lastFrame = "";
180
+ let renderInFlight = false;
181
+ let tornDown = false;
182
+ let teardown!: () => void;
183
+
184
+ const render = async () => {
185
+ if (tornDown || renderInFlight) return;
186
+ renderInFlight = true;
187
+ try {
188
+ const nextWidth = Math.max(stdout.columns ?? 80, 1);
189
+ if (nextWidth !== currentWidth) {
190
+ currentWidth = nextWidth;
191
+ testSetup.resize(currentWidth, FOOTER_RENDER_ROWS);
192
+ }
72
193
 
73
- useEffect(() => {
74
- let tornDown = false;
75
- const teardown = () => {
194
+ const frame = await renderFooterSetupFrame(testSetup);
195
+ if (!tornDown && frame !== lastFrame) {
196
+ stdout.write(`${CLEAR_LINE}${frame}`);
197
+ lastFrame = frame;
198
+ }
199
+ } finally {
200
+ renderInFlight = false;
201
+ }
202
+ };
203
+
204
+ await render();
205
+
206
+ await new Promise<void>((resolve) => {
207
+ teardown = () => {
76
208
  if (tornDown) return;
77
209
  tornDown = true;
78
- try {
79
- renderer.destroy();
80
- } catch {
81
- // renderer may already be mid-destroy; the pty is likely gone
210
+ for (const sig of EXIT_SIGNALS) {
211
+ process.off(sig, teardown);
82
212
  }
83
- // Pane pty is already closed by the time we reach here, so there is
84
- // no terminal state left to preserve. Exit explicitly in case
85
- // destroy() doesn't (e.g. when stdout writes fail silently).
86
- process.exit(0);
213
+ process.off("SIGWINCH", requestRender);
214
+ clearInterval(renderTick);
215
+ clearInterval(watchdog);
216
+ act(() => {
217
+ testSetup.renderer.destroy();
218
+ });
219
+ resolve();
87
220
  };
221
+
222
+ const requestRender = () => {
223
+ void render();
224
+ };
225
+
88
226
  for (const sig of EXIT_SIGNALS) {
89
227
  process.on(sig, teardown);
90
228
  }
229
+ process.on("SIGWINCH", requestRender);
91
230
 
231
+ const renderTick = setInterval(() => {
232
+ void render();
233
+ }, FOOTER_RENDER_INTERVAL_MS);
92
234
  const watchdog = setInterval(() => {
93
235
  if (!originalParentAlive()) teardown();
94
236
  }, PARENT_WATCHDOG_MS);
95
- watchdog.unref?.();
96
-
97
- return () => {
98
- for (const sig of EXIT_SIGNALS) {
99
- process.off(sig, teardown);
100
- }
101
- clearInterval(watchdog);
102
- };
103
- }, [renderer]);
104
-
105
- return (
106
- <box
107
- width="100%"
108
- height="100%"
109
- flexDirection="column"
110
- justifyContent="flex-end"
111
- backgroundColor={theme.background}
112
- >
113
- <AttachedStatusline name={name} theme={theme} agentType={agentType} />
114
- </box>
115
- );
237
+ onReady?.();
238
+ });
116
239
  }
117
240
 
118
241
  export async function footerCommand(
119
242
  name: string,
120
243
  agentType?: AgentType,
244
+ options: Omit<FooterRendererOptions, "name" | "agentType"> = {},
121
245
  ): Promise<number> {
122
- const renderer = await createCliRenderer({
123
- exitOnCtrlC: false,
124
- });
125
- const theme = deriveGraphTheme(resolveTheme(renderer.themeMode));
126
- createRoot(renderer).render(
127
- <FooterShell name={name} theme={theme} agentType={agentType} />,
128
- );
129
-
130
- await new Promise<void>(() => {});
246
+ await runFooterRenderer({ name, agentType, ...options });
131
247
  return 0;
132
248
  }
@@ -0,0 +1,109 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import {
6
+ hasRequiredMuxBinary,
7
+ isMuxBinaryRequiredForPlatform,
8
+ prependPath,
9
+ psmuxReleaseAssetSuffix,
10
+ requiredMuxBinaryCandidatesForPlatform,
11
+ resolveCommandFromCurrentPath,
12
+ runCommand,
13
+ } from "./spawn.ts";
14
+
15
+ describe("spawn PATH helpers", () => {
16
+ let originalPath: string | undefined;
17
+ let tempDir: string;
18
+
19
+ beforeEach(() => {
20
+ originalPath = process.env.PATH;
21
+ tempDir = mkdtempSync(join(tmpdir(), "atomic-spawn-"));
22
+ });
23
+
24
+ afterEach(() => {
25
+ process.env.PATH = originalPath;
26
+ rmSync(tempDir, { force: true, recursive: true });
27
+ });
28
+
29
+ test("resolves commands added to PATH during the current process", () => {
30
+ const commandName = process.platform === "win32"
31
+ ? "atomic-spawn-test.cmd"
32
+ : "atomic-spawn-test";
33
+ const commandPath = join(tempDir, commandName);
34
+ const body = process.platform === "win32" ? "@echo off\r\n" : "#!/bin/sh\n";
35
+
36
+ writeFileSync(commandPath, body);
37
+ chmodSync(commandPath, 0o755);
38
+
39
+ process.env.PATH = originalPath ?? "";
40
+ prependPath(tempDir);
41
+
42
+ expect(resolveCommandFromCurrentPath("atomic-spawn-test")).toBe(commandPath);
43
+ });
44
+
45
+ test("requires native psmux binaries on Windows", () => {
46
+ expect(requiredMuxBinaryCandidatesForPlatform("win32")).toEqual([
47
+ "psmux",
48
+ "pmux",
49
+ ]);
50
+ expect(isMuxBinaryRequiredForPlatform("psmux", "win32")).toBe(true);
51
+ expect(isMuxBinaryRequiredForPlatform("pmux", "win32")).toBe(true);
52
+ expect(isMuxBinaryRequiredForPlatform("tmux", "win32")).toBe(false);
53
+ });
54
+
55
+ test("requires tmux on Unix-like platforms", () => {
56
+ expect(requiredMuxBinaryCandidatesForPlatform("linux")).toEqual(["tmux"]);
57
+ expect(requiredMuxBinaryCandidatesForPlatform("darwin")).toEqual(["tmux"]);
58
+ expect(isMuxBinaryRequiredForPlatform("tmux", "linux")).toBe(true);
59
+ expect(isMuxBinaryRequiredForPlatform("psmux", "linux")).toBe(false);
60
+ expect(isMuxBinaryRequiredForPlatform("pmux", "darwin")).toBe(false);
61
+ });
62
+
63
+ test("maps supported Windows architectures to psmux release assets", () => {
64
+ expect(psmuxReleaseAssetSuffix("x64")).toBe("windows-x64.zip");
65
+ expect(psmuxReleaseAssetSuffix("ia32")).toBe("windows-x86.zip");
66
+ expect(psmuxReleaseAssetSuffix("arm64")).toBe("windows-arm64.zip");
67
+ expect(psmuxReleaseAssetSuffix("arm")).toBeNull();
68
+ });
69
+
70
+ test("uses platform requirement when checking PATH", () => {
71
+ const commandPath = join(tempDir, "tmux");
72
+
73
+ writeFileSync(commandPath, "#!/bin/sh\n");
74
+ chmodSync(commandPath, 0o755);
75
+
76
+ process.env.PATH = tempDir;
77
+
78
+ expect(hasRequiredMuxBinary()).toBe(process.platform !== "win32");
79
+ });
80
+
81
+ test("does not add duplicate PATH entries", () => {
82
+ process.env.PATH = originalPath ?? "";
83
+
84
+ prependPath(tempDir);
85
+ prependPath(tempDir);
86
+
87
+ const delimiter = process.platform === "win32" ? ";" : ":";
88
+ const entries = (process.env.PATH ?? "").split(delimiter);
89
+ expect(entries.filter((entry) => entry === tempDir)).toHaveLength(1);
90
+ });
91
+
92
+ test("runCommand keeps stdout and stderr separate", async () => {
93
+ const scriptPath = join(tempDir, "streams.ts");
94
+ writeFileSync(
95
+ scriptPath,
96
+ "await Bun.write(Bun.stderr, 'warning\\n'); await Bun.write(Bun.stdout, 'value\\n');\n",
97
+ );
98
+
99
+ const result = await runCommand([
100
+ process.execPath,
101
+ scriptPath,
102
+ ]);
103
+
104
+ expect(result.success).toBe(true);
105
+ expect(result.details).toBe("warning");
106
+ expect(result.stderr).toBe("warning");
107
+ expect(result.stdout).toBe("value");
108
+ });
109
+ });