@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.
- package/dist/api/client.d.ts +3 -1
- package/dist/api/client.js +4 -0
- package/dist/api/dto/canvas.d.ts +10 -0
- package/dist/api/dto/common.d.ts +1 -1
- package/dist/api/dto/health.d.ts +2 -1
- package/dist/api/dto/lifecycle.d.ts +3 -4
- package/dist/api/dto/messages.d.ts +5 -4
- package/dist/api/dto/nodes.d.ts +2 -0
- package/dist/api/dto/profiles.d.ts +5 -0
- package/dist/api/routes.d.ts +1 -0
- package/dist/api/routes.js +1 -0
- package/dist/builtin-memory/00-runtime-base/00-authoring.md +8 -0
- package/dist/builtin-memory/00-runtime-base/01-escalation.md +1 -1
- package/dist/builtin-memory/01-spine/00-has-manager.md +1 -1
- package/dist/builtin-memory/02-turn-lifecycle/00-ending-a-turn.md +5 -0
- package/dist/builtin-memory/02-turn-lifecycle/02-resident.md +5 -3
- package/dist/builtin-memory/04-orchestration-kernel.md +2 -2
- package/dist/builtin-memory/insights/capture.md +3 -2
- package/dist/builtin-memory/internal/memory-loading.md +4 -3
- package/dist/builtin-pi-packages/pi-crtr-extensions/README.md +6 -1
- package/dist/clients/attach/render/diagram.js +13 -5
- package/dist/clients/attach/render/page-block.d.ts +0 -1
- package/dist/clients/attach/render/page-block.js +4 -57
- package/dist/clients/attach/viewer.js +570 -563
- package/dist/clients/inbox/__tests__/integration/inbox-controller.test.js +9 -0
- package/dist/clients/inbox/__tests__/integration/mount-panel.test.js +62 -1
- package/dist/clients/inbox/controller.d.ts +10 -0
- package/dist/clients/inbox/controller.js +56 -12
- package/dist/clients/inbox/tui/input.js +38 -10
- package/dist/clients/inbox/tui/page-body.d.ts +10 -0
- package/dist/clients/inbox/tui/page-body.js +66 -0
- package/dist/clients/inbox/tui/panel.js +6 -4
- package/dist/clients/inbox/tui/render.js +89 -17
- package/dist/clients/inbox/tui/types.d.ts +4 -4
- package/dist/commands/__tests__/human.test.js +18 -3
- package/dist/commands/__tests__/node-message.test.js +3 -3
- package/dist/commands/api-client.js +1 -7
- package/dist/commands/canvas-config.js +6 -14
- package/dist/commands/canvas-use.js +4 -6
- package/dist/commands/cron.js +16 -22
- package/dist/commands/human/prompts.d.ts +1 -1
- package/dist/commands/human/prompts.js +118 -112
- package/dist/commands/human/request.js +13 -14
- package/dist/commands/human/review.js +3 -4
- package/dist/commands/human/shared.d.ts +6 -0
- package/dist/commands/human/shared.js +43 -5
- package/dist/commands/human.js +1 -1
- package/dist/commands/memory/delete.js +4 -6
- package/dist/commands/memory/edit.js +0 -4
- package/dist/commands/memory/move.js +3 -5
- package/dist/commands/memory/shared.d.ts +1 -1
- package/dist/commands/memory/shared.js +11 -7
- package/dist/commands/memory/write.js +60 -29
- package/dist/commands/memory.js +1 -1
- package/dist/commands/node/bash.js +6 -9
- package/dist/commands/node/create.js +89 -22
- package/dist/commands/node/inspect.js +3 -3
- package/dist/commands/node/lifecycle.js +30 -27
- package/dist/commands/node/message.js +24 -45
- package/dist/commands/node/subscription.js +6 -15
- package/dist/commands/node/wait.js +2 -3
- package/dist/commands/node-lifecycle-revive.js +1 -12
- package/dist/commands/pkg/browse/actions.js +2 -3
- package/dist/commands/pkg/market-manage.js +2 -5
- package/dist/commands/pkg/plugin-manage.js +7 -8
- package/dist/commands/profile/default.js +5 -5
- package/dist/commands/profile/delete.js +1 -1
- package/dist/commands/profile/env.js +9 -15
- package/dist/commands/profile/kind.js +3 -7
- package/dist/commands/profile/meta.js +3 -5
- package/dist/commands/profile/new.js +0 -6
- package/dist/commands/profile/pause.js +4 -8
- package/dist/commands/profile/project.js +5 -9
- package/dist/commands/profile/rename.js +3 -7
- package/dist/commands/profile/show.js +3 -3
- package/dist/commands/profile.js +4 -3
- package/dist/commands/surface-tmux-spread.js +1 -3
- package/dist/commands/sys/config.js +3 -4
- package/dist/commands/sys/support/prepare.js +8 -4
- package/dist/commands/sys/support/submit.js +2 -3
- package/dist/commands/sys/sync-deps.js +1 -9
- package/dist/commands/sys/sync-project-guidance.js +1 -7
- package/dist/commands/sys/sync-skills.js +1 -11
- package/dist/core/__tests__/cron-node-sink-parked-root.test.d.ts +1 -0
- package/dist/core/__tests__/cron-node-sink-parked-root.test.js +147 -0
- package/dist/core/__tests__/history-inbox.test.js +11 -1
- package/dist/core/__tests__/human-deliver.test.js +2 -1
- package/dist/core/__tests__/integration/command-plugins.test.js +0 -1
- package/dist/core/__tests__/integration/deferred-no-wake.test.js +0 -1
- package/dist/core/__tests__/lifecycle.test.js +30 -2
- package/dist/core/__tests__/revive-parked-fresh.test.d.ts +1 -0
- package/dist/core/__tests__/revive-parked-fresh.test.js +109 -0
- package/dist/core/__tests__/seam/dormancy-release.test.js +32 -5
- package/dist/core/canvas/attention.d.ts +2 -0
- package/dist/core/canvas/attention.js +25 -18
- package/dist/core/canvas/extensions.d.ts +1 -1
- package/dist/core/canvas/extensions.js +7 -1
- package/dist/core/canvas/history.js +20 -2
- package/dist/core/canvas/types.d.ts +1 -1
- package/dist/core/command.js +33 -8
- package/dist/core/help.d.ts +28 -2
- package/dist/core/help.js +46 -10
- package/dist/core/human/__tests__/page-html-markdown.test.d.ts +1 -0
- package/dist/core/human/__tests__/page-html-markdown.test.js +48 -0
- package/dist/core/human/component-docs.js +4 -4
- package/dist/core/human/page-html-markdown.d.ts +8 -0
- package/dist/core/human/page-html-markdown.js +260 -0
- package/dist/core/memory/lint.d.ts +15 -0
- package/dist/core/memory/lint.js +150 -90
- package/dist/core/profiles/__tests__/fuzzy-match.test.d.ts +1 -0
- package/dist/core/profiles/__tests__/fuzzy-match.test.js +51 -0
- package/dist/core/profiles/fuzzy-match.d.ts +19 -0
- package/dist/core/profiles/fuzzy-match.js +92 -0
- package/dist/core/profiles/manifest.d.ts +14 -7
- package/dist/core/profiles/manifest.js +62 -12
- package/dist/core/profiles/select.d.ts +3 -1
- package/dist/core/profiles/select.js +5 -3
- package/dist/core/profiles/state-block.js +4 -3
- package/dist/core/runtime/boot-root.d.ts +3 -2
- package/dist/core/runtime/canvas-extensions.d.ts +7 -1
- package/dist/core/runtime/canvas-extensions.js +8 -1
- package/dist/core/runtime/lifecycle.d.ts +11 -2
- package/dist/core/runtime/lifecycle.js +15 -2
- package/dist/core/runtime/model-selection.d.ts +4 -0
- package/dist/core/runtime/model-selection.js +5 -0
- package/dist/core/runtime/nodes.js +5 -0
- package/dist/core/runtime/reopen.d.ts +6 -0
- package/dist/core/runtime/reopen.js +12 -1
- package/dist/core/runtime/revive.d.ts +6 -0
- package/dist/core/runtime/revive.js +22 -2
- package/dist/core/runtime/spawn.d.ts +5 -2
- package/dist/core/runtime/spawn.js +18 -32
- package/dist/core/runtime/structured-output.d.ts +6 -0
- package/dist/core/runtime/structured-output.js +6 -0
- package/dist/core/substrate/__tests__/surface-match-command.test.d.ts +1 -0
- package/dist/core/substrate/__tests__/surface-match-command.test.js +89 -0
- package/dist/core/substrate/__tests__/surface-match-pre-command.test.d.ts +1 -0
- package/dist/core/substrate/__tests__/surface-match-pre-command.test.js +92 -0
- package/dist/core/substrate/frontmatter-validation.js +1 -1
- package/dist/core/substrate/injected-store.d.ts +6 -0
- package/dist/core/substrate/injected-store.js +24 -0
- package/dist/core/substrate/on-read.d.ts +17 -1
- package/dist/core/substrate/on-read.js +38 -3
- package/dist/core/substrate/schema.d.ts +3 -3
- package/dist/core/substrate/schema.js +4 -4
- package/dist/core/substrate/surface-match.d.ts +19 -0
- package/dist/core/substrate/surface-match.js +224 -12
- package/dist/core/termrender/version.d.ts +1 -1
- package/dist/core/termrender/version.js +1 -1
- package/dist/core/user-settings.js +1 -1
- package/dist/daemon/api/__tests__/broker-settle-park.test.d.ts +1 -0
- package/dist/daemon/api/__tests__/broker-settle-park.test.js +102 -0
- package/dist/daemon/api/__tests__/canvas-snapshot-fields.test.d.ts +1 -0
- package/dist/daemon/api/__tests__/canvas-snapshot-fields.test.js +188 -0
- package/dist/daemon/api/__tests__/node-create-description.test.d.ts +1 -0
- package/dist/daemon/api/__tests__/node-create-description.test.js +83 -0
- package/dist/daemon/api/__tests__/profile-metadata-route.test.d.ts +1 -0
- package/dist/daemon/api/__tests__/profile-metadata-route.test.js +92 -0
- package/dist/daemon/api/__tests__/reopen-delivery.test.d.ts +1 -0
- package/dist/daemon/api/__tests__/reopen-delivery.test.js +173 -0
- package/dist/daemon/api/handlers/broker-ops.js +21 -0
- package/dist/daemon/api/handlers/canvas.js +10 -0
- package/dist/daemon/api/handlers/messages.js +25 -16
- package/dist/daemon/api/handlers/nodes.js +5 -0
- package/dist/daemon/api/handlers/profiles.js +22 -1
- package/dist/daemon/cron-run.js +19 -1
- package/dist/daemon/manage.d.ts +16 -1
- package/dist/daemon/manage.js +20 -1
- package/dist/daemon/park-pending.d.ts +13 -0
- package/dist/daemon/park-pending.js +42 -0
- package/dist/daemon/reconcilers/broker-supervision.d.ts +13 -0
- package/dist/daemon/reconcilers/broker-supervision.js +139 -21
- package/dist/daemon/reconcilers/live-obligation.d.ts +10 -4
- package/dist/daemon/reconcilers/live-obligation.js +7 -3
- package/dist/daemon/reconcilers/storage-maintenance.d.ts +0 -4
- package/dist/daemon/reconcilers/storage-maintenance.js +1 -33
- package/dist/pi-extensions/__tests__/pre-command-gate.test.d.ts +1 -0
- package/dist/pi-extensions/__tests__/pre-command-gate.test.js +220 -0
- package/dist/pi-extensions/canvas-doc-substrate.d.ts +14 -0
- package/dist/pi-extensions/canvas-doc-substrate.js +75 -2
- package/dist/pi-extensions/canvas-prompt-scrub.d.ts +13 -0
- package/dist/pi-extensions/canvas-prompt-scrub.js +53 -0
- package/dist/shared/generated-context.d.ts +7 -0
- package/dist/shared/generated-context.js +11 -0
- package/package.json +4 -4
- package/runtime.lock.json +2 -2
- 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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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 (
|
|
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,
|
|
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
|
-
|
|
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(''
|
|
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
|
-
|
|
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
|
-
|
|
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 +
|
|
261
|
-
while (sliced.length <
|
|
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
|
-
|
|
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}
|
|
287
|
-
|
|
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
|
-
|
|
290
|
-
|
|
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
|
-
|
|
293
|
-
|
|
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
|
-
|
|
35
|
-
|
|
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;
|