@cueloop/client 0.1.0-alpha.66 → 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 (115) hide show
  1. package/package.json +6 -5
  2. package/src/App.autoclose.test.tsx +2 -2
  3. package/src/App.diff.test.tsx +71 -39
  4. package/src/App.plan-review.test.tsx +20 -21
  5. package/src/App.readonly.test.tsx +7 -3
  6. package/src/App.test.tsx +73 -14
  7. package/src/App.tsx +375 -129
  8. package/src/App.walk.test.tsx +14 -15
  9. package/src/App.workbench.test.tsx +390 -0
  10. package/src/__snapshots__/view-plan.regression.test.ts.snap +1 -1
  11. package/src/annotation-palette.ts +61 -0
  12. package/src/app-screens.tsx +176 -45
  13. package/src/app-view-model.ts +7 -65
  14. package/src/components/AnnotationCard.tsx +1 -1
  15. package/src/components/AnnotationCards.stories.tsx +87 -0
  16. package/src/components/AnnotationCards.tsx +303 -0
  17. package/src/components/AppHeader.stories.tsx +50 -0
  18. package/src/components/AppHeader.tsx +83 -0
  19. package/src/components/AppShell.stories.tsx +47 -42
  20. package/src/components/AppShell.tsx +212 -100
  21. package/src/components/AppShell.zoom.test.tsx +94 -0
  22. package/src/components/ChangesColumn.tsx +49 -26
  23. package/src/components/DiffSheet.scroll.test.tsx +38 -55
  24. package/src/components/DiffSheet.split.test.tsx +140 -0
  25. package/src/components/DiffSheet.stories.tsx +55 -53
  26. package/src/components/DiffSheet.syntax.test.tsx +24 -7
  27. package/src/components/DiffSheet.tsx +607 -276
  28. package/src/components/DiscussionMarkerRail.stories.tsx +54 -0
  29. package/src/components/DiscussionMarkerRail.tsx +181 -0
  30. package/src/components/EditorGrid.stories.tsx +59 -0
  31. package/src/components/EditorGrid.tabs.test.tsx +105 -0
  32. package/src/components/EditorGrid.tsx +338 -0
  33. package/src/components/EditorGrid.zoom.test.tsx +68 -0
  34. package/src/components/FileContentsView.stories.tsx +16 -0
  35. package/src/components/FileContentsView.test.tsx +29 -0
  36. package/src/components/FileContentsView.tsx +78 -0
  37. package/src/components/InboxList.tsx +159 -30
  38. package/src/components/PaneGrid.stories.tsx +84 -0
  39. package/src/components/PaneGrid.tsx +128 -0
  40. package/src/components/PanelColumn.stories.tsx +25 -0
  41. package/src/components/PanelColumn.tsx +95 -0
  42. package/src/components/ProjectTreeView.stories.tsx +25 -0
  43. package/src/components/ProjectTreeView.test.tsx +32 -0
  44. package/src/components/ProjectTreeView.tsx +82 -0
  45. package/src/components/PromptDialog.tsx +8 -1
  46. package/src/components/RootOverlay.stories.tsx +44 -0
  47. package/src/components/RootOverlay.tsx +35 -0
  48. package/src/components/SettingsDialog.tsx +2 -1
  49. package/src/components/SurfaceScrollbar.stories.tsx +34 -0
  50. package/src/components/SurfaceScrollbar.test.tsx +111 -0
  51. package/src/components/SurfaceScrollbar.tsx +93 -0
  52. package/src/components/ThreadFooter.stories.tsx +2 -2
  53. package/src/components/ThreadFooter.test.tsx +31 -0
  54. package/src/components/ThreadFooter.tsx +12 -7
  55. package/src/components/ThreadView.tsx +62 -1440
  56. package/src/components/ThreadsSidebar.tsx +14 -1
  57. package/src/components/Tooltip.stories.tsx +26 -0
  58. package/src/components/Tooltip.tsx +72 -0
  59. package/src/components/WelcomeSurface.stories.tsx +12 -0
  60. package/src/components/WelcomeSurface.tsx +52 -0
  61. package/src/components/__snapshots__/stories.test.tsx.snap +509 -559
  62. package/src/components/diff-sheet-layout.test.ts +125 -55
  63. package/src/components/diff-sheet-layout.ts +3 -103
  64. package/src/components/editor-grid.test.ts +86 -0
  65. package/src/components/editor-grid.ts +138 -0
  66. package/src/components/fade-title.test.ts +49 -0
  67. package/src/components/fade-title.ts +61 -0
  68. package/src/components/file-tree.test.ts +15 -0
  69. package/src/components/file-tree.ts +59 -4
  70. package/src/components/pages/Workspace.stories.tsx +2 -2
  71. package/src/components/primitives/IconButton.tsx +21 -2
  72. package/src/components/primitives/Tree.tsx +13 -4
  73. package/src/components/primitives/icons.ts +27 -4
  74. package/src/components/session-tree.test.ts +20 -0
  75. package/src/components/session-tree.ts +16 -2
  76. package/src/components/stories-app.tsx +2 -2
  77. package/src/config.test.ts +20 -49
  78. package/src/config.ts +26 -28
  79. package/src/diff-fold.test.ts +78 -0
  80. package/src/diff-fold.ts +126 -0
  81. package/src/diff-syntax.test.ts +16 -0
  82. package/src/diff-syntax.ts +6 -3
  83. package/src/discussions.ts +79 -0
  84. package/src/intent-dispatch.test.ts +31 -11
  85. package/src/intent-dispatch.ts +93 -48
  86. package/src/key-bindings.test.ts +1 -1
  87. package/src/keymap.test.ts +3 -25
  88. package/src/keymap.ts +17 -18
  89. package/src/mark-runs.ts +120 -0
  90. package/src/pty.ts +10 -2
  91. package/src/run.ts +17 -7
  92. package/src/serve.test.ts +4 -8
  93. package/src/session-controller.curate.test.ts +75 -0
  94. package/src/session-controller.share.test.ts +1 -0
  95. package/src/session-controller.tree.test.ts +26 -0
  96. package/src/session-controller.ts +153 -29
  97. package/src/slash-palette.ts +83 -0
  98. package/src/split-diff.test.ts +110 -0
  99. package/src/split-diff.ts +149 -0
  100. package/src/test-support.test.ts +3 -3
  101. package/src/test-support.ts +39 -8
  102. package/src/thread-chords.test.ts +25 -9
  103. package/src/thread-chords.ts +33 -8
  104. package/src/use-annotation-surface.tsx +837 -0
  105. package/src/use-changes-workbench.ts +151 -0
  106. package/src/use-frame-measure.ts +38 -0
  107. package/src/use-settings-dialog.tsx +14 -24
  108. package/src/use-terminal-virtualizer.ts +233 -0
  109. package/src/view-diff.ts +114 -0
  110. package/src/components/ReviewPanel.stories.tsx +0 -100
  111. package/src/components/ReviewPanel.tsx +0 -103
  112. package/src/components/ReviewRail.stories.tsx +0 -232
  113. package/src/components/ReviewRail.tsx +0 -327
  114. package/src/review-panel.test.ts +0 -113
  115. package/src/review-panel.ts +0 -83
