@cueloop/client 0.1.0-alpha.102 → 0.1.0-alpha.104

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 (126) hide show
  1. package/package.json +5 -5
  2. package/src/annotations/annotation-palette.ts +1 -0
  3. package/src/annotations/components/AnnotationCards.tsx +4 -0
  4. package/src/annotations/components/DiscussionMarkerRail.tsx +3 -0
  5. package/src/annotations/discussions.ts +1 -0
  6. package/src/annotations/thread-selection.ts +6 -0
  7. package/src/annotations/use-annotation-surface.tsx +97 -0
  8. package/src/app/App.deleted-scroll.test.tsx +3 -0
  9. package/src/app/App.harness-settings.test.tsx +77 -0
  10. package/src/app/App.plan-render.test.tsx +2 -0
  11. package/src/app/App.plan-review.test.tsx +1 -0
  12. package/src/app/App.readonly.test.tsx +63 -0
  13. package/src/app/App.test.tsx +1 -0
  14. package/src/app/App.tsx +94 -12
  15. package/src/app/App.visual.test.tsx +1 -0
  16. package/src/app/App.walk.test.tsx +1 -0
  17. package/src/app/App.workbench.test.tsx +1 -0
  18. package/src/app/app-screens.tsx +3 -2
  19. package/src/app/app-view-model.ts +11 -1
  20. package/src/app/components/WorkspacePanels.tsx +1 -0
  21. package/src/app/intent-dispatch.test.ts +1 -0
  22. package/src/app/intent-dispatch.ts +26 -0
  23. package/src/app/ready-signal.ts +2 -0
  24. package/src/app/run.ts +2 -0
  25. package/src/app/serve.test.ts +4 -0
  26. package/src/diff/components/AnnotatableFileView.tsx +1 -0
  27. package/src/diff/components/DiffContentView.scroll.test.tsx +9 -0
  28. package/src/diff/components/DiffContentView.syntax.test.tsx +1 -0
  29. package/src/diff/components/DiffContentView.tsx +25 -0
  30. package/src/diff/components/diff-content-view-layout.ts +3 -0
  31. package/src/diff/diff-fold.ts +4 -0
  32. package/src/diff/diff-hunk-curate.ts +3 -0
  33. package/src/diff/diff-intraline.ts +6 -0
  34. package/src/diff/diff-syntax.ts +8 -0
  35. package/src/diff/split-diff.ts +6 -0
  36. package/src/diff/view-diff.ts +18 -0
  37. package/src/diff/walk.ts +3 -0
  38. package/src/index.ts +1 -0
  39. package/src/integrations/attribution.ts +2 -0
  40. package/src/integrations/client-extension-registry.ts +4 -0
  41. package/src/integrations/clipboard.ts +1 -0
  42. package/src/integrations/components/ShareDialog.agent.test.tsx +2 -0
  43. package/src/integrations/components/ShareDialog.tsx +28 -0
  44. package/src/integrations/editor.ts +3 -0
  45. package/src/integrations/github-identity.test.ts +1 -0
  46. package/src/integrations/github-identity.ts +1 -0
  47. package/src/integrations/herdr.ts +2 -0
  48. package/src/integrations/pasted-image.ts +2 -0
  49. package/src/integrations/share.ts +46 -1
  50. package/src/keyboard/components/HandleChipsInput.tsx +4 -0
  51. package/src/keyboard/components/SlashComposer.tsx +3 -0
  52. package/src/keyboard/key-bindings.ts +3 -0
  53. package/src/keyboard/keymap.ts +51 -0
  54. package/src/keyboard/skills.ts +2 -0
  55. package/src/keyboard/slash-palette.ts +8 -0
  56. package/src/keyboard/thread-chords.ts +5 -0
  57. package/src/keyboard/use-shared-keyboard.ts +1 -0
  58. package/src/markdown/components/MarkdownThreadEditor.tsx +3 -0
  59. package/src/markdown/components/ThreadView.selection.test.tsx +3 -0
  60. package/src/markdown/components/ThreadView.tsx +13 -0
  61. package/src/markdown/mark-runs.ts +4 -0
  62. package/src/markdown/markdown-editor-syntax-style.ts +1 -0
  63. package/src/markdown/markdown-grid.ts +6 -0
  64. package/src/markdown/markdown-highlight.ts +1 -0
  65. package/src/markdown/thread-code-syntax.ts +3 -0
  66. package/src/markdown/view-plan.ts +52 -0
  67. package/src/performance/perf-report.ts +1 -0
  68. package/src/performance/perf-timings.ts +1 -0
  69. package/src/prototype/components/PrototypeContentView.tsx +17 -0
  70. package/src/prototype/prototype-browser.ts +11 -0
  71. package/src/settings/components/SettingsDialog.tsx +3 -0
  72. package/src/settings/config.test.ts +79 -1
  73. package/src/settings/config.ts +91 -1
  74. package/src/settings/use-settings-dialog.test.tsx +172 -0
  75. package/src/settings/use-settings-dialog.tsx +115 -1
  76. package/src/settings/use-thread-harness-setting.test.tsx +87 -0
  77. package/src/settings/use-thread-harness-setting.ts +86 -0
  78. package/src/stories/stories.test.tsx +4 -0
  79. package/src/stories/story.ts +6 -0
  80. package/src/terminal/ansi-screen.ts +5 -0
  81. package/src/terminal/components/terminal-pane.ts +12 -0
  82. package/src/terminal/ghostty-terminal.ts +10 -0
  83. package/src/terminal/kitty-image.ts +3 -0
  84. package/src/terminal/pty.ts +9 -0
  85. package/src/testing/test-support.test.ts +2 -0
  86. package/src/testing/test-support.ts +16 -0
  87. package/src/thread/agent-continuation.test.ts +2 -0
  88. package/src/thread/agent-transcript.ts +7 -0
  89. package/src/thread/components/AgentConfigControls.test.tsx +41 -0
  90. package/src/thread/components/AgentConfigControls.tsx +8 -1
  91. package/src/thread/components/AgentThreadPane.test.tsx +91 -3
  92. package/src/thread/components/AgentThreadPane.tsx +61 -0
  93. package/src/thread/components/ThreadTree.tsx +1 -0
  94. package/src/thread/components/thread-tree.test.ts +1 -0
  95. package/src/thread/thread-controller.curate.test.ts +2 -0
  96. package/src/thread/thread-controller.share.test.ts +47 -2
  97. package/src/thread/thread-controller.ts +129 -4
  98. package/src/thread/tree-view.ts +4 -0
  99. package/src/thread/use-thread-agent.test.tsx +1 -0
  100. package/src/thread/use-thread-agent.ts +5 -0
  101. package/src/ui/components/OverlayScrollbar.test.tsx +3 -0
  102. package/src/ui/components/OverlayScrollbar.tsx +5 -0
  103. package/src/ui/components/PromptDialog.tsx +2 -0
  104. package/src/ui/components/RootOverlay.tsx +1 -0
  105. package/src/ui/components/ScrollArea.tsx +2 -0
  106. package/src/ui/components/primitives/Button.tsx +1 -0
  107. package/src/ui/components/primitives/Tabs.tsx +1 -0
  108. package/src/ui/components/primitives/Tree.tsx +4 -0
  109. package/src/ui/components/primitives/tree-model.ts +7 -0
  110. package/src/ui/components/syntax-highlight.ts +5 -0
  111. package/src/ui/components/truncate-title.ts +1 -0
  112. package/src/ui/use-frame-measure.ts +3 -0
  113. package/src/ui/use-terminal-virtualizer.ts +6 -0
  114. package/src/workbench/components/ChangesColumn.tsx +7 -0
  115. package/src/workbench/components/ChangesFileTree.test.tsx +3 -0
  116. package/src/workbench/components/EditorGrid.tsx +8 -0
  117. package/src/workbench/components/FileContentsView.tsx +2 -0
  118. package/src/workbench/components/GridTabContent.tsx +2 -0
  119. package/src/workbench/components/ProjectTreeView.test.tsx +3 -0
  120. package/src/workbench/components/ProjectTreeView.tsx +8 -0
  121. package/src/workbench/components/TreePane.tsx +2 -0
  122. package/src/workbench/components/editor-grid.ts +3 -0
  123. package/src/workbench/components/file-tree.ts +2 -0
  124. package/src/workbench/use-changes-workbench.ts +5 -0
  125. package/src/workbench/use-remember-layout.ts +2 -0
  126. package/src/workbench/use-repo-changes.ts +2 -0
