@cueloop/client 0.1.0-alpha.4 → 0.1.0-alpha.41

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. package/native/build.sh +56 -0
  2. package/native/darwin-arm64/libcueloopvt.dylib +0 -0
  3. package/native/src/shim.c +107 -0
  4. package/package.json +34 -24
  5. package/src/App.autoclose.test.tsx +137 -0
  6. package/src/App.diff.test.tsx +158 -22
  7. package/src/App.export.test.tsx +44 -37
  8. package/src/App.plan-review.test.tsx +632 -0
  9. package/src/App.readonly.test.tsx +60 -36
  10. package/src/App.rendering.test.tsx +132 -0
  11. package/src/App.review-panel.test.tsx +136 -0
  12. package/src/App.submit-confirm.test.tsx +220 -0
  13. package/src/App.test.tsx +128 -69
  14. package/src/App.toast.test.tsx +75 -0
  15. package/src/App.tsx +0 -0
  16. package/src/App.walk.test.tsx +288 -0
  17. package/src/attribution.test.ts +31 -0
  18. package/src/attribution.ts +38 -0
  19. package/src/clipboard.test.ts +23 -0
  20. package/src/clipboard.ts +30 -0
  21. package/src/components/AnnotationCard.compose-rows.test.ts +49 -0
  22. package/src/components/AnnotationCard.stories.tsx +94 -0
  23. package/src/components/AnnotationCard.tsx +228 -0
  24. package/src/components/Breadcrumb.stories.tsx +19 -0
  25. package/src/components/Breadcrumb.tsx +44 -0
  26. package/src/components/CodeBlock.stories.tsx +42 -0
  27. package/src/components/CodeBlock.tsx +83 -0
  28. package/src/components/CompletionOverlay.stories.tsx +23 -0
  29. package/src/components/CompletionOverlay.tsx +59 -0
  30. package/src/components/ConfirmCard.stories.tsx +56 -0
  31. package/src/components/ConfirmCard.tsx +135 -0
  32. package/src/components/ConfirmDialog.stories.tsx +21 -0
  33. package/src/components/ConfirmDialog.tsx +64 -0
  34. package/src/components/DiffSheet.scroll.test.tsx +123 -0
  35. package/src/components/DiffSheet.stories.tsx +78 -0
  36. package/src/components/DiffSheet.syntax.test.tsx +62 -0
  37. package/src/components/DiffSheet.tsx +355 -0
  38. package/src/components/InboxList.stories.tsx +31 -0
  39. package/src/components/InboxList.tsx +62 -0
  40. package/src/components/KeybindsDialog.stories.tsx +31 -0
  41. package/src/components/KeybindsDialog.tsx +58 -0
  42. package/src/components/MarkerPopover.stories.tsx +58 -0
  43. package/src/components/MarkerPopover.tsx +102 -0
  44. package/src/components/MenuBar.stories.tsx +37 -0
  45. package/src/components/MenuBar.tsx +74 -0
  46. package/src/components/PlanSheet.stories.tsx +113 -0
  47. package/src/components/PlanSheet.tsx +389 -0
  48. package/src/components/PromptDialog.stories.tsx +36 -0
  49. package/src/components/PromptDialog.tsx +78 -0
  50. package/src/components/ReviewPanel.stories.tsx +86 -0
  51. package/src/components/ReviewPanel.tsx +101 -0
  52. package/src/components/ReviewRail.stories.tsx +220 -0
  53. package/src/components/ReviewRail.tsx +324 -0
  54. package/src/components/SettingsDialog.stories.tsx +111 -0
  55. package/src/components/SettingsDialog.tsx +141 -0
  56. package/src/components/SettingsRows.stories.tsx +18 -0
  57. package/src/components/SettingsRows.tsx +108 -0
  58. package/src/components/Toast.stories.tsx +12 -0
  59. package/src/components/Toast.tsx +44 -0
  60. package/src/components/WalkWizard.stories.tsx +100 -0
  61. package/src/components/WalkWizard.tsx +152 -0
  62. package/src/components/__snapshots__/stories.test.tsx.snap +1829 -0
  63. package/src/components/agent-launcher.stories.tsx +26 -0
  64. package/src/components/agent-launcher.test.tsx +21 -0
  65. package/src/components/agent-launcher.tsx +180 -0
  66. package/src/components/diff-sheet-layout.test.ts +127 -0
  67. package/src/components/diff-sheet-layout.ts +167 -0
  68. package/src/components/primitives/Button.stories.tsx +32 -0
  69. package/src/components/primitives/Button.tsx +48 -0
  70. package/src/components/primitives/Card.stories.tsx +25 -0
  71. package/src/components/primitives/Card.tsx +59 -0
  72. package/src/components/primitives/Dialog.stories.tsx +24 -0
  73. package/src/components/primitives/Dialog.tsx +64 -0
  74. package/src/components/primitives/StatusBar.stories.tsx +11 -0
  75. package/src/components/primitives/StatusBar.tsx +22 -0
  76. package/src/components/primitives/Tabs.stories.tsx +29 -0
  77. package/src/components/primitives/Tabs.tsx +98 -0
  78. package/src/components/primitives/Toolbar.stories.tsx +17 -0
  79. package/src/components/primitives/Toolbar.tsx +14 -0
  80. package/src/components/primitives/frame.test.ts +12 -0
  81. package/src/components/primitives/frame.ts +14 -0
  82. package/src/components/quick-actions-editor.stories.tsx +54 -0
  83. package/src/components/quick-actions-editor.tsx +136 -0
  84. package/src/components/stories-app.tsx +123 -0
  85. package/src/components/stories.test.tsx +62 -0
  86. package/src/components/story-fixtures.ts +98 -0
  87. package/src/components/story.ts +69 -0
  88. package/src/components/syntax-highlight.ts +92 -0
  89. package/src/components/terminal-pane.test.tsx +40 -0
  90. package/src/components/terminal-pane.ts +174 -0
  91. package/src/components/theme-context.stories.tsx +46 -0
  92. package/src/components/theme-context.tsx +36 -0
  93. package/src/components/truncate-text.ts +6 -0
  94. package/src/config.test.ts +430 -31
  95. package/src/config.ts +304 -26
  96. package/src/diff-hunk-curate.test.ts +315 -0
  97. package/src/diff-hunk-curate.ts +233 -0
  98. package/src/diff-intraline.test.ts +164 -0
  99. package/src/diff-intraline.ts +132 -0
  100. package/src/diff-syntax.test.ts +96 -0
  101. package/src/diff-syntax.ts +154 -0
  102. package/src/editor.test.ts +143 -0
  103. package/src/editor.ts +123 -9
  104. package/src/ghostty-terminal.test.ts +45 -0
  105. package/src/ghostty-terminal.ts +168 -0
  106. package/src/herdr.test.ts +41 -0
  107. package/src/herdr.ts +27 -0
  108. package/src/index.ts +10 -0
  109. package/src/integrations.test.ts +41 -0
  110. package/src/integrations.ts +30 -0
  111. package/src/intent-dispatch.test.ts +492 -0
  112. package/src/intent-dispatch.ts +428 -0
  113. package/src/key-bindings.test.ts +152 -0
  114. package/src/key-bindings.ts +424 -0
  115. package/src/keymap.test.ts +622 -0
  116. package/src/keymap.ts +339 -0
  117. package/src/review-panel.test.ts +113 -0
  118. package/src/review-panel.ts +83 -0
  119. package/src/run.ts +9 -5
  120. package/src/serve.test.ts +24 -9
  121. package/src/serve.ts +7 -7
  122. package/src/session-controller.curate.test.ts +253 -0
  123. package/src/session-controller.share.test.ts +309 -0
  124. package/src/session-controller.ts +900 -0
  125. package/src/share.ts +113 -0
  126. package/src/test-support.test.ts +55 -0
  127. package/src/test-support.ts +187 -0
  128. package/src/theme-presets.ts +168 -0
  129. package/src/theme.ts +36 -15
  130. package/src/version.ts +5 -0
  131. package/src/view-diff.test.ts +19 -8
  132. package/src/view-diff.ts +34 -23
  133. package/src/view-plan.test.ts +294 -0
  134. package/src/view-plan.ts +408 -0
  135. package/src/walk.test.ts +121 -0
  136. package/src/walk.ts +70 -0
  137. package/src/view.test.ts +0 -157
  138. 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 (#22). Values carry over from the design-system
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
- bg: string;
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
- cursorBg: string;
22
- markCommentBg: string;
23
- markSuggestionBg: string;
24
- insFg: string;
25
- delFg: string;
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
- bg: "#1f1f29",
30
- panel: "#1a1a23",
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",
@@ -38,9 +59,9 @@ export const DARK: Theme = {
38
59
  green: "#62d96b",
39
60
  red: "#ff6b6b",
40
61
  blue: "#84a6e8",
41
- cursorBg: "#33262a",
42
- markCommentBg: "#3d2a2e",
43
- markSuggestionBg: "#20351f",
44
- insFg: "#62d96b",
45
- delFg: "#ff6b6b",
46
- }
62
+ cursorBackground: "#33262a",
63
+ markCommentBackground: "#3d2a2e",
64
+ insertedForeground: "#62d96b",
65
+ deletedForeground: "#ff6b6b",
66
+ backdrop: "transparent",
67
+ };
package/src/version.ts ADDED
@@ -0,0 +1,5 @@
1
+ /** The published client version, read from the package manifest. */
2
+
3
+ import manifest from "../package.json" with { type: "json" };
4
+
5
+ export const CLIENT_VERSION: string = manifest.version;
@@ -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
- expect(rows[0]).toMatchObject({ t: "file", file: "src/store.ts" });
26
- expect(rows[1]!.t).toBe("hunk");
27
- const del = rows.find((r) => r.t === "del")!;
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((r) => r.t === "add");
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((r) => r.t === "hunk").length).toBe(2);
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
- const ctx = rows.find((r) => r.t === "ctx")!;
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 idx = rows.findIndex((r) => r.text.includes("new Map()"));
50
- const anchor = diffRowAnchor(rows, idx);
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);