@cueloop/client 0.1.0-alpha.77 → 0.1.0-alpha.79

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 (130) hide show
  1. package/package.json +14 -13
  2. package/src/App.autoclose.test.tsx +3 -3
  3. package/src/App.diff.test.tsx +22 -5
  4. package/src/App.export.test.tsx +1 -2
  5. package/src/App.focus.test.ts +1 -59
  6. package/src/App.identity.test.tsx +3 -3
  7. package/src/App.plan-render.test.tsx +209 -0
  8. package/src/App.plan-review.test.tsx +91 -26
  9. package/src/App.readonly.test.tsx +6 -5
  10. package/src/App.share-dialog.test.tsx +172 -0
  11. package/src/App.submit-confirm.test.tsx +39 -8
  12. package/src/App.test.tsx +33 -30
  13. package/src/App.toast.test.tsx +11 -4
  14. package/src/App.tsx +263 -274
  15. package/src/App.walk.test.tsx +11 -10
  16. package/src/App.workbench.test.tsx +86 -0
  17. package/src/__snapshots__/view-plan.regression.test.ts.snap +1 -1
  18. package/src/annotation-palette.ts +1 -0
  19. package/src/ansi-screen.test.ts +28 -0
  20. package/src/ansi-screen.ts +72 -0
  21. package/src/app-screens.tsx +19 -9
  22. package/src/app-view-model.ts +3 -1
  23. package/src/components/AnnotatableFileView.tsx +1 -1
  24. package/src/components/AnnotationCards.tsx +18 -2
  25. package/src/components/AnsiScreen.stories.tsx +12 -0
  26. package/src/components/AnsiScreen.tsx +28 -0
  27. package/src/components/CompletionOverlay.stories.tsx +15 -1
  28. package/src/components/CompletionOverlay.tsx +22 -5
  29. package/src/components/ConfirmCard.stories.tsx +2 -0
  30. package/src/components/ConfirmCard.tsx +45 -18
  31. package/src/components/ConfirmDialog.stories.tsx +3 -3
  32. package/src/components/ConfirmDialog.tsx +11 -12
  33. package/src/components/DiffContentView.tsx +18 -11
  34. package/src/components/DiscussionMarkerRail.tsx +9 -4
  35. package/src/components/FileContentsView.tsx +1 -1
  36. package/src/components/GridTabContent.tsx +8 -2
  37. package/src/components/HandleChipsInput.stories.tsx +24 -0
  38. package/src/components/HandleChipsInput.test.tsx +35 -0
  39. package/src/components/HandleChipsInput.tsx +145 -0
  40. package/src/components/MarkdownGridBlock.stories.tsx +33 -0
  41. package/src/components/MarkdownGridBlock.tsx +72 -0
  42. package/src/components/MarkdownThreadEditor.stories.tsx +15 -0
  43. package/src/components/MarkdownThreadEditor.test.tsx +98 -0
  44. package/src/components/MarkdownThreadEditor.tsx +162 -0
  45. package/src/components/NavModeHint.stories.tsx +40 -0
  46. package/src/components/NavModeHint.tsx +31 -0
  47. package/src/components/PromptDialog.test.tsx +2 -2
  48. package/src/components/PromptDialog.tsx +9 -10
  49. package/src/components/SettingsDialog.tsx +1 -0
  50. package/src/components/ShareDialog.stories.tsx +121 -0
  51. package/src/components/ShareDialog.tsx +552 -0
  52. package/src/components/SlashComposer.stories.tsx +37 -0
  53. package/src/components/SlashComposer.tsx +87 -0
  54. package/src/components/ThreadFooter.tsx +4 -1
  55. package/src/components/ThreadTree.tsx +12 -7
  56. package/src/components/ThreadView.selection.test.tsx +88 -1
  57. package/src/components/ThreadView.slash.test.tsx +0 -36
  58. package/src/components/ThreadView.tsx +200 -67
  59. package/src/components/Toast.stories.tsx +7 -0
  60. package/src/components/Toast.tsx +15 -8
  61. package/src/components/WalkWizard.stories.tsx +2 -2
  62. package/src/components/WalkWizard.tsx +10 -12
  63. package/src/components/__snapshots__/stories.test.tsx.snap +436 -182
  64. package/src/components/primitives/Button.tsx +6 -2
  65. package/src/components/primitives/Card.tsx +5 -2
  66. package/src/components/primitives/Dialog.tsx +2 -2
  67. package/src/components/primitives/DialogActions.stories.tsx +20 -0
  68. package/src/components/primitives/DialogActions.tsx +39 -0
  69. package/src/components/primitives/Switch.stories.tsx +17 -0
  70. package/src/components/primitives/Switch.tsx +32 -0
  71. package/src/components/primitives/Tree.tsx +5 -1
  72. package/src/components/primitives/frame.ts +5 -0
  73. package/src/components/session-tree.ts +7 -6
  74. package/src/components/share-dialog-store.ts +58 -0
  75. package/src/components/stories-app.tsx +13 -4
  76. package/src/components/stories.test.tsx +12 -4
  77. package/src/components/story-tree.test.ts +32 -24
  78. package/src/components/story.ts +32 -15
  79. package/src/config.test.ts +18 -0
  80. package/src/config.ts +8 -1
  81. package/src/diff-fold.ts +1 -1
  82. package/src/diff-intraline.test.ts +75 -2
  83. package/src/diff-intraline.ts +95 -26
  84. package/src/intent-dispatch.test.ts +12 -3
  85. package/src/intent-dispatch.ts +10 -25
  86. package/src/key-bindings.ts +0 -6
  87. package/src/keymap.test.ts +2 -2
  88. package/src/keymap.ts +0 -2
  89. package/src/markdown-editor-syntax-style.ts +50 -0
  90. package/src/markdown-grid.test.ts +101 -0
  91. package/src/markdown-grid.ts +203 -0
  92. package/src/markdown-highlight.test.ts +61 -0
  93. package/src/markdown-highlight.ts +122 -0
  94. package/src/pasted-image.test.ts +67 -0
  95. package/src/pasted-image.ts +60 -0
  96. package/src/prototype-browser.test.ts +9 -1
  97. package/src/prototype-browser.ts +22 -0
  98. package/src/run.ts +39 -25
  99. package/src/serve.ts +1 -1
  100. package/src/share.ts +16 -2
  101. package/src/slash-palette.test.ts +17 -0
  102. package/src/slash-palette.ts +11 -0
  103. package/src/test-support.ts +14 -0
  104. package/src/thread-chords.test.ts +55 -178
  105. package/src/thread-chords.ts +78 -169
  106. package/src/thread-controller.curate.test.ts +1 -0
  107. package/src/thread-controller.derivation.test.ts +62 -0
  108. package/src/thread-controller.live-diff.test.ts +3 -0
  109. package/src/thread-controller.optimistic.test.ts +69 -0
  110. package/src/thread-controller.share.test.ts +60 -5
  111. package/src/thread-controller.switch-cache.test.ts +176 -0
  112. package/src/thread-controller.tree.test.ts +6 -1
  113. package/src/thread-controller.ts +386 -78
  114. package/src/use-annotation-surface.tsx +88 -63
  115. package/src/use-settings-dialog.tsx +14 -3
  116. package/src/use-thread-body-editing.tsx +58 -0
  117. package/src/view-plan.test.ts +56 -6
  118. package/src/view-plan.ts +154 -5
  119. package/src/App.share-choice.test.tsx +0 -105
  120. package/src/components/ManageAccessDialog.stories.tsx +0 -36
  121. package/src/components/ManageAccessDialog.test.tsx +0 -94
  122. package/src/components/ManageAccessDialog.tsx +0 -120
  123. package/src/components/ShareChoiceDialog.stories.tsx +0 -34
  124. package/src/components/ShareChoiceDialog.test.tsx +0 -103
  125. package/src/components/ShareChoiceDialog.tsx +0 -82
  126. package/src/components/ThreadsSidebar.stories.tsx +0 -36
  127. package/src/components/ThreadsSidebar.tsx +0 -71
  128. package/src/components/agent-launcher.stories.tsx +0 -26
  129. package/src/components/agent-launcher.test.tsx +0 -50
  130. package/src/components/agent-launcher.tsx +0 -149
