@cueloop/client 0.1.0-alpha.6 → 0.1.0-alpha.61

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 (161) 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 +37 -33
  8. package/src/App.appearance.test.tsx +51 -0
  9. package/src/App.autoclose.test.tsx +73 -47
  10. package/src/App.diff.test.tsx +169 -22
  11. package/src/App.export.test.tsx +49 -37
  12. package/src/App.plan-review.test.tsx +714 -0
  13. package/src/App.prototype.test.tsx +194 -0
  14. package/src/App.readonly.test.tsx +69 -35
  15. package/src/App.rendering.test.tsx +59 -31
  16. package/src/App.review-panel.test.tsx +143 -0
  17. package/src/App.submit-confirm.test.tsx +229 -0
  18. package/src/App.test.tsx +139 -68
  19. package/src/App.toast.test.tsx +77 -0
  20. package/src/App.tsx +0 -0
  21. package/src/App.walk.test.tsx +299 -0
  22. package/src/__snapshots__/view-plan.regression.test.ts.snap +100 -0
  23. package/src/app-key-state.ts +61 -0
  24. package/src/app-screens.tsx +270 -0
  25. package/src/app-view-model.ts +307 -0
  26. package/src/attribution.test.ts +31 -0
  27. package/src/attribution.ts +42 -0
  28. package/src/clipboard.test.ts +23 -0
  29. package/src/clipboard.ts +33 -0
  30. package/src/components/AnnotationCard.compose-rows.test.ts +49 -0
  31. package/src/components/AnnotationCard.stories.tsx +94 -0
  32. package/src/components/AnnotationCard.tsx +250 -0
  33. package/src/components/Breadcrumb.stories.tsx +19 -0
  34. package/src/components/Breadcrumb.tsx +45 -0
  35. package/src/components/CodeBlock.stories.tsx +42 -0
  36. package/src/components/CodeBlock.tsx +84 -0
  37. package/src/components/CompletionOverlay.stories.tsx +23 -0
  38. package/src/components/CompletionOverlay.tsx +60 -0
  39. package/src/components/ConfirmCard.stories.tsx +35 -0
  40. package/src/components/ConfirmCard.tsx +129 -0
  41. package/src/components/ConfirmDialog.stories.tsx +21 -0
  42. package/src/components/ConfirmDialog.tsx +66 -0
  43. package/src/components/DiffSheet.scroll.test.tsx +135 -0
  44. package/src/components/DiffSheet.stories.tsx +78 -0
  45. package/src/components/DiffSheet.syntax.test.tsx +65 -0
  46. package/src/components/DiffSheet.tsx +374 -0
  47. package/src/components/InboxList.stories.tsx +31 -0
  48. package/src/components/InboxList.tsx +64 -0
  49. package/src/components/KeybindsDialog.stories.tsx +31 -0
  50. package/src/components/KeybindsDialog.tsx +59 -0
  51. package/src/components/MarkerPopover.stories.tsx +58 -0
  52. package/src/components/MarkerPopover.tsx +109 -0
  53. package/src/components/MenuBar.stories.tsx +37 -0
  54. package/src/components/MenuBar.tsx +75 -0
  55. package/src/components/PlanSheet.stories.tsx +113 -0
  56. package/src/components/PlanSheet.tsx +422 -0
  57. package/src/components/PromptDialog.stories.tsx +36 -0
  58. package/src/components/PromptDialog.tsx +80 -0
  59. package/src/components/PrototypeSheet.stories.tsx +20 -0
  60. package/src/components/PrototypeSheet.tsx +450 -0
  61. package/src/components/ReviewPanel.stories.tsx +86 -0
  62. package/src/components/ReviewPanel.tsx +103 -0
  63. package/src/components/ReviewRail.stories.tsx +218 -0
  64. package/src/components/ReviewRail.tsx +318 -0
  65. package/src/components/SettingsDialog.stories.tsx +111 -0
  66. package/src/components/SettingsDialog.tsx +147 -0
  67. package/src/components/SettingsRows.stories.tsx +18 -0
  68. package/src/components/SettingsRows.tsx +112 -0
  69. package/src/components/Toast.stories.tsx +12 -0
  70. package/src/components/Toast.tsx +45 -0
  71. package/src/components/WalkWizard.stories.tsx +100 -0
  72. package/src/components/WalkWizard.tsx +152 -0
  73. package/src/components/__snapshots__/stories.test.tsx.snap +1857 -0
  74. package/src/components/agent-launcher.stories.tsx +26 -0
  75. package/src/components/agent-launcher.test.tsx +50 -0
  76. package/src/components/agent-launcher.tsx +149 -0
  77. package/src/components/diff-sheet-layout.test.ts +128 -0
  78. package/src/components/diff-sheet-layout.ts +184 -0
  79. package/src/components/plan-sheet-run-style.ts +90 -0
  80. package/src/components/primitives/Button.stories.tsx +32 -0
  81. package/src/components/primitives/Button.tsx +52 -0
  82. package/src/components/primitives/Card.stories.tsx +25 -0
  83. package/src/components/primitives/Card.tsx +60 -0
  84. package/src/components/primitives/Dialog.stories.tsx +24 -0
  85. package/src/components/primitives/Dialog.tsx +66 -0
  86. package/src/components/primitives/StatusBar.stories.tsx +11 -0
  87. package/src/components/primitives/StatusBar.tsx +23 -0
  88. package/src/components/primitives/Tabs.stories.tsx +29 -0
  89. package/src/components/primitives/Tabs.tsx +102 -0
  90. package/src/components/primitives/Toolbar.stories.tsx +17 -0
  91. package/src/components/primitives/Toolbar.tsx +14 -0
  92. package/src/components/primitives/frame.test.ts +12 -0
  93. package/src/components/primitives/frame.ts +14 -0
  94. package/src/components/quick-actions-editor.stories.tsx +54 -0
  95. package/src/components/quick-actions-editor.tsx +138 -0
  96. package/src/components/stories-app.tsx +124 -0
  97. package/src/components/stories.test.tsx +66 -0
  98. package/src/components/story-fixtures.ts +98 -0
  99. package/src/components/story.ts +75 -0
  100. package/src/components/syntax-highlight.ts +102 -0
  101. package/src/components/terminal-pane.test.tsx +40 -0
  102. package/src/components/terminal-pane.ts +193 -0
  103. package/src/components/theme-context.stories.tsx +46 -0
  104. package/src/components/theme-context.tsx +37 -0
  105. package/src/components/truncate-text.ts +7 -0
  106. package/src/config.test.ts +448 -31
  107. package/src/config.ts +339 -57
  108. package/src/diff-hunk-curate.test.ts +315 -0
  109. package/src/diff-hunk-curate.ts +253 -0
  110. package/src/diff-intraline.test.ts +165 -0
  111. package/src/diff-intraline.ts +146 -0
  112. package/src/diff-syntax.test.ts +96 -0
  113. package/src/diff-syntax.ts +176 -0
  114. package/src/editor.test.ts +149 -0
  115. package/src/editor.ts +135 -9
  116. package/src/ghostty-terminal.test.ts +84 -0
  117. package/src/ghostty-terminal.ts +176 -0
  118. package/src/herdr.test.ts +9 -34
  119. package/src/herdr.ts +17 -21
  120. package/src/index.ts +10 -0
  121. package/src/integrations.test.ts +41 -0
  122. package/src/integrations.ts +32 -0
  123. package/src/intent-dispatch.test.ts +499 -0
  124. package/src/intent-dispatch.ts +585 -0
  125. package/src/key-bindings.test.ts +156 -0
  126. package/src/key-bindings.ts +452 -0
  127. package/src/keymap.test.ts +635 -0
  128. package/src/keymap.ts +404 -0
  129. package/src/kitty-image.test.ts +66 -0
  130. package/src/kitty-image.ts +138 -0
  131. package/src/prototype-browser.test.ts +56 -0
  132. package/src/prototype-browser.ts +289 -0
  133. package/src/pty.test.ts +81 -0
  134. package/src/pty.ts +237 -0
  135. package/src/review-panel.test.ts +113 -0
  136. package/src/review-panel.ts +83 -0
  137. package/src/run.ts +16 -5
  138. package/src/serve.test.ts +25 -9
  139. package/src/serve.ts +19 -8
  140. package/src/session-controller.curate.test.ts +260 -0
  141. package/src/session-controller.share.test.ts +320 -0
  142. package/src/session-controller.ts +993 -0
  143. package/src/share.ts +120 -0
  144. package/src/test-support.test.ts +60 -0
  145. package/src/test-support.ts +197 -0
  146. package/src/theme-presets.test.ts +51 -0
  147. package/src/theme-presets.ts +187 -0
  148. package/src/theme.ts +64 -17
  149. package/src/use-settings-dialog.tsx +275 -0
  150. package/src/version.ts +5 -0
  151. package/src/view-diff.test.ts +22 -8
  152. package/src/view-diff.ts +39 -23
  153. package/src/view-plan.regression.test.ts +60 -0
  154. package/src/view-plan.test.ts +406 -0
  155. package/src/view-plan.ts +554 -0
  156. package/src/walk.test.ts +121 -0
  157. package/src/walk.ts +74 -0
  158. package/src/syntax.test.ts +0 -41
  159. package/src/syntax.ts +0 -88
  160. package/src/view.test.ts +0 -157
  161. package/src/view.ts +0 -0
