@north-light/crouter 0.3.240 → 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.
@@ -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;
@@ -5,7 +5,7 @@ import { resolve as resolvePath } from 'node:path';
5
5
  import { theme } from '../../../../core/tui/panel.js';
6
6
  import { realpathOrSelf } from '../../../../core/fs-utils.js';
7
7
  import { planDelivery, } from '../../../../core/substrate/plan.js';
8
- import { RUNGS, SURFACE_EVENTS, rungRank, } from '../../../../core/substrate/schema.js';
8
+ import { RUNGS, SURFACE_EVENTS, previewLine, rungRank, } from '../../../../core/substrate/schema.js';
9
9
  import { scopeForCwd, profileNameFor } from '../../../../core/substrate/subject-fields.js';
10
10
  import { resolveMemoryDocForTarget } from '../../../../core/memory-resolver.js';
11
11
  export function subjectOf(snapshot) {
@@ -207,3 +207,30 @@ export function describeSurfaceEntry(entry) {
207
207
  export function plural(count, one, many) {
208
208
  return `${count} ${count === 1 ? one : many}`;
209
209
  }
210
+ export function rungCostsOf(record) {
211
+ const entry = record.name.length;
212
+ const routing = previewLine(record.doc).length;
213
+ return {
214
+ none: 0,
215
+ name: tokens(entry),
216
+ preview: tokens(entry + routing),
217
+ content: tokens(record.doc.body.length),
218
+ };
219
+ }
220
+ /** What this document actually spends in the event the record belongs to. */
221
+ export function deliveredCost(record) {
222
+ return record === undefined ? 0 : rungCostsOf(record)[record.finalRung];
223
+ }
224
+ function tokens(characters) {
225
+ return Math.ceil(characters / 4);
226
+ }
227
+ /** Approximate tokens in at most six columns, always marked as an estimate. */
228
+ export function formatCost(count) {
229
+ if (count <= 0)
230
+ return '';
231
+ if (count < 1000)
232
+ return `~${count}`;
233
+ if (count < 10_000)
234
+ return `~${(count / 1000).toFixed(1)}k`;
235
+ return `~${Math.round(count / 1000)}k`;
236
+ }
@@ -0,0 +1,46 @@
1
+ import { type Snapshot } from './model.js';
2
+ import type { Filters } from './list-view.js';
3
+ export type AxisId = 'event' | 'payload' | 'kind' | 'mode' | 'lifecycle' | 'manager' | 'depth' | 'profile' | 'cwd';
4
+ export interface Axis {
5
+ id: AxisId;
6
+ label: string;
7
+ value: string;
8
+ /** Free-text axes open a field; the rest cycle in place. */
9
+ typed: boolean;
10
+ }
11
+ export type FacetId = 'rung' | 'origin' | 'kind' | 'state' | 'query';
12
+ export interface Facet {
13
+ id: FacetId;
14
+ label: string;
15
+ value: string;
16
+ /** The query is typed; every other facet cycles through a closed set. */
17
+ typed: boolean;
18
+ }
19
+ /** The FILTERS rows for one filter value. `doc kind` is spelled out because the
20
+ * snapshot above it dials the NODE's kind. */
21
+ export declare function facetsOf(filters: Filters): Facet[];
22
+ export declare function cycleFacet(filters: Filters, id: FacetId, delta: number): Filters;
23
+ /** The rail's rows for a snapshot. The payload row appears only for the events
24
+ * that read one, because on boot there is nothing it could change. */
25
+ export declare function axesOf(snapshot: Snapshot, profileName: string): Axis[];
26
+ /** Cycle one enum axis, returning the changed snapshot. A typed axis is never
27
+ * cycled — the shell opens a field for it instead. */
28
+ export declare function cycleAxis(snapshot: Snapshot, id: AxisId, delta: number): Snapshot;
29
+ /** Apply a typed axis's submitted value. */
30
+ export declare function setTypedAxis(snapshot: Snapshot, id: AxisId, value: string): Snapshot;
31
+ /** The value a typed axis's field is seeded with. */
32
+ export declare function typedAxisValue(snapshot: Snapshot, id: AxisId): string;
33
+ /** Both sections as one column. `index` runs over the axes and then the facets
34
+ * — the order the shell routes a cycle in — while the section headings and the
35
+ * blank rows between them are drawn but never selectable. */
36
+ export declare function renderRail(opts: {
37
+ axes: Axis[];
38
+ facets: Facet[];
39
+ index: number;
40
+ width: number;
41
+ height: number;
42
+ focused: boolean;
43
+ /** Documents the filters select, out of the whole corpus. */
44
+ matched: number;
45
+ total: number;
46
+ }): string[];
@@ -1,11 +1,45 @@
1
- // The snapshot rail: the configuration axes the page plans against. Every row
2
- // is a live control — an enum cycles in place, a free-text axis opens a field —
3
- // and every change re-plans the whole surface.
1
+ // The rail: two sections in one row grammar. SNAPSHOT holds the configuration
2
+ // axes the page plans against — changing one re-plans every event. FILTERS holds
3
+ // the question asked of that plan — changing one only changes what is shown.
4
+ // An enum cycles in place; a free-text row opens a field.
4
5
  import { truncateToWidth } from '@earendil-works/pi-tui';
5
- import { padAnsi, theme, visibleRange } from '../../../../core/tui/panel.js';
6
- import { SURFACE_EVENTS } from '../../../../core/substrate/schema.js';
6
+ import { padAnsi, theme } from '../../../../core/tui/panel.js';
7
+ import { KINDS, RUNGS, SURFACE_EVENTS } from '../../../../core/substrate/schema.js';
7
8
  import { listProfiles } from '../../../../core/profiles/manifest.js';
8
9
  import { payloadAxisFor } from './model.js';
10
+ const RUNG_FACETS = ['any', ...RUNGS.filter((rung) => rung !== 'none'), 'silent'];
11
+ const ORIGIN_FACETS = ['any', 'project', 'user', 'profile', 'builtin', 'node'];
12
+ const KIND_FACETS = ['any', ...KINDS];
13
+ const STATE_FACETS = ['any', 'delivering', 'gated', 'shadowed', 'no-entry'];
14
+ /** The FILTERS rows for one filter value. `doc kind` is spelled out because the
15
+ * snapshot above it dials the NODE's kind. */
16
+ export function facetsOf(filters) {
17
+ return [
18
+ { id: 'rung', label: 'rung', value: filters.rung, typed: false },
19
+ { id: 'origin', label: 'origin', value: filters.origin, typed: false },
20
+ { id: 'kind', label: 'doc kind', value: filters.kind, typed: false },
21
+ { id: 'state', label: 'state', value: filters.state, typed: false },
22
+ { id: 'query', label: 'search', value: filters.query === '' ? 'any' : filters.query, typed: true },
23
+ ];
24
+ }
25
+ export function cycleFacet(filters, id, delta) {
26
+ const step = (values, current) => {
27
+ const at = values.indexOf(current);
28
+ return values[(Math.max(0, at) + delta + values.length) % values.length];
29
+ };
30
+ switch (id) {
31
+ case 'rung':
32
+ return { ...filters, rung: step(RUNG_FACETS, filters.rung) };
33
+ case 'origin':
34
+ return { ...filters, origin: step(ORIGIN_FACETS, filters.origin) };
35
+ case 'kind':
36
+ return { ...filters, kind: step(KIND_FACETS, filters.kind) };
37
+ case 'state':
38
+ return { ...filters, state: step(STATE_FACETS, filters.state) };
39
+ case 'query':
40
+ return filters;
41
+ }
42
+ }
9
43
  function tildify(path) {
10
44
  const home = process.env['HOME'];
11
45
  return home !== undefined && home !== '' && path.startsWith(home) ? `~${path.slice(home.length)}` : path;
@@ -92,26 +126,35 @@ export function typedAxisValue(snapshot, id) {
92
126
  return axis === 'file' ? snapshot.file : axis === 'docName' ? snapshot.docName : axis === 'command' ? snapshot.command : '';
93
127
  }
94
128
  const LABEL_WIDTH = 10;
95
- export function renderAxes(axes, index, width, height, focused) {
96
- const out = [theme.fg('dim', ' SNAPSHOT'), ''];
97
- const headerRows = out.length;
98
- const rows = [];
99
- for (const [at, axis] of axes.entries()) {
129
+ /** Both sections as one column. `index` runs over the axes and then the facets
130
+ * — the order the shell routes a cycle in — while the section headings and the
131
+ * blank rows between them are drawn but never selectable. */
132
+ export function renderRail(opts) {
133
+ const { axes, facets, index, width, height, focused, matched, total } = opts;
134
+ const lines = [];
135
+ let cursorLine = 0;
136
+ const push = (at, label, value) => {
100
137
  const room = Math.max(1, width - LABEL_WIDTH - 2);
101
- const label = padAnsi(truncateToWidth(axis.label, LABEL_WIDTH), LABEL_WIDTH);
102
- const value = truncateToWidth(axis.value, room);
103
- const body = ` ${label} ${value}`;
138
+ const padded = padAnsi(truncateToWidth(label, LABEL_WIDTH), LABEL_WIDTH);
139
+ const shown = truncateToWidth(value, room);
140
+ const body = ` ${padded} ${shown}`;
141
+ if (at === index)
142
+ cursorLine = lines.length;
104
143
  if (at === index && focused)
105
- rows.push(theme.bg('selectedBg', padAnsi(body, width)));
144
+ lines.push(theme.bg('selectedBg', padAnsi(body, width)));
106
145
  else if (at === index)
107
- rows.push(`${theme.fg('accent', '▌')}${padAnsi(theme.fg('accent', body.slice(1)), width - 1)}`);
146
+ lines.push(`${theme.fg('accent', '▌')}${padAnsi(theme.fg('accent', body.slice(1)), width - 1)}`);
108
147
  else
109
- rows.push(padAnsi(` ${theme.fg('dim', label)} ${value}`, width));
110
- }
111
- const capacity = Math.max(1, height - headerRows);
112
- const range = visibleRange(rows.length, index, capacity);
113
- out.push(...rows.slice(range.start, range.start + capacity));
148
+ lines.push(padAnsi(` ${theme.fg('dim', padded)} ${shown}`, width));
149
+ };
150
+ lines.push(theme.fg('dim', ' SNAPSHOT'), '');
151
+ axes.forEach((axis, at) => push(at, axis.label, axis.value));
152
+ lines.push('', theme.fg('dim', ` FILTERS · ${matched} of ${total}`), '');
153
+ facets.forEach((facet, at) => push(axes.length + at, facet.label, facet.value));
154
+ const maxStart = Math.max(0, lines.length - height);
155
+ const start = Math.min(Math.max(0, cursorLine - Math.floor(height / 2)), maxStart);
156
+ const out = lines.slice(start, start + height);
114
157
  while (out.length < height)
115
158
  out.push('');
116
- return out.slice(0, height);
159
+ return out;
117
160
  }
@@ -14,7 +14,11 @@ export declare class ContextAdminShell implements Component, Focusable {
14
14
  private snapshot;
15
15
  private plans;
16
16
  private zone;
17
- private axisIndex;
17
+ private railIndex;
18
+ private filters;
19
+ private rows;
20
+ /** The search field owns the keyboard while it is open. */
21
+ private searching;
18
22
  private readonly docs;
19
23
  private readonly detail;
20
24
  private modal;
@@ -35,6 +39,9 @@ export declare class ContextAdminShell implements Component, Focusable {
35
39
  /** Recompute the focused event's plan, refresh the panels from it, and let
36
40
  * the remaining events fill in behind it. */
37
41
  private rebuild;
42
+ /** A filter never re-plans: it is a question about the plan already computed,
43
+ * which is what keeps a facet change instant on a corpus this size. */
44
+ private setFilters;
38
45
  private syncDetail;
39
46
  private setSnapshot;
40
47
  private matches;
@@ -47,8 +54,14 @@ export declare class ContextAdminShell implements Component, Focusable {
47
54
  * drawn against, so it earns the page-level gesture. */
48
55
  private cyclesEvent;
49
56
  handleInput(data: string): void;
50
- private handleAxes;
57
+ /** The rail's rows are the snapshot axes followed by the filter facets, so
58
+ * one cursor and one gesture drive both sections. */
59
+ private handleRail;
51
60
  private handleDocs;
61
+ private openSearch;
62
+ /** Typing filters live, so the field is the list's own heading rather than a
63
+ * modal over the rows it is selecting. */
64
+ private handleSearch;
52
65
  private handleDetail;
53
66
  /** What the focused dossier row does when opened — the same write its letter
54
67
  * key starts, so a user who never learns the letters can still edit. */
@@ -78,5 +91,9 @@ export declare class ContextAdminShell implements Component, Focusable {
78
91
  private renderModal;
79
92
  private footer;
80
93
  render(width: number): string[];
94
+ /** What the delivering documents spend on this event. An estimate, and a
95
+ * floor on the rendered prompt: boot wraps these documents in its own intro
96
+ * prose and catalog tree. */
97
+ private costMeta;
81
98
  }
82
99
  export declare function runContextAdmin(seed: Snapshot): Promise<AdminResult | undefined>;