@north-light/crouter 0.3.243 → 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.
@@ -1,11 +1,12 @@
1
1
  // The middle column: every document the snapshot can see, grouped recursively
2
- // by the directories its canonical name names, and each carrying its own
3
- // six-event matrix. One row is one physical document, so two stores answering
4
- // to the same canonical name stay two rows and the shadowed one is visible.
2
+ // by the directories its canonical name names, each stating in one word what it
3
+ // does on the dialled event and what that costs. One row is one physical
4
+ // document, so two stores answering to the same canonical name stay two rows
5
+ // and the shadowed one is visible.
5
6
  import { truncateToWidth, visibleWidth } from '@earendil-works/pi-tui';
6
7
  import { padAnsi, theme, visibleRange } from '../../../../core/tui/panel.js';
7
8
  import { SURFACE_EVENTS } from '../../../../core/substrate/schema.js';
8
- import { EVENT_INITIALS, cellGlyph, cellStateOf, formatCost, plural } from './model.js';
9
+ import { deliveryStateOf, formatCost, plural, stateLabel } from './model.js';
9
10
  import { NO_FILTERS, buildListView, groupKeysOf } from './list-view.js';
10
11
  /** The union of every planned event's corpus, ordered by the focused event's
11
12
  * precedence order with the documents only other events see appended. */
@@ -43,13 +44,10 @@ export function buildRows(plans, event) {
43
44
  absorb(other);
44
45
  return order.map((key) => rows.get(key));
45
46
  }
46
- /** Cells and their width, so the shell's legend and the list agree. */
47
- const CELL_COUNT = SURFACE_EVENTS.length;
48
- const MATRIX_WIDTH = CELL_COUNT * 2 - 1;
47
+ /** The word a row's state prints in, wide enough for `shadowed` and for the
48
+ * `N of M` a group rolls its subtree up to. */
49
+ const STATE_WIDTH = 10;
49
50
  const COST_WIDTH = 6;
50
- /** The glyphs a group header counts its members with — the matrix vocabulary,
51
- * so a rung reads the same in both columns. */
52
- const RUNG_MARK = { content: '█', preview: '▄', name: '▁' };
53
51
  /** Columns one nesting level spends, so a doc sits under its directory's label
54
52
  * and the truncator sees the indent as part of the name it is eliding. */
55
53
  const INDENT = 2;
