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

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 (68) hide show
  1. package/package.json +7 -6
  2. package/src/App.autoclose.test.tsx +2 -2
  3. package/src/App.export.test.tsx +2 -2
  4. package/src/App.plan-review.test.tsx +76 -247
  5. package/src/App.readonly.test.tsx +80 -35
  6. package/src/App.rendering.test.tsx +5 -6
  7. package/src/App.review-panel.test.tsx +29 -32
  8. package/src/App.submit-confirm.test.tsx +27 -12
  9. package/src/App.test.tsx +73 -49
  10. package/src/App.toast.test.tsx +34 -22
  11. package/src/App.tree.test.tsx +224 -0
  12. package/src/App.tsx +134 -87
  13. package/src/app-screens.tsx +27 -1
  14. package/src/app-view-model.ts +7 -109
  15. package/src/components/AnnotationCard.tsx +2 -2
  16. package/src/components/DiffSheet.scroll.test.tsx +5 -2
  17. package/src/components/DiffSheet.tsx +1 -6
  18. package/src/components/KeybindsDialog.tsx +61 -17
  19. package/src/components/PrototypeSheet.tsx +5 -9
  20. package/src/components/ReviewPanel.stories.tsx +14 -0
  21. package/src/components/ReviewRail.stories.tsx +14 -0
  22. package/src/components/ReviewRail.tsx +15 -6
  23. package/src/components/ThreadView.selection.test.tsx +812 -0
  24. package/src/components/ThreadView.stories.tsx +26 -0
  25. package/src/components/ThreadView.tsx +1620 -0
  26. package/src/components/TreePane.stories.tsx +106 -0
  27. package/src/components/TreePane.tsx +156 -0
  28. package/src/components/__snapshots__/stories.test.tsx.snap +120 -172
  29. package/src/components/primitives/Tabs.tsx +1 -1
  30. package/src/components/story-fixtures.ts +6 -2
  31. package/src/components/story.ts +16 -4
  32. package/src/components/syntax-highlight.ts +10 -2
  33. package/src/components/terminal-pane.test.tsx +1 -1
  34. package/src/components/terminal-pane.ts +7 -1
  35. package/src/config.ts +112 -99
  36. package/src/diff-hunk-curate.ts +4 -104
  37. package/src/editor.ts +5 -1
  38. package/src/ghostty-terminal.test.ts +1 -1
  39. package/src/herdr.ts +10 -2
  40. package/src/index.ts +1 -0
  41. package/src/intent-dispatch.test.ts +235 -55
  42. package/src/intent-dispatch.ts +104 -34
  43. package/src/key-bindings.test.ts +1 -1
  44. package/src/key-bindings.ts +16 -7
  45. package/src/keymap.test.ts +9 -11
  46. package/src/keymap.ts +14 -7
  47. package/src/kitty-image.test.ts +3 -3
  48. package/src/prototype-browser.ts +7 -6
  49. package/src/serve.ts +1 -1
  50. package/src/session-controller.curate.test.ts +106 -4
  51. package/src/session-controller.share.test.ts +220 -70
  52. package/src/session-controller.tree.test.ts +228 -0
  53. package/src/session-controller.ts +370 -94
  54. package/src/share.ts +117 -17
  55. package/src/test-support.test.ts +193 -1
  56. package/src/test-support.ts +69 -1
  57. package/src/theme-presets.ts +9 -2
  58. package/src/thread-chords.test.ts +154 -0
  59. package/src/thread-chords.ts +144 -0
  60. package/src/thread-selection.test.ts +210 -0
  61. package/src/thread-selection.ts +243 -0
  62. package/src/tree-view.test.ts +128 -0
  63. package/src/tree-view.ts +148 -0
  64. package/src/view-diff.ts +1 -4
  65. package/src/view-plan.ts +35 -19
  66. package/src/components/PlanSheet.stories.tsx +0 -113
  67. package/src/components/PlanSheet.tsx +0 -422
  68. package/src/components/plan-sheet-run-style.ts +0 -90
package/src/App.tsx CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * The cueloop TUI: state wiring, keymap dispatch, and layout composition -
3
3
  * nothing else. Rendering lives in components/, daemon IO and the mutation
4
- * verbs in session-controller.ts, the keyboard grammar in keymap.ts with
4
+ * primitives in session-controller.ts, the keyboard grammar in keymap.ts with
5
5
  * binding resolution and status hints from key-bindings.ts, and theming in
6
6
  * the ThemeProvider. Selection is the entry primitive (mouse drag or keyboard
7
7
  * span on one native renderer selection); annotation text lives in the rail
