@crouton-kit/crouter 0.3.70 → 0.3.79

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 (193) hide show
  1. package/README.md +1 -1
  2. package/dist/build-root.d.ts +12 -4
  3. package/dist/build-root.js +25 -6
  4. package/dist/builtin-memory/00-runtime-base.md +3 -10
  5. package/dist/builtin-memory/04-base-worker.md +18 -0
  6. package/dist/builtin-memory/04-orchestration-kernel.md +1 -1
  7. package/dist/builtin-memory/crouter-development/plugins.md +82 -5
  8. package/dist/builtin-pi-packages/pi-crtr-extensions/README.md +13 -34
  9. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/provider-rotation.test.ts +1115 -1
  10. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +234 -71
  11. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/statusline.ts +2 -9
  12. package/dist/builtin-pi-packages/pi-crtr-extensions/lib/subscription-state.ts +2 -516
  13. package/dist/builtin-pi-packages/pi-crtr-extensions/package-lock.json +2 -2
  14. package/dist/builtin-pi-packages/pi-mode-switch/README.md +11 -39
  15. package/dist/builtin-pi-packages/pi-mode-switch/extensions/index.ts +20 -15
  16. package/dist/builtin-pi-packages/pi-mode-switch/package.json +1 -1
  17. package/dist/builtin-views/canvas/tui.mjs +8 -9
  18. package/dist/builtin-views/chat/tui.mjs +12 -12
  19. package/dist/builtin-views/git-pr/tui.mjs +7 -8
  20. package/dist/builtin-views/inbox/tui.mjs +27 -41
  21. package/dist/builtin-views/linkedin/tui.mjs +23 -37
  22. package/dist/builtin-views/prompt-review/tui.mjs +11 -11
  23. package/dist/builtin-views/settings/tui.mjs +11 -11
  24. package/dist/builtin-views/workspace-sidebar/tui.mjs +8 -9
  25. package/dist/clients/attach/__tests__/attach-chrome-remote.test.js +11 -6
  26. package/dist/clients/attach/__tests__/attach-keybindings.test.d.ts +1 -0
  27. package/dist/clients/attach/__tests__/attach-keybindings.test.js +113 -0
  28. package/dist/clients/attach/__tests__/crtr-output-render.test.js +1 -1
  29. package/dist/clients/attach/__tests__/mermaid-render.test.d.ts +1 -0
  30. package/dist/clients/attach/__tests__/mermaid-render.test.js +28 -0
  31. package/dist/clients/attach/__tests__/oauth-dialog-lifecycle.test.d.ts +1 -0
  32. package/dist/clients/attach/__tests__/oauth-dialog-lifecycle.test.js +110 -0
  33. package/dist/clients/attach/attach-cmd.d.ts +9 -1
  34. package/dist/clients/attach/attach-cmd.js +841 -792
  35. package/dist/clients/attach/auth-pickers.d.ts +0 -12
  36. package/dist/clients/attach/auth-pickers.js +64 -15
  37. package/dist/clients/attach/canvas-panels.js +2 -3
  38. package/dist/clients/attach/chat-view.d.ts +7 -8
  39. package/dist/clients/attach/chat-view.js +119 -79
  40. package/dist/clients/attach/edit-diff-render.d.ts +6 -0
  41. package/dist/clients/attach/edit-diff-render.js +263 -0
  42. package/dist/clients/attach/graph-overlay.d.ts +12 -2
  43. package/dist/clients/attach/graph-overlay.js +83 -33
  44. package/dist/clients/attach/input-controller.d.ts +28 -2
  45. package/dist/clients/attach/input-controller.js +38 -5
  46. package/dist/clients/attach/mermaid-render.js +5 -2
  47. package/dist/clients/attach/pickers.d.ts +8 -7
  48. package/dist/clients/attach/pickers.js +11 -17
  49. package/dist/clients/attach/slash-commands.d.ts +9 -0
  50. package/dist/clients/attach/slash-commands.js +127 -7
  51. package/dist/clients/attach/titled-editor.d.ts +12 -1
  52. package/dist/clients/attach/titled-editor.js +103 -8
  53. package/dist/commands/canvas-browse.js +2 -2
  54. package/dist/commands/human/queue.js +3 -4
  55. package/dist/commands/memory/lint.js +39 -5
  56. package/dist/commands/memory/write.js +1 -0
  57. package/dist/commands/node.js +9 -2
  58. package/dist/commands/pkg/plugin-inspect.js +22 -1
  59. package/dist/commands/pkg/plugin-manage.js +31 -9
  60. package/dist/commands/surface-tmux-spread.js +1 -3
  61. package/dist/commands/sys/__tests__/config-keybindings.test.d.ts +1 -0
  62. package/dist/commands/sys/__tests__/config-keybindings.test.js +55 -0
  63. package/dist/commands/sys/__tests__/config-model-ladders.test.d.ts +1 -0
  64. package/dist/commands/sys/__tests__/config-model-ladders.test.js +121 -0
  65. package/dist/commands/sys/__tests__/setup-core.test.js +158 -1
  66. package/dist/commands/sys/config.js +18 -21
  67. package/dist/commands/sys/doctor.js +42 -4
  68. package/dist/commands/sys/setup-core.d.ts +49 -1
  69. package/dist/commands/sys/setup-core.js +161 -4
  70. package/dist/commands/sys/setup.d.ts +88 -0
  71. package/dist/commands/sys/setup.js +943 -169
  72. package/dist/commands/view-pick.d.ts +4 -0
  73. package/dist/commands/view-pick.js +17 -7
  74. package/dist/core/__tests__/base-worker-prompt.test.d.ts +1 -0
  75. package/dist/core/__tests__/base-worker-prompt.test.js +24 -0
  76. package/dist/core/__tests__/canvas-inbox-watcher-hold.test.js +232 -1
  77. package/dist/core/__tests__/canvas-inbox-watcher.test.js +34 -9
  78. package/dist/core/__tests__/command-plugins-surfaces.test.d.ts +1 -0
  79. package/dist/core/__tests__/command-plugins-surfaces.test.js +298 -0
  80. package/dist/core/__tests__/command-plugins.test.d.ts +1 -0
  81. package/dist/core/__tests__/command-plugins.test.js +444 -0
  82. package/dist/core/__tests__/fault-classifier.test.js +15 -0
  83. package/dist/core/__tests__/fixtures/fake-engine.d.ts +6 -0
  84. package/dist/core/__tests__/fixtures/fake-engine.js +9 -1
  85. package/dist/core/__tests__/full/broker-dialogs.test.js +7 -2
  86. package/dist/core/__tests__/host-teardown-process-group.test.js +15 -4
  87. package/dist/core/__tests__/preview-registry-sync.test.js +30 -1
  88. package/dist/core/__tests__/scope-crouter-home-fence.test.d.ts +1 -0
  89. package/dist/core/__tests__/scope-crouter-home-fence.test.js +55 -0
  90. package/dist/core/__tests__/stream-watchdog.test.d.ts +1 -0
  91. package/dist/core/__tests__/stream-watchdog.test.js +70 -0
  92. package/dist/core/__tests__/tmux-surface.test.js +72 -0
  93. package/dist/core/canvas/browse/__tests__/model.test.js +23 -5
  94. package/dist/core/canvas/browse/__tests__/render.test.js +1 -0
  95. package/dist/core/canvas/browse/app.d.ts +6 -0
  96. package/dist/core/canvas/browse/app.js +230 -41
  97. package/dist/core/canvas/browse/model.d.ts +10 -6
  98. package/dist/core/canvas/browse/model.js +20 -12
  99. package/dist/core/canvas/browse/pins.d.ts +4 -0
  100. package/dist/core/canvas/browse/pins.js +29 -0
  101. package/dist/core/canvas/browse/render.d.ts +13 -1
  102. package/dist/core/canvas/browse/render.js +72 -15
  103. package/dist/core/canvas/nav-model.js +5 -11
  104. package/dist/core/canvas/paths.d.ts +5 -0
  105. package/dist/core/canvas/paths.js +7 -0
  106. package/dist/core/command-plugins/adapter.d.ts +15 -0
  107. package/dist/core/command-plugins/adapter.js +145 -0
  108. package/dist/core/command-plugins/compose.d.ts +5 -0
  109. package/dist/core/command-plugins/compose.js +56 -0
  110. package/dist/core/command-plugins/discovery.d.ts +104 -0
  111. package/dist/core/command-plugins/discovery.js +565 -0
  112. package/dist/core/config.d.ts +48 -0
  113. package/dist/core/config.js +228 -110
  114. package/dist/core/fault-classifier.js +1 -1
  115. package/dist/core/fs-utils.d.ts +1 -0
  116. package/dist/core/fs-utils.js +15 -1
  117. package/dist/core/keybindings/__tests__/bespoke-consumers.test.d.ts +1 -0
  118. package/dist/core/keybindings/__tests__/bespoke-consumers.test.js +40 -0
  119. package/dist/core/keybindings/__tests__/resolve.test.d.ts +1 -0
  120. package/dist/core/keybindings/__tests__/resolve.test.js +224 -0
  121. package/dist/core/keybindings/catalog.d.ts +14 -0
  122. package/dist/core/keybindings/catalog.js +257 -0
  123. package/dist/core/keybindings/index.d.ts +5 -0
  124. package/dist/core/keybindings/index.js +4 -0
  125. package/dist/core/keybindings/match.d.ts +29 -0
  126. package/dist/core/keybindings/match.js +99 -0
  127. package/dist/core/keybindings/persistence.d.ts +14 -0
  128. package/dist/core/keybindings/persistence.js +31 -0
  129. package/dist/core/keybindings/resolve.d.ts +14 -0
  130. package/dist/core/keybindings/resolve.js +305 -0
  131. package/dist/core/keybindings/types.d.ts +37 -0
  132. package/dist/core/keybindings/types.js +1 -0
  133. package/dist/core/memory-resolver.d.ts +1 -1
  134. package/dist/core/memory-resolver.js +10 -3
  135. package/dist/core/predicate.d.ts +5 -3
  136. package/dist/core/predicate.js +5 -3
  137. package/dist/core/profiles/select.d.ts +6 -0
  138. package/dist/core/profiles/select.js +86 -52
  139. package/dist/core/provider-management.d.ts +12 -0
  140. package/dist/core/provider-management.js +24 -0
  141. package/dist/core/runtime/broker-protocol.d.ts +33 -5
  142. package/dist/core/runtime/broker.js +164 -14
  143. package/dist/core/runtime/launch.d.ts +39 -6
  144. package/dist/core/runtime/launch.js +78 -20
  145. package/dist/core/runtime/naming.js +3 -3
  146. package/dist/core/runtime/pi-cli.d.ts +6 -0
  147. package/dist/core/runtime/pi-cli.js +16 -2
  148. package/dist/core/runtime/pi-vendored.d.ts +8 -0
  149. package/dist/core/runtime/pi-vendored.js +14 -0
  150. package/dist/core/runtime/placement.d.ts +2 -2
  151. package/dist/core/runtime/placement.js +4 -1
  152. package/dist/core/runtime/promote.js +4 -0
  153. package/dist/core/runtime/recap.d.ts +3 -3
  154. package/dist/core/runtime/recap.js +75 -47
  155. package/dist/core/runtime/recycle.js +6 -1
  156. package/dist/core/runtime/reset.js +5 -0
  157. package/dist/core/runtime/session-list-cache.d.ts +33 -0
  158. package/dist/core/runtime/session-list-cache.js +338 -0
  159. package/dist/core/runtime/spawn.js +52 -45
  160. package/dist/core/runtime/stream-watchdog.d.ts +26 -0
  161. package/dist/core/runtime/stream-watchdog.js +75 -0
  162. package/dist/core/runtime/tmux-chrome.d.ts +1 -1
  163. package/dist/core/runtime/tmux-chrome.js +2 -2
  164. package/dist/core/runtime/tmux.d.ts +18 -15
  165. package/dist/core/runtime/tmux.js +217 -110
  166. package/dist/core/scope.js +27 -4
  167. package/dist/core/subscription-state.d.ts +90 -0
  168. package/dist/core/subscription-state.js +762 -0
  169. package/dist/core/tui/__tests__/host-keybindings.test.d.ts +1 -0
  170. package/dist/core/tui/__tests__/host-keybindings.test.js +112 -0
  171. package/dist/core/tui/host.d.ts +30 -1
  172. package/dist/core/tui/host.js +148 -34
  173. package/dist/core/view/contract.d.ts +17 -3
  174. package/dist/daemon/crtrd.js +253 -12
  175. package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +41 -0
  176. package/dist/pi-extensions/__tests__/canvas-stophook-context-nudge.test.d.ts +1 -0
  177. package/dist/pi-extensions/__tests__/canvas-stophook-context-nudge.test.js +121 -0
  178. package/dist/pi-extensions/canvas-inbox-watcher.d.ts +1 -1
  179. package/dist/pi-extensions/canvas-inbox-watcher.js +203 -25
  180. package/dist/pi-extensions/canvas-recap.d.ts +4 -0
  181. package/dist/pi-extensions/canvas-recap.js +73 -46
  182. package/dist/pi-extensions/canvas-stophook.d.ts +11 -0
  183. package/dist/pi-extensions/canvas-stophook.js +33 -4
  184. package/dist/types.d.ts +9 -26
  185. package/dist/types.js +1 -28
  186. package/dist/web-client/assets/{index-DiFuLcp6.js → index--md2ylfi.js} +1 -1
  187. package/dist/web-client/index.html +1 -1
  188. package/dist/web-client/sw.js +1 -1
  189. package/package.json +8 -4
  190. package/dist/pi-extensions/canvas-nav.d.ts +0 -43
  191. package/dist/pi-extensions/canvas-nav.js +0 -640
  192. package/dist/pi-extensions/widget-order-bus.d.ts +0 -6
  193. package/dist/pi-extensions/widget-order-bus.js +0 -34