@@ -0,0 +1,270 @@
1
+ import React, { type Dispatch, type SetStateAction } from "react";
2
+ import type { ReviewSession, VerdictKind } from "@cueloop/schema";
3
+ import { returnPaneFor } from "@cueloop/schema";
4
+ import type { Theme } from "./theme";
5
+ import type { Mode } from "./intent-dispatch";
6
+ import type { Intent } from "./keymap";
7
+ import type { ReviewController, ToastState } from "./session-controller";
8
+ import { noteForFile } from "./walk";
9
+ import type { WalkFile } from "./walk";
10
+ import type { CheatsheetSection } from "./key-bindings";
11
+ import type { SettingsCategory, SettingsValues } from "./components/SettingsDialog";
12
+ import type { SettingsNav } from "./use-settings-dialog";
13
+ import { CLIENT_VERSION } from "./version";
14
+ import { ThemeProvider } from "./components/theme-context";
15
+ import { MenuBar } from "./components/MenuBar";
16
+ import { KeybindsDialog } from "./components/KeybindsDialog";
17
+ import { SettingsDialog } from "./components/SettingsDialog";
18
+ import { CompletionOverlay } from "./components/CompletionOverlay";
19
+ import { InboxList } from "./components/InboxList";
20
+ import { ConfirmDialog } from "./components/ConfirmDialog";
21
+ import { PromptDialog } from "./components/PromptDialog";
22
+ import { WalkWizard } from "./components/WalkWizard";
23
+ import { Toast } from "./components/Toast";
24
+
25
+ export function ErrorScreen({ error, theme }: { error: string; theme: Theme }): React.ReactNode {
26
+ return (
27
+ <ThemeProvider theme={theme}>
28
+ <text fg={theme.red}>cueloop: {error}</text>
29
+ </ThemeProvider>
30
+ );
31
+ }
32
+
33
+ export function ConnectingScreen({ theme }: { theme: Theme }): React.ReactNode {
34
+ return (
35
+ <ThemeProvider theme={theme}>
36
+ <text fg={theme.textDim}>connecting to the daemon…</text>
37
+ </ThemeProvider>
38
+ );
39
+ }
40
+
41
+ export function MenuChrome(props: {
42
+ menuOpen: boolean;
43
+ menuDialog: "keybinds" | "settings" | null;
44
+ status: string;
45
+ theme: Theme;
46
+ setMenuOpen: Dispatch<SetStateAction<boolean>>;
47
+ setMenuDialog: Dispatch<SetStateAction<"keybinds" | "settings" | null>>;
48
+ keybindsSections: CheatsheetSection[];
49
+ settingsCategories: SettingsCategory[];
50
+ settingsValues: SettingsValues;
51
+ settingsNav: SettingsNav;
52
+ onCategorySelect: (categoryId: string) => void;
53
+ cycleSetting: (rowKey: string) => void;
54
+ }): React.ReactNode {
55
+ const {
56
+ menuOpen,
57
+ menuDialog,
58
+ status,
59
+ theme,
60
+ setMenuOpen,
61
+ setMenuDialog,
62
+ keybindsSections,
63
+ settingsCategories,
64
+ settingsValues,
65
+ settingsNav,
66
+ onCategorySelect,
67
+ cycleSetting,
68
+ } = props;
69
+
70
+ return (
71
+ <>
72
+ <MenuBar
73
+ open={menuOpen}
74
+ version={CLIENT_VERSION}
75
+ status={status}
76
+ onToggle={() => setMenuOpen((isOpen) => !isOpen)}
77
+ onSettings={() => {
78
+ setMenuOpen(false);
79
+ setMenuDialog("settings");
80
+ }}
81
+ onKeybinds={() => {
82
+ setMenuOpen(false);
83
+ setMenuDialog("keybinds");
84
+ }}
85
+ theme={theme}
86
+ />
87
+ {menuDialog === "keybinds" ? (
88
+ <KeybindsDialog sections={keybindsSections} theme={theme} />
89
+ ) : null}
90
+ {menuDialog === "settings" ? (
91
+ <SettingsDialog
92
+ isOpen
93
+ categories={settingsCategories}
94
+ values={settingsValues}
95
+ activeCategoryId={settingsNav.categoryId}
96
+ activeRowIndex={settingsNav.rowIndex}
97
+ activeZone={settingsNav.zone}
98
+ onCategorySelect={onCategorySelect}
99
+ onRowActivate={(row) => cycleSetting(row.key)}
100
+ theme={theme}
101
+ />
102
+ ) : null}
103
+ </>
104
+ );
105
+ }
106
+
107
+ export function InboxScreen(props: {
108
+ inbox: ReviewSession[];
109
+ inboxCursor: number;
110
+ mode: Mode;
111
+ theme: Theme;
112
+ controller: ReviewController;
113
+ setMode: Dispatch<SetStateAction<Mode>>;
114
+ menuChrome: React.ReactNode;
115
+ }): React.ReactNode {
116
+ const { inbox, inboxCursor, mode, theme, controller, setMode, menuChrome } = props;
117
+ const confirming = mode.type === "confirmDelete" ? mode : null;
118
+
119
+ return (
120
+ <ThemeProvider theme={theme}>
121
+ <box
122
+ style={{
123
+ flexDirection: "column",
124
+ width: "100%",
125
+ height: "100%",
126
+ backgroundColor: theme.background,
127
+ }}
128
+ >
129
+ {/* mirrors the review header row: same box, position, and accent product
130
+ word, with a " · resume" separator and no Edit/Share toolbar */}
131
+ <box
132
+ style={{ flexDirection: "row", height: 2, paddingTop: 1, backgroundColor: theme.panel }}
133
+ >
134
+ <box
135
+ style={{
136
+ height: 1,
137
+ backgroundColor: theme.panel,
138
+ paddingLeft: 1,
139
+ flexDirection: "row",
140
+ }}
141
+ >
142
+ <text>
143
+ <span fg={theme.accent}>cueloop</span>
144
+ <span fg={theme.textDim}> · resume</span>
145
+ </text>
146
+ </box>
147
+ <box style={{ flexGrow: 1 }} />
148
+ </box>
149
+ <InboxList
150
+ inbox={inbox}
151
+ cursor={inboxCursor}
152
+ onRequestDelete={(id, title) => setMode({ type: "confirmDelete", sessionId: id, title })}
153
+ />
154
+ {menuChrome}
155
+ <ConfirmDialog
156
+ isOpen={confirming !== null}
157
+ title=" Delete plan "
158
+ message={
159
+ confirming ? `Delete "${confirming.title}"? This removes the plan and its review.` : ""
160
+ }
161
+ onConfirm={() => {
162
+ if (confirming) controller.deleteSession(confirming.sessionId);
163
+ setMode({ type: "normal" });
164
+ }}
165
+ onCancel={() => setMode({ type: "normal" })}
166
+ theme={theme}
167
+ />
168
+ </box>
169
+ </ThemeProvider>
170
+ );
171
+ }
172
+
173
+ export function CompletionScreen(props: {
174
+ theme: Theme;
175
+ session: ReviewSession;
176
+ verdict: VerdictKind;
177
+ completion: { phase: "prompt" } | { phase: "counting"; remaining: number };
178
+ status: string;
179
+ }): React.ReactNode {
180
+ const { theme, session, verdict, completion, status } = props;
181
+
182
+ return (
183
+ <ThemeProvider theme={theme}>
184
+ <CompletionOverlay
185
+ verdict={verdict}
186
+ completion={completion}
187
+ status={status}
188
+ returnsTo={
189
+ returnPaneFor(session.artifact.meta.herdrPane)
190
+ ? (session.artifact.meta.agent ?? "the agent")
191
+ : undefined
192
+ }
193
+ />
194
+ </ThemeProvider>
195
+ );
196
+ }
197
+
198
+ export function TrailingOverlays(props: {
199
+ walking: boolean;
200
+ walk: { index: number } | null;
201
+ walkFileList: WalkFile[];
202
+ viewedPaths: Set<string>;
203
+ session: ReviewSession;
204
+ terminalWidth: number;
205
+ theme: Theme;
206
+ mode: Mode;
207
+ toast: ToastState | null;
208
+ setMode: Dispatch<SetStateAction<Mode>>;
209
+ dispatch: (intent: Intent) => void;
210
+ }): React.ReactNode {
211
+ const {
212
+ walking,
213
+ walk,
214
+ walkFileList,
215
+ viewedPaths,
216
+ session,
217
+ terminalWidth,
218
+ theme,
219
+ mode,
220
+ toast,
221
+ setMode,
222
+ dispatch,
223
+ } = props;
224
+
225
+ return (
226
+ <>
227
+ {walking && walk !== null ? (
228
+ <WalkWizard
229
+ files={walkFileList}
230
+ index={walk.index}
231
+ viewedPaths={viewedPaths}
232
+ note={
233
+ walkFileList[walk.index] !== undefined
234
+ ? noteForFile(session.annotations, walkFileList[walk.index]!.path)
235
+ : undefined
236
+ }
237
+ terminalWidth={terminalWidth}
238
+ onSubmitRequest={() => {
239
+ dispatch({ type: "walkLeave" });
240
+ dispatch({ type: "openSubmit" });
241
+ }}
242
+ onBack={() => dispatch({ type: "walkBack" })}
243
+ />
244
+ ) : null}
245
+ {mode.type === "rename" ? (
246
+ <PromptDialog
247
+ isOpen
248
+ title=" Rename author "
249
+ label="Display name for this collaborator:"
250
+ value={mode.text}
251
+ placeholder="their name"
252
+ onInput={(text) => setMode({ ...mode, text })}
253
+ theme={theme}
254
+ />
255
+ ) : null}
256
+ {mode.type === "nameSelf" ? (
257
+ <PromptDialog
258
+ isOpen
259
+ title=" Welcome "
260
+ label="Your name (optional) - it attributes the notes you leave:"
261
+ value={mode.text}
262
+ placeholder="your name"
263
+ onInput={(text) => setMode({ ...mode, text })}
264
+ theme={theme}
265
+ />
266
+ ) : null}
267
+ {toast ? <Toast title={toast.title} body={toast.body} theme={theme} /> : null}
268
+ </>
269
+ );
270
+ }
@@ -0,0 +1,307 @@
1
+ import type { Dispatch, MutableRefObject, SetStateAction } from "react";
2
+ import type { ReviewSession, VerdictKind } from "@cueloop/schema";
3
+ import { activeSpanState, reviewerAnnotations, type Mode } from "./intent-dispatch";
4
+ import { displayText, type DisplayBlock } from "./view-plan";
5
+ import type { DiffRow } from "./view-diff";
6
+ import type { Intent, KeyState } from "./keymap";
7
+ import type { Completion, ReviewController } from "./session-controller";
8
+ import type { QuickAction } from "./config";
9
+ import type { WalkFile } from "./walk";
10
+ import { viewedCount } from "./walk";
11
+ import type { PlanComposeState, PlanPopoverState } from "./components/PlanSheet";
12
+ import type { DiffComposeState } from "./components/DiffSheet";
13
+ import type { ConfirmCardProps } from "./components/ConfirmCard";
14
+ import type { RailCardEdit } from "./components/ReviewRail";
15
+ import type { BreadcrumbItem } from "./components/Breadcrumb";
16
+ import { REVIEW_COMPACT_WIDTH, resolveReviewWidth, type ReviewPanelMode } from "./review-panel";
17
+
18
+ export function computeRoleCapabilities(
19
+ readOnly: boolean,
20
+ role: "owner" | "observer" | "collaborator",
21
+ ): { observer: boolean; isOwner: boolean } {
22
+ const observer = readOnly || role === "observer";
23
+
24
+ return { observer, isOwner: !observer && role === "owner" };
25
+ }
26
+
27
+ export function deriveReviewFlags(session: ReviewSession | null): {
28
+ isDiff: boolean;
29
+ isPrototype: boolean;
30
+ resolved: boolean;
31
+ } {
32
+ return {
33
+ isDiff: session?.artifact.type === "diff",
34
+ isPrototype: session?.artifact.type === "prototype",
35
+ resolved: session?.status === "resolved",
36
+ };
37
+ }
38
+
39
+ export function isWalking(isDiff: boolean, walk: { index: number } | null): boolean {
40
+ return isDiff && walk !== null;
41
+ }
42
+
43
+ export function resolveOverlay(
44
+ mode: Mode,
45
+ completionPhase: Completion["phase"],
46
+ walking: boolean,
47
+ ): KeyState["overlay"] {
48
+ if (mode.type === "compose" || mode.type === "railEdit") return "compose";
49
+ if (mode.type === "submit") return "submit";
50
+ if (mode.type === "confirmDelete") return "confirm";
51
+ if (mode.type === "rename" || mode.type === "nameSelf") return "prompt";
52
+ if (mode.type === "spanActions") return "spanActions";
53
+ if (completionPhase === "prompt") return "completion-prompt";
54
+ if (completionPhase === "counting") return "completion-counting";
55
+ if (walking) return "walk";
56
+
57
+ return "none";
58
+ }
59
+
60
+ export function isCompletionOverlayPhase(
61
+ completion: Completion,
62
+ ): completion is { phase: "prompt" } | { phase: "counting"; remaining: number } {
63
+ return completion.phase === "prompt" || completion.phase === "counting";
64
+ }
65
+
66
+ export function computePendingCount(session: ReviewSession): number {
67
+ return reviewerAnnotations(session).length + (session.workingCopy !== undefined ? 1 : 0);
68
+ }
69
+
70
+ export function computeRailFootprint(
71
+ reviewMode: ReviewPanelMode,
72
+ reviewWidth: number,
73
+ terminalWidth: number,
74
+ ): number {
75
+ if (reviewMode === "hidden") return 0;
76
+
77
+ return (
78
+ 1 +
79
+ (reviewMode === "compact"
80
+ ? REVIEW_COMPACT_WIDTH
81
+ : resolveReviewWidth(reviewWidth, terminalWidth))
82
+ );
83
+ }
84
+
85
+ export function buildHeaderItems(params: {
86
+ session: ReviewSession;
87
+ resolved: boolean;
88
+ observer: boolean;
89
+ role: "owner" | "observer" | "collaborator";
90
+ }): BreadcrumbItem[] {
91
+ const { session, resolved, observer, role } = params;
92
+
93
+ return [
94
+ { label: "cueloop", tone: "accent" },
95
+ ...(resolved
96
+ ? [
97
+ {
98
+ label: `resolved: ${session.verdict!.kind.replace("_", " ")}`,
99
+ tone: "green" as const,
100
+ },
101
+ ]
102
+ : []),
103
+ ...(observer ? [{ label: "observer", tone: "dim" as const }] : []),
104
+ ...(role === "collaborator"
105
+ ? [{ label: "shared · your notes save as you go", tone: "dim" as const }]
106
+ : []),
107
+ {
108
+ label: `${session.artifact.meta.title ?? session.artifact.meta.planPath ?? session.id} · rev ${session.revisions.length}`,
109
+ tone: "dim",
110
+ },
111
+ { label: `submitted by ${session.artifact.meta.agent ?? "unknown"}`, tone: "dim" },
112
+ ];
113
+ }
114
+
115
+ export function buildRenderFlags(params: {
116
+ session: ReviewSession;
117
+ isOwner: boolean;
118
+ isDiff: boolean;
119
+ isPrototype: boolean;
120
+ resolved: boolean;
121
+ menuOpen: boolean;
122
+ menuDialog: "keybinds" | "settings" | null;
123
+ resolvedIds: Set<string>;
124
+ }): {
125
+ showOwnerActions: boolean;
126
+ prototypeCanComment: boolean;
127
+ chromeHidden: boolean;
128
+ prototypePath: string;
129
+ railResolvedIds: Set<string> | null;
130
+ } {
131
+ const { session, isOwner, isDiff, isPrototype, resolved, menuOpen, menuDialog, resolvedIds } =
132
+ params;
133
+
134
+ return {
135
+ showOwnerActions: isOwner && !isDiff && !resolved,
136
+ prototypeCanComment: isOwner && !resolved,
137
+ chromeHidden: menuOpen || menuDialog !== null,
138
+ prototypePath: session.artifact.meta.prototypePath ?? "",
139
+ railResolvedIds: isDiff || isPrototype ? null : resolvedIds,
140
+ };
141
+ }
142
+
143
+ export function buildActiveSpan(
144
+ mode: Mode,
145
+ isDiff: boolean,
146
+ ): { displayIndex: number; start: number; end: number } | null {
147
+ const markedSpan = activeSpanState(mode);
148
+
149
+ if (markedSpan)
150
+ return { displayIndex: markedSpan.displayIndex, start: markedSpan.start, end: markedSpan.end };
151
+ if (mode.type === "compose" && !isDiff)
152
+ return { displayIndex: mode.displayIndex, start: mode.start, end: mode.end };
153
+
154
+ return null;
155
+ }
156
+
157
+ interface DraftHandlerDeps {
158
+ liveInput: MutableRefObject<string>;
159
+ setMode: Dispatch<SetStateAction<Mode>>;
160
+ dispatch: (intent: Intent) => void;
161
+ }
162
+
163
+ export function buildComposeState(
164
+ deps: DraftHandlerDeps & { mode: Mode; isDiff: boolean; display: DisplayBlock[] },
165
+ ): PlanComposeState | null {
166
+ const { mode, isDiff, display, liveInput, setMode, dispatch } = deps;
167
+
168
+ if (mode.type !== "compose" || isDiff) return null;
169
+
170
+ return {
171
+ kind: mode.kind,
172
+ displayIndex: mode.displayIndex,
173
+ quote: displayText(display[mode.displayIndex]!).slice(mode.start, mode.end),
174
+ draft: {
175
+ text: mode.text,
176
+ onInput: (text: string) => {
177
+ liveInput.current = text;
178
+ setMode({ ...mode, text });
179
+ },
180
+ onSave: () => dispatch({ type: "saveCompose" }),
181
+ onCancel: () => dispatch({ type: "closeOverlay" }),
182
+ },
183
+ };
184
+ }
185
+
186
+ export function buildDiffComposeState(
187
+ deps: DraftHandlerDeps & { mode: Mode; isDiff: boolean; rows: DiffRow[] },
188
+ ): DiffComposeState | null {
189
+ const { mode, isDiff, rows, liveInput, setMode, dispatch } = deps;
190
+
191
+ if (mode.type !== "compose" || !isDiff) return null;
192
+
193
+ return {
194
+ kind: mode.kind,
195
+ rowIndex: mode.displayIndex,
196
+ quote: rows[mode.displayIndex]?.text.replace(/\n$/, "") ?? "",
197
+ draft: {
198
+ text: mode.text,
199
+ onInput: (text: string) => {
200
+ liveInput.current = text;
201
+ setMode({ ...mode, text });
202
+ },
203
+ onSave: () => dispatch({ type: "saveCompose" }),
204
+ onCancel: () => dispatch({ type: "closeOverlay" }),
205
+ },
206
+ };
207
+ }
208
+
209
+ export function buildPopoverState(deps: {
210
+ mode: Mode;
211
+ isDiff: boolean;
212
+ quickActions: QuickAction[];
213
+ isOwner: boolean;
214
+ observer: boolean;
215
+ resolved: boolean;
216
+ controller: ReviewController;
217
+ dispatch: (intent: Intent) => void;
218
+ }): PlanPopoverState | null {
219
+ const { mode, isDiff, quickActions, isOwner, observer, resolved, controller, dispatch } = deps;
220
+ const markedSpan = activeSpanState(mode);
221
+
222
+ if (!markedSpan || isDiff) return null;
223
+ const spanMutationBlock = (): string | null =>
224
+ observer ? "observer - read-only" : resolved ? "review submitted - read-only" : null;
225
+
226
+ return {
227
+ displayIndex: markedSpan.displayIndex,
228
+ view: mode.type === "spanActions" ? ("actions" as const) : ("toolbar" as const),
229
+ actions: quickActions,
230
+ actionIndex: mode.type === "spanActions" ? mode.index : 0,
231
+ canCut: isOwner,
232
+ onComment: () => {
233
+ const blocked = spanMutationBlock();
234
+
235
+ if (blocked) return controller.setStatus(blocked);
236
+ dispatch({ type: "openCompose", kind: "comment", from: "span" });
237
+ },
238
+ onCut: () => {
239
+ const blocked = spanMutationBlock();
240
+
241
+ if (blocked) return controller.setStatus(blocked);
242
+ if (!isOwner) return;
243
+ dispatch({ type: "spanCut" });
244
+ },
245
+ onOpenActions: () => {
246
+ const blocked = spanMutationBlock();
247
+
248
+ if (blocked) return controller.setStatus(blocked);
249
+ dispatch({ type: "openSpanActions" });
250
+ },
251
+ onClose: () => dispatch({ type: "closeOverlay" }),
252
+ onPickAction: (index: number) => {
253
+ const blocked = spanMutationBlock();
254
+
255
+ if (blocked) return controller.setStatus(blocked);
256
+ dispatch({ type: "pickSpanAction", index });
257
+ },
258
+ onBack: () => dispatch({ type: "closeSpanActions" }),
259
+ };
260
+ }
261
+
262
+ export function buildSubmitConfirmState(
263
+ deps: DraftHandlerDeps & {
264
+ mode: Mode;
265
+ isDiff: boolean;
266
+ session: ReviewSession;
267
+ walkFileList: WalkFile[];
268
+ viewedPaths: Set<string>;
269
+ },
270
+ ): Omit<ConfirmCardProps, "theme"> | null {
271
+ const { mode, isDiff, session, walkFileList, viewedPaths, liveInput, setMode, dispatch } = deps;
272
+
273
+ if (mode.type !== "submit") return null;
274
+
275
+ return {
276
+ verdict: mode.verdict,
277
+ summary: mode.summary,
278
+ viewedSummary:
279
+ isDiff && session.viewedPaths !== undefined
280
+ ? `${viewedCount(walkFileList, viewedPaths)}/${walkFileList.length} files viewed`
281
+ : undefined,
282
+ onInput: (summary: string) => {
283
+ liveInput.current = summary;
284
+ setMode({ ...mode, summary });
285
+ },
286
+ onSelectVerdict: (verdict: VerdictKind) => setMode({ ...mode, verdict }),
287
+ onSubmit: () => dispatch({ type: "submitVerdict" }),
288
+ onCancel: () => dispatch({ type: "closeOverlay" }),
289
+ };
290
+ }
291
+
292
+ export function buildCardEditState(deps: DraftHandlerDeps & { mode: Mode }): RailCardEdit | null {
293
+ const { mode, liveInput, setMode, dispatch } = deps;
294
+
295
+ if (mode.type !== "railEdit") return null;
296
+
297
+ return {
298
+ id: mode.id,
299
+ text: mode.text,
300
+ onInput: (text: string) => {
301
+ liveInput.current = text;
302
+ setMode({ type: "railEdit", id: mode.id, text });
303
+ },
304
+ onSave: () => dispatch({ type: "saveCompose" }),
305
+ onCancel: () => dispatch({ type: "closeOverlay" }),
306
+ };
307
+ }
@@ -0,0 +1,31 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { Identity } from "@cueloop/schema";
3
+ import { ANONYMOUS_LABEL, resolveDisplayName, shortHandle } from "./attribution";
4
+
5
+ describe(shortHandle, () => {
6
+ test("shortens an ssh fingerprint to a stable handle", () => {
7
+ // Arrange / Act / Assert
8
+ expect(shortHandle("SHA256:1a2b3c4d5e6f")).toBe("1a2b3c4d");
9
+ expect(shortHandle("SHA256:1a2b3c4d5e6f")).toBe(shortHandle("SHA256:1a2b3c4d5e6f"));
10
+ });
11
+ });
12
+
13
+ describe(resolveDisplayName, () => {
14
+ const named: Identity[] = [{ id: "SHA256:abc", provider: "ssh", name: "Al" }];
15
+ const unnamed: Identity[] = [{ id: "SHA256:abc", provider: "ssh" }];
16
+
17
+ test("a local rename override wins over everything", () => {
18
+ // Arrange / Act
19
+ const label = resolveDisplayName("SHA256:abc", named, { "SHA256:abc": "Alex" });
20
+
21
+ // Assert
22
+ expect(label).toBe("Alex");
23
+ });
24
+
25
+ test("falls back to name, then anonymous, then the short handle", () => {
26
+ // Arrange / Act / Assert
27
+ expect(resolveDisplayName("SHA256:abc", named, {})).toBe("Al");
28
+ expect(resolveDisplayName("SHA256:abc", unnamed, {})).toBe(ANONYMOUS_LABEL);
29
+ expect(resolveDisplayName("SHA256:1a2b3c4d5e", [], {})).toBe("1a2b3c4d");
30
+ });
31
+ });
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Attribution labels for annotations. An annotation's `author` is an identity
3
+ * id (an SSH fingerprint today). The rail resolves it against the share's
4
+ * participant registry and the planner's local rename overrides; ADR 0006.
5
+ */
6
+
7
+ import type { Identity } from "@cueloop/schema";
8
+
9
+ const FINGERPRINT_PREFIX = "SHA256:";
10
+
11
+ /** Shown for a known participant who chose not to give a name. */
12
+ export const ANONYMOUS_LABEL = "-- anonymous --";
13
+
14
+ /** A short, stable handle from a raw author id when no name is known. */
15
+ export function shortHandle(authorId: string): string {
16
+ const base = authorId.startsWith(FINGERPRINT_PREFIX)
17
+ ? authorId.slice(FINGERPRINT_PREFIX.length)
18
+ : authorId;
19
+
20
+ return base.slice(0, 8) || authorId;
21
+ }
22
+
23
+ /**
24
+ * The rail label for an author id: a local rename wins, then the collaborator's
25
+ * own name, then anonymous for a known-but-unnamed participant, then a short
26
+ * handle for an id we hold no identity for.
27
+ */
28
+ export function resolveDisplayName(
29
+ authorId: string,
30
+ participants: Identity[] | undefined,
31
+ overrides: Record<string, string>,
32
+ ): string {
33
+ const override = overrides[authorId];
34
+
35
+ if (override) return override;
36
+ const identity = participants?.find((participant) => participant.id === authorId);
37
+
38
+ if (identity?.name) return identity.name;
39
+ if (identity) return ANONYMOUS_LABEL;
40
+
41
+ return shortHandle(authorId);
42
+ }
@@ -0,0 +1,23 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { clipboardCommands } from "./clipboard";
3
+
4
+ describe(clipboardCommands, () => {
5
+ test("uses pbcopy on macOS", () => {
6
+ // Assert
7
+ expect(clipboardCommands("darwin")).toEqual([["pbcopy"]]);
8
+ });
9
+
10
+ test("uses clip.exe on Windows", () => {
11
+ // Assert
12
+ expect(clipboardCommands("win32")).toEqual([["clip.exe"]]);
13
+ });
14
+
15
+ test("prefers wayland, then X11 tools on Linux", () => {
16
+ // Act
17
+ const commands = clipboardCommands("linux");
18
+
19
+ // Assert
20
+ expect(commands[0]).toEqual(["wl-copy"]);
21
+ expect(commands.map((command) => command[0])).toEqual(["wl-copy", "xclip", "xsel"]);
22
+ });
23
+ });