feat(slash-commands): usage footer modes

This commit is contained in:
Peter Steinberger
2026-01-18 05:35:22 +00:00
parent e7a4931932
commit 2dabce59ce
38 changed files with 370 additions and 303 deletions

View File

@@ -48,8 +48,8 @@ describe("commands registry args", () => {
it("resolves auto arg menus when missing a choice arg", () => {
const command: ChatCommandDefinition = {
key: "cost",
description: "cost",
key: "usage",
description: "usage",
textAliases: [],
scope: "both",
argsMenu: "auto",
@@ -59,20 +59,20 @@ describe("commands registry args", () => {
name: "mode",
description: "mode",
type: "string",
choices: ["on", "off"],
choices: ["off", "tokens", "full"],
},
],
};
const menu = resolveCommandArgMenu({ command, args: undefined, cfg: {} as never });
expect(menu?.arg.name).toBe("mode");
expect(menu?.choices).toEqual(["on", "off"]);
expect(menu?.choices).toEqual(["off", "tokens", "full"]);
});
it("does not show menus when arg already provided", () => {
const command: ChatCommandDefinition = {
key: "cost",
description: "cost",
key: "usage",
description: "usage",
textAliases: [],
scope: "both",
argsMenu: "auto",
@@ -82,14 +82,14 @@ describe("commands registry args", () => {
name: "mode",
description: "mode",
type: "string",
choices: ["on", "off"],
choices: ["off", "tokens", "full"],
},
],
};
const menu = resolveCommandArgMenu({
command,
args: { values: { mode: "on" } },
args: { values: { mode: "tokens" } },
cfg: {} as never,
});
expect(menu).toBeNull();
@@ -130,8 +130,8 @@ describe("commands registry args", () => {
it("does not show menus when args were provided as raw text only", () => {
const command: ChatCommandDefinition = {
key: "cost",
description: "cost",
key: "usage",
description: "usage",
textAliases: [],
scope: "both",
argsMenu: "auto",
@@ -141,7 +141,7 @@ describe("commands registry args", () => {
name: "mode",
description: "on or off",
type: "string",
choices: ["on", "off"],
choices: ["off", "tokens", "full"],
},
],
};

View File

@@ -225,16 +225,16 @@ export const CHAT_COMMANDS: ChatCommandDefinition[] = (() => {
formatArgs: COMMAND_ARG_FORMATTERS.debug,
}),
defineChatCommand({
key: "cost",
nativeName: "cost",
key: "usage",
nativeName: "usage",
description: "Toggle per-response usage line.",
textAlias: "/cost",
textAlias: "/usage",
args: [
{
name: "mode",
description: "on or off",
description: "off, tokens, or full",
type: "string",
choices: ["on", "off"],
choices: ["off", "tokens", "full"],
},
],
argsMenu: "auto",
@@ -431,7 +431,6 @@ export const CHAT_COMMANDS: ChatCommandDefinition[] = (() => {
.map((dock) => defineDockCommand(dock)),
];
registerAlias(commands, "status", "/usage");
registerAlias(commands, "whoami", "/id");
registerAlias(commands, "think", "/thinking", "/t");
registerAlias(commands, "verbose", "/v");

View File

@@ -144,10 +144,10 @@ describe("directive parsing", () => {
expect(res.cleaned).toBe("thats not /tmp/hello");
});
it("preserves spacing when stripping usage directives before paths", () => {
it("does not treat /usage as a status directive", () => {
const res = extractStatusDirective("thats not /usage:/tmp/hello");
expect(res.hasDirective).toBe(true);
expect(res.cleaned).toBe("thats not /tmp/hello");
expect(res.hasDirective).toBe(false);
expect(res.cleaned).toBe("thats not /usage:/tmp/hello");
});
it("parses queue options and modes", () => {

View File

@@ -159,12 +159,12 @@ describe("trigger handling", () => {
expect(String(replies[0]?.text ?? "")).toContain("Model:");
});
});
it("emits /usage once (alias of /status)", async () => {
it("sets per-response usage footer via /usage", async () => {
await withTempHome(async (home) => {
const blockReplies: Array<{ text?: string }> = [];
const res = await getReplyFromConfig(
{
Body: "/usage",
Body: "/usage tokens",
From: "+1000",
To: "+2000",
Provider: "whatsapp",
@@ -181,7 +181,8 @@ describe("trigger handling", () => {
const replies = res ? (Array.isArray(res) ? res : [res]) : [];
expect(blockReplies.length).toBe(0);
expect(replies.length).toBe(1);
expect(String(replies[0]?.text ?? "")).toContain("Model:");
expect(String(replies[0]?.text ?? "")).toContain("Usage footer: tokens");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});
it("sends one inline status and still returns agent reply for mixed text", async () => {

View File

@@ -203,21 +203,4 @@ describe("trigger handling", () => {
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});
it("reports status via /usage without invoking the agent", async () => {
await withTempHome(async (home) => {
const res = await getReplyFromConfig(
{
Body: "/usage",
From: "+1002",
To: "+2000",
CommandAuthorized: true,
},
{},
makeCfg(home),
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Clawdbot");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});
});

View File

@@ -457,10 +457,16 @@ export async function runReplyAgent(params: {
}
}
const responseUsageEnabled =
(activeSessionEntry?.responseUsage ??
(sessionKey ? activeSessionStore?.[sessionKey]?.responseUsage : undefined)) === "on";
if (responseUsageEnabled && hasNonzeroUsage(usage)) {
const responseUsageRaw =
activeSessionEntry?.responseUsage ??
(sessionKey ? activeSessionStore?.[sessionKey]?.responseUsage : undefined);
const responseUsageMode =
responseUsageRaw === "full"
? "full"
: responseUsageRaw === "tokens" || responseUsageRaw === "on"
? "tokens"
: "off";
if (responseUsageMode !== "off" && hasNonzeroUsage(usage)) {
const authMode = resolveModelAuthMode(providerUsed, cfg);
const showCost = authMode === "api-key";
const costConfig = showCost
@@ -470,11 +476,14 @@ export async function runReplyAgent(params: {
config: cfg,
})
: undefined;
const formatted = formatResponseUsageLine({
let formatted = formatResponseUsageLine({
usage,
showCost,
costConfig,
});
if (formatted && responseUsageMode === "full" && sessionKey) {
formatted = `${formatted} · session ${sessionKey}`;
}
if (formatted) responseUsageLine = formatted;
}

View File

@@ -20,6 +20,7 @@ import {
handleRestartCommand,
handleSendPolicyCommand,
handleStopCommand,
handleUsageCommand,
} from "./commands-session.js";
import type {
CommandHandler,
@@ -31,6 +32,7 @@ const HANDLERS: CommandHandler[] = [
handleBashCommand,
handleActivationCommand,
handleSendPolicyCommand,
handleUsageCommand,
handleRestartCommand,
handleHelpCommand,
handleCommandsListCommand,

View File

@@ -6,6 +6,7 @@ import { createInternalHookEvent, triggerInternalHook } from "../../hooks/intern
import { scheduleGatewaySigusr1Restart, triggerClawdbotRestart } from "../../infra/restart.js";
import { parseActivationCommand } from "../group-activation.js";
import { parseSendPolicyCommand } from "../send-policy.js";
import { normalizeUsageDisplay } from "../thinking.js";
import {
formatAbortReplyText,
isAbortTrigger,
@@ -127,6 +128,57 @@ export const handleSendPolicyCommand: CommandHandler = async (params, allowTextC
};
};
export const handleUsageCommand: CommandHandler = async (params, allowTextCommands) => {
if (!allowTextCommands) return null;
const normalized = params.command.commandBodyNormalized;
if (normalized !== "/usage" && !normalized.startsWith("/usage ")) return null;
if (!params.command.isAuthorizedSender) {
logVerbose(
`Ignoring /usage from unauthorized sender: ${params.command.senderId || "<unknown>"}`,
);
return { shouldContinue: false };
}
const rawArgs = normalized === "/usage" ? "" : normalized.slice("/usage".length).trim();
const requested = rawArgs ? normalizeUsageDisplay(rawArgs) : undefined;
if (rawArgs && !requested) {
return {
shouldContinue: false,
reply: { text: "⚙️ Usage: /usage off|tokens|full" },
};
}
const currentRaw =
params.sessionEntry?.responseUsage ??
(params.sessionKey ? params.sessionStore?.[params.sessionKey]?.responseUsage : undefined);
const current =
currentRaw === "full"
? "full"
: currentRaw === "tokens" || currentRaw === "on"
? "tokens"
: "off";
const next = requested ?? (current === "off" ? "tokens" : current === "tokens" ? "full" : "off");
if (params.sessionEntry && params.sessionStore && params.sessionKey) {
if (next === "off") delete params.sessionEntry.responseUsage;
else params.sessionEntry.responseUsage = next;
params.sessionEntry.updatedAt = Date.now();
params.sessionStore[params.sessionKey] = params.sessionEntry;
if (params.storePath) {
await updateSessionStore(params.storePath, (store) => {
store[params.sessionKey] = params.sessionEntry as SessionEntry;
});
}
}
return {
shouldContinue: false,
reply: {
text: `⚙️ Usage footer: ${next}.`,
},
};
};
export const handleRestartCommand: CommandHandler = async (params, allowTextCommands) => {
if (!allowTextCommands) return null;
if (params.command.commandBodyNormalized !== "/restart") return null;

View File

@@ -10,7 +10,10 @@ import {
resolveAuthProfileOrder,
} from "../../agents/auth-profiles.js";
import { getCustomProviderApiKey, resolveEnvApiKey } from "../../agents/model-auth.js";
import { resolveInternalSessionKey, resolveMainSessionAlias } from "../../agents/tools/sessions-helpers.js";
import {
resolveInternalSessionKey,
resolveMainSessionAlias,
} from "../../agents/tools/sessions-helpers.js";
import { normalizeProviderId } from "../../agents/model-selection.js";
import type { ClawdbotConfig } from "../../config/config.js";
import type { SessionEntry, SessionScope } from "../../config/sessions.js";

View File

@@ -180,10 +180,7 @@ export const handleSubagentsCommand: CommandHandler = async (params, allowTextCo
const sorted = sortSubagentRuns(runs);
const active = sorted.filter((entry) => !entry.endedAt);
const done = sorted.length - active.length;
const lines = [
"🧭 Subagents (current session)",
`Active: ${active.length} · Done: ${done}`,
];
const lines = ["🧭 Subagents (current session)", `Active: ${active.length} · Done: ${done}`];
sorted.forEach((entry, index) => {
const status = formatRunStatus(entry);
const label = formatRunLabel(entry);
@@ -396,8 +393,7 @@ export const handleSubagentsCommand: CommandHandler = async (params, allowTextCo
shouldContinue: false,
reply: {
text:
replyText ??
`✅ Sent to ${formatRunLabel(resolved.entry)} (run ${runId.slice(0, 8)}).`,
replyText ?? `✅ Sent to ${formatRunLabel(resolved.entry)} (run ${runId.slice(0, 8)}).`,
},
};
}

View File

@@ -1,6 +1,9 @@
import { describe, expect, it, vi } from "vitest";
import { addSubagentRunForTests, resetSubagentRegistryForTests } from "../../agents/subagent-registry.js";
import {
addSubagentRunForTests,
resetSubagentRegistryForTests,
} from "../../agents/subagent-registry.js";
import type { ClawdbotConfig } from "../../config/config.js";
import * as internalHooks from "../../hooks/internal-hooks.js";
import type { MsgContext } from "../templating.js";

View File

@@ -149,7 +149,7 @@ export function extractStatusDirective(body?: string): {
hasDirective: boolean;
} {
if (!body) return { cleaned: "", hasDirective: false };
return extractSimpleDirective(body, ["status", "usage"]);
return extractSimpleDirective(body, ["status"]);
}
export type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel };

View File

@@ -6,7 +6,7 @@ const INLINE_SIMPLE_COMMAND_ALIASES = new Map<string, string>([
]);
const INLINE_SIMPLE_COMMAND_RE = /(?:^|\s)\/(help|commands|whoami|id)(?=$|\s|:)/i;
const INLINE_STATUS_RE = /(?:^|\s)\/(?:status|usage)(?=$|\s|:)(?:\s*:\s*)?/gi;
const INLINE_STATUS_RE = /(?:^|\s)\/status(?=$|\s|:)(?:\s*:\s*)?/gi;
export function extractInlineSimpleCommand(body?: string): {
command: string;

View File

@@ -49,12 +49,10 @@ describe("subagents utils", () => {
it("formats run status from outcome and timestamps", () => {
expect(formatRunStatus({ ...baseRun })).toBe("running");
expect(formatRunStatus({ ...baseRun, endedAt: 2000, outcome: { status: "ok" } })).toBe(
"done",
expect(formatRunStatus({ ...baseRun, endedAt: 2000, outcome: { status: "ok" } })).toBe("done");
expect(formatRunStatus({ ...baseRun, endedAt: 2000, outcome: { status: "timeout" } })).toBe(
"timeout",
);
expect(
formatRunStatus({ ...baseRun, endedAt: 2000, outcome: { status: "timeout" } }),
).toBe("timeout");
});
it("formats duration short for seconds and minutes", () => {

View File

@@ -28,10 +28,7 @@ export function resolveSubagentLabel(entry: SubagentRunRecord, fallback = "subag
return raw || fallback;
}
export function formatRunLabel(
entry: SubagentRunRecord,
options?: { maxLength?: number },
) {
export function formatRunLabel(entry: SubagentRunRecord, options?: { maxLength?: number }) {
const raw = resolveSubagentLabel(entry);
const maxLength = options?.maxLength ?? 72;
if (!Number.isFinite(maxLength) || maxLength <= 0) return raw;

View File

@@ -383,7 +383,7 @@ export function buildHelpMessage(cfg?: ClawdbotConfig): string {
"/reasoning on|off",
"/elevated on|off",
"/model <id>",
"/cost on|off",
"/usage off|tokens|full",
];
if (cfg?.commands?.config === true) options.push("/config show");
if (cfg?.commands?.debug === true) options.push("/debug show");

View File

@@ -2,7 +2,7 @@ export type ThinkLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh"
export type VerboseLevel = "off" | "on" | "full";
export type ElevatedLevel = "off" | "on";
export type ReasoningLevel = "off" | "on" | "stream";
export type UsageDisplayLevel = "off" | "on";
export type UsageDisplayLevel = "off" | "tokens" | "full";
function normalizeProviderId(provider?: string | null): string {
if (!provider) return "";
@@ -92,12 +92,14 @@ export function normalizeVerboseLevel(raw?: string | null): VerboseLevel | undef
return undefined;
}
// Normalize response-usage display flags used to toggle cost/token lines.
// Normalize response-usage display modes used to toggle per-response usage footers.
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";
if (["on", "true", "yes", "1", "enable", "enabled"].includes(key)) return "tokens";
if (["tokens", "token", "tok", "minimal", "min"].includes(key)) return "tokens";
if (["full", "session"].includes(key)) return "full";
return undefined;
}