feat: add usage command (#281)
This commit is contained in:
116
src/cli/App.tsx
116
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<StaticItem[]>([]);
|
||||
|
||||
@@ -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 && <Box height={1} />}
|
||||
|
||||
{/* Show exit stats when exiting */}
|
||||
{showExitStats && (
|
||||
<SessionStatsComponent
|
||||
stats={sessionStatsRef.current.getSnapshot()}
|
||||
agentId={agentId}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Input row - always mounted to preserve state */}
|
||||
<Input
|
||||
visible={
|
||||
!showExitStats &&
|
||||
pendingApprovals.length === 0 &&
|
||||
!anySelectorOpen
|
||||
}
|
||||
visible={pendingApprovals.length === 0 && !anySelectorOpen}
|
||||
streaming={
|
||||
streaming && !abortControllerRef.current?.signal.aborted
|
||||
}
|
||||
|
||||
@@ -25,9 +25,9 @@ export const commands: Record<string, Command> = {
|
||||
},
|
||||
},
|
||||
"/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<string, Command> = {
|
||||
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...";
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 }) => {
|
||||
<Text>{" ⎿ "}</Text>
|
||||
</Box>
|
||||
<Box flexGrow={1} width={Math.max(0, columns - 5)}>
|
||||
<MarkdownDisplay text={line.output} />
|
||||
<MarkdownDisplay text={line.output} dimColor={line.dimOutput} />
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
@@ -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 (
|
||||
<Box flexDirection="column" paddingTop={1}>
|
||||
<Text dimColor>Total duration (API): {apiDuration}</Text>
|
||||
<Text dimColor>Total duration (wall): {wallDuration}</Text>
|
||||
<Text dimColor>
|
||||
Usage: {steps} steps · {inputTokens} input · {outputTokens} output
|
||||
</Text>
|
||||
{agentId && <Text dimColor>Agent ID: {agentId}</Text>}
|
||||
</Box>
|
||||
);
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ export type Line =
|
||||
output: string;
|
||||
phase?: "running" | "finished";
|
||||
success?: boolean;
|
||||
dimOutput?: boolean;
|
||||
}
|
||||
| {
|
||||
kind: "status";
|
||||
|
||||
Reference in New Issue
Block a user