From d8a23cf5ab1cc0545d4e0c03a4057b8ea347351a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 9 Jan 2026 03:14:39 +0000 Subject: [PATCH] fix: restore emoji-rich status output --- docs/concepts/usage-tracking.md | 2 +- docs/token-use.md | 2 +- src/auto-reply/reply.triggers.test.ts | 2 +- src/auto-reply/reply/commands.ts | 4 + src/auto-reply/status.test.ts | 105 ++++++++++++++++++----- src/auto-reply/status.ts | 119 +++++++++++++++++++------- 6 files changed, 181 insertions(+), 53 deletions(-) diff --git a/docs/concepts/usage-tracking.md b/docs/concepts/usage-tracking.md index 84329a656..97e5fcfa6 100644 --- a/docs/concepts/usage-tracking.md +++ b/docs/concepts/usage-tracking.md @@ -11,7 +11,7 @@ read_when: - No estimated costs; only the provider-reported windows. ## Where it shows up -- `/status` in chats: compact one‑liner with session tokens + estimated cost (API key only) and provider quota windows when available. +- `/status` in chats: emoji‑rich status card 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). diff --git a/docs/token-use.md b/docs/token-use.md index d142dcfc4..d628b1fb6 100644 --- a/docs/token-use.md +++ b/docs/token-use.md @@ -38,7 +38,7 @@ Everything the model receives counts toward the context limit: Use these in chat: -- `/status` → **compact one‑liner** with the session model, context usage, +- `/status` → **emoji‑rich status card** 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`). diff --git a/src/auto-reply/reply.triggers.test.ts b/src/auto-reply/reply.triggers.test.ts index f6d3a1a71..d798bab5f 100644 --- a/src/auto-reply/reply.triggers.test.ts +++ b/src/auto-reply/reply.triggers.test.ts @@ -213,7 +213,7 @@ describe("trigger handling", () => { makeCfg(home), ); const text = Array.isArray(res) ? res[0]?.text : res?.text; - expect(text).toContain("status"); + expect(text).toContain("ClawdBot"); expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); }); }); diff --git a/src/auto-reply/reply/commands.ts b/src/auto-reply/reply/commands.ts index 2e847c35e..ea8d5fc03 100644 --- a/src/auto-reply/reply/commands.ts +++ b/src/auto-reply/reply/commands.ts @@ -164,6 +164,7 @@ export async function buildStatusReply(params: { defaultGroupActivation()) : undefined; const statusText = buildStatusMessage({ + config: cfg, agent: { ...cfg.agent, model: { @@ -554,6 +555,9 @@ export async function handleCommands(params: { const reply = await buildStatusReply({ cfg, command, + provider: command.provider, + sessionEntry, + }); sessionEntry, sessionKey, sessionScope, diff --git a/src/auto-reply/status.test.ts b/src/auto-reply/status.test.ts index 093690ea3..7d0c2965f 100644 --- a/src/auto-reply/status.test.ts +++ b/src/auto-reply/status.test.ts @@ -2,6 +2,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; +import type { ClawdbotConfig } from "../config/config.js"; import { buildStatusMessage } from "./status.js"; const HOME_ENV_KEYS = ["HOME", "USERPROFILE", "HOMEDRIVE", "HOMEPATH"] as const; @@ -45,6 +46,27 @@ afterEach(() => { describe("buildStatusMessage", () => { it("summarizes agent readiness and context usage", () => { const text = buildStatusMessage({ + config: { + models: { + providers: { + anthropic: { + apiKey: "test-key", + models: [ + { + id: "pi:opus", + cost: { + input: 1, + output: 1, + cacheRead: 0, + cacheWrite: 0, + }, + }, + ], + }, + }, + }, + }, + } as ClawdbotConfig, agent: { model: "anthropic/pi:opus", contextTokens: 32_000, @@ -52,6 +74,8 @@ describe("buildStatusMessage", () => { sessionEntry: { sessionId: "abc", updatedAt: 0, + inputTokens: 1200, + outputTokens: 800, totalTokens: 16_000, contextTokens: 32_000, thinkingLevel: "low", @@ -64,17 +88,22 @@ describe("buildStatusMessage", () => { resolvedVerbose: "off", queue: { mode: "collect", depth: 0 }, modelAuth: "api-key", + now: 10 * 60_000, // 10 minutes later }); - 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"); + expect(text).toContain("🦞 ClawdBot"); + expect(text).toContain("🧠 Model: anthropic/pi:opus · 🔑 api-key"); + expect(text).toContain("🧮 Tokens: 1.2k in / 800 out"); + expect(text).toContain("💵 Cost: $0.0020"); + 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("Runtime: direct"); + expect(text).toContain("Think: medium"); + expect(text).toContain("Verbose: off"); + expect(text).toContain("Elevated: on"); + expect(text).toContain("Queue: collect"); }); it("shows verbose/elevated labels only when enabled", () => { @@ -89,8 +118,8 @@ describe("buildStatusMessage", () => { queue: { mode: "collect", depth: 0 }, }); - expect(text).toContain("verbose on"); - expect(text).toContain("elevated on"); + expect(text).toContain("Verbose: on"); + expect(text).toContain("Elevated: on"); }); it("prefers model overrides over last-run model", () => { @@ -114,7 +143,7 @@ describe("buildStatusMessage", () => { 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,7 +156,7 @@ describe("buildStatusMessage", () => { 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", () => { @@ -138,9 +167,9 @@ describe("buildStatusMessage", () => { modelAuth: "api-key", }); - expect(text).toContain("model"); - expect(text).toContain("Context"); - expect(text).toContain("queue collect"); + expect(text).toContain("🧠 Model:"); + expect(text).toContain("Context:"); + expect(text).toContain("Queue: collect"); }); it("includes group activation for group sessions", () => { @@ -158,7 +187,7 @@ describe("buildStatusMessage", () => { modelAuth: "api-key", }); - expect(text).toContain("activation always"); + expect(text).toContain("Activation: always"); }); it("shows queue details when overridden", () => { @@ -179,7 +208,7 @@ describe("buildStatusMessage", () => { }); expect(text).toContain( - "queue collect (depth 3 · debounce 2s · cap 5 · drop old)", + "Queue: collect (depth 3 · debounce 2s · cap 5 · drop old)", ); }); @@ -194,7 +223,43 @@ describe("buildStatusMessage", () => { modelAuth: "api-key", }); - expect(text).toContain("📊 Usage: Claude 80% left (5h)"); + 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)"); + }); + + it("hides cost when not using an API key", () => { + const text = buildStatusMessage({ + config: { + models: { + providers: { + anthropic: { + models: [ + { + id: "claude-opus-4-5", + cost: { + input: 1, + output: 1, + cacheRead: 0, + cacheWrite: 0, + }, + }, + ], + }, + }, + }, + }, + } as ClawdbotConfig, + agent: { model: "anthropic/claude-opus-4-5" }, + sessionEntry: { sessionId: "c1", updatedAt: 0, inputTokens: 10 }, + sessionKey: "agent:main:main", + sessionScope: "per-sender", + queue: { mode: "collect", depth: 0 }, + modelAuth: "oauth", + }); + + expect(text).not.toContain("💵 Cost:"); }); it("prefers cached prompt tokens from the session log", async () => { @@ -257,7 +322,7 @@ describe("buildStatusMessage", () => { 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 5ad5ec8b9..fef52f199 100644 --- a/src/auto-reply/status.ts +++ b/src/auto-reply/status.ts @@ -15,16 +15,19 @@ 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 { estimateUsageCost, formatTokenCount as formatTokenCountShared, formatUsd, resolveModelCostConfig, } from "../utils/usage-format.js"; +import { VERSION } from "../version.js"; import type { ElevatedLevel, ReasoningLevel, @@ -60,6 +63,7 @@ type StatusArgs = { usageLine?: string; queue?: QueueStatus; includeTranscriptUsage?: boolean; + now?: number; }; const formatTokens = ( @@ -82,6 +86,17 @@ export const formatContextUsageShort = ( contextTokens: number | null | undefined, ) => `Context ${formatTokens(total, contextTokens ?? null)}`; +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 formatQueueDetails = (queue?: QueueStatus) => { if (!queue) return ""; const depth = typeof queue.depth === "number" ? `depth ${queue.depth}` : null; @@ -166,10 +181,11 @@ const formatUsagePair = (input?: number | null, output?: number | null) => { const inputLabel = typeof input === "number" ? formatTokenCount(input) : "?"; const outputLabel = typeof output === "number" ? formatTokenCount(output) : "?"; - return `usage ${inputLabel} in / ${outputLabel} out`; + return `🧮 Tokens: ${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 ?? {} }, @@ -219,6 +235,33 @@ 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" || @@ -229,8 +272,30 @@ export function buildStatusMessage(args: StatusArgs): string { ? (args.groupActivation ?? entry?.groupActivation ?? "mention") : undefined; - const authMode = - args.modelAuth ?? resolveModelAuthMode(provider, args.config); + const contextLine = [ + `Context: ${formatTokens(totalTokens, contextTokens ?? null)}`, + `🧹 Compactions: ${entry?.compactionCount ?? 0}`, + ] + .filter(Boolean) + .join(" · "); + + const queueMode = args.queue?.mode ?? "unknown"; + const queueDetails = formatQueueDetails(args.queue); + const optionParts = [ + `Runtime: ${runtime.label}`, + `Think: ${thinkLevel}`, + `Verbose: ${verboseLevel}`, + reasoningLevel !== "off" ? `Reasoning: ${reasoningLevel}` : null, + `Elevated: ${elevatedLevel}`, + ]; + const optionsLine = optionParts.filter(Boolean).join(" · "); + const activationParts = [ + groupActivationValue ? `👥 Activation: ${groupActivationValue}` : null, + `🪢 Queue: ${queueMode}${queueDetails}`, + ]; + const activationLine = activationParts.filter(Boolean).join(" · "); + + const authMode = resolveModelAuthMode(provider, args.config); const showCost = authMode === "api-key"; const costConfig = showCost ? resolveModelCostConfig({ @@ -253,36 +318,30 @@ export function buildStatusMessage(args: StatusArgs): string { : 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 authLabelValue = + args.modelAuth ?? + (authMode && authMode !== "unknown" ? authMode : undefined); + const authLabel = authLabelValue ? ` · 🔑 ${authLabelValue}` : ""; + const modelLine = `🧠 Model: ${modelLabel}${authLabel}`; + const commit = resolveCommitHash(); + const versionLine = `🦞 ClawdBot ${VERSION}${commit ? ` (${commit})` : ""}`; const usagePair = formatUsagePair(inputTokens, outputTokens); - if (usagePair) parts.push(usagePair); - if (costLabel) parts.push(`cost ${costLabel}`); + const costLine = costLabel ? `💵 Cost: ${costLabel}` : null; - 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); - parts.push(`queue ${queueMode}${queueDetails}`); - - if (args.usageLine) parts.push(args.usageLine); - - return parts.filter(Boolean).join(" · "); + return [ + versionLine, + modelLine, + usagePair, + costLine, + `📚 ${contextLine}`, + args.usageLine, + `🧵 ${sessionLine}`, + `⚙️ ${optionsLine}`, + activationLine, + ] + .filter(Boolean) + .join("\n"); } export function buildHelpMessage(): string {