Merge pull request #48 from getpaseo/feature/commands-in-draft-screen-clean

Make slash commands available in the new-agent draft screen
This commit is contained in:
Mohamed Boudra
2026-02-18 09:58:56 +07:00
committed by GitHub
10 changed files with 374 additions and 25 deletions
@@ -26,7 +26,10 @@ import {
} from "./message-input"; } from "./message-input";
import { Theme } from "@/styles/theme"; import { Theme } from "@/styles/theme";
import { CommandAutocomplete } from "./command-autocomplete"; 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 { encodeImages } from "@/utils/encode-images";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { focusWithRetries } from "@/utils/web-focus"; import { focusWithRetries } from "@/utils/web-focus";
@@ -55,6 +58,8 @@ interface AgentInputAreaProps {
autoFocus?: boolean; autoFocus?: boolean;
/** Callback to expose the addImages function to parent components */ /** Callback to expose the addImages function to parent components */
onAddImages?: (addImages: (images: ImageAttachment[]) => void) => void; onAddImages?: (addImages: (images: ImageAttachment[]) => void) => void;
/** Optional draft context for listing commands before an agent exists. */
commandDraftConfig?: DraftCommandConfig;
} }
const EMPTY_ARRAY: readonly QueuedMessage[] = []; const EMPTY_ARRAY: readonly QueuedMessage[] = [];
@@ -69,6 +74,7 @@ export function AgentInputArea({
onChangeText, onChangeText,
autoFocus = false, autoFocus = false,
onAddImages, onAddImages,
commandDraftConfig,
}: AgentInputAreaProps) { }: AgentInputAreaProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
@@ -117,13 +123,39 @@ export function AgentInputArea({
// Command autocomplete logic // Command autocomplete logic
const showCommandAutocomplete = userInput.startsWith("/") && !userInput.includes(" "); const showCommandAutocomplete = userInput.startsWith("/") && !userInput.includes(" ");
const commandFilter = showCommandAutocomplete ? userInput.slice(1) : ""; 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 isRealAgent = agentId && !agentId.startsWith("__");
const commandQueryDraftConfig = isRealAgent
? undefined
: normalizedCommandDraftConfig;
const canLoadCommands = Boolean(serverId) && (isRealAgent || !!commandQueryDraftConfig);
const { commands } = useAgentCommandsQuery({ const { commands } = useAgentCommandsQuery({
serverId, serverId,
agentId, agentId,
enabled: !!isRealAgent && !!serverId, enabled: canLoadCommands,
draftConfig: commandQueryDraftConfig,
}); });
// Filter commands for keyboard navigation // Filter commands for keyboard navigation
@@ -742,13 +774,14 @@ export function AgentInputArea({
)} )}
{/* Command autocomplete dropdown */} {/* Command autocomplete dropdown */}
{showCommandAutocomplete && isRealAgent && ( {showCommandAutocomplete && canLoadCommands && (
<CommandAutocomplete <CommandAutocomplete
serverId={serverId} serverId={serverId}
agentId={agentId} agentId={agentId}
filter={commandFilter} filter={commandFilter}
selectedIndex={commandSelectedIndex} selectedIndex={commandSelectedIndex}
onSelect={handleCommandSelect} onSelect={handleCommandSelect}
draftConfig={commandQueryDraftConfig}
/> />
)} )}
@@ -1,6 +1,9 @@
import { View, Text, Pressable, ScrollView } from "react-native"; import { View, Text, Pressable, ScrollView } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; 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 { Fonts } from "@/constants/theme";
import { Theme } from "@/styles/theme"; import { Theme } from "@/styles/theme";
@@ -16,6 +19,7 @@ interface CommandAutocompleteProps {
filter: string; filter: string;
selectedIndex: number; selectedIndex: number;
onSelect: (command: AgentSlashCommand) => void; onSelect: (command: AgentSlashCommand) => void;
draftConfig?: DraftCommandConfig;
} }
export function CommandAutocomplete({ export function CommandAutocomplete({
@@ -24,12 +28,14 @@ export function CommandAutocomplete({
filter, filter,
selectedIndex, selectedIndex,
onSelect, onSelect,
draftConfig,
}: CommandAutocompleteProps) { }: CommandAutocompleteProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const { commands, isLoading, isError, error } = useAgentCommandsQuery({ const { commands, isLoading, isError, error } = useAgentCommandsQuery({
serverId, serverId,
agentId, agentId,
enabled: true, enabled: true,
draftConfig,
}); });
// Filter commands based on input after / // Filter commands based on input after /
@@ -1,5 +1,6 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useSessionStore } from "@/stores/session-store"; 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 const COMMANDS_STALE_TIME = 60_000; // Commands rarely change, cache for 1 minute
@@ -9,20 +10,43 @@ interface AgentSlashCommand {
argumentHint: string; argumentHint: string;
} }
function commandsQueryKey(serverId: string, agentId: string) { export interface DraftCommandConfig {
return ["agentCommands", serverId, agentId] as const; 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 { interface UseAgentCommandsQueryOptions {
serverId: string; serverId: string;
agentId: string; agentId: string;
enabled?: boolean; enabled?: boolean;
draftConfig?: DraftCommandConfig;
} }
export function useAgentCommandsQuery({ export function useAgentCommandsQuery({
serverId, serverId,
agentId, agentId,
enabled = true, enabled = true,
draftConfig,
}: UseAgentCommandsQueryOptions) { }: UseAgentCommandsQueryOptions) {
const client = useSessionStore( const client = useSessionStore(
(state) => state.sessions[serverId]?.client ?? null (state) => state.sessions[serverId]?.client ?? null
@@ -32,12 +56,12 @@ export function useAgentCommandsQuery({
); );
const query = useQuery({ const query = useQuery({
queryKey: commandsQueryKey(serverId, agentId), queryKey: commandsQueryKey(serverId, agentId, draftConfig),
queryFn: async () => { queryFn: async () => {
if (!client) { if (!client) {
throw new Error("Daemon client not available"); 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[]; return response.commands as AgentSlashCommand[];
}, },
enabled: enabled && !!client && isConnected && !!agentId, enabled: enabled && !!client && isConnected && !!agentId,
@@ -23,6 +23,7 @@ import { Combobox } from "@/components/ui/combobox";
import { FileDropZone } from "@/components/file-drop-zone"; import { FileDropZone } from "@/components/file-drop-zone";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useAgentFormState, type CreateAgentInitialValues } from "@/hooks/use-agent-form-state"; import { useAgentFormState, type CreateAgentInitialValues } from "@/hooks/use-agent-form-state";
import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query";
import { import {
CHECKOUT_STATUS_STALE_TIME, CHECKOUT_STATUS_STALE_TIME,
checkoutStatusQueryKey, checkoutStatusQueryKey,
@@ -788,6 +789,33 @@ export function DraftAgentScreen({
const createAgentClient = useSessionStore((state) => const createAgentClient = useSessionStore((state) =>
selectedServerId ? state.sessions[selectedServerId]?.client ?? null : null selectedServerId ? state.sessions[selectedServerId]?.client ?? null : null
); );
const draftCommandConfig = useMemo<DraftCommandConfig | undefined>(() => {
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 promptValue = machine.tag === "draft" ? machine.promptText : "";
const formErrorMessage = machine.tag === "draft" ? machine.errorMessage : ""; const formErrorMessage = machine.tag === "draft" ? machine.errorMessage : "";
@@ -1240,6 +1268,7 @@ export function DraftAgentScreen({
onChangeText={(next) => dispatch({ type: "DRAFT_SET_PROMPT", text: next })} onChangeText={(next) => dispatch({ type: "DRAFT_SET_PROMPT", text: next })}
autoFocus={machine.tag === "draft"} autoFocus={machine.tag === "draft"}
onAddImages={handleAddImagesCallback} onAddImages={handleAddImagesCallback}
commandDraftConfig={draftCommandConfig}
/> />
</View> </View>
</View> </View>
@@ -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 () => { test("auto-acks terminal stream chunks after delivery", async () => {
const logger = createMockLogger(); const logger = createMockLogger();
const mock = createMockTransport(); const mock = createMockTransport();
+24 -1
View File
@@ -209,6 +209,14 @@ type SpeechModelsListPayload = SpeechModelsListResponse["payload"];
type SpeechModelsDownloadPayload = SpeechModelsDownloadResponse["payload"]; type SpeechModelsDownloadPayload = SpeechModelsDownloadResponse["payload"];
type ListCommandsPayload = ListCommandsResponse["payload"]; type ListCommandsPayload = ListCommandsResponse["payload"];
type ExecuteCommandPayload = ExecuteCommandResponse["payload"]; type ExecuteCommandPayload = ExecuteCommandResponse["payload"];
type ListCommandsDraftConfig = Pick<
AgentSessionConfig,
"provider" | "cwd" | "modeId" | "model" | "thinkingOptionId"
>;
type ListCommandsOptions = {
requestId?: string;
draftConfig?: ListCommandsDraftConfig;
};
type SetVoiceModePayload = Extract< type SetVoiceModePayload = Extract<
SessionOutboundMessage, SessionOutboundMessage,
{ type: "set_voice_mode_response" } { type: "set_voice_mode_response" }
@@ -2171,15 +2179,30 @@ export class DaemonClient {
}); });
} }
async listCommands(agentId: string, requestId?: string): Promise<ListCommandsPayload>;
async listCommands( async listCommands(
agentId: string, agentId: string,
requestId?: string options?: ListCommandsOptions
): Promise<ListCommandsPayload>;
async listCommands(
agentId: string,
requestIdOrOptions?: string | ListCommandsOptions
): Promise<ListCommandsPayload> { ): Promise<ListCommandsPayload> {
const requestId =
typeof requestIdOrOptions === "string"
? requestIdOrOptions
: requestIdOrOptions?.requestId;
const draftConfig =
typeof requestIdOrOptions === "string"
? undefined
: requestIdOrOptions?.draftConfig;
return this.sendCorrelatedSessionRequest({ return this.sendCorrelatedSessionRequest({
requestId, requestId,
message: { message: {
type: "list_commands_request", type: "list_commands_request",
agentId, agentId,
...(draftConfig ? { draftConfig } : {}),
}, },
responseType: "list_commands_response", responseType: "list_commands_response",
timeout: 30000, timeout: 30000,
@@ -11,6 +11,7 @@ import { z } from "zod";
import type { import type {
AgentCapabilityFlags, AgentCapabilityFlags,
AgentClient, AgentClient,
AgentSlashCommand,
AgentMode, AgentMode,
AgentPermissionRequest, AgentPermissionRequest,
AgentPermissionResponse, AgentPermissionResponse,
@@ -437,6 +438,36 @@ export class AgentManager {
return Promise.all(checks); return Promise.all(checks);
} }
async listDraftCommands(config: AgentSessionConfig): Promise<AgentSlashCommand[]> {
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 { getAgent(id: string): ManagedAgent | null {
const agent = this.agents.get(id); const agent = this.agents.get(id);
return agent ? { ...agent } : null; return agent ? { ...agent } : null;
+30 -15
View File
@@ -1524,7 +1524,7 @@ export class Session {
} }
case "list_commands_request": case "list_commands_request":
await this.handleListCommandsRequest(msg.agentId, msg.requestId); await this.handleListCommandsRequest(msg);
break; break;
case "execute_command_request": case "execute_command_request":
@@ -3473,9 +3473,12 @@ export class Session {
/** /**
* Handle list commands request for an agent * Handle list commands request for an agent
*/ */
private async handleListCommandsRequest(agentId: string, requestId: string): Promise<void> { private async handleListCommandsRequest(
msg: Extract<SessionInboundMessage, { type: "list_commands_request" }>
): Promise<void> {
const { agentId, requestId, draftConfig } = msg;
this.sessionLogger.debug( this.sessionLogger.debug(
{ agentId }, { agentId, draftConfig },
`Handling list commands request for agent ${agentId}` `Handling list commands request for agent ${agentId}`
); );
@@ -3483,47 +3486,59 @@ export class Session {
const agents = this.agentManager.listAgents(); const agents = this.agentManager.listAgents();
const agent = agents.find((a) => a.id === agentId); const agent = agents.find((a) => a.id === agentId);
if (!agent) { if (agent?.session?.listCommands) {
const commands = await agent.session.listCommands();
this.emit({ this.emit({
type: "list_commands_response", type: "list_commands_response",
payload: { payload: {
agentId, agentId,
commands: [], commands,
error: `Agent not found: ${agentId}`, error: null,
requestId, requestId,
}, },
}); });
return; return;
} }
const session = agent.session; if (!agent && draftConfig) {
if (!session || !session.listCommands) { const sessionConfig: AgentSessionConfig = {
provider: draftConfig.provider,
cwd: expandTilde(draftConfig.cwd),
...(draftConfig.modeId ? { modeId: draftConfig.modeId } : {}),
...(draftConfig.model ? { model: draftConfig.model } : {}),
...(draftConfig.thinkingOptionId
? { thinkingOptionId: draftConfig.thinkingOptionId }
: {}),
};
const commands = await this.agentManager.listDraftCommands(sessionConfig);
this.emit({ this.emit({
type: "list_commands_response", type: "list_commands_response",
payload: { payload: {
agentId, agentId,
commands: [], commands,
error: `Agent does not support listing commands`, error: null,
requestId, requestId,
}, },
}); });
return; return;
} }
const commands = await session.listCommands();
this.emit({ this.emit({
type: "list_commands_response", type: "list_commands_response",
payload: { payload: {
agentId, agentId,
commands, commands: [],
error: null, error: agent
? `Agent does not support listing commands`
: `Agent not found: ${agentId}`,
requestId, requestId,
}, },
}); });
} catch (error: any) { } catch (error: any) {
this.sessionLogger.error( this.sessionLogger.error(
{ err: error, agentId }, { err: error, agentId, draftConfig },
"Failed to list commands" "Failed to list commands"
); );
this.emit({ this.emit({
@@ -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",
});
});
});
+9
View File
@@ -926,9 +926,18 @@ export const PingMessageSchema = z.object({
clientSentAt: z.number().int().optional(), 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({ export const ListCommandsRequestSchema = z.object({
type: z.literal("list_commands_request"), type: z.literal("list_commands_request"),
agentId: z.string(), agentId: z.string(),
draftConfig: ListCommandsDraftConfigSchema.optional(),
requestId: z.string(), requestId: z.string(),
}); });