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

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 (160) hide show
  1. package/package.json +6 -5
  2. package/src/App.autoclose.test.tsx +2 -2
  3. package/src/App.diff.test.tsx +79 -112
  4. package/src/App.plan-review.test.tsx +23 -205
  5. package/src/App.prototype.test.tsx +1 -8
  6. package/src/App.readonly.test.tsx +9 -27
  7. package/src/App.submit-confirm.test.tsx +19 -72
  8. package/src/App.test.tsx +136 -31
  9. package/src/App.tsx +491 -322
  10. package/src/App.walk.test.tsx +15 -16
  11. package/src/App.workbench.test.tsx +390 -0
  12. package/src/__snapshots__/view-plan.regression.test.ts.snap +1 -1
  13. package/src/annotation-palette.ts +61 -0
  14. package/src/app-screens.tsx +197 -77
  15. package/src/app-view-model.ts +9 -69
  16. package/src/components/AnnotationCard.stories.tsx +1 -1
  17. package/src/components/AnnotationCard.tsx +1 -1
  18. package/src/components/AnnotationCards.stories.tsx +87 -0
  19. package/src/components/AnnotationCards.tsx +303 -0
  20. package/src/components/AppHeader.stories.tsx +50 -0
  21. package/src/components/AppHeader.tsx +83 -0
  22. package/src/components/AppShell.stories.tsx +89 -0
  23. package/src/components/AppShell.tsx +239 -0
  24. package/src/components/AppShell.zoom.test.tsx +94 -0
  25. package/src/components/Breadcrumb.stories.tsx +1 -1
  26. package/src/components/ChangesColumn.stories.tsx +39 -0
  27. package/src/components/ChangesColumn.tsx +170 -0
  28. package/src/components/CodeBlock.stories.tsx +1 -1
  29. package/src/components/CompletionOverlay.stories.tsx +1 -1
  30. package/src/components/ConfirmCard.stories.tsx +1 -1
  31. package/src/components/ConfirmCard.tsx +3 -4
  32. package/src/components/ConfirmDialog.stories.tsx +1 -1
  33. package/src/components/DiffSheet.scroll.test.tsx +38 -55
  34. package/src/components/DiffSheet.split.test.tsx +140 -0
  35. package/src/components/DiffSheet.stories.tsx +56 -54
  36. package/src/components/DiffSheet.syntax.test.tsx +24 -7
  37. package/src/components/DiffSheet.tsx +607 -276
  38. package/src/components/DiscussionMarkerRail.stories.tsx +54 -0
  39. package/src/components/DiscussionMarkerRail.tsx +181 -0
  40. package/src/components/EditorGrid.stories.tsx +59 -0
  41. package/src/components/EditorGrid.tabs.test.tsx +105 -0
  42. package/src/components/EditorGrid.tsx +338 -0
  43. package/src/components/EditorGrid.zoom.test.tsx +68 -0
  44. package/src/components/FileContentsView.stories.tsx +16 -0
  45. package/src/components/FileContentsView.test.tsx +29 -0
  46. package/src/components/FileContentsView.tsx +78 -0
  47. package/src/components/InboxList.stories.tsx +23 -8
  48. package/src/components/InboxList.tsx +188 -33
  49. package/src/components/KeybindsDialog.stories.tsx +1 -1
  50. package/src/components/MarkerPopover.stories.tsx +1 -1
  51. package/src/components/MenuBar.stories.tsx +3 -12
  52. package/src/components/MenuBar.tsx +25 -56
  53. package/src/components/PaneGrid.stories.tsx +84 -0
  54. package/src/components/PaneGrid.tsx +128 -0
  55. package/src/components/PanelColumn.stories.tsx +25 -0
  56. package/src/components/PanelColumn.tsx +95 -0
  57. package/src/components/ProjectTreeView.stories.tsx +25 -0
  58. package/src/components/ProjectTreeView.test.tsx +32 -0
  59. package/src/components/ProjectTreeView.tsx +82 -0
  60. package/src/components/PromptDialog.stories.tsx +1 -1
  61. package/src/components/PromptDialog.tsx +8 -1
  62. package/src/components/PrototypeSheet.stories.tsx +1 -1
  63. package/src/components/RootOverlay.stories.tsx +44 -0
  64. package/src/components/RootOverlay.tsx +35 -0
  65. package/src/components/SettingsDialog.stories.tsx +7 -1
  66. package/src/components/SettingsDialog.tsx +114 -67
  67. package/src/components/SettingsRows.stories.tsx +1 -1
  68. package/src/components/ShellHeader.stories.tsx +50 -0
  69. package/src/components/ShellHeader.tsx +137 -0
  70. package/src/components/SurfaceScrollbar.stories.tsx +34 -0
  71. package/src/components/SurfaceScrollbar.test.tsx +111 -0
  72. package/src/components/SurfaceScrollbar.tsx +93 -0
  73. package/src/components/ThreadFooter.stories.tsx +30 -0
  74. package/src/components/ThreadFooter.test.tsx +31 -0
  75. package/src/components/ThreadFooter.tsx +54 -0
  76. package/src/components/ThreadView.slash.test.tsx +113 -0
  77. package/src/components/ThreadView.stories.tsx +1 -1
  78. package/src/components/ThreadView.tsx +62 -1352
  79. package/src/components/ThreadsSidebar.stories.tsx +36 -0
  80. package/src/components/ThreadsSidebar.tsx +70 -0
  81. package/src/components/Toast.stories.tsx +1 -1
  82. package/src/components/Tooltip.stories.tsx +26 -0
  83. package/src/components/Tooltip.tsx +72 -0
  84. package/src/components/TreePane.stories.tsx +1 -1
  85. package/src/components/WalkWizard.stories.tsx +1 -1
  86. package/src/components/WelcomeSurface.stories.tsx +12 -0
  87. package/src/components/WelcomeSurface.tsx +52 -0
  88. package/src/components/__snapshots__/stories.test.tsx.snap +732 -572
  89. package/src/components/agent-launcher.stories.tsx +1 -1
  90. package/src/components/diff-sheet-layout.test.ts +125 -55
  91. package/src/components/diff-sheet-layout.ts +3 -103
  92. package/src/components/editor-grid.test.ts +86 -0
  93. package/src/components/editor-grid.ts +138 -0
  94. package/src/components/fade-title.test.ts +49 -0
  95. package/src/components/fade-title.ts +61 -0
  96. package/src/components/file-tree.test.ts +60 -0
  97. package/src/components/file-tree.ts +112 -0
  98. package/src/components/pages/Workspace.stories.tsx +181 -0
  99. package/src/components/primitives/Button.stories.tsx +1 -1
  100. package/src/components/primitives/Card.stories.tsx +1 -1
  101. package/src/components/primitives/Dialog.stories.tsx +1 -1
  102. package/src/components/primitives/IconButton.stories.tsx +36 -0
  103. package/src/components/primitives/IconButton.tsx +62 -0
  104. package/src/components/primitives/StatusBar.stories.tsx +1 -1
  105. package/src/components/primitives/Tabs.stories.tsx +1 -1
  106. package/src/components/primitives/Toolbar.stories.tsx +1 -1
  107. package/src/components/primitives/Tree.stories.tsx +124 -0
  108. package/src/components/primitives/Tree.tsx +96 -0
  109. package/src/components/primitives/icons.ts +61 -0
  110. package/src/components/primitives/tree-model.test.ts +99 -0
  111. package/src/components/primitives/tree-model.ts +137 -0
  112. package/src/components/quick-actions-editor.stories.tsx +1 -1
  113. package/src/components/session-tree.test.ts +134 -0
  114. package/src/components/session-tree.ts +108 -0
  115. package/src/components/stories-app.tsx +142 -82
  116. package/src/components/story-tree.test.ts +100 -0
  117. package/src/components/story.ts +55 -0
  118. package/src/components/theme-context.stories.tsx +1 -1
  119. package/src/config.test.ts +20 -49
  120. package/src/config.ts +26 -28
  121. package/src/diff-fold.test.ts +78 -0
  122. package/src/diff-fold.ts +126 -0
  123. package/src/diff-syntax.test.ts +16 -0
  124. package/src/diff-syntax.ts +6 -3
  125. package/src/discussions.ts +79 -0
  126. package/src/intent-dispatch.test.ts +31 -11
  127. package/src/intent-dispatch.ts +93 -48
  128. package/src/key-bindings.test.ts +1 -1
  129. package/src/keymap.test.ts +3 -25
  130. package/src/keymap.ts +17 -18
  131. package/src/mark-runs.ts +120 -0
  132. package/src/pty.ts +10 -2
  133. package/src/run.ts +17 -7
  134. package/src/serve.test.ts +4 -8
  135. package/src/session-controller.curate.test.ts +75 -0
  136. package/src/session-controller.share.test.ts +1 -0
  137. package/src/session-controller.tree.test.ts +26 -0
  138. package/src/session-controller.ts +160 -31
  139. package/src/slash-palette.ts +83 -0
  140. package/src/split-diff.test.ts +110 -0
  141. package/src/split-diff.ts +149 -0
  142. package/src/test-support.test.ts +3 -3
  143. package/src/test-support.ts +39 -8
  144. package/src/theme.ts +4 -4
  145. package/src/thread-chords.test.ts +25 -9
  146. package/src/thread-chords.ts +33 -8
  147. package/src/use-annotation-surface.tsx +837 -0
  148. package/src/use-changes-workbench.ts +151 -0
  149. package/src/use-frame-measure.ts +38 -0
  150. package/src/use-settings-dialog.tsx +23 -24
  151. package/src/use-terminal-virtualizer.ts +233 -0
  152. package/src/view-diff.ts +114 -0
  153. package/src/App.review-panel.test.tsx +0 -140
  154. package/src/App.tree.test.tsx +0 -224
  155. package/src/components/ReviewPanel.stories.tsx +0 -100
  156. package/src/components/ReviewPanel.tsx +0 -103
  157. package/src/components/ReviewRail.stories.tsx +0 -232
  158. package/src/components/ReviewRail.tsx +0 -327
  159. package/src/review-panel.test.ts +0 -113
  160. package/src/review-panel.ts +0 -83
