@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.
Files changed (138) hide show
  1. package/native/build.sh +56 -0
  2. package/native/darwin-arm64/libcueloopvt.dylib +0 -0
  3. package/native/src/shim.c +107 -0
  4. package/package.json +34 -24
  5. package/src/App.autoclose.test.tsx +137 -0
  6. package/src/App.diff.test.tsx +158 -22
  7. package/src/App.export.test.tsx +44 -37
  8. package/src/App.plan-review.test.tsx +632 -0
  9. package/src/App.readonly.test.tsx +60 -36
  10. package/src/App.rendering.test.tsx +132 -0
  11. package/src/App.review-panel.test.tsx +136 -0
  12. package/src/App.submit-confirm.test.tsx +220 -0
  13. package/src/App.test.tsx +128 -69
  14. package/src/App.toast.test.tsx +75 -0
  15. package/src/App.tsx +0 -0
  16. package/src/App.walk.test.tsx +288 -0
  17. package/src/attribution.test.ts +31 -0
  18. package/src/attribution.ts +38 -0
  19. package/src/clipboard.test.ts +23 -0
  20. package/src/clipboard.ts +30 -0
  21. package/src/components/AnnotationCard.compose-rows.test.ts +49 -0
  22. package/src/components/AnnotationCard.stories.tsx +94 -0
  23. package/src/components/AnnotationCard.tsx +228 -0
  24. package/src/components/Breadcrumb.stories.tsx +19 -0
  25. package/src/components/Breadcrumb.tsx +44 -0
  26. package/src/components/CodeBlock.stories.tsx +42 -0
  27. package/src/components/CodeBlock.tsx +83 -0
  28. package/src/components/CompletionOverlay.stories.tsx +23 -0
  29. package/src/components/CompletionOverlay.tsx +59 -0
  30. package/src/components/ConfirmCard.stories.tsx +56 -0
  31. package/src/components/ConfirmCard.tsx +135 -0
  32. package/src/components/ConfirmDialog.stories.tsx +21 -0
  33. package/src/components/ConfirmDialog.tsx +64 -0
  34. package/src/components/DiffSheet.scroll.test.tsx +123 -0
  35. package/src/components/DiffSheet.stories.tsx +78 -0
  36. package/src/components/DiffSheet.syntax.test.tsx +62 -0
  37. package/src/components/DiffSheet.tsx +355 -0
  38. package/src/components/InboxList.stories.tsx +31 -0
  39. package/src/components/InboxList.tsx +62 -0
  40. package/src/components/KeybindsDialog.stories.tsx +31 -0
  41. package/src/components/KeybindsDialog.tsx +58 -0
  42. package/src/components/MarkerPopover.stories.tsx +58 -0
  43. package/src/components/MarkerPopover.tsx +102 -0
  44. package/src/components/MenuBar.stories.tsx +37 -0
  45. package/src/components/MenuBar.tsx +74 -0
  46. package/src/components/PlanSheet.stories.tsx +113 -0
  47. package/src/components/PlanSheet.tsx +389 -0
  48. package/src/components/PromptDialog.stories.tsx +36 -0
  49. package/src/components/PromptDialog.tsx +78 -0
  50. package/src/components/ReviewPanel.stories.tsx +86 -0
  51. package/src/components/ReviewPanel.tsx +101 -0
  52. package/src/components/ReviewRail.stories.tsx +220 -0
  53. package/src/components/ReviewRail.tsx +324 -0
  54. package/src/components/SettingsDialog.stories.tsx +111 -0
  55. package/src/components/SettingsDialog.tsx +141 -0
  56. package/src/components/SettingsRows.stories.tsx +18 -0
  57. package/src/components/SettingsRows.tsx +108 -0
  58. package/src/components/Toast.stories.tsx +12 -0
  59. package/src/components/Toast.tsx +44 -0
  60. package/src/components/WalkWizard.stories.tsx +100 -0
  61. package/src/components/WalkWizard.tsx +152 -0
  62. package/src/components/__snapshots__/stories.test.tsx.snap +1829 -0
  63. package/src/components/agent-launcher.stories.tsx +26 -0
  64. package/src/components/agent-launcher.test.tsx +21 -0
  65. package/src/components/agent-launcher.tsx +180 -0
  66. package/src/components/diff-sheet-layout.test.ts +127 -0
  67. package/src/components/diff-sheet-layout.ts +167 -0
  68. package/src/components/primitives/Button.stories.tsx +32 -0
  69. package/src/components/primitives/Button.tsx +48 -0
  70. package/src/components/primitives/Card.stories.tsx +25 -0
  71. package/src/components/primitives/Card.tsx +59 -0
  72. package/src/components/primitives/Dialog.stories.tsx +24 -0
  73. package/src/components/primitives/Dialog.tsx +64 -0
  74. package/src/components/primitives/StatusBar.stories.tsx +11 -0
  75. package/src/components/primitives/StatusBar.tsx +22 -0
  76. package/src/components/primitives/Tabs.stories.tsx +29 -0
  77. package/src/components/primitives/Tabs.tsx +98 -0
  78. package/src/components/primitives/Toolbar.stories.tsx +17 -0
  79. package/src/components/primitives/Toolbar.tsx +14 -0
  80. package/src/components/primitives/frame.test.ts +12 -0
  81. package/src/components/primitives/frame.ts +14 -0
  82. package/src/components/quick-actions-editor.stories.tsx +54 -0
  83. package/src/components/quick-actions-editor.tsx +136 -0
  84. package/src/components/stories-app.tsx +123 -0
  85. package/src/components/stories.test.tsx +62 -0
  86. package/src/components/story-fixtures.ts +98 -0
  87. package/src/components/story.ts +69 -0
  88. package/src/components/syntax-highlight.ts +92 -0
  89. package/src/components/terminal-pane.test.tsx +40 -0
  90. package/src/components/terminal-pane.ts +174 -0
  91. package/src/components/theme-context.stories.tsx +46 -0
  92. package/src/components/theme-context.tsx +36 -0
  93. package/src/components/truncate-text.ts +6 -0
  94. package/src/config.test.ts +430 -31
  95. package/src/config.ts +304 -26
  96. package/src/diff-hunk-curate.test.ts +315 -0
  97. package/src/diff-hunk-curate.ts +233 -0
  98. package/src/diff-intraline.test.ts +164 -0
  99. package/src/diff-intraline.ts +132 -0
  100. package/src/diff-syntax.test.ts +96 -0
  101. package/src/diff-syntax.ts +154 -0
  102. package/src/editor.test.ts +143 -0
  103. package/src/editor.ts +123 -9
  104. package/src/ghostty-terminal.test.ts +45 -0
  105. package/src/ghostty-terminal.ts +168 -0
  106. package/src/herdr.test.ts +41 -0
  107. package/src/herdr.ts +27 -0
  108. package/src/index.ts +10 -0
  109. package/src/integrations.test.ts +41 -0
  110. package/src/integrations.ts +30 -0
  111. package/src/intent-dispatch.test.ts +492 -0
  112. package/src/intent-dispatch.ts +428 -0
  113. package/src/key-bindings.test.ts +152 -0
  114. package/src/key-bindings.ts +424 -0
  115. package/src/keymap.test.ts +622 -0
  116. package/src/keymap.ts +339 -0
  117. package/src/review-panel.test.ts +113 -0
  118. package/src/review-panel.ts +83 -0
  119. package/src/run.ts +9 -5
  120. package/src/serve.test.ts +24 -9
  121. package/src/serve.ts +7 -7
  122. package/src/session-controller.curate.test.ts +253 -0
  123. package/src/session-controller.share.test.ts +309 -0
  124. package/src/session-controller.ts +900 -0
  125. package/src/share.ts +113 -0
  126. package/src/test-support.test.ts +55 -0
  127. package/src/test-support.ts +187 -0
  128. package/src/theme-presets.ts +168 -0
  129. package/src/theme.ts +36 -15
  130. package/src/version.ts +5 -0
  131. package/src/view-diff.test.ts +19 -8
  132. package/src/view-diff.ts +34 -23
  133. package/src/view-plan.test.ts +294 -0
  134. package/src/view-plan.ts +408 -0
  135. package/src/walk.test.ts +121 -0
  136. package/src/walk.ts +70 -0
  137. package/src/view.test.ts +0 -157
  138. package/src/view.ts +0 -0
@@ -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 =="
@@ -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.4",
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.4",
11
- "@cueloop/extension-api": "0.1.0-alpha.4",
12
- "@cueloop/integration-obsidian": "0.1.0-alpha.4",
13
- "@cueloop/schema": "0.1.0-alpha.4",
14
- "@opentui/core": "^0.4.3",
15
- "@opentui/react": "^0.4.3",
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
- "react": "^19.2.0",
18
- "@opentui/ssh": "0.4.5"
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.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
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
+ });
@@ -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} />, { width: 120, height: 30 });
44
- for (let i = 0; i < 40 && !setup.captureCharFrame().includes("cueloop"); i++) {
45
- await Bun.sleep(25);
46
- await setup.renderOnce();
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
- expect(setup.captureCharFrame()).toContain("COMMENT ON");
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
- await Bun.sleep(80);
79
- await setup.renderOnce();
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
- await Bun.sleep(100);
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("plan verbs are guarded in diff sessions", async () => {
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
- expect(setup.captureCharFrame()).toContain("plan-only verb");
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
  });