@@ -19,7 +19,7 @@ import React, {
19
19
  } from "react";
20
20
  import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react";
21
21
  import type { Clock, MouseEvent } from "@opentui/core";
22
- import { displayText, marksByDisplay, spanFromRange, type Mark } from "./view-plan";
22
+ import { marksByDisplay, type Mark } from "./view-plan";
23
23
  import { dimmedTheme } from "./theme";
24
24
  import {
25
25
  DEFAULT_KEYS,
@@ -37,21 +37,27 @@ import {
37
37
  type ThemeName,
38
38
  } from "./theme-presets";
39
39
  import type { Theme } from "./theme";
40
- import { createReviewController } from "./session-controller";
40
+ import { createReviewController, type ShareTransport } from "./session-controller";
41
41
  import type { SessionClient } from "@cueloop/daemon/client";
42
42
  import { launchHarnessInSplit } from "@cueloop/daemon/herdr-split";
43
- import { activeSpanState, createIntentDispatch, type Mode } from "./intent-dispatch";
44
- import { reduceKey } from "./keymap";
45
- import { KeyBindings } from "./key-bindings";
43
+ import { createIntentDispatch, type Mode } from "./intent-dispatch";
44
+ import { reduceKey, type KeyState } from "./keymap";
45
+ import { KeyBindings, type CheatsheetSection } from "./key-bindings";
46
46
  import { ThemeProvider } from "./components/theme-context";
47
47
  import { Button } from "./components/primitives/Button";
48
48
  import { Toolbar } from "./components/primitives/Toolbar";
49
49
  import { Breadcrumb } from "./components/Breadcrumb";
50
- import { PlanSheet, type PlanSheetHandle } from "./components/PlanSheet";
50
+ import { THREAD_VIEW_CHEATSHEET, ThreadView } from "./components/ThreadView";
51
+ import {
52
+ RAIL_CHORD_ENTRIES,
53
+ resolveThreadChord,
54
+ THREAD_CHORD_ENTRIES,
55
+ TREE_CHORD_ENTRIES,
56
+ } from "./thread-chords";
51
57
  import { DiffSheet } from "./components/DiffSheet";
52
58
  import { PrototypeSheet } from "./components/PrototypeSheet";
53
59
  import type { PrototypeElement } from "./prototype-browser";
54
- import { type ReviewRailHandle } from "./components/ReviewRail";
60
+ import { type RailTab, type ReviewRailHandle } from "./components/ReviewRail";
55
61
  import type { AgentTerminalHandle } from "./components/agent-launcher";
56
62
  import { ReviewPanel } from "./components/ReviewPanel";
57
63
  import {
@@ -62,12 +68,9 @@ import {
62
68
  type ReviewPanelMode,
63
69
  } from "./review-panel";
64
70
  import {
65
- buildActiveSpan,
66
71
  buildCardEditState,
67
- buildComposeState,
68
72
  buildDiffComposeState,
69
73
  buildHeaderItems,
70
- buildPopoverState,
71
74
  buildRenderFlags,
72
75
  buildSubmitConfirmState,
73
76
  computePendingCount,
@@ -96,7 +99,7 @@ export interface AppProps {
96
99
  home?: string;
97
100
  sessionId?: string;
98
101
  /**
99
- * Observer mode (SSH-served connections): every mutating verb is ignored and
102
+ * Observer mode (SSH-served connections): every mutating primitive is ignored and
100
103
  * answers "observer - read-only" in the status line; navigation still works.
101
104
  */
102
105
  readOnly?: boolean;
@@ -105,6 +108,7 @@ export interface AppProps {
105
108
  clock?: Clock;
106
109
  /** Session source; the sharing gateway injects a blob-backed client. */
107
110
  openClient?: () => Promise<SessionClient>;
111
+ shareTransport?: ShareTransport;
108
112
  /**
109
113
  * Who is at the keyboard. `owner` is the local planner (default). `observer`
110
114
  * is a passive `cueloop serve` watcher (read-only). `collaborator` is a share
@@ -124,6 +128,37 @@ export interface AppProps {
124
128
  appearance?: Appearance;
125
129
  }
126
130
 
131
+ /** True while the drop-up or one of its dialogs is open and owns the keyboard. */
132
+ function menuChromeOpen(menuOpen: boolean, menuDialog: "keybinds" | "settings" | null): boolean {
133
+ return menuOpen || menuDialog !== null;
134
+ }
135
+
136
+ /** True while a menu, an overlay, or the agent terminal owns the keyboard instead of the thread view. */
137
+ function keyboardOwnedElsewhere(
138
+ menuOwnsKeyboard: boolean,
139
+ overlay: KeyState["overlay"],
140
+ agentTerminal: AgentTerminalHandle | null,
141
+ ): boolean {
142
+ return menuOwnsKeyboard || overlay !== "none" || agentTerminal !== null;
143
+ }
144
+
145
+ /** The keybinds dialog content: the thread grammar while the thread view owns the keys. */
146
+ function cheatsheetFor(keyBindings: KeyBindings, threadViewActive: boolean): CheatsheetSection[] {
147
+ const base = keyBindings.cheatsheet();
148
+
149
+ if (!threadViewActive) {
150
+ return base;
151
+ }
152
+
153
+ return [
154
+ ...THREAD_VIEW_CHEATSHEET,
155
+ { title: "Session", entries: [...THREAD_CHORD_ENTRIES] },
156
+ { title: "Rail", entries: [...RAIL_CHORD_ENTRIES] },
157
+ { title: "Tree", entries: [...TREE_CHORD_ENTRIES] },
158
+ ...base.filter((section) => section.title === "Agent terminal"),
159
+ ];
160
+ }
161
+
127
162
  export function App({
128
163
  home,
129
164
  sessionId,
@@ -131,6 +166,7 @@ export function App({
131
166
  onExit,
132
167
  clock,
133
168
  openClient,
169
+ shareTransport,
134
170
  role = "owner",
135
171
  selfAuthor,
136
172
  appearance = "dark",
@@ -138,7 +174,15 @@ export function App({
138
174
  const { observer, isOwner } = computeRoleCapabilities(readOnly, role);
139
175
  const controller = useMemo(
140
176
  () =>
141
- createReviewController({ home, sessionId, readOnly: observer, onExit, clock, openClient }),
177
+ createReviewController({
178
+ home,
179
+ sessionId,
180
+ readOnly: observer,
181
+ onExit,
182
+ clock,
183
+ openClient,
184
+ shareTransport,
185
+ }),
142
186
  // eslint-disable-next-line react-hooks/exhaustive-deps
143
187
  [home, sessionId],
144
188
  );
@@ -163,7 +207,7 @@ export function App({
163
207
  useEffect(() => {
164
208
  if (!isOwner || !session?.shareId) return;
165
209
 
166
- return controller.startSharePoll();
210
+ return controller.startShareSync();
167
211
  }, [isOwner, session?.id, session?.shareId, controller]);
168
212
  const renderer = useRenderer();
169
213
  const { width: terminalWidth } = useTerminalDimensions();
@@ -178,7 +222,9 @@ export function App({
178
222
  const [autoClose, setAutoClose] = useState<AutoClose>("off");
179
223
  const [focusedAnnotationId, setFocusedAnnotationId] = useState<string | undefined>(undefined);
180
224
  const [selectedCurationId, setSelectedCurationId] = useState<string | undefined>(undefined);
181
- const [railTab, setRailTab] = useState<"review" | "agent">("review");
225
+ const [railTab, setRailTab] = useState<RailTab>("review");
226
+ // the tree row the reviewer stands on in the rail's Tree tab
227
+ const [selectedEntryId, setSelectedEntryId] = useState<string | undefined>(undefined);
182
228
  // A running in-tab agent terminal (embedded harness); while set, keys route to it.
183
229
  const [agentTerminal, setAgentTerminal] = useState<AgentTerminalHandle | null>(null);
184
230
  // review panel layout: mode + expanded width are client view state, loaded
@@ -194,7 +240,6 @@ export function App({
194
240
  // live mirror of overlay input text: refs commit synchronously, so the
195
241
  // RETURN handler never reads a stale value mid-typing
196
242
  const liveInput = useRef("");
197
- const planSheetRef = useRef<PlanSheetHandle | null>(null);
198
243
  const railRef = useRef<ReviewRailHandle | null>(null);
199
244
  // keymap from layered config; the loaded theme swaps the provider value
200
245
  const keysRef = useRef(DEFAULT_KEYS);
@@ -295,6 +340,9 @@ export function App({
295
340
  return ids;
296
341
  }, [marks]);
297
342
  const { isDiff, isPrototype, resolved } = deriveReviewFlags(session);
343
+ // plans and replies open in the thread view; diffs and prototypes keep their sheets
344
+ const threadViewActive = session !== null && !isDiff && !isPrototype;
345
+ const [threadComposing, setThreadComposing] = useState(false);
298
346
  const [prototypeComposing, setPrototypeComposing] = useState(false);
299
347
  // sort position per annotation so the rail interleaves annotation and removal
300
348
  // cards in one line-ordered stack: a diff row carries its blockIndex; a plan
@@ -305,7 +353,8 @@ export function App({
305
353
  if (!session) return positions;
306
354
  if (isDiff) {
307
355
  for (const annotation of session.annotations) {
308
- const blockIndex = (annotation.anchor as { blockIndex?: number }).blockIndex;
356
+ const blockIndex =
357
+ "blockIndex" in annotation.anchor ? annotation.anchor.blockIndex : undefined;
309
358
 
310
359
  if (blockIndex !== undefined) positions.set(annotation.id, blockIndex);
311
360
  }
@@ -324,18 +373,6 @@ export function App({
324
373
  const walking = isWalking(isDiff, walk);
325
374
  const viewedPaths = useMemo(() => new Set(session?.viewedPaths ?? []), [session]);
326
375
 
327
- // driving needs committed layout, so it runs after render; any transition
328
- // out of span mode clears the renderer selection (compose paints its own
329
- // mark, and a mouse drag never changes the mode, so it survives)
330
- useEffect(() => {
331
- // one marker at a time: clear any prior selection, then paint the current
332
- // span (span and its quick-actions sub-mode both keep it painted)
333
- const span = activeSpanState(mode);
334
-
335
- planSheetRef.current?.clearSelection();
336
- if (span) planSheetRef.current?.driveSpanSelection(span);
337
- }, [mode]);
338
-
339
376
  // ── selection symmetry: one selected id, both sides ──
340
377
  const pulse = (id: string): void => {
341
378
  setPulsedAnnotationId(id);
@@ -348,7 +385,6 @@ export function App({
348
385
  for (const [displayIndex, blockMarks] of marks) {
349
386
  if (!blockMarks.some((mark) => mark.annotationId === annotationId)) continue;
350
387
  setCursor(displayIndex);
351
- planSheetRef.current?.revealBlock(displayIndex);
352
388
 
353
389
  return;
354
390
  }
@@ -374,7 +410,6 @@ export function App({
374
410
 
375
411
  if (!item) return;
376
412
  setCursor(item.revealIndex);
377
- if (item.source === "plan") planSheetRef.current?.revealBlock(item.revealIndex);
378
413
  };
379
414
 
380
415
  // the selected removal card's undo button: same restore path as the u key,
@@ -433,6 +468,8 @@ export function App({
433
468
  terminalWidth,
434
469
  focusedAnnotationId,
435
470
  selectedCurationId,
471
+ railTab,
472
+ selectedEntryId,
436
473
  authorNames,
437
474
  quickActions,
438
475
  renameAuthor: (id: string, name: string) => {
@@ -441,13 +478,13 @@ export function App({
441
478
  },
442
479
  liveInput,
443
480
  reviewWidthRef,
444
- planSheetRef,
445
481
  setCursor,
446
482
  setInboxCursor,
447
483
  setMode,
448
484
  setReviewMode,
449
485
  setReviewWidth,
450
486
  setRailTab,
487
+ setSelectedEntryId,
451
488
  setFocusedAnnotationId,
452
489
  setSelectedCurationId,
453
490
  setPulsedAnnotationId,
@@ -458,7 +495,28 @@ export function App({
458
495
 
459
496
  const overlay = resolveOverlay(mode, completion.phase, walking);
460
497
 
498
+ const menuOwnsKeyboard = menuChromeOpen(menuOpen, menuDialog);
499
+ // an overlay (submit, walk, prompt, confirm) or the menu takes the keyboard
500
+ // from the thread view; the view suspends its own grammar meanwhile
501
+ const threadViewSuspended = keyboardOwnedElsewhere(menuOwnsKeyboard, overlay, agentTerminal);
502
+
461
503
  useKeyboard((key) => {
504
+ // The thread view owns the document grammar while active (its own
505
+ // useKeyboard handles marks, comments, and ctrl+q); the session chords
506
+ // (submit, share, edit, walk, the rail) resolve here, and the keymap only
507
+ // sees keys while an overlay or the menu owns them.
508
+ if (threadViewActive && !threadViewSuspended) {
509
+ const chord = resolveThreadChord(key, {
510
+ composing: threadComposing,
511
+ isOwner,
512
+ resolved,
513
+ treeActive: railTab === "tree",
514
+ });
515
+
516
+ if (chord) dispatch(chord);
517
+
518
+ return;
519
+ }
462
520
  // The prototype compose textarea owns the keyboard while open: let it receive
463
521
  // the typed note instead of the global keymap acting on each letter.
464
522
  if (prototypeComposing) return;
@@ -515,7 +573,7 @@ export function App({
515
573
  theme={theme}
516
574
  setMenuOpen={setMenuOpen}
517
575
  setMenuDialog={setMenuDialog}
518
- keybindsSections={keyBindings.cheatsheet()}
576
+ keybindsSections={cheatsheetFor(keyBindings, threadViewActive)}
519
577
  settingsCategories={settingsCategories}
520
578
  settingsValues={settingsValues}
521
579
  settingsNav={settingsNav}
@@ -554,7 +612,6 @@ export function App({
554
612
  />
555
613
  );
556
614
 
557
- const composeState = buildComposeState({ mode, isDiff, display, liveInput, setMode, dispatch });
558
615
  const diffComposeState = buildDiffComposeState({
559
616
  mode,
560
617
  isDiff,
@@ -563,17 +620,6 @@ export function App({
563
620
  setMode,
564
621
  dispatch,
565
622
  });
566
- const activeSpan = buildActiveSpan(mode, isDiff);
567
- const popoverState = buildPopoverState({
568
- mode,
569
- isDiff,
570
- quickActions,
571
- isOwner,
572
- observer,
573
- resolved,
574
- controller,
575
- dispatch,
576
- });
577
623
  const submitConfirmState = buildSubmitConfirmState({
578
624
  mode,
579
625
  isDiff,
@@ -600,39 +646,6 @@ export function App({
600
646
  resolvedIds,
601
647
  });
602
648
 
603
- // a mouse drag leaves a native selection: turn it into a word span so the
604
- // marker popover opens at the dragged range, mirroring the `v` grammar.
605
- // Returns whether a native selection existed (the release ended a drag).
606
- const activateSpanFromSelection = (preferredIndex?: number): boolean => {
607
- if (!renderer?.hasSelection) return false;
608
- // the release block re-anchors the span, so each drag replaces the last
609
- const selection = planSheetRef.current?.readSelection(preferredIndex);
610
- const block = selection ? display[selection.displayIndex] : undefined;
611
- const span =
612
- selection && block
613
- ? spanFromRange(selection.displayIndex, displayText(block), selection.start, selection.end)
614
- : null;
615
-
616
- if (span) setMode({ type: "span", span });
617
-
618
- return true;
619
- };
620
-
621
- const onLineActivate = (displayIndex: number): void => {
622
- if (activateSpanFromSelection(displayIndex)) return;
623
- setCursor(displayIndex);
624
- const annotationId = marks.get(displayIndex)?.[0]?.annotationId;
625
-
626
- if (annotationId) selectCardFromDocument(annotationId);
627
- };
628
-
629
- // a drag released outside any block's text (the gutter, past a line end, a
630
- // gap between blocks) never reaches a block handler; the sheet-level release
631
- // still turns the finished drag into a span
632
- const onSelectionRelease = (): void => {
633
- activateSpanFromSelection();
634
- };
635
-
636
649
  const onEditRequest = (): void => {
637
650
  // A share viewer/observer has no Edit affordance (the button is hidden), so
638
651
  // this is owner-only; stay silent rather than nag if it is ever reached.
@@ -734,18 +747,37 @@ export function App({
734
747
  theme={walking ? dimmedTheme(theme) : undefined}
735
748
  />
736
749
  ) : (
737
- <PlanSheet
738
- ref={planSheetRef}
750
+ <ThreadView
739
751
  session={activeSession}
752
+ suspended={threadViewSuspended}
753
+ editOrphanCount={editOrphanCount}
754
+ onComposingChange={setThreadComposing}
755
+ resolved={resolved}
756
+ onObserverBlocked={(reason) =>
757
+ controller.setStatus(
758
+ reason === "observer" ? "observer - read-only" : "review submitted - read-only",
759
+ )
760
+ }
761
+ onCursorChange={setCursor}
762
+ focusedAnnotationId={focusedAnnotationId}
763
+ onFocusAnnotation={setFocusedAnnotationId}
740
764
  display={display}
741
765
  marks={marks}
742
- cursor={cursor}
743
- activeSpan={activeSpan}
744
- compose={composeState}
745
- popover={popoverState}
746
- editOrphanCount={editOrphanCount}
747
- onLineActivate={onLineActivate}
748
- onSelectionRelease={onSelectionRelease}
766
+ quickActions={quickActions}
767
+ observer={observer}
768
+ onAnnotate={(span, body) =>
769
+ void controller.annotate(
770
+ "comment",
771
+ span.start.blockIndex,
772
+ span.start.char,
773
+ span.end.char,
774
+ body,
775
+ span.end.blockIndex,
776
+ )
777
+ }
778
+ onReply={(rootAnnotationId, body) => void controller.reply(rootAnnotationId, body)}
779
+ onUpdateAnnotation={(id, body) => controller.updateAnnotation(id, body)}
780
+ onExit={() => onExit?.(0)}
749
781
  />
750
782
  )}
751
783
  <ReviewPanel
@@ -779,6 +811,21 @@ export function App({
779
811
  seedText,
780
812
  }),
781
813
  onAgentTerminal: setAgentTerminal,
814
+ tree: {
815
+ rows: controller.treeRows(),
816
+ selectedEntryId,
817
+ canMove: isOwner && !resolved,
818
+ canFork: isOwner,
819
+ onSelect: setSelectedEntryId,
820
+ onGo: (entryId) => {
821
+ setSelectedEntryId(entryId);
822
+ dispatch({ type: "treeGo" });
823
+ },
824
+ onBranch: () => dispatch({ type: "treeBranch" }),
825
+ onLabel: () => dispatch({ type: "treeLabel" }),
826
+ onFork: () => dispatch({ type: "treeFork" }),
827
+ onForkAndShare: () => dispatch({ type: "treeForkShare" }),
828
+ },
782
829
  }}
783
830
  />
784
831
  </box>
@@ -2,7 +2,7 @@ import React, { type Dispatch, type SetStateAction } from "react";
2
2
  import type { ReviewSession, VerdictKind } from "@cueloop/schema";
3
3
  import { returnPaneFor } from "@cueloop/schema";
4
4
  import type { Theme } from "./theme";
5
- import type { Mode } from "./intent-dispatch";
5
+ import type { Mode, TreeAsk } from "./intent-dispatch";
6
6
  import type { Intent } from "./keymap";
7
7
  import type { ReviewController, ToastState } from "./session-controller";
8
8
  import { noteForFile } from "./walk";
@@ -195,6 +195,21 @@ export function CompletionScreen(props: {
195
195
  );
196
196
  }
197
197
 
198
+ /** The words each tree prompt uses; enter with an empty summary moves back without one. */
199
+ const TREE_PROMPTS: Record<TreeAsk, { title: string; label: string; placeholder: string }> = {
200
+ branch: { title: " Branch ", label: "Name for the new branch:", placeholder: "alt" },
201
+ label: {
202
+ title: " Checkpoint ",
203
+ label: "Name for this checkpoint:",
204
+ placeholder: "before the rewrite",
205
+ },
206
+ navigate: {
207
+ title: " Move back ",
208
+ label: "Summary of what you leave behind (optional):",
209
+ placeholder: "tried a shorter plan",
210
+ },
211
+ };
212
+
198
213
  export function TrailingOverlays(props: {
199
214
  walking: boolean;
200
215
  walk: { index: number } | null;
@@ -264,6 +279,17 @@ export function TrailingOverlays(props: {
264
279
  theme={theme}
265
280
  />
266
281
  ) : null}
282
+ {mode.type === "treePrompt" ? (
283
+ <PromptDialog
284
+ isOpen
285
+ title={TREE_PROMPTS[mode.ask].title}
286
+ label={TREE_PROMPTS[mode.ask].label}
287
+ value={mode.text}
288
+ placeholder={TREE_PROMPTS[mode.ask].placeholder}
289
+ onInput={(text) => setMode({ ...mode, text })}
290
+ theme={theme}
291
+ />
292
+ ) : null}
267
293
  {toast ? <Toast title={toast.title} body={toast.body} theme={theme} /> : null}
268
294
  </>
269
295
  );
@@ -1,14 +1,11 @@
1
1
  import type { Dispatch, MutableRefObject, SetStateAction } from "react";
2
2
  import type { ReviewSession, VerdictKind } from "@cueloop/schema";
3
- import { activeSpanState, reviewerAnnotations, type Mode } from "./intent-dispatch";
4
- import { displayText, type DisplayBlock } from "./view-plan";
3
+ import { reviewerAnnotations, type Mode } from "./intent-dispatch";
5
4
  import type { DiffRow } from "./view-diff";
6
5
  import type { Intent, KeyState } from "./keymap";
7
- import type { Completion, ReviewController } from "./session-controller";
8
- import type { QuickAction } from "./config";
6
+ import type { Completion } from "./session-controller";
9
7
  import type { WalkFile } from "./walk";
10
8
  import { viewedCount } from "./walk";
11
- import type { PlanComposeState, PlanPopoverState } from "./components/PlanSheet";
12
9
  import type { DiffComposeState } from "./components/DiffSheet";
13
10
  import type { ConfirmCardProps } from "./components/ConfirmCard";
14
11
  import type { RailCardEdit } from "./components/ReviewRail";
@@ -18,17 +15,13 @@ import { REVIEW_COMPACT_WIDTH, resolveReviewWidth, type ReviewPanelMode } from "
18
15
  export function computeRoleCapabilities(
19
16
  readOnly: boolean,
20
17
  role: "owner" | "observer" | "collaborator",
21
- ): { observer: boolean; isOwner: boolean } {
18
+ ) {
22
19
  const observer = readOnly || role === "observer";
23
20
 
24
21
  return { observer, isOwner: !observer && role === "owner" };
25
22
  }
26
23
 
27
- export function deriveReviewFlags(session: ReviewSession | null): {
28
- isDiff: boolean;
29
- isPrototype: boolean;
30
- resolved: boolean;
31
- } {
24
+ export function deriveReviewFlags(session: ReviewSession | null) {
32
25
  return {
33
26
  isDiff: session?.artifact.type === "diff",
34
27
  isPrototype: session?.artifact.type === "prototype",
@@ -48,7 +41,8 @@ export function resolveOverlay(
48
41
  if (mode.type === "compose" || mode.type === "railEdit") return "compose";
49
42
  if (mode.type === "submit") return "submit";
50
43
  if (mode.type === "confirmDelete") return "confirm";
51
- if (mode.type === "rename" || mode.type === "nameSelf") return "prompt";
44
+ if (mode.type === "rename" || mode.type === "nameSelf" || mode.type === "treePrompt")
45
+ return "prompt";
52
46
  if (mode.type === "spanActions") return "spanActions";
53
47
  if (completionPhase === "prompt") return "completion-prompt";
54
48
  if (completionPhase === "counting") return "completion-counting";
@@ -121,13 +115,7 @@ export function buildRenderFlags(params: {
121
115
  menuOpen: boolean;
122
116
  menuDialog: "keybinds" | "settings" | null;
123
117
  resolvedIds: Set<string>;
124
- }): {
125
- showOwnerActions: boolean;
126
- prototypeCanComment: boolean;
127
- chromeHidden: boolean;
128
- prototypePath: string;
129
- railResolvedIds: Set<string> | null;
130
- } {
118
+ }) {
131
119
  const { session, isOwner, isDiff, isPrototype, resolved, menuOpen, menuDialog, resolvedIds } =
132
120
  params;
133
121
 
@@ -140,49 +128,12 @@ export function buildRenderFlags(params: {
140
128
  };
141
129
  }
142
130
 
143
- export function buildActiveSpan(
144
- mode: Mode,
145
- isDiff: boolean,
146
- ): { displayIndex: number; start: number; end: number } | null {
147
- const markedSpan = activeSpanState(mode);
148
-
149
- if (markedSpan)
150
- return { displayIndex: markedSpan.displayIndex, start: markedSpan.start, end: markedSpan.end };
151
- if (mode.type === "compose" && !isDiff)
152
- return { displayIndex: mode.displayIndex, start: mode.start, end: mode.end };
153
-
154
- return null;
155
- }
156
-
157
131
  interface DraftHandlerDeps {
158
132
  liveInput: MutableRefObject<string>;
159
133
  setMode: Dispatch<SetStateAction<Mode>>;
160
134
  dispatch: (intent: Intent) => void;
161
135
  }
162
136
 
163
- export function buildComposeState(
164
- deps: DraftHandlerDeps & { mode: Mode; isDiff: boolean; display: DisplayBlock[] },
165
- ): PlanComposeState | null {
166
- const { mode, isDiff, display, liveInput, setMode, dispatch } = deps;
167
-
168
- if (mode.type !== "compose" || isDiff) return null;
169
-
170
- return {
171
- kind: mode.kind,
172
- displayIndex: mode.displayIndex,
173
- quote: displayText(display[mode.displayIndex]!).slice(mode.start, mode.end),
174
- draft: {
175
- text: mode.text,
176
- onInput: (text: string) => {
177
- liveInput.current = text;
178
- setMode({ ...mode, text });
179
- },
180
- onSave: () => dispatch({ type: "saveCompose" }),
181
- onCancel: () => dispatch({ type: "closeOverlay" }),
182
- },
183
- };
184
- }
185
-
186
137
  export function buildDiffComposeState(
187
138
  deps: DraftHandlerDeps & { mode: Mode; isDiff: boolean; rows: DiffRow[] },
188
139
  ): DiffComposeState | null {
@@ -206,59 +157,6 @@ export function buildDiffComposeState(
206
157
  };
207
158
  }
208
159
 
209
- export function buildPopoverState(deps: {
210
- mode: Mode;
211
- isDiff: boolean;
212
- quickActions: QuickAction[];
213
- isOwner: boolean;
214
- observer: boolean;
215
- resolved: boolean;
216
- controller: ReviewController;
217
- dispatch: (intent: Intent) => void;
218
- }): PlanPopoverState | null {
219
- const { mode, isDiff, quickActions, isOwner, observer, resolved, controller, dispatch } = deps;
220
- const markedSpan = activeSpanState(mode);
221
-
222
- if (!markedSpan || isDiff) return null;
223
- const spanMutationBlock = (): string | null =>
224
- observer ? "observer - read-only" : resolved ? "review submitted - read-only" : null;
225
-
226
- return {
227
- displayIndex: markedSpan.displayIndex,
228
- view: mode.type === "spanActions" ? ("actions" as const) : ("toolbar" as const),
229
- actions: quickActions,
230
- actionIndex: mode.type === "spanActions" ? mode.index : 0,
231
- canCut: isOwner,
232
- onComment: () => {
233
- const blocked = spanMutationBlock();
234
-
235
- if (blocked) return controller.setStatus(blocked);
236
- dispatch({ type: "openCompose", kind: "comment", from: "span" });
237
- },
238
- onCut: () => {
239
- const blocked = spanMutationBlock();
240
-
241
- if (blocked) return controller.setStatus(blocked);
242
- if (!isOwner) return;
243
- dispatch({ type: "spanCut" });
244
- },
245
- onOpenActions: () => {
246
- const blocked = spanMutationBlock();
247
-
248
- if (blocked) return controller.setStatus(blocked);
249
- dispatch({ type: "openSpanActions" });
250
- },
251
- onClose: () => dispatch({ type: "closeOverlay" }),
252
- onPickAction: (index: number) => {
253
- const blocked = spanMutationBlock();
254
-
255
- if (blocked) return controller.setStatus(blocked);
256
- dispatch({ type: "pickSpanAction", index });
257
- },
258
- onBack: () => dispatch({ type: "closeSpanActions" }),
259
- };
260
- }
261
-
262
160
  export function buildSubmitConfirmState(
263
161
  deps: DraftHandlerDeps & {
264
162
  mode: Mode;
@@ -181,11 +181,11 @@ export function AnnotationCard({
181
181
  );
182
182
 
183
183
  if (draft) {
184
- const verb = "comment on";
184
+ const primitive = "comment on";
185
185
 
186
186
  return (
187
187
  <Card
188
- title={` ${verb} "${truncateToSingleLine(quote, 40)}" `}
188
+ title={` ${primitive} "${truncateToSingleLine(quote, 40)}" `}
189
189
  contentRows={editorRowCount + 1}
190
190
  borderColor={kindColor}
191
191
  backgroundColor="transparent"
@@ -4,7 +4,7 @@ import { expect, test } from "bun:test";
4
4
  import React, { useState } from "react";
5
5
  import { testRender } from "@opentui/react/test-utils";
6
6
  import { useKeyboard } from "@opentui/react";
7
- import type { Renderable, ScrollBoxRenderable } from "@opentui/core";
7
+ import { ScrollBoxRenderable, type Renderable } from "@opentui/core";
8
8
  import type { Annotation } from "@cueloop/schema";
9
9
  import { DiffSheet } from "./DiffSheet";
10
10
  import { diffRows, type DiffRow } from "../view-diff";
@@ -82,7 +82,10 @@ test("scrolling down past wrapped annotation cards keeps the cursor pinned and t
82
82
  });
83
83
 
84
84
  await setup.waitForVisualIdle();
85
- const scrollbox = findById(setup.renderer.root, "diff-scroll") as ScrollBoxRenderable;
85
+ const found = findById(setup.renderer.root, "diff-scroll");
86
+
87
+ if (!(found instanceof ScrollBoxRenderable)) throw new Error("diff-scroll is not a scrollbox");
88
+ const scrollbox = found;
86
89
 
87
90
  // the cursor row is the one painted with the cursor background
88
91
  const cursorScreenRow = (): number => {
@@ -60,12 +60,7 @@ export interface DiffSheetProps {
60
60
  }
61
61
 
62
62
  /** The sign, base color, and background a row draws with. */
63
- function rowStyle(
64
- row: DiffRow,
65
- isCursorRow: boolean,
66
- isAnnotatedRow: boolean,
67
- tokens: Theme,
68
- ): { sign: string; baseColor: string; background: string | undefined } {
63
+ function rowStyle(row: DiffRow, isCursorRow: boolean, isAnnotatedRow: boolean, tokens: Theme) {
69
64
  const sign = row.kind === "add" ? "+" : row.kind === "del" ? "-" : " ";
70
65
  const baseColor =
71
66
  row.kind === "add"