@cueloop/client 0.1.0-alpha.6 → 0.1.0-alpha.61

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 (161) 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 +37 -33
  8. package/src/App.appearance.test.tsx +51 -0
  9. package/src/App.autoclose.test.tsx +73 -47
  10. package/src/App.diff.test.tsx +169 -22
  11. package/src/App.export.test.tsx +49 -37
  12. package/src/App.plan-review.test.tsx +714 -0
  13. package/src/App.prototype.test.tsx +194 -0
  14. package/src/App.readonly.test.tsx +69 -35
  15. package/src/App.rendering.test.tsx +59 -31
  16. package/src/App.review-panel.test.tsx +143 -0
  17. package/src/App.submit-confirm.test.tsx +229 -0
  18. package/src/App.test.tsx +139 -68
  19. package/src/App.toast.test.tsx +77 -0
  20. package/src/App.tsx +0 -0
  21. package/src/App.walk.test.tsx +299 -0
  22. package/src/__snapshots__/view-plan.regression.test.ts.snap +100 -0
  23. package/src/app-key-state.ts +61 -0
  24. package/src/app-screens.tsx +270 -0
  25. package/src/app-view-model.ts +307 -0
  26. package/src/attribution.test.ts +31 -0
  27. package/src/attribution.ts +42 -0
  28. package/src/clipboard.test.ts +23 -0
  29. package/src/clipboard.ts +33 -0
  30. package/src/components/AnnotationCard.compose-rows.test.ts +49 -0
  31. package/src/components/AnnotationCard.stories.tsx +94 -0
  32. package/src/components/AnnotationCard.tsx +250 -0
  33. package/src/components/Breadcrumb.stories.tsx +19 -0
  34. package/src/components/Breadcrumb.tsx +45 -0
  35. package/src/components/CodeBlock.stories.tsx +42 -0
  36. package/src/components/CodeBlock.tsx +84 -0
  37. package/src/components/CompletionOverlay.stories.tsx +23 -0
  38. package/src/components/CompletionOverlay.tsx +60 -0
  39. package/src/components/ConfirmCard.stories.tsx +35 -0
  40. package/src/components/ConfirmCard.tsx +129 -0
  41. package/src/components/ConfirmDialog.stories.tsx +21 -0
  42. package/src/components/ConfirmDialog.tsx +66 -0
  43. package/src/components/DiffSheet.scroll.test.tsx +135 -0
  44. package/src/components/DiffSheet.stories.tsx +78 -0
  45. package/src/components/DiffSheet.syntax.test.tsx +65 -0
  46. package/src/components/DiffSheet.tsx +374 -0
  47. package/src/components/InboxList.stories.tsx +31 -0
  48. package/src/components/InboxList.tsx +64 -0
  49. package/src/components/KeybindsDialog.stories.tsx +31 -0
  50. package/src/components/KeybindsDialog.tsx +59 -0
  51. package/src/components/MarkerPopover.stories.tsx +58 -0
  52. package/src/components/MarkerPopover.tsx +109 -0
  53. package/src/components/MenuBar.stories.tsx +37 -0
  54. package/src/components/MenuBar.tsx +75 -0
  55. package/src/components/PlanSheet.stories.tsx +113 -0
  56. package/src/components/PlanSheet.tsx +422 -0
  57. package/src/components/PromptDialog.stories.tsx +36 -0
  58. package/src/components/PromptDialog.tsx +80 -0
  59. package/src/components/PrototypeSheet.stories.tsx +20 -0
  60. package/src/components/PrototypeSheet.tsx +450 -0
  61. package/src/components/ReviewPanel.stories.tsx +86 -0
  62. package/src/components/ReviewPanel.tsx +103 -0
  63. package/src/components/ReviewRail.stories.tsx +218 -0
  64. package/src/components/ReviewRail.tsx +318 -0
  65. package/src/components/SettingsDialog.stories.tsx +111 -0
  66. package/src/components/SettingsDialog.tsx +147 -0
  67. package/src/components/SettingsRows.stories.tsx +18 -0
  68. package/src/components/SettingsRows.tsx +112 -0
  69. package/src/components/Toast.stories.tsx +12 -0
  70. package/src/components/Toast.tsx +45 -0
  71. package/src/components/WalkWizard.stories.tsx +100 -0
  72. package/src/components/WalkWizard.tsx +152 -0
  73. package/src/components/__snapshots__/stories.test.tsx.snap +1857 -0
  74. package/src/components/agent-launcher.stories.tsx +26 -0
  75. package/src/components/agent-launcher.test.tsx +50 -0
  76. package/src/components/agent-launcher.tsx +149 -0
  77. package/src/components/diff-sheet-layout.test.ts +128 -0
  78. package/src/components/diff-sheet-layout.ts +184 -0
  79. package/src/components/plan-sheet-run-style.ts +90 -0
  80. package/src/components/primitives/Button.stories.tsx +32 -0
  81. package/src/components/primitives/Button.tsx +52 -0
  82. package/src/components/primitives/Card.stories.tsx +25 -0
  83. package/src/components/primitives/Card.tsx +60 -0
  84. package/src/components/primitives/Dialog.stories.tsx +24 -0
  85. package/src/components/primitives/Dialog.tsx +66 -0
  86. package/src/components/primitives/StatusBar.stories.tsx +11 -0
  87. package/src/components/primitives/StatusBar.tsx +23 -0
  88. package/src/components/primitives/Tabs.stories.tsx +29 -0
  89. package/src/components/primitives/Tabs.tsx +102 -0
  90. package/src/components/primitives/Toolbar.stories.tsx +17 -0
  91. package/src/components/primitives/Toolbar.tsx +14 -0
  92. package/src/components/primitives/frame.test.ts +12 -0
  93. package/src/components/primitives/frame.ts +14 -0
  94. package/src/components/quick-actions-editor.stories.tsx +54 -0
  95. package/src/components/quick-actions-editor.tsx +138 -0
  96. package/src/components/stories-app.tsx +124 -0
  97. package/src/components/stories.test.tsx +66 -0
  98. package/src/components/story-fixtures.ts +98 -0
  99. package/src/components/story.ts +75 -0
  100. package/src/components/syntax-highlight.ts +102 -0
  101. package/src/components/terminal-pane.test.tsx +40 -0
  102. package/src/components/terminal-pane.ts +193 -0
  103. package/src/components/theme-context.stories.tsx +46 -0
  104. package/src/components/theme-context.tsx +37 -0
  105. package/src/components/truncate-text.ts +7 -0
  106. package/src/config.test.ts +448 -31
  107. package/src/config.ts +339 -57
  108. package/src/diff-hunk-curate.test.ts +315 -0
  109. package/src/diff-hunk-curate.ts +253 -0
  110. package/src/diff-intraline.test.ts +165 -0
  111. package/src/diff-intraline.ts +146 -0
  112. package/src/diff-syntax.test.ts +96 -0
  113. package/src/diff-syntax.ts +176 -0
  114. package/src/editor.test.ts +149 -0
  115. package/src/editor.ts +135 -9
  116. package/src/ghostty-terminal.test.ts +84 -0
  117. package/src/ghostty-terminal.ts +176 -0
  118. package/src/herdr.test.ts +9 -34
  119. package/src/herdr.ts +17 -21
  120. package/src/index.ts +10 -0
  121. package/src/integrations.test.ts +41 -0
  122. package/src/integrations.ts +32 -0
  123. package/src/intent-dispatch.test.ts +499 -0
  124. package/src/intent-dispatch.ts +585 -0
  125. package/src/key-bindings.test.ts +156 -0
  126. package/src/key-bindings.ts +452 -0
  127. package/src/keymap.test.ts +635 -0
  128. package/src/keymap.ts +404 -0
  129. package/src/kitty-image.test.ts +66 -0
  130. package/src/kitty-image.ts +138 -0
  131. package/src/prototype-browser.test.ts +56 -0
  132. package/src/prototype-browser.ts +289 -0
  133. package/src/pty.test.ts +81 -0
  134. package/src/pty.ts +237 -0
  135. package/src/review-panel.test.ts +113 -0
  136. package/src/review-panel.ts +83 -0
  137. package/src/run.ts +16 -5
  138. package/src/serve.test.ts +25 -9
  139. package/src/serve.ts +19 -8
  140. package/src/session-controller.curate.test.ts +260 -0
  141. package/src/session-controller.share.test.ts +320 -0
  142. package/src/session-controller.ts +993 -0
  143. package/src/share.ts +120 -0
  144. package/src/test-support.test.ts +60 -0
  145. package/src/test-support.ts +197 -0
  146. package/src/theme-presets.test.ts +51 -0
  147. package/src/theme-presets.ts +187 -0
  148. package/src/theme.ts +64 -17
  149. package/src/use-settings-dialog.tsx +275 -0
  150. package/src/version.ts +5 -0
  151. package/src/view-diff.test.ts +22 -8
  152. package/src/view-diff.ts +39 -23
  153. package/src/view-plan.regression.test.ts +60 -0
  154. package/src/view-plan.test.ts +406 -0
  155. package/src/view-plan.ts +554 -0
  156. package/src/walk.test.ts +121 -0
  157. package/src/walk.ts +74 -0
  158. package/src/syntax.test.ts +0 -41
  159. package/src/syntax.ts +0 -88
  160. package/src/view.test.ts +0 -157
  161. package/src/view.ts +0 -0
