pi-editor-footer 0.1.2 → 0.2.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,20 +4,41 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.2.0] - 2026-08-21
8
+
9
+ ### Added
10
+
11
+ - Real-time telemetry via `TurnTelemetryTracker.peekLive()` and 1 s live tick — bottom border `> TPS 60.6 tok/s | ~ TTFT 2.5s | + 8.3s | ↑ 395 | ↓ 505 | $0.16` refreshes on every `agent/turn/message/tool` event during streaming
12
+ - Top-border run activity `T1 · 8s · 2 tools · 1 failed` via `RunActivityTracker` (turn · duration · tool calls · failed) adapted from `pi-atelier/src/run-activity.ts`
13
+ - Context bar moved to left bottom border `── # [#####-------] 39.6% · 416k/1.0M ──` (`formatContextBar` helper, `barWidth 10`, theme-aware, respects `footerSegments.context`, live-updates)
14
+ - Architecture deepening: split `utils.ts` into 5 focused modules (`path-format`, `color-policy`, `format`, `layout`, `tip-policy`), deepened `ConfigStore`, `Footer`, `TrackingEditor` (`BorderRenderer`/`CursorPolicy`), `SessionKernel` (candidates 1–5)
15
+
16
+ ### Changed
17
+
18
+ - Footer no longer renders context (moved to border); right block is now just stats (`↑`/`↓`/`$`)
19
+ - Context window `formatContextWindow` now `1.0M` (was `1m` lowercase no decimal) to match `fmtTokens` (`362k/1.0M`)
20
+
21
+ ### Fixed
22
+
23
+ - Streaming `TPS — | ↓ 0` until `turn_end` — now estimates live tokens (`~4 chars/token`) and shows live `TPS`/`↓` during `message_update`
24
+ - Cost duplicate `$ $0.16/M` → `$0.16` (single `$` when glyph is `$`, nerd keeps ` $0.16`) and hid `per M` suffix as common sense
25
+ - Cost duplicate in footer `$ $0.000` → `$0.000`
26
+ - `formatContextWindow` `1m` → `1.0M` (capital `M`, one decimal) for `0.9k/1.0M` consistency
27
+
7
28
  ## [0.1.2] - 2026-08-20
8
29
 
9
30
  ### Fixed
31
+
10
32
  - `npm:pi-editor-footer` not being discovered when installed via `pi install npm:pi-editor-footer` — added `pi.extensions` (`dist/index.js`) and `keywords` so pi loads the theme from `~/.pi/agent/npm/node_modules`
11
33
 
12
34
  ## [0.1.1] - 2026-08-20
13
35
 
14
-
15
36
  ### Fixed
37
+
16
38
  - `npm:pi-editor-footer` now works when installed via `pi install npm:pi-editor-footer` — added `main` (`dist/index.js`), `files`, and `build` (`tsc --project tsconfig.build.json`) so pi can discover the extension from `~/.pi/agent/npm/node_modules`
17
39
 
18
40
  ## [0.1.0] - 2026-08-20
19
41
 
20
-
21
42
  ### Added
22
43
 
23
44
  - Full TUI theme `pi-editor-footer` rebuilt on `TrackingEditor`: project-aware footer (`cwd` · `git` • `runtime` left, `tokens` next to `context` right), model-info border glow top, live theme respect, detail window preserved
