pi-editor-footer 0.2.0 → 0.4.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,56 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.4.0] - 2026-08-22
8
+
9
+ ### Added
10
+
11
+ - Tiered context usage colors — `contextUsageColor(pct)` with 25%/50%/75% thresholds (`dim` <25 → `accent` 25–50 → `warning` 50–75 → `error` ≥75) applied to both `percent` and `tokens/window` sections and to bar/icon, so low usage stays dimmed and high usage highlights for quota visibility (`color-policy`, `chrome-state:formatContextBar`, `utils` barrel)
12
+ - Deepened architecture — candidates 1–5 (`TrackingEditor`, `TranscriptTimeline`, `LiveBorder`, `ChromeState`, `SessionKernel`) behind single seams, plus sync contract extension for transcript timeline seam (candidate 2)
13
+ - Telemetry formatting — `TPS`/`TTFT` with units and fixed width (`60.6 tok/s TPS`, `2.5s TTFT`), default tokens at start, and `↑`/`↓` relocated to right of cache with `|` separator
14
+
15
+ ### Changed
16
+
17
+ - Hide `↑`/`↓` tokens at startup (only after `turn_start` via `liveInputTokens`)
18
+ - Dim line padding and fixed-width `TPS`/`TTFT`/`duration` with gradual TPS decay (half-life ~5s, reset after 2s idle)
19
+
20
+ ### Fixed
21
+
22
+ - Reset TPS to default after 2s without incoming tokens before first token
23
+ - Interleave timeline in transcript (`chatContainer` injection, left-aligned dim `·`/`↑`/`↓`/`c`/`$` with `|`, close right) and correct `pi` extension path (`src/index.ts`)
24
+ - Satisfy `pi-lens` blocking — `SAFETY` comments for `as unknown as` casts (including `chrome-state`, `live-border`) and best-effort catches
25
+
26
+ ## [0.3.0] - 2026-08-21
27
+
28
+ ### Changed
29
+
30
+ - Timeline format now two-line dim per user spec: `2026-08-21 13:48:46 GMT+8 · 11s · ↑ 495 · ↓ 708 · c 85.3% · $0.00` + `3 turns · 5 tools · 1 failed` (datetime with timezone via `Intl` `en-CA` `short` TZ, wall `formatDuration`, `↑`/`↓` `fmtTokens`, cache `glyphs.cacheHit` `latestCacheHitRate`, cost `$`, turn/tools/failed from `runActivity` snapshot)
31
+ - Dimmed timeline now in transcript between runs — `User: hello? / Assistant: hi. / <dim timeline>` left-aligned dim, injected into `chatContainer` (scrollable) via `InteractiveMode` patch, not `aboveEditor` widget, so it sits between each `User/Assistant` pair and scrolls with history
32
+ - Dimmed timeline between each agent run in transcript — `chatContainer` injection `User/Assistant/<dim timeline>` left-aligned dim `· 8s wall · ↑ 1.2k · ↓ 800 · $0.12` via `InteractiveMode` patch (not `aboveEditor` widget), one per `agent_settled` per `timeline.*`, permanent in scroll history, never exposed to model (between transcript bottom and input), one per `agent_settled` (`· 8s wall · ↑ 1.2k · ↓ 800 · $0.12`), permanent between runs, never exposed to model (pi-tui only exposes `aboveEditor`/`belowEditor` widgets, so stacking aboveEditor is the non-exposing compromise)
33
+ - Wall time dim line left aligned and permanent between `agent_end` and next `agent_start` (`aboveEditor` `· 8s wall · ↑` dim left, not centered, stays in gap, never exposed)
34
+ - Dim line after `agent_end` now with specified timeline metrics (`timeline.enabled`/`wallTime`/`tokens`/`cost` in `ThemeConfig`, `Timeline` tab in `/pi-editor-footer` settings) — `· 8s wall · ↑ 1.2k · ↓ 800 · $0.12` dim aboveEditor, never exposed to model
35
+ - Wall time dim line after `agent_end` now positioned after last agent message in transcript (`aboveEditor` widget `· 8s wall · ↑ 1.2k · ↓ 800 · $0.12` dim, not footer second line) — visible right after `agent_end` message, hidden on next `agent_start`, never exposed to model
36
+ - Wall time dim line after `agent_end` now a separate footer row (was inline `cwd · wall`) — now `line1: cwd · runtime | ↑·↓·$` and `line2: · 8s wall · ↑ 1.2k · ↓ 800 · $0.12` dim, so timeline is visible
37
+ - Tokens separator now `·` not `|` (`↑ 1.2k · ↓ 800 · $0.12` in footer stats and `· 8s wall · ↑ · ↓ · $` dim line)
38
+ - Settings window title aligned to repo name `pi-editor-footer Settings` (was `pi-lsz-theme Settings`) and slash command renamed `pi-footer` → `pi-editor-footer`
39
+ - Tokens/cost relocated from telemetry bottom to wall time dim line — bottom now `TPS 4.0 tok/s · TTFT 4.0s · 5.0s` (no `↑`/`↓`/`$`), dim line after `agent_end` is `· 8s wall · ↑ 1.2k · ↓ 800 · $0.12` (all `dim`, TUI-only, never exposed to model, via `FooterState.lastDoneIn` + `getUsageTotals` gated by `telemetry.tokens`/`cost`)
40
+ - Git branch never folded — `renderGitSegment` no longer `truncateBranch(…,20)`, full `feature/very-long-…` shown; priority `6` (> cwd `5`) so `fitSegmentsByPriority` never omits branch before cwd/runtime/stats
41
+ - Telemetry TPS display throttled to 1 s (data vs display separated) — `message_update` now only updates `liveDeltaChars`/`liveEstimatedTokens` in `TurnTelemetryTracker`, display `peekLive()` → `formatTurnTelemetry` only via `liveTickTimer` every `REFRESH_MS` and `message_start`/`end`/`turn_end`/`agent_settled` for approximately accurate TPS without per-delta TUI jank
42
+ - Telemetry cost toggling `telemetry.cost` off/on no longer resets cost to `$0.00` — `refreshLiveTelemetry` now falls back to `getLastTelemetry()` when `peekLive()` is null (idle) and `onConfigChanged` calls `refreshLiveTelemetry()` immediately
43
+ - Refresh rate set to one second (`REFRESH_MS = 1000` for `liveTickTimer` telemetry/top/context and `watchTimer` editor ownership watchdog, was hardcoded `1000`)
44
+ - Context icon bar now switchable via config `contextIconBar` (default `false` disabled) — `0.0% · 0/1.0M | c 0.0%` by default, `# [████░░] 39.6% · 416k/1.0M | c 85.3%` when enabled (footer top, `nerd`/`ascii` glyphs)
45
+ - Context bar format now `0.0% · 0/1.0M | c 0.0%` (was `# [#####-------] 39.6% · 416k/1.0M | c`; no icon/bar, compact `pct · tokens/W`)
46
+ - Context bar and cache now follow icon mode (`nerd` ``/``/`█` vs `ascii` `#`/`c`/`#`) — `refreshContextBar` called in `onConfigChanged` so top `# [...] | c` updates immediately when `icons.mode` changes
47
+ - Telemetry `>`, `~`, `+` glyphs removed (`TPS 60.6 tok/s · TTFT 2.5s · 8.3s · ↑395 · ↓505 · $0.16` not `> TPS … · ~ TTFT … · +8.3s`)
48
+ - Telemetry bottom border compacted (`TPS 60.6 tok/s · TTFT 2.5s · 8.3s · ↑395 · ↓505 · $0.16` not `> TPS 60.6 tok/s | ~ TTFT 2.5s | + 8.3s | ↑ 395 | ↓ 505 | $0.16`; `TPS`/`TTFT` labels preserved, no space after `↑`/`↓`, joiner `·` not ` | `, stall `!2·3.3s` not `! stall 2x / 3.3s`)
49
+ - Footer cache omitted to the right of input/output (`↑ 395 | ↓ 505 | $0.16` not `| c 85.3%`; cache remains only in top context bar `# [...] | c 0.0%`)
50
+ - Nerd mode cost now `$0.00` not ` $0.00` (always single `$` in both footer `renderFooter` and telemetry `formatTurnTelemetry`, was `glyph === "$" ?`$ / ` : `glyph $` -> ` $`)
51
+ - Cache session now always shown with zero value (`c 0.0%` in footer `↑ | ↓ | $ | c` and `| c 0.0%` in top context bar) instead of hidden when no cache tokens
52
+ - Footer cost now `toFixed(2)` (`$0.16` not `$0.160`) and moved directly after input/output (`↑ 395 | ↓ 505 | $0.16 | c 85.3%` not `c | $`; `theme.fg("dim","|")` pipe)
53
+ - Top border model label no longer shows context window (`provider/model · thinking` not `· 1.0M`; `buildLabel` ignores `contextWindow`)
54
+ - Context bar now appends cache hit rate with pipe (`# [#####-------] 39.6% · 416k/1.0M | c 85.3%` via `formatContextBar(..., cacheHitRate)` from `getUsageTotals`) and model/context are pipe-separated (`model | context` via dim `|`)
55
+ - Context bar moved from left bottom border to top border right of model info (`── model · thinking · 1.0M # [#####-------] 39.6% · 416k/1.0M ── T1 · 8s` via `embedTopWithLeftAndRight`, `TrackingEditor.setTopContextText`)
56
+
7
57
  ## [0.2.0] - 2026-08-21
8
58
 
9
59
  ### Added
@@ -1,5 +1,5 @@
1
1
  import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
2
- import { applyModelInfo, } from "./model-info.js";
2
+ import { buildLabel, } from "./model-info.js";
3
3
  function stripAnsi(s) {
4
4
  return s.replace(/\x1b\[[0-9;]*m/g, "");
5
5
  }
@@ -23,25 +23,67 @@ export class BorderRenderer {
23
23
  let out = [...lines];
24
24
  if (out.length === 0)
25
25
  return out;
26
- // Top glow + label (+ optional right side for run activity)
27
- if (opts.glowEnabled || opts.topRightText) {
26
+ // Top: model info left, context bar right of it, run-activity far right
27
+ const hasTop = opts.glowEnabled || opts.topContextText || (!opts.topTokensText && opts.topRightText);
28
+ if (hasTop) {
29
+ const theme = this.getLiveTheme();
30
+ const info = this.getModelInfo();
31
+ const glow = (s) => {
32
+ try {
33
+ const maybeGlow = theme.getThinkingBorderColor;
34
+ if (typeof maybeGlow === "function")
35
+ return maybeGlow.call(theme, info.level)(s);
36
+ }
37
+ catch {
38
+ // SAFETY: best-effort UI, ignore recoverable error
39
+ }
40
+ return s;
41
+ };
42
+ // When tokens line exists, right moves to that line (one padding) — don't duplicate on top
43
+ const topRightForTop = opts.topTokensText ? "" : opts.topRightText;
28
44
  if (opts.glowEnabled) {
29
- out = applyModelInfo(out, width, this.getLiveTheme(), this.getModelInfo());
45
+ // Build left label; if context bar present, append it to the right of model info with pipe separator
46
+ let leftLabel = buildLabel(theme, info.provider, info.modelId, info.level, info.contextWindow);
47
+ if (opts.topContextText) {
48
+ leftLabel = `${leftLabel} ${theme.fg("dim", "|")} ${opts.topContextText}`;
49
+ }
50
+ // Embed left (model+context) and optional right (run activity) in one pass to avoid double-truncate
51
+ if (topRightForTop) {
52
+ out = embedTopWithLeftAndRight(out, width, leftLabel, topRightForTop, glow);
53
+ }
54
+ else {
55
+ // Only left (model+context), no right — use applyModelInfo replacement but with combined left
56
+ // Reuse embedTopWithLeftAndRight with empty right
57
+ out = embedTopWithLeftAndRight(out, width, leftLabel, "", glow);
58
+ }
30
59
  }
31
- if (opts.topRightText) {
32
- const theme = this.getLiveTheme();
33
- const glow = (s) => {
34
- try {
35
- const maybeGlow = theme.getThinkingBorderColor;
36
- if (typeof maybeGlow === "function")
37
- return maybeGlow.call(theme, this.getModelInfo().level)(s);
38
- }
39
- catch {
40
- // ignore
41
- }
42
- return s;
43
- };
44
- out = embedTopRightBorder(out, width, opts.topRightText, glow);
60
+ else if (opts.topContextText || topRightForTop) {
61
+ // No glow, but still need top borders for context/run activity
62
+ const left = opts.topContextText ?? "";
63
+ const right = topRightForTop ?? "";
64
+ if (left && right) {
65
+ out = embedTopWithLeftAndRight(out, width, left, right, glow);
66
+ }
67
+ else if (left) {
68
+ out = embedTopRightBorder(out, width, left, glow);
69
+ }
70
+ else if (right) {
71
+ out = embedTopRightBorder(out, width, right, glow);
72
+ }
73
+ }
74
+ }
75
+ // Tokens above model info — left aligned, no border; right moved close to tokens with | separator per user request
76
+ if (opts.topTokensText || opts.topRightText) {
77
+ const tokensLeft = opts.topTokensText ?? "";
78
+ const tokensRight = opts.topRightText ?? "";
79
+ if (tokensLeft) {
80
+ // Use | as separator between ↑/↓ and tool turns (dimmed) per user request
81
+ // SAFETY: pi theme seam — fg is optional theme method
82
+ const themeForTokens = this.getLiveTheme();
83
+ const fg = themeForTokens.fg;
84
+ const dimPipe = typeof fg === "function" ? fg.call(themeForTokens, "dim", " | ") : " | ";
85
+ const combined = tokensRight ? `${tokensLeft}${dimPipe}${tokensRight}` : tokensLeft;
86
+ out = embedTokensAbove(out, width, combined);
45
87
  }
46
88
  }
47
89
  // Bottom embedding (telemetry left/right)
@@ -54,7 +96,7 @@ export class BorderRenderer {
54
96
  return maybeGlow.call(theme, this.getModelInfo().level)(s);
55
97
  }
56
98
  catch {
57
- // ignore
99
+ // SAFETY: best-effort UI, ignore recoverable error
58
100
  }
59
101
  return s;
60
102
  };
@@ -63,6 +105,49 @@ export class BorderRenderer {
63
105
  return out;
64
106
  }
65
107
  }
108
+ function embedTokensAbove(lines, width, tokensText) {
109
+ if (!tokensText || lines.length === 0)
110
+ return lines;
111
+ // Left aligned, no border; one leading padding as before
112
+ const raw = ` ${tokensText}`;
113
+ const display = truncateToWidth(raw, width, "");
114
+ const result = [...lines];
115
+ result.splice(0, 0, display);
116
+ return result;
117
+ }
118
+ function embedTopWithLeftAndRight(lines, width, leftText, rightText, getGlow) {
119
+ if (lines.length === 0)
120
+ return lines;
121
+ const top = lines[0] ?? "";
122
+ const plainTop = stripAnsi(top);
123
+ if (/^─── [↑↓] \d+ more/.test(plainTop))
124
+ return lines;
125
+ const leftW = leftText ? visibleWidth(leftText) : 0;
126
+ const rightW = rightText ? visibleWidth(rightText) : 0;
127
+ const maxLeft = rightText ? Math.max(0, width - rightW - 6) : width - 3;
128
+ const maxRight = leftText ? Math.max(0, width - leftW - 6) : width - 3;
129
+ let displayLeft = leftText;
130
+ let displayRight = rightText;
131
+ if (leftW > maxLeft)
132
+ displayLeft = truncateToWidth(leftText, maxLeft, "");
133
+ if (rightW > maxRight)
134
+ displayRight = truncateToWidth(rightText, maxRight, "");
135
+ const leftSegment = displayLeft
136
+ ? `${getGlow("─")} ${displayLeft} `
137
+ : getGlow("─");
138
+ const rightSegment = displayRight
139
+ ? ` ${displayRight} ${getGlow("─")}`
140
+ : getGlow("─");
141
+ const used = visibleWidth(leftSegment) + visibleWidth(rightSegment);
142
+ const middleWidth = Math.max(0, width - used);
143
+ const middle = getGlow("─".repeat(middleWidth));
144
+ const embedded = `${leftSegment}${middle}${rightSegment}`;
145
+ const result = [...lines];
146
+ result[0] = truncateToWidth(embedded, width, "");
147
+ if (visibleWidth(embedded) !== width)
148
+ result[0] = embedded;
149
+ return result;
150
+ }
66
151
  function embedBottomBorder(lines, width, leftText, rightText, getGlow) {
67
152
  if ((!leftText && !rightText) || lines.length === 0)
68
153
  return lines;
@@ -0,0 +1,120 @@
1
+ /**
2
+ * ChromeState — deep module owning chrome snapshot derivation behind one seam.
3
+ *
4
+ * Problem it solves (C5): same chrome concepts (context %, git, runtime,
5
+ * tokens, cacheHitRate) render through two seams — footer below input and
6
+ * border on TrackingEditor — with derivation scattered across
7
+ * footer.ts:formatContextBar, state.ts:getUsageTotals, index.ts:refreshContextBar,
8
+ * and border-renderer. Tracing one token requires bouncing 4 modules.
9
+ *
10
+ * Depth: one interface (snapshot / format helpers) hides truncation, color
11
+ * policy, cache-hit derivation and cwd fallbacks. Two adapters (footer +
12
+ * border) justify the seam — with one it would be hypothetical.
13
+ *
14
+ * Internal seams (renderBar, stressColor, cacheHitColor, fmtTokens) stay
15
+ * inside this module, not part of its external seam.
16
+ */
17
+ import { getUsageTotals } from "./state.js";
18
+ import { cacheHitColor, contextUsageColor } from "./color-policy.js";
19
+ import { fmtTokens } from "./format.js";
20
+ // ---------------------------------------------------------------------------
21
+ // Context bar formatting — moved from footer.ts to centralize chrome rendering.
22
+ // Re-exported from footer.ts for backward compatibility.
23
+ // ---------------------------------------------------------------------------
24
+ function renderBar(theme, pct, barWidth, ascii) {
25
+ const filled = Math.max(0, Math.min(barWidth, Math.round((pct / 100) * barWidth)));
26
+ const empty = barWidth - filled;
27
+ const color = contextUsageColor(pct);
28
+ const filledCell = ascii ? "#" : "█";
29
+ const emptyCell = ascii ? "-" : "░";
30
+ return (theme.fg("dim", "[") +
31
+ theme.fg(color, filledCell.repeat(filled)) +
32
+ theme.fg("dim", emptyCell.repeat(empty)) +
33
+ theme.fg("dim", "]"));
34
+ }
35
+ export function formatContextBar(contextUsage, theme, glyphs, isAscii, barWidth = 10, cacheHitRate, showIconBar = false) {
36
+ const contextWindow = contextUsage?.contextWindow ?? 0;
37
+ if (contextWindow <= 0)
38
+ return "";
39
+ const contextPct = contextUsage?.percent ?? 0;
40
+ const contextColor = contextUsageColor(contextPct);
41
+ const pctText = theme.fg(contextColor, `${contextPct.toFixed(1)}%`);
42
+ const contextTokens = contextUsage?.tokens ?? 0;
43
+ const ctxText = `${theme.fg(contextColor, fmtTokens(contextTokens))}${theme.fg("dim", "/")}${theme.fg(contextColor, fmtTokens(contextWindow))}`;
44
+ const baseCore = `${pctText} ${theme.fg("dim", "·")} ${ctxText}`;
45
+ const base = showIconBar
46
+ ? `${theme.fg(contextColor, glyphs.context)} ${renderBar(theme, contextPct, barWidth, isAscii)} ${baseCore}`
47
+ : baseCore;
48
+ const rate = cacheHitRate !== undefined && Number.isFinite(cacheHitRate)
49
+ ? cacheHitRate
50
+ : 0;
51
+ const cacheText = `${glyphs.cacheHit} ${rate.toFixed(1)}%`;
52
+ return `${base} ${theme.fg("dim", "|")} ${theme.fg(cacheHitColor(rate), cacheText)}`;
53
+ }
54
+ /** Border adapter helper — one call from snapshot + theme/glyphs. */
55
+ export function formatTopContextFromSnapshot(snapshot, theme, glyphs, isAscii, showIconBar = false) {
56
+ return formatContextBar(snapshot.contextUsage, theme, glyphs, isAscii, 10, snapshot.totals.latestCacheHitRate, showIconBar);
57
+ }
58
+ // ---------------------------------------------------------------------------
59
+ // Snapshot derivation — pure, owns cwd/sessionName/context/totals consolidation.
60
+ // ---------------------------------------------------------------------------
61
+ /**
62
+ * Create a chrome snapshot from extension context + footer state.
63
+ * Pure derivation: hides getUsageTotals caching, getContextUsage optional,
64
+ * and cwd/sessionName fallbacks behind one seam.
65
+ */
66
+ export function createChromeSnapshot(ctx, footerState) {
67
+ const cwd = ctx?.sessionManager?.getCwd?.() ??
68
+ // SAFETY: pi seam
69
+ ctx?.cwd ?? // SAFETY: pi seam — intentional unsafe cast, validated at runtime
70
+ process.cwd();
71
+ const sessionName = ctx?.sessionManager?.getSessionName?.();
72
+ const contextUsage = ctx?.getContextUsage?.();
73
+ const totals = getUsageTotals(
74
+ // SAFETY: pi seam
75
+ (ctx ?? {}));
76
+ // footerState may be absent when called from LiveBorder (context-only); use empty defaults
77
+ // SAFETY: pi seam
78
+ const git = footerState?.git ??
79
+ {
80
+ branch: undefined,
81
+ ahead: 0,
82
+ behind: 0,
83
+ modified: 0,
84
+ untracked: 0,
85
+ staged: 0,
86
+ stashed: 0,
87
+ conflicted: 0,
88
+ renamed: 0,
89
+ deleted: 0,
90
+ commit: null,
91
+ }; // SAFETY: pi seam — intentional unsafe cast, validated at runtime
92
+ const runtime = footerState?.runtime ?? null;
93
+ return {
94
+ cwd,
95
+ sessionName,
96
+ git,
97
+ runtime,
98
+ contextUsage,
99
+ totals,
100
+ };
101
+ }
102
+ /**
103
+ * Deep module variant — owns snapshot + refresh lifecycle behind one seam.
104
+ * Thin wrapper over createChromeSnapshot for callers that prefer an instance.
105
+ */
106
+ export class ChromeState {
107
+ getFooterState;
108
+ getCtx;
109
+ constructor(getFooterState, getCtx) {
110
+ this.getFooterState = getFooterState;
111
+ this.getCtx = getCtx;
112
+ }
113
+ snapshot() {
114
+ return createChromeSnapshot(this.getCtx(), this.getFooterState());
115
+ }
116
+ /** Convenience: format top context bar from current snapshot + theme. */
117
+ formatTopContext(theme, glyphs, isAscii, showIconBar = false) {
118
+ return formatTopContextFromSnapshot(this.snapshot(), theme, glyphs, isAscii, showIconBar);
119
+ }
120
+ }
@@ -5,6 +5,17 @@ export function stressColor(value, warn = 70, danger = 90) {
5
5
  return "warning";
6
6
  return "accent";
7
7
  }
8
+ export function contextUsageColor(pct) {
9
+ // 25% 50% 75% thresholds — transit from dimmed (low) to highlight (high)
10
+ // for intuitive quota status: dim (<25) → accent (25-50) → warning (50-75) → error (≥75)
11
+ if (pct >= 75)
12
+ return "error";
13
+ if (pct >= 50)
14
+ return "warning";
15
+ if (pct >= 25)
16
+ return "accent";
17
+ return "dim";
18
+ }
8
19
  export function cacheHitColor(value) {
9
20
  if (value < 30)
10
21
  return "error";
package/dist/config.js CHANGED
@@ -9,6 +9,7 @@ export const DEFAULT_CONFIG = {
9
9
  icons: {
10
10
  mode: "auto",
11
11
  },
12
+ contextIconBar: false,
12
13
  footerSegments: {
13
14
  cwd: true,
14
15
  sessionName: false,
@@ -30,6 +31,12 @@ export const DEFAULT_CONFIG = {
30
31
  stalls: true,
31
32
  cost: true,
32
33
  },
34
+ timeline: {
35
+ enabled: true,
36
+ wallTime: true,
37
+ tokens: true,
38
+ cost: true,
39
+ },
33
40
  };
34
41
  export function getConfigPath() {
35
42
  const home = homedir();
@@ -84,18 +91,41 @@ function validate(config) {
84
91
  if (!isBoolean(config.enabled)) {
85
92
  config.enabled = DEFAULT_CONFIG.enabled;
86
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
87
100
  const fs = config.footerSegments;
101
+ // SAFETY: intentional unsafe cast — validated at runtime
88
102
  const dfs = DEFAULT_CONFIG.footerSegments;
89
103
  for (const k of Object.keys(dfs)) {
90
104
  if (!isBoolean(fs[k]))
91
105
  fs[k] = dfs[k];
92
106
  }
107
+ // SAFETY: intentional unsafe cast — validated at runtime
93
108
  const t = config.telemetry;
109
+ // SAFETY: intentional unsafe cast — validated at runtime
94
110
  const dt = DEFAULT_CONFIG.telemetry;
95
111
  for (const k of Object.keys(dt)) {
96
112
  if (!isBoolean(t[k]))
97
113
  t[k] = dt[k];
98
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);
122
+ }
123
+ else {
124
+ for (const k of Object.keys(dtl)) {
125
+ if (!isBoolean(tl[k]))
126
+ tl[k] = dtl[k];
127
+ }
128
+ }
99
129
  return config;
100
130
  }
101
131
  export class ConfigStore {
@@ -108,7 +138,9 @@ export class ConfigStore {
108
138
  constructor(deps = {}) {
109
139
  this.readFile =
110
140
  deps.readFile ??
111
- ((p, enc) => readFileSync(p, enc));
141
+ ((p, enc) =>
142
+ // SAFETY: intentional unsafe cast — validated at runtime
143
+ readFileSync(p, enc));
112
144
  this.writeFile =
113
145
  deps.writeFile ??
114
146
  ((p, d, enc) => writeFileSync(p, d, enc));
@@ -129,7 +161,7 @@ export class ConfigStore {
129
161
  this.writeFile(p, JSON.stringify(DEFAULT_CONFIG, null, 2) + "\n", "utf8");
130
162
  }
131
163
  catch {
132
- // best-effort
164
+ // SAFETY: best-effort, ignore recoverable error
133
165
  }
134
166
  return structuredClone(DEFAULT_CONFIG);
135
167
  }
@@ -140,6 +172,7 @@ export class ConfigStore {
140
172
  return validate(merged);
141
173
  }
142
174
  catch (err) {
175
+ // SAFETY: best-effort, ignore recoverable error
143
176
  console.warn(`[pi-skill-desc] config parse error (${p}): ${err instanceof Error ? err.message : String(err)} — using defaults`);
144
177
  return structuredClone(DEFAULT_CONFIG);
145
178
  }
@@ -155,13 +188,14 @@ export class ConfigStore {
155
188
  this.writeFile(p, JSON.stringify(merged, null, 2) + "\n", "utf8");
156
189
  }
157
190
  catch {
158
- // best-effort
191
+ // SAFETY: best-effort, ignore recoverable error
159
192
  }
160
193
  for (const fn of this.listeners) {
161
194
  try {
162
195
  fn(merged, prev);
163
196
  }
164
197
  catch {
198
+ // SAFETY: best-effort, ignore recoverable error
165
199
  // subscriber error should not break store
166
200
  }
167
201
  }
@@ -17,10 +17,13 @@ function removeSoftwareCursor(line, cursorMarker = "") {
17
17
  function configureCursor(tui, cursorStyle) {
18
18
  if (cursorStyle === "block")
19
19
  return;
20
- const setShow = tui.setShowHardwareCursor;
20
+ const setShow =
21
+ // SAFETY: intentional unsafe cast — validated at runtime
22
+ tui.setShowHardwareCursor;
21
23
  if (typeof setShow === "function")
22
24
  setShow.call(tui, true);
23
25
  const seq = CURSOR_STYLE_SEQUENCES[cursorStyle];
26
+ // SAFETY: intentional unsafe cast — validated at runtime
24
27
  const term = tui
25
28
  .terminal;
26
29
  if (seq && term && typeof term.write === "function")
@@ -46,6 +49,7 @@ export class CursorPolicy {
46
49
  this.previewHardwareCursor = style !== "block";
47
50
  this.style = style;
48
51
  if (changed) {
52
+ // SAFETY: intentional unsafe cast — validated at runtime
49
53
  const tuiAny = this.tui;
50
54
  if (style === "block") {
51
55
  if (tuiAny.terminal)
@@ -37,7 +37,7 @@ export class FooterController {
37
37
  this.state = { ...this.state, git };
38
38
  }
39
39
  catch {
40
- // best-effort
40
+ // SAFETY: best-effort, ignore recoverable error
41
41
  }
42
42
  }
43
43
  async refreshRuntime() {
@@ -47,7 +47,7 @@ export class FooterController {
47
47
  this.state = { ...this.state, runtime };
48
48
  }
49
49
  catch {
50
- // best-effort
50
+ // SAFETY: best-effort, ignore recoverable error
51
51
  }
52
52
  }
53
53
  async refreshAll() {
package/dist/footer.js CHANGED
@@ -2,37 +2,16 @@ import { truncateToWidth, visibleWidth, wrapTextWithAnsi, } from "@earendil-work
2
2
  import { getUsageTotals } from "./state.js";
3
3
  import { resolveGlyphs, runtimeSymbol } from "./icons.js";
4
4
  import { alignRight, fitSegmentsByPriority, } from "./layout.js";
5
- import { basenamePath, formatCwd, truncateBranch, truncatePath, } from "./path-format.js";
6
- import { cacheHitColor, stressColor, } from "./color-policy.js";
5
+ import { basenamePath, formatCwd, truncatePath, } from "./path-format.js";
7
6
  import { fmtTokens, formatDuration, sanitizeStatus } from "./format.js";
8
- function renderBar(theme, pct, barWidth, ascii) {
9
- const filled = Math.max(0, Math.min(barWidth, Math.round((pct / 100) * barWidth)));
10
- const empty = barWidth - filled;
11
- const color = stressColor(pct);
12
- const filledCell = ascii ? "#" : "█";
13
- const emptyCell = ascii ? "-" : "░";
14
- return (theme.fg("dim", "[") +
15
- theme.fg(color, filledCell.repeat(filled)) +
16
- theme.fg("dim", emptyCell.repeat(empty)) +
17
- theme.fg("dim", "]"));
18
- }
19
- export function formatContextBar(contextUsage, theme, glyphs, isAscii, barWidth = 10) {
20
- const contextWindow = contextUsage?.contextWindow ?? 0;
21
- if (contextWindow <= 0)
22
- return "";
23
- const contextPct = contextUsage?.percent ?? 0;
24
- const pctText = theme.fg(stressColor(contextPct), `${contextPct.toFixed(1)}%`);
25
- const contextTokens = contextUsage?.tokens ?? 0;
26
- const ctxText = `${theme.fg("text", fmtTokens(contextTokens))}${theme.fg("dim", "/")}${theme.fg("text", fmtTokens(contextWindow))}`;
27
- const contextIcon = theme.fg(stressColor(contextPct), glyphs.context);
28
- const bar = renderBar(theme, contextPct, barWidth, isAscii);
29
- return `${contextIcon} ${bar} ${pctText} ${theme.fg("dim", "·")} ${ctxText}`;
30
- }
31
- function renderGitSegment(theme, git, glyphs, segments, maxBranchLen = 20) {
7
+ // ChromeState owns context bar formatting — re-export for backward compat.
8
+ export { formatContextBar } from "./chrome-state.js";
9
+ function renderGitSegment(theme, git, glyphs, segments) {
32
10
  const parts = [];
33
11
  if (segments.gitBranch) {
34
12
  if (git.branch) {
35
- parts.push(theme.fg("mdLink", truncateBranch(git.branch, maxBranchLen)));
13
+ // Never truncate branch name — full name always shown per user request
14
+ parts.push(theme.fg("mdLink", git.branch));
36
15
  }
37
16
  else if (git.commit?.detached) {
38
17
  parts.push(theme.fg("warning", "HEAD"));
@@ -80,12 +59,17 @@ function renderRuntimeSegment(theme, runtime, iconMode) {
80
59
  const label = [symbol, version].filter(Boolean).join(" ");
81
60
  return label;
82
61
  }
83
- function renderTimerSegment(theme, state, glyphs) {
62
+ function renderTimerSegment(theme, state, glyphs, totals, config) {
63
+ // Wall time dim line — never exposed to model, TUI-only
64
+ // After agent_end: · 8.3s wall · ↑ 1.2k · ↓ 800 · $0.12 (all dim)
65
+ // During run: working 8.3s (accent)
84
66
  if (state.workingSince !== undefined) {
85
67
  return `${theme.fg("accent", glyphs.working)} ${theme.fg("dim", "working")} ${theme.fg("accent", formatDuration(Date.now() - state.workingSince))}`;
86
68
  }
87
69
  if (state.lastDoneIn !== undefined) {
88
- return `${theme.fg("success", glyphs.done)} ${theme.fg("success", "done")} ${theme.fg("text", formatDuration(state.lastDoneIn))}`;
70
+ // Wall time after agent_end is now a dim row after last message (aboveEditor widget), not inline footer
71
+ // Keep footer clean — wall time lives in transcript, not here
72
+ return "";
89
73
  }
90
74
  return "";
91
75
  }
@@ -135,7 +119,8 @@ export function renderFooter(width, state, config, theme, ctx) {
135
119
  const gitSeg = renderGitSegment(theme, state.git, glyphs, segments);
136
120
  if (gitSeg) {
137
121
  const sep = leftParts.length > 0 ? `${theme.fg("dim", " · ")}` : "";
138
- leftParts.push({ text: `${sep}${gitSeg}`, priority: 4 });
122
+ // Branch name must never be folded/omitted — keep priority higher than cwd (5)
123
+ leftParts.push({ text: `${sep}${gitSeg}`, priority: 6 });
139
124
  }
140
125
  if (segments.runtime) {
141
126
  const runtimeSeg = renderRuntimeSegment(theme, state.runtime, config.icons.mode);
@@ -144,7 +129,7 @@ export function renderFooter(width, state, config, theme, ctx) {
144
129
  leftParts.push({ text: `${sep}${runtimeSeg}`, priority: 4 });
145
130
  }
146
131
  }
147
- const timerSeg = renderTimerSegment(theme, state, glyphs);
132
+ const timerSeg = renderTimerSegment(theme, state, glyphs, totals, config);
148
133
  if (timerSeg) {
149
134
  const sep = leftParts.length > 0 ? `${theme.fg("dim", " • ")}` : "";
150
135
  leftParts.push({ text: `${sep}${timerSeg}`, priority: 1 });
@@ -153,18 +138,15 @@ export function renderFooter(width, state, config, theme, ctx) {
153
138
  if (segments.tokens) {
154
139
  stats.push(theme.fg("accent", `${glyphs.input} ${fmtTokens(totals.input)}`));
155
140
  stats.push(theme.fg("success", `${glyphs.output} ${fmtTokens(totals.output)}`));
156
- const hasCacheTokens = totals.cacheRead > 0 || totals.cacheWrite > 0;
157
- if (hasCacheTokens && totals.latestCacheHitRate !== undefined) {
158
- stats.push(theme.fg(cacheHitColor(totals.latestCacheHitRate), `${glyphs.cacheHit} ${totals.latestCacheHitRate.toFixed(1)}%`));
159
- }
160
141
  }
161
142
  if (segments.cost) {
162
- const costValue = totals.cost.toFixed(3);
163
- // Avoid "$ $0.000" when the cost glyph itself is "$" (ascii mode) — glyph already is the currency symbol
164
- const costText = glyphs.cost === "$" ? `$${costValue}` : `${glyphs.cost} $${costValue}`;
143
+ const costValue = totals.cost.toFixed(2);
144
+ // Avoid "$ $0.00" when the cost glyph itself is "$" (ascii mode) — glyph already is the currency symbol
145
+ const costText = `$${costValue}`;
165
146
  stats.push(theme.fg("warning", costText));
166
147
  }
167
- const statsBlock = stats.join(` ${theme.fg("dim", "|")} `);
148
+ // cache to the right of input/output omitted per user request — cache stays in top context bar only
149
+ const statsBlock = stats.join(` ${theme.fg("dim", "·")} `);
168
150
  const rightBlock = statsBlock;
169
151
  const rightCompact = statsBlock;
170
152
  const allParts = [...leftParts];
@@ -203,7 +185,9 @@ export function installFooter(ctx, getState, getConfig, getModelMeta, hooks) {
203
185
  const state = getState();
204
186
  const config = getConfig();
205
187
  const cwd = ctx.sessionManager?.getCwd() ?? process.cwd();
206
- const totals = getUsageTotals(ctx);
188
+ const totals = getUsageTotals(
189
+ // SAFETY: pi seam — intentional unsafe cast, validated at runtime
190
+ ctx);
207
191
  return renderFooter(width, state, config, themeStub, {
208
192
  cwd,
209
193
  sessionName: ctx.sessionManager?.getSessionName?.(),
@@ -214,8 +198,11 @@ export function installFooter(ctx, getState, getConfig, getModelMeta, hooks) {
214
198
  });
215
199
  };
216
200
  // Prefer native footer if available
217
- if (typeof ctx.ui.setFooter ===
201
+ if (
202
+ // SAFETY: pi seam — intentional unsafe cast, validated at runtime
203
+ typeof ctx.ui.setFooter ===
218
204
  "function") {
205
+ // SAFETY: pi seam — intentional unsafe cast, validated at runtime
219
206
  const ui = ctx.ui;
220
207
  ui.setFooter((tui, _theme, footerData) => {
221
208
  hooks.setRequestRender(() => tui.requestRender());
@@ -231,11 +218,14 @@ export function installFooter(ctx, getState, getConfig, getModelMeta, hooks) {
231
218
  invalidate() { },
232
219
  render(width) {
233
220
  // Use real theme when rendering
221
+ // SAFETY: pi seam — intentional unsafe cast, validated at runtime
234
222
  const theme = _theme;
235
223
  const state = getState();
236
224
  const config = getConfig();
237
225
  const cwd = ctx.sessionManager?.getCwd() ?? process.cwd();
238
- const totals = getUsageTotals(ctx);
226
+ const totals = getUsageTotals(
227
+ // SAFETY: pi seam — intentional unsafe cast, validated at runtime
228
+ ctx);
239
229
  return renderFooter(width, state, config, theme, {
240
230
  cwd,
241
231
  sessionName: ctx.sessionManager?.getSessionName?.(),
@@ -249,6 +239,7 @@ export function installFooter(ctx, getState, getConfig, getModelMeta, hooks) {
249
239
  };
250
240
  });
251
241
  return () => {
242
+ // SAFETY: pi seam — intentional unsafe cast, validated at runtime
252
243
  ctx.ui.setFooter(undefined);
253
244
  };
254
245
  }
package/dist/git.js CHANGED
@@ -29,6 +29,7 @@ async function gitExec(args, cwd) {
29
29
  return stdout;
30
30
  }
31
31
  catch {
32
+ // SAFETY: best-effort, ignore recoverable error
32
33
  return null;
33
34
  }
34
35
  }