@north-light/crouter 0.3.231 → 0.3.233

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 (187) hide show
  1. package/dist/api/client.d.ts +3 -1
  2. package/dist/api/client.js +4 -0
  3. package/dist/api/dto/canvas.d.ts +10 -0
  4. package/dist/api/dto/common.d.ts +1 -1
  5. package/dist/api/dto/health.d.ts +2 -1
  6. package/dist/api/dto/lifecycle.d.ts +3 -4
  7. package/dist/api/dto/messages.d.ts +5 -4
  8. package/dist/api/dto/nodes.d.ts +2 -0
  9. package/dist/api/dto/profiles.d.ts +5 -0
  10. package/dist/api/routes.d.ts +1 -0
  11. package/dist/api/routes.js +1 -0
  12. package/dist/builtin-memory/00-runtime-base/00-authoring.md +8 -0
  13. package/dist/builtin-memory/00-runtime-base/01-escalation.md +1 -1
  14. package/dist/builtin-memory/01-spine/00-has-manager.md +1 -1
  15. package/dist/builtin-memory/02-turn-lifecycle/00-ending-a-turn.md +5 -0
  16. package/dist/builtin-memory/02-turn-lifecycle/02-resident.md +5 -3
  17. package/dist/builtin-memory/04-orchestration-kernel.md +2 -2
  18. package/dist/builtin-memory/insights/capture.md +3 -2
  19. package/dist/builtin-memory/internal/memory-loading.md +4 -3
  20. package/dist/builtin-pi-packages/pi-crtr-extensions/README.md +6 -1
  21. package/dist/clients/attach/render/diagram.js +13 -5
  22. package/dist/clients/attach/render/page-block.d.ts +0 -1
  23. package/dist/clients/attach/render/page-block.js +4 -57
  24. package/dist/clients/attach/viewer.js +570 -563
  25. package/dist/clients/inbox/__tests__/integration/inbox-controller.test.js +9 -0
  26. package/dist/clients/inbox/__tests__/integration/mount-panel.test.js +62 -1
  27. package/dist/clients/inbox/controller.d.ts +10 -0
  28. package/dist/clients/inbox/controller.js +56 -12
  29. package/dist/clients/inbox/tui/input.js +38 -10
  30. package/dist/clients/inbox/tui/page-body.d.ts +10 -0
  31. package/dist/clients/inbox/tui/page-body.js +66 -0
  32. package/dist/clients/inbox/tui/panel.js +6 -4
  33. package/dist/clients/inbox/tui/render.js +89 -17
  34. package/dist/clients/inbox/tui/types.d.ts +4 -4
  35. package/dist/commands/__tests__/human.test.js +18 -3
  36. package/dist/commands/__tests__/node-message.test.js +3 -3
  37. package/dist/commands/api-client.js +1 -7
  38. package/dist/commands/canvas-config.js +6 -14
  39. package/dist/commands/canvas-use.js +4 -6
  40. package/dist/commands/cron.js +16 -22
  41. package/dist/commands/human/prompts.d.ts +1 -1
  42. package/dist/commands/human/prompts.js +118 -112
  43. package/dist/commands/human/request.js +13 -14
  44. package/dist/commands/human/review.js +3 -4
  45. package/dist/commands/human/shared.d.ts +6 -0
  46. package/dist/commands/human/shared.js +43 -5
  47. package/dist/commands/human.js +1 -1
  48. package/dist/commands/memory/delete.js +4 -6
  49. package/dist/commands/memory/edit.js +0 -4
  50. package/dist/commands/memory/move.js +3 -5
  51. package/dist/commands/memory/shared.d.ts +1 -1
  52. package/dist/commands/memory/shared.js +11 -7
  53. package/dist/commands/memory/write.js +60 -29
  54. package/dist/commands/memory.js +1 -1
  55. package/dist/commands/node/bash.js +6 -9
  56. package/dist/commands/node/create.js +89 -22
  57. package/dist/commands/node/inspect.js +3 -3
  58. package/dist/commands/node/lifecycle.js +30 -27
  59. package/dist/commands/node/message.js +24 -45
  60. package/dist/commands/node/subscription.js +6 -15
  61. package/dist/commands/node/wait.js +2 -3
  62. package/dist/commands/node-lifecycle-revive.js +1 -12
  63. package/dist/commands/pkg/browse/actions.js +2 -3
  64. package/dist/commands/pkg/market-manage.js +2 -5
  65. package/dist/commands/pkg/plugin-manage.js +7 -8
  66. package/dist/commands/profile/default.js +5 -5
  67. package/dist/commands/profile/delete.js +1 -1
  68. package/dist/commands/profile/env.js +9 -15
  69. package/dist/commands/profile/kind.js +3 -7
  70. package/dist/commands/profile/meta.js +3 -5
  71. package/dist/commands/profile/new.js +0 -6
  72. package/dist/commands/profile/pause.js +4 -8
  73. package/dist/commands/profile/project.js +5 -9
  74. package/dist/commands/profile/rename.js +3 -7
  75. package/dist/commands/profile/show.js +3 -3
  76. package/dist/commands/profile.js +4 -3
  77. package/dist/commands/surface-tmux-spread.js +1 -3
  78. package/dist/commands/sys/config.js +3 -4
  79. package/dist/commands/sys/support/prepare.js +8 -4
  80. package/dist/commands/sys/support/submit.js +2 -3
  81. package/dist/commands/sys/sync-deps.js +1 -9
  82. package/dist/commands/sys/sync-project-guidance.js +1 -7
  83. package/dist/commands/sys/sync-skills.js +1 -11
  84. package/dist/core/__tests__/cron-node-sink-parked-root.test.d.ts +1 -0
  85. package/dist/core/__tests__/cron-node-sink-parked-root.test.js +147 -0
  86. package/dist/core/__tests__/history-inbox.test.js +11 -1
  87. package/dist/core/__tests__/human-deliver.test.js +2 -1
  88. package/dist/core/__tests__/integration/command-plugins.test.js +0 -1
  89. package/dist/core/__tests__/integration/deferred-no-wake.test.js +0 -1
  90. package/dist/core/__tests__/lifecycle.test.js +30 -2
  91. package/dist/core/__tests__/revive-parked-fresh.test.d.ts +1 -0
  92. package/dist/core/__tests__/revive-parked-fresh.test.js +109 -0
  93. package/dist/core/__tests__/seam/dormancy-release.test.js +32 -5
  94. package/dist/core/canvas/attention.d.ts +2 -0
  95. package/dist/core/canvas/attention.js +25 -18
  96. package/dist/core/canvas/extensions.d.ts +1 -1
  97. package/dist/core/canvas/extensions.js +7 -1
  98. package/dist/core/canvas/history.js +20 -2
  99. package/dist/core/canvas/types.d.ts +1 -1
  100. package/dist/core/command.js +33 -8
  101. package/dist/core/help.d.ts +28 -2
  102. package/dist/core/help.js +46 -10
  103. package/dist/core/human/__tests__/page-html-markdown.test.d.ts +1 -0
  104. package/dist/core/human/__tests__/page-html-markdown.test.js +48 -0
  105. package/dist/core/human/component-docs.js +4 -4
  106. package/dist/core/human/page-html-markdown.d.ts +8 -0
  107. package/dist/core/human/page-html-markdown.js +260 -0
  108. package/dist/core/memory/lint.d.ts +15 -0
  109. package/dist/core/memory/lint.js +150 -90
  110. package/dist/core/profiles/__tests__/fuzzy-match.test.d.ts +1 -0
  111. package/dist/core/profiles/__tests__/fuzzy-match.test.js +51 -0
  112. package/dist/core/profiles/fuzzy-match.d.ts +19 -0
  113. package/dist/core/profiles/fuzzy-match.js +92 -0
  114. package/dist/core/profiles/manifest.d.ts +14 -7
  115. package/dist/core/profiles/manifest.js +62 -12
  116. package/dist/core/profiles/select.d.ts +3 -1
  117. package/dist/core/profiles/select.js +5 -3
  118. package/dist/core/profiles/state-block.js +4 -3
  119. package/dist/core/runtime/boot-root.d.ts +3 -2
  120. package/dist/core/runtime/canvas-extensions.d.ts +7 -1
  121. package/dist/core/runtime/canvas-extensions.js +8 -1
  122. package/dist/core/runtime/lifecycle.d.ts +11 -2
  123. package/dist/core/runtime/lifecycle.js +15 -2
  124. package/dist/core/runtime/model-selection.d.ts +4 -0
  125. package/dist/core/runtime/model-selection.js +5 -0
  126. package/dist/core/runtime/nodes.js +5 -0
  127. package/dist/core/runtime/reopen.d.ts +6 -0
  128. package/dist/core/runtime/reopen.js +12 -1
  129. package/dist/core/runtime/revive.d.ts +6 -0
  130. package/dist/core/runtime/revive.js +22 -2
  131. package/dist/core/runtime/spawn.d.ts +5 -2
  132. package/dist/core/runtime/spawn.js +18 -32
  133. package/dist/core/runtime/structured-output.d.ts +6 -0
  134. package/dist/core/runtime/structured-output.js +6 -0
  135. package/dist/core/substrate/__tests__/surface-match-command.test.d.ts +1 -0
  136. package/dist/core/substrate/__tests__/surface-match-command.test.js +89 -0
  137. package/dist/core/substrate/__tests__/surface-match-pre-command.test.d.ts +1 -0
  138. package/dist/core/substrate/__tests__/surface-match-pre-command.test.js +92 -0
  139. package/dist/core/substrate/frontmatter-validation.js +1 -1
  140. package/dist/core/substrate/injected-store.d.ts +6 -0
  141. package/dist/core/substrate/injected-store.js +24 -0
  142. package/dist/core/substrate/on-read.d.ts +17 -1
  143. package/dist/core/substrate/on-read.js +38 -3
  144. package/dist/core/substrate/schema.d.ts +3 -3
  145. package/dist/core/substrate/schema.js +4 -4
  146. package/dist/core/substrate/surface-match.d.ts +19 -0
  147. package/dist/core/substrate/surface-match.js +224 -12
  148. package/dist/core/termrender/version.d.ts +1 -1
  149. package/dist/core/termrender/version.js +1 -1
  150. package/dist/core/user-settings.js +1 -1
  151. package/dist/daemon/api/__tests__/broker-settle-park.test.d.ts +1 -0
  152. package/dist/daemon/api/__tests__/broker-settle-park.test.js +102 -0
  153. package/dist/daemon/api/__tests__/canvas-snapshot-fields.test.d.ts +1 -0
  154. package/dist/daemon/api/__tests__/canvas-snapshot-fields.test.js +188 -0
  155. package/dist/daemon/api/__tests__/node-create-description.test.d.ts +1 -0
  156. package/dist/daemon/api/__tests__/node-create-description.test.js +83 -0
  157. package/dist/daemon/api/__tests__/profile-metadata-route.test.d.ts +1 -0
  158. package/dist/daemon/api/__tests__/profile-metadata-route.test.js +92 -0
  159. package/dist/daemon/api/__tests__/reopen-delivery.test.d.ts +1 -0
  160. package/dist/daemon/api/__tests__/reopen-delivery.test.js +173 -0
  161. package/dist/daemon/api/handlers/broker-ops.js +21 -0
  162. package/dist/daemon/api/handlers/canvas.js +10 -0
  163. package/dist/daemon/api/handlers/messages.js +25 -16
  164. package/dist/daemon/api/handlers/nodes.js +5 -0
  165. package/dist/daemon/api/handlers/profiles.js +22 -1
  166. package/dist/daemon/cron-run.js +19 -1
  167. package/dist/daemon/manage.d.ts +16 -1
  168. package/dist/daemon/manage.js +20 -1
  169. package/dist/daemon/park-pending.d.ts +13 -0
  170. package/dist/daemon/park-pending.js +42 -0
  171. package/dist/daemon/reconcilers/broker-supervision.d.ts +13 -0
  172. package/dist/daemon/reconcilers/broker-supervision.js +139 -21
  173. package/dist/daemon/reconcilers/live-obligation.d.ts +10 -4
  174. package/dist/daemon/reconcilers/live-obligation.js +7 -3
  175. package/dist/daemon/reconcilers/storage-maintenance.d.ts +0 -4
  176. package/dist/daemon/reconcilers/storage-maintenance.js +1 -33
  177. package/dist/pi-extensions/__tests__/pre-command-gate.test.d.ts +1 -0
  178. package/dist/pi-extensions/__tests__/pre-command-gate.test.js +220 -0
  179. package/dist/pi-extensions/canvas-doc-substrate.d.ts +14 -0
  180. package/dist/pi-extensions/canvas-doc-substrate.js +75 -2
  181. package/dist/pi-extensions/canvas-prompt-scrub.d.ts +13 -0
  182. package/dist/pi-extensions/canvas-prompt-scrub.js +53 -0
  183. package/dist/shared/generated-context.d.ts +7 -0
  184. package/dist/shared/generated-context.js +11 -0
  185. package/package.json +4 -4
  186. package/runtime.lock.json +2 -2
  187. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/strip-skills-docs.ts +0 -47
