Refine shortcut settings UX (#2787)

* Compact shortcut row actions

* Redesign shortcuts settings controls

* Refine shortcuts settings UX

* Polish shortcut settings interactions
This commit is contained in:
Neil
2026-05-25 11:36:53 -07:00
committed by GitHub
parent 503c2044e3
commit 6744f19a49
13 changed files with 706 additions and 292 deletions
@@ -22,12 +22,14 @@ import {
GENERAL_CACHE_TIMER_SEARCH_ENTRIES,
GENERAL_CLI_SEARCH_ENTRIES,
GENERAL_EDITOR_SEARCH_ENTRIES,
GENERAL_NAVIGATION_SEARCH_ENTRIES,
GENERAL_PANE_SEARCH_ENTRIES,
GENERAL_SUPPORT_SEARCH_ENTRIES,
GENERAL_UPDATE_SEARCH_ENTRIES,
GENERAL_WORKSPACE_SEARCH_ENTRIES
} from './general-search'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
import { RecentTabOrderControl } from './RecentTabOrderControl'
import { SearchableSetting } from './SearchableSetting'
import { matchesSettingsSearch } from './settings-search'
import {
@@ -220,6 +222,20 @@ export function GeneralPane({ settings, updateSettings }: GeneralPaneProps): Rea
}
const visibleSections = [
matchesSettingsSearch(searchQuery, GENERAL_NAVIGATION_SEARCH_ENTRIES) ? (
<section key="navigation" className="space-y-4">
<SettingsSubsectionHeader title="Navigation" />
<RecentTabOrderControl
ctrlTabOrderMode={settings.ctrlTabOrderMode ?? 'mru'}
keywords={GENERAL_NAVIGATION_SEARCH_ENTRIES.flatMap((entry) => [
entry.title,
entry.description ?? '',
...(entry.keywords ?? [])
])}
updateSettings={updateSettings}
/>
</section>
) : null,
matchesSettingsSearch(searchQuery, GENERAL_WORKSPACE_SEARCH_ENTRIES) ? (
<section key="workspace" className="space-y-4">
<SettingsSubsectionHeader
@@ -0,0 +1,44 @@
import React from 'react'
import type { CtrlTabOrderMode } from '../../../../shared/types'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
import { SearchableSetting } from './SearchableSetting'
import { SettingsRow } from './SettingsFormControls'
export function RecentTabOrderControl({
ctrlTabOrderMode,
keywords,
updateSettings
}: {
ctrlTabOrderMode: CtrlTabOrderMode
keywords?: string[]
updateSettings: (updates: { ctrlTabOrderMode?: CtrlTabOrderMode }) => Promise<void> | void
}): React.JSX.Element {
return (
<SearchableSetting
title="Tab Order"
description="Recent or tab strip."
keywords={keywords}
className="max-w-none"
>
<SettingsRow
label="Tab Order"
control={
<Select
value={ctrlTabOrderMode}
onValueChange={(value) =>
void updateSettings({ ctrlTabOrderMode: value as CtrlTabOrderMode })
}
>
<SelectTrigger className="w-[180px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="mru">Most recent</SelectItem>
<SelectItem value="sequential">Tab strip order</SelectItem>
</SelectContent>
</Select>
}
/>
</SearchableSetting>
)
}
@@ -1,5 +1,6 @@
/* eslint-disable max-lines */
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { toast } from 'sonner'
import { Info } from 'lucide-react'
import type { OrcaHooks } from '../../../../shared/types'
import { isFolderRepo } from '../../../../shared/repo-kind'
@@ -43,6 +44,7 @@ import { PrivacyPane } from './PrivacyPane'
import { SettingsSidebar } from './SettingsSidebar'
import { ActiveSettingsSectionProvider, SettingsSection } from './SettingsSection'
import { matchesSettingsSearch } from './settings-search'
import { cn } from '@/lib/utils'
import { checkRuntimeHooks } from '@/runtime/runtime-hooks-client'
import { useWindowsTerminalCapabilities } from '@/lib/windows-terminal-capabilities'
import { getShortcutPlatform } from '@/lib/shortcut-platform'
@@ -73,6 +75,9 @@ const SETTINGS_NAV_GROUPS = [
{ id: 'experimental', title: 'Experimental' }
] as const
const SHORTCUTS_ESCAPE_CONFIRM_TOAST_ID = 'shortcuts-escape-confirm'
const SHORTCUTS_ESCAPE_CONFIRM_WINDOW_MS = 2200
function getSettingsSectionId(pane: SettingsNavTarget, repoId: string | null): string {
if (pane === 'repo' && repoId) {
return `repo-${repoId}`
@@ -195,6 +200,7 @@ function Settings(): React.JSX.Element {
const pendingScrollTargetRef = useRef<string | null>(null)
const repoHooksRequestSeqRef = useRef(0)
const repoHooksRuntimeIdentityRef = useRef<string>('local')
const shortcutsEscapeConfirmUntilRef = useRef(0)
const confirmDiscardCommitPromptChanges = useCallback(async (): Promise<boolean> => {
if (!hasUnsavedCommitPromptChanges) {
@@ -263,12 +269,29 @@ function Settings(): React.JSX.Element {
if (isEditableTarget(event.target)) {
return
}
if (activeSectionId === 'shortcuts') {
event.preventDefault()
const now = Date.now()
if (now <= shortcutsEscapeConfirmUntilRef.current) {
shortcutsEscapeConfirmUntilRef.current = 0
toast.dismiss(SHORTCUTS_ESCAPE_CONFIRM_TOAST_ID)
void closeSettingsPageWithPromptGuard()
return
}
shortcutsEscapeConfirmUntilRef.current = now + SHORTCUTS_ESCAPE_CONFIRM_WINDOW_MS
toast.info('Press ESC again to exit settings', {
id: SHORTCUTS_ESCAPE_CONFIRM_TOAST_ID,
duration: SHORTCUTS_ESCAPE_CONFIRM_WINDOW_MS,
className: 'whitespace-nowrap'
})
return
}
void closeSettingsPageWithPromptGuard()
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [closeSettingsPageWithPromptGuard])
}, [activeSectionId, closeSettingsPageWithPromptGuard])
useEffect(() => {
const handleBeforeUnload = (event: BeforeUnloadEvent): void => {
@@ -655,6 +678,8 @@ function Settings(): React.JSX.Element {
return { ...section, badgeColor: repo?.badgeColor, isRemote: !!repo?.connectionId }
})
const isSectionMounted = (sectionId: string): boolean => neededSectionIds.has(sectionId)
const isFocusedShortcutsPane =
activeSectionId === 'shortcuts' && settingsSearchQuery.trim() === ''
return (
<div className="settings-view-shell flex min-h-0 flex-1 overflow-hidden bg-background">
@@ -671,8 +696,19 @@ function Settings(): React.JSX.Element {
/>
<div className="flex min-h-0 flex-1 flex-col">
<div ref={contentScrollRef} className="min-h-0 flex-1 overflow-y-auto scrollbar-sleek">
<div className="flex w-full max-w-4xl flex-col gap-10 px-8 pb-24 pt-10">
<div
ref={contentScrollRef}
className={cn(
'min-h-0 flex-1',
isFocusedShortcutsPane ? 'overflow-hidden' : 'overflow-y-auto scrollbar-sleek'
)}
>
<div
className={cn(
'flex w-full max-w-4xl flex-col gap-10 px-8 pt-10',
isFocusedShortcutsPane ? 'h-full pb-6' : 'pb-24'
)}
>
{visibleNavSections.length === 0 ? (
<div className="flex min-h-[24rem] items-center justify-center rounded-2xl border border-dashed border-border/60 bg-card/30 text-sm text-muted-foreground">
No settings found for &quot;{settingsSearchQuery.trim()}&quot;
@@ -877,6 +913,14 @@ function Settings(): React.JSX.Element {
title="Shortcuts"
description="Keyboard shortcuts for common actions."
searchEntries={getSectionSearchEntries('shortcuts')}
className={
isFocusedShortcutsPane
? 'flex min-h-0 flex-1 flex-col space-y-0 gap-6'
: undefined
}
bodyClassName={
isFocusedShortcutsPane ? 'min-h-0 flex-1 overflow-hidden' : undefined
}
>
{isSectionMounted('shortcuts') ? <ShortcutsPane /> : null}
</SettingsSection>
@@ -18,6 +18,7 @@ type SettingsSectionProps = {
searchEntries?: SettingsSearchEntry[]
children?: React.ReactNode
className?: string
bodyClassName?: string
badge?: string
badgeAccessory?: React.ReactNode
forceVisible?: boolean
@@ -39,6 +40,7 @@ export function SettingsSection({
searchEntries,
children,
className,
bodyClassName,
badge,
badgeAccessory,
forceVisible = false,
@@ -80,7 +82,12 @@ export function SettingsSection({
{/* Why: body content sits in a visually distinct band — a soft card with
rounded corners and tight inner padding — so each row group reads as
contained inside the section, not as a continuation of the sidebar. */}
<div className="rounded-xl border border-border/40 bg-card/30 px-8 py-7 shadow-[0_1px_0_rgba(0,0,0,0.02)]">
<div
className={cn(
'rounded-xl border border-border/40 bg-card/30 px-8 py-7 shadow-[0_1px_0_rgba(0,0,0,0.02)]',
bodyClassName
)}
>
{children}
</div>
</section>
@@ -10,6 +10,7 @@ import { cn } from '../../lib/utils'
import { ShortcutKeyCombo } from '../ShortcutKeyCombo'
import { Badge } from '../ui/badge'
import { Button } from '../ui/button'
import { HoverCard, HoverCardContent, HoverCardTrigger } from '../ui/hover-card'
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'
import { SearchableSetting } from './SearchableSetting'
@@ -45,17 +46,17 @@ function BindingPreview({
}): React.JSX.Element {
if (bindings.length === 0) {
return (
<div className="flex min-h-7 items-center">
<span className="flex min-h-7 items-center">
<span className="text-xs text-muted-foreground">Unassigned</span>
</div>
</span>
)
}
return (
<div className="flex min-h-7 flex-wrap items-center justify-start gap-1.5">
<span className="flex min-h-7 flex-wrap items-center justify-start gap-1.5">
{bindings.map((binding) => (
<ShortcutKeyCombo key={binding} keys={formatKeybinding(binding, platform)} />
))}
</div>
</span>
)
}
@@ -124,109 +125,132 @@ export function ShortcutBindingRow({
title={item.title}
description={`${groupTitle} shortcut`}
keywords={[...item.searchKeywords]}
className="grid min-h-[54px] grid-cols-1 gap-x-3 rounded-md px-2 py-1.5 transition-colors hover:bg-accent/40 lg:grid-cols-[minmax(0,1.1fr)_minmax(10rem,0.8fr)_10rem_4rem] lg:items-center"
className="group relative grid min-h-[54px] max-w-none grid-cols-1 gap-x-3 rounded-md px-2 py-1.5 transition-colors hover:bg-accent/40 lg:grid-cols-[minmax(0,1fr)_minmax(12rem,auto)] lg:grid-rows-[minmax(1.75rem,auto)_1rem] lg:items-start"
>
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-2">
<span className="truncate text-sm text-foreground">{item.title}</span>
{modified ? (
<Badge variant="outline" className="shrink-0 text-[11px]">
Modified
</Badge>
) : null}
{terminalStatus ? (
<div className="flex min-w-0 items-center gap-2 lg:col-start-1 lg:row-start-1 lg:self-center">
<span className="truncate text-sm text-foreground">{item.title}</span>
{modified ? (
<Badge variant="outline" className="shrink-0 text-[11px]">
Modified
</Badge>
) : null}
{terminalStatus ? (
<Tooltip>
<TooltipTrigger asChild>
<Badge
variant="outline"
className="shrink-0 gap-1 border-border/70 text-[11px] text-muted-foreground"
>
<Terminal className="size-3" />
{terminalStatus.label}
</Badge>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
{terminalStatus.description}
</TooltipContent>
</Tooltip>
) : null}
</div>
<div
className={cn(
'h-[16px] overflow-hidden text-[11px] leading-4 lg:col-start-1 lg:row-start-2',
error ? 'text-destructive' : 'text-muted-foreground'
)}
aria-live="polite"
>
{helperMessage ? <span className="block truncate">{helperMessage}</span> : null}
</div>
<HoverCard openDelay={0} closeDelay={80}>
<HoverCardTrigger asChild>
<button
type="button"
aria-label={`Shortcut actions for ${item.title}`}
className="mt-1 flex min-w-0 items-center rounded-md outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 lg:col-start-2 lg:row-start-1 lg:mt-0 lg:self-center lg:justify-self-end"
>
<BindingPreview bindings={effective} platform={platform} />
</button>
</HoverCardTrigger>
<HoverCardContent
side="right"
align="center"
sideOffset={8}
collisionPadding={12}
className="w-auto max-w-[min(22rem,calc(100vw-2rem))] p-1"
>
<div className="flex items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Badge
variant="outline"
className="shrink-0 gap-1 border-border/70 text-[11px] text-muted-foreground"
<Button
ref={recordButtonRef}
type="button"
variant={recording ? 'secondary' : 'ghost'}
size="icon-sm"
aria-label={
recording
? `Press shortcut keys for ${item.title}. Escape cancels.`
: `Change shortcut for ${item.title}`
}
aria-invalid={Boolean(error)}
aria-pressed={recording}
data-shortcut-recorder=""
data-shortcut-recorder-active={recording ? '' : undefined}
onClick={() => {
if (recording) {
return
}
onStartRecording(item.id)
}}
onKeyDown={handleRecordKeyDown}
className={cn(
'text-muted-foreground hover:text-foreground',
recording &&
'border border-ring bg-accent text-accent-foreground ring-[3px] ring-ring/30'
)}
>
<Terminal className="size-3" />
{terminalStatus.label}
</Badge>
<Keyboard className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
{terminalStatus.description}
{recording ? 'Listening for shortcut' : 'Change shortcut'}
</TooltipContent>
</Tooltip>
) : null}
</div>
<div
className={cn(
'h-[16px] overflow-hidden text-[11px] leading-4',
error ? 'text-destructive' : 'text-muted-foreground'
)}
aria-live="polite"
>
{helperMessage ? <span className="block truncate">{helperMessage}</span> : null}
</div>
</div>
<div className="mt-1 min-w-0 lg:mt-0">
<BindingPreview bindings={effective} platform={platform} />
</div>
<Button
ref={recordButtonRef}
type="button"
variant={recording ? 'secondary' : 'outline'}
size="sm"
aria-invalid={Boolean(error)}
aria-pressed={recording}
data-shortcut-recorder=""
data-shortcut-recorder-active={recording ? '' : undefined}
onClick={() => {
if (recording) {
return
}
onStartRecording(item.id)
}}
onKeyDown={handleRecordKeyDown}
className={cn(
'mt-2 h-8 w-full justify-start px-2.5 text-xs lg:mt-0 lg:w-40',
!recording &&
'border-border/70 bg-background/60 text-foreground/80 shadow-none hover:border-border hover:bg-accent/70 hover:text-foreground dark:text-foreground dark:shadow-xs',
recording && 'border-ring bg-accent text-accent-foreground ring-[3px] ring-ring/30'
)}
>
<Keyboard className="size-3.5" />
<span className="truncate">{recording ? 'Press keys...' : 'Change shortcut'}</span>
</Button>
<div className="mt-2 flex items-center gap-1 lg:mt-0 lg:justify-end">
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={`Disable ${item.title}`}
onClick={() => onDisable(item.id)}
>
<Ban className="size-3" />
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
Disable
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={`Reset ${item.title}`}
onClick={() => onReset(item.id)}
>
<RotateCcw className="size-3" />
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
Reset
</TooltipContent>
</Tooltip>
</div>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={`Disable ${item.title}`}
onClick={() => onDisable(item.id)}
>
<Ban className="size-3" />
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
Disable
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={`Reset ${item.title}`}
onClick={() => onReset(item.id)}
>
<RotateCcw className="size-3" />
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
Reset
</TooltipContent>
</Tooltip>
</div>
</HoverCardContent>
</HoverCard>
</SearchableSetting>
)
}
@@ -0,0 +1,195 @@
import React from 'react'
import { Search, X } from 'lucide-react'
import { formatKeybindingList, type KeybindingDefinition } from '../../../../shared/keybindings'
import { cn } from '../../lib/utils'
import { Button } from '../ui/button'
import { Input } from '../ui/input'
import type { ShortcutTerminalStatus } from './ShortcutBindingRow'
import type { SettingsSearchEntry } from './settings-search'
export type ShortcutFilter = 'all' | 'modified' | 'unassigned' | 'conflicts'
export type ShortcutRowModel = {
item: KeybindingDefinition
groupTitle: string
effective: readonly string[]
modified: boolean
warnings: readonly string[]
terminalStatus?: ShortcutTerminalStatus
}
export type ShortcutRowsByGroup = {
title: string
rows: ShortcutRowModel[]
}
export type ShortcutGroupSummary = {
id: string
label: string
count: number
}
const SHORTCUT_FILTER_LABELS: Record<ShortcutFilter, string> = {
all: 'All',
modified: 'Modified',
unassigned: 'Unassigned',
conflicts: 'Conflicts'
}
export function getShortcutSearchEntry(row: ShortcutRowModel): SettingsSearchEntry {
return {
title: row.item.title,
description: `${row.groupTitle} shortcut`,
keywords: [...row.item.searchKeywords]
}
}
export function matchesShortcutFilter(row: ShortcutRowModel, filter: ShortcutFilter): boolean {
switch (filter) {
case 'modified':
return row.modified
case 'unassigned':
return row.effective.length === 0
case 'conflicts':
return row.warnings.length > 0
case 'all':
return true
}
}
export function matchesShortcutLocalSearch(
row: ShortcutRowModel,
query: string,
platform: NodeJS.Platform
): boolean {
if (!query) {
return true
}
const searchableText = [
row.item.title,
row.item.id,
row.groupTitle,
...row.item.searchKeywords,
formatKeybindingList(row.effective, platform)
]
return searchableText.some((value) => value.toLowerCase().includes(query))
}
export function ShortcutFilterRail({
query,
onQueryChange,
filter,
onFilterChange,
activeGroup,
onActiveGroupChange,
filterCounts,
groupSummaries,
visibleCount,
totalCount
}: {
query: string
onQueryChange: (value: string) => void
filter: ShortcutFilter
onFilterChange: (value: ShortcutFilter) => void
activeGroup: string
onActiveGroupChange: (value: string) => void
filterCounts: Record<ShortcutFilter, number>
groupSummaries: ShortcutGroupSummary[]
visibleCount: number
totalCount: number
}): React.JSX.Element {
const filters = (Object.keys(SHORTCUT_FILTER_LABELS) as ShortcutFilter[]).map((id) => ({
id,
label: SHORTCUT_FILTER_LABELS[id],
count: filterCounts[id]
}))
return (
<aside className="flex min-h-0 flex-col gap-5 xl:h-full">
<div className="shrink-0 space-y-2">
<div className="flex items-center justify-between gap-3">
<label htmlFor="shortcut-filter-search" className="text-xs font-medium">
Find shortcuts
</label>
<span className="text-[11px] text-muted-foreground">
{visibleCount}/{totalCount}
</span>
</div>
<div className="relative">
<Search className="pointer-events-none absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
id="shortcut-filter-search"
value={query}
onChange={(event) => onQueryChange(event.target.value)}
placeholder="Search command or keys"
className="h-8 pl-8 pr-8 text-sm"
/>
{query ? (
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label="Clear shortcut search"
onClick={() => onQueryChange('')}
className="absolute top-1/2 right-1 -translate-y-1/2 text-muted-foreground"
>
<X className="size-3" />
</Button>
) : null}
</div>
</div>
<nav aria-label="Shortcut status filters" className="shrink-0 space-y-2">
<p className="text-[11px] font-semibold tracking-[0.05em] text-muted-foreground uppercase">
Status
</p>
<div className="grid gap-1">
{filters.map((option) => (
<button
key={option.id}
type="button"
onClick={() => onFilterChange(option.id)}
className={cn(
'flex items-center justify-between gap-2 rounded-md px-2 py-1.5 text-left text-xs outline-none transition-colors focus-visible:ring-[3px] focus-visible:ring-ring/50',
filter === option.id
? 'bg-accent font-medium text-accent-foreground'
: 'text-muted-foreground hover:bg-accent/60 hover:text-foreground'
)}
>
<span className="truncate">{option.label}</span>
<span className="text-[11px] tabular-nums opacity-80">{option.count}</span>
</button>
))}
</div>
</nav>
<nav
aria-label="Shortcut groups"
className="min-h-0 flex-1 space-y-2 overflow-y-auto pr-1 scrollbar-sleek"
>
<p className="text-[11px] font-semibold tracking-[0.05em] text-muted-foreground uppercase">
Groups
</p>
<div className="grid gap-1">
{groupSummaries.map((group) => (
<button
key={group.id}
type="button"
onClick={() => onActiveGroupChange(group.id)}
className={cn(
'flex items-center justify-between gap-2 rounded-md px-2 py-1.5 text-left text-xs outline-none transition-colors focus-visible:ring-[3px] focus-visible:ring-ring/50',
activeGroup === group.id
? 'bg-accent font-medium text-accent-foreground'
: 'text-muted-foreground hover:bg-accent/60 hover:text-foreground',
group.count === 0 && activeGroup !== group.id ? 'opacity-55' : ''
)}
>
<span className="truncate">{group.label}</span>
<span className="text-[11px] tabular-nums opacity-80">{group.count}</span>
</button>
))}
</div>
</nav>
</aside>
)
}
@@ -0,0 +1,78 @@
import React from 'react'
import type { KeybindingActionId, KeybindingInput } from '../../../../shared/keybindings'
import { cn } from '../../lib/utils'
import { ShortcutBindingRow } from './ShortcutBindingRow'
import type { ShortcutRowsByGroup } from './ShortcutFilterRail'
export function ShortcutRowsList({
className,
groups,
platform,
errors,
recordingActionId,
onStartRecording,
onCancelRecording,
onCapture,
onClearError,
onDisable,
onReset
}: {
className?: string
groups: ShortcutRowsByGroup[]
platform: NodeJS.Platform
errors: Partial<Record<KeybindingActionId, string>>
recordingActionId: KeybindingActionId | null
onStartRecording: (actionId: KeybindingActionId) => void
onCancelRecording: () => void
onCapture: (actionId: KeybindingActionId, input: KeybindingInput) => void
onClearError: (actionId: KeybindingActionId) => void
onDisable: (actionId: KeybindingActionId) => void
onReset: (actionId: KeybindingActionId) => void
}): React.JSX.Element {
if (groups.length === 0) {
return (
<div
className={cn(
'rounded-md border border-dashed border-border/70 px-4 py-8 text-center text-sm text-muted-foreground',
className
)}
>
No shortcuts match those filters.
</div>
)
}
return (
<div className={cn('grid gap-8', className)}>
{groups.map((group) => (
<div key={group.title} className="space-y-3">
<h3 className="border-b border-border/50 pb-2 text-sm font-medium text-muted-foreground">
{group.title}
</h3>
<div className="grid gap-2">
{group.rows.map((row) => (
<ShortcutBindingRow
key={row.item.id}
item={row.item}
groupTitle={group.title}
platform={platform}
effective={row.effective}
modified={row.modified}
error={errors[row.item.id]}
warnings={row.warnings}
recording={recordingActionId === row.item.id}
terminalStatus={row.terminalStatus}
onStartRecording={onStartRecording}
onCancelRecording={onCancelRecording}
onCapture={onCapture}
onClearError={onClearError}
onDisable={onDisable}
onReset={onReset}
/>
))}
</div>
</div>
))}
</div>
)
}
@@ -0,0 +1,50 @@
import React from 'react'
import type { TerminalShortcutPolicy } from '../../../../shared/keybindings'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
import { SearchableSetting } from './SearchableSetting'
import { SettingsRow } from './SettingsFormControls'
export function ShortcutTerminalPolicyControl({
terminalShortcutPolicy,
keywords,
updateSettings
}: {
terminalShortcutPolicy: TerminalShortcutPolicy
keywords?: string[]
updateSettings: (updates: {
terminalShortcutPolicy?: TerminalShortcutPolicy
}) => Promise<void> | void
}): React.JSX.Element {
return (
<SearchableSetting
id="terminal-shortcut-policy"
title="Shortcuts in Terminal"
description="Choose whether Orca or the focused terminal wins when shortcuts overlap."
keywords={keywords}
className="max-w-none"
>
<SettingsRow
label="Shortcuts in Terminal"
description="Decide who first intercepts shortcuts"
control={
<Select
value={terminalShortcutPolicy}
onValueChange={(value) =>
void updateSettings({
terminalShortcutPolicy: value as TerminalShortcutPolicy
})
}
>
<SelectTrigger className="w-[180px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="orca-first">Orca first</SelectItem>
<SelectItem value="terminal-first">Terminal first</SelectItem>
</SelectContent>
</Select>
}
/>
</SearchableSetting>
)
}
@@ -1,5 +1,4 @@
import React, { useMemo, useState } from 'react'
import type { CtrlTabOrderMode } from '../../../../shared/types'
import {
KEYBINDING_DEFINITIONS,
findKeybindingConflicts,
@@ -18,18 +17,22 @@ import {
type TerminalShortcutPolicy
} from '../../../../shared/keybindings'
import { useAppStore } from '../../store'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
import { KeybindingsFileActions } from './KeybindingsFileActions'
import { SearchableSetting } from './SearchableSetting'
import { SettingsRow, SettingsSubsectionHeader } from './SettingsFormControls'
import { ShortcutBindingRow, type ShortcutTerminalStatus } from './ShortcutBindingRow'
import { matchesSettingsSearch, type SettingsSearchEntry } from './settings-search'
import { SettingsSubsectionHeader } from './SettingsFormControls'
import type { ShortcutTerminalStatus } from './ShortcutBindingRow'
import {
CTRL_TAB_BEHAVIOR_SEARCH_ENTRY,
SHORTCUTS_PANE_SEARCH_ENTRIES,
TERMINAL_SHORTCUT_POLICY_SEARCH_ENTRY
} from './shortcuts-search'
export { SHORTCUTS_PANE_SEARCH_ENTRIES }
getShortcutSearchEntry,
matchesShortcutFilter,
matchesShortcutLocalSearch,
ShortcutFilterRail,
type ShortcutFilter,
type ShortcutGroupSummary,
type ShortcutRowsByGroup
} from './ShortcutFilterRail'
import { ShortcutRowsList } from './ShortcutRowsList'
import { ShortcutTerminalPolicyControl } from './ShortcutTerminalPolicyControl'
import { TERMINAL_SHORTCUT_POLICY_SEARCH_ENTRY } from './shortcuts-search'
import { matchesSettingsSearch, normalizeSettingsSearchQuery } from './settings-search'
type ShortcutGroup = {
title: string
@@ -118,7 +121,6 @@ function getShortcutTerminalStatus(
export function ShortcutsPane(): React.JSX.Element {
const searchQuery = useAppStore((state) => state.settingsSearchQuery)
const ctrlTabOrderMode = useAppStore((state) => state.settings?.ctrlTabOrderMode ?? 'mru')
const terminalShortcutPolicy = useAppStore(
(state) => state.settings?.terminalShortcutPolicy ?? 'orca-first'
)
@@ -130,22 +132,11 @@ export function ShortcutsPane(): React.JSX.Element {
const disableKeybindingAction = useAppStore((state) => state.disableKeybindingAction)
const [errors, setErrors] = useState<Partial<Record<KeybindingActionId, string>>>({})
const [recordingActionId, setRecordingActionId] = useState<KeybindingActionId | null>(null)
const [shortcutQuery, setShortcutQuery] = useState('')
const [shortcutFilter, setShortcutFilter] = useState<ShortcutFilter>('all')
const [activeShortcutGroup, setActiveShortcutGroup] = useState<string>('all')
const groups = useMemo(groupDefinitions, [])
const groupEntries = useMemo<Record<string, SettingsSearchEntry[]>>(
() =>
Object.fromEntries(
groups.map((group) => [
group.title,
group.items.map((item) => ({
title: item.title,
description: `${group.title} shortcut`,
keywords: [...item.searchKeywords]
}))
])
),
[groups]
)
const conflictByAction = useMemo(() => {
const result = new Map<KeybindingActionId, string[]>()
for (const conflict of findKeybindingConflicts(platform, keybindings)) {
@@ -161,6 +152,76 @@ export function ShortcutsPane(): React.JSX.Element {
}
return result
}, [keybindings])
const shortcutGroups = useMemo<ShortcutRowsByGroup[]>(
() =>
groups.map((group) => ({
title: group.title,
rows: group.items.map((item) => {
const effective = getEffectiveKeybindingsForAction(item.id, platform, keybindings)
const modified = hasOwnBindingOverride(keybindings, item.id)
const warnings = conflictByAction.get(item.id) ?? []
return {
item,
groupTitle: group.title,
effective,
modified,
warnings,
terminalStatus: getShortcutTerminalStatus(
item,
terminalShortcutPolicy,
effective.length > 0
)
}
})
})),
[conflictByAction, groups, keybindings, terminalShortcutPolicy]
)
const shortcutSearchQuery = normalizeSettingsSearchQuery(shortcutQuery)
const shortcutRows = shortcutGroups.flatMap((group) => group.rows)
const baseVisibleRows = shortcutRows.filter(
(row) =>
matchesSettingsSearch(searchQuery, getShortcutSearchEntry(row)) &&
matchesShortcutLocalSearch(row, shortcutSearchQuery, platform)
)
const filterCounts: Record<ShortcutFilter, number> = {
all: baseVisibleRows.length,
modified: baseVisibleRows.filter((row) => row.modified).length,
unassigned: baseVisibleRows.filter((row) => row.effective.length === 0).length,
conflicts: baseVisibleRows.filter((row) => row.warnings.length > 0).length
}
const groupSummaries: ShortcutGroupSummary[] = [
{
id: 'all',
label: 'All shortcuts',
count: baseVisibleRows.filter((row) => matchesShortcutFilter(row, shortcutFilter)).length
},
...shortcutGroups.map((group) => ({
id: group.title,
label: group.title,
count: group.rows.filter(
(row) =>
matchesSettingsSearch(searchQuery, getShortcutSearchEntry(row)) &&
matchesShortcutLocalSearch(row, shortcutSearchQuery, platform) &&
matchesShortcutFilter(row, shortcutFilter)
).length
}))
]
const visibleShortcutGroups = shortcutGroups
.map((group) => ({
title: group.title,
rows: group.rows.filter(
(row) =>
(activeShortcutGroup === 'all' || row.groupTitle === activeShortcutGroup) &&
matchesSettingsSearch(searchQuery, getShortcutSearchEntry(row)) &&
matchesShortcutLocalSearch(row, shortcutSearchQuery, platform) &&
matchesShortcutFilter(row, shortcutFilter)
)
}))
.filter((group) => group.rows.length > 0)
const visibleShortcutCount = visibleShortcutGroups.reduce(
(sum, group) => sum + group.rows.length,
0
)
const saveBindings = async (
actionId: KeybindingActionId,
@@ -262,133 +323,57 @@ export function ShortcutsPane(): React.JSX.Element {
}
const showPolicy = matchesSettingsSearch(searchQuery, TERMINAL_SHORTCUT_POLICY_SEARCH_ENTRY)
const showCtrlTab = matchesSettingsSearch(searchQuery, CTRL_TAB_BEHAVIOR_SEARCH_ENTRY)
return (
<div className="space-y-6">
<section className="space-y-3">
<div className="flex h-full min-h-0 flex-col gap-6 overflow-hidden">
<section className="flex min-h-0 flex-1 flex-col space-y-3">
<SettingsSubsectionHeader
title="Keyboard Shortcuts"
description="Customize shortcuts visually or edit the file directly."
/>
{showPolicy || showCtrlTab ? (
<div className="divide-y divide-border/40">
<div className="grid min-h-0 flex-1 gap-6 xl:grid-cols-[16rem_minmax(0,1fr)]">
<ShortcutFilterRail
query={shortcutQuery}
onQueryChange={setShortcutQuery}
filter={shortcutFilter}
onFilterChange={setShortcutFilter}
activeGroup={activeShortcutGroup}
onActiveGroupChange={setActiveShortcutGroup}
filterCounts={filterCounts}
groupSummaries={groupSummaries}
visibleCount={visibleShortcutCount}
totalCount={shortcutRows.length}
/>
<div className="flex min-h-0 min-w-0 flex-col gap-5">
{showPolicy ? (
<SearchableSetting
id="terminal-shortcut-policy"
title="Shortcuts in Terminal"
description="Choose whether Orca or the focused terminal wins when shortcuts overlap."
<ShortcutTerminalPolicyControl
terminalShortcutPolicy={terminalShortcutPolicy}
keywords={TERMINAL_SHORTCUT_POLICY_SEARCH_ENTRY.keywords}
>
<SettingsRow
label="Shortcuts in Terminal"
description="Orca first keeps app shortcuts active in TUIs. Terminal first lets shell shortcuts win unless marked terminal-active."
control={
<Select
value={terminalShortcutPolicy}
onValueChange={(value) =>
void updateSettings({
terminalShortcutPolicy: value as TerminalShortcutPolicy
})
}
>
<SelectTrigger className="w-[180px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="orca-first">Orca first</SelectItem>
<SelectItem value="terminal-first">Terminal first</SelectItem>
</SelectContent>
</Select>
}
/>
</SearchableSetting>
updateSettings={updateSettings}
/>
) : null}
{showCtrlTab ? (
<SearchableSetting
title="Recent Tab Order"
description="Choose recent or sequential tab switching."
keywords={CTRL_TAB_BEHAVIOR_SEARCH_ENTRY.keywords}
>
<SettingsRow
label="Recent Tab Order"
description="Choose whether recent tab switching follows recent use or the tab strip order."
control={
<Select
value={ctrlTabOrderMode}
onValueChange={(value) =>
void updateSettings({ ctrlTabOrderMode: value as CtrlTabOrderMode })
}
>
<SelectTrigger className="w-[180px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="mru">Most recent</SelectItem>
<SelectItem value="sequential">Tab strip order</SelectItem>
</SelectContent>
</Select>
}
/>
</SearchableSetting>
) : null}
<KeybindingsFileActions />
<ShortcutRowsList
className="min-h-0 flex-1 overflow-y-auto pr-1 scrollbar-sleek"
groups={visibleShortcutGroups}
platform={platform}
errors={errors}
recordingActionId={recordingActionId}
onStartRecording={(actionId) => {
setRecordingActionId(actionId)
clearError(actionId)
}}
onCancelRecording={() => setRecordingActionId(null)}
onCapture={(actionId, input) => void captureBinding(actionId, input)}
onClearError={clearError}
onDisable={(actionId) => void disableBinding(actionId)}
onReset={(actionId) => void resetBinding(actionId)}
/>
</div>
) : null}
<KeybindingsFileActions />
<div className="grid gap-8">
{groups
.filter((group) => matchesSettingsSearch(searchQuery, groupEntries[group.title] ?? []))
.map((group) => (
<div key={group.title} className="space-y-3">
<h3 className="border-b border-border/50 pb-2 text-sm font-medium text-muted-foreground">
{group.title}
</h3>
<div className="grid gap-2">
{group.items.map((item) => {
const effective = getEffectiveKeybindingsForAction(
item.id,
platform,
keybindings
)
const modified = hasOwnBindingOverride(keybindings, item.id)
const warnings = conflictByAction.get(item.id) ?? []
const terminalStatus = getShortcutTerminalStatus(
item,
terminalShortcutPolicy,
effective.length > 0
)
return (
<ShortcutBindingRow
key={item.id}
item={item}
groupTitle={group.title}
platform={platform}
effective={effective}
modified={modified}
error={errors[item.id]}
warnings={warnings}
recording={recordingActionId === item.id}
terminalStatus={terminalStatus}
onStartRecording={(actionId) => {
setRecordingActionId(actionId)
clearError(actionId)
}}
onCancelRecording={() => setRecordingActionId(null)}
onCapture={(actionId, input) => void captureBinding(actionId, input)}
onClearError={clearError}
onDisable={(actionId) => void disableBinding(actionId)}
onReset={(actionId) => void resetBinding(actionId)}
/>
)
})}
</div>
</div>
))}
</div>
</section>
</div>
@@ -61,6 +61,23 @@ export const GENERAL_EDITOR_SEARCH_ENTRIES: SettingsSearchEntry[] = [
}
]
export const GENERAL_NAVIGATION_SEARCH_ENTRIES: SettingsSearchEntry[] = [
{
title: 'Tab Order',
description: 'Recent or tab strip.',
keywords: [
'recent tab order',
'tab',
'ctrl',
'control',
'recent',
'mru',
'sequential',
'switch'
]
}
]
export const GENERAL_CLI_SEARCH_ENTRIES: SettingsSearchEntry[] = [
{
title: 'Shell command',
@@ -119,6 +136,7 @@ export const GENERAL_SUPPORT_SEARCH_ENTRIES: SettingsSearchEntry[] = [
export const GENERAL_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
...GENERAL_WORKSPACE_SEARCH_ENTRIES,
...GENERAL_NAVIGATION_SEARCH_ENTRIES,
...GENERAL_EDITOR_SEARCH_ENTRIES,
...GENERAL_CLI_SEARCH_ENTRIES,
...GENERAL_CACHE_TIMER_SEARCH_ENTRIES,
@@ -1,12 +1,6 @@
import { KEYBINDING_DEFINITIONS } from '../../../../shared/keybindings'
import type { SettingsSearchEntry } from './settings-search'
export const CTRL_TAB_BEHAVIOR_SEARCH_ENTRY: SettingsSearchEntry = {
title: 'Recent Tab Order',
description: 'Choose recent or sequential tab switching.',
keywords: ['shortcut', 'tab', 'ctrl', 'control', 'recent', 'mru', 'sequential', 'switch']
}
export const TERMINAL_SHORTCUT_POLICY_SEARCH_ENTRY: SettingsSearchEntry = {
title: 'Shortcuts in Terminal',
description: 'Choose whether Orca or the focused terminal wins when shortcuts overlap.',
@@ -29,6 +23,5 @@ export const SHORTCUTS_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
description: `${item.group} shortcut`,
keywords: [...item.searchKeywords]
})),
TERMINAL_SHORTCUT_POLICY_SEARCH_ENTRY,
CTRL_TAB_BEHAVIOR_SEARCH_ENTRY
TERMINAL_SHORTCUT_POLICY_SEARCH_ENTRY
]
@@ -48,8 +48,6 @@ import {
type LinkedWorkItemSummary,
type SetupConfig
} from '@/lib/new-workspace'
import { getShortcutPlatform } from '@/lib/shortcut-platform'
import { useShortcutLabel } from '@/hooks/useShortcutLabel'
import {
getFullComposerCreateDisabled,
getQuickComposerCreateDisabled
@@ -77,7 +75,6 @@ import {
} from '@/lib/workspace-create-error-format'
import type { SshConnectionStatus } from '../../../shared/ssh-types'
import { resolveComposerBranchSelection } from './composer-branch-selection'
import { keybindingMatchesAction } from '../../../shared/keybindings'
export type UseComposerStateOptions = {
initialRepoId?: string
@@ -134,7 +131,6 @@ export type ComposerCardProps = {
onClearSmartNameSelection: () => void
agentPrompt: string
onAgentPromptChange: (value: string) => void
onPromptKeyDown: (event: React.KeyboardEvent<HTMLTextAreaElement>) => void
/** Rendered issueCommand template to preview inside the empty prompt
* textarea when the user has linked a work item but not typed anything. */
linkedOnlyTemplatePreview: string | null
@@ -142,7 +138,6 @@ export type ComposerCardProps = {
getAttachmentLabel: (pathValue: string) => string
onAddAttachment: () => void
onRemoveAttachment: (pathValue: string) => void
addAttachmentShortcut: string
linkedWorkItem: LinkedWorkItemSummary | null
onRemoveLinkedWorkItem: () => void
linkPopoverOpen: boolean
@@ -1372,28 +1367,6 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
}
}, [])
const handlePromptKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLTextAreaElement>): void => {
if (
!keybindingMatchesAction(
'composer.addAttachment',
event,
getShortcutPlatform(),
useAppStore.getState().keybindings
)
) {
return
}
// Why: the attachment picker should only steal Cmd/Ctrl+U while the user
// is composing a prompt, so the shortcut is scoped to the textarea rather
// than registered globally for the whole new-workspace surface.
event.preventDefault()
void handleAddAttachment()
},
[handleAddAttachment]
)
const handleRepoChange = useCallback(
(value: string): void => {
if (value === repoId) {
@@ -2153,8 +2126,6 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
createGateMode === 'quick'
? getQuickComposerCreateDisabled(createGateInput)
: getFullComposerCreateDisabled(createGateInput)
const addAttachmentShortcut = useShortcutLabel('composer.addAttachment')
const cardProps: ComposerCardProps = {
eligibleRepos,
repoId,
@@ -2170,14 +2141,12 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
onClearSmartNameSelection: handleClearSmartNameSelection,
agentPrompt,
onAgentPromptChange: setAgentPrompt,
onPromptKeyDown: handlePromptKeyDown,
linkedOnlyTemplatePreview: shouldApplyLinkedOnlyTemplate ? linkedOnlyTemplatePrompt : null,
attachmentPaths,
getAttachmentLabel,
onAddAttachment: () => void handleAddAttachment(),
onRemoveAttachment: (pathValue) =>
setAttachmentPaths((current) => current.filter((currentPath) => currentPath !== pathValue)),
addAttachmentShortcut,
linkedWorkItem,
onRemoveLinkedWorkItem: handleRemoveLinkedWorkItem,
linkPopoverOpen,
-9
View File
@@ -71,7 +71,6 @@ export type KeybindingActionId =
| 'fileExplorer.copyPath'
| 'fileExplorer.copyRelativePath'
| 'fileExplorer.delete'
| 'composer.addAttachment'
| 'settings.search'
| 'terminal.copySelection'
| 'terminal.paste'
@@ -566,14 +565,6 @@ export const KEYBINDING_DEFINITIONS: readonly KeybindingDefinition[] = [
},
allowBareKeybindings: true
},
{
id: 'composer.addAttachment',
title: 'Add Attachment',
group: 'Composer',
scope: 'composer',
searchKeywords: ['shortcut', 'composer', 'attachment', 'upload'],
defaultBindings: platformBindings(['Mod+U'])
},
{
id: 'settings.search',
title: 'Search Settings',