@north-light/crouter 0.3.239 → 0.3.241
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 +400 -400
- package/dist/commands/node/bash.js +69 -5
- 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/context/admin.js +4 -2
- package/dist/commands/sys/context/resolve.js +5 -1
- package/dist/core/bash-jobs.d.ts +23 -0
- package/dist/core/bash-jobs.js +44 -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/runtime/tmux-bindings.js +4 -3
- package/dist/core/tui/fuzzy.d.ts +9 -0
- package/dist/core/tui/fuzzy.js +38 -0
- package/dist/core/wake.d.ts +3 -0
- package/dist/core/wake.js +3 -2
- package/dist/daemon/crtrd.js +3 -0
- package/dist/daemon/reconcilers/bash-deadline.d.ts +17 -0
- package/dist/daemon/reconcilers/bash-deadline.js +73 -0
- package/dist/pi-extensions/canvas-bash-valve.js +15 -2
- package/dist/shared/env.d.ts +4 -0
- package/dist/shared/env.js +7 -0
- package/package.json +1 -1
- package/runtime.lock.json +2 -2
- package/dist/commands/sys/context/admin/axes-panel.d.ts +0 -21
|
@@ -4,7 +4,9 @@ import { defineBranch, defineLeaf } from '../../core/command.js';
|
|
|
4
4
|
import { InputError } from '../../core/io.js';
|
|
5
5
|
import { displayMessage } from '../../core/runtime/tmux-chrome.js';
|
|
6
6
|
import { contextDir } from '../../core/canvas/paths.js';
|
|
7
|
-
import {
|
|
7
|
+
import { existsSync } from 'node:fs';
|
|
8
|
+
import { activeBackgroundBashJobs, backgroundRunningBashJobs, bashJobCommandLine, bashJobPaths, clearJobDeadline, formatBashElapsed, isSafeBashJobId, jobStartedAtMs, readJobPgid, stopBackgroundBashJob, tailJobLog, writeJobDeadlineMs } from '../../core/bash-jobs.js';
|
|
9
|
+
import { parseDurationMs } from '../../core/wake.js';
|
|
8
10
|
import { cliClient, getNodeOrNull } from '../api-client.js';
|
|
9
11
|
import { nodeInPane } from '../surface/node/placement.js';
|
|
10
12
|
function resolveBashTarget(input, missingMessage) {
|
|
@@ -77,7 +79,7 @@ const nodeBashList = defineLeaf({
|
|
|
77
79
|
],
|
|
78
80
|
output: [
|
|
79
81
|
{ name: 'node_id', type: 'string', required: true, constraint: 'The target node.' },
|
|
80
|
-
{ name: 'jobs', type: 'object[]', required: true, constraint: 'Oldest-first {job_id, command, purpose, started_at, elapsed_ms, log_path, pgid, log_tail} for every live background job; purpose is a human-readable label or null.' },
|
|
82
|
+
{ name: 'jobs', type: 'object[]', required: true, constraint: 'Oldest-first {job_id, command, purpose, started_at, elapsed_ms, deadline_at, log_path, pgid, log_tail} for every live background job; purpose is a human-readable label or null; deadline_at is the auto-cancel time or null for a job that runs to completion.' },
|
|
81
83
|
],
|
|
82
84
|
outputKind: 'object',
|
|
83
85
|
effects: ['Reads the node context jobs directory. Changes nothing.'],
|
|
@@ -97,6 +99,7 @@ const nodeBashList = defineLeaf({
|
|
|
97
99
|
purpose: job.purpose,
|
|
98
100
|
started_at: new Date(job.startedAtMs).toISOString(),
|
|
99
101
|
elapsed_ms: now - job.startedAtMs,
|
|
102
|
+
deadline_at: job.deadlineAtMs === null ? null : new Date(job.deadlineAtMs).toISOString(),
|
|
100
103
|
log_path: job.logPath,
|
|
101
104
|
pgid: readJobPgid(dir, job.jobId) ?? null,
|
|
102
105
|
log_tail: tailJobLog(job.logPath, tail),
|
|
@@ -107,7 +110,7 @@ const nodeBashList = defineLeaf({
|
|
|
107
110
|
const jobs = result['jobs'];
|
|
108
111
|
if (jobs.length === 0)
|
|
109
112
|
return `No background bash jobs for ${result['node_id']}.`;
|
|
110
|
-
return jobs.map((job) => `${job.job_id} · ${formatBashElapsed(job.elapsed_ms)} · ${bashJobCommandLine(job.command)}${job.purpose === null ? '' : `\n purpose: ${job.purpose}`}\n log: ${job.log_path}`).join('\n');
|
|
113
|
+
return jobs.map((job) => `${job.job_id} · ${formatBashElapsed(job.elapsed_ms)} · ${bashJobCommandLine(job.command)}${job.purpose === null ? '' : `\n purpose: ${job.purpose}`}${job.deadline_at === null ? '' : `\n cancels at: ${job.deadline_at}`}\n log: ${job.log_path}`).join('\n');
|
|
111
114
|
},
|
|
112
115
|
});
|
|
113
116
|
const nodeBashKill = defineLeaf({
|
|
@@ -171,14 +174,75 @@ const nodeBashKill = defineLeaf({
|
|
|
171
174
|
},
|
|
172
175
|
render: (result) => `Canceled a background bash job for ${result['node_id']}${result['signaled'] === true ? '' : ' (its process group was already gone)'}.`,
|
|
173
176
|
});
|
|
177
|
+
const nodeBashExtend = defineLeaf({
|
|
178
|
+
name: 'extend',
|
|
179
|
+
description: 'claim an auto-backgrounded bash job so the daemon does not cancel it at its deadline',
|
|
180
|
+
whenToUse: 'a command the valve auto-backgrounded is worth keeping past its cancel deadline — give it more time with --for, or let it run to completion with --keep',
|
|
181
|
+
tier: 'hidden',
|
|
182
|
+
help: {
|
|
183
|
+
name: 'node bash extend',
|
|
184
|
+
summary: 'move or clear the auto-cancel deadline on one of a node’s live background bash jobs',
|
|
185
|
+
params: [
|
|
186
|
+
{ kind: 'positional', name: 'job', required: true, constraint: 'Job id, as reported by node bash list or the auto-background notice.' },
|
|
187
|
+
{ kind: 'flag', name: 'for', type: 'string', required: false, constraint: 'New window from now before the job is canceled, as a duration (e.g. 90s, 10m, 1h30m). Mutually exclusive with --keep; give exactly one.' },
|
|
188
|
+
{ kind: 'flag', name: 'keep', type: 'bool', required: false, constraint: 'Remove the deadline entirely so the job runs to completion. Mutually exclusive with --for; give exactly one.' },
|
|
189
|
+
{ kind: 'flag', name: 'node', type: 'string', required: false, constraint: 'Node owning the job. Defaults to the node in --pane, else the caller (CRTR_NODE_ID).' },
|
|
190
|
+
{ kind: 'flag', name: 'pane', type: 'string', required: false, constraint: 'tmux pane showing the target node, when --node is omitted. Defaults to $TMUX_PANE.' },
|
|
191
|
+
],
|
|
192
|
+
output: [
|
|
193
|
+
{ name: 'node_id', type: 'string', required: true, constraint: 'The owning node.' },
|
|
194
|
+
{ name: 'deadline_at', type: 'string', required: false, constraint: 'The job’s new auto-cancel time (ISO), or absent when --keep cleared the deadline so the job runs to completion.' },
|
|
195
|
+
],
|
|
196
|
+
outputKind: 'object',
|
|
197
|
+
effects: ['Rewrites or removes the job’s deadline file. The daemon’s deadline lane reads it on its next scan. Signals nothing.'],
|
|
198
|
+
},
|
|
199
|
+
run: async (input) => {
|
|
200
|
+
const { id } = resolveBashTarget(input, 'no node found for the bash job');
|
|
201
|
+
const node = await getNodeOrNull(id);
|
|
202
|
+
if (node === null) {
|
|
203
|
+
throw new InputError({ error: 'not_found', message: `no node: ${id}`, next: 'List nodes with `crtr node inspect list`.' });
|
|
204
|
+
}
|
|
205
|
+
const jobId = input['job'];
|
|
206
|
+
if (!isSafeBashJobId(jobId)) {
|
|
207
|
+
throw new InputError({ error: 'invalid_job_id', message: `invalid background job id: ${jobId}`, next: 'Use a job id reported by `crtr node bash list`.' });
|
|
208
|
+
}
|
|
209
|
+
const forDuration = input['for'];
|
|
210
|
+
const keep = input['keep'] === true;
|
|
211
|
+
if (keep === (forDuration !== undefined && forDuration !== '')) {
|
|
212
|
+
throw new InputError({ error: 'invalid_args', message: 'give exactly one of --for <duration> or --keep', next: 'Extend by a window with --for 10m, or drop the deadline with --keep.' });
|
|
213
|
+
}
|
|
214
|
+
const dir = contextDir(node.node_id);
|
|
215
|
+
const paths = bashJobPaths(dir, jobId);
|
|
216
|
+
if (!existsSync(paths.dir)) {
|
|
217
|
+
throw new InputError({ error: 'not_found', message: `no background job ${jobId} for ${node.node_id}`, next: 'List live jobs with `crtr node bash list`.' });
|
|
218
|
+
}
|
|
219
|
+
if (!existsSync(paths.jobBg) || existsSync(paths.jobExit)) {
|
|
220
|
+
throw new InputError({ error: 'not_running', message: `background job ${jobId} for ${node.node_id} is no longer running`, next: `It finished or was stopped, so there is no deadline to change. Log: ${paths.jobLog}` });
|
|
221
|
+
}
|
|
222
|
+
if (keep) {
|
|
223
|
+
clearJobDeadline(paths);
|
|
224
|
+
return { node_id: node.node_id };
|
|
225
|
+
}
|
|
226
|
+
const durationMs = parseDurationMs(forDuration);
|
|
227
|
+
if (durationMs === null || durationMs <= 0) {
|
|
228
|
+
throw new InputError({ error: 'invalid_duration', message: `not a duration: ${forDuration}`, next: 'Use a value like 90s, 10m, or 1h30m.' });
|
|
229
|
+
}
|
|
230
|
+
const deadlineMs = Date.now() + durationMs;
|
|
231
|
+
writeJobDeadlineMs(paths, deadlineMs);
|
|
232
|
+
return { node_id: node.node_id, deadline_at: new Date(deadlineMs).toISOString() };
|
|
233
|
+
},
|
|
234
|
+
render: (result) => result['deadline_at'] === undefined
|
|
235
|
+
? `Cleared the deadline — the background job will run to completion for ${result['node_id']}.`
|
|
236
|
+
: `Background job for ${result['node_id']} will be canceled at ${result['deadline_at']} unless extended again.`,
|
|
237
|
+
});
|
|
174
238
|
export const nodeBash = defineBranch({
|
|
175
239
|
name: 'bash',
|
|
176
240
|
description: 'control file-backed bash jobs owned by a node',
|
|
177
|
-
whenToUse: 'a running bash command must be handed off, listed, or
|
|
241
|
+
whenToUse: 'a running bash command must be handed off, listed, stopped, or its auto-cancel deadline changed — the tmux prefix menu backgrounds, the Inspector’s jobs section lists and cancels',
|
|
178
242
|
tier: 'hidden',
|
|
179
243
|
help: {
|
|
180
244
|
name: 'node bash',
|
|
181
245
|
summary: 'control file-backed bash jobs owned by a node',
|
|
182
246
|
},
|
|
183
|
-
children: [nodeBashBackground, nodeBashList, nodeBashKill],
|
|
247
|
+
children: [nodeBashBackground, nodeBashList, nodeBashKill, nodeBashExtend],
|
|
184
248
|
});
|
|
@@ -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
|
+
};
|