@cueloop/client 0.1.0-alpha.101 → 0.1.0-alpha.103
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/package.json +5 -5
- package/src/annotations/annotation-palette.ts +1 -0
- package/src/annotations/components/AnnotationCards.tsx +14 -1
- package/src/annotations/components/DiscussionMarkerRail.tsx +3 -0
- package/src/annotations/discussions.ts +1 -0
- package/src/annotations/thread-selection.ts +6 -0
- package/src/annotations/use-annotation-surface.tsx +154 -19
- package/src/app/App.deleted-scroll.test.tsx +3 -0
- package/src/app/App.harness-settings.test.tsx +77 -0
- package/src/app/App.plan-render.test.tsx +2 -0
- package/src/app/App.plan-review.test.tsx +1 -0
- package/src/app/App.test.tsx +1 -0
- package/src/app/App.tsx +72 -6
- package/src/app/App.visual.test.tsx +1 -0
- package/src/app/App.walk.test.tsx +1 -0
- package/src/app/App.workbench.test.tsx +1 -0
- package/src/app/app-screens.tsx +1 -0
- package/src/app/app-view-model.ts +7 -0
- package/src/app/components/WorkspacePanels.tsx +1 -0
- package/src/app/intent-dispatch.test.ts +1 -0
- package/src/app/intent-dispatch.ts +26 -0
- package/src/app/ready-signal.ts +2 -0
- package/src/app/run.ts +2 -0
- package/src/app/serve.test.ts +4 -0
- package/src/diff/components/AnnotatableFileView.tsx +3 -2
- package/src/diff/components/DiffContentView.scroll.test.tsx +9 -0
- package/src/diff/components/DiffContentView.syntax.test.tsx +1 -0
- package/src/diff/components/DiffContentView.tsx +28 -3
- package/src/diff/components/diff-content-view-layout.ts +3 -0
- package/src/diff/diff-fold.ts +4 -0
- package/src/diff/diff-hunk-curate.ts +3 -0
- package/src/diff/diff-intraline.ts +6 -0
- package/src/diff/diff-syntax.ts +8 -0
- package/src/diff/split-diff.ts +6 -0
- package/src/diff/view-diff.ts +18 -0
- package/src/diff/walk.ts +3 -0
- package/src/integrations/attribution.ts +2 -0
- package/src/integrations/client-extension-registry.ts +4 -0
- package/src/integrations/clipboard.ts +1 -0
- package/src/integrations/components/ShareDialog.agent.test.tsx +2 -0
- package/src/integrations/components/ShareDialog.tsx +28 -0
- package/src/integrations/editor.ts +3 -0
- package/src/integrations/github-identity.test.ts +1 -0
- package/src/integrations/github-identity.ts +1 -0
- package/src/integrations/herdr.ts +2 -0
- package/src/integrations/pasted-image.ts +2 -0
- package/src/integrations/share.ts +4 -0
- package/src/keyboard/components/HandleChipsInput.tsx +4 -0
- package/src/keyboard/components/SlashComposer.tsx +3 -0
- package/src/keyboard/key-bindings.ts +3 -0
- package/src/keyboard/keymap.ts +51 -0
- package/src/keyboard/skills.ts +2 -0
- package/src/keyboard/slash-palette.ts +8 -0
- package/src/keyboard/thread-chords.ts +5 -0
- package/src/keyboard/use-shared-keyboard.ts +1 -0
- package/src/markdown/components/MarkdownThreadEditor.tsx +3 -0
- package/src/markdown/components/ThreadView.selection.test.tsx +3 -0
- package/src/markdown/components/ThreadView.tsx +16 -3
- package/src/markdown/mark-runs.ts +4 -0
- package/src/markdown/markdown-editor-syntax-style.ts +1 -0
- package/src/markdown/markdown-grid.ts +6 -0
- package/src/markdown/markdown-highlight.ts +1 -0
- package/src/markdown/thread-code-syntax.ts +3 -0
- package/src/markdown/view-plan.ts +52 -0
- package/src/performance/perf-report.ts +1 -0
- package/src/performance/perf-timings.ts +1 -0
- package/src/prototype/components/PrototypeContentView.tsx +17 -0
- package/src/prototype/prototype-browser.ts +11 -0
- package/src/settings/components/SettingsDialog.tsx +3 -0
- package/src/settings/config.test.ts +41 -0
- package/src/settings/config.ts +44 -0
- package/src/settings/use-settings-dialog.test.tsx +98 -0
- package/src/settings/use-settings-dialog.tsx +38 -1
- package/src/settings/use-thread-harness-setting.test.tsx +87 -0
- package/src/settings/use-thread-harness-setting.ts +86 -0
- package/src/stories/stories.test.tsx +4 -0
- package/src/stories/story.ts +6 -0
- package/src/terminal/ansi-screen.ts +5 -0
- package/src/terminal/components/terminal-pane.ts +12 -0
- package/src/terminal/ghostty-terminal.ts +10 -0
- package/src/terminal/kitty-image.ts +3 -0
- package/src/terminal/pty.ts +9 -0
- package/src/testing/test-support.test.ts +2 -0
- package/src/testing/test-support.ts +16 -0
- package/src/thread/agent-continuation.test.ts +2 -0
- package/src/thread/agent-transcript.ts +18 -10
- package/src/thread/components/AgentConfigControls.tsx +1 -0
- package/src/thread/components/AgentThreadPane.test.tsx +257 -1
- package/src/thread/components/AgentThreadPane.tsx +48 -16
- package/src/thread/components/ThreadTree.tsx +1 -0
- package/src/thread/components/thread-tree.test.ts +1 -0
- package/src/thread/thread-controller.curate.test.ts +2 -0
- package/src/thread/thread-controller.ts +123 -0
- package/src/thread/tree-view.ts +4 -0
- package/src/thread/use-thread-agent.test.tsx +1 -0
- package/src/thread/use-thread-agent.ts +5 -0
- package/src/ui/components/OverlayScrollbar.test.tsx +3 -0
- package/src/ui/components/OverlayScrollbar.tsx +5 -0
- package/src/ui/components/PromptDialog.tsx +2 -0
- package/src/ui/components/RootOverlay.tsx +1 -0
- package/src/ui/components/ScrollArea.tsx +2 -0
- package/src/ui/components/primitives/Button.tsx +1 -0
- package/src/ui/components/primitives/Tabs.tsx +1 -0
- package/src/ui/components/primitives/Tree.tsx +4 -0
- package/src/ui/components/primitives/tree-model.ts +7 -0
- package/src/ui/components/syntax-highlight.ts +5 -0
- package/src/ui/components/truncate-title.ts +1 -0
- package/src/ui/use-frame-measure.ts +3 -0
- package/src/ui/use-terminal-virtualizer.ts +6 -0
- package/src/workbench/components/ChangesColumn.tsx +7 -0
- package/src/workbench/components/ChangesFileTree.test.tsx +3 -0
- package/src/workbench/components/EditorGrid.tsx +8 -0
- package/src/workbench/components/FileContentsView.tsx +2 -0
- package/src/workbench/components/GridTabContent.tsx +2 -0
- package/src/workbench/components/ProjectTreeView.test.tsx +3 -0
- package/src/workbench/components/ProjectTreeView.tsx +8 -0
- package/src/workbench/components/TreePane.tsx +2 -0
- package/src/workbench/components/editor-grid.ts +3 -0
- package/src/workbench/components/file-tree.ts +2 -0
- package/src/workbench/use-changes-workbench.ts +5 -0
- package/src/workbench/use-remember-layout.ts +2 -0
- package/src/workbench/use-repo-changes.ts +2 -0
package/src/diff/walk.ts
CHANGED
|
@@ -32,9 +32,12 @@ export function walkFiles(rows: DiffRow[]): WalkFile[] {
|
|
|
32
32
|
byPath.set(row.file, file);
|
|
33
33
|
files.push(file);
|
|
34
34
|
}
|
|
35
|
+
|
|
35
36
|
if (row.kind !== "add" && row.kind !== "del") continue;
|
|
37
|
+
|
|
36
38
|
if (row.kind === "add") file.added += 1;
|
|
37
39
|
else file.removed += 1;
|
|
40
|
+
|
|
38
41
|
if (file.preview.length < WALK_PREVIEW_ROWS) {
|
|
39
42
|
file.preview.push({
|
|
40
43
|
sign: row.kind === "add" ? "+" : "-",
|
|
@@ -33,9 +33,11 @@ export function resolveDisplayName(
|
|
|
33
33
|
const override = overrides[authorId];
|
|
34
34
|
|
|
35
35
|
if (override) return override;
|
|
36
|
+
|
|
36
37
|
const identity = participants?.find((participant) => participant.id === authorId);
|
|
37
38
|
|
|
38
39
|
if (identity?.name) return identity.name;
|
|
40
|
+
|
|
39
41
|
if (identity) return ANONYMOUS_LABEL;
|
|
40
42
|
|
|
41
43
|
return shortHandle(authorId);
|
|
@@ -65,12 +65,15 @@ export class ClientExtensionRegistry {
|
|
|
65
65
|
): ExtensionUIDisposable {
|
|
66
66
|
if (!/^[a-z][a-z0-9-]*$/.test(contribution.id))
|
|
67
67
|
throw new Error(`Client extension invalid contribution ID: ${contribution.id}`);
|
|
68
|
+
|
|
68
69
|
if (contribution.zone !== zone)
|
|
69
70
|
throw new Error(`Client extension ${owner}:${contribution.id} must use zone ${zone}`);
|
|
71
|
+
|
|
70
72
|
const key = `${owner}:${contribution.id}`;
|
|
71
73
|
|
|
72
74
|
if (this.sections.has(key) || this.actions.has(key) || this.views.has(key))
|
|
73
75
|
throw new Error(`Client extension duplicate contribution: ${key}`);
|
|
76
|
+
|
|
74
77
|
map.set(key, contribution);
|
|
75
78
|
this.changed();
|
|
76
79
|
|
|
@@ -149,6 +152,7 @@ export async function loadInstalledClientExtensions(
|
|
|
149
152
|
const parsed = v.safeParse(v.object({ default: v.function() }), module);
|
|
150
153
|
|
|
151
154
|
if (!parsed.success) throw new Error("client entry point must export a default factory");
|
|
155
|
+
|
|
152
156
|
await registry.load(extension.name, async (api) => {
|
|
153
157
|
await parsed.output.default(api);
|
|
154
158
|
});
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
/** The clipboard-write commands to try, most-preferred first, for this platform. */
|
|
9
9
|
export function clipboardCommands(platform: NodeJS.Platform = process.platform): string[][] {
|
|
10
10
|
if (platform === "darwin") return [["pbcopy"]];
|
|
11
|
+
|
|
11
12
|
if (platform === "win32") return [["clip.exe"]];
|
|
12
13
|
|
|
13
14
|
return [["wl-copy"], ["xclip", "-selection", "clipboard"], ["xsel", "--clipboard", "--input"]];
|
|
@@ -36,10 +36,12 @@ for (const enabled of [false, true])
|
|
|
36
36
|
expect(setup.captureCharFrame().includes("Allow agent messages")).toBe(enabled);
|
|
37
37
|
await press(setup, "down");
|
|
38
38
|
await press(setup, "down");
|
|
39
|
+
|
|
39
40
|
if (enabled) {
|
|
40
41
|
await press(setup, " ");
|
|
41
42
|
await press(setup, "down");
|
|
42
43
|
}
|
|
44
|
+
|
|
43
45
|
await press(setup, "enter");
|
|
44
46
|
expect(created).toHaveLength(1);
|
|
45
47
|
expect(created[0]?.agentEnabled).toBe(enabled ? true : undefined);
|
|
@@ -293,7 +293,9 @@ function Wizard({
|
|
|
293
293
|
|
|
294
294
|
useEffect(() => {
|
|
295
295
|
if (focus !== "name") return;
|
|
296
|
+
|
|
296
297
|
nameRef.current?.focus();
|
|
298
|
+
|
|
297
299
|
if (nameRef.current) nameRef.current.cursorOffset = draft.name.length;
|
|
298
300
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
299
301
|
}, [focus]);
|
|
@@ -418,6 +420,7 @@ export function ShareDialog({
|
|
|
418
420
|
|
|
419
421
|
const saveWizard = (): void => {
|
|
420
422
|
if (!wizard || saveDisabled) return;
|
|
423
|
+
|
|
421
424
|
const input: NewShareLink = {
|
|
422
425
|
name: wizard.name.trim() || undefined,
|
|
423
426
|
requireAuth: wizard.requireAuth,
|
|
@@ -425,13 +428,16 @@ export function ShareDialog({
|
|
|
425
428
|
};
|
|
426
429
|
|
|
427
430
|
if (agentAvailable) input.agentEnabled = wizard.agentEnabled ?? false;
|
|
431
|
+
|
|
428
432
|
if (wizard.editingId) onUpdateLink(wizard.editingId, input);
|
|
429
433
|
else onCreateLink(input);
|
|
434
|
+
|
|
430
435
|
backToList();
|
|
431
436
|
};
|
|
432
437
|
|
|
433
438
|
const activateListRow = (): void => {
|
|
434
439
|
if (listCursor >= links.length) return openWizardWith(newWizardDraft(threadName));
|
|
440
|
+
|
|
435
441
|
const link = links[listCursor];
|
|
436
442
|
|
|
437
443
|
if (link) onCopyLink(link.id);
|
|
@@ -440,30 +446,41 @@ export function ShareDialog({
|
|
|
440
446
|
// turning auth on drops the cursor straight into the handles field; turning it off resets them
|
|
441
447
|
const toggleAuth = (): void => {
|
|
442
448
|
if (!wizard) return;
|
|
449
|
+
|
|
443
450
|
const next = !wizard.requireAuth;
|
|
444
451
|
|
|
445
452
|
shareDialogStore.getState().setRequireAuth(next);
|
|
453
|
+
|
|
446
454
|
if (!next) shareDialogStore.getState().setAllowlist([]);
|
|
455
|
+
|
|
447
456
|
setWizardFocus(next ? "allowlist" : "auth");
|
|
448
457
|
};
|
|
449
458
|
|
|
450
459
|
// grammar keys only bite outside a focused text field, where they cannot be typed
|
|
451
460
|
const handleWizardKey = (name: string): void => {
|
|
452
461
|
if (!wizard) return;
|
|
462
|
+
|
|
453
463
|
if (name === "escape") return backToList();
|
|
464
|
+
|
|
454
465
|
if (name === "down") return moveWizardFocus(1);
|
|
466
|
+
|
|
455
467
|
if (name === "up") return moveWizardFocus(-1);
|
|
468
|
+
|
|
456
469
|
const activated = name === "return" || name === "enter";
|
|
457
470
|
|
|
458
471
|
if (wizardFocus === "auth" && (activated || name === "space")) return toggleAuth();
|
|
472
|
+
|
|
459
473
|
if (wizardFocus === "agent" && (activated || name === "space"))
|
|
460
474
|
return shareDialogStore.getState().setAgentEnabled(!wizard.agentEnabled);
|
|
475
|
+
|
|
461
476
|
if (wizardFocus === "actions" && activated) saveWizard();
|
|
462
477
|
};
|
|
463
478
|
|
|
464
479
|
const handleNavKey = (name: string): void => {
|
|
465
480
|
if (name === "j" || name === "down") return shareDialogStore.getState().setCategory("export");
|
|
481
|
+
|
|
466
482
|
if (name === "k" || name === "up") return shareDialogStore.getState().setCategory("external");
|
|
483
|
+
|
|
467
484
|
const enter =
|
|
468
485
|
name === "l" || name === "right" || name === "return" || name === "enter" || name === "tab";
|
|
469
486
|
|
|
@@ -478,21 +495,32 @@ export function ShareDialog({
|
|
|
478
495
|
|
|
479
496
|
const handleBodyKey = (name: string): void => {
|
|
480
497
|
if (name === "h" || name === "left") return setActiveZone("nav");
|
|
498
|
+
|
|
481
499
|
if (name === "j" || name === "down")
|
|
482
500
|
return setListCursor((cursor) => Math.min(cursor + 1, rowCount - 1));
|
|
501
|
+
|
|
483
502
|
if (name === "k" || name === "up") return setListCursor((cursor) => Math.max(cursor - 1, 0));
|
|
503
|
+
|
|
484
504
|
if (name === "return" || name === "enter") return activateListRow();
|
|
505
|
+
|
|
485
506
|
if (listCursor >= links.length) return;
|
|
507
|
+
|
|
486
508
|
if (name === "e") return editLinkAtCursor();
|
|
509
|
+
|
|
487
510
|
if (name === "d") onDeleteLink(links[listCursor]!.id);
|
|
488
511
|
};
|
|
489
512
|
|
|
490
513
|
useKeyboard((key) => {
|
|
491
514
|
if (!isOpen) return;
|
|
515
|
+
|
|
492
516
|
if (wizard) return handleWizardKey(key.name);
|
|
517
|
+
|
|
493
518
|
if (menuOpenId !== null) return void (key.name === "escape" && setMenuOpenId(null));
|
|
519
|
+
|
|
494
520
|
if (key.name === "escape") return onClose();
|
|
521
|
+
|
|
495
522
|
if (activeZone === "nav") return handleNavKey(key.name);
|
|
523
|
+
|
|
496
524
|
handleBodyKey(key.name);
|
|
497
525
|
});
|
|
498
526
|
|
|
@@ -85,6 +85,7 @@ export function resolveEditorCommand(rawEditor: string): ResolvedEditor {
|
|
|
85
85
|
const base = editorBaseName(parts[0] ?? "");
|
|
86
86
|
|
|
87
87
|
if (TERMINAL_EDITORS.has(base)) return { argv: parts, waits: true };
|
|
88
|
+
|
|
88
89
|
const waitFlags = GUI_WAIT_FLAGS[base];
|
|
89
90
|
|
|
90
91
|
if (waitFlags) {
|
|
@@ -103,6 +104,7 @@ const NO_WAIT_THRESHOLD_MS = 1000;
|
|
|
103
104
|
|
|
104
105
|
function suspectsNoWait(resolved: ResolvedEditor, elapsedMs: number, unchanged: boolean): boolean {
|
|
105
106
|
if (resolved.waits) return false; // holds the terminal or has a wait flag - trust the exit
|
|
107
|
+
|
|
106
108
|
if (!unchanged) return false; // edits landed, so it waited after all
|
|
107
109
|
|
|
108
110
|
return elapsedMs < NO_WAIT_THRESHOLD_MS;
|
|
@@ -145,6 +147,7 @@ export function editInEditor(
|
|
|
145
147
|
});
|
|
146
148
|
|
|
147
149
|
if (editorProcess.exitCode !== 0) throw new Error(`editor exited ${editorProcess.exitCode}`);
|
|
150
|
+
|
|
148
151
|
let next = readFileSync(path, "utf8");
|
|
149
152
|
|
|
150
153
|
if (suspectsNoWait(resolved, now() - startedAt, next === content)) {
|
|
@@ -10,6 +10,7 @@ const tempDirectories: string[] = [];
|
|
|
10
10
|
afterEach(() => {
|
|
11
11
|
if (originalBinary === undefined) delete process.env.CUELOOP_GH;
|
|
12
12
|
else process.env.CUELOOP_GH = originalBinary;
|
|
13
|
+
|
|
13
14
|
for (const directory of tempDirectories.splice(0)) {
|
|
14
15
|
rmSync(directory, { recursive: true, force: true });
|
|
15
16
|
}
|
|
@@ -21,9 +21,11 @@ export function focusHerdrPane(binPath: string, paneId: string): boolean {
|
|
|
21
21
|
});
|
|
22
22
|
|
|
23
23
|
if (got.exitCode !== 0) return false;
|
|
24
|
+
|
|
24
25
|
const parsed = v.safeParse(HerdrPaneSchema, JSON.parse(got.stdout.toString()));
|
|
25
26
|
|
|
26
27
|
if (!parsed.success) return false;
|
|
28
|
+
|
|
27
29
|
const tab = parsed.output.result?.pane?.tab_id;
|
|
28
30
|
|
|
29
31
|
if (!tab) return false;
|
|
@@ -37,6 +37,7 @@ function isControlCodePoint(codePoint: number): boolean {
|
|
|
37
37
|
/** True when a paste is an image or other non-text binary payload and should collapse to a placeholder. */
|
|
38
38
|
export function looksLikeBinaryPaste(bytes: Uint8Array): boolean {
|
|
39
39
|
if (bytes.length === 0) return false;
|
|
40
|
+
|
|
40
41
|
if (hasImageMagic(bytes)) return true;
|
|
41
42
|
|
|
42
43
|
const decoded = PASTE_DECODER.decode(bytes);
|
|
@@ -48,6 +49,7 @@ export function looksLikeBinaryPaste(bytes: Uint8Array): boolean {
|
|
|
48
49
|
const codePoint = character.codePointAt(0) ?? 0;
|
|
49
50
|
|
|
50
51
|
if (codePoint === 0 || codePoint === 0xfffd) return true;
|
|
52
|
+
|
|
51
53
|
if (isControlCodePoint(codePoint)) controlCount += 1;
|
|
52
54
|
}
|
|
53
55
|
|
|
@@ -62,9 +62,11 @@ export async function publishShare(
|
|
|
62
62
|
|
|
63
63
|
if (code !== 0)
|
|
64
64
|
throw new Error(`gateway upload failed: ${stderr.trim() || `ssh exited ${code}`}`);
|
|
65
|
+
|
|
65
66
|
const line = stdout.trim();
|
|
66
67
|
|
|
67
68
|
if (!line.startsWith("ssh ")) throw new Error(`unexpected gateway reply: ${line || "(empty)"}`);
|
|
69
|
+
|
|
68
70
|
const copied = await copyToClipboard(line);
|
|
69
71
|
|
|
70
72
|
return { line, copied };
|
|
@@ -98,6 +100,7 @@ export function mergeFromShare(remote: Thread): SharedMerge {
|
|
|
98
100
|
if (remote.shares?.length === 1) merge.shareId = remote.shares[0]!.id;
|
|
99
101
|
|
|
100
102
|
if (remote.participants) merge.participants = remote.participants;
|
|
103
|
+
|
|
101
104
|
if (remote.history) {
|
|
102
105
|
merge.removals = removalEntries(remote.history).map((entry) => ({
|
|
103
106
|
id: entry.id,
|
|
@@ -193,6 +196,7 @@ async function readLines(
|
|
|
193
196
|
pending = lines.pop() ?? "";
|
|
194
197
|
for (const line of lines) {
|
|
195
198
|
if (line.trim() === "") continue;
|
|
199
|
+
|
|
196
200
|
try {
|
|
197
201
|
onLine(line);
|
|
198
202
|
} catch {
|
|
@@ -77,6 +77,7 @@ export function HandleChipsInput({
|
|
|
77
77
|
const input = inputRef.current;
|
|
78
78
|
|
|
79
79
|
if (!input || !focused) return;
|
|
80
|
+
|
|
80
81
|
input.focus();
|
|
81
82
|
input.cursorOffset = seed.length; // land the caret at the end of the seeded text
|
|
82
83
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
@@ -91,12 +92,15 @@ export function HandleChipsInput({
|
|
|
91
92
|
const handle = (inputRef.current?.plainText ?? "").trim().replace(/^@/, "").toLowerCase();
|
|
92
93
|
|
|
93
94
|
if (handle && !logins.includes(handle)) onChange([...logins, handle]);
|
|
95
|
+
|
|
94
96
|
reseed("");
|
|
95
97
|
};
|
|
96
98
|
|
|
97
99
|
useKeyboard((key) => {
|
|
98
100
|
if (!focused) return;
|
|
101
|
+
|
|
99
102
|
if (key.name !== "backspace" || (inputRef.current?.plainText ?? "").length > 0) return;
|
|
103
|
+
|
|
100
104
|
// backspace on an empty field pulls the last chip back in to edit; with none left, collapse
|
|
101
105
|
if (logins.length > 0) {
|
|
102
106
|
const last = logins[logins.length - 1]!;
|
|
@@ -46,10 +46,13 @@ export function SlashComposer({
|
|
|
46
46
|
|
|
47
47
|
useKeyboard((key) => {
|
|
48
48
|
if (!slashActive) return;
|
|
49
|
+
|
|
49
50
|
const selected = Math.min(slashIndex, items.length - 1);
|
|
50
51
|
|
|
51
52
|
if (key.name === "up") return setSlashIndex(Math.max(0, selected - 1));
|
|
53
|
+
|
|
52
54
|
if (key.name === "down") return setSlashIndex(Math.min(items.length - 1, selected + 1));
|
|
55
|
+
|
|
53
56
|
if (key.name === "tab" || (key.name === "return" && !key.meta && !key.ctrl && !key.super)) {
|
|
54
57
|
const inserted = insertSlashItem(text, caret, items[selected]!.name);
|
|
55
58
|
|
|
@@ -405,11 +405,13 @@ export class KeyBindings {
|
|
|
405
405
|
fragments.push(entry.text);
|
|
406
406
|
continue;
|
|
407
407
|
}
|
|
408
|
+
|
|
408
409
|
const displays = entry.commands
|
|
409
410
|
.map((command) => this.keyDisplayFor(command))
|
|
410
411
|
.filter((display): display is string => display !== null);
|
|
411
412
|
|
|
412
413
|
if (!displays.length) continue;
|
|
414
|
+
|
|
413
415
|
const keyPart = displays.join("/");
|
|
414
416
|
|
|
415
417
|
if (!entry.label) fragments.push(keyPart);
|
|
@@ -435,6 +437,7 @@ export class KeyBindings {
|
|
|
435
437
|
|
|
436
438
|
for (const entry of HINT_TEMPLATES[mode]) {
|
|
437
439
|
if ("text" in entry || !entry.label) continue;
|
|
440
|
+
|
|
438
441
|
const keys = entry.commands
|
|
439
442
|
.map((command) => this.keyDisplayFor(command))
|
|
440
443
|
.filter((display): display is string => display !== null)
|
package/src/keyboard/keymap.ts
CHANGED
|
@@ -121,9 +121,11 @@ export function reduceKey(state: KeyState, key: KeyInput, resolvedAction?: strin
|
|
|
121
121
|
const overlayGrammar = overlayGrammars[state.overlay];
|
|
122
122
|
|
|
123
123
|
if (overlayGrammar) return overlayGrammar(state, key);
|
|
124
|
+
|
|
124
125
|
const action = resolvedAction ?? actionFor(state.keys, name, key.shift);
|
|
125
126
|
|
|
126
127
|
if (action === "quit") return [{ type: "exit" }];
|
|
128
|
+
|
|
127
129
|
// the ONE read-only rule: any mutating attempt answers instead of acting
|
|
128
130
|
// (span-mode c, x, and a are hardwired keys, so they gate by name as well)
|
|
129
131
|
const mutating =
|
|
@@ -140,8 +142,10 @@ export function reduceKey(state: KeyState, key: KeyInput, resolvedAction?: strin
|
|
|
140
142
|
}
|
|
141
143
|
|
|
142
144
|
if (state.view === "inbox") return inboxGrammar(state, name);
|
|
145
|
+
|
|
143
146
|
// span mode owns its single-letter keys (b slides the span back)
|
|
144
147
|
if (state.spanMode) return spanGrammar(state, name);
|
|
148
|
+
|
|
145
149
|
if (state.view === "diff") return diffGrammar(state, action);
|
|
146
150
|
|
|
147
151
|
return planGrammar(state, action, name);
|
|
@@ -164,6 +168,7 @@ function composeOverlayGrammar(state: KeyState, key: KeyInput): Intent[] {
|
|
|
164
168
|
const name = key.name;
|
|
165
169
|
|
|
166
170
|
if (name === "escape") return [{ type: "closeOverlay" }];
|
|
171
|
+
|
|
167
172
|
if (name === "return" || name === "enter") {
|
|
168
173
|
// In the composer, ⌥/Alt+⏎ (meta) and shift+⏎ insert a newline - the
|
|
169
174
|
// focused textarea owns that; only a bare ⏎ saves. The submit overlay
|
|
@@ -180,6 +185,7 @@ function submitOverlayGrammar(state: KeyState, key: KeyInput): Intent[] {
|
|
|
180
185
|
const name = key.name;
|
|
181
186
|
|
|
182
187
|
if (name === "escape") return [{ type: "closeOverlay" }];
|
|
188
|
+
|
|
183
189
|
if (name === "left" || name === "right") {
|
|
184
190
|
return [{ type: "cycleMessage", direction: name === "left" ? -1 : 1 }];
|
|
185
191
|
}
|
|
@@ -191,6 +197,7 @@ function confirmOverlayGrammar(state: KeyState, key: KeyInput): Intent[] {
|
|
|
191
197
|
const name = key.name;
|
|
192
198
|
|
|
193
199
|
if (name === "return" || name === "enter") return [{ type: "confirmDialog" }];
|
|
200
|
+
|
|
194
201
|
if (name === "escape") return [{ type: "closeOverlay" }];
|
|
195
202
|
|
|
196
203
|
return [];
|
|
@@ -200,6 +207,7 @@ function promptOverlayGrammar(state: KeyState, key: KeyInput): Intent[] {
|
|
|
200
207
|
const name = key.name;
|
|
201
208
|
|
|
202
209
|
if (name === "return" || name === "enter") return [{ type: "confirmDialog" }];
|
|
210
|
+
|
|
203
211
|
if (name === "escape") return [{ type: "closeOverlay" }];
|
|
204
212
|
|
|
205
213
|
return [];
|
|
@@ -209,8 +217,11 @@ function spanActionsOverlayGrammar(state: KeyState, key: KeyInput): Intent[] {
|
|
|
209
217
|
const name = key.name;
|
|
210
218
|
|
|
211
219
|
if (name === "j" || name === "down") return [{ type: "moveSpanAction", direction: 1 }];
|
|
220
|
+
|
|
212
221
|
if (name === "k" || name === "up") return [{ type: "moveSpanAction", direction: -1 }];
|
|
222
|
+
|
|
213
223
|
if (name === "return" || name === "enter") return [{ type: "pickSpanAction" }];
|
|
224
|
+
|
|
214
225
|
if (name === "escape") return [{ type: "closeSpanActions" }];
|
|
215
226
|
|
|
216
227
|
return [];
|
|
@@ -220,7 +231,9 @@ function completionOverlayGrammar(state: KeyState, key: KeyInput): Intent[] {
|
|
|
220
231
|
const name = key.name;
|
|
221
232
|
|
|
222
233
|
if (name === "return" || name === "enter" || name === "q") return [{ type: "finishReview" }];
|
|
234
|
+
|
|
223
235
|
if (name === "a") return [{ type: "optInAutoClose" }];
|
|
236
|
+
|
|
224
237
|
if (name === "escape") return [{ type: "dismissCompletion" }];
|
|
225
238
|
|
|
226
239
|
return [];
|
|
@@ -230,11 +243,15 @@ function walkOverlayGrammar(state: KeyState, key: KeyInput): Intent[] {
|
|
|
230
243
|
const name = key.name;
|
|
231
244
|
|
|
232
245
|
if (name === "]") return [{ type: "walkForward" }];
|
|
246
|
+
|
|
233
247
|
if (name === "[") return [{ type: "walkBack" }];
|
|
248
|
+
|
|
234
249
|
if (name === "escape") return [{ type: "walkLeave" }];
|
|
250
|
+
|
|
235
251
|
if ((name === "return" || name === "enter") && state.walkAtEnd) {
|
|
236
252
|
return [{ type: "walkLeave" }, { type: "openSubmit" }];
|
|
237
253
|
}
|
|
254
|
+
|
|
238
255
|
if (name === "q") return [{ type: "exit" }];
|
|
239
256
|
|
|
240
257
|
return [];
|
|
@@ -242,9 +259,13 @@ function walkOverlayGrammar(state: KeyState, key: KeyInput): Intent[] {
|
|
|
242
259
|
|
|
243
260
|
function inboxGrammar(state: KeyState, name: string): Intent[] {
|
|
244
261
|
if (!state.hasInboxItems) return [];
|
|
262
|
+
|
|
245
263
|
if (name === "j" || name === "down") return [{ type: "inboxMove", to: "down" }];
|
|
264
|
+
|
|
246
265
|
if (name === "k" || name === "up") return [{ type: "inboxMove", to: "up" }];
|
|
266
|
+
|
|
247
267
|
if (name === "return" || name === "enter") return [{ type: "openSession" }];
|
|
268
|
+
|
|
248
269
|
if (name === "d") return [{ type: "requestDeleteSession" }];
|
|
249
270
|
|
|
250
271
|
return [];
|
|
@@ -254,39 +275,49 @@ function diffGrammar(state: KeyState, action: string | undefined): Intent[] {
|
|
|
254
275
|
const navigation = navigationIntent(action);
|
|
255
276
|
|
|
256
277
|
if (navigation) return navigation;
|
|
278
|
+
|
|
257
279
|
if (action === "walk") {
|
|
258
280
|
// marking viewed writes the session record, so a resolved review answers
|
|
259
281
|
if (state.resolved) return status("review submitted - read-only");
|
|
260
282
|
|
|
261
283
|
return [{ type: "walkStart" }];
|
|
262
284
|
}
|
|
285
|
+
|
|
263
286
|
if (action === "comment") {
|
|
264
287
|
if (state.resolved) return status("review submitted - read-only");
|
|
288
|
+
|
|
265
289
|
if (!state.cursorAnnotatable) return status("move to a code line to comment");
|
|
266
290
|
|
|
267
291
|
return [{ type: "openCompose", kind: "comment", from: "cursor" }];
|
|
268
292
|
}
|
|
293
|
+
|
|
269
294
|
// cut rejects the change under the cursor, reject_hunk the whole hunk; both
|
|
270
295
|
// write the working copy, so they gate on owner like a plan edit.
|
|
271
296
|
if (action === "cut" || action === "reject_hunk") {
|
|
272
297
|
if (state.resolved) return status("review submitted - read-only");
|
|
298
|
+
|
|
273
299
|
if (state.canEditPlan === false) return status("only the diff owner can curate hunks");
|
|
274
300
|
|
|
275
301
|
return [{ type: action === "reject_hunk" ? "rejectHunk" : "rejectChange" }];
|
|
276
302
|
}
|
|
303
|
+
|
|
277
304
|
const viewToggle = viewToggleIntent(action);
|
|
278
305
|
|
|
279
306
|
if (viewToggle) return viewToggle;
|
|
307
|
+
|
|
280
308
|
// restore un-does a curated-out rejection from the rail; same owner gate as reject
|
|
281
309
|
if (action === "restore_curation") {
|
|
282
310
|
if (state.resolved) return status("review submitted - read-only");
|
|
311
|
+
|
|
283
312
|
if (state.canEditPlan === false) return status("only the diff owner can curate hunks");
|
|
284
313
|
|
|
285
314
|
return [{ type: "restoreCuration" }];
|
|
286
315
|
}
|
|
316
|
+
|
|
287
317
|
const shared = annotationCluster(state, action);
|
|
288
318
|
|
|
289
319
|
if (shared) return shared;
|
|
320
|
+
|
|
290
321
|
if (action === "span" || action === "edit") {
|
|
291
322
|
return status("plan-only primitive - diff review uses c on a line");
|
|
292
323
|
}
|
|
@@ -296,13 +327,18 @@ function diffGrammar(state: KeyState, action: string | undefined): Intent[] {
|
|
|
296
327
|
|
|
297
328
|
function spanGrammar(state: KeyState, name: string): Intent[] {
|
|
298
329
|
if (name === "escape") return [{ type: "closeOverlay" }];
|
|
330
|
+
|
|
299
331
|
if (SPAN_KEYS.has(name)) return [{ type: "spanKey", name }];
|
|
332
|
+
|
|
300
333
|
// c comments, x cuts, a opens quick-actions - all mutate, so a resolved
|
|
301
334
|
// review is read-only, like the plan grammar
|
|
302
335
|
if (name === "c" || name === "x" || name === "a") {
|
|
303
336
|
if (state.resolved) return status("review submitted - read-only");
|
|
337
|
+
|
|
304
338
|
if (name === "c") return [{ type: "openCompose", kind: "comment", from: "span" }];
|
|
339
|
+
|
|
305
340
|
if (name === "a") return [{ type: "openSpanActions" }];
|
|
341
|
+
|
|
306
342
|
if (state.canEditPlan === false) return [];
|
|
307
343
|
|
|
308
344
|
return [{ type: "spanCut" }];
|
|
@@ -315,28 +351,38 @@ function planGrammar(state: KeyState, action: string | undefined, name: string):
|
|
|
315
351
|
const navigation = navigationIntent(action);
|
|
316
352
|
|
|
317
353
|
if (navigation) return navigation;
|
|
354
|
+
|
|
318
355
|
if (action === "walk") return status("the guided walk is a diff-review mode");
|
|
356
|
+
|
|
319
357
|
if (name === "escape") return [{ type: "deselect" }];
|
|
358
|
+
|
|
320
359
|
if (action === "span") return state.cursorAnnotatable ? [{ type: "startSpan" }] : [];
|
|
360
|
+
|
|
321
361
|
if (action === "comment") {
|
|
322
362
|
if (state.resolved) return status("review submitted - read-only");
|
|
363
|
+
|
|
323
364
|
if (!state.cursorAnnotatable) return status("text is cut - restore it first");
|
|
324
365
|
|
|
325
366
|
return [{ type: "openCompose", kind: "comment", from: "cursor" }];
|
|
326
367
|
}
|
|
368
|
+
|
|
327
369
|
// restore un-does a rail removal (a cut block); a plan edit, so owner-only
|
|
328
370
|
if (action === "restore_curation") {
|
|
329
371
|
if (state.resolved) return status("review submitted - read-only");
|
|
372
|
+
|
|
330
373
|
if (state.canEditPlan === false) return [];
|
|
331
374
|
|
|
332
375
|
return [{ type: "restoreCuration" }];
|
|
333
376
|
}
|
|
377
|
+
|
|
334
378
|
if (action === "cut" || action === "edit") {
|
|
335
379
|
if (state.resolved) return status("review submitted - read-only");
|
|
380
|
+
|
|
336
381
|
// the document selects, the rail edits: with a card selected, Cut deletes
|
|
337
382
|
// the annotation and edit rewrites the card body in place
|
|
338
383
|
if (state.hasFocusedAnnotation)
|
|
339
384
|
return [action === "cut" ? { type: "removeAnnotation" } : { type: "editCard" }];
|
|
385
|
+
|
|
340
386
|
// editing the plan itself is the owner's primitive; a share viewer only annotates,
|
|
341
387
|
// and has no edit affordance, so the key is silent rather than a nag
|
|
342
388
|
if (state.canEditPlan === false) return [];
|
|
@@ -360,7 +406,9 @@ function navigationIntent(action: string | undefined): Intent[] | null {
|
|
|
360
406
|
* split/stacked (split lays out only when the Changes pane is wide/zoomed). */
|
|
361
407
|
function viewToggleIntent(action: string | undefined): Intent[] | null {
|
|
362
408
|
if (action === "collapse_file") return [{ type: "foldFile" }];
|
|
409
|
+
|
|
363
410
|
if (action === "expand_file") return [{ type: "unfoldFile" }];
|
|
411
|
+
|
|
364
412
|
if (action === "split_diff") return [{ type: "toggleDiffView" }];
|
|
365
413
|
|
|
366
414
|
return null;
|
|
@@ -373,16 +421,19 @@ function annotationCluster(state: KeyState, action: string | undefined): Intent[
|
|
|
373
421
|
|
|
374
422
|
return [{ type: action === "next_annotation" ? "nextAnnotation" : "prevAnnotation" }];
|
|
375
423
|
}
|
|
424
|
+
|
|
376
425
|
if (action === "delete_annotation") {
|
|
377
426
|
if (state.resolved || !state.hasFocusedAnnotation) return [];
|
|
378
427
|
|
|
379
428
|
return [{ type: "removeAnnotation" }];
|
|
380
429
|
}
|
|
430
|
+
|
|
381
431
|
if (action === "rename") {
|
|
382
432
|
if (!state.hasFocusedAnnotation) return status("select a collaborator's note to rename them");
|
|
383
433
|
|
|
384
434
|
return [{ type: "openRename" }];
|
|
385
435
|
}
|
|
436
|
+
|
|
386
437
|
if (action === "submit") {
|
|
387
438
|
// a collaborator's notes union back as they go; there is no message to submit
|
|
388
439
|
if (state.canSubmitMessage === false)
|
package/src/keyboard/skills.ts
CHANGED
|
@@ -37,6 +37,7 @@ export function loadSkills(dir: string): SlashItem[] {
|
|
|
37
37
|
|
|
38
38
|
for (const entry of entries) {
|
|
39
39
|
if (!entry.isDirectory()) continue;
|
|
40
|
+
|
|
40
41
|
let text;
|
|
41
42
|
|
|
42
43
|
try {
|
|
@@ -47,6 +48,7 @@ export function loadSkills(dir: string): SlashItem[] {
|
|
|
47
48
|
const frontmatter = /^---\n([\s\S]*?)\n---/.exec(text.replace(/\r\n/g, "\n"));
|
|
48
49
|
|
|
49
50
|
if (!frontmatter) continue;
|
|
51
|
+
|
|
50
52
|
const name = v.safeParse(
|
|
51
53
|
SkillNameSchema,
|
|
52
54
|
frontmatterValue(frontmatter[1]!, "name") ?? entry.name,
|
|
@@ -28,11 +28,14 @@ const WORD_BOUNDARY = /[\s\-_./:]/;
|
|
|
28
28
|
/** Order-preserving subsequence score, or null when a query character is missing; higher is better. */
|
|
29
29
|
export function scoreMatch(name: string, query: string): number | null {
|
|
30
30
|
if (query.length === 0) return 0;
|
|
31
|
+
|
|
31
32
|
const lowerName = name.toLowerCase();
|
|
32
33
|
const lowerQuery = query.toLowerCase();
|
|
33
34
|
|
|
34
35
|
if (lowerName === lowerQuery) return 1000;
|
|
36
|
+
|
|
35
37
|
if (lowerName.startsWith(lowerQuery)) return 500 - name.length;
|
|
38
|
+
|
|
36
39
|
let score = 0;
|
|
37
40
|
let cursor = 0;
|
|
38
41
|
let run = 0;
|
|
@@ -41,9 +44,12 @@ export function scoreMatch(name: string, query: string): number | null {
|
|
|
41
44
|
const found = lowerName.indexOf(character, cursor);
|
|
42
45
|
|
|
43
46
|
if (found === -1) return null;
|
|
47
|
+
|
|
44
48
|
run = found === cursor ? run + 1 : 0;
|
|
45
49
|
score += found === cursor ? 5 + run : 1;
|
|
50
|
+
|
|
46
51
|
if (found === 0 || WORD_BOUNDARY.test(lowerName[found - 1]!)) score += 10;
|
|
52
|
+
|
|
47
53
|
score -= found - cursor;
|
|
48
54
|
cursor = found + 1;
|
|
49
55
|
}
|
|
@@ -53,6 +59,7 @@ export function scoreMatch(name: string, query: string): number | null {
|
|
|
53
59
|
|
|
54
60
|
export function slashFilter(items: SlashItem[], query: string): SlashItem[] {
|
|
55
61
|
if (query.length === 0) return items;
|
|
62
|
+
|
|
56
63
|
const scored: Array<{ item: SlashItem; score: number }> = [];
|
|
57
64
|
|
|
58
65
|
for (const item of items) {
|
|
@@ -113,6 +120,7 @@ export function skillReferenceRanges(
|
|
|
113
120
|
const token = match[1]!;
|
|
114
121
|
|
|
115
122
|
if (!names.has(token.slice(1))) continue;
|
|
123
|
+
|
|
116
124
|
const start = match.index + match[0].length - token.length;
|
|
117
125
|
|
|
118
126
|
ranges.push({ start, end: start + token.length });
|
|
@@ -61,6 +61,7 @@ const RESOLVED: Intent = { type: "status", message: "review submitted - read-onl
|
|
|
61
61
|
|
|
62
62
|
function mutating(intent: Intent, context: ThreadNavContext): Intent {
|
|
63
63
|
if (!context.isOwner) return READ_ONLY;
|
|
64
|
+
|
|
64
65
|
if (context.resolved) return RESOLVED;
|
|
65
66
|
|
|
66
67
|
return intent;
|
|
@@ -134,13 +135,17 @@ export function resolveSessionChord(
|
|
|
134
135
|
): Intent | null {
|
|
135
136
|
if (key.name === "return" || key.name === "enter") {
|
|
136
137
|
if (!(key.ctrl || key.meta || key.super)) return null;
|
|
138
|
+
|
|
137
139
|
if (!context.isOwner) return READ_ONLY;
|
|
138
140
|
|
|
139
141
|
return context.resolved ? null : { type: "openSubmit" };
|
|
140
142
|
}
|
|
143
|
+
|
|
141
144
|
if (!key.ctrl) return null;
|
|
145
|
+
|
|
142
146
|
if (key.name === "e")
|
|
143
147
|
return context.resolved ? RESOLVED : context.isOwner ? { type: "edit" } : READ_ONLY;
|
|
148
|
+
|
|
144
149
|
if (key.name === "s") return context.isOwner ? { type: "share" } : READ_ONLY;
|
|
145
150
|
|
|
146
151
|
return null;
|