@@ -0,0 +1,113 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ REVIEW_DEFAULT_WIDTH,
4
+ REVIEW_MAX_WIDTH,
5
+ REVIEW_MIN_WIDTH,
6
+ REVIEW_PLAN_MIN_WIDTH,
7
+ REVIEW_RESIZE_STEP,
8
+ clampWidth,
9
+ cycleReviewPanelMode,
10
+ maxWidthForTerminal,
11
+ resolveReviewWidth,
12
+ reviewRowsToDivider,
13
+ toggleReviewPanelMode,
14
+ widthFromMouseColumn,
15
+ } from "./review-panel";
16
+
17
+ describe("clampWidth", () => {
18
+ test("keeps a width inside the bounds untouched", () => {
19
+ expect(clampWidth(REVIEW_DEFAULT_WIDTH)).toBe(REVIEW_DEFAULT_WIDTH);
20
+ });
21
+
22
+ test("clamps below the minimum and above the maximum", () => {
23
+ expect(clampWidth(REVIEW_MIN_WIDTH - 10)).toBe(REVIEW_MIN_WIDTH);
24
+ expect(clampWidth(REVIEW_MAX_WIDTH + 10)).toBe(REVIEW_MAX_WIDTH);
25
+ });
26
+
27
+ test("rounds a fractional column to a whole one", () => {
28
+ expect(clampWidth(30.7)).toBe(31);
29
+ });
30
+
31
+ test("honors explicit bounds", () => {
32
+ expect(clampWidth(100, 10, 20)).toBe(20);
33
+ expect(clampWidth(5, 10, 20)).toBe(10);
34
+ });
35
+ });
36
+
37
+ describe("cycleReviewPanelMode", () => {
38
+ test("cycles expanded -> compact -> hidden -> expanded", () => {
39
+ expect(cycleReviewPanelMode("expanded")).toBe("compact");
40
+ expect(cycleReviewPanelMode("compact")).toBe("hidden");
41
+ expect(cycleReviewPanelMode("hidden")).toBe("expanded");
42
+ });
43
+ });
44
+
45
+ describe("toggleReviewPanelMode", () => {
46
+ test("the chevron toggles only between expanded and compact, never hidden", () => {
47
+ expect(toggleReviewPanelMode("expanded")).toBe("compact");
48
+ expect(toggleReviewPanelMode("compact")).toBe("expanded");
49
+ // a hidden panel has no chevron; a stray toggle still opens it
50
+ expect(toggleReviewPanelMode("hidden")).toBe("expanded");
51
+ });
52
+ });
53
+
54
+ describe("widthFromMouseColumn", () => {
55
+ test("measures the rail from the mouse column to the right edge", () => {
56
+ // divider dragged to column 80 of a 120-wide terminal -> a 40-col rail
57
+ expect(widthFromMouseColumn(80, 120)).toBe(40);
58
+ });
59
+
60
+ test("clamps the dragged width to the bounds", () => {
61
+ expect(widthFromMouseColumn(0, 120)).toBe(REVIEW_MAX_WIDTH);
62
+ expect(widthFromMouseColumn(119, 120)).toBe(REVIEW_MIN_WIDTH);
63
+ });
64
+
65
+ test("cannot drag the rail wide enough to starve the plan on a narrow terminal", () => {
66
+ // dragging to the far left edge of a 60-col terminal still leaves the plan its minimum
67
+ expect(widthFromMouseColumn(0, 60)).toBe(60 - REVIEW_PLAN_MIN_WIDTH - 1);
68
+ });
69
+
70
+ test("the resize step widens and narrows within the clamp", () => {
71
+ expect(clampWidth(REVIEW_DEFAULT_WIDTH + REVIEW_RESIZE_STEP)).toBe(
72
+ REVIEW_DEFAULT_WIDTH + REVIEW_RESIZE_STEP,
73
+ );
74
+ expect(clampWidth(REVIEW_MIN_WIDTH - REVIEW_RESIZE_STEP)).toBe(REVIEW_MIN_WIDTH);
75
+ });
76
+ });
77
+
78
+ describe("maxWidthForTerminal", () => {
79
+ test("allows the full rail on a wide terminal", () => {
80
+ expect(maxWidthForTerminal(120)).toBe(REVIEW_MAX_WIDTH);
81
+ });
82
+
83
+ test("reserves the plan its minimum on a narrow terminal", () => {
84
+ // 60-col terminal: 30 for the plan and 1 for the divider leaves 29 for the rail
85
+ expect(maxWidthForTerminal(60)).toBe(60 - REVIEW_PLAN_MIN_WIDTH - 1);
86
+ });
87
+
88
+ test("never drops below the rail minimum, however narrow the terminal", () => {
89
+ expect(maxWidthForTerminal(40)).toBe(REVIEW_MIN_WIDTH);
90
+ });
91
+ });
92
+
93
+ describe("resolveReviewWidth", () => {
94
+ test("leaves a width that already fits untouched", () => {
95
+ expect(resolveReviewWidth(REVIEW_DEFAULT_WIDTH, 120)).toBe(REVIEW_DEFAULT_WIDTH);
96
+ });
97
+
98
+ test("shrinks a wide persisted width so the plan keeps its room", () => {
99
+ // a saved 50-col rail on a 60-col terminal would leave the plan 9 cols; clamp it back
100
+ expect(resolveReviewWidth(REVIEW_MAX_WIDTH, 60)).toBe(60 - REVIEW_PLAN_MIN_WIDTH - 1);
101
+ });
102
+ });
103
+
104
+ describe("reviewRowsToDivider", () => {
105
+ test("builds one vertical glyph per row", () => {
106
+ expect(reviewRowsToDivider(3)).toBe("│\n│\n│");
107
+ });
108
+
109
+ test("never collapses to an empty column", () => {
110
+ expect(reviewRowsToDivider(0)).toBe("│");
111
+ expect(reviewRowsToDivider(-4)).toBe("│");
112
+ });
113
+ });
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Pure geometry and state for the resizable review panel. The panel shows the
3
+ * review rail in one of three modes and the expanded rail resizes within a
4
+ * clamp; App owns the React state and the mouse wiring, this module owns the
5
+ * arithmetic so the layout math stays unit-tested and out of the JSX. Bounds
6
+ * and the compact width carry over verbatim from the approved prototype.
7
+ */
8
+
9
+ /** Expanded (full rail), compact (count + dots strip), or hidden (nothing). */
10
+ export type ReviewPanelMode = "expanded" | "compact" | "hidden";
11
+
12
+ /** Expanded-rail width bounds, in terminal columns. */
13
+ export const REVIEW_MIN_WIDTH = 24;
14
+ export const REVIEW_MAX_WIDTH = 50;
15
+ export const REVIEW_DEFAULT_WIDTH = 34;
16
+ /**
17
+ * Columns the plan/diff column keeps for itself: the expanded rail yields past
18
+ * this so a narrow terminal (or a wide persisted width) can never crowd the
19
+ * plan out to nothing - the whole reason the panel is resizable. On terminals
20
+ * wide enough for the full 50-col rail this bound never binds.
21
+ */
22
+ export const REVIEW_PLAN_MIN_WIDTH = 30;
23
+ /** The compact strip is a fixed narrow column: count on top, one dot per card. */
24
+ export const REVIEW_COMPACT_WIDTH = 6;
25
+ /** Keyboard resize step, in columns, for the widen/narrow keys. */
26
+ export const REVIEW_RESIZE_STEP = 2;
27
+
28
+ /** Round a desired width to a whole column and clamp it to the bounds. */
29
+ export function clampWidth(
30
+ desiredWidth: number,
31
+ min = REVIEW_MIN_WIDTH,
32
+ max = REVIEW_MAX_WIDTH,
33
+ ): number {
34
+ return Math.max(min, Math.min(max, Math.round(desiredWidth)));
35
+ }
36
+
37
+ /**
38
+ * The widest the expanded rail may be on a given terminal, reserving the plan
39
+ * its minimum and one column for the divider. Never drops below REVIEW_MIN_WIDTH
40
+ * (an extremely narrow terminal is the auto-collapse case, handled elsewhere).
41
+ */
42
+ export function maxWidthForTerminal(terminalWidth: number): number {
43
+ return Math.max(
44
+ REVIEW_MIN_WIDTH,
45
+ Math.min(REVIEW_MAX_WIDTH, terminalWidth - REVIEW_PLAN_MIN_WIDTH - 1),
46
+ );
47
+ }
48
+
49
+ /** Clamp a desired rail width to the bounds AND the terminal, so the plan keeps its room. */
50
+ export function resolveReviewWidth(desiredWidth: number, terminalWidth: number): number {
51
+ return clampWidth(desiredWidth, REVIEW_MIN_WIDTH, maxWidthForTerminal(terminalWidth));
52
+ }
53
+
54
+ /** The keybinding cycle: expanded -> compact -> hidden -> expanded. */
55
+ export function cycleReviewPanelMode(mode: ReviewPanelMode): ReviewPanelMode {
56
+ return mode === "expanded" ? "compact" : mode === "compact" ? "hidden" : "expanded";
57
+ }
58
+
59
+ /**
60
+ * The clickable chevron toggles only between expanded and compact - hidden is
61
+ * reachable by keybinding alone, so it never leaves a leftover tab behind. A
62
+ * toggle from hidden still opens the panel rather than doing nothing.
63
+ */
64
+ export function toggleReviewPanelMode(mode: ReviewPanelMode): ReviewPanelMode {
65
+ return mode === "expanded" ? "compact" : "expanded";
66
+ }
67
+
68
+ /**
69
+ * Rail width from an absolute mouse column while dragging the divider: the rail
70
+ * spans from the mouse column to the terminal's right edge, then clamps.
71
+ */
72
+ export function widthFromMouseColumn(mouseColumn: number, terminalWidth: number): number {
73
+ return resolveReviewWidth(terminalWidth - mouseColumn, terminalWidth);
74
+ }
75
+
76
+ /**
77
+ * The divider is a single-column stack of vertical glyphs (a text block, not a
78
+ * bordered box - a one-column box with a border renders a full box). One glyph
79
+ * per row, at least one so the column never collapses.
80
+ */
81
+ export function reviewRowsToDivider(rows: number): string {
82
+ return Array.from({ length: Math.max(1, rows) }, () => "│").join("\n");
83
+ }
package/src/run.ts CHANGED
@@ -10,17 +10,28 @@ export interface RunClientOptions {
10
10
  home?: string;
11
11
  }
