@crouton-kit/crouter 0.3.68 → 0.3.78

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 (176) hide show
  1. package/README.md +1 -1
  2. package/dist/builtin-memory/00-runtime-base.md +3 -10
  3. package/dist/builtin-memory/04-base-worker.md +18 -0
  4. package/dist/builtin-memory/04-orchestration-kernel.md +1 -1
  5. package/dist/builtin-pi-packages/pi-crtr-extensions/README.md +13 -34
  6. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/provider-rotation.test.ts +639 -741
  7. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +241 -58
  8. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/statusline.ts +2 -9
  9. package/dist/builtin-pi-packages/pi-crtr-extensions/lib/subscription-state.ts +365 -94
  10. package/dist/builtin-pi-packages/pi-crtr-extensions/package-lock.json +2 -2
  11. package/dist/builtin-pi-packages/pi-mode-switch/README.md +11 -39
  12. package/dist/builtin-pi-packages/pi-mode-switch/extensions/index.ts +20 -15
  13. package/dist/builtin-pi-packages/pi-mode-switch/package.json +1 -1
  14. package/dist/builtin-views/canvas/tui.mjs +8 -9
  15. package/dist/builtin-views/chat/tui.mjs +12 -12
  16. package/dist/builtin-views/git-pr/tui.mjs +7 -8
  17. package/dist/builtin-views/inbox/tui.mjs +27 -41
  18. package/dist/builtin-views/linkedin/tui.mjs +23 -37
  19. package/dist/builtin-views/prompt-review/tui.mjs +11 -11
  20. package/dist/builtin-views/settings/tui.mjs +11 -11
  21. package/dist/builtin-views/workspace-sidebar/tui.mjs +8 -9
  22. package/dist/cli.d.ts +1 -1
  23. package/dist/cli.js +1 -0
  24. package/dist/clients/attach/__tests__/attach-chrome-remote.test.js +11 -7
  25. package/dist/clients/attach/__tests__/crtr-output-render.test.js +1 -1
  26. package/dist/clients/attach/__tests__/mermaid-render.test.d.ts +1 -0
  27. package/dist/clients/attach/__tests__/mermaid-render.test.js +20 -0
  28. package/dist/clients/attach/__tests__/oauth-dialog-lifecycle.test.d.ts +1 -0
  29. package/dist/clients/attach/__tests__/oauth-dialog-lifecycle.test.js +110 -0
  30. package/dist/clients/attach/attach-cmd.d.ts +1 -1
  31. package/dist/clients/attach/attach-cmd.js +733 -730
  32. package/dist/clients/attach/auth-pickers.d.ts +12 -0
  33. package/dist/clients/attach/auth-pickers.js +11 -0
  34. package/dist/clients/attach/canvas-panels.js +2 -3
  35. package/dist/clients/attach/chat-view.d.ts +7 -8
  36. package/dist/clients/attach/chat-view.js +119 -79
  37. package/dist/clients/attach/edit-diff-render.d.ts +6 -0
  38. package/dist/clients/attach/edit-diff-render.js +263 -0
  39. package/dist/clients/attach/input-controller.d.ts +28 -2
  40. package/dist/clients/attach/input-controller.js +38 -5
  41. package/dist/clients/attach/mermaid-render.js +5 -2
  42. package/dist/clients/attach/pickers.d.ts +8 -7
  43. package/dist/clients/attach/pickers.js +13 -17
  44. package/dist/clients/attach/slash-commands.d.ts +9 -0
  45. package/dist/clients/attach/slash-commands.js +128 -7
  46. package/dist/clients/attach/titled-editor.d.ts +12 -1
  47. package/dist/clients/attach/titled-editor.js +103 -8
  48. package/dist/commands/canvas-browse.js +2 -2
  49. package/dist/commands/memory/lint.js +39 -5
  50. package/dist/commands/memory/write.js +1 -0
  51. package/dist/commands/node.js +12 -5
  52. package/dist/commands/profile/new.js +30 -5
  53. package/dist/commands/surface-tmux-spread.js +1 -3
  54. package/dist/commands/sys/__tests__/config-keybindings.test.d.ts +1 -0
  55. package/dist/commands/sys/__tests__/config-keybindings.test.js +55 -0
  56. package/dist/commands/sys/__tests__/config-model-ladders.test.d.ts +1 -0
  57. package/dist/commands/sys/__tests__/config-model-ladders.test.js +121 -0
  58. package/dist/commands/sys/config.js +18 -21
  59. package/dist/commands/sys/setup-core.d.ts +12 -1
  60. package/dist/commands/sys/setup-core.js +25 -3
  61. package/dist/commands/sys/setup.js +166 -20
  62. package/dist/core/__tests__/base-worker-prompt.test.d.ts +1 -0
  63. package/dist/core/__tests__/base-worker-prompt.test.js +24 -0
  64. package/dist/core/__tests__/canvas-inbox-watcher-hold.test.js +232 -1
  65. package/dist/core/__tests__/fault-classifier.test.js +15 -0
  66. package/dist/core/__tests__/full/broker-dialogs.test.js +7 -2
  67. package/dist/core/__tests__/full/broker-pane-resolution.test.js +9 -6
  68. package/dist/core/__tests__/full/cascade-close.test.js +5 -2
  69. package/dist/core/__tests__/full/dead-pane-regression.test.js +6 -3
  70. package/dist/core/__tests__/full/detach-focus.test.js +10 -5
  71. package/dist/core/__tests__/full/human-new-window-regression.test.js +6 -3
  72. package/dist/core/__tests__/full/review-render-pane-regression.test.js +2 -2
  73. package/dist/core/__tests__/helpers/harness.d.ts +1 -0
  74. package/dist/core/__tests__/helpers/harness.js +28 -4
  75. package/dist/core/__tests__/host-teardown-process-group.test.js +15 -4
  76. package/dist/core/__tests__/live-mutation-verbs.test.js +5 -4
  77. package/dist/core/__tests__/review-model-floor.test.js +1 -1
  78. package/dist/core/__tests__/stream-watchdog.test.d.ts +1 -0
  79. package/dist/core/__tests__/stream-watchdog.test.js +70 -0
  80. package/dist/core/__tests__/tmux-surface.test.js +72 -0
  81. package/dist/core/canvas/__tests__/remote-event-stream.test.js +11 -9
  82. package/dist/core/canvas/__tests__/render-remote.test.js +7 -4
  83. package/dist/core/canvas/browse/__tests__/model.test.js +23 -5
  84. package/dist/core/canvas/browse/__tests__/rebuild-coalescer.test.js +30 -13
  85. package/dist/core/canvas/browse/__tests__/render.test.js +1 -0
  86. package/dist/core/canvas/browse/app.js +164 -5
  87. package/dist/core/canvas/browse/model.d.ts +10 -6
  88. package/dist/core/canvas/browse/model.js +20 -12
  89. package/dist/core/canvas/browse/pins.d.ts +4 -0
  90. package/dist/core/canvas/browse/pins.js +29 -0
  91. package/dist/core/canvas/browse/render.d.ts +13 -1
  92. package/dist/core/canvas/browse/render.js +72 -15
  93. package/dist/core/canvas/nav-model.js +5 -11
  94. package/dist/core/canvas/paths.d.ts +5 -0
  95. package/dist/core/canvas/paths.js +7 -0
  96. package/dist/core/canvas/pid.d.ts +14 -10
  97. package/dist/core/canvas/pid.js +14 -10
  98. package/dist/core/config.d.ts +53 -2
  99. package/dist/core/config.js +228 -110
  100. package/dist/core/fault-classifier.js +1 -1
  101. package/dist/core/fs-utils.d.ts +1 -0
  102. package/dist/core/fs-utils.js +15 -1
  103. package/dist/core/keybindings/__tests__/resolve.test.d.ts +1 -0
  104. package/dist/core/keybindings/__tests__/resolve.test.js +224 -0
  105. package/dist/core/keybindings/catalog.d.ts +14 -0
  106. package/dist/core/keybindings/catalog.js +256 -0
  107. package/dist/core/keybindings/index.d.ts +5 -0
  108. package/dist/core/keybindings/index.js +4 -0
  109. package/dist/core/keybindings/match.d.ts +29 -0
  110. package/dist/core/keybindings/match.js +99 -0
  111. package/dist/core/keybindings/persistence.d.ts +14 -0
  112. package/dist/core/keybindings/persistence.js +31 -0
  113. package/dist/core/keybindings/resolve.d.ts +14 -0
  114. package/dist/core/keybindings/resolve.js +305 -0
  115. package/dist/core/keybindings/types.d.ts +37 -0
  116. package/dist/core/keybindings/types.js +1 -0
  117. package/dist/core/memory-resolver.d.ts +1 -1
  118. package/dist/core/memory-resolver.js +10 -3
  119. package/dist/core/predicate.d.ts +5 -3
  120. package/dist/core/predicate.js +5 -3
  121. package/dist/core/runtime/broker-protocol.d.ts +33 -5
  122. package/dist/core/runtime/broker.js +165 -14
  123. package/dist/core/runtime/launch.d.ts +39 -6
  124. package/dist/core/runtime/launch.js +79 -21
  125. package/dist/core/runtime/naming.js +3 -3
  126. package/dist/core/runtime/pi-cli.d.ts +6 -0
  127. package/dist/core/runtime/pi-cli.js +16 -2
  128. package/dist/core/runtime/placement.d.ts +2 -2
  129. package/dist/core/runtime/placement.js +4 -1
  130. package/dist/core/runtime/promote.js +4 -0
  131. package/dist/core/runtime/recap.d.ts +3 -3
  132. package/dist/core/runtime/recap.js +47 -44
  133. package/dist/core/runtime/recycle.js +6 -1
  134. package/dist/core/runtime/reset.js +5 -0
  135. package/dist/core/runtime/revive.js +28 -18
  136. package/dist/core/runtime/session-list-cache.d.ts +23 -0
  137. package/dist/core/runtime/session-list-cache.js +270 -0
  138. package/dist/core/runtime/spawn.js +51 -32
  139. package/dist/core/runtime/stream-watchdog.d.ts +26 -0
  140. package/dist/core/runtime/stream-watchdog.js +75 -0
  141. package/dist/core/runtime/tmux-chrome.d.ts +1 -1
  142. package/dist/core/runtime/tmux-chrome.js +2 -2
  143. package/dist/core/runtime/tmux.d.ts +18 -15
  144. package/dist/core/runtime/tmux.js +216 -110
  145. package/dist/core/tui/__tests__/host-keybindings.test.d.ts +1 -0
  146. package/dist/core/tui/__tests__/host-keybindings.test.js +112 -0
  147. package/dist/core/tui/host.d.ts +30 -1
  148. package/dist/core/tui/host.js +148 -34
  149. package/dist/core/view/__tests__/transport-remote.test.js +9 -6
  150. package/dist/core/view/contract.d.ts +17 -3
  151. package/dist/daemon/crtrd-cli.d.ts +1 -1
  152. package/dist/daemon/crtrd-cli.js +1 -0
  153. package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +41 -0
  154. package/dist/pi-extensions/__tests__/canvas-stophook-context-nudge.test.d.ts +1 -0
  155. package/dist/pi-extensions/__tests__/canvas-stophook-context-nudge.test.js +121 -0
  156. package/dist/pi-extensions/canvas-inbox-watcher.d.ts +1 -1
  157. package/dist/pi-extensions/canvas-inbox-watcher.js +203 -25
  158. package/dist/pi-extensions/canvas-recap.d.ts +4 -0
  159. package/dist/pi-extensions/canvas-recap.js +38 -37
  160. package/dist/pi-extensions/canvas-stophook.d.ts +11 -0
  161. package/dist/pi-extensions/canvas-stophook.js +33 -4
  162. package/dist/suppress-experimental-warnings.d.ts +1 -0
  163. package/dist/suppress-experimental-warnings.js +15 -0
  164. package/dist/types.d.ts +3 -13
  165. package/dist/types.js +12 -36
  166. package/dist/web-client/assets/{index-CnF5r8ky.js → index--md2ylfi.js} +18 -17
  167. package/dist/web-client/assets/index-BUdm9s9s.css +2 -0
  168. package/dist/web-client/index.html +3 -3
  169. package/dist/web-client/sw.js +1 -1
  170. package/package.json +11 -8
  171. package/scripts/postinstall.mjs +1 -1
  172. package/dist/pi-extensions/canvas-nav.d.ts +0 -43
  173. package/dist/pi-extensions/canvas-nav.js +0 -640
  174. package/dist/pi-extensions/widget-order-bus.d.ts +0 -6
  175. package/dist/pi-extensions/widget-order-bus.js +0 -34
  176. package/dist/web-client/assets/index-BnmSLNLa.css +0 -2
