@cueloop/client 0.1.0-alpha.5 → 0.1.0-alpha.51

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 (152) hide show
  1. package/native/build-pty.sh +51 -0
  2. package/native/build.sh +82 -0
  3. package/native/darwin-arm64/libcuelooppty.dylib +0 -0
  4. package/native/darwin-arm64/libcueloopvt.dylib +0 -0
  5. package/native/src/pty.zig +193 -0
  6. package/native/src/shim.zig +114 -0
  7. package/package.json +36 -31
  8. package/src/App.autoclose.test.tsx +137 -0
  9. package/src/App.diff.test.tsx +158 -22
  10. package/src/App.export.test.tsx +44 -37
  11. package/src/App.plan-review.test.tsx +672 -0
  12. package/src/App.prototype.test.tsx +155 -0
  13. package/src/App.readonly.test.tsx +60 -36
  14. package/src/App.rendering.test.tsx +132 -0
  15. package/src/App.review-panel.test.tsx +136 -0
  16. package/src/App.submit-confirm.test.tsx +218 -0
  17. package/src/App.test.tsx +128 -69
  18. package/src/App.toast.test.tsx +75 -0
  19. package/src/App.tsx +0 -0
  20. package/src/App.walk.test.tsx +289 -0
  21. package/src/__snapshots__/view-plan.regression.test.ts.snap +100 -0
  22. package/src/attribution.test.ts +31 -0
  23. package/src/attribution.ts +38 -0
  24. package/src/clipboard.test.ts +23 -0
  25. package/src/clipboard.ts +30 -0
  26. package/src/components/AnnotationCard.compose-rows.test.ts +49 -0
  27. package/src/components/AnnotationCard.stories.tsx +94 -0
  28. package/src/components/AnnotationCard.tsx +241 -0
  29. package/src/components/Breadcrumb.stories.tsx +19 -0
  30. package/src/components/Breadcrumb.tsx +44 -0
  31. package/src/components/CodeBlock.stories.tsx +42 -0
  32. package/src/components/CodeBlock.tsx +83 -0
  33. package/src/components/CompletionOverlay.stories.tsx +23 -0
  34. package/src/components/CompletionOverlay.tsx +59 -0
  35. package/src/components/ConfirmCard.stories.tsx +35 -0
  36. package/src/components/ConfirmCard.tsx +127 -0
  37. package/src/components/ConfirmDialog.stories.tsx +21 -0
  38. package/src/components/ConfirmDialog.tsx +64 -0
  39. package/src/components/DiffSheet.scroll.test.tsx +123 -0
  40. package/src/components/DiffSheet.stories.tsx +78 -0
  41. package/src/components/DiffSheet.syntax.test.tsx +62 -0
  42. package/src/components/DiffSheet.tsx +355 -0
  43. package/src/components/InboxList.stories.tsx +31 -0
  44. package/src/components/InboxList.tsx +62 -0
  45. package/src/components/KeybindsDialog.stories.tsx +31 -0
  46. package/src/components/KeybindsDialog.tsx +58 -0
  47. package/src/components/MarkerPopover.stories.tsx +58 -0
  48. package/src/components/MarkerPopover.tsx +108 -0
  49. package/src/components/MenuBar.stories.tsx +37 -0
  50. package/src/components/MenuBar.tsx +74 -0
  51. package/src/components/PlanSheet.stories.tsx +113 -0
  52. package/src/components/PlanSheet.tsx +456 -0
  53. package/src/components/PromptDialog.stories.tsx +36 -0
  54. package/src/components/PromptDialog.tsx +78 -0
  55. package/src/components/PrototypeSheet.stories.tsx +20 -0
  56. package/src/components/PrototypeSheet.tsx +358 -0
  57. package/src/components/ReviewPanel.stories.tsx +86 -0
  58. package/src/components/ReviewPanel.tsx +101 -0
  59. package/src/components/ReviewRail.stories.tsx +218 -0
  60. package/src/components/ReviewRail.tsx +317 -0
  61. package/src/components/SettingsDialog.stories.tsx +111 -0
  62. package/src/components/SettingsDialog.tsx +141 -0
  63. package/src/components/SettingsRows.stories.tsx +18 -0
  64. package/src/components/SettingsRows.tsx +108 -0
  65. package/src/components/Toast.stories.tsx +12 -0
  66. package/src/components/Toast.tsx +44 -0
  67. package/src/components/WalkWizard.stories.tsx +100 -0
  68. package/src/components/WalkWizard.tsx +152 -0
  69. package/src/components/__snapshots__/stories.test.tsx.snap +1857 -0
  70. package/src/components/agent-launcher.stories.tsx +26 -0
  71. package/src/components/agent-launcher.test.tsx +49 -0
  72. package/src/components/agent-launcher.tsx +147 -0
  73. package/src/components/diff-sheet-layout.test.ts +127 -0
  74. package/src/components/diff-sheet-layout.ts +167 -0
  75. package/src/components/primitives/Button.stories.tsx +32 -0
  76. package/src/components/primitives/Button.tsx +51 -0
  77. package/src/components/primitives/Card.stories.tsx +25 -0
  78. package/src/components/primitives/Card.tsx +59 -0
  79. package/src/components/primitives/Dialog.stories.tsx +24 -0
  80. package/src/components/primitives/Dialog.tsx +64 -0
  81. package/src/components/primitives/StatusBar.stories.tsx +11 -0
  82. package/src/components/primitives/StatusBar.tsx +22 -0
  83. package/src/components/primitives/Tabs.stories.tsx +29 -0
  84. package/src/components/primitives/Tabs.tsx +98 -0
  85. package/src/components/primitives/Toolbar.stories.tsx +17 -0
  86. package/src/components/primitives/Toolbar.tsx +14 -0
  87. package/src/components/primitives/frame.test.ts +12 -0
  88. package/src/components/primitives/frame.ts +14 -0
  89. package/src/components/quick-actions-editor.stories.tsx +54 -0
  90. package/src/components/quick-actions-editor.tsx +136 -0
  91. package/src/components/stories-app.tsx +123 -0
  92. package/src/components/stories.test.tsx +62 -0
  93. package/src/components/story-fixtures.ts +98 -0
  94. package/src/components/story.ts +69 -0
  95. package/src/components/syntax-highlight.ts +92 -0
  96. package/src/components/terminal-pane.test.tsx +40 -0
  97. package/src/components/terminal-pane.ts +181 -0
  98. package/src/components/theme-context.stories.tsx +46 -0
  99. package/src/components/theme-context.tsx +36 -0
  100. package/src/components/truncate-text.ts +6 -0
  101. package/src/config.test.ts +430 -31
  102. package/src/config.ts +304 -26
  103. package/src/diff-hunk-curate.test.ts +315 -0
  104. package/src/diff-hunk-curate.ts +233 -0
  105. package/src/diff-intraline.test.ts +164 -0
  106. package/src/diff-intraline.ts +132 -0
  107. package/src/diff-syntax.test.ts +96 -0
  108. package/src/diff-syntax.ts +154 -0
  109. package/src/editor.test.ts +143 -0
  110. package/src/editor.ts +123 -9
  111. package/src/ghostty-terminal.test.ts +82 -0
  112. package/src/ghostty-terminal.ts +168 -0
  113. package/src/herdr.test.ts +41 -0
  114. package/src/herdr.ts +27 -0
  115. package/src/index.ts +10 -0
  116. package/src/integrations.test.ts +41 -0
  117. package/src/integrations.ts +30 -0
  118. package/src/intent-dispatch.test.ts +493 -0
  119. package/src/intent-dispatch.ts +428 -0
  120. package/src/key-bindings.test.ts +152 -0
  121. package/src/key-bindings.ts +430 -0
  122. package/src/keymap.test.ts +622 -0
  123. package/src/keymap.ts +339 -0
  124. package/src/kitty-image.test.ts +20 -0
  125. package/src/kitty-image.ts +75 -0
  126. package/src/prototype-browser.test.ts +55 -0
  127. package/src/prototype-browser.ts +243 -0
  128. package/src/pty.test.ts +78 -0
  129. package/src/pty.ts +222 -0
  130. package/src/review-panel.test.ts +113 -0
  131. package/src/review-panel.ts +83 -0
  132. package/src/run.ts +9 -5
  133. package/src/serve.test.ts +24 -9
  134. package/src/serve.ts +7 -7
  135. package/src/session-controller.curate.test.ts +253 -0
  136. package/src/session-controller.share.test.ts +309 -0
  137. package/src/session-controller.ts +914 -0
  138. package/src/share.ts +113 -0
  139. package/src/test-support.test.ts +55 -0
  140. package/src/test-support.ts +187 -0
  141. package/src/theme-presets.ts +168 -0
  142. package/src/theme.ts +38 -17
  143. package/src/version.ts +5 -0
  144. package/src/view-diff.test.ts +19 -8
  145. package/src/view-diff.ts +34 -23
  146. package/src/view-plan.regression.test.ts +60 -0
  147. package/src/view-plan.test.ts +399 -0
  148. package/src/view-plan.ts +495 -0
  149. package/src/walk.test.ts +121 -0
  150. package/src/walk.ts +70 -0
  151. package/src/view.test.ts +0 -157
  152. package/src/view.ts +0 -0
