From 6aa5e97d75fc87e2488bd4dfb61575f64b610d39 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 17 Feb 2026 23:03:42 +0700 Subject: [PATCH 1/2] feat: make slash commands available in draft agent screen --- .../app/src/components/agent-input-area.tsx | 41 +++++- .../src/components/command-autocomplete.tsx | 8 +- .../app/src/hooks/use-agent-commands-query.ts | 32 ++++- .../src/screens/agent/draft-agent-screen.tsx | 29 ++++ .../server/src/client/daemon-client.test.ts | 133 ++++++++++++++++++ packages/server/src/client/daemon-client.ts | 25 +++- packages/server/src/server/session.ts | 100 ++++++++++--- .../src/shared/messages.list-commands.test.ts | 46 ++++++ packages/server/src/shared/messages.ts | 9 ++ 9 files changed, 391 insertions(+), 32 deletions(-) create mode 100644 packages/server/src/shared/messages.list-commands.test.ts diff --git a/packages/app/src/components/agent-input-area.tsx b/packages/app/src/components/agent-input-area.tsx index 2f35633e3..bd35b4211 100644 --- a/packages/app/src/components/agent-input-area.tsx +++ b/packages/app/src/components/agent-input-area.tsx @@ -26,7 +26,10 @@ import { } from "./message-input"; import { Theme } from "@/styles/theme"; import { CommandAutocomplete } from "./command-autocomplete"; -import { useAgentCommandsQuery } from "@/hooks/use-agent-commands-query"; +import { + useAgentCommandsQuery, + type DraftCommandConfig, +} from "@/hooks/use-agent-commands-query"; import { encodeImages } from "@/utils/encode-images"; import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; import { focusWithRetries } from "@/utils/web-focus"; @@ -55,6 +58,8 @@ interface AgentInputAreaProps { autoFocus?: boolean; /** Callback to expose the addImages function to parent components */ onAddImages?: (addImages: (images: ImageAttachment[]) => void) => void; + /** Optional draft context for listing commands before an agent exists. */ + commandDraftConfig?: DraftCommandConfig; } const EMPTY_ARRAY: readonly QueuedMessage[] = []; @@ -69,6 +74,7 @@ export function AgentInputArea({ onChangeText, autoFocus = false, onAddImages, + commandDraftConfig, }: AgentInputAreaProps) { const { theme } = useUnistyles(); const insets = useSafeAreaInsets(); @@ -117,13 +123,39 @@ export function AgentInputArea({ // Command autocomplete logic const showCommandAutocomplete = userInput.startsWith("/") && !userInput.includes(" "); const commandFilter = showCommandAutocomplete ? userInput.slice(1) : ""; + const normalizedCommandDraftConfig = useMemo(() => { + if (!commandDraftConfig) { + return undefined; + } - // Prefetch commands when agent is available (not on new agent screen) + const cwd = commandDraftConfig.cwd.trim(); + if (!cwd) { + return undefined; + } + + const modeId = commandDraftConfig.modeId?.trim() ?? ""; + const model = commandDraftConfig.model?.trim() ?? ""; + const thinkingOptionId = commandDraftConfig.thinkingOptionId?.trim() ?? ""; + return { + provider: commandDraftConfig.provider, + cwd, + ...(modeId ? { modeId } : {}), + ...(model ? { model } : {}), + ...(thinkingOptionId ? { thinkingOptionId } : {}), + }; + }, [commandDraftConfig]); + + // Prefetch commands for real agents and for draft screens when context is available. const isRealAgent = agentId && !agentId.startsWith("__"); + const commandQueryDraftConfig = isRealAgent + ? undefined + : normalizedCommandDraftConfig; + const canLoadCommands = Boolean(serverId) && (isRealAgent || !!commandQueryDraftConfig); const { commands } = useAgentCommandsQuery({ serverId, agentId, - enabled: !!isRealAgent && !!serverId, + enabled: canLoadCommands, + draftConfig: commandQueryDraftConfig, }); // Filter commands for keyboard navigation @@ -742,13 +774,14 @@ export function AgentInputArea({ )} {/* Command autocomplete dropdown */} - {showCommandAutocomplete && isRealAgent && ( + {showCommandAutocomplete && canLoadCommands && ( )} diff --git a/packages/app/src/components/command-autocomplete.tsx b/packages/app/src/components/command-autocomplete.tsx index fb83afafe..29e03f5e4 100644 --- a/packages/app/src/components/command-autocomplete.tsx +++ b/packages/app/src/components/command-autocomplete.tsx @@ -1,6 +1,9 @@ import { View, Text, Pressable, ScrollView } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; -import { useAgentCommandsQuery } from "@/hooks/use-agent-commands-query"; +import { + useAgentCommandsQuery, + type DraftCommandConfig, +} from "@/hooks/use-agent-commands-query"; import { Fonts } from "@/constants/theme"; import { Theme } from "@/styles/theme"; @@ -16,6 +19,7 @@ interface CommandAutocompleteProps { filter: string; selectedIndex: number; onSelect: (command: AgentSlashCommand) => void; + draftConfig?: DraftCommandConfig; } export function CommandAutocomplete({ @@ -24,12 +28,14 @@ export function CommandAutocomplete({ filter, selectedIndex, onSelect, + draftConfig, }: CommandAutocompleteProps) { const { theme } = useUnistyles(); const { commands, isLoading, isError, error } = useAgentCommandsQuery({ serverId, agentId, enabled: true, + draftConfig, }); // Filter commands based on input after / diff --git a/packages/app/src/hooks/use-agent-commands-query.ts b/packages/app/src/hooks/use-agent-commands-query.ts index 3180ae15a..1d7e05708 100644 --- a/packages/app/src/hooks/use-agent-commands-query.ts +++ b/packages/app/src/hooks/use-agent-commands-query.ts @@ -1,5 +1,6 @@ import { useQuery } from "@tanstack/react-query"; import { useSessionStore } from "@/stores/session-store"; +import type { AgentProvider } from "@server/server/agent/agent-sdk-types"; const COMMANDS_STALE_TIME = 60_000; // Commands rarely change, cache for 1 minute @@ -9,20 +10,43 @@ interface AgentSlashCommand { argumentHint: string; } -function commandsQueryKey(serverId: string, agentId: string) { - return ["agentCommands", serverId, agentId] as const; +export interface DraftCommandConfig { + provider: AgentProvider; + cwd: string; + modeId?: string; + model?: string; + thinkingOptionId?: string; +} + +function commandsQueryKey( + serverId: string, + agentId: string, + draftConfig?: DraftCommandConfig +) { + return [ + "agentCommands", + serverId, + agentId, + draftConfig?.provider ?? null, + draftConfig?.cwd ?? null, + draftConfig?.modeId ?? null, + draftConfig?.model ?? null, + draftConfig?.thinkingOptionId ?? null, + ] as const; } interface UseAgentCommandsQueryOptions { serverId: string; agentId: string; enabled?: boolean; + draftConfig?: DraftCommandConfig; } export function useAgentCommandsQuery({ serverId, agentId, enabled = true, + draftConfig, }: UseAgentCommandsQueryOptions) { const client = useSessionStore( (state) => state.sessions[serverId]?.client ?? null @@ -32,12 +56,12 @@ export function useAgentCommandsQuery({ ); const query = useQuery({ - queryKey: commandsQueryKey(serverId, agentId), + queryKey: commandsQueryKey(serverId, agentId, draftConfig), queryFn: async () => { if (!client) { throw new Error("Daemon client not available"); } - const response = await client.listCommands(agentId); + const response = await client.listCommands(agentId, { draftConfig }); return response.commands as AgentSlashCommand[]; }, enabled: enabled && !!client && isConnected && !!agentId, diff --git a/packages/app/src/screens/agent/draft-agent-screen.tsx b/packages/app/src/screens/agent/draft-agent-screen.tsx index ca98a5700..01e9b7909 100644 --- a/packages/app/src/screens/agent/draft-agent-screen.tsx +++ b/packages/app/src/screens/agent/draft-agent-screen.tsx @@ -23,6 +23,7 @@ import { Combobox } from "@/components/ui/combobox"; import { FileDropZone } from "@/components/file-drop-zone"; import { useQuery } from "@tanstack/react-query"; import { useAgentFormState, type CreateAgentInitialValues } from "@/hooks/use-agent-form-state"; +import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query"; import { CHECKOUT_STATUS_STALE_TIME, checkoutStatusQueryKey, @@ -788,6 +789,33 @@ export function DraftAgentScreen({ const createAgentClient = useSessionStore((state) => selectedServerId ? state.sessions[selectedServerId]?.client ?? null : null ); + const draftCommandConfig = useMemo(() => { + const cwd = ( + isAttachWorktree && selectedWorktreePath ? selectedWorktreePath : workingDir + ).trim(); + if (!cwd) { + return undefined; + } + + return { + provider: selectedProvider, + cwd, + ...(modeOptions.length > 0 && selectedMode !== "" ? { modeId: selectedMode } : {}), + ...(selectedModel.trim() ? { model: selectedModel.trim() } : {}), + ...(selectedThinkingOptionId.trim() + ? { thinkingOptionId: selectedThinkingOptionId.trim() } + : {}), + }; + }, [ + isAttachWorktree, + modeOptions.length, + selectedMode, + selectedModel, + selectedProvider, + selectedThinkingOptionId, + selectedWorktreePath, + workingDir, + ]); const promptValue = machine.tag === "draft" ? machine.promptText : ""; const formErrorMessage = machine.tag === "draft" ? machine.errorMessage : ""; @@ -1240,6 +1268,7 @@ export function DraftAgentScreen({ onChangeText={(next) => dispatch({ type: "DRAFT_SET_PROMPT", text: next })} autoFocus={machine.tag === "draft"} onAddImages={handleAddImagesCallback} + commandDraftConfig={draftCommandConfig} /> diff --git a/packages/server/src/client/daemon-client.test.ts b/packages/server/src/client/daemon-client.test.ts index 4df5f8cb7..6c934683d 100644 --- a/packages/server/src/client/daemon-client.test.ts +++ b/packages/server/src/client/daemon-client.test.ts @@ -769,6 +769,139 @@ describe("DaemonClient", () => { }); }); + test("lists commands with draft config via RPC", async () => { + const logger = createMockLogger(); + const mock = createMockTransport(); + + const client = new DaemonClient({ + url: "ws://test", + logger, + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); + + const connectPromise = client.connect(); + mock.triggerOpen(); + await connectPromise; + + const promise = client.listCommands("__new_agent__", { + draftConfig: { + provider: "codex", + cwd: "/tmp/project", + modeId: "bypassPermissions", + model: "gpt-5", + thinkingOptionId: "off", + }, + }); + expect(mock.sent).toHaveLength(1); + + const request = JSON.parse(mock.sent[0]) as { + type: "session"; + message: { + type: "list_commands_request"; + agentId: string; + draftConfig?: { + provider: string; + cwd: string; + modeId?: string; + model?: string; + thinkingOptionId?: string; + }; + requestId: string; + }; + }; + expect(request.message.type).toBe("list_commands_request"); + expect(request.message.agentId).toBe("__new_agent__"); + expect(request.message.draftConfig).toEqual({ + provider: "codex", + cwd: "/tmp/project", + modeId: "bypassPermissions", + model: "gpt-5", + thinkingOptionId: "off", + }); + + mock.triggerMessage( + JSON.stringify({ + type: "session", + message: { + type: "list_commands_response", + payload: { + agentId: "__new_agent__", + commands: [ + { name: "help", description: "Show help", argumentHint: "" }, + ], + error: null, + requestId: request.message.requestId, + }, + }, + }) + ); + + await expect(promise).resolves.toEqual({ + agentId: "__new_agent__", + commands: [{ name: "help", description: "Show help", argumentHint: "" }], + error: null, + requestId: request.message.requestId, + }); + }); + + test("lists commands with legacy requestId signature via RPC", async () => { + const logger = createMockLogger(); + const mock = createMockTransport(); + + const client = new DaemonClient({ + url: "ws://test", + logger, + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); + + const connectPromise = client.connect(); + mock.triggerOpen(); + await connectPromise; + + const promise = client.listCommands("agent-1", "req-legacy"); + expect(mock.sent).toHaveLength(1); + + const request = JSON.parse(mock.sent[0]) as { + type: "session"; + message: { + type: "list_commands_request"; + agentId: string; + draftConfig?: unknown; + requestId: string; + }; + }; + expect(request.message.type).toBe("list_commands_request"); + expect(request.message.agentId).toBe("agent-1"); + expect(request.message.requestId).toBe("req-legacy"); + expect(request.message.draftConfig).toBeUndefined(); + + mock.triggerMessage( + JSON.stringify({ + type: "session", + message: { + type: "list_commands_response", + payload: { + agentId: "agent-1", + commands: [], + error: null, + requestId: "req-legacy", + }, + }, + }) + ); + + await expect(promise).resolves.toEqual({ + agentId: "agent-1", + commands: [], + error: null, + requestId: "req-legacy", + }); + }); + test("auto-acks terminal stream chunks after delivery", async () => { const logger = createMockLogger(); const mock = createMockTransport(); diff --git a/packages/server/src/client/daemon-client.ts b/packages/server/src/client/daemon-client.ts index 0355ae193..d4c41001a 100644 --- a/packages/server/src/client/daemon-client.ts +++ b/packages/server/src/client/daemon-client.ts @@ -209,6 +209,14 @@ type SpeechModelsListPayload = SpeechModelsListResponse["payload"]; type SpeechModelsDownloadPayload = SpeechModelsDownloadResponse["payload"]; type ListCommandsPayload = ListCommandsResponse["payload"]; type ExecuteCommandPayload = ExecuteCommandResponse["payload"]; +type ListCommandsDraftConfig = Pick< + AgentSessionConfig, + "provider" | "cwd" | "modeId" | "model" | "thinkingOptionId" +>; +type ListCommandsOptions = { + requestId?: string; + draftConfig?: ListCommandsDraftConfig; +}; type SetVoiceModePayload = Extract< SessionOutboundMessage, { type: "set_voice_mode_response" } @@ -2171,15 +2179,30 @@ export class DaemonClient { }); } + async listCommands(agentId: string, requestId?: string): Promise; async listCommands( agentId: string, - requestId?: string + options?: ListCommandsOptions + ): Promise; + async listCommands( + agentId: string, + requestIdOrOptions?: string | ListCommandsOptions ): Promise { + const requestId = + typeof requestIdOrOptions === "string" + ? requestIdOrOptions + : requestIdOrOptions?.requestId; + const draftConfig = + typeof requestIdOrOptions === "string" + ? undefined + : requestIdOrOptions?.draftConfig; + return this.sendCorrelatedSessionRequest({ requestId, message: { type: "list_commands_request", agentId, + ...(draftConfig ? { draftConfig } : {}), }, responseType: "list_commands_response", timeout: 30000, diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index f82c3379a..2b048f591 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -1524,7 +1524,7 @@ export class Session { } case "list_commands_request": - await this.handleListCommandsRequest(msg.agentId, msg.requestId); + await this.handleListCommandsRequest(msg); break; case "execute_command_request": @@ -3473,9 +3473,12 @@ export class Session { /** * Handle list commands request for an agent */ - private async handleListCommandsRequest(agentId: string, requestId: string): Promise { + private async handleListCommandsRequest( + msg: Extract + ): Promise { + const { agentId, requestId, draftConfig } = msg; this.sessionLogger.debug( - { agentId }, + { agentId, draftConfig }, `Handling list commands request for agent ${agentId}` ); @@ -3483,47 +3486,100 @@ export class Session { const agents = this.agentManager.listAgents(); const agent = agents.find((a) => a.id === agentId); - if (!agent) { + if (agent?.session?.listCommands) { + const commands = await agent.session.listCommands(); this.emit({ type: "list_commands_response", payload: { agentId, - commands: [], - error: `Agent not found: ${agentId}`, + commands, + error: null, requestId, }, }); return; } - const session = agent.session; - if (!session || !session.listCommands) { - this.emit({ - type: "list_commands_response", - payload: { - agentId, - commands: [], - error: `Agent does not support listing commands`, - requestId, - }, - }); + if (!agent && draftConfig) { + const provider = draftConfig.provider; + const client = this.providerRegistry[provider].createClient(this.sessionLogger); + const available = await client.isAvailable(); + if (!available) { + this.emit({ + type: "list_commands_response", + payload: { + agentId, + commands: [], + error: `Provider '${provider}' is not available`, + requestId, + }, + }); + return; + } + + const sessionConfig: AgentSessionConfig = { + provider, + cwd: expandTilde(draftConfig.cwd), + ...(draftConfig.modeId ? { modeId: draftConfig.modeId } : {}), + ...(draftConfig.model ? { model: draftConfig.model } : {}), + ...(draftConfig.thinkingOptionId + ? { thinkingOptionId: draftConfig.thinkingOptionId } + : {}), + }; + + const ephemeralSession = await client.createSession(sessionConfig); + try { + if (!ephemeralSession.listCommands) { + this.emit({ + type: "list_commands_response", + payload: { + agentId, + commands: [], + error: `Provider '${provider}' does not support listing commands`, + requestId, + }, + }); + return; + } + + const commands = await ephemeralSession.listCommands(); + this.emit({ + type: "list_commands_response", + payload: { + agentId, + commands, + error: null, + requestId, + }, + }); + } finally { + try { + await ephemeralSession.close(); + } catch (closeError) { + this.sessionLogger.warn( + { err: closeError, provider, agentId }, + "Failed to close ephemeral command listing session" + ); + } + } return; } - const commands = await session.listCommands(); - this.emit({ type: "list_commands_response", payload: { agentId, - commands, - error: null, + commands: [], + error: agent + ? `Agent does not support listing commands` + : `Agent not found: ${agentId}`, requestId, }, }); + } catch (error: any) { this.sessionLogger.error( - { err: error, agentId }, + { err: error, agentId, draftConfig }, "Failed to list commands" ); this.emit({ diff --git a/packages/server/src/shared/messages.list-commands.test.ts b/packages/server/src/shared/messages.list-commands.test.ts new file mode 100644 index 000000000..42a610d66 --- /dev/null +++ b/packages/server/src/shared/messages.list-commands.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, test } from "vitest"; +import { SessionInboundMessageSchema } from "./messages.js"; + +describe("list_commands_request schema", () => { + test("accepts legacy agent-only payload", () => { + const parsed = SessionInboundMessageSchema.parse({ + type: "list_commands_request", + agentId: "agent-123", + requestId: "req-123", + }); + + expect(parsed.type).toBe("list_commands_request"); + if (parsed.type !== "list_commands_request") { + throw new Error("Expected list_commands_request message"); + } + expect(parsed.agentId).toBe("agent-123"); + expect(parsed.draftConfig).toBeUndefined(); + }); + + test("accepts draft command context payload", () => { + const parsed = SessionInboundMessageSchema.parse({ + type: "list_commands_request", + agentId: "__new_agent__", + draftConfig: { + provider: "codex", + cwd: "/tmp/project", + modeId: "bypassPermissions", + model: "gpt-5", + thinkingOptionId: "off", + }, + requestId: "req-456", + }); + + expect(parsed.type).toBe("list_commands_request"); + if (parsed.type !== "list_commands_request") { + throw new Error("Expected list_commands_request message"); + } + expect(parsed.draftConfig).toEqual({ + provider: "codex", + cwd: "/tmp/project", + modeId: "bypassPermissions", + model: "gpt-5", + thinkingOptionId: "off", + }); + }); +}); diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index b4c0a5b5b..37870c1db 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -926,9 +926,18 @@ export const PingMessageSchema = z.object({ clientSentAt: z.number().int().optional(), }); +const ListCommandsDraftConfigSchema = z.object({ + provider: AgentProviderSchema, + cwd: z.string(), + modeId: z.string().optional(), + model: z.string().optional(), + thinkingOptionId: z.string().optional(), +}); + export const ListCommandsRequestSchema = z.object({ type: z.literal("list_commands_request"), agentId: z.string(), + draftConfig: ListCommandsDraftConfigSchema.optional(), requestId: z.string(), }); From ecd276e3d27e68e34c763c0084906d385ae60c47 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 17 Feb 2026 23:08:33 +0700 Subject: [PATCH 2/2] refactor(server): route draft command listing through agent manager --- .../server/src/server/agent/agent-manager.ts | 31 +++++++++ packages/server/src/server/session.ts | 63 ++++--------------- 2 files changed, 42 insertions(+), 52 deletions(-) diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index 958abd824..f230e3984 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -11,6 +11,7 @@ import { z } from "zod"; import type { AgentCapabilityFlags, AgentClient, + AgentSlashCommand, AgentMode, AgentPermissionRequest, AgentPermissionResponse, @@ -437,6 +438,36 @@ export class AgentManager { return Promise.all(checks); } + async listDraftCommands(config: AgentSessionConfig): Promise { + const normalizedConfig = await this.normalizeConfig(config); + const client = this.requireClient(normalizedConfig.provider); + const available = await client.isAvailable(); + if (!available) { + throw new Error( + `Provider '${normalizedConfig.provider}' is not available. Please ensure the CLI is installed.` + ); + } + + const session = await client.createSession(normalizedConfig); + try { + if (!session.listCommands) { + throw new Error( + `Provider '${normalizedConfig.provider}' does not support listing commands` + ); + } + return await session.listCommands(); + } finally { + try { + await session.close(); + } catch (error) { + this.logger.warn( + { err: error, provider: normalizedConfig.provider }, + "Failed to close draft command listing session" + ); + } + } + } + getAgent(id: string): ManagedAgent | null { const agent = this.agents.get(id); return agent ? { ...agent } : null; diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 2b048f591..6234e71bc 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -3501,24 +3501,8 @@ export class Session { } if (!agent && draftConfig) { - const provider = draftConfig.provider; - const client = this.providerRegistry[provider].createClient(this.sessionLogger); - const available = await client.isAvailable(); - if (!available) { - this.emit({ - type: "list_commands_response", - payload: { - agentId, - commands: [], - error: `Provider '${provider}' is not available`, - requestId, - }, - }); - return; - } - const sessionConfig: AgentSessionConfig = { - provider, + provider: draftConfig.provider, cwd: expandTilde(draftConfig.cwd), ...(draftConfig.modeId ? { modeId: draftConfig.modeId } : {}), ...(draftConfig.model ? { model: draftConfig.model } : {}), @@ -3527,41 +3511,16 @@ export class Session { : {}), }; - const ephemeralSession = await client.createSession(sessionConfig); - try { - if (!ephemeralSession.listCommands) { - this.emit({ - type: "list_commands_response", - payload: { - agentId, - commands: [], - error: `Provider '${provider}' does not support listing commands`, - requestId, - }, - }); - return; - } - - const commands = await ephemeralSession.listCommands(); - this.emit({ - type: "list_commands_response", - payload: { - agentId, - commands, - error: null, - requestId, - }, - }); - } finally { - try { - await ephemeralSession.close(); - } catch (closeError) { - this.sessionLogger.warn( - { err: closeError, provider, agentId }, - "Failed to close ephemeral command listing session" - ); - } - } + const commands = await this.agentManager.listDraftCommands(sessionConfig); + this.emit({ + type: "list_commands_response", + payload: { + agentId, + commands, + error: null, + requestId, + }, + }); return; }