@cueloop/client 0.1.0-alpha.7 → 0.1.0-alpha.71

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 (248) 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 +39 -32
  8. package/src/App.appearance.test.tsx +53 -0
  9. package/src/App.autoclose.test.tsx +76 -50
  10. package/src/App.diff.test.tsx +156 -42
  11. package/src/App.export.test.tsx +58 -40
  12. package/src/App.plan-review.test.tsx +363 -0
  13. package/src/App.prototype-markdown.test.tsx +92 -0
  14. package/src/App.prototype.test.tsx +176 -0
  15. package/src/App.readonly.test.tsx +128 -67
  16. package/src/App.rendering.test.tsx +61 -35
  17. package/src/App.submit-confirm.test.tsx +191 -0
  18. package/src/App.test.tsx +310 -114
  19. package/src/App.toast.test.tsx +89 -0
  20. package/src/App.tsx +1043 -798
  21. package/src/App.walk.test.tsx +298 -0
  22. package/src/App.workbench.test.tsx +495 -0
  23. package/src/__snapshots__/view-plan.regression.test.ts.snap +100 -0
  24. package/src/annotation-palette.ts +61 -0
  25. package/src/app-key-state.ts +61 -0
  26. package/src/app-screens.tsx +432 -0
  27. package/src/app-view-model.ts +159 -0
  28. package/src/attribution.test.ts +31 -0
  29. package/src/attribution.ts +42 -0
  30. package/src/clipboard.test.ts +23 -0
  31. package/src/clipboard.ts +33 -0
  32. package/src/components/AnnotatableFileView.stories.tsx +42 -0
  33. package/src/components/AnnotatableFileView.test.tsx +102 -0
  34. package/src/components/AnnotatableFileView.tsx +126 -0
  35. package/src/components/AnnotationCards.stories.tsx +87 -0
  36. package/src/components/AnnotationCards.tsx +303 -0
  37. package/src/components/AppHeader.stories.tsx +50 -0
  38. package/src/components/AppHeader.tsx +83 -0
  39. package/src/components/AppShell.stories.tsx +89 -0
  40. package/src/components/AppShell.tsx +290 -0
  41. package/src/components/AppShell.zoom.test.tsx +135 -0
  42. package/src/components/BareWorkbenchFileView.stories.tsx +27 -0
  43. package/src/components/BareWorkbenchFileView.tsx +64 -0
  44. package/src/components/Breadcrumb.stories.tsx +19 -0
  45. package/src/components/Breadcrumb.tsx +45 -0
  46. package/src/components/ChangesColumn.stories.tsx +39 -0
  47. package/src/components/ChangesColumn.tsx +171 -0
  48. package/src/components/CodeBlock.stories.tsx +42 -0
  49. package/src/components/CodeBlock.tsx +84 -0
  50. package/src/components/CompletionOverlay.stories.tsx +23 -0
  51. package/src/components/CompletionOverlay.tsx +60 -0
  52. package/src/components/ConfirmCard.stories.tsx +35 -0
  53. package/src/components/ConfirmCard.tsx +128 -0
  54. package/src/components/ConfirmDialog.stories.tsx +21 -0
  55. package/src/components/ConfirmDialog.tsx +66 -0
  56. package/src/components/DiffContentView.scroll.test.tsx +121 -0
  57. package/src/components/DiffContentView.split.test.tsx +140 -0
  58. package/src/components/DiffContentView.stories.tsx +80 -0
  59. package/src/components/DiffContentView.syntax.test.tsx +82 -0
  60. package/src/components/DiffContentView.tsx +859 -0
  61. package/src/components/DiscussionMarkerRail.stories.tsx +54 -0
  62. package/src/components/DiscussionMarkerRail.tsx +181 -0
  63. package/src/components/EditorGrid.stories.tsx +59 -0
  64. package/src/components/EditorGrid.tabs.test.tsx +118 -0
  65. package/src/components/EditorGrid.tsx +368 -0
  66. package/src/components/EditorGrid.zoom.test.tsx +68 -0
  67. package/src/components/FileContentsView.stories.tsx +16 -0
  68. package/src/components/FileContentsView.test.tsx +29 -0
  69. package/src/components/FileContentsView.tsx +98 -0
  70. package/src/components/KeybindsDialog.stories.tsx +31 -0
  71. package/src/components/KeybindsDialog.tsx +103 -0
  72. package/src/components/MenuBar.stories.tsx +28 -0
  73. package/src/components/MenuBar.tsx +44 -0
  74. package/src/components/OverlayScrollbar.stories.tsx +33 -0
  75. package/src/components/OverlayScrollbar.test.tsx +139 -0
  76. package/src/components/OverlayScrollbar.tsx +129 -0
  77. package/src/components/PaneGrid.stories.tsx +84 -0
  78. package/src/components/PaneGrid.tsx +128 -0
  79. package/src/components/PanelColumn.stories.tsx +25 -0
  80. package/src/components/PanelColumn.tsx +95 -0
  81. package/src/components/ProjectTreeView.stories.tsx +25 -0
  82. package/src/components/ProjectTreeView.test.tsx +32 -0
  83. package/src/components/ProjectTreeView.tsx +83 -0
  84. package/src/components/PromptDialog.stories.tsx +36 -0
  85. package/src/components/PromptDialog.tsx +87 -0
  86. package/src/components/PrototypeContentView.stories.tsx +19 -0
  87. package/src/components/PrototypeContentView.tsx +446 -0
  88. package/src/components/RootOverlay.stories.tsx +44 -0
  89. package/src/components/RootOverlay.tsx +35 -0
  90. package/src/components/ScrollArea.stories.tsx +20 -0
  91. package/src/components/ScrollArea.tsx +44 -0
  92. package/src/components/SettingsDialog.stories.tsx +117 -0
  93. package/src/components/SettingsDialog.tsx +195 -0
  94. package/src/components/SettingsRows.stories.tsx +18 -0
  95. package/src/components/SettingsRows.tsx +112 -0
  96. package/src/components/ShellHeader.stories.tsx +50 -0
  97. package/src/components/ShellHeader.tsx +137 -0
  98. package/src/components/ThreadFooter.stories.tsx +30 -0
  99. package/src/components/ThreadFooter.test.tsx +31 -0
  100. package/src/components/ThreadFooter.tsx +54 -0
  101. package/src/components/ThreadTree.stories.tsx +46 -0
  102. package/src/components/ThreadTree.tsx +219 -0
  103. package/src/components/ThreadView.selection.test.tsx +812 -0
  104. package/src/components/ThreadView.slash.test.tsx +158 -0
  105. package/src/components/ThreadView.stories.tsx +26 -0
  106. package/src/components/ThreadView.tsx +330 -0
  107. package/src/components/ThreadsSidebar.stories.tsx +36 -0
  108. package/src/components/ThreadsSidebar.tsx +71 -0
  109. package/src/components/Toast.stories.tsx +12 -0
  110. package/src/components/Toast.tsx +45 -0
  111. package/src/components/Tooltip.stories.tsx +26 -0
  112. package/src/components/Tooltip.tsx +72 -0
  113. package/src/components/TreePane.stories.tsx +106 -0
  114. package/src/components/TreePane.tsx +157 -0
  115. package/src/components/WalkWizard.stories.tsx +100 -0
  116. package/src/components/WalkWizard.tsx +152 -0
  117. package/src/components/WelcomePlayground.stories.tsx +12 -0
  118. package/src/components/WelcomePlayground.test.tsx +47 -0
  119. package/src/components/WelcomePlayground.tsx +140 -0
  120. package/src/components/__snapshots__/stories.test.tsx.snap +1845 -0
  121. package/src/components/agent-launcher.stories.tsx +26 -0
  122. package/src/components/agent-launcher.test.tsx +50 -0
  123. package/src/components/agent-launcher.tsx +149 -0
  124. package/src/components/diff-content-view-layout.test.ts +198 -0
  125. package/src/components/diff-content-view-layout.ts +84 -0
  126. package/src/components/editor-grid.test.ts +86 -0
  127. package/src/components/editor-grid.ts +138 -0
  128. package/src/components/fade-title.test.ts +49 -0
  129. package/src/components/fade-title.ts +61 -0
  130. package/src/components/file-tree.test.ts +60 -0
  131. package/src/components/file-tree.ts +112 -0
  132. package/src/components/pages/Workspace.stories.tsx +181 -0
  133. package/src/components/primitives/Button.stories.tsx +32 -0
  134. package/src/components/primitives/Button.tsx +52 -0
  135. package/src/components/primitives/Card.stories.tsx +25 -0
  136. package/src/components/primitives/Card.tsx +60 -0
  137. package/src/components/primitives/Dialog.stories.tsx +24 -0
  138. package/src/components/primitives/Dialog.tsx +66 -0
  139. package/src/components/primitives/IconButton.stories.tsx +36 -0
  140. package/src/components/primitives/IconButton.tsx +62 -0
  141. package/src/components/primitives/StatusBar.stories.tsx +11 -0
  142. package/src/components/primitives/StatusBar.tsx +23 -0
  143. package/src/components/primitives/Tabs.stories.tsx +29 -0
  144. package/src/components/primitives/Tabs.tsx +102 -0
  145. package/src/components/primitives/Toolbar.stories.tsx +17 -0
  146. package/src/components/primitives/Toolbar.tsx +14 -0
  147. package/src/components/primitives/Tree.stories.tsx +124 -0
  148. package/src/components/primitives/Tree.tsx +96 -0
  149. package/src/components/primitives/frame.test.ts +12 -0
  150. package/src/components/primitives/frame.ts +14 -0
  151. package/src/components/primitives/icons.ts +61 -0
  152. package/src/components/primitives/tree-model.test.ts +99 -0
  153. package/src/components/primitives/tree-model.ts +137 -0
  154. package/src/components/quick-actions-editor.stories.tsx +54 -0
  155. package/src/components/quick-actions-editor.test.tsx +49 -0
  156. package/src/components/quick-actions-editor.tsx +150 -0
  157. package/src/components/session-tree.test.ts +134 -0
  158. package/src/components/session-tree.ts +108 -0
  159. package/src/components/stories-app.tsx +184 -0
  160. package/src/components/stories.test.tsx +66 -0
  161. package/src/components/story-fixtures.ts +102 -0
  162. package/src/components/story-tree.test.ts +100 -0
  163. package/src/components/story.ts +142 -0
  164. package/src/components/syntax-highlight.ts +110 -0
  165. package/src/components/terminal-pane.test.tsx +40 -0
  166. package/src/components/terminal-pane.ts +199 -0
  167. package/src/components/theme-context.stories.tsx +46 -0
  168. package/src/components/theme-context.tsx +37 -0
  169. package/src/components/truncate-text.ts +7 -0
  170. package/src/config.test.ts +461 -31
  171. package/src/config.ts +389 -57
  172. package/src/diff-fold.test.ts +78 -0
  173. package/src/diff-fold.ts +126 -0
  174. package/src/diff-hunk-curate.test.ts +315 -0
  175. package/src/diff-hunk-curate.ts +153 -0
  176. package/src/diff-intraline.test.ts +165 -0
  177. package/src/diff-intraline.ts +146 -0
  178. package/src/diff-syntax.test.ts +112 -0
  179. package/src/diff-syntax.ts +179 -0
  180. package/src/discussions.ts +79 -0
  181. package/src/editor.test.ts +149 -0
  182. package/src/editor.ts +139 -9
  183. package/src/ghostty-terminal.test.ts +85 -0
  184. package/src/ghostty-terminal.ts +199 -0
  185. package/src/herdr.test.ts +9 -34
  186. package/src/herdr.ts +25 -21
  187. package/src/index.ts +11 -0
  188. package/src/integrations.test.ts +41 -0
  189. package/src/integrations.ts +32 -0
  190. package/src/intent-dispatch.test.ts +698 -0
  191. package/src/intent-dispatch.ts +700 -0
  192. package/src/key-bindings.test.ts +156 -0
  193. package/src/key-bindings.ts +465 -0
  194. package/src/keymap.test.ts +611 -0
  195. package/src/keymap.ts +410 -0
  196. package/src/kitty-image.test.ts +66 -0
  197. package/src/kitty-image.ts +138 -0
  198. package/src/mark-runs.ts +120 -0
  199. package/src/prototype-browser.test.ts +56 -0
  200. package/src/prototype-browser.ts +307 -0
  201. package/src/prototype-pixels.tsx +35 -0
  202. package/src/pty.test.ts +81 -0
  203. package/src/pty.ts +245 -0
  204. package/src/ready-signal.test.ts +85 -0
  205. package/src/ready-signal.ts +52 -0
  206. package/src/run.ts +30 -9
  207. package/src/serve.test.ts +24 -12
  208. package/src/serve.ts +20 -9
  209. package/src/share.ts +215 -0
  210. package/src/skills.test.ts +74 -0
  211. package/src/skills.ts +68 -0
  212. package/src/slash-palette.test.ts +54 -0
  213. package/src/slash-palette.ts +104 -0
  214. package/src/split-diff.test.ts +110 -0
  215. package/src/split-diff.ts +149 -0
  216. package/src/test-support.test.ts +264 -0
  217. package/src/test-support.ts +355 -0
  218. package/src/theme-presets.test.ts +51 -0
  219. package/src/theme-presets.ts +214 -0
  220. package/src/theme.ts +84 -19
  221. package/src/thread-chords.test.ts +170 -0
  222. package/src/thread-chords.ts +169 -0
  223. package/src/thread-controller.curate.test.ts +438 -0
  224. package/src/thread-controller.live-diff.test.ts +199 -0
  225. package/src/thread-controller.share.test.ts +472 -0
  226. package/src/thread-controller.tree.test.ts +255 -0
  227. package/src/thread-controller.ts +1509 -0
  228. package/src/thread-selection.test.ts +210 -0
  229. package/src/thread-selection.ts +243 -0
  230. package/src/tree-view.test.ts +128 -0
  231. package/src/tree-view.ts +148 -0
  232. package/src/use-annotation-surface.tsx +851 -0
  233. package/src/use-changes-workbench.ts +153 -0
  234. package/src/use-frame-measure.ts +38 -0
  235. package/src/use-settings-dialog.tsx +274 -0
  236. package/src/use-terminal-virtualizer.ts +233 -0
  237. package/src/version.ts +5 -0
  238. package/src/view-diff.test.ts +66 -9
  239. package/src/view-diff.ts +231 -23
  240. package/src/view-plan.regression.test.ts +60 -0
  241. package/src/view-plan.test.ts +406 -0
  242. package/src/view-plan.ts +570 -0
  243. package/src/walk.test.ts +121 -0
  244. package/src/walk.ts +74 -0
  245. package/src/syntax.test.ts +0 -41
  246. package/src/syntax.ts +0 -88
  247. package/src/view.test.ts +0 -157
  248. package/src/view.ts +0 -312
