@cueloop/client 0.1.0-alpha.83 → 0.1.0-alpha.85

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.
@@ -12,18 +12,24 @@ import { createStore, type StoreApi } from "zustand/vanilla";
12
12
  import { DaemonClient, type ThreadClient } from "@cueloop/daemon/client";
13
13
  import {
14
14
  applyPathView,
15
+ applyTextCuts,
16
+ blockCutSourceRange,
15
17
  createBranch,
16
18
  cutBlock,
19
+ cutTextRange,
17
20
  detectHerdr,
18
21
  labelTip,
19
22
  MAIN_BRANCH,
20
23
  makeAnchor,
24
+ mergeTextCut,
21
25
  navigateTo,
22
26
  newAnnotationId,
23
27
  parseBlocks,
24
28
  resolveAnchor,
25
29
  restoreBlock,
26
30
  restoreLine,
31
+ restoreTextCut,
32
+ sourceOffsetAt,
27
33
  returnPaneFor,
28
34
  switchBranch,
29
35
  threadShareLinks,
@@ -50,7 +56,15 @@ import {
50
56
  shareIdFromLine,
51
57
  formatShareLine,
52
58
  } from "./share";
53
- import { buildDisplay, nextWorkBlock, renderedSpanToWork, type DisplayBlock } from "./view-plan";
59
+ import {
60
+ baseRangeForRendered,
61
+ blockRuns,
62
+ buildDisplay,
63
+ nextWorkBlock,
64
+ renderedSpanToWork,
65
+ renderedText,
66
+ type DisplayBlock,
67
+ } from "./view-plan";
54
68
  import { entryTarget, treeRows, type TreeRow } from "./tree-view";
55
69
  import {
56
70
  diffRowBlocks,
@@ -105,9 +119,6 @@ const EMPTY_REJECTED_ROWS: Set<number> = new Set();
105
119
  /** Shared empty list so "nothing curated out" is a stable identity for renders. */
106
120
  const EMPTY_CURATION_ITEMS: CurationItem[] = [];
107
121
 
108
- /** Status shown when a curated-out removal (diff rejection or plan cut) is restored. */
109
- const REMOVAL_RESTORED_STATUS = "removal restored";
110
-
111
122
  export interface ToastState {
112
123
  title?: string;
113
124
  body: string;
@@ -144,10 +155,24 @@ function planCutId(base: { lineStart: number; lineEnd: number }): string {
144
155
  * reused rather than re-parsed. Content and working copy are strings, compared here by value.
145
156
  */
146
157
  export function sameDerivationInputs(previous: Thread, next: Thread): boolean {
158
+ const sameCuts =
159
+ previous.textCuts === next.textCuts ||
160
+ ((previous.textCuts?.length ?? 0) === (next.textCuts?.length ?? 0) &&
161
+ (previous.textCuts ?? []).every((cut, index) => {
162
+ const candidate = next.textCuts?.[index];
163
+
164
+ return (
165
+ candidate?.start === cut.start &&
166
+ candidate.end === cut.end &&
167
+ candidate.quote === cut.quote
168
+ );
169
+ }));
170
+
147
171
  return (
148
172
  previous.id === next.id &&
149
173
  previous.artifact.content === next.artifact.content &&
150
- previous.workingCopy === next.workingCopy
174
+ previous.workingCopy === next.workingCopy &&
175
+ sameCuts
151
176
  );
152
177
  }
153
178
 
@@ -293,8 +318,8 @@ export interface ReviewController {
293
318
  renameSession(id: string, title: string): void;
294
319
  /** Record the viewer's own name into the share's participant registry (collaborator self-naming). */
295
320
  setSelfName(name: string): void;
296
- /** Cut the block under the cursor, or restore a cut one. */
297
- cut(displayIndex: number): void;
321
+ /** Cut a marked range, or toggle the block under the cursor. */
322
+ cut(displayIndex: number, start?: number, end?: number, endDisplayIndex?: number): void;
298
323
  /** Toggle rejection of the whole hunk under the diff cursor (owner curation). */
299
324
  toggleRejectHunk(rowIndex: number): void;
300
325
  /** Toggle rejection of the single change under the diff cursor (owner curation). */
@@ -606,7 +631,9 @@ class Controller implements ReviewController {
606
631
  for (const file of files) fileContents.set(file.path, file);
607
632
 
608
633
  return {
609
- display: session ? buildDisplay(session.artifact.content, session.workingCopy) : [],
634
+ display: session
635
+ ? buildDisplay(session.artifact.content, session.workingCopy, session.textCuts)
636
+ : [],
610
637
  rows,
611
638
  files: walkFiles(rows),
612
639
  fileContents,
@@ -904,9 +931,8 @@ class Controller implements ReviewController {
904
931
  // unreachable gateway never blocks the local delete (the blob's 30-day TTL is the backstop)
905
932
  for (const link of this.linksFor(this.sessionRecord(id)))
906
933
  void this.shareTransport.revoke(link.id).catch(() => {});
907
- this.client
934
+ void this.client
908
935
  ?.sessionDelete(id)
909
- .then(() => this.setStatus("thread deleted"))
910
936
  .catch((cause: unknown) =>
911
937
  this.setStatus(`delete failed: ${cause instanceof Error ? cause.message : String(cause)}`),
912
938
  );
@@ -926,9 +952,8 @@ class Controller implements ReviewController {
926
952
 
927
953
  renameSession(id: string, title: string): void {
928
954
  if (this.readOnly) return this.setStatus("observer - read-only");
929
- this.client
955
+ void this.client
930
956
  ?.sessionSetTitle(id, title)
931
- .then(() => this.setStatus("thread renamed"))
932
957
  .catch((cause: unknown) =>
933
958
  this.setStatus(`rename failed: ${cause instanceof Error ? cause.message : String(cause)}`),
934
959
  );
@@ -941,7 +966,7 @@ class Controller implements ReviewController {
941
966
  this.apply(this.client!.sessionSetSelfName(session.id, name));
942
967
  }
943
968
 
944
- cut(displayIndex: number): void {
969
+ cut(displayIndex: number, start?: number, end?: number, endDisplayIndex = displayIndex): void {
945
970
  const session = this.snapshot.session;
946
971
 
947
972
  if (!session || session.status === "resolved") return;
@@ -950,20 +975,142 @@ class Controller implements ReviewController {
950
975
  if (!block) return;
951
976
  const working = this.working();
952
977
 
953
- if (block.type === "del") {
978
+ if (start !== undefined && end !== undefined && block.work) {
979
+ const display = this.display();
980
+ const endBlock = display[endDisplayIndex];
981
+
982
+ if (!endBlock?.work) return;
983
+ const exactSourceRange = this.exactCutSourceRange(
984
+ session,
985
+ display,
986
+ displayIndex,
987
+ endDisplayIndex,
988
+ start,
989
+ end,
990
+ );
991
+
992
+ if (exactSourceRange) {
993
+ const existingCuts = session.textCuts ?? [];
994
+ const textCuts =
995
+ restoreTextCut(
996
+ session.artifact.content,
997
+ existingCuts,
998
+ exactSourceRange.start,
999
+ exactSourceRange.end,
1000
+ ) ??
1001
+ mergeTextCut(
1002
+ session.artifact.content,
1003
+ existingCuts,
1004
+ exactSourceRange.start,
1005
+ exactSourceRange.end,
1006
+ );
1007
+ const content = applyTextCuts(session.artifact.content, textCuts);
1008
+
1009
+ if (content === working) return;
1010
+ const expected: Thread = { ...session, workingCopy: content, textCuts };
1011
+
1012
+ if (textCuts.length === 0) {
1013
+ delete expected.workingCopy;
1014
+ delete expected.textCuts;
1015
+ }
1016
+
1017
+ this.applyOptimistic(
1018
+ expected,
1019
+ this.client!.sessionSetWorkingCopy(session.id, content, textCuts),
1020
+ );
1021
+
1022
+ return;
1023
+ }
1024
+ const range = renderedSpanToWork(display, displayIndex, endDisplayIndex, start, end);
1025
+ const content = cutTextRange(working, block.work, range.start, endBlock.work, range.end);
1026
+
1027
+ if (content === working) return;
1028
+ this.setWorkingCopy(content);
1029
+ } else if (block.type === "del") {
954
1030
  this.restoreDelBlock(block, displayIndex);
955
1031
  } else if (block.work) {
956
- const workIndex = parseBlocks(working).findIndex(
957
- (candidate) => candidate.lineStart === block.work!.lineStart,
958
- );
1032
+ this.cutWholeBlock(session, block, working);
1033
+ }
1034
+ }
1035
+
1036
+ private cutWholeBlock(session: Thread, block: DisplayBlock, working: string): void {
1037
+ if (!block.work) return;
1038
+ const base = session.artifact.content;
1039
+ const existingCuts = session.textCuts ?? [];
959
1040
 
960
- if (workIndex === -1) return;
1041
+ if (existingCuts.length > 0 && block.base && working === applyTextCuts(base, existingCuts)) {
1042
+ const range = blockCutSourceRange(base, block.base);
1043
+ const textCuts = mergeTextCut(base, existingCuts, range.start, range.end);
1044
+ const content = applyTextCuts(base, textCuts);
1045
+
1046
+ if (content === working) return;
961
1047
  this.applyOptimistic(
962
- { ...session, workingCopy: cutBlock(working, block.work) },
963
- this.client!.sessionCutBlock(session.id, workIndex),
1048
+ { ...session, workingCopy: content, textCuts },
1049
+ this.client!.sessionSetWorkingCopy(session.id, content, textCuts),
1050
+ );
1051
+
1052
+ return;
1053
+ }
1054
+ const workIndex = parseBlocks(working).findIndex(
1055
+ (candidate) => candidate.lineStart === block.work!.lineStart,
1056
+ );
1057
+
1058
+ if (workIndex === -1) return;
1059
+ this.applyOptimistic(
1060
+ { ...session, workingCopy: cutBlock(working, block.work) },
1061
+ this.client!.sessionCutBlock(session.id, workIndex),
1062
+ );
1063
+ }
1064
+
1065
+ private exactCutSourceRange(
1066
+ session: Thread,
1067
+ display: DisplayBlock[],
1068
+ displayIndex: number,
1069
+ endDisplayIndex: number,
1070
+ renderedStart: number,
1071
+ renderedEnd: number,
1072
+ ): { start: number; end: number } | null {
1073
+ const base = session.artifact.content;
1074
+ const existingCuts = session.textCuts ?? [];
1075
+
1076
+ if ((session.workingCopy ?? base) !== applyTextCuts(base, existingCuts)) return null;
1077
+ const startBlock = display[displayIndex];
1078
+ const endBlock = display[endDisplayIndex];
1079
+
1080
+ if (!startBlock?.base || !endBlock?.base) return null;
1081
+ if (existingCuts.length === 0) {
1082
+ const workRange = renderedSpanToWork(
1083
+ display,
1084
+ displayIndex,
1085
+ endDisplayIndex,
1086
+ renderedStart,
1087
+ renderedEnd,
964
1088
  );
965
- this.setStatus("block cut - it serializes into the diff");
1089
+ const sourceStart = sourceOffsetAt(base, startBlock.base, workRange.start);
1090
+ const sourceEnd = sourceOffsetAt(base, endBlock.base, workRange.end);
1091
+
1092
+ return sourceStart === undefined || sourceEnd === undefined
1093
+ ? null
1094
+ : { start: sourceStart, end: sourceEnd };
966
1095
  }
1096
+ const startRange = baseRangeForRendered(
1097
+ blockRuns(startBlock, true),
1098
+ renderedStart,
1099
+ displayIndex === endDisplayIndex ? renderedEnd : renderedText(startBlock).length,
1100
+ );
1101
+ const endRange =
1102
+ displayIndex === endDisplayIndex
1103
+ ? startRange
1104
+ : baseRangeForRendered(blockRuns(endBlock, true), 0, renderedEnd);
1105
+
1106
+ if (!startRange || !endRange) return null;
1107
+
1108
+ const sourceStart = sourceOffsetAt(base, startBlock.base, startRange.start);
1109
+ const sourceEnd = sourceOffsetAt(base, endBlock.base, endRange.end);
1110
+
1111
+ return sourceStart === undefined || sourceEnd === undefined
1112
+ ? null
1113
+ : { start: sourceStart, end: sourceEnd };
967
1114
  }
968
1115
 
969
1116
  /** Re-insert a cut plan block at the next surviving block's line (Cut toggle + rail undo). */
@@ -989,7 +1136,6 @@ class Controller implements ReviewController {
989
1136
 
990
1137
  if (expected.workingCopy === undefined) delete expected.workingCopy;
991
1138
  this.applyOptimistic(expected, this.client!.sessionRestoreBlock(session.id, baseIndex, line));
992
- this.setStatus(REMOVAL_RESTORED_STATUS);
993
1139
  }
994
1140
 
995
1141
  // ── diff hunk curation ──────────────────────
@@ -1028,7 +1174,6 @@ class Controller implements ReviewController {
1028
1174
 
1029
1175
  if (this.rejections.some(wholeHunk)) {
1030
1176
  this.curate(this.rejections.filter((rejection) => !wholeHunk(rejection)));
1031
- this.setStatus("hunk restored");
1032
1177
  } else {
1033
1178
  // a whole-hunk reject supersedes any change-level rejects inside it
1034
1179
  const others = this.rejections.filter(
@@ -1037,7 +1182,6 @@ class Controller implements ReviewController {
1037
1182
  );
1038
1183
 
1039
1184
  this.curate([...others, target]);
1040
- this.setStatus("hunk rejected - dropped from the working copy");
1041
1185
  }
1042
1186
  }
1043
1187
 
@@ -1055,10 +1199,8 @@ class Controller implements ReviewController {
1055
1199
  if (wholeCovers) return this.setStatus("the whole hunk is rejected - restore it first");
1056
1200
  if (this.rejections.some((rejection) => sameRejection(rejection, target))) {
1057
1201
  this.curate(this.rejections.filter((rejection) => !sameRejection(rejection, target)));
1058
- this.setStatus("change restored");
1059
1202
  } else {
1060
1203
  this.curate([...this.rejections, target]);
1061
- this.setStatus("change rejected - dropped from the working copy");
1062
1204
  }
1063
1205
  }
1064
1206
 
@@ -1116,7 +1258,6 @@ class Controller implements ReviewController {
1116
1258
 
1117
1259
  if (kept.length === this.rejections.length) return;
1118
1260
  this.curate(kept);
1119
- this.setStatus(REMOVAL_RESTORED_STATUS);
1120
1261
 
1121
1262
  return;
1122
1263
  }
@@ -1197,10 +1338,7 @@ class Controller implements ReviewController {
1197
1338
  try {
1198
1339
  const result = editInEditor(this.working(), "plan.md", { editor: this.editor });
1199
1340
 
1200
- if (result.changed) {
1201
- this.saveEditedBody(result.content);
1202
- this.setStatus("edits tracked - one diff");
1203
- } else this.setStatus("no changes");
1341
+ if (result.changed) this.saveEditedBody(result.content);
1204
1342
  } catch (err) {
1205
1343
  this.setStatus(err instanceof Error ? err.message : String(err));
1206
1344
  }
@@ -1444,7 +1582,6 @@ class Controller implements ReviewController {
1444
1582
 
1445
1583
  if (!session) return;
1446
1584
  this.apply(this.client!.sessionRemoveAnnotation(session.id, id));
1447
- this.setStatus("annotation deleted");
1448
1585
  }
1449
1586
 
1450
1587
  setWorkingCopy(content: string | undefined): void {
@@ -1516,6 +1653,11 @@ class Controller implements ReviewController {
1516
1653
  // The completion overlay heading already states the message, so the
1517
1654
  // status line stays empty here - only export/error messages fill it.
1518
1655
  this.update({ session: resolved, status: "" });
1656
+ if (message === "comment") {
1657
+ this.setStatus("comment sent - thread stays open");
1658
+
1659
+ return;
1660
+ }
1519
1661
  // notes-vault export: guarded by each exporter's policy (default manual = no-op)
1520
1662
  for (const exporter of this.exporters) {
1521
1663
  if (!exporter.runsOn(message)) continue;
@@ -1623,7 +1765,6 @@ class Controller implements ReviewController {
1623
1765
  { ...session, shares: next },
1624
1766
  this.client.sessionSetShares(session.id, next),
1625
1767
  );
1626
- this.setStatus("link updated");
1627
1768
  }
1628
1769
 
1629
1770
  deleteShareLink(id: string): void {
@@ -1638,7 +1779,6 @@ class Controller implements ReviewController {
1638
1779
  { ...session, shares: remaining },
1639
1780
  this.client.sessionSetShares(session.id, remaining),
1640
1781
  );
1641
- this.setStatus("link revoked");
1642
1782
  }
1643
1783
 
1644
1784
  copyShareLink(id: string): void {
@@ -1657,9 +1797,7 @@ class Controller implements ReviewController {
1657
1797
 
1658
1798
  if (!session || links.length === 0) return this.setStatus("this thread is not shared");
1659
1799
  this.stopShareSync();
1660
- void Promise.all(links.map((link) => this.shareTransport.revoke(link.id).catch(() => {})))
1661
- .then(() => this.setStatus("sharing stopped"))
1662
- .catch(() => {});
1800
+ void Promise.all(links.map((link) => this.shareTransport.revoke(link.id).catch(() => {})));
1663
1801
  void this.client?.sessionSetShares(session.id, []).catch(() => {});
1664
1802
  }
1665
1803
 
@@ -1675,7 +1813,6 @@ class Controller implements ReviewController {
1675
1813
  this.moveTree(session, switchBranch(session.history, target.branch), () =>
1676
1814
  this.client!.sessionSwitch(session.id, target.branch),
1677
1815
  );
1678
- this.setStatus(`on branch ${target.branch}`);
1679
1816
 
1680
1817
  return;
1681
1818
  }
@@ -1688,11 +1825,6 @@ class Controller implements ReviewController {
1688
1825
  this.moveTree(session, moved, () =>
1689
1826
  this.client!.sessionNavigate(session.id, entryId, options.summary, branch),
1690
1827
  );
1691
- this.setStatus(
1692
- options.summary === undefined
1693
- ? "moved back - later entries stay in the tree"
1694
- : "moved back with a summary",
1695
- );
1696
1828
  }
1697
1829
 
1698
1830
  branch(name: string): void {
@@ -1706,7 +1838,6 @@ class Controller implements ReviewController {
1706
1838
  this.moveTree(session, createBranch(session.history, branchName), () =>
1707
1839
  this.client!.sessionBranch(session.id, branchName),
1708
1840
  );
1709
- this.setStatus(`on branch ${branchName}`);
1710
1841
  }
1711
1842
 
1712
1843
  labelTip(label: string): void {
@@ -1718,7 +1849,6 @@ class Controller implements ReviewController {
1718
1849
  this.moveTree(session, labelTip(session.history, name), () =>
1719
1850
  this.client!.sessionLabel(session.id, name),
1720
1851
  );
1721
- this.setStatus(`checkpoint ${name}`);
1722
1852
  }
1723
1853
 
1724
1854
  fork(): void {
@@ -1729,7 +1859,6 @@ class Controller implements ReviewController {
1729
1859
  .sessionFork(session.id)
1730
1860
  .then((fork) => {
1731
1861
  this.update({ session: fork });
1732
- this.showToast(`forked ${session.id} - you are on the fork now`, "fork");
1733
1862
  })
1734
1863
  .catch((cause: unknown) =>
1735
1864
  this.setStatus(`fork failed: ${cause instanceof Error ? cause.message : String(cause)}`),
package/src/tree-view.ts CHANGED
@@ -57,7 +57,12 @@ export function describeEntry(entry: SessionEntry, round?: number): EntryDescrip
57
57
  return { glyph: "·", text: "comment removed" };
58
58
  case "message":
59
59
  return {
60
- glyph: entry.message.outcome === "approved" ? "✓" : "✗",
60
+ glyph:
61
+ entry.message.outcome === "approved"
62
+ ? "✓"
63
+ : entry.message.outcome === "changes_requested"
64
+ ? "✗"
65
+ : "·",
61
66
  text: entry.message.outcome.replace("_", " "),
62
67
  };
63
68
  case "branch-summary":
@@ -104,10 +104,16 @@ export interface AnnotationSurfaceOptions {
104
104
  onAnnotate: (span: TextSpan, body: string) => void;
105
105
  onReply: (rootAnnotationId: string, body: string) => void;
106
106
  onUpdateAnnotation: (id: string, body: string) => void;
107
+ /** The visible scroll viewport used to keep a held mouse mark moving at its edges. */
108
+ dragViewport?: () => {
109
+ top: number;
110
+ bottom: number;
111
+ scrollBy: (rows: -1 | 1) => boolean;
112
+ } | null;
107
113
  /** The author's display name for a comment's hover tooltip; the rail resolves it against the participant registry. */
108
114
  resolveAuthorLabel?: (annotation: Annotation) => string | undefined;
109
115
  /** Resolve a nav-mode key to a session/curation/tree/diff command; true when it acted. */
110
- onNavCommand?: (key: KeyEvent) => boolean;
116
+ onNavCommand?: (key: KeyEvent, selection: TextSpan | null) => boolean;
111
117
  onExit: () => void;
112
118
  }
113
119
 
@@ -164,6 +170,14 @@ function firstAnnotatable(source: LineSource): number {
164
170
  return 0;
165
171
  }
166
172
 
173
+ /** The visual-row direction requested by an arrow or readline-style caret key. */
174
+ function verticalCaretDelta(key: KeyEvent): -1 | 0 | 1 {
175
+ if (key.name === "up" || (key.ctrl && key.name === "p")) return -1;
176
+ if (key.name === "down" || (key.ctrl && key.name === "n")) return 1;
177
+
178
+ return 0;
179
+ }
180
+
167
181
  export function useAnnotationSurface(options: AnnotationSurfaceOptions): AnnotationSurface {
168
182
  const {
169
183
  source,
@@ -183,6 +197,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
183
197
  onAnnotate,
184
198
  onReply,
185
199
  onUpdateAnnotation,
200
+ dragViewport,
186
201
  resolveAuthorLabel,
187
202
  onNavCommand,
188
203
  onExit,
@@ -199,6 +214,11 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
199
214
  return { head: start, anchor: start };
200
215
  });
201
216
  const [compose, setCompose] = useState<ComposeState | null>(null);
217
+ const dragPointer = useRef<{ x: number; y: number } | null>(null);
218
+ const edgeScrollTimer = useRef<ReturnType<typeof setInterval> | null>(null);
219
+ const dragViewportRef = useRef(dragViewport);
220
+
221
+ dragViewportRef.current = dragViewport;
202
222
  const [navMode, setNavModeState] = useState(false);
203
223
  const navModeRef = useRef(false);
204
224
  const setNavMode = (value: boolean): void => {
@@ -316,6 +336,27 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
316
336
  x: entry.renderable.x,
317
337
  y: entry.renderable.y,
318
338
  }));
339
+ const verticalTextPosition = (position: TextPosition, direction: -1 | 1): TextPosition | null => {
340
+ const lines = allGeometry()
341
+ .filter((line) => source.annotatable(line.blockIndex))
342
+ .toSorted((left, right) => left.y - right.y || left.x - right.x);
343
+ const currentIndex = lines.findIndex(
344
+ (line) =>
345
+ line.blockIndex === position.blockIndex &&
346
+ line.start <= position.char &&
347
+ position.char <= line.end,
348
+ );
349
+ const target = currentIndex === -1 ? undefined : lines[currentIndex + direction];
350
+
351
+ if (!target) return null;
352
+ const current = lines[currentIndex]!;
353
+ const column = Math.max(0, position.char - current.start);
354
+
355
+ return {
356
+ blockIndex: target.blockIndex,
357
+ char: Math.min(target.end, target.start + column),
358
+ };
359
+ };
319
360
 
320
361
  const openCompose = (state: ComposeState): void => {
321
362
  // a view-only surface (a non-diff thread's live diff) never opens a draft
@@ -428,14 +469,34 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
428
469
  // on screen - the mark follows the pointer across blocks.
429
470
  const handleRootDrag = (event: TerminalMouseEvent): void => {
430
471
  if (!dragging.current) return;
472
+ dragPointer.current = { x: event.x, y: event.y };
431
473
  const position = positionAt(allGeometry(), event.x, event.y);
432
474
 
433
475
  if (position) extendSelectionTo(position);
476
+ if (edgeScrollTimer.current === null) {
477
+ edgeScrollTimer.current = setInterval(() => {
478
+ const pointer = dragPointer.current;
479
+ const viewport = dragViewportRef.current?.();
480
+
481
+ if (!dragging.current || !pointer || !viewport) return;
482
+ const direction = pointer.y <= viewport.top ? -1 : pointer.y >= viewport.bottom ? 1 : 0;
483
+
484
+ if (direction === 0 || !viewport.scrollBy(direction)) return;
485
+ const next = positionAt(allGeometry(), pointer.x, pointer.y);
486
+
487
+ if (next) extendSelectionTo(next);
488
+ }, 100);
489
+ }
434
490
  };
435
491
  const endDrag = (): void => {
436
492
  dragging.current = null;
493
+ dragPointer.current = null;
494
+ if (edgeScrollTimer.current !== null) clearInterval(edgeScrollTimer.current);
495
+ edgeScrollTimer.current = null;
437
496
  };
438
497
 
498
+ useEffect(() => endDrag, []);
499
+
439
500
  const jumpToDiscussion = (key: string): void => {
440
501
  const target = discussions.find((discussion) => discussion.key === key);
441
502
 
@@ -611,22 +672,21 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
611
672
  };
612
673
 
613
674
  const handleCaretKey = (key: KeyEvent): boolean => {
614
- const vertical =
615
- key.name === "up" || (key.ctrl && key.name === "p")
616
- ? -1
617
- : key.name === "down" || (key.ctrl && key.name === "n")
618
- ? 1
619
- : 0;
675
+ const vertical = verticalCaretDelta(key);
620
676
 
621
677
  if (vertical !== 0) {
622
- const next = nearestAnnotatable(source, cursor, vertical);
678
+ const nextBlock = nearestAnnotatable(source, cursor, vertical);
679
+ const nextHead = verticalTextPosition(head, vertical) ?? {
680
+ blockIndex: nextBlock,
681
+ char: key.shift ? Math.min(head.char, textLengthOf(nextBlock)) : 0,
682
+ };
623
683
 
624
684
  setCaret({
625
- head: { blockIndex: next, char: 0 },
626
- anchor: { blockIndex: next, char: 0 },
685
+ head: nextHead,
686
+ anchor: key.shift ? caret.anchor : nextHead,
627
687
  });
628
688
  setFocusedDiscussion(null);
629
- setCursor(next);
689
+ setCursor(nextHead.blockIndex);
630
690
 
631
691
  return true;
632
692
  }
@@ -682,7 +742,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
682
742
  if (navModeRef.current) {
683
743
  if (handleCaretKey(key)) return;
684
744
  if (key.name === "z") return toggleFoldAtCursor();
685
- if (onNavCommand?.(key)) return;
745
+ if (onNavCommand?.(key, heldSpan)) return;
686
746
  if (key.name === "c") {
687
747
  setNavMode(false);
688
748
 
@@ -31,6 +31,32 @@ export interface TerminalVirtualizer {
31
31
  scrollToIndex: (index: number, align?: "auto" | "start" | "center" | "end") => void;
32
32
  }
33
33
 
34
+ /** Visible rows and one-row scrolling for a held mouse mark at a viewport edge. */
35
+ export interface ScrollDragViewport {
36
+ top: number;
37
+ bottom: number;
38
+ scrollBy: (rows: -1 | 1) => boolean;
39
+ }
40
+
41
+ /** Adapt an OpenTUI scrollbox to the annotation surface's edge-scroll contract. */
42
+ export function scrollBoxDragViewport(
43
+ scrollbox: ScrollBoxRenderable | null,
44
+ ): ScrollDragViewport | null {
45
+ if (!scrollbox) return null;
46
+
47
+ return {
48
+ top: scrollbox.viewport.screenY,
49
+ bottom: scrollbox.viewport.screenY + scrollbox.viewport.height - 1,
50
+ scrollBy: (rows) => {
51
+ const before = scrollbox.scrollTop;
52
+
53
+ scrollbox.scrollBy(rows);
54
+
55
+ return scrollbox.scrollTop !== before;
56
+ },
57
+ };
58
+ }
59
+
34
60
  /**
35
61
  * The scroll container as the core reads it: the offset and the extents it clamps a scroll
36
62
  * target with. These are the `Element` members of the same names, so the view passes as one.