@cueloop/client 0.1.0-alpha.5 → 0.1.0-alpha.50

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 (152) hide show
  1. package/native/build-pty.sh +51 -0
  2. package/native/build.sh +82 -0
  3. package/native/darwin-arm64/libcuelooppty.dylib +0 -0
  4. package/native/darwin-arm64/libcueloopvt.dylib +0 -0
  5. package/native/src/pty.zig +193 -0
  6. package/native/src/shim.zig +114 -0
  7. package/package.json +36 -31
  8. package/src/App.autoclose.test.tsx +137 -0
  9. package/src/App.diff.test.tsx +158 -22
  10. package/src/App.export.test.tsx +44 -37
  11. package/src/App.plan-review.test.tsx +672 -0
  12. package/src/App.prototype.test.tsx +155 -0
  13. package/src/App.readonly.test.tsx +60 -36
  14. package/src/App.rendering.test.tsx +132 -0
  15. package/src/App.review-panel.test.tsx +136 -0
  16. package/src/App.submit-confirm.test.tsx +218 -0
  17. package/src/App.test.tsx +128 -69
  18. package/src/App.toast.test.tsx +75 -0
  19. package/src/App.tsx +0 -0
  20. package/src/App.walk.test.tsx +289 -0
  21. package/src/__snapshots__/view-plan.regression.test.ts.snap +100 -0
  22. package/src/attribution.test.ts +31 -0
  23. package/src/attribution.ts +38 -0
  24. package/src/clipboard.test.ts +23 -0
  25. package/src/clipboard.ts +30 -0
  26. package/src/components/AnnotationCard.compose-rows.test.ts +49 -0
  27. package/src/components/AnnotationCard.stories.tsx +94 -0
  28. package/src/components/AnnotationCard.tsx +241 -0
  29. package/src/components/Breadcrumb.stories.tsx +19 -0
  30. package/src/components/Breadcrumb.tsx +44 -0
  31. package/src/components/CodeBlock.stories.tsx +42 -0
  32. package/src/components/CodeBlock.tsx +83 -0
  33. package/src/components/CompletionOverlay.stories.tsx +23 -0
  34. package/src/components/CompletionOverlay.tsx +59 -0
  35. package/src/components/ConfirmCard.stories.tsx +35 -0
  36. package/src/components/ConfirmCard.tsx +127 -0
  37. package/src/components/ConfirmDialog.stories.tsx +21 -0
  38. package/src/components/ConfirmDialog.tsx +64 -0
  39. package/src/components/DiffSheet.scroll.test.tsx +123 -0
  40. package/src/components/DiffSheet.stories.tsx +78 -0
  41. package/src/components/DiffSheet.syntax.test.tsx +62 -0
  42. package/src/components/DiffSheet.tsx +355 -0
  43. package/src/components/InboxList.stories.tsx +31 -0
  44. package/src/components/InboxList.tsx +62 -0
  45. package/src/components/KeybindsDialog.stories.tsx +31 -0
  46. package/src/components/KeybindsDialog.tsx +58 -0
  47. package/src/components/MarkerPopover.stories.tsx +58 -0
  48. package/src/components/MarkerPopover.tsx +108 -0
  49. package/src/components/MenuBar.stories.tsx +37 -0
  50. package/src/components/MenuBar.tsx +74 -0
  51. package/src/components/PlanSheet.stories.tsx +113 -0
  52. package/src/components/PlanSheet.tsx +456 -0
  53. package/src/components/PromptDialog.stories.tsx +36 -0
  54. package/src/components/PromptDialog.tsx +78 -0
  55. package/src/components/PrototypeSheet.stories.tsx +20 -0
  56. package/src/components/PrototypeSheet.tsx +358 -0
  57. package/src/components/ReviewPanel.stories.tsx +86 -0
  58. package/src/components/ReviewPanel.tsx +101 -0
  59. package/src/components/ReviewRail.stories.tsx +218 -0
  60. package/src/components/ReviewRail.tsx +317 -0
  61. package/src/components/SettingsDialog.stories.tsx +111 -0
  62. package/src/components/SettingsDialog.tsx +141 -0
  63. package/src/components/SettingsRows.stories.tsx +18 -0
  64. package/src/components/SettingsRows.tsx +108 -0
  65. package/src/components/Toast.stories.tsx +12 -0
  66. package/src/components/Toast.tsx +44 -0
  67. package/src/components/WalkWizard.stories.tsx +100 -0
  68. package/src/components/WalkWizard.tsx +152 -0
  69. package/src/components/__snapshots__/stories.test.tsx.snap +1857 -0
  70. package/src/components/agent-launcher.stories.tsx +26 -0
  71. package/src/components/agent-launcher.test.tsx +49 -0
  72. package/src/components/agent-launcher.tsx +147 -0
  73. package/src/components/diff-sheet-layout.test.ts +127 -0
  74. package/src/components/diff-sheet-layout.ts +167 -0
  75. package/src/components/primitives/Button.stories.tsx +32 -0
  76. package/src/components/primitives/Button.tsx +51 -0
  77. package/src/components/primitives/Card.stories.tsx +25 -0
  78. package/src/components/primitives/Card.tsx +59 -0
  79. package/src/components/primitives/Dialog.stories.tsx +24 -0
  80. package/src/components/primitives/Dialog.tsx +64 -0
  81. package/src/components/primitives/StatusBar.stories.tsx +11 -0
  82. package/src/components/primitives/StatusBar.tsx +22 -0
  83. package/src/components/primitives/Tabs.stories.tsx +29 -0
  84. package/src/components/primitives/Tabs.tsx +98 -0
  85. package/src/components/primitives/Toolbar.stories.tsx +17 -0
  86. package/src/components/primitives/Toolbar.tsx +14 -0
  87. package/src/components/primitives/frame.test.ts +12 -0
  88. package/src/components/primitives/frame.ts +14 -0
  89. package/src/components/quick-actions-editor.stories.tsx +54 -0
  90. package/src/components/quick-actions-editor.tsx +136 -0
  91. package/src/components/stories-app.tsx +123 -0
  92. package/src/components/stories.test.tsx +62 -0
  93. package/src/components/story-fixtures.ts +98 -0
  94. package/src/components/story.ts +69 -0
  95. package/src/components/syntax-highlight.ts +92 -0
  96. package/src/components/terminal-pane.test.tsx +40 -0
  97. package/src/components/terminal-pane.ts +181 -0
  98. package/src/components/theme-context.stories.tsx +46 -0
  99. package/src/components/theme-context.tsx +36 -0
  100. package/src/components/truncate-text.ts +6 -0
  101. package/src/config.test.ts +430 -31
  102. package/src/config.ts +304 -26
  103. package/src/diff-hunk-curate.test.ts +315 -0
  104. package/src/diff-hunk-curate.ts +233 -0
  105. package/src/diff-intraline.test.ts +164 -0
  106. package/src/diff-intraline.ts +132 -0
  107. package/src/diff-syntax.test.ts +96 -0
  108. package/src/diff-syntax.ts +154 -0
  109. package/src/editor.test.ts +143 -0
  110. package/src/editor.ts +123 -9
  111. package/src/ghostty-terminal.test.ts +82 -0
  112. package/src/ghostty-terminal.ts +168 -0
  113. package/src/herdr.test.ts +41 -0
  114. package/src/herdr.ts +27 -0
  115. package/src/index.ts +10 -0
  116. package/src/integrations.test.ts +41 -0
  117. package/src/integrations.ts +30 -0
  118. package/src/intent-dispatch.test.ts +493 -0
  119. package/src/intent-dispatch.ts +428 -0
  120. package/src/key-bindings.test.ts +152 -0
  121. package/src/key-bindings.ts +430 -0
  122. package/src/keymap.test.ts +622 -0
  123. package/src/keymap.ts +339 -0
  124. package/src/kitty-image.test.ts +20 -0
  125. package/src/kitty-image.ts +75 -0
  126. package/src/prototype-browser.test.ts +55 -0
  127. package/src/prototype-browser.ts +243 -0
  128. package/src/pty.test.ts +78 -0
  129. package/src/pty.ts +222 -0
  130. package/src/review-panel.test.ts +113 -0
  131. package/src/review-panel.ts +83 -0
  132. package/src/run.ts +9 -5
  133. package/src/serve.test.ts +24 -9
  134. package/src/serve.ts +7 -7
  135. package/src/session-controller.curate.test.ts +253 -0
  136. package/src/session-controller.share.test.ts +309 -0
  137. package/src/session-controller.ts +914 -0
  138. package/src/share.ts +113 -0
  139. package/src/test-support.test.ts +55 -0
  140. package/src/test-support.ts +187 -0
  141. package/src/theme-presets.ts +168 -0
  142. package/src/theme.ts +38 -17
  143. package/src/version.ts +5 -0
  144. package/src/view-diff.test.ts +19 -8
  145. package/src/view-diff.ts +34 -23
  146. package/src/view-plan.regression.test.ts +60 -0
  147. package/src/view-plan.test.ts +399 -0
  148. package/src/view-plan.ts +495 -0
  149. package/src/walk.test.ts +121 -0
  150. package/src/walk.ts +70 -0
  151. package/src/view.test.ts +0 -157
  152. package/src/view.ts +0 -0
