@cueloop/client 0.1.0-alpha.77 → 0.1.0-alpha.79

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 (130) hide show
  1. package/package.json +14 -13
  2. package/src/App.autoclose.test.tsx +3 -3
  3. package/src/App.diff.test.tsx +22 -5
  4. package/src/App.export.test.tsx +1 -2
  5. package/src/App.focus.test.ts +1 -59
  6. package/src/App.identity.test.tsx +3 -3
  7. package/src/App.plan-render.test.tsx +209 -0
  8. package/src/App.plan-review.test.tsx +91 -26
  9. package/src/App.readonly.test.tsx +6 -5
  10. package/src/App.share-dialog.test.tsx +172 -0
  11. package/src/App.submit-confirm.test.tsx +39 -8
  12. package/src/App.test.tsx +33 -30
  13. package/src/App.toast.test.tsx +11 -4
  14. package/src/App.tsx +263 -274
  15. package/src/App.walk.test.tsx +11 -10
  16. package/src/App.workbench.test.tsx +86 -0
  17. package/src/__snapshots__/view-plan.regression.test.ts.snap +1 -1
  18. package/src/annotation-palette.ts +1 -0
  19. package/src/ansi-screen.test.ts +28 -0
  20. package/src/ansi-screen.ts +72 -0
  21. package/src/app-screens.tsx +19 -9
  22. package/src/app-view-model.ts +3 -1
  23. package/src/components/AnnotatableFileView.tsx +1 -1
  24. package/src/components/AnnotationCards.tsx +18 -2
  25. package/src/components/AnsiScreen.stories.tsx +12 -0
  26. package/src/components/AnsiScreen.tsx +28 -0
  27. package/src/components/CompletionOverlay.stories.tsx +15 -1
  28. package/src/components/CompletionOverlay.tsx +22 -5
  29. package/src/components/ConfirmCard.stories.tsx +2 -0
  30. package/src/components/ConfirmCard.tsx +45 -18
  31. package/src/components/ConfirmDialog.stories.tsx +3 -3
  32. package/src/components/ConfirmDialog.tsx +11 -12
  33. package/src/components/DiffContentView.tsx +18 -11
  34. package/src/components/DiscussionMarkerRail.tsx +9 -4
  35. package/src/components/FileContentsView.tsx +1 -1
  36. package/src/components/GridTabContent.tsx +8 -2
  37. package/src/components/HandleChipsInput.stories.tsx +24 -0
  38. package/src/components/HandleChipsInput.test.tsx +35 -0
  39. package/src/components/HandleChipsInput.tsx +145 -0
  40. package/src/components/MarkdownGridBlock.stories.tsx +33 -0
  41. package/src/components/MarkdownGridBlock.tsx +72 -0
  42. package/src/components/MarkdownThreadEditor.stories.tsx +15 -0
  43. package/src/components/MarkdownThreadEditor.test.tsx +98 -0
  44. package/src/components/MarkdownThreadEditor.tsx +162 -0
  45. package/src/components/NavModeHint.stories.tsx +40 -0
  46. package/src/components/NavModeHint.tsx +31 -0
  47. package/src/components/PromptDialog.test.tsx +2 -2
  48. package/src/components/PromptDialog.tsx +9 -10
  49. package/src/components/SettingsDialog.tsx +1 -0
  50. package/src/components/ShareDialog.stories.tsx +121 -0
  51. package/src/components/ShareDialog.tsx +552 -0
  52. package/src/components/SlashComposer.stories.tsx +37 -0
  53. package/src/components/SlashComposer.tsx +87 -0
  54. package/src/components/ThreadFooter.tsx +4 -1
  55. package/src/components/ThreadTree.tsx +12 -7
  56. package/src/components/ThreadView.selection.test.tsx +88 -1
  57. package/src/components/ThreadView.slash.test.tsx +0 -36
  58. package/src/components/ThreadView.tsx +200 -67
  59. package/src/components/Toast.stories.tsx +7 -0
  60. package/src/components/Toast.tsx +15 -8
  61. package/src/components/WalkWizard.stories.tsx +2 -2
  62. package/src/components/WalkWizard.tsx +10 -12
  63. package/src/components/__snapshots__/stories.test.tsx.snap +436 -182
  64. package/src/components/primitives/Button.tsx +6 -2
  65. package/src/components/primitives/Card.tsx +5 -2
  66. package/src/components/primitives/Dialog.tsx +2 -2
  67. package/src/components/primitives/DialogActions.stories.tsx +20 -0
  68. package/src/components/primitives/DialogActions.tsx +39 -0
  69. package/src/components/primitives/Switch.stories.tsx +17 -0
  70. package/src/components/primitives/Switch.tsx +32 -0
  71. package/src/components/primitives/Tree.tsx +5 -1
  72. package/src/components/primitives/frame.ts +5 -0
  73. package/src/components/session-tree.ts +7 -6
  74. package/src/components/share-dialog-store.ts +58 -0
  75. package/src/components/stories-app.tsx +13 -4
  76. package/src/components/stories.test.tsx +12 -4
  77. package/src/components/story-tree.test.ts +32 -24
  78. package/src/components/story.ts +32 -15
  79. package/src/config.test.ts +18 -0
  80. package/src/config.ts +8 -1
  81. package/src/diff-fold.ts +1 -1
  82. package/src/diff-intraline.test.ts +75 -2
  83. package/src/diff-intraline.ts +95 -26
  84. package/src/intent-dispatch.test.ts +12 -3
  85. package/src/intent-dispatch.ts +10 -25
  86. package/src/key-bindings.ts +0 -6
  87. package/src/keymap.test.ts +2 -2
  88. package/src/keymap.ts +0 -2
  89. package/src/markdown-editor-syntax-style.ts +50 -0
  90. package/src/markdown-grid.test.ts +101 -0
  91. package/src/markdown-grid.ts +203 -0
  92. package/src/markdown-highlight.test.ts +61 -0
  93. package/src/markdown-highlight.ts +122 -0
  94. package/src/pasted-image.test.ts +67 -0
  95. package/src/pasted-image.ts +60 -0
  96. package/src/prototype-browser.test.ts +9 -1
  97. package/src/prototype-browser.ts +22 -0
  98. package/src/run.ts +39 -25
  99. package/src/serve.ts +1 -1
  100. package/src/share.ts +16 -2
  101. package/src/slash-palette.test.ts +17 -0
  102. package/src/slash-palette.ts +11 -0
  103. package/src/test-support.ts +14 -0
  104. package/src/thread-chords.test.ts +55 -178
  105. package/src/thread-chords.ts +78 -169
  106. package/src/thread-controller.curate.test.ts +1 -0
  107. package/src/thread-controller.derivation.test.ts +62 -0
  108. package/src/thread-controller.live-diff.test.ts +3 -0
  109. package/src/thread-controller.optimistic.test.ts +69 -0
  110. package/src/thread-controller.share.test.ts +60 -5
  111. package/src/thread-controller.switch-cache.test.ts +176 -0
  112. package/src/thread-controller.tree.test.ts +6 -1
  113. package/src/thread-controller.ts +386 -78
  114. package/src/use-annotation-surface.tsx +88 -63
  115. package/src/use-settings-dialog.tsx +14 -3
  116. package/src/use-thread-body-editing.tsx +58 -0
  117. package/src/view-plan.test.ts +56 -6
  118. package/src/view-plan.ts +154 -5
  119. package/src/App.share-choice.test.tsx +0 -105
  120. package/src/components/ManageAccessDialog.stories.tsx +0 -36
  121. package/src/components/ManageAccessDialog.test.tsx +0 -94
  122. package/src/components/ManageAccessDialog.tsx +0 -120
  123. package/src/components/ShareChoiceDialog.stories.tsx +0 -34
  124. package/src/components/ShareChoiceDialog.test.tsx +0 -103
  125. package/src/components/ShareChoiceDialog.tsx +0 -82
  126. package/src/components/ThreadsSidebar.stories.tsx +0 -36
  127. package/src/components/ThreadsSidebar.tsx +0 -71
  128. package/src/components/agent-launcher.stories.tsx +0 -26
  129. package/src/components/agent-launcher.test.tsx +0 -50
  130. package/src/components/agent-launcher.tsx +0 -149
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cueloop/client",
3
- "version": "0.1.0-alpha.77",
3
+ "version": "0.1.0-alpha.79",
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,24 +27,25 @@
27
27
  "access": "public"
