@cueloop/client 0.1.0-alpha.63 → 0.1.0-alpha.65

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 (68) hide show
  1. package/package.json +7 -6
  2. package/src/App.autoclose.test.tsx +2 -2
  3. package/src/App.export.test.tsx +2 -2
  4. package/src/App.plan-review.test.tsx +76 -247
  5. package/src/App.readonly.test.tsx +80 -35
  6. package/src/App.rendering.test.tsx +5 -6
  7. package/src/App.review-panel.test.tsx +29 -32
  8. package/src/App.submit-confirm.test.tsx +27 -12
  9. package/src/App.test.tsx +73 -49
  10. package/src/App.toast.test.tsx +34 -22
  11. package/src/App.tree.test.tsx +224 -0
  12. package/src/App.tsx +134 -87
  13. package/src/app-screens.tsx +27 -1
  14. package/src/app-view-model.ts +7 -109
  15. package/src/components/AnnotationCard.tsx +2 -2
  16. package/src/components/DiffSheet.scroll.test.tsx +5 -2
  17. package/src/components/DiffSheet.tsx +1 -6
  18. package/src/components/KeybindsDialog.tsx +61 -17
  19. package/src/components/PrototypeSheet.tsx +5 -9
  20. package/src/components/ReviewPanel.stories.tsx +14 -0
  21. package/src/components/ReviewRail.stories.tsx +14 -0
  22. package/src/components/ReviewRail.tsx +15 -6
  23. package/src/components/ThreadView.selection.test.tsx +812 -0
  24. package/src/components/ThreadView.stories.tsx +26 -0
  25. package/src/components/ThreadView.tsx +1620 -0
  26. package/src/components/TreePane.stories.tsx +106 -0
  27. package/src/components/TreePane.tsx +156 -0
  28. package/src/components/__snapshots__/stories.test.tsx.snap +120 -172
  29. package/src/components/primitives/Tabs.tsx +1 -1
  30. package/src/components/story-fixtures.ts +6 -2
  31. package/src/components/story.ts +16 -4
  32. package/src/components/syntax-highlight.ts +10 -2
  33. package/src/components/terminal-pane.test.tsx +1 -1
  34. package/src/components/terminal-pane.ts +7 -1
  35. package/src/config.ts +112 -99
  36. package/src/diff-hunk-curate.ts +4 -104
  37. package/src/editor.ts +5 -1
  38. package/src/ghostty-terminal.test.ts +1 -1
  39. package/src/herdr.ts +10 -2
  40. package/src/index.ts +1 -0
  41. package/src/intent-dispatch.test.ts +235 -55
  42. package/src/intent-dispatch.ts +104 -34
  43. package/src/key-bindings.test.ts +1 -1
  44. package/src/key-bindings.ts +16 -7
  45. package/src/keymap.test.ts +9 -11
  46. package/src/keymap.ts +14 -7
  47. package/src/kitty-image.test.ts +3 -3
  48. package/src/prototype-browser.ts +7 -6
  49. package/src/serve.ts +1 -1
  50. package/src/session-controller.curate.test.ts +106 -4
  51. package/src/session-controller.share.test.ts +220 -70
  52. package/src/session-controller.tree.test.ts +228 -0
  53. package/src/session-controller.ts +370 -94
  54. package/src/share.ts +117 -17
  55. package/src/test-support.test.ts +193 -1
  56. package/src/test-support.ts +69 -1
  57. package/src/theme-presets.ts +9 -2
  58. package/src/thread-chords.test.ts +154 -0
  59. package/src/thread-chords.ts +144 -0
  60. package/src/thread-selection.test.ts +210 -0
  61. package/src/thread-selection.ts +243 -0
  62. package/src/tree-view.test.ts +128 -0
  63. package/src/tree-view.ts +148 -0
  64. package/src/view-diff.ts +1 -4
  65. package/src/view-plan.ts +35 -19
  66. package/src/components/PlanSheet.stories.tsx +0 -113
  67. package/src/components/PlanSheet.tsx +0 -422
  68. package/src/components/plan-sheet-run-style.ts +0 -90