@@ -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,82 @@
1
+ #!/bin/sh
2
+ # Rebuild libcueloopvt.dylib: libghostty-vt (Ghostty's VT core) statically linked
3
+ # behind the flat FFI shim. Pinned to a Ghostty commit + Zig 0.16.0.
4
+ # Output lands in native/<os>-<arch>/libcueloopvt.dylib. Requires curl and git.
5
+ set -eu
6
+
7
+ GHOSTTY_COMMIT="5834a0e3df621802e9578e4562d88b0c2ad4ada8"
8
+ ZIG_VERSION="0.16.0"
9
+
10
+ here="$(cd "$(dirname "$0")" && pwd)"
11
+ work="${TMPDIR:-/tmp}/cueloop-libghostty-build"
12
+ mkdir -p "$work"
13
+ export ZIG_GLOBAL_CACHE_DIR="$work/zig-gcache"
14
+ export ZIG_LOCAL_CACHE_DIR="$work/zig-lcache"
15
+ cd "$work"
16
+
17
+ # Host triple for the Bun prebuilt dir and Zig download. The two tools use
18
+ # different architecture names.
19
+ arch="$(uname -m)"; os="$(uname -s | tr '[:upper:]' '[:lower:]')"
20
+ case "$os" in darwin) zig_os="macos"; dylib="libcueloopvt.dylib";; linux) zig_os="linux"; dylib="libcueloopvt.so";; *) echo "unsupported os $os" >&2; exit 1;; esac
21
+ case "$arch-$os" in
22
+ arm64-darwin|aarch64-linux) zig_arch="aarch64"; bun_arch="arm64";;
23
+ x86_64-darwin|x86_64-linux) zig_arch="x86_64"; bun_arch="x64";;
24
+ *) echo "unsupported architecture $arch on $os" >&2; exit 1;;
25
+ esac
26
+ case "$zig_arch-$zig_os" in
27
+ aarch64-macos) zig_sha="b23d70deaa879b5c2d486ed3316f7eaa53e84acf6fc9cc747de152450d401489";;
28
+ x86_64-macos) zig_sha="0387557ed1877bc6a2e1802c8391953baddba76081876301c522f52977b52ba7";;
29
+ aarch64-linux) zig_sha="ea4b09bfb22ec6f6c6ceac57ab63efb6b46e17ab08d21f69f3a48b38e1534f17";;
30
+ x86_64-linux) zig_sha="70e49664a74374b48b51e6f3fdfbf437f6395d42509050588bd49abe52ba3d00";;
31
+ esac
32
+ outdir="$here/${os}-${bun_arch}"
33
+ zig_archive="zig-$zig_arch-$zig_os-$ZIG_VERSION.tar.xz"
34
+
35
+ if [ ! -f "$zig_archive" ]; then
36
+ echo "== fetching zig $ZIG_VERSION =="
37
+ curl -fsSL -o "$zig_archive" "https://ziglang.org/download/$ZIG_VERSION/$zig_archive"
38
+ fi
39
+ printf '%s %s\n' "$zig_sha" "$zig_archive" | shasum -a 256 -c -
40
+
41
+ if [ ! -d ghostty ]; then
42
+ echo "== cloning ghostty @ $GHOSTTY_COMMIT =="
43
+ mkdir ghostty && cd ghostty && git init -q
44
+ git remote add origin https://github.com/ghostty-org/ghostty.git
45
+ cd ..
46
+ fi
47
+ if ! git -C ghostty cat-file -e "$GHOSTTY_COMMIT^{commit}" 2>/dev/null; then
48
+ git -C ghostty fetch -q --depth 1 origin "$GHOSTTY_COMMIT"
49
+ fi
50
+
51
+ # Build only from fresh trees materialized from the verified archive and exact
52
+ # Git object. Cached working-tree edits and stale install outputs cannot enter.
53
+ build_root="$(mktemp -d "$work/native-build.XXXXXX")"
54
+ trap 'rm -rf "$build_root"' EXIT HUP INT TERM
55
+ tar -xf "$zig_archive" -C "$build_root"
56
+ zig="$build_root/zig-$zig_arch-$zig_os-$ZIG_VERSION/zig"
57
+ ghostty_source="$build_root/ghostty"
58
+ mkdir "$ghostty_source"
59
+ git -C ghostty archive "$GHOSTTY_COMMIT" | tar -x -C "$ghostty_source"
60
+
61
+ echo "== building libghostty-vt (ReleaseFast) =="
62
+ cd "$ghostty_source"
63
+ # The final xcframework packaging step can fail while the .a we need is produced,
64
+ # so tolerate a non-zero exit here but assert the static lib exists below.
65
+ vt_install="$build_root/vt-install"
66
+ "$zig" build -Demit-lib-vt -Doptimize=ReleaseFast --prefix "$vt_install" || true
67
+ cd ..
68
+ staticlib="$vt_install/lib/libghostty-vt.a"
69
+ [ -f "$staticlib" ] || { echo "build failed: $staticlib not produced" >&2; exit 1; }
70
+
71
+ echo "== linking shim -> $outdir/$dylib =="
72
+ mkdir -p "$outdir"
73
+ if [ "$os" = "darwin" ]; then
74
+ "$zig" build-lib -dynamic -OReleaseFast -lc -femit-bin="$outdir/$dylib" \
75
+ -install_name "@rpath/$dylib" \
76
+ -I"$vt_install/include" "$here/src/shim.zig" "$staticlib" \
77
+ -framework CoreFoundation -framework CoreText -framework CoreGraphics
78
+ else
79
+ "$zig" build-lib -dynamic -OReleaseFast -lc -femit-bin="$outdir/$dylib" \
80
+ -I"$vt_install/include" "$here/src/shim.zig" "$staticlib" -lm -lpthread
81
+ fi
82
+ 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
+ }
@@ -0,0 +1,114 @@
1
+ // Flat FFI wrapper over libghostty-vt. It hides the allocator vtable, the
2
+ // by-value GhosttyPoint that bun:ffi cannot pass, and packed-cell decoding.
3
+ const std = @import("std");
4
+ const ghostty = @cImport({
5
+ @cInclude("ghostty/vt/types.h");
6
+ @cInclude("ghostty/vt/allocator.h");
7
+ @cInclude("ghostty/vt/terminal.h");
8
+ @cInclude("ghostty/vt/point.h");
9
+ @cInclude("ghostty/vt/grid_ref.h");
10
+ @cInclude("ghostty/vt/screen.h");
11
+ @cInclude("ghostty/vt/style.h");
12
+ @cInclude("ghostty/vt/color.h");
13
+ });
14
+
15
+ const CvtCell = extern struct {
16
+ codepoint: u32,
17
+ fg_r: u8,
18
+ fg_g: u8,
19
+ fg_b: u8,
20
+ fg_kind: u8,
21
+ bg_r: u8,
22
+ bg_g: u8,
23
+ bg_b: u8,
24
+ bg_kind: u8,
25
+ width: u8,
26
+ flags: u8,
27
+ _pad: u16,
28
+ };
29
+
30
+ comptime {
31
+ std.debug.assert(@sizeOf(CvtCell) == 16);
32
+ }
33
+
34
+ export fn cvt_new(cols: u16, rows: u16) ?*anyopaque {
35
+ var terminal: ghostty.GhosttyTerminal = null;
36
+ if (ghostty.ghostty_terminal_new(null, &terminal, cols, rows) != ghostty.GHOSTTY_SUCCESS) {
37
+ return null;
38
+ }
39
+ return terminal;
40
+ }
41
+
42
+ export fn cvt_free(terminal: ?*anyopaque) void {
43
+ if (terminal) |handle| ghostty.ghostty_terminal_free(@ptrCast(handle));
44
+ }
45
+
46
+ export fn cvt_write(terminal: *anyopaque, data: [*]const u8, len: usize) void {
47
+ ghostty.ghostty_terminal_vt_write(@ptrCast(terminal), data, len);
48
+ }
49
+
50
+ export fn cvt_resize(terminal: *anyopaque, cols: u16, rows: u16) void {
51
+ // Cell pixel size does not affect the character grid.
52
+ _ = ghostty.ghostty_terminal_resize(@ptrCast(terminal), cols, rows, 1, 1);
53
+ }
54
+
55
+ // Writes [col u16 LE, row u16 LE, visible u8] into the caller's eight bytes.
56
+ export fn cvt_cursor(terminal: *anyopaque, out: [*]u8) void {
57
+ var x: u16 = 0;
58
+ var y: u16 = 0;
59
+ var visible = false;
60
+ _ = ghostty.ghostty_terminal_get(@ptrCast(terminal), ghostty.GHOSTTY_TERMINAL_DATA_CURSOR_X, &x);
61
+ _ = ghostty.ghostty_terminal_get(@ptrCast(terminal), ghostty.GHOSTTY_TERMINAL_DATA_CURSOR_Y, &y);
62
+ _ = ghostty.ghostty_terminal_get(@ptrCast(terminal), ghostty.GHOSTTY_TERMINAL_DATA_CURSOR_VISIBLE, &visible);
63
+ out[0] = @truncate(x);
64
+ out[1] = @truncate(x >> 8);
65
+ out[2] = @truncate(y);
66
+ out[3] = @truncate(y >> 8);
67
+ out[4] = @intFromBool(visible);
68
+ }
69
+
70
+ fn decodeColor(color: *const ghostty.GhosttyStyleColor, red: *u8, green: *u8, blue: *u8, kind: *u8) void {
71
+ if (color.tag == ghostty.GHOSTTY_STYLE_COLOR_RGB) {
72
+ kind.* = 2;
73
+ red.* = color.value.rgb.r;
74
+ green.* = color.value.rgb.g;
75
+ blue.* = color.value.rgb.b;
76
+ } else if (color.tag == ghostty.GHOSTTY_STYLE_COLOR_PALETTE) {
77
+ kind.* = 1;
78
+ red.* = @intCast(color.value.palette);
79
+ }
80
+ }
81
+
82
+ export fn cvt_cell(terminal: *anyopaque, x: u16, y: u32, out: *CvtCell) c_int {
83
+ out.* = std.mem.zeroes(CvtCell);
84
+
85
+ var point: ghostty.GhosttyPoint = undefined;
86
+ point.tag = ghostty.GHOSTTY_POINT_TAG_VIEWPORT;
87
+ point.value.coordinate.x = x;
88
+ point.value.coordinate.y = y;
89
+
90
+ var reference: ghostty.GhosttyGridRef = undefined;
91
+ if (ghostty.ghostty_terminal_grid_ref(@ptrCast(terminal), point, &reference) != ghostty.GHOSTTY_SUCCESS) return -1;
92
+
93
+ var cell: ghostty.GhosttyCell = undefined;
94
+ if (ghostty.ghostty_grid_ref_cell(&reference, &cell) != ghostty.GHOSTTY_SUCCESS) return -1;
95
+
96
+ _ = ghostty.ghostty_cell_get(cell, ghostty.GHOSTTY_CELL_DATA_CODEPOINT, &out.codepoint);
97
+ var width: u32 = 0;
98
+ _ = ghostty.ghostty_cell_get(cell, ghostty.GHOSTTY_CELL_DATA_WIDE, &width);
99
+ out.width = @intCast(width);
100
+
101
+ var style: ghostty.GhosttyStyle = undefined;
102
+ ghostty.ghostty_style_default(&style);
103
+ if (ghostty.ghostty_grid_ref_style(&reference, &style) == ghostty.GHOSTTY_SUCCESS) {
104
+ decodeColor(&style.fg_color, &out.fg_r, &out.fg_g, &out.fg_b, &out.fg_kind);
105
+ decodeColor(&style.bg_color, &out.bg_r, &out.bg_g, &out.bg_b, &out.bg_kind);
106
+ if (style.bold) out.flags |= 1;
107
+ if (style.italic) out.flags |= 2;
108
+ if (style.underline != 0) out.flags |= 4;
109
+ if (style.inverse) out.flags |= 8;
110
+ if (style.faint) out.flags |= 16;
111
+ if (style.strikethrough) out.flags |= 32;
112
+ }
113
+ return 0;
114
+ }
package/package.json CHANGED
@@ -1,45 +1,50 @@
1
1
  {
2
2
  "name": "@cueloop/client",
3
- "version": "0.1.0-alpha.5",
3
+ "version": "0.1.0-alpha.50",
4
+ "description": "The cueloop review UI: projection renderer, keyboard grammar, review rail",
5
+ "homepage": "https://github.com/mmurakaru/cueloop#readme",
6
+ "bugs": {
7
+ "url": "https://github.com/mmurakaru/cueloop/issues"
8
+ },
4
9
  "license": "Apache-2.0",
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/mmurakaru/cueloop.git",
13
+ "directory": "packages/client"
14
+ },
15
+ "files": [
16
+ "src",
17
+ "native",
18
+ "README.md"
19
+ ],
5
20
  "type": "module",
