From 9af2be35ee2b23f2682f9f99303926db3de18e8e Mon Sep 17 00:00:00 2001 From: al4xdev Date: Tue, 30 Jun 2026 21:27:10 -0300 Subject: [PATCH 1/2] feat(cli): add /usage slash command for DeepSeek API balance - Add 'usage' to SlashCommandKind type and BUILTIN_SLASH_COMMANDS - Handle /usage in PromptInput (command type + handleSlashSelection) - Create handleUsage callback in App: detects DeepSeek via baseURL, fetches GET /user/balance, displays result as system message - Detect slash commands in -p/--prompt startup path - Show provider mismatch error when baseURL is not api.deepseek.com --- packages/cli/src/ui/core/slash-commands.ts | 7 ++ packages/cli/src/ui/views/App.tsx | 85 +++++++++++++++++++++- packages/cli/src/ui/views/PromptInput.tsx | 7 +- 3 files changed, 97 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/ui/core/slash-commands.ts b/packages/cli/src/ui/core/slash-commands.ts index ba5ae6ec..7f779d0a 100644 --- a/packages/cli/src/ui/core/slash-commands.ts +++ b/packages/cli/src/ui/core/slash-commands.ts @@ -9,6 +9,7 @@ export type SlashCommandKind = | "resume" | "continue" | "undo" + | "usage" | "mcp" | "raw" | "exit"; @@ -65,6 +66,12 @@ export const BUILTIN_SLASH_COMMANDS: SlashCommandItem[] = [ label: "/undo", description: "Restore code and/or conversation to a previous point", }, + { + kind: "usage", + name: "usage", + label: "/usage", + description: "Show DeepSeek API balance / credits", + }, { kind: "mcp", name: "mcp", diff --git a/packages/cli/src/ui/views/App.tsx b/packages/cli/src/ui/views/App.tsx index 3b2886cd..98fd6124 100644 --- a/packages/cli/src/ui/views/App.tsx +++ b/packages/cli/src/ui/views/App.tsx @@ -35,6 +35,7 @@ import { resolveCurrentSettings, writeModelConfigSelection } from "@vegamo/deepc import { useStatusLine } from "../hooks"; import type { SessionInfo } from "../statusline"; import { isCollapsedThinking } from "../core/thinking-state"; +import { BUILTIN_SLASH_COMMANDS, findExactSlashCommand } from "../core/slash-commands"; import { ANSI_CLEAR_SCREEN } from "../constants"; import type { LlmStreamProgress, @@ -321,6 +322,78 @@ function App({ projectRoot, initialPrompt, resumeSessionId, onRestart }: AppProp [exit, sessionManager] ); + const handleUsage = useCallback(async () => { + const settings = resolveCurrentSettings(projectRoot); + const baseURL = settings.baseURL.toLowerCase(); + if (!baseURL.includes("api.deepseek.com")) { + setErrorLine(`/usage is only compatible with DeepSeek API. Current base URL: ${settings.baseURL}`); + return; + } + if (!settings.apiKey) { + setErrorLine("No API key configured. Set DEEPCODE_API_KEY env var or apiKey in settings.json."); + return; + } + + setBusy(true); + setErrorLine(null); + try { + const response = await fetch("https://api.deepseek.com/user/balance", { + headers: { Authorization: `Bearer ${settings.apiKey}` }, + }); + if (!response.ok) { + setErrorLine(`Failed to fetch balance: HTTP ${response.status} ${response.statusText || ""}`.trim()); + return; + } + const data = (await response.json()) as { + is_available: boolean; + balance_infos?: Array<{ + currency: string; + total_balance: string; + granted_balance: string; + topped_up_balance: string; + }>; + }; + + const statusIcon = data.is_available ? "🟢" : "🔴"; + const statusText = data.is_available ? "Available" : "Not available"; + let content = `/usage\n└ API status: ${statusIcon} ${statusText}`; + + if (data.balance_infos && data.balance_infos.length > 0) { + for (const info of data.balance_infos) { + content += `\n ${info.currency}: total ${info.total_balance} (granted ${info.granted_balance}, topped up ${info.topped_up_balance})`; + } + } else { + content += `\n No balance info returned.`; + } + + const now = new Date().toISOString(); + const activeSessionId = sessionManager.getActiveSessionId(); + if (activeSessionId) { + sessionManager.addSessionSystemMessage(activeSessionId, content, true); + } else { + setMessages((prev) => [ + ...prev, + { + id: crypto.randomUUID(), + sessionId: "local", + role: "system" as const, + content, + contentParams: null, + messageParams: null, + compacted: false, + visible: true, + createTime: now, + updateTime: now, + }, + ]); + } + } catch (error) { + setErrorLine(`Failed to fetch balance: ${error instanceof Error ? error.message : String(error)}`); + } finally { + setBusy(false); + } + }, [projectRoot, sessionManager]); + const handlePrompt = useCallback( async (submission: PromptSubmission) => { if (submission.command === "exit") { @@ -361,6 +434,10 @@ function App({ projectRoot, initialPrompt, resumeSessionId, onRestart }: AppProp navigateToSubView("mcp-status"); return; } + if (submission.command === "usage") { + void handleUsage(); + return; + } const prompt: UserPromptContent = { text: submission.text, @@ -420,6 +497,7 @@ function App({ projectRoot, initialPrompt, resumeSessionId, onRestart }: AppProp sessionManager, pendingPermissionReply, handleExit, + handleUsage, onRestart, refreshSkills, refreshSessionsList, @@ -551,10 +629,15 @@ function App({ projectRoot, initialPrompt, resumeSessionId, onRestart }: AppProp // Step 2: Submit prompt if provided if (initialPrompt && initialPrompt.trim()) { initialPromptSubmittedRef.current = true; + const trimmed = initialPrompt.trim(); + const slashToken = trimmed.split(/\s+/, 1)[0]; + const match = findExactSlashCommand(BUILTIN_SLASH_COMMANDS, slashToken); + const command = match && match.kind !== "skill" ? (match.kind as PromptSubmission["command"]) : undefined; handleSubmit({ - text: initialPrompt, + text: trimmed, imageUrls: [], selectedSkills: undefined, + command, }); } } diff --git a/packages/cli/src/ui/views/PromptInput.tsx b/packages/cli/src/ui/views/PromptInput.tsx index 3f548def..8055f401 100644 --- a/packages/cli/src/ui/views/PromptInput.tsx +++ b/packages/cli/src/ui/views/PromptInput.tsx @@ -72,7 +72,7 @@ export type PromptSubmission = { selectedSkills?: SkillInfo[]; permissions?: UserToolPermission[]; alwaysAllows?: PermissionScope[]; - command?: "new" | "resume" | "continue" | "undo" | "mcp" | "exit"; + command?: "new" | "resume" | "continue" | "undo" | "usage" | "mcp" | "exit"; }; export type PromptDraft = { @@ -708,6 +708,11 @@ export const PromptInput = React.memo(function PromptInput({ resetPromptInput(); return; } + if (item.kind === "usage") { + onSubmit({ text: "/usage", imageUrls: [], command: "usage" }); + resetPromptInput(); + return; + } if (item.kind === "mcp") { onSubmit({ text: "/mcp", imageUrls: [], command: "mcp" }); resetPromptInput(); From b5338dc923803a74eef549b1b050c370f16c7d54 Mon Sep 17 00:00:00 2001 From: al4xdev Date: Tue, 30 Jun 2026 21:54:58 -0300 Subject: [PATCH 2/2] refactor(cli): show /usage balance in dedicated view instead of system message Replace inline system message with a UsageView component (bordered box, Esc to close), matching the MCP status pattern. This avoids render issues with addSessionSystemMessage and provides a cleaner UX. --- packages/cli/src/tests/slash-commands.test.ts | 1 + packages/cli/src/ui/views/App.tsx | 54 +++-------------- packages/cli/src/ui/views/UsageView.tsx | 59 +++++++++++++++++++ 3 files changed, 69 insertions(+), 45 deletions(-) create mode 100644 packages/cli/src/ui/views/UsageView.tsx diff --git a/packages/cli/src/tests/slash-commands.test.ts b/packages/cli/src/tests/slash-commands.test.ts index 420e5a48..96e12ae2 100644 --- a/packages/cli/src/tests/slash-commands.test.ts +++ b/packages/cli/src/tests/slash-commands.test.ts @@ -27,6 +27,7 @@ test("buildSlashCommands prefixes skills before built-ins", () => { "resume", "continue", "undo", + "usage", "mcp", "raw", "exit", diff --git a/packages/cli/src/ui/views/App.tsx b/packages/cli/src/ui/views/App.tsx index 98fd6124..d2697ac9 100644 --- a/packages/cli/src/ui/views/App.tsx +++ b/packages/cli/src/ui/views/App.tsx @@ -13,6 +13,7 @@ import { findExpandedThinkingId } from "../core/thinking-state"; import { WelcomeScreen } from "./WelcomeScreen"; import { AskUserQuestionPrompt } from "./AskUserQuestionPrompt"; import { McpStatusList } from "./McpStatusList"; +import { UsageView, type UsageData } from "./UsageView"; import { ProcessStdoutView } from "./ProcessStdoutView"; import { type AskUserQuestionAnswers, @@ -51,7 +52,7 @@ import { SessionManager } from "@vegamo/deepcode-core"; import { getCompactPromptTokenThreshold } from "@vegamo/deepcode-core"; import { writeStdout, writeStdoutLine } from "../../utils/stdio-helpers"; -type View = "chat" | "session-list" | "undo" | "mcp-status"; +type View = "chat" | "session-list" | "undo" | "usage" | "mcp-status"; const STATUS_SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; @@ -133,6 +134,7 @@ function App({ projectRoot, initialPrompt, resumeSessionId, onRestart }: AppProp const [resolvedSettings, setResolvedSettings] = useState(() => resolveCurrentSettings(projectRoot)); const [nowTick, setNowTick] = useState(0); const [mcpStatuses, setMcpStatuses] = useState>([]); + const [usageData, setUsageData] = useState(null); const [showProcessStdout, setShowProcessStdout] = useState(false); rawModeRef.current = mode; @@ -344,55 +346,15 @@ function App({ projectRoot, initialPrompt, resumeSessionId, onRestart }: AppProp setErrorLine(`Failed to fetch balance: HTTP ${response.status} ${response.statusText || ""}`.trim()); return; } - const data = (await response.json()) as { - is_available: boolean; - balance_infos?: Array<{ - currency: string; - total_balance: string; - granted_balance: string; - topped_up_balance: string; - }>; - }; - - const statusIcon = data.is_available ? "🟢" : "🔴"; - const statusText = data.is_available ? "Available" : "Not available"; - let content = `/usage\n└ API status: ${statusIcon} ${statusText}`; - - if (data.balance_infos && data.balance_infos.length > 0) { - for (const info of data.balance_infos) { - content += `\n ${info.currency}: total ${info.total_balance} (granted ${info.granted_balance}, topped up ${info.topped_up_balance})`; - } - } else { - content += `\n No balance info returned.`; - } - - const now = new Date().toISOString(); - const activeSessionId = sessionManager.getActiveSessionId(); - if (activeSessionId) { - sessionManager.addSessionSystemMessage(activeSessionId, content, true); - } else { - setMessages((prev) => [ - ...prev, - { - id: crypto.randomUUID(), - sessionId: "local", - role: "system" as const, - content, - contentParams: null, - messageParams: null, - compacted: false, - visible: true, - createTime: now, - updateTime: now, - }, - ]); - } + const data: UsageData = await response.json(); + setUsageData(data); + navigateToSubView("usage"); } catch (error) { setErrorLine(`Failed to fetch balance: ${error instanceof Error ? error.message : String(error)}`); } finally { setBusy(false); } - }, [projectRoot, sessionManager]); + }, [projectRoot, navigateToSubView]); const handlePrompt = useCallback( async (submission: PromptSubmission) => { @@ -1032,6 +994,8 @@ function App({ projectRoot, initialPrompt, resumeSessionId, onRestart }: AppProp void sessionManager.reconnectMcpServer(name, latest.mcpServers?.[name]); }} /> + ) : view === "usage" ? ( + setView("chat")} /> ) : shouldShowQuestionPrompt && pendingQuestion && !busy ? ( ; +}; + +type Props = { + data: UsageData; + onCancel: () => void; +}; + +export function UsageView({ data, onCancel }: Props): React.ReactElement { + useInput((_input, key) => { + if (key.escape) { + onCancel(); + } + }); + + return ( + + + + /usage{" "} + + {data.is_available ? "🟢 Available" : "🔴 Not available"} + + + + {data.balance_infos.length > 0 ? ( + + {data.balance_infos.map((info, i) => ( + + {info.currency} + {" total "} + {info.total_balance} + {" (granted "} + {info.granted_balance} + {", topped up "} + {info.topped_up_balance} + {")"} + + ))} + + ) : ( + + No balance info returned. + + )} + Esc to close + + ); +}