@north-light/crouter 0.3.192 → 0.3.194
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 +31 -10
- package/dist/api/client.js +83 -24
- package/dist/api/dto/bash-jobs.d.ts +24 -0
- package/dist/api/dto/bash-jobs.js +9 -0
- package/dist/api/dto/human.d.ts +3 -4
- package/dist/api/dto/inbox.d.ts +106 -65
- package/dist/api/dto/inbox.js +2 -8
- package/dist/api/index.d.ts +1 -0
- package/dist/api/index.js +1 -0
- package/dist/api/routes.d.ts +7 -0
- package/dist/api/routes.js +7 -0
- package/dist/builtin-memory/02-lifecycle/01-resident.md +1 -1
- package/dist/builtin-memory/internal/examples/imessage-assistant.md +1 -1
- package/dist/builtin-memory/internal/nodes-and-canvas.md +2 -2
- package/dist/builtin-memory/internal/storage-tiers.md +1 -1
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/serial/provider-rotation.test.ts +101 -3
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.d.ts +6 -2
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.js +68 -14
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +87 -13
- package/dist/clients/attach/__tests__/chat-view-snapshot-ordering.test.js +2 -2
- package/dist/clients/attach/__tests__/group-activity.test.js +2 -2
- package/dist/clients/attach/chrome/canvas-panels.d.ts +1 -1
- package/dist/clients/attach/chrome/canvas-panels.js +1 -1
- package/dist/clients/attach/input/overlay-owner.js +1 -1
- package/dist/clients/attach/overlays/auth.js +34 -8
- package/dist/clients/attach/overlays/pickers.d.ts +5 -8
- package/dist/clients/attach/overlays/pickers.js +37 -43
- package/dist/clients/attach/viewer.js +643 -620
- package/dist/clients/inbox/__tests__/serial/inbox-controller.test.js +38 -128
- package/dist/clients/inbox/__tests__/serial/mount-panel.test.js +72 -148
- package/dist/clients/inbox/controller.d.ts +2 -3
- package/dist/clients/inbox/controller.js +41 -42
- package/dist/clients/inbox/page-adapter.d.ts +33 -0
- package/dist/clients/inbox/page-adapter.js +54 -0
- package/dist/clients/inbox/resolve.d.ts +2 -2
- package/dist/clients/inbox/resolve.js +1 -1
- package/dist/clients/inbox/review-visibility.d.ts +1 -1
- package/dist/clients/inbox/review-visibility.js +1 -1
- package/dist/clients/inbox/tui/ansi.d.ts +1 -1
- package/dist/clients/inbox/tui/ansi.js +2 -2
- package/dist/clients/inbox/tui/input.d.ts +1 -3
- package/dist/clients/inbox/tui/input.js +242 -553
- package/dist/clients/inbox/tui/panel.d.ts +3 -1
- package/dist/clients/inbox/tui/panel.js +59 -187
- package/dist/clients/inbox/tui/render.d.ts +0 -25
- package/dist/clients/inbox/tui/render.js +165 -416
- package/dist/clients/inbox/tui/slots.d.ts +32 -0
- package/dist/clients/inbox/tui/slots.js +156 -0
- package/dist/clients/inbox/tui/types.d.ts +25 -13
- package/dist/clients/inbox/tui.js +5 -3
- package/dist/commands/attention.js +2 -2
- package/dist/commands/human/doc.js +66 -20
- package/dist/commands/human/prompts.d.ts +3 -3
- package/dist/commands/human/prompts.js +158 -112
- package/dist/commands/human/queue.d.ts +0 -1
- package/dist/commands/human/queue.js +34 -94
- package/dist/commands/human/shared.d.ts +10 -4
- package/dist/commands/human/shared.js +62 -13
- package/dist/commands/human.js +8 -4
- package/dist/commands/memory/read.js +13 -5
- package/dist/commands/node/bash.js +18 -30
- package/dist/commands/node/lifecycle.js +2 -2
- package/dist/commands/surface/node/focus.js +1 -1
- package/dist/core/__tests__/human-deliver.test.js +40 -46
- package/dist/core/__tests__/seam/broker-attach-stream.test.js +7 -1
- package/dist/core/__tests__/seam/dormancy-release.test.js +64 -10
- package/dist/core/__tests__/serial/human-deliver-e2e.test.js +40 -31
- package/dist/core/__tests__/serial/spawn-root.test.js +3 -1
- package/dist/core/__tests__/spawn-worktree-id-conflict.test.js +3 -1
- package/dist/core/auth-interaction.d.ts +2 -2
- package/dist/core/bash-jobs.d.ts +43 -0
- package/dist/core/bash-jobs.js +96 -2
- package/dist/core/canvas/__tests__/attention.test.js +16 -10
- package/dist/core/canvas/__tests__/render-remote.test.js +9 -3
- package/dist/core/canvas/attention.js +1 -1
- package/dist/core/canvas/browse/app.js +1 -1
- package/dist/core/canvas/nav-model.d.ts +1 -1
- package/dist/core/canvas/nav-model.js +1 -1
- package/dist/core/canvas/remote-canvas-source.js +1 -1
- package/dist/core/canvas/types.d.ts +2 -1
- package/dist/core/config.js +4 -1
- package/dist/core/human/__tests__/page-render.test.d.ts +1 -0
- package/dist/core/human/__tests__/page-render.test.js +49 -0
- package/dist/core/human/__tests__/page-tickets.test.d.ts +1 -0
- package/dist/core/human/__tests__/page-tickets.test.js +112 -0
- package/dist/core/human/__tests__/page.test.d.ts +1 -0
- package/dist/core/human/__tests__/page.test.js +139 -0
- package/dist/core/human/__tests__/serial/inbox-core.test.js +42 -32
- package/dist/core/human/claim.js +2 -2
- package/dist/core/human/convention.d.ts +4 -4
- package/dist/core/human/convention.js +6 -13
- package/dist/core/human/markdown-html.d.ts +8 -0
- package/dist/core/human/markdown-html.js +581 -0
- package/dist/core/human/page-catalog.d.ts +6 -0
- package/dist/core/human/page-catalog.js +43 -0
- package/dist/core/human/page-render.d.ts +3 -0
- package/dist/core/human/page-render.js +131 -0
- package/dist/core/human/page-schema.d.ts +312 -0
- package/dist/core/human/page-schema.js +291 -0
- package/dist/core/human/page-synth.d.ts +9 -0
- package/dist/core/human/page-synth.js +17 -0
- package/dist/core/human/page.d.ts +22 -0
- package/dist/core/human/page.js +427 -0
- package/dist/core/human/review-schema.d.ts +19 -0
- package/dist/core/human/review-schema.js +34 -0
- package/dist/core/human/scan.d.ts +12 -3
- package/dist/core/human/scan.js +69 -26
- package/dist/core/human/summary.d.ts +5 -6
- package/dist/core/human/summary.js +54 -37
- package/dist/core/human/tickets.d.ts +61 -11
- package/dist/core/human/tickets.js +100 -57
- package/dist/core/human/types.d.ts +21 -66
- package/dist/core/human/types.js +1 -5
- package/dist/core/inspector/model.d.ts +5 -0
- package/dist/core/inspector/model.js +5 -0
- package/dist/core/inspector/text.js +2 -2
- package/dist/core/inspector/tui.js +10 -3
- package/dist/core/keybindings/catalog.d.ts +1 -1
- package/dist/core/keybindings/catalog.js +4 -2
- package/dist/core/preview-registry.js +0 -1
- package/dist/core/review/ticket-filter.d.ts +1 -1
- package/dist/core/review/ticket-filter.js +2 -8
- package/dist/core/runtime/broker/read-ops.js +3 -0
- package/dist/core/runtime/broker.js +19 -10
- package/dist/core/runtime/package-health.js +1 -1
- package/dist/core/runtime/persona.js +2 -1
- package/dist/core/runtime/tmux-bindings.js +8 -1
- package/dist/core/termrender/termrender.d.ts +1 -1
- package/dist/core/termrender/termrender.js +2 -2
- package/dist/core/termrender/version.d.ts +1 -1
- package/dist/core/termrender/version.js +1 -1
- package/dist/core/tui/page-host.d.ts +1 -1
- package/dist/core/tui/page-host.js +2 -2
- package/dist/core/user-settings.d.ts +19 -0
- package/dist/core/user-settings.js +22 -0
- package/dist/daemon/api/handlers/bash-jobs.d.ts +2 -0
- package/dist/daemon/api/handlers/bash-jobs.js +90 -0
- package/dist/daemon/api/handlers/human.js +16 -14
- package/dist/daemon/api/handlers/inbox.js +311 -227
- package/dist/daemon/api/router.d.ts +5 -1
- package/dist/daemon/api/router.js +5 -0
- package/dist/daemon/api/server.js +2 -0
- package/dist/daemon/cron-run.js +23 -10
- package/dist/daemon/human/finish.d.ts +8 -3
- package/dist/daemon/human/finish.js +35 -15
- package/dist/daemon/reconcilers/broker-supervision.js +7 -1
- package/dist/daemon/reconcilers/dormant-inbox.js +8 -0
- package/dist/daemon/reconcilers/live-obligation.d.ts +2 -0
- package/dist/daemon/reconcilers/live-obligation.js +11 -0
- package/dist/pages/bundle.css +1 -0
- package/dist/pages/bundle.js +1347 -0
- package/dist/pages/comments.d.ts +34 -0
- package/dist/pages/comments.js +72 -0
- package/dist/pages/elements/cards.d.ts +17 -0
- package/dist/pages/elements/cards.js +785 -0
- package/dist/pages/elements/chart.d.ts +1 -0
- package/dist/pages/elements/chart.js +648 -0
- package/dist/pages/elements/options.d.ts +21 -0
- package/dist/pages/elements/options.js +604 -0
- package/dist/pages/elements/pages.d.ts +24 -0
- package/dist/pages/elements/pages.js +355 -0
- package/dist/pages/elements/slot.d.ts +21 -0
- package/dist/pages/elements/slot.js +105 -0
- package/dist/pages/elements/table.d.ts +19 -0
- package/dist/pages/elements/table.js +906 -0
- package/dist/pages/elements/text.d.ts +25 -0
- package/dist/pages/elements/text.js +951 -0
- package/dist/pages/entry.d.ts +8 -0
- package/dist/pages/entry.js +8 -0
- package/dist/pages/host.d.ts +132 -0
- package/dist/pages/host.js +149 -0
- package/dist/pages/register.d.ts +2 -0
- package/dist/pages/register.js +3 -0
- package/dist/pages/slot-config.d.ts +36 -0
- package/dist/pages/slot-config.js +50 -0
- package/dist/pages/types.d.ts +98 -0
- package/dist/pages/types.js +23 -0
- package/dist/pi-extensions/canvas-bash-valve.d.ts +4 -2
- package/dist/pi-extensions/canvas-bash-valve.js +25 -7
- package/dist/types.d.ts +4 -0
- package/dist/types.js +2 -0
- package/package.json +8 -8
- package/runtime.lock.json +156 -101
- package/scripts/install-runtime.mjs +172 -5
- package/dist/clients/inbox/deck-adapter.d.ts +0 -29
- package/dist/clients/inbox/deck-adapter.js +0 -63
- package/dist/core/human/deck-factories.d.ts +0 -8
- package/dist/core/human/deck-factories.js +0 -15
- package/dist/core/human/deck-schema.d.ts +0 -70
- package/dist/core/human/deck-schema.js +0 -92
|
@@ -1,159 +1,69 @@
|
|
|
1
1
|
import assert from 'node:assert/strict';
|
|
2
2
|
import { createServer } from 'node:http';
|
|
3
|
-
import { existsSync, mkdtempSync,
|
|
3
|
+
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
4
4
|
import { tmpdir } from 'node:os';
|
|
5
5
|
import { join } from 'node:path';
|
|
6
|
-
import { claimExists } from '#core/human/claim';
|
|
7
6
|
import { ticketDir } from '#core/human/root';
|
|
8
|
-
import {
|
|
7
|
+
import { submitPage, takeoverAndCompletePage, writePageProgress } from '#core/human/tickets';
|
|
9
8
|
import { InboxController } from '../../controller.js';
|
|
10
9
|
const key = (part = {}) => ({ ctrl: false, meta: false, upArrow: false, downArrow: false, leftArrow: false, rightArrow: false, wordLeft: false, wordRight: false, home: false, end: false, pageUp: false, pageDown: false, del: false, return: false, newline: false, escape: false, tab: false, backspace: false, ...part });
|
|
11
10
|
function fakeHost() {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
const handle = {
|
|
15
|
-
close() {
|
|
16
|
-
if (closed)
|
|
17
|
-
return;
|
|
18
|
-
closed = true;
|
|
19
|
-
for (const fn of cleanup)
|
|
20
|
-
void fn();
|
|
21
|
-
cleanup.length = 0;
|
|
22
|
-
},
|
|
23
|
-
invalidate() { },
|
|
24
|
-
onClose(fn) {
|
|
25
|
-
if (!closed)
|
|
26
|
-
cleanup.push(fn);
|
|
27
|
-
},
|
|
28
|
-
};
|
|
29
|
-
return {
|
|
30
|
-
capabilities: { remote: false, canFocusNode: false, selfPane: undefined },
|
|
31
|
-
mount() { return handle; },
|
|
32
|
-
notice() { },
|
|
33
|
-
suspendForChild() { return () => { }; },
|
|
34
|
-
focusNode: async () => false,
|
|
35
|
-
pickReviewFile: async () => null,
|
|
36
|
-
};
|
|
11
|
+
const handle = { close() { }, invalidate() { }, onClose() { } };
|
|
12
|
+
return { capabilities: { remote: false, canFocusNode: false, selfPane: undefined }, mount: () => handle, notice() { }, suspendForChild: () => () => { }, focusNode: async () => false, pickReviewFile: async () => null };
|
|
37
13
|
}
|
|
38
|
-
const
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
assert.ok(matches(), `expected ${dir} to be selectable`);
|
|
49
|
-
}
|
|
50
|
-
async function waitFor(predicate, message) {
|
|
51
|
-
const deadline = Date.now() + 1_000;
|
|
52
|
-
while (!predicate()) {
|
|
53
|
-
if (Date.now() >= deadline)
|
|
54
|
-
throw new Error(message);
|
|
55
|
-
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
// The controller answers a ticket by crossing the daemon `/resolve` seam. This
|
|
59
|
-
// fake daemon plays back each failure the seam can return so the controller's
|
|
60
|
-
// unmount-or-retry decision is exercised against a real socket.
|
|
61
|
-
let mode = 'success';
|
|
14
|
+
const controller = (options = {}) => new InboxController({ host: fakeHost(), onDirty() { }, onClosed() { }, ...options });
|
|
15
|
+
async function waitFor(predicate, message) { const end = Date.now() + 1000; while (!predicate()) {
|
|
16
|
+
if (Date.now() > end)
|
|
17
|
+
throw new Error(message);
|
|
18
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
19
|
+
} }
|
|
20
|
+
const temp = mkdtempSync(join(tmpdir(), 'crtr-inbox-controller-'));
|
|
21
|
+
const sources = mkdtempSync(join(tmpdir(), 'crtr-page-sources-'));
|
|
22
|
+
process.env['CRTR_HOME'] = temp;
|
|
23
|
+
let received;
|
|
62
24
|
async function startDaemon() {
|
|
63
25
|
const server = createServer(async (req, res) => {
|
|
64
26
|
if (!req.url?.endsWith('/resolve')) {
|
|
65
27
|
res.statusCode = 404;
|
|
66
|
-
res.end(
|
|
67
|
-
return;
|
|
68
|
-
}
|
|
69
|
-
if (mode === 'unreachable') {
|
|
70
|
-
res.statusCode = 503;
|
|
71
|
-
res.end(JSON.stringify({ error: { code: 'daemon_unavailable', message: 'offline' } }));
|
|
28
|
+
res.end();
|
|
72
29
|
return;
|
|
73
30
|
}
|
|
74
|
-
const body = await new Promise((resolve) => {
|
|
75
|
-
let text = '';
|
|
76
|
-
req.on('data', (chunk) => { text += chunk; });
|
|
77
|
-
req.on('end', () => resolve(text));
|
|
78
|
-
});
|
|
31
|
+
const body = await new Promise((resolve) => { let text = ''; req.on('data', (c) => { text += c; }); req.on('end', () => resolve(text)); });
|
|
79
32
|
const nodeId = req.url.split('/').at(-2);
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
return;
|
|
84
|
-
}
|
|
85
|
-
const request = JSON.parse(body);
|
|
86
|
-
if (request.responses === undefined)
|
|
87
|
-
throw new Error('expected a deck resolution');
|
|
88
|
-
takeoverAndCompleteDeck(ticketDir(nodeId), request.responses, { host: 'daemon', pid: 1 });
|
|
89
|
-
if (mode === 'delivery_failed') {
|
|
90
|
-
res.statusCode = 500;
|
|
91
|
-
res.end(JSON.stringify({ error: { code: 'delivery_failed', message: 'final report delivery failed' } }));
|
|
92
|
-
return;
|
|
93
|
-
}
|
|
94
|
-
res.end(JSON.stringify({ delivered: true, kind: 'deck' }));
|
|
33
|
+
received = JSON.parse(body).responses;
|
|
34
|
+
takeoverAndCompletePage(ticketDir(nodeId), received, { host: 'daemon', pid: 1 });
|
|
35
|
+
res.end(JSON.stringify({ delivered: true, kind: 'page' }));
|
|
95
36
|
});
|
|
96
|
-
await new Promise((resolve) => server.listen(join(
|
|
37
|
+
await new Promise((resolve) => server.listen(join(temp, 'crtrd.sock'), resolve));
|
|
97
38
|
return server;
|
|
98
39
|
}
|
|
99
|
-
const
|
|
100
|
-
|
|
40
|
+
const source = (name, body) => { const path = join(sources, name); writeFileSync(path, body); return path; };
|
|
41
|
+
const ask = source('ask.md', `---\ntitle: Restored page\nsubtitle: Pick one\nplacement: panel\n---\n\nLeading prose\n\n\`\`\`slot:options\n{"id":"choice","mode":"single","options":[{"id":"yes","label":"Yes"},{"id":"no","label":"No"}]}\n\`\`\`\n`);
|
|
42
|
+
const html = source('rich.html', '<!doctype html><html><head><meta name="crtr-page-title" content="Rich page"><meta name="crtr-page-subtitle" content="Browser content"></head><body><crtr-pages><crtr-page><crtr-chart><script type="application/json">{"kind":"bar","x":"day","y":[{"field":"count"}]}</script></crtr-chart></crtr-page></crtr-pages></body></html>');
|
|
101
43
|
const server = await startDaemon();
|
|
102
|
-
const deck = (id) => ({ title: id, source: { blockedSince: new Date().toISOString(), sessionName: 'Release planning', nodeId: 'node-private-123' }, interactions: [{ id: 'answer', title: 'Answer', subtitle: 'Answer requires your attention.', options: [{ id: 'yes', label: 'Yes', shortcut: 'y' }] }] });
|
|
103
44
|
try {
|
|
104
|
-
const
|
|
105
|
-
|
|
45
|
+
const ticket = submitPage({ dir: ticketDir('page-one'), sourceFile: ask, dialect: 'md' });
|
|
46
|
+
writePageProgress(ticket.dir, { choice: { selectedOptionIds: ['yes'], comments: [] } });
|
|
47
|
+
const active = controller({ cols: 100, rows: 24 });
|
|
106
48
|
active.activate();
|
|
107
|
-
active.
|
|
108
|
-
|
|
109
|
-
|
|
49
|
+
assert.equal(active.snapshot().screen, 'detail', 'page ticket opens in the page panel');
|
|
50
|
+
active.handleKey('n', key());
|
|
51
|
+
active.handleKey('', key({ return: true }));
|
|
52
|
+
await waitFor(() => existsSync(join(ticket.dir, 'response.json')), 'restored page responses cross the daemon seam');
|
|
53
|
+
assert.deepEqual(received, { choice: { selectedOptionIds: ['yes'], comments: [] } }, 'progress.json is restored into the response map');
|
|
110
54
|
active.close();
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
editingController.handleKey('r', key());
|
|
118
|
-
for (const char of 'draft')
|
|
119
|
-
editingController.handleKey(char, key());
|
|
120
|
-
submitDeck({ dir: ticketDir('arrival'), deck: deck('arrival') });
|
|
121
|
-
editingController.rescan();
|
|
122
|
-
assert.equal(realpathSync(editingController.snapshot().selectedDir), realpathSync(editing.dir), 'arrival does not steal active selection');
|
|
123
|
-
assert.equal(editingController.snapshot().inputBuffer, 'draft', 'arrival does not alter freetext input');
|
|
124
|
-
editingController.close();
|
|
125
|
-
cancelTicket(editing.dir, { reason: 'done editing' });
|
|
126
|
-
const already = submitDeck({ dir: ticketDir('already'), deck: deck('already') });
|
|
127
|
-
const alreadyController = makeController({ cols: 100, rows: 24 });
|
|
128
|
-
alreadyController.activate();
|
|
129
|
-
cancelTicket(already.dir, { reason: 'resolved elsewhere' });
|
|
130
|
-
mode = 'already_resolved';
|
|
131
|
-
alreadyController.handleKey('y', key());
|
|
132
|
-
await waitFor(() => alreadyController.snapshot().screen === 'list', 'already_resolved unmounts instead of offering a stale resubmit');
|
|
133
|
-
assert.equal(claimExists(already.dir), true, 'already_resolved retains the daemon-taken claim');
|
|
134
|
-
alreadyController.close();
|
|
135
|
-
const delivery = submitDeck({ dir: ticketDir('delivery'), deck: deck('delivery') });
|
|
136
|
-
mode = 'delivery_failed';
|
|
137
|
-
const deliveryController = makeController({ cols: 100, rows: 24 });
|
|
138
|
-
deliveryController.activate();
|
|
139
|
-
deliveryController.handleKey('y', key());
|
|
140
|
-
await waitFor(() => deliveryController.snapshot().screen === 'list', 'delivery_failed unmounts because the canonical answer is already recorded');
|
|
141
|
-
assert.equal(existsSync(join(delivery.dir, 'response.json')), true, 'delivery_failed still leaves the canonical response on disk');
|
|
142
|
-
deliveryController.close();
|
|
143
|
-
const retry = submitDeck({ dir: ticketDir('retry'), deck: deck('retry') });
|
|
144
|
-
mode = 'unreachable';
|
|
145
|
-
const retryController = makeController({ cols: 100, rows: 24 });
|
|
146
|
-
retryController.activate();
|
|
147
|
-
retryController.handleKey('y', key());
|
|
148
|
-
await tick();
|
|
149
|
-
assert.equal(retryController.snapshot().screen, 'detail', 'unreachable keeps the mounted answer available for retry');
|
|
150
|
-
assert.equal(existsSync(join(retry.dir, 'response.json')), false, 'unreachable does not manufacture a local completion');
|
|
151
|
-
retryController.close();
|
|
55
|
+
submitPage({ dir: ticketDir('rich-page'), sourceFile: html, dialect: 'html' });
|
|
56
|
+
const preview = controller({ cols: 100, rows: 24 });
|
|
57
|
+
assert.match(preview.render().join('\n'), /rich page — open in Northlight/, 'html page preview points to the rich surface');
|
|
58
|
+
preview.activate();
|
|
59
|
+
assert.match(preview.render().join('\n'), /chart — bar: day vs count/, 'html page uses the same manifest-backed terminal projection');
|
|
60
|
+
preview.close();
|
|
152
61
|
}
|
|
153
62
|
finally {
|
|
154
63
|
server.closeAllConnections();
|
|
155
64
|
await new Promise((resolve) => server.close(() => resolve()));
|
|
156
65
|
rmSync(temp, { recursive: true, force: true });
|
|
66
|
+
rmSync(sources, { recursive: true, force: true });
|
|
157
67
|
delete process.env['CRTR_HOME'];
|
|
158
68
|
}
|
|
159
69
|
console.log('inbox controller tests passed');
|
|
@@ -1,151 +1,75 @@
|
|
|
1
|
-
import { mountPanel } from '../../tui/panel.js';
|
|
2
1
|
import assert from 'node:assert/strict';
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
// never fired — leaving the deck permanently unfinishable.
|
|
33
|
-
const freetextOnlyDeck = {
|
|
34
|
-
title: 'Test deck',
|
|
35
|
-
interactions: [
|
|
36
|
-
{ id: 'q1', title: 'Optional notes', subtitle: 'Optional notes requires your attention.', options: [], allowFreetext: true },
|
|
37
|
-
],
|
|
38
|
-
};
|
|
39
|
-
let capturedFreetextEmpty = [];
|
|
40
|
-
const ftPanel = mountPanel({
|
|
41
|
-
deck: freetextOnlyDeck,
|
|
42
|
-
cols: 80,
|
|
43
|
-
rows: 24,
|
|
44
|
-
onComplete: (responses) => { capturedFreetextEmpty = responses; },
|
|
45
|
-
});
|
|
46
|
-
// Single-item deck starts directly in item-review (no overview). 'r' opens freetext.
|
|
47
|
-
ftPanel.handleKey('r', mkKey({}));
|
|
48
|
-
ftPanel.handleKey('', RETURN); // submit with an empty buffer
|
|
49
|
-
ftPanel.handleKey('', RETURN); // final phase → complete
|
|
50
|
-
assert.deepEqual(capturedFreetextEmpty, [{ id: 'q1', freetext: '' }], 'empty freetext Enter must record response and fire onComplete');
|
|
51
|
-
ftPanel.unmount();
|
|
52
|
-
// ── An empty Enter in comment mode records the response ───────────────────────
|
|
53
|
-
const commentDeck = {
|
|
54
|
-
title: 'Test deck',
|
|
55
|
-
interactions: [
|
|
56
|
-
{
|
|
57
|
-
id: 'c1', title: 'Approve deploy?', subtitle: 'Approve deploy? requires your attention.',
|
|
58
|
-
options: [{ id: 'yes', label: 'Yes', shortcut: 'y' }],
|
|
59
|
-
allowFreetext: true,
|
|
60
|
-
},
|
|
61
|
-
],
|
|
62
|
-
};
|
|
63
|
-
let capturedCommentEmpty = [];
|
|
64
|
-
const cmPanel = mountPanel({
|
|
65
|
-
deck: commentDeck,
|
|
66
|
-
cols: 80,
|
|
67
|
-
rows: 24,
|
|
68
|
-
onComplete: (responses) => { capturedCommentEmpty = responses; },
|
|
69
|
-
});
|
|
70
|
-
// Navigate to the [c] row WITHOUT pre-attaching an option, then comment.
|
|
71
|
-
cmPanel.handleKey('j', mkKey({}));
|
|
72
|
-
cmPanel.handleKey('c', mkKey({}));
|
|
73
|
-
cmPanel.handleKey('', RETURN); // submit an empty comment, no option selected
|
|
74
|
-
cmPanel.handleKey('', RETURN); // final phase → complete
|
|
75
|
-
assert.deepEqual(capturedCommentEmpty, [{ id: 'c1', freetext: '' }], 'empty Enter in comment mode must record response and fire onComplete');
|
|
76
|
-
cmPanel.unmount();
|
|
77
|
-
// ── Option-shortcut letters typed in comment mode are text, never a choice ────
|
|
78
|
-
// Regression: pressing an option's shortcut while commenting submitted that
|
|
79
|
-
// option for the user instead of appending the character to the buffer.
|
|
80
|
-
const shortcutTypingDeck = {
|
|
81
|
-
title: 'Test deck',
|
|
82
|
-
interactions: [
|
|
83
|
-
{
|
|
84
|
-
id: 'q1', title: 'Approve?', subtitle: 'Approve? requires your attention.',
|
|
85
|
-
options: [
|
|
86
|
-
{ id: 'yes', label: 'Yes', shortcut: 'y' },
|
|
87
|
-
{ id: 'no', label: 'No', shortcut: 'n' },
|
|
88
|
-
],
|
|
89
|
-
allowFreetext: true,
|
|
90
|
-
},
|
|
91
|
-
],
|
|
92
|
-
};
|
|
93
|
-
let capturedShortcutTyping = [];
|
|
94
|
-
const stPanel = mountPanel({
|
|
95
|
-
deck: shortcutTypingDeck,
|
|
96
|
-
cols: 80,
|
|
97
|
-
rows: 24,
|
|
98
|
-
onComplete: (responses) => { capturedShortcutTyping = responses; },
|
|
99
|
-
});
|
|
100
|
-
stPanel.handleKey('j', mkKey({}));
|
|
101
|
-
stPanel.handleKey('j', mkKey({}));
|
|
102
|
-
stPanel.handleKey('c', mkKey({}));
|
|
103
|
-
// 'y' and 'n' are both option shortcuts; every character must reach the buffer.
|
|
104
|
-
for (const ch of 'yes no')
|
|
105
|
-
stPanel.handleKey(ch, mkKey({}));
|
|
106
|
-
stPanel.handleKey('', RETURN); // submit the comment — must carry no selectedOptionId
|
|
107
|
-
stPanel.handleKey('', RETURN); // final → complete
|
|
108
|
-
assert.deepEqual(capturedShortcutTyping, [{ id: 'q1', freetext: 'yes no' }], 'option-shortcut letters typed in comment mode must append to buffer, not select option');
|
|
109
|
-
stPanel.unmount();
|
|
110
|
-
// ── Multi-select needs an explicit confirm, and an empty Enter does nothing ───
|
|
111
|
-
// Two regressions on one deck: Enter with nothing toggled committed an empty
|
|
112
|
-
// set and finalized, and a confirmed set auto-finalized with no chance to
|
|
113
|
-
// review it.
|
|
114
|
-
const multiDeck = {
|
|
115
|
-
title: 'Test deck',
|
|
116
|
-
interactions: [
|
|
117
|
-
{
|
|
118
|
-
id: 'cs1', title: 'Pick toppings', subtitle: 'Pick toppings requires your attention.', multiSelect: true,
|
|
119
|
-
options: [
|
|
120
|
-
{ id: 'mush', label: 'Mushroom', shortcut: 'a' },
|
|
121
|
-
{ id: 'onion', label: 'Onion', shortcut: 'b' },
|
|
122
|
-
],
|
|
123
|
-
},
|
|
124
|
-
],
|
|
125
|
-
};
|
|
126
|
-
let multiFired = false;
|
|
127
|
-
let multiResponses = [];
|
|
128
|
-
const multiPanel = mountPanel({
|
|
129
|
-
deck: multiDeck,
|
|
130
|
-
cols: 80,
|
|
131
|
-
rows: 24,
|
|
132
|
-
onComplete: (responses) => { multiFired = true; multiResponses = responses; },
|
|
133
|
-
});
|
|
134
|
-
// Nothing toggled: Enter must be an inert no-op, not an empty answer.
|
|
135
|
-
multiPanel.handleKey('', RETURN);
|
|
136
|
-
assert.equal(multiFired, false, 'Enter with an empty multi-select set must NOT finalize');
|
|
137
|
-
const stillReview = multiPanel.render().join('\n');
|
|
138
|
-
assert.ok(stillReview.includes('Pick toppings'), 'after empty Enter, still on the interaction (no advance)');
|
|
139
|
-
assert.ok(!stillReview.includes('Summary'), 'after empty Enter, the deck did NOT advance to the Summary screen');
|
|
140
|
-
assert.ok(stillReview.toLowerCase().includes('select at least one'), 'empty multi-select Enter surfaces an inline hint');
|
|
141
|
-
// Toggle an option and confirm the set: the first Enter reviews, never submits.
|
|
142
|
-
multiPanel.handleKey(' ', mkKey({}));
|
|
143
|
-
multiPanel.handleKey('', RETURN);
|
|
144
|
-
assert.equal(multiFired, false, 'multi-select first Enter must NOT auto-finalize (lands on the confirm screen)');
|
|
145
|
-
assert.ok(multiPanel.render().join('\n').includes('Summary'), 'after confirming a multi-select set, the deck shows the Summary/confirm screen');
|
|
146
|
-
// A second deliberate Enter submits exactly the toggled set.
|
|
147
|
-
multiPanel.handleKey('', RETURN);
|
|
148
|
-
assert.equal(multiFired, true, 'second Enter on the Summary screen finalizes the multi-select deck');
|
|
149
|
-
assert.deepEqual(multiResponses, [{ id: 'cs1', selectedOptionIds: ['mush'] }], 'confirmed multi-select set returned on finalize');
|
|
2
|
+
import { mountPanel } from '../../tui/panel.js';
|
|
3
|
+
function key(partial = {}) { return { ctrl: false, meta: false, upArrow: false, downArrow: false, leftArrow: false, rightArrow: false, wordLeft: false, wordRight: false, home: false, end: false, pageUp: false, pageDown: false, del: false, return: false, newline: false, escape: false, tab: false, backspace: false, ...partial }; }
|
|
4
|
+
const enter = key({ return: true });
|
|
5
|
+
const manifest = (slots, extra = {}) => ({ schema: 'crtr.page/v1', dialect: 'md', title: 'Test page', placement: 'panel', document: 'page.md', steps: 1, slots, ...extra });
|
|
6
|
+
const projection = (slots, prose = []) => prose.length > 0 ? prose : slots.map((slotIndex) => ({ kind: 'slot', slotIndex }));
|
|
7
|
+
// Option comment is anchored and survives the subsequent single-select completion.
|
|
8
|
+
const options = manifest([{ id: 'choice', kind: 'options', step: 0, config: { mode: 'single', options: [{ id: 'yes', label: 'Yes' }, { id: 'no', label: 'No' }] } }]);
|
|
9
|
+
let optionResponses = {};
|
|
10
|
+
const optionsPanel = mountPanel({ manifest: options, projection: projection([0]), cols: 80, rows: 24, onComplete: (r) => { optionResponses = r; } });
|
|
11
|
+
optionsPanel.handleKey('c', key());
|
|
12
|
+
for (const ch of 'ship it')
|
|
13
|
+
optionsPanel.handleKey(ch, key());
|
|
14
|
+
optionsPanel.handleKey('', enter);
|
|
15
|
+
optionsPanel.handleKey('y', key());
|
|
16
|
+
assert.deepEqual(optionResponses, { choice: { selectedOptionIds: ['yes'], comments: [{ id: 'option:yes', anchor: { kind: 'option', optionId: 'yes' }, text: 'ship it' }] } });
|
|
17
|
+
optionsPanel.unmount();
|
|
18
|
+
// Multi-select empty Enter remains inert; selection confirmation lands on Summary before submit.
|
|
19
|
+
const multi = manifest([{ id: 'toppings', kind: 'options', step: 0, config: { mode: 'multi', options: [{ id: 'mush', label: 'Mushroom' }, { id: 'onion', label: 'Onion' }] } }]);
|
|
20
|
+
let multiResponses;
|
|
21
|
+
const multiPanel = mountPanel({ manifest: multi, projection: projection([0]), cols: 80, rows: 24, onComplete: (r) => { multiResponses = r; } });
|
|
22
|
+
multiPanel.handleKey('', enter);
|
|
23
|
+
assert.equal(multiResponses, undefined);
|
|
24
|
+
assert.match(multiPanel.render().join('\n'), /select at least one/i);
|
|
25
|
+
multiPanel.handleKey(' ', key());
|
|
26
|
+
multiPanel.handleKey('', enter);
|
|
27
|
+
assert.match(multiPanel.render().join('\n'), /Summary/);
|
|
28
|
+
assert.equal(multiResponses, undefined);
|
|
29
|
+
multiPanel.handleKey('', enter);
|
|
30
|
+
assert.deepEqual(multiResponses, { toppings: { selectedOptionIds: ['mush'], comments: [] } });
|
|
150
31
|
multiPanel.unmount();
|
|
32
|
+
// Table selection uses row ids and permits an empty committed selection by schema.
|
|
33
|
+
const table = manifest([{ id: 'rows', kind: 'table', step: 0, config: { columns: [{ id: 'name', label: 'Name' }], rows: [{ id: 'a', cells: { name: 'Alpha' } }, { id: 'b', cells: { name: 'Beta' } }], rowSelect: 'multi', columnSelect: 'none' } }]);
|
|
34
|
+
let tableResponses = {};
|
|
35
|
+
const tablePanel = mountPanel({ manifest: table, projection: projection([0]), cols: 80, rows: 24, onComplete: (r) => { tableResponses = r; } });
|
|
36
|
+
assert.match(tablePanel.render().join('\n'), /Alpha/);
|
|
37
|
+
tablePanel.handleKey(' ', key());
|
|
38
|
+
tablePanel.handleKey('', enter);
|
|
39
|
+
tablePanel.handleKey('', enter);
|
|
40
|
+
assert.deepEqual(tableResponses, { rows: { selectedRowIds: ['a'], selectedColumnIds: [], comments: [] } });
|
|
41
|
+
tablePanel.unmount();
|
|
42
|
+
// A display-only page is dismissed with q and completes with an empty response map.
|
|
43
|
+
let displayResponses;
|
|
44
|
+
const displayPanel = mountPanel({ manifest: manifest([]), projection: [{ kind: 'prose', markdown: 'FYI only' }], cols: 80, rows: 24, onComplete: (r) => { displayResponses = r; } });
|
|
45
|
+
displayPanel.handleKey('q', key());
|
|
46
|
+
assert.deepEqual(displayResponses, {});
|
|
47
|
+
displayPanel.unmount();
|
|
48
|
+
// A single-slot page opens in item-review, so its leading prose renders there.
|
|
49
|
+
const lone = manifest([{ id: 'only', kind: 'options', step: 0, config: { mode: 'single', options: [{ id: 'a', label: 'Alpha' }] } }]);
|
|
50
|
+
const lonePanel = mountPanel({ manifest: lone, projection: projection([], [{ kind: 'prose', markdown: 'Read this first' }, { kind: 'slot', slotIndex: 0 }]), cols: 80, rows: 24 });
|
|
51
|
+
assert.match(lonePanel.render().join('\n'), /Read this first/);
|
|
52
|
+
lonePanel.unmount();
|
|
53
|
+
// An unvalidated slot takes no terminal input and keeps the page unsubmittable.
|
|
54
|
+
const unvalidated = manifest([{ id: 'widget', kind: 'acme.widget', step: 0, config: {}, unvalidated: true }]);
|
|
55
|
+
let unvalidatedResponses;
|
|
56
|
+
const unvalidatedPanel = mountPanel({ manifest: unvalidated, projection: projection([0]), cols: 80, rows: 24, onComplete: (r) => { unvalidatedResponses = r; } });
|
|
57
|
+
unvalidatedPanel.handleKey('c', key());
|
|
58
|
+
assert.equal(unvalidatedPanel.getInputBuffer(), undefined);
|
|
59
|
+
unvalidatedPanel.handleKey('n', key());
|
|
60
|
+
assert.match(unvalidatedPanel.render().join('\n'), /must be answered in Northlight/);
|
|
61
|
+
unvalidatedPanel.handleKey('', enter);
|
|
62
|
+
assert.equal(unvalidatedResponses, undefined);
|
|
63
|
+
unvalidatedPanel.unmount();
|
|
64
|
+
// Overview groups slots by step, without adding a progression gate.
|
|
65
|
+
const stepped = manifest([
|
|
66
|
+
{ id: 'one', kind: 'options', step: 0, config: { mode: 'single', options: [{ id: 'a', label: 'Alpha' }] } },
|
|
67
|
+
{ id: 'two', kind: 'options', step: 1, config: { mode: 'single', options: [{ id: 'b', label: 'Beta' }] } },
|
|
68
|
+
], { dialect: 'html', document: 'page.html', steps: 2 });
|
|
69
|
+
const steppedPanel = mountPanel({ manifest: stepped, projection: projection([0, 1]), cols: 80, rows: 24 });
|
|
70
|
+
const overview = steppedPanel.render().join('\n');
|
|
71
|
+
assert.match(overview, /Step 1\/2/);
|
|
72
|
+
assert.match(overview, /Step 2\/2/);
|
|
73
|
+
assert.match(overview, /rich page — open in Northlight/);
|
|
74
|
+
steppedPanel.unmount();
|
|
151
75
|
console.log('OK');
|
|
@@ -53,12 +53,11 @@ export declare class InboxController {
|
|
|
53
53
|
private cancelFromInbox;
|
|
54
54
|
activate(): void;
|
|
55
55
|
private activateReview;
|
|
56
|
-
|
|
57
|
-
private readDeck;
|
|
56
|
+
reloadSelectedPage(): void;
|
|
58
57
|
close(): void;
|
|
59
58
|
private complete;
|
|
60
59
|
/** Edit the active buffer while the host releases the terminal to $EDITOR. */
|
|
61
|
-
private
|
|
60
|
+
private editActivePageInput;
|
|
62
61
|
private leaveDetail;
|
|
63
62
|
private select;
|
|
64
63
|
private detailSize;
|
|
@@ -4,16 +4,16 @@ import { BOLD, CYAN, DIM, GRAY, RESET, YELLOW, clipLine } from './tui/ansi.js';
|
|
|
4
4
|
import { buildInboxLines } from './tui.js';
|
|
5
5
|
import { inboxLayout } from './layout.js';
|
|
6
6
|
import { scanInbox } from '../../core/human/scan.js';
|
|
7
|
+
import { readConfig } from '../../core/config.js';
|
|
8
|
+
import { parsePageSource } from '../../core/human/page.js';
|
|
7
9
|
import { watchInboxActivity } from '../../core/human/root.js';
|
|
8
10
|
import { claimTicket, heartbeatClaim, releaseClaim } from '../../core/human/claim.js';
|
|
9
|
-
import { readTicketResult } from '../../core/human/tickets.js';
|
|
10
|
-
import { clearProgress
|
|
11
|
-
import {
|
|
12
|
-
import { DeckAdapter } from './deck-adapter.js';
|
|
13
|
-
import { validateDeck } from '../../core/human/deck-schema.js';
|
|
11
|
+
import { readPageTicket, readTicketResult } from '../../core/human/tickets.js';
|
|
12
|
+
import { clearProgress } from '../../core/human/convention.js';
|
|
13
|
+
import { PageAdapter } from './page-adapter.js';
|
|
14
14
|
import { ReviewAdapter } from './review-adapter.js';
|
|
15
15
|
import { editBufferInEditor } from './review/roundtrip.js';
|
|
16
|
-
import { cancelTicketViaDaemon, daemonErrorMessage, nodeIdFor, resolveFailureKind,
|
|
16
|
+
import { cancelTicketViaDaemon, daemonErrorMessage, nodeIdFor, resolveFailureKind, resolveTicketPage, } from './resolve.js';
|
|
17
17
|
/** Host-agnostic model for scanning, stable selection, claims, and rendering. */
|
|
18
18
|
export class InboxController {
|
|
19
19
|
options;
|
|
@@ -40,7 +40,7 @@ export class InboxController {
|
|
|
40
40
|
constructor(options) {
|
|
41
41
|
this.options = options;
|
|
42
42
|
this.host = options.host;
|
|
43
|
-
this.scan = options.scan ?? scanInbox;
|
|
43
|
+
this.scan = options.scan ?? (() => scanInbox(readConfig('user').page_components ?? []));
|
|
44
44
|
this.cols = options.cols ?? 80;
|
|
45
45
|
this.rows = options.rows ?? 24;
|
|
46
46
|
this.rescan();
|
|
@@ -113,7 +113,7 @@ export class InboxController {
|
|
|
113
113
|
// Hard-clip the list line to its column: an overflowing row would eat
|
|
114
114
|
// the pad and push the divider out of alignment for that row alone.
|
|
115
115
|
const left = clipLine(list[i] ?? '', geometry.listWidth);
|
|
116
|
-
// Clip the detail column too: a
|
|
116
|
+
// Clip the detail column too: a preview row is only ever entitled to
|
|
117
117
|
// its own panel, so an over-wide row cannot run past the pane's edge.
|
|
118
118
|
const right = clipLine(detail[i] ?? '', geometry.detailWidth);
|
|
119
119
|
const pad = ' '.repeat(Math.max(0, geometry.listWidth - visibleWidth(left)));
|
|
@@ -197,7 +197,7 @@ export class InboxController {
|
|
|
197
197
|
void this.activateReview(item);
|
|
198
198
|
return;
|
|
199
199
|
}
|
|
200
|
-
if (item.kind !== '
|
|
200
|
+
if (item.kind !== 'page')
|
|
201
201
|
return;
|
|
202
202
|
const claim = claimTicket(item.dir);
|
|
203
203
|
if (claim === null) {
|
|
@@ -205,23 +205,30 @@ export class InboxController {
|
|
|
205
205
|
return;
|
|
206
206
|
}
|
|
207
207
|
this.claim = { dir: item.dir, token: claim.token };
|
|
208
|
-
|
|
209
|
-
|
|
208
|
+
let loaded;
|
|
209
|
+
try {
|
|
210
|
+
const productKinds = readConfig('user').page_components ?? [];
|
|
211
|
+
const ticket = readPageTicket(item.dir, productKinds);
|
|
212
|
+
loaded = { ticket, projection: parsePageSource(ticket.document, ticket.manifest, productKinds) };
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
210
215
|
releaseClaim(item.dir, claim.token);
|
|
211
216
|
this.claim = undefined;
|
|
217
|
+
this.status = 'page could not be read';
|
|
212
218
|
this.invalidate();
|
|
213
219
|
return;
|
|
214
220
|
}
|
|
215
|
-
|
|
216
|
-
// opening is not acknowledgement; panel completion is.
|
|
221
|
+
const productKinds = readConfig('user').page_components ?? [];
|
|
217
222
|
this.screen = 'detail';
|
|
218
|
-
this.adapter = new
|
|
223
|
+
this.adapter = new PageAdapter({
|
|
219
224
|
dir: item.dir,
|
|
220
|
-
|
|
225
|
+
ticket: loaded.ticket,
|
|
226
|
+
projection: loaded.projection,
|
|
227
|
+
productKinds,
|
|
221
228
|
cols: this.detailSize().cols,
|
|
222
229
|
rows: this.detailSize().rows,
|
|
223
230
|
onDirty: () => this.markDirty(),
|
|
224
|
-
onEditorRequest: () => this.
|
|
231
|
+
onEditorRequest: () => this.editActivePageInput(),
|
|
225
232
|
onBack: () => { this.leaveDetail(); this.markDirty(); },
|
|
226
233
|
onComplete: (responses) => { void this.complete(responses); },
|
|
227
234
|
});
|
|
@@ -244,30 +251,20 @@ export class InboxController {
|
|
|
244
251
|
this.markDirty();
|
|
245
252
|
}
|
|
246
253
|
}
|
|
247
|
-
|
|
254
|
+
reloadSelectedPage() {
|
|
248
255
|
if (this.adapter === undefined || this.claim === undefined) {
|
|
249
256
|
this.markDirty();
|
|
250
257
|
return;
|
|
251
258
|
}
|
|
252
|
-
const deck = this.readDeck(this.claim.dir);
|
|
253
|
-
if (deck === undefined) {
|
|
254
|
-
this.markDirty();
|
|
255
|
-
return;
|
|
256
|
-
}
|
|
257
|
-
// Reload applies the latest deck shape before the panel preserves surviving answers.
|
|
258
|
-
this.adapter.reload(deck);
|
|
259
|
-
this.markDirty();
|
|
260
|
-
}
|
|
261
|
-
readDeck(dir) {
|
|
262
|
-
const deck = readJsonOrNull(deckPath(dir));
|
|
263
|
-
if (deck === null)
|
|
264
|
-
return undefined;
|
|
265
259
|
try {
|
|
266
|
-
|
|
260
|
+
const productKinds = readConfig('user').page_components ?? [];
|
|
261
|
+
const ticket = readPageTicket(this.claim.dir, productKinds);
|
|
262
|
+
this.adapter.reload(ticket, parsePageSource(ticket.document, ticket.manifest, productKinds));
|
|
267
263
|
}
|
|
268
264
|
catch {
|
|
269
|
-
|
|
265
|
+
this.status = 'page could not be read';
|
|
270
266
|
}
|
|
267
|
+
this.markDirty();
|
|
271
268
|
}
|
|
272
269
|
close() {
|
|
273
270
|
if (this.closed)
|
|
@@ -292,7 +289,7 @@ export class InboxController {
|
|
|
292
289
|
return;
|
|
293
290
|
this.submittingDir = claim.dir;
|
|
294
291
|
try {
|
|
295
|
-
await
|
|
292
|
+
await resolveTicketPage(claim.dir, responses);
|
|
296
293
|
}
|
|
297
294
|
catch (error) {
|
|
298
295
|
this.submittingDir = undefined;
|
|
@@ -315,7 +312,7 @@ export class InboxController {
|
|
|
315
312
|
this.markDirty();
|
|
316
313
|
}
|
|
317
314
|
/** Edit the active buffer while the host releases the terminal to $EDITOR. */
|
|
318
|
-
|
|
315
|
+
editActivePageInput() {
|
|
319
316
|
const buffer = this.adapter?.inputBuffer();
|
|
320
317
|
if (buffer === undefined)
|
|
321
318
|
return;
|
|
@@ -359,8 +356,8 @@ export class InboxController {
|
|
|
359
356
|
if (selected === undefined)
|
|
360
357
|
return this.passiveDetailLines(width).slice(0, rows);
|
|
361
358
|
const focusHint = this.focusAvailable(selected.dir) ? ` ${DIM}g${RESET} chat` : '';
|
|
362
|
-
const footer = selected.kind === '
|
|
363
|
-
? [` ${DIM}Enter${RESET} opens the full
|
|
359
|
+
const footer = selected.kind === 'page'
|
|
360
|
+
? [` ${DIM}Enter${RESET} opens the full page ${DIM}j/k${RESET} select ${DIM}x${RESET} cancel`, ` ${DIM}Page:${RESET} c comment u/d scroll${focusHint} ${DIM}q${RESET} close`]
|
|
364
361
|
: [` ${DIM}Enter${RESET} opens the full review ${DIM}j/k${RESET} select ${DIM}x${RESET} cancel`, ` ${focusHint} ${DIM}q${RESET} close`];
|
|
365
362
|
const preview = this.passiveDetailLines(width).slice(0, Math.max(0, rows - footer.length));
|
|
366
363
|
return [...preview, ...footer.map((line) => clipLine(line, width))];
|
|
@@ -394,10 +391,12 @@ export class InboxController {
|
|
|
394
391
|
const lines = [` ${BOLD}${CYAN}${selected.title}${RESET}`, ''];
|
|
395
392
|
for (const rendered of renderMarkdown(selected.subtitle, Math.max(1, width - 2)))
|
|
396
393
|
lines.push(` ${rendered}`);
|
|
397
|
-
const count = selected.kind === '
|
|
398
|
-
? `${selected.
|
|
394
|
+
const count = selected.kind === 'page'
|
|
395
|
+
? `${selected.slotKinds.length} slot${selected.slotKinds.length === 1 ? '' : 's'}`
|
|
399
396
|
: '1 file to review';
|
|
400
397
|
lines.push('', ` ${DIM}${count}${RESET}`);
|
|
398
|
+
if (selected.kind === 'page' && selected.dialect === 'html')
|
|
399
|
+
lines.push(` ${DIM}rich page — open in Northlight${RESET}`);
|
|
401
400
|
return lines.map((line) => clipLine(line, width));
|
|
402
401
|
}
|
|
403
402
|
withStatus(lines) {
|
|
@@ -423,8 +422,8 @@ export class InboxController {
|
|
|
423
422
|
this.selectedWatcher?.close();
|
|
424
423
|
try {
|
|
425
424
|
this.selectedWatcher = watch(dir, (_event, file) => {
|
|
426
|
-
if (file === '
|
|
427
|
-
this.
|
|
425
|
+
if (file === 'page.json') {
|
|
426
|
+
this.reloadSelectedPage();
|
|
428
427
|
return;
|
|
429
428
|
}
|
|
430
429
|
this.invalidate();
|
|
@@ -439,9 +438,9 @@ function visibleWidth(line) { return line.replace(/\x1b\[[0-9;]*m/g, '').length;
|
|
|
439
438
|
function canonicalResultStatus(result) {
|
|
440
439
|
if (result === null)
|
|
441
440
|
return 'ticket was already resolved';
|
|
442
|
-
if (result.kind === 'deck')
|
|
443
|
-
return `ticket was already resolved: ${result.summary}`;
|
|
444
441
|
if (result.kind === 'review')
|
|
445
442
|
return `ticket was already resolved: ${result.result.approved ? 'approved' : 'review submitted'}`;
|
|
443
|
+
if (result.kind === 'page')
|
|
444
|
+
return 'ticket was already resolved: page submitted';
|
|
446
445
|
return 'ticket was already canceled';
|
|
447
446
|
}
|