@cueloop/client 0.1.0-alpha.92 → 0.1.0-alpha.93

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 (37) hide show
  1. package/package.json +5 -5
  2. package/src/App.deleted-scroll.test.tsx +186 -0
  3. package/src/App.diff.test.tsx +3 -4
  4. package/src/App.test.tsx +1 -0
  5. package/src/App.toast.test.tsx +27 -1
  6. package/src/App.tsx +21 -12
  7. package/src/App.workbench.test.tsx +208 -2
  8. package/src/app-screens.tsx +34 -8
  9. package/src/components/AppShell.zoom.test.tsx +16 -3
  10. package/src/components/ChangesColumn.tsx +10 -4
  11. package/src/components/ChangesFileTree.test.tsx +122 -4
  12. package/src/components/DiffContentView.scroll.test.tsx +285 -1
  13. package/src/components/DiffContentView.tsx +214 -56
  14. package/src/components/EditorGrid.split.test.tsx +118 -0
  15. package/src/components/EditorGrid.stories.tsx +27 -0
  16. package/src/components/EditorGrid.tsx +170 -50
  17. package/src/components/EditorGrid.zoom.test.tsx +65 -1
  18. package/src/components/GridTabContent.actions.test.tsx +151 -0
  19. package/src/components/GridTabContent.stories.tsx +2 -2
  20. package/src/components/GridTabContent.tsx +5 -1
  21. package/src/components/ProjectTreeView.test.tsx +166 -8
  22. package/src/components/ProjectTreeView.tsx +53 -27
  23. package/src/components/ScrollArea.tsx +21 -2
  24. package/src/components/Toast.tsx +3 -1
  25. package/src/components/WelcomePlayground.test.tsx +28 -1
  26. package/src/components/__snapshots__/stories.test.tsx.snap +41 -21
  27. package/src/components/editor-grid.test.ts +87 -0
  28. package/src/components/editor-grid.ts +142 -6
  29. package/src/components/primitives/Tree.tsx +51 -15
  30. package/src/thread-controller.curate.test.ts +27 -0
  31. package/src/thread-controller.ts +4 -6
  32. package/src/use-annotation-surface.tsx +86 -19
  33. package/src/use-changes-workbench.ts +30 -18
  34. package/src/use-frame-measure.ts +40 -7
  35. package/src/use-settings-dialog.tsx +1 -13
  36. package/src/use-shared-keyboard.ts +55 -0
  37. package/src/use-terminal-virtualizer.ts +8 -2