@@ -81,18 +79,24 @@ export class ContextDocsPanel {
81
79
  get visibleCount() {
82
80
  return this.lines.reduce((count, line) => count + (line.kind === 'group' && line.depth === 0 ? line.docs : 0), 0);
83
81
  }
84
- setRows(rows, event, selectedKey) {
82
+ /** Documents that actually load on the dialled event, counted the same way —
83
+ * the numerator of the heading's `N of M`. */
84
+ get loadedCount() {
85
+ return this.lines.reduce((count, line) => count +
86
+ (line.kind === 'group' && line.depth === 0 ? line.rungs.content + line.rungs.preview + line.rungs.name : 0), 0);
87
+ }
88
+ setRows(rows, event) {
85
89
  this.rows = rows;
86
90
  this.event = event;
87
91
  const live = groupKeysOf(rows);
88
92
  for (const key of this.expanded)
89
93
  if (!live.has(key))
90
94
  this.expanded.delete(key);
91
- this.relayout(selectedKey);
95
+ this.relayout();
92
96
  }
93
97
  setFilters(filters) {
94
98
  this.filters = filters;
95
- this.relayout(this.selected?.key);
99
+ this.relayout();
96
100
  }
97
101
  move(delta) {
98
102
  if (this.lines.length === 0)
@@ -104,13 +108,6 @@ export class ContextDocsPanel {
104
108
  get onGroup() {
105
109
  return this.lines[this.index]?.kind === 'group';
106
110
  }
107
- /** True when the focused line IS a document rather than merely standing for
108
- * one. A directory that is also a document is both, which is why the two
109
- * gestures split there: space opens it, enter edits it. */
110
- get onDocument() {
111
- const line = this.lines[this.index];
112
- return line !== undefined && (line.kind === 'doc' || line.isDoc);
113
- }
114
111
  toggleGroup() {
115
112
  const line = this.lines[this.index];
116
113
  if (line?.kind !== 'group')
@@ -119,32 +116,28 @@ export class ContextDocsPanel {
119
116
  this.expanded.delete(line.key);
120
117
  else
121
118
  this.expanded.add(line.key);
122
- this.relayout(undefined, line.key);
119
+ this.relayout();
123
120
  this.onChange();
124
121
  }
125
- /** Rebuild the drawn lines, keeping the cursor on the same document (or the
126
- * same directory), and clamping when what it was on is gone. A document that
127
- * owns a directory is drawn as that directory's header, so a held document
128
- * key matches there too. */
129
- relayout(selectedKey, groupKey) {
130
- const held = selectedKey ?? (this.lines[this.index]?.kind === 'doc' ? this.lines[this.index].key : undefined);
122
+ /** Rebuild the drawn lines, keeping the cursor on whatever it holds — the
123
+ * same document, or the same directory header, which a collapsed group draws
124
+ * no document row for — and clamping when what it was on is gone. */
125
+ relayout() {
126
+ const held = this.lines[this.index];
131
127
  this.lines = buildListView(this.rows, this.filters, this.expanded, this.event);
132
- const wanted = groupKey !== undefined
133
- ? this.lines.findIndex((line) => line.kind === 'group' && line.key === groupKey)
134
- : held === undefined
135
- ? -1
136
- : this.lines.findIndex((line) => (line.kind === 'doc' && line.key === held) || (line.kind === 'group' && line.isDoc && line.first.key === held));
128
+ const wanted = held === undefined ? -1 : this.lines.findIndex((line) => line.kind === held.kind && line.key === held.key);
137
129
  this.index = wanted >= 0 ? wanted : Math.max(0, Math.min(this.index, this.lines.length - 1));
138
130
  }
139
- render(width, height, focused, plans) {
131
+ render(width, height, focused) {
140
132
  const out = [];
141
- const nameRoom = Math.max(1, width - MATRIX_WIDTH - COST_WIDTH - 3);
133
+ const nameRoom = Math.max(1, width - STATE_WIDTH - COST_WIDTH - 3);
142
134
  const heading = this.filters.query === '' ? 'DOCUMENTS' : `/${this.filters.query}`;
135
+ // The question the page is asking, in the terms the rows answer it in.
143
136
  const count = this.filters.query === ''
144
- ? plural(this.visibleCount, 'doc', 'docs')
137
+ ? `${this.loadedCount} of ${this.visibleCount} load on ${this.event}`
145
138
  : plural(this.visibleCount, 'match', 'matches');
146
- const matrixHead = SURFACE_EVENTS.map((event) => EVENT_INITIALS[event]).join(' ');
147
- out.push(truncateToWidth(` ${padAnsi(`${theme.fg('accent', theme.bold(heading))} ${theme.fg('dim', count)}`, nameRoom + COST_WIDTH + 1)} ${theme.fg('dim', matrixHead)}`, width));
139
+ const title = `${theme.fg('accent', theme.bold(heading))} ${theme.fg('dim', count)}`;
140
+ out.push(` ${padAnsi(truncateToWidth(title, nameRoom), nameRoom)} ${field(theme.fg('dim', 'loads'), STATE_WIDTH)} ${field(theme.fg('dim', 'tokens'), COST_WIDTH)}`);
148
141
  if (this.lines.length === 0) {
149
142
  out.push('', theme.fg('dim', ' Nothing matches this question — cycle a facet back to "any".'));
150
143
  return this.fit(out, height);
@@ -153,7 +146,7 @@ export class ContextDocsPanel {
153
146
  const range = visibleRange(this.lines.length, this.index, capacity);
154
147
  for (let at = range.start; at < range.end; at += 1) {
155
148
  const line = this.lines[at];
156
- const body = line.kind === 'group' ? this.groupLine(line, nameRoom, plans) : this.docLine(line, nameRoom, plans);
149
+ const body = line.kind === 'group' ? this.groupLine(line, nameRoom) : this.docLine(line, nameRoom);
157
150
  out.push(at === this.index && focused
158
151
  ? theme.bg('selectedBg', padAnsi(body, width))
159
152
  : at === this.index
@@ -162,25 +155,21 @@ export class ContextDocsPanel {
162
155
  }
163
156
  return this.fit(out, height);
164
157
  }
165
- /** A directory header. Its stats and cost are the whole subtree's; the matrix
166
- * is drawn only when the directory is itself a document, which is what marks
167
- * the one row as both a document and a directory. */
168
- groupLine(line, nameRoom, plans) {
158
+ /** A directory header: a pure rollup of its whole subtree — how many of its
159
+ * documents load, and what they cost together. */
160
+ groupLine(line, nameRoom) {
169
161
  const marker = line.expanded ? '▾' : '▸';
170
162
  const label = `${' '.repeat(line.depth * INDENT)}${marker} ${theme.bold(line.label)}`;
171
- const marks = ['content', 'preview', 'name']
172
- .filter((rung) => line.rungs[rung] > 0)
173
- .map((rung) => `${line.rungs[rung]}${RUNG_MARK[rung]}`)
174
- .join(' ');
175
- const stats = theme.fg('dim', marks === '' ? plural(line.docs, 'doc', 'docs') : `${line.docs} · ${marks}`);
176
- const room = Math.max(1, nameRoom - visibleWidth(stats) - 1);
177
- const field = `${padAnsi(truncateToWidth(label, room), room)} ${stats}`;
178
- const matrix = line.isDoc ? matrixOf(line.first, plans) : ' '.repeat(MATRIX_WIDTH);
179
- return ` ${padAnsi(field, nameRoom)} ${cost(line.cost)} ${matrix}`;
163
+ const loads = line.rungs.content + line.rungs.preview + line.rungs.name;
164
+ const rollup = theme.fg(loads > 0 ? 'success' : 'dim', `${loads} of ${line.docs}`);
165
+ return ` ${padAnsi(truncateToWidth(label, nameRoom), nameRoom)} ${field(rollup, STATE_WIDTH)} ${cost(line.cost)}`;
180
166
  }
181
- docLine(line, nameRoom, plans) {
167
+ docLine(line, nameRoom) {
182
168
  const label = truncateToWidth(`${' '.repeat(line.depth * INDENT)}${line.label}`, nameRoom);
183
- return ` ${padAnsi(label, nameRoom)} ${cost(line.cost)} ${matrixOf(line.row, plans)}`;
169
+ const record = line.row.records.get(this.event);
170
+ const state = stateLabel(record, deliveryStateOf(record));
171
+ const word = state.text === '' ? '' : theme.fg(state.tone, state.text);
172
+ return ` ${padAnsi(label, nameRoom)} ${field(word, STATE_WIDTH)} ${cost(line.cost)}`;
184
173
  }
185
174
  /** Pad rather than trim: this column sets the length of the divider drawn
186
175
  * beside it. */
@@ -190,15 +179,13 @@ export class ContextDocsPanel {
190
179
  return lines.slice(0, height).map((line) => (visibleWidth(line) === 0 ? '' : line));
191
180
  }
192
181
  }
193
- /** One document's six events, in the order the heading names them. */
194
- function matrixOf(row, plans) {
195
- return SURFACE_EVENTS.map((event) => {
196
- const state = plans.peek(event) === undefined ? 'pending' : cellStateOf(row.records.get(event));
197
- return cellGlyph(row.records.get(event), state);
198
- }).join(' ');
182
+ /** Right-flushed into a fixed column, so the state and cost fields read as one
183
+ * block against the pane's right edge. */
184
+ function field(text, width) {
185
+ const used = visibleWidth(text);
186
+ return used >= width ? truncateToWidth(text, width) : `${' '.repeat(width - used)}${text}`;
199
187
  }
200
188
  /** Right-flushed into the cost column, dim because it is an estimate. */
201
189
  function cost(count) {
202
- const text = formatCost(count);
203
- return theme.fg('dim', ' '.repeat(Math.max(0, COST_WIDTH - text.length)) + text);
190
+ return field(theme.fg('dim', formatCost(count)), COST_WIDTH);
204
191
  }
@@ -32,11 +32,9 @@ export type ListLine = {
32
32
  expanded: boolean;
33
33
  /** The document the dossier describes while the cursor rests on the
34
34
  * header: this directory's own document when it has one, else the first
35
- * document in its subtree. */
35
+ * document in its subtree. A header never stands in for that document's
36
+ * own row — it only keeps the dossier from going blank. */
36
37
  first: DocRow;
37
- /** True when the directory is itself a document — `first` is that
38
- * document, and the one row is both selectable and expandable. */
39
- isDoc: boolean;
40
38
  } | {
41
39
  kind: 'doc';
42
40
  key: string;
@@ -75,7 +75,6 @@ export function buildListView(rows, filters, expanded, event) {
75
75
  ...rollup(leading, event),
76
76
  expanded: open,
77
77
  first: leading[0],
78
- isDoc: false,
79
78
  });
80
79
  if (open)
81
80
  for (const row of leading)
@@ -110,14 +109,14 @@ function emit(dir, filters, expanded, event, lines) {
110
109
  ...rollup(members, event),
111
110
  expanded: open,
112
111
  first: members[0],
113
- isDoc: dir.selves.length > 0,
114
112
  });
115
113
  if (!open)
116
114
  return;
117
- // The header already stands for the first document named exactly this path;
118
- // a second store answering the same name still needs its own row.
119
- for (const shadowed of dir.selves.slice(1))
120
- lines.push(docLine(shadowed, dir.segment, dir.depth + 1, event));
115
+ // A directory that is also a document is a header plus a row, never one row
116
+ // doing both: the header is a pure rollup that expands, and the document sits
117
+ // inside it under its own name — no trailing slash — like any other member.
118
+ for (const self of dir.selves)
119
+ lines.push(docLine(self, dir.segment, dir.depth + 1, event));
121
120
  for (const child of dir.children.values())
122
121
  emit(child, filters, expanded, event, lines);
123
122
  }
@@ -46,24 +46,28 @@ export declare class PlanSet {
46
46
  dispose(): void;
47
47
  /** The plan for one event, computing it now. */
48
48
  plan(event: SurfaceEvent): DeliveryPlan;
49
- /** The plan for one event only if it is already computed — what the matrix
50
- * reads, so an uncomputed column draws as pending instead of stalling the
49
+ /** The plan for one event only if it is already computed — what the rail's
50
+ * EVENTS counts read, so an unwarmed event draws `…` instead of stalling the
51
51
  * frame on five extra corpus loads. */
52
52
  peek(event: SurfaceEvent): DeliveryPlan | undefined;
53
53
  /** Compute one pending event per macrotask, calling back after each so the
54
- * matrix fills in column by column. */
54
+ * rail's counts fill in row by row. */
55
55
  warm(onProgress: () => void): void;
56
56
  private payload;
57
57
  }
58
- /** How one document reads in one event's cell. */
59
- export type CellState = 'pending' | 'delivers' | 'capped' | 'gated' | 'shadowed' | 'silent';
60
- export declare function cellStateOf(record: DeliveryRecord | undefined): CellState;
61
- /** One matrix cell: the delivered rung as a filled bar, or the mark that says
62
- * why nothing is delivered. Colour separates a clean delivery from one the
63
- * profile cap trimmed, so a capped `content` never reads as an authored one. */
64
- export declare function cellGlyph(record: DeliveryRecord | undefined, state: CellState): string;
65
- /** The one-letter column heads over the matrix, in `SURFACE_EVENTS` order. */
66
- export declare const EVENT_INITIALS: Record<SurfaceEvent, string>;
58
+ /** What one document does in one event. */
59
+ export type DeliveryState = 'delivers' | 'capped' | 'gated' | 'shadowed' | 'silent';
60
+ export declare function deliveryStateOf(record: DeliveryRecord | undefined): DeliveryState;
61
+ /** The word the list prints for one document's state, and the tone it prints it
62
+ * in. The vocabulary is the one the author already writes in frontmatter
63
+ * (`at: content`), so the column needs no legend: a delivery names the rung it
64
+ * arrives at, warning-toned when the profile ceiling trimmed it below what the
65
+ * entry authored. A document that does not load renders nothing at all unless
66
+ * its silence has a name the page can act on — `gated` or `shadowed`. */
67
+ export declare function stateLabel(record: DeliveryRecord | undefined, state: DeliveryState): {
68
+ text: string;
69
+ tone: 'success' | 'warning' | 'muted';
70
+ };
67
71
  export declare function nextRung(rung: Rung, delta: number): Rung;
68
72
  /** A parsed entry back in the shape `memory edit --surface` accepts. The parsed
69
73
  * form always carries `match` as a list and spells its frontmatter key
@@ -1,8 +1,7 @@
1
1
  // The read model behind `crtr sys context admin`: the configuration snapshot
2
2
  // the page dials, the per-event delivery plans it resolves from, and the
3
- // vocabulary both the matrix and the dossier read from.
3
+ // vocabulary both the list and the dossier read from.
4
4
  import { resolve as resolvePath } from 'node:path';
5
- import { theme } from '../../../../core/tui/panel.js';
6
5
  import { realpathOrSelf } from '../../../../core/fs-utils.js';
7
6
  import { planDelivery, } from '../../../../core/substrate/plan.js';
8
7
  import { RUNGS, SURFACE_EVENTS, previewLine, rungRank, } from '../../../../core/substrate/schema.js';
@@ -76,14 +75,14 @@ export class PlanSet {
76
75
  this.plans.set(event, computed);
77
76
  return computed;
78
77
  }
79
- /** The plan for one event only if it is already computed — what the matrix
80
- * reads, so an uncomputed column draws as pending instead of stalling the
78
+ /** The plan for one event only if it is already computed — what the rail's
79
+ * EVENTS counts read, so an unwarmed event draws `…` instead of stalling the
81
80
  * frame on five extra corpus loads. */
82
81
  peek(event) {
83
82
  return this.plans.get(event);
84
83
  }
85
84
  /** Compute one pending event per macrotask, calling back after each so the
86
- * matrix fills in column by column. */
85
+ * rail's counts fill in row by row. */
87
86
  warm(onProgress) {
88
87
  if (this.warming !== undefined)
89
88
  return;
@@ -130,7 +129,7 @@ function resolvedDocRealpath(name, target) {
130
129
  return '';
131
130
  }
132
131
  }
133
- export function cellStateOf(record) {
132
+ export function deliveryStateOf(record) {
134
133
  if (record === undefined)
135
134
  return 'silent';
136
135
  if (!record.docGate.pass)
@@ -146,33 +145,21 @@ export function cellStateOf(record) {
146
145
  return 'gated';
147
146
  return 'silent';
148
147
  }
149
- const RUNG_GLYPH = { none: '·', name: '▁', preview: '▄', content: '█' };
150
- /** One matrix cell: the delivered rung as a filled bar, or the mark that says
151
- * why nothing is delivered. Colour separates a clean delivery from one the
152
- * profile cap trimmed, so a capped `content` never reads as an authored one. */
153
- export function cellGlyph(record, state) {
154
- if (state === 'pending')
155
- return theme.fg('dim', '…');
148
+ /** The word the list prints for one document's state, and the tone it prints it
149
+ * in. The vocabulary is the one the author already writes in frontmatter
150
+ * (`at: content`), so the column needs no legend: a delivery names the rung it
151
+ * arrives at, warning-toned when the profile ceiling trimmed it below what the
152
+ * entry authored. A document that does not load renders nothing at all unless
153
+ * its silence has a name the page can act on — `gated` or `shadowed`. */
154
+ export function stateLabel(record, state) {
156
155
  if (state === 'gated')
157
- return theme.fg('warning', '×');
156
+ return { text: 'gated', tone: 'muted' };
158
157
  if (state === 'shadowed')
159
- return theme.fg('muted', '∅');
160
- if (record === undefined || state === 'silent')
161
- return theme.fg('dim', RUNG_GLYPH.none);
162
- const glyph = RUNG_GLYPH[record.finalRung];
163
- if (state === 'capped')
164
- return theme.fg('warning', record.finalRung === 'none' ? '⌐' : glyph);
165
- return theme.fg('success', glyph);
158
+ return { text: 'shadowed', tone: 'muted' };
159
+ if (record === undefined || record.finalRung === 'none')
160
+ return { text: '', tone: 'muted' };
161
+ return { text: record.finalRung, tone: state === 'capped' ? 'warning' : 'success' };
166
162
  }
167
- /** The one-letter column heads over the matrix, in `SURFACE_EVENTS` order. */
168
- export const EVENT_INITIALS = {
169
- boot: 'b',
170
- 'workspace-open': 'w',
171
- read: 'r',
172
- 'memory-read': 'm',
173
- command: 'c',
174
- 'pre-command': 'p',
175
- };
176
163
  export function nextRung(rung, delta) {
177
164
  const at = (rungRank(rung) + delta + RUNGS.length) % RUNGS.length;
178
165
  return RUNGS[at];
@@ -1,3 +1,4 @@
1
+ import { type SurfaceEvent } from '../../../../core/substrate/schema.js';
1
2
  import { type Snapshot } from './model.js';
2
3
  import type { Filters } from './list-view.js';
3
4
  export type AxisId = 'event' | 'payload' | 'kind' | 'mode' | 'lifecycle' | 'manager' | 'depth' | 'profile' | 'cwd';
@@ -20,9 +21,14 @@ export interface Facet {
20
21
  * snapshot above it dials the NODE's kind. */
21
22
  export declare function facetsOf(filters: Filters): Facet[];
22
23
  export declare function cycleFacet(filters: Filters, id: FacetId, delta: number): Filters;
23
- /** The rail's rows for a snapshot. The payload row appears only for the events
24
- * that read one, because on boot there is nothing it could change. */
24
+ /** The configuration rows for a snapshot. The event and its payload are not
25
+ * here — they belong to the EVENTS section, where the payload sits under the
26
+ * count it explains. */
25
27
  export declare function axesOf(snapshot: Snapshot, profileName: string): Axis[];
28
+ /** The payload field for the dialled event, or nothing when the event reads no
29
+ * payload — on boot there is nothing it could change. It renders inside the
30
+ * EVENTS section, directly under the count it explains. */
31
+ export declare function payloadRowOf(snapshot: Snapshot): Axis | undefined;
26
32
  /** Cycle one enum axis, returning the changed snapshot. A typed axis is never
27
33
  * cycled — the shell opens a field for it instead. */
28
34
  export declare function cycleAxis(snapshot: Snapshot, id: AxisId, delta: number): Snapshot;
@@ -30,12 +36,42 @@ export declare function cycleAxis(snapshot: Snapshot, id: AxisId, delta: number)
30
36
  export declare function setTypedAxis(snapshot: Snapshot, id: AxisId, value: string): Snapshot;
31
37
  /** The value a typed axis's field is seeded with. */
32
38
  export declare function typedAxisValue(snapshot: Snapshot, id: AxisId): string;
33
- /** Both sections as one column. `index` runs over the axes and then the facets
34
- * — the order the shell routes a cycle in — while the section headings and the
35
- * blank rows between them are drawn but never selectable. */
39
+ /** One surface event as the rail states it: how many documents it delivers, and
40
+ * whether it is the one the page is planning. `delivers` is undefined while
41
+ * that event's plan is still warming. */
42
+ export interface EventRow {
43
+ event: SurfaceEvent;
44
+ delivers: number | undefined;
45
+ dialled: boolean;
46
+ }
47
+ /** Every selectable rail row in drawn order: the events, the dialled event's
48
+ * payload field directly under it, the configuration axes, then the filter
49
+ * facets. One list, so the shell's single index is also the reading order. */
50
+ export type RailRow = {
51
+ kind: 'event';
52
+ event: EventRow;
53
+ } | {
54
+ kind: 'axis';
55
+ axis: Axis;
56
+ indent: boolean;
57
+ } | {
58
+ kind: 'facet';
59
+ facet: Facet;
60
+ };
61
+ /** A rail row's identity, which the shell holds the cursor by. The list's
62
+ * composition changes whenever the dialled event moves the payload row, so an
63
+ * offset kept across that change points at a different row. */
64
+ export declare function railRowKey(row: RailRow): string;
65
+ /** What enter does on a rail row: an event dials it, a typed row opens its
66
+ * field, and every other row hands the keyboard to the documents. Space does
67
+ * the same thing on the first two and cycles the value on the last. */
68
+ export declare function railEnter(row: RailRow | undefined): 'dial' | 'edit' | 'documents';
69
+ export declare function railRowsOf(events: EventRow[], payload: Axis | undefined, axes: Axis[], facets: Facet[]): RailRow[];
70
+ /** All three sections as one column. `index` runs over `rows`, while the
71
+ * section headings and the blank rows between them are drawn but never
72
+ * selectable. */
36
73
  export declare function renderRail(opts: {
37
- axes: Axis[];
38
- facets: Facet[];
74
+ rows: RailRow[];
39
75
  index: number;
40
76
  width: number;
41
77
  height: number;
@@ -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);