@cueloop/client 0.1.0-alpha.53 → 0.1.0-alpha.54

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cueloop/client",
3
- "version": "0.1.0-alpha.53",
3
+ "version": "0.1.0-alpha.54",
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": {
@@ -26,10 +26,10 @@
26
26
  "access": "public"
27
27
  },
28
28
  "dependencies": {
29
- "@cueloop/daemon": "0.1.0-alpha.53",
30
- "@cueloop/extension-api": "0.1.0-alpha.53",
31
- "@cueloop/integration-obsidian": "0.1.0-alpha.53",
32
- "@cueloop/schema": "0.1.0-alpha.53",
29
+ "@cueloop/daemon": "0.1.0-alpha.54",
30
+ "@cueloop/extension-api": "0.1.0-alpha.54",
31
+ "@cueloop/integration-obsidian": "0.1.0-alpha.54",
32
+ "@cueloop/schema": "0.1.0-alpha.54",
33
33
  "@opentui/core": "^0.5.8",
34
34
  "@opentui/keymap": "0.5.8",
35
35
  "@opentui/react": "^0.5.8",
@@ -0,0 +1,51 @@
1
+ /** The App renders in a light virtual terminal: passing appearance="light" threads the light branded theme through the provider without breaking the mount (the char frame is color-blind, so this covers propagation and wiring, not the pixel colors). */
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 type { ReviewSession } from "@cueloop/schema";
11
+ import { App } from "./App";
12
+ import { isolateUserConfig, settle, waitForText } from "./test-support";
13
+
14
+ const PLAN = "# Sync Plan\n\n## Steps\n\n- flush the queue\n";
15
+
16
+ let home: string;
17
+ let server: DaemonServer;
18
+ let session: ReviewSession;
19
+ let restoreUserConfig: () => void;
20
+
21
+ beforeEach(() => {
22
+ home = mkdtempSync(join(tmpdir(), "cueloop-appearance-"));
23
+ restoreUserConfig = isolateUserConfig(home);
24
+ server = new DaemonServer({ home, idleExitMs: 0 });
25
+ server.start();
26
+ session = server.core.sessionCreate({
27
+ workspace: { repoRoot: "/repo", branch: "main" },
28
+ artifact: { type: "plan", content: PLAN, meta: { title: "Sync Plan", planPath: "plan.md" } },
29
+ });
30
+ });
31
+ afterEach(() => {
32
+ restoreUserConfig();
33
+ server.stop();
34
+ rmSync(home, { recursive: true, force: true });
35
+ });
36
+
37
+ describe("appearance", () => {
38
+ test("renders the review on a light terminal", async () => {
39
+ // Arrange + Act
40
+ const setup = await testRender(<App home={home} sessionId={session.id} appearance="light" />, {
41
+ width: 120,
42
+ height: 32,
43
+ });
44
+
45
+ // Assert
46
+ await waitForText(setup, "cueloop");
47
+ await waitForText(setup, "Sync Plan");
48
+ await settle(setup);
49
+ expect(setup.captureCharFrame()).toContain("flush the queue");
50
+ });
51
+ });
package/src/App.tsx CHANGED
@@ -22,7 +22,7 @@ import type { Clock, MouseEvent } from "@opentui/core";
22
22
  import { type VerdictKind } from "@cueloop/schema";
23
23
  import { displayText, marksByDisplay, spanFromRange, type Mark } from "./view-plan";
24
24
  import { noteForFile, viewedCount } from "./walk";
25
- import { DARK, dimmedTheme } from "./theme";
25
+ import { dimmedTheme } from "./theme";
26
26
  import {
27
27
  DEFAULT_KEYS,
28
28
  DEFAULT_QUICK_ACTIONS,
@@ -39,6 +39,8 @@ import {
39
39
  DEFAULT_THEME_NAME,
40
40
  THEME_LABELS,
41
41
  THEME_NAMES,
42
+ themeForName,
43
+ type Appearance,
42
44
  type ThemeName,
43
45
  } from "./theme-presets";
44
46
  import type { Theme } from "./theme";
@@ -112,6 +114,12 @@ export interface AppProps {
112
114
  * name prompt so their notes attribute to a name, not a fingerprint.
113
115
  */
114
116
  selfAuthor?: string;
117
+ /**
118
+ * The terminal's background appearance (from an OSC query at startup). The
119
+ * branded transparent theme darkens its text on a light terminal so it is not
120
+ * light-on-light. Defaults to dark - the historical assumption.
121
+ */
122
+ appearance?: Appearance;
115
123
  }
116
124
 
117
125
  export function App({
@@ -123,6 +131,7 @@ export function App({
123
131
  openClient,
124
132
  role = "owner",
125
133
  selfAuthor,
134
+ appearance = "dark",
126
135
  }: AppProps): React.ReactNode {
127
136
  // Observer stays fully read-only; a collaborator writes annotations but not
128
137
  // the plan or a verdict. `observer` is what the controller and every write
@@ -200,7 +209,7 @@ export function App({
200
209
  // keymap from layered config; the loaded theme swaps the provider value
201
210
  const keysRef = useRef(DEFAULT_KEYS);
202
211
  const keyBindings = useMemo(() => new KeyBindings(DEFAULT_KEYS), []);
203
- const [theme, setTheme] = useState(DARK);
212
+ const [theme, setTheme] = useState(() => themeForName(DEFAULT_THEME_NAME, appearance));
204
213
  const [themeName, setThemeName] = useState<ThemeName>(DEFAULT_THEME_NAME);
205
214
  const [themeOverrides, setThemeOverrides] = useState<Partial<Theme>>({});
206
215
  const [authorNames, setAuthorNames] = useState<Record<string, string>>({});
@@ -211,7 +220,7 @@ export function App({
211
220
  const config = loadConfig({ repoRoot: session?.workspace.repoRoot });
212
221
  keysRef.current = config.keys;
213
222
  keyBindings.setKeys(config.keys);
214
- setTheme(config.theme);
223
+ setTheme(composeTheme(config.ui.theme, config.themeOverrides, appearance));
215
224
  setThemeName(config.ui.theme);
216
225
  setThemeOverrides(config.themeOverrides);
217
226
  setReviewMode(config.ui.reviewState);
@@ -221,7 +230,7 @@ export function App({
221
230
  setQuickActions(config.actions);
222
231
  setAutoClose(config.ui.autoClose);
223
232
  controller.applyConfig(config);
224
- }, [session?.workspace.repoRoot, controller, keyBindings]);
233
+ }, [session?.workspace.repoRoot, controller, keyBindings, appearance]);
225
234
  useEffect(
226
235
  () => () => {
227
236
  if (pulseTimer.current) clearTimeout(pulseTimer.current);
@@ -541,7 +550,7 @@ export function App({
541
550
  } else if (rowKey === "theme") {
542
551
  const next = THEME_NAMES[(THEME_NAMES.indexOf(themeName) + 1) % THEME_NAMES.length]!;
543
552
  setThemeName(next);
544
- setTheme(composeTheme(next, themeOverrides));
553
+ setTheme(composeTheme(next, themeOverrides, appearance));
545
554
  persistTheme(next);
546
555
  }
547
556
  };
package/src/run.ts CHANGED
@@ -10,15 +10,21 @@ export interface RunClientOptions {
10
10
  home?: string;
11
11
  }
12
12
 
13
+ /** OSC background query budget: brief so a terminal that never answers falls back to dark. */
14
+ const THEME_QUERY_TIMEOUT_MS = 200;
15
+
13
16
  export async function runClient(options: RunClientOptions): Promise<number> {
14
17
  // mouse movement reporting makes multiplexers forward drags to the app,
15
18
  // so the renderer's native selection is the drag driver
16
19
  const renderer = await createCliRenderer({ enableMouseMovement: true });
20
+ const appearance =
21
+ (await renderer.waitForThemeMode(THEME_QUERY_TIMEOUT_MS).catch(() => null)) ?? "dark";
17
22
  return new Promise<number>((resolve) => {
18
23
  createRoot(renderer).render(
19
24
  React.createElement(App, {
20
25
  home: options.home,
21
26
  sessionId: options.sessionId,
27
+ appearance,
22
28
  onExit: (code: number) => {
23
29
  renderer.destroy();
24
30
  resolve(code);
package/src/serve.ts CHANGED
@@ -22,6 +22,9 @@ import { createServer } from "@opentui/ssh";
22
22
  import { cueloopHome } from "@cueloop/daemon";
23
23
  import { App } from "./App";
24
24
 
25
+ /** OSC background query budget: brief so a terminal that never answers falls back to dark. */
26
+ const THEME_QUERY_TIMEOUT_MS = 200;
27
+
25
28
  export interface ServeOptions {
26
29
  /** TCP port for the SSH listener; 0 picks an ephemeral port. Default 2222. */
27
30
  port?: number;
@@ -56,13 +59,18 @@ export async function serveClient(options: ServeOptions = {}): Promise<ServeHand
56
59
  startupBanner: options.banner ?? true,
57
60
  idleTimeout: "2h",
58
61
  onError: options.onError,
59
- }).serve((session) => {
62
+ }).serve(async (session) => {
63
+ // the observer's terminal reports its own background, so a light-terminal
64
+ // watcher gets the readable variant too
65
+ const appearance =
66
+ (await session.renderer.waitForThemeMode(THEME_QUERY_TIMEOUT_MS).catch(() => null)) ?? "dark";
60
67
  const root = createRoot(session.renderer);
61
68
  root.render(
62
69
  React.createElement(App, {
63
70
  home,
64
71
  sessionId: options.sessionId,
65
72
  readOnly: true,
73
+ appearance,
66
74
  // q disconnects only this observer, never the server
67
75
  onExit: () => session.end(),
68
76
  }),
@@ -0,0 +1,51 @@
1
+ /** The branded transparent theme adapts to the terminal background - a light terminal gets dark text instead of light-on-light - while the opaque palette presets ignore appearance. */
2
+
3
+ import { describe, expect, test } from "bun:test";
4
+ import { DARK, LIGHT } from "./theme";
5
+ import { composeTheme, themeForName } from "./theme-presets";
6
+
7
+ describe("themeForName appearance", () => {
8
+ test("the branded cueloop theme picks the light variant on a light terminal", () => {
9
+ // Assert
10
+ expect(themeForName("cueloop", "dark")).toBe(DARK);
11
+ expect(themeForName("cueloop", "light")).toBe(LIGHT);
12
+ });
13
+
14
+ test("defaults to the dark variant when appearance is unspecified", () => {
15
+ // Assert
16
+ expect(themeForName("cueloop")).toBe(DARK);
17
+ });
18
+
19
+ test("an unknown name falls back to the branded theme, still appearance-aware", () => {
20
+ // Assert
21
+ expect(themeForName("nope", "light")).toBe(LIGHT);
22
+ expect(themeForName("nope", "dark")).toBe(DARK);
23
+ });
24
+
25
+ test("an explicit opaque preset ignores appearance (it paints its own background)", () => {
26
+ // Assert
27
+ expect(themeForName("nord", "light")).toBe(themeForName("nord", "dark"));
28
+ });
29
+ });
30
+
31
+ describe("the branded variants", () => {
32
+ test("both keep the transparent background but the light variant darkens its text", () => {
33
+ // Assert
34
+ expect(DARK.background).toBe("transparent");
35
+ expect(LIGHT.background).toBe("transparent");
36
+ expect(LIGHT.text).toBe("#1c1d24");
37
+ expect(LIGHT.text).not.toBe(DARK.text);
38
+ expect(LIGHT.elevated).not.toBe(DARK.elevated);
39
+ });
40
+ });
41
+
42
+ describe("composeTheme", () => {
43
+ test("resolves the appearance base, then layers per-token overrides on top", () => {
44
+ // Act
45
+ const themed = composeTheme("cueloop", { accent: "#123456" }, "light");
46
+
47
+ // Assert
48
+ expect(themed.text).toBe(LIGHT.text);
49
+ expect(themed.accent).toBe("#123456");
50
+ });
51
+ });
@@ -10,7 +10,15 @@
10
10
  * by that palette's own convention.
11
11
  */
12
12
 
13
- import { DARK, type Theme } from "./theme";
13
+ import { DARK, LIGHT, type Theme } from "./theme";
14
+
15
+ /** Terminal background appearance, reported by the terminal (OSC) or assumed dark. */
16
+ export type Appearance = "dark" | "light";
17
+
18
+ /** The branded transparent theme's variant for the detected terminal background. */
19
+ function brandedTheme(appearance: Appearance): Theme {
20
+ return appearance === "light" ? LIGHT : DARK;
21
+ }
14
22
 
15
23
  /** Selectable theme name; the persisted `[ui] theme` value and cycle key. */
16
24
  export type ThemeName =
@@ -152,14 +160,24 @@ export const THEME_LABELS: Record<ThemeName, string> = {
152
160
  nord: "Nord",
153
161
  };
154
162
 
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;
163
+ /**
164
+ * Resolve a persisted theme name to its token set. The branded "cueloop" theme
165
+ * (and any unknown name that falls back to it) adapts to the terminal
166
+ * background; the explicit palette presets paint their own opaque background, so
167
+ * they read the same on a light or dark terminal and ignore `appearance`.
168
+ */
169
+ export function themeForName(name: string, appearance: Appearance = "dark"): Theme {
170
+ if (name === "cueloop") return brandedTheme(appearance);
171
+ return THEME_PRESETS[name as ThemeName] ?? brandedTheme(appearance);
158
172
  }
159
173
 
160
174
  /** 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 };
175
+ export function composeTheme(
176
+ name: string,
177
+ overrides: Partial<Theme>,
178
+ appearance: Appearance = "dark",
179
+ ): Theme {
180
+ return { ...themeForName(name, appearance), ...overrides };
163
181
  }
164
182
 
165
183
  /** True when the name is a known preset (guards a persisted `[ui] theme` value). */
package/src/theme.ts CHANGED
@@ -65,3 +65,29 @@ export const DARK: Theme = {
65
65
  deletedForeground: "#ff6b6b",
66
66
  backdrop: "transparent",
67
67
  };
68
+
69
+ /**
70
+ * The branded transparent theme for a LIGHT terminal: same unpainted
71
+ * background, but every reading color darkens so text is legible on a white
72
+ * terminal instead of light-on-light. Chosen when the terminal reports a light
73
+ * background; the dark variant above is the default and the fallback.
74
+ */
75
+ export const LIGHT: Theme = {
76
+ background: "transparent",
77
+ panel: "transparent",
78
+ elevated: "#ececf2",
79
+ border: "#c7c7d2",
80
+ text: "#1c1d24",
81
+ textMuted: "#4b5162",
82
+ textDim: "#868c9c",
83
+ accent: "#b5495b",
84
+ accentInk: "#ffffff",
85
+ green: "#2f8a3e",
86
+ red: "#c0392b",
87
+ blue: "#2f6fb0",
88
+ cursorBackground: "#e6e2ea",
89
+ markCommentBackground: "#f2e2e8",
90
+ insertedForeground: "#2f8a3e",
91
+ deletedForeground: "#c0392b",
92
+ backdrop: "transparent",
93
+ };