@north-light/crouter 0.3.240 → 0.3.242
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/api/dto/broker-ops.d.ts +8 -3
- package/dist/api/dto/broker.d.ts +4 -2
- package/dist/api/dto/inbox.d.ts +1 -0
- package/dist/api/dto/nodes.d.ts +4 -0
- package/dist/api/dto/review-comments.d.ts +1 -0
- package/dist/builtin-memory/04-orchestration-kernel.md +1 -1
- package/dist/clients/attach/session/profile-files.js +4 -1
- package/dist/clients/attach/viewer.js +366 -366
- package/dist/commands/node/create.js +8 -3
- package/dist/commands/node/inspect.js +1 -1
- package/dist/commands/sys/context/admin/detail-panel.d.ts +3 -0
- package/dist/commands/sys/context/admin/detail-panel.js +32 -3
- package/dist/commands/sys/context/admin/docs-panel.d.ts +23 -13
- package/dist/commands/sys/context/admin/docs-panel.js +101 -103
- package/dist/commands/sys/context/admin/list-view.d.ts +53 -0
- package/dist/commands/sys/context/admin/list-view.js +125 -0
- package/dist/commands/sys/context/admin/model.d.ts +11 -0
- package/dist/commands/sys/context/admin/model.js +28 -1
- package/dist/commands/sys/context/admin/rail-panel.d.ts +46 -0
- package/dist/commands/sys/context/admin/{axes-panel.js → rail-panel.js} +64 -21
- package/dist/commands/sys/context/admin/shell.d.ts +19 -2
- package/dist/commands/sys/context/admin/shell.js +142 -46
- package/dist/commands/sys/daemon.js +1 -1
- package/dist/core/__tests__/child-death-wake.test.js +0 -48
- package/dist/core/__tests__/daemon-boot.test.js +0 -8
- package/dist/core/__tests__/dead-node-policy-table.test.js +1 -2
- package/dist/core/__tests__/integration/deferred-no-wake.test.js +14 -2
- package/dist/core/__tests__/lifecycle.test.js +5 -5
- package/dist/core/__tests__/relaunch-root.test.js +91 -1
- package/dist/core/__tests__/revive-capacity.test.js +36 -1
- package/dist/core/__tests__/seam/broker-cap-freeze.test.js +73 -0
- package/dist/core/__tests__/seam/dormancy-release.test.js +1 -2
- package/dist/core/canvas/browse/model.d.ts +0 -9
- package/dist/core/canvas/browse/model.js +1 -37
- package/dist/core/canvas/browse/render.js +2 -1
- package/dist/core/canvas/canvas.d.ts +6 -5
- package/dist/core/canvas/canvas.js +8 -7
- package/dist/core/canvas/types.d.ts +6 -0
- package/dist/core/review/realize.js +1 -1
- package/dist/core/runtime/broker/node-named.d.ts +1 -0
- package/dist/core/runtime/host.js +114 -103
- package/dist/core/runtime/lifecycle.js +3 -2
- package/dist/core/runtime/naming.d.ts +26 -8
- package/dist/core/runtime/naming.js +75 -43
- package/dist/core/runtime/reset.js +12 -5
- package/dist/core/runtime/revive-all.d.ts +4 -11
- package/dist/core/runtime/revive-all.js +6 -13
- package/dist/core/runtime/tmux-bindings.js +4 -3
- package/dist/core/runtime/warm-pool.js +4 -4
- package/dist/core/tui/fuzzy.d.ts +9 -0
- package/dist/core/tui/fuzzy.js +38 -0
- package/dist/daemon/api/__tests__/node-create-description.test.js +1 -0
- package/dist/daemon/api/handlers/broker-ops.js +14 -7
- package/dist/daemon/api/handlers/feedback-comments.js +1 -1
- package/dist/daemon/api/map.js +2 -0
- package/dist/daemon/crtrd.js +17 -20
- package/dist/daemon/messaging/node-message.d.ts +1 -0
- package/dist/daemon/messaging/node-message.js +6 -2
- package/dist/daemon/reconcilers/live-obligation.d.ts +1 -2
- package/dist/daemon/reconcilers/node-lifecycle/freeze-lane.d.ts +0 -5
- package/dist/daemon/reconcilers/node-lifecycle/freeze-lane.js +9 -13
- package/dist/daemon/reconcilers/node-lifecycle/respawn-policy.js +0 -6
- package/dist/daemon/reconcilers/node-lifecycle/terminating.d.ts +0 -2
- package/dist/daemon/reconcilers/node-lifecycle/terminating.js +0 -10
- package/dist/daemon/reconcilers/node-lifecycle/tick.d.ts +2 -2
- package/dist/daemon/reconcilers/node-lifecycle/tick.js +4 -2
- package/dist/daemon/reconcilers/storage-maintenance.js +2 -2
- package/dist/daemon/review/comment-notify.d.ts +1 -0
- package/dist/daemon/review/comment-notify.js +1 -0
- package/dist/pi-extensions/broker-local.d.ts +1 -0
- package/dist/pi-extensions/broker-local.js +12 -7
- package/dist/pi-extensions/canvas-recap.js +1 -0
- package/dist/pi-extensions/canvas-stophook.js +2 -0
- package/package.json +1 -1
- package/runtime.lock.json +2 -2
- package/dist/commands/sys/context/admin/axes-panel.d.ts +0 -21
|
@@ -75,7 +75,7 @@ function nodeNewParams() {
|
|
|
75
75
|
return [
|
|
76
76
|
{ kind: 'stdin', name: 'prompt', required: false, constraint: 'First user message.' },
|
|
77
77
|
{ kind: 'flag', name: 'kind', type: 'string', required: false, constraint: "Persona kind. Defaults to the profile's default kind; `general` when the profile has none. The <kinds> list below names every top-level installable kind." },
|
|
78
|
-
{ kind: 'flag', name: 'mode', type: 'enum', choices: ['base', 'orchestrator'], required: false, default: 'base', constraint: 'Persona mode. base for hands-on
|
|
78
|
+
{ kind: 'flag', name: 'mode', type: 'enum', choices: ['base', 'orchestrator'], required: false, default: 'base', constraint: 'Persona mode. base for one hands-on unit; orchestrator when the unit itself needs decomposition across children. When you hold several parallel units of one kind, spawn ONE orchestrator child of that kind to own the fan-out rather than spawning the units yourself — unless you are already an orchestrator of that kind, whose job is exactly that fan-out.' },
|
|
79
79
|
{ kind: 'flag', name: 'cwd', type: 'path', required: false, constraint: 'Pin the node to this directory. Defaults to the spawner\u2019s directory for a managed child, and to the directory this command runs in for a root.' },
|
|
80
80
|
{ kind: 'flag', name: 'name', type: 'string', required: false, constraint: 'Display name.' },
|
|
81
81
|
{ kind: 'flag', name: 'parent', type: 'string', required: false, constraint: 'Parent node id. Defaults to the calling node.' },
|
|
@@ -147,6 +147,7 @@ function nodeNewOutput() {
|
|
|
147
147
|
return [
|
|
148
148
|
{ name: 'node_id', type: 'string', required: true, constraint: 'New node id.' },
|
|
149
149
|
{ name: 'name', type: 'string', required: true, constraint: 'Display name.' },
|
|
150
|
+
{ name: 'frozen_at', type: 'string', required: false, constraint: 'Present when a managed child exists but capacity deferred its broker start.' },
|
|
150
151
|
...(inTmux()
|
|
151
152
|
? [
|
|
152
153
|
{ name: 'window', type: 'string', required: false, constraint: 'tmux window id when a viewer opens.' },
|
|
@@ -242,23 +243,27 @@ async function runNodeCreation(input) {
|
|
|
242
243
|
placement = openSpawnViewer(detail.node_id, detail.cwd, detail.name);
|
|
243
244
|
if (root)
|
|
244
245
|
mark('node_new.viewer_window_opened');
|
|
246
|
+
const frozen = !root && detail.frozen_at !== null;
|
|
245
247
|
const result = {
|
|
246
248
|
node_id: detail.node_id,
|
|
247
249
|
name: detail.name,
|
|
250
|
+
...(frozen ? { frozen_at: detail.frozen_at } : {}),
|
|
248
251
|
window: placement.window,
|
|
249
252
|
session: placement.session,
|
|
250
253
|
follow_up: root
|
|
251
254
|
? (noKickoff
|
|
252
255
|
? 'Independent root spawned idle. No kickoff turn ran; hand it off or attach and prompt it. No report wakes you.'
|
|
253
256
|
: 'Independent root spawned. You are not subscribed, so its finish does not wake you; hand it off and move on.')
|
|
254
|
-
:
|
|
257
|
+
: (frozen
|
|
258
|
+
? 'Child row exists, but no broker started because capacity is full. It is frozen and will launch when a broker slot frees; its final report wakes you then.'
|
|
259
|
+
: await childFollowUp(parentId)),
|
|
255
260
|
};
|
|
256
261
|
return result;
|
|
257
262
|
}
|
|
258
263
|
export const nodeNew = defineLeaf({
|
|
259
264
|
name: 'new',
|
|
260
265
|
description: 'create and start a node immediately',
|
|
261
|
-
whenToUse: 'a unit of work is genuinely separable from what you are doing: a bounded subtask you would otherwise grind out inline, several independent units worth running in parallel, a scouting pass over unfamiliar code, a change that wants its own git worktree, or an answer you need in a fixed shape rather than as prose',
|
|
266
|
+
whenToUse: 'a unit of work is genuinely separable from what you are doing: a bounded subtask you would otherwise grind out inline, several independent units worth running in parallel (usually via one orchestrator child of the matching kind rather than a flat fan-out from you), a scouting pass over unfamiliar code, a change that wants its own git worktree, or an answer you need in a fixed shape rather than as prose',
|
|
262
267
|
tier: 'important',
|
|
263
268
|
help: {
|
|
264
269
|
name: 'node new',
|
|
@@ -3,7 +3,7 @@ import { defineBranch, defineLeaf } from '../../core/command.js';
|
|
|
3
3
|
import { InputError } from '../../core/io.js';
|
|
4
4
|
import { isPidAlive } from '../../core/canvas/pid.js';
|
|
5
5
|
import { fullName } from '../../core/canvas/labels.js';
|
|
6
|
-
import { fuzzyMatch } from '../../core/
|
|
6
|
+
import { fuzzyMatch } from '../../core/tui/fuzzy.js';
|
|
7
7
|
import { resolveProfileOperand } from '../../core/profiles/manifest.js';
|
|
8
8
|
import { profilesStateBlock } from '../../core/profiles/state-block.js';
|
|
9
9
|
import { nodeSnapshotLeaf } from '../node-snapshot.js';
|
|
@@ -47,6 +47,9 @@ export declare class ContextDetailPanel {
|
|
|
47
47
|
move(delta: number): void;
|
|
48
48
|
render(width: number, height: number, focused: boolean): string[];
|
|
49
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;
|
|
50
53
|
private acrossEvents;
|
|
51
54
|
}
|
|
52
55
|
/** The editable rows for one document, in the order the panel draws them. */
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
// The dossier: one document's editable configuration above the read-only
|
|
2
|
-
// verdict that configuration produces
|
|
3
|
-
//
|
|
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.
|
|
4
5
|
import { truncateToWidth, visibleWidth } from '@earendil-works/pi-tui';
|
|
5
6
|
import { padAnsi, theme, wrapText } from '../../../../core/tui/panel.js';
|
|
6
7
|
import { SURFACE_EVENTS } from '../../../../core/substrate/schema.js';
|
|
7
|
-
import { EVENT_INITIALS, cellStateOf, describeSurfaceEntry, plural } from './model.js';
|
|
8
|
+
import { EVENT_INITIALS, cellStateOf, describeSurfaceEntry, formatCost, plural, rungCostsOf } from './model.js';
|
|
8
9
|
const STATE_WORD = {
|
|
9
10
|
pending: 'planning…',
|
|
10
11
|
delivers: 'delivers',
|
|
@@ -104,8 +105,14 @@ export class ContextDetailPanel {
|
|
|
104
105
|
lines.push(theme.fg('dim', ` VERDICT · ${this.event}`));
|
|
105
106
|
lines.push(...this.verdict(record, width));
|
|
106
107
|
lines.push('');
|
|
108
|
+
lines.push(theme.fg('dim', ' COST IF DELIVERED · approximate'));
|
|
109
|
+
lines.push(...this.costs(record, width));
|
|
110
|
+
lines.push('');
|
|
107
111
|
lines.push(theme.fg('dim', ' ACROSS EVENTS'));
|
|
108
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));
|
|
109
116
|
const cursorLine = rowLines.get(this.index) ?? 0;
|
|
110
117
|
const maxStart = Math.max(0, lines.length - height);
|
|
111
118
|
let start = Math.min(Math.max(0, cursorLine - Math.floor(height / 3)), maxStart);
|
|
@@ -133,6 +140,16 @@ export class ContextDetailPanel {
|
|
|
133
140
|
}
|
|
134
141
|
return out;
|
|
135
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
|
+
}
|
|
136
153
|
acrossEvents(row, width) {
|
|
137
154
|
return SURFACE_EVENTS.map((event) => {
|
|
138
155
|
const record = row.records.get(event);
|
|
@@ -165,6 +182,18 @@ function bodyLines(body) {
|
|
|
165
182
|
const trimmed = body.replace(/\n+$/, '');
|
|
166
183
|
return trimmed === '' ? 0 : trimmed.split('\n').length;
|
|
167
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
|
+
}
|
|
168
197
|
const FIELD_WIDTH = 15;
|
|
169
198
|
function field(label, value, width) {
|
|
170
199
|
const room = Math.max(1, width - FIELD_WIDTH - 2);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type SurfaceEvent } from '../../../../core/substrate/schema.js';
|
|
2
2
|
import type { DeliveryRecord } from '../../../../core/substrate/plan.js';
|
|
3
3
|
import { PlanSet } from './model.js';
|
|
4
|
+
import { type Filters } from './list-view.js';
|
|
4
5
|
/** One physical document across every event. `records` is sparse: an event
|
|
5
6
|
* whose corpus never reaches this document has no record for it, and an event
|
|
6
7
|
* still being planned has none yet either. */
|
|
@@ -18,27 +19,36 @@ export interface DocRow {
|
|
|
18
19
|
/** The union of every planned event's corpus, ordered by the focused event's
|
|
19
20
|
* precedence order with the documents only other events see appended. */
|
|
20
21
|
export declare function buildRows(plans: PlanSet, event: SurfaceEvent): DocRow[];
|
|
21
|
-
export declare function rowDelivers(row: DocRow): boolean;
|
|
22
22
|
export declare class ContextDocsPanel {
|
|
23
23
|
private readonly onChange;
|
|
24
24
|
private rows;
|
|
25
|
+
private filters;
|
|
26
|
+
private event;
|
|
27
|
+
private lines;
|
|
25
28
|
private index;
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
* visible beside the delivering ones. */
|
|
30
|
-
private deliveringOnly;
|
|
29
|
+
/** Groups the user opened. Empty means every group is collapsed, which is how
|
|
30
|
+
* the page opens: ~25 rows that name the whole corpus. */
|
|
31
|
+
private readonly expanded;
|
|
31
32
|
constructor(onChange: () => void);
|
|
33
|
+
/** The document the dossier describes: the focused row, or the first member
|
|
34
|
+
* of the focused group so the dossier is never blank. */
|
|
32
35
|
get selected(): DocRow | undefined;
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
+
/** Documents the current question selects — counted from the group rollups,
|
|
37
|
+
* so a fully collapsed list still reports its size. */
|
|
38
|
+
get visibleCount(): number;
|
|
39
|
+
setRows(rows: DocRow[], event: SurfaceEvent, selectedKey?: string): void;
|
|
40
|
+
setFilters(filters: Filters): void;
|
|
36
41
|
move(delta: number): void;
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
42
|
+
/** True when the focused line is a group, so the shell knows whether a
|
|
43
|
+
* descend opens a group or the dossier. */
|
|
44
|
+
get onGroup(): boolean;
|
|
45
|
+
toggleGroup(): void;
|
|
46
|
+
/** Rebuild the drawn lines, keeping the cursor on the same document (or the
|
|
47
|
+
* same group), and clamping when what it was on is gone. */
|
|
48
|
+
private relayout;
|
|
41
49
|
render(width: number, height: number, focused: boolean, plans: PlanSet): string[];
|
|
50
|
+
private groupLine;
|
|
51
|
+
private docLine;
|
|
42
52
|
/** Pad rather than trim: this column sets the length of the divider drawn
|
|
43
53
|
* beside it. */
|
|
44
54
|
private fit;
|
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
// The middle column: every document the snapshot can see,
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
|
|
1
|
+
// The middle column: every document the snapshot can see, grouped by the first
|
|
2
|
+
// segment of its canonical name and each carrying its own six-event matrix. One
|
|
3
|
+
// row is one physical document, so two stores answering to the same canonical
|
|
4
|
+
// name stay two rows and the shadowed one is visible.
|
|
5
|
+
import { truncateToWidth, visibleWidth } from '@earendil-works/pi-tui';
|
|
5
6
|
import { padAnsi, theme, visibleRange } from '../../../../core/tui/panel.js';
|
|
6
7
|
import { SURFACE_EVENTS } from '../../../../core/substrate/schema.js';
|
|
7
|
-
import { EVENT_INITIALS, cellGlyph, cellStateOf, plural } from './model.js';
|
|
8
|
+
import { EVENT_INITIALS, cellGlyph, cellStateOf, formatCost, plural } from './model.js';
|
|
9
|
+
import { NO_FILTERS, buildListView, groupKeysOf } from './list-view.js';
|
|
8
10
|
/** The union of every planned event's corpus, ordered by the focused event's
|
|
9
11
|
* precedence order with the documents only other events see appended. */
|
|
10
12
|
export function buildRows(plans, event) {
|
|
@@ -41,133 +43,104 @@ export function buildRows(plans, event) {
|
|
|
41
43
|
absorb(other);
|
|
42
44
|
return order.map((key) => rows.get(key));
|
|
43
45
|
}
|
|
44
|
-
export function rowDelivers(row) {
|
|
45
|
-
for (const record of row.records.values())
|
|
46
|
-
if (record.finalRung !== 'none')
|
|
47
|
-
return true;
|
|
48
|
-
return false;
|
|
49
|
-
}
|
|
50
46
|
/** Cells and their width, so the shell's legend and the list agree. */
|
|
51
47
|
const CELL_COUNT = SURFACE_EVENTS.length;
|
|
52
48
|
const MATRIX_WIDTH = CELL_COUNT * 2 - 1;
|
|
49
|
+
const COST_WIDTH = 6;
|
|
50
|
+
/** The glyphs a group header counts its members with — the matrix vocabulary,
|
|
51
|
+
* so a rung reads the same in both columns. */
|
|
52
|
+
const RUNG_MARK = { content: '█', preview: '▄', name: '▁' };
|
|
53
53
|
export class ContextDocsPanel {
|
|
54
54
|
onChange;
|
|
55
55
|
rows = [];
|
|
56
|
+
filters = NO_FILTERS;
|
|
57
|
+
event = 'boot';
|
|
58
|
+
lines = [];
|
|
56
59
|
index = 0;
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
* visible beside the delivering ones. */
|
|
61
|
-
deliveringOnly = false;
|
|
60
|
+
/** Groups the user opened. Empty means every group is collapsed, which is how
|
|
61
|
+
* the page opens: ~25 rows that name the whole corpus. */
|
|
62
|
+
expanded = new Set();
|
|
62
63
|
constructor(onChange) {
|
|
63
64
|
this.onChange = onChange;
|
|
64
65
|
}
|
|
66
|
+
/** The document the dossier describes: the focused row, or the first member
|
|
67
|
+
* of the focused group so the dossier is never blank. */
|
|
65
68
|
get selected() {
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
get ownsKeyboard() {
|
|
71
|
-
return this.filtering;
|
|
69
|
+
const line = this.lines[this.index];
|
|
70
|
+
if (line === undefined)
|
|
71
|
+
return undefined;
|
|
72
|
+
return line.kind === 'doc' ? line.row : line.first;
|
|
72
73
|
}
|
|
73
|
-
|
|
74
|
-
|
|
74
|
+
/** Documents the current question selects — counted from the group rollups,
|
|
75
|
+
* so a fully collapsed list still reports its size. */
|
|
76
|
+
get visibleCount() {
|
|
77
|
+
return this.lines.reduce((count, line) => count + (line.kind === 'group' ? line.docs : 0), 0);
|
|
75
78
|
}
|
|
76
|
-
setRows(rows, selectedKey) {
|
|
79
|
+
setRows(rows, event, selectedKey) {
|
|
77
80
|
this.rows = rows;
|
|
78
|
-
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
+
this.event = event;
|
|
82
|
+
const live = groupKeysOf(rows);
|
|
83
|
+
for (const key of this.expanded)
|
|
84
|
+
if (!live.has(key))
|
|
85
|
+
this.expanded.delete(key);
|
|
86
|
+
this.relayout(selectedKey);
|
|
87
|
+
}
|
|
88
|
+
setFilters(filters) {
|
|
89
|
+
this.filters = filters;
|
|
90
|
+
this.relayout(this.selected?.key);
|
|
81
91
|
}
|
|
82
92
|
move(delta) {
|
|
83
|
-
|
|
84
|
-
if (rows.length === 0)
|
|
93
|
+
if (this.lines.length === 0)
|
|
85
94
|
return;
|
|
86
|
-
this.index = Math.max(0, Math.min(
|
|
95
|
+
this.index = Math.max(0, Math.min(this.lines.length - 1, this.index + delta));
|
|
87
96
|
this.onChange();
|
|
88
97
|
}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
this.index
|
|
93
|
-
if (held !== undefined) {
|
|
94
|
-
const at = this.visible().findIndex((row) => row.key === held);
|
|
95
|
-
if (at >= 0)
|
|
96
|
-
this.index = at;
|
|
97
|
-
}
|
|
98
|
-
this.onChange();
|
|
98
|
+
/** True when the focused line is a group, so the shell knows whether a
|
|
99
|
+
* descend opens a group or the dossier. */
|
|
100
|
+
get onGroup() {
|
|
101
|
+
return this.lines[this.index]?.kind === 'group';
|
|
99
102
|
}
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
if (
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
else if (matchesKey(data, 'backspace')) {
|
|
111
|
-
this.filter = this.filter.slice(0, -1);
|
|
112
|
-
this.index = 0;
|
|
113
|
-
}
|
|
114
|
-
else if (data.length === 1 && data.charCodeAt(0) >= 32) {
|
|
115
|
-
this.filter += data;
|
|
116
|
-
this.index = 0;
|
|
117
|
-
}
|
|
118
|
-
this.onChange();
|
|
119
|
-
return true;
|
|
120
|
-
}
|
|
121
|
-
if (data === '/' || matchesKey(data, '/')) {
|
|
122
|
-
this.filtering = true;
|
|
123
|
-
this.onChange();
|
|
124
|
-
return true;
|
|
125
|
-
}
|
|
126
|
-
if (matchesKey(data, 'escape') && this.filter !== '') {
|
|
127
|
-
this.filter = '';
|
|
128
|
-
this.index = 0;
|
|
129
|
-
this.onChange();
|
|
130
|
-
return true;
|
|
131
|
-
}
|
|
132
|
-
return false;
|
|
103
|
+
toggleGroup() {
|
|
104
|
+
const line = this.lines[this.index];
|
|
105
|
+
if (line?.kind !== 'group')
|
|
106
|
+
return;
|
|
107
|
+
if (this.expanded.has(line.key))
|
|
108
|
+
this.expanded.delete(line.key);
|
|
109
|
+
else
|
|
110
|
+
this.expanded.add(line.key);
|
|
111
|
+
this.relayout(undefined, line.key);
|
|
112
|
+
this.onChange();
|
|
133
113
|
}
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
114
|
+
/** Rebuild the drawn lines, keeping the cursor on the same document (or the
|
|
115
|
+
* same group), and clamping when what it was on is gone. */
|
|
116
|
+
relayout(selectedKey, groupKey) {
|
|
117
|
+
const held = selectedKey ?? (this.lines[this.index]?.kind === 'doc' ? this.lines[this.index].key : undefined);
|
|
118
|
+
this.lines = buildListView(this.rows, this.filters, this.expanded, this.event);
|
|
119
|
+
const wanted = groupKey !== undefined
|
|
120
|
+
? this.lines.findIndex((line) => line.kind === 'group' && line.key === groupKey)
|
|
121
|
+
: held === undefined
|
|
122
|
+
? -1
|
|
123
|
+
: this.lines.findIndex((line) => line.kind === 'doc' && line.key === held);
|
|
124
|
+
this.index = wanted >= 0 ? wanted : Math.max(0, Math.min(this.index, this.lines.length - 1));
|
|
143
125
|
}
|
|
144
126
|
render(width, height, focused, plans) {
|
|
145
|
-
const rows = this.visible();
|
|
146
127
|
const out = [];
|
|
147
|
-
const
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
const count = this.filter === '' ? plural(rows.length, 'doc', 'docs') : plural(rows.length, 'match', 'matches');
|
|
128
|
+
const nameRoom = Math.max(1, width - MATRIX_WIDTH - COST_WIDTH - 3);
|
|
129
|
+
const heading = this.filters.query === '' ? 'DOCUMENTS' : `/${this.filters.query}`;
|
|
130
|
+
const count = this.filters.query === ''
|
|
131
|
+
? plural(this.visibleCount, 'doc', 'docs')
|
|
132
|
+
: plural(this.visibleCount, 'match', 'matches');
|
|
153
133
|
const matrixHead = SURFACE_EVENTS.map((event) => EVENT_INITIALS[event]).join(' ');
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
out.push('', theme.fg('dim', this.filter === '' ? ' No documents in this snapshot\u2019s corpus.' : ' Nothing matches that filter.'));
|
|
134
|
+
out.push(truncateToWidth(` ${padAnsi(`${theme.fg('accent', theme.bold(heading))} ${theme.fg('dim', count)}`, nameRoom + COST_WIDTH + 1)} ${theme.fg('dim', matrixHead)}`, width));
|
|
135
|
+
if (this.lines.length === 0) {
|
|
136
|
+
out.push('', theme.fg('dim', ' Nothing matches this question — cycle a facet back to "any".'));
|
|
158
137
|
return this.fit(out, height);
|
|
159
138
|
}
|
|
160
139
|
const capacity = Math.max(1, height - out.length);
|
|
161
|
-
const range = visibleRange(
|
|
140
|
+
const range = visibleRange(this.lines.length, this.index, capacity);
|
|
162
141
|
for (let at = range.start; at < range.end; at += 1) {
|
|
163
|
-
const
|
|
164
|
-
const
|
|
165
|
-
const state = plans.peek(event) === undefined ? 'pending' : cellStateOf(row.records.get(event));
|
|
166
|
-
return cellGlyph(row.records.get(event), state);
|
|
167
|
-
}).join(' ');
|
|
168
|
-
const scope = theme.fg('dim', ` ${row.anchor.scope}`);
|
|
169
|
-
const label = truncateToWidth(`${row.name}${scope}`, nameRoom - 1);
|
|
170
|
-
const body = ` ${padAnsi(label, nameRoom - 1)} ${matrix}`;
|
|
142
|
+
const line = this.lines[at];
|
|
143
|
+
const body = line.kind === 'group' ? this.groupLine(line, nameRoom) : this.docLine(line, nameRoom, plans);
|
|
171
144
|
out.push(at === this.index && focused
|
|
172
145
|
? theme.bg('selectedBg', padAnsi(body, width))
|
|
173
146
|
: at === this.index
|
|
@@ -176,6 +149,26 @@ export class ContextDocsPanel {
|
|
|
176
149
|
}
|
|
177
150
|
return this.fit(out, height);
|
|
178
151
|
}
|
|
152
|
+
groupLine(line, nameRoom) {
|
|
153
|
+
const marker = line.expanded ? '▾' : '▸';
|
|
154
|
+
const label = `${marker} ${theme.bold(line.label)}`;
|
|
155
|
+
const marks = ['content', 'preview', 'name']
|
|
156
|
+
.filter((rung) => line.rungs[rung] > 0)
|
|
157
|
+
.map((rung) => `${line.rungs[rung]}${RUNG_MARK[rung]}`)
|
|
158
|
+
.join(' ');
|
|
159
|
+
const stats = theme.fg('dim', marks === '' ? plural(line.docs, 'doc', 'docs') : `${line.docs} · ${marks}`);
|
|
160
|
+
const room = Math.max(1, nameRoom - visibleWidth(stats) - 1);
|
|
161
|
+
const field = `${padAnsi(truncateToWidth(label, room), room)} ${stats}`;
|
|
162
|
+
return ` ${padAnsi(field, nameRoom)} ${cost(line.cost)} ${' '.repeat(MATRIX_WIDTH)}`;
|
|
163
|
+
}
|
|
164
|
+
docLine(line, nameRoom, plans) {
|
|
165
|
+
const matrix = SURFACE_EVENTS.map((event) => {
|
|
166
|
+
const state = plans.peek(event) === undefined ? 'pending' : cellStateOf(line.row.records.get(event));
|
|
167
|
+
return cellGlyph(line.row.records.get(event), state);
|
|
168
|
+
}).join(' ');
|
|
169
|
+
const label = truncateToWidth(` ${line.label}`, nameRoom);
|
|
170
|
+
return ` ${padAnsi(label, nameRoom)} ${cost(line.cost)} ${matrix}`;
|
|
171
|
+
}
|
|
179
172
|
/** Pad rather than trim: this column sets the length of the divider drawn
|
|
180
173
|
* beside it. */
|
|
181
174
|
fit(lines, height) {
|
|
@@ -184,3 +177,8 @@ export class ContextDocsPanel {
|
|
|
184
177
|
return lines.slice(0, height).map((line) => (visibleWidth(line) === 0 ? '' : line));
|
|
185
178
|
}
|
|
186
179
|
}
|
|
180
|
+
/** Right-flushed into the cost column, dim because it is an estimate. */
|
|
181
|
+
function cost(count) {
|
|
182
|
+
const text = formatCost(count);
|
|
183
|
+
return theme.fg('dim', ' '.repeat(Math.max(0, COST_WIDTH - text.length)) + text);
|
|
184
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { MemoryScope } from '../../../../core/memory-resolver.js';
|
|
2
|
+
import type { DocKind, Rung, SurfaceEvent } from '../../../../core/substrate/schema.js';
|
|
3
|
+
import type { DocRow } from './docs-panel.js';
|
|
4
|
+
export type RungFacet = 'any' | Exclude<Rung, 'none'> | 'silent';
|
|
5
|
+
export type OriginFacet = 'any' | MemoryScope;
|
|
6
|
+
export type KindFacet = 'any' | DocKind;
|
|
7
|
+
export type StateFacet = 'any' | 'delivering' | 'gated' | 'shadowed' | 'no-entry';
|
|
8
|
+
/** The question the page is asking. Rung asks what arrives, state asks why it
|
|
9
|
+
* does not, and the two compose. Every facet is "any" by default, so the page
|
|
10
|
+
* opens on the whole corpus. */
|
|
11
|
+
export interface Filters {
|
|
12
|
+
rung: RungFacet;
|
|
13
|
+
origin: OriginFacet;
|
|
14
|
+
kind: KindFacet;
|
|
15
|
+
state: StateFacet;
|
|
16
|
+
/** Fuzzy subsequence over the canonical name only. */
|
|
17
|
+
query: string;
|
|
18
|
+
}
|
|
19
|
+
export declare const NO_FILTERS: Filters;
|
|
20
|
+
/** One drawn line. A group carries what its members add up to, so a collapsed
|
|
21
|
+
* list still states where the corpus and its cost sit. */
|
|
22
|
+
export type ListLine = {
|
|
23
|
+
kind: 'group';
|
|
24
|
+
key: string;
|
|
25
|
+
label: string;
|
|
26
|
+
docs: number;
|
|
27
|
+
rungs: Record<Exclude<Rung, 'none'>, number>;
|
|
28
|
+
cost: number;
|
|
29
|
+
expanded: boolean;
|
|
30
|
+
/** The first member, so the dossier has a document to describe while the
|
|
31
|
+
* cursor rests on the header. */
|
|
32
|
+
first: DocRow;
|
|
33
|
+
} | {
|
|
34
|
+
kind: 'doc';
|
|
35
|
+
key: string;
|
|
36
|
+
row: DocRow;
|
|
37
|
+
label: string;
|
|
38
|
+
cost: number;
|
|
39
|
+
};
|
|
40
|
+
export declare function groupKeyOf(name: string): string;
|
|
41
|
+
/** Every group key the rows can produce, so collapse state can drop the keys a
|
|
42
|
+
* re-plan removed instead of accumulating them. */
|
|
43
|
+
export declare function groupKeysOf(rows: readonly DocRow[]): Set<string>;
|
|
44
|
+
/** The lines to draw, in corpus order: the leading section, then each group in
|
|
45
|
+
* order of first appearance. A live query force-expands every group holding a
|
|
46
|
+
* match without touching `expanded`, so clearing it restores what the user
|
|
47
|
+
* collapsed. */
|
|
48
|
+
export declare function buildListView(rows: readonly DocRow[], filters: Filters, expanded: ReadonlySet<string>, event: SurfaceEvent): ListLine[];
|
|
49
|
+
/** What the whole filtered set costs, split by the rung it arrives at. */
|
|
50
|
+
export declare function costTotals(rows: readonly DocRow[], event: SurfaceEvent): {
|
|
51
|
+
total: number;
|
|
52
|
+
byRung: Record<Exclude<Rung, 'none'>, number>;
|
|
53
|
+
};
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// The list's view model: which documents a question selects, how they group,
|
|
2
|
+
// and what each group costs. Pure — no terminal, no colour, no width — so the
|
|
3
|
+
// rail's counts and the list's rows are computed from the same call.
|
|
4
|
+
import { fuzzyMatch } from '../../../../core/tui/fuzzy.js';
|
|
5
|
+
import { deliveredCost, rungCostsOf } from './model.js';
|
|
6
|
+
export const NO_FILTERS = { rung: 'any', origin: 'any', kind: 'any', state: 'any', query: '' };
|
|
7
|
+
/** Documents whose canonical name carries no `/` share one leading section
|
|
8
|
+
* rather than becoming a crowd of one-member groups. */
|
|
9
|
+
const LEADING_KEY = '';
|
|
10
|
+
const LEADING_LABEL = 'top level';
|
|
11
|
+
export function groupKeyOf(name) {
|
|
12
|
+
const cut = name.indexOf('/');
|
|
13
|
+
return cut < 0 ? LEADING_KEY : name.slice(0, cut);
|
|
14
|
+
}
|
|
15
|
+
/** Every group key the rows can produce, so collapse state can drop the keys a
|
|
16
|
+
* re-plan removed instead of accumulating them. */
|
|
17
|
+
export function groupKeysOf(rows) {
|
|
18
|
+
return new Set(rows.map((row) => groupKeyOf(row.name)));
|
|
19
|
+
}
|
|
20
|
+
/** The lines to draw, in corpus order: the leading section, then each group in
|
|
21
|
+
* order of first appearance. A live query force-expands every group holding a
|
|
22
|
+
* match without touching `expanded`, so clearing it restores what the user
|
|
23
|
+
* collapsed. */
|
|
24
|
+
export function buildListView(rows, filters, expanded, event) {
|
|
25
|
+
const groups = new Map();
|
|
26
|
+
const order = [];
|
|
27
|
+
for (const row of rows) {
|
|
28
|
+
if (!passes(row, row.records.get(event), filters))
|
|
29
|
+
continue;
|
|
30
|
+
const key = groupKeyOf(row.name);
|
|
31
|
+
const held = groups.get(key);
|
|
32
|
+
if (held === undefined) {
|
|
33
|
+
groups.set(key, [row]);
|
|
34
|
+
order.push(key);
|
|
35
|
+
}
|
|
36
|
+
else
|
|
37
|
+
held.push(row);
|
|
38
|
+
}
|
|
39
|
+
if (groups.has(LEADING_KEY)) {
|
|
40
|
+
order.splice(order.indexOf(LEADING_KEY), 1);
|
|
41
|
+
order.unshift(LEADING_KEY);
|
|
42
|
+
}
|
|
43
|
+
const lines = [];
|
|
44
|
+
for (const key of order) {
|
|
45
|
+
const members = groups.get(key);
|
|
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);
|
|
55
|
+
lines.push({
|
|
56
|
+
kind: 'group',
|
|
57
|
+
key,
|
|
58
|
+
label: key === LEADING_KEY ? LEADING_LABEL : `${key}/`,
|
|
59
|
+
docs: members.length,
|
|
60
|
+
rungs,
|
|
61
|
+
cost,
|
|
62
|
+
expanded: open,
|
|
63
|
+
first: members[0],
|
|
64
|
+
});
|
|
65
|
+
if (!open)
|
|
66
|
+
continue;
|
|
67
|
+
for (const row of members) {
|
|
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
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return lines;
|
|
78
|
+
}
|
|
79
|
+
/** What the whole filtered set costs, split by the rung it arrives at. */
|
|
80
|
+
export function costTotals(rows, event) {
|
|
81
|
+
const byRung = { name: 0, preview: 0, content: 0 };
|
|
82
|
+
let total = 0;
|
|
83
|
+
for (const row of rows) {
|
|
84
|
+
const record = row.records.get(event);
|
|
85
|
+
if (record === undefined || record.finalRung === 'none')
|
|
86
|
+
continue;
|
|
87
|
+
const spend = rungCostsOf(record)[record.finalRung];
|
|
88
|
+
byRung[record.finalRung] += spend;
|
|
89
|
+
total += spend;
|
|
90
|
+
}
|
|
91
|
+
return { total, byRung };
|
|
92
|
+
}
|
|
93
|
+
function passes(row, record, filters) {
|
|
94
|
+
if (filters.rung !== 'any') {
|
|
95
|
+
const rung = record?.finalRung ?? 'none';
|
|
96
|
+
if (filters.rung === 'silent' ? rung !== 'none' : rung !== filters.rung)
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
if (filters.origin !== 'any' && row.anchor.scope !== filters.origin)
|
|
100
|
+
return false;
|
|
101
|
+
if (filters.kind !== 'any' && row.anchor.kind !== filters.kind)
|
|
102
|
+
return false;
|
|
103
|
+
if (filters.state !== 'any' && !inState(record, filters.state))
|
|
104
|
+
return false;
|
|
105
|
+
if (filters.query !== '' && !fuzzyMatch(filters.query, row.name))
|
|
106
|
+
return false;
|
|
107
|
+
return true;
|
|
108
|
+
}
|
|
109
|
+
/** `gated` is the document's own gate — the one predicate that can silence a
|
|
110
|
+
* document in every event. An entry gate failure belongs to a single entry and
|
|
111
|
+
* reads in the dossier's verdict, not here. */
|
|
112
|
+
function inState(record, state) {
|
|
113
|
+
if (record === undefined)
|
|
114
|
+
return state === 'no-entry';
|
|
115
|
+
switch (state) {
|
|
116
|
+
case 'delivering':
|
|
117
|
+
return record.finalRung !== 'none';
|
|
118
|
+
case 'gated':
|
|
119
|
+
return !record.docGate.pass;
|
|
120
|
+
case 'shadowed':
|
|
121
|
+
return record.shadowedBy !== null;
|
|
122
|
+
case 'no-entry':
|
|
123
|
+
return record.authoredEntries.length === 0;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
@@ -73,3 +73,14 @@ export declare function surfaceEntryToFlag(entry: SurfaceEntry): string;
|
|
|
73
73
|
/** One authored entry as a single readable row. */
|
|
74
74
|
export declare function describeSurfaceEntry(entry: SurfaceEntry): string;
|
|
75
75
|
export declare function plural(count: number, one: string, many: string): string;
|
|
76
|
+
/** What one document would cost at each rung, in approximate tokens. The
|
|
77
|
+
* renderers decide the payload: `content` emits the body, `preview` emits the
|
|
78
|
+
* catalog entry plus the routing line, `name` emits the entry alone. The
|
|
79
|
+
* estimate is characters ÷ 4 — it ranks documents correctly and totals
|
|
80
|
+
* approximately, and every place it renders says so. */
|
|
81
|
+
export type RungCosts = Record<Rung, number>;
|
|
82
|
+
export declare function rungCostsOf(record: DeliveryRecord): RungCosts;
|
|
83
|
+
/** What this document actually spends in the event the record belongs to. */
|
|
84
|
+
export declare function deliveredCost(record: DeliveryRecord | undefined): number;
|
|
85
|
+
/** Approximate tokens in at most six columns, always marked as an estimate. */
|
|
86
|
+
export declare function formatCost(count: number): string;
|