@cueloop/client 0.1.0-alpha.7 → 0.1.0-alpha.71
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 +39 -32
- package/src/App.appearance.test.tsx +53 -0
- package/src/App.autoclose.test.tsx +76 -50
- package/src/App.diff.test.tsx +156 -42
- package/src/App.export.test.tsx +58 -40
- package/src/App.plan-review.test.tsx +363 -0
- package/src/App.prototype-markdown.test.tsx +92 -0
- package/src/App.prototype.test.tsx +176 -0
- package/src/App.readonly.test.tsx +128 -67
- package/src/App.rendering.test.tsx +61 -35
- package/src/App.submit-confirm.test.tsx +191 -0
- package/src/App.test.tsx +310 -114
- package/src/App.toast.test.tsx +89 -0
- package/src/App.tsx +1043 -798
- package/src/App.walk.test.tsx +298 -0
- package/src/App.workbench.test.tsx +495 -0
- package/src/__snapshots__/view-plan.regression.test.ts.snap +100 -0
- package/src/annotation-palette.ts +61 -0
- package/src/app-key-state.ts +61 -0
- package/src/app-screens.tsx +432 -0
- package/src/app-view-model.ts +159 -0
- package/src/attribution.test.ts +31 -0
- package/src/attribution.ts +42 -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 +126 -0
- package/src/components/AnnotationCards.stories.tsx +87 -0
- package/src/components/AnnotationCards.tsx +303 -0
- package/src/components/AppHeader.stories.tsx +50 -0
- package/src/components/AppHeader.tsx +83 -0
- package/src/components/AppShell.stories.tsx +89 -0
- package/src/components/AppShell.tsx +290 -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 +39 -0
- package/src/components/ChangesColumn.tsx +171 -0
- package/src/components/CodeBlock.stories.tsx +42 -0
- package/src/components/CodeBlock.tsx +84 -0
- package/src/components/CompletionOverlay.stories.tsx +23 -0
- package/src/components/CompletionOverlay.tsx +60 -0
- package/src/components/ConfirmCard.stories.tsx +35 -0
- package/src/components/ConfirmCard.tsx +128 -0
- package/src/components/ConfirmDialog.stories.tsx +21 -0
- package/src/components/ConfirmDialog.tsx +66 -0
- package/src/components/DiffContentView.scroll.test.tsx +121 -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 +859 -0
- package/src/components/DiscussionMarkerRail.stories.tsx +54 -0
- package/src/components/DiscussionMarkerRail.tsx +181 -0
- package/src/components/EditorGrid.stories.tsx +59 -0
- package/src/components/EditorGrid.tabs.test.tsx +118 -0
- package/src/components/EditorGrid.tsx +368 -0
- package/src/components/EditorGrid.zoom.test.tsx +68 -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/KeybindsDialog.stories.tsx +31 -0
- package/src/components/KeybindsDialog.tsx +103 -0
- package/src/components/MenuBar.stories.tsx +28 -0
- package/src/components/MenuBar.tsx +44 -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 +95 -0
- package/src/components/ProjectTreeView.stories.tsx +25 -0
- package/src/components/ProjectTreeView.test.tsx +32 -0
- package/src/components/ProjectTreeView.tsx +83 -0
- package/src/components/PromptDialog.stories.tsx +36 -0
- package/src/components/PromptDialog.tsx +87 -0
- package/src/components/PrototypeContentView.stories.tsx +19 -0
- package/src/components/PrototypeContentView.tsx +446 -0
- package/src/components/RootOverlay.stories.tsx +44 -0
- package/src/components/RootOverlay.tsx +35 -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 +195 -0
- package/src/components/SettingsRows.stories.tsx +18 -0
- package/src/components/SettingsRows.tsx +112 -0
- package/src/components/ShellHeader.stories.tsx +50 -0
- package/src/components/ShellHeader.tsx +137 -0
- package/src/components/ThreadFooter.stories.tsx +30 -0
- package/src/components/ThreadFooter.test.tsx +31 -0
- package/src/components/ThreadFooter.tsx +54 -0
- package/src/components/ThreadTree.stories.tsx +46 -0
- package/src/components/ThreadTree.tsx +219 -0
- package/src/components/ThreadView.selection.test.tsx +812 -0
- package/src/components/ThreadView.slash.test.tsx +158 -0
- package/src/components/ThreadView.stories.tsx +26 -0
- package/src/components/ThreadView.tsx +330 -0
- package/src/components/ThreadsSidebar.stories.tsx +36 -0
- package/src/components/ThreadsSidebar.tsx +71 -0
- package/src/components/Toast.stories.tsx +12 -0
- package/src/components/Toast.tsx +45 -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 +152 -0
- package/src/components/WelcomePlayground.stories.tsx +12 -0
- package/src/components/WelcomePlayground.test.tsx +47 -0
- package/src/components/WelcomePlayground.tsx +140 -0
- package/src/components/__snapshots__/stories.test.tsx.snap +1845 -0
- package/src/components/agent-launcher.stories.tsx +26 -0
- package/src/components/agent-launcher.test.tsx +50 -0
- package/src/components/agent-launcher.tsx +149 -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/fade-title.test.ts +49 -0
- package/src/components/fade-title.ts +61 -0
- package/src/components/file-tree.test.ts +60 -0
- package/src/components/file-tree.ts +112 -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 +52 -0
- package/src/components/primitives/Card.stories.tsx +25 -0
- package/src/components/primitives/Card.tsx +60 -0
- package/src/components/primitives/Dialog.stories.tsx +24 -0
- package/src/components/primitives/Dialog.tsx +66 -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/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 +96 -0
- package/src/components/primitives/frame.test.ts +12 -0
- package/src/components/primitives/frame.ts +14 -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 +54 -0
- package/src/components/quick-actions-editor.test.tsx +49 -0
- package/src/components/quick-actions-editor.tsx +150 -0
- package/src/components/session-tree.test.ts +134 -0
- package/src/components/session-tree.ts +108 -0
- package/src/components/stories-app.tsx +184 -0
- package/src/components/stories.test.tsx +66 -0
- package/src/components/story-fixtures.ts +102 -0
- package/src/components/story-tree.test.ts +100 -0
- package/src/components/story.ts +142 -0
- package/src/components/syntax-highlight.ts +110 -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/truncate-text.ts +7 -0
- package/src/config.test.ts +461 -31
- package/src/config.ts +389 -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 +165 -0
- package/src/diff-intraline.ts +146 -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/herdr.test.ts +9 -34
- package/src/herdr.ts +25 -21
- package/src/index.ts +11 -0
- package/src/integrations.test.ts +41 -0
- package/src/integrations.ts +32 -0
- package/src/intent-dispatch.test.ts +698 -0
- package/src/intent-dispatch.ts +700 -0
- package/src/key-bindings.test.ts +156 -0
- package/src/key-bindings.ts +465 -0
- package/src/keymap.test.ts +611 -0
- package/src/keymap.ts +410 -0
- package/src/kitty-image.test.ts +66 -0
- package/src/kitty-image.ts +138 -0
- package/src/mark-runs.ts +120 -0
- package/src/prototype-browser.test.ts +56 -0
- package/src/prototype-browser.ts +307 -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 +30 -9
- package/src/serve.test.ts +24 -12
- package/src/serve.ts +20 -9
- package/src/share.ts +215 -0
- package/src/skills.test.ts +74 -0
- package/src/skills.ts +68 -0
- package/src/slash-palette.test.ts +54 -0
- package/src/slash-palette.ts +104 -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 +355 -0
- package/src/theme-presets.test.ts +51 -0
- package/src/theme-presets.ts +214 -0
- package/src/theme.ts +84 -19
- package/src/thread-chords.test.ts +170 -0
- package/src/thread-chords.ts +169 -0
- package/src/thread-controller.curate.test.ts +438 -0
- package/src/thread-controller.live-diff.test.ts +199 -0
- package/src/thread-controller.share.test.ts +472 -0
- package/src/thread-controller.tree.test.ts +255 -0
- package/src/thread-controller.ts +1509 -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 +148 -0
- package/src/use-annotation-surface.tsx +851 -0
- package/src/use-changes-workbench.ts +153 -0
- package/src/use-frame-measure.ts +38 -0
- package/src/use-settings-dialog.tsx +274 -0
- package/src/use-terminal-virtualizer.ts +233 -0
- package/src/version.ts +5 -0
- package/src/view-diff.test.ts +66 -9
- package/src/view-diff.ts +231 -23
- package/src/view-plan.regression.test.ts +60 -0
- package/src/view-plan.test.ts +406 -0
- package/src/view-plan.ts +570 -0
- package/src/walk.test.ts +121 -0
- package/src/walk.ts +74 -0
- 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 -312
package/src/share.ts
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Publish a review session 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 { removalEntries, viewFollowing, type Annotation, type Thread } from "@cueloop/schema";
|
|
16
|
+
import { ThreadRecordSchema } from "@cueloop/daemon/validate";
|
|
17
|
+
import type { SharedMerge } from "@cueloop/daemon/client";
|
|
18
|
+
import * as v from "valibot";
|
|
19
|
+
import { copyToClipboard } from "./clipboard";
|
|
20
|
+
|
|
21
|
+
export interface ShareTarget {
|
|
22
|
+
host?: string;
|
|
23
|
+
port?: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ShareResult {
|
|
27
|
+
/** The one line to paste: `ssh p_xxxxxxxx@host`. */
|
|
28
|
+
line: string;
|
|
29
|
+
/** Whether the line made it onto the system clipboard. */
|
|
30
|
+
copied: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The `p_…` share id inside a `ssh p_…@host` line, or undefined. */
|
|
34
|
+
export function shareIdFromLine(line: string): string | undefined {
|
|
35
|
+
return line.match(/^ssh (\S+)@/)?.[1];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Upload the session to the gateway and copy the resulting ssh line. */
|
|
39
|
+
export async function publishShare(
|
|
40
|
+
session: Thread,
|
|
41
|
+
target: ShareTarget = {},
|
|
42
|
+
): Promise<ShareResult> {
|
|
43
|
+
const { stdout, stderr, code } = await runShareSsh(
|
|
44
|
+
"cueloop-share",
|
|
45
|
+
// the share follows one branch: collaborators see its path wherever the owner stands
|
|
46
|
+
packSessionBlob(viewFollowing(session)),
|
|
47
|
+
target,
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
if (code !== 0)
|
|
51
|
+
throw new Error(`gateway upload failed: ${stderr.trim() || `ssh exited ${code}`}`);
|
|
52
|
+
const line = stdout.trim();
|
|
53
|
+
|
|
54
|
+
if (!line.startsWith("ssh ")) throw new Error(`unexpected gateway reply: ${line || "(empty)"}`);
|
|
55
|
+
const copied = await copyToClipboard(line);
|
|
56
|
+
|
|
57
|
+
return { line, copied };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Pull a share's current session back from the gateway. The gateway lets only
|
|
62
|
+
* the fingerprint that uploaded it through, so collaborator notes reach the
|
|
63
|
+
* planner without exposing the master key.
|
|
64
|
+
*/
|
|
65
|
+
export async function pullShare(shareId: string, target: ShareTarget = {}): Promise<Thread> {
|
|
66
|
+
const { stdout, stderr, code } = await runShareSsh("cueloop-pull", Buffer.from(shareId), target);
|
|
67
|
+
|
|
68
|
+
if (code !== 0) throw new Error(`gateway pull failed: ${stderr.trim() || `ssh exited ${code}`}`);
|
|
69
|
+
|
|
70
|
+
return v.parse(ThreadRecordSchema, JSON.parse(stdout));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** A share's collaborator notes: the ones a viewer authored (author stamped). */
|
|
74
|
+
export function collaboratorAnnotations(session: Thread): Annotation[] {
|
|
75
|
+
return session.annotations.filter((annotation) => annotation.author);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* What a pulled share hands the local merge: collaborators' notes, the
|
|
80
|
+
* participant registry, and the removals the share recorded, by entry id.
|
|
81
|
+
*/
|
|
82
|
+
export function mergeFromShare(remote: Thread): SharedMerge {
|
|
83
|
+
const merge: SharedMerge = { annotations: collaboratorAnnotations(remote) };
|
|
84
|
+
|
|
85
|
+
if (remote.participants) merge.participants = remote.participants;
|
|
86
|
+
if (remote.history) {
|
|
87
|
+
merge.removals = removalEntries(remote.history).map((entry) => ({
|
|
88
|
+
id: entry.id,
|
|
89
|
+
annotationId: entry.annotationId,
|
|
90
|
+
createdAt: entry.createdAt,
|
|
91
|
+
}));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return merge;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Mirror the planner's own annotations up into the share, so collaborators see
|
|
99
|
+
* them on their next refresh. Owner-gated at the gateway; the notes stay
|
|
100
|
+
* unauthored (the planner's), unioned by id.
|
|
101
|
+
*/
|
|
102
|
+
export async function pushShare(
|
|
103
|
+
shareId: string,
|
|
104
|
+
annotations: Array<Omit<Annotation, "createdAt">>,
|
|
105
|
+
target: ShareTarget = {},
|
|
106
|
+
): Promise<void> {
|
|
107
|
+
const { stderr, code } = await runShareSsh(
|
|
108
|
+
"cueloop-push",
|
|
109
|
+
Buffer.from(JSON.stringify({ shareId, annotations })),
|
|
110
|
+
target,
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
if (code !== 0) throw new Error(`gateway push failed: ${stderr.trim() || `ssh exited ${code}`}`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export interface ShareWatchHandlers {
|
|
117
|
+
/** The whole session record, each time the share changes. */
|
|
118
|
+
onSession: (session: Thread) => void;
|
|
119
|
+
/** The stream ended, for any reason; the caller decides whether to reconnect. */
|
|
120
|
+
onClose: (reason: string) => void;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const WatchFrameSchema = v.variant("type", [
|
|
124
|
+
v.object({ type: v.literal("ready") }),
|
|
125
|
+
v.object({ type: v.literal("ping") }),
|
|
126
|
+
v.object({ type: v.literal("session"), session: ThreadRecordSchema }),
|
|
127
|
+
]);
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Follow a share live: one long-lived `cueloop-watch` ssh stream that carries
|
|
131
|
+
* a JSON line per change. Owner-gated at the gateway. Returns a stop handle.
|
|
132
|
+
*/
|
|
133
|
+
export function watchShare(
|
|
134
|
+
shareId: string,
|
|
135
|
+
handlers: ShareWatchHandlers,
|
|
136
|
+
target: ShareTarget = {},
|
|
137
|
+
): () => void {
|
|
138
|
+
const proc = Bun.spawn(shareSshCommand("cueloop-watch", target), {
|
|
139
|
+
stdin: "pipe",
|
|
140
|
+
stdout: "pipe",
|
|
141
|
+
stderr: "pipe",
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
proc.stdin.write(shareId);
|
|
145
|
+
proc.stdin.end();
|
|
146
|
+
void readLines(proc.stdout, (line) => {
|
|
147
|
+
const frame = v.safeParse(WatchFrameSchema, JSON.parse(line));
|
|
148
|
+
|
|
149
|
+
if (frame.success && frame.output.type === "session") handlers.onSession(frame.output.session);
|
|
150
|
+
}).catch(() => {});
|
|
151
|
+
void Promise.all([proc.exited, new Response(proc.stderr).text()]).then(([code, stderr]) =>
|
|
152
|
+
handlers.onClose(stderr.trim() || `ssh exited ${code}`),
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
return () => proc.kill();
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Feed each complete line of a byte stream to `onLine`; a torn line is dropped. */
|
|
159
|
+
async function readLines(
|
|
160
|
+
stream: ReadableStream<Uint8Array>,
|
|
161
|
+
onLine: (line: string) => void,
|
|
162
|
+
): Promise<void> {
|
|
163
|
+
const decoder = new TextDecoder();
|
|
164
|
+
let pending = "";
|
|
165
|
+
|
|
166
|
+
for await (const chunk of stream) {
|
|
167
|
+
pending += decoder.decode(chunk, { stream: true });
|
|
168
|
+
const lines = pending.split("\n");
|
|
169
|
+
|
|
170
|
+
pending = lines.pop() ?? "";
|
|
171
|
+
for (const line of lines) {
|
|
172
|
+
if (line.trim() === "") continue;
|
|
173
|
+
try {
|
|
174
|
+
onLine(line);
|
|
175
|
+
} catch {
|
|
176
|
+
// one bad line never ends the stream
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function shareSshCommand(command: string, target: ShareTarget): string[] {
|
|
183
|
+
const host = target.host ?? DEFAULT_SHARE_HOST;
|
|
184
|
+
const port = target.port ?? DEFAULT_SHARE_PORT;
|
|
185
|
+
|
|
186
|
+
return [
|
|
187
|
+
"ssh",
|
|
188
|
+
"-p",
|
|
189
|
+
String(port),
|
|
190
|
+
"-o",
|
|
191
|
+
"StrictHostKeyChecking=accept-new",
|
|
192
|
+
`${SHARE_UPLOAD_USER}@${host}`,
|
|
193
|
+
command,
|
|
194
|
+
];
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Run one `share@host:port` ssh command with `stdin`; hand back its streams and exit code. */
|
|
198
|
+
async function runShareSsh(
|
|
199
|
+
command: string,
|
|
200
|
+
stdin: Buffer,
|
|
201
|
+
target: ShareTarget,
|
|
202
|
+
): Promise<{ stdout: string; stderr: string; code: number }> {
|
|
203
|
+
const proc = Bun.spawn(shareSshCommand(command, target), {
|
|
204
|
+
stdin,
|
|
205
|
+
stdout: "pipe",
|
|
206
|
+
stderr: "pipe",
|
|
207
|
+
});
|
|
208
|
+
const [stdout, stderr, code] = await Promise.all([
|
|
209
|
+
new Response(proc.stdout).text(),
|
|
210
|
+
new Response(proc.stderr).text(),
|
|
211
|
+
proc.exited,
|
|
212
|
+
]);
|
|
213
|
+
|
|
214
|
+
return { stdout, stderr, code };
|
|
215
|
+
}
|
|
@@ -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,68 @@
|
|
|
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[]>([]);
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { inlineSlashToken, scoreMatch, slashFilter, type SlashItem } from "./slash-palette";
|
|
3
|
+
|
|
4
|
+
function item(name: string): SlashItem {
|
|
5
|
+
return { name, description: name, body: name };
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const ITEMS: SlashItem[] = [
|
|
9
|
+
item("zoom-out-research-in-depth"),
|
|
10
|
+
item("restate-simplified"),
|
|
11
|
+
item("out-of-scope"),
|
|
12
|
+
item("lgtm"),
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
describe("scoreMatch", () => {
|
|
16
|
+
test("an exact name scores highest, then a prefix, then a subsequence", () => {
|
|
17
|
+
const exact = scoreMatch("lgtm", "lgtm")!;
|
|
18
|
+
const prefix = scoreMatch("lgtm-please", "lgtm")!;
|
|
19
|
+
const subsequence = scoreMatch("looking-good-to-me", "lgtm")!;
|
|
20
|
+
|
|
21
|
+
expect(exact).toBeGreaterThan(prefix);
|
|
22
|
+
expect(prefix).toBeGreaterThan(subsequence);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("a query whose characters are not all present, in order, does not match", () => {
|
|
26
|
+
expect(scoreMatch("restate", "zzz")).toBeNull();
|
|
27
|
+
expect(scoreMatch("abc", "acb")).toBeNull();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("an empty query matches everything", () => {
|
|
31
|
+
expect(scoreMatch("anything", "")).toBe(0);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe("slashFilter", () => {
|
|
36
|
+
test("ranks a prefix match above a mere subsequence match", () => {
|
|
37
|
+
const ranked = slashFilter(ITEMS, "out");
|
|
38
|
+
|
|
39
|
+
expect(ranked[0]!.name).toBe("out-of-scope");
|
|
40
|
+
expect(ranked.map((entry) => entry.name)).toContain("zoom-out-research-in-depth");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("drops items that do not match and returns all on an empty query", () => {
|
|
44
|
+
expect(slashFilter(ITEMS, "zzzz")).toEqual([]);
|
|
45
|
+
expect(slashFilter(ITEMS, "")).toHaveLength(ITEMS.length);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe("inlineSlashToken", () => {
|
|
50
|
+
test("finds a trailing /word only when text precedes it", () => {
|
|
51
|
+
expect(inlineSlashToken("use /vitest-patt")).toBe("/vitest-patt");
|
|
52
|
+
expect(inlineSlashToken("/vitest-patt")).toBeNull();
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The "/" quick-action palette behind a composer: the skill list a leading
|
|
3
|
+
* slash opens, its prefix > substring > subsequence filter, and the inline
|
|
4
|
+
* "/word" completion offered mid-sentence. Pure; the surfaces render it.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { quickActionBody, type QuickAction } from "./config";
|
|
8
|
+
|
|
9
|
+
export interface SlashItem {
|
|
10
|
+
name: string;
|
|
11
|
+
description: string;
|
|
12
|
+
body: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function slashItemsFrom(quickActions: QuickAction[]): SlashItem[] {
|
|
16
|
+
return quickActions.map((action) => ({
|
|
17
|
+
name: action.prompt
|
|
18
|
+
.toLowerCase()
|
|
19
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
20
|
+
.replace(/^-|-$/g, ""),
|
|
21
|
+
description: action.metadata ?? action.prompt,
|
|
22
|
+
body: quickActionBody(action),
|
|
23
|
+
}));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const WORD_BOUNDARY = /[\s\-_./:]/;
|
|
27
|
+
|
|
28
|
+
/** Order-preserving subsequence score, or null when a query character is missing; higher is better. */
|
|
29
|
+
export function scoreMatch(name: string, query: string): number | null {
|
|
30
|
+
if (query.length === 0) return 0;
|
|
31
|
+
const lowerName = name.toLowerCase();
|
|
32
|
+
const lowerQuery = query.toLowerCase();
|
|
33
|
+
|
|
34
|
+
if (lowerName === lowerQuery) return 1000;
|
|
35
|
+
if (lowerName.startsWith(lowerQuery)) return 500 - name.length;
|
|
36
|
+
let score = 0;
|
|
37
|
+
let cursor = 0;
|
|
38
|
+
let run = 0;
|
|
39
|
+
|
|
40
|
+
for (const character of lowerQuery) {
|
|
41
|
+
const found = lowerName.indexOf(character, cursor);
|
|
42
|
+
|
|
43
|
+
if (found === -1) return null;
|
|
44
|
+
run = found === cursor ? run + 1 : 0;
|
|
45
|
+
score += found === cursor ? 5 + run : 1;
|
|
46
|
+
if (found === 0 || WORD_BOUNDARY.test(lowerName[found - 1]!)) score += 10;
|
|
47
|
+
score -= found - cursor;
|
|
48
|
+
cursor = found + 1;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return score;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function slashFilter(items: SlashItem[], query: string): SlashItem[] {
|
|
55
|
+
if (query.length === 0) return items;
|
|
56
|
+
const scored: Array<{ item: SlashItem; score: number }> = [];
|
|
57
|
+
|
|
58
|
+
for (const item of items) {
|
|
59
|
+
const score = scoreMatch(item.name, query);
|
|
60
|
+
|
|
61
|
+
if (score !== null) scored.push({ item, score });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return scored.toSorted((left, right) => right.score - left.score).map((entry) => entry.item);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* A skill invoked mid-sentence: the trailing "/word" token when text already
|
|
69
|
+
* precedes it (a draft that starts with "/" is the palette, not an inline
|
|
70
|
+
* completion). Newline-safe, since the token may sit at the start of a new line.
|
|
71
|
+
*/
|
|
72
|
+
export function inlineSlashToken(text: string): string | null {
|
|
73
|
+
if (text.startsWith("/")) return null;
|
|
74
|
+
const match = /(?:^|\s)(\/[a-zA-Z0-9-]*)$/.exec(text);
|
|
75
|
+
|
|
76
|
+
return match ? match[1]! : null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface InlineSlash {
|
|
80
|
+
token: string;
|
|
81
|
+
suggestion: SlashItem;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The inline completion state: the trailing "/word" and its closest item, or null. */
|
|
85
|
+
export function resolveInlineSuggestion(
|
|
86
|
+
slashActive: boolean,
|
|
87
|
+
text: string,
|
|
88
|
+
items: SlashItem[],
|
|
89
|
+
): InlineSlash | null {
|
|
90
|
+
if (slashActive) return null;
|
|
91
|
+
const token = inlineSlashToken(text);
|
|
92
|
+
|
|
93
|
+
if (token === null) return null;
|
|
94
|
+
const suggestion = slashFilter(items, token.slice(1))[0];
|
|
95
|
+
|
|
96
|
+
return suggestion ? { token, suggestion } : null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Quick actions and user skills in one palette; a quick action wins a name collision (it expands). */
|
|
100
|
+
export function mergeSlashItems(actions: SlashItem[], skills: SlashItem[]): SlashItem[] {
|
|
101
|
+
const taken = new Set(actions.map((action) => action.name));
|
|
102
|
+
|
|
103
|
+
return [...actions, ...skills.filter((skill) => !taken.has(skill.name))];
|
|
104
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { diffRows } from "./view-diff";
|
|
3
|
+
import { splitDiffRows, splitRowOffsets, type SplitRow } from "./split-diff";
|
|
4
|
+
|
|
5
|
+
const PATCH = `diff --git a/greet.ts b/greet.ts
|
|
6
|
+
--- a/greet.ts
|
|
7
|
+
+++ b/greet.ts
|
|
8
|
+
@@ -1,4 +1,4 @@
|
|
9
|
+
const name = "world";
|
|
10
|
+
-console.log("hi " + name);
|
|
11
|
+
-console.log("done");
|
|
12
|
+
+console.log("hello " + name);
|
|
13
|
+
+console.log("finished");
|
|
14
|
+
export {};
|
|
15
|
+
`;
|
|
16
|
+
|
|
17
|
+
function pairs(rows: SplitRow[]): SplitRow[] {
|
|
18
|
+
return rows.filter((row) => row.kind === "pair");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe("splitDiffRows", () => {
|
|
22
|
+
test("carries the file and hunk headers through unchanged", () => {
|
|
23
|
+
// Act
|
|
24
|
+
const split = splitDiffRows(diffRows(PATCH));
|
|
25
|
+
|
|
26
|
+
// Assert
|
|
27
|
+
expect(split[0]).toMatchObject({ kind: "file", file: "greet.ts", text: "greet.ts" });
|
|
28
|
+
expect(split[1]?.kind).toBe("hunk");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("a context line sits on both sides with its own line numbers", () => {
|
|
32
|
+
// Act
|
|
33
|
+
const first = pairs(splitDiffRows(diffRows(PATCH)))[0]!;
|
|
34
|
+
|
|
35
|
+
// Assert
|
|
36
|
+
expect(first.left).toMatchObject({ kind: "ctx", lineNumber: 1 });
|
|
37
|
+
expect(first.right).toMatchObject({ kind: "ctx", lineNumber: 1 });
|
|
38
|
+
expect(first.left?.text).toBe(first.right?.text);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("a balanced change block pairs each deletion against an addition", () => {
|
|
42
|
+
// Act - the two deletions zip against the two additions, one per row
|
|
43
|
+
const changed = pairs(splitDiffRows(diffRows(PATCH))).filter(
|
|
44
|
+
(row) => row.left?.kind === "del" || row.right?.kind === "add",
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
// Assert
|
|
48
|
+
expect(changed).toHaveLength(2);
|
|
49
|
+
expect(changed[0]).toMatchObject({
|
|
50
|
+
left: { kind: "del", lineNumber: 2 },
|
|
51
|
+
right: { kind: "add", lineNumber: 2 },
|
|
52
|
+
});
|
|
53
|
+
expect(changed[1]).toMatchObject({
|
|
54
|
+
left: { kind: "del", lineNumber: 3 },
|
|
55
|
+
right: { kind: "add", lineNumber: 3 },
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("an unbalanced block blank-fills the shorter side", () => {
|
|
60
|
+
// Arrange - one deletion, three additions
|
|
61
|
+
const patch = `diff --git a/x.ts b/x.ts
|
|
62
|
+
--- a/x.ts
|
|
63
|
+
+++ b/x.ts
|
|
64
|
+
@@ -1,2 +1,4 @@
|
|
65
|
+
keep;
|
|
66
|
+
-old;
|
|
67
|
+
+new1;
|
|
68
|
+
+new2;
|
|
69
|
+
+new3;
|
|
70
|
+
`;
|
|
71
|
+
|
|
72
|
+
// Act
|
|
73
|
+
const changed = pairs(splitDiffRows(diffRows(patch))).filter(
|
|
74
|
+
(row) => row.left?.kind === "del" || row.right?.kind === "add",
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
// Assert - three rows tall, only the first has a left deletion
|
|
78
|
+
expect(changed).toHaveLength(3);
|
|
79
|
+
expect(changed[0]?.left?.kind).toBe("del");
|
|
80
|
+
expect(changed[1]?.left).toBeUndefined();
|
|
81
|
+
expect(changed[2]?.left).toBeUndefined();
|
|
82
|
+
expect(changed.map((row) => row.right?.text.trim())).toEqual(["new1;", "new2;", "new3;"]);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("each side keeps its originating unified row for anchoring", () => {
|
|
86
|
+
// Act
|
|
87
|
+
const base = diffRows(PATCH);
|
|
88
|
+
const changed = pairs(splitDiffRows(base)).find((row) => row.right?.kind === "add")!;
|
|
89
|
+
|
|
90
|
+
// Assert - the split line points back at a row from the base list
|
|
91
|
+
expect(base[changed.right!.rowIndex]).toBe(changed.right!.row);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe("splitRowOffsets", () => {
|
|
96
|
+
test("maps both sides of a pair to the same y and steps a file band by three", () => {
|
|
97
|
+
// Arrange
|
|
98
|
+
const base = diffRows(PATCH);
|
|
99
|
+
const split = splitDiffRows(base);
|
|
100
|
+
|
|
101
|
+
// Act
|
|
102
|
+
const offsets = splitRowOffsets(split);
|
|
103
|
+
|
|
104
|
+
// Assert - the file band is three rows and the hunk one, so the first context pair sits at y 4
|
|
105
|
+
const firstContext = split.find((row) => row.kind === "pair" && row.left?.kind === "ctx")!;
|
|
106
|
+
|
|
107
|
+
expect(offsets.get(firstContext.left!.rowIndex)).toBe(4);
|
|
108
|
+
expect(offsets.get(firstContext.right!.rowIndex)).toBe(4);
|
|
109
|
+
});
|
|
110
|
+
});
|