@@ -29,10 +29,14 @@ export function Button({
29
29
  children,
30
30
  }: ButtonProps): React.ReactNode {
31
31
  const tokens = useComponentTheme(theme);
32
- const backgroundColor = variant === "solid" ? tokens.accent : undefined;
32
+ // a disabled solid button reads as muted, so it never looks like a live call to action
33
+ const backgroundColor =
34
+ variant === "solid" ? (isDisabled ? tokens.border : tokens.accent) : undefined;
33
35
  const foreground =
34
36
  variant === "solid"
35
- ? tokens.accentInk
37
+ ? isDisabled
38
+ ? tokens.textMuted
39
+ : tokens.accentInk
36
40
  : variant === "accent-text"
37
41
  ? tokens.accent
38
42
  : tokens.textDim;
@@ -8,12 +8,13 @@
8
8
  import React from "react";
9
9
  import type { Theme } from "../../theme";
10
10
  import { useComponentTheme } from "../theme-context";
11
- import { FRAME_BORDER_STYLE } from "./frame";
11
+ import { FRAME_BORDER_STYLE, frameTitle } from "./frame";
12
12
 
13
13
  export interface CardProps {
14
14
  title?: string;
15
15
  /** Number of content rows inside the border; the box adds the two border rows. */
16
16
  contentRows: number;
17
+ width?: number;
17
18
  borderColor?: string;
18
19
  backgroundColor?: string;
19
20
  marginLeft?: number;
@@ -30,6 +31,7 @@ export function cardHeight(contentRows: number): number {
30
31
  export function Card({
31
32
  title,
32
33
  contentRows,
34
+ width,
33
35
  borderColor,
34
36
  backgroundColor,
35
37
  marginLeft,
@@ -43,6 +45,7 @@ export function Card({
43
45
  <box
44
46
  style={{
45
47
  height: cardHeight(contentRows),
48
+ width,
46
49
  marginLeft,
47
50
  marginRight,
48
51
  border: true,
@@ -52,7 +55,7 @@ export function Card({
52
55
  flexDirection: "column",
53
56
  paddingLeft: 1,
54
57
  }}
55
- title={title}
58
+ title={frameTitle(title)}
56
59
  >
57
60
  {children}
58
61
  </box>
@@ -8,7 +8,7 @@
8
8
  import React from "react";
9
9
  import type { Theme } from "../../theme";
10
10
  import { useComponentTheme } from "../theme-context";
11
- import { FRAME_BORDER_STYLE } from "./frame";
11
+ import { FRAME_BORDER_STYLE, frameTitle } from "./frame";
12
12
 
13
13
  export interface DialogProps {
14
14
  isOpen: boolean;
@@ -62,7 +62,7 @@ export function Dialog({
62
62
  backgroundColor: background,
63
63
  flexDirection: "column",
64
64
  }}
65
- title={title}
65
+ title={frameTitle(title)}
66
66
  >
67
67
  {children}
68
68
  </box>
@@ -0,0 +1,20 @@
1
+ import React from "react";
2
+ import { DARK } from "../../theme";
3
+ import type { Story, StoryMeta } from "../story";
4
+ import { DialogActions } from "./DialogActions";
5
+
6
+ export const meta: StoryMeta = { title: "Primitives/DialogActions" };
7
+
8
+ const noop = () => {};
9
+
10
+ export const SaveCancel: Story = {
11
+ render: () => <DialogActions confirmLabel="save" onConfirm={noop} onCancel={noop} />,
12
+ expectedColors: [DARK.accent],
13
+ size: { width: 40, height: 3 },
14
+ };
15
+
16
+ export const DeleteCancel: Story = {
17
+ render: () => <DialogActions confirmLabel="delete" onConfirm={noop} onCancel={noop} />,
18
+ expectedColors: [DARK.accent],
19
+ size: { width: 40, height: 3 },
20
+ };
@@ -0,0 +1,39 @@
1
+ /** The one action row every overlay uses: a solid confirm word-button and a plain cancel, lowercased. */
2
+
3
+ import React from "react";
4
+ import type { Theme } from "../../theme";
5
+ import { Toolbar } from "./Toolbar";
6
+ import { Button } from "./Button";
7
+
8
+ export interface DialogActionsProps {
9
+ confirmLabel: string;
10
+ onConfirm: () => void;
11
+ onCancel: () => void;
12
+ cancelLabel?: string;
13
+ confirmDisabled?: boolean;
14
+ theme?: Theme;
15
+ }
16
+
17
+ export function DialogActions({
18
+ confirmLabel,
19
+ onConfirm,
20
+ onCancel,
21
+ cancelLabel = "cancel",
22
+ confirmDisabled = false,
23
+ theme,
24
+ }: DialogActionsProps): React.ReactNode {
25
+ return (
26
+ <Toolbar>
27
+ <Button
28
+ variant="solid"
29
+ marginRight={2}
30
+ onPress={onConfirm}
31
+ isDisabled={confirmDisabled}
32
+ theme={theme}
33
+ >
34
+ {` ${confirmLabel} `}
35
+ </Button>
36
+ <Button onPress={onCancel} theme={theme}>{` ${cancelLabel} `}</Button>
37
+ </Toolbar>
38
+ );
39
+ }
@@ -0,0 +1,17 @@
1
+ import React from "react";
2
+ import { DARK } from "../../theme";
3
+ import type { Story, StoryMeta } from "../story";
4
+ import { Switch } from "./Switch";
5
+
6
+ export const meta: StoryMeta = { title: "Primitives/Switch" };
7
+
8
+ export const On: Story = {
9
+ render: () => <Switch on onToggle={() => {}} theme={DARK} />,
10
+ expectedColors: [DARK.accent],
11
+ size: { width: 8, height: 1 },
12
+ };
13
+
14
+ export const Off: Story = {
15
+ render: () => <Switch on={false} onToggle={() => {}} theme={DARK} />,
16
+ size: { width: 8, height: 1 },
17
+ };
@@ -0,0 +1,32 @@
1
+ /**
2
+ * On/off switch: a pill with a knob that sits right (accent) when on and left
3
+ * (dim) when off. `onToggle` unifies a mouse click and keyboard activation, so
4
+ * the dialog grammar and a pointer both flip it.
5
+ */
6
+
7
+ import React from "react";
8
+ import type { MouseEvent } from "@opentui/core";
9
+ import type { Theme } from "../../theme";
10
+ import { useComponentTheme } from "../theme-context";
11
+
12
+ export interface SwitchProps {
13
+ on: boolean;
14
+ onToggle: () => void;
15
+ theme?: Theme;
16
+ }
17
+
18
+ export function Switch({ on, onToggle, theme }: SwitchProps): React.ReactNode {
19
+ const tokens = useComponentTheme(theme);
20
+
21
+ return (
22
+ <box
23
+ onMouseUp={(event: MouseEvent) => {
24
+ event.stopPropagation();
25
+ onToggle();
26
+ }}
27
+ style={{ flexShrink: 0 }}
28
+ >
29
+ <text fg={on ? tokens.accent : tokens.textDim}>{on ? "( ●)" : "(● )"}</text>
30
+ </box>
31
+ );
32
+ }
@@ -13,6 +13,8 @@ export interface TreeProps {
13
13
  showStatus?: boolean;
14
14
  /** Drop the folder/file glyph prefix, for a plain text tree (e.g. the settings nav). */
15
15
  hideIcons?: boolean;
16
+ /** The selected row's backdrop; defaults to `elevated` (invisible on an elevated dialog, use `border` there). */
17
+ selectedBackground?: string;
16
18
  indentWidth?: number;
17
19
  icons?: TreeIcons;
18
20
  onSelect?: (id: string) => void;
@@ -41,6 +43,7 @@ export function Tree({
41
43
  flattenEmptyDirectories,
42
44
  showStatus,
43
45
  hideIcons,
46
+ selectedBackground,
44
47
  indentWidth = 2,
45
48
  icons = NERD_TREE_ICONS,
46
49
  onSelect,
@@ -48,6 +51,7 @@ export function Tree({
48
51
  theme,
49
52
  }: TreeProps): React.ReactNode {
50
53
  const tokens = theme ?? DARK;
54
+ const selectedBackdrop = selectedBackground ?? tokens.elevated;
51
55
  const rows = flattenTree(nodes, { expandedIds, flattenEmptyDirectories });
52
56
 
53
57
  return (
@@ -71,7 +75,7 @@ export function Tree({
71
75
  flexDirection: "row",
72
76
  paddingLeft: 1 + row.depth * indentWidth,
73
77
  paddingRight: 1,
74
- backgroundColor: selected ? tokens.elevated : undefined,
78
+ backgroundColor: selected ? selectedBackdrop : undefined,
75
79
  }}
76
80
  onMouseUp={() => (row.isFolder ? onToggle?.(row.id) : onSelect?.(row.id))}
77
81
  >
@@ -10,3 +10,8 @@ import type { BorderStyle } from "@opentui/core";
10
10
 
11
11
  /** Corner style shared by every bordered frame in the app: square, not rounded. */
12
12
  export const FRAME_BORDER_STYLE: BorderStyle = "single";
13
+
14
+ /** Every frame's border title renders lowercase, so the convention holds without each caller minding case. */
15
+ export function frameTitle(title: string | undefined): string | undefined {
16
+ return title?.toLowerCase();
17
+ }
@@ -33,7 +33,7 @@ export function projectName(workspace: WorkspaceKey): string {
33
33
  export type InboxRow =
34
34
  | { kind: "section"; id: string; label: string }
35
35
  | { kind: "project"; id: string; label: string }
36
- | { kind: "thread"; id: string; session: Thread; selectionIndex: number };
36
+ | { kind: "thread"; id: string; session: Thread; selectionIndex: number; depth: number };
37
37
 
38
38
  export interface GroupedInbox {
39
39
  rows: InboxRow[];
@@ -78,14 +78,14 @@ export function groupInbox(
78
78
 
79
79
  const rows: InboxRow[] = [];
80
80
  const ordered: Thread[] = [];
81
- const pushThread = (session: Thread): void => {
82
- rows.push({ kind: "thread", id: session.id, session, selectionIndex: ordered.length });
81
+ const pushThread = (session: Thread, depth: number): void => {
82
+ rows.push({ kind: "thread", id: session.id, session, selectionIndex: ordered.length, depth });
83
83
  ordered.push(session);
84
84
  };
85
85
 
86
86
  if (pinned.length > 0) {
87
87
  rows.push({ kind: "section", id: "section:pinned", label: "Starred" });
88
- for (const session of pinned) pushThread(session);
88
+ for (const session of pinned) pushThread(session, 1);
89
89
  }
90
90
 
91
91
  const projectEntries = [...projects.entries()].sort((a, b) => a[1].name.localeCompare(b[1].name));
@@ -93,13 +93,14 @@ export function groupInbox(
93
93
  rows.push({ kind: "section", id: "section:projects", label: "Projects" });
94
94
  for (const [key, project] of projectEntries) {
95
95
  rows.push({ kind: "project", id: `project:${key}`, label: project.name });
96
- for (const session of project.sessions) pushThread(session);
96
+ // a project's threads cascade one level under its name
97
+ for (const session of project.sessions) pushThread(session, 2);
97
98
  }
98
99
  }
99
100
 
100
101
  if (standalone.length > 0) {
101
102
  rows.push({ kind: "section", id: "section:threads", label: "Threads" });
102
- for (const session of standalone) pushThread(session);
103
+ for (const session of standalone) pushThread(session, 1);
103
104
  }
104
105
 
105
106
  return { rows, ordered };
@@ -0,0 +1,58 @@
1
+ /**
2
+ * UI state for the share dialog, held in a zustand store rather than juggled
3
+ * across component state: which category is open (share externally / export),
4
+ * whether the link wizard is showing, and the wizard's draft (name, auth,
5
+ * allowlist). The links themselves live on the thread; this is only the
6
+ * ephemeral surface state.
7
+ */
8
+
9
+ import { createStore } from "zustand/vanilla";
10
+ import { useStore } from "zustand";
11
+
12
+ export type ShareCategory = "external" | "export";
13
+
14
+ export interface ShareWizardDraft {
15
+ /** The link being edited, or null when creating a new one. */
16
+ editingId: string | null;
17
+ name: string;
18
+ requireAuth: boolean;
19
+ allowlist: string[];
20
+ }
21
+
22
+ export interface ShareDialogState {
23
+ category: ShareCategory;
24
+ /** Null = the links list; a draft = the new/edit wizard. */
25
+ wizard: ShareWizardDraft | null;
26
+ setCategory: (category: ShareCategory) => void;
27
+ openWizard: (draft: ShareWizardDraft) => void;
28
+ closeWizard: () => void;
29
+ setName: (name: string) => void;
30
+ setRequireAuth: (requireAuth: boolean) => void;
31
+ setAllowlist: (allowlist: string[]) => void;
32
+ /** Reset to the opening state (links list, external tab) when the dialog opens. */
33
+ reset: () => void;
34
+ }
35
+
36
+ /** A fresh wizard draft, prefilled with the thread name for a new link. */
37
+ export function newWizardDraft(threadName: string): ShareWizardDraft {
38
+ return { editingId: null, name: threadName, requireAuth: false, allowlist: [] };
39
+ }
40
+
41
+ export const shareDialogStore = createStore<ShareDialogState>((set) => ({
42
+ category: "external",
43
+ wizard: null,
44
+ setCategory: (category) => set({ category }),
45
+ openWizard: (wizard) => set({ wizard }),
46
+ closeWizard: () => set({ wizard: null }),
47
+ setName: (name) => set((state) => (state.wizard ? { wizard: { ...state.wizard, name } } : {})),
48
+ setRequireAuth: (requireAuth) =>
49
+ set((state) => (state.wizard ? { wizard: { ...state.wizard, requireAuth } } : {})),
50
+ setAllowlist: (allowlist) =>
51
+ set((state) => (state.wizard ? { wizard: { ...state.wizard, allowlist } } : {})),
52
+ reset: () => set({ category: "external", wizard: null }),
53
+ }));
54
+
55
+ /** Subscribe a component to the share dialog store. */
56
+ export function useShareDialog<T>(selector: (state: ShareDialogState) => T): T {
57
+ return useStore(shareDialogStore, selector);
58
+ }
@@ -13,9 +13,10 @@ import { ThemeProvider } from "./theme-context";
13
13
  import { buildStoryTree, loadStories, type LoadedStory } from "./story";
14
14
  import { PaneGrid } from "./PaneGrid";
15
15
  import { ShellHeader } from "./ShellHeader";
16
+ import { AnsiScreen } from "./AnsiScreen";
16
17
  import { Tree } from "./primitives/Tree";
17
18
  import { NERD } from "./primitives/icons";
18
- import { allFolderIds, flattenTree } from "./primitives/tree-model";
19
+ import { flattenTree } from "./primitives/tree-model";
19
20
 
20
21
  interface StoriesAppProps {
21
22
  stories: LoadedStory[];
@@ -30,8 +31,8 @@ function StoriesApp({ stories, onExit }: StoriesAppProps): React.ReactNode {
30
31
  );
31
32
  const firstStoryId = `${stories[0]!.moduleTitle}/${stories[0]!.storyName}`;
32
33
 
33
- const [expandedIds, setExpandedIds] = useState<ReadonlySet<string>>(() =>
34
- allFolderIds(treeNodes),
34
+ const [expandedIds, setExpandedIds] = useState<ReadonlySet<string>>(
35
+ () => new Set(treeNodes.filter((node) => node.children !== undefined).map((node) => node.id)),
35
36
  );
36
37
  const [selectedId, setSelectedId] = useState(firstStoryId);
37
38
  const [openedId, setOpenedId] = useState(firstStoryId);
@@ -126,7 +127,15 @@ function StoriesApp({ stories, onExit }: StoriesAppProps): React.ReactNode {
126
127
  <scrollbox style={{ flexGrow: 1, paddingLeft: 1, paddingTop: 1 }} focused={false}>
127
128
  {/* remount per story so component-local state never leaks across */}
128
129
  <box key={openedId} style={{ flexDirection: "column" }}>
129
- {opened.story.render()}
130
+ {opened.story.ansi ? (
131
+ <AnsiScreen
132
+ ansi={opened.story.ansi()}
133
+ cols={opened.story.size?.width ?? 80}
134
+ rows={opened.story.size?.height ?? 24}
135
+ />
136
+ ) : (
137
+ opened.story.render?.()
138
+ )}
130
139
  </box>
131
140
  </scrollbox>
132
141
  ) : (
@@ -5,11 +5,13 @@
5
5
  * stories file fails the harness, so the safety net cannot silently thin.
6
6
  */
7
7
 
8
+ import React from "react";
8
9
  import { describe, expect, test } from "bun:test";
9
10
  import { testRender } from "@opentui/react/test-utils";
10
11
  import { RGBA } from "@opentui/core";
11
12
  import { componentFilesMissingStories, loadStories } from "./story";
12
- import { allowEventLoopUpdates } from "../test-support";
13
+ import { AnsiScreen } from "./AnsiScreen";
14
+ import { settle } from "../test-support";
13
15
 
14
16
  const stories = await loadStories();
15
17
 
@@ -34,12 +36,18 @@ describe("stories catalog", () => {
34
36
  test(`${moduleTitle}/${storyName} renders, snapshots, and carries its colors`, async () => {
35
37
  // Arrange
36
38
  const size = story.size ?? { width: 80, height: 24 };
39
+ const node = story.ansi ? (
40
+ <AnsiScreen ansi={story.ansi()} cols={size.width} rows={size.height} />
41
+ ) : (
42
+ story.render!()
43
+ );
37
44
 
38
45
  // Act
39
- const setup = await testRender(story.render(), size);
46
+ const setup = await testRender(node, size);
40
47
 
41
- allowEventLoopUpdates();
42
- await setup.waitForVisualIdle();
48
+ // settle, not just visual-idle: async stories (a file read, a virtualizer's
49
+ // first measure) commit after React's own scheduler, invisible to idle
50
+ await settle(setup);
43
51
  const frame = setup.captureCharFrame();
44
52
 
45
53
  // Assert
@@ -1,12 +1,12 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { buildStoryTree, type LoadedStory } from "./story";
2
+ import { buildStoryTree, type LoadedStory, type StorySection } from "./story";
3
3
  import type { TreeNode } from "./primitives/tree-model";
4
4
 
5
5
  /** A story object whose render is never invoked in these structural tests. */
6
6
  const STUB = { render: () => null };
7
7
 
8
- function story(moduleTitle: string, storyName: string): LoadedStory {
9
- return { moduleTitle, storyName, story: STUB };
8
+ function story(moduleTitle: string, storyName: string, section: StorySection = "App"): LoadedStory {
9
+ return { section, moduleTitle, storyName, story: STUB };
10
10
  }
11
11
 
12
12
  function childLabels(node: TreeNode): string[] {
@@ -14,19 +14,31 @@ function childLabels(node: TreeNode): string[] {
14
14
  }
15
15
 
16
16
  describe("buildStoryTree", () => {
17
- test("splits moduleTitle on / into nested folders keyed by their path", () => {
17
+ test("groups the top level by section", () => {
18
+ const tree = buildStoryTree([
19
+ story("Primitives/Tree", "Default"),
20
+ story("Collaborator Join", "JoinSplash", "SSH"),
21
+ ]);
22
+
23
+ expect(tree.map((node) => [node.id, node.label])).toEqual([
24
+ ["App", "App"],
25
+ ["SSH", "SSH"],
26
+ ]);
27
+ });
28
+
29
+ test("splits moduleTitle on / into nested folders under the section", () => {
18
30
  // Arrange
19
31
  const stories = [story("Primitives/Tree", "Default"), story("Layout/AppShell", "Default")];
20
32
 
21
33
  // Act
22
- const tree = buildStoryTree(stories);
34
+ const app = buildStoryTree(stories)[0]!;
23
35
 
24
36
  // Assert
25
- expect(tree.map((node) => [node.id, node.label])).toEqual([
26
- ["Primitives", "Primitives"],
27
- ["Layout", "Layout"],
37
+ expect(app.children!.map((node) => [node.id, node.label])).toEqual([
38
+ ["App/Primitives", "Primitives"],
39
+ ["App/Layout", "Layout"],
28
40
  ]);
29
- expect(tree[0]!.children!.map((node) => node.id)).toEqual(["Primitives/Tree"]);
41
+ expect(app.children![0]!.children!.map((node) => node.id)).toEqual(["App/Primitives/Tree"]);
30
42
  });
31
43
 
32
44
  test("groups sibling components under one shared parent folder", () => {
@@ -34,11 +46,11 @@ describe("buildStoryTree", () => {
34
46
  const stories = [story("Primitives/Tree", "Default"), story("Primitives/Card", "Default")];
35
47
 
36
48
  // Act
37
- const tree = buildStoryTree(stories);
49
+ const app = buildStoryTree(stories)[0]!;
38
50
 
39
51
  // Assert
40
- expect(tree).toHaveLength(1);
41
- expect(childLabels(tree[0]!)).toEqual(["Tree", "Card"]);
52
+ expect(app.children).toHaveLength(1);
53
+ expect(childLabels(app.children![0]!)).toEqual(["Tree", "Card"]);
42
54
  });
43
55
 
44
56
  test("nests to the full depth of the moduleTitle path", () => {
@@ -46,13 +58,12 @@ describe("buildStoryTree", () => {
46
58
  const stories = [story("A/B/C", "Only")];
47
59
 
48
60
  // Act
49
- const tree = buildStoryTree(stories);
50
- const a = tree[0]!;
61
+ const a = buildStoryTree(stories)[0]!.children![0]!;
51
62
  const b = a.children![0]!;
52
63
 
53
64
  // Assert
54
- expect([a.id, b.id]).toEqual(["A", "A/B"]);
55
- expect(b.children![0]!.id).toBe("A/B/C");
65
+ expect([a.id, b.id]).toEqual(["App/A", "App/A/B"]);
66
+ expect(b.children![0]!.id).toBe("App/A/B/C");
56
67
  });
57
68
 
58
69
  test("adds each story as a leaf keyed `${moduleTitle}/${storyName}` under its component", () => {
@@ -60,11 +71,10 @@ describe("buildStoryTree", () => {
60
71
  const stories = [story("Primitives/Tree", "Empty"), story("Primitives/Tree", "Deep")];
61
72
 
62
73
  // Act
63
- const tree = buildStoryTree(stories);
64
- const component = tree[0]!.children![0]!;
74
+ const component = buildStoryTree(stories)[0]!.children![0]!.children![0]!;
65
75
 
66
76
  // Assert
67
- expect(component.id).toBe("Primitives/Tree");
77
+ expect(component.id).toBe("App/Primitives/Tree");
68
78
  expect(component.children!.map((leaf) => [leaf.id, leaf.label])).toEqual([
69
79
  ["Primitives/Tree/Empty", "Empty"],
70
80
  ["Primitives/Tree/Deep", "Deep"],
@@ -76,8 +86,7 @@ describe("buildStoryTree", () => {
76
86
  const stories = [story("Primitives/Card", "Card")];
77
87
 
78
88
  // Act
79
- const tree = buildStoryTree(stories);
80
- const hoisted = tree[0]!.children![0]!;
89
+ const hoisted = buildStoryTree(stories)[0]!.children![0]!.children![0]!;
81
90
 
82
91
  // Assert
83
92
  expect(hoisted.children).toBeUndefined();
@@ -90,11 +99,10 @@ describe("buildStoryTree", () => {
90
99
  const stories = [story("Primitives/Card", "Default")];
91
100
 
92
101
  // Act
93
- const tree = buildStoryTree(stories);
94
- const component = tree[0]!.children![0]!;
102
+ const component = buildStoryTree(stories)[0]!.children![0]!.children![0]!;
95
103
 
96
104
  // Assert
97
- expect(component.id).toBe("Primitives/Card");
105
+ expect(component.id).toBe("App/Primitives/Card");
98
106
  expect(component.children!.map((leaf) => leaf.id)).toEqual(["Primitives/Card/Default"]);
99
107
  });
100
108
  });
@@ -15,7 +15,9 @@ export interface StoryMeta {
15
15
  }
16
16
 
17
17
  export interface Story {
18
- render: () => React.ReactNode;
18
+ render?: () => React.ReactNode;
19
+ /** Raw ANSI to paint through AnsiScreen instead of a React tree; exactly one of render or ansi is set. */
20
+ ansi?: () => string;
19
21
  /**
20
22
  * Colors (hex tokens) that must appear among the rendered frame's styled
21
23
  * spans - the color regression net for color-bearing stories.
@@ -25,18 +27,21 @@ export interface Story {
25
27
  size?: { width: number; height: number };
26
28
  }
27
29
 
30
+ export type StorySection = "App" | "SSH";
31
+
28
32
  export interface LoadedStory {
33
+ section: StorySection;
29
34
  moduleTitle: string;
30
35
  storyName: string;
31
36
  story: Story;
32
37
  }
33
38
 
34
39
  export function isStory(value: unknown): value is Story {
40
+ if (typeof value !== "object" || value === null) return false;
41
+
35
42
  return (
36
- typeof value === "object" &&
37
- value !== null &&
38
- "render" in value &&
39
- typeof value.render === "function"
43
+ ("render" in value && typeof value.render === "function") ||
44
+ ("ansi" in value && typeof value.ansi === "function")
40
45
  );
41
46
  }
42
47
 
@@ -49,22 +54,34 @@ function isStoryMeta(value: unknown): value is StoryMeta {
49
54
  );
50
55
  }
51
56
 
52
- /** Import every *.stories.tsx next to the components and flatten the exports. */
53
- export async function loadStories(): Promise<LoadedStory[]> {
54
- const glob = new Bun.Glob("**/*.stories.tsx");
55
- const files = [...glob.scanSync({ cwd: import.meta.dir })].sort();
56
- const loaded: LoadedStory[] = [];
57
+ const GATEWAY_SRC_DIR = `${import.meta.dir}/../../../gateway/src`;
58
+
59
+ async function collectStories(
60
+ dir: string,
61
+ pattern: string,
62
+ section: StorySection,
63
+ loaded: LoadedStory[],
64
+ ): Promise<void> {
65
+ const glob = new Bun.Glob(pattern);
66
+ const files = [...glob.scanSync({ cwd: dir })].sort();
57
67
 
58
68
  for (const file of files) {
59
- const moduleExports = await import(`${import.meta.dir}/${file}`);
69
+ const moduleExports = await import(`${dir}/${file}`);
60
70
  const meta = moduleExports["meta"];
61
- const moduleTitle = isStoryMeta(meta) ? meta.title : file.replace(/\.stories\.tsx$/, "");
71
+ const moduleTitle = isStoryMeta(meta) ? meta.title : file.replace(/\.stories\.tsx?$/, "");
62
72
 
63
73
  for (const [exportName, exported] of Object.entries(moduleExports)) {
64
74
  if (exportName === "meta" || !isStory(exported)) continue;
65
- loaded.push({ moduleTitle, storyName: exportName, story: exported });
75
+ loaded.push({ section, moduleTitle, storyName: exportName, story: exported });
66
76
  }
67
77
  }
78
+ }
79
+
80
+ export async function loadStories(): Promise<LoadedStory[]> {
81
+ const loaded: LoadedStory[] = [];
82
+
83
+ await collectStories(import.meta.dir, "**/*.stories.tsx", "App", loaded);
84
+ await collectStories(GATEWAY_SRC_DIR, "**/*.stories.ts", "SSH", loaded);
68
85
 
69
86
  return loaded;
70
87
  }
@@ -102,11 +119,11 @@ export function buildStoryTree(stories: LoadedStory[]): TreeNode[] {
102
119
  const roots: TreeNode[] = [];
103
120
  const foldersByPath = new Map<string, StoryTreeFolder>();
104
121
 
105
- for (const { moduleTitle, storyName } of stories) {
122
+ for (const { section, moduleTitle, storyName } of stories) {
106
123
  let siblings = roots;
107
124
  let path = "";
108
125
 
109
- for (const segment of moduleTitle.split("/")) {
126
+ for (const segment of [section, ...moduleTitle.split("/")]) {
110
127
  path = path === "" ? segment : `${path}/${segment}`;
111
128
  let folder = foldersByPath.get(path);
112
129
  if (folder === undefined) {
@@ -143,6 +143,24 @@ describe("loadConfig", () => {
143
143
  }
144
144
  });
145
145
 
146
+ test("[ui] default_verdict defaults to approve and parses request_changes", () => {
147
+ // Arrange
148
+ const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg-verdict-"));
149
+ const path = join(dir, "config.toml");
150
+
151
+ writeFileSync(path, `[ui]\ndefault_verdict = "request_changes"\n`);
152
+
153
+ try {
154
+ // Assert
155
+ expect(loadConfig({ userConfigPath: "/nonexistent/config.toml" }).ui.defaultVerdict).toBe(
156
+ "approve",
157
+ );
158
+ expect(loadConfig({ userConfigPath: path }).ui.defaultVerdict).toBe("request_changes");
159
+ } finally {
160
+ rmSync(dir, { recursive: true, force: true });
161
+ }
162
+ });
163
+
146
164
  test("[skills] path overrides the default and a malformed value never discards the config", () => {
147
165
  const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg-skills-"));
148
166
  const path = join(dir, "config.toml");