feat: improve /help and /commands formatting with categories and pagination

- Add CommandCategory type to organize commands into groups (session, options, status, management, media, tools, docks)
- Refactor /help to show grouped sections for better discoverability
- Add pagination support for /commands on Telegram (8 commands per page with nav buttons)
- Show grouped list without pagination on other channels
- Handle commands_page_N callback queries for Telegram pagination navigation
This commit is contained in:
hougangdev
2026-01-27 09:37:22 +08:00
committed by Gustavo Madeira Santana
parent d3a6333ef7
commit d91b4a3045
5 changed files with 384 additions and 48 deletions

View File

@@ -2,7 +2,11 @@ import { listChannelDocks } from "../channels/dock.js";
import { getActivePluginRegistry } from "../plugins/runtime.js";
import { listThinkingLevels } from "./thinking.js";
import { COMMAND_ARG_FORMATTERS } from "./commands-args.js";
import type { ChatCommandDefinition, CommandScope } from "./commands-registry.types.js";
import type {
ChatCommandDefinition,
CommandCategory,
CommandScope,
} from "./commands-registry.types.js";
type DefineChatCommandInput = {
key: string;
@@ -16,6 +20,7 @@ type DefineChatCommandInput = {
textAlias?: string;
textAliases?: string[];
scope?: CommandScope;
category?: CommandCategory;
};
function defineChatCommand(command: DefineChatCommandInput): ChatCommandDefinition {
@@ -37,6 +42,7 @@ function defineChatCommand(command: DefineChatCommandInput): ChatCommandDefiniti
argsMenu: command.argsMenu,
textAliases: aliases,
scope,
category: command.category,
};
}
@@ -48,6 +54,7 @@ function defineDockCommand(dock: ChannelDock): ChatCommandDefinition {
nativeName: `dock_${dock.id}`,
description: `Switch to ${dock.id} for replies.`,
textAliases: [`/dock-${dock.id}`, `/dock_${dock.id}`],
category: "docks",
});
}
@@ -124,18 +131,21 @@ function buildChatCommands(): ChatCommandDefinition[] {
nativeName: "help",
description: "Show available commands.",
textAlias: "/help",
category: "status",
}),
defineChatCommand({
key: "commands",
nativeName: "commands",
description: "List all slash commands.",
textAlias: "/commands",
category: "status",
}),
defineChatCommand({
key: "skill",
nativeName: "skill",
description: "Run a skill by name.",
textAlias: "/skill",
category: "tools",
args: [
{
name: "name",
@@ -156,6 +166,7 @@ function buildChatCommands(): ChatCommandDefinition[] {
nativeName: "status",
description: "Show current status.",
textAlias: "/status",
category: "status",
}),
defineChatCommand({
key: "allowlist",
@@ -163,6 +174,7 @@ function buildChatCommands(): ChatCommandDefinition[] {
textAlias: "/allowlist",
acceptsArgs: true,
scope: "text",
category: "management",
}),
defineChatCommand({
key: "approve",
@@ -170,6 +182,7 @@ function buildChatCommands(): ChatCommandDefinition[] {
description: "Approve or deny exec requests.",
textAlias: "/approve",
acceptsArgs: true,
category: "management",
}),
defineChatCommand({
key: "context",
@@ -177,12 +190,14 @@ function buildChatCommands(): ChatCommandDefinition[] {
description: "Explain how context is built and used.",
textAlias: "/context",
acceptsArgs: true,
category: "status",
}),
defineChatCommand({
key: "tts",
nativeName: "tts",
description: "Control text-to-speech (TTS).",
textAlias: "/tts",
category: "media",
args: [
{
name: "action",
@@ -225,12 +240,14 @@ function buildChatCommands(): ChatCommandDefinition[] {
nativeName: "whoami",
description: "Show your sender id.",
textAlias: "/whoami",
category: "status",
}),
defineChatCommand({
key: "subagents",
nativeName: "subagents",
description: "List/stop/log/info subagent runs for this session.",
textAlias: "/subagents",
category: "management",
args: [
{
name: "action",
@@ -257,6 +274,7 @@ function buildChatCommands(): ChatCommandDefinition[] {
nativeName: "config",
description: "Show or set config values.",
textAlias: "/config",
category: "management",
args: [
{
name: "action",
@@ -284,6 +302,7 @@ function buildChatCommands(): ChatCommandDefinition[] {
nativeName: "debug",
description: "Set runtime debug overrides.",
textAlias: "/debug",
category: "management",
args: [
{
name: "action",
@@ -311,6 +330,7 @@ function buildChatCommands(): ChatCommandDefinition[] {
nativeName: "usage",
description: "Usage footer or cost summary.",
textAlias: "/usage",
category: "options",
args: [
{
name: "mode",
@@ -326,18 +346,21 @@ function buildChatCommands(): ChatCommandDefinition[] {
nativeName: "stop",
description: "Stop the current run.",
textAlias: "/stop",
category: "session",
}),
defineChatCommand({
key: "restart",
nativeName: "restart",
description: "Restart Clawdbot.",
textAlias: "/restart",
category: "tools",
}),
defineChatCommand({
key: "activation",
nativeName: "activation",
description: "Set group activation mode.",
textAlias: "/activation",
category: "management",
args: [
{
name: "mode",
@@ -353,6 +376,7 @@ function buildChatCommands(): ChatCommandDefinition[] {
nativeName: "send",
description: "Set send policy.",
textAlias: "/send",
category: "management",
args: [
{
name: "mode",
@@ -369,6 +393,7 @@ function buildChatCommands(): ChatCommandDefinition[] {
description: "Reset the current session.",
textAlias: "/reset",
acceptsArgs: true,
category: "session",
}),
defineChatCommand({
key: "new",
@@ -376,12 +401,14 @@ function buildChatCommands(): ChatCommandDefinition[] {
description: "Start a new session.",
textAlias: "/new",
acceptsArgs: true,
category: "session",
}),
defineChatCommand({
key: "compact",
description: "Compact the session context.",
textAlias: "/compact",
scope: "text",
category: "session",
args: [
{
name: "instructions",
@@ -396,6 +423,7 @@ function buildChatCommands(): ChatCommandDefinition[] {
nativeName: "think",
description: "Set thinking level.",
textAlias: "/think",
category: "options",
args: [
{
name: "level",
@@ -411,6 +439,7 @@ function buildChatCommands(): ChatCommandDefinition[] {
nativeName: "verbose",
description: "Toggle verbose mode.",
textAlias: "/verbose",
category: "options",
args: [
{
name: "mode",
@@ -426,6 +455,7 @@ function buildChatCommands(): ChatCommandDefinition[] {
nativeName: "reasoning",
description: "Toggle reasoning visibility.",
textAlias: "/reasoning",
category: "options",
args: [
{
name: "mode",
@@ -441,6 +471,7 @@ function buildChatCommands(): ChatCommandDefinition[] {
nativeName: "elevated",
description: "Toggle elevated mode.",
textAlias: "/elevated",
category: "options",
args: [
{
name: "mode",
@@ -456,6 +487,7 @@ function buildChatCommands(): ChatCommandDefinition[] {
nativeName: "exec",
description: "Set exec defaults for this session.",
textAlias: "/exec",
category: "options",
args: [
{
name: "options",
@@ -470,6 +502,7 @@ function buildChatCommands(): ChatCommandDefinition[] {
nativeName: "model",
description: "Show or set the model.",
textAlias: "/model",
category: "options",
args: [
{
name: "model",
@@ -485,12 +518,14 @@ function buildChatCommands(): ChatCommandDefinition[] {
textAlias: "/models",
argsParsing: "none",
acceptsArgs: true,
category: "options",
}),
defineChatCommand({
key: "queue",
nativeName: "queue",
description: "Adjust queue settings.",
textAlias: "/queue",
category: "options",
args: [
{
name: "mode",
@@ -523,6 +558,7 @@ function buildChatCommands(): ChatCommandDefinition[] {
description: "Run host shell commands (host-only).",
textAlias: "/bash",
scope: "text",
category: "tools",
args: [
{
name: "command",

View File

@@ -2,6 +2,15 @@ import type { ClawdbotConfig } from "../config/types.js";
export type CommandScope = "text" | "native" | "both";
export type CommandCategory =
| "session"
| "options"
| "status"
| "management"
| "media"
| "tools"
| "docks";
export type CommandArgType = "string" | "number" | "boolean";
export type CommandArgChoiceContext = {
@@ -51,6 +60,7 @@ export type ChatCommandDefinition = {
formatArgs?: (values: CommandArgValues) => string | undefined;
argsMenu?: CommandArgMenuSpec | "auto";
scope: CommandScope;
category?: CommandCategory;
};
export type NativeCommandSpec = {

View File

@@ -1,6 +1,10 @@
import { logVerbose } from "../../globals.js";
import { listSkillCommandsForWorkspace } from "../skill-commands.js";
import { buildCommandsMessage, buildHelpMessage } from "../status.js";
import {
buildCommandsMessage,
buildCommandsMessagePaginated,
buildHelpMessage,
} from "../status.js";
import { buildStatusReply } from "./commands-status.js";
import { buildContextReply } from "./commands-context-report.js";
import type { CommandHandler } from "./commands-types.js";
@@ -35,12 +39,61 @@ export const handleCommandsListCommand: CommandHandler = async (params, allowTex
workspaceDir: params.workspaceDir,
cfg: params.cfg,
});
const surface = params.ctx.Surface;
// For Telegram, return paginated result with inline buttons
if (surface === "telegram") {
const result = buildCommandsMessagePaginated(params.cfg, skillCommands, {
page: 1,
surface,
});
// Build inline keyboard for pagination if there are multiple pages
if (result.totalPages > 1) {
return {
shouldContinue: false,
reply: {
text: result.text,
channelData: {
telegram: {
buttons: buildCommandsPaginationKeyboard(result.currentPage, result.totalPages),
},
},
},
};
}
return {
shouldContinue: false,
reply: { text: result.text },
};
}
return {
shouldContinue: false,
reply: { text: buildCommandsMessage(params.cfg, skillCommands) },
reply: { text: buildCommandsMessage(params.cfg, skillCommands, { surface }) },
};
};
export function buildCommandsPaginationKeyboard(
currentPage: number,
totalPages: number,
): Array<Array<{ text: string; callback_data: string }>> {
const buttons: Array<{ text: string; callback_data: string }> = [];
if (currentPage > 1) {
buttons.push({ text: "◀ Prev", callback_data: `commands_page_${currentPage - 1}` });
}
buttons.push({ text: `${currentPage}/${totalPages}`, callback_data: "commands_page_noop" });
if (currentPage < totalPages) {
buttons.push({ text: "Next ▶", callback_data: `commands_page_${currentPage + 1}` });
}
return [buttons];
}
export const handleStatusCommand: CommandHandler = async (params, allowTextCommands) => {
if (!allowTextCommands) return null;
const statusRequested =

View File

@@ -29,7 +29,12 @@ import {
resolveModelCostConfig,
} from "../utils/usage-format.js";
import { VERSION } from "../version.js";
import { listChatCommands, listChatCommandsForConfig } from "./commands-registry.js";
import {
listChatCommands,
listChatCommandsForConfig,
type ChatCommandDefinition,
} from "./commands-registry.js";
import type { CommandCategory } from "./commands-registry.types.js";
import { listPluginCommands } from "../plugins/commands.js";
import type { SkillCommandSpec } from "../agents/skills.js";
import type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel } from "./thinking.js";
@@ -427,61 +432,248 @@ export function buildStatusMessage(args: StatusArgs): string {
.join("\n");
}
const CATEGORY_LABELS: Record<CommandCategory, string> = {
session: "Session",
options: "Options",
status: "Status",
management: "Management",
media: "Media",
tools: "Tools",
docks: "Docks",
};
const CATEGORY_ORDER: CommandCategory[] = [
"session",
"options",
"status",
"management",
"media",
"tools",
"docks",
];
function groupCommandsByCategory(
commands: ChatCommandDefinition[],
): Map<CommandCategory, ChatCommandDefinition[]> {
const grouped = new Map<CommandCategory, ChatCommandDefinition[]>();
for (const category of CATEGORY_ORDER) {
grouped.set(category, []);
}
for (const command of commands) {
const category = command.category ?? "tools";
const list = grouped.get(category) ?? [];
list.push(command);
grouped.set(category, list);
}
return grouped;
}
export function buildHelpMessage(cfg?: ClawdbotConfig): string {
const options = [
"/think <level>",
"/verbose on|full|off",
"/reasoning on|off",
"/elevated on|off|ask|full",
"/model <id>",
"/usage off|tokens|full",
];
if (cfg?.commands?.config === true) options.push("/config show");
if (cfg?.commands?.debug === true) options.push("/debug show");
return [
" Help",
"Shortcuts: /new reset | /compact [instructions] | /restart relink (if enabled)",
`Options: ${options.join(" | ")}`,
"Skills: /skill <name> [input]",
"More: /commands for all slash commands",
].join("\n");
const lines = [" Help", ""];
// Session commands - quick shortcuts
lines.push("Session");
lines.push(" /new | /reset | /compact [instructions] | /stop");
lines.push("");
// Options - most commonly used
const optionParts = ["/think <level>", "/model <id>", "/verbose on|off"];
if (cfg?.commands?.config === true) optionParts.push("/config");
if (cfg?.commands?.debug === true) optionParts.push("/debug");
lines.push("Options");
lines.push(` ${optionParts.join(" | ")}`);
lines.push("");
// Status commands
lines.push("Status");
lines.push(" /status | /whoami | /context");
lines.push("");
// Skills
lines.push("Skills");
lines.push(" /skill <name> [input]");
lines.push("");
lines.push("More: /commands for full list");
return lines.join("\n");
}
const COMMANDS_PER_PAGE = 8;
export type CommandsMessageOptions = {
page?: number;
surface?: string;
};
export type CommandsMessageResult = {
text: string;
totalPages: number;
currentPage: number;
hasNext: boolean;
hasPrev: boolean;
};
function formatCommandEntry(command: ChatCommandDefinition): string {
const primary = command.nativeName
? `/${command.nativeName}`
: command.textAliases[0]?.trim() || `/${command.key}`;
const seen = new Set<string>();
const aliases = command.textAliases
.map((alias) => alias.trim())
.filter(Boolean)
.filter((alias) => alias.toLowerCase() !== primary.toLowerCase())
.filter((alias) => {
const key = alias.toLowerCase();
if (seen.has(key)) return false;
seen.add(key);
return true;
});
const aliasLabel = aliases.length ? ` (${aliases.join(", ")})` : "";
const scopeLabel = command.scope === "text" ? " [text]" : "";
return `${primary}${aliasLabel}${scopeLabel} - ${command.description}`;
}
export function buildCommandsMessage(
cfg?: ClawdbotConfig,
skillCommands?: SkillCommandSpec[],
options?: CommandsMessageOptions,
): string {
const lines = [" Slash commands"];
const result = buildCommandsMessagePaginated(cfg, skillCommands, options);
return result.text;
}
export function buildCommandsMessagePaginated(
cfg?: ClawdbotConfig,
skillCommands?: SkillCommandSpec[],
options?: CommandsMessageOptions,
): CommandsMessageResult {
const page = Math.max(1, options?.page ?? 1);
const surface = options?.surface?.toLowerCase();
const isTelegram = surface === "telegram";
const commands = cfg
? listChatCommandsForConfig(cfg, { skillCommands })
: listChatCommands({ skillCommands });
for (const command of commands) {
const primary = command.nativeName
? `/${command.nativeName}`
: command.textAliases[0]?.trim() || `/${command.key}`;
const seen = new Set<string>();
const aliases = command.textAliases
.map((alias) => alias.trim())
.filter(Boolean)
.filter((alias) => alias.toLowerCase() !== primary.toLowerCase())
.filter((alias) => {
const key = alias.toLowerCase();
if (seen.has(key)) return false;
seen.add(key);
return true;
});
const aliasLabel = aliases.length ? ` (aliases: ${aliases.join(", ")})` : "";
const scopeLabel = command.scope === "text" ? " (text-only)" : "";
lines.push(`${primary}${aliasLabel}${scopeLabel} - ${command.description}`);
}
const pluginCommands = listPluginCommands();
if (pluginCommands.length > 0) {
lines.push("");
lines.push("Plugin commands:");
for (const command of pluginCommands) {
const pluginLabel = command.pluginId ? ` (plugin: ${command.pluginId})` : "";
lines.push(`/${command.name}${pluginLabel} - ${command.description}`);
// For non-Telegram surfaces, show grouped list without pagination
if (!isTelegram) {
const grouped = groupCommandsByCategory(commands);
const lines = [" Slash commands", ""];
for (const category of CATEGORY_ORDER) {
const categoryCommands = grouped.get(category) ?? [];
if (categoryCommands.length === 0) continue;
lines.push(`${CATEGORY_LABELS[category]}`);
for (const command of categoryCommands) {
lines.push(` ${formatCommandEntry(command)}`);
}
lines.push("");
}
if (pluginCommands.length > 0) {
lines.push("Plugins");
for (const command of pluginCommands) {
const pluginLabel = command.pluginId ? ` (${command.pluginId})` : "";
lines.push(` /${command.name}${pluginLabel} - ${command.description}`);
}
}
return {
text: lines.join("\n").trim(),
totalPages: 1,
currentPage: 1,
hasNext: false,
hasPrev: false,
};
}
// For Telegram, use pagination
const grouped = groupCommandsByCategory(commands);
// Flatten commands with category headers for pagination
type PageItem =
| { type: "header"; category: CommandCategory }
| { type: "command"; command: ChatCommandDefinition };
const items: PageItem[] = [];
for (const category of CATEGORY_ORDER) {
const categoryCommands = grouped.get(category) ?? [];
if (categoryCommands.length === 0) continue;
items.push({ type: "header", category });
for (const command of categoryCommands) {
items.push({ type: "command", command });
}
}
return lines.join("\n");
// Add plugin commands
if (pluginCommands.length > 0) {
items.push({ type: "header", category: "tools" }); // Reuse tools category for plugins header indicator
}
// Calculate pages based on command count (headers don't count toward limit)
const commandItems = items.filter((item) => item.type === "command");
const totalCommands = commandItems.length + pluginCommands.length;
const totalPages = Math.max(1, Math.ceil(totalCommands / COMMANDS_PER_PAGE));
const currentPage = Math.min(page, totalPages);
const startIndex = (currentPage - 1) * COMMANDS_PER_PAGE;
const endIndex = startIndex + COMMANDS_PER_PAGE;
// Build page content
const lines = [` Commands (${currentPage}/${totalPages})`, ""];
let commandIndex = 0;
let currentCategory: CommandCategory | null = null;
let pageCommandCount = 0;
for (const item of items) {
if (pageCommandCount >= COMMANDS_PER_PAGE) break;
if (item.type === "header") {
currentCategory = item.category;
continue;
}
if (commandIndex >= startIndex && commandIndex < endIndex) {
// Add category header if this is the first command of a category on this page
if (
(currentCategory && pageCommandCount === 0) ||
items[items.indexOf(item) - 1]?.type === "header"
) {
if (currentCategory) {
if (pageCommandCount > 0) lines.push("");
lines.push(CATEGORY_LABELS[currentCategory]);
}
}
lines.push(` ${formatCommandEntry(item.command)}`);
pageCommandCount++;
}
commandIndex++;
}
// Add plugin commands if they fall within this page range
const pluginStartIndex = commandItems.length;
for (let i = 0; i < pluginCommands.length && pageCommandCount < COMMANDS_PER_PAGE; i++) {
const pluginIndex = pluginStartIndex + i;
if (pluginIndex >= startIndex && pluginIndex < endIndex) {
if (i === 0 || pluginIndex === startIndex) {
if (pageCommandCount > 0) lines.push("");
lines.push("Plugins");
}
const command = pluginCommands[i];
const pluginLabel = command.pluginId ? ` (${command.pluginId})` : "";
lines.push(` /${command.name}${pluginLabel} - ${command.description}`);
pageCommandCount++;
}
}
return {
text: lines.join("\n"),
totalPages,
currentPage,
hasNext: currentPage < totalPages,
hasPrev: currentPage > 1,
};
}

View File

@@ -4,6 +4,9 @@ import {
createInboundDebouncer,
resolveInboundDebounceMs,
} from "../auto-reply/inbound-debounce.js";
import { buildCommandsPaginationKeyboard } from "../auto-reply/reply/commands-info.js";
import { buildCommandsMessagePaginated } from "../auto-reply/status.js";
import { listSkillCommandsForAgents } from "../auto-reply/skill-commands.js";
import { loadConfig } from "../config/config.js";
import { writeConfigFile } from "../config/io.js";
import { danger, logVerbose, warn } from "../globals.js";
@@ -17,6 +20,7 @@ import { migrateTelegramGroupConfig } from "./group-migration.js";
import { resolveTelegramInlineButtonsScope } from "./inline-buttons.js";
import { readTelegramAllowFromStore } from "./pairing-store.js";
import { resolveChannelConfigWrites } from "../channels/plugins/config-writes.js";
import { buildInlineKeyboard } from "./send.js";
export const registerTelegramHandlers = ({
cfg,
@@ -199,6 +203,47 @@ export const registerTelegramHandlers = ({
const callbackMessage = callback.message;
if (!data || !callbackMessage) return;
// Handle commands pagination callback
const paginationMatch = data.match(/^commands_page_(\d+|noop)$/);
if (paginationMatch) {
const pageValue = paginationMatch[1];
if (pageValue === "noop") return; // Page number button - no action
const page = parseInt(pageValue, 10);
if (isNaN(page) || page < 1) return;
const skillCommands = listSkillCommandsForAgents({ cfg });
const result = buildCommandsMessagePaginated(cfg, skillCommands, {
page,
surface: "telegram",
});
const messageId = callbackMessage.message_id;
const chatId = callbackMessage.chat.id;
const keyboard =
result.totalPages > 1
? buildInlineKeyboard(
buildCommandsPaginationKeyboard(result.currentPage, result.totalPages),
)
: undefined;
try {
await bot.api.editMessageText(
chatId,
messageId,
result.text,
keyboard ? { reply_markup: keyboard } : undefined,
);
} catch (editErr) {
// Ignore "message is not modified" errors (user clicked same page)
const errStr = String(editErr);
if (!errStr.includes("message is not modified")) {
throw editErr;
}
}
return;
}
const inlineButtonsScope = resolveTelegramInlineButtonsScope({
cfg,
accountId,