@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,6 +1,9 @@
1
- import { type Rung, type SurfaceEvent } from '../../../../core/substrate/schema.js';
1
+ import 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';
4
+ import { type CapOwner } from './read-view.js';
3
5
  import type { DocRow } from './docs-panel.js';
6
+ export type { CapOwner } from './read-view.js';
4
7
  /** A field of the focused document the shell can write. `entry` carries an
5
8
  * index into the document's whole surfaces list, not the focused event's
6
9
  * subset, because that list is what a write replaces. */
@@ -26,31 +29,53 @@ export type DetailRow = {
26
29
  } | {
27
30
  kind: 'move';
28
31
  };
29
- export interface CapOwner {
30
- path: string;
31
- memory: Rung;
32
- }
33
32
  export declare class ContextDetailPanel {
34
33
  private row;
35
34
  private event;
36
35
  private capOwner;
37
36
  private readOnly;
37
+ private subject;
38
38
  private rows;
39
39
  private index;
40
- /** Lines scrolled past the cursor's own window, so the verdict below the last
41
- * editable row stays reachable. */
42
- private tail;
43
- setContext(row: DocRow | undefined, event: SurfaceEvent, capOwner: CapOwner | null, readOnly: string | null): void;
40
+ private face;
41
+ private open;
42
+ private scroll;
43
+ /** Last render's geometry, so a half-pane jump and the end-of-content clamp
44
+ * measure against real content rather than a counter nobody can see. */
45
+ private lastLines;
46
+ private lastHeight;
47
+ setContext(row: DocRow | undefined, event: SurfaceEvent, capOwner: CapOwner | null, readOnly: string | null, subject: NodeConfigSubject | null): void;
48
+ /** True while the field list owns the pane, so the shell routes the write
49
+ * letters and teaches them in the footer. */
50
+ get editing(): boolean;
51
+ get bodyOpen(): boolean;
44
52
  get selected(): DetailRow | undefined;
45
53
  /** The entry index a rung cycle or a removal would act on. */
46
54
  get selectedEntry(): number | undefined;
55
+ /** `j`/`k`: a line of the read face, a field of the edit face. */
47
56
  move(delta: number): void;
57
+ /** `ctrl-d`/`ctrl-u`: half a pane, so a long body is crossed in jumps a
58
+ * reader can still follow. */
59
+ page(direction: 1 | -1): void;
60
+ /** `g`/`G`. */
61
+ jump(to: 'top' | 'bottom'): void;
62
+ /** `enter`: the document's own text, in place. False when there is no body,
63
+ * so the shell leaves the key alone. */
64
+ openBody(): boolean;
65
+ /** `e`: the field list. */
66
+ edit(): void;
67
+ /** `esc`/`h`: leave the edit face, else collapse the body. False means the
68
+ * read face is already at rest and the shell should leave the dossier. */
69
+ back(): boolean;
70
+ private scrollBy;
71
+ private maxScroll;
72
+ private record;
73
+ private context;
48
74
  render(width: number, height: number, focused: boolean): string[];
49
- private verdict;
50
- /** All three rungs, not just the current one, so "what would demoting this
51
- * save" is read rather than computed. */
52
- private costs;
53
- private acrossEvents;
75
+ /** Today's field list, under the identity and delivery it is being edited
76
+ * against. A row's clarifying tail renders only while that row is focused:
77
+ * unfocused, twelve explanations are twelve lines of noise. */
78
+ private editLines;
54
79
  }
55
80
  /** The editable rows for one document, in the order the panel draws them. */
56
81
  export declare function buildDetailRows(record: DeliveryRecord): DetailRow[];
@@ -1,91 +1,194 @@
1
- // The dossier: one document's editable configuration above the read-only
2
- // verdict that configuration produces, and below that the document's own
3
- // opening lines. Every selectable row here is a field the shell can write;
4
- // everything below the rule is what the planner decided.
1
+ // The dossier: one memory document, read first.
2
+ //
3
+ // Two faces over one pane. The read face is the resting state — who this
4
+ // document is, when an agent reads it, what it says, and whether it loads on
5
+ // this event — and `enter` opens its body in place. The edit face is the field
6
+ // list every write still starts from, one `e` away. The panel owns which face
7
+ // is showing and how far it is scrolled; it owns no text layout.
5
8
  import { truncateToWidth, visibleWidth } from '@earendil-works/pi-tui';