@@ -0,0 +1,24 @@
1
+ import React from "react";
2
+ import { DARK } from "../../theme";
3
+ import type { Story, StoryMeta } from "../story";
4
+ import { Dialog } from "./Dialog";
5
+
6
+ export const meta: StoryMeta = { title: "primitives/Dialog" };
7
+
8
+ export const Open: Story = {
9
+ render: () => (
10
+ <Dialog isOpen title=" a modal " width={40} height={8}>
11
+ <text>dialog body content</text>
12
+ </Dialog>
13
+ ),
14
+ expectedColors: [DARK.accent],
15
+ size: { width: 60, height: 16 },
16
+ };
17
+
18
+ export const Closed: Story = {
19
+ render: () => (
20
+ <Dialog isOpen={false} title=" hidden " width={40} height={8}>
21
+ <text>never rendered</text>
22
+ </Dialog>
23
+ ),
24
+ };
@@ -0,0 +1,64 @@
1
+ /**
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.
5
+ * `isOpen` keeps the mount decision with the caller-visible prop vocabulary.
6
+ */
7
+
8
+ import React from "react";
9
+ import type { Theme } from "../../theme";
10
+ import { useComponentTheme } from "../theme-context";
11
+ import { FRAME_BORDER_STYLE } from "./frame";
12
+
13
+ export interface DialogProps {
14
+ isOpen: boolean;
15
+ title?: string;
16
+ width: number;
17
+ height: number;
18
+ /** Solid panel fill for content-heavy dialogs; default transparent. */
19
+ background?: string;
20
+ theme?: Theme;
21
+ children: React.ReactNode;
22
+ }
23
+
24
+ export function Dialog({
25
+ isOpen,
26
+ title,
27
+ width,
28
+ height,
29
+ background,
30
+ theme,
31
+ children,
32
+ }: DialogProps): React.ReactNode {
33
+ const tokens = useComponentTheme(theme);
34
+ if (!isOpen) return null;
35
+ return (
36
+ <box
37
+ style={{
38
+ position: "absolute",
39
+ left: 0,
40
+ top: 0,
41
+ width: "100%",
42
+ height: "100%",
43
+ backgroundColor: tokens.backdrop,
44
+ alignItems: "center",
45
+ justifyContent: "center",
46
+ }}
47
+ >
48
+ <box
49
+ style={{
50
+ width,
51
+ height,
52
+ border: true,
53
+ borderStyle: FRAME_BORDER_STYLE,
54
+ borderColor: tokens.accent,
55
+ backgroundColor: background,
56
+ flexDirection: "column",
57
+ }}
58
+ title={title}
59
+ >
60
+ {children}
61
+ </box>
62
+ </box>
63
+ );
64
+ }
@@ -0,0 +1,11 @@
1
+ import React from "react";
2
+ import { DARK } from "../../theme";
3
+ import type { Story, StoryMeta } from "../story";
4
+ import { StatusBar } from "./StatusBar";
5
+
6
+ export const meta: StoryMeta = { title: "primitives/StatusBar" };
7
+
8
+ export const HintLine: Story = {
9
+ render: () => <StatusBar>j/k move · v span · c comment · q quit</StatusBar>,
10
+ expectedColors: [DARK.textDim],
11
+ };
@@ -0,0 +1,22 @@
1
+ /**
2
+ * One-row panel bar for the status/hint line. Content renders as dim text;
3
+ * callers pass richer spans as children when a segment needs another tone.
4
+ */
5
+
6
+ import React from "react";
7
+ import type { Theme } from "../../theme";
8
+ import { useComponentTheme } from "../theme-context";
9
+
10
+ export interface StatusBarProps {
11
+ theme?: Theme;
12
+ children: React.ReactNode;
13
+ }
14
+
15
+ export function StatusBar({ theme, children }: StatusBarProps): React.ReactNode {
16
+ const tokens = useComponentTheme(theme);
17
+ return (
18
+ <box style={{ height: 1, backgroundColor: tokens.panel, paddingLeft: 1 }}>
19
+ <text fg={tokens.textDim}>{children}</text>
20
+ </box>
21
+ );
22
+ }
@@ -0,0 +1,29 @@
1
+ import React from "react";
2
+ import { DARK } from "../../theme";
3
+ import type { Story, StoryMeta } from "../story";
4
+ import { Tab, TabList, Tabs } from "./Tabs";
5
+
6
+ export const meta: StoryMeta = { title: "primitives/Tabs" };
7
+
8
+ export const TwoTabs: Story = {
9
+ render: () => (
10
+ <Tabs selectedKey="review" onSelectionChange={() => {}}>
11
+ <TabList>
12
+ <Tab id="review">Review (2)</Tab>
13
+ <Tab id="agent">Agent</Tab>
14
+ </TabList>
15
+ </Tabs>
16
+ ),
17
+ expectedColors: [DARK.accent, DARK.textDim],
18
+ };
19
+
20
+ export const SecondSelected: Story = {
21
+ render: () => (
22
+ <Tabs selectedKey="agent" onSelectionChange={() => {}}>
23
+ <TabList>
24
+ <Tab id="review">Review (0)</Tab>
25
+ <Tab id="agent">Agent</Tab>
26
+ </TabList>
27
+ </Tabs>
28
+ ),
29
+ };
@@ -0,0 +1,98 @@
1
+ /**
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.
7
+ */
8
+
9
+ import React, { createContext, useContext } from "react";
10
+ import type { Theme } from "../../theme";
11
+ import { useComponentTheme } from "../theme-context";
12
+ import { FRAME_BORDER_STYLE } from "./frame";
13
+
14
+ interface TabsContextValue {
15
+ selectedKey: string;
16
+ onSelectionChange: (key: string) => void;
17
+ /** Text color of the selected tab; defaults to the accent token. */
18
+ selectedColor?: string;
19
+ theme?: Theme;
20
+ }
21
+
22
+ const TabsContext = createContext<TabsContextValue | null>(null);
23
+
24
+ export interface TabsProps {
25
+ selectedKey: string;
26
+ onSelectionChange: (key: string) => void;
27
+ selectedColor?: string;
28
+ theme?: Theme;
29
+ children: React.ReactNode;
30
+ }
31
+
32
+ export function Tabs({
33
+ selectedKey,
34
+ onSelectionChange,
35
+ selectedColor,
36
+ theme,
37
+ children,
38
+ }: TabsProps): React.ReactNode {
39
+ return (
40
+ <TabsContext.Provider value={{ selectedKey, onSelectionChange, selectedColor, theme }}>
41
+ {children}
42
+ </TabsContext.Provider>
43
+ );
44
+ }
45
+
46
+ export interface TabProps {
47
+ id: string;
48
+ children: string;
49
+ }
50
+
51
+ /** Declarative item: TabList collects Tab children into the native strip. */
52
+ export function Tab(_props: TabProps): React.ReactNode {
53
+ return null;
54
+ }
55
+
56
+ export interface TabListProps {
57
+ children: React.ReactNode;
58
+ }
59
+
60
+ export function TabList({ children }: TabListProps): React.ReactNode {
61
+ const tabs = useContext(TabsContext);
62
+ if (!tabs) throw new Error("TabList must render inside Tabs");
63
+ const tokens = useComponentTheme(tabs.theme);
64
+ const items: { id: string; label: string }[] = [];
65
+ React.Children.forEach(children, (child) => {
66
+ if (React.isValidElement<TabProps>(child) && typeof child.props.id === "string") {
67
+ items.push({ id: child.props.id, label: child.props.children });
68
+ }
69
+ });
70
+ return (
71
+ <box
72
+ style={{
73
+ width: "100%",
74
+ height: 3,
75
+ border: true,
76
+ borderStyle: FRAME_BORDER_STYLE,
77
+ borderColor: tokens.text,
78
+ flexDirection: "row",
79
+ paddingLeft: 1,
80
+ }}
81
+ >
82
+ {items.map((item) => {
83
+ const selected = item.id === tabs.selectedKey;
84
+ return (
85
+ <box
86
+ key={item.id}
87
+ style={{ marginRight: 2 }}
88
+ onMouseUp={() => tabs.onSelectionChange(item.id)}
89
+ >
90
+ <text fg={selected ? (tabs.selectedColor ?? tokens.accent) : tokens.textDim}>
91
+ {item.label}
92
+ </text>
93
+ </box>
94
+ );
95
+ })}
96
+ </box>
97
+ );
98
+ }
@@ -0,0 +1,17 @@
1
+ import React from "react";
2
+ import type { Story, StoryMeta } from "../story";
3
+ import { Button } from "./Button";
4
+ import { Toolbar } from "./Toolbar";
5
+
6
+ export const meta: StoryMeta = { title: "primitives/Toolbar" };
7
+
8
+ export const ActionRow: Story = {
9
+ render: () => (
10
+ <Toolbar>
11
+ <Button variant="solid" marginRight={2} onPress={() => {}}>
12
+ {" Submit "}
13
+ </Button>
14
+ <Button onPress={() => {}}>{" Cancel "}</Button>
15
+ </Toolbar>
16
+ ),
17
+ };
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Horizontal action row: children flow left to right on one row. The classic
3
+ * home of Button groups (Save/Cancel, Submit/Cancel).
4
+ */
5
+
6
+ import React from "react";
7
+
8
+ export interface ToolbarProps {
9
+ children: React.ReactNode;
10
+ }
11
+
12
+ export function Toolbar({ children }: ToolbarProps): React.ReactNode {
13
+ return <box style={{ flexDirection: "row", height: 1 }}>{children}</box>;
14
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Locks the single frame border rule: every bordered frame reads this token,
3
+ * so a regression to square corners (or any other style) fails here first,
4
+ * before the slower VRT snapshots catch it. Decision: rounded (issue #116).
5
+ */
6
+
7
+ import { expect, test } from "bun:test";
8
+ import { FRAME_BORDER_STYLE } from "./frame";
9
+
10
+ test("frames use the rounded border style", () => {
11
+ expect(FRAME_BORDER_STYLE).toBe("rounded");
12
+ });
@@ -0,0 +1,14 @@
1
+ /**
2
+ * The one frame border rule for the design system. Every bordered frame -
3
+ * cards, dialogs, and the stories gallery chrome - reads its corner style
4
+ * from FRAME_BORDER_STYLE, so the rounded-corner decision lives in a single
5
+ * place and can never drift between frames. Buttons stay text-first and
6
+ * borderless: the frame they sit in carries the border, not the button.
7
+ *
8
+ * Decision: rounded on the frames, not on the buttons (issue #116).
9
+ */
10
+
11
+ import type { BorderStyle } from "@opentui/core";
12
+
13
+ /** Corner style shared by every bordered frame in the app. */
14
+ export const FRAME_BORDER_STYLE: BorderStyle = "rounded";
@@ -0,0 +1,54 @@
1
+ import React from "react";
2
+ import { DARK } from "../theme";
3
+ import { DEFAULT_QUICK_ACTIONS } from "../config";
4
+ import type { Story, StoryMeta } from "./story";
5
+ import { QuickActionsEditor } from "./quick-actions-editor";
6
+
7
+ export const meta: StoryMeta = { title: "QuickActionsEditor" };
8
+
9
+ /** The editor renders inside the settings body column. */
10
+ function BodyFrame({ children }: { children: React.ReactNode }): React.ReactNode {
11
+ return (
12
+ <box style={{ width: 54, height: 22, paddingLeft: 2, backgroundColor: DARK.elevated }}>
13
+ {children}
14
+ </box>
15
+ );
16
+ }
17
+
18
+ const noop = () => {};
19
+
20
+ export const Collapsed: Story = {
21
+ render: () => (
22
+ <BodyFrame>
23
+ <QuickActionsEditor
24
+ actions={DEFAULT_QUICK_ACTIONS}
25
+ selectedIndex={0}
26
+ expandedIndex={null}
27
+ onToggleExpand={noop}
28
+ onEditMetadata={noop}
29
+ onReset={noop}
30
+ onAdd={noop}
31
+ theme={DARK}
32
+ />
33
+ </BodyFrame>
34
+ ),
35
+ size: { width: 54, height: 22 },
36
+ };
37
+
38
+ export const RowExpanded: Story = {
39
+ render: () => (
40
+ <BodyFrame>
41
+ <QuickActionsEditor
42
+ actions={DEFAULT_QUICK_ACTIONS}
43
+ selectedIndex={1}
44
+ expandedIndex={1}
45
+ onToggleExpand={noop}
46
+ onEditMetadata={noop}
47
+ onReset={noop}
48
+ onAdd={noop}
49
+ theme={DARK}
50
+ />
51
+ </BodyFrame>
52
+ ),
53
+ size: { width: 54, height: 22 },
54
+ };
@@ -0,0 +1,136 @@
1
+ /**
2
+ * The Settings "Actions" body: the quick-action vocabulary editor. Each row is a
3
+ * clickable prompt; the selected-and-expanded row reveals a focused input for the
4
+ * per-action system prompt (the extra guidance appended when the action is used).
5
+ * A reset-to-defaults control and an add-action row bracket the list. Controlled:
6
+ * the app owns the actions, the selection, and which row is expanded.
7
+ */
8
+
9
+ import React, { useRef } from "react";
10
+ import type { KeyBinding, TextareaRenderable } from "@opentui/core";
11
+ import type { QuickAction } from "../config";
12
+ import type { Theme } from "../theme";
13
+ import { useComponentTheme } from "./theme-context";
14
+
15
+ // ⏎ never inserts a newline: a system prompt is one line, esc closes the editor.
16
+ const ACTION_INPUT_KEY_BINDINGS: KeyBinding[] = [{ name: "return", action: "submit" }];
17
+
18
+ export interface QuickActionsEditorProps {
19
+ actions: QuickAction[];
20
+ /** The highlighted row; equals the settings body row index. */
21
+ selectedIndex: number;
22
+ /** The row whose system-prompt input is open and focused, or null. */
23
+ expandedIndex: number | null;
24
+ /** Select and toggle a row's system-prompt input open/closed. */
25
+ onToggleExpand: (index: number) => void;
26
+ onEditMetadata: (index: number, metadata: string) => void;
27
+ onReset: () => void;
28
+ onAdd: () => void;
29
+ theme?: Theme;
30
+ }
31
+
32
+ export function QuickActionsEditor({
33
+ actions,
34
+ selectedIndex,
35
+ expandedIndex,
36
+ onToggleExpand,
37
+ onEditMetadata,
38
+ onReset,
39
+ onAdd,
40
+ theme,
41
+ }: QuickActionsEditorProps): React.ReactNode {
42
+ const tokens = useComponentTheme(theme);
43
+ return (
44
+ <box style={{ flexDirection: "column", flexGrow: 1 }}>
45
+ <box style={{ flexDirection: "row" }}>
46
+ <box style={{ flexGrow: 1 }}>
47
+ <text fg={tokens.textDim}>click a prompt to edit its system prompt</text>
48
+ </box>
49
+ <box onMouseUp={onReset}>
50
+ <text fg={tokens.textDim}>reset to defaults ▸</text>
51
+ </box>
52
+ </box>
53
+ <box style={{ height: 1 }} />
54
+ {actions.map((action, index) => (
55
+ <ActionRow
56
+ key={index}
57
+ action={action}
58
+ isSelected={index === selectedIndex}
59
+ isExpanded={index === expandedIndex}
60
+ onToggleExpand={() => onToggleExpand(index)}
61
+ onEditMetadata={(metadata) => onEditMetadata(index, metadata)}
62
+ theme={theme}
63
+ />
64
+ ))}
65
+ <box
66
+ style={{ backgroundColor: selectedIndex === actions.length ? tokens.border : undefined }}
67
+ onMouseUp={onAdd}
68
+ >
69
+ <text fg={tokens.textMuted}>+ Add action</text>
70
+ </box>
71
+ </box>
72
+ );
73
+ }
74
+
75
+ function ActionRow({
76
+ action,
77
+ isSelected,
78
+ isExpanded,
79
+ onToggleExpand,
80
+ onEditMetadata,
81
+ theme,
82
+ }: {
83
+ action: QuickAction;
84
+ isSelected: boolean;
85
+ isExpanded: boolean;
86
+ onToggleExpand: () => void;
87
+ onEditMetadata: (metadata: string) => void;
88
+ theme?: Theme;
89
+ }): React.ReactNode {
90
+ const tokens = useComponentTheme(theme);
91
+ const inputRef = useRef<TextareaRenderable | null>(null);
92
+ return (
93
+ <box style={{ flexDirection: "column" }}>
94
+ <box
95
+ style={{ backgroundColor: isSelected ? tokens.border : undefined }}
96
+ onMouseUp={onToggleExpand}
97
+ >
98
+ <text fg={isSelected ? tokens.text : tokens.textMuted}>
99
+ {`${isExpanded ? "▾ " : "▸ "}${action.prompt}`}
100
+ </text>
101
+ </box>
102
+ {isExpanded ? (
103
+ <box style={{ flexDirection: "row", paddingLeft: 2 }}>
104
+ <box style={{ width: 2, flexShrink: 0 }}>
105
+ <text fg={tokens.textDim}>{">"}</text>
106
+ </box>
107
+ <textarea
108
+ ref={inputRef}
109
+ focused
110
+ initialValue={action.metadata ?? ""}
111
+ placeholder="extra system prompt appended to this comment (optional)"
112
+ keyBindings={ACTION_INPUT_KEY_BINDINGS}
113
+ onContentChange={() => onEditMetadata(inputRef.current?.plainText ?? "")}
114
+ style={{
115
+ height: 1,
116
+ flexGrow: 1,
117
+ backgroundColor: tokens.elevated,
118
+ focusedBackgroundColor: tokens.elevated,
119
+ textColor: tokens.text,
120
+ focusedTextColor: tokens.text,
121
+ }}
122
+ />
123
+ </box>
124
+ ) : action.metadata ? (
125
+ <box style={{ paddingLeft: 2 }}>
126
+ <text fg={tokens.textDim}>{truncateMetadata(action.metadata)}</text>
127
+ </box>
128
+ ) : null}
129
+ </box>
130
+ );
131
+ }
132
+
133
+ /** A one-line preview of the system prompt under a collapsed row. */
134
+ function truncateMetadata(metadata: string): string {
135
+ return metadata.length > 44 ? `${metadata.slice(0, 43)}…` : metadata;
136
+ }
@@ -0,0 +1,123 @@
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
+ createRoot(renderer).render(
115
+ <StoriesApp
116
+ stories={stories}
117
+ onExit={() => {
118
+ renderer.destroy();
119
+ queueMicrotask(() => process.exit(0));
120
+ }}
121
+ />,
122
+ );
123
+ }