@cueloop/client 0.1.0-alpha.20 → 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.
Files changed (47) hide show
  1. package/package.json +5 -5
  2. package/src/App.autoclose.test.tsx +29 -5
  3. package/src/App.diff.test.tsx +20 -0
  4. package/src/App.export.test.tsx +12 -8
  5. package/src/App.plan-review.test.tsx +182 -7
  6. package/src/App.readonly.test.tsx +19 -5
  7. package/src/App.rendering.test.tsx +21 -2
  8. package/src/App.review-panel.test.tsx +120 -0
  9. package/src/App.submit-confirm.test.tsx +57 -6
  10. package/src/App.test.tsx +62 -5
  11. package/src/App.tsx +147 -185
  12. package/src/App.walk.test.tsx +63 -8
  13. package/src/clipboard.test.ts +23 -0
  14. package/src/clipboard.ts +26 -0
  15. package/src/components/AnnotationCard.compose-rows.test.ts +49 -0
  16. package/src/components/AnnotationCard.tsx +66 -13
  17. package/src/components/ReviewPanel.stories.tsx +86 -0
  18. package/src/components/ReviewPanel.tsx +116 -0
  19. package/src/components/ReviewRail.tsx +30 -5
  20. package/src/components/__snapshots__/stories.test.tsx.snap +140 -0
  21. package/src/components/primitives/Card.tsx +2 -1
  22. package/src/components/primitives/Dialog.tsx +2 -1
  23. package/src/components/primitives/frame.test.ts +12 -0
  24. package/src/components/primitives/frame.ts +14 -0
  25. package/src/components/stories-app.tsx +2 -1
  26. package/src/components/stories.test.tsx +5 -0
  27. package/src/config.test.ts +113 -1
  28. package/src/config.ts +58 -22
  29. package/src/editor.test.ts +24 -0
  30. package/src/herdr.test.ts +4 -0
  31. package/src/index.ts +2 -0
  32. package/src/integrations.test.ts +12 -0
  33. package/src/intent-dispatch.test.ts +213 -0
  34. package/src/intent-dispatch.ts +270 -0
  35. package/src/key-bindings.test.ts +16 -5
  36. package/src/keymap.test.ts +141 -9
  37. package/src/keymap.ts +54 -7
  38. package/src/review-panel.test.ts +111 -0
  39. package/src/review-panel.ts +76 -0
  40. package/src/serve.test.ts +7 -0
  41. package/src/session-controller.ts +39 -4
  42. package/src/share.ts +47 -0
  43. package/src/view-diff.test.ts +11 -0
  44. package/src/view-diff.ts +6 -6
  45. package/src/view-plan.test.ts +81 -0
  46. package/src/view-plan.ts +3 -0
  47. package/src/walk.test.ts +6 -0
@@ -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";
@@ -25,11 +21,12 @@ function state(patch: Partial<KeyState> = {}): KeyState {
25
21
  };
26
22
  }
27
23
 
28
- function key(name: string, shift = false): { name: string; shift: boolean } {
29
- return { name, shift };
24
+ function key(name: string, shift = false, meta = false): { name: string; shift: boolean; meta: boolean } {
25
+ return { name, shift, meta };
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" }]],
@@ -45,16 +42,26 @@ describe("compose overlay", () => {
45
42
  expect(reduceKey(keyState, key(name))).toEqual(expected);
46
43
  });
47
44
  }
45
+
46
+ // ⌥/Alt+⏎ (meta) and shift+⏎ insert a newline in the composer, so the
47
+ // grammar must not save on them; only a bare ⏎ saves.
48
+ test("option/alt+return (meta) does not save - the textarea inserts a newline", () => {
49
+ expect(reduceKey(keyState, key("return", false, true))).toEqual([]);
50
+ });
51
+ test("shift+return does not save - the textarea inserts a newline", () => {
52
+ expect(reduceKey(keyState, key("return", true, false))).toEqual([]);
53
+ });
48
54
  });
49
55
 
