@cueloop/client 0.1.0-alpha.4 → 0.1.0-alpha.40

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 (131) hide show
  1. package/package.json +32 -24
  2. package/src/App.autoclose.test.tsx +137 -0
  3. package/src/App.diff.test.tsx +158 -22
  4. package/src/App.export.test.tsx +44 -37
  5. package/src/App.plan-review.test.tsx +632 -0
  6. package/src/App.readonly.test.tsx +60 -36
  7. package/src/App.rendering.test.tsx +132 -0
  8. package/src/App.review-panel.test.tsx +136 -0
  9. package/src/App.submit-confirm.test.tsx +220 -0
  10. package/src/App.test.tsx +128 -69
  11. package/src/App.toast.test.tsx +75 -0
  12. package/src/App.tsx +0 -0
  13. package/src/App.walk.test.tsx +288 -0
  14. package/src/attribution.test.ts +31 -0
  15. package/src/attribution.ts +38 -0
  16. package/src/clipboard.test.ts +23 -0
  17. package/src/clipboard.ts +30 -0
  18. package/src/components/AnnotationCard.compose-rows.test.ts +49 -0
  19. package/src/components/AnnotationCard.stories.tsx +94 -0
  20. package/src/components/AnnotationCard.tsx +228 -0
  21. package/src/components/Breadcrumb.stories.tsx +19 -0
  22. package/src/components/Breadcrumb.tsx +44 -0
  23. package/src/components/CodeBlock.stories.tsx +42 -0
  24. package/src/components/CodeBlock.tsx +83 -0
  25. package/src/components/CompletionOverlay.stories.tsx +23 -0
  26. package/src/components/CompletionOverlay.tsx +59 -0
  27. package/src/components/ConfirmCard.stories.tsx +56 -0
  28. package/src/components/ConfirmCard.tsx +135 -0
  29. package/src/components/ConfirmDialog.stories.tsx +21 -0
  30. package/src/components/ConfirmDialog.tsx +64 -0
  31. package/src/components/DiffSheet.scroll.test.tsx +123 -0
  32. package/src/components/DiffSheet.stories.tsx +78 -0
  33. package/src/components/DiffSheet.syntax.test.tsx +62 -0
  34. package/src/components/DiffSheet.tsx +355 -0
  35. package/src/components/InboxList.stories.tsx +31 -0
  36. package/src/components/InboxList.tsx +62 -0
  37. package/src/components/KeybindsDialog.stories.tsx +31 -0
  38. package/src/components/KeybindsDialog.tsx +58 -0
  39. package/src/components/MarkerPopover.stories.tsx +58 -0
  40. package/src/components/MarkerPopover.tsx +102 -0
  41. package/src/components/MenuBar.stories.tsx +37 -0
  42. package/src/components/MenuBar.tsx +74 -0
  43. package/src/components/PlanSheet.stories.tsx +113 -0
  44. package/src/components/PlanSheet.tsx +389 -0
  45. package/src/components/PromptDialog.stories.tsx +36 -0
  46. package/src/components/PromptDialog.tsx +78 -0
  47. package/src/components/ReviewPanel.stories.tsx +86 -0
  48. package/src/components/ReviewPanel.tsx +101 -0
  49. package/src/components/ReviewRail.stories.tsx +220 -0
  50. package/src/components/ReviewRail.tsx +316 -0
  51. package/src/components/SettingsDialog.stories.tsx +111 -0
  52. package/src/components/SettingsDialog.tsx +141 -0
  53. package/src/components/SettingsRows.stories.tsx +18 -0
  54. package/src/components/SettingsRows.tsx +108 -0
  55. package/src/components/Toast.stories.tsx +12 -0
  56. package/src/components/Toast.tsx +44 -0
  57. package/src/components/WalkWizard.stories.tsx +100 -0
  58. package/src/components/WalkWizard.tsx +152 -0
  59. package/src/components/__snapshots__/stories.test.tsx.snap +1829 -0
  60. package/src/components/agent-launcher.stories.tsx +26 -0
  61. package/src/components/agent-launcher.test.tsx +21 -0
  62. package/src/components/agent-launcher.tsx +123 -0
  63. package/src/components/diff-sheet-layout.test.ts +127 -0
  64. package/src/components/diff-sheet-layout.ts +167 -0
  65. package/src/components/primitives/Button.stories.tsx +32 -0
  66. package/src/components/primitives/Button.tsx +48 -0
  67. package/src/components/primitives/Card.stories.tsx +25 -0
  68. package/src/components/primitives/Card.tsx +59 -0
  69. package/src/components/primitives/Dialog.stories.tsx +24 -0
  70. package/src/components/primitives/Dialog.tsx +64 -0
  71. package/src/components/primitives/StatusBar.stories.tsx +11 -0
  72. package/src/components/primitives/StatusBar.tsx +22 -0
  73. package/src/components/primitives/Tabs.stories.tsx +29 -0
  74. package/src/components/primitives/Tabs.tsx +98 -0
  75. package/src/components/primitives/Toolbar.stories.tsx +17 -0
  76. package/src/components/primitives/Toolbar.tsx +14 -0
  77. package/src/components/primitives/frame.test.ts +12 -0
  78. package/src/components/primitives/frame.ts +14 -0
  79. package/src/components/quick-actions-editor.stories.tsx +54 -0
  80. package/src/components/quick-actions-editor.tsx +136 -0
  81. package/src/components/stories-app.tsx +123 -0
  82. package/src/components/stories.test.tsx +62 -0
  83. package/src/components/story-fixtures.ts +98 -0
  84. package/src/components/story.ts +69 -0
  85. package/src/components/syntax-highlight.ts +92 -0
  86. package/src/components/theme-context.stories.tsx +46 -0
  87. package/src/components/theme-context.tsx +36 -0
  88. package/src/components/truncate-text.ts +6 -0
  89. package/src/config.test.ts +430 -31
  90. package/src/config.ts +304 -26
  91. package/src/diff-hunk-curate.test.ts +315 -0
  92. package/src/diff-hunk-curate.ts +233 -0
  93. package/src/diff-intraline.test.ts +164 -0
  94. package/src/diff-intraline.ts +132 -0
  95. package/src/diff-syntax.test.ts +96 -0
  96. package/src/diff-syntax.ts +154 -0
  97. package/src/editor.test.ts +143 -0
  98. package/src/editor.ts +123 -9
  99. package/src/herdr.test.ts +41 -0
  100. package/src/herdr.ts +27 -0
  101. package/src/index.ts +10 -0
  102. package/src/integrations.test.ts +41 -0
  103. package/src/integrations.ts +30 -0
  104. package/src/intent-dispatch.test.ts +492 -0
  105. package/src/intent-dispatch.ts +428 -0
  106. package/src/key-bindings.test.ts +152 -0
  107. package/src/key-bindings.ts +424 -0
  108. package/src/keymap.test.ts +622 -0
  109. package/src/keymap.ts +339 -0
  110. package/src/review-panel.test.ts +113 -0
  111. package/src/review-panel.ts +83 -0
  112. package/src/run.ts +9 -5
  113. package/src/serve.test.ts +24 -9
  114. package/src/serve.ts +7 -7
  115. package/src/session-controller.curate.test.ts +253 -0
  116. package/src/session-controller.share.test.ts +309 -0
  117. package/src/session-controller.ts +900 -0
  118. package/src/share.ts +113 -0
  119. package/src/test-support.test.ts +55 -0
  120. package/src/test-support.ts +187 -0
  121. package/src/theme-presets.ts +168 -0
  122. package/src/theme.ts +36 -15
  123. package/src/version.ts +5 -0
  124. package/src/view-diff.test.ts +19 -8
  125. package/src/view-diff.ts +34 -23
  126. package/src/view-plan.test.ts +294 -0
  127. package/src/view-plan.ts +408 -0
  128. package/src/walk.test.ts +121 -0
  129. package/src/walk.ts +70 -0
  130. package/src/view.test.ts +0 -157
  131. package/src/view.ts +0 -0
