@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.
Files changed (36) hide show
  1. package/dist/clients/attach/viewer.js +400 -400
  2. package/dist/commands/node/bash.js +69 -5
  3. package/dist/commands/node/inspect.js +1 -1
  4. package/dist/commands/sys/context/admin/detail-panel.d.ts +3 -0
  5. package/dist/commands/sys/context/admin/detail-panel.js +32 -3
  6. package/dist/commands/sys/context/admin/docs-panel.d.ts +23 -13
  7. package/dist/commands/sys/context/admin/docs-panel.js +101 -103
  8. package/dist/commands/sys/context/admin/list-view.d.ts +53 -0
  9. package/dist/commands/sys/context/admin/list-view.js +125 -0
  10. package/dist/commands/sys/context/admin/model.d.ts +11 -0
  11. package/dist/commands/sys/context/admin/model.js +28 -1
  12. package/dist/commands/sys/context/admin/rail-panel.d.ts +46 -0
  13. package/dist/commands/sys/context/admin/{axes-panel.js → rail-panel.js} +64 -21
  14. package/dist/commands/sys/context/admin/shell.d.ts +19 -2
  15. package/dist/commands/sys/context/admin/shell.js +142 -46
  16. package/dist/commands/sys/context/admin.js +4 -2
  17. package/dist/commands/sys/context/resolve.js +5 -1
  18. package/dist/core/bash-jobs.d.ts +23 -0
  19. package/dist/core/bash-jobs.js +44 -2
  20. package/dist/core/canvas/browse/model.d.ts +0 -9
  21. package/dist/core/canvas/browse/model.js +1 -37
  22. package/dist/core/canvas/browse/render.js +2 -1
  23. package/dist/core/runtime/tmux-bindings.js +4 -3
  24. package/dist/core/tui/fuzzy.d.ts +9 -0
  25. package/dist/core/tui/fuzzy.js +38 -0
  26. package/dist/core/wake.d.ts +3 -0
  27. package/dist/core/wake.js +3 -2
  28. package/dist/daemon/crtrd.js +3 -0
  29. package/dist/daemon/reconcilers/bash-deadline.d.ts +17 -0
  30. package/dist/daemon/reconcilers/bash-deadline.js +73 -0
  31. package/dist/pi-extensions/canvas-bash-valve.js +15 -2
  32. package/dist/shared/env.d.ts +4 -0
  33. package/dist/shared/env.js +7 -0
  34. package/package.json +1 -1
  35. package/runtime.lock.json +2 -2
  36. 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 { activeBackgroundBashJobs, backgroundRunningBashJobs, bashJobCommandLine, bashJobPaths, formatBashElapsed, isSafeBashJobId, jobStartedAtMs, readJobPgid, stopBackgroundBashJob, tailJobLog } from '../../core/bash-jobs.js';
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 stopped — the tmux prefix menu backgrounds, the Inspector’s jobs section lists and cancels',
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/canvas/browse/model.js';
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. Every selectable row here is a field the
3
- // shell can write; everything below the rule is what the planner decided.
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
- private filter;
27
- private filtering;
28
- /** Off by default: the surface is legible only when the silent documents are
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
- get ownsKeyboard(): boolean;
34
- get filteringDelivering(): boolean;
35
- setRows(rows: DocRow[], selectedKey?: string): void;
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
- toggleDeliveringOnly(): void;
38
- /** True only when a live filter consumed the input. */
39
- handleInput(data: string): boolean;
40
- private visible;
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, each carrying its own
2
- // six-event matrix. One row is one physical document, so two stores answering
3
- // to the same canonical name stay two rows and the shadowed one is visible.
4
- import { matchesKey, truncateToWidth, visibleWidth } from '@earendil-works/pi-tui';
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
- filter = '';
58
- filtering = false;
59
- /** Off by default: the surface is legible only when the silent documents are
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 rows = this.visible();
67
- this.index = Math.max(0, Math.min(this.index, rows.length - 1));
68
- return rows[this.index];
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
- get filteringDelivering() {
74
- return this.deliveringOnly;
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
- const visible = this.visible();
79
- const found = selectedKey === undefined ? -1 : visible.findIndex((row) => row.key === selectedKey);
80
- this.index = found >= 0 ? found : Math.min(this.index, Math.max(0, visible.length - 1));
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
- const rows = this.visible();
84
- if (rows.length === 0)
93
+ if (this.lines.length === 0)
85
94
  return;
86
- this.index = Math.max(0, Math.min(rows.length - 1, this.index + delta));
95
+ this.index = Math.max(0, Math.min(this.lines.length - 1, this.index + delta));
87
96
  this.onChange();
88
97
  }
89
- toggleDeliveringOnly() {
90
- this.deliveringOnly = !this.deliveringOnly;
91
- const held = this.selected?.key;
92
- this.index = 0;
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
- /** True only when a live filter consumed the input. */
101
- handleInput(data) {
102
- if (this.filtering) {
103
- if (matchesKey(data, 'escape')) {
104
- this.filter = '';
105
- this.filtering = false;
106
- }
107
- else if (matchesKey(data, 'enter')) {
108
- this.filtering = false;
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
- visible() {
135
- const needle = this.filter.trim().toLowerCase();
136
- return this.rows.filter((row) => {
137
- if (this.deliveringOnly && !rowDelivers(row))
138
- return false;
139
- if (needle === '')
140
- return true;
141
- return `${row.name}\n${row.anchor.scope}\n${row.anchor.storePath}`.toLowerCase().includes(needle);
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 heading = this.filtering
148
- ? `/${this.filter}`
149
- : this.deliveringOnly
150
- ? 'DELIVERING'
151
- : 'DOCUMENTS';
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
- const nameRoom = Math.max(1, width - MATRIX_WIDTH - 2);
155
- out.push(truncateToWidth(` ${padAnsi(`${theme.fg('accent', theme.bold(heading))} ${theme.fg('dim', count)}`, nameRoom - 1)} ${theme.fg('dim', matrixHead)}`, width));
156
- if (rows.length === 0) {
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(rows.length, this.index, capacity);
140
+ const range = visibleRange(this.lines.length, this.index, capacity);
162
141
  for (let at = range.start; at < range.end; at += 1) {
163
- const row = rows[at];
164
- const matrix = SURFACE_EVENTS.map((event) => {
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
+ };