pi-editor-footer 0.8.0 → 0.11.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,32 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.11.0] - 2026-08-29
8
+
9
+ ### Changed
10
+
11
+ - Release workflow trigger clarified — tag `v*` remains the release trigger (semantic-release on tag push), reverted tentative `main` trigger
12
+
13
+ ## [0.10.0] - 2026-08-28
14
+
15
+ ### Added
16
+
17
+ - Detail header shows slash command path dimmed at right of type — workspace-relative/~/ tildified, middle-truncated with ellipsis, hidden when <10 cols remain, auto-resolved for skill items across project and home candidates with fallback to global symlink path
18
+
19
+ ### Fixed
20
+
21
+ - Resolve pre-existing blocking diagnostics (`require-safety-comment`, `no-unknown-returns`, `zizmor cache-poisoning`) to keep lens clean
22
+
23
+ ## [0.9.0] - 2026-08-27
24
+
25
+ ### Added
26
+
27
+ - Incremental per-agent_run live input accounting — `trigger+Σ(outputs+tools)+liveDelta` per run (`~` during live, authoritative `deltaFromBaseline` after), `AgentRunLedger` owns `triggerTokens/accumOutput/accumTool` reset in `startRun`, `getIncrementalLiveDisplayTotals`/`getIdleAuthoritativeDisplay` (grill Q6→a Q7→b Q8→a Q9→a Q11→a)
28
+
29
+ ### Changed
30
+
31
+ - `turn_start` input now synthetic `trigger+Σ` (independent per run) instead of `getContextUsage` window; `tool_result`/`tool_execution_end` fold `chars/4` into accumulation; `LiveBorder` top `↑` hybrid (`~` live, no `~` idle), context bar stays real window (Q10); co-coded with `typescript-expert` (`unknown` boundaries, `SAFETY` on pi seams)
32
+
7
33
  ## [0.8.0] - 2026-08-26
8
34
 
9
35
  ### Added
@@ -15,11 +15,29 @@
15
15
  * / cap helpers) hides baseline delta, max-vs-sum, predictive vs authoritative capping,
16
16
  * and tps derivation. Two adapters (LiveBorder token bar, index timeline) justify the seam.
17
17
  * Internal seams (aggregateTurns, capIdle, capLive) stay private.
18
+ *
19
+ * Incremental live input (per-agent_run):
20
+ * liveInput = triggerTokens + Σ(completed turn outputs) + Σ(toolResult tokens) + liveDelta
21
+ * Each agent_run resets independently. triggerTokens = ceil(triggerChars/4) or 0 if
22
+ * no user message (Q11 a). Tool results are ceil(chars/4) per tool_result (Q8 a).
23
+ * Live input is predictive (estimated=true, "~" prefix) and capped only to contextWindow.
24
+ * Settled/idle input snaps to authoritative deltaFromBaseline (hybrid Q7 b, Q6 a).
18
25
  */
19
26
  function round(value, decimals) {
20
27
  const factor = 10 ** decimals;
21
28
  return Math.round(value * factor) / factor;
22
29
  }