@@ -0,0 +1,316 @@
1
+ /**
2
+ * The right review rail: tab strip (Review/Agent), the scrollable annotation
3
+ * stack, and the pinned submit affordance. The confirm card sits OUTSIDE the
4
+ * scrollbox so the stack scrolls while the card stays at the rail bottom.
5
+ * Card reveal goes through the scrollbox's own scrollChildIntoView - no
6
+ * hardcoded card-height arithmetic.
7
+ */
8
+
9
+ import React, { forwardRef, useImperativeHandle, useRef } from "react";
10
+ import { createTextAttributes, type ScrollBoxRenderable } from "@opentui/core";
11
+ import { isAddressed, isAgentNote, type Annotation, type ReviewSession } from "@cueloop/schema";
12
+ import type { CurationItem } from "../session-controller";
13
+ import type { Theme } from "../theme";
14
+ import { useComponentTheme } from "./theme-context";
15
+ import { Tabs, Tab, TabList } from "./primitives/Tabs";
16
+ import { Button } from "./primitives/Button";
17
+ import { Toolbar } from "./primitives/Toolbar";
18
+ import { AnnotationCard, type AnnotationDraft } from "./AnnotationCard";
19
+ import { ConfirmCard, type ConfirmCardProps } from "./ConfirmCard";
20
+ import { AgentLauncher } from "./agent-launcher";
21
+ import { Card } from "./primitives/Card";
22
+ import { truncateToSingleLine } from "./truncate-text";
23
+ import { resolveDisplayName } from "../attribution";
24
+
25
+ export type RailTab = "review" | "agent";
26
+
27
+ export interface RailCardEdit extends AnnotationDraft {
28
+ id: string;
29
+ }
30
+
31
+ export interface ReviewRailHandle {
32
+ /** Scroll the card for an annotation into view (selection symmetry). */
33
+ revealCard(annotationId: string): void;
34
+ }
35
+
36
+ export interface ReviewRailProps {
37
+ session: ReviewSession;
38
+ /** Planner-local author renames (config [authors]); override the registry name. */
39
+ authorNames: Record<string, string>;
40
+ selectedId?: string;
41
+ /** Ids whose anchor resolved; null = orphan display off (diff view). */
42
+ resolvedIds: Set<string> | null;
43
+ /** Removed pieces (diff rejections / plan cuts) interleaved into the card stack. */
44
+ curationItems: CurationItem[];
45
+ /** The selected curation item, highlighted and the undo target. */
46
+ selectedCurationId?: string;
47
+ /**
48
+ * Sort position per annotation id (diff row index / plan display index), so
49
+ * annotation and removal cards interleave in one position-ordered stack.
50
+ */
51
+ annotationPositions?: Map<string, number>;
52
+ railTab: RailTab;
53
+ pendingCount: number;
54
+ cardEdit: RailCardEdit | null;
55
+ submitConfirm: Omit<ConfirmCardProps, "theme"> | null;
56
+ onTabChange: (tab: RailTab) => void;
57
+ onSelectCard: (id: string) => void;
58
+ onActivateCard: (id: string) => void;
59
+ /** Select a curation item (reveals its diff row); pressing undo restores it. */
60
+ onSelectCuration: (id: string) => void;
61
+ /** Restore a curation item (the selected card's undo button, same as the u key). */
62
+ onUndoCuration: (id: string) => void;
63
+ onSubmitRequest: () => void;
64
+ /** Launch a bring-your-own harness in the rail (Agent tab); seedText is the plan-context briefing. */
65
+ onLaunchHarness: (command: string, seedText?: string) => void;
66
+ /** Rail column width; the app derives it from the persisted review layout. */
67
+ width?: number;
68
+ /**
69
+ * When set, renders the muted `>` chevron pinned at the rail's bottom-left,
70
+ * one column from the divider. Clicking it collapses the panel to compact.
71
+ */
72
+ onCollapse?: () => void;
73
+ theme?: Theme;
74
+ }
75
+
76
+ /** Forward-compatible: open annotation kinds may carry a blocking flag. */
77
+ export function annotationBlocking(annotation: Annotation): boolean {
78
+ return (annotation as Annotation & { blocking?: boolean }).blocking === true;
79
+ }
80
+
81
+ export const ReviewRail = forwardRef<ReviewRailHandle, ReviewRailProps>(function ReviewRail(
82
+ {
83
+ session,
84
+ authorNames,
85
+ selectedId,
86
+ resolvedIds,
87
+ curationItems,
88
+ selectedCurationId,
89
+ annotationPositions,
90
+ railTab,
91
+ cardEdit,
92
+ submitConfirm,
93
+ onTabChange,
94
+ onSelectCard,
95
+ onActivateCard,
96
+ onSelectCuration,
97
+ onUndoCuration,
98
+ onSubmitRequest,
99
+ onLaunchHarness,
100
+ width = 34,
101
+ onCollapse,
102
+ theme,
103
+ }: ReviewRailProps,
104
+ handleRef,
105
+ ): React.ReactNode {
106
+ const tokens = useComponentTheme(theme);
107
+ const scrollRef = useRef<ScrollBoxRenderable | null>(null);
108
+ // addressed annotations leave the card list; a dim count keeps them honest
109
+ const openAnnotations = session.annotations.filter((annotation) => !isAddressed(annotation));
110
+ const addressedCount = session.annotations.length - openAnnotations.length;
111
+
112
+ useImperativeHandle(handleRef, () => ({
113
+ revealCard: (annotationId: string): void => {
114
+ try {
115
+ scrollRef.current?.scrollChildIntoView(`annotation-card-${annotationId}`);
116
+ } catch {
117
+ // reveal is best-effort; selection state is already correct
118
+ }
119
+ },
120
+ }));
121
+
122
+ const renderAnnotationCard = (annotation: Annotation): React.ReactNode => (
123
+ <AnnotationCard
124
+ key={annotation.id}
125
+ id={`annotation-card-${annotation.id}`}
126
+ kind={annotation.kind}
127
+ quote={annotation.anchor.quote}
128
+ theme={theme}
129
+ saved={{
130
+ body: annotation.body,
131
+ isSelected: annotation.id === selectedId,
132
+ isOrphan: resolvedIds !== null && !resolvedIds.has(annotation.id),
133
+ isBlocking: annotationBlocking(annotation),
134
+ // border-title author: a collaborator's name, "agent" for agent notes, else "me"
135
+ author: annotation.author
136
+ ? resolveDisplayName(annotation.author, session.participants, authorNames)
137
+ : isAgentNote(annotation)
138
+ ? "agent"
139
+ : "me",
140
+ editing:
141
+ cardEdit && cardEdit.id === annotation.id
142
+ ? {
143
+ text: cardEdit.text,
144
+ onInput: cardEdit.onInput,
145
+ onSave: cardEdit.onSave,
146
+ onCancel: cardEdit.onCancel,
147
+ }
148
+ : null,
149
+ onPress: () =>
150
+ annotation.id === selectedId
151
+ ? onActivateCard(annotation.id)
152
+ : onSelectCard(annotation.id),
153
+ }}
154
+ />
155
+ );
156
+
157
+ // one position-ordered stack: annotation and removal cards interleaved by their
158
+ // source line (diff row / plan block), original order breaking ties
159
+ const railEntries: { sort: number; order: number; node: React.ReactNode }[] = [
160
+ ...openAnnotations.map((annotation, order) => ({
161
+ sort: annotationPositions?.get(annotation.id) ?? Number.MAX_SAFE_INTEGER,
162
+ order,
163
+ node: renderAnnotationCard(annotation),
164
+ })),
165
+ ...curationItems.map((item, index) => ({
166
+ sort: item.revealIndex,
167
+ order: openAnnotations.length + index,
168
+ node: (
169
+ <RemovalCard
170
+ key={item.id}
171
+ item={item}
172
+ isSelected={item.id === selectedCurationId}
173
+ onSelect={onSelectCuration}
174
+ onUndo={onUndoCuration}
175
+ theme={theme}
176
+ />
177
+ ),
178
+ })),
179
+ ].sort((left, right) => left.sort - right.sort || left.order - right.order);
180
+
181
+ return (
182
+ <box
183
+ style={{
184
+ width,
185
+ backgroundColor: tokens.panel,
186
+ flexDirection: "column",
187
+ paddingLeft: 1,
188
+ paddingBottom: 1,
189
+ }}
190
+ >
191
+ <Tabs
192
+ selectedKey={railTab}
193
+ onSelectionChange={(key) => onTabChange(key as RailTab)}
194
+ theme={theme}
195
+ >
196
+ <TabList>
197
+ <Tab id="review">Review</Tab>
198
+ <Tab id="agent">Agent</Tab>
199
+ </TabList>
200
+ </Tabs>
201
+ {railTab === "agent" ? (
202
+ <box style={{ flexGrow: 1, flexDirection: "column", paddingLeft: 2 }}>
203
+ <text> </text>
204
+ <AgentLauncher session={session} onLaunchHarness={onLaunchHarness} theme={theme} />
205
+ </box>
206
+ ) : openAnnotations.length === 0 && curationItems.length === 0 ? (
207
+ <box style={{ flexGrow: 1, alignItems: "center", justifyContent: "center" }}>
208
+ <text fg={tokens.textDim}>
209
+ {session.annotations.length === 0 ? "no annotations yet" : "all annotations addressed"}
210
+ </text>
211
+ </box>
212
+ ) : (
213
+ <box style={{ flexGrow: 1, flexDirection: "column" }}>
214
+ {addressedCount > 0 ? (
215
+ <text fg={tokens.textDim}>✓ {addressedCount} addressed by revision</text>
216
+ ) : null}
217
+ <scrollbox ref={scrollRef} style={{ flexGrow: 1 }} focused={false}>
218
+ {railEntries.map((entry) => entry.node)}
219
+ </scrollbox>
220
+ </box>
221
+ )}
222
+ {/* the confirm card keeps full width, above the footer row */}
223
+ {railTab !== "agent" && submitConfirm ? (
224
+ <ConfirmCard {...submitConfirm} theme={theme} />
225
+ ) : null}
226
+ {/* footer row: collapse chevron left-bound, the submit affordance inline
227
+ beside it, both on the rail's last row (the plan's bottom-border height) */}
228
+ <box style={{ flexDirection: "row", alignItems: "center", justifyContent: "space-between" }}>
229
+ {onCollapse ? (
230
+ <box onMouseUp={onCollapse}>
231
+ <text fg={tokens.textDim}>{">"}</text>
232
+ </box>
233
+ ) : null}
234
+ {railTab !== "agent" && !submitConfirm ? (
235
+ session.status === "resolved" ? (
236
+ <text fg={tokens.green}>resolved: {session.verdict!.kind.replace("_", " ")}</text>
237
+ ) : (
238
+ <Button variant="accent-text" onPress={onSubmitRequest} theme={theme}>
239
+ Submit review
240
+ </Button>
241
+ )
242
+ ) : null}
243
+ </box>
244
+ </box>
245
+ );
246
+ });
247
+
248
+ /** A removal preview reads as removed content: struck through and dimmed. */
249
+ const REMOVAL_ATTRIBUTES = createTextAttributes({ strikethrough: true, dim: true });
250
+
251
+ /** Preview lines a removal card shows before it collapses to a "+N more" row. */
252
+ const REMOVAL_PREVIEW_LINES = 4;
253
+
254
+ /** Rough content width inside a removal card's border + padding, for truncation. */
255
+ const REMOVAL_PREVIEW_WIDTH = 24;
256
+
257
+ /**
258
+ * A removed piece as a full display card - the same border/padding/selection
259
+ * chrome as a saved annotation card - previewing the removed content struck
260
+ * through. Unified across diff (a curated-out hunk/change) and plan (a cut
261
+ * block). Clicking selects (App reveals its source line); the undo key restores.
262
+ */
263
+ function RemovalCard({
264
+ item,
265
+ isSelected,
266
+ onSelect,
267
+ onUndo,
268
+ theme,
269
+ }: {
270
+ item: CurationItem;
271
+ isSelected: boolean;
272
+ onSelect: (id: string) => void;
273
+ onUndo: (id: string) => void;
274
+ theme?: Theme;
275
+ }): React.ReactNode {
276
+ const tokens = useComponentTheme(theme);
277
+ const previewLines = item.preview.slice(0, REMOVAL_PREVIEW_LINES);
278
+ const overflow = item.preview.length - previewLines.length;
279
+ // body = the struck-through preview (at least one row) + an overflow row, plus
280
+ // the undo button's own row when selected (mirrors the draft card's Save/Cancel)
281
+ const contentRows =
282
+ Math.max(1, previewLines.length) + (overflow > 0 ? 1 : 0) + (isSelected ? 1 : 0);
283
+ // border = "what + who": a diff reject or a plan cut, always the reviewer's own
284
+ const title = ` ${item.source === "diff" ? "REJECT" : "CUT"} · me `;
285
+ return (
286
+ <box id={`removal-card-${item.id}`} onMouseUp={() => onSelect(item.id)}>
287
+ <Card
288
+ title={title}
289
+ contentRows={contentRows}
290
+ borderColor={isSelected ? tokens.red : tokens.border}
291
+ backgroundColor="transparent"
292
+ theme={theme}
293
+ >
294
+ {previewLines.length === 0 ? (
295
+ <text fg={tokens.textDim}> </text>
296
+ ) : (
297
+ previewLines.map((line, lineIndex) => (
298
+ <text key={lineIndex} fg={tokens.textDim}>
299
+ <span attributes={REMOVAL_ATTRIBUTES}>
300
+ {truncateToSingleLine(line, REMOVAL_PREVIEW_WIDTH)}
301
+ </span>
302
+ </text>
303
+ ))
304
+ )}
305
+ {overflow > 0 ? <text fg={tokens.textDim}>{` …+${overflow} more`}</text> : null}
306
+ {isSelected ? (
307
+ <Toolbar>
308
+ <Button variant="solid" onPress={() => onUndo(item.id)} theme={theme}>
309
+ {" undo "}
310
+ </Button>
311
+ </Toolbar>
312
+ ) : null}
313
+ </Card>
314
+ </box>
315
+ );
316
+ }
@@ -0,0 +1,111 @@
1
+ import React from "react";
2
+ import { DARK } from "../theme";
3
+ import { THEME_PRESETS } from "../theme-presets";
4
+ import type { Story, StoryMeta } from "./story";
5
+ import { SettingsDialog, type SettingsCategory } from "./SettingsDialog";
6
+
7
+ export const meta: StoryMeta = { title: "SettingsDialog" };
8
+
9
+ const CATEGORIES: SettingsCategory[] = [
10
+ {
11
+ id: "general",
12
+ name: "General",
13
+ description: "identity and submission",
14
+ rows: [
15
+ { key: "displayName", label: "Display name", kind: "text" },
16
+ {
17
+ key: "autoClose",
18
+ label: "Auto-close on submit",
19
+ kind: "cycle",
20
+ options: ["off", "3s", "on"],
21
+ },
22
+ ],
23
+ },
24
+ {
25
+ id: "display",
26
+ name: "Display",
27
+ description: "plan width and chrome",
28
+ rows: [
29
+ {
30
+ key: "planWidth",
31
+ label: "Plan width",
32
+ kind: "cycle",
33
+ options: ["default", "wide", "full"],
34
+ },
35
+ { key: "showLineNumbers", label: "Line numbers", kind: "toggle" },
36
+ ],
37
+ },
38
+ {
39
+ id: "appearance",
40
+ name: "Appearance",
41
+ description: "the color theme",
42
+ rows: [
43
+ {
44
+ key: "theme",
45
+ label: "Theme",
46
+ kind: "cycle",
47
+ options: ["cueloop", "Rosé Pine Moon", "Nord"],
48
+ },
49
+ ],
50
+ },
51
+ ];
52
+
53
+ const VALUES = {
54
+ displayName: "amber-heron",
55
+ autoClose: "3s",
56
+ planWidth: "default",
57
+ showLineNumbers: true,
58
+ theme: "Rosé Pine Moon",
59
+ };
60
+
61
+ export const NavZone: Story = {
62
+ render: () => (
63
+ <SettingsDialog
64
+ isOpen
65
+ categories={CATEGORIES}
66
+ values={VALUES}
67
+ activeCategoryId="general"
68
+ activeRowIndex={0}
69
+ activeZone="nav"
70
+ onCategorySelect={() => {}}
71
+ onRowActivate={() => {}}
72
+ />
73
+ ),
74
+ expectedColors: [DARK.accent],
75
+ size: { width: 100, height: 30 },
76
+ };
77
+
78
+ export const BodyZone: Story = {
79
+ render: () => (
80
+ <SettingsDialog
81
+ isOpen
82
+ categories={CATEGORIES}
83
+ values={VALUES}
84
+ activeCategoryId="display"
85
+ activeRowIndex={1}
86
+ activeZone="body"
87
+ onCategorySelect={() => {}}
88
+ onRowActivate={() => {}}
89
+ />
90
+ ),
91
+ expectedColors: [DARK.green],
92
+ size: { width: 100, height: 30 },
93
+ };
94
+
95
+ export const AppearanceThemed: Story = {
96
+ render: () => (
97
+ <SettingsDialog
98
+ isOpen
99
+ categories={CATEGORIES}
100
+ values={VALUES}
101
+ activeCategoryId="appearance"
102
+ activeRowIndex={0}
103
+ activeZone="body"
104
+ onCategorySelect={() => {}}
105
+ onRowActivate={() => {}}
106
+ theme={THEME_PRESETS["rose-pine-moon"]}
107
+ />
108
+ ),
109
+ expectedColors: [THEME_PRESETS["rose-pine-moon"].accent],
110
+ size: { width: 100, height: 30 },
111
+ };
@@ -0,0 +1,141 @@
1
+ /**
2
+ * The settings overlay, promoted from the design prototype: a centered
3
+ * Dialog with a left category nav and a right body of typed rows, all driven
4
+ * by one settings record. The component is controlled - the caller owns the
5
+ * open flag, the active category/row/zone, and value changes - so the app's
6
+ * keyboard grammar (and the stories catalog) can drive it without a second
7
+ * key handler inside. The modal sizes itself from the terminal dimensions.
8
+ */
9
+
10
+ import React from "react";
11
+ import { useTerminalDimensions } from "@opentui/react";
12
+ import type { Theme } from "../theme";
13
+ import { useComponentTheme } from "./theme-context";
14
+ import { Dialog } from "./primitives/Dialog";
15
+ import { CycleRow, TextRow, ToggleRow, type SettingsRowDescriptor } from "./SettingsRows";
16
+
17
+ export interface SettingsCategory {
18
+ id: string;
19
+ name: string;
20
+ description: string;
21
+ rows: SettingsRowDescriptor[];
22
+ /** A bespoke body (e.g. the quick-actions editor) rendered instead of typed rows. */
23
+ customBody?: React.ReactNode;
24
+ }
25
+
26
+ export type SettingsValues = Record<string, string | boolean>;
27
+
28
+ export interface SettingsDialogProps {
29
+ isOpen: boolean;
30
+ categories: SettingsCategory[];
31
+ values: SettingsValues;
32
+ activeCategoryId: string;
33
+ activeRowIndex: number;
34
+ activeZone: "nav" | "body";
35
+ onCategorySelect: (categoryId: string) => void;
36
+ onRowActivate: (row: SettingsRowDescriptor) => void;
37
+ theme?: Theme;
38
+ }
39
+
40
+ export function SettingsDialog({
41
+ isOpen,
42
+ categories,
43
+ values,
44
+ activeCategoryId,
45
+ activeRowIndex,
46
+ activeZone,
47
+ onCategorySelect,
48
+ onRowActivate,
49
+ theme,
50
+ }: SettingsDialogProps): React.ReactNode {
51
+ const tokens = useComponentTheme(theme);
52
+ const { width: terminalWidth, height: terminalHeight } = useTerminalDimensions();
53
+ if (!isOpen) return null;
54
+ const category =
55
+ categories.find((candidate) => candidate.id === activeCategoryId) ?? categories[0];
56
+ if (!category) return null;
57
+ return (
58
+ <Dialog
59
+ isOpen
60
+ title=" Settings "
61
+ width={Math.min(76, terminalWidth - 6)}
62
+ height={Math.min(22, terminalHeight - 4)}
63
+ background={tokens.elevated}
64
+ theme={theme}
65
+ >
66
+ <box style={{ flexDirection: "row", flexGrow: 1 }}>
67
+ <box style={{ flexDirection: "column", width: 18, paddingLeft: 1, paddingRight: 1 }}>
68
+ <text fg={tokens.textDim}>SETTINGS</text>
69
+ {categories.map((candidate) => {
70
+ const isActive = candidate.id === category.id;
71
+ return (
72
+ <box
73
+ key={candidate.id}
74
+ style={{
75
+ backgroundColor: isActive && activeZone === "nav" ? tokens.border : undefined,
76
+ }}
77
+ onMouseUp={() => onCategorySelect(candidate.id)}
78
+ >
79
+ <text
80
+ fg={isActive ? tokens.accent : tokens.textMuted}
81
+ >{`${isActive ? "› " : " "}${candidate.name}`}</text>
82
+ </box>
83
+ );
84
+ })}
85
+ </box>
86
+ <box style={{ width: 1, backgroundColor: tokens.border }} />
87
+ <box style={{ flexDirection: "column", flexGrow: 1, paddingLeft: 2, paddingRight: 1 }}>
88
+ <text fg={tokens.text}>{category.name}</text>
89
+ <text fg={tokens.textDim}>{category.description}</text>
90
+ <box style={{ height: 1 }} />
91
+ {category.customBody}
92
+ {category.rows.map((row, rowIndex) => {
93
+ const isActive = activeZone === "body" && rowIndex === activeRowIndex;
94
+ const value = values[row.key];
95
+ if (row.kind === "toggle") {
96
+ return (
97
+ <ToggleRow
98
+ key={row.key}
99
+ label={row.label}
100
+ value={value === true}
101
+ isActive={isActive}
102
+ onPress={() => onRowActivate(row)}
103
+ theme={theme}
104
+ />
105
+ );
106
+ }
107
+ if (row.kind === "cycle") {
108
+ return (
109
+ <CycleRow
110
+ key={row.key}
111
+ label={row.label}
112
+ value={String(value ?? "")}
113
+ isActive={isActive}
114
+ onPress={() => onRowActivate(row)}
115
+ theme={theme}
116
+ />
117
+ );
118
+ }
119
+ return (
120
+ <TextRow
121
+ key={row.key}
122
+ label={row.label}
123
+ value={String(value ?? "")}
124
+ isActive={isActive}
125
+ onPress={() => onRowActivate(row)}
126
+ theme={theme}
127
+ />
128
+ );
129
+ })}
130
+ </box>
131
+ </box>
132
+ <box style={{ flexDirection: "row", height: 1, paddingLeft: 1 }}>
133
+ <text fg={tokens.textDim}>
134
+ {activeZone === "nav"
135
+ ? "j/k category · l/enter into settings · esc close"
136
+ : "j/k row · l/space change · h back · esc close"}
137
+ </text>
138
+ </box>
139
+ </Dialog>
140
+ );
141
+ }
@@ -0,0 +1,18 @@
1
+ import React from "react";
2
+ import { DARK } from "../theme";
3
+ import type { Story, StoryMeta } from "./story";
4
+ import { CycleRow, TextRow, ToggleRow } from "./SettingsRows";
5
+
6
+ export const meta: StoryMeta = { title: "SettingsRows" };
7
+
8
+ export const AllKinds: Story = {
9
+ render: () => (
10
+ <box style={{ flexDirection: "column", width: 50 }}>
11
+ <ToggleRow label="Line numbers" value={true} isActive={false} onPress={() => {}} />
12
+ <ToggleRow label="Hide whitespace" value={false} isActive={true} onPress={() => {}} />
13
+ <CycleRow label="Palette" value="dark" isActive={false} onPress={() => {}} />
14
+ <TextRow label="Vault path" value="~/vaults/notes" isActive={false} onPress={() => {}} />
15
+ </box>
16
+ ),
17
+ expectedColors: [DARK.green, DARK.accent, DARK.border],
18
+ };
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Typed settings rows for the settings dialog: toggle (on/off), cycle
3
+ * (enumerated options), and text (freeform value). Every row is one line -
4
+ * label column left, value right - and presses go through `onPress` so mouse
5
+ * and keyboard activation share one path.
6
+ */
7
+
8
+ import React from "react";
9
+ import type { Theme } from "../theme";
10
+ import { useComponentTheme } from "./theme-context";
11
+
12
+ export interface SettingsRowDescriptor {
13
+ key: string;
14
+ label: string;
15
+ kind: "toggle" | "cycle" | "text";
16
+ options?: string[];
17
+ hint?: string;
18
+ }
19
+
20
+ interface RowShellProps {
21
+ label: string;
22
+ isActive: boolean;
23
+ onPress: () => void;
24
+ theme?: Theme;
25
+ children: React.ReactNode;
26
+ }
27
+
28
+ function RowShell({ label, isActive, onPress, theme, children }: RowShellProps): React.ReactNode {
29
+ const tokens = useComponentTheme(theme);
30
+ return (
31
+ <box
32
+ style={{
33
+ flexDirection: "row",
34
+ height: 1,
35
+ backgroundColor: isActive ? tokens.border : undefined,
36
+ }}
37
+ onMouseUp={onPress}
38
+ >
39
+ <box style={{ width: 26, paddingLeft: 1 }}>
40
+ <text fg={isActive ? tokens.text : tokens.textMuted}>{label}</text>
41
+ </box>
42
+ {children}
43
+ </box>
44
+ );
45
+ }
46
+
47
+ export interface ToggleRowProps {
48
+ label: string;
49
+ value: boolean;
50
+ isActive: boolean;
51
+ onPress: () => void;
52
+ theme?: Theme;
53
+ }
54
+
55
+ export function ToggleRow({
56
+ label,
57
+ value,
58
+ isActive,
59
+ onPress,
60
+ theme,
61
+ }: ToggleRowProps): React.ReactNode {
62
+ const tokens = useComponentTheme(theme);
63
+ return (
64
+ <RowShell label={label} isActive={isActive} onPress={onPress} theme={theme}>
65
+ <text fg={value ? tokens.green : tokens.accent}>{value ? "on" : "off"}</text>
66
+ </RowShell>
67
+ );
68
+ }
69
+
70
+ export interface CycleRowProps {
71
+ label: string;
72
+ value: string;
73
+ isActive: boolean;
74
+ onPress: () => void;
75
+ theme?: Theme;
76
+ }
77
+
78
+ export function CycleRow({
79
+ label,
80
+ value,
81
+ isActive,
82
+ onPress,
83
+ theme,
84
+ }: CycleRowProps): React.ReactNode {
85
+ const tokens = useComponentTheme(theme);
86
+ return (
87
+ <RowShell label={label} isActive={isActive} onPress={onPress} theme={theme}>
88
+ <text fg={tokens.accent}>{value}</text>
89
+ </RowShell>
90
+ );
91
+ }
92
+
93
+ export interface TextRowProps {
94
+ label: string;
95
+ value: string;
96
+ isActive: boolean;
97
+ onPress: () => void;
98
+ theme?: Theme;
99
+ }
100
+
101
+ export function TextRow({ label, value, isActive, onPress, theme }: TextRowProps): React.ReactNode {
102
+ const tokens = useComponentTheme(theme);
103
+ return (
104
+ <RowShell label={label} isActive={isActive} onPress={onPress} theme={theme}>
105
+ <text fg={tokens.accent}>{value}</text>
106
+ </RowShell>
107
+ );
108
+ }