Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/cli/src/tests/slash-commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ test("buildSlashCommands prefixes skills before built-ins", () => {
"undo",
"mcp",
"raw",
"tokens",
"context",
"cost",
"exit",
]);
});
Expand Down
93 changes: 93 additions & 0 deletions packages/cli/src/tests/token-usage.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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> = {}): 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> = {}): 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+\$/);
});
21 changes: 21 additions & 0 deletions packages/cli/src/ui/core/slash-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ export type SlashCommandKind =
| "undo"
| "mcp"
| "raw"
| "tokens"
| "context"
| "cost"
| "exit";

export type SlashCommandItem = {
Expand Down Expand Up @@ -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",
Expand Down
91 changes: 91 additions & 0 deletions packages/cli/src/ui/core/token-usage.ts
Original file line number Diff line number Diff line change
@@ -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<string, { input: number; output: number }> = {
"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");
}
14 changes: 14 additions & 0 deletions packages/cli/src/ui/views/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down
7 changes: 6 additions & 1 deletion packages/cli/src/ui/views/PromptInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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);
Expand Down