From 18a5eca3ae92c2a2278814def4e94f5776b3c5b8 Mon Sep 17 00:00:00 2001 From: Carlos Gutierrez Date: Sun, 15 Feb 2026 12:32:36 -0500 Subject: [PATCH] feat: display detailed session stats on exit with resume command When quitting the CLI, users now see a comprehensive session summary: - Total API time spent and session duration - Total code changes (+additions/-deletions) - Per-model token usage breakdown (input/output/cached) - Resume command with session ID Implementation details: - Extended SessionStats type with apiTimeSpent, apiCallStartTime, and modelUsage - Added startApiCall(), stopApiCall(), and addTokensWithModel() tracking functions - Created session-stats.ts utility with formatters and generateSessionSummary() - Updated TUI exit handler to display formatted stats - Added mouse tracking disable to drainStdin() for cleaner exit - Added modifiedFiles to getState() for exit summary access --- README.md | 3 + docs/CHANGELOG.md | 6 ++ src/commands/components/execute/index.ts | 6 +- src/tui-solid/app.tsx | 18 ++-- src/tui-solid/context/app.tsx | 74 +++++++++++++ src/types/tui.ts | 16 +++ src/utils/core/session-stats.ts | 131 +++++++++++++++++++++++ src/utils/core/terminal.ts | 5 +- 8 files changed, 244 insertions(+), 15 deletions(-) create mode 100644 src/utils/core/session-stats.ts diff --git a/README.md b/README.md index d9bb4cc..1cb7e0a 100644 --- a/README.md +++ b/README.md @@ -465,6 +465,9 @@ bun run lint ## Recent Changes (v0.4.2) +- **Session Stats on Exit**: Displays detailed session summary when quitting: + - Total API time spent, session duration, code changes (+/-) + - Per-model token usage breakdown with resume command - **Pink Purple Theme**: New built-in color theme - **Activity Panel Toggle**: `Ctrl+O` to show/hide the activity panel - **Image Paste Fix**: Fixed race condition where pasted images were silently dropped diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 6e1ac34..535766a 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -11,6 +11,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Pink Purple Theme**: New built-in theme with hot pink primary, purple secondary, and deep magenta accent on a dark plum background - **Activity Panel Toggle**: `Ctrl+O` keybind to show/hide the activity panel (context/tokens, modified files) +- **Session Stats on Exit**: Display detailed session statistics when quitting the CLI + - Total usage estimate and Premium requests + - API time spent and total session time + - Total code changes (+additions/-deletions) + - Breakdown by AI model with token usage (input/output/cached) + - Resume command with session ID for easy session continuation ### Fixed diff --git a/src/commands/components/execute/index.ts b/src/commands/components/execute/index.ts index fc8df9a..11a5d03 100644 --- a/src/commands/components/execute/index.ts +++ b/src/commands/components/execute/index.ts @@ -18,8 +18,6 @@ import { getThinkingMessage } from "@constants/status-messages"; import { enterFullscreen, registerExitHandlers, - exitFullscreen, - clearScreen, drainStdin, } from "@utils/core/terminal"; import { createCallbacks } from "@commands/chat-tui"; @@ -33,9 +31,7 @@ interface ExecuteContext { const createHandleExit = (): (() => void) => (): void => { cleanupPermissionHandler(); - exitFullscreen(); - clearScreen(); - console.log("Goodbye!"); + // Note: Session stats are displayed by the TUI exit handler in app.tsx // Drain stdin to consume pending terminal responses (e.g. DECRQM 997;1n) // before exiting, so they don't echo as garbage text in the shell drainStdin().then(() => process.exit(0)); diff --git a/src/tui-solid/app.tsx b/src/tui-solid/app.tsx index ce22c7c..2dccc4b 100644 --- a/src/tui-solid/app.tsx +++ b/src/tui-solid/app.tsx @@ -18,7 +18,7 @@ import { } from "@services/chat-tui-service"; import { matchesAction } from "@services/keybind-resolver"; import { TERMINAL_RESET } from "@constants/terminal"; -import { formatExitMessage } from "@services/exit-message"; +import { generateSessionSummary } from "@utils/core/session-stats"; import { copyToClipboard } from "@services/clipboard/text-clipboard"; import versionData from "@/version.json"; import { ExitProvider, useExit } from "@tui-solid/context/exit"; @@ -582,14 +582,14 @@ export function tui(options: TuiRenderOptions): Promise { writeSync(1, TERMINAL_RESET); const state = appStore.getState(); - const firstUserLog = state?.logs?.find( - (log: { type: string }) => log.type === "user", - ); - const sessionTitle = firstUserLog?.content; - const exitMsg = formatExitMessage(output.sessionId, sessionTitle); - if (exitMsg) { - writeSync(1, exitMsg); - } + const summary = generateSessionSummary({ + sessionId: output.sessionId ?? "unknown", + sessionStats: state.sessionStats, + modifiedFiles: state.modifiedFiles, + modelName: state.model, + providerName: state.provider, + }); + writeSync(1, summary); } catch { // Ignore - stdout may already be closed } diff --git a/src/tui-solid/context/app.tsx b/src/tui-solid/context/app.tsx index ef25535..37b430c 100644 --- a/src/tui-solid/context/app.tsx +++ b/src/tui-solid/context/app.tsx @@ -169,6 +169,9 @@ interface AppContextValue { startThinking: () => void; stopThinking: () => void; addTokens: (input: number, output: number) => void; + startApiCall: () => void; + stopApiCall: () => void; + addTokensWithModel: (modelId: string, input: number, output: number, cached?: number) => void; resetSessionStats: () => void; setContextMaxTokens: (maxTokens: number) => void; @@ -234,6 +237,9 @@ const createInitialSessionStats = (): SessionStats => ({ thinkingStartTime: null, lastThinkingDuration: 0, contextMaxTokens: 128000, // Default, updated when model is selected + apiTimeSpent: 0, + apiCallStartTime: null, + modelUsage: [], }); const createInitialStreamingState = (): StreamingLogState => ({ @@ -689,6 +695,53 @@ export const { provider: AppStoreProvider, use: useAppStore } = }); }; + const startApiCall = (): void => { + setStore("sessionStats", { + ...store.sessionStats, + apiCallStartTime: Date.now(), + }); + }; + + const stopApiCall = (): void => { + const elapsed = store.sessionStats.apiCallStartTime + ? Date.now() - store.sessionStats.apiCallStartTime + : 0; + setStore("sessionStats", { + ...store.sessionStats, + apiTimeSpent: store.sessionStats.apiTimeSpent + elapsed, + apiCallStartTime: null, + }); + }; + + const addTokensWithModel = ( + modelId: string, + input: number, + output: number, + cached?: number, + ): void => { + setStore( + produce((s) => { + const existing = s.sessionStats.modelUsage.find( + (m) => m.modelId === modelId, + ); + if (existing) { + existing.inputTokens += input; + existing.outputTokens += output; + if (cached) existing.cachedTokens = (existing.cachedTokens ?? 0) + cached; + } else { + s.sessionStats.modelUsage.push({ + modelId, + inputTokens: input, + outputTokens: output, + cachedTokens: cached, + }); + } + s.sessionStats.inputTokens += input; + s.sessionStats.outputTokens += output; + }), + ); + }; + const resetSessionStats = (): void => { setStore("sessionStats", createInitialSessionStats()); }; @@ -982,6 +1035,7 @@ export const { provider: AppStoreProvider, use: useAppStore } = updateMcpServerStatus, // Modified file tracking + modifiedFiles: () => store.modifiedFiles, addModifiedFile, clearModifiedFiles, @@ -995,6 +1049,9 @@ export const { provider: AppStoreProvider, use: useAppStore } = startThinking, stopThinking, addTokens, + startApiCall, + stopApiCall, + addTokensWithModel, resetSessionStats, setContextMaxTokens, @@ -1062,6 +1119,7 @@ const defaultAppState = { suggestions: createInitialSuggestionState(), mcpServers: [] as MCPServerDisplay[], pastedImages: [] as PastedImage[], + modifiedFiles: [] as ModifiedFileEntry[], brain: { status: "disconnected" as BrainConnectionStatus, user: null, @@ -1105,6 +1163,7 @@ export const appStore = { suggestions: storeRef.suggestions(), mcpServers: storeRef.mcpServers(), pastedImages: storeRef.pastedImages(), + modifiedFiles: storeRef.modifiedFiles(), brain: storeRef.brain(), }; }, @@ -1214,6 +1273,21 @@ export const appStore = { storeRef.addTokens(input, output); }, + startApiCall: (): void => { + if (!storeRef) return; + storeRef.startApiCall(); + }, + + stopApiCall: (): void => { + if (!storeRef) return; + storeRef.stopApiCall(); + }, + + addTokensWithModel: (modelId: string, input: number, output: number, cached?: number): void => { + if (!storeRef) return; + storeRef.addTokensWithModel(modelId, input, output, cached); + }, + resetSessionStats: (): void => { if (!storeRef) return; storeRef.resetSessionStats(); diff --git a/src/types/tui.ts b/src/types/tui.ts index e698232..6c65a1c 100644 --- a/src/types/tui.ts +++ b/src/types/tui.ts @@ -247,6 +247,16 @@ export interface LearningResponse { // Session Types // ============================================================================ +/** + * Per-model token usage tracking + */ +export interface ModelUsage { + modelId: string; + inputTokens: number; + outputTokens: number; + cachedTokens?: number; +} + export interface SessionStats { startTime: number; inputTokens: number; @@ -254,6 +264,12 @@ export interface SessionStats { thinkingStartTime: number | null; lastThinkingDuration: number; contextMaxTokens: number; + /** Total time spent in API calls (milliseconds) */ + apiTimeSpent: number; + /** API call start time for tracking (null if not in a call) */ + apiCallStartTime: number | null; + /** Per-model token usage breakdown */ + modelUsage: ModelUsage[]; } // ============================================================================ diff --git a/src/utils/core/session-stats.ts b/src/utils/core/session-stats.ts new file mode 100644 index 0000000..8b1dadf --- /dev/null +++ b/src/utils/core/session-stats.ts @@ -0,0 +1,131 @@ +import type { SessionStats, ModelUsage, ModifiedFileEntry } from "@/types/tui"; + +/** + * Format milliseconds to human readable duration + * e.g., 40m 28.641s, 16h 3m 29.775s + */ +export function formatDuration(ms: number): string { + if (ms < 1000) return `${(ms / 1000).toFixed(3)}s`; + + const seconds = Math.floor((ms / 1000) % 60); + const minutes = Math.floor((ms / (1000 * 60)) % 60); + const hours = Math.floor((ms / (1000 * 60 * 60))); + + const fractionalSeconds = ((ms % 1000) / 1000).toFixed(3).slice(1); // .641 + + if (hours > 0) { + return `${hours}h ${minutes}m ${seconds}${fractionalSeconds}s`; + } + if (minutes > 0) { + return `${minutes}m ${seconds}${fractionalSeconds}s`; + } + return `${seconds}${fractionalSeconds}s`; +} + +/** + * Format tokens for display (e.g., "4.2m", "193.7k") + */ +export function formatTokens(tokens: number): string { + if (tokens >= 1_000_000) { + return `${(tokens / 1_000_000).toFixed(1)}m`; + } + if (tokens >= 1_000) { + return `${(tokens / 1_000).toFixed(1)}k`; + } + return tokens.toString(); +} + +/** + * Calculate total additions and deletions from modified files + */ +export function calculateCodeChanges(files: ModifiedFileEntry[]): { additions: number; deletions: number } { + return files.reduce( + (acc, file) => ({ + additions: acc.additions + file.additions, + deletions: acc.deletions + file.deletions, + }), + { additions: 0, deletions: 0 } + ); +} + +/** + * Format model usage line for display + * e.g., "gpt-5-mini 4.2m in, 193.7k out, 3.4m cached (Est. 0 Premium requests)" + */ +export function formatModelUsage(usage: ModelUsage): string { + const parts: string[] = []; + + if (usage.inputTokens > 0) { + parts.push(`${formatTokens(usage.inputTokens)} in`); + } + if (usage.outputTokens > 0) { + parts.push(`${formatTokens(usage.outputTokens)} out`); + } + if (usage.cachedTokens && usage.cachedTokens > 0) { + parts.push(`${formatTokens(usage.cachedTokens)} cached`); + } + + return parts.join(", ") || "0 tokens"; +} + +/** + * Generate the session summary display + */ +export interface SessionSummaryInput { + sessionId: string; + sessionStats: SessionStats; + modifiedFiles: ModifiedFileEntry[]; + modelName?: string; + providerName?: string; +} + +export function generateSessionSummary(input: SessionSummaryInput): string { + const { sessionId, sessionStats, modifiedFiles, modelName, providerName } = input; + + const { additions, deletions } = calculateCodeChanges(modifiedFiles); + const totalSessionTime = Date.now() - sessionStats.startTime; + + // Build the summary lines + const lines: string[] = [ + "", + "═══════════════════════════════════════════════════════════════", + "", + ` Total usage est: 0 Premium requests`, + ` API time spent: ${formatDuration(sessionStats.apiTimeSpent)}`, + ` Total session time: ${formatDuration(totalSessionTime)}`, + ` Total code changes: +${additions} -${deletions}`, + "", + ]; + + // Add model breakdown if available + if (sessionStats.modelUsage.length > 0) { + lines.push(" Breakdown by AI model:"); + + for (const usage of sessionStats.modelUsage) { + const displayModel = usage.modelId.length > 20 + ? usage.modelId.slice(0, 17) + "..." + : usage.modelId; + const padding = " ".repeat(Math.max(1, 24 - displayModel.length)); + const usageStr = formatModelUsage(usage); + lines.push(` ${displayModel}${padding}${usageStr} (Est. 0 Premium requests)`); + } + lines.push(""); + } else if (modelName) { + // Fallback to current model if no detailed usage tracked + const displayModel = modelName.length > 20 ? modelName.slice(0, 17) + "..." : modelName; + const padding = " ".repeat(Math.max(1, 24 - displayModel.length)); + const totalIn = sessionStats.inputTokens; + const totalOut = sessionStats.outputTokens; + lines.push(" Breakdown by AI model:"); + lines.push(` ${displayModel}${padding}${formatTokens(totalIn)} in, ${formatTokens(totalOut)} out (Est. 0 Premium requests)`); + lines.push(""); + } + + // Add resume command + lines.push(` Resume this session with copilot --resume=${sessionId}`); + lines.push(""); + lines.push("═══════════════════════════════════════════════════════════════"); + lines.push(""); + + return lines.join("\n"); +} diff --git a/src/utils/core/terminal.ts b/src/utils/core/terminal.ts index ac638bb..c68bc94 100644 --- a/src/utils/core/terminal.ts +++ b/src/utils/core/terminal.ts @@ -7,7 +7,7 @@ import chalk from "chalk"; import ora, { Ora } from "ora"; import boxen from "boxen"; import { TERMINAL_SEQUENCES } from "@constants/ui"; -import { TERMINAL_RESET } from "@constants/terminal"; +import { TERMINAL_RESET, DISABLE_MOUSE_TRACKING } from "@constants/terminal"; /** * Spinner state @@ -43,6 +43,9 @@ export const drainStdin = (): Promise => process.stdin.resume(); process.stdin.setEncoding("utf8"); + // Disable mouse tracking to prevent mouse events from leaking to shell + process.stdout.write(DISABLE_MOUSE_TRACKING); + // Swallow any bytes that arrive const sink = (): void => {}; process.stdin.on("data", sink);