diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 82caffbd..189a3ece 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -199,6 +199,7 @@ Total color count increased from 46 to 50. See [docs/theme.md](docs/theme.md) fo ### 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)) - **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)) diff --git a/packages/coding-agent/src/modes/interactive/components/footer.ts b/packages/coding-agent/src/modes/interactive/components/footer.ts index 05e7a766..347404dc 100644 --- a/packages/coding-agent/src/modes/interactive/components/footer.ts +++ b/packages/coding-agent/src/modes/interactive/components/footer.ts @@ -1,9 +1,8 @@ -import type { AgentState } from "@mariozechner/pi-agent-core"; import type { AssistantMessage } from "@mariozechner/pi-ai"; import { type Component, visibleWidth } from "@mariozechner/pi-tui"; import { existsSync, type FSWatcher, readFileSync, watch } from "fs"; 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"; /** @@ -30,16 +29,14 @@ function findGitHeadPath(): string | null { * Footer component that shows pwd, token stats, and context usage */ export class FooterComponent implements Component { - private state: AgentState; - private modelRegistry: ModelRegistry; + private session: AgentSession; private cachedBranch: string | null | undefined = undefined; // undefined = not checked yet, null = not in git repo, string = branch name private gitWatcher: FSWatcher | null = null; private onBranchChange: (() => void) | null = null; private autoCompactEnabled: boolean = true; - constructor(state: AgentState, modelRegistry: ModelRegistry) { - this.state = state; - this.modelRegistry = modelRegistry; + constructor(session: AgentSession) { + this.session = session; } setAutoCompactEnabled(enabled: boolean): void { @@ -89,10 +86,6 @@ export class FooterComponent implements Component { } } - updateState(state: AgentState): void { - this.state = state; - } - invalidate(): void { // Invalidate cached branch so it gets re-read on next render this.cachedBranch = undefined; @@ -132,26 +125,27 @@ export class FooterComponent implements Component { } 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 totalOutput = 0; let totalCacheRead = 0; let totalCacheWrite = 0; let totalCost = 0; - for (const message of this.state.messages) { - if (message.role === "assistant") { - const assistantMsg = message as AssistantMessage; - totalInput += assistantMsg.usage.input; - totalOutput += assistantMsg.usage.output; - totalCacheRead += assistantMsg.usage.cacheRead; - totalCacheWrite += assistantMsg.usage.cacheWrite; - totalCost += assistantMsg.usage.cost.total; + for (const entry of this.session.sessionManager.getEntries()) { + if (entry.type === "message" && entry.message.role === "assistant") { + totalInput += entry.message.usage.input; + totalOutput += entry.message.usage.output; + totalCacheRead += entry.message.usage.cacheRead; + totalCacheWrite += entry.message.usage.cacheWrite; + totalCost += entry.message.usage.cost.total; } } // Get last assistant message for context percentage calculation (skip aborted messages) - const lastAssistantMessage = this.state.messages + const lastAssistantMessage = state.messages .slice() .reverse() .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.cacheWrite : 0; - const contextWindow = this.state.model?.contextWindow || 0; + const contextWindow = state.model?.contextWindow || 0; const contextPercentValue = contextWindow > 0 ? (contextTokens / contextWindow) * 100 : 0; const contextPercent = contextPercentValue.toFixed(1); @@ -209,7 +203,7 @@ export class FooterComponent implements Component { if (totalCacheWrite) statsParts.push(`W${formatTokens(totalCacheWrite)}`); // 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) { const costStr = `$${totalCost.toFixed(3)}${usingSubscription ? " (sub)" : ""}`; statsParts.push(costStr); @@ -231,12 +225,12 @@ export class FooterComponent implements Component { let statsLeft = statsParts.join(" "); // 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 let rightSide = modelName; - if (this.state.model?.reasoning) { - const thinkingLevel = this.state.thinkingLevel || "off"; + if (state.model?.reasoning) { + const thinkingLevel = state.thinkingLevel || "off"; if (thinkingLevel !== "off") { rightSide = `${modelName} • ${thinkingLevel}`; } diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 1190e201..1a48fdf2 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -6,7 +6,7 @@ import * as fs from "node:fs"; import * as os from "node:os"; 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 { SlashCommand } from "@mariozechner/pi-tui"; import { @@ -165,7 +165,7 @@ export class InteractiveMode { this.editor = new CustomEditor(getEditorTheme()); this.editorContainer = new Container(); this.editorContainer.addChild(this.editor); - this.footer = new FooterComponent(session.state, session.modelRegistry); + this.footer = new FooterComponent(session); this.footer.setAutoCompactEnabled(session.autoCompactionEnabled); // Define slash commands for autocomplete @@ -806,16 +806,16 @@ export class InteractiveMode { private subscribeToAgent(): void { 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 { + private async handleEvent(event: AgentSessionEvent): Promise { if (!this.isInitialized) { await this.init(); } - this.footer.updateState(state); + this.footer.invalidate(); switch (event.type) { case "agent_start": @@ -1013,7 +1013,7 @@ export class InteractiveMode { summary: event.result.summary, timestamp: Date.now(), }); - this.footer.updateState(this.session.state); + this.footer.invalidate(); } this.ui.requestRender(); break; @@ -1173,7 +1173,7 @@ export class InteractiveMode { this.pendingTools.clear(); if (options.updateFooter) { - this.footer.updateState(this.session.state); + this.footer.invalidate(); this.updateEditorBorderColor(); } @@ -1320,7 +1320,7 @@ export class InteractiveMode { if (newLevel === undefined) { this.showStatus("Current model does not support thinking"); } else { - this.footer.updateState(this.session.state); + this.footer.invalidate(); this.updateEditorBorderColor(); 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"; this.showStatus(msg); } else { - this.footer.updateState(this.session.state); + this.footer.invalidate(); this.updateEditorBorderColor(); const thinkingStr = result.model.reasoning && result.thinkingLevel !== "off" ? ` (thinking: ${result.thinkingLevel})` : ""; @@ -1530,7 +1530,7 @@ export class InteractiveMode { }, onThinkingLevelChange: (level) => { this.session.setThinkingLevel(level); - this.footer.updateState(this.session.state); + this.footer.invalidate(); this.updateEditorBorderColor(); }, onThemeChange: (themeName) => { @@ -1583,7 +1583,7 @@ export class InteractiveMode { async (model) => { try { await this.session.setModel(model); - this.footer.updateState(this.session.state); + this.footer.invalidate(); this.updateEditorBorderColor(); done(); this.showStatus(`Model: ${model.id}`); @@ -2172,7 +2172,7 @@ export class InteractiveMode { const msg = createCompactionSummaryMessage(result.summary, result.tokensBefore, new Date().toISOString()); this.addMessageToChat(msg); - this.footer.updateState(this.session.state); + this.footer.invalidate(); } catch (error) { const message = error instanceof Error ? error.message : String(error); if (message === "Compaction cancelled" || (error instanceof Error && error.name === "AbortError")) {