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
This commit is contained in:
2026-02-15 12:32:36 -05:00
parent b51e3d49a6
commit 18a5eca3ae
8 changed files with 244 additions and 15 deletions

View File

@@ -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<TuiOutput> {
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
}

View File

@@ -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();