@cueloop/client 0.1.0-alpha.31 → 0.1.0-alpha.33

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 (61) hide show
  1. package/package.json +5 -5
  2. package/src/App.autoclose.test.tsx +4 -3
  3. package/src/App.diff.test.tsx +5 -2
  4. package/src/App.export.test.tsx +5 -7
  5. package/src/App.plan-review.test.tsx +14 -17
  6. package/src/App.readonly.test.tsx +9 -7
  7. package/src/App.rendering.test.tsx +6 -2
  8. package/src/App.review-panel.test.tsx +19 -14
  9. package/src/App.submit-confirm.test.tsx +6 -7
  10. package/src/App.test.tsx +8 -5
  11. package/src/App.toast.test.tsx +68 -0
  12. package/src/App.tsx +154 -50
  13. package/src/App.walk.test.tsx +7 -4
  14. package/src/components/AnnotationCard.stories.tsx +1 -1
  15. package/src/components/AnnotationCard.tsx +18 -13
  16. package/src/components/CompletionOverlay.tsx +1 -1
  17. package/src/components/ConfirmCard.tsx +1 -0
  18. package/src/components/ConfirmDialog.stories.tsx +1 -1
  19. package/src/components/ConfirmDialog.tsx +1 -1
  20. package/src/components/DiffSheet.stories.tsx +21 -3
  21. package/src/components/DiffSheet.tsx +45 -19
  22. package/src/components/InboxList.stories.tsx +1 -1
  23. package/src/components/InboxList.tsx +3 -3
  24. package/src/components/KeybindsDialog.stories.tsx +31 -0
  25. package/src/components/KeybindsDialog.tsx +42 -0
  26. package/src/components/MenuBar.stories.tsx +33 -0
  27. package/src/components/MenuBar.tsx +58 -0
  28. package/src/components/PlanSheet.stories.tsx +2 -2
  29. package/src/components/PlanSheet.tsx +26 -75
  30. package/src/components/PromptDialog.stories.tsx +2 -2
  31. package/src/components/PromptDialog.tsx +1 -1
  32. package/src/components/ReviewPanel.stories.tsx +5 -17
  33. package/src/components/ReviewPanel.tsx +10 -29
  34. package/src/components/ReviewRail.stories.tsx +39 -0
  35. package/src/components/ReviewRail.tsx +62 -57
  36. package/src/components/SettingsDialog.stories.tsx +1 -1
  37. package/src/components/SettingsDialog.tsx +4 -3
  38. package/src/components/SettingsRows.stories.tsx +1 -1
  39. package/src/components/SettingsRows.tsx +1 -1
  40. package/src/components/Toast.stories.tsx +12 -0
  41. package/src/components/Toast.tsx +29 -0
  42. package/src/components/WalkWizard.stories.tsx +1 -1
  43. package/src/components/WalkWizard.tsx +1 -1
  44. package/src/components/__snapshots__/stories.test.tsx.snap +598 -566
  45. package/src/components/primitives/Button.stories.tsx +2 -2
  46. package/src/components/primitives/Dialog.stories.tsx +1 -1
  47. package/src/components/primitives/Dialog.tsx +7 -5
  48. package/src/components/primitives/Tabs.tsx +19 -38
  49. package/src/components/stories-app.tsx +1 -1
  50. package/src/components/stories.test.tsx +2 -0
  51. package/src/key-bindings.test.ts +4 -4
  52. package/src/key-bindings.ts +41 -2
  53. package/src/serve.test.ts +2 -2
  54. package/src/session-controller.share.test.ts +13 -0
  55. package/src/session-controller.ts +19 -1
  56. package/src/test-support.test.ts +55 -0
  57. package/src/test-support.ts +113 -23
  58. package/src/theme.ts +16 -16
  59. package/src/version.ts +5 -0
  60. package/src/components/ComposeBar.stories.tsx +0 -18
  61. package/src/components/ComposeBar.tsx +0 -54