28
28
  },
29
29
  "dependencies": {
30
- "@cueloop/daemon": "0.1.0-alpha.77",
31
- "@cueloop/extension-api": "0.1.0-alpha.77",
32
- "@cueloop/integration-obsidian": "0.1.0-alpha.77",
33
- "@cueloop/schema": "0.1.0-alpha.77",
34
- "@opentui/core": "^0.5.8",
35
- "@opentui/keymap": "0.5.8",
36
- "@opentui/react": "^0.5.8",
37
- "@opentui/ssh": "0.5.8",
38
- "@pierre/diffs": "^1.3.6",
30
+ "@cueloop/daemon": "0.1.0-alpha.79",
31
+ "@cueloop/extension-api": "0.1.0-alpha.79",
32
+ "@cueloop/integration-obsidian": "0.1.0-alpha.79",
33
+ "@cueloop/schema": "0.1.0-alpha.79",
34
+ "@opentui/core": "^0.5.11",
35
+ "@opentui/keymap": "0.5.11",
36
+ "@opentui/react": "^0.5.11",
37
+ "@opentui/ssh": "0.5.11",
38
+ "@pierre/diffs": "^1.4.1",
39
39
  "@tanstack/virtual-core": "^3.17.9",
40
40
  "diff": "^9",
41
- "puppeteer-core": "^25.8.0",
41
+ "puppeteer-core": "^25.9.0",
42
42
  "react": "^19.2.0",
43
- "valibot": "^1.4.2"
43
+ "valibot": "^1.4.2",
44
+ "zustand": "^5.0.15"
44
45
  },