50
56
  describe("submit overlay", () => {
57
+ // Arrange
51
58
  const keyState = state({ overlay: "submit" });
52
59
  const table: [string, Intent[]][] = [
53
60
  ["escape", [{ type: "closeOverlay" }]],
54
61
  ["return", [{ type: "submitVerdict" }]],
55
62
  ["enter", [{ type: "submitVerdict" }]],
56
- ["left", [{ type: "cycleVerdict", dir: -1 }]],
57
- ["right", [{ type: "cycleVerdict", dir: 1 }]],
63
+ ["left", [{ type: "cycleVerdict", direction: -1 }]],
64
+ ["right", [{ type: "cycleVerdict", direction: 1 }]],
58
65
  ["j", []],
59
66
  ["q", []],
60
67
  ];
@@ -66,6 +73,7 @@ describe("submit overlay", () => {
66
73
  });
67
74
 
68
75
  describe("completion overlay", () => {
76
+ // Arrange
69
77
  const prompt = state({ overlay: "completion-prompt" });
70
78
  const counting = state({ overlay: "completion-counting" });
71
79
  const table: [KeyState, string, Intent[]][] = [
@@ -88,6 +96,7 @@ describe("completion overlay", () => {
88
96
  });
89
97
 
90
98
  describe("inbox mode", () => {
99
+ // Arrange
91
100
  const keyState = state({ view: "inbox" });
92
101
  const table: [string, Intent[]][] = [
93
102
  ["j", [{ type: "inboxMove", to: "down" }]],
@@ -107,7 +116,10 @@ describe("inbox mode", () => {
107
116
  }
108
117
 
109
118
  test("an empty inbox swallows every non-quit key", () => {
119
+ // Arrange
110
120
  const empty = state({ view: "inbox", hasInboxItems: false });
121
+
122
+ // Assert
111
123
  for (const name of ["j", "k", "down", "up", "return", "enter"]) {
112
124
  expect(reduceKey(empty, key(name))).toEqual([]);
113
125
  }
@@ -115,7 +127,63 @@ describe("inbox mode", () => {
115
127
  });
116
128
  });
117
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 refused", () => {
148
+ // Arrange
149
+ const collab = state({ canEditPlan: false, hasFocusedAnnotation: false });
150
+
151
+ // Act / Assert
152
+ expect(reduceKey(collab, key("e"), "edit")).toEqual([{ type: "status", message: "shared plan - edit it in your own copy" }]);
153
+ expect(reduceKey(collab, key("c"), "cut")).toEqual([{ type: "status", message: "shared plan - edit it in your own copy" }]);
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
+
118
185
  describe("plan normal mode", () => {
186
+ // Arrange
119
187
  const keyState = state();
120
188
  const table: [string, boolean, Intent[]][] = [
121
189
  ["j", false, [{ type: "move", to: "down" }]],
@@ -145,7 +213,10 @@ describe("plan normal mode", () => {
145
213
  }
146
214
 
147
215
  test("resolved sessions guard the mutating verbs", () => {
216
+ // Arrange
148
217
  const resolvedState = state({ resolved: true });
218
+
219
+ // Assert
149
220
  for (const name of ["c", "s", "x", "e"]) {
150
221
  expect(reduceKey(resolvedState, key(name))).toEqual([{ type: "status", message: "review submitted - read-only" }]);
151
222
  }
@@ -154,27 +225,37 @@ describe("plan normal mode", () => {
154
225
  });
155
226
 
156
227
  test("cut text cannot host a comment or a span", () => {
228
+ // Arrange
157
229
  const cut = state({ cursorAnnotatable: false });
230
+
231
+ // Assert
158
232
  expect(reduceKey(cut, key("c"))).toEqual([{ type: "status", message: "text is cut - restore it first" }]);
159
233
  expect(reduceKey(cut, key("s"))).toEqual([{ type: "status", message: "text is cut - restore it first" }]);
160
234
  expect(reduceKey(cut, key("v"))).toEqual([]);
161
235
  });
162
236
 
163
237
  test("annotation navigation without annotations reports, delete without focus is silent", () => {
238
+ // Arrange
164
239
  const none = state({ annotationCount: 0, hasFocusedAnnotation: false });
240
+
241
+ // Assert
165
242
  expect(reduceKey(none, key("n"))).toEqual([{ type: "status", message: "no annotations" }]);
166
243
  expect(reduceKey(none, key("p"))).toEqual([{ type: "status", message: "no annotations" }]);
167
244
  expect(reduceKey(none, key("backspace"))).toEqual([]);
168
245
  });
169
246
 
170
247
  test("without a selected card x cuts the block and e opens the editor", () => {
248
+ // Arrange
171
249
  const unfocused = state({ hasFocusedAnnotation: false });
250
+
251
+ // Assert
172
252
  expect(reduceKey(unfocused, key("x"))).toEqual([{ type: "cut" }]);
173
253
  expect(reduceKey(unfocused, key("e"))).toEqual([{ type: "edit" }]);
174
254
  });
175
255
  });
176
256
 
177
257
  describe("span mode", () => {
258
+ // Arrange
178
259
  const keyState = state({ spanMode: true });
179
260
  const table: [string, Intent[]][] = [
180
261
  ["l", [{ type: "spanKey", name: "l" }]],
@@ -197,6 +278,7 @@ describe("span mode", () => {
197
278
  });
198
279
 
199
280
  describe("diff mode", () => {
281
+ // Arrange
200
282
  const keyState = state({ view: "diff" });
201
283
  const table: [string, Intent[]][] = [
202
284
  ["j", [{ type: "move", to: "down" }]],
@@ -220,6 +302,7 @@ describe("diff mode", () => {
220
302
  }
221
303
 
222
304
  test("comment guards: resolved first, then non-code rows", () => {
305
+ // Assert
223
306
  expect(reduceKey(state({ view: "diff", resolved: true }), key("c"))).toEqual([
224
307
  { type: "status", message: "review submitted - read-only" },
225
308
  ]);
@@ -231,6 +314,7 @@ describe("diff mode", () => {
231
314
 
232
315
  describe("guided walk", () => {
233
316
  test("w enters the walk in diff view; resolved and read-only sessions answer", () => {
317
+ // Assert
234
318
  expect(reduceKey(state({ view: "diff" }), key("w"))).toEqual([{ type: "walkStart" }]);
235
319
  expect(reduceKey(state({ view: "diff", resolved: true }), key("w"))).toEqual([
236
320
  { type: "status", message: "review submitted - read-only" },
@@ -241,6 +325,7 @@ describe("guided walk", () => {
241
325
  expect(reduceKey(state(), key("w"))).toEqual([{ type: "status", message: "the guided walk is a diff-review mode" }]);
242
326
  });
243
327
 
328
+ // Arrange
244
329
  const walking = state({ view: "diff", overlay: "walk" });
245
330
  const table: [string, Intent[]][] = [
246
331
  ["]", [{ type: "walkForward" }]],
@@ -259,12 +344,53 @@ describe("guided walk", () => {
259
344
  }
260
345
 
261
346
  test("the end card's return leaves the walk and opens the submit confirm", () => {
347
+ // Arrange
262
348
  const atEnd = state({ view: "diff", overlay: "walk", walkAtEnd: true });
349
+
350
+ // Assert
263
351
  expect(reduceKey(atEnd, key("return"))).toEqual([{ type: "walkLeave" }, { type: "openSubmit" }]);
264
352
  expect(reduceKey(atEnd, key("enter"))).toEqual([{ type: "walkLeave" }, { type: "openSubmit" }]);
265
353
  });
266
354
  });
267
355
 
356
+ describe("review panel controls", () => {
357
+ const table: [string, Intent[]][] = [
358
+ ["b", [{ type: "cycleReviewPanel" }]],
359
+ ["]", [{ type: "resizeReviewPanel", direction: 1 }]],
360
+ ["[", [{ type: "resizeReviewPanel", direction: -1 }]],
361
+ ];
362
+ for (const view of ["plan", "diff"] as const) {
363
+ for (const [name, expected] of table) {
364
+ test(`${view} ${name} -> ${JSON.stringify(expected)}`, () => {
365
+ expect(reduceKey(state({ view }), key(name))).toEqual(expected);
366
+ });
367
+ }
368
+ }
369
+
370
+ test("observers may still collapse and resize the panel (it is view state, not a mutation)", () => {
371
+ // Arrange
372
+ const observer = state({ readOnly: true });
373
+
374
+ // Assert
375
+ expect(reduceKey(observer, key("b"))).toEqual([{ type: "cycleReviewPanel" }]);
376
+ expect(reduceKey(observer, key("]"))).toEqual([{ type: "resizeReviewPanel", direction: 1 }]);
377
+ });
378
+
379
+ test("span mode still owns b as a span verb, not a panel cycle", () => {
380
+ // Assert
381
+ expect(reduceKey(state({ spanMode: true }), key("b"))).toEqual([{ type: "spanKey", name: "b" }]);
382
+ });
383
+
384
+ test("the walk overlay keeps the brackets for stepping", () => {
385
+ // Arrange
386
+ const walking = state({ view: "diff", overlay: "walk" });
387
+
388
+ // Assert
389
+ expect(reduceKey(walking, key("]"))).toEqual([{ type: "walkForward" }]);
390
+ expect(reduceKey(walking, key("["))).toEqual([{ type: "walkBack" }]);
391
+ });
392
+ });
393
+
268
394
  describe("duplicated-branch collapse", () => {
269
395
  test("plan and diff produce the same intents for annotation nav, delete, and submit", () => {
270
396
  for (const patch of [
@@ -292,15 +418,21 @@ describe("read-only filter", () => {
292
418
  });
293
419
 
294
420
  test("navigation and annotation focus still work for observers", () => {
421
+ // Arrange
295
422
  const keyState = state({ readOnly: true });
423
+
424
+ // Assert
296
425
  expect(reduceKey(keyState, key("j"))).toEqual([{ type: "move", to: "down" }]);
297
426
  expect(reduceKey(keyState, key("n"))).toEqual([{ type: "nextAnnotation" }]);
298
427
  expect(reduceKey(keyState, key("q"))).toEqual([{ type: "exit" }]);
299
428
  });
300
429
 
301
430
  test("span-mode c/s are gated by key name even under a rebound keymap", () => {
431
+ // Arrange
302
432
  const rebound = { ...DEFAULT_KEYS, comment: ["m"], suggest: ["t"] };
303
433
  const keyState = state({ spanMode: true, readOnly: true, keys: rebound });
434
+
435
+ // Assert
304
436
  expect(reduceKey(keyState, key("c"))).toEqual([{ type: "status", message: "observer - read-only" }]);
305
437
  expect(reduceKey(keyState, key("s"))).toEqual([{ type: "status", message: "observer - read-only" }]);
306
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" }
@@ -32,20 +33,33 @@ export type Intent =
32
33
  | { type: "closeOverlay" }
33
34
  | { type: "saveCompose" }
34
35
  | { type: "submitVerdict" }
35
- | { type: "cycleVerdict"; dir: -1 | 1 }
36
+ | { type: "cycleVerdict"; direction: -1 | 1 }
36
37
  | { type: "finishReview" }
37
38
  | { type: "optInAutoClose" }
38
- | { type: "dismissCompletion" };
39
+ | { type: "dismissCompletion" }
40
+ | { type: "cycleReviewPanel" }
41
+ | { type: "resizeReviewPanel"; direction: -1 | 1 };
39
42
 
40
43
  export interface KeyInput {
41
44
  name: string;
42
45
  shift: boolean;
46
+ /** Option/Alt arrives as `meta` in this terminal stack (never as a raw alt). */
47
+ meta?: boolean;
43
48
  }
44
49
 
45
50
  export interface KeyState {
46
51
  /** Loaded keymap (config.ts): action -> key combos. */
47
52
  keys: Record<string, string[]>;
48
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;
49
63
  /** Layer that owns keys before the grammar runs. */
50
64
  overlay: "none" | "walk" | "compose" | "submit" | "completion-prompt" | "completion-counting";
51
65
  view: "inbox" | "plan" | "diff";
@@ -62,7 +76,7 @@ export interface KeyState {
62
76
  }
63
77
 
64
78
  /** Verbs that write session state; an observer never reaches their handlers. */
65
- 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"]);
66
80
 
67
81
  const SPAN_KEYS = ["l", "h", "w", "b", "$", "0"];
68
82
 
@@ -83,10 +97,17 @@ export function reduceKey(state: KeyState, key: KeyInput, resolvedAction?: strin
83
97
  if (state.overlay === "compose" || state.overlay === "submit") {
84
98
  if (name === "escape") return [{ type: "closeOverlay" }];
85
99
  if (name === "return" || name === "enter") {
86
- return [state.overlay === "compose" ? { type: "saveCompose" } : { type: "submitVerdict" }];
100
+ // In the composer, ⌥/Alt+⏎ (meta) and shift+⏎ insert a newline - the
101
+ // focused textarea owns that; only a bare ⏎ saves. The submit overlay
102
+ // keeps its plain ⏎ submit.
103
+ if (state.overlay === "compose") {
104
+ if (key.shift || key.meta) return [];
105
+ return [{ type: "saveCompose" }];
106
+ }
107
+ return [{ type: "submitVerdict" }];
87
108
  }
88
109
  if (state.overlay === "submit" && (name === "left" || name === "right")) {
89
- return [{ type: "cycleVerdict", dir: name === "left" ? -1 : 1 }];
110
+ return [{ type: "cycleVerdict", direction: name === "left" ? -1 : 1 }];
90
111
  }
91
112
  return [];
92
113
  }
@@ -116,12 +137,32 @@ export function reduceKey(state: KeyState, key: KeyInput, resolvedAction?: strin
116
137
  const mutating = MUTATING_ACTIONS.has(action ?? "") || (state.spanMode && (name === "c" || name === "s"));
117
138
  if (state.readOnly && mutating) return status("observer - read-only");
118
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
+
119
146
  if (state.view === "inbox") return inboxGrammar(state, name);
120
- if (state.view === "diff") return diffGrammar(state, action);
147
+ // span mode owns its single-letter keys (b slides the span back) before the
148
+ // review-panel controls claim them
121
149
  if (state.spanMode) return spanGrammar(name);
150
+ // the review panel rides both plan and diff reviews; collapsing and resizing
151
+ // are view state, so the read-only gate above lets them through
152
+ const reviewPanel = reviewPanelGrammar(action);
153
+ if (reviewPanel) return reviewPanel;
154
+ if (state.view === "diff") return diffGrammar(state, action);
122
155
  return planGrammar(state, action, name);
123
156
  }
124
157
 
158
+ /** The review-panel controls: cycle the mode, widen and narrow the rail. */
159
+ function reviewPanelGrammar(action: string | undefined): Intent[] | null {
160
+ if (action === "review_cycle") return [{ type: "cycleReviewPanel" }];
161
+ if (action === "review_wider") return [{ type: "resizeReviewPanel", direction: 1 }];
162
+ if (action === "review_narrower") return [{ type: "resizeReviewPanel", direction: -1 }];
163
+ return null;
164
+ }
165
+
125
166
  function inboxGrammar(state: KeyState, name: string): Intent[] {
126
167
  if (!state.hasInboxItems) return [];
127
168
  if (name === "j" || name === "down") return [{ type: "inboxMove", to: "down" }];
@@ -176,6 +217,8 @@ function planGrammar(state: KeyState, action: string | undefined, name: string):
176
217
  // the document selects, the rail edits: with a card selected, Cut deletes
177
218
  // the annotation and edit rewrites the card body in place
178
219
  if (state.hasFocusedAnnotation) return [action === "cut" ? { type: "removeAnnotation" } : { type: "editCard" }];
220
+ // editing the plan itself is the owner's verb - a collaborator only annotates
221
+ if (state.canEditPlan === false) return status("shared plan - edit it in your own copy");
179
222
  return [{ type: action }];
180
223
  }
181
224
  return annotationCluster(state, action) ?? [];
@@ -199,6 +242,10 @@ function annotationCluster(state: KeyState, action: string | undefined): Intent[
199
242
  if (state.resolved || !state.hasFocusedAnnotation) return [];
200
243
  return [{ type: "removeAnnotation" }];
201
244
  }
202
- if (action === "submit") return state.resolved ? [] : [{ type: "openSubmit" }];
245
+ if (action === "submit") {
246
+ // a collaborator's notes union back as they go; there is no verdict to submit
247
+ if (state.canSubmitVerdict === false) return status("shared view - your notes save as you go; q to leave");
248
+ return state.resolved ? [] : [{ type: "openSubmit" }];
249
+ }
203
250
  return null;
204
251
  }
@@ -0,0 +1,111 @@
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(REVIEW_DEFAULT_WIDTH + REVIEW_RESIZE_STEP);
72
+ expect(clampWidth(REVIEW_MIN_WIDTH - REVIEW_RESIZE_STEP)).toBe(REVIEW_MIN_WIDTH);
73
+ });
74
+ });
75
+
76
+ describe("maxWidthForTerminal", () => {
77
+ test("allows the full rail on a wide terminal", () => {
78
+ expect(maxWidthForTerminal(120)).toBe(REVIEW_MAX_WIDTH);
79
+ });
80
+
81
+ test("reserves the plan its minimum on a narrow terminal", () => {
82
+ // 60-col terminal: 30 for the plan and 1 for the divider leaves 29 for the rail
83
+ expect(maxWidthForTerminal(60)).toBe(60 - REVIEW_PLAN_MIN_WIDTH - 1);
84
+ });
85
+
86
+ test("never drops below the rail minimum, however narrow the terminal", () => {
87
+ expect(maxWidthForTerminal(40)).toBe(REVIEW_MIN_WIDTH);
88
+ });
89
+ });
90
+
91
+ describe("resolveReviewWidth", () => {
92
+ test("leaves a width that already fits untouched", () => {
93
+ expect(resolveReviewWidth(REVIEW_DEFAULT_WIDTH, 120)).toBe(REVIEW_DEFAULT_WIDTH);
94
+ });
95
+
96
+ test("shrinks a wide persisted width so the plan keeps its room", () => {
97
+ // a saved 50-col rail on a 60-col terminal would leave the plan 9 cols; clamp it back
98
+ expect(resolveReviewWidth(REVIEW_MAX_WIDTH, 60)).toBe(60 - REVIEW_PLAN_MIN_WIDTH - 1);
99
+ });
100
+ });
101
+
102
+ describe("reviewRowsToDivider", () => {
103
+ test("builds one vertical glyph per row", () => {
104
+ expect(reviewRowsToDivider(3)).toBe("│\n│\n│");
105
+ });
106
+
107
+ test("never collapses to an empty column", () => {
108
+ expect(reviewRowsToDivider(0)).toBe("│");
109
+ expect(reviewRowsToDivider(-4)).toBe("│");
110
+ });
111
+ });
@@ -0,0 +1,76 @@
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(desiredWidth: number, min = REVIEW_MIN_WIDTH, max = REVIEW_MAX_WIDTH): number {
30
+ return Math.max(min, Math.min(max, Math.round(desiredWidth)));
31
+ }
32
+
33
+ /**
34
+ * The widest the expanded rail may be on a given terminal, reserving the plan
35
+ * its minimum and one column for the divider. Never drops below REVIEW_MIN_WIDTH
36
+ * (an extremely narrow terminal is the auto-collapse case, handled elsewhere).
37
+ */
38
+ export function maxWidthForTerminal(terminalWidth: number): number {
39
+ return Math.max(REVIEW_MIN_WIDTH, Math.min(REVIEW_MAX_WIDTH, terminalWidth - REVIEW_PLAN_MIN_WIDTH - 1));
40
+ }
41
+
42
+ /** Clamp a desired rail width to the bounds AND the terminal, so the plan keeps its room. */
43
+ export function resolveReviewWidth(desiredWidth: number, terminalWidth: number): number {
44
+ return clampWidth(desiredWidth, REVIEW_MIN_WIDTH, maxWidthForTerminal(terminalWidth));
45
+ }
46
+
47
+ /** The keybinding cycle: expanded -> compact -> hidden -> expanded. */
48
+ export function cycleReviewPanelMode(mode: ReviewPanelMode): ReviewPanelMode {
49
+ return mode === "expanded" ? "compact" : mode === "compact" ? "hidden" : "expanded";
50
+ }
51
+
52
+ /**
53
+ * The clickable chevron toggles only between expanded and compact - hidden is
54
+ * reachable by keybinding alone, so it never leaves a leftover tab behind. A
55
+ * toggle from hidden still opens the panel rather than doing nothing.
56
+ */
57
+ export function toggleReviewPanelMode(mode: ReviewPanelMode): ReviewPanelMode {
58
+ return mode === "expanded" ? "compact" : "expanded";
59
+ }
60
+
61
+ /**
62
+ * Rail width from an absolute mouse column while dragging the divider: the rail
63
+ * spans from the mouse column to the terminal's right edge, then clamps.
64
+ */
65
+ export function widthFromMouseColumn(mouseColumn: number, terminalWidth: number): number {
66
+ return resolveReviewWidth(terminalWidth - mouseColumn, terminalWidth);
67
+ }
68
+
69
+ /**
70
+ * The divider is a single-column stack of vertical glyphs (a text block, not a
71
+ * bordered box - a one-column box with a border renders a full box). One glyph
72
+ * per row, at least one so the column never collapses.
73
+ */
74
+ export function reviewRowsToDivider(rows: number): string {
75
+ return Array.from({ length: Math.max(1, rows) }, () => "│").join("\n");
76
+ }
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
  });