Add number key shortcuts for agent prompts

This commit is contained in:
Mohamed Boudra
2026-03-23 21:56:39 +07:00
parent 988c432ded
commit 2f6b8cea01
11 changed files with 935 additions and 308 deletions
+34
View File
@@ -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,
@@ -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<AgentStreamViewHandle, AgentStreamVi
ref,
) {
const viewportRef = useRef<StreamViewportHandle | null>(null);
const permissionCardRefs = useRef(new Map<string, PermissionRequestCardHandle>());
const { theme } = useUnistyles();
const router = useRouter();
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
@@ -208,6 +216,10 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
workspaceId,
],
);
const pendingPermissionItems = useMemo(
() => Array.from(pendingPermissions.values()).filter((perm) => perm.agentId === agentId),
[pendingPermissions, agentId],
);
const baseRenderModel = useMemo(() => {
return buildAgentStreamRenderModel({
@@ -226,8 +238,15 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
prepareForViewportChange() {
viewportRef.current?.prepareForViewportChange();
},
selectPendingPermissionOption(index: number) {
const topPermission = pendingPermissionItems[0];
if (!topPermission) {
return false;
}
return permissionCardRefs.current.get(topPermission.key)?.selectOption(index) ?? false;
},
}),
[],
[pendingPermissionItems],
);
function scrollToBottom() {
@@ -490,18 +509,24 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
[getGapBetween, renderStreamItemContent, agent.status, streamRenderStrategy],
);
const pendingPermissionItems = useMemo(
() => Array.from(pendingPermissions.values()).filter((perm) => perm.agentId === agentId),
[pendingPermissions, agentId],
);
const showWorkingIndicator = agent.status === "running";
const renderModel = useMemo<AgentStreamRenderModel>(() => {
const pendingPermissionsNode =
pendingPermissionItems.length > 0 ? (
<View style={stylesheet.permissionsContainer}>
{pendingPermissionItems.map((permission) => (
<PermissionRequestCard key={permission.key} permission={permission} client={client} />
<PermissionRequestCard
key={permission.key}
ref={(handle) => {
if (handle) {
permissionCardRefs.current.set(permission.key, handle);
return;
}
permissionCardRefs.current.delete(permission.key);
}}
permission={permission}
client={client}
/>
))}
</View>
) : 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<QuestionFormCardHandle | null>(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 (
<QuestionFormCard
ref={questionFormRef}
permission={permission}
onRespond={handleResponse}
isResponding={isResponding}
@@ -958,13 +1022,7 @@ function PermissionRequestCard({
},
pressed ? permissionStyles.optionButtonPressed : null,
]}
onPress={() => {
setRespondingAction("deny");
handleResponse({
behavior: "deny",
message: "Denied by user",
});
}}
onPress={handleDeny}
disabled={isResponding}
>
{respondingAction === "deny" ? (
@@ -975,6 +1033,9 @@ function PermissionRequestCard({
<Text style={[permissionStyles.optionText, { color: theme.colors.foregroundMuted }]}>
Deny
</Text>
{firstOptionShortcut ? (
<Shortcut chord={firstOptionShortcut} style={permissionStyles.optionShortcut} />
) : null}
</View>
)}
</Pressable>
@@ -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({
<Text style={[permissionStyles.optionText, { color: theme.colors.foreground }]}>
Accept
</Text>
{secondOptionShortcut ? (
<Shortcut chord={secondOptionShortcut} style={permissionStyles.optionShortcut} />
) : null}
</View>
)}
</Pressable>
</View>
</View>
);
}
});
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,
+301 -270
View File
@@ -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<string, unknown>).questions)
) {
return null;
}
const raw = (input as Record<string, unknown>).questions as unknown[];
const questions: Question[] = [];
for (const item of raw) {
if (typeof item !== "object" || item === null) return null;
const q = item as Record<string, unknown>;
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<string, unknown>;
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<QuestionFormCardHandle, QuestionFormCardProps>(
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<Record<number, Set<number>>>({});
const [otherTexts, setOtherTexts] = useState<Record<number, string>>({});
const [respondingAction, setRespondingAction] = useState<"submit" | "dismiss" | null>(null);
const [selections, setSelections] = useState<QuestionSelections>({});
const [otherTexts, setOtherTexts] = useState<QuestionOtherTexts>({});
const [respondingAction, setRespondingAction] = useState<"submit" | "dismiss" | null>(null);
const toggleOption = useCallback((qIndex: number, optIndex: number, multiSelect: boolean) => {
setSelections((prev) => {
const current = prev[qIndex] ?? new Set<number>();
const next = new Set(current);
if (multiSelect) {
if (next.has(optIndex)) {
next.delete(optIndex);
} else {
next.add(optIndex);
const applyAnswerState = useCallback(
(next: { selections: QuestionSelections; otherTexts: QuestionOtherTexts }) => {
setSelections(next.selections);
setOtherTexts(next.otherTexts);
},
[],
);
const submitResponses = useCallback(
(nextSelections: QuestionSelections, nextOtherTexts: QuestionOtherTexts) => {
if (!questions) {
return;
}
} else {
if (next.has(optIndex)) {
next.clear();
} else {
next.clear();
next.add(optIndex);
}
}
return { ...prev, [qIndex]: next };
});
setOtherTexts((prev) => {
if (!prev[qIndex]) return prev;
const next = { ...prev };
delete next[qIndex];
return next;
});
}, []);
setRespondingAction("submit");
const answers = buildQuestionAnswers({
questions,
selections: nextSelections,
otherTexts: nextOtherTexts,
});
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<number>() };
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],
);
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<string, string> = {};
for (let i = 0; i < questions!.length; i++) {
const q = questions![i];
const selected = selections[i];
const otherText = otherTexts[i]?.trim();
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(", ");
}
}
onRespond({
behavior: "allow",
updatedInput: { ...permission.request.input, answers },
});
}
function handleDeny() {
setRespondingAction("dismiss");
onRespond({
behavior: "deny",
message: "Dismissed by user",
});
}
return (
<View
style={[
styles.container,
{
backgroundColor: theme.colors.surface1,
borderColor: theme.colors.border,
applyAnswerState({
selections: result.selections,
otherTexts: result.otherTexts,
});
if (result.shouldAutoSubmit) {
submitResponses(result.selections, result.otherTexts);
}
return true;
},
]}
>
{questions.map((q, qIndex) => {
const selected = selections[qIndex] ?? new Set<number>();
const otherText = otherTexts[qIndex] ?? "";
}),
[applyAnswerState, isResponding, otherTexts, questions, selections, submitResponses],
);
return (
<View key={qIndex} style={styles.questionBlock}>
<View style={styles.questionHeader}>
<Text style={[styles.questionText, { color: theme.colors.foreground }]}>
{q.question}
</Text>
<CircleHelp size={14} color={theme.colors.foregroundMuted} />
</View>
<View style={styles.optionsWrap}>
{q.options.map((opt, optIndex) => {
const isSelected = selected.has(optIndex);
return (
<Pressable
key={optIndex}
style={({ pressed, hovered = false }) => [
styles.optionItem,
(hovered || isSelected) && {
backgroundColor: theme.colors.surface2,
},
pressed && styles.optionItemPressed,
]}
onPress={() => toggleOption(qIndex, optIndex, q.multiSelect)}
disabled={isResponding}
>
<View style={styles.optionItemContent}>
<View style={styles.optionTextBlock}>
<Text style={[styles.optionLabel, { color: theme.colors.foreground }]}>
{opt.label}
</Text>
{opt.description ? (
<Text
style={[
styles.optionDescription,
{ color: theme.colors.foregroundMuted },
]}
>
{opt.description}
if (!questions) {
return null;
}
const activeShortcutQuestionIndex = findFirstUnansweredQuestionIndex({
questions,
selections,
otherTexts,
});
const allAnswered = areAllQuestionsAnswered({
questions,
selections,
otherTexts,
});
return (
<View
style={[
styles.container,
{
backgroundColor: theme.colors.surface1,
borderColor: theme.colors.border,
},
]}
>
{questions.map((q, qIndex) => {
const selected = selections[qIndex] ?? new Set<number>();
const otherText = otherTexts[qIndex] ?? "";
return (
<View key={qIndex} style={styles.questionBlock}>
<View style={styles.questionHeader}>
<Text style={[styles.questionText, { color: theme.colors.foreground }]}>
{q.question}
</Text>
<CircleHelp size={14} color={theme.colors.foregroundMuted} />
</View>
<View style={styles.optionsWrap}>
{q.options.map((opt, optIndex) => {
const isSelected = selected.has(optIndex);
const shortcutKeys =
activeShortcutQuestionIndex === qIndex
? optionShortcuts[optIndex] ?? null
: null;
return (
<Pressable
key={optIndex}
style={({ pressed, hovered = false }) => [
styles.optionItem,
(hovered || isSelected) && {
backgroundColor: theme.colors.surface2,
},
pressed && styles.optionItemPressed,
]}
onPress={() => toggleOption(qIndex, optIndex)}
disabled={isResponding}
>
<View style={styles.optionItemContent}>
<View style={styles.optionTextBlock}>
<Text style={[styles.optionLabel, { color: theme.colors.foreground }]}>
{opt.label}
</Text>
{opt.description ? (
<Text
style={[
styles.optionDescription,
{ color: theme.colors.foregroundMuted },
]}
>
{opt.description}
</Text>
) : null}
</View>
{shortcutKeys ? (
<Shortcut chord={shortcutKeys} style={styles.optionShortcut} />
) : null}
{isSelected ? (
<View style={styles.optionCheckSlot}>
<Check size={16} color={theme.colors.foregroundMuted} />
</View>
) : null}
</View>
{isSelected ? (
<View style={styles.optionCheckSlot}>
<Check size={16} color={theme.colors.foregroundMuted} />
</View>
) : null}
</View>
</Pressable>
);
})}
</View>
<TextInput
style={[
styles.otherInput,
{
borderColor:
otherText.length > 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}
/>
</View>
);
})}
<View style={[styles.actionsContainer, !isMobile && styles.actionsContainerDesktop]}>
<Pressable
style={({ pressed, hovered = false }) => [
styles.actionButton,
{
backgroundColor: hovered ? theme.colors.surface2 : theme.colors.surface1,
borderColor: theme.colors.borderAccent,
},
pressed && styles.optionItemPressed,
]}
onPress={handleDeny}
disabled={isResponding}
>
{respondingAction === "dismiss" ? (
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
) : (
<View style={styles.actionContent}>
<X size={14} color={theme.colors.foregroundMuted} />
<Text style={[styles.actionText, { color: theme.colors.foregroundMuted }]}>
Dismiss
</Text>
</View>
)}
</Pressable>
<Pressable
style={({ pressed, hovered = false }) => {
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" ? (
<ActivityIndicator size="small" color={theme.colors.foreground} />
) : (
<View style={styles.actionContent}>
<Check
size={14}
color={allAnswered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
<Text
</Pressable>
);
})}
</View>
<TextInput
style={[
styles.actionText,
styles.otherInput,
{
color: allAnswered ? theme.colors.foreground : theme.colors.foregroundMuted,
borderColor:
otherText.length > 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
</Text>
placeholder="Other..."
placeholderTextColor={theme.colors.foregroundMuted}
value={otherText}
onChangeText={(text) => setOtherText(qIndex, text)}
editable={!isResponding}
/>
</View>
)}
</Pressable>
);
})}
<View style={[styles.actionsContainer, !isMobile && styles.actionsContainerDesktop]}>
<Pressable
style={({ pressed, hovered = false }) => [
styles.actionButton,
{
backgroundColor: hovered ? theme.colors.surface2 : theme.colors.surface1,
borderColor: theme.colors.borderAccent,
},
pressed && styles.optionItemPressed,
]}
onPress={handleDeny}
disabled={isResponding}
>
{respondingAction === "dismiss" ? (
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
) : (
<View style={styles.actionContent}>
<X size={14} color={theme.colors.foregroundMuted} />
<Text style={[styles.actionText, { color: theme.colors.foregroundMuted }]}>
Dismiss
</Text>
</View>
)}
</Pressable>
<Pressable
style={({ pressed, hovered = false }) => {
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" ? (
<ActivityIndicator size="small" color={theme.colors.foreground} />
) : (
<View style={styles.actionContent}>
<Check
size={14}
color={allAnswered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
<Text
style={[
styles.actionText,
{
color: allAnswered ? theme.colors.foreground : theme.colors.foregroundMuted,
},
]}
>
Submit
</Text>
</View>
)}
</Pressable>
</View>
</View>
</View>
);
}
);
},
);
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,
},
@@ -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",
});
});
});
@@ -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<number, Set<number>>;
export type QuestionOtherTexts = Record<number, string>;
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<string, unknown>).questions)
) {
return null;
}
const raw = (input as Record<string, unknown>).questions as unknown[];
const questions: Question[] = [];
for (const item of raw) {
if (typeof item !== "object" || item === null) return null;
const question = item as Record<string, unknown>;
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<string, unknown>;
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<number>();
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<number>();
}
return { selections, otherTexts };
}
export function buildQuestionAnswers(input: QuestionAnswerState): Record<string, string> {
const answers: Record<string, string> = {};
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,
};
}
@@ -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,
+1
View File
@@ -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"
@@ -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 }
@@ -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", "."],
},
},
];
@@ -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;
}
+22
View File
@@ -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]);