@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.
Files changed (122) hide show
  1. package/package.json +5 -5
  2. package/src/annotations/annotation-palette.ts +1 -0
  3. package/src/annotations/components/AnnotationCards.tsx +14 -1
  4. package/src/annotations/components/DiscussionMarkerRail.tsx +3 -0
  5. package/src/annotations/discussions.ts +1 -0
  6. package/src/annotations/thread-selection.ts +6 -0
  7. package/src/annotations/use-annotation-surface.tsx +154 -19
  8. package/src/app/App.deleted-scroll.test.tsx +3 -0
  9. package/src/app/App.harness-settings.test.tsx +77 -0
  10. package/src/app/App.plan-render.test.tsx +2 -0
  11. package/src/app/App.plan-review.test.tsx +1 -0
  12. package/src/app/App.test.tsx +1 -0
  13. package/src/app/App.tsx +72 -6
  14. package/src/app/App.visual.test.tsx +1 -0
  15. package/src/app/App.walk.test.tsx +1 -0
  16. package/src/app/App.workbench.test.tsx +1 -0
  17. package/src/app/app-screens.tsx +1 -0
  18. package/src/app/app-view-model.ts +7 -0
  19. package/src/app/components/WorkspacePanels.tsx +1 -0
  20. package/src/app/intent-dispatch.test.ts +1 -0
  21. package/src/app/intent-dispatch.ts +26 -0
  22. package/src/app/ready-signal.ts +2 -0
  23. package/src/app/run.ts +2 -0
  24. package/src/app/serve.test.ts +4 -0
  25. package/src/diff/components/AnnotatableFileView.tsx +3 -2
  26. package/src/diff/components/DiffContentView.scroll.test.tsx +9 -0
  27. package/src/diff/components/DiffContentView.syntax.test.tsx +1 -0
  28. package/src/diff/components/DiffContentView.tsx +28 -3
  29. package/src/diff/components/diff-content-view-layout.ts +3 -0
  30. package/src/diff/diff-fold.ts +4 -0
  31. package/src/diff/diff-hunk-curate.ts +3 -0
  32. package/src/diff/diff-intraline.ts +6 -0
  33. package/src/diff/diff-syntax.ts +8 -0
  34. package/src/diff/split-diff.ts +6 -0
  35. package/src/diff/view-diff.ts +18 -0
  36. package/src/diff/walk.ts +3 -0
  37. package/src/integrations/attribution.ts +2 -0
  38. package/src/integrations/client-extension-registry.ts +4 -0
  39. package/src/integrations/clipboard.ts +1 -0
  40. package/src/integrations/components/ShareDialog.agent.test.tsx +2 -0
  41. package/src/integrations/components/ShareDialog.tsx +28 -0
  42. package/src/integrations/editor.ts +3 -0
  43. package/src/integrations/github-identity.test.ts +1 -0
  44. package/src/integrations/github-identity.ts +1 -0
  45. package/src/integrations/herdr.ts +2 -0
  46. package/src/integrations/pasted-image.ts +2 -0
  47. package/src/integrations/share.ts +4 -0
  48. package/src/keyboard/components/HandleChipsInput.tsx +4 -0
  49. package/src/keyboard/components/SlashComposer.tsx +3 -0
  50. package/src/keyboard/key-bindings.ts +3 -0
  51. package/src/keyboard/keymap.ts +51 -0
  52. package/src/keyboard/skills.ts +2 -0
  53. package/src/keyboard/slash-palette.ts +8 -0
  54. package/src/keyboard/thread-chords.ts +5 -0
  55. package/src/keyboard/use-shared-keyboard.ts +1 -0
  56. package/src/markdown/components/MarkdownThreadEditor.tsx +3 -0
  57. package/src/markdown/components/ThreadView.selection.test.tsx +3 -0
  58. package/src/markdown/components/ThreadView.tsx +16 -3
  59. package/src/markdown/mark-runs.ts +4 -0
  60. package/src/markdown/markdown-editor-syntax-style.ts +1 -0
  61. package/src/markdown/markdown-grid.ts +6 -0
  62. package/src/markdown/markdown-highlight.ts +1 -0
  63. package/src/markdown/thread-code-syntax.ts +3 -0
  64. package/src/markdown/view-plan.ts +52 -0
  65. package/src/performance/perf-report.ts +1 -0
  66. package/src/performance/perf-timings.ts +1 -0
  67. package/src/prototype/components/PrototypeContentView.tsx +17 -0
  68. package/src/prototype/prototype-browser.ts +11 -0
  69. package/src/settings/components/SettingsDialog.tsx +3 -0
  70. package/src/settings/config.test.ts +41 -0
  71. package/src/settings/config.ts +44 -0
  72. package/src/settings/use-settings-dialog.test.tsx +98 -0
  73. package/src/settings/use-settings-dialog.tsx +38 -1
  74. package/src/settings/use-thread-harness-setting.test.tsx +87 -0
  75. package/src/settings/use-thread-harness-setting.ts +86 -0
  76. package/src/stories/stories.test.tsx +4 -0
  77. package/src/stories/story.ts +6 -0
  78. package/src/terminal/ansi-screen.ts +5 -0
  79. package/src/terminal/components/terminal-pane.ts +12 -0
  80. package/src/terminal/ghostty-terminal.ts +10 -0
  81. package/src/terminal/kitty-image.ts +3 -0
  82. package/src/terminal/pty.ts +9 -0
  83. package/src/testing/test-support.test.ts +2 -0
  84. package/src/testing/test-support.ts +16 -0
  85. package/src/thread/agent-continuation.test.ts +2 -0
  86. package/src/thread/agent-transcript.ts +18 -10
  87. package/src/thread/components/AgentConfigControls.tsx +1 -0
  88. package/src/thread/components/AgentThreadPane.test.tsx +257 -1
  89. package/src/thread/components/AgentThreadPane.tsx +48 -16
  90. package/src/thread/components/ThreadTree.tsx +1 -0
  91. package/src/thread/components/thread-tree.test.ts +1 -0
  92. package/src/thread/thread-controller.curate.test.ts +2 -0
  93. package/src/thread/thread-controller.ts +123 -0
  94. package/src/thread/tree-view.ts +4 -0
  95. package/src/thread/use-thread-agent.test.tsx +1 -0
  96. package/src/thread/use-thread-agent.ts +5 -0
  97. package/src/ui/components/OverlayScrollbar.test.tsx +3 -0
  98. package/src/ui/components/OverlayScrollbar.tsx +5 -0
  99. package/src/ui/components/PromptDialog.tsx +2 -0
  100. package/src/ui/components/RootOverlay.tsx +1 -0
  101. package/src/ui/components/ScrollArea.tsx +2 -0
  102. package/src/ui/components/primitives/Button.tsx +1 -0
  103. package/src/ui/components/primitives/Tabs.tsx +1 -0
  104. package/src/ui/components/primitives/Tree.tsx +4 -0
  105. package/src/ui/components/primitives/tree-model.ts +7 -0
  106. package/src/ui/components/syntax-highlight.ts +5 -0
  107. package/src/ui/components/truncate-title.ts +1 -0
  108. package/src/ui/use-frame-measure.ts +3 -0
  109. package/src/ui/use-terminal-virtualizer.ts +6 -0
  110. package/src/workbench/components/ChangesColumn.tsx +7 -0
  111. package/src/workbench/components/ChangesFileTree.test.tsx +3 -0
  112. package/src/workbench/components/EditorGrid.tsx +8 -0
  113. package/src/workbench/components/FileContentsView.tsx +2 -0
  114. package/src/workbench/components/GridTabContent.tsx +2 -0
  115. package/src/workbench/components/ProjectTreeView.test.tsx +3 -0
  116. package/src/workbench/components/ProjectTreeView.tsx +8 -0
  117. package/src/workbench/components/TreePane.tsx +2 -0
  118. package/src/workbench/components/editor-grid.ts +3 -0
  119. package/src/workbench/components/file-tree.ts +2 -0
  120. package/src/workbench/use-changes-workbench.ts +5 -0
  121. package/src/workbench/use-remember-layout.ts +2 -0
  122. 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
  }
@@ -30,6 +30,7 @@ export async function resolveGithubIdentity(): Promise<GithubIdentity | null> {
30
30
  ]);
31
31
 
32
32
  if (exitCode !== 0) return null;
33
+
33
34
  const parsed = v.safeParse(GithubUserSchema, JSON.parse(output));
34
35
 
35
36
  if (!parsed.success) return null;
@@ -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)
@@ -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)
@@ -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;