diff --git a/src/features/chat/composer/slash-commands.ts b/src/features/chat/composer/slash-commands.ts index 648d77ef..862f0534 100644 --- a/src/features/chat/composer/slash-commands.ts +++ b/src/features/chat/composer/slash-commands.ts @@ -1,42 +1,115 @@ export type SlashCommandArgsKind = "none" | "optionalThread" | "requiredThread" | "optionalMessage" | "threadAndMessage" | "showOrSet"; +export type SlashCommandSurface = "panelAction" | "threadSetting" | "diagnostic" | "composition"; + +export const SLASH_COMMAND_SURFACE_LABELS: Record = { + panelAction: "Panel actions", + threadSetting: "Thread settings", + diagnostic: "Diagnostics", + composition: "Composition", +}; + export const SLASH_COMMANDS = [ { command: "/clear", usage: "/clear", argsKind: "none", + surface: "panelAction", detail: "Clear the current panel and start a fresh Codex thread.", }, - { command: "/resume", usage: "/resume [thread]", argsKind: "optionalThread", detail: "Resume a recent Codex thread." }, + { + command: "/resume", + usage: "/resume [thread]", + argsKind: "optionalThread", + surface: "panelAction", + detail: "Resume a recent Codex thread.", + }, { command: "/refer", usage: "/refer ", argsKind: "threadAndMessage", + surface: "composition", detail: "Send a message with recent turns from another non-archived thread.", }, - { command: "/fork", usage: "/fork", argsKind: "none", detail: "Fork the active Codex thread." }, - { command: "/rollback", usage: "/rollback", argsKind: "none", detail: "Roll back the latest turn and restore its prompt." }, - { command: "/compact", usage: "/compact", argsKind: "none", detail: "Compact the current thread context." }, - { command: "/archive", usage: "/archive ", argsKind: "requiredThread", detail: "Archive the selected Codex thread." }, + { command: "/fork", usage: "/fork", argsKind: "none", surface: "panelAction", detail: "Fork the active Codex thread." }, + { + command: "/rollback", + usage: "/rollback", + argsKind: "none", + surface: "panelAction", + detail: "Roll back the latest turn and restore its prompt.", + }, + { command: "/compact", usage: "/compact", argsKind: "none", surface: "panelAction", detail: "Compact the current thread context." }, + { + command: "/archive", + usage: "/archive ", + argsKind: "requiredThread", + surface: "panelAction", + detail: "Archive the selected Codex thread.", + }, { command: "/auto-review", usage: "/auto-review", argsKind: "none", + surface: "threadSetting", detail: "Toggle approval auto-review.", }, - { command: "/fast", usage: "/fast", argsKind: "none", detail: "Toggle fast service tier for subsequent turns." }, - { command: "/plan", usage: "/plan [message]", argsKind: "optionalMessage", detail: "Toggle Plan mode, optionally with a message." }, - { command: "/status", usage: "/status", argsKind: "none", detail: "Show current thread, context, and usage limits." }, - { command: "/doctor", usage: "/doctor", argsKind: "none", detail: "Show Codex CLI and Codex App Server diagnostics." }, - { command: "/mcp", usage: "/mcp", argsKind: "none", detail: "Show MCP servers reported by Codex App Server." }, - { command: "/model", usage: "/model [model|default]", argsKind: "showOrSet", detail: "Show or set the model for subsequent turns." }, + { + command: "/fast", + usage: "/fast", + argsKind: "none", + surface: "threadSetting", + detail: "Toggle fast service tier for subsequent turns.", + }, + { + command: "/plan", + usage: "/plan [message]", + argsKind: "optionalMessage", + surface: "threadSetting", + detail: "Toggle Plan mode, optionally with a message.", + }, + { + command: "/status", + usage: "/status", + argsKind: "none", + surface: "diagnostic", + detail: "Show current thread, context, and usage limits.", + }, + { + command: "/doctor", + usage: "/doctor", + argsKind: "none", + surface: "diagnostic", + detail: "Show Codex CLI and Codex App Server diagnostics.", + }, + { + command: "/mcp", + usage: "/mcp", + argsKind: "none", + surface: "diagnostic", + detail: "Show MCP servers reported by Codex App Server.", + }, + { + command: "/model", + usage: "/model [model|default]", + argsKind: "showOrSet", + surface: "threadSetting", + detail: "Show or set the model for subsequent turns.", + }, { command: "/effort", usage: "/effort [effort|default]", argsKind: "showOrSet", + surface: "threadSetting", detail: "Show or set reasoning effort for subsequent turns.", }, - { command: "/help", usage: "/help", argsKind: "none", detail: "Show available Codex slash commands." }, + { + command: "/help", + usage: "/help", + argsKind: "none", + surface: "diagnostic", + detail: "Show available Codex slash commands.", + }, ] as const; type SlashCommand = (typeof SLASH_COMMANDS)[number]["command"]; @@ -58,3 +131,12 @@ export function slashCommandHelpLines(): string[] { export function slashCommandHelpRows(): { key: string; value: string }[] { return SLASH_COMMANDS.map((item) => ({ key: item.usage, value: item.detail })); } + +export function slashCommandHelpSections(): { title: string; rows: { key: string; value: string }[] }[] { + return (Object.keys(SLASH_COMMAND_SURFACE_LABELS) as SlashCommandSurface[]) + .map((surface) => ({ + title: SLASH_COMMAND_SURFACE_LABELS[surface], + rows: SLASH_COMMANDS.filter((item) => item.surface === surface).map((item) => ({ key: item.usage, value: item.detail })), + })) + .filter((section) => section.rows.length > 0); +} diff --git a/src/features/chat/composer/suggestions.ts b/src/features/chat/composer/suggestions.ts index 01e930f9..987a81ab 100644 --- a/src/features/chat/composer/suggestions.ts +++ b/src/features/chat/composer/suggestions.ts @@ -9,7 +9,7 @@ import { sortedAvailableModels, supportedEffortsForModel, } from "../../../runtime/model"; -import { SLASH_COMMANDS, type SlashCommandName } from "./slash-commands"; +import { SLASH_COMMANDS, SLASH_COMMAND_SURFACE_LABELS, type SlashCommandName } from "./slash-commands"; import { getThreadTitle } from "../../../domain/threads/model"; import { shortThreadId } from "../../../utils"; @@ -170,7 +170,7 @@ export function activeSlashCommandSuggestions(beforeCursor: string): ComposerSug .slice(0, 8) .map((item) => ({ display: item.command, - detail: `${item.usage} - ${item.detail}`, + detail: `${SLASH_COMMAND_SURFACE_LABELS[item.surface]}: ${item.usage} - ${item.detail}`, replacement: item.command, start, appendSpaceOnInsert: true, diff --git a/src/features/chat/slash-commands.ts b/src/features/chat/slash-commands.ts index e79d6cdf..d7f99610 100644 --- a/src/features/chat/slash-commands.ts +++ b/src/features/chat/slash-commands.ts @@ -3,7 +3,7 @@ import type { Thread } from "../../generated/app-server/v2/Thread"; import type { UserInput } from "../../generated/app-server/v2/UserInput"; import { getThreadTitle } from "../../domain/threads/model"; import type { ReferencedThreadDisplay } from "../../domain/threads/reference"; -import { slashCommandDefinition, slashCommandHelpRows, type SlashCommandName } from "./composer/slash-commands"; +import { slashCommandDefinition, slashCommandHelpSections, type SlashCommandName } from "./composer/slash-commands"; import type { DisplayDetailSection, DisplayDetailMetaRow } from "./display/types"; import { modelOverrideMessage, @@ -206,7 +206,7 @@ export async function executeSlashCommand( return; } - context.addStructuredSystemMessage("Available slash commands", [{ rows: slashCommandHelpRows() }]); + context.addStructuredSystemMessage("Available slash commands", slashCommandHelpSections()); } function validateSlashCommandArguments(command: SlashCommandName, args: string): string | null { diff --git a/tests/features/chat/composer/composer-suggestions.test.ts b/tests/features/chat/composer/composer-suggestions.test.ts index 7f8daa69..204b8d77 100644 --- a/tests/features/chat/composer/composer-suggestions.test.ts +++ b/tests/features/chat/composer/composer-suggestions.test.ts @@ -293,7 +293,7 @@ describe("composer suggestions", () => { const wikilink = expectPresent(activeComposerSuggestions("[[bet", notes, [])[0]); expect(slash).toMatchObject({ - detail: "/status - Show current thread, context, and usage limits.", + detail: "Diagnostics: /status - Show current thread, context, and usage limits.", replacement: "/status", appendSpaceOnInsert: true, }); diff --git a/tests/features/chat/composer/slash-commands.test.ts b/tests/features/chat/composer/slash-commands.test.ts index 2a22d930..8d236570 100644 --- a/tests/features/chat/composer/slash-commands.test.ts +++ b/tests/features/chat/composer/slash-commands.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vitest"; -import { slashCommandHelpLines, slashCommandHelpRows } from "../../../../src/features/chat/composer/slash-commands"; +import { slashCommandHelpLines, slashCommandHelpSections } from "../../../../src/features/chat/composer/slash-commands"; import type { Thread } from "../../../../src/generated/app-server/v2/Thread"; import { executeSlashCommand, type SlashCommandExecutionContext } from "../../../../src/features/chat/slash-commands"; @@ -303,7 +303,37 @@ describe("slash commands", () => { await executeSlashCommand("help", "", ctx); expect(ctx.addSystemMessage).not.toHaveBeenCalled(); - expect(ctx.addStructuredSystemMessage).toHaveBeenCalledWith("Available slash commands", [{ rows: slashCommandHelpRows() }]); + expect(ctx.addStructuredSystemMessage).toHaveBeenCalledWith("Available slash commands", slashCommandHelpSections()); + }); + + it("groups slash command help by command surface", () => { + expect(slashCommandHelpSections()).toEqual([ + { + title: "Panel actions", + rows: expect.arrayContaining([ + { key: "/clear", value: "Clear the current panel and start a fresh Codex thread." }, + { key: "/archive ", value: "Archive the selected Codex thread." }, + ]), + }, + { + title: "Thread settings", + rows: expect.arrayContaining([ + { key: "/plan [message]", value: "Toggle Plan mode, optionally with a message." }, + { key: "/model [model|default]", value: "Show or set the model for subsequent turns." }, + ]), + }, + { + title: "Diagnostics", + rows: expect.arrayContaining([ + { key: "/status", value: "Show current thread, context, and usage limits." }, + { key: "/help", value: "Show available Codex slash commands." }, + ]), + }, + { + title: "Composition", + rows: [{ key: "/refer ", value: "Send a message with recent turns from another non-archived thread." }], + }, + ]); }); it("rejects /help arguments", async () => {