@cueloop/client 0.1.0-alpha.63 → 0.1.0-alpha.65

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 (68) hide show
  1. package/package.json +7 -6
  2. package/src/App.autoclose.test.tsx +2 -2
  3. package/src/App.export.test.tsx +2 -2
  4. package/src/App.plan-review.test.tsx +76 -247
  5. package/src/App.readonly.test.tsx +80 -35
  6. package/src/App.rendering.test.tsx +5 -6
  7. package/src/App.review-panel.test.tsx +29 -32
  8. package/src/App.submit-confirm.test.tsx +27 -12
  9. package/src/App.test.tsx +73 -49
  10. package/src/App.toast.test.tsx +34 -22
  11. package/src/App.tree.test.tsx +224 -0
  12. package/src/App.tsx +134 -87
  13. package/src/app-screens.tsx +27 -1
  14. package/src/app-view-model.ts +7 -109
  15. package/src/components/AnnotationCard.tsx +2 -2
  16. package/src/components/DiffSheet.scroll.test.tsx +5 -2
  17. package/src/components/DiffSheet.tsx +1 -6
  18. package/src/components/KeybindsDialog.tsx +61 -17
  19. package/src/components/PrototypeSheet.tsx +5 -9
  20. package/src/components/ReviewPanel.stories.tsx +14 -0
  21. package/src/components/ReviewRail.stories.tsx +14 -0
  22. package/src/components/ReviewRail.tsx +15 -6
  23. package/src/components/ThreadView.selection.test.tsx +812 -0
  24. package/src/components/ThreadView.stories.tsx +26 -0
  25. package/src/components/ThreadView.tsx +1620 -0
  26. package/src/components/TreePane.stories.tsx +106 -0
  27. package/src/components/TreePane.tsx +156 -0
  28. package/src/components/__snapshots__/stories.test.tsx.snap +120 -172
  29. package/src/components/primitives/Tabs.tsx +1 -1
  30. package/src/components/story-fixtures.ts +6 -2
  31. package/src/components/story.ts +16 -4
  32. package/src/components/syntax-highlight.ts +10 -2
  33. package/src/components/terminal-pane.test.tsx +1 -1
  34. package/src/components/terminal-pane.ts +7 -1
  35. package/src/config.ts +112 -99
  36. package/src/diff-hunk-curate.ts +4 -104
  37. package/src/editor.ts +5 -1
  38. package/src/ghostty-terminal.test.ts +1 -1
  39. package/src/herdr.ts +10 -2
  40. package/src/index.ts +1 -0
  41. package/src/intent-dispatch.test.ts +235 -55
  42. package/src/intent-dispatch.ts +104 -34
  43. package/src/key-bindings.test.ts +1 -1
  44. package/src/key-bindings.ts +16 -7
  45. package/src/keymap.test.ts +9 -11
  46. package/src/keymap.ts +14 -7
  47. package/src/kitty-image.test.ts +3 -3
  48. package/src/prototype-browser.ts +7 -6
  49. package/src/serve.ts +1 -1
  50. package/src/session-controller.curate.test.ts +106 -4
  51. package/src/session-controller.share.test.ts +220 -70
  52. package/src/session-controller.tree.test.ts +228 -0
  53. package/src/session-controller.ts +370 -94
  54. package/src/share.ts +117 -17
  55. package/src/test-support.test.ts +193 -1
  56. package/src/test-support.ts +69 -1
  57. package/src/theme-presets.ts +9 -2
  58. package/src/thread-chords.test.ts +154 -0
  59. package/src/thread-chords.ts +144 -0
  60. package/src/thread-selection.test.ts +210 -0
  61. package/src/thread-selection.ts +243 -0
  62. package/src/tree-view.test.ts +128 -0
  63. package/src/tree-view.ts +148 -0
  64. package/src/view-diff.ts +1 -4
  65. package/src/view-plan.ts +35 -19
  66. package/src/components/PlanSheet.stories.tsx +0 -113
  67. package/src/components/PlanSheet.tsx +0 -422
  68. package/src/components/plan-sheet-run-style.ts +0 -90
@@ -10,6 +10,7 @@
10
10
 
