feat(app): cycle agent modes with Shift+Tab
This commit is contained in:
@@ -16,6 +16,8 @@ import { Bot, ShieldAlert, ShieldCheck, ShieldOff, ShieldQuestionMark } from "lu
|
||||
import { ComboboxTrigger } from "@/components/ui/combobox-trigger";
|
||||
import { type SheetHeader } from "@/components/adaptive-modal-sheet";
|
||||
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||
import { mergeProviderPreferences, useFormPreferences } from "@/hooks/use-form-preferences";
|
||||
@@ -24,7 +26,12 @@ import { useToast } from "@/contexts/toast-context";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { toErrorMessage } from "@/utils/error-messages";
|
||||
import { showProviderNoticeToast } from "@/utils/provider-notice-toast";
|
||||
import { formatAgentModeLabel } from "@/composer/agent-controls/utils";
|
||||
import { formatAgentModeLabel, getAgentControlHintKey } from "@/composer/agent-controls/utils";
|
||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
||||
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
|
||||
import { resolveNextAgentModeId } from "@/composer/agent-controls/mode";
|
||||
import { useComposerKeyboardScope } from "@/composer/keyboard-scope";
|
||||
import type { AgentMode, AgentProvider } from "@getpaseo/protocol/agent-types";
|
||||
import { getModeVisuals, type AgentProviderDefinition } from "@getpaseo/protocol/provider-manifest";
|
||||
|
||||
@@ -106,7 +113,10 @@ function AgentModeControlView({
|
||||
}: AgentModeControlViewProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const { isActiveComposer } = useComposerKeyboardScope();
|
||||
const cycleShortcutKeys = useShortcutKeys("cycle-agent-mode");
|
||||
const anchorRef = useRef<View>(null);
|
||||
const keyboardHandlerIdRef = useRef(`mode-control:${Math.random().toString(36).slice(2)}`);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
@@ -146,6 +156,26 @@ function AgentModeControlView({
|
||||
[onSelectMode, handleOpenChange],
|
||||
);
|
||||
|
||||
const handleKeyboardAction = useCallback(
|
||||
(action: KeyboardActionDefinition): boolean => {
|
||||
if (action.id !== "message-input.mode-cycle") return false;
|
||||
if (disabled || !isActiveComposer) return false;
|
||||
const nextModeId = resolveNextAgentModeId({ modeOptions, selectedMode: selectedModeId });
|
||||
if (!nextModeId) return false;
|
||||
onSelectMode(nextModeId);
|
||||
return true;
|
||||
},
|
||||
[disabled, isActiveComposer, modeOptions, onSelectMode, selectedModeId],
|
||||
);
|
||||
|
||||
useKeyboardActionHandler({
|
||||
handlerId: keyboardHandlerIdRef.current,
|
||||
actions: ["message-input.mode-cycle"],
|
||||
enabled: isActiveComposer && !disabled && modeOptions.length > 1,
|
||||
priority: 200,
|
||||
handle: handleKeyboardAction,
|
||||
});
|
||||
|
||||
const renderOption = useCallback(
|
||||
(args: {
|
||||
option: ComboboxOption;
|
||||
@@ -194,21 +224,31 @@ function AgentModeControlView({
|
||||
|
||||
return (
|
||||
<>
|
||||
<ComboboxTrigger
|
||||
ref={anchorRef}
|
||||
collapsable={false}
|
||||
disabled={disabled}
|
||||
onPress={handlePress}
|
||||
style={pressableStyle}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("agentControls.mode.selectWithValue", {
|
||||
value: selectedModeLabel,
|
||||
})}
|
||||
testID="mode-control"
|
||||
>
|
||||
{Icon ? <Icon size={theme.iconSize.md} color={iconColor} /> : null}
|
||||
<Text style={labelStyle}>{selectedModeLabel}</Text>
|
||||
</ComboboxTrigger>
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger asChild triggerRefProp="ref">
|
||||
<ComboboxTrigger
|
||||
ref={anchorRef}
|
||||
collapsable={false}
|
||||
disabled={disabled}
|
||||
onPress={handlePress}
|
||||
style={pressableStyle}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("agentControls.mode.selectWithValue", {
|
||||
value: selectedModeLabel,
|
||||
})}
|
||||
testID="mode-control"
|
||||
>
|
||||
{Icon ? <Icon size={theme.iconSize.md} color={iconColor} /> : null}
|
||||
<Text style={labelStyle}>{selectedModeLabel}</Text>
|
||||
</ComboboxTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<View style={styles.tooltipRow}>
|
||||
<Text style={styles.tooltipText}>{t(getAgentControlHintKey("mode"))}</Text>
|
||||
{isActiveComposer && cycleShortcutKeys ? <Shortcut chord={cycleShortcutKeys} /> : null}
|
||||
</View>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Combobox
|
||||
options={options}
|
||||
value={selectedMode.id}
|
||||
@@ -373,4 +413,13 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
tooltipRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
tooltipText: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveAgentControlsMode } from "./mode";
|
||||
import type { AgentMode } from "@getpaseo/protocol/agent-types";
|
||||
import { resolveAgentControlsMode, resolveNextAgentModeId } from "./mode";
|
||||
|
||||
const PLAN_MODE = { id: "plan", label: "Plan" } satisfies AgentMode;
|
||||
|
||||
const MODES = [
|
||||
PLAN_MODE,
|
||||
{ id: "build", label: "Build" },
|
||||
{ id: "full-access", label: "Full Access" },
|
||||
] satisfies AgentMode[];
|
||||
|
||||
describe("resolveAgentControlsMode", () => {
|
||||
it("uses ready mode when no controlled agent controls are provided", () => {
|
||||
@@ -29,3 +38,32 @@ describe("resolveAgentControlsMode", () => {
|
||||
).toBe("draft");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveNextAgentModeId", () => {
|
||||
it("cycles from the selected mode to the next mode", () => {
|
||||
expect(resolveNextAgentModeId({ modeOptions: MODES, selectedMode: "build" })).toBe(
|
||||
"full-access",
|
||||
);
|
||||
});
|
||||
|
||||
it("wraps from the last mode to the first mode", () => {
|
||||
expect(resolveNextAgentModeId({ modeOptions: MODES, selectedMode: "full-access" })).toBe(
|
||||
"plan",
|
||||
);
|
||||
});
|
||||
|
||||
it("treats an empty selection as the visible first mode", () => {
|
||||
expect(resolveNextAgentModeId({ modeOptions: MODES, selectedMode: "" })).toBe("build");
|
||||
});
|
||||
|
||||
it("treats a stale selection as the visible first mode", () => {
|
||||
expect(resolveNextAgentModeId({ modeOptions: MODES, selectedMode: "deleted-mode" })).toBe(
|
||||
"build",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns null when there are fewer than two modes", () => {
|
||||
expect(resolveNextAgentModeId({ modeOptions: [], selectedMode: "" })).toBeNull();
|
||||
expect(resolveNextAgentModeId({ modeOptions: [PLAN_MODE], selectedMode: "plan" })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,20 @@
|
||||
import type { DraftAgentControlsProps } from "@/composer/agent-controls";
|
||||
import type { AgentMode } from "@getpaseo/protocol/agent-types";
|
||||
|
||||
export function resolveNextAgentModeId({
|
||||
modeOptions,
|
||||
selectedMode,
|
||||
}: {
|
||||
modeOptions: readonly AgentMode[];
|
||||
selectedMode: string | null | undefined;
|
||||
}): string | null {
|
||||
if (modeOptions.length < 2) return null;
|
||||
|
||||
const selectedIndex = modeOptions.findIndex((mode) => mode.id === selectedMode);
|
||||
const currentIndex = selectedIndex >= 0 ? selectedIndex : 0;
|
||||
const nextIndex = (currentIndex + 1) % modeOptions.length;
|
||||
return modeOptions[nextIndex]?.id ?? null;
|
||||
}
|
||||
|
||||
export function resolveAgentControlsMode(agentControls?: DraftAgentControlsProps) {
|
||||
return agentControls ? "draft" : "ready";
|
||||
|
||||
@@ -89,6 +89,7 @@ import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
||||
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
|
||||
import type { MessageInputKeyboardActionKind } from "@/keyboard/actions";
|
||||
import { submitAgentInput } from "@/composer/submit";
|
||||
import { ComposerKeyboardScopeProvider } from "@/composer/keyboard-scope";
|
||||
import { useAppSettings } from "@/hooks/use-settings";
|
||||
import { isWeb, isNative } from "@/constants/platform";
|
||||
import type { GitHubSearchItem } from "@getpaseo/protocol/messages";
|
||||
@@ -1746,8 +1747,15 @@ export function Composer({
|
||||
);
|
||||
|
||||
const leftContent = useMemo(
|
||||
() => renderLeftContent({ agentControls, agentId, serverId, focusInput, isCompactLayout }),
|
||||
[agentId, focusInput, serverId, agentControls, isCompactLayout],
|
||||
() =>
|
||||
renderLeftContent({
|
||||
agentControls,
|
||||
agentId,
|
||||
serverId,
|
||||
focusInput,
|
||||
isCompactLayout,
|
||||
}),
|
||||
[agentControls, agentId, focusInput, isCompactLayout, serverId],
|
||||
);
|
||||
|
||||
const handleAttachButtonRef = useCallback((node: View | null) => {
|
||||
@@ -1860,90 +1868,92 @@ export function Composer({
|
||||
const autocompleteVisible = autocomplete.isVisible && isPaneFocused;
|
||||
|
||||
return (
|
||||
<Animated.View style={composerContainerStyle}>
|
||||
<AttachmentLightbox metadata={lightboxMetadata} onClose={handleLightboxClose} />
|
||||
{/* Input area */}
|
||||
<View style={inputAreaContainerStyle}>
|
||||
<View style={styles.inputAreaContent}>
|
||||
{queueList}
|
||||
{sendErrorNode}
|
||||
<ComposerKeyboardScopeProvider isActiveComposer={isPaneFocused}>
|
||||
<Animated.View style={composerContainerStyle}>
|
||||
<AttachmentLightbox metadata={lightboxMetadata} onClose={handleLightboxClose} />
|
||||
{/* Input area */}
|
||||
<View style={inputAreaContainerStyle}>
|
||||
<View style={styles.inputAreaContent}>
|
||||
{queueList}
|
||||
{sendErrorNode}
|
||||
|
||||
<View ref={messageInputContainerRef} style={styles.messageInputContainer}>
|
||||
<AutocompletePopover
|
||||
visible={autocompleteVisible}
|
||||
anchorRef={messageInputContainerRef}
|
||||
options={autocomplete.options}
|
||||
selectedIndex={autocomplete.selectedIndex}
|
||||
onSelect={autocomplete.onSelectOption}
|
||||
isLoading={autocomplete.isLoading}
|
||||
errorMessage={autocomplete.errorMessage}
|
||||
loadingText={autocomplete.loadingText}
|
||||
emptyText={autocomplete.emptyText}
|
||||
/>
|
||||
<View ref={messageInputContainerRef} style={styles.messageInputContainer}>
|
||||
<AutocompletePopover
|
||||
visible={autocompleteVisible}
|
||||
anchorRef={messageInputContainerRef}
|
||||
options={autocomplete.options}
|
||||
selectedIndex={autocomplete.selectedIndex}
|
||||
onSelect={autocomplete.onSelectOption}
|
||||
isLoading={autocomplete.isLoading}
|
||||
errorMessage={autocomplete.errorMessage}
|
||||
loadingText={autocomplete.loadingText}
|
||||
emptyText={autocomplete.emptyText}
|
||||
/>
|
||||
|
||||
{/* MessageInput handles everything: text, dictation, attachments, all buttons */}
|
||||
<StableMessageInput
|
||||
ref={messageInputRef}
|
||||
value={userInput}
|
||||
onChangeText={setUserInput}
|
||||
onSubmit={handleSubmit}
|
||||
hasExternalContent={hasExternalContent}
|
||||
allowEmptySubmit={allowEmptySubmit}
|
||||
submitButtonAccessibilityLabel={submitButtonAccessibilityLabel}
|
||||
submitButtonTestID={submitButtonTestID}
|
||||
submitIcon={submitIcon}
|
||||
isSubmitDisabled={isSubmitBusy}
|
||||
isSubmitLoading={isSubmitBusy}
|
||||
preserveHeightOnSubmit={submitBehavior === "preserve-and-lock"}
|
||||
attachments={selectedAttachments}
|
||||
cwd={cwd}
|
||||
attachmentMenuItems={attachmentMenuItems}
|
||||
onAttachButtonRef={handleAttachButtonRef}
|
||||
onAddImages={addImages}
|
||||
client={client}
|
||||
isReadyForDictation={isDictationReady}
|
||||
placeholder={messagePlaceholder}
|
||||
autoFocus={messageInputAutoFocus}
|
||||
autoFocusKey={`${serverId}:${agentId}`}
|
||||
disabled={isSubmitLoading}
|
||||
isPaneFocused={isPaneFocused}
|
||||
leftContent={leftContent}
|
||||
beforeVoiceContent={beforeVoiceContent}
|
||||
rightContent={rightContent}
|
||||
voiceServerId={serverId}
|
||||
voiceAgentId={agentId}
|
||||
isAgentRunning={isAgentRunning}
|
||||
defaultSendBehavior={appSettings.sendBehavior}
|
||||
onQueue={handleQueue}
|
||||
onSubmitLoadingPress={submitLoadingPressHandler}
|
||||
onKeyPress={handleCommandKeyPress}
|
||||
onSelectionChange={handleSelectionChange}
|
||||
onFocusChange={handleFocusChange}
|
||||
onHeightChange={onComposerHeightChange}
|
||||
inputWrapperStyle={inputWrapperStyle}
|
||||
attachmentSlot={attachmentTray}
|
||||
/>
|
||||
<Combobox
|
||||
options={githubSearchOptions}
|
||||
value=""
|
||||
onSelect={noop}
|
||||
keepOpenOnSelect
|
||||
searchable
|
||||
searchPlaceholder={t("composer.github.searchPlaceholder")}
|
||||
title={t("composer.github.title")}
|
||||
open={isGithubPickerOpen}
|
||||
onOpenChange={handleGithubPickerOpenChange}
|
||||
onSearchQueryChange={setGithubSearchQuery}
|
||||
desktopPlacement="top-start"
|
||||
anchorRef={attachButtonRef}
|
||||
emptyText={githubEmptyText}
|
||||
renderOption={renderGithubPickerOption}
|
||||
/>
|
||||
{/* MessageInput handles everything: text, dictation, attachments, all buttons */}
|
||||
<StableMessageInput
|
||||
ref={messageInputRef}
|
||||
value={userInput}
|
||||
onChangeText={setUserInput}
|
||||
onSubmit={handleSubmit}
|
||||
hasExternalContent={hasExternalContent}
|
||||
allowEmptySubmit={allowEmptySubmit}
|
||||
submitButtonAccessibilityLabel={submitButtonAccessibilityLabel}
|
||||
submitButtonTestID={submitButtonTestID}
|
||||
submitIcon={submitIcon}
|
||||
isSubmitDisabled={isSubmitBusy}
|
||||
isSubmitLoading={isSubmitBusy}
|
||||
preserveHeightOnSubmit={submitBehavior === "preserve-and-lock"}
|
||||
attachments={selectedAttachments}
|
||||
cwd={cwd}
|
||||
attachmentMenuItems={attachmentMenuItems}
|
||||
onAttachButtonRef={handleAttachButtonRef}
|
||||
onAddImages={addImages}
|
||||
client={client}
|
||||
isReadyForDictation={isDictationReady}
|
||||
placeholder={messagePlaceholder}
|
||||
autoFocus={messageInputAutoFocus}
|
||||
autoFocusKey={`${serverId}:${agentId}`}
|
||||
disabled={isSubmitLoading}
|
||||
isPaneFocused={isPaneFocused}
|
||||
leftContent={leftContent}
|
||||
beforeVoiceContent={beforeVoiceContent}
|
||||
rightContent={rightContent}
|
||||
voiceServerId={serverId}
|
||||
voiceAgentId={agentId}
|
||||
isAgentRunning={isAgentRunning}
|
||||
defaultSendBehavior={appSettings.sendBehavior}
|
||||
onQueue={handleQueue}
|
||||
onSubmitLoadingPress={submitLoadingPressHandler}
|
||||
onKeyPress={handleCommandKeyPress}
|
||||
onSelectionChange={handleSelectionChange}
|
||||
onFocusChange={handleFocusChange}
|
||||
onHeightChange={onComposerHeightChange}
|
||||
inputWrapperStyle={inputWrapperStyle}
|
||||
attachmentSlot={attachmentTray}
|
||||
/>
|
||||
<Combobox
|
||||
options={githubSearchOptions}
|
||||
value=""
|
||||
onSelect={noop}
|
||||
keepOpenOnSelect
|
||||
searchable
|
||||
searchPlaceholder={t("composer.github.searchPlaceholder")}
|
||||
title={t("composer.github.title")}
|
||||
open={isGithubPickerOpen}
|
||||
onOpenChange={handleGithubPickerOpenChange}
|
||||
onSearchQueryChange={setGithubSearchQuery}
|
||||
desktopPlacement="top-start"
|
||||
anchorRef={attachButtonRef}
|
||||
emptyText={githubEmptyText}
|
||||
renderOption={renderGithubPickerOption}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
{renderComposerFooter(footer, footerInlineContent)}
|
||||
</Animated.View>
|
||||
{renderComposerFooter(footer, footerInlineContent)}
|
||||
</Animated.View>
|
||||
</ComposerKeyboardScopeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useMemo,
|
||||
type PropsWithChildren,
|
||||
type ReactElement,
|
||||
} from "react";
|
||||
|
||||
interface ComposerKeyboardScopeValue {
|
||||
isActiveComposer: boolean;
|
||||
}
|
||||
|
||||
const ComposerKeyboardScopeContext = createContext<ComposerKeyboardScopeValue>({
|
||||
isActiveComposer: false,
|
||||
});
|
||||
|
||||
export function ComposerKeyboardScopeProvider({
|
||||
isActiveComposer,
|
||||
children,
|
||||
}: PropsWithChildren<ComposerKeyboardScopeValue>): ReactElement {
|
||||
const value = useMemo(() => ({ isActiveComposer }), [isActiveComposer]);
|
||||
return (
|
||||
<ComposerKeyboardScopeContext.Provider value={value}>
|
||||
{children}
|
||||
</ComposerKeyboardScopeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useComposerKeyboardScope(): ComposerKeyboardScopeValue {
|
||||
return useContext(ComposerKeyboardScopeContext);
|
||||
}
|
||||
@@ -183,6 +183,7 @@ describe("translation resources", () => {
|
||||
expect(en.composer.github.title).toBe("Attach issue or PR");
|
||||
expect(en.agentControls.provider.fallback).toBe("Provider");
|
||||
expect(en.agentControls.hints.model).toBe("Change model");
|
||||
expect(en.agentControls.hints.mode).toBe("Change mode");
|
||||
expect(en.agentControls.features.title).toBe("Features");
|
||||
expect(en.agentControls.mode.title).toBe("Mode");
|
||||
expect(en.agentStream.permission.required).toBe("Permission Required");
|
||||
@@ -289,6 +290,7 @@ describe("translation resources", () => {
|
||||
expect(en.settings.shortcuts.sections.tabsPanes).toBe("Tabs & Panes");
|
||||
expect(en.settings.shortcuts.help.toggleCommandCenter).toBe("Toggle command center");
|
||||
expect(en.settings.shortcuts.help.newWorkspace).toBe("New workspace");
|
||||
expect(en.settings.shortcuts.help.cycleAgentMode).toBe("Cycle agent mode");
|
||||
expect(en.settings.shortcuts.helpNotes.showKeyboardShortcuts).toBe(
|
||||
"Available when focus is not in a text field or terminal.",
|
||||
);
|
||||
|
||||
@@ -165,7 +165,7 @@ export const ar: TranslationResources = {
|
||||
hints: {
|
||||
thinking: "وضع التفكير",
|
||||
model: "تغيير النموذج",
|
||||
mode: "تغيير وضع الإذن",
|
||||
mode: "تغيير الوضع",
|
||||
},
|
||||
},
|
||||
agentStream: {
|
||||
@@ -1567,6 +1567,7 @@ export const ar: TranslationResources = {
|
||||
toggleFocusMode: "تبديل وضع التركيز",
|
||||
cycleTheme: "موضوع الدورة",
|
||||
focusMessageInput: "التركيز على إدخال الرسالة",
|
||||
cycleAgentMode: "تبديل وضع الوكيل",
|
||||
toggleVoiceMode: "تبديل الوضع الصوتي",
|
||||
startStopDictation: "بدء إملاء /stop",
|
||||
interruptAgent: "عامل المقاطعة",
|
||||
|
||||
@@ -163,7 +163,7 @@ export const en = {
|
||||
hints: {
|
||||
thinking: "Thinking mode",
|
||||
model: "Change model",
|
||||
mode: "Change permission mode",
|
||||
mode: "Change mode",
|
||||
},
|
||||
},
|
||||
agentStream: {
|
||||
@@ -1573,6 +1573,7 @@ export const en = {
|
||||
toggleFocusMode: "Toggle focus mode",
|
||||
cycleTheme: "Cycle theme",
|
||||
focusMessageInput: "Focus message input",
|
||||
cycleAgentMode: "Cycle agent mode",
|
||||
toggleVoiceMode: "Toggle voice mode",
|
||||
startStopDictation: "Start/stop dictation",
|
||||
interruptAgent: "Interrupt agent",
|
||||
|
||||
@@ -165,7 +165,7 @@ export const es: TranslationResources = {
|
||||
hints: {
|
||||
thinking: "Modo de pensamiento",
|
||||
model: "Cambiar modelo",
|
||||
mode: "Cambiar modo de permiso",
|
||||
mode: "Cambiar modo",
|
||||
},
|
||||
},
|
||||
agentStream: {
|
||||
@@ -1605,6 +1605,7 @@ export const es: TranslationResources = {
|
||||
toggleFocusMode: "Alternar modo de enfoque",
|
||||
cycleTheme: "Tema del ciclo",
|
||||
focusMessageInput: "Entrada de mensaje de enfoque",
|
||||
cycleAgentMode: "Alternar modo del agente",
|
||||
toggleVoiceMode: "Alternar modo de voz",
|
||||
startStopDictation: "Iniciar dictado/stop",
|
||||
interruptAgent: "agente de interrupción",
|
||||
|
||||
@@ -166,7 +166,7 @@ export const fr: TranslationResources = {
|
||||
hints: {
|
||||
thinking: "Mode réflexion",
|
||||
model: "Changer de modèle",
|
||||
mode: "Changer le mode d'autorisation",
|
||||
mode: "Changer de mode",
|
||||
},
|
||||
},
|
||||
agentStream: {
|
||||
@@ -1609,6 +1609,7 @@ export const fr: TranslationResources = {
|
||||
toggleFocusMode: "Basculer le mode de mise au point",
|
||||
cycleTheme: "Thème du cycle",
|
||||
focusMessageInput: "Saisie du message de focus",
|
||||
cycleAgentMode: "Parcourir les modes de l'agent",
|
||||
toggleVoiceMode: "Changer le mode vocal",
|
||||
startStopDictation: "Démarrer la dictée/stop",
|
||||
interruptAgent: "Agent d'interruption",
|
||||
|
||||
@@ -165,7 +165,7 @@ export const ru: TranslationResources = {
|
||||
hints: {
|
||||
thinking: "Режим мышления",
|
||||
model: "Изменить модель",
|
||||
mode: "Изменить режим разрешений",
|
||||
mode: "Изменить режим",
|
||||
},
|
||||
},
|
||||
agentStream: {
|
||||
@@ -1597,6 +1597,7 @@ export const ru: TranslationResources = {
|
||||
toggleFocusMode: "Переключить режим фокусировки",
|
||||
cycleTheme: "Циклическая тема",
|
||||
focusMessageInput: "Фокус ввода сообщения",
|
||||
cycleAgentMode: "Переключить режим агента",
|
||||
toggleVoiceMode: "Переключить голосовой режим",
|
||||
startStopDictation: "Начать диктовку /stop",
|
||||
interruptAgent: "Агент прерываний",
|
||||
|
||||
@@ -165,7 +165,7 @@ export const zhCN: TranslationResources = {
|
||||
hints: {
|
||||
thinking: "Thinking mode",
|
||||
model: "切换 Model",
|
||||
mode: "切换权限 Mode",
|
||||
mode: "更改模式",
|
||||
},
|
||||
},
|
||||
agentStream: {
|
||||
@@ -1548,6 +1548,7 @@ export const zhCN: TranslationResources = {
|
||||
toggleFocusMode: "切换专注模式",
|
||||
cycleTheme: "循环切换主题",
|
||||
focusMessageInput: "聚焦消息输入框",
|
||||
cycleAgentMode: "循环切换代理模式",
|
||||
toggleVoiceMode: "切换语音模式",
|
||||
startStopDictation: "开始/停止听写",
|
||||
interruptAgent: "中断 Agent",
|
||||
|
||||
@@ -12,7 +12,8 @@ export type MessageInputKeyboardActionKind =
|
||||
| "dictation-cancel"
|
||||
| "dictation-confirm"
|
||||
| "voice-toggle"
|
||||
| "voice-mute-toggle";
|
||||
| "voice-mute-toggle"
|
||||
| "mode-cycle";
|
||||
|
||||
export type KeyboardActionId =
|
||||
| "agent.interrupt"
|
||||
|
||||
@@ -9,6 +9,7 @@ export type KeyboardActionId =
|
||||
| "message-input.dictation-confirm"
|
||||
| "message-input.voice-toggle"
|
||||
| "message-input.voice-mute-toggle"
|
||||
| "message-input.mode-cycle"
|
||||
| "workspace.tab.new"
|
||||
| "workspace.tab.close-current"
|
||||
| "workspace.tab.navigate-index"
|
||||
@@ -39,6 +40,7 @@ export type KeyboardActionDefinition =
|
||||
| { id: "message-input.dictation-confirm"; scope: KeyboardActionScope }
|
||||
| { id: "message-input.voice-toggle"; scope: KeyboardActionScope }
|
||||
| { id: "message-input.voice-mute-toggle"; scope: KeyboardActionScope }
|
||||
| { id: "message-input.mode-cycle"; scope: KeyboardActionScope }
|
||||
| { id: "workspace.tab.new"; scope: KeyboardActionScope }
|
||||
| { id: "workspace.tab.close-current"; scope: KeyboardActionScope }
|
||||
| { id: "workspace.tab.navigate-index"; scope: KeyboardActionScope; index: number }
|
||||
|
||||
@@ -299,6 +299,13 @@ describe("keyboard-shortcuts", () => {
|
||||
action: "message-input.action",
|
||||
payload: { kind: "dictation-toggle" },
|
||||
},
|
||||
{
|
||||
name: "routes Shift+Tab to cycle agent mode from the message input",
|
||||
event: { key: "Tab", code: "Tab", shiftKey: true },
|
||||
context: { focusScope: "message-input" },
|
||||
action: "message-input.action",
|
||||
payload: { kind: "mode-cycle" },
|
||||
},
|
||||
{
|
||||
name: "routes space to voice mute toggle outside editable scopes",
|
||||
event: { key: " ", code: "Space" },
|
||||
@@ -427,6 +434,16 @@ describe("keyboard-shortcuts", () => {
|
||||
event: { key: "d", code: "KeyD", metaKey: true },
|
||||
context: { isMac: true, focusScope: "terminal" },
|
||||
},
|
||||
{
|
||||
name: "does not cycle agent mode outside the message input",
|
||||
event: { key: "Tab", code: "Tab", shiftKey: true },
|
||||
context: { focusScope: "other" },
|
||||
},
|
||||
{
|
||||
name: "does not repeat agent mode cycling while Shift+Tab is held",
|
||||
event: { key: "Tab", code: "Tab", shiftKey: true, repeat: true },
|
||||
context: { focusScope: "message-input" },
|
||||
},
|
||||
{
|
||||
name: "does not bind Cmd+Enter as a rebindable message queue shortcut",
|
||||
event: { key: "Enter", code: "Enter", metaKey: true },
|
||||
@@ -570,6 +587,7 @@ describe("keyboard-shortcut help sections", () => {
|
||||
"workspace-tab-close-current": ["alt", "shift", "W"],
|
||||
"workspace-pane-split-right": ["mod", "\\"],
|
||||
"workspace-pane-close": ["mod", "shift", "W"],
|
||||
"cycle-agent-mode": ["shift", "Tab"],
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -617,12 +635,14 @@ describe("keyboard-shortcut help sections", () => {
|
||||
const projects = sections.find((section) => section.id === "projects");
|
||||
const panels = sections.find((section) => section.id === "panels");
|
||||
const openProject = findRow(sections, "new-agent");
|
||||
const cycleAgentMode = findRow(sections, "cycle-agent-mode");
|
||||
const showShortcuts = findRow(sections, "show-shortcuts");
|
||||
|
||||
expect(projects?.titleKey).toBe("settings.shortcuts.sections.projects");
|
||||
expect(panels?.titleKey).toBe("settings.shortcuts.sections.panels");
|
||||
expect(openProject?.labelKey).toBe("settings.shortcuts.help.openProject");
|
||||
expect(openProject?.label).toBe("Open project");
|
||||
expect(cycleAgentMode?.labelKey).toBe("settings.shortcuts.help.cycleAgentMode");
|
||||
expect(showShortcuts?.noteKey).toBe("settings.shortcuts.helpNotes.showKeyboardShortcuts");
|
||||
});
|
||||
|
||||
|
||||
@@ -152,6 +152,7 @@ const SHORTCUT_HELP_LABEL_KEYS: Record<string, string> = {
|
||||
"toggle-focus": "settings.shortcuts.help.toggleFocusMode",
|
||||
"cycle-theme": "settings.shortcuts.help.cycleTheme",
|
||||
"focus-message-input": "settings.shortcuts.help.focusMessageInput",
|
||||
"cycle-agent-mode": "settings.shortcuts.help.cycleAgentMode",
|
||||
"voice-toggle": "settings.shortcuts.help.toggleVoiceMode",
|
||||
"dictation-toggle": "settings.shortcuts.help.startStopDictation",
|
||||
"agent-interrupt": "settings.shortcuts.help.interruptAgent",
|
||||
@@ -910,6 +911,20 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
keys: ["mod", "L"],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "message-input-mode-cycle-shift-tab",
|
||||
action: "message-input.action",
|
||||
combo: "Shift+Tab",
|
||||
repeat: false,
|
||||
when: { commandCenter: false, focusScope: "message-input" },
|
||||
payload: { type: "message-input", kind: "mode-cycle" },
|
||||
help: {
|
||||
id: "cycle-agent-mode",
|
||||
section: "agent-input",
|
||||
label: "Cycle agent mode",
|
||||
keys: ["shift", "Tab"],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "message-input-voice-toggle-cmd-shift-d-mac",
|
||||
action: "message-input.action",
|
||||
|
||||
@@ -276,6 +276,7 @@ describe("routeKeyboardShortcut — message-input.action", () => {
|
||||
["dictation-confirm", "message-input.dictation-confirm"],
|
||||
["voice-toggle", "message-input.voice-toggle"],
|
||||
["voice-mute-toggle", "message-input.voice-mute-toggle"],
|
||||
["mode-cycle", "message-input.mode-cycle"],
|
||||
] as const)("kind=%s → dispatch %s", (kind, id) => {
|
||||
expect(
|
||||
routeKeyboardShortcut({ action: "message-input.action", payload: { kind } }, makeCtx()),
|
||||
|
||||
@@ -82,6 +82,7 @@ const MESSAGE_INPUT_DISPATCH: Record<
|
||||
"dictation-confirm": { id: "message-input.dictation-confirm", scope: "message-input" },
|
||||
"voice-toggle": { id: "message-input.voice-toggle", scope: "message-input" },
|
||||
"voice-mute-toggle": { id: "message-input.voice-mute-toggle", scope: "message-input" },
|
||||
"mode-cycle": { id: "message-input.mode-cycle", scope: "message-input" },
|
||||
};
|
||||
|
||||
function hasPayloadKey<K extends "index" | "delta" | "kind">(
|
||||
|
||||
@@ -8,6 +8,12 @@ describe("formatShortcut", () => {
|
||||
expect(formatShortcut(["mod", "E"], "mac")).toBe("⌘E");
|
||||
});
|
||||
|
||||
it("spells out Shift in shortcut labels", () => {
|
||||
expect(formatShortcut(["shift", "Tab"], "mac")).toBe("Shift+Tab");
|
||||
expect(formatShortcut(["mod", "shift", "P"], "mac")).toBe("Shift+⌘+P");
|
||||
expect(formatShortcut(["shift", "Tab"], "non-mac")).toBe("Shift+Tab");
|
||||
});
|
||||
|
||||
it("uses Ctrl+ on non-mac platforms", () => {
|
||||
expect(formatShortcut(["mod", "B"], "non-mac")).toBe("Ctrl+B");
|
||||
expect(formatShortcut(["mod", "E"], "non-mac")).toBe("Ctrl+E");
|
||||
|
||||
@@ -27,18 +27,22 @@ export function formatShortcut(keys: ShortcutKey[], os: ShortcutOs): string {
|
||||
const order = ["ctrl", "alt", "shift", "mod", "meta"];
|
||||
const symbols: Record<string, string> = {
|
||||
mod: "⌘",
|
||||
shift: "⇧",
|
||||
alt: "⌥",
|
||||
ctrl: "⌃",
|
||||
meta: "⌘",
|
||||
};
|
||||
|
||||
const modifierSet = new Set(normalized);
|
||||
const mods = order.filter((k) => modifierSet.has(k)).map((k) => symbols[k] ?? "");
|
||||
const mods = order
|
||||
.filter((k) => modifierSet.has(k))
|
||||
.map((k) => (k === "shift" ? "Shift" : (symbols[k] ?? "")));
|
||||
const main = normalized
|
||||
.filter((k) => !order.includes(k))
|
||||
.map(normalizeKey)
|
||||
.join("");
|
||||
if (mods.includes("Shift")) {
|
||||
return [...mods, main].filter(Boolean).join("+");
|
||||
}
|
||||
return `${mods.join("")}${main}`;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user