45
46
  "devDependencies": {
46
47
  "@types/react": "^19.2.0",
47
- "@types/ssh2": "^1.15.5",
48
+ "@types/ssh2": "^1.15.6",
48
49
  "ssh2": "^1.17.0"
49
50
  },
50
51
  "engines": {
@@ -57,7 +57,7 @@ type Setup = Awaited<ReturnType<typeof renderApp>>;
57
57
 
58
58
  async function submitApprove(setup: Setup): Promise<void> {
59
59
  await pressKey(setup, "RETURN", { meta: true }); // open submit (approve default: no pending items)
60
- await press(setup, "enter"); // confirm
60
+ await pressKey(setup, "RETURN", { meta: true }); // confirm
61
61
  await waitForText(setup, "review approved");
62
62
  }
63
63
 
@@ -74,8 +74,8 @@ describe("completion overlay", () => {
74
74
  const frame = await waitForText(setup, "closing in 5s");
75
75
 
76
76
  expect(frame).toContain("review approved");
77
- expect(frame).toContain("close [return]");
78
- expect(frame).toContain("return to plan [esc]");
77
+ expect(frame).toContain("close");
78
+ expect(frame).toContain("back to plan");
79
79
 
80
80
  // Act
81
81
  await press(setup, "enter");
@@ -13,6 +13,7 @@ import {
13
13
  clickText,
14
14
  dragText,
15
15
  isolateUserConfig,
16
+ navCommand,
16
17
  press,
17
18
  pressKey,
18
19
  renderReadyApp,
@@ -93,9 +94,12 @@ describe("diff review", () => {
93
94
  await waitForText(setup, "● Map needs an eviction story.");
94
95
  expect(server.core.sessionGet(session.id).annotations[0]!.anchor.quote).toBe("new Map()");
95
96
 
96
- // Act - submit with the session chord (cmd+enter, no composer open), confirm request_changes
97
+ // Act - submit with the session chord (cmd+enter, no composer open), cycle to request changes
98
+ await pressKey(setup, "RETURN", { meta: true });
99
+ await waitForText(setup, "[Approve]");
100
+ await press(setup, "right");
101
+ await waitForText(setup, "[Changes]");
97
102
  await pressKey(setup, "RETURN", { meta: true });
98
- await press(setup, "enter");
99
103
 
100
104
  // Assert
101
105
  await waitForText(setup, "feedback sent");
@@ -105,6 +109,19 @@ describe("diff review", () => {
105
109
  expect(resolved.verdict!.feedback).toContain("Map needs an eviction story.");
106
110
  });
107
111
 
112
+ test("pasting an image into a diff comment drops in an [Image #n] placeholder", async () => {
113
+ // Arrange - mark the added line and open the inline comment composer
114
+ const setup = await renderApp();
115
+ await dragText(setup, "new Map()", "new Map()", "new Map()".length);
116
+ await typeText(setup, "see ");
117
+
118
+ // Act - a burst of control bytes stands in for the binary an image paste delivers
119
+ await setup.mockInput.pasteBracketedText("");
120
+
121
+ // Assert - the same placeholder the summary composer shows
122
+ await waitForText(setup, "see [Image #1]");
123
+ });
124
+
108
125
  test("a comment reopens with its mark painted on the code", async () => {
109
126
  // Arrange - a stored comment anchored to the added line's words
110
127
  server.core.sessionAnnotate(session.id, {
@@ -126,7 +143,7 @@ describe("diff review", () => {
126
143
  const setup = await renderApp();
127
144
 
128
145
  // Act
129
- await pressKey(setup, "c", { meta: true });
146
+ await navCommand(setup, "c");
130
147
 
131
148
  // Assert - the body is gone but the band (with its counts) remains
132
149
  await waitForState(setup, () => !setup.captureCharFrame().includes("new Map()"));
@@ -147,7 +164,7 @@ describe("diff review", () => {
147
164
  const setup = await renderApp();
148
165
 
149
166
  // Act
150
- await pressKey(setup, "x", { meta: true });
167
+ await navCommand(setup, "x");
151
168
 
152
169
  // Assert
153
170
  await waitForText(setup, "hunk curation needs full file contents");
@@ -175,7 +192,7 @@ describe("diff review", () => {
175
192
 
176
193
  // Act - place the caret on the added line and reject its change
177
194
  await clickText(setup, "new Map()");
178
- await pressKey(setup, "x", { meta: true });
195
+ await navCommand(setup, "x");
179
196
 
180
197
  // Assert - the single change is gone, so the curated working copy is empty
181
198
  await waitForText(setup, "change rejected");
@@ -18,7 +18,6 @@ import type { Thread } from "@cueloop/schema";
18
18
  import { App } from "./App";
19
19
  import {
20
20
  isolateUserConfig,
21
- press,
22
21
  waitForState,
23
22
  waitForText,
24
23
  pressKey,
@@ -81,7 +80,7 @@ describe("obsidian export on resolve", () => {
81
80
  await waitForText(setup, "[Approve]");
82
81
 
83
82
  // Act
84
- await press(setup, "enter"); // submit
83
+ await pressKey(setup, "RETURN", { meta: true }); // submit
85
84
 
86
85
  // Assert
87
86
  // the export is an async round-trip after resolve; the status line lands
@@ -1,11 +1,5 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import {
3
- appLeaderHandled,
4
- nextFocusPane,
5
- reconciledFocus,
6
- threadsNavHandled,
7
- visiblePanes,
8
- } from "./App";
2
+ import { nextFocusPane, reconciledFocus, threadsNavHandled, visiblePanes } from "./App";
9
3
 
10
4
  describe("threads keyboard nav", () => {
11
5
  const base = () => {
@@ -92,56 +86,4 @@ describe("pane focus cycle", () => {
92
86
  expect(reconciledFocus(["changes", "project"], "thread")).toBe("changes");
93
87
  expect(reconciledFocus([], "thread")).toBeNull();
94
88
  });
95
-
96
- test("appLeaderHandled captures the leader for the sidebar panes, then the next key", () => {
97
- const leaderCombos = ["ctrl+g"];
98
- const pending = { current: false };
99
- const ran: string[] = [];
100
- const call = (
101
- focusedPane: "threads" | "project" | "thread" | "changes",
102
- key: { name: string; ctrl?: boolean },
103
- ) =>
104
- appLeaderHandled({
105
- focusedPane,
106
- key,
107
- leaderCombos,
108
- pending,
109
- runLeaderCommand: (pressed) => ran.push(pressed.name),
110
- });
111
-
112
- expect(call("threads", { name: "g", ctrl: true })).toBe(true);
113
- expect(pending.current).toBe(true);
114
- expect(call("threads", { name: "tab" })).toBe(true);
115
- expect(pending.current).toBe(false);
116
- expect(ran).toEqual(["tab"]);
117
- });
118
-
119
- test("appLeaderHandled defers on the content panes and swallows escape without a command", () => {
120
- const leaderCombos = ["ctrl+g"];
121
- let ran = false;
122
- const runLeaderCommand = () => (ran = true);
123
-
124
- expect(
125
- appLeaderHandled({
126
- focusedPane: "thread",
127
- key: { name: "ctrl+g" },
128
- leaderCombos,
129
- pending: { current: false },
130
- runLeaderCommand,
131
- }),
132
- ).toBe(false);
133
-
134
- const pending = { current: true };
135
- expect(
136
- appLeaderHandled({
137
- focusedPane: "project",
138
- key: { name: "escape" },
139
- leaderCombos,
140
- pending,
141
- runLeaderCommand,
142
- }),
143
- ).toBe(true);
144
- expect(pending.current).toBe(false);
145
- expect(ran).toBe(false);
146
- });
147
89
  });
@@ -45,11 +45,11 @@ test("the Account settings tab shows the display name row and the GitHub sync ac
45
45
  const setup = await renderApp();
46
46
 
47
47
  await setup.mockMouse.click(1, 0);
48
- await waitForText(setup, "Settings");
48
+ await waitForText(setup, "settings");
49
49
  const menu = setup.captureCharFrame().split("\n");
50
- const settingsRow = menu.findIndex((line) => line.includes("Settings"));
50
+ const settingsRow = menu.findIndex((line) => line.includes("settings"));
51
51
 
52
- await setup.mockMouse.click(menu[settingsRow]!.indexOf("Settings") + 1, settingsRow);
52
+ await setup.mockMouse.click(menu[settingsRow]!.indexOf("settings") + 1, settingsRow);
53
53
  await waitForText(setup, "Account");
54
54
  const dialog = setup.captureCharFrame().split("\n");
55
55
  const accountRow = dialog.findIndex((line) => line.includes("Account"));
@@ -0,0 +1,209 @@
1
+ /**
2
+ * The read-only plan render: h1/h2 sit over
3
+ * a rule, links show their label in the link color with the URL concealed,
4
+ * inline code grays with its backticks gone, GFM tables render as an aligned grid
5
+ * and leading YAML frontmatter as a bordered key/value table. Because rendered
6
+ * text now differs from the source, a comment dragged over a concealed-markup
7
+ * passage must still anchor to the source and repaint on the visible label.
8
+ * Char-frame + styled-span assertions over the real App and an in-process daemon.
9
+ */
10
+
11
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
12
+ import { mkdtempSync, rmSync } from "node:fs";
13
+ import { tmpdir } from "node:os";
14
+ import { join } from "node:path";
15
+ import React from "react";
16
+ import { DaemonServer } from "@cueloop/daemon";
17
+ import type { Thread } from "@cueloop/schema";
18
+ import { App } from "./App";
19
+ import { DARK } from "./theme";
20
+ import {
21
+ dragText,
22
+ isolateUserConfig,
23
+ pressKey,
24
+ renderReadyApp,
25
+ settle,
26
+ typeText as type,
27
+ waitForState,
28
+ waitForText,
29
+ } from "./test-support";
30
+
31
+ const PLAN = `---
32
+ title: Migration Plan
33
+ owner: platform team
34
+ ---
35
+
36
+ # Migration Plan
37
+
38
+ ## Rollout
39
+
40
+ Ship it behind a \`flag\` and read [OpenTUI](https://opentui) first.
41
+
42
+ | Name | Size |
43
+ | :--- | ---: |
44
+ | daemon | 12 |
45
+ | client | 7 |
46
+ `;
47
+
48
+ let home: string;
49
+ let server: DaemonServer;
50
+ let session: Thread;
51
+ let restoreUserConfig: () => void;
52
+
53
+ beforeEach(() => {
54
+ home = mkdtempSync(join(tmpdir(), "cueloop-plan-render-"));
55
+ restoreUserConfig = isolateUserConfig(home);
56
+ server = new DaemonServer({ home, idleExitMs: 0 });
57
+ server.start();
58
+ session = server.core.sessionCreate({
59
+ workspace: { repoRoot: "/repo", branch: "main" },
60
+ artifact: {
61
+ type: "plan",
62
+ content: PLAN,
63
+ meta: { title: "Migration Plan", planPath: "plan.md" },
64
+ },
65
+ });
66
+ });
67
+ afterEach(() => {
68
+ restoreUserConfig();
69
+ server.stop();
70
+ rmSync(home, { recursive: true, force: true });
71
+ });
72
+
73
+ type Setup = Awaited<ReturnType<typeof renderPlan>>;
74
+
75
+ async function renderPlan() {
76
+ const setup = await renderReadyApp(<App home={home} sessionId={session.id} />, {
77
+ width: 90,
78
+ height: 40,
79
+ });
80
+
81
+ await waitForText(setup, "cueloop");
82
+ await settle(setup);
83
+
84
+ return setup;
85
+ }
86
+
87
+ /** Hex foregrounds of every styled span containing the needle. */
88
+ function foregroundsOf(setup: Setup, needle: string): string[] {
89
+ const foregrounds: string[] = [];
90
+
91
+ for (const line of setup.captureSpans().lines) {
92
+ for (const span of line.spans) {
93
+ if (span.fg === undefined || !span.text.includes(needle)) continue;
94
+ const [red, green, blue] = span.fg.toInts();
95
+
96
+ foregrounds.push(
97
+ "#" + [red, green, blue].map((part) => part.toString(16).padStart(2, "0")).join(""),
98
+ );
99
+ }
100
+ }
101
+
102
+ return foregrounds;
103
+ }
104
+
105
+ /** Hex backgrounds of every styled span containing the needle. */
106
+ function backgroundsOf(setup: Setup, needle: string): string[] {
107
+ const backgrounds: string[] = [];
108
+
109
+ for (const line of setup.captureSpans().lines) {
110
+ for (const span of line.spans) {
111
+ if (!span.text.includes(needle)) continue;
112
+ const [red, green, blue] = span.bg.toInts();
113
+
114
+ backgrounds.push(
115
+ "#" + [red, green, blue].map((part) => part.toString(16).padStart(2, "0")).join(""),
116
+ );
117
+ }
118
+ }
119
+
120
+ return backgrounds;
121
+ }
122
+
123
+ const THREAD_MARK = "#463852";
124
+
125
+ describe("read-only markdown render", () => {
126
+ test("h1 and h2 sit over a rule", async () => {
127
+ const setup = await renderPlan();
128
+ const rows = setup.captureCharFrame().split("\n");
129
+ // "Migration Plan" also rides the header bar; a heading is any body row directly over a rule
130
+ const ruleBelow = (heading: string): boolean =>
131
+ rows.some((row, index) => row.includes(heading) && (rows[index + 1] ?? "").includes("──"));
132
+
133
+ expect(ruleBelow("Migration Plan")).toBe(true);
134
+ expect(ruleBelow("Rollout")).toBe(true);
135
+ });
136
+
137
+ test("a link shows its label in the link color with the URL concealed", async () => {
138
+ const setup = await renderPlan();
139
+ const frame = setup.captureCharFrame();
140
+
141
+ expect(frame).toContain("OpenTUI");
142
+ expect(frame).not.toContain("https://opentui");
143
+ expect(frame).not.toContain("[OpenTUI]");
144
+ expect(foregroundsOf(setup, "OpenTUI")).toContain(DARK.blue);
145
+ });
146
+
147
+ test("inline code grays with its backticks concealed", async () => {
148
+ const setup = await renderPlan();
149
+ const frame = setup.captureCharFrame();
150
+
151
+ expect(frame).toContain("flag");
152
+ expect(frame).not.toContain("`flag`");
153
+ expect(foregroundsOf(setup, "flag")).toContain(DARK.textDim);
154
+ });
155
+
156
+ test("a table renders as an aligned grid without pipes", async () => {
157
+ const setup = await renderPlan();
158
+ const rows = setup.captureCharFrame().split("\n");
159
+ const header = rows.find((row) => row.includes("Name") && row.includes("Size"))!;
160
+
161
+ expect(header).not.toContain("|");
162
+ // the numeric column is right-aligned, so 12 and 7 line up on their last digit
163
+ const daemon = rows.find((row) => row.includes("daemon"))!;
164
+ const client = rows.find((row) => row.includes("client"))!;
165
+
166
+ expect(daemon.indexOf("12") + 1).toBe(client.indexOf("7"));
167
+ });
168
+
169
+ test("frontmatter renders as a bordered key/value grid", async () => {
170
+ const setup = await renderPlan();
171
+ const frame = setup.captureCharFrame();
172
+
173
+ expect(frame).toContain("┌");
174
+ expect(frame).toContain("└");
175
+ expect(frame).toContain("title");
176
+ expect(frame).toContain("platform team");
177
+ });
178
+ });
179
+
180
+ describe("annotating a grid block", () => {
181
+ test("a comment dragged over a table cell anchors on the grid and marks it", async () => {
182
+ const setup = await renderPlan();
183
+
184
+ // the table renders as a grid, yet it still takes a comment like any other block
185
+ await dragText(setup, "daemon", "daemon", "daemon".length);
186
+ await type(setup, "which daemon?");
187
+ await pressKey(setup, "RETURN", { meta: true });
188
+
189
+ await waitForState(setup, () => server.core.sessionGet(session.id).annotations.length === 1);
190
+ // the grid carries the mark backdrop, so an annotation resolving into it stays visible
191
+ await waitForState(setup, () => backgroundsOf(setup, "daemon").includes(THREAD_MARK));
192
+ }, 60_000);
193
+ });
194
+
195
+ describe("anchoring over concealed markup", () => {
196
+ test("a comment dragged over a rendered link label anchors to the source and repaints", async () => {
197
+ const setup = await renderPlan();
198
+
199
+ // drag the visible label "OpenTUI" (its brackets and URL are concealed)
200
+ await dragText(setup, "OpenTUI", "OpenTUI", "OpenTUI".length);
201
+ await type(setup, "which build?");
202
+ await pressKey(setup, "RETURN", { meta: true });
203
+
204
+ await waitForState(setup, () => server.core.sessionGet(session.id).annotations.length === 1);
205
+ expect(server.core.sessionGet(session.id).annotations[0]!.anchor.quote).toBe("OpenTUI");
206
+ // the saved comment keeps the visible label marked
207
+ await waitForState(setup, () => backgroundsOf(setup, "OpenTUI").includes(THREAD_MARK));
208
+ }, 60_000);
209
+ });
@@ -14,6 +14,7 @@ import {
14
14
  clickText,
15
15
  dragText,
16
16
  isolateUserConfig,
17
+ navCommand,
17
18
  press,
18
19
  pressKey,
19
20
  renderReadyApp,
@@ -210,11 +211,15 @@ describe("the mark stays painted while composing", () => {
210
211
  await waitForText(setup, "● x");
211
212
  expect(backgroundsOf(setup, "The daemon")).toContain(THREAD_MARK);
212
213
 
213
- // Act: escape discards the draft; the mark stays for re-typing, a second escape drops it
214
+ // Act: escape discards the draft and the mark stays for re-typing; the next escape
215
+ // enters nav mode with the mark still held; only an escape in nav drops it
214
216
  await press(setup, "escape");
215
217
  await waitForTextGone(setup, "● x");
216
218
  expect(backgroundsOf(setup, "The daemon")).toContain(THREAD_MARK);
217
219
  await press(setup, "escape");
220
+ await waitForText(setup, "type to leave");
221
+ expect(backgroundsOf(setup, "The daemon")).toContain(THREAD_MARK);
222
+ await press(setup, "escape");
218
223
 
219
224
  // Assert
220
225
  await waitForState(setup, () => !backgroundsOf(setup, "The daemon").includes(THREAD_MARK));
@@ -228,6 +233,38 @@ describe("the mark stays painted while composing", () => {
228
233
  await waitForState(setup, () => server.core.sessionGet(session.id).annotations.length === 1);
229
234
  await waitForState(setup, () => backgroundsOf(setup, "The daemon").includes(THREAD_MARK));
230
235
  }, 60_000);
236
+
237
+ test("backspace on an empty draft dismisses the composer back to the mark", async () => {
238
+ // Arrange
239
+ const setup = await renderApp();
240
+
241
+ // Act: mark, type one character, then delete it and backspace again on the now-empty draft
242
+ await dragText(setup, "The daemon", "daemon persists", "daemon".length);
243
+ await type(setup, "x");
244
+ await waitForText(setup, "● x");
245
+ await press(setup, "backspace");
246
+ await press(setup, "backspace");
247
+
248
+ // Assert: the composer is gone and the mark stays, ready to re-type
249
+ await waitForTextGone(setup, "● x");
250
+ expect(backgroundsOf(setup, "The daemon")).toContain(THREAD_MARK);
251
+ }, 60_000);
252
+
253
+ test("esc from type mode keeps the mark, so c in nav comments on the marked text", async () => {
254
+ // Arrange
255
+ const setup = await renderApp();
256
+
257
+ // Act: mark, enter nav with esc, open the composer with c, save
258
+ await dragText(setup, "The daemon", "daemon persists", "daemon".length);
259
+ await navCommand(setup, "c");
260
+ await type(setup, "Which daemon?");
261
+ await waitForText(setup, "● Which daemon?");
262
+ await pressKey(setup, "RETURN", { meta: true });
263
+
264
+ // Assert: the comment anchors to the mark, not to the word under the caret
265
+ await waitForState(setup, () => server.core.sessionGet(session.id).annotations.length === 1);
266
+ expect(server.core.sessionGet(session.id).annotations[0]!.anchor.quote).toBe("The daemon");
267
+ }, 60_000);
231
268
  });
232
269
 
233
270
  describe("compose newline convention", () => {
@@ -295,7 +332,7 @@ describe("quick-actions settings editor", () => {
295
332
  // Act - open Settings from the top-left gear, enter Actions, expand the first action, type
296
333
  await setup.mockMouse.click(1, 0);
297
334
  await waitForText(setup, "Keybinds");
298
- await clickText(setup, "Settings");
335
+ await clickText(setup, "settings");
299
336
  await clickText(setup, "Actions");
300
337
  await clickText(setup, "Zoom out, research in depth");
301
338
  await type(setup, "CUSTOM");
@@ -311,30 +348,58 @@ describe("quick-actions settings editor", () => {
311
348
 
312
349
  describe("edit-exit reconciliation", () => {
313
350
  test("an edit that removes an anchored passage orphans the annotation and shows the banner", async () => {
314
- const script = join(home, "fake-editor.sh");
315
-
316
- await Bun.write(script, `#!/bin/sh\nsed -i '' '/^The daemon persists/d' "$1"\n`);
317
- Bun.spawnSync(["chmod", "+x", script]);
318
- process.env.CUELOOP_EDITOR = script;
319
- try {
320
- // Arrange
321
- const setup = await renderApp();
322
-
323
- await clickText(setup, "daemon");
324
- await type(setup, "Anchor me to the doomed passage.");
325
- await pressKey(setup, "RETURN", { meta: true });
326
- await waitForState(setup, () => server.core.sessionGet(session.id).annotations.length === 1);
327
-
328
- // Act
329
- await pressKey(setup, "e", { ctrl: true });
330
-
331
- // Assert - the thread view banner reports the orphaned annotation
332
- await waitForText(setup, "1 annotation no longer match - the passage was removed.");
333
- // the annotation is NOT deleted: the feedback serializer handles orphans
334
- expect(server.core.sessionGet(session.id).annotations.length).toBe(1);
335
- } finally {
336
- delete process.env.CUELOOP_EDITOR;
337
- }
351
+ // Arrange - anchor a comment to a passage
352
+ const setup = await renderApp();
353
+
354
+ await clickText(setup, "daemon");
355
+ await type(setup, "Anchor me to the doomed passage.");
356
+ await pressKey(setup, "RETURN", { meta: true });
357
+ await waitForState(setup, () => server.core.sessionGet(session.id).annotations.length === 1);
358
+
359
+ // Act - open the inline editor, replace the whole body (dropping the passage), and leave
360
+ await pressKey(setup, "e", { ctrl: true });
361
+ await waitForText(setup, "save & close");
362
+ await pressKey(setup, "a", { meta: true });
363
+ await type(setup, "A fresh body with no anchored passage.");
364
+ await clickText(setup, "normal");
365
+
366
+ // Assert - the thread view banner reports the orphaned annotation
367
+ await waitForText(setup, "1 annotation no longer match - the passage was removed.");
368
+ // the annotation is NOT deleted: the feedback serializer handles orphans
369
+ expect(server.core.sessionGet(session.id).annotations.length).toBe(1);
370
+ });
371
+ });
372
+
373
+ describe("thread switch preserves an open edit", () => {
374
+ test("clicking another thread while editing saves the body into the leaving thread", async () => {
375
+ // Arrange - a second thread sits in the sidebar to switch to
376
+ server.core.sessionCreate({
377
+ workspace: { repoRoot: "/repo", branch: "main" },
378
+ artifact: {
379
+ type: "plan",
380
+ content: "# Second Thread\n\nAnother body.\n",
381
+ meta: { title: "Second Thread", planPath: "plan.md", agent: "agent/worker-3" },
382
+ },
383
+ });
384
+ const setup = await renderApp();
385
+
386
+ // open the Threads sidebar so the other thread is on screen to click
387
+ await setup.mockMouse.click(4, 0);
388
+ await waitForText(setup, "Second Thread");
389
+
390
+ // Act - open the inline editor, rewrite the body, then click away before an explicit save
391
+ await pressKey(setup, "e", { ctrl: true });
392
+ await waitForText(setup, "save & close");
393
+ await pressKey(setup, "a", { meta: true });
394
+ await type(setup, "Body kept across a thread switch.");
395
+ await clickText(setup, "Second Thread");
396
+
397
+ // Assert - the leaving thread's working copy holds the edit rather than dropping it
398
+ await waitForState(setup, () =>
399
+ (server.core.sessionGet(session.id).workingCopy ?? "").includes(
400
+ "kept across a thread switch",
401
+ ),
402
+ );
338
403
  });
339
404
  });
340
405
 
@@ -13,6 +13,7 @@ import {
13
13
  dragText,
14
14
  frameRow,
15
15
  isolateUserConfig,
16
+ navCommand,
16
17
  pressKey,
17
18
  renderReadyApp,
18
19
  typeText,
@@ -97,7 +98,7 @@ describe("observer rendering", () => {
97
98
  describe("observer primitives are blocked", () => {
98
99
  const attempts: Array<[string, (setup: Setup) => Promise<void>]> = [
99
100
  ["comment (typing)", (setup) => typeText(setup, "c")],
100
- ["cut (option+x)", (setup) => pressKey(setup, "x", { meta: true })],
101
+ ["cut (option+x)", (setup) => navCommand(setup, "x")],
101
102
  ["edit (ctrl+e)", (setup) => pressKey(setup, "e", { ctrl: true })],
102
103
  ["submit (cmd+enter)", (setup) => pressKey(setup, "RETURN", { meta: true })],
103
104
  ];
@@ -159,10 +160,10 @@ describe("a resolved review is read-only for its owner too", () => {
159
160
  expect(setup.captureCharFrame()).not.toContain("● c");
160
161
 
161
162
  // Act + Assert: the focused card cannot be deleted or edited
162
- await pressKey(setup, "n", { meta: true });
163
+ await navCommand(setup, "n");
163
164
  await pressKey(setup, "BACKSPACE", { meta: true });
164
- await pressKey(setup, "e", { meta: true });
165
- await pressKey(setup, "x", { meta: true });
165
+ await pressKey(setup, "e", { ctrl: true });
166
+ await navCommand(setup, "x");
166
167
  expect(snapshot()).toEqual(before);
167
168
  expect(server.core.sessionGet(session.id).annotations).toHaveLength(1);
168
169
  });
@@ -199,7 +200,7 @@ describe("observer navigation still works", () => {
199
200
  const setup = await renderObserver();
200
201
 
201
202
  // Act - the observer navigates to the annotation without mutating anything
202
- await pressKey(setup, "n", { meta: true });
203
+ await navCommand(setup, "n");
203
204
  await setup.renderOnce();
204
205
 
205
206
  // Assert - the observer reads the controller's comment inline in the thread