@cueloop/client 0.1.0-alpha.75 → 0.1.0-alpha.76
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 +5 -5
- package/src/App.focus.test.ts +147 -0
- package/src/App.regression.test.tsx +94 -0
- package/src/App.test.tsx +11 -8
- package/src/App.tsx +476 -220
- package/src/App.visual.test.tsx +57 -0
- package/src/App.workbench.test.tsx +41 -22
- package/src/app-screens.tsx +168 -123
- package/src/components/AppShell.brand.test.tsx +1 -1
- package/src/components/AppShell.tsx +14 -1
- package/src/components/ChangesColumn.tsx +40 -12
- package/src/components/ChangesFileTree.test.tsx +61 -0
- package/src/components/ConfirmDialog.tsx +8 -1
- package/src/components/DiffContentView.tsx +7 -1
- package/src/components/DiscussionMarkerRail.tsx +2 -2
- package/src/components/EditorGrid.tsx +84 -26
- package/src/components/GridTabContent.tsx +10 -1
- package/src/components/PanelColumn.tsx +3 -0
- package/src/components/ProjectTreeView.test.tsx +39 -12
- package/src/components/ProjectTreeView.tsx +38 -10
- package/src/components/PromptDialog.test.tsx +61 -0
- package/src/components/PromptDialog.tsx +16 -3
- package/src/components/RootOverlay.stories.tsx +1 -0
- package/src/components/RootOverlay.tsx +19 -6
- package/src/components/SettingsDialog.stories.tsx +3 -3
- package/src/components/SettingsDialog.tsx +3 -4
- package/src/components/ThreadFooter.test.tsx +12 -7
- package/src/components/ThreadFooter.tsx +18 -4
- package/src/components/ThreadTree.tsx +79 -36
- package/src/components/ThreadView.slash.test.tsx +36 -0
- package/src/components/ThreadView.tsx +7 -1
- package/src/components/WelcomePlayground.tsx +2 -0
- package/src/components/__snapshots__/stories.test.tsx.snap +60 -51
- package/src/components/menu-control.stories.tsx +35 -0
- package/src/components/menu-control.tsx +43 -0
- package/src/components/primitives/Dialog.test.tsx +37 -0
- package/src/components/primitives/Dialog.tsx +5 -0
- package/src/components/primitives/icons.ts +1 -1
- package/src/components/quick-actions-editor.stories.tsx +2 -0
- package/src/components/quick-actions-editor.test.tsx +35 -1
- package/src/components/quick-actions-editor.tsx +23 -22
- package/src/components/session-tree.test.ts +3 -3
- package/src/components/session-tree.ts +1 -1
- package/src/config.ts +1 -0
- package/src/diff-intraline.ts +26 -5
- package/src/perf/otlp-trace.test.ts +116 -0
- package/src/perf/otlp-trace.ts +130 -0
- package/src/perf/perf-report.ts +17 -0
- package/src/perf/perf-timings.test.ts +18 -0
- package/src/perf/perf-timings.ts +38 -0
- package/src/run.ts +22 -2
- package/src/thread-chords.test.ts +33 -1
- package/src/thread-chords.ts +114 -29
- package/src/thread-controller.ts +4 -3
- package/src/use-annotation-surface.tsx +22 -0
- package/src/use-frame-measure.ts +4 -1
- package/src/use-settings-dialog.tsx +9 -7
- package/src/visual/contrast.test.ts +35 -0
- package/src/visual/contrast.ts +33 -0
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.76",
|
|
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,10 +27,10 @@
|
|
|
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.
|
|
30
|
+
"@cueloop/daemon": "0.1.0-alpha.76",
|
|
31
|
+
"@cueloop/extension-api": "0.1.0-alpha.76",
|
|
32
|
+
"@cueloop/integration-obsidian": "0.1.0-alpha.76",
|
|
33
|
+
"@cueloop/schema": "0.1.0-alpha.76",
|
|
34
34
|
"@opentui/core": "^0.5.8",
|
|
35
35
|
"@opentui/keymap": "0.5.8",
|
|
36
36
|
"@opentui/react": "^0.5.8",
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
appLeaderHandled,
|
|
4
|
+
nextFocusPane,
|
|
5
|
+
reconciledFocus,
|
|
6
|
+
threadsNavHandled,
|
|
7
|
+
visiblePanes,
|
|
8
|
+
} from "./App";
|
|
9
|
+
|
|
10
|
+
describe("threads keyboard nav", () => {
|
|
11
|
+
const base = () => {
|
|
12
|
+
let cursor = 1;
|
|
13
|
+
let opened = 0;
|
|
14
|
+
|
|
15
|
+
return {
|
|
16
|
+
setInboxCursor: (update: (cursor: number) => number) => (cursor = update(cursor)),
|
|
17
|
+
openSession: () => (opened += 1),
|
|
18
|
+
read: () => ({
|
|
19
|
+
get cursor() {
|
|
20
|
+
return cursor;
|
|
21
|
+
},
|
|
22
|
+
get opened() {
|
|
23
|
+
return opened;
|
|
24
|
+
},
|
|
25
|
+
}),
|
|
26
|
+
};
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
test("moves the cursor and opens only when the Threads pane is quiet and focused", () => {
|
|
30
|
+
const spy = base();
|
|
31
|
+
const call = (name: string) =>
|
|
32
|
+
threadsNavHandled({
|
|
33
|
+
focusedPane: "threads",
|
|
34
|
+
quiet: true,
|
|
35
|
+
key: { name },
|
|
36
|
+
count: 3,
|
|
37
|
+
setInboxCursor: spy.setInboxCursor,
|
|
38
|
+
openSession: spy.openSession,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
expect(call("down")).toBe(true);
|
|
42
|
+
expect(spy.read().cursor).toBe(2);
|
|
43
|
+
expect(call("down")).toBe(true); // clamps at the last row
|
|
44
|
+
expect(spy.read().cursor).toBe(2);
|
|
45
|
+
expect(call("k")).toBe(true);
|
|
46
|
+
expect(spy.read().cursor).toBe(1);
|
|
47
|
+
expect(call("return")).toBe(true);
|
|
48
|
+
expect(spy.read().opened).toBe(1);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("defers when another pane is focused, keys are owned elsewhere, or the inbox is empty", () => {
|
|
52
|
+
const spy = base();
|
|
53
|
+
const params = {
|
|
54
|
+
quiet: true,
|
|
55
|
+
key: { name: "down" },
|
|
56
|
+
count: 3,
|
|
57
|
+
setInboxCursor: spy.setInboxCursor,
|
|
58
|
+
openSession: spy.openSession,
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
expect(threadsNavHandled({ ...params, focusedPane: "changes" })).toBe(false);
|
|
62
|
+
expect(threadsNavHandled({ ...params, focusedPane: "threads", quiet: false })).toBe(false);
|
|
63
|
+
expect(threadsNavHandled({ ...params, focusedPane: "threads", count: 0 })).toBe(false);
|
|
64
|
+
expect(spy.read().cursor).toBe(1);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe("pane focus cycle", () => {
|
|
69
|
+
test("visiblePanes lists only the open panes, in reading order", () => {
|
|
70
|
+
expect(visiblePanes(true, true, true, true)).toEqual([
|
|
71
|
+
"threads",
|
|
72
|
+
"thread",
|
|
73
|
+
"changes",
|
|
74
|
+
"project",
|
|
75
|
+
]);
|
|
76
|
+
expect(visiblePanes(true, false, true, true)).toEqual(["threads", "changes", "project"]);
|
|
77
|
+
expect(visiblePanes(false, false, true, false)).toEqual(["changes"]);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("nextFocusPane wraps forward and back over the visible panes", () => {
|
|
81
|
+
const panes = visiblePanes(true, false, true, true);
|
|
82
|
+
|
|
83
|
+
expect(nextFocusPane("threads", panes, false)).toBe("changes");
|
|
84
|
+
expect(nextFocusPane("project", panes, false)).toBe("threads");
|
|
85
|
+
expect(nextFocusPane("threads", panes, true)).toBe("project");
|
|
86
|
+
expect(nextFocusPane("changes", [], false)).toBe("changes");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("reconciledFocus falls back only when the focused pane is not navigable", () => {
|
|
90
|
+
expect(reconciledFocus(["threads", "changes"], "threads")).toBeNull();
|
|
91
|
+
expect(reconciledFocus(["threads", "changes"], "thread")).toBe("threads");
|
|
92
|
+
expect(reconciledFocus(["changes", "project"], "thread")).toBe("changes");
|
|
93
|
+
expect(reconciledFocus([], "thread")).toBeNull();
|
|
94
|
+
});
|
|
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
|
+
});
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/** Regressions from the focus/perf round: ctrl+q must quit from any keyboard state, and clicking a
|
|
2
|
+
* sidebar thread must give it the selected accent at once (the cursor follows the click). */
|
|
3
|
+
|
|
4
|
+
import { afterEach, beforeEach, expect, test } from "bun:test";
|
|
5
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import React from "react";
|
|
9
|
+
import { DaemonServer } from "@cueloop/daemon";
|
|
10
|
+
import { App } from "./App";
|
|
11
|
+
import {
|
|
12
|
+
clickText,
|
|
13
|
+
isolateUserConfig,
|
|
14
|
+
pressKey,
|
|
15
|
+
renderReadyApp,
|
|
16
|
+
waitForText,
|
|
17
|
+
} from "./test-support";
|
|
18
|
+
|
|
19
|
+
const ACCENT_DARK: [number, number, number] = [203, 166, 247];
|
|
20
|
+
|
|
21
|
+
let home: string;
|
|
22
|
+
let restoreUserConfig: () => void;
|
|
23
|
+
let server: DaemonServer;
|
|
24
|
+
|
|
25
|
+
function plan(title: string): void {
|
|
26
|
+
server.core.sessionCreate({
|
|
27
|
+
workspace: { repoRoot: "/repo", branch: "main" },
|
|
28
|
+
artifact: { type: "plan", content: `# ${title}\n`, meta: { title, planPath: "plan.md" } },
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
beforeEach(() => {
|
|
33
|
+
home = mkdtempSync(join(tmpdir(), "cueloop-regression-"));
|
|
34
|
+
restoreUserConfig = isolateUserConfig(home);
|
|
35
|
+
server = new DaemonServer({ home, idleExitMs: 0 });
|
|
36
|
+
server.start();
|
|
37
|
+
plan("First review");
|
|
38
|
+
plan("Second review");
|
|
39
|
+
});
|
|
40
|
+
afterEach(() => {
|
|
41
|
+
restoreUserConfig();
|
|
42
|
+
server.stop();
|
|
43
|
+
rmSync(home, { recursive: true, force: true });
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
/** The text of the sidebar title span currently painted in the accent colour, or undefined. */
|
|
47
|
+
function accentTitle(setup: Awaited<ReturnType<typeof renderReadyApp>>): string | undefined {
|
|
48
|
+
for (const line of setup.captureSpans().lines) {
|
|
49
|
+
for (const span of line.spans) {
|
|
50
|
+
const rgb = span.fg?.toInts();
|
|
51
|
+
if (
|
|
52
|
+
rgb &&
|
|
53
|
+
rgb[0] === ACCENT_DARK[0] &&
|
|
54
|
+
rgb[1] === ACCENT_DARK[1] &&
|
|
55
|
+
rgb[2] === ACCENT_DARK[2]
|
|
56
|
+
) {
|
|
57
|
+
const text = span.text.trim();
|
|
58
|
+
if (text.includes("review")) return text;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
test("ctrl+q quits even when a menu holds the keyboard", async () => {
|
|
67
|
+
let exitCode: number | undefined;
|
|
68
|
+
const setup = await renderReadyApp(<App home={home} onExit={(code) => (exitCode = code)} />, {
|
|
69
|
+
width: 120,
|
|
70
|
+
height: 32,
|
|
71
|
+
});
|
|
72
|
+
await waitForText(setup, "review");
|
|
73
|
+
|
|
74
|
+
// open the settings dialog from the top-left gear - it takes the keyboard
|
|
75
|
+
await setup.mockMouse.click(1, 0);
|
|
76
|
+
await waitForText(setup, "Keybinds");
|
|
77
|
+
|
|
78
|
+
await pressKey(setup, "q", { ctrl: true });
|
|
79
|
+
|
|
80
|
+
expect(exitCode).toBe(0);
|
|
81
|
+
setup.renderer.destroy();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("clicking a sidebar thread gives it the selected accent at once", async () => {
|
|
85
|
+
const setup = await renderReadyApp(<App home={home} />, { width: 120, height: 32 });
|
|
86
|
+
await waitForText(setup, "Second review");
|
|
87
|
+
|
|
88
|
+
// click whichever thread is not already the selected (accent) one
|
|
89
|
+
const target = accentTitle(setup)?.includes("Second") ? "First review" : "Second review";
|
|
90
|
+
await clickText(setup, target);
|
|
91
|
+
|
|
92
|
+
expect(accentTitle(setup)).toContain(target.split(" ")[0]!);
|
|
93
|
+
setup.renderer.destroy();
|
|
94
|
+
});
|
package/src/App.test.tsx
CHANGED
|
@@ -285,24 +285,27 @@ describe("no-thread shell", () => {
|
|
|
285
285
|
expect(frame).toContain("Other Plan");
|
|
286
286
|
});
|
|
287
287
|
|
|
288
|
-
test("the row kebab menu
|
|
288
|
+
test("the row kebab menu stars a thread into a Starred section", async () => {
|
|
289
289
|
// Arrange
|
|
290
290
|
const setup = await renderReadyApp(<App home={home} />, { width: 120, height: 32 });
|
|
291
291
|
|
|
292
292
|
await waitForText(setup, "Migration Plan");
|
|
293
293
|
|
|
294
|
-
// Act - hover the row to reveal its kebab, open the menu, and pick
|
|
294
|
+
// Act - hover the row to reveal its kebab, open the menu, and pick Star
|
|
295
295
|
const row = locateText(setup, "Migration Plan");
|
|
296
296
|
|
|
297
297
|
await setup.mockMouse.moveTo(row.column, row.row);
|
|
298
298
|
await waitForText(setup, "⋮");
|
|
299
299
|
await clickText(setup, "⋮");
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
await
|
|
305
|
-
|
|
300
|
+
// "star" alone collides with "Getting started" in the welcome pane, so target the menu via its
|
|
301
|
+
// unique "rename" row - the star action sits directly above it
|
|
302
|
+
await waitForText(setup, "rename");
|
|
303
|
+
const rename = locateText(setup, "rename");
|
|
304
|
+
await setup.mockMouse.click(rename.column, rename.row - 1);
|
|
305
|
+
|
|
306
|
+
// Assert - a Starred section now holds the thread
|
|
307
|
+
await waitForText(setup, "Starred");
|
|
308
|
+
expect(frameRow(setup, "Migration Plan")).toBeGreaterThan(frameRow(setup, "Starred"));
|
|
306
309
|
});
|
|
307
310
|
|
|
308
311
|
test("a bare launch shows the Welcome surface with the right region collapsed", async () => {
|