@cueloop/client 0.1.0-alpha.0 → 0.1.0-alpha.100
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 +41 -27
- 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 +364 -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 +1238 -0
- package/src/app/App.appearance.test.tsx +53 -0
- package/src/app/App.autoclose.test.tsx +144 -0
- 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 +12 -0
- package/src/app/App.toast.test.tsx +124 -0
- package/src/app/App.tsx +1650 -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.stories.tsx +121 -0
- package/src/integrations/components/ShareDialog.tsx +550 -0
- package/src/integrations/components/share-dialog-store.ts +55 -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 +829 -0
- package/src/thread/components/AgentThreadPane.tsx +367 -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 +2018 -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 +111 -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 -80
- package/src/App.readonly.test.tsx +0 -148
- package/src/App.test.tsx +0 -217
- package/src/App.tsx +0 -0
- package/src/config.test.ts +0 -116
- package/src/config.ts +0 -113
- package/src/editor.ts +0 -34
- package/src/run.ts +0 -28
- package/src/serve.ts +0 -75
- 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 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/** Detects an image or other binary paste so a composer shows an [Image #n] placeholder instead of dumping raw bytes into the draft. */
|
|
2
|
+
|
|
3
|
+
const IMAGE_MAGIC_SIGNATURES: number[][] = [
|
|
4
|
+
[0x89, 0x50, 0x4e, 0x47], // PNG
|
|
5
|
+
[0xff, 0xd8, 0xff], // JPEG
|
|
6
|
+
[0x47, 0x49, 0x46, 0x38], // GIF87a / GIF89a
|
|
7
|
+
];
|
|
8
|
+
|
|
9
|
+
const CONTROL_RATIO_THRESHOLD = 0.15;
|
|
10
|
+
const PASTE_DECODER = new TextDecoder("utf-8", { fatal: false });
|
|
11
|
+
|
|
12
|
+
function hasImageMagic(bytes: Uint8Array): boolean {
|
|
13
|
+
for (const signature of IMAGE_MAGIC_SIGNATURES) {
|
|
14
|
+
if (signature.every((byte, index) => bytes[index] === byte)) return true;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const isRiffWebp =
|
|
18
|
+
bytes.length >= 12 &&
|
|
19
|
+
bytes[0] === 0x52 && // R
|
|
20
|
+
bytes[1] === 0x49 && // I
|
|
21
|
+
bytes[2] === 0x46 && // F
|
|
22
|
+
bytes[3] === 0x46 && // F
|
|
23
|
+
bytes[8] === 0x57 && // W
|
|
24
|
+
bytes[9] === 0x45 && // E
|
|
25
|
+
bytes[10] === 0x42 && // B
|
|
26
|
+
bytes[11] === 0x50; // P
|
|
27
|
+
|
|
28
|
+
return isRiffWebp;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function isControlCodePoint(codePoint: number): boolean {
|
|
32
|
+
if (codePoint === 0x09 || codePoint === 0x0a || codePoint === 0x0d) return false;
|
|
33
|
+
|
|
34
|
+
return codePoint < 0x20 || codePoint === 0x7f;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** True when a paste is an image or other non-text binary payload and should collapse to a placeholder. */
|
|
38
|
+
export function looksLikeBinaryPaste(bytes: Uint8Array): boolean {
|
|
39
|
+
if (bytes.length === 0) return false;
|
|
40
|
+
if (hasImageMagic(bytes)) return true;
|
|
41
|
+
|
|
42
|
+
const decoded = PASTE_DECODER.decode(bytes);
|
|
43
|
+
let controlCount = 0;
|
|
44
|
+
let total = 0;
|
|
45
|
+
|
|
46
|
+
for (const character of decoded) {
|
|
47
|
+
total += 1;
|
|
48
|
+
const codePoint = character.codePointAt(0) ?? 0;
|
|
49
|
+
|
|
50
|
+
if (codePoint === 0 || codePoint === 0xfffd) return true;
|
|
51
|
+
if (isControlCodePoint(codePoint)) controlCount += 1;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return total > 0 && controlCount / total > CONTROL_RATIO_THRESHOLD;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The draft placeholder for the nth image pasted into a composer: [Image #1], [Image #2], … */
|
|
58
|
+
export function imagePlaceholder(index: number): string {
|
|
59
|
+
return `[Image #${index}]`;
|
|
60
|
+
}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Publish a thread as a share: pack it, stream it to the gateway's
|
|
3
|
+
* `share` user over SSH, and copy the returned `ssh p_…@host` line to the
|
|
4
|
+
* clipboard. The planner holds no key, so all this side does is upload; the
|
|
5
|
+
* gateway seals and stores. Used by both `cueloop share` and the in-TUI
|
|
6
|
+
* Share button, so the two share exactly the same path.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
DEFAULT_SHARE_HOST,
|
|
11
|
+
DEFAULT_SHARE_PORT,
|
|
12
|
+
SHARE_UPLOAD_USER,
|
|
13
|
+
packSessionBlob,
|
|
14
|
+
} from "@cueloop/daemon/share-blob";
|
|
15
|
+
import {
|
|
16
|
+
removalEntries,
|
|
17
|
+
sharePublicationView,
|
|
18
|
+
type Annotation,
|
|
19
|
+
type SharePolicy,
|
|
20
|
+
type ShareLink,
|
|
21
|
+
type Thread,
|
|
22
|
+
} from "@cueloop/schema";
|
|
23
|
+
import { ThreadRecordSchema } from "@cueloop/daemon/validate";
|
|
24
|
+
import type { SharedMerge } from "@cueloop/daemon/client";
|
|
25
|
+
import * as v from "valibot";
|
|
26
|
+
import { copyToClipboard } from "./clipboard";
|
|
27
|
+
|
|
28
|
+
export interface ShareTarget {
|
|
29
|
+
host?: string;
|
|
30
|
+
port?: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface ShareResult {
|
|
34
|
+
line: string;
|
|
35
|
+
copied: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The `p_…` share id inside a `ssh p_…@host` line, or undefined. */
|
|
39
|
+
export function shareIdFromLine(line: string): string | undefined {
|
|
40
|
+
return line.match(/^ssh (\S+)@/)?.[1];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The paste line for a share id: `ssh p_…@host`. Used to copy an existing link. */
|
|
44
|
+
export function formatShareLine(shareId: string, target: ShareTarget = {}): string {
|
|
45
|
+
return `ssh ${shareId}@${target.host ?? DEFAULT_SHARE_HOST}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Upload the session to the gateway and copy the resulting ssh line. */
|
|
49
|
+
export async function publishShare(
|
|
50
|
+
session: Thread,
|
|
51
|
+
target: ShareTarget = {},
|
|
52
|
+
publication: SharePolicy & Pick<ShareLink, "shareBranch"> = {
|
|
53
|
+
requireAuth: false,
|
|
54
|
+
allowlist: [],
|
|
55
|
+
},
|
|
56
|
+
): Promise<ShareResult> {
|
|
57
|
+
const { stdout, stderr, code } = await runShareSsh(
|
|
58
|
+
"cueloop-share",
|
|
59
|
+
packSessionBlob(sharePublicationView(session, publication)),
|
|
60
|
+
target,
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
if (code !== 0)
|
|
64
|
+
throw new Error(`gateway upload failed: ${stderr.trim() || `ssh exited ${code}`}`);
|
|
65
|
+
const line = stdout.trim();
|
|
66
|
+
|
|
67
|
+
if (!line.startsWith("ssh ")) throw new Error(`unexpected gateway reply: ${line || "(empty)"}`);
|
|
68
|
+
const copied = await copyToClipboard(line);
|
|
69
|
+
|
|
70
|
+
return { line, copied };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Pull a share's current session back from the gateway. The gateway lets only
|
|
75
|
+
* the fingerprint that uploaded it through, so collaborator notes reach the
|
|
76
|
+
* planner without exposing the master key.
|
|
77
|
+
*/
|
|
78
|
+
export async function pullShare(shareId: string, target: ShareTarget = {}): Promise<Thread> {
|
|
79
|
+
const { stdout, stderr, code } = await runShareSsh("cueloop-pull", Buffer.from(shareId), target);
|
|
80
|
+
|
|
81
|
+
if (code !== 0) throw new Error(`gateway pull failed: ${stderr.trim() || `ssh exited ${code}`}`);
|
|
82
|
+
|
|
83
|
+
return v.parse(ThreadRecordSchema, JSON.parse(stdout));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** A share's collaborator notes: the ones a viewer authored (author stamped). */
|
|
87
|
+
export function collaboratorAnnotations(session: Thread): Annotation[] {
|
|
88
|
+
return session.annotations.filter((annotation) => annotation.author);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* What a pulled share hands the local merge: collaborators' notes, the
|
|
93
|
+
* participant registry, and the removals the share recorded, by entry id.
|
|
94
|
+
*/
|
|
95
|
+
export function mergeFromShare(remote: Thread): SharedMerge {
|
|
96
|
+
const merge: SharedMerge = { annotations: collaboratorAnnotations(remote) };
|
|
97
|
+
|
|
98
|
+
if (remote.shares?.length === 1) merge.shareId = remote.shares[0]!.id;
|
|
99
|
+
|
|
100
|
+
if (remote.participants) merge.participants = remote.participants;
|
|
101
|
+
if (remote.history) {
|
|
102
|
+
merge.removals = removalEntries(remote.history).map((entry) => ({
|
|
103
|
+
id: entry.id,
|
|
104
|
+
annotationId: entry.annotationId,
|
|
105
|
+
createdAt: entry.createdAt,
|
|
106
|
+
}));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return merge;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Mirror the planner's own annotations up into the share, so collaborators see
|
|
114
|
+
* them on their next refresh. Owner-gated at the gateway; the notes stay
|
|
115
|
+
* unauthored (the planner's), unioned by id.
|
|
116
|
+
*/
|
|
117
|
+
export async function pushShare(
|
|
118
|
+
shareId: string,
|
|
119
|
+
annotations: Array<Omit<Annotation, "createdAt">>,
|
|
120
|
+
policy?: SharePolicy,
|
|
121
|
+
target: ShareTarget = {},
|
|
122
|
+
): Promise<void> {
|
|
123
|
+
const payload = policy ? { shareId, annotations, policy } : { shareId, annotations };
|
|
124
|
+
const { stderr, code } = await runShareSsh(
|
|
125
|
+
"cueloop-push",
|
|
126
|
+
Buffer.from(JSON.stringify(payload)),
|
|
127
|
+
target,
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
if (code !== 0) throw new Error(`gateway push failed: ${stderr.trim() || `ssh exited ${code}`}`);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Revoke a share: the gateway deletes the blob so its link stops resolving. Owner-gated; idempotent. */
|
|
134
|
+
export async function revokeShare(shareId: string, target: ShareTarget = {}): Promise<void> {
|
|
135
|
+
const { stderr, code } = await runShareSsh("cueloop-revoke", Buffer.from(shareId), target);
|
|
136
|
+
|
|
137
|
+
if (code !== 0)
|
|
138
|
+
throw new Error(`gateway revoke failed: ${stderr.trim() || `ssh exited ${code}`}`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export interface ShareWatchHandlers {
|
|
142
|
+
onSession: (session: Thread) => void;
|
|
143
|
+
onClose: (reason: string) => void;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const WatchFrameSchema = v.variant("type", [
|
|
147
|
+
v.object({ type: v.literal("ready") }),
|
|
148
|
+
v.object({ type: v.literal("ping") }),
|
|
149
|
+
v.object({ type: v.literal("session"), session: ThreadRecordSchema }),
|
|
150
|
+
]);
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Follow a share live: one long-lived `cueloop-watch` ssh stream that carries
|
|
154
|
+
* a JSON line per change. Owner-gated at the gateway. Returns a stop handle.
|
|
155
|
+
*/
|
|
156
|
+
export function watchShare(
|
|
157
|
+
shareId: string,
|
|
158
|
+
handlers: ShareWatchHandlers,
|
|
159
|
+
target: ShareTarget = {},
|
|
160
|
+
): () => void {
|
|
161
|
+
const proc = Bun.spawn(shareSshCommand("cueloop-watch", target), {
|
|
162
|
+
stdin: "pipe",
|
|
163
|
+
stdout: "pipe",
|
|
164
|
+
stderr: "pipe",
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
proc.stdin.write(shareId);
|
|
168
|
+
proc.stdin.end();
|
|
169
|
+
void readLines(proc.stdout, (line) => {
|
|
170
|
+
const frame = v.safeParse(WatchFrameSchema, JSON.parse(line));
|
|
171
|
+
|
|
172
|
+
if (frame.success && frame.output.type === "session") handlers.onSession(frame.output.session);
|
|
173
|
+
}).catch(() => {});
|
|
174
|
+
void Promise.all([proc.exited, new Response(proc.stderr).text()]).then(([code, stderr]) =>
|
|
175
|
+
handlers.onClose(stderr.trim() || `ssh exited ${code}`),
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
return () => proc.kill();
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Feed each complete line of a byte stream to `onLine`; a torn line is dropped. */
|
|
182
|
+
async function readLines(
|
|
183
|
+
stream: ReadableStream<Uint8Array>,
|
|
184
|
+
onLine: (line: string) => void,
|
|
185
|
+
): Promise<void> {
|
|
186
|
+
const decoder = new TextDecoder();
|
|
187
|
+
let pending = "";
|
|
188
|
+
|
|
189
|
+
for await (const chunk of stream) {
|
|
190
|
+
pending += decoder.decode(chunk, { stream: true });
|
|
191
|
+
const lines = pending.split("\n");
|
|
192
|
+
|
|
193
|
+
pending = lines.pop() ?? "";
|
|
194
|
+
for (const line of lines) {
|
|
195
|
+
if (line.trim() === "") continue;
|
|
196
|
+
try {
|
|
197
|
+
onLine(line);
|
|
198
|
+
} catch {
|
|
199
|
+
// one bad line never ends the stream
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function shareSshCommand(command: string, target: ShareTarget): string[] {
|
|
206
|
+
const host = target.host ?? DEFAULT_SHARE_HOST;
|
|
207
|
+
const port = target.port ?? DEFAULT_SHARE_PORT;
|
|
208
|
+
|
|
209
|
+
return [
|
|
210
|
+
"ssh",
|
|
211
|
+
"-p",
|
|
212
|
+
String(port),
|
|
213
|
+
"-o",
|
|
214
|
+
"StrictHostKeyChecking=accept-new",
|
|
215
|
+
`${SHARE_UPLOAD_USER}@${host}`,
|
|
216
|
+
command,
|
|
217
|
+
];
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Run one `share@host:port` ssh command with `stdin`; hand back its streams and exit code. */
|
|
221
|
+
async function runShareSsh(
|
|
222
|
+
command: string,
|
|
223
|
+
stdin: Buffer,
|
|
224
|
+
target: ShareTarget,
|
|
225
|
+
): Promise<{ stdout: string; stderr: string; code: number }> {
|
|
226
|
+
const proc = Bun.spawn(shareSshCommand(command, target), {
|
|
227
|
+
stdin,
|
|
228
|
+
stdout: "pipe",
|
|
229
|
+
stderr: "pipe",
|
|
230
|
+
});
|
|
231
|
+
const [stdout, stderr, code] = await Promise.all([
|
|
232
|
+
new Response(proc.stdout).text(),
|
|
233
|
+
new Response(proc.stderr).text(),
|
|
234
|
+
proc.exited,
|
|
235
|
+
]);
|
|
236
|
+
|
|
237
|
+
return { stdout, stderr, code };
|
|
238
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { DARK } from "../../appearance/theme";
|
|
3
|
+
import type { Story, StoryMeta } from "../../stories/story";
|
|
4
|
+
import { CompletionOverlay } from "./CompletionOverlay";
|
|
5
|
+
|
|
6
|
+
export const meta: StoryMeta = { title: "Overlays/CompletionOverlay" };
|
|
7
|
+
|
|
8
|
+
const noop = () => {};
|
|
9
|
+
|
|
10
|
+
export const ApprovedPrompt: Story = {
|
|
11
|
+
render: () => (
|
|
12
|
+
<CompletionOverlay
|
|
13
|
+
message="approved"
|
|
14
|
+
completion={{ phase: "prompt" }}
|
|
15
|
+
status=""
|
|
16
|
+
onClose={noop}
|
|
17
|
+
onBackToPlan={noop}
|
|
18
|
+
onAlways={noop}
|
|
19
|
+
/>
|
|
20
|
+
),
|
|
21
|
+
expectedColors: [DARK.green],
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export const FeedbackCounting: Story = {
|
|
25
|
+
render: () => (
|
|
26
|
+
<CompletionOverlay
|
|
27
|
+
message="changes_requested"
|
|
28
|
+
completion={{ phase: "counting", remaining: 3 }}
|
|
29
|
+
status="exported to vault/cueloop/plan.md"
|
|
30
|
+
returnsTo="agent/worker-3"
|
|
31
|
+
onClose={noop}
|
|
32
|
+
onBackToPlan={noop}
|
|
33
|
+
onAlways={noop}
|
|
34
|
+
/>
|
|
35
|
+
),
|
|
36
|
+
expectedColors: [DARK.accent],
|
|
37
|
+
};
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Post-submit hand-back overlay: the agent is unblocked, the reviewer either
|
|
3
|
+
* closes now, opts into auto-close, or dismisses back to the resolved view.
|
|
4
|
+
* The latest status line (e.g. the vault-export path) stays visible here.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import React from "react";
|
|
8
|
+
import type { MessageOutcome } from "@cueloop/schema";
|
|
9
|
+
import type { Theme } from "../../appearance/theme";
|
|
10
|
+
import { useComponentTheme } from "../../appearance/components/theme-context";
|
|
11
|
+
import { Toolbar } from "../../ui/components/primitives/Toolbar";
|
|
12
|
+
import { Button } from "../../ui/components/primitives/Button";
|
|
13
|
+
|
|
14
|
+
export interface CompletionOverlayProps {
|
|
15
|
+
message: MessageOutcome;
|
|
16
|
+
completion: { phase: "prompt" } | { phase: "counting"; remaining: number };
|
|
17
|
+
status: string;
|
|
18
|
+
returnsTo?: string;
|
|
19
|
+
onClose: () => void;
|
|
20
|
+
onBackToPlan: () => void;
|
|
21
|
+
onAlways: () => void;
|
|
22
|
+
theme?: Theme;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function CompletionOverlay({
|
|
26
|
+
message,
|
|
27
|
+
completion,
|
|
28
|
+
status,
|
|
29
|
+
returnsTo,
|
|
30
|
+
onClose,
|
|
31
|
+
onBackToPlan,
|
|
32
|
+
onAlways,
|
|
33
|
+
theme,
|
|
34
|
+
}: CompletionOverlayProps): React.ReactNode {
|
|
35
|
+
const tokens = useComponentTheme(theme);
|
|
36
|
+
const approved = message === "approved";
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<box
|
|
40
|
+
style={{
|
|
41
|
+
width: "100%",
|
|
42
|
+
height: "100%",
|
|
43
|
+
backgroundColor: tokens.background,
|
|
44
|
+
flexDirection: "column",
|
|
45
|
+
justifyContent: "center",
|
|
46
|
+
alignItems: "center",
|
|
47
|
+
}}
|
|
48
|
+
>
|
|
49
|
+
<text fg={approved ? tokens.green : tokens.accent}>
|
|
50
|
+
{approved ? "review approved" : "feedback sent"}
|
|
51
|
+
</text>
|
|
52
|
+
<text> </text>
|
|
53
|
+
<text fg={tokens.text}>
|
|
54
|
+
The agent has your {approved ? "approval" : "feedback"} and is unblocked.
|
|
55
|
+
</text>
|
|
56
|
+
{returnsTo ? <text fg={tokens.textDim}>returning to {returnsTo} on close</text> : null}
|
|
57
|
+
{status ? <text fg={tokens.textDim}>{status}</text> : null}
|
|
58
|
+
{completion.phase === "counting" ? (
|
|
59
|
+
<text fg={tokens.textDim}>closing in {completion.remaining}s</text>
|
|
60
|
+
) : null}
|
|
61
|
+
<text> </text>
|
|
62
|
+
<Toolbar>
|
|
63
|
+
<Button variant="solid" marginRight={2} onPress={onClose} theme={theme}>
|
|
64
|
+
{" close "}
|
|
65
|
+
</Button>
|
|
66
|
+
<Button marginRight={2} onPress={onBackToPlan} theme={theme}>
|
|
67
|
+
{" back to plan "}
|
|
68
|
+
</Button>
|
|
69
|
+
<Button onPress={onAlways} theme={theme}>
|
|
70
|
+
{" always "}
|
|
71
|
+
</Button>
|
|
72
|
+
</Toolbar>
|
|
73
|
+
</box>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { DARK } from "../../appearance/theme";
|
|
3
|
+
import type { Story, StoryMeta } from "../../stories/story";
|
|
4
|
+
import { HandleChipsInput } from "./HandleChipsInput";
|
|
5
|
+
|
|
6
|
+
export const meta: StoryMeta = { title: "Inputs/HandleChips" };
|
|
7
|
+
|
|
8
|
+
export const WithHandles: Story = {
|
|
9
|
+
render: () => (
|
|
10
|
+
<box style={{ padding: 1, backgroundColor: DARK.elevated }}>
|
|
11
|
+
<HandleChipsInput logins={["octocat", "hubot"]} onChange={() => {}} theme={DARK} />
|
|
12
|
+
</box>
|
|
13
|
+
),
|
|
14
|
+
size: { width: 48, height: 6 },
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export const Empty: Story = {
|
|
18
|
+
render: () => (
|
|
19
|
+
<box style={{ padding: 1, backgroundColor: DARK.elevated }}>
|
|
20
|
+
<HandleChipsInput logins={[]} onChange={() => {}} theme={DARK} />
|
|
21
|
+
</box>
|
|
22
|
+
),
|
|
23
|
+
size: { width: 48, height: 6 },
|
|
24
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/** Backspace on the empty field pulls the last chip back in to edit, rather than deleting it. */
|
|
2
|
+
|
|
3
|
+
import React, { useState } from "react";
|
|
4
|
+
import { describe, expect, test } from "bun:test";
|
|
5
|
+
import { testRender } from "@opentui/react/test-utils";
|
|
6
|
+
import { DARK } from "../../appearance/theme";
|
|
7
|
+
import { press, settle } from "../../testing/test-support";
|
|
8
|
+
import { HandleChipsInput } from "./HandleChipsInput";
|
|
9
|
+
|
|
10
|
+
/** A controlled host so onChange edits flow back into the rendered list, like the wizard. */
|
|
11
|
+
function Harness({ initial }: { initial: string[] }): React.ReactNode {
|
|
12
|
+
const [logins, setLogins] = useState(initial);
|
|
13
|
+
|
|
14
|
+
return <HandleChipsInput logins={logins} onChange={setLogins} theme={DARK} />;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe("HandleChipsInput", () => {
|
|
18
|
+
test("backspace on the empty field restores the last chip as editable text", async () => {
|
|
19
|
+
// Arrange - one committed chip and an empty input showing its placeholder
|
|
20
|
+
const setup = await testRender(<Harness initial={["octocat"]} />, { width: 48, height: 6 });
|
|
21
|
+
|
|
22
|
+
await settle(setup);
|
|
23
|
+
expect(setup.captureCharFrame()).toContain("handle");
|
|
24
|
+
|
|
25
|
+
// Act - backspace with the input empty
|
|
26
|
+
await press(setup, "backspace");
|
|
27
|
+
|
|
28
|
+
// Assert - the handle is back in the field (placeholder gone), not deleted
|
|
29
|
+
const frame = setup.captureCharFrame();
|
|
30
|
+
|
|
31
|
+
expect(frame).toContain("octocat");
|
|
32
|
+
expect(frame).not.toContain("handle");
|
|
33
|
+
setup.renderer.destroy();
|
|
34
|
+
});
|
|
35
|
+
});
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A tag-style input for GitHub logins: each added handle is an inline chip with
|
|
3
|
+
* a hover × to remove, and a trailing one-line field where enter adds the next
|
|
4
|
+
* and backspace on an empty field drops the last. Controlled - the caller owns
|
|
5
|
+
* the list and receives every change.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import React, { useEffect, useRef, useState } from "react";
|
|
9
|
+
import { useKeyboard } from "@opentui/react";
|
|
10
|
+
import type { KeyBinding, TextareaRenderable } from "@opentui/core";
|
|
11
|
+
import type { Theme } from "../../appearance/theme";
|
|
12
|
+
import { useComponentTheme } from "../../appearance/components/theme-context";
|
|
13
|
+
import { NERD } from "../../ui/components/primitives/icons";
|
|
14
|
+
|
|
15
|
+
// enter adds the handle rather than inserting a newline into the one-line field
|
|
16
|
+
const ADD_KEY_BINDINGS: KeyBinding[] = [{ name: "return", action: "submit" }];
|
|
17
|
+
|
|
18
|
+
function HandleChip({
|
|
19
|
+
login,
|
|
20
|
+
onRemove,
|
|
21
|
+
tokens,
|
|
22
|
+
}: {
|
|
23
|
+
login: string;
|
|
24
|
+
onRemove: () => void;
|
|
25
|
+
tokens: Theme;
|
|
26
|
+
}): React.ReactNode {
|
|
27
|
+
const [hovered, setHovered] = useState(false);
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<box
|
|
31
|
+
onMouseOver={() => setHovered(true)}
|
|
32
|
+
onMouseOut={() => setHovered(false)}
|
|
33
|
+
style={{
|
|
34
|
+
flexDirection: "row",
|
|
35
|
+
flexShrink: 0,
|
|
36
|
+
marginRight: 2,
|
|
37
|
+
paddingLeft: 1,
|
|
38
|
+
backgroundColor: tokens.border,
|
|
39
|
+
}}
|
|
40
|
+
>
|
|
41
|
+
<text fg={tokens.text} style={{ wrapMode: "none" }}>{`@${login}`}</text>
|
|
42
|
+
<box
|
|
43
|
+
onMouseUp={(event) => {
|
|
44
|
+
event.stopPropagation();
|
|
45
|
+
onRemove();
|
|
46
|
+
}}
|
|
47
|
+
style={{ paddingLeft: 1, paddingRight: 1 }}
|
|
48
|
+
>
|
|
49
|
+
<text fg={tokens.textDim}>{hovered ? NERD.close : " "}</text>
|
|
50
|
+
</box>
|
|
51
|
+
</box>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface HandleChipsInputProps {
|
|
56
|
+
logins: string[];
|
|
57
|
+
onChange: (logins: string[]) => void;
|
|
58
|
+
focused?: boolean;
|
|
59
|
+
onEmptyBackspace?: () => void;
|
|
60
|
+
theme?: Theme;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function HandleChipsInput({
|
|
64
|
+
logins,
|
|
65
|
+
onChange,
|
|
66
|
+
focused = true,
|
|
67
|
+
onEmptyBackspace,
|
|
68
|
+
theme,
|
|
69
|
+
}: HandleChipsInputProps): React.ReactNode {
|
|
70
|
+
const tokens = useComponentTheme(theme);
|
|
71
|
+
const inputRef = useRef<TextareaRenderable | null>(null);
|
|
72
|
+
// the text the current field generation mounts with; a remount reseeds it
|
|
73
|
+
const [seed, setSeed] = useState("");
|
|
74
|
+
const [inputGeneration, setInputGeneration] = useState(0);
|
|
75
|
+
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
const input = inputRef.current;
|
|
78
|
+
|
|
79
|
+
if (!input || !focused) return;
|
|
80
|
+
input.focus();
|
|
81
|
+
input.cursorOffset = seed.length; // land the caret at the end of the seeded text
|
|
82
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
83
|
+
}, [inputGeneration, focused]);
|
|
84
|
+
|
|
85
|
+
const reseed = (text: string): void => {
|
|
86
|
+
setSeed(text);
|
|
87
|
+
setInputGeneration((generation) => generation + 1);
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const addDraft = (): void => {
|
|
91
|
+
const handle = (inputRef.current?.plainText ?? "").trim().replace(/^@/, "").toLowerCase();
|
|
92
|
+
|
|
93
|
+
if (handle && !logins.includes(handle)) onChange([...logins, handle]);
|
|
94
|
+
reseed("");
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
useKeyboard((key) => {
|
|
98
|
+
if (!focused) return;
|
|
99
|
+
if (key.name !== "backspace" || (inputRef.current?.plainText ?? "").length > 0) return;
|
|
100
|
+
// backspace on an empty field pulls the last chip back in to edit; with none left, collapse
|
|
101
|
+
if (logins.length > 0) {
|
|
102
|
+
const last = logins[logins.length - 1]!;
|
|
103
|
+
|
|
104
|
+
onChange(logins.slice(0, -1));
|
|
105
|
+
reseed(last);
|
|
106
|
+
} else {
|
|
107
|
+
onEmptyBackspace?.();
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
return (
|
|
112
|
+
<box style={{ flexDirection: "row", flexWrap: "wrap", alignItems: "center" }}>
|
|
113
|
+
{logins.map((login) => (
|
|
114
|
+
<HandleChip
|
|
115
|
+
key={login}
|
|
116
|
+
login={login}
|
|
117
|
+
onRemove={() => onChange(logins.filter((entry) => entry !== login))}
|
|
118
|
+
tokens={tokens}
|
|
119
|
+
/>
|
|
120
|
+
))}
|
|
121
|
+
<box style={{ flexDirection: "row", flexShrink: 0 }}>
|
|
122
|
+
<text fg={tokens.textDim}>{"@"}</text>
|
|
123
|
+
<textarea
|
|
124
|
+
key={inputGeneration}
|
|
125
|
+
ref={inputRef}
|
|
126
|
+
focused
|
|
127
|
+
initialValue={seed}
|
|
128
|
+
placeholder="handle"
|
|
129
|
+
keyBindings={ADD_KEY_BINDINGS}
|
|
130
|
+
onSubmit={addDraft}
|
|
131
|
+
style={{
|
|
132
|
+
height: 1,
|
|
133
|
+
width: 16,
|
|
134
|
+
backgroundColor: tokens.elevated,
|
|
135
|
+
focusedBackgroundColor: tokens.elevated,
|
|
136
|
+
textColor: tokens.text,
|
|
137
|
+
focusedTextColor: tokens.text,
|
|
138
|
+
}}
|
|
139
|
+
/>
|
|
140
|
+
</box>
|
|
141
|
+
</box>
|
|
142
|
+
);
|
|
143
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { DARK } from "../../appearance/theme";
|
|
3
|
+
import type { Story, StoryMeta } from "../../stories/story";
|
|
4
|
+
import { KeybindsDialog } from "./KeybindsDialog";
|
|
5
|
+
|
|
6
|
+
export const meta: StoryMeta = { title: "Overlays/KeybindsDialog" };
|
|
7
|
+
|
|
8
|
+
const SECTIONS = [
|
|
9
|
+
{
|
|
10
|
+
title: "Review",
|
|
11
|
+
entries: [
|
|
12
|
+
{ keys: "j / k", label: "move" },
|
|
13
|
+
{ keys: "v", label: "span" },
|
|
14
|
+
{ keys: "c", label: "comment" },
|
|
15
|
+
{ keys: "enter", label: "submit" },
|
|
16
|
+
],
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
title: "Submit",
|
|
20
|
+
entries: [
|
|
21
|
+
{ keys: "← / →", label: "message" },
|
|
22
|
+
{ keys: "esc", label: "cancel" },
|
|
23
|
+
],
|
|
24
|
+
},
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
export const Cheatsheet: Story = {
|
|
28
|
+
render: () => <KeybindsDialog sections={SECTIONS} />,
|
|
29
|
+
expectedColors: [DARK.accent],
|
|
30
|
+
size: { width: 70, height: 24 },
|
|
31
|
+
};
|