@@ -0,0 +1,139 @@
1
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
2
+ import { applyModelInfo, } from "./model-info.js";
3
+ function stripAnsi(s) {
4
+ return s.replace(/\x1b\[[0-9;]*m/g, "");
5
+ }
6
+ function isBorderLine(line) {
7
+ const plain = stripAnsi(line);
8
+ return /^─+$/.test(plain) || /^─── [↑↓] \d+ more/.test(plain);
9
+ }
10
+ /**
11
+ * BorderRenderer — deep module owning top glow + bottom border embedding.
12
+ * Deduplicates stripAnsi/isBorder logic previously spread between model-info.ts
13
+ * and tracking-editor.ts. Behind a single seam, tested via render().
14
+ */
15
+ export class BorderRenderer {
16
+ getLiveTheme;
17
+ getModelInfo;
18
+ constructor(getLiveTheme, getModelInfo) {
19
+ this.getLiveTheme = getLiveTheme;
20
+ this.getModelInfo = getModelInfo;
21
+ }
22
+ renderWithBorders(lines, width, opts) {
23
+ let out = [...lines];
24
+ if (out.length === 0)
25
+ return out;
26
+ // Top glow + label (+ optional right side for run activity)
27
+ if (opts.glowEnabled || opts.topRightText) {
28
+ if (opts.glowEnabled) {
29
+ out = applyModelInfo(out, width, this.getLiveTheme(), this.getModelInfo());
30
+ }
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);
45
+ }
46
+ }
47
+ // Bottom embedding (telemetry left/right)
48
+ if (opts.telemetryText || opts.bottomLeftText) {
49
+ const theme = this.getLiveTheme();
50
+ const glow = (s) => {
51
+ try {
52
+ const maybeGlow = theme.getThinkingBorderColor;
53
+ if (typeof maybeGlow === "function")
54
+ return maybeGlow.call(theme, this.getModelInfo().level)(s);
55
+ }
56
+ catch {
57
+ // ignore
58
+ }
59
+ return s;
60
+ };
61
+ out = embedBottomBorder(out, width, opts.bottomLeftText ?? "", opts.telemetryText ?? "", glow);
62
+ }
63
+ return out;
64
+ }
65
+ }
66
+ function embedBottomBorder(lines, width, leftText, rightText, getGlow) {
67
+ if ((!leftText && !rightText) || lines.length === 0)
68
+ return lines;
69
+ let bottomIdx = -1;
70
+ for (let i = lines.length - 1; i >= 0; i--) {
71
+ if (isBorderLine(lines[i] ?? "")) {
72
+ bottomIdx = i;
73
+ break;
74
+ }
75
+ }
76
+ if (bottomIdx === -1)
77
+ return lines;
78
+ const leftW = leftText ? visibleWidth(leftText) : 0;
79
+ const rightW = rightText ? visibleWidth(rightText) : 0;
80
+ const maxLeft = rightText ? Math.max(0, width - rightW - 6) : width - 3;
81
+ const maxRight = leftText ? Math.max(0, width - leftW - 6) : width - 3;
82
+ let displayLeft = leftText;
83
+ let displayRight = rightText;
84
+ if (leftW > maxLeft)
85
+ displayLeft = truncateToWidth(leftText, maxLeft, "");
86
+ if (rightW > maxRight)
87
+ displayRight = truncateToWidth(rightText, maxRight, "");
88
+ const leftSegment = displayLeft
89
+ ? `${getGlow("─")} ${displayLeft} `
90
+ : getGlow("─");
91
+ const rightSegment = displayRight
92
+ ? ` ${displayRight} ${getGlow("─")}`
93
+ : getGlow("─");
94
+ const used = visibleWidth(leftSegment) + visibleWidth(rightSegment);
95
+ const middleWidth = Math.max(0, width - used);
96
+ const middle = getGlow("─".repeat(middleWidth));
97
+ const embedded = `${leftSegment}${middle}${rightSegment}`;
98
+ const result = [...lines];
99
+ result[bottomIdx] = truncateToWidth(embedded, width, "");
100
+ if (visibleWidth(embedded) !== width)
101
+ result[bottomIdx] = embedded;
102
+ return result;
103
+ }
104
+ function embedTopRightBorder(lines, width, rightText, getGlow) {
105
+ if (!rightText || lines.length === 0)
106
+ return lines;
107
+ // Top border is always lines[0] — unless it's a scroll indicator, treat similarly
108
+ const top = lines[0] ?? "";
109
+ const plainTop = stripAnsi(top);
110
+ // If top is scroll indicator, don't embed right — keep glow recolor only
111
+ if (/^─── [↑↓] \d+ more/.test(plainTop))
112
+ return lines;
113
+ const rightW = visibleWidth(rightText);
114
+ // left label already embedded by applyModelInfo — its visible width is width - middle - right
115
+ // We need to truncate right if too wide, preserving at least 10 chars for left
116
+ const maxRight = Math.max(0, width - 12);
117
+ let displayRight = rightText;
118
+ if (rightW > maxRight)
119
+ displayRight = truncateToWidth(rightText, maxRight, "");
120
+ const displayW = visibleWidth(displayRight);
121
+ // Rebuild top: keep existing left-embedded line, replace its right tail
122
+ const existing = lines[0] ?? "";
123
+ // Strip then rebuild: we need to preserve left part and insert right
124
+ // Simple approach: truncate existing to width - displayW - 3, then add " " + displayRight + " " + glow("─")
125
+ const availableForLeft = Math.max(0, width - displayW - 3);
126
+ const leftPart = truncateToWidth(existing, availableForLeft, "");
127
+ // Ensure leftPart ends with glow dash if truncated
128
+ const rightSegment = ` ${displayRight} ${getGlow("─")}`;
129
+ const leftW2 = visibleWidth(leftPart);
130
+ const rightW2 = visibleWidth(rightSegment);
131
+ const middleWidth = Math.max(0, width - leftW2 - rightW2);
132
+ const middle = getGlow("─".repeat(middleWidth));
133
+ const embedded = `${leftPart}${middle}${rightSegment}`;
134
+ const result = [...lines];
135
+ result[0] = truncateToWidth(embedded, width, "");
136
+ if (visibleWidth(embedded) !== width)
137
+ result[0] = embedded;
138
+ return result;
139
+ }
@@ -0,0 +1,53 @@
1
+ export function stressColor(value, warn = 70, danger = 90) {
2
+ if (value >= danger)
3
+ return "error";
4
+ if (value >= warn)
5
+ return "warning";
6
+ return "accent";
7
+ }
8
+ export function cacheHitColor(value) {
9
+ if (value < 30)
10
+ return "error";
11
+ if (value < 70)
12
+ return "warning";
13
+ return "success";
14
+ }
15
+ export function providerColor(provider) {
16
+ switch (provider.toLowerCase()) {
17
+ case "anthropic":
18
+ return "accent";
19
+ case "openai":
20
+ case "openai-codex":
21
+ return "success";
22
+ case "google":
23
+ case "google-vertex":
24
+ return "warning";
25
+ case "amazon-bedrock":
26
+ return "thinkingHigh";
27
+ case "github-copilot":
28
+ return "mdLink";
29
+ case "deepseek":
30
+ return "thinkingLow";
31
+ case "xai":
32
+ case "groq":
33
+ return "error";
34
+ default:
35
+ return "muted";
36
+ }
37
+ }
38
+ export function effortColor(level) {
39
+ switch (level) {
40
+ case "minimal":
41
+ return "thinkingMinimal";
42
+ case "low":
43
+ return "thinkingLow";
44
+ case "medium":
45
+ return "thinkingMedium";
46
+ case "high":
47
+ return "thinkingHigh";
48
+ case "xhigh":
49
+ return "thinkingXhigh";
50
+ default:
51
+ return "thinkingMedium";
52
+ }
53
+ }
package/dist/config.js CHANGED
@@ -63,69 +63,123 @@ function deepMerge(base, override) {
63
63
  }
