@north-light/crouter 0.3.244 → 0.3.245
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/clients/attach/viewer.js +64 -64
- package/dist/commands/sys/context/admin/detail-panel.d.ts +3 -1
- package/dist/commands/sys/context/admin/detail-panel.js +4 -1
- package/dist/commands/sys/context/admin/docs-panel.d.ts +10 -13
- package/dist/commands/sys/context/admin/docs-panel.js +48 -61
- package/dist/commands/sys/context/admin/list-view.d.ts +2 -4
- package/dist/commands/sys/context/admin/list-view.js +5 -6
- package/dist/commands/sys/context/admin/model.d.ts +16 -12
- package/dist/commands/sys/context/admin/model.js +17 -30
- package/dist/commands/sys/context/admin/rail-panel.d.ts +43 -7
- package/dist/commands/sys/context/admin/rail-panel.js +104 -37
- package/dist/commands/sys/context/admin/read-view.d.ts +4 -0
- package/dist/commands/sys/context/admin/read-view.js +41 -4
- package/dist/commands/sys/context/admin/shell.d.ts +14 -4
- package/dist/commands/sys/context/admin/shell.js +97 -50
- package/dist/commands/sys/context/doc.js +6 -5
- package/dist/commands/sys/context/resolve.d.ts +7 -3
- package/dist/commands/sys/context/resolve.js +19 -11
- package/dist/commands/sys/panels/profiles-panel.js +2 -1
- package/dist/core/fs-utils.d.ts +3 -0
- package/dist/core/fs-utils.js +10 -1
- package/dist/core/profiles/select.d.ts +0 -3
- package/dist/core/profiles/select.js +1 -11
- package/dist/core/substrate/gate-explain.d.ts +48 -0
- package/dist/core/substrate/gate-explain.js +353 -0
- package/package.json +1 -1
- package/runtime.lock.json +2 -2
|
@@ -1,9 +1,12 @@
|
|
|
1
|
-
// The rail:
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
1
|
+
// The rail: three sections in one row grammar. EVENTS names every surface the
|
|
2
|
+
// runtime delivers on and how many documents each one delivers, and dials which
|
|
3
|
+
// of them the page is planning. SNAPSHOT holds the configuration axes that plan
|
|
4
|
+
// is computed against — changing one re-plans every event. FILTERS holds the
|
|
5
|
+
// question asked of the plan — changing one only changes what is shown. An enum
|
|
6
|
+
// cycles in place; a free-text row opens a field.
|
|
5
7
|
import { truncateToWidth } from '@earendil-works/pi-tui';
|
|
6
8
|
import { padAnsi, theme } from '../../../../core/tui/panel.js';
|
|
9
|
+
import { tildify } from '../../../../core/fs-utils.js';
|
|
7
10
|
import { KINDS, RUNGS, SURFACE_EVENTS } from '../../../../core/substrate/schema.js';
|
|
8
11
|
import { listProfiles } from '../../../../core/profiles/manifest.js';
|
|
9
12
|
import { payloadAxisFor } from './model.js';
|
|
@@ -40,25 +43,11 @@ export function cycleFacet(filters, id, delta) {
|
|
|
40
43
|
return filters;
|
|
41
44
|
}
|
|
42
45
|
}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
}
|
|
47
|
-
/** The rail's rows for a snapshot. The payload row appears only for the events
|
|
48
|
-
* that read one, because on boot there is nothing it could change. */
|
|
46
|
+
/** The configuration rows for a snapshot. The event and its payload are not
|
|
47
|
+
* here — they belong to the EVENTS section, where the payload sits under the
|
|
48
|
+
* count it explains. */
|
|
49
49
|
export function axesOf(snapshot, profileName) {
|
|
50
|
-
const payload = payloadAxisFor(snapshot.event);
|
|
51
|
-
const payloadValue = payload === 'file' ? snapshot.file : payload === 'docName' ? snapshot.docName : payload === 'command' ? snapshot.command : '';
|
|
52
50
|
return [
|
|
53
|
-
{ id: 'event', label: 'event', value: snapshot.event, typed: false },
|
|
54
|
-
...(payload === null
|
|
55
|
-
? []
|
|
56
|
-
: [{
|
|
57
|
-
id: 'payload',
|
|
58
|
-
label: payload === 'file' ? 'file' : payload === 'docName' ? 'doc' : 'command',
|
|
59
|
-
value: payloadValue === '' ? '(none — nothing matches)' : payloadValue,
|
|
60
|
-
typed: true,
|
|
61
|
-
}]),
|
|
62
51
|
{ id: 'kind', label: 'kind', value: snapshot.kind, typed: true },
|
|
63
52
|
{ id: 'mode', label: 'mode', value: snapshot.mode, typed: false },
|
|
64
53
|
{ id: 'lifecycle', label: 'lifecycle', value: snapshot.lifecycle, typed: false },
|
|
@@ -68,6 +57,21 @@ export function axesOf(snapshot, profileName) {
|
|
|
68
57
|
{ id: 'cwd', label: 'cwd', value: tildify(snapshot.cwd), typed: true },
|
|
69
58
|
];
|
|
70
59
|
}
|
|
60
|
+
/** The payload field for the dialled event, or nothing when the event reads no
|
|
61
|
+
* payload — on boot there is nothing it could change. It renders inside the
|
|
62
|
+
* EVENTS section, directly under the count it explains. */
|
|
63
|
+
export function payloadRowOf(snapshot) {
|
|
64
|
+
const payload = payloadAxisFor(snapshot.event);
|
|
65
|
+
if (payload === null)
|
|
66
|
+
return undefined;
|
|
67
|
+
const value = payload === 'file' ? snapshot.file : payload === 'docName' ? snapshot.docName : snapshot.command;
|
|
68
|
+
return {
|
|
69
|
+
id: 'payload',
|
|
70
|
+
label: payload === 'file' ? 'file' : payload === 'docName' ? 'doc' : 'command',
|
|
71
|
+
value: value === '' ? '(none — nothing matches)' : value,
|
|
72
|
+
typed: true,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
71
75
|
/** Cycle one enum axis, returning the changed snapshot. A typed axis is never
|
|
72
76
|
* cycled — the shell opens a field for it instead. */
|
|
73
77
|
export function cycleAxis(snapshot, id, delta) {
|
|
@@ -126,31 +130,94 @@ export function typedAxisValue(snapshot, id) {
|
|
|
126
130
|
return axis === 'file' ? snapshot.file : axis === 'docName' ? snapshot.docName : axis === 'command' ? snapshot.command : '';
|
|
127
131
|
}
|
|
128
132
|
const LABEL_WIDTH = 10;
|
|
129
|
-
/**
|
|
130
|
-
*
|
|
131
|
-
*
|
|
133
|
+
/** A rail row's identity, which the shell holds the cursor by. The list's
|
|
134
|
+
* composition changes whenever the dialled event moves the payload row, so an
|
|
135
|
+
* offset kept across that change points at a different row. */
|
|
136
|
+
export function railRowKey(row) {
|
|
137
|
+
if (row.kind === 'event')
|
|
138
|
+
return `event:${row.event.event}`;
|
|
139
|
+
return row.kind === 'axis' ? `axis:${row.axis.id}` : `facet:${row.facet.id}`;
|
|
140
|
+
}
|
|
141
|
+
/** What enter does on a rail row: an event dials it, a typed row opens its
|
|
142
|
+
* field, and every other row hands the keyboard to the documents. Space does
|
|
143
|
+
* the same thing on the first two and cycles the value on the last. */
|
|
144
|
+
export function railEnter(row) {
|
|
145
|
+
if (row === undefined)
|
|
146
|
+
return 'documents';
|
|
147
|
+
if (row.kind === 'event')
|
|
148
|
+
return 'dial';
|
|
149
|
+
if (row.kind === 'axis')
|
|
150
|
+
return row.axis.typed ? 'edit' : 'documents';
|
|
151
|
+
return row.facet.typed ? 'edit' : 'documents';
|
|
152
|
+
}
|
|
153
|
+
export function railRowsOf(events, payload, axes, facets) {
|
|
154
|
+
const rows = [];
|
|
155
|
+
for (const event of events) {
|
|
156
|
+
rows.push({ kind: 'event', event });
|
|
157
|
+
if (event.dialled && payload !== undefined)
|
|
158
|
+
rows.push({ kind: 'axis', axis: payload, indent: true });
|
|
159
|
+
}
|
|
160
|
+
for (const axis of axes)
|
|
161
|
+
rows.push({ kind: 'axis', axis, indent: false });
|
|
162
|
+
for (const facet of facets)
|
|
163
|
+
rows.push({ kind: 'facet', facet });
|
|
164
|
+
return rows;
|
|
165
|
+
}
|
|
166
|
+
/** All three sections as one column. `index` runs over `rows`, while the
|
|
167
|
+
* section headings and the blank rows between them are drawn but never
|
|
168
|
+
* selectable. */
|
|
132
169
|
export function renderRail(opts) {
|
|
133
|
-
const {
|
|
170
|
+
const { rows, index, width, height, focused, matched, total } = opts;
|
|
134
171
|
const lines = [];
|
|
135
172
|
let cursorLine = 0;
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
const shown = truncateToWidth(value, room);
|
|
140
|
-
const body = ` ${padded} ${shown}`;
|
|
173
|
+
/** `plain` is the uncoloured body without its leading space — what the accent
|
|
174
|
+
* mark recolours, so no inner reset cuts the highlight short. */
|
|
175
|
+
const push = (at, marked, body, dim, plain) => {
|
|
141
176
|
if (at === index)
|
|
142
177
|
cursorLine = lines.length;
|
|
143
178
|
if (at === index && focused)
|
|
144
179
|
lines.push(theme.bg('selectedBg', padAnsi(body, width)));
|
|
145
|
-
else if (at === index)
|
|
146
|
-
lines.push(`${theme.fg('accent', '▌')}${padAnsi(theme.fg('accent',
|
|
180
|
+
else if (at === index || marked) {
|
|
181
|
+
lines.push(`${theme.fg('accent', '▌')}${padAnsi(theme.fg('accent', plain), width - 1)}`);
|
|
182
|
+
}
|
|
147
183
|
else
|
|
148
|
-
lines.push(padAnsi(
|
|
184
|
+
lines.push(padAnsi(dim, width));
|
|
149
185
|
};
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
186
|
+
const labelled = (at, label, value, indent) => {
|
|
187
|
+
const labelRoom = Math.max(1, LABEL_WIDTH - indent);
|
|
188
|
+
const room = Math.max(1, width - LABEL_WIDTH - 2);
|
|
189
|
+
const padded = `${' '.repeat(indent)}${padAnsi(truncateToWidth(label, labelRoom), labelRoom)}`;
|
|
190
|
+
const shown = truncateToWidth(value, room);
|
|
191
|
+
push(at, false, ` ${padded} ${shown}`, ` ${theme.fg('dim', padded)} ${shown}`, `${padded} ${shown}`);
|
|
192
|
+
};
|
|
193
|
+
const evented = (at, row) => {
|
|
194
|
+
const count = row.delivers === undefined ? '…' : String(row.delivers);
|
|
195
|
+
const tone = row.delivers === undefined || row.delivers === 0 ? 'dim' : 'success';
|
|
196
|
+
const room = Math.max(1, width - 2 - count.length);
|
|
197
|
+
const name = padAnsi(truncateToWidth(row.event, room), room);
|
|
198
|
+
const toned = theme.fg(tone, count);
|
|
199
|
+
// The dialled event wears the same mark a selected row wears, because it IS
|
|
200
|
+
// the selection every other pane is drawn against.
|
|
201
|
+
push(at, row.dialled, ` ${name} ${toned}`, ` ${theme.fg('dim', name)} ${toned}`, `${name} ${count}`);
|
|
202
|
+
};
|
|
203
|
+
const sectionOf = (row) => row.kind === 'facet' ? 'filters' : row.kind === 'event' || row.indent ? 'events' : 'snapshot';
|
|
204
|
+
const headings = { events: ' EVENTS', snapshot: ' SNAPSHOT', filters: ` FILTERS · ${matched} of ${total}` };
|
|
205
|
+
let section;
|
|
206
|
+
rows.forEach((row, at) => {
|
|
207
|
+
const belongs = sectionOf(row);
|
|
208
|
+
if (belongs !== section) {
|
|
209
|
+
if (lines.length > 0)
|
|
210
|
+
lines.push('');
|
|
211
|
+
lines.push(theme.fg('dim', headings[belongs]), '');
|
|
212
|
+
section = belongs;
|
|
213
|
+
}
|
|
214
|
+
if (row.kind === 'event')
|
|
215
|
+
evented(at, row.event);
|
|
216
|
+
else if (row.kind === 'axis')
|
|
217
|
+
labelled(at, row.axis.label, row.axis.value, row.indent ? 2 : 0);
|
|
218
|
+
else
|
|
219
|
+
labelled(at, row.facet.label, row.facet.value, 0);
|
|
220
|
+
});
|
|
154
221
|
const maxStart = Math.max(0, lines.length - height);
|
|
155
222
|
const start = Math.min(Math.max(0, cursorLine - Math.floor(height / 2)), maxStart);
|
|
156
223
|
const out = lines.slice(start, start + height);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type Rung, type SurfaceEvent } from '../../../../core/substrate/schema.js';
|
|
2
2
|
import type { DeliveryRecord } from '../../../../core/substrate/plan.js';
|
|
3
|
+
import type { NodeConfigSubject } from '../../../../core/substrate/subject-fields.js';
|
|
3
4
|
/** The profile project entry whose `memory` rung ceilings this document's
|
|
4
5
|
* store, or null when no project governs it. */
|
|
5
6
|
export interface CapOwner {
|
|
@@ -13,6 +14,9 @@ export interface DossierContext {
|
|
|
13
14
|
records: Map<SurfaceEvent, DeliveryRecord>;
|
|
14
15
|
capOwner: CapOwner | null;
|
|
15
16
|
readOnly: boolean;
|
|
17
|
+
/** The configuration every record here was planned against — what a failed
|
|
18
|
+
* gate is explained against. Null when there is no node config at all. */
|
|
19
|
+
subject: NodeConfigSubject | null;
|
|
16
20
|
}
|
|
17
21
|
/** The resting card, and — when `bodyOpen` — the document's own text beneath
|
|
18
22
|
* it in place of the affordance line that names it. */
|
|
@@ -13,6 +13,7 @@ import { getMarkdownTheme } from '@earendil-works/pi-coding-agent';
|
|
|
13
13
|
import { Markdown, visibleWidth, wrapTextWithAnsi } from '@earendil-works/pi-tui';
|
|
14
14
|
import { theme, wrapText } from '../../../../core/tui/panel.js';
|
|
15
15
|
import { SURFACE_EVENTS, rungRank } from '../../../../core/substrate/schema.js';
|
|
16
|
+
import { allMismatches, CLAUSE_SEPARATOR, explainRecordGate, formatClause, } from '../../../../core/substrate/gate-explain.js';
|
|
16
17
|
import { formatCost, plural, rungCostsOf } from './model.js';
|
|
17
18
|
/** Read-only, at rest: no word and no sentence. The refusal itself is a notice
|
|
18
19
|
* fired at the moment a write is attempted. */
|
|
@@ -107,10 +108,29 @@ export function renderDossierDelivery(record, context, width) {
|
|
|
107
108
|
const warn = (text) => {
|
|
108
109
|
out.push(...indent(wrapped(text, inner)).map((line) => theme.fg('warning', line)));
|
|
109
110
|
};
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
111
|
+
let unexplained = false;
|
|
112
|
+
const gateBlock = (side, noun) => {
|
|
113
|
+
const outcome = side === 'doc' ? record.docGate : record.entryGate;
|
|
114
|
+
if (outcome === null || outcome.pass)
|
|
115
|
+
return [];
|
|
116
|
+
const clauses = explainRecordGate(record, side, context.subject);
|
|
117
|
+
if (clauses.length === 0) {
|
|
118
|
+
unexplained = true;
|
|
119
|
+
warn(`${noun} fails: ${outcome.reason}`);
|
|
120
|
+
return clauses;
|
|
121
|
+
}
|
|
122
|
+
// A gate carrying a defect can never match ANY node, so it names a
|
|
123
|
+
// different remedy than a node that merely does not qualify.
|
|
124
|
+
warn(allMismatches(clauses) ? `${noun} fails` : `${noun} never matches`);
|
|
125
|
+
for (const clause of clauses) {
|
|
126
|
+
out.push(...clauseRows(formatClause(clause), inner).map((line) => theme.fg('warning', line)));
|
|
127
|
+
}
|
|
128
|
+
return clauses;
|
|
129
|
+
};
|
|
130
|
+
const failing = [...gateBlock('doc', 'gate'), ...gateBlock('entry', 'entry gate')];
|
|
131
|
+
if (!unexplained && allMismatches(failing)) {
|
|
132
|
+
out.push(...dim('dial the snapshot at left to load it here', width));
|
|
133
|
+
}
|
|
114
134
|
if (record.matchedEntry === null && record.authoredEntries.length > 0) {
|
|
115
135
|
out.push(...indent(wrapped(`${plural(record.authoredEntries.length, 'entry', 'entries')} for this event, none matched`, inner)).map((line) => theme.fg('dim', line)));
|
|
116
136
|
}
|
|
@@ -138,6 +158,23 @@ export function bodyLineCount(body) {
|
|
|
138
158
|
function indent(lines) {
|
|
139
159
|
return lines.map((line) => `${INDENT}${line}`);
|
|
140
160
|
}
|
|
161
|
+
/** Every column a clause line under a gate header carries. */
|
|
162
|
+
const CLAUSE_INDENT = `${INDENT} `;
|
|
163
|
+
/** One clause, kept on one row while the pane can hold it. Too wide, it breaks
|
|
164
|
+
* at the grammar's own separator — the reader's value on one row, the gate's demand beneath it —
|
|
165
|
+
* and each half wraps rather than losing a character to an ellipsis. */
|
|
166
|
+
function clauseRows(clause, inner) {
|
|
167
|
+
const room = Math.max(1, inner - 2);
|
|
168
|
+
if (visibleWidth(clause) <= room)
|
|
169
|
+
return [`${CLAUSE_INDENT}${clause}`];
|
|
170
|
+
const at = clause.indexOf(CLAUSE_SEPARATOR);
|
|
171
|
+
if (at === -1)
|
|
172
|
+
return wrapped(clause, room).map((line) => `${CLAUSE_INDENT}${line}`);
|
|
173
|
+
return [
|
|
174
|
+
...wrapped(clause.slice(0, at), room).map((line) => `${CLAUSE_INDENT}${line}`),
|
|
175
|
+
...wrapped(clause.slice(at + 1), Math.max(1, room - 2)).map((line) => `${CLAUSE_INDENT} ${line}`),
|
|
176
|
+
];
|
|
177
|
+
}
|
|
141
178
|
/** `wrapText` is the page's prose wrapper, and its word-oriented contract
|
|
142
179
|
* deliberately leaves a filesystem path or a compact JSON predicate intact.
|
|
143
180
|
* Values carrying such a token go through pi-tui's character-safe wrapper
|
|
@@ -44,6 +44,11 @@ export declare class ContextAdminShell implements Component, Focusable {
|
|
|
44
44
|
private setFilters;
|
|
45
45
|
private syncDetail;
|
|
46
46
|
private setSnapshot;
|
|
47
|
+
/** Put the rail cursor back on the row it was on, since dialling a different
|
|
48
|
+
* event moves the payload row and every offset below it. The payload belongs
|
|
49
|
+
* to the dialled event, so when the new event reads none the cursor falls to
|
|
50
|
+
* that event's own row rather than to whatever slid into the offset. */
|
|
51
|
+
private holdRail;
|
|
47
52
|
private matches;
|
|
48
53
|
private label;
|
|
49
54
|
private isDown;
|
|
@@ -54,8 +59,13 @@ export declare class ContextAdminShell implements Component, Focusable {
|
|
|
54
59
|
* drawn against, so it earns the page-level gesture. */
|
|
55
60
|
private cyclesEvent;
|
|
56
61
|
handleInput(data: string): void;
|
|
57
|
-
/**
|
|
58
|
-
*
|
|
62
|
+
/** Every surface event with what it delivers, which is what the rail states
|
|
63
|
+
* instead of the list drawing a cell per event on every row. An event whose
|
|
64
|
+
* plan has not warmed yet has no count to state. */
|
|
65
|
+
private eventRows;
|
|
66
|
+
/** The rail's rows: the events, then the snapshot axes, then the filter
|
|
67
|
+
* facets, so one cursor and one gesture drive all three sections. */
|
|
68
|
+
private railRows;
|
|
59
69
|
private handleRail;
|
|
60
70
|
private handleDocs;
|
|
61
71
|
private openSearch;
|
|
@@ -102,8 +112,8 @@ export declare class ContextAdminShell implements Component, Focusable {
|
|
|
102
112
|
private handleModal;
|
|
103
113
|
private renderModal;
|
|
104
114
|
private footer;
|
|
105
|
-
/** What the focused row does when opened. A directory
|
|
106
|
-
*
|
|
115
|
+
/** What the focused row does when opened. A directory expands; a document
|
|
116
|
+
* opens its dossier. */
|
|
107
117
|
private docsGesture;
|
|
108
118
|
render(width: number): string[];
|
|
109
119
|
/** What the delivering documents spend on this event. An estimate, and a
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
// The `crtr sys context admin` page: a rail, the document
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
1
|
+
// The `crtr sys context admin` page: a rail, the document list, and the dossier
|
|
2
|
+
// that edits one document's routing. The rail decides WHAT is being planned and
|
|
3
|
+
// WHICH slice of it is shown, the list states what each document does on the
|
|
4
|
+
// dialled event, and the dossier is the only place a write starts.
|
|
5
5
|
import { Input, matchesKey, truncateToWidth, visibleWidth, } from '@earendil-works/pi-tui';
|
|
6
6
|
import { formatGesture, matchesPiTuiInput, } from '../../../../core/keybindings/index.js';
|
|
7
7
|
import { runTuiPage } from '../../../../core/tui/page-host.js';
|
|
@@ -10,7 +10,7 @@ import { RUNGS, SURFACE_EVENTS, SURFACE_RUNGS, } from '../../../../core/substrat
|
|
|
10
10
|
import { profileNameFor } from '../../../../core/substrate/subject-fields.js';
|
|
11
11
|
import { editBufferInEditor } from '../../../../clients/inbox/review/roundtrip.js';
|
|
12
12
|
import { capOwnerFor, moveDoc, readBody, readOnlyReason, toggleFlag, writeBody, writeCap, writeGate, writeRoutingLine, writeShortForm, writeSurfaces, } from './actions.js';
|
|
13
|
-
import { axesOf, cycleAxis, cycleFacet, facetsOf, renderRail, setTypedAxis, typedAxisValue, } from './rail-panel.js';
|
|
13
|
+
import { axesOf, cycleAxis, cycleFacet, facetsOf, payloadRowOf, railEnter, railRowKey, railRowsOf, renderRail, setTypedAxis, typedAxisValue, } from './rail-panel.js';
|
|
14
14
|
import { ContextDetailPanel } from './detail-panel.js';
|
|
15
15
|
import { ContextDocsPanel, buildRows } from './docs-panel.js';
|
|
16
16
|
import { NO_FILTERS, costTotals } from './list-view.js';
|
|
@@ -20,8 +20,8 @@ const RAIL_MIN_TOTAL_WIDTH = 86;
|
|
|
20
20
|
const THREE_COLUMN_MIN_WIDTH = 112;
|
|
21
21
|
/** Columns the two dividers between the three zones spend. */
|
|
22
22
|
const DIVIDER_WIDTH = 6;
|
|
23
|
-
/** Below the floor a row cannot hold a name beside
|
|
24
|
-
*
|
|
23
|
+
/** Below the floor a row cannot hold a name beside its state; above the ceiling
|
|
24
|
+
* extra width stops buying legibility and the dossier wants it. */
|
|
25
25
|
const DOCS_MIN_WIDTH = 48;
|
|
26
26
|
const DOCS_MAX_WIDTH = 100;
|
|
27
27
|
const DOCS_SHARE = 0.45;
|
|
@@ -78,14 +78,14 @@ export class ContextAdminShell {
|
|
|
78
78
|
// planning
|
|
79
79
|
/** Recompute the focused event's plan, refresh the panels from it, and let
|
|
80
80
|
* the remaining events fill in behind it. */
|
|
81
|
-
rebuild(
|
|
81
|
+
rebuild() {
|
|
82
82
|
this.plans.plan(this.snapshot.event);
|
|
83
83
|
this.rows = buildRows(this.plans, this.snapshot.event);
|
|
84
|
-
this.docs.setRows(this.rows, this.snapshot.event
|
|
84
|
+
this.docs.setRows(this.rows, this.snapshot.event);
|
|
85
85
|
this.syncDetail();
|
|
86
86
|
this.plans.warm(() => {
|
|
87
87
|
this.rows = buildRows(this.plans, this.snapshot.event);
|
|
88
|
-
this.docs.setRows(this.rows, this.snapshot.event
|
|
88
|
+
this.docs.setRows(this.rows, this.snapshot.event);
|
|
89
89
|
this.syncDetail();
|
|
90
90
|
this.tui.requestRender();
|
|
91
91
|
});
|
|
@@ -101,13 +101,29 @@ export class ContextAdminShell {
|
|
|
101
101
|
const row = this.docs.selected;
|
|
102
102
|
const capOwner = row === undefined ? null : capOwnerFor(row.anchor, this.snapshot.profileId);
|
|
103
103
|
const readOnly = row === undefined ? null : readOnlyReason(row.anchor, this.snapshot.profileId);
|
|
104
|
-
|
|
104
|
+
// The plan's own subject, not a re-derived one: what a failed gate is
|
|
105
|
+
// explained against must be exactly what it was judged against.
|
|
106
|
+
this.detail.setContext(row, this.snapshot.event, capOwner, readOnly, this.plans.plan(this.snapshot.event).subject);
|
|
105
107
|
}
|
|
106
108
|
setSnapshot(next) {
|
|
109
|
+
const held = this.railRows()[this.railIndex];
|
|
107
110
|
this.snapshot = next;
|
|
108
111
|
this.plans.setSnapshot(next);
|
|
109
112
|
this.notice = undefined;
|
|
110
113
|
this.rebuild();
|
|
114
|
+
this.holdRail(held);
|
|
115
|
+
}
|
|
116
|
+
/** Put the rail cursor back on the row it was on, since dialling a different
|
|
117
|
+
* event moves the payload row and every offset below it. The payload belongs
|
|
118
|
+
* to the dialled event, so when the new event reads none the cursor falls to
|
|
119
|
+
* that event's own row rather than to whatever slid into the offset. */
|
|
120
|
+
holdRail(held) {
|
|
121
|
+
if (held === undefined)
|
|
122
|
+
return;
|
|
123
|
+
const rows = this.railRows();
|
|
124
|
+
const key = railRowKey(held);
|
|
125
|
+
const at = rows.findIndex((row) => railRowKey(row) === key);
|
|
126
|
+
this.railIndex = Math.max(0, at >= 0 ? at : rows.findIndex((row) => row.kind === 'event' && row.event.dialled));
|
|
111
127
|
}
|
|
112
128
|
// input
|
|
113
129
|
matches(id, data) {
|
|
@@ -172,31 +188,47 @@ export class ContextAdminShell {
|
|
|
172
188
|
else
|
|
173
189
|
this.handleDetail(data);
|
|
174
190
|
}
|
|
175
|
-
/**
|
|
176
|
-
*
|
|
191
|
+
/** Every surface event with what it delivers, which is what the rail states
|
|
192
|
+
* instead of the list drawing a cell per event on every row. An event whose
|
|
193
|
+
* plan has not warmed yet has no count to state. */
|
|
194
|
+
eventRows() {
|
|
195
|
+
return SURFACE_EVENTS.map((event) => {
|
|
196
|
+
const plan = this.plans.peek(event);
|
|
197
|
+
return {
|
|
198
|
+
event,
|
|
199
|
+
delivers: plan === undefined ? undefined : plan.docs.filter((record) => record.finalRung !== 'none').length,
|
|
200
|
+
dialled: event === this.snapshot.event,
|
|
201
|
+
};
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
/** The rail's rows: the events, then the snapshot axes, then the filter
|
|
205
|
+
* facets, so one cursor and one gesture drive all three sections. */
|
|
206
|
+
railRows() {
|
|
207
|
+
return railRowsOf(this.eventRows(), payloadRowOf(this.snapshot), axesOf(this.snapshot, profileNameFor(this.snapshot.profileId) ?? 'none'), facetsOf(this.filters));
|
|
208
|
+
}
|
|
177
209
|
handleRail(data) {
|
|
178
|
-
const
|
|
179
|
-
|
|
180
|
-
const
|
|
181
|
-
this.railIndex = Math.max(0, Math.min(this.railIndex, total - 1));
|
|
182
|
-
const axis = axes[this.railIndex];
|
|
183
|
-
const facet = facets[this.railIndex - axes.length];
|
|
210
|
+
const rows = this.railRows();
|
|
211
|
+
this.railIndex = Math.max(0, Math.min(this.railIndex, rows.length - 1));
|
|
212
|
+
const row = rows[this.railIndex];
|
|
184
213
|
const change = (delta) => {
|
|
185
|
-
if (
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
if (facet.typed)
|
|
193
|
-
this.openSearch();
|
|
214
|
+
if (row === undefined)
|
|
215
|
+
return;
|
|
216
|
+
if (row.kind === 'event')
|
|
217
|
+
this.setSnapshot({ ...this.snapshot, event: row.event.event });
|
|
218
|
+
else if (row.kind === 'axis') {
|
|
219
|
+
if (row.axis.typed)
|
|
220
|
+
this.openAxisField(row.axis.id, row.axis.label);
|
|
194
221
|
else
|
|
195
|
-
this.
|
|
222
|
+
this.setSnapshot(cycleAxis(this.snapshot, row.axis.id, delta));
|
|
196
223
|
}
|
|
224
|
+
else if (row.facet.typed)
|
|
225
|
+
this.openSearch();
|
|
226
|
+
else
|
|
227
|
+
this.setFilters(cycleFacet(this.filters, row.facet.id, delta));
|
|
197
228
|
};
|
|
229
|
+
const onEnter = railEnter(row);
|
|
198
230
|
if (this.isDown(data)) {
|
|
199
|
-
this.railIndex = Math.min(
|
|
231
|
+
this.railIndex = Math.min(rows.length - 1, this.railIndex + 1);
|
|
200
232
|
}
|
|
201
233
|
else if (this.isUp(data)) {
|
|
202
234
|
this.railIndex = Math.max(0, this.railIndex - 1);
|
|
@@ -205,7 +237,7 @@ export class ContextAdminShell {
|
|
|
205
237
|
change(1);
|
|
206
238
|
}
|
|
207
239
|
else if (this.isDescend(data)) {
|
|
208
|
-
if (matchesKey(data, 'enter') &&
|
|
240
|
+
if (matchesKey(data, 'enter') && onEnter !== 'documents')
|
|
209
241
|
change(1);
|
|
210
242
|
else
|
|
211
243
|
this.zone = 'docs';
|
|
@@ -231,9 +263,8 @@ export class ContextAdminShell {
|
|
|
231
263
|
this.syncDetail();
|
|
232
264
|
}
|
|
233
265
|
else if (this.isDescend(data)) {
|
|
234
|
-
//
|
|
235
|
-
|
|
236
|
-
if (this.docs.onGroup && !this.docs.onDocument) {
|
|
266
|
+
// One gesture per row: a header expands, a document opens its dossier.
|
|
267
|
+
if (this.docs.onGroup) {
|
|
237
268
|
this.docs.toggleGroup();
|
|
238
269
|
this.syncDetail();
|
|
239
270
|
}
|
|
@@ -695,7 +726,7 @@ export class ContextAdminShell {
|
|
|
695
726
|
return out.slice(0, height);
|
|
696
727
|
}
|
|
697
728
|
// rendering
|
|
698
|
-
footer(width) {
|
|
729
|
+
footer(width, railRow) {
|
|
699
730
|
if (this.busy !== undefined)
|
|
700
731
|
return theme.fg('warning', this.busy);
|
|
701
732
|
if (this.modal?.kind === 'choice')
|
|
@@ -710,7 +741,7 @@ export class ContextAdminShell {
|
|
|
710
741
|
return theme.fg(this.notice.ok ? 'success' : 'warning', truncateToWidth(this.notice.text, width));
|
|
711
742
|
const move = `${this.label('crtr.setup.list-down', 'j')}/${this.label('crtr.setup.list-up', 'k')}`;
|
|
712
743
|
const left = this.zone === 'rail'
|
|
713
|
-
? `${move} row ·
|
|
744
|
+
? `${move} row · ${railGesture(railRow)}`
|
|
714
745
|
: this.zone === 'docs'
|
|
715
746
|
? `${move} row · ${this.docsGesture()} · / search`
|
|
716
747
|
: this.detail.editing
|
|
@@ -724,23 +755,19 @@ export class ContextAdminShell {
|
|
|
724
755
|
? `${theme.fg('dim', left)}${' '.repeat(gap)}${theme.fg('dim', right)}`
|
|
725
756
|
: theme.fg('dim', truncateToWidth(`${left} · ${this.label('crtr.setup.cancel', 'q')} close`, width));
|
|
726
757
|
}
|
|
727
|
-
/** What the focused row does when opened. A directory
|
|
728
|
-
*
|
|
758
|
+
/** What the focused row does when opened. A directory expands; a document
|
|
759
|
+
* opens its dossier. */
|
|
729
760
|
docsGesture() {
|
|
730
|
-
|
|
731
|
-
return 'enter dossier';
|
|
732
|
-
return this.docs.onDocument ? 'space open · enter dossier' : 'space/enter open';
|
|
761
|
+
return this.docs.onGroup ? 'space/enter open' : 'enter dossier';
|
|
733
762
|
}
|
|
734
763
|
render(width) {
|
|
735
764
|
const height = this.terminalRows();
|
|
736
765
|
const bodyHeight = Math.max(1, height - PAGE_CHROME_ROWS);
|
|
737
766
|
const railVisible = width >= RAIL_MIN_TOTAL_WIDTH;
|
|
738
|
-
const
|
|
739
|
-
|
|
740
|
-
this.railIndex = Math.max(0, Math.min(this.railIndex, axes.length + facets.length - 1));
|
|
767
|
+
const rows = this.railRows();
|
|
768
|
+
this.railIndex = Math.max(0, Math.min(this.railIndex, rows.length - 1));
|
|
741
769
|
const rail = (railWidth) => renderRail({
|
|
742
|
-
|
|
743
|
-
facets,
|
|
770
|
+
rows,
|
|
744
771
|
index: this.railIndex,
|
|
745
772
|
width: railWidth,
|
|
746
773
|
height: bodyHeight,
|
|
@@ -752,7 +779,7 @@ export class ContextAdminShell {
|
|
|
752
779
|
if (width >= THREE_COLUMN_MIN_WIDTH) {
|
|
753
780
|
const docsWidth = docsWidthFor(width);
|
|
754
781
|
const detailWidth = Math.max(1, width - RAIL_WIDTH - docsWidth - DIVIDER_WIDTH);
|
|
755
|
-
const middle = columns(this.docs.render(docsWidth, bodyHeight, this.zone === 'docs'
|
|
782
|
+
const middle = columns(this.docs.render(docsWidth, bodyHeight, this.zone === 'docs'), docsWidth, this.modal === undefined
|
|
756
783
|
? this.detail.render(detailWidth, bodyHeight, this.zone === 'detail')
|
|
757
784
|
: this.renderModal(detailWidth, bodyHeight), detailWidth);
|
|
758
785
|
body = columns(rail(RAIL_WIDTH), RAIL_WIDTH, middle, width - RAIL_WIDTH - 3);
|
|
@@ -763,7 +790,7 @@ export class ContextAdminShell {
|
|
|
763
790
|
? this.renderModal(panelWidth, bodyHeight)
|
|
764
791
|
: this.zone === 'detail'
|
|
765
792
|
? this.detail.render(panelWidth, bodyHeight, true)
|
|
766
|
-
: this.docs.render(panelWidth, bodyHeight, this.zone === 'docs'
|
|
793
|
+
: this.docs.render(panelWidth, bodyHeight, this.zone === 'docs');
|
|
767
794
|
body = columns(rail(RAIL_WIDTH), RAIL_WIDTH, panel, panelWidth);
|
|
768
795
|
}
|
|
769
796
|
else if (this.modal !== undefined) {
|
|
@@ -776,7 +803,7 @@ export class ContextAdminShell {
|
|
|
776
803
|
body = this.detail.render(width, bodyHeight, true);
|
|
777
804
|
}
|
|
778
805
|
else {
|
|
779
|
-
body = this.docs.render(width, bodyHeight, true
|
|
806
|
+
body = this.docs.render(width, bodyHeight, true);
|
|
780
807
|
}
|
|
781
808
|
const pending = SURFACE_EVENTS.filter((event) => this.plans.peek(event) === undefined).length;
|
|
782
809
|
const meta = [
|
|
@@ -787,7 +814,14 @@ export class ContextAdminShell {
|
|
|
787
814
|
]
|
|
788
815
|
.filter((part) => part !== '')
|
|
789
816
|
.join(' · ');
|
|
790
|
-
return pageFrame({
|
|
817
|
+
return pageFrame({
|
|
818
|
+
width,
|
|
819
|
+
height,
|
|
820
|
+
title: 'crouter context',
|
|
821
|
+
meta,
|
|
822
|
+
body,
|
|
823
|
+
footer: this.footer(width, rows[this.railIndex]),
|
|
824
|
+
});
|
|
791
825
|
}
|
|
792
826
|
/** What the delivering documents spend on this event. An estimate, and a
|
|
793
827
|
* floor on the rendered prompt: boot wraps these documents in its own intro
|
|
@@ -803,6 +837,19 @@ export class ContextAdminShell {
|
|
|
803
837
|
return `docs load ${formatCost(total)} — ${split}`;
|
|
804
838
|
}
|
|
805
839
|
}
|
|
840
|
+
/** What the focused rail row does, which differs by row: an event dials, a
|
|
841
|
+
* typed row opens its field, and an enum cycles in place with enter left free
|
|
842
|
+
* to move zones. */
|
|
843
|
+
function railGesture(row) {
|
|
844
|
+
switch (railEnter(row)) {
|
|
845
|
+
case 'dial':
|
|
846
|
+
return 'space/enter dial · l documents';
|
|
847
|
+
case 'edit':
|
|
848
|
+
return 'space/enter edit · l documents';
|
|
849
|
+
default:
|
|
850
|
+
return 'space change · enter documents';
|
|
851
|
+
}
|
|
852
|
+
}
|
|
806
853
|
/** Every field here is an edit-or-accept, never a blank to retype, so the
|
|
807
854
|
* cursor lands at the end (`\x05`) — `setValue` alone leaves it at 0, where
|
|
808
855
|
* typing prepends and backspace does nothing. */
|
|
@@ -7,6 +7,7 @@ import { realpathOrSelf } from '../../../core/fs-utils.js';
|
|
|
7
7
|
import { resolveMemoryDocForTarget } from '../../../core/memory-resolver.js';
|
|
8
8
|
import { InputError } from '../../../core/io.js';
|
|
9
9
|
import { planDelivery } from '../../../core/substrate/plan.js';
|
|
10
|
+
import { explainRecordGate } from '../../../core/substrate/gate-explain.js';
|
|
10
11
|
import { parseSubstrateDoc, SURFACE_EVENTS } from '../../../core/substrate/schema.js';
|
|
11
12
|
import { buildSnapshot, cell, delivers, describeSubject, entryText, exclusionReason, gateText, optionalString, payloadFor, subjectSummary, } from './resolve.js';
|
|
12
13
|
export const docLeaf = defineLeaf({
|
|
@@ -58,7 +59,7 @@ export const docLeaf = defineLeaf({
|
|
|
58
59
|
const recordPathCache = new Map();
|
|
59
60
|
const rows = events.map((event) => {
|
|
60
61
|
const plan = planDelivery(snapshot.subject, snapshot.target, event, payloadFor(event, flags, snapshot.target));
|
|
61
|
-
return eventRow(event, findRecord(plan.docs, targetPath, recordPathCache));
|
|
62
|
+
return eventRow(event, findRecord(plan.docs, targetPath, recordPathCache), plan.subject);
|
|
62
63
|
});
|
|
63
64
|
return {
|
|
64
65
|
name: doc.name,
|
|
@@ -111,7 +112,7 @@ function surfaceRow(entry) {
|
|
|
111
112
|
at: entry.at,
|
|
112
113
|
};
|
|
113
114
|
}
|
|
114
|
-
function eventRow(event, record) {
|
|
115
|
+
function eventRow(event, record, subject) {
|
|
115
116
|
if (record === null) {
|
|
116
117
|
return {
|
|
117
118
|
event,
|
|
@@ -133,12 +134,12 @@ function eventRow(event, record) {
|
|
|
133
134
|
authored_rung: record.authoredRung,
|
|
134
135
|
capped_rung: record.cappedRung,
|
|
135
136
|
final_rung: record.finalRung,
|
|
136
|
-
doc_gate: gateText(record.docGate),
|
|
137
|
-
entry_gate: record.entryGate === null ? null : gateText(record.entryGate),
|
|
137
|
+
doc_gate: gateText(record.docGate, explainRecordGate(record, 'doc', subject)),
|
|
138
|
+
entry_gate: record.entryGate === null ? null : gateText(record.entryGate, explainRecordGate(record, 'entry', subject)),
|
|
138
139
|
matched_entry: entryText(record.matchedEntry),
|
|
139
140
|
winner: record.winner,
|
|
140
141
|
shadowed_by: record.shadowedBy === null ? null : `${record.shadowedBy.scope}:${record.shadowedBy.path}`,
|
|
141
|
-
excluded: delivers(record) ? null : exclusionReason(record),
|
|
142
|
+
excluded: delivers(record) ? null : exclusionReason(record, subject),
|
|
142
143
|
};
|
|
143
144
|
}
|
|
144
145
|
function corpusNote(event) {
|