@north-light/crouter 0.3.244 → 0.3.246

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.
@@ -1,9 +1,12 @@
1
- // The rail: two sections in one row grammar. SNAPSHOT holds the configuration
2
- // axes the page plans against — changing one re-plans every event. FILTERS holds
3
- // the question asked of that plan — changing one only changes what is shown.
4
- // An enum cycles in place; a free-text row opens a field.
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
- function tildify(path) {
44
- const home = process.env['HOME'];
45
- return home !== undefined && home !== '' && path.startsWith(home) ? `~${path.slice(home.length)}` : path;
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
- /** Both sections as one column. `index` runs over the axes and then the facets
130
- * — the order the shell routes a cycle in — while the section headings and the
131
- * blank rows between them are drawn but never selectable. */
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 { axes, facets, index, width, height, focused, matched, total } = opts;
170
+ const { rows, index, width, height, focused, matched, total } = opts;
134
171
  const lines = [];
135
172
  let cursorLine = 0;
136
- const push = (at, label, value) => {
137
- const room = Math.max(1, width - LABEL_WIDTH - 2);
138
- const padded = padAnsi(truncateToWidth(label, LABEL_WIDTH), LABEL_WIDTH);
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', body.slice(1)), width - 1)}`);
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(` ${theme.fg('dim', padded)} ${shown}`, width));
184
+ lines.push(padAnsi(dim, width));
149
185
  };
150
- lines.push(theme.fg('dim', ' SNAPSHOT'), '');
151
- axes.forEach((axis, at) => push(at, axis.label, axis.value));
152
- lines.push('', theme.fg('dim', ` FILTERS · ${matched} of ${total}`), '');
153
- facets.forEach((facet, at) => push(axes.length + at, facet.label, facet.value));
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
- if (!record.docGate.pass)
111
- warn(`gate fails: ${record.docGate.reason}`);
112
- if (record.entryGate !== null && !record.entryGate.pass)
113
- warn(`entry gate fails: ${record.entryGate.reason}`);
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
- /** The rail's rows are the snapshot axes followed by the filter facets, so
58
- * one cursor and one gesture drive both sections. */
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 answers to space, a
106
- * document to enter, and a directory that is also a document to both. */
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 matrix, and the
2
- // dossier that edits one document's routing. The rail decides WHAT is being
3
- // planned and WHICH slice of it is shown, the matrix shows WHERE every document
4
- // lands, and the dossier is the only place a write starts.
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 the matrix; above the
24
- * ceiling extra width stops buying legibility and the dossier wants it. */
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(selectedKey) {
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, selectedKey ?? this.docs.selected?.key);
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, this.docs.selected?.key);
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
- this.detail.setContext(row, this.snapshot.event, capOwner, readOnly);
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
- /** The rail's rows are the snapshot axes followed by the filter facets, so
176
- * one cursor and one gesture drive both sections. */
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 axes = axesOf(this.snapshot, profileNameFor(this.snapshot.profileId) ?? 'none');
179
- const facets = facetsOf(this.filters);
180
- const total = axes.length + facets.length;
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 (axis !== undefined) {
186
- if (axis.typed)
187
- this.openAxisField(axis.id, axis.label);
188
- else
189
- this.setSnapshot(cycleAxis(this.snapshot, axis.id, delta));
190
- }
191
- else if (facet !== undefined) {
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.setFilters(cycleFacet(this.filters, facet.id, delta));
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(total - 1, this.railIndex + 1);
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') && (axis?.typed === true || facet?.typed === true))
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
- // A directory that is also a document keeps both gestures: space opens it,
235
- // and descending goes to its dossier like any other document.
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 · space change · enter documents`
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 answers to space, a
728
- * document to enter, and a directory that is also a document to both. */
758
+ /** What the focused row does when opened. A directory expands; a document
759
+ * opens its dossier. */
729
760
  docsGesture() {
730
- if (!this.docs.onGroup)
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 axes = axesOf(this.snapshot, profileNameFor(this.snapshot.profileId) ?? 'none');
739
- const facets = facetsOf(this.filters);
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
- axes,
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', this.plans), docsWidth, this.modal === undefined
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', this.plans);
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, this.plans);
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({ width, height, title: 'crouter context', meta, body, footer: this.footer(width) });
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) {