64
64
  return result;
65
65
  }
66
+ function isBoolean(v) {
67
+ return typeof v === "boolean";
68
+ }
66
69
  function validate(config) {
67
- // workspaceDisplay
68
70
  if (config.workspaceDisplay !== "path" &&
69
71
  config.workspaceDisplay !== "name") {
70
72
  config.workspaceDisplay = DEFAULT_CONFIG.workspaceDisplay;
71
73
  }
72
- // cursorStyle
73
74
  if (config.cursorStyle !== "block" &&
74
75
  config.cursorStyle !== "bar" &&
75
76
  config.cursorStyle !== "underline") {
76
77
  config.cursorStyle = DEFAULT_CONFIG.cursorStyle;
77
78
  }
78
- // icons.mode
79
79
  if (config.icons.mode !== "auto" &&
80
80
  config.icons.mode !== "nerd" &&
81
81
  config.icons.mode !== "ascii") {
82
82
  config.icons.mode = DEFAULT_CONFIG.icons.mode;
83
83
  }
84
+ if (!isBoolean(config.enabled)) {
85
+ config.enabled = DEFAULT_CONFIG.enabled;
86
+ }
87
+ const fs = config.footerSegments;
88
+ const dfs = DEFAULT_CONFIG.footerSegments;
89
+ for (const k of Object.keys(dfs)) {
90
+ if (!isBoolean(fs[k]))
91
+ fs[k] = dfs[k];
92
+ }
93
+ const t = config.telemetry;
94
+ const dt = DEFAULT_CONFIG.telemetry;
95
+ for (const k of Object.keys(dt)) {
96
+ if (!isBoolean(t[k]))
97
+ t[k] = dt[k];
98
+ }
84
99
  return config;
85
100
  }
