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 +28 -0
- package/dist/agent-run-ledger.js +274 -0
- package/dist/chrome-composition.js +91 -0
- package/dist/config.js +66 -52
- package/dist/detail-chrome.js +75 -0
- package/dist/detail-render.js +11 -0
- package/dist/footer.js +4 -2
- package/dist/index.js +4 -819
- package/dist/live-border.js +63 -139
- package/dist/session-orchestrator.js +632 -0
- package/dist/telemetry.js +162 -141
- package/dist/theme-settings.js +2 -7
- package/dist/tracking-editor.js +1 -53
- package/dist/transcript-timeline.js +111 -3
- package/package.json +2 -2
- package/src/agent-run-ledger.ts +340 -0
- package/src/chrome-composition.ts +144 -0
- package/src/config.ts +77 -57
- package/src/detail-chrome.ts +123 -0
- package/src/detail-render.ts +15 -0
- package/src/footer.ts +4 -2
- package/src/index.ts +14 -921
- package/src/live-border.ts +77 -185
- package/src/session-orchestrator.ts +831 -0
- package/src/telemetry.ts +178 -148
- package/src/theme-settings.ts +4 -9
- package/src/tracking-editor.ts +1 -66
- package/src/transcript-timeline.ts +127 -3
- package/src/session-kernel.ts +0 -255
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
|
-
|
|
74
|
-
|
|
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
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
+
}
|
package/dist/detail-render.js
CHANGED
|
@@ -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 {
|
|
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
|
|
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,
|