@cueloop/client 0.1.0-alpha.6 → 0.1.0-alpha.61
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/native/build-pty.sh +51 -0
- package/native/build.sh +82 -0
- package/native/darwin-arm64/libcuelooppty.dylib +0 -0
- package/native/darwin-arm64/libcueloopvt.dylib +0 -0
- package/native/src/pty.zig +193 -0
- package/native/src/shim.zig +114 -0
- package/package.json +37 -33
- package/src/App.appearance.test.tsx +51 -0
- package/src/App.autoclose.test.tsx +73 -47
- package/src/App.diff.test.tsx +169 -22
- package/src/App.export.test.tsx +49 -37
- package/src/App.plan-review.test.tsx +714 -0
- package/src/App.prototype.test.tsx +194 -0
- package/src/App.readonly.test.tsx +69 -35
- package/src/App.rendering.test.tsx +59 -31
- package/src/App.review-panel.test.tsx +143 -0
- package/src/App.submit-confirm.test.tsx +229 -0
- package/src/App.test.tsx +139 -68
- package/src/App.toast.test.tsx +77 -0
- package/src/App.tsx +0 -0
- package/src/App.walk.test.tsx +299 -0
- package/src/__snapshots__/view-plan.regression.test.ts.snap +100 -0
- package/src/app-key-state.ts +61 -0
- package/src/app-screens.tsx +270 -0
- package/src/app-view-model.ts +307 -0
- package/src/attribution.test.ts +31 -0
- package/src/attribution.ts +42 -0
- package/src/clipboard.test.ts +23 -0
- package/src/clipboard.ts +33 -0
- package/src/components/AnnotationCard.compose-rows.test.ts +49 -0
- package/src/components/AnnotationCard.stories.tsx +94 -0
- package/src/components/AnnotationCard.tsx +250 -0
- package/src/components/Breadcrumb.stories.tsx +19 -0
- package/src/components/Breadcrumb.tsx +45 -0
- package/src/components/CodeBlock.stories.tsx +42 -0
- package/src/components/CodeBlock.tsx +84 -0
- package/src/components/CompletionOverlay.stories.tsx +23 -0
- package/src/components/CompletionOverlay.tsx +60 -0
- package/src/components/ConfirmCard.stories.tsx +35 -0
- package/src/components/ConfirmCard.tsx +129 -0
- package/src/components/ConfirmDialog.stories.tsx +21 -0
- package/src/components/ConfirmDialog.tsx +66 -0
- package/src/components/DiffSheet.scroll.test.tsx +135 -0
- package/src/components/DiffSheet.stories.tsx +78 -0
- package/src/components/DiffSheet.syntax.test.tsx +65 -0
- package/src/components/DiffSheet.tsx +374 -0
- package/src/components/InboxList.stories.tsx +31 -0
- package/src/components/InboxList.tsx +64 -0
- package/src/components/KeybindsDialog.stories.tsx +31 -0
- package/src/components/KeybindsDialog.tsx +59 -0
- package/src/components/MarkerPopover.stories.tsx +58 -0
- package/src/components/MarkerPopover.tsx +109 -0
- package/src/components/MenuBar.stories.tsx +37 -0
- package/src/components/MenuBar.tsx +75 -0
- package/src/components/PlanSheet.stories.tsx +113 -0
- package/src/components/PlanSheet.tsx +422 -0
- package/src/components/PromptDialog.stories.tsx +36 -0
- package/src/components/PromptDialog.tsx +80 -0
- package/src/components/PrototypeSheet.stories.tsx +20 -0
- package/src/components/PrototypeSheet.tsx +450 -0
- package/src/components/ReviewPanel.stories.tsx +86 -0
- package/src/components/ReviewPanel.tsx +103 -0
- package/src/components/ReviewRail.stories.tsx +218 -0
- package/src/components/ReviewRail.tsx +318 -0
- package/src/components/SettingsDialog.stories.tsx +111 -0
- package/src/components/SettingsDialog.tsx +147 -0
- package/src/components/SettingsRows.stories.tsx +18 -0
- package/src/components/SettingsRows.tsx +112 -0
- package/src/components/Toast.stories.tsx +12 -0
- package/src/components/Toast.tsx +45 -0
- package/src/components/WalkWizard.stories.tsx +100 -0
- package/src/components/WalkWizard.tsx +152 -0
- package/src/components/__snapshots__/stories.test.tsx.snap +1857 -0
- package/src/components/agent-launcher.stories.tsx +26 -0
- package/src/components/agent-launcher.test.tsx +50 -0
- package/src/components/agent-launcher.tsx +149 -0
- package/src/components/diff-sheet-layout.test.ts +128 -0
- package/src/components/diff-sheet-layout.ts +184 -0
- package/src/components/plan-sheet-run-style.ts +90 -0
- package/src/components/primitives/Button.stories.tsx +32 -0
- package/src/components/primitives/Button.tsx +52 -0
- package/src/components/primitives/Card.stories.tsx +25 -0
- package/src/components/primitives/Card.tsx +60 -0
- package/src/components/primitives/Dialog.stories.tsx +24 -0
- package/src/components/primitives/Dialog.tsx +66 -0
- package/src/components/primitives/StatusBar.stories.tsx +11 -0
- package/src/components/primitives/StatusBar.tsx +23 -0
- package/src/components/primitives/Tabs.stories.tsx +29 -0
- package/src/components/primitives/Tabs.tsx +102 -0
- package/src/components/primitives/Toolbar.stories.tsx +17 -0
- package/src/components/primitives/Toolbar.tsx +14 -0
- package/src/components/primitives/frame.test.ts +12 -0
- package/src/components/primitives/frame.ts +14 -0
- package/src/components/quick-actions-editor.stories.tsx +54 -0
- package/src/components/quick-actions-editor.tsx +138 -0
- package/src/components/stories-app.tsx +124 -0
- package/src/components/stories.test.tsx +66 -0
- package/src/components/story-fixtures.ts +98 -0
- package/src/components/story.ts +75 -0
- package/src/components/syntax-highlight.ts +102 -0
- package/src/components/terminal-pane.test.tsx +40 -0
- package/src/components/terminal-pane.ts +193 -0
- package/src/components/theme-context.stories.tsx +46 -0
- package/src/components/theme-context.tsx +37 -0
- package/src/components/truncate-text.ts +7 -0
- package/src/config.test.ts +448 -31
- package/src/config.ts +339 -57
- package/src/diff-hunk-curate.test.ts +315 -0
- package/src/diff-hunk-curate.ts +253 -0
- package/src/diff-intraline.test.ts +165 -0
- package/src/diff-intraline.ts +146 -0
- package/src/diff-syntax.test.ts +96 -0
- package/src/diff-syntax.ts +176 -0
- package/src/editor.test.ts +149 -0
- package/src/editor.ts +135 -9
- package/src/ghostty-terminal.test.ts +84 -0
- package/src/ghostty-terminal.ts +176 -0
- package/src/herdr.test.ts +9 -34
- package/src/herdr.ts +17 -21
- package/src/index.ts +10 -0
- package/src/integrations.test.ts +41 -0
- package/src/integrations.ts +32 -0
- package/src/intent-dispatch.test.ts +499 -0
- package/src/intent-dispatch.ts +585 -0
- package/src/key-bindings.test.ts +156 -0
- package/src/key-bindings.ts +452 -0
- package/src/keymap.test.ts +635 -0
- package/src/keymap.ts +404 -0
- package/src/kitty-image.test.ts +66 -0
- package/src/kitty-image.ts +138 -0
- package/src/prototype-browser.test.ts +56 -0
- package/src/prototype-browser.ts +289 -0
- package/src/pty.test.ts +81 -0
- package/src/pty.ts +237 -0
- package/src/review-panel.test.ts +113 -0
- package/src/review-panel.ts +83 -0
- package/src/run.ts +16 -5
- package/src/serve.test.ts +25 -9
- package/src/serve.ts +19 -8
- package/src/session-controller.curate.test.ts +260 -0
- package/src/session-controller.share.test.ts +320 -0
- package/src/session-controller.ts +993 -0
- package/src/share.ts +120 -0
- package/src/test-support.test.ts +60 -0
- package/src/test-support.ts +197 -0
- package/src/theme-presets.test.ts +51 -0
- package/src/theme-presets.ts +187 -0
- package/src/theme.ts +64 -17
- package/src/use-settings-dialog.tsx +275 -0
- package/src/version.ts +5 -0
- package/src/view-diff.test.ts +22 -8
- package/src/view-diff.ts +39 -23
- package/src/view-plan.regression.test.ts +60 -0
- package/src/view-plan.test.ts +406 -0
- package/src/view-plan.ts +554 -0
- package/src/walk.test.ts +121 -0
- package/src/walk.ts +74 -0
- package/src/syntax.test.ts +0 -41
- package/src/syntax.ts +0 -88
- package/src/view.test.ts +0 -157
- package/src/view.ts +0 -0
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The interactive stories catalog (`bun run stories`): a left list of
|
|
3
|
+
* component modules (j/k), h/l cycling through the module's stories, and the
|
|
4
|
+
* selected story rendered live in the preview pane. The same story objects
|
|
5
|
+
* back the snapshot harness - what this TUI shows is what the tests assert.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import React, { useMemo, useState } from "react";
|
|
9
|
+
import { createCliRenderer } from "@opentui/core";
|
|
10
|
+
import { createRoot, useKeyboard } from "@opentui/react";
|
|
11
|
+
import { DARK } from "../theme";
|
|
12
|
+
import { ThemeProvider } from "./theme-context";
|
|
13
|
+
import { loadStories, type LoadedStory } from "./story";
|
|
14
|
+
import { FRAME_BORDER_STYLE } from "./primitives/frame";
|
|
15
|
+
|
|
16
|
+
function StoriesApp({
|
|
17
|
+
stories,
|
|
18
|
+
onExit,
|
|
19
|
+
}: {
|
|
20
|
+
stories: LoadedStory[];
|
|
21
|
+
onExit: () => void;
|
|
22
|
+
}): React.ReactNode {
|
|
23
|
+
const moduleTitles = useMemo(
|
|
24
|
+
() => [...new Set(stories.map((story) => story.moduleTitle))],
|
|
25
|
+
[stories],
|
|
26
|
+
);
|
|
27
|
+
const [moduleIndex, setModuleIndex] = useState(0);
|
|
28
|
+
const [storyIndex, setStoryIndex] = useState(0);
|
|
29
|
+
const moduleTitle = moduleTitles[moduleIndex]!;
|
|
30
|
+
const moduleStories = stories.filter((story) => story.moduleTitle === moduleTitle);
|
|
31
|
+
const current = moduleStories[Math.min(storyIndex, moduleStories.length - 1)]!;
|
|
32
|
+
|
|
33
|
+
useKeyboard((key) => {
|
|
34
|
+
if (key.name === "q") onExit();
|
|
35
|
+
else if (key.name === "j" || key.name === "down") {
|
|
36
|
+
setModuleIndex((index) => Math.min(moduleTitles.length - 1, index + 1));
|
|
37
|
+
setStoryIndex(0);
|
|
38
|
+
} else if (key.name === "k" || key.name === "up") {
|
|
39
|
+
setModuleIndex((index) => Math.max(0, index - 1));
|
|
40
|
+
setStoryIndex(0);
|
|
41
|
+
} else if (key.name === "l" || key.name === "right") {
|
|
42
|
+
setStoryIndex((index) => (index + 1) % moduleStories.length);
|
|
43
|
+
} else if (key.name === "h" || key.name === "left") {
|
|
44
|
+
setStoryIndex((index) => (index - 1 + moduleStories.length) % moduleStories.length);
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<ThemeProvider theme={DARK}>
|
|
50
|
+
<box
|
|
51
|
+
style={{
|
|
52
|
+
flexDirection: "column",
|
|
53
|
+
width: "100%",
|
|
54
|
+
height: "100%",
|
|
55
|
+
backgroundColor: DARK.background,
|
|
56
|
+
}}
|
|
57
|
+
>
|
|
58
|
+
<box style={{ height: 1, backgroundColor: DARK.panel, paddingLeft: 1 }}>
|
|
59
|
+
<text fg={DARK.text}>
|
|
60
|
+
<span fg={DARK.accent}>cueloop stories</span>
|
|
61
|
+
<span fg={DARK.textDim}>
|
|
62
|
+
{" "}
|
|
63
|
+
· {moduleTitle} / {current.storyName}
|
|
64
|
+
</span>
|
|
65
|
+
</text>
|
|
66
|
+
</box>
|
|
67
|
+
<box style={{ flexDirection: "row", flexGrow: 1 }}>
|
|
68
|
+
<box
|
|
69
|
+
style={{
|
|
70
|
+
width: 28,
|
|
71
|
+
backgroundColor: DARK.panel,
|
|
72
|
+
flexDirection: "column",
|
|
73
|
+
paddingLeft: 1,
|
|
74
|
+
paddingTop: 1,
|
|
75
|
+
}}
|
|
76
|
+
>
|
|
77
|
+
{moduleTitles.map((title, index) => (
|
|
78
|
+
<text key={title} fg={index === moduleIndex ? DARK.accent : DARK.textMuted}>
|
|
79
|
+
{index === moduleIndex ? "▸ " : " "}
|
|
80
|
+
{title}
|
|
81
|
+
</text>
|
|
82
|
+
))}
|
|
83
|
+
</box>
|
|
84
|
+
<box
|
|
85
|
+
style={{
|
|
86
|
+
flexGrow: 1,
|
|
87
|
+
flexDirection: "column",
|
|
88
|
+
border: true,
|
|
89
|
+
borderStyle: FRAME_BORDER_STYLE,
|
|
90
|
+
borderColor: DARK.border,
|
|
91
|
+
}}
|
|
92
|
+
title={` ${current.storyName} (${moduleStories.indexOf(current) + 1}/${moduleStories.length}) `}
|
|
93
|
+
>
|
|
94
|
+
{/* remount per story so component-local state never leaks across */}
|
|
95
|
+
<box
|
|
96
|
+
key={`${moduleTitle}/${current.storyName}`}
|
|
97
|
+
style={{ flexGrow: 1, flexDirection: "column" }}
|
|
98
|
+
>
|
|
99
|
+
{current.story.render()}
|
|
100
|
+
</box>
|
|
101
|
+
</box>
|
|
102
|
+
</box>
|
|
103
|
+
<box style={{ height: 1, backgroundColor: DARK.panel, paddingLeft: 1 }}>
|
|
104
|
+
<text fg={DARK.textDim}>j/k component · h/l story · q quit</text>
|
|
105
|
+
</box>
|
|
106
|
+
</box>
|
|
107
|
+
</ThemeProvider>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (import.meta.main) {
|
|
112
|
+
const stories = await loadStories();
|
|
113
|
+
const renderer = await createCliRenderer();
|
|
114
|
+
|
|
115
|
+
createRoot(renderer).render(
|
|
116
|
+
<StoriesApp
|
|
117
|
+
stories={stories}
|
|
118
|
+
onExit={() => {
|
|
119
|
+
renderer.destroy();
|
|
120
|
+
queueMicrotask(() => process.exit(0));
|
|
121
|
+
}}
|
|
122
|
+
/>,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The catalog IS the regression suite: every exported story renders in
|
|
3
|
+
* a virtual terminal, its char frame snapshots, and its declared colors must
|
|
4
|
+
* appear among the styled spans. A component file without a colocated
|
|
5
|
+
* stories file fails the harness, so the safety net cannot silently thin.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, expect, test } from "bun:test";
|
|
9
|
+
import { testRender } from "@opentui/react/test-utils";
|
|
10
|
+
import { RGBA } from "@opentui/core";
|
|
11
|
+
import { componentFilesMissingStories, loadStories } from "./story";
|
|
12
|
+
import { allowEventLoopUpdates } from "../test-support";
|
|
13
|
+
|
|
14
|
+
const stories = await loadStories();
|
|
15
|
+
|
|
16
|
+
/** Hex of a painted color; undefined for unpainted (transparent) spans. */
|
|
17
|
+
function hexOf(color: RGBA | null | undefined): string | undefined {
|
|
18
|
+
if (!color) return undefined;
|
|
19
|
+
const [red, green, blue] = color.toInts();
|
|
20
|
+
|
|
21
|
+
return "#" + [red, green, blue].map((part) => part!.toString(16).padStart(2, "0")).join("");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe("stories catalog", () => {
|
|
25
|
+
test("every component file ships stories", () => {
|
|
26
|
+
expect(componentFilesMissingStories()).toEqual([]);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("the catalog is not empty", () => {
|
|
30
|
+
expect(stories.length).toBeGreaterThan(0);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
for (const { moduleTitle, storyName, story } of stories) {
|
|
34
|
+
test(`${moduleTitle}/${storyName} renders, snapshots, and carries its colors`, async () => {
|
|
35
|
+
// Arrange
|
|
36
|
+
const size = story.size ?? { width: 80, height: 24 };
|
|
37
|
+
|
|
38
|
+
// Act
|
|
39
|
+
const setup = await testRender(story.render(), size);
|
|
40
|
+
|
|
41
|
+
allowEventLoopUpdates();
|
|
42
|
+
await setup.waitForVisualIdle();
|
|
43
|
+
const frame = setup.captureCharFrame();
|
|
44
|
+
|
|
45
|
+
// Assert
|
|
46
|
+
expect(frame).toMatchSnapshot(`${moduleTitle}/${storyName}`);
|
|
47
|
+
if (story.expectedColors?.length) {
|
|
48
|
+
const seenColors = new Set<string>();
|
|
49
|
+
|
|
50
|
+
for (const line of setup.captureSpans().lines) {
|
|
51
|
+
for (const span of line.spans) {
|
|
52
|
+
const foregroundHex = hexOf(span.fg);
|
|
53
|
+
const backgroundHex = hexOf(span.bg);
|
|
54
|
+
|
|
55
|
+
if (foregroundHex) seenColors.add(foregroundHex);
|
|
56
|
+
if (backgroundHex) seenColors.add(backgroundHex);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
for (const expectedColor of story.expectedColors) {
|
|
60
|
+
expect(seenColors).toContain(expectedColor.toLowerCase());
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
setup.renderer.destroy();
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/** Shared fixture data for the stories catalog: one plan session, one diff. */
|
|
2
|
+
|
|
3
|
+
import type { Annotation, ReviewSession } from "@cueloop/schema";
|
|
4
|
+
import { buildDisplay, marksByDisplay } from "../view-plan";
|
|
5
|
+
import { diffRows } from "../view-diff";
|
|
6
|
+
|
|
7
|
+
export const FIXTURE_PLAN = `# Migration Plan
|
|
8
|
+
|
|
9
|
+
## Context
|
|
10
|
+
|
|
11
|
+
The daemon persists sessions to disk atomically.
|
|
12
|
+
|
|
13
|
+
## Steps
|
|
14
|
+
|
|
15
|
+
- move the store
|
|
16
|
+
- add recovery
|
|
17
|
+
|
|
18
|
+
\`\`\`ts
|
|
19
|
+
export function persist(session: Session): void {
|
|
20
|
+
// one JSON document per session
|
|
21
|
+
writeAtomically(pathFor(session.id), serialize(session));
|
|
22
|
+
}
|
|
23
|
+
\`\`\`
|
|
24
|
+
`;
|
|
25
|
+
|
|
26
|
+
export const FIXTURE_PATCH = `diff --git a/src/store.ts b/src/store.ts
|
|
27
|
+
index 111..222 100644
|
|
28
|
+
--- a/src/store.ts
|
|
29
|
+
+++ b/src/store.ts
|
|
30
|
+
@@ -1,4 +1,4 @@
|
|
31
|
+
export class Store {
|
|
32
|
+
- private items = [];
|
|
33
|
+
+ private items = new Map();
|
|
34
|
+
}
|
|
35
|
+
`;
|
|
36
|
+
|
|
37
|
+
export const FIXTURE_ANNOTATIONS: Annotation[] = [
|
|
38
|
+
{
|
|
39
|
+
id: "a_story_1",
|
|
40
|
+
kind: "comment",
|
|
41
|
+
anchor: { quote: "persists sessions", prefix: "The daemon ", suffix: " to disk" },
|
|
42
|
+
body: "Which daemon owns this?",
|
|
43
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
id: "a_story_2",
|
|
47
|
+
kind: "comment",
|
|
48
|
+
anchor: { quote: "move the store", prefix: "", suffix: "" },
|
|
49
|
+
body: "move the store behind one interface",
|
|
50
|
+
createdAt: "2026-01-01T00:00:01Z",
|
|
51
|
+
},
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
export function fixturePlanSession(overrides: Partial<ReviewSession> = {}): ReviewSession {
|
|
55
|
+
return {
|
|
56
|
+
schemaVersion: "1",
|
|
57
|
+
id: "s_story_plan",
|
|
58
|
+
workspace: { repoRoot: "/repo", branch: "main" },
|
|
59
|
+
artifact: {
|
|
60
|
+
type: "plan",
|
|
61
|
+
content: FIXTURE_PLAN,
|
|
62
|
+
meta: { title: "Migration Plan", agent: "agent/worker-3" },
|
|
63
|
+
},
|
|
64
|
+
revisions: [{ revision: 1, content: FIXTURE_PLAN, submittedAt: "2026-01-01T00:00:00Z" }],
|
|
65
|
+
annotations: FIXTURE_ANNOTATIONS,
|
|
66
|
+
status: "pending",
|
|
67
|
+
...overrides,
|
|
68
|
+
} as ReviewSession;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function fixtureDiffSession(overrides: Partial<ReviewSession> = {}): ReviewSession {
|
|
72
|
+
return {
|
|
73
|
+
schemaVersion: "1",
|
|
74
|
+
id: "s_story_diff",
|
|
75
|
+
workspace: { repoRoot: "/repo", branch: "main" },
|
|
76
|
+
artifact: {
|
|
77
|
+
type: "diff",
|
|
78
|
+
content: FIXTURE_PATCH,
|
|
79
|
+
meta: { title: "working tree", agent: "agent/worker-3" },
|
|
80
|
+
},
|
|
81
|
+
revisions: [{ revision: 1, content: FIXTURE_PATCH, submittedAt: "2026-01-01T00:00:00Z" }],
|
|
82
|
+
annotations: [],
|
|
83
|
+
status: "pending",
|
|
84
|
+
...overrides,
|
|
85
|
+
} as ReviewSession;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function fixtureDisplay() {
|
|
89
|
+
return buildDisplay(FIXTURE_PLAN);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function fixtureMarks() {
|
|
93
|
+
return marksByDisplay(FIXTURE_ANNOTATIONS, fixtureDisplay());
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function fixtureDiffRows() {
|
|
97
|
+
return diffRows(FIXTURE_PATCH);
|
|
98
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The story contract. Every component file in components/ ships a
|
|
3
|
+
* colocated *.stories.tsx that exports `meta` plus named Story objects.
|
|
4
|
+
* The catalog IS the regression suite: stories.test.tsx walks every story,
|
|
5
|
+
* snapshots its char frame, and asserts declared colors against the styled
|
|
6
|
+
* spans; the `bun run stories` TUI renders the same objects interactively.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type React from "react";
|
|
10
|
+
|
|
11
|
+
export interface StoryMeta {
|
|
12
|
+
/** Catalog path, e.g. "primitives/Card". */
|
|
13
|
+
title: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface Story {
|
|
17
|
+
render: () => React.ReactNode;
|
|
18
|
+
/**
|
|
19
|
+
* Colors (hex tokens) that must appear among the rendered frame's styled
|
|
20
|
+
* spans - the color regression net for color-bearing stories.
|
|
21
|
+
*/
|
|
22
|
+
expectedColors?: string[];
|
|
23
|
+
/** Virtual terminal size; defaults to 80x24. */
|
|
24
|
+
size?: { width: number; height: number };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface LoadedStory {
|
|
28
|
+
moduleTitle: string;
|
|
29
|
+
storyName: string;
|
|
30
|
+
story: Story;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function isStory(value: unknown): value is Story {
|
|
34
|
+
return (
|
|
35
|
+
typeof value === "object" && value !== null && typeof (value as Story).render === "function"
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Import every *.stories.tsx next to the components and flatten the exports. */
|
|
40
|
+
export async function loadStories(): Promise<LoadedStory[]> {
|
|
41
|
+
const glob = new Bun.Glob("**/*.stories.tsx");
|
|
42
|
+
const files = [...glob.scanSync({ cwd: import.meta.dir })].sort();
|
|
43
|
+
const loaded: LoadedStory[] = [];
|
|
44
|
+
|
|
45
|
+
for (const file of files) {
|
|
46
|
+
const moduleExports = (await import(`${import.meta.dir}/${file}`)) as Record<string, unknown>;
|
|
47
|
+
const meta = moduleExports["meta"] as StoryMeta | undefined;
|
|
48
|
+
const moduleTitle = meta?.title ?? file.replace(/\.stories\.tsx$/, "");
|
|
49
|
+
|
|
50
|
+
for (const [exportName, exported] of Object.entries(moduleExports)) {
|
|
51
|
+
if (exportName === "meta" || !isStory(exported)) continue;
|
|
52
|
+
loaded.push({ moduleTitle, storyName: exportName, story: exported });
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return loaded;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Component files that must carry stories: every .tsx that is not a story/test/prototype. */
|
|
60
|
+
export function componentFilesMissingStories(): string[] {
|
|
61
|
+
const componentGlob = new Bun.Glob("**/*.tsx");
|
|
62
|
+
const files = [...componentGlob.scanSync({ cwd: import.meta.dir })];
|
|
63
|
+
const missing: string[] = [];
|
|
64
|
+
|
|
65
|
+
for (const file of files) {
|
|
66
|
+
if (file.endsWith(".stories.tsx") || file.endsWith(".test.tsx")) continue;
|
|
67
|
+
if (file.endsWith(".prototype.tsx")) continue;
|
|
68
|
+
if (file === "stories-app.tsx") continue;
|
|
69
|
+
const storiesSibling = file.replace(/\.tsx$/, ".stories.tsx");
|
|
70
|
+
|
|
71
|
+
if (!files.includes(storiesSibling)) missing.push(file);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return missing;
|
|
75
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Code highlighting tokens: the theme's named colors projected onto tree-sitter
|
|
3
|
+
* capture names. One group -> color map is the single source of truth, feeding
|
|
4
|
+
* both the native code renderable (via a cached SyntaxStyle per theme) and the
|
|
5
|
+
* diff sheet's per-row spans. Unknown languages simply render unstyled -
|
|
6
|
+
* highlighting is an enhancement, never a blocker.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
SyntaxStyle,
|
|
11
|
+
infoStringToFiletype,
|
|
12
|
+
extensionToFiletype,
|
|
13
|
+
basenameToFiletype,
|
|
14
|
+
} from "@opentui/core";
|
|
15
|
+
import type { Theme } from "../theme";
|
|
16
|
+
|
|
17
|
+
/** Markdown fence info -> tree-sitter filetype, with the aliases we accept. */
|
|
18
|
+
export function filetypeFor(language?: string): string | undefined {
|
|
19
|
+
const info = (language ?? "").toLowerCase();
|
|
20
|
+
|
|
21
|
+
if (!info) return undefined;
|
|
22
|
+
const aliases: Record<string, string> = {
|
|
23
|
+
ts: "typescript",
|
|
24
|
+
js: "javascript",
|
|
25
|
+
py: "python",
|
|
26
|
+
rb: "ruby",
|
|
27
|
+
rs: "rust",
|
|
28
|
+
sh: "bash",
|
|
29
|
+
shell: "bash",
|
|
30
|
+
zsh: "bash",
|
|
31
|
+
yml: "yaml",
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
return infoStringToFiletype(aliases[info] ?? info) ?? aliases[info] ?? info;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A diff/code file path -> tree-sitter filetype, by basename then extension. */
|
|
38
|
+
export function filetypeForPath(path: string): string | undefined {
|
|
39
|
+
const basename = path.split("/").pop() ?? path;
|
|
40
|
+
const byBasename = basenameToFiletype.get(basename);
|
|
41
|
+
|
|
42
|
+
if (byBasename) return byBasename;
|
|
43
|
+
const dotIndex = basename.lastIndexOf(".");
|
|
44
|
+
|
|
45
|
+
if (dotIndex <= 0) return undefined;
|
|
46
|
+
|
|
47
|
+
return extensionToFiletype.get(basename.slice(dotIndex + 1).toLowerCase());
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface SyntaxGroupStyle {
|
|
51
|
+
fg: string;
|
|
52
|
+
italic?: boolean;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The one group -> style map, keyed by tree-sitter capture name. */
|
|
56
|
+
function syntaxGroupStyles(theme: Theme): Record<string, SyntaxGroupStyle> {
|
|
57
|
+
return {
|
|
58
|
+
default: { fg: theme.textMuted },
|
|
59
|
+
comment: { fg: theme.textDim, italic: true },
|
|
60
|
+
string: { fg: theme.green },
|
|
61
|
+
number: { fg: theme.blue },
|
|
62
|
+
constant: { fg: theme.blue },
|
|
63
|
+
boolean: { fg: theme.blue },
|
|
64
|
+
keyword: { fg: theme.accent },
|
|
65
|
+
operator: { fg: theme.textDim },
|
|
66
|
+
punctuation: { fg: theme.textDim },
|
|
67
|
+
function: { fg: theme.text },
|
|
68
|
+
method: { fg: theme.text },
|
|
69
|
+
type: { fg: theme.blue },
|
|
70
|
+
constructor: { fg: theme.blue },
|
|
71
|
+
variable: { fg: theme.textMuted },
|
|
72
|
+
property: { fg: theme.textMuted },
|
|
73
|
+
parameter: { fg: theme.textMuted },
|
|
74
|
+
tag: { fg: theme.accent },
|
|
75
|
+
attribute: { fg: theme.blue },
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const syntaxStyleCache = new WeakMap<Theme, SyntaxStyle>();
|
|
80
|
+
|
|
81
|
+
export function syntaxStyleFor(theme: Theme): SyntaxStyle {
|
|
82
|
+
const cached = syntaxStyleCache.get(theme);
|
|
83
|
+
|
|
84
|
+
if (cached) return cached;
|
|
85
|
+
const style = SyntaxStyle.fromStyles(syntaxGroupStyles(theme));
|
|
86
|
+
|
|
87
|
+
syntaxStyleCache.set(theme, style);
|
|
88
|
+
|
|
89
|
+
return style;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The foreground for a tree-sitter capture group; undefined when unstyled. */
|
|
93
|
+
export function colorForSyntaxGroup(group: string, theme: Theme): string | undefined {
|
|
94
|
+
const styles = syntaxGroupStyles(theme);
|
|
95
|
+
const direct = styles[group];
|
|
96
|
+
|
|
97
|
+
if (direct) return direct.fg;
|
|
98
|
+
// capture names are dotted (e.g. "keyword.control"); match the broadest prefix
|
|
99
|
+
const base = group.split(".")[0]!;
|
|
100
|
+
|
|
101
|
+
return styles[base]?.fg;
|
|
102
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/** The embedded terminal renderable runs a real PTY child through Ghostty's VT and paints its screen; a live `printf` child must show up in the OpenTUI frame. Skipped where no prebuilt libghostty-vt ships for the platform. */
|
|
2
|
+
|
|
3
|
+
import { describe, expect, test } from "bun:test";
|
|
4
|
+
import React from "react";
|
|
5
|
+
import { testRender } from "@opentui/react/test-utils";
|
|
6
|
+
import { waitForText } from "../test-support";
|
|
7
|
+
import {
|
|
8
|
+
embeddedTerminalAvailable,
|
|
9
|
+
registerTerminalPane,
|
|
10
|
+
TerminalPaneRenderable,
|
|
11
|
+
} from "./terminal-pane";
|
|
12
|
+
|
|
13
|
+
registerTerminalPane();
|
|
14
|
+
|
|
15
|
+
describe("TerminalPaneRenderable", () => {
|
|
16
|
+
test.skipIf(!embeddedTerminalAvailable())(
|
|
17
|
+
"paints a live child process's output into the box",
|
|
18
|
+
async () => {
|
|
19
|
+
// Arrange / Act - a child that prints a known line
|
|
20
|
+
const setup = await testRender(
|
|
21
|
+
React.createElement("terminalPane", {
|
|
22
|
+
command: "/bin/sh",
|
|
23
|
+
args: ["-c", "printf 'HELLO-EMBED\\n'; sleep 5"],
|
|
24
|
+
style: { width: 40, height: 6 },
|
|
25
|
+
}),
|
|
26
|
+
{ width: 40, height: 6 },
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
// Assert - the child's output reaches the painted frame via Ghostty
|
|
30
|
+
await waitForText(setup, "HELLO-EMBED");
|
|
31
|
+
setup.renderer.destroy();
|
|
32
|
+
},
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
test("exposes availability + the renderable class", () => {
|
|
36
|
+
// Assert - the module loads and reports platform support honestly
|
|
37
|
+
expect(typeof embeddedTerminalAvailable()).toBe("boolean");
|
|
38
|
+
expect(TerminalPaneRenderable).toBeDefined();
|
|
39
|
+
});
|
|
40
|
+
});
|