@@ -61,6 +61,15 @@ try {
61
61
  preview.activate();
62
62
  assert.match(preview.render().join('\n'), /chart — bar: day vs count/, 'a page uses the manifest-backed terminal projection');
63
63
  preview.close();
64
+ // The list preview shows what the page says, because `s` settles the selected
65
+ // ticket from this screen without ever opening it.
66
+ submitPage({ dir: ticketDir('preview-page'), sourceFile: ask, delivery: inboxDelivery });
67
+ const listing = controller({ cols: 100, rows: 40 });
68
+ const shown = listing.render().join('\n');
69
+ assert.match(shown, /Pick one to continue/, 'the preview carries the question body');
70
+ assert.match(shown, /Yes/, 'the preview carries the options the reader would pick from');
71
+ assert.doesNotMatch(shown, /\d+ components?\b/, 'the component count no longer stands in for the content');
72
+ listing.close();
64
73
  }
65
74
  finally {
66
75
  server.closeAllConnections();
@@ -44,6 +44,20 @@ const displayPanel = mountPanel({ manifest: manifest([]), cols: 80, rows: 24, on
44
44
  displayPanel.handleKey('q', key());
45
45
  assert.deepEqual(displayResponses, {});
46
46
  displayPanel.unmount();
47
+ // An HTML page has no slots to carry its content, so the document itself is
48
+ // projected into the panel — dismissing it with q means the reader saw it.
49
+ const html = manifest([], { dialect: 'html', title: 'Deploy sign-off', document: 'page.html' });
50
+ const htmlDoc = '<html><body><h1>Deploy sign-off</h1><p>Staging is green for <strong>6 hours</strong>.</p><ul><li>Smoke suite passed</li></ul></body></html>';
51
+ const htmlPanel = mountPanel({ manifest: html, document: htmlDoc, cols: 80, rows: 24 });
52
+ const htmlView = htmlPanel.render().join('\n');
53
+ assert.match(htmlView, /Staging is green for/);
54
+ assert.match(htmlView, /Smoke suite passed/);
55
+ assert.match(htmlView, /q.*dismiss/);
56
+ htmlPanel.unmount();
57
+ // An HTML page whose document never arrived says so rather than showing a bare title.
58
+ const blindPanel = mountPanel({ manifest: html, cols: 80, rows: 24 });
59
+ assert.match(blindPanel.render().join('\n'), /open in Northlight to read this/);
60
+ blindPanel.unmount();
47
61
  // A single-slot page opens straight in item-review.
48
62
  const lone = manifest([{ id: 'only', kind: 'options', step: 0, config: { label: 'Pick one', mode: 'single', options: [{ id: 'a', label: 'Alpha' }] } }]);
49
63
  const lonePanel = mountPanel({ manifest: lone, cols: 80, rows: 24 });
@@ -55,11 +69,22 @@ let unvalidatedResponses;
55
69
  const unvalidatedPanel = mountPanel({ manifest: unvalidated, cols: 80, rows: 24, productKinds: [{ kind: 'acme.widget' }], onComplete: (r) => { unvalidatedResponses = r; } });
56
70
  unvalidatedPanel.handleKey('c', key());
57
71
  assert.equal(unvalidatedPanel.getInputBuffer(), undefined);
72
+ // The item screen names the route out instead of dead-ending on a submit gate
73
+ // that cannot open.
74
+ assert.match(unvalidatedPanel.render().join('\n'), /the terminal cannot submit/);
75
+ assert.match(unvalidatedPanel.render().join('\n'), /q leaves it in your inbox/);
58
76
  unvalidatedPanel.handleKey('n', key());
59
- assert.match(unvalidatedPanel.render().join('\n'), /must be answered in Northlight/);
60
77
  unvalidatedPanel.handleKey('', enter);
61
78
  assert.equal(unvalidatedResponses, undefined);
62
79
  unvalidatedPanel.unmount();
80
+ const mixed = manifest([
81
+ { id: 'widget', kind: 'acme.widget', step: 0, config: {}, unvalidated: true },
82
+ { id: 'choice', kind: 'options', step: 0, config: { mode: 'single', options: [{ id: 'a', label: 'Alpha' }] } },
83
+ ]);
84
+ // The overview says the same before the reader answers anything else.
85
+ const mixedPanel = mountPanel({ manifest: mixed, cols: 80, rows: 24, productKinds: [{ kind: 'acme.widget' }] });
86
+ assert.match(mixedPanel.render().join('\n'), /This page must be answered in Northlight/);
87
+ mixedPanel.unmount();
63
88
  // Overview groups slots by step, without adding a progression gate.
64
89
  const stepped = manifest([
65
90
  { id: 'one', kind: 'options', step: 0, config: { mode: 'single', options: [{ id: 'a', label: 'Alpha' }] } },
@@ -71,4 +96,40 @@ assert.match(overview, /Test page/);
71
96
  assert.match(overview, /Step 1\/2/);
72
97
  assert.match(overview, /Step 2\/2/);
73
98
  steppedPanel.unmount();
99
+ // A page taller than the pane scrolls on overview and on the summary; before
100
+ // this, only item-review moved its window, so a long page was truncated with no
101
+ // key that could reach the rest of it.
102
+ const long = manifest(Array.from({ length: 30 }, (_v, i) => ({ id: `q${i}`, kind: 'options', step: 0, config: { label: `Question ${i}`, mode: 'single', options: [{ id: 'a', label: 'Alpha' }] } })));
103
+ const longPanel = mountPanel({ manifest: long, cols: 80, rows: 24 });
104
+ assert.doesNotMatch(longPanel.render().join('\n'), /Question 29/);
105
+ for (let i = 0; i < 5; i++)
106
+ longPanel.handleKey('d', key());
107
+ assert.match(longPanel.render().join('\n'), /Question 29/);
108
+ longPanel.handleKey('u', key());
109
+ assert.match(longPanel.render().join('\n'), /Question 2\b/);
110
+ // j/k keeps steering the selection, and the window follows it rather than
111
+ // letting the cursor wander off screen.
112
+ for (let i = 0; i < 29; i++)
113
+ longPanel.handleKey('j', key());
114
+ assert.match(longPanel.render().join('\n'), /Question 29/);
115
+ // The key hints hold the bottom row instead of scrolling away with the content,
116
+ // and carry the position badge that says there is more to scroll to.
117
+ const plain = (line) => line.replace(/\x1b\[[0-9;]*m/g, '');
118
+ const lastRow = () => plain(longPanel.render().at(-1) ?? '');
119
+ for (let i = 0; i < 20; i++)
120
+ longPanel.handleKey('u', key());
121
+ assert.match(lastRow(), /j\/k navigate/);
122
+ assert.match(lastRow(), /▾ 1–\d+\/\d+/);
123
+ assert.doesNotMatch(lastRow(), /▴/);
124
+ for (let i = 0; i < 20; i++)
125
+ longPanel.handleKey('d', key());
126
+ assert.match(lastRow(), /j\/k navigate/);
127
+ assert.match(lastRow(), /▴/);
128
+ assert.doesNotMatch(lastRow(), /▾/);
129
+ longPanel.unmount();
130
+ // A page shorter than the pane keeps its hints and shows no position badge.
131
+ const shortPanel = mountPanel({ manifest: manifest([{ id: 'only', kind: 'options', step: 0, config: { label: 'Pick one', mode: 'single', options: [{ id: 'a', label: 'Alpha' }] } }]), cols: 80, rows: 24 });
132
+ assert.match(plain(shortPanel.render().at(-1) ?? ''), /q close/);
133
+ assert.doesNotMatch(shortPanel.render().join('\n'), /[▴▾]/);
134
+ shortPanel.unmount();
74
135
  console.log('OK');
@@ -39,6 +39,9 @@ export declare class InboxController {
39
39
  private heartbeat;
40
40
  private started;
41
41
  private closed;
42
+ /** The selected page's projected body, kept across frames because building it
43
+ * reads and evaluates the page document. Keyed by what changes it. */
44
+ private preview;
42
45
  constructor(options: InboxControllerOptions);
43
46
  snapshot(): {
44
47
  items: TicketSummary[];
@@ -74,6 +77,13 @@ export declare class InboxController {
74
77
  private focusAvailable;
75
78
  /** Ask the host to bring the selected ticket's node viewer into view. */
76
79
  private focusSelectedSource;
80
+ /** What the selected page actually says, projected once per document and
81
+ * kept until the document, the selection, or the pane width changes — the
82
+ * projection reads the ticket from disk and evaluates it. */
83
+ private pagePreviewBody;
84
+ /** The read-only preview beside the list. It shows the page's own content,
85
+ * because `s` settles the selected ticket from here without ever opening
86
+ * it — a reader must not answer something they were never shown. */
77
87
  private passiveDetailLines;
78
88
  private withStatus;
79
89
  private markDirty;
@@ -1,4 +1,4 @@
1
- import { watch } from 'node:fs';
1
+ import { statSync, watch } from 'node:fs';
2
2
  import { renderMarkdown } from '../../core/termrender/termrender.js';
3
3
  import { BOLD, CYAN, DIM, GRAY, RESET, YELLOW, clipLine } from './tui/ansi.js';
4
4
  import { buildInboxLines, recommendedLabel } from './tui.js';
@@ -8,7 +8,10 @@ import { resolvePageComponents } from '../../core/config.js';
8
8
  import { watchInboxActivity } from '../../core/human/root.js';
9
9
  import { claimTicket, heartbeatClaim, releaseClaim } from '../../core/human/claim.js';
10
10
  import { readPageTicket, readTicketResult } from '../../core/human/tickets.js';
11
- import { clearProgress } from '../../core/human/convention.js';
11
+ import { clearProgress, pagePath } from '../../core/human/convention.js';
12
+ import { projectPageDisplayMarkdown } from '../../core/human/page-markdown.js';
13
+ import { projectHtmlDocumentMarkdown } from '../../core/human/page-html-markdown.js';
14
+ import { pageBodyLines } from './tui/page-body.js';
12
15
  import { PageAdapter } from './page-adapter.js';
13
16
  import { ReviewAdapter } from './review-adapter.js';
14
17
  import { editBufferInEditor } from './review/roundtrip.js';
@@ -36,6 +39,9 @@ export class InboxController {
36
39
  heartbeat;
37
40
  started = false;
38
41
  closed = false;
42
+ /** The selected page's projected body, kept across frames because building it
43
+ * reads and evaluates the page document. Keyed by what changes it. */
44
+ preview;
39
45
  constructor(options) {
40
46
  this.options = options;
41
47
  this.host = options.host;
@@ -400,7 +406,7 @@ export class InboxController {
400
406
  return this.adapter.render();
401
407
  const selected = this.items[this.selectedIndex];
402
408
  if (selected === undefined)
403
- return this.passiveDetailLines(width).slice(0, rows);
409
+ return this.passiveDetailLines(width, rows);
404
410
  const focusHint = this.focusAvailable(selected.dir) ? ` ${DIM}g${RESET} chat` : '';
405
411
  const sendHint = selected.kind === 'page' && selected.fastAction !== undefined
406
412
  ? ` ${DIM}s${RESET} ${selected.fastAction.kind === 'acknowledge' ? 'acknowledge' : 'send recommended'}`
@@ -408,7 +414,12 @@ export class InboxController {
408
414
  const footer = selected.kind === 'page'
409
415
  ? [` ${DIM}Enter${RESET} opens the full page ${DIM}j/k${RESET} select${sendHint} ${DIM}x${RESET} cancel`, ` ${DIM}Page:${RESET} c comment u/d scroll${focusHint} ${DIM}q${RESET} close`]
410
416
  : [` ${DIM}Enter${RESET} opens the full review ${DIM}j/k${RESET} select ${DIM}x${RESET} cancel`, ` ${focusHint} ${DIM}q${RESET} close`];
411
- const preview = this.passiveDetailLines(width).slice(0, Math.max(0, rows - footer.length));
417
+ // The hints hold the bottom of the pane rather than trailing the preview,
418
+ // so they stay in one place as the preview grows and shrinks.
419
+ const room = Math.max(0, rows - footer.length);
420
+ const preview = this.passiveDetailLines(width, room);
421
+ while (preview.length < room)
422
+ preview.push('');
412
423
  return [...preview, ...footer.map((line) => clipLine(line, width))];
413
424
  }
414
425
  focusAvailable(_dir) {
@@ -433,20 +444,53 @@ export class InboxController {
433
444
  this.focusingSource = false;
434
445
  }
435
446
  }
436
- passiveDetailLines(width) {
447
+ /** What the selected page actually says, projected once per document and
448
+ * kept until the document, the selection, or the pane width changes — the
449
+ * projection reads the ticket from disk and evaluates it. */
450
+ pagePreviewBody(item, width) {
451
+ let stamp;
452
+ try {
453
+ stamp = String(statSync(pagePath(item.dir, item.dialect)).mtimeMs);
454
+ }
455
+ catch {
456
+ stamp = 'missing';
457
+ }
458
+ const cached = this.preview;
459
+ if (cached !== undefined && cached.dir === item.dir && cached.width === width && cached.stamp === stamp)
460
+ return cached.lines;
461
+ let lines = [];
462
+ try {
463
+ const ticket = readPageTicket(item.dir);
464
+ const display = ticket.manifest.dialect === 'html'
465
+ ? projectHtmlDocumentMarkdown(ticket.document)
466
+ : projectPageDisplayMarkdown(ticket.document, ticket.manifest);
467
+ lines = pageBodyLines(ticket.manifest, display, Math.min(Math.max(20, width - 4), 100), Math.max(20, width - 4));
468
+ }
469
+ catch { /* a half-written or removed ticket previews as its title alone */ }
470
+ this.preview = { dir: item.dir, width, stamp, lines };
471
+ return lines;
472
+ }
473
+ /** The read-only preview beside the list. It shows the page's own content,
474
+ * because `s` settles the selected ticket from here without ever opening
475
+ * it — a reader must not answer something they were never shown. */
476
+ passiveDetailLines(width, rows) {
437
477
  const selected = this.items[this.selectedIndex];
438
478
  if (selected === undefined)
439
479
  return [` ${DIM}Select a pending interaction.${RESET}`];
440
480
  const lines = [` ${BOLD}${CYAN}${selected.title}${RESET}`, ''];
441
481
  for (const rendered of renderMarkdown(selected.subtitle, Math.max(1, width - 2)))
442
482
  lines.push(` ${rendered}`);
443
- const count = selected.kind === 'page'
444
- ? `${selected.slotKinds.length} component${selected.slotKinds.length === 1 ? '' : 's'}`
445
- : '1 file to review';
446
- lines.push('', ` ${DIM}${count}${RESET}`);
447
- if (selected.kind === 'page' && selected.dialect === 'html')
448
- lines.push(` ${DIM}HTML pages render in a browser surface${RESET}`);
449
- return lines.map((line) => clipLine(line, width));
483
+ const body = selected.kind === 'page' ? this.pagePreviewBody(selected, width) : [`${DIM}1 file to review${RESET}`];
484
+ if (body.length > 0) {
485
+ lines.push('');
486
+ const room = Math.max(0, rows - lines.length);
487
+ const shown = body.length <= room ? body : body.slice(0, Math.max(0, room - 1));
488
+ for (const line of shown)
489
+ lines.push(` ${line}`);
490
+ if (shown.length < body.length)
491
+ lines.push(` ${DIM}… +${body.length - shown.length} more lines${RESET}`);
492
+ }
493
+ return lines.map((line) => clipLine(line, width)).slice(0, Math.max(1, rows));
450
494
  }
451
495
  withStatus(lines) {
452
496
  const status = this.cancelConfirmation === undefined
@@ -20,11 +20,36 @@ export function handleKeypress(input, key, state, render, exit) {
20
20
  else
21
21
  handleFinal(input, key, state, render, exit);
22
22
  }
23
+ /** The one vertical-scroll gesture, shared by all three page screens: a long
24
+ * page must be readable wherever its content sits, and every screen draws its
25
+ * content into the same windowed rectangle. Each screen clamps the offset
26
+ * against its own rendered length at draw time, so this only ever moves it. */
27
+ function scrollKey(input, key, state) {
28
+ if (input === 'd' || key.pageDown || (key.ctrl && (input === 'd' || input === 'e'))) {
29
+ state.scrollOffset += input === 'e' ? 1 : 10;
30
+ state.followSelection = false;
31
+ return true;
32
+ }
33
+ if (input === 'u' || key.pageUp || (key.ctrl && (input === 'u' || input === 'y'))) {
34
+ state.scrollOffset = Math.max(0, state.scrollOffset - (input === 'y' ? 1 : 10));
35
+ state.followSelection = false;
36
+ return true;
37
+ }
38
+ return false;
39
+ }
23
40
  function handleOverview(input, key, state, render, exit) {
24
- if (input === 'j' || key.downArrow)
41
+ if (scrollKey(input, key, state)) {
42
+ render();
43
+ return;
44
+ }
45
+ if (input === 'j' || key.downArrow) {
25
46
  state.currentIndex = Math.min(state.currentIndex + 1, Math.max(0, state.slots.length - 1));
26
- else if (input === 'k' || key.upArrow)
47
+ state.followSelection = true;
48
+ }
49
+ else if (input === 'k' || key.upArrow) {
27
50
  state.currentIndex = Math.max(state.currentIndex - 1, 0);
51
+ state.followSelection = true;
52
+ }
28
53
  else if ((key.return || input === ' ') && state.slots.length > 0) {
29
54
  state.phase = 'item-review';
30
55
  resetItem(state);
@@ -59,13 +84,7 @@ function handleItemReview(input, key, state, render, exit) {
59
84
  exit(false);
60
85
  return;
61
86
  }
62
- if (input === 'd' || key.pageDown || (key.ctrl && (input === 'd' || input === 'e'))) {
63
- state.scrollOffset += input === 'e' ? 1 : 10;
64
- render();
65
- return;
66
- }
67
- if (input === 'u' || key.pageUp || (key.ctrl && (input === 'u' || input === 'y'))) {
68
- state.scrollOffset = Math.max(0, state.scrollOffset - (input === 'y' ? 1 : 10));
87
+ if (scrollKey(input, key, state)) {
69
88
  render();
70
89
  return;
71
90
  }
@@ -175,19 +194,28 @@ function handleFinal(input, key, state, render, exit) {
175
194
  render();
176
195
  return;
177
196
  }
197
+ if (scrollKey(input, key, state)) {
198
+ render();
199
+ return;
200
+ }
178
201
  if (key.return && (pageComplete(state) || !state.manifest.slots.some(isResponseBearingSlot)))
179
202
  exit(true);
180
203
  }
181
- function resetItem(state) { state.selectedAction = 0; state.scrollOffset = 0; state.hscrollOffset = 0; state.hscrollMax = 0; }
204
+ function resetItem(state) { state.selectedAction = 0; state.scrollOffset = 0; state.followSelection = true; state.hscrollOffset = 0; state.hscrollMax = 0; }
182
205
  function advanceItem(state, delta) {
183
206
  const next = state.currentIndex + delta;
207
+ // Every phase reads the one scrollOffset, so entering a screen starts it at
208
+ // the top rather than inheriting how far the previous screen was scrolled.
184
209
  if (next < 0) {
185
210
  state.phase = state.slots.length > 1 ? 'overview' : 'item-review';
211
+ state.scrollOffset = 0;
212
+ state.followSelection = true;
186
213
  return;
187
214
  }
188
215
  if (next >= state.slots.length) {
189
216
  state.phase = 'final';
190
217
  state.scrollOffset = 0;
218
+ state.followSelection = true;
191
219
  return;
192
220
  }
193
221
  state.currentIndex = next;
@@ -0,0 +1,10 @@
1
+ import type { PageManifest } from '../../../core/human/page-schema.js';
2
+ /** What a page says, as terminal rows: the authored prose, then each question
3
+ * with its own body and the choices it offers. One projection serves every
4
+ * read-only view of a page — the inline transcript block and the inbox list's
5
+ * preview — so a reader is never asked to settle a page from a summary that
6
+ * showed them less than the page itself does.
7
+ *
8
+ * `maxW` is the prose measure, `paneW` the full rectangle a wide block (a
9
+ * table, a code fence) may use. Callers window or fold the result. */
10
+ export declare function pageBodyLines(manifest: PageManifest, displayMarkdown: string, maxW: number, paneW: number): string[];
@@ -0,0 +1,66 @@
1
+ import { renderMarkdownBlockAwareLines } from '../../../core/termrender/termrender.js';
2
+ import { describeSlot, tableMarkdown, terminalAnswerable } from './slots.js';
3
+ import { BOLD, DIM, RESET, sanitize, singleLine, truncate, wrap } from './ansi.js';
4
+ /** What a page says, as terminal rows: the authored prose, then each question
5
+ * with its own body and the choices it offers. One projection serves every
6
+ * read-only view of a page — the inline transcript block and the inbox list's
7
+ * preview — so a reader is never asked to settle a page from a summary that
8
+ * showed them less than the page itself does.
9
+ *
10
+ * `maxW` is the prose measure, `paneW` the full rectangle a wide block (a
11
+ * table, a code fence) may use. Callers window or fold the result. */
12
+ export function pageBodyLines(manifest, displayMarkdown, maxW, paneW) {
13
+ const md = (text) => renderMarkdownBlockAwareLines(text, maxW, paneW);
14
+ // An HTML page carries no slots, so its projected document is the whole of
15
+ // what a terminal reader gets — a browser renders it, this one reads it.
16
+ if (manifest.dialect === 'html')
17
+ return displayMarkdown === '' ? [`${DIM}HTML page — open in Northlight to read this${RESET}`] : md(displayMarkdown);
18
+ const lines = displayMarkdown === '' ? [] : md(displayMarkdown);
19
+ let priorStep = -1;
20
+ for (const slot of manifest.slots) {
21
+ if (manifest.steps > 1 && slot.step !== priorStep) {
22
+ if (lines.length > 0)
23
+ lines.push('');
24
+ lines.push(`${BOLD}Step ${slot.step + 1}/${manifest.steps}${RESET}`);
25
+ priorStep = slot.step;
26
+ }
27
+ if (lines.length > 0)
28
+ lines.push('');
29
+ lines.push(`${BOLD}${truncate(sanitize(singleLine(describeSlot(slot))), maxW)}${RESET}`);
30
+ const body = slot.config.body;
31
+ if (typeof body === 'string' && body !== '')
32
+ lines.push(...md(body));
33
+ if (!terminalAnswerable(slot)) {
34
+ lines.push(`${DIM}${sanitize(slot.kind)} — open in Northlight to answer this${RESET}`);
35
+ continue;
36
+ }
37
+ if (slot.kind === 'text') {
38
+ lines.push(...md(slot.config.initialText));
39
+ }
40
+ else if (slot.kind === 'table') {
41
+ lines.push(...md(tableMarkdown(slot.config)));
42
+ }
43
+ else if (slot.kind === 'cards') {
44
+ for (const card of slot.config.cards ?? []) {
45
+ lines.push(` • ${truncate(sanitize(singleLine(card.subtitle === undefined ? card.title : `${card.title} — ${card.subtitle}`)), Math.max(10, maxW - 4))}`);
46
+ if (card.body !== undefined)
47
+ for (const line of wrap(sanitize(card.body), Math.max(10, maxW - 6)))
48
+ lines.push(` ${DIM}${line}${RESET}`);
49
+ }
50
+ }
51
+ else if (slot.kind === 'options') {
52
+ for (const option of slot.config.options) {
53
+ lines.push(` • ${truncate(sanitize(singleLine(option.label)), Math.max(10, maxW - 4))}`);
54
+ if (option.description !== undefined)
55
+ for (const line of wrap(sanitize(option.description), Math.max(10, maxW - 6)))
56
+ lines.push(` ${DIM}${line}${RESET}`);
57
+ }
58
+ }
59
+ else if (slot.kind === 'chart') {
60
+ const config = slot.config;
61
+ const title = config.title ?? `${config.x ?? ''} vs ${(config.y ?? []).map((y) => y.label ?? y.field).join(', ')}`;
62
+ lines.push(`${DIM}chart — ${sanitize(config.kind ?? 'chart')}: ${sanitize(title)}${RESET}`);
63
+ }
64
+ }
65
+ return lines;
66
+ }
@@ -1,15 +1,17 @@
1
1
  import { projectPageDisplayMarkdown } from '../../../core/human/page-markdown.js';
2
+ import { projectHtmlDocumentMarkdown } from '../../../core/human/page-html-markdown.js';
2
3
  import { handleKeypress } from './input.js';
3
4
  import { clampItemReviewScroll, renderFinal, renderItemReview, renderOverview } from './render.js';
4
5
  import { pageComplete, slotAnswered } from './slots.js';
5
6
  /** Same projection the inline chat block reads (page-block.ts) — the authored
6
- * prose outside slot tags. A malformed or unreadable document just yields no
7
+ * prose outside slot tags, or the whole document for an HTML page, which has
8
+ * no slots to carry it. A malformed or unreadable document just yields no
7
9
  * display content here, same as the inline block's own reload() fallback. */
8
10
  function displayMarkdownOf(manifest, document) {
9
- if (manifest.dialect !== 'jsx' || document === undefined)
11
+ if (document === undefined)
10
12
  return '';
11
13
  try {
12
- return projectPageDisplayMarkdown(document, manifest);
14
+ return manifest.dialect === 'html' ? projectHtmlDocumentMarkdown(document) : projectPageDisplayMarkdown(document, manifest);
13
15
  }
14
16
  catch {
15
17
  return '';
@@ -26,7 +28,7 @@ function buildInitialState(manifest, document, initialResponses, productKinds, e
26
28
  // when there is no display prose to show first; otherwise that prose would
27
29
  // never be seen.
28
30
  const phase = manifest.slots.length === 1 && displayMarkdown === '' ? 'item-review' : 'overview';
29
- const state = { phase, currentIndex: 0, manifest, displayMarkdown, slots: manifest.slots, responses, productKinds, inputMode: null, selectedAction: 0, bodyMode: 'question', scrollOffset: 0, bodyScrollOffsets: { question: 0 }, hscrollOffset: 0, hscrollMax: 0, wrapWidth, editorAvailable };
31
+ const state = { phase, currentIndex: 0, manifest, displayMarkdown, slots: manifest.slots, responses, productKinds, inputMode: null, selectedAction: 0, scrollOffset: 0, followSelection: true, hscrollOffset: 0, hscrollMax: 0, wrapWidth, editorAvailable };
30
32
  const first = manifest.slots.findIndex((slot) => slot.id !== undefined && !slotAnswered(state, slot));
31
33
  if (first >= 0)
32
34
  state.currentIndex = first;
@@ -39,11 +39,17 @@ export function renderOverview(state, cols, rows) {
39
39
  for (const line of mdLines(state.displayMarkdown, maxW, paneW))
40
40
  lines.push(` ${line}`);
41
41
  }
42
+ else if (state.manifest.dialect === 'html') {
43
+ // An HTML page has no slots, so an unreadable document would otherwise
44
+ // leave the reader dismissing a title with nothing under it.
45
+ lines.push('', ` ${DIM}HTML page — open in Northlight to read this${RESET}`);
46
+ }
42
47
  const hasBody = state.slots.length > 0;
43
48
  if (hasBody)
44
49
  lines.push(` ${DIM}${hline(maxW)}${RESET}`, '');
45
50
  const answer = pageAnswer(state);
46
51
  let priorStep = -1;
52
+ let cursorLine;
47
53
  for (let i = 0; i < state.slots.length; i++) {
48
54
  const slot = state.slots[i];
49
55
  if (state.manifest.steps > 1 && slot.step !== priorStep) {
@@ -55,16 +61,26 @@ export function renderOverview(state, cols, rows) {
55
61
  const response = slot.id === undefined ? undefined : state.responses.get(slot.id);
56
62
  const icon = isResponseBearingSlot(slot) ? (slotAnswered(state, slot) ? `${GREEN}✓${RESET}` : `${DIM}○${RESET}`) : `${DIM}·${RESET}`;
57
63
  const cursor = i === state.currentIndex ? `${CYAN}▸${RESET} ` : ' ';
64
+ if (i === state.currentIndex)
65
+ cursorLine = lines.length;
58
66
  lines.push(` ${cursor}${icon} ${truncate(singleLine(describeSlot(slot)), Math.max(10, cols - 16))}`);
59
67
  if (response !== undefined)
60
68
  lines.push(` ${DIM}${truncate(singleLine(answerSummary(answerSlot(answer, slot))), Math.max(10, cols - 10))}${RESET}`);
61
69
  }
70
+ // A page whose question the terminal cannot author can never be submitted
71
+ // here, so it says so on the way in rather than letting the reader answer
72
+ // everything else and meet a submit gate that never opens.
73
+ if (state.manifest.slots.some((slot) => isResponseBearingSlot(slot) && !terminalAnswerable(slot))) {
74
+ lines.push('');
75
+ for (const line of wrap('This page must be answered in Northlight. Leaving it here keeps it in your inbox; x on the list cancels it.', maxW - 2))
76
+ lines.push(` ${YELLOW}${line}${RESET}`);
77
+ }
62
78
  if (hasBody)
63
79
  lines.push('');
64
80
  lines.push(` ${DIM}${hline(maxW)}${RESET}`);
65
81
  const display = !state.manifest.slots.some(isResponseBearingSlot);
66
- lines.push(display ? ` ${DIM}q${RESET} dismiss` : ` ${DIM}enter${RESET} review ${DIM}j/k${RESET} navigate ${DIM}q${RESET} close`);
67
- return centeredWindow(lines, state, cols, rows, maxW + 2);
82
+ const footer = display ? ` ${DIM}u/d${RESET} scroll ${DIM}q${RESET} dismiss` : ` ${DIM}enter${RESET} review ${DIM}j/k${RESET} navigate ${DIM}u/d${RESET} scroll ${DIM}q${RESET} close`;
83
+ return centeredWindow(lines, footer, state, cols, rows, maxW + 2, state.followSelection ? cursorLine : undefined);
68
84
  }
69
85
  /** Wrap an input buffer and paint the cursor the way a terminal does: the
70
86
  * character under it is drawn in reverse video, occupying its own cell rather
@@ -183,8 +199,12 @@ function itemLines(state, cols) {
183
199
  for (const line of mdLines(body, maxW, paneW))
184
200
  lines.push(` ${line}`);
185
201
  }
186
- if (!terminalAnswerable(slot))
187
- lines.push('', ` ${DIM}${sanitize(slot.kind)} — cannot be answered in the terminal; open in Northlight${RESET}`);
202
+ if (!terminalAnswerable(slot)) {
203
+ lines.push('');
204
+ for (const line of wrap(`${sanitize(slot.kind)} — must be answered in Northlight; the terminal cannot submit this page`, Math.max(10, maxW - 2)))
205
+ lines.push(` ${YELLOW}${line}${RESET}`);
206
+ lines.push(` ${DIM}q leaves it in your inbox; x on the list cancels it${RESET}`);
207
+ }
188
208
  else if (slot.kind === 'chart') {
189
209
  const c = slot.config;
190
210
  const title = c.title ?? `${c.x} vs ${(c.y ?? []).map((y) => y.label ?? y.field).join(', ')}`;
@@ -206,12 +226,13 @@ function itemLines(state, cols) {
206
226
  lines.push(` ${line}`);
207
227
  }
208
228
  lines.push('', ` ${DIM}${hline(maxW)}${RESET}`);
229
+ let footer;
209
230
  if (state.inputMode !== null) {
210
231
  const label = state.inputMode.kind === 'comment' ? `Comment on ${commentLabel(slot, state.inputMode.anchor)}` : state.inputMode.kind === 'text' ? 'Edit text' : 'Other response';
211
232
  lines.push(` ${BOLD}${label}${RESET}`);
212
233
  for (const line of renderInputBuffer(state.inputMode.buffer, state.inputMode.cursor, Math.max(10, maxW - 4)))
213
234
  lines.push(` ${line}`);
214
- lines.push(` ${DIM}enter save esc cancel${state.editorAvailable ? ' ^o editor' : ''}${RESET}`);
235
+ footer = ` ${DIM}enter save esc cancel${state.editorAvailable ? ' ^o editor' : ''}${RESET}`;
215
236
  }
216
237
  else {
217
238
  const actions = slotActions(slot);
@@ -234,10 +255,10 @@ function itemLines(state, cols) {
234
255
  lines.push(` ${DIM}${line}${RESET}`);
235
256
  if (state.hint)
236
257
  lines.push(` ${YELLOW}${sanitize(state.hint)}${RESET}`);
237
- lines.push(` ${DIM}n/p next/previous j/k actions${commentAnchors(slot).length > 0 ? ' c comment' : ''} u/d scroll h/l pan q close${RESET}`);
258
+ footer = ` ${DIM}n/p next/previous j/k actions${commentAnchors(slot).length > 0 ? ' c comment' : ''} u/d scroll h/l pan q close${RESET}`;
238
259
  }
239
260
  const maxLine = Math.max(0, ...lines.map(visibleWidth));
240
- return { lines, maxW, maxH: Math.max(0, maxLine - Math.max(1, cols)) };
261
+ return { lines, footer, maxW, maxH: Math.max(0, maxLine - Math.max(1, cols)) };
241
262
  }
242
263
  export function clampItemReviewScroll(state, cols, rows) {
243
264
  if (state.slots[state.currentIndex] === undefined) {
@@ -249,19 +270,25 @@ export function clampItemReviewScroll(state, cols, rows) {
249
270
  const layout = itemLines(state, cols);
250
271
  state.hscrollMax = layout.maxH;
251
272
  state.hscrollOffset = Math.max(0, Math.min(state.hscrollOffset, state.hscrollMax));
252
- state.scrollOffset = Math.max(0, Math.min(state.scrollOffset, Math.max(0, layout.lines.length - rows)));
273
+ state.scrollOffset = Math.max(0, Math.min(state.scrollOffset, Math.max(0, layout.lines.length - bodyRows(rows))));
253
274
  }
254
275
  export function panItemReview(state, direction) {
255
276
  state.hscrollOffset = Math.max(0, Math.min(state.hscrollMax, state.hscrollOffset + direction * PAN_STEP));
256
277
  }
257
278
  export function renderItemReview(state, cols, rows) {
258
279
  const layout = itemLines(state, cols);
280
+ const body = bodyRows(rows);
259
281
  const start = state.scrollOffset;
260
- const sliced = layout.lines.slice(start, start + rows);
261
- while (sliced.length < rows)
282
+ const sliced = layout.lines.slice(start, start + body);
283
+ while (sliced.length < body)
262
284
  sliced.push('');
263
285
  const panned = state.hscrollOffset > 0 ? sliced.map((line) => panLine(line, state.hscrollOffset, cols)) : sliced;
264
- return centerHorizontal(panned.map((line) => clipLine(line, cols)), cols, Math.min(cols, layout.maxW + 2));
286
+ const width = Math.min(cols, layout.maxW + 2);
287
+ // The key hints never pan: a horizontal pan moves the content the user is
288
+ // reading, not the controls they steer it with.
289
+ if (body < rows)
290
+ panned.push(pinnedFooter(layout.footer, scrollBadge(start, body, layout.lines.length), layout.maxW + 2));
291
+ return centerHorizontal(panned.map((line) => clipLine(line, cols)), cols, width);
265
292
  }
266
293
  export function renderFinal(state, cols, rows) {
267
294
  const maxW = Math.min(Math.max(20, cols - 4), 120);
@@ -283,14 +310,59 @@ export function renderFinal(state, cols, rows) {
283
310
  lines.push(` ${DIM}${slot.id} must be answered in Northlight${RESET}`);
284
311
  }
285
312
  }
286
- lines.push('', ` ${DIM}${hline(maxW)}${RESET}`, complete ? ` ${DIM}enter${RESET} submit ${DIM}q${RESET} back` : ` ${DIM}q${RESET} back`);
287
- return centeredWindow(lines, state, cols, rows, maxW + 2);
313
+ lines.push('', ` ${DIM}${hline(maxW)}${RESET}`);
314
+ const footer = complete ? ` ${DIM}enter${RESET} submit ${DIM}u/d${RESET} scroll ${DIM}q${RESET} back` : ` ${DIM}u/d${RESET} scroll ${DIM}q${RESET} back`;
315
+ return centeredWindow(lines, footer, state, cols, rows, maxW + 2);
316
+ }
317
+ /** Rows left for content once the pinned key-hint row is reserved. Below two
318
+ * rows there is nothing to pin a footer against, so the whole pane is body. */
319
+ function bodyRows(rows) { return rows >= 2 ? rows - 1 : rows; }
320
+ /** How much of the content this window is showing, and which directions still
321
+ * have more. Without it a long page looks like a short one: the scroll keys
322
+ * exist but nothing says there is anything to scroll to. The arrow cells are
323
+ * held even when empty so the badge does not change width as it moves. */
324
+ function scrollBadge(offset, visibleRows, total) {
325
+ if (visibleRows <= 0 || total <= visibleRows)
326
+ return { marks: '', full: '' };
327
+ const end = Math.min(total, offset + visibleRows);
328
+ const marks = `${offset > 0 ? '▴' : ' '}${end < total ? '▾' : ' '}`;
329
+ return { marks: `${DIM}${marks}${RESET}`, full: `${DIM}${marks} ${offset + 1}–${end}/${total}${RESET}` };
288
330
  }
289
- function centeredWindow(lines, state, cols, rows, width) {
290
- const max = Math.max(0, lines.length - rows);
331
+ /** The key hints with the scroll badge trailing at the right edge of the
332
+ * column. A row too narrow for both drops the counts and keeps the arrows,
333
+ * and one too narrow even for those moves them ahead of the hints — the keys
334
+ * themselves are never the thing that gets clipped away. */
335
+ function pinnedFooter(footer, badge, width) {
336
+ if (badge.full === '')
337
+ return footer;
338
+ const room = (candidate) => width - visibleWidth(footer) - visibleWidth(candidate) - 2;
339
+ for (const candidate of [badge.full, badge.marks])
340
+ if (room(candidate) >= 2)
341
+ return `${footer}${' '.repeat(room(candidate))}${candidate}`;
342
+ return ` ${badge.marks} ${footer.trimStart()}`;
343
+ }
344
+ /** Window `lines` to the pane, clamping the shared scroll offset to what this
345
+ * screen actually rendered, and pinning `footer` to the bottom row. The key
346
+ * hints are durable controls, so they hold the same row instead of scrolling
347
+ * out of reach with the content they describe. `focusLine`, when given, is a
348
+ * row the user is steering with j/k, so the window is nudged to keep it on
349
+ * screen — otherwise a long page lets the selection wander out of view while
350
+ * it still moves. It is passed only while the window is following the
351
+ * selection, so a window the user scrolled themselves is never dragged back. */
352
+ function centeredWindow(lines, footer, state, cols, rows, width, focusLine) {
353
+ const body = bodyRows(rows);
354
+ const max = Math.max(0, lines.length - body);
291
355
  state.scrollOffset = Math.max(0, Math.min(state.scrollOffset, max));
292
- const out = lines.slice(state.scrollOffset, state.scrollOffset + rows);
293
- while (out.length < rows)
356
+ if (focusLine !== undefined && body > 0) {
357
+ if (focusLine < state.scrollOffset)
358
+ state.scrollOffset = focusLine;
359
+ else if (focusLine >= state.scrollOffset + body)
360
+ state.scrollOffset = Math.min(max, focusLine - body + 1);
361
+ }
362
+ const out = lines.slice(state.scrollOffset, state.scrollOffset + body);
363
+ while (out.length < body)
294
364
  out.push('');
365
+ if (body < rows)
366
+ out.push(pinnedFooter(footer, scrollBadge(state.scrollOffset, body, lines.length), width));
295
367
  return centerHorizontal(out.map((line) => clipLine(line, cols)), cols, Math.min(cols, width));
296
368
  }
@@ -29,11 +29,11 @@ export interface TuiState {
29
29
  productKinds: ProductPageComponents;
30
30
  inputMode: InputMode;
31
31
  selectedAction: number;
32
- bodyMode: 'question';
33
32
  scrollOffset: number;
34
- bodyScrollOffsets: {
35
- question: number;
36
- };
33
+ /** Whether the overview window is following the j/k selection. Set by moving
34
+ * the selection, cleared the moment the user scrolls the window themselves —
35
+ * otherwise the follow would drag the window back on every manual scroll. */
36
+ followSelection: boolean;
37
37
  hscrollOffset: number;
38
38
  hscrollMax: number;
39
39
  wrapWidth: number;