@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cueloop/client",
3
- "version": "0.1.0-alpha.92",
3
+ "version": "0.1.0-alpha.93",
4
4
  "description": "The cueloop review UI: projection renderer, keyboard grammar, review rail",
5
5
  "homepage": "https://github.com/mmurakaru/cueloop#readme",
6
6
  "bugs": {
@@ -27,10 +27,10 @@
27
27
  "access": "public"
28
28
  },
29
29
  "dependencies": {
30
- "@cueloop/daemon": "0.1.0-alpha.92",
31
- "@cueloop/extension-api": "0.1.0-alpha.92",
32
- "@cueloop/integration-obsidian": "0.1.0-alpha.92",
33
- "@cueloop/schema": "0.1.0-alpha.92",
30
+ "@cueloop/daemon": "0.1.0-alpha.93",
31
+ "@cueloop/extension-api": "0.1.0-alpha.93",
32
+ "@cueloop/integration-obsidian": "0.1.0-alpha.93",
33
+ "@cueloop/schema": "0.1.0-alpha.93",
34
34
  "@opentui/core": "^0.5.11",
35
35
  "@opentui/keymap": "0.5.11",
36
36
  "@opentui/react": "^0.5.11",
@@ -0,0 +1,186 @@
1
+ /** Keyboard scrolling in a changed-file tab uses caret navigation, even when the file was deleted. */
2
+
3
+ import { expect, test } from "bun:test";
4
+ import { mkdtempSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
5
+ import { tmpdir } from "node:os";
6
+ import { join } from "node:path";
7
+ import React from "react";
8
+ import { ScrollBoxRenderable, type Renderable } from "@opentui/core";
9
+ import { DaemonServer } from "@cueloop/daemon";
10
+ import { App } from "./App";
11
+ import { annotationPaletteFor } from "./annotation-palette";
12
+ import { DARK } from "./theme";
13
+ import {
14
+ isolateUserConfig,
15
+ locateText,
16
+ press,
17
+ renderReadyApp,
18
+ settle,
19
+ waitForText,
20
+ } from "./test-support";
21
+
22
+ function findScrollbox(node: Renderable): ScrollBoxRenderable | null {
23
+ if (node.id === "diff-scroll" && node instanceof ScrollBoxRenderable) return node;
24
+
25
+ for (const child of node.getChildren()) {
26
+ const found = findScrollbox(child);
27
+
28
+ if (found) return found;
29
+ }
30
+
31
+ return null;
32
+ }
33
+
34
+ function hex(color: { toInts(): [number, number, number, number] }): string {
35
+ const [red, green, blue] = color.toInts();
36
+
37
+ return "#" + [red, green, blue].map((part) => part.toString(16).padStart(2, "0")).join("");
38
+ }
39
+
40
+ test("deleted file caret scrolls one row and stays at the file boundaries", async () => {
41
+ const home = mkdtempSync(join(tmpdir(), "cueloop-scroll-home-"));
42
+ const repo = mkdtempSync(join(tmpdir(), "cueloop-scroll-repo-"));
43
+ const restoreConfig = isolateUserConfig(home);
44
+ const server = new DaemonServer({ home, idleExitMs: 0 });
45
+ let setup: Awaited<ReturnType<typeof renderReadyApp>> | undefined;
46
+
47
+ try {
48
+ const file = join(repo, "long.md");
49
+ const lines = Array.from({ length: 180 }, (_, index) =>
50
+ index === 113
51
+ ? "[nodejs/node#36005](https://github.com/nodejs/node/issues/36005)."
52
+ : `row ${String(index).padStart(3, "0")} ${"a paragraph with wrapped text and a link [source](https://example.test/page) ".repeat((index % 3) + 1)}`,
53
+ );
54
+ const git = (args: string[]) => Bun.spawnSync(["git", "-C", repo, ...args]);
55
+
56
+ writeFileSync(file, lines.join("\n") + "\n");
57
+ git(["init", "-q"]);
58
+ git(["config", "user.email", "fixture@example.com"]);
59
+ git(["config", "user.name", "Fixture"]);
60
+ git(["add", "-A"]);
61
+ git(["commit", "-q", "-m", "seed"]);
62
+ unlinkSync(file);
63
+ server.start();
64
+ setup = await renderReadyApp(<App home={home} sessionId={undefined} cwd={repo} />, {
65
+ width: 180,
66
+ height: 48,
67
+ });
68
+ await waitForText(setup, "long.md");
69
+ const treeFile = locateText(setup, "long.md");
70
+
71
+ await setup.mockMouse.click(treeFile.column, treeFile.row);
72
+ await waitForText(setup, "row 00");
73
+ const box = findScrollbox(setup.renderer.root);
74
+
75
+ if (!box) throw new Error("file diff scrollbox missing");
76
+ for (let offset = 0; offset < 1000; offset += 20) {
77
+ box.scrollTo({ x: 0, y: offset });
78
+ await settle(setup);
79
+ if (setup.captureCharFrame().includes("row 130")) break;
80
+ }
81
+ const target = locateText(setup, "row 130");
82
+
83
+ await setup.mockMouse.click(target.column, target.row);
84
+ await press(setup, "escape");
85
+
86
+ const steps: number[] = [];
87
+ const caretCell = annotationPaletteFor(DARK).caretCell;
88
+ const frameTrace: Array<{ markers: number[]; caret: number }> = [];
89
+ const captureFrame = () => {
90
+ const lines = setup!.captureCharFrame().split("\n");
91
+ const markers = lines.flatMap((line, row) => (line.includes("▎") ? [row] : []));
92
+ const caret = setup!
93
+ .captureSpans()
94
+ .lines.findIndex((line) => line.spans.some((span) => hex(span.bg) === caretCell));
95
+
96
+ frameTrace.push({ markers, caret });
97
+ };
98
+
99
+ setup.renderer.on("frame", captureFrame);
100
+
101
+ for (let index = 0; index < 12; index++) {
102
+ const before = box.scrollTop;
103
+ await press(setup, "down");
104
+ steps.push(box.scrollTop - before);
105
+ const caretRow = setup
106
+ .captureSpans()
107
+ .lines.findIndex((line) => line.spans.some((span) => hex(span.bg) === caretCell));
108
+ const markerRows = setup
109
+ .captureCharFrame()
110
+ .split("\n")
111
+ .flatMap((line, row) => (line.includes("▎") ? [row] : []));
112
+
113
+ expect(caretRow).toBeGreaterThanOrEqual(0);
114
+ expect(markerRows).toEqual([caretRow]);
115
+ }
116
+
117
+ expect(Math.max(...steps)).toBeLessThanOrEqual(1);
118
+ expect(Math.min(...steps)).toBeGreaterThanOrEqual(0);
119
+ let anchored = false;
120
+
121
+ for (let index = 0; index < 80; index++) {
122
+ await press(setup, "up");
123
+ const marker = setup
124
+ .captureCharFrame()
125
+ .split("\n")
126
+ .findIndex((line) => line.includes("▎"));
127
+
128
+ if (marker === box.viewport.screenY) anchored = true;
129
+ if (anchored && box.scrollTop > 0) expect(marker).toBe(box.viewport.screenY);
130
+ }
131
+ expect(anchored).toBe(true);
132
+ box.scrollTo({ x: 0, y: 100000 });
133
+ await settle(setup);
134
+ const last = locateText(setup, "row 179");
135
+
136
+ await setup.mockMouse.click(last.column, last.row);
137
+ await press(setup, "escape");
138
+ let lastMarker = -1;
139
+
140
+ for (let index = 0; index < 12; index++) {
141
+ const traceStart = frameTrace.length;
142
+ const before = box.scrollTop;
143
+
144
+ await press(setup, "down");
145
+ const states = frameTrace.slice(traceStart);
146
+
147
+ expect(box.scrollTop).toBe(before);
148
+ expect(states.length).toBeGreaterThan(0);
149
+ for (const state of states) {
150
+ expect(state.markers).toEqual([state.caret]);
151
+ expect(state.caret).toBeGreaterThanOrEqual(lastMarker);
152
+ lastMarker = state.caret;
153
+ }
154
+ }
155
+ box.scrollTo({ x: 0, y: 0 });
156
+ await settle(setup);
157
+ const first = locateText(setup, "row 000");
158
+
159
+ await setup.mockMouse.click(first.column, first.row);
160
+ await press(setup, "escape");
161
+ let firstMarker = Number.POSITIVE_INFINITY;
162
+
163
+ for (let index = 0; index < 12; index++) {
164
+ const traceStart = frameTrace.length;
165
+ const before = box.scrollTop;
166
+
167
+ await press(setup, "up");
168
+ const states = frameTrace.slice(traceStart);
169
+
170
+ expect(box.scrollTop).toBe(before);
171
+ expect(states.length).toBeGreaterThan(0);
172
+ for (const state of states) {
173
+ expect(state.markers).toEqual([state.caret]);
174
+ expect(state.caret).toBeLessThanOrEqual(firstMarker);
175
+ firstMarker = state.caret;
176
+ }
177
+ }
178
+ setup.renderer.off("frame", captureFrame);
179
+ } finally {
180
+ setup?.renderer.destroy();
181
+ server.stop();
182
+ restoreConfig();
183
+ rmSync(home, { recursive: true, force: true });
184
+ rmSync(repo, { recursive: true, force: true });
185
+ }
186
+ }, 30000);
@@ -10,7 +10,6 @@ import type { Thread } from "@cueloop/schema";
10
10
  import { App } from "./App";
11
11
  import { pullRequestReviewLayout } from "./launch-layout";
12
12
  import { DARK } from "./theme";
13
- import { NERD } from "./components/primitives/icons";
14
13
  import {
15
14
  clickText,
16
15
  dragText,
@@ -220,7 +219,7 @@ describe("diff review", () => {
220
219
  await waitForText(setup, "● Existing note.");
221
220
  });
222
221
 
223
- test("option+c folds the caret's file to its band; the chevron restores its body", async () => {
222
+ test("option+c folds the caret's file to its band; its title restores the body", async () => {
224
223
  // Arrange - the caret opens on the file's first code line
225
224
  const setup = await renderApp();
226
225
 
@@ -234,8 +233,8 @@ describe("diff review", () => {
234
233
  expect(collapsed).toContain("src/store.ts");
235
234
  expect(collapsed).toContain("+1");
236
235
 
237
- // Act - the band's chevron unfolds it again
238
- await clickText(setup, NERD.chevronRight);
236
+ // Act - the title unfolds it again
237
+ await clickText(setup, "src/store.ts");
239
238
 
240
239
  // Assert
241
240
  await waitForText(setup, "new Map()");
package/src/App.test.tsx CHANGED
@@ -442,6 +442,7 @@ describe("the thread view and the menu", () => {
442
442
  const dialog = setup.captureCharFrame();
443
443
 
444
444
  expect(dialog).toContain("nav mode");
445
+ expect(dialog).toContain("open selected file or toggle folder");
445
446
  expect(dialog).toContain("place the caret");
446
447
  expect(dialog).not.toContain("grow/shrink");
447
448
 
@@ -1,6 +1,6 @@
1
1
  /** The share toast is non-modal: while it is up, escape still cancels an open overlay. */
2
2
 
3
- import { afterEach, beforeEach, describe, mock, test } from "bun:test";
3
+ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
4
4
  import { mkdtempSync, rmSync } from "node:fs";
5
5
  import { tmpdir } from "node:os";
6
6
  import { join } from "node:path";
@@ -13,6 +13,7 @@ import type { ShareTransport } from "./thread-controller";
13
13
  import {
14
14
  clickText,
15
15
  isolateUserConfig,
16
+ locateText,
16
17
  press,
17
18
  pressKey,
18
19
  typeText,
@@ -63,6 +64,31 @@ afterEach(() => {
63
64
  });
64
65
 
65
66
  describe("share toast", () => {
67
+ test("clicking outside dismisses the toast while clicking inside keeps it open", async () => {
68
+ const setup = await testRender(
69
+ <App home={home} sessionId={session.id} shareTransport={shareTransport} />,
70
+ { width: 120, height: 32 },
71
+ );
72
+
73
+ await waitForText(setup, "cueloop");
74
+ await pressKey(setup, "s", { ctrl: true });
75
+ await waitForText(setup, "+ new link");
76
+ await press(setup, "enter");
77
+ await press(setup, "enter");
78
+ await waitForText(setup, "link name");
79
+ await press(setup, "enter");
80
+ await waitForText(setup, "link copied");
81
+ await press(setup, "escape");
82
+ await waitForTextGone(setup, "share externally");
83
+ const body = locateText(setup, "ssh p_share01@cueloop.dev");
84
+
85
+ await setup.mockMouse.click(body.column, body.row);
86
+ expect(setup.captureCharFrame()).toContain("link copied");
87
+ await setup.mockMouse.click(1, 1);
88
+ await waitForTextGone(setup, "link copied");
89
+ setup.renderer.destroy();
90
+ });
91
+
66
92
  test("escape cancels an open composer even while the toast is up", async () => {
67
93
  // Arrange
68
94
  const setup = await testRender(
package/src/App.tsx CHANGED
@@ -330,8 +330,8 @@ function ProjectPanelBody(props: {
330
330
  diffSourceKey: string;
331
331
  liveWorkingTree: boolean;
332
332
  frozenFiles?: readonly DiffFileContents[];
333
- onOpenChangedFile: (path: string) => void;
334
- onOpenProjectFile: (path: string) => void;
333
+ onOpenChangedFile: (path: string, persistent?: boolean) => void;
334
+ onOpenProjectFile: (path: string, persistent?: boolean) => void;
335
335
  commentCounts?: ReadonlyMap<string, number>;
336
336
  focused?: boolean;
337
337
  theme: Theme;
@@ -391,12 +391,20 @@ function usableScreenReached(
391
391
  /** The keybinds dialog content: the thread grammar while the thread view owns the keys. */
392
392
  function cheatsheetFor(keyBindings: KeyBindings, threadViewActive: boolean): CheatsheetSection[] {
393
393
  const base = keyBindings.cheatsheet();
394
+ const fileTree: CheatsheetSection = {
395
+ title: "Changes / Project files",
396
+ entries: [
397
+ { keys: "enter", label: "open selected file or toggle folder" },
398
+ { keys: "tab", label: "cycle panes" },
399
+ ],
400
+ };
394
401
 
395
402
  if (!threadViewActive) {
396
- return base;
403
+ return [fileTree, ...base];
397
404
  }
398
405
 
399
406
  return [
407
+ fileTree,
400
408
  ...THREAD_VIEW_CHEATSHEET,
401
409
  { title: "Nav mode · session", entries: sessionCommandEntries() },
402
410
  { title: "Nav mode · diff", entries: diffCommandEntries() },
@@ -630,7 +638,7 @@ export function App({
630
638
  const [menuDialog, setMenuDialog] = useState<"keybinds" | "settings" | null>(null);
631
639
  const [shareDialogOpen, setShareDialogOpen] = useState(false);
632
640
  const [autoClose, setAutoClose] = useState<AutoClose>("off");
633
- // unified or side-by-side diff; split only lays out when the Changes pane is zoomed
641
+ // unified or side-by-side diff; split lays out when the Changes pane is zoomed
634
642
  const [diffView, setDiffView] = useState<DiffViewMode>("split");
635
643
  const [defaultMessage, setDefaultMessage] = useState<MessageOutcome>("approved");
636
644
  const [focusedAnnotationId, setFocusedAnnotationId] = useState<string | undefined>(undefined);
@@ -656,10 +664,6 @@ export function App({
656
664
  const [skills, setSkills] = useState<SlashItem[]>([]);
657
665
  const [reviewSkill, setReviewSkill] = useState("code-review");
658
666
  const [reviewWorkspace, setReviewWorkspace] = useState<ReviewWorkspaceMode>("worktree");
659
- const reviewSkillOptions = useMemo(
660
- () => [...new Set([reviewSkill, "code-review", ...skills.map((skill) => skill.name)])],
661
- [reviewSkill, skills],
662
- );
663
667
  const paletteNames = useMemo(
664
668
  () => new Set(mergeSlashItems(slashItemsFrom(quickActions), skills).map((item) => item.name)),
665
669
  [quickActions, skills],
@@ -767,8 +771,6 @@ export function App({
767
771
  setMode({ type: "renameSelf", text: identity.name ?? "" });
768
772
  },
769
773
  reviewSkill,
770
- reviewSkillOptions,
771
- setReviewSkill,
772
774
  reviewWorkspace,
773
775
  setReviewWorkspace,
774
776
  });
@@ -1116,6 +1118,7 @@ export function App({
1116
1118
  onPin={togglePin}
1117
1119
  onRename={(id, title) => setMode({ type: "renameThread", sessionId: id, text: title })}
1118
1120
  quickActions={quickActions}
1121
+ diffView={diffView}
1119
1122
  onWelcomeComposingChange={setWelcomeComposing}
1120
1123
  layout={layout}
1121
1124
  />
@@ -1367,6 +1370,7 @@ export function App({
1367
1370
  commentCounts={diffCommentCounts}
1368
1371
  onFocusGroup={workbench.focusGroup}
1369
1372
  onActivateTab={workbench.activate}
1373
+ onKeepTab={workbench.keepTab}
1370
1374
  onCloseTab={workbench.close}
1371
1375
  onSplit={workbench.split}
1372
1376
  onZoom={workbench.toggleZoom}
@@ -1447,8 +1451,12 @@ export function App({
1447
1451
  {...projectDiffFiles(activeSession)}
1448
1452
  // the Changes navigator always opens a changed file as a diff - a diff review shows its
1449
1453
  // captured snapshot, every other thread the live working-tree diff
1450
- onOpenChangedFile={(path) => workbench.openFile(path, "diff")}
1451
- onOpenProjectFile={(path) => workbench.openFile(path, "contents")}
1454
+ onOpenChangedFile={(path, persistent) =>
1455
+ workbench.openFile(path, "diff", persistent)
1456
+ }
1457
+ onOpenProjectFile={(path, persistent) =>
1458
+ workbench.openFile(path, "contents", persistent)
1459
+ }
1452
1460
  commentCounts={diffCommentCounts}
1453
1461
  focused={focusedPane === "project"}
1454
1462
  theme={theme}
@@ -1466,6 +1474,7 @@ export function App({
1466
1474
  theme={theme}
1467
1475
  mode={mode}
1468
1476
  toast={toast}
1477
+ onDismissToast={() => controller.dismissToast()}
1469
1478
  setMode={setMode}
1470
1479
  dispatch={dispatch}
1471
1480
  />
@@ -113,6 +113,44 @@ const rightToggleColumn = (setup: Setup): number => toggleColumn(setup, NERD.sid
113
113
  const railToggleColumn = (setup: Setup): number => toggleColumn(setup, NERD.sidebarRightOff);
114
114
 
115
115
  describe("the four-pane workbench", () => {
116
+ test("rapid wheel scrolling a large Changes diff keeps the app responsive", async () => {
117
+ const filePatches = Array.from({ length: 40 }, (_, fileIndex) => [
118
+ `diff --git a/file-${fileIndex}.ts b/file-${fileIndex}.ts`,
119
+ `--- a/file-${fileIndex}.ts`,
120
+ `+++ b/file-${fileIndex}.ts`,
121
+ "@@ -0,0 +1,10 @@",
122
+ ...Array.from(
123
+ { length: 10 },
124
+ (_, lineIndex) => `+line ${lineIndex} ${"dense source text ".repeat(40)}`,
125
+ ),
126
+ ]).flat();
127
+ const large = server.core.sessionCreate({
128
+ workspace: { repoRoot: repo, branch: "main" },
129
+ artifact: {
130
+ type: "diff",
131
+ content: [...filePatches, ""].join("\n"),
132
+ meta: { title: "large changes" },
133
+ },
134
+ });
135
+ const setup = await renderReadyApp(<App home={home} sessionId={large.id} />, {
136
+ width: 160,
137
+ height: 20,
138
+ });
139
+
140
+ await waitForText(setup, "file-0.ts");
141
+ for (const [index, direction] of Array.from({ length: 500 }, () => "down" as const).entries()) {
142
+ // eslint-disable-next-line no-await-in-loop
143
+ await setup.mockMouse.scroll(75, 10, direction);
144
+ if (index % 25 === 0) {
145
+ // eslint-disable-next-line no-await-in-loop
146
+ await setup.waitForVisualIdle();
147
+ }
148
+ }
149
+ await setup.waitForVisualIdle();
150
+ expect(setup.captureCharFrame()).toContain("changes");
151
+ setup.renderer.destroy();
152
+ }, 60000);
153
+
116
154
  test("pair starts focused on the Project tree beside the zoomed Changes editor", async () => {
117
155
  const setup = await renderReadyApp(
118
156
  <App home={home} sessionId={session.id} layout={pairLayout()} />,
@@ -198,6 +236,8 @@ describe("the four-pane workbench", () => {
198
236
  expect(frame).toContain("cueloop");
199
237
  expect(frame).toContain("Changes");
200
238
  expect(frame).toContain("store.ts");
239
+ expect(frame).toContain("src/store.ts");
240
+ expect(frame).toContain(NERD.copy);
201
241
  // the diff (±) toggle exists in the Project header
202
242
  expect(diffToggleColumn(setup)).toBeGreaterThan(0);
203
243
  });
@@ -233,6 +273,43 @@ describe("the four-pane workbench", () => {
233
273
  expect(frame.split("\n")[HEADER_ROW]!).toContain("README.md");
234
274
  });
235
275
 
276
+ test("project clicks reuse one preview and a double-click keeps the file open once", async () => {
277
+ const setup = await renderApp();
278
+
279
+ await setup.mockMouse.click(treeToggleColumn(setup), HEADER_ROW);
280
+ await waitForText(setup, "README.md");
281
+ const readme = locateText(setup, "README.md");
282
+ await setup.mockMouse.click(readme.column, readme.row);
283
+ await waitForState(setup, () =>
284
+ setup.captureCharFrame().split("\n")[HEADER_ROW]!.includes("README.md"),
285
+ );
286
+
287
+ const folder = locateText(setup, "src");
288
+ await setup.mockMouse.click(folder.column, folder.row);
289
+ await waitForText(setup, "util.ts");
290
+ const util = locateText(setup, "util.ts");
291
+ await setup.mockMouse.click(util.column, util.row);
292
+ await waitForState(setup, () => {
293
+ const header = setup.captureCharFrame().split("\n")[HEADER_ROW]!;
294
+
295
+ return header.includes("util.ts") && !header.includes("README.md");
296
+ });
297
+
298
+ await setup.mockMouse.doubleClick(util.column, util.row);
299
+ await setup.mockMouse.click(readme.column, readme.row);
300
+ await waitForState(setup, () => {
301
+ const header = setup.captureCharFrame().split("\n")[HEADER_ROW]!;
302
+
303
+ return header.includes("util.ts") && header.includes("README.md");
304
+ });
305
+ await setup.mockMouse.click(util.column, util.row);
306
+ const header = setup.captureCharFrame().split("\n")[HEADER_ROW]!;
307
+
308
+ expect(header.match(/util\.ts/g)).toHaveLength(1);
309
+ expect(header.match(/README\.md/g)).toHaveLength(1);
310
+ setup.renderer.destroy();
311
+ });
312
+
236
313
  test("the split control offers four directions and arrow-right makes two groups", async () => {
237
314
  const setup = await renderApp();
238
315
 
@@ -255,8 +332,15 @@ describe("the four-pane workbench", () => {
255
332
 
256
333
  // the arrow keys drive the popover: right splits the group into two
257
334
  await pressKey(setup, "ARROW_RIGHT");
258
- await waitForState(setup, () => setup.captureCharFrame().split("split").length - 1 >= 2);
259
- expect((setup.captureCharFrame().match(/README\.md/g) ?? []).length).toBeGreaterThanOrEqual(2);
335
+ await waitForState(setup, () =>
336
+ /│ Changes\s+│.*│ README\.md/.test(setup.captureCharFrame().split("\n")[HEADER_ROW]!),
337
+ );
338
+ expect(
339
+ setup
340
+ .captureCharFrame()
341
+ .split("\n")
342
+ [HEADER_ROW]!.match(/README\.md/g),
343
+ ).toHaveLength(1);
260
344
  });
261
345
 
262
346
  test("dismissing the Changes tab collapses the Changes pane but keeps the Project sidebar", async () => {
@@ -461,6 +545,28 @@ describe("the bare-launch welcome shell", () => {
461
545
  expect(setup.captureCharFrame()).toContain("README.md");
462
546
  });
463
547
 
548
+ test("Review shows only the skill selected in TOML", async () => {
549
+ writeFileSync(process.env.CUELOOP_CONFIG!, '[review]\nskill = "custom-review"\n');
550
+ const setup = await renderWelcome();
551
+
552
+ await setup.mockMouse.click(1, HEADER_ROW);
553
+ await waitForText(setup, "settings");
554
+ const settings = locateText(setup, "settings");
555
+
556
+ await setup.mockMouse.click(settings.column, settings.row);
557
+ await waitForText(setup, "General");
558
+ const review = locateText(setup, "Review");
559
+
560
+ await setup.mockMouse.click(review.column, review.row);
561
+ await waitForText(setup, "custom-review");
562
+ expect(setup.captureCharFrame()).not.toContain("research");
563
+ const skill = locateText(setup, "custom-review");
564
+
565
+ await setup.mockMouse.click(skill.column, skill.row);
566
+ expect(setup.captureCharFrame()).toContain("custom-review");
567
+ setup.renderer.destroy();
568
+ });
569
+
464
570
  test("the first comment in the bare shell creates and persists a per-repo workbench thread", async () => {
465
571
  const setup = await renderWelcome();
466
572
 
@@ -501,6 +607,63 @@ describe("the bare-launch welcome shell", () => {
501
607
  expect(setup.captureCharFrame()).toContain("README.md");
502
608
  });
503
609
 
610
+ test("a changed file tab keeps copy and expand without a collapse action", async () => {
611
+ const setup = await renderWelcome();
612
+
613
+ await waitForText(setup, "README.md");
614
+ const readme = locateText(setup, "README.md");
615
+
616
+ await setup.mockMouse.click(readme.column, readme.row);
617
+ await waitForText(setup, "edited in the working tree");
618
+ const frame = setup.captureCharFrame();
619
+
620
+ expect(frame).toContain(NERD.copy);
621
+ expect(frame).not.toContain("v README.md");
622
+ expect(frame).toContain("expand");
623
+ setup.renderer.destroy();
624
+ });
625
+
626
+ test("a bare changed-file tab uses Split only when zoomed in", async () => {
627
+ const setup = await renderWelcome();
628
+
629
+ await waitForText(setup, "README.md");
630
+ const readme = locateText(setup, "README.md");
631
+
632
+ await setup.mockMouse.click(readme.column, readme.row);
633
+ await waitForText(setup, "edited in the working tree");
634
+ const pairedLine = (line: string): boolean =>
635
+ line.includes("Workbench Fixture") && line.includes("+ # Fixture");
636
+
637
+ expect(setup.captureCharFrame().split("\n").some(pairedLine)).toBe(false);
638
+ const zoom = locateText(setup, NERD.zoom);
639
+
640
+ await setup.mockMouse.click(zoom.column, zoom.row);
641
+ await waitForState(setup, () => setup.captureCharFrame().split("\n").some(pairedLine));
642
+ expect(setup.captureCharFrame().split("\n").some(pairedLine)).toBe(true);
643
+ setup.renderer.destroy();
644
+ });
645
+
646
+ test("reopening Changes after dismissing Welcome keeps file actions", async () => {
647
+ const setup = await renderWelcome();
648
+ const welcome = locateText(setup, "Welcome");
649
+
650
+ await setup.mockMouse.click(welcome.column + 8, welcome.row);
651
+ await setup.mockMouse.click(diffToggleColumn(setup), HEADER_ROW);
652
+ await waitForText(setup, "edited in the working tree");
653
+ const frame = setup.captureCharFrame();
654
+
655
+ expect(frame).toContain("README.md");
656
+ expect(frame).toContain(NERD.copy);
657
+ const title = locateText(setup, "README.md");
658
+
659
+ await setup.mockMouse.click(title.column, title.row);
660
+ await waitForTextGone(setup, "edited in the working tree");
661
+ expect(setup.captureCharFrame()).toContain("README.md");
662
+ await setup.mockMouse.click(title.column, title.row);
663
+ await waitForText(setup, "edited in the working tree");
664
+ setup.renderer.destroy();
665
+ });
666
+
504
667
  test("the changes tree clears after the launch repo becomes clean", async () => {
505
668
  const setup = await renderWelcome();
506
669
 
@@ -766,6 +929,49 @@ describe("the bare-launch welcome shell", () => {
766
929
  await waitForState(setup, () => diffToggleColumn(setup) === -1);
767
930
  });
768
931
 
932
+ test("the changes sidebar label opens one Changes tab beside Welcome and reactivates it", async () => {
933
+ const setup = await renderWelcome();
934
+
935
+ await setup.mockMouse.click(diffToggleColumn(setup), HEADER_ROW);
936
+ await waitForText(setup, "edited in the working tree");
937
+ const firstHeader = setup.captureCharFrame().split("\n")[HEADER_ROW]!;
938
+
939
+ expect(firstHeader).toContain("Welcome");
940
+ expect(firstHeader).toContain("Changes");
941
+
942
+ const welcome = locateText(setup, "Welcome");
943
+
944
+ await setup.mockMouse.click(welcome.column, welcome.row);
945
+ await waitForText(setup, "Getting started");
946
+ await setup.mockMouse.click(diffToggleColumn(setup), HEADER_ROW);
947
+ await waitForText(setup, "edited in the working tree");
948
+ expect(
949
+ setup
950
+ .captureCharFrame()
951
+ .split("\n")
952
+ [HEADER_ROW]!.match(/Changes/g),
953
+ ).toHaveLength(1);
954
+ setup.renderer.destroy();
955
+ });
956
+
957
+ test("the changes sidebar label activates Changes beside an open file tab", async () => {
958
+ const setup = await renderWelcome();
959
+
960
+ await waitForText(setup, "README.md");
961
+ const file = locateText(setup, "README.md");
962
+
963
+ await setup.mockMouse.click(file.column, file.row);
964
+ await waitForText(setup, "README.md");
965
+ await setup.mockMouse.click(diffToggleColumn(setup), HEADER_ROW);
966
+ await waitForText(setup, "edited in the working tree");
967
+ const header = setup.captureCharFrame().split("\n")[HEADER_ROW]!;
968
+
969
+ expect(header).toContain("Welcome");
970
+ expect(header).toContain("README.md");
971
+ expect(header).toContain("Changes");
972
+ setup.renderer.destroy();
973
+ });
974
+
769
975
  test("a closed region draws its reopen divider in the header only, not full-height", async () => {
770
976
  const setup = await renderWelcome();
771
977