@@ -3,10 +3,14 @@ import {
3
3
  addTab,
4
4
  changesTab,
5
5
  closeTab,
6
+ countEditorGroups,
6
7
  containsGroup,
7
8
  fileTab,
8
9
  firstGroupId,
10
+ keepFileTab,
9
11
  makeGroup,
12
+ MAX_EDITOR_GROUPS,
13
+ openFileTab,
10
14
  splitGroup,
11
15
  type EditorBranch,
12
16
  type EditorGroup,
@@ -38,6 +42,42 @@ describe("editor grid", () => {
38
42
  expect(next.activeTabId).toBe(file.id);
39
43
  });
40
44
 
45
+ test("a new preview replaces the group's previous preview", () => {
46
+ const group = makeGroup([changesTab()]);
47
+ const first = asGroup(openFileTab(group, group.id, "src/a.ts", "contents", false).tree);
48
+ const second = asGroup(openFileTab(first, group.id, "src/b.ts", "contents", false).tree);
49
+
50
+ expect(second.tabs.map((tab) => tab.path)).toEqual([undefined, "src/b.ts"]);
51
+ expect(second.tabs[1]?.preview).toBe(true);
52
+ });
53
+
54
+ test("keeping a preview makes subsequent previews open beside it", () => {
55
+ const group = makeGroup([changesTab()]);
56
+ const preview = asGroup(openFileTab(group, group.id, "src/a.ts", "contents", false).tree);
57
+ const kept = asGroup(keepFileTab(preview, group.id, preview.activeTabId!));
58
+ const next = asGroup(openFileTab(kept, group.id, "src/b.ts", "contents", false).tree);
59
+
60
+ expect(next.tabs.map((tab) => tab.path)).toEqual([undefined, "src/a.ts", "src/b.ts"]);
61
+ expect(next.tabs[1]?.preview).toBe(false);
62
+ });
63
+
64
+ test("reopening a file focuses its existing tab across groups and changes its view", () => {
65
+ const group = makeGroup([changesTab()]);
66
+ const open = asGroup(openFileTab(group, group.id, "src/a.ts", "contents", true).tree);
67
+ const { tree } = splitGroup(open, group.id, "right");
68
+ const branch = asBranch(tree);
69
+ const right = asGroup(branch.children[1]!);
70
+ const reopened = openFileTab(tree, group.id, "src/a.ts", "diff", false);
71
+
72
+ expect(reopened.focusGroupId).toBe(right.id);
73
+ expect(asGroup(asBranch(reopened.tree).children[1]!).tabs).toHaveLength(1);
74
+ expect(asGroup(asBranch(reopened.tree).children[1]!).tabs[0]).toMatchObject({
75
+ path: "src/a.ts",
76
+ fileView: "diff",
77
+ preview: false,
78
+ });
79
+ });
80
+
41
81
  test("splitting right makes a horizontal branch and focuses the new group", () => {
42
82
  const group = makeGroup([fileTab("App.tsx", "src/App.tsx", "diff")]);
43
83
  const { tree, focusGroupId } = splitGroup(group, group.id, "right");
@@ -48,6 +88,7 @@ describe("editor grid", () => {
48
88
  // the new group is the second child (right edge) and takes focus
49
89
  expect(branch.children[1]!.id).toBe(focusGroupId);
50
90
  expect(firstGroupId(tree)).toBe(group.id);
91
+ expect(asGroup(branch.children[0]!).tabs.some((tab) => tab.path === "src/App.tsx")).toBe(false);
51
92
  });
52
93
 
53
94
  test("splitting up makes a vertical branch with the new group first", () => {
@@ -58,6 +99,32 @@ describe("editor grid", () => {
58
99
  expect(branch.children[0]!.id).toBe(focusGroupId);
59
100
  });
60
101
 
102
+ test("splitting a file-only group keeps the Changes tab unique", () => {
103
+ const changes = makeGroup([changesTab()]);
104
+ const file = makeGroup([fileTab("a.ts", "a.ts", "diff")]);
105
+ const tree: EditorNode = {
106
+ type: "branch",
107
+ id: "two-groups",
108
+ orientation: "horizontal",
109
+ children: [changes, file],
110
+ };
111
+ const split = splitGroup(tree, file.id, "down");
112
+
113
+ expect(asBranch(split.tree).children[0]).toBe(changes);
114
+ expect(asBranch(asBranch(split.tree).children[1]!).children[0]).toMatchObject({
115
+ tabs: [{ kind: "welcome" }],
116
+ });
117
+ });
118
+
119
+ test("splitting Changes moves it to the new group", () => {
120
+ const group = makeGroup([changesTab()]);
121
+ const split = splitGroup(group, group.id, "right");
122
+ const branch = asBranch(split.tree);
123
+
124
+ expect(asGroup(branch.children[0]!).tabs[0]?.kind).toBe("welcome");
125
+ expect(asGroup(branch.children[1]!).tabs[0]?.kind).toBe("changes");
126
+ });
127
+
61
128
  test("closing the last tab of a split collapses the branch back to one group", () => {
62
129
  const group = makeGroup([fileTab("a.ts", "a.ts", "diff")]);
63
130
  const { tree } = splitGroup(group, group.id, "right");
@@ -83,4 +150,24 @@ describe("editor grid", () => {
83
150
  const collapsed = closeTab(tree, rightGroup.id, rightGroup.tabs[0]!.id);
84
151
  expect(containsGroup(asGroup(collapsed), rightGroup.id)).toBe(false);
85
152
  });
153
+
154
+ test("mixed split directions stop at eight editor groups", () => {
155
+ const first = makeGroup([changesTab()]);
156
+ let tree: EditorNode = first;
157
+ let focusGroupId = first.id;
158
+ const directions = ["right", "up", "left", "down"] as const;
159
+
160
+ for (const direction of [...directions, ...directions]) {
161
+ const result = splitGroup(tree, focusGroupId, direction);
162
+
163
+ tree = result.tree;
164
+ focusGroupId = result.focusGroupId;
165
+ }
166
+
167
+ expect(countEditorGroups(tree)).toBe(MAX_EDITOR_GROUPS);
168
+ const excess = splitGroup(tree, focusGroupId, "right");
169
+
170
+ expect(excess.tree).toBe(tree);
171
+ expect(excess.focusGroupId).toBe(focusGroupId);
172
+ });
86
173
  });
@@ -1,6 +1,6 @@
1
1
  // The Changes pane's editor layout: a tree of nodes where a leaf is an editor group (a tab strip plus
2
- // its one active tab) and a branch splits child nodes along an orientation. Splitting an editor group
3
- // moves its active tab into a new group; a group is pruned when its last tab closes, collapsing the
2
+ // its one active tab) and a branch splits child nodes along an orientation. Splitting a file tab
3
+ // moves it into a new group; a group is pruned when its last tab closes, collapsing the
4
4
  // branch that held it.
5
5
 
6
6
  /** One editor in a group's tab strip: the Changes tab (the whole diff), a per-file tab, or Welcome. */
@@ -12,6 +12,8 @@ export interface EditorTab {
12
12
  path?: string;
13
13
  /** A file tab shows either a single-file diff or the file's plain contents. */
14
14
  fileView?: "diff" | "contents";
15
+ /** A preview is replaced by the next file opened in its group. */
16
+ preview?: boolean;
15
17
  }
16
18
 
17
19
  /** A leaf of the grid: one editor group holding tabs, with the active tab id. */
@@ -35,6 +37,9 @@ export type EditorNode = EditorGroup | EditorBranch;
35
37
  /** Which edge a split places the new group on; Left/Right split horizontally, Up/Down vertically. */
36
38
  export type SplitDirection = "left" | "right" | "up" | "down";
37
39
 
40
+ /** The editor grid stops at eight groups so every tile remains usable. */
41
+ export const MAX_EDITOR_GROUPS = 8;
42
+
38
43
  /** A split's outcome: the new grid and the id of the group that should take focus. */
39
44
  export interface SplitResult {
40
45
  tree: EditorNode;
@@ -59,8 +64,13 @@ export function welcomeTab(): EditorTab {
59
64
  }
60
65
 
61
66
  /** A file tab, opened from a tree; a diff view for a changed file, plain contents otherwise. */
62
- export function fileTab(label: string, path: string, fileView: "diff" | "contents"): EditorTab {
63
- return { id: freshId(), kind: "file", label, path, fileView };
67
+ export function fileTab(
68
+ label: string,
69
+ path: string,
70
+ fileView: "diff" | "contents",
71
+ preview = false,
72
+ ): EditorTab {
73
+ return { id: freshId(), kind: "file", label, path, fileView, preview };
64
74
  }
65
75
 
66
76
  /** The id of the first editor group in reading order; the fallback focus target. */
@@ -74,6 +84,13 @@ export function containsGroup(node: EditorNode, groupId: string): boolean {
74
84
  return node.children.some((child) => containsGroup(child, groupId));
75
85
  }
76
86
 
87
+ /** Count editor tiles, including groups inside nested horizontal and vertical splits. */
88
+ export function countEditorGroups(node: EditorNode): number {
89
+ if (node.type === "group") return 1;
90
+
91
+ return node.children.reduce((count, child) => count + countEditorGroups(child), 0);
92
+ }
93
+
77
94
  function replaceGroup(
78
95
  node: EditorNode,
79
96
  id: string,
@@ -97,6 +114,111 @@ export function addTab(node: EditorNode, groupId: string, tab: EditorTab): Edito
97
114
  }));
98
115
  }
99
116
 
117
+ /** Activate the one Changes tab, or add it beside the active group's existing tabs. */
118
+ export function openChangesTab(node: EditorNode, groupId: string): SplitResult {
119
+ const existing = findTabByKind(node, "changes");
120
+
121
+ if (existing) {
122
+ return {
123
+ tree: activateTab(node, existing.groupId, existing.tab.id),
124
+ focusGroupId: existing.groupId,
125
+ };
126
+ }
127
+
128
+ return { tree: addTab(node, groupId, changesTab()), focusGroupId: groupId };
129
+ }
130
+
131
+ function findTabByKind(
132
+ node: EditorNode,
133
+ kind: EditorTab["kind"],
134
+ ): { groupId: string; tab: EditorTab } | null {
135
+ if (node.type === "group") {
136
+ const tab = node.tabs.find((candidate) => candidate.kind === kind);
137
+
138
+ return tab ? { groupId: node.id, tab } : null;
139
+ }
140
+
141
+ for (const child of node.children) {
142
+ const found = findTabByKind(child, kind);
143
+
144
+ if (found) return found;
145
+ }
146
+
147
+ return null;
148
+ }
149
+
150
+ interface FileTabLocation {
151
+ groupId: string;
152
+ tab: EditorTab;
153
+ }
154
+
155
+ /** A file path is one editor tab across the grid, even when the groups are split. */
156
+ function findFileTab(node: EditorNode, path: string): FileTabLocation | null {
157
+ if (node.type === "group") {
158
+ const tab = node.tabs.find((candidate) => candidate.kind === "file" && candidate.path === path);
159
+
160
+ return tab ? { groupId: node.id, tab } : null;
161
+ }
162
+
163
+ for (const child of node.children) {
164
+ const found = findFileTab(child, path);
165
+
166
+ if (found) return found;
167
+ }
168
+
169
+ return null;
170
+ }
171
+
172
+ /** Open a file once, replacing only a preview; reopening focuses its existing tab. */
173
+ export function openFileTab(
174
+ node: EditorNode,
175
+ groupId: string,
176
+ path: string,
177
+ fileView: "diff" | "contents",
178
+ persistent: boolean,
179
+ ): SplitResult {
180
+ const existing = findFileTab(node, path);
181
+
182
+ if (existing) {
183
+ return {
184
+ tree: replaceGroup(node, existing.groupId, (group) => ({
185
+ ...group,
186
+ tabs: group.tabs.map((tab) =>
187
+ tab.id === existing.tab.id
188
+ ? { ...tab, fileView, preview: persistent ? false : tab.preview }
189
+ : tab,
190
+ ),
191
+ activeTabId: existing.tab.id,
192
+ })),
193
+ focusGroupId: existing.groupId,
194
+ };
195
+ }
196
+
197
+ const label = path.split("/").pop() ?? path;
198
+ const tab = fileTab(label, path, fileView, !persistent);
199
+
200
+ return {
201
+ tree: replaceGroup(node, groupId, (group) => {
202
+ const previewIndex = persistent ? -1 : group.tabs.findIndex((item) => item.preview);
203
+ const tabs = [...group.tabs];
204
+
205
+ if (previewIndex >= 0) tabs.splice(previewIndex, 1, tab);
206
+ else tabs.push(tab);
207
+
208
+ return { ...group, tabs, activeTabId: tab.id };
209
+ }),
210
+ focusGroupId: groupId,
211
+ };
212
+ }
213
+
214
+ /** Keep a preview file tab open when its tab is double-clicked. */
215
+ export function keepFileTab(node: EditorNode, groupId: string, tabId: string): EditorNode {
216
+ return replaceGroup(node, groupId, (group) => ({
217
+ ...group,
218
+ tabs: group.tabs.map((tab) => (tab.id === tabId ? { ...tab, preview: false } : tab)),
219
+ }));
220
+ }
221
+
100
222
  /** Drop empty groups and collapse a branch that ends up with one child; null when nothing remains. */
101
223
  export function pruneEmpty(node: EditorNode): EditorNode | null {
102
224
  if (node.type === "group") return node.tabs.length > 0 ? node : null;
@@ -122,16 +244,30 @@ export function splitGroup(
122
244
  groupId: string,
123
245
  direction: SplitDirection,
124
246
  ): SplitResult {
247
+ if (countEditorGroups(node) >= MAX_EDITOR_GROUPS) return { tree: node, focusGroupId: groupId };
248
+
125
249
  let focusGroupId = groupId;
126
250
  const tree = replaceGroup(node, groupId, (group) => {
127
251
  const active = group.tabs.find((tab) => tab.id === group.activeTabId) ?? group.tabs[0];
128
252
  if (active === undefined) return group;
129
- const movedTab: EditorTab = { ...active, id: freshId() };
253
+ const movedTab: EditorTab =
254
+ active.kind === "welcome" ? { ...active, id: freshId() } : { ...active, preview: false };
130
255
  const newGroup = makeGroup([movedTab]);
131
256
  focusGroupId = newGroup.id;
132
257
  const orientation = direction === "left" || direction === "right" ? "horizontal" : "vertical";
133
258
  const newFirst = direction === "left" || direction === "up";
134
- const children = newFirst ? [newGroup, group] : [group, newGroup];
259
+ const remainingTabs =
260
+ active.kind === "welcome" ? group.tabs : group.tabs.filter((tab) => tab.id !== active.id);
261
+ const sourceTabs =
262
+ remainingTabs.length > 0
263
+ ? remainingTabs
264
+ : [findTabByKind(node, "changes") ? welcomeTab() : changesTab()];
265
+ const sourceGroup = {
266
+ ...group,
267
+ tabs: sourceTabs,
268
+ activeTabId: sourceTabs[0]!.id,
269
+ };
270
+ const children = newFirst ? [newGroup, sourceGroup] : [sourceGroup, newGroup];
135
271
  return { type: "branch", id: freshId(), orientation, children };
136
272
  });
137
273
  return { tree, focusGroupId };
@@ -1,7 +1,8 @@
1
1
  // OpenTUI renderer for the headless tree-model; the caller owns expansion and selection.
2
2
 
3
- import React from "react";
3
+ import React, { useRef } from "react";
4
4
  import { DARK, type Theme } from "../../theme";
5
+ import { isDoubleClick, type ClickStamp } from "../../thread-selection";
5
6
  import { flattenTree, statusMeta, type TreeNode, type TreeTone } from "./tree-model";
6
7
  import { NERD_TREE_ICONS, type TreeIcons } from "./icons";
7
8
 
@@ -13,11 +14,14 @@ export interface TreeProps {
13
14
  showStatus?: boolean;
14
15
  /** Drop the folder/file glyph prefix, for a plain text tree (e.g. the settings nav). */
15
16
  hideIcons?: boolean;
17
+ /** Keep file navigator entries to one visual row, truncating long names. */
18
+ singleLine?: boolean;
16
19
  /** The selected row's backdrop; defaults to `elevated` (invisible on an elevated dialog, use `border` there). */
17
20
  selectedBackground?: string;
18
21
  indentWidth?: number;
19
22
  icons?: TreeIcons;
20
23
  onSelect?: (id: string) => void;
24
+ onDoubleSelect?: (id: string) => void;
21
25
  onToggle?: (id: string) => void;
22
26
  theme?: Theme;
23
27
  }
@@ -43,16 +47,19 @@ export function Tree({
43
47
  flattenEmptyDirectories,
44
48
  showStatus,
45
49
  hideIcons,
50
+ singleLine = false,
46
51
  selectedBackground,
47
52
  indentWidth = 2,
48
53
  icons = NERD_TREE_ICONS,
49
54
  onSelect,
55
+ onDoubleSelect,
50
56
  onToggle,
51
57
  theme,
52
58
  }: TreeProps): React.ReactNode {
53
59
  const tokens = theme ?? DARK;
54
60
  const selectedBackdrop = selectedBackground ?? tokens.elevated;
55
61
  const rows = flattenTree(nodes, { expandedIds, flattenEmptyDirectories });
62
+ const lastClick = useRef<{ id: string; stamp: ClickStamp } | null>(null);
56
63
 
57
64
  return (
58
65
  <box style={{ flexDirection: "column" }}>
@@ -73,25 +80,54 @@ export function Tree({
73
80
  id={`tree-row-${row.id}`}
74
81
  style={{
75
82
  flexDirection: "row",
83
+ justifyContent: "space-between",
84
+ height: singleLine ? 1 : undefined,
85
+ overflow: singleLine ? "hidden" : undefined,
76
86
  paddingLeft: 1 + row.depth * indentWidth,
77
- paddingRight: 1,
87
+ paddingRight: singleLine && status ? 0 : 1,
78
88
  backgroundColor: selected ? selectedBackdrop : undefined,
79
89
  }}
80
- onMouseUp={() => (row.isFolder ? onToggle?.(row.id) : onSelect?.(row.id))}
90
+ onMouseUp={(event) => {
91
+ const stamp = { time: Date.now(), x: event.x, y: event.y };
92
+ const doubleClick =
93
+ lastClick.current?.id === row.id && isDoubleClick(lastClick.current.stamp, stamp);
94
+
95
+ lastClick.current = { id: row.id, stamp };
96
+ if (row.isFolder) onToggle?.(row.id);
97
+ else {
98
+ onSelect?.(row.id);
99
+ if (doubleClick) onDoubleSelect?.(row.id);
100
+ }
101
+ }}
81
102
  >
82
- {hideIcons ? null : (
83
- <text fg={row.isFolder ? tokens.blue : tokens.textDim}>
84
- {row.icon ?? rowGlyph(row.isFolder, row.expanded, icons)}{" "}
85
- </text>
86
- )}
87
- <box style={{ flexShrink: 1, minWidth: 0 }}>
88
- <text fg={selected ? tokens.accent : labelColor} truncate>
89
- {row.label}
90
- </text>
103
+ <box
104
+ style={{
105
+ flexDirection: "row",
106
+ flexShrink: 1,
107
+ minWidth: 0,
108
+ overflow: singleLine ? "hidden" : undefined,
109
+ }}
110
+ >
111
+ {hideIcons ? null : (
112
+ <text fg={row.isFolder ? tokens.blue : tokens.textDim}>
113
+ {row.icon ?? rowGlyph(row.isFolder, row.expanded, icons)}{" "}
114
+ </text>
115
+ )}
116
+ <box style={{ flexShrink: 1, minWidth: 0 }}>
117
+ <text
118
+ id={`tree-label-${row.id}`}
119
+ fg={selected ? tokens.accent : labelColor}
120
+ wrapMode={singleLine ? "none" : undefined}
121
+ truncate={!singleLine}
122
+ >
123
+ {singleLine ? `${row.label} ` : row.label}
124
+ </text>
125
+ </box>
126
+ </box>
127
+ <box style={{ flexDirection: "row", flexShrink: 0 }}>
128
+ {row.badge !== undefined ? <text fg={tokens.textDim}>{row.badge}</text> : null}
129
+ {status ? <text fg={toneColor(status.tone, tokens)}> {status.letter}</text> : null}
91
130
  </box>
92
- <box style={{ flexGrow: 1 }} />
93
- {row.badge !== undefined ? <text fg={tokens.textDim}>{row.badge}</text> : null}
94
- {status ? <text fg={toneColor(status.tone, tokens)}> {status.letter}</text> : null}
95
131
  </box>
96
132
  );
97
133
  })}
@@ -431,6 +431,33 @@ function tailSession(): Thread {
431
431
  }
432
432
 
433
433
  describe("diff file fold", () => {
434
+ test("added and deleted files with a zero-sided hunk retain their expand action", async () => {
435
+ const patch = [
436
+ "diff --git a/src/new.ts b/src/new.ts",
437
+ "--- /dev/null",
438
+ "+++ b/src/new.ts",
439
+ "@@ -0,0 +1,1 @@",
440
+ "+new line",
441
+ "diff --git a/src/gone.ts b/src/gone.ts",
442
+ "--- a/src/gone.ts",
443
+ "+++ /dev/null",
444
+ "@@ -1,1 +0,0 @@",
445
+ "-old line",
446
+ "",
447
+ ].join("\n");
448
+ const files: DiffFileContents[] = [
449
+ { path: "src/new.ts", oldContents: "", newContents: "new line\n", status: "added" },
450
+ { path: "src/gone.ts", oldContents: "old line\n", newContents: "", status: "deleted" },
451
+ ];
452
+ const session = diffSession(files);
453
+ session.artifact.content = patch;
454
+ session.revisions[0]!.content = patch;
455
+ const { controller } = await connected(session);
456
+
457
+ expect(controller.canExpandFile("src/new.ts")).toBe(true);
458
+ expect(controller.canExpandFile("src/gone.ts")).toBe(true);
459
+ });
460
+
434
461
  test("collapse leaves only the file band; expand restores the body", async () => {
435
462
  // Arrange
436
463
  const { controller } = await connected(tailSession());
@@ -293,8 +293,8 @@ export interface ReviewController {
293
293
  isFileExpanded(file: string): boolean;
294
294
  /** Whether the file carries the full contents weaving needs (curatable diffs do). */
295
295
  canExpandFile(file: string): boolean;
296
- /** Copy a file's path to the clipboard; reports the outcome via status. */
297
- copyFilePath(file: string): void;
296
+ /** Copy a file's path to the clipboard; the control reports the outcome. */
297
+ copyFilePath(file: string): Promise<boolean>;
298
298
  /** Per-file added/removed line counts from the base rows (survives collapse). */
299
299
  fileStats(): Map<string, { additions: number; deletions: number }>;
300
300
  /** The walk's step list, derived from the diff rows. */
@@ -782,10 +782,8 @@ class Controller implements ReviewController {
782
782
  return (this.foldFiles() ?? []).some((entry) => entry.path === file);
783
783
  }
784
784
 
785
- copyFilePath(file: string): void {
786
- void copyToClipboard(file).then((copied) =>
787
- this.setStatus(copied ? `copied ${file}` : "no clipboard tool available"),
788
- );
785
+ copyFilePath(file: string): Promise<boolean> {
786
+ return copyToClipboard(file);
789
787
  }
790
788
 
791
789
  fileStats(): Map<string, { additions: number; deletions: number }> {
@@ -14,7 +14,8 @@
14
14
 
15
15
  import React, { useContext, useEffect, useRef, useState } from "react";
16
16
  import type { KeyEvent, MouseEvent as TerminalMouseEvent, TextRenderable } from "@opentui/core";
17
- import { useKeyboard } from "@opentui/react";
17
+ import { flushSync } from "@opentui/react";
18
+ import { useSharedKeyboard } from "./use-shared-keyboard";
18
19
  import type { Annotation, Thread } from "@cueloop/schema";
19
20
  import type { Mark } from "./view-plan";
20
21
  import type { QuickAction } from "./config";
@@ -97,6 +98,13 @@ export interface AnnotationSurfaceOptions {
97
98
  onObserverBlocked?: (reason: "observer" | "resolved") => void;
98
99
  /** Reports the caret's block, so block-level primitives (cut, restore) act where the caret is. */
99
100
  onCursorChange?: (blockIndex: number) => void;
101
+ /** Scroll a visual row before crossing a tall gap; true defers the caret move. */
102
+ onVerticalStep?: (
103
+ fromBlock: number,
104
+ toBlock: number,
105
+ direction: -1 | 1,
106
+ targetY?: number,
107
+ ) => boolean;
100
108
  /** The rail's focused card; the discussion holding it takes focus here. */
101
109
  focusedAnnotationId?: string;
102
110
  /** Reports the focused discussion's root comment, so the rail follows. */
@@ -129,6 +137,10 @@ export interface AnnotationSurface {
129
137
  focusedDiscussion: string | null;
130
138
  /** The block to keep in view: an opening card, a focused discussion, else the caret. */
131
139
  revealBlockIndex: number;
140
+ /** Screen row of the mounted visual line carrying the caret. */
141
+ headVisualY: () => number | undefined;
142
+ /** Capture mounted lines before a viewport scroll, then keep an exiting caret at its visible edge. */
143
+ prepareViewportScroll: () => (delta: number, top: number, bottom: number) => void;
132
144
  /** The text a span covers, blocks joined by a space, for previews. */
133
145
  spanQuote: (span: TextSpan) => string;
134
146
  /** Ref callback for one visual line, so a drag can hit-test it. */
@@ -192,6 +204,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
192
204
  onComposingChange,
193
205
  onObserverBlocked,
194
206
  onCursorChange,
207
+ onVerticalStep,
195
208
  focusedAnnotationId,
196
209
  onFocusAnnotation,
197
210
  onAnnotate,
@@ -336,15 +349,55 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
336
349
  x: entry.renderable.x,
337
350
  y: entry.renderable.y,
338
351
  }));
339
- const verticalTextPosition = (position: TextPosition, direction: -1 | 1): TextPosition | null => {
352
+ const headVisualY = (): number | undefined => {
353
+ const textLength = textLengthOf(head.blockIndex);
354
+ const cell = Math.max(0, Math.min(textLength - 1, head.char));
355
+
356
+ return allGeometry().find(
357
+ (line) =>
358
+ line.blockIndex === head.blockIndex &&
359
+ line.start <= cell &&
360
+ (cell < line.end || line.start === line.end),
361
+ )?.y;
362
+ };
363
+ const prepareViewportScroll = (): ((delta: number, top: number, bottom: number) => void) => {
364
+ const lines = allGeometry().filter((line) => source.annotatable(line.blockIndex));
365
+ const oldHeadY = headVisualY();
366
+
367
+ return (delta, top, bottom) => {
368
+ if (delta === 0 || oldHeadY === undefined || compose || focusedDiscussion || caretIsSelection)
369
+ return;
370
+ const shiftedHeadY = oldHeadY - delta;
371
+
372
+ if (shiftedHeadY >= top && shiftedHeadY <= bottom) return;
373
+ const visible = lines
374
+ .filter((line) => line.y - delta >= top && line.y - delta <= bottom)
375
+ .toSorted((left, right) => left.y - right.y || left.x - right.x);
376
+ const target = delta < 0 ? visible.at(-1) : visible[0];
377
+
378
+ if (!target) return;
379
+ const next = { blockIndex: target.blockIndex, char: target.start };
380
+
381
+ flushSync(() => {
382
+ setCaret({ head: next, anchor: next });
383
+ setCursor(next.blockIndex);
384
+ });
385
+ };
386
+ };
387
+ const verticalTextPosition = (
388
+ position: TextPosition,
389
+ direction: -1 | 1,
390
+ ): { position: TextPosition; y: number } | null => {
391
+ const textLength = textLengthOf(position.blockIndex);
392
+ const cell = Math.max(0, Math.min(textLength - 1, position.char));
340
393
  const lines = allGeometry()
341
394
  .filter((line) => source.annotatable(line.blockIndex))
342
395
  .toSorted((left, right) => left.y - right.y || left.x - right.x);
343
396
  const currentIndex = lines.findIndex(
344
397
  (line) =>
345
398
  line.blockIndex === position.blockIndex &&
346
- line.start <= position.char &&
347
- position.char <= line.end,
399
+ line.start <= cell &&
400
+ (cell < line.end || line.start === line.end),
348
401
  );
349
402
  const target = currentIndex === -1 ? undefined : lines[currentIndex + direction];
350
403
 
@@ -353,8 +406,11 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
353
406
  const column = Math.max(0, position.char - current.start);
354
407
 
355
408
  return {
356
- blockIndex: target.blockIndex,
357
- char: Math.min(target.end, target.start + column),
409
+ position: {
410
+ blockIndex: target.blockIndex,
411
+ char: Math.min(target.end, target.start + column),
412
+ },
413
+ y: target.y,
358
414
  };
359
415
  };
360
416
 
@@ -671,22 +727,31 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
671
727
  );
672
728
  };
673
729
 
730
+ const moveCaretVertical = (key: KeyEvent, vertical: -1 | 1): void => {
731
+ const nextBlock = nearestAnnotatable(source, cursor, vertical);
732
+ const visualTarget = verticalTextPosition(head, vertical);
733
+
734
+ if (!visualTarget && nextBlock === head.blockIndex) return;
735
+ const nextHead = visualTarget?.position ?? {
736
+ blockIndex: nextBlock,
737
+ char: key.shift ? Math.min(head.char, textLengthOf(nextBlock)) : 0,
738
+ };
739
+
740
+ if (onVerticalStep?.(head.blockIndex, nextHead.blockIndex, vertical, visualTarget?.y)) return;
741
+
742
+ setCaret({
743
+ head: nextHead,
744
+ anchor: key.shift ? caret.anchor : nextHead,
745
+ });
746
+ setFocusedDiscussion(null);
747
+ setCursor(nextHead.blockIndex);
748
+ };
749
+
674
750
  const handleCaretKey = (key: KeyEvent): boolean => {
675
751
  const vertical = verticalCaretDelta(key);
676
752
 
677
753
  if (vertical !== 0) {
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
- };
683
-
684
- setCaret({
685
- head: nextHead,
686
- anchor: key.shift ? caret.anchor : nextHead,
687
- });
688
- setFocusedDiscussion(null);
689
- setCursor(nextHead.blockIndex);
754
+ moveCaretVertical(key, vertical);
690
755
 
691
756
  return true;
692
757
  }
@@ -726,7 +791,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
726
791
  openNewCompose(sequence);
727
792
  };
728
793
 
729
- useKeyboard((key) => {
794
+ useSharedKeyboard((key) => {
730
795
  if (suspended) return;
731
796
  if (key.ctrl && key.name === "q") return onExit();
732
797
  const activeCompose = composeRef.current;
@@ -963,6 +1028,8 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
963
1028
  navMode,
964
1029
  focusedDiscussion,
965
1030
  revealBlockIndex,
1031
+ headVisualY,
1032
+ prepareViewportScroll,
966
1033
  spanQuote,
967
1034
  registerLine,
968
1035
  onLineMouseDown,