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