@@ -10,13 +10,13 @@ export const Variants: Story = {
10
10
  render: () => (
11
11
  <Toolbar>
12
12
  <Button variant="solid" marginRight={2} onPress={() => {}}>
13
- {" Save ⏎ "}
13
+ {" Save "}
14
14
  </Button>
15
15
  <Button marginRight={2} onPress={() => {}}>
16
16
  {" Cancel esc "}
17
17
  </Button>
18
18
  <Button variant="accent-text" onPress={() => {}}>
19
- {"Submit review (2) ⏎"}
19
+ {"Submit review"}
20
20
  </Button>
21
21
  </Toolbar>
22
22
  ),
@@ -11,7 +11,7 @@ export const Open: Story = {
11
11
  <text>dialog body content</text>
12
12
  </Dialog>
13
13
  ),
14
- expectedColors: [DARK.accent, DARK.backdrop],
14
+ expectedColors: [DARK.accent],
15
15
  size: { width: 60, height: 16 },
16
16
  };
17
17
 
@@ -1,6 +1,7 @@
1
1
  /**
2
- * Centered modal over a dimmed backdrop. The dialog fills the terminal with
3
- * the backdrop token and centers a bordered panel; content composes inside.
2
+ * Centered modal: a full-screen layer (backdrop token, transparent by
3
+ * default) centers a bordered, unpainted panel over the session - the frame
4
+ * delineates the dialog, the content behind stays visible.
4
5
  * `isOpen` keeps the mount decision with the caller-visible prop vocabulary.
5
6
  */
6
7
 
@@ -14,11 +15,13 @@ export interface DialogProps {
14
15
  title?: string;
15
16
  width: number;
16
17
  height: number;
18
+ /** Solid panel fill for content-heavy dialogs; default transparent. */
19
+ background?: string;
17
20
  theme?: Theme;
18
21
  children: React.ReactNode;
19
22
  }
20
23
 
21
- export function Dialog({ isOpen, title, width, height, theme, children }: DialogProps): React.ReactNode {
24
+ export function Dialog({ isOpen, title, width, height, background, theme, children }: DialogProps): React.ReactNode {
22
25
  const tokens = useComponentTheme(theme);
23
26
  if (!isOpen) return null;
24
27
  return (
@@ -41,8 +44,7 @@ export function Dialog({ isOpen, title, width, height, theme, children }: Dialog
41
44
  border: true,
42
45
  borderStyle: FRAME_BORDER_STYLE,
43
46
  borderColor: tokens.accent,
44
- /* Floating surface: painted so dialog text never mixes with the page behind. */
45
- backgroundColor: tokens.elevated,
47
+ backgroundColor: background,
46
48
  flexDirection: "column",
47
49
  }}
48
50
  title={title}
@@ -1,16 +1,15 @@
1
1
  /**
2
- * Tabs / TabList / Tab over the native tab-select renderable. The composition
3
- * follows the ARIA tabs pattern: Tabs owns the controlled selection
4
- * (`selectedKey` + `onSelectionChange`), TabList renders the strip, Tab
5
- * declares one item. Selection stays controlled from outside so the keyboard
6
- * grammar (not the strip) remains the key owner; clicks map through the
7
- * native tab geometry.
2
+ * Tabs / TabList / Tab: a transparent, full-width rounded strip of clickable
3
+ * text tabs. The composition follows the ARIA tabs pattern: Tabs owns the
4
+ * controlled selection (`selectedKey` + `onSelectionChange`), TabList renders
5
+ * the strip, Tab declares one item. Selection stays controlled from outside so
6
+ * the keyboard grammar remains the key owner; each tab click sets the key.
8
7
  */
9
8
 
10
- import React, { createContext, useContext, useEffect, useRef } from "react";
11
- import type { MouseEvent, TabSelectRenderable } from "@opentui/core";
9
+ import React, { createContext, useContext } from "react";
12
10
  import type { Theme } from "../../theme";
13
11
  import { useComponentTheme } from "../theme-context";
12
+ import { FRAME_BORDER_STYLE } from "./frame";
14
13
 
15
14
  interface TabsContextValue {
16
15
  selectedKey: string;
@@ -62,36 +61,18 @@ export function TabList({ children }: TabListProps): React.ReactNode {
62
61
  items.push({ id: child.props.id, label: child.props.children });
63
62
  }
64
63
  });
65
- const tabWidth = Math.max(...items.map((item) => item.label.length)) + 2;
66
- const selectedIndex = Math.max(
67
- 0,
68
- items.findIndex((item) => item.id === tabs.selectedKey),
69
- );
70
- const stripRef = useRef<TabSelectRenderable | null>(null);
71
- // selection is controlled: mirror the selected key into the native strip
72
- useEffect(() => {
73
- stripRef.current?.setSelectedIndex(selectedIndex);
74
- }, [selectedIndex, items.length]);
75
- const onMouseUp = (event: MouseEvent): void => {
76
- const strip = stripRef.current;
77
- if (!strip) return;
78
- const index = Math.floor((event.x - strip.x) / tabWidth);
79
- const item = items[index];
80
- if (item) tabs.onSelectionChange(item.id);
81
- };
82
64
  return (
83
- <tab-select
84
- ref={stripRef}
85
- options={items.map((item) => ({ name: item.label, description: "", value: item.id }))}
86
- tabWidth={tabWidth}
87
- showUnderline={false}
88
- showDescription={false}
89
- showScrollArrows={false}
90
- textColor={tokens.textDim}
91
- selectedTextColor={tabs.selectedColor ?? tokens.accent}
92
- selectedBackgroundColor={tokens.elevated}
93
- onMouseUp={onMouseUp}
94
- style={{ height: 1, width: tabWidth * items.length }}
95
- />
65
+ <box
66
+ style={{ width: "100%", height: 3, border: true, borderStyle: FRAME_BORDER_STYLE, borderColor: tokens.text, flexDirection: "row", paddingLeft: 1 }}
67
+ >
68
+ {items.map((item) => {
69
+ const selected = item.id === tabs.selectedKey;
70
+ return (
71
+ <box key={item.id} style={{ marginRight: 2 }} onMouseUp={() => tabs.onSelectionChange(item.id)}>
72
+ <text fg={selected ? (tabs.selectedColor ?? tokens.accent) : tokens.textDim}>{item.label}</text>
73
+ </box>
74
+ );
75
+ })}
76
+ </box>
96
77
  );
97
78
  }