@@ -1,4 +1,4 @@
1
- /** Observer mode (tier 2): the App with readOnly renders and navigates like the controller's, but every mutating verb answers "observer - read-only" and leaves daemon state untouched. */
1
+ /** Observer mode (tier 2): the App with readOnly renders and navigates like the controller's, but every mutating primitive answers "observer - read-only" and leaves daemon state untouched. */
2
2
 
3
3
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
4
4
  import { mkdtempSync, rmSync } from "node:fs";
@@ -10,7 +10,16 @@ import { DaemonServer } from "@cueloop/daemon";
10
10
  import { makeAnchor, parseBlocks, type ReviewSession } from "@cueloop/schema";
11
11
  import { App } from "./App";
12
12
  import { DARK } from "./theme";
13
- import { isolateUserConfig, press, waitForState, waitForText } from "./test-support";
13
+ import {
14
+ clickText,
15
+ dragText,
16
+ frameRow,
17
+ isolateUserConfig,
18
+ pressKey,
19
+ typeText,
20
+ waitForState,
21
+ waitForText,
22
+ } from "./test-support";
14
23
 
15
24
  const PLAN = `# Migration Plan
16
25
 
@@ -60,7 +69,9 @@ async function renderObserver() {
60
69
  return setup;
61
70
  }
62
71
 
63
- /** Session state that any mutating verb would change. */
72
+ type Setup = Awaited<ReturnType<typeof renderObserver>>;
73
+
74
+ /** Session state that any mutating primitive would change. */
64
75
  function snapshot() {
65
76
  const stored = server.core.sessionGet(session.id);
66
77
 
@@ -84,67 +95,101 @@ describe("observer rendering", () => {
84
95
  });
85
96
  });
86
97
 
87
- describe("observer verbs are blocked", () => {
88
- for (const [key, verb] of [
89
- ["c", "comment"],
90
- ["x", "cut"],
91
- ["e", "edit"],
92
- ["enter", "submit"],
93
- ] as const) {
94
- test(`${verb} (${key}) answers observer - read-only and mutates nothing`, async () => {
98
+ describe("observer primitives are blocked", () => {
99
+ const attempts: Array<[string, (setup: Setup) => Promise<void>]> = [
100
+ ["comment (typing)", (setup) => typeText(setup, "c")],
101
+ ["cut (option+x)", (setup) => pressKey(setup, "x", { meta: true })],
102
+ ["edit (ctrl+e)", (setup) => pressKey(setup, "e", { ctrl: true })],
103
+ ["submit (cmd+enter)", (setup) => pressKey(setup, "RETURN", { meta: true })],
104
+ ];
105
+
106
+ for (const [primitive, attempt] of attempts) {
107
+ test(`${primitive} answers observer - read-only and mutates nothing`, async () => {
95
108
  const setup = await renderObserver();
96
109
  const before = snapshot();
97
110
 
98
- await press(setup, "j");
99
- await press(setup, "j");
100
- await press(setup, key);
111
+ await clickText(setup, "daemon");
112
+ await attempt(setup);
101
113
  const frame = await waitForText(setup, "observer - read-only");
102
114
 
103
- // no overlay opened: compose/submit bars never appear
104
- expect(frame).not.toContain('comment on "');
105
- expect(frame).not.toContain("verdict ←/→");
115
+ // no draft card and no submit card ever appear
116
+ expect(frame).not.toContain("● c");
117
+ expect(frame).not.toContain("[Approve]");
106
118
  expect(snapshot()).toEqual(before);
107
119
  });
108
120
  }
109
121
 
110
- test("span mode c is blocked too", async () => {
122
+ test("typing over a marked span is blocked too", async () => {
111
123
  // Arrange
112
124
  const setup = await renderObserver();
113
125
 
114
126
  // Act
115
- await press(setup, "j");
116
- await press(setup, "j");
117
- await press(setup, "v");
118
- await press(setup, "c");
119
- await setup.renderOnce();
127
+ await dragText(setup, "The daemon", "daemon persists", "daemon".length);
128
+ await typeText(setup, "c");
120
129
 
121
130
  // Assert
122
- const frame = setup.captureCharFrame();
131
+ const frame = await waitForText(setup, "observer - read-only");
123
132
 
124
- expect(frame).toContain("observer - read-only");
125
- expect(frame).not.toContain('comment on "');
133
+ expect(frame).not.toContain("● c");
126
134
  expect(snapshot().annotations).toBe(0);
127
135
  });
128
136
  });
129
137
 
138
+ describe("a resolved review is read-only for its owner too", () => {
139
+ test("typing, deleting a card, and editing answer review submitted - read-only", async () => {
140
+ // Arrange: a comment exists and the verdict is in
141
+ server.core.sessionAnnotate(session.id, {
142
+ id: "a_done",
143
+ kind: "comment",
144
+ anchor: makeAnchor(parseBlocks(PLAN), 2, 0, 10),
145
+ body: "settled",
146
+ });
147
+ server.core.sessionResolve(session.id, "approve", "");
148
+ const setup = await testRender(<App home={home} sessionId={session.id} />, {
149
+ width: 120,
150
+ height: 32,
151
+ });
152
+
153
+ await waitForText(setup, "settled");
154
+ const before = snapshot();
155
+
156
+ // Act + Assert: a draft never opens
157
+ await clickText(setup, "daemon");
158
+ await typeText(setup, "c");
159
+ await waitForText(setup, "review submitted - read-only");
160
+ expect(setup.captureCharFrame()).not.toContain("● c");
161
+
162
+ // Act + Assert: the focused card cannot be deleted or edited
163
+ await pressKey(setup, "n", { meta: true });
164
+ await pressKey(setup, "BACKSPACE", { meta: true });
165
+ await pressKey(setup, "e", { meta: true });
166
+ await pressKey(setup, "x", { meta: true });
167
+ expect(snapshot()).toEqual(before);
168
+ expect(server.core.sessionGet(session.id).annotations).toHaveLength(1);
169
+ });
170
+ });
171
+
130
172
  describe("observer navigation still works", () => {
131
- test("j/k moves the cursor between blocks", async () => {
173
+ test("↓ moves the caret between blocks", async () => {
132
174
  // Arrange
133
175
  const setup = await renderObserver();
134
176
 
135
177
  // Act
136
- await press(setup, "j");
137
- await press(setup, "j");
178
+ await pressKey(setup, "ARROW_DOWN");
179
+ await pressKey(setup, "ARROW_DOWN");
138
180
  await setup.renderOnce();
139
181
 
140
- // Assert
141
- const lines = setup.captureCharFrame().split("\n");
142
- const cursorLine = lines.find((line) => line.includes("▎"))!;
182
+ // Assert: the caret cell is painted on the paragraph's row
183
+ const caretRow = setup
184
+ .captureSpans()
185
+ .lines.findIndex((line) =>
186
+ line.spans.some((span) => span.bg.toInts().slice(0, 3).join() === "86,91,104"),
187
+ );
143
188
 
144
- expect(cursorLine).toContain("persists sessions");
189
+ expect(caretRow).toBe(frameRow(setup, "persists sessions"));
145
190
  });
146
191
 
147
- test("n focuses annotations made by the controller", async () => {
192
+ test("option+n focuses annotations made by the controller", async () => {
148
193
  // Arrange
149
194
  server.core.sessionAnnotate(session.id, {
150
195
  id: "a_obs1",
@@ -155,7 +200,7 @@ describe("observer navigation still works", () => {
155
200
  const setup = await renderObserver();
156
201
 
157
202
  // Act
158
- await press(setup, "n");
203
+ await pressKey(setup, "n", { meta: true });
159
204
  await setup.renderOnce();
160
205
 
161
206
  // Assert - focus shows as the card's blue border (transparent fill); a
@@ -83,10 +83,9 @@ describe("code blocks", () => {
83
83
  });
84
84
 
85
85
  describe("block spacing", () => {
86
- // the review panel's full-height divider paints a `│` on every row, so read
87
- // the plan column (everything left of the divider) before asserting spacing
88
- // the plan body is framed: content sits between the left border and the next │
89
- const planColumn = (line: string): string => line.split("│")[1]?.trimEnd() ?? "";
86
+ // the review rail paints box edges on the right, so read the document
87
+ // column (everything left of the first rail glyph) before asserting spacing
88
+ const planColumn = (line: string): string => line.split(/[│╭╰]/)[0]!.trimEnd();
90
89
 
91
90
  test("a code block never glues to the list item above it", async () => {
92
91
  // Arrange
@@ -129,8 +128,8 @@ describe("block spacing", () => {
129
128
  .captureCharFrame()
130
129
  .split("\n")
131
130
  .map((line) => line.trimEnd());
132
- const one = lines.findIndex((line) => line.includes("- one"));
131
+ const one = lines.findIndex((line) => line.includes("· one"));
133
132
 
134
- expect(lines[one + 1]).toContain("- two");
133
+ expect(lines[one + 1]).toContain("· two");
135
134
  });
136
135
  });
@@ -10,7 +10,7 @@ import { DaemonServer } from "@cueloop/daemon";
10
10
  import type { ReviewSession } from "@cueloop/schema";
11
11
  import { App } from "./App";
12
12
  import { loadConfig } from "./config";
13
- import { isolateUserConfig, press, settle, waitForText, waitForTextGone } from "./test-support";
13
+ import { isolateUserConfig, settle, waitForText, waitForTextGone, pressKey } from "./test-support";
14
14
 
15
15
  const PLAN = "# Plan\n\nShip the thing.\n";
16
16
 
@@ -49,43 +49,40 @@ async function renderApp() {
49
49
  }
50
50
 
51
51
  describe("review panel", () => {
52
- test("b cycles expanded -> compact -> hidden -> expanded", async () => {
52
+ test("ctrl+r cycles expanded -> compact -> hidden -> expanded", async () => {
53
53
  // Arrange
54
54
  const setup = await renderApp();
55
- // the plan frame widens as the rail shrinks: expanded < compact < hidden.
56
- // its width is the column of its top-right corner (the first ╮ on the row).
57
- const planWidth = () =>
58
- setup
59
- .captureCharFrame()
60
- .split("\n")
61
- .find((line) => line.includes("╮"))
62
- ?.indexOf("╮") ?? 0;
63
-
64
- // Assert: expanded shows the submit button and leaves room for the rail
65
- expect(setup.captureCharFrame()).toContain("Submit review");
66
- const expandedWidth = planWidth();
55
+ // each mode has its own signature: expanded draws the tab box and the
56
+ // submit button, compact keeps only the count strip, hidden draws nothing
57
+ const railSignature = () => {
58
+ const frame = setup.captureCharFrame();
59
+
60
+ return {
61
+ tabs: frame.includes("Review Tree Agent"),
62
+ submit: frame.includes("Submit review"),
63
+ strip: /\s0\s*\n/.test(frame) && frame.includes("<"),
64
+ };
65
+ };
66
+
67
+ // Assert: expanded
68
+ expect(railSignature()).toEqual({ tabs: true, submit: true, strip: false });
67
69
 
68
70
  // Act
69
- await press(setup, "b");
71
+ await pressKey(setup, "r", { ctrl: true });
70
72
 
71
- // Assert: compact drops the submit button and widens the plan (rail is a strip)
73
+ // Assert: compact drops the tabs and the button, keeps the strip
72
74
  await waitForTextGone(setup, "Submit review");
73
- const compactWidth = planWidth();
74
-
75
- expect(compactWidth).toBeGreaterThan(expandedWidth);
75
+ expect(railSignature()).toEqual({ tabs: false, submit: false, strip: true });
76
76
 
77
77
  // Act
78
- await press(setup, "b");
78
+ await pressKey(setup, "r", { ctrl: true });
79
79
  await settle(setup);
80
80
 
81
- // Assert: hidden spans the plan full width, no rail column
82
- const hiddenWidth = planWidth();
83
-
84
- expect(hiddenWidth).toBeGreaterThan(compactWidth);
85
- expect(hiddenWidth).toBeGreaterThan(115);
81
+ // Assert: hidden draws no rail at all
82
+ expect(railSignature()).toEqual({ tabs: false, submit: false, strip: false });
86
83
 
87
84
  // Act
88
- await press(setup, "b");
85
+ await pressKey(setup, "r", { ctrl: true });
89
86
 
90
87
  // Assert
91
88
  await waitForText(setup, "Submit review"); // back to expanded
@@ -96,19 +93,19 @@ describe("review panel", () => {
96
93
  const setup = await renderApp();
97
94
 
98
95
  // Act
99
- await press(setup, "b"); // -> compact
96
+ await pressKey(setup, "r", { ctrl: true }); // -> compact
100
97
 
101
98
  // Assert
102
99
  expect(loadConfig({ userConfigPath: configPath }).ui.reviewState).toBe("compact");
103
100
  });
104
101
 
105
- test("] widens the rail and persists the new width", async () => {
102
+ test("option+w widens the rail and persists the new width", async () => {
106
103
  // Arrange
107
104
  const setup = await renderApp();
108
105
  const startWidth = loadConfig({ userConfigPath: configPath }).ui.reviewWidth;
109
106
 
110
107
  // Act
111
- await press(setup, "]");
108
+ await pressKey(setup, "w", { meta: true });
112
109
 
113
110
  // Assert
114
111
  const widened = loadConfig({ userConfigPath: configPath }).ui.reviewWidth;
@@ -120,15 +117,15 @@ describe("review panel", () => {
120
117
  // Arrange
121
118
  const setup = await renderApp();
122
119
 
123
- await press(setup, "b"); // -> compact
124
- await press(setup, "b"); // -> hidden
120
+ await pressKey(setup, "r", { ctrl: true }); // -> compact
121
+ await pressKey(setup, "r", { ctrl: true }); // -> hidden
125
122
 
126
123
  // Assert
127
124
  await waitForTextGone(setup, "Submit review"); // rail is gone
128
125
  expect(setup.captureCharFrame()).not.toContain("Submit review");
129
126
 
130
127
  // Act
131
- await press(setup, "enter"); // submit while hidden must not trap in an invisible modal
128
+ await pressKey(setup, "RETURN", { meta: true }); // submit while hidden must not trap in an invisible modal
132
129
 
133
130
  // Assert
134
131
  await waitForText(setup, "submit review");
@@ -9,7 +9,14 @@ import { testRender } from "@opentui/react/test-utils";
9
9
  import { DaemonServer } from "@cueloop/daemon";
10
10
  import { makeAnchor, parseBlocks, type ReviewSession } from "@cueloop/schema";
11
11
  import { App } from "./App";
12
- import { isolateUserConfig, press, settle, waitForText, waitForTextGone } from "./test-support";
12
+ import {
13
+ isolateUserConfig,
14
+ press,
15
+ pressKey,
16
+ settle,
17
+ waitForText,
18
+ waitForTextGone,
19
+ } from "./test-support";
13
20
 
14
21
  const PLAN = `# Migration Plan
