@north-light/crouter 0.3.242 → 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/clients/attach/viewer.js +366 -366
- 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/docs-panel.d.ts +17 -7
- package/dist/commands/sys/context/admin/docs-panel.js +43 -23
- package/dist/commands/sys/context/admin/list-view.d.ts +22 -11
- package/dist/commands/sys/context/admin/list-view.js +112 -53
- 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 +15 -0
- package/dist/commands/sys/context/admin/shell.js +111 -36
- package/package.json +1 -1
- package/runtime.lock.json +2 -2
|
@@ -17,34 +17,45 @@ export interface Filters {
|
|
|
17
17
|
query: string;
|
|
18
18
|
}
|
|
19
19
|
export declare const NO_FILTERS: Filters;
|
|
20
|
-
/** One drawn line. A group carries what its
|
|
21
|
-
* list still states where the corpus and its cost sit.
|
|
20
|
+
/** One drawn line. A group carries what its whole subtree adds up to, so a
|
|
21
|
+
* collapsed list still states where the corpus and its cost sit. `depth` is
|
|
22
|
+
* how many name segments sit above the line, which the renderer spends on
|
|
23
|
+
* indent. */
|
|
22
24
|
export type ListLine = {
|
|
23
25
|
kind: 'group';
|
|
24
26
|
key: string;
|
|
25
27
|
label: string;
|
|
28
|
+
depth: number;
|
|
26
29
|
docs: number;
|
|
27
30
|
rungs: Record<Exclude<Rung, 'none'>, number>;
|
|
28
31
|
cost: number;
|
|
29
32
|
expanded: boolean;
|
|
30
|
-
/** The
|
|
31
|
-
*
|
|
33
|
+
/** The document the dossier describes while the cursor rests on the
|
|
34
|
+
* header: this directory's own document when it has one, else the first
|
|
35
|
+
* document in its subtree. */
|
|
32
36
|
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;
|
|
33
40
|
} | {
|
|
34
41
|
kind: 'doc';
|
|
35
42
|
key: string;
|
|
36
43
|
row: DocRow;
|
|
37
44
|
label: string;
|
|
45
|
+
depth: number;
|
|
38
46
|
cost: number;
|
|
39
47
|
};
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
* re-plan removed instead of accumulating them.
|
|
48
|
+
/** Every group key the rows can produce — one per proper name prefix, since a
|
|
49
|
+
* prefix exists only because something sits under it — so collapse state can
|
|
50
|
+
* drop the keys a re-plan removed instead of accumulating them. The leading
|
|
51
|
+
* section is a key only when a name with no `/` owns no directory, which is
|
|
52
|
+
* exactly when it is not itself one of those prefixes. */
|
|
43
53
|
export declare function groupKeysOf(rows: readonly DocRow[]): Set<string>;
|
|
44
|
-
/** The lines to draw, in corpus order: the leading section, then each
|
|
45
|
-
* order of first appearance
|
|
46
|
-
*
|
|
47
|
-
*
|
|
54
|
+
/** The lines to draw, in corpus order: the leading section, then each directory
|
|
55
|
+
* in order of first appearance, each recursing into its own members only when
|
|
56
|
+
* it is open. A live query force-expands every directory, which is exactly the
|
|
57
|
+
* ancestor chain of every surviving match, without touching `expanded` — so
|
|
58
|
+
* clearing the query restores what the user collapsed. */
|
|
48
59
|
export declare function buildListView(rows: readonly DocRow[], filters: Filters, expanded: ReadonlySet<string>, event: SurfaceEvent): ListLine[];
|
|
49
60
|
/** What the whole filtered set costs, split by the rung it arrives at. */
|
|
50
61
|
export declare function costTotals(rows: readonly DocRow[], event: SurfaceEvent): {
|
|
@@ -4,78 +4,137 @@
|
|
|
4
4
|
import { fuzzyMatch } from '../../../../core/tui/fuzzy.js';
|
|
5
5
|
import { deliveredCost, rungCostsOf } from './model.js';
|
|
6
6
|
export const NO_FILTERS = { rung: 'any', origin: 'any', kind: 'any', state: 'any', query: '' };
|
|
7
|
-
/** Documents whose canonical name carries no `/`
|
|
8
|
-
* rather than becoming a crowd of one-member groups. */
|
|
7
|
+
/** Documents whose canonical name carries no `/` and owns no directory share
|
|
8
|
+
* one leading section rather than becoming a crowd of one-member groups. */
|
|
9
9
|
const LEADING_KEY = '';
|
|
10
10
|
const LEADING_LABEL = 'top level';
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
* re-plan removed instead of accumulating them. */
|
|
11
|
+
/** Every group key the rows can produce — one per proper name prefix, since a
|
|
12
|
+
* prefix exists only because something sits under it — so collapse state can
|
|
13
|
+
* drop the keys a re-plan removed instead of accumulating them. The leading
|
|
14
|
+
* section is a key only when a name with no `/` owns no directory, which is
|
|
15
|
+
* exactly when it is not itself one of those prefixes. */
|
|
17
16
|
export function groupKeysOf(rows) {
|
|
18
|
-
|
|
17
|
+
const keys = new Set();
|
|
18
|
+
const unslashed = [];
|
|
19
|
+
for (const row of rows) {
|
|
20
|
+
if (row.name.includes('/')) {
|
|
21
|
+
for (let cut = row.name.indexOf('/'); cut >= 0; cut = row.name.indexOf('/', cut + 1)) {
|
|
22
|
+
keys.add(row.name.slice(0, cut));
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
else
|
|
26
|
+
unslashed.push(row.name);
|
|
27
|
+
}
|
|
28
|
+
if (unslashed.some((name) => !keys.has(name)))
|
|
29
|
+
keys.add(LEADING_KEY);
|
|
30
|
+
return keys;
|
|
19
31
|
}
|
|
20
|
-
/** The lines to draw, in corpus order: the leading section, then each
|
|
21
|
-
* order of first appearance
|
|
22
|
-
*
|
|
23
|
-
*
|
|
32
|
+
/** The lines to draw, in corpus order: the leading section, then each directory
|
|
33
|
+
* in order of first appearance, each recursing into its own members only when
|
|
34
|
+
* it is open. A live query force-expands every directory, which is exactly the
|
|
35
|
+
* ancestor chain of every surviving match, without touching `expanded` — so
|
|
36
|
+
* clearing the query restores what the user collapsed. */
|
|
24
37
|
export function buildListView(rows, filters, expanded, event) {
|
|
25
|
-
const
|
|
26
|
-
const order = [];
|
|
38
|
+
const root = { key: '', segment: '', depth: -1, children: new Map(), selves: [] };
|
|
27
39
|
for (const row of rows) {
|
|
28
40
|
if (!passes(row, row.records.get(event), filters))
|
|
29
41
|
continue;
|
|
30
|
-
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
42
|
+
let dir = root;
|
|
43
|
+
for (const segment of row.name.split('/')) {
|
|
44
|
+
let child = dir.children.get(segment);
|
|
45
|
+
if (child === undefined) {
|
|
46
|
+
child = {
|
|
47
|
+
key: dir.depth < 0 ? segment : `${dir.key}/${segment}`,
|
|
48
|
+
segment,
|
|
49
|
+
depth: dir.depth + 1,
|
|
50
|
+
children: new Map(),
|
|
51
|
+
selves: [],
|
|
52
|
+
};
|
|
53
|
+
dir.children.set(segment, child);
|
|
54
|
+
}
|
|
55
|
+
dir = child;
|
|
35
56
|
}
|
|
36
|
-
|
|
37
|
-
held.push(row);
|
|
57
|
+
dir.selves.push(row);
|
|
38
58
|
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
59
|
+
const leading = [];
|
|
60
|
+
const dirs = [];
|
|
61
|
+
for (const child of root.children.values()) {
|
|
62
|
+
if (child.children.size === 0)
|
|
63
|
+
leading.push(...child.selves);
|
|
64
|
+
else
|
|
65
|
+
dirs.push(child);
|
|
42
66
|
}
|
|
43
67
|
const lines = [];
|
|
44
|
-
|
|
45
|
-
const
|
|
46
|
-
const rungs = { name: 0, preview: 0, content: 0 };
|
|
47
|
-
let cost = 0;
|
|
48
|
-
for (const row of members) {
|
|
49
|
-
const record = row.records.get(event);
|
|
50
|
-
if (record !== undefined && record.finalRung !== 'none')
|
|
51
|
-
rungs[record.finalRung] += 1;
|
|
52
|
-
cost += deliveredCost(record);
|
|
53
|
-
}
|
|
54
|
-
const open = filters.query !== '' || expanded.has(key);
|
|
68
|
+
if (leading.length > 0) {
|
|
69
|
+
const open = filters.query !== '' || expanded.has(LEADING_KEY);
|
|
55
70
|
lines.push({
|
|
56
71
|
kind: 'group',
|
|
57
|
-
key,
|
|
58
|
-
label:
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
cost,
|
|
72
|
+
key: LEADING_KEY,
|
|
73
|
+
label: LEADING_LABEL,
|
|
74
|
+
depth: 0,
|
|
75
|
+
...rollup(leading, event),
|
|
62
76
|
expanded: open,
|
|
63
|
-
first:
|
|
77
|
+
first: leading[0],
|
|
78
|
+
isDoc: false,
|
|
64
79
|
});
|
|
65
|
-
if (
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
lines.push({
|
|
69
|
-
kind: 'doc',
|
|
70
|
-
key: row.key,
|
|
71
|
-
row,
|
|
72
|
-
label: key === LEADING_KEY ? row.name : row.name.slice(key.length + 1),
|
|
73
|
-
cost: deliveredCost(row.records.get(event)),
|
|
74
|
-
});
|
|
75
|
-
}
|
|
80
|
+
if (open)
|
|
81
|
+
for (const row of leading)
|
|
82
|
+
lines.push(docLine(row, row.name, 1, event));
|
|
76
83
|
}
|
|
84
|
+
for (const dir of dirs)
|
|
85
|
+
emit(dir, filters, expanded, event, lines);
|
|
77
86
|
return lines;
|
|
78
87
|
}
|
|
88
|
+
/** A directory's rows in drawn order: its own document first, then each child
|
|
89
|
+
* segment's subtree — so the head of the list is also the document the header
|
|
90
|
+
* hands the dossier. */
|
|
91
|
+
function subtree(dir, out = []) {
|
|
92
|
+
out.push(...dir.selves);
|
|
93
|
+
for (const child of dir.children.values())
|
|
94
|
+
subtree(child, out);
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
function emit(dir, filters, expanded, event, lines) {
|
|
98
|
+
if (dir.children.size === 0) {
|
|
99
|
+
for (const row of dir.selves)
|
|
100
|
+
lines.push(docLine(row, dir.segment, dir.depth, event));
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
const members = subtree(dir);
|
|
104
|
+
const open = filters.query !== '' || expanded.has(dir.key);
|
|
105
|
+
lines.push({
|
|
106
|
+
kind: 'group',
|
|
107
|
+
key: dir.key,
|
|
108
|
+
label: `${dir.segment}/`,
|
|
109
|
+
depth: dir.depth,
|
|
110
|
+
...rollup(members, event),
|
|
111
|
+
expanded: open,
|
|
112
|
+
first: members[0],
|
|
113
|
+
isDoc: dir.selves.length > 0,
|
|
114
|
+
});
|
|
115
|
+
if (!open)
|
|
116
|
+
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));
|
|
121
|
+
for (const child of dir.children.values())
|
|
122
|
+
emit(child, filters, expanded, event, lines);
|
|
123
|
+
}
|
|
124
|
+
function docLine(row, label, depth, event) {
|
|
125
|
+
return { kind: 'doc', key: row.key, row, label, depth, cost: deliveredCost(row.records.get(event)) };
|
|
126
|
+
}
|
|
127
|
+
function rollup(members, event) {
|
|
128
|
+
const rungs = { name: 0, preview: 0, content: 0 };
|
|
129
|
+
let cost = 0;
|
|
130
|
+
for (const row of members) {
|
|
131
|
+
const record = row.records.get(event);
|
|
132
|
+
if (record !== undefined && record.finalRung !== 'none')
|
|
133
|
+
rungs[record.finalRung] += 1;
|
|
134
|
+
cost += deliveredCost(record);
|
|
135
|
+
}
|
|
136
|
+
return { docs: members.length, rungs, cost };
|
|
137
|
+
}
|
|
79
138
|
/** What the whole filtered set costs, split by the rung it arrives at. */
|
|
80
139
|
export function costTotals(rows, event) {
|
|
81
140
|
const byRung = { name: 0, preview: 0, content: 0 };
|
|
@@ -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;
|
|
@@ -90,6 +102,9 @@ export declare class ContextAdminShell implements Component, Focusable {
|
|
|
90
102
|
private handleModal;
|
|
91
103
|
private renderModal;
|
|
92
104
|
private footer;
|
|
105
|
+
/** What the focused row does when opened. A directory answers to space, a
|
|
106
|
+
* document to enter, and a directory that is also a document to both. */
|
|
107
|
+
private docsGesture;
|
|
93
108
|
render(width: number): string[];
|
|
94
109
|
/** What the delivering documents spend on this event. An estimate, and a
|
|
95
110
|
* floor on the rendered prompt: boot wraps these documents in its own intro
|
|
@@ -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
|
}
|
|
@@ -228,7 +231,9 @@ export class ContextAdminShell {
|
|
|
228
231
|
this.syncDetail();
|
|
229
232
|
}
|
|
230
233
|
else if (this.isDescend(data)) {
|
|
231
|
-
|
|
234
|
+
// A directory that is also a document keeps both gestures: space opens it,
|
|
235
|
+
// and descending goes to its dossier like any other document.
|
|
236
|
+
if (this.docs.onGroup && !this.docs.onDocument) {
|
|
232
237
|
this.docs.toggleGroup();
|
|
233
238
|
this.syncDetail();
|
|
234
239
|
}
|
|
@@ -266,35 +271,101 @@ export class ContextAdminShell {
|
|
|
266
271
|
this.tui.requestRender();
|
|
267
272
|
}
|
|
268
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) {
|
|
269
281
|
if (this.isDown(data))
|
|
270
282
|
this.detail.move(1);
|
|
271
283
|
else if (this.isUp(data))
|
|
272
284
|
this.detail.move(-1);
|
|
273
|
-
else if (
|
|
274
|
-
this.
|
|
275
|
-
else if (matchesKey(data, '
|
|
276
|
-
this.
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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;
|
|
298
369
|
}
|
|
299
370
|
/** What the focused dossier row does when opened — the same write its letter
|
|
300
371
|
* key starts, so a user who never learns the letters can still edit. */
|
|
@@ -470,13 +541,6 @@ export class ContextAdminShell {
|
|
|
470
541
|
const record = this.record;
|
|
471
542
|
if (record === undefined)
|
|
472
543
|
return;
|
|
473
|
-
// Checked before the terminal leaves for $EDITOR, so a read-only record
|
|
474
|
-
// refuses in place rather than after a whole editing session.
|
|
475
|
-
const refusal = readOnlyReason(record, this.snapshot.profileId);
|
|
476
|
-
if (refusal !== null) {
|
|
477
|
-
this.notice = { text: refusal, ok: false };
|
|
478
|
-
return;
|
|
479
|
-
}
|
|
480
544
|
let before;
|
|
481
545
|
try {
|
|
482
546
|
before = readBody(record);
|
|
@@ -648,14 +712,25 @@ export class ContextAdminShell {
|
|
|
648
712
|
const left = this.zone === 'rail'
|
|
649
713
|
? `${move} row · space change · enter documents`
|
|
650
714
|
: this.zone === 'docs'
|
|
651
|
-
? `${move} row ·
|
|
652
|
-
:
|
|
715
|
+
? `${move} row · ${this.docsGesture()} · / search`
|
|
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`;
|
|
653
721
|
const right = `${this.label('crtr.setup.tab-next', 'tab')} event · ${this.label('crtr.setup.cancel', 'q')} close`;
|
|
654
722
|
const gap = width - visibleWidth(left) - visibleWidth(right);
|
|
655
723
|
return gap >= 2
|
|
656
724
|
? `${theme.fg('dim', left)}${' '.repeat(gap)}${theme.fg('dim', right)}`
|
|
657
725
|
: theme.fg('dim', truncateToWidth(`${left} · ${this.label('crtr.setup.cancel', 'q')} close`, width));
|
|
658
726
|
}
|
|
727
|
+
/** What the focused row does when opened. A directory answers to space, a
|
|
728
|
+
* document to enter, and a directory that is also a document to both. */
|
|
729
|
+
docsGesture() {
|
|
730
|
+
if (!this.docs.onGroup)
|
|
731
|
+
return 'enter dossier';
|
|
732
|
+
return this.docs.onDocument ? 'space open · enter dossier' : 'space/enter open';
|
|
733
|
+
}
|
|
659
734
|
render(width) {
|
|
660
735
|
const height = this.terminalRows();
|
|
661
736
|
const bodyHeight = Math.max(1, height - PAGE_CHROME_ROWS);
|