30
+ /** Estimate tokens from char length — ~4 chars/token, same as TurnTelemetryTracker live estimate. */
31
+ export function estimateTokensFromChars(chars) {
32
+ if (!Number.isFinite(chars) || chars <= 0)
33
+ return 0;
34
+ return Math.ceil(chars / 4);
35
+ }
36
+ export function estimateTokensFromText(text) {
37
+ if (typeof text !== "string" || text.length === 0)
38
+ return 0;
39
+ return estimateTokensFromChars(text.length);
40
+ }
23
41
  /**
24
42
  * Pure aggregation: max(input) + sum(output/cost/stalls) across turns, plus optional live turn.
25
43
  * Shared by TurnTelemetryTracker.peekAgentLive / endAgent and ledger — single source for
@@ -65,7 +83,7 @@ export function aggregateAgentTurns(turns, live, startMs, now) {
65
83
  ttftMs = live.ttftMs;
66
84
  }
67
85
  totalTokens = inputTokens + outputTokens;
68
- const totalMs = startMs !== null ? Math.max(0, now - startMs) : 0;
86
+ const totalMs = startMs === null ? 0 : Math.max(0, now - startMs);
69
87
  const measurementMs = outputTokens > 0 && generationMs > 0 ? generationMs : null;
70
88
  const tps = measurementMs === null
71
89
  ? null
@@ -131,6 +149,10 @@ export class AgentRunLedger {
131
149
  turns = [];
132
150
  startMs = null;
133
151
  now;
152
+ // Incremental per-agent_run accumulation (Q1/Q6/Q9)
153
+ triggerTokens = 0;
154
+ accumOutputTokens = 0;
155
+ accumToolTokens = 0;
134
156
  constructor(now = () => Date.now()) {
135
157
  this.now = now;
136
158
  }
@@ -144,10 +166,54 @@ export class AgentRunLedger {
144
166
  startRun(startMs) {
145
167
  this.startMs = typeof startMs === "number" ? startMs : this.now();
146
168
  this.turns = [];
169
+ this.triggerTokens = 0;
170
+ this.accumOutputTokens = 0;
171
+ this.accumToolTokens = 0;
172
+ }
173
+ /** Set trigger message tokens for this agent_run (chars/4, or 0 per Q11 a). */
174
+ setTriggerTokens(tokens) {
175
+ if (typeof tokens !== "number" || !Number.isFinite(tokens) || tokens < 0)
176
+ return;
177
+ this.triggerTokens = Math.round(tokens);
178
+ }
179
+ getTriggerTokens() {
180
+ return this.triggerTokens;
181
+ }
182
+ /** Add tool result tokens (chars/4) to accumulation (Q8 a). */
183
+ addToolResultTokens(tokens) {
184
+ if (typeof tokens !== "number" || !Number.isFinite(tokens) || tokens <= 0)
185
+ return;
186
+ this.accumToolTokens += Math.round(tokens);
187
+ }
188
+ getAccumOutputTokens() {
189
+ return this.accumOutputTokens;
190
+ }
191
+ getAccumToolTokens() {
192
+ return this.accumToolTokens;
193
+ }
194
+ /** Synthetic completed input without live delta: trigger + Σ(outputs) + Σ(tools). */
195
+ getSyntheticCompletedInput() {
196
+ return this.triggerTokens + this.accumOutputTokens + this.accumToolTokens;
197
+ }
198
+ /** Synthetic live input including live delta: trigger + Σ(outputs+tools) + liveDelta. */
199
+ getSyntheticLiveInput(liveDeltaTokens) {
200
+ const d = typeof liveDeltaTokens === "number" && Number.isFinite(liveDeltaTokens)
201
+ ? liveDeltaTokens
202
+ : 0;
203
+ return (this.triggerTokens +
204
+ this.accumOutputTokens +
205
+ this.accumToolTokens +
206
+ Math.max(0, Math.round(d)));
147
207
  }
148
208
  /** Record a completed turn's telemetry (called by telemetryTracker or directly). */
149
209
  recordTurn(turn) {
150
210
  this.turns.push({ ...turn });
211
+ // Accumulate authoritative output for synthetic input (trigger + Σ outputs)
212
+ if (typeof turn.outputTokens === "number" &&
213
+ Number.isFinite(turn.outputTokens) &&
214
+ turn.outputTokens > 0) {
215
+ this.accumOutputTokens += Math.round(turn.outputTokens);
216
+ }
151
217
  }
152
218
  /** Settled totals without live turn — used at agent_settled for timeline. */
153
219
  getSettledTotals(now) {
@@ -231,9 +297,46 @@ export class AgentRunLedger {
231
297
  estimated: false,
232
298
  };
233
299
  }
