From 2f6b8cea01b4916aded2aa71b8c0731cb5b81d3c Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 23 Mar 2026 21:56:39 +0700 Subject: [PATCH 1/2] Add number key shortcuts for agent prompts --- packages/app/src/app/_layout.tsx | 34 ++ .../app/src/components/agent-stream-view.tsx | 122 +++- .../app/src/components/question-form-card.tsx | 559 +++++++++--------- .../question-form-card.utils.test.ts | 127 ++++ .../components/question-form-card.utils.ts | 266 +++++++++ .../app/src/hooks/use-keyboard-shortcuts.ts | 13 + packages/app/src/keyboard/actions.ts | 1 + .../keyboard/keyboard-action-dispatcher.ts | 2 + .../src/keyboard/keyboard-shortcuts.test.ts | 39 +- .../app/src/keyboard/keyboard-shortcuts.ts | 46 ++ packages/app/src/panels/agent-panel.tsx | 22 + 11 files changed, 929 insertions(+), 302 deletions(-) create mode 100644 packages/app/src/components/question-form-card.utils.test.ts create mode 100644 packages/app/src/components/question-form-card.utils.ts diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index 164b9de5e..e7e988f3a 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -44,6 +44,7 @@ import { DownloadToast } from "@/components/download-toast"; import { UpdateBanner } from "@/desktop/updates/update-banner"; import { ToastProvider } from "@/contexts/toast-context"; import { usePanelStore } from "@/stores/panel-store"; +import { useSessionStore } from "@/stores/session-store"; import { runOnJS, interpolate, Extrapolation, useSharedValue } from "react-native-reanimated"; import { SidebarAnimationProvider, @@ -255,6 +256,22 @@ interface AppContainerProps { chromeEnabled?: boolean; } +function parseSelectedAgentKey( + selectedAgentKey: string | undefined, +): { serverId: string; agentId: string } | null { + if (!selectedAgentKey) { + return null; + } + const separatorIndex = selectedAgentKey.indexOf(":"); + if (separatorIndex <= 0 || separatorIndex >= selectedAgentKey.length - 1) { + return null; + } + return { + serverId: selectedAgentKey.slice(0, separatorIndex), + agentId: selectedAgentKey.slice(separatorIndex + 1), + }; +} + function AppContainer({ children, selectedAgentId, @@ -267,6 +284,22 @@ function AppContainer({ const toggleBothSidebars = usePanelStore((state) => state.toggleBothSidebars); const toggleFocusMode = usePanelStore((state) => state.toggleFocusMode); const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled); + const selectedAgent = useMemo(() => parseSelectedAgentKey(selectedAgentId), [selectedAgentId]); + const agentAwaitingInput = useSessionStore((state) => { + if (!selectedAgent) { + return false; + } + const pendingPermissions = state.sessions[selectedAgent.serverId]?.pendingPermissions; + if (!pendingPermissions) { + return false; + } + for (const permission of pendingPermissions.values()) { + if (permission.agentId === selectedAgent.agentId) { + return true; + } + } + return false; + }); const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; const chromeEnabled = chromeEnabledOverride ?? daemons.length > 0; @@ -276,6 +309,7 @@ function AppContainer({ isMobile, toggleAgentList, selectedAgentId, + agentAwaitingInput, toggleFileExplorer, toggleBothSidebars, toggleFocusMode, diff --git a/packages/app/src/components/agent-stream-view.tsx b/packages/app/src/components/agent-stream-view.tsx index b7ec45512..ae9ddc1b9 100644 --- a/packages/app/src/components/agent-stream-view.tsx +++ b/packages/app/src/components/agent-stream-view.tsx @@ -36,15 +36,17 @@ import { MessageOuterSpacingProvider, type InlinePathTarget, } from "./message"; +import { Shortcut } from "./ui/shortcut"; import type { StreamItem } from "@/types/stream"; import type { PendingPermission } from "@/types/shared"; import type { AgentPermissionResponse } from "@server/server/agent/agent-sdk-types"; import type { Agent } from "@/contexts/session-context"; import { useSessionStore } from "@/stores/session-store"; import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions"; +import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; import type { DaemonClient } from "@server/client/daemon-client"; import { ToolCallDetailsContent } from "./tool-call-details"; -import { QuestionFormCard } from "./question-form-card"; +import { QuestionFormCard, type QuestionFormCardHandle } from "./question-form-card"; import { ToolCallSheetProvider } from "./tool-call-sheet"; import { buildAgentStreamRenderModel, @@ -77,6 +79,11 @@ const isToolSequenceItem = (item?: StreamItem) => export interface AgentStreamViewHandle { scrollToBottom(reason?: BottomAnchorLocalRequest["reason"]): void; prepareForViewportChange(): void; + selectPendingPermissionOption(index: number): boolean; +} + +interface PermissionRequestCardHandle { + selectOption(index: number): boolean; } export interface AgentStreamViewProps { @@ -105,6 +112,7 @@ const AgentStreamViewComponent = forwardRef(null); + const permissionCardRefs = useRef(new Map()); const { theme } = useUnistyles(); const router = useRouter(); const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; @@ -208,6 +216,10 @@ const AgentStreamViewComponent = forwardRef Array.from(pendingPermissions.values()).filter((perm) => perm.agentId === agentId), + [pendingPermissions, agentId], + ); const baseRenderModel = useMemo(() => { return buildAgentStreamRenderModel({ @@ -226,8 +238,15 @@ const AgentStreamViewComponent = forwardRef Array.from(pendingPermissions.values()).filter((perm) => perm.agentId === agentId), - [pendingPermissions, agentId], - ); - const showWorkingIndicator = agent.status === "running"; const renderModel = useMemo(() => { const pendingPermissionsNode = pendingPermissionItems.length > 0 ? ( {pendingPermissionItems.map((permission) => ( - + { + if (handle) { + permissionCardRefs.current.set(permission.key, handle); + return; + } + permissionCardRefs.current.delete(permission.key); + }} + permission={permission} + client={client} + /> ))} ) : null; @@ -724,15 +749,18 @@ function WorkingIndicator() { } // Permission Request Card Component -function PermissionRequestCard({ - permission, - client, -}: { - permission: PendingPermission; - client: DaemonClient | null; -}) { +const PermissionRequestCard = forwardRef< + PermissionRequestCardHandle, + { + permission: PendingPermission; + client: DaemonClient | null; + } +>(function PermissionRequestCard({ permission, client }, ref) { const { theme } = useUnistyles(); const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; + const questionFormRef = useRef(null); + const firstOptionShortcut = useShortcutKeys("agent-prompt-select-1"); + const secondOptionShortcut = useShortcutKeys("agent-prompt-select-2"); const { request } = permission; const isPlanRequest = request.kind === "plan"; @@ -740,9 +768,6 @@ function PermissionRequestCard({ const description = request.description ?? ""; const planMarkdown = useMemo(() => { - if (!request) { - return undefined; - } const planFromMetadata = typeof request.metadata?.planText === "string" ? request.metadata.planText : undefined; if (planFromMetadata) { @@ -868,6 +893,7 @@ function PermissionRequestCard({ resetPermissionMutation(); setRespondingAction(null); }, [permission.request.id, resetPermissionMutation]); + const handleResponse = useCallback( (response: AgentPermissionResponse) => { respondToPermission({ @@ -881,9 +907,47 @@ function PermissionRequestCard({ [permission.agentId, permission.request.id, respondToPermission], ); + const handleDeny = useCallback(() => { + setRespondingAction("deny"); + handleResponse({ + behavior: "deny", + message: "Denied by user", + }); + }, [handleResponse]); + + const handleAccept = useCallback(() => { + setRespondingAction("accept"); + handleResponse({ behavior: "allow" }); + }, [handleResponse]); + + useImperativeHandle( + ref, + () => ({ + selectOption(index: number) { + if (isResponding) { + return false; + } + if (request.kind === "question") { + return questionFormRef.current?.selectOption(index) ?? false; + } + if (index === 1) { + handleDeny(); + return true; + } + if (index === 2) { + handleAccept(); + return true; + } + return false; + }, + }), + [handleAccept, handleDeny, isResponding, request.kind], + ); + if (request.kind === "question") { return ( { - setRespondingAction("deny"); - handleResponse({ - behavior: "deny", - message: "Denied by user", - }); - }} + onPress={handleDeny} disabled={isResponding} > {respondingAction === "deny" ? ( @@ -975,6 +1033,9 @@ function PermissionRequestCard({ Deny + {firstOptionShortcut ? ( + + ) : null} )} @@ -989,10 +1050,7 @@ function PermissionRequestCard({ }, pressed ? permissionStyles.optionButtonPressed : null, ]} - onPress={() => { - setRespondingAction("accept"); - handleResponse({ behavior: "allow" }); - }} + onPress={handleAccept} disabled={isResponding} > {respondingAction === "accept" ? ( @@ -1003,13 +1061,16 @@ function PermissionRequestCard({ Accept + {secondOptionShortcut ? ( + + ) : null} )} ); -} +}); const stylesheet = StyleSheet.create((theme) => ({ container: { @@ -1192,6 +1253,7 @@ const permissionStyles = StyleSheet.create((theme) => ({ alignItems: "center", gap: theme.spacing[2], }, + optionShortcut: {}, optionText: { fontSize: theme.fontSize.sm, fontWeight: theme.fontWeight.normal, diff --git a/packages/app/src/components/question-form-card.tsx b/packages/app/src/components/question-form-card.tsx index f5d711fdc..3dec29669 100644 --- a/packages/app/src/components/question-form-card.tsx +++ b/packages/app/src/components/question-form-card.tsx @@ -1,57 +1,22 @@ -import { useState, useCallback } from "react"; +import { forwardRef, useCallback, useImperativeHandle, useState } from "react"; import { View, Text, TextInput, Pressable, ActivityIndicator, Platform } from "react-native"; import { StyleSheet, useUnistyles, UnistylesRuntime } from "react-native-unistyles"; import { Check, CircleHelp, X } from "lucide-react-native"; import type { PendingPermission } from "@/types/shared"; +import { Shortcut } from "@/components/ui/shortcut"; +import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; import type { AgentPermissionResponse } from "@server/server/agent/agent-sdk-types"; - -interface QuestionOption { - label: string; - description?: string; -} - -interface Question { - question: string; - header: string; - options: QuestionOption[]; - multiSelect: boolean; -} - -function parseQuestions(input: unknown): Question[] | null { - if ( - typeof input !== "object" || - input === null || - !("questions" in input) || - !Array.isArray((input as Record).questions) - ) { - return null; - } - const raw = (input as Record).questions as unknown[]; - const questions: Question[] = []; - for (const item of raw) { - if (typeof item !== "object" || item === null) return null; - const q = item as Record; - if (typeof q.question !== "string" || typeof q.header !== "string") return null; - if (!Array.isArray(q.options)) return null; - const options: QuestionOption[] = []; - for (const opt of q.options as unknown[]) { - if (typeof opt !== "object" || opt === null) return null; - const o = opt as Record; - if (typeof o.label !== "string") return null; - options.push({ - label: o.label, - description: typeof o.description === "string" ? o.description : undefined, - }); - } - questions.push({ - question: q.question, - header: q.header, - options, - multiSelect: q.multiSelect === true, - }); - } - return questions.length > 0 ? questions : null; -} +import { + applyQuestionShortcutSelection, + areAllQuestionsAnswered, + buildQuestionAnswers, + findFirstUnansweredQuestionIndex, + parseQuestions, + setQuestionOtherText, + toggleQuestionOption, + type QuestionOtherTexts, + type QuestionSelections, +} from "./question-form-card.utils"; interface QuestionFormCardProps { permission: PendingPermission; @@ -59,248 +24,313 @@ interface QuestionFormCardProps { isResponding: boolean; } +export interface QuestionFormCardHandle { + selectOption(index: number): boolean; +} + const IS_WEB = Platform.OS === "web"; -export function QuestionFormCard({ permission, onRespond, isResponding }: QuestionFormCardProps) { - const { theme } = useUnistyles(); - const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; - const questions = parseQuestions(permission.request.input); +export const QuestionFormCard = forwardRef( + function QuestionFormCard({ permission, onRespond, isResponding }, ref) { + const { theme } = useUnistyles(); + const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; + const questions = parseQuestions(permission.request.input); + const firstOptionShortcut = useShortcutKeys("agent-prompt-select-1"); + const secondOptionShortcut = useShortcutKeys("agent-prompt-select-2"); + const thirdOptionShortcut = useShortcutKeys("agent-prompt-select-3"); + const optionShortcuts = [ + firstOptionShortcut, + secondOptionShortcut, + thirdOptionShortcut, + ] as const; + + const [selections, setSelections] = useState({}); + const [otherTexts, setOtherTexts] = useState({}); + const [respondingAction, setRespondingAction] = useState<"submit" | "dismiss" | null>(null); - const [selections, setSelections] = useState>>({}); - const [otherTexts, setOtherTexts] = useState>({}); - const [respondingAction, setRespondingAction] = useState<"submit" | "dismiss" | null>(null); + const applyAnswerState = useCallback( + (next: { selections: QuestionSelections; otherTexts: QuestionOtherTexts }) => { + setSelections(next.selections); + setOtherTexts(next.otherTexts); + }, + [], + ); - const toggleOption = useCallback((qIndex: number, optIndex: number, multiSelect: boolean) => { - setSelections((prev) => { - const current = prev[qIndex] ?? new Set(); - const next = new Set(current); - if (multiSelect) { - if (next.has(optIndex)) { - next.delete(optIndex); - } else { - next.add(optIndex); + const submitResponses = useCallback( + (nextSelections: QuestionSelections, nextOtherTexts: QuestionOtherTexts) => { + if (!questions) { + return; } - } else { - if (next.has(optIndex)) { - next.clear(); - } else { - next.clear(); - next.add(optIndex); + setRespondingAction("submit"); + const answers = buildQuestionAnswers({ + questions, + selections: nextSelections, + otherTexts: nextOtherTexts, + }); + + onRespond({ + behavior: "allow", + updatedInput: { ...permission.request.input, answers }, + }); + }, + [onRespond, permission.request.input, questions], + ); + + const toggleOption = useCallback( + (questionIndex: number, optionIndex: number) => { + if (!questions) { + return; } - } - return { ...prev, [qIndex]: next }; - }); - setOtherTexts((prev) => { - if (!prev[qIndex]) return prev; - const next = { ...prev }; - delete next[qIndex]; - return next; - }); - }, []); + applyAnswerState( + toggleQuestionOption({ + questions, + selections, + otherTexts, + questionIndex, + optionIndex, + }), + ); + }, + [applyAnswerState, otherTexts, questions, selections], + ); + + const setOtherText = useCallback( + (questionIndex: number, text: string) => { + applyAnswerState( + setQuestionOtherText({ + selections, + otherTexts, + questionIndex, + text, + }), + ); + }, + [applyAnswerState, otherTexts, selections], + ); - const setOtherText = useCallback((qIndex: number, text: string) => { - setOtherTexts((prev) => ({ ...prev, [qIndex]: text })); - if (text.length > 0) { - setSelections((prev) => { - if (!prev[qIndex] || prev[qIndex].size === 0) return prev; - return { ...prev, [qIndex]: new Set() }; + const handleSubmit = useCallback(() => { + submitResponses(selections, otherTexts); + }, [otherTexts, selections, submitResponses]); + + const handleDeny = useCallback(() => { + setRespondingAction("dismiss"); + onRespond({ + behavior: "deny", + message: "Dismissed by user", }); - } - }, []); + }, [onRespond]); - if (!questions) { - return null; - } + useImperativeHandle( + ref, + () => ({ + selectOption(index: number) { + if (isResponding || !questions) { + return false; + } - const allAnswered = questions.every((_, qIndex) => { - const selected = selections[qIndex]; - const otherText = otherTexts[qIndex]?.trim(); - return (selected && selected.size > 0) || (otherText && otherText.length > 0); - }); + const result = applyQuestionShortcutSelection({ + questions, + selections, + otherTexts, + optionIndex: index - 1, + }); + if (!result.applied) { + return false; + } - function handleSubmit() { - setRespondingAction("submit"); - const answers: Record = {}; - for (let i = 0; i < questions!.length; i++) { - const q = questions![i]; - const selected = selections[i]; - const otherText = otherTexts[i]?.trim(); + applyAnswerState({ + selections: result.selections, + otherTexts: result.otherTexts, + }); + if (result.shouldAutoSubmit) { + submitResponses(result.selections, result.otherTexts); + } + return true; + }, + }), + [applyAnswerState, isResponding, otherTexts, questions, selections, submitResponses], + ); - if (otherText && otherText.length > 0) { - answers[q.header] = otherText; - } else if (selected && selected.size > 0) { - const labels = Array.from(selected).map((idx) => q.options[idx].label); - answers[q.header] = labels.join(", "); - } + if (!questions) { + return null; } - onRespond({ - behavior: "allow", - updatedInput: { ...permission.request.input, answers }, + const activeShortcutQuestionIndex = findFirstUnansweredQuestionIndex({ + questions, + selections, + otherTexts, }); - } - - function handleDeny() { - setRespondingAction("dismiss"); - onRespond({ - behavior: "deny", - message: "Dismissed by user", + const allAnswered = areAllQuestionsAnswered({ + questions, + selections, + otherTexts, }); - } - return ( - - {questions.map((q, qIndex) => { - const selected = selections[qIndex] ?? new Set(); - const otherText = otherTexts[qIndex] ?? ""; + return ( + + {questions.map((q, qIndex) => { + const selected = selections[qIndex] ?? new Set(); + const otherText = otherTexts[qIndex] ?? ""; - return ( - - - - {q.question} - - - - - {q.options.map((opt, optIndex) => { - const isSelected = selected.has(optIndex); - return ( - [ - styles.optionItem, - (hovered || isSelected) && { - backgroundColor: theme.colors.surface2, - }, - pressed && styles.optionItemPressed, - ]} - onPress={() => toggleOption(qIndex, optIndex, q.multiSelect)} - disabled={isResponding} - > - - - - {opt.label} - - {opt.description ? ( - - {opt.description} + return ( + + + + {q.question} + + + + + {q.options.map((opt, optIndex) => { + const isSelected = selected.has(optIndex); + const shortcutKeys = + activeShortcutQuestionIndex === qIndex + ? optionShortcuts[optIndex] ?? null + : null; + return ( + [ + styles.optionItem, + (hovered || isSelected) && { + backgroundColor: theme.colors.surface2, + }, + pressed && styles.optionItemPressed, + ]} + onPress={() => toggleOption(qIndex, optIndex)} + disabled={isResponding} + > + + + + {opt.label} + {opt.description ? ( + + {opt.description} + + ) : null} + + {shortcutKeys ? ( + + ) : null} + {isSelected ? ( + + + ) : null} - {isSelected ? ( - - - - ) : null} - - - ); - })} - - 0 ? theme.colors.borderAccent : theme.colors.border, - color: theme.colors.foreground, - backgroundColor: theme.colors.surface2, - }, - // @ts-expect-error - outlineStyle is web-only - IS_WEB && { outlineStyle: "none", outlineWidth: 0, outlineColor: "transparent" }, - ]} - placeholder="Other..." - placeholderTextColor={theme.colors.foregroundMuted} - value={otherText} - onChangeText={(text) => setOtherText(qIndex, text)} - editable={!isResponding} - /> - - ); - })} - - - [ - styles.actionButton, - { - backgroundColor: hovered ? theme.colors.surface2 : theme.colors.surface1, - borderColor: theme.colors.borderAccent, - }, - pressed && styles.optionItemPressed, - ]} - onPress={handleDeny} - disabled={isResponding} - > - {respondingAction === "dismiss" ? ( - - ) : ( - - - - Dismiss - - - )} - - - { - const disabled = !allAnswered || isResponding; - return [ - styles.actionButton, - { - backgroundColor: - hovered && !disabled ? theme.colors.surface2 : theme.colors.surface1, - borderColor: disabled ? theme.colors.border : theme.colors.borderAccent, - opacity: disabled ? 0.5 : 1, - }, - pressed && !disabled ? styles.optionItemPressed : null, - ]; - }} - onPress={handleSubmit} - disabled={!allAnswered || isResponding} - > - {respondingAction === "submit" ? ( - - ) : ( - - - + ); + })} + + 0 ? theme.colors.borderAccent : theme.colors.border, + color: theme.colors.foreground, + backgroundColor: theme.colors.surface2, + }, + // @ts-expect-error - outlineStyle is web-only + IS_WEB && { + outlineStyle: "none", + outlineWidth: 0, + outlineColor: "transparent", }, ]} - > - Submit - + placeholder="Other..." + placeholderTextColor={theme.colors.foregroundMuted} + value={otherText} + onChangeText={(text) => setOtherText(qIndex, text)} + editable={!isResponding} + /> - )} - + ); + })} + + + [ + styles.actionButton, + { + backgroundColor: hovered ? theme.colors.surface2 : theme.colors.surface1, + borderColor: theme.colors.borderAccent, + }, + pressed && styles.optionItemPressed, + ]} + onPress={handleDeny} + disabled={isResponding} + > + {respondingAction === "dismiss" ? ( + + ) : ( + + + + Dismiss + + + )} + + + { + const disabled = !allAnswered || isResponding; + return [ + styles.actionButton, + { + backgroundColor: + hovered && !disabled ? theme.colors.surface2 : theme.colors.surface1, + borderColor: disabled ? theme.colors.border : theme.colors.borderAccent, + opacity: disabled ? 0.5 : 1, + }, + pressed && !disabled ? styles.optionItemPressed : null, + ]; + }} + onPress={handleSubmit} + disabled={!allAnswered || isResponding} + > + {respondingAction === "submit" ? ( + + ) : ( + + + + Submit + + + )} + + - - ); -} + ); + }, +); const styles = StyleSheet.create((theme) => ({ container: { @@ -347,6 +377,7 @@ const styles = StyleSheet.create((theme) => ({ flex: 1, gap: 2, }, + optionShortcut: {}, optionLabel: { fontSize: theme.fontSize.sm, }, diff --git a/packages/app/src/components/question-form-card.utils.test.ts b/packages/app/src/components/question-form-card.utils.test.ts new file mode 100644 index 000000000..1e05fc200 --- /dev/null +++ b/packages/app/src/components/question-form-card.utils.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it } from "vitest"; + +import { + applyQuestionShortcutSelection, + areAllQuestionsAnswered, + buildQuestionAnswers, + findFirstUnansweredQuestionIndex, + parseQuestions, + type Question, + type QuestionOtherTexts, + type QuestionSelections, +} from "./question-form-card.utils"; + +const singleChoiceQuestions: Question[] = [ + { + header: "deploy_target", + question: "Where should this go?", + multiSelect: false, + options: [{ label: "Production" }, { label: "Staging" }, { label: "Preview" }], + }, + { + header: "rollout", + question: "How should I roll this out?", + multiSelect: false, + options: [{ label: "Gradual" }, { label: "Immediate" }], + }, +]; + +function selections( + input: Array<[number, number[]]> = [], +): QuestionSelections { + return Object.fromEntries( + input.map(([questionIndex, optionIndices]) => [questionIndex, new Set(optionIndices)]), + ) as QuestionSelections; +} + +function otherTexts(input: Array<[number, string]> = []): QuestionOtherTexts { + return Object.fromEntries(input) as QuestionOtherTexts; +} + +describe("question-form-card utils", () => { + it("parses structured question input", () => { + expect( + parseQuestions({ + questions: [ + { + header: "approval", + question: "Proceed?", + options: [{ label: "Yes", description: "Ship it" }, { label: "No" }], + }, + ], + }), + ).toEqual([ + { + header: "approval", + question: "Proceed?", + multiSelect: false, + options: [{ label: "Yes", description: "Ship it" }, { label: "No" }], + }, + ]); + }); + + it("selects the first unanswered question when using shortcut options", () => { + const result = applyQuestionShortcutSelection({ + questions: singleChoiceQuestions, + selections: selections([[0, [1]]]), + otherTexts: {}, + optionIndex: 0, + }); + + expect(result.applied).toBe(true); + expect(result.questionIndex).toBe(1); + expect(result.shouldAutoSubmit).toBe(true); + expect(result.selections[1]).toEqual(new Set([0])); + }); + + it("does not auto-submit multi-select questionnaires", () => { + const result = applyQuestionShortcutSelection({ + questions: [ + { + header: "files", + question: "Which files should I include?", + multiSelect: true, + options: [{ label: "src" }, { label: "docs" }], + }, + ], + selections: {}, + otherTexts: {}, + optionIndex: 1, + }); + + expect(result.applied).toBe(true); + expect(result.shouldAutoSubmit).toBe(false); + expect(result.selections[0]).toEqual(new Set([1])); + }); + + it("tracks unanswered questions from selections and freeform answers", () => { + expect( + findFirstUnansweredQuestionIndex({ + questions: singleChoiceQuestions, + selections: selections([[0, [2]]]), + otherTexts: otherTexts([[1, "later"]]), + }), + ).toBeNull(); + + expect( + areAllQuestionsAnswered({ + questions: singleChoiceQuestions, + selections: selections([[0, [2]]]), + otherTexts: otherTexts([[1, "later"]]), + }), + ).toBe(true); + }); + + it("builds answers from selected options and trimmed other text", () => { + expect( + buildQuestionAnswers({ + questions: singleChoiceQuestions, + selections: selections([[0, [0]], [1, [1]]]), + otherTexts: otherTexts([[1, " manual rollout "]]), + }), + ).toEqual({ + deploy_target: "Production", + rollout: "manual rollout", + }); + }); +}); diff --git a/packages/app/src/components/question-form-card.utils.ts b/packages/app/src/components/question-form-card.utils.ts new file mode 100644 index 000000000..21599dc81 --- /dev/null +++ b/packages/app/src/components/question-form-card.utils.ts @@ -0,0 +1,266 @@ +export interface QuestionOption { + label: string; + description?: string; +} + +export interface Question { + question: string; + header: string; + options: QuestionOption[]; + multiSelect: boolean; +} + +export type QuestionSelections = Record>; +export type QuestionOtherTexts = Record; + +type QuestionAnswerState = { + questions: readonly Question[]; + selections: QuestionSelections; + otherTexts: QuestionOtherTexts; +}; + +function cloneSelections(input: QuestionSelections): QuestionSelections { + const next: QuestionSelections = {}; + for (const [key, value] of Object.entries(input)) { + next[Number(key)] = new Set(value); + } + return next; +} + +function cloneOtherTexts(input: QuestionOtherTexts): QuestionOtherTexts { + return { ...input }; +} + +function isQuestionAnswered(input: { + selections: QuestionSelections; + otherTexts: QuestionOtherTexts; + questionIndex: number; +}): boolean { + const selected = input.selections[input.questionIndex]; + const otherText = input.otherTexts[input.questionIndex]?.trim(); + return (selected && selected.size > 0) || Boolean(otherText && otherText.length > 0); +} + +export function parseQuestions(input: unknown): Question[] | null { + if ( + typeof input !== "object" || + input === null || + !("questions" in input) || + !Array.isArray((input as Record).questions) + ) { + return null; + } + + const raw = (input as Record).questions as unknown[]; + const questions: Question[] = []; + + for (const item of raw) { + if (typeof item !== "object" || item === null) return null; + const question = item as Record; + if (typeof question.question !== "string" || typeof question.header !== "string") { + return null; + } + if (!Array.isArray(question.options)) { + return null; + } + + const options: QuestionOption[] = []; + for (const option of question.options as unknown[]) { + if (typeof option !== "object" || option === null) return null; + const candidate = option as Record; + if (typeof candidate.label !== "string") return null; + options.push({ + label: candidate.label, + description: typeof candidate.description === "string" ? candidate.description : undefined, + }); + } + + questions.push({ + question: question.question, + header: question.header, + options, + multiSelect: question.multiSelect === true, + }); + } + + return questions.length > 0 ? questions : null; +} + +export function areAllQuestionsAnswered(input: QuestionAnswerState): boolean { + return input.questions.every((_, questionIndex) => + isQuestionAnswered({ + selections: input.selections, + otherTexts: input.otherTexts, + questionIndex, + }), + ); +} + +export function findFirstUnansweredQuestionIndex(input: QuestionAnswerState): number | null { + for (let questionIndex = 0; questionIndex < input.questions.length; questionIndex += 1) { + if ( + !isQuestionAnswered({ + selections: input.selections, + otherTexts: input.otherTexts, + questionIndex, + }) + ) { + return questionIndex; + } + } + return null; +} + +export function toggleQuestionOption(input: { + questions: readonly Question[]; + selections: QuestionSelections; + otherTexts: QuestionOtherTexts; + questionIndex: number; + optionIndex: number; +}): { + selections: QuestionSelections; + otherTexts: QuestionOtherTexts; +} { + const question = input.questions[input.questionIndex]; + if (!question || !question.options[input.optionIndex]) { + return { + selections: input.selections, + otherTexts: input.otherTexts, + }; + } + + const selections = cloneSelections(input.selections); + const otherTexts = cloneOtherTexts(input.otherTexts); + const current = selections[input.questionIndex] ?? new Set(); + const next = new Set(current); + + if (question.multiSelect) { + if (next.has(input.optionIndex)) { + next.delete(input.optionIndex); + } else { + next.add(input.optionIndex); + } + } else if (next.has(input.optionIndex)) { + next.clear(); + } else { + next.clear(); + next.add(input.optionIndex); + } + + selections[input.questionIndex] = next; + delete otherTexts[input.questionIndex]; + + return { selections, otherTexts }; +} + +export function setQuestionOtherText(input: { + selections: QuestionSelections; + otherTexts: QuestionOtherTexts; + questionIndex: number; + text: string; +}): { + selections: QuestionSelections; + otherTexts: QuestionOtherTexts; +} { + const selections = cloneSelections(input.selections); + const otherTexts = cloneOtherTexts(input.otherTexts); + + otherTexts[input.questionIndex] = input.text; + if (input.text.length > 0) { + selections[input.questionIndex] = new Set(); + } + + return { selections, otherTexts }; +} + +export function buildQuestionAnswers(input: QuestionAnswerState): Record { + const answers: Record = {}; + + for (let questionIndex = 0; questionIndex < input.questions.length; questionIndex += 1) { + const question = input.questions[questionIndex]; + if (!question) { + continue; + } + + const selected = input.selections[questionIndex]; + const otherText = input.otherTexts[questionIndex]?.trim(); + + if (otherText && otherText.length > 0) { + answers[question.header] = otherText; + continue; + } + + if (!selected || selected.size === 0) { + continue; + } + + const labels = Array.from(selected) + .map((optionIndex) => question.options[optionIndex]?.label) + .filter((label): label is string => typeof label === "string"); + if (labels.length > 0) { + answers[question.header] = labels.join(", "); + } + } + + return answers; +} + +export function applyQuestionShortcutSelection(input: { + questions: readonly Question[]; + selections: QuestionSelections; + otherTexts: QuestionOtherTexts; + optionIndex: number; +}): { + applied: boolean; + selections: QuestionSelections; + otherTexts: QuestionOtherTexts; + shouldAutoSubmit: boolean; + questionIndex: number | null; +} { + const questionIndex = findFirstUnansweredQuestionIndex({ + questions: input.questions, + selections: input.selections, + otherTexts: input.otherTexts, + }); + if (questionIndex === null) { + return { + applied: false, + selections: input.selections, + otherTexts: input.otherTexts, + shouldAutoSubmit: false, + questionIndex: null, + }; + } + + const question = input.questions[questionIndex]; + if (!question || !question.options[input.optionIndex]) { + return { + applied: false, + selections: input.selections, + otherTexts: input.otherTexts, + shouldAutoSubmit: false, + questionIndex, + }; + } + + const next = toggleQuestionOption({ + questions: input.questions, + selections: input.selections, + otherTexts: input.otherTexts, + questionIndex, + optionIndex: input.optionIndex, + }); + + return { + applied: true, + selections: next.selections, + otherTexts: next.otherTexts, + shouldAutoSubmit: + areAllQuestionsAnswered({ + questions: input.questions, + selections: next.selections, + otherTexts: next.otherTexts, + }) && input.questions.every((candidate) => candidate.multiSelect !== true), + questionIndex, + }; +} diff --git a/packages/app/src/hooks/use-keyboard-shortcuts.ts b/packages/app/src/hooks/use-keyboard-shortcuts.ts index 2ec127b7a..d79d95420 100644 --- a/packages/app/src/hooks/use-keyboard-shortcuts.ts +++ b/packages/app/src/hooks/use-keyboard-shortcuts.ts @@ -32,6 +32,7 @@ export function useKeyboardShortcuts({ isMobile, toggleAgentList, selectedAgentId, + agentAwaitingInput, toggleFileExplorer, toggleBothSidebars, toggleFocusMode, @@ -40,6 +41,7 @@ export function useKeyboardShortcuts({ isMobile: boolean; toggleAgentList: () => void; selectedAgentId?: string; + agentAwaitingInput: boolean; toggleFileExplorer?: () => void; toggleBothSidebars?: () => void; toggleFocusMode?: () => void; @@ -161,6 +163,15 @@ export function useKeyboardShortcuts({ switch (input.action) { case "agent.new": return openProjectPicker(); + case "agent.prompt.select": + if (!input.payload || typeof input.payload !== "object" || !("index" in input.payload)) { + return false; + } + return keyboardActionDispatcher.dispatch({ + id: "agent.prompt.select", + scope: "workspace", + index: input.payload.index, + }); case "workspace.tab.new": return keyboardActionDispatcher.dispatch({ id: "workspace.tab.new", @@ -329,6 +340,7 @@ export function useKeyboardShortcuts({ pathname, toggleFileExplorer, }), + agentAwaitingInput, }, chordState: chordStateRef.current, onChordReset: () => { @@ -408,6 +420,7 @@ export function useKeyboardShortcuts({ openProjectPickerAction, pathname, resetModifiers, + agentAwaitingInput, selectedAgentId, toggleAgentList, toggleFileExplorer, diff --git a/packages/app/src/keyboard/actions.ts b/packages/app/src/keyboard/actions.ts index 2653ac323..b29169795 100644 --- a/packages/app/src/keyboard/actions.ts +++ b/packages/app/src/keyboard/actions.ts @@ -16,6 +16,7 @@ export type MessageInputKeyboardActionKind = export type KeyboardActionId = | "agent.new" + | "agent.prompt.select" | "workspace.tab.new" | "workspace.tab.close.current" | "workspace.tab.navigate.index" diff --git a/packages/app/src/keyboard/keyboard-action-dispatcher.ts b/packages/app/src/keyboard/keyboard-action-dispatcher.ts index 817ac7101..b89f3d861 100644 --- a/packages/app/src/keyboard/keyboard-action-dispatcher.ts +++ b/packages/app/src/keyboard/keyboard-action-dispatcher.ts @@ -1,6 +1,7 @@ export type KeyboardActionScope = "global" | "message-input" | "sidebar" | "workspace"; export type KeyboardActionId = + | "agent.prompt.select" | "message-input.focus" | "message-input.dictation-toggle" | "message-input.dictation-cancel" @@ -26,6 +27,7 @@ export type KeyboardActionId = | "worktree.archive"; export type KeyboardActionDefinition = + | { id: "agent.prompt.select"; scope: KeyboardActionScope; index: number } | { id: "message-input.focus"; scope: KeyboardActionScope } | { id: "message-input.dictation-toggle"; scope: KeyboardActionScope } | { id: "message-input.dictation-cancel"; scope: KeyboardActionScope } diff --git a/packages/app/src/keyboard/keyboard-shortcuts.test.ts b/packages/app/src/keyboard/keyboard-shortcuts.test.ts index 21bc67c72..d8c534ee6 100644 --- a/packages/app/src/keyboard/keyboard-shortcuts.test.ts +++ b/packages/app/src/keyboard/keyboard-shortcuts.test.ts @@ -30,6 +30,7 @@ function shortcutContext( focusScope: "other", commandCenterOpen: false, hasSelectedAgent: true, + agentAwaitingInput: false, ...overrides, }; } @@ -261,9 +262,15 @@ describe("keyboard-shortcuts", () => { action: "sidebar.toggle.left", }, { - name: "keeps Mod+. as sidebar toggle fallback", + name: "matches Mod+. to toggle both sidebars on non-mac", event: { key: ".", code: "Period", ctrlKey: true }, context: { isMac: false }, + action: "sidebar.toggle.both", + }, + { + name: "matches Ctrl+B sidebar toggle on non-mac", + event: { key: "b", code: "KeyB", ctrlKey: true }, + context: { isMac: false }, action: "sidebar.toggle.left", }, { @@ -289,6 +296,13 @@ describe("keyboard-shortcuts", () => { preventDefault: false, stopPropagation: false, }, + { + name: "routes number keys to prompt selection when an agent is awaiting input", + event: { key: "2", code: "Digit2" }, + context: { agentAwaitingInput: true, focusScope: "other" }, + action: "agent.prompt.select", + payload: { index: 2 }, + }, ]; it.each(matchingCases)("$name", ({ @@ -344,11 +358,6 @@ describe("keyboard-shortcuts", () => { event: { key: "k", code: "KeyK", ctrlKey: true }, context: { isMac: false, focusScope: "terminal" }, }, - { - name: "does not bind Ctrl+B on non-mac", - event: { key: "b", code: "KeyB", ctrlKey: true }, - context: { isMac: false }, - }, { name: "does not route message-input actions when terminal is focused", event: { key: "d", code: "KeyD", metaKey: true }, @@ -364,6 +373,16 @@ describe("keyboard-shortcuts", () => { event: { key: " ", code: "Space" }, context: { focusScope: "message-input" }, }, + { + name: "does not route prompt selection when no agent is awaiting input", + event: { key: "1", code: "Digit1" }, + context: { focusScope: "other", agentAwaitingInput: false }, + }, + { + name: "does not route prompt selection while typing in the message input", + event: { key: "1", code: "Digit1" }, + context: { focusScope: "message-input", agentAwaitingInput: true }, + }, ]; it.each(nonMatchingCases)("$name", ({ event, context }) => { @@ -467,6 +486,9 @@ describe("keyboard-shortcut help sections", () => { "workspace-tab-close-current": ["meta", "W"], "workspace-pane-split-right": ["mod", "\\"], "workspace-pane-close": ["mod", "shift", "W"], + "agent-prompt-select-1": ["1"], + "agent-prompt-select-2": ["2"], + "agent-prompt-select-3": ["3"], }, }, { @@ -477,10 +499,11 @@ describe("keyboard-shortcut help sections", () => { }, }, { - name: "uses mod+period as non-mac left sidebar shortcut", + name: "uses mod+b and mod+period for non-mac sidebar shortcuts", context: { isMac: false, isDesktop: false }, expectedKeys: { - "toggle-left-sidebar": ["mod", "."], + "toggle-left-sidebar": ["mod", "B"], + "toggle-both-sidebars": ["mod", "."], }, }, ]; diff --git a/packages/app/src/keyboard/keyboard-shortcuts.ts b/packages/app/src/keyboard/keyboard-shortcuts.ts index 2540e8549..6ece0ce8b 100644 --- a/packages/app/src/keyboard/keyboard-shortcuts.ts +++ b/packages/app/src/keyboard/keyboard-shortcuts.ts @@ -17,6 +17,7 @@ export type KeyboardShortcutContext = { focusScope: KeyboardFocusScope; commandCenterOpen: boolean; hasSelectedAgent: boolean; + agentAwaitingInput: boolean; }; export type KeyboardShortcutMatch = { @@ -65,6 +66,8 @@ interface ShortcutWhen { commandCenter?: false; /** true = requires a selected agent */ hasSelectedAgent?: true; + /** true = requires a visible prompt awaiting agent input */ + agentAwaitingInput?: true; /** Exact focus scope match */ focusScope?: KeyboardFocusScope; } @@ -814,6 +817,48 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ keys: ["Esc"], }, }, + { + id: "agent-prompt-select-1", + action: "agent.prompt.select", + combo: "1", + repeat: false, + when: { agentAwaitingInput: true, focusScope: "other", commandCenter: false }, + payload: { type: "index" }, + help: { + id: "agent-prompt-select-1", + section: "agent-input", + label: "Select prompt option 1", + keys: ["1"], + }, + }, + { + id: "agent-prompt-select-2", + action: "agent.prompt.select", + combo: "2", + repeat: false, + when: { agentAwaitingInput: true, focusScope: "other", commandCenter: false }, + payload: { type: "index" }, + help: { + id: "agent-prompt-select-2", + section: "agent-input", + label: "Select prompt option 2", + keys: ["2"], + }, + }, + { + id: "agent-prompt-select-3", + action: "agent.prompt.select", + combo: "3", + repeat: false, + when: { agentAwaitingInput: true, focusScope: "other", commandCenter: false }, + payload: { type: "index" }, + help: { + id: "agent-prompt-select-3", + section: "agent-input", + label: "Select prompt option 3", + keys: ["3"], + }, + }, { id: "message-input-send-enter", action: "message-input.action", @@ -964,6 +1009,7 @@ function matchesWhen(when: ShortcutWhen | undefined, context: KeyboardShortcutCo if (when.terminal === false && context.focusScope === "terminal") return false; if (when.commandCenter === false && context.commandCenterOpen) return false; if (when.hasSelectedAgent === true && !context.hasSelectedAgent) return false; + if (when.agentAwaitingInput === true && !context.agentAwaitingInput) return false; if (when.focusScope !== undefined && context.focusScope !== when.focusScope) return false; return true; } diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index c32116953..4bec10027 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -22,9 +22,11 @@ import { useArchiveAgent } from "@/hooks/use-archive-agent"; import { useDelayedHistoryRefreshToast } from "@/hooks/use-delayed-history-refresh-toast"; import { useAgentInputDraft } from "@/hooks/use-agent-input-draft"; import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style"; +import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler"; import { useStableEvent } from "@/hooks/use-stable-event"; import { usePaneContext } from "@/panels/pane-context"; import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry"; +import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher"; import { useHostRuntimeClient, useHostRuntimeConnectionStatus, @@ -322,6 +324,26 @@ function AgentPanelBody({ attentionReason: agent?.attentionReason, isScreenFocused: isPaneFocused, }); + + const handlePromptShortcut = useCallback( + (action: KeyboardActionDefinition): boolean => { + if (!isPaneFocused || action.id !== "agent.prompt.select") { + return false; + } + return streamViewRef.current?.selectPendingPermissionOption(action.index) ?? false; + }, + [isPaneFocused], + ); + + useKeyboardActionHandler({ + handlerId: `agent-prompt-actions:${serverId}:${agentId ?? "__pending__"}`, + actions: ["agent.prompt.select"], + enabled: Boolean(agentId), + priority: 150, + isActive: () => isPaneFocused, + handle: handlePromptShortcut, + }); + useEffect(() => { clearOnAgentBlurRef.current = attentionController.clearOnAgentBlur; }, [attentionController.clearOnAgentBlur]); From c28d917a4bf4d5c03b1309c00c13cd8a54d23e73 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 24 Mar 2026 11:14:13 +0700 Subject: [PATCH 2/2] Simplify prompt shortcut handling --- packages/app/src/app/_layout.tsx | 34 - .../app/src/components/agent-stream-view.tsx | 115 ++- .../app/src/components/question-form-card.tsx | 793 +++++++++++------- .../question-form-card.utils.test.ts | 127 --- .../components/question-form-card.utils.ts | 266 ------ .../app/src/hooks/use-keyboard-shortcuts.ts | 4 - .../src/keyboard/keyboard-shortcuts.test.ts | 12 +- .../app/src/keyboard/keyboard-shortcuts.ts | 10 +- packages/app/src/panels/agent-panel.tsx | 22 +- 9 files changed, 549 insertions(+), 834 deletions(-) delete mode 100644 packages/app/src/components/question-form-card.utils.test.ts delete mode 100644 packages/app/src/components/question-form-card.utils.ts diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index e7e988f3a..164b9de5e 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -44,7 +44,6 @@ import { DownloadToast } from "@/components/download-toast"; import { UpdateBanner } from "@/desktop/updates/update-banner"; import { ToastProvider } from "@/contexts/toast-context"; import { usePanelStore } from "@/stores/panel-store"; -import { useSessionStore } from "@/stores/session-store"; import { runOnJS, interpolate, Extrapolation, useSharedValue } from "react-native-reanimated"; import { SidebarAnimationProvider, @@ -256,22 +255,6 @@ interface AppContainerProps { chromeEnabled?: boolean; } -function parseSelectedAgentKey( - selectedAgentKey: string | undefined, -): { serverId: string; agentId: string } | null { - if (!selectedAgentKey) { - return null; - } - const separatorIndex = selectedAgentKey.indexOf(":"); - if (separatorIndex <= 0 || separatorIndex >= selectedAgentKey.length - 1) { - return null; - } - return { - serverId: selectedAgentKey.slice(0, separatorIndex), - agentId: selectedAgentKey.slice(separatorIndex + 1), - }; -} - function AppContainer({ children, selectedAgentId, @@ -284,22 +267,6 @@ function AppContainer({ const toggleBothSidebars = usePanelStore((state) => state.toggleBothSidebars); const toggleFocusMode = usePanelStore((state) => state.toggleFocusMode); const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled); - const selectedAgent = useMemo(() => parseSelectedAgentKey(selectedAgentId), [selectedAgentId]); - const agentAwaitingInput = useSessionStore((state) => { - if (!selectedAgent) { - return false; - } - const pendingPermissions = state.sessions[selectedAgent.serverId]?.pendingPermissions; - if (!pendingPermissions) { - return false; - } - for (const permission of pendingPermissions.values()) { - if (permission.agentId === selectedAgent.agentId) { - return true; - } - } - return false; - }); const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; const chromeEnabled = chromeEnabledOverride ?? daemons.length > 0; @@ -309,7 +276,6 @@ function AppContainer({ isMobile, toggleAgentList, selectedAgentId, - agentAwaitingInput, toggleFileExplorer, toggleBothSidebars, toggleFocusMode, diff --git a/packages/app/src/components/agent-stream-view.tsx b/packages/app/src/components/agent-stream-view.tsx index ae9ddc1b9..7c45d66ca 100644 --- a/packages/app/src/components/agent-stream-view.tsx +++ b/packages/app/src/components/agent-stream-view.tsx @@ -5,8 +5,8 @@ import { useEffect, useImperativeHandle, useMemo, - useRef, useState, + useRef, } from "react"; import { View, Text, Pressable, Platform, ActivityIndicator } from "react-native"; import Markdown from "react-native-markdown-display"; @@ -36,17 +36,16 @@ import { MessageOuterSpacingProvider, type InlinePathTarget, } from "./message"; -import { Shortcut } from "./ui/shortcut"; import type { StreamItem } from "@/types/stream"; import type { PendingPermission } from "@/types/shared"; import type { AgentPermissionResponse } from "@server/server/agent/agent-sdk-types"; import type { Agent } from "@/contexts/session-context"; import { useSessionStore } from "@/stores/session-store"; import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions"; -import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; +import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler"; import type { DaemonClient } from "@server/client/daemon-client"; import { ToolCallDetailsContent } from "./tool-call-details"; -import { QuestionFormCard, type QuestionFormCardHandle } from "./question-form-card"; +import { QuestionFormCard } from "./question-form-card"; import { ToolCallSheetProvider } from "./tool-call-sheet"; import { buildAgentStreamRenderModel, @@ -79,11 +78,6 @@ const isToolSequenceItem = (item?: StreamItem) => export interface AgentStreamViewHandle { scrollToBottom(reason?: BottomAnchorLocalRequest["reason"]): void; prepareForViewportChange(): void; - selectPendingPermissionOption(index: number): boolean; -} - -interface PermissionRequestCardHandle { - selectOption(index: number): boolean; } export interface AgentStreamViewProps { @@ -92,6 +86,7 @@ export interface AgentStreamViewProps { agent: Agent; streamItems: StreamItem[]; pendingPermissions: Map; + isPaneFocused?: boolean; routeBottomAnchorRequest?: BottomAnchorRouteRequest | null; isAuthoritativeHistoryReady?: boolean; onOpenWorkspaceFile?: (input: { filePath: string }) => void; @@ -105,6 +100,7 @@ const AgentStreamViewComponent = forwardRef(null); - const permissionCardRefs = useRef(new Map()); const { theme } = useUnistyles(); const router = useRouter(); const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; @@ -238,15 +233,8 @@ const AgentStreamViewComponent = forwardRef 0 ? ( - {pendingPermissionItems.map((permission) => ( + {pendingPermissionItems.map((permission, index) => ( { - if (handle) { - permissionCardRefs.current.set(permission.key, handle); - return; - } - permissionCardRefs.current.delete(permission.key); - }} permission={permission} client={client} + shortcutActive={isPaneFocused && index === 0} /> ))} @@ -550,7 +532,14 @@ const AgentStreamViewComponent = forwardRef { if ( @@ -749,18 +738,17 @@ function WorkingIndicator() { } // Permission Request Card Component -const PermissionRequestCard = forwardRef< - PermissionRequestCardHandle, - { - permission: PendingPermission; - client: DaemonClient | null; - } ->(function PermissionRequestCard({ permission, client }, ref) { +function PermissionRequestCard({ + permission, + client, + shortcutActive, +}: { + permission: PendingPermission; + client: DaemonClient | null; + shortcutActive: boolean; +}) { const { theme } = useUnistyles(); const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; - const questionFormRef = useRef(null); - const firstOptionShortcut = useShortcutKeys("agent-prompt-select-1"); - const secondOptionShortcut = useShortcutKeys("agent-prompt-select-2"); const { request } = permission; const isPlanRequest = request.kind === "plan"; @@ -920,37 +908,39 @@ const PermissionRequestCard = forwardRef< handleResponse({ behavior: "allow" }); }, [handleResponse]); - useImperativeHandle( - ref, - () => ({ - selectOption(index: number) { - if (isResponding) { - return false; - } - if (request.kind === "question") { - return questionFormRef.current?.selectOption(index) ?? false; - } - if (index === 1) { - handleDeny(); - return true; - } - if (index === 2) { - handleAccept(); - return true; - } + const handlePromptSelection = useCallback( + (action: { id: string; index?: number }): boolean => { + if (action.id !== "agent.prompt.select" || request.kind === "question" || isResponding) { return false; - }, - }), + } + if (action.index === 1) { + handleDeny(); + return true; + } + if (action.index === 2) { + handleAccept(); + return true; + } + return false; + }, [handleAccept, handleDeny, isResponding, request.kind], ); + useKeyboardActionHandler({ + handlerId: `agent-prompt-permission:${permission.key}`, + actions: ["agent.prompt.select"], + enabled: shortcutActive && request.kind !== "question" && !isResponding, + priority: 150, + handle: handlePromptSelection, + }); + if (request.kind === "question") { return ( ); } @@ -1033,9 +1023,6 @@ const PermissionRequestCard = forwardRef< Deny - {firstOptionShortcut ? ( - - ) : null} )} @@ -1061,16 +1048,13 @@ const PermissionRequestCard = forwardRef< Accept - {secondOptionShortcut ? ( - - ) : null} )} ); -}); +} const stylesheet = StyleSheet.create((theme) => ({ container: { @@ -1253,7 +1237,6 @@ const permissionStyles = StyleSheet.create((theme) => ({ alignItems: "center", gap: theme.spacing[2], }, - optionShortcut: {}, optionText: { fontSize: theme.fontSize.sm, fontWeight: theme.fontWeight.normal, diff --git a/packages/app/src/components/question-form-card.tsx b/packages/app/src/components/question-form-card.tsx index 3dec29669..84690d6c3 100644 --- a/packages/app/src/components/question-form-card.tsx +++ b/packages/app/src/components/question-form-card.tsx @@ -1,336 +1,530 @@ -import { forwardRef, useCallback, useImperativeHandle, useState } from "react"; +import { useCallback, useState } from "react"; import { View, Text, TextInput, Pressable, ActivityIndicator, Platform } from "react-native"; import { StyleSheet, useUnistyles, UnistylesRuntime } from "react-native-unistyles"; import { Check, CircleHelp, X } from "lucide-react-native"; import type { PendingPermission } from "@/types/shared"; -import { Shortcut } from "@/components/ui/shortcut"; -import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; +import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler"; import type { AgentPermissionResponse } from "@server/server/agent/agent-sdk-types"; -import { - applyQuestionShortcutSelection, - areAllQuestionsAnswered, - buildQuestionAnswers, - findFirstUnansweredQuestionIndex, - parseQuestions, - setQuestionOtherText, - toggleQuestionOption, - type QuestionOtherTexts, - type QuestionSelections, -} from "./question-form-card.utils"; + +interface QuestionOption { + label: string; + description?: string; +} + +interface Question { + question: string; + header: string; + options: QuestionOption[]; + multiSelect: boolean; +} + +type QuestionSelections = Record>; +type QuestionOtherTexts = Record; + +function parseQuestions(input: unknown): Question[] | null { + if ( + typeof input !== "object" || + input === null || + !("questions" in input) || + !Array.isArray((input as Record).questions) + ) { + return null; + } + + const raw = (input as Record).questions as unknown[]; + const questions: Question[] = []; + + for (const item of raw) { + if (typeof item !== "object" || item === null) { + return null; + } + + const question = item as Record; + if (typeof question.question !== "string" || typeof question.header !== "string") { + return null; + } + if (!Array.isArray(question.options)) { + return null; + } + + const options: QuestionOption[] = []; + for (const option of question.options as unknown[]) { + if (typeof option !== "object" || option === null) { + return null; + } + + const candidate = option as Record; + if (typeof candidate.label !== "string") { + return null; + } + + options.push({ + label: candidate.label, + description: typeof candidate.description === "string" ? candidate.description : undefined, + }); + } + + questions.push({ + question: question.question, + header: question.header, + options, + multiSelect: question.multiSelect === true, + }); + } + + return questions.length > 0 ? questions : null; +} + +function cloneSelections(input: QuestionSelections): QuestionSelections { + const next: QuestionSelections = {}; + for (const [key, value] of Object.entries(input)) { + next[Number(key)] = new Set(value); + } + return next; +} + +function isQuestionAnswered(input: { + selections: QuestionSelections; + otherTexts: QuestionOtherTexts; + questionIndex: number; +}): boolean { + const selected = input.selections[input.questionIndex]; + const otherText = input.otherTexts[input.questionIndex]?.trim(); + return (selected && selected.size > 0) || Boolean(otherText && otherText.length > 0); +} + +function findFirstUnansweredQuestionIndex(input: { + questions: readonly Question[]; + selections: QuestionSelections; + otherTexts: QuestionOtherTexts; +}): number | null { + for (let questionIndex = 0; questionIndex < input.questions.length; questionIndex += 1) { + if ( + !isQuestionAnswered({ + selections: input.selections, + otherTexts: input.otherTexts, + questionIndex, + }) + ) { + return questionIndex; + } + } + + return null; +} + +function areAllQuestionsAnswered(input: { + questions: readonly Question[]; + selections: QuestionSelections; + otherTexts: QuestionOtherTexts; +}): boolean { + return input.questions.every((_, questionIndex) => + isQuestionAnswered({ + selections: input.selections, + otherTexts: input.otherTexts, + questionIndex, + }), + ); +} + +function buildQuestionAnswers(input: { + questions: readonly Question[]; + selections: QuestionSelections; + otherTexts: QuestionOtherTexts; +}): Record { + const answers: Record = {}; + + for (let questionIndex = 0; questionIndex < input.questions.length; questionIndex += 1) { + const question = input.questions[questionIndex]; + if (!question) { + continue; + } + + const selected = input.selections[questionIndex]; + const otherText = input.otherTexts[questionIndex]?.trim(); + + if (otherText && otherText.length > 0) { + answers[question.header] = otherText; + continue; + } + + if (!selected || selected.size === 0) { + continue; + } + + const labels = Array.from(selected) + .map((optionIndex) => question.options[optionIndex]?.label) + .filter((label): label is string => typeof label === "string"); + if (labels.length > 0) { + answers[question.header] = labels.join(", "); + } + } + + return answers; +} + +function selectQuestionOption(input: { + questions: readonly Question[]; + selections: QuestionSelections; + otherTexts: QuestionOtherTexts; + questionIndex: number; + optionIndex: number; +}): { + selections: QuestionSelections; + otherTexts: QuestionOtherTexts; +} { + const question = input.questions[input.questionIndex]; + if (!question || !question.options[input.optionIndex]) { + return { + selections: input.selections, + otherTexts: input.otherTexts, + }; + } + + const selections = cloneSelections(input.selections); + const otherTexts = { ...input.otherTexts }; + const current = selections[input.questionIndex] ?? new Set(); + const next = new Set(current); + + if (question.multiSelect) { + if (next.has(input.optionIndex)) { + next.delete(input.optionIndex); + } else { + next.add(input.optionIndex); + } + } else if (next.has(input.optionIndex)) { + next.clear(); + } else { + next.clear(); + next.add(input.optionIndex); + } + + selections[input.questionIndex] = next; + delete otherTexts[input.questionIndex]; + + return { selections, otherTexts }; +} interface QuestionFormCardProps { permission: PendingPermission; onRespond: (response: AgentPermissionResponse) => void; isResponding: boolean; -} - -export interface QuestionFormCardHandle { - selectOption(index: number): boolean; + shortcutActive: boolean; } const IS_WEB = Platform.OS === "web"; -export const QuestionFormCard = forwardRef( - function QuestionFormCard({ permission, onRespond, isResponding }, ref) { - const { theme } = useUnistyles(); - const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; - const questions = parseQuestions(permission.request.input); - const firstOptionShortcut = useShortcutKeys("agent-prompt-select-1"); - const secondOptionShortcut = useShortcutKeys("agent-prompt-select-2"); - const thirdOptionShortcut = useShortcutKeys("agent-prompt-select-3"); - const optionShortcuts = [ - firstOptionShortcut, - secondOptionShortcut, - thirdOptionShortcut, - ] as const; - - const [selections, setSelections] = useState({}); - const [otherTexts, setOtherTexts] = useState({}); - const [respondingAction, setRespondingAction] = useState<"submit" | "dismiss" | null>(null); - - const applyAnswerState = useCallback( - (next: { selections: QuestionSelections; otherTexts: QuestionOtherTexts }) => { - setSelections(next.selections); - setOtherTexts(next.otherTexts); - }, - [], - ); - - const submitResponses = useCallback( - (nextSelections: QuestionSelections, nextOtherTexts: QuestionOtherTexts) => { - if (!questions) { - return; - } - setRespondingAction("submit"); - const answers = buildQuestionAnswers({ - questions, - selections: nextSelections, - otherTexts: nextOtherTexts, - }); - - onRespond({ - behavior: "allow", - updatedInput: { ...permission.request.input, answers }, - }); - }, - [onRespond, permission.request.input, questions], - ); - - const toggleOption = useCallback( - (questionIndex: number, optionIndex: number) => { - if (!questions) { - return; - } - applyAnswerState( - toggleQuestionOption({ - questions, - selections, - otherTexts, - questionIndex, - optionIndex, - }), - ); - }, - [applyAnswerState, otherTexts, questions, selections], - ); - - const setOtherText = useCallback( - (questionIndex: number, text: string) => { - applyAnswerState( - setQuestionOtherText({ - selections, - otherTexts, - questionIndex, - text, - }), - ); - }, - [applyAnswerState, otherTexts, selections], - ); +export function QuestionFormCard({ + permission, + onRespond, + isResponding, + shortcutActive, +}: QuestionFormCardProps) { + const { theme } = useUnistyles(); + const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; + const questions = parseQuestions(permission.request.input); + + const [selections, setSelections] = useState({}); + const [otherTexts, setOtherTexts] = useState({}); + const [respondingAction, setRespondingAction] = useState<"submit" | "dismiss" | null>(null); - const handleSubmit = useCallback(() => { - submitResponses(selections, otherTexts); - }, [otherTexts, selections, submitResponses]); + const submitResponses = useCallback( + (input: { selections: QuestionSelections; otherTexts: QuestionOtherTexts }) => { + if (!questions) { + return; + } - const handleDeny = useCallback(() => { - setRespondingAction("dismiss"); + setRespondingAction("submit"); onRespond({ - behavior: "deny", - message: "Dismissed by user", + behavior: "allow", + updatedInput: { + ...permission.request.input, + answers: buildQuestionAnswers({ + questions, + selections: input.selections, + otherTexts: input.otherTexts, + }), + }, }); - }, [onRespond]); + }, + [onRespond, permission.request.input, questions], + ); - useImperativeHandle( - ref, - () => ({ - selectOption(index: number) { - if (isResponding || !questions) { - return false; - } + const toggleOption = useCallback( + (input: { questionIndex: number; optionIndex: number }) => { + if (!questions) { + return; + } - const result = applyQuestionShortcutSelection({ - questions, - selections, - otherTexts, - optionIndex: index - 1, - }); - if (!result.applied) { - return false; - } - - applyAnswerState({ - selections: result.selections, - otherTexts: result.otherTexts, - }); - if (result.shouldAutoSubmit) { - submitResponses(result.selections, result.otherTexts); - } - return true; - }, - }), - [applyAnswerState, isResponding, otherTexts, questions, selections, submitResponses], - ); + const next = selectQuestionOption({ + questions, + selections, + otherTexts, + questionIndex: input.questionIndex, + optionIndex: input.optionIndex, + }); + setSelections(next.selections); + setOtherTexts(next.otherTexts); + }, + [otherTexts, questions, selections], + ); - if (!questions) { - return null; + const setOtherText = useCallback((input: { questionIndex: number; text: string }) => { + setOtherTexts((previous) => ({ ...previous, [input.questionIndex]: input.text })); + if (input.text.length > 0) { + setSelections((previous) => { + if (!previous[input.questionIndex] || previous[input.questionIndex]?.size === 0) { + return previous; + } + return { ...previous, [input.questionIndex]: new Set() }; + }); } + }, []); - const activeShortcutQuestionIndex = findFirstUnansweredQuestionIndex({ - questions, - selections, - otherTexts, - }); - const allAnswered = areAllQuestionsAnswered({ - questions, - selections, - otherTexts, + const handleSubmit = useCallback(() => { + submitResponses({ selections, otherTexts }); + }, [otherTexts, selections, submitResponses]); + + const handleDeny = useCallback(() => { + setRespondingAction("dismiss"); + onRespond({ + behavior: "deny", + message: "Dismissed by user", }); + }, [onRespond]); + + const handlePromptSelection = useCallback( + (action: { id: string; index?: number }): boolean => { + if (action.id !== "agent.prompt.select" || isResponding || !questions) { + return false; + } + + const questionIndex = findFirstUnansweredQuestionIndex({ + questions, + selections, + otherTexts, + }); + if (questionIndex === null) { + return false; + } + + const optionIndex = (action.index ?? 0) - 1; + const question = questions[questionIndex]; + if (!question?.options[optionIndex]) { + return false; + } + + const next = selectQuestionOption({ + questions, + selections, + otherTexts, + questionIndex, + optionIndex, + }); + setSelections(next.selections); + setOtherTexts(next.otherTexts); + + const shouldAutoSubmit = + areAllQuestionsAnswered({ + questions, + selections: next.selections, + otherTexts: next.otherTexts, + }) && questions.every((candidate) => candidate.multiSelect !== true); + + if (shouldAutoSubmit) { + submitResponses(next); + } + + return true; + }, + [isResponding, otherTexts, questions, selections, submitResponses], + ); + + useKeyboardActionHandler({ + handlerId: `agent-prompt-question:${permission.key}`, + actions: ["agent.prompt.select"], + enabled: shortcutActive && !isResponding && questions !== null, + priority: 150, + handle: handlePromptSelection, + }); + + if (!questions) { + return null; + } - return ( - - {questions.map((q, qIndex) => { - const selected = selections[qIndex] ?? new Set(); - const otherText = otherTexts[qIndex] ?? ""; - - return ( - - - - {q.question} - - - - - {q.options.map((opt, optIndex) => { - const isSelected = selected.has(optIndex); - const shortcutKeys = - activeShortcutQuestionIndex === qIndex - ? optionShortcuts[optIndex] ?? null - : null; - return ( - [ - styles.optionItem, - (hovered || isSelected) && { - backgroundColor: theme.colors.surface2, - }, - pressed && styles.optionItemPressed, - ]} - onPress={() => toggleOption(qIndex, optIndex)} - disabled={isResponding} - > - - - - {opt.label} + const allAnswered = areAllQuestionsAnswered({ + questions, + selections, + otherTexts, + }); + + return ( + + {questions.map((question, questionIndex) => { + const selected = selections[questionIndex] ?? new Set(); + const otherText = otherTexts[questionIndex] ?? ""; + + return ( + + + + {question.question} + + + + + {question.options.map((option, optionIndex) => { + const isSelected = selected.has(optionIndex); + return ( + [ + styles.optionItem, + (hovered || isSelected) && { + backgroundColor: theme.colors.surface2, + }, + pressed && styles.optionItemPressed, + ]} + onPress={() => + toggleOption({ + questionIndex, + optionIndex, + }) + } + disabled={isResponding} + > + + + + {option.label} + + {option.description ? ( + + {option.description} - {opt.description ? ( - - {opt.description} - - ) : null} - - {shortcutKeys ? ( - - ) : null} - {isSelected ? ( - - - ) : null} - - ); - })} - - 0 ? theme.colors.borderAccent : theme.colors.border, - color: theme.colors.foreground, - backgroundColor: theme.colors.surface2, - }, - // @ts-expect-error - outlineStyle is web-only - IS_WEB && { - outlineStyle: "none", - outlineWidth: 0, - outlineColor: "transparent", - }, - ]} - placeholder="Other..." - placeholderTextColor={theme.colors.foregroundMuted} - value={otherText} - onChangeText={(text) => setOtherText(qIndex, text)} - editable={!isResponding} - /> + {isSelected ? ( + + + + ) : null} + + + ); + })} - ); - })} + 0 ? theme.colors.borderAccent : theme.colors.border, + color: theme.colors.foreground, + backgroundColor: theme.colors.surface2, + }, + // @ts-expect-error - outlineStyle is web-only + IS_WEB && { + outlineStyle: "none", + outlineWidth: 0, + outlineColor: "transparent", + }, + ]} + placeholder="Other..." + placeholderTextColor={theme.colors.foregroundMuted} + value={otherText} + onChangeText={(text) => + setOtherText({ + questionIndex, + text, + }) + } + editable={!isResponding} + /> + + ); + })} + + + [ + styles.actionButton, + { + backgroundColor: hovered ? theme.colors.surface2 : theme.colors.surface1, + borderColor: theme.colors.borderAccent, + }, + pressed && styles.optionItemPressed, + ]} + onPress={handleDeny} + disabled={isResponding} + > + {respondingAction === "dismiss" ? ( + + ) : ( + + + + Dismiss + + + )} + - - [ + { + const disabled = !allAnswered || isResponding; + return [ styles.actionButton, { - backgroundColor: hovered ? theme.colors.surface2 : theme.colors.surface1, - borderColor: theme.colors.borderAccent, + backgroundColor: + hovered && !disabled ? theme.colors.surface2 : theme.colors.surface1, + borderColor: disabled ? theme.colors.border : theme.colors.borderAccent, + opacity: disabled ? 0.5 : 1, }, - pressed && styles.optionItemPressed, - ]} - onPress={handleDeny} - disabled={isResponding} - > - {respondingAction === "dismiss" ? ( - - ) : ( - - - - Dismiss - - - )} - - - { - const disabled = !allAnswered || isResponding; - return [ - styles.actionButton, - { - backgroundColor: - hovered && !disabled ? theme.colors.surface2 : theme.colors.surface1, - borderColor: disabled ? theme.colors.border : theme.colors.borderAccent, - opacity: disabled ? 0.5 : 1, - }, - pressed && !disabled ? styles.optionItemPressed : null, - ]; - }} - onPress={handleSubmit} - disabled={!allAnswered || isResponding} - > - {respondingAction === "submit" ? ( - - ) : ( - - - - Submit - - - )} - - + pressed && !disabled ? styles.optionItemPressed : null, + ]; + }} + onPress={handleSubmit} + disabled={!allAnswered || isResponding} + > + {respondingAction === "submit" ? ( + + ) : ( + + + + Submit + + + )} + - ); - }, -); + + ); +} const styles = StyleSheet.create((theme) => ({ container: { @@ -377,7 +571,6 @@ const styles = StyleSheet.create((theme) => ({ flex: 1, gap: 2, }, - optionShortcut: {}, optionLabel: { fontSize: theme.fontSize.sm, }, diff --git a/packages/app/src/components/question-form-card.utils.test.ts b/packages/app/src/components/question-form-card.utils.test.ts deleted file mode 100644 index 1e05fc200..000000000 --- a/packages/app/src/components/question-form-card.utils.test.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { - applyQuestionShortcutSelection, - areAllQuestionsAnswered, - buildQuestionAnswers, - findFirstUnansweredQuestionIndex, - parseQuestions, - type Question, - type QuestionOtherTexts, - type QuestionSelections, -} from "./question-form-card.utils"; - -const singleChoiceQuestions: Question[] = [ - { - header: "deploy_target", - question: "Where should this go?", - multiSelect: false, - options: [{ label: "Production" }, { label: "Staging" }, { label: "Preview" }], - }, - { - header: "rollout", - question: "How should I roll this out?", - multiSelect: false, - options: [{ label: "Gradual" }, { label: "Immediate" }], - }, -]; - -function selections( - input: Array<[number, number[]]> = [], -): QuestionSelections { - return Object.fromEntries( - input.map(([questionIndex, optionIndices]) => [questionIndex, new Set(optionIndices)]), - ) as QuestionSelections; -} - -function otherTexts(input: Array<[number, string]> = []): QuestionOtherTexts { - return Object.fromEntries(input) as QuestionOtherTexts; -} - -describe("question-form-card utils", () => { - it("parses structured question input", () => { - expect( - parseQuestions({ - questions: [ - { - header: "approval", - question: "Proceed?", - options: [{ label: "Yes", description: "Ship it" }, { label: "No" }], - }, - ], - }), - ).toEqual([ - { - header: "approval", - question: "Proceed?", - multiSelect: false, - options: [{ label: "Yes", description: "Ship it" }, { label: "No" }], - }, - ]); - }); - - it("selects the first unanswered question when using shortcut options", () => { - const result = applyQuestionShortcutSelection({ - questions: singleChoiceQuestions, - selections: selections([[0, [1]]]), - otherTexts: {}, - optionIndex: 0, - }); - - expect(result.applied).toBe(true); - expect(result.questionIndex).toBe(1); - expect(result.shouldAutoSubmit).toBe(true); - expect(result.selections[1]).toEqual(new Set([0])); - }); - - it("does not auto-submit multi-select questionnaires", () => { - const result = applyQuestionShortcutSelection({ - questions: [ - { - header: "files", - question: "Which files should I include?", - multiSelect: true, - options: [{ label: "src" }, { label: "docs" }], - }, - ], - selections: {}, - otherTexts: {}, - optionIndex: 1, - }); - - expect(result.applied).toBe(true); - expect(result.shouldAutoSubmit).toBe(false); - expect(result.selections[0]).toEqual(new Set([1])); - }); - - it("tracks unanswered questions from selections and freeform answers", () => { - expect( - findFirstUnansweredQuestionIndex({ - questions: singleChoiceQuestions, - selections: selections([[0, [2]]]), - otherTexts: otherTexts([[1, "later"]]), - }), - ).toBeNull(); - - expect( - areAllQuestionsAnswered({ - questions: singleChoiceQuestions, - selections: selections([[0, [2]]]), - otherTexts: otherTexts([[1, "later"]]), - }), - ).toBe(true); - }); - - it("builds answers from selected options and trimmed other text", () => { - expect( - buildQuestionAnswers({ - questions: singleChoiceQuestions, - selections: selections([[0, [0]], [1, [1]]]), - otherTexts: otherTexts([[1, " manual rollout "]]), - }), - ).toEqual({ - deploy_target: "Production", - rollout: "manual rollout", - }); - }); -}); diff --git a/packages/app/src/components/question-form-card.utils.ts b/packages/app/src/components/question-form-card.utils.ts deleted file mode 100644 index 21599dc81..000000000 --- a/packages/app/src/components/question-form-card.utils.ts +++ /dev/null @@ -1,266 +0,0 @@ -export interface QuestionOption { - label: string; - description?: string; -} - -export interface Question { - question: string; - header: string; - options: QuestionOption[]; - multiSelect: boolean; -} - -export type QuestionSelections = Record>; -export type QuestionOtherTexts = Record; - -type QuestionAnswerState = { - questions: readonly Question[]; - selections: QuestionSelections; - otherTexts: QuestionOtherTexts; -}; - -function cloneSelections(input: QuestionSelections): QuestionSelections { - const next: QuestionSelections = {}; - for (const [key, value] of Object.entries(input)) { - next[Number(key)] = new Set(value); - } - return next; -} - -function cloneOtherTexts(input: QuestionOtherTexts): QuestionOtherTexts { - return { ...input }; -} - -function isQuestionAnswered(input: { - selections: QuestionSelections; - otherTexts: QuestionOtherTexts; - questionIndex: number; -}): boolean { - const selected = input.selections[input.questionIndex]; - const otherText = input.otherTexts[input.questionIndex]?.trim(); - return (selected && selected.size > 0) || Boolean(otherText && otherText.length > 0); -} - -export function parseQuestions(input: unknown): Question[] | null { - if ( - typeof input !== "object" || - input === null || - !("questions" in input) || - !Array.isArray((input as Record).questions) - ) { - return null; - } - - const raw = (input as Record).questions as unknown[]; - const questions: Question[] = []; - - for (const item of raw) { - if (typeof item !== "object" || item === null) return null; - const question = item as Record; - if (typeof question.question !== "string" || typeof question.header !== "string") { - return null; - } - if (!Array.isArray(question.options)) { - return null; - } - - const options: QuestionOption[] = []; - for (const option of question.options as unknown[]) { - if (typeof option !== "object" || option === null) return null; - const candidate = option as Record; - if (typeof candidate.label !== "string") return null; - options.push({ - label: candidate.label, - description: typeof candidate.description === "string" ? candidate.description : undefined, - }); - } - - questions.push({ - question: question.question, - header: question.header, - options, - multiSelect: question.multiSelect === true, - }); - } - - return questions.length > 0 ? questions : null; -} - -export function areAllQuestionsAnswered(input: QuestionAnswerState): boolean { - return input.questions.every((_, questionIndex) => - isQuestionAnswered({ - selections: input.selections, - otherTexts: input.otherTexts, - questionIndex, - }), - ); -} - -export function findFirstUnansweredQuestionIndex(input: QuestionAnswerState): number | null { - for (let questionIndex = 0; questionIndex < input.questions.length; questionIndex += 1) { - if ( - !isQuestionAnswered({ - selections: input.selections, - otherTexts: input.otherTexts, - questionIndex, - }) - ) { - return questionIndex; - } - } - return null; -} - -export function toggleQuestionOption(input: { - questions: readonly Question[]; - selections: QuestionSelections; - otherTexts: QuestionOtherTexts; - questionIndex: number; - optionIndex: number; -}): { - selections: QuestionSelections; - otherTexts: QuestionOtherTexts; -} { - const question = input.questions[input.questionIndex]; - if (!question || !question.options[input.optionIndex]) { - return { - selections: input.selections, - otherTexts: input.otherTexts, - }; - } - - const selections = cloneSelections(input.selections); - const otherTexts = cloneOtherTexts(input.otherTexts); - const current = selections[input.questionIndex] ?? new Set(); - const next = new Set(current); - - if (question.multiSelect) { - if (next.has(input.optionIndex)) { - next.delete(input.optionIndex); - } else { - next.add(input.optionIndex); - } - } else if (next.has(input.optionIndex)) { - next.clear(); - } else { - next.clear(); - next.add(input.optionIndex); - } - - selections[input.questionIndex] = next; - delete otherTexts[input.questionIndex]; - - return { selections, otherTexts }; -} - -export function setQuestionOtherText(input: { - selections: QuestionSelections; - otherTexts: QuestionOtherTexts; - questionIndex: number; - text: string; -}): { - selections: QuestionSelections; - otherTexts: QuestionOtherTexts; -} { - const selections = cloneSelections(input.selections); - const otherTexts = cloneOtherTexts(input.otherTexts); - - otherTexts[input.questionIndex] = input.text; - if (input.text.length > 0) { - selections[input.questionIndex] = new Set(); - } - - return { selections, otherTexts }; -} - -export function buildQuestionAnswers(input: QuestionAnswerState): Record { - const answers: Record = {}; - - for (let questionIndex = 0; questionIndex < input.questions.length; questionIndex += 1) { - const question = input.questions[questionIndex]; - if (!question) { - continue; - } - - const selected = input.selections[questionIndex]; - const otherText = input.otherTexts[questionIndex]?.trim(); - - if (otherText && otherText.length > 0) { - answers[question.header] = otherText; - continue; - } - - if (!selected || selected.size === 0) { - continue; - } - - const labels = Array.from(selected) - .map((optionIndex) => question.options[optionIndex]?.label) - .filter((label): label is string => typeof label === "string"); - if (labels.length > 0) { - answers[question.header] = labels.join(", "); - } - } - - return answers; -} - -export function applyQuestionShortcutSelection(input: { - questions: readonly Question[]; - selections: QuestionSelections; - otherTexts: QuestionOtherTexts; - optionIndex: number; -}): { - applied: boolean; - selections: QuestionSelections; - otherTexts: QuestionOtherTexts; - shouldAutoSubmit: boolean; - questionIndex: number | null; -} { - const questionIndex = findFirstUnansweredQuestionIndex({ - questions: input.questions, - selections: input.selections, - otherTexts: input.otherTexts, - }); - if (questionIndex === null) { - return { - applied: false, - selections: input.selections, - otherTexts: input.otherTexts, - shouldAutoSubmit: false, - questionIndex: null, - }; - } - - const question = input.questions[questionIndex]; - if (!question || !question.options[input.optionIndex]) { - return { - applied: false, - selections: input.selections, - otherTexts: input.otherTexts, - shouldAutoSubmit: false, - questionIndex, - }; - } - - const next = toggleQuestionOption({ - questions: input.questions, - selections: input.selections, - otherTexts: input.otherTexts, - questionIndex, - optionIndex: input.optionIndex, - }); - - return { - applied: true, - selections: next.selections, - otherTexts: next.otherTexts, - shouldAutoSubmit: - areAllQuestionsAnswered({ - questions: input.questions, - selections: next.selections, - otherTexts: next.otherTexts, - }) && input.questions.every((candidate) => candidate.multiSelect !== true), - questionIndex, - }; -} diff --git a/packages/app/src/hooks/use-keyboard-shortcuts.ts b/packages/app/src/hooks/use-keyboard-shortcuts.ts index d79d95420..7cedcad08 100644 --- a/packages/app/src/hooks/use-keyboard-shortcuts.ts +++ b/packages/app/src/hooks/use-keyboard-shortcuts.ts @@ -32,7 +32,6 @@ export function useKeyboardShortcuts({ isMobile, toggleAgentList, selectedAgentId, - agentAwaitingInput, toggleFileExplorer, toggleBothSidebars, toggleFocusMode, @@ -41,7 +40,6 @@ export function useKeyboardShortcuts({ isMobile: boolean; toggleAgentList: () => void; selectedAgentId?: string; - agentAwaitingInput: boolean; toggleFileExplorer?: () => void; toggleBothSidebars?: () => void; toggleFocusMode?: () => void; @@ -340,7 +338,6 @@ export function useKeyboardShortcuts({ pathname, toggleFileExplorer, }), - agentAwaitingInput, }, chordState: chordStateRef.current, onChordReset: () => { @@ -420,7 +417,6 @@ export function useKeyboardShortcuts({ openProjectPickerAction, pathname, resetModifiers, - agentAwaitingInput, selectedAgentId, toggleAgentList, toggleFileExplorer, diff --git a/packages/app/src/keyboard/keyboard-shortcuts.test.ts b/packages/app/src/keyboard/keyboard-shortcuts.test.ts index d8c534ee6..25fca321a 100644 --- a/packages/app/src/keyboard/keyboard-shortcuts.test.ts +++ b/packages/app/src/keyboard/keyboard-shortcuts.test.ts @@ -30,7 +30,6 @@ function shortcutContext( focusScope: "other", commandCenterOpen: false, hasSelectedAgent: true, - agentAwaitingInput: false, ...overrides, }; } @@ -297,9 +296,9 @@ describe("keyboard-shortcuts", () => { stopPropagation: false, }, { - name: "routes number keys to prompt selection when an agent is awaiting input", + name: "routes number keys to prompt selection outside editable scopes", event: { key: "2", code: "Digit2" }, - context: { agentAwaitingInput: true, focusScope: "other" }, + context: { focusScope: "other" }, action: "agent.prompt.select", payload: { index: 2 }, }, @@ -373,15 +372,10 @@ describe("keyboard-shortcuts", () => { event: { key: " ", code: "Space" }, context: { focusScope: "message-input" }, }, - { - name: "does not route prompt selection when no agent is awaiting input", - event: { key: "1", code: "Digit1" }, - context: { focusScope: "other", agentAwaitingInput: false }, - }, { name: "does not route prompt selection while typing in the message input", event: { key: "1", code: "Digit1" }, - context: { focusScope: "message-input", agentAwaitingInput: true }, + context: { focusScope: "message-input" }, }, ]; diff --git a/packages/app/src/keyboard/keyboard-shortcuts.ts b/packages/app/src/keyboard/keyboard-shortcuts.ts index 6ece0ce8b..dcfe8a5a2 100644 --- a/packages/app/src/keyboard/keyboard-shortcuts.ts +++ b/packages/app/src/keyboard/keyboard-shortcuts.ts @@ -17,7 +17,6 @@ export type KeyboardShortcutContext = { focusScope: KeyboardFocusScope; commandCenterOpen: boolean; hasSelectedAgent: boolean; - agentAwaitingInput: boolean; }; export type KeyboardShortcutMatch = { @@ -66,8 +65,6 @@ interface ShortcutWhen { commandCenter?: false; /** true = requires a selected agent */ hasSelectedAgent?: true; - /** true = requires a visible prompt awaiting agent input */ - agentAwaitingInput?: true; /** Exact focus scope match */ focusScope?: KeyboardFocusScope; } @@ -822,7 +819,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ action: "agent.prompt.select", combo: "1", repeat: false, - when: { agentAwaitingInput: true, focusScope: "other", commandCenter: false }, + when: { focusScope: "other", commandCenter: false }, payload: { type: "index" }, help: { id: "agent-prompt-select-1", @@ -836,7 +833,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ action: "agent.prompt.select", combo: "2", repeat: false, - when: { agentAwaitingInput: true, focusScope: "other", commandCenter: false }, + when: { focusScope: "other", commandCenter: false }, payload: { type: "index" }, help: { id: "agent-prompt-select-2", @@ -850,7 +847,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ action: "agent.prompt.select", combo: "3", repeat: false, - when: { agentAwaitingInput: true, focusScope: "other", commandCenter: false }, + when: { focusScope: "other", commandCenter: false }, payload: { type: "index" }, help: { id: "agent-prompt-select-3", @@ -1009,7 +1006,6 @@ function matchesWhen(when: ShortcutWhen | undefined, context: KeyboardShortcutCo if (when.terminal === false && context.focusScope === "terminal") return false; if (when.commandCenter === false && context.commandCenterOpen) return false; if (when.hasSelectedAgent === true && !context.hasSelectedAgent) return false; - if (when.agentAwaitingInput === true && !context.agentAwaitingInput) return false; if (when.focusScope !== undefined && context.focusScope !== when.focusScope) return false; return true; } diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index 4bec10027..fc44380ca 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -22,11 +22,9 @@ import { useArchiveAgent } from "@/hooks/use-archive-agent"; import { useDelayedHistoryRefreshToast } from "@/hooks/use-delayed-history-refresh-toast"; import { useAgentInputDraft } from "@/hooks/use-agent-input-draft"; import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style"; -import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler"; import { useStableEvent } from "@/hooks/use-stable-event"; import { usePaneContext } from "@/panels/pane-context"; import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry"; -import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher"; import { useHostRuntimeClient, useHostRuntimeConnectionStatus, @@ -325,25 +323,6 @@ function AgentPanelBody({ isScreenFocused: isPaneFocused, }); - const handlePromptShortcut = useCallback( - (action: KeyboardActionDefinition): boolean => { - if (!isPaneFocused || action.id !== "agent.prompt.select") { - return false; - } - return streamViewRef.current?.selectPendingPermissionOption(action.index) ?? false; - }, - [isPaneFocused], - ); - - useKeyboardActionHandler({ - handlerId: `agent-prompt-actions:${serverId}:${agentId ?? "__pending__"}`, - actions: ["agent.prompt.select"], - enabled: Boolean(agentId), - priority: 150, - isActive: () => isPaneFocused, - handle: handlePromptShortcut, - }); - useEffect(() => { clearOnAgentBlurRef.current = attentionController.clearOnAgentBlur; }, [attentionController.clearOnAgentBlur]); @@ -818,6 +797,7 @@ function AgentPanelBody({ agent={effectiveAgent} streamItems={shouldUseOptimisticStream ? mergedStreamItems : streamItems} pendingPermissions={pendingPermissions} + isPaneFocused={isPaneFocused} routeBottomAnchorRequest={routeBottomAnchorRequest} isAuthoritativeHistoryReady={hasAppliedAuthoritativeHistory} onOpenWorkspaceFile={onOpenWorkspaceFile}