@cueloop/client 0.1.0-alpha.65 → 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 (160) hide show
  1. package/package.json +6 -5
  2. package/src/App.autoclose.test.tsx +2 -2
  3. package/src/App.diff.test.tsx +79 -112
  4. package/src/App.plan-review.test.tsx +23 -205
  5. package/src/App.prototype.test.tsx +1 -8
  6. package/src/App.readonly.test.tsx +9 -27
  7. package/src/App.submit-confirm.test.tsx +19 -72
  8. package/src/App.test.tsx +136 -31
  9. package/src/App.tsx +491 -322
  10. package/src/App.walk.test.tsx +15 -16
  11. package/src/App.workbench.test.tsx +390 -0
  12. package/src/__snapshots__/view-plan.regression.test.ts.snap +1 -1
  13. package/src/annotation-palette.ts +61 -0
  14. package/src/app-screens.tsx +197 -77
  15. package/src/app-view-model.ts +9 -69
  16. package/src/components/AnnotationCard.stories.tsx +1 -1
  17. package/src/components/AnnotationCard.tsx +1 -1
  18. package/src/components/AnnotationCards.stories.tsx +87 -0
  19. package/src/components/AnnotationCards.tsx +303 -0
  20. package/src/components/AppHeader.stories.tsx +50 -0
  21. package/src/components/AppHeader.tsx +83 -0
  22. package/src/components/AppShell.stories.tsx +89 -0
  23. package/src/components/AppShell.tsx +239 -0
  24. package/src/components/AppShell.zoom.test.tsx +94 -0
  25. package/src/components/Breadcrumb.stories.tsx +1 -1
  26. package/src/components/ChangesColumn.stories.tsx +39 -0
  27. package/src/components/ChangesColumn.tsx +170 -0
  28. package/src/components/CodeBlock.stories.tsx +1 -1
  29. package/src/components/CompletionOverlay.stories.tsx +1 -1
  30. package/src/components/ConfirmCard.stories.tsx +1 -1
  31. package/src/components/ConfirmCard.tsx +3 -4
  32. package/src/components/ConfirmDialog.stories.tsx +1 -1
  33. package/src/components/DiffSheet.scroll.test.tsx +38 -55
  34. package/src/components/DiffSheet.split.test.tsx +140 -0
  35. package/src/components/DiffSheet.stories.tsx +56 -54
  36. package/src/components/DiffSheet.syntax.test.tsx +24 -7
  37. package/src/components/DiffSheet.tsx +607 -276
  38. package/src/components/DiscussionMarkerRail.stories.tsx +54 -0
  39. package/src/components/DiscussionMarkerRail.tsx +181 -0
  40. package/src/components/EditorGrid.stories.tsx +59 -0
  41. package/src/components/EditorGrid.tabs.test.tsx +105 -0
  42. package/src/components/EditorGrid.tsx +338 -0
  43. package/src/components/EditorGrid.zoom.test.tsx +68 -0
  44. package/src/components/FileContentsView.stories.tsx +16 -0
  45. package/src/components/FileContentsView.test.tsx +29 -0
  46. package/src/components/FileContentsView.tsx +78 -0
  47. package/src/components/InboxList.stories.tsx +23 -8
  48. package/src/components/InboxList.tsx +188 -33
  49. package/src/components/KeybindsDialog.stories.tsx +1 -1
  50. package/src/components/MarkerPopover.stories.tsx +1 -1
  51. package/src/components/MenuBar.stories.tsx +3 -12
  52. package/src/components/MenuBar.tsx +25 -56
  53. package/src/components/PaneGrid.stories.tsx +84 -0
  54. package/src/components/PaneGrid.tsx +128 -0
  55. package/src/components/PanelColumn.stories.tsx +25 -0
  56. package/src/components/PanelColumn.tsx +95 -0
  57. package/src/components/ProjectTreeView.stories.tsx +25 -0
  58. package/src/components/ProjectTreeView.test.tsx +32 -0
  59. package/src/components/ProjectTreeView.tsx +82 -0
  60. package/src/components/PromptDialog.stories.tsx +1 -1
  61. package/src/components/PromptDialog.tsx +8 -1
  62. package/src/components/PrototypeSheet.stories.tsx +1 -1
  63. package/src/components/RootOverlay.stories.tsx +44 -0
  64. package/src/components/RootOverlay.tsx +35 -0
  65. package/src/components/SettingsDialog.stories.tsx +7 -1
  66. package/src/components/SettingsDialog.tsx +114 -67
  67. package/src/components/SettingsRows.stories.tsx +1 -1
  68. package/src/components/ShellHeader.stories.tsx +50 -0
  69. package/src/components/ShellHeader.tsx +137 -0
  70. package/src/components/SurfaceScrollbar.stories.tsx +34 -0
  71. package/src/components/SurfaceScrollbar.test.tsx +111 -0
  72. package/src/components/SurfaceScrollbar.tsx +93 -0
  73. package/src/components/ThreadFooter.stories.tsx +30 -0
  74. package/src/components/ThreadFooter.test.tsx +31 -0
  75. package/src/components/ThreadFooter.tsx +54 -0
  76. package/src/components/ThreadView.slash.test.tsx +113 -0
  77. package/src/components/ThreadView.stories.tsx +1 -1
  78. package/src/components/ThreadView.tsx +62 -1352
  79. package/src/components/ThreadsSidebar.stories.tsx +36 -0
  80. package/src/components/ThreadsSidebar.tsx +70 -0
  81. package/src/components/Toast.stories.tsx +1 -1
  82. package/src/components/Tooltip.stories.tsx +26 -0
  83. package/src/components/Tooltip.tsx +72 -0
  84. package/src/components/TreePane.stories.tsx +1 -1
  85. package/src/components/WalkWizard.stories.tsx +1 -1
  86. package/src/components/WelcomeSurface.stories.tsx +12 -0
  87. package/src/components/WelcomeSurface.tsx +52 -0
  88. package/src/components/__snapshots__/stories.test.tsx.snap +732 -572
  89. package/src/components/agent-launcher.stories.tsx +1 -1
  90. package/src/components/diff-sheet-layout.test.ts +125 -55
  91. package/src/components/diff-sheet-layout.ts +3 -103
  92. package/src/components/editor-grid.test.ts +86 -0
  93. package/src/components/editor-grid.ts +138 -0
  94. package/src/components/fade-title.test.ts +49 -0
  95. package/src/components/fade-title.ts +61 -0
  96. package/src/components/file-tree.test.ts +60 -0
  97. package/src/components/file-tree.ts +112 -0
  98. package/src/components/pages/Workspace.stories.tsx +181 -0
  99. package/src/components/primitives/Button.stories.tsx +1 -1
  100. package/src/components/primitives/Card.stories.tsx +1 -1
  101. package/src/components/primitives/Dialog.stories.tsx +1 -1
  102. package/src/components/primitives/IconButton.stories.tsx +36 -0
  103. package/src/components/primitives/IconButton.tsx +62 -0
  104. package/src/components/primitives/StatusBar.stories.tsx +1 -1
  105. package/src/components/primitives/Tabs.stories.tsx +1 -1
  106. package/src/components/primitives/Toolbar.stories.tsx +1 -1
  107. package/src/components/primitives/Tree.stories.tsx +124 -0
  108. package/src/components/primitives/Tree.tsx +96 -0
  109. package/src/components/primitives/icons.ts +61 -0
  110. package/src/components/primitives/tree-model.test.ts +99 -0
  111. package/src/components/primitives/tree-model.ts +137 -0
  112. package/src/components/quick-actions-editor.stories.tsx +1 -1
  113. package/src/components/session-tree.test.ts +134 -0
  114. package/src/components/session-tree.ts +108 -0
  115. package/src/components/stories-app.tsx +142 -82
  116. package/src/components/story-tree.test.ts +100 -0
  117. package/src/components/story.ts +55 -0
  118. package/src/components/theme-context.stories.tsx +1 -1
  119. package/src/config.test.ts +20 -49
  120. package/src/config.ts +26 -28
  121. package/src/diff-fold.test.ts +78 -0
  122. package/src/diff-fold.ts +126 -0
  123. package/src/diff-syntax.test.ts +16 -0
  124. package/src/diff-syntax.ts +6 -3
  125. package/src/discussions.ts +79 -0
  126. package/src/intent-dispatch.test.ts +31 -11
  127. package/src/intent-dispatch.ts +93 -48
  128. package/src/key-bindings.test.ts +1 -1
  129. package/src/keymap.test.ts +3 -25
  130. package/src/keymap.ts +17 -18
  131. package/src/mark-runs.ts +120 -0
  132. package/src/pty.ts +10 -2
  133. package/src/run.ts +17 -7
  134. package/src/serve.test.ts +4 -8
  135. package/src/session-controller.curate.test.ts +75 -0
  136. package/src/session-controller.share.test.ts +1 -0
  137. package/src/session-controller.tree.test.ts +26 -0
  138. package/src/session-controller.ts +160 -31
  139. package/src/slash-palette.ts +83 -0
  140. package/src/split-diff.test.ts +110 -0
  141. package/src/split-diff.ts +149 -0
  142. package/src/test-support.test.ts +3 -3
  143. package/src/test-support.ts +39 -8
  144. package/src/theme.ts +4 -4
  145. package/src/thread-chords.test.ts +25 -9
  146. package/src/thread-chords.ts +33 -8
  147. package/src/use-annotation-surface.tsx +837 -0
  148. package/src/use-changes-workbench.ts +151 -0
  149. package/src/use-frame-measure.ts +38 -0
  150. package/src/use-settings-dialog.tsx +23 -24
  151. package/src/use-terminal-virtualizer.ts +233 -0
  152. package/src/view-diff.ts +114 -0
  153. package/src/App.review-panel.test.tsx +0 -140
  154. package/src/App.tree.test.tsx +0 -224
  155. package/src/components/ReviewPanel.stories.tsx +0 -100
  156. package/src/components/ReviewPanel.tsx +0 -103
  157. package/src/components/ReviewRail.stories.tsx +0 -232
  158. package/src/components/ReviewRail.tsx +0 -327
  159. package/src/review-panel.test.ts +0 -113
  160. package/src/review-panel.ts +0 -83
