@cueloop/client 0.1.0-alpha.42 → 0.1.0-alpha.43

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.
@@ -0,0 +1,51 @@
1
+ #!/bin/sh
2
+ # Build libcuelooppty: the flat forkpty(3) FFI shim in src/pty.zig. Pure libc, so
3
+ # unlike build.sh this needs no Ghostty clone - just the pinned Zig toolchain.
4
+ # Output lands in native/<os>-<arch>/libcuelooppty.{dylib,so}. Requires curl.
5
+ set -eu
6
+
7
+ ZIG_VERSION="0.16.0"
8
+
9
+ here="$(cd "$(dirname "$0")" && pwd)"
10
+ work="${TMPDIR:-/tmp}/cueloop-libghostty-build"
11
+ mkdir -p "$work"
12
+ export ZIG_GLOBAL_CACHE_DIR="$work/zig-gcache"
13
+ export ZIG_LOCAL_CACHE_DIR="$work/zig-lcache"
14
+ cd "$work"
15
+
16
+ arch="$(uname -m)"; os="$(uname -s | tr '[:upper:]' '[:lower:]')"
17
+ case "$os" in darwin) zig_os="macos"; dylib="libcuelooppty.dylib";; linux) zig_os="linux"; dylib="libcuelooppty.so";; *) echo "unsupported os $os" >&2; exit 1;; esac
18
+ case "$arch-$os" in
19
+ arm64-darwin|aarch64-linux) zig_arch="aarch64"; bun_arch="arm64";;
20
+ x86_64-darwin|x86_64-linux) zig_arch="x86_64"; bun_arch="x64";;
21
+ *) echo "unsupported architecture $arch on $os" >&2; exit 1;;
22
+ esac
23
+ case "$zig_arch-$zig_os" in
24
+ aarch64-macos) zig_sha="b23d70deaa879b5c2d486ed3316f7eaa53e84acf6fc9cc747de152450d401489";;
25
+ x86_64-macos) zig_sha="0387557ed1877bc6a2e1802c8391953baddba76081876301c522f52977b52ba7";;
26
+ aarch64-linux) zig_sha="ea4b09bfb22ec6f6c6ceac57ab63efb6b46e17ab08d21f69f3a48b38e1534f17";;
27
+ x86_64-linux) zig_sha="70e49664a74374b48b51e6f3fdfbf437f6395d42509050588bd49abe52ba3d00";;
28
+ esac
29
+ outdir="$here/${os}-${bun_arch}"
30
+ zig_archive="zig-$zig_arch-$zig_os-$ZIG_VERSION.tar.xz"
31
+
32
+ if [ ! -f "$zig_archive" ]; then
33
+ echo "== fetching zig $ZIG_VERSION =="
34
+ curl -fsSL -o "$zig_archive" "https://ziglang.org/download/$ZIG_VERSION/$zig_archive"
35
+ fi
36
+ printf '%s %s\n' "$zig_sha" "$zig_archive" | shasum -a 256 -c -
37
+
38
+ build_root="$(mktemp -d "$work/native-pty-build.XXXXXX")"
39
+ trap 'rm -rf "$build_root"' EXIT HUP INT TERM
40
+ tar -xf "$zig_archive" -C "$build_root"
41
+ zig="$build_root/zig-$zig_arch-$zig_os-$ZIG_VERSION/zig"
42
+
43
+ echo "== building pty shim -> $outdir/$dylib =="
44
+ mkdir -p "$outdir"
45
+ if [ "$os" = "darwin" ]; then
46
+ "$zig" build-lib -dynamic -OReleaseFast -lc -femit-bin="$outdir/$dylib" \
47
+ -install_name "@rpath/$dylib" "$here/src/pty.zig"
48
+ else
49
+ "$zig" build-lib -dynamic -OReleaseFast -lc -femit-bin="$outdir/$dylib" "$here/src/pty.zig"
50
+ fi
51
+ echo "== done: $outdir/$dylib =="
@@ -0,0 +1,193 @@
1
+ // Flat, FFI-trivial pseudo-terminal over libc forkpty(3). Exposes exactly what
2
+ // the embedded Agent-tab terminal needs: spawn a child on a PTY, non-blocking
3
+ // read of its output, write, resize, and reap. Handles are small ints into a
4
+ // fixed table so bun:ffi only ever passes ints and byte pointers, never structs.
5
+ //
6
+ // Packed-argument contract (avoids shell parsing and per-token FFI marshalling):
7
+ // argv_packed: "arg0\0arg1\0...\0" - NUL-separated, terminated by an empty
8
+ // token (a trailing extra NUL).
9
+ // env_packed: "K=V\0K=V\0...\0" - same shape; empty means inherit parent.
10
+ // cwd: a plain C string, or empty to keep the parent's directory.
11
+
12
+ const std = @import("std");
13
+ const builtin = @import("builtin");
14
+
15
+ const c = @cImport({
16
+ @cInclude("unistd.h");
17
+ @cInclude("fcntl.h");
18
+ @cInclude("errno.h");
19
+ @cInclude("signal.h");
20
+ @cInclude("stdlib.h");
21
+ @cInclude("string.h");
22
+ @cInclude("termios.h");
23
+ @cInclude("sys/ioctl.h");
24
+ @cInclude("sys/wait.h");
25
+ if (builtin.os.tag == .macos) @cInclude("util.h") else @cInclude("pty.h");
26
+ });
27
+
28
+ extern var environ: [*c][*c]u8;
29
+
30
+ const MAX_SESSIONS = 64;
31
+ const MAX_TOKENS = 1024;
32
+
33
+ const PtySession = struct {
34
+ in_use: bool = false,
35
+ master_fd: c_int = -1,
36
+ pid: c.pid_t = 0,
37
+ reaped: bool = false,
38
+ exit_code: c_int = 0,
39
+ };
40
+
41
+ var sessions = [_]PtySession{.{}} ** MAX_SESSIONS;
42
+
43
+ // Split a double-NUL-terminated packed buffer into a NULL-terminated array of
44
+ // pointers into that same buffer. Returns the token count, or -1 on overflow.
45
+ fn splitPacked(packed_buffer: [*c]u8, out: [][*c]u8) c_int {
46
+ var count: usize = 0;
47
+ var cursor: [*c]u8 = packed_buffer;
48
+ while (cursor[0] != 0) {
49
+ if (count >= out.len - 1) return -1;
50
+ out[count] = cursor;
51
+ count += 1;
52
+ cursor += c.strlen(cursor) + 1;
53
+ }
54
+ out[count] = null;
55
+ return @intCast(count);
56
+ }
57
+
58
+ // Reap the child once, recording its exit status. WNOHANG unless `block`.
59
+ fn reapSession(session: *PtySession, block: bool) void {
60
+ if (session.reaped) return;
61
+ var status: c_int = 0;
62
+ const result = c.waitpid(session.pid, &status, if (block) 0 else c.WNOHANG);
63
+ if (result != session.pid) return;
64
+ session.reaped = true;
65
+ if (c.WIFEXITED(status)) {
66
+ session.exit_code = c.WEXITSTATUS(status);
67
+ } else if (c.WIFSIGNALED(status)) {
68
+ session.exit_code = 128 + c.WTERMSIG(status);
69
+ } else {
70
+ session.exit_code = -1;
71
+ }
72
+ }
73
+
74
+ fn sessionOf(handle: c_int) ?*PtySession {
75
+ if (handle < 0 or handle >= MAX_SESSIONS) return null;
76
+ const session = &sessions[@intCast(handle)];
77
+ if (!session.in_use) return null;
78
+ return session;
79
+ }
80
+
81
+ // Spawn `argv_packed` on a fresh PTY sized cols x rows, in cwd, with env_packed
82
+ // (or the parent's env when empty). Returns a session handle, or -1 on failure.
83
+ export fn cueloop_pty_spawn(argv_packed: [*c]u8, cwd: [*c]const u8, env_packed: [*c]u8, cols: c_int, rows: c_int) c_int {
84
+ var handle: c_int = -1;
85
+ for (0..MAX_SESSIONS) |index| {
86
+ if (!sessions[index].in_use) {
87
+ handle = @intCast(index);
88
+ break;
89
+ }
90
+ }
91
+ if (handle < 0) return -1;
92
+
93
+ var argv: [MAX_TOKENS][*c]u8 = undefined;
94
+ if (splitPacked(argv_packed, &argv) < 1) return -1;
95
+
96
+ var envp: [MAX_TOKENS][*c]u8 = undefined;
97
+ const have_env = env_packed != null and env_packed[0] != 0;
98
+ if (have_env and splitPacked(env_packed, &envp) < 0) return -1;
99
+
100
+ var winsize = std.mem.zeroes(c.struct_winsize);
101
+ winsize.ws_col = @intCast(cols);
102
+ winsize.ws_row = @intCast(rows);
103
+
104
+ var master_fd: c_int = -1;
105
+ const pid = c.forkpty(&master_fd, null, null, &winsize);
106
+ if (pid < 0) return -1;
107
+
108
+ if (pid == 0) {
109
+ // Child: adopt cwd/env then exec; _exit on any failure so a broken child never runs JS.
110
+ if (cwd != null and cwd[0] != 0) {
111
+ if (c.chdir(cwd) != 0) c._exit(127);
112
+ }
113
+ if (have_env) environ = &envp;
114
+ _ = c.execvp(argv[0], &argv);
115
+ c._exit(127);
116
+ }
117
+
118
+ // Parent: non-blocking master so reads can poll without stalling the loop.
119
+ const flags = c.fcntl(master_fd, c.F_GETFL, @as(c_int, 0));
120
+ _ = c.fcntl(master_fd, c.F_SETFL, flags | c.O_NONBLOCK);
121
+
122
+ sessions[@intCast(handle)] = .{
123
+ .in_use = true,
124
+ .master_fd = master_fd,
125
+ .pid = pid,
126
+ .reaped = false,
127
+ .exit_code = 0,
128
+ };
129
+ return handle;
130
+ }
131
+
132
+ export fn cueloop_pty_write(handle: c_int, data: [*c]const u8, len: c_int) c_int {
133
+ const session = sessionOf(handle) orelse return -1;
134
+ const written = c.write(session.master_fd, data, @intCast(len));
135
+ return @intCast(written);
136
+ }
137
+
138
+ // Non-blocking read of child output. Returns bytes read (>0), 0 when nothing is
139
+ // available yet, -2 once the child has exited and its output is drained, or -1.
140
+ export fn cueloop_pty_read(handle: c_int, buf: [*c]u8, len: c_int) c_int {
141
+ const session = sessionOf(handle) orelse return -1;
142
+ const bytes_read = c.read(session.master_fd, buf, @intCast(len));
143
+ if (bytes_read > 0) return @intCast(bytes_read);
144
+ if (bytes_read == 0) {
145
+ reapSession(session, true);
146
+ return -2; // EOF: slave closed
147
+ }
148
+ const err = std.c._errno().*;
149
+ if (err == c.EAGAIN or err == c.EWOULDBLOCK) {
150
+ reapSession(session, false);
151
+ return if (session.reaped) -2 else 0; // exited with no more output vs still running
152
+ }
153
+ if (err == c.EIO) {
154
+ reapSession(session, true);
155
+ return -2; // Linux: slave gone
156
+ }
157
+ return -1;
158
+ }
159
+
160
+ export fn cueloop_pty_resize(handle: c_int, cols: c_int, rows: c_int) c_int {
161
+ const session = sessionOf(handle) orelse return -1;
162
+ var winsize = std.mem.zeroes(c.struct_winsize);
163
+ winsize.ws_col = @intCast(cols);
164
+ winsize.ws_row = @intCast(rows);
165
+ return c.ioctl(session.master_fd, c.TIOCSWINSZ, &winsize);
166
+ }
167
+
168
+ export fn cueloop_pty_kill(handle: c_int) c_int {
169
+ const session = sessionOf(handle) orelse return -1;
170
+ return c.kill(session.pid, c.SIGTERM);
171
+ }
172
+
173
+ export fn cueloop_pty_get_pid(handle: c_int) c_int {
174
+ const session = sessionOf(handle) orelse return -1;
175
+ return @intCast(session.pid);
176
+ }
177
+
178
+ export fn cueloop_pty_get_exit_code(handle: c_int) c_int {
179
+ const session = sessionOf(handle) orelse return -1;
180
+ reapSession(session, true);
181
+ return session.exit_code;
182
+ }
183
+
184
+ export fn cueloop_pty_close(handle: c_int) void {
185
+ const session = sessionOf(handle) orelse return;
186
+ // Close the master first (hangs up the child), then block to reap it: after the
187
+ // SIGTERM from kill() plus this SIGHUP a well-behaved child exits at once, so the
188
+ // wait returns promptly and never leaves a zombie.
189
+ if (session.master_fd >= 0) _ = c.close(session.master_fd);
190
+ reapSession(session, true);
191
+ session.in_use = false;
192
+ session.master_fd = -1;
193
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cueloop/client",
3
- "version": "0.1.0-alpha.42",
3
+ "version": "0.1.0-alpha.43",
4
4
  "description": "The cueloop review UI: projection renderer, keyboard grammar, review rail",
5
5
  "homepage": "https://github.com/mmurakaru/cueloop#readme",
6
6
  "bugs": {
@@ -26,16 +26,15 @@
26
26
  "access": "public"
27
27
  },
28
28
  "dependencies": {
29
- "@cueloop/daemon": "0.1.0-alpha.42",
30
- "@cueloop/extension-api": "0.1.0-alpha.42",
31
- "@cueloop/integration-obsidian": "0.1.0-alpha.42",
32
- "@cueloop/schema": "0.1.0-alpha.42",
29
+ "@cueloop/daemon": "0.1.0-alpha.43",
30
+ "@cueloop/extension-api": "0.1.0-alpha.43",
31
+ "@cueloop/integration-obsidian": "0.1.0-alpha.43",
32
+ "@cueloop/schema": "0.1.0-alpha.43",
33
33
  "@opentui/core": "^0.5.2",
34
34
  "@opentui/keymap": "0.5.2",
35
35
  "@opentui/react": "^0.5.2",
36
36
  "@opentui/ssh": "0.5.2",
37
37
  "@pierre/diffs": "^1.3.5",
38
- "bun-pty": "^0.4.10",
39
38
  "diff": "^8",
40
39
  "react": "^19.2.0"
41
40
  },
@@ -107,6 +107,14 @@ async function toContextParagraph(setup: Setup): Promise<void> {
107
107
  await press(setup, "j");
108
108
  }
109
109
 
110
+ /** Click the first on-screen occurrence of a string, by its char-frame position. */
111
+ async function clickText(setup: Setup, needle: string): Promise<void> {
112
+ const lines = setup.captureCharFrame().split("\n");
113
+ const row = lines.findIndex((line) => line.includes(needle));
114
+ await setup.mockMouse.click(lines[row]!.indexOf(needle) + 1, row);
115
+ await setup.renderOnce();
116
+ }
117
+
110
118
  describe("share button", () => {
111
119
  test("the owner's plan header shows the Share button next to Edit", async () => {
112
120
  // Arrange / Act
@@ -296,6 +304,45 @@ describe("inline compose keeps the anchor painted", () => {
296
304
  // runner, and the whole-suite publish lane has timed even 15s out; give the
297
305
  // heaviest frame-wait chain generous headroom so runner load cannot flake it.
298
306
  }, 60_000);
307
+
308
+ test("clicking the Cancel word-button closes the compose box", async () => {
309
+ // Arrange
310
+ const setup = await renderApp();
311
+ await toContextParagraph(setup);
312
+ await press(setup, "v");
313
+ await press(setup, "l");
314
+ await press(setup, "c");
315
+ await waitForText(setup, "Cancel");
316
+
317
+ // Act
318
+ await clickText(setup, "Cancel");
319
+
320
+ // Assert
321
+ await waitForTextGone(setup, 'comment on "');
322
+ });
323
+
324
+ test("clicking Cancel in a saved card's edit composer closes it", async () => {
325
+ // Arrange
326
+ const setup = await renderApp();
327
+ await toContextParagraph(setup);
328
+ await press(setup, "v");
329
+ await press(setup, "l");
330
+ await press(setup, "c");
331
+ await waitForText(setup, "Cancel");
332
+ await type(setup, "note body");
333
+ await press(setup, "enter");
334
+ await waitForState(setup, () => server.core.sessionGet(session.id).annotations.length === 1);
335
+ // select the saved card, then activate it to open the in-place editor
336
+ await clickText(setup, "note body");
337
+ await clickText(setup, "note body");
338
+ await waitForText(setup, "Cancel");
339
+
340
+ // Act
341
+ await clickText(setup, "Cancel");
342
+
343
+ // Assert - the editor closes back to the plain saved card
344
+ await waitForTextGone(setup, "Cancel");
345
+ });
299
346
  });
300
347
 
301
348
  describe("compose newline convention", () => {
@@ -525,7 +572,7 @@ describe("sheet header", () => {
525
572
  expect(frame).toContain("Share");
526
573
  });
527
574
 
528
- test("the Agent tab shows the harness launcher and a compact status footer", async () => {
575
+ test("the Agent tab shows the text-only harness launcher buttons", async () => {
529
576
  // Arrange
530
577
  const setup = await renderApp();
531
578
  const lines = setup.captureCharFrame().split("\n");
@@ -536,12 +583,11 @@ describe("sheet header", () => {
536
583
  await setup.mockMouse.click(tabColumn + 1, tabRow);
537
584
  await setup.renderOnce();
538
585
 
539
- // Assert - branded launcher cards, and the submitter kept in the footer
540
- await waitForText(setup, "claude code");
586
+ // Assert - the three launcher buttons, in order
587
+ await waitForText(setup, "Claude Code");
541
588
  const frame = setup.captureCharFrame();
542
- expect(frame).toContain("Ask an agent about this plan");
543
- expect(frame).toContain("codex");
544
- expect(frame).toContain("agent/worker-3");
589
+ expect(frame).toContain("Pi");
590
+ expect(frame).toContain("OpenAI Codex");
545
591
  });
546
592
  });
547
593
 
@@ -549,18 +595,12 @@ describe("quick-actions settings editor", () => {
549
595
  test("expanding an action edits its system prompt inline and persists it", async () => {
550
596
  // Arrange
551
597
  const setup = await renderApp();
552
- const clickText = async (needle: string): Promise<void> => {
553
- const lines = setup.captureCharFrame().split("\n");
554
- const row = lines.findIndex((line) => line.includes(needle));
555
- await setup.mockMouse.click(lines[row]!.indexOf(needle) + 1, row);
556
- await setup.renderOnce();
557
- };
558
598
 
559
599
  // Act - open Settings, enter Actions, expand the first action, type into its input
560
- await clickText("menu");
561
- await clickText("Settings");
562
- await clickText("Actions");
563
- await clickText("Zoom out, research in depth");
600
+ await clickText(setup, "menu");
601
+ await clickText(setup, "Settings");
602
+ await clickText(setup, "Actions");
603
+ await clickText(setup, "Zoom out, research in depth");
564
604
  await type(setup, "CUSTOM");
565
605
  await setup.renderOnce();
566
606
 
@@ -187,7 +187,6 @@ export const ReviewRail = forwardRef<ReviewRailHandle, ReviewRailProps>(function
187
187
  width,
188
188
  backgroundColor: tokens.panel,
189
189
  flexDirection: "column",
190
- paddingLeft: 1,
191
190
  paddingBottom: 1,
192
191
  }}
193
192
  >
@@ -202,8 +201,7 @@ export const ReviewRail = forwardRef<ReviewRailHandle, ReviewRailProps>(function
202
201
  </TabList>
203
202
  </Tabs>
204
203
  {railTab === "agent" ? (
205
- <box style={{ flexGrow: 1, flexDirection: "column", paddingLeft: 2 }}>
206
- <text> </text>
204
+ <box style={{ flexGrow: 1, flexDirection: "column" }}>
207
205
  <AgentLauncher
208
206
  session={session}
209
207
  onLaunchHarness={onLaunchHarness}