@@ -38,7 +38,7 @@ function StoriesApp({ stories, onExit }: { stories: LoadedStory[]; onExit: () =>
38
38
 
39
39
  return (
40
40
  <ThemeProvider theme={DARK}>
41
- <box style={{ flexDirection: "column", width: "100%", height: "100%", backgroundColor: DARK.bg }}>
41
+ <box style={{ flexDirection: "column", width: "100%", height: "100%", backgroundColor: DARK.background }}>
42
42
  <box style={{ height: 1, backgroundColor: DARK.panel, paddingLeft: 1 }}>
43
43
  <text fg={DARK.text}>
44
44
  <span fg={DARK.accent}>cueloop stories</span>
@@ -9,6 +9,7 @@ import { describe, expect, test } from "bun:test";
9
9
  import { testRender } from "@opentui/react/test-utils";
10
10
  import { RGBA } from "@opentui/core";
11
11
  import { componentFilesMissingStories, loadStories } from "./story";
12
+ import { allowEventLoopUpdates } from "../test-support";
12
13
 
13
14
  const stories = await loadStories();
14
15
 
@@ -35,6 +36,7 @@ describe("stories catalog", () => {
35
36
 
36
37
  // Act
37
38
  const setup = await testRender(story.render(), size);
39
+ allowEventLoopUpdates();
38
40
  await setup.waitForVisualIdle();
39
41
  const frame = setup.captureCharFrame();
40
42
 
@@ -86,12 +86,12 @@ describe("mode layers own their keys", () => {
86
86
  describe("getActiveKeys-generated status hints", () => {
87
87
  test("normal mode hint matches the locked string", () => {
88
88
  expect(bindings().statusHint("normal")).toBe(
89
- "j/k move · v span · drag selects · c comment · s suggest · x cut · e edit · n/p annotations · ⏎ submit · q quit",
89
+ "j/k move · v span · drag selects · c comment · s suggest · x cut · e edit · n/p annotations · enter submit · q quit",
90
90
  );
91
91
  });
92
92
 
93
93
  test("card-selected hint keeps the locked Cut label", () => {
94
- expect(bindings().statusHint("card")).toBe("card · e edit · x Cut · n/p cards · esc deselect · ⏎ submit");
94
+ expect(bindings().statusHint("card")).toBe("card · e edit · x Cut · n/p cards · esc deselect · enter submit");
95
95
  });
96
96
 
97
97
  test("span hint", () => {
@@ -101,11 +101,11 @@ describe("getActiveKeys-generated status hints", () => {
101
101
  });
102
102
 
103
103
  test("compose hint", () => {
104
- expect(bindings({ overlay: "compose" }).statusHint("compose")).toBe("typing · ⏎ save · esc cancel");
104
+ expect(bindings({ overlay: "compose" }).statusHint("compose")).toBe("typing · enter save · esc cancel");
105
105
  });
106
106
 
107
107
  test("submit hint", () => {
108
- expect(bindings({ overlay: "submit" }).statusHint("submit")).toBe("verdict ←/→ · ⏎ submit · esc cancel");
108
+ expect(bindings({ overlay: "submit" }).statusHint("submit")).toBe("verdict ←/→ · enter submit · esc cancel");
109
109
  });
110
110
 
111
111
  test("walk hint", () => {
@@ -26,6 +26,15 @@ export interface KeyLayerContext {
26
26
  /** Hint templates keyed by the same view states the status line shows. */
27
27
  export type HintMode = "normal" | "collaborator" | "card" | "span" | "compose" | "submit" | "walk" | "read-only";
28
28
 
29
+ export interface CheatsheetEntry {
30
+ keys: string;
31
+ label: string;
32
+ }
33
+ export interface CheatsheetSection {
34
+ title: string;
35
+ entries: CheatsheetEntry[];
36
+ }
37
+
29
38
  type HintEntry =
30
39
  | { text: string }
31
40
  | { commands: string[]; label: string; labelFirst?: boolean };
@@ -97,8 +106,8 @@ const HINT_TEMPLATES: Record<HintMode, HintEntry[]> = {
97
106
 
98
107
  /** Status-line glyphs for named keys. */
99
108
  const KEY_GLYPHS: Record<string, string> = {
100
- return: "⏎",
101
- enter: "⏎",
109
+ return: "enter",
110
+ enter: "enter",
102
111
  escape: "esc",
103
112
  left: "←",
104
113
  right: "→",
@@ -360,4 +369,34 @@ export class KeyBindings {
360
369
  }
361
370
  return fragments.join(" · ");
362
371
  }
372
+
373
+ /**
374
+ * The full keybinding cheatsheet, grouped by mode. Each section sets the
375
+ * layer context so getActiveKeys resolves that mode's real bindings, then
376
+ * restores it - a rebound key shows its live binding here too.
377
+ */
378
+ cheatsheet(): CheatsheetSection[] {
379
+ const saved = this.context;
380
+ const build = (context: KeyLayerContext, mode: HintMode, title: string): CheatsheetSection => {
381
+ this.context = context;
382
+ const entries: CheatsheetEntry[] = [];
383
+ for (const entry of HINT_TEMPLATES[mode]) {
384
+ if ("text" in entry || !entry.label) continue;
385
+ const keys = entry.commands
386
+ .map((command) => this.keyDisplayFor(command))
387
+ .filter((display): display is string => display !== null)
388
+ .join(" / ");
389
+ if (keys) entries.push({ keys, label: entry.label });
390
+ }
391
+ return { title, entries };
392
+ };
393
+ const sections = [
394
+ build({ overlay: "none", spanMode: false }, "normal", "Review"),
395
+ build({ overlay: "none", spanMode: true }, "span", "Selection"),
396
+ build({ overlay: "submit", spanMode: false }, "submit", "Submit"),
397
+ build({ overlay: "walk", spanMode: false }, "walk", "Walk"),
398
+ ];
399
+ this.context = saved;
400
+ return sections.filter((section) => section.entries.length > 0);
401
+ }
363
402
  }
package/src/serve.test.ts CHANGED
@@ -85,8 +85,8 @@ describe("cueloop serve", () => {
85
85
  // Assert
86
86
  expect(bytes).toContain("cueloop");
87
87
  expect(bytes).toContain("Rollout Plan");
88
- // observer chrome, not the controller's mutating hint bar
89
- expect(bytes).toContain("observer - read-only");
88
+ // observer chrome: the header badge marks the read-only session
89
+ expect(bytes).toContain("observer");
90
90
  });
91
91
 
92
92
  test("two observers can watch at once", async () => {
@@ -80,6 +80,19 @@ describe("share", () => {
80
80
  // Assert
81
81
  expect(client.sessionSetShareId).toHaveBeenCalledWith("ses_1", "p_abc123xy");
82
82
  });
83
+
84
+ test("surfaces the ssh line as a toast, not an inline status", async () => {
85
+ // Arrange
86
+ const { controller } = await connectedController(sessionFixture());
87
+
88
+ // Act
89
+ controller.share();
90
+ await tick();
91
+
92
+ // Assert
93
+ expect(controller.getSnapshot().toast).toEqual({ body: "ssh p_abc123xy@cueloop.dev", title: "share link copied" });
94
+ expect(controller.getSnapshot().status).not.toContain("ssh p_abc123xy@cueloop.dev");
95
+ });
83
96
  });
84
97
 
85
98
  describe("pullShared", () => {
@@ -50,10 +50,16 @@ export const DEFAULT_AUTO_CLOSE = 5;
50
50
  /** How often an open shared plan re-pulls collaborator notes (ADR 0005 stage 2). */
51
51
  export const SHARE_POLL_MS = 4000;
52
52
 
53
+ export interface ToastState {
54
+ title?: string;
55
+ body: string;
56
+ }
57
+
53
58
  export interface ControllerSnapshot {
54
59
  session: ReviewSession | null;
55
60
  inbox: ReviewSession[] | null;
56
61
  status: string;
62
+ toast: ToastState | null;
57
63
  error: string | null;
58
64
  completion: Completion;
59
65
  /**
@@ -96,6 +102,8 @@ export interface ReviewController {
96
102
  /** Loaded config parts the controller acts on: auto-close and exporters. */
97
103
  applyConfig(config: CueloopConfig): void;
98
104
  setStatus(message: string): void;
105
+ showToast(body: string, title?: string): void;
106
+ dismissToast(): void;
99
107
  /** Derived projections, cached per session identity. */
100
108
  display(): DisplayBlock[];
101
109
  rows(): DiffRow[];
@@ -161,6 +169,7 @@ class Controller implements ReviewController {
161
169
  session: null,
162
170
  inbox: null,
163
171
  status: "",
172
+ toast: null,
164
173
  error: null,
165
174
  completion: { phase: "idle" },
166
175
  editOrphanCount: 0,
@@ -248,6 +257,14 @@ class Controller implements ReviewController {
248
257
  this.update({ status: message });
249
258
  }
250
259
 
260
+ showToast(body: string, title?: string): void {
261
+ this.update({ toast: { body, title } });
262
+ }
263
+
264
+ dismissToast(): void {
265
+ if (this.snapshot.toast) this.update({ toast: null });
266
+ }
267
+
251
268
  // ── derived projections ─────────────────────
252
269
  private ensureDerived(): void {
253
270
  const session = this.snapshot.session;
@@ -515,7 +532,8 @@ class Controller implements ReviewController {
515
532
  // Stamp the id back so a later pull knows which share to collect from.
516
533
  const shareId = shareIdFromLine(line);
517
534
  if (shareId && this.client) await this.client.sessionSetShareId(session.id, shareId);
518
- this.setStatus(copied ? `✓ share link copied - ${line}` : line);
535
+ this.setStatus(copied ? "✓ share link copied" : "share link ready");
536
+ this.showToast(line, copied ? "share link copied" : "share link");
519
537
  })
520
538
  .catch((error: unknown) => this.setStatus(`share failed: ${error instanceof Error ? error.message : String(error)}`));
521
539
  }
@@ -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
+ });
@@ -6,16 +6,40 @@
6
6
  * generous pass budgets.
7
7
  */
8
8
 
9
+ import { join } from "node:path";
9
10
  import type { TestRendererSetup } from "@opentui/core/testing";
10
11
 
11
12
  /** Pass budget for waits that include daemon or subprocess round-trips. */
12
13
  export const WAIT_PASSES = { maxPasses: 400 };
13
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
+
14
37
  /**
15
38
  * One macrotask yield lets the input parser and the React scheduler run,
16
39
  * then a render pass commits the result before the visual-idle wait.
17
40
  */
18
41
  export async function settle(setup: TestRendererSetup): Promise<void> {
42
+ allowEventLoopUpdates();
19
43
  await new Promise((resolve) => setTimeout(resolve, 0));
20
44
  await setup.renderOnce();
21
45
  await setup.waitForVisualIdle();
@@ -37,51 +61,117 @@ export async function typeText(setup: TestRendererSetup, text: string): Promise<
37
61
  await settle(setup);
38
62
  }
39
63
 
40
- const WAIT_DEADLINE_MS = 30_000;
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
+ }
41
85
 
42
86
  /**
43
- * waitForFrame gives up as soon as the renderer is idle, but external timers
44
- * (the parser's bare-ESC window, daemon IO, spawned editors) settle on the
45
- * event loop while nothing is scheduled. Between attempts, one real
46
- * event-loop turn plus a render pass lets those land; the deadline bounds
47
- * the whole wait.
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.
48
92
  */
49
93
  async function waitForFramePredicate(
50
94
  setup: TestRendererSetup,
51
95
  predicate: (frame: string) => boolean,
96
+ label: string,
52
97
  ): Promise<string> {
53
- const deadline = Date.now() + WAIT_DEADLINE_MS;
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;
54
105
  for (;;) {
55
- try {
56
- return await setup.waitForFrame(predicate, { maxPasses: 50 });
57
- } catch (error) {
58
- if (Date.now() > deadline) throw error;
59
- await new Promise((resolve) => setTimeout(resolve, 1));
60
- await setup.renderOnce();
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
+ );
61
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();
62
128
  }
63
129
  }
64
130
 
65
131
  /** Wait until a state predicate holds (daemon round-trips included). */
66
- export async function waitForState(setup: TestRendererSetup, predicate: () => boolean): Promise<void> {
67
- const deadline = Date.now() + WAIT_DEADLINE_MS;
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;
68
144
  for (;;) {
69
- try {
70
- return await setup.waitFor(predicate, { maxPasses: 50 });
71
- } catch (error) {
72
- if (Date.now() > deadline) throw error;
73
- await new Promise((resolve) => setTimeout(resolve, 1));
74
- await setup.renderOnce();
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(`[waitForState] still waiting for ${label} — ${now - start}ms, ${polls} polls, ${frameChanges} frame changes`);
75
164
  }
165
+ await yieldEventLoop();
76
166
  }
77
167
  }
78
168
 
79
169
  /** Wait until the frame contains the needle (daemon round-trips included). */
80
170
  export async function waitForText(setup: TestRendererSetup, needle: string): Promise<string> {
81
- return waitForFramePredicate(setup, (frame) => frame.includes(needle));
171
+ return waitForFramePredicate(setup, (frame) => frame.includes(needle), `text ${JSON.stringify(needle)}`);
82
172
  }
83
173
 
84
174
  /** Wait until the frame no longer contains the needle. */
85
175
  export async function waitForTextGone(setup: TestRendererSetup, needle: string): Promise<string> {
86
- return waitForFramePredicate(setup, (frame) => !frame.includes(needle));
176
+ return waitForFramePredicate(setup, (frame) => !frame.includes(needle), `text-gone ${JSON.stringify(needle)}`);
87
177
  }
package/src/theme.ts CHANGED
@@ -6,7 +6,7 @@
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,12 +18,12 @@ 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;
26
- /** Dimmed layer painted behind centered dialogs. */
21
+ cursorBackground: string;
22
+ markCommentBackground: string;
23
+ markSuggestionBackground: string;
24
+ insertedForeground: string;
25
+ deletedForeground: string;
26
+ /** Layer behind centered dialogs; transparent keeps the session visible. */
27
27
  backdrop: string;
28
28
  }
29
29
 
@@ -41,14 +41,14 @@ export function dimmedTheme(theme: Theme): Theme {
41
41
  green: theme.textDim,
42
42
  red: theme.textDim,
43
43
  blue: theme.textDim,
44
- insFg: theme.textDim,
45
- delFg: theme.textDim,
44
+ insertedForeground: theme.textDim,
45
+ deletedForeground: theme.textDim,
46
46
  };
47
47
  }
48
48
 
49
49
  export const DARK: Theme = {
50
50
  /* Unpainted so the terminal background shows through; override in [theme]. */
51
- bg: "transparent",
51
+ background: "transparent",
52
52
  panel: "transparent",
53
53
  elevated: "#23232f",
54
54
  border: "#3a3a4a",
@@ -60,10 +60,10 @@ export const DARK: Theme = {
60
60
  green: "#62d96b",
61
61
  red: "#ff6b6b",
62
62
  blue: "#84a6e8",
63
- cursorBg: "#33262a",
64
- markCommentBg: "#3d2a2e",
65
- markSuggestionBg: "#20351f",
66
- insFg: "#62d96b",
67
- delFg: "#ff6b6b",
68
- backdrop: "#14141b",
63
+ cursorBackground: "#33262a",
64
+ markCommentBackground: "#3d2a2e",
65
+ markSuggestionBackground: "#20351f",
66
+ insertedForeground: "#62d96b",
67
+ deletedForeground: "#ff6b6b",
68
+ backdrop: "transparent",
69
69
  }
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;