From 559e175b3876b795643c89af435c5ba082c99226 Mon Sep 17 00:00:00 2001 From: LK Date: Thu, 8 Jan 2026 16:02:54 +0100 Subject: [PATCH] feat(commands): add /commands slash list --- docs/tools/slash-commands.md | 1 + src/auto-reply/command-detection.test.ts | 6 ++++++ src/auto-reply/commands-registry.test.ts | 4 ++++ src/auto-reply/commands-registry.ts | 19 ++++++++++++++++- src/auto-reply/reply/commands.ts | 12 +++++++++++ src/auto-reply/status.test.ts | 15 +++++++++++++- src/auto-reply/status.ts | 26 ++++++++++++++++++++++++ 7 files changed, 81 insertions(+), 2 deletions(-) diff --git a/docs/tools/slash-commands.md b/docs/tools/slash-commands.md index fa9e4e636..3653bbd22 100644 --- a/docs/tools/slash-commands.md +++ b/docs/tools/slash-commands.md @@ -32,6 +32,7 @@ Inline text like `hello /status` is ignored. Text + native (when enabled): - `/help` +- `/commands` - `/status` - `/stop` - `/restart` diff --git a/src/auto-reply/command-detection.test.ts b/src/auto-reply/command-detection.test.ts index e8a14a898..98cfbd568 100644 --- a/src/auto-reply/command-detection.test.ts +++ b/src/auto-reply/command-detection.test.ts @@ -42,9 +42,15 @@ describe("control command parsing", () => { expect(hasControlCommand("/help")).toBe(true); expect(hasControlCommand("/help:")).toBe(true); expect(hasControlCommand("help")).toBe(false); + expect(hasControlCommand("/commands")).toBe(true); + expect(hasControlCommand("/commands:")).toBe(true); + expect(hasControlCommand("commands")).toBe(false); expect(hasControlCommand("/status")).toBe(true); expect(hasControlCommand("/status:")).toBe(true); expect(hasControlCommand("status")).toBe(false); + expect(hasControlCommand("/compact")).toBe(true); + expect(hasControlCommand("/compact:")).toBe(true); + expect(hasControlCommand("compact")).toBe(false); }); it("requires commands to be the full message", () => { diff --git a/src/auto-reply/commands-registry.test.ts b/src/auto-reply/commands-registry.test.ts index 58bfa00c0..ca17e4913 100644 --- a/src/auto-reply/commands-registry.test.ts +++ b/src/auto-reply/commands-registry.test.ts @@ -17,13 +17,17 @@ describe("commands registry", () => { const specs = listNativeCommandSpecs(); expect(specs.find((spec) => spec.name === "help")).toBeTruthy(); expect(specs.find((spec) => spec.name === "stop")).toBeTruthy(); + expect(specs.find((spec) => spec.name === "compact")).toBeFalsy(); }); it("detects known text commands", () => { const detection = getCommandDetection(); expect(detection.exact.has("/help")).toBe(true); + expect(detection.exact.has("/commands")).toBe(true); expect(detection.regex.test("/status")).toBe(true); expect(detection.regex.test("/status:")).toBe(true); + expect(detection.regex.test("/compact")).toBe(true); + expect(detection.regex.test("/compact:")).toBe(true); expect(detection.regex.test("/stop")).toBe(true); expect(detection.regex.test("/send:")).toBe(true); expect(detection.regex.test("try /status")).toBe(false); diff --git a/src/auto-reply/commands-registry.ts b/src/auto-reply/commands-registry.ts index 8fbbe611e..5e252f0b7 100644 --- a/src/auto-reply/commands-registry.ts +++ b/src/auto-reply/commands-registry.ts @@ -6,6 +6,7 @@ export type ChatCommandDefinition = { description: string; textAliases: string[]; acceptsArgs?: boolean; + supportsNative?: boolean; }; export type NativeCommandSpec = { @@ -21,6 +22,12 @@ const CHAT_COMMANDS: ChatCommandDefinition[] = [ description: "Show available commands.", textAliases: ["/help"], }, + { + key: "commands", + nativeName: "commands", + description: "List all slash commands.", + textAliases: ["/commands"], + }, { key: "status", nativeName: "status", @@ -65,6 +72,14 @@ const CHAT_COMMANDS: ChatCommandDefinition[] = [ description: "Start a new session.", textAliases: ["/new"], }, + { + key: "compact", + nativeName: "compact", + description: "Compact the current session context.", + textAliases: ["/compact"], + acceptsArgs: true, + supportsNative: false, + }, { key: "think", nativeName: "think", @@ -127,7 +142,9 @@ export function listChatCommands(): ChatCommandDefinition[] { } export function listNativeCommandSpecs(): NativeCommandSpec[] { - return CHAT_COMMANDS.map((command) => ({ + return CHAT_COMMANDS.filter( + (command) => command.supportsNative !== false, + ).map((command) => ({ name: command.nativeName, description: command.description, acceptsArgs: Boolean(command.acceptsArgs), diff --git a/src/auto-reply/reply/commands.ts b/src/auto-reply/reply/commands.ts index 78bc7010f..0b4f4eed3 100644 --- a/src/auto-reply/reply/commands.ts +++ b/src/auto-reply/reply/commands.ts @@ -43,6 +43,7 @@ import { } from "../group-activation.js"; import { parseSendPolicyCommand } from "../send-policy.js"; import { + buildCommandsMessage, buildHelpMessage, buildStatusMessage, formatContextUsageShort, @@ -404,6 +405,17 @@ export async function handleCommands(params: { return { shouldContinue: false, reply: { text: buildHelpMessage() } }; } + const commandsRequested = command.commandBodyNormalized === "/commands"; + if (allowTextCommands && commandsRequested) { + if (!command.isAuthorizedSender) { + logVerbose( + `Ignoring /commands from unauthorized sender: ${command.senderE164 || ""}`, + ); + return { shouldContinue: false }; + } + return { shouldContinue: false, reply: { text: buildCommandsMessage() } }; + } + const statusRequested = directives.hasStatusDirective || command.commandBodyNormalized === "/status"; diff --git a/src/auto-reply/status.test.ts b/src/auto-reply/status.test.ts index 2e9f5ba80..f6d482318 100644 --- a/src/auto-reply/status.test.ts +++ b/src/auto-reply/status.test.ts @@ -2,7 +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 { buildStatusMessage } from "./status.js"; +import { buildCommandsMessage, buildStatusMessage } from "./status.js"; const HOME_ENV_KEYS = ["HOME", "USERPROFILE", "HOMEDRIVE", "HOMEPATH"] as const; type HomeEnvSnapshot = Record< @@ -234,3 +234,16 @@ describe("buildStatusMessage", () => { } }); }); + +describe("buildCommandsMessage", () => { + it("lists commands with aliases and text-only hints", () => { + const text = buildCommandsMessage(); + expect(text).toContain("/commands - List all slash commands."); + expect(text).toContain( + "/think (aliases: /thinking, /t) - Set thinking level.", + ); + expect(text).toContain( + "/compact (text-only) - Compact the current session context.", + ); + }); +}); diff --git a/src/auto-reply/status.ts b/src/auto-reply/status.ts index a6a5c8588..d25a045f2 100644 --- a/src/auto-reply/status.ts +++ b/src/auto-reply/status.ts @@ -21,6 +21,7 @@ import { } from "../config/sessions.js"; import { resolveCommitHash } from "../infra/git-commit.js"; import { VERSION } from "../version.js"; +import { listChatCommands } from "./commands-registry.js"; import type { ElevatedLevel, ReasoningLevel, @@ -302,5 +303,30 @@ export function buildHelpMessage(): string { "ℹ️ Help", "Shortcuts: /new reset | /compact [instructions] | /restart relink", "Options: /think | /verbose on|off | /reasoning on|off | /elevated on|off | /model ", + "More: /commands for all slash commands", ].join("\n"); } + +export function buildCommandsMessage(): string { + const lines = ["ℹ️ Slash commands"]; + for (const command of listChatCommands()) { + const primary = `/${command.nativeName}`; + 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.supportsNative === false ? " (text-only)" : ""; + lines.push(`${primary}${aliasLabel}${scopeLabel} - ${command.description}`); + } + return lines.join("\n"); +}