@cueloop/client 0.1.0-alpha.59 → 0.1.0-alpha.60

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 (98) hide show
  1. package/package.json +5 -5
  2. package/src/App.autoclose.test.tsx +7 -0
  3. package/src/App.diff.test.tsx +11 -0
  4. package/src/App.export.test.tsx +5 -0
  5. package/src/App.plan-review.test.tsx +42 -0
  6. package/src/App.prototype.test.tsx +11 -0
  7. package/src/App.readonly.test.tsx +10 -0
  8. package/src/App.rendering.test.tsx +4 -0
  9. package/src/App.review-panel.test.tsx +7 -0
  10. package/src/App.submit-confirm.test.tsx +11 -0
  11. package/src/App.test.tsx +13 -0
  12. package/src/App.toast.test.tsx +2 -0
  13. package/src/App.tsx +27 -0
  14. package/src/App.walk.test.tsx +10 -0
  15. package/src/app-key-state.ts +1 -0
  16. package/src/app-screens.tsx +4 -0
  17. package/src/app-view-model.ts +21 -0
  18. package/src/attribution.ts +4 -0
  19. package/src/clipboard.ts +3 -0
  20. package/src/components/AnnotationCard.tsx +9 -0
  21. package/src/components/Breadcrumb.tsx +1 -0
  22. package/src/components/CodeBlock.tsx +1 -0
  23. package/src/components/CompletionOverlay.tsx +1 -0
  24. package/src/components/ConfirmCard.tsx +2 -0
  25. package/src/components/ConfirmDialog.tsx +2 -0
  26. package/src/components/DiffSheet.scroll.test.tsx +12 -0
  27. package/src/components/DiffSheet.syntax.test.tsx +3 -0
  28. package/src/components/DiffSheet.tsx +19 -0
  29. package/src/components/InboxList.tsx +2 -0
  30. package/src/components/KeybindsDialog.tsx +1 -0
  31. package/src/components/MarkerPopover.tsx +1 -0
  32. package/src/components/MenuBar.tsx +1 -0
  33. package/src/components/PlanSheet.tsx +26 -0
  34. package/src/components/PromptDialog.tsx +2 -0
  35. package/src/components/PrototypeSheet.tsx +27 -0
  36. package/src/components/ReviewPanel.tsx +2 -0
  37. package/src/components/ReviewRail.tsx +1 -0
  38. package/src/components/SettingsDialog.tsx +6 -0
  39. package/src/components/SettingsRows.tsx +4 -0
  40. package/src/components/Toast.tsx +1 -0
  41. package/src/components/agent-launcher.test.tsx +1 -0
  42. package/src/components/agent-launcher.tsx +2 -0
  43. package/src/components/diff-sheet-layout.test.ts +1 -0
  44. package/src/components/diff-sheet-layout.ts +17 -0
  45. package/src/components/plan-sheet-run-style.ts +4 -0
  46. package/src/components/primitives/Button.tsx +1 -0
  47. package/src/components/primitives/Card.tsx +1 -0
  48. package/src/components/primitives/Dialog.tsx +2 -0
  49. package/src/components/primitives/StatusBar.tsx +1 -0
  50. package/src/components/primitives/Tabs.tsx +4 -0
  51. package/src/components/quick-actions-editor.tsx +2 -0
  52. package/src/components/stories-app.tsx +1 -0
  53. package/src/components/stories.test.tsx +4 -0
  54. package/src/components/story.ts +6 -0
  55. package/src/components/syntax-highlight.ts +10 -0
  56. package/src/components/terminal-pane.ts +12 -0
  57. package/src/components/theme-context.tsx +1 -0
  58. package/src/components/truncate-text.ts +1 -0
  59. package/src/config.test.ts +18 -0
  60. package/src/config.ts +27 -0
  61. package/src/diff-hunk-curate.ts +20 -0
  62. package/src/diff-intraline.test.ts +1 -0
  63. package/src/diff-intraline.ts +14 -0
  64. package/src/diff-syntax.ts +22 -0
  65. package/src/editor.test.ts +6 -0
  66. package/src/editor.ts +12 -0
  67. package/src/ghostty-terminal.test.ts +2 -0
  68. package/src/ghostty-terminal.ts +8 -0
  69. package/src/herdr.test.ts +2 -0
  70. package/src/herdr.ts +3 -0
  71. package/src/integrations.ts +2 -0
  72. package/src/intent-dispatch.test.ts +6 -0
  73. package/src/intent-dispatch.ts +37 -0
  74. package/src/key-bindings.test.ts +4 -0
  75. package/src/key-bindings.ts +22 -0
  76. package/src/keymap.test.ts +13 -0
  77. package/src/keymap.ts +43 -0
  78. package/src/kitty-image.ts +9 -0
  79. package/src/prototype-browser.test.ts +1 -0
  80. package/src/prototype-browser.ts +22 -0
  81. package/src/pty.test.ts +3 -0
  82. package/src/pty.ts +15 -0
  83. package/src/run.ts +1 -0
  84. package/src/serve.test.ts +1 -0
  85. package/src/serve.ts +3 -0
  86. package/src/session-controller.curate.test.ts +7 -0
  87. package/src/session-controller.share.test.ts +11 -0
  88. package/src/session-controller.ts +79 -0
  89. package/src/share.ts +7 -0
  90. package/src/test-support.test.ts +5 -0
  91. package/src/test-support.ts +10 -0
  92. package/src/theme-presets.ts +1 -0
  93. package/src/use-settings-dialog.tsx +12 -0
  94. package/src/view-diff.test.ts +3 -0
  95. package/src/view-diff.ts +5 -0
  96. package/src/view-plan.test.ts +7 -0
  97. package/src/view-plan.ts +59 -0
  98. package/src/walk.ts +4 -0