@@ -0,0 +1,138 @@
1
+ // The Changes pane's editor layout: a tree of nodes where a leaf is an editor group (a tab strip plus
2
+ // its one active tab) and a branch splits child nodes along an orientation. Splitting an editor group
3
+ // moves its active tab into a new group; a group is pruned when its last tab closes, collapsing the
4
+ // branch that held it.
5
+
6
+ /** One editor in a group's tab strip: the Changes tab (the whole diff), a per-file tab, or Welcome. */
7
+ export interface EditorTab {
8
+ id: string;
9
+ kind: "changes" | "file" | "welcome";
10
+ label: string;
11
+ /** Repo-relative path for a file tab; absent for the Changes and Welcome tabs. */
12
+ path?: string;
13
+ /** A file tab shows either a single-file diff or the file's plain contents. */
14
+ fileView?: "diff" | "contents";
15
+ }
16
+
17
+ /** A leaf of the grid: one editor group holding tabs, with the active tab id. */
18
+ export interface EditorGroup {
19
+ type: "group";
20
+ id: string;
21
+ tabs: EditorTab[];
22
+ activeTabId: string | null;
23
+ }
24
+
25
+ /** A branch of the grid: children laid out along one orientation (horizontal = side by side). */
26
+ export interface EditorBranch {
27
+ type: "branch";
28
+ id: string;
29
+ orientation: "horizontal" | "vertical";
30
+ children: EditorNode[];
31
+ }
32
+
33
+ export type EditorNode = EditorGroup | EditorBranch;
34
+
35
+ /** Which edge a split places the new group on; Left/Right split horizontally, Up/Down vertically. */
36
+ export type SplitDirection = "left" | "right" | "up" | "down";
37
+
38
+ /** A split's outcome: the new grid and the id of the group that should take focus. */
39
+ export interface SplitResult {
40
+ tree: EditorNode;
41
+ focusGroupId: string;
42
+ }
43
+
44
+ const freshId = (): string => crypto.randomUUID();
45
+
46
+ /** A new editor group holding the given tabs, with the first tab active. */
47
+ export function makeGroup(tabs: EditorTab[]): EditorGroup {
48
+ return { type: "group", id: freshId(), tabs, activeTabId: tabs[0]?.id ?? null };
49
+ }
50
+
51
+ /** The Changes tab: the whole diff in one scroll, the disposable default of the Changes pane. */
52
+ export function changesTab(): EditorTab {
53
+ return { id: freshId(), kind: "changes", label: "Changes" };
54
+ }
55
+
56
+ /** The Welcome tab: the getting-started surface, a disposable tab shown before any thread is opened. */
57
+ export function welcomeTab(): EditorTab {
58
+ return { id: freshId(), kind: "welcome", label: "Welcome" };
59
+ }
60
+
61
+ /** A file tab, opened from a tree; a diff view for a changed file, plain contents otherwise. */
62
+ export function fileTab(label: string, path: string, fileView: "diff" | "contents"): EditorTab {
63
+ return { id: freshId(), kind: "file", label, path, fileView };
64
+ }
65
+
66
+ /** The id of the first editor group in reading order; the fallback focus target. */
67
+ export function firstGroupId(node: EditorNode): string {
68
+ return node.type === "group" ? node.id : firstGroupId(node.children[0]!);
69
+ }
70
+
71
+ /** Whether a group id still exists in the grid; used to re-home focus after a prune. */
72
+ export function containsGroup(node: EditorNode, groupId: string): boolean {
73
+ if (node.type === "group") return node.id === groupId;
74
+ return node.children.some((child) => containsGroup(child, groupId));
75
+ }
76
+
77
+ function replaceGroup(
78
+ node: EditorNode,
79
+ id: string,
80
+ fn: (group: EditorGroup) => EditorNode,
81
+ ): EditorNode {
82
+ if (node.type === "group") return node.id === id ? fn(node) : node;
83
+ return { ...node, children: node.children.map((child) => replaceGroup(child, id, fn)) };
84
+ }
85
+
86
+ /** Make a tab the active one in its group. */
87
+ export function activateTab(node: EditorNode, groupId: string, tabId: string): EditorNode {
88
+ return replaceGroup(node, groupId, (group) => ({ ...group, activeTabId: tabId }));
89
+ }
90
+
91
+ /** Append a tab to a group and focus it. */
92
+ export function addTab(node: EditorNode, groupId: string, tab: EditorTab): EditorNode {
93
+ return replaceGroup(node, groupId, (group) => ({
94
+ ...group,
95
+ tabs: [...group.tabs, tab],
96
+ activeTabId: tab.id,
97
+ }));
98
+ }
99
+
100
+ /** Drop empty groups and collapse a branch that ends up with one child; null when nothing remains. */
101
+ export function pruneEmpty(node: EditorNode): EditorNode | null {
102
+ if (node.type === "group") return node.tabs.length > 0 ? node : null;
103
+ const kept = node.children.map(pruneEmpty).filter((child): child is EditorNode => child !== null);
104
+ if (kept.length === 0) return null;
105
+ if (kept.length === 1) return kept[0]!;
106
+ return { ...node, children: kept };
107
+ }
108
+
109
+ /** Close a tab; returns the pruned tree, or null when the last tab of the whole grid is closed. */
110
+ export function closeTab(node: EditorNode, groupId: string, tabId: string): EditorNode | null {
111
+ const next = replaceGroup(node, groupId, (group) => {
112
+ const tabs = group.tabs.filter((tab) => tab.id !== tabId);
113
+ const activeTabId = group.activeTabId === tabId ? (tabs[0]?.id ?? null) : group.activeTabId;
114
+ return { ...group, tabs, activeTabId };
115
+ });
116
+ return pruneEmpty(next);
117
+ }
118
+
119
+ /** Split a group's active tab into a new group on the given edge; returns the tree and the new focus. */
120
+ export function splitGroup(
121
+ node: EditorNode,
122
+ groupId: string,
123
+ direction: SplitDirection,
124
+ ): SplitResult {
125
+ let focusGroupId = groupId;
126
+ const tree = replaceGroup(node, groupId, (group) => {
127
+ const active = group.tabs.find((tab) => tab.id === group.activeTabId) ?? group.tabs[0];
128
+ if (active === undefined) return group;
129
+ const movedTab: EditorTab = { ...active, id: freshId() };
130
+ const newGroup = makeGroup([movedTab]);
131
+ focusGroupId = newGroup.id;
132
+ const orientation = direction === "left" || direction === "right" ? "horizontal" : "vertical";
133
+ const newFirst = direction === "left" || direction === "up";
134
+ const children = newFirst ? [newGroup, group] : [group, newGroup];
135
+ return { type: "branch", id: freshId(), orientation, children };
136
+ });
137
+ return { tree, focusGroupId };
138
+ }
@@ -0,0 +1,49 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { fadeTitle, mixHex } from "./fade-title";
3
+
4
+ describe("mixHex", () => {
5
+ test("returns the endpoints at t=0 and t=1", () => {
6
+ expect(mixHex("#000000", "#ffffff", 0)).toBe("#000000");
7
+ expect(mixHex("#000000", "#ffffff", 1)).toBe("#ffffff");
8
+ });
9
+
10
+ test("blends halfway", () => {
11
+ expect(mixHex("#000000", "#ffffff", 0.5)).toBe("#808080");
12
+ });
13
+
14
+ test("clamps out-of-range t", () => {
15
+ expect(mixHex("#101010", "#202020", 2)).toBe("#202020");
16
+ expect(mixHex("#101010", "#202020", -1)).toBe("#101010");
17
+ });
18
+ });
19
+
20
+ describe("fadeTitle", () => {
21
+ test("a title that fits is one plain segment", () => {
22
+ const segments = fadeTitle("short", 20, "#aabbcc", "#000000");
23
+
24
+ expect(segments).toEqual([{ text: "short", fg: "#aabbcc" }]);
25
+ });
26
+
27
+ test("a long title is clipped to the width and its tail fades toward the background", () => {
28
+ const segments = fadeTitle("Review the accent change in the theme", 20, "#ffffff", "#000000");
29
+
30
+ expect(segments.map((segment) => segment.text).join("")).toHaveLength(20);
31
+ // the head keeps the base color, the last characters step toward the fade color
32
+ expect(segments[0]!.fg).toBe("#ffffff");
33
+ expect(segments.at(-1)!.fg).not.toBe("#ffffff");
34
+ // the very last character is the most faded (closest to the background)
35
+ const lastTwo = segments.slice(-2);
36
+
37
+ expect(mixHex("#ffffff", "#000000", 0)).not.toBe(lastTwo[1]!.fg);
38
+ });
39
+
40
+ test("newlines collapse to spaces so the row never wraps", () => {
41
+ const segments = fadeTitle("line one\nline two", 40, "#ffffff", "#000000");
42
+
43
+ expect(segments[0]!.text).toBe("line one line two");
44
+ });
45
+
46
+ test("a zero width yields nothing", () => {
47
+ expect(fadeTitle("anything", 0, "#ffffff", "#000000")).toEqual([]);
48
+ });
49
+ });
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Fits a thread title onto one line, fading the last few characters toward the
3
+ * background instead of cutting hard or wrapping - a gradient mask on the right.
4
+ * Returns colored segments the caller renders as spans.
5
+ */
6
+
7
+ export interface TitleSegment {
8
+ text: string;
9
+ fg: string;
10
+ }
11
+
12
+ function channel(hex: string, start: number): number {
13
+ return Number.parseInt(hex.slice(start, start + 2), 16);
14
+ }
15
+
16
+ /** Blend two #rrggbb colors; t=0 is `from`, t=1 is `to`. */
17
+ export function mixHex(from: string, to: string, t: number): string {
18
+ const clamp = Math.max(0, Math.min(1, t));
19
+ const parts = [1, 3, 5].map((start) => {
20
+ const value = Math.round(
21
+ channel(from, start) + (channel(to, start) - channel(from, start)) * clamp,
22
+ );
23
+
24
+ return value.toString(16).padStart(2, "0");
25
+ });
26
+
27
+ return `#${parts.join("")}`;
28
+ }
29
+
30
+ /** The tail length that fades out; kept short so most of the title stays legible. */
31
+ const FADE_WIDTH = 5;
32
+
33
+ export function fadeTitle(
34
+ title: string,
35
+ maxWidth: number,
36
+ baseColor: string,
37
+ fadeColor: string,
38
+ ): TitleSegment[] {
39
+ const oneLine = title.replace(/\n/g, " ");
40
+
41
+ if (maxWidth <= 0) return [];
42
+ if (oneLine.length <= maxWidth) return [{ text: oneLine, fg: baseColor }];
43
+
44
+ const clipped = oneLine.slice(0, maxWidth);
45
+ const fadeLength = Math.min(FADE_WIDTH, maxWidth);
46
+ const head = clipped.slice(0, maxWidth - fadeLength);
47
+ const segments: TitleSegment[] = head.length > 0 ? [{ text: head, fg: baseColor }] : [];
48
+
49
+ for (let index = 0; index < fadeLength; index++) {
50
+ // the last character reaches the background fully, so the tail masks out
51
+ // rather than leaving a legible word fragment
52
+ const progress = (index + 1) / fadeLength;
53
+
54
+ segments.push({
55
+ text: clipped[maxWidth - fadeLength + index]!,
56
+ fg: mixHex(baseColor, fadeColor, progress),
57
+ });
58
+ }
59
+
60
+ return segments;
61
+ }
@@ -0,0 +1,60 @@
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
+
46
+ test("badges a leaf with its comment count, and leaves an uncommented file bare", () => {
47
+ // Act
48
+ const tree = buildFileTree(
49
+ [file("a.ts"), file("b.ts")],
50
+ new Map([
51
+ ["a.ts", 3],
52
+ ["b.ts", 0],
53
+ ]),
54
+ );
55
+
56
+ // Assert - a.ts shows the dot-and-count, b.ts (0) shows nothing
57
+ expect(tree[0]!.badge).toBe("● 3");
58
+ expect(tree[1]!.badge).toBeUndefined();
59
+ });
60
+ });
@@ -0,0 +1,112 @@
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
+ badge?: string;
20
+ children?: Map<string, MutableTreeNode>;
21
+ }
22
+
23
+ function freeze(node: MutableTreeNode): TreeNode {
24
+ if (node.children === undefined) {
25
+ const leaf: TreeNode = { id: node.id, label: node.label, status: node.status };
26
+
27
+ if (node.badge !== undefined) leaf.badge = node.badge;
28
+
29
+ return leaf;
30
+ }
31
+
32
+ return {
33
+ id: node.id,
34
+ label: node.label,
35
+ children: [...node.children.values()].map(freeze),
36
+ };
37
+ }
38
+
39
+ /** A file's comment count as a leaf badge, or undefined when it carries none. */
40
+ function commentBadge(
41
+ counts: ReadonlyMap<string, number> | undefined,
42
+ path: string,
43
+ ): string | undefined {
44
+ const count = counts?.get(path);
45
+
46
+ return count ? `● ${count}` : undefined;
47
+ }
48
+
49
+ /**
50
+ * Group changed files into a directory tree; leaves are keyed by their full path. A leaf whose
51
+ * file carries comments shows a dot-and-count badge, so feedback is visible even with its tab closed.
52
+ */
53
+ export function buildFileTree(
54
+ files: readonly DiffFileContents[],
55
+ commentCounts?: ReadonlyMap<string, number>,
56
+ ): TreeNode[] {
57
+ const roots = new Map<string, MutableTreeNode>();
58
+
59
+ for (const file of files) {
60
+ const segments = file.path.split("/").filter((segment) => segment.length > 0);
61
+ let level = roots;
62
+ let prefix = "";
63
+
64
+ segments.forEach((segment, index) => {
65
+ prefix = prefix.length > 0 ? `${prefix}/${segment}` : segment;
66
+ const isLeaf = index === segments.length - 1;
67
+ let node = level.get(segment);
68
+
69
+ if (node === undefined) {
70
+ node = isLeaf
71
+ ? {
72
+ id: file.path,
73
+ label: segment,
74
+ status: gitStatusOf(file.status),
75
+ badge: commentBadge(commentCounts, file.path),
76
+ }
77
+ : { id: prefix, label: segment, children: new Map() };
78
+ level.set(segment, node);
79
+ }
80
+ if (!isLeaf) level = node.children!;
81
+ });
82
+ }
83
+
84
+ return [...roots.values()].map(freeze);
85
+ }
86
+
87
+ /** Group plain repo-relative paths (git ls-files) into a directory tree; leaves keep their full path. */
88
+ export function buildPathTree(paths: readonly string[]): TreeNode[] {
89
+ const roots = new Map<string, MutableTreeNode>();
90
+
91
+ for (const path of paths) {
92
+ const segments = path.split("/").filter((segment) => segment.length > 0);
93
+ let level = roots;
94
+ let prefix = "";
95
+
96
+ segments.forEach((segment, index) => {
97
+ prefix = prefix.length > 0 ? `${prefix}/${segment}` : segment;
98
+ const isLeaf = index === segments.length - 1;
99
+ let node = level.get(segment);
100
+
101
+ if (node === undefined) {
102
+ node = isLeaf
103
+ ? { id: path, label: segment }
104
+ : { id: prefix, label: segment, children: new Map() };
105
+ level.set(segment, node);
106
+ }
107
+ if (!isLeaf) level = node.children!;
108
+ });
109
+ }
110
+
111
+ return [...roots.values()].map(freeze);
112
+ }
@@ -0,0 +1,181 @@
1
+ import React from "react";
2
+ import { DARK } from "../../theme";
3
+ import type { Story, StoryMeta } from "../story";
4
+ import { PaneGrid } from "../PaneGrid";
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
+ <PaneGrid
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
+ };
@@ -0,0 +1,32 @@
1
+ import React from "react";
2
+ import { DARK } from "../../theme";
3
+ import type { Story, StoryMeta } from "../story";
4
+ import { Button } from "./Button";
5
+ import { Toolbar } from "./Toolbar";
6
+
7
+ export const meta: StoryMeta = { title: "Primitives/Button" };
8
+
9
+ export const Variants: Story = {
10
+ render: () => (
11
+ <Toolbar>
12
+ <Button variant="solid" marginRight={2} onPress={() => {}}>
13
+ {" Save "}
14
+ </Button>
15
+ <Button marginRight={2} onPress={() => {}}>
16
+ {" Cancel esc "}
17
+ </Button>
18
+ <Button variant="accent-text" onPress={() => {}}>
19
+ {"Submit review"}
20
+ </Button>
21
+ </Toolbar>
22
+ ),
23
+ expectedColors: [DARK.accent, DARK.accentInk, DARK.textDim],
24
+ };
25
+
26
+ export const Disabled: Story = {
27
+ render: () => (
28
+ <Button isDisabled onPress={() => {}}>
29
+ {" Edit "}
30
+ </Button>
31
+ ),
32
+ };
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Word-button: a pressable label. `onPress` unifies mouse and (future)
3
+ * keyboard activation; `isDisabled` swallows presses without changing the
4
+ * rendered label, so read-only affordances keep their place in the layout.
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 ButtonProps {
13
+ /** Unified activation: mouse release today, keyboard activation tomorrow. */
14
+ onPress: () => void;
15
+ isDisabled?: boolean;
16
+ /** solid = accent-filled call to action; plain = quiet word-button. */
17
+ variant?: "solid" | "plain" | "accent-text";
18
+ marginRight?: number;
19
+ theme?: Theme;
20
+ children: string;
21
+ }
22
+
23
+ export function Button({
24
+ onPress,
25
+ isDisabled = false,
26
+ variant = "plain",
27
+ marginRight,
28
+ theme,
29
+ children,
30
+ }: ButtonProps): React.ReactNode {
31
+ const tokens = useComponentTheme(theme);
32
+ const backgroundColor = variant === "solid" ? tokens.accent : undefined;
33
+ const foreground =
34
+ variant === "solid"
35
+ ? tokens.accentInk
36
+ : variant === "accent-text"
37
+ ? tokens.accent
38
+ : tokens.textDim;
39
+
40
+ return (
41
+ <box
42
+ style={{ backgroundColor, marginRight }}
43
+ onMouseUp={(event: MouseEvent) => {
44
+ // Consume the press so it never bubbles to an ancestor's onMouseUp (e.g. a card).
45
+ event.stopPropagation();
46
+ if (!isDisabled) onPress();
47
+ }}
48
+ >
49
+ <text fg={foreground}>{children}</text>
50
+ </box>
51
+ );
52
+ }