@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,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UI state for the share dialog, held in a zustand store rather than juggled
|
|
3
|
+
* across component state: which category is open (share externally / export),
|
|
4
|
+
* whether the link wizard is showing, and the wizard's draft (name, auth,
|
|
5
|
+
* allowlist). The links themselves live on the thread; this is only the
|
|
6
|
+
* ephemeral surface state.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { createStore } from "zustand/vanilla";
|
|
10
|
+
import { useStore } from "zustand";
|
|
11
|
+
|
|
12
|
+
export type ShareCategory = "external" | "export";
|
|
13
|
+
|
|
14
|
+
export interface ShareWizardDraft {
|
|
15
|
+
editingId: string | null;
|
|
16
|
+
name: string;
|
|
17
|
+
requireAuth: boolean;
|
|
18
|
+
allowlist: string[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface ShareDialogState {
|
|
22
|
+
category: ShareCategory;
|
|
23
|
+
wizard: ShareWizardDraft | null;
|
|
24
|
+
setCategory: (category: ShareCategory) => void;
|
|
25
|
+
openWizard: (draft: ShareWizardDraft) => void;
|
|
26
|
+
closeWizard: () => void;
|
|
27
|
+
setName: (name: string) => void;
|
|
28
|
+
setRequireAuth: (requireAuth: boolean) => void;
|
|
29
|
+
setAllowlist: (allowlist: string[]) => void;
|
|
30
|
+
reset: () => void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** A fresh wizard draft, prefilled with the thread name for a new link. */
|
|
34
|
+
export function newWizardDraft(threadName: string): ShareWizardDraft {
|
|
35
|
+
return { editingId: null, name: threadName, requireAuth: false, allowlist: [] };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const shareDialogStore = createStore<ShareDialogState>((set) => ({
|
|
39
|
+
category: "external",
|
|
40
|
+
wizard: null,
|
|
41
|
+
setCategory: (category) => set({ category }),
|
|
42
|
+
openWizard: (wizard) => set({ wizard }),
|
|
43
|
+
closeWizard: () => set({ wizard: null }),
|
|
44
|
+
setName: (name) => set((state) => (state.wizard ? { wizard: { ...state.wizard, name } } : {})),
|
|
45
|
+
setRequireAuth: (requireAuth) =>
|
|
46
|
+
set((state) => (state.wizard ? { wizard: { ...state.wizard, requireAuth } } : {})),
|
|
47
|
+
setAllowlist: (allowlist) =>
|
|
48
|
+
set((state) => (state.wizard ? { wizard: { ...state.wizard, allowlist } } : {})),
|
|
49
|
+
reset: () => set({ category: "external", wizard: null }),
|
|
50
|
+
}));
|
|
51
|
+
|
|
52
|
+
/** Subscribe a component to the share dialog store. */
|
|
53
|
+
export function useShareDialog<T>(selector: (state: ShareDialogState) => T): T {
|
|
54
|
+
return useStore(shareDialogStore, selector);
|
|
55
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { editInEditor, resolveEditor, resolveEditorCommand } from "./editor";
|
|
6
|
+
|
|
7
|
+
describe("resolveEditor", () => {
|
|
8
|
+
test("a clean environment falls back to nano so editing always works", () => {
|
|
9
|
+
// Assert
|
|
10
|
+
expect(resolveEditor(undefined, {})).toBe("nano");
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("the environment chain wins in order: CUELOOP_EDITOR, VISUAL, EDITOR", () => {
|
|
14
|
+
// Assert
|
|
15
|
+
expect(resolveEditor(undefined, { EDITOR: "vim", VISUAL: "code", CUELOOP_EDITOR: "hx" })).toBe(
|
|
16
|
+
"hx",
|
|
17
|
+
);
|
|
18
|
+
expect(resolveEditor(undefined, { EDITOR: "vim", VISUAL: "code" })).toBe("code");
|
|
19
|
+
expect(resolveEditor(undefined, { EDITOR: "vim" })).toBe("vim");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("the [ui] editor config overrides the environment", () => {
|
|
23
|
+
// Assert
|
|
24
|
+
expect(resolveEditor("code --wait", { EDITOR: "vim" })).toBe("code --wait");
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe("resolveEditorCommand", () => {
|
|
29
|
+
test("a known GUI editor gets its wait flag appended and is trusted to wait", () => {
|
|
30
|
+
// Assert
|
|
31
|
+
expect(resolveEditorCommand("code")).toEqual({ argv: ["code", "--wait"], waits: true });
|
|
32
|
+
expect(resolveEditorCommand("subl")).toEqual({
|
|
33
|
+
argv: ["subl", "--new-window", "--wait"],
|
|
34
|
+
waits: true,
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("an existing wait flag is not duplicated", () => {
|
|
39
|
+
// Assert
|
|
40
|
+
expect(resolveEditorCommand("code --wait").argv).toEqual(["code", "--wait"]);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("a terminal editor is left alone and trusted to hold the tty", () => {
|
|
44
|
+
// Assert
|
|
45
|
+
expect(resolveEditorCommand("vim")).toEqual({ argv: ["vim"], waits: true });
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("an unknown editor is neither flagged nor trusted to wait", () => {
|
|
49
|
+
// Assert
|
|
50
|
+
expect(resolveEditorCommand("my-editor")).toEqual({ argv: ["my-editor"], waits: false });
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe("editInEditor", () => {
|
|
55
|
+
let dir: string;
|
|
56
|
+
|
|
57
|
+
beforeEach(() => {
|
|
58
|
+
dir = mkdtempSync(join(tmpdir(), "cueloop-edit-test-"));
|
|
59
|
+
});
|
|
60
|
+
afterEach(() => {
|
|
61
|
+
rmSync(dir, { recursive: true, force: true });
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
/** A runnable editor stub with a chosen base name and body. */
|
|
65
|
+
function stubEditor(name: string, body: string): string {
|
|
66
|
+
const script = join(dir, name);
|
|
67
|
+
|
|
68
|
+
writeFileSync(script, `#!/bin/sh\n${body}\n`);
|
|
69
|
+
chmodSync(script, 0o755);
|
|
70
|
+
|
|
71
|
+
return script;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const instant = (): (() => number) => {
|
|
75
|
+
let value = 0;
|
|
76
|
+
|
|
77
|
+
return () => (value += 5);
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
test("an editor that changes the file reports the new content", () => {
|
|
81
|
+
// Act
|
|
82
|
+
const result = editInEditor("old", "plan.md", {
|
|
83
|
+
editor: stubEditor("writer.sh", `printf 'new' > "$1"`),
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// Assert
|
|
87
|
+
expect(result).toEqual({ content: "new", changed: true });
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("a fast, unchanged return opens the gate; confirming re-reads the saved file", () => {
|
|
91
|
+
// Arrange
|
|
92
|
+
let askedLabel = "";
|
|
93
|
+
|
|
94
|
+
// Act
|
|
95
|
+
const result = editInEditor("keep", "plan.md", {
|
|
96
|
+
editor: stubEditor("gui-like.sh", "exit 0"),
|
|
97
|
+
now: instant(),
|
|
98
|
+
confirmSaved: (label, path) => {
|
|
99
|
+
askedLabel = label;
|
|
100
|
+
writeFileSync(path, "saved in the GUI");
|
|
101
|
+
|
|
102
|
+
return true;
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// Assert
|
|
107
|
+
expect(askedLabel).toContain("gui-like.sh");
|
|
108
|
+
expect(result).toEqual({ content: "saved in the GUI", changed: true });
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("declining the gate keeps the original content", () => {
|
|
112
|
+
// Act
|
|
113
|
+
const result = editInEditor("keep", "plan.md", {
|
|
114
|
+
editor: stubEditor("gui-like.sh", "exit 0"),
|
|
115
|
+
now: instant(),
|
|
116
|
+
confirmSaved: () => false,
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// Assert
|
|
120
|
+
expect(result).toEqual({ content: "keep", changed: false });
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("a terminal editor that makes no change never opens the gate", () => {
|
|
124
|
+
// Arrange
|
|
125
|
+
let asked = false;
|
|
126
|
+
|
|
127
|
+
// Act
|
|
128
|
+
const result = editInEditor("keep", "plan.md", {
|
|
129
|
+
editor: stubEditor("vi", "exit 0"),
|
|
130
|
+
now: instant(),
|
|
131
|
+
confirmSaved: () => {
|
|
132
|
+
asked = true;
|
|
133
|
+
|
|
134
|
+
return true;
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// Assert
|
|
139
|
+
expect(asked).toBeFalse();
|
|
140
|
+
expect(result).toEqual({ content: "keep", changed: false });
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("a nonzero editor exit throws", () => {
|
|
144
|
+
// Assert
|
|
145
|
+
expect(() => editInEditor("x", "plan.md", { editor: stubEditor("fail.sh", "exit 3") })).toThrow(
|
|
146
|
+
"editor exited 3",
|
|
147
|
+
);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File-level edit mode = the editor hand-off: write the working copy to a temp
|
|
3
|
+
* file, run the reviewer's editor on it, read the result back. Editing must
|
|
4
|
+
* work with zero configuration, so the resolution chain ends in a terminal
|
|
5
|
+
* fallback that is always present. A GUI editor launched without a wait flag
|
|
6
|
+
* forks and returns instantly, losing the edit silently; known GUI editors get
|
|
7
|
+
* their wait flag applied, and any editor that still returns right away with an
|
|
8
|
+
* untouched file drops to a confirm gate rather than discarding the work.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { tmpdir } from "node:os";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
|
|
15
|
+
export interface EditResult {
|
|
16
|
+
content: string;
|
|
17
|
+
changed: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Present on every POSIX system, so a clean shell can still edit. */
|
|
21
|
+
const DEFAULT_EDITOR = "nano";
|
|
22
|
+
|
|
23
|
+
/** GUI editors fork and return immediately unless given their wait flag. */
|
|
24
|
+
interface GuiWaitFlags {
|
|
25
|
+
[editor: string]: string[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const GUI_WAIT_FLAGS: GuiWaitFlags = {
|
|
29
|
+
code: ["--wait"],
|
|
30
|
+
"code-insiders": ["--wait"],
|
|
31
|
+
codium: ["--wait"],
|
|
32
|
+
cursor: ["--wait"],
|
|
33
|
+
windsurf: ["--wait"],
|
|
34
|
+
subl: ["--new-window", "--wait"],
|
|
35
|
+
sublime_text: ["--new-window", "--wait"],
|
|
36
|
+
zed: ["--wait"],
|
|
37
|
+
bbedit: ["--wait"],
|
|
38
|
+
mate: ["--wait"],
|
|
39
|
+
gedit: ["--wait"],
|
|
40
|
+
kate: ["--block"],
|
|
41
|
+
idea: ["--wait"],
|
|
42
|
+
webstorm: ["--wait"],
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/** Editors that hold the terminal themselves - never GUI, never a wait gate. */
|
|
46
|
+
const TERMINAL_EDITORS = new Set([
|
|
47
|
+
"vi",
|
|
48
|
+
"vim",
|
|
49
|
+
"nvim",
|
|
50
|
+
"nano",
|
|
51
|
+
"pico",
|
|
52
|
+
"emacs",
|
|
53
|
+
"emacsclient",
|
|
54
|
+
"micro",
|
|
55
|
+
"hx",
|
|
56
|
+
"helix",
|
|
57
|
+
"kak",
|
|
58
|
+
"ed",
|
|
59
|
+
"joe",
|
|
60
|
+
"mcedit",
|
|
61
|
+
]);
|
|
62
|
+
|
|
63
|
+
export interface ResolvedEditor {
|
|
64
|
+
argv: string[];
|
|
65
|
+
waits: boolean;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The editor to run: [ui] editor, then the environment chain, then nano. */
|
|
69
|
+
export function resolveEditor(
|
|
70
|
+
configuredEditor: string | undefined,
|
|
71
|
+
env: Record<string, string | undefined> = process.env,
|
|
72
|
+
): string {
|
|
73
|
+
const candidates = [configuredEditor, env.CUELOOP_EDITOR, env.VISUAL, env.EDITOR];
|
|
74
|
+
|
|
75
|
+
return candidates.find((candidate) => candidate && candidate.trim())?.trim() ?? DEFAULT_EDITOR;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function editorBaseName(command: string): string {
|
|
79
|
+
return (command.split(/[\\/]/).pop() ?? command).toLowerCase();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Split the editor string and apply a GUI wait flag when we know one. */
|
|
83
|
+
export function resolveEditorCommand(rawEditor: string): ResolvedEditor {
|
|
84
|
+
const parts = rawEditor.trim().split(/\s+/);
|
|
85
|
+
const base = editorBaseName(parts[0] ?? "");
|
|
86
|
+
|
|
87
|
+
if (TERMINAL_EDITORS.has(base)) return { argv: parts, waits: true };
|
|
88
|
+
const waitFlags = GUI_WAIT_FLAGS[base];
|
|
89
|
+
|
|
90
|
+
if (waitFlags) {
|
|
91
|
+
const alreadyWaits = parts.some(
|
|
92
|
+
(part) => part === "--wait" || part === "-w" || part === "--block",
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
return { argv: alreadyWaits ? parts : [...parts, ...waitFlags], waits: true };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return { argv: parts, waits: false };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** A fast return with an untouched file means the editor probably did not wait. */
|
|
102
|
+
const NO_WAIT_THRESHOLD_MS = 1000;
|
|
103
|
+
|
|
104
|
+
function suspectsNoWait(resolved: ResolvedEditor, elapsedMs: number, unchanged: boolean): boolean {
|
|
105
|
+
if (resolved.waits) return false; // holds the terminal or has a wait flag - trust the exit
|
|
106
|
+
if (!unchanged) return false; // edits landed, so it waited after all
|
|
107
|
+
|
|
108
|
+
return elapsedMs < NO_WAIT_THRESHOLD_MS;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export interface EditHandOff {
|
|
112
|
+
editor?: string;
|
|
113
|
+
env?: Record<string, string | undefined>;
|
|
114
|
+
confirmSaved?: (editorLabel: string, path: string) => boolean;
|
|
115
|
+
now?: () => number;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Prompt on the released terminal during the suspend window. */
|
|
119
|
+
function promptSaved(editorLabel: string, path: string): boolean {
|
|
120
|
+
const answer = prompt(
|
|
121
|
+
`${editorLabel} returned immediately and the plan is unchanged - a GUI editor needs its wait flag. Save and close ${path}, then press Enter to load your edits (or type n to skip):`,
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
return answer !== null && answer.trim().toLowerCase() !== "n";
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function editInEditor(
|
|
128
|
+
content: string,
|
|
129
|
+
filename = "plan.md",
|
|
130
|
+
handOff: EditHandOff = {},
|
|
131
|
+
): EditResult {
|
|
132
|
+
const env = handOff.env ?? process.env;
|
|
133
|
+
const now = handOff.now ?? Date.now;
|
|
134
|
+
const resolved = resolveEditorCommand(resolveEditor(handOff.editor, env));
|
|
135
|
+
const dir = mkdtempSync(join(tmpdir(), "cueloop-edit-"));
|
|
136
|
+
const path = join(dir, filename);
|
|
137
|
+
|
|
138
|
+
writeFileSync(path, content);
|
|
139
|
+
try {
|
|
140
|
+
const startedAt = now();
|
|
141
|
+
const editorProcess = Bun.spawnSync([...resolved.argv, path], {
|
|
142
|
+
stdin: "inherit",
|
|
143
|
+
stdout: "inherit",
|
|
144
|
+
stderr: "inherit",
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
if (editorProcess.exitCode !== 0) throw new Error(`editor exited ${editorProcess.exitCode}`);
|
|
148
|
+
let next = readFileSync(path, "utf8");
|
|
149
|
+
|
|
150
|
+
if (suspectsNoWait(resolved, now() - startedAt, next === content)) {
|
|
151
|
+
const confirm = handOff.confirmSaved ?? promptSaved;
|
|
152
|
+
|
|
153
|
+
if (confirm(resolved.argv[0] ?? "the editor", path)) next = readFileSync(path, "utf8");
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return { content: next, changed: next !== content };
|
|
157
|
+
} finally {
|
|
158
|
+
rmSync(dir, { recursive: true, force: true });
|
|
159
|
+
}
|
|
160
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { afterEach, expect, test } from "bun:test";
|
|
2
|
+
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { resolveGithubIdentity } from "./github-identity";
|
|
6
|
+
|
|
7
|
+
const originalBinary = process.env.CUELOOP_GH;
|
|
8
|
+
const tempDirectories: string[] = [];
|
|
9
|
+
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
if (originalBinary === undefined) delete process.env.CUELOOP_GH;
|
|
12
|
+
else process.env.CUELOOP_GH = originalBinary;
|
|
13
|
+
for (const directory of tempDirectories.splice(0)) {
|
|
14
|
+
rmSync(directory, { recursive: true, force: true });
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
function stubGithubCli(script: string): string {
|
|
19
|
+
const directory = mkdtempSync(join(tmpdir(), "gh-stub-"));
|
|
20
|
+
|
|
21
|
+
tempDirectories.push(directory);
|
|
22
|
+
const path = join(directory, "gh");
|
|
23
|
+
|
|
24
|
+
writeFileSync(path, `#!/bin/sh\n${script}\n`);
|
|
25
|
+
chmodSync(path, 0o755);
|
|
26
|
+
|
|
27
|
+
return path;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
test("reads the GitHub login and name from gh", async () => {
|
|
31
|
+
process.env.CUELOOP_GH = stubGithubCli(`printf '{"login":"markus","name":"Markus M"}'`);
|
|
32
|
+
|
|
33
|
+
expect(await resolveGithubIdentity()).toEqual({ login: "markus", name: "Markus M" });
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("drops a null name to undefined", async () => {
|
|
37
|
+
process.env.CUELOOP_GH = stubGithubCli(`printf '{"login":"markus","name":null}'`);
|
|
38
|
+
|
|
39
|
+
expect(await resolveGithubIdentity()).toEqual({ login: "markus" });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("falls back to null when gh exits non-zero (logged out)", async () => {
|
|
43
|
+
process.env.CUELOOP_GH = stubGithubCli(`exit 1`);
|
|
44
|
+
|
|
45
|
+
expect(await resolveGithubIdentity()).toBeNull();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("falls back to null when gh is not installed", async () => {
|
|
49
|
+
process.env.CUELOOP_GH = join(tmpdir(), "cueloop-absent-gh-binary");
|
|
50
|
+
|
|
51
|
+
expect(await resolveGithubIdentity()).toBeNull();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("falls back to null when gh prints an unexpected shape", async () => {
|
|
55
|
+
process.env.CUELOOP_GH = stubGithubCli(`printf 'not json'`);
|
|
56
|
+
|
|
57
|
+
expect(await resolveGithubIdentity()).toBeNull();
|
|
58
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/** The signed-in GitHub identity from the local gh tool, or null when gh cannot answer. */
|
|
2
|
+
|
|
3
|
+
import * as v from "valibot";
|
|
4
|
+
|
|
5
|
+
export interface GithubIdentity {
|
|
6
|
+
login: string;
|
|
7
|
+
name?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const GithubUserSchema = v.object({
|
|
11
|
+
login: v.string(),
|
|
12
|
+
name: v.nullish(v.string()),
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
/** The gh binary is injectable so tests stub it without a real GitHub login. */
|
|
16
|
+
function githubCliBinary(): string {
|
|
17
|
+
return process.env.CUELOOP_GH || "gh";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Read the authenticated GitHub login and name, or null when gh cannot answer. */
|
|
21
|
+
export async function resolveGithubIdentity(): Promise<GithubIdentity | null> {
|
|
22
|
+
try {
|
|
23
|
+
const githubProcess = Bun.spawn(
|
|
24
|
+
[githubCliBinary(), "api", "user", "--jq", "{login: .login, name: .name}"],
|
|
25
|
+
{ stdout: "pipe", stderr: "ignore" },
|
|
26
|
+
);
|
|
27
|
+
const [output, exitCode] = await Promise.all([
|
|
28
|
+
new Response(githubProcess.stdout).text(),
|
|
29
|
+
githubProcess.exited,
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
if (exitCode !== 0) return null;
|
|
33
|
+
const parsed = v.safeParse(GithubUserSchema, JSON.parse(output));
|
|
34
|
+
|
|
35
|
+
if (!parsed.success) return null;
|
|
36
|
+
|
|
37
|
+
return { login: parsed.output.login, name: parsed.output.name ?? undefined };
|
|
38
|
+
} catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { focusHerdrPane } from "./herdr";
|
|
6
|
+
|
|
7
|
+
let dir: string;
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
dir = mkdtempSync(join(tmpdir(), "cueloop-herdr-"));
|
|
11
|
+
});
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
rmSync(dir, { recursive: true, force: true });
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
describe("focusHerdrPane", () => {
|
|
17
|
+
test("resolves the pane's tab and focuses it", () => {
|
|
18
|
+
// Arrange
|
|
19
|
+
const log = join(dir, "calls.log");
|
|
20
|
+
const stub = join(dir, "herdr");
|
|
21
|
+
|
|
22
|
+
writeFileSync(
|
|
23
|
+
stub,
|
|
24
|
+
`#!/bin/sh
|
|
25
|
+
echo "$@" >> "${log}"
|
|
26
|
+
if [ "$1" = "pane" ]; then echo '{"result":{"pane":{"tab_id":"w1:t3"}}}'; fi
|
|
27
|
+
`,
|
|
28
|
+
);
|
|
29
|
+
Bun.spawnSync(["chmod", "+x", stub]);
|
|
30
|
+
|
|
31
|
+
// Assert
|
|
32
|
+
expect(focusHerdrPane(stub, "w1:p1")).toBe(true);
|
|
33
|
+
const calls = readFileSync(log, "utf8").trim().split("\n");
|
|
34
|
+
|
|
35
|
+
expect(calls[0]).toBe("pane get w1:p1");
|
|
36
|
+
expect(calls[1]).toBe("tab focus w1:t3");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("a missing binary never throws", () => {
|
|
40
|
+
// Assert
|
|
41
|
+
expect(focusHerdrPane(join(dir, "nope"), "w1:p1")).toBe(false);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* First-class herdr return: a review opened beside an agent hands focus back
|
|
3
|
+
* to the agent's pane when it closes, not leaving the reviewer on a dead tab.
|
|
4
|
+
* The env contract and the return-target rule live in @cueloop/schema; this
|
|
5
|
+
* module owns the one reviewer-side action that spawns the herdr CLI. Focus
|
|
6
|
+
* is best-effort - a failure never blocks closing the review.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import * as v from "valibot";
|
|
10
|
+
|
|
11
|
+
const HerdrPaneSchema = v.object({
|
|
12
|
+
result: v.optional(v.object({ pane: v.optional(v.object({ tab_id: v.optional(v.string()) })) })),
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
/** Focus the tab holding the target pane. Synchronous: runs just before exit. */
|
|
16
|
+
export function focusHerdrPane(binPath: string, paneId: string): boolean {
|
|
17
|
+
try {
|
|
18
|
+
const got = Bun.spawnSync([binPath, "pane", "get", paneId], {
|
|
19
|
+
stdout: "pipe",
|
|
20
|
+
stderr: "ignore",
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
if (got.exitCode !== 0) return false;
|
|
24
|
+
const parsed = v.safeParse(HerdrPaneSchema, JSON.parse(got.stdout.toString()));
|
|
25
|
+
|
|
26
|
+
if (!parsed.success) return false;
|
|
27
|
+
const tab = parsed.output.result?.pane?.tab_id;
|
|
28
|
+
|
|
29
|
+
if (!tab) return false;
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
Bun.spawnSync([binPath, "tab", "focus", tab], { stdout: "ignore", stderr: "ignore" })
|
|
33
|
+
.exitCode === 0
|
|
34
|
+
);
|
|
35
|
+
} catch {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { OBSIDIAN_DEFAULTS } from "@cueloop/integration-obsidian";
|
|
3
|
+
import { loadBundledExporters } from "./integrations";
|
|
4
|
+
import type { IntegrationsConfig } from "../settings/config";
|
|
5
|
+
|
|
6
|
+
function integrations(overrides: Partial<typeof OBSIDIAN_DEFAULTS> = {}): IntegrationsConfig {
|
|
7
|
+
return { obsidian: { ...OBSIDIAN_DEFAULTS, ...overrides } };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
describe("loadBundledExporters", () => {
|
|
11
|
+
test("composes the obsidian integration into one named exporter", async () => {
|
|
12
|
+
// Act
|
|
13
|
+
const exporters = await loadBundledExporters(integrations());
|
|
14
|
+
|
|
15
|
+
// Assert
|
|
16
|
+
expect(exporters.map((exporter) => exporter.name)).toEqual(["obsidian"]);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("runsOn mirrors the configured export policy", async () => {
|
|
20
|
+
// Arrange
|
|
21
|
+
const onApprove = (await loadBundledExporters(integrations({ exportOn: "approved" })))[0]!;
|
|
22
|
+
|
|
23
|
+
// Assert
|
|
24
|
+
expect(onApprove.runsOn("approved")).toBeTrue();
|
|
25
|
+
expect(onApprove.runsOn("changes_requested")).toBeFalse();
|
|
26
|
+
|
|
27
|
+
// Arrange
|
|
28
|
+
const onMessage = (await loadBundledExporters(integrations({ exportOn: "message" })))[0]!;
|
|
29
|
+
|
|
30
|
+
// Assert
|
|
31
|
+
expect(onMessage.runsOn("approved")).toBeTrue();
|
|
32
|
+
expect(onMessage.runsOn("changes_requested")).toBeTrue();
|
|
33
|
+
|
|
34
|
+
// Arrange
|
|
35
|
+
const manual = (await loadBundledExporters(integrations({ exportOn: "manual" })))[0]!;
|
|
36
|
+
|
|
37
|
+
// Assert
|
|
38
|
+
expect(manual.runsOn("approved")).toBeFalse();
|
|
39
|
+
expect(manual.runsOn("changes_requested")).toBeFalse();
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bundled integration wiring: composes the configured integrations into
|
|
3
|
+
* generic exporters plus their run policy, so the session controller depends
|
|
4
|
+
* only on the extension seam, never on an integration's own config type. This
|
|
5
|
+
* and config.ts are the only client modules that name a concrete integration.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { Registry, type Exporter } from "@cueloop/extension-api";
|
|
9
|
+
import { createObsidianExtension, shouldExport } from "@cueloop/integration-obsidian";
|
|
10
|
+
import type { MessageOutcome } from "@cueloop/schema";
|
|
11
|
+
import type { IntegrationsConfig } from "../settings/config";
|
|
12
|
+
|
|
13
|
+
/** An exporter plus the policy deciding which messages trigger it. */
|
|
14
|
+
export interface BundledExporter {
|
|
15
|
+
name: string;
|
|
16
|
+
run: Exporter;
|
|
17
|
+
runsOn(message: MessageOutcome): boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Load the bundled integrations from config as generic exporters. */
|
|
21
|
+
export async function loadBundledExporters(
|
|
22
|
+
integrations: IntegrationsConfig,
|
|
23
|
+
): Promise<BundledExporter[]> {
|
|
24
|
+
const registry = new Registry();
|
|
25
|
+
const obsidian = integrations.obsidian;
|
|
26
|
+
const record = await registry.load("obsidian", createObsidianExtension(obsidian));
|
|
27
|
+
const run = record.exporters.get("obsidian");
|
|
28
|
+
|
|
29
|
+
if (!run) return [];
|
|
30
|
+
|
|
31
|
+
return [{ name: "obsidian", run, runsOn: (message) => shouldExport(obsidian.exportOn, message) }];
|
|
32
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { imagePlaceholder, looksLikeBinaryPaste } from "./pasted-image";
|
|
3
|
+
|
|
4
|
+
function bytesOf(...values: number[]): Uint8Array {
|
|
5
|
+
return Uint8Array.from(values);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
describe("looksLikeBinaryPaste", () => {
|
|
9
|
+
test("plain text pastes stay text", () => {
|
|
10
|
+
expect(looksLikeBinaryPaste(new TextEncoder().encode("just a normal comment"))).toBe(false);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("multi-line text with tabs and newlines stays text", () => {
|
|
14
|
+
expect(
|
|
15
|
+
looksLikeBinaryPaste(new TextEncoder().encode("line one\n\tline two\r\nline three")),
|
|
16
|
+
).toBe(false);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("unicode text stays text", () => {
|
|
20
|
+
expect(looksLikeBinaryPaste(new TextEncoder().encode("café — naïve — 日本語 — 🎉"))).toBe(
|
|
21
|
+
false,
|
|
22
|
+
);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("a PNG signature is binary", () => {
|
|
26
|
+
expect(looksLikeBinaryPaste(bytesOf(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a))).toBe(
|
|
27
|
+
true,
|
|
28
|
+
);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("a JPEG signature is binary", () => {
|
|
32
|
+
expect(looksLikeBinaryPaste(bytesOf(0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10))).toBe(true);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("a GIF signature is binary", () => {
|
|
36
|
+
expect(looksLikeBinaryPaste(new TextEncoder().encode("GIF89a\x01\x02"))).toBe(true);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("a WEBP signature is binary", () => {
|
|
40
|
+
expect(
|
|
41
|
+
looksLikeBinaryPaste(bytesOf(0x52, 0x49, 0x46, 0x46, 1, 2, 3, 4, 0x57, 0x45, 0x42, 0x50)),
|
|
42
|
+
).toBe(true);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("a NUL byte marks binary", () => {
|
|
46
|
+
expect(looksLikeBinaryPaste(bytesOf(0x68, 0x69, 0x00, 0x74, 0x68, 0x65, 0x72, 0x65))).toBe(
|
|
47
|
+
true,
|
|
48
|
+
);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("a burst of control bytes marks binary", () => {
|
|
52
|
+
expect(looksLikeBinaryPaste(bytesOf(0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08))).toBe(
|
|
53
|
+
true,
|
|
54
|
+
);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("an empty paste is not binary", () => {
|
|
58
|
+
expect(looksLikeBinaryPaste(new Uint8Array())).toBe(false);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe("imagePlaceholder", () => {
|
|
63
|
+
test("numbers each pasted image", () => {
|
|
64
|
+
expect(imagePlaceholder(1)).toBe("[Image #1]");
|
|
65
|
+
expect(imagePlaceholder(2)).toBe("[Image #2]");
|
|
66
|
+
});
|
|
67
|
+
});
|