From b9e52d20e89c84abe113674978fea73fca8561c5 Mon Sep 17 00:00:00 2001 From: cthomas Date: Wed, 17 Dec 2025 19:48:04 -0800 Subject: [PATCH] feat: add usage command (#281) --- src/cli/App.tsx | 116 +++++++++++++++++++++----- src/cli/commands/registry.ts | 11 ++- src/cli/components/CommandMessage.tsx | 3 +- src/cli/components/SessionStats.tsx | 62 ++++++++------ src/cli/helpers/accumulator.ts | 1 + 5 files changed, 145 insertions(+), 48 deletions(-) diff --git a/src/cli/App.tsx b/src/cli/App.tsx index bd119e8..30037ec 100644 --- a/src/cli/App.tsx +++ b/src/cli/App.tsx @@ -59,7 +59,7 @@ import { ProfileSelector } from "./components/ProfileSelector"; import { QuestionDialog } from "./components/QuestionDialog"; import { ReasoningMessage } from "./components/ReasoningMessageRich"; import { ResumeSelector } from "./components/ResumeSelector"; -import { SessionStats as SessionStatsComponent } from "./components/SessionStats"; +import { formatUsageStats } from "./components/SessionStats"; import { StatusMessage } from "./components/StatusMessage"; import { SubagentGroupDisplay } from "./components/SubagentGroupDisplay"; import { SubagentGroupStatic } from "./components/SubagentGroupStatic"; @@ -460,9 +460,6 @@ export default function App({ // Track if we've sent the session context for this CLI session const hasSentSessionContextRef = useRef(false); - // Show exit stats on exit - const [showExitStats, setShowExitStats] = useState(false); - // Static items (things that are done rendering and can be frozen) const [staticItems, setStaticItems] = useState([]); @@ -1333,8 +1330,7 @@ export default function App({ const handleExit = useCallback(() => { saveLastAgentBeforeExit(); - setShowExitStats(true); - // Give React time to render the stats, then exit + // Give React time to render the goodbye message, then exit setTimeout(() => { process.exit(0); }, 100); @@ -1674,7 +1670,99 @@ export default function App({ return { submitted: true }; } - // Special handling for /exit command - show stats and exit + // Special handling for /usage command - show session stats + if (trimmed === "/usage") { + const cmdId = uid("cmd"); + buffersRef.current.byId.set(cmdId, { + kind: "command", + id: cmdId, + input: trimmed, + output: "Fetching usage statistics...", + phase: "running", + }); + buffersRef.current.order.push(cmdId); + refreshDerived(); + + // Fetch balance and display stats asynchronously + (async () => { + try { + const stats = sessionStatsRef.current.getSnapshot(); + + // Try to fetch balance info (only works for Letta Cloud) + // Silently skip if endpoint not available (not deployed yet or self-hosted) + let balance: + | { + total_balance: number; + monthly_credit_balance: number; + purchased_credit_balance: number; + billing_tier: string; + } + | undefined; + + try { + const settings = settingsManager.getSettings(); + const baseURL = + process.env.LETTA_BASE_URL || + settings.env?.LETTA_BASE_URL || + "https://api.letta.com"; + const apiKey = + process.env.LETTA_API_KEY || settings.env?.LETTA_API_KEY; + + const balanceResponse = await fetch( + `${baseURL}/v1/metadata/balance`, + { + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + "X-Letta-Source": "letta-code", + }, + }, + ); + + if (balanceResponse.ok) { + balance = (await balanceResponse.json()) as { + total_balance: number; + monthly_credit_balance: number; + purchased_credit_balance: number; + billing_tier: string; + }; + } + } catch { + // Silently skip balance info if endpoint not available + } + + const output = formatUsageStats({ + stats, + balance, + }); + + buffersRef.current.byId.set(cmdId, { + kind: "command", + id: cmdId, + input: trimmed, + output, + phase: "finished", + success: true, + dimOutput: true, + }); + refreshDerived(); + } catch (error) { + buffersRef.current.byId.set(cmdId, { + kind: "command", + id: cmdId, + input: trimmed, + output: `Error fetching usage: ${error instanceof Error ? error.message : String(error)}`, + phase: "finished", + success: false, + }); + refreshDerived(); + } + })(); + + return { submitted: true }; + } + + // Special handling for /exit command - exit without stats if (trimmed === "/exit") { const cmdId = uid("cmd"); buffersRef.current.byId.set(cmdId, { @@ -3986,21 +4074,9 @@ Plan file path: ${planFilePath}`; {/* Ensure 1 blank line above input when there are no live items */} {liveItems.length === 0 && } - {/* Show exit stats when exiting */} - {showExitStats && ( - - )} - {/* Input row - always mounted to preserve state */} = { }, }, "/exit": { - desc: "Exit and show session stats", + desc: "Exit this session", handler: () => { - // Handled specially in App.tsx to show stats + // Handled specially in App.tsx return "Exiting..."; }, }, @@ -180,6 +180,13 @@ export const commands: Record = { return "Opening memory viewer..."; }, }, + "/usage": { + desc: "Show session usage statistics and balance", + handler: () => { + // Handled specially in App.tsx to display usage stats + return "Fetching usage statistics..."; + }, + }, }; /** diff --git a/src/cli/components/CommandMessage.tsx b/src/cli/components/CommandMessage.tsx index ab42e45..f0b5625 100644 --- a/src/cli/components/CommandMessage.tsx +++ b/src/cli/components/CommandMessage.tsx @@ -12,6 +12,7 @@ type CommandLine = { output: string; phase?: "running" | "finished"; success?: boolean; + dimOutput?: boolean; }; /** @@ -63,7 +64,7 @@ export const CommandMessage = memo(({ line }: { line: CommandLine }) => { {" ⎿ "} - + )} diff --git a/src/cli/components/SessionStats.tsx b/src/cli/components/SessionStats.tsx index a65420f..fcb112d 100644 --- a/src/cli/components/SessionStats.tsx +++ b/src/cli/components/SessionStats.tsx @@ -1,12 +1,6 @@ -import { Box, Text } from "ink"; import type { SessionStatsSnapshot } from "../../agent/stats"; -interface SessionStatsProps { - stats: SessionStatsSnapshot; - agentId?: string; -} - -function formatDuration(ms: number): string { +export function formatDuration(ms: number): string { if (ms < 1000) { return `${Math.round(ms)}ms`; } @@ -25,25 +19,43 @@ function formatDuration(ms: number): string { return `${(ms / 1000).toFixed(1)}s`; } -function formatNumber(n: number): string { +export function formatNumber(n: number): string { return n.toLocaleString(); } -export function SessionStats({ stats, agentId }: SessionStatsProps) { - const wallDuration = formatDuration(stats.totalWallMs); - const apiDuration = formatDuration(stats.totalApiMs); - const steps = stats.usage.stepCount; - const inputTokens = formatNumber(stats.usage.promptTokens); - const outputTokens = formatNumber(stats.usage.completionTokens); - - return ( - - Total duration (API): {apiDuration} - Total duration (wall): {wallDuration} - - Usage: {steps} steps · {inputTokens} input · {outputTokens} output - - {agentId && Agent ID: {agentId}} - - ); +interface BalanceInfo { + total_balance: number; + monthly_credit_balance: number; + purchased_credit_balance: number; + billing_tier: string; +} + +interface FormatUsageStatsOptions { + stats: SessionStatsSnapshot; + balance?: BalanceInfo; +} + +/** + * Format usage statistics as markdown text for display in CommandMessage + */ +export function formatUsageStats({ + stats, + balance, +}: FormatUsageStatsOptions): string { + const outputLines = [ + `Total duration (API): ${formatDuration(stats.totalApiMs)}`, + `Total duration (wall): ${formatDuration(stats.totalWallMs)}`, + `Session usage: ${stats.usage.stepCount} steps, ${formatNumber(stats.usage.promptTokens)} input, ${formatNumber(stats.usage.completionTokens)} output`, + "", + ]; + + if (balance) { + outputLines.push( + `Available credits: $${balance.total_balance.toFixed(2)} Plan: [${balance.billing_tier}]`, + ` Monthly credits: $${balance.monthly_credit_balance.toFixed(2)}`, + ` Purchased credits: $${balance.purchased_credit_balance.toFixed(2)}`, + ); + } + + return outputLines.join("\n"); } diff --git a/src/cli/helpers/accumulator.ts b/src/cli/helpers/accumulator.ts index 8fd5d10..bcee7ef 100644 --- a/src/cli/helpers/accumulator.ts +++ b/src/cli/helpers/accumulator.ts @@ -44,6 +44,7 @@ export type Line = output: string; phase?: "running" | "finished"; success?: boolean; + dimOutput?: boolean; } | { kind: "status";