@crouton-kit/crouter 0.3.70 → 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 (147) 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 +622 -724
  7. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +231 -68
  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 +361 -90
  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/clients/attach/__tests__/attach-chrome-remote.test.js +3 -3
  23. package/dist/clients/attach/__tests__/crtr-output-render.test.js +1 -1
  24. package/dist/clients/attach/__tests__/mermaid-render.test.d.ts +1 -0
  25. package/dist/clients/attach/__tests__/mermaid-render.test.js +20 -0
  26. package/dist/clients/attach/__tests__/oauth-dialog-lifecycle.test.d.ts +1 -0
  27. package/dist/clients/attach/__tests__/oauth-dialog-lifecycle.test.js +110 -0
  28. package/dist/clients/attach/attach-cmd.d.ts +1 -1
  29. package/dist/clients/attach/attach-cmd.js +738 -735
  30. package/dist/clients/attach/canvas-panels.js +2 -3
  31. package/dist/clients/attach/chat-view.d.ts +7 -8
  32. package/dist/clients/attach/chat-view.js +119 -79
  33. package/dist/clients/attach/edit-diff-render.d.ts +6 -0
  34. package/dist/clients/attach/edit-diff-render.js +263 -0
  35. package/dist/clients/attach/input-controller.d.ts +28 -2
  36. package/dist/clients/attach/input-controller.js +38 -5
  37. package/dist/clients/attach/mermaid-render.js +5 -2
  38. package/dist/clients/attach/pickers.d.ts +8 -7
  39. package/dist/clients/attach/pickers.js +11 -17
  40. package/dist/clients/attach/slash-commands.d.ts +9 -0
  41. package/dist/clients/attach/slash-commands.js +127 -7
  42. package/dist/clients/attach/titled-editor.d.ts +12 -1
  43. package/dist/clients/attach/titled-editor.js +103 -8
  44. package/dist/commands/canvas-browse.js +2 -2
  45. package/dist/commands/memory/lint.js +39 -5
  46. package/dist/commands/memory/write.js +1 -0
  47. package/dist/commands/node.js +9 -2
  48. package/dist/commands/surface-tmux-spread.js +1 -3
  49. package/dist/commands/sys/__tests__/config-keybindings.test.d.ts +1 -0
  50. package/dist/commands/sys/__tests__/config-keybindings.test.js +55 -0
  51. package/dist/commands/sys/__tests__/config-model-ladders.test.d.ts +1 -0
  52. package/dist/commands/sys/__tests__/config-model-ladders.test.js +121 -0
  53. package/dist/commands/sys/config.js +18 -21
  54. package/dist/commands/sys/setup-core.d.ts +12 -1
  55. package/dist/commands/sys/setup-core.js +23 -3
  56. package/dist/commands/sys/setup.js +42 -12
  57. package/dist/core/__tests__/base-worker-prompt.test.d.ts +1 -0
  58. package/dist/core/__tests__/base-worker-prompt.test.js +24 -0
  59. package/dist/core/__tests__/canvas-inbox-watcher-hold.test.js +232 -1
  60. package/dist/core/__tests__/fault-classifier.test.js +15 -0
  61. package/dist/core/__tests__/full/broker-dialogs.test.js +7 -2
  62. package/dist/core/__tests__/host-teardown-process-group.test.js +15 -4
  63. package/dist/core/__tests__/stream-watchdog.test.d.ts +1 -0
  64. package/dist/core/__tests__/stream-watchdog.test.js +70 -0
  65. package/dist/core/__tests__/tmux-surface.test.js +72 -0
  66. package/dist/core/canvas/browse/__tests__/model.test.js +23 -5
  67. package/dist/core/canvas/browse/__tests__/render.test.js +1 -0
  68. package/dist/core/canvas/browse/app.js +164 -5
  69. package/dist/core/canvas/browse/model.d.ts +10 -6
  70. package/dist/core/canvas/browse/model.js +20 -12
  71. package/dist/core/canvas/browse/pins.d.ts +4 -0
  72. package/dist/core/canvas/browse/pins.js +29 -0
  73. package/dist/core/canvas/browse/render.d.ts +13 -1
  74. package/dist/core/canvas/browse/render.js +72 -15
  75. package/dist/core/canvas/nav-model.js +5 -11
  76. package/dist/core/canvas/paths.d.ts +5 -0
  77. package/dist/core/canvas/paths.js +7 -0
  78. package/dist/core/config.d.ts +53 -2
  79. package/dist/core/config.js +228 -110
  80. package/dist/core/fault-classifier.js +1 -1
  81. package/dist/core/fs-utils.d.ts +1 -0
  82. package/dist/core/fs-utils.js +15 -1
  83. package/dist/core/keybindings/__tests__/resolve.test.d.ts +1 -0
  84. package/dist/core/keybindings/__tests__/resolve.test.js +224 -0
  85. package/dist/core/keybindings/catalog.d.ts +14 -0
  86. package/dist/core/keybindings/catalog.js +256 -0
  87. package/dist/core/keybindings/index.d.ts +5 -0
  88. package/dist/core/keybindings/index.js +4 -0
  89. package/dist/core/keybindings/match.d.ts +29 -0
  90. package/dist/core/keybindings/match.js +99 -0
  91. package/dist/core/keybindings/persistence.d.ts +14 -0
  92. package/dist/core/keybindings/persistence.js +31 -0
  93. package/dist/core/keybindings/resolve.d.ts +14 -0
  94. package/dist/core/keybindings/resolve.js +305 -0
  95. package/dist/core/keybindings/types.d.ts +37 -0
  96. package/dist/core/keybindings/types.js +1 -0
  97. package/dist/core/memory-resolver.d.ts +1 -1
  98. package/dist/core/memory-resolver.js +10 -3
  99. package/dist/core/predicate.d.ts +5 -3
  100. package/dist/core/predicate.js +5 -3
  101. package/dist/core/runtime/broker-protocol.d.ts +33 -5
  102. package/dist/core/runtime/broker.js +163 -14
  103. package/dist/core/runtime/launch.d.ts +39 -6
  104. package/dist/core/runtime/launch.js +78 -20
  105. package/dist/core/runtime/naming.js +3 -3
  106. package/dist/core/runtime/pi-cli.d.ts +6 -0
  107. package/dist/core/runtime/pi-cli.js +16 -2
  108. package/dist/core/runtime/placement.d.ts +2 -2
  109. package/dist/core/runtime/placement.js +4 -1
  110. package/dist/core/runtime/promote.js +4 -0
  111. package/dist/core/runtime/recap.d.ts +3 -3
  112. package/dist/core/runtime/recap.js +47 -44
  113. package/dist/core/runtime/recycle.js +6 -1
  114. package/dist/core/runtime/reset.js +5 -0
  115. package/dist/core/runtime/session-list-cache.d.ts +23 -0
  116. package/dist/core/runtime/session-list-cache.js +270 -0
  117. package/dist/core/runtime/spawn.js +51 -32
  118. package/dist/core/runtime/stream-watchdog.d.ts +26 -0
  119. package/dist/core/runtime/stream-watchdog.js +75 -0
  120. package/dist/core/runtime/tmux-chrome.d.ts +1 -1
  121. package/dist/core/runtime/tmux-chrome.js +2 -2
  122. package/dist/core/runtime/tmux.d.ts +18 -15
  123. package/dist/core/runtime/tmux.js +216 -110
  124. package/dist/core/tui/__tests__/host-keybindings.test.d.ts +1 -0
  125. package/dist/core/tui/__tests__/host-keybindings.test.js +112 -0
  126. package/dist/core/tui/host.d.ts +30 -1
  127. package/dist/core/tui/host.js +148 -34
  128. package/dist/core/view/contract.d.ts +17 -3
  129. package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +41 -0
  130. package/dist/pi-extensions/__tests__/canvas-stophook-context-nudge.test.d.ts +1 -0
  131. package/dist/pi-extensions/__tests__/canvas-stophook-context-nudge.test.js +121 -0
  132. package/dist/pi-extensions/canvas-inbox-watcher.d.ts +1 -1
  133. package/dist/pi-extensions/canvas-inbox-watcher.js +203 -25
  134. package/dist/pi-extensions/canvas-recap.d.ts +4 -0
  135. package/dist/pi-extensions/canvas-recap.js +38 -37
  136. package/dist/pi-extensions/canvas-stophook.d.ts +11 -0
  137. package/dist/pi-extensions/canvas-stophook.js +33 -4
  138. package/dist/types.d.ts +3 -13
  139. package/dist/types.js +2 -26
  140. package/dist/web-client/assets/{index-DiFuLcp6.js → index--md2ylfi.js} +1 -1
  141. package/dist/web-client/index.html +1 -1
  142. package/dist/web-client/sw.js +1 -1
  143. package/package.json +3 -3
  144. package/dist/pi-extensions/canvas-nav.d.ts +0 -43
  145. package/dist/pi-extensions/canvas-nav.js +0 -640
  146. package/dist/pi-extensions/widget-order-bus.d.ts +0 -6
  147. package/dist/pi-extensions/widget-order-bus.js +0 -34
