Compare commits

...
Author SHA1 Message Date
Mohamed Boudra c28d917a4b Simplify prompt shortcut handling 2026-03-24 11:14:13 +07:00
Mohamed Boudra 2f6b8cea01 Add number key shortcuts for agent prompts 2026-03-23 21:56:39 +07:00
8 changed files with 468 additions and 126 deletions
@@ -5,8 +5,8 @@ import {
useEffect, useEffect,
useImperativeHandle, useImperativeHandle,
useMemo, useMemo,
useRef,
useState, useState,
useRef,
} from "react"; } from "react";
import { View, Text, Pressable, Platform, ActivityIndicator } from "react-native"; import { View, Text, Pressable, Platform, ActivityIndicator } from "react-native";
import Markdown from "react-native-markdown-display"; import Markdown from "react-native-markdown-display";
@@ -42,6 +42,7 @@ import type { AgentPermissionResponse } from "@server/server/agent/agent-sdk-typ
import type { Agent } from "@/contexts/session-context"; import type { Agent } from "@/contexts/session-context";
import { useSessionStore } from "@/stores/session-store"; import { useSessionStore } from "@/stores/session-store";
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions"; import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
import type { DaemonClient } from "@server/client/daemon-client"; import type { DaemonClient } from "@server/client/daemon-client";
import { ToolCallDetailsContent } from "./tool-call-details"; import { ToolCallDetailsContent } from "./tool-call-details";
import { QuestionFormCard } from "./question-form-card"; import { QuestionFormCard } from "./question-form-card";
@@ -85,6 +86,7 @@ export interface AgentStreamViewProps {
agent: Agent; agent: Agent;
streamItems: StreamItem[]; streamItems: StreamItem[];
pendingPermissions: Map<string, PendingPermission>; pendingPermissions: Map<string, PendingPermission>;
isPaneFocused?: boolean;
routeBottomAnchorRequest?: BottomAnchorRouteRequest | null; routeBottomAnchorRequest?: BottomAnchorRouteRequest | null;
isAuthoritativeHistoryReady?: boolean; isAuthoritativeHistoryReady?: boolean;
onOpenWorkspaceFile?: (input: { filePath: string }) => void; onOpenWorkspaceFile?: (input: { filePath: string }) => void;
@@ -98,6 +100,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
agent, agent,
streamItems, streamItems,
pendingPermissions, pendingPermissions,
isPaneFocused = false,
routeBottomAnchorRequest = null, routeBottomAnchorRequest = null,
isAuthoritativeHistoryReady = true, isAuthoritativeHistoryReady = true,
onOpenWorkspaceFile, onOpenWorkspaceFile,
@@ -208,6 +211,10 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
workspaceId, workspaceId,
], ],
); );
const pendingPermissionItems = useMemo(
() => Array.from(pendingPermissions.values()).filter((perm) => perm.agentId === agentId),
[pendingPermissions, agentId],
);
const baseRenderModel = useMemo(() => { const baseRenderModel = useMemo(() => {
return buildAgentStreamRenderModel({ return buildAgentStreamRenderModel({
@@ -490,18 +497,18 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
[getGapBetween, renderStreamItemContent, agent.status, streamRenderStrategy], [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 showWorkingIndicator = agent.status === "running";
const renderModel = useMemo<AgentStreamRenderModel>(() => { const renderModel = useMemo<AgentStreamRenderModel>(() => {
const pendingPermissionsNode = const pendingPermissionsNode =
pendingPermissionItems.length > 0 ? ( pendingPermissionItems.length > 0 ? (
<View style={stylesheet.permissionsContainer}> <View style={stylesheet.permissionsContainer}>
{pendingPermissionItems.map((permission) => ( {pendingPermissionItems.map((permission, index) => (
<PermissionRequestCard key={permission.key} permission={permission} client={client} /> <PermissionRequestCard
key={permission.key}
permission={permission}
client={client}
shortcutActive={isPaneFocused && index === 0}
/>
))} ))}
</View> </View>
) : null; ) : null;
@@ -525,7 +532,14 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
workingIndicator: workingIndicatorNode, workingIndicator: workingIndicatorNode,
}, },
}; };
}, [baseRenderModel, client, getGapBetween, pendingPermissionItems, showWorkingIndicator]); }, [
baseRenderModel,
client,
getGapBetween,
isPaneFocused,
pendingPermissionItems,
showWorkingIndicator,
]);
const listEmptyComponent = useMemo(() => { const listEmptyComponent = useMemo(() => {
if ( if (
@@ -727,9 +741,11 @@ function WorkingIndicator() {
function PermissionRequestCard({ function PermissionRequestCard({
permission, permission,
client, client,
shortcutActive,
}: { }: {
permission: PendingPermission; permission: PendingPermission;
client: DaemonClient | null; client: DaemonClient | null;
shortcutActive: boolean;
}) { }) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
@@ -740,9 +756,6 @@ function PermissionRequestCard({
const description = request.description ?? ""; const description = request.description ?? "";
const planMarkdown = useMemo(() => { const planMarkdown = useMemo(() => {
if (!request) {
return undefined;
}
const planFromMetadata = const planFromMetadata =
typeof request.metadata?.planText === "string" ? request.metadata.planText : undefined; typeof request.metadata?.planText === "string" ? request.metadata.planText : undefined;
if (planFromMetadata) { if (planFromMetadata) {
@@ -868,6 +881,7 @@ function PermissionRequestCard({
resetPermissionMutation(); resetPermissionMutation();
setRespondingAction(null); setRespondingAction(null);
}, [permission.request.id, resetPermissionMutation]); }, [permission.request.id, resetPermissionMutation]);
const handleResponse = useCallback( const handleResponse = useCallback(
(response: AgentPermissionResponse) => { (response: AgentPermissionResponse) => {
respondToPermission({ respondToPermission({
@@ -881,12 +895,52 @@ function PermissionRequestCard({
[permission.agentId, permission.request.id, respondToPermission], [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]);
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") { if (request.kind === "question") {
return ( return (
<QuestionFormCard <QuestionFormCard
permission={permission} permission={permission}
onRespond={handleResponse} onRespond={handleResponse}
isResponding={isResponding} isResponding={isResponding}
shortcutActive={shortcutActive}
/> />
); );
} }
@@ -958,13 +1012,7 @@ function PermissionRequestCard({
}, },
pressed ? permissionStyles.optionButtonPressed : null, pressed ? permissionStyles.optionButtonPressed : null,
]} ]}
onPress={() => { onPress={handleDeny}
setRespondingAction("deny");
handleResponse({
behavior: "deny",
message: "Denied by user",
});
}}
disabled={isResponding} disabled={isResponding}
> >
{respondingAction === "deny" ? ( {respondingAction === "deny" ? (
@@ -989,10 +1037,7 @@ function PermissionRequestCard({
}, },
pressed ? permissionStyles.optionButtonPressed : null, pressed ? permissionStyles.optionButtonPressed : null,
]} ]}
onPress={() => { onPress={handleAccept}
setRespondingAction("accept");
handleResponse({ behavior: "allow" });
}}
disabled={isResponding} disabled={isResponding}
> >
{respondingAction === "accept" ? ( {respondingAction === "accept" ? (
@@ -1,8 +1,9 @@
import { useState, useCallback } from "react"; import { useCallback, useState } from "react";
import { View, Text, TextInput, Pressable, ActivityIndicator, Platform } from "react-native"; import { View, Text, TextInput, Pressable, ActivityIndicator, Platform } from "react-native";
import { StyleSheet, useUnistyles, UnistylesRuntime } from "react-native-unistyles"; import { StyleSheet, useUnistyles, UnistylesRuntime } from "react-native-unistyles";
import { Check, CircleHelp, X } from "lucide-react-native"; import { Check, CircleHelp, X } from "lucide-react-native";
import type { PendingPermission } from "@/types/shared"; import type { PendingPermission } from "@/types/shared";
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
import type { AgentPermissionResponse } from "@server/server/agent/agent-sdk-types"; import type { AgentPermissionResponse } from "@server/server/agent/agent-sdk-types";
interface QuestionOption { interface QuestionOption {
@@ -17,6 +18,9 @@ interface Question {
multiSelect: boolean; multiSelect: boolean;
} }
type QuestionSelections = Record<number, Set<number>>;
type QuestionOtherTexts = Record<number, string>;
function parseQuestions(input: unknown): Question[] | null { function parseQuestions(input: unknown): Question[] | null {
if ( if (
typeof input !== "object" || typeof input !== "object" ||
@@ -26,128 +30,334 @@ function parseQuestions(input: unknown): Question[] | null {
) { ) {
return null; return null;
} }
const raw = (input as Record<string, unknown>).questions as unknown[]; const raw = (input as Record<string, unknown>).questions as unknown[];
const questions: Question[] = []; const questions: Question[] = [];
for (const item of raw) { for (const item of raw) {
if (typeof item !== "object" || item === null) return null; if (typeof item !== "object" || item === null) {
const q = item as Record<string, unknown>; return null;
if (typeof q.question !== "string" || typeof q.header !== "string") return null; }
if (!Array.isArray(q.options)) 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[] = []; const options: QuestionOption[] = [];
for (const opt of q.options as unknown[]) { for (const option of question.options as unknown[]) {
if (typeof opt !== "object" || opt === null) return null; if (typeof option !== "object" || option === null) {
const o = opt as Record<string, unknown>; return null;
if (typeof o.label !== "string") return null; }
const candidate = option as Record<string, unknown>;
if (typeof candidate.label !== "string") {
return null;
}
options.push({ options.push({
label: o.label, label: candidate.label,
description: typeof o.description === "string" ? o.description : undefined, description: typeof candidate.description === "string" ? candidate.description : undefined,
}); });
} }
questions.push({ questions.push({
question: q.question, question: question.question,
header: q.header, header: question.header,
options, options,
multiSelect: q.multiSelect === true, multiSelect: question.multiSelect === true,
}); });
} }
return questions.length > 0 ? questions : null; 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<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;
}
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<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 };
}
interface QuestionFormCardProps { interface QuestionFormCardProps {
permission: PendingPermission; permission: PendingPermission;
onRespond: (response: AgentPermissionResponse) => void; onRespond: (response: AgentPermissionResponse) => void;
isResponding: boolean; isResponding: boolean;
shortcutActive: boolean;
} }
const IS_WEB = Platform.OS === "web"; const IS_WEB = Platform.OS === "web";
export function QuestionFormCard({ permission, onRespond, isResponding }: QuestionFormCardProps) { export function QuestionFormCard({
permission,
onRespond,
isResponding,
shortcutActive,
}: QuestionFormCardProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const questions = parseQuestions(permission.request.input); const questions = parseQuestions(permission.request.input);
const [selections, setSelections] = useState<Record<number, Set<number>>>({}); const [selections, setSelections] = useState<QuestionSelections>({});
const [otherTexts, setOtherTexts] = useState<Record<number, string>>({}); const [otherTexts, setOtherTexts] = useState<QuestionOtherTexts>({});
const [respondingAction, setRespondingAction] = useState<"submit" | "dismiss" | null>(null); const [respondingAction, setRespondingAction] = useState<"submit" | "dismiss" | null>(null);
const toggleOption = useCallback((qIndex: number, optIndex: number, multiSelect: boolean) => { const submitResponses = useCallback(
setSelections((prev) => { (input: { selections: QuestionSelections; otherTexts: QuestionOtherTexts }) => {
const current = prev[qIndex] ?? new Set<number>(); if (!questions) {
const next = new Set(current); return;
if (multiSelect) {
if (next.has(optIndex)) {
next.delete(optIndex);
} else {
next.add(optIndex);
}
} 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;
});
}, []);
const setOtherText = useCallback((qIndex: number, text: string) => { setRespondingAction("submit");
setOtherTexts((prev) => ({ ...prev, [qIndex]: text })); onRespond({
if (text.length > 0) { behavior: "allow",
setSelections((prev) => { updatedInput: {
if (!prev[qIndex] || prev[qIndex].size === 0) return prev; ...permission.request.input,
return { ...prev, [qIndex]: new Set<number>() }; answers: buildQuestionAnswers({
questions,
selections: input.selections,
otherTexts: input.otherTexts,
}),
},
});
},
[onRespond, permission.request.input, questions],
);
const toggleOption = useCallback(
(input: { questionIndex: number; optionIndex: number }) => {
if (!questions) {
return;
}
const next = selectQuestionOption({
questions,
selections,
otherTexts,
questionIndex: input.questionIndex,
optionIndex: input.optionIndex,
});
setSelections(next.selections);
setOtherTexts(next.otherTexts);
},
[otherTexts, questions, selections],
);
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<number>() };
}); });
} }
}, []); }, []);
if (!questions) { const handleSubmit = useCallback(() => {
return null; submitResponses({ selections, otherTexts });
} }, [otherTexts, selections, submitResponses]);
const allAnswered = questions.every((_, qIndex) => { const handleDeny = useCallback(() => {
const selected = selections[qIndex];
const otherText = otherTexts[qIndex]?.trim();
return (selected && selected.size > 0) || (otherText && otherText.length > 0);
});
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"); setRespondingAction("dismiss");
onRespond({ onRespond({
behavior: "deny", behavior: "deny",
message: "Dismissed by user", 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;
} }
const allAnswered = areAllQuestionsAnswered({
questions,
selections,
otherTexts,
});
return ( return (
<View <View
style={[ style={[
@@ -158,24 +368,24 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
}, },
]} ]}
> >
{questions.map((q, qIndex) => { {questions.map((question, questionIndex) => {
const selected = selections[qIndex] ?? new Set<number>(); const selected = selections[questionIndex] ?? new Set<number>();
const otherText = otherTexts[qIndex] ?? ""; const otherText = otherTexts[questionIndex] ?? "";
return ( return (
<View key={qIndex} style={styles.questionBlock}> <View key={questionIndex} style={styles.questionBlock}>
<View style={styles.questionHeader}> <View style={styles.questionHeader}>
<Text style={[styles.questionText, { color: theme.colors.foreground }]}> <Text style={[styles.questionText, { color: theme.colors.foreground }]}>
{q.question} {question.question}
</Text> </Text>
<CircleHelp size={14} color={theme.colors.foregroundMuted} /> <CircleHelp size={14} color={theme.colors.foregroundMuted} />
</View> </View>
<View style={styles.optionsWrap}> <View style={styles.optionsWrap}>
{q.options.map((opt, optIndex) => { {question.options.map((option, optionIndex) => {
const isSelected = selected.has(optIndex); const isSelected = selected.has(optionIndex);
return ( return (
<Pressable <Pressable
key={optIndex} key={optionIndex}
style={({ pressed, hovered = false }) => [ style={({ pressed, hovered = false }) => [
styles.optionItem, styles.optionItem,
(hovered || isSelected) && { (hovered || isSelected) && {
@@ -183,22 +393,27 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
}, },
pressed && styles.optionItemPressed, pressed && styles.optionItemPressed,
]} ]}
onPress={() => toggleOption(qIndex, optIndex, q.multiSelect)} onPress={() =>
toggleOption({
questionIndex,
optionIndex,
})
}
disabled={isResponding} disabled={isResponding}
> >
<View style={styles.optionItemContent}> <View style={styles.optionItemContent}>
<View style={styles.optionTextBlock}> <View style={styles.optionTextBlock}>
<Text style={[styles.optionLabel, { color: theme.colors.foreground }]}> <Text style={[styles.optionLabel, { color: theme.colors.foreground }]}>
{opt.label} {option.label}
</Text> </Text>
{opt.description ? ( {option.description ? (
<Text <Text
style={[ style={[
styles.optionDescription, styles.optionDescription,
{ color: theme.colors.foregroundMuted }, { color: theme.colors.foregroundMuted },
]} ]}
> >
{opt.description} {option.description}
</Text> </Text>
) : null} ) : null}
</View> </View>
@@ -222,12 +437,21 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
backgroundColor: theme.colors.surface2, backgroundColor: theme.colors.surface2,
}, },
// @ts-expect-error - outlineStyle is web-only // @ts-expect-error - outlineStyle is web-only
IS_WEB && { outlineStyle: "none", outlineWidth: 0, outlineColor: "transparent" }, IS_WEB && {
outlineStyle: "none",
outlineWidth: 0,
outlineColor: "transparent",
},
]} ]}
placeholder="Other..." placeholder="Other..."
placeholderTextColor={theme.colors.foregroundMuted} placeholderTextColor={theme.colors.foregroundMuted}
value={otherText} value={otherText}
onChangeText={(text) => setOtherText(qIndex, text)} onChangeText={(text) =>
setOtherText({
questionIndex,
text,
})
}
editable={!isResponding} editable={!isResponding}
/> />
</View> </View>
@@ -161,6 +161,15 @@ export function useKeyboardShortcuts({
switch (input.action) { switch (input.action) {
case "agent.new": case "agent.new":
return openProjectPicker(); 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": case "workspace.tab.new":
return keyboardActionDispatcher.dispatch({ return keyboardActionDispatcher.dispatch({
id: "workspace.tab.new", id: "workspace.tab.new",
+1
View File
@@ -16,6 +16,7 @@ export type MessageInputKeyboardActionKind =
export type KeyboardActionId = export type KeyboardActionId =
| "agent.new" | "agent.new"
| "agent.prompt.select"
| "workspace.tab.new" | "workspace.tab.new"
| "workspace.tab.close.current" | "workspace.tab.close.current"
| "workspace.tab.navigate.index" | "workspace.tab.navigate.index"
@@ -1,6 +1,7 @@
export type KeyboardActionScope = "global" | "message-input" | "sidebar" | "workspace"; export type KeyboardActionScope = "global" | "message-input" | "sidebar" | "workspace";
export type KeyboardActionId = export type KeyboardActionId =
| "agent.prompt.select"
| "message-input.focus" | "message-input.focus"
| "message-input.dictation-toggle" | "message-input.dictation-toggle"
| "message-input.dictation-cancel" | "message-input.dictation-cancel"
@@ -26,6 +27,7 @@ export type KeyboardActionId =
| "worktree.archive"; | "worktree.archive";
export type KeyboardActionDefinition = export type KeyboardActionDefinition =
| { id: "agent.prompt.select"; scope: KeyboardActionScope; index: number }
| { id: "message-input.focus"; scope: KeyboardActionScope } | { id: "message-input.focus"; scope: KeyboardActionScope }
| { id: "message-input.dictation-toggle"; scope: KeyboardActionScope } | { id: "message-input.dictation-toggle"; scope: KeyboardActionScope }
| { id: "message-input.dictation-cancel"; scope: KeyboardActionScope } | { id: "message-input.dictation-cancel"; scope: KeyboardActionScope }
@@ -261,9 +261,15 @@ describe("keyboard-shortcuts", () => {
action: "sidebar.toggle.left", 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 }, event: { key: ".", code: "Period", ctrlKey: true },
context: { isMac: false }, 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", action: "sidebar.toggle.left",
}, },
{ {
@@ -289,6 +295,13 @@ describe("keyboard-shortcuts", () => {
preventDefault: false, preventDefault: false,
stopPropagation: false, stopPropagation: false,
}, },
{
name: "routes number keys to prompt selection outside editable scopes",
event: { key: "2", code: "Digit2" },
context: { focusScope: "other" },
action: "agent.prompt.select",
payload: { index: 2 },
},
]; ];
it.each(matchingCases)("$name", ({ it.each(matchingCases)("$name", ({
@@ -344,11 +357,6 @@ describe("keyboard-shortcuts", () => {
event: { key: "k", code: "KeyK", ctrlKey: true }, event: { key: "k", code: "KeyK", ctrlKey: true },
context: { isMac: false, focusScope: "terminal" }, 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", name: "does not route message-input actions when terminal is focused",
event: { key: "d", code: "KeyD", metaKey: true }, event: { key: "d", code: "KeyD", metaKey: true },
@@ -364,6 +372,11 @@ describe("keyboard-shortcuts", () => {
event: { key: " ", code: "Space" }, event: { key: " ", code: "Space" },
context: { focusScope: "message-input" }, context: { focusScope: "message-input" },
}, },
{
name: "does not route prompt selection while typing in the message input",
event: { key: "1", code: "Digit1" },
context: { focusScope: "message-input" },
},
]; ];
it.each(nonMatchingCases)("$name", ({ event, context }) => { it.each(nonMatchingCases)("$name", ({ event, context }) => {
@@ -467,6 +480,9 @@ describe("keyboard-shortcut help sections", () => {
"workspace-tab-close-current": ["meta", "W"], "workspace-tab-close-current": ["meta", "W"],
"workspace-pane-split-right": ["mod", "\\"], "workspace-pane-split-right": ["mod", "\\"],
"workspace-pane-close": ["mod", "shift", "W"], "workspace-pane-close": ["mod", "shift", "W"],
"agent-prompt-select-1": ["1"],
"agent-prompt-select-2": ["2"],
"agent-prompt-select-3": ["3"],
}, },
}, },
{ {
@@ -477,10 +493,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 }, context: { isMac: false, isDesktop: false },
expectedKeys: { expectedKeys: {
"toggle-left-sidebar": ["mod", "."], "toggle-left-sidebar": ["mod", "B"],
"toggle-both-sidebars": ["mod", "."],
}, },
}, },
]; ];
@@ -814,6 +814,48 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
keys: ["Esc"], keys: ["Esc"],
}, },
}, },
{
id: "agent-prompt-select-1",
action: "agent.prompt.select",
combo: "1",
repeat: false,
when: { 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: { 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: { 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", id: "message-input-send-enter",
action: "message-input.action", action: "message-input.action",
+2
View File
@@ -322,6 +322,7 @@ function AgentPanelBody({
attentionReason: agent?.attentionReason, attentionReason: agent?.attentionReason,
isScreenFocused: isPaneFocused, isScreenFocused: isPaneFocused,
}); });
useEffect(() => { useEffect(() => {
clearOnAgentBlurRef.current = attentionController.clearOnAgentBlur; clearOnAgentBlurRef.current = attentionController.clearOnAgentBlur;
}, [attentionController.clearOnAgentBlur]); }, [attentionController.clearOnAgentBlur]);
@@ -796,6 +797,7 @@ function AgentPanelBody({
agent={effectiveAgent} agent={effectiveAgent}
streamItems={shouldUseOptimisticStream ? mergedStreamItems : streamItems} streamItems={shouldUseOptimisticStream ? mergedStreamItems : streamItems}
pendingPermissions={pendingPermissions} pendingPermissions={pendingPermissions}
isPaneFocused={isPaneFocused}
routeBottomAnchorRequest={routeBottomAnchorRequest} routeBottomAnchorRequest={routeBottomAnchorRequest}
isAuthoritativeHistoryReady={hasAppliedAuthoritativeHistory} isAuthoritativeHistoryReady={hasAppliedAuthoritativeHistory}
onOpenWorkspaceFile={onOpenWorkspaceFile} onOpenWorkspaceFile={onOpenWorkspaceFile}