6
21
  "exports": {
7
- ".": "./src/index.ts"
22
+ ".": "./src/index.ts",
23
+ "./config": "./src/config.ts"
24
+ },
25
+ "publishConfig": {
26
+ "access": "public"
8
27
  },
9
28
  "dependencies": {
10
- "@cueloop/daemon": "0.1.0-alpha.5",
11
- "@cueloop/extension-api": "0.1.0-alpha.5",
12
- "@cueloop/integration-obsidian": "0.1.0-alpha.5",
13
- "@cueloop/schema": "0.1.0-alpha.5",
14
- "@opentui/core": "^0.4.3",
15
- "@opentui/react": "^0.4.3",
29
+ "@cueloop/daemon": "0.1.0-alpha.50",
30
+ "@cueloop/extension-api": "0.1.0-alpha.50",
31
+ "@cueloop/integration-obsidian": "0.1.0-alpha.50",
32
+ "@cueloop/schema": "0.1.0-alpha.50",
33
+ "@opentui/core": "^0.5.2",
34
+ "@opentui/keymap": "0.5.2",
35
+ "@opentui/react": "^0.5.2",
36
+ "@opentui/ssh": "0.5.2",
16
37
  "@pierre/diffs": "^1.3.5",
17
- "react": "^19.2.0",
18
- "@opentui/ssh": "0.4.5"
38
+ "diff": "^8",
39
+ "puppeteer-core": "^25.8.0",
40
+ "react": "^19.2.0"
19
41
  },