300
+ /**
301
+ * Idle authoritative display (hybrid Q7 b): snapshot delta capped, for use when
302
+ * not running to show billed total without ~ after agent_end.
303
+ */
304
+ getIdleAuthoritativeDisplay(snapshotTotals, contextTokens) {
305
+ let displayInput;
306
+ let displayOutput;
307
+ let displayCost;
308
+ if (this.baseline) {
309
+ const d = deltaFromBaseline(snapshotTotals, this.baseline);
310
+ displayInput = d.input;
311
+ displayOutput = d.output;
312
+ displayCost = d.cost;
313
+ }
314
+ else {
315
+ displayInput = snapshotTotals.input;
316
+ displayOutput = snapshotTotals.output;
317
+ displayCost = snapshotTotals.cost;
318
+ }
319
+ const cappedInput = capInputForIdle(displayInput, contextTokens, snapshotTotals.input);
320
+ return {
321
+ tps: null,
322
+ ttftMs: 0,
323
+ totalMs: 0,
324
+ inputTokens: cappedInput,
325
+ outputTokens: displayOutput,
326
+ stallMs: 0,
327
+ stallCount: 0,
328
+ rateUsdPerMTokens: null,
329
+ generationMs: 0,
330
+ totalTokens: cappedInput + displayOutput,
331
+ costUsd: displayCost,
332
+ measurementMs: null,
333
+ estimated: false,
334
+ };
335
+ }
234
336
  /**
235
337
  * Live display totals when running: liveTurn input (current window) replaces
236
338
  * agentLive input, output/cost remain per-agent sum, capped to live context only.
339
+ * @deprecated — retained for backward compat; prefer getIncrementalLiveDisplayTotals for Q1/Q9.
237
340
  */
238
341
  getLiveDisplayTotals(liveTurn, agentLive, contextTokens) {
239
342
  if (!agentLive && !liveTurn)
@@ -260,10 +363,61 @@ export class AgentRunLedger {
260
363
  estimated: true,
261
364
  };
262
365
  }
366
+ /**
367
+ * Incremental live display (Q1/Q6/Q9): trigger + Σ(outputs+tools) + liveDelta, capped to live.
368
+ * Replaces max(input) with synthetic accumulation, per-agent_run independent.
369
+ */
370
+ getIncrementalLiveDisplayTotals(liveTurn, agentLive, contextTokens) {
371
+ if (!agentLive && !liveTurn)
372
+ return null;
373
+ const liveDelta = liveTurn?.outputTokens ?? 0;
374
+ const syntheticInput = this.getSyntheticLiveInput(liveDelta);
375
+ const cappedInput = capInputForLive(syntheticInput, contextTokens);
376
+ // Output/cost/stalls come from agentLive (sum) when available, else liveTurn
377
+ const base = agentLive ?? liveTurn;
378
+ if (!base)
379
+ return null;
380
+ // When both exist, agentLive already sums outputs, so use it; else liveTurn
381
+ const displayOutput = agentLive
382
+ ? agentLive.outputTokens
383
+ : liveTurn.outputTokens;
384
+ const displayCost = agentLive ? agentLive.costUsd : liveTurn.costUsd;
385
+ const displayStallMs = agentLive ? agentLive.stallMs : liveTurn.stallMs;
386
+ const displayStallCount = agentLive
387
+ ? agentLive.stallCount
388
+ : liveTurn.stallCount;
389
+ const displayGenerationMs = agentLive
390
+ ? agentLive.generationMs
391
+ : liveTurn.generationMs;
392
+ const displayTtft = agentLive ? agentLive.ttftMs : liveTurn.ttftMs;
393
+ const displayTps = agentLive ? agentLive.tps : liveTurn.tps;
394
+ const displayTotalMs = agentLive ? agentLive.totalMs : liveTurn.totalMs;
395
+ const displayMeasurementMs = agentLive
396
+ ? agentLive.measurementMs
397
+ : liveTurn.measurementMs;
398
+ return {
399
+ tps: displayTps,
400
+ ttftMs: displayTtft,
401
+ totalMs: displayTotalMs,
402
+ inputTokens: cappedInput,
403
+ outputTokens: displayOutput,
404
+ stallMs: displayStallMs,
405
+ stallCount: displayStallCount,
406
+ rateUsdPerMTokens: base.rateUsdPerMTokens,
407
+ generationMs: displayGenerationMs,
408
+ totalTokens: cappedInput + displayOutput,
409
+ costUsd: displayCost,
410
+ measurementMs: displayMeasurementMs,
411
+ estimated: true,
412
+ };
413
+ }
263
414
  reset() {
264
415
  this.baseline = null;
265
416
  this.turns = [];
266
417
  this.startMs = null;
418
+ this.triggerTokens = 0;
419
+ this.accumOutputTokens = 0;
420
+ this.accumToolTokens = 0;
267
421
  }
