@cueloop/client 0.1.0-alpha.42 → 0.1.0-alpha.44
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/native/build-pty.sh +51 -0
- package/native/darwin-arm64/libcuelooppty.dylib +0 -0
- package/native/src/pty.zig +193 -0
- package/package.json +5 -6
- package/src/App.plan-review.test.tsx +58 -32
- package/src/components/AnnotationCard.stories.tsx +6 -2
- package/src/components/AnnotationCard.tsx +7 -3
- package/src/components/ConfirmCard.tsx +1 -1
- package/src/components/ReviewRail.tsx +5 -5
- package/src/components/__snapshots__/stories.test.tsx.snap +146 -146
- package/src/components/agent-launcher.stories.tsx +1 -1
- package/src/components/agent-launcher.test.tsx +32 -4
- package/src/components/agent-launcher.tsx +21 -54
- package/src/components/primitives/Button.tsx +4 -1
- package/src/components/terminal-pane.ts +15 -8
- package/src/key-bindings.ts +6 -0
- package/src/pty.test.ts +78 -0
- package/src/pty.ts +222 -0
- package/src/theme.ts +2 -2
|
@@ -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 =="
|
|
Binary file
|
|
@@ -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.
|
|
3
|
+
"version": "0.1.0-alpha.44",
|
|
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.
|
|
30
|
-
"@cueloop/extension-api": "0.1.0-alpha.
|
|
31
|
-
"@cueloop/integration-obsidian": "0.1.0-alpha.
|
|
32
|
-
"@cueloop/schema": "0.1.0-alpha.
|
|
29
|
+
"@cueloop/daemon": "0.1.0-alpha.44",
|
|
30
|
+
"@cueloop/extension-api": "0.1.0-alpha.44",
|
|
31
|
+
"@cueloop/integration-obsidian": "0.1.0-alpha.44",
|
|
32
|
+
"@cueloop/schema": "0.1.0-alpha.44",
|
|
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
|
},
|
|
@@ -87,26 +87,20 @@ function backgroundsOf(setup: Setup, needle: string): string[] {
|
|
|
87
87
|
return backgrounds;
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
-
/** Whether any border character (box-drawing) is painted in the given foreground hex. */
|
|
91
|
-
function hasBorderColor(setup: Setup, hex: string): boolean {
|
|
92
|
-
for (const line of setup.captureSpans().lines) {
|
|
93
|
-
for (const span of line.spans) {
|
|
94
|
-
if (!/[╭╮╰╯│─┌┐└┘]/.test(span.text)) continue;
|
|
95
|
-
const [red, green, blue] = span.fg.toInts();
|
|
96
|
-
const rendered =
|
|
97
|
-
"#" + [red, green, blue].map((part) => part.toString(16).padStart(2, "0")).join("");
|
|
98
|
-
if (rendered === hex) return true;
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
return false;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
90
|
/** Move the cursor to "The daemon persists sessions to disk atomically." */
|
|
105
91
|
async function toContextParagraph(setup: Setup): Promise<void> {
|
|
106
92
|
await press(setup, "j");
|
|
107
93
|
await press(setup, "j");
|
|
108
94
|
}
|
|
109
95
|
|
|
96
|
+
/** Click the first on-screen occurrence of a string, by its char-frame position. */
|
|
97
|
+
async function clickText(setup: Setup, needle: string): Promise<void> {
|
|
98
|
+
const lines = setup.captureCharFrame().split("\n");
|
|
99
|
+
const row = lines.findIndex((line) => line.includes(needle));
|
|
100
|
+
await setup.mockMouse.click(lines[row]!.indexOf(needle) + 1, row);
|
|
101
|
+
await setup.renderOnce();
|
|
102
|
+
}
|
|
103
|
+
|
|
110
104
|
describe("share button", () => {
|
|
111
105
|
test("the owner's plan header shows the Share button next to Edit", async () => {
|
|
112
106
|
// Arrange / Act
|
|
@@ -296,6 +290,45 @@ describe("inline compose keeps the anchor painted", () => {
|
|
|
296
290
|
// runner, and the whole-suite publish lane has timed even 15s out; give the
|
|
297
291
|
// heaviest frame-wait chain generous headroom so runner load cannot flake it.
|
|
298
292
|
}, 60_000);
|
|
293
|
+
|
|
294
|
+
test("clicking the Cancel word-button closes the compose box", async () => {
|
|
295
|
+
// Arrange
|
|
296
|
+
const setup = await renderApp();
|
|
297
|
+
await toContextParagraph(setup);
|
|
298
|
+
await press(setup, "v");
|
|
299
|
+
await press(setup, "l");
|
|
300
|
+
await press(setup, "c");
|
|
301
|
+
await waitForText(setup, "Cancel");
|
|
302
|
+
|
|
303
|
+
// Act
|
|
304
|
+
await clickText(setup, "Cancel");
|
|
305
|
+
|
|
306
|
+
// Assert
|
|
307
|
+
await waitForTextGone(setup, 'comment on "');
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
test("clicking Cancel in a saved card's edit composer closes it", async () => {
|
|
311
|
+
// Arrange
|
|
312
|
+
const setup = await renderApp();
|
|
313
|
+
await toContextParagraph(setup);
|
|
314
|
+
await press(setup, "v");
|
|
315
|
+
await press(setup, "l");
|
|
316
|
+
await press(setup, "c");
|
|
317
|
+
await waitForText(setup, "Cancel");
|
|
318
|
+
await type(setup, "note body");
|
|
319
|
+
await press(setup, "enter");
|
|
320
|
+
await waitForState(setup, () => server.core.sessionGet(session.id).annotations.length === 1);
|
|
321
|
+
// select the saved card, then activate it to open the in-place editor
|
|
322
|
+
await clickText(setup, "note body");
|
|
323
|
+
await clickText(setup, "note body");
|
|
324
|
+
await waitForText(setup, "Cancel");
|
|
325
|
+
|
|
326
|
+
// Act
|
|
327
|
+
await clickText(setup, "Cancel");
|
|
328
|
+
|
|
329
|
+
// Assert - the editor closes back to the plain saved card
|
|
330
|
+
await waitForTextGone(setup, "Cancel");
|
|
331
|
+
});
|
|
299
332
|
});
|
|
300
333
|
|
|
301
334
|
describe("compose newline convention", () => {
|
|
@@ -525,7 +558,7 @@ describe("sheet header", () => {
|
|
|
525
558
|
expect(frame).toContain("Share");
|
|
526
559
|
});
|
|
527
560
|
|
|
528
|
-
test("the Agent tab shows the harness launcher
|
|
561
|
+
test("the Agent tab shows the text-only harness launcher buttons", async () => {
|
|
529
562
|
// Arrange
|
|
530
563
|
const setup = await renderApp();
|
|
531
564
|
const lines = setup.captureCharFrame().split("\n");
|
|
@@ -536,12 +569,11 @@ describe("sheet header", () => {
|
|
|
536
569
|
await setup.mockMouse.click(tabColumn + 1, tabRow);
|
|
537
570
|
await setup.renderOnce();
|
|
538
571
|
|
|
539
|
-
// Assert -
|
|
540
|
-
await waitForText(setup, "
|
|
572
|
+
// Assert - the three launcher buttons, in order
|
|
573
|
+
await waitForText(setup, "Claude Code");
|
|
541
574
|
const frame = setup.captureCharFrame();
|
|
542
|
-
expect(frame).toContain("
|
|
543
|
-
expect(frame).toContain("
|
|
544
|
-
expect(frame).toContain("agent/worker-3");
|
|
575
|
+
expect(frame).toContain("Pi");
|
|
576
|
+
expect(frame).toContain("OpenAI Codex");
|
|
545
577
|
});
|
|
546
578
|
});
|
|
547
579
|
|
|
@@ -549,18 +581,12 @@ describe("quick-actions settings editor", () => {
|
|
|
549
581
|
test("expanding an action edits its system prompt inline and persists it", async () => {
|
|
550
582
|
// Arrange
|
|
551
583
|
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
584
|
|
|
559
585
|
// 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");
|
|
586
|
+
await clickText(setup, "menu");
|
|
587
|
+
await clickText(setup, "Settings");
|
|
588
|
+
await clickText(setup, "Actions");
|
|
589
|
+
await clickText(setup, "Zoom out, research in depth");
|
|
564
590
|
await type(setup, "CUSTOM");
|
|
565
591
|
await setup.renderOnce();
|
|
566
592
|
|
|
@@ -589,9 +615,9 @@ describe("edit-exit reconciliation", () => {
|
|
|
589
615
|
|
|
590
616
|
// Act
|
|
591
617
|
// deselect the card so e reaches the editor hand-off, then edit; deselection
|
|
592
|
-
// shows as the card's
|
|
618
|
+
// shows as the card's selected fill (elevated) clearing to a transparent card
|
|
593
619
|
await press(setup, "escape");
|
|
594
|
-
await waitForState(setup, () => !
|
|
620
|
+
await waitForState(setup, () => !backgroundsOf(setup, "Anchor me").includes(DARK.elevated));
|
|
595
621
|
await press(setup, "e");
|
|
596
622
|
|
|
597
623
|
// Assert
|
|
@@ -29,6 +29,7 @@ export const SavedSelected: Story = {
|
|
|
29
29
|
isOrphan: false,
|
|
30
30
|
isBlocking: false,
|
|
31
31
|
author: "me",
|
|
32
|
+
tone: DARK.accent,
|
|
32
33
|
editing: null,
|
|
33
34
|
onPress: () => {},
|
|
34
35
|
}}
|
|
@@ -48,12 +49,13 @@ export const SavedCollaborator: Story = {
|
|
|
48
49
|
isOrphan: false,
|
|
49
50
|
isBlocking: false,
|
|
50
51
|
author: "Priya",
|
|
52
|
+
tone: DARK.blue,
|
|
51
53
|
editing: null,
|
|
52
54
|
onPress: () => {},
|
|
53
55
|
}}
|
|
54
56
|
/>
|
|
55
57
|
),
|
|
56
|
-
expectedColors: [DARK.
|
|
58
|
+
expectedColors: [DARK.blue],
|
|
57
59
|
};
|
|
58
60
|
|
|
59
61
|
export const SavedOrphanBlocking: Story = {
|
|
@@ -67,12 +69,13 @@ export const SavedOrphanBlocking: Story = {
|
|
|
67
69
|
isOrphan: true,
|
|
68
70
|
isBlocking: true,
|
|
69
71
|
author: "me",
|
|
72
|
+
tone: DARK.accent,
|
|
70
73
|
editing: null,
|
|
71
74
|
onPress: () => {},
|
|
72
75
|
}}
|
|
73
76
|
/>
|
|
74
77
|
),
|
|
75
|
-
expectedColors: [DARK.
|
|
78
|
+
expectedColors: [DARK.accent],
|
|
76
79
|
};
|
|
77
80
|
|
|
78
81
|
export const SavedEditing: Story = {
|
|
@@ -86,6 +89,7 @@ export const SavedEditing: Story = {
|
|
|
86
89
|
isOrphan: false,
|
|
87
90
|
isBlocking: false,
|
|
88
91
|
author: "me",
|
|
92
|
+
tone: DARK.accent,
|
|
89
93
|
editing: { text: "new body", ...draftCallbacks },
|
|
90
94
|
onPress: () => {},
|
|
91
95
|
}}
|
|
@@ -34,6 +34,8 @@ export interface AnnotationSaved {
|
|
|
34
34
|
isBlocking: boolean;
|
|
35
35
|
/** Border-title author: a collaborator's display name, or "me" for the reviewer's own note. */
|
|
36
36
|
author: string;
|
|
37
|
+
/** Border + title color for this card (own = salmon, collaborator = blue). */
|
|
38
|
+
tone: string;
|
|
37
39
|
/** Non-null while the card body is being rewritten in place. */
|
|
38
40
|
editing: AnnotationDraft | null;
|
|
39
41
|
onPress: () => void;
|
|
@@ -202,9 +204,11 @@ export function AnnotationCard({
|
|
|
202
204
|
height: cardHeight(contentRows),
|
|
203
205
|
border: true,
|
|
204
206
|
borderStyle: FRAME_BORDER_STYLE,
|
|
205
|
-
//
|
|
206
|
-
//
|
|
207
|
-
|
|
207
|
+
// the border (and its title) always wears the card's tone - salmon for
|
|
208
|
+
// own notes, blue for a collaborator's; selection reads from a filled
|
|
209
|
+
// background so the color stays put whether or not the card is active
|
|
210
|
+
borderColor: card.tone,
|
|
211
|
+
backgroundColor: card.isSelected || card.editing ? tokens.elevated : "transparent",
|
|
208
212
|
paddingLeft: 1,
|
|
209
213
|
paddingRight: 1,
|
|
210
214
|
}}
|
|
@@ -103,7 +103,7 @@ export function ConfirmCard({
|
|
|
103
103
|
<Card
|
|
104
104
|
title=" submit review "
|
|
105
105
|
contentRows={CONFIRM_CONTENT_ROWS + (viewedSummary !== undefined ? 1 : 0)}
|
|
106
|
-
borderColor={tokens.
|
|
106
|
+
borderColor={tokens.text}
|
|
107
107
|
backgroundColor="transparent"
|
|
108
108
|
marginRight={1}
|
|
109
109
|
theme={theme}
|
|
@@ -140,6 +140,8 @@ export const ReviewRail = forwardRef<ReviewRailHandle, ReviewRailProps>(function
|
|
|
140
140
|
: isAgentNote(annotation)
|
|
141
141
|
? "agent"
|
|
142
142
|
: "me",
|
|
143
|
+
// own + agent notes read as salmon; a share collaborator's note as blue
|
|
144
|
+
tone: annotation.author && !isAgentNote(annotation) ? tokens.blue : tokens.accent,
|
|
143
145
|
editing:
|
|
144
146
|
cardEdit && cardEdit.id === annotation.id
|
|
145
147
|
? {
|
|
@@ -187,7 +189,6 @@ export const ReviewRail = forwardRef<ReviewRailHandle, ReviewRailProps>(function
|
|
|
187
189
|
width,
|
|
188
190
|
backgroundColor: tokens.panel,
|
|
189
191
|
flexDirection: "column",
|
|
190
|
-
paddingLeft: 1,
|
|
191
192
|
paddingBottom: 1,
|
|
192
193
|
}}
|
|
193
194
|
>
|
|
@@ -202,8 +203,7 @@ export const ReviewRail = forwardRef<ReviewRailHandle, ReviewRailProps>(function
|
|
|
202
203
|
</TabList>
|
|
203
204
|
</Tabs>
|
|
204
205
|
{railTab === "agent" ? (
|
|
205
|
-
<box style={{ flexGrow: 1, flexDirection: "column"
|
|
206
|
-
<text> </text>
|
|
206
|
+
<box style={{ flexGrow: 1, flexDirection: "column" }}>
|
|
207
207
|
<AgentLauncher
|
|
208
208
|
session={session}
|
|
209
209
|
onLaunchHarness={onLaunchHarness}
|
|
@@ -295,8 +295,8 @@ function RemovalCard({
|
|
|
295
295
|
<Card
|
|
296
296
|
title={title}
|
|
297
297
|
contentRows={contentRows}
|
|
298
|
-
borderColor={
|
|
299
|
-
backgroundColor="transparent"
|
|
298
|
+
borderColor={tokens.red}
|
|
299
|
+
backgroundColor={isSelected ? tokens.elevated : "transparent"}
|
|
300
300
|
theme={theme}
|
|
301
301
|
>
|
|
302
302
|
{previewLines.length === 0 ? (
|