@@ -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>;
@@ -22,6 +22,7 @@ import { setupTerminal, restoreTerminal, getTerminalSize, parseKeypress, } from
22
22
  import { createDraw, detectColorCaps } from './draw.js';
23
23
  import { initialChrome, deriveState } from '../view/chrome.js';
24
24
  import { createLocalTransport } from '../view/transport-local.js';
25
+ import { formatBinding, matchesTerminalInput, resolveUserKeybindings, } from '../keybindings/index.js';
25
26
  // Numeric SGR codes only (a color NAME emits a broken CSI — see draw.ts guard).
26
27
  const FG = { cyan: '36', green: '32', yellow: '33', red: '31', grey: '90' };
27
28
  /** The persistent state signal: word + glyph + hue, derived from host signals so
@@ -179,15 +180,94 @@ function drawChrome(draw, size, manifest, c, now = Date.now()) {
179
180
  return { row: top, col: 0, width: cols, height };
180
181
  }
181
182
  export { drawChrome };
182
- // ── Dual-target core host (runCoreView) ─────────────────────────────────────
183
- //
184
- // Hosts a ViewCore + TuiPresenter under the dual-target contract
185
- // (src/core/view/contract.ts). The model is the immutable-state + intents thunk
186
- // runtime: the core owns all state + behavior, presenters are pure reads, and a
187
- // keystroke maps through the keymap to a named intent the host dispatches.
188
- /** Tokens a keystroke can match a keymap binding's `keys` against. Arrows →
189
- * up/down/left/right, return → return|enter, escape → escape|esc, a printable
190
- * char → the char itself (+ 'space' for ' '), ctrl+x → ctrl+x|c-x. */
183
+ /** True when some effective binding claims the `ctrl+h` gesture — used to decide
184
+ * whether the ambiguous 0x08 byte is a Ctrl-H remap or a plain Backspace. */
185
+ function bindingSetClaimsCtrlH(bindings) {
186
+ for (const eff of bindings.bindings.values()) {
187
+ if (eff.gestures.includes('ctrl+h'))
188
+ return true;
189
+ }
190
+ return false;
191
+ }
192
+ /** One-line summary of rejected hand-edited overrides, surfaced once per surface. */
193
+ function summarizeKeybindingDiagnostics(diags) {
194
+ if (diags.length === 0)
195
+ return null;
196
+ const extra = diags.length > 1 ? ` (+${diags.length - 1} more)` : '';
197
+ return `keybindings: ${diags[0].message}${extra}`;
198
+ }
199
+ /** Extend the shared legacy parser with the terminal gestures the catalog can
200
+ * express. The raw shape goes only to the catalog matcher; custom literals keep
201
+ * their established Key-based matching. When `bindings` is supplied the ambiguous
202
+ * 0x08 byte resolves to Ctrl-H only if an effective binding claims it. */
203
+ export function parseViewKeypress(data, bindings) {
204
+ const parsed = parseKeypress(data);
205
+ const raw = { ...parsed.key, input: parsed.input };
206
+ const sequence = data.toString('utf8');
207
+ const named = {
208
+ '\x1b[3~': 'delete', '\x1b[5~': 'pageUp', '\x1b[6~': 'pageDown',
209
+ '\x1b[H': 'home', '\x1b[F': 'end', '\x1b[1~': 'home', '\x1b[4~': 'end',
210
+ '\x1bOH': 'home', '\x1bOF': 'end',
211
+ };
212
+ const setModifiers = (code) => {
213
+ const value = Number(code);
214
+ raw.shift = value === 2 || value === 4 || value === 6 || value === 8;
215
+ raw.meta = value === 3 || value === 4 || value === 7 || value === 8;
216
+ raw.ctrl = value === 5 || value === 6 || value === 7 || value === 8;
217
+ };
218
+ const namedKey = named[sequence];
219
+ if (namedKey)
220
+ raw[namedKey] = true;
221
+ const arrow = /^\x1b\[(?:1;)?([2-8])?([ABCD])$/.exec(sequence);
222
+ if (arrow) {
223
+ setModifiers(arrow[1]);
224
+ raw[{ A: 'upArrow', B: 'downArrow', C: 'rightArrow', D: 'leftArrow' }[arrow[2]]] = true;
225
+ }
226
+ const modifiedNamed = /^\x1b\[(3|5|6|1|4);([2-8])~$/.exec(sequence);
227
+ if (modifiedNamed) {
228
+ raw[{ 3: 'delete', 5: 'pageUp', 6: 'pageDown', 1: 'home', 4: 'end' }[modifiedNamed[1]]] = true;
229
+ setModifiers(modifiedNamed[2]);
230
+ }
231
+ const modifiedHomeEnd = /^\x1b\[1;([2-8])([HF])$/.exec(sequence);
232
+ if (modifiedHomeEnd) {
233
+ raw[modifiedHomeEnd[2] === 'H' ? 'home' : 'end'] = true;
234
+ setModifiers(modifiedHomeEnd[1]);
235
+ }
236
+ // Alt+<char> gestures (ESC-prefixed printables/control letters) that the
237
+ // legacy parser leaves as a raw two-byte `input`. Only the raw catalog shape
238
+ // gains the alt modifier; `parsed` keeps its Key-based shape so custom literals
239
+ // still match exactly as before (Alt+<letter> must NOT match a bare literal).
240
+ // Special keys the legacy parser already decoded (Alt+Backspace, arrows, …)
241
+ // return `input: ''`, so `parsed.input === sequence` skips them here.
242
+ if (sequence.length === 2 && sequence.startsWith('\x1b') && parsed.input === sequence) {
243
+ const code = sequence.charCodeAt(1);
244
+ raw.input = code < 32 ? String.fromCharCode(code + 64).toLowerCase() : sequence[1];
245
+ raw.meta = true;
246
+ raw.ctrl = code < 32;
247
+ }
248
+ // Backspace family. \x7f, \b, and their Alt-prefixed forms all mean "delete one
249
+ // char" and canonicalize to plain backspace (there is no alt+backspace catalog
250
+ // action, so Alt+Backspace deletes exactly like Backspace, as before). The 0x08
251
+ // byte is ALSO Ctrl-H: honor a Ctrl-H remap only when an effective binding
252
+ // actually claims `ctrl+h`, otherwise it stays backspace so default deletion
253
+ // keeps working on ^H-erase terminals.
254
+ if (raw.backspace) {
255
+ const ctrlHByte = sequence === '\b' || sequence === '\x1b\b';
256
+ if (ctrlHByte && bindings && bindingSetClaimsCtrlH(bindings)) {
257
+ raw.input = 'h';
258
+ raw.ctrl = true;
259
+ raw.meta = false;
260
+ raw.backspace = false;
261
+ }
262
+ else {
263
+ raw.input = '';
264
+ raw.ctrl = false;
265
+ raw.meta = false;
266
+ raw.backspace = true;
267
+ }
268
+ }
269
+ return { ...parsed, raw };
270
+ }
191
271
  function keyTokens(input, key) {
192
272
  const t = [];
193
273
  if (key.upArrow)
@@ -228,21 +308,53 @@ function isPrintable(input, key) {
228
308
  function sanitizePrintable(s) {
229
309
  return Array.from(s).filter((ch) => ch.charCodeAt(0) >= 32).join('');
230
310
  }
311
+ /** Resolve footer hints for the CURRENT state: only bindings whose `when(state)`
312
+ * passes contribute, so mode-gated affordances (compose/react send/cancel/pick)
313
+ * appear exactly while active. Catalog bindings render the effective key label
314
+ * (omitted entirely when disabled); custom-view literal hints stay verbatim. */
315
+ export function resolveViewKeyHints(keymap, bindings, state) {
316
+ const hints = [];
317
+ for (const binding of keymap) {
318
+ if (!binding.hint)
319
+ continue;
320
+ if (binding.when && !binding.when(state))
321
+ continue;
322
+ if ('bindingId' in binding) {
323
+ if (bindings.gestures(binding.bindingId).length === 0)
324
+ continue; // disabled: no hint
325
+ hints.push({ keys: formatBinding(bindings, binding.bindingId), label: binding.hint.label });
326
+ }
327
+ else if ('capture' in binding) {
328
+ hints.push('keys' in binding.hint ? binding.hint : { keys: 'type', label: binding.hint.label });
329
+ }
330
+ else {
331
+ hints.push(binding.hint);
332
+ }
333
+ }
334
+ return hints;
335
+ }
231
336
  /** The first non-capture binding whose key matches and whose `when` (if any)
232
- * passes — returns the intent name + resolved payload. */
233
- function matchBinding(keymap, input, key, state) {
337
+ * passes — catalog IDs use the terminal matcher; custom literals retain their
338
+ * presenter-authored matching. */
339
+ export function matchViewBinding(keymap, bindings, input, key, state, raw = { ...key, input }) {
234
340
  const tokens = keyTokens(input, key);
235
- for (const b of keymap) {
236
- if ('capture' in b)
341
+ for (const binding of keymap) {
342
+ if ('capture' in binding)
237
343
  continue;
238
- if (b.when && !b.when(state))
344
+ if (binding.when && !binding.when(state))
239
345
  continue;
240
- if (b.keys.some((k) => tokens.includes(k))) {
241
- return { intent: b.intent, payload: b.payload ? b.payload(state) : undefined };
242
- }
346
+ const matches = 'bindingId' in binding
347
+ ? matchesTerminalInput(bindings, binding.bindingId, raw)
348
+ : binding.keys.some((literal) => tokens.includes(literal));
349
+ if (matches)
350
+ return { intent: binding.intent, payload: binding.payload ? binding.payload(state) : undefined };
243
351
  }
244
352
  return null;
245
353
  }
354
+ /** Match a host-owned generic-view action through the same catalog snapshot. */
355
+ export function matchesViewHostBinding(bindings, id, raw) {
356
+ return matchesTerminalInput(bindings, id, raw);
357
+ }
246
358
  /** The active text-capture binding for this state, if any (compose-mode entry). */
247
359
  function activeCapture(keymap, state) {
248
360
  for (const b of keymap) {
@@ -271,9 +383,13 @@ async function runRequest(transport, src, args) {
271
383
  * (or Ctrl-C). `text` is the optional text presenter for the piped path. */
272
384
  export async function runCoreView(core, tui, text, opts = {}) {
273
385
  const options = Object.freeze({ ...(opts.options ?? {}) });
386
+ const bindings = resolveUserKeybindings();
387
+ const keybindingNotice = summarizeKeybindingDiagnostics(bindings.diagnostics);
274
388
  const transport = createLocalTransport({ cwd: process.cwd() });
275
389
  // ── Non-TTY / piped path: init, best-effort refresh, dump, exit 0. ──
276
390
  if (!process.stdin.isTTY) {
391
+ if (keybindingNotice)
392
+ process.stderr.write(`${keybindingNotice}\n`);
277
393
  let dstate = core.init(options);
278
394
  const dchrome = initialChrome();
279
395
  const dsignal = {
@@ -307,13 +423,9 @@ export async function runCoreView(core, tui, text, opts = {}) {
307
423
  let state = core.init(options);
308
424
  const chrome = initialChrome();
309
425
  let tick = 0;
310
- // Footer hints come from the keymap bindings' `hint` field (single source of
311
- // truth) — projected into the (ChromeManifest, Chrome) shape drawChrome reads.
312
- const hints = [];
313
- for (const b of tui.keymap) {
314
- if (b.hint)
315
- hints.push(b.hint);
316
- }
426
+ // Report rejected hand-edited overrides once, as a chrome notice.
427
+ if (keybindingNotice)
428
+ chrome.banner = { msg: keybindingNotice, level: 'action' };
317
429
  // Restore the terminal exactly once, however we leave.
318
430
  let restored = false;
319
431
  const cleanup = () => {
@@ -329,7 +441,9 @@ export async function runCoreView(core, tui, text, opts = {}) {
329
441
  const render = () => {
330
442
  const size = getTerminalSize();
331
443
  const { draw, frame } = createDraw(size, caps);
332
- const chromeManifest = { ...core.manifest, keymap: hints };
444
+ // Footer hints are re-derived per draw so they track the active mode
445
+ // (compose/react affordances appear only while their `when(state)` holds).
446
+ const chromeManifest = { ...core.manifest, keymap: resolveViewKeyHints(tui.keymap, bindings, state) };
333
447
  const tickChrome = { ...chrome, tick };
334
448
  const content = drawChrome(draw, size, chromeManifest, tickChrome);
335
449
  try {
@@ -525,14 +639,14 @@ export async function runCoreView(core, tui, text, opts = {}) {
525
639
  return;
526
640
  let parsed;
527
641
  try {
528
- parsed = parseKeypress(data);
642
+ parsed = parseViewKeypress(data, bindings);
529
643
  }
530
644
  catch {
531
645
  return;
532
646
  }
533
- const { input, key } = parsed;
534
- // Ctrl-C is the universal escape hatch works even mid-flight.
535
- if (key.ctrl && input === 'c') {
647
+ const { input, key, raw } = parsed;
648
+ // The catalog-backed escape hatch works even mid-flight.
649
+ if (matchesViewHostBinding(bindings, 'crtr.view.host.force-quit', raw)) {
536
650
  finish();
537
651
  return;
538
652
  }
@@ -544,13 +658,13 @@ export async function runCoreView(core, tui, text, opts = {}) {
544
658
  // fall through to the keymap so submit/cancel can be bound.
545
659
  const cap = activeCapture(tui.keymap, state);
546
660
  if (cap) {
547
- if (isPrintable(input, key)) {
548
- captureBuf += sanitizePrintable(input);
661
+ if (matchesViewHostBinding(bindings, 'crtr.view.host.editor.backspace', raw)) {
662
+ captureBuf = captureBuf.slice(0, -1);
549
663
  await enqueueDispatch(cap.capture, captureBuf);
550
664
  return;
551
665
  }
552
- if (key.backspace) {
553
- captureBuf = captureBuf.slice(0, -1);
666
+ if (isPrintable(input, key)) {
667
+ captureBuf += sanitizePrintable(input);
554
668
  await enqueueDispatch(cap.capture, captureBuf);
555
669
  return;
556
670
  }
@@ -558,7 +672,7 @@ export async function runCoreView(core, tui, text, opts = {}) {
558
672
  else {
559
673
  captureBuf = '';
560
674
  }
561
- const m = matchBinding(tui.keymap, input, key, state);
675
+ const m = matchViewBinding(tui.keymap, bindings, input, key, state, raw);
562
676
  if (m)
563
677
  await enqueueDispatch(m.intent, m.payload);
564
678
  };
@@ -154,7 +154,20 @@ export interface KeyHint {
154
154
  keys: string;
155
155
  label: string;
156
156
  }
157
+ /** A catalog binding supplies only its semantic hint label: the host derives
158
+ * the rendered key label from the effective user binding snapshot. */
159
+ export interface CatalogKeyHint {
160
+ label: string;
161
+ }
157
162
  export type KeyBinding<S> = {
163
+ /** Closed crouter catalog action. Shipped presenters use this form. */
164
+ bindingId: import('../keybindings/index.js').BindingId;
165
+ intent: string;
166
+ payload?: (state: S) => unknown;
167
+ when?: (state: S) => boolean;
168
+ hint?: CatalogKeyHint;
169
+ } | {
170
+ /** Literal keys remain owned by custom/plugin views, outside crouter's catalog. */
158
171
  keys: string[];
159
172
  intent: string;
160
173
  payload?: (state: S) => unknown;
@@ -162,11 +175,12 @@ export type KeyBinding<S> = {
162
175
  hint?: KeyHint;
163
176
  } | {
164
177
  /** Text-capture binding: while `when(state)` is true the host runs a
165
- * built-in line-edit buffer over raw printable/backspace keys and
166
- * dispatches `capture` with the next draft value on each edit. */
178
+ * built-in line-edit buffer over raw printable keys and dispatches
179
+ * `capture` with the next draft value on each edit. Deletion is the
180
+ * host catalog action `crtr.view.host.editor.backspace`. */
167
181
  capture: string;
168
182
  when: (state: S) => boolean;
169
- hint?: KeyHint;
183
+ hint?: KeyHint | CatalogKeyHint;
170
184
  };
171
185
  export interface TuiPresenter<S = unknown> {
172
186
  /** Pure read of state, paints via draw.*; never ANSI. */