@cueloop/client 0.1.0-alpha.64 → 0.1.0-alpha.66

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 (109) hide show
  1. package/package.json +5 -5
  2. package/src/App.autoclose.test.tsx +2 -2
  3. package/src/App.diff.test.tsx +8 -73
  4. package/src/App.export.test.tsx +2 -2
  5. package/src/App.plan-review.test.tsx +60 -412
  6. package/src/App.prototype.test.tsx +1 -8
  7. package/src/App.readonly.test.tsx +85 -62
  8. package/src/App.rendering.test.tsx +5 -6
  9. package/src/App.submit-confirm.test.tsx +32 -70
  10. package/src/App.test.tsx +136 -66
  11. package/src/App.toast.test.tsx +17 -8
  12. package/src/App.tsx +243 -284
  13. package/src/App.walk.test.tsx +1 -1
  14. package/src/app-screens.tsx +67 -52
  15. package/src/app-view-model.ts +6 -100
  16. package/src/components/AnnotationCard.stories.tsx +1 -1
  17. package/src/components/AnnotationCard.tsx +2 -2
  18. package/src/components/AppShell.stories.tsx +84 -0
  19. package/src/components/AppShell.tsx +127 -0
  20. package/src/components/Breadcrumb.stories.tsx +1 -1
  21. package/src/components/ChangesColumn.stories.tsx +39 -0
  22. package/src/components/ChangesColumn.tsx +147 -0
  23. package/src/components/CodeBlock.stories.tsx +1 -1
  24. package/src/components/CompletionOverlay.stories.tsx +1 -1
  25. package/src/components/ConfirmCard.stories.tsx +1 -1
  26. package/src/components/ConfirmCard.tsx +3 -4
  27. package/src/components/ConfirmDialog.stories.tsx +1 -1
  28. package/src/components/DiffSheet.stories.tsx +1 -1
  29. package/src/components/InboxList.stories.tsx +23 -8
  30. package/src/components/InboxList.tsx +45 -19
  31. package/src/components/KeybindsDialog.stories.tsx +1 -1
  32. package/src/components/KeybindsDialog.tsx +61 -17
  33. package/src/components/MarkerPopover.stories.tsx +1 -1
  34. package/src/components/MenuBar.stories.tsx +3 -12
  35. package/src/components/MenuBar.tsx +25 -56
  36. package/src/components/PromptDialog.stories.tsx +1 -1
  37. package/src/components/PrototypeSheet.stories.tsx +1 -1
  38. package/src/components/ReviewPanel.stories.tsx +15 -1
  39. package/src/components/ReviewRail.stories.tsx +15 -1
  40. package/src/components/ReviewRail.tsx +13 -6
  41. package/src/components/SettingsDialog.stories.tsx +7 -1
  42. package/src/components/SettingsDialog.tsx +112 -66
  43. package/src/components/SettingsRows.stories.tsx +1 -1
  44. package/src/components/ShellHeader.stories.tsx +50 -0
  45. package/src/components/ShellHeader.tsx +137 -0
  46. package/src/components/ThreadFooter.stories.tsx +30 -0
  47. package/src/components/ThreadFooter.tsx +49 -0
  48. package/src/components/ThreadView.selection.test.tsx +812 -0
  49. package/src/components/ThreadView.slash.test.tsx +113 -0
  50. package/src/components/ThreadView.stories.tsx +26 -0
  51. package/src/components/ThreadView.tsx +1708 -0
  52. package/src/components/ThreadsSidebar.stories.tsx +36 -0
  53. package/src/components/ThreadsSidebar.tsx +57 -0
  54. package/src/components/Toast.stories.tsx +1 -1
  55. package/src/components/TreePane.stories.tsx +106 -0
  56. package/src/components/TreePane.tsx +156 -0
  57. package/src/components/WalkWizard.stories.tsx +1 -1
  58. package/src/components/__snapshots__/stories.test.tsx.snap +463 -305
  59. package/src/components/agent-launcher.stories.tsx +1 -1
  60. package/src/components/file-tree.test.ts +45 -0
  61. package/src/components/file-tree.ts +57 -0
  62. package/src/components/pages/Workspace.stories.tsx +181 -0
  63. package/src/components/primitives/Button.stories.tsx +1 -1
  64. package/src/components/primitives/Card.stories.tsx +1 -1
  65. package/src/components/primitives/Dialog.stories.tsx +1 -1
  66. package/src/components/primitives/IconButton.stories.tsx +36 -0
  67. package/src/components/primitives/IconButton.tsx +43 -0
  68. package/src/components/primitives/StatusBar.stories.tsx +1 -1
  69. package/src/components/primitives/Tabs.stories.tsx +1 -1
  70. package/src/components/primitives/Toolbar.stories.tsx +1 -1
  71. package/src/components/primitives/Tree.stories.tsx +124 -0
  72. package/src/components/primitives/Tree.tsx +87 -0
  73. package/src/components/primitives/icons.ts +38 -0
  74. package/src/components/primitives/tree-model.test.ts +99 -0
  75. package/src/components/primitives/tree-model.ts +137 -0
  76. package/src/components/quick-actions-editor.stories.tsx +1 -1
  77. package/src/components/session-tree.test.ts +114 -0
  78. package/src/components/session-tree.ts +94 -0
  79. package/src/components/stories-app.tsx +142 -82
  80. package/src/components/story-tree.test.ts +100 -0
  81. package/src/components/story.ts +55 -0
  82. package/src/components/theme-context.stories.tsx +1 -1
  83. package/src/diff-hunk-curate.ts +4 -104
  84. package/src/index.ts +1 -0
  85. package/src/intent-dispatch.test.ts +136 -3
  86. package/src/intent-dispatch.ts +92 -24
  87. package/src/key-bindings.test.ts +1 -1
  88. package/src/keymap.test.ts +4 -4
  89. package/src/keymap.ts +14 -7
  90. package/src/session-controller.curate.test.ts +89 -2
  91. package/src/session-controller.share.test.ts +195 -55
  92. package/src/session-controller.tree.test.ts +228 -0
  93. package/src/session-controller.ts +331 -72
  94. package/src/share.ts +114 -16
  95. package/src/test-support.test.ts +193 -1
  96. package/src/test-support.ts +64 -0
  97. package/src/theme.ts +4 -4
  98. package/src/thread-chords.test.ts +154 -0
  99. package/src/thread-chords.ts +144 -0
  100. package/src/thread-selection.test.ts +210 -0
  101. package/src/thread-selection.ts +243 -0
  102. package/src/tree-view.test.ts +128 -0
  103. package/src/tree-view.ts +148 -0
  104. package/src/use-settings-dialog.tsx +9 -0
  105. package/src/view-plan.ts +29 -12
  106. package/src/App.review-panel.test.tsx +0 -143
  107. package/src/components/PlanSheet.stories.tsx +0 -113
  108. package/src/components/PlanSheet.tsx +0 -419
  109. package/src/components/plan-sheet-run-style.ts +0 -88