@@ -13,6 +13,7 @@ import { DARK } from "./theme";
13
13
  import {
14
14
  isolateUserConfig,
15
15
  press,
16
+ pressKey,
16
17
  waitForState,
17
18
  waitForText,
18
19
  waitForTextGone,
@@ -103,7 +104,7 @@ describe("the guided walk", () => {
103
104
  const setup = await renderApp();
104
105
 
105
106
  // Act
106
- await press(setup, "w");
107
+ await pressKey(setup, "k", { meta: true });
107
108
 
108
109
  // Assert
109
110
  await waitForText(setup, "file 1 of 3 · 0 viewed");
@@ -157,7 +158,7 @@ describe("the guided walk", () => {
157
158
  await press(setup, "enter");
158
159
 
159
160
  // Assert
160
- await waitForText(setup, "submit review");
161
+ await waitForText(setup, "send message");
161
162
  expect(setup.captureCharFrame()).toContain("3/3 files viewed");
162
163
 
163
164
  // Act
@@ -171,7 +172,7 @@ describe("the guided walk", () => {
171
172
  // Arrange
172
173
  const setup = await renderApp();
173
174
 
174
- await press(setup, "w");
175
+ await pressKey(setup, "k", { meta: true });
175
176
  await waitForText(setup, "file 1 of 3 · 0 viewed");
176
177
  await press(setup, "]");
177
178
  await waitForText(setup, "file 2 of 3 · 1 viewed");
@@ -185,7 +186,7 @@ describe("the guided walk", () => {
185
186
  expect(setup.captureCharFrame()).not.toContain("· 1 viewed");
186
187
 
187
188
  // Act
188
- await press(setup, "w");
189
+ await pressKey(setup, "k", { meta: true });
189
190
 
190
191
  // Assert
191
192
  await waitForText(setup, "file 2 of 3 · 1 viewed");
@@ -195,7 +196,7 @@ describe("the guided walk", () => {
195
196
  // Arrange
196
197
  const first = await renderApp();
197
198
 
198
- await press(first, "w");
199
+ await pressKey(first, "k", { meta: true });
199
200
  await waitForText(first, "file 1 of 3 · 0 viewed");
200
201
  await press(first, "]");
201
202
  await press(first, "]");
@@ -210,7 +211,7 @@ describe("the guided walk", () => {
210
211
  // a fresh client reads the viewed set back from the session record
211
212
  const second = await renderApp();
212
213
 
213
- await press(second, "w");
214
+ await pressKey(second, "k", { meta: true });
214
215
 
215
216
  // Assert
216
217
  await waitForText(second, "file 3 of 3 · 2 viewed");
@@ -231,7 +232,7 @@ describe("the guided walk", () => {
231
232
  const setup = await renderApp();
232
233
 
233
234
  // Act
234
- await press(setup, "w");
235
+ await pressKey(setup, "k", { meta: true });
235
236
 
236
237
  // Assert
237
238
  await waitForText(setup, "file 1 of 3");
@@ -249,7 +250,7 @@ describe("the guided walk", () => {
249
250
  // flips the default verdict, and never comes back in the feedback doc
250
251
  await press(setup, "escape");
251
252
  await waitForTextGone(setup, "agent note");
252
- await press(setup, "enter");
253
+ await pressKey(setup, "RETURN", { meta: true });
253
254
  // nothing pending despite the note, so the confirm card defaults to approve
254
255
  await waitForText(setup, "[Approve]");
255
256
  await press(setup, "enter");
@@ -268,16 +269,16 @@ describe("the guided walk", () => {
268
269
 
269
270
  // Assert
270
271
  // before the walk the changed word on the added line wears the insertion color
271
- await waitForText(setup, "+new line");
272
+ await waitForText(setup, "+ new line");
272
273
  expect(foregroundsOf(setup, "new")).toContain(DARK.insertedForeground);
273
274
 
274
275
  // Act
275
- await press(setup, "w");
276
+ await pressKey(setup, "k", { meta: true });
276
277
 
277
278
  // Assert
278
279
  await waitForText(setup, "file 1 of 3 · 0 viewed");
279
- // dimmed: the sheet's changed word drops to the dim token...
280
- expect(foregroundsOf(setup, "new")).toEqual([DARK.textDim]);
280
+ // dimmed: an added line the wizard preview does not show drops to the dim token...
281
+ expect(foregroundsOf(setup, "added tail")).toEqual([DARK.textDim]);
281
282
  // ...while the wizard preview keeps the insertion color
282
283
  expect(foregroundsOf(setup, "+export const b = 3;")).toContain(DARK.insertedForeground);
283
284
  });
@@ -287,10 +288,8 @@ describe("the guided walk", () => {
287
288
  server.core.sessionResolve(session.id, "approve", "");
288
289
  const setup = await renderApp();
289
290
 
290
- await waitForText(setup, "resolved");
291
-
292
- // Act
293
- await press(setup, "w");
291
+ // Act - renderApp already waited for the diff to load; the session is resolved
292
+ await pressKey(setup, "k", { meta: true });
294
293
 
295
294
  // Assert
296
295
  await waitForText(setup, "review submitted - read-only");
@@ -0,0 +1,390 @@
1
+ /** The four-pane workbench in the virtual terminal, mirroring the browser prototype's interactions:
2
+ * a diff session lays out Threads | Thread | Changes editor | Project sidebar; the diff (±) and tree
3
+ * toggles are mutually exclusive and switching to tree keeps the Changes editor open; a project-tree
4
+ * file opens as a read-only contents tab; the split control offers four directions and Split Right makes
5
+ * two editor groups; dismissing the Changes tab collapses the Changes pane but keeps the Project sidebar;
6
+ * the right-sidebar toggle collapses the region to a thin rail and reopens it; zoom hides the Thread pane
7
+ * while the sidebars stay. */
8
+
9
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
10
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
11
+ import { tmpdir } from "node:os";
12
+ import { join } from "node:path";
13
+ import React from "react";
14
+ import { testRender } from "@opentui/react/test-utils";
15
+ import { DaemonServer } from "@cueloop/daemon";
16
+ import type { ReviewSession } from "@cueloop/schema";
17
+ import { App } from "./App";
18
+ import { isolateUserConfig, locateText, waitForState, waitForText } from "./test-support";
19
+ import { NERD } from "./components/primitives/icons";
20
+
21
+ const PATCH = `diff --git a/src/store.ts b/src/store.ts
22
+ index 111..222 100644
23
+ --- a/src/store.ts
24
+ +++ b/src/store.ts
25
+ @@ -1,3 +1,3 @@
26
+ export class Store {
27
+ - private items = [];
28
+ + private items = new Map();
29
+ }
30
+ `;
31
+
32
+ let home: string;
33
+ let repo: string;
34
+ let restoreUserConfig: () => void;
35
+ let server: DaemonServer;
36
+ let session: ReviewSession;
37
+
38
+ /** A throwaway git repo so the Project tree (git ls-files) and file contents resolve without a real checkout. */
39
+ function makeRepo(): string {
40
+ const root = mkdtempSync(join(tmpdir(), "cueloop-workbench-repo-"));
41
+
42
+ mkdirSync(join(root, "src"), { recursive: true });
43
+ writeFileSync(join(root, "README.md"), "# Workbench Fixture\n\nA tiny tracked repo.\n");
44
+ writeFileSync(join(root, "src", "store.ts"), "export class Store {}\n");
45
+ writeFileSync(join(root, "src", "util.ts"), "export const noop = () => {};\n");
46
+ const git = (args: string[]) => Bun.spawnSync(["git", "-C", root, ...args]);
47
+
48
+ git(["init", "-q"]);
49
+ git(["config", "user.email", "fixture@example.com"]);
50
+ git(["config", "user.name", "Fixture"]);
51
+ git(["add", "-A"]);
52
+ git(["commit", "-q", "-m", "seed"]);
53
+
54
+ return root;
55
+ }
56
+
57
+ beforeEach(() => {
58
+ home = mkdtempSync(join(tmpdir(), "cueloop-workbench-"));
59
+ repo = makeRepo();
60
+ restoreUserConfig = isolateUserConfig(home);
61
+ server = new DaemonServer({ home, idleExitMs: 0 });
62
+ server.start();
63
+ session = server.core.sessionCreate({
64
+ workspace: { repoRoot: repo, branch: "main" },
65
+ artifact: { type: "diff", content: PATCH, meta: { title: "working tree" } },
66
+ });
67
+ });
68
+ afterEach(() => {
69
+ restoreUserConfig();
70
+ server.stop();
71
+ rmSync(home, { recursive: true, force: true });
72
+ rmSync(repo, { recursive: true, force: true });
73
+ });
74
+
75
+ async function renderApp() {
76
+ const setup = await testRender(<App home={home} sessionId={session.id} />, {
77
+ width: 160,
78
+ height: 20,
79
+ });
80
+
81
+ await waitForText(setup, "store.ts");
82
+
83
+ return setup;
84
+ }
85
+
86
+ type Setup = Awaited<ReturnType<typeof renderApp>>;
87
+
88
+ /** The header controls sit on the top content row of the compact header. */
89
+ const HEADER_ROW = 0;
90
+
91
+ /** Column of a Project-header toggle, located by its own glyph so spacing changes never break the click. */
92
+ function toggleColumn(setup: Setup, glyph: string): number {
93
+ return setup.captureCharFrame().split("\n")[HEADER_ROW]!.lastIndexOf(glyph);
94
+ }
95
+ const diffToggleColumn = (setup: Setup): number => toggleColumn(setup, "changes");
96
+ const treeToggleColumn = (setup: Setup): number => toggleColumn(setup, "project");
97
+ const rightToggleColumn = (setup: Setup): number => toggleColumn(setup, NERD.sidebarRight);
98
+ // the collapsed rail shows the outline (off) variant, mirroring the left toggle's filled/outline states
99
+ const railToggleColumn = (setup: Setup): number => toggleColumn(setup, NERD.sidebarRightOff);
100
+
101
+ describe("the four-pane workbench", () => {
102
+ test("a diff session lays out the four panes with the diff toggle active", async () => {
103
+ const setup = await renderApp();
104
+
105
+ const frame = setup.captureCharFrame();
106
+ expect(frame).toContain("cueloop");
107
+ expect(frame).toContain("Changes");
108
+ expect(frame).toContain("store.ts");
109
+ // the diff (±) toggle exists in the Project header
110
+ expect(diffToggleColumn(setup)).toBeGreaterThan(0);
111
+ });
112
+
113
+ test("switching to the project tree keeps the Changes editor open", async () => {
114
+ const setup = await renderApp();
115
+
116
+ // the diff and tree toggles are mutually exclusive; switch to the full project tree
117
+ await setup.mockMouse.click(treeToggleColumn(setup), HEADER_ROW);
118
+ await waitForText(setup, "README.md");
119
+
120
+ const frame = setup.captureCharFrame();
121
+ // the full tree is showing...
122
+ expect(frame).toContain("README.md");
123
+ expect(frame).toContain("src");
124
+ // ...and the Changes editor stayed open through the mode switch
125
+ expect(frame).toContain("Changes");
126
+ });
127
+
128
+ test("opening a project-tree file adds a read-only contents tab", async () => {
129
+ const setup = await renderApp();
130
+
131
+ await setup.mockMouse.click(treeToggleColumn(setup), HEADER_ROW);
132
+ await waitForText(setup, "README.md");
133
+
134
+ const readme = locateText(setup, "README.md");
135
+ await setup.mockMouse.click(readme.column, readme.row);
136
+ await waitForText(setup, "Workbench Fixture");
137
+
138
+ const frame = setup.captureCharFrame();
139
+ // the contents tab carries the file's text with no diff markers
140
+ expect(frame).toContain("A tiny tracked repo.");
141
+ expect(frame.split("\n")[HEADER_ROW]!).toContain("README.md");
142
+ });
143
+
144
+ test("the split control offers four directions and Split Right makes two groups", async () => {
145
+ const setup = await renderApp();
146
+
147
+ await setup.mockMouse.click(treeToggleColumn(setup), HEADER_ROW);
148
+ await waitForText(setup, "README.md");
149
+ const readme = locateText(setup, "README.md");
150
+ await setup.mockMouse.click(readme.column, readme.row);
151
+ await waitForText(setup, "Workbench Fixture");
152
+
153
+ // the split control appears only on a file tab
154
+ const split = locateText(setup, "split");
155
+ await setup.mockMouse.click(split.column, split.row);
156
+ await waitForText(setup, "Split Right");
157
+
158
+ const menu = setup.captureCharFrame();
159
+ expect(menu).toContain("Split Left");
160
+ expect(menu).toContain("Split Right");
161
+ expect(menu).toContain("Split Up");
162
+ expect(menu).toContain("Split Down");
163
+
164
+ const right = locateText(setup, "Split Right");
165
+ await setup.mockMouse.click(right.column, right.row);
166
+ // two editor groups now ride the header, each with its own split control
167
+ await waitForState(setup, () => setup.captureCharFrame().split("split").length - 1 >= 2);
168
+ expect((setup.captureCharFrame().match(/README\.md/g) ?? []).length).toBeGreaterThanOrEqual(2);
169
+ });
170
+
171
+ test("dismissing the Changes tab collapses the Changes pane but keeps the Project sidebar", async () => {
172
+ const setup = await renderApp();
173
+
174
+ const changes = locateText(setup, "Changes");
175
+ // the tab's close box sits just past the label; the ✕ is a hover affordance, the box always clicks
176
+ await setup.mockMouse.click(changes.column + 8, changes.row);
177
+ await waitForState(setup, () => !setup.captureCharFrame().includes("store.ts"));
178
+
179
+ const frame = setup.captureCharFrame();
180
+ // the Changes editor is gone...
181
+ expect(frame).not.toContain("store.ts");
182
+ // ...but the Project sidebar and its toggles remain
183
+ expect(diffToggleColumn(setup)).toBeGreaterThan(0);
184
+ });
185
+
186
+ test("the right-sidebar toggle collapses the region to a rail and reopens it", async () => {
187
+ const setup = await renderApp();
188
+
189
+ await setup.mockMouse.click(rightToggleColumn(setup), HEADER_ROW);
190
+ await waitForState(setup, () => !setup.captureCharFrame().includes("store.ts"));
191
+
192
+ // the right region is closed: no Changes editor, no Project tree toggles
193
+ expect(setup.captureCharFrame()).not.toContain("store.ts");
194
+ expect(diffToggleColumn(setup)).toBe(-1);
195
+
196
+ // the collapsed rail shows the outline (off) variant; its toggle reopens the region
197
+ await setup.mockMouse.click(railToggleColumn(setup), HEADER_ROW);
198
+ await waitForText(setup, "store.ts");
199
+ expect(diffToggleColumn(setup)).toBeGreaterThan(0);
200
+ });
201
+
202
+ test("the Threads toggle opens and closes the sidebar, relocating the brand", async () => {
203
+ const setup = await renderApp();
204
+
205
+ // deep-linked into a session, the Threads sidebar starts collapsed: the brand rides in the
206
+ // Thread header and the inbox groups are hidden
207
+ const collapsed = setup.captureCharFrame();
208
+ expect(collapsed).toContain("cueloop");
209
+ expect(collapsed).not.toContain("Threads");
210
+
211
+ // the sidebar toggle sits just left of the brand (one glyph plus its margin)
212
+ const brand = locateText(setup, "cueloop");
213
+ await setup.mockMouse.click(brand.column - 3, brand.row);
214
+ await waitForText(setup, "Threads");
215
+
216
+ // the Threads pane is open with its inbox groups, and the brand moved into its header
217
+ expect(setup.captureCharFrame()).toContain("Threads");
218
+
219
+ // toggling again collapses it back
220
+ const reopened = locateText(setup, "cueloop");
221
+ await setup.mockMouse.click(reopened.column - 3, reopened.row);
222
+ await waitForState(setup, () => !setup.captureCharFrame().includes("Threads"));
223
+ expect(setup.captureCharFrame()).not.toContain("Threads");
224
+ });
225
+
226
+ test("zoom hides the Thread pane while the sidebars stay", async () => {
227
+ const setup = await renderApp();
228
+
229
+ const zoom = locateText(setup, NERD.zoom);
230
+ await setup.mockMouse.click(zoom.column, zoom.row);
231
+ await waitForState(setup, () => !setup.captureCharFrame().includes("review the changes"));
232
+
233
+ const frame = setup.captureCharFrame();
234
+ // the Thread pane's prompt is gone...
235
+ expect(frame).not.toContain("review the changes");
236
+ // ...while the Changes editor and Project sidebar stay
237
+ expect(frame).toContain("store.ts");
238
+ expect(diffToggleColumn(setup)).toBeGreaterThan(0);
239
+ });
240
+
241
+ test("toggling the right sidebar while zoomed exits zoom and restores the Thread pane", async () => {
242
+ const setup = await renderApp();
243
+
244
+ // zoom the Changes editor - the Thread pane hides
245
+ const zoom = locateText(setup, NERD.zoom);
246
+ await setup.mockMouse.click(zoom.column, zoom.row);
247
+ await waitForState(setup, () => !setup.captureCharFrame().includes("review the changes"));
248
+
249
+ // toggling the right sidebar off must not strand a blank screen: zoom exits, the Thread pane returns
250
+ await setup.mockMouse.click(rightToggleColumn(setup), HEADER_ROW);
251
+ await waitForText(setup, "review the changes");
252
+ const frame = setup.captureCharFrame();
253
+ expect(frame).toContain("review the changes");
254
+ // the right region is collapsed and the Changes editor is gone
255
+ expect(frame).not.toContain("store.ts");
256
+ expect(diffToggleColumn(setup)).toBe(-1);
257
+ });
258
+
259
+ test("hovering the right-sidebar icon surfaces its tooltip label at the screen root", async () => {
260
+ const setup = await renderApp();
261
+
262
+ // the tip is not painted until the pointer is over the control
263
+ expect(setup.captureCharFrame()).not.toContain("Toggle Right Sidebar");
264
+
265
+ await setup.mockMouse.moveTo(rightToggleColumn(setup), HEADER_ROW);
266
+ // the label surfaces at the root, escaping the header cell that would otherwise clip it
267
+ await waitForText(setup, "Toggle Right Sidebar");
268
+ });
269
+ });
270
+
271
+ /** The bare-launch (no thread) shell is the same four panes as a thread: the Thread pane waits in its
272
+ * empty state and a disposable Welcome tab rides in the Changes editor. The right-region toggles behave
273
+ * like the thread shell, dismissing Welcome collapses the editor without stranding an empty screen, and
274
+ * the collapsed rail draws its divider in the header only. */
275
+ describe("the bare-launch welcome shell", () => {
276
+ let welcomeHome: string;
277
+ let welcomeRepo: string;
278
+ let restoreWelcome: () => void;
279
+ let welcomeServer: DaemonServer;
280
+
281
+ beforeEach(() => {
282
+ welcomeHome = mkdtempSync(join(tmpdir(), "cueloop-welcome-"));
283
+ // a small, controlled launch repo so the welcome tree/changes resolve fast and deterministically
284
+ welcomeRepo = makeRepo();
285
+ writeFileSync(join(welcomeRepo, "README.md"), "# Fixture\n\nedited in the working tree\n");
286
+ restoreWelcome = isolateUserConfig(welcomeHome);
287
+ welcomeServer = new DaemonServer({ home: welcomeHome, idleExitMs: 0 });
288
+ welcomeServer.start();
289
+ });
290
+ afterEach(() => {
291
+ restoreWelcome();
292
+ welcomeServer.stop();
293
+ rmSync(welcomeHome, { recursive: true, force: true });
294
+ rmSync(welcomeRepo, { recursive: true, force: true });
295
+ });
296
+
297
+ async function renderWelcome() {
298
+ const setup = await testRender(
299
+ <App home={welcomeHome} sessionId={undefined} cwd={welcomeRepo} />,
300
+ { width: 180, height: 14 },
301
+ );
302
+ await waitForText(setup, "cueloop");
303
+
304
+ return setup;
305
+ }
306
+
307
+ test("the project tree shows the launch repo's files even with no thread open", async () => {
308
+ const setup = await renderWelcome();
309
+
310
+ // the tree toggle loads the git repo the client launched in
311
+ await setup.mockMouse.click(treeToggleColumn(setup), HEADER_ROW);
312
+ await waitForText(setup, "README.md");
313
+ expect(setup.captureCharFrame()).toContain("README.md");
314
+ });
315
+
316
+ test("the changes tree lists the launch repo's working-tree changes", async () => {
317
+ const setup = await renderWelcome();
318
+
319
+ // changes mode is the default; the edited README shows with its status
320
+ await waitForText(setup, "README.md");
321
+ expect(setup.captureCharFrame()).toContain("README.md");
322
+ });
323
+
324
+ test("a bare launch rides a Welcome tab in the editor while the Thread pane waits empty", async () => {
325
+ const setup = await renderWelcome();
326
+
327
+ const frame = setup.captureCharFrame();
328
+ // the getting-started surface is an editor tab, not the thread pane
329
+ expect(frame.split("\n")[HEADER_ROW]!).toContain("Welcome");
330
+ expect(frame).toContain("Welcome to cueloop");
331
+ // the Thread pane shows its empty state until a thread is opened
332
+ expect(frame).toContain("Select a thread");
333
+ });
334
+
335
+ test("dismissing the Welcome tab collapses the editor without stranding an empty screen", async () => {
336
+ const setup = await renderWelcome();
337
+
338
+ const welcome = locateText(setup, "Welcome");
339
+ // the close box sits just past the label
340
+ await setup.mockMouse.click(welcome.column + 8, welcome.row);
341
+ await waitForState(
342
+ setup,
343
+ () => !setup.captureCharFrame().includes("review loop for coding agents"),
344
+ );
345
+
346
+ // the Threads sidebar and the Thread empty state remain - never a blank shell
347
+ const frame = setup.captureCharFrame();
348
+ expect(frame).toContain("cueloop");
349
+ expect(frame).toContain("Select a thread");
350
+ });
351
+
352
+ test("the changed-files and tree toggles switch navigator mode without collapsing", async () => {
353
+ const setup = await renderWelcome();
354
+
355
+ expect(diffToggleColumn(setup)).toBeGreaterThan(0);
356
+
357
+ // the tree toggle switches to the project view (the launch repo's tree) without collapsing
358
+ await setup.mockMouse.click(treeToggleColumn(setup), HEADER_ROW);
359
+ await waitForText(setup, "src");
360
+ expect(diffToggleColumn(setup)).toBeGreaterThan(0);
361
+
362
+ // the changed-files toggle switches back, still open
363
+ await setup.mockMouse.click(diffToggleColumn(setup), HEADER_ROW);
364
+ await waitForState(setup, () => !setup.captureCharFrame().includes("src"));
365
+ expect(diffToggleColumn(setup)).toBeGreaterThan(0);
366
+
367
+ // the sidebar toggle collapses the region to a rail
368
+ await setup.mockMouse.click(rightToggleColumn(setup), HEADER_ROW);
369
+ await waitForState(setup, () => diffToggleColumn(setup) === -1);
370
+ });
371
+
372
+ test("the collapsed rail draws its divider in the header only, not full-height", async () => {
373
+ const setup = await renderWelcome();
374
+
375
+ // collapse the right region so only the rail remains
376
+ await setup.mockMouse.click(rightToggleColumn(setup), HEADER_ROW);
377
+ await waitForState(setup, () => diffToggleColumn(setup) === -1);
378
+
379
+ const lines = setup.captureCharFrame().split("\n");
380
+ // the rail's divider sits at the far right; find its column on the header row
381
+ const headerDivider = lines[HEADER_ROW]!.lastIndexOf("│");
382
+ expect(headerDivider).toBeGreaterThan(0);
383
+ // that column stays clear below the two-row header - no full-height column rule
384
+ const bodyRows = lines.slice(3).filter((line) => line.length > headerDivider);
385
+ expect(bodyRows.length).toBeGreaterThan(0);
386
+ for (const line of bodyRows) {
387
+ expect(line[headerDivider]).not.toBe("│");
388
+ }
389
+ });
390
+ });
@@ -1,4 +1,4 @@
1
- // Bun Snapshot v1, https://bun.sh/docs/test/snapshots
1
+ // Bun Snapshot v1, https://goo.gl/fbAQLP
2
2
 
3
3
  exports[`view-plan regression harness (M0) blockRuns output is stable across the representative blocks 1`] = `
4
4
  [
@@ -0,0 +1,61 @@
1
+ /**
2
+ * The shared annotation palette: how a marked stretch, the caret cell, and a
3
+ * discussion card's accent edge paint, in every surface that hosts inline
4
+ * comments (the plan thread view and the diff sheet). One source, so a mark
5
+ * looks the same wherever it is drawn.
6
+ */
7
+
8
+ import { createTextAttributes } from "@opentui/core";
9
+ import type { Theme } from "./theme";
10
+
11
+ export const UNDERLINE = createTextAttributes({ underline: true });
12
+ export const CUT = createTextAttributes({ strikethrough: true, dim: true });
13
+ export const BOLD = createTextAttributes({ bold: true });
14
+
15
+ /** The marked-words treatment: a violet backdrop under an underline. */
16
+ const MARK_BACKDROP_DARK = "#463852";
17
+ const MARK_BACKDROP_LIGHT = "#e6ddf5";
18
+ /** The discussion card's accent left edge. */
19
+ const CARD_EDGE_DARK = "#ab7aca";
20
+ const CARD_EDGE_LIGHT = "#8b5fd6";
21
+ /** The caret cell: a visible cursor painted on the character under the caret. */
22
+ const CARET_CELL_DARK = "#565b68";
23
+ const CARET_CELL_LIGHT = "#b8bcc8";
24
+
25
+ export interface AnnotationPalette {
26
+ markBackdrop: string;
27
+ cardEdge: string;
28
+ caretCell: string;
29
+ }
30
+
31
+ /** Dark tokens carry light text; use that to pick the palette variant. */
32
+ export function annotationPaletteFor(tokens: Theme): AnnotationPalette {
33
+ const dark = Number.parseInt(tokens.text.slice(1, 3) || "e4", 16) > 128;
34
+
35
+ return dark
36
+ ? { markBackdrop: MARK_BACKDROP_DARK, cardEdge: CARD_EDGE_DARK, caretCell: CARET_CELL_DARK }
37
+ : {
38
+ markBackdrop: MARK_BACKDROP_LIGHT,
39
+ cardEdge: CARD_EDGE_LIGHT,
40
+ caretCell: CARET_CELL_LIGHT,
41
+ };
42
+ }
43
+
44
+ /** Focus cue: mix a hex color toward white, keeping its hue readable. */
45
+ export function lighten(hex: string, amount = 0.25): string {
46
+ const channels = hex.match(/^#(..)(..)(..)$/);
47
+
48
+ if (!channels) return hex;
49
+ const lifted = channels
50
+ .slice(1)
51
+ .map((channel) => {
52
+ const value = Number.parseInt(channel, 16);
53
+
54
+ return Math.round(value + (255 - value) * amount)
55
+ .toString(16)
56
+ .padStart(2, "0");
57
+ })
58
+ .join("");
59
+
60
+ return `#${lifted}`;
61
+ }