@@ -161,6 +161,7 @@ class HeadlessKeymapHost implements KeymapHost<object, KeymapEvent> {
161
161
  }
162
162
  onKeyPress(listener: (event: KeymapEvent) => void): () => void {
163
163
  this.pressListeners.add(listener);
164
+
164
165
  return () => this.pressListeners.delete(listener);
165
166
  }
166
167
  onKeyRelease(): () => void {
@@ -193,14 +194,17 @@ function normalizedEvent(key: ResolvableKey): { name: string; shift: boolean } {
193
194
  shift: key.shift || key.name !== key.name.toLowerCase(),
194
195
  };
195
196
  }
197
+
196
198
  return { name: key.name, shift: false };
197
199
  }
200
+
198
201
  return { name: key.name, shift: key.shift };
199
202
  }
200
203
 
201
204
  /** Config combos write shifted letters as uppercase; the parser wants shift+. */
202
205
  function bindingKeyFor(combo: string): string {
203
206
  if (combo.length === 1 && /[A-Z]/.test(combo)) return `shift+${combo.toLowerCase()}`;
207
+
204
208
  return combo;
205
209
  }
206
210
 
@@ -242,6 +246,7 @@ export class KeyBindings {
242
246
  this.unregisterGrammarLayer?.();
243
247
  const bindings: { key: string; cmd: string }[] = [];
244
248
  const commandNames = new Set<string>();
249
+
245
250
  for (const [action, combos] of Object.entries(keys)) {
246
251
  commandNames.add(action);
247
252
  for (const combo of combos) bindings.push({ key: bindingKeyFor(combo), cmd: action });
@@ -257,6 +262,7 @@ export class KeyBindings {
257
262
  name,
258
263
  run: () => {
259
264
  this.resolvedCommand = name;
265
+
260
266
  return true;
261
267
  },
262
268
  })),
@@ -268,9 +274,11 @@ export class KeyBindings {
268
274
  name,
269
275
  run: () => {
270
276
  this.resolvedCommand = name;
277
+
271
278
  return true;
272
279
  },
273
280
  });
