@cueloop/client 0.1.0-alpha.4 → 0.1.0-alpha.41
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.sh +56 -0
- package/native/darwin-arm64/libcueloopvt.dylib +0 -0
- package/native/src/shim.c +107 -0
- package/package.json +34 -24
- package/src/App.autoclose.test.tsx +137 -0
- package/src/App.diff.test.tsx +158 -22
- package/src/App.export.test.tsx +44 -37
- package/src/App.plan-review.test.tsx +632 -0
- package/src/App.readonly.test.tsx +60 -36
- package/src/App.rendering.test.tsx +132 -0
- package/src/App.review-panel.test.tsx +136 -0
- package/src/App.submit-confirm.test.tsx +220 -0
- package/src/App.test.tsx +128 -69
- package/src/App.toast.test.tsx +75 -0
- package/src/App.tsx +0 -0
- package/src/App.walk.test.tsx +288 -0
- package/src/attribution.test.ts +31 -0
- package/src/attribution.ts +38 -0
- package/src/clipboard.test.ts +23 -0
- package/src/clipboard.ts +30 -0
- package/src/components/AnnotationCard.compose-rows.test.ts +49 -0
- package/src/components/AnnotationCard.stories.tsx +94 -0
- package/src/components/AnnotationCard.tsx +228 -0
- package/src/components/Breadcrumb.stories.tsx +19 -0
- package/src/components/Breadcrumb.tsx +44 -0
- package/src/components/CodeBlock.stories.tsx +42 -0
- package/src/components/CodeBlock.tsx +83 -0
- package/src/components/CompletionOverlay.stories.tsx +23 -0
- package/src/components/CompletionOverlay.tsx +59 -0
- package/src/components/ConfirmCard.stories.tsx +56 -0
- package/src/components/ConfirmCard.tsx +135 -0
- package/src/components/ConfirmDialog.stories.tsx +21 -0
- package/src/components/ConfirmDialog.tsx +64 -0
- package/src/components/DiffSheet.scroll.test.tsx +123 -0
- package/src/components/DiffSheet.stories.tsx +78 -0
- package/src/components/DiffSheet.syntax.test.tsx +62 -0
- package/src/components/DiffSheet.tsx +355 -0
- package/src/components/InboxList.stories.tsx +31 -0
- package/src/components/InboxList.tsx +62 -0
- package/src/components/KeybindsDialog.stories.tsx +31 -0
- package/src/components/KeybindsDialog.tsx +58 -0
- package/src/components/MarkerPopover.stories.tsx +58 -0
- package/src/components/MarkerPopover.tsx +102 -0
- package/src/components/MenuBar.stories.tsx +37 -0
- package/src/components/MenuBar.tsx +74 -0
- package/src/components/PlanSheet.stories.tsx +113 -0
- package/src/components/PlanSheet.tsx +389 -0
- package/src/components/PromptDialog.stories.tsx +36 -0
- package/src/components/PromptDialog.tsx +78 -0
- package/src/components/ReviewPanel.stories.tsx +86 -0
- package/src/components/ReviewPanel.tsx +101 -0
- package/src/components/ReviewRail.stories.tsx +220 -0
- package/src/components/ReviewRail.tsx +324 -0
- package/src/components/SettingsDialog.stories.tsx +111 -0
- package/src/components/SettingsDialog.tsx +141 -0
- package/src/components/SettingsRows.stories.tsx +18 -0
- package/src/components/SettingsRows.tsx +108 -0
- package/src/components/Toast.stories.tsx +12 -0
- package/src/components/Toast.tsx +44 -0
- package/src/components/WalkWizard.stories.tsx +100 -0
- package/src/components/WalkWizard.tsx +152 -0
- package/src/components/__snapshots__/stories.test.tsx.snap +1829 -0
- package/src/components/agent-launcher.stories.tsx +26 -0
- package/src/components/agent-launcher.test.tsx +21 -0
- package/src/components/agent-launcher.tsx +180 -0
- package/src/components/diff-sheet-layout.test.ts +127 -0
- package/src/components/diff-sheet-layout.ts +167 -0
- package/src/components/primitives/Button.stories.tsx +32 -0
- package/src/components/primitives/Button.tsx +48 -0
- package/src/components/primitives/Card.stories.tsx +25 -0
- package/src/components/primitives/Card.tsx +59 -0
- package/src/components/primitives/Dialog.stories.tsx +24 -0
- package/src/components/primitives/Dialog.tsx +64 -0
- package/src/components/primitives/StatusBar.stories.tsx +11 -0
- package/src/components/primitives/StatusBar.tsx +22 -0
- package/src/components/primitives/Tabs.stories.tsx +29 -0
- package/src/components/primitives/Tabs.tsx +98 -0
- package/src/components/primitives/Toolbar.stories.tsx +17 -0
- package/src/components/primitives/Toolbar.tsx +14 -0
- package/src/components/primitives/frame.test.ts +12 -0
- package/src/components/primitives/frame.ts +14 -0
- package/src/components/quick-actions-editor.stories.tsx +54 -0
- package/src/components/quick-actions-editor.tsx +136 -0
- package/src/components/stories-app.tsx +123 -0
- package/src/components/stories.test.tsx +62 -0
- package/src/components/story-fixtures.ts +98 -0
- package/src/components/story.ts +69 -0
- package/src/components/syntax-highlight.ts +92 -0
- package/src/components/terminal-pane.test.tsx +40 -0
- package/src/components/terminal-pane.ts +174 -0
- package/src/components/theme-context.stories.tsx +46 -0
- package/src/components/theme-context.tsx +36 -0
- package/src/components/truncate-text.ts +6 -0
- package/src/config.test.ts +430 -31
- package/src/config.ts +304 -26
- package/src/diff-hunk-curate.test.ts +315 -0
- package/src/diff-hunk-curate.ts +233 -0
- package/src/diff-intraline.test.ts +164 -0
- package/src/diff-intraline.ts +132 -0
- package/src/diff-syntax.test.ts +96 -0
- package/src/diff-syntax.ts +154 -0
- package/src/editor.test.ts +143 -0
- package/src/editor.ts +123 -9
- package/src/ghostty-terminal.test.ts +45 -0
- package/src/ghostty-terminal.ts +168 -0
- package/src/herdr.test.ts +41 -0
- package/src/herdr.ts +27 -0
- package/src/index.ts +10 -0
- package/src/integrations.test.ts +41 -0
- package/src/integrations.ts +30 -0
- package/src/intent-dispatch.test.ts +492 -0
- package/src/intent-dispatch.ts +428 -0
- package/src/key-bindings.test.ts +152 -0
- package/src/key-bindings.ts +424 -0
- package/src/keymap.test.ts +622 -0
- package/src/keymap.ts +339 -0
- package/src/review-panel.test.ts +113 -0
- package/src/review-panel.ts +83 -0
- package/src/run.ts +9 -5
- package/src/serve.test.ts +24 -9
- package/src/serve.ts +7 -7
- package/src/session-controller.curate.test.ts +253 -0
- package/src/session-controller.share.test.ts +309 -0
- package/src/session-controller.ts +900 -0
- package/src/share.ts +113 -0
- package/src/test-support.test.ts +55 -0
- package/src/test-support.ts +187 -0
- package/src/theme-presets.ts +168 -0
- package/src/theme.ts +36 -15
- package/src/version.ts +5 -0
- package/src/view-diff.test.ts +19 -8
- package/src/view-diff.ts +34 -23
- package/src/view-plan.test.ts +294 -0
- package/src/view-plan.ts +408 -0
- package/src/walk.test.ts +121 -0
- package/src/walk.ts +70 -0
- package/src/view.test.ts +0 -157
- package/src/view.ts +0 -0
package/native/build.sh
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# Rebuild libcueloopvt.dylib: libghostty-vt (Ghostty's VT core) statically linked
|
|
3
|
+
# behind the flat FFI shim in src/shim.c. Pinned to a Ghostty commit + Zig 0.16.0.
|
|
4
|
+
# Output lands in native/<os>-<arch>/libcueloopvt.dylib. Requires curl, git, clang.
|
|
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
|
+
cd "$work"
|
|
14
|
+
|
|
15
|
+
# host triple for the prebuilt dir + zig download
|
|
16
|
+
arch="$(uname -m)"; os="$(uname -s | tr '[:upper:]' '[:lower:]')"
|
|
17
|
+
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
|
|
18
|
+
outdir="$here/${os}-${arch}"
|
|
19
|
+
|
|
20
|
+
if [ ! -x "zig-$arch-$zig_os-$ZIG_VERSION/zig" ]; then
|
|
21
|
+
echo "== fetching zig $ZIG_VERSION =="
|
|
22
|
+
curl -sSL -o zig.tar.xz "https://ziglang.org/download/$ZIG_VERSION/zig-$arch-$zig_os-$ZIG_VERSION.tar.xz"
|
|
23
|
+
tar -xf zig.tar.xz
|
|
24
|
+
fi
|
|
25
|
+
zig="$work/zig-$arch-$zig_os-$ZIG_VERSION/zig"
|
|
26
|
+
|
|
27
|
+
if [ ! -d ghostty ]; then
|
|
28
|
+
echo "== cloning ghostty @ $GHOSTTY_COMMIT =="
|
|
29
|
+
mkdir ghostty && cd ghostty && git init -q
|
|
30
|
+
git remote add origin https://github.com/ghostty-org/ghostty.git
|
|
31
|
+
git fetch -q --depth 1 origin "$GHOSTTY_COMMIT"
|
|
32
|
+
git checkout -q FETCH_HEAD
|
|
33
|
+
cd ..
|
|
34
|
+
fi
|
|
35
|
+
|
|
36
|
+
echo "== building libghostty-vt (ReleaseFast) =="
|
|
37
|
+
cd ghostty
|
|
38
|
+
# The final xcframework packaging step can fail while the .a we need is produced,
|
|
39
|
+
# so tolerate a non-zero exit here but assert the static lib exists below.
|
|
40
|
+
ZIG_GLOBAL_CACHE_DIR="$work/zig-gcache" ZIG_LOCAL_CACHE_DIR="$work/zig-lcache" \
|
|
41
|
+
"$zig" build -Demit-lib-vt -Doptimize=ReleaseFast --prefix "$work/vt-install" || true
|
|
42
|
+
cd ..
|
|
43
|
+
staticlib="$work/vt-install/lib/libghostty-vt.a"
|
|
44
|
+
[ -f "$staticlib" ] || { echo "build failed: $staticlib not produced" >&2; exit 1; }
|
|
45
|
+
|
|
46
|
+
echo "== linking shim -> $outdir/$dylib =="
|
|
47
|
+
mkdir -p "$outdir"
|
|
48
|
+
if [ "$os" = "darwin" ]; then
|
|
49
|
+
clang -O2 -dynamiclib -o "$outdir/$dylib" "$here/src/shim.c" \
|
|
50
|
+
-I"$work/vt-install/include" "$staticlib" \
|
|
51
|
+
-framework CoreFoundation -framework CoreText -framework CoreGraphics
|
|
52
|
+
else
|
|
53
|
+
clang -O2 -shared -fPIC -o "$outdir/$dylib" "$here/src/shim.c" \
|
|
54
|
+
-I"$work/vt-install/include" "$staticlib" -lm -lpthread
|
|
55
|
+
fi
|
|
56
|
+
echo "== done: $outdir/$dylib =="
|
|
Binary file
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// Flat, FFI-trivial wrapper over libghostty-vt: hides the allocator vtable, the
|
|
2
|
+
// by-value GhosttyPoint (which bun:ffi cannot pass), and the packed-cell decode,
|
|
3
|
+
// exposing exactly what an OpenTUI paint loop needs - new/write/resize/cell/free.
|
|
4
|
+
#include <stdint.h>
|
|
5
|
+
#include <stddef.h>
|
|
6
|
+
#include <stdbool.h>
|
|
7
|
+
#include <string.h>
|
|
8
|
+
#include "ghostty/vt/types.h"
|
|
9
|
+
#include "ghostty/vt/allocator.h"
|
|
10
|
+
#include "ghostty/vt/terminal.h"
|
|
11
|
+
#include "ghostty/vt/point.h"
|
|
12
|
+
#include "ghostty/vt/grid_ref.h"
|
|
13
|
+
#include "ghostty/vt/screen.h"
|
|
14
|
+
#include "ghostty/vt/style.h"
|
|
15
|
+
#include "ghostty/vt/color.h"
|
|
16
|
+
|
|
17
|
+
typedef struct {
|
|
18
|
+
uint32_t codepoint; // 0 = blank
|
|
19
|
+
uint8_t fg_r, fg_g, fg_b, fg_kind; // kind: 0 default, 1 palette (index in fg_r), 2 rgb
|
|
20
|
+
uint8_t bg_r, bg_g, bg_b, bg_kind;
|
|
21
|
+
uint8_t width; // 0 (wide trailing), 1, 2
|
|
22
|
+
uint8_t flags; // bit0 bold, 1 italic, 2 underline, 3 inverse, 4 faint, 5 strikethrough
|
|
23
|
+
uint16_t _pad;
|
|
24
|
+
} CvtCell;
|
|
25
|
+
|
|
26
|
+
void *cvt_new(uint16_t cols, uint16_t rows) {
|
|
27
|
+
GhosttyTerminal t = NULL;
|
|
28
|
+
// NULL allocator selects libghostty's default (libc malloc/free when linked).
|
|
29
|
+
if (ghostty_terminal_new(NULL, &t, cols, rows) != GHOSTTY_SUCCESS) return NULL;
|
|
30
|
+
return (void *)t;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
void cvt_free(void *t) {
|
|
34
|
+
if (t) ghostty_terminal_free((GhosttyTerminal)t);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
void cvt_write(void *t, const uint8_t *data, size_t len) {
|
|
38
|
+
ghostty_terminal_vt_write((GhosttyTerminal)t, data, len);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
void cvt_resize(void *t, uint16_t cols, uint16_t rows) {
|
|
42
|
+
// cell pixel size is irrelevant to the character grid; pass a nominal 1x1.
|
|
43
|
+
ghostty_terminal_resize((GhosttyTerminal)t, cols, rows, 1, 1);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Writes cursor state into out8: [0..1] col (u16), [2..3] row (u16), [4] visible (u8).
|
|
47
|
+
void cvt_cursor(void *t, uint8_t *out8) {
|
|
48
|
+
uint16_t cx = 0, cy = 0;
|
|
49
|
+
bool vis = false;
|
|
50
|
+
ghostty_terminal_get((GhosttyTerminal)t, GHOSTTY_TERMINAL_DATA_CURSOR_X, &cx);
|
|
51
|
+
ghostty_terminal_get((GhosttyTerminal)t, GHOSTTY_TERMINAL_DATA_CURSOR_Y, &cy);
|
|
52
|
+
ghostty_terminal_get((GhosttyTerminal)t, GHOSTTY_TERMINAL_DATA_CURSOR_VISIBLE, &vis);
|
|
53
|
+
memcpy(out8, &cx, 2);
|
|
54
|
+
memcpy(out8 + 2, &cy, 2);
|
|
55
|
+
out8[4] = vis ? 1 : 0;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
static void decode_color(const GhosttyStyleColor *c, uint8_t *r, uint8_t *g, uint8_t *b,
|
|
59
|
+
uint8_t *kind) {
|
|
60
|
+
if (c->tag == GHOSTTY_STYLE_COLOR_RGB) {
|
|
61
|
+
*kind = 2;
|
|
62
|
+
*r = c->value.rgb.r;
|
|
63
|
+
*g = c->value.rgb.g;
|
|
64
|
+
*b = c->value.rgb.b;
|
|
65
|
+
} else if (c->tag == GHOSTTY_STYLE_COLOR_PALETTE) {
|
|
66
|
+
*kind = 1;
|
|
67
|
+
*r = (uint8_t)c->value.palette; // palette index in r; JS maps 0-255 -> RGB
|
|
68
|
+
*g = 0;
|
|
69
|
+
*b = 0;
|
|
70
|
+
} else {
|
|
71
|
+
*kind = 0;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Reads the viewport cell at (x, y). Returns 0 on success, -1 otherwise.
|
|
76
|
+
int cvt_cell(void *t, uint16_t x, uint32_t y, CvtCell *out) {
|
|
77
|
+
memset(out, 0, sizeof(*out));
|
|
78
|
+
GhosttyPoint p;
|
|
79
|
+
p.tag = GHOSTTY_POINT_TAG_VIEWPORT;
|
|
80
|
+
p.value.coordinate.x = x;
|
|
81
|
+
p.value.coordinate.y = y;
|
|
82
|
+
GhosttyGridRef ref;
|
|
83
|
+
if (ghostty_terminal_grid_ref((GhosttyTerminal)t, p, &ref) != GHOSTTY_SUCCESS) return -1;
|
|
84
|
+
GhosttyCell cell;
|
|
85
|
+
if (ghostty_grid_ref_cell(&ref, &cell) != GHOSTTY_SUCCESS) return -1;
|
|
86
|
+
uint32_t cp = 0;
|
|
87
|
+
ghostty_cell_get(cell, GHOSTTY_CELL_DATA_CODEPOINT, &cp);
|
|
88
|
+
out->codepoint = cp;
|
|
89
|
+
uint32_t wide = 0;
|
|
90
|
+
ghostty_cell_get(cell, GHOSTTY_CELL_DATA_WIDE, &wide);
|
|
91
|
+
out->width = (uint8_t)wide;
|
|
92
|
+
GhosttyStyle st;
|
|
93
|
+
ghostty_style_default(&st);
|
|
94
|
+
if (ghostty_grid_ref_style(&ref, &st) == GHOSTTY_SUCCESS) {
|
|
95
|
+
decode_color(&st.fg_color, &out->fg_r, &out->fg_g, &out->fg_b, &out->fg_kind);
|
|
96
|
+
decode_color(&st.bg_color, &out->bg_r, &out->bg_g, &out->bg_b, &out->bg_kind);
|
|
97
|
+
uint8_t f = 0;
|
|
98
|
+
if (st.bold) f |= 1;
|
|
99
|
+
if (st.italic) f |= 2;
|
|
100
|
+
if (st.underline) f |= 4;
|
|
101
|
+
if (st.inverse) f |= 8;
|
|
102
|
+
if (st.faint) f |= 16;
|
|
103
|
+
if (st.strikethrough) f |= 32;
|
|
104
|
+
out->flags = f;
|
|
105
|
+
}
|
|
106
|
+
return 0;
|
|
107
|
+
}
|
package/package.json
CHANGED
|
@@ -1,40 +1,50 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cueloop/client",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.41",
|
|
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.
|
|
11
|
-
"@cueloop/extension-api": "0.1.0-alpha.
|
|
12
|
-
"@cueloop/integration-obsidian": "0.1.0-alpha.
|
|
13
|
-
"@cueloop/schema": "0.1.0-alpha.
|
|
14
|
-
"@opentui/core": "^0.
|
|
15
|
-
"@opentui/
|
|
29
|
+
"@cueloop/daemon": "0.1.0-alpha.41",
|
|
30
|
+
"@cueloop/extension-api": "0.1.0-alpha.41",
|
|
31
|
+
"@cueloop/integration-obsidian": "0.1.0-alpha.41",
|
|
32
|
+
"@cueloop/schema": "0.1.0-alpha.41",
|
|
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
|
-
"
|
|
18
|
-
"
|
|
38
|
+
"bun-pty": "^0.4.10",
|
|
39
|
+
"diff": "^8",
|
|
40
|
+
"react": "^19.2.0"
|
|
19
41
|
},
|
|
20
42
|
"devDependencies": {
|
|
21
43
|
"@types/react": "^19.2.0",
|
|
22
|
-
"ssh2": "^1.
|
|
23
|
-
"
|
|
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
49
|
}
|
|
40
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
|
+
});
|
package/src/App.diff.test.tsx
CHANGED
|
@@ -9,6 +9,7 @@ import { testRender } from "@opentui/react/test-utils";
|
|
|
9
9
|
import { DaemonServer } from "@cueloop/daemon";
|
|
10
10
|
import type { ReviewSession } from "@cueloop/schema";
|
|
11
11
|
import { App } from "./App";
|
|
12
|
+
import { isolateUserConfig, press, waitForText } from "./test-support";
|
|
12
13
|
|
|
13
14
|
const PATCH = `diff --git a/src/store.ts b/src/store.ts
|
|
14
15
|
index 111..222 100644
|
|
@@ -22,11 +23,13 @@ index 111..222 100644
|
|
|
22
23
|
`;
|
|
23
24
|
|
|
24
25
|
let home: string;
|
|
26
|
+
let restoreUserConfig: () => void;
|
|
25
27
|
let server: DaemonServer;
|
|
26
28
|
let session: ReviewSession;
|
|
27
29
|
|
|
28
30
|
beforeEach(() => {
|
|
29
31
|
home = mkdtempSync(join(tmpdir(), "cueloop-diff-"));
|
|
32
|
+
restoreUserConfig = isolateUserConfig(home);
|
|
30
33
|
server = new DaemonServer({ home, idleExitMs: 0 });
|
|
31
34
|
server.start();
|
|
32
35
|
session = server.core.sessionCreate({
|
|
@@ -35,31 +38,26 @@ beforeEach(() => {
|
|
|
35
38
|
});
|
|
36
39
|
});
|
|
37
40
|
afterEach(() => {
|
|
41
|
+
restoreUserConfig();
|
|
38
42
|
server.stop();
|
|
39
43
|
rmSync(home, { recursive: true, force: true });
|
|
40
44
|
});
|
|
41
45
|
|
|
42
46
|
async function renderApp() {
|
|
43
|
-
const setup = await testRender(<App home={home} sessionId={session.id} />, {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
await setup.renderOnce();
|
|
47
|
+
const setup = await testRender(<App home={home} sessionId={session.id} />, {
|
|
48
|
+
width: 120,
|
|
49
|
+
height: 30,
|
|
50
|
+
});
|
|
51
|
+
await waitForText(setup, "cueloop");
|
|
49
52
|
return setup;
|
|
50
53
|
}
|
|
51
54
|
|
|
52
|
-
type Setup = Awaited<ReturnType<typeof renderApp>>;
|
|
53
|
-
async function press(setup: Setup, k: string): Promise<void> {
|
|
54
|
-
if (k === "enter") setup.mockInput.pressKey("RETURN");
|
|
55
|
-
else await setup.mockInput.typeText(k);
|
|
56
|
-
await Bun.sleep(15);
|
|
57
|
-
await setup.renderOnce();
|
|
58
|
-
}
|
|
59
|
-
|
|
60
55
|
describe("diff review", () => {
|
|
61
56
|
test("renders file header, hunks, and signed lines", async () => {
|
|
57
|
+
// Arrange
|
|
62
58
|
const setup = await renderApp();
|
|
59
|
+
|
|
60
|
+
// Assert
|
|
63
61
|
const frame = setup.captureCharFrame();
|
|
64
62
|
expect(frame).toContain("■ src/store.ts");
|
|
65
63
|
expect(frame).toContain("@@ -1,4 +1,4 @@");
|
|
@@ -68,32 +66,170 @@ describe("diff review", () => {
|
|
|
68
66
|
});
|
|
69
67
|
|
|
70
68
|
test("line comment: quote-anchored, lands in feedback with the code line", async () => {
|
|
69
|
+
// Arrange
|
|
71
70
|
const setup = await renderApp();
|
|
71
|
+
|
|
72
|
+
// Act
|
|
72
73
|
// rows: file(0), hunk(1), ctx(2), del(3), add(4)
|
|
73
74
|
for (let i = 0; i < 4; i++) await press(setup, "j");
|
|
74
75
|
await press(setup, "c");
|
|
75
|
-
|
|
76
|
+
|
|
77
|
+
// Assert
|
|
78
|
+
await waitForText(setup, 'comment on "');
|
|
79
|
+
|
|
80
|
+
// Act
|
|
76
81
|
await setup.mockInput.typeText("Map needs an eviction story.");
|
|
77
82
|
await press(setup, "enter");
|
|
78
|
-
|
|
79
|
-
|
|
83
|
+
|
|
84
|
+
// Assert
|
|
85
|
+
// inline annotation card rendered under the line
|
|
86
|
+
await waitForText(setup, "◆ Map needs an eviction story.");
|
|
80
87
|
const stored = server.core.sessionGet(session.id);
|
|
81
88
|
expect(stored.annotations.length).toBe(1);
|
|
82
89
|
expect(stored.annotations[0]!.anchor.quote).toContain("new Map()");
|
|
83
|
-
// inline annotation card rendered under the line
|
|
84
|
-
expect(setup.captureCharFrame()).toContain("◆ Map needs an eviction story.");
|
|
85
90
|
|
|
91
|
+
// Act
|
|
86
92
|
await press(setup, "enter"); // submit
|
|
87
93
|
await press(setup, "enter"); // confirm request_changes
|
|
88
|
-
|
|
94
|
+
|
|
95
|
+
// Assert
|
|
96
|
+
await waitForText(setup, "feedback sent");
|
|
89
97
|
const resolved = server.core.sessionGet(session.id);
|
|
90
98
|
expect(resolved.verdict!.feedback).toContain("new Map()");
|
|
91
99
|
expect(resolved.verdict!.feedback).toContain("Map needs an eviction story.");
|
|
92
100
|
});
|
|
93
101
|
|
|
94
|
-
test("
|
|
102
|
+
test("curation needs full file contents; a legacy diff answers", async () => {
|
|
103
|
+
// Arrange - the session carries no artifact.files
|
|
95
104
|
const setup = await renderApp();
|
|
105
|
+
|
|
106
|
+
// Act
|
|
107
|
+
await press(setup, "x");
|
|
108
|
+
|
|
109
|
+
// Assert
|
|
110
|
+
await waitForText(setup, "hunk curation needs full file contents");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("rejecting the only change empties the curated working copy", async () => {
|
|
114
|
+
// Arrange - a diff session that carries full file contents
|
|
115
|
+
const withFiles = server.core.sessionCreate({
|
|
116
|
+
workspace: { repoRoot: "/repo", branch: "main" },
|
|
117
|
+
artifact: {
|
|
118
|
+
type: "diff",
|
|
119
|
+
content: PATCH,
|
|
120
|
+
meta: { title: "working tree" },
|
|
121
|
+
files: [
|
|
122
|
+
{
|
|
123
|
+
path: "src/store.ts",
|
|
124
|
+
oldContents: "export class Store {\n private items = [];\n}\n",
|
|
125
|
+
newContents: "export class Store {\n private items = new Map();\n}\n",
|
|
126
|
+
status: "modified",
|
|
127
|
+
},
|
|
128
|
+
],
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
const setup = await testRender(<App home={home} sessionId={withFiles.id} />, {
|
|
132
|
+
width: 120,
|
|
133
|
+
height: 30,
|
|
134
|
+
});
|
|
135
|
+
await waitForText(setup, "cueloop");
|
|
136
|
+
|
|
137
|
+
// Act - move to the added line and reject its change
|
|
138
|
+
// rows: file(0), hunk(1), ctx(2), del(3), add(4)
|
|
139
|
+
for (let i = 0; i < 4; i++) await press(setup, "j");
|
|
96
140
|
await press(setup, "x");
|
|
97
|
-
|
|
141
|
+
|
|
142
|
+
// Assert - the single change is gone, so the curated working copy is empty
|
|
143
|
+
await waitForText(setup, "change rejected");
|
|
144
|
+
const stored = server.core.sessionGet(withFiles.id);
|
|
145
|
+
expect(stored.workingCopy).toBe("");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("a rejected change lists in the rail and undo (u) restores it", async () => {
|
|
149
|
+
// Arrange - a diff session that carries full file contents
|
|
150
|
+
const withFiles = server.core.sessionCreate({
|
|
151
|
+
workspace: { repoRoot: "/repo", branch: "main" },
|
|
152
|
+
artifact: {
|
|
153
|
+
type: "diff",
|
|
154
|
+
content: PATCH,
|
|
155
|
+
meta: { title: "working tree" },
|
|
156
|
+
files: [
|
|
157
|
+
{
|
|
158
|
+
path: "src/store.ts",
|
|
159
|
+
oldContents: "export class Store {\n private items = [];\n}\n",
|
|
160
|
+
newContents: "export class Store {\n private items = new Map();\n}\n",
|
|
161
|
+
status: "modified",
|
|
162
|
+
},
|
|
163
|
+
],
|
|
164
|
+
},
|
|
165
|
+
});
|
|
166
|
+
const setup = await testRender(<App home={home} sessionId={withFiles.id} />, {
|
|
167
|
+
width: 120,
|
|
168
|
+
height: 30,
|
|
169
|
+
});
|
|
170
|
+
await waitForText(setup, "cueloop");
|
|
171
|
+
|
|
172
|
+
// Act - reject the change under the added line
|
|
173
|
+
// rows: file(0), hunk(1), ctx(2), del(3), add(4)
|
|
174
|
+
for (let i = 0; i < 4; i++) await press(setup, "j");
|
|
175
|
+
await press(setup, "x");
|
|
176
|
+
|
|
177
|
+
// Assert - the rail shows a removal card titled REJECT · me for the change
|
|
178
|
+
await waitForText(setup, "REJECT · me");
|
|
179
|
+
expect(server.core.sessionGet(withFiles.id).workingCopy).toBe("");
|
|
180
|
+
|
|
181
|
+
// Act - undo restores it (no selection, so the last item)
|
|
182
|
+
await press(setup, "u");
|
|
183
|
+
|
|
184
|
+
// Assert - the change returns to the curated diff (working copy cleared)
|
|
185
|
+
await waitForText(setup, "removal restored");
|
|
186
|
+
expect(server.core.sessionGet(withFiles.id).workingCopy).toBeUndefined();
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("a selected removal card shows an undo button that restores it on click", async () => {
|
|
190
|
+
// Arrange - a diff session that carries full file contents
|
|
191
|
+
const withFiles = server.core.sessionCreate({
|
|
192
|
+
workspace: { repoRoot: "/repo", branch: "main" },
|
|
193
|
+
artifact: {
|
|
194
|
+
type: "diff",
|
|
195
|
+
content: PATCH,
|
|
196
|
+
meta: { title: "working tree" },
|
|
197
|
+
files: [
|
|
198
|
+
{
|
|
199
|
+
path: "src/store.ts",
|
|
200
|
+
oldContents: "export class Store {\n private items = [];\n}\n",
|
|
201
|
+
newContents: "export class Store {\n private items = new Map();\n}\n",
|
|
202
|
+
status: "modified",
|
|
203
|
+
},
|
|
204
|
+
],
|
|
205
|
+
},
|
|
206
|
+
});
|
|
207
|
+
const setup = await testRender(<App home={home} sessionId={withFiles.id} />, {
|
|
208
|
+
width: 120,
|
|
209
|
+
height: 30,
|
|
210
|
+
});
|
|
211
|
+
await waitForText(setup, "cueloop");
|
|
212
|
+
|
|
213
|
+
// Act - reject the change; the removal card lands unselected (no undo button)
|
|
214
|
+
for (let i = 0; i < 4; i++) await press(setup, "j");
|
|
215
|
+
await press(setup, "x");
|
|
216
|
+
await waitForText(setup, "REJECT · me");
|
|
217
|
+
expect(setup.captureCharFrame()).not.toContain("undo");
|
|
218
|
+
|
|
219
|
+
// Act - click the card to select it; the undo button appears
|
|
220
|
+
const clickText = async (needle: string): Promise<void> => {
|
|
221
|
+
const lines = setup.captureCharFrame().split("\n");
|
|
222
|
+
const row = lines.findIndex((line) => line.includes(needle));
|
|
223
|
+
await setup.mockMouse.click(lines[row]!.indexOf(needle) + 1, row);
|
|
224
|
+
};
|
|
225
|
+
await clickText("REJECT · me");
|
|
226
|
+
await waitForText(setup, "undo");
|
|
227
|
+
|
|
228
|
+
// Act - click the undo button
|
|
229
|
+
await clickText("undo");
|
|
230
|
+
|
|
231
|
+
// Assert - the change returns to the curated diff (working copy cleared)
|
|
232
|
+
await waitForText(setup, "removal restored");
|
|
233
|
+
expect(server.core.sessionGet(withFiles.id).workingCopy).toBeUndefined();
|
|
98
234
|
});
|
|
99
235
|
});
|