@@ -0,0 +1,837 @@
1
+ /**
2
+ * The annotation surface: everything an inline-commenting view shares, over a
3
+ * list of text lines it does not know how to paint. A character-precise caret
4
+ * sits in the text; click/drag marks (word mode on double-click); typing opens a
5
+ * composer anchored at the caret word or held selection; "/" opens the quick-action
6
+ * palette; enter replies, tab folds, esc dismisses; cmd+enter sends. The plan
7
+ * thread view and the diff sheet both drive this hook and only paint their own
8
+ * rows, so marking and commenting behave identically on prose and on code.
9
+ *
10
+ * The view registers one renderable per visual line (so a drag can hit-test any
11
+ * row on screen), asks for the mark ranges to paint on a block, and slots the
12
+ * cards this hook builds under the visual line a span ends on.
13
+ */
14
+
15
+ import React, { useEffect, useRef, useState } from "react";
16
+ import type { KeyEvent, MouseEvent as TerminalMouseEvent, TextRenderable } from "@opentui/core";
17
+ import { useKeyboard } from "@opentui/react";
18
+ import type { ReviewSession } from "@cueloop/schema";
19
+ import type { Mark } from "./view-plan";
20
+ import type { QuickAction } from "./config";
21
+ import type { Theme } from "./theme";
22
+ import {
23
+ comparePositions,
24
+ isDoubleClick,
25
+ nextWordStart,
26
+ orderedSpan,
27
+ positionAt,
28
+ previousWordStart,
29
+ snapSpanToWords,
30
+ spanRangeInBlock,
31
+ tightenSpan,
32
+ wordRangeAt,
33
+ type ClickStamp,
34
+ type LineGeometry,
35
+ type TextPosition,
36
+ type TextSpan,
37
+ } from "./thread-selection";
38
+ import { annotationPaletteFor, type AnnotationPalette } from "./annotation-palette";
39
+ import { printableSequence, type MarkRange, type VisualLine } from "./mark-runs";
40
+ import { resolveInlineSuggestion, slashFilter, slashItemsFrom } from "./slash-palette";
41
+ import { discussionsFrom, type Discussion } from "./discussions";
42
+ import {
43
+ CommentRow,
44
+ Composer,
45
+ ComposerPalette,
46
+ DiscussionCard,
47
+ type EdgeSegment,
48
+ } from "./components/AnnotationCards";
49
+
50
+ /** The lines a surface annotates: the text per block and which blocks take a comment. */
51
+ export interface LineSource {
52
+ count: number;
53
+ textAt(blockIndex: number): string;
54
+ /** A block the caret can rest on and a comment can anchor to (a diff's header rows cannot). */
55
+ annotatable(blockIndex: number): boolean;
56
+ }
57
+
58
+ export interface ComposeState {
59
+ /** The block the card renders under: where the span ends. */
60
+ blockIndex: number;
61
+ /** Reply target; null composes a new discussion. */
62
+ discussionKey: string | null;
63
+ span: TextSpan | null;
64
+ seed: string;
65
+ /** When set, the composer rewrites this annotation instead of appending. */
66
+ editAnnotationId: string | null;
67
+ }
68
+
69
+ export interface AnnotationSurfaceOptions {
70
+ source: LineSource;
71
+ session: ReviewSession;
72
+ marks: Map<number, Mark[]>;
73
+ quickActions: QuickAction[];
74
+ tokens: Theme;
75
+ observer: boolean;
76
+ /** A verdict is in: no draft may open; the app answers with its read-only status. */
77
+ resolved: boolean;
78
+ /** True while a menu, dialog, or overlay owns the keyboard. */
79
+ suspended: boolean;
80
+ /** Reports whether a composer is open, so session chords can yield to typing. */
81
+ onComposingChange?: (composing: boolean) => void;
82
+ /** An observer or a resolved review refused a draft; the app shows why. */
83
+ onObserverBlocked?: (reason: "observer" | "resolved") => void;
84
+ /** Reports the caret's block, so block-level primitives (cut, restore) act where the caret is. */
85
+ onCursorChange?: (blockIndex: number) => void;
86
+ /** The rail's focused card; the discussion holding it takes focus here. */
87
+ focusedAnnotationId?: string;
88
+ /** Reports the focused discussion's root comment, so the rail follows. */
89
+ onFocusAnnotation?: (annotationId: string | undefined) => void;
90
+ onAnnotate: (span: TextSpan, body: string) => void;
91
+ onReply: (rootAnnotationId: string, body: string) => void;
92
+ onUpdateAnnotation: (id: string, body: string) => void;
93
+ onExit: () => void;
94
+ }
95
+
96
+ export interface AnnotationSurface {
97
+ palette: AnnotationPalette;
98
+ discussions: Discussion[];
99
+ /** The caret's block. */
100
+ cursor: number;
101
+ head: TextPosition;
102
+ compose: ComposeState | null;
103
+ focusedDiscussion: string | null;
104
+ /** The block to keep in view: an opening card, a focused discussion, else the caret. */
105
+ revealBlockIndex: number;
106
+ /** The text a span covers, blocks joined by a space, for previews. */
107
+ spanQuote: (span: TextSpan) => string;
108
+ /** Ref callback for one visual line, so a drag can hit-test it. */
109
+ registerLine: (
110
+ blockIndex: number,
111
+ lineIndex: number,
112
+ line: VisualLine,
113
+ ) => (renderable: TextRenderable | null) => void;
114
+ /** Press on a visual line: place the caret (word mode on double-click) and start a drag. */
115
+ onLineMouseDown: (event: TerminalMouseEvent) => void;
116
+ /** Drag routing for the view root, so a fast flick off a row never strands the gesture. */
117
+ rootMouseProps: {
118
+ onMouseDrag: (event: TerminalMouseEvent) => void;
119
+ onMouseDragEnd: () => void;
120
+ onMouseUp: () => void;
121
+ };
122
+ /** Ranges to paint on a block: discussion marks, the compose mark, the caret cell. */
123
+ rangesFor: (blockIndex: number) => MarkRange[];
124
+ /** Cards anchored to a visual line: discussion cards, the new-discussion composer, the palette. */
125
+ cardsAfterLine: (blockIndex: number, line: VisualLine, isLastLine: boolean) => React.ReactNode[];
126
+ jumpToDiscussion: (key: string) => void;
127
+ /** Clicking away from an open composer commits the draft. */
128
+ blurSaveCompose: () => void;
129
+ }
130
+
131
+ /** The nearest block the caret can rest on, stepping from `from` in `direction`; `from` when none. */
132
+ function nearestAnnotatable(source: LineSource, from: number, direction: 1 | -1): number {
133
+ for (let index = from + direction; index >= 0 && index < source.count; index += direction) {
134
+ if (source.annotatable(index)) return index;
135
+ }
136
+
137
+ return from;
138
+ }
139
+
140
+ /** The first block the caret can rest on, so a surface never opens on a header. */
141
+ function firstAnnotatable(source: LineSource): number {
142
+ for (let index = 0; index < source.count; index++) if (source.annotatable(index)) return index;
143
+
144
+ return 0;
145
+ }
146
+
147
+ export function useAnnotationSurface(options: AnnotationSurfaceOptions): AnnotationSurface {
148
+ const {
149
+ source,
150
+ session,
151
+ marks,
152
+ quickActions,
153
+ tokens,
154
+ observer,
155
+ resolved,
156
+ suspended,
157
+ onComposingChange,
158
+ onObserverBlocked,
159
+ onCursorChange,
160
+ focusedAnnotationId,
161
+ onFocusAnnotation,
162
+ onAnnotate,
163
+ onReply,
164
+ onUpdateAnnotation,
165
+ onExit,
166
+ } = options;
167
+ const palette = annotationPaletteFor(tokens);
168
+ const discussions = discussionsFrom(session, marks);
169
+ const [cursor, setCursor] = useState(() => firstAnnotatable(source));
170
+ // character-precise: `head` is the caret, `anchor` the other end of a held
171
+ // selection (the same position when collapsed); both are full positions, so
172
+ // the cursor block (cards, folding) can move without conjuring a selection
173
+ const [caret, setCaret] = useState<{ head: TextPosition; anchor: TextPosition }>(() => {
174
+ const start = { blockIndex: firstAnnotatable(source), char: 0 };
175
+
176
+ return { head: start, anchor: start };
177
+ });
178
+ const [compose, setCompose] = useState<ComposeState | null>(null);
179
+
180
+ useEffect(() => {
181
+ onComposingChange?.(compose !== null);
182
+ }, [compose, onComposingChange]);
183
+ useEffect(() => {
184
+ onCursorChange?.(caret.head.blockIndex);
185
+ }, [caret.head.blockIndex, onCursorChange]);
186
+ // the lines can change under the caret (a diff loads, a file folds): land it on
187
+ // the nearest block it may rest on rather than a header or past the end
188
+ useEffect(() => {
189
+ const { blockIndex } = caret.head;
190
+
191
+ if (source.count === 0) return;
192
+ if (blockIndex < source.count && source.annotatable(blockIndex)) return;
193
+ const clamped = Math.min(blockIndex, source.count - 1);
194
+ const next = source.annotatable(clamped)
195
+ ? clamped
196
+ : nearestAnnotatable(source, clamped, 1) !== clamped
197
+ ? nearestAnnotatable(source, clamped, 1)
198
+ : nearestAnnotatable(source, clamped, -1);
199
+ const position = { blockIndex: next, char: 0 };
200
+
201
+ setCursor(next);
202
+ setCaret({ head: position, anchor: position });
203
+ // eslint-disable-next-line react-hooks/exhaustive-deps
204
+ }, [source.count]);
205
+
206
+ // discussion focus is the rail's card focus seen from the document: when the
207
+ // app owns it (onFocusAnnotation), the focused discussion is the one holding
208
+ // the focused card and focusing here names the root comment; standalone, it
209
+ // is local state
210
+ const [localFocus, setLocalFocus] = useState<string | null>(null);
211
+ const focusedDiscussion =
212
+ onFocusAnnotation === undefined
213
+ ? localFocus
214
+ : (discussions.find((discussion) =>
215
+ discussion.annotations.some((annotation) => annotation.id === focusedAnnotationId),
216
+ )?.key ?? null);
217
+ const setFocusedDiscussion = (key: string | null): void => {
218
+ if (onFocusAnnotation === undefined) return setLocalFocus(key);
219
+ onFocusAnnotation(discussions.find((discussion) => discussion.key === key)?.rootId);
220
+ };
221
+ const [folded, setFolded] = useState<Set<string>>(new Set());
222
+ const [composeText, setComposeText] = useState("");
223
+ const [slashIndex, setSlashIndex] = useState(0);
224
+ const composerReady = useRef(false);
225
+ const composeRef = useRef<ComposeState | null>(null);
226
+ // every visual line registers its renderable so a drag can hit-test any
227
+ // row on screen, across blocks (rows without text resolve to the block above)
228
+ const lineRenderables = useRef(
229
+ new Map<
230
+ string,
231
+ { blockIndex: number; start: number; end: number; renderable: TextRenderable }
232
+ >(),
233
+ );
234
+ // the press anchors the gesture; the first drag samples can land before
235
+ // React has re-rendered, so the anchor travels with the gesture, not the closure
236
+ const dragging = useRef<{ wordMode: boolean; anchor: TextPosition; head: TextPosition } | null>(
237
+ null,
238
+ );
239
+ const lastClick = useRef<ClickStamp | null>(null);
240
+
241
+ const blockText = (blockIndex: number): string =>
242
+ blockIndex < source.count ? source.textAt(blockIndex) : "";
243
+ const textLengthOf = (blockIndex: number): number => blockText(blockIndex).length;
244
+ const { head } = caret;
245
+ /** The held selection in document order, off block edges; null when collapsed. */
246
+ const heldSpan = ((): TextSpan | null => {
247
+ const span = orderedSpan({ anchor: caret.anchor, head });
248
+
249
+ return span ? tightenSpan(span, textLengthOf) : null;
250
+ })();
251
+ const caretIsSelection = heldSpan !== null;
252
+ /** The typing anchor: the held selection (char-precise), else the marked word. */
253
+ const caretSpan = (): TextSpan | null => {
254
+ if (heldSpan) return heldSpan;
255
+ const word = wordRangeAt(blockText(head.blockIndex), head.char);
256
+
257
+ return word
258
+ ? {
259
+ start: { blockIndex: cursor, char: word.start },
260
+ end: { blockIndex: cursor, char: word.end },
261
+ }
262
+ : null;
263
+ };
264
+ const collapseCaret = (): void => setCaret({ head, anchor: head });
265
+ const spanQuote = (span: TextSpan): string => {
266
+ const parts: string[] = [];
267
+
268
+ for (let blockIndex = span.start.blockIndex; blockIndex <= span.end.blockIndex; blockIndex++) {
269
+ const range = spanRangeInBlock(span, blockIndex, textLengthOf(blockIndex));
270
+
271
+ if (range) parts.push(blockText(blockIndex).slice(range.start, range.end));
272
+ }
273
+
274
+ return parts.join(" ");
275
+ };
276
+ const allGeometry = (): LineGeometry[] =>
277
+ [...lineRenderables.current.values()].map((entry) => ({
278
+ blockIndex: entry.blockIndex,
279
+ start: entry.start,
280
+ end: entry.end,
281
+ x: entry.renderable.x,
282
+ y: entry.renderable.y,
283
+ }));
284
+
285
+ const openCompose = (state: ComposeState): void => {
286
+ if (observer) return onObserverBlocked?.("observer");
287
+ if (resolved) return onObserverBlocked?.("resolved");
288
+ composerReady.current = false;
289
+ composeRef.current = state;
290
+ setCompose(state);
291
+ setComposeText(state.seed);
292
+ setSlashIndex(0);
293
+ };
294
+ const closeCompose = (): void => {
295
+ composeRef.current = null;
296
+ setCompose(null);
297
+ setComposeText("");
298
+ };
299
+ const slashActive = compose !== null && composeText.startsWith("/");
300
+ const slashItems = slashActive
301
+ ? slashFilter(slashItemsFrom(quickActions), composeText.slice(1).trim())
302
+ : [];
303
+ // inline skill completion: the trailing "/word" token and its closest match,
304
+ // offered as a hint below the composer that tab completes to the full name
305
+ const inlineSlash = resolveInlineSuggestion(slashActive, composeText, quickActions);
306
+
307
+ const saveComment = (body: string): void => {
308
+ // the body saves verbatim - typed newlines are the author's choice;
309
+ // trimming only decides whether the draft is empty enough to discard
310
+ const target = composeRef.current;
311
+
312
+ closeCompose();
313
+ collapseCaret();
314
+ if (!target || body.trim().length === 0) return;
315
+ if (target.editAnnotationId !== null) {
316
+ onUpdateAnnotation(target.editAnnotationId, body);
317
+
318
+ return;
319
+ }
320
+ if (target.discussionKey !== null) {
321
+ const discussion = discussions.find((candidate) => candidate.key === target.discussionKey);
322
+
323
+ if (discussion) {
324
+ onReply(discussion.rootId, body);
325
+
326
+ return;
327
+ }
328
+ }
329
+ const span = target.span ?? caretSpan();
330
+
331
+ if (span) onAnnotate(span, body);
332
+ };
333
+ /** A new discussion on the typing anchor; the card renders under the span's last block. */
334
+ const openNewCompose = (seed: string): void => {
335
+ const span = caretSpan();
336
+
337
+ openCompose({
338
+ blockIndex: span?.end.blockIndex ?? head.blockIndex,
339
+ discussionKey: null,
340
+ span,
341
+ seed,
342
+ editAnnotationId: null,
343
+ });
344
+ };
345
+
346
+ // clicking away from an open composer commits the draft (blur-save);
347
+ // a half-typed slash query is never a comment, so it discards instead
348
+ const blurSaveCompose = (): void => {
349
+ if (!composeRef.current) return;
350
+ if (slashActive || composeText.trim().length === 0) return closeCompose();
351
+ saveComment(composeText);
352
+ };
353
+
354
+ /** Extend the held selection to the pointer's position (word mode snaps both ends). */
355
+ const extendSelectionTo = (pointer: TextPosition): void => {
356
+ const drag = dragging.current;
357
+
358
+ if (!drag) return;
359
+ let next = { head: pointer, anchor: drag.anchor };
360
+
361
+ if (drag.wordMode) {
362
+ const span = orderedSpan({ anchor: drag.anchor, head: pointer });
363
+
364
+ if (span) {
365
+ const snapped = snapSpanToWords(span, blockText);
366
+ const forward = comparePositions(pointer, drag.anchor) >= 0;
367
+
368
+ next = forward
369
+ ? { anchor: snapped.start, head: snapped.end }
370
+ : { anchor: snapped.end, head: snapped.start };
371
+ }
372
+ }
373
+ if (comparePositions(next.head, drag.head) === 0) return;
374
+ drag.head = next.head;
375
+ setCursor(next.head.blockIndex);
376
+ setCaret({ head: next.head, anchor: next.anchor });
377
+ };
378
+
379
+ // Drag routing lives on the view root: the renderer captures the drag on
380
+ // its FIRST drag sample, to whatever is under the pointer then, so a fast
381
+ // flick off the text row would otherwise strand the gesture. Events
382
+ // bubble, so the root sees every drag and resolves it against every line
383
+ // on screen - the mark follows the pointer across blocks.
384
+ const handleRootDrag = (event: TerminalMouseEvent): void => {
385
+ if (!dragging.current) return;
386
+ const position = positionAt(allGeometry(), event.x, event.y);
387
+
388
+ if (position) extendSelectionTo(position);
389
+ };
390
+ const endDrag = (): void => {
391
+ dragging.current = null;
392
+ };
393
+
394
+ const jumpToDiscussion = (key: string): void => {
395
+ const target = discussions.find((discussion) => discussion.key === key);
396
+
397
+ if (!target) return;
398
+ blurSaveCompose();
399
+ setCursor(target.blockIndex);
400
+ setFocusedDiscussion(key);
401
+ };
402
+
403
+ // ── keyboard, split per concern to stay within the complexity budget ──
404
+
405
+ const handleSlashKey = (key: KeyEvent, activeCompose: ComposeState): boolean => {
406
+ if (!slashActive || slashItems.length === 0) return false;
407
+ const selected = Math.min(slashIndex, slashItems.length - 1);
408
+
409
+ if (key.name === "up") {
410
+ setSlashIndex(Math.max(0, selected - 1));
411
+
412
+ return true;
413
+ }
414
+ if (key.name === "down") {
415
+ setSlashIndex(Math.min(slashItems.length - 1, selected + 1));
416
+
417
+ return true;
418
+ }
419
+ if (key.name === "return" || key.name === "tab") {
420
+ openCompose({ ...activeCompose, seed: slashItems[selected]!.body });
421
+
422
+ return true;
423
+ }
424
+
425
+ return false;
426
+ };
427
+
428
+ /** Tab completes the trailing "/word" to the matched skill's full name, then a space to chain. */
429
+ const handleInlineSlashKey = (key: KeyEvent, activeCompose: ComposeState): boolean => {
430
+ if (inlineSlash === null || key.name !== "tab") return false;
431
+ const cut = composeText.length - inlineSlash.token.length;
432
+ const completed = `${composeText.slice(0, cut)}/${inlineSlash.suggestion.name} `;
433
+
434
+ openCompose({ ...activeCompose, seed: completed });
435
+
436
+ return true;
437
+ };
438
+
439
+ /** Pre-mount window: buffer printables, honor a fast cmd+enter or newline. */
440
+ const handlePremountKey = (key: KeyEvent, activeCompose: ComposeState): void => {
441
+ if (key.name === "return") {
442
+ if (key.super || key.meta || key.ctrl) return saveComment(activeCompose.seed);
443
+ const grown = { ...activeCompose, seed: `${activeCompose.seed}\n` };
444
+
445
+ composeRef.current = grown;
446
+ setComposeText(grown.seed);
447
+
448
+ return setCompose(grown);
449
+ }
450
+ const sequence = printableSequence(key);
451
+
452
+ if (sequence) {
453
+ const grown = { ...activeCompose, seed: activeCompose.seed + sequence };
454
+
455
+ composeRef.current = grown;
456
+ setComposeText(grown.seed);
457
+ setCompose(grown);
458
+ }
459
+ };
460
+
461
+ const handleComposeKey = (key: KeyEvent, activeCompose: ComposeState): void => {
462
+ // the textarea owns every key while open; the view takes dismiss (which
463
+ // also releases the discussion focus), the slash palette, and pre-mount input
464
+ if (key.name === "escape") {
465
+ setFocusedDiscussion(null);
466
+
467
+ return closeCompose();
468
+ }
469
+ // backspace on an already empty draft undoes it: the card dissolves
470
+ // and the caret sits back on the still-held selection, ready to re-type
471
+ // (an edit of an existing comment is never deleted this way)
472
+ if (
473
+ key.name === "backspace" &&
474
+ composeText.length === 0 &&
475
+ activeCompose.editAnnotationId === null
476
+ ) {
477
+ return closeCompose();
478
+ }
479
+ if (handleSlashKey(key, activeCompose)) return;
480
+ if (handleInlineSlashKey(key, activeCompose)) return;
481
+ if (!composerReady.current) handlePremountKey(key, activeCompose);
482
+ };
483
+
484
+ /** m / cmd+[ / cmd+] / tab / return - true when the key was a discussion primitive. */
485
+ const handleDiscussionVerb = (key: KeyEvent): boolean => {
486
+ // comment on selection: cmd+option+m (alt+m where cmd arrives ESC-prefixed)
487
+ if (key.name === "m" && (key.super || key.meta || key.option)) {
488
+ openNewCompose("");
489
+
490
+ return true;
491
+ }
492
+ if ((key.super || key.meta || key.ctrl) && (key.name === "[" || key.name === "]")) {
493
+ if (discussions.length === 0) return true;
494
+ const currentIndex = discussions.findIndex(
495
+ (discussion) => discussion.key === focusedDiscussion,
496
+ );
497
+ const nextIndex =
498
+ key.name === "]"
499
+ ? (currentIndex + 1) % discussions.length
500
+ : currentIndex <= 0
501
+ ? discussions.length - 1
502
+ : currentIndex - 1;
503
+
504
+ jumpToDiscussion(discussions[nextIndex]!.key);
505
+
506
+ return true;
507
+ }
508
+ if (key.name === "tab") {
509
+ setFolded((current) => {
510
+ const next = new Set(current);
511
+
512
+ for (const discussion of discussions) {
513
+ if (discussion.blockIndex !== cursor) continue;
514
+ if (next.has(discussion.key)) next.delete(discussion.key);
515
+ else next.add(discussion.key);
516
+ }
517
+
518
+ return next;
519
+ });
520
+
521
+ return true;
522
+ }
523
+ // enter replies into the focused discussion, else the cursor block's discussion
524
+ if (key.name === "return") {
525
+ const replyTarget =
526
+ discussions.find((discussion) => discussion.key === focusedDiscussion) ??
527
+ discussions.findLast((discussion) => discussion.blockIndex === cursor);
528
+
529
+ if (replyTarget) {
530
+ openCompose({
531
+ blockIndex: replyTarget.blockIndex,
532
+ discussionKey: replyTarget.key,
533
+ span: null,
534
+ seed: "",
535
+ editAnnotationId: null,
536
+ });
537
+ }
538
+
539
+ return true;
540
+ }
541
+
542
+ return false;
543
+ };
544
+
545
+ /** Arrows step by word start; shift holds the anchor; the caret flows across blocks. */
546
+ const moveCaretHorizontal = (delta: 1 | -1, extend: boolean): void => {
547
+ const text = blockText(head.blockIndex);
548
+ const target =
549
+ delta === 1 ? nextWordStart(text, head.char) : previousWordStart(text, head.char);
550
+ const moveTo = (blockIndex: number, char: number): void => {
551
+ const next = { blockIndex, char };
552
+
553
+ setCursor(blockIndex);
554
+ setCaret({ head: next, anchor: extend ? caret.anchor : next });
555
+ };
556
+
557
+ if (target !== null) return moveTo(head.blockIndex, target);
558
+ if (extend && head.char !== (delta === 1 ? text.length : 0)) {
559
+ // the block edge first, then the next step crosses into the neighbour
560
+ return moveTo(head.blockIndex, delta === 1 ? text.length : 0);
561
+ }
562
+ // the neighbour is the next block the caret may rest on: headers are skipped
563
+ const neighbour = nearestAnnotatable(source, head.blockIndex, delta);
564
+
565
+ if (neighbour === head.blockIndex) return;
566
+ if (delta === 1) return moveTo(neighbour, 0);
567
+ const previousText = blockText(neighbour);
568
+
569
+ moveTo(
570
+ neighbour,
571
+ extend ? previousText.length : (previousWordStart(previousText, previousText.length) ?? 0),
572
+ );
573
+ };
574
+
575
+ const handleCaretKey = (key: KeyEvent): boolean => {
576
+ const vertical =
577
+ key.name === "up" || (key.ctrl && key.name === "p")
578
+ ? -1
579
+ : key.name === "down" || (key.ctrl && key.name === "n")
580
+ ? 1
581
+ : 0;
582
+
583
+ if (vertical !== 0) {
584
+ const next = nearestAnnotatable(source, cursor, vertical);
585
+
586
+ setCaret({
587
+ head: { blockIndex: next, char: 0 },
588
+ anchor: { blockIndex: next, char: 0 },
589
+ });
590
+ setFocusedDiscussion(null);
591
+ setCursor(next);
592
+
593
+ return true;
594
+ }
595
+ if (key.name === "right" || (key.ctrl && key.name === "l")) {
596
+ moveCaretHorizontal(1, Boolean(key.shift || key.meta || key.ctrl));
597
+
598
+ return true;
599
+ }
600
+ if (key.name === "left" || (key.ctrl && key.name === "h")) {
601
+ moveCaretHorizontal(-1, Boolean(key.shift || key.meta || key.ctrl));
602
+
603
+ return true;
604
+ }
605
+
606
+ return false;
607
+ };
608
+
609
+ /** A printable starts a comment: edit my trailing, reply, or a new discussion. */
610
+ const startTyping = (key: KeyEvent): void => {
611
+ const sequence = printableSequence(key);
612
+
613
+ if (!sequence) return;
614
+ const discussion = discussions.find((candidate) => candidate.key === focusedDiscussion);
615
+
616
+ if (discussion) {
617
+ const last = discussion.annotations.at(-1)!;
618
+ const editingOwn = last.author === undefined;
619
+
620
+ return openCompose({
621
+ blockIndex: discussion.blockIndex,
622
+ discussionKey: discussion.key,
623
+ span: null,
624
+ seed: editingOwn ? `${last.body}${sequence}` : sequence,
625
+ editAnnotationId: editingOwn ? last.id : null,
626
+ });
627
+ }
628
+ openNewCompose(sequence);
629
+ };
630
+
631
+ useKeyboard((key) => {
632
+ if (suspended) return;
633
+ if (key.ctrl && key.name === "q") return onExit();
634
+ const activeCompose = composeRef.current;
635
+
636
+ if (activeCompose) return handleComposeKey(key, activeCompose);
637
+ if (key.name === "escape") {
638
+ if (focusedDiscussion !== null) return setFocusedDiscussion(null);
639
+
640
+ return collapseCaret();
641
+ }
642
+ if (handleDiscussionVerb(key)) return;
643
+ if (handleCaretKey(key)) return;
644
+ startTyping(key);
645
+ });
646
+
647
+ // ── the cards the view slots under its lines ──
648
+
649
+ // one composer element (keyed by seed so slash acceptance remounts it);
650
+ // the slash palette renders BELOW the whole card, not inside it
651
+ const composerNode = compose ? (
652
+ <Composer
653
+ key={compose.seed}
654
+ seed={compose.seed}
655
+ glyph="●"
656
+ tokens={tokens}
657
+ onSave={saveComment}
658
+ onReady={() => (composerReady.current = true)}
659
+ onInput={setComposeText}
660
+ />
661
+ ) : null;
662
+ // one node below the composer: the palette list for a leading "/", otherwise the
663
+ // inline completion hint. Renders null when neither applies, so it always pushes.
664
+ const paletteNode = (
665
+ <ComposerPalette
666
+ key="composer-palette"
667
+ slashActive={slashActive}
668
+ slashItems={slashItems}
669
+ slashIndex={slashIndex}
670
+ inline={inlineSlash}
671
+ tokens={tokens}
672
+ />
673
+ );
674
+
675
+ const foldedSummaryFor = (discussion: Discussion): React.ReactNode => (
676
+ <box key={discussion.key} style={{ flexDirection: "row", marginTop: 1, marginLeft: 2 }}>
677
+ <text fg={tokens.textDim}>
678
+ {`○ ${discussion.annotations.length} comment${discussion.annotations.length === 1 ? "" : "s"} ›`}
679
+ </text>
680
+ </box>
681
+ );
682
+
683
+ const discussionCardFor = (discussion: Discussion, composingHere: boolean): React.ReactNode => {
684
+ const segments: EdgeSegment[] = discussion.annotations.map((annotation) => {
685
+ const editingHere =
686
+ composingHere && compose?.editAnnotationId === annotation.id && composerNode !== null;
687
+
688
+ return {
689
+ color: annotation.author === undefined ? palette.cardEdge : tokens.text,
690
+ node: editingHere ? (
691
+ composerNode
692
+ ) : (
693
+ <CommentRow key={annotation.id} annotation={annotation} tokens={tokens} />
694
+ ),
695
+ };
696
+ });
697
+
698
+ if (composingHere && compose?.editAnnotationId === null && composerNode) {
699
+ segments.push({ color: palette.cardEdge, node: composerNode });
700
+ }
701
+
702
+ return (
703
+ <DiscussionCard
704
+ key={discussion.key}
705
+ segments={segments}
706
+ tokens={tokens}
707
+ focused={focusedDiscussion === discussion.key}
708
+ onFocus={() => {
709
+ if (composeRef.current?.discussionKey === discussion.key) return;
710
+ blurSaveCompose();
711
+ setFocusedDiscussion(discussion.key);
712
+ setCursor(discussion.blockIndex);
713
+ }}
714
+ />
715
+ );
716
+ };
717
+
718
+ const rangesFor = (blockIndex: number): MarkRange[] => {
719
+ const textLength = textLengthOf(blockIndex);
720
+ const ranges: MarkRange[] = [];
721
+ const pushSpan = (span: TextSpan | null): void => {
722
+ const range = span ? spanRangeInBlock(span, blockIndex, textLength) : null;
723
+
724
+ if (range) ranges.push(range);
725
+ };
726
+
727
+ for (const discussion of discussions) pushSpan(discussion.span);
728
+ if (compose) pushSpan(compose.span);
729
+ if (caretIsSelection && !compose) pushSpan(heldSpan);
730
+ // the idle caret cell sits at the head, also at the end of a held mark;
731
+ // an open card takes the cursor with it, so no cell is painted then
732
+ if (blockIndex === head.blockIndex && !compose && textLength > 0) {
733
+ const cell = Math.max(0, Math.min(textLength - 1, head.char));
734
+
735
+ ranges.push({ start: cell, end: cell + 1, caretOnly: true });
736
+ }
737
+
738
+ return ranges;
739
+ };
740
+
741
+ const cardsAfterLine = (
742
+ blockIndex: number,
743
+ line: VisualLine,
744
+ isLastLine: boolean,
745
+ ): React.ReactNode[] => {
746
+ const endsInLine = (end: number): boolean => end - 1 >= line.start && end - 1 < line.end;
747
+ const nodes: React.ReactNode[] = [];
748
+ const composeHere = compose && compose.blockIndex === blockIndex;
749
+
750
+ for (const discussion of discussions) {
751
+ if (discussion.blockIndex !== blockIndex || !endsInLine(discussion.span.end.char)) continue;
752
+ const composingHere = Boolean(composeHere && compose.discussionKey === discussion.key);
753
+
754
+ if (folded.has(discussion.key) && !composingHere) {
755
+ nodes.push(foldedSummaryFor(discussion));
756
+ continue;
757
+ }
758
+ nodes.push(discussionCardFor(discussion, composingHere));
759
+ if (composingHere) nodes.push(paletteNode);
760
+ }
761
+ if (composeHere && compose.discussionKey === null && composerNode) {
762
+ const anchoredHere = compose.span ? endsInLine(compose.span.end.char) : isLastLine;
763
+
764
+ if (anchoredHere) {
765
+ nodes.push(
766
+ <DiscussionCard
767
+ key="compose-new"
768
+ tokens={tokens}
769
+ segments={[{ color: palette.cardEdge, node: composerNode }]}
770
+ />,
771
+ );
772
+ nodes.push(paletteNode);
773
+ }
774
+ }
775
+
776
+ return nodes;
777
+ };
778
+
779
+ const registerLine =
780
+ (blockIndex: number, lineIndex: number, line: VisualLine) =>
781
+ (renderable: TextRenderable | null): void => {
782
+ const key = `${blockIndex}:${lineIndex}`;
783
+
784
+ if (renderable) {
785
+ lineRenderables.current.set(key, {
786
+ blockIndex,
787
+ start: line.start,
788
+ end: line.end,
789
+ renderable,
790
+ });
791
+ } else lineRenderables.current.delete(key);
792
+ };
793
+
794
+ const onLineMouseDown = (event: TerminalMouseEvent): void => {
795
+ blurSaveCompose();
796
+ const pressed = positionAt(allGeometry(), event.x, event.y);
797
+
798
+ if (!pressed || !source.annotatable(pressed.blockIndex)) return;
799
+ const stamp = { time: Date.now(), x: event.x, y: event.y };
800
+ const wordMode = isDoubleClick(lastClick.current, stamp);
801
+
802
+ lastClick.current = stamp;
803
+ const word = wordMode ? wordRangeAt(blockText(pressed.blockIndex), pressed.char) : null;
804
+ const anchor = word ? { blockIndex: pressed.blockIndex, char: word.start } : pressed;
805
+ const pressHead = word ? { blockIndex: pressed.blockIndex, char: word.end } : pressed;
806
+
807
+ dragging.current = { wordMode, anchor, head: pressHead };
808
+ setFocusedDiscussion(null);
809
+ setCursor(pressed.blockIndex);
810
+ setCaret({ head: pressHead, anchor });
811
+ };
812
+
813
+ // an opening card shifts the layout, so the block it belongs to is revealed
814
+ // again; a discussion focused from the rail is scrolled into view the same way
815
+ const revealBlockIndex =
816
+ compose?.blockIndex ??
817
+ discussions.find((discussion) => discussion.key === focusedDiscussion)?.blockIndex ??
818
+ cursor;
819
+
820
+ return {
821
+ palette,
822
+ discussions,
823
+ cursor,
824
+ head,
825
+ compose,
826
+ focusedDiscussion,
827
+ revealBlockIndex,
828
+ spanQuote,
829
+ registerLine,
830
+ onLineMouseDown,
831
+ rootMouseProps: { onMouseDrag: handleRootDrag, onMouseDragEnd: endDrag, onMouseUp: endDrag },
832
+ rangesFor,
833
+ cardsAfterLine,
834
+ jumpToDiscussion,
835
+ blurSaveCompose,
836
+ };
837
+ }