@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
|
@@ -0,0 +1,560 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The thread view: the plan surface where discussions live inline in the
|
|
3
|
+
* document instead of the rail. The caret, marks, composer, palette, and cards
|
|
4
|
+
* come from the shared annotation surface (the diff sheet drives the same hook);
|
|
5
|
+
* this view paints the plan's blocks - headings, lists, quotes, tracked changes -
|
|
6
|
+
* and the right-edge scroll markers that navigate between discussions.
|
|
7
|
+
*
|
|
8
|
+
* Grammar: arrows move (caret flows across blocks) · shift+arrows select ·
|
|
9
|
+
* click/drag mark · typing comments (or edits my trailing comment / replies
|
|
10
|
+
* in a focused discussion) · "/" opens the quick-action palette · cmd+option+m
|
|
11
|
+
* comments on selection · cmd+enter sends, enter breaks the line · esc
|
|
12
|
+
* dismisses · tab folds · cmd+[ / cmd+] cycle discussions · blur-save on click.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import React, { useEffect, useRef, useState } from "react";
|
|
16
|
+
import { useTerminalDimensions } from "@opentui/react";
|
|
17
|
+
import type { KeyEvent, ScrollBoxRenderable } from "@opentui/core";
|
|
18
|
+
import type { Annotation, Thread } from "@cueloop/schema";
|
|
19
|
+
import {
|
|
20
|
+
displayText,
|
|
21
|
+
renderedText,
|
|
22
|
+
renderedStyleRuns,
|
|
23
|
+
styledRunsFor,
|
|
24
|
+
type DisplayBlock,
|
|
25
|
+
type Mark,
|
|
26
|
+
type RenderedRun,
|
|
27
|
+
type RunRole,
|
|
28
|
+
type StyledRun,
|
|
29
|
+
} from "../view-plan";
|
|
30
|
+
import type { TextSpan } from "../../annotations/thread-selection";
|
|
31
|
+
import type { QuickAction } from "../../settings/config";
|
|
32
|
+
import type { CheatsheetSection } from "../../keyboard/key-bindings";
|
|
33
|
+
import type { Theme } from "../../appearance/theme";
|
|
34
|
+
import { BOLD, CUT, ITALIC, UNDERLINE } from "../../annotations/annotation-palette";
|
|
35
|
+
import { wrapLines, type MarkRange, type VisualLine } from "../mark-runs";
|
|
36
|
+
import { MarkdownGridBlock } from "./MarkdownGridBlock";
|
|
37
|
+
import { useFrameMeasure } from "../../ui/use-frame-measure";
|
|
38
|
+
import { scrollBoxDragViewport, useTerminalVirtualizer } from "../../ui/use-terminal-virtualizer";
|
|
39
|
+
import {
|
|
40
|
+
useAnnotationSurface,
|
|
41
|
+
type LineSource,
|
|
42
|
+
type PromptFocusRequest,
|
|
43
|
+
type PromptRestoreRequest,
|
|
44
|
+
} from "../../annotations/use-annotation-surface";
|
|
45
|
+
import { NavModeHint } from "../../keyboard/components/NavModeHint";
|
|
46
|
+
import { DiscussionMarkerRail } from "../../annotations/components/DiscussionMarkerRail";
|
|
47
|
+
import { useComponentTheme } from "../../appearance/components/theme-context";
|
|
48
|
+
import { colorForSyntaxGroup } from "../../ui/components/syntax-highlight";
|
|
49
|
+
import { highlightThreadCodeBlocks, type SyntaxSpan } from "../thread-code-syntax";
|
|
50
|
+
|
|
51
|
+
/** Blocks kept mounted beyond the viewport, so a scroll step or a drag past the edge never shows a gap. */
|
|
52
|
+
const OVERSCAN_BLOCKS = 8;
|
|
53
|
+
|
|
54
|
+
export { lighten } from "../../annotations/annotation-palette";
|
|
55
|
+
|
|
56
|
+
interface CodeSyntaxState {
|
|
57
|
+
display: DisplayBlock[];
|
|
58
|
+
byBlock: Map<number, SyntaxSpan[]>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function syntaxSpansForBlock(
|
|
62
|
+
block: DisplayBlock,
|
|
63
|
+
blockIndex: number,
|
|
64
|
+
display: DisplayBlock[],
|
|
65
|
+
codeSyntax: CodeSyntaxState | null,
|
|
66
|
+
): readonly SyntaxSpan[] {
|
|
67
|
+
if (block.type === "del" || codeSyntax?.display !== display) return [];
|
|
68
|
+
|
|
69
|
+
return codeSyntax.byBlock.get(blockIndex) ?? [];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* How a block's rows are painted: heading weight, muted kinds, and the list or
|
|
74
|
+
* quote marker. A cut block reads dim and struck through; other edits are
|
|
75
|
+
* tracked into the working copy but not tagged in the read-only view.
|
|
76
|
+
*/
|
|
77
|
+
interface BlockStyle {
|
|
78
|
+
baseFg: string;
|
|
79
|
+
baseAttributes: number;
|
|
80
|
+
marker: string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function blockStyle(block: DisplayBlock, tokens: Theme): BlockStyle {
|
|
84
|
+
const isHeading = block.kind === "h1" || block.kind === "h2" || block.kind === "h3";
|
|
85
|
+
const isCut = block.type === "del";
|
|
86
|
+
const muted = block.kind === "h2" || block.kind === "h3" || block.kind === "code";
|
|
87
|
+
const marker =
|
|
88
|
+
block.kind === "li"
|
|
89
|
+
? "· "
|
|
90
|
+
: block.kind === "oli"
|
|
91
|
+
? `${block.orderedItemNumber ?? 1}. `
|
|
92
|
+
: block.kind === "quote"
|
|
93
|
+
? "▏ "
|
|
94
|
+
: "";
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
baseFg: isCut ? tokens.textDim : muted ? tokens.textMuted : tokens.text,
|
|
98
|
+
baseAttributes: (isHeading ? BOLD : 0) | (isCut ? CUT : 0),
|
|
99
|
+
marker,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The read-only color of an inline markdown role: links accent, code and removals dim, insertions green. */
|
|
104
|
+
function roleForeground(role: RunRole, baseFg: string, tokens: Theme): string {
|
|
105
|
+
if (role === "link") return tokens.blue;
|
|
106
|
+
if (role === "ins") return tokens.green;
|
|
107
|
+
if (role === "code" || role === "del") return tokens.textDim;
|
|
108
|
+
|
|
109
|
+
return baseFg;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The text attributes an inline role adds over a block's base attributes. */
|
|
113
|
+
function roleAttributes(role: RunRole, baseAttributes: number): number {
|
|
114
|
+
if (role === "strong") return baseAttributes | BOLD;
|
|
115
|
+
if (role === "em") return baseAttributes | ITALIC;
|
|
116
|
+
if (role === "strike" || role === "del") return baseAttributes | CUT;
|
|
117
|
+
|
|
118
|
+
return baseAttributes;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function isGridKind(kind: DisplayBlock["kind"]): boolean {
|
|
122
|
+
return kind === "table" || kind === "frontmatter";
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/* ------------------------------------------------------------------ view */
|
|
126
|
+
|
|
127
|
+
/** The grammar as the keybinds dialog lists it; the view owns these keys, so they are not rebindable. */
|
|
128
|
+
export const THREAD_VIEW_CHEATSHEET: CheatsheetSection[] = [
|
|
129
|
+
{
|
|
130
|
+
title: "Thread (type)",
|
|
131
|
+
entries: [
|
|
132
|
+
{ keys: "click", label: "place the caret" },
|
|
133
|
+
{ keys: "drag", label: "mark text, across blocks" },
|
|
134
|
+
{ keys: "dbl-click", label: "mark the word" },
|
|
135
|
+
{ keys: "← / →", label: "move by word" },
|
|
136
|
+
{ keys: "⇧← / ⇧→", label: "hold a mark" },
|
|
137
|
+
{ keys: "↑ / ↓", label: "move by block" },
|
|
138
|
+
{ keys: "type", label: "comment on the mark" },
|
|
139
|
+
{ keys: "enter", label: "reply to the comment" },
|
|
140
|
+
{ keys: "esc", label: "nav mode" },
|
|
141
|
+
{ keys: "tab", label: "cycle panes" },
|
|
142
|
+
{ keys: "⌃q", label: "quit" },
|
|
143
|
+
],
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
title: "Comment",
|
|
147
|
+
entries: [
|
|
148
|
+
{ keys: "⌘enter", label: "send (⌃enter as well)" },
|
|
149
|
+
{ keys: "enter", label: "new line" },
|
|
150
|
+
{ keys: "backspace", label: "dismiss an empty draft" },
|
|
151
|
+
{ keys: "/", label: "actions" },
|
|
152
|
+
{ keys: "click away", label: "save" },
|
|
153
|
+
],
|
|
154
|
+
},
|
|
155
|
+
];
|
|
156
|
+
|
|
157
|
+
export interface ThreadViewProps {
|
|
158
|
+
canAnnotateBlock?: (blockIndex: number) => boolean;
|
|
159
|
+
isAnnotationReadOnly?: (id: string) => boolean;
|
|
160
|
+
annotationAction?: (id: string) => { label: string; run: () => void } | undefined;
|
|
161
|
+
requestedBlock?: { blockIndex: number };
|
|
162
|
+
onInvoke?: () => void;
|
|
163
|
+
isPromptBlock?: (blockIndex: number) => boolean;
|
|
164
|
+
promptFocusRequest?: PromptFocusRequest;
|
|
165
|
+
promptRestoreRequest?: PromptRestoreRequest;
|
|
166
|
+
renderBlock?: (index: number) => React.ReactNode | undefined;
|
|
167
|
+
session: Thread;
|
|
168
|
+
display: DisplayBlock[];
|
|
169
|
+
marks: Map<number, Mark[]>;
|
|
170
|
+
quickActions: QuickAction[];
|
|
171
|
+
observer: boolean;
|
|
172
|
+
suspended?: boolean;
|
|
173
|
+
editOrphanCount?: number;
|
|
174
|
+
onComposingChange?: (composing: boolean) => void;
|
|
175
|
+
resolved?: boolean;
|
|
176
|
+
onObserverBlocked?: (reason: "observer" | "resolved") => void;
|
|
177
|
+
onCursorChange?: (blockIndex: number) => void;
|
|
178
|
+
focusedAnnotationId?: string;
|
|
179
|
+
onFocusAnnotation?: (annotationId: string | undefined) => void;
|
|
180
|
+
onAnnotate: (span: TextSpan, body: string) => void;
|
|
181
|
+
onReply: (rootAnnotationId: string, body: string) => void;
|
|
182
|
+
onUpdateAnnotation: (id: string, body: string) => void;
|
|
183
|
+
resolveAuthorLabel?: (annotation: Annotation) => string | undefined;
|
|
184
|
+
onNavCommand?: (key: KeyEvent, selection: TextSpan | null) => boolean;
|
|
185
|
+
onExit: () => void;
|
|
186
|
+
theme?: Theme;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function ThreadView({
|
|
190
|
+
session,
|
|
191
|
+
display,
|
|
192
|
+
marks,
|
|
193
|
+
quickActions,
|
|
194
|
+
observer,
|
|
195
|
+
resolved = false,
|
|
196
|
+
suspended = false,
|
|
197
|
+
editOrphanCount = 0,
|
|
198
|
+
onComposingChange,
|
|
199
|
+
onObserverBlocked,
|
|
200
|
+
onCursorChange,
|
|
201
|
+
focusedAnnotationId,
|
|
202
|
+
onFocusAnnotation,
|
|
203
|
+
onAnnotate,
|
|
204
|
+
onReply,
|
|
205
|
+
onUpdateAnnotation,
|
|
206
|
+
resolveAuthorLabel,
|
|
207
|
+
onNavCommand,
|
|
208
|
+
onExit,
|
|
209
|
+
theme,
|
|
210
|
+
canAnnotateBlock,
|
|
211
|
+
isAnnotationReadOnly,
|
|
212
|
+
annotationAction,
|
|
213
|
+
requestedBlock,
|
|
214
|
+
onInvoke,
|
|
215
|
+
isPromptBlock,
|
|
216
|
+
promptFocusRequest,
|
|
217
|
+
promptRestoreRequest,
|
|
218
|
+
renderBlock,
|
|
219
|
+
}: ThreadViewProps): React.ReactNode {
|
|
220
|
+
const tokens = useComponentTheme(theme);
|
|
221
|
+
const source: LineSource = {
|
|
222
|
+
count: display.length,
|
|
223
|
+
// the surface hit-tests and paints in rendered text (inline markers concealed); a grid anchors on its raw source
|
|
224
|
+
textAt: (blockIndex) => renderedText(display[blockIndex]!),
|
|
225
|
+
annotatable: canAnnotateBlock ?? (() => true),
|
|
226
|
+
};
|
|
227
|
+
const scrollRef = useRef<ScrollBoxRenderable | null>(null);
|
|
228
|
+
const surface = useAnnotationSurface({
|
|
229
|
+
source,
|
|
230
|
+
session,
|
|
231
|
+
marks,
|
|
232
|
+
quickActions,
|
|
233
|
+
tokens,
|
|
234
|
+
observer,
|
|
235
|
+
resolved,
|
|
236
|
+
suspended,
|
|
237
|
+
onComposingChange,
|
|
238
|
+
onObserverBlocked,
|
|
239
|
+
onCursorChange,
|
|
240
|
+
focusedAnnotationId,
|
|
241
|
+
onFocusAnnotation,
|
|
242
|
+
onAnnotate,
|
|
243
|
+
onReply,
|
|
244
|
+
onUpdateAnnotation,
|
|
245
|
+
isAnnotationReadOnly,
|
|
246
|
+
annotationAction,
|
|
247
|
+
requestedBlock,
|
|
248
|
+
onInvoke,
|
|
249
|
+
isPromptBlock,
|
|
250
|
+
promptFocusRequest,
|
|
251
|
+
promptRestoreRequest,
|
|
252
|
+
dragViewport: () => scrollBoxDragViewport(scrollRef.current),
|
|
253
|
+
resolveAuthorLabel,
|
|
254
|
+
onNavCommand,
|
|
255
|
+
onExit,
|
|
256
|
+
});
|
|
257
|
+
const { palette, discussions } = surface;
|
|
258
|
+
const { height: terminalHeight } = useTerminalDimensions();
|
|
259
|
+
const viewWidth = useFrameMeasure(
|
|
260
|
+
() => scrollRef.current?.content?.width ?? 0,
|
|
261
|
+
(left, right) => left === right,
|
|
262
|
+
0,
|
|
263
|
+
);
|
|
264
|
+
const [codeSyntax, setCodeSyntax] = useState<CodeSyntaxState | null>(null);
|
|
265
|
+
|
|
266
|
+
useEffect(() => {
|
|
267
|
+
let active = true;
|
|
268
|
+
|
|
269
|
+
void highlightThreadCodeBlocks(display).then(
|
|
270
|
+
(byBlock) => {
|
|
271
|
+
if (active) setCodeSyntax({ display, byBlock });
|
|
272
|
+
},
|
|
273
|
+
(error) => {
|
|
274
|
+
if (active) console.error("thread code syntax highlighting failed", error);
|
|
275
|
+
},
|
|
276
|
+
);
|
|
277
|
+
|
|
278
|
+
return () => {
|
|
279
|
+
active = false;
|
|
280
|
+
};
|
|
281
|
+
}, [display]);
|
|
282
|
+
|
|
283
|
+
// only the viewport's blocks (plus an overscan) mount; a block is estimated at its wrapped-line
|
|
284
|
+
// count until its box, cards included, is measured. The initial viewport seeds the first render so a
|
|
285
|
+
// freshly mounted thread (a switch from a diff, another project) windows its first paint rather than
|
|
286
|
+
// building every block, and never flashes blank.
|
|
287
|
+
const estimateBlockRows = (index: number): number => {
|
|
288
|
+
const block = display[index];
|
|
289
|
+
|
|
290
|
+
if (!block) return 1;
|
|
291
|
+
const usable = viewWidth > 0 ? Math.max(1, viewWidth - 6) : 40;
|
|
292
|
+
const lines = Math.max(1, Math.ceil(displayText(block).length / usable));
|
|
293
|
+
|
|
294
|
+
return lines + (index === 0 ? 0 : 1);
|
|
295
|
+
};
|
|
296
|
+
const virtual = useTerminalVirtualizer({
|
|
297
|
+
scrollbox: scrollRef,
|
|
298
|
+
count: display.length,
|
|
299
|
+
estimateSize: estimateBlockRows,
|
|
300
|
+
overscan: OVERSCAN_BLOCKS,
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
useEffect(() => {
|
|
304
|
+
virtual.scrollToIndex(surface.revealBlockIndex, surface.compose ? "end" : "auto");
|
|
305
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
306
|
+
}, [surface.revealBlockIndex, surface.compose]);
|
|
307
|
+
|
|
308
|
+
interface LineContext {
|
|
309
|
+
blockIndex: number;
|
|
310
|
+
roleRuns: RenderedRun[];
|
|
311
|
+
line: VisualLine;
|
|
312
|
+
lineIndex: number;
|
|
313
|
+
ranges: MarkRange[];
|
|
314
|
+
marker: string;
|
|
315
|
+
baseFg: string;
|
|
316
|
+
baseAttributes: number;
|
|
317
|
+
syntaxSpans: readonly SyntaxSpan[];
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const paintedSpan = (
|
|
321
|
+
run: StyledRun,
|
|
322
|
+
runIndex: number,
|
|
323
|
+
baseFg: string,
|
|
324
|
+
baseAttributes: number,
|
|
325
|
+
) => (
|
|
326
|
+
<span
|
|
327
|
+
key={runIndex}
|
|
328
|
+
fg={
|
|
329
|
+
run.role === "plain" && run.syntaxGroup
|
|
330
|
+
? (colorForSyntaxGroup(run.syntaxGroup, tokens) ?? baseFg)
|
|
331
|
+
: roleForeground(run.role, baseFg, tokens)
|
|
332
|
+
}
|
|
333
|
+
bg={run.caretOnly ? palette.caretCell : run.marked ? palette.markBackdrop : undefined}
|
|
334
|
+
attributes={roleAttributes(run.role, baseAttributes) | (run.marked ? UNDERLINE : 0)}
|
|
335
|
+
>
|
|
336
|
+
{run.text}
|
|
337
|
+
</span>
|
|
338
|
+
);
|
|
339
|
+
|
|
340
|
+
const lineRowFor = (context: LineContext, hasComposer = false): React.ReactNode => {
|
|
341
|
+
const {
|
|
342
|
+
blockIndex,
|
|
343
|
+
roleRuns,
|
|
344
|
+
line,
|
|
345
|
+
lineIndex,
|
|
346
|
+
ranges,
|
|
347
|
+
marker,
|
|
348
|
+
baseFg,
|
|
349
|
+
baseAttributes,
|
|
350
|
+
syntaxSpans,
|
|
351
|
+
} = context;
|
|
352
|
+
|
|
353
|
+
// The prompt composer occupies its blank line instead of adding a row below it.
|
|
354
|
+
if (isPromptBlock?.(blockIndex) && hasComposer) return null;
|
|
355
|
+
|
|
356
|
+
return (
|
|
357
|
+
<box key={`line-${lineIndex}`} style={{ flexDirection: "row" }}>
|
|
358
|
+
<text selectable={false}>
|
|
359
|
+
<span fg={tokens.textDim}>{" "}</span>
|
|
360
|
+
<span fg={tokens.textDim}>{lineIndex === 0 ? marker : " ".repeat(marker.length)}</span>
|
|
361
|
+
</text>
|
|
362
|
+
<text
|
|
363
|
+
selectable={false}
|
|
364
|
+
style={{ flexGrow: 1, flexShrink: 1 }}
|
|
365
|
+
ref={surface.registerLine(blockIndex, lineIndex, line)}
|
|
366
|
+
onMouseDown={surface.onLineMouseDown}
|
|
367
|
+
>
|
|
368
|
+
{styledRunsFor(roleRuns, line, ranges, syntaxSpans).map((run, runIndex) =>
|
|
369
|
+
paintedSpan(run, runIndex, baseFg, baseAttributes),
|
|
370
|
+
)}
|
|
371
|
+
</text>
|
|
372
|
+
</box>
|
|
373
|
+
);
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
// an h1 or h2 sits over a dim rule; h3 gets weight only. The row is
|
|
377
|
+
// always present for those kinds (only its width tracks the measured viewport) so a late width
|
|
378
|
+
// measurement never adds a row and shifts the blocks below it out from under a pending click
|
|
379
|
+
const headingRule = (block: DisplayBlock): React.ReactNode => {
|
|
380
|
+
if (block.kind !== "h1" && block.kind !== "h2") return null;
|
|
381
|
+
const width = Math.max(1, (viewWidth > 6 ? viewWidth : 46) - 6);
|
|
382
|
+
|
|
383
|
+
return (
|
|
384
|
+
<box key="heading-rule" style={{ flexDirection: "row" }}>
|
|
385
|
+
<text selectable={false} fg={tokens.textDim}>
|
|
386
|
+
{" " + "─".repeat(width)}
|
|
387
|
+
</text>
|
|
388
|
+
</box>
|
|
389
|
+
);
|
|
390
|
+
};
|
|
391
|
+
|
|
392
|
+
const blockNodeFor = (blockIndex: number): React.ReactNode => {
|
|
393
|
+
const custom = renderBlock?.(blockIndex);
|
|
394
|
+
|
|
395
|
+
if (custom !== undefined)
|
|
396
|
+
return (
|
|
397
|
+
<box key={`custom-${blockIndex}`} ref={virtual.measureRef(blockIndex)}>
|
|
398
|
+
{custom}
|
|
399
|
+
</box>
|
|
400
|
+
);
|
|
401
|
+
const block = display[blockIndex]!;
|
|
402
|
+
const { baseFg, baseAttributes, marker } = blockStyle(block, tokens);
|
|
403
|
+
// list items of one list stay tight; every other block sits a blank row below its neighbour
|
|
404
|
+
const previous = display[blockIndex - 1];
|
|
405
|
+
const tight =
|
|
406
|
+
previous !== undefined &&
|
|
407
|
+
block.kind === previous.kind &&
|
|
408
|
+
(block.kind === "li" || block.kind === "oli");
|
|
409
|
+
// the leading gap rides inside the measured box (not marginTop, which sits outside it) so the
|
|
410
|
+
// virtualizer's spacer math counts it
|
|
411
|
+
const leadingGap =
|
|
412
|
+
blockIndex === 0 || tight ? null : <box key="lead-gap" style={{ height: 1 }} />;
|
|
413
|
+
const measuredBox = (children: React.ReactNode): React.ReactNode => (
|
|
414
|
+
<box
|
|
415
|
+
key={isPromptBlock?.(blockIndex) ? "prompt-block" : `discussion-block-${blockIndex}`}
|
|
416
|
+
id={`discussion-block-${blockIndex}`}
|
|
417
|
+
ref={virtual.measureRef(blockIndex)}
|
|
418
|
+
style={{ flexDirection: "column" }}
|
|
419
|
+
>
|
|
420
|
+
{leadingGap}
|
|
421
|
+
{children}
|
|
422
|
+
</box>
|
|
423
|
+
);
|
|
424
|
+
|
|
425
|
+
if (isGridKind(block.kind)) {
|
|
426
|
+
// a grid annotates as one unit: every row hit-tests to the same block span, a mark tints the grid,
|
|
427
|
+
// and the block's discussion cards slot under it, so annotations resolving into a grid stay visible
|
|
428
|
+
const gridLine: VisualLine = { start: 0, end: renderedText(block).length };
|
|
429
|
+
const marked = surface.rangesFor(blockIndex).some((range) => !range.caretOnly);
|
|
430
|
+
|
|
431
|
+
return measuredBox(
|
|
432
|
+
<>
|
|
433
|
+
<MarkdownGridBlock
|
|
434
|
+
block={block}
|
|
435
|
+
theme={tokens}
|
|
436
|
+
contentWidth={viewWidth > 0 ? viewWidth - 6 : 40}
|
|
437
|
+
marked={marked}
|
|
438
|
+
markBackdrop={palette.markBackdrop}
|
|
439
|
+
registerRow={(rowIndex) => surface.registerLine(blockIndex, rowIndex, gridLine)}
|
|
440
|
+
onRowMouseDown={surface.onLineMouseDown}
|
|
441
|
+
/>
|
|
442
|
+
{surface.cardsAfterLine(blockIndex, gridLine, true)}
|
|
443
|
+
</>,
|
|
444
|
+
);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
const roleRuns = renderedStyleRuns(block);
|
|
448
|
+
const ranges = surface.rangesFor(blockIndex);
|
|
449
|
+
const lines = wrapLines(renderedText(block), viewWidth > 0 ? viewWidth - marker.length - 6 : 0);
|
|
450
|
+
const syntaxSpans = syntaxSpansForBlock(block, blockIndex, display, codeSyntax);
|
|
451
|
+
const lineRows: React.ReactNode[] = [];
|
|
452
|
+
|
|
453
|
+
for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) {
|
|
454
|
+
const line = lines[lineIndex]!;
|
|
455
|
+
const isLastLine = lineIndex === lines.length - 1;
|
|
456
|
+
|
|
457
|
+
const cards = surface.cardsAfterLine(blockIndex, line, isLastLine);
|
|
458
|
+
|
|
459
|
+
lineRows.push(
|
|
460
|
+
lineRowFor(
|
|
461
|
+
{
|
|
462
|
+
blockIndex,
|
|
463
|
+
roleRuns,
|
|
464
|
+
line,
|
|
465
|
+
lineIndex,
|
|
466
|
+
ranges,
|
|
467
|
+
marker,
|
|
468
|
+
baseFg,
|
|
469
|
+
baseAttributes,
|
|
470
|
+
syntaxSpans,
|
|
471
|
+
},
|
|
472
|
+
cards.length > 0,
|
|
473
|
+
),
|
|
474
|
+
);
|
|
475
|
+
if (isLastLine) lineRows.push(headingRule(block));
|
|
476
|
+
lineRows.push(...cards);
|
|
477
|
+
if (cards.length > 0 && !isLastLine) {
|
|
478
|
+
lineRows.push(<box key={`gap-${lineIndex}`} style={{ height: 1 }} />);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// a code block announces its language first
|
|
483
|
+
const languageRow =
|
|
484
|
+
block.kind === "code" ? (
|
|
485
|
+
<text key="language" fg={tokens.textDim} selectable={false}>
|
|
486
|
+
{` ${block.work?.lang ?? block.base?.lang ?? "code"}`}
|
|
487
|
+
</text>
|
|
488
|
+
) : null;
|
|
489
|
+
|
|
490
|
+
return measuredBox(
|
|
491
|
+
<>
|
|
492
|
+
{languageRow}
|
|
493
|
+
{lineRows}
|
|
494
|
+
</>,
|
|
495
|
+
);
|
|
496
|
+
};
|
|
497
|
+
|
|
498
|
+
const virtualBlocks = (): React.ReactNode[] => {
|
|
499
|
+
const nodes: React.ReactNode[] = [];
|
|
500
|
+
const firstItem = virtual.items[0];
|
|
501
|
+
const lastItem = virtual.items.at(-1);
|
|
502
|
+
|
|
503
|
+
// the very first render has no measured viewport yet, so the virtualizer's window is empty; paint a
|
|
504
|
+
// viewport-worth from the top rather than blank or the whole document, then it takes over next frame
|
|
505
|
+
if (!firstItem || !lastItem) {
|
|
506
|
+
let mountedRows = 0;
|
|
507
|
+
|
|
508
|
+
for (let index = 0; index < display.length; index++) {
|
|
509
|
+
nodes.push(blockNodeFor(index));
|
|
510
|
+
mountedRows += estimateBlockRows(index);
|
|
511
|
+
if (mountedRows > terminalHeight + OVERSCAN_BLOCKS) break;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
return nodes;
|
|
515
|
+
}
|
|
516
|
+
if (firstItem.start > 0) {
|
|
517
|
+
nodes.push(<box key="spacer-above" style={{ height: firstItem.start }} />);
|
|
518
|
+
}
|
|
519
|
+
for (const item of virtual.items) nodes.push(blockNodeFor(item.index));
|
|
520
|
+
const below = virtual.totalSize - lastItem.end;
|
|
521
|
+
|
|
522
|
+
if (below > 0) nodes.push(<box key="spacer-below" style={{ height: below }} />);
|
|
523
|
+
|
|
524
|
+
return nodes;
|
|
525
|
+
};
|
|
526
|
+
|
|
527
|
+
return (
|
|
528
|
+
<box style={{ flexGrow: 1, flexDirection: "row" }} {...surface.rootMouseProps}>
|
|
529
|
+
<box style={{ flexGrow: 1, flexDirection: "column" }}>
|
|
530
|
+
{editOrphanCount > 0 ? (
|
|
531
|
+
<box style={{ height: 1, backgroundColor: palette.markBackdrop, paddingLeft: 2 }}>
|
|
532
|
+
<text fg={tokens.red}>
|
|
533
|
+
{`${editOrphanCount} annotation${editOrphanCount === 1 ? "" : "s"} no longer match - the passage was removed.`}
|
|
534
|
+
</text>
|
|
535
|
+
</box>
|
|
536
|
+
) : null}
|
|
537
|
+
{/* the rail draws this box's scrollbar past the dots, so the built-in one stays hidden */}
|
|
538
|
+
<scrollbox
|
|
539
|
+
ref={scrollRef}
|
|
540
|
+
style={{ flexGrow: 1, paddingTop: 1 }}
|
|
541
|
+
focused={false}
|
|
542
|
+
verticalScrollbarOptions={{ visible: false }}
|
|
543
|
+
>
|
|
544
|
+
{virtualBlocks()}
|
|
545
|
+
</scrollbox>
|
|
546
|
+
{suspended ? null : (
|
|
547
|
+
<NavModeHint navMode={surface.navMode} surface="thread" theme={tokens} />
|
|
548
|
+
)}
|
|
549
|
+
</box>
|
|
550
|
+
<DiscussionMarkerRail
|
|
551
|
+
discussions={discussions}
|
|
552
|
+
spanQuote={surface.spanQuote}
|
|
553
|
+
focusedKey={surface.focusedDiscussion}
|
|
554
|
+
onJump={surface.jumpToDiscussion}
|
|
555
|
+
scrollbox={scrollRef}
|
|
556
|
+
theme={theme}
|
|
557
|
+
/>
|
|
558
|
+
</box>
|
|
559
|
+
);
|
|
560
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mark geometry for an annotated line of text: which stretches are marked
|
|
3
|
+
* (a discussion span, a held selection) or carry the caret cell, the greedy
|
|
4
|
+
* word wrap into visual lines, and the printable-key test that starts a
|
|
5
|
+
* comment. Renderer-free, shared by the plan thread view and the diff sheet.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { KeyEvent } from "@opentui/core";
|
|
9
|
+
import { wordRanges } from "../annotations/thread-selection";
|
|
10
|
+
|
|
11
|
+
export interface MarkRange {
|
|
12
|
+
start: number;
|
|
13
|
+
end: number;
|
|
14
|
+
caretOnly?: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface Run {
|
|
18
|
+
text: string;
|
|
19
|
+
marked: boolean;
|
|
20
|
+
caretOnly: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Split a block's text into plain/marked runs from the union of ranges. */
|
|
24
|
+
export function runsFor(text: string, ranges: MarkRange[]): Run[] {
|
|
25
|
+
const cuts = new Set<number>([0, text.length]);
|
|
26
|
+
|
|
27
|
+
for (const range of ranges) {
|
|
28
|
+
cuts.add(Math.max(0, range.start));
|
|
29
|
+
cuts.add(Math.min(text.length, range.end));
|
|
30
|
+
}
|
|
31
|
+
const edges = [...cuts].toSorted((left, right) => left - right);
|
|
32
|
+
const runs: Run[] = [];
|
|
33
|
+
|
|
34
|
+
for (let index = 0; index < edges.length - 1; index++) {
|
|
35
|
+
const start = edges[index]!;
|
|
36
|
+
const end = edges[index + 1]!;
|
|
37
|
+
|
|
38
|
+
if (end <= start) continue;
|
|
39
|
+
const covering = ranges.filter((range) => range.start <= start && end <= range.end);
|
|
40
|
+
|
|
41
|
+
// the caret cell shows through a mark, so the head of a selection is visible
|
|
42
|
+
runs.push({
|
|
43
|
+
text: text.slice(start, end),
|
|
44
|
+
marked: covering.some((range) => range.caretOnly !== true),
|
|
45
|
+
caretOnly: covering.some((range) => range.caretOnly === true),
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return runs.length > 0 ? runs : [{ text, marked: false, caretOnly: false }];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** A visual line of a block: the char range of the block's text it shows. */
|
|
53
|
+
export interface VisualLine {
|
|
54
|
+
start: number;
|
|
55
|
+
end: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Greedy word wrap into visual-line char ranges, so a comment card can slot
|
|
60
|
+
* in directly below the line its mark ends on (mid-paragraph when needed).
|
|
61
|
+
*/
|
|
62
|
+
export function wrapLines(text: string, width: number): VisualLine[] {
|
|
63
|
+
const lines: VisualLine[] = [];
|
|
64
|
+
let segmentStart = 0;
|
|
65
|
+
|
|
66
|
+
// hard breaks first: every visual line must be exactly one terminal row,
|
|
67
|
+
// or hit-testing and the mark geometry drift from what is painted
|
|
68
|
+
for (const segment of text.split("\n")) {
|
|
69
|
+
const segmentEnd = segmentStart + segment.length;
|
|
70
|
+
|
|
71
|
+
if (width <= 0 || segment.length <= width) {
|
|
72
|
+
lines.push({ start: segmentStart, end: segmentEnd });
|
|
73
|
+
} else {
|
|
74
|
+
// greedy word wrap inside the segment; the first line keeps its
|
|
75
|
+
// leading indentation, continuation lines start at a word
|
|
76
|
+
let lineStart = segmentStart;
|
|
77
|
+
let lineEnd = segmentStart;
|
|
78
|
+
|
|
79
|
+
for (const word of wordRanges(segment)) {
|
|
80
|
+
const wordStart = segmentStart + word.start;
|
|
81
|
+
const wordEnd = segmentStart + word.end;
|
|
82
|
+
|
|
83
|
+
if (wordEnd - lineStart > width && lineEnd > lineStart) {
|
|
84
|
+
lines.push({ start: lineStart, end: lineEnd });
|
|
85
|
+
lineStart = wordStart;
|
|
86
|
+
}
|
|
87
|
+
// a token wider than the line (a long identifier, a URL) breaks at the cell edge, so
|
|
88
|
+
// every visual line stays one terminal row and nothing is clipped away
|
|
89
|
+
while (wordEnd - lineStart > width) {
|
|
90
|
+
lines.push({ start: lineStart, end: lineStart + width });
|
|
91
|
+
lineStart += width;
|
|
92
|
+
}
|
|
93
|
+
lineEnd = wordEnd;
|
|
94
|
+
}
|
|
95
|
+
lines.push({ start: lineStart, end: Math.max(lineEnd, lineStart) });
|
|
96
|
+
}
|
|
97
|
+
segmentStart = segmentEnd + 1;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return lines;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The block's mark ranges clipped to one visual line, in that line's coordinates. */
|
|
104
|
+
export function lineMarkRanges(ranges: MarkRange[], line: VisualLine): MarkRange[] {
|
|
105
|
+
return ranges
|
|
106
|
+
.map((range) => ({
|
|
107
|
+
start: Math.max(range.start, line.start) - line.start,
|
|
108
|
+
end: Math.min(range.end, line.end) - line.start,
|
|
109
|
+
caretOnly: range.caretOnly,
|
|
110
|
+
}))
|
|
111
|
+
.filter((range) => range.end > range.start);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Printable input that should reach a composer: one key, or a whole pasted run in one event. */
|
|
115
|
+
export function printableSequence(key: KeyEvent): string | null {
|
|
116
|
+
if (!key.sequence || key.ctrl || key.meta) return null;
|
|
117
|
+
// reject any run carrying a control byte, so escape sequences and \r\t\b never seed a draft
|
|
118
|
+
for (const character of key.sequence) if (character < " ") return null;
|
|
119
|
+
|
|
120
|
+
return key.sequence.length > 0 ? key.sequence : null;
|
|
121
|
+
}
|