6
9
  import { padAnsi, theme, wrapText } from '../../../../core/tui/panel.js';
7
- import { SURFACE_EVENTS } from '../../../../core/substrate/schema.js';
8
- import { EVENT_INITIALS, cellStateOf, describeSurfaceEntry, formatCost, plural, rungCostsOf } from './model.js';
9
- const STATE_WORD = {
10
- pending: 'planning…',
11
- delivers: 'delivers',
12
- capped: 'capped',
13
- gated: 'gated out',
14
- shadowed: 'shadowed',
15
- silent: 'silent',
16
- };
10
+ import { describeSurfaceEntry, plural } from './model.js';
11
+ import { bodyLineCount, renderDossierDelivery, renderDossierHeader, renderDossierRead, } from './read-view.js';
17
12
  export class ContextDetailPanel {
18
13
  row;
19
14
  event = 'boot';
20
15
  capOwner = null;
21
16
  readOnly = null;
17
+ subject = null;
22
18
  rows = [];
23
19
  index = 0;
24
- /** Lines scrolled past the cursor's own window, so the verdict below the last
25
- * editable row stays reachable. */
26
- tail = 0;
27
- setContext(row, event, capOwner, readOnly) {
20
+ face = 'read';
21
+ open = false;
22
+ scroll = 0;
23
+ /** Last render's geometry, so a half-pane jump and the end-of-content clamp
24
+ * measure against real content rather than a counter nobody can see. */
25
+ lastLines = 1;
26
+ lastHeight = 1;
27
+ setContext(row, event, capOwner, readOnly, subject) {
28
28
  const changed = row?.key !== this.row?.key;
29
29
  this.row = row;
30
30
  this.event = event;
31
31
  this.capOwner = capOwner;
32
32
  this.readOnly = readOnly;
33
+ this.subject = subject;
33
34
  this.rows = row === undefined ? [] : buildDetailRows(row.anchor);
34
35
  if (changed) {
35
36
  this.index = 0;
36
- this.tail = 0;
37
+ this.scroll = 0;
38
+ this.open = false;
39
+ this.face = 'read';
37
40
  }
38
41
  this.index = Math.max(0, Math.min(this.index, this.rows.length - 1));
39
42
  }
43
+ /** True while the field list owns the pane, so the shell routes the write
44
+ * letters and teaches them in the footer. */
45
+ get editing() {
46
+ return this.face === 'edit';
47
+ }
48
+ get bodyOpen() {
49
+ return this.open;
50
+ }
40
51
  get selected() {
41
- return this.rows[this.index];
52
+ return this.face === 'edit' ? this.rows[this.index] : undefined;
42
53
  }
43
54
  /** The entry index a rung cycle or a removal would act on. */
44
55
  get selectedEntry() {
45
56
  const row = this.selected;
46
57
  return row?.kind === 'entry' ? row.at : undefined;
47
58
  }
59
+ // navigation
60
+ /** `j`/`k`: a line of the read face, a field of the edit face. */
48
61
  move(delta) {
49
- const next = this.index + delta;
50
- if (next < 0 || next >= this.rows.length) {
51
- this.tail = Math.max(0, this.tail + delta);
62
+ if (this.face === 'edit') {
63
+ this.index = Math.max(0, Math.min(this.rows.length - 1, this.index + delta));
52
64
  return;
53
65
  }
54
- this.index = next;
55
- this.tail = 0;
66
+ this.scrollBy(delta);
56
67
  }
57
- render(width, height, focused) {
68
+ /** `ctrl-d`/`ctrl-u`: half a pane, so a long body is crossed in jumps a
69
+ * reader can still follow. */
70
+ page(direction) {
71
+ if (this.face === 'edit')
72
+ return;
73
+ this.scrollBy(direction * Math.max(1, Math.floor(this.lastHeight / 2)));
74
+ }
75
+ /** `g`/`G`. */
76
+ jump(to) {
77
+ if (this.face === 'edit')
78
+ return;
79
+ this.scroll = to === 'top' ? 0 : this.maxScroll();
80
+ }
81
+ /** `enter`: the document's own text, in place. False when there is no body,
82
+ * so the shell leaves the key alone. */
83
+ openBody() {
84
+ if (this.face === 'edit' || this.row === undefined)
85
+ return false;
86
+ if (bodyLineCount(this.record().doc.body) === 0)
87
+ return false;
88
+ this.open = true;
89
+ this.scroll = 0;
90
+ return true;
91
+ }
92
+ /** `e`: the field list. */
93
+ edit() {
94
+ if (this.row === undefined)
95
+ return;
96
+ this.face = 'edit';
97
+ this.scroll = 0;
98
+ }
99
+ /** `esc`/`h`: leave the edit face, else collapse the body. False means the
100
+ * read face is already at rest and the shell should leave the dossier. */
101
+ back() {
102
+ if (this.face === 'edit') {
103
+ this.face = 'read';
104
+ this.scroll = 0;
105
+ return true;
106
+ }
107
+ if (!this.open)
108
+ return false;
109
+ this.open = false;
110
+ this.scroll = 0;
111
+ return true;
112
+ }
113
+ scrollBy(delta) {
114
+ this.scroll = Math.max(0, Math.min(this.scroll + delta, this.maxScroll()));
115
+ }
116
+ maxScroll() {
117
+ return Math.max(0, this.lastLines - this.lastHeight);
118
+ }
119
+ record() {
58
120
  const row = this.row;
59
- if (row === undefined) {
121
+ return row.records.get(this.event) ?? row.anchor;
122
+ }
123
+ context() {
124
+ return {
125
+ event: this.event,
126
+ records: this.row?.records ?? new Map(),
127
+ capOwner: this.capOwner,
128
+ readOnly: this.readOnly !== null,
129
+ subject: this.subject,
130
+ };
131
+ }
132
+ // rendering
133
+ render(width, height, focused) {
134
+ if (this.row === undefined) {
135
+ this.lastLines = 1;
136
+ this.lastHeight = height;
60
137
  return fit([theme.fg('dim', ' Select a document.')], height);
61
138
  }
62
- const record = row.records.get(this.event) ?? row.anchor;
63
- const lines = [];
64
- const rowLines = new Map();
65
- lines.push(truncateToWidth(`${theme.fg('accent', theme.bold(record.name))}${this.readOnly === null ? '' : theme.fg('warning', ' read-only')}`, width));
66
- lines.push(truncateToWidth(theme.fg('dim', `${record.kind} · ${record.scope}${record.nodeLocal ? ' · node-local' : ''} · ${record.source.representation}`), width));
67
- lines.push(truncateToWidth(theme.fg('dim', record.storePath), width));
68
- if (this.readOnly !== null)
69
- lines.push(...wrapText(this.readOnly, width).map((line) => theme.fg('warning', line)));
70
- lines.push('');
71
- const push = (at, body) => {
72
- rowLines.set(at, lines.length);
73
- lines.push(at === this.index && focused
74
- ? theme.bg('selectedBg', padAnsi(truncateToWidth(body, width), width))
75
- : at === this.index
76
- ? `${theme.fg('accent', '▌')}${truncateToWidth(body, Math.max(0, width - 1))}`
77
- : ` ${truncateToWidth(body, Math.max(0, width - 1))}`);
139
+ const record = this.record();
140
+ const built = this.face === 'read'
141
+ ? { lines: renderDossierRead(record, this.context(), width, this.open), cursor: undefined }
142
+ : this.editLines(record, width, focused);
143
+ const lines = built.lines;
144
+ // The badge is the pane's last row, so it costs a line of content only
145
+ // while there is content it cannot show anyway.
146
+ const overflows = lines.length > height;
147
+ const viewport = Math.max(1, overflows ? height - 1 : height);
148
+ this.lastLines = lines.length;
149
+ this.lastHeight = viewport;
150
+ let start;
151
+ if (built.cursor === undefined) {
152
+ this.scroll = Math.max(0, Math.min(this.scroll, Math.max(0, lines.length - viewport)));
153
+ start = this.scroll;
154
+ }
155
+ else {
156
+ start = Math.min(Math.max(0, built.cursor - Math.floor(viewport / 3)), Math.max(0, lines.length - viewport));
157
+ }
158
+ const window = fit(lines.slice(start, start + viewport), viewport);
159
+ if (!overflows)
160
+ return window;
161
+ window.push(badge(start + 1, Math.min(lines.length, start + viewport), lines.length, width));
162
+ return window;
163
+ }
164
+ /** Today's field list, under the identity and delivery it is being edited
165
+ * against. A row's clarifying tail renders only while that row is focused:
166
+ * unfocused, twelve explanations are twelve lines of noise. */
167
+ editLines(record, width, focused) {
168
+ const context = this.context();
169
+ const lines = [...renderDossierHeader(record, context, width), ...renderDossierDelivery(record, context, width), ''];
170
+ let cursor = 0;
171
+ const push = (at, body, tail) => {
172
+ const own = [body, ...(at === this.index && tail !== undefined ? tailLines(tail, width) : [])];
173
+ if (at === this.index)
174
+ cursor = lines.length;
175
+ for (const line of own) {
176
+ lines.push(at === this.index && focused
177
+ ? theme.bg('selectedBg', padAnsi(truncateToWidth(line, width), width))
178
+ : at === this.index
179
+ ? `${theme.fg('accent', '\u258c')}${truncateToWidth(line, Math.max(0, width - 1))}`
180
+ : ` ${truncateToWidth(line, Math.max(0, width - 1))}`);
181
+ }
78
182
  };
79
183
  let at = 0;
80
184
  lines.push(theme.fg('dim', ' SURFACES'));
81
185
  const surfaces = record.doc.surfaces;
82
- if (surfaces.length === 0)
83
- push(at++, theme.fg('dim', 'no entries — this document only appears in its listing'));
84
- else {
85
- for (const entry of surfaces) {
86
- const text = describeSurfaceEntry(entry);
87
- push(at++, entry.on === this.event ? text : theme.fg('dim', text));
88
- }
186
+ if (surfaces.length === 0) {
187
+ lines.push(theme.fg('dim', ' no entries — this document only appears in its listing'));
188
+ }
189
+ for (const entry of surfaces) {
190
+ const text = describeSurfaceEntry(entry);
191
+ push(at++, entry.on === this.event ? text : theme.fg('dim', text));
89
192
  }
90
193
  push(at++, theme.fg('success', '+ add a surface entry'));
91
194
  lines.push('');
@@ -95,71 +198,13 @@ export class ContextDetailPanel {
95
198
  push(at++, field('slash', record.doc.slash ? 'yes' : theme.fg('dim', 'no'), width));
96
199
  push(at++, field('routing', record.doc.whenAndWhyToRead, width));
97
200
  push(at++, field('short form', record.doc.shortForm === '' ? theme.fg('dim', 'none') : record.doc.shortForm, width));
98
- push(at++, field('body', theme.fg('dim', `${plural(bodyLines(record.doc.body), 'line', 'lines')} \u2014 opens in $EDITOR`), width));
99
- push(at++, field('profile cap', this.capOwner === null
100
- ? theme.fg('dim', 'no profile project governs this store — uncapped')
101
- : `${this.capOwner.memory} ${theme.fg('dim', this.capOwner.path)}`, width));
201
+ push(at++, field('body', theme.fg('dim', plural(bodyLineCount(record.doc.body), 'line', 'lines')), width), 'opens in $EDITOR');
202
+ push(at++, field('profile cap', this.capOwner === null ? theme.fg('dim', 'uncapped') : `${this.capOwner.memory} ${theme.fg('dim', this.capOwner.path)}`, width), this.capOwner === null
203
+ ? 'no profile project governs this store'
204
+ : 'the highest rung any document from this store can deliver at');
102
205
  push(at++, field('canonical name', record.name, width));
103
- lines.push('');
104
- lines.push(theme.fg('border', '─'.repeat(Math.max(0, width))));
105
- lines.push(theme.fg('dim', ` VERDICT · ${this.event}`));
106
- lines.push(...this.verdict(record, width));
107
- lines.push('');
108
- lines.push(theme.fg('dim', ' COST IF DELIVERED · approximate'));
109
- lines.push(...this.costs(record, width));
110
- lines.push('');
111
- lines.push(theme.fg('dim', ' ACROSS EVENTS'));
112
- lines.push(...this.acrossEvents(row, width));
113
- lines.push('');
114
- lines.push(theme.fg('dim', ' BODY'));
115
- lines.push(...bodyPreview(record.doc.body, width, height));
116
- const cursorLine = rowLines.get(this.index) ?? 0;
117
- const maxStart = Math.max(0, lines.length - height);
118
- let start = Math.min(Math.max(0, cursorLine - Math.floor(height / 3)), maxStart);
119
- if (this.tail > 0)
120
- start = Math.min(maxStart, start + this.tail);
121
- return fit(lines.slice(start, start + height), height);
122
- }
123
- verdict(record, width) {
124
- const out = [];
125
- const cap = record.projectMemoryCap === null ? 'uncapped' : record.projectMemoryCap;
126
- out.push(truncateToWidth(` authored ${theme.bold(record.authoredRung)} → cap ${cap} → ${theme.fg(record.finalRung === 'none' ? 'dim' : 'success', theme.bold(record.finalRung))}`, width));
127
- if (!record.docGate.pass)
128
- out.push(...wrapText(` document gate fails: ${record.docGate.reason}`, width).map((line) => theme.fg('warning', line)));
129
- if (record.entryGate !== null && !record.entryGate.pass) {
130
- out.push(...wrapText(` entry gate fails: ${record.entryGate.reason}`, width).map((line) => theme.fg('warning', line)));
131
- }
132
- if (record.matchedEntry === null && record.authoredEntries.length > 0) {
133
- out.push(theme.fg('dim', ` ${plural(record.authoredEntries.length, 'entry', 'entries')} for this event, none matched`));
134
- }
135
- if (record.shadowedBy !== null) {
136
- out.push(...wrapText(` shadowed by the ${record.shadowedBy.scope} copy at ${record.shadowedBy.path}`, width).map((line) => theme.fg('muted', line)));
137
- }
138
- else if (record.winner) {
139
- out.push(theme.fg('dim', ' wins this canonical name'));
140
- }
141
- return out;
142
- }
143
- /** All three rungs, not just the current one, so "what would demoting this
144
- * save" is read rather than computed. */
145
- costs(record, width) {
146
- const costs = rungCostsOf(record);
147
- const cells = ['content', 'preview', 'name'].map((rung) => {
148
- const text = `${padAnsi(rung, 8)}${padAnsi(formatCost(costs[rung]) || '~0', 8)}`;
149
- return rung === record.finalRung ? theme.fg('success', text) : theme.fg('dim', text);
150
- });
151
- return [truncateToWidth(` ${cells.join(' ')}`, width)];
152
- }
153
- acrossEvents(row, width) {
154
- return SURFACE_EVENTS.map((event) => {
155
- const record = row.records.get(event);
156
- const state = record === undefined ? 'silent' : cellStateOf(record);
157
- const rung = record === undefined ? 'none' : record.finalRung;
158
- const label = padAnsi(` ${EVENT_INITIALS[event]} ${event}`, 20);
159
- const word = STATE_WORD[state] ?? state;
160
- const body = `${label} ${padAnsi(word, 11)} ${rung}`;
161
- return truncateToWidth(state === 'delivers' || state === 'capped' ? body : theme.fg('dim', body), width);
162
- });
206
+ lines.push(` ${field('store path', theme.fg('dim', record.storePath), Math.max(0, width - 1))}`);
207
+ return { lines, cursor };
163
208
  }
164
209
  }
165
210
  /** The editable rows for one document, in the order the panel draws them. */
@@ -178,27 +223,22 @@ export function buildDetailRows(record) {
178
223
  { kind: 'move' },
179
224
  ];
180
225
  }
181
- function bodyLines(body) {
182
- const trimmed = body.replace(/\n+$/, '');
183
- return trimmed === '' ? 0 : trimmed.split('\n').length;
184
- }
185
- /** The document's opening lines, filling the rows the dossier would otherwise
186
- * leave blank. Bounded by the pane height: past that the reader is scrolling,
187
- * and the body opens in $EDITOR. */
188
- function bodyPreview(body, width, height) {
189
- const trimmed = body.trim();
190
- if (trimmed === '')
191
- return [theme.fg('dim', ' (empty)')];
192
- return trimmed
193
- .split('\n')
194
- .slice(0, Math.max(1, height))
195
- .map((line) => theme.fg('dim', truncateToWidth(` ${line}`, width)));
196
- }
197
226
  const FIELD_WIDTH = 15;
198
227
  function field(label, value, width) {
199
228
  const room = Math.max(1, width - FIELD_WIDTH - 2);
200
229
  return `${padAnsi(theme.fg('dim', label), FIELD_WIDTH)} ${truncateToWidth(value, room)}`;
201
230
  }
231
+ /** A focused row's explanation, hanging under its value rather than its label. */
232
+ function tailLines(text, width) {
233
+ const room = Math.max(1, width - FIELD_WIDTH - 2);
234
+ return wrapText(`\u2014 ${text}`, room).map((line) => `${' '.repeat(FIELD_WIDTH + 1)}${theme.fg('dim', line)}`);
235
+ }
236
+ /** Where the visible window sits in the whole document, drawn only while there
237
+ * is something outside it. */
238
+ function badge(from, to, total, width) {
239
+ const text = `${from}\u2013${to} / ${total}`;
240
+ return theme.fg('dim', `${' '.repeat(Math.max(0, width - text.length))}${text}`);
241
+ }
202
242
  function fit(lines, height) {
203
243
  const out = [...lines];
204
244
  while (out.length < height)
@@ -38,25 +38,22 @@ export declare class ContextDocsPanel {
38
38
  * rollups, which partition the corpus, so a fully collapsed list still
39
39
  * reports its size and a nested rollup is never counted twice. */
40
40
  get visibleCount(): number;
41
- setRows(rows: DocRow[], event: SurfaceEvent, selectedKey?: string): void;
41
+ /** Documents that actually load on the dialled event, counted the same way —
42
+ * the numerator of the heading's `N of M`. */
43
+ get loadedCount(): number;
44
+ setRows(rows: DocRow[], event: SurfaceEvent): void;
42
45
  setFilters(filters: Filters): void;
43
46
  move(delta: number): void;
44
47
  /** True when the focused line can be opened — a directory header. */
45
48
  get onGroup(): boolean;
46
- /** True when the focused line IS a document rather than merely standing for
47
- * one. A directory that is also a document is both, which is why the two
48
- * gestures split there: space opens it, enter edits it. */
49
- get onDocument(): boolean;
50
49
  toggleGroup(): void;
51
- /** Rebuild the drawn lines, keeping the cursor on the same document (or the
52
- * same directory), and clamping when what it was on is gone. A document that
53
- * owns a directory is drawn as that directory's header, so a held document
54
- * key matches there too. */
50
+ /** Rebuild the drawn lines, keeping the cursor on whatever it holds — the
51
+ * same document, or the same directory header, which a collapsed group draws
52
+ * no document row for — and clamping when what it was on is gone. */
55
53
  private relayout;
56
- render(width: number, height: number, focused: boolean, plans: PlanSet): string[];
57
- /** A directory header. Its stats and cost are the whole subtree's; the matrix
58
- * is drawn only when the directory is itself a document, which is what marks
59
- * the one row as both a document and a directory. */
54
+ render(width: number, height: number, focused: boolean): string[];
55
+ /** A directory header: a pure rollup of its whole subtree — how many of its
56
+ * documents load, and what they cost together. */
60
57
  private groupLine;
61
58
  private docLine;
62
59
  /** Pad rather than trim: this column sets the length of the divider drawn