diff --git a/docs/agent-cli.md b/docs/agent-cli.md new file mode 100644 index 00000000..ae269703 --- /dev/null +++ b/docs/agent-cli.md @@ -0,0 +1,54 @@ +# Agent commands in chat + +For Codex and Claude Code, the `/` picker has remembered **All**, **Agent +commands**, and **Skills** filters. Commands operate on the current MonoCode +conversation. Their results and controls appear above the composer while the +transcript, draft, and attachments stay mounted. + +Use `/agent:status` or `/agent:model` to choose a command explicitly when a skill +has the same name. `/cli:name` is accepted as a compatibility alias and has the +same chat behavior. In **All**, skills and MonoCode's `/plan` and `/compact` keep +their existing meaning. **Skills** retains normal skill submission. + +| Command | Behavior in MonoCode | +| --- | --- | +| `/status` | Show this chat's model, access mode, project, provider session ID, state, and reported context usage. | +| `/context` | Show the last context reading reported by the agent. | +| `/model [model ID]` | Open a model selector or select an exact ID/name from this agent's catalog. | +| `/permissions [mode]`, `/approvals [mode]` | Show or change this chat's MonoCode access mode. | +| `/compact` | Compact the existing conversation through its provider adapter. | +| `/plan [request]` | Plan the next message, or send the supplied request in plan mode. | +| `/fast [on\|off]` | Show or set fast mode when the current model's catalog exposes it. | +| `/effort [level]`, `/reasoning [level]` | Show or set an effort level advertised by the current model. | +| `/diff` | Open this session's project diff in MonoCode. | +| `/stop` | Stop the current agent turn. | +| `/help` | Show the supported chat commands. | + +Access modes use MonoCode's existing names: `supervised`, `auto-accept-edits`, +`auto`, and `full-access`. Settings apply to the same conversation through the +same callbacks as the normal composer controls. Settings changes and compaction +are blocked during an active turn or queued follow-ups; status and stop remain +available. + +This is a GUI command integration, not full CLI command parity. The picker also +recognizes documented CLI names, but entries without a chat implementation are +marked **Not supported in chat**. Selecting one shows an error and preserves the +command draft. Unknown names in **Agent commands** receive the same treatment. +No command starts a terminal, forks provider history, or falls back to sending +unsupported command text as a model prompt. `/plan ` intentionally +starts a normal planning turn, and `/compact` invokes provider compaction. + +`/status` displays MonoCode's current session data, not the terminal's native +status screen. Account rate limits remain in the existing usage footer; unknown +context usage is shown as unavailable rather than zero. + +Validate with `npm run check`. The browser regression fixture at +`/tests/agent-commands.html` (serve with `npm run dev`) uses the real SessionPane, +composer, and transcript with mocked IPC. It cannot launch a real agent or PTY. + +Protocol and command references: + +- [Codex app-server](https://learn.chatgpt.com/docs/app-server) +- [Codex CLI commands](https://learn.chatgpt.com/docs/developer-commands) +- [Claude Agent SDK commands](https://code.claude.com/docs/en/agent-sdk/slash-commands#commands-in-agent-sdk-sessions) +- [Claude Code commands](https://code.claude.com/docs/en/commands) diff --git a/docs/validation/agent-commands-chat.png b/docs/validation/agent-commands-chat.png new file mode 100644 index 00000000..d7d63e6b Binary files /dev/null and b/docs/validation/agent-commands-chat.png differ diff --git a/src/chrome/AgentCommandPanel.tsx b/src/chrome/AgentCommandPanel.tsx new file mode 100644 index 00000000..2479dd84 --- /dev/null +++ b/src/chrome/AgentCommandPanel.tsx @@ -0,0 +1,191 @@ +import { useSyncExternalStore } from "react"; +import { X } from "./icons"; +import { ModelSettings } from "./ModelSettings"; +import { + CHAT_COMMANDS, + commandModel, + commandsLocked, + type CommandPanel, +} from "../lib/agentCommands"; +import { getModelSnapshot, modelsFor, subscribeModels } from "../lib/models"; +import { contextTooltip } from "../lib/contextUsage"; +import { + RUNTIME_MODES, + RUNTIME_MODE_LABEL, + RUNTIME_MODE_HINT, + sessionWorkCwd, + type RuntimeMode, + type Session, +} from "../lib/session"; + +type Props = { + panel: CommandPanel; + session: Session; + onClose: () => void; + onModelChange: (model: string) => void; + onModelSettingsChange: (settings: Record) => void; + onRuntimeModeChange: (mode: RuntimeMode) => void; +}; + +/** A small response/control panel in the existing composer. It never replaces + * the transcript or changes the identity of the conversation. */ +export function AgentCommandPanel({ + panel, + session, + onClose, + onModelChange, + onModelSettingsChange, + onRuntimeModeChange, +}: Props) { + useSyncExternalStore(subscribeModels, getModelSnapshot, getModelSnapshot); + const model = commandModel(session); + const context = session.context ? contextTooltip(session.context) : null; + const locked = commandsLocked(session); + return ( +
+
+ /{panel.command} + + {session.harness === "codex" ? "Codex" : "Claude Code"} · current chat + + +
+ {panel.message ? ( +

+ {panel.message} +

+ ) : null} + {panel.kind === "status" || panel.kind === "context" ? ( +
+ {panel.kind === "status" ? ( + <> +
Model
+
+ {model?.name || session.model || "Not selected"} +
+
Access
+
{RUNTIME_MODE_LABEL[session.runtimeMode]}
+
Project
+
{sessionWorkCwd(session)}
+
Session
+
+ {session.providerSessionId || "Starts with the first message"} +
+
State
+
+ {session.busy ? "Working" : "Idle"} + {session.queuedMessages?.length + ? ` · ${session.queuedMessages.length} queued` + : ""} +
+ + ) : null} +
Context
+
+ {context + ? `${context.headline} · ${context.detail}` + : "Not reported by the agent yet"} +
+
+ ) : null} + {panel.kind === "model" ? ( + + ) : null} + {panel.kind === "permissions" ? ( +
+ {RUNTIME_MODES.map((mode) => ( + + ))} +
+ ) : null} + {panel.kind === "settings" ? ( +
+ { + if (!locked) onModelSettingsChange(settings); + }} + /> +
+ ) : null} + {["model", "permissions", "settings"].includes(panel.kind) && locked ? ( +

+ Finish the current turn and queued messages to change settings. +

+ ) : null} + {panel.kind === "help" ? ( + <> +

+ These commands act on this chat. Use /agent:name when a skill has + the same name. Other CLI commands are marked as unsupported in chat. +

+
+ {Object.entries(CHAT_COMMANDS).map(([name, description]) => ( +
+
/{name}
+
{description}
+
+ ))} +
+ + ) : null} +
+ ); +} diff --git a/src/chrome/Composer.tsx b/src/chrome/Composer.tsx index b93cc63a..d9e2f39a 100644 --- a/src/chrome/Composer.tsx +++ b/src/chrome/Composer.tsx @@ -121,6 +121,15 @@ import { useComposerSkills } from "./useComposerSkills"; import { Popover } from "./Popover"; import { consumePlanCommand, PLAN_COMMAND } from "../lib/plan"; import { COMPACT_COMMAND, isCompactCommand } from "../lib/compact"; +import { + agentCommands, + agentCommandPrompt, + filterSlashItems, + loadSlashFilter, + saveSlashFilter, + supportsAgentCommands, + type SlashFilter, +} from "../lib/agentCommands"; type Props = { enabled?: boolean; @@ -154,6 +163,8 @@ type Props = { onCwdChange: (cwd: string) => void; onBranchChange?: () => void; onNewTerminal?: () => void; + onAgentCommand?: (command: string) => boolean; + commandResult?: ReactNode; onModelChange: (harness: HarnessId, model: string) => void; onModelSettingsChange?: (settings: Record) => void; onRuntimeModeChange: (mode: RuntimeMode) => void; @@ -407,6 +418,8 @@ export function Composer({ onCwdChange, onBranchChange, onNewTerminal, + onAgentCommand, + commandResult, onModelChange, onModelSettingsChange, onRuntimeModeChange, @@ -491,10 +504,14 @@ export function Composer({ pickerOpen, }); const skills = skillCatalog.skills; + const [slashFilter, setSlashFilter] = useState(loadSlashFilter); + const commandsSupported = !!onAgentCommand && supportsAgentCommands(harness); + const effectiveFilter = commandsSupported ? slashFilter : "all"; const slashItems = useMemo( () => [ PLAN_COMMAND, COMPACT_COMMAND, + ...(commandsSupported ? agentCommands(harness) : []), ...skills.filter( (skill) => skill.kind === "native" || @@ -502,12 +519,17 @@ export function Composer({ skill.name !== COMPACT_COMMAND.name), ), ], - [skills], + [skills, commandsSupported, harness], + ); + const skillLimit = + hasNativeCommands(harness) || commandsSupported + ? Number.POSITIVE_INFINITY + : undefined; + const rankedSkills = rankSkills( + filterSlashItems(slashItems, effectiveFilter), + slash?.query ?? "", + skillLimit, ); - const skillLimit = hasNativeCommands(harness) - ? Number.POSITIVE_INFINITY - : undefined; - const rankedSkills = rankSkills(slashItems, slash?.query ?? "", skillLimit); const attachmentsSupported = harnessSupportsAttachments(harness); const skillNames = useMemo( () => new Set(slashItems.map((skill) => skill.invocation)), @@ -701,7 +723,11 @@ export function Composer({ const syncTokensFromTextarea = (el: HTMLTextAreaElement) => { if (creatingSkill) return; const cursor = el.selectionStart ?? 0; - const token = slashTokenAt(el.value, cursor, hasNativeCommands(harness)); + const token = slashTokenAt( + el.value, + cursor, + hasNativeCommands(harness) || commandsSupported, + ); setSlash(token); setMention(token ? null : mentionTokenAt(el.value, cursor)); }; @@ -900,6 +926,36 @@ export function Composer({ }, [addAttachments, attachmentsSupported, enabled]); const submit = (value: string) => { + const native = commandsSupported + ? agentCommandPrompt(value, harness, effectiveFilter, skills) + : null; + if (native && /^\/plan(?=\s|$)/.test(native)) { + value = native; + if (!consumePlanCommand(value).text.trim()) { + setPlanSelected(true); + if (ref.current) ref.current.value = ""; + setDraft(""); + onDraftChange?.(""); + setSlash(null); + syncHasValue("", attachments); + return; + } + } else if (native) { + const accepted = onAgentCommand?.(native); + setSlash(null); + if (!accepted) return; + if (ref.current) { + ref.current.value = ""; + ref.current.style.height = "auto"; + } + setDraft(""); + onDraftChange?.(""); + setMention(null); + // A chat command consumes only its text. Keep attachments, cards and + // the next-message plan selection in the same mounted composer. + syncHasValue("", attachments); + return; + } if (isCompactCommand(value)) { if (!onCompactContext?.()) return; if (!ref.current) return; @@ -983,7 +1039,23 @@ export function Composer({ if ( e.key === "Enter" && !e.shiftKey && - isCompactCommand(e.currentTarget.value) + (isCompactCommand(e.currentTarget.value) || + (commandsSupported && + agentCommandPrompt( + e.currentTarget.value, + harness, + effectiveFilter, + skills, + ) !== null && + (!slash || + rankedSkills.length === 0 || + agentCommands(harness).some((item) => { + const name = e.currentTarget.value + .trim() + .split(/\s/)[0] + .replace(/^\/(agent:|cli:)?/, ""); + return item.name === name; + })))) ) { e.preventDefault(); submit(e.currentTarget.value); @@ -1072,6 +1144,7 @@ export function Composer({ onSteer={onSteerQueuedMessage} onResume={onResumeQueue} /> + {commandResult}
{pickerOpen ? (
@@ -1085,6 +1158,17 @@ export function Composer({ busy={createBusy} onActive={setSkillActive} onPick={pickSkill} + filter={effectiveFilter} + onFilterChange={ + commandsSupported + ? (filter) => { + setSlashFilter(filter); + saveSlashFilter(filter); + setSkillActive(0); + ref.current?.focus(); + } + : undefined + } onStartCreate={() => { setCreatingSkill(true); setCreateError(null); diff --git a/src/chrome/SkillPicker.tsx b/src/chrome/SkillPicker.tsx index f84af83d..65f11b98 100644 --- a/src/chrome/SkillPicker.tsx +++ b/src/chrome/SkillPicker.tsx @@ -7,12 +7,9 @@ import { type MouseEvent as ReactMouseEvent, } from "react"; import { looksLikeProject } from "../lib/recents"; -import { - isValidSkillName, - slugSkillName, - type Skill, -} from "../lib/skills"; +import { isValidSkillName, slugSkillName, type Skill } from "../lib/skills"; import { useLockOverscroll } from "../hooks/useLockOverscroll"; +import type { SlashFilter } from "../lib/agentCommands"; type Props = { skills: Skill[]; @@ -27,6 +24,8 @@ type Props = { onStartCreate: () => void; onCancelCreate: () => void; onCreate: (name: string, scope: "project" | "user") => void; + filter?: SlashFilter; + onFilterChange?: (filter: SlashFilter) => void; }; export function SkillPicker({ @@ -42,6 +41,8 @@ export function SkillPicker({ onStartCreate, onCancelCreate, onCreate, + filter = "all", + onFilterChange, }: Props) { return (
) : ( <> + {onFilterChange ? ( +
+ {( + [ + ["all", "All"], + ["commands", "Agent commands"], + ["skills", "Skills"], + ] as const + ).map(([value, label]) => ( + + ))} +
+ ) : null} - + {filter !== "commands" ? ( + + ) : null} )}
@@ -174,9 +203,13 @@ function SkillList({ {skill.description} ) : null} - {skill.kind === "native" && (skill.inputHint || skill.subcommands?.length) ? ( + {skill.kind === "native" && + (skill.inputHint || skill.subcommands?.length) ? ( - {skill.inputHint || skill.subcommands?.map((sub) => sub.usage || sub.name).join(" · ")} + {skill.inputHint || + skill.subcommands + ?.map((sub) => sub.usage || sub.name) + .join(" · ")} ) : null} @@ -305,7 +338,9 @@ function ScopeButton({ disabled={disabled} onClick={onClick} className={`flex min-w-0 flex-1 flex-col rounded-md px-2 py-1.5 text-left ${ - selected ? "bg-content/20 text-content" : "bg-content/10 text-content/70" + selected + ? "bg-content/20 text-content" + : "bg-content/10 text-content/70" } disabled:opacity-40`} > {label} @@ -322,6 +357,7 @@ function scopeLabel(skill: Skill): string { } if (skill.kind === "builtin") return "monocode"; if (skill.scope === "user") return "personal"; - if (skill.source !== "agents" && skill.source !== "monocode") return skill.source; + if (skill.source !== "agents" && skill.source !== "monocode") + return skill.source; return "project"; } diff --git a/src/lib/agentCommands.test.ts b/src/lib/agentCommands.test.ts new file mode 100644 index 00000000..fbd35f5b --- /dev/null +++ b/src/lib/agentCommands.test.ts @@ -0,0 +1,225 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + agentCommands, + agentCommandPrompt, + filterSlashItems, + runChatCommand, +} from "./agentCommands"; +import { resetHarnessModelOverlays, setHarnessModels } from "./models"; +import { newSession, type Session } from "./session"; +import type { Skill } from "./skills"; + +const skill: Skill = { + kind: "file", + name: "status", + invocation: "status", + description: "Project status", + path: "/repo/.agents/skills/status/SKILL.md", + scope: "project", + source: "agents", +}; +const handlers = () => ({ + onModelChange: vi.fn(), + onModelSettingsChange: vi.fn(), + onRuntimeModeChange: vi.fn(), + onCompactContext: vi.fn(() => true), + onStop: vi.fn(), + onOpenDiff: vi.fn(), +}); +function session(harness: "codex" | "claude" = "codex"): Session { + return { + ...newSession(harness, "/repo", `${harness}:current`), + id: "chat-1", + providerSessionId: "existing-provider-history", + }; +} +beforeEach(() => { + resetHarnessModelOverlays(); + for (const harness of ["codex", "claude"] as const) + setHarnessModels(harness, [ + { + id: `${harness}:current`, + harness, + name: "Current model", + nativeId: "current", + settings: [ + { + id: "effort", + label: "Effort", + kind: "select", + value: "low", + options: [ + { value: "low", label: "Low" }, + { value: "high", label: "High" }, + ], + }, + { + id: "fast", + label: "Fast", + kind: "toggle", + value: "false", + options: [], + }, + ], + }, + { id: `${harness}:next`, harness, name: "Next model", nativeId: "next" }, + ]); +}); + +describe("agent command routing", () => { + it.each(["codex", "claude"] as const)( + "recognizes %s commands and legacy aliases without changing skill semantics", + (harness) => { + expect(agentCommandPrompt("/status", harness, "all", [])).toBe("/status"); + expect( + agentCommandPrompt("/agent:model next", harness, "skills", [skill]), + ).toBe("/model next"); + expect(agentCommandPrompt("/cli:status", harness, "all", [skill])).toBe( + "/status", + ); + expect(agentCommandPrompt("/status", harness, "all", [skill])).toBeNull(); + expect(agentCommandPrompt("/status", harness, "skills", [])).toBeNull(); + expect( + agentCommandPrompt("/future-command", harness, "commands", []), + ).toBe("/future-command"); + expect(agentCommandPrompt("/compact", harness, "all", [])).toBeNull(); + expect(agentCommandPrompt("/plan fix it", harness, "all", [])).toBeNull(); + expect( + agentCommandPrompt("Discuss /status", harness, "commands", []), + ).toBeNull(); + expect( + agentCommandPrompt("> /status", harness, "commands", []), + ).toBeNull(); + expect(agentCommandPrompt("/status", "omp", "commands", [])).toBeNull(); + }, + ); + it("labels unsupported commands instead of advertising terminal execution", () => { + const commands = agentCommands("claude"); + expect(commands.find((entry) => entry.name === "theme")).toMatchObject({ + origin: "Not supported in chat", + }); + expect(commands.find((entry) => entry.name === "status")).toMatchObject({ + origin: "Chat", + invocation: "agent:status", + }); + expect(filterSlashItems([skill, ...commands], "skills")).toEqual([skill]); + expect(filterSlashItems([skill, ...commands], "commands")).toEqual( + commands, + ); + expect(agentCommands("cursor")).toEqual([]); + }); +}); + +describe("commands in the existing conversation", () => { + it.each(["codex", "claude"] as const)( + "reads %s status without mutating the session or invoking an action", + (harness) => { + const current = session(harness); + const snapshot = structuredClone(current); + const actions = handlers(); + expect(runChatCommand(current, "/status", actions)).toMatchObject({ + accepted: true, + panel: { kind: "status" }, + }); + expect(current).toEqual(snapshot); + Object.values(actions).forEach((action) => + expect(action).not.toHaveBeenCalled(), + ); + }, + ); + it("changes the existing session's model and permissions through its normal callbacks", () => { + const actions = handlers(); + expect(runChatCommand(session(), "/model next", actions).accepted).toBe( + true, + ); + expect(actions.onModelChange).toHaveBeenCalledWith( + "chat-1", + "codex", + "codex:next", + ); + expect( + runChatCommand(session(), "/permissions supervised", actions).accepted, + ).toBe(true); + expect(actions.onRuntimeModeChange).toHaveBeenCalledWith( + "chat-1", + "supervised", + ); + expect(runChatCommand(session(), "/model missing", actions).accepted).toBe( + false, + ); + expect(actions.onModelChange).toHaveBeenCalledTimes(1); + }); + it("uses only settings advertised by the current model, preserving other settings", () => { + const current = { + ...session(), + modelSettings: { effort: "low", other: "keep" }, + }; + const actions = handlers(); + expect(runChatCommand(current, "/fast on", actions).accepted).toBe(true); + expect(actions.onModelSettingsChange).toHaveBeenCalledWith("chat-1", { + effort: "low", + other: "keep", + fast: "true", + }); + expect(runChatCommand(current, "/reasoning high", actions).accepted).toBe( + true, + ); + expect(actions.onModelSettingsChange).toHaveBeenLastCalledWith("chat-1", { + effort: "high", + other: "keep", + }); + expect( + runChatCommand(current, "/reasoning imaginary", actions).accepted, + ).toBe(false); + expect( + runChatCommand({ ...current, model: "codex:next" }, "/fast on", actions) + .accepted, + ).toBe(false); + expect(actions.onModelSettingsChange).toHaveBeenCalledTimes(2); + }); + it("compacts the same conversation and honors rejected compaction", () => { + const actions = handlers(); + expect(runChatCommand(session(), "/compact", actions).accepted).toBe(true); + expect(actions.onCompactContext).toHaveBeenCalledWith("chat-1"); + actions.onCompactContext.mockReturnValue(false); + expect(runChatCommand(session(), "/compact", actions).accepted).toBe(false); + }); + it.each([ + { busy: true }, + { queuedMessages: [{ id: "queued", text: "follow up", attachments: [] }] }, + ])("protects settings and history while work is pending: %j", (patch) => { + const actions = handlers(); + const current = { ...session(), ...patch }; + for (const command of [ + "/model next", + "/permissions full-access", + "/compact", + "/fast on", + ]) + expect(runChatCommand(current, command, actions).accepted).toBe(false); + expect(runChatCommand(current, "/status", actions).accepted).toBe(true); + Object.values(actions).forEach((action) => + expect(action).not.toHaveBeenCalled(), + ); + expect(runChatCommand(current, "/stop", actions).accepted).toBe(true); + expect(actions.onStop).toHaveBeenCalledWith("chat-1"); + }); + it("rejects unsupported commands and invalid input without invoking any action", () => { + const actions = handlers(); + for (const text of [ + "/theme", + "/future-command", + "/logout", + "/clear", + "/status\n/logout", + "/status\x1b[A", + "/status\t", + "/compact extra", + "status", + ]) + expect(runChatCommand(session(), text, actions).accepted).toBe(false); + Object.values(actions).forEach((action) => + expect(action).not.toHaveBeenCalled(), + ); + }); +}); diff --git a/src/lib/agentCommands.ts b/src/lib/agentCommands.ts new file mode 100644 index 00000000..901f5453 --- /dev/null +++ b/src/lib/agentCommands.ts @@ -0,0 +1,279 @@ +import codexCommands from "./codexCliCommands.json"; +import claudeCommands from "./claudeCliCommands.json"; +import { modelsFor, nativeModelId, type AgentModel } from "./models"; +import { + RUNTIME_MODES, + type HarnessId, + type RuntimeMode, + type Session, +} from "./session"; +import type { Skill } from "./skills"; + +export type SlashFilter = "all" | "commands" | "skills"; +const FILTER_KEY = "monocode.slashFilter"; + +export function supportsAgentCommands(harness: HarnessId): boolean { + return harness === "codex" || harness === "claude"; +} + +export const CHAT_COMMANDS: Record = { + status: "Show this chat's model, permissions and context usage.", + context: "Show context usage reported for this chat.", + model: "Choose the model for this chat: /model [model ID].", + permissions: "Choose this chat's access mode: /permissions [mode].", + approvals: "Choose this chat's access mode.", + compact: + "Compact the current conversation using its existing agent connection.", + plan: "Plan the next message in this chat: /plan [request].", + fast: "Configure fast mode for the current model: /fast [on|off].", + effort: "Configure the current model's reasoning effort: /effort [level].", + reasoning: + "Configure the current model's reasoning effort: /reasoning [level].", + diff: "Open this chat's project diff in MonoCode.", + stop: "Stop the current agent turn.", + help: "Show commands supported in this chat.", +}; + +// Documented CLI names are also recognized so unsupported commands never +// silently launch a terminal or become model prompts in Agent commands mode. +export function agentCommands(harness: HarnessId): Skill[] { + if (!supportsAgentCommands(harness)) return []; + const names = harness === "codex" ? codexCommands : claudeCommands; + return [...new Set([...Object.keys(CHAT_COMMANDS), ...names])].map( + (name) => ({ + kind: "native", + name, + invocation: `agent:${name}`, + aliases: [name, `cli:${name}`], + description: CHAT_COMMANDS[name] ?? "Not supported in MonoCode chat yet.", + source: harness, + origin: CHAT_COMMANDS[name] ? "Chat" : "Not supported in chat", + }), + ); +} + +export function loadSlashFilter(): SlashFilter { + try { + const value = localStorage.getItem(FILTER_KEY); + return value === "commands" || value === "skills" ? value : "all"; + } catch { + return "all"; + } +} + +export function saveSlashFilter(filter: SlashFilter): void { + try { + localStorage.setItem(FILTER_KEY, filter); + } catch { + /* private mode */ + } +} + +export function filterSlashItems(items: Skill[], filter: SlashFilter): Skill[] { + return items.filter( + (item) => + filter === "all" || + (filter === "commands" + ? item.kind === "native" + : item.kind === "file" || item.name === "create-skill"), + ); +} + +/** Preserve skills and MonoCode shortcuts. /agent:name disambiguates them; + * /cli:name from the earlier local build is a compatibility alias only. */ +export function agentCommandPrompt( + text: string, + harness: HarnessId, + filter: SlashFilter, + skills: Skill[], +): string | null { + if (!supportsAgentCommands(harness)) return null; + const match = text.match(/^\s*\/([a-zA-Z0-9_.:-]+)(?=\s|$)/); + if (!match) return null; + const name = match[1]; + if (/^(agent|cli):.+/.test(name)) + return text.trimStart().replace(/^\/(agent|cli):/, "/"); + if (filter === "skills") return null; + if (filter === "commands") return text.trimStart(); + if ( + name === "plan" || + name === "compact" || + skills.some((skill) => skill.invocation === name) + ) + return null; + return agentCommands(harness).some((item) => item.name === name) + ? text.trimStart() + : null; +} + +export type CommandPanel = { + command: string; + kind: + | "status" + | "context" + | "model" + | "permissions" + | "settings" + | "help" + | "notice"; + message?: string; + error?: boolean; +}; + +type CommandHandlers = { + onModelChange: (id: string, harness: HarnessId, model: string) => void; + onModelSettingsChange: (id: string, values: Record) => void; + onRuntimeModeChange: (id: string, mode: RuntimeMode) => void; + onCompactContext: (id: string) => boolean; + onStop: (id: string) => void; + onOpenDiff: ( + path?: string, + session?: { sessionId: string; cwd: string }, + ) => void; +}; + +export function commandsLocked(session: Session): boolean { + return !!session.busy || !!session.queuedMessages?.length; +} + +export function commandModel( + session: Pick, +): AgentModel | undefined { + return modelsFor(session.harness).find((model) => model.id === session.model); +} + +/** Dispatch against the same MonoCode session callbacks used by its controls. + * No PTY, fork, shell, second conversation or generic model-turn fallback. */ +export function runChatCommand( + session: Session, + text: string, + handlers: CommandHandlers, +): { accepted: boolean; panel: CommandPanel } { + const match = text.trim().match(/^\/([a-zA-Z0-9_.:-]+)(?: +(.+))?$/); + const name = match?.[1] ?? "command"; + const argument = match?.[2]?.trim() ?? ""; + const result = (kind: CommandPanel["kind"], message?: string) => ({ + accepted: true, + panel: { command: name, kind, message }, + }); + const reject = (message: string) => ({ + accepted: false, + panel: { command: name, kind: "notice" as const, message, error: true }, + }); + if (!match || /[\x00-\x1f\x7f]/.test(text)) + return reject( + "Enter one slash command without line breaks or control characters.", + ); + if (!supportsAgentCommands(session.harness) || !CHAT_COMMANDS[name]) + return reject( + `/${name} is not supported in MonoCode chat yet. Use /help to see supported commands.`, + ); + if ( + ![ + "model", + "permissions", + "approvals", + "fast", + "effort", + "reasoning", + ].includes(name) && + argument + ) + return reject(`/${name} does not accept arguments in MonoCode chat.`); + if ( + [ + "model", + "permissions", + "approvals", + "fast", + "effort", + "reasoning", + "compact", + ].includes(name) && + commandsLocked(session) + ) + return reject( + "Finish the current turn and queued messages before changing this chat's settings or compacting it.", + ); + switch (name) { + case "status": + case "context": + case "help": + return result(name); + case "model": { + if (argument) { + const query = argument.toLowerCase(); + const model = modelsFor(session.harness).find((entry) => + [entry.id, entry.name, nativeModelId(entry)].some( + (value) => value.toLowerCase() === query, + ), + ); + if (!model) + return reject( + `Unknown model: ${argument}. Use /model and select a model from this agent's catalog.`, + ); + handlers.onModelChange(session.id, session.harness, model.id); + } + return result("model"); + } + case "permissions": + case "approvals": { + if (argument) { + const mode = RUNTIME_MODES.find((value) => value === argument); + if (!mode) return reject(`Choose one of: ${RUNTIME_MODES.join(", ")}.`); + handlers.onRuntimeModeChange(session.id, mode); + } + return result("permissions"); + } + case "fast": + case "effort": + case "reasoning": { + const model = commandModel(session); + const setting = model?.settings?.find((entry) => + name === "fast" + ? entry.id === "fast" + : ["effort", "reasoning"].includes(entry.id), + ); + if (!setting) + return reject( + `The current model's catalog does not expose a ${name} setting.`, + ); + if (argument) { + const value = + argument === "on" ? "true" : argument === "off" ? "false" : argument; + const allowed = + setting.kind === "toggle" + ? ["true", "false"] + : setting.options.map((option) => option.value); + if (!allowed.includes(value)) + return reject(`Choose one of: ${allowed.join(", ")}.`); + handlers.onModelSettingsChange(session.id, { + ...session.modelSettings, + [setting.id]: value, + }); + } + return result("settings"); + } + case "compact": + return handlers.onCompactContext(session.id) + ? result( + "notice", + "Compacting this conversation. Progress appears in the chat.", + ) + : reject("This conversation cannot be compacted right now."); + case "stop": + handlers.onStop(session.id); + return result( + "notice", + session.busy ? "Stopping this turn." : "No turn is running.", + ); + case "diff": + handlers.onOpenDiff(undefined, { + sessionId: session.id, + cwd: session.worktreeCwd || session.cwd, + }); + return result("notice", "Opened this project's diff."); + default: + return reject("Use /plan in the composer to plan your next message."); + } +} diff --git a/src/lib/claudeCliCommands.json b/src/lib/claudeCliCommands.json new file mode 100644 index 00000000..1eec13d3 --- /dev/null +++ b/src/lib/claudeCliCommands.json @@ -0,0 +1,114 @@ +[ + "add-dir", + "advisor", + "agents", + "artifacts", + "auto-mode-setup", + "autocompact", + "autofix-pr", + "background", + "batch", + "branch", + "btw", + "bug", + "cd", + "chrome", + "claude-api", + "clear", + "code-review", + "color", + "compact", + "config", + "context", + "copy", + "cost", + "dataviz", + "debug", + "deep-research", + "design", + "design-login", + "design-sync", + "desktop", + "diff", + "doctor", + "effort", + "exit", + "export", + "fast", + "feedback", + "fewer-permission-prompts", + "focus", + "fork", + "goal", + "heapdump", + "help", + "hooks", + "ide", + "import", + "init", + "insights", + "install-github-app", + "install-slack-app", + "keybindings", + "list-agents", + "login", + "logout", + "loop", + "mcp", + "memory", + "mobile", + "model", + "passes", + "permissions", + "plan", + "plugin", + "powerup", + "pr-comments", + "privacy-settings", + "radio", + "rate-limit-options", + "recap", + "release-notes", + "reload-plugins", + "reload-skills", + "remote-control", + "remote-env", + "rename", + "resume", + "review", + "rewind", + "run", + "run-skill-generator", + "sandbox", + "schedule", + "scroll-speed", + "security-review", + "setup-bedrock", + "setup-vertex", + "simplify", + "skill-doctor", + "skills", + "stats", + "status", + "statusline", + "stickers", + "stop", + "subtask", + "tasks", + "team-onboarding", + "teleport", + "terminal-setup", + "theme", + "tui", + "ultraplan", + "ultrareview", + "upgrade", + "usage", + "usage-credits", + "verify", + "vim", + "voice", + "web-setup", + "workflow-authoring", + "workflows" +] diff --git a/src/lib/codexCliCommands.json b/src/lib/codexCliCommands.json new file mode 100644 index 00000000..331e1941 --- /dev/null +++ b/src/lib/codexCliCommands.json @@ -0,0 +1,59 @@ +[ + "permissions", + "ide", + "keymap", + "vim", + "setup-default-sandbox", + "sandbox-add-read-dir", + "agent", + "apps", + "plugins", + "hooks", + "clear", + "rename", + "archive", + "delete", + "compact", + "copy", + "diff", + "exit", + "experimental", + "approve", + "memories", + "skills", + "import", + "feedback", + "init", + "logout", + "mcp", + "mention", + "model", + "fast", + "plan", + "goal", + "personality", + "ps", + "stop", + "fork", + "app", + "side", + "raw", + "resume", + "new", + "quit", + "review", + "status", + "usage", + "debug-config", + "statusline", + "title", + "theme", + "pets", + "cloud", + "cloud-environment", + "ide-context", + "local", + "project", + "reasoning", + "worktree" +] diff --git a/src/surfaces/SessionPane.tsx b/src/surfaces/SessionPane.tsx index 3bff1dbe..17443a02 100644 --- a/src/surfaces/SessionPane.tsx +++ b/src/surfaces/SessionPane.tsx @@ -42,6 +42,12 @@ import { loadNotesEnabled, subscribeNotesEnabled } from "../lib/settings"; import { resolveModel } from "../lib/models"; import { isAstraModel } from "../lib/astraWelcome"; import { AstraWelcome } from "./AstraWelcome"; +import { AgentCommandPanel } from "../chrome/AgentCommandPanel"; +import { + runChatCommand, + supportsAgentCommands, + type CommandPanel, +} from "../lib/agentCommands"; type Props = { session: Session; @@ -238,6 +244,19 @@ export const SessionPane = memo(function SessionPane({ const showDeckProjectPicker = isEmpty && !looksLikeProject(session.cwd); const dockComposer = !isEmpty || inSplit || !!session.inboxAsk; const draftRef = useRef(undefined); + const [commandPanel, setCommandPanel] = useState(null); + const handleAgentCommand = (command: string): boolean => { + const result = runChatCommand(session, command, { + onModelChange, + onModelSettingsChange, + onRuntimeModeChange, + onCompactContext, + onStop, + onOpenDiff, + }); + setCommandPanel(result.panel); + return result.accepted; + }; const composer = ( onCwdChange(session.id, cwd)} onBranchChange={() => onBranchChange(session.id)} onNewTerminal={() => onNewTerminal(session.id)} + onAgentCommand={ + supportsAgentCommands(session.harness) ? handleAgentCommand : undefined + } + commandResult={ + commandPanel && supportsAgentCommands(session.harness) ? ( + setCommandPanel(null)} + onModelChange={(model) => + onModelChange(session.id, session.harness, model) + } + onModelSettingsChange={(settings) => + onModelSettingsChange(session.id, settings) + } + onRuntimeModeChange={(mode) => + onRuntimeModeChange(session.id, mode) + } + /> + ) : null + } onModelChange={(harness, model) => { onModelChange(session.id, harness, model); const selected = resolveModel(harness, model); diff --git a/tests/agent-commands.html b/tests/agent-commands.html new file mode 100644 index 00000000..6a41ecfb --- /dev/null +++ b/tests/agent-commands.html @@ -0,0 +1,11 @@ + + + + + MonoCode chat commands test + + +
+ + + diff --git a/tests/agent-commands.tsx b/tests/agent-commands.tsx new file mode 100644 index 00000000..6d0fd3dc --- /dev/null +++ b/tests/agent-commands.tsx @@ -0,0 +1,167 @@ +// Browser regression fixture using the real SessionPane. IPC is mocked; +// commands cannot start a real agent, PTY or model turn here. +import React, { useState } from "react"; +import { createRoot } from "react-dom/client"; +import { mockIPC, mockWindows } from "@tauri-apps/api/mocks"; +import "../src/index.css"; +const calls: unknown[] = []; +Object.assign(window, { testCalls: calls }); +mockWindows("main"); +mockIPC( + (cmd, args) => { + calls.push({ cmd, args }); + if (cmd === "list_skills") + return [ + { + name: "research", + description: "Research skill", + path: "/repo/.agents/skills/research/SKILL.md", + scope: "project", + source: "agents", + }, + ]; + if (cmd === "home_dir" || cmd === "default_cwd") return "/repo"; + if (cmd === "git_status") + return { branch: "main", changes: [], isRepo: true }; + if (cmd.includes("list_") || cmd.includes("read_dir")) return []; + if (cmd.startsWith("harness_resolve_")) return { path: "/test/agent" }; + return null; + }, + { shouldMockEvents: true }, +); +const { SessionPane } = await import("../src/surfaces/SessionPane"); +const { newSession } = await import("../src/lib/session"); +const { setHarnessModels } = await import("../src/lib/models"); +for (const harness of ["codex", "claude"] as const) + setHarnessModels(harness, [ + { + id: `${harness}:current`, + harness, + name: `${harness} current`, + nativeId: "current", + settings: [ + { + id: "effort", + label: "Effort", + kind: "select", + value: "low", + options: [ + { value: "low", label: "Low" }, + { value: "high", label: "High" }, + ], + }, + { + id: "fast", + label: "Fast", + kind: "toggle", + value: "false", + options: [], + }, + ], + }, + { + id: `${harness}:next`, + harness, + name: `${harness} next`, + nativeId: "next", + }, + ]); +function Fixture() { + const [session, setSession] = useState(() => ({ + ...newSession("codex", "/repo", "codex:current"), + id: "existing-chat", + providerSessionId: "existing-provider-session", + context: { used: 32000, window: 128000 }, + blocks: [ + { + id: "user", + role: "user" as const, + text: "Keep this conversation in the chat UI.", + }, + { + id: "answer", + role: "assistant" as const, + text: "Existing conversation remains visible.", + }, + ], + })); + Object.assign(window, { testSession: session }); + return ( +
+ + {}} + onClose={() => {}} + onCwdChange={() => {}} + onBranchChange={() => {}} + onModelChange={(id, harness, model) => { + calls.push({ modelChange: { id, harness, model } }); + setSession((current) => ({ ...current, harness, model })); + }} + onModelSettingsChange={(id, modelSettings) => { + calls.push({ modelSettings: { id, modelSettings } }); + setSession((current) => ({ ...current, modelSettings })); + }} + onRuntimeModeChange={(id, runtimeMode) => { + calls.push({ permissionChange: { id, runtimeMode } }); + setSession((current) => ({ ...current, runtimeMode })); + }} + onSubmit={(id, text, attachments, options) => + calls.push({ modelTurn: { id, text, attachments, options } }) + } + onStop={(id) => { + calls.push({ stop: id }); + setSession((current) => ({ ...current, busy: false })); + }} + onCompactContext={(id) => { + calls.push({ compact: id }); + return true; + }} + onDeleteQueuedMessage={() => {}} + onEditQueuedMessage={() => {}} + onQueuedMessageEditingChange={() => {}} + onSteerQueuedMessage={() => {}} + onResumeQueue={() => {}} + onApproval={() => {}} + onQuestionReply={() => {}} + onOpenFile={() => {}} + onOpenDiff={(path, context) => calls.push({ diff: { path, context } })} + onOpenPlan={() => {}} + onBuildPlan={() => {}} + onNewTerminal={() => calls.push({ newTerminal: true })} + /> +
+ ); +} +createRoot(document.getElementById("root")!).render();