diff --git a/CHANGELOG.md b/CHANGELOG.md
index e2fb77924..74de86ff3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -25,6 +25,7 @@
- Auto-reply: avoid splitting outbound chunks inside parentheses. (#499) — thanks @philipp-spiess
- Auto-reply: preserve spacing when stripping inline directives. (#539) — thanks @joshp123
- Status: show provider prefix in /status model display. (#506) — thanks @mcinteerj
+- Status: compact /status with session token usage + estimated cost, add `/cost` per-response usage lines (tokens-only for OAuth).
- macOS: package ClawdbotKit resources and Swift 6.2 compatibility dylib to avoid launch/tool crashes. (#473) — thanks @gupsammy
- WhatsApp: group `/model list` output by provider for scannability. (#456) - thanks @mcinteerj
- Hooks: allow per-hook model overrides for webhook/Gmail runs (e.g. GPT 5 Mini).
diff --git a/README.md b/README.md
index a15d2bcf4..52fe98fc7 100644
--- a/README.md
+++ b/README.md
@@ -240,11 +240,12 @@ ClawdHub is a minimal skill registry. With ClawdHub enabled, the agent can searc
Send these in WhatsApp/Telegram/Slack/WebChat (group commands are owner-only):
-- `/status` — health + session info (group shows activation mode)
+- `/status` — compact session status (model + tokens, cost when available)
- `/new` or `/reset` — reset the session
- `/compact` — compact session context (summary)
- `/think ` — off|minimal|low|medium|high
- `/verbose on|off`
+- `/cost on|off` — append per-response token/cost usage lines
- `/restart` — restart the gateway (owner-only in groups)
- `/activation mention|always` — group activation toggle (groups only)
@@ -460,10 +461,10 @@ Thanks to all clawtributors:
-
-
-
-
-
-
+
+
+
+
+
+
diff --git a/apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift b/apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift
index 9a4761215..713239414 100644
--- a/apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift
+++ b/apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift
@@ -664,19 +664,22 @@ public struct SessionsListParams: Codable, Sendable {
public let includeglobal: Bool?
public let includeunknown: Bool?
public let spawnedby: String?
+ public let agentid: String?
public init(
limit: Int?,
activeminutes: Int?,
includeglobal: Bool?,
includeunknown: Bool?,
- spawnedby: String?
+ spawnedby: String?,
+ agentid: String?
) {
self.limit = limit
self.activeminutes = activeminutes
self.includeglobal = includeglobal
self.includeunknown = includeunknown
self.spawnedby = spawnedby
+ self.agentid = agentid
}
private enum CodingKeys: String, CodingKey {
case limit
@@ -684,6 +687,7 @@ public struct SessionsListParams: Codable, Sendable {
case includeglobal = "includeGlobal"
case includeunknown = "includeUnknown"
case spawnedby = "spawnedBy"
+ case agentid = "agentId"
}
}
@@ -692,6 +696,7 @@ public struct SessionsPatchParams: Codable, Sendable {
public let thinkinglevel: AnyCodable?
public let verboselevel: AnyCodable?
public let reasoninglevel: AnyCodable?
+ public let responseusage: AnyCodable?
public let elevatedlevel: AnyCodable?
public let model: AnyCodable?
public let spawnedby: AnyCodable?
@@ -703,6 +708,7 @@ public struct SessionsPatchParams: Codable, Sendable {
thinkinglevel: AnyCodable?,
verboselevel: AnyCodable?,
reasoninglevel: AnyCodable?,
+ responseusage: AnyCodable?,
elevatedlevel: AnyCodable?,
model: AnyCodable?,
spawnedby: AnyCodable?,
@@ -713,6 +719,7 @@ public struct SessionsPatchParams: Codable, Sendable {
self.thinkinglevel = thinkinglevel
self.verboselevel = verboselevel
self.reasoninglevel = reasoninglevel
+ self.responseusage = responseusage
self.elevatedlevel = elevatedlevel
self.model = model
self.spawnedby = spawnedby
@@ -724,6 +731,7 @@ public struct SessionsPatchParams: Codable, Sendable {
case thinkinglevel = "thinkingLevel"
case verboselevel = "verboseLevel"
case reasoninglevel = "reasoningLevel"
+ case responseusage = "responseUsage"
case elevatedlevel = "elevatedLevel"
case model
case spawnedby = "spawnedBy"
@@ -1100,6 +1108,51 @@ public struct WebLoginWaitParams: Codable, Sendable {
}
}
+public struct AgentSummary: Codable, Sendable {
+ public let id: String
+ public let name: String?
+
+ public init(
+ id: String,
+ name: String?
+ ) {
+ self.id = id
+ self.name = name
+ }
+ private enum CodingKeys: String, CodingKey {
+ case id
+ case name
+ }
+}
+
+public struct AgentsListParams: Codable, Sendable {
+}
+
+public struct AgentsListResult: Codable, Sendable {
+ public let defaultid: String
+ public let mainkey: String
+ public let scope: AnyCodable
+ public let agents: [AgentSummary]
+
+ public init(
+ defaultid: String,
+ mainkey: String,
+ scope: AnyCodable,
+ agents: [AgentSummary]
+ ) {
+ self.defaultid = defaultid
+ self.mainkey = mainkey
+ self.scope = scope
+ self.agents = agents
+ }
+ private enum CodingKeys: String, CodingKey {
+ case defaultid = "defaultId"
+ case mainkey = "mainKey"
+ case scope
+ case agents
+ }
+}
+
public struct ModelChoice: Codable, Sendable {
public let id: String
public let name: String
diff --git a/docs/concepts/usage-tracking.md b/docs/concepts/usage-tracking.md
index 9921567c5..84329a656 100644
--- a/docs/concepts/usage-tracking.md
+++ b/docs/concepts/usage-tracking.md
@@ -11,7 +11,8 @@ read_when:
- No estimated costs; only the provider-reported windows.
## Where it shows up
-- `/status` in chats: adds a short “Usage” line (only if available).
+- `/status` in chats: compact one‑liner with session tokens + estimated cost (API key only) and provider quota windows when available.
+- `/cost on|off` in chats: toggles per‑response usage lines (OAuth shows tokens only).
- CLI: `clawdbot status --usage` prints a full per-provider breakdown.
- CLI: `clawdbot providers list` prints the same usage snapshot alongside provider config (use `--no-usage` to skip).
- macOS menu bar: “Usage” section under Context (only if available).
diff --git a/docs/docs.json b/docs/docs.json
index 84c3179aa..b74b08aab 100644
--- a/docs/docs.json
+++ b/docs/docs.json
@@ -556,6 +556,7 @@
"concepts/agent",
"concepts/agent-loop",
"concepts/system-prompt",
+ "token-use",
"concepts/oauth",
"concepts/agent-workspace",
"concepts/multi-agent",
diff --git a/docs/token-use.md b/docs/token-use.md
new file mode 100644
index 000000000..d142dcfc4
--- /dev/null
+++ b/docs/token-use.md
@@ -0,0 +1,72 @@
+---
+summary: "How Clawdbot builds prompt context and reports token usage + costs"
+read_when:
+ - Explaining token usage, costs, or context windows
+ - Debugging context growth or compaction behavior
+---
+# Token use & costs
+
+Clawdbot tracks **tokens**, not characters. Tokens are model-specific, but most
+OpenAI-style models average ~4 characters per token for English text.
+
+## How the system prompt is built
+
+Clawdbot assembles its own system prompt on every run. It includes:
+
+- Tool list + short descriptions
+- Skills list (only metadata; instructions are loaded on demand with `read`)
+- Self-update instructions
+- Workspace + bootstrap files (`AGENTS.md`, `SOUL.md`, `TOOLS.md`, `IDENTITY.md`, `USER.md`, `HEARTBEAT.md`, `BOOTSTRAP.md` when new)
+- Time (UTC + user timezone)
+- Reply tags + heartbeat behavior
+- Runtime metadata (host/OS/model/thinking)
+
+See the full breakdown in [System Prompt](/concepts/system-prompt).
+
+## What counts in the context window
+
+Everything the model receives counts toward the context limit:
+
+- System prompt (all sections listed above)
+- Conversation history (user + assistant messages)
+- Tool calls and tool results
+- Attachments/transcripts (images, audio, files)
+- Compaction summaries and pruning artifacts
+- Provider wrappers or safety headers (not visible, but still counted)
+
+## How to see current token usage
+
+Use these in chat:
+
+- `/status` → **compact one‑liner** with the session model, context usage,
+ last response input/output tokens, and **estimated cost** (API key only).
+- `/cost on|off` → appends a **per-response usage line** to every reply.
+ - Persists per session (stored as `responseUsage`).
+ - OAuth auth **hides cost** (tokens only).
+
+Other surfaces:
+
+- **TUI/Web TUI:** `/status` + `/cost` are supported.
+- **CLI:** `clawdbot status --usage` and `clawdbot providers list` show
+ provider quota windows (not per-response costs).
+
+## Cost estimation (when shown)
+
+Costs are estimated from your model pricing config:
+
+```
+models.providers..models[].cost
+```
+
+These are **USD per 1M tokens** for `input`, `output`, `cacheRead`, and
+`cacheWrite`. If pricing is missing, Clawdbot shows tokens only. OAuth tokens
+never show dollar cost.
+
+## Tips for reducing token pressure
+
+- Use `/compact` to summarize long sessions.
+- Trim large tool outputs in your workflows.
+- Keep skill descriptions short (skill list is injected into the prompt).
+- Prefer smaller models for verbose, exploratory work.
+
+See [Skills](/tools/skills) for the exact skill list overhead formula.
diff --git a/docs/tools/skills.md b/docs/tools/skills.md
index 4d6e04654..f5f4e4374 100644
--- a/docs/tools/skills.md
+++ b/docs/tools/skills.md
@@ -163,6 +163,23 @@ This is **scoped to the agent run**, not a global shell environment.
Clawdbot snapshots the eligible skills **when a session starts** and reuses that list for subsequent turns in the same session. Changes to skills or config take effect on the next new session.
+## Token impact (skills list)
+
+When skills are eligible, Clawdbot injects a compact XML list of available skills into the system prompt (via `formatSkillsForPrompt` in `pi-coding-agent`). The cost is deterministic:
+
+- **Base overhead (only when ≥1 skill):** 195 characters.
+- **Per skill:** 97 characters + the length of the XML-escaped ``, ``, and `` values.
+
+Formula (characters):
+
+```
+total = 195 + Σ (97 + len(name_escaped) + len(description_escaped) + len(location_escaped))
+```
+
+Notes:
+- XML escaping expands `& < > " '` into entities (`&`, `<`, etc.), increasing length.
+- Token counts vary by model tokenizer. A rough OpenAI-style estimate is ~4 chars/token, so **97 chars ≈ 24 tokens** per skill plus your actual field lengths.
+
## Managed skills lifecycle
Clawdbot ships a baseline set of skills as **bundled skills** as part of the
diff --git a/docs/tools/slash-commands.md b/docs/tools/slash-commands.md
index 7e06c4abb..6b306b798 100644
--- a/docs/tools/slash-commands.md
+++ b/docs/tools/slash-commands.md
@@ -35,6 +35,7 @@ Directives (`/think`, `/verbose`, `/reasoning`, `/elevated`) are parsed even whe
Text + native (when enabled):
- `/help`
- `/status`
+- `/cost on|off` (toggle per-response usage line)
- `/stop`
- `/restart`
- `/activation mention|always` (groups only)
@@ -52,6 +53,7 @@ Text-only:
Notes:
- Commands accept an optional `:` between the command and args (e.g. `/think: high`, `/send: on`, `/help:`).
+- `/cost` appends per-response token usage; it only shows dollar cost when the model uses an API key (OAuth hides cost).
- `/verbose` is meant for debugging and extra visibility; keep it **off** in normal use.
- `/reasoning` (and `/verbose`) are risky in group settings: they may reveal internal reasoning or tool output you did not intend to expose. Prefer leaving them off, especially in group chats.
diff --git a/docs/tui.md b/docs/tui.md
index c164ec129..6b5207d76 100644
--- a/docs/tui.md
+++ b/docs/tui.md
@@ -77,6 +77,7 @@ Session controls:
- `/think `
- `/verbose `
- `/reasoning `
+- `/cost `
- `/elevated ` (alias: `/elev`)
- `/activation `
- `/deliver `
diff --git a/docs/web/tui.md b/docs/web/tui.md
index b4daa0e5a..5135a4cf6 100644
--- a/docs/web/tui.md
+++ b/docs/web/tui.md
@@ -53,6 +53,7 @@ Use SSH tunneling or Tailscale to reach the Gateway WS.
- `/think `
- `/verbose `
- `/reasoning ` (stream = Telegram draft only)
+- `/cost `
- `/elevated `
- `/elev `
- `/activation `
diff --git a/scripts/clawtributors-map.json b/scripts/clawtributors-map.json
index bafdd13f1..5d75a5e8a 100644
--- a/scripts/clawtributors-map.json
+++ b/scripts/clawtributors-map.json
@@ -1,6 +1,7 @@
{
"ensureLogins": [
"jdrhyne",
+ "latitudeki5223",
"manmal"
],
"seedCommit": "d6863f87",
diff --git a/src/agents/model-auth.ts b/src/agents/model-auth.ts
index 1716f7800..4390747ac 100644
--- a/src/agents/model-auth.ts
+++ b/src/agents/model-auth.ts
@@ -100,6 +100,7 @@ export async function resolveApiKeyForProvider(params: {
}
export type EnvApiKeyResult = { apiKey: string; source: string };
+export type ModelAuthMode = "api-key" | "oauth" | "mixed" | "unknown";
export function resolveEnvApiKey(provider: string): EnvApiKeyResult | null {
const applied = new Set(getShellEnvAppliedKeys());
@@ -143,6 +144,37 @@ export function resolveEnvApiKey(provider: string): EnvApiKeyResult | null {
return pick(envVar);
}
+export function resolveModelAuthMode(
+ provider?: string,
+ cfg?: ClawdbotConfig,
+ store?: AuthProfileStore,
+): ModelAuthMode | undefined {
+ const resolved = provider?.trim();
+ if (!resolved) return undefined;
+
+ const authStore = store ?? ensureAuthProfileStore();
+ const profiles = listProfilesForProvider(authStore, resolved);
+ if (profiles.length > 0) {
+ const modes = new Set(
+ profiles
+ .map((id) => authStore.profiles[id]?.type)
+ .filter((mode): mode is "api_key" | "oauth" => Boolean(mode)),
+ );
+ if (modes.has("oauth") && modes.has("api_key")) return "mixed";
+ if (modes.has("oauth")) return "oauth";
+ if (modes.has("api_key")) return "api-key";
+ }
+
+ const envKey = resolveEnvApiKey(resolved);
+ if (envKey?.apiKey) {
+ return envKey.source.includes("OAUTH_TOKEN") ? "oauth" : "api-key";
+ }
+
+ if (getCustomProviderApiKey(cfg, resolved)) return "api-key";
+
+ return "unknown";
+}
+
export async function getApiKeyForModel(params: {
model: Model;
cfg?: ClawdbotConfig;
diff --git a/src/auto-reply/commands-registry.ts b/src/auto-reply/commands-registry.ts
index 8fbbe611e..80dd5b892 100644
--- a/src/auto-reply/commands-registry.ts
+++ b/src/auto-reply/commands-registry.ts
@@ -27,6 +27,13 @@ const CHAT_COMMANDS: ChatCommandDefinition[] = [
description: "Show current status.",
textAliases: ["/status"],
},
+ {
+ key: "cost",
+ nativeName: "cost",
+ description: "Toggle per-response usage line.",
+ textAliases: ["/cost"],
+ acceptsArgs: true,
+ },
{
key: "stop",
nativeName: "stop",
diff --git a/src/auto-reply/reply.triggers.test.ts b/src/auto-reply/reply.triggers.test.ts
index 371da1a8c..da3a52c33 100644
--- a/src/auto-reply/reply.triggers.test.ts
+++ b/src/auto-reply/reply.triggers.test.ts
@@ -212,7 +212,7 @@ describe("trigger handling", () => {
makeCfg(home),
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
- expect(text).toContain("ClawdBot");
+ expect(text).toContain("status");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});
diff --git a/src/auto-reply/reply/agent-runner.ts b/src/auto-reply/reply/agent-runner.ts
index 1225491b2..076b92fa2 100644
--- a/src/auto-reply/reply/agent-runner.ts
+++ b/src/auto-reply/reply/agent-runner.ts
@@ -2,12 +2,13 @@ import crypto from "node:crypto";
import fs from "node:fs";
import { lookupContextTokens } from "../../agents/context.js";
import { DEFAULT_CONTEXT_TOKENS } from "../../agents/defaults.js";
+import { resolveModelAuthMode } from "../../agents/model-auth.js";
import { runWithModelFallback } from "../../agents/model-fallback.js";
import {
queueEmbeddedPiMessage,
runEmbeddedPiAgent,
} from "../../agents/pi-embedded.js";
-import { hasNonzeroUsage } from "../../agents/usage.js";
+import { hasNonzeroUsage, type NormalizedUsage } from "../../agents/usage.js";
import {
loadSessionStore,
resolveSessionTranscriptPath,
@@ -18,6 +19,12 @@ import type { TypingMode } from "../../config/types.js";
import { logVerbose } from "../../globals.js";
import { registerAgentRunContext } from "../../infra/agent-events.js";
import { defaultRuntime } from "../../runtime.js";
+import {
+ estimateUsageCost,
+ formatTokenCount,
+ formatUsd,
+ resolveModelCostConfig,
+} from "../../utils/usage-format.js";
import { stripHeartbeatToken } from "../heartbeat.js";
import type { OriginatingChannelType, TemplateContext } from "../templating.js";
import { normalizeVerboseLevel, type VerboseLevel } from "../thinking.js";
@@ -62,6 +69,65 @@ const formatBunFetchSocketError = (message: string) => {
].join("\n");
};
+const formatResponseUsageLine = (params: {
+ usage?: NormalizedUsage;
+ showCost: boolean;
+ costConfig?: {
+ input: number;
+ output: number;
+ cacheRead: number;
+ cacheWrite: number;
+ };
+}): string | null => {
+ const usage = params.usage;
+ if (!usage) return null;
+ const input = usage.input;
+ const output = usage.output;
+ if (typeof input !== "number" && typeof output !== "number") return null;
+ const inputLabel = typeof input === "number" ? formatTokenCount(input) : "?";
+ const outputLabel =
+ typeof output === "number" ? formatTokenCount(output) : "?";
+ const cost =
+ params.showCost && typeof input === "number" && typeof output === "number"
+ ? estimateUsageCost({
+ usage: {
+ input,
+ output,
+ cacheRead: usage.cacheRead,
+ cacheWrite: usage.cacheWrite,
+ },
+ cost: params.costConfig,
+ })
+ : undefined;
+ const costLabel = params.showCost ? formatUsd(cost) : undefined;
+ const suffix = costLabel ? ` · est ${costLabel}` : "";
+ return `Usage: ${inputLabel} in / ${outputLabel} out${suffix}`;
+};
+
+const appendUsageLine = (
+ payloads: ReplyPayload[],
+ line: string,
+): ReplyPayload[] => {
+ let index = -1;
+ for (let i = payloads.length - 1; i >= 0; i -= 1) {
+ if (payloads[i]?.text) {
+ index = i;
+ break;
+ }
+ }
+ if (index === -1) return [...payloads, { text: line }];
+ const existing = payloads[index];
+ const existingText = existing.text ?? "";
+ const separator = existingText.endsWith("\n") ? "" : "\n";
+ const next = {
+ ...existing,
+ text: `${existingText}${separator}${line}`,
+ };
+ const updated = payloads.slice();
+ updated[index] = next;
+ return updated;
+};
+
const withTimeout = async (
promise: Promise,
timeoutMs: number,
@@ -191,6 +257,7 @@ export async function runReplyAgent(params: {
replyToChannel,
);
const applyReplyToMode = createReplyToModeFilter(replyToMode);
+ const cfg = followupRun.run.config;
if (shouldSteer && isStreaming) {
const steered = queueEmbeddedPiMessage(
@@ -242,6 +309,7 @@ export async function runReplyAgent(params: {
let didLogHeartbeatStrip = false;
let autoCompactionCompleted = false;
+ let responseUsageLine: string | undefined;
try {
const runId = crypto.randomUUID();
if (sessionKey) {
@@ -641,20 +709,20 @@ export async function runReplyAgent(params: {
await typingSignals.signalRunStart();
}
- if (sessionStore && sessionKey) {
- const usage = runResult.meta.agentMeta?.usage;
- const modelUsed =
- runResult.meta.agentMeta?.model ?? fallbackModel ?? defaultModel;
- const providerUsed =
- runResult.meta.agentMeta?.provider ??
- fallbackProvider ??
- followupRun.run.provider;
- const contextTokensUsed =
- agentCfgContextTokens ??
- lookupContextTokens(modelUsed) ??
- sessionEntry?.contextTokens ??
- DEFAULT_CONTEXT_TOKENS;
+ const usage = runResult.meta.agentMeta?.usage;
+ const modelUsed =
+ runResult.meta.agentMeta?.model ?? fallbackModel ?? defaultModel;
+ const providerUsed =
+ runResult.meta.agentMeta?.provider ??
+ fallbackProvider ??
+ followupRun.run.provider;
+ const contextTokensUsed =
+ agentCfgContextTokens ??
+ lookupContextTokens(modelUsed) ??
+ sessionEntry?.contextTokens ??
+ DEFAULT_CONTEXT_TOKENS;
+ if (sessionStore && sessionKey) {
if (hasNonzeroUsage(usage)) {
const entry = sessionEntry ?? sessionStore[sessionKey];
if (entry) {
@@ -694,6 +762,29 @@ export async function runReplyAgent(params: {
}
}
+ const responseUsageEnabled =
+ (sessionEntry?.responseUsage ??
+ (sessionKey
+ ? sessionStore?.[sessionKey]?.responseUsage
+ : undefined)) === "on";
+ if (responseUsageEnabled && hasNonzeroUsage(usage)) {
+ const authMode = resolveModelAuthMode(providerUsed, cfg);
+ const showCost = authMode === "api-key";
+ const costConfig = showCost
+ ? resolveModelCostConfig({
+ provider: providerUsed,
+ model: modelUsed,
+ config: cfg,
+ })
+ : undefined;
+ const formatted = formatResponseUsageLine({
+ usage,
+ showCost,
+ costConfig,
+ });
+ if (formatted) responseUsageLine = formatted;
+ }
+
// If verbose is enabled and this is a new session, prepend a session hint.
let finalPayloads = replyPayloads;
if (autoCompactionCompleted) {
@@ -717,6 +808,9 @@ export async function runReplyAgent(params: {
...finalPayloads,
];
}
+ if (responseUsageLine) {
+ finalPayloads = appendUsageLine(finalPayloads, responseUsageLine);
+ }
return finalizeWithFollowup(
finalPayloads.length === 1 ? finalPayloads[0] : finalPayloads,
diff --git a/src/auto-reply/reply/commands.ts b/src/auto-reply/reply/commands.ts
index fba52f153..14289f6c9 100644
--- a/src/auto-reply/reply/commands.ts
+++ b/src/auto-reply/reply/commands.ts
@@ -1,11 +1,5 @@
-import {
- ensureAuthProfileStore,
- listProfilesForProvider,
-} from "../../agents/auth-profiles.js";
-import {
- getCustomProviderApiKey,
- resolveEnvApiKey,
-} from "../../agents/model-auth.js";
+import crypto from "node:crypto";
+import { resolveModelAuthMode } from "../../agents/model-auth.js";
import { normalizeProviderId } from "../../agents/model-selection.js";
import {
abortEmbeddedPiRun,
@@ -55,8 +49,10 @@ import type {
ElevatedLevel,
ReasoningLevel,
ThinkLevel,
+ UsageDisplayLevel,
VerboseLevel,
} from "../thinking.js";
+import { normalizeUsageDisplay } from "../thinking.js";
import type { ReplyPayload } from "../types.js";
import { isAbortTrigger, setAbortMemory } from "./abort.js";
import type { InlineDirectives } from "./directive-handling.js";
@@ -109,36 +105,6 @@ export type CommandContext = {
to?: string;
};
-function resolveModelAuthLabel(
- provider?: string,
- cfg?: ClawdbotConfig,
-): string | undefined {
- const resolved = provider?.trim();
- if (!resolved) return undefined;
-
- const store = ensureAuthProfileStore();
- const profiles = listProfilesForProvider(store, resolved);
- if (profiles.length > 0) {
- const modes = new Set(
- profiles
- .map((id) => store.profiles[id]?.type)
- .filter((mode): mode is "api_key" | "oauth" => Boolean(mode)),
- );
- if (modes.has("oauth") && modes.has("api_key")) return "mixed";
- if (modes.has("oauth")) return "oauth";
- if (modes.has("api_key")) return "api-key";
- }
-
- const envKey = resolveEnvApiKey(resolved);
- if (envKey?.apiKey) {
- return envKey.source.includes("OAUTH_TOKEN") ? "oauth" : "api-key";
- }
-
- if (getCustomProviderApiKey(cfg, resolved)) return "api-key";
-
- return "unknown";
-}
-
function extractCompactInstructions(params: {
rawBody?: string;
ctx: MsgContext;
@@ -468,6 +434,7 @@ export async function handleCommands(params: {
defaultGroupActivation())
: undefined;
const statusText = buildStatusMessage({
+ config: cfg,
agent: {
...cfg.agent,
model: {
@@ -488,7 +455,7 @@ export async function handleCommands(params: {
resolvedVerbose: resolvedVerboseLevel,
resolvedReasoning: resolvedReasoningLevel,
resolvedElevated: resolvedElevatedLevel,
- modelAuth: resolveModelAuthLabel(provider, cfg),
+ modelAuth: resolveModelAuthMode(provider, cfg),
usageLine: usageLine ?? undefined,
queue: {
mode: queueSettings.mode,
@@ -503,6 +470,51 @@ export async function handleCommands(params: {
return { shouldContinue: false, reply: { text: statusText } };
}
+ const costRequested =
+ command.commandBodyNormalized === "/cost" ||
+ command.commandBodyNormalized.startsWith("/cost ");
+ if (allowTextCommands && costRequested) {
+ if (!command.isAuthorizedSender) {
+ logVerbose(
+ `Ignoring /cost from unauthorized sender: ${command.senderE164 || ""}`,
+ );
+ return { shouldContinue: false };
+ }
+ const rawArgs = command.commandBodyNormalized.slice("/cost".length).trim();
+ const normalized =
+ rawArgs.length > 0 ? normalizeUsageDisplay(rawArgs) : undefined;
+ if (rawArgs.length > 0 && !normalized) {
+ return {
+ shouldContinue: false,
+ reply: { text: "⚙️ Usage: /cost on|off" },
+ };
+ }
+ const current: UsageDisplayLevel =
+ sessionEntry?.responseUsage === "on" ? "on" : "off";
+ const next = normalized ?? (current === "on" ? "off" : "on");
+ if (sessionStore && sessionKey) {
+ const entry = sessionEntry ??
+ sessionStore[sessionKey] ?? {
+ sessionId: crypto.randomUUID(),
+ updatedAt: Date.now(),
+ };
+ if (next === "off") delete entry.responseUsage;
+ else entry.responseUsage = next;
+ entry.updatedAt = Date.now();
+ sessionStore[sessionKey] = entry;
+ if (storePath) {
+ await saveSessionStore(storePath, sessionStore);
+ }
+ }
+ return {
+ shouldContinue: false,
+ reply: {
+ text:
+ next === "on" ? "⚙️ Usage line enabled." : "⚙️ Usage line disabled.",
+ },
+ };
+ }
+
const stopRequested = command.commandBodyNormalized === "/stop";
if (allowTextCommands && stopRequested) {
if (!command.isAuthorizedSender) {
diff --git a/src/auto-reply/reply/session.ts b/src/auto-reply/reply/session.ts
index 65744c62e..5cf3bd8cc 100644
--- a/src/auto-reply/reply/session.ts
+++ b/src/auto-reply/reply/session.ts
@@ -194,6 +194,7 @@ export async function initSessionState(params: {
// Persist previously stored thinking/verbose levels when present.
thinkingLevel: persistedThinking ?? baseEntry?.thinkingLevel,
verboseLevel: persistedVerbose ?? baseEntry?.verboseLevel,
+ responseUsage: baseEntry?.responseUsage,
modelOverride: persistedModelOverride ?? baseEntry?.modelOverride,
providerOverride: persistedProviderOverride ?? baseEntry?.providerOverride,
sendPolicy: baseEntry?.sendPolicy,
diff --git a/src/auto-reply/status.test.ts b/src/auto-reply/status.test.ts
index fe6035c88..093690ea3 100644
--- a/src/auto-reply/status.test.ts
+++ b/src/auto-reply/status.test.ts
@@ -63,20 +63,18 @@ describe("buildStatusMessage", () => {
resolvedThink: "medium",
resolvedVerbose: "off",
queue: { mode: "collect", depth: 0 },
- now: 10 * 60_000, // 10 minutes later
+ modelAuth: "api-key",
});
- expect(text).toContain("🦞 ClawdBot");
- expect(text).toContain("🧠 Model:");
- expect(text).toContain("Runtime: direct");
- expect(text).toContain("Context: 16k/32k (50%)");
- expect(text).toContain("🧹 Compactions: 2");
- expect(text).toContain("Session: agent:main:main");
- expect(text).toContain("updated 10m ago");
- expect(text).toContain("Think: medium");
- expect(text).not.toContain("Verbose");
- expect(text).toContain("Elevated");
- expect(text).toContain("Queue: collect");
+ expect(text).toContain("status agent:main:main");
+ expect(text).toContain("model anthropic/pi:opus (api-key)");
+ expect(text).toContain("Context 16k/32k (50%)");
+ expect(text).toContain("compactions 2");
+ expect(text).toContain("think medium");
+ expect(text).toContain("verbose off");
+ expect(text).toContain("reasoning off");
+ expect(text).toContain("elevated on");
+ expect(text).toContain("queue collect");
});
it("shows verbose/elevated labels only when enabled", () => {
@@ -91,10 +89,8 @@ describe("buildStatusMessage", () => {
queue: { mode: "collect", depth: 0 },
});
- expect(text).toContain("Verbose");
- expect(text).toContain("Elevated");
- expect(text).not.toContain("Verbose:");
- expect(text).not.toContain("Elevated:");
+ expect(text).toContain("verbose on");
+ expect(text).toContain("elevated on");
});
it("prefers model overrides over last-run model", () => {
@@ -115,9 +111,10 @@ describe("buildStatusMessage", () => {
sessionKey: "agent:main:main",
sessionScope: "per-sender",
queue: { mode: "collect", depth: 0 },
+ modelAuth: "api-key",
});
- expect(text).toContain("🧠 Model: openai/gpt-4.1-mini");
+ expect(text).toContain("model openai/gpt-4.1-mini");
});
it("keeps provider prefix from configured model", () => {
@@ -127,21 +124,23 @@ describe("buildStatusMessage", () => {
},
sessionScope: "per-sender",
queue: { mode: "collect", depth: 0 },
+ modelAuth: "api-key",
});
- expect(text).toContain("🧠 Model: google-antigravity/claude-sonnet-4-5");
+ expect(text).toContain("model google-antigravity/claude-sonnet-4-5");
});
it("handles missing agent config gracefully", () => {
const text = buildStatusMessage({
agent: {},
sessionScope: "per-sender",
- webLinked: false,
+ queue: { mode: "collect", depth: 0 },
+ modelAuth: "api-key",
});
- expect(text).toContain("🧠 Model:");
- expect(text).toContain("Context:");
- expect(text).toContain("Queue:");
+ expect(text).toContain("model");
+ expect(text).toContain("Context");
+ expect(text).toContain("queue collect");
});
it("includes group activation for group sessions", () => {
@@ -156,9 +155,10 @@ describe("buildStatusMessage", () => {
sessionKey: "agent:main:whatsapp:group:123@g.us",
sessionScope: "per-sender",
queue: { mode: "collect", depth: 0 },
+ modelAuth: "api-key",
});
- expect(text).toContain("Activation: always");
+ expect(text).toContain("activation always");
});
it("shows queue details when overridden", () => {
@@ -175,10 +175,11 @@ describe("buildStatusMessage", () => {
dropPolicy: "old",
showDetails: true,
},
+ modelAuth: "api-key",
});
expect(text).toContain(
- "Queue: collect (depth 3 · debounce 2s · cap 5 · drop old)",
+ "queue collect (depth 3 · debounce 2s · cap 5 · drop old)",
);
});
@@ -190,12 +191,10 @@ describe("buildStatusMessage", () => {
sessionScope: "per-sender",
queue: { mode: "collect", depth: 0 },
usageLine: "📊 Usage: Claude 80% left (5h)",
+ modelAuth: "api-key",
});
- const lines = text.split("\n");
- const contextIndex = lines.findIndex((line) => line.startsWith("📚 "));
- expect(contextIndex).toBeGreaterThan(-1);
- expect(lines[contextIndex + 1]).toBe("📊 Usage: Claude 80% left (5h)");
+ expect(text).toContain("📊 Usage: Claude 80% left (5h)");
});
it("prefers cached prompt tokens from the session log", async () => {
@@ -255,9 +254,10 @@ describe("buildStatusMessage", () => {
sessionScope: "per-sender",
queue: { mode: "collect", depth: 0 },
includeTranscriptUsage: true,
+ modelAuth: "api-key",
});
- expect(text).toContain("Context: 1.0k/32k");
+ expect(text).toContain("Context 1.0k/32k");
} finally {
restoreHomeEnv(previousHome);
fs.rmSync(dir, { recursive: true, force: true });
diff --git a/src/auto-reply/status.ts b/src/auto-reply/status.ts
index fc374d83a..5ad5ec8b9 100644
--- a/src/auto-reply/status.ts
+++ b/src/auto-reply/status.ts
@@ -6,6 +6,7 @@ import {
DEFAULT_MODEL,
DEFAULT_PROVIDER,
} from "../agents/defaults.js";
+import { resolveModelAuthMode } from "../agents/model-auth.js";
import { resolveConfiguredModelRef } from "../agents/model-selection.js";
import {
derivePromptTokens,
@@ -14,13 +15,16 @@ import {
} from "../agents/usage.js";
import type { ClawdbotConfig } from "../config/config.js";
import {
- resolveMainSessionKey,
resolveSessionFilePath,
type SessionEntry,
type SessionScope,
} from "../config/sessions.js";
-import { resolveCommitHash } from "../infra/git-commit.js";
-import { VERSION } from "../version.js";
+import {
+ estimateUsageCost,
+ formatTokenCount as formatTokenCountShared,
+ formatUsd,
+ resolveModelCostConfig,
+} from "../utils/usage-format.js";
import type {
ElevatedLevel,
ReasoningLevel,
@@ -30,6 +34,8 @@ import type {
type AgentConfig = NonNullable;
+export const formatTokenCount = formatTokenCountShared;
+
type QueueStatus = {
mode?: string;
depth?: number;
@@ -40,6 +46,7 @@ type QueueStatus = {
};
type StatusArgs = {
+ config?: ClawdbotConfig;
agent: AgentConfig;
sessionEntry?: SessionEntry;
sessionKey?: string;
@@ -53,37 +60,20 @@ type StatusArgs = {
usageLine?: string;
queue?: QueueStatus;
includeTranscriptUsage?: boolean;
- now?: number;
};
-const formatAge = (ms?: number | null) => {
- if (!ms || ms < 0) return "unknown";
- const minutes = Math.round(ms / 60_000);
- if (minutes < 1) return "just now";
- if (minutes < 60) return `${minutes}m ago`;
- const hours = Math.round(minutes / 60);
- if (hours < 48) return `${hours}h ago`;
- const days = Math.round(hours / 24);
- return `${days}d ago`;
-};
-
-const formatKTokens = (value: number) =>
- `${(value / 1000).toFixed(value >= 10_000 ? 0 : 1)}k`;
-
-export const formatTokenCount = (value: number) => formatKTokens(value);
-
const formatTokens = (
total: number | null | undefined,
contextTokens: number | null,
) => {
const ctx = contextTokens ?? null;
if (total == null) {
- const ctxLabel = ctx ? formatKTokens(ctx) : "?";
- return `unknown/${ctxLabel}`;
+ const ctxLabel = ctx ? formatTokenCount(ctx) : "?";
+ return `?/${ctxLabel}`;
}
const pct = ctx ? Math.min(999, Math.round((total / ctx) * 100)) : null;
- const totalLabel = formatKTokens(total);
- const ctxLabel = ctx ? formatKTokens(ctx) : "?";
+ const totalLabel = formatTokenCount(total);
+ const ctxLabel = ctx ? formatTokenCount(ctx) : "?";
return `${totalLabel}/${ctxLabel}${pct !== null ? ` (${pct}%)` : ""}`;
};
@@ -171,8 +161,15 @@ const readUsageFromSessionLog = (
}
};
+const formatUsagePair = (input?: number | null, output?: number | null) => {
+ if (input == null && output == null) return null;
+ const inputLabel = typeof input === "number" ? formatTokenCount(input) : "?";
+ const outputLabel =
+ typeof output === "number" ? formatTokenCount(output) : "?";
+ return `usage ${inputLabel} in / ${outputLabel} out`;
+};
+
export function buildStatusMessage(args: StatusArgs): string {
- const now = args.now ?? Date.now();
const entry = args.sessionEntry;
const resolved = resolveConfiguredModelRef({
cfg: { agent: args.agent ?? {} },
@@ -188,6 +185,8 @@ export function buildStatusMessage(args: StatusArgs): string {
lookupContextTokens(model) ??
DEFAULT_CONTEXT_TOKENS;
+ let inputTokens = entry?.inputTokens;
+ let outputTokens = entry?.outputTokens;
let totalTokens =
entry?.totalTokens ??
(entry?.inputTokens ?? 0) + (entry?.outputTokens ?? 0);
@@ -205,6 +204,8 @@ export function buildStatusMessage(args: StatusArgs): string {
if (!contextTokens && logUsage.model) {
contextTokens = lookupContextTokens(logUsage.model) ?? contextTokens;
}
+ if (!inputTokens || inputTokens === 0) inputTokens = logUsage.input;
+ if (!outputTokens || outputTokens === 0) outputTokens = logUsage.output;
}
}
@@ -218,33 +219,6 @@ export function buildStatusMessage(args: StatusArgs): string {
args.agent?.elevatedDefault ??
"on";
- const runtime = (() => {
- const sandboxMode = args.agent?.sandbox?.mode ?? "off";
- if (sandboxMode === "off") return { label: "direct" };
- const sessionScope = args.sessionScope ?? "per-sender";
- const mainKey = resolveMainSessionKey({
- session: { scope: sessionScope },
- });
- const sessionKey = args.sessionKey?.trim();
- const sandboxed = sessionKey
- ? sandboxMode === "all" || sessionKey !== mainKey.trim()
- : false;
- const runtime = sandboxed ? "docker" : sessionKey ? "direct" : "unknown";
- return {
- label: `${runtime}/${sandboxMode}`,
- };
- })();
-
- const updatedAt = entry?.updatedAt;
- const sessionLine = [
- `Session: ${args.sessionKey ?? "unknown"}`,
- typeof updatedAt === "number"
- ? `updated ${formatAge(now - updatedAt)}`
- : "no activity",
- ]
- .filter(Boolean)
- .join(" • ");
-
const isGroupSession =
entry?.chatType === "group" ||
entry?.chatType === "room" ||
@@ -255,52 +229,66 @@ export function buildStatusMessage(args: StatusArgs): string {
? (args.groupActivation ?? entry?.groupActivation ?? "mention")
: undefined;
- const contextLine = [
- `Context: ${formatTokens(totalTokens, contextTokens ?? null)}`,
- `🧹 Compactions: ${entry?.compactionCount ?? 0}`,
- ]
- .filter(Boolean)
- .join(" · ");
+ const authMode =
+ args.modelAuth ?? resolveModelAuthMode(provider, args.config);
+ const showCost = authMode === "api-key";
+ const costConfig = showCost
+ ? resolveModelCostConfig({
+ provider,
+ model,
+ config: args.config,
+ })
+ : undefined;
+ const hasUsage =
+ typeof inputTokens === "number" || typeof outputTokens === "number";
+ const cost =
+ showCost && hasUsage
+ ? estimateUsageCost({
+ usage: {
+ input: inputTokens ?? undefined,
+ output: outputTokens ?? undefined,
+ },
+ cost: costConfig,
+ })
+ : undefined;
+ const costLabel = showCost && hasUsage ? formatUsd(cost) : undefined;
+
+ const parts: Array = [];
+ parts.push(`status ${args.sessionKey ?? "unknown"}`);
+
+ const modelLabel = model ? `${provider}/${model}` : "unknown";
+ const authLabel = authMode && authMode !== "unknown" ? ` (${authMode})` : "";
+ parts.push(`model ${modelLabel}${authLabel}`);
+
+ const usagePair = formatUsagePair(inputTokens, outputTokens);
+ if (usagePair) parts.push(usagePair);
+ if (costLabel) parts.push(`cost ${costLabel}`);
+
+ const contextSummary = formatContextUsageShort(
+ totalTokens && totalTokens > 0 ? totalTokens : null,
+ contextTokens ?? null,
+ );
+ parts.push(contextSummary);
+ parts.push(`compactions ${entry?.compactionCount ?? 0}`);
+ parts.push(`think ${thinkLevel}`);
+ parts.push(`verbose ${verboseLevel}`);
+ parts.push(`reasoning ${reasoningLevel}`);
+ parts.push(`elevated ${elevatedLevel}`);
+ if (groupActivationValue) parts.push(`activation ${groupActivationValue}`);
const queueMode = args.queue?.mode ?? "unknown";
const queueDetails = formatQueueDetails(args.queue);
- const optionParts = [
- `Runtime: ${runtime.label}`,
- `Think: ${thinkLevel}`,
- verboseLevel === "on" ? "Verbose" : null,
- reasoningLevel !== "off" ? `Reasoning: ${reasoningLevel}` : null,
- elevatedLevel === "on" ? "Elevated" : null,
- ];
- const optionsLine = optionParts.filter(Boolean).join(" · ");
- const activationParts = [
- groupActivationValue ? `👥 Activation: ${groupActivationValue}` : null,
- `🪢 Queue: ${queueMode}${queueDetails}`,
- ];
- const activationLine = activationParts.filter(Boolean).join(" · ");
+ parts.push(`queue ${queueMode}${queueDetails}`);
- const modelLabel = model ? `${provider}/${model}` : "unknown";
- const authLabel = args.modelAuth ? ` · 🔑 ${args.modelAuth}` : "";
- const modelLine = `🧠 Model: ${modelLabel}${authLabel}`;
- const commit = resolveCommitHash();
- const versionLine = `🦞 ClawdBot ${VERSION}${commit ? ` (${commit})` : ""}`;
+ if (args.usageLine) parts.push(args.usageLine);
- return [
- versionLine,
- modelLine,
- `📚 ${contextLine}`,
- args.usageLine,
- `🧵 ${sessionLine}`,
- `⚙️ ${optionsLine}`,
- activationLine,
- ]
- .filter(Boolean)
- .join("\n");
+ return parts.filter(Boolean).join(" · ");
}
export function buildHelpMessage(): string {
return [
"ℹ️ Help",
"Shortcuts: /new reset | /compact [instructions] | /restart relink",
- "Options: /think | /verbose on|off | /reasoning on|off | /elevated on|off | /model ",
+ "Options: /think | /verbose on|off | /reasoning on|off | /elevated on|off | /model | /cost on|off",
].join("\n");
}
diff --git a/src/auto-reply/thinking.ts b/src/auto-reply/thinking.ts
index 1550fde76..90ac4ff44 100644
--- a/src/auto-reply/thinking.ts
+++ b/src/auto-reply/thinking.ts
@@ -2,6 +2,7 @@ export type ThinkLevel = "off" | "minimal" | "low" | "medium" | "high";
export type VerboseLevel = "off" | "on";
export type ElevatedLevel = "off" | "on";
export type ReasoningLevel = "off" | "on" | "stream";
+export type UsageDisplayLevel = "off" | "on";
// Normalize user-provided thinking level strings to the canonical enum.
export function normalizeThinkLevel(
@@ -46,6 +47,19 @@ export function normalizeVerboseLevel(
return undefined;
}
+// Normalize response-usage display flags used to toggle cost/token lines.
+export function normalizeUsageDisplay(
+ raw?: string | null,
+): UsageDisplayLevel | undefined {
+ if (!raw) return undefined;
+ const key = raw.toLowerCase();
+ if (["off", "false", "no", "0", "disable", "disabled"].includes(key))
+ return "off";
+ if (["on", "true", "yes", "1", "enable", "enabled"].includes(key))
+ return "on";
+ return undefined;
+}
+
// Normalize elevated flags used to toggle elevated bash permissions.
export function normalizeElevatedLevel(
raw?: string | null,
diff --git a/src/config/sessions.ts b/src/config/sessions.ts
index 93e4c0d93..6dfa7b3be 100644
--- a/src/config/sessions.ts
+++ b/src/config/sessions.ts
@@ -87,6 +87,7 @@ export type SessionEntry = {
verboseLevel?: string;
reasoningLevel?: string;
elevatedLevel?: string;
+ responseUsage?: "on" | "off";
providerOverride?: string;
modelOverride?: string;
authProfileOverride?: string;
diff --git a/src/gateway/protocol/schema.ts b/src/gateway/protocol/schema.ts
index ac11af14c..4e2e98700 100644
--- a/src/gateway/protocol/schema.ts
+++ b/src/gateway/protocol/schema.ts
@@ -325,6 +325,9 @@ export const SessionsPatchParamsSchema = Type.Object(
thinkingLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
verboseLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
reasoningLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
+ responseUsage: Type.Optional(
+ Type.Union([Type.Literal("on"), Type.Literal("off"), Type.Null()]),
+ ),
elevatedLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
model: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
spawnedBy: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
diff --git a/src/gateway/server-methods/sessions.ts b/src/gateway/server-methods/sessions.ts
index 3e86dfdb1..d3cc38f04 100644
--- a/src/gateway/server-methods/sessions.ts
+++ b/src/gateway/server-methods/sessions.ts
@@ -19,6 +19,7 @@ import { normalizeGroupActivation } from "../../auto-reply/group-activation.js";
import {
normalizeReasoningLevel,
normalizeThinkLevel,
+ normalizeUsageDisplay,
normalizeVerboseLevel,
} from "../../auto-reply/thinking.js";
import { loadConfig } from "../../config/config.js";
@@ -234,6 +235,28 @@ export const sessionsHandlers: GatewayRequestHandlers = {
}
}
+ if ("responseUsage" in p) {
+ const raw = p.responseUsage;
+ if (raw === null) {
+ delete next.responseUsage;
+ } else if (raw !== undefined) {
+ const normalized = normalizeUsageDisplay(String(raw));
+ if (!normalized) {
+ respond(
+ false,
+ undefined,
+ errorShape(
+ ErrorCodes.INVALID_REQUEST,
+ 'invalid responseUsage (use "on"|"off")',
+ ),
+ );
+ return;
+ }
+ if (normalized === "off") delete next.responseUsage;
+ else next.responseUsage = normalized;
+ }
+ }
+
if ("model" in p) {
const raw = p.model;
if (raw === null) {
@@ -394,6 +417,7 @@ export const sessionsHandlers: GatewayRequestHandlers = {
thinkingLevel: entry?.thinkingLevel,
verboseLevel: entry?.verboseLevel,
reasoningLevel: entry?.reasoningLevel,
+ responseUsage: entry?.responseUsage,
model: entry?.model,
contextTokens: entry?.contextTokens,
sendPolicy: entry?.sendPolicy,
diff --git a/src/gateway/session-utils.ts b/src/gateway/session-utils.ts
index dd3bb0024..6df2cf9e5 100644
--- a/src/gateway/session-utils.ts
+++ b/src/gateway/session-utils.ts
@@ -51,6 +51,8 @@ export type GatewaySessionRow = {
inputTokens?: number;
outputTokens?: number;
totalTokens?: number;
+ responseUsage?: "on" | "off";
+ modelProvider?: string;
model?: string;
contextTokens?: number;
lastProvider?: SessionEntry["lastProvider"];
@@ -503,6 +505,8 @@ export function listSessionsFromStore(params: {
inputTokens: entry?.inputTokens,
outputTokens: entry?.outputTokens,
totalTokens: total,
+ responseUsage: entry?.responseUsage,
+ modelProvider: entry?.modelProvider,
model: entry?.model,
contextTokens: entry?.contextTokens,
lastProvider: entry?.lastProvider,
diff --git a/src/tui/commands.ts b/src/tui/commands.ts
index 6b4d29947..516ed7f89 100644
--- a/src/tui/commands.ts
+++ b/src/tui/commands.ts
@@ -64,6 +64,14 @@ export function getSlashCommands(): SlashCommand[] {
(value) => ({ value, label: value }),
),
},
+ {
+ name: "cost",
+ description: "Toggle per-response usage line",
+ getArgumentCompletions: (prefix) =>
+ TOGGLE.filter((v) => v.startsWith(prefix.toLowerCase())).map(
+ (value) => ({ value, label: value }),
+ ),
+ },
{
name: "elevated",
description: "Set elevated on/off",
@@ -116,6 +124,7 @@ export function helpText(): string {
"/think ",
"/verbose ",
"/reasoning ",
+ "/cost ",
"/elevated ",
"/elev ",
"/activation ",
diff --git a/src/tui/gateway-chat.ts b/src/tui/gateway-chat.ts
index 2b9f0c65b..bd8afd21c 100644
--- a/src/tui/gateway-chat.ts
+++ b/src/tui/gateway-chat.ts
@@ -44,7 +44,11 @@ export type GatewaySessionList = {
sendPolicy?: string;
model?: string;
contextTokens?: number | null;
+ inputTokens?: number | null;
+ outputTokens?: number | null;
totalTokens?: number | null;
+ responseUsage?: "on" | "off";
+ modelProvider?: string;
displayName?: string;
provider?: string;
room?: string;
diff --git a/src/tui/tui.ts b/src/tui/tui.ts
index 03d2cc86e..5a189ba39 100644
--- a/src/tui/tui.ts
+++ b/src/tui/tui.ts
@@ -6,12 +6,14 @@ import {
Text,
TUI,
} from "@mariozechner/pi-tui";
+import { normalizeUsageDisplay } from "../auto-reply/thinking.js";
import { loadConfig } from "../config/config.js";
import {
buildAgentMainSessionKey,
normalizeAgentId,
parseAgentSessionKey,
} from "../routing/session-key.js";
+import { formatTokenCount } from "../utils/usage-format.js";
import { getSlashCommands, helpText, parseCommand } from "./commands.js";
import { ChatLog } from "./components/chat-log.js";
import { CustomEditor } from "./components/custom-editor.js";
@@ -52,8 +54,12 @@ type SessionInfo = {
verboseLevel?: string;
reasoningLevel?: string;
model?: string;
+ modelProvider?: string;
contextTokens?: number | null;
+ inputTokens?: number | null;
+ outputTokens?: number | null;
totalTokens?: number | null;
+ responseUsage?: "on" | "off";
updatedAt?: number | null;
displayName?: string;
};
@@ -99,13 +105,16 @@ function extractTextFromMessage(
}
function formatTokens(total?: number | null, context?: number | null) {
- if (!total && !context) return "tokens ?";
- if (!context) return `tokens ${total ?? 0}`;
+ if (total == null && context == null) return "tokens ?";
+ const totalLabel = total == null ? "?" : formatTokenCount(total);
+ if (context == null) return `tokens ${totalLabel}`;
const pct =
typeof total === "number" && context > 0
? Math.min(999, Math.round((total / context) * 100))
: null;
- return `tokens ${total ?? 0}/${context}${pct !== null ? ` (${pct}%)` : ""}`;
+ return `tokens ${totalLabel}/${formatTokenCount(context)}${
+ pct !== null ? ` (${pct}%)` : ""
+ }`;
}
function asString(value: unknown, fallback = ""): string {
@@ -213,7 +222,11 @@ export async function runTui(opts: TuiOptions) {
? `${sessionKeyLabel} (${sessionInfo.displayName})`
: sessionKeyLabel;
const agentLabel = formatAgentLabel(currentAgentId);
- const modelLabel = sessionInfo.model ?? "unknown";
+ const modelLabel = sessionInfo.model
+ ? sessionInfo.modelProvider
+ ? `${sessionInfo.modelProvider}/${sessionInfo.model}`
+ : sessionInfo.model
+ : "unknown";
const tokens = formatTokens(
sessionInfo.totalTokens ?? null,
sessionInfo.contextTokens ?? null,
@@ -321,8 +334,12 @@ export async function runTui(opts: TuiOptions) {
verboseLevel: entry?.verboseLevel,
reasoningLevel: entry?.reasoningLevel,
model: entry?.model ?? result.defaults?.model ?? undefined,
+ modelProvider: entry?.modelProvider,
contextTokens: entry?.contextTokens ?? result.defaults?.contextTokens,
+ inputTokens: entry?.inputTokens ?? null,
+ outputTokens: entry?.outputTokens ?? null,
totalTokens: entry?.totalTokens ?? null,
+ responseUsage: entry?.responseUsage,
updatedAt: entry?.updatedAt ?? null,
displayName: entry?.displayName,
};
@@ -773,6 +790,28 @@ export async function runTui(opts: TuiOptions) {
chatLog.addSystem(`reasoning failed: ${String(err)}`);
}
break;
+ case "cost": {
+ const normalized = args ? normalizeUsageDisplay(args) : undefined;
+ if (args && !normalized) {
+ chatLog.addSystem("usage: /cost ");
+ break;
+ }
+ const current = sessionInfo.responseUsage === "on" ? "on" : "off";
+ const next = normalized ?? (current === "on" ? "off" : "on");
+ try {
+ await client.patchSession({
+ key: currentSessionKey,
+ responseUsage: next === "off" ? null : next,
+ });
+ chatLog.addSystem(
+ next === "on" ? "usage line enabled" : "usage line disabled",
+ );
+ await refreshSessionInfo();
+ } catch (err) {
+ chatLog.addSystem(`cost failed: ${String(err)}`);
+ }
+ break;
+ }
case "elevated":
if (!args) {
chatLog.addSystem("usage: /elevated ");
diff --git a/src/utils/usage-format.test.ts b/src/utils/usage-format.test.ts
new file mode 100644
index 000000000..d77a89356
--- /dev/null
+++ b/src/utils/usage-format.test.ts
@@ -0,0 +1,60 @@
+import { describe, expect, it } from "vitest";
+import type { ClawdbotConfig } from "../config/config.js";
+import {
+ estimateUsageCost,
+ formatTokenCount,
+ formatUsd,
+ resolveModelCostConfig,
+} from "./usage-format.js";
+
+describe("usage-format", () => {
+ it("formats token counts", () => {
+ expect(formatTokenCount(999)).toBe("999");
+ expect(formatTokenCount(1234)).toBe("1.2k");
+ expect(formatTokenCount(12000)).toBe("12k");
+ expect(formatTokenCount(2_500_000)).toBe("2.5m");
+ });
+
+ it("formats USD values", () => {
+ expect(formatUsd(1.234)).toBe("$1.23");
+ expect(formatUsd(0.5)).toBe("$0.50");
+ expect(formatUsd(0.0042)).toBe("$0.0042");
+ });
+
+ it("resolves model cost config and estimates usage cost", () => {
+ const config = {
+ models: {
+ providers: {
+ test: {
+ models: [
+ {
+ id: "m1",
+ cost: { input: 1, output: 2, cacheRead: 0.5, cacheWrite: 0 },
+ },
+ ],
+ },
+ },
+ },
+ } as ClawdbotConfig;
+
+ const cost = resolveModelCostConfig({
+ provider: "test",
+ model: "m1",
+ config,
+ });
+
+ expect(cost).toEqual({
+ input: 1,
+ output: 2,
+ cacheRead: 0.5,
+ cacheWrite: 0,
+ });
+
+ const total = estimateUsageCost({
+ usage: { input: 1000, output: 500, cacheRead: 2000 },
+ cost,
+ });
+
+ expect(total).toBeCloseTo(0.003);
+ });
+});
diff --git a/src/utils/usage-format.ts b/src/utils/usage-format.ts
new file mode 100644
index 000000000..3d391b1a1
--- /dev/null
+++ b/src/utils/usage-format.ts
@@ -0,0 +1,69 @@
+import type { NormalizedUsage } from "../agents/usage.js";
+import type { ClawdbotConfig } from "../config/config.js";
+
+export type ModelCostConfig = {
+ input: number;
+ output: number;
+ cacheRead: number;
+ cacheWrite: number;
+};
+
+export type UsageTotals = {
+ input?: number;
+ output?: number;
+ cacheRead?: number;
+ cacheWrite?: number;
+ total?: number;
+};
+
+export function formatTokenCount(value?: number): string {
+ if (value === undefined || !Number.isFinite(value)) return "0";
+ const safe = Math.max(0, value);
+ if (safe >= 1_000_000) return `${(safe / 1_000_000).toFixed(1)}m`;
+ if (safe >= 1_000)
+ return `${(safe / 1_000).toFixed(safe >= 10_000 ? 0 : 1)}k`;
+ return String(Math.round(safe));
+}
+
+export function formatUsd(value?: number): string | undefined {
+ if (value === undefined || !Number.isFinite(value)) return undefined;
+ if (value >= 1) return `$${value.toFixed(2)}`;
+ if (value >= 0.01) return `$${value.toFixed(2)}`;
+ return `$${value.toFixed(4)}`;
+}
+
+export function resolveModelCostConfig(params: {
+ provider?: string;
+ model?: string;
+ config?: ClawdbotConfig;
+}): ModelCostConfig | undefined {
+ const provider = params.provider?.trim();
+ const model = params.model?.trim();
+ if (!provider || !model) return undefined;
+ const providers = params.config?.models?.providers ?? {};
+ const entry = providers[provider]?.models?.find((item) => item.id === model);
+ return entry?.cost;
+}
+
+const toNumber = (value: number | undefined): number =>
+ typeof value === "number" && Number.isFinite(value) ? value : 0;
+
+export function estimateUsageCost(params: {
+ usage?: NormalizedUsage | UsageTotals | null;
+ cost?: ModelCostConfig;
+}): number | undefined {
+ const usage = params.usage;
+ const cost = params.cost;
+ if (!usage || !cost) return undefined;
+ const input = toNumber(usage.input);
+ const output = toNumber(usage.output);
+ const cacheRead = toNumber(usage.cacheRead);
+ const cacheWrite = toNumber(usage.cacheWrite);
+ const total =
+ input * cost.input +
+ output * cost.output +
+ cacheRead * cost.cacheRead +
+ cacheWrite * cost.cacheWrite;
+ if (!Number.isFinite(total)) return undefined;
+ return total / 1_000_000;
+}