@@ -84,6 +84,7 @@ export function splitDiffRows(rows: DiffRow[]): SplitRow[] {
84
84
 
85
85
  const flushChangeBlock = (): void => {
86
86
  if (pendingDeletions.length === 0 && pendingAdditions.length === 0) return;
87
+
87
88
  split.push(...zipChangeBlock(pendingDeletions, pendingAdditions, blockFile));
88
89
  pendingDeletions = [];
89
90
  pendingAdditions = [];
@@ -96,14 +97,17 @@ export function splitDiffRows(rows: DiffRow[]): SplitRow[] {
96
97
 
97
98
  return;
98
99
  }
100
+
99
101
  if (row.kind === "del") {
100
102
  blockFile = row.file;
101
103
  pendingDeletions.push({ row, index });
102
104
 
103
105
  return;
104
106
  }
107
+
105
108
  // any non-change row closes the current change block before it renders
106
109
  flushChangeBlock();
110
+
107
111
  if (row.kind === "file" || row.kind === "hunk") {
108
112
  split.push({ kind: row.kind, file: row.file, text: row.text, rowIndex: index });
109
113
  } else
@@ -135,7 +139,9 @@ export function splitRowOffsets(splitRows: SplitRow[]): Map<number, number> {
135
139
  contentY += 1;
136
140
  } else {
137
141
  if (row.left) offsets.set(row.left.rowIndex, contentY);
142
+
138
143
  if (row.right) offsets.set(row.right.rowIndex, contentY);
144
+
139
145
  contentY += 1;
140
146
  }
141
147
  }
@@ -92,10 +92,12 @@ export function fileChangeCounts(
92
92
 
93
93
  for (const row of rows) {
94
94
  if (row.kind !== "add" && row.kind !== "del") continue;
95
+
95
96
  const entry = counts.get(row.file) ?? { additions: 0, deletions: 0 };
96
97
 
97
98
  if (row.kind === "add") entry.additions += 1;
98
99
  else entry.deletions += 1;
100
+
99
101
  counts.set(row.file, entry);
100
102
  }
101
103
 
@@ -143,9 +145,11 @@ export function marksByRows(
143
145
  for (const annotation of annotations) {
144
146
  // an addressed annotation keeps its record but paints no highlight
145
147
  if (isAddressed(annotation)) continue;
148
+
146
149
  const resolved = resolveAnchor(annotation.anchor, blocks);
147
150
 
148
151
  if (!resolved) continue;
152
+
149
153
  const span: TextSpan = {
150
154
  start: { blockIndex: resolved.blockIndex, char: resolved.start },
151
155
  end: { blockIndex: resolved.endBlockIndex, char: resolved.end },
@@ -156,6 +160,7 @@ export function marksByRows(
156
160
  const range = spanRangeInBlock(span, rowIndex, blocks[rowIndex]!.text.length);
157
161
 
158
162
  if (!range) continue;
163
+
159
164
  const marks = marksByIndex.get(rowIndex) ?? [];
160
165
 
161
166
  marks.push({
@@ -205,6 +210,7 @@ function addResolvedMark(
205
210
  blocks[rowIndex]!.text.length === 0;
206
211
 
207
212
  if (!range && !marksEmptyLine) continue;
213
+
208
214
  const marks = marksByIndex.get(rowIndex) ?? [];
209
215
 
210
216
  marks.push({
@@ -228,12 +234,14 @@ function addReviewFindingMark(
228
234
  const finding = annotation.reviewComment;
229
235
 
230
236
  if (!finding || isAddressed(annotation)) return false;
237
+
231
238
  const end = resolveReviewAnchorRow(rows, annotation.anchor, finding.path, finding.side);
232
239
  const start = finding.startAnchor
233
240
  ? resolveReviewAnchorRow(rows, finding.startAnchor, finding.path, finding.side)
234
241
  : end;
235
242
 
236
243
  if (!start || !end || start.rowIndex > end.rowIndex) return false;
244
+
237
245
  addResolvedMark(marksByIndex, annotation, blocks, start.rowIndex, end.rowIndex, focusedId);
238
246
 
239
247
  return true;
@@ -261,6 +269,7 @@ export function fileRowRange(rows: DiffRow[], path: string): { start: number; en
261
269
  const start = rows.findIndex((row) => row.file === path);
262
270
 
263
271
  if (start === -1) return null;
272
+
264
273
  let end = start;
265
274
 
266
275
  while (end < rows.length && rows[end]!.file === path) end++;
@@ -284,6 +293,7 @@ export function fileTargetMarks(
284
293
  const target = annotationTarget(annotation);
285
294
 
286
295
  if (target.kind !== "file") continue;
296
+
287
297
  const forPath = byFile.get(target.path) ?? [];
288
298
 
289
299
  forPath.push(annotation);
@@ -295,6 +305,7 @@ export function fileTargetMarks(
295
305
  const range = fileRowRange(rows, path);
296
306
 
297
307
  if (!range) continue;
308
+
298
309
  const base = range.start;
299
310
  const fileRows = rows.slice(range.start, range.end);
300
311
  const reviewRootIds = new Set(
@@ -319,10 +330,12 @@ export function fileTargetMarks(
319
330
  const finding = annotation.reviewComment;
320
331
 
321
332
  if (!finding || isAddressed(annotation)) continue;
333
+
322
334
  if (addReviewFindingMark(fileMarks, annotation, fileRows, blocks, focusedId)) {
323
335
  resolvedIds.add(annotation.id);
324
336
  continue;
325
337
  }
338
+
326
339
  const relativeRow = fileRows.findIndex(
327
340
  (row) =>
328
341
  (finding.side === "RIGHT" ? row.newLine : row.oldLine) === finding.line &&
@@ -349,11 +362,13 @@ export function fileTargetMarks(
349
362
  for (const annotation of fileAnnotations) {
350
363
  if (!annotation.replyTo || resolvedIds.has(annotation.id) || isAddressed(annotation))
351
364
  continue;
365
+
352
366
  const parent = [...fileMarks.entries()].find(([, marks]) =>
353
367
  marks.some((mark) => mark.annotationId === annotation.replyTo),
354
368
  );
355
369
 
356
370
  if (!parent) continue;
371
+
357
372
  const [fallbackRow, parentMarks] = parent;
358
373
  const parentMark = parentMarks.find((mark) => mark.annotationId === annotation.replyTo)!;
359
374
  const replyMark: Mark = {
@@ -448,6 +463,7 @@ export function commentCountsByFile(session: Thread, rows: DiffRow[]): Map<strin
448
463
  const file = rows[discussion.blockIndex]?.file;
449
464
 
450
465
  if (file === undefined) continue;
466
+
451
467
  counts.set(file, (counts.get(file) ?? 0) + discussion.annotations.length);
452
468
  }
453
469
 
@@ -496,6 +512,7 @@ export function resolveReviewAnchorRow(
496
512
  (side === "RIGHT" ? row.newLine === undefined : row.oldLine === undefined)
497
513
  )
498
514
  return [];
515
+
499
516
  const context = diffRowAnchorContext(rows, rowIndex);
500
517
 
501
518
  return [
@@ -508,6 +525,7 @@ export function resolveReviewAnchorRow(
508
525
  });
509
526
 
510
527
  if (candidates.length === 0) return undefined;
528
+
511
529
  const bestScore = Math.max(...candidates.map((candidate) => candidate.score));
512
530
  const best = candidates.filter((candidate) => candidate.score === bestScore);
513
531
 
package/src/diff/walk.ts CHANGED
@@ -32,9 +32,12 @@ export function walkFiles(rows: DiffRow[]): WalkFile[] {
32
32
  byPath.set(row.file, file);
33
33
  files.push(file);
34
34
  }
35
+
35
36
  if (row.kind !== "add" && row.kind !== "del") continue;
37
+
36
38
  if (row.kind === "add") file.added += 1;
37
39
  else file.removed += 1;
40
+
38
41
  if (file.preview.length < WALK_PREVIEW_ROWS) {
39
42
  file.preview.push({
40
43
  sign: row.kind === "add" ? "+" : "-",
package/src/index.ts CHANGED
@@ -23,6 +23,7 @@ export {
23
23
  collaboratorAnnotations,
24
24
  mergeFromShare,
25
25
  publishShare,
26
+ snapshotThreadConversation,
26
27
  pullShare,
27
28
  pushShare,
28
29
  shareIdFromLine,
@@ -33,9 +33,11 @@ export function resolveDisplayName(
33
33
  const override = overrides[authorId];
34
34
 
35
35
  if (override) return override;
36
+
36
37
  const identity = participants?.find((participant) => participant.id === authorId);
37
38
 
38
39
  if (identity?.name) return identity.name;
40
+
39
41
  if (identity) return ANONYMOUS_LABEL;
40
42
 
41
43
  return shortHandle(authorId);
@@ -65,12 +65,15 @@ export class ClientExtensionRegistry {
65
65
  ): ExtensionUIDisposable {
66
66
  if (!/^[a-z][a-z0-9-]*$/.test(contribution.id))
67
67
  throw new Error(`Client extension invalid contribution ID: ${contribution.id}`);
68
+
68
69
  if (contribution.zone !== zone)
69
70
  throw new Error(`Client extension ${owner}:${contribution.id} must use zone ${zone}`);
71
+
70
72
  const key = `${owner}:${contribution.id}`;
71
73
 
72
74
  if (this.sections.has(key) || this.actions.has(key) || this.views.has(key))
73
75
  throw new Error(`Client extension duplicate contribution: ${key}`);
76
+
74
77
  map.set(key, contribution);
75
78
  this.changed();
76
79
 
@@ -149,6 +152,7 @@ export async function loadInstalledClientExtensions(
149
152
  const parsed = v.safeParse(v.object({ default: v.function() }), module);
150
153
 
151
154
  if (!parsed.success) throw new Error("client entry point must export a default factory");
155
+
152
156
  await registry.load(extension.name, async (api) => {
153
157
  await parsed.output.default(api);
154
158
  });
@@ -8,6 +8,7 @@
8
8
  /** The clipboard-write commands to try, most-preferred first, for this platform. */
9
9
  export function clipboardCommands(platform: NodeJS.Platform = process.platform): string[][] {
10
10
  if (platform === "darwin") return [["pbcopy"]];
11
+
11
12
  if (platform === "win32") return [["clip.exe"]];
12
13
 
13
14
  return [["wl-copy"], ["xclip", "-selection", "clipboard"], ["xsel", "--clipboard", "--input"]];
@@ -36,10 +36,12 @@ for (const enabled of [false, true])
36
36
  expect(setup.captureCharFrame().includes("Allow agent messages")).toBe(enabled);
37
37
  await press(setup, "down");
38
38
  await press(setup, "down");
39
+
39
40
  if (enabled) {
40
41
  await press(setup, " ");
41
42
  await press(setup, "down");
42
43
  }
44
+
43
45
  await press(setup, "enter");
44
46
  expect(created).toHaveLength(1);
45
47
  expect(created[0]?.agentEnabled).toBe(enabled ? true : undefined);
@@ -293,7 +293,9 @@ function Wizard({
293
293
 
294
294
  useEffect(() => {
295
295
  if (focus !== "name") return;
296
+
296
297
  nameRef.current?.focus();
298
+
297
299
  if (nameRef.current) nameRef.current.cursorOffset = draft.name.length;
298
300
  // eslint-disable-next-line react-hooks/exhaustive-deps
299
301
  }, [focus]);
@@ -418,6 +420,7 @@ export function ShareDialog({
418
420
 
419
421
  const saveWizard = (): void => {
420
422
  if (!wizard || saveDisabled) return;
423
+
421
424
  const input: NewShareLink = {
422
425
  name: wizard.name.trim() || undefined,
423
426
  requireAuth: wizard.requireAuth,
@@ -425,13 +428,16 @@ export function ShareDialog({
425
428
  };
426
429
 
427
430
  if (agentAvailable) input.agentEnabled = wizard.agentEnabled ?? false;
431
+
428
432
  if (wizard.editingId) onUpdateLink(wizard.editingId, input);
429
433
  else onCreateLink(input);
434
+
430
435
  backToList();
431
436
  };
432
437
 
433
438
  const activateListRow = (): void => {
434
439
  if (listCursor >= links.length) return openWizardWith(newWizardDraft(threadName));
440
+
435
441
  const link = links[listCursor];
436
442
 
437
443
  if (link) onCopyLink(link.id);
@@ -440,30 +446,41 @@ export function ShareDialog({
440
446
  // turning auth on drops the cursor straight into the handles field; turning it off resets them
441
447
  const toggleAuth = (): void => {
442
448
  if (!wizard) return;
449
+
443
450
  const next = !wizard.requireAuth;
444
451
 
445
452
  shareDialogStore.getState().setRequireAuth(next);
453
+
446
454
  if (!next) shareDialogStore.getState().setAllowlist([]);
455
+
447
456
  setWizardFocus(next ? "allowlist" : "auth");
448
457
  };
449
458
 
450
459
  // grammar keys only bite outside a focused text field, where they cannot be typed
451
460
  const handleWizardKey = (name: string): void => {
452
461
  if (!wizard) return;
462
+
453
463
  if (name === "escape") return backToList();
464
+
454
465
  if (name === "down") return moveWizardFocus(1);
466
+
455
467
  if (name === "up") return moveWizardFocus(-1);
468
+
456
469
  const activated = name === "return" || name === "enter";
457
470
 
458
471
  if (wizardFocus === "auth" && (activated || name === "space")) return toggleAuth();
472
+
459
473
  if (wizardFocus === "agent" && (activated || name === "space"))
460
474
  return shareDialogStore.getState().setAgentEnabled(!wizard.agentEnabled);
475
+
461
476
  if (wizardFocus === "actions" && activated) saveWizard();
462
477
  };
463
478
 
464
479
  const handleNavKey = (name: string): void => {
465
480
  if (name === "j" || name === "down") return shareDialogStore.getState().setCategory("export");
481
+
466
482
  if (name === "k" || name === "up") return shareDialogStore.getState().setCategory("external");
483
+
467
484
  const enter =
468
485
  name === "l" || name === "right" || name === "return" || name === "enter" || name === "tab";
469
486
 
@@ -478,21 +495,32 @@ export function ShareDialog({
478
495
 
479
496
  const handleBodyKey = (name: string): void => {
480
497
  if (name === "h" || name === "left") return setActiveZone("nav");
498
+
481
499
  if (name === "j" || name === "down")
482
500
  return setListCursor((cursor) => Math.min(cursor + 1, rowCount - 1));
501
+
483
502
  if (name === "k" || name === "up") return setListCursor((cursor) => Math.max(cursor - 1, 0));
503
+
484
504
  if (name === "return" || name === "enter") return activateListRow();
505
+
485
506
  if (listCursor >= links.length) return;
507
+
486
508
  if (name === "e") return editLinkAtCursor();
509
+
487
510
  if (name === "d") onDeleteLink(links[listCursor]!.id);
488
511
  };
489
512
 
490
513
  useKeyboard((key) => {
491
514
  if (!isOpen) return;
515
+
492
516
  if (wizard) return handleWizardKey(key.name);
517
+
493
518
  if (menuOpenId !== null) return void (key.name === "escape" && setMenuOpenId(null));
519
+
494
520
  if (key.name === "escape") return onClose();
521
+
495
522
  if (activeZone === "nav") return handleNavKey(key.name);
523
+
496
524
  handleBodyKey(key.name);
497
525
  });
498
526
 
@@ -85,6 +85,7 @@ export function resolveEditorCommand(rawEditor: string): ResolvedEditor {
85
85
  const base = editorBaseName(parts[0] ?? "");
86
86
 
87
87
  if (TERMINAL_EDITORS.has(base)) return { argv: parts, waits: true };
88
+
88
89
  const waitFlags = GUI_WAIT_FLAGS[base];
89
90
 
90
91
  if (waitFlags) {
@@ -103,6 +104,7 @@ const NO_WAIT_THRESHOLD_MS = 1000;
103
104
 
104
105
  function suspectsNoWait(resolved: ResolvedEditor, elapsedMs: number, unchanged: boolean): boolean {
105
106
  if (resolved.waits) return false; // holds the terminal or has a wait flag - trust the exit
107
+
106
108
  if (!unchanged) return false; // edits landed, so it waited after all
107
109
 
108
110
  return elapsedMs < NO_WAIT_THRESHOLD_MS;
@@ -145,6 +147,7 @@ export function editInEditor(
145
147
  });
146
148
 
147
149
  if (editorProcess.exitCode !== 0) throw new Error(`editor exited ${editorProcess.exitCode}`);
150
+
148
151
  let next = readFileSync(path, "utf8");
149
152
 
150
153
  if (suspectsNoWait(resolved, now() - startedAt, next === content)) {
@@ -10,6 +10,7 @@ const tempDirectories: string[] = [];
10
10
  afterEach(() => {
11
11
  if (originalBinary === undefined) delete process.env.CUELOOP_GH;
12
12
  else process.env.CUELOOP_GH = originalBinary;
13
+
13
14
  for (const directory of tempDirectories.splice(0)) {
14
15
  rmSync(directory, { recursive: true, force: true });
15
16
  }
@@ -30,6 +30,7 @@ export async function resolveGithubIdentity(): Promise<GithubIdentity | null> {
30
30
  ]);
31
31
 
32
32
  if (exitCode !== 0) return null;
33
+
33
34
  const parsed = v.safeParse(GithubUserSchema, JSON.parse(output));
34
35
 
35
36
  if (!parsed.success) return null;
@@ -21,9 +21,11 @@ export function focusHerdrPane(binPath: string, paneId: string): boolean {
21
21
  });
22
22
 
23
23
  if (got.exitCode !== 0) return false;
24
+
24
25
  const parsed = v.safeParse(HerdrPaneSchema, JSON.parse(got.stdout.toString()));
25
26
 
26
27
  if (!parsed.success) return false;
28
+
27
29
  const tab = parsed.output.result?.pane?.tab_id;
28
30
 
29
31
  if (!tab) return false;
@@ -37,6 +37,7 @@ function isControlCodePoint(codePoint: number): boolean {
37
37
  /** True when a paste is an image or other non-text binary payload and should collapse to a placeholder. */
38
38
  export function looksLikeBinaryPaste(bytes: Uint8Array): boolean {
39
39
  if (bytes.length === 0) return false;
40
+
40
41
  if (hasImageMagic(bytes)) return true;
41
42
 
42
43
  const decoded = PASTE_DECODER.decode(bytes);
@@ -48,6 +49,7 @@ export function looksLikeBinaryPaste(bytes: Uint8Array): boolean {
48
49
  const codePoint = character.codePointAt(0) ?? 0;
49
50
 
50
51
  if (codePoint === 0 || codePoint === 0xfffd) return true;
52
+
51
53
  if (isControlCodePoint(codePoint)) controlCount += 1;
52
54
  }
53
55
 
@@ -19,9 +19,10 @@ import {
19
19
  type SharePolicy,
20
20
  type ShareLink,
21
21
  type Thread,
22
+ type ThreadAgentState,
22
23
  } from "@cueloop/schema";
23
24
  import { ThreadRecordSchema } from "@cueloop/daemon/validate";
24
- import type { SharedMerge } from "@cueloop/daemon/client";
25
+ import type { ThreadClient, SharedMerge } from "@cueloop/daemon/client";
25
26
  import * as v from "valibot";
26
27
  import { copyToClipboard } from "./clipboard";
27
28
 
@@ -62,9 +63,11 @@ export async function publishShare(
62
63
 
63
64
  if (code !== 0)
64
65
  throw new Error(`gateway upload failed: ${stderr.trim() || `ssh exited ${code}`}`);
66
+
65
67
  const line = stdout.trim();
66
68
 
67
69
  if (!line.startsWith("ssh ")) throw new Error(`unexpected gateway reply: ${line || "(empty)"}`);
70
+
68
71
  const copied = await copyToClipboard(line);
69
72
 
70
73
  return { line, copied };
@@ -98,6 +101,7 @@ export function mergeFromShare(remote: Thread): SharedMerge {
98
101
  if (remote.shares?.length === 1) merge.shareId = remote.shares[0]!.id;
99
102
 
100
103
  if (remote.participants) merge.participants = remote.participants;
104
+
101
105
  if (remote.history) {
102
106
  merge.removals = removalEntries(remote.history).map((entry) => ({
103
107
  id: entry.id,
@@ -193,6 +197,7 @@ async function readLines(
193
197
  pending = lines.pop() ?? "";
194
198
  for (const line of lines) {
195
199
  if (line.trim() === "") continue;
200
+
196
201
  try {
197
202
  onLine(line);
198
203
  } catch {
@@ -236,3 +241,43 @@ async function runShareSsh(
236
241
 
237
242
  return { stdout, stderr, code };
238
243
  }
244
+
245
+ /** Freeze the visible conversation without owner session IDs, execution data or delivery receipts. */
246
+ export async function snapshotThreadConversation(
247
+ thread: Thread,
248
+ client: Pick<ThreadClient, "agentGet">,
249
+ ): Promise<Thread> {
250
+ if (!client.agentGet) return thread;
251
+
252
+ let state: ThreadAgentState;
253
+
254
+ try {
255
+ state = await client.agentGet(thread.id);
256
+ } catch (error) {
257
+ if (error instanceof Error && error.message.startsWith("Thread agent is disabled;"))
258
+ return thread;
259
+
260
+ throw error;
261
+ }
262
+
263
+ if (!state.messages.length && !state.submissions?.length) return thread;
264
+
265
+ return {
266
+ ...thread,
267
+ agentSnapshot: {
268
+ threadId: thread.id,
269
+ harness: state.harness ? { id: state.harness.id, label: state.harness.label } : undefined,
270
+ phase: { kind: "idle" },
271
+ messages: state.messages,
272
+ comments: state.comments,
273
+ tools: [],
274
+ submissions: state.submissions?.map(({ id, commentId, prompt, quote, status }) => ({
275
+ id,
276
+ commentId,
277
+ prompt,
278
+ quote,
279
+ status,
280
+ })),
281
+ },
282
+ };
283
+ }
@@ -77,6 +77,7 @@ export function HandleChipsInput({
77
77
  const input = inputRef.current;
78
78
 
79
79
  if (!input || !focused) return;
80
+
80
81
  input.focus();
81
82
  input.cursorOffset = seed.length; // land the caret at the end of the seeded text
82
83
  // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -91,12 +92,15 @@ export function HandleChipsInput({
91
92
  const handle = (inputRef.current?.plainText ?? "").trim().replace(/^@/, "").toLowerCase();
92
93
 
93
94
  if (handle && !logins.includes(handle)) onChange([...logins, handle]);
95
+
94
96
  reseed("");
95
97
  };
96
98
 
97
99
  useKeyboard((key) => {
98
100
  if (!focused) return;
101
+
99
102
  if (key.name !== "backspace" || (inputRef.current?.plainText ?? "").length > 0) return;
103
+
100
104
  // backspace on an empty field pulls the last chip back in to edit; with none left, collapse
101
105
  if (logins.length > 0) {
102
106
  const last = logins[logins.length - 1]!;
@@ -46,10 +46,13 @@ export function SlashComposer({
46
46
 
47
47
  useKeyboard((key) => {
48
48
  if (!slashActive) return;
49
+
49
50
  const selected = Math.min(slashIndex, items.length - 1);
50
51
 
51
52
  if (key.name === "up") return setSlashIndex(Math.max(0, selected - 1));
53
+
52
54
  if (key.name === "down") return setSlashIndex(Math.min(items.length - 1, selected + 1));
55
+
53
56
  if (key.name === "tab" || (key.name === "return" && !key.meta && !key.ctrl && !key.super)) {
54
57
  const inserted = insertSlashItem(text, caret, items[selected]!.name);
55
58
 
@@ -405,11 +405,13 @@ export class KeyBindings {
405
405
  fragments.push(entry.text);
406
406
  continue;
407
407
  }
408
+
408
409
  const displays = entry.commands
409
410
  .map((command) => this.keyDisplayFor(command))
410
411
  .filter((display): display is string => display !== null);
411
412
 
412
413
  if (!displays.length) continue;
414
+
413
415
  const keyPart = displays.join("/");
414
416
 
415
417
  if (!entry.label) fragments.push(keyPart);
@@ -435,6 +437,7 @@ export class KeyBindings {
435
437
 
436
438
  for (const entry of HINT_TEMPLATES[mode]) {
437
439
  if ("text" in entry || !entry.label) continue;
440
+
438
441
  const keys = entry.commands
439
442
  .map((command) => this.keyDisplayFor(command))
440
443
  .filter((display): display is string => display !== null)