@cueloop/client 0.1.0-alpha.3 → 0.1.0-alpha.30
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.
- package/package.json +17 -11
- package/src/App.autoclose.test.tsx +133 -0
- package/src/App.diff.test.tsx +27 -20
- package/src/App.export.test.tsx +19 -28
- package/src/App.plan-review.test.tsx +507 -0
- package/src/App.readonly.test.tsx +26 -27
- package/src/App.rendering.test.tsx +119 -0
- package/src/App.review-panel.test.tsx +123 -0
- package/src/App.submit-confirm.test.tsx +216 -0
- package/src/App.test.tsx +83 -64
- package/src/App.tsx +0 -0
- package/src/App.walk.test.tsx +265 -0
- package/src/attribution.test.ts +31 -0
- package/src/attribution.ts +32 -0
- package/src/clipboard.test.ts +23 -0
- package/src/clipboard.ts +26 -0
- package/src/components/AnnotationCard.compose-rows.test.ts +49 -0
- package/src/components/AnnotationCard.stories.tsx +75 -0
- package/src/components/AnnotationCard.tsx +208 -0
- package/src/components/Breadcrumb.stories.tsx +19 -0
- package/src/components/Breadcrumb.tsx +38 -0
- package/src/components/CodeBlock.stories.tsx +25 -0
- package/src/components/CodeBlock.tsx +68 -0
- package/src/components/CompletionOverlay.stories.tsx +23 -0
- package/src/components/CompletionOverlay.tsx +49 -0
- package/src/components/ComposeBar.stories.tsx +18 -0
- package/src/components/ComposeBar.tsx +54 -0
- package/src/components/ConfirmCard.stories.tsx +37 -0
- package/src/components/ConfirmCard.tsx +117 -0
- package/src/components/ConfirmDialog.stories.tsx +21 -0
- package/src/components/ConfirmDialog.tsx +56 -0
- package/src/components/DiffSheet.stories.tsx +44 -0
- package/src/components/DiffSheet.tsx +243 -0
- package/src/components/InboxList.stories.tsx +18 -0
- package/src/components/InboxList.tsx +55 -0
- package/src/components/PlanSheet.stories.tsx +81 -0
- package/src/components/PlanSheet.tsx +329 -0
- package/src/components/PromptDialog.stories.tsx +36 -0
- package/src/components/PromptDialog.tsx +62 -0
- package/src/components/ReviewPanel.stories.tsx +87 -0
- package/src/components/ReviewPanel.tsx +116 -0
- package/src/components/ReviewRail.stories.tsx +107 -0
- package/src/components/ReviewRail.tsx +180 -0
- package/src/components/SettingsDialog.stories.tsx +63 -0
- package/src/components/SettingsDialog.tsx +132 -0
- package/src/components/SettingsRows.stories.tsx +18 -0
- package/src/components/SettingsRows.tsx +92 -0
- package/src/components/WalkWizard.stories.tsx +89 -0
- package/src/components/WalkWizard.tsx +146 -0
- package/src/components/__snapshots__/stories.test.tsx.snap +1519 -0
- package/src/components/primitives/Button.stories.tsx +32 -0
- package/src/components/primitives/Button.tsx +44 -0
- package/src/components/primitives/Card.stories.tsx +25 -0
- package/src/components/primitives/Card.tsx +59 -0
- package/src/components/primitives/Dialog.stories.tsx +24 -0
- package/src/components/primitives/Dialog.tsx +53 -0
- package/src/components/primitives/StatusBar.stories.tsx +11 -0
- package/src/components/primitives/StatusBar.tsx +22 -0
- package/src/components/primitives/Tabs.stories.tsx +29 -0
- package/src/components/primitives/Tabs.tsx +97 -0
- package/src/components/primitives/Toolbar.stories.tsx +17 -0
- package/src/components/primitives/Toolbar.tsx +14 -0
- package/src/components/primitives/frame.test.ts +12 -0
- package/src/components/primitives/frame.ts +14 -0
- package/src/components/stories-app.tsx +95 -0
- package/src/components/stories.test.tsx +56 -0
- package/src/components/story-fixtures.ts +90 -0
- package/src/components/story.ts +66 -0
- package/src/components/syntax-highlight.ts +57 -0
- package/src/components/theme-context.stories.tsx +41 -0
- package/src/components/theme-context.tsx +30 -0
- package/src/components/truncate-text.ts +6 -0
- package/src/config.test.ts +196 -30
- package/src/config.ts +128 -16
- package/src/editor.test.ts +134 -0
- package/src/editor.ts +101 -13
- package/src/herdr.test.ts +41 -0
- package/src/herdr.ts +21 -0
- package/src/index.ts +2 -0
- package/src/integrations.test.ts +41 -0
- package/src/integrations.ts +28 -0
- package/src/intent-dispatch.test.ts +308 -0
- package/src/intent-dispatch.ts +295 -0
- package/src/key-bindings.test.ts +142 -0
- package/src/key-bindings.ts +363 -0
- package/src/keymap.test.ts +477 -0
- package/src/keymap.ts +272 -0
- package/src/review-panel.test.ts +111 -0
- package/src/review-panel.ts +76 -0
- package/src/run.ts +9 -5
- package/src/serve.test.ts +13 -6
- package/src/serve.ts +7 -7
- package/src/session-controller.share.test.ts +248 -0
- package/src/session-controller.ts +607 -0
- package/src/share.ts +80 -0
- package/src/test-support.ts +87 -0
- package/src/theme.ts +23 -1
- package/src/view-diff.test.ts +19 -8
- package/src/view-diff.ts +22 -22
- package/src/view-plan.test.ts +260 -0
- package/src/view-plan.ts +365 -0
- package/src/walk.test.ts +116 -0
- package/src/walk.ts +65 -0
- package/src/view.test.ts +0 -157
- package/src/view.ts +0 -0
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { DARK } from "../theme";
|
|
3
|
+
import type { Story, StoryMeta } from "./story";
|
|
4
|
+
import { ReviewRail } from "./ReviewRail";
|
|
5
|
+
import { FIXTURE_ANNOTATIONS, fixturePlanSession } from "./story-fixtures";
|
|
6
|
+
|
|
7
|
+
export const meta: StoryMeta = { title: "ReviewRail" };
|
|
8
|
+
|
|
9
|
+
const callbacks = {
|
|
10
|
+
onTabChange: () => {},
|
|
11
|
+
onSelectCard: () => {},
|
|
12
|
+
onActivateCard: () => {},
|
|
13
|
+
onSubmitRequest: () => {},
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/** The rail expects a height-constrained parent, like the app's main row. */
|
|
17
|
+
function RailFrame({ children }: { children: React.ReactNode }): React.ReactNode {
|
|
18
|
+
return <box style={{ flexDirection: "row", width: "100%", height: "100%" }}>{children}</box>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const AnnotationStack: Story = {
|
|
22
|
+
render: () => (
|
|
23
|
+
<RailFrame>
|
|
24
|
+
<ReviewRail
|
|
25
|
+
session={fixturePlanSession()}
|
|
26
|
+
authorNames={{}}
|
|
27
|
+
selectedId={FIXTURE_ANNOTATIONS[0]!.id}
|
|
28
|
+
resolvedIds={new Set(FIXTURE_ANNOTATIONS.map((annotation) => annotation.id))}
|
|
29
|
+
railTab="review"
|
|
30
|
+
pendingCount={2}
|
|
31
|
+
cardEdit={null}
|
|
32
|
+
submitConfirm={null}
|
|
33
|
+
{...callbacks}
|
|
34
|
+
/>
|
|
35
|
+
</RailFrame>
|
|
36
|
+
),
|
|
37
|
+
expectedColors: [DARK.panel, DARK.green],
|
|
38
|
+
size: { width: 40, height: 24 },
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export const AgentTab: Story = {
|
|
42
|
+
render: () => (
|
|
43
|
+
<RailFrame>
|
|
44
|
+
<ReviewRail
|
|
45
|
+
session={fixturePlanSession()}
|
|
46
|
+
authorNames={{}}
|
|
47
|
+
resolvedIds={new Set()}
|
|
48
|
+
railTab="agent"
|
|
49
|
+
pendingCount={2}
|
|
50
|
+
cardEdit={null}
|
|
51
|
+
submitConfirm={null}
|
|
52
|
+
{...callbacks}
|
|
53
|
+
/>
|
|
54
|
+
</RailFrame>
|
|
55
|
+
),
|
|
56
|
+
size: { width: 40, height: 24 },
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export const SubmitConfirmOpen: Story = {
|
|
60
|
+
render: () => (
|
|
61
|
+
<RailFrame>
|
|
62
|
+
<ReviewRail
|
|
63
|
+
session={fixturePlanSession()}
|
|
64
|
+
authorNames={{}}
|
|
65
|
+
resolvedIds={new Set()}
|
|
66
|
+
railTab="review"
|
|
67
|
+
pendingCount={2}
|
|
68
|
+
cardEdit={null}
|
|
69
|
+
submitConfirm={{
|
|
70
|
+
verdict: "request_changes",
|
|
71
|
+
summary: "",
|
|
72
|
+
annotationCount: 2,
|
|
73
|
+
blockingCount: 0,
|
|
74
|
+
onInput: () => {},
|
|
75
|
+
onSelectVerdict: () => {},
|
|
76
|
+
onSubmit: () => {},
|
|
77
|
+
onCancel: () => {},
|
|
78
|
+
}}
|
|
79
|
+
{...callbacks}
|
|
80
|
+
/>
|
|
81
|
+
</RailFrame>
|
|
82
|
+
),
|
|
83
|
+
expectedColors: [DARK.red],
|
|
84
|
+
size: { width: 40, height: 30 },
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
export const Resolved: Story = {
|
|
88
|
+
render: () => (
|
|
89
|
+
<RailFrame>
|
|
90
|
+
<ReviewRail
|
|
91
|
+
session={fixturePlanSession({
|
|
92
|
+
status: "resolved",
|
|
93
|
+
verdict: { kind: "approve", summary: "", feedback: "", resolvedAt: "2026-01-01T00:00:00Z" },
|
|
94
|
+
})}
|
|
95
|
+
authorNames={{}}
|
|
96
|
+
resolvedIds={new Set()}
|
|
97
|
+
railTab="review"
|
|
98
|
+
pendingCount={0}
|
|
99
|
+
cardEdit={null}
|
|
100
|
+
submitConfirm={null}
|
|
101
|
+
{...callbacks}
|
|
102
|
+
/>
|
|
103
|
+
</RailFrame>
|
|
104
|
+
),
|
|
105
|
+
expectedColors: [DARK.green],
|
|
106
|
+
size: { width: 40, height: 24 },
|
|
107
|
+
};
|
|
@@ -0,0 +1,180 @@
|
|
|
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 type { ScrollBoxRenderable } from "@opentui/core";
|
|
11
|
+
import { isAddressed, type Annotation, type ReviewSession } from "@cueloop/schema";
|
|
12
|
+
import type { Theme } from "../theme";
|
|
13
|
+
import { useComponentTheme } from "./theme-context";
|
|
14
|
+
import { Tabs, Tab, TabList } from "./primitives/Tabs";
|
|
15
|
+
import { Button } from "./primitives/Button";
|
|
16
|
+
import { AnnotationCard, type AnnotationDraft } from "./AnnotationCard";
|
|
17
|
+
import { ConfirmCard, type ConfirmCardProps } from "./ConfirmCard";
|
|
18
|
+
import { resolveDisplayName } from "../attribution";
|
|
19
|
+
|
|
20
|
+
export type RailTab = "review" | "agent";
|
|
21
|
+
|
|
22
|
+
export interface RailCardEdit extends AnnotationDraft {
|
|
23
|
+
id: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ReviewRailHandle {
|
|
27
|
+
/** Scroll the card for an annotation into view (selection symmetry). */
|
|
28
|
+
revealCard(annotationId: string): void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface ReviewRailProps {
|
|
32
|
+
session: ReviewSession;
|
|
33
|
+
/** Planner-local author renames (config [authors]); override the registry name. */
|
|
34
|
+
authorNames: Record<string, string>;
|
|
35
|
+
selectedId?: string;
|
|
36
|
+
/** Ids whose anchor resolved; null = orphan display off (diff view). */
|
|
37
|
+
resolvedIds: Set<string> | null;
|
|
38
|
+
railTab: RailTab;
|
|
39
|
+
pendingCount: number;
|
|
40
|
+
cardEdit: RailCardEdit | null;
|
|
41
|
+
submitConfirm: Omit<ConfirmCardProps, "theme"> | null;
|
|
42
|
+
onTabChange: (tab: RailTab) => void;
|
|
43
|
+
onSelectCard: (id: string) => void;
|
|
44
|
+
onActivateCard: (id: string) => void;
|
|
45
|
+
onSubmitRequest: () => void;
|
|
46
|
+
/** Rail column width; the app derives it from the persisted review layout. */
|
|
47
|
+
width?: number;
|
|
48
|
+
/**
|
|
49
|
+
* When set, renders the muted `»` chevron pinned at the rail's bottom-left,
|
|
50
|
+
* one column from the divider. Clicking it collapses the panel to compact.
|
|
51
|
+
*/
|
|
52
|
+
onCollapse?: () => void;
|
|
53
|
+
theme?: Theme;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Forward-compatible: open annotation kinds may carry a blocking flag. */
|
|
57
|
+
export function annotationBlocking(annotation: Annotation): boolean {
|
|
58
|
+
return (annotation as Annotation & { blocking?: boolean }).blocking === true;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export const ReviewRail = forwardRef<ReviewRailHandle, ReviewRailProps>(function ReviewRail(
|
|
62
|
+
{
|
|
63
|
+
session,
|
|
64
|
+
authorNames,
|
|
65
|
+
selectedId,
|
|
66
|
+
resolvedIds,
|
|
67
|
+
railTab,
|
|
68
|
+
pendingCount,
|
|
69
|
+
cardEdit,
|
|
70
|
+
submitConfirm,
|
|
71
|
+
onTabChange,
|
|
72
|
+
onSelectCard,
|
|
73
|
+
onActivateCard,
|
|
74
|
+
onSubmitRequest,
|
|
75
|
+
width = 34,
|
|
76
|
+
onCollapse,
|
|
77
|
+
theme,
|
|
78
|
+
}: ReviewRailProps,
|
|
79
|
+
handleRef,
|
|
80
|
+
): React.ReactNode {
|
|
81
|
+
const tokens = useComponentTheme(theme);
|
|
82
|
+
const scrollRef = useRef<ScrollBoxRenderable | null>(null);
|
|
83
|
+
// addressed annotations leave the card list; a dim count keeps them honest
|
|
84
|
+
const openAnnotations = session.annotations.filter((annotation) => !isAddressed(annotation));
|
|
85
|
+
const addressedCount = session.annotations.length - openAnnotations.length;
|
|
86
|
+
|
|
87
|
+
useImperativeHandle(handleRef, () => ({
|
|
88
|
+
revealCard: (annotationId: string): void => {
|
|
89
|
+
try {
|
|
90
|
+
scrollRef.current?.scrollChildIntoView(`annotation-card-${annotationId}`);
|
|
91
|
+
} catch {
|
|
92
|
+
// reveal is best-effort; selection state is already correct
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
}));
|
|
96
|
+
|
|
97
|
+
return (
|
|
98
|
+
<box style={{ width, backgroundColor: tokens.panel, flexDirection: "column", paddingLeft: 1, paddingTop: 1 }}>
|
|
99
|
+
<Tabs selectedKey={railTab} onSelectionChange={(key) => onTabChange(key as RailTab)} theme={theme}>
|
|
100
|
+
<TabList>
|
|
101
|
+
<Tab id="review">{`Review (${pendingCount})`}</Tab>
|
|
102
|
+
<Tab id="agent">Agent</Tab>
|
|
103
|
+
</TabList>
|
|
104
|
+
</Tabs>
|
|
105
|
+
<text> </text>
|
|
106
|
+
{railTab === "agent" ? (
|
|
107
|
+
<AgentTab session={session} theme={theme} />
|
|
108
|
+
) : (
|
|
109
|
+
<>
|
|
110
|
+
{session.workingCopy !== undefined ? <text fg={tokens.textDim}>± plan edits → one diff</text> : null}
|
|
111
|
+
{addressedCount > 0 ? <text fg={tokens.textDim}>✓ {addressedCount} addressed by revision</text> : null}
|
|
112
|
+
{openAnnotations.length === 0 ? (
|
|
113
|
+
<text fg={tokens.textDim}>{session.annotations.length === 0 ? "no annotations yet" : "all annotations addressed"}</text>
|
|
114
|
+
) : (
|
|
115
|
+
<scrollbox ref={scrollRef} style={{ flexGrow: 1 }} focused={false}>
|
|
116
|
+
{openAnnotations.map((annotation) => (
|
|
117
|
+
<AnnotationCard
|
|
118
|
+
key={annotation.id}
|
|
119
|
+
id={`annotation-card-${annotation.id}`}
|
|
120
|
+
kind={annotation.kind}
|
|
121
|
+
quote={annotation.anchor.quote}
|
|
122
|
+
theme={theme}
|
|
123
|
+
saved={{
|
|
124
|
+
body: annotation.body,
|
|
125
|
+
isSelected: annotation.id === selectedId,
|
|
126
|
+
isOrphan: resolvedIds !== null && !resolvedIds.has(annotation.id),
|
|
127
|
+
isBlocking: annotationBlocking(annotation),
|
|
128
|
+
authorLabel: annotation.author ? resolveDisplayName(annotation.author, session.participants, authorNames) : undefined,
|
|
129
|
+
editing:
|
|
130
|
+
cardEdit && cardEdit.id === annotation.id
|
|
131
|
+
? {
|
|
132
|
+
text: cardEdit.text,
|
|
133
|
+
onInput: cardEdit.onInput,
|
|
134
|
+
onSave: cardEdit.onSave,
|
|
135
|
+
onCancel: cardEdit.onCancel,
|
|
136
|
+
}
|
|
137
|
+
: null,
|
|
138
|
+
onPress: () =>
|
|
139
|
+
annotation.id === selectedId ? onActivateCard(annotation.id) : onSelectCard(annotation.id),
|
|
140
|
+
}}
|
|
141
|
+
/>
|
|
142
|
+
))}
|
|
143
|
+
</scrollbox>
|
|
144
|
+
)}
|
|
145
|
+
<box style={{ flexGrow: 1 }} />
|
|
146
|
+
{/* the confirm card sits OUTSIDE the scrollbox: the annotation stack
|
|
147
|
+
above scrolls while the card stays pinned to the rail bottom */}
|
|
148
|
+
{session.status === "resolved" ? (
|
|
149
|
+
<text fg={tokens.green}>resolved: {session.verdict!.kind.replace("_", " ")}</text>
|
|
150
|
+
) : submitConfirm ? (
|
|
151
|
+
<ConfirmCard {...submitConfirm} theme={theme} />
|
|
152
|
+
) : (
|
|
153
|
+
<Button variant="accent-text" onPress={onSubmitRequest} theme={theme}>
|
|
154
|
+
{`Submit review (${pendingCount}) ⏎`}
|
|
155
|
+
</Button>
|
|
156
|
+
)}
|
|
157
|
+
</>
|
|
158
|
+
)}
|
|
159
|
+
{/* the collapse chevron: muted, left-bound, one column from the divider.
|
|
160
|
+
» points right - it hands the width back toward the plan */}
|
|
161
|
+
{onCollapse ? (
|
|
162
|
+
<box style={{ flexDirection: "row" }} onMouseUp={onCollapse}>
|
|
163
|
+
<text fg={tokens.textDim}>»</text>
|
|
164
|
+
</box>
|
|
165
|
+
) : null}
|
|
166
|
+
</box>
|
|
167
|
+
);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
/** Agent tab: who submitted, where the session stands, which revision. */
|
|
171
|
+
function AgentTab({ session, theme }: { session: ReviewSession; theme?: Theme }): React.ReactNode {
|
|
172
|
+
const tokens = useComponentTheme(theme);
|
|
173
|
+
return (
|
|
174
|
+
<box style={{ flexDirection: "column", flexGrow: 1 }}>
|
|
175
|
+
<text fg={tokens.textMuted}>{session.artifact.meta.agent ?? "unknown"}</text>
|
|
176
|
+
<text fg={tokens.textDim}>status: {session.status}</text>
|
|
177
|
+
<text fg={tokens.textDim}>revision {session.revisions.length}</text>
|
|
178
|
+
</box>
|
|
179
|
+
);
|
|
180
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { DARK } from "../theme";
|
|
3
|
+
import type { Story, StoryMeta } from "./story";
|
|
4
|
+
import { SettingsDialog, type SettingsCategory } from "./SettingsDialog";
|
|
5
|
+
|
|
6
|
+
export const meta: StoryMeta = { title: "SettingsDialog" };
|
|
7
|
+
|
|
8
|
+
const CATEGORIES: SettingsCategory[] = [
|
|
9
|
+
{
|
|
10
|
+
id: "general",
|
|
11
|
+
name: "General",
|
|
12
|
+
description: "identity and submission",
|
|
13
|
+
rows: [
|
|
14
|
+
{ key: "displayName", label: "Display name", kind: "text" },
|
|
15
|
+
{ key: "autoClose", label: "Auto-close on submit", kind: "cycle", options: ["off", "3s", "on"] },
|
|
16
|
+
],
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
id: "display",
|
|
20
|
+
name: "Display",
|
|
21
|
+
description: "plan width and chrome",
|
|
22
|
+
rows: [
|
|
23
|
+
{ key: "planWidth", label: "Plan width", kind: "cycle", options: ["default", "wide", "full"] },
|
|
24
|
+
{ key: "showLineNumbers", label: "Line numbers", kind: "toggle" },
|
|
25
|
+
],
|
|
26
|
+
},
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
const VALUES = { displayName: "amber-heron", autoClose: "3s", planWidth: "default", showLineNumbers: true };
|
|
30
|
+
|
|
31
|
+
export const NavZone: Story = {
|
|
32
|
+
render: () => (
|
|
33
|
+
<SettingsDialog
|
|
34
|
+
isOpen
|
|
35
|
+
categories={CATEGORIES}
|
|
36
|
+
values={VALUES}
|
|
37
|
+
activeCategoryId="general"
|
|
38
|
+
activeRowIndex={0}
|
|
39
|
+
activeZone="nav"
|
|
40
|
+
onCategorySelect={() => {}}
|
|
41
|
+
onRowActivate={() => {}}
|
|
42
|
+
/>
|
|
43
|
+
),
|
|
44
|
+
expectedColors: [DARK.accent, DARK.backdrop],
|
|
45
|
+
size: { width: 100, height: 30 },
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export const BodyZone: Story = {
|
|
49
|
+
render: () => (
|
|
50
|
+
<SettingsDialog
|
|
51
|
+
isOpen
|
|
52
|
+
categories={CATEGORIES}
|
|
53
|
+
values={VALUES}
|
|
54
|
+
activeCategoryId="display"
|
|
55
|
+
activeRowIndex={1}
|
|
56
|
+
activeZone="body"
|
|
57
|
+
onCategorySelect={() => {}}
|
|
58
|
+
onRowActivate={() => {}}
|
|
59
|
+
/>
|
|
60
|
+
),
|
|
61
|
+
expectedColors: [DARK.green],
|
|
62
|
+
size: { width: 100, height: 30 },
|
|
63
|
+
};
|
|
@@ -0,0 +1,132 @@
|
|
|
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
|
+
}
|
|
23
|
+
|
|
24
|
+
export type SettingsValues = Record<string, string | boolean>;
|
|
25
|
+
|
|
26
|
+
export interface SettingsDialogProps {
|
|
27
|
+
isOpen: boolean;
|
|
28
|
+
categories: SettingsCategory[];
|
|
29
|
+
values: SettingsValues;
|
|
30
|
+
activeCategoryId: string;
|
|
31
|
+
activeRowIndex: number;
|
|
32
|
+
activeZone: "nav" | "body";
|
|
33
|
+
onCategorySelect: (categoryId: string) => void;
|
|
34
|
+
onRowActivate: (row: SettingsRowDescriptor) => void;
|
|
35
|
+
theme?: Theme;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function SettingsDialog({
|
|
39
|
+
isOpen,
|
|
40
|
+
categories,
|
|
41
|
+
values,
|
|
42
|
+
activeCategoryId,
|
|
43
|
+
activeRowIndex,
|
|
44
|
+
activeZone,
|
|
45
|
+
onCategorySelect,
|
|
46
|
+
onRowActivate,
|
|
47
|
+
theme,
|
|
48
|
+
}: SettingsDialogProps): React.ReactNode {
|
|
49
|
+
const tokens = useComponentTheme(theme);
|
|
50
|
+
const { width: terminalWidth, height: terminalHeight } = useTerminalDimensions();
|
|
51
|
+
if (!isOpen) return null;
|
|
52
|
+
const category = categories.find((candidate) => candidate.id === activeCategoryId) ?? categories[0];
|
|
53
|
+
if (!category) return null;
|
|
54
|
+
return (
|
|
55
|
+
<Dialog
|
|
56
|
+
isOpen
|
|
57
|
+
title=" Settings "
|
|
58
|
+
width={Math.min(76, terminalWidth - 6)}
|
|
59
|
+
height={Math.min(22, terminalHeight - 4)}
|
|
60
|
+
theme={theme}
|
|
61
|
+
>
|
|
62
|
+
<box style={{ flexDirection: "row", flexGrow: 1 }}>
|
|
63
|
+
<box style={{ flexDirection: "column", width: 18, paddingLeft: 1, paddingRight: 1 }}>
|
|
64
|
+
<text fg={tokens.textDim}>SETTINGS</text>
|
|
65
|
+
{categories.map((candidate) => {
|
|
66
|
+
const isActive = candidate.id === category.id;
|
|
67
|
+
return (
|
|
68
|
+
<box
|
|
69
|
+
key={candidate.id}
|
|
70
|
+
style={{ backgroundColor: isActive && activeZone === "nav" ? tokens.elevated : undefined }}
|
|
71
|
+
onMouseUp={() => onCategorySelect(candidate.id)}
|
|
72
|
+
>
|
|
73
|
+
<text fg={isActive ? tokens.accent : tokens.textMuted}>{`${isActive ? "› " : " "}${candidate.name}`}</text>
|
|
74
|
+
</box>
|
|
75
|
+
);
|
|
76
|
+
})}
|
|
77
|
+
</box>
|
|
78
|
+
<box style={{ width: 1, backgroundColor: tokens.border }} />
|
|
79
|
+
<box style={{ flexDirection: "column", flexGrow: 1, paddingLeft: 2, paddingRight: 1 }}>
|
|
80
|
+
<text fg={tokens.text}>{category.name}</text>
|
|
81
|
+
<text fg={tokens.textDim}>{category.description}</text>
|
|
82
|
+
<box style={{ height: 1 }} />
|
|
83
|
+
{category.rows.map((row, rowIndex) => {
|
|
84
|
+
const isActive = activeZone === "body" && rowIndex === activeRowIndex;
|
|
85
|
+
const value = values[row.key];
|
|
86
|
+
if (row.kind === "toggle") {
|
|
87
|
+
return (
|
|
88
|
+
<ToggleRow
|
|
89
|
+
key={row.key}
|
|
90
|
+
label={row.label}
|
|
91
|
+
value={value === true}
|
|
92
|
+
isActive={isActive}
|
|
93
|
+
onPress={() => onRowActivate(row)}
|
|
94
|
+
theme={theme}
|
|
95
|
+
/>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
if (row.kind === "cycle") {
|
|
99
|
+
return (
|
|
100
|
+
<CycleRow
|
|
101
|
+
key={row.key}
|
|
102
|
+
label={row.label}
|
|
103
|
+
value={String(value ?? "")}
|
|
104
|
+
isActive={isActive}
|
|
105
|
+
onPress={() => onRowActivate(row)}
|
|
106
|
+
theme={theme}
|
|
107
|
+
/>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
return (
|
|
111
|
+
<TextRow
|
|
112
|
+
key={row.key}
|
|
113
|
+
label={row.label}
|
|
114
|
+
value={String(value ?? "")}
|
|
115
|
+
isActive={isActive}
|
|
116
|
+
onPress={() => onRowActivate(row)}
|
|
117
|
+
theme={theme}
|
|
118
|
+
/>
|
|
119
|
+
);
|
|
120
|
+
})}
|
|
121
|
+
</box>
|
|
122
|
+
</box>
|
|
123
|
+
<box style={{ flexDirection: "row", height: 1, backgroundColor: tokens.elevated, paddingLeft: 1 }}>
|
|
124
|
+
<text fg={tokens.textDim}>
|
|
125
|
+
{activeZone === "nav"
|
|
126
|
+
? "j/k category · l/⏎ into settings · esc close"
|
|
127
|
+
: "j/k row · l/space change · h back · esc close"}
|
|
128
|
+
</text>
|
|
129
|
+
</box>
|
|
130
|
+
</Dialog>
|
|
131
|
+
);
|
|
132
|
+
}
|
|
@@ -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.elevated],
|
|
18
|
+
};
|
|
@@ -0,0 +1,92 @@
|
|
|
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={{ flexDirection: "row", height: 1, backgroundColor: isActive ? tokens.elevated : undefined }}
|
|
33
|
+
onMouseUp={onPress}
|
|
34
|
+
>
|
|
35
|
+
<box style={{ width: 26, paddingLeft: 1 }}>
|
|
36
|
+
<text fg={isActive ? tokens.text : tokens.textMuted}>{label}</text>
|
|
37
|
+
</box>
|
|
38
|
+
{children}
|
|
39
|
+
</box>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface ToggleRowProps {
|
|
44
|
+
label: string;
|
|
45
|
+
value: boolean;
|
|
46
|
+
isActive: boolean;
|
|
47
|
+
onPress: () => void;
|
|
48
|
+
theme?: Theme;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function ToggleRow({ label, value, isActive, onPress, theme }: ToggleRowProps): React.ReactNode {
|
|
52
|
+
const tokens = useComponentTheme(theme);
|
|
53
|
+
return (
|
|
54
|
+
<RowShell label={label} isActive={isActive} onPress={onPress} theme={theme}>
|
|
55
|
+
<text fg={value ? tokens.green : tokens.accent}>{value ? "on" : "off"}</text>
|
|
56
|
+
</RowShell>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface CycleRowProps {
|
|
61
|
+
label: string;
|
|
62
|
+
value: string;
|
|
63
|
+
isActive: boolean;
|
|
64
|
+
onPress: () => void;
|
|
65
|
+
theme?: Theme;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function CycleRow({ label, value, isActive, onPress, theme }: CycleRowProps): React.ReactNode {
|
|
69
|
+
const tokens = useComponentTheme(theme);
|
|
70
|
+
return (
|
|
71
|
+
<RowShell label={label} isActive={isActive} onPress={onPress} theme={theme}>
|
|
72
|
+
<text fg={tokens.accent}>{value}</text>
|
|
73
|
+
</RowShell>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface TextRowProps {
|
|
78
|
+
label: string;
|
|
79
|
+
value: string;
|
|
80
|
+
isActive: boolean;
|
|
81
|
+
onPress: () => void;
|
|
82
|
+
theme?: Theme;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function TextRow({ label, value, isActive, onPress, theme }: TextRowProps): React.ReactNode {
|
|
86
|
+
const tokens = useComponentTheme(theme);
|
|
87
|
+
return (
|
|
88
|
+
<RowShell label={label} isActive={isActive} onPress={onPress} theme={theme}>
|
|
89
|
+
<text fg={tokens.accent}>{value}</text>
|
|
90
|
+
</RowShell>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { DARK } from "../theme";
|
|
3
|
+
import type { WalkFile } from "../walk";
|
|
4
|
+
import type { Story, StoryMeta } from "./story";
|
|
5
|
+
import { WalkWizard } from "./WalkWizard";
|
|
6
|
+
|
|
7
|
+
export const meta: StoryMeta = { title: "WalkWizard" };
|
|
8
|
+
|
|
9
|
+
const FILES: WalkFile[] = [
|
|
10
|
+
{
|
|
11
|
+
path: "packages/daemon/src/store.ts",
|
|
12
|
+
added: 12,
|
|
13
|
+
removed: 3,
|
|
14
|
+
preview: [
|
|
15
|
+
{ sign: "+", text: "const viewed = new Set<string>();" },
|
|
16
|
+
{ sign: "+", text: "record.viewedPaths = [...viewed];" },
|
|
17
|
+
{ sign: "-", text: "// viewed tracking lives in the client only" },
|
|
18
|
+
],
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
path: "packages/client/src/walk.ts",
|
|
22
|
+
added: 48,
|
|
23
|
+
removed: 0,
|
|
24
|
+
preview: [{ sign: "+", text: "export function firstUnviewedIndex(files, viewed) {" }],
|
|
25
|
+
},
|
|
26
|
+
{ path: "packages/schema/src/types.ts", added: 3, removed: 0, preview: [{ sign: "+", text: "viewedPaths?: string[];" }] },
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
const noop = (): void => {};
|
|
30
|
+
|
|
31
|
+
export const FirstFile: Story = {
|
|
32
|
+
render: () => (
|
|
33
|
+
<WalkWizard
|
|
34
|
+
files={FILES}
|
|
35
|
+
index={0}
|
|
36
|
+
viewedPaths={new Set()}
|
|
37
|
+
terminalWidth={80}
|
|
38
|
+
onSubmitRequest={noop}
|
|
39
|
+
onBack={noop}
|
|
40
|
+
/>
|
|
41
|
+
),
|
|
42
|
+
// the main card wears the accent border; stats carry the +/- colors
|
|
43
|
+
expectedColors: [DARK.accent, DARK.green, DARK.red, DARK.insFg, DARK.delFg],
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export const WithAgentNote: Story = {
|
|
47
|
+
render: () => (
|
|
48
|
+
<WalkWizard
|
|
49
|
+
files={FILES}
|
|
50
|
+
index={1}
|
|
51
|
+
viewedPaths={new Set(["packages/daemon/src/store.ts"])}
|
|
52
|
+
note="New module: pure helpers that pick the next unviewed file. No IO, unit tested."
|
|
53
|
+
terminalWidth={80}
|
|
54
|
+
onSubmitRequest={noop}
|
|
55
|
+
onBack={noop}
|
|
56
|
+
/>
|
|
57
|
+
),
|
|
58
|
+
// the agent-note block wears the plain gray border token, not the accent
|
|
59
|
+
expectedColors: [DARK.accent, DARK.border, DARK.textMuted],
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export const EndCard: Story = {
|
|
63
|
+
render: () => (
|
|
64
|
+
<WalkWizard
|
|
65
|
+
files={FILES}
|
|
66
|
+
index={3}
|
|
67
|
+
viewedPaths={new Set(FILES.map((file) => file.path))}
|
|
68
|
+
terminalWidth={80}
|
|
69
|
+
onSubmitRequest={noop}
|
|
70
|
+
onBack={noop}
|
|
71
|
+
/>
|
|
72
|
+
),
|
|
73
|
+
// the end card wears the green border and the solid submit button
|
|
74
|
+
expectedColors: [DARK.green, DARK.accent, DARK.accentInk],
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export const EndCardPartialPass: Story = {
|
|
78
|
+
render: () => (
|
|
79
|
+
<WalkWizard
|
|
80
|
+
files={FILES}
|
|
81
|
+
index={3}
|
|
82
|
+
viewedPaths={new Set(["packages/daemon/src/store.ts"])}
|
|
83
|
+
terminalWidth={80}
|
|
84
|
+
onSubmitRequest={noop}
|
|
85
|
+
onBack={noop}
|
|
86
|
+
/>
|
|
87
|
+
),
|
|
88
|
+
expectedColors: [DARK.green],
|
|
89
|
+
};
|