diff --git a/src/auto-reply/commands-registry.data.ts b/src/auto-reply/commands-registry.data.ts index 5ba6826fe..1e2ebeb57 100644 --- a/src/auto-reply/commands-registry.data.ts +++ b/src/auto-reply/commands-registry.data.ts @@ -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", diff --git a/src/auto-reply/commands-registry.types.ts b/src/auto-reply/commands-registry.types.ts index 5e5bdd8cb..6b9371604 100644 --- a/src/auto-reply/commands-registry.types.ts +++ b/src/auto-reply/commands-registry.types.ts @@ -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 = { diff --git a/src/auto-reply/reply/commands-info.ts b/src/auto-reply/reply/commands-info.ts index 1a525150c..eec7053a7 100644 --- a/src/auto-reply/reply/commands-info.ts +++ b/src/auto-reply/reply/commands-info.ts @@ -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> { + 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 = diff --git a/src/auto-reply/status.ts b/src/auto-reply/status.ts index 733205c8c..713815f4f 100644 --- a/src/auto-reply/status.ts +++ b/src/auto-reply/status.ts @@ -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 = { + 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 { + const grouped = new Map(); + 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 ", - "/verbose on|full|off", - "/reasoning on|off", - "/elevated on|off|ask|full", - "/model ", - "/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 [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 ", "/model ", "/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 [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(); + 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(); - 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, + }; } diff --git a/src/telegram/bot-handlers.ts b/src/telegram/bot-handlers.ts index 7a5b88fd7..f4acafc19 100644 --- a/src/telegram/bot-handlers.ts +++ b/src/telegram/bot-handlers.ts @@ -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,