pi-open-tui 0.2.15 → 0.2.16

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.
Binary file
@@ -1,328 +1,329 @@
1
- import type { ExtensionContext, Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
2
- import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
3
- import type { OpenTuiConfig } from "./config.ts";
4
- import type { IconGlyphs } from "./icons.ts";
5
- import { resolveGlyphs, resolveIconMode, runtimeSymbol } from "./icons.ts";
6
- import type { GitStatus } from "./git.ts";
7
- import type { RuntimeInfo } from "./runtime.ts";
8
- import {
9
- alignRight,
10
- basenamePath,
11
- cacheHitColor,
12
- effortColor,
13
- fitSegmentsByPriority,
14
- fmtTokens,
15
- formatCwd,
16
- formatDuration,
17
- formatProviderLabel,
18
- providerColor,
19
- sanitizeStatus,
20
- stressColor,
21
- truncateBranch,
22
- truncatePath,
23
- type PrioritizedSegment,
24
- } from "./utils.ts";
25
- import type { FooterState, ModelMeta, UsageTotals } from "./state.ts";
26
- import { getUsageTotals } from "./state.ts";
27
-
28
- function renderBar(theme: Theme, pct: number, barWidth: number, ascii: boolean): string {
29
- const filled = Math.max(0, Math.min(barWidth, Math.round((pct / 100) * barWidth)));
30
- const empty = barWidth - filled;
31
- const color = stressColor(pct);
32
- const filledCell = ascii ? "#" : "█";
33
- const emptyCell = ascii ? "-" : "";
34
- return (
35
- theme.fg("dim", "[") +
36
- theme.fg(color, filledCell.repeat(filled)) +
37
- theme.fg("dim", emptyCell.repeat(empty)) +
38
- theme.fg("dim", "]")
39
- );
40
- }
41
-
42
- /** Compact context form: icon + percentage, no bar or token counts. */
43
- function renderContextCompact(theme: Theme, ctx: ExtensionContext, glyphs: IconGlyphs): string {
44
- const contextUsage = ctx.getContextUsage();
45
- const contextWindow = contextUsage?.contextWindow ?? ctx.model?.contextWindow ?? 0;
46
- if (contextWindow <= 0) return "";
47
- const contextPct = contextUsage?.percent ?? 0;
48
- return `${theme.fg(stressColor(contextPct), glyphs.context)} ${theme.fg(stressColor(contextPct), `${contextPct.toFixed(1)}%`)}`;
49
- }
50
-
51
- function renderGitSegment(
52
- theme: Theme,
53
- git: GitStatus,
54
- glyphs: IconGlyphs,
55
- segments: OpenTuiConfig["footerSegments"],
56
- maxBranchLen = 20,
57
- ): string {
58
- const parts: string[] = [];
59
- if (segments.gitBranch) {
60
- if (git.branch) {
61
- parts.push(theme.fg("mdLink", glyphs.git));
62
- parts.push(theme.fg("mdLink", truncateBranch(git.branch, maxBranchLen)));
63
- } else if (git.commit?.detached) {
64
- parts.push(theme.fg("warning", glyphs.git));
65
- parts.push(theme.fg("warning", "HEAD"));
66
- if (git.commit.oid) {
67
- const shortHash = git.commit.oid.slice(0, 7);
68
- const tag = git.commit.tag ? ` ${git.commit.tag}` : "";
69
- parts.push(theme.fg("dim", `${shortHash}${tag}`));
70
- }
71
- }
72
- }
73
-
74
- if (segments.gitStatus) {
75
- const statusIcons: string[] = [];
76
- // ponytail: always show count — `!1` not `!`, so 1 vs 100 is distinguishable.
77
- const addStatus = (count: number, glyph: string, color: ThemeColor) => {
78
- if (count > 0) statusIcons.push(theme.fg(color, `${glyph}${count}`));
79
- };
80
- addStatus(git.conflicted, glyphs.conflicted, "error");
81
- addStatus(git.deleted, glyphs.deleted, "error");
82
- addStatus(git.modified, glyphs.modified, "warning");
83
- addStatus(git.renamed, glyphs.renamed, "warning");
84
- addStatus(git.staged, glyphs.staged, "success");
85
- addStatus(git.untracked, glyphs.untracked, "muted");
86
- addStatus(git.stashed, glyphs.stashed, "muted");
87
-
88
- if (git.ahead > 0 && git.behind > 0) {
89
- statusIcons.push(theme.fg("warning", `${glyphs.diverged}${git.ahead}/${git.behind}`));
90
- } else if (git.ahead > 0) {
91
- statusIcons.push(theme.fg("success", `${glyphs.ahead}${git.ahead}`));
92
- } else if (git.behind > 0) {
93
- statusIcons.push(theme.fg("warning", `${glyphs.behind}${git.behind}`));
94
- }
95
-
96
- const statusBlock = statusIcons.join(" ");
97
- if (statusBlock) {
98
- parts.push(`${theme.fg("dim", "[")}${statusBlock}${theme.fg("dim", "]")}`);
99
- }
100
- }
101
-
102
- return parts.join(" ");
103
- }
104
-
105
- function renderRuntimeSegment(
106
- theme: Theme,
107
- runtime: RuntimeInfo | null,
108
- iconMode: OpenTuiConfig["icons"]["mode"],
109
- ): string {
110
- if (!runtime) return "";
111
- const symbol = theme.fg("success", runtimeSymbol(runtime.name, iconMode));
112
- const version = runtime.version ? theme.fg("muted", runtime.version) : "";
113
- const label = [symbol, version].filter(Boolean).join(" ");
114
- return label;
115
- }
116
-
117
- function renderTimerSegment(theme: Theme, state: FooterState, glyphs: IconGlyphs): string {
118
- if (state.workingSince !== undefined) {
119
- return `${theme.fg("accent", glyphs.working)} ${theme.fg("dim", "working")} ${theme.fg("accent", formatDuration(Date.now() - state.workingSince))}`;
120
- }
121
- if (state.lastDoneIn !== undefined) {
122
- return `${theme.fg("success", glyphs.done)} ${theme.fg("success", "done")} ${theme.fg("text", formatDuration(state.lastDoneIn))}`;
123
- }
124
- return "";
125
- }
126
-
127
- function renderContextBar(
128
- theme: Theme,
129
- ctx: ExtensionContext,
130
- width: number,
131
- glyphs: IconGlyphs,
132
- iconMode: OpenTuiConfig["icons"]["mode"],
133
- ): string {
134
- const contextUsage = ctx.getContextUsage();
135
- const contextWindow = contextUsage?.contextWindow ?? ctx.model?.contextWindow ?? 0;
136
- const contextTokens = contextUsage?.tokens ?? 0;
137
- const contextPct = contextUsage?.percent ?? 0;
138
-
139
- // ponytail: render 0% bar once we know the window — keeps the right side
140
- // populated instead of collapsing everything left in an empty session.
141
- if (contextWindow <= 0) return "";
142
-
143
- const pctText = theme.fg(stressColor(contextPct), `${contextPct.toFixed(1)}%`);
144
- const ctxText = `${theme.fg("text", fmtTokens(contextTokens))}${theme.fg("dim", "/")}${theme.fg("text", fmtTokens(contextWindow))}`;
145
- const contextIcon = theme.fg(stressColor(contextPct), glyphs.context);
146
- // Cap the bar at `width - reserved` (with a floor of 4) so the full form
147
- // never forces the left segments out before compact/drop logic kicks in.
148
- const reserved = visibleWidth(contextIcon) + visibleWidth(pctText) + visibleWidth(ctxText) + 5 + 2;
149
- const barWidth = Math.max(4, Math.min(12, width - reserved));
150
- return `${contextIcon} ${renderBar(theme, contextPct, barWidth, resolveIconMode(iconMode) === "ascii")} ${pctText} ${theme.fg("dim", "·")} ${ctxText}`;
151
- }
152
-
153
- function renderStatsBlock(
154
- theme: Theme,
155
- totals: UsageTotals,
156
- glyphs: IconGlyphs,
157
- segments: OpenTuiConfig["footerSegments"],
158
- ): string {
159
- const stats: string[] = [];
160
- if (segments.tokens) {
161
- stats.push(theme.fg("accent", `${glyphs.input} ${fmtTokens(totals.input)}`));
162
- stats.push(theme.fg("success", `${glyphs.output} ${fmtTokens(totals.output)}`));
163
- // ponytail: hide cache-hit rate when the provider never reported cache
164
- // tokens avoids a misleading "0%" on providers without prompt caching.
165
- const hasCacheTokens = totals.cacheRead > 0 || totals.cacheWrite > 0;
166
- if (hasCacheTokens && totals.latestCacheHitRate !== undefined) {
167
- stats.push(theme.fg(cacheHitColor(totals.latestCacheHitRate), `${glyphs.cacheHit} ${totals.latestCacheHitRate.toFixed(1)}%`));
168
- }
169
- }
170
- if (segments.cost) {
171
- stats.push(theme.fg("warning", `${glyphs.cost} $${totals.cost.toFixed(3)}`));
172
- }
173
-
174
- return stats.join(` ${theme.fg("dim", "|")} `);
175
- }
176
-
177
- function renderExtensionStatusLines(
178
- theme: Theme,
179
- extensionStatuses: ReadonlyMap<string, string>,
180
- glyphs: IconGlyphs,
181
- width: number,
182
- ): string[] {
183
- const statuses = Array.from(extensionStatuses.entries())
184
- .sort(([a], [b]) => a.localeCompare(b))
185
- .map(([, text]) => sanitizeStatus(text))
186
- .filter((text) => text.length > 0);
187
- if (statuses.length === 0) return [];
188
-
189
- const separator = ` ${theme.fg("dim", "|")} `;
190
- const statusText = statuses.map((status) => theme.fg("muted", status)).join(separator);
191
- const line = `${theme.fg("mdLink", glyphs.extensions)} ${statusText}`;
192
- return wrapTextWithAnsi(line, width);
193
- }
194
-
195
- export interface FooterHooks {
196
- setRequestRender: (fn: (() => void) | undefined) => void;
197
- scheduleGitRefresh: () => void;
198
- }
199
-
200
- export function installFooter(
201
- ctx: ExtensionContext,
202
- getState: () => FooterState,
203
- getConfig: () => OpenTuiConfig,
204
- getModelMeta: () => ModelMeta,
205
- hooks: FooterHooks,
206
- ): () => void {
207
- ctx.ui.setFooter((tui, theme, footerData) => {
208
- hooks.setRequestRender(() => tui.requestRender());
209
- const unsubBranch = footerData.onBranchChange(() => {
210
- hooks.scheduleGitRefresh();
211
- tui.requestRender();
212
- });
213
-
214
- return {
215
- dispose() {
216
- unsubBranch();
217
- hooks.setRequestRender(undefined);
218
- },
219
- invalidate() {},
220
- render(width: number): string[] {
221
- if (width <= 0) return [""];
222
- const state = getState();
223
- const config = getConfig();
224
- const glyphs = resolveGlyphs(config.icons.mode);
225
- const segments = config.footerSegments;
226
- const meta = getModelMeta();
227
-
228
- const totals = getUsageTotals(ctx);
229
-
230
- const leftParts: PrioritizedSegment[] = [];
231
- if (segments.cwd) {
232
- const maxCwd = Math.min(30, Math.max(10, Math.floor(width * 0.4)));
233
- const cwd = formatCwd(ctx.sessionManager.getCwd());
234
- const cwdPrefix = `${theme.fg("mdLink", glyphs.cwd)} `;
235
- const accent = (text: string) => theme.fg("accent", text);
236
- leftParts.push({
237
- text: `${cwdPrefix}${accent(truncatePath(cwd, maxCwd))}`,
238
- compactText: `${cwdPrefix}${accent(truncatePath(basenamePath(cwd), maxCwd))}`,
239
- priority: 0,
240
- truncate: (_text, maxWidth, ellipsis) => {
241
- const pathWidth = maxWidth - visibleWidth(cwdPrefix);
242
- if (pathWidth <= visibleWidth(ellipsis)) {
243
- return truncateToWidth(`${cwdPrefix}${accent(basenamePath(cwd))}`, maxWidth, ellipsis);
244
- }
245
- return `${cwdPrefix}${accent(truncatePath(basenamePath(cwd), pathWidth))}`;
246
- },
247
- });
248
- }
249
- if (segments.sessionName) {
250
- const sessionName = ctx.sessionManager.getSessionName();
251
- if (sessionName) {
252
- leftParts.push({
253
- text: `${theme.fg("dim", glyphs.session)} ${theme.fg("text", truncateToWidth(sessionName, 24, theme.fg("dim", "...")))}`,
254
- priority: 2,
255
- });
256
- }
257
- }
258
- const gitSeg = renderGitSegment(theme, state.git, glyphs, segments);
259
- if (gitSeg) leftParts.push({ text: gitSeg, priority: 3 });
260
- if (segments.runtime) {
261
- const runtimeSeg = renderRuntimeSegment(theme, state.runtime, config.icons.mode);
262
- if (runtimeSeg) leftParts.push({ text: runtimeSeg, priority: 4 });
263
- }
264
- const timerSeg = renderTimerSegment(theme, state, glyphs);
265
- if (timerSeg) leftParts.push({ text: timerSeg, priority: 1 });
266
-
267
- // The context bar competes with the left segments for the same row:
268
- // full bar first, then the compact icon+pct form, then dropped.
269
- let contextText = "";
270
- let contextCompact: string | undefined;
271
- if (segments.context) {
272
- contextText = renderContextBar(theme, ctx, width, glyphs, config.icons.mode);
273
- const compact = renderContextCompact(theme, ctx, glyphs);
274
- if (compact && visibleWidth(compact) < visibleWidth(contextText)) {
275
- contextCompact = compact;
276
- }
277
- }
278
- const allParts: PrioritizedSegment[] = [...leftParts];
279
- if (contextText) {
280
- // ponytail: priority 4 = sheds with runtime, before git/timer/cwd.
281
- allParts.push({ text: contextText, compactText: contextCompact, priority: 4 });
282
- }
283
-
284
- const fitted = fitSegmentsByPriority(allParts, width, theme.fg("dim", "..."));
285
- const fittedContext = contextText ? fitted.pop() ?? "" : "";
286
- const line1 = alignRight(fitted.join(" "), fittedContext, width, theme);
287
-
288
- const modelParts: string[] = [];
289
- modelParts.push(theme.fg("mdLink", glyphs.model));
290
- if (meta.provider && meta.provider !== "Unknown") {
291
- modelParts.push(theme.fg(providerColor(ctx.model?.provider ?? "none"), meta.provider));
292
- }
293
- modelParts.push(theme.fg("text", meta.model));
294
- if (meta.effort && meta.effort !== "off") {
295
- modelParts.push(theme.fg(effortColor(meta.effort), `${glyphs.thinking} ${meta.effort}`));
296
- }
297
- const modelBlock = modelParts.join(theme.fg("dim", " · "));
298
-
299
- const statsBlock = renderStatsBlock(
300
- theme,
301
- totals,
302
- glyphs,
303
- segments,
304
- );
305
-
306
- const line2 = alignRight(modelBlock, statsBlock, width, theme);
307
-
308
- const mainLines = [line1, line2]
309
- .map((line) => truncateToWidth(line, width, theme.fg("dim", "...")));
310
- return segments.extensionStatuses
311
- ? [
312
- ...mainLines,
313
- ...renderExtensionStatusLines(
314
- theme,
315
- footerData.getExtensionStatuses(),
316
- glyphs,
317
- width,
318
- ),
319
- ]
320
- : mainLines;
321
- },
322
- };
323
- });
324
-
325
- return () => {
326
- ctx.ui.setFooter(undefined);
327
- };
328
- }
1
+ import type { ExtensionContext, Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
2
+ import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
3
+ import type { OpenTuiConfig } from "./config.ts";
4
+ import type { IconGlyphs } from "./icons.ts";
5
+ import { resolveGlyphs, resolveIconMode, runtimeSymbol } from "./icons.ts";
6
+ import type { GitStatus } from "./git.ts";
7
+ import type { RuntimeInfo } from "./runtime.ts";
8
+ import {
9
+ alignRight,
10
+ basenamePath,
11
+ cacheHitColor,
12
+ effortColor,
13
+ fitSegmentsByPriority,
14
+ fmtTokens,
15
+ formatCwd,
16
+ formatDuration,
17
+ formatInputBreakdown,
18
+ formatProviderLabel,
19
+ providerColor,
20
+ sanitizeStatus,
21
+ stressColor,
22
+ truncateBranch,
23
+ truncatePath,
24
+ type PrioritizedSegment,
25
+ } from "./utils.ts";
26
+ import type { FooterState, ModelMeta, UsageTotals } from "./state.ts";
27
+ import { getUsageTotals } from "./state.ts";
28
+
29
+ function renderBar(theme: Theme, pct: number, barWidth: number, ascii: boolean): string {
30
+ const filled = Math.max(0, Math.min(barWidth, Math.round((pct / 100) * barWidth)));
31
+ const empty = barWidth - filled;
32
+ const color = stressColor(pct);
33
+ const filledCell = ascii ? "#" : "";
34
+ const emptyCell = ascii ? "-" : "░";
35
+ return (
36
+ theme.fg("dim", "[") +
37
+ theme.fg(color, filledCell.repeat(filled)) +
38
+ theme.fg("dim", emptyCell.repeat(empty)) +
39
+ theme.fg("dim", "]")
40
+ );
41
+ }
42
+
43
+ /** Compact context form: icon + percentage, no bar or token counts. */
44
+ function renderContextCompact(theme: Theme, ctx: ExtensionContext, glyphs: IconGlyphs): string {
45
+ const contextUsage = ctx.getContextUsage();
46
+ const contextWindow = contextUsage?.contextWindow ?? ctx.model?.contextWindow ?? 0;
47
+ if (contextWindow <= 0) return "";
48
+ const contextPct = contextUsage?.percent ?? 0;
49
+ return `${theme.fg(stressColor(contextPct), glyphs.context)} ${theme.fg(stressColor(contextPct), `${contextPct.toFixed(1)}%`)}`;
50
+ }
51
+
52
+ function renderGitSegment(
53
+ theme: Theme,
54
+ git: GitStatus,
55
+ glyphs: IconGlyphs,
56
+ segments: OpenTuiConfig["footerSegments"],
57
+ maxBranchLen = 20,
58
+ ): string {
59
+ const parts: string[] = [];
60
+ if (segments.gitBranch) {
61
+ if (git.branch) {
62
+ parts.push(theme.fg("mdLink", glyphs.git));
63
+ parts.push(theme.fg("mdLink", truncateBranch(git.branch, maxBranchLen)));
64
+ } else if (git.commit?.detached) {
65
+ parts.push(theme.fg("warning", glyphs.git));
66
+ parts.push(theme.fg("warning", "HEAD"));
67
+ if (git.commit.oid) {
68
+ const shortHash = git.commit.oid.slice(0, 7);
69
+ const tag = git.commit.tag ? ` ${git.commit.tag}` : "";
70
+ parts.push(theme.fg("dim", `${shortHash}${tag}`));
71
+ }
72
+ }
73
+ }
74
+
75
+ if (segments.gitStatus) {
76
+ const statusIcons: string[] = [];
77
+ // ponytail: always show count `!1` not `!`, so 1 vs 100 is distinguishable.
78
+ const addStatus = (count: number, glyph: string, color: ThemeColor) => {
79
+ if (count > 0) statusIcons.push(theme.fg(color, `${glyph}${count}`));
80
+ };
81
+ addStatus(git.conflicted, glyphs.conflicted, "error");
82
+ addStatus(git.deleted, glyphs.deleted, "error");
83
+ addStatus(git.modified, glyphs.modified, "warning");
84
+ addStatus(git.renamed, glyphs.renamed, "warning");
85
+ addStatus(git.staged, glyphs.staged, "success");
86
+ addStatus(git.untracked, glyphs.untracked, "muted");
87
+ addStatus(git.stashed, glyphs.stashed, "muted");
88
+
89
+ if (git.ahead > 0 && git.behind > 0) {
90
+ statusIcons.push(theme.fg("warning", `${glyphs.diverged}${git.ahead}/${git.behind}`));
91
+ } else if (git.ahead > 0) {
92
+ statusIcons.push(theme.fg("success", `${glyphs.ahead}${git.ahead}`));
93
+ } else if (git.behind > 0) {
94
+ statusIcons.push(theme.fg("warning", `${glyphs.behind}${git.behind}`));
95
+ }
96
+
97
+ const statusBlock = statusIcons.join(" ");
98
+ if (statusBlock) {
99
+ parts.push(`${theme.fg("dim", "[")}${statusBlock}${theme.fg("dim", "]")}`);
100
+ }
101
+ }
102
+
103
+ return parts.join(" ");
104
+ }
105
+
106
+ function renderRuntimeSegment(
107
+ theme: Theme,
108
+ runtime: RuntimeInfo | null,
109
+ iconMode: OpenTuiConfig["icons"]["mode"],
110
+ ): string {
111
+ if (!runtime) return "";
112
+ const symbol = theme.fg("success", runtimeSymbol(runtime.name, iconMode));
113
+ const version = runtime.version ? theme.fg("muted", runtime.version) : "";
114
+ const label = [symbol, version].filter(Boolean).join(" ");
115
+ return label;
116
+ }
117
+
118
+ function renderTimerSegment(theme: Theme, state: FooterState, glyphs: IconGlyphs): string {
119
+ if (state.workingSince !== undefined) {
120
+ return `${theme.fg("accent", glyphs.working)} ${theme.fg("dim", "working")} ${theme.fg("accent", formatDuration(Date.now() - state.workingSince))}`;
121
+ }
122
+ if (state.lastDoneIn !== undefined) {
123
+ return `${theme.fg("success", glyphs.done)} ${theme.fg("success", "done")} ${theme.fg("text", formatDuration(state.lastDoneIn))}`;
124
+ }
125
+ return "";
126
+ }
127
+
128
+ function renderContextBar(
129
+ theme: Theme,
130
+ ctx: ExtensionContext,
131
+ width: number,
132
+ glyphs: IconGlyphs,
133
+ iconMode: OpenTuiConfig["icons"]["mode"],
134
+ ): string {
135
+ const contextUsage = ctx.getContextUsage();
136
+ const contextWindow = contextUsage?.contextWindow ?? ctx.model?.contextWindow ?? 0;
137
+ const contextTokens = contextUsage?.tokens ?? 0;
138
+ const contextPct = contextUsage?.percent ?? 0;
139
+
140
+ // ponytail: render 0% bar once we know the window — keeps the right side
141
+ // populated instead of collapsing everything left in an empty session.
142
+ if (contextWindow <= 0) return "";
143
+
144
+ const pctText = theme.fg(stressColor(contextPct), `${contextPct.toFixed(1)}%`);
145
+ const ctxText = `${theme.fg("text", fmtTokens(contextTokens))}${theme.fg("dim", "/")}${theme.fg("text", fmtTokens(contextWindow))}`;
146
+ const contextIcon = theme.fg(stressColor(contextPct), glyphs.context);
147
+ // Cap the bar at `width - reserved` (with a floor of 4) so the full form
148
+ // never forces the left segments out before compact/drop logic kicks in.
149
+ const reserved = visibleWidth(contextIcon) + visibleWidth(pctText) + visibleWidth(ctxText) + 5 + 2;
150
+ const barWidth = Math.max(4, Math.min(12, width - reserved));
151
+ return `${contextIcon} ${renderBar(theme, contextPct, barWidth, resolveIconMode(iconMode) === "ascii")} ${pctText} ${theme.fg("dim", "·")} ${ctxText}`;
152
+ }
153
+
154
+ function renderStatsBlock(
155
+ theme: Theme,
156
+ totals: UsageTotals,
157
+ glyphs: IconGlyphs,
158
+ segments: OpenTuiConfig["footerSegments"],
159
+ ): string {
160
+ const stats: string[] = [];
161
+ if (segments.tokens) {
162
+ stats.push(theme.fg("accent", `${glyphs.input} ${formatInputBreakdown(totals.input, totals.cacheRead)}`));
163
+ stats.push(theme.fg("success", `${glyphs.output} ${fmtTokens(totals.output)}`));
164
+ // ponytail: hide cache-hit rate when the provider never reported cache
165
+ // tokens avoids a misleading "0%" on providers without prompt caching.
166
+ const hasCacheTokens = totals.cacheRead > 0 || totals.cacheWrite > 0;
167
+ if (hasCacheTokens && totals.latestCacheHitRate !== undefined) {
168
+ stats.push(theme.fg(cacheHitColor(totals.latestCacheHitRate), `${glyphs.cacheHit} ${totals.latestCacheHitRate.toFixed(1)}%`));
169
+ }
170
+ }
171
+ if (segments.cost) {
172
+ stats.push(theme.fg("warning", `${glyphs.cost} $${totals.cost.toFixed(3)}`));
173
+ }
174
+
175
+ return stats.join(` ${theme.fg("dim", "|")} `);
176
+ }
177
+
178
+ function renderExtensionStatusLines(
179
+ theme: Theme,
180
+ extensionStatuses: ReadonlyMap<string, string>,
181
+ glyphs: IconGlyphs,
182
+ width: number,
183
+ ): string[] {
184
+ const statuses = Array.from(extensionStatuses.entries())
185
+ .sort(([a], [b]) => a.localeCompare(b))
186
+ .map(([, text]) => sanitizeStatus(text))
187
+ .filter((text) => text.length > 0);
188
+ if (statuses.length === 0) return [];
189
+
190
+ const separator = ` ${theme.fg("dim", "|")} `;
191
+ const statusText = statuses.map((status) => theme.fg("muted", status)).join(separator);
192
+ const line = `${theme.fg("mdLink", glyphs.extensions)} ${statusText}`;
193
+ return wrapTextWithAnsi(line, width);
194
+ }
195
+
196
+ export interface FooterHooks {
197
+ setRequestRender: (fn: (() => void) | undefined) => void;
198
+ scheduleGitRefresh: () => void;
199
+ }
200
+
201
+ export function installFooter(
202
+ ctx: ExtensionContext,
203
+ getState: () => FooterState,
204
+ getConfig: () => OpenTuiConfig,
205
+ getModelMeta: () => ModelMeta,
206
+ hooks: FooterHooks,
207
+ ): () => void {
208
+ ctx.ui.setFooter((tui, theme, footerData) => {
209
+ hooks.setRequestRender(() => tui.requestRender());
210
+ const unsubBranch = footerData.onBranchChange(() => {
211
+ hooks.scheduleGitRefresh();
212
+ tui.requestRender();
213
+ });
214
+
215
+ return {
216
+ dispose() {
217
+ unsubBranch();
218
+ hooks.setRequestRender(undefined);
219
+ },
220
+ invalidate() {},
221
+ render(width: number): string[] {
222
+ if (width <= 0) return [""];
223
+ const state = getState();
224
+ const config = getConfig();
225
+ const glyphs = resolveGlyphs(config.icons.mode);
226
+ const segments = config.footerSegments;
227
+ const meta = getModelMeta();
228
+
229
+ const totals = getUsageTotals(ctx);
230
+
231
+ const leftParts: PrioritizedSegment[] = [];
232
+ if (segments.cwd) {
233
+ const maxCwd = Math.min(30, Math.max(10, Math.floor(width * 0.4)));
234
+ const cwd = formatCwd(ctx.sessionManager.getCwd());
235
+ const cwdPrefix = `${theme.fg("mdLink", glyphs.cwd)} `;
236
+ const accent = (text: string) => theme.fg("accent", text);
237
+ leftParts.push({
238
+ text: `${cwdPrefix}${accent(truncatePath(cwd, maxCwd))}`,
239
+ compactText: `${cwdPrefix}${accent(truncatePath(basenamePath(cwd), maxCwd))}`,
240
+ priority: 0,
241
+ truncate: (_text, maxWidth, ellipsis) => {
242
+ const pathWidth = maxWidth - visibleWidth(cwdPrefix);
243
+ if (pathWidth <= visibleWidth(ellipsis)) {
244
+ return truncateToWidth(`${cwdPrefix}${accent(basenamePath(cwd))}`, maxWidth, ellipsis);
245
+ }
246
+ return `${cwdPrefix}${accent(truncatePath(basenamePath(cwd), pathWidth))}`;
247
+ },
248
+ });
249
+ }
250
+ if (segments.sessionName) {
251
+ const sessionName = ctx.sessionManager.getSessionName();
252
+ if (sessionName) {
253
+ leftParts.push({
254
+ text: `${theme.fg("dim", glyphs.session)} ${theme.fg("text", truncateToWidth(sessionName, 24, theme.fg("dim", "...")))}`,
255
+ priority: 2,
256
+ });
257
+ }
258
+ }
259
+ const gitSeg = renderGitSegment(theme, state.git, glyphs, segments);
260
+ if (gitSeg) leftParts.push({ text: gitSeg, priority: 3 });
261
+ if (segments.runtime) {
262
+ const runtimeSeg = renderRuntimeSegment(theme, state.runtime, config.icons.mode);
263
+ if (runtimeSeg) leftParts.push({ text: runtimeSeg, priority: 4 });
264
+ }
265
+ const timerSeg = renderTimerSegment(theme, state, glyphs);
266
+ if (timerSeg) leftParts.push({ text: timerSeg, priority: 1 });
267
+
268
+ // The context bar competes with the left segments for the same row:
269
+ // full bar first, then the compact icon+pct form, then dropped.
270
+ let contextText = "";
271
+ let contextCompact: string | undefined;
272
+ if (segments.context) {
273
+ contextText = renderContextBar(theme, ctx, width, glyphs, config.icons.mode);
274
+ const compact = renderContextCompact(theme, ctx, glyphs);
275
+ if (compact && visibleWidth(compact) < visibleWidth(contextText)) {
276
+ contextCompact = compact;
277
+ }
278
+ }
279
+ const allParts: PrioritizedSegment[] = [...leftParts];
280
+ if (contextText) {
281
+ // ponytail: priority 4 = sheds with runtime, before git/timer/cwd.
282
+ allParts.push({ text: contextText, compactText: contextCompact, priority: 4 });
283
+ }
284
+
285
+ const fitted = fitSegmentsByPriority(allParts, width, theme.fg("dim", "..."));
286
+ const fittedContext = contextText ? fitted.pop() ?? "" : "";
287
+ const line1 = alignRight(fitted.join(" "), fittedContext, width, theme);
288
+
289
+ const modelParts: string[] = [];
290
+ modelParts.push(theme.fg("mdLink", glyphs.model));
291
+ if (meta.provider && meta.provider !== "Unknown") {
292
+ modelParts.push(theme.fg(providerColor(ctx.model?.provider ?? "none"), meta.provider));
293
+ }
294
+ modelParts.push(theme.fg("text", meta.model));
295
+ if (meta.effort && meta.effort !== "off") {
296
+ modelParts.push(theme.fg(effortColor(meta.effort), `${glyphs.thinking} ${meta.effort}`));
297
+ }
298
+ const modelBlock = modelParts.join(theme.fg("dim", " · "));
299
+
300
+ const statsBlock = renderStatsBlock(
301
+ theme,
302
+ totals,
303
+ glyphs,
304
+ segments,
305
+ );
306
+
307
+ const line2 = alignRight(modelBlock, statsBlock, width, theme);
308
+
309
+ const mainLines = [line1, line2]
310
+ .map((line) => truncateToWidth(line, width, theme.fg("dim", "...")));
311
+ return segments.extensionStatuses
312
+ ? [
313
+ ...mainLines,
314
+ ...renderExtensionStatusLines(
315
+ theme,
316
+ footerData.getExtensionStatuses(),
317
+ glyphs,
318
+ width,
319
+ ),
320
+ ]
321
+ : mainLines;
322
+ },
323
+ };
324
+ });
325
+
326
+ return () => {
327
+ ctx.ui.setFooter(undefined);
328
+ };
329
+ }
@@ -46,7 +46,9 @@ export function getUsageTotals(ctx: ExtensionContext): UsageTotals {
46
46
  const input = finiteOrZero(u.input);
47
47
  const cacheRead = finiteOrZero(u.cacheRead);
48
48
  const cacheWrite = finiteOrZero(u.cacheWrite);
49
- totals.input += input;
49
+ // input matches /session's "uncached" total: cacheWrite is billed near full
50
+ // price (fresh content), only cacheRead is discounted repeat content.
51
+ totals.input += input + cacheWrite;
50
52
  totals.output += finiteOrZero(u.output);
51
53
  totals.cacheRead += cacheRead;
52
54
  totals.cacheWrite += cacheWrite;
@@ -11,7 +11,7 @@ import type {
11
11
  } from "@earendil-works/pi-coding-agent";
12
12
  import type { IconMode, TelemetryConfig } from "./config.ts";
13
13
  import { resolveGlyphs } from "./icons.ts";
14
- import { finiteOrZero, fmtTokens, formatDuration } from "./utils.ts";
14
+ import { finiteOrZero, fmtTokens, formatDuration, formatInputBreakdown } from "./utils.ts";
15
15
 
16
16
  const STALL_THRESHOLD_MS = 1000;
17
17
 
@@ -49,6 +49,7 @@ export interface TurnTelemetry {
49
49
  totalMs: number;
50
50
  inputTokens: number;
51
51
  outputTokens: number;
52
+ cacheReadTokens: number;
52
53
  stallMs: number;
53
54
  stallCount: number;
54
55
  rateUsdPerMTokens: number | null;
@@ -190,11 +191,15 @@ export class TurnTelemetryTracker {
190
191
  const endMs = this.now();
191
192
  let inputTokens = 0;
192
193
  let outputTokens = 0;
194
+ let cacheReadTokens = 0;
193
195
  let totalTokens = 0;
194
196
  let costUsd = 0;
195
197
  for (const message of turn.messages) {
196
- inputTokens += finiteOrZero(message.usage?.input);
198
+ // match /session's "uncached" total: cacheWrite is fresh, near-full-price
199
+ // content; only cacheRead is discounted repeat content.
200
+ inputTokens += finiteOrZero(message.usage?.input) + finiteOrZero(message.usage?.cacheWrite);
197
201
  outputTokens += finiteOrZero(message.usage?.output);
202
+ cacheReadTokens += finiteOrZero(message.usage?.cacheRead);
198
203
  totalTokens += finiteOrZero(message.usage?.totalTokens);
199
204
  costUsd += finiteOrZero(message.usage?.cost?.total);
200
205
  }
@@ -211,6 +216,7 @@ export class TurnTelemetryTracker {
211
216
  totalMs: endMs - turn.startMs,
212
217
  inputTokens,
213
218
  outputTokens,
219
+ cacheReadTokens,
214
220
  stallMs: turn.stallMs,
215
221
  stallCount: turn.stallCount,
216
222
  rateUsdPerMTokens: validCost && validTokens
@@ -232,6 +238,7 @@ export class TurnTelemetryTracker {
232
238
 
233
239
  const outputTokens = turns.reduce((sum, turn) => sum + turn.outputTokens, 0);
234
240
  const inputTokens = turns.reduce((sum, turn) => sum + turn.inputTokens, 0);
241
+ const cacheReadTokens = turns.reduce((sum, turn) => sum + turn.cacheReadTokens, 0);
235
242
  const totalTokens = turns.reduce((sum, turn) => sum + turn.totalTokens, 0);
236
243
  const costUsd = turns.reduce((sum, turn) => sum + turn.costUsd, 0);
237
244
  const stallMs = turns.reduce((sum, turn) => sum + turn.stallMs, 0);
@@ -248,6 +255,7 @@ export class TurnTelemetryTracker {
248
255
  totalMs: this.now() - startMs,
249
256
  inputTokens,
250
257
  outputTokens,
258
+ cacheReadTokens,
251
259
  stallMs,
252
260
  stallCount,
253
261
  rateUsdPerMTokens: validRate ? round(costUsd / (totalTokens / 1_000_000), 2) : null,
@@ -282,7 +290,7 @@ export function formatTurnTelemetry(
282
290
  parts.push(theme.fg("success", `${glyphs.done} ${formatTurnDuration(telemetry.totalMs)}`));
283
291
  }
284
292
  if (config.tokens) {
285
- parts.push(theme.fg("accent", `${glyphs.input} ${fmtTokens(telemetry.inputTokens)}`));
293
+ parts.push(theme.fg("accent", `${glyphs.input} ${formatInputBreakdown(telemetry.inputTokens, telemetry.cacheReadTokens)}`));
286
294
  parts.push(theme.fg("success", `${glyphs.output} ${fmtTokens(telemetry.outputTokens)}`));
287
295
  }
288
296
  if (config.stalls && telemetry.stallMs > 0) {
@@ -67,6 +67,13 @@ export function fmtTokens(n: number): string {
67
67
  return `${Math.round(n / 1_000_000)}M`;
68
68
  }
69
69
 
70
+ export function formatInputBreakdown(uncached: number, cacheRead: number): string {
71
+ const total = fmtTokens(uncached + cacheRead);
72
+ return cacheRead > 0
73
+ ? `${total} (U ${fmtTokens(uncached)} + R ${fmtTokens(cacheRead)})`
74
+ : total;
75
+ }
76
+
70
77
  export function formatDuration(ms: number): string {
71
78
  const totalSeconds = Math.max(0, Math.floor(ms / 1000));
72
79
  if (totalSeconds < 60) return `${totalSeconds}s`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-open-tui",
3
- "version": "0.2.15",
3
+ "version": "0.2.16",
4
4
  "description": "A polished TUI for Pi coding agent: animated logo header, Starship-style footer, rounded editor with model metadata, and prompt-box user messages.",
5
5
  "type": "module",
6
6
  "repository": {