@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
@@ -61,6 +61,7 @@ function singleRangeTextChanges(
61
61
  newText: string,
62
62
  ): ReturnType<typeof wordLevelChanges> | null {
63
63
  if (oldText === newText || oldText.length === newText.length) return null;
64
+
64
65
  let prefixLength = 0;
65
66
 
66
67
  while (
@@ -77,7 +78,9 @@ function singleRangeTextChanges(
77
78
  oldText[oldText.length - 1 - suffixLength] === newText[newText.length - 1 - suffixLength]
78
79
  )
79
80
  suffixLength++;
81
+
80
82
  if (prefixLength + suffixLength !== shorterLength) return null;
83
+
81
84
  const commonPrefix = oldText.slice(0, prefixLength);
82
85
  const commonSuffix = oldText.slice(oldText.length - suffixLength);
83
86
  const changed =
@@ -101,8 +104,11 @@ function singleRangeTextChanges(
101
104
  /** Word-overlap similarity gate: an unrelated cut + insert must not merge. */
102
105
  function similar(baseBlock: Block, workBlock: Block, expectedText?: string): boolean {
103
106
  if (baseBlock.kind !== workBlock.kind) return false;
107
+
104
108
  if (expectedText === workBlock.text) return true;
109
+
105
110
  if (singleRangeTextChanges(baseBlock.text, workBlock.text)) return true;
111
+
106
112
  const words = (text: string) => new Set(text.toLowerCase().split(/\s+/).filter(Boolean));
107
113
  const baseWords = words(baseBlock.text);
108
114
  const workWords = words(workBlock.text);
@@ -132,6 +138,7 @@ export function buildDisplay(
132
138
  const segments = sourceSegments[blockIndex];
133
139
 
134
140
  if (!segments?.length) return;
141
+
135
142
  const sourceStart = segments[0]!.sourceStart;
136
143
  const last = segments.at(-1)!;
137
144
  const sourceEnd = last.sourceStart + last.textEnd - last.textStart;
@@ -143,6 +150,7 @@ export function buildDisplay(
143
150
  const cut = exactCuts[index]!;
144
151
 
145
152
  if (cut.start >= sourceEnd) break;
153
+
146
154
  const start = blockTextBoundary(segments, block.text.length, cut.start);
147
155
  const end = blockTextBoundary(segments, block.text.length, cut.end);
148
156
 
@@ -153,6 +161,7 @@ export function buildDisplay(
153
161
  };
154
162
 
155
163
  if (ranges.length > 0) data.textCutRanges = ranges;
164
+
156
165
  cutData.set(block, data);
157
166
  });
158
167
  const blockCutData = (block: Block) => cutData.get(block) ?? {};
@@ -160,6 +169,7 @@ export function buildDisplay(
160
169
  const data = blockCutData(block);
161
170
 
162
171
  if (!data.textCutRanges) return undefined;
172
+
163
173
  let text = block.text;
164
174
 
165
175
  for (let index = data.textCutRanges.length - 1; index >= 0; index--) {
@@ -178,6 +188,7 @@ export function buildDisplay(
178
188
  if (!data.textCutRanges) {
179
189
  return { type: "same", kind: base.kind, base, work: base, ...data };
180
190
  }
191
+
181
192
  const text = expectedBlockText(base)!;
182
193
 
183
194
  return {
@@ -207,6 +218,7 @@ export function buildDisplay(
207
218
 
208
219
  return display;
209
220
  }
221
+
210
222
  const workBlocks = parseBlocks(workingContent);
211
223
  const signature = (block: Block) => block.kind + "\0" + block.text;
212
224
  const ops = lcsDiff(
@@ -225,6 +237,7 @@ export function buildDisplay(
225
237
  opIndex++;
226
238
  continue;
227
239
  }
240
+
228
241
  const deletedBlocks: Block[] = [];
229
242
  const addedBlocks: Block[] = [];
230
243
 
@@ -233,6 +246,7 @@ export function buildDisplay(
233
246
 
234
247
  if (changeOp.kind === "del") deletedBlocks.push(changeOp.oldValue!);
235
248
  else addedBlocks.push(changeOp.newValue!);
249
+
236
250
  opIndex++;
237
251
  }
238
252
  let deletedIndex = 0;
@@ -309,6 +323,7 @@ function blockTextBoundary(
309
323
  const sourceEnd = segment.sourceStart + segment.textEnd - segment.textStart;
310
324
 
311
325
  if (sourceOffset < segment.sourceStart) return segment.textStart;
326
+
312
327
  if (sourceOffset <= sourceEnd) return segment.textStart + sourceOffset - segment.sourceStart;
313
328
  }
314
329
 
@@ -377,13 +392,16 @@ export function marksByDisplay(
377
392
  for (const annotation of annotations) {
378
393
  // an addressed annotation keeps its record but paints no highlight
379
394
  if (isAddressed(annotation)) continue;
395
+
380
396
  const resolved = resolveAnchor(annotation.anchor, workBlocks);
381
397
 
382
398
  if (!resolved) continue;
399
+
383
400
  const startEntry = workEntries[resolved.blockIndex];
384
401
  const endEntry = workEntries[resolved.endBlockIndex];
385
402
 
386
403
  if (!startEntry || !endEntry) continue;
404
+
387
405
  // resolveAnchor works in the block's work text; the surface paints and hit-tests in rendered
388
406
  // text (markers concealed), so map the span onto the visible characters it covers
389
407
  const renderedStart = renderedOffsetAtOrAfter(
@@ -396,6 +414,7 @@ export function marksByDisplay(
396
414
  );
397
415
 
398
416
  if (renderedStart === null || renderedEndInclusive === null) continue;
417
+
399
418
  const span: TextSpan = {
400
419
  start: { blockIndex: startEntry.displayIndex, char: renderedStart },
401
420
  end: { blockIndex: endEntry.displayIndex, char: renderedEndInclusive + 1 },
@@ -407,6 +426,7 @@ export function marksByDisplay(
407
426
  const range = spanRangeInBlock(span, entry.displayIndex, renderedText(entry.block).length);
408
427
 
409
428
  if (!range) continue;
429
+
410
430
  const marks = marksByIndex.get(entry.displayIndex) ?? [];
411
431
 
412
432
  marks.push({
@@ -425,6 +445,7 @@ export function marksByDisplay(
425
445
 
426
446
  function exactCutRuns(block: DisplayBlock, literal: boolean): StyleRun[] | null {
427
447
  if (!block.base || !block.work || !block.textCutRanges) return null;
448
+
428
449
  const localCuts = block.textCutRanges;
429
450
  const expected = localCuts.reduceRight(
430
451
  (text, cut) => text.slice(0, cut.start) + text.slice(cut.end),
@@ -432,6 +453,7 @@ function exactCutRuns(block: DisplayBlock, literal: boolean): StyleRun[] | null
432
453
  );
433
454
 
434
455
  if (expected !== block.work.text) return null;
456
+
435
457
  const sourceRuns = literal
436
458
  ? [{ text: block.base.text, role: "plain" as const, start: 0 }]
437
459
  : inlineStyleRuns(block.base.text, 0);
@@ -441,6 +463,7 @@ function exactCutRuns(block: DisplayBlock, literal: boolean): StyleRun[] | null
441
463
 
442
464
  for (const sourceRun of sourceRuns) {
443
465
  if (sourceRun.start === null) continue;
466
+
444
467
  const runStart = sourceRun.start;
445
468
  const runEnd = runStart + sourceRun.text.length;
446
469
  let position = runStart;
@@ -461,6 +484,7 @@ function exactCutRuns(block: DisplayBlock, literal: boolean): StyleRun[] | null
461
484
  });
462
485
  break;
463
486
  }
487
+
464
488
  if (position < cut.start) {
465
489
  const commonEnd = Math.min(runEnd, cut.start);
466
490
 
@@ -473,6 +497,7 @@ function exactCutRuns(block: DisplayBlock, literal: boolean): StyleRun[] | null
473
497
  position = commonEnd;
474
498
  continue;
475
499
  }
500
+
476
501
  const removedEnd = Math.min(runEnd, cut.end);
477
502
 
478
503
  runs.push({
@@ -482,6 +507,7 @@ function exactCutRuns(block: DisplayBlock, literal: boolean): StyleRun[] | null
482
507
  baseStart: position,
483
508
  });
484
509
  position = removedEnd;
510
+
485
511
  if (position === cut.end) {
486
512
  deletedBefore += cut.end - cut.start;
487
513
  cutIndex++;
@@ -518,6 +544,7 @@ export function blockRuns(block: DisplayBlock, markup: boolean): StyleRun[] {
518
544
  runs.push({ text: change.text, role: "del", start: null });
519
545
  continue;
520
546
  }
547
+
521
548
  // A code/rule block stays literal; prose segments inline-tokenize so their
522
549
  // markers conceal while their work offsets stay intact. Added text keeps
523
550
  // the green diff role (it wins over emphasis); context text shows emphasis.
@@ -533,6 +560,7 @@ export function blockRuns(block: DisplayBlock, markup: boolean): StyleRun[] {
533
560
 
534
561
  return runs;
535
562
  }
563
+
536
564
  const text = displayText(block);
537
565
 
538
566
  // Prose carries inline markup; code fences and rules are literal. Concealed
@@ -550,6 +578,7 @@ export function blockRuns(block: DisplayBlock, markup: boolean): StyleRun[] {
550
578
  * document could otherwise splice into the terminal escape stream. */
551
579
  export function safeLinkHref(href: string | undefined): string | undefined {
552
580
  if (href === undefined) return undefined;
581
+
553
582
  if (!/^(https?:|mailto:)/i.test(href)) return undefined;
554
583
 
555
584
  return /^[\x21-\x7e]+$/.test(href) ? href : undefined;
@@ -565,6 +594,7 @@ export function inlineStyleRuns(text: string, base: number): StyleRun[] {
565
594
 
566
595
  for (const run of inlineRuns(text)) {
567
596
  if (run.role === "marker" || run.start === null) continue;
597
+
568
598
  const styleRun: StyleRun = {
569
599
  text: run.text,
570
600
  role: run.role === "text" ? "plain" : run.role,
@@ -572,6 +602,7 @@ export function inlineStyleRuns(text: string, base: number): StyleRun[] {
572
602
  };
573
603
 
574
604
  if (run.href !== undefined) styleRun.href = run.href;
605
+
575
606
  runs.push(styleRun);
576
607
  }
577
608
 
@@ -595,6 +626,7 @@ export function renderedStyleRuns(block: DisplayBlock): RenderedRun[] {
595
626
  const positioned: RenderedRun = { text: run.text, role: run.role, start: rendered };
596
627
 
597
628
  if (run.href !== undefined) positioned.href = run.href;
629
+
598
630
  runs.push(positioned);
599
631
  rendered += run.text.length;
600
632
  }
@@ -619,6 +651,7 @@ function addSyntaxBoundaries(
619
651
  ): void {
620
652
  for (const span of syntaxSpans) {
621
653
  if (span.start > line.start && span.start < line.end) cuts.add(span.start);
654
+
622
655
  if (span.end > line.start && span.end < line.end) cuts.add(span.end);
623
656
  }
624
657
  }
@@ -647,6 +680,7 @@ export function styledRunsFor(
647
680
 
648
681
  for (const run of roleRuns) {
649
682
  if (run.start > line.start && run.start < line.end) cuts.add(run.start);
683
+
650
684
  const runEnd = run.start + run.text.length;
651
685
 
652
686
  if (runEnd > line.start && runEnd < line.end) cuts.add(runEnd);
@@ -669,6 +703,7 @@ export function styledRunsFor(
669
703
  const end = edges[index + 1]!;
670
704
 
671
705
  if (end <= start) continue;
706
+
672
707
  const roleRun = roleRuns.find(
673
708
  (candidate) => candidate.start <= start && start < candidate.start + candidate.text.length,
674
709
  );
@@ -681,9 +716,11 @@ export function styledRunsFor(
681
716
  };
682
717
 
683
718
  if (roleRun?.href !== undefined) styled.href = roleRun.href;
719
+
684
720
  const syntaxGroup = syntaxGroupForRun(syntaxSpans, start, end);
685
721
 
686
722
  if (syntaxGroup) styled.syntaxGroup = syntaxGroup;
723
+
687
724
  runs.push(styled);
688
725
  }
689
726
 
@@ -693,6 +730,7 @@ export function styledRunsFor(
693
730
  /** Split runs at mark boundaries; marks only bind to runs with offsets. */
694
731
  export function overlayMarks(runs: StyleRun[], marks: Mark[]): StyleRun[] {
695
732
  if (!marks.length) return runs;
733
+
696
734
  const splitRuns: StyleRun[] = [];
697
735
 
698
736
  for (const run of runs) {
@@ -700,6 +738,7 @@ export function overlayMarks(runs: StyleRun[], marks: Mark[]): StyleRun[] {
700
738
  splitRuns.push(run);
701
739
  continue;
702
740
  }
741
+
703
742
  const bounds = new Set<number>([0, run.text.length]);
704
743
 
705
744
  for (const mark of marks) {
@@ -719,6 +758,7 @@ export function overlayMarks(runs: StyleRun[], marks: Mark[]): StyleRun[] {
719
758
  const sliceEnd = boundaries[boundaryIndex + 1]!;
720
759
 
721
760
  if (sliceStart >= sliceEnd) continue;
761
+
722
762
  const absoluteStart = runStart + sliceStart;
723
763
  const mark = marks.find(
724
764
  (candidate) => candidate.start <= absoluteStart && runStart + sliceEnd <= candidate.end,
@@ -751,6 +791,7 @@ export function renderedOffsetFor(runs: StyleRun[], workOffset: number): number
751
791
  if (run.start !== null && workOffset >= run.start && workOffset < run.start + run.text.length) {
752
792
  return rendered + (workOffset - run.start);
753
793
  }
794
+
754
795
  rendered += run.text.length;
755
796
  }
756
797
 
@@ -770,6 +811,7 @@ export function renderedOffsetAtOrAfter(runs: StyleRun[], workOffset: number): n
770
811
  if (run.start !== null && run.start + run.text.length > workOffset) {
771
812
  return rendered + Math.max(0, workOffset - run.start);
772
813
  }
814
+
773
815
  rendered += run.text.length;
774
816
  }
775
817
 
@@ -788,6 +830,7 @@ export function renderedOffsetAtOrBefore(runs: StyleRun[], workOffset: number):
788
830
  if (run.start !== null && run.start <= workOffset) {
789
831
  best = rendered + Math.min(workOffset - run.start, run.text.length - 1);
790
832
  }
833
+
791
834
  rendered += run.text.length;
792
835
  }
793
836
 
@@ -812,11 +855,14 @@ export function workRangeForRendered(
812
855
  const runRenderedEnd = rendered + run.text.length;
813
856
 
814
857
  rendered = runRenderedEnd;
858
+
815
859
  if (run.start === null) continue;
860
+
816
861
  const overlapStart = Math.max(renderedStart, runRenderedStart);
817
862
  const overlapEnd = Math.min(renderedEnd, runRenderedEnd);
818
863
 
819
864
  if (overlapEnd <= overlapStart) continue;
865
+
820
866
  const workStart = run.start + (overlapStart - runRenderedStart);
821
867
  const workEnd = run.start + (overlapEnd - runRenderedStart);
822
868
 
@@ -844,11 +890,14 @@ export function baseRangeForRendered(
844
890
  const runRenderedEnd = rendered + run.text.length;
845
891
 
846
892
  rendered = runRenderedEnd;
893
+
847
894
  if (run.baseStart === undefined) continue;
895
+
848
896
  const overlapStart = Math.max(renderedStart, runRenderedStart);
849
897
  const overlapEnd = Math.min(renderedEnd, runRenderedEnd);
850
898
 
851
899
  if (overlapEnd <= overlapStart) continue;
900
+
852
901
  const baseStart = run.baseStart + overlapStart - runRenderedStart;
853
902
  const baseEnd = run.baseStart + overlapEnd - runRenderedStart;
854
903
 
@@ -883,6 +932,7 @@ export function renderedSpanToWork(
883
932
 
884
933
  return range ?? { start: renderedStart, end: renderedEnd };
885
934
  }
935
+
886
936
  const startRuns = blockRuns(startBlock, true);
887
937
  const startRange = workRangeForRendered(
888
938
  startRuns,
@@ -952,6 +1002,7 @@ export function spanFromRange(
952
1002
  const wordIndex = words.findIndex((word) => word[1] > start);
953
1003
 
954
1004
  if (wordIndex === -1) return null;
1005
+
955
1006
  let wordEnd = wordIndex;
956
1007
 
957
1008
  for (let index = wordIndex; index < words.length && words[index]![0] < end; index++) {
@@ -989,6 +1040,7 @@ export function spanKey(span: SpanState, key: string, text: string): SpanState {
989
1040
  nextSpan.wordEnd = width;
990
1041
  nextSpan.wordIndex = 0;
991
1042
  } else return span;
1043
+
992
1044
  nextSpan.start = words[nextSpan.wordIndex]![0];
993
1045
  nextSpan.end = words[nextSpan.wordEnd]![1];
994
1046
 
@@ -14,5 +14,6 @@ export function reportPerfMarks(scope: string): void {
14
14
  if (marks.length === 0) return;
15
15
 
16
16
  if (process.env.CUELOOP_PERF === "1") process.stderr.write(formatPerfBlock(scope, marks));
17
+
17
18
  void postOtlpTrace(PERF_SERVICE_NAME, phaseMarksToSpans(scope, marks));
18
19
  }
@@ -17,6 +17,7 @@ function perfInstrumentationEnabled(): boolean {
17
17
  /** Record the elapsed ms since the previous mark (or process start) under `label`. */
18
18
  export function perfMark(label: string): void {
19
19
  if (!perfInstrumentationEnabled()) return;
20
+
20
21
  const now = performance.now();
21
22
 
22
23
  phaseMarks.push({ label, elapsedMs: now - (lastMarkAt ?? 0) });
@@ -126,6 +126,7 @@ function terminalColorScheme(textColor: string): "dark" | "light" {
126
126
  const hex = textColor.replace("#", "");
127
127
 
128
128
  if (hex.length < 6) return "dark";
129
+
129
130
  const r = parseInt(hex.slice(0, 2), 16);
130
131
  const g = parseInt(hex.slice(2, 4), 16);
131
132
  const b = parseInt(hex.slice(4, 6), 16);
@@ -181,12 +182,14 @@ function PrototypeContentViewImpl({
181
182
  // harness), but the launch trigger still runs.
182
183
  useEffect(() => {
183
184
  if (!renderer) return;
185
+
184
186
  const rendererOutput = v.safeParse(v.object({ writeOut: v.function() }), renderer);
185
187
  const rawWrite = rendererOutput.success ? rendererOutput.output.writeOut : null;
186
188
  const write = rawWrite ? (chunk: string) => rawWrite.call(renderer, chunk) : null;
187
189
  const medium = resolveTransmitMedium();
188
190
  const paint = (): void => {
189
191
  if (!write) return;
192
+
190
193
  // an app overlay is covering the sheet: pull the image so it does not show
191
194
  // through the overlay's cells, and re-transmit once the overlay closes
192
195
  if (hiddenRef.current) {
@@ -197,10 +200,12 @@ function PrototypeContentViewImpl({
197
200
 
198
201
  return;
199
202
  }
203
+
200
204
  const region = regionOf(regionRef.current);
201
205
  const png = pngRef.current;
202
206
 
203
207
  if (!region || !png) return;
208
+
204
209
  if (transmittedRef.current !== png) {
205
210
  transmitKittyImage(write, png, region, PROTOTYPE_IMAGE_ID, medium);
206
211
  transmittedRef.current = png;
@@ -219,6 +224,7 @@ function PrototypeContentViewImpl({
219
224
 
220
225
  return () => {
221
226
  renderer.off("frame", onFrame);
227
+
222
228
  if (write) deleteKittyImage(write, PROTOTYPE_IMAGE_ID);
223
229
  };
224
230
  }, [renderer]);
@@ -241,11 +247,13 @@ function PrototypeContentViewImpl({
241
247
  const caps = renderer?.capabilities;
242
248
 
243
249
  if (launchedRef.current || !region) return;
250
+
244
251
  if (caps && caps.kitty_graphics === false) {
245
252
  setStatus("unsupported");
246
253
 
247
254
  return;
248
255
  }
256
+
249
257
  launchedRef.current = true;
250
258
  const capture = captureConfig(region, renderer!);
251
259
 
@@ -266,6 +274,7 @@ function PrototypeContentViewImpl({
266
274
 
267
275
  return;
268
276
  }
277
+
269
278
  browserRef.current = browser;
270
279
  setPng(await browser.screenshot());
271
280
  setStatus("ready");
@@ -304,6 +313,7 @@ function PrototypeContentViewImpl({
304
313
  const region = regionOf(regionRef.current);
305
314
 
306
315
  if (status !== "ready" || !browser || !region) return;
316
+
307
317
  const geometry = {
308
318
  x: region.column,
309
319
  y: region.row,
@@ -313,10 +323,12 @@ function PrototypeContentViewImpl({
313
323
  const cssPoint = imageCellToCss(event, geometry, viewportRef.current);
314
324
 
315
325
  if (!cssPoint) return;
326
+
316
327
  void (async () => {
317
328
  const element = await browser.elementAt(cssPoint.x, cssPoint.y);
318
329
 
319
330
  if (!element) return;
331
+
320
332
  const cell = cssBoxToCell(element.box, geometry, viewportRef.current);
321
333
  const topRaw = cell.row - geometry.y;
322
334
 
@@ -326,6 +338,7 @@ function PrototypeContentViewImpl({
326
338
  top: Math.max(0, topRaw < OVERLAY_ROWS ? topRaw + 1 : topRaw - OVERLAY_ROWS),
327
339
  regionColumns: geometry.width,
328
340
  });
341
+
329
342
  // selecting an element opens the compose card directly, the same edit
330
343
  // primitive plan and diff use - there is no intermediate marker toolbar
331
344
  if (canComment) {
@@ -344,6 +357,7 @@ function PrototypeContentViewImpl({
344
357
  const browser = browserRef.current;
345
358
 
346
359
  if (status !== "ready" || !browser) return;
360
+
347
361
  const delta = event.scroll?.direction === "up" ? -SCROLL_STEP : SCROLL_STEP;
348
362
 
349
363
  // a selection would drift once the page scrolls under it, so drop it
@@ -356,7 +370,9 @@ function PrototypeContentViewImpl({
356
370
 
357
371
  const commit = (body: string): void => {
358
372
  if (!canComment) return;
373
+
359
374
  if (selected && body.trim()) onCommentElement(selected, body.trim());
375
+
360
376
  clearSelection();
361
377
  };
362
378
 
@@ -435,6 +451,7 @@ export const PrototypeContentView = React.memo(PrototypeContentViewImpl);
435
451
 
436
452
  function statusLine(status: SheetStatus, errorMessage: string): string {
437
453
  if (status === "loading") return "rendering prototype…";
454
+
438
455
  if (status === "unsupported")
439
456
  return "prototype preview needs a graphics terminal (kitty or ghostty)";
440
457
 
@@ -114,6 +114,7 @@ async function warmBrowser(executablePath: string | undefined): Promise<Puppetee
114
114
  const cached = sharedBrowser ? await sharedBrowser.catch(() => null) : null;
115
115
 
116
116
  if (cached && cached.connected) return cached;
117
+
117
118
  const puppeteer = (await import("puppeteer-core")).default;
118
119
  const launched = puppeteer.launch({
119
120
  executablePath: executablePath ?? chromeExecutable(),
@@ -124,6 +125,7 @@ async function warmBrowser(executablePath: string | undefined): Promise<Puppetee
124
125
  sharedBrowser = launched;
125
126
  const browser = await launched.catch((error) => {
126
127
  if (sharedBrowser === launched) sharedBrowser = null;
128
+
127
129
  throw error;
128
130
  });
129
131
 
@@ -143,7 +145,9 @@ export async function closePrototypeBrowser(): Promise<void> {
143
145
  const pending = sharedBrowser;
144
146
 
145
147
  sharedBrowser = null;
148
+
146
149
  if (!pending) return;
150
+
147
151
  const browser = await pending.catch(() => null);
148
152
 
149
153
  await browser?.close().catch(() => undefined);
@@ -233,8 +237,10 @@ function elementAtScript(x: number, y: number) {
233
237
  while (current && current !== document.body) {
234
238
  if (isNamed(current)) {
235
239
  if (current.childElementCount > 1) return current;
240
+
236
241
  namedFallback = namedFallback ?? current;
237
242
  }
243
+
238
244
  current = current.parentElement;
239
245
  }
240
246
 
@@ -251,6 +257,7 @@ function elementAtScript(x: number, y: number) {
251
257
  parts.unshift(part + "#" + CSS.escape(current.id));
252
258
  break;
253
259
  }
260
+
254
261
  const parent: Element | null = current.parentElement;
255
262
 
256
263
  if (parent) {
@@ -259,6 +266,7 @@ function elementAtScript(x: number, y: number) {
259
266
 
260
267
  if (twins.length > 1) part += ":nth-of-type(" + (twins.indexOf(current) + 1) + ")";
261
268
  }
269
+
262
270
  parts.unshift(part);
263
271
  current = current.parentElement;
264
272
  }
@@ -282,10 +290,12 @@ function elementAtScript(x: number, y: number) {
282
290
  const hit = document.elementFromPoint(x, y);
283
291
 
284
292
  if (!(hit instanceof Element)) return null;
293
+
285
294
  const node = focusableTarget(hit) ?? componentRoot(hit);
286
295
  const selector = selectorFor(node);
287
296
 
288
297
  if (!selector) return null;
298
+
289
299
  const text = (node.textContent || "").replace(/\s+/g, " ").trim();
290
300
  const tag = node.tagName.toLowerCase();
291
301
  const quote = text
@@ -313,6 +323,7 @@ function chromeExecutable(): string {
313
323
  if (process.platform === "darwin") {
314
324
  return "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
315
325
  }
326
+
316
327
  if (process.platform === "win32") {
317
328
  return "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe";
318
329
  }
@@ -92,10 +92,12 @@ export function SettingsDialog({
92
92
  const { width: terminalWidth, height: terminalHeight } = useTerminalDimensions();
93
93
 
94
94
  if (!isOpen) return null;
95
+
95
96
  const category =
96
97
  categories.find((candidate) => candidate.id === activeCategoryId) ?? categories[0];
97
98
 
98
99
  if (!category) return null;
100
+
99
101
  const onKeybinds = category.id === KEYBINDS_CATEGORY_ID;
100
102
 
101
103
  return (
@@ -143,6 +145,7 @@ export function SettingsDialog({
143
145
  />
144
146
  );
145
147
  }
148
+
146
149
  if (row.kind === "cycle") {
147
150
  return (
148
151
  <CycleRow
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import {
@@ -12,6 +12,7 @@ import {
12
12
  persistLayout,
13
13
  persistPins,
14
14
  persistReviewSkill,
15
+ persistThreadModelDefault,
15
16
  persistReviewWorkspace,
16
17
  persistTheme,
17
18
  quickActionBody,
@@ -674,3 +675,80 @@ test("Thread agents default off and require a boolean experimental TOML flag", (
674
675
  rmSync(dir, { recursive: true, force: true });
675
676
  }
676
677
  });
678
+
679
+ test("Thread harness preference persists without changing experimental or other settings", async () => {
680
+ const { persistThreadHarness } = await import("./config");
681
+ const dir = mkdtempSync(join(tmpdir(), "cueloop-thread-config-"));
682
+ const path = join(dir, "config.toml");
683
+
684
+ try {
685
+ writeFileSync(path, '[experimental]\nthread_agent = true\n[ui]\ntheme = "cueloop"\n');
686
+ persistThreadHarness("fx", path);
687
+ const config = loadConfig({ userConfigPath: path });
688
+
689
+ expect(config.thread.harness).toBe("fx");
690
+ expect(config.experimental.threadAgent).toBe(true);
691
+ expect(config.ui.theme).toBe("cueloop");
692
+ persistThreadHarness("pi", path);
693
+ expect(loadConfig({ userConfigPath: path }).thread.harness).toBe("pi");
694
+ } finally {
695
+ rmSync(dir, { recursive: true, force: true });
696
+ }
697
+ });
698
+
699
+ test("a malformed Thread section does not discard other preferences", () => {
700
+ const dir = mkdtempSync(join(tmpdir(), "cueloop-invalid-thread-config-"));
701
+ const path = join(dir, "config.toml");
702
+
703
+ try {
704
+ for (const section of ['thread = "fx"\n', '[thread]\nharness = "typo"\n']) {
705
+ writeFileSync(
706
+ path,
707
+ `${section}[experimental]\nthread_agent = true\n[ui]\ntheme = "cueloop"\n`,
708
+ );
709
+ const config = loadConfig({ userConfigPath: path });
710
+
711
+ expect(config.thread.harness).toBe("pi");
712
+ expect(config.experimental.threadAgent).toBe(true);
713
+ expect(config.ui.theme).toBe("cueloop");
714
+ }
715
+ } finally {
716
+ rmSync(dir, { recursive: true, force: true });
717
+ }
718
+ });
719
+
720
+ test("Thread model defaults layer independently and persistence stays in the selected table", () => {
721
+ const directory = mkdtempSync(join(tmpdir(), "cueloop-model-defaults-"));
722
+ const user = join(directory, "user.toml");
723
+ const repository = join(directory, "repo");
724
+
725
+ try {
726
+ const defaults = loadConfig({ userConfigPath: user }).thread;
727
+
728
+ expect(defaults.pi).toEqual({ model: "openai-codex/gpt-6.1-sol", effort: "medium" });
729
+ expect(defaults.fx).toEqual({ model: "grok/grok-4.7", effort: "medium" });
730
+ writeFileSync(
731
+ user,
732
+ '[thread.pi]\nmodel = "openai-codex/custom"\neffort = "high"\n\n[thread.fx]\nmodel = "grok/custom"\neffort = "low"\n',
733
+ );
734
+ persistThreadModelDefault("pi", "effort", "medium", user);
735
+ persistThreadModelDefault("fx", "model", "grok/grok-4.7", user);
736
+
737
+ const config = loadConfig({ userConfigPath: user });
738
+
739
+ expect(config.thread.pi).toEqual({ model: "openai-codex/custom", effort: "medium" });
740
+ expect(config.thread.fx).toEqual({ model: "grok/grok-4.7", effort: "low" });
741
+ mkdirSync(join(repository, ".cueloop"), { recursive: true });
742
+ writeFileSync(
743
+ join(repository, ".cueloop/config.toml"),
744
+ '[thread.pi]\neffort = "high"\n[thread.fx]\nmodel = " "\neffort = "invalid"\n',
745
+ );
746
+
747
+ const layered = loadConfig({ userConfigPath: user, repoRoot: repository });
748
+
749
+ expect(layered.thread.pi).toEqual({ model: "openai-codex/custom", effort: "high" });
750
+ expect(layered.thread.fx).toEqual(config.thread.fx);
751
+ } finally {
752
+ rmSync(directory, { recursive: true, force: true });
753
+ }
754
+ });