@@ -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. */
@@ -21,6 +21,7 @@ import { closeDb } from '../../core/canvas/db.js';
21
21
  import { reportsDir } from '../../core/canvas/paths.js';
22
22
  import { readInboxSince } from '../../core/feed/inbox.js';
23
23
  import { STALL_REPROMPT } from '../../core/runtime/stop-guard.js';
24
+ import { recordFault, readFault } from '../../core/runtime/fault.js';
24
25
  let home;
25
26
  let origNode;
26
27
  let origPane;
@@ -262,6 +263,46 @@ test('refresh-yield (intent=refresh) writes NO push — silent to subscribers',
262
263
  assert.equal(readInboxSince('root').length, 0, 'a yield is silent: NO inbox pointer');
263
264
  assert.equal(pi.injected.length, 0, 'no reprompt on a refresh-yield');
264
265
  });
266
+ // ---------------------------------------------------------------------------
267
+ // turn_end — stale-⚠ fix: reaching turn_end proves the current provider
268
+ // round-trip and any wedge episode are both over, so both fault links clear.
269
+ // ---------------------------------------------------------------------------
270
+ /** A turn_end event carrying one assistant text block. */
271
+ function turnEndEvent(text) {
272
+ return { message: { role: 'assistant', content: [{ type: 'text', text }] } };
273
+ }
274
+ test('turn_end clears a stale pi→provider fatal fault (a completed round-trip proves the episode is over)', () => {
275
+ createNode(node('leaf', { parent: null, lifecycle: 'terminal' }));
276
+ recordFault('leaf', {
277
+ link: 'pi→provider',
278
+ op: 'generation turn',
279
+ kind: 'other',
280
+ retry: { disposition: 'fatal' },
281
+ message: 'terminated',
282
+ });
283
+ assert.notEqual(readFault('leaf'), null, 'precondition: fault marker recorded');
284
+ process.env['CRTR_NODE_ID'] = 'leaf';
285
+ const pi = makeFakePi();
286
+ registerCanvasStophook(pi);
287
+ pi.fire('turn_end', turnEndEvent('all good now'), {});
288
+ assert.equal(readFault('leaf'), null, 'the pi→provider fault is cleared at turn_end');
289
+ });
290
+ test('turn_end still clears a daemon→node wedge fault (existing behavior, unchanged)', () => {
291
+ createNode(node('leaf', { parent: null, lifecycle: 'terminal' }));
292
+ recordFault('leaf', {
293
+ link: 'daemon→node',
294
+ op: 'wedge detection',
295
+ kind: 'wedged',
296
+ retry: { disposition: 'manual' },
297
+ message: 'no progress for 20m',
298
+ });
299
+ assert.notEqual(readFault('leaf'), null, 'precondition: wedge fault recorded');
300
+ process.env['CRTR_NODE_ID'] = 'leaf';
301
+ const pi = makeFakePi();
302
+ registerCanvasStophook(pi);
303
+ pi.fire('turn_end', turnEndEvent('turn completed'), {});
304
+ assert.equal(readFault('leaf'), null, 'the daemon→node wedge fault is still cleared at turn_end');
305
+ });
265
306
  test('stalled leaf (nothing live to await, no final) is still reprompted', () => {
266
307
  createNode(node('mgr', { parent: null, lifecycle: 'terminal', mode: 'orchestrator' }));
267
308
  createNode(node('leaf', { parent: 'mgr', lifecycle: 'terminal', status: 'active' }));
@@ -0,0 +1,121 @@
1
+ // Run with: node --import tsx/esm --test src/pi-extensions/__tests__/canvas-stophook-context-nudge.test.ts
2
+ //
3
+ // The context-size nudge's delivery mode splits on whether the agent run
4
+ // continues past the turn. The regression this pins: the nudge used to be an
5
+ // unconditional STEER, and pi's agent loop drains the steering queue right
6
+ // after turn_end — so a band crossing on the run's FINAL message (no tool
7
+ // calls) extended the run past the agent's closing reply, burying the summary
8
+ // a human comes back to read once the conversation goes quiet.
9
+ // • turn_end with toolCall content (mid-run) → sendUserMessage steer
10
+ // • turn_end with text-only content (final) → sendMessage 'nextTurn' hold
11
+ //
12
+ // Second regression pinned here: a tool-calling turn ends with stopReason
13
+ // 'toolUse' (pi-ai's StopReason), NEVER 'stop'. Gating the steer path on
14
+ // stopReason 'stop'/'length' made runContinues unconditionally false, so the
15
+ // nudge was ALWAYS appended as nextTurn and never steered mid-run.
16
+ import { test, before, after, beforeEach } from 'node:test';
17
+ import assert from 'node:assert/strict';
18
+ import { mkdtempSync, rmSync } from 'node:fs';
19
+ import { tmpdir } from 'node:os';
20
+ import { join } from 'node:path';
21
+ import registerCanvasStophook from '../canvas-stophook.js';
22
+ import { createNode } from '../../core/canvas/canvas.js';
23
+ import { closeDb } from '../../core/canvas/db.js';
24
+ let home;
25
+ let origNode;
26
+ function node(id) {
27
+ return {
28
+ node_id: id,
29
+ name: id,
30
+ created: new Date().toISOString(),
31
+ cwd: '/tmp/work',
32
+ kind: 'general',
33
+ mode: 'base',
34
+ lifecycle: 'terminal',
35
+ status: 'active',
36
+ };
37
+ }
38
+ function makeFakePi() {
39
+ const handlers = {};
40
+ return {
41
+ steered: [],
42
+ held: [],
43
+ on(e, h) { handlers[e] = h; },
44
+ sendUserMessage(content, options) { this.steered.push({ content, deliverAs: options?.deliverAs }); },
45
+ sendMessage(m, options) {
46
+ this.held.push({ customType: m.customType, content: m.content, deliverAs: options?.deliverAs, triggerTurn: options?.triggerTurn });
47
+ },
48
+ fire(e, ev, ctx) { handlers[e]?.(ev, ctx); },
49
+ };
50
+ }
51
+ /** A turn_end event whose assistant message crossed a steering band. A
52
+ * tool-calling turn really ends with stopReason 'toolUse'; text-only finals
53
+ * end with 'stop'. */
54
+ function turnEnd(content, stopReason = 'stop') {
55
+ return { message: { role: 'assistant', stopReason, content, usage: { input: 1, output: 1 } }, toolResults: [] };
56
+ }
57
+ const ctxAt = (tokens) => ({ getContextUsage: () => ({ tokens }) });
58
+ before(() => { origNode = process.env['CRTR_NODE_ID']; });
59
+ beforeEach(() => {
60
+ closeDb();
61
+ if (home)
62
+ rmSync(home, { recursive: true, force: true });
63
+ home = mkdtempSync(join(tmpdir(), 'crtr-stophook-nudge-'));
64
+ process.env['CRTR_HOME'] = home;
65
+ });
66
+ after(() => {
67
+ closeDb();
68
+ if (home)
69
+ rmSync(home, { recursive: true, force: true });
70
+ delete process.env['CRTR_HOME'];
71
+ if (origNode === undefined)
72
+ delete process.env['CRTR_NODE_ID'];
73
+ else
74
+ process.env['CRTR_NODE_ID'] = origNode;
75
+ });
76
+ test('band crossing mid-run (tool calls present) → context nudge is a STEER', () => {
77
+ createNode(node('midrun'));
78
+ process.env['CRTR_NODE_ID'] = 'midrun';
79
+ const pi = makeFakePi();
80
+ registerCanvasStophook(pi);
81
+ pi.fire('turn_end', turnEnd([{ type: 'toolCall', id: 't1', name: 'bash', arguments: {} }], 'toolUse'), ctxAt(150_000));
82
+ assert.equal(pi.steered.length, 1, 'exactly one steer delivered');
83
+ assert.equal(pi.steered[0].deliverAs, 'steer');
84
+ assert.match(pi.steered[0].content, /^\[crtr\] Context ~150k/);
85
+ assert.equal(pi.held.length, 0, 'nothing held for nextTurn');
86
+ });
87
+ test('band crossing on the FINAL message (no tool calls) → nudge is HELD as nextTurn, never a steer', () => {
88
+ createNode(node('final'));
89
+ process.env['CRTR_NODE_ID'] = 'final';
90
+ const pi = makeFakePi();
91
+ registerCanvasStophook(pi);
92
+ pi.fire('turn_end', turnEnd([{ type: 'text', text: 'here is my summary' }]), ctxAt(150_000));
93
+ assert.equal(pi.steered.length, 0, 'no steer — a steer here would extend the run past the closing reply');
94
+ assert.equal(pi.held.length, 1, 'nudge held for the next user message');
95
+ assert.equal(pi.held[0].deliverAs, 'nextTurn');
96
+ assert.equal(pi.held[0].customType, 'crtr-context-nudge');
97
+ assert.match(pi.held[0].content, /^\[crtr\] Context ~150k/);
98
+ assert.notEqual(pi.held[0].triggerTurn, true, 'must not trigger a turn');
99
+ });
100
+ test('a truncated tool-calling turn (stopReason length) still steers — the loop continues past failed calls', () => {
101
+ createNode(node('lengthrun'));
102
+ process.env['CRTR_NODE_ID'] = 'lengthrun';
103
+ const pi = makeFakePi();
104
+ registerCanvasStophook(pi);
105
+ pi.fire('turn_end', turnEnd([{ type: 'toolCall', id: 't1', name: 'bash', arguments: {} }], 'length'), ctxAt(150_000));
106
+ assert.equal(pi.steered.length, 1);
107
+ assert.equal(pi.steered[0].deliverAs, 'steer');
108
+ assert.equal(pi.held.length, 0);
109
+ });
110
+ test('an aborted final turn also holds (the loop ends unconditionally on abort)', () => {
111
+ createNode(node('aborted'));
112
+ process.env['CRTR_NODE_ID'] = 'aborted';
113
+ const pi = makeFakePi();
114
+ registerCanvasStophook(pi);
115
+ // Aborted mid-tool-call: toolCall content present, but the run ends anyway —
116
+ // a steer would sit queued and leak into the NEXT run's start instead.
117
+ pi.fire('turn_end', turnEnd([{ type: 'toolCall', id: 't1', name: 'bash', arguments: {} }], 'aborted'), ctxAt(150_000));
118
+ assert.equal(pi.steered.length, 0);
119
+ assert.equal(pi.held.length, 1);
120
+ assert.equal(pi.held[0].deliverAs, 'nextTurn');
121
+ });
@@ -1,4 +1,4 @@
1
- type PiEvents = 'session_start' | 'turn_end' | 'agent_start' | 'agent_end' | 'session_shutdown';
1
+ type PiEvents = 'session_start' | 'turn_end' | 'agent_start' | 'agent_end' | 'agent_settled' | 'session_shutdown';
2
2
  interface PiLike {
3
3
  on: (event: PiEvents, handler: (event: any, ctx: any) => void | Promise<void>) => void;
4
4
  sendUserMessage: (content: string, options?: {