Footer shows full session stats after compaction

FooterComponent now iterates over all session entries for cumulative
token usage and cost, not just post-compaction messages.

fixes #322
This commit is contained in:
Mario Zechner 2026-01-01 00:37:59 +01:00
parent a2afa490f1
commit 7369128b3a
3 changed files with 33 additions and 38 deletions

View file

@ -199,6 +199,7 @@ Total color count increased from 46 to 50. See [docs/theme.md](docs/theme.md) fo
### Fixed ### Fixed
- **Footer shows full session stats**: Token usage and cost now include all messages, not just those after compaction. ([#322](https://github.com/badlogic/pi-mono/issues/322))
- **Status messages spam chat log**: Rapidly changing settings (e.g., thinking level via Shift+Tab) would add multiple status lines. Sequential status updates now coalesce into a single line. ([#365](https://github.com/badlogic/pi-mono/pull/365) by [@paulbettner](https://github.com/paulbettner)) - **Status messages spam chat log**: Rapidly changing settings (e.g., thinking level via Shift+Tab) would add multiple status lines. Sequential status updates now coalesce into a single line. ([#365](https://github.com/badlogic/pi-mono/pull/365) by [@paulbettner](https://github.com/paulbettner))
- **Toggling thinking blocks during streaming shows nothing**: Pressing Ctrl+T while streaming would hide the current message until streaming completed. - **Toggling thinking blocks during streaming shows nothing**: Pressing Ctrl+T while streaming would hide the current message until streaming completed.
- **Resuming session resets thinking level to off**: Initial model and thinking level were not saved to session file, causing `--resume`/`--continue` to default to `off`. ([#342](https://github.com/badlogic/pi-mono/issues/342) by [@aliou](https://github.com/aliou)) - **Resuming session resets thinking level to off**: Initial model and thinking level were not saved to session file, causing `--resume`/`--continue` to default to `off`. ([#342](https://github.com/badlogic/pi-mono/issues/342) by [@aliou](https://github.com/aliou))

View file

@ -1,9 +1,8 @@
import type { AgentState } from "@mariozechner/pi-agent-core";
import type { AssistantMessage } from "@mariozechner/pi-ai"; import type { AssistantMessage } from "@mariozechner/pi-ai";
import { type Component, visibleWidth } from "@mariozechner/pi-tui"; import { type Component, visibleWidth } from "@mariozechner/pi-tui";
import { existsSync, type FSWatcher, readFileSync, watch } from "fs"; import { existsSync, type FSWatcher, readFileSync, watch } from "fs";
import { dirname, join } from "path"; import { dirname, join } from "path";
import type { ModelRegistry } from "../../../core/model-registry.js"; import type { AgentSession } from "../../../core/agent-session.js";
import { theme } from "../theme/theme.js"; import { theme } from "../theme/theme.js";
/** /**
@ -30,16 +29,14 @@ function findGitHeadPath(): string | null {
* Footer component that shows pwd, token stats, and context usage * Footer component that shows pwd, token stats, and context usage
*/ */
export class FooterComponent implements Component { export class FooterComponent implements Component {
private state: AgentState; private session: AgentSession;
private modelRegistry: ModelRegistry;
private cachedBranch: string | null | undefined = undefined; // undefined = not checked yet, null = not in git repo, string = branch name private cachedBranch: string | null | undefined = undefined; // undefined = not checked yet, null = not in git repo, string = branch name
private gitWatcher: FSWatcher | null = null; private gitWatcher: FSWatcher | null = null;
private onBranchChange: (() => void) | null = null; private onBranchChange: (() => void) | null = null;
private autoCompactEnabled: boolean = true; private autoCompactEnabled: boolean = true;
constructor(state: AgentState, modelRegistry: ModelRegistry) { constructor(session: AgentSession) {
this.state = state; this.session = session;
this.modelRegistry = modelRegistry;
} }
setAutoCompactEnabled(enabled: boolean): void { setAutoCompactEnabled(enabled: boolean): void {
@ -89,10 +86,6 @@ export class FooterComponent implements Component {
} }
} }
updateState(state: AgentState): void {
this.state = state;
}
invalidate(): void { invalidate(): void {
// Invalidate cached branch so it gets re-read on next render // Invalidate cached branch so it gets re-read on next render
this.cachedBranch = undefined; this.cachedBranch = undefined;
@ -132,26 +125,27 @@ export class FooterComponent implements Component {
} }
render(width: number): string[] { render(width: number): string[] {
// Calculate cumulative usage from all assistant messages const state = this.session.state;
// Calculate cumulative usage from ALL session entries (not just post-compaction messages)
let totalInput = 0; let totalInput = 0;
let totalOutput = 0; let totalOutput = 0;
let totalCacheRead = 0; let totalCacheRead = 0;
let totalCacheWrite = 0; let totalCacheWrite = 0;
let totalCost = 0; let totalCost = 0;
for (const message of this.state.messages) { for (const entry of this.session.sessionManager.getEntries()) {
if (message.role === "assistant") { if (entry.type === "message" && entry.message.role === "assistant") {
const assistantMsg = message as AssistantMessage; totalInput += entry.message.usage.input;
totalInput += assistantMsg.usage.input; totalOutput += entry.message.usage.output;
totalOutput += assistantMsg.usage.output; totalCacheRead += entry.message.usage.cacheRead;
totalCacheRead += assistantMsg.usage.cacheRead; totalCacheWrite += entry.message.usage.cacheWrite;
totalCacheWrite += assistantMsg.usage.cacheWrite; totalCost += entry.message.usage.cost.total;
totalCost += assistantMsg.usage.cost.total;
} }
} }
// Get last assistant message for context percentage calculation (skip aborted messages) // Get last assistant message for context percentage calculation (skip aborted messages)
const lastAssistantMessage = this.state.messages const lastAssistantMessage = state.messages
.slice() .slice()
.reverse() .reverse()
.find((m) => m.role === "assistant" && m.stopReason !== "aborted") as AssistantMessage | undefined; .find((m) => m.role === "assistant" && m.stopReason !== "aborted") as AssistantMessage | undefined;
@ -163,7 +157,7 @@ export class FooterComponent implements Component {
lastAssistantMessage.usage.cacheRead + lastAssistantMessage.usage.cacheRead +
lastAssistantMessage.usage.cacheWrite lastAssistantMessage.usage.cacheWrite
: 0; : 0;
const contextWindow = this.state.model?.contextWindow || 0; const contextWindow = state.model?.contextWindow || 0;
const contextPercentValue = contextWindow > 0 ? (contextTokens / contextWindow) * 100 : 0; const contextPercentValue = contextWindow > 0 ? (contextTokens / contextWindow) * 100 : 0;
const contextPercent = contextPercentValue.toFixed(1); const contextPercent = contextPercentValue.toFixed(1);
@ -209,7 +203,7 @@ export class FooterComponent implements Component {
if (totalCacheWrite) statsParts.push(`W${formatTokens(totalCacheWrite)}`); if (totalCacheWrite) statsParts.push(`W${formatTokens(totalCacheWrite)}`);
// Show cost with "(sub)" indicator if using OAuth subscription // Show cost with "(sub)" indicator if using OAuth subscription
const usingSubscription = this.state.model ? this.modelRegistry.isUsingOAuth(this.state.model) : false; const usingSubscription = state.model ? this.session.modelRegistry.isUsingOAuth(state.model) : false;
if (totalCost || usingSubscription) { if (totalCost || usingSubscription) {
const costStr = `$${totalCost.toFixed(3)}${usingSubscription ? " (sub)" : ""}`; const costStr = `$${totalCost.toFixed(3)}${usingSubscription ? " (sub)" : ""}`;
statsParts.push(costStr); statsParts.push(costStr);
@ -231,12 +225,12 @@ export class FooterComponent implements Component {
let statsLeft = statsParts.join(" "); let statsLeft = statsParts.join(" ");
// Add model name on the right side, plus thinking level if model supports it // Add model name on the right side, plus thinking level if model supports it
const modelName = this.state.model?.id || "no-model"; const modelName = state.model?.id || "no-model";
// Add thinking level hint if model supports reasoning and thinking is enabled // Add thinking level hint if model supports reasoning and thinking is enabled
let rightSide = modelName; let rightSide = modelName;
if (this.state.model?.reasoning) { if (state.model?.reasoning) {
const thinkingLevel = this.state.thinkingLevel || "off"; const thinkingLevel = state.thinkingLevel || "off";
if (thinkingLevel !== "off") { if (thinkingLevel !== "off") {
rightSide = `${modelName}${thinkingLevel}`; rightSide = `${modelName}${thinkingLevel}`;
} }

View file

@ -6,7 +6,7 @@
import * as fs from "node:fs"; import * as fs from "node:fs";
import * as os from "node:os"; import * as os from "node:os";
import * as path from "node:path"; import * as path from "node:path";
import type { AgentMessage, AgentState } from "@mariozechner/pi-agent-core"; import type { AgentMessage } from "@mariozechner/pi-agent-core";
import type { AssistantMessage, Message, OAuthProvider } from "@mariozechner/pi-ai"; import type { AssistantMessage, Message, OAuthProvider } from "@mariozechner/pi-ai";
import type { SlashCommand } from "@mariozechner/pi-tui"; import type { SlashCommand } from "@mariozechner/pi-tui";
import { import {
@ -165,7 +165,7 @@ export class InteractiveMode {
this.editor = new CustomEditor(getEditorTheme()); this.editor = new CustomEditor(getEditorTheme());
this.editorContainer = new Container(); this.editorContainer = new Container();
this.editorContainer.addChild(this.editor); this.editorContainer.addChild(this.editor);
this.footer = new FooterComponent(session.state, session.modelRegistry); this.footer = new FooterComponent(session);
this.footer.setAutoCompactEnabled(session.autoCompactionEnabled); this.footer.setAutoCompactEnabled(session.autoCompactionEnabled);
// Define slash commands for autocomplete // Define slash commands for autocomplete
@ -806,16 +806,16 @@ export class InteractiveMode {
private subscribeToAgent(): void { private subscribeToAgent(): void {
this.unsubscribe = this.session.subscribe(async (event) => { this.unsubscribe = this.session.subscribe(async (event) => {
await this.handleEvent(event, this.session.state); await this.handleEvent(event);
}); });
} }
private async handleEvent(event: AgentSessionEvent, state: AgentState): Promise<void> { private async handleEvent(event: AgentSessionEvent): Promise<void> {
if (!this.isInitialized) { if (!this.isInitialized) {
await this.init(); await this.init();
} }
this.footer.updateState(state); this.footer.invalidate();
switch (event.type) { switch (event.type) {
case "agent_start": case "agent_start":
@ -1013,7 +1013,7 @@ export class InteractiveMode {
summary: event.result.summary, summary: event.result.summary,
timestamp: Date.now(), timestamp: Date.now(),
}); });
this.footer.updateState(this.session.state); this.footer.invalidate();
} }
this.ui.requestRender(); this.ui.requestRender();
break; break;
@ -1173,7 +1173,7 @@ export class InteractiveMode {
this.pendingTools.clear(); this.pendingTools.clear();
if (options.updateFooter) { if (options.updateFooter) {
this.footer.updateState(this.session.state); this.footer.invalidate();
this.updateEditorBorderColor(); this.updateEditorBorderColor();
} }
@ -1320,7 +1320,7 @@ export class InteractiveMode {
if (newLevel === undefined) { if (newLevel === undefined) {
this.showStatus("Current model does not support thinking"); this.showStatus("Current model does not support thinking");
} else { } else {
this.footer.updateState(this.session.state); this.footer.invalidate();
this.updateEditorBorderColor(); this.updateEditorBorderColor();
this.showStatus(`Thinking level: ${newLevel}`); this.showStatus(`Thinking level: ${newLevel}`);
} }
@ -1333,7 +1333,7 @@ export class InteractiveMode {
const msg = this.session.scopedModels.length > 0 ? "Only one model in scope" : "Only one model available"; const msg = this.session.scopedModels.length > 0 ? "Only one model in scope" : "Only one model available";
this.showStatus(msg); this.showStatus(msg);
} else { } else {
this.footer.updateState(this.session.state); this.footer.invalidate();
this.updateEditorBorderColor(); this.updateEditorBorderColor();
const thinkingStr = const thinkingStr =
result.model.reasoning && result.thinkingLevel !== "off" ? ` (thinking: ${result.thinkingLevel})` : ""; result.model.reasoning && result.thinkingLevel !== "off" ? ` (thinking: ${result.thinkingLevel})` : "";
@ -1530,7 +1530,7 @@ export class InteractiveMode {
}, },
onThinkingLevelChange: (level) => { onThinkingLevelChange: (level) => {
this.session.setThinkingLevel(level); this.session.setThinkingLevel(level);
this.footer.updateState(this.session.state); this.footer.invalidate();
this.updateEditorBorderColor(); this.updateEditorBorderColor();
}, },
onThemeChange: (themeName) => { onThemeChange: (themeName) => {
@ -1583,7 +1583,7 @@ export class InteractiveMode {
async (model) => { async (model) => {
try { try {
await this.session.setModel(model); await this.session.setModel(model);
this.footer.updateState(this.session.state); this.footer.invalidate();
this.updateEditorBorderColor(); this.updateEditorBorderColor();
done(); done();
this.showStatus(`Model: ${model.id}`); this.showStatus(`Model: ${model.id}`);
@ -2172,7 +2172,7 @@ export class InteractiveMode {
const msg = createCompactionSummaryMessage(result.summary, result.tokensBefore, new Date().toISOString()); const msg = createCompactionSummaryMessage(result.summary, result.tokensBefore, new Date().toISOString());
this.addMessageToChat(msg); this.addMessageToChat(msg);
this.footer.updateState(this.session.state); this.footer.invalidate();
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
if (message === "Compaction cancelled" || (error instanceof Error && error.name === "AbortError")) { if (message === "Compaction cancelled" || (error instanceof Error && error.name === "AbortError")) {