12
12
 
13
- export async function runClient(opts: RunClientOptions): Promise<number> {
14
- const renderer = await createCliRenderer();
13
+ /** OSC background query budget: brief so a terminal that never answers falls back to dark. */
14
+ const THEME_QUERY_TIMEOUT_MS = 200;
15
+
16
+ export async function runClient(options: RunClientOptions): Promise<number> {
17
+ // mouse movement reporting makes multiplexers forward drags to the app,
18
+ // so the renderer's native selection is the drag driver
19
+ const renderer = await createCliRenderer({ enableMouseMovement: true });
20
+ const appearance =
21
+ (await renderer.waitForThemeMode(THEME_QUERY_TIMEOUT_MS).catch(() => null)) ?? "dark";
22
+
15
23
  return new Promise<number>((resolve) => {
16
24
  createRoot(renderer).render(
17
25
  React.createElement(App, {
18
- home: opts.home,
19
- sessionId: opts.sessionId,
26
+ home: options.home,
27
+ sessionId: options.sessionId,
28
+ appearance,
20
29
  onExit: (code: number) => {
21
30
  renderer.destroy();
22
31
  resolve(code);
23
- process.exit(code);
32
+ // one microtask between destroy and exit lets the renderer flush
33
+ // its terminal-restore sequences before the process dies
34
+ queueMicrotask(() => process.exit(code));
24
35
  },
25
36
  }),
26
37
  );
package/src/serve.test.ts CHANGED
@@ -38,7 +38,11 @@ afterEach(async () => {
38
38
  });
39
39
 
40
40
  /** Connect, open a PTY shell, resolve with the bytes seen until `until` matches. */
41
- function sshCapture(port: number, until: (s: string) => boolean, timeoutMs = 8000): Promise<string> {
41
+ function sshCapture(
42
+ port: number,
43
+ until: (frame: string) => boolean,
44
+ timeoutMs = 8000,
45
+ ): Promise<string> {
42
46
  return new Promise((resolve, reject) => {
43
47
  const conn = new Client();
44
48
  let out = "";
@@ -48,15 +52,17 @@ function sshCapture(port: number, until: (s: string) => boolean, timeoutMs = 800
48
52
  done = true;
49
53
  clearTimeout(timer);
50
54
  conn.end();
51
- err ? reject(err) : resolve(out);
55
+ if (err) reject(err);
56
+ else resolve(out);
52
57
  };
53
58
  const timer = setTimeout(() => finish(new Error(`timed out; captured:\n${out}`)), timeoutMs);
59
+
54
60
  conn
55
61
  .on("ready", () => {
56
62
  conn.shell({ term: "xterm-256color", cols: 100, rows: 30 }, (err, stream) => {
57
63
  if (err) return finish(err);
58
- stream.on("data", (d: Buffer) => {
59
- out += d.toString("utf8");
64
+ stream.on("data", (chunk: Buffer) => {
65
+ out += chunk.toString("utf8");
60
66
  if (until(out)) finish();
61
67
  });
62
68
  stream.on("close", () => finish(new Error(`stream closed early; captured:\n${out}`)));
@@ -70,6 +76,7 @@ function sshCapture(port: number, until: (s: string) => boolean, timeoutMs = 800
70
76
 
71
77
  describe("cueloop serve", () => {
72
78
  test("listens on localhost, persists a host key under CUELOOP_HOME/ssh", () => {
79
+ // Assert
73
80
  expect(handle.host).toBe("127.0.0.1");
74
81
  expect(handle.port).toBeGreaterThan(0);
75
82
  expect(handle.fingerprints.length).toBe(1);
@@ -78,18 +85,27 @@ describe("cueloop serve", () => {
78
85
  });
79
86
 
80
87
  test("an anonymous ssh client gets the observer TUI for the session", async () => {
81
- const bytes = await sshCapture(handle.port, (s) => s.includes("Rollout Plan") && s.includes("observer"));
88
+ // Act
89
+ const bytes = await sshCapture(
90
+ handle.port,
91
+ (frame) => frame.includes("Rollout Plan") && frame.includes("observer"),
92
+ );
93
+
94
+ // Assert
82
95
  expect(bytes).toContain("cueloop");
83
96
  expect(bytes).toContain("Rollout Plan");
84
- // observer chrome, not the controller's mutating hint bar
85
- expect(bytes).toContain("observer - read-only");
97
+ // observer chrome: the header badge marks the read-only session
98
+ expect(bytes).toContain("observer");
86
99
  });
87
100
 
88
101
  test("two observers can watch at once", async () => {
102
+ // Act
89
103
  const [a, b] = await Promise.all([
90
- sshCapture(handle.port, (s) => s.includes("Rollout Plan")),
91
- sshCapture(handle.port, (s) => s.includes("Rollout Plan")),
104
+ sshCapture(handle.port, (frame) => frame.includes("Rollout Plan")),
105
+ sshCapture(handle.port, (frame) => frame.includes("Rollout Plan")),
92
106
  ]);
107
+
108
+ // Assert
93
109
  expect(a).toContain("Rollout Plan");
94
110
  expect(b).toContain("Rollout Plan");
95
111
  });
package/src/serve.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * SSH-served TUI (#22 share path): `cueloop serve` lets teammates join a
2
+ * SSH-served TUI: `cueloop serve` lets teammates join a
3
3
  * review session over plain ssh. Every SSH connection renders <App> in
4
4
  * observer mode (readOnly) against the same local daemon; the one writable
5
5
  * controller stays the local owner's own `cueloop` TUI.
@@ -22,6 +22,9 @@ import { createServer } from "@opentui/ssh";
22
22
  import { cueloopHome } from "@cueloop/daemon";
23
23
  import { App } from "./App";
24
24
 
25
+ /** OSC background query budget: brief so a terminal that never answers falls back to dark. */
26
+ const THEME_QUERY_TIMEOUT_MS = 200;
27
+
25
28
  export interface ServeOptions {
26
29
  /** TCP port for the SSH listener; 0 picks an ephemeral port. Default 2222. */
27
30
  port?: number;
@@ -44,25 +47,32 @@ export interface ServeHandle {
44
47
  close(): Promise<void>;
45
48
  }
46
49
 
47
- export async function serveClient(opts: ServeOptions = {}): Promise<ServeHandle> {
48
- const home = opts.home ?? cueloopHome();
50
+ export async function serveClient(options: ServeOptions = {}): Promise<ServeHandle> {
51
+ const home = options.home ?? cueloopHome();
49
52
  const sshDir = join(home, "ssh");
53
+
50
54
  mkdirSync(sshDir, { recursive: true, mode: 0o700 });
51
55
 
52
56
  const server = createServer({
53
57
  // password-less by design; see the trust model in the module comment
54
58
  auth: "open",
55
59
  hostKey: { path: join(sshDir, "host_key") },
56
- startupBanner: opts.banner ?? true,
60
+ startupBanner: options.banner ?? true,
57
61
  idleTimeout: "2h",
58
- onError: opts.onError,
59
- }).serve((session) => {
62
+ onError: options.onError,
63
+ }).serve(async (session) => {
64
+ // the observer's terminal reports its own background, so a light-terminal
65
+ // watcher gets the readable variant too
66
+ const appearance =
67
+ (await session.renderer.waitForThemeMode(THEME_QUERY_TIMEOUT_MS).catch(() => null)) ?? "dark";
60
68
  const root = createRoot(session.renderer);
69
+
61
70
  root.render(
62
71
  React.createElement(App, {
63
72
  home,
64
- sessionId: opts.sessionId,
73
+ sessionId: options.sessionId,
65
74
  readOnly: true,
75
+ appearance,
66
76
  // q disconnects only this observer, never the server
67
77
  onExit: () => session.end(),
68
78
  }),
@@ -70,6 +80,7 @@ export async function serveClient(opts: ServeOptions = {}): Promise<ServeHandle>
70
80
  session.onClose(() => root.unmount());
71
81
  });
72
82
 
73
- const info = await server.listen(opts.port ?? 2222, opts.host ?? "127.0.0.1");
83
+ const info = await server.listen(options.port ?? 2222, options.host ?? "127.0.0.1");
84
+
74
85
  return { ...info, close: () => server.close() };
75
86
  }
@@ -0,0 +1,260 @@
1
+ import { describe, expect, mock, test } from "bun:test";
2
+ import { SCHEMA_VERSION, type DiffFileContents, type ReviewSession } from "@cueloop/schema";
3
+ import type { SessionClient } from "@cueloop/daemon/client";
4
+ import { createReviewController } from "./session-controller";
5
+
6
+ const PATCH = `diff --git a/src/store.ts b/src/store.ts
7
+ index 111..222 100644
8
+ --- a/src/store.ts
9
+ +++ b/src/store.ts
10
+ @@ -1,4 +1,4 @@
11
+ export class Store {
12
+ - private items = [];
13
+ + private items = new Map();
14
+ }
15
+ `;
16
+
17
+ const FILES: DiffFileContents[] = [
18
+ {
19
+ path: "src/store.ts",
20
+ oldContents: "export class Store {\n private items = [];\n}\n",
21
+ newContents: "export class Store {\n private items = new Map();\n}\n",
22
+ status: "modified",
23
+ },
24
+ ];
25
+
26
+ function diffSession(files?: DiffFileContents[]): ReviewSession {
27
+ return {
28
+ schemaVersion: SCHEMA_VERSION,
29
+ id: "ses_diff",
30
+ workspace: { repoRoot: "/repo", branch: "main" },
31
+ artifact: { type: "diff", content: PATCH, meta: {}, files },
32
+ revisions: [{ revision: 1, content: PATCH, submittedAt: "2026-01-01T00:00:00.000Z" }],
33
+ annotations: [],
34
+ verdict: null,
35
+ status: "pending",
36
+ createdAt: "2026-01-01T00:00:00.000Z",
37
+ };
38
+ }
39
+
40
+ /** A fake client that records the working copy the controller writes. */
41
+ function fakeClient(session: ReviewSession, sink: { workingCopy?: string }): SessionClient {
42
+ return {
43
+ onEvent: () => () => {},
44
+ subscribe: async () => {},
45
+ sessionGet: async () => session,
46
+ sessionList: async () => [session],
47
+ sessionSetWorkingCopy: mock(async (_id: string, content: string | undefined) => {
48
+ sink.workingCopy = content;
49
+
50
+ return { ...session, workingCopy: content };
51
+ }),
52
+ close: () => {},
53
+ } as unknown as SessionClient;
54
+ }
55
+
56
+ const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
57
+
58
+ async function connected(session: ReviewSession) {
59
+ const sink: { workingCopy?: string } = {};
60
+ const client = fakeClient(session, sink);
61
+ const controller = createReviewController({
62
+ sessionId: session.id,
63
+ openClient: async () => client,
64
+ });
65
+
66
+ controller.connect();
67
+ await tick();
68
+
69
+ return { controller, client, sink };
70
+ }
71
+
72
+ // rows for PATCH: file(0), hunk(1), ctx(2), del(3), add(4), ctx(5)
73
+ describe("diff hunk curation", () => {
74
+ test("rejecting the only change writes an empty curated working copy", async () => {
75
+ // Arrange
76
+ const { controller, sink } = await connected(diffSession(FILES));
77
+
78
+ // Act - reject the change on the added line
79
+ controller.toggleRejectChange(4);
80
+ await tick();
81
+
82
+ // Assert
83
+ expect(controller.getSnapshot().status).toContain("change rejected");
84
+ expect(sink.workingCopy).toBe("");
85
+ });
86
+
87
+ test("both the deletion and addition rows of the change dim", async () => {
88
+ // Arrange
89
+ const { controller } = await connected(diffSession(FILES));
90
+
91
+ // Act
92
+ controller.toggleRejectChange(4);
93
+ await tick();
94
+
95
+ // Assert - the change spans the del row and the add row
96
+ expect([...controller.rejectedRows()].sort((a, b) => a - b)).toEqual([3, 4]);
97
+ });
98
+
99
+ test("toggling the same change twice restores the full diff", async () => {
100
+ // Arrange
101
+ const { controller, sink } = await connected(diffSession(FILES));
102
+
103
+ // Act - reject then restore
104
+ controller.toggleRejectChange(4);
105
+ await tick();
106
+ controller.toggleRejectChange(4);
107
+ await tick();
108
+
109
+ // Assert - no decisions left, so the working copy reverts to undefined
110
+ expect(controller.getSnapshot().status).toContain("change restored");
111
+ expect(sink.workingCopy).toBeUndefined();
112
+ expect(controller.rejectedRows().size).toBe(0);
113
+ });
114
+
115
+ test("whole-hunk reject drops the hunk from the working copy", async () => {
116
+ // Arrange
117
+ const { controller, sink } = await connected(diffSession(FILES));
118
+
119
+ // Act - reject the whole hunk from any of its rows
120
+ controller.toggleRejectHunk(3);
121
+ await tick();
122
+
123
+ // Assert - the single hunk is the whole diff, so nothing remains
124
+ expect(controller.getSnapshot().status).toContain("hunk rejected");
125
+ expect(sink.workingCopy).toBe("");
126
+ });
127
+
128
+ test("a rejected change becomes a removal card with a source, id, and preview", async () => {
129
+ // Arrange
130
+ const { controller } = await connected(diffSession(FILES));
131
+
132
+ // Act
133
+ controller.toggleRejectChange(4);
134
+ await tick();
135
+
136
+ // Assert - one item: diff source, revealing the change's row, both lines previewed
137
+ const items = controller.curationItems();
138
+
139
+ expect(items.length).toBe(1);
140
+ expect(items[0]!.source).toBe("diff");
141
+ expect(items[0]!.id).toBe("diff:src/store.ts#0#1");
142
+ expect(items[0]!.revealIndex).toBe(3);
143
+ expect(items[0]!.preview).toEqual([
144
+ "- private items = [];",
145
+ "+ private items = new Map();",
146
+ ]);
147
+ });
148
+
149
+ test("a rejected hunk becomes a removal card with the whole-hunk id", async () => {
150
+ // Arrange
151
+ const { controller } = await connected(diffSession(FILES));
152
+
153
+ // Act
154
+ controller.toggleRejectHunk(3);
155
+ await tick();
156
+
157
+ // Assert - the whole hunk, id diff:path#hunk#hunk
158
+ const items = controller.curationItems();
159
+
160
+ expect(items.length).toBe(1);
161
+ expect(items[0]!.source).toBe("diff");
162
+ expect(items[0]!.id).toBe("diff:src/store.ts#0#hunk");
163
+ });
164
+
165
+ test("restoreCuration drops the diff removal and reverts the working copy", async () => {
166
+ // Arrange - one change rejected
167
+ const { controller, sink } = await connected(diffSession(FILES));
168
+
169
+ controller.toggleRejectChange(4);
170
+ await tick();
171
+ const [item] = controller.curationItems();
172
+
173
+ // Act
174
+ controller.restoreCuration(item!.id);
175
+ await tick();
176
+
177
+ // Assert - the list empties and the working copy reverts to the full diff
178
+ expect(controller.getSnapshot().status).toContain("removal restored");
179
+ expect(controller.curationItems().length).toBe(0);
180
+ expect(sink.workingCopy).toBeUndefined();
181
+ expect(controller.rejectedRows().size).toBe(0);
182
+ });
183
+
184
+ test("restoreCuration ignores an unknown id", async () => {
185
+ // Arrange - one change rejected
186
+ const { controller } = await connected(diffSession(FILES));
187
+
188
+ controller.toggleRejectChange(4);
189
+ await tick();
190
+
191
+ // Act
192
+ controller.restoreCuration("diff:nope#0#0");
193
+ await tick();
194
+
195
+ // Assert - the item stays
196
+ expect(controller.curationItems().length).toBe(1);
197
+ });
198
+
199
+ test("curation is disabled without full file contents", async () => {
200
+ // Arrange - a legacy diff with no artifact.files
201
+ const { controller, sink } = await connected(diffSession(undefined));
202
+
203
+ // Act
204
+ controller.toggleRejectChange(4);
205
+ await tick();
206
+
207
+ // Assert
208
+ expect(controller.getSnapshot().status).toContain("hunk curation needs full file contents");
209
+ expect(sink.workingCopy).toBeUndefined();
210
+ });
211
+ });
212
+
213
+ const PLAN_CONTENT = "# Title\n\nFirst paragraph.\n\nSecond paragraph.\n";
214
+ // the working copy with the "Second paragraph." block cut out
215
+ const PLAN_CUT = "# Title\n\nFirst paragraph.\n";
216
+
217
+ function planSession(workingCopy?: string): ReviewSession {
218
+ return {
219
+ schemaVersion: SCHEMA_VERSION,
220
+ id: "ses_plan",
221
+ workspace: { repoRoot: "/repo", branch: "main" },
222
+ artifact: { type: "plan", content: PLAN_CONTENT, meta: {} },
223
+ revisions: [{ revision: 1, content: PLAN_CONTENT, submittedAt: "2026-01-01T00:00:00.000Z" }],
224
+ annotations: [],
225
+ verdict: null,
226
+ status: "pending",
227
+ createdAt: "2026-01-01T00:00:00.000Z",
228
+ workingCopy,
229
+ };
230
+ }
231
+
232
+ describe("plan cut removals", () => {
233
+ test("a cut block becomes a plan removal card previewing its content", async () => {
234
+ // Arrange - a plan whose working copy dropped the second paragraph
235
+ const { controller } = await connected(planSession(PLAN_CUT));
236
+
237
+ // Act
238
+ const items = controller.curationItems();
239
+
240
+ // Assert - one plan-source removal, keyed on the base line range, content previewed
241
+ expect(items.length).toBe(1);
242
+ expect(items[0]!.source).toBe("plan");
243
+ expect(items[0]!.id).toBe("plan:4-4");
244
+ expect(items[0]!.preview).toEqual(["Second paragraph."]);
245
+ });
246
+
247
+ test("restoreCuration re-inserts the cut block and returns to pristine", async () => {
248
+ // Arrange
249
+ const { controller, sink } = await connected(planSession(PLAN_CUT));
250
+ const [item] = controller.curationItems();
251
+
252
+ // Act
253
+ controller.restoreCuration(item!.id);
254
+ await tick();
255
+
256
+ // Assert - restoring the only cut round-trips to the submitted revision
257
+ expect(controller.getSnapshot().status).toContain("removal restored");
258
+ expect(sink.workingCopy).toBeUndefined();
259
+ });
260
+ });