@cueloop/client 0.1.0-alpha.21 → 0.1.0-alpha.22

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.
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  import { SystemClock, type Clock, type TimerHandle } from "@opentui/core";
11
- import { DaemonClient } from "@cueloop/daemon/client";
11
+ import { DaemonClient, type SessionClient } from "@cueloop/daemon/client";
12
12
  import {
13
13
  cutBlock,
14
14
  detectHerdr,
@@ -23,6 +23,7 @@ import {
23
23
  type VerdictKind,
24
24
  } from "@cueloop/schema";
25
25
  import { loadBundledExporters, type BundledExporter } from "./integrations";
26
+ import { publishShare } from "./share";
26
27
  import { buildDisplay, nextWorkBlock, type DisplayBlock } from "./view-plan";
27
28
  import { diffRowAnchor, diffRows, type DiffRow } from "./view-diff";
28
29
  import { firstUnviewedIndex, walkFiles, type WalkFile } from "./walk";
@@ -72,6 +73,12 @@ export interface ReviewControllerOptions {
72
73
  onExit?: (code: number) => void;
73
74
  /** Timer source for the auto-close countdown; tests inject a ManualClock. */
74
75
  clock?: Clock;
76
+ /**
77
+ * How the controller gets its session client. Defaults to dialing the local
78
+ * daemon; the sharing gateway injects a blob-backed client so the same <App>
79
+ * renders a decrypted share instead.
80
+ */
81
+ openClient?: () => Promise<SessionClient>;
75
82
  }
76
83
 
77
84
  export interface ReviewController {
@@ -115,6 +122,8 @@ export interface ReviewController {
115
122
  walkLeave(): void;
116
123
  /** Resolve the review, run the export, start the completion hand-back. */
117
124
  submit(verdict: VerdictKind, summary: string): void;
125
+ /** Publish the current session as a share; the ssh line lands on the clipboard. */
126
+ share(): void;
118
127
  /** Close the review and, inside herdr, bounce focus back to the agent. */
119
128
  finishReview(): void;
120
129
  dismissCompletion(): void;
@@ -134,7 +143,7 @@ export function createReviewController(options: ReviewControllerOptions): Review
134
143
 
135
144
  class Controller implements ReviewController {
136
145
  readonly readOnly: boolean;
137
- private client: DaemonClient | null = null;
146
+ private client: SessionClient | null = null;
138
147
  private closed = false;
139
148
  private snapshot: ControllerSnapshot = {
140
149
  session: null,
@@ -179,7 +188,8 @@ class Controller implements ReviewController {
179
188
  connect(): void {
180
189
  void (async () => {
181
190
  try {
182
- const client = await DaemonClient.connect({ home: this.options.home, autostart: true });
191
+ const openClient = this.options.openClient ?? (() => DaemonClient.connect({ home: this.options.home, autostart: true }));
192
+ const client = await openClient();
183
193
  if (this.closed) return void client.close();
184
194
  this.client = client;
185
195
  client.onEvent((event) => {
@@ -470,6 +480,15 @@ class Controller implements ReviewController {
470
480
  .catch((error: unknown) => this.setStatus(String(error instanceof Error ? error.message : error)));
471
481
  }
472
482
 
483
+ share(): void {
484
+ const session = this.snapshot.session;
485
+ if (!session) return;
486
+ this.setStatus("sharing…");
487
+ publishShare(session)
488
+ .then(({ line, copied }) => this.setStatus(copied ? `✓ share link copied - ${line}` : line))
489
+ .catch((error: unknown) => this.setStatus(`share failed: ${error instanceof Error ? error.message : String(error)}`));
490
+ }
491
+
473
492
  // ── completion hand-back ────────────────────
474
493
  private startCounting(remaining: number): void {
475
494
  if (remaining <= 0) return this.finishReview();
package/src/share.ts ADDED
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Publish a review session as a share: pack it, stream it to the gateway's
3
+ * `share` user over SSH, and copy the returned `ssh p_…@host` line to the
4
+ * clipboard. The planner holds no key, so all this side does is upload; the
5
+ * gateway seals and stores. Used by both `cueloop share` and the in-TUI
6
+ * Share button, so the two share exactly the same path.
7
+ */
8
+
9
+ import { DEFAULT_SHARE_HOST, DEFAULT_SHARE_PORT, SHARE_UPLOAD_USER, packSessionBlob } from "@cueloop/daemon/share-blob";
10
+ import type { ReviewSession } from "@cueloop/schema";
11
+ import { copyToClipboard } from "./clipboard";
12
+
13
+ export interface ShareTarget {
14
+ host?: string;
15
+ port?: number;
16
+ }
17
+
18
+ export interface ShareResult {
19
+ /** The one line to paste: `ssh p_xxxxxxxx@host`. */
20
+ line: string;
21
+ /** Whether the line made it onto the system clipboard. */
22
+ copied: boolean;
23
+ }
24
+
25
+ /** Upload the session to the gateway and copy the resulting ssh line. */
26
+ export async function publishShare(session: ReviewSession, target: ShareTarget = {}): Promise<ShareResult> {
27
+ const line = await uploadOverSsh(packSessionBlob(session), target.host ?? DEFAULT_SHARE_HOST, target.port ?? DEFAULT_SHARE_PORT);
28
+ const copied = await copyToClipboard(line);
29
+ return { line, copied };
30
+ }
31
+
32
+ /** Stream the blob to `share@host:port` over the system `ssh` client. */
33
+ async function uploadOverSsh(blob: Buffer, host: string, port: number): Promise<string> {
34
+ const proc = Bun.spawn(
35
+ ["ssh", "-p", String(port), "-o", "StrictHostKeyChecking=accept-new", `${SHARE_UPLOAD_USER}@${host}`, "cueloop-share"],
36
+ { stdin: blob, stdout: "pipe", stderr: "pipe" },
37
+ );
38
+ const [stdout, stderr, code] = await Promise.all([
39
+ new Response(proc.stdout).text(),
40
+ new Response(proc.stderr).text(),
41
+ proc.exited,
42
+ ]);
43
+ if (code !== 0) throw new Error(`gateway upload failed: ${stderr.trim() || `ssh exited ${code}`}`);
44
+ const line = stdout.trim();
45
+ if (!line.startsWith("ssh ")) throw new Error(`unexpected gateway reply: ${line || "(empty)"}`);
46
+ return line;
47
+ }
@@ -21,7 +21,10 @@ index 111..222 100644
21
21
 
22
22
  describe("diffRows", () => {
23
23
  test("flattens files, hunks, and signed lines with line numbers", () => {
24
+ // Act
24
25
  const rows = diffRows(PATCH);
26
+
27
+ // Assert
25
28
  expect(rows[0]).toMatchObject({ kind: "file", file: "src/store.ts" });
26
29
  expect(rows[1]!.kind).toBe("hunk");
27
30
  const del = rows.find((row) => row.kind === "del")!;
@@ -36,7 +39,10 @@ describe("diffRows", () => {
36
39
  });
37
40
 
38
41
  test("context lines carry both line numbers", () => {
42
+ // Act
39
43
  const rows = diffRows(PATCH);
44
+
45
+ // Assert
40
46
  const ctx = rows.find((row) => row.kind === "ctx")!;
41
47
  expect(ctx.oldLine).toBe(1);
42
48
  expect(ctx.newLine).toBe(1);
@@ -45,9 +51,14 @@ describe("diffRows", () => {
45
51
 
46
52
  describe("diffRowAnchor", () => {
47
53
  test("quote is the line, neighbors are the context selectors", () => {
54
+ // Arrange
48
55
  const rows = diffRows(PATCH);
49
56
  const rowIndex = rows.findIndex((row) => row.text.includes("new Map()"));
57
+
58
+ // Act
50
59
  const anchor = diffRowAnchor(rows, rowIndex);
60
+
61
+ // Assert
51
62
  expect(anchor.quote).toContain("new Map()");
52
63
  expect(anchor.prefix.length).toBeGreaterThan(0);
53
64
  expect(anchor.suffix.length).toBeGreaterThan(0);
package/src/view-diff.ts CHANGED
@@ -29,28 +29,28 @@ export function diffRows(patchText: string): DiffRow[] {
29
29
  let newLine = hunk.additionStart;
30
30
  for (const segment of hunk.hunkContent) {
31
31
  if (segment.type === "context") {
32
- for (let i = 0; i < segment.lines; i++) {
32
+ for (let lineOffset = 0; lineOffset < segment.lines; lineOffset++) {
33
33
  rows.push({
34
34
  kind: "ctx",
35
- text: file.additionLines[segment.additionLineIndex + i] ?? "",
35
+ text: file.additionLines[segment.additionLineIndex + lineOffset] ?? "",
36
36
  file: file.name,
37
37
  oldLine: oldLine++,
38
38
  newLine: newLine++,
39
39
  });
40
40
  }
41
41
  } else {
42
- for (let i = 0; i < segment.deletions; i++) {
42
+ for (let lineOffset = 0; lineOffset < segment.deletions; lineOffset++) {
43
43
  rows.push({
44
44
  kind: "del",
45
- text: file.deletionLines[segment.deletionLineIndex + i] ?? "",
45
+ text: file.deletionLines[segment.deletionLineIndex + lineOffset] ?? "",
46
46
  file: file.name,
47
47
  oldLine: oldLine++,
48
48
  });
49
49
  }
50
- for (let i = 0; i < segment.additions; i++) {
50
+ for (let lineOffset = 0; lineOffset < segment.additions; lineOffset++) {
51
51
  rows.push({
52
52
  kind: "add",
53
- text: file.additionLines[segment.additionLineIndex + i] ?? "",
53
+ text: file.additionLines[segment.additionLineIndex + lineOffset] ?? "",
54
54
  file: file.name,
55
55
  newLine: newLine++,
56
56
  });
@@ -25,36 +25,57 @@ The daemon persists sessions to disk atomically.
25
25
 
26
26
  describe("buildDisplay reconciliation", () => {
27
27
  test("no working copy: everything is same", () => {
28
+ // Act
28
29
  const display = buildDisplay(BASE);
30
+
31
+ // Assert
29
32
  expect(display.every((block) => block.type === "same")).toBe(true);
30
33
  });
31
34
 
32
35
  test("reworded block pairs as mod (similar)", () => {
36
+ // Arrange
33
37
  const working = BASE.replace("persists sessions to disk", "writes sessions to disk");
38
+
39
+ // Act
34
40
  const display = buildDisplay(BASE, working);
41
+
42
+ // Assert
35
43
  const mod = display.find((block) => block.type === "mod")!;
36
44
  expect(mod.base!.text).toContain("persists");
37
45
  expect(mod.work!.text).toContain("writes");
38
46
  });
39
47
 
40
48
  test("unrelated cut + insert do NOT merge into a mod", () => {
49
+ // Arrange
41
50
  const working = BASE.replace("- second item\n", "") + "\nA totally new closing note.\n";
51
+
52
+ // Act
42
53
  const display = buildDisplay(BASE, working);
54
+
55
+ // Assert
43
56
  expect(display.some((block) => block.type === "del" && block.base!.text === "second item")).toBe(true);
44
57
  expect(display.some((block) => block.type === "add" && block.work!.text.includes("closing note"))).toBe(true);
45
58
  expect(display.some((block) => block.type === "mod")).toBe(false);
46
59
  });
47
60
 
48
61
  test("ordered list items get running numbers", () => {
62
+ // Act
49
63
  const display = buildDisplay("1. one\n2. two\n3. three\n");
64
+
65
+ // Assert
50
66
  expect(display.map((block) => block.orderedItemNumber)).toEqual([1, 2, 3]);
51
67
  });
52
68
  });
53
69
 
54
70
  describe("blockRuns + overlayMarks", () => {
55
71
  test("mod blocks emit word-diff runs that reconstruct both sides", () => {
72
+ // Arrange
56
73
  const block = buildDisplay("plain old text here\n", "plain new text here\n")[0]!;
74
+
75
+ // Act
57
76
  const runs = blockRuns(block, true);
77
+
78
+ // Assert
58
79
  const oldSide = runs.filter((run) => run.role !== "ins").map((run) => run.text).join("");
59
80
  const newSide = runs.filter((run) => run.role !== "del").map((run) => run.text).join("");
60
81
  expect(oldSide).toBe("plain old text here");
@@ -62,9 +83,14 @@ describe("blockRuns + overlayMarks", () => {
62
83
  });
63
84
 
64
85
  test("marks bind to working-text offsets and skip del runs", () => {
86
+ // Arrange
65
87
  const block = buildDisplay("old words linger\n", "new words linger\n")[0]!;
66
88
  const runs = blockRuns(block, true);
89
+
90
+ // Act
67
91
  const marked = overlayMarks(runs, [{ start: 4, end: 9, role: "mark-comment" }]); // "words"
92
+
93
+ // Assert
68
94
  const markedText = marked.filter((run) => run.role === "mark-comment").map((run) => run.text).join("");
69
95
  expect(markedText).toBe("words");
70
96
  // del run text untouched
@@ -72,14 +98,20 @@ describe("blockRuns + overlayMarks", () => {
72
98
  });
73
99
 
74
100
  test("marks never override ins emphasis", () => {
101
+ // Arrange
75
102
  const block = buildDisplay("keep\n", "keep added\n")[0]!;
103
+
104
+ // Act
76
105
  const runs = overlayMarks(blockRuns(block, true), [{ start: 0, end: 10, role: "mark-comment" }]);
106
+
107
+ // Assert
77
108
  expect(runs.some((run) => run.role === "ins")).toBe(true);
78
109
  });
79
110
  });
80
111
 
81
112
  describe("marksByDisplay", () => {
82
113
  test("annotations resolve into display coordinates", () => {
114
+ // Arrange
83
115
  const working = BASE.replace("first item", "first item edited beyond recognition of it");
84
116
  const display = buildDisplay(BASE, working);
85
117
  const ann: Annotation = {
@@ -89,13 +121,18 @@ describe("marksByDisplay", () => {
89
121
  body: "define atomically",
90
122
  createdAt: "",
91
123
  };
124
+
125
+ // Act
92
126
  const map = marksByDisplay([ann], display);
127
+
128
+ // Assert
93
129
  const [displayIndex, marks] = [...map.entries()][0]!;
94
130
  expect(displayText(display[displayIndex]!)).toContain("atomically");
95
131
  expect(marks[0]!.annotationId).toBe("a1");
96
132
  });
97
133
 
98
134
  test("orphaned annotations produce no marks", () => {
135
+ // Arrange
99
136
  const display = buildDisplay(BASE);
100
137
  const ann: Annotation = {
101
138
  id: "a1",
@@ -104,25 +141,35 @@ describe("marksByDisplay", () => {
104
141
  body: "",
105
142
  createdAt: "",
106
143
  };
144
+
145
+ // Assert
107
146
  expect(marksByDisplay([ann], display).size).toBe(0);
108
147
  });
109
148
  });
110
149
 
111
150
  describe("rendered/work offset mapping", () => {
112
151
  test("plain blocks map 1:1 in both directions", () => {
152
+ // Arrange
113
153
  const block = buildDisplay("alpha beta gamma\n")[0]!;
114
154
  const runs = blockRuns(block, true);
155
+
156
+ // Assert
115
157
  expect(renderedOffsetFor(runs, 6)).toBe(6);
116
158
  expect(workRangeForRendered(runs, 6, 10)).toEqual({ start: 6, end: 10 });
117
159
  });
118
160
 
119
161
  test("inline del runs shift rendered offsets in word-diffed blocks", () => {
162
+ // Arrange
120
163
  // rendered: "old new words linger" - "old " is a del run without offsets
121
164
  const block = buildDisplay("old words linger\n", "new words linger\n")[0]!;
122
165
  const runs = blockRuns(block, true);
123
166
  const workText = displayText(block);
124
167
  const wordsAt = workText.indexOf("words");
168
+
169
+ // Act
125
170
  const rendered = renderedOffsetFor(runs, wordsAt)!;
171
+
172
+ // Assert
126
173
  const renderedText = runs.map((run) => run.text).join("");
127
174
  expect(renderedText.slice(rendered, rendered + "words".length)).toBe("words");
128
175
  // reading a rendered selection over "words" recovers the work range
@@ -133,16 +180,24 @@ describe("rendered/work offset mapping", () => {
133
180
  });
134
181
 
135
182
  test("a rendered selection spanning a del run keeps only positioned text", () => {
183
+ // Arrange
136
184
  const block = buildDisplay("old words\n", "new words\n")[0]!;
137
185
  const runs = blockRuns(block, true);
138
186
  const renderedText = runs.map((run) => run.text).join("");
187
+
188
+ // Act
139
189
  // select everything rendered: the work range is the whole work text
140
190
  const range = workRangeForRendered(runs, 0, renderedText.length)!;
191
+
192
+ // Assert
141
193
  expect(displayText(block).slice(range.start, range.end)).toBe(displayText(block));
142
194
  });
143
195
 
144
196
  test("offsets outside any positioned run map to null", () => {
197
+ // Arrange
145
198
  const runs = blockRuns(buildDisplay("short\n")[0]!, true);
199
+
200
+ // Assert
146
201
  expect(renderedOffsetFor(runs, 99)).toBeNull();
147
202
  expect(workRangeForRendered(runs, 90, 99)).toBeNull();
148
203
  });
@@ -151,28 +206,54 @@ describe("rendered/work offset mapping", () => {
151
206
  describe("span mode", () => {
152
207
  const text = "one two three four";
153
208
  test("v selects the first word; l grows; w slides; $ to end", () => {
209
+ // Act
154
210
  let span = startSpan(0, text)!;
211
+
212
+ // Assert
155
213
  expect(text.slice(span.start, span.end)).toBe("one");
214
+
215
+ // Act
156
216
  span = spanKey(span, "l", text);
217
+
218
+ // Assert
157
219
  expect(text.slice(span.start, span.end)).toBe("one two");
220
+
221
+ // Act
158
222
  span = spanKey(span, "w", text);
223
+
224
+ // Assert
159
225
  expect(text.slice(span.start, span.end)).toBe("two three");
226
+
227
+ // Act
160
228
  span = spanKey(span, "$", text);
229
+
230
+ // Assert
161
231
  expect(text.slice(span.start, span.end)).toBe("two three four");
162
232
  });
163
233
  });
164
234
 
165
235
  describe("cut / restore round-trip", () => {
166
236
  test("cut removes the block lines; restore puts them back", () => {
237
+ // Arrange
167
238
  const blocks = parseBlocks(BASE);
168
239
  const listItem = blocks.find((candidate) => candidate.text === "second item")!;
240
+
241
+ // Act
169
242
  const cut = cutBlock(BASE, listItem);
243
+
244
+ // Assert
170
245
  expect(cut).not.toContain("second item");
246
+
247
+ // Arrange
171
248
  const display = buildDisplay(BASE, cut);
172
249
  const deletedIndex = display.findIndex((block) => block.type === "del");
173
250
  const line = restoreLine(nextWorkBlock(display, deletedIndex), cut.split("\n").length);
251
+
252
+ // Act
174
253
  // undefined = the working copy round-tripped back to the submitted revision
175
254
  const restored = restoreBlock(BASE, cut, listItem, line);
255
+
256
+ // Assert
176
257
  expect(restored).toBeUndefined();
177
258
  expect(buildDisplay(BASE, restored).every((block) => block.type === "same")).toBe(true);
178
259
  });
package/src/view-plan.ts CHANGED
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  import {
11
+ isAddressed,
11
12
  lcsDiff,
12
13
  parseBlocks,
13
14
  resolveAnchor,
@@ -161,6 +162,8 @@ export function marksByDisplay(
161
162
  const workBlocks = workEntries.map((entry) => entry.block.work!);
162
163
  const marksByIndex = new Map<number, Mark[]>();
163
164
  for (const annotation of annotations) {
165
+ // an addressed annotation keeps its record but paints no highlight
166
+ if (isAddressed(annotation)) continue;
164
167
  const resolved = resolveAnchor(annotation.anchor, workBlocks);
165
168
  if (!resolved) continue;
166
169
  const entry = workEntries[resolved.blockIndex];
package/src/walk.test.ts CHANGED
@@ -92,12 +92,16 @@ describe("noteForFile", () => {
92
92
  });
93
93
 
94
94
  test("finds the note anchored at the file path", () => {
95
+ // Arrange
95
96
  const annotations = [note("src/a.ts", "Renames b and adds c."), note("src/b.ts", "Swaps the line.")];
97
+
98
+ // Assert
96
99
  expect(noteForFile(annotations, "src/b.ts")).toBe("Swaps the line.");
97
100
  expect(noteForFile(annotations, "src/c.ts")).toBeUndefined();
98
101
  });
99
102
 
100
103
  test("ignores non-note annotations that happen to quote the path", () => {
104
+ // Arrange
101
105
  const comment: Annotation = {
102
106
  id: "a_1",
103
107
  kind: "comment",
@@ -105,6 +109,8 @@ describe("noteForFile", () => {
105
109
  body: "not a note",
106
110
  createdAt: "now",
107
111
  };
112
+
113
+ // Assert
108
114
  expect(noteForFile([comment], "src/a.ts")).toBeUndefined();
109
115
  });
110
116
  });