@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.
- package/package.json +5 -5
- package/src/App.autoclose.test.tsx +4 -3
- package/src/App.diff.test.tsx +5 -2
- package/src/App.export.test.tsx +5 -7
- package/src/App.plan-review.test.tsx +14 -17
- package/src/App.readonly.test.tsx +9 -7
- package/src/App.rendering.test.tsx +6 -2
- package/src/App.review-panel.test.tsx +19 -14
- package/src/App.submit-confirm.test.tsx +6 -7
- package/src/App.test.tsx +8 -5
- package/src/App.toast.test.tsx +68 -0
- package/src/App.tsx +154 -50
- package/src/App.walk.test.tsx +7 -4
- package/src/components/AnnotationCard.stories.tsx +1 -1
- package/src/components/AnnotationCard.tsx +18 -13
- package/src/components/CompletionOverlay.tsx +1 -1
- package/src/components/ConfirmCard.tsx +1 -0
- package/src/components/ConfirmDialog.stories.tsx +1 -1
- package/src/components/ConfirmDialog.tsx +1 -1
- package/src/components/DiffSheet.stories.tsx +21 -3
- package/src/components/DiffSheet.tsx +45 -19
- package/src/components/InboxList.stories.tsx +1 -1
- package/src/components/InboxList.tsx +3 -3
- package/src/components/KeybindsDialog.stories.tsx +31 -0
- package/src/components/KeybindsDialog.tsx +42 -0
- package/src/components/MenuBar.stories.tsx +33 -0
- package/src/components/MenuBar.tsx +58 -0
- package/src/components/PlanSheet.stories.tsx +2 -2
- package/src/components/PlanSheet.tsx +26 -75
- package/src/components/PromptDialog.stories.tsx +2 -2
- package/src/components/PromptDialog.tsx +1 -1
- package/src/components/ReviewPanel.stories.tsx +5 -17
- package/src/components/ReviewPanel.tsx +10 -29
- package/src/components/ReviewRail.stories.tsx +39 -0
- package/src/components/ReviewRail.tsx +62 -57
- package/src/components/SettingsDialog.stories.tsx +1 -1
- package/src/components/SettingsDialog.tsx +4 -3
- package/src/components/SettingsRows.stories.tsx +1 -1
- package/src/components/SettingsRows.tsx +1 -1
- package/src/components/Toast.stories.tsx +12 -0
- package/src/components/Toast.tsx +29 -0
- package/src/components/WalkWizard.stories.tsx +1 -1
- package/src/components/WalkWizard.tsx +1 -1
- package/src/components/__snapshots__/stories.test.tsx.snap +598 -566
- package/src/components/primitives/Button.stories.tsx +2 -2
- package/src/components/primitives/Dialog.stories.tsx +1 -1
- package/src/components/primitives/Dialog.tsx +7 -5
- package/src/components/primitives/Tabs.tsx +19 -38
- package/src/components/stories-app.tsx +1 -1
- package/src/components/stories.test.tsx +2 -0
- package/src/key-bindings.test.ts +4 -4
- package/src/key-bindings.ts +41 -2
- package/src/serve.test.ts +2 -2
- package/src/session-controller.share.test.ts +13 -0
- package/src/session-controller.ts +19 -1
- package/src/test-support.test.ts +55 -0
- package/src/test-support.ts +113 -23
- package/src/theme.ts +16 -16
- package/src/version.ts +5 -0
- package/src/components/ComposeBar.stories.tsx +0 -18
- 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
|
|
19
|
+
{"Submit review"}
|
|
20
20
|
</Button>
|
|
21
21
|
</Toolbar>
|
|
22
22
|
),
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Centered modal
|
|
3
|
-
*
|
|
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
|
-
|
|
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
|
|
3
|
-
* follows the ARIA tabs pattern: Tabs owns the
|
|
4
|
-
* (`selectedKey` + `onSelectionChange`), TabList renders
|
|
5
|
-
* declares one item. Selection stays controlled from outside so
|
|
6
|
-
*
|
|
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
|
|
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
|
-
<
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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.
|
|
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
|
|
package/src/key-bindings.test.ts
CHANGED
|
@@ -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 ·
|
|
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 ·
|
|
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 ·
|
|
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 ←/→ ·
|
|
108
|
+
expect(bindings({ overlay: "submit" }).statusHint("submit")).toBe("verdict ←/→ · enter submit · esc cancel");
|
|
109
109
|
});
|
|
110
110
|
|
|
111
111
|
test("walk hint", () => {
|
package/src/key-bindings.ts
CHANGED
|
@@ -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
|
|
89
|
-
expect(bytes).toContain("observer
|
|
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 ?
|
|
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
|
+
});
|
package/src/test-support.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
* the
|
|
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
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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(
|
|
67
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
/**
|
|
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
|
-
|
|
45
|
-
|
|
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
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
backdrop: "
|
|
63
|
+
cursorBackground: "#33262a",
|
|
64
|
+
markCommentBackground: "#3d2a2e",
|
|
65
|
+
markSuggestionBackground: "#20351f",
|
|
66
|
+
insertedForeground: "#62d96b",
|
|
67
|
+
deletedForeground: "#ff6b6b",
|
|
68
|
+
backdrop: "transparent",
|
|
69
69
|
}
|