15
22
 
@@ -85,7 +92,7 @@ describe("rail submit confirm", () => {
85
92
  expect(setup.captureCharFrame()).toContain("Submit review");
86
93
 
87
94
  // Act
88
- await press(setup, "enter");
95
+ await pressKey(setup, "RETURN", { meta: true });
89
96
 
90
97
  // Assert
91
98
  await waitForText(setup, "submit review");
@@ -102,7 +109,7 @@ describe("rail submit confirm", () => {
102
109
  // Arrange
103
110
  const setup = await renderApp();
104
111
 
105
- await press(setup, "enter");
112
+ await pressKey(setup, "RETURN", { meta: true });
106
113
 
107
114
  // Assert
108
115
  await waitForText(setup, "[Approve]");
@@ -130,7 +137,7 @@ describe("rail submit confirm", () => {
130
137
  // Arrange
131
138
  const setup = await renderApp();
132
139
 
133
- await press(setup, "enter");
140
+ await pressKey(setup, "RETURN", { meta: true });
134
141
 
135
142
  // Assert
136
143
  await waitForText(setup, "[Approve]");
@@ -152,7 +159,7 @@ describe("rail submit confirm", () => {
152
159
  const setup = await renderApp();
153
160
 
154
161
  // Act
155
- await press(setup, "enter");
162
+ await pressKey(setup, "RETURN", { meta: true });
156
163
 
157
164
  // Assert
158
165
  await waitForText(setup, "[Changes]"); // pending items: request changes default
@@ -173,26 +180,34 @@ describe("rail submit confirm", () => {
173
180
  expect(stored.verdict!.kind).toBe("request_changes");
174
181
  });
175
182
 
183
+ /** The rail's columns of the frame, so document text never leaks into a rail assertion. */
184
+ function railText(frame: string): string {
185
+ const rows = frame.split("\n");
186
+ const railColumn = rows.find((row) => row.includes("Review Tree Agent"))!.indexOf("│");
187
+
188
+ return rows.map((row) => row.slice(railColumn)).join("\n");
189
+ }
190
+
176
191
  test("the annotation stack stays scrollable while the card is open", async () => {
177
192
  // Arrange
178
193
  seedAnnotations(12);
179
194
  const setup = await renderApp();
180
195
 
181
196
  // Act
182
- // walk annotation focus to the last card: the rail scrolls it into view
183
- for (let index = 0; index < 12; index++) await press(setup, "n");
197
+ // walk card focus to the last one: the rail scrolls it into view
198
+ for (let index = 0; index < 12; index++) await pressKey(setup, "n", { meta: true });
184
199
 
185
200
  // Assert
186
201
  await waitForText(setup, "note 12");
187
- expect(setup.captureCharFrame()).not.toContain("note 01");
202
+ expect(railText(setup.captureCharFrame())).not.toContain("note 01");
188
203
 
189
204
  // Act
190
- await press(setup, "enter");
205
+ await pressKey(setup, "RETURN", { meta: true });
191
206
 
192
207
  // Assert
193
208
  await waitForText(setup, "[Changes]");
194
209
  // the card is pinned outside the scrollbox; the stack stays scrolled off the top
195
- expect(setup.captureCharFrame()).not.toContain("note 01");
210
+ expect(railText(setup.captureCharFrame())).not.toContain("note 01");
196
211
 
197
212
  // Act
198
213
  // the stack still scrolls with the card open: wheel over the rail
@@ -202,7 +217,7 @@ describe("rail submit confirm", () => {
202
217
 
203
218
  // Assert
204
219
  await waitForText(setup, "note 12");
205
- const scrolledWithCard = setup.captureCharFrame();
220
+ const scrolledWithCard = railText(setup.captureCharFrame());
206
221
 
207
222
  expect(scrolledWithCard).toContain("submit review");
208
223
  expect(scrolledWithCard).not.toContain("note 01");
@@ -214,7 +229,7 @@ describe("rail submit confirm", () => {
214
229
  const setup = await renderApp({ readOnly: true });
215
230
 
216
231
  // Act
217
- await press(setup, "enter");
232
+ await pressKey(setup, "RETURN", { meta: true });
218
233
 
219
234
  // Assert
220
235
  await waitForText(setup, "observer - read-only");
package/src/App.test.tsx CHANGED
@@ -10,8 +10,12 @@ import { DaemonServer } from "@cueloop/daemon";
10
10
  import type { ReviewSession } from "@cueloop/schema";
11
11
  import { App } from "./App";
12
12
  import {
13
+ clickText,
14
+ dragText,
15
+ frameRow,
13
16
  isolateUserConfig,
14
17
  press,
18
+ pressKey,
15
19
  typeText as type,
16
20
  waitForState,
17
21
  waitForText,
@@ -77,45 +81,36 @@ describe("plan rendering", () => {
77
81
  expect(frame).toContain("Migration Plan");
78
82
  expect(frame).toContain("Context");
79
83
  expect(frame).toContain("persists sessions to disk atomically");
80
- expect(frame).toContain("- move the store");
84
+ expect(frame).toContain("· move the store");
81
85
  expect(frame).toContain("Review");
82
86
  expect(frame).toContain("Submit review");
83
87
  });
84
88
  });
85
89
 
86
- describe("keyboard grammar", () => {
87
- test("j/k moves the cursor glyph between blocks", async () => {
90
+ describe("thread view grammar", () => {
91
+ test("↓ moves the caret between blocks and typing anchors to the block under it", async () => {
88
92
  // Arrange
89
93
  const setup = await renderApp();
90
94
 
91
- // Act
92
- await press(setup, "j");
93
- await press(setup, "j");
94
- await setup.renderOnce();
95
+ // Act: two blocks down lands on the paragraph; a printable opens a draft there
96
+ await pressKey(setup, "ARROW_DOWN");
97
+ await pressKey(setup, "ARROW_DOWN");
98
+ await type(setup, "x");
95
99
 
96
- // Assert
97
- const lines = setup.captureCharFrame().split("\n");
98
- const cursorLine = lines.find((line) => line.includes("▎"))!;
99
-
100
- expect(cursorLine).toContain("persists sessions");
100
+ // Assert: the draft card sits right under the paragraph's row
101
+ await waitForText(setup, "● x");
102
+ expect(frameRow(setup, "● x")).toBeGreaterThan(frameRow(setup, "persists sessions"));
103
+ expect(frameRow(setup, "● x")).toBeLessThan(frameRow(setup, "Steps"));
101
104
  });
102
105
 
103
- test("comment flow: c types a body, ⏎ saves to the daemon", async () => {
106
+ test("comment flow: click a word, type a body, cmd+⏎ saves to the daemon", async () => {
104
107
  // Arrange
105
108
  const setup = await renderApp();
106
109
 
107
110
  // Act
108
- await press(setup, "j");
109
- await press(setup, "j");
110
- await press(setup, "c");
111
- await setup.renderOnce();
112
-
113
- // Assert
114
- await waitForText(setup, 'comment on "The daemon');
115
-
116
- // Act
111
+ await clickText(setup, "daemon");
117
112
  await type(setup, "Define atomically.");
118
- await press(setup, "enter");
113
+ await pressKey(setup, "RETURN", { meta: true });
119
114
 
120
115
  // Assert
121
116
  await waitForText(setup, "COMMENT");
@@ -123,50 +118,44 @@ describe("keyboard grammar", () => {
123
118
 
124
119
  expect(stored.annotations.length).toBe(1);
125
120
  expect(stored.annotations[0]!.body).toBe("Define atomically.");
121
+ expect(stored.annotations[0]!.anchor.quote).toBe("daemon");
126
122
  });
127
123
 
128
- test("span mode: v + l selects words, c anchors the exact span", async () => {
124
+ test("a drag marks the exact span the comment anchors to", async () => {
129
125
  // Arrange
130
126
  const setup = await renderApp();
131
127
 
132
- // Act
133
- await press(setup, "j");
134
- await press(setup, "j");
135
- await press(setup, "v");
136
- await press(setup, "l"); // "The daemon"
137
- await press(setup, "c");
128
+ // Act: from the start of "The" to the end of "daemon"
129
+ await dragText(setup, "The daemon", "daemon persists", "daemon".length);
138
130
  await type(setup, "Which daemon?");
139
- await press(setup, "enter");
131
+ await pressKey(setup, "RETURN", { meta: true });
140
132
 
141
133
  // Assert
142
134
  await waitForState(setup, () => server.core.sessionGet(session.id).annotations.length === 1);
143
- const stored = server.core.sessionGet(session.id);
144
-
145
- expect(stored.annotations[0]!.anchor.quote).toBe("The daemon");
135
+ expect(server.core.sessionGet(session.id).annotations[0]!.anchor.quote).toBe("The daemon");
146
136
  });
147
137
 
148
- test("x cuts a block into the working copy; x restores it", async () => {
138
+ test("option+x cuts the caret's block into the working copy; option+x restores it", async () => {
149
139
  // Arrange
150
140
  const setup = await renderApp();
151
141
 
152
- // move to "- move the store" (h1, h2, p, h2 = 4 steps in)
153
- for (let i = 0; i < 4; i++) await press(setup, "j");
142
+ await clickText(setup, "move the store");
154
143
 
155
144
  // Act
156
- await press(setup, "x");
145
+ await pressKey(setup, "x", { meta: true });
157
146
 
158
147
  // Assert - the rail shows the cut as a removal card titled CUT · me
159
148
  await waitForText(setup, "CUT · me");
160
149
  expect(server.core.sessionGet(session.id).workingCopy).not.toContain("move the store");
161
150
 
162
151
  // Act
163
- await press(setup, "x");
152
+ await pressKey(setup, "x", { meta: true });
164
153
 
165
154
  // Assert
166
155
  await waitForState(setup, () => server.core.sessionGet(session.id).workingCopy === undefined);
167
156
  });
168
157
 
169
- test("e runs $EDITOR on the working copy and tracks the diff", async () => {
158
+ test("ctrl+e runs $EDITOR on the working copy and tracks the diff", async () => {
170
159
  const script = join(home, "fake-editor.sh");
171
160
 
172
161
  await Bun.write(script, `#!/bin/sh\nsed -i '' 's/atomically/very atomically/' "$1"\n`);
@@ -177,7 +166,7 @@ describe("keyboard grammar", () => {
177
166
  const setup = await renderApp();
178
167
 
179
168
  // Act
180
- await press(setup, "e");
169
+ await pressKey(setup, "e", { ctrl: true });
181
170
 
182
171
  // Assert
183
172
  await waitForText(setup, "[edited]");
@@ -189,19 +178,17 @@ describe("keyboard grammar", () => {
189
178
  });
190
179
 
191
180
  describe("submit", () => {
192
- test("⏎ opens the rail confirm card; verdict + summary resolve the session", async () => {
181
+ test("cmd+⏎ opens the rail confirm card; verdict + summary resolve the session", async () => {
193
182
  // Arrange
194
183
  const setup = await renderApp();
195
184
 
196
- await press(setup, "j");
197
- await press(setup, "j");
198
- await press(setup, "c");
185
+ await clickText(setup, "daemon");
199
186
  await type(setup, "Needs a phase list.");
200
- await press(setup, "enter");
187
+ await pressKey(setup, "RETURN", { meta: true });
201
188
  await waitForText(setup, "Needs a phase list");
202
189
 
203
- // Act
204
- await press(setup, "enter"); // open submit
190
+ // Act: with no composer open the same chord opens submit
191
+ await pressKey(setup, "RETURN", { meta: true });
205
192
 
206
193
  // Assert
207
194
  await waitForText(setup, "[Changes]");
@@ -226,7 +213,7 @@ describe("submit", () => {
226
213
  const setup = await renderApp();
227
214
 
228
215
  // Act
229
- await press(setup, "enter");
216
+ await pressKey(setup, "RETURN", { meta: true });
230
217
 
231
218
  // Assert
232
219
  await waitForText(setup, "[Approve]"); // no pending items → approve default
@@ -287,3 +274,40 @@ describe("inbox", () => {
287
274
  await waitForText(setup, "Submit review");
288
275
  });
289
276
  });
277
+
278
+ describe("the thread view and the menu", () => {
279
+ test("the keybinds dialog lists the thread view grammar, keeps keys away from the text, and closes on escape", async () => {
280
+ // Arrange
281
+ const setup = await renderApp();
282
+
283
+ await waitForText(setup, "The daemon persists");
284
+ const lines = setup.captureCharFrame().split("\n");
285
+ const menuRow = lines.findIndex((line) => line.includes("menu"));
286
+ const menuColumn = lines[menuRow]!.indexOf("menu");
287
+
288
+ // Act - open the menu, then the keybinds dialog
289
+ await setup.mockMouse.click(menuColumn + 1, menuRow);
290
+ await waitForText(setup, "Keybinds");
291
+ const dropUp = setup.captureCharFrame().split("\n");
292
+ const keybindsRow = dropUp.findIndex((line) => line.includes("Keybinds"));
293
+
294
+ await setup.mockMouse.click(dropUp[keybindsRow]!.indexOf("Keybinds") + 1, keybindsRow);
295
+ await waitForText(setup, "mark text, across blocks");
296
+
297
+ // Assert - the thread grammar, not the plan sheet's
298
+ const dialog = setup.captureCharFrame();
299
+
300
+ expect(dialog).toContain("⌘⌥m");
301
+ expect(dialog).toContain("dismiss an empty draft");
302
+ expect(dialog).not.toContain("grow/shrink");
303
+
304
+ // Act - a printable behind the dialog must not open a comment; escape closes it
305
+ await press(setup, "x");
306
+ expect(setup.captureCharFrame()).not.toContain("● x");
307
+ await press(setup, "escape");
308
+
309
+ // Assert
310
+ await waitForState(setup, () => !setup.captureCharFrame().includes("dismiss an empty draft"));
311
+ expect(setup.captureCharFrame()).not.toContain("● x");
312
+ });
313
+ });