@cueloop/client 0.1.0-alpha.56 → 0.1.0-alpha.59

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.
@@ -0,0 +1,60 @@
1
+ import type { ReviewSession } from "@cueloop/schema";
2
+ import type { Mode } from "./intent-dispatch";
3
+ import type { KeyState } from "./keymap";
4
+ import type { DisplayBlock } from "./view-plan";
5
+ import type { DiffRow } from "./view-diff";
6
+ import type { WalkFile } from "./walk";
7
+
8
+ export function buildKeyState(params: {
9
+ keys: Record<string, string[]>;
10
+ observer: boolean;
11
+ isOwner: boolean;
12
+ overlay: KeyState["overlay"];
13
+ session: ReviewSession | null;
14
+ isDiff: boolean;
15
+ mode: Mode;
16
+ resolved: boolean;
17
+ inbox: ReviewSession[] | null;
18
+ focusedAnnotationId: string | undefined;
19
+ walk: { index: number } | null;
20
+ walkFileList: WalkFile[];
21
+ rows: DiffRow[];
22
+ cursor: number;
23
+ display: DisplayBlock[];
24
+ }): KeyState {
25
+ const {
26
+ keys,
27
+ observer,
28
+ isOwner,
29
+ overlay,
30
+ session,
31
+ isDiff,
32
+ mode,
33
+ resolved,
34
+ inbox,
35
+ focusedAnnotationId,
36
+ walk,
37
+ walkFileList,
38
+ rows,
39
+ cursor,
40
+ display,
41
+ } = params;
42
+ return {
43
+ keys,
44
+ readOnly: observer,
45
+ canEditPlan: isOwner,
46
+ canSubmitVerdict: isOwner,
47
+ canShare: isOwner,
48
+ overlay,
49
+ view: !session ? "inbox" : isDiff ? "diff" : "plan",
50
+ spanMode: mode.type === "span",
51
+ resolved,
52
+ hasInboxItems: !!inbox?.length,
53
+ annotationCount: session?.annotations.length ?? 0,
54
+ hasFocusedAnnotation: focusedAnnotationId !== undefined,
55
+ walkAtEnd: walk !== null && walk.index >= walkFileList.length,
56
+ cursorAnnotatable: isDiff
57
+ ? rows[cursor] !== undefined && rows[cursor]!.kind !== "file" && rows[cursor]!.kind !== "hunk"
58
+ : !!display[cursor]?.work,
59
+ };
60
+ }
@@ -0,0 +1,266 @@
1
+ import React, { type Dispatch, type SetStateAction } from "react";
2
+ import type { ReviewSession, VerdictKind } from "@cueloop/schema";
3
+ import { returnPaneFor } from "@cueloop/schema";
4
+ import type { Theme } from "./theme";
5
+ import type { Mode } from "./intent-dispatch";
6
+ import type { Intent } from "./keymap";
7
+ import type { ReviewController, ToastState } from "./session-controller";
8
+ import { noteForFile } from "./walk";
9
+ import type { WalkFile } from "./walk";
10
+ import type { CheatsheetSection } from "./key-bindings";
11
+ import type { SettingsCategory, SettingsValues } from "./components/SettingsDialog";
12
+ import type { SettingsNav } from "./use-settings-dialog";
13
+ import { CLIENT_VERSION } from "./version";
14
+ import { ThemeProvider } from "./components/theme-context";
15
+ import { MenuBar } from "./components/MenuBar";
16
+ import { KeybindsDialog } from "./components/KeybindsDialog";
17
+ import { SettingsDialog } from "./components/SettingsDialog";
18
+ import { CompletionOverlay } from "./components/CompletionOverlay";
19
+ import { InboxList } from "./components/InboxList";
20
+ import { ConfirmDialog } from "./components/ConfirmDialog";
21
+ import { PromptDialog } from "./components/PromptDialog";
22
+ import { WalkWizard } from "./components/WalkWizard";
23
+ import { Toast } from "./components/Toast";
24
+
25
+ export function ErrorScreen({ error, theme }: { error: string; theme: Theme }): React.ReactNode {
26
+ return (
27
+ <ThemeProvider theme={theme}>
28
+ <text fg={theme.red}>cueloop: {error}</text>
29
+ </ThemeProvider>
30
+ );
31
+ }
32
+
33
+ export function ConnectingScreen({ theme }: { theme: Theme }): React.ReactNode {
34
+ return (
35
+ <ThemeProvider theme={theme}>
36
+ <text fg={theme.textDim}>connecting to the daemon…</text>
37
+ </ThemeProvider>
38
+ );
39
+ }
40
+
41
+ export function MenuChrome(props: {
42
+ menuOpen: boolean;
43
+ menuDialog: "keybinds" | "settings" | null;
44
+ status: string;
45
+ theme: Theme;
46
+ setMenuOpen: Dispatch<SetStateAction<boolean>>;
47
+ setMenuDialog: Dispatch<SetStateAction<"keybinds" | "settings" | null>>;
48
+ keybindsSections: CheatsheetSection[];
49
+ settingsCategories: SettingsCategory[];
50
+ settingsValues: SettingsValues;
51
+ settingsNav: SettingsNav;
52
+ onCategorySelect: (categoryId: string) => void;
53
+ cycleSetting: (rowKey: string) => void;
54
+ }): React.ReactNode {
55
+ const {
56
+ menuOpen,
57
+ menuDialog,
58
+ status,
59
+ theme,
60
+ setMenuOpen,
61
+ setMenuDialog,
62
+ keybindsSections,
63
+ settingsCategories,
64
+ settingsValues,
65
+ settingsNav,
66
+ onCategorySelect,
67
+ cycleSetting,
68
+ } = props;
69
+ return (
70
+ <>
71
+ <MenuBar
72
+ open={menuOpen}
73
+ version={CLIENT_VERSION}
74
+ status={status}
75
+ onToggle={() => setMenuOpen((isOpen) => !isOpen)}
76
+ onSettings={() => {
77
+ setMenuOpen(false);
78
+ setMenuDialog("settings");
79
+ }}
80
+ onKeybinds={() => {
81
+ setMenuOpen(false);
82
+ setMenuDialog("keybinds");
83
+ }}
84
+ theme={theme}
85
+ />
86
+ {menuDialog === "keybinds" ? (
87
+ <KeybindsDialog sections={keybindsSections} theme={theme} />
88
+ ) : null}
89
+ {menuDialog === "settings" ? (
90
+ <SettingsDialog
91
+ isOpen
92
+ categories={settingsCategories}
93
+ values={settingsValues}
94
+ activeCategoryId={settingsNav.categoryId}
95
+ activeRowIndex={settingsNav.rowIndex}
96
+ activeZone={settingsNav.zone}
97
+ onCategorySelect={onCategorySelect}
98
+ onRowActivate={(row) => cycleSetting(row.key)}
99
+ theme={theme}
100
+ />
101
+ ) : null}
102
+ </>
103
+ );
104
+ }
105
+
106
+ export function InboxScreen(props: {
107
+ inbox: ReviewSession[];
108
+ inboxCursor: number;
109
+ mode: Mode;
110
+ theme: Theme;
111
+ controller: ReviewController;
112
+ setMode: Dispatch<SetStateAction<Mode>>;
113
+ menuChrome: React.ReactNode;
114
+ }): React.ReactNode {
115
+ const { inbox, inboxCursor, mode, theme, controller, setMode, menuChrome } = props;
116
+ const confirming = mode.type === "confirmDelete" ? mode : null;
117
+ return (
118
+ <ThemeProvider theme={theme}>
119
+ <box
120
+ style={{
121
+ flexDirection: "column",
122
+ width: "100%",
123
+ height: "100%",
124
+ backgroundColor: theme.background,
125
+ }}
126
+ >
127
+ {/* mirrors the review header row: same box, position, and accent product
128
+ word, with a " · resume" separator and no Edit/Share toolbar */}
129
+ <box
130
+ style={{ flexDirection: "row", height: 2, paddingTop: 1, backgroundColor: theme.panel }}
131
+ >
132
+ <box
133
+ style={{
134
+ height: 1,
135
+ backgroundColor: theme.panel,
136
+ paddingLeft: 1,
137
+ flexDirection: "row",
138
+ }}
139
+ >
140
+ <text>
141
+ <span fg={theme.accent}>cueloop</span>
142
+ <span fg={theme.textDim}> · resume</span>
143
+ </text>
144
+ </box>
145
+ <box style={{ flexGrow: 1 }} />
146
+ </box>
147
+ <InboxList
148
+ inbox={inbox}
149
+ cursor={inboxCursor}
150
+ onRequestDelete={(id, title) => setMode({ type: "confirmDelete", sessionId: id, title })}
151
+ />
152
+ {menuChrome}
153
+ <ConfirmDialog
154
+ isOpen={confirming !== null}
155
+ title=" Delete plan "
156
+ message={
157
+ confirming ? `Delete "${confirming.title}"? This removes the plan and its review.` : ""
158
+ }
159
+ onConfirm={() => {
160
+ if (confirming) controller.deleteSession(confirming.sessionId);
161
+ setMode({ type: "normal" });
162
+ }}
163
+ onCancel={() => setMode({ type: "normal" })}
164
+ theme={theme}
165
+ />
166
+ </box>
167
+ </ThemeProvider>
168
+ );
169
+ }
170
+
171
+ export function CompletionScreen(props: {
172
+ theme: Theme;
173
+ session: ReviewSession;
174
+ verdict: VerdictKind;
175
+ completion: { phase: "prompt" } | { phase: "counting"; remaining: number };
176
+ status: string;
177
+ }): React.ReactNode {
178
+ const { theme, session, verdict, completion, status } = props;
179
+ return (
180
+ <ThemeProvider theme={theme}>
181
+ <CompletionOverlay
182
+ verdict={verdict}
183
+ completion={completion}
184
+ status={status}
185
+ returnsTo={
186
+ returnPaneFor(session.artifact.meta.herdrPane)
187
+ ? (session.artifact.meta.agent ?? "the agent")
188
+ : undefined
189
+ }
190
+ />
191
+ </ThemeProvider>
192
+ );
193
+ }
194
+
195
+ export function TrailingOverlays(props: {
196
+ walking: boolean;
197
+ walk: { index: number } | null;
198
+ walkFileList: WalkFile[];
199
+ viewedPaths: Set<string>;
200
+ session: ReviewSession;
201
+ terminalWidth: number;
202
+ theme: Theme;
203
+ mode: Mode;
204
+ toast: ToastState | null;
205
+ setMode: Dispatch<SetStateAction<Mode>>;
206
+ dispatch: (intent: Intent) => void;
207
+ }): React.ReactNode {
208
+ const {
209
+ walking,
210
+ walk,
211
+ walkFileList,
212
+ viewedPaths,
213
+ session,
214
+ terminalWidth,
215
+ theme,
216
+ mode,
217
+ toast,
218
+ setMode,
219
+ dispatch,
220
+ } = props;
221
+ return (
222
+ <>
223
+ {walking && walk !== null ? (
224
+ <WalkWizard
225
+ files={walkFileList}
226
+ index={walk.index}
227
+ viewedPaths={viewedPaths}
228
+ note={
229
+ walkFileList[walk.index] !== undefined
230
+ ? noteForFile(session.annotations, walkFileList[walk.index]!.path)
231
+ : undefined
232
+ }
233
+ terminalWidth={terminalWidth}
234
+ onSubmitRequest={() => {
235
+ dispatch({ type: "walkLeave" });
236
+ dispatch({ type: "openSubmit" });
237
+ }}
238
+ onBack={() => dispatch({ type: "walkBack" })}
239
+ />
240
+ ) : null}
241
+ {mode.type === "rename" ? (
242
+ <PromptDialog
243
+ isOpen
244
+ title=" Rename author "
245
+ label="Display name for this collaborator:"
246
+ value={mode.text}
247
+ placeholder="their name"
248
+ onInput={(text) => setMode({ ...mode, text })}
249
+ theme={theme}
250
+ />
251
+ ) : null}
252
+ {mode.type === "nameSelf" ? (
253
+ <PromptDialog
254
+ isOpen
255
+ title=" Welcome "
256
+ label="Your name (optional) - it attributes the notes you leave:"
257
+ value={mode.text}
258
+ placeholder="your name"
259
+ onInput={(text) => setMode({ ...mode, text })}
260
+ theme={theme}
261
+ />
262
+ ) : null}
263
+ {toast ? <Toast title={toast.title} body={toast.body} theme={theme} /> : null}
264
+ </>
265
+ );
266
+ }
@@ -0,0 +1,286 @@
1
+ import type { Dispatch, MutableRefObject, SetStateAction } from "react";
2
+ import type { ReviewSession, VerdictKind } from "@cueloop/schema";
3
+ import { activeSpanState, reviewerAnnotations, type Mode } from "./intent-dispatch";
4
+ import { displayText, type DisplayBlock } from "./view-plan";
5
+ import type { DiffRow } from "./view-diff";
6
+ import type { Intent, KeyState } from "./keymap";
7
+ import type { Completion, ReviewController } from "./session-controller";
8
+ import type { QuickAction } from "./config";
9
+ import type { WalkFile } from "./walk";
10
+ import { viewedCount } from "./walk";
11
+ import type { PlanComposeState, PlanPopoverState } from "./components/PlanSheet";
12
+ import type { DiffComposeState } from "./components/DiffSheet";
13
+ import type { ConfirmCardProps } from "./components/ConfirmCard";
14
+ import type { RailCardEdit } from "./components/ReviewRail";
15
+ import type { BreadcrumbItem } from "./components/Breadcrumb";
16
+ import { REVIEW_COMPACT_WIDTH, resolveReviewWidth, type ReviewPanelMode } from "./review-panel";
17
+
18
+ export function computeRoleCapabilities(
19
+ readOnly: boolean,
20
+ role: "owner" | "observer" | "collaborator",
21
+ ): { observer: boolean; isOwner: boolean } {
22
+ const observer = readOnly || role === "observer";
23
+ return { observer, isOwner: !observer && role === "owner" };
24
+ }
25
+
26
+ export function deriveReviewFlags(session: ReviewSession | null): {
27
+ isDiff: boolean;
28
+ isPrototype: boolean;
29
+ resolved: boolean;
30
+ } {
31
+ return {
32
+ isDiff: session?.artifact.type === "diff",
33
+ isPrototype: session?.artifact.type === "prototype",
34
+ resolved: session?.status === "resolved",
35
+ };
36
+ }
37
+
38
+ export function isWalking(isDiff: boolean, walk: { index: number } | null): boolean {
39
+ return isDiff && walk !== null;
40
+ }
41
+
42
+ export function resolveOverlay(
43
+ mode: Mode,
44
+ completionPhase: Completion["phase"],
45
+ walking: boolean,
46
+ ): KeyState["overlay"] {
47
+ if (mode.type === "compose" || mode.type === "railEdit") return "compose";
48
+ if (mode.type === "submit") return "submit";
49
+ if (mode.type === "confirmDelete") return "confirm";
50
+ if (mode.type === "rename" || mode.type === "nameSelf") return "prompt";
51
+ if (mode.type === "spanActions") return "spanActions";
52
+ if (completionPhase === "prompt") return "completion-prompt";
53
+ if (completionPhase === "counting") return "completion-counting";
54
+ if (walking) return "walk";
55
+ return "none";
56
+ }
57
+
58
+ export function isCompletionOverlayPhase(
59
+ completion: Completion,
60
+ ): completion is { phase: "prompt" } | { phase: "counting"; remaining: number } {
61
+ return completion.phase === "prompt" || completion.phase === "counting";
62
+ }
63
+
64
+ export function computePendingCount(session: ReviewSession): number {
65
+ return reviewerAnnotations(session).length + (session.workingCopy !== undefined ? 1 : 0);
66
+ }
67
+
68
+ export function computeRailFootprint(
69
+ reviewMode: ReviewPanelMode,
70
+ reviewWidth: number,
71
+ terminalWidth: number,
72
+ ): number {
73
+ if (reviewMode === "hidden") return 0;
74
+ return (
75
+ 1 +
76
+ (reviewMode === "compact"
77
+ ? REVIEW_COMPACT_WIDTH
78
+ : resolveReviewWidth(reviewWidth, terminalWidth))
79
+ );
80
+ }
81
+
82
+ export function buildHeaderItems(params: {
83
+ session: ReviewSession;
84
+ resolved: boolean;
85
+ observer: boolean;
86
+ role: "owner" | "observer" | "collaborator";
87
+ }): BreadcrumbItem[] {
88
+ const { session, resolved, observer, role } = params;
89
+ return [
90
+ { label: "cueloop", tone: "accent" },
91
+ ...(resolved
92
+ ? [
93
+ {
94
+ label: `resolved: ${session.verdict!.kind.replace("_", " ")}`,
95
+ tone: "green" as const,
96
+ },
97
+ ]
98
+ : []),
99
+ ...(observer ? [{ label: "observer", tone: "dim" as const }] : []),
100
+ ...(role === "collaborator"
101
+ ? [{ label: "shared · your notes save as you go", tone: "dim" as const }]
102
+ : []),
103
+ {
104
+ label: `${session.artifact.meta.title ?? session.artifact.meta.planPath ?? session.id} · rev ${session.revisions.length}`,
105
+ tone: "dim",
106
+ },
107
+ { label: `submitted by ${session.artifact.meta.agent ?? "unknown"}`, tone: "dim" },
108
+ ];
109
+ }
110
+
111
+ export function buildRenderFlags(params: {
112
+ session: ReviewSession;
113
+ isOwner: boolean;
114
+ isDiff: boolean;
115
+ isPrototype: boolean;
116
+ resolved: boolean;
117
+ menuOpen: boolean;
118
+ menuDialog: "keybinds" | "settings" | null;
119
+ resolvedIds: Set<string>;
120
+ }): {
121
+ showOwnerActions: boolean;
122
+ prototypeCanComment: boolean;
123
+ chromeHidden: boolean;
124
+ prototypePath: string;
125
+ railResolvedIds: Set<string> | null;
126
+ } {
127
+ const { session, isOwner, isDiff, isPrototype, resolved, menuOpen, menuDialog, resolvedIds } =
128
+ params;
129
+ return {
130
+ showOwnerActions: isOwner && !isDiff && !resolved,
131
+ prototypeCanComment: isOwner && !resolved,
132
+ chromeHidden: menuOpen || menuDialog !== null,
133
+ prototypePath: session.artifact.meta.prototypePath ?? "",
134
+ railResolvedIds: isDiff || isPrototype ? null : resolvedIds,
135
+ };
136
+ }
137
+
138
+ export function buildActiveSpan(
139
+ mode: Mode,
140
+ isDiff: boolean,
141
+ ): { displayIndex: number; start: number; end: number } | null {
142
+ const markedSpan = activeSpanState(mode);
143
+ if (markedSpan)
144
+ return { displayIndex: markedSpan.displayIndex, start: markedSpan.start, end: markedSpan.end };
145
+ if (mode.type === "compose" && !isDiff)
146
+ return { displayIndex: mode.displayIndex, start: mode.start, end: mode.end };
147
+ return null;
148
+ }
149
+
150
+ interface DraftHandlerDeps {
151
+ liveInput: MutableRefObject<string>;
152
+ setMode: Dispatch<SetStateAction<Mode>>;
153
+ dispatch: (intent: Intent) => void;
154
+ }
155
+
156
+ export function buildComposeState(
157
+ deps: DraftHandlerDeps & { mode: Mode; isDiff: boolean; display: DisplayBlock[] },
158
+ ): PlanComposeState | null {
159
+ const { mode, isDiff, display, liveInput, setMode, dispatch } = deps;
160
+ if (mode.type !== "compose" || isDiff) return null;
161
+ return {
162
+ kind: mode.kind,
163
+ displayIndex: mode.displayIndex,
164
+ quote: displayText(display[mode.displayIndex]!).slice(mode.start, mode.end),
165
+ draft: {
166
+ text: mode.text,
167
+ onInput: (text: string) => {
168
+ liveInput.current = text;
169
+ setMode({ ...mode, text });
170
+ },
171
+ onSave: () => dispatch({ type: "saveCompose" }),
172
+ onCancel: () => dispatch({ type: "closeOverlay" }),
173
+ },
174
+ };
175
+ }
176
+
177
+ export function buildDiffComposeState(
178
+ deps: DraftHandlerDeps & { mode: Mode; isDiff: boolean; rows: DiffRow[] },
179
+ ): DiffComposeState | null {
180
+ const { mode, isDiff, rows, liveInput, setMode, dispatch } = deps;
181
+ if (mode.type !== "compose" || !isDiff) return null;
182
+ return {
183
+ kind: mode.kind,
184
+ rowIndex: mode.displayIndex,
185
+ quote: rows[mode.displayIndex]?.text.replace(/\n$/, "") ?? "",
186
+ draft: {
187
+ text: mode.text,
188
+ onInput: (text: string) => {
189
+ liveInput.current = text;
190
+ setMode({ ...mode, text });
191
+ },
192
+ onSave: () => dispatch({ type: "saveCompose" }),
193
+ onCancel: () => dispatch({ type: "closeOverlay" }),
194
+ },
195
+ };
196
+ }
197
+
198
+ export function buildPopoverState(deps: {
199
+ mode: Mode;
200
+ isDiff: boolean;
201
+ quickActions: QuickAction[];
202
+ isOwner: boolean;
203
+ observer: boolean;
204
+ resolved: boolean;
205
+ controller: ReviewController;
206
+ dispatch: (intent: Intent) => void;
207
+ }): PlanPopoverState | null {
208
+ const { mode, isDiff, quickActions, isOwner, observer, resolved, controller, dispatch } = deps;
209
+ const markedSpan = activeSpanState(mode);
210
+ if (!markedSpan || isDiff) return null;
211
+ const spanMutationBlock = (): string | null =>
212
+ observer ? "observer - read-only" : resolved ? "review submitted - read-only" : null;
213
+ return {
214
+ displayIndex: markedSpan.displayIndex,
215
+ view: mode.type === "spanActions" ? ("actions" as const) : ("toolbar" as const),
216
+ actions: quickActions,
217
+ actionIndex: mode.type === "spanActions" ? mode.index : 0,
218
+ canCut: isOwner,
219
+ onComment: () => {
220
+ const blocked = spanMutationBlock();
221
+ if (blocked) return controller.setStatus(blocked);
222
+ dispatch({ type: "openCompose", kind: "comment", from: "span" });
223
+ },
224
+ onCut: () => {
225
+ const blocked = spanMutationBlock();
226
+ if (blocked) return controller.setStatus(blocked);
227
+ if (!isOwner) return;
228
+ dispatch({ type: "spanCut" });
229
+ },
230
+ onOpenActions: () => {
231
+ const blocked = spanMutationBlock();
232
+ if (blocked) return controller.setStatus(blocked);
233
+ dispatch({ type: "openSpanActions" });
234
+ },
235
+ onClose: () => dispatch({ type: "closeOverlay" }),
236
+ onPickAction: (index: number) => {
237
+ const blocked = spanMutationBlock();
238
+ if (blocked) return controller.setStatus(blocked);
239
+ dispatch({ type: "pickSpanAction", index });
240
+ },
241
+ onBack: () => dispatch({ type: "closeSpanActions" }),
242
+ };
243
+ }
244
+
245
+ export function buildSubmitConfirmState(
246
+ deps: DraftHandlerDeps & {
247
+ mode: Mode;
248
+ isDiff: boolean;
249
+ session: ReviewSession;
250
+ walkFileList: WalkFile[];
251
+ viewedPaths: Set<string>;
252
+ },
253
+ ): Omit<ConfirmCardProps, "theme"> | null {
254
+ const { mode, isDiff, session, walkFileList, viewedPaths, liveInput, setMode, dispatch } = deps;
255
+ if (mode.type !== "submit") return null;
256
+ return {
257
+ verdict: mode.verdict,
258
+ summary: mode.summary,
259
+ viewedSummary:
260
+ isDiff && session.viewedPaths !== undefined
261
+ ? `${viewedCount(walkFileList, viewedPaths)}/${walkFileList.length} files viewed`
262
+ : undefined,
263
+ onInput: (summary: string) => {
264
+ liveInput.current = summary;
265
+ setMode({ ...mode, summary });
266
+ },
267
+ onSelectVerdict: (verdict: VerdictKind) => setMode({ ...mode, verdict }),
268
+ onSubmit: () => dispatch({ type: "submitVerdict" }),
269
+ onCancel: () => dispatch({ type: "closeOverlay" }),
270
+ };
271
+ }
272
+
273
+ export function buildCardEditState(deps: DraftHandlerDeps & { mode: Mode }): RailCardEdit | null {
274
+ const { mode, liveInput, setMode, dispatch } = deps;
275
+ if (mode.type !== "railEdit") return null;
276
+ return {
277
+ id: mode.id,
278
+ text: mode.text,
279
+ onInput: (text: string) => {
280
+ liveInput.current = text;
281
+ setMode({ type: "railEdit", id: mode.id, text });
282
+ },
283
+ onSave: () => dispatch({ type: "saveCompose" }),
284
+ onCancel: () => dispatch({ type: "closeOverlay" }),
285
+ };
286
+ }
@@ -10,7 +10,7 @@
10
10
 
11
11
  import React, { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react";
12
12
  import { useRenderer } from "@opentui/react";
13
- import { createTextAttributes, type ScrollBoxRenderable, type TextRenderable } from "@opentui/core";
13
+ import { type ScrollBoxRenderable, type TextRenderable } from "@opentui/core";
14
14
  import type { ReviewSession } from "@cueloop/schema";
15
15
  import {
16
16
  blockRuns,
@@ -31,18 +31,7 @@ import { CodeBlock } from "./CodeBlock";
31
31
  import { AnnotationCard, type AnnotationDraft } from "./AnnotationCard";
32
32
  import { MarkerPopover, type MarkerPopoverProps } from "./MarkerPopover";
33
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 });
34
+ import { runStyle } from "./plan-sheet-run-style";
46
35
 
47
36
  /** The popover toolbar card is 3 rows tall and sits flush above the marked words. */
48
37
  const POPOVER_ROWS_ABOVE = 3;
@@ -405,52 +394,3 @@ function tagLabel(block: DisplayBlock): "new" | "edited" {
405
394
  function tagColor(block: DisplayBlock, tokens: Theme): string {
406
395
  return block.type === "add" ? tokens.green : tokens.accent;
407
396
  }
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
- }