@@ -14,15 +14,11 @@ import type { DiffRow } from "./view-diff";
14
14
  import type { ReviewController } from "./session-controller";
15
15
  import type { Intent } from "./keymap";
16
16
  import type { TreeRow } from "./tree-view";
17
- import type { RailTab } from "./components/ReviewRail";
18
17
  import { quickActionBody, type QuickAction } from "./config";
19
18
  import { VERDICTS } from "./components/ConfirmCard";
20
- import {
21
- REVIEW_RESIZE_STEP,
22
- cycleReviewPanelMode,
23
- resolveReviewWidth,
24
- type ReviewPanelMode,
25
- } from "./review-panel";
19
+
20
+ /** Which pane of the session tree / review the keyboard grammar is aimed at. */
21
+ export type RailTab = "review" | "agent" | "tree";
26
22
 
27
23
  /** The one overlay/mode the TUI is in; every compose/submit/edit flow is a variant. */
28
24
  export type Mode =
@@ -41,6 +37,7 @@ export type Mode =
41
37
  | { type: "submit"; verdict: VerdictKind; summary: string }
42
38
  | { type: "confirmDelete"; sessionId: string; title: string }
43
39
  | { type: "rename"; authorId: string; text: string }
40
+ | { type: "renameThread"; sessionId: string; text: string }
44
41
  | { type: "nameSelf"; text: string }
