@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,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The composer's auto-grow row math as a pure function: hard newlines and soft
|
|
3
|
+
* wrapping both add rows, growth stops at the cap, and a non-positive width
|
|
4
|
+
* (before layout is known) degrades to a hard-newline count. The live textarea
|
|
5
|
+
* scrolls internally past the cap, so this only needs to get the growth curve
|
|
6
|
+
* and the ceiling right.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, expect, test } from "bun:test";
|
|
10
|
+
import { COMPOSE_MAX_ROWS, composeRowCount } from "./AnnotationCard";
|
|
11
|
+
|
|
12
|
+
describe("composeRowCount", () => {
|
|
13
|
+
const width = 10;
|
|
14
|
+
|
|
15
|
+
test("empty and short text are one row", () => {
|
|
16
|
+
expect(composeRowCount("", width)).toBe(1);
|
|
17
|
+
expect(composeRowCount("hi", width)).toBe(1);
|
|
18
|
+
expect(composeRowCount("exactly-10", width)).toBe(1); // length 10 / width 10
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("hard newlines add rows", () => {
|
|
22
|
+
expect(composeRowCount("a\nb", width)).toBe(2);
|
|
23
|
+
expect(composeRowCount("a\nb\nc", width)).toBe(3);
|
|
24
|
+
// a blank trailing line still occupies a row
|
|
25
|
+
expect(composeRowCount("a\n", width)).toBe(2);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("a soft-wrapped long line grows the box", () => {
|
|
29
|
+
expect(composeRowCount("a".repeat(11), width)).toBe(2); // ceil(11/10)
|
|
30
|
+
expect(composeRowCount("a".repeat(25), width)).toBe(3); // ceil(25/10)
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("wrapping and hard newlines combine", () => {
|
|
34
|
+
// 1 row for "short" plus ceil(21/10)=3 rows for the long line
|
|
35
|
+
expect(composeRowCount("short\n" + "b".repeat(21), width)).toBe(4);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("growth is capped", () => {
|
|
39
|
+
expect(composeRowCount("a".repeat(100), width)).toBe(COMPOSE_MAX_ROWS);
|
|
40
|
+
expect(composeRowCount("l\ni\nn\ne\ns\nx", width)).toBe(COMPOSE_MAX_ROWS);
|
|
41
|
+
expect(composeRowCount("a\nb", width, 1)).toBe(1); // custom cap
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("a non-positive width counts hard newlines only, never divides by zero", () => {
|
|
45
|
+
expect(composeRowCount("a".repeat(100), 0)).toBe(1);
|
|
46
|
+
expect(composeRowCount("a\nb\nc", 0)).toBe(3);
|
|
47
|
+
expect(composeRowCount("a".repeat(100), -5)).toBe(1);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { DARK } from "../theme";
|
|
3
|
+
import type { Story, StoryMeta } from "./story";
|
|
4
|
+
import { AnnotationCard } from "./AnnotationCard";
|
|
5
|
+
|
|
6
|
+
export const meta: StoryMeta = { title: "AnnotationCard" };
|
|
7
|
+
|
|
8
|
+
const draftCallbacks = { onInput: () => {}, onSave: () => {}, onCancel: () => {} };
|
|
9
|
+
|
|
10
|
+
export const CommentDraft: Story = {
|
|
11
|
+
render: () => (
|
|
12
|
+
<AnnotationCard kind="comment" quote="persists sessions" draft={{ text: "Which daemon?", ...draftCallbacks }} />
|
|
13
|
+
),
|
|
14
|
+
expectedColors: [DARK.accent, DARK.elevated],
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export const SuggestionDraft: Story = {
|
|
18
|
+
render: () => (
|
|
19
|
+
<AnnotationCard kind="suggestion" quote="move the store" draft={{ text: "", ...draftCallbacks }} />
|
|
20
|
+
),
|
|
21
|
+
expectedColors: [DARK.green],
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export const SavedSelected: Story = {
|
|
25
|
+
render: () => (
|
|
26
|
+
<AnnotationCard
|
|
27
|
+
kind="comment"
|
|
28
|
+
quote="persists sessions"
|
|
29
|
+
saved={{
|
|
30
|
+
body: "Which daemon owns this?",
|
|
31
|
+
isSelected: true,
|
|
32
|
+
isOrphan: false,
|
|
33
|
+
isBlocking: false,
|
|
34
|
+
editing: null,
|
|
35
|
+
onPress: () => {},
|
|
36
|
+
}}
|
|
37
|
+
/>
|
|
38
|
+
),
|
|
39
|
+
expectedColors: [DARK.elevated],
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export const SavedOrphanBlocking: Story = {
|
|
43
|
+
render: () => (
|
|
44
|
+
<AnnotationCard
|
|
45
|
+
kind="comment"
|
|
46
|
+
quote="a removed passage"
|
|
47
|
+
saved={{
|
|
48
|
+
body: "The passage is gone.",
|
|
49
|
+
isSelected: false,
|
|
50
|
+
isOrphan: true,
|
|
51
|
+
isBlocking: true,
|
|
52
|
+
editing: null,
|
|
53
|
+
onPress: () => {},
|
|
54
|
+
}}
|
|
55
|
+
/>
|
|
56
|
+
),
|
|
57
|
+
expectedColors: [DARK.red],
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export const SavedEditing: Story = {
|
|
61
|
+
render: () => (
|
|
62
|
+
<AnnotationCard
|
|
63
|
+
kind="suggestion"
|
|
64
|
+
quote="move the store"
|
|
65
|
+
saved={{
|
|
66
|
+
body: "old body",
|
|
67
|
+
isSelected: true,
|
|
68
|
+
isOrphan: false,
|
|
69
|
+
isBlocking: false,
|
|
70
|
+
editing: { text: "new body", ...draftCallbacks },
|
|
71
|
+
onPress: () => {},
|
|
72
|
+
}}
|
|
73
|
+
/>
|
|
74
|
+
),
|
|
75
|
+
};
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One annotation component in three modes: a draft composer (bordered card
|
|
3
|
+
* inline in the document flow), a saved rail card, and the rail card editing
|
|
4
|
+
* its body in place. Compose, saved, and re-edit share this rendering path,
|
|
5
|
+
* so the kind-color and quote treatment can never drift between them.
|
|
6
|
+
*
|
|
7
|
+
* Bodies are multiline: the composer is a textarea (wrap, undo/redo). Plain ⏎
|
|
8
|
+
* stays the save key while ⌥/Alt+⏎ and shift+⏎ insert a new line (the Slack
|
|
9
|
+
* convention). The box auto-grows with the wrapped text up to a cap, then
|
|
10
|
+
* scrolls internally.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import React, { useEffect, useRef, useState } from "react";
|
|
14
|
+
import type { KeyBinding, TextareaRenderable } from "@opentui/core";
|
|
15
|
+
import type { Theme } from "../theme";
|
|
16
|
+
import { useComponentTheme } from "./theme-context";
|
|
17
|
+
import { Card, cardHeight } from "./primitives/Card";
|
|
18
|
+
import { FRAME_BORDER_STYLE } from "./primitives/frame";
|
|
19
|
+
import { Button } from "./primitives/Button";
|
|
20
|
+
import { Toolbar } from "./primitives/Toolbar";
|
|
21
|
+
import { truncateToSingleLine } from "./truncate-text";
|
|
22
|
+
|
|
23
|
+
export interface AnnotationDraft {
|
|
24
|
+
text: string;
|
|
25
|
+
onInput: (text: string) => void;
|
|
26
|
+
onSave: () => void;
|
|
27
|
+
onCancel: () => void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface AnnotationSaved {
|
|
31
|
+
body: string;
|
|
32
|
+
isSelected: boolean;
|
|
33
|
+
isOrphan: boolean;
|
|
34
|
+
isBlocking: boolean;
|
|
35
|
+
/** Resolved author display name; own notes carry none. Set = name-in-border. */
|
|
36
|
+
authorLabel?: string;
|
|
37
|
+
/** Non-null while the card body is being rewritten in place. */
|
|
38
|
+
editing: AnnotationDraft | null;
|
|
39
|
+
onPress: () => void;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface AnnotationCardProps {
|
|
43
|
+
id?: string;
|
|
44
|
+
kind: string;
|
|
45
|
+
quote: string;
|
|
46
|
+
draft?: AnnotationDraft;
|
|
47
|
+
saved?: AnnotationSaved;
|
|
48
|
+
theme?: Theme;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* ⏎ submits (which keeps the textarea from inserting its default newline; the
|
|
53
|
+
* grammar owns the actual save). ⌥/Alt+⏎ and shift+⏎ insert a newline so the
|
|
54
|
+
* body can grow multiline without leaving the composer.
|
|
55
|
+
*
|
|
56
|
+
* Option/Alt reaches a binding as `meta`, not `option`: a binding key here is
|
|
57
|
+
* built from name+ctrl+shift+meta+super only, so the newline binding matches on
|
|
58
|
+
* `meta`. This also overrides the textarea default that maps meta+⏎ to submit.
|
|
59
|
+
*/
|
|
60
|
+
const COMPOSE_KEY_BINDINGS: KeyBinding[] = [
|
|
61
|
+
{ name: "return", action: "submit" },
|
|
62
|
+
{ name: "return", meta: true, action: "newline" },
|
|
63
|
+
{ name: "return", shift: true, action: "newline" },
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
/** The composer never grows past this many rows; beyond it the textarea scrolls. */
|
|
67
|
+
export const COMPOSE_MAX_ROWS = 4;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Visible row count for the composer, so a soft-wrapped long line grows the box
|
|
71
|
+
* just like a hard newline does. Each logical line takes `ceil(length /
|
|
72
|
+
* contentWidth)` visual rows (at least one), summed over the hard-newline lines
|
|
73
|
+
* and capped. A non-positive width (before layout is known) counts hard
|
|
74
|
+
* newlines only. Beyond the cap the textarea scrolls internally and keeps the
|
|
75
|
+
* caret line in view.
|
|
76
|
+
*/
|
|
77
|
+
export function composeRowCount(text: string, contentWidth: number, cap: number = COMPOSE_MAX_ROWS): number {
|
|
78
|
+
const usableWidth = contentWidth > 0 ? contentWidth : Number.MAX_SAFE_INTEGER;
|
|
79
|
+
let visualRowCount = 0;
|
|
80
|
+
for (const line of text.split("\n")) {
|
|
81
|
+
visualRowCount += Math.max(1, Math.ceil(line.length / usableWidth));
|
|
82
|
+
}
|
|
83
|
+
return Math.min(cap, Math.max(1, visualRowCount));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function DraftEditor({
|
|
87
|
+
draft,
|
|
88
|
+
rows,
|
|
89
|
+
onRowsChange,
|
|
90
|
+
theme,
|
|
91
|
+
}: {
|
|
92
|
+
draft: AnnotationDraft;
|
|
93
|
+
/** Current visible height in rows; owned by the parent so the card frame agrees. */
|
|
94
|
+
rows: number;
|
|
95
|
+
onRowsChange: (rows: number) => void;
|
|
96
|
+
theme?: Theme;
|
|
97
|
+
}): React.ReactNode {
|
|
98
|
+
const tokens = useComponentTheme(theme);
|
|
99
|
+
const editorRef = useRef<TextareaRenderable | null>(null);
|
|
100
|
+
// re-edit opens with the caret after the existing body, like a text field
|
|
101
|
+
const initialTextLength = useRef(draft.text.length);
|
|
102
|
+
useEffect(() => {
|
|
103
|
+
const editor = editorRef.current;
|
|
104
|
+
if (!editor) return;
|
|
105
|
+
editor.cursorOffset = initialTextLength.current;
|
|
106
|
+
// the box is laid out now, so its width is known: size to the wrapped body
|
|
107
|
+
onRowsChange(composeRowCount(editor.plainText, editor.width));
|
|
108
|
+
}, []);
|
|
109
|
+
return (
|
|
110
|
+
<>
|
|
111
|
+
<textarea
|
|
112
|
+
ref={editorRef}
|
|
113
|
+
focused
|
|
114
|
+
initialValue={draft.text}
|
|
115
|
+
placeholder="write a note..."
|
|
116
|
+
keyBindings={COMPOSE_KEY_BINDINGS}
|
|
117
|
+
onContentChange={() => {
|
|
118
|
+
const editor = editorRef.current;
|
|
119
|
+
if (!editor) return;
|
|
120
|
+
draft.onInput(editor.plainText);
|
|
121
|
+
onRowsChange(composeRowCount(editor.plainText, editor.width));
|
|
122
|
+
}}
|
|
123
|
+
style={{
|
|
124
|
+
height: rows,
|
|
125
|
+
backgroundColor: tokens.elevated,
|
|
126
|
+
focusedBackgroundColor: tokens.elevated,
|
|
127
|
+
textColor: tokens.text,
|
|
128
|
+
focusedTextColor: tokens.text,
|
|
129
|
+
}}
|
|
130
|
+
/>
|
|
131
|
+
<Toolbar>
|
|
132
|
+
<Button variant="solid" marginRight={2} onPress={draft.onSave} theme={theme}>
|
|
133
|
+
{" Save ⏎ "}
|
|
134
|
+
</Button>
|
|
135
|
+
<Button onPress={draft.onCancel} theme={theme}>
|
|
136
|
+
{" Cancel esc "}
|
|
137
|
+
</Button>
|
|
138
|
+
</Toolbar>
|
|
139
|
+
</>
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function AnnotationCard({ id, kind, quote, draft, saved, theme }: AnnotationCardProps): React.ReactNode {
|
|
144
|
+
const tokens = useComponentTheme(theme);
|
|
145
|
+
const kindColor = kind === "suggestion" ? tokens.green : tokens.accent;
|
|
146
|
+
const activeDraft = draft ?? saved?.editing ?? null;
|
|
147
|
+
// The composer's visible height, grown from the wrapped text. It is shared
|
|
148
|
+
// with the card frame (contentRows) so the border never clips the textarea
|
|
149
|
+
// or the toolbar. Seeded from hard newlines, then corrected once the mounted
|
|
150
|
+
// textarea reports its measured width.
|
|
151
|
+
const [editorRowCount, setEditorRowCount] = useState(() =>
|
|
152
|
+
// Width 0 is the "layout not known yet" contract: count hard newlines only,
|
|
153
|
+
// until the mounted textarea reports its measured width below.
|
|
154
|
+
composeRowCount(activeDraft?.text ?? "", 0),
|
|
155
|
+
);
|
|
156
|
+
if (draft) {
|
|
157
|
+
const verb = kind === "suggestion" ? "suggest replacement for" : "comment on";
|
|
158
|
+
return (
|
|
159
|
+
<Card
|
|
160
|
+
title={` ${verb} "${truncateToSingleLine(quote, 40)}" `}
|
|
161
|
+
contentRows={editorRowCount + 1}
|
|
162
|
+
borderColor={kindColor}
|
|
163
|
+
marginLeft={2}
|
|
164
|
+
marginRight={2}
|
|
165
|
+
theme={theme}
|
|
166
|
+
>
|
|
167
|
+
<DraftEditor draft={draft} rows={editorRowCount} onRowsChange={setEditorRowCount} theme={theme} />
|
|
168
|
+
</Card>
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
const card = saved!;
|
|
172
|
+
// A collaborator's note (author set) renders as a named, bordered card; own notes stay borderless.
|
|
173
|
+
const authored = card.authorLabel !== undefined && card.authorLabel !== "";
|
|
174
|
+
const indent = authored ? "" : " ";
|
|
175
|
+
const marker = card.isSelected ? "▸ " : indent;
|
|
176
|
+
// Fixed content rows (a bordered box must declare a height or it collapses): header + quote + body, or the editor.
|
|
177
|
+
const contentRows = card.editing ? editorRowCount + 3 : 3;
|
|
178
|
+
// The border + its side padding eat this much content width, so text truncates shorter.
|
|
179
|
+
const borderInset = authored ? 4 : 0;
|
|
180
|
+
return (
|
|
181
|
+
<box
|
|
182
|
+
id={id}
|
|
183
|
+
title={authored ? ` ${card.authorLabel} ` : undefined}
|
|
184
|
+
style={{
|
|
185
|
+
flexDirection: "column",
|
|
186
|
+
marginBottom: 1,
|
|
187
|
+
backgroundColor: card.isSelected ? tokens.elevated : undefined,
|
|
188
|
+
...(authored
|
|
189
|
+
? { height: cardHeight(contentRows), border: true, borderStyle: FRAME_BORDER_STYLE, borderColor: tokens.blue, paddingLeft: 1, paddingRight: 1 }
|
|
190
|
+
: {}),
|
|
191
|
+
}}
|
|
192
|
+
onMouseUp={card.onPress}
|
|
193
|
+
>
|
|
194
|
+
<text fg={card.isSelected ? tokens.text : kindColor}>
|
|
195
|
+
{marker}
|
|
196
|
+
{kind.toUpperCase()}
|
|
197
|
+
{card.isBlocking ? <span fg={tokens.red}> · BLOCKING</span> : null}
|
|
198
|
+
<span fg={tokens.textDim}>{card.isOrphan ? " · ORPHANED" : " · pending"}</span>
|
|
199
|
+
</text>
|
|
200
|
+
<text fg={tokens.textDim}>{indent}"{truncateToSingleLine(quote, 26 - borderInset)}"</text>
|
|
201
|
+
{card.editing ? (
|
|
202
|
+
<DraftEditor draft={card.editing} rows={editorRowCount} onRowsChange={setEditorRowCount} theme={theme} />
|
|
203
|
+
) : (
|
|
204
|
+
<text fg={card.isOrphan ? tokens.textDim : tokens.textMuted}>{indent}{truncateToSingleLine(card.body, 28 - borderInset)}</text>
|
|
205
|
+
)}
|
|
206
|
+
</box>
|
|
207
|
+
);
|
|
208
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { DARK } from "../theme";
|
|
3
|
+
import type { Story, StoryMeta } from "./story";
|
|
4
|
+
import { Breadcrumb } from "./Breadcrumb";
|
|
5
|
+
|
|
6
|
+
export const meta: StoryMeta = { title: "Breadcrumb" };
|
|
7
|
+
|
|
8
|
+
export const SessionHeader: Story = {
|
|
9
|
+
render: () => (
|
|
10
|
+
<Breadcrumb
|
|
11
|
+
items={[
|
|
12
|
+
{ label: "cueloop", tone: "accent" },
|
|
13
|
+
{ label: "Migration Plan · rev 2", tone: "dim" },
|
|
14
|
+
{ label: "resolved: approve", tone: "green" },
|
|
15
|
+
]}
|
|
16
|
+
/>
|
|
17
|
+
),
|
|
18
|
+
expectedColors: [DARK.accent, DARK.green, DARK.panel],
|
|
19
|
+
};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One-row breadcrumb trail: toned segments joined by " · " on the panel
|
|
3
|
+
* surface. The app header is a breadcrumb (product · artifact · revision)
|
|
4
|
+
* followed by state badges; each item picks a semantic tone.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import React from "react";
|
|
8
|
+
import type { Theme } from "../theme";
|
|
9
|
+
import { useComponentTheme } from "./theme-context";
|
|
10
|
+
|
|
11
|
+
export type BreadcrumbTone = "accent" | "dim" | "text" | "green";
|
|
12
|
+
|
|
13
|
+
export interface BreadcrumbItem {
|
|
14
|
+
label: string;
|
|
15
|
+
tone?: BreadcrumbTone;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface BreadcrumbProps {
|
|
19
|
+
items: BreadcrumbItem[];
|
|
20
|
+
theme?: Theme;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function Breadcrumb({ items, theme }: BreadcrumbProps): React.ReactNode {
|
|
24
|
+
const tokens = useComponentTheme(theme);
|
|
25
|
+
const toneColor = (tone?: BreadcrumbTone): string =>
|
|
26
|
+
tone === "accent" ? tokens.accent : tone === "green" ? tokens.green : tone === "text" ? tokens.text : tokens.textDim;
|
|
27
|
+
return (
|
|
28
|
+
<box style={{ height: 1, backgroundColor: tokens.panel, paddingLeft: 1, flexDirection: "row" }}>
|
|
29
|
+
<text fg={tokens.text}>
|
|
30
|
+
{items.map((item, index) => (
|
|
31
|
+
<span key={index} fg={toneColor(item.tone)}>
|
|
32
|
+
{index === 0 ? item.label : ` · ${item.label}`}
|
|
33
|
+
</span>
|
|
34
|
+
))}
|
|
35
|
+
</text>
|
|
36
|
+
</box>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { DARK } from "../theme";
|
|
3
|
+
import type { Story, StoryMeta } from "./story";
|
|
4
|
+
import { CodeBlock } from "./CodeBlock";
|
|
5
|
+
|
|
6
|
+
export const meta: StoryMeta = { title: "CodeBlock" };
|
|
7
|
+
|
|
8
|
+
const SNIPPET = `export function gate(full: number) {
|
|
9
|
+
// threshold
|
|
10
|
+
return full < 0.6 ? "blocked" : "clear";
|
|
11
|
+
}`;
|
|
12
|
+
|
|
13
|
+
export const Typescript: Story = {
|
|
14
|
+
render: () => <CodeBlock language="ts" content={SNIPPET} />,
|
|
15
|
+
expectedColors: [DARK.elevated],
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export const CursorAnnotatedEdited: Story = {
|
|
19
|
+
render: () => <CodeBlock language="ts" content={SNIPPET} isCursor isAnnotated changeTag="edited" marginTop={1} />,
|
|
20
|
+
expectedColors: [DARK.accent],
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export const UnknownLanguage: Story = {
|
|
24
|
+
render: () => <CodeBlock language="brainfog" content={"plain verbatim lines\n with indentation"} />,
|
|
25
|
+
};
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fenced code block: verbatim lines in a contained elevated box, highlighted
|
|
3
|
+
* by the native code renderable (tree-sitter). Unstyled text draws first, so
|
|
4
|
+
* the content never flashes empty while highlights resolve; unknown languages
|
|
5
|
+
* stay unstyled.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import React from "react";
|
|
9
|
+
import type { Theme } from "../theme";
|
|
10
|
+
import { useComponentTheme } from "./theme-context";
|
|
11
|
+
import { filetypeFor, syntaxStyleFor } from "./syntax-highlight";
|
|
12
|
+
|
|
13
|
+
export interface CodeBlockProps {
|
|
14
|
+
id?: string;
|
|
15
|
+
/** Markdown fence info string ("tsx", "python", ...). */
|
|
16
|
+
language?: string;
|
|
17
|
+
content: string;
|
|
18
|
+
isCursor?: boolean;
|
|
19
|
+
/** Vertical rhythm above the block. */
|
|
20
|
+
marginTop?: number;
|
|
21
|
+
isAnnotated?: boolean;
|
|
22
|
+
/** Working-copy change tag rendered after the language label. */
|
|
23
|
+
changeTag?: "cut" | "new" | "edited";
|
|
24
|
+
theme?: Theme;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function CodeBlock({
|
|
28
|
+
id,
|
|
29
|
+
language,
|
|
30
|
+
content,
|
|
31
|
+
isCursor = false,
|
|
32
|
+
marginTop = 0,
|
|
33
|
+
isAnnotated = false,
|
|
34
|
+
changeTag,
|
|
35
|
+
theme,
|
|
36
|
+
}: CodeBlockProps): React.ReactNode {
|
|
37
|
+
const tokens = useComponentTheme(theme);
|
|
38
|
+
const tagColor = changeTag === "cut" ? tokens.red : changeTag === "new" ? tokens.green : tokens.accent;
|
|
39
|
+
return (
|
|
40
|
+
<box id={id} style={{ flexDirection: "column", marginTop }}>
|
|
41
|
+
<text>
|
|
42
|
+
<span fg={isCursor ? tokens.accent : tokens.textDim}>{isCursor ? "▎ " : " "}</span>
|
|
43
|
+
<span fg={tokens.textDim}>{language ?? "code"}</span>
|
|
44
|
+
{isAnnotated ? <span fg={tokens.accent}> ◆</span> : null}
|
|
45
|
+
{changeTag ? <span fg={tagColor}> [{changeTag}]</span> : null}
|
|
46
|
+
</text>
|
|
47
|
+
<box
|
|
48
|
+
style={{
|
|
49
|
+
flexDirection: "column",
|
|
50
|
+
backgroundColor: tokens.elevated,
|
|
51
|
+
paddingLeft: 2,
|
|
52
|
+
paddingRight: 2,
|
|
53
|
+
paddingTop: 1,
|
|
54
|
+
paddingBottom: 1,
|
|
55
|
+
marginLeft: 2,
|
|
56
|
+
}}
|
|
57
|
+
>
|
|
58
|
+
<code
|
|
59
|
+
content={content}
|
|
60
|
+
filetype={filetypeFor(language)}
|
|
61
|
+
syntaxStyle={syntaxStyleFor(tokens)}
|
|
62
|
+
selectable={false}
|
|
63
|
+
style={{ wrapMode: "none", fg: tokens.textMuted, bg: tokens.elevated }}
|
|
64
|
+
/>
|
|
65
|
+
</box>
|
|
66
|
+
</box>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { DARK } from "../theme";
|
|
3
|
+
import type { Story, StoryMeta } from "./story";
|
|
4
|
+
import { CompletionOverlay } from "./CompletionOverlay";
|
|
5
|
+
|
|
6
|
+
export const meta: StoryMeta = { title: "CompletionOverlay" };
|
|
7
|
+
|
|
8
|
+
export const ApprovedPrompt: Story = {
|
|
9
|
+
render: () => <CompletionOverlay verdict="approve" completion={{ phase: "prompt" }} status="" />,
|
|
10
|
+
expectedColors: [DARK.green],
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export const FeedbackCounting: Story = {
|
|
14
|
+
render: () => (
|
|
15
|
+
<CompletionOverlay
|
|
16
|
+
verdict="request_changes"
|
|
17
|
+
completion={{ phase: "counting", remaining: 3 }}
|
|
18
|
+
status="exported to vault/cueloop/plan.md"
|
|
19
|
+
returnsTo="agent/worker-3"
|
|
20
|
+
/>
|
|
21
|
+
),
|
|
22
|
+
expectedColors: [DARK.accent],
|
|
23
|
+
};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Post-submit hand-back overlay: the agent is unblocked, the reviewer either
|
|
3
|
+
* closes now, opts into auto-close, or dismisses back to the resolved view.
|
|
4
|
+
* The latest status line (e.g. the vault-export path) stays visible here.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import React from "react";
|
|
8
|
+
import type { VerdictKind } from "@cueloop/schema";
|
|
9
|
+
import type { Theme } from "../theme";
|
|
10
|
+
import { useComponentTheme } from "./theme-context";
|
|
11
|
+
|
|
12
|
+
export interface CompletionOverlayProps {
|
|
13
|
+
verdict: VerdictKind;
|
|
14
|
+
completion: { phase: "prompt" } | { phase: "counting"; remaining: number };
|
|
15
|
+
/** Latest status line (e.g. the vault-export path) stays visible here. */
|
|
16
|
+
status: string;
|
|
17
|
+
/** Where focus goes on close (the agent's pane), when known. */
|
|
18
|
+
returnsTo?: string;
|
|
19
|
+
theme?: Theme;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function CompletionOverlay({ verdict, completion, status, returnsTo, theme }: CompletionOverlayProps): React.ReactNode {
|
|
23
|
+
const tokens = useComponentTheme(theme);
|
|
24
|
+
const approved = verdict === "approve";
|
|
25
|
+
return (
|
|
26
|
+
<box
|
|
27
|
+
style={{
|
|
28
|
+
width: "100%",
|
|
29
|
+
height: "100%",
|
|
30
|
+
backgroundColor: tokens.bg,
|
|
31
|
+
flexDirection: "column",
|
|
32
|
+
justifyContent: "center",
|
|
33
|
+
alignItems: "center",
|
|
34
|
+
}}
|
|
35
|
+
>
|
|
36
|
+
<text fg={approved ? tokens.green : tokens.accent}>{approved ? "review approved" : "feedback sent"}</text>
|
|
37
|
+
<text> </text>
|
|
38
|
+
<text fg={tokens.text}>The agent has your {approved ? "approval" : "feedback"} and is unblocked.</text>
|
|
39
|
+
{returnsTo ? <text fg={tokens.textDim}>returning to {returnsTo} on close</text> : null}
|
|
40
|
+
{status ? <text fg={tokens.textDim}>{status}</text> : null}
|
|
41
|
+
<text> </text>
|
|
42
|
+
<text fg={tokens.textDim}>
|
|
43
|
+
close [return]
|
|
44
|
+
{completion.phase === "counting" ? ` · closing in ${completion.remaining}s` : ""}
|
|
45
|
+
{" · return to plan [esc] · always [a]"}
|
|
46
|
+
</text>
|
|
47
|
+
</box>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { DARK } from "../theme";
|
|
3
|
+
import type { Story, StoryMeta } from "./story";
|
|
4
|
+
import { ComposeBar } from "./ComposeBar";
|
|
5
|
+
|
|
6
|
+
export const meta: StoryMeta = { title: "ComposeBar" };
|
|
7
|
+
|
|
8
|
+
export const CommentOnLine: Story = {
|
|
9
|
+
render: () => (
|
|
10
|
+
<ComposeBar kind="comment" quote=" private items = new Map();" text="Map needs an eviction story." onInput={() => {}} />
|
|
11
|
+
),
|
|
12
|
+
expectedColors: [DARK.accent, DARK.elevated],
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export const SuggestionOnLine: Story = {
|
|
16
|
+
render: () => <ComposeBar kind="suggestion" quote="export class Store {" text="" onInput={() => {}} />,
|
|
17
|
+
expectedColors: [DARK.green],
|
|
18
|
+
};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bottom compose bar - the diff view composes annotation bodies here. The
|
|
3
|
+
* editor is the same multiline textarea contract as the inline composer:
|
|
4
|
+
* ⏎ saves through the grammar, shift+⏎ adds a line.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import React, { useRef } from "react";
|
|
8
|
+
import type { KeyBinding, TextareaRenderable } from "@opentui/core";
|
|
9
|
+
import type { Theme } from "../theme";
|
|
10
|
+
import { useComponentTheme } from "./theme-context";
|
|
11
|
+
import { truncateToSingleLine } from "./truncate-text";
|
|
12
|
+
|
|
13
|
+
export interface ComposeBarProps {
|
|
14
|
+
kind: "comment" | "suggestion";
|
|
15
|
+
quote: string;
|
|
16
|
+
text: string;
|
|
17
|
+
onInput: (text: string) => void;
|
|
18
|
+
theme?: Theme;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const COMPOSE_KEY_BINDINGS: KeyBinding[] = [
|
|
22
|
+
{ name: "return", action: "submit" },
|
|
23
|
+
{ name: "return", shift: true, action: "newline" },
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
export function ComposeBar({ kind, quote, text, onInput, theme }: ComposeBarProps): React.ReactNode {
|
|
27
|
+
const tokens = useComponentTheme(theme);
|
|
28
|
+
const editorRef = useRef<TextareaRenderable | null>(null);
|
|
29
|
+
const editorRows = Math.min(4, Math.max(1, text.split("\n").length));
|
|
30
|
+
return (
|
|
31
|
+
<box style={{ height: 1 + editorRows, backgroundColor: tokens.elevated, flexDirection: "column", paddingLeft: 1 }}>
|
|
32
|
+
<text fg={kind === "suggestion" ? tokens.green : tokens.accent}>
|
|
33
|
+
{kind === "suggestion" ? "SUGGEST REPLACEMENT FOR" : "COMMENT ON"} “{truncateToSingleLine(quote, 60)}” · ⏎ save · esc cancel
|
|
34
|
+
</text>
|
|
35
|
+
<textarea
|
|
36
|
+
ref={editorRef}
|
|
37
|
+
focused
|
|
38
|
+
initialValue=""
|
|
39
|
+
keyBindings={COMPOSE_KEY_BINDINGS}
|
|
40
|
+
onContentChange={() => {
|
|
41
|
+
const editor = editorRef.current;
|
|
42
|
+
if (editor) onInput(editor.plainText);
|
|
43
|
+
}}
|
|
44
|
+
style={{
|
|
45
|
+
height: editorRows,
|
|
46
|
+
backgroundColor: tokens.elevated,
|
|
47
|
+
focusedBackgroundColor: tokens.elevated,
|
|
48
|
+
textColor: tokens.text,
|
|
49
|
+
focusedTextColor: tokens.text,
|
|
50
|
+
}}
|
|
51
|
+
/>
|
|
52
|
+
</box>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { DARK } from "../theme";
|
|
3
|
+
import type { Story, StoryMeta } from "./story";
|
|
4
|
+
import { ConfirmCard } from "./ConfirmCard";
|
|
5
|
+
|
|
6
|
+
export const meta: StoryMeta = { title: "ConfirmCard" };
|
|
7
|
+
|
|
8
|
+
const callbacks = { onInput: () => {}, onSelectVerdict: () => {}, onSubmit: () => {}, onCancel: () => {} };
|
|
9
|
+
|
|
10
|
+
export const ApproveDefault: Story = {
|
|
11
|
+
render: () => <ConfirmCard verdict="approve" summary="" annotationCount={0} blockingCount={0} {...callbacks} />,
|
|
12
|
+
expectedColors: [DARK.green, DARK.accent],
|
|
13
|
+
size: { width: 40, height: 14 },
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export const RequestChanges: Story = {
|
|
17
|
+
render: () => (
|
|
18
|
+
<ConfirmCard verdict="request_changes" summary="Tighten the steps." annotationCount={3} blockingCount={1} {...callbacks} />
|
|
19
|
+
),
|
|
20
|
+
expectedColors: [DARK.red],
|
|
21
|
+
size: { width: 40, height: 14 },
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export const WithViewedSummary: Story = {
|
|
25
|
+
render: () => (
|
|
26
|
+
<ConfirmCard
|
|
27
|
+
verdict="approve"
|
|
28
|
+
summary=""
|
|
29
|
+
annotationCount={1}
|
|
30
|
+
blockingCount={0}
|
|
31
|
+
viewedSummary="2/3 files viewed"
|
|
32
|
+
{...callbacks}
|
|
33
|
+
/>
|
|
34
|
+
),
|
|
35
|
+
expectedColors: [DARK.green, DARK.textDim],
|
|
36
|
+
size: { width: 40, height: 14 },
|
|
37
|
+
};
|