@north-light/crouter 0.3.243 → 0.3.244
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/sys/context/admin/detail-panel.d.ts +36 -13
- package/dist/commands/sys/context/admin/detail-panel.js +166 -129
- package/dist/commands/sys/context/admin/read-view.d.ts +27 -0
- package/dist/commands/sys/context/admin/read-view.js +152 -0
- package/dist/commands/sys/context/admin/shell.d.ts +12 -0
- package/dist/commands/sys/context/admin/shell.js +100 -34
- package/package.json +1 -1
- package/runtime.lock.json +2 -2
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import
|
|
1
|
+
import type { SurfaceEvent } from '../../../../core/substrate/schema.js';
|
|
2
2
|
import type { DeliveryRecord } from '../../../../core/substrate/plan.js';
|
|
3
|
+
import { type CapOwner } from './read-view.js';
|
|
3
4
|
import type { DocRow } from './docs-panel.js';
|
|
5
|
+
export type { CapOwner } from './read-view.js';
|
|
4
6
|
/** A field of the focused document the shell can write. `entry` carries an
|
|
5
7
|
* index into the document's whole surfaces list, not the focused event's
|
|
6
8
|
* subset, because that list is what a write replaces. */
|
|
@@ -26,10 +28,6 @@ export type DetailRow = {
|
|
|
26
28
|
} | {
|
|
27
29
|
kind: 'move';
|
|
28
30
|
};
|
|
29
|
-
export interface CapOwner {
|
|
30
|
-
path: string;
|
|
31
|
-
memory: Rung;
|
|
32
|
-
}
|
|
33
31
|
export declare class ContextDetailPanel {
|
|
34
32
|
private row;
|
|
35
33
|
private event;
|
|
@@ -37,20 +35,45 @@ export declare class ContextDetailPanel {
|
|
|
37
35
|
private readOnly;
|
|
38
36
|
private rows;
|
|
39
37
|
private index;
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
private
|
|
38
|
+
private face;
|
|
39
|
+
private open;
|
|
40
|
+
private scroll;
|
|
41
|
+
/** Last render's geometry, so a half-pane jump and the end-of-content clamp
|
|
42
|
+
* measure against real content rather than a counter nobody can see. */
|
|
43
|
+
private lastLines;
|
|
44
|
+
private lastHeight;
|
|
43
45
|
setContext(row: DocRow | undefined, event: SurfaceEvent, capOwner: CapOwner | null, readOnly: string | null): void;
|
|
46
|
+
/** True while the field list owns the pane, so the shell routes the write
|
|
47
|
+
* letters and teaches them in the footer. */
|
|
48
|
+
get editing(): boolean;
|
|
49
|
+
get bodyOpen(): boolean;
|
|
44
50
|
get selected(): DetailRow | undefined;
|
|
45
51
|
/** The entry index a rung cycle or a removal would act on. */
|
|
46
52
|
get selectedEntry(): number | undefined;
|
|
53
|
+
/** `j`/`k`: a line of the read face, a field of the edit face. */
|
|
47
54
|
move(delta: number): void;
|
|
55
|
+
/** `ctrl-d`/`ctrl-u`: half a pane, so a long body is crossed in jumps a
|
|
56
|
+
* reader can still follow. */
|
|
57
|
+
page(direction: 1 | -1): void;
|
|
58
|
+
/** `g`/`G`. */
|
|
59
|
+
jump(to: 'top' | 'bottom'): void;
|
|
60
|
+
/** `enter`: the document's own text, in place. False when there is no body,
|
|
61
|
+
* so the shell leaves the key alone. */
|
|
62
|
+
openBody(): boolean;
|
|
63
|
+
/** `e`: the field list. */
|
|
64
|
+
edit(): void;
|
|
65
|
+
/** `esc`/`h`: leave the edit face, else collapse the body. False means the
|
|
66
|
+
* read face is already at rest and the shell should leave the dossier. */
|
|
67
|
+
back(): boolean;
|
|
68
|
+
private scrollBy;
|
|
69
|
+
private maxScroll;
|
|
70
|
+
private record;
|
|
71
|
+
private context;
|
|
48
72
|
render(width: number, height: number, focused: boolean): string[];
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
*
|
|
52
|
-
private
|
|
53
|
-
private acrossEvents;
|
|
73
|
+
/** Today's field list, under the identity and delivery it is being edited
|
|
74
|
+
* against. A row's clarifying tail renders only while that row is focused:
|
|
75
|
+
* unfocused, twelve explanations are twelve lines of noise. */
|
|
76
|
+
private editLines;
|
|
54
77
|
}
|
|
55
78
|
/** The editable rows for one document, in the order the panel draws them. */
|
|
56
79
|
export declare function buildDetailRows(record: DeliveryRecord): DetailRow[];
|
|
@@ -1,19 +1,14 @@
|
|
|
1
|
-
// The dossier: one document
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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 {
|
|
8
|
-
import {
|
|
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';
|
|
@@ -21,9 +16,13 @@ export class ContextDetailPanel {
|
|
|
21
16
|
readOnly = null;
|
|
22
17
|
rows = [];
|
|
23
18
|
index = 0;
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
19
|
+
face = 'read';
|
|
20
|
+
open = false;
|
|
21
|
+
scroll = 0;
|
|
22
|
+
/** Last render's geometry, so a half-pane jump and the end-of-content clamp
|
|
23
|
+
* measure against real content rather than a counter nobody can see. */
|
|
24
|
+
lastLines = 1;
|
|
25
|
+
lastHeight = 1;
|
|
27
26
|
setContext(row, event, capOwner, readOnly) {
|
|
28
27
|
const changed = row?.key !== this.row?.key;
|
|
29
28
|
this.row = row;
|
|
@@ -33,59 +32,160 @@ export class ContextDetailPanel {
|
|
|
33
32
|
this.rows = row === undefined ? [] : buildDetailRows(row.anchor);
|
|
34
33
|
if (changed) {
|
|
35
34
|
this.index = 0;
|
|
36
|
-
this.
|
|
35
|
+
this.scroll = 0;
|
|
36
|
+
this.open = false;
|
|
37
|
+
this.face = 'read';
|
|
37
38
|
}
|
|
38
39
|
this.index = Math.max(0, Math.min(this.index, this.rows.length - 1));
|
|
39
40
|
}
|
|
41
|
+
/** True while the field list owns the pane, so the shell routes the write
|
|
42
|
+
* letters and teaches them in the footer. */
|
|
43
|
+
get editing() {
|
|
44
|
+
return this.face === 'edit';
|
|
45
|
+
}
|
|
46
|
+
get bodyOpen() {
|
|
47
|
+
return this.open;
|
|
48
|
+
}
|
|
40
49
|
get selected() {
|
|
41
|
-
return this.rows[this.index];
|
|
50
|
+
return this.face === 'edit' ? this.rows[this.index] : undefined;
|
|
42
51
|
}
|
|
43
52
|
/** The entry index a rung cycle or a removal would act on. */
|
|
44
53
|
get selectedEntry() {
|
|
45
54
|
const row = this.selected;
|
|
46
55
|
return row?.kind === 'entry' ? row.at : undefined;
|
|
47
56
|
}
|
|
57
|
+
// navigation
|
|
58
|
+
/** `j`/`k`: a line of the read face, a field of the edit face. */
|
|
48
59
|
move(delta) {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
this.tail = Math.max(0, this.tail + delta);
|
|
60
|
+
if (this.face === 'edit') {
|
|
61
|
+
this.index = Math.max(0, Math.min(this.rows.length - 1, this.index + delta));
|
|
52
62
|
return;
|
|
53
63
|
}
|
|
54
|
-
this.
|
|
55
|
-
this.tail = 0;
|
|
64
|
+
this.scrollBy(delta);
|
|
56
65
|
}
|
|
57
|
-
|
|
66
|
+
/** `ctrl-d`/`ctrl-u`: half a pane, so a long body is crossed in jumps a
|
|
67
|
+
* reader can still follow. */
|
|
68
|
+
page(direction) {
|
|
69
|
+
if (this.face === 'edit')
|
|
70
|
+
return;
|
|
71
|
+
this.scrollBy(direction * Math.max(1, Math.floor(this.lastHeight / 2)));
|
|
72
|
+
}
|
|
73
|
+
/** `g`/`G`. */
|
|
74
|
+
jump(to) {
|
|
75
|
+
if (this.face === 'edit')
|
|
76
|
+
return;
|
|
77
|
+
this.scroll = to === 'top' ? 0 : this.maxScroll();
|
|
78
|
+
}
|
|
79
|
+
/** `enter`: the document's own text, in place. False when there is no body,
|
|
80
|
+
* so the shell leaves the key alone. */
|
|
81
|
+
openBody() {
|
|
82
|
+
if (this.face === 'edit' || this.row === undefined)
|
|
83
|
+
return false;
|
|
84
|
+
if (bodyLineCount(this.record().doc.body) === 0)
|
|
85
|
+
return false;
|
|
86
|
+
this.open = true;
|
|
87
|
+
this.scroll = 0;
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
/** `e`: the field list. */
|
|
91
|
+
edit() {
|
|
92
|
+
if (this.row === undefined)
|
|
93
|
+
return;
|
|
94
|
+
this.face = 'edit';
|
|
95
|
+
this.scroll = 0;
|
|
96
|
+
}
|
|
97
|
+
/** `esc`/`h`: leave the edit face, else collapse the body. False means the
|
|
98
|
+
* read face is already at rest and the shell should leave the dossier. */
|
|
99
|
+
back() {
|
|
100
|
+
if (this.face === 'edit') {
|
|
101
|
+
this.face = 'read';
|
|
102
|
+
this.scroll = 0;
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
if (!this.open)
|
|
106
|
+
return false;
|
|
107
|
+
this.open = false;
|
|
108
|
+
this.scroll = 0;
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
scrollBy(delta) {
|
|
112
|
+
this.scroll = Math.max(0, Math.min(this.scroll + delta, this.maxScroll()));
|
|
113
|
+
}
|
|
114
|
+
maxScroll() {
|
|
115
|
+
return Math.max(0, this.lastLines - this.lastHeight);
|
|
116
|
+
}
|
|
117
|
+
record() {
|
|
58
118
|
const row = this.row;
|
|
59
|
-
|
|
119
|
+
return row.records.get(this.event) ?? row.anchor;
|
|
120
|
+
}
|
|
121
|
+
context() {
|
|
122
|
+
return {
|
|
123
|
+
event: this.event,
|
|
124
|
+
records: this.row?.records ?? new Map(),
|
|
125
|
+
capOwner: this.capOwner,
|
|
126
|
+
readOnly: this.readOnly !== null,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
// rendering
|
|
130
|
+
render(width, height, focused) {
|
|
131
|
+
if (this.row === undefined) {
|
|
132
|
+
this.lastLines = 1;
|
|
133
|
+
this.lastHeight = height;
|
|
60
134
|
return fit([theme.fg('dim', ' Select a document.')], height);
|
|
61
135
|
}
|
|
62
|
-
const record =
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
lines
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
136
|
+
const record = this.record();
|
|
137
|
+
const built = this.face === 'read'
|
|
138
|
+
? { lines: renderDossierRead(record, this.context(), width, this.open), cursor: undefined }
|
|
139
|
+
: this.editLines(record, width, focused);
|
|
140
|
+
const lines = built.lines;
|
|
141
|
+
// The badge is the pane's last row, so it costs a line of content only
|
|
142
|
+
// while there is content it cannot show anyway.
|
|
143
|
+
const overflows = lines.length > height;
|
|
144
|
+
const viewport = Math.max(1, overflows ? height - 1 : height);
|
|
145
|
+
this.lastLines = lines.length;
|
|
146
|
+
this.lastHeight = viewport;
|
|
147
|
+
let start;
|
|
148
|
+
if (built.cursor === undefined) {
|
|
149
|
+
this.scroll = Math.max(0, Math.min(this.scroll, Math.max(0, lines.length - viewport)));
|
|
150
|
+
start = this.scroll;
|
|
151
|
+
}
|
|
152
|
+
else {
|
|
153
|
+
start = Math.min(Math.max(0, built.cursor - Math.floor(viewport / 3)), Math.max(0, lines.length - viewport));
|
|
154
|
+
}
|
|
155
|
+
const window = fit(lines.slice(start, start + viewport), viewport);
|
|
156
|
+
if (!overflows)
|
|
157
|
+
return window;
|
|
158
|
+
window.push(badge(start + 1, Math.min(lines.length, start + viewport), lines.length, width));
|
|
159
|
+
return window;
|
|
160
|
+
}
|
|
161
|
+
/** Today's field list, under the identity and delivery it is being edited
|
|
162
|
+
* against. A row's clarifying tail renders only while that row is focused:
|
|
163
|
+
* unfocused, twelve explanations are twelve lines of noise. */
|
|
164
|
+
editLines(record, width, focused) {
|
|
165
|
+
const context = this.context();
|
|
166
|
+
const lines = [...renderDossierHeader(record, context, width), ...renderDossierDelivery(record, context, width), ''];
|
|
167
|
+
let cursor = 0;
|
|
168
|
+
const push = (at, body, tail) => {
|
|
169
|
+
const own = [body, ...(at === this.index && tail !== undefined ? tailLines(tail, width) : [])];
|
|
170
|
+
if (at === this.index)
|
|
171
|
+
cursor = lines.length;
|
|
172
|
+
for (const line of own) {
|
|
173
|
+
lines.push(at === this.index && focused
|
|
174
|
+
? theme.bg('selectedBg', padAnsi(truncateToWidth(line, width), width))
|
|
175
|
+
: at === this.index
|
|
176
|
+
? `${theme.fg('accent', '\u258c')}${truncateToWidth(line, Math.max(0, width - 1))}`
|
|
177
|
+
: ` ${truncateToWidth(line, Math.max(0, width - 1))}`);
|
|
178
|
+
}
|
|
78
179
|
};
|
|
79
180
|
let at = 0;
|
|
80
181
|
lines.push(theme.fg('dim', ' SURFACES'));
|
|
81
182
|
const surfaces = record.doc.surfaces;
|
|
82
|
-
if (surfaces.length === 0)
|
|
83
|
-
push(
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
}
|
|
183
|
+
if (surfaces.length === 0) {
|
|
184
|
+
lines.push(theme.fg('dim', ' no entries — this document only appears in its listing'));
|
|
185
|
+
}
|
|
186
|
+
for (const entry of surfaces) {
|
|
187
|
+
const text = describeSurfaceEntry(entry);
|
|
188
|
+
push(at++, entry.on === this.event ? text : theme.fg('dim', text));
|
|
89
189
|
}
|
|
90
190
|
push(at++, theme.fg('success', '+ add a surface entry'));
|
|
91
191
|
lines.push('');
|
|
@@ -95,71 +195,13 @@ export class ContextDetailPanel {
|
|
|
95
195
|
push(at++, field('slash', record.doc.slash ? 'yes' : theme.fg('dim', 'no'), width));
|
|
96
196
|
push(at++, field('routing', record.doc.whenAndWhyToRead, width));
|
|
97
197
|
push(at++, field('short form', record.doc.shortForm === '' ? theme.fg('dim', 'none') : record.doc.shortForm, width));
|
|
98
|
-
push(at++, field('body', theme.fg('dim',
|
|
99
|
-
push(at++, field('profile cap', this.capOwner === null
|
|
100
|
-
?
|
|
101
|
-
:
|
|
198
|
+
push(at++, field('body', theme.fg('dim', plural(bodyLineCount(record.doc.body), 'line', 'lines')), width), 'opens in $EDITOR');
|
|
199
|
+
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
|
|
200
|
+
? 'no profile project governs this store'
|
|
201
|
+
: 'the highest rung any document from this store can deliver at');
|
|
102
202
|
push(at++, field('canonical name', record.name, width));
|
|
103
|
-
lines.push('');
|
|
104
|
-
lines
|
|
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
|
-
});
|
|
203
|
+
lines.push(` ${field('store path', theme.fg('dim', record.storePath), Math.max(0, width - 1))}`);
|
|
204
|
+
return { lines, cursor };
|
|
163
205
|
}
|
|
164
206
|
}
|
|
165
207
|
/** The editable rows for one document, in the order the panel draws them. */
|
|
@@ -178,27 +220,22 @@ export function buildDetailRows(record) {
|
|
|
178
220
|
{ kind: 'move' },
|
|
179
221
|
];
|
|
180
222
|
}
|
|
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
223
|
const FIELD_WIDTH = 15;
|
|
198
224
|
function field(label, value, width) {
|
|
199
225
|
const room = Math.max(1, width - FIELD_WIDTH - 2);
|
|
200
226
|
return `${padAnsi(theme.fg('dim', label), FIELD_WIDTH)} ${truncateToWidth(value, room)}`;
|
|
201
227
|
}
|
|
228
|
+
/** A focused row's explanation, hanging under its value rather than its label. */
|
|
229
|
+
function tailLines(text, width) {
|
|
230
|
+
const room = Math.max(1, width - FIELD_WIDTH - 2);
|
|
231
|
+
return wrapText(`\u2014 ${text}`, room).map((line) => `${' '.repeat(FIELD_WIDTH + 1)}${theme.fg('dim', line)}`);
|
|
232
|
+
}
|
|
233
|
+
/** Where the visible window sits in the whole document, drawn only while there
|
|
234
|
+
* is something outside it. */
|
|
235
|
+
function badge(from, to, total, width) {
|
|
236
|
+
const text = `${from}\u2013${to} / ${total}`;
|
|
237
|
+
return theme.fg('dim', `${' '.repeat(Math.max(0, width - text.length))}${text}`);
|
|
238
|
+
}
|
|
202
239
|
function fit(lines, height) {
|
|
203
240
|
const out = [...lines];
|
|
204
241
|
while (out.length < height)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { type Rung, type SurfaceEvent } from '../../../../core/substrate/schema.js';
|
|
2
|
+
import type { DeliveryRecord } from '../../../../core/substrate/plan.js';
|
|
3
|
+
/** The profile project entry whose `memory` rung ceilings this document's
|
|
4
|
+
* store, or null when no project governs it. */
|
|
5
|
+
export interface CapOwner {
|
|
6
|
+
path: string;
|
|
7
|
+
memory: Rung;
|
|
8
|
+
}
|
|
9
|
+
/** What the read face needs beyond the record: the event it is judged against,
|
|
10
|
+
* the sibling events' records, and the two facts the shell resolves. */
|
|
11
|
+
export interface DossierContext {
|
|
12
|
+
event: SurfaceEvent;
|
|
13
|
+
records: Map<SurfaceEvent, DeliveryRecord>;
|
|
14
|
+
capOwner: CapOwner | null;
|
|
15
|
+
readOnly: boolean;
|
|
16
|
+
}
|
|
17
|
+
/** The resting card, and — when `bodyOpen` — the document's own text beneath
|
|
18
|
+
* it in place of the affordance line that names it. */
|
|
19
|
+
export declare function renderDossierRead(record: DeliveryRecord, context: DossierContext, width: number, bodyOpen: boolean): string[];
|
|
20
|
+
/** Identity and routing — what the edit face keeps overhead of its field list,
|
|
21
|
+
* because they are the context a field is being edited against. */
|
|
22
|
+
export declare function renderDossierHeader(record: DeliveryRecord, context: DossierContext, width: number): string[];
|
|
23
|
+
/** Does it load here, what does it cost, and — only when there is one — what
|
|
24
|
+
* interfered. The three questions the old verdict, cost table and event grid
|
|
25
|
+
* each answered a piece of. */
|
|
26
|
+
export declare function renderDossierDelivery(record: DeliveryRecord, context: DossierContext, width: number): string[];
|
|
27
|
+
export declare function bodyLineCount(body: string): number;
|
|
@@ -0,0 +1,152 @@
|
|
|
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 { formatCost, plural, rungCostsOf } from './model.js';
|
|
17
|
+
/** Read-only, at rest: no word and no sentence. The refusal itself is a notice
|
|
18
|
+
* fired at the moment a write is attempted. */
|
|
19
|
+
const READ_ONLY_GLYPH = '\u2298';
|
|
20
|
+
/** Every column but the left margin, which every line of the card carries. */
|
|
21
|
+
const INDENT = ' ';
|
|
22
|
+
/** The resting card, and — when `bodyOpen` — the document's own text beneath
|
|
23
|
+
* it in place of the affordance line that names it. */
|
|
24
|
+
export function renderDossierRead(record, context, width, bodyOpen) {
|
|
25
|
+
const inner = Math.max(1, width - INDENT.length);
|
|
26
|
+
const out = renderDossierHeader(record, context, width);
|
|
27
|
+
if (record.doc.shortForm.trim() !== '') {
|
|
28
|
+
out.push('');
|
|
29
|
+
out.push(...indent(wrapped(record.doc.shortForm, inner)));
|
|
30
|
+
}
|
|
31
|
+
out.push(...renderDossierDelivery(record, context, width));
|
|
32
|
+
const lines = bodyLineCount(record.doc.body);
|
|
33
|
+
if (bodyOpen) {
|
|
34
|
+
out.push('');
|
|
35
|
+
out.push(...indent(new Markdown(record.doc.body, 0, 0, getMarkdownTheme()).render(inner)));
|
|
36
|
+
}
|
|
37
|
+
else if (lines === 0) {
|
|
38
|
+
out.push('', ...dim('no body — this document is its routing line', width));
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
out.push('', ...dim(`enter read the body \u2014 ${plural(lines, 'line', 'lines')}`, width));
|
|
42
|
+
}
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
/** A dim line of the card, kept exactly as written while the row can hold it
|
|
46
|
+
* and wrapped when it cannot — so a narrow dossier costs a second row rather
|
|
47
|
+
* than an ellipsis. */
|
|
48
|
+
function dim(text, width) {
|
|
49
|
+
const inner = Math.max(1, width - INDENT.length);
|
|
50
|
+
return visibleWidth(text) <= inner
|
|
51
|
+
? [`${INDENT}${theme.fg('dim', text)}`]
|
|
52
|
+
: indent(wrapped(text, inner)).map((line) => theme.fg('dim', line));
|
|
53
|
+
}
|
|
54
|
+
/** Identity and routing — what the edit face keeps overhead of its field list,
|
|
55
|
+
* because they are the context a field is being edited against. */
|
|
56
|
+
export function renderDossierHeader(record, context, width) {
|
|
57
|
+
const inner = Math.max(1, width - INDENT.length);
|
|
58
|
+
const out = identityLines(record, context, inner);
|
|
59
|
+
out.push('', theme.fg('dim', `${INDENT}READ WHEN`));
|
|
60
|
+
const routing = record.doc.whenAndWhyToRead.trim();
|
|
61
|
+
out.push(...(routing === ''
|
|
62
|
+
? [theme.fg('dim', `${INDENT}Not declared.`)]
|
|
63
|
+
: indent(wrapped(routing, inner))));
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
/** The name, with the metadata cluster pushed to the far right of the same row
|
|
67
|
+
* whenever both fit. A name too long to share the row keeps every character
|
|
68
|
+
* and the cluster drops to its own row. */
|
|
69
|
+
function identityLines(record, context, inner) {
|
|
70
|
+
const meta = `${record.kind} \u00b7 ${record.scope}${record.nodeLocal ? ' \u00b7 node-local' : ''}${context.readOnly ? ` ${READ_ONLY_GLYPH}` : ''}`;
|
|
71
|
+
const names = wrapped(record.name, inner);
|
|
72
|
+
const out = names.map((line) => `${INDENT}${theme.fg('accent', theme.bold(line))}`);
|
|
73
|
+
const last = names[names.length - 1] ?? '';
|
|
74
|
+
const gap = inner - visibleWidth(last) - visibleWidth(meta);
|
|
75
|
+
if (gap >= 2) {
|
|
76
|
+
out[out.length - 1] = `${INDENT}${theme.fg('accent', theme.bold(last))}${' '.repeat(gap)}${theme.fg('dim', meta)}`;
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
// The cluster takes its own row, right-aligned under the name — or its own
|
|
80
|
+
// rows, when the dossier is too narrow to hold it in one.
|
|
81
|
+
out.push(...wrapped(meta, inner).map((line) => `${INDENT}${' '.repeat(Math.max(0, inner - visibleWidth(line)))}${theme.fg('dim', line)}`));
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
/** Does it load here, what does it cost, and — only when there is one — what
|
|
85
|
+
* interfered. The three questions the old verdict, cost table and event grid
|
|
86
|
+
* each answered a piece of. */
|
|
87
|
+
export function renderDossierDelivery(record, context, width) {
|
|
88
|
+
const inner = Math.max(1, width - INDENT.length);
|
|
89
|
+
const costs = rungCostsOf(record);
|
|
90
|
+
const delivers = record.finalRung !== 'none';
|
|
91
|
+
const ceilingLowered = rungRank(record.cappedRung) < rungRank(record.authoredRung);
|
|
92
|
+
const rung = delivers ? record.finalRung : 'nothing';
|
|
93
|
+
const cost = delivers ? ` \u00b7 ${formatCost(costs[record.finalRung]) || '~0'} of ${formatCost(costs.content) || '~0'}` : '';
|
|
94
|
+
const color = !delivers ? 'dim' : ceilingLowered ? 'warning' : 'success';
|
|
95
|
+
const asked = `${context.event} \u2192 `;
|
|
96
|
+
const answer = `${rung}${cost}`;
|
|
97
|
+
const out = [''];
|
|
98
|
+
if (visibleWidth(asked) + visibleWidth(answer) <= inner) {
|
|
99
|
+
out.push(`${INDENT}${theme.fg('dim', asked)}${theme.fg(color, rung)}${theme.fg('dim', cost)}`);
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
// Too narrow for one row: the event names itself and its verdict wraps
|
|
103
|
+
// beneath, rather than the cost ellipsizing off the end.
|
|
104
|
+
out.push(...indent(wrapped(asked, inner)).map((line) => theme.fg('dim', line)));
|
|
105
|
+
out.push(...indent(wrapped(answer, inner)).map((line) => theme.fg(color, line)));
|
|
106
|
+
}
|
|
107
|
+
const warn = (text) => {
|
|
108
|
+
out.push(...indent(wrapped(text, inner)).map((line) => theme.fg('warning', line)));
|
|
109
|
+
};
|
|
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}`);
|
|
114
|
+
if (record.matchedEntry === null && record.authoredEntries.length > 0) {
|
|
115
|
+
out.push(...indent(wrapped(`${plural(record.authoredEntries.length, 'entry', 'entries')} for this event, none matched`, inner)).map((line) => theme.fg('dim', line)));
|
|
116
|
+
}
|
|
117
|
+
if (ceilingLowered) {
|
|
118
|
+
warn(context.capOwner === null
|
|
119
|
+
? `capped to ${record.cappedRung} by a profile ceiling on this store`
|
|
120
|
+
: `capped to ${record.cappedRung} by the ${context.capOwner.memory} ceiling on ${context.capOwner.path}`);
|
|
121
|
+
}
|
|
122
|
+
if (record.shadowedBy !== null) {
|
|
123
|
+
out.push(...indent(wrapped(`shadowed by the ${record.shadowedBy.scope} copy at ${record.shadowedBy.path}`, inner)).map((line) => theme.fg('muted', line)));
|
|
124
|
+
}
|
|
125
|
+
const also = SURFACE_EVENTS.filter((event) => event !== context.event)
|
|
126
|
+
.map((event) => ({ event, held: context.records.get(event) }))
|
|
127
|
+
.filter((pair) => pair.held !== undefined && pair.held.finalRung !== 'none')
|
|
128
|
+
.map((pair) => `${pair.event} \u2192 ${pair.held.finalRung}`);
|
|
129
|
+
if (also.length > 0) {
|
|
130
|
+
out.push(...indent(wrapped(`also ${also.join(' \u00b7 ')}`, inner)).map((line) => theme.fg('dim', line)));
|
|
131
|
+
}
|
|
132
|
+
return out;
|
|
133
|
+
}
|
|
134
|
+
export function bodyLineCount(body) {
|
|
135
|
+
const trimmed = body.replace(/\n+$/, '');
|
|
136
|
+
return trimmed === '' ? 0 : trimmed.split('\n').length;
|
|
137
|
+
}
|
|
138
|
+
function indent(lines) {
|
|
139
|
+
return lines.map((line) => `${INDENT}${line}`);
|
|
140
|
+
}
|
|
141
|
+
/** `wrapText` is the page's prose wrapper, and its word-oriented contract
|
|
142
|
+
* deliberately leaves a filesystem path or a compact JSON predicate intact.
|
|
143
|
+
* Values carrying such a token go through pi-tui's character-safe wrapper
|
|
144
|
+
* instead, so an over-wide token breaks rather than becoming an ellipsis. */
|
|
145
|
+
function wrapped(text, width) {
|
|
146
|
+
const clean = text.trim();
|
|
147
|
+
if (clean === '')
|
|
148
|
+
return [];
|
|
149
|
+
return clean.split(/\s+/).some((word) => visibleWidth(word) > width)
|
|
150
|
+
? wrapTextWithAnsi(clean, width)
|
|
151
|
+
: wrapText(clean, width);
|
|
152
|
+
}
|
|
@@ -63,6 +63,18 @@ export declare class ContextAdminShell implements Component, Focusable {
|
|
|
63
63
|
* modal over the rows it is selecting. */
|
|
64
64
|
private handleSearch;
|
|
65
65
|
private handleDetail;
|
|
66
|
+
/** The resting face: reading, and the two gestures that leave it. */
|
|
67
|
+
private handleRead;
|
|
68
|
+
/** The field list. Every write gesture is resolved before it is started, so a
|
|
69
|
+
* read-only document refuses at the keypress rather than after a modal. */
|
|
70
|
+
private handleEdit;
|
|
71
|
+
/** The write one edit-face key starts, or undefined when the key writes
|
|
72
|
+
* nothing. `document` marks the writes a read-only document refuses — the
|
|
73
|
+
* profile ceiling is not one of them; it lives in the profile manifest. */
|
|
74
|
+
private writeGesture;
|
|
75
|
+
/** The refusal a read-only document owes the write just attempted, carried by
|
|
76
|
+
* the same footer channel every other write outcome uses. */
|
|
77
|
+
private refusesWrite;
|
|
66
78
|
/** What the focused dossier row does when opened — the same write its letter
|
|
67
79
|
* key starts, so a user who never learns the letters can still edit. */
|
|
68
80
|
private activate;
|
|
@@ -158,7 +158,10 @@ export class ContextAdminShell {
|
|
|
158
158
|
this.setSnapshot(cycleAxis(this.snapshot, 'event', step));
|
|
159
159
|
return;
|
|
160
160
|
}
|
|
161
|
-
|
|
161
|
+
// `crtr.setup.cancel` also claims ctrl+d, so the read face's half-pane
|
|
162
|
+
// gesture has to outrank it or paging a body would close the page instead.
|
|
163
|
+
const pagesBody = this.zone === 'detail' && !this.detail.editing && matchesKey(data, 'ctrl+d');
|
|
164
|
+
if (!pagesBody && (matchesKey(data, 'q') || this.matches('crtr.setup.cancel', data))) {
|
|
162
165
|
this.close();
|
|
163
166
|
return;
|
|
164
167
|
}
|
|
@@ -268,35 +271,101 @@ export class ContextAdminShell {
|
|
|
268
271
|
this.tui.requestRender();
|
|
269
272
|
}
|
|
270
273
|
handleDetail(data) {
|
|
274
|
+
if (this.detail.editing)
|
|
275
|
+
this.handleEdit(data);
|
|
276
|
+
else
|
|
277
|
+
this.handleRead(data);
|
|
278
|
+
}
|
|
279
|
+
/** The resting face: reading, and the two gestures that leave it. */
|
|
280
|
+
handleRead(data) {
|
|
271
281
|
if (this.isDown(data))
|
|
272
282
|
this.detail.move(1);
|
|
273
283
|
else if (this.isUp(data))
|
|
274
284
|
this.detail.move(-1);
|
|
275
|
-
else if (
|
|
276
|
-
this.
|
|
277
|
-
else if (matchesKey(data, '
|
|
278
|
-
this.
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
285
|
+
else if (matchesKey(data, 'ctrl+d'))
|
|
286
|
+
this.detail.page(1);
|
|
287
|
+
else if (matchesKey(data, 'ctrl+u'))
|
|
288
|
+
this.detail.page(-1);
|
|
289
|
+
else if (data === 'G' || matchesKey(data, 'shift+g'))
|
|
290
|
+
this.detail.jump('bottom');
|
|
291
|
+
else if (data === 'g' || matchesKey(data, 'g'))
|
|
292
|
+
this.detail.jump('top');
|
|
293
|
+
else if (data === 'e' || matchesKey(data, 'e'))
|
|
294
|
+
this.detail.edit();
|
|
295
|
+
else if (this.isDescend(data))
|
|
296
|
+
this.detail.openBody();
|
|
297
|
+
else if (this.isAscend(data) || matchesKey(data, 'escape')) {
|
|
298
|
+
if (!this.detail.back())
|
|
299
|
+
this.zone = 'docs';
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
/** The field list. Every write gesture is resolved before it is started, so a
|
|
303
|
+
* read-only document refuses at the keypress rather than after a modal. */
|
|
304
|
+
handleEdit(data) {
|
|
305
|
+
if (this.isDown(data)) {
|
|
306
|
+
this.detail.move(1);
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
if (this.isUp(data)) {
|
|
310
|
+
this.detail.move(-1);
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
if (this.isAscend(data) || matchesKey(data, 'escape')) {
|
|
314
|
+
this.detail.back();
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
const gesture = this.writeGesture(data);
|
|
318
|
+
if (gesture === undefined)
|
|
319
|
+
return;
|
|
320
|
+
if (gesture.document && this.refusesWrite())
|
|
321
|
+
return;
|
|
322
|
+
gesture.start();
|
|
323
|
+
}
|
|
324
|
+
/** The write one edit-face key starts, or undefined when the key writes
|
|
325
|
+
* nothing. `document` marks the writes a read-only document refuses — the
|
|
326
|
+
* profile ceiling is not one of them; it lives in the profile manifest. */
|
|
327
|
+
writeGesture(data) {
|
|
328
|
+
const doc = (start) => ({ start, document: true });
|
|
329
|
+
if (matchesKey(data, 'enter') || matchesKey(data, 'space') || this.matches('crtr.setup.toggle', data)) {
|
|
330
|
+
const row = this.detail.selected;
|
|
331
|
+
if (row === undefined)
|
|
332
|
+
return undefined;
|
|
333
|
+
return row.kind === 'cap' ? { start: () => this.editCap(), document: false } : doc(() => this.activate());
|
|
334
|
+
}
|
|
335
|
+
if (matchesKey(data, 'm'))
|
|
336
|
+
return { start: () => this.editCap(), document: false };
|
|
337
|
+
if (matchesKey(data, 'b'))
|
|
338
|
+
return doc(() => this.editBody());
|
|
339
|
+
if (matchesKey(data, 'x'))
|
|
340
|
+
return doc(() => this.removeEntry());
|
|
341
|
+
if (matchesKey(data, 'e'))
|
|
342
|
+
return doc(() => this.composeEntry());
|
|
343
|
+
if (matchesKey(data, 'g'))
|
|
344
|
+
return doc(() => this.editGate());
|
|
345
|
+
if (matchesKey(data, 'u'))
|
|
346
|
+
return doc(() => this.flip('unlisted'));
|
|
347
|
+
if (matchesKey(data, 's'))
|
|
348
|
+
return doc(() => this.flip('slash'));
|
|
349
|
+
if (matchesKey(data, 'r'))
|
|
350
|
+
return doc(() => this.editMove());
|
|
351
|
+
if (matchesKey(data, 'w'))
|
|
352
|
+
return doc(() => this.editRouting());
|
|
353
|
+
if (matchesKey(data, 'f'))
|
|
354
|
+
return doc(() => this.editShortForm());
|
|
355
|
+
return undefined;
|
|
356
|
+
}
|
|
357
|
+
/** The refusal a read-only document owes the write just attempted, carried by
|
|
358
|
+
* the same footer channel every other write outcome uses. */
|
|
359
|
+
refusesWrite() {
|
|
360
|
+
const record = this.record;
|
|
361
|
+
if (record === undefined)
|
|
362
|
+
return false;
|
|
363
|
+
const refusal = readOnlyReason(record, this.snapshot.profileId);
|
|
364
|
+
if (refusal === null)
|
|
365
|
+
return false;
|
|
366
|
+
this.notice = { text: refusal, ok: false };
|
|
367
|
+
this.tui.requestRender();
|
|
368
|
+
return true;
|
|
300
369
|
}
|
|
301
370
|
/** What the focused dossier row does when opened — the same write its letter
|
|
302
371
|
* key starts, so a user who never learns the letters can still edit. */
|
|
@@ -472,13 +541,6 @@ export class ContextAdminShell {
|
|
|
472
541
|
const record = this.record;
|
|
473
542
|
if (record === undefined)
|
|
474
543
|
return;
|
|
475
|
-
// Checked before the terminal leaves for $EDITOR, so a read-only record
|
|
476
|
-
// refuses in place rather than after a whole editing session.
|
|
477
|
-
const refusal = readOnlyReason(record, this.snapshot.profileId);
|
|
478
|
-
if (refusal !== null) {
|
|
479
|
-
this.notice = { text: refusal, ok: false };
|
|
480
|
-
return;
|
|
481
|
-
}
|
|
482
544
|
let before;
|
|
483
545
|
try {
|
|
484
546
|
before = readBody(record);
|
|
@@ -651,7 +713,11 @@ export class ContextAdminShell {
|
|
|
651
713
|
? `${move} row · space change · enter documents`
|
|
652
714
|
: this.zone === 'docs'
|
|
653
715
|
? `${move} row · ${this.docsGesture()} · / search`
|
|
654
|
-
:
|
|
716
|
+
: this.detail.editing
|
|
717
|
+
? `${move} field · enter edit · e add · x remove · g gate · b body · u unlisted · s slash · m cap · r rename · esc read`
|
|
718
|
+
: this.detail.bodyOpen
|
|
719
|
+
? `${move} scroll · ctrl-d/u page · g/G ends · esc collapse`
|
|
720
|
+
: `${move} scroll · enter body · e edit · esc list`;
|
|
655
721
|
const right = `${this.label('crtr.setup.tab-next', 'tab')} event · ${this.label('crtr.setup.cancel', 'q')} close`;
|
|
656
722
|
const gap = width - visibleWidth(left) - visibleWidth(right);
|
|
657
723
|
return gap >= 2
|
package/package.json
CHANGED
package/runtime.lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@north-light/crouter",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.244",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@north-light/crouter",
|
|
9
|
-
"version": "0.3.
|
|
9
|
+
"version": "0.3.244",
|
|
10
10
|
"hasInstallScript": true,
|
|
11
11
|
"license": "MIT",
|
|
12
12
|
"dependencies": {
|