diff --git a/apps/desktop-tauri/src/components/ProviderGrid.test.tsx b/apps/desktop-tauri/src/components/ProviderGrid.test.tsx new file mode 100644 index 0000000000..764a779ed7 --- /dev/null +++ b/apps/desktop-tauri/src/components/ProviderGrid.test.tsx @@ -0,0 +1,43 @@ +import { render } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import ProviderGrid from "./ProviderGrid"; +import { providerPlaceholder } from "../lib/trayProviders"; + +vi.mock("../hooks/useLocale", () => ({ + useLocale: () => ({ t: (key: string) => key, language: "english" }), +})); + +const providers = [ + providerPlaceholder("codex", "Codex"), + providerPlaceholder("claude", "Claude"), +]; + +function grid(selectedProviderId: string | null) { + return ( + {}} + /> + ); +} + +afterEach(() => { + delete (Element.prototype as Partial).scrollIntoView; +}); + +describe("ProviderGrid", () => { + it("scrolls the active item into view when the selection changes", () => { + const scrollIntoView = vi.fn(); + Element.prototype.scrollIntoView = scrollIntoView; + const { rerender } = render(grid(null)); + scrollIntoView.mockClear(); + rerender(grid("claude")); + expect(scrollIntoView).toHaveBeenCalledTimes(1); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", inline: "nearest" }); + expect(scrollIntoView.mock.contexts[0]).toBe( + document.querySelector(".provider-grid__item--active"), + ); + }); +}); diff --git a/apps/desktop-tauri/src/components/ProviderGrid.tsx b/apps/desktop-tauri/src/components/ProviderGrid.tsx index 7fe353dc7e..32bbf60743 100644 --- a/apps/desktop-tauri/src/components/ProviderGrid.tsx +++ b/apps/desktop-tauri/src/components/ProviderGrid.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState, type CSSProperties } from "react"; +import { useEffect, useMemo, useRef, useState, type CSSProperties } from "react"; import type { ProviderUsageSnapshot } from "../types/bridge"; import { ProviderIcon } from "./providers/ProviderIcon"; import { getProviderIcon } from "./providers/providerIcons"; @@ -36,6 +36,15 @@ export default function ProviderGrid({ const [dragId, setDragId] = useState(null); const [overId, setOverId] = useState(null); const canReorder = typeof onReorder === "function"; + const gridRef = useRef(null); + + // Keep the selected item visible when it changes (keyboard switching in the + // scrollable dense grid); this never expands a collapsed grid. + useEffect(() => { + gridRef.current + ?.querySelector(".provider-grid__item--active") + ?.scrollIntoView?.({ block: "nearest", inline: "nearest" }); + }, [selectedProviderId]); const applyReorder = (targetId: string) => { if (!onReorder || !dragId || dragId === targetId) return; @@ -85,6 +94,7 @@ export default function ProviderGrid({ return (
+ useProviderSwitcherKeys({ + providerIds: IDS, + selectedProviderId: props.selected, + onSelect, + }), + { initialProps: { selected: selectedProviderId } }, + ); + return { onSelect, ...view }; +} + +afterEach(() => { + document.body.replaceChildren(); +}); + +describe("useProviderSwitcherKeys", () => { + it("moves with Right and Left, wrapping through Overview", () => { + const { onSelect, rerender } = setup(null); + fireEvent.keyDown(window, { key: "ArrowRight", code: "ArrowRight" }); + expect(onSelect).toHaveBeenLastCalledWith("codex"); + + rerender({ selected: "gemini" }); + fireEvent.keyDown(window, { key: "ArrowRight", code: "ArrowRight" }); + expect(onSelect).toHaveBeenLastCalledWith(null); + + rerender({ selected: null }); + fireEvent.keyDown(window, { key: "ArrowLeft", code: "ArrowLeft" }); + expect(onSelect).toHaveBeenLastCalledWith("gemini"); + }); + + it("selects the Nth segment with Ctrl+digit, Overview first", () => { + const { onSelect } = setup("codex"); + fireEvent.keyDown(window, { key: "1", code: "Digit1", ctrlKey: true }); + expect(onSelect).toHaveBeenLastCalledWith(null); + fireEvent.keyDown(window, { key: "3", code: "Digit3", ctrlKey: true }); + expect(onSelect).toHaveBeenLastCalledWith("claude"); + }); + + it("prevents default only when it handles the key", () => { + setup(null); + expect(fireEvent.keyDown(window, { key: "ArrowRight", code: "ArrowRight" })).toBe(false); + expect(fireEvent.keyDown(window, { key: "9", code: "Digit9", ctrlKey: true })).toBe(true); + expect(fireEvent.keyDown(window, { key: "x", code: "KeyX" })).toBe(true); + }); + + it("ignores keys typed into inputs and sliders", () => { + const { onSelect } = setup(null); + const input = document.createElement("input"); + input.type = "range"; + document.body.append(input); + fireEvent.keyDown(input, { key: "ArrowRight", code: "ArrowRight" }); + const field = document.createElement("input"); + document.body.append(field); + fireEvent.keyDown(field, { key: "ArrowLeft", code: "ArrowLeft" }); + expect(onSelect).not.toHaveBeenCalled(); + }); + + it("ignores keys while a grid drag is active", () => { + const { onSelect } = setup(null); + const dragging = document.createElement("button"); + dragging.className = "provider-grid__item provider-grid__item--dragging"; + document.body.append(dragging); + fireEvent.keyDown(window, { key: "ArrowRight", code: "ArrowRight" }); + expect(onSelect).not.toHaveBeenCalled(); + }); + + it("ignores events another handler already consumed", () => { + const { onSelect } = setup(null); + const consume = (event: KeyboardEvent) => event.preventDefault(); + document.addEventListener("keydown", consume); + fireEvent.keyDown(document.body, { key: "ArrowRight", code: "ArrowRight" }); + document.removeEventListener("keydown", consume); + expect(onSelect).not.toHaveBeenCalled(); + }); + + it("stops listening after unmount", () => { + const { onSelect, unmount } = setup(null); + unmount(); + fireEvent.keyDown(window, { key: "ArrowRight", code: "ArrowRight" }); + expect(onSelect).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/desktop-tauri/src/hooks/useProviderSwitcherKeys.ts b/apps/desktop-tauri/src/hooks/useProviderSwitcherKeys.ts new file mode 100644 index 0000000000..55fa3902e2 --- /dev/null +++ b/apps/desktop-tauri/src/hooks/useProviderSwitcherKeys.ts @@ -0,0 +1,47 @@ +import { useEffect, useRef } from "react"; +import { matchSwitcherAction, resolveSwitcherTarget } from "../lib/switcherShortcuts"; + +const EDITABLE_SELECTOR = + 'input, textarea, select, [contenteditable=""], [contenteditable="true"], [role="slider"]'; +const DRAGGING_SELECTOR = ".provider-grid__item--dragging"; + +/** + * Provider-switcher keyboard navigation shared by the tray flyout and the + * pop-out window. `providerIds` must be the list the grid displays, in + * display order. Keys are ignored while focus is in a text field, select or + * slider (the zoom slider uses the arrow keys) or while a grid drag is active. + */ +export function useProviderSwitcherKeys({ + providerIds, + selectedProviderId, + onSelect, +}: { + providerIds: readonly string[]; + selectedProviderId: string | null; + onSelect: (providerId: string | null) => void; +}) { + const latest = useRef({ providerIds, selectedProviderId, onSelect }); + latest.current = { providerIds, selectedProviderId, onSelect }; + + useEffect(() => { + const handler = (event: KeyboardEvent) => { + if (event.defaultPrevented) return; + const target = event.target; + if (target instanceof Element && target.closest(EDITABLE_SELECTOR)) return; + if (document.querySelector(DRAGGING_SELECTOR)) return; + const current = latest.current; + const action = matchSwitcherAction(event); + if (action === null) return; + const next = resolveSwitcherTarget( + action, + current.providerIds, + current.selectedProviderId, + ); + if (next === null) return; + event.preventDefault(); + current.onSelect(next.providerId); + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, []); +} diff --git a/apps/desktop-tauri/src/hooks/useTrayPanelController.ts b/apps/desktop-tauri/src/hooks/useTrayPanelController.ts index c47a506e32..c259fb0860 100644 --- a/apps/desktop-tauri/src/hooks/useTrayPanelController.ts +++ b/apps/desktop-tauri/src/hooks/useTrayPanelController.ts @@ -17,6 +17,7 @@ import { useProviders } from "./useProviders"; import { useSettings } from "./useSettings"; import { useUpdateState } from "./useUpdateState"; import { useLocale } from "./useLocale"; +import { useProviderSwitcherKeys } from "./useProviderSwitcherKeys"; import { useSurfaceTarget } from "./useSurfaceMode"; import { useTrayPanelLayout } from "./useTrayPanelLayout"; import type { MenuFooterRow } from "../components/MenuSurface"; @@ -150,6 +151,10 @@ export function useTrayPanelController(state: BootstrapState) { return hydrateProviderSlots(denseProviderSlots, providersById); }, [denseProviderSlots, expectsDenseOverview, providersById, sorted]); + // What the switcher grid displays: the dense overview shows hydrated slots + // (with placeholders); everything else shows the sorted providers. + const gridProviders = expectsDenseOverview ? denseTrayProviders : sorted; + useEffect(() => { setSelectedProviderId(initialProviderId); }, [initialProviderId]); @@ -345,6 +350,15 @@ export function useTrayPanelController(state: BootstrapState) { }, [], ); + const gridProviderIds = useMemo( + () => gridProviders.map((provider) => provider.providerId), + [gridProviders], + ); + useProviderSwitcherKeys({ + providerIds: gridProviderIds, + selectedProviderId, + onSelect: handleGridClick, + }); const handleReorder = useCallback((orderedIds: string[]) => { void reorderProviders(orderedIds).catch(() => {}); }, []); @@ -369,8 +383,7 @@ export function useTrayPanelController(state: BootstrapState) { trayScaleFillPercent, handleTrayScaleChange, sorted, - denseTrayProviders, - expectsDenseOverview, + gridProviders, selectedProviderId, gridExpanded, setGridExpanded, diff --git a/apps/desktop-tauri/src/lib/switcherShortcuts.test.ts b/apps/desktop-tauri/src/lib/switcherShortcuts.test.ts new file mode 100644 index 0000000000..30c46fc1dc --- /dev/null +++ b/apps/desktop-tauri/src/lib/switcherShortcuts.test.ts @@ -0,0 +1,199 @@ +import { describe, expect, it } from "vitest"; +import { + DEFAULT_SWITCHER_SHORTCUTS, + SWITCHER_ACTIONS, + SwitcherShortcutError, + matchSwitcherAction, + normalizeShortcut, + resolveSwitcherTarget, + shortcutFromEvent, + validateSwitcherShortcuts, + type SwitcherKeyEvent, +} from "./switcherShortcuts"; + +function codeOf(error: unknown): string | undefined { + return error instanceof SwitcherShortcutError ? error.code : undefined; +} + +function failure(run: () => unknown): string | undefined { + try { + run(); + } catch (error) { + return codeOf(error); + } + return undefined; +} + +function keyEvent(init: Partial): SwitcherKeyEvent { + return { + key: "", + code: "", + ctrlKey: false, + altKey: false, + shiftKey: false, + metaKey: false, + ...init, + }; +} + +describe("defaults", () => { + it("cover every action with upstream keys (cmd becomes ctrl)", () => { + expect(Object.keys(DEFAULT_SWITCHER_SHORTCUTS)).toEqual([...SWITCHER_ACTIONS]); + expect(DEFAULT_SWITCHER_SHORTCUTS.previous).toBe("left"); + expect(DEFAULT_SWITCHER_SHORTCUTS.next).toBe("right"); + expect(DEFAULT_SWITCHER_SHORTCUTS.select9).toBe("ctrl+9"); + }); + + it("are valid and unchanged by validation", () => { + expect(validateSwitcherShortcuts()).toEqual(DEFAULT_SWITCHER_SHORTCUTS); + expect(validateSwitcherShortcuts({})).toEqual(DEFAULT_SWITCHER_SHORTCUTS); + }); +}); + +describe("normalizeShortcut", () => { + it("orders modifiers canonically and lowercases", () => { + expect(normalizeShortcut("Shift+Ctrl+Right")).toBe("ctrl+shift+right"); + expect(normalizeShortcut(" alt + ctrl + 2 ")).toBe("ctrl+alt+2"); + expect(normalizeShortcut("Alt+,")).toBe("alt+,"); + }); + + it("aliases cmd to ctrl", () => { + expect(normalizeShortcut("alt+cmd+2")).toBe("ctrl+alt+2"); + expect(normalizeShortcut("cmd+3")).toBe("ctrl+3"); + }); + + it("accepts none", () => { + expect(normalizeShortcut("NONE")).toBe("none"); + }); + + it("rejects malformed shortcuts", () => { + for (const bad of ["", "ctrl+", "ctrl+ab", "ctrl+ctrl+1", "ctrl+cmd+1", "meta+1", "ctrl+f1", "ctrl+up", "alt+f4", "alt+tab", "ctrl+none"]) { + expect(failure(() => normalizeShortcut(bad)), bad).toBe("invalid"); + } + }); + + it("rejects reserved Windows shortcuts", () => { + for (const reserved of ["ctrl+r", "cmd+q", "ctrl+,", "ctrl+w"]) { + expect(failure(() => normalizeShortcut(reserved)), reserved).toBe("reserved"); + } + }); + + it("requires a non-shift modifier for letters, digits and comma", () => { + for (const bare of ["a", "1", ",", "shift+a", "shift+1"]) { + expect(failure(() => normalizeShortcut(bare)), bare).toBe("reserved"); + } + expect(normalizeShortcut("alt+a")).toBe("alt+a"); + expect(normalizeShortcut("ctrl+shift+1")).toBe("ctrl+shift+1"); + expect(normalizeShortcut("shift+ctrl+r")).toBe("ctrl+shift+r"); + }); + + it("allows arrows with any or no modifiers", () => { + expect(normalizeShortcut("left")).toBe("left"); + expect(normalizeShortcut("shift+right")).toBe("shift+right"); + }); +}); + +describe("validateSwitcherShortcuts", () => { + it("overlays overrides and normalizes them", () => { + const resolved = validateSwitcherShortcuts({ select2: "alt+cmd+2", next: "shift+right" }); + expect(resolved.select2).toBe("ctrl+alt+2"); + expect(resolved.next).toBe("shift+right"); + expect(resolved.previous).toBe("left"); + }); + + it("rejects unknown actions", () => { + expect(failure(() => validateSwitcherShortcuts({ select10: "ctrl+0" }))).toBe("unknown"); + }); + + it("rejects duplicates, including against untouched defaults", () => { + expect(failure(() => validateSwitcherShortcuts({ next: "left" }))).toBe("duplicate"); + expect(failure(() => validateSwitcherShortcuts({ select1: "cmd+2" }))).toBe("duplicate"); + }); + + it("lets none disable an action and free its key", () => { + const resolved = validateSwitcherShortcuts({ previous: "none", next: "none" }); + expect(resolved.previous).toBe("none"); + expect(validateSwitcherShortcuts({ previous: "none", next: "left" }).next).toBe("left"); + }); + + it("allows several disabled actions at once", () => { + expect(() => validateSwitcherShortcuts({ select1: "none", select2: "none" })).not.toThrow(); + }); +}); + +describe("shortcutFromEvent", () => { + it("maps arrows and modifiers", () => { + expect(shortcutFromEvent(keyEvent({ key: "ArrowLeft", code: "ArrowLeft" }))).toBe("left"); + expect( + shortcutFromEvent(keyEvent({ key: "ArrowRight", code: "ArrowRight", ctrlKey: true, shiftKey: true })), + ).toBe("ctrl+shift+right"); + }); + + it("uses the physical key so shifted digits stay digits", () => { + expect(shortcutFromEvent(keyEvent({ key: "!", code: "Digit1", ctrlKey: true, shiftKey: true }))).toBe( + "ctrl+shift+1", + ); + expect(shortcutFromEvent(keyEvent({ key: "A", code: "KeyA", altKey: true }))).toBe("alt+a"); + }); + + it("maps comma and numpad digits via key", () => { + expect(shortcutFromEvent(keyEvent({ key: ",", code: "Comma", ctrlKey: true }))).toBe("ctrl+,"); + expect(shortcutFromEvent(keyEvent({ key: "3", code: "Numpad3", ctrlKey: true }))).toBe("ctrl+3"); + }); + + it("returns null for the Windows key and unsupported keys", () => { + expect(shortcutFromEvent(keyEvent({ key: "1", code: "Digit1", metaKey: true }))).toBeNull(); + expect(shortcutFromEvent(keyEvent({ key: "Control", code: "ControlLeft", ctrlKey: true }))).toBeNull(); + expect(shortcutFromEvent(keyEvent({ key: "ArrowUp", code: "ArrowUp" }))).toBeNull(); + expect(shortcutFromEvent(keyEvent({ key: "F4", code: "F4", altKey: true }))).toBeNull(); + }); +}); + +describe("matchSwitcherAction", () => { + it("matches defaults", () => { + expect(matchSwitcherAction(keyEvent({ key: "ArrowLeft", code: "ArrowLeft" }))).toBe("previous"); + expect(matchSwitcherAction(keyEvent({ key: "ArrowRight", code: "ArrowRight" }))).toBe("next"); + expect(matchSwitcherAction(keyEvent({ key: "3", code: "Digit3", ctrlKey: true }))).toBe("select3"); + }); + + it("does not match extra or missing modifiers", () => { + expect(matchSwitcherAction(keyEvent({ key: "ArrowLeft", code: "ArrowLeft", shiftKey: true }))).toBeNull(); + expect(matchSwitcherAction(keyEvent({ key: "3", code: "Digit3" }))).toBeNull(); + expect(matchSwitcherAction(keyEvent({ key: "3", code: "Digit3", ctrlKey: true, altKey: true }))).toBeNull(); + }); + + it("honors a custom mapping and disabled actions", () => { + const mapping = validateSwitcherShortcuts({ previous: "none", next: "shift+right" }); + expect(matchSwitcherAction(keyEvent({ key: "ArrowLeft", code: "ArrowLeft" }), mapping)).toBeNull(); + expect( + matchSwitcherAction(keyEvent({ key: "ArrowRight", code: "ArrowRight", shiftKey: true }), mapping), + ).toBe("next"); + }); +}); + +describe("resolveSwitcherTarget", () => { + const ids = ["codex", "claude", "gemini"]; + + it("moves Overview -> providers -> Overview with wraparound", () => { + expect(resolveSwitcherTarget("next", ids, null)).toEqual({ providerId: "codex" }); + expect(resolveSwitcherTarget("next", ids, "gemini")).toEqual({ providerId: null }); + expect(resolveSwitcherTarget("previous", ids, null)).toEqual({ providerId: "gemini" }); + expect(resolveSwitcherTarget("previous", ids, "codex")).toEqual({ providerId: null }); + }); + + it("treats an unknown selection as Overview", () => { + expect(resolveSwitcherTarget("next", ids, "gone")).toEqual({ providerId: "codex" }); + }); + + it("does nothing when there are no providers", () => { + expect(resolveSwitcherTarget("next", [], null)).toBeNull(); + expect(resolveSwitcherTarget("previous", [], null)).toBeNull(); + }); + + it("selectN counts Overview as 1 and ignores positions past the end", () => { + expect(resolveSwitcherTarget("select1", ids, "codex")).toEqual({ providerId: null }); + expect(resolveSwitcherTarget("select2", ids, null)).toEqual({ providerId: "codex" }); + expect(resolveSwitcherTarget("select4", ids, null)).toEqual({ providerId: "gemini" }); + expect(resolveSwitcherTarget("select5", ids, null)).toBeNull(); + }); +}); diff --git a/apps/desktop-tauri/src/lib/switcherShortcuts.ts b/apps/desktop-tauri/src/lib/switcherShortcuts.ts new file mode 100644 index 0000000000..7085355f54 --- /dev/null +++ b/apps/desktop-tauri/src/lib/switcherShortcuts.ts @@ -0,0 +1,194 @@ +/** + * Provider-switcher keyboard shortcuts (upstream CodexBar 0.67.0, + * `ProviderSwitcherShortcuts`). Shortcuts are menu-local strings such as + * `left`, `ctrl+3` or `ctrl+shift+right`; `none` disables an action. + * + * Grammar is upstream's, with one Windows difference: `cmd` is accepted as an + * alias for `ctrl` and normalized to `ctrl`, so documents exported from macOS + * stay portable. Windows reserves `ctrl+r`, `ctrl+q`, `ctrl+,` and `ctrl+w` + * (the tray/pop-out commands). `alt+f4` and `alt+tab` need no entry: F4 and + * Tab are outside the grammar, so they are rejected as invalid. + */ + +export const SWITCHER_ACTIONS = [ + "previous", + "next", + "select1", + "select2", + "select3", + "select4", + "select5", + "select6", + "select7", + "select8", + "select9", +] as const; + +export type SwitcherAction = (typeof SWITCHER_ACTIONS)[number]; +export type SwitcherShortcutMap = Record; + +export const SWITCHER_SHORTCUT_NONE = "none"; + +export const DEFAULT_SWITCHER_SHORTCUTS: Readonly = { + previous: "left", + next: "right", + select1: "ctrl+1", + select2: "ctrl+2", + select3: "ctrl+3", + select4: "ctrl+4", + select5: "ctrl+5", + select6: "ctrl+6", + select7: "ctrl+7", + select8: "ctrl+8", + select9: "ctrl+9", +}; + +export type SwitcherShortcutErrorCode = + | "unknown" + | "duplicate" + | "reserved" + | "invalid"; + +/** `code` selects the localized message shown by the editor. */ +export class SwitcherShortcutError extends Error { + readonly code: SwitcherShortcutErrorCode; + + constructor(code: SwitcherShortcutErrorCode) { + super(`switcher shortcut error: ${code}`); + this.name = "SwitcherShortcutError"; + this.code = code; + } +} + +const MODIFIER_ORDER = ["ctrl", "alt", "shift"] as const; +const NAMED_KEYS = new Set(["left", "right", ","]); +const RESERVED = new Set(["ctrl+r", "ctrl+q", "ctrl+,", "ctrl+w"]); + +function isSwitcherAction(value: string): value is SwitcherAction { + return (SWITCHER_ACTIONS as readonly string[]).includes(value); +} + +/** Canonicalize one shortcut string, or throw `SwitcherShortcutError`. */ +export function normalizeShortcut(shortcut: string): string { + const parts = shortcut + .toLowerCase() + .split("+") + .map((part) => part.trim()); + if (parts.length === 1 && parts[0] === SWITCHER_SHORTCUT_NONE) { + return SWITCHER_SHORTCUT_NONE; + } + const key = parts[parts.length - 1]; + const modifiers = parts + .slice(0, -1) + .map((part) => (part === "cmd" ? "ctrl" : part)); + if ( + new Set(modifiers).size !== modifiers.length || + !modifiers.every((part) => + (MODIFIER_ORDER as readonly string[]).includes(part), + ) || + !(NAMED_KEYS.has(key) || /^[a-z0-9]$/.test(key)) + ) { + throw new SwitcherShortcutError("invalid"); + } + const result = [ + ...MODIFIER_ORDER.filter((part) => modifiers.includes(part)), + key, + ].join("+"); + const isArrow = key === "left" || key === "right"; + if ( + RESERVED.has(result) || + (!isArrow && modifiers.every((part) => part === "shift")) + ) { + throw new SwitcherShortcutError("reserved"); + } + return result; +} + +/** + * Overlay `overrides` on the defaults and validate the result: unknown + * actions and duplicate assignments are rejected; omitted actions keep their + * default. Returns the fully resolved, normalized map. + */ +export function validateSwitcherShortcuts( + overrides: Readonly> = {}, +): SwitcherShortcutMap { + const result: SwitcherShortcutMap = { ...DEFAULT_SWITCHER_SHORTCUTS }; + for (const [action, shortcut] of Object.entries(overrides)) { + if (!isSwitcherAction(action)) throw new SwitcherShortcutError("unknown"); + result[action] = normalizeShortcut(shortcut); + } + const assigned = Object.values(result).filter( + (value) => value !== SWITCHER_SHORTCUT_NONE, + ); + if (new Set(assigned).size !== assigned.length) { + throw new SwitcherShortcutError("duplicate"); + } + return result; +} + +export type SwitcherKeyEvent = Pick< + KeyboardEvent, + "key" | "code" | "ctrlKey" | "altKey" | "shiftKey" | "metaKey" +>; + +/** + * Canonical shortcut string for a key event, or null when the event cannot + * be expressed in the grammar (bare modifier, Windows key, punctuation other + * than `,`, ...). Letters and digits come from `code` so Shift+1 stays `1`. + */ +export function shortcutFromEvent(event: SwitcherKeyEvent): string | null { + if (event.metaKey) return null; + let key: string | undefined = /^(?:Digit|Key)([0-9A-Z])$/ + .exec(event.code)?.[1] + ?.toLowerCase(); + if (key === undefined) { + if (event.key === "ArrowLeft") key = "left"; + else if (event.key === "ArrowRight") key = "right"; + else if (/^[a-z0-9,]$/i.test(event.key)) key = event.key.toLowerCase(); + } + if (key === undefined) return null; + const modifiers = [ + event.ctrlKey && "ctrl", + event.altKey && "alt", + event.shiftKey && "shift", + ].filter((part): part is string => part !== false); + return [...modifiers, key].join("+"); +} + +/** The action bound to this key event under `mapping`, or null. */ +export function matchSwitcherAction( + event: SwitcherKeyEvent, + mapping: Readonly = DEFAULT_SWITCHER_SHORTCUTS, +): SwitcherAction | null { + const shortcut = shortcutFromEvent(event); + if (shortcut === null) return null; + return SWITCHER_ACTIONS.find((action) => mapping[action] === shortcut) ?? null; +} + +/** Result of resolving an action; `providerId: null` means Overview. */ +export interface SwitcherTarget { + providerId: string | null; +} + +/** + * Resolve `action` against the switcher order (Overview first, then + * `providerIds` in display order). previous/next wrap around; `selectN` is + * the Nth segment with Overview as 1. Returns null when there is no target + * (N past the last segment, or nothing to switch to). + */ +export function resolveSwitcherTarget( + action: SwitcherAction, + providerIds: readonly string[], + selectedProviderId: string | null, +): SwitcherTarget | null { + const segments: (string | null)[] = [null, ...providerIds]; + if (action === "previous" || action === "next") { + if (segments.length < 2) return null; + const current = Math.max(0, segments.indexOf(selectedProviderId)); + const step = action === "next" ? 1 : -1; + const index = (current + step + segments.length) % segments.length; + return { providerId: segments[index] }; + } + const index = Number(action.slice("select".length)) - 1; + return index < segments.length ? { providerId: segments[index] } : null; +} diff --git a/apps/desktop-tauri/src/surfaces/PopOutPanel.tsx b/apps/desktop-tauri/src/surfaces/PopOutPanel.tsx index 824f359716..cc8885a167 100644 --- a/apps/desktop-tauri/src/surfaces/PopOutPanel.tsx +++ b/apps/desktop-tauri/src/surfaces/PopOutPanel.tsx @@ -6,6 +6,7 @@ import { useProviders } from "../hooks/useProviders"; import { useSettings } from "../hooks/useSettings"; import { useUpdateState } from "../hooks/useUpdateState"; import { useLocale } from "../hooks/useLocale"; +import { useProviderSwitcherKeys } from "../hooks/useProviderSwitcherKeys"; import MenuCard from "../components/MenuCard"; import PopOutTitleBar from "../components/PopOutTitleBar"; import MenuSurface, { @@ -89,14 +90,23 @@ export default function PopOutPanel({ }, [sorted, selectedProviderId, gridExpanded], ); - const providerOrderKey = useMemo( - () => sorted.map((provider) => provider.providerId).join(","), + const sortedProviderIds = useMemo( + () => sorted.map((provider) => provider.providerId), [sorted], ); + const providerOrderKey = useMemo( + () => sortedProviderIds.join(","), + [sortedProviderIds], + ); const handleGridClick = useCallback((nextProviderId: string | null) => { setSelectedProviderId(nextProviderId); }, []); + useProviderSwitcherKeys({ + providerIds: sortedProviderIds, + selectedProviderId, + onSelect: handleGridClick, + }); const handleReorder = useCallback((orderedIds: string[]) => { void reorderProviders(orderedIds).catch(() => {}); }, []); diff --git a/apps/desktop-tauri/src/surfaces/TrayPanel.tsx b/apps/desktop-tauri/src/surfaces/TrayPanel.tsx index ccc2e8fe2f..05a7759acb 100644 --- a/apps/desktop-tauri/src/surfaces/TrayPanel.tsx +++ b/apps/desktop-tauri/src/surfaces/TrayPanel.tsx @@ -64,8 +64,7 @@ export default function TrayPanel({ state }: { state: BootstrapState }) { trayScaleFillPercent, handleTrayScaleChange, sorted, - denseTrayProviders, - expectsDenseOverview, + gridProviders, selectedProviderId, gridExpanded, setGridExpanded, @@ -193,7 +192,7 @@ export default function TrayPanel({ state }: { state: BootstrapState }) { > {settings.agentSessionsEnabled && }