281
+
274
282
  this.keymap.registerLayer({
275
283
  priority: 10,
276
284
  when: () => this.context.overlay === "none" && this.context.spanMode,
@@ -355,8 +363,10 @@ export class KeyBindings {
355
363
  */
356
364
  resolveAction(key: ResolvableKey): string | undefined {
357
365
  const normalized = normalizedEvent(key);
366
+
358
367
  this.resolvedCommand = undefined;
359
368
  this.host.emitKeyPress(makeKeymapEvent(normalized.name, normalized.shift));
369
+
360
370
  return this.resolvedCommand;
361
371
  }
362
372
 
@@ -367,15 +377,19 @@ export class KeyBindings {
367
377
  typeof activeKey.command === "string"
368
378
  ? activeKey.command
369
379
  : activeKey.bindings?.[0]?.command;
380
+
370
381
  if (bindingCommand !== command) continue;
382
+
371
383
  return KEY_GLYPHS[activeKey.stroke.name] ?? activeKey.display;
372
384
  }
385
+
373
386
  return null;
374
387
  }
375
388
 
376
389
  /** The status-line hint for a view state, generated from the active keys. */
377
390
  statusHint(mode: HintMode): string {
378
391
  const fragments: string[] = [];
392
+
379
393
  for (const entry of HINT_TEMPLATES[mode]) {
380
394
  if ("text" in entry) {
381
395
  fragments.push(entry.text);
@@ -384,14 +398,17 @@ export class KeyBindings {
384
398
  const displays = entry.commands
385
399
  .map((command) => this.keyDisplayFor(command))
386
400
  .filter((display): display is string => display !== null);
401
+
387
402
  if (!displays.length) continue;
388
403
  const keyPart = displays.join("/");
404
+
389
405
  if (!entry.label) fragments.push(keyPart);
390
406
  else
391
407
  fragments.push(
392
408
  entry.labelFirst ? `${entry.label} ${keyPart}` : `${keyPart} ${entry.label}`,
393
409
  );
394
410
  }
411
+
395
412
  return fragments.join(" · ");
396
413
  }
397
414
 
@@ -405,14 +422,17 @@ export class KeyBindings {
405
422
  const build = (context: KeyLayerContext, mode: HintMode, title: string): CheatsheetSection => {
406
423
  this.context = context;
407
424
  const entries: CheatsheetEntry[] = [];
425
+
408
426
  for (const entry of HINT_TEMPLATES[mode]) {
409
427
  if ("text" in entry || !entry.label) continue;
410
428
  const keys = entry.commands
411
429
  .map((command) => this.keyDisplayFor(command))
412
430
  .filter((display): display is string => display !== null)
413
431
  .join(" / ");
432
+
414
433
  if (keys) entries.push({ keys, label: entry.label });
415
434
  }
435
+
416
436
  return { title, entries };
417
437
  };
418
438
  const sections = [
@@ -424,7 +444,9 @@ export class KeyBindings {
424
444
  // so it is listed statically rather than resolved from the active keys.
425
445
  { title: "Agent terminal", entries: [{ keys: AGENT_DETACH_HINT, label: "detach" }] },
426
446
  ];
447
+
427
448
  this.context = saved;
449
+
428
450
  return sections.filter((section) => section.entries.length > 0);
429
451
  }
430
452
  }
@@ -41,6 +41,7 @@ describe("compose overlay", () => {
41
41
  ["j", []],
42
42
  ["q", []],
43
43
  ];
44
+
44
45
  for (const [name, expected] of table) {
45
46
  test(`${name} -> ${JSON.stringify(expected)}`, () => {
46
47
  expect(reduceKey(keyState, key(name))).toEqual(expected);
@@ -69,6 +70,7 @@ describe("submit overlay", () => {
69
70
  ["j", []],
70
71
  ["q", []],
71
72
  ];
73
+
72
74
  for (const [name, expected] of table) {
73
75
  test(`${name} -> ${JSON.stringify(expected)}`, () => {
74
76
  expect(reduceKey(keyState, key(name))).toEqual(expected);
@@ -92,6 +94,7 @@ describe("completion overlay", () => {
92
94
  [counting, "a", [{ type: "optInAutoClose" }]],
93
95
  [counting, "escape", [{ type: "dismissCompletion" }]],
94
96
  ];
97
+
95
98
  for (const [overlayState, name, expected] of table) {
96
99
  test(`${overlayState.overlay} ${name} -> ${JSON.stringify(expected)}`, () => {
97
100
  expect(reduceKey(overlayState, key(name))).toEqual(expected);
@@ -113,6 +116,7 @@ describe("inbox mode", () => {
113
116
  ["c", []],
114
117
  ["escape", []],
115
118
  ];
119
+
116
120
  for (const [name, expected] of table) {
117
121
  test(`${name} -> ${JSON.stringify(expected)}`, () => {
118
122
  expect(reduceKey(keyState, key(name))).toEqual(expected);
@@ -217,6 +221,7 @@ describe("plan normal mode", () => {
217
221
  ["escape", false, [{ type: "deselect" }]],
218
222
  ["z", false, []],
219
223
  ];
224
+
220
225
  for (const [name, shift, expected] of table) {
221
226
  test(`${shift ? "shift+" : ""}${name} -> ${JSON.stringify(expected)}`, () => {
222
227
  expect(reduceKey(keyState, key(name, shift))).toEqual(expected);
@@ -297,6 +302,7 @@ describe("span mode", () => {
297
302
  ["q", [{ type: "exit" }]],
298
303
  ["j", []],
299
304
  ];
305
+
300
306
  for (const [name, expected] of table) {
301
307
  test(`${name} -> ${JSON.stringify(expected)}`, () => {
302
308
  expect(reduceKey(keyState, key(name))).toEqual(expected);
@@ -364,6 +370,7 @@ describe("span actions overlay", () => {
364
370
  ["c", []],
365
371
  ["q", []],
366
372
  ];
373
+
367
374
  for (const [name, expected] of table) {
368
375
  test(`${name} -> ${JSON.stringify(expected)}`, () => {
369
376
  expect(reduceKey(keyState, key(name))).toEqual(expected);
@@ -388,6 +395,7 @@ describe("diff mode", () => {
388
395
  ["e", [{ type: "status", message: "plan-only verb - diff review uses c on a line" }]],
389
396
  ["q", [{ type: "exit" }]],
390
397
  ];
398
+
391
399
  for (const [name, expected] of table) {
392
400
  test(`${name} -> ${JSON.stringify(expected)}`, () => {
393
401
  expect(reduceKey(keyState, key(name))).toEqual(expected);
@@ -466,6 +474,7 @@ describe("guided walk", () => {
466
474
  ["j", []],
467
475
  ["c", []],
468
476
  ];
477
+
469
478
  for (const [name, expected] of table) {
470
479
  test(`walking ${name} -> ${JSON.stringify(expected)}`, () => {
471
480
  expect(reduceKey(walking, key(name))).toEqual(expected);
@@ -491,6 +500,7 @@ describe("review panel controls", () => {
491
500
  ["]", [{ type: "resizeReviewPanel", direction: 1 }]],
492
501
  ["[", [{ type: "resizeReviewPanel", direction: -1 }]],
493
502
  ];
503
+
494
504
  for (const view of ["plan", "diff"] as const) {
495
505
  for (const [name, expected] of table) {
496
506
  test(`${view} ${name} -> ${JSON.stringify(expected)}`, () => {
@@ -535,6 +545,7 @@ describe("duplicated-branch collapse", () => {
535
545
  for (const name of ["n", "p", "backspace", "return"]) {
536
546
  const plan = reduceKey(state(patch), key(name));
537
547
  const diff = reduceKey(state({ ...patch, view: "diff" }), key(name));
548
+
538
549
  expect(diff).toEqual(plan);
539
550
  }
540
551
  }
@@ -545,6 +556,7 @@ describe("read-only filter", () => {
545
556
  test("every mutating key answers observer - read-only, in plan and diff", () => {
546
557
  for (const view of ["plan", "diff"] as const) {
547
558
  const keyState = state({ view, readOnly: true });
559
+
548
560
  for (const name of ["c", "x", "e", "backspace", "return", "enter"]) {
549
561
  expect(reduceKey(keyState, key(name))).toEqual([
550
562
  { type: "status", message: "observer - read-only" },
@@ -594,6 +606,7 @@ describe("confirm overlay", () => {
594
606
  ["j", []],
595
607
  ["d", []],
596
608
  ];
609
+
597
610
  for (const [name, expected] of table) {
598
611
  test(`${name} -> ${JSON.stringify(expected)}`, () => {
599
612
  expect(reduceKey(keyState, key(name))).toEqual(expected);
package/src/keymap.ts CHANGED
@@ -123,19 +123,23 @@ function status(message: string): Intent[] {
123
123
  export function reduceKey(state: KeyState, key: KeyInput, resolvedAction?: string): Intent[] {
124
124
  const name = key.name;
125
125
  const overlayGrammar = overlayGrammars[state.overlay];
126
+
126
127
  if (overlayGrammar) return overlayGrammar(state, key);
127
128
  const action = resolvedAction ?? actionFor(state.keys, name, key.shift);
129
+
128
130
  if (action === "quit") return [{ type: "exit" }];
129
131
  // the ONE read-only rule: any mutating attempt answers instead of acting
130
132
  // (span-mode c, x, and a are hardwired keys, so they gate by name as well)
131
133
  const mutating =
132
134
  MUTATING_ACTIONS.has(action ?? "") ||
133
135
  (state.spanMode && (name === "c" || name === "x" || name === "a"));
136
+
134
137
  if (state.readOnly && mutating) return status("observer - read-only");
135
138
 
136
139
  // share is a session-level verb: it works from any view, owner only
137
140
  if (action === "share") {
138
141
  if (state.canShare === false) return status("only the plan owner can share");
142
+
139
143
  return [{ type: "share" }];
140
144
  }
141
145
 
@@ -146,8 +150,10 @@ export function reduceKey(state: KeyState, key: KeyInput, resolvedAction?: strin
146
150
  // the review panel rides both plan and diff reviews; collapsing and resizing
147
151
  // are view state, so the read-only gate above lets them through
148
152
  const reviewPanel = reviewPanelGrammar(action);
153
+
149
154
  if (reviewPanel) return reviewPanel;
150
155
  if (state.view === "diff") return diffGrammar(state, action);
156
+
151
157
  return planGrammar(state, action, name);
152
158
  }
153
159
 
@@ -166,60 +172,74 @@ const overlayGrammars: Partial<Record<KeyState["overlay"], OverlayGrammar>> = {
166
172
 
167
173
  function composeOverlayGrammar(state: KeyState, key: KeyInput): Intent[] {
168
174
  const name = key.name;
175
+
169
176
  if (name === "escape") return [{ type: "closeOverlay" }];
170
177
  if (name === "return" || name === "enter") {
171
178
  // In the composer, ⌥/Alt+⏎ (meta) and shift+⏎ insert a newline - the
172
179
  // focused textarea owns that; only a bare ⏎ saves. The submit overlay
173
180
  // keeps its plain ⏎ submit.
174
181
  if (key.shift || key.meta) return [];
182
+
175
183
  return [{ type: "saveCompose" }];
176
184
  }
185
+
177
186
  return [];
178
187
  }
179
188
 
180
189
  function submitOverlayGrammar(state: KeyState, key: KeyInput): Intent[] {
181
190
  const name = key.name;
191
+
182
192
  if (name === "escape") return [{ type: "closeOverlay" }];
183
193
  if (name === "return" || name === "enter") return [{ type: "submitVerdict" }];
184
194
  if (name === "left" || name === "right") {
185
195
  return [{ type: "cycleVerdict", direction: name === "left" ? -1 : 1 }];
186
196
  }
197
+
187
198
  return [];
188
199
  }
189
200
 
190
201
  function confirmOverlayGrammar(state: KeyState, key: KeyInput): Intent[] {
191
202
  const name = key.name;
203
+
192
204
  if (name === "return" || name === "enter") return [{ type: "confirmDialog" }];
193
205
  if (name === "escape") return [{ type: "closeOverlay" }];
206
+
194
207
  return [];
195
208
  }
196
209
 
197
210
  function promptOverlayGrammar(state: KeyState, key: KeyInput): Intent[] {
198
211
  const name = key.name;
212
+
199
213
  if (name === "return" || name === "enter") return [{ type: "confirmDialog" }];
200
214
  if (name === "escape") return [{ type: "closeOverlay" }];
215
+
201
216
  return [];
202
217
  }
203
218
 
204
219
  function spanActionsOverlayGrammar(state: KeyState, key: KeyInput): Intent[] {
205
220
  const name = key.name;
221
+
206
222
  if (name === "j" || name === "down") return [{ type: "moveSpanAction", direction: 1 }];
207
223
  if (name === "k" || name === "up") return [{ type: "moveSpanAction", direction: -1 }];
208
224
  if (name === "return" || name === "enter") return [{ type: "pickSpanAction" }];
209
225
  if (name === "escape") return [{ type: "closeSpanActions" }];
226
+
210
227
  return [];
211
228
  }
212
229
 
213
230
  function completionOverlayGrammar(state: KeyState, key: KeyInput): Intent[] {
214
231
  const name = key.name;
232
+
215
233
  if (name === "return" || name === "enter" || name === "q") return [{ type: "finishReview" }];
216
234
  if (name === "a") return [{ type: "optInAutoClose" }];
217
235
  if (name === "escape") return [{ type: "dismissCompletion" }];
236
+
218
237
  return [];
219
238
  }
220
239
 
221
240
  function walkOverlayGrammar(state: KeyState, key: KeyInput): Intent[] {
222
241
  const name = key.name;
242
+
223
243
  if (name === "]") return [{ type: "walkForward" }];
224
244
  if (name === "[") return [{ type: "walkBack" }];
225
245
  if (name === "escape") return [{ type: "walkLeave" }];
@@ -227,6 +247,7 @@ function walkOverlayGrammar(state: KeyState, key: KeyInput): Intent[] {
227
247
  return [{ type: "walkLeave" }, { type: "openSubmit" }];
228
248
  }
229
249
  if (name === "q") return [{ type: "exit" }];
250
+
230
251
  return [];
231
252
  }
232
253
 
@@ -235,6 +256,7 @@ function reviewPanelGrammar(action: string | undefined): Intent[] | null {
235
256
  if (action === "review_cycle") return [{ type: "cycleReviewPanel" }];
236
257
  if (action === "review_wider") return [{ type: "resizeReviewPanel", direction: 1 }];
237
258
  if (action === "review_narrower") return [{ type: "resizeReviewPanel", direction: -1 }];
259
+
238
260
  return null;
239
261
  }
240
262
 
@@ -244,20 +266,24 @@ function inboxGrammar(state: KeyState, name: string): Intent[] {
244
266
  if (name === "k" || name === "up") return [{ type: "inboxMove", to: "up" }];
245
267
  if (name === "return" || name === "enter") return [{ type: "openSession" }];
246
268
  if (name === "d") return [{ type: "requestDeleteSession" }];
269
+
247
270
  return [];
248
271
  }
249
272
 
250
273
  function diffGrammar(state: KeyState, action: string | undefined): Intent[] {
251
274
  const navigation = navigationIntent(action);
275
+
252
276
  if (navigation) return navigation;
253
277
  if (action === "walk") {
254
278
  // marking viewed writes the session record, so a resolved review answers
255
279
  if (state.resolved) return status("review submitted - read-only");
280
+
256
281
  return [{ type: "walkStart" }];
257
282
  }
258
283
  if (action === "comment") {
259
284
  if (state.resolved) return status("review submitted - read-only");
260
285
  if (!state.cursorAnnotatable) return status("move to a code line to comment");
286
+
261
287
  return [{ type: "openCompose", kind: "comment", from: "cursor" }];
262
288
  }
263
289
  // cut rejects the change under the cursor, reject_hunk the whole hunk; both
@@ -265,19 +291,23 @@ function diffGrammar(state: KeyState, action: string | undefined): Intent[] {
265
291
  if (action === "cut" || action === "reject_hunk") {
266
292
  if (state.resolved) return status("review submitted - read-only");
267
293
  if (state.canEditPlan === false) return status("only the diff owner can curate hunks");
294
+
268
295
  return [{ type: action === "reject_hunk" ? "rejectHunk" : "rejectChange" }];
269
296
  }
270
297
  // restore un-does a curated-out rejection from the rail; same owner gate as reject
271
298
  if (action === "restore_curation") {
272
299
  if (state.resolved) return status("review submitted - read-only");
273
300
  if (state.canEditPlan === false) return status("only the diff owner can curate hunks");
301
+
274
302
  return [{ type: "restoreCuration" }];
275
303
  }
276
304
  const shared = annotationCluster(state, action);
305
+
277
306
  if (shared) return shared;
278
307
  if (action === "span" || action === "edit") {
279
308
  return status("plan-only verb - diff review uses c on a line");
280
309
  }
310
+
281
311
  return [];
282
312
  }
283
313
 
@@ -293,13 +323,16 @@ function spanGrammar(state: KeyState, name: string): Intent[] {
293
323
  // partial-span cut is not in the working-copy model, so cut removes the
294
324
  // whole block the span sits in; owner-only, like plan cut
295
325
  if (state.canEditPlan === false) return [];
326
+
296
327
  return [{ type: "spanCut" }];
297
328
  }
329
+
298
330
  return [];
299
331
  }
300
332
 
301
333
  function planGrammar(state: KeyState, action: string | undefined, name: string): Intent[] {
302
334
  const navigation = navigationIntent(action);
335
+
303
336
  if (navigation) return navigation;
304
337
  if (action === "walk") return status("the guided walk is a diff-review mode");
305
338
  if (name === "escape") return [{ type: "deselect" }];
@@ -307,12 +340,14 @@ function planGrammar(state: KeyState, action: string | undefined, name: string):
307
340
  if (action === "comment") {
308
341
  if (state.resolved) return status("review submitted - read-only");
309
342
  if (!state.cursorAnnotatable) return status("text is cut - restore it first");
343
+
310
344
  return [{ type: "openCompose", kind: "comment", from: "cursor" }];
311
345
  }
312
346
  // restore un-does a rail removal (a cut block); a plan edit, so owner-only
313
347
  if (action === "restore_curation") {
314
348
  if (state.resolved) return status("review submitted - read-only");
315
349
  if (state.canEditPlan === false) return [];
350
+
316
351
  return [{ type: "restoreCuration" }];
317
352
  }
318
353
  if (action === "cut" || action === "edit") {
@@ -324,8 +359,10 @@ function planGrammar(state: KeyState, action: string | undefined, name: string):
324
359
  // editing the plan itself is the owner's verb; a share viewer only annotates,
325
360
  // and has no edit affordance, so the key is silent rather than a nag
326
361
  if (state.canEditPlan === false) return [];
362
+
327
363
  return [{ type: action }];
328
364
  }
365
+
329
366
  return annotationCluster(state, action) ?? [];
330
367
  }
331
368
 
@@ -334,6 +371,7 @@ function navigationIntent(action: string | undefined): Intent[] | null {
334
371
  if (action === "down" || action === "up" || action === "top" || action === "bottom") {
335
372
  return [{ type: "move", to: action }];
336
373
  }
374
+
337
375
  return null;
338
376
  }
339
377
 
@@ -341,21 +379,26 @@ function navigationIntent(action: string | undefined): Intent[] | null {
341
379
  function annotationCluster(state: KeyState, action: string | undefined): Intent[] | null {
342
380
  if (action === "next_annotation" || action === "prev_annotation") {
343
381
  if (!state.annotationCount) return status("no annotations");
382
+
344
383
  return [{ type: action === "next_annotation" ? "nextAnnotation" : "prevAnnotation" }];
345
384
  }
346
385
  if (action === "delete_annotation") {
347
386
  if (state.resolved || !state.hasFocusedAnnotation) return [];
387
+
348
388
  return [{ type: "removeAnnotation" }];
349
389
  }
350
390
  if (action === "rename") {
351
391
  if (!state.hasFocusedAnnotation) return status("select a collaborator's note to rename them");
392
+
352
393
  return [{ type: "openRename" }];
353
394
  }
354
395
  if (action === "submit") {
355
396
  // a collaborator's notes union back as they go; there is no verdict to submit
356
397
  if (state.canSubmitVerdict === false)
357
398
  return status("shared view - your notes save as you go; q to leave");
399
+
358
400
  return state.resolved ? [] : [{ type: "openSubmit" }];
359
401
  }
402
+
360
403
  return null;
361
404
  }
@@ -49,12 +49,14 @@ let privateTempDir: string | null = null;
49
49
  */
50
50
  export function resolveTransmitMedium(env: NodeJS.ProcessEnv = process.env): TransmitMedium {
51
51
  if (env.CUELOOP_KITTY_FILE === "1" && !env.SSH_CONNECTION && !env.SSH_TTY) return "file";
52
+
52
53
  return "base64";
53
54
  }
54
55
 
55
56
  /** Save the cursor, run the placement at the region's corner, restore it. */
56
57
  function atRegion(region: CellRegion, body: string): string {
57
58
  const moveToCorner = `\x1b[${region.row + 1};${region.column + 1}H`;
59
+
58
60
  return `\x1b7${moveToCorner}${body}\x1b8`;
59
61
  }
60
62
 
@@ -71,7 +73,9 @@ function placementKeys(imageId: number, region: CellRegion): string {
71
73
  function writeTempPng(png: Uint8Array): string {
72
74
  if (!privateTempDir) privateTempDir = mkdtempSync(join(tmpdir(), "cueloop-prototype-"));
73
75
  const path = join(privateTempDir, `${tempFileCounter++}.png`);
76
+
74
77
  writeFileSync(path, png, { flag: "wx" });
78
+
75
79
  return path;
76
80
  }
77
81
 
@@ -79,20 +83,24 @@ function base64Transmit(png: Uint8Array, region: CellRegion, imageId: number): s
79
83
  const base64 = Buffer.from(png).toString("base64");
80
84
  const controlKeys = `a=T,f=100,${placementKeys(imageId, region)}`;
81
85
  let sequence = "";
86
+
82
87
  for (let offset = 0; offset < base64.length; offset += CHUNK_BYTES) {
83
88
  const chunk = base64.slice(offset, offset + CHUNK_BYTES);
84
89
  const isMore = offset + CHUNK_BYTES < base64.length ? 1 : 0;
90
+
85
91
  sequence +=
86
92
  offset === 0
87
93
  ? graphicsCommand(`${controlKeys},m=${isMore}`, chunk)
88
94
  : graphicsCommand(`m=${isMore}`, chunk);
89
95
  }
96
+
90
97
  return sequence;
91
98
  }
92
99
 
93
100
  function fileTransmit(png: Uint8Array, region: CellRegion, imageId: number): string {
94
101
  const path = writeTempPng(png);
95
102
  const payload = Buffer.from(path).toString("base64");
103
+
96
104
  return graphicsCommand(`a=T,f=100,t=t,${placementKeys(imageId, region)}`, payload);
97
105
  }
98
106
 
@@ -114,6 +122,7 @@ export function transmitKittyImage(
114
122
  if (region.columns < 1 || region.rows < 1) return;
115
123
  const body =
116
124
  medium === "file" ? fileTransmit(png, region, imageId) : base64Transmit(png, region, imageId);
125
+
117
126
  write(SYNCHRONIZED_UPDATE_START + atRegion(region, body) + SYNCHRONIZED_UPDATE_END);
118
127
  }
119
128
 
@@ -49,6 +49,7 @@ describe("cssBoxToCell", () => {
49
49
  test("a zero-size rect still reserves at least one cell", () => {
50
50
  // Assert
51
51
  const cell = cssBoxToCell({ x: 0, y: 0, width: 0, height: 0 }, IMAGE, VIEWPORT);
52
+
52
53
  expect(cell.columns).toBe(1);
53
54
  expect(cell.rows).toBe(1);
54
55
  });
@@ -52,6 +52,7 @@ export function imageCellToCss(
52
52
  ): { x: number; y: number } | null {
53
53
  const columnInImage = event.x - image.x;
54
54
  const rowInImage = event.y - image.y;
55
+
55
56
  if (
56
57
  columnInImage < 0 ||
57
58
  rowInImage < 0 ||
@@ -60,6 +61,7 @@ export function imageCellToCss(
60
61
  ) {
61
62
  return null;
62
63
  }
64
+
63
65
  return {
64
66
  x: ((columnInImage + 0.5) / image.width) * viewport.width,
65
67
  y: ((rowInImage + 0.5) / image.height) * viewport.height,
@@ -74,6 +76,7 @@ export function cssBoxToCell(
74
76
  ): { column: number; row: number; columns: number; rows: number } {
75
77
  const scaleColumn = image.width / viewport.width;
76
78
  const scaleRow = image.height / viewport.height;
79
+
77
80
  return {
78
81
  column: image.x + Math.floor(box.x * scaleColumn),
79
82
  row: image.y + Math.floor(box.y * scaleRow),
@@ -107,6 +110,7 @@ async function warmBrowser(executablePath: string | undefined): Promise<Puppetee
107
110
  // disconnected Chromium is dropped so the next open relaunches instead of
108
111
  // calling newPage on a dead process forever
109
112
  const cached = sharedBrowser ? await sharedBrowser.catch(() => null) : null;
113
+
110
114
  if (cached && cached.connected) return cached;
111
115
  const puppeteer = (await import("puppeteer-core")).default;
112
116
  const launched = puppeteer.launch({
@@ -114,14 +118,17 @@ async function warmBrowser(executablePath: string | undefined): Promise<Puppetee
114
118
  headless: true,
115
119
  args: ["--no-sandbox", "--hide-scrollbars", "--force-color-profile=srgb"],
116
120
  });
121
+
117
122
  sharedBrowser = launched;
118
123
  const browser = await launched.catch((error) => {
119
124
  if (sharedBrowser === launched) sharedBrowser = null;
120
125
  throw error;
121
126
  });
127
+
122
128
  browser.once("disconnected", () => {
123
129
  if (sharedBrowser === launched) sharedBrowser = null;
124
130
  });
131
+
125
132
  return browser;
126
133
  }
127
134
 
@@ -131,6 +138,7 @@ export async function launchPrototypeRenderer(options: LaunchOptions): Promise<P
131
138
  // close only the page on teardown, never the shared browser, so the next open
132
139
  // reuses the warm Chromium
133
140
  const page = await browser.newPage();
141
+
134
142
  try {
135
143
  await page.setViewport({
136
144
  width: options.viewport.width,
@@ -165,6 +173,7 @@ export async function launchPrototypeRenderer(options: LaunchOptions): Promise<P
165
173
  encoding: "binary",
166
174
  omitBackground: true,
167
175
  });
176
+
168
177
  return new Uint8Array(buffer as Buffer);
169
178
  },
170
179
  async elementAt(cssX, cssY) {
@@ -198,6 +207,7 @@ function elementAtScript(x: number, y: number): unknown {
198
207
  const componentRoot = (start: Element): Element => {
199
208
  let namedFallback: Element | null = null;
200
209
  let current: Element | null = start;
210
+
201
211
  while (current && current !== document.body) {
202
212
  if (isNamed(current)) {
203
213
  if (current.childElementCount > 1) return current;
@@ -205,28 +215,35 @@ function elementAtScript(x: number, y: number): unknown {
205
215
  }
206
216
  current = current.parentElement;
207
217
  }
218
+
208
219
  return namedFallback ?? start;
209
220
  };
210
221
  const selectorFor = (start: Element): string => {
211
222
  const parts: string[] = [];
212
223
  let current: Element | null = start;
224
+
213
225
  while (current && current.nodeType === 1 && current !== document.documentElement) {
214
226
  let part = current.tagName.toLowerCase();
227
+
215
228
  if (current.id) {
216
229
  parts.unshift(part + "#" + CSS.escape(current.id));
217
230
  break;
218
231
  }
219
232
  const parent: Element | null = current.parentElement;
233
+
220
234
  if (parent) {
221
235
  const twins = [...parent.children].filter((child) => child.tagName === current!.tagName);
236
+
222
237
  if (twins.length > 1) part += ":nth-of-type(" + (twins.indexOf(current) + 1) + ")";
223
238
  }
224
239
  parts.unshift(part);
225
240
  current = current.parentElement;
226
241
  }
242
+
227
243
  return parts.join(" > ");
228
244
  };
229
245
  const hit = document.elementFromPoint(x, y);
246
+
230
247
  if (!(hit instanceof Element)) return null;
231
248
  // A click on an interactive control anchors to that control - a button in a
232
249
  // grid is the target, not the grid. Everything else climbs to its component.
@@ -235,6 +252,7 @@ function elementAtScript(x: number, y: number): unknown {
235
252
  );
236
253
  const node = control ?? componentRoot(hit);
237
254
  const selector = selectorFor(node);
255
+
238
256
  if (!selector) return null;
239
257
  const text = (node.textContent || "").replace(/\s+/g, " ").trim();
240
258
  const tag = node.tagName.toLowerCase();
@@ -246,12 +264,15 @@ function elementAtScript(x: number, y: number): unknown {
246
264
  ? tag + "." + node.classList[0]
247
265
  : tag;
248
266
  const rect = node.getBoundingClientRect();
267
+
249
268
  return { selector, quote, box: { x: rect.x, y: rect.y, width: rect.width, height: rect.height } };
250
269
  }
251
270
 
252
271
  function scrollByScript(deltaY: number): boolean {
253
272
  const before = window.scrollY;
273
+
254
274
  window.scrollBy(0, deltaY);
275
+
255
276
  return window.scrollY !== before;
256
277
  }
257
278
 
@@ -263,5 +284,6 @@ function chromeExecutable(): string {
263
284
  if (process.platform === "win32") {
264
285
  return "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe";
265
286
  }
287
+
266
288
  return "/usr/bin/google-chrome";
267
289
  }