268
422
  isActive() {
269
423
  return this.startMs !== null;
@@ -30,7 +30,9 @@ export class BorderRenderer {
30
30
  const info = this.getModelInfo();
31
31
  const glow = (s) => {
32
32
  try {
33
- const maybeGlow = theme.getThinkingBorderColor;
33
+ const maybeGlow =
34
+ // SAFETY: intentional unsafe cast — validated at runtime
35
+ theme.getThinkingBorderColor;
34
36
  if (typeof maybeGlow === "function")
35
37
  return maybeGlow.call(theme, info.level)(s);
36
38
  }
@@ -79,7 +81,7 @@ export class BorderRenderer {
79
81
  if (tokensLeft) {
80
82
  // Use | as separator between ↑/↓ and tool turns (dimmed) per user request
81
83
  // SAFETY: pi theme seam — fg is optional theme method
82
- const themeForTokens = this.getLiveTheme();
84
+ const themeForTokens = this.getLiveTheme(); // SAFETY: intentional unsafe cast — validated at runtime
83
85
  const fg = themeForTokens.fg;
84
86
  const dimPipe = typeof fg === "function" ? fg.call(themeForTokens, "dim", " | ") : " | ";
85
87
  const combined = tokensRight ? `${tokensLeft}${dimPipe}${tokensRight}` : tokensLeft;
@@ -91,7 +93,9 @@ export class BorderRenderer {
91
93
  const theme = this.getLiveTheme();
92
94
  const glow = (s) => {
93
95
  try {
94
- const maybeGlow = theme.getThinkingBorderColor;
96
+ const maybeGlow =
97
+ // SAFETY: intentional unsafe cast — validated at runtime
98
+ theme.getThinkingBorderColor;
95
99
  if (typeof maybeGlow === "function")
96
100
  return maybeGlow.call(theme, this.getModelInfo().level)(s);
97
101
  }
@@ -66,13 +66,16 @@ export function formatTopContextFromSnapshot(snapshot, theme, glyphs, isAscii, s
66
66
  export function createChromeSnapshot(ctx, footerState) {
67
67
  const cwd = ctx?.sessionManager?.getCwd?.() ??
68
68
  // SAFETY: pi seam — intentional unsafe cast, validated at runtime
69
- ctx?.cwd ?? // SAFETY: pi seam — intentional unsafe cast, validated at runtime
69
+ // ast-grep-ignore: require-safety-comment-for-as-unknown-as
70
+ // SAFETY: intentional unsafe cast — validated at runtime
71
+ /* SAFETY: intentional unsafe cast — validated at runtime */ ctx?.cwd ?? // SAFETY: pi seam — intentional unsafe cast, validated at runtime
70
72
  process.cwd();
71
73
  const sessionName = ctx?.sessionManager?.getSessionName?.();
72
74
  const contextUsage = ctx?.getContextUsage?.();
73
75
  const totals = getUsageTotals(
76
+ // ast-grep-ignore: require-safety-comment-for-as-unknown-as
74
77
  // SAFETY: pi seam
75
- (ctx ?? {}));
78
+ /* SAFETY: intentional unsafe cast — validated at runtime */ (ctx ?? {}));
76
79
  // footerState may be absent when called from LiveBorder (context-only); use empty defaults
77
80
  // SAFETY: pi seam
78
81
  const git = footerState?.git ??
@@ -88,6 +91,9 @@ export function createChromeSnapshot(ctx, footerState) {
88
91
  renamed: 0,
89
92
  deleted: 0,
90
93
  commit: null,
94
+ // ast-grep-ignore: require-safety-comment-for-as-unknown-as
95
+ // SAFETY: intentional unsafe cast — validated at runtime
96
+ /* SAFETY: intentional unsafe cast — validated at runtime */
91
97
  }; // SAFETY: pi seam — intentional unsafe cast, validated at runtime
92
98
  const runtime = footerState?.runtime ?? null;
93
99
  return {
package/dist/config.js CHANGED
@@ -99,6 +99,7 @@ export const CONFIG_SCHEMA = [
99
99
  { path: "timeline.tokens", kind: "boolean" },
100
100
  { path: "timeline.cost", kind: "boolean" },
101
101
  ];
102
+ // SAFETY: table-driven config access — path validated against CONFIG_SCHEMA, caller validates via validate()
102
103
  function getByPath(obj, path) {
103
104
  const parts = path.split(".");
104
105
  let cur = obj;
@@ -123,6 +124,7 @@ function setByPath(obj, path, value) {
123
124
  }
124
125
  cur[parts[parts.length - 1]] = value;
125
126
  }
127
+ // SAFETY: table-driven config access — path validated against CONFIG_SCHEMA, caller validates via validate()
126
128
  function getDefaultByPath(path) {
127
129
  return getByPath(DEFAULT_CONFIG, path);
128
130
  }
@@ -1,3 +1,21 @@
1
+ /**
2
+ * DetailChrome — deep module owning the whole Detail-window flow behind one seam.
3
+ *
4
+ * Previously the Highlight → Detail window flow was fragmented: TrackingEditor
5
+ * observed the Candidate highlight, src/index.ts held widget state
6
+ * (currentItem/scrollOffset/lastWidth), scroll math, and header-marker
7
+ * re-parsing (contentLinesFrom); src/detail-render.ts owned wrapping;
8
+ * src/window-presentation.ts owned the border. Fixing a wrapping bug required
9
+ * holding 3 modules in one head — no locality.
10
+ *
11
+ * Depth: small interface (setItem / getItem / scrollBy / render) hides scroll
12
+ * offset, width, MAX_LINES, wrapping, border, and header-marker parsing inside.
13
+ * detail-render + window-presentation are INTERNAL seams, not part of the
14
+ * interface. One place to learn — one place to test, no TUI needed.
15
+ */
16
+ import * as fs from "node:fs";
17
+ import * as os from "node:os";
18
+ import * as path from "node:path";
1
19
  import { contentLineCount, renderDetail, scroll, } from "./detail-render.js";
2
20
  import { decorateWindow } from "./window-presentation.js";
3
21
  /** Height cap of the detail window (the user's spec: up to 5 lines). */
@@ -6,12 +24,65 @@ export const MAX_LINES = 5;
6
24
  function kindOf(value) {
7
25
  return value.startsWith("skill:") ? "skill" : "command";
8
26
  }
27
+ function resolveSkillPath(skillName) {
28
+ const name = skillName.trim();
29
+ if (!name)
30
+ return undefined;
31
+ const candidates = [];
32
+ const cwd = process.cwd();
33
+ const home = (() => {
34
+ try {
35
+ return os.homedir();
36
+ }
37
+ catch {
38
+ return "";
39
+ }
40
+ })();
41
+ // project-local candidates
42
+ candidates.push(path.join(cwd, ".pi", "agent", "skills", name, "SKILL.md"));
43
+ candidates.push(path.join(cwd, ".agents", "skills", name, "SKILL.md"));
44
+ candidates.push(path.join(cwd, ".pi", "skills", name, "SKILL.md"));
45
+ candidates.push(path.join(cwd, "skills", name, "SKILL.md"));
46
+ // user-global candidates
47
+ if (home) {
48
+ candidates.push(path.join(home, ".pi", "agent", "skills", name, "SKILL.md"));
49
+ candidates.push(path.join(home, ".agents", "skills", name, "SKILL.md"));
50
+ candidates.push(path.join(home, ".pi", ".agents", "skills", name, "SKILL.md"));
51
+ candidates.push(path.join(home, ".pi", "skills", name, "SKILL.md"));
52
+ candidates.push(path.join(home, "stowfiles", "dotfiles", ".pi", "agent", "skills", name, "SKILL.md"));
53
+ candidates.push(path.join(home, "stowfiles", "dotfiles", ".pi", "agent", ".agents", "skills", name, "SKILL.md"));
54
+ }
55
+ for (const p of candidates) {
56
+ try {
57
+ if (fs.existsSync(p))
58
+ return p;
59
+ }
60
+ catch {
61
+ // ignore
62
+ }
63
+ }
64
+ // fallback to global symlink path so header always shows a path for skills
65
+ if (home)
66
+ return path.join(home, ".pi", "agent", "skills", name, "SKILL.md");
67
+ return path.join(cwd, ".pi", "agent", "skills", name, "SKILL.md");
68
+ }
9
69
  function detailItemOf(item) {
10
- return {
70
+ const detail = {
11
71
  label: item.label,
12
72
  kind: kindOf(item.value),
13
73
  description: item.description ?? "",
14
74
  };
75
+ const rawPath = item.path?.trim() ?? "";
76
+ if (rawPath !== "") {
77
+ detail.path = rawPath;
78
+ }
79
+ else if (item.value.startsWith("skill:")) {
80
+ const skillName = item.value.slice("skill:".length);
81
+ const resolved = resolveSkillPath(skillName);
82
+ if (resolved !== undefined)
83
+ detail.path = resolved;
84
+ }
85
+ return detail;
15
86
  }
16
87
  export class DetailChrome {
17
88
  item = null;
@@ -5,6 +5,7 @@
5
5
  * lines to render in the 5-line detail window above the input box.
6
6
  * No pi imports — this is the single testable seam of the extension.
7
7
  */
8
+ import * as os from "node:os";
8
9
  /**
9
10
  * Rendered lines for the detail window: `[header, ...contentLines]`.
10
11
  *
@@ -36,16 +37,40 @@ export function renderDetail(item, width, maxLines, scrollOffset) {
36
37
  visibleLines[visibleLines.length - 1] = "...";
37
38
  }
38
39
  const overflows = contentLines.length > capacity;
39
- const namePart = `${item.label} · ${item.kind}`;
40
+ const base = `${item.label} · ${item.kind}`;
41
+ const marker = overflows ? ` ${offset + 1}/${contentLines.length}` : "";
42
+ const markerLen = marker.length;
40
43
  let header;
41
- if (overflows) {
42
- // Reserve room for the scroll marker so it always survives truncation.
43
- const marker = ` ${offset + 1}/${contentLines.length}`;
44
- const nameWidth = Math.max(0, wrapWidth - marker.length);
45
- header = truncateToWidth(namePart, nameWidth) + marker;
44
+ const rawPath = item.path?.trim() ?? "";
45
+ if (rawPath !== "") {
46
+ const normalized = normalizePath(rawPath);
47
+ const availableForPath = Math.max(0, wrapWidth - base.length - markerLen - 2);
48
+ let pathSeg = "";
49
+ if (availableForPath >= 10) {
50
+ const truncated = normalized.length <= availableForPath
51
+ ? normalized
52
+ : truncateMiddle(normalized, availableForPath);
53
+ pathSeg = ` ${truncated}`;
54
+ }
55
+ const totalLen = base.length + pathSeg.length + markerLen;
56
+ if (totalLen <= wrapWidth) {
57
+ header = base + pathSeg + marker;
58
+ }
59
+ else if (pathSeg !== "") {
60
+ const baseWidth = Math.max(0, wrapWidth - pathSeg.length - markerLen);
61
+ header = truncateToWidth(base, baseWidth) + pathSeg + marker;
62
+ }
63
+ else {
64
+ const nameWidth = Math.max(0, wrapWidth - markerLen);
65
+ header = truncateToWidth(base, nameWidth) + marker;
66
+ }
67
+ }
68
+ else if (overflows) {
69
+ const nameWidth = Math.max(0, wrapWidth - markerLen);
70
+ header = truncateToWidth(base, nameWidth) + marker;
46
71
  }
47
72
  else {
48
- header = truncateToWidth(namePart, wrapWidth);
73
+ header = truncateToWidth(base, wrapWidth);
49
74
  }
50
75
  return [header, ...visibleLines];
51
76
  }
@@ -58,7 +83,8 @@ export function contentLineCount(item, width) {
58
83
  if (!item || item.description.trim() === "") {
59
84
  return 0;
60
85
  }
61
- return wrapDescription(item.description, Math.max(1, Math.floor(width))).length;
86
+ return wrapDescription(item.description, Math.max(1, Math.floor(width)))
87
+ .length;
62
88
  }
63
89
  /**
64
90
  * Next scroll offset after moving by `delta` (-1 = back/up, +1 = forward/down).
@@ -99,6 +125,44 @@ function wrapDescription(description, width) {
99
125
  function truncateToWidth(text, width) {
100
126
  return text.length <= width ? text : text.slice(0, width);
101
127
  }
128
+ export function normalizePath(raw) {
129
+ if (!raw)
130
+ return raw;
131
+ let p = raw.replace(/\\/g, "/");
132
+ try {
133
+ const cwd = process.cwd().replace(/\\/g, "/");
134
+ if (p === cwd)
135
+ p = ".";
136
+ else if (p.startsWith(`${cwd}/`))
137
+ p = p.slice(cwd.length + 1);
138
+ else {
139
+ const home = os.homedir();
140
+ if (home) {
141
+ const homePosix = home.replace(/\\/g, "/");
142
+ if (p === homePosix)
143
+ p = "~";
144
+ else if (p.startsWith(`${homePosix}/`))
145
+ p = `~${p.slice(homePosix.length)}`;
146
+ }
147
+ }
148
+ }
149
+ catch {
150
+ // ignore homedir/cwd failures
151
+ }
152
+ return p;
153
+ }
154
+ export function truncateMiddle(text, maxWidth) {
155
+ if (text.length <= maxWidth)
156
+ return text;
157
+ if (maxWidth <= 1)
158
+ return text.slice(0, maxWidth);
159
+ if (maxWidth === 2)
160
+ return `${text.slice(0, 1)}…`;
161
+ const keep = maxWidth - 1;
162
+ const left = Math.ceil(keep / 2);
163
+ const right = Math.floor(keep / 2);
164
+ return `${text.slice(0, left)}…${text.slice(text.length - right)}`;
165
+ }
102
166
  function clamp(value, min, max) {
103
167
  return Math.max(min, Math.min(max, value));
104
168
  }
package/dist/footer.js CHANGED
@@ -188,8 +188,9 @@ export function installFooter(ctx, getState, getConfig, getModelMeta, hooks) {
188
188
  const config = getConfig();
189
189
  const cwd = ctx.sessionManager?.getCwd() ?? process.cwd();
190
190
  const totals = getUsageTotals(
191
+ // ast-grep-ignore: require-safety-comment-for-as-unknown-as
191
192
  // SAFETY: pi seam — intentional unsafe cast, validated at runtime
192
- ctx);
193
+ /* SAFETY: intentional unsafe cast — validated at runtime */ ctx);
193
194
  return renderFooter(width, state, config, themeStub, {
194
195
  cwd,
195
196
  sessionName: ctx.sessionManager?.getSessionName?.(),
@@ -202,10 +203,13 @@ export function installFooter(ctx, getState, getConfig, getModelMeta, hooks) {
202
203
  // Prefer native footer if available
203
204
  if (
204
205
  // SAFETY: pi seam — intentional unsafe cast, validated at runtime
205
- typeof ctx.ui.setFooter ===
206
+ // ast-grep-ignore: require-safety-comment-for-as-unknown-as
207
+ // SAFETY: intentional unsafe cast — validated at runtime
208
+ /* SAFETY: intentional unsafe cast — validated at runtime */ typeof /* SAFETY: intentional unsafe cast — validated at runtime */ ctx.ui.setFooter === // SAFETY: intentional unsafe cast — validated at runtime
206
209
  "function") {
210
+ // ast-grep-ignore: require-safety-comment-for-as-unknown-as
207
211
  // SAFETY: pi seam — intentional unsafe cast, validated at runtime
208
- const ui = ctx.ui;
212
+ /* SAFETY: intentional unsafe cast — validated at runtime */ const ui = ctx.ui;
209
213
  ui.setFooter((tui, _theme, footerData) => {
210
214
  hooks.setRequestRender(() => tui.requestRender());
211
215
  const unsub = footerData.onBranchChange(() => {
@@ -220,14 +224,16 @@ export function installFooter(ctx, getState, getConfig, getModelMeta, hooks) {
220
224
  invalidate() { },
221
225
  render(width) {
222
226
  // Use real theme when rendering
227
+ // ast-grep-ignore: require-safety-comment-for-as-unknown-as
223
228
  // SAFETY: pi seam — intentional unsafe cast, validated at runtime
224
- const theme = _theme;
229
+ /* SAFETY: intentional unsafe cast — validated at runtime */ const theme = _theme; // SAFETY: intentional unsafe cast — validated at runtime
225
230
  const state = getState();
226
231
  const config = getConfig();
227
232
  const cwd = ctx.sessionManager?.getCwd() ?? process.cwd();
228
233
  const totals = getUsageTotals(
234
+ // ast-grep-ignore: require-safety-comment-for-as-unknown-as
229
235
  // SAFETY: pi seam — intentional unsafe cast, validated at runtime
230
- ctx);
236
+ /* SAFETY: intentional unsafe cast — validated at runtime */ ctx);
231
237
  return renderFooter(width, state, config, theme, {
232
238
  cwd,
233
239
  sessionName: ctx.sessionManager?.getSessionName?.(),
@@ -241,8 +247,10 @@ export function installFooter(ctx, getState, getConfig, getModelMeta, hooks) {
241
247
  };
242
248
  });
243
249
  return () => {
250
+ // ast-grep-ignore: require-safety-comment-for-as-unknown-as
244
251
  // SAFETY: pi seam — intentional unsafe cast, validated at runtime
245
- ctx.ui.setFooter(undefined);
252
+ /* SAFETY: intentional unsafe cast — validated at runtime */ ctx.ui.setFooter(// SAFETY: intentional unsafe cast — validated at runtime
253
+ undefined);
246
254
  };
247
255
  }
248
256
  // Fallback: widget belowEditor
@@ -58,7 +58,10 @@ export class LiveBorder {
58
58
  const comp = this.composition();
59
59
  if (!comp)
60
60
  return;
61
- const snapshot = createChromeSnapshot(ctx, // SAFETY: pi seam — intentional unsafe cast, validated at runtime
61
+ // SAFETY: pi seam — intentional unsafe cast, validated at runtime
62
+ const snapshot = createChromeSnapshot(
63
+ // SAFETY: pi seam — intentional unsafe cast, validated at runtime
64
+ ctx, // SAFETY: pi seam — intentional unsafe cast, validated at runtime
62
65
  undefined);
63
66
  this.refreshTopBorder(comp);
64
67
  this.refreshLiveTelemetry(comp);
@@ -179,9 +182,8 @@ export class LiveBorder {
179
182
  if (cfg.footerSegments.context &&
180
183
  snapshot.contextUsage &&
181
184
  snapshot.contextUsage.contextWindow) {
182
- contextText = comp.formatTopContext(snapshot,
183
- // SAFETY: intentional unsafe cast — validated at runtime
184
- cfg.contextIconBar ?? // SAFETY: pi seam — intentional unsafe cast, validated at runtime
185
+ // SAFETY: pi seam — intentional unsafe cast, validated at runtime
186
+ contextText = comp.formatTopContext(snapshot, cfg.contextIconBar ?? // SAFETY: pi seam — intentional unsafe cast, validated at runtime
185
187
  false);
186
188
  }
187
189
  let tokensText = "";
@@ -189,15 +191,15 @@ export class LiveBorder {
189
191
  const isRunning = this.deps.runActivityTracker.isRunning();
190
192
  const contextTokens = snapshot.contextUsage?.tokens;
191
193
  if (!isRunning) {
192
- const telIdle = this.deps.agentLedger.getLiveTotals(this.deps.telemetryTracker.peekLive()) ?? this.deps.telemetryTracker.getLastTelemetry();
193
- const display = this.deps.agentLedger.getIdleDisplayTotals(snapshot.totals, contextTokens, telIdle);
194
+ // Hybrid Q7 b: idle shows authoritative billed total (no ~), live shows synthetic incremental (~)
195
+ const display = this.deps.agentLedger.getIdleAuthoritativeDisplay(snapshot.totals, contextTokens);
194
196
  tokensText = comp.formatTelemetryTokens(display, cfg.telemetry);
195
197
  }
196
198
  else {
197
199
  const liveTurn = this.deps.telemetryTracker.peekLive();
198
200
  const agentLive = this.deps.agentLedger.getLiveTotals(liveTurn) ??
199
201
  this.deps.telemetryTracker.getLastTelemetry();
200
- const display = this.deps.agentLedger.getLiveDisplayTotals(liveTurn, agentLive, contextTokens);
202
+ const display = this.deps.agentLedger.getIncrementalLiveDisplayTotals(liveTurn, agentLive, contextTokens);
201
203
  if (display) {
202
204
  tokensText = comp.formatTelemetryTokens(display, cfg.telemetry);
203
205
  }