@cueloop/client 0.1.0-alpha.21 → 0.1.0-alpha.23
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/package.json +5 -5
- package/src/App.autoclose.test.tsx +29 -5
- package/src/App.diff.test.tsx +20 -0
- package/src/App.export.test.tsx +12 -8
- package/src/App.plan-review.test.tsx +140 -7
- package/src/App.readonly.test.tsx +19 -5
- package/src/App.rendering.test.tsx +15 -0
- package/src/App.review-panel.test.tsx +29 -6
- package/src/App.submit-confirm.test.tsx +57 -6
- package/src/App.test.tsx +62 -5
- package/src/App.tsx +77 -183
- package/src/App.walk.test.tsx +63 -8
- package/src/clipboard.test.ts +23 -0
- package/src/clipboard.ts +26 -0
- package/src/components/PlanSheet.stories.tsx +1 -1
- package/src/components/PlanSheet.tsx +13 -6
- package/src/components/ReviewRail.tsx +16 -4
- package/src/components/stories.test.tsx +5 -0
- package/src/config.test.ts +62 -0
- package/src/config.ts +1 -0
- package/src/editor.test.ts +24 -0
- package/src/herdr.test.ts +4 -0
- package/src/index.ts +2 -0
- package/src/integrations.test.ts +12 -0
- package/src/intent-dispatch.test.ts +213 -0
- package/src/intent-dispatch.ts +270 -0
- package/src/key-bindings.test.ts +27 -5
- package/src/key-bindings.ts +11 -1
- package/src/keymap.test.ts +97 -5
- package/src/keymap.ts +25 -2
- package/src/serve.test.ts +7 -0
- package/src/session-controller.ts +22 -3
- package/src/share.ts +47 -0
- package/src/view-diff.test.ts +11 -0
- package/src/view-diff.ts +6 -6
- package/src/view-plan.test.ts +81 -0
- package/src/view-plan.ts +3 -0
- package/src/walk.test.ts +6 -0
package/src/keymap.test.ts
CHANGED
|
@@ -1,8 +1,4 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The keyboard grammar as data: every mode x key produces a known intent
|
|
3
|
-
* list, the read-only rule is one gate, and plan and diff reviews share one
|
|
4
|
-
* path for annotation navigation, deletion, and submit.
|
|
5
|
-
*/
|
|
1
|
+
/** The keyboard grammar as data: every mode x key maps to a known intent list, gated once by the read-only rule. */
|
|
6
2
|
|
|
7
3
|
import { describe, expect, test } from "bun:test";
|
|
8
4
|
import { DEFAULT_KEYS } from "./config";
|
|
@@ -30,6 +26,7 @@ function key(name: string, shift = false, meta = false): { name: string; shift:
|
|
|
30
26
|
}
|
|
31
27
|
|
|
32
28
|
describe("compose overlay", () => {
|
|
29
|
+
// Arrange
|
|
33
30
|
const keyState = state({ overlay: "compose" });
|
|
34
31
|
const table: [string, Intent[]][] = [
|
|
35
32
|
["escape", [{ type: "closeOverlay" }]],
|
|
@@ -57,6 +54,7 @@ describe("compose overlay", () => {
|
|
|
57
54
|
});
|
|
58
55
|
|
|
59
56
|
describe("submit overlay", () => {
|
|
57
|
+
// Arrange
|
|
60
58
|
const keyState = state({ overlay: "submit" });
|
|
61
59
|
const table: [string, Intent[]][] = [
|
|
62
60
|
["escape", [{ type: "closeOverlay" }]],
|
|
@@ -75,6 +73,7 @@ describe("submit overlay", () => {
|
|
|
75
73
|
});
|
|
76
74
|
|
|
77
75
|
describe("completion overlay", () => {
|
|
76
|
+
// Arrange
|
|
78
77
|
const prompt = state({ overlay: "completion-prompt" });
|
|
79
78
|
const counting = state({ overlay: "completion-counting" });
|
|
80
79
|
const table: [KeyState, string, Intent[]][] = [
|
|
@@ -97,6 +96,7 @@ describe("completion overlay", () => {
|
|
|
97
96
|
});
|
|
98
97
|
|
|
99
98
|
describe("inbox mode", () => {
|
|
99
|
+
// Arrange
|
|
100
100
|
const keyState = state({ view: "inbox" });
|
|
101
101
|
const table: [string, Intent[]][] = [
|
|
102
102
|
["j", [{ type: "inboxMove", to: "down" }]],
|
|
@@ -116,7 +116,10 @@ describe("inbox mode", () => {
|
|
|
116
116
|
}
|
|
117
117
|
|
|
118
118
|
test("an empty inbox swallows every non-quit key", () => {
|
|
119
|
+
// Arrange
|
|
119
120
|
const empty = state({ view: "inbox", hasInboxItems: false });
|
|
121
|
+
|
|
122
|
+
// Assert
|
|
120
123
|
for (const name of ["j", "k", "down", "up", "return", "enter"]) {
|
|
121
124
|
expect(reduceKey(empty, key(name))).toEqual([]);
|
|
122
125
|
}
|
|
@@ -124,7 +127,63 @@ describe("inbox mode", () => {
|
|
|
124
127
|
});
|
|
125
128
|
});
|
|
126
129
|
|
|
130
|
+
describe("collaborator (share) capabilities", () => {
|
|
131
|
+
test("can annotate: comment opens the composer", () => {
|
|
132
|
+
// Arrange
|
|
133
|
+
const collab = state({ canEditPlan: false, canSubmitVerdict: false });
|
|
134
|
+
|
|
135
|
+
// Act / Assert
|
|
136
|
+
expect(reduceKey(collab, key("c"), "comment")).toEqual([{ type: "openCompose", kind: "comment", from: "cursor" }]);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("can edit their own card: edit with a focused annotation rewrites it", () => {
|
|
140
|
+
// Arrange
|
|
141
|
+
const collab = state({ canEditPlan: false, hasFocusedAnnotation: true });
|
|
142
|
+
|
|
143
|
+
// Act / Assert
|
|
144
|
+
expect(reduceKey(collab, key("e"), "edit")).toEqual([{ type: "editCard" }]);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("cannot edit the plan: cut/edit with no card is silent (no nag, no button)", () => {
|
|
148
|
+
// Arrange
|
|
149
|
+
const collab = state({ canEditPlan: false, hasFocusedAnnotation: false });
|
|
150
|
+
|
|
151
|
+
// Act / Assert
|
|
152
|
+
expect(reduceKey(collab, key("e"), "edit")).toEqual([]);
|
|
153
|
+
expect(reduceKey(collab, key("c"), "cut")).toEqual([]);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("cannot submit a verdict: there is no agent on a share", () => {
|
|
157
|
+
// Arrange
|
|
158
|
+
const collab = state({ canSubmitVerdict: false });
|
|
159
|
+
|
|
160
|
+
// Act / Assert
|
|
161
|
+
expect(reduceKey(collab, key("x"), "submit")).toEqual([{ type: "status", message: "shared view - your notes save as you go; q to leave" }]);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("cannot re-share: only the owner shares", () => {
|
|
165
|
+
// Arrange
|
|
166
|
+
const collab = state({ canShare: false });
|
|
167
|
+
|
|
168
|
+
// Act / Assert
|
|
169
|
+
expect(reduceKey(collab, key("S", true), "share")).toEqual([{ type: "status", message: "only the plan owner can share" }]);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
describe("share", () => {
|
|
174
|
+
test("the owner's share key publishes", () => {
|
|
175
|
+
// Act / Assert
|
|
176
|
+
expect(reduceKey(state(), key("S", true), "share")).toEqual([{ type: "share" }]);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("an observer cannot share", () => {
|
|
180
|
+
// Act / Assert
|
|
181
|
+
expect(reduceKey(state({ readOnly: true }), key("S", true), "share")).toEqual([{ type: "status", message: "observer - read-only" }]);
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
127
185
|
describe("plan normal mode", () => {
|
|
186
|
+
// Arrange
|
|
128
187
|
const keyState = state();
|
|
129
188
|
const table: [string, boolean, Intent[]][] = [
|
|
130
189
|
["j", false, [{ type: "move", to: "down" }]],
|
|
@@ -154,7 +213,10 @@ describe("plan normal mode", () => {
|
|
|
154
213
|
}
|
|
155
214
|
|
|
156
215
|
test("resolved sessions guard the mutating verbs", () => {
|
|
216
|
+
// Arrange
|
|
157
217
|
const resolvedState = state({ resolved: true });
|
|
218
|
+
|
|
219
|
+
// Assert
|
|
158
220
|
for (const name of ["c", "s", "x", "e"]) {
|
|
159
221
|
expect(reduceKey(resolvedState, key(name))).toEqual([{ type: "status", message: "review submitted - read-only" }]);
|
|
160
222
|
}
|
|
@@ -163,27 +225,37 @@ describe("plan normal mode", () => {
|
|
|
163
225
|
});
|
|
164
226
|
|
|
165
227
|
test("cut text cannot host a comment or a span", () => {
|
|
228
|
+
// Arrange
|
|
166
229
|
const cut = state({ cursorAnnotatable: false });
|
|
230
|
+
|
|
231
|
+
// Assert
|
|
167
232
|
expect(reduceKey(cut, key("c"))).toEqual([{ type: "status", message: "text is cut - restore it first" }]);
|
|
168
233
|
expect(reduceKey(cut, key("s"))).toEqual([{ type: "status", message: "text is cut - restore it first" }]);
|
|
169
234
|
expect(reduceKey(cut, key("v"))).toEqual([]);
|
|
170
235
|
});
|
|
171
236
|
|
|
172
237
|
test("annotation navigation without annotations reports, delete without focus is silent", () => {
|
|
238
|
+
// Arrange
|
|
173
239
|
const none = state({ annotationCount: 0, hasFocusedAnnotation: false });
|
|
240
|
+
|
|
241
|
+
// Assert
|
|
174
242
|
expect(reduceKey(none, key("n"))).toEqual([{ type: "status", message: "no annotations" }]);
|
|
175
243
|
expect(reduceKey(none, key("p"))).toEqual([{ type: "status", message: "no annotations" }]);
|
|
176
244
|
expect(reduceKey(none, key("backspace"))).toEqual([]);
|
|
177
245
|
});
|
|
178
246
|
|
|
179
247
|
test("without a selected card x cuts the block and e opens the editor", () => {
|
|
248
|
+
// Arrange
|
|
180
249
|
const unfocused = state({ hasFocusedAnnotation: false });
|
|
250
|
+
|
|
251
|
+
// Assert
|
|
181
252
|
expect(reduceKey(unfocused, key("x"))).toEqual([{ type: "cut" }]);
|
|
182
253
|
expect(reduceKey(unfocused, key("e"))).toEqual([{ type: "edit" }]);
|
|
183
254
|
});
|
|
184
255
|
});
|
|
185
256
|
|
|
186
257
|
describe("span mode", () => {
|
|
258
|
+
// Arrange
|
|
187
259
|
const keyState = state({ spanMode: true });
|
|
188
260
|
const table: [string, Intent[]][] = [
|
|
189
261
|
["l", [{ type: "spanKey", name: "l" }]],
|
|
@@ -206,6 +278,7 @@ describe("span mode", () => {
|
|
|
206
278
|
});
|
|
207
279
|
|
|
208
280
|
describe("diff mode", () => {
|
|
281
|
+
// Arrange
|
|
209
282
|
const keyState = state({ view: "diff" });
|
|
210
283
|
const table: [string, Intent[]][] = [
|
|
211
284
|
["j", [{ type: "move", to: "down" }]],
|
|
@@ -229,6 +302,7 @@ describe("diff mode", () => {
|
|
|
229
302
|
}
|
|
230
303
|
|
|
231
304
|
test("comment guards: resolved first, then non-code rows", () => {
|
|
305
|
+
// Assert
|
|
232
306
|
expect(reduceKey(state({ view: "diff", resolved: true }), key("c"))).toEqual([
|
|
233
307
|
{ type: "status", message: "review submitted - read-only" },
|
|
234
308
|
]);
|
|
@@ -240,6 +314,7 @@ describe("diff mode", () => {
|
|
|
240
314
|
|
|
241
315
|
describe("guided walk", () => {
|
|
242
316
|
test("w enters the walk in diff view; resolved and read-only sessions answer", () => {
|
|
317
|
+
// Assert
|
|
243
318
|
expect(reduceKey(state({ view: "diff" }), key("w"))).toEqual([{ type: "walkStart" }]);
|
|
244
319
|
expect(reduceKey(state({ view: "diff", resolved: true }), key("w"))).toEqual([
|
|
245
320
|
{ type: "status", message: "review submitted - read-only" },
|
|
@@ -250,6 +325,7 @@ describe("guided walk", () => {
|
|
|
250
325
|
expect(reduceKey(state(), key("w"))).toEqual([{ type: "status", message: "the guided walk is a diff-review mode" }]);
|
|
251
326
|
});
|
|
252
327
|
|
|
328
|
+
// Arrange
|
|
253
329
|
const walking = state({ view: "diff", overlay: "walk" });
|
|
254
330
|
const table: [string, Intent[]][] = [
|
|
255
331
|
["]", [{ type: "walkForward" }]],
|
|
@@ -268,7 +344,10 @@ describe("guided walk", () => {
|
|
|
268
344
|
}
|
|
269
345
|
|
|
270
346
|
test("the end card's return leaves the walk and opens the submit confirm", () => {
|
|
347
|
+
// Arrange
|
|
271
348
|
const atEnd = state({ view: "diff", overlay: "walk", walkAtEnd: true });
|
|
349
|
+
|
|
350
|
+
// Assert
|
|
272
351
|
expect(reduceKey(atEnd, key("return"))).toEqual([{ type: "walkLeave" }, { type: "openSubmit" }]);
|
|
273
352
|
expect(reduceKey(atEnd, key("enter"))).toEqual([{ type: "walkLeave" }, { type: "openSubmit" }]);
|
|
274
353
|
});
|
|
@@ -289,17 +368,24 @@ describe("review panel controls", () => {
|
|
|
289
368
|
}
|
|
290
369
|
|
|
291
370
|
test("observers may still collapse and resize the panel (it is view state, not a mutation)", () => {
|
|
371
|
+
// Arrange
|
|
292
372
|
const observer = state({ readOnly: true });
|
|
373
|
+
|
|
374
|
+
// Assert
|
|
293
375
|
expect(reduceKey(observer, key("b"))).toEqual([{ type: "cycleReviewPanel" }]);
|
|
294
376
|
expect(reduceKey(observer, key("]"))).toEqual([{ type: "resizeReviewPanel", direction: 1 }]);
|
|
295
377
|
});
|
|
296
378
|
|
|
297
379
|
test("span mode still owns b as a span verb, not a panel cycle", () => {
|
|
380
|
+
// Assert
|
|
298
381
|
expect(reduceKey(state({ spanMode: true }), key("b"))).toEqual([{ type: "spanKey", name: "b" }]);
|
|
299
382
|
});
|
|
300
383
|
|
|
301
384
|
test("the walk overlay keeps the brackets for stepping", () => {
|
|
385
|
+
// Arrange
|
|
302
386
|
const walking = state({ view: "diff", overlay: "walk" });
|
|
387
|
+
|
|
388
|
+
// Assert
|
|
303
389
|
expect(reduceKey(walking, key("]"))).toEqual([{ type: "walkForward" }]);
|
|
304
390
|
expect(reduceKey(walking, key("["))).toEqual([{ type: "walkBack" }]);
|
|
305
391
|
});
|
|
@@ -332,15 +418,21 @@ describe("read-only filter", () => {
|
|
|
332
418
|
});
|
|
333
419
|
|
|
334
420
|
test("navigation and annotation focus still work for observers", () => {
|
|
421
|
+
// Arrange
|
|
335
422
|
const keyState = state({ readOnly: true });
|
|
423
|
+
|
|
424
|
+
// Assert
|
|
336
425
|
expect(reduceKey(keyState, key("j"))).toEqual([{ type: "move", to: "down" }]);
|
|
337
426
|
expect(reduceKey(keyState, key("n"))).toEqual([{ type: "nextAnnotation" }]);
|
|
338
427
|
expect(reduceKey(keyState, key("q"))).toEqual([{ type: "exit" }]);
|
|
339
428
|
});
|
|
340
429
|
|
|
341
430
|
test("span-mode c/s are gated by key name even under a rebound keymap", () => {
|
|
431
|
+
// Arrange
|
|
342
432
|
const rebound = { ...DEFAULT_KEYS, comment: ["m"], suggest: ["t"] };
|
|
343
433
|
const keyState = state({ spanMode: true, readOnly: true, keys: rebound });
|
|
434
|
+
|
|
435
|
+
// Assert
|
|
344
436
|
expect(reduceKey(keyState, key("c"))).toEqual([{ type: "status", message: "observer - read-only" }]);
|
|
345
437
|
expect(reduceKey(keyState, key("s"))).toEqual([{ type: "status", message: "observer - read-only" }]);
|
|
346
438
|
});
|
package/src/keymap.ts
CHANGED
|
@@ -18,6 +18,7 @@ export type Intent =
|
|
|
18
18
|
| { type: "spanKey"; name: string }
|
|
19
19
|
| { type: "openCompose"; kind: "comment" | "suggestion"; from: "cursor" | "span" }
|
|
20
20
|
| { type: "openSubmit" }
|
|
21
|
+
| { type: "share" }
|
|
21
22
|
| { type: "cut" }
|
|
22
23
|
| { type: "edit" }
|
|
23
24
|
| { type: "editCard" }
|
|
@@ -50,6 +51,15 @@ export interface KeyState {
|
|
|
50
51
|
/** Loaded keymap (config.ts): action -> key combos. */
|
|
51
52
|
keys: Record<string, string[]>;
|
|
52
53
|
readOnly: boolean;
|
|
54
|
+
/**
|
|
55
|
+
* Owner-only verbs a share collaborator lacks (undefined = owner, allowed).
|
|
56
|
+
* A collaborator annotates but cannot edit the plan (cut / $EDITOR runs on
|
|
57
|
+
* the gateway) or submit an agent verdict (there is no agent on a share).
|
|
58
|
+
*/
|
|
59
|
+
canEditPlan?: boolean;
|
|
60
|
+
canSubmitVerdict?: boolean;
|
|
61
|
+
/** Owner-only: publish the plan as a share. A collaborator never re-shares. */
|
|
62
|
+
canShare?: boolean;
|
|
53
63
|
/** Layer that owns keys before the grammar runs. */
|
|
54
64
|
overlay: "none" | "walk" | "compose" | "submit" | "completion-prompt" | "completion-counting";
|
|
55
65
|
view: "inbox" | "plan" | "diff";
|
|
@@ -66,7 +76,7 @@ export interface KeyState {
|
|
|
66
76
|
}
|
|
67
77
|
|
|
68
78
|
/** Verbs that write session state; an observer never reaches their handlers. */
|
|
69
|
-
const MUTATING_ACTIONS = new Set(["comment", "suggest", "cut", "edit", "delete_annotation", "submit", "walk"]);
|
|
79
|
+
const MUTATING_ACTIONS = new Set(["comment", "suggest", "cut", "edit", "delete_annotation", "submit", "walk", "share"]);
|
|
70
80
|
|
|
71
81
|
const SPAN_KEYS = ["l", "h", "w", "b", "$", "0"];
|
|
72
82
|
|
|
@@ -127,6 +137,12 @@ export function reduceKey(state: KeyState, key: KeyInput, resolvedAction?: strin
|
|
|
127
137
|
const mutating = MUTATING_ACTIONS.has(action ?? "") || (state.spanMode && (name === "c" || name === "s"));
|
|
128
138
|
if (state.readOnly && mutating) return status("observer - read-only");
|
|
129
139
|
|
|
140
|
+
// share is a session-level verb: it works from any view, owner only
|
|
141
|
+
if (action === "share") {
|
|
142
|
+
if (state.canShare === false) return status("only the plan owner can share");
|
|
143
|
+
return [{ type: "share" }];
|
|
144
|
+
}
|
|
145
|
+
|
|
130
146
|
if (state.view === "inbox") return inboxGrammar(state, name);
|
|
131
147
|
// span mode owns its single-letter keys (b slides the span back) before the
|
|
132
148
|
// review-panel controls claim them
|
|
@@ -201,6 +217,9 @@ function planGrammar(state: KeyState, action: string | undefined, name: string):
|
|
|
201
217
|
// the document selects, the rail edits: with a card selected, Cut deletes
|
|
202
218
|
// the annotation and edit rewrites the card body in place
|
|
203
219
|
if (state.hasFocusedAnnotation) return [action === "cut" ? { type: "removeAnnotation" } : { type: "editCard" }];
|
|
220
|
+
// editing the plan itself is the owner's verb; a share viewer only annotates,
|
|
221
|
+
// and has no edit affordance, so the key is silent rather than a nag
|
|
222
|
+
if (state.canEditPlan === false) return [];
|
|
204
223
|
return [{ type: action }];
|
|
205
224
|
}
|
|
206
225
|
return annotationCluster(state, action) ?? [];
|
|
@@ -224,6 +243,10 @@ function annotationCluster(state: KeyState, action: string | undefined): Intent[
|
|
|
224
243
|
if (state.resolved || !state.hasFocusedAnnotation) return [];
|
|
225
244
|
return [{ type: "removeAnnotation" }];
|
|
226
245
|
}
|
|
227
|
-
if (action === "submit")
|
|
246
|
+
if (action === "submit") {
|
|
247
|
+
// a collaborator's notes union back as they go; there is no verdict to submit
|
|
248
|
+
if (state.canSubmitVerdict === false) return status("shared view - your notes save as you go; q to leave");
|
|
249
|
+
return state.resolved ? [] : [{ type: "openSubmit" }];
|
|
250
|
+
}
|
|
228
251
|
return null;
|
|
229
252
|
}
|
package/src/serve.test.ts
CHANGED
|
@@ -70,6 +70,7 @@ function sshCapture(port: number, until: (frame: string) => boolean, timeoutMs =
|
|
|
70
70
|
|
|
71
71
|
describe("cueloop serve", () => {
|
|
72
72
|
test("listens on localhost, persists a host key under CUELOOP_HOME/ssh", () => {
|
|
73
|
+
// Assert
|
|
73
74
|
expect(handle.host).toBe("127.0.0.1");
|
|
74
75
|
expect(handle.port).toBeGreaterThan(0);
|
|
75
76
|
expect(handle.fingerprints.length).toBe(1);
|
|
@@ -78,7 +79,10 @@ describe("cueloop serve", () => {
|
|
|
78
79
|
});
|
|
79
80
|
|
|
80
81
|
test("an anonymous ssh client gets the observer TUI for the session", async () => {
|
|
82
|
+
// Act
|
|
81
83
|
const bytes = await sshCapture(handle.port, (frame) => frame.includes("Rollout Plan") && frame.includes("observer"));
|
|
84
|
+
|
|
85
|
+
// Assert
|
|
82
86
|
expect(bytes).toContain("cueloop");
|
|
83
87
|
expect(bytes).toContain("Rollout Plan");
|
|
84
88
|
// observer chrome, not the controller's mutating hint bar
|
|
@@ -86,10 +90,13 @@ describe("cueloop serve", () => {
|
|
|
86
90
|
});
|
|
87
91
|
|
|
88
92
|
test("two observers can watch at once", async () => {
|
|
93
|
+
// Act
|
|
89
94
|
const [a, b] = await Promise.all([
|
|
90
95
|
sshCapture(handle.port, (frame) => frame.includes("Rollout Plan")),
|
|
91
96
|
sshCapture(handle.port, (frame) => frame.includes("Rollout Plan")),
|
|
92
97
|
]);
|
|
98
|
+
|
|
99
|
+
// Assert
|
|
93
100
|
expect(a).toContain("Rollout Plan");
|
|
94
101
|
expect(b).toContain("Rollout Plan");
|
|
95
102
|
});
|
|
@@ -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:
|
|
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
|
|
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
|
+
}
|
package/src/view-diff.test.ts
CHANGED
|
@@ -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
|
|
32
|
+
for (let lineOffset = 0; lineOffset < segment.lines; lineOffset++) {
|
|
33
33
|
rows.push({
|
|
34
34
|
kind: "ctx",
|
|
35
|
-
text: file.additionLines[segment.additionLineIndex +
|
|
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
|
|
42
|
+
for (let lineOffset = 0; lineOffset < segment.deletions; lineOffset++) {
|
|
43
43
|
rows.push({
|
|
44
44
|
kind: "del",
|
|
45
|
-
text: file.deletionLines[segment.deletionLineIndex +
|
|
45
|
+
text: file.deletionLines[segment.deletionLineIndex + lineOffset] ?? "",
|
|
46
46
|
file: file.name,
|
|
47
47
|
oldLine: oldLine++,
|
|
48
48
|
});
|
|
49
49
|
}
|
|
50
|
-
for (let
|
|
50
|
+
for (let lineOffset = 0; lineOffset < segment.additions; lineOffset++) {
|
|
51
51
|
rows.push({
|
|
52
52
|
kind: "add",
|
|
53
|
-
text: file.additionLines[segment.additionLineIndex +
|
|
53
|
+
text: file.additionLines[segment.additionLineIndex + lineOffset] ?? "",
|
|
54
54
|
file: file.name,
|
|
55
55
|
newLine: newLine++,
|
|
56
56
|
});
|