@cueloop/client 0.1.0-alpha.5 → 0.1.0-alpha.50
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/native/build-pty.sh +51 -0
- package/native/build.sh +82 -0
- package/native/darwin-arm64/libcuelooppty.dylib +0 -0
- package/native/darwin-arm64/libcueloopvt.dylib +0 -0
- package/native/src/pty.zig +193 -0
- package/native/src/shim.zig +114 -0
- package/package.json +36 -31
- package/src/App.autoclose.test.tsx +137 -0
- package/src/App.diff.test.tsx +158 -22
- package/src/App.export.test.tsx +44 -37
- package/src/App.plan-review.test.tsx +672 -0
- package/src/App.prototype.test.tsx +155 -0
- package/src/App.readonly.test.tsx +60 -36
- package/src/App.rendering.test.tsx +132 -0
- package/src/App.review-panel.test.tsx +136 -0
- package/src/App.submit-confirm.test.tsx +218 -0
- package/src/App.test.tsx +128 -69
- package/src/App.toast.test.tsx +75 -0
- package/src/App.tsx +0 -0
- package/src/App.walk.test.tsx +289 -0
- package/src/__snapshots__/view-plan.regression.test.ts.snap +100 -0
- package/src/attribution.test.ts +31 -0
- package/src/attribution.ts +38 -0
- package/src/clipboard.test.ts +23 -0
- package/src/clipboard.ts +30 -0
- package/src/components/AnnotationCard.compose-rows.test.ts +49 -0
- package/src/components/AnnotationCard.stories.tsx +94 -0
- package/src/components/AnnotationCard.tsx +241 -0
- package/src/components/Breadcrumb.stories.tsx +19 -0
- package/src/components/Breadcrumb.tsx +44 -0
- package/src/components/CodeBlock.stories.tsx +42 -0
- package/src/components/CodeBlock.tsx +83 -0
- package/src/components/CompletionOverlay.stories.tsx +23 -0
- package/src/components/CompletionOverlay.tsx +59 -0
- package/src/components/ConfirmCard.stories.tsx +35 -0
- package/src/components/ConfirmCard.tsx +127 -0
- package/src/components/ConfirmDialog.stories.tsx +21 -0
- package/src/components/ConfirmDialog.tsx +64 -0
- package/src/components/DiffSheet.scroll.test.tsx +123 -0
- package/src/components/DiffSheet.stories.tsx +78 -0
- package/src/components/DiffSheet.syntax.test.tsx +62 -0
- package/src/components/DiffSheet.tsx +355 -0
- package/src/components/InboxList.stories.tsx +31 -0
- package/src/components/InboxList.tsx +62 -0
- package/src/components/KeybindsDialog.stories.tsx +31 -0
- package/src/components/KeybindsDialog.tsx +58 -0
- package/src/components/MarkerPopover.stories.tsx +58 -0
- package/src/components/MarkerPopover.tsx +108 -0
- package/src/components/MenuBar.stories.tsx +37 -0
- package/src/components/MenuBar.tsx +74 -0
- package/src/components/PlanSheet.stories.tsx +113 -0
- package/src/components/PlanSheet.tsx +456 -0
- package/src/components/PromptDialog.stories.tsx +36 -0
- package/src/components/PromptDialog.tsx +78 -0
- package/src/components/PrototypeSheet.stories.tsx +20 -0
- package/src/components/PrototypeSheet.tsx +358 -0
- package/src/components/ReviewPanel.stories.tsx +86 -0
- package/src/components/ReviewPanel.tsx +101 -0
- package/src/components/ReviewRail.stories.tsx +218 -0
- package/src/components/ReviewRail.tsx +317 -0
- package/src/components/SettingsDialog.stories.tsx +111 -0
- package/src/components/SettingsDialog.tsx +141 -0
- package/src/components/SettingsRows.stories.tsx +18 -0
- package/src/components/SettingsRows.tsx +108 -0
- package/src/components/Toast.stories.tsx +12 -0
- package/src/components/Toast.tsx +44 -0
- package/src/components/WalkWizard.stories.tsx +100 -0
- package/src/components/WalkWizard.tsx +152 -0
- package/src/components/__snapshots__/stories.test.tsx.snap +1857 -0
- package/src/components/agent-launcher.stories.tsx +26 -0
- package/src/components/agent-launcher.test.tsx +49 -0
- package/src/components/agent-launcher.tsx +147 -0
- package/src/components/diff-sheet-layout.test.ts +127 -0
- package/src/components/diff-sheet-layout.ts +167 -0
- package/src/components/primitives/Button.stories.tsx +32 -0
- package/src/components/primitives/Button.tsx +51 -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 +64 -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 +98 -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/quick-actions-editor.stories.tsx +54 -0
- package/src/components/quick-actions-editor.tsx +136 -0
- package/src/components/stories-app.tsx +123 -0
- package/src/components/stories.test.tsx +62 -0
- package/src/components/story-fixtures.ts +98 -0
- package/src/components/story.ts +69 -0
- package/src/components/syntax-highlight.ts +92 -0
- package/src/components/terminal-pane.test.tsx +40 -0
- package/src/components/terminal-pane.ts +181 -0
- package/src/components/theme-context.stories.tsx +46 -0
- package/src/components/theme-context.tsx +36 -0
- package/src/components/truncate-text.ts +6 -0
- package/src/config.test.ts +430 -31
- package/src/config.ts +304 -26
- package/src/diff-hunk-curate.test.ts +315 -0
- package/src/diff-hunk-curate.ts +233 -0
- package/src/diff-intraline.test.ts +164 -0
- package/src/diff-intraline.ts +132 -0
- package/src/diff-syntax.test.ts +96 -0
- package/src/diff-syntax.ts +154 -0
- package/src/editor.test.ts +143 -0
- package/src/editor.ts +123 -9
- package/src/ghostty-terminal.test.ts +82 -0
- package/src/ghostty-terminal.ts +168 -0
- package/src/herdr.test.ts +41 -0
- package/src/herdr.ts +27 -0
- package/src/index.ts +10 -0
- package/src/integrations.test.ts +41 -0
- package/src/integrations.ts +30 -0
- package/src/intent-dispatch.test.ts +493 -0
- package/src/intent-dispatch.ts +428 -0
- package/src/key-bindings.test.ts +152 -0
- package/src/key-bindings.ts +430 -0
- package/src/keymap.test.ts +622 -0
- package/src/keymap.ts +339 -0
- package/src/kitty-image.test.ts +20 -0
- package/src/kitty-image.ts +75 -0
- package/src/prototype-browser.test.ts +55 -0
- package/src/prototype-browser.ts +243 -0
- package/src/pty.test.ts +78 -0
- package/src/pty.ts +222 -0
- package/src/review-panel.test.ts +113 -0
- package/src/review-panel.ts +83 -0
- package/src/run.ts +9 -5
- package/src/serve.test.ts +24 -9
- package/src/serve.ts +7 -7
- package/src/session-controller.curate.test.ts +253 -0
- package/src/session-controller.share.test.ts +309 -0
- package/src/session-controller.ts +914 -0
- package/src/share.ts +113 -0
- package/src/test-support.test.ts +55 -0
- package/src/test-support.ts +187 -0
- package/src/theme-presets.ts +168 -0
- package/src/theme.ts +38 -17
- package/src/version.ts +5 -0
- package/src/view-diff.test.ts +19 -8
- package/src/view-diff.ts +34 -23
- package/src/view-plan.regression.test.ts +60 -0
- package/src/view-plan.test.ts +399 -0
- package/src/view-plan.ts +495 -0
- package/src/walk.test.ts +121 -0
- package/src/walk.ts +70 -0
- package/src/view.test.ts +0 -157
- package/src/view.ts +0 -0
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The plan document sheet: header chrome, the reconciliation banner, and the
|
|
3
|
+
* scrollable block flow. Each prose block renders as one natively wrapped
|
|
4
|
+
* text (wrapMode "word") beside a marker gutter, so wrapping is the
|
|
5
|
+
* renderer's job; the sheet keeps the rendered/work offset mapping per block
|
|
6
|
+
* and exposes an imperative selection surface - the keyboard span drives the
|
|
7
|
+
* native selection, and a mouse drag reads back as an exact work-text range,
|
|
8
|
+
* which keeps quote anchors char-precise.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import React, { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react";
|
|
12
|
+
import { useRenderer } from "@opentui/react";
|
|
13
|
+
import { createTextAttributes, type ScrollBoxRenderable, type TextRenderable } from "@opentui/core";
|
|
14
|
+
import type { ReviewSession } from "@cueloop/schema";
|
|
15
|
+
import {
|
|
16
|
+
blockRuns,
|
|
17
|
+
displayText,
|
|
18
|
+
overlayMarks,
|
|
19
|
+
renderedOffsetAtOrAfter,
|
|
20
|
+
renderedOffsetAtOrBefore,
|
|
21
|
+
safeLinkHref,
|
|
22
|
+
workRangeForRendered,
|
|
23
|
+
type DisplayBlock,
|
|
24
|
+
type Mark,
|
|
25
|
+
type SpanState,
|
|
26
|
+
type StyleRun,
|
|
27
|
+
} from "../view-plan";
|
|
28
|
+
import type { Theme } from "../theme";
|
|
29
|
+
import { useComponentTheme } from "./theme-context";
|
|
30
|
+
import { CodeBlock } from "./CodeBlock";
|
|
31
|
+
import { AnnotationCard, type AnnotationDraft } from "./AnnotationCard";
|
|
32
|
+
import { MarkerPopover, type MarkerPopoverProps } from "./MarkerPopover";
|
|
33
|
+
import { FRAME_BORDER_STYLE } from "./primitives/frame";
|
|
34
|
+
|
|
35
|
+
/** A cut block reads as removed: struck through and grayed, never red. */
|
|
36
|
+
const CUT_ATTRIBUTES = createTextAttributes({ strikethrough: true, dim: true });
|
|
37
|
+
|
|
38
|
+
/** Inline-emphasis attributes, reused per run so the render loop allocates none. */
|
|
39
|
+
const STRONG_ATTRIBUTES = createTextAttributes({ bold: true });
|
|
40
|
+
const EM_ATTRIBUTES = createTextAttributes({ italic: true });
|
|
41
|
+
const STRIKE_ATTRIBUTES = createTextAttributes({ strikethrough: true });
|
|
42
|
+
const LINK_ATTRIBUTES = createTextAttributes({ underline: true });
|
|
43
|
+
/** Block-level base attributes: headings read bold, blockquotes read italic. */
|
|
44
|
+
const HEADING_ATTRIBUTES = createTextAttributes({ bold: true });
|
|
45
|
+
const QUOTE_ATTRIBUTES = createTextAttributes({ italic: true });
|
|
46
|
+
|
|
47
|
+
/** The popover toolbar card is 3 rows tall and sits flush above the marked words. */
|
|
48
|
+
const POPOVER_ROWS_ABOVE = 3;
|
|
49
|
+
/** Widest toolbar row ("comment · cut · actions · [x]") plus border and padding. */
|
|
50
|
+
const POPOVER_TOOLBAR_COLUMNS = 33;
|
|
51
|
+
|
|
52
|
+
export interface PlanSelection {
|
|
53
|
+
displayIndex: number;
|
|
54
|
+
start: number;
|
|
55
|
+
end: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface PlanSheetHandle {
|
|
59
|
+
/** Work-text range of the current native (mouse) selection, if any. */
|
|
60
|
+
/** The current native selection; `preferredIndex` (the mouse-up block) re-anchors a fresh drag. */
|
|
61
|
+
readSelection(preferredIndex?: number): PlanSelection | null;
|
|
62
|
+
/** Anchor/extend the renderer's native selection from keyboard span offsets. */
|
|
63
|
+
driveSpanSelection(span: SpanState): void;
|
|
64
|
+
clearSelection(): void;
|
|
65
|
+
revealBlock(displayIndex: number): void;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface PlanComposeState {
|
|
69
|
+
kind: "comment";
|
|
70
|
+
displayIndex: number;
|
|
71
|
+
quote: string;
|
|
72
|
+
draft: AnnotationDraft;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The marker-actions popover, rendered inline at its block while span mode is live. */
|
|
76
|
+
export interface PlanPopoverState extends MarkerPopoverProps {
|
|
77
|
+
displayIndex: number;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface PlanSheetProps {
|
|
81
|
+
session: ReviewSession;
|
|
82
|
+
display: DisplayBlock[];
|
|
83
|
+
marks: Map<number, Mark[]>;
|
|
84
|
+
cursor: number;
|
|
85
|
+
/** Extra selection-style paint on one block (keyboard span or compose anchor). */
|
|
86
|
+
activeSpan: { displayIndex: number; start: number; end: number } | null;
|
|
87
|
+
compose: PlanComposeState | null;
|
|
88
|
+
/** The marker-actions popover for the span's block; null when not in span mode. */
|
|
89
|
+
popover: PlanPopoverState | null;
|
|
90
|
+
editOrphanCount: number;
|
|
91
|
+
onLineActivate: (displayIndex: number) => void;
|
|
92
|
+
/**
|
|
93
|
+
* Fires on any mouse release inside the sheet. A drag released outside a
|
|
94
|
+
* block's text (the gutter, past a line end, a gap between blocks) never
|
|
95
|
+
* reaches a block's own handler, so this fallback lets the app still turn
|
|
96
|
+
* the finished drag's native selection into a span.
|
|
97
|
+
*/
|
|
98
|
+
onSelectionRelease?: () => void;
|
|
99
|
+
theme?: Theme;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
interface BlockRef {
|
|
103
|
+
renderable: TextRenderable;
|
|
104
|
+
runs: StyleRun[];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Screen position of a rendered-text offset inside a wrapped text. */
|
|
108
|
+
function positionOfRenderedOffset(
|
|
109
|
+
renderable: TextRenderable,
|
|
110
|
+
renderedOffset: number,
|
|
111
|
+
): { x: number; y: number } {
|
|
112
|
+
const info = renderable.lineInfo;
|
|
113
|
+
let lineIndex = 0;
|
|
114
|
+
for (let index = 0; index < info.lineStartCols.length; index++) {
|
|
115
|
+
if (info.lineStartCols[index]! <= renderedOffset) lineIndex = index;
|
|
116
|
+
else break;
|
|
117
|
+
}
|
|
118
|
+
return {
|
|
119
|
+
x: renderable.x + renderedOffset - (info.lineStartCols[lineIndex] ?? 0),
|
|
120
|
+
y: renderable.y + lineIndex,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export const PlanSheet = forwardRef<PlanSheetHandle, PlanSheetProps>(function PlanSheet(
|
|
125
|
+
{
|
|
126
|
+
display,
|
|
127
|
+
marks,
|
|
128
|
+
cursor,
|
|
129
|
+
activeSpan,
|
|
130
|
+
compose,
|
|
131
|
+
popover,
|
|
132
|
+
editOrphanCount,
|
|
133
|
+
onLineActivate,
|
|
134
|
+
onSelectionRelease,
|
|
135
|
+
theme,
|
|
136
|
+
}: PlanSheetProps,
|
|
137
|
+
handleRef,
|
|
138
|
+
): React.ReactNode {
|
|
139
|
+
const tokens = useComponentTheme(theme);
|
|
140
|
+
const renderer = useRenderer();
|
|
141
|
+
const blockRefs = useRef(new Map<number, BlockRef>());
|
|
142
|
+
const scrollRef = useRef<ScrollBoxRenderable | null>(null);
|
|
143
|
+
|
|
144
|
+
useImperativeHandle(handleRef, () => ({
|
|
145
|
+
readSelection: (preferredIndex?: number): PlanSelection | null => {
|
|
146
|
+
if (!renderer?.hasSelection) return null;
|
|
147
|
+
const readBlock = (displayIndex: number): PlanSelection | null => {
|
|
148
|
+
const blockRef = blockRefs.current.get(displayIndex);
|
|
149
|
+
if (!blockRef) return null;
|
|
150
|
+
const selection = blockRef.renderable.getSelection();
|
|
151
|
+
if (!selection || selection.end <= selection.start) return null;
|
|
152
|
+
const range = workRangeForRendered(blockRef.runs, selection.start, selection.end);
|
|
153
|
+
return range ? { displayIndex, ...range } : null;
|
|
154
|
+
};
|
|
155
|
+
// the block the mouse released on wins, so a fresh drag re-anchors the span
|
|
156
|
+
// (one marker at a time) instead of sticking to the topmost prior selection
|
|
157
|
+
if (preferredIndex !== undefined) {
|
|
158
|
+
const preferred = readBlock(preferredIndex);
|
|
159
|
+
if (preferred) return preferred;
|
|
160
|
+
}
|
|
161
|
+
for (const displayIndex of [...blockRefs.current.keys()].sort(
|
|
162
|
+
(left, right) => left - right,
|
|
163
|
+
)) {
|
|
164
|
+
const found = readBlock(displayIndex);
|
|
165
|
+
if (found) return found;
|
|
166
|
+
}
|
|
167
|
+
return null;
|
|
168
|
+
},
|
|
169
|
+
driveSpanSelection: (span: SpanState): void => {
|
|
170
|
+
if (!renderer) return;
|
|
171
|
+
const blockRef = blockRefs.current.get(span.displayIndex);
|
|
172
|
+
if (!blockRef) return;
|
|
173
|
+
// a span over an emphasized word starts/ends on concealed markers with
|
|
174
|
+
// no rendered cell; snap to the visible characters inside them
|
|
175
|
+
const renderedStart = renderedOffsetAtOrAfter(blockRef.runs, span.start);
|
|
176
|
+
const renderedEnd = renderedOffsetAtOrBefore(blockRef.runs, span.end - 1);
|
|
177
|
+
if (renderedStart === null || renderedEnd === null || renderedEnd < renderedStart) return;
|
|
178
|
+
const startPosition = positionOfRenderedOffset(blockRef.renderable, renderedStart);
|
|
179
|
+
const endPosition = positionOfRenderedOffset(blockRef.renderable, renderedEnd);
|
|
180
|
+
renderer.startSelection(blockRef.renderable, startPosition.x, startPosition.y);
|
|
181
|
+
renderer.updateSelection(blockRef.renderable, endPosition.x + 1, endPosition.y);
|
|
182
|
+
},
|
|
183
|
+
clearSelection: (): void => {
|
|
184
|
+
renderer?.clearSelection();
|
|
185
|
+
},
|
|
186
|
+
revealBlock: (displayIndex: number): void => {
|
|
187
|
+
try {
|
|
188
|
+
scrollRef.current?.scrollChildIntoView(`plan-block-${displayIndex}`);
|
|
189
|
+
} catch {
|
|
190
|
+
// reveal is best-effort; selection state is already correct
|
|
191
|
+
}
|
|
192
|
+
},
|
|
193
|
+
}));
|
|
194
|
+
|
|
195
|
+
// the popover sits flush above the marked words, anchored to the
|
|
196
|
+
// selection start mapped through the wrap geometry (line + column, not the
|
|
197
|
+
// block's linear offset). It renders as the LAST child of the scrollbox in
|
|
198
|
+
// content coordinates: content coordinates are scroll-invariant so the card
|
|
199
|
+
// tracks its block, and the last sibling paints over every block it floats
|
|
200
|
+
// across. Flips below when the line sits too near the viewport top.
|
|
201
|
+
const [popoverTop, setPopoverTop] = useState(0);
|
|
202
|
+
const [popoverLeft, setPopoverLeft] = useState(2);
|
|
203
|
+
// a terminal resize rewraps every block; re-run the anchor math against the
|
|
204
|
+
// new geometry instead of leaving the card at its pre-resize cell
|
|
205
|
+
const [resizeEpoch, setResizeEpoch] = useState(0);
|
|
206
|
+
useEffect(() => {
|
|
207
|
+
if (!renderer) return;
|
|
208
|
+
const onResize = (): void => setResizeEpoch((epoch) => epoch + 1);
|
|
209
|
+
renderer.on("resize", onResize);
|
|
210
|
+
return () => void renderer.off("resize", onResize);
|
|
211
|
+
}, [renderer]);
|
|
212
|
+
useEffect(() => {
|
|
213
|
+
if (!popover) return;
|
|
214
|
+
const blockRef = blockRefs.current.get(popover.displayIndex);
|
|
215
|
+
const scrollbox = scrollRef.current;
|
|
216
|
+
if (!blockRef || !scrollbox) return;
|
|
217
|
+
const renderedStart =
|
|
218
|
+
activeSpan && activeSpan.displayIndex === popover.displayIndex
|
|
219
|
+
? (renderedOffsetAtOrAfter(blockRef.runs, activeSpan.start) ?? 0)
|
|
220
|
+
: 0;
|
|
221
|
+
const startPosition = positionOfRenderedOffset(blockRef.renderable, renderedStart);
|
|
222
|
+
const content = scrollbox.content;
|
|
223
|
+
const anchorLeft = startPosition.x - content.x;
|
|
224
|
+
const anchorTop = startPosition.y - content.y;
|
|
225
|
+
// clamp so the toolbar card never runs past the content's right edge
|
|
226
|
+
const maxLeft = Math.max(0, content.width - POPOVER_TOOLBAR_COLUMNS);
|
|
227
|
+
setPopoverLeft(Math.min(anchorLeft, maxLeft));
|
|
228
|
+
// only the toolbar (3 rows) + gap must fit above; the dropdown flows down
|
|
229
|
+
const lineViewportRow = startPosition.y - scrollbox.y;
|
|
230
|
+
setPopoverTop(anchorTop + (lineViewportRow < POPOVER_ROWS_ABOVE ? 1 : -POPOVER_ROWS_ABOVE));
|
|
231
|
+
}, [popover, activeSpan, display, cursor, resizeEpoch]);
|
|
232
|
+
|
|
233
|
+
const registerBlock = (
|
|
234
|
+
displayIndex: number,
|
|
235
|
+
renderable: TextRenderable | null,
|
|
236
|
+
runs: StyleRun[],
|
|
237
|
+
): void => {
|
|
238
|
+
if (renderable) blockRefs.current.set(displayIndex, { renderable, runs });
|
|
239
|
+
else blockRefs.current.delete(displayIndex);
|
|
240
|
+
};
|
|
241
|
+
// stale refs must not survive a shrinking display list
|
|
242
|
+
useEffect(() => {
|
|
243
|
+
for (const displayIndex of blockRefs.current.keys()) {
|
|
244
|
+
if (displayIndex >= display.length) blockRefs.current.delete(displayIndex);
|
|
245
|
+
}
|
|
246
|
+
}, [display]);
|
|
247
|
+
|
|
248
|
+
const children: React.ReactNode[] = [];
|
|
249
|
+
for (let displayIndex = 0; displayIndex < display.length; displayIndex++) {
|
|
250
|
+
const block = display[displayIndex]!;
|
|
251
|
+
const isCursor = displayIndex === cursor;
|
|
252
|
+
const gap = topGap(display[displayIndex - 1], block);
|
|
253
|
+
if (block.kind === "code") {
|
|
254
|
+
children.push(
|
|
255
|
+
<CodeBlock
|
|
256
|
+
key={displayIndex}
|
|
257
|
+
id={`plan-block-${displayIndex}`}
|
|
258
|
+
language={(block.work ?? block.base)?.lang}
|
|
259
|
+
content={displayText(block)}
|
|
260
|
+
isCursor={isCursor}
|
|
261
|
+
marginTop={gap}
|
|
262
|
+
isAnnotated={(marks.get(displayIndex) ?? []).length > 0}
|
|
263
|
+
changeTag={showsChangeTag(block) ? tagLabel(block) : undefined}
|
|
264
|
+
cut={block.type === "del"}
|
|
265
|
+
theme={theme}
|
|
266
|
+
/>,
|
|
267
|
+
);
|
|
268
|
+
} else {
|
|
269
|
+
const blockMarks = [...(marks.get(displayIndex) ?? [])];
|
|
270
|
+
if (activeSpan && activeSpan.displayIndex === displayIndex) {
|
|
271
|
+
blockMarks.push({ start: activeSpan.start, end: activeSpan.end, role: "kspan" });
|
|
272
|
+
}
|
|
273
|
+
const runs = overlayMarks(blockRuns(block, true), blockMarks);
|
|
274
|
+
// the change tag participates in the rendered text but carries no offsets;
|
|
275
|
+
// a cut block shows no tag (its strikethrough already reads as removed)
|
|
276
|
+
const mappedRuns: StyleRun[] = showsChangeTag(block)
|
|
277
|
+
? [...runs, { text: ` [${tagLabel(block)}]`, role: "plain", start: null }]
|
|
278
|
+
: runs;
|
|
279
|
+
children.push(
|
|
280
|
+
<box
|
|
281
|
+
key={displayIndex}
|
|
282
|
+
id={`plan-block-${displayIndex}`}
|
|
283
|
+
style={{ flexDirection: "row", marginTop: gap }}
|
|
284
|
+
>
|
|
285
|
+
<text selectable={false}>
|
|
286
|
+
<span fg={isCursor ? tokens.accent : tokens.textDim}>{isCursor ? "▎ " : " "}</span>
|
|
287
|
+
<span fg={tokens.textDim}>{marker(block)}</span>
|
|
288
|
+
</text>
|
|
289
|
+
<text
|
|
290
|
+
bg={isCursor ? tokens.cursorBackground : undefined}
|
|
291
|
+
selectable
|
|
292
|
+
selectionBg={tokens.accent}
|
|
293
|
+
selectionFg={tokens.accentInk}
|
|
294
|
+
style={{ wrapMode: "word", flexGrow: 1, flexShrink: 1 }}
|
|
295
|
+
ref={(renderable: TextRenderable | null) =>
|
|
296
|
+
registerBlock(displayIndex, renderable, mappedRuns)
|
|
297
|
+
}
|
|
298
|
+
onMouseUp={() => onLineActivate(displayIndex)}
|
|
299
|
+
>
|
|
300
|
+
{runs.map((run, runIndex) => {
|
|
301
|
+
const url = run.role === "link" ? safeLinkHref(run.href) : undefined;
|
|
302
|
+
return (
|
|
303
|
+
<span
|
|
304
|
+
key={runIndex}
|
|
305
|
+
{...runStyle(run, block, tokens)}
|
|
306
|
+
{...(url ? { link: { url } } : {})}
|
|
307
|
+
>
|
|
308
|
+
{run.text}
|
|
309
|
+
</span>
|
|
310
|
+
);
|
|
311
|
+
})}
|
|
312
|
+
{showsChangeTag(block) ? (
|
|
313
|
+
<span fg={tagColor(block, tokens)}> [{tagLabel(block)}]</span>
|
|
314
|
+
) : null}
|
|
315
|
+
</text>
|
|
316
|
+
</box>,
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
if (compose && compose.displayIndex === displayIndex) {
|
|
320
|
+
children.push(
|
|
321
|
+
<AnnotationCard
|
|
322
|
+
key={`compose-${displayIndex}`}
|
|
323
|
+
kind={compose.kind}
|
|
324
|
+
quote={compose.quote}
|
|
325
|
+
draft={compose.draft}
|
|
326
|
+
theme={theme}
|
|
327
|
+
/>,
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
if (popover) {
|
|
332
|
+
// last child on purpose: later siblings paint on top, so the floating card
|
|
333
|
+
// covers every block it overlaps regardless of scroll position
|
|
334
|
+
children.push(
|
|
335
|
+
<box
|
|
336
|
+
key="marker-popover"
|
|
337
|
+
style={{
|
|
338
|
+
position: "absolute",
|
|
339
|
+
left: popoverLeft,
|
|
340
|
+
top: popoverTop,
|
|
341
|
+
flexDirection: "column",
|
|
342
|
+
}}
|
|
343
|
+
>
|
|
344
|
+
<MarkerPopover {...popover} theme={theme} />
|
|
345
|
+
</box>,
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
return (
|
|
350
|
+
<box style={{ flexGrow: 1, flexDirection: "column" }} onMouseUp={onSelectionRelease}>
|
|
351
|
+
<box
|
|
352
|
+
style={{
|
|
353
|
+
flexGrow: 1,
|
|
354
|
+
flexDirection: "column",
|
|
355
|
+
border: true,
|
|
356
|
+
borderStyle: FRAME_BORDER_STYLE,
|
|
357
|
+
borderColor: tokens.text,
|
|
358
|
+
}}
|
|
359
|
+
>
|
|
360
|
+
{editOrphanCount > 0 ? (
|
|
361
|
+
<box style={{ height: 1, backgroundColor: tokens.markCommentBackground, paddingLeft: 1 }}>
|
|
362
|
+
<text fg={tokens.red}>
|
|
363
|
+
{editOrphanCount} annotation{editOrphanCount === 1 ? "" : "s"} no longer match - the
|
|
364
|
+
passage was removed.
|
|
365
|
+
</text>
|
|
366
|
+
</box>
|
|
367
|
+
) : null}
|
|
368
|
+
<scrollbox
|
|
369
|
+
ref={scrollRef}
|
|
370
|
+
style={{ flexGrow: 1, paddingLeft: 1, paddingTop: 0 }}
|
|
371
|
+
focused={false}
|
|
372
|
+
>
|
|
373
|
+
{children}
|
|
374
|
+
</scrollbox>
|
|
375
|
+
</box>
|
|
376
|
+
</box>
|
|
377
|
+
);
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
/** Vertical rhythm: gaps live ABOVE blocks so boundaries never collapse. */
|
|
381
|
+
function topGap(previous: DisplayBlock | undefined, current: DisplayBlock): number {
|
|
382
|
+
if (!previous) return 0;
|
|
383
|
+
const tightPair =
|
|
384
|
+
(current.kind === "li" && previous.kind === "li") ||
|
|
385
|
+
(current.kind === "oli" && previous.kind === "oli");
|
|
386
|
+
return tightPair ? 0 : 1;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function marker(block: DisplayBlock): string {
|
|
390
|
+
if (block.kind === "li") return "- ";
|
|
391
|
+
if (block.kind === "oli") return `${block.orderedItemNumber ?? 1}. `;
|
|
392
|
+
if (block.kind === "quote") return "▏ ";
|
|
393
|
+
return "";
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** An added or edited block earns a tag; a cut block relies on its strikethrough. */
|
|
397
|
+
function showsChangeTag(block: DisplayBlock): boolean {
|
|
398
|
+
return block.type === "add" || block.type === "mod";
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function tagLabel(block: DisplayBlock): "new" | "edited" {
|
|
402
|
+
return block.type === "add" ? "new" : "edited";
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function tagColor(block: DisplayBlock, tokens: Theme): string {
|
|
406
|
+
return block.type === "add" ? tokens.green : tokens.accent;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function runStyle(
|
|
410
|
+
run: StyleRun,
|
|
411
|
+
block: DisplayBlock,
|
|
412
|
+
tokens: Theme,
|
|
413
|
+
): { fg?: string; bg?: string; attributes?: number } {
|
|
414
|
+
// a cut block reads as removed: every run struck through and grayed, never red
|
|
415
|
+
if (block.type === "del") return { fg: tokens.textDim, attributes: CUT_ATTRIBUTES };
|
|
416
|
+
const isHeading = block.kind === "h1" || block.kind === "h2" || block.kind === "h3";
|
|
417
|
+
const isQuote = block.kind === "quote";
|
|
418
|
+
// headings are all bold; level reads from descending brightness alone (a
|
|
419
|
+
// terminal cannot scale font size), leaving the salmon accent to annotations
|
|
420
|
+
const headingFg =
|
|
421
|
+
block.kind === "h1"
|
|
422
|
+
? tokens.text
|
|
423
|
+
: block.kind === "h2"
|
|
424
|
+
? tokens.textMuted
|
|
425
|
+
: block.kind === "h3"
|
|
426
|
+
? tokens.textDim
|
|
427
|
+
: undefined;
|
|
428
|
+
// block-level base: headings bold, quotes muted italic; inline roles compose on top
|
|
429
|
+
const baseFg = headingFg ?? (isQuote ? tokens.textMuted : tokens.text);
|
|
430
|
+
const baseAttributes = (isHeading ? HEADING_ATTRIBUTES : 0) | (isQuote ? QUOTE_ATTRIBUTES : 0);
|
|
431
|
+
const withBase = (roleAttributes: number): number => baseAttributes | roleAttributes;
|
|
432
|
+
switch (run.role) {
|
|
433
|
+
case "ins":
|
|
434
|
+
return { fg: tokens.insertedForeground, attributes: baseAttributes || undefined };
|
|
435
|
+
case "del":
|
|
436
|
+
return { fg: tokens.deletedForeground };
|
|
437
|
+
case "mark-comment":
|
|
438
|
+
return { fg: tokens.text, bg: tokens.markCommentBackground };
|
|
439
|
+
case "mark-focus":
|
|
440
|
+
return { fg: tokens.accentInk, bg: tokens.accent };
|
|
441
|
+
case "kspan":
|
|
442
|
+
return { fg: tokens.accentInk, bg: tokens.accent };
|
|
443
|
+
case "strong":
|
|
444
|
+
return { fg: baseFg, attributes: withBase(STRONG_ATTRIBUTES) };
|
|
445
|
+
case "em":
|
|
446
|
+
return { fg: baseFg, attributes: withBase(EM_ATTRIBUTES) };
|
|
447
|
+
case "code":
|
|
448
|
+
return { fg: tokens.text, bg: tokens.elevated };
|
|
449
|
+
case "strike":
|
|
450
|
+
return { fg: tokens.textMuted, attributes: withBase(STRIKE_ATTRIBUTES) };
|
|
451
|
+
case "link":
|
|
452
|
+
return { fg: tokens.blue, attributes: withBase(LINK_ATTRIBUTES) };
|
|
453
|
+
default:
|
|
454
|
+
return { fg: baseFg, attributes: baseAttributes || undefined };
|
|
455
|
+
}
|
|
456
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { DARK } from "../theme";
|
|
3
|
+
import type { Story, StoryMeta } from "./story";
|
|
4
|
+
import { PromptDialog } from "./PromptDialog";
|
|
5
|
+
|
|
6
|
+
export const meta: StoryMeta = { title: "PromptDialog" };
|
|
7
|
+
|
|
8
|
+
export const RenameAuthor: Story = {
|
|
9
|
+
render: () => (
|
|
10
|
+
<PromptDialog
|
|
11
|
+
isOpen
|
|
12
|
+
title=" Rename author "
|
|
13
|
+
label="Display name for this collaborator:"
|
|
14
|
+
value="Robin"
|
|
15
|
+
placeholder="their name"
|
|
16
|
+
onInput={() => {}}
|
|
17
|
+
/>
|
|
18
|
+
),
|
|
19
|
+
expectedColors: [DARK.elevated, DARK.accent],
|
|
20
|
+
size: { width: 64, height: 16 },
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export const NameYourself: Story = {
|
|
24
|
+
render: () => (
|
|
25
|
+
<PromptDialog
|
|
26
|
+
isOpen
|
|
27
|
+
title=" Welcome "
|
|
28
|
+
label="Your name (optional) - it attributes the notes you leave:"
|
|
29
|
+
value=""
|
|
30
|
+
placeholder="your name"
|
|
31
|
+
onInput={() => {}}
|
|
32
|
+
/>
|
|
33
|
+
),
|
|
34
|
+
expectedColors: [DARK.elevated],
|
|
35
|
+
size: { width: 64, height: 16 },
|
|
36
|
+
};
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A centered single-field text prompt on the shared Dialog overlay. The focused
|
|
3
|
+
* input owns typing/cursor natively; the reducer's `prompt` overlay drives ⏎
|
|
4
|
+
* save / esc cancel (like the composer). Used to rename an author and to ask a
|
|
5
|
+
* collaborator for their name.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import React, { useEffect, useRef } from "react";
|
|
9
|
+
import { useTerminalDimensions } from "@opentui/react";
|
|
10
|
+
import type { KeyBinding, TextareaRenderable } from "@opentui/core";
|
|
11
|
+
import type { Theme } from "../theme";
|
|
12
|
+
import { useComponentTheme } from "./theme-context";
|
|
13
|
+
import { Dialog } from "./primitives/Dialog";
|
|
14
|
+
|
|
15
|
+
// ⏎ submits (suppresses the textarea's default newline); the grammar owns save.
|
|
16
|
+
const PROMPT_KEY_BINDINGS: KeyBinding[] = [{ name: "return", action: "submit" }];
|
|
17
|
+
|
|
18
|
+
export interface PromptDialogProps {
|
|
19
|
+
isOpen: boolean;
|
|
20
|
+
title?: string;
|
|
21
|
+
label: string;
|
|
22
|
+
value: string;
|
|
23
|
+
placeholder?: string;
|
|
24
|
+
onInput: (text: string) => void;
|
|
25
|
+
theme?: Theme;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function PromptDialog({
|
|
29
|
+
isOpen,
|
|
30
|
+
title,
|
|
31
|
+
label,
|
|
32
|
+
value,
|
|
33
|
+
placeholder,
|
|
34
|
+
onInput,
|
|
35
|
+
theme,
|
|
36
|
+
}: PromptDialogProps): React.ReactNode {
|
|
37
|
+
const tokens = useComponentTheme(theme);
|
|
38
|
+
const { width: terminalWidth } = useTerminalDimensions();
|
|
39
|
+
const inputRef = useRef<TextareaRenderable | null>(null);
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
// open with the caret after the seeded value, like a text field
|
|
42
|
+
if (inputRef.current) inputRef.current.cursorOffset = value.length;
|
|
43
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
44
|
+
}, []);
|
|
45
|
+
if (!isOpen) return null;
|
|
46
|
+
return (
|
|
47
|
+
<Dialog isOpen title={title} width={Math.min(54, terminalWidth - 6)} height={7} theme={theme}>
|
|
48
|
+
<box
|
|
49
|
+
style={{
|
|
50
|
+
flexDirection: "column",
|
|
51
|
+
flexGrow: 1,
|
|
52
|
+
paddingLeft: 1,
|
|
53
|
+
paddingRight: 1,
|
|
54
|
+
paddingTop: 1,
|
|
55
|
+
}}
|
|
56
|
+
>
|
|
57
|
+
<text fg={tokens.textDim}>{label}</text>
|
|
58
|
+
<textarea
|
|
59
|
+
ref={inputRef}
|
|
60
|
+
focused
|
|
61
|
+
initialValue={value}
|
|
62
|
+
placeholder={placeholder}
|
|
63
|
+
keyBindings={PROMPT_KEY_BINDINGS}
|
|
64
|
+
onContentChange={() => onInput(inputRef.current?.plainText ?? "")}
|
|
65
|
+
style={{
|
|
66
|
+
height: 1,
|
|
67
|
+
backgroundColor: tokens.elevated,
|
|
68
|
+
focusedBackgroundColor: tokens.elevated,
|
|
69
|
+
textColor: tokens.text,
|
|
70
|
+
focusedTextColor: tokens.text,
|
|
71
|
+
}}
|
|
72
|
+
/>
|
|
73
|
+
<box style={{ flexGrow: 1 }} />
|
|
74
|
+
<text fg={tokens.textDim}>enter save · esc cancel</text>
|
|
75
|
+
</box>
|
|
76
|
+
</Dialog>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { DARK } from "../theme";
|
|
3
|
+
import type { Story, StoryMeta } from "./story";
|
|
4
|
+
import { PrototypeSheet } from "./PrototypeSheet";
|
|
5
|
+
|
|
6
|
+
export const meta: StoryMeta = { title: "PrototypeSheet" };
|
|
7
|
+
|
|
8
|
+
// The story renderer reports no kitty graphics, so the sheet shows its fallback.
|
|
9
|
+
export const Unsupported: Story = {
|
|
10
|
+
render: () => (
|
|
11
|
+
<PrototypeSheet
|
|
12
|
+
prototypePath="/tmp/example.html"
|
|
13
|
+
quickActions={[]}
|
|
14
|
+
canComment={true}
|
|
15
|
+
onCommentElement={() => {}}
|
|
16
|
+
/>
|
|
17
|
+
),
|
|
18
|
+
expectedColors: [DARK.textDim],
|
|
19
|
+
size: { width: 80, height: 24 },
|
|
20
|
+
};
|