@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.
Files changed (76) hide show
  1. package/dist/api/dto/broker-ops.d.ts +8 -3
  2. package/dist/api/dto/broker.d.ts +4 -2
  3. package/dist/api/dto/inbox.d.ts +1 -0
  4. package/dist/api/dto/nodes.d.ts +4 -0
  5. package/dist/api/dto/review-comments.d.ts +1 -0
  6. package/dist/builtin-memory/04-orchestration-kernel.md +1 -1
  7. package/dist/clients/attach/session/profile-files.js +4 -1
  8. package/dist/clients/attach/viewer.js +366 -366
  9. package/dist/commands/node/create.js +8 -3
  10. package/dist/commands/node/inspect.js +1 -1
  11. package/dist/commands/sys/context/admin/detail-panel.d.ts +3 -0
  12. package/dist/commands/sys/context/admin/detail-panel.js +32 -3
  13. package/dist/commands/sys/context/admin/docs-panel.d.ts +23 -13
  14. package/dist/commands/sys/context/admin/docs-panel.js +101 -103
  15. package/dist/commands/sys/context/admin/list-view.d.ts +53 -0
  16. package/dist/commands/sys/context/admin/list-view.js +125 -0
  17. package/dist/commands/sys/context/admin/model.d.ts +11 -0
  18. package/dist/commands/sys/context/admin/model.js +28 -1
  19. package/dist/commands/sys/context/admin/rail-panel.d.ts +46 -0
  20. package/dist/commands/sys/context/admin/{axes-panel.js → rail-panel.js} +64 -21
  21. package/dist/commands/sys/context/admin/shell.d.ts +19 -2
  22. package/dist/commands/sys/context/admin/shell.js +142 -46
  23. package/dist/commands/sys/daemon.js +1 -1
  24. package/dist/core/__tests__/child-death-wake.test.js +0 -48
  25. package/dist/core/__tests__/daemon-boot.test.js +0 -8
  26. package/dist/core/__tests__/dead-node-policy-table.test.js +1 -2
  27. package/dist/core/__tests__/integration/deferred-no-wake.test.js +14 -2
  28. package/dist/core/__tests__/lifecycle.test.js +5 -5
  29. package/dist/core/__tests__/relaunch-root.test.js +91 -1
  30. package/dist/core/__tests__/revive-capacity.test.js +36 -1
  31. package/dist/core/__tests__/seam/broker-cap-freeze.test.js +73 -0
  32. package/dist/core/__tests__/seam/dormancy-release.test.js +1 -2
  33. package/dist/core/canvas/browse/model.d.ts +0 -9
  34. package/dist/core/canvas/browse/model.js +1 -37
  35. package/dist/core/canvas/browse/render.js +2 -1
  36. package/dist/core/canvas/canvas.d.ts +6 -5
  37. package/dist/core/canvas/canvas.js +8 -7
  38. package/dist/core/canvas/types.d.ts +6 -0
  39. package/dist/core/review/realize.js +1 -1
  40. package/dist/core/runtime/broker/node-named.d.ts +1 -0
  41. package/dist/core/runtime/host.js +114 -103
  42. package/dist/core/runtime/lifecycle.js +3 -2
  43. package/dist/core/runtime/naming.d.ts +26 -8
  44. package/dist/core/runtime/naming.js +75 -43
  45. package/dist/core/runtime/reset.js +12 -5
  46. package/dist/core/runtime/revive-all.d.ts +4 -11
  47. package/dist/core/runtime/revive-all.js +6 -13
  48. package/dist/core/runtime/tmux-bindings.js +4 -3
  49. package/dist/core/runtime/warm-pool.js +4 -4
  50. package/dist/core/tui/fuzzy.d.ts +9 -0
  51. package/dist/core/tui/fuzzy.js +38 -0
  52. package/dist/daemon/api/__tests__/node-create-description.test.js +1 -0
  53. package/dist/daemon/api/handlers/broker-ops.js +14 -7
  54. package/dist/daemon/api/handlers/feedback-comments.js +1 -1
  55. package/dist/daemon/api/map.js +2 -0
  56. package/dist/daemon/crtrd.js +17 -20
  57. package/dist/daemon/messaging/node-message.d.ts +1 -0
  58. package/dist/daemon/messaging/node-message.js +6 -2
  59. package/dist/daemon/reconcilers/live-obligation.d.ts +1 -2
  60. package/dist/daemon/reconcilers/node-lifecycle/freeze-lane.d.ts +0 -5
  61. package/dist/daemon/reconcilers/node-lifecycle/freeze-lane.js +9 -13
  62. package/dist/daemon/reconcilers/node-lifecycle/respawn-policy.js +0 -6
  63. package/dist/daemon/reconcilers/node-lifecycle/terminating.d.ts +0 -2
  64. package/dist/daemon/reconcilers/node-lifecycle/terminating.js +0 -10
  65. package/dist/daemon/reconcilers/node-lifecycle/tick.d.ts +2 -2
  66. package/dist/daemon/reconcilers/node-lifecycle/tick.js +4 -2
  67. package/dist/daemon/reconcilers/storage-maintenance.js +2 -2
  68. package/dist/daemon/review/comment-notify.d.ts +1 -0
  69. package/dist/daemon/review/comment-notify.js +1 -0
  70. package/dist/pi-extensions/broker-local.d.ts +1 -0
  71. package/dist/pi-extensions/broker-local.js +12 -7
  72. package/dist/pi-extensions/canvas-recap.js +1 -0
  73. package/dist/pi-extensions/canvas-stophook.js +2 -0
  74. package/package.json +1 -1
  75. package/runtime.lock.json +2 -2
  76. 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 work; orchestrator when the unit itself needs decomposition across children.' },
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
- : await childFollowUp(parentId),
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/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
+ };
@@ -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;