@cueloop/client 0.1.0-alpha.9 → 0.1.0-alpha.91
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/native/build-pty.sh +51 -0
- package/native/build.sh +82 -0
- package/native/darwin-arm64/libcuelooppty.dylib +0 -0
- package/native/darwin-arm64/libcueloopvt.dylib +0 -0
- package/native/src/pty.zig +193 -0
- package/native/src/shim.zig +114 -0
- package/package.json +42 -34
- package/src/App.appearance.test.tsx +53 -0
- package/src/App.autoclose.test.tsx +77 -51
- package/src/App.diff.test.tsx +259 -45
- package/src/App.export.test.tsx +58 -41
- package/src/App.focus.test.ts +89 -0
- package/src/App.identity.test.tsx +78 -0
- package/src/App.plan-render.test.tsx +209 -0
- package/src/App.plan-review.test.tsx +330 -161
- package/src/App.prototype-markdown.test.tsx +128 -0
- package/src/App.prototype.test.tsx +176 -0
- package/src/App.readonly.test.tsx +129 -67
- package/src/App.regression.test.tsx +94 -0
- package/src/App.rendering.test.tsx +61 -35
- package/src/App.share-dialog.test.tsx +172 -0
- package/src/App.submit-confirm.test.tsx +264 -0
- package/src/App.test.tsx +402 -126
- package/src/App.toast.test.tsx +98 -0
- package/src/App.tsx +1406 -1060
- package/src/App.visual.test.tsx +57 -0
- package/src/App.walk.test.tsx +299 -0
- package/src/App.workbench.test.tsx +787 -0
- package/src/__snapshots__/view-plan.regression.test.ts.snap +100 -0
- package/src/annotation-palette.ts +62 -0
- package/src/ansi-screen.test.ts +28 -0
- package/src/ansi-screen.ts +72 -0
- package/src/app-key-state.ts +61 -0
- package/src/app-screens.tsx +577 -0
- package/src/app-view-model.ts +168 -0
- package/src/attribution.test.ts +31 -0
- package/src/attribution.ts +42 -0
- package/src/client-extension-registry.test.tsx +61 -0
- package/src/client-extension-registry.ts +159 -0
- package/src/clipboard.test.ts +23 -0
- package/src/clipboard.ts +33 -0
- package/src/components/AnnotatableFileView.stories.tsx +42 -0
- package/src/components/AnnotatableFileView.test.tsx +102 -0
- package/src/components/AnnotatableFileView.tsx +134 -0
- package/src/components/AnnotationCards.stories.tsx +86 -0
- package/src/components/AnnotationCards.tsx +341 -0
- package/src/components/AnsiScreen.stories.tsx +12 -0
- package/src/components/AnsiScreen.tsx +28 -0
- package/src/components/AppHeader.stories.tsx +50 -0
- package/src/components/AppHeader.tsx +83 -0
- package/src/components/AppShell.brand.test.tsx +58 -0
- package/src/components/AppShell.extensions.test.tsx +191 -0
- package/src/components/AppShell.stories.tsx +89 -0
- package/src/components/AppShell.tsx +362 -0
- package/src/components/AppShell.zoom.test.tsx +135 -0
- package/src/components/BareWorkbenchFileView.stories.tsx +27 -0
- package/src/components/BareWorkbenchFileView.tsx +64 -0
- package/src/components/Breadcrumb.stories.tsx +19 -0
- package/src/components/Breadcrumb.tsx +45 -0
- package/src/components/ChangesColumn.stories.tsx +44 -0
- package/src/components/ChangesColumn.tsx +199 -0
- package/src/components/ChangesFileTree.test.tsx +61 -0
- package/src/components/CodeBlock.stories.tsx +42 -0
- package/src/components/CodeBlock.tsx +84 -0
- package/src/components/CompletionOverlay.stories.tsx +37 -0
- package/src/components/CompletionOverlay.tsx +77 -0
- package/src/components/ConfirmCard.stories.tsx +37 -0
- package/src/components/ConfirmCard.tsx +150 -0
- package/src/components/ConfirmDialog.stories.tsx +21 -0
- package/src/components/ConfirmDialog.tsx +72 -0
- package/src/components/DiffContentView.scroll.test.tsx +149 -0
- package/src/components/DiffContentView.split.test.tsx +140 -0
- package/src/components/DiffContentView.stories.tsx +80 -0
- package/src/components/DiffContentView.syntax.test.tsx +82 -0
- package/src/components/DiffContentView.tsx +906 -0
- package/src/components/DiscussionMarkerRail.stories.tsx +54 -0
- package/src/components/DiscussionMarkerRail.tsx +186 -0
- package/src/components/EditorGrid.stories.tsx +59 -0
- package/src/components/EditorGrid.tabs.test.tsx +118 -0
- package/src/components/EditorGrid.tsx +426 -0
- package/src/components/EditorGrid.zoom.test.tsx +68 -0
- package/src/components/ExtensionZone.stories.tsx +28 -0
- package/src/components/ExtensionZone.tsx +145 -0
- package/src/components/FileContentsView.stories.tsx +16 -0
- package/src/components/FileContentsView.test.tsx +29 -0
- package/src/components/FileContentsView.tsx +98 -0
- package/src/components/GridTabContent.stories.tsx +59 -0
- package/src/components/GridTabContent.tsx +182 -0
- package/src/components/HandleChipsInput.stories.tsx +24 -0
- package/src/components/HandleChipsInput.test.tsx +35 -0
- package/src/components/HandleChipsInput.tsx +145 -0
- package/src/components/KeybindsDialog.stories.tsx +31 -0
- package/src/components/KeybindsDialog.tsx +103 -0
- package/src/components/MarkdownGridBlock.stories.tsx +33 -0
- package/src/components/MarkdownGridBlock.tsx +72 -0
- package/src/components/MarkdownThreadEditor.stories.tsx +15 -0
- package/src/components/MarkdownThreadEditor.test.tsx +98 -0
- package/src/components/MarkdownThreadEditor.tsx +162 -0
- package/src/components/MenuBar.stories.tsx +28 -0
- package/src/components/MenuBar.tsx +44 -0
- package/src/components/NavModeHint.stories.tsx +40 -0
- package/src/components/NavModeHint.tsx +31 -0
- package/src/components/OverlayScrollbar.stories.tsx +33 -0
- package/src/components/OverlayScrollbar.test.tsx +139 -0
- package/src/components/OverlayScrollbar.tsx +129 -0
- package/src/components/PaneGrid.stories.tsx +84 -0
- package/src/components/PaneGrid.tsx +128 -0
- package/src/components/PanelColumn.stories.tsx +25 -0
- package/src/components/PanelColumn.tsx +98 -0
- package/src/components/ProjectTreeView.stories.tsx +25 -0
- package/src/components/ProjectTreeView.test.tsx +75 -0
- package/src/components/ProjectTreeView.tsx +125 -0
- package/src/components/PromptDialog.stories.tsx +36 -0
- package/src/components/PromptDialog.test.tsx +61 -0
- package/src/components/PromptDialog.tsx +99 -0
- package/src/components/PrototypeContentView.stories.tsx +19 -0
- package/src/components/PrototypeContentView.tsx +446 -0
- package/src/components/RootOverlay.stories.tsx +45 -0
- package/src/components/RootOverlay.tsx +48 -0
- package/src/components/ScrollArea.stories.tsx +20 -0
- package/src/components/ScrollArea.tsx +44 -0
- package/src/components/SettingsDialog.stories.tsx +117 -0
- package/src/components/SettingsDialog.tsx +186 -0
- package/src/components/SettingsRows.stories.tsx +18 -0
- package/src/components/SettingsRows.tsx +112 -0
- package/src/components/ShareDialog.stories.tsx +121 -0
- package/src/components/ShareDialog.tsx +552 -0
- package/src/components/ShellHeader.stories.tsx +50 -0
- package/src/components/ShellHeader.tsx +137 -0
- package/src/components/SlashComposer.stories.tsx +37 -0
- package/src/components/SlashComposer.tsx +87 -0
- package/src/components/ThreadFooter.stories.tsx +30 -0
- package/src/components/ThreadFooter.test.tsx +47 -0
- package/src/components/ThreadFooter.tsx +76 -0
- package/src/components/ThreadTree.stories.tsx +46 -0
- package/src/components/ThreadTree.tsx +255 -0
- package/src/components/ThreadView.selection.test.tsx +951 -0
- package/src/components/ThreadView.slash.test.tsx +146 -0
- package/src/components/ThreadView.stories.tsx +26 -0
- package/src/components/ThreadView.tsx +525 -0
- package/src/components/Toast.stories.tsx +19 -0
- package/src/components/Toast.tsx +52 -0
- package/src/components/Tooltip.stories.tsx +26 -0
- package/src/components/Tooltip.tsx +72 -0
- package/src/components/TreePane.stories.tsx +106 -0
- package/src/components/TreePane.tsx +157 -0
- package/src/components/WalkWizard.stories.tsx +100 -0
- package/src/components/WalkWizard.tsx +150 -0
- package/src/components/WelcomePlayground.stories.tsx +12 -0
- package/src/components/WelcomePlayground.test.tsx +49 -0
- package/src/components/WelcomePlayground.tsx +139 -0
- package/src/components/WorkspacePanels.stories.tsx +36 -0
- package/src/components/WorkspacePanels.tsx +116 -0
- package/src/components/__snapshots__/stories.test.tsx.snap +2264 -0
- package/src/components/diff-content-view-layout.test.ts +198 -0
- package/src/components/diff-content-view-layout.ts +84 -0
- package/src/components/editor-grid.test.ts +86 -0
- package/src/components/editor-grid.ts +138 -0
- package/src/components/file-tree.test.ts +60 -0
- package/src/components/file-tree.ts +112 -0
- package/src/components/menu-control.stories.tsx +35 -0
- package/src/components/menu-control.tsx +43 -0
- package/src/components/pages/Workspace.stories.tsx +181 -0
- package/src/components/primitives/Button.stories.tsx +32 -0
- package/src/components/primitives/Button.tsx +59 -0
- package/src/components/primitives/Card.stories.tsx +25 -0
- package/src/components/primitives/Card.tsx +63 -0
- package/src/components/primitives/Dialog.stories.tsx +24 -0
- package/src/components/primitives/Dialog.test.tsx +37 -0
- package/src/components/primitives/Dialog.tsx +71 -0
- package/src/components/primitives/DialogActions.stories.tsx +20 -0
- package/src/components/primitives/DialogActions.tsx +39 -0
- package/src/components/primitives/IconButton.stories.tsx +36 -0
- package/src/components/primitives/IconButton.tsx +62 -0
- package/src/components/primitives/StatusBar.stories.tsx +11 -0
- package/src/components/primitives/StatusBar.tsx +23 -0
- package/src/components/primitives/Switch.stories.tsx +17 -0
- package/src/components/primitives/Switch.tsx +32 -0
- package/src/components/primitives/Tabs.stories.tsx +29 -0
- package/src/components/primitives/Tabs.tsx +102 -0
- package/src/components/primitives/Toolbar.stories.tsx +17 -0
- package/src/components/primitives/Toolbar.tsx +14 -0
- package/src/components/primitives/Tree.stories.tsx +124 -0
- package/src/components/primitives/Tree.tsx +100 -0
- package/src/components/primitives/frame.test.ts +12 -0
- package/src/components/primitives/frame.ts +17 -0
- package/src/components/primitives/icons.ts +61 -0
- package/src/components/primitives/tree-model.test.ts +99 -0
- package/src/components/primitives/tree-model.ts +137 -0
- package/src/components/quick-actions-editor.stories.tsx +58 -0
- package/src/components/quick-actions-editor.test.tsx +117 -0
- package/src/components/quick-actions-editor.tsx +226 -0
- package/src/components/share-dialog-store.ts +58 -0
- package/src/components/stories-app.tsx +193 -0
- package/src/components/stories.test.tsx +74 -0
- package/src/components/story-fixtures.ts +102 -0
- package/src/components/story-tree.test.ts +108 -0
- package/src/components/story.ts +159 -0
- package/src/components/syntax-highlight.ts +133 -0
- package/src/components/terminal-pane.test.tsx +40 -0
- package/src/components/terminal-pane.ts +199 -0
- package/src/components/theme-context.stories.tsx +46 -0
- package/src/components/theme-context.tsx +37 -0
- package/src/components/thread-tree.test.ts +134 -0
- package/src/components/thread-tree.ts +109 -0
- package/src/components/truncate-text.ts +7 -0
- package/src/components/truncate-title.test.ts +31 -0
- package/src/components/truncate-title.ts +15 -0
- package/src/config-identity.test.ts +78 -0
- package/src/config.test.ts +579 -34
- package/src/config.ts +533 -57
- package/src/diff-fold.test.ts +78 -0
- package/src/diff-fold.ts +126 -0
- package/src/diff-hunk-curate.test.ts +315 -0
- package/src/diff-hunk-curate.ts +153 -0
- package/src/diff-intraline.test.ts +238 -0
- package/src/diff-intraline.ts +236 -0
- package/src/diff-syntax.test.ts +112 -0
- package/src/diff-syntax.ts +179 -0
- package/src/discussions.ts +79 -0
- package/src/editor.test.ts +149 -0
- package/src/editor.ts +139 -9
- package/src/ghostty-terminal.test.ts +85 -0
- package/src/ghostty-terminal.ts +199 -0
- package/src/github-identity.test.ts +58 -0
- package/src/github-identity.ts +41 -0
- package/src/herdr.test.ts +9 -34
- package/src/herdr.ts +25 -21
- package/src/index.ts +29 -0
- package/src/integrations.test.ts +41 -0
- package/src/integrations.ts +32 -0
- package/src/intent-dispatch.test.ts +724 -0
- package/src/intent-dispatch.ts +690 -0
- package/src/key-bindings.test.ts +156 -0
- package/src/key-bindings.ts +459 -0
- package/src/keymap.test.ts +456 -115
- package/src/keymap.ts +315 -81
- package/src/kitty-image.test.ts +66 -0
- package/src/kitty-image.ts +138 -0
- package/src/launch-layout.test.ts +78 -0
- package/src/launch-layout.ts +62 -0
- package/src/mark-runs.ts +121 -0
- package/src/markdown-editor-syntax-style.ts +50 -0
- package/src/markdown-grid.test.ts +101 -0
- package/src/markdown-grid.ts +203 -0
- package/src/markdown-highlight.test.ts +61 -0
- package/src/markdown-highlight.ts +122 -0
- package/src/pasted-image.test.ts +67 -0
- package/src/pasted-image.ts +60 -0
- package/src/perf/otlp-trace.test.ts +116 -0
- package/src/perf/otlp-trace.ts +130 -0
- package/src/perf/perf-report.ts +17 -0
- package/src/perf/perf-timings.test.ts +18 -0
- package/src/perf/perf-timings.ts +38 -0
- package/src/prototype-browser.test.ts +81 -0
- package/src/prototype-browser.ts +329 -0
- package/src/prototype-pixels.tsx +35 -0
- package/src/pty.test.ts +81 -0
- package/src/pty.ts +245 -0
- package/src/ready-signal.test.ts +85 -0
- package/src/ready-signal.ts +52 -0
- package/src/run.ts +85 -12
- package/src/serve.test.ts +24 -12
- package/src/serve.ts +52 -10
- package/src/share.ts +237 -0
- package/src/skills.test.ts +74 -0
- package/src/skills.ts +75 -0
- package/src/slash-palette.test.ts +103 -0
- package/src/slash-palette.ts +129 -0
- package/src/split-diff.test.ts +110 -0
- package/src/split-diff.ts +149 -0
- package/src/test-support.test.ts +264 -0
- package/src/test-support.ts +369 -0
- package/src/theme-presets.test.ts +51 -0
- package/src/theme-presets.ts +219 -0
- package/src/theme.ts +88 -19
- package/src/thread-chords.test.ts +79 -0
- package/src/thread-chords.ts +163 -0
- package/src/thread-code-syntax.ts +45 -0
- package/src/thread-controller.curate.test.ts +476 -0
- package/src/thread-controller.derivation.test.ts +62 -0
- package/src/thread-controller.live-diff.test.ts +322 -0
- package/src/thread-controller.optimistic.test.ts +69 -0
- package/src/thread-controller.share.test.ts +561 -0
- package/src/thread-controller.switch-cache.test.ts +176 -0
- package/src/thread-controller.tree.test.ts +261 -0
- package/src/thread-controller.ts +2050 -0
- package/src/thread-selection.test.ts +210 -0
- package/src/thread-selection.ts +243 -0
- package/src/tree-view.test.ts +128 -0
- package/src/tree-view.ts +153 -0
- package/src/use-annotation-surface.tsx +975 -0
- package/src/use-changes-workbench.ts +190 -0
- package/src/use-frame-measure.ts +41 -0
- package/src/use-remember-layout.ts +31 -0
- package/src/use-repo-changes.ts +87 -0
- package/src/use-settings-dialog.tsx +364 -0
- package/src/use-terminal-virtualizer.ts +259 -0
- package/src/use-thread-body-editing.tsx +58 -0
- package/src/version.ts +5 -0
- package/src/view-diff.test.ts +279 -9
- package/src/view-diff.ts +460 -24
- package/src/view-plan.regression.test.ts +60 -0
- package/src/view-plan.test.ts +545 -0
- package/src/view-plan.ts +1019 -0
- package/src/visual/contrast.test.ts +35 -0
- package/src/visual/contrast.ts +33 -0
- package/src/walk.test.ts +121 -0
- package/src/walk.ts +74 -0
- package/src/workbench-snapshot.test.ts +99 -0
- package/src/workbench-snapshot.ts +59 -0
- package/src/session-controller.ts +0 -398
- package/src/syntax.test.ts +0 -41
- package/src/syntax.ts +0 -88
- package/src/view.test.ts +0 -157
- package/src/view.ts +0 -364
package/src/serve.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* SSH-served TUI
|
|
3
|
-
*
|
|
2
|
+
* SSH-served TUI: `cueloop serve` lets teammates join a
|
|
3
|
+
* thread over plain ssh. Every SSH connection renders <App> in
|
|
4
4
|
* observer mode (readOnly) against the same local daemon; the one writable
|
|
5
5
|
* controller stays the local owner's own `cueloop` TUI.
|
|
6
6
|
*
|
|
@@ -20,7 +20,38 @@ import React from "react";
|
|
|
20
20
|
import { createRoot } from "@opentui/react";
|
|
21
21
|
import { createServer } from "@opentui/ssh";
|
|
22
22
|
import { cueloopHome } from "@cueloop/daemon";
|
|
23
|
+
import { DaemonClient } from "@cueloop/daemon/client";
|
|
24
|
+
import type { Artifact } from "@cueloop/schema";
|
|
23
25
|
import { App } from "./App";
|
|
26
|
+
import { snapshotWorkbench } from "./workbench-snapshot";
|
|
27
|
+
|
|
28
|
+
/** OSC background query budget: brief so a terminal that never answers falls back to dark. */
|
|
29
|
+
const THEME_QUERY_TIMEOUT_MS = 200;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The frozen diff an observer reads for a served workbench thread: captured once at serve time, so a
|
|
33
|
+
* late local edit never shifts it. Undefined when nothing is served or the thread is not a workbench.
|
|
34
|
+
*/
|
|
35
|
+
async function captureFrozenArtifact(
|
|
36
|
+
home: string,
|
|
37
|
+
sessionId: string | undefined,
|
|
38
|
+
): Promise<Artifact | undefined> {
|
|
39
|
+
if (sessionId === undefined) return undefined;
|
|
40
|
+
|
|
41
|
+
const probe = await DaemonClient.connect({ home, autostart: true });
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
const thread = await probe.sessionGet(sessionId).catch(() => undefined);
|
|
45
|
+
|
|
46
|
+
if (thread === undefined) return undefined;
|
|
47
|
+
|
|
48
|
+
const snapshot = await snapshotWorkbench(thread, (root, vcs) => probe.repoDiff(root, vcs));
|
|
49
|
+
|
|
50
|
+
return snapshot === thread ? undefined : snapshot.artifact;
|
|
51
|
+
} finally {
|
|
52
|
+
probe.close();
|
|
53
|
+
}
|
|
54
|
+
}
|
|
24
55
|
|
|
25
56
|
export interface ServeOptions {
|
|
26
57
|
/** TCP port for the SSH listener; 0 picks an ephemeral port. Default 2222. */
|
|
@@ -33,7 +64,7 @@ export interface ServeOptions {
|
|
|
33
64
|
home?: string;
|
|
34
65
|
/** Silence the @opentui/ssh startup banner (tests). Default true. */
|
|
35
66
|
banner?: boolean;
|
|
36
|
-
onError?: (
|
|
67
|
+
onError?: (cause: unknown) => void;
|
|
37
68
|
}
|
|
38
69
|
|
|
39
70
|
export interface ServeHandle {
|
|
@@ -44,25 +75,35 @@ export interface ServeHandle {
|
|
|
44
75
|
close(): Promise<void>;
|
|
45
76
|
}
|
|
46
77
|
|
|
47
|
-
export async function serveClient(
|
|
48
|
-
const home =
|
|
78
|
+
export async function serveClient(options: ServeOptions = {}): Promise<ServeHandle> {
|
|
79
|
+
const home = options.home ?? cueloopHome();
|
|
49
80
|
const sshDir = join(home, "ssh");
|
|
81
|
+
|
|
50
82
|
mkdirSync(sshDir, { recursive: true, mode: 0o700 });
|
|
51
83
|
|
|
84
|
+
const servedArtifact = await captureFrozenArtifact(home, options.sessionId);
|
|
85
|
+
|
|
52
86
|
const server = createServer({
|
|
53
87
|
// password-less by design; see the trust model in the module comment
|
|
54
88
|
auth: "open",
|
|
55
89
|
hostKey: { path: join(sshDir, "host_key") },
|
|
56
|
-
startupBanner:
|
|
90
|
+
startupBanner: options.banner ?? true,
|
|
57
91
|
idleTimeout: "2h",
|
|
58
|
-
onError:
|
|
59
|
-
}).serve((session) => {
|
|
92
|
+
onError: options.onError,
|
|
93
|
+
}).serve(async (session) => {
|
|
94
|
+
// the observer's terminal reports its own background, so a light-terminal
|
|
95
|
+
// watcher gets the readable variant too
|
|
96
|
+
const appearance =
|
|
97
|
+
(await session.renderer.waitForThemeMode(THEME_QUERY_TIMEOUT_MS).catch(() => null)) ?? "dark";
|
|
60
98
|
const root = createRoot(session.renderer);
|
|
99
|
+
|
|
61
100
|
root.render(
|
|
62
101
|
React.createElement(App, {
|
|
63
102
|
home,
|
|
64
|
-
sessionId:
|
|
103
|
+
sessionId: options.sessionId,
|
|
65
104
|
readOnly: true,
|
|
105
|
+
appearance,
|
|
106
|
+
servedArtifact,
|
|
66
107
|
// q disconnects only this observer, never the server
|
|
67
108
|
onExit: () => session.end(),
|
|
68
109
|
}),
|
|
@@ -70,6 +111,7 @@ export async function serveClient(opts: ServeOptions = {}): Promise<ServeHandle>
|
|
|
70
111
|
session.onClose(() => root.unmount());
|
|
71
112
|
});
|
|
72
113
|
|
|
73
|
-
const info = await server.listen(
|
|
114
|
+
const info = await server.listen(options.port ?? 2222, options.host ?? "127.0.0.1");
|
|
115
|
+
|
|
74
116
|
return { ...info, close: () => server.close() };
|
|
75
117
|
}
|
package/src/share.ts
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
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
|
+
viewFollowing,
|
|
18
|
+
type Annotation,
|
|
19
|
+
type ShareAccess,
|
|
20
|
+
type Thread,
|
|
21
|
+
} from "@cueloop/schema";
|
|
22
|
+
import { ThreadRecordSchema } from "@cueloop/daemon/validate";
|
|
23
|
+
import type { SharedMerge } from "@cueloop/daemon/client";
|
|
24
|
+
import * as v from "valibot";
|
|
25
|
+
import { copyToClipboard } from "./clipboard";
|
|
26
|
+
|
|
27
|
+
export interface ShareTarget {
|
|
28
|
+
host?: string;
|
|
29
|
+
port?: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface ShareResult {
|
|
33
|
+
/** The one line to paste: `ssh p_xxxxxxxx@host`. */
|
|
34
|
+
line: string;
|
|
35
|
+
/** Whether the line made it onto the system clipboard. */
|
|
36
|
+
copied: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The `p_…` share id inside a `ssh p_…@host` line, or undefined. */
|
|
40
|
+
export function shareIdFromLine(line: string): string | undefined {
|
|
41
|
+
return line.match(/^ssh (\S+)@/)?.[1];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The paste line for a share id: `ssh p_…@host`. Used to copy an existing link. */
|
|
45
|
+
export function formatShareLine(shareId: string, target: ShareTarget = {}): string {
|
|
46
|
+
return `ssh ${shareId}@${target.host ?? DEFAULT_SHARE_HOST}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Upload the session to the gateway and copy the resulting ssh line. */
|
|
50
|
+
export async function publishShare(
|
|
51
|
+
session: Thread,
|
|
52
|
+
target: ShareTarget = {},
|
|
53
|
+
): Promise<ShareResult> {
|
|
54
|
+
const { stdout, stderr, code } = await runShareSsh(
|
|
55
|
+
"cueloop-share",
|
|
56
|
+
// the share follows one branch: collaborators see its path wherever the owner stands
|
|
57
|
+
packSessionBlob(viewFollowing(session)),
|
|
58
|
+
target,
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
if (code !== 0)
|
|
62
|
+
throw new Error(`gateway upload failed: ${stderr.trim() || `ssh exited ${code}`}`);
|
|
63
|
+
const line = stdout.trim();
|
|
64
|
+
|
|
65
|
+
if (!line.startsWith("ssh ")) throw new Error(`unexpected gateway reply: ${line || "(empty)"}`);
|
|
66
|
+
const copied = await copyToClipboard(line);
|
|
67
|
+
|
|
68
|
+
return { line, copied };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Pull a share's current session back from the gateway. The gateway lets only
|
|
73
|
+
* the fingerprint that uploaded it through, so collaborator notes reach the
|
|
74
|
+
* planner without exposing the master key.
|
|
75
|
+
*/
|
|
76
|
+
export async function pullShare(shareId: string, target: ShareTarget = {}): Promise<Thread> {
|
|
77
|
+
const { stdout, stderr, code } = await runShareSsh("cueloop-pull", Buffer.from(shareId), target);
|
|
78
|
+
|
|
79
|
+
if (code !== 0) throw new Error(`gateway pull failed: ${stderr.trim() || `ssh exited ${code}`}`);
|
|
80
|
+
|
|
81
|
+
return v.parse(ThreadRecordSchema, JSON.parse(stdout));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** A share's collaborator notes: the ones a viewer authored (author stamped). */
|
|
85
|
+
export function collaboratorAnnotations(session: Thread): Annotation[] {
|
|
86
|
+
return session.annotations.filter((annotation) => annotation.author);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* What a pulled share hands the local merge: collaborators' notes, the
|
|
91
|
+
* participant registry, and the removals the share recorded, by entry id.
|
|
92
|
+
*/
|
|
93
|
+
export function mergeFromShare(remote: Thread): SharedMerge {
|
|
94
|
+
const merge: SharedMerge = { annotations: collaboratorAnnotations(remote) };
|
|
95
|
+
|
|
96
|
+
if (remote.participants) merge.participants = remote.participants;
|
|
97
|
+
if (remote.history) {
|
|
98
|
+
merge.removals = removalEntries(remote.history).map((entry) => ({
|
|
99
|
+
id: entry.id,
|
|
100
|
+
annotationId: entry.annotationId,
|
|
101
|
+
createdAt: entry.createdAt,
|
|
102
|
+
}));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return merge;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Mirror the planner's own annotations up into the share, so collaborators see
|
|
110
|
+
* them on their next refresh. Owner-gated at the gateway; the notes stay
|
|
111
|
+
* unauthored (the planner's), unioned by id.
|
|
112
|
+
*/
|
|
113
|
+
export async function pushShare(
|
|
114
|
+
shareId: string,
|
|
115
|
+
annotations: Array<Omit<Annotation, "createdAt">>,
|
|
116
|
+
// ShareAccess sets a private allowlist; "public" clears it (makes the link public); undefined leaves it
|
|
117
|
+
access?: ShareAccess | "public",
|
|
118
|
+
target: ShareTarget = {},
|
|
119
|
+
): Promise<void> {
|
|
120
|
+
const payload = access ? { shareId, annotations, access } : { shareId, annotations };
|
|
121
|
+
const { stderr, code } = await runShareSsh(
|
|
122
|
+
"cueloop-push",
|
|
123
|
+
Buffer.from(JSON.stringify(payload)),
|
|
124
|
+
target,
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
if (code !== 0) throw new Error(`gateway push failed: ${stderr.trim() || `ssh exited ${code}`}`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Revoke a share: the gateway deletes the blob so its link stops resolving. Owner-gated; idempotent. */
|
|
131
|
+
export async function revokeShare(shareId: string, target: ShareTarget = {}): Promise<void> {
|
|
132
|
+
const { stderr, code } = await runShareSsh("cueloop-revoke", Buffer.from(shareId), target);
|
|
133
|
+
|
|
134
|
+
if (code !== 0)
|
|
135
|
+
throw new Error(`gateway revoke failed: ${stderr.trim() || `ssh exited ${code}`}`);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export interface ShareWatchHandlers {
|
|
139
|
+
/** The whole session record, each time the share changes. */
|
|
140
|
+
onSession: (session: Thread) => void;
|
|
141
|
+
/** The stream ended, for any reason; the caller decides whether to reconnect. */
|
|
142
|
+
onClose: (reason: string) => void;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const WatchFrameSchema = v.variant("type", [
|
|
146
|
+
v.object({ type: v.literal("ready") }),
|
|
147
|
+
v.object({ type: v.literal("ping") }),
|
|
148
|
+
v.object({ type: v.literal("session"), session: ThreadRecordSchema }),
|
|
149
|
+
]);
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Follow a share live: one long-lived `cueloop-watch` ssh stream that carries
|
|
153
|
+
* a JSON line per change. Owner-gated at the gateway. Returns a stop handle.
|
|
154
|
+
*/
|
|
155
|
+
export function watchShare(
|
|
156
|
+
shareId: string,
|
|
157
|
+
handlers: ShareWatchHandlers,
|
|
158
|
+
target: ShareTarget = {},
|
|
159
|
+
): () => void {
|
|
160
|
+
const proc = Bun.spawn(shareSshCommand("cueloop-watch", target), {
|
|
161
|
+
stdin: "pipe",
|
|
162
|
+
stdout: "pipe",
|
|
163
|
+
stderr: "pipe",
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
proc.stdin.write(shareId);
|
|
167
|
+
proc.stdin.end();
|
|
168
|
+
void readLines(proc.stdout, (line) => {
|
|
169
|
+
const frame = v.safeParse(WatchFrameSchema, JSON.parse(line));
|
|
170
|
+
|
|
171
|
+
if (frame.success && frame.output.type === "session") handlers.onSession(frame.output.session);
|
|
172
|
+
}).catch(() => {});
|
|
173
|
+
void Promise.all([proc.exited, new Response(proc.stderr).text()]).then(([code, stderr]) =>
|
|
174
|
+
handlers.onClose(stderr.trim() || `ssh exited ${code}`),
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
return () => proc.kill();
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Feed each complete line of a byte stream to `onLine`; a torn line is dropped. */
|
|
181
|
+
async function readLines(
|
|
182
|
+
stream: ReadableStream<Uint8Array>,
|
|
183
|
+
onLine: (line: string) => void,
|
|
184
|
+
): Promise<void> {
|
|
185
|
+
const decoder = new TextDecoder();
|
|
186
|
+
let pending = "";
|
|
187
|
+
|
|
188
|
+
for await (const chunk of stream) {
|
|
189
|
+
pending += decoder.decode(chunk, { stream: true });
|
|
190
|
+
const lines = pending.split("\n");
|
|
191
|
+
|
|
192
|
+
pending = lines.pop() ?? "";
|
|
193
|
+
for (const line of lines) {
|
|
194
|
+
if (line.trim() === "") continue;
|
|
195
|
+
try {
|
|
196
|
+
onLine(line);
|
|
197
|
+
} catch {
|
|
198
|
+
// one bad line never ends the stream
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function shareSshCommand(command: string, target: ShareTarget): string[] {
|
|
205
|
+
const host = target.host ?? DEFAULT_SHARE_HOST;
|
|
206
|
+
const port = target.port ?? DEFAULT_SHARE_PORT;
|
|
207
|
+
|
|
208
|
+
return [
|
|
209
|
+
"ssh",
|
|
210
|
+
"-p",
|
|
211
|
+
String(port),
|
|
212
|
+
"-o",
|
|
213
|
+
"StrictHostKeyChecking=accept-new",
|
|
214
|
+
`${SHARE_UPLOAD_USER}@${host}`,
|
|
215
|
+
command,
|
|
216
|
+
];
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Run one `share@host:port` ssh command with `stdin`; hand back its streams and exit code. */
|
|
220
|
+
async function runShareSsh(
|
|
221
|
+
command: string,
|
|
222
|
+
stdin: Buffer,
|
|
223
|
+
target: ShareTarget,
|
|
224
|
+
): Promise<{ stdout: string; stderr: string; code: number }> {
|
|
225
|
+
const proc = Bun.spawn(shareSshCommand(command, target), {
|
|
226
|
+
stdin,
|
|
227
|
+
stdout: "pipe",
|
|
228
|
+
stderr: "pipe",
|
|
229
|
+
});
|
|
230
|
+
const [stdout, stderr, code] = await Promise.all([
|
|
231
|
+
new Response(proc.stdout).text(),
|
|
232
|
+
new Response(proc.stderr).text(),
|
|
233
|
+
proc.exited,
|
|
234
|
+
]);
|
|
235
|
+
|
|
236
|
+
return { stdout, stderr, code };
|
|
237
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { loadSkills } from "./skills";
|
|
6
|
+
import { mergeSlashItems, type SlashItem } from "./slash-palette";
|
|
7
|
+
|
|
8
|
+
function skillDir(): string {
|
|
9
|
+
const root = mkdtempSync(join(tmpdir(), "cueloop-skills-"));
|
|
10
|
+
|
|
11
|
+
mkdirSync(join(root, "typescript-magician"));
|
|
12
|
+
writeFileSync(
|
|
13
|
+
join(root, "typescript-magician", "SKILL.md"),
|
|
14
|
+
"---\nname: typescript-magician\ndescription: Design complex generic types.\n---\n\nbody\n",
|
|
15
|
+
);
|
|
16
|
+
mkdirSync(join(root, "no-frontmatter"));
|
|
17
|
+
writeFileSync(join(root, "no-frontmatter", "SKILL.md"), "just prose, no frontmatter\n");
|
|
18
|
+
|
|
19
|
+
return root;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe("loadSkills", () => {
|
|
23
|
+
test("reads name and description from each SKILL.md frontmatter", () => {
|
|
24
|
+
const root = skillDir();
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
const skills = loadSkills(root);
|
|
28
|
+
const magician = skills.find((skill) => skill.name === "typescript-magician");
|
|
29
|
+
|
|
30
|
+
expect(magician?.description).toBe("Design complex generic types.");
|
|
31
|
+
// a directory whose SKILL.md has no frontmatter is skipped, not crashed on
|
|
32
|
+
expect(skills.some((skill) => skill.name === "no-frontmatter")).toBe(false);
|
|
33
|
+
} finally {
|
|
34
|
+
rmSync(root, { recursive: true, force: true });
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("a missing skills directory yields no skills", () => {
|
|
39
|
+
expect(loadSkills(join(tmpdir(), "cueloop-does-not-exist-xyz"))).toEqual([]);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("CRLF delimiters parse, and a quoted name is unquoted", () => {
|
|
43
|
+
const root = mkdtempSync(join(tmpdir(), "cueloop-skills-crlf-"));
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
mkdirSync(join(root, "review"));
|
|
47
|
+
writeFileSync(
|
|
48
|
+
join(root, "review", "SKILL.md"),
|
|
49
|
+
'---\r\nname: "review"\r\ndescription: Review the change.\r\n---\r\n',
|
|
50
|
+
);
|
|
51
|
+
const skills = loadSkills(root);
|
|
52
|
+
|
|
53
|
+
// no stray quotes and no CRLF-broken frontmatter
|
|
54
|
+
expect(skills.map((skill) => skill.name)).toEqual(["review"]);
|
|
55
|
+
} finally {
|
|
56
|
+
rmSync(root, { recursive: true, force: true });
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe("mergeSlashItems", () => {
|
|
62
|
+
test("a quick action wins a name collision with a skill", () => {
|
|
63
|
+
const actions: SlashItem[] = [{ name: "lgtm", description: "action", body: "LGTM" }];
|
|
64
|
+
const skills: SlashItem[] = [
|
|
65
|
+
{ name: "lgtm", description: "skill", body: "" },
|
|
66
|
+
{ name: "vitest-patterns", description: "skill", body: "" },
|
|
67
|
+
];
|
|
68
|
+
const merged = mergeSlashItems(actions, skills);
|
|
69
|
+
|
|
70
|
+
expect(merged.filter((item) => item.name === "lgtm")).toHaveLength(1);
|
|
71
|
+
expect(merged.find((item) => item.name === "lgtm")?.description).toBe("action");
|
|
72
|
+
expect(merged.some((item) => item.name === "vitest-patterns")).toBe(true);
|
|
73
|
+
});
|
|
74
|
+
});
|
package/src/skills.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { createContext } from "react";
|
|
2
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import * as v from "valibot";
|
|
6
|
+
import type { SlashItem } from "./slash-palette";
|
|
7
|
+
|
|
8
|
+
/** The default user-level skill store, harness-agnostic; a config path overrides it. */
|
|
9
|
+
export function defaultSkillsDir(): string {
|
|
10
|
+
return join(homedir(), ".agents", "skills");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const SkillNameSchema = v.pipe(v.string(), v.trim(), v.minLength(1));
|
|
14
|
+
|
|
15
|
+
/** One `key: value` line from the frontmatter, unquoted; undefined when the key is absent. */
|
|
16
|
+
function frontmatterValue(frontmatter: string, key: string): string | undefined {
|
|
17
|
+
const match = new RegExp(`^${key}:\\s*(.+)$`, "m").exec(frontmatter);
|
|
18
|
+
|
|
19
|
+
return match
|
|
20
|
+
? match[1]!
|
|
21
|
+
.trim()
|
|
22
|
+
.replace(/^["']|["']$/g, "")
|
|
23
|
+
.trim()
|
|
24
|
+
: undefined;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** User skills as palette items: each `<dir>/SKILL.md` whose frontmatter carries a name and description. */
|
|
28
|
+
export function loadSkills(dir: string): SlashItem[] {
|
|
29
|
+
let entries;
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
33
|
+
} catch {
|
|
34
|
+
return [];
|
|
35
|
+
}
|
|
36
|
+
const items: SlashItem[] = [];
|
|
37
|
+
|
|
38
|
+
for (const entry of entries) {
|
|
39
|
+
if (!entry.isDirectory()) continue;
|
|
40
|
+
let text;
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
text = readFileSync(join(dir, entry.name, "SKILL.md"), "utf8");
|
|
44
|
+
} catch {
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
const frontmatter = /^---\n([\s\S]*?)\n---/.exec(text.replace(/\r\n/g, "\n"));
|
|
48
|
+
|
|
49
|
+
if (!frontmatter) continue;
|
|
50
|
+
const name = v.safeParse(
|
|
51
|
+
SkillNameSchema,
|
|
52
|
+
frontmatterValue(frontmatter[1]!, "name") ?? entry.name,
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
if (!name.success) continue;
|
|
56
|
+
|
|
57
|
+
items.push({
|
|
58
|
+
name: name.output,
|
|
59
|
+
description: frontmatterValue(frontmatter[1]!, "description") ?? "",
|
|
60
|
+
body: "",
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return items;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Skills reach the composer palette through context, so no surface needs a new prop to list them. */
|
|
68
|
+
export const SlashSkillsContext = createContext<SlashItem[]>([]);
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The action and skill names every composer paints as "/name" references. Held in
|
|
72
|
+
* context so each surface - the plan thread, the diff sheet, the prototype - reads
|
|
73
|
+
* the same palette without threading a prop.
|
|
74
|
+
*/
|
|
75
|
+
export const PaletteNamesContext = createContext<ReadonlySet<string>>(new Set());
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
activeSlashToken,
|
|
4
|
+
insertSlashItem,
|
|
5
|
+
isStandaloneSlashQuery,
|
|
6
|
+
scoreMatch,
|
|
7
|
+
slashFilter,
|
|
8
|
+
type SlashItem,
|
|
9
|
+
} from "./slash-palette";
|
|
10
|
+
|
|
11
|
+
function item(name: string): SlashItem {
|
|
12
|
+
return { name, description: name, body: name };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const ITEMS: SlashItem[] = [
|
|
16
|
+
item("zoom-out-research-in-depth"),
|
|
17
|
+
item("restate-simplified"),
|
|
18
|
+
item("out-of-scope"),
|
|
19
|
+
item("lgtm"),
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
describe("scoreMatch", () => {
|
|
23
|
+
test("an exact name scores highest, then a prefix, then a subsequence", () => {
|
|
24
|
+
const exact = scoreMatch("lgtm", "lgtm")!;
|
|
25
|
+
const prefix = scoreMatch("lgtm-please", "lgtm")!;
|
|
26
|
+
const subsequence = scoreMatch("looking-good-to-me", "lgtm")!;
|
|
27
|
+
|
|
28
|
+
expect(exact).toBeGreaterThan(prefix);
|
|
29
|
+
expect(prefix).toBeGreaterThan(subsequence);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("a query whose characters are not all present, in order, does not match", () => {
|
|
33
|
+
expect(scoreMatch("restate", "zzz")).toBeNull();
|
|
34
|
+
expect(scoreMatch("abc", "acb")).toBeNull();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("an empty query matches everything", () => {
|
|
38
|
+
expect(scoreMatch("anything", "")).toBe(0);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe("slashFilter", () => {
|
|
43
|
+
test("ranks a prefix match above a mere subsequence match", () => {
|
|
44
|
+
const ranked = slashFilter(ITEMS, "out");
|
|
45
|
+
|
|
46
|
+
expect(ranked[0]!.name).toBe("out-of-scope");
|
|
47
|
+
expect(ranked.map((entry) => entry.name)).toContain("zoom-out-research-in-depth");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("drops items that do not match and returns all on an empty query", () => {
|
|
51
|
+
expect(slashFilter(ITEMS, "zzzz")).toEqual([]);
|
|
52
|
+
expect(slashFilter(ITEMS, "")).toHaveLength(ITEMS.length);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe("activeSlashToken", () => {
|
|
57
|
+
const atEnd = (text: string) => activeSlashToken(text, text.length);
|
|
58
|
+
|
|
59
|
+
test("finds the caret's /word at the start or after a space, so each new / reopens the palette", () => {
|
|
60
|
+
expect(atEnd("/vitest-patt")).toBe("/vitest-patt");
|
|
61
|
+
expect(atEnd("use /vitest-patt")).toBe("/vitest-patt");
|
|
62
|
+
expect(atEnd("/zoom-out hello world /")).toBe("/");
|
|
63
|
+
// an underscore is a valid skill-name character, so the token spans it
|
|
64
|
+
expect(atEnd("run /my_skill")).toBe("/my_skill");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("closes on the space that ends the token, and ignores prose", () => {
|
|
68
|
+
expect(atEnd("/zoom-out ")).toBeNull();
|
|
69
|
+
expect(atEnd("just some prose")).toBeNull();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("tracks the caret, not the string end: a slash mid-draft is the active token", () => {
|
|
73
|
+
// caret sits right after "/imp" in "/imp done", ignoring the trailing text
|
|
74
|
+
expect(activeSlashToken("/imp done", 4)).toBe("/imp");
|
|
75
|
+
// caret inside plain prose, even with a later slash token, has no active token
|
|
76
|
+
expect(activeSlashToken("plain text /later", 5)).toBeNull();
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe("isStandaloneSlashQuery", () => {
|
|
81
|
+
test("true only when the whole draft is a bare /query, so prose ending in /name still saves", () => {
|
|
82
|
+
expect(isStandaloneSlashQuery("/lgtm")).toBe(true);
|
|
83
|
+
expect(isStandaloneSlashQuery(" /lgtm ")).toBe(true);
|
|
84
|
+
expect(isStandaloneSlashQuery("please inspect /tmp")).toBe(false);
|
|
85
|
+
expect(isStandaloneSlashQuery("just prose")).toBe(false);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe("insertSlashItem", () => {
|
|
90
|
+
test("replaces the active slash token with the reference and lands the caret after it", () => {
|
|
91
|
+
const result = insertSlashItem("look at /rev", 12, "review");
|
|
92
|
+
|
|
93
|
+
expect(result.text).toBe("look at /review ");
|
|
94
|
+
expect(result.caret).toBe("look at /review ".length);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("keeps text after the caret intact", () => {
|
|
98
|
+
const result = insertSlashItem("/pl tail", 3, "plan");
|
|
99
|
+
|
|
100
|
+
expect(result.text).toBe("/plan tail");
|
|
101
|
+
expect(result.caret).toBe("/plan ".length);
|
|
102
|
+
});
|
|
103
|
+
});
|