@@ -8,8 +8,7 @@
8
8
  // root) or switch-client + select-window (across roots). done/dead nodes close
9
9
  // their window; reviving opens a fresh one.
10
10
  import { spawnSync } from 'node:child_process';
11
- import { readConfig } from '../config.js';
12
- import { defaultCanvasNavConfig } from '../../types.js';
11
+ import { resolveUserKeybindings, } from '../keybindings/index.js';
13
12
  import { bundledPiCommandForShell } from './pi-cli.js';
14
13
  import { surfaceTmuxStyleArgs } from './surface-bg.js';
15
14
  // ---------------------------------------------------------------------------
@@ -72,13 +71,22 @@ function ensureBracketedPaste() {
72
71
  // broken `Enbp=E[?2004h` (no ESC) and silently disables bracketed paste.
73
72
  tmux(['set-option', '-sga', 'terminal-overrides', '*:Enbp=\\E[?2004h:Dsbp=\\E[?2004l']);
74
73
  }
74
+ /** Server-level tmux prerequisites for crtr's viewer surfaces, applied once a
75
+ * server exists (callers invoke via ensureSession). Currently: bracketed paste
76
+ * capability (above) and `focus-events on`, which forwards terminal focus
77
+ * in/out (DECSET 1004) to the program in each pane — the attach viewer uses it
78
+ * to hollow out the editor cursor in blurred panes. */
79
+ function ensureServerOptions() {
80
+ ensureBracketedPaste();
81
+ tmux(['set-option', '-s', 'focus-events', 'on']);
82
+ }
75
83
  /** Create a detached session rooted at `cwd` if it doesn't exist. The session
76
84
  * name is a root's tmux home; every node under that root is a window in it. */