@@ -4,7 +4,7 @@ import type { Story, StoryMeta } from "./story";
4
4
  import { AgentLauncher } from "./agent-launcher";
5
5
  import { fixturePlanSession } from "./story-fixtures";
6
6
 
7
- export const meta: StoryMeta = { title: "AgentLauncher" };
7
+ export const meta: StoryMeta = { title: "Chrome/AgentLauncher" };
8
8
 
9
9
  /** The launcher expects the rail's narrow, height-constrained column. */
10
10
  function RailFrame({ children }: { children: React.ReactNode }): React.ReactNode {
@@ -0,0 +1,45 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { DiffFileContents } from "@cueloop/schema";
3
+ import { buildFileTree } from "./file-tree";
4
+
5
+ function file(path: string, status: DiffFileContents["status"] = "modified"): DiffFileContents {
6
+ return { path, oldContents: "a", newContents: "b", status };
7
+ }
8
+
9
+ describe("buildFileTree", () => {
10
+ test("nests files under their directories and keeps the full path as the leaf id", () => {
11
+ // Arrange
12
+ const files = [file("src/app/main.ts"), file("src/app/util.ts"), file("README.md")];
13
+
14
+ // Act
15
+ const tree = buildFileTree(files);
16
+
17
+ // Assert - one src folder over one app folder over two leaves, plus a root file
18
+ expect(tree.map((node) => node.label)).toEqual(["src", "README.md"]);
19
+ const app = tree[0]!.children![0]!;
20
+
21
+ expect(app.label).toBe("app");
22
+ expect(app.children!.map((leaf) => leaf.id)).toEqual(["src/app/main.ts", "src/app/util.ts"]);
23
+ });
24
+
25
+ test("carries the git status onto each leaf so the tree can tint it", () => {
26
+ // Act
27
+ const tree = buildFileTree([file("a.ts", "added"), file("b.ts", "deleted")]);
28
+
29
+ // Assert
30
+ expect(tree.map((leaf) => leaf.status)).toEqual(["added", "deleted"]);
31
+ });
32
+
33
+ test("merges files that share a directory into one folder node", () => {
34
+ // Act
35
+ const tree = buildFileTree([file("pkg/one.ts"), file("pkg/two.ts")]);
36
+
37
+ // Assert
38
+ expect(tree).toHaveLength(1);
39
+ expect(tree[0]!.children).toHaveLength(2);
40
+ });
41
+
42
+ test("returns an empty tree for no changes", () => {
43
+ expect(buildFileTree([])).toEqual([]);
44
+ });
45
+ });
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Builds a nested file tree from a diff's changed files, so the Changes column
3
+ * can browse them by directory. Each leaf keeps its repo-relative path as the id
4
+ * (what the diff rows are keyed by) and carries the git status for its tint.
5
+ */
6
+
7
+ import type { DiffFileContents, DiffFileStatus } from "@cueloop/schema";
8
+ import type { GitStatus, TreeNode } from "./primitives/tree-model";
9
+
10
+ /** The diff file statuses are a subset of the tree's git statuses. */
11
+ function gitStatusOf(status: DiffFileStatus): GitStatus {
12
+ return status;
13
+ }
14
+
15
+ interface MutableTreeNode {
16
+ id: string;
17
+ label: string;
18
+ status?: GitStatus;
19
+ children?: Map<string, MutableTreeNode>;
20
+ }
21
+
22
+ function freeze(node: MutableTreeNode): TreeNode {
23
+ if (node.children === undefined) return { id: node.id, label: node.label, status: node.status };
24
+
25
+ return {
26
+ id: node.id,
27
+ label: node.label,
28
+ children: [...node.children.values()].map(freeze),
29
+ };
30
+ }
31
+
32
+ /** Group changed files into a directory tree; leaves are keyed by their full path. */
33
+ export function buildFileTree(files: readonly DiffFileContents[]): TreeNode[] {
34
+ const roots = new Map<string, MutableTreeNode>();
35
+
36
+ for (const file of files) {
37
+ const segments = file.path.split("/").filter((segment) => segment.length > 0);
38
+ let level = roots;
39
+ let prefix = "";
40
+
41
+ segments.forEach((segment, index) => {
42
+ prefix = prefix.length > 0 ? `${prefix}/${segment}` : segment;
43
+ const isLeaf = index === segments.length - 1;
44
+ let node = level.get(segment);
45
+
46
+ if (node === undefined) {
47
+ node = isLeaf
48
+ ? { id: file.path, label: segment, status: gitStatusOf(file.status) }
49
+ : { id: prefix, label: segment, children: new Map() };
50
+ level.set(segment, node);
51
+ }
52
+ if (!isLeaf) level = node.children!;
53
+ });
54
+ }
55
+
56
+ return [...roots.values()].map(freeze);
57
+ }
@@ -0,0 +1,181 @@
1
+ import React from "react";
2
+ import { DARK } from "../../theme";
3
+ import type { Story, StoryMeta } from "../story";
4
+ import { AppShell } from "../AppShell";
5
+ import { Tree } from "../primitives/Tree";
6
+ import type { TreeNode } from "../primitives/tree-model";
7
+
8
+ export const meta: StoryMeta = { title: "Pages/Workspace" };
9
+
10
+ const NAVIGATION: TreeNode[] = [
11
+ {
12
+ id: "projects",
13
+ label: "Projects",
14
+ children: [
15
+ {
16
+ id: "cueloop",
17
+ label: "cueloop",
18
+ children: [
19
+ { id: "cueloop/review", label: "Review the app shell" },
20
+ { id: "cueloop/read", label: "Read Cueloop Repository" },
21
+ ],
22
+ },
23
+ ],
24
+ },
25
+ {
26
+ id: "threads",
27
+ label: "Threads",
28
+ children: [{ id: "threads/welcome", label: "Welcome to cueloop" }],
29
+ },
30
+ ];
31
+
32
+ const CHANGED_FILES: TreeNode[] = [
33
+ {
34
+ id: "packages",
35
+ label: "packages",
36
+ children: [
37
+ {
38
+ id: "client",
39
+ label: "client",
40
+ children: [
41
+ { id: "client/AppShell.tsx", label: "AppShell.tsx", status: "modified" },
42
+ { id: "client/Workspace.stories.tsx", label: "Workspace.stories.tsx", status: "added" },
43
+ ],
44
+ },
45
+ ],
46
+ },
47
+ { id: "README.md", label: "README.md", status: "modified" },
48
+ ];
49
+
50
+ interface CodeSampleLine {
51
+ number: number;
52
+ code: string;
53
+ }
54
+
55
+ const CODE_SAMPLE: readonly CodeSampleLine[] = [
56
+ { number: 17, code: "export function AppShell({" },
57
+ { number: 18, code: " header," },
58
+ { number: 19, code: " sidebar," },
59
+ { number: 20, code: " main," },
60
+ { number: 21, code: " inspector," },
61
+ { number: 22, code: "}: AppShellProps) {" },
62
+ { number: 23, code: " const tokens = theme ?? DARK;" },
63
+ { number: 24, code: " return <box>{main}</box>;" },
64
+ ];
65
+
66
+ function Sidebar(): React.ReactNode {
67
+ return (
68
+ <box style={{ flexDirection: "column", paddingTop: 1 }}>
69
+ <Tree
70
+ nodes={NAVIGATION}
71
+ expandedIds={new Set(["projects", "cueloop", "threads"])}
72
+ selectedId="cueloop/review"
73
+ />
74
+ </box>
75
+ );
76
+ }
77
+
78
+ function TranscriptLine({ children }: { children: React.ReactNode }): React.ReactNode {
79
+ return <text fg={DARK.textMuted}>{children}</text>;
80
+ }
81
+
82
+ function CommentRow(): React.ReactNode {
83
+ return (
84
+ <box
85
+ style={{
86
+ border: ["left"],
87
+ borderStyle: "single",
88
+ borderColor: DARK.accent,
89
+ flexDirection: "column",
90
+ paddingLeft: 1,
91
+ marginTop: 1,
92
+ marginBottom: 1,
93
+ }}
94
+ >
95
+ <text fg={DARK.textDim}>markus commented on AppShell.tsx</text>
96
+ <text fg={DARK.text}>The single rules read cleaner than an enclosing frame here.</text>
97
+ </box>
98
+ );
99
+ }
100
+
101
+ function Main(): React.ReactNode {
102
+ return (
103
+ <box style={{ flexDirection: "column", paddingLeft: 1, paddingTop: 1 }}>
104
+ <text fg={DARK.text}>Review the app shell</text>
105
+ <text> </text>
106
+ <TranscriptLine>
107
+ The three-pane layout divides regions with straight rules only.
108
+ </TranscriptLine>
109
+ <TranscriptLine>
110
+ Sidebar navigates, the transcript reads, the inspector shows code.
111
+ </TranscriptLine>
112
+ <CommentRow />
113
+ <TranscriptLine>Header and footer are thin label rows the shell rules itself.</TranscriptLine>
114
+ </box>
115
+ );
116
+ }
117
+
118
+ function CodePane(): React.ReactNode {
119
+ return (
120
+ <box style={{ flexDirection: "column" }}>
121
+ <text fg={DARK.textMuted}>AppShell.tsx</text>
122
+ <text> </text>
123
+ {CODE_SAMPLE.map((line) => (
124
+ <box key={line.number} style={{ flexDirection: "row" }}>
125
+ <text fg={DARK.textDim}>{String(line.number).padStart(3, " ")} </text>
126
+ <text fg={DARK.text}>{line.code}</text>
127
+ </box>
128
+ ))}
129
+ </box>
130
+ );
131
+ }
132
+
133
+ function Inspector(): React.ReactNode {
134
+ return (
135
+ <box style={{ flexDirection: "column", paddingLeft: 1, paddingTop: 1 }}>
136
+ <CodePane />
137
+ <text> </text>
138
+ <text fg={DARK.textDim}>Changed files</text>
139
+ <Tree
140
+ nodes={CHANGED_FILES}
141
+ expandedIds={new Set(["packages", "client"])}
142
+ selectedId="client/AppShell.tsx"
143
+ showStatus
144
+ />
145
+ </box>
146
+ );
147
+ }
148
+
149
+ function Header(): React.ReactNode {
150
+ return (
151
+ <box style={{ flexDirection: "row", paddingLeft: 1 }}>
152
+ <text fg={DARK.textMuted}>Review the app shell</text>
153
+ <box style={{ flexGrow: 1 }} />
154
+ <text fg={DARK.textDim}>cueloop / main</text>
155
+ </box>
156
+ );
157
+ }
158
+
159
+ function Footer(): React.ReactNode {
160
+ return (
161
+ <box style={{ flexDirection: "row", paddingLeft: 1 }}>
162
+ <text fg={DARK.textDim}>2 files changed</text>
163
+ <box style={{ flexGrow: 1 }} />
164
+ <text fg={DARK.textDim}>Fable 5</text>
165
+ </box>
166
+ );
167
+ }
168
+
169
+ export const ThreePane: Story = {
170
+ render: () => (
171
+ <AppShell
172
+ sidebar={<Sidebar />}
173
+ main={<Main />}
174
+ mainHeader={<Header />}
175
+ inspector={<Inspector />}
176
+ footer={<Footer />}
177
+ />
178
+ ),
179
+ expectedColors: [DARK.border, DARK.accent],
180
+ size: { width: 160, height: 42 },
181
+ };
@@ -4,7 +4,7 @@ import type { Story, StoryMeta } from "../story";
4
4
  import { Button } from "./Button";
5
5
  import { Toolbar } from "./Toolbar";
6
6
 
7
- export const meta: StoryMeta = { title: "primitives/Button" };
7
+ export const meta: StoryMeta = { title: "Primitives/Button" };
8
8
 
9
9
  export const Variants: Story = {
10
10
  render: () => (
@@ -3,7 +3,7 @@ import { DARK } from "../../theme";
3
3
  import type { Story, StoryMeta } from "../story";
4
4
  import { Card } from "./Card";
5
5
 
6
- export const meta: StoryMeta = { title: "primitives/Card" };
6
+ export const meta: StoryMeta = { title: "Primitives/Card" };
7
7
 
8
8
  export const Titled: Story = {
9
9
  render: () => (
@@ -3,7 +3,7 @@ import { DARK } from "../../theme";
3
3
  import type { Story, StoryMeta } from "../story";
4
4
  import { Dialog } from "./Dialog";
5
5
 
6
- export const meta: StoryMeta = { title: "primitives/Dialog" };
6
+ export const meta: StoryMeta = { title: "Primitives/Dialog" };
7
7
 
8
8
  export const Open: Story = {
9
9
  render: () => (
@@ -0,0 +1,36 @@
1
+ import React from "react";
2
+ import { DARK } from "../../theme";
3
+ import type { Story, StoryMeta } from "../story";
4
+ import { IconButton } from "./IconButton";
5
+ import { NERD } from "./icons";
6
+
7
+ export const meta: StoryMeta = { title: "Primitives/IconButton" };
8
+
9
+ function Row({ children }: { children: React.ReactNode }): React.ReactNode {
10
+ return (
11
+ <box style={{ flexDirection: "row", padding: 1, backgroundColor: DARK.panel }}>{children}</box>
12
+ );
13
+ }
14
+
15
+ export const States: Story = {
16
+ render: () => (
17
+ <Row>
18
+ <IconButton glyph={NERD.settings} onPress={() => {}} marginRight={3} />
19
+ <IconButton glyph={NERD.listTree} active onPress={() => {}} marginRight={3} />
20
+ <IconButton glyph={NERD.diff} disabled marginRight={3} />
21
+ <IconButton glyph={NERD.submit} onPress={() => {}} />
22
+ </Row>
23
+ ),
24
+ expectedColors: [DARK.accent, DARK.textMuted, DARK.textDim],
25
+ size: { width: 40, height: 3 },
26
+ };
27
+
28
+ export const Submit: Story = {
29
+ render: () => (
30
+ <Row>
31
+ <IconButton glyph={NERD.submit} active onPress={() => {}} />
32
+ </Row>
33
+ ),
34
+ expectedColors: [DARK.accent],
35
+ size: { width: 20, height: 3 },
36
+ };
@@ -0,0 +1,43 @@
1
+ // A clickable single-glyph button for header and footer controls: an icon in a
2
+ // box that calls onPress, painted in the brand accent when active. alignSelf
3
+ // center keeps it on the same row as sibling text or icons.
4
+
5
+ import React from "react";
6
+ import { DARK, type Theme } from "../../theme";
7
+
8
+ export interface IconButtonProps {
9
+ glyph: string;
10
+ onPress?: () => void;
11
+ /** Paint the glyph in the brand accent to mark the current mode or view. */
12
+ active?: boolean;
13
+ /** Dim the glyph and ignore presses. */
14
+ disabled?: boolean;
15
+ /** Override the resolved foreground (wins over active/disabled colouring). */
16
+ color?: string;
17
+ marginLeft?: number;
18
+ marginRight?: number;
19
+ theme?: Theme;
20
+ }
21
+
22
+ export function IconButton({
23
+ glyph,
24
+ onPress,
25
+ active,
26
+ disabled,
27
+ color,
28
+ marginLeft,
29
+ marginRight,
30
+ theme,
31
+ }: IconButtonProps): React.ReactNode {
32
+ const tokens = theme ?? DARK;
33
+ const resolved = color ?? (disabled ? tokens.textDim : active ? tokens.accent : tokens.textMuted);
34
+
35
+ return (
36
+ <box
37
+ onMouseUp={disabled ? undefined : onPress}
38
+ style={{ alignSelf: "center", marginLeft, marginRight }}
39
+ >
40
+ <text fg={resolved}>{glyph}</text>
41
+ </box>
42
+ );
43
+ }
@@ -3,7 +3,7 @@ import { DARK } from "../../theme";
3
3
  import type { Story, StoryMeta } from "../story";
4
4
  import { StatusBar } from "./StatusBar";
5
5
 
6
- export const meta: StoryMeta = { title: "primitives/StatusBar" };
6
+ export const meta: StoryMeta = { title: "Primitives/StatusBar" };
7
7
 
8
8
  export const HintLine: Story = {
9
9
  render: () => <StatusBar>j/k move · v span · c comment · q quit</StatusBar>,
@@ -3,7 +3,7 @@ import { DARK } from "../../theme";
3
3
  import type { Story, StoryMeta } from "../story";
4
4
  import { Tab, TabList, Tabs } from "./Tabs";
5
5
 
6
- export const meta: StoryMeta = { title: "primitives/Tabs" };
6
+ export const meta: StoryMeta = { title: "Primitives/Tabs" };
7
7
 
8
8
  export const TwoTabs: Story = {
9
9
  render: () => (
@@ -3,7 +3,7 @@ import type { Story, StoryMeta } from "../story";
3
3
  import { Button } from "./Button";
4
4
  import { Toolbar } from "./Toolbar";
5
5
 
6
- export const meta: StoryMeta = { title: "primitives/Toolbar" };
6
+ export const meta: StoryMeta = { title: "Primitives/Toolbar" };
7
7
 
8
8
  export const ActionRow: Story = {
9
9
  render: () => (
@@ -0,0 +1,124 @@
1
+ import React from "react";
2
+ import { DARK } from "../../theme";
3
+ import type { Story, StoryMeta } from "../story";
4
+ import { Tree } from "./Tree";
5
+ import type { TreeNode } from "./tree-model";
6
+
7
+ export const meta: StoryMeta = { title: "Primitives/Tree" };
8
+
9
+ const FILES: TreeNode[] = [
10
+ {
11
+ id: "packages",
12
+ label: "packages",
13
+ children: [
14
+ {
15
+ id: "schema",
16
+ label: "schema",
17
+ children: [
18
+ { id: "schema/types.ts", label: "types.ts", status: "modified" },
19
+ { id: "schema/tree-model.ts", label: "tree-model.ts", status: "added" },
20
+ ],
21
+ },
22
+ {
23
+ id: "daemon",
24
+ label: "daemon",
25
+ children: [{ id: "daemon/api.ts", label: "api.ts", status: "modified" }],
26
+ },
27
+ ],
28
+ },
29
+ { id: "README.md", label: "README.md" },
30
+ { id: "old.ts", label: "old.ts", status: "deleted" },
31
+ ];
32
+
33
+ const DEEP: TreeNode[] = [
34
+ {
35
+ id: "src",
36
+ label: "src",
37
+ children: [
38
+ {
39
+ id: "src/components",
40
+ label: "components",
41
+ children: [
42
+ {
43
+ id: "src/components/primitives",
44
+ label: "primitives",
45
+ children: [
46
+ { id: "src/components/primitives/Tree.tsx", label: "Tree.tsx", status: "added" },
47
+ ],
48
+ },
49
+ ],
50
+ },
51
+ ],
52
+ },
53
+ ];
54
+
55
+ const PROJECTS: TreeNode[] = [
56
+ {
57
+ id: "projects",
58
+ label: "Projects",
59
+ children: [
60
+ {
61
+ id: "cueloop",
62
+ label: "cueloop",
63
+ children: [{ id: "cueloop/read", label: "Read Cueloop Repository" }],
64
+ },
65
+ ],
66
+ },
67
+ {
68
+ id: "threads",
69
+ label: "Threads",
70
+ children: [{ id: "threads/welcome", label: "Welcome to cueloop" }],
71
+ },
72
+ ];
73
+
74
+ function Frame({ children }: { children: React.ReactNode }): React.ReactNode {
75
+ return (
76
+ <box style={{ width: 40, height: "100%", backgroundColor: DARK.panel, paddingTop: 1 }}>
77
+ {children}
78
+ </box>
79
+ );
80
+ }
81
+
82
+ export const FileTree: Story = {
83
+ render: () => (
84
+ <Frame>
85
+ <Tree
86
+ nodes={FILES}
87
+ expandedIds={new Set(["packages", "schema", "daemon"])}
88
+ selectedId="schema/tree-model.ts"
89
+ showStatus
90
+ />
91
+ </Frame>
92
+ ),
93
+ expectedColors: [DARK.accent, DARK.green, DARK.red],
94
+ size: { width: 44, height: 16 },
95
+ };
96
+
97
+ export const FlattenedChain: Story = {
98
+ render: () => (
99
+ <Frame>
100
+ <Tree
101
+ nodes={DEEP}
102
+ expandedIds={new Set(["src", "src/components/primitives"])}
103
+ flattenEmptyDirectories
104
+ showStatus
105
+ />
106
+ </Frame>
107
+ ),
108
+ expectedColors: [DARK.green],
109
+ size: { width: 44, height: 10 },
110
+ };
111
+
112
+ export const ProjectsThreads: Story = {
113
+ render: () => (
114
+ <Frame>
115
+ <Tree
116
+ nodes={PROJECTS}
117
+ expandedIds={new Set(["projects", "cueloop", "threads"])}
118
+ selectedId="cueloop/read"
119
+ />
120
+ </Frame>
121
+ ),
122
+ expectedColors: [DARK.accent],
123
+ size: { width: 44, height: 12 },
124
+ };
@@ -0,0 +1,87 @@
1
+ // OpenTUI renderer for the headless tree-model; the caller owns expansion and selection.
2
+
3
+ import React from "react";
4
+ import { DARK, type Theme } from "../../theme";
5
+ import { flattenTree, statusMeta, type TreeNode, type TreeTone } from "./tree-model";
6
+ import { NERD_TREE_ICONS, type TreeIcons } from "./icons";
7
+
8
+ export interface TreeProps {
9
+ nodes: readonly TreeNode[];
10
+ expandedIds: ReadonlySet<string>;
11
+ selectedId?: string;
12
+ flattenEmptyDirectories?: boolean;
13
+ showStatus?: boolean;
14
+ indentWidth?: number;
15
+ icons?: TreeIcons;
16
+ onSelect?: (id: string) => void;
17
+ onToggle?: (id: string) => void;
18
+ theme?: Theme;
19
+ }
20
+
21
+ function rowGlyph(isFolder: boolean, expanded: boolean, icons: TreeIcons): string {
22
+ if (!isFolder) return icons.leaf;
23
+
24
+ return expanded ? icons.expanded : icons.collapsed;
25
+ }
26
+
27
+ function toneColor(tone: TreeTone, theme: Theme): string {
28
+ if (tone === "green") return theme.green;
29
+ if (tone === "blue") return theme.blue;
30
+ if (tone === "red") return theme.red;
31
+
32
+ return theme.textDim;
33
+ }
34
+
35
+ export function Tree({
36
+ nodes,
37
+ expandedIds,
38
+ selectedId,
39
+ flattenEmptyDirectories,
40
+ showStatus,
41
+ indentWidth = 2,
42
+ icons = NERD_TREE_ICONS,
43
+ onSelect,
44
+ onToggle,
45
+ theme,
46
+ }: TreeProps): React.ReactNode {
47
+ const tokens = theme ?? DARK;
48
+ const rows = flattenTree(nodes, { expandedIds, flattenEmptyDirectories });
49
+
50
+ return (
51
+ <box style={{ flexDirection: "column" }}>
52
+ {rows.map((row) => {
53
+ const selected = row.id === selectedId;
54
+ const status = showStatus && row.status !== undefined ? statusMeta(row.status) : null;
55
+ const labelColor = selected
56
+ ? tokens.text
57
+ : status
58
+ ? toneColor(status.tone, tokens)
59
+ : row.isFolder
60
+ ? tokens.text
61
+ : tokens.textMuted;
62
+
63
+ return (
64
+ <box
65
+ key={row.id}
66
+ id={`tree-row-${row.id}`}
67
+ style={{
68
+ flexDirection: "row",
69
+ paddingLeft: 1 + row.depth * indentWidth,
70
+ paddingRight: 1,
71
+ backgroundColor: selected ? tokens.elevated : undefined,
72
+ }}
73
+ onMouseUp={() => (row.isFolder ? onToggle?.(row.id) : onSelect?.(row.id))}
74
+ >
75
+ <text fg={row.isFolder ? tokens.blue : tokens.textDim}>
76
+ {row.icon ?? rowGlyph(row.isFolder, row.expanded, icons)}{" "}
77
+ </text>
78
+ <text fg={selected ? tokens.accent : labelColor}>{row.label}</text>
79
+ <box style={{ flexGrow: 1 }} />
80
+ {row.badge !== undefined ? <text fg={tokens.textDim}>{row.badge}</text> : null}
81
+ {status ? <text fg={toneColor(status.tone, tokens)}> {status.letter}</text> : null}
82
+ </box>
83
+ );
84
+ })}
85
+ </box>
86
+ );
87
+ }
@@ -0,0 +1,38 @@
1
+ // Nerd Font glyphs (Private Use Area / codicons) plus the plus-minus diff mark; render as icons under a Nerd Font, tofu without one.
2
+
3
+ export const NERD = {
4
+ folderClosed: "",
5
+ folderOpen: "",
6
+ file: "",
7
+ chevronRight: "",
8
+ chevronDown: "",
9
+ settings: "",
10
+ search: "",
11
+ expand: "",
12
+ diff: "±",
13
+ listTree: "",
14
+ sidebarLeft: "",
15
+ sidebarLeftOff: "",
16
+ sidebarRight: "",
17
+ sidebarRightOff: "",
18
+ submit: "",
19
+ } as const;
20
+
21
+ export interface TreeIcons {
22
+ collapsed: string;
23
+ expanded: string;
24
+ leaf: string;
25
+ }
26
+
27
+ export const NERD_TREE_ICONS: TreeIcons = {
28
+ collapsed: NERD.folderClosed,
29
+ expanded: NERD.folderOpen,
30
+ leaf: NERD.file,
31
+ };
32
+
33
+ // Fallback for terminals without a Nerd Font.
34
+ export const ASCII_TREE_ICONS: TreeIcons = {
35
+ collapsed: "▸",
36
+ expanded: "▾",
37
+ leaf: " ",
38
+ };