11
11
  import { Keymap, type KeymapEvent, type KeymapHost } from "@opentui/keymap";
12
12
  import { registerDefaultKeys } from "@opentui/keymap/addons";
13
+ import * as v from "valibot";
13
14
  import type { KeyState } from "./keymap";
14
15
 
15
16
  export interface ResolvableKey {
@@ -111,7 +112,11 @@ const HINT_TEMPLATES: Record<HintMode, HintEntry[]> = {
111
112
  };
112
113
 
113
114
  /** Status-line glyphs for named keys. */
114
- const KEY_GLYPHS: Record<string, string> = {
115
+ interface KeyGlyphs {
116
+ [key: string]: string;
117
+ }
118
+
119
+ const KEY_GLYPHS: KeyGlyphs = {
115
120
  return: "enter",
116
121
  enter: "enter",
117
122
  escape: "esc",
@@ -186,7 +191,7 @@ class HeadlessKeymapHost implements KeymapHost<object, KeymapEvent> {
186
191
  * keymap's canonical form is lowercase + the shift modifier. Non-letter
187
192
  * single characters ("$", "0") already encode their shift in the character.
188
193
  */
189
- function normalizedEvent(key: ResolvableKey): { name: string; shift: boolean } {
194
+ function normalizedEvent(key: ResolvableKey) {
190
195
  if (key.name.length === 1) {
191
196
  if (/[a-zA-Z]/.test(key.name)) {
192
197
  return {
@@ -220,6 +225,10 @@ const SPAN_COMMANDS: [key: string, command: string][] = [
220
225
  ["escape", "span_cancel"],
221
226
  ];
222
227
 
228
+ function isConditionMatcher(value: unknown): value is () => boolean {
229
+ return typeof value === "function";
230
+ }
231
+
223
232
  export class KeyBindings {
224
233
  private readonly host = new HeadlessKeymapHost();
225
234
  private readonly keymap = new Keymap<object, KeymapEvent>(this.host);
@@ -234,7 +243,7 @@ export class KeyBindings {
234
243
  // and dispatch only see the layer that owns the current mode
235
244
  this.keymap.registerLayerFields({
236
245
  when: (value, fieldContext) => {
237
- if (typeof value === "function") fieldContext.activeWhen(value as () => boolean);
246
+ if (isConditionMatcher(value)) fieldContext.activeWhen(value);
238
247
  },
239
248
  });
240
249
  this.registerModeLayers();
@@ -373,10 +382,10 @@ export class KeyBindings {
373
382
  /** First active key display for a command, mapped through the glyph table. */
374
383
  private keyDisplayFor(command: string): string | null {
375
384
  for (const activeKey of this.keymap.getActiveKeys({ includeBindings: true })) {
376
- const bindingCommand =
377
- typeof activeKey.command === "string"
378
- ? activeKey.command
379
- : activeKey.bindings?.[0]?.command;
385
+ const parsedCommand = v.safeParse(v.string(), activeKey.command);
386
+ const bindingCommand = parsedCommand.success
387
+ ? parsedCommand.output
388
+ : activeKey.bindings?.[0]?.command;
380
389
 
381
390
  if (bindingCommand !== command) continue;
382
391
 
@@ -21,11 +21,7 @@ function state(patch: Partial<KeyState> = {}): KeyState {
21
21
  };
22
22
  }
23
23
 
24
- function key(
25
- name: string,
26
- shift = false,
27
- meta = false,
28
- ): { name: string; shift: boolean; meta: boolean } {
24
+ function key(name: string, shift = false, meta = false) {
29
25
  return { name, shift, meta };
30
26
  }
31
27
 
@@ -228,7 +224,7 @@ describe("plan normal mode", () => {
228
224
  });
229
225
  }
230
226
 
231
- test("resolved sessions guard the mutating verbs", () => {
227
+ test("resolved sessions guard the mutating primitives", () => {
232
228
  // Arrange
233
229
  const resolvedState = state({ resolved: true });
234
230
 
@@ -390,9 +386,9 @@ describe("diff mode", () => {
390
386
  ["p", [{ type: "prevAnnotation" }]],
391
387
  ["backspace", [{ type: "removeAnnotation" }]],
392
388
  ["return", [{ type: "openSubmit" }]],
393
- ["v", [{ type: "status", message: "plan-only verb - diff review uses c on a line" }]],
389
+ ["v", [{ type: "status", message: "plan-only primitive - diff review uses c on a line" }]],
394
390
  ["x", [{ type: "rejectChange" }]],
395
- ["e", [{ type: "status", message: "plan-only verb - diff review uses c on a line" }]],
391
+ ["e", [{ type: "status", message: "plan-only primitive - diff review uses c on a line" }]],
396
392
  ["q", [{ type: "exit" }]],
397
393
  ];
398
394
 
@@ -518,7 +514,7 @@ describe("review panel controls", () => {
518
514
  expect(reduceKey(observer, key("]"))).toEqual([{ type: "resizeReviewPanel", direction: 1 }]);
519
515
  });
520
516
 
521
- test("span mode still owns b as a span verb, not a panel cycle", () => {
517
+ test("span mode still owns b as a span primitive, not a panel cycle", () => {
522
518
  // Assert
523
519
  expect(reduceKey(state({ spanMode: true }), key("b"))).toEqual([
524
520
  { type: "spanKey", name: "b" },
@@ -537,11 +533,13 @@ describe("review panel controls", () => {
537
533
 
538
534
  describe("duplicated-branch collapse", () => {
539
535
  test("plan and diff produce the same intents for annotation nav, delete, and submit", () => {
540
- for (const patch of [
536
+ const patches: Partial<KeyState>[] = [
541
537
  {},
542
538
  { annotationCount: 0, hasFocusedAnnotation: false },
543
539
  { resolved: true },
544
- ] as Partial<KeyState>[]) {
540
+ ];
541
+
542
+ for (const patch of patches) {
545
543
  for (const name of ["n", "p", "backspace", "return"]) {
546
544
  const plan = reduceKey(state(patch), key(name));
547
545
  const diff = reduceKey(state({ ...patch, view: "diff" }), key(name));
package/src/keymap.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * The keyboard grammar as a pure reducer: view state in, intents out.
3
3
  * App builds a KeyState per key event, calls reduceKey, and dispatches the
4
- * intents to controller verbs and view-state setters - no branch bodies live
4
+ * intents to controller primitives and view-state setters - no branch bodies live
5
5
  * in the key handler. Diff and plan reviews share one path for annotation
6
6
  * navigation, deletion, and submit; the read-only rule is one gate here.
7
7
  */
@@ -49,7 +49,14 @@ export type Intent =
49
49
  | { type: "optInAutoClose" }
50
50
  | { type: "dismissCompletion" }
51
51
  | { type: "cycleReviewPanel" }
52
- | { type: "resizeReviewPanel"; direction: -1 | 1 };
52
+ | { type: "resizeReviewPanel"; direction: -1 | 1 }
53
+ | { type: "toggleTree" }
54
+ | { type: "treeMove"; direction: -1 | 1 }
55
+ | { type: "treeGo" }
56
+ | { type: "treeBranch" }
57
+ | { type: "treeLabel" }
58
+ | { type: "treeFork" }
59
+ | { type: "treeForkShare" };
53
60
 
54
61
  export interface KeyInput {
55
62
  name: string;
@@ -63,7 +70,7 @@ export interface KeyState {
63
70
  keys: Record<string, string[]>;
64
71
  readOnly: boolean;
65
72
  /**
66
- * Owner-only verbs a share collaborator lacks (undefined = owner, allowed).
73
+ * Owner-only primitives a share collaborator lacks (undefined = owner, allowed).
67
74
  * A collaborator annotates but cannot edit the plan (cut / $EDITOR runs on
68
75
  * the gateway) or submit an agent verdict (there is no agent on a share).
69
76
  */
@@ -95,7 +102,7 @@ export interface KeyState {
95
102
  cursorAnnotatable: boolean;
96
103
  }
97
104
 
98
- /** Verbs that write session state; an observer never reaches their handlers. */
105
+ /** Primitives that write session state; an observer never reaches their handlers. */
99
106
  const MUTATING_ACTIONS = new Set([
100
107
  "comment",
101
108
  "cut",
@@ -136,7 +143,7 @@ export function reduceKey(state: KeyState, key: KeyInput, resolvedAction?: strin
136
143
 
137
144
  if (state.readOnly && mutating) return status("observer - read-only");
138
145
 
139
- // share is a session-level verb: it works from any view, owner only
146
+ // share is a session-level primitive: it works from any view, owner only
140
147
  if (action === "share") {
141
148
  if (state.canShare === false) return status("only the plan owner can share");
142
149
 
@@ -305,7 +312,7 @@ function diffGrammar(state: KeyState, action: string | undefined): Intent[] {
305
312
 
306
313
  if (shared) return shared;
307
314
  if (action === "span" || action === "edit") {
308
- return status("plan-only verb - diff review uses c on a line");
315
+ return status("plan-only primitive - diff review uses c on a line");
309
316
  }
310
317
 
311
318
  return [];
@@ -356,7 +363,7 @@ function planGrammar(state: KeyState, action: string | undefined, name: string):
356
363
  // the annotation and edit rewrites the card body in place
357
364
  if (state.hasFocusedAnnotation)
358
365
  return [action === "cut" ? { type: "removeAnnotation" } : { type: "editCard" }];
359
- // editing the plan itself is the owner's verb; a share viewer only annotates,
366
+ // editing the plan itself is the owner's primitive; a share viewer only annotates,
360
367
  // and has no edit affordance, so the key is silent rather than a nag
361
368
  if (state.canEditPlan === false) return [];
362
369
 
@@ -54,13 +54,13 @@ describe("kitty image placement", () => {
54
54
  describe("resolveTransmitMedium", () => {
55
55
  test("defaults to base64; file transfer is opt-in and never over ssh", () => {
56
56
  // Assert
57
- expect(resolveTransmitMedium({} as NodeJS.ProcessEnv)).toBe("base64");
58
- expect(resolveTransmitMedium({ CUELOOP_KITTY_FILE: "1" } as NodeJS.ProcessEnv)).toBe("file");
57
+ expect(resolveTransmitMedium({})).toBe("base64");
58
+ expect(resolveTransmitMedium({ CUELOOP_KITTY_FILE: "1" })).toBe("file");
59
59
  expect(
60
60
  resolveTransmitMedium({
61
61
  CUELOOP_KITTY_FILE: "1",
62
62
  SSH_CONNECTION: "1.2.3.4 5 6 7",
63
- } as NodeJS.ProcessEnv),
63
+ }),
64
64
  ).toBe("base64");
65
65
  });
66
66
  });
@@ -73,7 +73,7 @@ export function cssBoxToCell(
73
73
  box: ElementBox,
74
74
  image: { x: number; y: number; width: number; height: number },
75
75
  viewport: PrototypeViewport,
76
- ): { column: number; row: number; columns: number; rows: number } {
76
+ ) {
77
77
  const scaleColumn = image.width / viewport.width;
78
78
  const scaleRow = image.height / viewport.height;
79
79
 
@@ -174,13 +174,13 @@ export async function launchPrototypeRenderer(options: LaunchOptions): Promise<P
174
174
  omitBackground: true,
175
175
  });
176
176
 
177
- return new Uint8Array(buffer as Buffer);
177
+ return new Uint8Array(buffer);
178
178
  },
179
179
  async elementAt(cssX, cssY) {
180
- return (await page.evaluate(elementAtScript, cssX, cssY)) as PrototypeElement | null;
180
+ return page.evaluate(elementAtScript, cssX, cssY);
181
181
  },
182
182
  async scrollBy(deltaY) {
183
- return (await page.evaluate(scrollByScript, deltaY)) as boolean;
183
+ return page.evaluate(scrollByScript, deltaY);
184
184
  },
185
185
  async close() {
186
186
  await page.close().catch(() => undefined);
@@ -189,7 +189,7 @@ export async function launchPrototypeRenderer(options: LaunchOptions): Promise<P
189
189
  }
190
190
 
191
191
  /** Serialized into the page (self-contained for puppeteer): resolve the click to a component element. */
192
- function elementAtScript(x: number, y: number): unknown {
192
+ function elementAtScript(x: number, y: number) {
193
193
  const SEMANTIC = new Set([
194
194
  "SECTION",
195
195
  "ARTICLE",
@@ -232,7 +232,8 @@ function elementAtScript(x: number, y: number): unknown {
232
232
  const parent: Element | null = current.parentElement;
233
233
 
234
234
  if (parent) {
235
- const twins = [...parent.children].filter((child) => child.tagName === current!.tagName);
235
+ const currentTagName = current.tagName;
236
+ const twins = [...parent.children].filter((child) => child.tagName === currentTagName);
236
237
 
237
238
  if (twins.length > 1) part += ":nth-of-type(" + (twins.indexOf(current) + 1) + ")";
238
239
  }
package/src/serve.ts CHANGED
@@ -36,7 +36,7 @@ export interface ServeOptions {
36
36
  home?: string;
37
37
  /** Silence the @opentui/ssh startup banner (tests). Default true. */
38
38
  banner?: boolean;
39
- onError?: (err: unknown) => void;
39
+ onError?: (cause: unknown) => void;
40
40
  }
41
41
 
42
42
  export interface ServeHandle {
@@ -1,5 +1,14 @@
1
1
  import { describe, expect, mock, test } from "bun:test";
2
- import { SCHEMA_VERSION, type DiffFileContents, type ReviewSession } from "@cueloop/schema";
2
+ import {
3
+ cutBlock,
4
+ parseBlocks,
5
+ restoreBlock,
6
+ SCHEMA_VERSION,
7
+ type DiffFileContents,
8
+ type HunkRejection,
9
+ type ReviewSession,
10
+ } from "@cueloop/schema";
11
+ import { curateDiff } from "@cueloop/daemon/curate";
3
12
  import type { SessionClient } from "@cueloop/daemon/client";
4
13
  import { createReviewController } from "./session-controller";
5
14
 
@@ -37,26 +46,87 @@ function diffSession(files?: DiffFileContents[]): ReviewSession {
37
46
  };
38
47
  }
39
48
 
49
+ interface WorkingCopySink {
50
+ workingCopy?: string;
51
+ }
52
+
53
+ const unimplemented = (member: string) => () =>
54
+ Promise.reject(new Error(`fakeClient does not implement ${member}`));
55
+
40
56
  /** A fake client that records the working copy the controller writes. */
41
- function fakeClient(session: ReviewSession, sink: { workingCopy?: string }): SessionClient {
57
+ function fakeClient(initial: ReviewSession, sink: WorkingCopySink): SessionClient {
58
+ let session = initial;
59
+
42
60
  return {
43
61
  onEvent: () => () => {},
44
62
  subscribe: async () => {},
45
63
  sessionGet: async () => session,
46
64
  sessionList: async () => [session],
65
+ sessionAnnotate: unimplemented("sessionAnnotate"),
66
+ sessionRemoveAnnotation: unimplemented("sessionRemoveAnnotation"),
47
67
  sessionSetWorkingCopy: mock(async (_id: string, content: string | undefined) => {
48
68
  sink.workingCopy = content;
49
69
 
50
70
  return { ...session, workingCopy: content };
51
71
  }),
72
+ // the daemon's block primitives, stood in for with the same pure helpers it uses
73
+ sessionCutBlock: mock(async (_id: string, blockIndex: number) => {
74
+ const working = session.workingCopy ?? session.artifact.content;
75
+ const content = cutBlock(working, parseBlocks(working)[blockIndex]!);
76
+
77
+ sink.workingCopy = content;
78
+ session = { ...session, workingCopy: content };
79
+
80
+ return session;
81
+ }),
82
+ sessionRestoreBlock: mock(async (_id: string, baseBlockIndex: number, line?: number) => {
83
+ const base = session.artifact.content;
84
+ const working = session.workingCopy ?? base;
85
+ const content = restoreBlock(
86
+ base,
87
+ working,
88
+ parseBlocks(base)[baseBlockIndex]!,
89
+ line ?? working.split("\n").length,
90
+ );
91
+
92
+ sink.workingCopy = content;
93
+ session = { ...session, workingCopy: content };
94
+ if (content === undefined) delete session.workingCopy;
95
+
96
+ return session;
97
+ }),
98
+ // the daemon's curation, stood in for: the record carries the decisions and the patch they leave
99
+ sessionCurate: mock(async (_id: string, rejections: HunkRejection[]) => {
100
+ const content = rejections.length
101
+ ? curateDiff(session.artifact.files!, rejections)
102
+ : undefined;
103
+
104
+ sink.workingCopy = content;
105
+ session = { ...session, workingCopy: content };
106
+ if (rejections.length) session.curation = rejections;
107
+ else delete session.curation;
108
+
109
+ return session;
110
+ }),
111
+ sessionNavigate: unimplemented("sessionNavigate"),
112
+ sessionBranch: unimplemented("sessionBranch"),
113
+ sessionSwitch: unimplemented("sessionSwitch"),
114
+ sessionLabel: unimplemented("sessionLabel"),
115
+ sessionFork: unimplemented("sessionFork"),
116
+ sessionSetViewed: unimplemented("sessionSetViewed"),
117
+ sessionSetShareId: unimplemented("sessionSetShareId"),
118
+ sessionMergeShared: unimplemented("sessionMergeShared"),
119
+ sessionDelete: unimplemented("sessionDelete"),
120
+ sessionSetSelfName: unimplemented("sessionSetSelfName"),
121
+ sessionResolve: unimplemented("sessionResolve"),
52
122
  close: () => {},
53
- } as unknown as SessionClient;
123
+ };
54
124
  }
55
125
 
56
126
  const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
57
127
 
58
128
  async function connected(session: ReviewSession) {
59
- const sink: { workingCopy?: string } = {};
129
+ const sink: WorkingCopySink = {};
60
130
  const client = fakeClient(session, sink);
61
131
  const controller = createReviewController({
62
132
  sessionId: session.id,
@@ -196,6 +266,23 @@ describe("diff hunk curation", () => {
196
266
  expect(controller.curationItems().length).toBe(1);
197
267
  });
198
268
 
269
+ test("a second decision before the daemon answers builds on the first", async () => {
270
+ // Arrange
271
+ const { controller, client } = await connected(diffSession(FILES));
272
+
273
+ // Act - reject, then restore, with no answer in between
274
+ controller.toggleRejectChange(4);
275
+ controller.toggleRejectChange(4);
276
+ await tick();
277
+
278
+ // Assert - the second request restores; a stale snapshot would reject again
279
+ expect(client.sessionCurate).toHaveBeenCalledTimes(2);
280
+ expect(client.sessionCurate).toHaveBeenNthCalledWith(1, "ses_diff", [
281
+ { path: "src/store.ts", hunkIndex: 0, changeIndex: 1 },
282
+ ]);
283
+ expect(client.sessionCurate).toHaveBeenNthCalledWith(2, "ses_diff", []);
284
+ });
285
+
199
286
  test("curation is disabled without full file contents", async () => {
200
287
  // Arrange - a legacy diff with no artifact.files
201
288
  const { controller, sink } = await connected(diffSession(undefined));
@@ -257,4 +344,19 @@ describe("plan cut removals", () => {
257
344
  expect(controller.getSnapshot().status).toContain("removal restored");
258
345
  expect(sink.workingCopy).toBeUndefined();
259
346
  });
347
+
348
+ test("a second cut before the daemon answers indexes the already-cut copy", async () => {
349
+ // Arrange - pristine plan: title(0), first(1), second(2)
350
+ const { controller, client, sink } = await connected(planSession());
351
+
352
+ // Act - cut the first paragraph, then the second, with no answer in between
353
+ controller.cut(1);
354
+ controller.cut(2);
355
+ await tick();
356
+
357
+ // Assert - the second paragraph is block 1 once the first is gone
358
+ expect(client.sessionCutBlock).toHaveBeenNthCalledWith(1, "ses_plan", 1);
359
+ expect(client.sessionCutBlock).toHaveBeenNthCalledWith(2, "ses_plan", 1);
360
+ expect(sink.workingCopy).toBe("# Title\n");
361
+ });
260
362
  });