77
85
  export function ensureSession(name, cwd) {
78
86
  if (!sessionExists(name)) {
79
87
  tmux(['new-session', '-d', '-s', name, '-c', cwd]);
80
88
  }
81
- ensureBracketedPaste();
89
+ ensureServerOptions();
82
90
  }
83
91
  function envFlags(env) {
84
92
  const out = [];
@@ -400,135 +408,233 @@ export function sendKeysEnter(pane, text) {
400
408
  return tmux(['send-keys', '-t', pane, text, 'Enter']).ok;
401
409
  }
402
410
  // ---------------------------------------------------------------------------
403
- // Prefix menu — Alt+C opens a which-key-style tmux display-menu of crouter
404
- // actions. Installed on the running server at root boot; idempotent (a re-bind
405
- // overwrites the previous one). Menu entries are data-driven: tmux renders the
406
- // action tree, and every `crtr` / `popup` action routes through `crtr canvas
407
- // chord` so node-relative vars resolve in one safe place.
411
+ // Crouter-owned tmux bindings
408
412
  // ---------------------------------------------------------------------------
413
+ const TMUX_BINDING_MANIFEST = '@crtr_keybindings_manifest';
414
+ const ROOT_TABLE = 'root';
415
+ const MENU_ACTIONS = {
416
+ 'crtr.tmux.menu.promote': { description: 'promote to orchestrator', action: { kind: 'keys', keys: '/promote' } },
417
+ 'crtr.tmux.menu.resume': { description: 'resume node', action: { kind: 'keys', keys: '/resume-node' } },
418
+ 'crtr.tmux.menu.demote': { description: 'demote to terminal', action: { kind: 'command', run: 'node lifecycle demote --pane {pane}' } },
419
+ 'crtr.tmux.menu.detach': { description: 'detach to background', action: { kind: 'command', run: 'node lifecycle demote --pane {pane} --detach' } },
420
+ 'crtr.tmux.menu.close-subtree': { description: 'close agent + subtree', action: { kind: 'command', run: 'node lifecycle close --pane {pane}' } },
421
+ 'crtr.tmux.menu.context': { description: 'browse context dirs', action: { kind: 'keys', keys: '/context' } },
422
+ 'crtr.tmux.menu.graph': { description: 'graph view', action: { kind: 'keys', keys: '/graph' } },
423
+ 'crtr.tmux.menu.focus-manager': { description: 'focus manager', action: { kind: 'command', run: 'node focus {manager}' } },
424
+ 'crtr.tmux.menu.open': undefined,
425
+ 'crtr.tmux.menu.issues': undefined,
426
+ 'crtr.tmux.menu.issue.idea': undefined,
427
+ 'crtr.tmux.menu.issue.bug': undefined,
428
+ 'crtr.tmux.node.next': undefined,
429
+ 'crtr.tmux.node.previous': undefined,
430
+ 'crtr.tmux.view.next': undefined,
431
+ 'crtr.tmux.view.previous': undefined,
432
+ };
409
433
  function escapeTmuxFormats(value) {
410
434
  return value.replace(/#/g, '##');
411
435
  }
412
- function runShellCmd(shellCmd) {
413
- return `run-shell ${shellQuote(shellCmd)}`;
414
- }
415
- function tmuxCmd(args) {
436
+ function tmuxCommand(args) {
416
437
  return args.map(shellQuote).join(' ');
417
438
  }
418
- function chordShellCmd(run) {
419
- return runShellCmd(`crtr canvas chord --pane '#{pane_id}' --run ${shellQuote(escapeTmuxFormats(run))} >/dev/null 2>&1`);
420
- }
421
- function popupCmd(run) {
422
- return `display-popup -E -w 60% -h 50% -d "#{pane_current_path}" ${shellQuote(`crtr canvas chord --pane '#{pane_id}' --run ${shellQuote(escapeTmuxFormats(run))} --interactive`)}`;
423
- }
424
- function keysCmd(keys) {
425
- return `send-keys -t '#{pane_id}' ${shellQuote(escapeTmuxFormats(keys))} Enter`;
439
+ function chordCommand(run, interactive = false) {
440
+ const command = `crtr canvas chord --pane '#{pane_id}' --run ${shellQuote(escapeTmuxFormats(run))}${interactive ? ' --interactive' : ''}`;
441
+ return interactive
442
+ ? `display-popup -E -w 60% -h 50% -d "#{pane_current_path}" ${shellQuote(command)}`
443
+ : `run-shell ${shellQuote(`${command} >/dev/null 2>&1`)}`;
444
+ }
445
+ function renderMenuAction(action) {
446
+ switch (action.kind) {
447
+ case 'keys': return `send-keys -t '#{pane_id}' ${shellQuote(escapeTmuxFormats(action.keys))} Enter`;
448
+ case 'command': return chordCommand(action.run);
449
+ case 'popup': return chordCommand(action.run, true);
450
+ case 'submenu': return tmuxCommand(buildMenuArgs(action.title, action.items));
451
+ }
426
452
  }
427
453
  function buildMenuArgs(title, items) {
428
454
  const safeTitle = escapeTmuxFormats(title);
455
+ const widestItem = items.reduce((width, item) => Math.max(width, escapeTmuxFormats(item.description).length), 0);
429
456
  const args = ['display-menu', '-T', `#[align=centre]${safeTitle}`, ...surfaceTmuxStyleArgs()];
430
- const boxW = Math.max(safeTitle.length, ...items.map((i) => escapeTmuxFormats(i.desc).length)) + 6;
431
- const nudgeX = 1;
432
- const nudgeY = 3;
433
- args.push('-x', `#{e|-:#{pane_right},${boxW + nudgeX}}`, '-y', `#{e|+:#{pane_top},${nudgeY}}`);
457
+ args.push('-x', `#{e|-:#{pane_right},${Math.max(safeTitle.length, widestItem) + 7}}`, '-y', '#{e|+:#{pane_top},3}');
434
458
  for (const item of items) {
435
- args.push(escapeTmuxFormats(item.desc), escapeTmuxFormats(item.key), renderAction(item.action, item.desc));
459
+ args.push(escapeTmuxFormats(item.description), item.selector, renderMenuAction(item.action));
436
460
  }
437
461
  args.push('-esc / q to close', '', '');
438
462
  return args;
439
463
  }
440
- function renderAction(action, label) {
441
- const kind = action.kind ?? 'crtr';
442
- switch (kind) {
443
- case 'keys':
444
- return keysCmd(action.keys);
445
- case 'popup':
446
- return popupCmd(action.run);
447
- case 'menu':
448
- return tmuxCmd(buildMenuArgs(label, action.items));
449
- case 'crtr':
450
- return chordShellCmd(action.run);
451
- default:
452
- throw new Error(`unknown canvas action kind: ${kind}`);
464
+ const TMUX_NAMED_KEYS = {
465
+ enter: 'Enter', escape: 'Escape', tab: 'Tab', backspace: 'BSpace', delete: 'DC',
466
+ space: 'Space', up: 'Up', down: 'Down', left: 'Left', right: 'Right',
467
+ pageup: 'PPage', pagedown: 'NPage', home: 'Home', end: 'End',
468
+ };
469
+ function tmuxKey(stroke) {
470
+ const parts = stroke.split('+');
471
+ let key = parts.pop();
472
+ const modifiers = new Set(parts);
473
+ if (modifiers.has('shift') && key.length === 1 && /[a-z]/.test(key)) {
474
+ key = key.toUpperCase();
475
+ modifiers.delete('shift');
476
+ }
477
+ key = TMUX_NAMED_KEYS[key] ?? key;
478
+ const prefix = [
479
+ modifiers.has('ctrl') ? 'C-' : '',
480
+ modifiers.has('alt') ? 'M-' : '',
481
+ modifiers.has('shift') ? 'S-' : '',
482
+ ].join('');
483
+ return `${prefix}${key}`;
484
+ }
485
+ function menuSelector(stroke) {
486
+ const parts = stroke.split('+');
487
+ const key = parts.at(-1);
488
+ return parts.includes('shift') && /[a-z]/.test(key) ? key.toUpperCase() : key === 'space' ? ' ' : key;
489
+ }
490
+ function menuItems(bindings) {
491
+ const issueItems = [];
492
+ for (const [id, description, type] of [
493
+ ['crtr.tmux.menu.issue.idea', 'idea', 'idea'],
494
+ ['crtr.tmux.menu.issue.bug', 'bug', 'bug'],
495
+ ]) {
496
+ for (const gesture of bindings.gestures(id)) {
497
+ issueItems.push({ description, selector: menuSelector(gesture), action: { kind: 'popup', run: `canvas issue new --type ${type}` } });
498
+ }
453
499
  }
500
+ const items = [];
501
+ for (const id of [
502
+ 'crtr.tmux.menu.promote', 'crtr.tmux.menu.resume', 'crtr.tmux.menu.demote',
503
+ 'crtr.tmux.menu.detach', 'crtr.tmux.menu.close-subtree', 'crtr.tmux.menu.context',
504
+ 'crtr.tmux.menu.graph', 'crtr.tmux.menu.focus-manager', 'crtr.tmux.menu.issues',
505
+ ]) {
506
+ const entry = id === 'crtr.tmux.menu.issues'
507
+ ? { description: 'new issue / todo', action: { kind: 'submenu', title: 'new issue / todo', items: issueItems } }
508
+ : MENU_ACTIONS[id];
509
+ if (id === 'crtr.tmux.menu.issues' && issueItems.length === 0)
510
+ continue;
511
+ for (const gesture of bindings.gestures(id)) {
512
+ items.push({ description: entry.description, selector: menuSelector(gesture), action: entry.action });
513
+ }
514
+ }
515
+ return items;
516
+ }
517
+ function bindingAt(table, key) {
518
+ const result = tmux(['list-keys', '-T', table]);
519
+ if (!result.ok)
520
+ return null;
521
+ return result.stdout.split('\n').find((line) => {
522
+ const match = line.match(/^bind-key\s+(?:-r\s+)?-T\s+\S+\s+(\S+)\s+/);
523
+ return match?.[1] === key;
524
+ }) ?? null;
454
525
  }
455
- /** Bind Alt+C to the crouter action menu. Best-effort; false if tmux fails. */
456
- export function installMenuBinding() {
457
- let prefixBinds;
526
+ function sequenceTable(prefix) {
527
+ return `crtr-${Buffer.from(prefix).toString('hex')}`;
528
+ }
529
+ function readManifest() {
530
+ const option = tmux(['show-options', '-s', '-q', TMUX_BINDING_MANIFEST]);
531
+ if (!option.ok)
532
+ return null;
533
+ if (option.stdout === '')
534
+ return { pairs: [], existed: false };
535
+ const result = tmux(['show-options', '-s', '-qv', TMUX_BINDING_MANIFEST]);
536
+ if (!result.ok)
537
+ return null;
458
538
  try {
459
- prefixBinds = readConfig('user').canvasNav.prefixBinds;
539
+ const value = JSON.parse(result.stdout);
540
+ if (!Array.isArray(value) || value.some((pair) => typeof pair !== 'object' || pair === null || typeof pair.table !== 'string' || typeof pair.key !== 'string'))
541
+ return null;
542
+ return { pairs: value, existed: true };
460
543
  }
461
544
  catch {
462
- prefixBinds = defaultCanvasNavConfig().prefixBinds;
545
+ return null;
463
546
  }
464
- const items = [];
465
- for (const [key, bind] of Object.entries(prefixBinds)) {
466
- if (key.length !== 1)
467
- continue;
468
- const desc = bind.desc ?? `chord ${key}`;
469
- items.push({ key, desc, action: bind });
547
+ }
548
+ const LEGACY_ROOT_BINDINGS = [
549
+ { key: 'M-c', markers: ['display-menu', 'promote to orchestrator', 'crtr canvas chord', 'new issue / todo'] },
550
+ { key: 'M-]', markers: ['run-shell', 'crtr node cycle --dir next', "--pane '#{pane_id}'"] },
551
+ { key: 'M-[', markers: ['run-shell', 'crtr node cycle --dir prev', "--pane '#{pane_id}'"] },
552
+ { key: 'M-v', markers: ['switch-client', '-T crtr-view'] },
553
+ ];
554
+ function adoptLegacyBindings() {
555
+ for (const legacy of LEGACY_ROOT_BINDINGS) {
556
+ const existing = bindingAt(ROOT_TABLE, legacy.key);
557
+ if (existing !== null && legacy.markers.every((marker) => existing.includes(marker))) {
558
+ tmux(['unbind-key', '-T', ROOT_TABLE, legacy.key]);
559
+ }
470
560
  }
471
- return tmux(['bind-key', '-n', 'M-c', ...buildMenuArgs(' crtr ', items)]).ok;
472
561
  }
473
- // ---------------------------------------------------------------------------
474
- // Nav bindings — Alt+] / Alt+[ DFS-walk the canvas one window at a time. Each
475
- // key shells out to `crtr node cycle`, passing the active pane so the walk is
476
- // relative to the agent in front of you; cycle then swaps the next/prev node
477
- // into that pane (like `node focus`). Output is discarded so the keypress never
478
- // pops a results view. Installed at root boot alongside the Alt+C menu.
479
- // ---------------------------------------------------------------------------
480
- /** Bind Alt+] (forward) and Alt+[ (back) to the DFS canvas walk. Best-effort;
481
- * false if either bind fails. NOTE: Alt+[ is only delivered cleanly when the
482
- * terminal/tmux disambiguate it from a raw CSI introducer (`extended-keys on`).
483
- */
484
- export function installNavBindings() {
485
- const next = tmux([
486
- 'bind-key', '-n', 'M-]', 'run-shell',
487
- `crtr node cycle --dir next --pane '#{pane_id}' >/dev/null 2>&1`,
488
- ]).ok;
489
- const prev = tmux([
490
- 'bind-key', '-n', 'M-[', 'run-shell',
491
- `crtr node cycle --dir prev --pane '#{pane_id}' >/dev/null 2>&1`,
492
- ]).ok;
493
- return next && prev;
562
+ function actionCommand(id) {
563
+ switch (id) {
564
+ case 'crtr.tmux.node.next': return ['run-shell', `crtr node cycle --dir next --pane '#{pane_id}' >/dev/null 2>&1`];
565
+ case 'crtr.tmux.node.previous': return ['run-shell', `crtr node cycle --dir prev --pane '#{pane_id}' >/dev/null 2>&1`];
566
+ case 'crtr.tmux.view.next': return ['run-shell', `crtr surface view cycle --dir next --pane '#{pane_id}' >/dev/null 2>&1`];
567
+ case 'crtr.tmux.view.previous': return ['run-shell', `crtr surface view cycle --dir prev --pane '#{pane_id}' >/dev/null 2>&1`];
568
+ default: throw new Error(`no tmux command for ${id}`);
569
+ }
494
570
  }
495
- // ---------------------------------------------------------------------------
496
- // View-nav bindings Alt+V then ] / [ cycle the view hosted in a MONITOR pane
497
- // to the next/prev available view, in place. A VIEW-PREFIXED CHORD, not a bare
498
- // Alt pair: Alt+V switches into a private one-shot `crtr-view` key table, then
499
- // ] (next) / [ (prev) fire the cycle — mirroring node cycle's bracket DIRECTION
500
- // grammar (Alt+] next / Alt+[ prev) while NAMESPACING the brackets so they can
501
- // never shadow root bindings. This is collision-proof: only ONE root key (M-v)
502
- // is claimed, and bare Alt pairs are a minefield in real configs (e.g. Alt+,/.
503
- // and Alt+]/.[ commonly bound to window/pane nav). The bracket lives in the
504
- // private table, so it also sidesteps the M-[ vs CSI-introducer ambiguity that
505
- // dogs installNavBindings' Alt+[. Each key shells surface-view cycle, passing the
506
- // active pane; cycle reads the pane's @crtr_view tag and respawns it on the
507
- // next/prev view. Output discarded so the keypress never pops a results view.
508
- // Installed at root boot alongside the node nav + Alt+C menu.
509
- //
510
- // NOTE: `M-v` is a GLOBAL root binding — it intercepts Alt+V in every pane/app
511
- // (e.g. swallowed from the pi editor), the same tradeoff crtr already takes for
512
- // M-c / M-] / M-[. Intended: crtr owns a small set of Alt chords server-wide.
513
- // ---------------------------------------------------------------------------
514
- /** Bind Alt+V → (], [) to the view-monitor cycle. `M-v` enters the private
515
- * `crtr-view` key table (switch-client -T), then ] cycles next / [ cycles prev.
516
- * Best-effort; false if any of the three binds fail. Deliberately distinct from
517
- * installNavBindings' bare Alt+]/Alt+[ (node cycle): the two cycles coexist on
518
- * the same server — brackets alone walk the node graph, Alt+V-then-bracket flips
519
- * view monitors. The bracket keys are bound in the private table, so they NEVER
520
- * shadow the user's root ]/[ and carry no CSI-introducer ambiguity. */
521
- export function installViewNavBindings() {
522
- const enter = tmux([
523
- 'bind-key', '-n', 'M-v', 'switch-client', '-T', 'crtr-view',
524
- ]).ok;
525
- const next = tmux([
526
- 'bind-key', '-T', 'crtr-view', ']', 'run-shell',
527
- `crtr surface view cycle --dir next --pane '#{pane_id}' >/dev/null 2>&1`,
528
- ]).ok;
529
- const prev = tmux([
530
- 'bind-key', '-T', 'crtr-view', '[', 'run-shell',
531
- `crtr surface view cycle --dir prev --pane '#{pane_id}' >/dev/null 2>&1`,
532
- ]).ok;
533
- return enter && next && prev;
571
+ /** Reconcile the tmux server with one immutable crouter binding snapshot. */
572
+ export function installTmuxBindings(bindings = resolveUserKeybindings()) {
573
+ const diagnostics = [];
574
+ const installed = [];
575
+ const manifest = readManifest();
576
+ if (manifest === null) {
577
+ diagnostics.push({ code: 'tmux-error', message: `tmux is unavailable or ${TMUX_BINDING_MANIFEST} is malformed` });
578
+ return { ok: false, installed, diagnostics };
579
+ }
580
+ if (!manifest.existed)
581
+ adoptLegacyBindings();
582
+ for (const pair of manifest.pairs) {
583
+ const result = tmux(['unbind-key', '-T', pair.table, pair.key]);
584
+ if (!result.ok && bindingAt(pair.table, pair.key) !== null) {
585
+ diagnostics.push({ code: 'tmux-error', message: `could not remove crouter binding ${pair.table}:${pair.key}: ${result.stderr}` });
586
+ }
587
+ }
588
+ if (diagnostics.some((diagnostic) => diagnostic.code === 'tmux-error')) {
589
+ return { ok: false, installed, diagnostics };
590
+ }
591
+ const install = (table, key, command, id, gesture) => {
592
+ if (bindingAt(table, key) !== null) {
593
+ diagnostics.push({ code: 'occupied', bindingId: id, gesture, message: `${table}:${key} is already bound by tmux; ${id} was not installed` });
594
+ return false;
595
+ }
596
+ const result = tmux(['bind-key', '-T', table, key, ...command]);
597
+ if (!result.ok) {
598
+ diagnostics.push({ code: 'tmux-error', bindingId: id, gesture, message: `could not install ${id}: ${result.stderr}` });
599
+ return false;
600
+ }
601
+ installed.push({ table, key });
602
+ return true;
603
+ };
604
+ const menuCommand = buildMenuArgs(' crtr ', menuItems(bindings));
605
+ for (const gesture of bindings.gestures('crtr.tmux.menu.open')) {
606
+ install(ROOT_TABLE, tmuxKey(gesture), menuCommand, 'crtr.tmux.menu.open', gesture);
607
+ }
608
+ for (const id of ['crtr.tmux.node.next', 'crtr.tmux.node.previous']) {
609
+ for (const gesture of bindings.gestures(id))
610
+ install(ROOT_TABLE, tmuxKey(gesture), actionCommand(id), id, gesture);
611
+ }
612
+ const sequences = new Map();
613
+ for (const id of ['crtr.tmux.view.next', 'crtr.tmux.view.previous']) {
614
+ for (const gesture of bindings.gestures(id)) {
615
+ const [first, second] = gesture.split(' > ');
616
+ const prefix = tmuxKey(first);
617
+ const entries = sequences.get(prefix) ?? [];
618
+ entries.push({ id, gesture, second: tmuxKey(second) });
619
+ sequences.set(prefix, entries);
620
+ }
621
+ }
622
+ for (const [prefix, entries] of sequences) {
623
+ if (bindingAt(ROOT_TABLE, prefix) !== null) {
624
+ for (const entry of entries)
625
+ diagnostics.push({ code: 'occupied', bindingId: entry.id, gesture: entry.gesture, message: `${ROOT_TABLE}:${prefix} is already bound by tmux; ${entry.id} was not installed` });
626
+ continue;
627
+ }
628
+ const table = sequenceTable(prefix);
629
+ let children = 0;
630
+ for (const entry of entries)
631
+ if (install(table, entry.second, actionCommand(entry.id), entry.id, entry.gesture))
632
+ children++;
633
+ if (children > 0)
634
+ install(ROOT_TABLE, prefix, ['switch-client', '-T', table], entries[0].id, entries[0].gesture);
635
+ }
636
+ const save = tmux(['set-option', '-s', TMUX_BINDING_MANIFEST, JSON.stringify(installed)]);
637
+ if (!save.ok)
638
+ diagnostics.push({ code: 'tmux-error', message: `could not save crouter tmux ownership manifest: ${save.stderr}` });
639
+ return { ok: diagnostics.every((diagnostic) => diagnostic.code !== 'tmux-error'), installed, diagnostics };
534
640
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,112 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import { resolveKeybindings } from '../../keybindings/index.js';
4
+ import { matchViewBinding, matchesViewHostBinding, parseViewKeypress, resolveViewKeyHints } from '../host.js';
5
+ function key(input = '', patch = {}) {
6
+ return {
7
+ upArrow: false,
8
+ downArrow: false,
9
+ leftArrow: false,
10
+ rightArrow: false,
11
+ return: false,
12
+ escape: false,
13
+ ctrl: false,
14
+ meta: false,
15
+ tab: false,
16
+ shiftTab: false,
17
+ backspace: false,
18
+ ...patch,
19
+ };
20
+ }
21
+ test('catalog view bindings use defaults, remaps, and effective hint labels', () => {
22
+ const keymap = [
23
+ { bindingId: 'crtr.view.canvas.down', intent: 'down', hint: { label: 'move' } },
24
+ ];
25
+ const defaults = resolveKeybindings();
26
+ assert.deepEqual(matchViewBinding(keymap, defaults, 'j', key('j'), { row: 0 }), { intent: 'down', payload: undefined });
27
+ assert.deepEqual(resolveViewKeyHints(keymap, defaults, { row: 0 }), [{ keys: 'J / Down', label: 'move' }]);
28
+ const remapped = resolveKeybindings({ 'crtr.view.canvas.down': ['x'] });
29
+ assert.equal(matchViewBinding(keymap, remapped, 'j', key('j'), { row: 0 }), null);
30
+ assert.deepEqual(matchViewBinding(keymap, remapped, 'x', key('x'), { row: 0 }), { intent: 'down', payload: undefined });
31
+ assert.deepEqual(resolveViewKeyHints(keymap, remapped, { row: 0 }), [{ keys: 'X', label: 'move' }]);
32
+ });
33
+ test('custom view literals retain their matching and presenter-authored hints', () => {
34
+ const keymap = [
35
+ { keys: ['z'], intent: 'custom', hint: { keys: 'z', label: 'custom' } },
36
+ ];
37
+ const bindings = resolveKeybindings();
38
+ assert.deepEqual(matchViewBinding(keymap, bindings, 'z', key('z'), undefined), { intent: 'custom', payload: undefined });
39
+ assert.equal(matchViewBinding(keymap, bindings, 'x', key('x'), undefined), null);
40
+ assert.deepEqual(resolveViewKeyHints(keymap, bindings, undefined), [{ keys: 'z', label: 'custom' }]);
41
+ });
42
+ test('Alt+<letter> does not falsely match a bare custom literal', () => {
43
+ const keymap = [{ keys: ['q'], intent: 'quit' }];
44
+ const bindings = resolveKeybindings();
45
+ const altQ = parseViewKeypress(Buffer.from('\x1bq'));
46
+ assert.equal(matchViewBinding(keymap, bindings, altQ.input, altQ.key, undefined, altQ.raw), null);
47
+ const q = parseViewKeypress(Buffer.from('q'));
48
+ assert.deepEqual(matchViewBinding(keymap, bindings, q.input, q.key, undefined, q.raw), { intent: 'quit', payload: undefined });
49
+ });
50
+ test('host force quit and capture deletion follow catalog IDs through raw terminal decoding', () => {
51
+ const defaults = resolveKeybindings();
52
+ const ctrlC = parseViewKeypress(Buffer.from('\x03'));
53
+ const del = parseViewKeypress(Buffer.from('\x7f'), defaults);
54
+ const bs = parseViewKeypress(Buffer.from('\b'), defaults);
55
+ assert.ok(matchesViewHostBinding(defaults, 'crtr.view.host.force-quit', ctrlC.raw));
56
+ assert.ok(matchesViewHostBinding(defaults, 'crtr.view.host.editor.backspace', del.raw));
57
+ // 0x08 stays Backspace under default config so ^H-erase terminals still delete.
58
+ assert.ok(matchesViewHostBinding(defaults, 'crtr.view.host.editor.backspace', bs.raw));
59
+ const remapped = resolveKeybindings({
60
+ 'crtr.view.host.force-quit': ['alt+x'],
61
+ 'crtr.view.host.editor.backspace': ['ctrl+h'],
62
+ });
63
+ const altX = parseViewKeypress(Buffer.from('\x1bx'), remapped);
64
+ const ctrlH = parseViewKeypress(Buffer.from('\b'), remapped);
65
+ const delRemap = parseViewKeypress(Buffer.from('\x7f'), remapped);
66
+ assert.equal(matchesViewHostBinding(remapped, 'crtr.view.host.force-quit', ctrlC.raw), false);
67
+ assert.ok(matchesViewHostBinding(remapped, 'crtr.view.host.force-quit', altX.raw));
68
+ // With deletion remapped to Ctrl-H, the 0x08 byte deletes and DEL no longer does.
69
+ assert.equal(matchesViewHostBinding(remapped, 'crtr.view.host.editor.backspace', delRemap.raw), false);
70
+ assert.ok(matchesViewHostBinding(remapped, 'crtr.view.host.editor.backspace', ctrlH.raw));
71
+ });
72
+ test('Alt+Backspace deletes like Backspace under default config', () => {
73
+ const defaults = resolveKeybindings();
74
+ const altDel = parseViewKeypress(Buffer.from('\x1b\x7f'), defaults);
75
+ const altBs = parseViewKeypress(Buffer.from('\x1b\b'), defaults);
76
+ assert.ok(matchesViewHostBinding(defaults, 'crtr.view.host.editor.backspace', altDel.raw));
77
+ assert.ok(matchesViewHostBinding(defaults, 'crtr.view.host.editor.backspace', altBs.raw));
78
+ });
79
+ test('raw terminal decoding covers modified arrows and enter/return equivalence', () => {
80
+ const keymap = [{ bindingId: 'crtr.view.settings.edit-or-submit', intent: 'submit' }];
81
+ const bindings = resolveKeybindings();
82
+ const cr = parseViewKeypress(Buffer.from('\r'));
83
+ const lf = parseViewKeypress(Buffer.from('\n'));
84
+ assert.deepEqual(matchViewBinding(keymap, bindings, cr.input, cr.key, undefined, cr.raw), { intent: 'submit', payload: undefined });
85
+ assert.deepEqual(matchViewBinding(keymap, bindings, lf.input, lf.key, undefined, lf.raw), { intent: 'submit', payload: undefined });
86
+ const ctrlUp = parseViewKeypress(Buffer.from('\x1b[1;5A'));
87
+ assert.equal(ctrlUp.raw.upArrow, true);
88
+ assert.equal(ctrlUp.raw.ctrl, true);
89
+ const up = parseViewKeypress(Buffer.from('\x1b[A'));
90
+ assert.equal(up.raw.upArrow, true);
91
+ assert.equal(up.raw.ctrl, false);
92
+ });
93
+ test('a disabled binding neither matches nor renders a hint', () => {
94
+ const keymap = [
95
+ { bindingId: 'crtr.view.canvas.refresh', intent: 'refresh', hint: { label: 'refresh' } },
96
+ ];
97
+ const bindings = resolveKeybindings({ 'crtr.view.canvas.refresh': [] });
98
+ const g = parseViewKeypress(Buffer.from('g'));
99
+ assert.equal(matchViewBinding(keymap, bindings, g.input, g.key, undefined, g.raw), null);
100
+ assert.deepEqual(resolveViewKeyHints(keymap, bindings, undefined), []);
101
+ });
102
+ test('capture hints are host-owned, mode-gated, and keep custom KeyHint keys verbatim', () => {
103
+ const gated = [
104
+ { capture: 'setDraft', when: (state) => state.compose, hint: { label: 'compose' } },
105
+ ];
106
+ assert.deepEqual(resolveViewKeyHints(gated, resolveKeybindings(), { compose: true }), [{ keys: 'type', label: 'compose' }]);
107
+ assert.deepEqual(resolveViewKeyHints(gated, resolveKeybindings(), { compose: false }), []);
108
+ const custom = [
109
+ { capture: 'setDraft', when: () => true, hint: { keys: 'edit', label: 'compose' } },
110
+ ];
111
+ assert.deepEqual(resolveViewKeyHints(custom, resolveKeybindings(), undefined), [{ keys: 'edit', label: 'compose' }]);
112
+ });
@@ -1,5 +1,7 @@
1
+ import { type Key } from './terminal.js';
1
2
  import { type Draw, type Rect, type Size } from './draw.js';
2
- import type { ViewCore, TuiPresenter, TextPresenter, KeyHint, BannerLevel } from '../view/contract.js';
3
+ import type { ViewCore, TuiPresenter, TextPresenter, KeyBinding, KeyHint, BannerLevel } from '../view/contract.js';
4
+ import { type BindingId, type BindingResolution, type RawTerminalInput } from '../keybindings/index.js';
3
5
  export interface RunViewOptions {
4
6
  /** CLI flags forwarded verbatim to the view via host.options. */
5
7
  options?: Record<string, string>;
@@ -32,6 +34,33 @@ export interface ChromeManifest {
32
34
  * on the row above it when set. */
33
35
  declare function drawChrome(draw: Draw, size: Size, manifest: ChromeManifest, c: Chrome, now?: number): Rect;
34
36
  export { drawChrome };
37
+ /** Tokens a keystroke can match a keymap binding's `keys` against. Arrows →
38
+ * up/down/left/right, return → return|enter, escape → escape|esc, a printable
39
+ * char → the char itself (+ 'space' for ' '), ctrl+x → ctrl+x|c-x. */
40
+ export interface ViewKeypress {
41
+ readonly input: string;
42
+ readonly key: Key;
43
+ readonly raw: RawTerminalInput;
44
+ }
45
+ /** Extend the shared legacy parser with the terminal gestures the catalog can
46
+ * express. The raw shape goes only to the catalog matcher; custom literals keep
47
+ * their established Key-based matching. When `bindings` is supplied the ambiguous
48
+ * 0x08 byte resolves to Ctrl-H only if an effective binding claims it. */
49
+ export declare function parseViewKeypress(data: Buffer, bindings?: BindingResolution<BindingId>): ViewKeypress;
50
+ /** Resolve footer hints for the CURRENT state: only bindings whose `when(state)`
51
+ * passes contribute, so mode-gated affordances (compose/react send/cancel/pick)
52
+ * appear exactly while active. Catalog bindings render the effective key label
53
+ * (omitted entirely when disabled); custom-view literal hints stay verbatim. */
54
+ export declare function resolveViewKeyHints<S>(keymap: KeyBinding<S>[], bindings: BindingResolution<BindingId>, state: S): KeyHint[];
55
+ /** The first non-capture binding whose key matches and whose `when` (if any)
56
+ * passes — catalog IDs use the terminal matcher; custom literals retain their
57
+ * presenter-authored matching. */
58
+ export declare function matchViewBinding<S>(keymap: KeyBinding<S>[], bindings: BindingResolution<BindingId>, input: string, key: Key, state: S, raw?: RawTerminalInput): {
59
+ intent: string;
60
+ payload: unknown;
61
+ } | null;
62
+ /** Match a host-owned generic-view action through the same catalog snapshot. */
63
+ export declare function matchesViewHostBinding(bindings: BindingResolution<BindingId>, id: 'crtr.view.host.force-quit' | 'crtr.view.host.editor.backspace', raw: RawTerminalInput): boolean;
35
64
  /** Host a dual-target ViewCore + TuiPresenter in the alt screen until it quits
36
65
  * (or Ctrl-C). `text` is the optional text presenter for the piped path. */
37
66
  export declare function runCoreView<S>(core: ViewCore<S>, tui: TuiPresenter<S>, text: TextPresenter<S> | null, opts?: RunViewOptions): Promise<void>;