20
42
  "devDependencies": {
21
43
  "@types/react": "^19.2.0",
22
- "ssh2": "^1.17.0",
23
- "@types/ssh2": "^1.15.5"
44
+ "@types/ssh2": "^1.15.5",
45
+ "ssh2": "^1.17.0"
24
46
  },
25
- "publishConfig": {
26
- "access": "public"
27
- },
28
- "files": [
29
- "src",
30
- "README.md"
31
- ],
32
47
  "engines": {
33
48
  "bun": ">=1.3.0"
34
- },
35
- "repository": {
36
- "type": "git",
37
- "url": "git+https://github.com/mmurakaru/cueloop.git",
38
- "directory": "packages/client"
39
- },
40
- "homepage": "https://github.com/mmurakaru/cueloop#readme",
41
- "bugs": {
42
- "url": "https://github.com/mmurakaru/cueloop/issues"
43
- },
44
- "description": "The cueloop review UI: projection renderer, keyboard grammar, review rail"
45
- }
49
+ }
50
+ }
@@ -0,0 +1,137 @@
1
+ /** Post-submit completion overlay: prompt, opt-in persistence, countdown, dismissal. Countdown seconds tick on an injected ManualClock, so tests advance time instead of waiting it out. */
2
+
3
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
4
+ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
5
+ import { tmpdir } from "node:os";
6
+ import { join } from "node:path";
7
+ import React from "react";
8
+ import { testRender } from "@opentui/react/test-utils";
9
+ import { ManualClock } from "@opentui/core/testing";
10
+ import { DaemonServer } from "@cueloop/daemon";
11
+ import type { ReviewSession } from "@cueloop/schema";
12
+ import { App } from "./App";
13
+ import { isolateUserConfig, press, settle, waitForText } from "./test-support";
14
+
15
+ const PLAN = "# Plan\n\nShip the thing.\n";
16
+
17
+ let home: string;
18
+ let server: DaemonServer;
19
+ let session: ReviewSession;
20
+ let configPath: string;
21
+ let restoreUserConfig: () => void;
22
+ let clock: ManualClock;
23
+
24
+ beforeEach(() => {
25
+ home = mkdtempSync(join(tmpdir(), "cueloop-close-"));
26
+ configPath = join(home, "config.toml");
27
+ restoreUserConfig = isolateUserConfig(home, "config.toml");
28
+ server = new DaemonServer({ home, idleExitMs: 0 });
29
+ server.start();
30
+ session = server.core.sessionCreate({
31
+ workspace: { repoRoot: "/repo", branch: "main" },
32
+ artifact: { type: "plan", content: PLAN, meta: { title: "Plan" } },
33
+ });
34
+ clock = new ManualClock();
35
+ });
36
+ afterEach(() => {
37
+ restoreUserConfig();
38
+ server.stop();
39
+ rmSync(home, { recursive: true, force: true });
40
+ });
41
+
42
+ async function renderApp(onExit?: (code: number) => void) {
43
+ const setup = await testRender(
44
+ <App home={home} sessionId={session.id} onExit={onExit} clock={clock} />,
45
+ {
46
+ width: 120,
47
+ height: 30,
48
+ },
49
+ );
50
+ await waitForText(setup, "cueloop");
51
+ return setup;
52
+ }
53
+
54
+ type Setup = Awaited<ReturnType<typeof renderApp>>;
55
+
56
+ async function submitApprove(setup: Setup): Promise<void> {
57
+ await press(setup, "enter"); // open submit (approve default: no pending items)
58
+ await press(setup, "enter"); // confirm
59
+ await waitForText(setup, "review approved");
60
+ }
61
+
62
+ describe("completion overlay", () => {
63
+ test("submit with auto-close off counts down from 5; ⏎ exits now", async () => {
64
+ // Arrange
65
+ let exited = -1;
66
+ const setup = await renderApp((code) => (exited = code));
67
+
68
+ // Act
69
+ await submitApprove(setup);
70
+
71
+ // Assert
72
+ const frame = await waitForText(setup, "closing in 5s");
73
+ expect(frame).toContain("review approved");
74
+ expect(frame).toContain("close [return]");
75
+ expect(frame).toContain("return to plan [esc]");
76
+
77
+ // Act
78
+ await press(setup, "enter");
79
+
80
+ // Assert
81
+ expect(exited).toBe(0);
82
+ });
83
+
84
+ test("a remembers the countdown as the persisted default", async () => {
85
+ // Arrange
86
+ const setup = await renderApp();
87
+ await submitApprove(setup);
88
+ await waitForText(setup, "closing in 5s");
89
+
90
+ // Act
91
+ await press(setup, "a");
92
+
93
+ // Assert
94
+ expect(existsSync(configPath)).toBe(true);
95
+ expect(readFileSync(configPath, "utf8")).toContain("auto_close = 5");
96
+
97
+ // Act
98
+ // the countdown keeps running; each second is one manual-clock tick
99
+ clock.advance(1000);
100
+
101
+ // Assert
102
+ await waitForText(setup, "closing in 4s");
103
+ });
104
+
105
+ test("esc dismisses to the resolved read-only view", async () => {
106
+ // Arrange
107
+ const setup = await renderApp();
108
+ await submitApprove(setup);
109
+ await waitForText(setup, "closing in 5s");
110
+
111
+ // Act
112
+ // a bare ESC sits in the input parser's escape-sequence disambiguation
113
+ // window before it is delivered - the frame wait absorbs it
114
+ await press(setup, "escape");
115
+
116
+ // Assert
117
+ const frame = await waitForText(setup, "resolved: approve");
118
+ expect(frame).not.toContain("closing in");
119
+ });
120
+
121
+ test("configured auto_close counts down and exits without interaction", async () => {
122
+ // Arrange
123
+ writeFileSync(configPath, "[ui]\nauto_close = 1\n");
124
+ let exited = -1;
125
+ const setup = await renderApp((code) => (exited = code));
126
+ await submitApprove(setup);
127
+ await waitForText(setup, "closing in 1s");
128
+ expect(exited).toBe(-1);
129
+
130
+ // Act
131
+ clock.advance(1000);
132
+ await settle(setup);
133
+
134
+ // Assert
135
+ expect(exited).toBe(0);
136
+ });
137
+ });