@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
@@ -281,6 +281,7 @@ class Controller implements ReviewController {
281
281
 
282
282
  subscribe = (listener: () => void): (() => void) => {
283
283
  this.listeners.add(listener);
284
+
284
285
  return () => this.listeners.delete(listener);
285
286
  };
286
287
 
@@ -298,11 +299,13 @@ class Controller implements ReviewController {
298
299
  this.options.openClient ??
299
300
  (() => DaemonClient.connect({ home: this.options.home, autostart: true }));
300
301
  const client = await openClient();
302
+
301
303
  if (this.closed) return void client.close();
302
304
  this.client = client;
303
305
  client.onEvent((event) => {
304
306
  // another controller/observer changed state: re-fetch
305
307
  const session = this.snapshot.session;
308
+
306
309
  if (session && event.sessionId === session.id) void this.refreshSession(session.id);
307
310
  if (!this.options.sessionId) void this.refreshInbox();
308
311
  });
@@ -353,11 +356,13 @@ class Controller implements ReviewController {
353
356
  // ── derived projections ─────────────────────
354
357
  private ensureDerived(): void {
355
358
  const session = this.snapshot.session;
359
+
356
360
  if (this.derivedFor === session) return;
357
361
  this.derivedFor = session;
358
362
  const rows =
359
363
  session && session.artifact.type === "diff" ? diffRows(session.artifact.content) : [];
360
364
  const models = new Map<string, FileDiffMetadata>();
365
+
361
366
  for (const file of session?.artifact.files ?? []) models.set(file.path, parseFileDiff(file));
362
367
  this.derived = {
363
368
  display: session ? buildDisplay(session.artifact.content, session.workingCopy) : [],
@@ -369,21 +374,25 @@ class Controller implements ReviewController {
369
374
 
370
375
  display(): DisplayBlock[] {
371
376
  this.ensureDerived();
377
+
372
378
  return this.derived.display;
373
379
  }
374
380
 
375
381
  rows(): DiffRow[] {
376
382
  this.ensureDerived();
383
+
377
384
  return this.derived.rows;
378
385
  }
379
386
 
380
387
  files(): WalkFile[] {
381
388
  this.ensureDerived();
389
+
382
390
  return this.derived.files;
383
391
  }
384
392
 
385
393
  working(): string {
386
394
  const session = this.snapshot.session;
395
+
387
396
  return session ? (session.workingCopy ?? session.artifact.content) : "";
388
397
  }
389
398
 
@@ -420,6 +429,7 @@ class Controller implements ReviewController {
420
429
  this.locallyViewed.clear();
421
430
  this.rejections = [];
422
431
  const cached = this.snapshot.inbox?.find((candidate) => candidate.id === id);
432
+
423
433
  if (cached) this.update({ session: cached });
424
434
  else void this.refreshSession(id);
425
435
  }
@@ -435,16 +445,20 @@ class Controller implements ReviewController {
435
445
 
436
446
  setSelfName(name: string): void {
437
447
  const session = this.snapshot.session;
448
+
438
449
  if (!session) return;
439
450
  this.apply(this.client!.sessionSetSelfName(session.id, name));
440
451
  }
441
452
 
442
453
  cut(displayIndex: number): void {
443
454
  const session = this.snapshot.session;
455
+
444
456
  if (!session || session.status === "resolved") return;
445
457
  const block = this.display()[displayIndex];
458
+
446
459
  if (!block) return;
447
460
  const working = this.working();
461
+
448
462
  if (block.type === "del") {
449
463
  this.restoreDelBlock(block, displayIndex);
450
464
  } else if (block.work) {
@@ -456,6 +470,7 @@ class Controller implements ReviewController {
456
470
  /** Re-insert a cut plan block at the next surviving block's line (Cut toggle + rail undo). */
457
471
  private restoreDelBlock(block: DisplayBlock, displayIndex: number): void {
458
472
  const session = this.snapshot.session;
473
+
459
474
  if (!session || !block.base) return;
460
475
  const working = this.working();
461
476
  const line = restoreLine(
@@ -465,6 +480,7 @@ class Controller implements ReviewController {
465
480
  // restoreBlock returns undefined when the block structure round-trips to the
466
481
  // submitted revision - the working copy is back to pristine and is dropped
467
482
  const restored = restoreBlock(session.artifact.content, working, block.base, line);
483
+
468
484
  this.setWorkingCopy(restored);
469
485
  this.setStatus(REMOVAL_RESTORED_STATUS);
470
486
  }
@@ -473,27 +489,36 @@ class Controller implements ReviewController {
473
489
  /** The parsed model and row for a curation action, or null with a status set. */
474
490
  private curationRow(rowIndex: number): { row: DiffRow; model: FileDiffMetadata } | null {
475
491
  const session = this.snapshot.session;
492
+
476
493
  if (!session || session.status === "resolved") return null;
477
494
  if (!session.artifact.files) {
478
495
  this.setStatus("hunk curation needs full file contents (PR diffs cannot be curated)");
496
+
479
497
  return null;
480
498
  }
481
499
  const row = this.rows()[rowIndex];
500
+
482
501
  if (!row || row.kind === "file" || row.kind === "hunk") {
483
502
  this.setStatus("move to a code line to curate");
503
+
484
504
  return null;
485
505
  }
486
506
  const model = this.derived.models.get(row.file);
507
+
487
508
  if (!model) return null;
509
+
488
510
  return { row, model };
489
511
  }
490
512
 
491
513
  toggleRejectHunk(rowIndex: number): void {
492
514
  const located = this.curationRow(rowIndex);
515
+
493
516
  if (!located) return;
494
517
  const target = hunkRejectionForRow(located.row.file, located.model, located.row);
518
+
495
519
  if (!target) return this.setStatus("no hunk under the cursor");
496
520
  const wholeHunk = (rejection: HunkRejection): boolean => rejectsWholeHunk(rejection, target);
521
+
497
522
  if (this.rejections.some(wholeHunk)) {
498
523
  this.rejections = this.rejections.filter((rejection) => !wholeHunk(rejection));
499
524
  this.setStatus("hunk restored");
@@ -511,12 +536,15 @@ class Controller implements ReviewController {
511
536
 
512
537
  toggleRejectChange(rowIndex: number): void {
513
538
  const located = this.curationRow(rowIndex);
539
+
514
540
  if (!located) return;
515
541
  if (located.row.kind !== "add" && located.row.kind !== "del")
516
542
  return this.setStatus("move to a changed line to reject a change");
517
543
  const target = changeRejectionForRow(located.row.file, located.model, located.row);
544
+
518
545
  if (!target) return this.setStatus("no change under the cursor");
519
546
  const wholeCovers = this.rejections.some((rejection) => rejectsWholeHunk(rejection, target));
547
+
520
548
  if (wholeCovers) return this.setStatus("the whole hunk is rejected - restore it first");
521
549
  if (this.rejections.some((rejection) => sameRejection(rejection, target))) {
522
550
  this.rejections = this.rejections.filter((rejection) => !sameRejection(rejection, target));
@@ -531,6 +559,7 @@ class Controller implements ReviewController {
531
559
  /** Recompute the curated patch and push it as the working copy (or clear it). */
532
560
  private recomputeCuration(): void {
533
561
  const files = this.snapshot.session?.artifact.files;
562
+
534
563
  if (!files) return;
535
564
  // no decisions left = the working copy reverts to the full submitted diff
536
565
  this.setWorkingCopy(this.rejections.length ? curateDiff(files, this.rejections) : undefined);
@@ -542,17 +571,22 @@ class Controller implements ReviewController {
542
571
  this.ensureDerived();
543
572
  if (!this.rejections.length) return EMPTY_REJECTED_ROWS;
544
573
  const rejected = new Set<number>();
574
+
545
575
  this.derived.rows.forEach((row, index) => {
546
576
  const model = this.derived.models.get(row.file);
577
+
547
578
  if (model && isRowRejected(row.file, model, row, this.rejections)) rejected.add(index);
548
579
  });
580
+
549
581
  return rejected;
550
582
  }
551
583
 
552
584
  curationItems(): CurationItem[] {
553
585
  this.ensureDerived();
554
586
  const session = this.snapshot.session;
587
+
555
588
  if (!session) return EMPTY_CURATION_ITEMS;
589
+
556
590
  return session.artifact.type === "diff" ? this.diffCurationItems() : this.planCurationItems();
557
591
  }
558
592
 
@@ -560,16 +594,19 @@ class Controller implements ReviewController {
560
594
  // diff rejections drop from the reject list; a plan cut re-inserts its block
561
595
  if (id.startsWith("diff:")) {
562
596
  const kept = this.rejections.filter((rejection) => curationItemId(rejection) !== id);
597
+
563
598
  if (kept.length === this.rejections.length) return;
564
599
  this.rejections = kept;
565
600
  this.setStatus(REMOVAL_RESTORED_STATUS);
566
601
  this.recomputeCuration();
602
+
567
603
  return;
568
604
  }
569
605
  const display = this.display();
570
606
  const displayIndex = display.findIndex(
571
607
  (block) => block.type === "del" && block.base && planCutId(block.base) === id,
572
608
  );
609
+
573
610
  if (displayIndex !== -1) this.restoreDelBlock(display[displayIndex]!, displayIndex);
574
611
  }
575
612
 
@@ -577,9 +614,11 @@ class Controller implements ReviewController {
577
614
  private diffCurationItems(): CurationItem[] {
578
615
  if (!this.rejections.length) return EMPTY_CURATION_ITEMS;
579
616
  const items: CurationItem[] = [];
617
+
580
618
  for (const rejection of this.rejections) {
581
619
  const rows = this.rowsForRejection(rejection);
582
620
  const firstRow = rows[0];
621
+
583
622
  items.push({
584
623
  id: curationItemId(rejection),
585
624
  source: "diff",
@@ -589,12 +628,14 @@ class Controller implements ReviewController {
589
628
  revealIndex: firstRow ? this.derived.rows.indexOf(firstRow) : 0,
590
629
  });
591
630
  }
631
+
592
632
  return items;
593
633
  }
594
634
 
595
635
  /** The cut plan blocks as removal cards, in document order. */
596
636
  private planCurationItems(): CurationItem[] {
597
637
  const items: CurationItem[] = [];
638
+
598
639
  this.derived.display.forEach((block, displayIndex) => {
599
640
  if (block.type !== "del" || !block.base) return;
600
641
  items.push({
@@ -604,32 +645,40 @@ class Controller implements ReviewController {
604
645
  revealIndex: displayIndex,
605
646
  });
606
647
  });
648
+
607
649
  return items.length ? items : EMPTY_CURATION_ITEMS;
608
650
  }
609
651
 
610
652
  /** The change/deletion rows a rejection covers, for its preview and reveal row. */
611
653
  private rowsForRejection(rejection: HunkRejection): DiffRow[] {
612
654
  const model = this.derived.models.get(rejection.path);
655
+
613
656
  if (!model) return [];
614
657
  const rows: DiffRow[] = [];
658
+
615
659
  for (const row of this.derived.rows) {
616
660
  if (row.file !== rejection.path || (row.kind !== "add" && row.kind !== "del")) continue;
617
661
  if (rejection.changeIndex === undefined) {
618
662
  const target = hunkRejectionForRow(rejection.path, model, row);
663
+
619
664
  if (target && target.hunkIndex === rejection.hunkIndex) rows.push(row);
620
665
  } else {
621
666
  const target = changeRejectionForRow(rejection.path, model, row);
667
+
622
668
  if (target && sameRejection(target, rejection)) rows.push(row);
623
669
  }
624
670
  }
671
+
625
672
  return rows;
626
673
  }
627
674
 
628
675
  edit(): void {
629
676
  const session = this.snapshot.session;
677
+
630
678
  if (!session || session.status === "resolved") return;
631
679
  try {
632
680
  const result = editInEditor(this.working(), "plan.md", { editor: this.editor });
681
+
633
682
  if (result.changed) {
634
683
  this.setWorkingCopy(result.content);
635
684
  this.reconcileAnnotations(session, result.content);
@@ -651,6 +700,7 @@ class Controller implements ReviewController {
651
700
  const orphanCount = session.annotations.filter(
652
701
  (annotation) => resolveAnchor(annotation.anchor, editedBlocks) === null,
653
702
  ).length;
703
+
654
704
  this.update({ editOrphanCount: orphanCount });
655
705
  }
656
706
 
@@ -662,8 +712,10 @@ class Controller implements ReviewController {
662
712
  body: string,
663
713
  ): string | undefined {
664
714
  const session = this.snapshot.session;
715
+
665
716
  if (!session) return undefined;
666
717
  let anchor;
718
+
667
719
  if (session.artifact.type === "diff") {
668
720
  anchor = { ...diffRowAnchor(this.rows(), displayIndex), blockIndex: displayIndex };
669
721
  } else {
@@ -671,36 +723,45 @@ class Controller implements ReviewController {
671
723
  const workBlocks = display.filter((entry) => entry.work).map((entry) => entry.work!);
672
724
  const workBlockIndex =
673
725
  display.slice(0, displayIndex + 1).filter((entry) => entry.work).length - 1;
726
+
674
727
  anchor = makeAnchor(workBlocks, workBlockIndex, start, end);
675
728
  }
676
729
  const wire = { id: newAnnotationId(), kind, anchor, body };
677
730
  const persisted = this.client!.sessionAnnotate(session.id, wire);
731
+
678
732
  this.apply(persisted);
679
733
  this.mirrorAnnotation(persisted, wire);
680
734
  this.setStatus("comment added");
735
+
681
736
  return wire.id;
682
737
  }
683
738
 
684
739
  annotatePrototype(selector: string, quote: string, body: string): string | undefined {
685
740
  const session = this.snapshot.session;
741
+
686
742
  if (!session) return undefined;
687
743
  const anchor = { quote, prefix: "", suffix: "", selector };
688
744
  const wire = { id: newAnnotationId(), kind: "comment", anchor, body };
689
745
  const persisted = this.client!.sessionAnnotate(session.id, wire);
746
+
690
747
  this.apply(persisted);
691
748
  this.mirrorAnnotation(persisted, wire);
692
749
  this.setStatus("comment added");
750
+
693
751
  return wire.id;
694
752
  }
695
753
 
696
754
  updateAnnotation(id: string, body: string): void {
697
755
  const session = this.snapshot.session;
756
+
698
757
  if (!session) return;
699
758
  const existing = session.annotations.find((annotation) => annotation.id === id);
759
+
700
760
  if (!existing) return;
701
761
  // the daemon's annotate verb upserts by id: same id + anchor, new body
702
762
  const wire = { id: existing.id, kind: existing.kind, anchor: existing.anchor, body };
703
763
  const persisted = this.client!.sessionAnnotate(session.id, wire);
764
+
704
765
  this.apply(persisted);
705
766
  this.mirrorAnnotation(persisted, wire);
706
767
  this.setStatus("annotation updated");
@@ -712,12 +773,14 @@ class Controller implements ReviewController {
712
773
  annotation: Omit<Annotation, "createdAt">,
713
774
  ): void {
714
775
  const shareId = this.snapshot.session?.shareId;
776
+
715
777
  if (!shareId) return;
716
778
  void persisted.then(() => pushShare(shareId, [annotation])).catch(() => {});
717
779
  }
718
780
 
719
781
  removeAnnotation(id: string): void {
720
782
  const session = this.snapshot.session;
783
+
721
784
  if (!session) return;
722
785
  this.apply(this.client!.sessionRemoveAnnotation(session.id, id));
723
786
  this.setStatus("annotation deleted");
@@ -725,6 +788,7 @@ class Controller implements ReviewController {
725
788
 
726
789
  setWorkingCopy(content: string | undefined): void {
727
790
  const session = this.snapshot.session;
791
+
728
792
  if (!session) return;
729
793
  this.apply(this.client!.sessionSetWorkingCopy(session.id, content));
730
794
  }
@@ -740,8 +804,10 @@ class Controller implements ReviewController {
740
804
 
741
805
  walkStart(): void {
742
806
  const session = this.snapshot.session;
807
+
743
808
  if (!session || session.artifact.type !== "diff") return;
744
809
  const files = this.files();
810
+
745
811
  if (files.length === 0) return this.setStatus("nothing to walk - the diff is empty");
746
812
  // resume at the first unviewed file; a finished walk reopens on the end card
747
813
  this.update({ walk: { index: firstUnviewedIndex(files, this.viewedSet()) } });
@@ -750,9 +816,11 @@ class Controller implements ReviewController {
750
816
  walkForward(): void {
751
817
  const walk = this.snapshot.walk;
752
818
  const session = this.snapshot.session;
819
+
753
820
  if (!walk || !session) return;
754
821
  const files = this.files();
755
822
  const current = files[walk.index];
823
+
756
824
  if (!current) return; // already on the end card
757
825
  // advancing IS the viewed mark: the step is complete once you move past
758
826
  // it; the daemon verb merges, so only the new path travels
@@ -765,6 +833,7 @@ class Controller implements ReviewController {
765
833
 
766
834
  walkBack(): void {
767
835
  const walk = this.snapshot.walk;
836
+
768
837
  if (!walk) return;
769
838
  this.update({ walk: { index: Math.max(0, walk.index - 1) } });
770
839
  }
@@ -775,6 +844,7 @@ class Controller implements ReviewController {
775
844
 
776
845
  submit(verdict: VerdictKind, summary: string): void {
777
846
  const session = this.snapshot.session;
847
+
778
848
  if (!session) return;
779
849
  this.walkLeave();
780
850
  this.client!.sessionResolve(session.id, verdict, summary)
@@ -797,6 +867,7 @@ class Controller implements ReviewController {
797
867
  // countdown from DEFAULT_AUTO_CLOSE that closes on its own; esc stays,
798
868
  // a remembers the choice. A configured delay overrides; 0 closes now.
799
869
  const delay = this.autoClose;
870
+
800
871
  if (delay === 0) this.finishReview();
801
872
  else if (typeof delay === "number") this.startCounting(delay);
802
873
  else this.startCounting(DEFAULT_AUTO_CLOSE);
@@ -808,12 +879,14 @@ class Controller implements ReviewController {
808
879
 
809
880
  share(): void {
810
881
  const session = this.snapshot.session;
882
+
811
883
  if (!session) return;
812
884
  this.setStatus("sharing…");
813
885
  publishShare(session)
814
886
  .then(async ({ line, copied }) => {
815
887
  // Stamp the id back so a later pull knows which share to collect from.
816
888
  const shareId = shareIdFromLine(line);
889
+
817
890
  if (shareId && this.client) await this.client.sessionSetShareId(session.id, shareId);
818
891
  this.setStatus("");
819
892
  this.showToast(line, copied ? "share link copied" : "share link");
@@ -831,8 +904,10 @@ class Controller implements ReviewController {
831
904
  */
832
905
  pullShared(): Promise<void> {
833
906
  const session = this.snapshot.session;
907
+
834
908
  if (!session?.shareId || !this.client) return Promise.resolve();
835
909
  const client = this.client;
910
+
836
911
  return pullShare(session.shareId)
837
912
  .then((remote) =>
838
913
  client.sessionMergeShared(session.id, {
@@ -853,6 +928,7 @@ class Controller implements ReviewController {
853
928
  this.stopSharePoll();
854
929
  // per-run token so a stale run's in-flight pull never re-arms the poll
855
930
  const run = {};
931
+
856
932
  this.shareRun = run;
857
933
  const tick = (): void => {
858
934
  void this.pullShared().finally(() => {
@@ -860,7 +936,9 @@ class Controller implements ReviewController {
860
936
  this.sharePoll = this.clock.setTimeout(tick, SHARE_POLL_MS);
861
937
  });
862
938
  };
939
+
863
940
  tick();
941
+
864
942
  return () => {
865
943
  if (this.shareRun === run) this.stopSharePoll();
866
944
  };
@@ -883,6 +961,7 @@ class Controller implements ReviewController {
883
961
  this.clearCountdown();
884
962
  const herdr = detectHerdr();
885
963
  const pane = returnPaneFor(this.snapshot.session?.artifact.meta.herdrPane);
964
+
886
965
  if (herdr && pane) focusHerdrPane(herdr.binPath, pane);
887
966
  this.options.onExit?.(0);
888
967
  }
package/src/share.ts CHANGED
@@ -42,11 +42,14 @@ export async function publishShare(
42
42
  packSessionBlob(session),
43
43
  target,
44
44
  );
45
+
45
46
  if (code !== 0)
46
47
  throw new Error(`gateway upload failed: ${stderr.trim() || `ssh exited ${code}`}`);
47
48
  const line = stdout.trim();
49
+
48
50
  if (!line.startsWith("ssh ")) throw new Error(`unexpected gateway reply: ${line || "(empty)"}`);
49
51
  const copied = await copyToClipboard(line);
52
+
50
53
  return { line, copied };
51
54
  }
52
55
 
@@ -57,7 +60,9 @@ export async function publishShare(
57
60
  */
58
61
  export async function pullShare(shareId: string, target: ShareTarget = {}): Promise<ReviewSession> {
59
62
  const { stdout, stderr, code } = await runShareSsh("cueloop-pull", Buffer.from(shareId), target);
63
+
60
64
  if (code !== 0) throw new Error(`gateway pull failed: ${stderr.trim() || `ssh exited ${code}`}`);
65
+
61
66
  return JSON.parse(stdout) as ReviewSession;
62
67
  }
63
68
 
@@ -81,6 +86,7 @@ export async function pushShare(
81
86
  Buffer.from(JSON.stringify({ shareId, annotations })),
82
87
  target,
83
88
  );
89
+
84
90
  if (code !== 0) throw new Error(`gateway push failed: ${stderr.trim() || `ssh exited ${code}`}`);
85
91
  }
86
92
 
@@ -109,5 +115,6 @@ async function runShareSsh(
109
115
  new Response(proc.stderr).text(),
110
116
  proc.exited,
111
117
  ]);
118
+
112
119
  return { stdout, stderr, code };
113
120
  }
@@ -10,14 +10,17 @@ describe("isolateUserConfig", () => {
10
10
  // Arrange
11
11
  const home = mkdtempSync(join(tmpdir(), "cueloop-isolate-"));
12
12
  const userConfigPath = join(home, "config.toml");
13
+
13
14
  writeFileSync(userConfigPath, '[ui]\nreview_state = "compact"\n');
14
15
  const priorEnv = process.env.CUELOOP_CONFIG;
16
+
15
17
  process.env.CUELOOP_CONFIG = userConfigPath;
16
18
 
17
19
  try {
18
20
  // Act
19
21
  const restoreUserConfig = isolateUserConfig(home);
20
22
  const isolated = loadConfig({ repoRoot: home });
23
+
21
24
  restoreUserConfig();
22
25
  const restored = loadConfig({ repoRoot: home });
23
26
 
@@ -36,12 +39,14 @@ describe("isolateUserConfig", () => {
36
39
  // Arrange
37
40
  const home = mkdtempSync(join(tmpdir(), "cueloop-isolate-"));
38
41
  const priorEnv = process.env.CUELOOP_CONFIG;
42
+
39
43
  delete process.env.CUELOOP_CONFIG;
40
44
 
41
45
  try {
42
46
  // Act
43
47
  const restoreUserConfig = isolateUserConfig(home);
44
48
  const pointedAt = process.env.CUELOOP_CONFIG ?? "";
49
+
45
50
  restoreUserConfig();
46
51
 
47
52
  // Assert
@@ -22,7 +22,9 @@ export const WAIT_PASSES = { maxPasses: 400 };
22
22
  */
23
23
  export function isolateUserConfig(home: string, fileName = "no-config.toml"): () => void {
24
24
  const priorUserConfig = process.env.CUELOOP_CONFIG;
25
+
25
26
  process.env.CUELOOP_CONFIG = join(home, fileName);
27
+
26
28
  return () => {
27
29
  if (priorUserConfig === undefined) delete process.env.CUELOOP_CONFIG;
28
30
  else process.env.CUELOOP_CONFIG = priorUserConfig;
@@ -102,9 +104,11 @@ async function waitForFramePredicate(
102
104
  let frameChanges = 0;
103
105
  let lastFrame = "";
104
106
  let lastLog = start;
107
+
105
108
  for (;;) {
106
109
  await setup.renderOnce();
107
110
  const frame = setup.captureCharFrame();
111
+
108
112
  polls++;
109
113
  if (frame !== lastFrame) {
110
114
  frameChanges++;
@@ -112,8 +116,10 @@ async function waitForFramePredicate(
112
116
  }
113
117
  if (predicate(frame)) return frame;
114
118
  const now = Date.now();
119
+
115
120
  if (now > deadline) {
116
121
  const frozen = frameChanges <= 1 ? " - render never advanced (frozen)" : "";
122
+
117
123
  throw new Error(
118
124
  `waitFor ${label} timed out after ${now - start}ms (${polls} polls, ${frameChanges} frame changes${frozen}).\nlast frame:\n${frame}`,
119
125
  );
@@ -141,10 +147,12 @@ export async function waitForState(
141
147
  let frameChanges = 0;
142
148
  let lastFrame = "";
143
149
  let lastLog = start;
150
+
144
151
  for (;;) {
145
152
  if (predicate()) return;
146
153
  await setup.renderOnce();
147
154
  const frame = setup.captureCharFrame();
155
+
148
156
  polls++;
149
157
  if (frame !== lastFrame) {
150
158
  frameChanges++;
@@ -152,8 +160,10 @@ export async function waitForState(
152
160
  }
153
161
  if (predicate()) return;
154
162
  const now = Date.now();
163
+
155
164
  if (now > deadline) {
156
165
  const frozen = frameChanges <= 1 ? " - render never advanced (frozen)" : "";
166
+
157
167
  throw new Error(
158
168
  `waitForState ${label} timed out after ${now - start}ms (${polls} polls, ${frameChanges} frame changes${frozen}).\nlast frame:\n${frame}`,
159
169
  );
@@ -168,6 +168,7 @@ export const THEME_LABELS: Record<ThemeName, string> = {
168
168
  */
169
169
  export function themeForName(name: string, appearance: Appearance = "dark"): Theme {
170
170
  if (name === "cueloop") return brandedTheme(appearance);
171
+
171
172
  return THEME_PRESETS[name as ThemeName] ?? brandedTheme(appearance);
172
173
  }
173
174
 
@@ -57,6 +57,7 @@ function moveNavZone(
57
57
  return { categoryId: categories[Math.max(0, categoryIndex - 1)]!.id, rowIndex: 0, zone: "nav" };
58
58
  if (ENTER_BODY_KEYS.has(name))
59
59
  return { categoryId: categories[categoryIndex]!.id, rowIndex: 0, zone: "body" };
60
+
60
61
  return null;
61
62
  }
62
63
 
@@ -64,6 +65,7 @@ function moveBodyRow(name: string, nav: SettingsNav, rowCount: number): Settings
64
65
  if (DOWN_KEYS.has(name)) return { ...nav, rowIndex: Math.min(rowCount - 1, nav.rowIndex + 1) };
65
66
  if (UP_KEYS.has(name)) return { ...nav, rowIndex: Math.max(0, nav.rowIndex - 1) };
66
67
  if (name === "h" || name === "tab") return { ...nav, zone: "nav" };
68
+
67
69
  return null;
68
70
  }
69
71
 
@@ -198,15 +200,18 @@ export function useSettingsDialog(params: {
198
200
  const cycleSetting = (rowKey: string): void => {
199
201
  if (rowKey === "autoClose") {
200
202
  const next: AutoClose = autoClose === "off" ? 3 : autoClose === 3 ? 10 : "off";
203
+
201
204
  setAutoClose(next);
202
205
  persistAutoClose(next);
203
206
  } else if (rowKey === "reviewPanel") {
204
207
  const order: ReviewPanelMode[] = ["expanded", "compact", "hidden"];
205
208
  const next = order[(order.indexOf(reviewMode) + 1) % order.length]!;
209
+
206
210
  setReviewMode(next);
207
211
  controller.saveReviewPanel({ mode: next });
208
212
  } else if (rowKey === "theme") {
209
213
  const next = THEME_NAMES[(THEME_NAMES.indexOf(themeName) + 1) % THEME_NAMES.length]!;
214
+
210
215
  setThemeName(next);
211
216
  setTheme(composeTheme(next, themeOverrides, appearance));
212
217
  persistTheme(next);
@@ -222,6 +227,7 @@ export function useSettingsDialog(params: {
222
227
  // an open system-prompt input owns typing; only esc (close it) escapes here
223
228
  if (actionsExpandedIndex !== null) {
224
229
  if (name === "escape") setActionsExpandedIndex(null);
230
+
225
231
  return;
226
232
  }
227
233
  if (name === "escape") return void setMenuDialog(null);
@@ -229,22 +235,28 @@ export function useSettingsDialog(params: {
229
235
  (category) => category.id === settingsNav.categoryId,
230
236
  );
231
237
  const category = settingsCategories[categoryIndex]!;
238
+
232
239
  if (settingsNav.zone === "nav") {
233
240
  const next = moveNavZone(name, categoryIndex, settingsCategories);
241
+
234
242
  if (next) setSettingsNav(next);
243
+
235
244
  return;
236
245
  }
237
246
  // the Actions category is a list of quick actions plus a trailing "add" row
238
247
  if (category.id === "actions") {
239
248
  const moved = moveBodyRow(name, settingsNav, quickActions.length + 1);
249
+
240
250
  if (moved) return void setSettingsNav(moved);
241
251
  if (ACTIVATE_KEYS.has(name)) {
242
252
  if (settingsNav.rowIndex === quickActions.length) addAction();
243
253
  else setActionsExpandedIndex(settingsNav.rowIndex);
244
254
  }
255
+
245
256
  return;
246
257
  }
247
258
  const moved = moveBodyRow(name, settingsNav, category.rows.length);
259
+
248
260
  if (moved) return void setSettingsNav(moved);
249
261
  if (name === "return" || name === "space")
250
262
  cycleSetting(category.rows[settingsNav.rowIndex]!.key);
@@ -28,9 +28,11 @@ describe("diffRows", () => {
28
28
  expect(rows[0]).toMatchObject({ kind: "file", file: "src/store.ts" });
29
29
  expect(rows[1]!.kind).toBe("hunk");
30
30
  const del = rows.find((row) => row.kind === "del")!;
31
+
31
32
  expect(del.text).toContain("private items = [];");
32
33
  expect(del.oldLine).toBe(3);
33
34
  const adds = rows.filter((row) => row.kind === "add");
35
+
34
36
  expect(adds[0]!.text).toContain("new Map()");
35
37
  expect(adds[0]!.newLine).toBe(3);
36
38
  expect(adds[1]!.newLine).toBe(4);
@@ -44,6 +46,7 @@ describe("diffRows", () => {
44
46
 
45
47
  // Assert
46
48
  const ctx = rows.find((row) => row.kind === "ctx")!;
49
+
47
50
  expect(ctx.oldLine).toBe(1);
48
51
  expect(ctx.newLine).toBe(1);
49
52
  });