From b7844fd561006cf35030659b85aa013a66aa789b Mon Sep 17 00:00:00 2001 From: Wooseong Kim Date: Thu, 6 Aug 2026 18:16:01 +0900 Subject: [PATCH] feat(ui): add /tokens /context /cost commands (#224) New slash commands report token usage, context-window consumption, and estimated cost for the current session. Token usage comes from the session entry; context size is estimated from the active messages; cost uses a DeepSeek pricing table. (cherry picked from commit feaf159f38a141e1888323f6131a12478d81e87e) --- packages/cli/src/tests/slash-commands.test.ts | 3 + packages/cli/src/tests/token-usage.test.ts | 93 +++++++++++++++++++ packages/cli/src/ui/core/slash-commands.ts | 21 +++++ packages/cli/src/ui/core/token-usage.ts | 91 ++++++++++++++++++ packages/cli/src/ui/views/App.tsx | 14 +++ packages/cli/src/ui/views/PromptInput.tsx | 7 +- 6 files changed, 228 insertions(+), 1 deletion(-) create mode 100644 packages/cli/src/tests/token-usage.test.ts create mode 100644 packages/cli/src/ui/core/token-usage.ts diff --git a/packages/cli/src/tests/slash-commands.test.ts b/packages/cli/src/tests/slash-commands.test.ts index 311c01ed..c7ccacb6 100644 --- a/packages/cli/src/tests/slash-commands.test.ts +++ b/packages/cli/src/tests/slash-commands.test.ts @@ -31,6 +31,9 @@ test("buildSlashCommands prefixes skills before built-ins", () => { "undo", "mcp", "raw", + "tokens", + "context", + "cost", "exit", ]); }); diff --git a/packages/cli/src/tests/token-usage.test.ts b/packages/cli/src/tests/token-usage.test.ts new file mode 100644 index 00000000..b5d976a4 --- /dev/null +++ b/packages/cli/src/tests/token-usage.test.ts @@ -0,0 +1,93 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import type { ResolvedDeepcodingSettings, SessionEntry, SessionMessage } from "@vegamo/deepcode-core"; +import { buildUsageReport, estimateContextTokens, getPricingForModel } from "../ui/core/token-usage"; + +function message(overrides: Partial = {}): SessionMessage { + return { + id: "m1", + sessionId: "s1", + role: "assistant", + content: "", + contentParams: null, + messageParams: null, + compacted: false, + visible: true, + createTime: "2026-01-01T00:00:00.000Z", + updateTime: "2026-01-01T00:00:00.000Z", + ...overrides, + }; +} + +function sessionEntry(overrides: Partial = {}): SessionEntry { + return { + id: "s1", + summary: null, + assistantReply: null, + assistantThinking: null, + assistantRefusal: null, + toolCalls: null, + status: "completed", + failReason: null, + usage: { prompt_tokens: 1000, completion_tokens: 500, total_tokens: 1500 }, + usagePerModel: null, + activeTokens: 1200, + processes: null, + createTime: "2026-01-01T00:00:00.000Z", + updateTime: "2026-01-01T00:00:00.000Z", + ...overrides, + }; +} + +function settings(overrides: Partial = {}): ResolvedDeepcodingSettings { + return { + env: {}, + apiKey: "sk-test", + baseURL: "https://api.deepseek.com", + model: "deepseek-v4-flash", + contextWindow: 1024 * 1024, + autoCompactWindow: 512 * 1024, + thinkingEnabled: true, + reasoningEffort: "max", + debugLogEnabled: false, + telemetryEnabled: false, + permissions: { allow: [], deny: [], ask: [], defaultMode: "allowAll" }, + enabledSkills: {}, + statusline: { enabled: false, refreshMs: 2000, separator: " · ", providers: [] }, + ...overrides, + }; +} + +test("getPricingForModel returns known pricing and a default fallback", () => { + assert.deepEqual(getPricingForModel("deepseek-v4-flash"), { input: 0.28, output: 0.42 }); + assert.deepEqual(getPricingForModel("some-other-model"), { input: 1, output: 2 }); +}); + +test("estimateContextTokens skips compacted messages and counts chars / 4", () => { + const messages = [ + message({ content: "a".repeat(400) }), + message({ content: "b".repeat(400), compacted: true }), + message({ content: "cc" }), + ]; + assert.equal(estimateContextTokens(messages), Math.ceil(402 / 4)); +}); + +test("buildUsageReport formats the /tokens report", () => { + const report = buildUsageReport(sessionEntry(), settings(), [message({ content: "x".repeat(100) })], "tokens"); + assert.match(report, /prompt tokens:\s+1,000/); + assert.match(report, /completion tokens:\s+500/); + assert.match(report, /total tokens:\s+1,500/); +}); + +test("buildUsageReport formats the /context report with window usage", () => { + const report = buildUsageReport(sessionEntry(), settings(), [message({ content: "x".repeat(100) })], "context"); + assert.match(report, /context window:\s+1,048,576 tokens/); + assert.match(report, /estimated usage:/); + assert.match(report, /%\)/); +}); + +test("buildUsageReport formats the /cost report with pricing", () => { + const report = buildUsageReport(sessionEntry(), settings(), [], "cost"); + assert.match(report, /input rate:\s+\$0\.28\/1M tokens/); + assert.match(report, /estimated cost:\s+\$/); +}); diff --git a/packages/cli/src/ui/core/slash-commands.ts b/packages/cli/src/ui/core/slash-commands.ts index 77292b40..30cb9917 100644 --- a/packages/cli/src/ui/core/slash-commands.ts +++ b/packages/cli/src/ui/core/slash-commands.ts @@ -13,6 +13,9 @@ export type SlashCommandKind = | "undo" | "mcp" | "raw" + | "tokens" + | "context" + | "cost" | "exit"; export type SlashCommandItem = { @@ -92,6 +95,24 @@ export const BUILTIN_SLASH_COMMANDS: SlashCommandItem[] = [ args: ["lite", "normal", "raw-scrollback"], description: "Toggle display mode for viewing or collapsing reasoning content", }, + { + kind: "tokens", + name: "tokens", + label: "/tokens", + description: "Show token usage for the current session", + }, + { + kind: "context", + name: "context", + label: "/context", + description: "Show context window usage for the current session", + }, + { + kind: "cost", + name: "cost", + label: "/cost", + description: "Show estimated cost for the current session", + }, { kind: "exit", name: "exit", diff --git a/packages/cli/src/ui/core/token-usage.ts b/packages/cli/src/ui/core/token-usage.ts new file mode 100644 index 00000000..7c86de89 --- /dev/null +++ b/packages/cli/src/ui/core/token-usage.ts @@ -0,0 +1,91 @@ +import type { ResolvedDeepcodingSettings, SessionEntry, SessionMessage } from "@vegamo/deepcode-core"; + +export type UsageReportKind = "tokens" | "context" | "cost"; + +// Approximate USD per 1M tokens for common DeepSeek models. +const DEEPSEEK_PRICING_USD_PER_MTOK: Record = { + "deepseek-v4-pro": { input: 2, output: 8 }, + "deepseek-v4-flash": { input: 0.28, output: 0.42 }, + "deepseek-chat": { input: 0.27, output: 1.1 }, + "deepseek-reasoner": { input: 0.55, output: 2.19 }, +}; + +const DEFAULT_PRICING: { input: number; output: number } = { input: 1, output: 2 }; + +export function getPricingForModel(model: string): { input: number; output: number } { + return DEEPSEEK_PRICING_USD_PER_MTOK[model.trim()] ?? DEFAULT_PRICING; +} + +/** + * Estimate the context-window consumption of the active (non-compacted) + * session messages using a chars/4 heuristic. (#224) + */ +export function estimateContextTokens(messages: SessionMessage[]): number { + let chars = 0; + for (const message of messages) { + if (message.compacted) { + continue; + } + chars += (message.content ?? "").length; + if (message.messageParams) { + chars += JSON.stringify(message.messageParams).length; + } + if (Array.isArray(message.contentParams)) { + for (const param of message.contentParams) { + const url = (param as { image_url?: { url?: unknown } }).image_url?.url; + if (typeof url === "string") { + chars += url.length; + } + const text = (param as { text?: unknown }).text; + if (typeof text === "string") { + chars += text.length; + } + } + } + } + return Math.max(0, Math.ceil(chars / 4)); +} + +export function buildUsageReport( + session: SessionEntry | null, + settings: ResolvedDeepcodingSettings, + messages: SessionMessage[], + kind: UsageReportKind +): string { + const promptTokens = session?.usage?.prompt_tokens ?? 0; + const completionTokens = session?.usage?.completion_tokens ?? 0; + const totalTokens = session?.usage?.total_tokens ?? 0; + const activeTokens = session?.activeTokens ?? 0; + const estimated = estimateContextTokens(messages); + const contextWindow = settings.contextWindow; + const pricing = getPricingForModel(settings.model); + const estimatedCostUsd = (promptTokens / 1_000_000) * pricing.input + (completionTokens / 1_000_000) * pricing.output; + + if (kind === "tokens") { + return [ + `/tokens · ${settings.model}`, + ` prompt tokens: ${promptTokens.toLocaleString()}`, + ` completion tokens: ${completionTokens.toLocaleString()}`, + ` total tokens: ${totalTokens.toLocaleString()}`, + ` active tokens: ${activeTokens.toLocaleString()}`, + ` estimated context: ${estimated.toLocaleString()}`, + ].join("\n"); + } + + if (kind === "context") { + const percent = contextWindow > 0 ? Math.min(100, Math.round((estimated / contextWindow) * 100)) : 0; + return [ + `/context · ${settings.model}`, + ` context window: ${contextWindow.toLocaleString()} tokens`, + ` estimated usage: ${estimated.toLocaleString()} tokens (${percent}%)`, + ` messages: ${messages.filter((m) => !m.compacted).length} active / ${messages.length} total`, + ].join("\n"); + } + + return [ + `/cost · ${settings.model}`, + ` input rate: $${pricing.input}/1M tokens`, + ` output rate: $${pricing.output}/1M tokens`, + ` estimated cost: $${estimatedCostUsd.toFixed(4)}`, + ].join("\n"); +} diff --git a/packages/cli/src/ui/views/App.tsx b/packages/cli/src/ui/views/App.tsx index 034c62ac..6fd10102 100644 --- a/packages/cli/src/ui/views/App.tsx +++ b/packages/cli/src/ui/views/App.tsx @@ -9,6 +9,7 @@ import { MessageView, RawModeExitPrompt } from "../components"; import { SessionList } from "./SessionList"; import { type UndoRestoreMode, UndoSelector } from "./UndoSelector"; import { buildLoadingText } from "../core/loading-text"; +import { buildUsageReport } from "../core/token-usage"; import { findExpandedThinkingId } from "../core/thinking-state"; import { WelcomeScreen } from "./WelcomeScreen"; import { AskUserQuestionPrompt } from "./AskUserQuestionPrompt"; @@ -392,6 +393,19 @@ function App({ projectRoot, initialPrompt, resumeSessionId, forkSessionId, onRes navigateToSubView("mcp-status"); return; } + if (submission.command === "tokens" || submission.command === "context" || submission.command === "cost") { + const activeSessionId = sessionManager.getActiveSessionId(); + const session = activeSessionId ? sessionManager.getSession(activeSessionId) : null; + const sessionMessages = activeSessionId ? sessionManager.listSessionMessages(activeSessionId) : []; + const report = buildUsageReport( + session, + resolveCurrentSettings(projectRoot), + sessionMessages, + submission.command + ); + setMessages((prev) => [...prev, buildSyntheticUserMessage(report, 0)]); + return; + } const prompt: UserPromptContent = { text: submission.text, diff --git a/packages/cli/src/ui/views/PromptInput.tsx b/packages/cli/src/ui/views/PromptInput.tsx index 9e6240a4..86284a63 100644 --- a/packages/cli/src/ui/views/PromptInput.tsx +++ b/packages/cli/src/ui/views/PromptInput.tsx @@ -73,7 +73,7 @@ export type PromptSubmission = { permissions?: UserToolPermission[]; alwaysAllows?: PermissionScope[]; planMode?: boolean; - command?: "new" | "resume" | "fork" | "continue" | "undo" | "mcp" | "exit"; + command?: "new" | "resume" | "fork" | "continue" | "undo" | "mcp" | "tokens" | "context" | "cost" | "exit"; }; export type PromptDraft = { @@ -734,6 +734,11 @@ export const PromptInput = React.memo(function PromptInput({ resetPromptInput(); return; } + if (item.kind === "tokens" || item.kind === "context" || item.kind === "cost") { + onSubmit({ text: `/${item.kind}`, imageUrls: [], command: item.kind }); + resetPromptInput(); + return; + } if (item.kind === "exit") { onSubmit({ text: "/exit", imageUrls: [], command: "exit" }); setBuffer(EMPTY_BUFFER);