feat(slash-commands): usage footer modes
This commit is contained in:
@@ -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"],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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)}).`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user