pi-editor-footer 0.6.2 → 0.8.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 CHANGED
@@ -4,6 +4,34 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.8.0] - 2026-08-26
8
+
9
+ ### Added
10
+
11
+ - ~ prefix for live estimate — `TurnTelemetry.estimated` and `AgentRunLedger` propagation, `↑ ~1k · ↓ ~2k` and `~42.1 tok/s` while streaming vs authoritative after `turn_end` (#29)
12
+
13
+ ### Changed
14
+
15
+ - TPS whole-turn stable rate — absorb `pi-core-tps-stats` one-rate `output / turnDuration` (41/14/55 vs window 263/801/89), live and final share denominator, `CONTENT_START_EVENTS` for TTFT, reset on `model_select` (#29)
16
+ - Collapse Agent-run timeline into one seam — `TranscriptTimeline` now owns history, formatting (`buildTimelineText`), injection and rebuild replay; `SessionOrchestrator` wallTimeHistory deleted, seam turns hypothetical → real with two adapters (prod `chatContainer` + in-memory fake) (#23)
17
+ - Prune LiveBorder fallback — `AgentRunLedger` required, 80-line manual delta deleted, single `ChromeComposition` cached per `render()` (#24)
18
+
19
+ ### Fixed
20
+
21
+ - Delete dead `SessionKernel` (255 lines, 0 adapters) and ghost `liveTickTimer` wrappers — `SessionOrchestrator` calls `LiveBorder.startTick/stopTick` directly (#27)
22
+ - pi-lens `SAFETY` comments for `as unknown as` casts (#29)
23
+
24
+ ### Refactored
25
+
26
+ - Table-driven config validation — `CONFIG_SCHEMA` single source for defaults + validation; removes 10 `as unknown as` casts, `theme-settings` trusts typed boundaries (#25)
27
+ - Retire TrackingEditor compat wrappers — `setChrome`/`getChrome` is the single interface; 7 wrappers + 2 glow accessors deleted, codemod `LiveBorder`/`SessionOrchestrator` (#26)
28
+
29
+ ## [0.7.0] - 2026-08-25
30
+
31
+ ### Changed
32
+
33
+ - Deepen architecture — 5 candidates behind single seams: AgentRunLedger owns per-agent capping and max-vs-sum (C2), ChromeComposition centralizes glyph/theme (C3), TurnTelemetryTracker turn-scoped with ledger delegation (C4), DetailChrome owns Highlight→Detail window (C5), SessionOrchestrator owns lifecycle, footer deduplication and detail wiring — `src/index.ts` 899→93 lines, 139 tests (+52) (#20)
34
+
7
35
  ## [0.6.2] - 2026-08-25
8
36
 
9
37
  ### Fixed
@@ -0,0 +1,274 @@
1
+ /**
2
+ * AgentRunLedger — deep module owning per-Agent-run accounting behind one seam.
3
+ *
4
+ * Domain: Agent run = agent_start → agent_settled, containing one or more Turns
5
+ * (CONTEXT.md). Per-agent totals must use max(input) not sum(input) — each Turn's
6
+ * input is the full prompt (includes history), so summing double-counts overlapping
7
+ * history (e.g. 50k+60k=110k > window 60k). Output/cost do sum. Live input is window
8
+ * occupancy, capped to contextWindow and session totals.
9
+ *
10
+ * Previously this logic was scattered across 3 call sites (telemetry.peekAgentLive
11
+ * max-vs-sum, live-border.refreshContextBar 70L capping, index.agent_settled 60L
12
+ * baseline fallback) with divergent caps. Bugs required holding 3 sites in one head.
13
+ *
14
+ * Depth: small interface (setBaseline / startRun / recordTurn / getTotals / getLive
15
+ * / cap helpers) hides baseline delta, max-vs-sum, predictive vs authoritative capping,
16
+ * and tps derivation. Two adapters (LiveBorder token bar, index timeline) justify the seam.
17
+ * Internal seams (aggregateTurns, capIdle, capLive) stay private.
18
+ */
19
+ function round(value, decimals) {
20
+ const factor = 10 ** decimals;
21
+ return Math.round(value * factor) / factor;
22
+ }
23
+ /**
24
+ * Pure aggregation: max(input) + sum(output/cost/stalls) across turns, plus optional live turn.
25
+ * Shared by TurnTelemetryTracker.peekAgentLive / endAgent and ledger — single source for
26
+ * the "max not sum" invariant.
27
+ */
28
+ export function aggregateAgentTurns(turns, live, startMs, now) {
29
+ const hasCompleted = turns.length > 0;
30
+ if (startMs === null) {
31
+ // No agent active — caller should handle fallback to lastTelemetry/live
32
+ if (!hasCompleted && !live)
33
+ return null;
34
+ // still produce cumulative if we have turns/live but no startMs (defensive)
35
+ }
36
+ else if (!hasCompleted && !live) {
37
+ return null;
38
+ }
39
+ let inputTokens = 0;
40
+ let outputTokens = 0;
41
+ let totalTokens = 0;
42
+ let costUsd = 0;
43
+ let stallMs = 0;
44
+ let stallCount = 0;
45
+ let generationMs = 0;
46
+ let ttftMs = 0;
47
+ for (const t of turns) {
48
+ inputTokens = Math.max(inputTokens, t.inputTokens);
49
+ outputTokens += t.outputTokens;
50
+ costUsd += t.costUsd;
51
+ stallMs += t.stallMs;
52
+ stallCount += t.stallCount;
53
+ generationMs += t.generationMs;
54
+ }
55
+ if (turns.length > 0)
56
+ ttftMs = turns[0].ttftMs;
57
+ if (live) {
58
+ inputTokens = Math.max(inputTokens, live.inputTokens);
59
+ outputTokens += live.outputTokens;
60
+ costUsd += live.costUsd;
61
+ stallMs += live.stallMs;
62
+ stallCount += live.stallCount;
63
+ generationMs += live.generationMs;
64
+ if (ttftMs === 0)
65
+ ttftMs = live.ttftMs;
66
+ }
67
+ totalTokens = inputTokens + outputTokens;
68
+ const totalMs = startMs !== null ? Math.max(0, now - startMs) : 0;
69
+ const measurementMs = outputTokens > 0 && generationMs > 0 ? generationMs : null;
70
+ const tps = measurementMs === null
71
+ ? null
72
+ : round(outputTokens / (measurementMs / 1000), 1);
73
+ const validCost = Number.isFinite(costUsd) && costUsd > 0;
74
+ const validTokens = Number.isFinite(totalTokens) && totalTokens > 0;
75
+ return {
76
+ tps,
77
+ ttftMs,
78
+ totalMs,
79
+ inputTokens,
80
+ outputTokens,
81
+ stallMs,
82
+ stallCount,
83
+ rateUsdPerMTokens: validCost && validTokens
84
+ ? round(costUsd / (totalTokens / 1_000_000), 2)
85
+ : null,
86
+ generationMs,
87
+ totalTokens,
88
+ costUsd: validCost ? costUsd : 0,
89
+ measurementMs,
90
+ estimated: live?.estimated === true,
91
+ };
92
+ }
93
+ /**
94
+ * Capping helpers — single source for "live input 18k not 279k" invariant.
95
+ * Idle (settled) input is authoritative, capped to both context window and session total.
96
+ * Live (running) input is predictive, capped only to context window (not totals) — totals
97
+ * lag behind liveTurn, capping to totals would make live stale (50k) during second turn
98
+ * streaming instead of showing current window 60k.
99
+ */
100
+ export function capInputForIdle(input, contextTokens, sessionTotalInput) {
101
+ let capped = input;
102
+ if (typeof contextTokens === "number" &&
103
+ Number.isFinite(contextTokens) &&
104
+ contextTokens > 0) {
105
+ capped = Math.min(capped, contextTokens);
106
+ }
107
+ if (sessionTotalInput > 0) {
108
+ capped = Math.min(capped, sessionTotalInput);
109
+ }
110
+ return Math.max(0, capped);
111
+ }
112
+ export function capInputForLive(input, contextTokens) {
113
+ let capped = input;
114
+ if (typeof contextTokens === "number" &&
115
+ Number.isFinite(contextTokens) &&
116
+ contextTokens > 0) {
117
+ capped = Math.min(capped, contextTokens);
118
+ }
119
+ return Math.max(0, capped);
120
+ }
121
+ /** Delta from baseline totals (session delta), used when telemetry not available. */
122
+ export function deltaFromBaseline(cur, base) {
123
+ return {
124
+ input: Math.max(0, cur.input - base.input),
125
+ output: Math.max(0, cur.output - base.output),
126
+ cost: Math.max(0, cur.cost - base.cost),
127
+ };
128
+ }
129
+ export class AgentRunLedger {
130
+ baseline = null;
131
+ turns = [];
132
+ startMs = null;
133
+ now;
134
+ constructor(now = () => Date.now()) {
135
+ this.now = now;
136
+ }
137
+ /** Capture baseline at agent_start. Clone to avoid caller mutation. */
138
+ setBaseline(baseline) {
139
+ this.baseline = baseline ? { ...baseline } : null;
140
+ }
141
+ getBaseline() {
142
+ return this.baseline ? { ...this.baseline } : null;
143
+ }
144
+ startRun(startMs) {
145
+ this.startMs = typeof startMs === "number" ? startMs : this.now();
146
+ this.turns = [];
147
+ }
148
+ /** Record a completed turn's telemetry (called by telemetryTracker or directly). */
149
+ recordTurn(turn) {
150
+ this.turns.push({ ...turn });
151
+ }
152
+ /** Settled totals without live turn — used at agent_settled for timeline. */
153
+ getSettledTotals(now) {
154
+ const n = typeof now === "number" ? now : this.now();
155
+ return aggregateAgentTurns(this.turns, null, this.startMs, n);
156
+ }
157
+ /** Live totals including optional running turn. */
158
+ getLiveTotals(liveTurn, now) {
159
+ const n = typeof now === "number" ? now : this.now();
160
+ const result = aggregateAgentTurns(this.turns, liveTurn, this.startMs, n);
161
+ // If no agent active (startMs null) but we have turns/live, aggregateTurns handles it;
162
+ // otherwise fallback to null.
163
+ return result;
164
+ }
165
+ /**
166
+ * Timeline/display totals at agent_settled.
167
+ * Prefers authoritative telemetry (tel) when available; otherwise falls back to
168
+ * baseline delta; otherwise session totals. Caps idle input to context + session total.
169
+ * This is the single place that knows the "prefer tel, else delta capped" policy.
170
+ */
171
+ getPerAgentTotalsForTimeline(tel, snapshotTotals, contextTokens) {
172
+ if (tel) {
173
+ const cappedInput = capInputForIdle(tel.inputTokens, contextTokens, snapshotTotals.input);
174
+ return {
175
+ input: cappedInput,
176
+ output: tel.outputTokens,
177
+ cost: tel.costUsd,
178
+ };
179
+ }
180
+ if (this.baseline) {
181
+ const d = deltaFromBaseline(snapshotTotals, this.baseline);
182
+ const cappedInput = capInputForIdle(d.input, contextTokens, snapshotTotals.input);
183
+ return { input: cappedInput, output: d.output, cost: d.cost };
184
+ }
185
+ // No baseline — best effort session totals capped for input
186
+ const cappedInput = capInputForIdle(snapshotTotals.input, contextTokens, snapshotTotals.input);
187
+ return {
188
+ input: cappedInput,
189
+ output: snapshotTotals.output,
190
+ cost: snapshotTotals.cost,
191
+ };
192
+ }
193
+ /**
194
+ * Idle display totals for LiveBorder when not running.
195
+ * Prefers liveAgent/liveTelemetry when available; else baseline delta, capped to idle.
196
+ */
197
+ getIdleDisplayTotals(snapshotTotals, contextTokens, liveAgent) {
198
+ let displayInput;
199
+ let displayOutput;
200
+ let displayCost;
201
+ if (liveAgent) {
202
+ displayInput = liveAgent.inputTokens;
203
+ displayOutput = liveAgent.outputTokens;
204
+ displayCost = liveAgent.costUsd;
205
+ }
206
+ else if (this.baseline) {
207
+ const d = deltaFromBaseline(snapshotTotals, this.baseline);
208
+ displayInput = d.input;
209
+ displayOutput = d.output;
210
+ displayCost = d.cost;
211
+ }
212
+ else {
213
+ displayInput = snapshotTotals.input;
214
+ displayOutput = snapshotTotals.output;
215
+ displayCost = snapshotTotals.cost;
216
+ }
217
+ const cappedInput = capInputForIdle(displayInput, contextTokens, snapshotTotals.input);
218
+ return {
219
+ tps: null,
220
+ ttftMs: 0,
221
+ totalMs: 0,
222
+ inputTokens: cappedInput,
223
+ outputTokens: displayOutput,
224
+ stallMs: 0,
225
+ stallCount: 0,
226
+ rateUsdPerMTokens: null,
227
+ generationMs: 0,
228
+ totalTokens: cappedInput + displayOutput,
229
+ costUsd: displayCost,
230
+ measurementMs: null,
231
+ estimated: false,
232
+ };
233
+ }
234
+ /**
235
+ * Live display totals when running: liveTurn input (current window) replaces
236
+ * agentLive input, output/cost remain per-agent sum, capped to live context only.
237
+ */
238
+ getLiveDisplayTotals(liveTurn, agentLive, contextTokens) {
239
+ if (!agentLive && !liveTurn)
240
+ return null;
241
+ let displayLive = agentLive;
242
+ if (agentLive && liveTurn) {
243
+ displayLive = {
244
+ ...agentLive,
245
+ inputTokens: liveTurn.inputTokens,
246
+ totalTokens: liveTurn.inputTokens + agentLive.outputTokens,
247
+ };
248
+ }
249
+ else if (!agentLive && liveTurn) {
250
+ // Edge: no agent yet but liveTurn exists — use liveTurn window
251
+ displayLive = liveTurn;
252
+ }
253
+ if (!displayLive)
254
+ return null;
255
+ const cappedInput = capInputForLive(displayLive.inputTokens, contextTokens);
256
+ return {
257
+ ...displayLive,
258
+ inputTokens: cappedInput,
259
+ totalTokens: cappedInput + displayLive.outputTokens,
260
+ estimated: true,
261
+ };
262
+ }
263
+ reset() {
264
+ this.baseline = null;
265
+ this.turns = [];
266
+ this.startMs = null;
267
+ }
268
+ isActive() {
269
+ return this.startMs !== null;
270
+ }
271
+ getTurnCount() {
272
+ return this.turns.length;
273
+ }
274
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * ChromeComposition — deep module owning chrome glyph/theme derivation behind one seam.
3
+ *
4
+ * Problem it solves (C3): glyphs (resolveGlyphs / resolveIconMode) and the live TUI theme
5
+ * (with its `fg` + optional `getThinkingBorderColor`) were re-derived at every chrome island:
6
+ * live-border called resolveGlyphs in 3 methods, footer once, and the theme was reached
7
+ * through ~8 `as unknown as { ... }` SAFETY casts. A changed glyph or a pi theme shape change
8
+ * thus required touching 3-4 modules with no locality.
9
+ *
10
+ * Depth: one small interface (glyphs + isAscii + fg/dim/glow + format* helpers) hides
11
+ * icon-mode resolution, the SAFETY theme cast, colour application, and the chrome format
12
+ * entry points (context bar, telemetry, tokens, run activity, stall). Callers learn one
13
+ * shape; LiveBorder's islands become thin lookups.
14
+ *
15
+ * Internal seams (resolveGlyphs, resolveIconMode, formatTurnTelemetry, formatTelemetryTokens,
16
+ * formatRunActivityTopRight, formatTopContextFromSnapshot) stay inside this module, not part
17
+ * of its external seam. Two adapters (LiveBorder live chrome, footer/border elsewhere)
18
+ * justify the seam.
19
+ */
20
+ import { resolveGlyphs, resolveIconMode } from "./icons.js";
21
+ import { formatTopContextFromSnapshot } from "./chrome-state.js";
22
+ import { formatRunActivityTopRight } from "./run-activity.js";
23
+ import { formatTelemetryTokens, formatTurnDuration, formatTurnTelemetry, } from "./telemetry.js";
24
+ /**
25
+ * Read the live pi theme into a typed { fg } surface. The cast is a SAFETY seam — a pi theme
26
+ * missing `fg` degrades to identity rather than throwing, keeping the chrome resilient.
27
+ */
28
+ export function adaptTheme(rawTheme) {
29
+ const t = rawTheme; // SAFETY: pi theme seam — fg is optional, guarded below
30
+ return {
31
+ fg: (style, s) => (typeof t.fg === "function" ? t.fg(style, s) : s),
32
+ };
33
+ }
34
+ /** Optional thinking-border glow (pi theme extension). Falls back to identity. */
35
+ export function resolveGlow(rawTheme) {
36
+ const t = rawTheme;
37
+ if (typeof t.getThinkingBorderColor !== "function")
38
+ return undefined;
39
+ return (level, s) => {
40
+ try {
41
+ return t.getThinkingBorderColor(level)(s);
42
+ }
43
+ catch {
44
+ // SAFETY: best-effort UI, ignore recoverable error
45
+ return s;
46
+ }
47
+ };
48
+ }
49
+ export class ChromeComposition {
50
+ glyphs;
51
+ isAscii;
52
+ theme;
53
+ glow;
54
+ constructor(iconMode, rawTheme, opts = {}) {
55
+ this.glyphs = resolveGlyphs(iconMode);
56
+ this.isAscii = resolveIconMode(iconMode) === "ascii";
57
+ this.theme = adaptTheme(rawTheme);
58
+ this.glow = opts.glow ?? resolveGlow(rawTheme);
59
+ }
60
+ /** Colour a string with a theme style (derived once, cast cached). */
61
+ fg(style, s) {
62
+ return this.theme.fg(style, s);
63
+ }
64
+ dim(s) {
65
+ return this.fg("dim", s);
66
+ }
67
+ /** Apply the thinking-border glow at the given level (identity when unavailable). */
68
+ applyGlow(level, s) {
69
+ return this.glow ? this.glow(level, s) : s;
70
+ }
71
+ /** Top context bar from a chrome snapshot. */
72
+ formatTopContext(snapshot, showIconBar) {
73
+ return formatTopContextFromSnapshot(snapshot, this.theme, this.glyphs, this.isAscii, showIconBar);
74
+ }
75
+ /** Bottom telemetry string (TPS/TTFT/stalls) for a turn. */
76
+ formatTurnTelemetry(tel, cfg, glyphs) {
77
+ return formatTurnTelemetry(tel, this.theme, cfg, glyphs ?? this.glyphs);
78
+ }
79
+ /** Tokens `↑ n · ↓ m` line for a turn. */
80
+ formatTelemetryTokens(tel, cfg, glyphs) {
81
+ return formatTelemetryTokens(tel, this.theme, cfg, glyphs ?? this.glyphs);
82
+ }
83
+ /** Run-activity top-right (turns · duration · tools · failed). */
84
+ formatRunActivityTopRight(snap) {
85
+ return formatRunActivityTopRight(snap, this.theme);
86
+ }
87
+ /** Stall badge `!N×dur` for the top-right stall segment. */
88
+ formatStall(tel) {
89
+ return this.fg("warning", `${this.glyphs.stall}${tel.stallCount}×${formatTurnDuration(tel.stallMs).trim()}`);
90
+ }
91
+ }
package/dist/config.js CHANGED
@@ -70,60 +70,74 @@ function deepMerge(base, override) {
70
70
  }
71
71
  return result;
72
72
  }
73
- function isBoolean(v) {
74
- return typeof v === "boolean";
73
+ /** Single schema table — adding a config leaf is one row. */
74
+ export const CONFIG_SCHEMA = [
75
+ { path: "enabled", kind: "boolean" },
76
+ { path: "workspaceDisplay", kind: "enum", values: ["path", "name"] },
77
+ { path: "cursorStyle", kind: "enum", values: ["block", "bar", "underline"] },
78
+ { path: "icons.mode", kind: "enum", values: ["auto", "nerd", "ascii"] },
79
+ { path: "contextIconBar", kind: "boolean" },
80
+ { path: "footerSegments.cwd", kind: "boolean" },
81
+ { path: "footerSegments.sessionName", kind: "boolean" },
82
+ { path: "footerSegments.gitBranch", kind: "boolean" },
83
+ { path: "footerSegments.gitStatus", kind: "boolean" },
84
+ { path: "footerSegments.gitCommit", kind: "boolean" },
85
+ { path: "footerSegments.runtime", kind: "boolean" },
86
+ { path: "footerSegments.context", kind: "boolean" },
87
+ { path: "footerSegments.tokens", kind: "boolean" },
88
+ { path: "footerSegments.cost", kind: "boolean" },
89
+ { path: "footerSegments.extensionStatuses", kind: "boolean" },
90
+ { path: "telemetry.enabled", kind: "boolean" },
91
+ { path: "telemetry.tps", kind: "boolean" },
92
+ { path: "telemetry.ttft", kind: "boolean" },
93
+ { path: "telemetry.duration", kind: "boolean" },
94
+ { path: "telemetry.tokens", kind: "boolean" },
95
+ { path: "telemetry.stalls", kind: "boolean" },
96
+ { path: "telemetry.cost", kind: "boolean" },
97
+ { path: "timeline.enabled", kind: "boolean" },
98
+ { path: "timeline.wallTime", kind: "boolean" },
99
+ { path: "timeline.tokens", kind: "boolean" },
100
+ { path: "timeline.cost", kind: "boolean" },
101
+ ];
102
+ function getByPath(obj, path) {
103
+ const parts = path.split(".");
104
+ let cur = obj;
105
+ for (const p of parts) {
106
+ if (cur === null || typeof cur !== "object")
107
+ return undefined;
108
+ // single controlled cast — validation table owns all path access
109
+ cur = cur[p];
110
+ }
111
+ return cur;
75
112
  }
76
- function validate(config) {
77
- if (config.workspaceDisplay !== "path" &&
78
- config.workspaceDisplay !== "name") {
79
- config.workspaceDisplay = DEFAULT_CONFIG.workspaceDisplay;
80
- }
81
- if (config.cursorStyle !== "block" &&
82
- config.cursorStyle !== "bar" &&
83
- config.cursorStyle !== "underline") {
84
- config.cursorStyle = DEFAULT_CONFIG.cursorStyle;
85
- }
86
- if (config.icons.mode !== "auto" &&
87
- config.icons.mode !== "nerd" &&
88
- config.icons.mode !== "ascii") {
89
- config.icons.mode = DEFAULT_CONFIG.icons.mode;
90
- }
91
- if (!isBoolean(config.enabled)) {
92
- config.enabled = DEFAULT_CONFIG.enabled;
93
- }
94
- // SAFETY: intentional unsafe cast — validated at runtime
95
- if (!isBoolean(config.contextIconBar)) {
96
- // SAFETY: intentional unsafe cast — validated at runtime
97
- config.contextIconBar = DEFAULT_CONFIG.contextIconBar;
98
- }
99
- // SAFETY: intentional unsafe cast — validated at runtime
100
- const fs = config.footerSegments;
101
- // SAFETY: intentional unsafe cast — validated at runtime
102
- const dfs = DEFAULT_CONFIG.footerSegments;
103
- for (const k of Object.keys(dfs)) {
104
- if (!isBoolean(fs[k]))
105
- fs[k] = dfs[k];
106
- }
107
- // SAFETY: intentional unsafe cast — validated at runtime
108
- const t = config.telemetry;
109
- // SAFETY: intentional unsafe cast — validated at runtime
110
- const dt = DEFAULT_CONFIG.telemetry;
111
- for (const k of Object.keys(dt)) {
112
- if (!isBoolean(t[k]))
113
- t[k] = dt[k];
114
- }
115
- // SAFETY: intentional unsafe cast — validated at runtime
116
- const tl = config.timeline;
117
- // SAFETY: intentional unsafe cast — validated at runtime
118
- const dtl = DEFAULT_CONFIG.timeline;
119
- if (!tl || typeof tl !== "object") {
120
- // SAFETY: intentional unsafe cast — validated at runtime
121
- config.timeline = structuredClone(DEFAULT_CONFIG.timeline);
113
+ function setByPath(obj, path, value) {
114
+ const parts = path.split(".");
115
+ let cur = obj;
116
+ for (let i = 0; i < parts.length - 1; i++) {
117
+ const p = parts[i];
118
+ const next = cur[p];
119
+ if (typeof next !== "object" || next === null || Array.isArray(next)) {
120
+ cur[p] = {};
121
+ }
122
+ cur = cur[p];
122
123
  }
123
- else {
124
- for (const k of Object.keys(dtl)) {
125
- if (!isBoolean(tl[k]))
126
- tl[k] = dtl[k];
124
+ cur[parts[parts.length - 1]] = value;
125
+ }
126
+ function getDefaultByPath(path) {
127
+ return getByPath(DEFAULT_CONFIG, path);
128
+ }
129
+ function validate(config) {
130
+ for (const desc of CONFIG_SCHEMA) {
131
+ const cur = getByPath(config, desc.path);
132
+ if (desc.kind === "boolean") {
133
+ if (typeof cur !== "boolean") {
134
+ setByPath(config, desc.path, getDefaultByPath(desc.path));
135
+ }
136
+ }
137
+ else if (desc.kind === "enum") {
138
+ if (!desc.values.includes(cur)) {
139
+ setByPath(config, desc.path, getDefaultByPath(desc.path));
140
+ }
127
141
  }
128
142
  }
129
143
  return config;
@@ -0,0 +1,75 @@
1
+ import { contentLineCount, renderDetail, scroll, } from "./detail-render.js";
2
+ import { decorateWindow } from "./window-presentation.js";
3
+ /** Height cap of the detail window (the user's spec: up to 5 lines). */
4
+ export const MAX_LINES = 5;
5
+ /** Kind tag for the header — derived from the candidate's command prefix. */
6
+ function kindOf(value) {
7
+ return value.startsWith("skill:") ? "skill" : "command";
8
+ }
9
+ function detailItemOf(item) {
10
+ return {
11
+ label: item.label,
12
+ kind: kindOf(item.value),
13
+ description: item.description ?? "",
14
+ };
15
+ }
16
+ export class DetailChrome {
17
+ item = null;
18
+ scrollOffset = 0;
19
+ lastWidth = 0;
20
+ maxLines;
21
+ constructor(maxLines = MAX_LINES) {
22
+ this.maxLines = Math.max(1, Math.floor(maxLines));
23
+ }
24
+ /** New candidate — restarts the scroll. Null hides the window. */
25
+ setItem(item) {
26
+ this.item = item;
27
+ this.scrollOffset = 0;
28
+ }
29
+ getItem() {
30
+ return this.item;
31
+ }
32
+ /** Current scroll offset (for tests / reading). */
33
+ getScrollOffset() {
34
+ return this.scrollOffset;
35
+ }
36
+ /** Whether the current item has a non-empty description (widget install decision). */
37
+ hasContent() {
38
+ return this.item !== null && (this.item.description ?? "").trim() !== "";
39
+ }
40
+ /**
41
+ * Move the scroll one line, clamped. No-op when the window is hidden or the
42
+ * description fits. Returns the resulting offset.
43
+ */
44
+ scrollBy(delta) {
45
+ const item = this.item;
46
+ if (!item || (item.description ?? "").trim() === "") {
47
+ return this.scrollOffset;
48
+ }
49
+ const width = this.lastWidth > 0 ? this.lastWidth : 80;
50
+ const innerWidth = Math.max(1, width - 4);
51
+ this.scrollOffset = scroll(this.scrollOffset, delta, contentLineCount(detailItemOf(item), innerWidth), this.maxLines);
52
+ return this.scrollOffset;
53
+ }
54
+ /**
55
+ * Rendered bordered window at `width`. Returns `[]` when hidden (window
56
+ * not shown). Reads the LIVE theme at render time so theme swaps apply
57
+ * immediately.
58
+ */
59
+ render(width, theme) {
60
+ this.lastWidth = width;
61
+ const item = this.item;
62
+ if (!item || (item.description ?? "").trim() === "") {
63
+ return [];
64
+ }
65
+ const t = theme;
66
+ const windowTheme = {
67
+ border: (s) => t.fg("border", s),
68
+ highlight: (s) => t.fg("accent", t.bold(s)),
69
+ dim: (s) => t.fg("dim", s),
70
+ };
71
+ const innerWidth = Math.max(1, width - 4);
72
+ const lines = renderDetail(detailItemOf(item), innerWidth, this.maxLines, this.scrollOffset);
73
+ return decorateWindow(lines, width, windowTheme);
74
+ }
75
+ }
@@ -49,6 +49,17 @@ export function renderDetail(item, width, maxLines, scrollOffset) {
49
49
  }
50
50
  return [header, ...visibleLines];
51
51
  }
52
+ /**
53
+ * Number of wrapped content lines for a description at a given width
54
+ * (excluding the header). Single source of wrapping truth for both render
55
+ * and scroll clamping — avoids re-parsing the rendered header marker.
56
+ */
57
+ export function contentLineCount(item, width) {
58
+ if (!item || item.description.trim() === "") {
59
+ return 0;
60
+ }
61
+ return wrapDescription(item.description, Math.max(1, Math.floor(width))).length;
62
+ }
52
63
  /**
53
64
  * Next scroll offset after moving by `delta` (-1 = back/up, +1 = forward/down).
54
65
  *
package/dist/footer.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { truncateToWidth, visibleWidth, wrapTextWithAnsi, } from "@earendil-works/pi-tui";
2
2
  import { getUsageTotals } from "./state.js";
3
- import { resolveGlyphs, runtimeSymbol } from "./icons.js";
3
+ import { runtimeSymbol } from "./icons.js";
4
+ import { ChromeComposition } from "./chrome-composition.js";
4
5
  import { alignRight, fitSegmentsByPriority, } from "./layout.js";
5
6
  import { basenamePath, formatCwd, truncatePath, } from "./path-format.js";
6
7
  import { fmtTokens, formatDuration, sanitizeStatus } from "./format.js";
@@ -76,7 +77,8 @@ function renderTimerSegment(theme, state, glyphs, totals, config) {
76
77
  export function renderFooter(width, state, config, theme, ctx) {
77
78
  if (width <= 0)
78
79
  return [""];
79
- const glyphs = resolveGlyphs(config.icons.mode);
80
+ const comp = new ChromeComposition(config.icons.mode, theme);
81
+ const glyphs = comp.glyphs;
80
82
  const segments = config.footerSegments;
81
83
  const totals = ctx.totals ?? {
82
84
  input: 0,