@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,78 @@
1
+ /** Per-file collapse and full-file weave over the flat diff rows. */
2
+
3
+ import { describe, expect, test } from "bun:test";
4
+ import type { DiffRow } from "./view-diff";
5
+ import { applyFold, weaveFullFileRows } from "./diff-fold";
6
+
7
+ function row(kind: DiffRow["kind"], text: string, extra: Partial<DiffRow> = {}): DiffRow {
8
+ return { kind, text, file: "a.ts", ...extra };
9
+ }
10
+
11
+ // a two-line change in a four-line file: line 2 swapped, lines 1/3/4 unchanged
12
+ const base: DiffRow[] = [
13
+ row("file", "a.ts"),
14
+ row("hunk", "@@ -1,3 +1,3 @@"),
15
+ row("ctx", "line one", { oldLine: 1, newLine: 1 }),
16
+ row("del", "old two", { oldLine: 2 }),
17
+ row("add", "new two", { newLine: 2 }),
18
+ row("ctx", "line three", { oldLine: 3, newLine: 3 }),
19
+ ];
20
+
21
+ const contents = {
22
+ path: "a.ts",
23
+ oldContents: "line one\nold two\nline three\nline four\n",
24
+ newContents: "line one\nnew two\nline three\nline four\n",
25
+ };
26
+
27
+ describe("applyFold", () => {
28
+ test("with no fold state it returns the base rows unchanged", () => {
29
+ expect(applyFold(base, new Set(), new Set(), [contents])).toBe(base);
30
+ });
31
+
32
+ test("a collapsed file keeps only its file band", () => {
33
+ // Act
34
+ const folded = applyFold(base, new Set(["a.ts"]), new Set(), [contents]);
35
+
36
+ // Assert
37
+ expect(folded).toHaveLength(1);
38
+ expect(folded[0]).toEqual(row("file", "a.ts"));
39
+ });
40
+
41
+ test("an expanded file weaves the unchanged tail line the hunk never showed", () => {
42
+ // Act
43
+ const folded = applyFold(base, new Set(), new Set(["a.ts"]), [contents]);
44
+
45
+ // Assert - the hunk header is gone and "line four" (outside the hunk) now shows
46
+ expect(folded.some((r) => r.kind === "hunk")).toBe(false);
47
+ const four = folded.find((r) => r.text === "line four");
48
+
49
+ expect(four).toEqual(row("ctx", "line four", { oldLine: 4, newLine: 4 }));
50
+ });
51
+
52
+ test("expand is a no-op without the file's full contents", () => {
53
+ // Act - no contents entry, so the file keeps its hunks
54
+ const folded = applyFold(base, new Set(), new Set(["a.ts"]), undefined);
55
+
56
+ // Assert
57
+ expect(folded).toEqual(base);
58
+ });
59
+ });
60
+
61
+ describe("weaveFullFileRows", () => {
62
+ test("keeps the change rows and fills every unchanged line as context in order", () => {
63
+ // Arrange - the file's code rows (no headers)
64
+ const codeRows = base.slice(2);
65
+
66
+ // Act
67
+ const woven = weaveFullFileRows("a.ts", codeRows, contents.newContents);
68
+
69
+ // Assert - full file: ctx1, del, add, ctx3, ctx4
70
+ expect(woven.map((r) => `${r.kind}:${r.text}`)).toEqual([
71
+ "ctx:line one",
72
+ "del:old two",
73
+ "add:new two",
74
+ "ctx:line three",
75
+ "ctx:line four",
76
+ ]);
77
+ });
78
+ });
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Per-file fold state for the diff sheet, applied over the flat diff rows so the
3
+ * cursor, curation, and rendering all read one array. A collapsed file shows only
4
+ * its file band; an expanded file shows its whole contents with the diff woven in
5
+ * (unchanged lines become context rows), matching VSCode's "expand all lines".
6
+ */
7
+
8
+ import type { DiffRow } from "./view-diff";
9
+
10
+ /** The full old/new contents a diff carries per file (curatable diffs do). */
11
+ export interface FileContents {
12
+ path: string;
13
+ newContents: string;
14
+ oldContents: string;
15
+ }
16
+
17
+ /** Split file contents into lines, dropping the single trailing newline the file carries. */
18
+ function contentLines(contents: string): string[] {
19
+ if (contents === "") return [];
20
+
21
+ return contents.replace(/\n$/, "").split("\n");
22
+ }
23
+
24
+ /**
25
+ * Weave a file's hunk rows into its full contents: unchanged lines between and
26
+ * around the hunks become context rows, so the whole file renders with the diff
27
+ * inline. `codeRows` are the file's ctx/add/del rows in order (no headers).
28
+ */
29
+ export function weaveFullFileRows(
30
+ fileName: string,
31
+ codeRows: DiffRow[],
32
+ newContents: string,
33
+ ): DiffRow[] {
34
+ const newLines = contentLines(newContents);
35
+ const woven: DiffRow[] = [];
36
+ // the next unchanged old/new line numbers to emit while filling gaps between hunks
37
+ let newCursor = 1;
38
+ let oldCursor = 1;
39
+ const fillTo = (newLine: number): void => {
40
+ while (newCursor < newLine) {
41
+ woven.push({
42
+ kind: "ctx",
43
+ text: newLines[newCursor - 1] ?? "",
44
+ file: fileName,
45
+ oldLine: oldCursor,
46
+ newLine: newCursor,
47
+ });
48
+ newCursor += 1;
49
+ oldCursor += 1;
50
+ }
51
+ };
52
+
53
+ for (const row of codeRows) {
54
+ if (row.newLine !== undefined) {
55
+ fillTo(row.newLine);
56
+ woven.push(row);
57
+ newCursor = row.newLine + 1;
58
+ if (row.kind === "ctx") oldCursor = (row.oldLine ?? oldCursor) + 1;
59
+ } else {
60
+ // a deletion consumes an old line only; it keeps its place before the additions
61
+ woven.push(row);
62
+ oldCursor = (row.oldLine ?? oldCursor) + 1;
63
+ }
64
+ }
65
+ // the unchanged tail after the last hunk
66
+ while (newCursor <= newLines.length) {
67
+ woven.push({
68
+ kind: "ctx",
69
+ text: newLines[newCursor - 1] ?? "",
70
+ file: fileName,
71
+ oldLine: oldCursor,
72
+ newLine: newCursor,
73
+ });
74
+ newCursor += 1;
75
+ oldCursor += 1;
76
+ }
77
+
78
+ return woven;
79
+ }
80
+
81
+ /**
82
+ * Rebuild the diff rows with per-file fold state. A collapsed file keeps only its
83
+ * file band; an expanded file with known contents shows its whole file woven; every
84
+ * other file keeps its hunks. Row order and the file bands themselves are preserved.
85
+ */
86
+ export function applyFold(
87
+ base: DiffRow[],
88
+ collapsed: ReadonlySet<string>,
89
+ expanded: ReadonlySet<string>,
90
+ files: readonly FileContents[] | undefined,
91
+ ): DiffRow[] {
92
+ if (collapsed.size === 0 && expanded.size === 0) return base;
93
+ const contentsByPath = new Map((files ?? []).map((file) => [file.path, file]));
94
+ const out: DiffRow[] = [];
95
+ let index = 0;
96
+
97
+ while (index < base.length) {
98
+ const row = base[index]!;
99
+
100
+ if (row.kind !== "file") {
101
+ out.push(row);
102
+ index += 1;
103
+ continue;
104
+ }
105
+ out.push(row);
106
+ const file = row.file;
107
+ let end = index + 1;
108
+
109
+ while (end < base.length && base[end]!.kind !== "file") end += 1;
110
+ const body = base.slice(index + 1, end);
111
+ const contents = contentsByPath.get(file);
112
+
113
+ if (collapsed.has(file)) {
114
+ // only the file band; the body is hidden
115
+ } else if (expanded.has(file) && contents) {
116
+ const codeRows = body.filter((bodyRow) => bodyRow.kind !== "hunk");
117
+
118
+ out.push(...weaveFullFileRows(file, codeRows, contents.newContents));
119
+ } else {
120
+ out.push(...body);
121
+ }
122
+ index = end;
123
+ }
124
+
125
+ return out;
126
+ }
@@ -41,6 +41,22 @@ describe("spansByLine", () => {
41
41
  expect(lineSpans[1]).toEqual([{ start: 0, end: 2, group: "comment" }]);
42
42
  });
