@cueloop/client 0.1.0-alpha.5 → 0.1.0-alpha.50
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 +36 -31
- package/src/App.autoclose.test.tsx +137 -0
- package/src/App.diff.test.tsx +158 -22
- package/src/App.export.test.tsx +44 -37
- package/src/App.plan-review.test.tsx +672 -0
- package/src/App.prototype.test.tsx +155 -0
- package/src/App.readonly.test.tsx +60 -36
- package/src/App.rendering.test.tsx +132 -0
- package/src/App.review-panel.test.tsx +136 -0
- package/src/App.submit-confirm.test.tsx +218 -0
- package/src/App.test.tsx +128 -69
- package/src/App.toast.test.tsx +75 -0
- package/src/App.tsx +0 -0
- package/src/App.walk.test.tsx +289 -0
- package/src/__snapshots__/view-plan.regression.test.ts.snap +100 -0
- package/src/attribution.test.ts +31 -0
- package/src/attribution.ts +38 -0
- package/src/clipboard.test.ts +23 -0
- package/src/clipboard.ts +30 -0
- package/src/components/AnnotationCard.compose-rows.test.ts +49 -0
- package/src/components/AnnotationCard.stories.tsx +94 -0
- package/src/components/AnnotationCard.tsx +241 -0
- package/src/components/Breadcrumb.stories.tsx +19 -0
- package/src/components/Breadcrumb.tsx +44 -0
- package/src/components/CodeBlock.stories.tsx +42 -0
- package/src/components/CodeBlock.tsx +83 -0
- package/src/components/CompletionOverlay.stories.tsx +23 -0
- package/src/components/CompletionOverlay.tsx +59 -0
- package/src/components/ConfirmCard.stories.tsx +35 -0
- package/src/components/ConfirmCard.tsx +127 -0
- package/src/components/ConfirmDialog.stories.tsx +21 -0
- package/src/components/ConfirmDialog.tsx +64 -0
- package/src/components/DiffSheet.scroll.test.tsx +123 -0
- package/src/components/DiffSheet.stories.tsx +78 -0
- package/src/components/DiffSheet.syntax.test.tsx +62 -0
- package/src/components/DiffSheet.tsx +355 -0
- package/src/components/InboxList.stories.tsx +31 -0
- package/src/components/InboxList.tsx +62 -0
- package/src/components/KeybindsDialog.stories.tsx +31 -0
- package/src/components/KeybindsDialog.tsx +58 -0
- package/src/components/MarkerPopover.stories.tsx +58 -0
- package/src/components/MarkerPopover.tsx +108 -0
- package/src/components/MenuBar.stories.tsx +37 -0
- package/src/components/MenuBar.tsx +74 -0
- package/src/components/PlanSheet.stories.tsx +113 -0
- package/src/components/PlanSheet.tsx +456 -0
- package/src/components/PromptDialog.stories.tsx +36 -0
- package/src/components/PromptDialog.tsx +78 -0
- package/src/components/PrototypeSheet.stories.tsx +20 -0
- package/src/components/PrototypeSheet.tsx +358 -0
- package/src/components/ReviewPanel.stories.tsx +86 -0
- package/src/components/ReviewPanel.tsx +101 -0
- package/src/components/ReviewRail.stories.tsx +218 -0
- package/src/components/ReviewRail.tsx +317 -0
- package/src/components/SettingsDialog.stories.tsx +111 -0
- package/src/components/SettingsDialog.tsx +141 -0
- package/src/components/SettingsRows.stories.tsx +18 -0
- package/src/components/SettingsRows.tsx +108 -0
- package/src/components/Toast.stories.tsx +12 -0
- package/src/components/Toast.tsx +44 -0
- package/src/components/WalkWizard.stories.tsx +100 -0
- package/src/components/WalkWizard.tsx +152 -0
- package/src/components/__snapshots__/stories.test.tsx.snap +1857 -0
- package/src/components/agent-launcher.stories.tsx +26 -0
- package/src/components/agent-launcher.test.tsx +49 -0
- package/src/components/agent-launcher.tsx +147 -0
- package/src/components/diff-sheet-layout.test.ts +127 -0
- package/src/components/diff-sheet-layout.ts +167 -0
- package/src/components/primitives/Button.stories.tsx +32 -0
- package/src/components/primitives/Button.tsx +51 -0
- package/src/components/primitives/Card.stories.tsx +25 -0
- package/src/components/primitives/Card.tsx +59 -0
- package/src/components/primitives/Dialog.stories.tsx +24 -0
- package/src/components/primitives/Dialog.tsx +64 -0
- package/src/components/primitives/StatusBar.stories.tsx +11 -0
- package/src/components/primitives/StatusBar.tsx +22 -0
- package/src/components/primitives/Tabs.stories.tsx +29 -0
- package/src/components/primitives/Tabs.tsx +98 -0
- package/src/components/primitives/Toolbar.stories.tsx +17 -0
- package/src/components/primitives/Toolbar.tsx +14 -0
- package/src/components/primitives/frame.test.ts +12 -0
- package/src/components/primitives/frame.ts +14 -0
- package/src/components/quick-actions-editor.stories.tsx +54 -0
- package/src/components/quick-actions-editor.tsx +136 -0
- package/src/components/stories-app.tsx +123 -0
- package/src/components/stories.test.tsx +62 -0
- package/src/components/story-fixtures.ts +98 -0
- package/src/components/story.ts +69 -0
- package/src/components/syntax-highlight.ts +92 -0
- package/src/components/terminal-pane.test.tsx +40 -0
- package/src/components/terminal-pane.ts +181 -0
- package/src/components/theme-context.stories.tsx +46 -0
- package/src/components/theme-context.tsx +36 -0
- package/src/components/truncate-text.ts +6 -0
- package/src/config.test.ts +430 -31
- package/src/config.ts +304 -26
- package/src/diff-hunk-curate.test.ts +315 -0
- package/src/diff-hunk-curate.ts +233 -0
- package/src/diff-intraline.test.ts +164 -0
- package/src/diff-intraline.ts +132 -0
- package/src/diff-syntax.test.ts +96 -0
- package/src/diff-syntax.ts +154 -0
- package/src/editor.test.ts +143 -0
- package/src/editor.ts +123 -9
- package/src/ghostty-terminal.test.ts +82 -0
- package/src/ghostty-terminal.ts +168 -0
- package/src/herdr.test.ts +41 -0
- package/src/herdr.ts +27 -0
- package/src/index.ts +10 -0
- package/src/integrations.test.ts +41 -0
- package/src/integrations.ts +30 -0
- package/src/intent-dispatch.test.ts +493 -0
- package/src/intent-dispatch.ts +428 -0
- package/src/key-bindings.test.ts +152 -0
- package/src/key-bindings.ts +430 -0
- package/src/keymap.test.ts +622 -0
- package/src/keymap.ts +339 -0
- package/src/kitty-image.test.ts +20 -0
- package/src/kitty-image.ts +75 -0
- package/src/prototype-browser.test.ts +55 -0
- package/src/prototype-browser.ts +243 -0
- package/src/pty.test.ts +78 -0
- package/src/pty.ts +222 -0
- package/src/review-panel.test.ts +113 -0
- package/src/review-panel.ts +83 -0
- package/src/run.ts +9 -5
- package/src/serve.test.ts +24 -9
- package/src/serve.ts +7 -7
- package/src/session-controller.curate.test.ts +253 -0
- package/src/session-controller.share.test.ts +309 -0
- package/src/session-controller.ts +914 -0
- package/src/share.ts +113 -0
- package/src/test-support.test.ts +55 -0
- package/src/test-support.ts +187 -0
- package/src/theme-presets.ts +168 -0
- package/src/theme.ts +38 -17
- package/src/version.ts +5 -0
- package/src/view-diff.test.ts +19 -8
- package/src/view-diff.ts +34 -23
- package/src/view-plan.regression.test.ts +60 -0
- package/src/view-plan.test.ts +399 -0
- package/src/view-plan.ts +495 -0
- package/src/walk.test.ts +121 -0
- package/src/walk.ts +70 -0
- package/src/view.test.ts +0 -157
- package/src/view.ts +0 -0
package/src/share.ts
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Publish a review session as a share: pack it, stream it to the gateway's
|
|
3
|
+
* `share` user over SSH, and copy the returned `ssh p_…@host` line to the
|
|
4
|
+
* clipboard. The planner holds no key, so all this side does is upload; the
|
|
5
|
+
* gateway seals and stores. Used by both `cueloop share` and the in-TUI
|
|
6
|
+
* Share button, so the two share exactly the same path.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
DEFAULT_SHARE_HOST,
|
|
11
|
+
DEFAULT_SHARE_PORT,
|
|
12
|
+
SHARE_UPLOAD_USER,
|
|
13
|
+
packSessionBlob,
|
|
14
|
+
} from "@cueloop/daemon/share-blob";
|
|
15
|
+
import type { Annotation, ReviewSession } from "@cueloop/schema";
|
|
16
|
+
import { copyToClipboard } from "./clipboard";
|
|
17
|
+
|
|
18
|
+
export interface ShareTarget {
|
|
19
|
+
host?: string;
|
|
20
|
+
port?: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ShareResult {
|
|
24
|
+
/** The one line to paste: `ssh p_xxxxxxxx@host`. */
|
|
25
|
+
line: string;
|
|
26
|
+
/** Whether the line made it onto the system clipboard. */
|
|
27
|
+
copied: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The `p_…` share id inside a `ssh p_…@host` line, or undefined. */
|
|
31
|
+
export function shareIdFromLine(line: string): string | undefined {
|
|
32
|
+
return line.match(/^ssh (\S+)@/)?.[1];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Upload the session to the gateway and copy the resulting ssh line. */
|
|
36
|
+
export async function publishShare(
|
|
37
|
+
session: ReviewSession,
|
|
38
|
+
target: ShareTarget = {},
|
|
39
|
+
): Promise<ShareResult> {
|
|
40
|
+
const { stdout, stderr, code } = await runShareSsh(
|
|
41
|
+
"cueloop-share",
|
|
42
|
+
packSessionBlob(session),
|
|
43
|
+
target,
|
|
44
|
+
);
|
|
45
|
+
if (code !== 0)
|
|
46
|
+
throw new Error(`gateway upload failed: ${stderr.trim() || `ssh exited ${code}`}`);
|
|
47
|
+
const line = stdout.trim();
|
|
48
|
+
if (!line.startsWith("ssh ")) throw new Error(`unexpected gateway reply: ${line || "(empty)"}`);
|
|
49
|
+
const copied = await copyToClipboard(line);
|
|
50
|
+
return { line, copied };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Pull a share's current session back from the gateway. The gateway lets only
|
|
55
|
+
* the fingerprint that uploaded it through, so collaborator notes reach the
|
|
56
|
+
* planner without exposing the master key.
|
|
57
|
+
*/
|
|
58
|
+
export async function pullShare(shareId: string, target: ShareTarget = {}): Promise<ReviewSession> {
|
|
59
|
+
const { stdout, stderr, code } = await runShareSsh("cueloop-pull", Buffer.from(shareId), target);
|
|
60
|
+
if (code !== 0) throw new Error(`gateway pull failed: ${stderr.trim() || `ssh exited ${code}`}`);
|
|
61
|
+
return JSON.parse(stdout) as ReviewSession;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A share's collaborator notes: the ones a viewer authored (author stamped). */
|
|
65
|
+
export function collaboratorAnnotations(session: ReviewSession): Annotation[] {
|
|
66
|
+
return session.annotations.filter((annotation) => annotation.author);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Mirror the planner's own annotations up into the share, so collaborators see
|
|
71
|
+
* them on their next refresh. Owner-gated at the gateway; the notes stay
|
|
72
|
+
* unauthored (the planner's), unioned by id.
|
|
73
|
+
*/
|
|
74
|
+
export async function pushShare(
|
|
75
|
+
shareId: string,
|
|
76
|
+
annotations: Array<Omit<Annotation, "createdAt">>,
|
|
77
|
+
target: ShareTarget = {},
|
|
78
|
+
): Promise<void> {
|
|
79
|
+
const { stderr, code } = await runShareSsh(
|
|
80
|
+
"cueloop-push",
|
|
81
|
+
Buffer.from(JSON.stringify({ shareId, annotations })),
|
|
82
|
+
target,
|
|
83
|
+
);
|
|
84
|
+
if (code !== 0) throw new Error(`gateway push failed: ${stderr.trim() || `ssh exited ${code}`}`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Run one `share@host:port` ssh command with `stdin`; hand back its streams and exit code. */
|
|
88
|
+
async function runShareSsh(
|
|
89
|
+
command: string,
|
|
90
|
+
stdin: Buffer,
|
|
91
|
+
target: ShareTarget,
|
|
92
|
+
): Promise<{ stdout: string; stderr: string; code: number }> {
|
|
93
|
+
const host = target.host ?? DEFAULT_SHARE_HOST;
|
|
94
|
+
const port = target.port ?? DEFAULT_SHARE_PORT;
|
|
95
|
+
const proc = Bun.spawn(
|
|
96
|
+
[
|
|
97
|
+
"ssh",
|
|
98
|
+
"-p",
|
|
99
|
+
String(port),
|
|
100
|
+
"-o",
|
|
101
|
+
"StrictHostKeyChecking=accept-new",
|
|
102
|
+
`${SHARE_UPLOAD_USER}@${host}`,
|
|
103
|
+
command,
|
|
104
|
+
],
|
|
105
|
+
{ stdin, stdout: "pipe", stderr: "pipe" },
|
|
106
|
+
);
|
|
107
|
+
const [stdout, stderr, code] = await Promise.all([
|
|
108
|
+
new Response(proc.stdout).text(),
|
|
109
|
+
new Response(proc.stderr).text(),
|
|
110
|
+
proc.exited,
|
|
111
|
+
]);
|
|
112
|
+
return { stdout, stderr, code };
|
|
113
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { loadConfig } from "./config";
|
|
6
|
+
import { isolateUserConfig } from "./test-support";
|
|
7
|
+
|
|
8
|
+
describe("isolateUserConfig", () => {
|
|
9
|
+
test("loadConfig sees defaults while isolated and the env is restored after", () => {
|
|
10
|
+
// Arrange
|
|
11
|
+
const home = mkdtempSync(join(tmpdir(), "cueloop-isolate-"));
|
|
12
|
+
const userConfigPath = join(home, "config.toml");
|
|
13
|
+
writeFileSync(userConfigPath, '[ui]\nreview_state = "compact"\n');
|
|
14
|
+
const priorEnv = process.env.CUELOOP_CONFIG;
|
|
15
|
+
process.env.CUELOOP_CONFIG = userConfigPath;
|
|
16
|
+
|
|
17
|
+
try {
|
|
18
|
+
// Act
|
|
19
|
+
const restoreUserConfig = isolateUserConfig(home);
|
|
20
|
+
const isolated = loadConfig({ repoRoot: home });
|
|
21
|
+
restoreUserConfig();
|
|
22
|
+
const restored = loadConfig({ repoRoot: home });
|
|
23
|
+
|
|
24
|
+
// Assert
|
|
25
|
+
expect(isolated.ui.reviewState).toBe("expanded");
|
|
26
|
+
expect(process.env.CUELOOP_CONFIG).toBe(userConfigPath);
|
|
27
|
+
expect(restored.ui.reviewState).toBe("compact");
|
|
28
|
+
} finally {
|
|
29
|
+
if (priorEnv === undefined) delete process.env.CUELOOP_CONFIG;
|
|
30
|
+
else process.env.CUELOOP_CONFIG = priorEnv;
|
|
31
|
+
rmSync(home, { recursive: true, force: true });
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("restore removes the variable when nothing was set before", () => {
|
|
36
|
+
// Arrange
|
|
37
|
+
const home = mkdtempSync(join(tmpdir(), "cueloop-isolate-"));
|
|
38
|
+
const priorEnv = process.env.CUELOOP_CONFIG;
|
|
39
|
+
delete process.env.CUELOOP_CONFIG;
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
// Act
|
|
43
|
+
const restoreUserConfig = isolateUserConfig(home);
|
|
44
|
+
const pointedAt = process.env.CUELOOP_CONFIG ?? "";
|
|
45
|
+
restoreUserConfig();
|
|
46
|
+
|
|
47
|
+
// Assert
|
|
48
|
+
expect(pointedAt).toBe(join(home, "no-config.toml"));
|
|
49
|
+
expect(process.env.CUELOOP_CONFIG).toBeUndefined();
|
|
50
|
+
} finally {
|
|
51
|
+
if (priorEnv !== undefined) process.env.CUELOOP_CONFIG = priorEnv;
|
|
52
|
+
rmSync(home, { recursive: true, force: true });
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
});
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared driving helpers for the virtual-terminal App suites: key presses
|
|
3
|
+
* settle through a macrotask yield (input parser + React scheduler), one
|
|
4
|
+
* render pass, and the renderer's visual-idle wait - no fixed-duration
|
|
5
|
+
* sleeps. Waits on daemon round-trips go through waitForFrame/waitFor with
|
|
6
|
+
* generous pass budgets.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import type { TestRendererSetup } from "@opentui/core/testing";
|
|
11
|
+
|
|
12
|
+
/** Pass budget for waits that include daemon or subprocess round-trips. */
|
|
13
|
+
export const WAIT_PASSES = { maxPasses: 400 };
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Isolate the user config: point CUELOOP_CONFIG into the test home so
|
|
17
|
+
* loadConfig never reads the developer's real ~/.config/cueloop (a persisted
|
|
18
|
+
* review_state would change what char frames render locally while CI stays
|
|
19
|
+
* clean). The default file name does not exist, so loadConfig returns
|
|
20
|
+
* defaults; pass a file name for suites that write and assert a config of
|
|
21
|
+
* their own. Call in beforeEach; invoke the returned restore in afterEach.
|
|
22
|
+
*/
|
|
23
|
+
export function isolateUserConfig(home: string, fileName = "no-config.toml"): () => void {
|
|
24
|
+
const priorUserConfig = process.env.CUELOOP_CONFIG;
|
|
25
|
+
process.env.CUELOOP_CONFIG = join(home, fileName);
|
|
26
|
+
return () => {
|
|
27
|
+
if (priorUserConfig === undefined) delete process.env.CUELOOP_CONFIG;
|
|
28
|
+
else process.env.CUELOOP_CONFIG = priorUserConfig;
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The harness drives the real event loop by design; keep React's act warning off. */
|
|
33
|
+
export function allowEventLoopUpdates(): void {
|
|
34
|
+
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = false;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* One macrotask yield lets the input parser and the React scheduler run,
|
|
39
|
+
* then a render pass commits the result before the visual-idle wait.
|
|
40
|
+
*/
|
|
41
|
+
export async function settle(setup: TestRendererSetup): Promise<void> {
|
|
42
|
+
allowEventLoopUpdates();
|
|
43
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
44
|
+
await setup.renderOnce();
|
|
45
|
+
await setup.waitForVisualIdle();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Drive one key press: letters type as text; named keys use KeyCodes ids. */
|
|
49
|
+
export async function press(setup: TestRendererSetup, key: string): Promise<void> {
|
|
50
|
+
if (key === "enter") setup.mockInput.pressKey("RETURN");
|
|
51
|
+
else if (key === "escape") setup.mockInput.pressKey("ESCAPE");
|
|
52
|
+
else if (key === "backspace") setup.mockInput.pressKey("BACKSPACE");
|
|
53
|
+
else if (key === "left") setup.mockInput.pressKey("ARROW_LEFT");
|
|
54
|
+
else if (key === "right") setup.mockInput.pressKey("ARROW_RIGHT");
|
|
55
|
+
else return typeText(setup, key);
|
|
56
|
+
await settle(setup);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function typeText(setup: TestRendererSetup, text: string): Promise<void> {
|
|
60
|
+
await setup.mockInput.typeText(text);
|
|
61
|
+
await settle(setup);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Below the 60s per-test budget so a genuinely stuck wait throws its frame
|
|
65
|
+
// error (useful) before bun's bare "timed out" fires. Generous because a
|
|
66
|
+
// contended CI runner starves the render loop and daemon round-trips.
|
|
67
|
+
const WAIT_DEADLINE_MS = 45_000;
|
|
68
|
+
|
|
69
|
+
/** One macrotask turn: hands the event loop back so the in-process daemon can
|
|
70
|
+
* read its socket and apply the write before the next poll. Tight render-pass
|
|
71
|
+
* bursts starve that read on a contended CI runner - the round-trip then never
|
|
72
|
+
* lands and the wait hangs the whole budget. */
|
|
73
|
+
function yieldEventLoop(): Promise<void> {
|
|
74
|
+
return new Promise((resolve) => setTimeout(resolve, 2));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Log a still-waiting line at most this often, so only genuinely slow (i.e.
|
|
78
|
+
* flaking) waits are noisy; fast waits stay silent. */
|
|
79
|
+
const PROGRESS_LOG_MS = 4000;
|
|
80
|
+
|
|
81
|
+
/** The last render frame's tail, so a progress line shows what is on screen. */
|
|
82
|
+
function frameTail(frame: string, rows = 6): string {
|
|
83
|
+
return frame.split("\n").slice(-rows).join("\n");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Poll gently: one render pass, one frame check, one macrotask yield. No
|
|
88
|
+
* multi-pass bursts, so the daemon (same event loop) always gets a turn to
|
|
89
|
+
* process IO between checks. The deadline bounds the whole wait. Slow waits log
|
|
90
|
+
* progress (poll count + frame-change count) so a CI stall shows whether the
|
|
91
|
+
* render froze or the content simply never arrived.
|
|
92
|
+
*/
|
|
93
|
+
async function waitForFramePredicate(
|
|
94
|
+
setup: TestRendererSetup,
|
|
95
|
+
predicate: (frame: string) => boolean,
|
|
96
|
+
label: string,
|
|
97
|
+
): Promise<string> {
|
|
98
|
+
allowEventLoopUpdates();
|
|
99
|
+
const start = Date.now();
|
|
100
|
+
const deadline = start + WAIT_DEADLINE_MS;
|
|
101
|
+
let polls = 0;
|
|
102
|
+
let frameChanges = 0;
|
|
103
|
+
let lastFrame = "";
|
|
104
|
+
let lastLog = start;
|
|
105
|
+
for (;;) {
|
|
106
|
+
await setup.renderOnce();
|
|
107
|
+
const frame = setup.captureCharFrame();
|
|
108
|
+
polls++;
|
|
109
|
+
if (frame !== lastFrame) {
|
|
110
|
+
frameChanges++;
|
|
111
|
+
lastFrame = frame;
|
|
112
|
+
}
|
|
113
|
+
if (predicate(frame)) return frame;
|
|
114
|
+
const now = Date.now();
|
|
115
|
+
if (now > deadline) {
|
|
116
|
+
const frozen = frameChanges <= 1 ? " - render never advanced (frozen)" : "";
|
|
117
|
+
throw new Error(
|
|
118
|
+
`waitFor ${label} timed out after ${now - start}ms (${polls} polls, ${frameChanges} frame changes${frozen}).\nlast frame:\n${frame}`,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
if (now - lastLog >= PROGRESS_LOG_MS) {
|
|
122
|
+
lastLog = now;
|
|
123
|
+
console.error(
|
|
124
|
+
`[waitFor] still waiting for ${label} - ${now - start}ms, ${polls} polls, ${frameChanges} frame changes\n${frameTail(frame)}`,
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
await yieldEventLoop();
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Wait until a state predicate holds (daemon round-trips included). */
|
|
132
|
+
export async function waitForState(
|
|
133
|
+
setup: TestRendererSetup,
|
|
134
|
+
predicate: () => boolean,
|
|
135
|
+
label = "state",
|
|
136
|
+
): Promise<void> {
|
|
137
|
+
allowEventLoopUpdates();
|
|
138
|
+
const start = Date.now();
|
|
139
|
+
const deadline = start + WAIT_DEADLINE_MS;
|
|
140
|
+
let polls = 0;
|
|
141
|
+
let frameChanges = 0;
|
|
142
|
+
let lastFrame = "";
|
|
143
|
+
let lastLog = start;
|
|
144
|
+
for (;;) {
|
|
145
|
+
if (predicate()) return;
|
|
146
|
+
await setup.renderOnce();
|
|
147
|
+
const frame = setup.captureCharFrame();
|
|
148
|
+
polls++;
|
|
149
|
+
if (frame !== lastFrame) {
|
|
150
|
+
frameChanges++;
|
|
151
|
+
lastFrame = frame;
|
|
152
|
+
}
|
|
153
|
+
if (predicate()) return;
|
|
154
|
+
const now = Date.now();
|
|
155
|
+
if (now > deadline) {
|
|
156
|
+
const frozen = frameChanges <= 1 ? " - render never advanced (frozen)" : "";
|
|
157
|
+
throw new Error(
|
|
158
|
+
`waitForState ${label} timed out after ${now - start}ms (${polls} polls, ${frameChanges} frame changes${frozen}).\nlast frame:\n${frame}`,
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
if (now - lastLog >= PROGRESS_LOG_MS) {
|
|
162
|
+
lastLog = now;
|
|
163
|
+
console.error(
|
|
164
|
+
`[waitForState] still waiting for ${label} - ${now - start}ms, ${polls} polls, ${frameChanges} frame changes`,
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
await yieldEventLoop();
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Wait until the frame contains the needle (daemon round-trips included). */
|
|
172
|
+
export async function waitForText(setup: TestRendererSetup, needle: string): Promise<string> {
|
|
173
|
+
return waitForFramePredicate(
|
|
174
|
+
setup,
|
|
175
|
+
(frame) => frame.includes(needle),
|
|
176
|
+
`text ${JSON.stringify(needle)}`,
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Wait until the frame no longer contains the needle. */
|
|
181
|
+
export async function waitForTextGone(setup: TestRendererSetup, needle: string): Promise<string> {
|
|
182
|
+
return waitForFramePredicate(
|
|
183
|
+
setup,
|
|
184
|
+
(frame) => !frame.includes(needle),
|
|
185
|
+
`text-gone ${JSON.stringify(needle)}`,
|
|
186
|
+
);
|
|
187
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Named theme presets selectable from Settings and `[ui] theme`. The branded
|
|
3
|
+
* "cueloop" default keeps the transparent terminal-through look; each of the
|
|
4
|
+
* well-known palettes paints its own opaque background so it reads exactly like
|
|
5
|
+
* the published theme. Every preset fills the full Theme token contract, so a
|
|
6
|
+
* `[theme]` per-token override still layers cleanly on top of the chosen base.
|
|
7
|
+
*
|
|
8
|
+
* Each palette maps its first-party spec onto cueloop's semantic slots. Where a
|
|
9
|
+
* palette has no dedicated green (Rosé Pine), additions use its positive accent
|
|
10
|
+
* by that palette's own convention.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { DARK, type Theme } from "./theme";
|
|
14
|
+
|
|
15
|
+
/** Selectable theme name; the persisted `[ui] theme` value and cycle key. */
|
|
16
|
+
export type ThemeName =
|
|
17
|
+
| "cueloop"
|
|
18
|
+
| "rose-pine-moon"
|
|
19
|
+
| "catppuccin-mocha"
|
|
20
|
+
| "tokyo-night"
|
|
21
|
+
| "gruvbox-dark"
|
|
22
|
+
| "nord";
|
|
23
|
+
|
|
24
|
+
/** The branded default; the base every layered config starts from. */
|
|
25
|
+
export const DEFAULT_THEME_NAME: ThemeName = "cueloop";
|
|
26
|
+
|
|
27
|
+
/** Rosé Pine Moon - rosepinetheme.com. Foam stands in for additions (no green in the palette). */
|
|
28
|
+
const ROSE_PINE_MOON: Theme = {
|
|
29
|
+
background: "#232136",
|
|
30
|
+
panel: "#232136",
|
|
31
|
+
elevated: "#2a273f",
|
|
32
|
+
border: "#44415a",
|
|
33
|
+
text: "#e0def4",
|
|
34
|
+
textMuted: "#908caa",
|
|
35
|
+
textDim: "#6e6a86",
|
|
36
|
+
accent: "#ea9a97",
|
|
37
|
+
accentInk: "#232136",
|
|
38
|
+
green: "#9ccfd8",
|
|
39
|
+
red: "#eb6f92",
|
|
40
|
+
blue: "#3e8fb0",
|
|
41
|
+
cursorBackground: "#382a2f",
|
|
42
|
+
markCommentBackground: "#382a37",
|
|
43
|
+
insertedForeground: "#9ccfd8",
|
|
44
|
+
deletedForeground: "#eb6f92",
|
|
45
|
+
backdrop: "transparent",
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/** Catppuccin Mocha - catppuccin.com. Mauve is the signature accent. */
|
|
49
|
+
const CATPPUCCIN_MOCHA: Theme = {
|
|
50
|
+
background: "#1e1e2e",
|
|
51
|
+
panel: "#1e1e2e",
|
|
52
|
+
elevated: "#313244",
|
|
53
|
+
border: "#45475a",
|
|
54
|
+
text: "#cdd6f4",
|
|
55
|
+
textMuted: "#a6adc8",
|
|
56
|
+
textDim: "#6c7086",
|
|
57
|
+
accent: "#cba6f7",
|
|
58
|
+
accentInk: "#1e1e2e",
|
|
59
|
+
green: "#a6e3a1",
|
|
60
|
+
red: "#f38ba8",
|
|
61
|
+
blue: "#89b4fa",
|
|
62
|
+
cursorBackground: "#2e2740",
|
|
63
|
+
markCommentBackground: "#33222b",
|
|
64
|
+
insertedForeground: "#a6e3a1",
|
|
65
|
+
deletedForeground: "#f38ba8",
|
|
66
|
+
backdrop: "transparent",
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/** Tokyo Night - the folke "night" variant. Magenta is the signature accent. */
|
|
70
|
+
const TOKYO_NIGHT: Theme = {
|
|
71
|
+
background: "#1a1b26",
|
|
72
|
+
panel: "#1a1b26",
|
|
73
|
+
elevated: "#292e42",
|
|
74
|
+
border: "#3b4261",
|
|
75
|
+
text: "#c0caf5",
|
|
76
|
+
textMuted: "#a9b1d6",
|
|
77
|
+
textDim: "#565f89",
|
|
78
|
+
accent: "#bb9af7",
|
|
79
|
+
accentInk: "#1a1b26",
|
|
80
|
+
green: "#9ece6a",
|
|
81
|
+
red: "#f7768e",
|
|
82
|
+
blue: "#7aa2f7",
|
|
83
|
+
cursorBackground: "#2b2740",
|
|
84
|
+
markCommentBackground: "#2f2230",
|
|
85
|
+
insertedForeground: "#9ece6a",
|
|
86
|
+
deletedForeground: "#f7768e",
|
|
87
|
+
backdrop: "transparent",
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/** Gruvbox Dark - morhetz/gruvbox, medium contrast. Orange is the warm signature accent. */
|
|
91
|
+
const GRUVBOX_DARK: Theme = {
|
|
92
|
+
background: "#282828",
|
|
93
|
+
panel: "#282828",
|
|
94
|
+
elevated: "#3c3836",
|
|
95
|
+
border: "#504945",
|
|
96
|
+
text: "#ebdbb2",
|
|
97
|
+
textMuted: "#d5c4a1",
|
|
98
|
+
textDim: "#928374",
|
|
99
|
+
accent: "#fe8019",
|
|
100
|
+
accentInk: "#282828",
|
|
101
|
+
green: "#b8bb26",
|
|
102
|
+
red: "#fb4934",
|
|
103
|
+
blue: "#83a598",
|
|
104
|
+
cursorBackground: "#3a2f20",
|
|
105
|
+
markCommentBackground: "#3a2823",
|
|
106
|
+
insertedForeground: "#b8bb26",
|
|
107
|
+
deletedForeground: "#fb4934",
|
|
108
|
+
backdrop: "transparent",
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
/** Nord - nordtheme.com, the official 16-color spec. Frost cyan is the signature accent. */
|
|
112
|
+
const NORD: Theme = {
|
|
113
|
+
background: "#2e3440",
|
|
114
|
+
panel: "#2e3440",
|
|
115
|
+
elevated: "#3b4252",
|
|
116
|
+
border: "#434c5e",
|
|
117
|
+
text: "#eceff4",
|
|
118
|
+
textMuted: "#d8dee9",
|
|
119
|
+
textDim: "#4c566a",
|
|
120
|
+
accent: "#88c0d0",
|
|
121
|
+
accentInk: "#2e3440",
|
|
122
|
+
green: "#a3be8c",
|
|
123
|
+
red: "#bf616a",
|
|
124
|
+
blue: "#81a1c1",
|
|
125
|
+
cursorBackground: "#2a3540",
|
|
126
|
+
markCommentBackground: "#3a2f33",
|
|
127
|
+
insertedForeground: "#a3be8c",
|
|
128
|
+
deletedForeground: "#bf616a",
|
|
129
|
+
backdrop: "transparent",
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
/** All selectable themes by name; "cueloop" is the branded transparent default. */
|
|
133
|
+
export const THEME_PRESETS: Record<ThemeName, Theme> = {
|
|
134
|
+
cueloop: DARK,
|
|
135
|
+
"rose-pine-moon": ROSE_PINE_MOON,
|
|
136
|
+
"catppuccin-mocha": CATPPUCCIN_MOCHA,
|
|
137
|
+
"tokyo-night": TOKYO_NIGHT,
|
|
138
|
+
"gruvbox-dark": GRUVBOX_DARK,
|
|
139
|
+
nord: NORD,
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
/** Cycle order for the Settings theme row; the registry's insertion order, branded default first. */
|
|
143
|
+
export const THEME_NAMES: ThemeName[] = Object.keys(THEME_PRESETS) as ThemeName[];
|
|
144
|
+
|
|
145
|
+
/** Human labels for the Settings cycle row, keyed by the persisted name. */
|
|
146
|
+
export const THEME_LABELS: Record<ThemeName, string> = {
|
|
147
|
+
cueloop: "cueloop",
|
|
148
|
+
"rose-pine-moon": "Rosé Pine Moon",
|
|
149
|
+
"catppuccin-mocha": "Catppuccin Mocha",
|
|
150
|
+
"tokyo-night": "Tokyo Night",
|
|
151
|
+
"gruvbox-dark": "Gruvbox Dark",
|
|
152
|
+
nord: "Nord",
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
/** Resolve a persisted theme name to its token set; unknown names fall back to the branded default. */
|
|
156
|
+
export function themeForName(name: string): Theme {
|
|
157
|
+
return THEME_PRESETS[name as ThemeName] ?? DARK;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** A preset with the user's `[theme]` per-token overrides layered on top - the live-switch equivalent of config load. */
|
|
161
|
+
export function composeTheme(name: string, overrides: Partial<Theme>): Theme {
|
|
162
|
+
return { ...themeForName(name), ...overrides };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** True when the name is a known preset (guards a persisted `[ui] theme` value). */
|
|
166
|
+
export function isThemeName(name: string): name is ThemeName {
|
|
167
|
+
return name in THEME_PRESETS;
|
|
168
|
+
}
|
package/src/theme.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Semantic theme tokens
|
|
2
|
+
* Semantic theme tokens. Values carry over from the design-system
|
|
3
3
|
* prototypes (tokens.css). One built-in dark theme for now; the theme
|
|
4
4
|
* registry (palette-derived themes, terminal theme, user overrides) layers
|
|
5
5
|
* on top of these slots without touching consumers.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
export interface Theme {
|
|
9
|
-
|
|
9
|
+
background: string;
|
|
10
10
|
panel: string;
|
|
11
11
|
elevated: string;
|
|
12
12
|
border: string;
|
|
@@ -18,16 +18,37 @@ export interface Theme {
|
|
|
18
18
|
green: string;
|
|
19
19
|
red: string;
|
|
20
20
|
blue: string;
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
21
|
+
cursorBackground: string;
|
|
22
|
+
markCommentBackground: string;
|
|
23
|
+
insertedForeground: string;
|
|
24
|
+
deletedForeground: string;
|
|
25
|
+
/** Layer behind centered dialogs; transparent keeps the session visible. */
|
|
26
|
+
backdrop: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The dimmed variant behind the walk wizard: every reading color drops to
|
|
31
|
+
* the dim token so the file list stays legible context, not competition.
|
|
32
|
+
* Layout tokens (backgrounds, cursor) keep their values.
|
|
33
|
+
*/
|
|
34
|
+
export function dimmedTheme(theme: Theme): Theme {
|
|
35
|
+
return {
|
|
36
|
+
...theme,
|
|
37
|
+
text: theme.textDim,
|
|
38
|
+
textMuted: theme.textDim,
|
|
39
|
+
accent: theme.textDim,
|
|
40
|
+
green: theme.textDim,
|
|
41
|
+
red: theme.textDim,
|
|
42
|
+
blue: theme.textDim,
|
|
43
|
+
insertedForeground: theme.textDim,
|
|
44
|
+
deletedForeground: theme.textDim,
|
|
45
|
+
};
|
|
26
46
|
}
|
|
27
47
|
|
|
28
48
|
export const DARK: Theme = {
|
|
29
|
-
|
|
30
|
-
|
|
49
|
+
/* Unpainted so the terminal background shows through; override in [theme]. */
|
|
50
|
+
background: "transparent",
|
|
51
|
+
panel: "transparent",
|
|
31
52
|
elevated: "#23232f",
|
|
32
53
|
border: "#3a3a4a",
|
|
33
54
|
text: "#e4e6ec",
|
|
@@ -36,11 +57,11 @@ export const DARK: Theme = {
|
|
|
36
57
|
accent: "#f5a3a3",
|
|
37
58
|
accentInk: "#2a1416",
|
|
38
59
|
green: "#62d96b",
|
|
39
|
-
red: "#
|
|
40
|
-
blue: "#
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
}
|
|
60
|
+
red: "#f08080",
|
|
61
|
+
blue: "#a9c8f5",
|
|
62
|
+
cursorBackground: "#33262a",
|
|
63
|
+
markCommentBackground: "#3d2a2e",
|
|
64
|
+
insertedForeground: "#62d96b",
|
|
65
|
+
deletedForeground: "#ff6b6b",
|
|
66
|
+
backdrop: "transparent",
|
|
67
|
+
};
|
package/src/version.ts
ADDED
package/src/view-diff.test.ts
CHANGED
|
@@ -21,23 +21,29 @@ index 111..222 100644
|
|
|
21
21
|
|
|
22
22
|
describe("diffRows", () => {
|
|
23
23
|
test("flattens files, hunks, and signed lines with line numbers", () => {
|
|
24
|
+
// Act
|
|
24
25
|
const rows = diffRows(PATCH);
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
26
|
+
|
|
27
|
+
// Assert
|
|
28
|
+
expect(rows[0]).toMatchObject({ kind: "file", file: "src/store.ts" });
|
|
29
|
+
expect(rows[1]!.kind).toBe("hunk");
|
|
30
|
+
const del = rows.find((row) => row.kind === "del")!;
|
|
28
31
|
expect(del.text).toContain("private items = [];");
|
|
29
32
|
expect(del.oldLine).toBe(3);
|
|
30
|
-
const adds = rows.filter((
|
|
33
|
+
const adds = rows.filter((row) => row.kind === "add");
|
|
31
34
|
expect(adds[0]!.text).toContain("new Map()");
|
|
32
35
|
expect(adds[0]!.newLine).toBe(3);
|
|
33
36
|
expect(adds[1]!.newLine).toBe(4);
|
|
34
37
|
// two hunks
|
|
35
|
-
expect(rows.filter((
|
|
38
|
+
expect(rows.filter((row) => row.kind === "hunk").length).toBe(2);
|
|
36
39
|
});
|
|
37
40
|
|
|
38
41
|
test("context lines carry both line numbers", () => {
|
|
42
|
+
// Act
|
|
39
43
|
const rows = diffRows(PATCH);
|
|
40
|
-
|
|
44
|
+
|
|
45
|
+
// Assert
|
|
46
|
+
const ctx = rows.find((row) => row.kind === "ctx")!;
|
|
41
47
|
expect(ctx.oldLine).toBe(1);
|
|
42
48
|
expect(ctx.newLine).toBe(1);
|
|
43
49
|
});
|
|
@@ -45,9 +51,14 @@ describe("diffRows", () => {
|
|
|
45
51
|
|
|
46
52
|
describe("diffRowAnchor", () => {
|
|
47
53
|
test("quote is the line, neighbors are the context selectors", () => {
|
|
54
|
+
// Arrange
|
|
48
55
|
const rows = diffRows(PATCH);
|
|
49
|
-
const
|
|
50
|
-
|
|
56
|
+
const rowIndex = rows.findIndex((row) => row.text.includes("new Map()"));
|
|
57
|
+
|
|
58
|
+
// Act
|
|
59
|
+
const anchor = diffRowAnchor(rows, rowIndex);
|
|
60
|
+
|
|
61
|
+
// Assert
|
|
51
62
|
expect(anchor.quote).toContain("new Map()");
|
|
52
63
|
expect(anchor.prefix.length).toBeGreaterThan(0);
|
|
53
64
|
expect(anchor.suffix.length).toBeGreaterThan(0);
|