86
- export function ensureConfigExists() {
87
- const path = getConfigPath();
88
- if (existsSync(path))
89
- return;
90
- try {
91
- const dir = join(path, "..");
92
- if (!existsSync(dir))
93
- mkdirSync(dir, { recursive: true });
94
- writeFileSync(path, JSON.stringify(DEFAULT_CONFIG, null, 2) + "\n", "utf8");
101
+ export class ConfigStore {
102
+ readFile;
103
+ writeFile;
104
+ exists;
105
+ mkdirSyncFn;
106
+ explicitPath;
107
+ listeners = new Set();
108
+ constructor(deps = {}) {
109
+ this.readFile =
110
+ deps.readFile ??
111
+ ((p, enc) => readFileSync(p, enc));
112
+ this.writeFile =
113
+ deps.writeFile ??
114
+ ((p, d, enc) => writeFileSync(p, d, enc));
115
+ this.exists = deps.exists ?? existsSync;
116
+ this.mkdirSyncFn = deps.mkdirSync ?? mkdirSync;
117
+ this.explicitPath = deps.path;
95
118
  }
96
- catch {
97
- // best-effort
119
+ getPath() {
120
+ return this.explicitPath ?? getConfigPath();
98
121
  }
99
- }
100
- export function loadConfig() {
101
- const path = getConfigPath();
102
- if (!existsSync(path)) {
103
- ensureConfigExists();
104
- return structuredClone(DEFAULT_CONFIG);
122
+ get() {
123
+ const p = this.getPath();
124
+ if (!this.exists(p)) {
125
+ try {
126
+ const dir = join(p, "..");
127
+ if (!this.exists(dir))
128
+ this.mkdirSyncFn(dir, { recursive: true });
129
+ this.writeFile(p, JSON.stringify(DEFAULT_CONFIG, null, 2) + "\n", "utf8");
130
+ }
131
+ catch {
132
+ // best-effort
133
+ }
134
+ return structuredClone(DEFAULT_CONFIG);
135
+ }
136
+ try {
137
+ const raw = this.readFile(p, "utf8");
138
+ const parsed = JSON.parse(raw);
139
+ const merged = deepMerge(structuredClone(DEFAULT_CONFIG), parsed);
140
+ return validate(merged);
141
+ }
142
+ catch (err) {
143
+ console.warn(`[pi-skill-desc] config parse error (${p}): ${err instanceof Error ? err.message : String(err)} — using defaults`);
144
+ return structuredClone(DEFAULT_CONFIG);
145
+ }
105
146
  }
106
- try {
107
- const raw = readFileSync(path, "utf8");
108
- const parsed = JSON.parse(raw);
109
- const merged = deepMerge(structuredClone(DEFAULT_CONFIG), parsed);
110
- return validate(merged);
147
+ patch(patch) {
148
+ const prev = this.get();
149
+ const merged = validate(deepMerge(prev, patch));
150
+ const p = this.getPath();
151
+ try {
152
+ const dir = join(p, "..");
153
+ if (!this.exists(dir))
154
+ this.mkdirSyncFn(dir, { recursive: true });
155
+ this.writeFile(p, JSON.stringify(merged, null, 2) + "\n", "utf8");
156
+ }
157
+ catch {
158
+ // best-effort
159
+ }
160
+ for (const fn of this.listeners) {
161
+ try {
162
+ fn(merged, prev);
163
+ }
164
+ catch {
165
+ // subscriber error should not break store
166
+ }
167
+ }
168
+ return merged;
111
169
  }
112
- catch (err) {
113
- console.warn(`[pi-skill-desc] config parse error (${path}): ${err instanceof Error ? err.message : String(err)} — using defaults`);
114
- return structuredClone(DEFAULT_CONFIG);
170
+ subscribe(fn) {
171
+ this.listeners.add(fn);
172
+ return () => this.listeners.delete(fn);
115
173
  }
116
174
  }
175
+ // Default singleton for backward-compat free functions
176
+ const defaultStore = new ConfigStore();
177
+ export function ensureConfigExists() {
178
+ void defaultStore.get();
179
+ }
180
+ export function loadConfig() {
181
+ return defaultStore.get();
182
+ }
117
183
  export function saveConfig(patch) {
118
- const current = loadConfig();
119
- const merged = validate(deepMerge(current, patch));
120
- const path = getConfigPath();
121
- try {
122
- const dir = join(path, "..");
123
- if (!existsSync(dir))
124
- mkdirSync(dir, { recursive: true });
125
- writeFileSync(path, JSON.stringify(merged, null, 2) + "\n", "utf8");
126
- }
127
- catch {
128
- // best-effort
129
- }
130
- return merged;
184
+ return defaultStore.patch(patch);
131
185
  }
@@ -0,0 +1,77 @@
1
+ import { CURSOR_MARKER } from "@earendil-works/pi-tui";
2
+ const CURSOR_STYLE_SEQUENCES = {
3
+ bar: "\x1b[6 q",
4
+ underline: "\x1b[4 q",
5
+ };
6
+ const DEFAULT_CURSOR_STYLE_SEQUENCE = "\x1b[0 q";
7
+ function stripAnsi(s) {
8
+ return s.replace(/\x1b\[[0-9;]*m/g, "");
9
+ }
10
+ function removeSoftwareCursor(line, cursorMarker = "") {
11
+ return line.replace(/\x1b\[7m([\s\S]*?)\x1b\[0m/g, (_match, cursor) => {
12
+ const replacement = `${cursorMarker}${cursor}`;
13
+ cursorMarker = "";
14
+ return replacement;
15
+ });
16
+ }
17
+ function configureCursor(tui, cursorStyle) {
18
+ if (cursorStyle === "block")
19
+ return;
20
+ const setShow = tui.setShowHardwareCursor;
21
+ if (typeof setShow === "function")
22
+ setShow.call(tui, true);
23
+ const seq = CURSOR_STYLE_SEQUENCES[cursorStyle];
24
+ const term = tui
25
+ .terminal;
26
+ if (seq && term && typeof term.write === "function")
27
+ term.write(seq);
28
+ }
29
+ /**
30
+ * CursorPolicy — owns cursor style + hardware cursor juggling behind one seam.
31
+ * Previously tangled inside TrackingEditor; now a deep collaborator injected
32
+ * into the editor. Interface is the test surface.
33
+ */
34
+ export class CursorPolicy {
35
+ tui;
36
+ style = "block";
37
+ previewHardwareCursor = false;
38
+ constructor(tui) {
39
+ this.tui = tui;
40
+ }
41
+ getStyle() {
42
+ return this.style;
43
+ }
44
+ setStyle(style) {
45
+ const changed = style !== this.style;
46
+ this.previewHardwareCursor = style !== "block";
47
+ this.style = style;
48
+ if (changed) {
49
+ const tuiAny = this.tui;
50
+ if (style === "block") {
51
+ if (tuiAny.terminal)
52
+ tuiAny.terminal.write(DEFAULT_CURSOR_STYLE_SEQUENCE);
53
+ if (typeof tuiAny.setShowHardwareCursor === "function")
54
+ tuiAny.setShowHardwareCursor(false);
55
+ }
56
+ else {
57
+ configureCursor(this.tui, style);
58
+ }
59
+ }
60
+ }
61
+ // Called by TrackingEditor.renderBase — removes software cursor and injects hardware marker when needed
62
+ mapLines(lines, isFocused) {
63
+ if (this.style === "block")
64
+ return lines;
65
+ let cursorMarker = this.previewHardwareCursor && !isFocused ? CURSOR_MARKER : "";
66
+ if (isFocused)
67
+ this.previewHardwareCursor = false;
68
+ return lines.map((line) => {
69
+ const rendered = removeSoftwareCursor(line, cursorMarker);
70
+ if (rendered !== line)
71
+ cursorMarker = "";
72
+ return rendered;
73
+ });
74
+ }
75
+ }
76
+ export { configureCursor, removeSoftwareCursor };
77
+ export const __testing = { stripAnsi };
@@ -0,0 +1,70 @@
1
+ import { readGitStatus } from "./git.js";
2
+ import { readRuntimeInfo } from "./runtime.js";
3
+ import { createInitialState } from "./state.js";
4
+ /**
5
+ * FooterController — deep module owning FooterState + git/runtime polling behind one seam.
6
+ * Previously FooterState lived in index.ts and callers had to orchestrate readGitStatus/readRuntimeInfo
7
+ * and merge into state. Now all that hides behind Controller.
8
+ *
9
+ * Interface is the test surface: new FooterController({ getCwd }) → { getState, refresh, setWorking, setDone }
10
+ * The rendering adapter (setFooter vs setWidget) is also hidden inside install path (two adapters → real seam).
11
+ */
12
+ export class FooterController {
13
+ state = createInitialState();
14
+ getCwd;
15
+ constructor(opts) {
16
+ this.getCwd = opts.getCwd;
17
+ if (opts.initialState)
18
+ this.state = opts.initialState;
19
+ }
20
+ getState() {
21
+ return this.state;
22
+ }
23
+ setWorking(since) {
24
+ this.state = {
25
+ ...this.state,
26
+ workingSince: since,
27
+ lastDoneIn: since === undefined ? this.state.lastDoneIn : undefined,
28
+ };
29
+ }
30
+ setDone(doneIn) {
31
+ this.state = { ...this.state, lastDoneIn: doneIn, workingSince: undefined };
32
+ }
33
+ async refreshGit() {
34
+ try {
35
+ const cwd = this.getCwd();
36
+ const git = await readGitStatus(cwd);
37
+ this.state = { ...this.state, git };
38
+ }
39
+ catch {
40
+ // best-effort
41
+ }
42
+ }
43
+ async refreshRuntime() {
44
+ try {
45
+ const cwd = this.getCwd();
46
+ const runtime = await readRuntimeInfo(cwd);
47
+ this.state = { ...this.state, runtime };
48
+ }
49
+ catch {
50
+ // best-effort
51
+ }
52
+ }
53
+ async refreshAll() {
54
+ await this.refreshGit();
55
+ await this.refreshRuntime();
56
+ }
57
+ }
58
+ /**
59
+ * createFooter — deep public factory.
60
+ * Owns polling and state; callers supply only getCwd/getConfig/getContextUsage.
61
+ * Keeps renderFooter pure as internal seam.
62
+ */
63
+ export function createFooter(opts) {
64
+ const ctrl = new FooterController({ getCwd: opts.getCwd });
65
+ return {
66
+ controller: ctrl,
67
+ getState: () => ctrl.getState(),
68
+ refresh: () => ctrl.refreshAll(),
69
+ };
70
+ }
package/dist/footer.js CHANGED
@@ -1,7 +1,10 @@
1
1
  import { truncateToWidth, visibleWidth, wrapTextWithAnsi, } from "@earendil-works/pi-tui";
2
2
  import { getUsageTotals } from "./state.js";
3
- import { resolveGlyphs, resolveIconMode, runtimeSymbol } from "./icons.js";
4
- import { alignRight, basenamePath, cacheHitColor, fitSegmentsByPriority, fmtTokens, formatCwd, formatDuration, formatProviderLabel, sanitizeStatus, stressColor, truncateBranch, truncatePath, } from "./utils.js";
3
+ import { resolveGlyphs, runtimeSymbol } from "./icons.js";
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";
7
+ import { fmtTokens, formatDuration, sanitizeStatus } from "./format.js";
5
8
  function renderBar(theme, pct, barWidth, ascii) {
6
9
  const filled = Math.max(0, Math.min(barWidth, Math.round((pct / 100) * barWidth)));
7
10
  const empty = barWidth - filled;
@@ -13,6 +16,18 @@ function renderBar(theme, pct, barWidth, ascii) {
13
16
  theme.fg("dim", emptyCell.repeat(empty)) +
14
17
  theme.fg("dim", "]"));
15
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
+ }
16
31
  function renderGitSegment(theme, git, glyphs, segments, maxBranchLen = 20) {
17
32
  const parts = [];
18
33
  if (segments.gitBranch) {
@@ -87,13 +102,6 @@ export function renderFooter(width, state, config, theme, ctx) {
87
102
  cost: 0,
88
103
  latestCacheHitRate: undefined,
89
104
  };
90
- const meta = ctx.getModelMeta
91
- ? ctx.getModelMeta()
92
- : {
93
- provider: formatProviderLabel(ctx.model?.provider),
94
- model: ctx.model?.name ?? ctx.model?.id ?? "no-model",
95
- effort: undefined,
96
- };
97
105
  const leftParts = [];
98
106
  if (segments.cwd) {
99
107
  const maxCwd = Math.min(30, Math.max(10, Math.floor(width * 0.4)));
@@ -151,35 +159,14 @@ export function renderFooter(width, state, config, theme, ctx) {
151
159
  }
152
160
  }
153
161
  if (segments.cost) {
154
- stats.push(theme.fg("warning", `${glyphs.cost} $${totals.cost.toFixed(3)}`));
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}`;
165
+ stats.push(theme.fg("warning", costText));
155
166
  }
156
167
  const statsBlock = stats.join(` ${theme.fg("dim", "|")} `);
157
- // Context
158
- let contextText = "";
159
- let contextCompact;
160
- if (segments.context) {
161
- const contextUsage = ctx.contextUsage;
162
- const contextWindow = contextUsage?.contextWindow ?? 0;
163
- if (contextWindow > 0) {
164
- const contextPct = contextUsage?.percent ?? 0;
165
- const pctText = theme.fg(stressColor(contextPct), `${contextPct.toFixed(1)}%`);
166
- const contextTokens = contextUsage?.tokens ?? 0;
167
- const ctxText = `${theme.fg("text", fmtTokens(contextTokens))}${theme.fg("dim", "/")}${theme.fg("text", fmtTokens(contextWindow))}`;
168
- const contextIcon = theme.fg(stressColor(contextPct), glyphs.context);
169
- const reserved = visibleWidth(contextIcon) +
170
- visibleWidth(pctText) +
171
- visibleWidth(ctxText) +
172
- 7;
173
- const barWidth = Math.max(4, Math.min(12, width - reserved));
174
- contextText = `${contextIcon} ${renderBar(theme, contextPct, barWidth, resolveIconMode(config.icons.mode) === "ascii")} ${pctText} ${theme.fg("dim", "·")} ${ctxText}`;
175
- const compact = `${theme.fg(stressColor(contextPct), glyphs.context)} ${theme.fg(stressColor(contextPct), `${contextPct.toFixed(1)}%`)}`;
176
- if (visibleWidth(compact) < visibleWidth(contextText))
177
- contextCompact = compact;
178
- }
179
- }
180
- // Tokens right next to context bar (user request): combine them as single right block
181
- const rightBlock = [statsBlock, contextText].filter(Boolean).join(" ");
182
- const rightCompact = statsBlock && contextCompact ? `${statsBlock} ${contextCompact}` : statsBlock || contextCompact;
168
+ const rightBlock = statsBlock;
169
+ const rightCompact = statsBlock;
183
170
  const allParts = [...leftParts];
184
171
  if (rightBlock) {
185
172
  allParts.push({