@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.
@@ -0,0 +1,31 @@
1
+ import { type Rung, type SurfaceEvent } from '../../../../core/substrate/schema.js';
2
+ import type { DeliveryRecord } from '../../../../core/substrate/plan.js';
3
+ import type { NodeConfigSubject } from '../../../../core/substrate/subject-fields.js';
4
+ /** The profile project entry whose `memory` rung ceilings this document's
5
+ * store, or null when no project governs it. */
6
+ export interface CapOwner {
7
+ path: string;
8
+ memory: Rung;
9
+ }
10
+ /** What the read face needs beyond the record: the event it is judged against,
11
+ * the sibling events' records, and the two facts the shell resolves. */
12
+ export interface DossierContext {
13
+ event: SurfaceEvent;
14
+ records: Map<SurfaceEvent, DeliveryRecord>;
15
+ capOwner: CapOwner | null;
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;
20
+ }
21
+ /** The resting card, and — when `bodyOpen` — the document's own text beneath
22
+ * it in place of the affordance line that names it. */
23
+ export declare function renderDossierRead(record: DeliveryRecord, context: DossierContext, width: number, bodyOpen: boolean): string[];
24
+ /** Identity and routing — what the edit face keeps overhead of its field list,
25
+ * because they are the context a field is being edited against. */
26
+ export declare function renderDossierHeader(record: DeliveryRecord, context: DossierContext, width: number): string[];
27
+ /** Does it load here, what does it cost, and — only when there is one — what
28
+ * interfered. The three questions the old verdict, cost table and event grid
29
+ * each answered a piece of. */
30
+ export declare function renderDossierDelivery(record: DeliveryRecord, context: DossierContext, width: number): string[];
31
+ export declare function bodyLineCount(body: string): number;
@@ -0,0 +1,189 @@
1
+ // The dossier's read face: one memory document as the thing a person reads to
2
+ // decide whether it should load into this context.
3
+ //
4
+ // Pure rendering. It returns styled lines already wrapped to `width` and never
5
+ // truncates a value — the panel scrolls, so scrolling is the only thing that
6
+ // hides text, and "the routing line never ellipsizes" is structural rather than
7
+ // a rule someone has to remember.
8
+ //
9
+ // One rule decides what appears: a line renders only when it changes what the
10
+ // reader would conclude. A gate that is unset, a cap that lowers nothing, an
11
+ // event that delivers nothing — all absent.
12
+ import { getMarkdownTheme } from '@earendil-works/pi-coding-agent';
13
+ import { Markdown, visibleWidth, wrapTextWithAnsi } from '@earendil-works/pi-tui';
14
+ import { theme, wrapText } from '../../../../core/tui/panel.js';
15
+ import { SURFACE_EVENTS, rungRank } from '../../../../core/substrate/schema.js';
16
+ import { allMismatches, CLAUSE_SEPARATOR, explainRecordGate, formatClause, } from '../../../../core/substrate/gate-explain.js';
17
+ import { formatCost, plural, rungCostsOf } from './model.js';
18
+ /** Read-only, at rest: no word and no sentence. The refusal itself is a notice
19
+ * fired at the moment a write is attempted. */
20
+ const READ_ONLY_GLYPH = '\u2298';
21
+ /** Every column but the left margin, which every line of the card carries. */
22
+ const INDENT = ' ';
23
+ /** The resting card, and — when `bodyOpen` — the document's own text beneath
24
+ * it in place of the affordance line that names it. */
25
+ export function renderDossierRead(record, context, width, bodyOpen) {
26
+ const inner = Math.max(1, width - INDENT.length);
27
+ const out = renderDossierHeader(record, context, width);
28
+ if (record.doc.shortForm.trim() !== '') {
29
+ out.push('');
30
+ out.push(...indent(wrapped(record.doc.shortForm, inner)));
31
+ }
32
+ out.push(...renderDossierDelivery(record, context, width));
33
+ const lines = bodyLineCount(record.doc.body);
34
+ if (bodyOpen) {
35
+ out.push('');
36
+ out.push(...indent(new Markdown(record.doc.body, 0, 0, getMarkdownTheme()).render(inner)));
37
+ }
38
+ else if (lines === 0) {
39
+ out.push('', ...dim('no body — this document is its routing line', width));
40
+ }
41
+ else {
42
+ out.push('', ...dim(`enter read the body \u2014 ${plural(lines, 'line', 'lines')}`, width));
43
+ }
44
+ return out;
45
+ }
46
+ /** A dim line of the card, kept exactly as written while the row can hold it
47
+ * and wrapped when it cannot — so a narrow dossier costs a second row rather
48
+ * than an ellipsis. */
49
+ function dim(text, width) {
50
+ const inner = Math.max(1, width - INDENT.length);
51
+ return visibleWidth(text) <= inner
52
+ ? [`${INDENT}${theme.fg('dim', text)}`]
53
+ : indent(wrapped(text, inner)).map((line) => theme.fg('dim', line));
54
+ }
55
+ /** Identity and routing — what the edit face keeps overhead of its field list,
56
+ * because they are the context a field is being edited against. */
57
+ export function renderDossierHeader(record, context, width) {
58
+ const inner = Math.max(1, width - INDENT.length);
59
+ const out = identityLines(record, context, inner);
60
+ out.push('', theme.fg('dim', `${INDENT}READ WHEN`));
61
+ const routing = record.doc.whenAndWhyToRead.trim();
62
+ out.push(...(routing === ''
63
+ ? [theme.fg('dim', `${INDENT}Not declared.`)]
64
+ : indent(wrapped(routing, inner))));
65
+ return out;
66
+ }
67
+ /** The name, with the metadata cluster pushed to the far right of the same row
68
+ * whenever both fit. A name too long to share the row keeps every character
69
+ * and the cluster drops to its own row. */
70
+ function identityLines(record, context, inner) {
71
+ const meta = `${record.kind} \u00b7 ${record.scope}${record.nodeLocal ? ' \u00b7 node-local' : ''}${context.readOnly ? ` ${READ_ONLY_GLYPH}` : ''}`;
72
+ const names = wrapped(record.name, inner);
73
+ const out = names.map((line) => `${INDENT}${theme.fg('accent', theme.bold(line))}`);
74
+ const last = names[names.length - 1] ?? '';
75
+ const gap = inner - visibleWidth(last) - visibleWidth(meta);
76
+ if (gap >= 2) {
77
+ out[out.length - 1] = `${INDENT}${theme.fg('accent', theme.bold(last))}${' '.repeat(gap)}${theme.fg('dim', meta)}`;
78
+ return out;
79
+ }
80
+ // The cluster takes its own row, right-aligned under the name — or its own
81
+ // rows, when the dossier is too narrow to hold it in one.
82
+ out.push(...wrapped(meta, inner).map((line) => `${INDENT}${' '.repeat(Math.max(0, inner - visibleWidth(line)))}${theme.fg('dim', line)}`));
83
+ return out;
84
+ }
85
+ /** Does it load here, what does it cost, and — only when there is one — what
86
+ * interfered. The three questions the old verdict, cost table and event grid
87
+ * each answered a piece of. */
88
+ export function renderDossierDelivery(record, context, width) {
89
+ const inner = Math.max(1, width - INDENT.length);
90
+ const costs = rungCostsOf(record);
91
+ const delivers = record.finalRung !== 'none';
92
+ const ceilingLowered = rungRank(record.cappedRung) < rungRank(record.authoredRung);
93
+ const rung = delivers ? record.finalRung : 'nothing';
94
+ const cost = delivers ? ` \u00b7 ${formatCost(costs[record.finalRung]) || '~0'} of ${formatCost(costs.content) || '~0'}` : '';
95
+ const color = !delivers ? 'dim' : ceilingLowered ? 'warning' : 'success';
96
+ const asked = `${context.event} \u2192 `;
97
+ const answer = `${rung}${cost}`;
98
+ const out = [''];
99
+ if (visibleWidth(asked) + visibleWidth(answer) <= inner) {
100
+ out.push(`${INDENT}${theme.fg('dim', asked)}${theme.fg(color, rung)}${theme.fg('dim', cost)}`);
101
+ }
102
+ else {
103
+ // Too narrow for one row: the event names itself and its verdict wraps
104
+ // beneath, rather than the cost ellipsizing off the end.
105
+ out.push(...indent(wrapped(asked, inner)).map((line) => theme.fg('dim', line)));
106
+ out.push(...indent(wrapped(answer, inner)).map((line) => theme.fg(color, line)));
107
+ }
108
+ const warn = (text) => {
109
+ out.push(...indent(wrapped(text, inner)).map((line) => theme.fg('warning', line)));
110
+ };
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
+ }
134
+ if (record.matchedEntry === null && record.authoredEntries.length > 0) {
135
+ out.push(...indent(wrapped(`${plural(record.authoredEntries.length, 'entry', 'entries')} for this event, none matched`, inner)).map((line) => theme.fg('dim', line)));
136
+ }
137
+ if (ceilingLowered) {
138
+ warn(context.capOwner === null
139
+ ? `capped to ${record.cappedRung} by a profile ceiling on this store`
140
+ : `capped to ${record.cappedRung} by the ${context.capOwner.memory} ceiling on ${context.capOwner.path}`);
141
+ }
142
+ if (record.shadowedBy !== null) {
143
+ out.push(...indent(wrapped(`shadowed by the ${record.shadowedBy.scope} copy at ${record.shadowedBy.path}`, inner)).map((line) => theme.fg('muted', line)));
144
+ }
145
+ const also = SURFACE_EVENTS.filter((event) => event !== context.event)
146
+ .map((event) => ({ event, held: context.records.get(event) }))
147
+ .filter((pair) => pair.held !== undefined && pair.held.finalRung !== 'none')
148
+ .map((pair) => `${pair.event} \u2192 ${pair.held.finalRung}`);
149
+ if (also.length > 0) {
150
+ out.push(...indent(wrapped(`also ${also.join(' \u00b7 ')}`, inner)).map((line) => theme.fg('dim', line)));
151
+ }
152
+ return out;
153
+ }
154
+ export function bodyLineCount(body) {
155
+ const trimmed = body.replace(/\n+$/, '');
156
+ return trimmed === '' ? 0 : trimmed.split('\n').length;
157
+ }
158
+ function indent(lines) {
159
+ return lines.map((line) => `${INDENT}${line}`);
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
+ }
178
+ /** `wrapText` is the page's prose wrapper, and its word-oriented contract
179
+ * deliberately leaves a filesystem path or a compact JSON predicate intact.
180
+ * Values carrying such a token go through pi-tui's character-safe wrapper
181
+ * instead, so an over-wide token breaks rather than becoming an ellipsis. */
182
+ function wrapped(text, width) {
183
+ const clean = text.trim();
184
+ if (clean === '')
185
+ return [];
186
+ return clean.split(/\s+/).some((word) => visibleWidth(word) > width)
187
+ ? wrapTextWithAnsi(clean, width)
188
+ : wrapText(clean, width);
189
+ }
@@ -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;
@@ -63,6 +73,18 @@ export declare class ContextAdminShell implements Component, Focusable {
63
73
  * modal over the rows it is selecting. */
64
74
  private handleSearch;
65
75
  private handleDetail;
76
+ /** The resting face: reading, and the two gestures that leave it. */
77
+ private handleRead;
78
+ /** The field list. Every write gesture is resolved before it is started, so a
79
+ * read-only document refuses at the keypress rather than after a modal. */
80
+ private handleEdit;
81
+ /** The write one edit-face key starts, or undefined when the key writes
82
+ * nothing. `document` marks the writes a read-only document refuses — the
83
+ * profile ceiling is not one of them; it lives in the profile manifest. */
84
+ private writeGesture;
85
+ /** The refusal a read-only document owes the write just attempted, carried by
86
+ * the same footer channel every other write outcome uses. */
87
+ private refusesWrite;
66
88
  /** What the focused dossier row does when opened — the same write its letter
67
89
  * key starts, so a user who never learns the letters can still edit. */
68
90
  private activate;
@@ -90,8 +112,8 @@ export declare class ContextAdminShell implements Component, Focusable {
90
112
  private handleModal;
91
113
  private renderModal;
92
114
  private footer;
93
- /** What the focused row does when opened. A directory answers to space, a
94
- * 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. */
95
117
  private docsGesture;
96
118
  render(width: number): string[];
97
119
  /** What the delivering documents spend on this event. An estimate, and a