43
43
 
44
+ test("a capture ending past the source clamps to its end instead of walking forever", () => {
45
+ // Arrange - offsets past the string, as byte offsets over a multi-byte line report
46
+ const source = "a│b\ncc";
47
+ const highlights: SimpleHighlight[] = [
48
+ [0, 9, "string"],
49
+ [12, 20, "comment"],
50
+ ];
51
+
52
+ // Act
53
+ const lineSpans = spansByLine(source, highlights);
54
+
55
+ // Assert - the covered text is colored to the end and the second capture paints nothing
56
+ expect(lineSpans[0]).toEqual([{ start: 0, end: 3, group: "string" }]);
57
+ expect(lineSpans[1]).toEqual([{ start: 0, end: 2, group: "string" }]);
58
+ });
59
+
44
60
  test("resolves overlapping captures with later winning", () => {
45
61
  // Arrange
46
62
  const source = "foo";
@@ -111,10 +111,13 @@ export function spansByLine(source: string, highlights: SimpleHighlight[]): Synt
111
111
  return Array.from({ length: Math.max(0, end - start) }, () => undefined);
112
112
  });
113
113
 
114
- for (const [start, end, group] of highlights) {
114
+ for (const [start, rawEnd, group] of highlights) {
115
115
  // A capture can span newlines (block comments, template literals); color the
116
- // covered columns on every line it crosses, not just the starting line.
117
- let cursor = start;
116
+ // covered columns on every line it crosses, not just the starting line. Offsets
117
+ // past the source (byte offsets from a multi-byte line) clamp to its end, or the
118
+ // walk below would sit on the last line forever.
119
+ const end = Math.min(rawEnd, source.length);
120
+ let cursor = Math.max(0, start);
118
121
 
119
122
  while (cursor < end) {
120
123
  const line = lineOf(cursor);
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Discussions: the conversations an annotated surface hosts inline. A root
3
+ * annotation, its replies, and the span they mark, grouped so two roots on the
4
+ * very same text read as one conversation. Pure over the resolved marks, so the
5
+ * plan thread view and the diff sheet group the same way.
6
+ */
7
+
8
+ import type { Annotation, ReviewSession } from "@cueloop/schema";
9
+ import type { Mark } from "./view-plan";
10
+ import { comparePositions, type TextSpan } from "./thread-selection";
11
+
12
+ /** One conversation: a root annotation, its replies, and the span they mark. */
13
+ export interface Discussion {
14
+ key: string;
15
+ /** The annotation replies attach to. */
16
+ rootId: string;
17
+ /** The block the card renders under: where the span ends. */
18
+ blockIndex: number;
19
+ span: TextSpan;
20
+ annotations: Annotation[];
21
+ }
22
+
23
+ export function spanKey(span: TextSpan): string {
24
+ return `${span.start.blockIndex}:${span.start.char}:${span.end.blockIndex}:${span.end.char}`;
25
+ }
26
+
27
+ /**
28
+ * Roots group by the span they resolved to (two roots on the very same text
29
+ * read as one conversation); replies join the discussion their replyTo names, or
30
+ * stand as roots when that root is gone.
31
+ */
32
+ export function discussionsFrom(session: ReviewSession, marks: Map<number, Mark[]>): Discussion[] {
33
+ const byId = new Map(session.annotations.map((annotation) => [annotation.id, annotation]));
34
+ const spanOf = new Map<string, TextSpan>();
35
+
36
+ for (const [displayIndex, blockMarks] of marks) {
37
+ for (const mark of blockMarks) {
38
+ if (!mark.annotationId || spanOf.has(mark.annotationId)) continue;
39
+ spanOf.set(
40
+ mark.annotationId,
41
+ mark.span ?? {
42
+ start: { blockIndex: displayIndex, char: mark.start },
43
+ end: { blockIndex: displayIndex, char: mark.end },
44
+ },
45
+ );
46
+ }
47
+ }
48
+ const resolved = [...spanOf.keys()].map((id) => byId.get(id)!).filter(Boolean);
49
+ const isRoot = (annotation: Annotation): boolean =>
50
+ annotation.replyTo === undefined || !spanOf.has(annotation.replyTo);
51
+ const grouped = new Map<string, Discussion>();
52
+ const discussionOfRoot = new Map<string, Discussion>();
53
+
54
+ for (const annotation of resolved.filter(isRoot)) {
55
+ const span = spanOf.get(annotation.id)!;
56
+ const key = spanKey(span);
57
+ const discussion = grouped.get(key) ?? {
58
+ key,
59
+ rootId: annotation.id,
60
+ blockIndex: span.end.blockIndex,
61
+ span,
62
+ annotations: [],
63
+ };
64
+
65
+ discussion.annotations.push(annotation);
66
+ grouped.set(key, discussion);
67
+ discussionOfRoot.set(annotation.id, discussion);
68
+ }
69
+ for (const annotation of resolved.filter((candidate) => !isRoot(candidate))) {
70
+ discussionOfRoot.get(annotation.replyTo!)?.annotations.push(annotation);
71
+ }
72
+ for (const discussion of grouped.values()) {
73
+ discussion.annotations.sort((left, right) => left.createdAt.localeCompare(right.createdAt));
74
+ }
75
+
76
+ return [...grouped.values()].toSorted((left, right) =>
77
+ comparePositions(left.span.start, right.span.start),
78
+ );
79
+ }
@@ -71,10 +71,23 @@ function baseController(): ReviewController {
71
71
  dismissToast: mock(),
72
72
  display: mock(() => []),
73
73
  rows: mock(() => []),
74
+ setFileCollapsed: mock(),
75
+ isFileCollapsed: mock(() => false),
76
+ setFileExpanded: mock(),
77
+ isFileExpanded: mock(() => false),
78
+ canExpandFile: mock(() => false),
79
+ copyFilePath: mock(),
80
+ fileStats: mock(() => new Map()),
74
81
  files: mock(() => []),
75
82
  working: mock(() => ""),
83
+ projectFiles: mock(() => Promise.resolve<string[]>([])),
84
+ readFile: mock(() => Promise.resolve<string | null>(null)),
85
+ repoFiles: mock(() => Promise.resolve<string[]>([])),
86
+ repoReadFile: mock(() => Promise.resolve<string | null>(null)),
87
+ repoChanges: mock(() => Promise.resolve([])),
76
88
  open: mock(),
77
89
  deleteSession: mock(),
90
+ renameSession: mock(),
78
91
  setSelfName: mock(),
79
92
  cut: mock(),
80
93
  toggleRejectHunk: mock(),
@@ -106,7 +119,6 @@ function baseController(): ReviewController {
106
119
  finishReview: mock(),
107
120
  dismissCompletion: mock(),
108
121
  optInAutoClose: mock(),
109
- saveReviewPanel: mock(),
110
122
  };
111
123
  }
112
124
 
@@ -123,9 +135,6 @@ function makeDeps(overrides: Partial<IntentDispatchDeps> = {}): IntentDispatchDe
123
135
  inboxCursor: 0,
124
136
  mode: { type: "normal" },
125
137
  session: null,
126
- reviewMode: "expanded",
127
- reviewWidth: 34,
128
- terminalWidth: 120,
129
138
  focusedAnnotationId: undefined,
130
139
  selectedCurationId: undefined,
131
140
  railTab: "review",
@@ -133,13 +142,11 @@ function makeDeps(overrides: Partial<IntentDispatchDeps> = {}): IntentDispatchDe
133
142
  authorNames: {},
134
143
  quickActions: [],
135
144
  renameAuthor: mock(),
145
+ renameThread: mock(),
136
146
  liveInput: { current: "" },
137
- reviewWidthRef: { current: 34 },
138
147
  setCursor: mock(),
139
148
  setInboxCursor: mock(),
140
149
  setMode: mock(),
141
- setReviewMode: mock(),
142
- setReviewWidth: mock(),
143
150
  setRailTab: mock(),
144
151
  setSelectedEntryId: mock(),
145
152
  setFocusedAnnotationId: mock(),
@@ -148,6 +155,7 @@ function makeDeps(overrides: Partial<IntentDispatchDeps> = {}): IntentDispatchDe
148
155
  selectCardFromDocument: mock(),
149
156
  runEditorHandOff: mock(),
150
157
  openCardEdit: mock(),
158
+ toggleDiffView: mock(),
151
159
  ...overrides,
152
160
  };
153
161
  }
@@ -349,17 +357,15 @@ describe("marker-actions popover", () => {
349
357
  });
350
358
 
351
359
  describe("openSubmit", () => {
352
- test("force-opens the review rail so the confirm card can never be hidden", () => {
360
+ test("opens the submit confirm with the default verdict", () => {
353
361
  // Arrange
354
- const deps = makeDeps({ session: sessionWith([]), reviewMode: "hidden" });
362
+ const deps = makeDeps({ session: sessionWith([]) });
355
363
  const dispatch = createIntentDispatch(deps);
356
364
 
357
365
  // Act
358
366
  dispatch({ type: "openSubmit" });
359
367
 
360
368
  // Assert
361
- expect(deps.setReviewMode).toHaveBeenCalledWith("expanded");
362
- expect(deps.setRailTab).toHaveBeenCalledWith("review");
363
369
  expect(deps.setMode).toHaveBeenCalledWith({ type: "submit", verdict: "approve", summary: "" });
364
370
  });
365
371
 
@@ -436,6 +442,20 @@ describe("restoreCuration", () => {
436
442
  });
437
443
  });
438
444
 
445
+ describe("toggleDiffView", () => {
446
+ test("delegates to the App-owned toggle", () => {
447
+ // Arrange
448
+ const deps = makeDeps();
449
+ const dispatch = createIntentDispatch(deps);
450
+
451
+ // Act
452
+ dispatch({ type: "toggleDiffView" });
453
+
454
+ // Assert
455
+ expect(deps.toggleDiffView).toHaveBeenCalled();
456
+ });
457
+ });
458
+
439
459
  describe("exit", () => {
440
460
  test("calls the exit hook with 0", () => {
441
461
  // Arrange
@@ -14,15 +14,11 @@ import type { DiffRow } from "./view-diff";
14
14
  import type { ReviewController } from "./session-controller";
15
15
  import type { Intent } from "./keymap";
16
16
  import type { TreeRow } from "./tree-view";
17
- import type { RailTab } from "./components/ReviewRail";
18
17
  import { quickActionBody, type QuickAction } from "./config";
19
18
  import { VERDICTS } from "./components/ConfirmCard";
20
- import {
21
- REVIEW_RESIZE_STEP,
22
- cycleReviewPanelMode,
23
- resolveReviewWidth,
24
- type ReviewPanelMode,
25
- } from "./review-panel";
19
+
20
+ /** Which pane of the session tree / review the keyboard grammar is aimed at. */
21
+ export type RailTab = "review" | "agent" | "tree";
26
22
 
27
23
  /** The one overlay/mode the TUI is in; every compose/submit/edit flow is a variant. */
28
24
  export type Mode =
@@ -41,6 +37,7 @@ export type Mode =
41
37
  | { type: "submit"; verdict: VerdictKind; summary: string }
42
38
  | { type: "confirmDelete"; sessionId: string; title: string }
43
39
  | { type: "rename"; authorId: string; text: string }
40
+ | { type: "renameThread"; sessionId: string; text: string }
44
41
  | { type: "nameSelf"; text: string }
45
42
  | { type: "treePrompt"; ask: TreeAsk; entryId?: string; text: string };
46
43
 
@@ -87,14 +84,11 @@ export interface IntentDispatchDeps {
87
84
  inboxCursor: number;
88
85
  mode: Mode;
89
86
  session: ReviewSession | null;
90
- reviewMode: ReviewPanelMode;
91
- reviewWidth: number;
92
- terminalWidth: number;
93
87
  focusedAnnotationId: string | undefined;
94
- /** The curation item selected in the rail, if any; the undo target when set. */
88
+ /** The curation item selected for undo, if any. */
95
89
  selectedCurationId: string | undefined;
96
90
  railTab: RailTab;
97
- /** The tree row selected in the rail's Tree tab; the target of go. */
91
+ /** The tree row selected in the session tree; the target of go. */
98
92
  selectedEntryId: string | undefined;
99
93
  /** Planner-local author renames, for seeding the rename prompt. */
100
94
  authorNames: Record<string, string>;
@@ -102,15 +96,14 @@ export interface IntentDispatchDeps {
102
96
  quickActions: QuickAction[];
103
97
  /** Persist an author rename and update the live overrides (App-owned). */
104
98
  renameAuthor: (id: string, name: string) => void;
99
+ /** Rename a thread's title through the daemon. */
100
+ renameThread: (id: string, title: string) => void;
105
101
 
106
102
  liveInput: MutableRefObject<string>;
107
- reviewWidthRef: MutableRefObject<number>;
108
103
 
109
104
  setCursor: Dispatch<SetStateAction<number>>;
110
105
  setInboxCursor: Dispatch<SetStateAction<number>>;
111
106
  setMode: Dispatch<SetStateAction<Mode>>;
112
- setReviewMode: Dispatch<SetStateAction<ReviewPanelMode>>;
113
- setReviewWidth: Dispatch<SetStateAction<number>>;
114
107
  setRailTab: Dispatch<SetStateAction<RailTab>>;
115
108
  setSelectedEntryId: Dispatch<SetStateAction<string | undefined>>;
116
109
  setFocusedAnnotationId: Dispatch<SetStateAction<string | undefined>>;
@@ -120,6 +113,8 @@ export interface IntentDispatchDeps {
120
113
  selectCardFromDocument: (annotationId: string) => void;
121
114
  runEditorHandOff: () => void;
122
115
  openCardEdit: (annotationId: string) => void;
116
+ /** Flip unified/split diff and persist it (App-owned); split lays out only when zoomed. */
117
+ toggleDiffView: () => void;
123
118
  }
124
119
 
125
120
  type IntentOfType<Kind extends Intent["type"]> = Extract<Intent, { type: Kind }>;
@@ -132,8 +127,55 @@ function handleStatus(intent: IntentOfType<"status">, deps: IntentDispatchDeps):
132
127
  deps.controller.setStatus(intent.message);
133
128
  }
134
129
 
130
+ /** A diff cursor rests on code lines only; file and hunk headers are structural dividers. */
131
+ function isCodeRow(row: DiffRow | undefined): boolean {
132
+ return row?.kind === "ctx" || row?.kind === "add" || row?.kind === "del";
133
+ }
134
+
135
+ /**
136
+ * A landable diff row: any code line, plus a collapsed file's band (a file row with
137
+ * no body before the next file or the end) so the cursor can reach it to expand it.
138
+ */
139
+ function isLandableRow(rows: DiffRow[], index: number): boolean {
140
+ const row = rows[index];
141
+
142
+ if (isCodeRow(row)) return true;
143
+ if (row?.kind !== "file") return false;
144
+ const next = rows[index + 1];
145
+
146
+ return next === undefined || next.kind === "file";
147
+ }
148
+
149
+ /** The next landable index in the move direction, skipping expanded headers; stays put at an edge. */
150
+ function nextCodeRowIndex(rows: DiffRow[], from: number, to: IntentOfType<"move">["to"]): number {
151
+ if (to === "top") {
152
+ for (let index = 0; index < rows.length; index++) if (isLandableRow(rows, index)) return index;
153
+
154
+ return from;
155
+ }
156
+ if (to === "bottom") {
157
+ for (let index = rows.length - 1; index >= 0; index--) {
158
+ if (isLandableRow(rows, index)) return index;
159
+ }
160
+
161
+ return from;
162
+ }
163
+ const step = to === "down" ? 1 : -1;
164
+
165
+ for (let index = from + step; index >= 0 && index < rows.length; index += step) {
166
+ if (isLandableRow(rows, index)) return index;
167
+ }
168
+
169
+ return from;
170
+ }
171
+
135
172
  function handleMove(intent: IntentOfType<"move">, deps: IntentDispatchDeps): void {
136
- const navigableCount = deps.isDiff ? deps.rows.length : deps.display.length;
173
+ if (deps.isDiff) {
174
+ deps.setCursor((current) => nextCodeRowIndex(deps.rows, current, intent.to));
175
+
176
+ return;
177
+ }
178
+ const navigableCount = deps.display.length;
137
179
 
138
180
  if (intent.to === "down") deps.setCursor((current) => Math.min(navigableCount - 1, current + 1));
139
181
  else if (intent.to === "up") deps.setCursor((current) => Math.max(0, current - 1));
@@ -194,6 +236,7 @@ function handleConfirmDialog(
194
236
 
195
237
  if (mode.type === "confirmDelete") controller.deleteSession(mode.sessionId);
196
238
  else if (mode.type === "rename") deps.renameAuthor(mode.authorId, mode.text.trim());
239
+ else if (mode.type === "renameThread") deps.renameThread(mode.sessionId, mode.text.trim());
197
240
  else if (mode.type === "nameSelf") controller.setSelfName(mode.text.trim());
198
241
  else if (mode.type === "treePrompt") confirmTreePrompt(mode, deps);
199
242
  deps.setMode({ type: "normal" });
@@ -335,11 +378,6 @@ function handleOpenSubmit(_intent: IntentOfType<"openSubmit">, deps: IntentDispa
335
378
 
336
379
  if (!session) return;
337
380
  deps.liveInput.current = "";
338
- // the confirm card lives in the expanded review rail; a compact or hidden
339
- // panel would swallow the whole submit flow, so force the rail open (live
340
- // only - the saved panel preference is left untouched)
341
- deps.setReviewMode("expanded");
342
- deps.setRailTab("review");
343
381
  deps.setMode({ type: "submit", verdict: defaultVerdict(session), summary: "" });
344
382
  }
345
383
 
@@ -359,6 +397,37 @@ function handleRejectChange(_intent: IntentOfType<"rejectChange">, deps: IntentD
359
397
  deps.controller.toggleRejectChange(deps.cursor);
360
398
  }
361
399
 
400
+ function handleFoldFile(_intent: IntentOfType<"foldFile">, deps: IntentDispatchDeps): void {
401
+ const file = deps.rows[deps.cursor]?.file;
402
+
403
+ if (!file || deps.controller.isFileCollapsed(file)) return;
404
+ deps.controller.setFileCollapsed(file, true);
405
+ // the file band is unchanged in index by its own collapse, so land the cursor on it
406
+ const headerIndex = deps.controller
407
+ .rows()
408
+ .findIndex((row) => row.kind === "file" && row.file === file);
409
+
410
+ if (headerIndex >= 0) deps.setCursor(headerIndex);
411
+ }
412
+
413
+ function handleUnfoldFile(_intent: IntentOfType<"unfoldFile">, deps: IntentDispatchDeps): void {
414
+ const file = deps.rows[deps.cursor]?.file;
415
+
416
+ if (!file || !deps.controller.isFileCollapsed(file)) return;
417
+ deps.controller.setFileCollapsed(file, false);
418
+ // drop the cursor onto the file's first code line now that its body is back
419
+ const firstCode = deps.controller.rows().findIndex((row) => row.file === file && isCodeRow(row));
420
+
421
+ if (firstCode >= 0) deps.setCursor(firstCode);
422
+ }
423
+
424
+ function handleToggleDiffView(
425
+ _intent: IntentOfType<"toggleDiffView">,
426
+ deps: IntentDispatchDeps,
427
+ ): void {
428
+ deps.toggleDiffView();
429
+ }
430
+
362
431
  function handleRestoreCuration(
363
432
  _intent: IntentOfType<"restoreCuration">,
364
433
  deps: IntentDispatchDeps,
@@ -502,31 +571,6 @@ function handleDismissCompletion(
502
571
  deps.controller.dismissCompletion();
503
572
  }
504
573
 
505
- function handleCycleReviewPanel(
506
- _intent: IntentOfType<"cycleReviewPanel">,
507
- deps: IntentDispatchDeps,
508
- ): void {
509
- const next = cycleReviewPanelMode(deps.reviewMode);
510
-
511
- deps.setReviewMode(next);
512
- deps.controller.saveReviewPanel({ mode: next });
513
- }
514
-
515
- function handleResizeReviewPanel(
516
- intent: IntentOfType<"resizeReviewPanel">,
517
- deps: IntentDispatchDeps,
518
- ): void {
519
- if (deps.reviewMode !== "expanded") return;
520
- const next = resolveReviewWidth(
521
- deps.reviewWidth + intent.direction * REVIEW_RESIZE_STEP,
522
- deps.terminalWidth,
523
- );
524
-
525
- deps.reviewWidthRef.current = next;
526
- deps.setReviewWidth(next);
527
- deps.controller.saveReviewPanel({ width: next });
528
- }
529
-
530
574
  function confirmTreePrompt(
531
575
  mode: Extract<Mode, { type: "treePrompt" }>,
532
576
  deps: IntentDispatchDeps,
@@ -613,6 +657,9 @@ const intentHandlers: IntentHandlers = {
613
657
  cut: handleCut,
614
658
  rejectHunk: handleRejectHunk,
615
659
  rejectChange: handleRejectChange,
660
+ foldFile: handleFoldFile,
661
+ unfoldFile: handleUnfoldFile,
662
+ toggleDiffView: handleToggleDiffView,
616
663
  restoreCuration: handleRestoreCuration,
617
664
  edit: handleEdit,
618
665
  editCard: handleEditCard,
@@ -631,8 +678,6 @@ const intentHandlers: IntentHandlers = {
631
678
  finishReview: handleFinishReview,
632
679
  optInAutoClose: handleOptInAutoClose,
633
680
  dismissCompletion: handleDismissCompletion,
634
- cycleReviewPanel: handleCycleReviewPanel,
635
- resizeReviewPanel: handleResizeReviewPanel,
636
681
  toggleTree: handleToggleTree,
637
682
  treeMove: handleTreeMove,
638
683
  treeGo: handleTreeGo,
@@ -23,7 +23,7 @@ describe("grammar-layer resolution", () => {
23
23
  ["g", true, "bottom"],
24
24
  ["v", false, "span"],
25
25
  ["c", false, "comment"],
26
- ["s", false, undefined],
26
+ ["s", false, "split_diff"],
27
27
  ["x", false, "cut"],
28
28
  ["e", false, "edit"],
29
29
  ["n", false, "next_annotation"],