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

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 +1039 -799
  21. package/src/App.walk.test.tsx +298 -0
  22. package/src/App.workbench.test.tsx +461 -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 +286 -0
  41. package/src/components/AppShell.zoom.test.tsx +94 -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 +151 -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,368 @@
1
+ // Renders the Changes pane's editor grid: recursive branches split into rows/columns, each leaf an
2
+ // editor group with its own tab strip and header controls. The caller supplies renderTab so the grid
3
+ // stays layout-only - a tab becomes a diff, a file's contents, or the aggregate Changes view upstream.
4
+
5
+ import React, { useRef, useState } from "react";
6
+ import type { BoxRenderable } from "@opentui/core";
7
+ import { DARK, type Theme } from "../theme";
8
+ import { useFrameMeasure } from "../use-frame-measure";
9
+ import { IconButton } from "./primitives/IconButton";
10
+ import { NERD, HEADER_UNDERLINE_CHARS } from "./primitives/icons";
11
+ import type { EditorGroup, EditorNode, EditorTab, SplitDirection } from "./editor-grid";
12
+
13
+ export interface EditorGridProps {
14
+ tree: EditorNode;
15
+ focusedGroupId: string | null;
16
+ onFocusGroup: (groupId: string) => void;
17
+ onActivateTab: (groupId: string, tabId: string) => void;
18
+ onCloseTab: (groupId: string, tabId: string) => void;
19
+ onSplit: (groupId: string, direction: SplitDirection) => void;
20
+ onZoom: () => void;
21
+ zoomed: boolean;
22
+ /** The body of a group's active tab; `groupFocused` tells a keyboard-owning body whether it has the keys. */
23
+ renderTab: (tab: EditorTab, groupFocused: boolean) => React.ReactNode;
24
+ /** Comments per file path, shown as a dot-and-count badge on a file tab. */
25
+ commentCounts?: ReadonlyMap<string, number>;
26
+ theme?: Theme;
27
+ }
28
+
29
+ const SPLIT_ITEMS: ReadonlyArray<{ label: string; direction: SplitDirection; arrow: string }> = [
30
+ { label: "Split Left", direction: "left", arrow: "←" },
31
+ { label: "Split Right", direction: "right", arrow: "→" },
32
+ { label: "Split Up", direction: "up", arrow: "↑" },
33
+ { label: "Split Down", direction: "down", arrow: "↓" },
34
+ ];
35
+
36
+ function EditorTabButton({
37
+ tab,
38
+ active,
39
+ commentCount,
40
+ onSelect,
41
+ onClose,
42
+ tokens,
43
+ }: {
44
+ tab: EditorTab;
45
+ active: boolean;
46
+ /** Comments on this tab's file, shown as a dot-and-count badge; 0 shows nothing. */
47
+ commentCount: number;
48
+ onSelect: () => void;
49
+ onClose: () => void;
50
+ tokens: Theme;
51
+ }): React.ReactNode {
52
+ const [hovered, setHovered] = useState(false);
53
+
54
+ return (
55
+ <box
56
+ id={tab.id}
57
+ onMouseUp={onSelect}
58
+ onMouseOver={() => setHovered(true)}
59
+ onMouseOut={() => setHovered(false)}
60
+ style={{
61
+ flexDirection: "row",
62
+ // a tab keeps the width of its name; the strip scrolls when the row overflows
63
+ flexShrink: 0,
64
+ paddingLeft: 1,
65
+ paddingRight: 1,
66
+ backgroundColor: active ? tokens.background : undefined,
67
+ borderStyle: "single",
68
+ border: ["right"],
69
+ borderColor: tokens.border,
70
+ }}
71
+ >
72
+ <text fg={active ? tokens.textMuted : tokens.textDim} style={{ wrapMode: "none" }}>
73
+ {tab.label}
74
+ </text>
75
+ {commentCount > 0 ? (
76
+ <text
77
+ fg={tokens.textDim}
78
+ style={{ flexShrink: 0, wrapMode: "none" }}
79
+ >{` ● ${commentCount}`}</text>
80
+ ) : null}
81
+ <box
82
+ onMouseUp={(event) => {
83
+ event.stopPropagation();
84
+ onClose();
85
+ }}
86
+ style={{ paddingLeft: 1 }}
87
+ >
88
+ <text fg={tokens.textDim}>{hovered ? NERD.close : " "}</text>
89
+ </box>
90
+ </box>
91
+ );
92
+ }
93
+
94
+ /** Cells a tab occupies: padding, label, the ` ● N` comment badge when present, the close cell, the right rule. */
95
+ export function tabCellWidth(tab: EditorTab, commentCount = 0): number {
96
+ const badge = commentCount > 0 ? 3 + String(commentCount).length : 0;
97
+
98
+ return tab.label.length + 5 + badge;
99
+ }
100
+
101
+ /** The run of tabs `[first, end)` the strip shows. */
102
+ export interface TabWindow {
103
+ first: number;
104
+ end: number;
105
+ }
106
+
107
+ /** The run of tabs that fits the strip and contains the active tab; markers take a cell each side. */
108
+ export function visibleTabWindow(
109
+ tabs: EditorTab[],
110
+ firstVisible: number,
111
+ activeIndex: number,
112
+ stripWidth: number,
113
+ commentCount: (tab: EditorTab) => number = () => 0,
114
+ ): TabWindow {
115
+ // unmeasured strip: show everything, the header clips until the width is known
116
+ if (stripWidth <= 0) return { first: 0, end: tabs.length };
117
+ const width = (index: number): number => tabCellWidth(tabs[index]!, commentCount(tabs[index]!));
118
+ const fitEnd = (first: number): number => {
119
+ let used = first > 0 ? 1 : 0;
120
+ let end = first;
121
+
122
+ while (end < tabs.length) {
123
+ const remaining = end + 1 < tabs.length ? 1 : 0;
124
+
125
+ if (used + width(end) + remaining > stripWidth) break;
126
+ used += width(end);
127
+ end++;
128
+ }
129
+
130
+ // always show at least the tab the window starts on, even when it is wider than the strip
131
+ return Math.max(end, Math.min(first + 1, tabs.length));
132
+ };
133
+ let first = Math.min(Math.max(0, firstVisible), Math.max(0, activeIndex));
134
+ let end = fitEnd(first);
135
+
136
+ // the active tab past the right edge pulls the window along until it fits
137
+ while (activeIndex >= end && first < activeIndex) {
138
+ first++;
139
+ end = fitEnd(first);
140
+ }
141
+
142
+ return { first, end };
143
+ }
144
+
145
+ function SplitMenu({
146
+ onPick,
147
+ tokens,
148
+ }: {
149
+ onPick: (direction: SplitDirection) => void;
150
+ tokens: Theme;
151
+ }): React.ReactNode {
152
+ return (
153
+ <box
154
+ style={{
155
+ flexDirection: "column",
156
+ flexShrink: 0,
157
+ borderStyle: "single",
158
+ borderColor: tokens.border,
159
+ // the panel token is transparent in the branded theme, so the menu reads terminal-through
160
+ backgroundColor: tokens.panel,
161
+ }}
162
+ >
163
+ {SPLIT_ITEMS.map((item) => (
164
+ <box
165
+ key={item.direction}
166
+ onMouseUp={() => onPick(item.direction)}
167
+ style={{ flexDirection: "row", paddingLeft: 1, paddingRight: 1 }}
168
+ >
169
+ <text fg={tokens.text}>{item.label}</text>
170
+ <box style={{ flexGrow: 1 }} />
171
+ <text fg={tokens.textDim}>{` ${item.arrow}`}</text>
172
+ </box>
173
+ ))}
174
+ </box>
175
+ );
176
+ }
177
+
178
+ function EditorGroupPane({
179
+ group,
180
+ props,
181
+ }: {
182
+ group: EditorGroup;
183
+ props: EditorGridProps;
184
+ }): React.ReactNode {
185
+ const tokens = props.theme ?? DARK;
186
+ const [menuOpen, setMenuOpen] = useState(false);
187
+ const active = group.tabs.find((tab) => tab.id === group.activeTabId) ?? group.tabs[0];
188
+ const isFile = active?.kind === "file";
189
+ // The strip windows its tabs rather than scrolling a nested scrollbox (which stalls the
190
+ // renderer inside this header): tabs keep the width of their names, only the run that fits
191
+ // renders, and the window follows the active tab so a tab opened past the edge is never hidden.
192
+ const stripRef = useRef<BoxRenderable | null>(null);
193
+ const stripWidth = useFrameMeasure(
194
+ () => stripRef.current?.width ?? 0,
195
+ (left, right) => left === right,
196
+ 0,
197
+ );
198
+ const [firstVisible, setFirstVisible] = useState(0);
199
+ // a zoom re-anchors the strip to the leftmost tab, so the new width fills from the left
200
+ // and the step markers disappear when every tab now fits
201
+ const [wasZoomed, setWasZoomed] = useState(props.zoomed);
202
+
203
+ if (wasZoomed !== props.zoomed) {
204
+ setWasZoomed(props.zoomed);
205
+ setFirstVisible(0);
206
+ }
207
+ const activeIndex = Math.max(
208
+ 0,
209
+ group.tabs.findIndex((tab) => tab.id === active?.id),
210
+ );
211
+ // derived every render: the manual offset is a floor the active tab can pull the window past
212
+ const tabWindow = visibleTabWindow(group.tabs, firstVisible, activeIndex, stripWidth, (tab) =>
213
+ tab.path ? (props.commentCounts?.get(tab.path) ?? 0) : 0,
214
+ );
215
+
216
+ return (
217
+ <box
218
+ onMouseDown={() => props.onFocusGroup(group.id)}
219
+ style={{ flexDirection: "column", flexGrow: 1, flexBasis: 0, minWidth: 0, minHeight: 0 }}
220
+ >
221
+ <box
222
+ customBorderChars={HEADER_UNDERLINE_CHARS}
223
+ style={{
224
+ flexDirection: "row",
225
+ // pin the controls to the right edge so their column never shifts with the container
226
+ // width parity; a lone flexGrow tab strip would drag them a cell on zoom
227
+ justifyContent: "space-between",
228
+ height: 2,
229
+ // match the pane headers: the panel token is transparent in the branded theme, so the
230
+ // header reads as terminal-through rather than a raised box
231
+ backgroundColor: tokens.panel,
232
+ borderStyle: "single",
233
+ border: ["bottom"],
234
+ borderColor: tokens.border,
235
+ }}
236
+ >
237
+ {/* the tab strip windows its tabs: the run that fits renders in full, edge markers step
238
+ the window, and the controls stay pinned on the right */}
239
+ <box
240
+ ref={stripRef}
241
+ style={{ flexDirection: "row", flexGrow: 1, flexShrink: 1, flexBasis: 0, minWidth: 0 }}
242
+ >
243
+ {tabWindow.first > 0 ? (
244
+ <box
245
+ onMouseUp={(event) => {
246
+ event.stopPropagation();
247
+ setFirstVisible(tabWindow.first - 1);
248
+ }}
249
+ style={{ flexShrink: 0 }}
250
+ >
251
+ <text fg={tokens.textDim} selectable={false}>
252
+ {"‹"}
253
+ </text>
254
+ </box>
255
+ ) : null}
256
+ {group.tabs.slice(tabWindow.first, tabWindow.end).map((tab) => (
257
+ <EditorTabButton
258
+ key={tab.id}
259
+ tab={tab}
260
+ active={tab.id === active?.id}
261
+ commentCount={tab.path ? (props.commentCounts?.get(tab.path) ?? 0) : 0}
262
+ onSelect={() => props.onActivateTab(group.id, tab.id)}
263
+ onClose={() => props.onCloseTab(group.id, tab.id)}
264
+ tokens={tokens}
265
+ />
266
+ ))}
267
+ {tabWindow.end < group.tabs.length ? (
268
+ <box
269
+ onMouseUp={(event) => {
270
+ event.stopPropagation();
271
+ setFirstVisible(tabWindow.first + 1);
272
+ }}
273
+ style={{ flexShrink: 0 }}
274
+ >
275
+ <text fg={tokens.textDim} selectable={false}>
276
+ {"›"}
277
+ </text>
278
+ </box>
279
+ ) : null}
280
+ </box>
281
+ <box style={{ flexDirection: "row", flexShrink: 0, paddingLeft: 1, paddingRight: 1 }}>
282
+ {/* search sits out until it does something; split takes its place */}
283
+ {isFile ? (
284
+ <IconButton
285
+ glyph="split"
286
+ active={menuOpen}
287
+ onPress={() => setMenuOpen((open) => !open)}
288
+ marginRight={2}
289
+ theme={tokens}
290
+ />
291
+ ) : null}
292
+ <IconButton
293
+ glyph={NERD.zoom}
294
+ active={props.zoomed}
295
+ onPress={props.onZoom}
296
+ tip={props.zoomed ? "Zoom Out" : "Zoom In"}
297
+ theme={tokens}
298
+ />
299
+ </box>
300
+ </box>
301
+ {menuOpen ? (
302
+ <SplitMenu
303
+ onPick={(direction) => {
304
+ setMenuOpen(false);
305
+ props.onSplit(group.id, direction);
306
+ }}
307
+ tokens={tokens}
308
+ />
309
+ ) : null}
310
+ <box style={{ flexGrow: 1, flexDirection: "column" }}>
311
+ {active ? props.renderTab(active, props.focusedGroupId === group.id) : null}
312
+ </box>
313
+ </box>
314
+ );
315
+ }
316
+
317
+ function GridNode({ node, props }: { node: EditorNode; props: EditorGridProps }): React.ReactNode {
318
+ if (node.type === "group") {
319
+ return <EditorGroupPane group={node} props={props} />;
320
+ }
321
+ const tokens = props.theme ?? DARK;
322
+ const horizontal = node.orientation === "horizontal";
323
+ return (
324
+ <box
325
+ style={{
326
+ flexDirection: horizontal ? "row" : "column",
327
+ flexGrow: 1,
328
+ flexBasis: 0,
329
+ minWidth: 0,
330
+ minHeight: 0,
331
+ }}
332
+ >
333
+ {node.children.map((child, index) => (
334
+ <React.Fragment key={child.id}>
335
+ {index > 0 ? (
336
+ <box
337
+ style={{
338
+ borderStyle: "single",
339
+ border: [horizontal ? "left" : "top"],
340
+ borderColor: tokens.border,
341
+ }}
342
+ />
343
+ ) : null}
344
+ <box
345
+ style={{
346
+ flexDirection: "column",
347
+ flexGrow: 1,
348
+ flexBasis: 0,
349
+ minWidth: 0,
350
+ minHeight: 0,
351
+ }}
352
+ >
353
+ <GridNode node={child} props={props} />
354
+ </box>
355
+ </React.Fragment>
356
+ ))}
357
+ </box>
358
+ );
359
+ }
360
+
361
+ /** The Changes pane body: the editor grid of groups and splits, each rendering its active tab. */
362
+ export function EditorGrid(props: EditorGridProps): React.ReactNode {
363
+ return (
364
+ <box style={{ flexDirection: "column", flexGrow: 1, minWidth: 0, minHeight: 0 }}>
365
+ <GridNode node={props.tree} props={props} />
366
+ </box>
367
+ );
368
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Regression: the header controls (search, zoom, split) stay pinned to the right
3
+ * edge whatever the container width parity. A lone flexGrow tab strip used to
4
+ * underfill by a cell at even widths, dragging the zoom icon a column left when
5
+ * zoom widened the pane; space-between pins the controls so the column is stable.
6
+ */
7
+
8
+ import { describe, expect, test } from "bun:test";
9
+ import { testRender } from "@opentui/react/test-utils";
10
+ import React from "react";
11
+ import { EditorGrid } from "./EditorGrid";
12
+ import { changesTab, fileTab, makeGroup } from "./editor-grid";
13
+ import { allowEventLoopUpdates } from "../test-support";
14
+
15
+ const grid = makeGroup([changesTab(), fileTab("App.tsx", "src/App.tsx", "diff")]);
16
+
17
+ async function zoomIconInsetFromRight(width: number, zoomed: boolean): Promise<number> {
18
+ const setup = await testRender(
19
+ <EditorGrid
20
+ tree={grid}
21
+ focusedGroupId={grid.id}
22
+ onFocusGroup={() => {}}
23
+ onActivateTab={() => {}}
24
+ onCloseTab={() => {}}
25
+ onSplit={() => {}}
26
+ onZoom={() => {}}
27
+ zoomed={zoomed}
28
+ renderTab={() => <text>body</text>}
29
+ />,
30
+ { width, height: 6 },
31
+ );
32
+
33
+ allowEventLoopUpdates();
34
+ await setup.waitForVisualIdle();
35
+ const line = setup
36
+ .captureCharFrame()
37
+ .split("\n")
38
+ .find((row) => row.includes("⛶"));
39
+ const inset = line ? width - line.indexOf("⛶") : -1;
40
+
41
+ setup.renderer.destroy();
42
+
43
+ return inset;
44
+ }
45
+
46
+ describe("zoom icon position", () => {
47
+ test("stays a fixed inset from the right edge across width parities", async () => {
48
+ // Act - even and odd widths flip the flexGrow rounding the old layout was sensitive to
49
+ const insets = await Promise.all([
50
+ zoomIconInsetFromRight(100, false),
51
+ zoomIconInsetFromRight(101, false),
52
+ zoomIconInsetFromRight(102, false),
53
+ ]);
54
+
55
+ // Assert - one inset for all widths: the icon is pinned right, not drifting with parity
56
+ expect(new Set(insets).size).toBe(1);
57
+ expect(insets[0]).toBeGreaterThan(0);
58
+ });
59
+
60
+ test("does not shift when zoom becomes active", async () => {
61
+ // Act
62
+ const inactive = await zoomIconInsetFromRight(100, false);
63
+ const active = await zoomIconInsetFromRight(100, true);
64
+
65
+ // Assert - active only recolours the glyph; it must not move
66
+ expect(active).toBe(inactive);
67
+ });
68
+ });
@@ -0,0 +1,16 @@
1
+ import React from "react";
2
+ import { DARK } from "../theme";
3
+ import type { Story, StoryMeta } from "./story";
4
+ import { FileContentsView } from "./FileContentsView";
5
+
6
+ export const meta: StoryMeta = { title: "Surfaces/FileContentsView" };
7
+
8
+ const SAMPLE = `export function add(a: number, b: number): number {\n return a + b;\n}\n`;
9
+
10
+ export const Loaded: Story = {
11
+ render: () => (
12
+ <FileContentsView path="src/add.ts" loadContents={() => Promise.resolve(SAMPLE)} theme={DARK} />
13
+ ),
14
+ expectedColors: [DARK.textDim],
15
+ size: { width: 60, height: 12 },
16
+ };
@@ -0,0 +1,29 @@
1
+ import { expect, test } from "bun:test";
2
+ import React from "react";
3
+ import { testRender } from "@opentui/react/test-utils";
4
+ import { FileContentsView } from "./FileContentsView";
5
+ import { waitForText } from "../test-support";
6
+ import { DARK } from "../theme";
7
+
8
+ test("renders the loaded file contents", async () => {
9
+ const setup = await testRender(
10
+ <FileContentsView
11
+ path="src/add.ts"
12
+ loadContents={() => Promise.resolve("const answer = 42;\n")}
13
+ theme={DARK}
14
+ />,
15
+ { width: 60, height: 10 },
16
+ );
17
+
18
+ await waitForText(setup, "const answer = 42;");
19
+ });
20
+
21
+ test("shows a centered File deleted message when the file cannot be read", async () => {
22
+ const setup = await testRender(
23
+ <FileContentsView path="missing.ts" loadContents={() => Promise.resolve(null)} theme={DARK} />,
24
+ { width: 60, height: 10 },
25
+ );
26
+
27
+ await waitForText(setup, "File deleted");
28
+ expect(setup.captureCharFrame()).toContain("File deleted");
29
+ });
@@ -0,0 +1,98 @@
1
+ // A Changes/Project file tab in contents mode: a workspace file rendered read-only with line
2
+ // numbers, for surfaces with no review session to anchor notes to (the bare-launch welcome shell).
3
+ // Loads on mount and whenever the path changes; a null read renders a centered "File deleted".
4
+ // Syntax highlighting comes from the native code renderable (tree-sitter), the language
5
+ // auto-detected from the path; unknown languages simply render unstyled.
6
+
7
+ import { ScrollArea } from "./ScrollArea";
8
+ import React, { useEffect, useRef, useState } from "react";
9
+ import type { CodeRenderable } from "@opentui/core";
10
+ import type { Theme } from "../theme";
11
+ import { useComponentTheme } from "./theme-context";
12
+ import { filetypeForPath, syntaxStyleFor } from "./syntax-highlight";
13
+
14
+ export interface FileContentsViewProps {
15
+ path: string;
16
+ loadContents: (path: string) => Promise<string | null>;
17
+ theme?: Theme;
18
+ }
19
+
20
+ /** A finished read for a specific path; `lines` is null when the file could not be read. */
21
+ interface FileLoad {
22
+ path: string;
23
+ lines: string[] | null;
24
+ }
25
+
26
+ export function FileContentsView({
27
+ path,
28
+ loadContents,
29
+ theme,
30
+ }: FileContentsViewProps): React.ReactNode {
31
+ const tokens = useComponentTheme(theme);
32
+ const [loaded, setLoaded] = useState<FileLoad | null>(null);
33
+ // The gutter mirrors the code renderable's line info, so it needs the mounted
34
+ // instance; a state-backed ref rebinds the target once the code renderable exists.
35
+ const [codeTarget, setCodeTarget] = useState<CodeRenderable | null>(null);
36
+ const loadRef = useRef(loadContents);
37
+ useEffect(() => {
38
+ loadRef.current = loadContents;
39
+ });
40
+
41
+ useEffect(() => {
42
+ let alive = true;
43
+ void loadRef.current(path).then(
44
+ (contents) => {
45
+ if (alive) setLoaded({ path, lines: contents === null ? null : contents.split("\n") });
46
+ },
47
+ () => {
48
+ if (alive) setLoaded({ path, lines: null });
49
+ },
50
+ );
51
+
52
+ return () => {
53
+ alive = false;
54
+ };
55
+ }, [path]);
56
+
57
+ if (loaded === null || loaded.path !== path) {
58
+ return (
59
+ <box style={{ flexGrow: 1, paddingLeft: 1, paddingTop: 1 }}>
60
+ <text fg={tokens.textDim}>loading...</text>
61
+ </box>
62
+ );
63
+ }
64
+ if (loaded.lines === null) {
65
+ // a file that no longer reads in the Changes/Project view is one the working tree deleted;
66
+ // the bottom pad lifts the text one row so it lines up with the footer-shortened thread empty state
67
+ return (
68
+ <box
69
+ style={{ flexGrow: 1, alignItems: "center", justifyContent: "center", paddingBottom: 2 }}
70
+ >
71
+ <text fg={tokens.textDim}>File deleted</text>
72
+ </box>
73
+ );
74
+ }
75
+ const content = loaded.lines.join("\n");
76
+
77
+ return (
78
+ <ScrollArea>
79
+ <line-number
80
+ target={codeTarget ?? undefined}
81
+ showLineNumbers
82
+ fg={tokens.textDim}
83
+ minWidth={5}
84
+ paddingRight={1}
85
+ style={{ paddingTop: 1, paddingLeft: 1 }}
86
+ >
87
+ <code
88
+ ref={setCodeTarget}
89
+ content={content}
90
+ filetype={filetypeForPath(path)}
91
+ syntaxStyle={syntaxStyleFor(tokens)}
92
+ selectable={false}
93
+ style={{ wrapMode: "none", fg: tokens.text }}
94
+ />
95
+ </line-number>
96
+ </ScrollArea>
97
+ );
98
+ }
@@ -0,0 +1,31 @@
1
+ import React from "react";
2
+ import { DARK } from "../theme";
3
+ import type { Story, StoryMeta } from "./story";
4
+ import { KeybindsDialog } from "./KeybindsDialog";
5
+
6
+ export const meta: StoryMeta = { title: "Overlays/KeybindsDialog" };
7
+
8
+ const SECTIONS = [
9
+ {
10
+ title: "Review",
11
+ entries: [
12
+ { keys: "j / k", label: "move" },
13
+ { keys: "v", label: "span" },
14
+ { keys: "c", label: "comment" },
15
+ { keys: "enter", label: "submit" },
16
+ ],
17
+ },
18
+ {
19
+ title: "Submit",
20
+ entries: [
21
+ { keys: "← / →", label: "verdict" },
22
+ { keys: "esc", label: "cancel" },
23
+ ],
24
+ },
25
+ ];
26
+
27
+ export const Cheatsheet: Story = {
28
+ render: () => <KeybindsDialog sections={SECTIONS} />,
29
+ expectedColors: [DARK.accent],
30
+ size: { width: 70, height: 24 },
31
+ };