@cueloop/client 0.1.0-alpha.7 → 0.1.0-alpha.71
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/native/build-pty.sh +51 -0
- package/native/build.sh +82 -0
- package/native/darwin-arm64/libcuelooppty.dylib +0 -0
- package/native/darwin-arm64/libcueloopvt.dylib +0 -0
- package/native/src/pty.zig +193 -0
- package/native/src/shim.zig +114 -0
- package/package.json +39 -32
- package/src/App.appearance.test.tsx +53 -0
- package/src/App.autoclose.test.tsx +76 -50
- package/src/App.diff.test.tsx +156 -42
- package/src/App.export.test.tsx +58 -40
- package/src/App.plan-review.test.tsx +363 -0
- package/src/App.prototype-markdown.test.tsx +92 -0
- package/src/App.prototype.test.tsx +176 -0
- package/src/App.readonly.test.tsx +128 -67
- package/src/App.rendering.test.tsx +61 -35
- package/src/App.submit-confirm.test.tsx +191 -0
- package/src/App.test.tsx +310 -114
- package/src/App.toast.test.tsx +89 -0
- package/src/App.tsx +1043 -798
- package/src/App.walk.test.tsx +298 -0
- package/src/App.workbench.test.tsx +495 -0
- package/src/__snapshots__/view-plan.regression.test.ts.snap +100 -0
- package/src/annotation-palette.ts +61 -0
- package/src/app-key-state.ts +61 -0
- package/src/app-screens.tsx +432 -0
- package/src/app-view-model.ts +159 -0
- package/src/attribution.test.ts +31 -0
- package/src/attribution.ts +42 -0
- package/src/clipboard.test.ts +23 -0
- package/src/clipboard.ts +33 -0
- package/src/components/AnnotatableFileView.stories.tsx +42 -0
- package/src/components/AnnotatableFileView.test.tsx +102 -0
- package/src/components/AnnotatableFileView.tsx +126 -0
- package/src/components/AnnotationCards.stories.tsx +87 -0
- package/src/components/AnnotationCards.tsx +303 -0
- package/src/components/AppHeader.stories.tsx +50 -0
- package/src/components/AppHeader.tsx +83 -0
- package/src/components/AppShell.stories.tsx +89 -0
- package/src/components/AppShell.tsx +290 -0
- package/src/components/AppShell.zoom.test.tsx +135 -0
- package/src/components/BareWorkbenchFileView.stories.tsx +27 -0
- package/src/components/BareWorkbenchFileView.tsx +64 -0
- package/src/components/Breadcrumb.stories.tsx +19 -0
- package/src/components/Breadcrumb.tsx +45 -0
- package/src/components/ChangesColumn.stories.tsx +39 -0
- package/src/components/ChangesColumn.tsx +171 -0
- package/src/components/CodeBlock.stories.tsx +42 -0
- package/src/components/CodeBlock.tsx +84 -0
- package/src/components/CompletionOverlay.stories.tsx +23 -0
- package/src/components/CompletionOverlay.tsx +60 -0
- package/src/components/ConfirmCard.stories.tsx +35 -0
- package/src/components/ConfirmCard.tsx +128 -0
- package/src/components/ConfirmDialog.stories.tsx +21 -0
- package/src/components/ConfirmDialog.tsx +66 -0
- package/src/components/DiffContentView.scroll.test.tsx +121 -0
- package/src/components/DiffContentView.split.test.tsx +140 -0
- package/src/components/DiffContentView.stories.tsx +80 -0
- package/src/components/DiffContentView.syntax.test.tsx +82 -0
- package/src/components/DiffContentView.tsx +859 -0
- package/src/components/DiscussionMarkerRail.stories.tsx +54 -0
- package/src/components/DiscussionMarkerRail.tsx +181 -0
- package/src/components/EditorGrid.stories.tsx +59 -0
- package/src/components/EditorGrid.tabs.test.tsx +118 -0
- package/src/components/EditorGrid.tsx +368 -0
- package/src/components/EditorGrid.zoom.test.tsx +68 -0
- package/src/components/FileContentsView.stories.tsx +16 -0
- package/src/components/FileContentsView.test.tsx +29 -0
- package/src/components/FileContentsView.tsx +98 -0
- package/src/components/KeybindsDialog.stories.tsx +31 -0
- package/src/components/KeybindsDialog.tsx +103 -0
- package/src/components/MenuBar.stories.tsx +28 -0
- package/src/components/MenuBar.tsx +44 -0
- package/src/components/OverlayScrollbar.stories.tsx +33 -0
- package/src/components/OverlayScrollbar.test.tsx +139 -0
- package/src/components/OverlayScrollbar.tsx +129 -0
- package/src/components/PaneGrid.stories.tsx +84 -0
- package/src/components/PaneGrid.tsx +128 -0
- package/src/components/PanelColumn.stories.tsx +25 -0
- package/src/components/PanelColumn.tsx +95 -0
- package/src/components/ProjectTreeView.stories.tsx +25 -0
- package/src/components/ProjectTreeView.test.tsx +32 -0
- package/src/components/ProjectTreeView.tsx +83 -0
- package/src/components/PromptDialog.stories.tsx +36 -0
- package/src/components/PromptDialog.tsx +87 -0
- package/src/components/PrototypeContentView.stories.tsx +19 -0
- package/src/components/PrototypeContentView.tsx +446 -0
- package/src/components/RootOverlay.stories.tsx +44 -0
- package/src/components/RootOverlay.tsx +35 -0
- package/src/components/ScrollArea.stories.tsx +20 -0
- package/src/components/ScrollArea.tsx +44 -0
- package/src/components/SettingsDialog.stories.tsx +117 -0
- package/src/components/SettingsDialog.tsx +195 -0
- package/src/components/SettingsRows.stories.tsx +18 -0
- package/src/components/SettingsRows.tsx +112 -0
- package/src/components/ShellHeader.stories.tsx +50 -0
- package/src/components/ShellHeader.tsx +137 -0
- package/src/components/ThreadFooter.stories.tsx +30 -0
- package/src/components/ThreadFooter.test.tsx +31 -0
- package/src/components/ThreadFooter.tsx +54 -0
- package/src/components/ThreadTree.stories.tsx +46 -0
- package/src/components/ThreadTree.tsx +219 -0
- package/src/components/ThreadView.selection.test.tsx +812 -0
- package/src/components/ThreadView.slash.test.tsx +158 -0
- package/src/components/ThreadView.stories.tsx +26 -0
- package/src/components/ThreadView.tsx +330 -0
- package/src/components/ThreadsSidebar.stories.tsx +36 -0
- package/src/components/ThreadsSidebar.tsx +71 -0
- package/src/components/Toast.stories.tsx +12 -0
- package/src/components/Toast.tsx +45 -0
- package/src/components/Tooltip.stories.tsx +26 -0
- package/src/components/Tooltip.tsx +72 -0
- package/src/components/TreePane.stories.tsx +106 -0
- package/src/components/TreePane.tsx +157 -0
- package/src/components/WalkWizard.stories.tsx +100 -0
- package/src/components/WalkWizard.tsx +152 -0
- package/src/components/WelcomePlayground.stories.tsx +12 -0
- package/src/components/WelcomePlayground.test.tsx +47 -0
- package/src/components/WelcomePlayground.tsx +140 -0
- package/src/components/__snapshots__/stories.test.tsx.snap +1845 -0
- package/src/components/agent-launcher.stories.tsx +26 -0
- package/src/components/agent-launcher.test.tsx +50 -0
- package/src/components/agent-launcher.tsx +149 -0
- package/src/components/diff-content-view-layout.test.ts +198 -0
- package/src/components/diff-content-view-layout.ts +84 -0
- package/src/components/editor-grid.test.ts +86 -0
- package/src/components/editor-grid.ts +138 -0
- package/src/components/fade-title.test.ts +49 -0
- package/src/components/fade-title.ts +61 -0
- package/src/components/file-tree.test.ts +60 -0
- package/src/components/file-tree.ts +112 -0
- package/src/components/pages/Workspace.stories.tsx +181 -0
- package/src/components/primitives/Button.stories.tsx +32 -0
- package/src/components/primitives/Button.tsx +52 -0
- package/src/components/primitives/Card.stories.tsx +25 -0
- package/src/components/primitives/Card.tsx +60 -0
- package/src/components/primitives/Dialog.stories.tsx +24 -0
- package/src/components/primitives/Dialog.tsx +66 -0
- package/src/components/primitives/IconButton.stories.tsx +36 -0
- package/src/components/primitives/IconButton.tsx +62 -0
- package/src/components/primitives/StatusBar.stories.tsx +11 -0
- package/src/components/primitives/StatusBar.tsx +23 -0
- package/src/components/primitives/Tabs.stories.tsx +29 -0
- package/src/components/primitives/Tabs.tsx +102 -0
- package/src/components/primitives/Toolbar.stories.tsx +17 -0
- package/src/components/primitives/Toolbar.tsx +14 -0
- package/src/components/primitives/Tree.stories.tsx +124 -0
- package/src/components/primitives/Tree.tsx +96 -0
- package/src/components/primitives/frame.test.ts +12 -0
- package/src/components/primitives/frame.ts +14 -0
- package/src/components/primitives/icons.ts +61 -0
- package/src/components/primitives/tree-model.test.ts +99 -0
- package/src/components/primitives/tree-model.ts +137 -0
- package/src/components/quick-actions-editor.stories.tsx +54 -0
- package/src/components/quick-actions-editor.test.tsx +49 -0
- package/src/components/quick-actions-editor.tsx +150 -0
- package/src/components/session-tree.test.ts +134 -0
- package/src/components/session-tree.ts +108 -0
- package/src/components/stories-app.tsx +184 -0
- package/src/components/stories.test.tsx +66 -0
- package/src/components/story-fixtures.ts +102 -0
- package/src/components/story-tree.test.ts +100 -0
- package/src/components/story.ts +142 -0
- package/src/components/syntax-highlight.ts +110 -0
- package/src/components/terminal-pane.test.tsx +40 -0
- package/src/components/terminal-pane.ts +199 -0
- package/src/components/theme-context.stories.tsx +46 -0
- package/src/components/theme-context.tsx +37 -0
- package/src/components/truncate-text.ts +7 -0
- package/src/config.test.ts +461 -31
- package/src/config.ts +389 -57
- package/src/diff-fold.test.ts +78 -0
- package/src/diff-fold.ts +126 -0
- package/src/diff-hunk-curate.test.ts +315 -0
- package/src/diff-hunk-curate.ts +153 -0
- package/src/diff-intraline.test.ts +165 -0
- package/src/diff-intraline.ts +146 -0
- package/src/diff-syntax.test.ts +112 -0
- package/src/diff-syntax.ts +179 -0
- package/src/discussions.ts +79 -0
- package/src/editor.test.ts +149 -0
- package/src/editor.ts +139 -9
- package/src/ghostty-terminal.test.ts +85 -0
- package/src/ghostty-terminal.ts +199 -0
- package/src/herdr.test.ts +9 -34
- package/src/herdr.ts +25 -21
- package/src/index.ts +11 -0
- package/src/integrations.test.ts +41 -0
- package/src/integrations.ts +32 -0
- package/src/intent-dispatch.test.ts +698 -0
- package/src/intent-dispatch.ts +700 -0
- package/src/key-bindings.test.ts +156 -0
- package/src/key-bindings.ts +465 -0
- package/src/keymap.test.ts +611 -0
- package/src/keymap.ts +410 -0
- package/src/kitty-image.test.ts +66 -0
- package/src/kitty-image.ts +138 -0
- package/src/mark-runs.ts +120 -0
- package/src/prototype-browser.test.ts +56 -0
- package/src/prototype-browser.ts +307 -0
- package/src/prototype-pixels.tsx +35 -0
- package/src/pty.test.ts +81 -0
- package/src/pty.ts +245 -0
- package/src/ready-signal.test.ts +85 -0
- package/src/ready-signal.ts +52 -0
- package/src/run.ts +30 -9
- package/src/serve.test.ts +24 -12
- package/src/serve.ts +20 -9
- package/src/share.ts +215 -0
- package/src/skills.test.ts +74 -0
- package/src/skills.ts +68 -0
- package/src/slash-palette.test.ts +54 -0
- package/src/slash-palette.ts +104 -0
- package/src/split-diff.test.ts +110 -0
- package/src/split-diff.ts +149 -0
- package/src/test-support.test.ts +264 -0
- package/src/test-support.ts +355 -0
- package/src/theme-presets.test.ts +51 -0
- package/src/theme-presets.ts +214 -0
- package/src/theme.ts +84 -19
- package/src/thread-chords.test.ts +170 -0
- package/src/thread-chords.ts +169 -0
- package/src/thread-controller.curate.test.ts +438 -0
- package/src/thread-controller.live-diff.test.ts +199 -0
- package/src/thread-controller.share.test.ts +472 -0
- package/src/thread-controller.tree.test.ts +255 -0
- package/src/thread-controller.ts +1509 -0
- package/src/thread-selection.test.ts +210 -0
- package/src/thread-selection.ts +243 -0
- package/src/tree-view.test.ts +128 -0
- package/src/tree-view.ts +148 -0
- package/src/use-annotation-surface.tsx +851 -0
- package/src/use-changes-workbench.ts +153 -0
- package/src/use-frame-measure.ts +38 -0
- package/src/use-settings-dialog.tsx +274 -0
- package/src/use-terminal-virtualizer.ts +233 -0
- package/src/version.ts +5 -0
- package/src/view-diff.test.ts +66 -9
- package/src/view-diff.ts +231 -23
- package/src/view-plan.regression.test.ts +60 -0
- package/src/view-plan.test.ts +406 -0
- package/src/view-plan.ts +570 -0
- package/src/walk.test.ts +121 -0
- package/src/walk.ts +74 -0
- package/src/syntax.test.ts +0 -41
- package/src/syntax.ts +0 -88
- package/src/view.test.ts +0 -157
- package/src/view.ts +0 -312
|
@@ -1,18 +1,23 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Observer mode (tier 2): the App with readOnly renders and navigates like the
|
|
3
|
-
* controller's, but every mutating verb answers "observer - read-only" and
|
|
4
|
-
* leaves daemon state untouched.
|
|
5
|
-
*/
|
|
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. */
|
|
6
2
|
|
|
7
3
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
8
4
|
import { mkdtempSync, rmSync } from "node:fs";
|
|
9
5
|
import { tmpdir } from "node:os";
|
|
10
6
|
import { join } from "node:path";
|
|
11
7
|
import React from "react";
|
|
12
|
-
import { testRender } from "@opentui/react/test-utils";
|
|
13
8
|
import { DaemonServer } from "@cueloop/daemon";
|
|
14
|
-
import { makeAnchor, parseBlocks, type
|
|
9
|
+
import { makeAnchor, parseBlocks, type Thread } from "@cueloop/schema";
|
|
15
10
|
import { App } from "./App";
|
|
11
|
+
import {
|
|
12
|
+
clickText,
|
|
13
|
+
dragText,
|
|
14
|
+
frameRow,
|
|
15
|
+
isolateUserConfig,
|
|
16
|
+
pressKey,
|
|
17
|
+
renderReadyApp,
|
|
18
|
+
typeText,
|
|
19
|
+
waitForText,
|
|
20
|
+
} from "./test-support";
|
|
16
21
|
|
|
17
22
|
const PLAN = `# Migration Plan
|
|
18
23
|
|
|
@@ -27,113 +32,164 @@ The daemon persists sessions to disk atomically.
|
|
|
27
32
|
`;
|
|
28
33
|
|
|
29
34
|
let home: string;
|
|
35
|
+
let restoreUserConfig: () => void;
|
|
30
36
|
let server: DaemonServer;
|
|
31
|
-
let session:
|
|
37
|
+
let session: Thread;
|
|
32
38
|
|
|
33
39
|
beforeEach(() => {
|
|
34
40
|
home = mkdtempSync(join(tmpdir(), "cueloop-observer-"));
|
|
41
|
+
restoreUserConfig = isolateUserConfig(home);
|
|
35
42
|
server = new DaemonServer({ home, idleExitMs: 0 });
|
|
36
43
|
server.start();
|
|
37
44
|
session = server.core.sessionCreate({
|
|
38
45
|
workspace: { repoRoot: "/repo", branch: "main" },
|
|
39
|
-
artifact: {
|
|
46
|
+
artifact: {
|
|
47
|
+
type: "plan",
|
|
48
|
+
content: PLAN,
|
|
49
|
+
meta: { title: "Migration Plan", planPath: "plan.md" },
|
|
50
|
+
},
|
|
40
51
|
});
|
|
41
52
|
});
|
|
42
53
|
afterEach(() => {
|
|
54
|
+
restoreUserConfig();
|
|
43
55
|
server.stop();
|
|
44
56
|
rmSync(home, { recursive: true, force: true });
|
|
45
57
|
});
|
|
46
58
|
|
|
47
59
|
async function renderObserver() {
|
|
48
|
-
const setup = await
|
|
60
|
+
const setup = await renderReadyApp(<App home={home} sessionId={session.id} readOnly />, {
|
|
49
61
|
width: 120,
|
|
50
62
|
height: 32,
|
|
51
63
|
});
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
}
|
|
56
|
-
await setup.renderOnce();
|
|
64
|
+
|
|
65
|
+
await waitForText(setup, "cueloop");
|
|
66
|
+
|
|
57
67
|
return setup;
|
|
58
68
|
}
|
|
59
69
|
|
|
60
70
|
type Setup = Awaited<ReturnType<typeof renderObserver>>;
|
|
61
71
|
|
|
62
|
-
|
|
63
|
-
if (k === "enter") setup.mockInput.pressKey("RETURN");
|
|
64
|
-
else await setup.mockInput.typeText(k);
|
|
65
|
-
await Bun.sleep(15);
|
|
66
|
-
await setup.renderOnce();
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/** Session state that any mutating verb would change. */
|
|
72
|
+
/** Session state that any mutating primitive would change. */
|
|
70
73
|
function snapshot() {
|
|
71
|
-
const
|
|
72
|
-
|
|
74
|
+
const stored = server.core.sessionGet(session.id);
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
annotations: stored.annotations.length,
|
|
78
|
+
workingCopy: stored.workingCopy,
|
|
79
|
+
status: stored.status,
|
|
80
|
+
};
|
|
73
81
|
}
|
|
74
82
|
|
|
75
83
|
describe("observer rendering", () => {
|
|
76
|
-
test("
|
|
84
|
+
test("the minimal header mirrors the thread name, with no observer badge", async () => {
|
|
85
|
+
// Arrange
|
|
77
86
|
const setup = await renderObserver();
|
|
87
|
+
|
|
88
|
+
// Assert - the header shows the thread title only; read-only is enforced by
|
|
89
|
+
// the blocked primitives below, not a header badge
|
|
78
90
|
const frame = setup.captureCharFrame();
|
|
79
|
-
|
|
80
|
-
expect(frame).toContain("
|
|
81
|
-
expect(frame).not.toContain("
|
|
91
|
+
|
|
92
|
+
expect(frame).toContain("Migration Plan");
|
|
93
|
+
expect(frame).not.toContain("· observer");
|
|
82
94
|
});
|
|
83
95
|
});
|
|
84
96
|
|
|
85
|
-
describe("observer
|
|
86
|
-
|
|
87
|
-
["
|
|
88
|
-
["
|
|
89
|
-
["
|
|
90
|
-
["
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
97
|
+
describe("observer primitives are blocked", () => {
|
|
98
|
+
const attempts: Array<[string, (setup: Setup) => Promise<void>]> = [
|
|
99
|
+
["comment (typing)", (setup) => typeText(setup, "c")],
|
|
100
|
+
["cut (option+x)", (setup) => pressKey(setup, "x", { meta: true })],
|
|
101
|
+
["edit (ctrl+e)", (setup) => pressKey(setup, "e", { ctrl: true })],
|
|
102
|
+
["submit (cmd+enter)", (setup) => pressKey(setup, "RETURN", { meta: true })],
|
|
103
|
+
];
|
|
104
|
+
|
|
105
|
+
for (const [primitive, attempt] of attempts) {
|
|
106
|
+
test(`${primitive} answers observer - read-only and mutates nothing`, async () => {
|
|
94
107
|
const setup = await renderObserver();
|
|
95
108
|
const before = snapshot();
|
|
96
|
-
|
|
97
|
-
await
|
|
98
|
-
await
|
|
99
|
-
await
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
expect(frame).toContain("
|
|
103
|
-
|
|
104
|
-
expect(frame).not.toContain("COMMENT ON");
|
|
105
|
-
expect(frame).not.toContain("SUGGEST REPLACEMENT");
|
|
106
|
-
expect(frame).not.toContain("verdict ←/→");
|
|
109
|
+
|
|
110
|
+
await clickText(setup, "daemon");
|
|
111
|
+
await attempt(setup);
|
|
112
|
+
const frame = await waitForText(setup, "observer - read-only");
|
|
113
|
+
|
|
114
|
+
// no draft card and no submit card ever appear
|
|
115
|
+
expect(frame).not.toContain("● c");
|
|
116
|
+
expect(frame).not.toContain("[Approve]");
|
|
107
117
|
expect(snapshot()).toEqual(before);
|
|
108
118
|
});
|
|
109
119
|
}
|
|
110
120
|
|
|
111
|
-
test("
|
|
121
|
+
test("typing over a marked span is blocked too", async () => {
|
|
122
|
+
// Arrange
|
|
112
123
|
const setup = await renderObserver();
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
await
|
|
116
|
-
await
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
124
|
+
|
|
125
|
+
// Act
|
|
126
|
+
await dragText(setup, "The daemon", "daemon persists", "daemon".length);
|
|
127
|
+
await typeText(setup, "c");
|
|
128
|
+
|
|
129
|
+
// Assert
|
|
130
|
+
const frame = await waitForText(setup, "observer - read-only");
|
|
131
|
+
|
|
132
|
+
expect(frame).not.toContain("● c");
|
|
121
133
|
expect(snapshot().annotations).toBe(0);
|
|
122
134
|
});
|
|
123
135
|
});
|
|
124
136
|
|
|
137
|
+
describe("a resolved review is read-only for its owner too", () => {
|
|
138
|
+
test("typing, deleting a card, and editing answer review submitted - read-only", async () => {
|
|
139
|
+
// Arrange: a comment exists and the verdict is in
|
|
140
|
+
server.core.sessionAnnotate(session.id, {
|
|
141
|
+
id: "a_done",
|
|
142
|
+
kind: "comment",
|
|
143
|
+
anchor: makeAnchor(parseBlocks(PLAN), 2, 0, 10),
|
|
144
|
+
body: "settled",
|
|
145
|
+
});
|
|
146
|
+
server.core.sessionResolve(session.id, "approve", "");
|
|
147
|
+
const setup = await renderReadyApp(<App home={home} sessionId={session.id} />, {
|
|
148
|
+
width: 120,
|
|
149
|
+
height: 32,
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
await waitForText(setup, "settled");
|
|
153
|
+
const before = snapshot();
|
|
154
|
+
|
|
155
|
+
// Act + Assert: a draft never opens
|
|
156
|
+
await clickText(setup, "daemon");
|
|
157
|
+
await typeText(setup, "c");
|
|
158
|
+
await waitForText(setup, "review submitted - read-only");
|
|
159
|
+
expect(setup.captureCharFrame()).not.toContain("● c");
|
|
160
|
+
|
|
161
|
+
// Act + Assert: the focused card cannot be deleted or edited
|
|
162
|
+
await pressKey(setup, "n", { meta: true });
|
|
163
|
+
await pressKey(setup, "BACKSPACE", { meta: true });
|
|
164
|
+
await pressKey(setup, "e", { meta: true });
|
|
165
|
+
await pressKey(setup, "x", { meta: true });
|
|
166
|
+
expect(snapshot()).toEqual(before);
|
|
167
|
+
expect(server.core.sessionGet(session.id).annotations).toHaveLength(1);
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
125
171
|
describe("observer navigation still works", () => {
|
|
126
|
-
test("
|
|
172
|
+
test("↓ moves the caret between blocks", async () => {
|
|
173
|
+
// Arrange
|
|
127
174
|
const setup = await renderObserver();
|
|
128
|
-
|
|
129
|
-
|
|
175
|
+
|
|
176
|
+
// Act
|
|
177
|
+
await pressKey(setup, "ARROW_DOWN");
|
|
178
|
+
await pressKey(setup, "ARROW_DOWN");
|
|
130
179
|
await setup.renderOnce();
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
180
|
+
|
|
181
|
+
// Assert: the caret cell is painted on the paragraph's row
|
|
182
|
+
const caretRow = setup
|
|
183
|
+
.captureSpans()
|
|
184
|
+
.lines.findIndex((line) =>
|
|
185
|
+
line.spans.some((span) => span.bg.toInts().slice(0, 3).join() === "86,91,104"),
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
expect(caretRow).toBe(frameRow(setup, "persists sessions"));
|
|
134
189
|
});
|
|
135
190
|
|
|
136
|
-
test("n focuses annotations made by the controller", async () => {
|
|
191
|
+
test("option+n focuses annotations made by the controller", async () => {
|
|
192
|
+
// Arrange
|
|
137
193
|
server.core.sessionAnnotate(session.id, {
|
|
138
194
|
id: "a_obs1",
|
|
139
195
|
kind: "comment",
|
|
@@ -141,8 +197,13 @@ describe("observer navigation still works", () => {
|
|
|
141
197
|
body: "From the controller.",
|
|
142
198
|
});
|
|
143
199
|
const setup = await renderObserver();
|
|
144
|
-
|
|
200
|
+
|
|
201
|
+
// Act - the observer navigates to the annotation without mutating anything
|
|
202
|
+
await pressKey(setup, "n", { meta: true });
|
|
145
203
|
await setup.renderOnce();
|
|
146
|
-
|
|
204
|
+
|
|
205
|
+
// Assert - the observer reads the controller's comment inline in the thread
|
|
206
|
+
await waitForText(setup, "From the controller.");
|
|
207
|
+
expect(snapshot().annotations).toBe(1);
|
|
147
208
|
});
|
|
148
209
|
});
|
|
@@ -1,14 +1,14 @@
|
|
|
1
|
-
/** Plan rendering
|
|
1
|
+
/** Plan rendering: code blocks verbatim + contained, spacing never collapses. */
|
|
2
2
|
|
|
3
3
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
4
4
|
import { mkdtempSync, rmSync } from "node:fs";
|
|
5
5
|
import { tmpdir } from "node:os";
|
|
6
6
|
import { join } from "node:path";
|
|
7
7
|
import React from "react";
|
|
8
|
-
import { testRender } from "@opentui/react/test-utils";
|
|
9
8
|
import { DaemonServer } from "@cueloop/daemon";
|
|
10
|
-
import type {
|
|
9
|
+
import type { Thread } from "@cueloop/schema";
|
|
11
10
|
import { App } from "./App";
|
|
11
|
+
import { isolateUserConfig, waitForText, renderReadyApp } from "./test-support";
|
|
12
12
|
|
|
13
13
|
const PLAN = `# Render Plan
|
|
14
14
|
|
|
@@ -29,11 +29,13 @@ Deploys block below the threshold.
|
|
|
29
29
|
`;
|
|
30
30
|
|
|
31
31
|
let home: string;
|
|
32
|
+
let restoreUserConfig: () => void;
|
|
32
33
|
let server: DaemonServer;
|
|
33
|
-
let session:
|
|
34
|
+
let session: Thread;
|
|
34
35
|
|
|
35
36
|
beforeEach(() => {
|
|
36
37
|
home = mkdtempSync(join(tmpdir(), "cueloop-render-"));
|
|
38
|
+
restoreUserConfig = isolateUserConfig(home);
|
|
37
39
|
server = new DaemonServer({ home, idleExitMs: 0 });
|
|
38
40
|
server.start();
|
|
39
41
|
session = server.core.sessionCreate({
|
|
@@ -42,67 +44,91 @@ beforeEach(() => {
|
|
|
42
44
|
});
|
|
43
45
|
});
|
|
44
46
|
afterEach(() => {
|
|
47
|
+
restoreUserConfig();
|
|
45
48
|
server.stop();
|
|
46
49
|
rmSync(home, { recursive: true, force: true });
|
|
47
50
|
});
|
|
48
51
|
|
|
49
52
|
async function frame(): Promise<string> {
|
|
50
|
-
const setup = await
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
//
|
|
57
|
-
await
|
|
58
|
-
|
|
53
|
+
const setup = await renderReadyApp(<App home={home} sessionId={session.id} />, {
|
|
54
|
+
width: 120,
|
|
55
|
+
height: 36,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
await waitForText(setup, "Render Plan");
|
|
59
|
+
// async highlights settle within the visual-idle wait
|
|
60
|
+
await setup.waitForVisualIdle();
|
|
61
|
+
|
|
59
62
|
return setup.captureCharFrame();
|
|
60
63
|
}
|
|
61
64
|
|
|
62
65
|
describe("code blocks", () => {
|
|
63
66
|
test("lines render verbatim: indentation preserved, no word-wrap", async () => {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
+
// Arrange
|
|
68
|
+
const rendered = await frame();
|
|
69
|
+
|
|
70
|
+
// Assert
|
|
71
|
+
expect(rendered).toContain(" const blocked = full < 0.6;");
|
|
72
|
+
expect(rendered).toContain(' return <text>{blocked ? "blocked" : "clear"}</text>;');
|
|
67
73
|
});
|
|
68
74
|
|
|
69
75
|
test("the block carries its language tag", async () => {
|
|
70
|
-
|
|
71
|
-
|
|
76
|
+
// Arrange
|
|
77
|
+
const rendered = await frame();
|
|
78
|
+
|
|
79
|
+
// Assert
|
|
80
|
+
expect(rendered).toContain("tsx");
|
|
72
81
|
});
|
|
73
82
|
});
|
|
74
83
|
|
|
75
84
|
describe("block spacing", () => {
|
|
85
|
+
// the review rail paints box edges on the right, so read the document
|
|
86
|
+
// column (everything left of the first rail glyph) before asserting spacing
|
|
87
|
+
const planColumn = (line: string): string => line.split(/[│╭╰]/)[0]!.trimEnd();
|
|
88
|
+
|
|
76
89
|
test("a code block never glues to the list item above it", async () => {
|
|
77
|
-
|
|
78
|
-
const
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
90
|
+
// Arrange
|
|
91
|
+
const lines = (await frame()).split("\n").map(planColumn);
|
|
92
|
+
const listItemLineIndex = lines.findIndex((line) => line.includes("wire the duck over MQTT"));
|
|
93
|
+
const codeHeader = lines.findIndex((line) => line.trim().startsWith("tsx"));
|
|
94
|
+
|
|
95
|
+
// Assert
|
|
96
|
+
expect(listItemLineIndex).toBeGreaterThan(-1);
|
|
97
|
+
expect(codeHeader).toBeGreaterThan(listItemLineIndex);
|
|
98
|
+
expect(lines.slice(listItemLineIndex + 1, codeHeader).some((line) => line === "")).toBe(true);
|
|
83
99
|
});
|
|
84
100
|
|
|
85
101
|
test("a heading never sits directly on the previous block", async () => {
|
|
86
|
-
|
|
87
|
-
const
|
|
102
|
+
// Arrange
|
|
103
|
+
const lines = (await frame()).split("\n").map(planColumn);
|
|
104
|
+
const gate = lines.findIndex((line) => line.trim() === "Gate");
|
|
105
|
+
|
|
106
|
+
// Assert
|
|
88
107
|
expect(gate).toBeGreaterThan(0);
|
|
89
108
|
expect(lines[gate - 1]).toBe("");
|
|
90
109
|
});
|
|
91
110
|
|
|
92
111
|
test("consecutive list items stay tight", async () => {
|
|
112
|
+
// Arrange
|
|
93
113
|
const listPlan = "# T\n\n- one\n- two\n";
|
|
94
114
|
const s2 = server.core.sessionCreate({
|
|
95
115
|
workspace: { repoRoot: "/repo", branch: "main" },
|
|
96
116
|
artifact: { type: "plan", content: listPlan, meta: {} },
|
|
97
117
|
});
|
|
98
|
-
const setup = await
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
118
|
+
const setup = await renderReadyApp(<App home={home} sessionId={s2.id} />, {
|
|
119
|
+
width: 120,
|
|
120
|
+
height: 30,
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
await waitForText(setup, "one");
|
|
124
|
+
|
|
125
|
+
// Assert
|
|
126
|
+
const lines = setup
|
|
127
|
+
.captureCharFrame()
|
|
128
|
+
.split("\n")
|
|
129
|
+
.map((line) => line.trimEnd());
|
|
130
|
+
const one = lines.findIndex((line) => line.includes("· one"));
|
|
131
|
+
|
|
132
|
+
expect(lines[one + 1]).toContain("· two");
|
|
107
133
|
});
|
|
108
134
|
});
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/** The send-message confirm: cmd+enter opens a centered overlay with a verdict selector, summary input, and word-buttons. Char-frame assertions over the real App and an in-process daemon, like App.test.tsx. */
|
|
2
|
+
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
4
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import React from "react";
|
|
8
|
+
import { testRender } from "@opentui/react/test-utils";
|
|
9
|
+
import { DaemonServer } from "@cueloop/daemon";
|
|
10
|
+
import { makeAnchor, parseBlocks, type Thread } from "@cueloop/schema";
|
|
11
|
+
import { App } from "./App";
|
|
12
|
+
import {
|
|
13
|
+
isolateUserConfig,
|
|
14
|
+
press,
|
|
15
|
+
pressKey,
|
|
16
|
+
settle,
|
|
17
|
+
waitForText,
|
|
18
|
+
waitForTextGone,
|
|
19
|
+
} from "./test-support";
|
|
20
|
+
|
|
21
|
+
const PLAN = `# Migration Plan
|
|
22
|
+
|
|
23
|
+
## Context
|
|
24
|
+
|
|
25
|
+
The daemon persists sessions to disk atomically.
|
|
26
|
+
|
|
27
|
+
## Steps
|
|
28
|
+
|
|
29
|
+
- move the store
|
|
30
|
+
- add recovery
|
|
31
|
+
`;
|
|
32
|
+
|
|
33
|
+
let home: string;
|
|
34
|
+
let restoreUserConfig: () => void;
|
|
35
|
+
let server: DaemonServer;
|
|
36
|
+
let session: Thread;
|
|
37
|
+
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
home = mkdtempSync(join(tmpdir(), "cueloop-confirm-"));
|
|
40
|
+
restoreUserConfig = isolateUserConfig(home);
|
|
41
|
+
server = new DaemonServer({ home, idleExitMs: 0 });
|
|
42
|
+
server.start();
|
|
43
|
+
session = server.core.sessionCreate({
|
|
44
|
+
workspace: { repoRoot: "/repo", branch: "main" },
|
|
45
|
+
artifact: {
|
|
46
|
+
type: "plan",
|
|
47
|
+
content: PLAN,
|
|
48
|
+
meta: { title: "Migration Plan", planPath: "plan.md" },
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
afterEach(() => {
|
|
53
|
+
restoreUserConfig();
|
|
54
|
+
server.stop();
|
|
55
|
+
rmSync(home, { recursive: true, force: true });
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
async function renderApp(options: { readOnly?: boolean } = {}) {
|
|
59
|
+
const setup = await testRender(
|
|
60
|
+
<App home={home} sessionId={session.id} readOnly={options.readOnly ?? false} />,
|
|
61
|
+
{
|
|
62
|
+
width: 120,
|
|
63
|
+
height: 32,
|
|
64
|
+
},
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
await waitForText(setup, "cueloop");
|
|
68
|
+
|
|
69
|
+
return setup;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Seed annotations directly through the daemon core (all on one block). */
|
|
73
|
+
function seedAnnotations(count: number): void {
|
|
74
|
+
const blocks = parseBlocks(PLAN);
|
|
75
|
+
|
|
76
|
+
for (let index = 1; index <= count; index++) {
|
|
77
|
+
server.core.sessionAnnotate(session.id, {
|
|
78
|
+
id: `a_confirm_${index}`,
|
|
79
|
+
kind: "comment",
|
|
80
|
+
anchor: makeAnchor(blocks, 2, 0, 10),
|
|
81
|
+
body: `note ${String(index).padStart(2, "0")}`,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
describe("send message confirm", () => {
|
|
87
|
+
test("cmd+enter opens the send-message confirm overlay", async () => {
|
|
88
|
+
// Arrange
|
|
89
|
+
const setup = await renderApp();
|
|
90
|
+
|
|
91
|
+
// Assert - the footer carries the send control before the overlay opens
|
|
92
|
+
expect(setup.captureCharFrame()).toContain("send message");
|
|
93
|
+
|
|
94
|
+
// Act
|
|
95
|
+
await pressKey(setup, "RETURN", { meta: true });
|
|
96
|
+
|
|
97
|
+
// Assert - the overlay: verdict selector and word-buttons
|
|
98
|
+
await waitForText(setup, "[Approve]"); // nothing pending: approve default
|
|
99
|
+
const frame = setup.captureCharFrame();
|
|
100
|
+
|
|
101
|
+
expect(frame).toContain("[Approve]");
|
|
102
|
+
expect(frame).toContain(" send message ");
|
|
103
|
+
expect(frame).toContain(" Cancel ");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("left/right cycles the verdict selector in the overlay", async () => {
|
|
107
|
+
// Arrange
|
|
108
|
+
const setup = await renderApp();
|
|
109
|
+
|
|
110
|
+
await pressKey(setup, "RETURN", { meta: true });
|
|
111
|
+
|
|
112
|
+
// Assert
|
|
113
|
+
await waitForText(setup, "[Approve]");
|
|
114
|
+
|
|
115
|
+
// Act
|
|
116
|
+
await press(setup, "right");
|
|
117
|
+
|
|
118
|
+
// Assert
|
|
119
|
+
await waitForText(setup, "[Changes]");
|
|
120
|
+
|
|
121
|
+
// Act
|
|
122
|
+
await press(setup, "right");
|
|
123
|
+
|
|
124
|
+
// Assert
|
|
125
|
+
await waitForText(setup, "[Comment]");
|
|
126
|
+
|
|
127
|
+
// Act
|
|
128
|
+
await press(setup, "left");
|
|
129
|
+
|
|
130
|
+
// Assert
|
|
131
|
+
await waitForText(setup, "[Changes]");
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("esc closes the overlay", async () => {
|
|
135
|
+
// Arrange
|
|
136
|
+
const setup = await renderApp();
|
|
137
|
+
|
|
138
|
+
await pressKey(setup, "RETURN", { meta: true });
|
|
139
|
+
|
|
140
|
+
// Assert
|
|
141
|
+
await waitForText(setup, "[Approve]");
|
|
142
|
+
|
|
143
|
+
// Act
|
|
144
|
+
await press(setup, "escape");
|
|
145
|
+
|
|
146
|
+
// Assert - a bare ESC settles after the parser's escape-sequence window
|
|
147
|
+
const frame = await waitForTextGone(setup, "[Approve]");
|
|
148
|
+
|
|
149
|
+
expect(frame).not.toContain(" Cancel ");
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("enter in the overlay resolves the session through the controller", async () => {
|
|
153
|
+
// Arrange
|
|
154
|
+
seedAnnotations(1);
|
|
155
|
+
const setup = await renderApp();
|
|
156
|
+
|
|
157
|
+
// Act
|
|
158
|
+
await pressKey(setup, "RETURN", { meta: true });
|
|
159
|
+
|
|
160
|
+
// Assert
|
|
161
|
+
await waitForText(setup, "[Changes]"); // pending items: request changes default
|
|
162
|
+
|
|
163
|
+
// Act
|
|
164
|
+
await setup.mockInput.typeText("Tighten the steps.");
|
|
165
|
+
await press(setup, "enter");
|
|
166
|
+
|
|
167
|
+
// Assert - the completion flow after submit is unchanged
|
|
168
|
+
await waitForText(setup, "feedback sent");
|
|
169
|
+
const stored = server.core.sessionGet(session.id);
|
|
170
|
+
|
|
171
|
+
expect(stored.status).toBe("resolved");
|
|
172
|
+
expect(stored.verdict!.kind).toBe("request_changes");
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("read-only observers cannot open the confirm overlay", async () => {
|
|
176
|
+
// Arrange
|
|
177
|
+
seedAnnotations(1);
|
|
178
|
+
const setup = await renderApp({ readOnly: true });
|
|
179
|
+
|
|
180
|
+
// Act
|
|
181
|
+
await pressKey(setup, "RETURN", { meta: true });
|
|
182
|
+
await settle(setup);
|
|
183
|
+
|
|
184
|
+
// Assert - no overlay, and the session stays pending
|
|
185
|
+
const frame = setup.captureCharFrame();
|
|
186
|
+
|
|
187
|
+
expect(frame).not.toContain("[Changes]");
|
|
188
|
+
expect(frame).not.toContain(" Cancel ");
|
|
189
|
+
expect(server.core.sessionGet(session.id).status).toBe("pending");
|
|
190
|
+
});
|
|
191
|
+
});
|