@cocasts/pi-status-line 0.0.0-stage → 0.1.0

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/CHANGELOG.md ADDED
@@ -0,0 +1,7 @@
1
+ # @cocasts/pi-status-line
2
+
3
+ ## 0.1.0
4
+
5
+ ### Minor Changes
6
+
7
+ - feat(status-line): add modular status line package
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 moecasts
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,228 @@
1
- # Temporary Holding Version
1
+ # @cocasts/pi-status-line
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A modular, multi-row status line for the [pi coding agent](https://pi.dev). Instead of a fixed
4
+ footer layout, you list which **modules** appear on each row's left and right, and how each one is
5
+ labelled, formatted, and coloured.
6
+
7
+ ## Features
8
+
9
+ - Declares any number of footer rows, each with its own left and right module lists.
10
+ - Ships twelve built-in modules: model, provider, effort, path, branch, session, token volumes,
11
+ cache hit rate, throughput, context pressure, and cost.
12
+ - Lets each module choose a label style, format preset, theme colour, and literal prefix/suffix.
13
+ - Shrinks each row on its own by module priority, keeping the context reading as a floor.
14
+ - Reports the effective layout, last-turn timing, and config problems through `/statusline`.
15
+ - Keeps rendering the usable entries when the config file is partly invalid.
16
+
17
+ The default layout mirrors pi's native footer:
18
+
19
+ ```text
20
+ ~/projects/pi-extensions (feature/status-line) • tidy-footer
21
+ ↑ 35k · ↓ 1.2k · ◎ 99.8% · ϟ 82/s · $0.42 · 17.0%/1.0M (anthropic) anthropic/claude-sonnet-4 • high
22
+ ```
23
+
24
+ Everything renders `dim`, the way the native footer does; only context pressure borrows `warning`
25
+ (above 70 %) and `error` (above 90 %). Modules render in the order you list them, and a module with
26
+ nothing to say (no branch, no output yet, no rate yet, only one provider) disappears without
27
+ leaving a dangling separator.
28
+
29
+ ## Requirements
30
+
31
+ - A current [pi coding agent](https://pi.dev) installation.
32
+ - A monospace terminal font that draws `↑ ↓ ◎ ϟ` in one cell. Otherwise use `label: "word"`.
33
+
34
+ ## Installation
35
+
36
+ ```bash
37
+ pi install npm:@cocasts/pi-status-line
38
+
39
+ # or try it for one run without installing
40
+ pi -e npm:@cocasts/pi-status-line
41
+ ```
42
+
43
+ If pi is already running, `/reload` picks up a fresh install.
44
+
45
+ ## Configuration
46
+
47
+ The config file is `status-line/config.json` inside pi's agent directory — by default
48
+ `~/.pi/agent/status-line/config.json`, or `$PI_CODING_AGENT_DIR/status-line/config.json` when that
49
+ override is set.
50
+
51
+ It is read at session start and on `/reload`. A missing file means the built-in defaults; an invalid
52
+ entry is skipped with one warning and the rest of the line still renders.
53
+
54
+ ```jsonc
55
+ {
56
+ "divider": " · ",
57
+ "rows": [
58
+ {
59
+ "divider": " ",
60
+ "left": ["path", "branch", { "id": "session", "prefix": "• " }]
61
+ },
62
+ {
63
+ "left": [
64
+ "input",
65
+ "output",
66
+ { "id": "cache-hit", "format": "hit" },
67
+ "tps",
68
+ "cost",
69
+ { "id": "context", "format": "window" }
70
+ ],
71
+ "rightDivider": " ",
72
+ "right": [
73
+ "provider",
74
+ { "id": "model", "format": "short" },
75
+ { "id": "effort", "label": "word", "prefix": "• " }
76
+ ]
77
+ }
78
+ ]
79
+ }
80
+ ```
81
+
82
+ Top level:
83
+
84
+ | Field | Values | Meaning |
85
+ | --- | --- | --- |
86
+ | `rows` | array of `{ "left": [], "right": [] }` | Footer rows, top to bottom. A row with nothing to show is omitted. |
87
+ | `divider` | any string (default `" · "`) | Literal text between two modules on a row. |
88
+
89
+ A row accepts `left` and `right` module lists; an omitted side is empty. It may also override the
90
+ separator with `divider` (both sides), `leftDivider`, or `rightDivider` — the default layout uses
91
+ `" "` on the directory row and on the identity side so `~/app (main) • session` and
92
+ `(provider) model • level` read as phrases, while the readings keep the `·` beat. Every entry
93
+ accepts:
94
+
95
+ | Field | Values | Meaning |
96
+ | --- | --- | --- |
97
+ | `id` | a module id (below) | Which module to render. Unknown ids are skipped with a warning. |
98
+ | `label` | `glyph` · `word` · `none` | How the label reads. Omit for the module's default. |
99
+ | `format` | module-specific preset | Controls the value only. Omit for the module's default. |
100
+ | `color` | a pi theme token | Overrides the module's own colour. Omit to keep it (e.g. context pressure). |
101
+ | `prefix` / `suffix` | any string | Literal decoration around the whole module, rendered dim. Spaces are literal. |
102
+
103
+ A bare id is shorthand for `{ "id": "…" }`, so `"left": ["input", "context"]` also works. An empty
104
+ `rows` array clears the footer. The older single-row shape (`{ "left": [...], "right": [...] }`
105
+ without `rows`) is still read as one row, with the default metrics row for whichever side it omits.
106
+
107
+ ### Labels
108
+
109
+ - `glyph` — one-cell symbol from the terminal's own monospace font, followed by a space: `↑ 35k`.
110
+ - `word` — the spelled-out label with a space: `input 35k`. Use this when a font draws a glyph wider
111
+ than its cell.
112
+ - `none` — value only. The default for self-describing modules such as `model`, `context`, and
113
+ `cost`.
114
+
115
+ ### Colours
116
+
117
+ `color` accepts pi's foreground theme tokens (`accent`, `muted`, `success`, `error`, `warning`,
118
+ `syntaxString`, `thinkingLow`, …). Background tokens are rejected; run `/statusline` to validate a
119
+ name. Every module defaults to `dim`, matching the native footer. `context` keeps its dynamic
120
+ pressure colour unless you override it.
121
+
122
+ ## Modules
123
+
124
+ Priority decides what leaves the footer first when the terminal narrows: **lowest priority is dropped
125
+ first**. `floor` modules are never dropped by the shrink pass.
126
+
127
+ | Module | Formats (default first) | Default label | Default colour | Priority | Floor |
128
+ | --- | --- | --- | --- | --- | --- |
129
+ | `session` | `name` | `none` | `dim` | 10 | no |
130
+ | `branch` | `paren` (`(main)`) · `name` (`main`) | `none` | `dim` | 15 | no |
131
+ | `path` | `full` (home-relative) · `tail` (last two segments) | `none` | `dim` | 20 | no |
132
+ | `tps` | `rate` (`82/s`) · `long` (`82 tok/s`) | `glyph` `ϟ` | `dim` | 30 | no |
133
+ | `effort` | `native` (`medium`, `thinking off`) · `word` (`medium`) · `initial` (`M`) | `none` | `dim` | 40 | no |
134
+ | `cache-hit` | `percent` (`99.8%`) · `hit` (`99.8% hit`) | `glyph` `◎` | `dim` | 50 | no |
135
+ | `output` | `tokens` · `exact` | `glyph` `↓` | `dim` | 60 | no |
136
+ | `cost` | `usd` (`$0.42`) · `exact` (`$0.4200`) | `none` | `dim` | 70 | no |
137
+ | `input` | `tokens` · `exact` | `glyph` `↑` | `dim` | 80 | no |
138
+ | `provider` | `paren` (`(anthropic)`) · `name` (`anthropic`) | `none` | `dim` | 85 | no |
139
+ | `model` | `id` · `short` (no provider prefix) | `none` | `dim` | 90 | no |
140
+ | `context` | `native` (`17.0%/200k`) · `window` (`34k/200k`) · `tokens` (`34k`) · `percent` (`17%`) | `none` | pressure colour | 1000 | **yes** |
141
+
142
+ Notes:
143
+
144
+ - `input` / `output` count session prompt tokens that were neither read from nor written to cache, and
145
+ assistant output tokens. They hide while zero, as do `branch` (no repository), `session` (no name),
146
+ `provider` (only one provider available), `cost` (nothing spent yet), `cache-hit` and `tps` (before
147
+ the first turn).
148
+ - `tps` is the **latest turn**, not a session average: an average folds in cold-cache warm-up turns
149
+ and reports a speed the route no longer has. `~82/s` marks an estimate; a bare `82/s` came from
150
+ provider usage.
151
+ - `cost` is session spend in USD. A nonzero amount that rounds to nothing reads `$0.01`, never `$0`.
152
+ - `context` follows the native footer: one decimal of pressure over the abbreviated window, and `?`
153
+ when pi withholds the share after a compaction. It is a floor and survives every shrink rung.
154
+
155
+ ## Overflow
156
+
157
+ Each row shrinks on its own, so a long working directory never costs the metrics their seats. Within
158
+ a row, non-floor modules give up their seat one at a time in priority order — **whichever side they
159
+ sit on** — so:
160
+
161
+ ```text
162
+ session → branch → path → tps → effort → cache-hit → output → cost → input → provider → model →
163
+ (context stays)
164
+ ```
165
+
166
+ The directory row therefore keeps the path as long as it fits, dropping the session and branch tail
167
+ first, the way the native footer truncates its directory line from the right. A row never renders a
168
+ module half-cut: the last module on a row that still does not fit truncates with `…`. At
169
+ `width <= 0` the extension renders an empty line.
170
+
171
+ ## Commands
172
+
173
+ | Command | Effect |
174
+ | --- | --- |
175
+ | `/statusline` | Print the effective rows, their resolved formatting, the last turn's timing, and any config problems. |
176
+
177
+ The report lists each row's module order and divider, every resolved `label` / `format` / `color`
178
+ with its source (default or config), each module's priority and floor flag, the last turn's timing
179
+ (`ttft`, decode window, rate), and any validation problems. When there are no problems it says so
180
+ explicitly.
181
+
182
+ The report describes what is loaded, not the file on disk — after editing the file, run `/reload`,
183
+ then `/statusline` to confirm what pi is actually showing.
184
+
185
+ ## Turn metrics
186
+
187
+ The throughput reading follows the harness contract: a request opens at `before_provider_request`, the
188
+ first streamed delta freezes TTFT, and a finished message's rate is
189
+ `usage.output / ((messageEnd − firstToken) / 1000)`. A decode window shorter than 200 ms is treated as
190
+ a burst arrival and reports **no** rate rather than a confident fake; `/statusline` shows the last
191
+ turn's TTFT and decode window so the number is auditable.
192
+
193
+ ## Design rules
194
+
195
+ - A glyph and a word both keep a space before the value (`↑ 35k`, `input 35k`); `none` leaves the
196
+ value to speak for itself.
197
+ - Every glyph is one cell, drawn by the terminal's own monospace font, with no emoji presentation —
198
+ two-cell or fallback-drawn glyphs overlap the following character. Use `label: "word"` if a font
199
+ misbehaves.
200
+ - Values are dim by default, like pi's native footer; context pressure is the one dynamic colour.
201
+ A configured `color` is an opt-in accent.
202
+ - Rendering is a pure function of `(theme, width, layout, parts)`. There is no timer: no module reads a
203
+ clock, so the footer repaints on events rather than on a tick.
204
+ - A broken config never takes the footer down: parse problems are reported once, and the usable
205
+ entries still render.
206
+
207
+ ## Development
208
+
209
+ ```bash
210
+ bun install # from the monorepo root
211
+ bun run verify # TypeScript 7 + Biome checks + Vitest
212
+ bun run format # format with Biome
213
+ bun run test # vitest only
214
+ ```
215
+
216
+ To load the local package in pi:
217
+
218
+ ```bash
219
+ pi -e ./packages/pi-status-line
220
+ ```
221
+
222
+ ## Author
223
+
224
+ **moecasts** — <moecasts.caster@gmail.com>
225
+
226
+ ## License
227
+
228
+ MIT — see [LICENSE](./LICENSE).
@@ -0,0 +1,389 @@
1
+ /**
2
+ * The config schema and its tolerant reader.
3
+ *
4
+ * `parseConfig` is a pure function of the file's text: it validates every entry, records one
5
+ * human-readable problem per rejected or corrected field, and always returns a usable layout. A
6
+ * missing file, a half-edited JSON document, or a stale module id must never take the status line
7
+ * down — the footer is chrome, and chrome that throws is worse than chrome that is wrong.
8
+ */
9
+
10
+ import type { ThemeColor } from '@earendil-works/pi-coding-agent';
11
+
12
+ import { isModuleId, type LabelStyle, MODULE_IDS, MODULES, type ModuleId, type ResolvedEntry } from './modules.ts';
13
+ import type { Layout } from './render.ts';
14
+
15
+ /** Every foreground theme token pi exposes. Backgrounds are excluded on purpose. */
16
+ export const THEME_COLORS: readonly ThemeColor[] = [
17
+ 'accent',
18
+ 'border',
19
+ 'borderAccent',
20
+ 'borderMuted',
21
+ 'success',
22
+ 'error',
23
+ 'warning',
24
+ 'muted',
25
+ 'dim',
26
+ 'text',
27
+ 'thinkingText',
28
+ 'scrollbarTrack',
29
+ 'scrollbarThumb',
30
+ 'searchMatchText',
31
+ 'userMessageText',
32
+ 'customMessageText',
33
+ 'customMessageLabel',
34
+ 'toolTitle',
35
+ 'toolOutput',
36
+ 'mdHeading',
37
+ 'mdLink',
38
+ 'mdLinkUrl',
39
+ 'mdCode',
40
+ 'mdCodeBlock',
41
+ 'mdCodeBlockBorder',
42
+ 'mdQuote',
43
+ 'mdQuoteBorder',
44
+ 'mdHr',
45
+ 'mdListBullet',
46
+ 'toolDiffAdded',
47
+ 'toolDiffRemoved',
48
+ 'toolDiffContext',
49
+ 'syntaxComment',
50
+ 'syntaxKeyword',
51
+ 'syntaxFunction',
52
+ 'syntaxVariable',
53
+ 'syntaxString',
54
+ 'syntaxNumber',
55
+ 'syntaxType',
56
+ 'syntaxOperator',
57
+ 'syntaxPunctuation',
58
+ 'thinkingOff',
59
+ 'thinkingMinimal',
60
+ 'thinkingLow',
61
+ 'thinkingMedium',
62
+ 'thinkingHigh',
63
+ 'thinkingXhigh',
64
+ 'thinkingMax',
65
+ 'bashMode',
66
+ ];
67
+
68
+ const LABEL_STYLES: readonly LabelStyle[] = ['none', 'glyph', 'word'];
69
+
70
+ /** The separator used when the config does not name one: the visible beat between readings. */
71
+ const DEFAULT_DIVIDER = ' · ';
72
+
73
+ /** One module reference in the config file. */
74
+ export interface ConfigEntry {
75
+ id: ModuleId;
76
+ label?: LabelStyle;
77
+ format?: string;
78
+ color?: ThemeColor;
79
+ prefix?: string;
80
+ suffix?: string;
81
+ }
82
+
83
+ /**
84
+ * One footer row. A missing side is empty, not inherited.
85
+ *
86
+ * `divider` overrides the top-level separator for both groups; `leftDivider` and `rightDivider`
87
+ * override it per side, which is how the default layout keeps `·` between readings but a plain space
88
+ * between the directory and its branch, and between the provider, model, and thinking level.
89
+ */
90
+ export interface ConfigRow {
91
+ left: ConfigEntry[];
92
+ right: ConfigEntry[];
93
+ divider?: string;
94
+ leftDivider?: string;
95
+ rightDivider?: string;
96
+ }
97
+
98
+ export interface Config {
99
+ rows: ConfigRow[];
100
+ divider: string;
101
+ }
102
+
103
+ /**
104
+ * The order a fresh install reads, matching pi's native footer.
105
+ *
106
+ * Row one is the working directory and its context; row two is the session's volumes and the model
107
+ * identity. `provider` and `session` stay hidden until they have something to say, so the default
108
+ * line is exactly as short as the native one.
109
+ */
110
+ export function defaultConfig(): Config {
111
+ return {
112
+ divider: DEFAULT_DIVIDER,
113
+ rows: [
114
+ {
115
+ // The directory line reads like a sentence, not a list of readings.
116
+ divider: ' ',
117
+ left: [{ id: 'path' }, { id: 'branch' }, { id: 'session', prefix: '• ' }],
118
+ right: [],
119
+ },
120
+ {
121
+ left: [
122
+ { id: 'input' },
123
+ { id: 'output' },
124
+ { id: 'cache-hit' },
125
+ { id: 'tps' },
126
+ { id: 'cost' },
127
+ { id: 'context' },
128
+ ],
129
+ // Identity is one phrase: `(provider) model • level`.
130
+ rightDivider: ' ',
131
+ right: [{ id: 'provider' }, { id: 'model' }, { id: 'effort', prefix: '• ' }],
132
+ },
133
+ ],
134
+ };
135
+ }
136
+
137
+ export interface ParsedConfig {
138
+ config: Config;
139
+ layout: Layout;
140
+ problems: string[];
141
+ }
142
+
143
+ function isRecord(value: unknown): value is Record<string, unknown> {
144
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
145
+ }
146
+
147
+ /** "model, effort, path, …" — every valid id, for a problem that tells you what to pick instead. */
148
+ const KNOWN_IDS = MODULE_IDS.join(', ');
149
+
150
+ function validLabel(value: unknown): value is LabelStyle {
151
+ return typeof value === 'string' && (LABEL_STYLES as readonly string[]).includes(value);
152
+ }
153
+
154
+ function validColor(value: unknown): value is ThemeColor {
155
+ return typeof value === 'string' && (THEME_COLORS as readonly string[]).includes(value);
156
+ }
157
+
158
+ /** Resolves one validated config entry into the layout's module instance. */
159
+ export function resolveEntry(entry: ConfigEntry): ResolvedEntry {
160
+ const def = MODULES[entry.id];
161
+ return {
162
+ def,
163
+ label: entry.label ?? def.defaultLabel,
164
+ format: entry.format ?? def.defaultFormat,
165
+ color: entry.color ?? null,
166
+ prefix: entry.prefix ?? '',
167
+ suffix: entry.suffix ?? '',
168
+ };
169
+ }
170
+
171
+ /**
172
+ * Parses the config file.
173
+ *
174
+ * @param text - The file's contents, or null when it does not exist.
175
+ * @returns The effective config, its resolved layout, and every problem found while reading it.
176
+ */
177
+ export function parseConfig(text: string | null): ParsedConfig {
178
+ if (text === null) {
179
+ const config = defaultConfig();
180
+ return { config, layout: toLayout(config), problems: [] };
181
+ }
182
+
183
+ let raw: unknown;
184
+ try {
185
+ raw = JSON.parse(text);
186
+ } catch (error) {
187
+ const config = defaultConfig();
188
+ const detail = error instanceof Error ? error.message : String(error);
189
+ return {
190
+ config,
191
+ layout: toLayout(config),
192
+ problems: [`config.json is not valid JSON (${detail}); using the built-in defaults`],
193
+ };
194
+ }
195
+
196
+ if (!isRecord(raw)) {
197
+ const config = defaultConfig();
198
+ return {
199
+ config,
200
+ layout: toLayout(config),
201
+ problems: ['config.json must be a JSON object with a "rows" array; using the built-in defaults'],
202
+ };
203
+ }
204
+
205
+ const problems: string[] = [];
206
+ const config: Config = {
207
+ divider: parseDivider(raw.divider, 'divider', problems) ?? DEFAULT_DIVIDER,
208
+ rows: parseRowsSetting(raw, problems),
209
+ };
210
+ return { config, layout: toLayout(config), problems };
211
+ }
212
+
213
+ /** The literal separator between modules; newlines would break the one-line-per-row contract. */
214
+ function parseDivider(value: unknown, where: string, problems: string[]): string | null {
215
+ if (value === undefined) return null;
216
+ if (typeof value !== 'string') {
217
+ problems.push(`${where} must be a string; using the default separator`);
218
+ return null;
219
+ }
220
+ if (/[\r\n]/.test(value)) {
221
+ problems.push(`${where} must not contain a newline; using the default separator`);
222
+ return null;
223
+ }
224
+ return value;
225
+ }
226
+
227
+ /**
228
+ * Reads `rows`, or the older top-level `left` / `right` pair as a single row.
229
+ *
230
+ * The legacy pair keeps a just-written config working after the multi-row change: it is read as the
231
+ * metrics row, with the old defaults for whichever side it omitted.
232
+ */
233
+ function parseRowsSetting(raw: Record<string, unknown>, problems: string[]): ConfigRow[] {
234
+ if (raw.rows !== undefined) return parseRows(raw.rows, problems);
235
+
236
+ if (raw.left === undefined && raw.right === undefined) return defaultConfig().rows;
237
+
238
+ const legacy = defaultConfig().rows[1] ?? { left: [], right: [] };
239
+ return [
240
+ {
241
+ left: parseSide(raw.left, 'left', problems, legacy.left),
242
+ right: parseSide(raw.right, 'right', problems, legacy.right),
243
+ },
244
+ ];
245
+ }
246
+
247
+ function parseRows(value: unknown, problems: string[]): ConfigRow[] {
248
+ if (!Array.isArray(value)) {
249
+ problems.push('rows must be an array of { "left": [...], "right": [...] } objects; using the built-in rows');
250
+ return defaultConfig().rows;
251
+ }
252
+ const rows: ConfigRow[] = [];
253
+ value.forEach((item, index) => {
254
+ if (!isRecord(item)) {
255
+ problems.push(`rows[${index}]: expected an object with "left" and/or "right"; skipped`);
256
+ return;
257
+ }
258
+ const row: ConfigRow = {
259
+ left: parseSide(item.left, `rows[${index}].left`, problems, []),
260
+ right: parseSide(item.right, `rows[${index}].right`, problems, []),
261
+ };
262
+ for (const key of ['divider', 'leftDivider', 'rightDivider'] as const) {
263
+ const divider = parseDivider(item[key], `rows[${index}].${key}`, problems);
264
+ if (divider !== null) row[key] = divider;
265
+ }
266
+ rows.push(row);
267
+ });
268
+ return rows;
269
+ }
270
+
271
+ /** Accepts `[{ id: "input" }]`, the shorthand `["input"]`, or a missing/!array value (fallback). */
272
+ function parseSide(value: unknown, where: string, problems: string[], fallback: ConfigEntry[]): ConfigEntry[] {
273
+ if (value === undefined) return fallback;
274
+ if (!Array.isArray(value)) {
275
+ problems.push(`${where} must be an array of modules; using its default side`);
276
+ return fallback;
277
+ }
278
+ const entries: ConfigEntry[] = [];
279
+ value.forEach((item, index) => {
280
+ const entry = parseEntry(item, `${where}[${index}]`, problems);
281
+ if (entry !== null) entries.push(entry);
282
+ });
283
+ return entries;
284
+ }
285
+
286
+ function parseEntry(item: unknown, where: string, problems: string[]): ConfigEntry | null {
287
+ // Shorthand: a bare module id.
288
+ const raw = typeof item === 'string' ? { id: item } : item;
289
+ if (!isRecord(raw)) {
290
+ problems.push(`${where}: expected an object like { "id": "input" }; skipped`);
291
+ return null;
292
+ }
293
+
294
+ const id = raw.id;
295
+ if (!isModuleId(id)) {
296
+ problems.push(`${where}: unknown module ${JSON.stringify(id ?? null)}; skipped (known: ${KNOWN_IDS})`);
297
+ return null;
298
+ }
299
+
300
+ const def = MODULES[id];
301
+ const entry: ConfigEntry = { id };
302
+
303
+ if (raw.label !== undefined) {
304
+ if (validLabel(raw.label)) entry.label = raw.label;
305
+ else problems.push(`${where}: label must be one of ${LABEL_STYLES.join(' | ')}; using ${def.defaultLabel}`);
306
+ }
307
+
308
+ if (raw.format !== undefined) {
309
+ if (typeof raw.format === 'string' && def.formats.includes(raw.format)) entry.format = raw.format;
310
+ else {
311
+ problems.push(
312
+ `${where}: ${id} has no format ${JSON.stringify(raw.format)}; using ${def.defaultFormat}` +
313
+ (def.formats.length > 0 ? ` (known: ${def.formats.join(' | ')})` : ''),
314
+ );
315
+ }
316
+ }
317
+
318
+ if (raw.color !== undefined) {
319
+ if (validColor(raw.color)) entry.color = raw.color;
320
+ else problems.push(`${where}: unknown theme color ${JSON.stringify(raw.color)}; using the module's own colour`);
321
+ }
322
+
323
+ for (const key of ['prefix', 'suffix'] as const) {
324
+ const text = raw[key];
325
+ if (text === undefined) continue;
326
+ if (typeof text === 'string') entry[key] = text;
327
+ else problems.push(`${where}: ${key} must be a string; ignored`);
328
+ }
329
+
330
+ return entry;
331
+ }
332
+
333
+ /** Config entries → layout instances, preserving row order and resolving each side's separator. */
334
+ export function toLayout(config: Config): Layout {
335
+ return {
336
+ rows: config.rows.map((row) => {
337
+ const divider = row.divider ?? config.divider;
338
+ return {
339
+ left: row.left.map(resolveEntry),
340
+ right: row.right.map(resolveEntry),
341
+ divider: row.leftDivider ?? divider,
342
+ rightDivider: row.rightDivider ?? divider,
343
+ };
344
+ }),
345
+ };
346
+ }
347
+
348
+ /**
349
+ * The `/statusline` report: the effective rows, every resolved field with its source, and the
350
+ * problems the reader found. Plain text; the command layer adds the title.
351
+ */
352
+ export function reportConfig(parsed: ParsedConfig, source: string): string[] {
353
+ const lines: string[] = [`config: ${source}`, `divider: ${JSON.stringify(parsed.config.divider)}`];
354
+
355
+ parsed.config.rows.forEach((row, index) => {
356
+ const divider = row.divider ?? parsed.config.divider;
357
+ const leftDivider = row.leftDivider ?? divider;
358
+ const rightDivider = row.rightDivider ?? divider;
359
+ const dividers =
360
+ leftDivider === rightDivider
361
+ ? JSON.stringify(leftDivider)
362
+ : `${JSON.stringify(leftDivider)} (right: ${JSON.stringify(rightDivider)})`;
363
+ lines.push('', `row[${index}]:`, ` divider: ${dividers}`);
364
+ for (const side of ['left', 'right'] as const) {
365
+ lines.push(` ${side}:`);
366
+ if (row[side].length === 0) lines.push(' (empty)');
367
+ else {
368
+ for (const entry of row[side]) {
369
+ const def = MODULES[entry.id];
370
+ const fields = [
371
+ `label=${entry.label ?? def.defaultLabel}${entry.label === undefined ? ' (default)' : ''}`,
372
+ `format=${entry.format ?? def.defaultFormat}${entry.format === undefined ? ' (default)' : ''}`,
373
+ `color=${entry.color ?? `${def.colorNote ?? def.defaultColor} (module)`}`,
374
+ `priority=${def.priority}${def.floor ? ' floor' : ''}`,
375
+ ];
376
+ if (entry.prefix !== undefined) fields.push(`prefix=${JSON.stringify(entry.prefix)}`);
377
+ if (entry.suffix !== undefined) fields.push(`suffix=${JSON.stringify(entry.suffix)}`);
378
+ lines.push(` ${entry.id.padEnd(10)} ${fields.join(' ')}`);
379
+ }
380
+ }
381
+ }
382
+ });
383
+
384
+ lines.push('', 'problems:');
385
+ if (parsed.problems.length === 0) lines.push(' none — config is valid');
386
+ else for (const problem of parsed.problems) lines.push(` - ${problem}`);
387
+
388
+ return lines;
389
+ }