feat(commands): add /commands slash list
This commit is contained in:
@@ -32,6 +32,7 @@ Inline text like `hello /status` is ignored.
|
|||||||
|
|
||||||
Text + native (when enabled):
|
Text + native (when enabled):
|
||||||
- `/help`
|
- `/help`
|
||||||
|
- `/commands`
|
||||||
- `/status`
|
- `/status`
|
||||||
- `/stop`
|
- `/stop`
|
||||||
- `/restart`
|
- `/restart`
|
||||||
|
|||||||
@@ -42,9 +42,15 @@ describe("control command parsing", () => {
|
|||||||
expect(hasControlCommand("/help")).toBe(true);
|
expect(hasControlCommand("/help")).toBe(true);
|
||||||
expect(hasControlCommand("/help:")).toBe(true);
|
expect(hasControlCommand("/help:")).toBe(true);
|
||||||
expect(hasControlCommand("help")).toBe(false);
|
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(true);
|
expect(hasControlCommand("/status:")).toBe(true);
|
||||||
expect(hasControlCommand("status")).toBe(false);
|
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", () => {
|
it("requires commands to be the full message", () => {
|
||||||
|
|||||||
@@ -17,13 +17,17 @@ describe("commands registry", () => {
|
|||||||
const specs = listNativeCommandSpecs();
|
const specs = listNativeCommandSpecs();
|
||||||
expect(specs.find((spec) => spec.name === "help")).toBeTruthy();
|
expect(specs.find((spec) => spec.name === "help")).toBeTruthy();
|
||||||
expect(specs.find((spec) => spec.name === "stop")).toBeTruthy();
|
expect(specs.find((spec) => spec.name === "stop")).toBeTruthy();
|
||||||
|
expect(specs.find((spec) => spec.name === "compact")).toBeFalsy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("detects known text commands", () => {
|
it("detects known text commands", () => {
|
||||||
const detection = getCommandDetection();
|
const detection = getCommandDetection();
|
||||||
expect(detection.exact.has("/help")).toBe(true);
|
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("/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("/stop")).toBe(true);
|
||||||
expect(detection.regex.test("/send:")).toBe(true);
|
expect(detection.regex.test("/send:")).toBe(true);
|
||||||
expect(detection.regex.test("try /status")).toBe(false);
|
expect(detection.regex.test("try /status")).toBe(false);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export type ChatCommandDefinition = {
|
|||||||
description: string;
|
description: string;
|
||||||
textAliases: string[];
|
textAliases: string[];
|
||||||
acceptsArgs?: boolean;
|
acceptsArgs?: boolean;
|
||||||
|
supportsNative?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type NativeCommandSpec = {
|
export type NativeCommandSpec = {
|
||||||
@@ -21,6 +22,12 @@ const CHAT_COMMANDS: ChatCommandDefinition[] = [
|
|||||||
description: "Show available commands.",
|
description: "Show available commands.",
|
||||||
textAliases: ["/help"],
|
textAliases: ["/help"],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "commands",
|
||||||
|
nativeName: "commands",
|
||||||
|
description: "List all slash commands.",
|
||||||
|
textAliases: ["/commands"],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: "status",
|
key: "status",
|
||||||
nativeName: "status",
|
nativeName: "status",
|
||||||
@@ -65,6 +72,14 @@ const CHAT_COMMANDS: ChatCommandDefinition[] = [
|
|||||||
description: "Start a new session.",
|
description: "Start a new session.",
|
||||||
textAliases: ["/new"],
|
textAliases: ["/new"],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "compact",
|
||||||
|
nativeName: "compact",
|
||||||
|
description: "Compact the current session context.",
|
||||||
|
textAliases: ["/compact"],
|
||||||
|
acceptsArgs: true,
|
||||||
|
supportsNative: false,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: "think",
|
key: "think",
|
||||||
nativeName: "think",
|
nativeName: "think",
|
||||||
@@ -127,7 +142,9 @@ export function listChatCommands(): ChatCommandDefinition[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function listNativeCommandSpecs(): NativeCommandSpec[] {
|
export function listNativeCommandSpecs(): NativeCommandSpec[] {
|
||||||
return CHAT_COMMANDS.map((command) => ({
|
return CHAT_COMMANDS.filter(
|
||||||
|
(command) => command.supportsNative !== false,
|
||||||
|
).map((command) => ({
|
||||||
name: command.nativeName,
|
name: command.nativeName,
|
||||||
description: command.description,
|
description: command.description,
|
||||||
acceptsArgs: Boolean(command.acceptsArgs),
|
acceptsArgs: Boolean(command.acceptsArgs),
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ import {
|
|||||||
} from "../group-activation.js";
|
} from "../group-activation.js";
|
||||||
import { parseSendPolicyCommand } from "../send-policy.js";
|
import { parseSendPolicyCommand } from "../send-policy.js";
|
||||||
import {
|
import {
|
||||||
|
buildCommandsMessage,
|
||||||
buildHelpMessage,
|
buildHelpMessage,
|
||||||
buildStatusMessage,
|
buildStatusMessage,
|
||||||
formatContextUsageShort,
|
formatContextUsageShort,
|
||||||
@@ -404,6 +405,17 @@ export async function handleCommands(params: {
|
|||||||
return { shouldContinue: false, reply: { text: buildHelpMessage() } };
|
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 || "<unknown>"}`,
|
||||||
|
);
|
||||||
|
return { shouldContinue: false };
|
||||||
|
}
|
||||||
|
return { shouldContinue: false, reply: { text: buildCommandsMessage() } };
|
||||||
|
}
|
||||||
|
|
||||||
const statusRequested =
|
const statusRequested =
|
||||||
directives.hasStatusDirective ||
|
directives.hasStatusDirective ||
|
||||||
command.commandBodyNormalized === "/status";
|
command.commandBodyNormalized === "/status";
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import fs from "node:fs";
|
|||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
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;
|
const HOME_ENV_KEYS = ["HOME", "USERPROFILE", "HOMEDRIVE", "HOMEPATH"] as const;
|
||||||
type HomeEnvSnapshot = Record<
|
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.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
} from "../config/sessions.js";
|
} from "../config/sessions.js";
|
||||||
import { resolveCommitHash } from "../infra/git-commit.js";
|
import { resolveCommitHash } from "../infra/git-commit.js";
|
||||||
import { VERSION } from "../version.js";
|
import { VERSION } from "../version.js";
|
||||||
|
import { listChatCommands } from "./commands-registry.js";
|
||||||
import type {
|
import type {
|
||||||
ElevatedLevel,
|
ElevatedLevel,
|
||||||
ReasoningLevel,
|
ReasoningLevel,
|
||||||
@@ -302,5 +303,30 @@ export function buildHelpMessage(): string {
|
|||||||
"ℹ️ Help",
|
"ℹ️ Help",
|
||||||
"Shortcuts: /new reset | /compact [instructions] | /restart relink",
|
"Shortcuts: /new reset | /compact [instructions] | /restart relink",
|
||||||
"Options: /think <level> | /verbose on|off | /reasoning on|off | /elevated on|off | /model <id>",
|
"Options: /think <level> | /verbose on|off | /reasoning on|off | /elevated on|off | /model <id>",
|
||||||
|
"More: /commands for all slash commands",
|
||||||
].join("\n");
|
].join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function buildCommandsMessage(): string {
|
||||||
|
const lines = ["ℹ️ Slash commands"];
|
||||||
|
for (const command of listChatCommands()) {
|
||||||
|
const primary = `/${command.nativeName}`;
|
||||||
|
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.supportsNative === false ? " (text-only)" : "";
|
||||||
|
lines.push(`${primary}${aliasLabel}${scopeLabel} - ${command.description}`);
|
||||||
|
}
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user