45
42
  | { type: "treePrompt"; ask: TreeAsk; entryId?: string; text: string };
46
43
 
@@ -87,14 +84,11 @@ export interface IntentDispatchDeps {
87
84
  inboxCursor: number;
88
85
  mode: Mode;
89
86
  session: ReviewSession | null;
90
- reviewMode: ReviewPanelMode;
91
- reviewWidth: number;
92
- terminalWidth: number;
93
87
  focusedAnnotationId: string | undefined;
94
- /** The curation item selected in the rail, if any; the undo target when set. */
88
+ /** The curation item selected for undo, if any. */
95
89
  selectedCurationId: string | undefined;
96
90
  railTab: RailTab;
97
- /** The tree row selected in the rail's Tree tab; the target of go. */
91
+ /** The tree row selected in the session tree; the target of go. */
98
92
  selectedEntryId: string | undefined;
99
93
  /** Planner-local author renames, for seeding the rename prompt. */
100
94
  authorNames: Record<string, string>;
@@ -102,15 +96,14 @@ export interface IntentDispatchDeps {
102
96
  quickActions: QuickAction[];
103
97
  /** Persist an author rename and update the live overrides (App-owned). */
104
98
  renameAuthor: (id: string, name: string) => void;
99
+ /** Rename a thread's title through the daemon. */
100
+ renameThread: (id: string, title: string) => void;
105
101
 
106
102
  liveInput: MutableRefObject<string>;
107
- reviewWidthRef: MutableRefObject<number>;
108
103
 
109
104
  setCursor: Dispatch<SetStateAction<number>>;
110
105
  setInboxCursor: Dispatch<SetStateAction<number>>;
111
106
  setMode: Dispatch<SetStateAction<Mode>>;
112
- setReviewMode: Dispatch<SetStateAction<ReviewPanelMode>>;
113
- setReviewWidth: Dispatch<SetStateAction<number>>;
114
107
  setRailTab: Dispatch<SetStateAction<RailTab>>;
115
108
  setSelectedEntryId: Dispatch<SetStateAction<string | undefined>>;
116
109
  setFocusedAnnotationId: Dispatch<SetStateAction<string | undefined>>;
@@ -120,6 +113,8 @@ export interface IntentDispatchDeps {
120
113
  selectCardFromDocument: (annotationId: string) => void;
121
114
  runEditorHandOff: () => void;
122
115
  openCardEdit: (annotationId: string) => void;
116
+ /** Flip unified/split diff and persist it (App-owned); split lays out only when zoomed. */
117
+ toggleDiffView: () => void;
123
118
  }
124
119
 
125
120
  type IntentOfType<Kind extends Intent["type"]> = Extract<Intent, { type: Kind }>;
@@ -132,8 +127,55 @@ function handleStatus(intent: IntentOfType<"status">, deps: IntentDispatchDeps):
132
127
  deps.controller.setStatus(intent.message);
133
128
  }
134
129
 
130
+ /** A diff cursor rests on code lines only; file and hunk headers are structural dividers. */
131
+ function isCodeRow(row: DiffRow | undefined): boolean {
132
+ return row?.kind === "ctx" || row?.kind === "add" || row?.kind === "del";
133
+ }
134
+
135
+ /**
136
+ * A landable diff row: any code line, plus a collapsed file's band (a file row with
137
+ * no body before the next file or the end) so the cursor can reach it to expand it.
138
+ */
139
+ function isLandableRow(rows: DiffRow[], index: number): boolean {
140
+ const row = rows[index];
141
+
142
+ if (isCodeRow(row)) return true;
143
+ if (row?.kind !== "file") return false;
144
+ const next = rows[index + 1];
145
+
146
+ return next === undefined || next.kind === "file";
147
+ }
148
+
149
+ /** The next landable index in the move direction, skipping expanded headers; stays put at an edge. */
150
+ function nextCodeRowIndex(rows: DiffRow[], from: number, to: IntentOfType<"move">["to"]): number {
151
+ if (to === "top") {
152
+ for (let index = 0; index < rows.length; index++) if (isLandableRow(rows, index)) return index;
153
+
154
+ return from;
155
+ }
156
+ if (to === "bottom") {
157
+ for (let index = rows.length - 1; index >= 0; index--) {
158
+ if (isLandableRow(rows, index)) return index;
159
+ }
160
+
161
+ return from;
162
+ }
163
+ const step = to === "down" ? 1 : -1;
164
+
165
+ for (let index = from + step; index >= 0 && index < rows.length; index += step) {
166
+ if (isLandableRow(rows, index)) return index;
167
+ }
168
+
169
+ return from;
170
+ }
171
+
135
172
  function handleMove(intent: IntentOfType<"move">, deps: IntentDispatchDeps): void {
136
- const navigableCount = deps.isDiff ? deps.rows.length : deps.display.length;
173
+ if (deps.isDiff) {
174
+ deps.setCursor((current) => nextCodeRowIndex(deps.rows, current, intent.to));
175
+
176
+ return;
177
+ }
178
+ const navigableCount = deps.display.length;
137
179
 
138
180
  if (intent.to === "down") deps.setCursor((current) => Math.min(navigableCount - 1, current + 1));
139
181
  else if (intent.to === "up") deps.setCursor((current) => Math.max(0, current - 1));
@@ -194,6 +236,7 @@ function handleConfirmDialog(
194
236
 
195
237
  if (mode.type === "confirmDelete") controller.deleteSession(mode.sessionId);
196
238
  else if (mode.type === "rename") deps.renameAuthor(mode.authorId, mode.text.trim());
239
+ else if (mode.type === "renameThread") deps.renameThread(mode.sessionId, mode.text.trim());
197
240
  else if (mode.type === "nameSelf") controller.setSelfName(mode.text.trim());
198
241
  else if (mode.type === "treePrompt") confirmTreePrompt(mode, deps);
199
242
  deps.setMode({ type: "normal" });
@@ -335,11 +378,6 @@ function handleOpenSubmit(_intent: IntentOfType<"openSubmit">, deps: IntentDispa
335
378
 
336
379
  if (!session) return;
337
380
  deps.liveInput.current = "";
338
- // the confirm card lives in the expanded review rail; a compact or hidden
339
- // panel would swallow the whole submit flow, so force the rail open (live
340
- // only - the saved panel preference is left untouched)
341
- deps.setReviewMode("expanded");
342
- deps.setRailTab("review");
343
381
  deps.setMode({ type: "submit", verdict: defaultVerdict(session), summary: "" });
344
382
  }
345
383
 
@@ -359,6 +397,37 @@ function handleRejectChange(_intent: IntentOfType<"rejectChange">, deps: IntentD
359
397
  deps.controller.toggleRejectChange(deps.cursor);
360
398
  }
361
399
 
400
+ function handleFoldFile(_intent: IntentOfType<"foldFile">, deps: IntentDispatchDeps): void {
401
+ const file = deps.rows[deps.cursor]?.file;
402
+
403
+ if (!file || deps.controller.isFileCollapsed(file)) return;
404
+ deps.controller.setFileCollapsed(file, true);
405
+ // the file band is unchanged in index by its own collapse, so land the cursor on it
406
+ const headerIndex = deps.controller
407
+ .rows()
408
+ .findIndex((row) => row.kind === "file" && row.file === file);
409
+
410
+ if (headerIndex >= 0) deps.setCursor(headerIndex);
411
+ }
412
+
413
+ function handleUnfoldFile(_intent: IntentOfType<"unfoldFile">, deps: IntentDispatchDeps): void {
414
+ const file = deps.rows[deps.cursor]?.file;
415
+
416
+ if (!file || !deps.controller.isFileCollapsed(file)) return;
417
+ deps.controller.setFileCollapsed(file, false);
418
+ // drop the cursor onto the file's first code line now that its body is back
419
+ const firstCode = deps.controller.rows().findIndex((row) => row.file === file && isCodeRow(row));
420
+
421
+ if (firstCode >= 0) deps.setCursor(firstCode);
422
+ }
423
+
424
+ function handleToggleDiffView(
425
+ _intent: IntentOfType<"toggleDiffView">,
426
+ deps: IntentDispatchDeps,
427
+ ): void {
428
+ deps.toggleDiffView();
429
+ }
430
+
362
431
  function handleRestoreCuration(
363
432
  _intent: IntentOfType<"restoreCuration">,
364
433
  deps: IntentDispatchDeps,
@@ -502,31 +571,6 @@ function handleDismissCompletion(
502
571
  deps.controller.dismissCompletion();
503
572
  }
504
573
 
505
- function handleCycleReviewPanel(
506
- _intent: IntentOfType<"cycleReviewPanel">,
507
- deps: IntentDispatchDeps,
508
- ): void {
509
- const next = cycleReviewPanelMode(deps.reviewMode);
510
-
511
- deps.setReviewMode(next);
512
- deps.controller.saveReviewPanel({ mode: next });
513
- }
514
-
515
- function handleResizeReviewPanel(
516
- intent: IntentOfType<"resizeReviewPanel">,
517
- deps: IntentDispatchDeps,
518
- ): void {
519
- if (deps.reviewMode !== "expanded") return;
520
- const next = resolveReviewWidth(
521
- deps.reviewWidth + intent.direction * REVIEW_RESIZE_STEP,
522
- deps.terminalWidth,
523
- );
524
-
525
- deps.reviewWidthRef.current = next;
526
- deps.setReviewWidth(next);
527
- deps.controller.saveReviewPanel({ width: next });
528
- }
529
-
530
574
  function confirmTreePrompt(
531
575
  mode: Extract<Mode, { type: "treePrompt" }>,
532
576
  deps: IntentDispatchDeps,
@@ -613,6 +657,9 @@ const intentHandlers: IntentHandlers = {
613
657
  cut: handleCut,
614
658
  rejectHunk: handleRejectHunk,
615
659
  rejectChange: handleRejectChange,
660
+ foldFile: handleFoldFile,
661
+ unfoldFile: handleUnfoldFile,
662
+ toggleDiffView: handleToggleDiffView,
616
663
  restoreCuration: handleRestoreCuration,
617
664
  edit: handleEdit,
618
665
  editCard: handleEditCard,
@@ -631,8 +678,6 @@ const intentHandlers: IntentHandlers = {
631
678
  finishReview: handleFinishReview,
632
679
  optInAutoClose: handleOptInAutoClose,
633
680
  dismissCompletion: handleDismissCompletion,
634
- cycleReviewPanel: handleCycleReviewPanel,
635
- resizeReviewPanel: handleResizeReviewPanel,
636
681
  toggleTree: handleToggleTree,
637
682
  treeMove: handleTreeMove,
638
683
  treeGo: handleTreeGo,
@@ -23,7 +23,7 @@ describe("grammar-layer resolution", () => {
23
23
  ["g", true, "bottom"],
24
24
  ["v", false, "span"],
25
25
  ["c", false, "comment"],
26
- ["s", false, undefined],
26
+ ["s", false, "split_diff"],
27
27
  ["x", false, "cut"],
28
28
  ["e", false, "edit"],
29
29
  ["n", false, "next_annotation"],
@@ -389,6 +389,7 @@ describe("diff mode", () => {
389
389
  ["v", [{ type: "status", message: "plan-only primitive - diff review uses c on a line" }]],
390
390
  ["x", [{ type: "rejectChange" }]],
391
391
  ["e", [{ type: "status", message: "plan-only primitive - diff review uses c on a line" }]],
392
+ ["s", [{ type: "toggleDiffView" }]],
392
393
  ["q", [{ type: "exit" }]],
393
394
  ];
394
395
 
@@ -490,31 +491,8 @@ describe("guided walk", () => {
490
491
  });
491
492
  });
492
493
 
493
- describe("review panel controls", () => {
494
- const table: [string, Intent[]][] = [
495
- ["b", [{ type: "cycleReviewPanel" }]],
496
- ["]", [{ type: "resizeReviewPanel", direction: 1 }]],
497
- ["[", [{ type: "resizeReviewPanel", direction: -1 }]],
498
- ];
499
-
500
- for (const view of ["plan", "diff"] as const) {
501
- for (const [name, expected] of table) {
502
- test(`${view} ${name} -> ${JSON.stringify(expected)}`, () => {
503
- expect(reduceKey(state({ view }), key(name))).toEqual(expected);
504
- });
505
- }
506
- }
507
-
508
- test("observers may still collapse and resize the panel (it is view state, not a mutation)", () => {
509
- // Arrange
510
- const observer = state({ readOnly: true });
511
-
512
- // Assert
513
- expect(reduceKey(observer, key("b"))).toEqual([{ type: "cycleReviewPanel" }]);
514
- expect(reduceKey(observer, key("]"))).toEqual([{ type: "resizeReviewPanel", direction: 1 }]);
515
- });
516
-
517
- test("span mode still owns b as a span primitive, not a panel cycle", () => {
494
+ describe("span and walk own their keys", () => {
495
+ test("span mode owns b as a span primitive", () => {
518
496
  // Assert
519
497
  expect(reduceKey(state({ spanMode: true }), key("b"))).toEqual([
520
498
  { type: "spanKey", name: "b" },
package/src/keymap.ts CHANGED
@@ -33,6 +33,9 @@ export type Intent =
33
33
  | { type: "rejectHunk" }
34
34
  | { type: "rejectChange" }
35
35
  | { type: "restoreCuration" }
36
+ | { type: "foldFile" }
37
+ | { type: "unfoldFile" }
38
+ | { type: "toggleDiffView" }
36
39
  | { type: "nextAnnotation" }
37
40
  | { type: "prevAnnotation" }
38
41
  | { type: "walkStart" }
@@ -48,8 +51,6 @@ export type Intent =
48
51
  | { type: "finishReview" }
49
52
  | { type: "optInAutoClose" }
50
53
  | { type: "dismissCompletion" }
51
- | { type: "cycleReviewPanel" }
52
- | { type: "resizeReviewPanel"; direction: -1 | 1 }
53
54
  | { type: "toggleTree" }
54
55
  | { type: "treeMove"; direction: -1 | 1 }
55
56
  | { type: "treeGo" }
@@ -151,14 +152,8 @@ export function reduceKey(state: KeyState, key: KeyInput, resolvedAction?: strin
151
152
  }
152
153
 
153
154
  if (state.view === "inbox") return inboxGrammar(state, name);
154
- // span mode owns its single-letter keys (b slides the span back) before the
155
- // review-panel controls claim them
155
+ // span mode owns its single-letter keys (b slides the span back)
156
156
  if (state.spanMode) return spanGrammar(state, name);
157
- // the review panel rides both plan and diff reviews; collapsing and resizing
158
- // are view state, so the read-only gate above lets them through
159
- const reviewPanel = reviewPanelGrammar(action);
160
-
161
- if (reviewPanel) return reviewPanel;
162
157
  if (state.view === "diff") return diffGrammar(state, action);
163
158
 
164
159
  return planGrammar(state, action, name);
@@ -258,15 +253,6 @@ function walkOverlayGrammar(state: KeyState, key: KeyInput): Intent[] {
258
253
  return [];
259
254
  }
260
255
 
261
- /** The review-panel controls: cycle the mode, widen and narrow the rail. */
262
- function reviewPanelGrammar(action: string | undefined): Intent[] | null {
263
- if (action === "review_cycle") return [{ type: "cycleReviewPanel" }];
264
- if (action === "review_wider") return [{ type: "resizeReviewPanel", direction: 1 }];
265
- if (action === "review_narrower") return [{ type: "resizeReviewPanel", direction: -1 }];
266
-
267
- return null;
268
- }
269
-
270
256
  function inboxGrammar(state: KeyState, name: string): Intent[] {
271
257
  if (!state.hasInboxItems) return [];
272
258
  if (name === "j" || name === "down") return [{ type: "inboxMove", to: "down" }];
@@ -301,6 +287,9 @@ function diffGrammar(state: KeyState, action: string | undefined): Intent[] {
301
287
 
302
288
  return [{ type: action === "reject_hunk" ? "rejectHunk" : "rejectChange" }];
303
289
  }
290
+ const viewToggle = viewToggleIntent(action);
291
+
292
+ if (viewToggle) return viewToggle;
304
293
  // restore un-does a curated-out rejection from the rail; same owner gate as reject
305
294
  if (action === "restore_curation") {
306
295
  if (state.resolved) return status("review submitted - read-only");
@@ -382,6 +371,16 @@ function navigationIntent(action: string | undefined): Intent[] | null {
382
371
  return null;
383
372
  }
384
373
 
374
+ /** Ungated view toggles in the diff: right folds a file to its band, left unfolds it, s flips
375
+ * unified/split (split lays out only when the Changes pane is zoomed). */
376
+ function viewToggleIntent(action: string | undefined): Intent[] | null {
377
+ if (action === "collapse_file") return [{ type: "foldFile" }];
378
+ if (action === "expand_file") return [{ type: "unfoldFile" }];
379
+ if (action === "split_diff") return [{ type: "toggleDiffView" }];
380
+
381
+ return null;
382
+ }
383
+
385
384
  /** Annotation navigation, deletion, and submit: one path for plan and diff. */
386
385
  function annotationCluster(state: KeyState, action: string | undefined): Intent[] | null {
387
386
  if (action === "next_annotation" || action === "prev_annotation") {
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Mark geometry for an annotated line of text: which stretches are marked
3
+ * (a discussion span, a held selection) or carry the caret cell, the greedy
4
+ * word wrap into visual lines, and the printable-key test that starts a
5
+ * comment. Renderer-free, shared by the plan thread view and the diff sheet.
6
+ */
7
+
8
+ import type { KeyEvent } from "@opentui/core";
9
+ import { wordRanges } from "./thread-selection";
10
+
11
+ export interface MarkRange {
12
+ start: number;
13
+ end: number;
14
+ /** The bare caret's single cell - a cursor, not yet a mark. */
15
+ caretOnly?: boolean;
16
+ }
17
+
18
+ export interface Run {
19
+ text: string;
20
+ marked: boolean;
21
+ caretOnly: boolean;
22
+ }
23
+
24
+ /** Split a block's text into plain/marked runs from the union of ranges. */
25
+ export function runsFor(text: string, ranges: MarkRange[]): Run[] {
26
+ const cuts = new Set<number>([0, text.length]);
27
+
28
+ for (const range of ranges) {
29
+ cuts.add(Math.max(0, range.start));
30
+ cuts.add(Math.min(text.length, range.end));
31
+ }
32
+ const edges = [...cuts].toSorted((left, right) => left - right);
33
+ const runs: Run[] = [];
34
+
35
+ for (let index = 0; index < edges.length - 1; index++) {
36
+ const start = edges[index]!;
37
+ const end = edges[index + 1]!;
38
+
39
+ if (end <= start) continue;
40
+ const covering = ranges.filter((range) => range.start <= start && end <= range.end);
41
+ // the caret cell shows through a mark, so the head of a selection is visible
42
+ runs.push({
43
+ text: text.slice(start, end),
44
+ marked: covering.some((range) => range.caretOnly !== true),
45
+ caretOnly: covering.some((range) => range.caretOnly === true),
46
+ });
47
+ }
48
+
49
+ return runs.length > 0 ? runs : [{ text, marked: false, caretOnly: false }];
50
+ }
51
+
52
+ /** A visual line of a block: the char range of the block's text it shows. */
53
+ export interface VisualLine {
54
+ start: number;
55
+ end: number;
56
+ }
57
+
58
+ /**
59
+ * Greedy word wrap into visual-line char ranges, so a comment card can slot
60
+ * in directly below the line its mark ends on (mid-paragraph when needed).
61
+ */
62
+ export function wrapLines(text: string, width: number): VisualLine[] {
63
+ const lines: VisualLine[] = [];
64
+ let segmentStart = 0;
65
+
66
+ // hard breaks first: every visual line must be exactly one terminal row,
67
+ // or hit-testing and the mark geometry drift from what is painted
68
+ for (const segment of text.split("\n")) {
69
+ const segmentEnd = segmentStart + segment.length;
70
+
71
+ if (width <= 0 || segment.length <= width) {
72
+ lines.push({ start: segmentStart, end: segmentEnd });
73
+ } else {
74
+ // greedy word wrap inside the segment; the first line keeps its
75
+ // leading indentation, continuation lines start at a word
76
+ let lineStart = segmentStart;
77
+ let lineEnd = segmentStart;
78
+
79
+ for (const word of wordRanges(segment)) {
80
+ const wordStart = segmentStart + word.start;
81
+ const wordEnd = segmentStart + word.end;
82
+
83
+ if (wordEnd - lineStart > width && lineEnd > lineStart) {
84
+ lines.push({ start: lineStart, end: lineEnd });
85
+ lineStart = wordStart;
86
+ }
87
+ // a token wider than the line (a long identifier, a URL) breaks at the cell edge, so
88
+ // every visual line stays one terminal row and nothing is clipped away
89
+ while (wordEnd - lineStart > width) {
90
+ lines.push({ start: lineStart, end: lineStart + width });
91
+ lineStart += width;
92
+ }
93
+ lineEnd = wordEnd;
94
+ }
95
+ lines.push({ start: lineStart, end: Math.max(lineEnd, lineStart) });
96
+ }
97
+ segmentStart = segmentEnd + 1;
98
+ }
99
+
100
+ return lines;
101
+ }
102
+
103
+ /** The block's mark ranges clipped to one visual line, in that line's coordinates. */
104
+ export function lineMarkRanges(ranges: MarkRange[], line: VisualLine): MarkRange[] {
105
+ return ranges
106
+ .map((range) => ({
107
+ start: Math.max(range.start, line.start) - line.start,
108
+ end: Math.min(range.end, line.end) - line.start,
109
+ caretOnly: range.caretOnly,
110
+ }))
111
+ .filter((range) => range.end > range.start);
112
+ }
113
+
114
+ /** A printable character: single-width input that should reach a composer. */
115
+ export function printableSequence(key: KeyEvent): string | null {
116
+ const printable =
117
+ key.sequence && key.sequence.length === 1 && !key.ctrl && !key.meta && key.sequence >= " ";
118
+
119
+ return printable ? key.sequence : null;
120
+ }
package/src/pty.ts CHANGED
@@ -13,8 +13,10 @@ import { join } from "node:path";
13
13
 
14
14
  const DEFAULT_COLS = 80;
15
15
  const DEFAULT_ROWS = 24;
16
- /** How long the read loop sleeps when the child produced no output this tick. */
16
+ /** Shortest read-loop sleep, used while the child is actively streaming - keeps output snappy. */
17
17
  const READ_IDLE_MS = 8;
18
+ /** Longest read-loop sleep, reached after the child sits quiet - stops the loop burning a core at idle. */
19
+ const READ_IDLE_MAX_MS = 100;
18
20
  const READ_BUFFER_BYTES = 4096;
19
21
  /** cueloop_pty_read's sentinel: the child has exited and its output is drained. */
20
22
  const CHILD_EXITED = -2;
@@ -200,11 +202,15 @@ class Pty implements IPty {
200
202
  if (this.reading) return;
201
203
  this.reading = true;
202
204
  const buffer = Buffer.allocUnsafe(READ_BUFFER_BYTES);
205
+ // consecutive empty reads; the idle sleep backs off exponentially so a quiet agent prompt
206
+ // ramps from an 8ms hot poll up to a 100ms idle poll instead of spinning a core forever
207
+ let idlePolls = 0;
203
208
 
204
209
  while (!this.closing) {
205
210
  const count = this.lib.cueloop_pty_read(this.handle, ptr(buffer), buffer.length);
206
211
 
207
212
  if (count > 0) {
213
+ idlePolls = 0;
208
214
  // Stream mode buffers a multibyte char split across reads (box-drawing etc.).
209
215
  const text = this.decoder.decode(buffer.subarray(0, count), { stream: true });
210
216
 
@@ -221,7 +227,9 @@ class Pty implements IPty {
221
227
  this.closing = true;
222
228
  this.exitEvent.fire({ exitCode });
223
229
  } else {
224
- await new Promise((resolve) => setTimeout(resolve, READ_IDLE_MS));
230
+ const delay = Math.min(READ_IDLE_MAX_MS, READ_IDLE_MS * 2 ** Math.min(idlePolls, 5));
231
+ idlePolls += 1;
232
+ await new Promise((resolve) => setTimeout(resolve, delay));
225
233
  }
226
234
  }
227
235
  }
package/src/run.ts CHANGED
@@ -21,18 +21,28 @@ export async function runClient(options: RunClientOptions): Promise<number> {
21
21
  (await renderer.waitForThemeMode(THEME_QUERY_TIMEOUT_MS).catch(() => null)) ?? "dark";
22
22
 
23
23
  return new Promise<number>((resolve) => {
24
+ let exited = false;
25
+ const shutdown = (code: number): void => {
26
+ if (exited) return;
27
+ exited = true;
28
+ renderer.destroy();
29
+ resolve(code);
30
+ // one microtask between destroy and exit lets the renderer flush
31
+ // its terminal-restore sequences before the process dies
32
+ queueMicrotask(() => process.exit(code));
33
+ };
34
+
35
+ // a closed pane/terminal hangs up (or the parent kills us); exit instead of
36
+ // lingering as an orphan whose pty read loop keeps burning CPU
37
+ process.once("SIGHUP", () => shutdown(0));
38
+ process.once("SIGTERM", () => shutdown(0));
39
+
24
40
  createRoot(renderer).render(
25
41
  React.createElement(App, {
26
42
  home: options.home,
27
43
  sessionId: options.sessionId,
28
44
  appearance,
29
- onExit: (code: number) => {
30
- renderer.destroy();
31
- resolve(code);
32
- // one microtask between destroy and exit lets the renderer flush
33
- // its terminal-restore sequences before the process dies
34
- queueMicrotask(() => process.exit(code));
35
- },
45
+ onExit: shutdown,
36
46
  }),
37
47
  );
38
48
  });
package/src/serve.test.ts CHANGED
@@ -84,18 +84,14 @@ describe("cueloop serve", () => {
84
84
  expect(existsSync(join(home, "ssh", "host_key"))).toBe(true);
85
85
  });
86
86
 
87
- test("an anonymous ssh client gets the observer TUI for the session", async () => {
87
+ test("an anonymous ssh client gets the read-only TUI for the session", async () => {
88
88
  // Act
89
- const bytes = await sshCapture(
90
- handle.port,
91
- (frame) => frame.includes("Rollout Plan") && frame.includes("observer"),
92
- );
89
+ const bytes = await sshCapture(handle.port, (frame) => frame.includes("Rollout Plan"));
93
90
 
94
- // Assert
91
+ // Assert - the viewer sees the session; read-only is enforced behaviorally
92
+ // (covered by the observer-primitives tests), not by a header badge
95
93
  expect(bytes).toContain("cueloop");
96
94
  expect(bytes).toContain("Rollout Plan");
97
- // observer chrome: the header badge marks the read-only session
98
- expect(bytes).toContain("observer");
99
95
  });
100
96
 
101
97
  test("two observers can watch at once", async () => {
@@ -114,6 +114,7 @@ function fakeClient(initial: ReviewSession, sink: WorkingCopySink): SessionClien
114
114
  sessionLabel: unimplemented("sessionLabel"),
115
115
  sessionFork: unimplemented("sessionFork"),
116
116
  sessionSetViewed: unimplemented("sessionSetViewed"),
117
+ sessionSetTitle: unimplemented("sessionSetTitle"),
117
118
  sessionSetShareId: unimplemented("sessionSetShareId"),
118
119
  sessionMergeShared: unimplemented("sessionMergeShared"),
119
120
  sessionDelete: unimplemented("sessionDelete"),
@@ -360,3 +361,77 @@ describe("plan cut removals", () => {
360
361
  expect(sink.workingCopy).toBe("# Title\n");
361
362
  });
362
363
  });
364
+
365
+ // a file whose hunk touches line 2 only, so lines 1/3/4 are unchanged tail/context
366
+ const TAIL_PATCH = `diff --git a/src/store.ts b/src/store.ts
367
+ --- a/src/store.ts
368
+ +++ b/src/store.ts
369
+ @@ -2,1 +2,1 @@
370
+ - private items = [];
371
+ + private items = new Map();
372
+ `;
373
+
374
+ function tailSession(): ReviewSession {
375
+ return {
376
+ ...diffSession(),
377
+ artifact: {
378
+ type: "diff",
379
+ content: TAIL_PATCH,
380
+ meta: {},
381
+ files: [
382
+ {
383
+ path: "src/store.ts",
384
+ oldContents: "export class Store {\n private items = [];\n}\nexport const x = 1;\n",
385
+ newContents:
386
+ "export class Store {\n private items = new Map();\n}\nexport const x = 1;\n",
387
+ status: "modified",
388
+ },
389
+ ],
390
+ },
391
+ };
392
+ }
393
+
394
+ describe("diff file fold", () => {
395
+ test("collapse leaves only the file band; expand restores the body", async () => {
396
+ // Arrange
397
+ const { controller } = await connected(tailSession());
398
+ const bodyRows = () =>
399
+ controller.rows().filter((row) => row.file === "src/store.ts" && row.kind !== "file").length;
400
+
401
+ // Assert - starts unfolded with a body
402
+ expect(bodyRows()).toBeGreaterThan(0);
403
+
404
+ // Act / Assert - collapse hides the body
405
+ controller.setFileCollapsed("src/store.ts", true);
406
+ expect(bodyRows()).toBe(0);
407
+ expect(controller.isFileCollapsed("src/store.ts")).toBe(true);
408
+
409
+ // Act / Assert - unfold brings it back
410
+ controller.setFileCollapsed("src/store.ts", false);
411
+ expect(bodyRows()).toBeGreaterThan(0);
412
+ });
413
+
414
+ test("expand weaves the file's unchanged tail line the hunk never showed", async () => {
415
+ // Arrange
416
+ const { controller } = await connected(tailSession());
417
+ const hasTail = () => controller.rows().some((row) => row.text.includes("export const x = 1"));
418
+
419
+ // Assert - the hunk view does not show the tail line
420
+ expect(controller.canExpandFile("src/store.ts")).toBe(true);
421
+ expect(hasTail()).toBe(false);
422
+
423
+ // Act - weave the full file
424
+ controller.setFileExpanded("src/store.ts", true);
425
+
426
+ // Assert
427
+ expect(hasTail()).toBe(true);
428
+ });
429
+
430
+ test("a diff without full contents cannot be expanded", async () => {
431
+ // Arrange - no artifact.files
432
+ const { controller } = await connected(diffSession(undefined));
433
+
434
+ // Assert
435
+ expect(controller.canExpandFile("src/store.ts")).toBe(false);
436
+ });
437
+ });
@@ -78,6 +78,7 @@ function fakeClient(session: ReviewSession): FakeSessionClient {
78
78
  sessionLabel: unimplemented("sessionLabel"),
79
79
  sessionFork: unimplemented("sessionFork"),
80
80
  sessionSetViewed: unimplemented("sessionSetViewed"),
81
+ sessionSetTitle: unimplemented("sessionSetTitle"),
81
82
  sessionSetShareId: mock(
82
83
  async (_id: string, shareId: string) => ((session.shareId = shareId), session),
83
84
  ),