@cueloop/client 0.1.0-alpha.10 → 0.1.0-alpha.101
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 +42 -34
- package/src/annotations/annotation-palette.ts +62 -0
- package/src/annotations/components/AnnotationCards.stories.tsx +86 -0
- package/src/annotations/components/AnnotationCards.test.tsx +42 -0
- package/src/annotations/components/AnnotationCards.tsx +369 -0
- package/src/annotations/components/DiscussionMarkerRail.stories.tsx +54 -0
- package/src/annotations/components/DiscussionMarkerRail.tsx +186 -0
- package/src/annotations/discussions.ts +77 -0
- package/src/annotations/thread-selection.test.ts +210 -0
- package/src/annotations/thread-selection.ts +242 -0
- package/src/annotations/use-annotation-surface.tsx +1243 -0
- package/src/app/App.appearance.test.tsx +53 -0
- package/src/{App.autoclose.test.tsx → app/App.autoclose.test.tsx} +77 -51
- package/src/app/App.deleted-scroll.test.tsx +187 -0
- package/src/app/App.diff.test.tsx +313 -0
- package/src/app/App.export.test.tsx +105 -0
- package/src/app/App.focus.test.ts +89 -0
- package/src/app/App.identity.test.tsx +84 -0
- package/src/app/App.plan-render.test.tsx +209 -0
- package/src/app/App.plan-review.test.tsx +428 -0
- package/src/app/App.prototype-markdown.test.tsx +128 -0
- package/src/app/App.prototype.test.tsx +176 -0
- package/src/app/App.readonly.test.tsx +210 -0
- package/src/app/App.regression.test.tsx +99 -0
- package/src/app/App.rendering.test.tsx +134 -0
- package/src/app/App.share-dialog.test.tsx +177 -0
- package/src/app/App.submit-confirm.test.tsx +267 -0
- package/src/app/App.test.tsx +496 -0
- package/src/app/App.thread-agent.test.ts +20 -0
- package/src/app/App.toast.test.tsx +124 -0
- package/src/app/App.tsx +1667 -0
- package/src/app/App.visual.test.tsx +59 -0
- package/src/app/App.walk.test.tsx +299 -0
- package/src/app/App.workbench.test.tsx +1149 -0
- package/src/app/app-key-state.ts +61 -0
- package/src/app/app-screens.tsx +606 -0
- package/src/app/app-view-model.ts +168 -0
- package/src/app/components/AppHeader.stories.tsx +50 -0
- package/src/app/components/AppHeader.tsx +81 -0
- package/src/app/components/AppShell.brand.test.tsx +58 -0
- package/src/app/components/AppShell.extensions.test.tsx +191 -0
- package/src/app/components/AppShell.stories.tsx +89 -0
- package/src/app/components/AppShell.tsx +358 -0
- package/src/app/components/AppShell.zoom.test.tsx +149 -0
- package/src/app/components/Breadcrumb.stories.tsx +19 -0
- package/src/app/components/Breadcrumb.tsx +45 -0
- package/src/app/components/MenuBar.stories.tsx +28 -0
- package/src/app/components/MenuBar.tsx +44 -0
- package/src/app/components/PaneGrid.stories.tsx +84 -0
- package/src/app/components/PaneGrid.tsx +128 -0
- package/src/app/components/PanelColumn.stories.tsx +25 -0
- package/src/app/components/PanelColumn.tsx +93 -0
- package/src/app/components/ShellHeader.stories.tsx +50 -0
- package/src/app/components/ShellHeader.tsx +137 -0
- package/src/app/components/WorkspacePanels.stories.tsx +36 -0
- package/src/app/components/WorkspacePanels.tsx +120 -0
- package/src/app/components/pages/Workspace.stories.tsx +181 -0
- package/src/app/intent-dispatch.test.ts +728 -0
- package/src/app/intent-dispatch.ts +687 -0
- package/src/app/launch-layout.test.ts +78 -0
- package/src/app/launch-layout.ts +57 -0
- package/src/app/ready-signal.test.ts +85 -0
- package/src/app/ready-signal.ts +52 -0
- package/src/app/run.ts +109 -0
- package/src/{serve.test.ts → app/serve.test.ts} +24 -12
- package/src/app/serve.ts +111 -0
- package/src/app/version.ts +5 -0
- package/src/appearance/components/theme-context.stories.tsx +46 -0
- package/src/appearance/components/theme-context.tsx +37 -0
- package/src/appearance/theme-presets.test.ts +51 -0
- package/src/appearance/theme-presets.ts +219 -0
- package/src/appearance/theme.ts +112 -0
- package/src/appearance/visual/contrast.test.ts +35 -0
- package/src/appearance/visual/contrast.ts +33 -0
- package/src/diff/components/AnnotatableFileView.stories.tsx +42 -0
- package/src/diff/components/AnnotatableFileView.test.tsx +157 -0
- package/src/diff/components/AnnotatableFileView.tsx +141 -0
- package/src/diff/components/BareWorkbenchFileView.stories.tsx +27 -0
- package/src/diff/components/BareWorkbenchFileView.tsx +63 -0
- package/src/diff/components/DiffContentView.scroll.test.tsx +434 -0
- package/src/diff/components/DiffContentView.split.test.tsx +140 -0
- package/src/diff/components/DiffContentView.stories.tsx +80 -0
- package/src/diff/components/DiffContentView.syntax.test.tsx +82 -0
- package/src/diff/components/DiffContentView.tsx +1055 -0
- package/src/diff/components/WalkWizard.stories.tsx +100 -0
- package/src/diff/components/WalkWizard.tsx +146 -0
- package/src/diff/components/diff-content-view-layout.test.ts +198 -0
- package/src/diff/components/diff-content-view-layout.ts +84 -0
- package/src/diff/diff-fold.test.ts +78 -0
- package/src/diff/diff-fold.ts +126 -0
- package/src/diff/diff-hunk-curate.test.ts +315 -0
- package/src/diff/diff-hunk-curate.ts +152 -0
- package/src/diff/diff-intraline.test.ts +239 -0
- package/src/diff/diff-intraline.ts +238 -0
- package/src/diff/diff-syntax.test.ts +125 -0
- package/src/diff/diff-syntax.ts +186 -0
- package/src/diff/split-diff.test.ts +110 -0
- package/src/diff/split-diff.ts +144 -0
- package/src/diff/view-diff.test.ts +325 -0
- package/src/diff/view-diff.ts +522 -0
- package/src/diff/walk.test.ts +121 -0
- package/src/diff/walk.ts +73 -0
- package/src/index.ts +31 -2
- package/src/integrations/attribution.test.ts +31 -0
- package/src/integrations/attribution.ts +42 -0
- package/src/integrations/client-extension-registry.test.tsx +61 -0
- package/src/integrations/client-extension-registry.ts +159 -0
- package/src/integrations/clipboard.test.ts +23 -0
- package/src/integrations/clipboard.ts +33 -0
- package/src/integrations/components/ExtensionZone.stories.tsx +28 -0
- package/src/integrations/components/ExtensionZone.tsx +145 -0
- package/src/integrations/components/ShareDialog.agent.test.tsx +50 -0
- package/src/integrations/components/ShareDialog.stories.tsx +121 -0
- package/src/integrations/components/ShareDialog.tsx +574 -0
- package/src/integrations/components/share-dialog-store.ts +59 -0
- package/src/integrations/editor.test.ts +149 -0
- package/src/integrations/editor.ts +160 -0
- package/src/integrations/github-identity.test.ts +58 -0
- package/src/integrations/github-identity.ts +41 -0
- package/src/integrations/herdr.test.ts +43 -0
- package/src/integrations/herdr.ts +38 -0
- package/src/integrations/integrations.test.ts +41 -0
- package/src/integrations/integrations.ts +32 -0
- package/src/integrations/pasted-image.test.ts +67 -0
- package/src/integrations/pasted-image.ts +60 -0
- package/src/integrations/share.ts +238 -0
- package/src/keyboard/components/CompletionOverlay.stories.tsx +37 -0
- package/src/keyboard/components/CompletionOverlay.tsx +75 -0
- package/src/keyboard/components/HandleChipsInput.stories.tsx +24 -0
- package/src/keyboard/components/HandleChipsInput.test.tsx +35 -0
- package/src/keyboard/components/HandleChipsInput.tsx +143 -0
- package/src/keyboard/components/KeybindsDialog.stories.tsx +31 -0
- package/src/keyboard/components/KeybindsDialog.tsx +103 -0
- package/src/keyboard/components/NavModeHint.stories.tsx +40 -0
- package/src/keyboard/components/NavModeHint.tsx +31 -0
- package/src/keyboard/components/SlashComposer.stories.tsx +37 -0
- package/src/keyboard/components/SlashComposer.tsx +87 -0
- package/src/keyboard/key-bindings.test.ts +156 -0
- package/src/keyboard/key-bindings.ts +459 -0
- package/src/keyboard/keymap.test.ts +611 -0
- package/src/keyboard/keymap.ts +395 -0
- package/src/keyboard/skills.test.ts +74 -0
- package/src/keyboard/skills.ts +75 -0
- package/src/keyboard/slash-palette.test.ts +103 -0
- package/src/keyboard/slash-palette.ts +129 -0
- package/src/keyboard/thread-chords.test.ts +79 -0
- package/src/keyboard/thread-chords.ts +163 -0
- package/src/keyboard/use-shared-keyboard.ts +55 -0
- package/src/markdown/__snapshots__/view-plan.regression.test.ts.snap +100 -0
- package/src/markdown/components/MarkdownGridBlock.stories.tsx +33 -0
- package/src/markdown/components/MarkdownGridBlock.tsx +72 -0
- package/src/markdown/components/MarkdownThreadEditor.stories.tsx +15 -0
- package/src/markdown/components/MarkdownThreadEditor.test.tsx +98 -0
- package/src/markdown/components/MarkdownThreadEditor.tsx +160 -0
- package/src/markdown/components/ThreadView.selection.test.tsx +951 -0
- package/src/markdown/components/ThreadView.slash.test.tsx +146 -0
- package/src/markdown/components/ThreadView.stories.tsx +26 -0
- package/src/markdown/components/ThreadView.tsx +560 -0
- package/src/markdown/mark-runs.ts +121 -0
- package/src/markdown/markdown-editor-syntax-style.ts +50 -0
- package/src/markdown/markdown-grid.test.ts +101 -0
- package/src/markdown/markdown-grid.ts +203 -0
- package/src/markdown/markdown-highlight.test.ts +61 -0
- package/src/markdown/markdown-highlight.ts +124 -0
- package/src/markdown/thread-code-syntax.ts +45 -0
- package/src/markdown/use-thread-body-editing.tsx +54 -0
- package/src/markdown/view-plan.regression.test.ts +60 -0
- package/src/markdown/view-plan.test.ts +545 -0
- package/src/markdown/view-plan.ts +1009 -0
- package/src/performance/otlp-trace.test.ts +119 -0
- package/src/performance/otlp-trace.ts +134 -0
- package/src/performance/perf-report.ts +18 -0
- package/src/performance/perf-timings.test.ts +18 -0
- package/src/performance/perf-timings.ts +39 -0
- package/src/prototype/components/PrototypeContentView.stories.tsx +19 -0
- package/src/prototype/components/PrototypeContentView.tsx +442 -0
- package/src/prototype/prototype-browser.test.ts +81 -0
- package/src/prototype/prototype-browser.ts +321 -0
- package/src/prototype/prototype-pixels.tsx +35 -0
- package/src/settings/components/SettingsDialog.stories.tsx +117 -0
- package/src/settings/components/SettingsDialog.tsx +183 -0
- package/src/settings/components/SettingsRows.stories.tsx +18 -0
- package/src/settings/components/SettingsRows.tsx +112 -0
- package/src/settings/components/quick-actions-editor.stories.tsx +58 -0
- package/src/settings/components/quick-actions-editor.test.tsx +120 -0
- package/src/settings/components/quick-actions-editor.tsx +222 -0
- package/src/settings/config-identity.test.ts +78 -0
- package/src/settings/config.test.ts +676 -0
- package/src/settings/config.ts +628 -0
- package/src/settings/use-settings-dialog.tsx +351 -0
- package/src/stories/__snapshots__/stories.test.tsx.snap +2358 -0
- package/src/stories/stories-app.tsx +196 -0
- package/src/stories/stories.test.tsx +74 -0
- package/src/stories/story-fixtures.ts +102 -0
- package/src/stories/story-tree.test.ts +108 -0
- package/src/stories/story.ts +154 -0
- package/src/terminal/ansi-screen.test.ts +28 -0
- package/src/terminal/ansi-screen.ts +72 -0
- package/src/terminal/components/AnsiScreen.stories.tsx +12 -0
- package/src/terminal/components/AnsiScreen.tsx +28 -0
- package/src/terminal/components/terminal-pane.test.tsx +40 -0
- package/src/terminal/components/terminal-pane.ts +196 -0
- package/src/terminal/ghostty-terminal.test.ts +85 -0
- package/src/terminal/ghostty-terminal.ts +197 -0
- package/src/terminal/kitty-image.test.ts +66 -0
- package/src/terminal/kitty-image.ts +137 -0
- package/src/terminal/pty.test.ts +81 -0
- package/src/terminal/pty.ts +246 -0
- package/src/testing/test-support.test.ts +264 -0
- package/src/testing/test-support.ts +367 -0
- package/src/thread/agent-continuation.test.ts +32 -0
- package/src/thread/agent-continuation.ts +13 -0
- package/src/thread/agent-transcript.test.ts +161 -0
- package/src/thread/agent-transcript.ts +336 -0
- package/src/thread/components/AgentConfigControls.stories.tsx +39 -0
- package/src/thread/components/AgentConfigControls.test.tsx +112 -0
- package/src/thread/components/AgentConfigControls.tsx +114 -0
- package/src/thread/components/AgentThreadPane.stories.tsx +105 -0
- package/src/thread/components/AgentThreadPane.test.tsx +935 -0
- package/src/thread/components/AgentThreadPane.tsx +377 -0
- package/src/thread/components/ThreadFooter.stories.tsx +30 -0
- package/src/thread/components/ThreadFooter.test.tsx +49 -0
- package/src/thread/components/ThreadFooter.tsx +77 -0
- package/src/thread/components/ThreadTree.stories.tsx +46 -0
- package/src/thread/components/ThreadTree.test.tsx +41 -0
- package/src/thread/components/ThreadTree.tsx +267 -0
- package/src/thread/components/thread-tree.test.ts +137 -0
- package/src/thread/components/thread-tree.ts +112 -0
- package/src/thread/thread-controller.curate.test.ts +502 -0
- package/src/thread/thread-controller.derivation.test.ts +62 -0
- package/src/thread/thread-controller.live-diff.test.ts +357 -0
- package/src/thread/thread-controller.optimistic.test.ts +69 -0
- package/src/thread/thread-controller.share.test.ts +573 -0
- package/src/thread/thread-controller.switch-cache.test.ts +174 -0
- package/src/thread/thread-controller.tree.test.ts +263 -0
- package/src/thread/thread-controller.ts +2026 -0
- package/src/thread/tree-view.test.ts +128 -0
- package/src/thread/tree-view.ts +146 -0
- package/src/thread/use-thread-agent.test.tsx +208 -0
- package/src/thread/use-thread-agent.ts +117 -0
- package/src/ui/components/CodeBlock.stories.tsx +42 -0
- package/src/ui/components/CodeBlock.tsx +80 -0
- package/src/ui/components/ConfirmCard.stories.tsx +37 -0
- package/src/ui/components/ConfirmCard.tsx +151 -0
- package/src/ui/components/ConfirmDialog.stories.tsx +21 -0
- package/src/ui/components/ConfirmDialog.tsx +72 -0
- package/src/ui/components/OverlayScrollbar.stories.tsx +33 -0
- package/src/ui/components/OverlayScrollbar.test.tsx +139 -0
- package/src/ui/components/OverlayScrollbar.tsx +129 -0
- package/src/ui/components/PromptDialog.stories.tsx +36 -0
- package/src/ui/components/PromptDialog.test.tsx +65 -0
- package/src/ui/components/PromptDialog.tsx +99 -0
- package/src/ui/components/RootOverlay.stories.tsx +45 -0
- package/src/ui/components/RootOverlay.tsx +49 -0
- package/src/ui/components/ScrollArea.stories.tsx +20 -0
- package/src/ui/components/ScrollArea.tsx +61 -0
- package/src/ui/components/Toast.stories.tsx +19 -0
- package/src/ui/components/Toast.tsx +54 -0
- package/src/ui/components/Tooltip.stories.tsx +26 -0
- package/src/ui/components/Tooltip.tsx +72 -0
- package/src/ui/components/menu-control.stories.tsx +35 -0
- package/src/ui/components/menu-control.tsx +43 -0
- package/src/ui/components/primitives/Button.stories.tsx +32 -0
- package/src/ui/components/primitives/Button.tsx +57 -0
- package/src/ui/components/primitives/Card.stories.tsx +25 -0
- package/src/ui/components/primitives/Card.tsx +62 -0
- package/src/ui/components/primitives/Dialog.stories.tsx +24 -0
- package/src/ui/components/primitives/Dialog.test.tsx +38 -0
- package/src/ui/components/primitives/Dialog.tsx +69 -0
- package/src/ui/components/primitives/DialogActions.stories.tsx +20 -0
- package/src/ui/components/primitives/DialogActions.tsx +39 -0
- package/src/ui/components/primitives/IconButton.stories.tsx +36 -0
- package/src/ui/components/primitives/IconButton.tsx +58 -0
- package/src/ui/components/primitives/StatusBar.stories.tsx +11 -0
- package/src/ui/components/primitives/StatusBar.tsx +23 -0
- package/src/ui/components/primitives/Switch.stories.tsx +17 -0
- package/src/ui/components/primitives/Switch.tsx +32 -0
- package/src/ui/components/primitives/Tabs.stories.tsx +29 -0
- package/src/ui/components/primitives/Tabs.tsx +101 -0
- package/src/ui/components/primitives/Toolbar.stories.tsx +17 -0
- package/src/ui/components/primitives/Toolbar.tsx +14 -0
- package/src/ui/components/primitives/Tree.stories.tsx +124 -0
- package/src/ui/components/primitives/Tree.tsx +133 -0
- package/src/ui/components/primitives/frame.test.ts +12 -0
- package/src/ui/components/primitives/frame.ts +17 -0
- package/src/ui/components/primitives/icons.ts +61 -0
- package/src/ui/components/primitives/tree-model.test.ts +99 -0
- package/src/ui/components/primitives/tree-model.ts +137 -0
- package/src/ui/components/syntax-highlight.ts +133 -0
- package/src/ui/components/truncate-text.ts +7 -0
- package/src/ui/components/truncate-title.test.ts +31 -0
- package/src/ui/components/truncate-title.ts +15 -0
- package/src/ui/use-frame-measure.ts +74 -0
- package/src/ui/use-terminal-virtualizer.ts +259 -0
- package/src/workbench/components/ChangesColumn.stories.tsx +44 -0
- package/src/workbench/components/ChangesColumn.tsx +203 -0
- package/src/workbench/components/ChangesFileTree.test.tsx +181 -0
- package/src/workbench/components/EditorGrid.split.test.tsx +118 -0
- package/src/workbench/components/EditorGrid.stories.tsx +86 -0
- package/src/workbench/components/EditorGrid.tabs.test.tsx +118 -0
- package/src/workbench/components/EditorGrid.tsx +544 -0
- package/src/workbench/components/EditorGrid.zoom.test.tsx +132 -0
- package/src/workbench/components/FileContentsView.stories.tsx +16 -0
- package/src/workbench/components/FileContentsView.test.tsx +29 -0
- package/src/workbench/components/FileContentsView.tsx +100 -0
- package/src/workbench/components/GridTabContent.actions.test.tsx +151 -0
- package/src/workbench/components/GridTabContent.stories.tsx +59 -0
- package/src/workbench/components/GridTabContent.tsx +193 -0
- package/src/workbench/components/ProjectTreeView.stories.tsx +25 -0
- package/src/workbench/components/ProjectTreeView.test.tsx +236 -0
- package/src/workbench/components/ProjectTreeView.tsx +151 -0
- package/src/workbench/components/TreePane.stories.tsx +106 -0
- package/src/workbench/components/TreePane.tsx +155 -0
- package/src/workbench/components/WelcomePlayground.stories.tsx +12 -0
- package/src/workbench/components/WelcomePlayground.test.tsx +83 -0
- package/src/workbench/components/WelcomePlayground.tsx +138 -0
- package/src/workbench/components/editor-grid.test.ts +184 -0
- package/src/workbench/components/editor-grid.ts +281 -0
- package/src/workbench/components/file-tree.test.ts +60 -0
- package/src/workbench/components/file-tree.ts +112 -0
- package/src/workbench/use-changes-workbench.ts +205 -0
- package/src/workbench/use-remember-layout.ts +31 -0
- package/src/workbench/use-repo-changes.ts +91 -0
- package/src/workbench/workbench-snapshot.test.ts +99 -0
- package/src/workbench/workbench-snapshot.ts +59 -0
- package/src/App.diff.test.tsx +0 -99
- package/src/App.export.test.tsx +0 -88
- package/src/App.plan-review.test.tsx +0 -259
- package/src/App.readonly.test.tsx +0 -148
- package/src/App.rendering.test.tsx +0 -108
- package/src/App.submit-confirm.test.tsx +0 -183
- package/src/App.test.tsx +0 -218
- package/src/App.tsx +0 -1249
- package/src/config.test.ts +0 -116
- package/src/config.ts +0 -150
- package/src/editor.ts +0 -34
- package/src/herdr.test.ts +0 -68
- package/src/herdr.ts +0 -34
- package/src/keymap.test.ts +0 -270
- package/src/keymap.ts +0 -173
- package/src/run.ts +0 -30
- package/src/serve.ts +0 -75
- package/src/session-controller.ts +0 -398
- package/src/syntax.test.ts +0 -41
- package/src/syntax.ts +0 -88
- package/src/theme.ts +0 -46
- package/src/view-diff.test.ts +0 -55
- package/src/view-diff.ts +0 -86
- package/src/view.test.ts +0 -157
- package/src/view.ts +0 -364
package/src/App.tsx
DELETED
|
@@ -1,1249 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The cueloop TUI (#22, #71): header, plan sheet (sheet header + inline
|
|
3
|
-
* compose + selectable lines) or diff pane, right review rail, footer hint
|
|
4
|
-
* bar. Thin renderer over the review-session controller: daemon IO and the
|
|
5
|
-
* mutation verbs live in session-controller.ts, the keyboard grammar in
|
|
6
|
-
* keymap.ts; local state is view state only (cursor, selection, overlays).
|
|
7
|
-
*
|
|
8
|
-
* The plan review grammar: selection is the entry primitive (mouse drag or
|
|
9
|
-
* keyboard span on one native renderer selection), compose happens inline
|
|
10
|
-
* under the anchor, annotation text lives in the rail while the document
|
|
11
|
-
* keeps only the kind-colored highlight, and the rail edits what the
|
|
12
|
-
* document selects - one selected id drives both sides.
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
import React, { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
|
16
|
-
import { useKeyboard, useRenderer } from "@opentui/react";
|
|
17
|
-
import type { ScrollBoxRenderable, TextRenderable } from "@opentui/core";
|
|
18
|
-
import { type Annotation, type ReviewSession, type VerdictKind } from "@cueloop/schema";
|
|
19
|
-
import {
|
|
20
|
-
blockRuns,
|
|
21
|
-
composeBoxHeight,
|
|
22
|
-
displayText,
|
|
23
|
-
marksByDisplay,
|
|
24
|
-
overlayMarks,
|
|
25
|
-
revisionDelta,
|
|
26
|
-
spanKey,
|
|
27
|
-
startSpan,
|
|
28
|
-
wrapRuns,
|
|
29
|
-
type DisplayBlock,
|
|
30
|
-
type Mark,
|
|
31
|
-
type SpanState,
|
|
32
|
-
type StyleRun,
|
|
33
|
-
} from "./view";
|
|
34
|
-
import { type DiffRow } from "./view-diff";
|
|
35
|
-
import { DARK as T } from "./theme";
|
|
36
|
-
import { highlightCode, type CodeToken } from "./syntax";
|
|
37
|
-
import { DEFAULT_KEYS, loadConfig } from "./config";
|
|
38
|
-
import { returnPaneFor } from "./herdr";
|
|
39
|
-
import { createReviewController } from "./session-controller";
|
|
40
|
-
import { reduceKey, type Intent, type KeyState } from "./keymap";
|
|
41
|
-
|
|
42
|
-
export interface AppProps {
|
|
43
|
-
home?: string;
|
|
44
|
-
sessionId?: string;
|
|
45
|
-
/**
|
|
46
|
-
* Observer mode (SSH-served connections): every mutating verb is ignored and
|
|
47
|
-
* answers "observer - read-only" in the status line; navigation still works.
|
|
48
|
-
*/
|
|
49
|
-
readOnly?: boolean;
|
|
50
|
-
onExit?: (code: number) => void;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
type Mode =
|
|
54
|
-
| { m: "normal" }
|
|
55
|
-
| { m: "span"; span: SpanState }
|
|
56
|
-
| { m: "compose"; kind: "comment" | "suggestion"; dispIdx: number; start: number; end: number; text: string }
|
|
57
|
-
| { m: "railEdit"; id: string; text: string }
|
|
58
|
-
| { m: "submit"; verdict: VerdictKind; summary: string };
|
|
59
|
-
|
|
60
|
-
type RailTab = "review" | "agent";
|
|
61
|
-
|
|
62
|
-
const VERDICTS: VerdictKind[] = ["comment", "approve", "request_changes"];
|
|
63
|
-
/** Selector words in the rail confirm card - one word per verdict. */
|
|
64
|
-
const VERDICT_LABEL: Record<VerdictKind, string> = {
|
|
65
|
-
comment: "Comment",
|
|
66
|
-
approve: "Approve",
|
|
67
|
-
request_changes: "Changes",
|
|
68
|
-
};
|
|
69
|
-
|
|
70
|
-
/** One rendered plan line, registered for the native selection primitive. */
|
|
71
|
-
interface PlanLineRef {
|
|
72
|
-
renderable: TextRenderable;
|
|
73
|
-
dispIdx: number;
|
|
74
|
-
lineIndex: number;
|
|
75
|
-
/** Block-text offset of the line's first positioned run; null for pure-del lines. */
|
|
76
|
-
lineStart: number | null;
|
|
77
|
-
/** Screen columns before the run text (cursor glyph + list marker). */
|
|
78
|
-
prefixColumns: number;
|
|
79
|
-
/** Length of the positioned run text in this line. */
|
|
80
|
-
textLength: number;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/** Rail card height (3 text rows + 1 margin), for reveal-scroll math. */
|
|
84
|
-
const RAIL_CARD_HEIGHT = 4;
|
|
85
|
-
|
|
86
|
-
export function App({ home, sessionId, readOnly = false, onExit }: AppProps): React.ReactNode {
|
|
87
|
-
const controller = useMemo(
|
|
88
|
-
() => createReviewController({ home, sessionId, readOnly, onExit }),
|
|
89
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
90
|
-
[home, sessionId],
|
|
91
|
-
);
|
|
92
|
-
useEffect(() => {
|
|
93
|
-
controller.connect();
|
|
94
|
-
return () => controller.close();
|
|
95
|
-
}, [controller]);
|
|
96
|
-
const { session, inbox, status, error, completion, editOrphanCount } = useSyncExternalStore(
|
|
97
|
-
controller.subscribe,
|
|
98
|
-
controller.getSnapshot,
|
|
99
|
-
controller.getSnapshot,
|
|
100
|
-
);
|
|
101
|
-
const renderer = useRenderer();
|
|
102
|
-
|
|
103
|
-
// ── view state ──────────────────────────────
|
|
104
|
-
const [cursor, setCursor] = useState(0);
|
|
105
|
-
const [inboxCursor, setInboxCursor] = useState(0);
|
|
106
|
-
const [mode, setMode] = useState<Mode>({ m: "normal" });
|
|
107
|
-
const [focusedAnn, setFocusedAnn] = useState<string | undefined>(undefined);
|
|
108
|
-
const [railTab, setRailTab] = useState<RailTab>("review");
|
|
109
|
-
// ~2s focus pulse on the document highlight when a rail card is activated
|
|
110
|
-
const [pulseAnn, setPulseAnn] = useState<string | null>(null);
|
|
111
|
-
const pulseTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
112
|
-
// live mirror of overlay input text: refs commit synchronously, so the
|
|
113
|
-
// RETURN handler never reads a stale value mid-typing
|
|
114
|
-
const liveInput = useRef("");
|
|
115
|
-
// rendered plan lines, for driving and reading the native selection
|
|
116
|
-
const lineRefs = useRef(new Map<string, PlanLineRef>());
|
|
117
|
-
const docScrollRef = useRef<ScrollBoxRenderable | null>(null);
|
|
118
|
-
const railScrollRef = useRef<ScrollBoxRenderable | null>(null);
|
|
119
|
-
// keymap from layered config; theme overrides land on the shared tokens
|
|
120
|
-
const keysRef = useRef(DEFAULT_KEYS);
|
|
121
|
-
useEffect(() => {
|
|
122
|
-
const cfg = loadConfig({ repoRoot: session?.workspace.repoRoot });
|
|
123
|
-
keysRef.current = cfg.keys;
|
|
124
|
-
Object.assign(T, cfg.theme);
|
|
125
|
-
controller.applyConfig(cfg);
|
|
126
|
-
}, [session?.workspace.repoRoot, controller]);
|
|
127
|
-
useEffect(
|
|
128
|
-
() => () => {
|
|
129
|
-
if (pulseTimer.current) clearTimeout(pulseTimer.current);
|
|
130
|
-
},
|
|
131
|
-
[],
|
|
132
|
-
);
|
|
133
|
-
|
|
134
|
-
// ── derived view model ──────────────────────
|
|
135
|
-
const display = controller.display();
|
|
136
|
-
const rows = controller.rows();
|
|
137
|
-
const marks = useMemo(
|
|
138
|
-
() => (session ? marksByDisplay(session.annotations, display, pulseAnn ?? undefined) : new Map<number, Mark[]>()),
|
|
139
|
-
[session, display, pulseAnn],
|
|
140
|
-
);
|
|
141
|
-
/** Annotation ids whose anchor resolved against the working copy. */
|
|
142
|
-
const resolvedIds = useMemo(() => {
|
|
143
|
-
const ids = new Set<string>();
|
|
144
|
-
for (const blockMarks of marks.values()) {
|
|
145
|
-
for (const mark of blockMarks) if (mark.annotationId) ids.add(mark.annotationId);
|
|
146
|
-
}
|
|
147
|
-
return ids;
|
|
148
|
-
}, [marks]);
|
|
149
|
-
const resolved = session?.status === "resolved";
|
|
150
|
-
const isDiff = session?.artifact.type === "diff";
|
|
151
|
-
|
|
152
|
-
// ── the native selection: keyboard driver ───
|
|
153
|
-
const lineRefFor = (dispIdx: number, offset: number): PlanLineRef | null => {
|
|
154
|
-
for (const meta of lineRefs.current.values()) {
|
|
155
|
-
if (meta.dispIdx !== dispIdx || meta.lineStart === null) continue;
|
|
156
|
-
if (offset >= meta.lineStart && offset < meta.lineStart + meta.textLength) return meta;
|
|
157
|
-
}
|
|
158
|
-
return null;
|
|
159
|
-
};
|
|
160
|
-
|
|
161
|
-
/** Anchor/extend the renderer's native selection from keyboard span offsets. */
|
|
162
|
-
const driveNativeSelection = (span: SpanState): void => {
|
|
163
|
-
if (!renderer) return;
|
|
164
|
-
const startMeta = lineRefFor(span.dispIdx, span.start);
|
|
165
|
-
const endMeta = lineRefFor(span.dispIdx, span.end - 1);
|
|
166
|
-
if (!startMeta || !endMeta) return;
|
|
167
|
-
const startColumn = startMeta.prefixColumns + (span.start - startMeta.lineStart!);
|
|
168
|
-
const endColumn = endMeta.prefixColumns + (span.end - 1 - endMeta.lineStart!);
|
|
169
|
-
renderer.startSelection(startMeta.renderable, startMeta.renderable.x + startColumn, startMeta.renderable.y);
|
|
170
|
-
renderer.updateSelection(endMeta.renderable, endMeta.renderable.x + endColumn + 1, endMeta.renderable.y);
|
|
171
|
-
};
|
|
172
|
-
// driving needs committed layout, so it runs after render; any transition
|
|
173
|
-
// out of span mode clears the renderer selection (compose paints its own
|
|
174
|
-
// mark, and a mouse drag never changes the mode, so it survives)
|
|
175
|
-
useEffect(() => {
|
|
176
|
-
if (mode.m === "span") driveNativeSelection(mode.span);
|
|
177
|
-
else renderer?.clearSelection();
|
|
178
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
179
|
-
}, [mode]);
|
|
180
|
-
|
|
181
|
-
/**
|
|
182
|
-
* Read the native selection (mouse drag) back as a block range. The quote
|
|
183
|
-
* anchors in the FIRST selected block - quote anchors resolve within one
|
|
184
|
-
* block. Columns map 1:1 onto text offsets except inside word-diffed mod
|
|
185
|
-
* blocks, where inline del runs shift the screen columns.
|
|
186
|
-
*/
|
|
187
|
-
const readNativeSelection = (): { dispIdx: number; start: number; end: number } | null => {
|
|
188
|
-
if (!renderer?.hasSelection) return null;
|
|
189
|
-
const ordered = [...lineRefs.current.values()].sort(
|
|
190
|
-
(a, b) => a.dispIdx - b.dispIdx || a.lineIndex - b.lineIndex,
|
|
191
|
-
);
|
|
192
|
-
let found: { dispIdx: number; start: number; end: number } | null = null;
|
|
193
|
-
for (const meta of ordered) {
|
|
194
|
-
if (meta.lineStart === null) continue;
|
|
195
|
-
const selection = meta.renderable.getSelection();
|
|
196
|
-
if (!selection || selection.end <= selection.start) continue;
|
|
197
|
-
const startInText = Math.min(meta.textLength, Math.max(0, selection.start - meta.prefixColumns));
|
|
198
|
-
const endInText = Math.min(meta.textLength, Math.max(0, selection.end - meta.prefixColumns));
|
|
199
|
-
if (endInText <= startInText) continue;
|
|
200
|
-
const blockStart = meta.lineStart + startInText;
|
|
201
|
-
const blockEnd = meta.lineStart + endInText;
|
|
202
|
-
if (!found) found = { dispIdx: meta.dispIdx, start: blockStart, end: blockEnd };
|
|
203
|
-
else if (found.dispIdx === meta.dispIdx) found.end = Math.max(found.end, blockEnd);
|
|
204
|
-
}
|
|
205
|
-
return found;
|
|
206
|
-
};
|
|
207
|
-
|
|
208
|
-
// ── selection symmetry: one selected id, both sides ──
|
|
209
|
-
const pulse = (id: string): void => {
|
|
210
|
-
setPulseAnn(id);
|
|
211
|
-
if (pulseTimer.current) clearTimeout(pulseTimer.current);
|
|
212
|
-
pulseTimer.current = setTimeout(() => setPulseAnn(null), 2000);
|
|
213
|
-
};
|
|
214
|
-
|
|
215
|
-
const revealRailCard = (annotationId: string): void => {
|
|
216
|
-
const index = session?.annotations.findIndex((annotation) => annotation.id === annotationId) ?? -1;
|
|
217
|
-
if (index >= 0) railScrollRef.current?.scrollTo(Math.max(0, index * RAIL_CARD_HEIGHT - 1));
|
|
218
|
-
};
|
|
219
|
-
|
|
220
|
-
/** Card activation scrolls the document to the anchor and pulses it. */
|
|
221
|
-
const revealAnchor = (annotationId: string): void => {
|
|
222
|
-
for (const [dispIdx, blockMarks] of marks) {
|
|
223
|
-
if (!blockMarks.some((mark) => mark.annotationId === annotationId)) continue;
|
|
224
|
-
setCursor(dispIdx);
|
|
225
|
-
try {
|
|
226
|
-
docScrollRef.current?.scrollChildIntoView(`plan-block-${dispIdx}`);
|
|
227
|
-
} catch {
|
|
228
|
-
// reveal is best-effort; selection state is already correct
|
|
229
|
-
}
|
|
230
|
-
return;
|
|
231
|
-
}
|
|
232
|
-
};
|
|
233
|
-
|
|
234
|
-
const selectCardFromDocument = (annotationId: string): void => {
|
|
235
|
-
setFocusedAnn(annotationId);
|
|
236
|
-
revealRailCard(annotationId);
|
|
237
|
-
};
|
|
238
|
-
|
|
239
|
-
const selectCardFromRail = (annotationId: string): void => {
|
|
240
|
-
setFocusedAnn(annotationId);
|
|
241
|
-
pulse(annotationId);
|
|
242
|
-
revealAnchor(annotationId);
|
|
243
|
-
};
|
|
244
|
-
|
|
245
|
-
const openCardEdit = (annotationId: string): void => {
|
|
246
|
-
if (readOnly) return controller.setStatus("observer - read-only");
|
|
247
|
-
if (resolved) return controller.setStatus("review submitted - read-only");
|
|
248
|
-
const annotation = session?.annotations.find((candidate) => candidate.id === annotationId);
|
|
249
|
-
if (!annotation) return;
|
|
250
|
-
liveInput.current = annotation.body;
|
|
251
|
-
setMode({ m: "railEdit", id: annotation.id, text: annotation.body });
|
|
252
|
-
};
|
|
253
|
-
|
|
254
|
-
// ── keyboard grammar: build state, reduce, dispatch ──
|
|
255
|
-
const dispatch = (intent: Intent): void => {
|
|
256
|
-
switch (intent.t) {
|
|
257
|
-
case "exit":
|
|
258
|
-
return void onExit?.(0);
|
|
259
|
-
case "status":
|
|
260
|
-
return controller.setStatus(intent.msg);
|
|
261
|
-
case "move": {
|
|
262
|
-
const len = isDiff ? rows.length : display.length;
|
|
263
|
-
if (intent.to === "down") setCursor((c) => Math.min(len - 1, c + 1));
|
|
264
|
-
else if (intent.to === "up") setCursor((c) => Math.max(0, c - 1));
|
|
265
|
-
else if (intent.to === "top") setCursor(0);
|
|
266
|
-
else setCursor(len - 1);
|
|
267
|
-
return;
|
|
268
|
-
}
|
|
269
|
-
case "inboxMove": {
|
|
270
|
-
const len = inbox?.length ?? 0;
|
|
271
|
-
setInboxCursor((c) => (intent.to === "down" ? Math.min(len - 1, c + 1) : Math.max(0, c - 1)));
|
|
272
|
-
return;
|
|
273
|
-
}
|
|
274
|
-
case "openSession": {
|
|
275
|
-
const s = inbox?.[inboxCursor];
|
|
276
|
-
if (s) controller.open(s.id);
|
|
277
|
-
return;
|
|
278
|
-
}
|
|
279
|
-
case "startSpan": {
|
|
280
|
-
const d = display[cursor];
|
|
281
|
-
if (!d?.work) return;
|
|
282
|
-
const span = startSpan(cursor, displayText(d));
|
|
283
|
-
if (span) setMode({ m: "span", span });
|
|
284
|
-
return;
|
|
285
|
-
}
|
|
286
|
-
case "spanKey":
|
|
287
|
-
if (mode.m === "span") {
|
|
288
|
-
const span = spanKey(mode.span, intent.name, displayText(display[mode.span.dispIdx]!));
|
|
289
|
-
setMode({ m: "span", span });
|
|
290
|
-
driveNativeSelection(span);
|
|
291
|
-
}
|
|
292
|
-
return;
|
|
293
|
-
case "openCompose": {
|
|
294
|
-
liveInput.current = "";
|
|
295
|
-
if (intent.from === "span" && mode.m === "span") {
|
|
296
|
-
setMode({
|
|
297
|
-
m: "compose",
|
|
298
|
-
kind: intent.kind,
|
|
299
|
-
dispIdx: mode.span.dispIdx,
|
|
300
|
-
start: mode.span.start,
|
|
301
|
-
end: mode.span.end,
|
|
302
|
-
text: "",
|
|
303
|
-
});
|
|
304
|
-
} else if (isDiff) {
|
|
305
|
-
const row = rows[cursor];
|
|
306
|
-
if (row) setMode({ m: "compose", kind: intent.kind, dispIdx: cursor, start: 0, end: row.text.length, text: "" });
|
|
307
|
-
} else {
|
|
308
|
-
// a mouse drag leaves a native selection; it wins over the cursor block
|
|
309
|
-
const native = readNativeSelection();
|
|
310
|
-
if (native) {
|
|
311
|
-
setMode({ m: "compose", kind: intent.kind, ...native, text: "" });
|
|
312
|
-
} else {
|
|
313
|
-
const d = display[cursor];
|
|
314
|
-
if (d) setMode({ m: "compose", kind: intent.kind, dispIdx: cursor, start: 0, end: displayText(d).length, text: "" });
|
|
315
|
-
}
|
|
316
|
-
}
|
|
317
|
-
return;
|
|
318
|
-
}
|
|
319
|
-
case "openSubmit":
|
|
320
|
-
if (!session) return;
|
|
321
|
-
liveInput.current = "";
|
|
322
|
-
// the confirm card lives in the review tab; opening submit reveals it
|
|
323
|
-
setRailTab("review");
|
|
324
|
-
return void setMode({ m: "submit", verdict: defaultVerdict(session), summary: "" });
|
|
325
|
-
case "cut":
|
|
326
|
-
return controller.cut(cursor);
|
|
327
|
-
case "edit":
|
|
328
|
-
return controller.edit();
|
|
329
|
-
case "editCard":
|
|
330
|
-
if (focusedAnn) openCardEdit(focusedAnn);
|
|
331
|
-
return;
|
|
332
|
-
case "nextAnn":
|
|
333
|
-
case "prevAnn": {
|
|
334
|
-
const anns = session?.annotations ?? [];
|
|
335
|
-
if (!anns.length) return;
|
|
336
|
-
const idx = anns.findIndex((a) => a.id === focusedAnn);
|
|
337
|
-
const next = idx === -1 ? 0 : (idx + (intent.t === "nextAnn" ? 1 : -1) + anns.length) % anns.length;
|
|
338
|
-
return void selectCardFromDocument(anns[next]!.id);
|
|
339
|
-
}
|
|
340
|
-
case "removeAnnotation":
|
|
341
|
-
if (focusedAnn) {
|
|
342
|
-
controller.removeAnnotation(focusedAnn);
|
|
343
|
-
setFocusedAnn(undefined);
|
|
344
|
-
}
|
|
345
|
-
return;
|
|
346
|
-
case "deselect":
|
|
347
|
-
renderer?.clearSelection();
|
|
348
|
-
setFocusedAnn(undefined);
|
|
349
|
-
setPulseAnn(null);
|
|
350
|
-
return;
|
|
351
|
-
case "closeOverlay":
|
|
352
|
-
return void setMode({ m: "normal" });
|
|
353
|
-
case "saveCompose": {
|
|
354
|
-
const body = liveInput.current.trim();
|
|
355
|
-
if (mode.m === "railEdit") {
|
|
356
|
-
if (session && body) controller.updateAnnotation(mode.id, body);
|
|
357
|
-
return void setMode({ m: "normal" });
|
|
358
|
-
}
|
|
359
|
-
if (mode.m !== "compose") return;
|
|
360
|
-
if (session && body) {
|
|
361
|
-
const annotationId = controller.annotate(mode.kind, mode.dispIdx, mode.start, mode.end, body);
|
|
362
|
-
if (annotationId) setFocusedAnn(annotationId);
|
|
363
|
-
}
|
|
364
|
-
return void setMode({ m: "normal" });
|
|
365
|
-
}
|
|
366
|
-
case "submitVerdict":
|
|
367
|
-
if (mode.m === "submit") controller.submit(mode.verdict, liveInput.current);
|
|
368
|
-
return void setMode({ m: "normal" });
|
|
369
|
-
case "cycleVerdict": {
|
|
370
|
-
if (mode.m !== "submit") return;
|
|
371
|
-
const idx = (VERDICTS.indexOf(mode.verdict) + intent.dir + VERDICTS.length) % VERDICTS.length;
|
|
372
|
-
return void setMode({ ...mode, verdict: VERDICTS[idx]! });
|
|
373
|
-
}
|
|
374
|
-
case "finishReview":
|
|
375
|
-
return controller.finishReview();
|
|
376
|
-
case "optInAutoClose":
|
|
377
|
-
return controller.optInAutoClose();
|
|
378
|
-
case "dismissCompletion":
|
|
379
|
-
return controller.dismissCompletion();
|
|
380
|
-
}
|
|
381
|
-
};
|
|
382
|
-
|
|
383
|
-
useKeyboard((key) => {
|
|
384
|
-
const state: KeyState = {
|
|
385
|
-
keys: keysRef.current,
|
|
386
|
-
readOnly,
|
|
387
|
-
overlay:
|
|
388
|
-
mode.m === "compose" || mode.m === "railEdit"
|
|
389
|
-
? "compose"
|
|
390
|
-
: mode.m === "submit"
|
|
391
|
-
? "submit"
|
|
392
|
-
: completion.phase === "prompt"
|
|
393
|
-
? "completion-prompt"
|
|
394
|
-
: completion.phase === "counting"
|
|
395
|
-
? "completion-counting"
|
|
396
|
-
: "none",
|
|
397
|
-
view: !session ? "inbox" : isDiff ? "diff" : "plan",
|
|
398
|
-
spanMode: mode.m === "span",
|
|
399
|
-
resolved: !!resolved,
|
|
400
|
-
hasInboxItems: !!inbox?.length,
|
|
401
|
-
annotationCount: session?.annotations.length ?? 0,
|
|
402
|
-
hasFocusedAnnotation: focusedAnn !== undefined,
|
|
403
|
-
cursorAnnotatable: isDiff
|
|
404
|
-
? rows[cursor] !== undefined && rows[cursor]!.t !== "file" && rows[cursor]!.t !== "hunk"
|
|
405
|
-
: !!display[cursor]?.work,
|
|
406
|
-
};
|
|
407
|
-
for (const intent of reduceKey(state, { name: key.name, shift: !!key.shift })) dispatch(intent);
|
|
408
|
-
});
|
|
409
|
-
|
|
410
|
-
// ── render ──────────────────────────────────
|
|
411
|
-
if (error) return <text fg={T.red}>cueloop: {error}</text>;
|
|
412
|
-
if (!session && !inbox) return <text fg={T.textDim}>connecting to the daemon…</text>;
|
|
413
|
-
if (!session && inbox) return <Inbox inbox={inbox} cursor={inboxCursor} />;
|
|
414
|
-
|
|
415
|
-
const s = session!;
|
|
416
|
-
const pendingCount = s.annotations.length + (s.workingCopy !== undefined ? 1 : 0);
|
|
417
|
-
|
|
418
|
-
if ((completion.phase === "prompt" || completion.phase === "counting") && s.verdict) {
|
|
419
|
-
return (
|
|
420
|
-
<CompletionOverlay
|
|
421
|
-
verdict={s.verdict.kind}
|
|
422
|
-
completion={completion}
|
|
423
|
-
status={status}
|
|
424
|
-
returnsTo={returnPaneFor(s.artifact.meta.herdrPane) ? (s.artifact.meta.agent ?? "the agent") : undefined}
|
|
425
|
-
/>
|
|
426
|
-
);
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
const composeState =
|
|
430
|
-
mode.m === "compose" && !isDiff
|
|
431
|
-
? {
|
|
432
|
-
kind: mode.kind,
|
|
433
|
-
dispIdx: mode.dispIdx,
|
|
434
|
-
quote: displayText(display[mode.dispIdx]!).slice(mode.start, mode.end),
|
|
435
|
-
text: mode.text,
|
|
436
|
-
onInput: (text: string) => {
|
|
437
|
-
liveInput.current = text;
|
|
438
|
-
setMode({ ...mode, text });
|
|
439
|
-
},
|
|
440
|
-
onSave: () => dispatch({ t: "saveCompose" }),
|
|
441
|
-
onCancel: () => dispatch({ t: "closeOverlay" }),
|
|
442
|
-
}
|
|
443
|
-
: null;
|
|
444
|
-
|
|
445
|
-
const registerLine = (key: string, renderable: TextRenderable | null, meta: Omit<PlanLineRef, "renderable">): void => {
|
|
446
|
-
if (renderable) lineRefs.current.set(key, { renderable, ...meta });
|
|
447
|
-
else lineRefs.current.delete(key);
|
|
448
|
-
};
|
|
449
|
-
|
|
450
|
-
const onLineActivate = (dispIdx: number): void => {
|
|
451
|
-
// releasing a drag-selection lands here too; a live selection is not a click
|
|
452
|
-
if (renderer?.hasSelection) return;
|
|
453
|
-
setCursor(dispIdx);
|
|
454
|
-
const annotationId = marks.get(dispIdx)?.[0]?.annotationId;
|
|
455
|
-
if (annotationId) selectCardFromDocument(annotationId);
|
|
456
|
-
};
|
|
457
|
-
|
|
458
|
-
const onEditRequest = (): void => {
|
|
459
|
-
if (readOnly) return controller.setStatus("observer - read-only");
|
|
460
|
-
if (resolved) return controller.setStatus("review submitted - read-only");
|
|
461
|
-
controller.edit();
|
|
462
|
-
};
|
|
463
|
-
|
|
464
|
-
// clicking the rail Submit button: same read-only answer as the submit key
|
|
465
|
-
const onSubmitRequest = (): void => {
|
|
466
|
-
if (readOnly) return controller.setStatus("observer - read-only");
|
|
467
|
-
if (resolved) return;
|
|
468
|
-
dispatch({ t: "openSubmit" });
|
|
469
|
-
};
|
|
470
|
-
|
|
471
|
-
const submitConfirmState: SubmitConfirmState | null =
|
|
472
|
-
mode.m === "submit"
|
|
473
|
-
? {
|
|
474
|
-
verdict: mode.verdict,
|
|
475
|
-
summary: mode.summary,
|
|
476
|
-
annotationCount: s.annotations.length,
|
|
477
|
-
blockingCount: s.annotations.filter(annotationBlocking).length,
|
|
478
|
-
onInput: (summary: string) => {
|
|
479
|
-
liveInput.current = summary;
|
|
480
|
-
setMode({ ...mode, summary });
|
|
481
|
-
},
|
|
482
|
-
onSelectVerdict: (verdict: VerdictKind) => setMode({ ...mode, verdict }),
|
|
483
|
-
onSubmit: () => dispatch({ t: "submitVerdict" }),
|
|
484
|
-
onCancel: () => dispatch({ t: "closeOverlay" }),
|
|
485
|
-
}
|
|
486
|
-
: null;
|
|
487
|
-
|
|
488
|
-
return (
|
|
489
|
-
<box style={{ flexDirection: "column", width: "100%", height: "100%", backgroundColor: T.bg }}>
|
|
490
|
-
<box style={{ height: 1, backgroundColor: T.panel, paddingLeft: 1, flexDirection: "row" }}>
|
|
491
|
-
<text fg={T.text}>
|
|
492
|
-
<span fg={T.accent}>cueloop</span>
|
|
493
|
-
<span fg={T.textDim}> · {s.artifact.meta.title ?? s.artifact.meta.planPath ?? s.id} · rev {s.revisions.length}</span>
|
|
494
|
-
{resolved ? <span fg={T.green}> · resolved: {s.verdict!.kind.replace("_", " ")}</span> : null}
|
|
495
|
-
{readOnly ? <span fg={T.textDim}> · observer</span> : null}
|
|
496
|
-
{status ? <span fg={T.accent}> · {status}</span> : null}
|
|
497
|
-
</text>
|
|
498
|
-
</box>
|
|
499
|
-
<box style={{ flexGrow: 1, flexDirection: "row" }}>
|
|
500
|
-
{isDiff ? (
|
|
501
|
-
<DiffPane rows={rows} cursor={cursor} annotations={s.annotations} focusedAnn={focusedAnn} />
|
|
502
|
-
) : (
|
|
503
|
-
<PlanPane
|
|
504
|
-
display={display}
|
|
505
|
-
marks={marks}
|
|
506
|
-
cursor={cursor}
|
|
507
|
-
mode={mode}
|
|
508
|
-
session={s}
|
|
509
|
-
editOrphanCount={editOrphanCount}
|
|
510
|
-
compose={composeState}
|
|
511
|
-
registerLine={registerLine}
|
|
512
|
-
onLineActivate={onLineActivate}
|
|
513
|
-
onEditRequest={onEditRequest}
|
|
514
|
-
docScrollRef={docScrollRef}
|
|
515
|
-
/>
|
|
516
|
-
)}
|
|
517
|
-
<Rail
|
|
518
|
-
session={s}
|
|
519
|
-
selectedId={focusedAnn}
|
|
520
|
-
resolvedIds={isDiff ? null : resolvedIds}
|
|
521
|
-
railTab={railTab}
|
|
522
|
-
pendingCount={pendingCount}
|
|
523
|
-
cardEdit={
|
|
524
|
-
mode.m === "railEdit"
|
|
525
|
-
? {
|
|
526
|
-
id: mode.id,
|
|
527
|
-
text: mode.text,
|
|
528
|
-
onInput: (text: string) => {
|
|
529
|
-
liveInput.current = text;
|
|
530
|
-
setMode({ m: "railEdit", id: mode.id, text });
|
|
531
|
-
},
|
|
532
|
-
onSave: () => dispatch({ t: "saveCompose" }),
|
|
533
|
-
onCancel: () => dispatch({ t: "closeOverlay" }),
|
|
534
|
-
}
|
|
535
|
-
: null
|
|
536
|
-
}
|
|
537
|
-
submitConfirm={submitConfirmState}
|
|
538
|
-
onTab={setRailTab}
|
|
539
|
-
onSelectCard={selectCardFromRail}
|
|
540
|
-
onActivateCard={openCardEdit}
|
|
541
|
-
onSubmitRequest={onSubmitRequest}
|
|
542
|
-
railScrollRef={railScrollRef}
|
|
543
|
-
/>
|
|
544
|
-
</box>
|
|
545
|
-
{mode.m === "compose" && isDiff ? (
|
|
546
|
-
<ComposeBar mode={mode} quote={rows[mode.dispIdx]?.text ?? ""} onChange={(text) => { liveInput.current = text; setMode({ ...mode, text }); }} />
|
|
547
|
-
) : (
|
|
548
|
-
<box style={{ height: 1, backgroundColor: T.panel, paddingLeft: 1 }}>
|
|
549
|
-
<text fg={T.textDim}>
|
|
550
|
-
{readOnly
|
|
551
|
-
? "observer - read-only · j/k move · n/p annotations · q quit"
|
|
552
|
-
: mode.m === "submit"
|
|
553
|
-
? "verdict ←/→ · ⏎ submit · esc cancel"
|
|
554
|
-
: mode.m === "span"
|
|
555
|
-
? "span · l/h grow/shrink · w/b slide · $ end · c comment · s suggest · esc"
|
|
556
|
-
: mode.m === "compose" || mode.m === "railEdit"
|
|
557
|
-
? "typing · ⏎ save · esc cancel"
|
|
558
|
-
: focusedAnn !== undefined
|
|
559
|
-
? "card · e edit · x Cut · n/p cards · esc deselect · ⏎ submit"
|
|
560
|
-
: "j/k move · v span · drag selects · c comment · s suggest · x cut · e edit · n/p annotations · ⏎ submit · q quit"}
|
|
561
|
-
</text>
|
|
562
|
-
</box>
|
|
563
|
-
)}
|
|
564
|
-
</box>
|
|
565
|
-
);
|
|
566
|
-
}
|
|
567
|
-
|
|
568
|
-
// ── panes ─────────────────────────────────────
|
|
569
|
-
|
|
570
|
-
interface ComposeState {
|
|
571
|
-
kind: "comment" | "suggestion";
|
|
572
|
-
dispIdx: number;
|
|
573
|
-
quote: string;
|
|
574
|
-
text: string;
|
|
575
|
-
onInput: (text: string) => void;
|
|
576
|
-
onSave: () => void;
|
|
577
|
-
onCancel: () => void;
|
|
578
|
-
}
|
|
579
|
-
|
|
580
|
-
function PlanPane({
|
|
581
|
-
display,
|
|
582
|
-
marks,
|
|
583
|
-
cursor,
|
|
584
|
-
mode,
|
|
585
|
-
session,
|
|
586
|
-
editOrphanCount,
|
|
587
|
-
compose,
|
|
588
|
-
registerLine,
|
|
589
|
-
onLineActivate,
|
|
590
|
-
onEditRequest,
|
|
591
|
-
docScrollRef,
|
|
592
|
-
}: {
|
|
593
|
-
display: DisplayBlock[];
|
|
594
|
-
marks: Map<number, Mark[]>;
|
|
595
|
-
cursor: number;
|
|
596
|
-
mode: Mode;
|
|
597
|
-
session: ReviewSession;
|
|
598
|
-
editOrphanCount: number;
|
|
599
|
-
compose: ComposeState | null;
|
|
600
|
-
registerLine: (key: string, renderable: TextRenderable | null, meta: Omit<PlanLineRef, "renderable">) => void;
|
|
601
|
-
onLineActivate: (dispIdx: number) => void;
|
|
602
|
-
onEditRequest: () => void;
|
|
603
|
-
docScrollRef: React.RefObject<ScrollBoxRenderable | null>;
|
|
604
|
-
}): React.ReactNode {
|
|
605
|
-
const width = 76;
|
|
606
|
-
const children: React.ReactNode[] = [];
|
|
607
|
-
for (let i = 0; i < display.length; i++) {
|
|
608
|
-
const d = display[i]!;
|
|
609
|
-
const isCursor = i === cursor;
|
|
610
|
-
const gap = topGap(display[i - 1], d);
|
|
611
|
-
if (d.kind === "code") {
|
|
612
|
-
children.push(
|
|
613
|
-
<CodeBlock key={i} id={`plan-block-${i}`} block={d} isCursor={isCursor} gap={gap} annotated={(marks.get(i) ?? []).length > 0} />,
|
|
614
|
-
);
|
|
615
|
-
} else {
|
|
616
|
-
const blockMarks = [...(marks.get(i) ?? [])];
|
|
617
|
-
if (mode.m === "span" && mode.span.dispIdx === i) {
|
|
618
|
-
blockMarks.push({ start: mode.span.start, end: mode.span.end, role: "kspan" });
|
|
619
|
-
}
|
|
620
|
-
// the compose anchor stays painted selection-style while the box is open
|
|
621
|
-
if (mode.m === "compose" && mode.dispIdx === i) {
|
|
622
|
-
blockMarks.push({ start: mode.start, end: mode.end, role: "kspan" });
|
|
623
|
-
}
|
|
624
|
-
const runs = overlayMarks(blockRuns(d, true), blockMarks);
|
|
625
|
-
const lines = wrapRuns(runs, width - marker(d).length);
|
|
626
|
-
const prefixColumns = 2 + marker(d).length;
|
|
627
|
-
children.push(
|
|
628
|
-
<box key={i} id={`plan-block-${i}`} style={{ flexDirection: "column", marginTop: gap }}>
|
|
629
|
-
{lines.map((line, li) => {
|
|
630
|
-
const positioned = line.filter((run) => run.start !== null);
|
|
631
|
-
const lineStart = positioned.length ? positioned[0]!.start : null;
|
|
632
|
-
const lastPositioned = positioned[positioned.length - 1];
|
|
633
|
-
const textLength =
|
|
634
|
-
lineStart !== null && lastPositioned ? lastPositioned.start! + lastPositioned.text.length - lineStart : 0;
|
|
635
|
-
return (
|
|
636
|
-
<text
|
|
637
|
-
key={li}
|
|
638
|
-
bg={isCursor ? T.cursorBg : undefined}
|
|
639
|
-
selectable
|
|
640
|
-
selectionBg={T.accent}
|
|
641
|
-
selectionFg={T.accentInk}
|
|
642
|
-
ref={(renderable: TextRenderable | null) =>
|
|
643
|
-
registerLine(`${i}:${li}`, renderable, { dispIdx: i, lineIndex: li, lineStart, prefixColumns, textLength })
|
|
644
|
-
}
|
|
645
|
-
onMouseUp={() => onLineActivate(i)}
|
|
646
|
-
>
|
|
647
|
-
<span fg={isCursor ? T.accent : T.textDim}>{li === 0 ? cursorGlyph(isCursor) : " "}</span>
|
|
648
|
-
<span fg={T.textDim}>{li === 0 ? marker(d) : " ".repeat(marker(d).length)}</span>
|
|
649
|
-
{line.map((r, ri) => (
|
|
650
|
-
<span key={ri} {...runStyle(r, d)}>
|
|
651
|
-
{r.text}
|
|
652
|
-
</span>
|
|
653
|
-
))}
|
|
654
|
-
{li === 0 && d.type !== "same" ? <span fg={tagColor(d)}> [{tagLabel(d)}]</span> : null}
|
|
655
|
-
</text>
|
|
656
|
-
);
|
|
657
|
-
})}
|
|
658
|
-
</box>,
|
|
659
|
-
);
|
|
660
|
-
}
|
|
661
|
-
if (compose && compose.dispIdx === i) {
|
|
662
|
-
children.push(
|
|
663
|
-
<AnnotationCard
|
|
664
|
-
key={`compose-${i}`}
|
|
665
|
-
kind={compose.kind}
|
|
666
|
-
quote={compose.quote}
|
|
667
|
-
draft={{ text: compose.text, onInput: compose.onInput, onSave: compose.onSave, onCancel: compose.onCancel }}
|
|
668
|
-
/>,
|
|
669
|
-
);
|
|
670
|
-
}
|
|
671
|
-
}
|
|
672
|
-
return (
|
|
673
|
-
<box style={{ flexGrow: 1, flexDirection: "column" }}>
|
|
674
|
-
<SheetHeader session={session} onEditRequest={onEditRequest} />
|
|
675
|
-
{editOrphanCount > 0 ? (
|
|
676
|
-
<box style={{ height: 1, backgroundColor: T.markCommentBg, paddingLeft: 2 }}>
|
|
677
|
-
<text fg={T.red}>
|
|
678
|
-
{editOrphanCount} annotation{editOrphanCount === 1 ? "" : "s"} no longer match - the passage was removed.
|
|
679
|
-
</text>
|
|
680
|
-
</box>
|
|
681
|
-
) : null}
|
|
682
|
-
<scrollbox ref={docScrollRef} style={{ flexGrow: 1, paddingLeft: 2, paddingTop: 1 }} focused={false}>
|
|
683
|
-
{children}
|
|
684
|
-
</scrollbox>
|
|
685
|
-
</box>
|
|
686
|
-
);
|
|
687
|
-
}
|
|
688
|
-
|
|
689
|
-
/** Sheet chrome: submitted-by + revision delta left, the Edit word-button right. */
|
|
690
|
-
function SheetHeader({ session, onEditRequest }: { session: ReviewSession; onEditRequest: () => void }): React.ReactNode {
|
|
691
|
-
const revisionCount = session.revisions.length;
|
|
692
|
-
const previous = revisionCount > 1 ? session.revisions[revisionCount - 2] : undefined;
|
|
693
|
-
const delta = previous ? revisionDelta(previous.content, session.artifact.content) : null;
|
|
694
|
-
return (
|
|
695
|
-
<box style={{ height: 1, flexDirection: "row", paddingLeft: 2, paddingRight: 1 }}>
|
|
696
|
-
<text fg={T.textDim}>
|
|
697
|
-
submitted by <span fg={T.textMuted}>{session.artifact.meta.agent ?? "unknown"}</span> · revision {revisionCount}
|
|
698
|
-
{delta ? (
|
|
699
|
-
<span fg={T.green}>
|
|
700
|
-
{" "}· v{revisionCount - 1}→v{revisionCount} +{delta.added} -{delta.removed}
|
|
701
|
-
</span>
|
|
702
|
-
) : null}
|
|
703
|
-
</text>
|
|
704
|
-
<box style={{ flexGrow: 1 }} />
|
|
705
|
-
<box onMouseUp={onEditRequest}>
|
|
706
|
-
<text fg={T.textDim}> Edit </text>
|
|
707
|
-
</box>
|
|
708
|
-
</box>
|
|
709
|
-
);
|
|
710
|
-
}
|
|
711
|
-
|
|
712
|
-
function CodeBlock({
|
|
713
|
-
id,
|
|
714
|
-
block,
|
|
715
|
-
isCursor,
|
|
716
|
-
gap,
|
|
717
|
-
annotated,
|
|
718
|
-
}: {
|
|
719
|
-
id: string;
|
|
720
|
-
block: DisplayBlock;
|
|
721
|
-
isCursor: boolean;
|
|
722
|
-
gap: number;
|
|
723
|
-
annotated: boolean;
|
|
724
|
-
}): React.ReactNode {
|
|
725
|
-
const lang = (block.work ?? block.base)?.lang;
|
|
726
|
-
const content = displayText(block);
|
|
727
|
-
// Shiki tokens arrive async; plain verbatim lines render until then
|
|
728
|
-
const [tokens, setTokens] = useState<CodeToken[][] | null>(null);
|
|
729
|
-
useEffect(() => {
|
|
730
|
-
let live = true;
|
|
731
|
-
void highlightCode(content, lang).then((t) => {
|
|
732
|
-
if (live && t) setTokens(t);
|
|
733
|
-
});
|
|
734
|
-
return () => {
|
|
735
|
-
live = false;
|
|
736
|
-
};
|
|
737
|
-
}, [content, lang]);
|
|
738
|
-
const lines: CodeToken[][] = tokens ?? content.split("\n").map((l) => [{ content: l }]);
|
|
739
|
-
return (
|
|
740
|
-
<box id={id} style={{ flexDirection: "column", marginTop: gap }}>
|
|
741
|
-
<text>
|
|
742
|
-
<span fg={isCursor ? T.accent : T.textDim}>{cursorGlyph(isCursor)}</span>
|
|
743
|
-
<span fg={T.textDim}>{lang ?? "code"}</span>
|
|
744
|
-
{annotated ? <span fg={T.accent}> ◆</span> : null}
|
|
745
|
-
{block.type !== "same" ? <span fg={tagColor(block)}> [{tagLabel(block)}]</span> : null}
|
|
746
|
-
</text>
|
|
747
|
-
<box
|
|
748
|
-
style={{
|
|
749
|
-
flexDirection: "column",
|
|
750
|
-
backgroundColor: T.elevated,
|
|
751
|
-
paddingLeft: 2,
|
|
752
|
-
paddingRight: 2,
|
|
753
|
-
paddingTop: 1,
|
|
754
|
-
paddingBottom: 1,
|
|
755
|
-
marginLeft: 2,
|
|
756
|
-
}}
|
|
757
|
-
>
|
|
758
|
-
{lines.map((line, li) => (
|
|
759
|
-
<text key={li}>
|
|
760
|
-
{line.length === 0 || (line.length === 1 && line[0]!.content === "") ? (
|
|
761
|
-
" "
|
|
762
|
-
) : (
|
|
763
|
-
line.map((tok, ti) => (
|
|
764
|
-
<span key={ti} fg={tok.color ?? T.textMuted}>
|
|
765
|
-
{tok.content}
|
|
766
|
-
</span>
|
|
767
|
-
))
|
|
768
|
-
)}
|
|
769
|
-
</text>
|
|
770
|
-
))}
|
|
771
|
-
</box>
|
|
772
|
-
</box>
|
|
773
|
-
);
|
|
774
|
-
}
|
|
775
|
-
|
|
776
|
-
/** Vertical rhythm: gaps live ABOVE blocks so boundaries never collapse. */
|
|
777
|
-
function topGap(prev: DisplayBlock | undefined, cur: DisplayBlock): number {
|
|
778
|
-
if (!prev) return 0;
|
|
779
|
-
const tightPair =
|
|
780
|
-
(cur.kind === "li" && prev.kind === "li") || (cur.kind === "oli" && prev.kind === "oli");
|
|
781
|
-
return tightPair ? 0 : 1;
|
|
782
|
-
}
|
|
783
|
-
|
|
784
|
-
// ── the rail ──────────────────────────────────
|
|
785
|
-
|
|
786
|
-
interface CardEditState {
|
|
787
|
-
id: string;
|
|
788
|
-
text: string;
|
|
789
|
-
onInput: (text: string) => void;
|
|
790
|
-
onSave: () => void;
|
|
791
|
-
onCancel: () => void;
|
|
792
|
-
}
|
|
793
|
-
|
|
794
|
-
/** The open confirm card: verdict, summary draft, counts, and its actions. */
|
|
795
|
-
interface SubmitConfirmState {
|
|
796
|
-
verdict: VerdictKind;
|
|
797
|
-
summary: string;
|
|
798
|
-
annotationCount: number;
|
|
799
|
-
blockingCount: number;
|
|
800
|
-
onInput: (summary: string) => void;
|
|
801
|
-
onSelectVerdict: (verdict: VerdictKind) => void;
|
|
802
|
-
onSubmit: () => void;
|
|
803
|
-
onCancel: () => void;
|
|
804
|
-
}
|
|
805
|
-
|
|
806
|
-
function Rail({
|
|
807
|
-
session,
|
|
808
|
-
selectedId,
|
|
809
|
-
resolvedIds,
|
|
810
|
-
railTab,
|
|
811
|
-
pendingCount,
|
|
812
|
-
cardEdit,
|
|
813
|
-
submitConfirm,
|
|
814
|
-
onTab,
|
|
815
|
-
onSelectCard,
|
|
816
|
-
onActivateCard,
|
|
817
|
-
onSubmitRequest,
|
|
818
|
-
railScrollRef,
|
|
819
|
-
}: {
|
|
820
|
-
session: ReviewSession;
|
|
821
|
-
selectedId?: string;
|
|
822
|
-
/** Ids whose anchor resolved; null = orphan display off (diff view). */
|
|
823
|
-
resolvedIds: Set<string> | null;
|
|
824
|
-
railTab: RailTab;
|
|
825
|
-
pendingCount: number;
|
|
826
|
-
cardEdit: CardEditState | null;
|
|
827
|
-
submitConfirm: SubmitConfirmState | null;
|
|
828
|
-
onTab: (tab: RailTab) => void;
|
|
829
|
-
onSelectCard: (id: string) => void;
|
|
830
|
-
onActivateCard: (id: string) => void;
|
|
831
|
-
onSubmitRequest: () => void;
|
|
832
|
-
railScrollRef: React.RefObject<ScrollBoxRenderable | null>;
|
|
833
|
-
}): React.ReactNode {
|
|
834
|
-
return (
|
|
835
|
-
<box style={{ width: 34, backgroundColor: T.panel, flexDirection: "column", paddingLeft: 1, paddingTop: 1 }}>
|
|
836
|
-
<box style={{ height: 1, flexDirection: "row" }}>
|
|
837
|
-
<box onMouseUp={() => onTab("review")}>
|
|
838
|
-
<text fg={railTab === "review" ? T.accent : T.textDim}>Review ({pendingCount})</text>
|
|
839
|
-
</box>
|
|
840
|
-
<text fg={T.textDim}>{" "}</text>
|
|
841
|
-
<box onMouseUp={() => onTab("agent")}>
|
|
842
|
-
<text fg={railTab === "agent" ? T.accent : T.textDim}>Agent</text>
|
|
843
|
-
</box>
|
|
844
|
-
</box>
|
|
845
|
-
<text> </text>
|
|
846
|
-
{railTab === "agent" ? (
|
|
847
|
-
<AgentTab session={session} />
|
|
848
|
-
) : (
|
|
849
|
-
<>
|
|
850
|
-
{session.workingCopy !== undefined ? <text fg={T.textDim}>± plan edits → one diff</text> : null}
|
|
851
|
-
{session.annotations.length === 0 ? (
|
|
852
|
-
<text fg={T.textDim}>no annotations yet</text>
|
|
853
|
-
) : (
|
|
854
|
-
<scrollbox ref={railScrollRef} style={{ flexGrow: 1 }} focused={false}>
|
|
855
|
-
{session.annotations.map((annotation) => (
|
|
856
|
-
<AnnotationCard
|
|
857
|
-
key={annotation.id}
|
|
858
|
-
kind={annotation.kind}
|
|
859
|
-
quote={annotation.anchor.quote}
|
|
860
|
-
saved={{
|
|
861
|
-
body: annotation.body,
|
|
862
|
-
selected: annotation.id === selectedId,
|
|
863
|
-
orphan: resolvedIds !== null && !resolvedIds.has(annotation.id),
|
|
864
|
-
blocking: annotationBlocking(annotation),
|
|
865
|
-
editing:
|
|
866
|
-
cardEdit && cardEdit.id === annotation.id
|
|
867
|
-
? { text: cardEdit.text, onInput: cardEdit.onInput, onSave: cardEdit.onSave, onCancel: cardEdit.onCancel }
|
|
868
|
-
: null,
|
|
869
|
-
onMouseUp: () =>
|
|
870
|
-
annotation.id === selectedId ? onActivateCard(annotation.id) : onSelectCard(annotation.id),
|
|
871
|
-
}}
|
|
872
|
-
/>
|
|
873
|
-
))}
|
|
874
|
-
</scrollbox>
|
|
875
|
-
)}
|
|
876
|
-
<box style={{ flexGrow: 1 }} />
|
|
877
|
-
{/* the confirm card sits OUTSIDE the scrollbox: the annotation stack
|
|
878
|
-
above scrolls while the card stays pinned to the rail bottom */}
|
|
879
|
-
{session.status === "resolved" ? (
|
|
880
|
-
<text fg={T.green}>resolved: {session.verdict!.kind.replace("_", " ")}</text>
|
|
881
|
-
) : submitConfirm ? (
|
|
882
|
-
<SubmitConfirmCard confirm={submitConfirm} />
|
|
883
|
-
) : (
|
|
884
|
-
<box onMouseUp={onSubmitRequest}>
|
|
885
|
-
<text fg={T.accent}>{`Submit review (${pendingCount}) ⏎`}</text>
|
|
886
|
-
</box>
|
|
887
|
-
)}
|
|
888
|
-
</>
|
|
889
|
-
)}
|
|
890
|
-
</box>
|
|
891
|
-
);
|
|
892
|
-
}
|
|
893
|
-
|
|
894
|
-
/**
|
|
895
|
-
* Confirm card content rows: counts, spacer, verdict selector, spacer,
|
|
896
|
-
* summary input, spacer, Submit/Cancel buttons. The bordered box height is
|
|
897
|
-
* this count plus the two border rows - a pure function of the content, so
|
|
898
|
-
* layout and render never drift.
|
|
899
|
-
*/
|
|
900
|
-
const SUBMIT_CONFIRM_CONTENT_ROWS = 7;
|
|
901
|
-
|
|
902
|
-
function submitConfirmHeight(): number {
|
|
903
|
-
return SUBMIT_CONFIRM_CONTENT_ROWS + 2;
|
|
904
|
-
}
|
|
905
|
-
|
|
906
|
-
/**
|
|
907
|
-
* The Submit button expanded into a bordered confirm card at the rail bottom:
|
|
908
|
-
* honest counts, the verdict selector (arrow keys or click), the optional
|
|
909
|
-
* summary, and plain word-buttons - key hints live in the status line only.
|
|
910
|
-
*/
|
|
911
|
-
function SubmitConfirmCard({ confirm }: { confirm: SubmitConfirmState }): React.ReactNode {
|
|
912
|
-
return (
|
|
913
|
-
<box
|
|
914
|
-
style={{
|
|
915
|
-
height: submitConfirmHeight(),
|
|
916
|
-
marginRight: 1,
|
|
917
|
-
border: true,
|
|
918
|
-
borderStyle: "rounded",
|
|
919
|
-
borderColor: T.accent,
|
|
920
|
-
backgroundColor: T.elevated,
|
|
921
|
-
flexDirection: "column",
|
|
922
|
-
paddingLeft: 1,
|
|
923
|
-
}}
|
|
924
|
-
title=" submit review "
|
|
925
|
-
>
|
|
926
|
-
<text fg={T.textDim}>{`${confirm.annotationCount} annotations · ${confirm.blockingCount} blocking`}</text>
|
|
927
|
-
<box style={{ height: 1 }} />
|
|
928
|
-
<box style={{ flexDirection: "row", height: 1 }}>
|
|
929
|
-
{VERDICTS.map((candidate) => (
|
|
930
|
-
<box key={candidate} style={{ paddingRight: 1 }} onMouseUp={() => confirm.onSelectVerdict(candidate)}>
|
|
931
|
-
<text fg={candidate === confirm.verdict ? verdictColor(candidate) : T.textDim}>
|
|
932
|
-
{candidate === confirm.verdict ? `[${VERDICT_LABEL[candidate]}]` : ` ${VERDICT_LABEL[candidate]} `}
|
|
933
|
-
</text>
|
|
934
|
-
</box>
|
|
935
|
-
))}
|
|
936
|
-
</box>
|
|
937
|
-
<box style={{ height: 1 }} />
|
|
938
|
-
<input focused value={confirm.summary} onInput={confirm.onInput} placeholder="summary for the agent (optional)" />
|
|
939
|
-
<box style={{ height: 1 }} />
|
|
940
|
-
<box style={{ flexDirection: "row", height: 1 }}>
|
|
941
|
-
<box style={{ backgroundColor: T.accent, marginRight: 2 }} onMouseUp={confirm.onSubmit}>
|
|
942
|
-
<text fg={T.accentInk}> Submit </text>
|
|
943
|
-
</box>
|
|
944
|
-
<box onMouseUp={confirm.onCancel}>
|
|
945
|
-
<text fg={T.textDim}> Cancel </text>
|
|
946
|
-
</box>
|
|
947
|
-
</box>
|
|
948
|
-
</box>
|
|
949
|
-
);
|
|
950
|
-
}
|
|
951
|
-
|
|
952
|
-
/** Agent tab: who submitted, where the session stands, which revision. */
|
|
953
|
-
function AgentTab({ session }: { session: ReviewSession }): React.ReactNode {
|
|
954
|
-
return (
|
|
955
|
-
<box style={{ flexDirection: "column", flexGrow: 1 }}>
|
|
956
|
-
<text fg={T.textMuted}>{session.artifact.meta.agent ?? "unknown"}</text>
|
|
957
|
-
<text fg={T.textDim}>status: {session.status}</text>
|
|
958
|
-
<text fg={T.textDim}>revision {session.revisions.length}</text>
|
|
959
|
-
</box>
|
|
960
|
-
);
|
|
961
|
-
}
|
|
962
|
-
|
|
963
|
-
/**
|
|
964
|
-
* The composer and the rail card are one component in two modes: draft props
|
|
965
|
-
* attached (bordered box inline in the document flow) or saved props attached
|
|
966
|
-
* (rail card, optionally editing its body in place). Compose, saved, and
|
|
967
|
-
* re-edit all share this rendering path.
|
|
968
|
-
*/
|
|
969
|
-
function AnnotationCard({
|
|
970
|
-
kind,
|
|
971
|
-
quote,
|
|
972
|
-
draft,
|
|
973
|
-
saved,
|
|
974
|
-
}: {
|
|
975
|
-
kind: string;
|
|
976
|
-
quote: string;
|
|
977
|
-
draft?: { text: string; onInput: (text: string) => void; onSave: () => void; onCancel: () => void };
|
|
978
|
-
saved?: {
|
|
979
|
-
body: string;
|
|
980
|
-
selected: boolean;
|
|
981
|
-
orphan: boolean;
|
|
982
|
-
blocking: boolean;
|
|
983
|
-
editing: { text: string; onInput: (text: string) => void; onSave: () => void; onCancel: () => void } | null;
|
|
984
|
-
onMouseUp: () => void;
|
|
985
|
-
};
|
|
986
|
-
}): React.ReactNode {
|
|
987
|
-
const color = kind === "suggestion" ? T.green : T.accent;
|
|
988
|
-
if (draft) {
|
|
989
|
-
const verb = kind === "suggestion" ? "suggest replacement for" : "comment on";
|
|
990
|
-
return (
|
|
991
|
-
<box
|
|
992
|
-
style={{
|
|
993
|
-
height: composeBoxHeight(),
|
|
994
|
-
marginLeft: 2,
|
|
995
|
-
marginRight: 2,
|
|
996
|
-
border: true,
|
|
997
|
-
borderStyle: "rounded",
|
|
998
|
-
borderColor: color,
|
|
999
|
-
backgroundColor: T.elevated,
|
|
1000
|
-
flexDirection: "column",
|
|
1001
|
-
paddingLeft: 1,
|
|
1002
|
-
}}
|
|
1003
|
-
title={` ${verb} "${truncate(quote, 40)}" `}
|
|
1004
|
-
>
|
|
1005
|
-
<input focused value={draft.text} onInput={draft.onInput} placeholder="write a note..." />
|
|
1006
|
-
<SaveCancelRow onSave={draft.onSave} onCancel={draft.onCancel} />
|
|
1007
|
-
</box>
|
|
1008
|
-
);
|
|
1009
|
-
}
|
|
1010
|
-
const card = saved!;
|
|
1011
|
-
return (
|
|
1012
|
-
<box
|
|
1013
|
-
style={{ flexDirection: "column", marginBottom: 1, backgroundColor: card.selected ? T.elevated : undefined }}
|
|
1014
|
-
onMouseUp={card.onMouseUp}
|
|
1015
|
-
>
|
|
1016
|
-
<text fg={card.selected ? T.text : color}>
|
|
1017
|
-
{card.selected ? "▸ " : " "}
|
|
1018
|
-
{kind.toUpperCase()}
|
|
1019
|
-
{card.blocking ? <span fg={T.red}> · BLOCKING</span> : null}
|
|
1020
|
-
<span fg={T.textDim}>{card.orphan ? " · ORPHANED" : " · pending"}</span>
|
|
1021
|
-
</text>
|
|
1022
|
-
<text fg={T.textDim}> "{truncate(quote, 26)}"</text>
|
|
1023
|
-
{card.editing ? (
|
|
1024
|
-
<>
|
|
1025
|
-
<input focused value={card.editing.text} onInput={card.editing.onInput} />
|
|
1026
|
-
<SaveCancelRow onSave={card.editing.onSave} onCancel={card.editing.onCancel} />
|
|
1027
|
-
</>
|
|
1028
|
-
) : (
|
|
1029
|
-
<text fg={card.orphan ? T.textDim : T.textMuted}> {truncate(card.body, 28)}</text>
|
|
1030
|
-
)}
|
|
1031
|
-
</box>
|
|
1032
|
-
);
|
|
1033
|
-
}
|
|
1034
|
-
|
|
1035
|
-
function SaveCancelRow({ onSave, onCancel }: { onSave: () => void; onCancel: () => void }): React.ReactNode {
|
|
1036
|
-
return (
|
|
1037
|
-
<box style={{ flexDirection: "row", height: 1 }}>
|
|
1038
|
-
<box style={{ backgroundColor: T.accent, marginRight: 2 }} onMouseUp={onSave}>
|
|
1039
|
-
<text fg={T.accentInk}> Save ⏎ </text>
|
|
1040
|
-
</box>
|
|
1041
|
-
<box onMouseUp={onCancel}>
|
|
1042
|
-
<text fg={T.textDim}> Cancel esc </text>
|
|
1043
|
-
</box>
|
|
1044
|
-
</box>
|
|
1045
|
-
);
|
|
1046
|
-
}
|
|
1047
|
-
|
|
1048
|
-
/** Forward-compatible: open annotation kinds may carry a blocking flag. */
|
|
1049
|
-
function annotationBlocking(annotation: Annotation): boolean {
|
|
1050
|
-
return (annotation as Annotation & { blocking?: boolean }).blocking === true;
|
|
1051
|
-
}
|
|
1052
|
-
|
|
1053
|
-
function Inbox({ inbox, cursor }: { inbox: ReviewSession[]; cursor: number }): React.ReactNode {
|
|
1054
|
-
return (
|
|
1055
|
-
<box style={{ flexDirection: "column", width: "100%", height: "100%", backgroundColor: T.bg, padding: 1 }}>
|
|
1056
|
-
<text fg={T.accent}>cueloop · inbox ({inbox.length} pending)</text>
|
|
1057
|
-
<text> </text>
|
|
1058
|
-
{inbox.length === 0 ? (
|
|
1059
|
-
<text fg={T.textDim}>nothing waiting for review</text>
|
|
1060
|
-
) : (
|
|
1061
|
-
inbox.map((s, i) => (
|
|
1062
|
-
<text key={s.id} fg={i === cursor ? T.text : T.textMuted} bg={i === cursor ? T.cursorBg : undefined}>
|
|
1063
|
-
{i === cursor ? "▸ " : " "}
|
|
1064
|
-
{s.artifact.meta.title ?? s.id} · {s.workspace.branch} · {s.artifact.type}
|
|
1065
|
-
</text>
|
|
1066
|
-
))
|
|
1067
|
-
)}
|
|
1068
|
-
<box style={{ flexGrow: 1 }} />
|
|
1069
|
-
<text fg={T.textDim}>j/k move · ⏎ open · q quit</text>
|
|
1070
|
-
</box>
|
|
1071
|
-
);
|
|
1072
|
-
}
|
|
1073
|
-
|
|
1074
|
-
/** Bottom compose bar - the diff view still composes here. */
|
|
1075
|
-
function ComposeBar({
|
|
1076
|
-
mode,
|
|
1077
|
-
quote,
|
|
1078
|
-
onChange,
|
|
1079
|
-
}: {
|
|
1080
|
-
mode: Extract<Mode, { m: "compose" }>;
|
|
1081
|
-
quote: string;
|
|
1082
|
-
onChange: (text: string) => void;
|
|
1083
|
-
}): React.ReactNode {
|
|
1084
|
-
return (
|
|
1085
|
-
<box style={{ height: 2, backgroundColor: T.elevated, flexDirection: "column", paddingLeft: 1 }}>
|
|
1086
|
-
<text fg={mode.kind === "suggestion" ? T.green : T.accent}>
|
|
1087
|
-
{mode.kind === "suggestion" ? "SUGGEST REPLACEMENT FOR" : "COMMENT ON"} “{truncate(quote, 60)}” · ⏎ save · esc cancel
|
|
1088
|
-
</text>
|
|
1089
|
-
<input focused value={mode.text} onInput={onChange} />
|
|
1090
|
-
</box>
|
|
1091
|
-
);
|
|
1092
|
-
}
|
|
1093
|
-
|
|
1094
|
-
function DiffPane({
|
|
1095
|
-
rows,
|
|
1096
|
-
cursor,
|
|
1097
|
-
annotations,
|
|
1098
|
-
focusedAnn,
|
|
1099
|
-
}: {
|
|
1100
|
-
rows: DiffRow[];
|
|
1101
|
-
cursor: number;
|
|
1102
|
-
annotations: Annotation[];
|
|
1103
|
-
focusedAnn?: string;
|
|
1104
|
-
}): React.ReactNode {
|
|
1105
|
-
// simple windowing: keep the cursor in view without a scroll dependency
|
|
1106
|
-
const windowStart = Math.max(0, cursor - 12);
|
|
1107
|
-
const visible = rows.slice(windowStart, windowStart + 200);
|
|
1108
|
-
const annotated = new Map<number, Annotation>();
|
|
1109
|
-
for (const a of annotations) {
|
|
1110
|
-
const idx = rows.findIndex((r) => r.text === a.anchor.quote && (r.t === "ctx" || r.t === "add" || r.t === "del"));
|
|
1111
|
-
if (idx !== -1) annotated.set(idx, a);
|
|
1112
|
-
}
|
|
1113
|
-
return (
|
|
1114
|
-
<box style={{ flexGrow: 1, flexDirection: "column", paddingLeft: 1, paddingTop: 1 }}>
|
|
1115
|
-
{visible.map((row, vi) => {
|
|
1116
|
-
const i = windowStart + vi;
|
|
1117
|
-
const isCursor = i === cursor;
|
|
1118
|
-
const ann = annotated.get(i);
|
|
1119
|
-
const gutter = row.t === "del" ? (row.oldLine?.toString() ?? "") : (row.newLine?.toString() ?? "");
|
|
1120
|
-
const sign = row.t === "add" ? "+" : row.t === "del" ? "-" : " ";
|
|
1121
|
-
if (row.t === "file") {
|
|
1122
|
-
return (
|
|
1123
|
-
<text key={i} fg={T.text} bg={isCursor ? T.cursorBg : T.panel}>
|
|
1124
|
-
{isCursor ? "▎" : " "}■ {row.text}
|
|
1125
|
-
</text>
|
|
1126
|
-
);
|
|
1127
|
-
}
|
|
1128
|
-
if (row.t === "hunk") {
|
|
1129
|
-
return (
|
|
1130
|
-
<text key={i} fg={T.blue} bg={isCursor ? T.cursorBg : undefined}>
|
|
1131
|
-
{isCursor ? "▎" : " "}{row.text}
|
|
1132
|
-
</text>
|
|
1133
|
-
);
|
|
1134
|
-
}
|
|
1135
|
-
const fg = row.t === "add" ? T.insFg : row.t === "del" ? T.delFg : T.textMuted;
|
|
1136
|
-
return (
|
|
1137
|
-
<box key={i} style={{ flexDirection: "column" }}>
|
|
1138
|
-
<text bg={isCursor ? T.cursorBg : ann ? T.markCommentBg : undefined}>
|
|
1139
|
-
<span fg={T.textDim}>{isCursor ? "▎" : " "}{gutter.padStart(4)} </span>
|
|
1140
|
-
<span fg={fg}>{sign}{row.text}</span>
|
|
1141
|
-
</text>
|
|
1142
|
-
{ann ? (
|
|
1143
|
-
<text>
|
|
1144
|
-
<span fg={T.textDim}>{" "}</span>
|
|
1145
|
-
<span fg={ann.id === focusedAnn ? T.text : T.accent}>◆ {truncate(ann.body, 70)}</span>
|
|
1146
|
-
</text>
|
|
1147
|
-
) : null}
|
|
1148
|
-
</box>
|
|
1149
|
-
);
|
|
1150
|
-
})}
|
|
1151
|
-
</box>
|
|
1152
|
-
);
|
|
1153
|
-
}
|
|
1154
|
-
|
|
1155
|
-
function CompletionOverlay({
|
|
1156
|
-
verdict,
|
|
1157
|
-
completion,
|
|
1158
|
-
status,
|
|
1159
|
-
returnsTo,
|
|
1160
|
-
}: {
|
|
1161
|
-
verdict: VerdictKind;
|
|
1162
|
-
completion: { phase: "prompt" } | { phase: "counting"; remaining: number };
|
|
1163
|
-
/** Latest status line (e.g. the vault-export path) stays visible here. */
|
|
1164
|
-
status: string;
|
|
1165
|
-
/** Where focus goes on close (the agent's herdr pane), when known. */
|
|
1166
|
-
returnsTo?: string;
|
|
1167
|
-
}): React.ReactNode {
|
|
1168
|
-
const approved = verdict === "approve";
|
|
1169
|
-
return (
|
|
1170
|
-
<box
|
|
1171
|
-
style={{
|
|
1172
|
-
width: "100%",
|
|
1173
|
-
height: "100%",
|
|
1174
|
-
backgroundColor: T.bg,
|
|
1175
|
-
flexDirection: "column",
|
|
1176
|
-
justifyContent: "center",
|
|
1177
|
-
alignItems: "center",
|
|
1178
|
-
}}
|
|
1179
|
-
>
|
|
1180
|
-
<text fg={approved ? T.green : T.accent}>{approved ? "✓ review approved" : "✎ feedback sent"}</text>
|
|
1181
|
-
<text> </text>
|
|
1182
|
-
<text fg={T.text}>The agent has your {approved ? "approval" : "feedback"} and is unblocked.</text>
|
|
1183
|
-
{status ? <text fg={T.textDim}>{status}</text> : null}
|
|
1184
|
-
<text> </text>
|
|
1185
|
-
{completion.phase === "counting" ? (
|
|
1186
|
-
<text fg={T.textDim}>
|
|
1187
|
-
closing in {completion.remaining}s{returnsTo ? ` - returning to ${returnsTo}` : ""} · ⏎ now · esc stay
|
|
1188
|
-
</text>
|
|
1189
|
-
) : (
|
|
1190
|
-
<text fg={T.textDim}>⏎ close · a always close after submit (3s) · esc stay</text>
|
|
1191
|
-
)}
|
|
1192
|
-
</box>
|
|
1193
|
-
);
|
|
1194
|
-
}
|
|
1195
|
-
|
|
1196
|
-
// ── small helpers ─────────────────────────────
|
|
1197
|
-
|
|
1198
|
-
function marker(d: DisplayBlock): string {
|
|
1199
|
-
if (d.kind === "li") return "- ";
|
|
1200
|
-
if (d.kind === "oli") return `${d.oliNum ?? 1}. `;
|
|
1201
|
-
if (d.kind === "quote") return "▏ ";
|
|
1202
|
-
return "";
|
|
1203
|
-
}
|
|
1204
|
-
|
|
1205
|
-
function cursorGlyph(isCursor: boolean): string {
|
|
1206
|
-
return isCursor ? "▎ " : " ";
|
|
1207
|
-
}
|
|
1208
|
-
|
|
1209
|
-
function tagLabel(d: DisplayBlock): string {
|
|
1210
|
-
return d.type === "del" ? "cut" : d.type === "add" ? "new" : "edited";
|
|
1211
|
-
}
|
|
1212
|
-
|
|
1213
|
-
function tagColor(d: DisplayBlock): string {
|
|
1214
|
-
return d.type === "del" ? T.red : d.type === "add" ? T.green : T.accent;
|
|
1215
|
-
}
|
|
1216
|
-
|
|
1217
|
-
function verdictColor(v: VerdictKind): string {
|
|
1218
|
-
return v === "approve" ? T.green : v === "request_changes" ? T.red : T.blue;
|
|
1219
|
-
}
|
|
1220
|
-
|
|
1221
|
-
function defaultVerdict(s: ReviewSession): VerdictKind {
|
|
1222
|
-
return s.annotations.length || s.workingCopy !== undefined ? "request_changes" : "approve";
|
|
1223
|
-
}
|
|
1224
|
-
|
|
1225
|
-
function runStyle(r: StyleRun, d: DisplayBlock): { fg?: string; bg?: string; attributes?: number } {
|
|
1226
|
-
const headingFg = d.kind === "h1" ? T.text : d.kind === "h2" || d.kind === "h3" ? T.accent : undefined;
|
|
1227
|
-
const struck = d.type === "del";
|
|
1228
|
-
switch (r.role) {
|
|
1229
|
-
case "ins":
|
|
1230
|
-
return { fg: T.insFg };
|
|
1231
|
-
case "del":
|
|
1232
|
-
return { fg: T.delFg };
|
|
1233
|
-
case "mark-comment":
|
|
1234
|
-
return { fg: T.text, bg: T.markCommentBg };
|
|
1235
|
-
case "mark-suggestion":
|
|
1236
|
-
return { fg: T.text, bg: T.markSuggestionBg };
|
|
1237
|
-
case "mark-focus":
|
|
1238
|
-
return { fg: T.accentInk, bg: T.accent };
|
|
1239
|
-
case "kspan":
|
|
1240
|
-
return { fg: T.accentInk, bg: T.accent };
|
|
1241
|
-
default:
|
|
1242
|
-
return { fg: struck ? T.red : (headingFg ?? (d.kind === "code" ? T.textMuted : T.text)) };
|
|
1243
|
-
}
|
|
1244
|
-
}
|
|
1245
|
-
|
|
1246
|
-
function truncate(s: string, n: number): string {
|
|
1247
|
-
const one = s.replace(/\n/g, " ");
|
|
1248
|
-
return one.length > n ? one.slice(0, n - 1) + "…" : one;
|
|
1249
|
-
}
|