@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.
- package/package.json +14 -13
- package/src/App.autoclose.test.tsx +3 -3
- package/src/App.diff.test.tsx +22 -5
- package/src/App.export.test.tsx +1 -2
- package/src/App.focus.test.ts +1 -59
- package/src/App.identity.test.tsx +3 -3
- package/src/App.plan-render.test.tsx +209 -0
- package/src/App.plan-review.test.tsx +91 -26
- package/src/App.readonly.test.tsx +6 -5
- package/src/App.share-dialog.test.tsx +172 -0
- package/src/App.submit-confirm.test.tsx +39 -8
- package/src/App.test.tsx +33 -30
- package/src/App.toast.test.tsx +11 -4
- package/src/App.tsx +263 -274
- package/src/App.walk.test.tsx +11 -10
- package/src/App.workbench.test.tsx +86 -0
- package/src/__snapshots__/view-plan.regression.test.ts.snap +1 -1
- package/src/annotation-palette.ts +1 -0
- package/src/ansi-screen.test.ts +28 -0
- package/src/ansi-screen.ts +72 -0
- package/src/app-screens.tsx +19 -9
- package/src/app-view-model.ts +3 -1
- package/src/components/AnnotatableFileView.tsx +1 -1
- package/src/components/AnnotationCards.tsx +18 -2
- package/src/components/AnsiScreen.stories.tsx +12 -0
- package/src/components/AnsiScreen.tsx +28 -0
- package/src/components/CompletionOverlay.stories.tsx +15 -1
- package/src/components/CompletionOverlay.tsx +22 -5
- package/src/components/ConfirmCard.stories.tsx +2 -0
- package/src/components/ConfirmCard.tsx +45 -18
- package/src/components/ConfirmDialog.stories.tsx +3 -3
- package/src/components/ConfirmDialog.tsx +11 -12
- package/src/components/DiffContentView.tsx +18 -11
- package/src/components/DiscussionMarkerRail.tsx +9 -4
- package/src/components/FileContentsView.tsx +1 -1
- package/src/components/GridTabContent.tsx +8 -2
- package/src/components/HandleChipsInput.stories.tsx +24 -0
- package/src/components/HandleChipsInput.test.tsx +35 -0
- package/src/components/HandleChipsInput.tsx +145 -0
- package/src/components/MarkdownGridBlock.stories.tsx +33 -0
- package/src/components/MarkdownGridBlock.tsx +72 -0
- package/src/components/MarkdownThreadEditor.stories.tsx +15 -0
- package/src/components/MarkdownThreadEditor.test.tsx +98 -0
- package/src/components/MarkdownThreadEditor.tsx +162 -0
- package/src/components/NavModeHint.stories.tsx +40 -0
- package/src/components/NavModeHint.tsx +31 -0
- package/src/components/PromptDialog.test.tsx +2 -2
- package/src/components/PromptDialog.tsx +9 -10
- package/src/components/SettingsDialog.tsx +1 -0
- package/src/components/ShareDialog.stories.tsx +121 -0
- package/src/components/ShareDialog.tsx +552 -0
- package/src/components/SlashComposer.stories.tsx +37 -0
- package/src/components/SlashComposer.tsx +87 -0
- package/src/components/ThreadFooter.tsx +4 -1
- package/src/components/ThreadTree.tsx +12 -7
- package/src/components/ThreadView.selection.test.tsx +88 -1
- package/src/components/ThreadView.slash.test.tsx +0 -36
- package/src/components/ThreadView.tsx +200 -67
- package/src/components/Toast.stories.tsx +7 -0
- package/src/components/Toast.tsx +15 -8
- package/src/components/WalkWizard.stories.tsx +2 -2
- package/src/components/WalkWizard.tsx +10 -12
- package/src/components/__snapshots__/stories.test.tsx.snap +436 -182
- package/src/components/primitives/Button.tsx +6 -2
- package/src/components/primitives/Card.tsx +5 -2
- package/src/components/primitives/Dialog.tsx +2 -2
- package/src/components/primitives/DialogActions.stories.tsx +20 -0
- package/src/components/primitives/DialogActions.tsx +39 -0
- package/src/components/primitives/Switch.stories.tsx +17 -0
- package/src/components/primitives/Switch.tsx +32 -0
- package/src/components/primitives/Tree.tsx +5 -1
- package/src/components/primitives/frame.ts +5 -0
- package/src/components/session-tree.ts +7 -6
- package/src/components/share-dialog-store.ts +58 -0
- package/src/components/stories-app.tsx +13 -4
- package/src/components/stories.test.tsx +12 -4
- package/src/components/story-tree.test.ts +32 -24
- package/src/components/story.ts +32 -15
- package/src/config.test.ts +18 -0
- package/src/config.ts +8 -1
- package/src/diff-fold.ts +1 -1
- package/src/diff-intraline.test.ts +75 -2
- package/src/diff-intraline.ts +95 -26
- package/src/intent-dispatch.test.ts +12 -3
- package/src/intent-dispatch.ts +10 -25
- package/src/key-bindings.ts +0 -6
- package/src/keymap.test.ts +2 -2
- package/src/keymap.ts +0 -2
- package/src/markdown-editor-syntax-style.ts +50 -0
- package/src/markdown-grid.test.ts +101 -0
- package/src/markdown-grid.ts +203 -0
- package/src/markdown-highlight.test.ts +61 -0
- package/src/markdown-highlight.ts +122 -0
- package/src/pasted-image.test.ts +67 -0
- package/src/pasted-image.ts +60 -0
- package/src/prototype-browser.test.ts +9 -1
- package/src/prototype-browser.ts +22 -0
- package/src/run.ts +39 -25
- package/src/serve.ts +1 -1
- package/src/share.ts +16 -2
- package/src/slash-palette.test.ts +17 -0
- package/src/slash-palette.ts +11 -0
- package/src/test-support.ts +14 -0
- package/src/thread-chords.test.ts +55 -178
- package/src/thread-chords.ts +78 -169
- package/src/thread-controller.curate.test.ts +1 -0
- package/src/thread-controller.derivation.test.ts +62 -0
- package/src/thread-controller.live-diff.test.ts +3 -0
- package/src/thread-controller.optimistic.test.ts +69 -0
- package/src/thread-controller.share.test.ts +60 -5
- package/src/thread-controller.switch-cache.test.ts +176 -0
- package/src/thread-controller.tree.test.ts +6 -1
- package/src/thread-controller.ts +386 -78
- package/src/use-annotation-surface.tsx +88 -63
- package/src/use-settings-dialog.tsx +14 -3
- package/src/use-thread-body-editing.tsx +58 -0
- package/src/view-plan.test.ts +56 -6
- package/src/view-plan.ts +154 -5
- package/src/App.share-choice.test.tsx +0 -105
- package/src/components/ManageAccessDialog.stories.tsx +0 -36
- package/src/components/ManageAccessDialog.test.tsx +0 -94
- package/src/components/ManageAccessDialog.tsx +0 -120
- package/src/components/ShareChoiceDialog.stories.tsx +0 -34
- package/src/components/ShareChoiceDialog.test.tsx +0 -103
- package/src/components/ShareChoiceDialog.tsx +0 -82
- package/src/components/ThreadsSidebar.stories.tsx +0 -36
- package/src/components/ThreadsSidebar.tsx +0 -71
- package/src/components/agent-launcher.stories.tsx +0 -26
- package/src/components/agent-launcher.test.tsx +0 -50
- 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.
|
|
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.
|
|
31
|
-
"@cueloop/extension-api": "0.1.0-alpha.
|
|
32
|
-
"@cueloop/integration-obsidian": "0.1.0-alpha.
|
|
33
|
-
"@cueloop/schema": "0.1.0-alpha.
|
|
34
|
-
"@opentui/core": "^0.5.
|
|
35
|
-
"@opentui/keymap": "0.5.
|
|
36
|
-
"@opentui/react": "^0.5.
|
|
37
|
-
"@opentui/ssh": "0.5.
|
|
38
|
-
"@pierre/diffs": "^1.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
78
|
-
expect(frame).toContain("
|
|
77
|
+
expect(frame).toContain("close");
|
|
78
|
+
expect(frame).toContain("back to plan");
|
|
79
79
|
|
|
80
80
|
// Act
|
|
81
81
|
await press(setup, "enter");
|
package/src/App.diff.test.tsx
CHANGED
|
@@ -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),
|
|
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
|
|
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
|
|
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
|
|
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");
|
package/src/App.export.test.tsx
CHANGED
|
@@ -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
|
|
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
|
package/src/App.focus.test.ts
CHANGED
|
@@ -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, "
|
|
48
|
+
await waitForText(setup, "settings");
|
|
49
49
|
const menu = setup.captureCharFrame().split("\n");
|
|
50
|
-
const settingsRow = menu.findIndex((line) => line.includes("
|
|
50
|
+
const settingsRow = menu.findIndex((line) => line.includes("settings"));
|
|
51
51
|
|
|
52
|
-
await setup.mockMouse.click(menu[settingsRow]!.indexOf("
|
|
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
|
|
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, "
|
|
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
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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) =>
|
|
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
|
|
163
|
+
await navCommand(setup, "n");
|
|
163
164
|
await pressKey(setup, "BACKSPACE", { meta: true });
|
|
164
|
-
await pressKey(setup, "e", {
|
|
165
|
-
await
|
|
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
|
|
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
|