mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-11 12:48:54 +08:00
Compare commits
1 Commits
dependabot
...
desktop-cm
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a23bbe3348 |
@@ -38,6 +38,7 @@ import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { searchSessions, type SessionInfo, type SessionSearchResult } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { normalizeCombo } from '@/lib/keybinds/combo'
|
||||
import { profileColor } from '@/lib/profile-color'
|
||||
import { sessionMatchesSearch } from '@/lib/session-search'
|
||||
import { normalizeSessionSource, sessionSourceLabel } from '@/lib/session-source'
|
||||
@@ -111,8 +112,7 @@ const NON_SESSION_LOAD_STEP = 10
|
||||
// Render the modifier key the user actually presses on this platform. The
|
||||
// global accelerator is bound to both Cmd+N (macOS) and Ctrl+N (everywhere
|
||||
// else) in desktop-controller.tsx, but the hint should match muscle memory.
|
||||
const NEW_SESSION_KBD: readonly string[] =
|
||||
typeof navigator !== 'undefined' && navigator.platform.toLowerCase().includes('mac') ? ['⌘', 'N'] : ['Ctrl', 'N']
|
||||
const NEW_SESSION_KBD: readonly string[] =normalizeCombo('mod+n')
|
||||
|
||||
const SIDEBAR_NAV: SidebarNavItem[] = [
|
||||
{
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { SessionInfo } from '@/hermes'
|
||||
import { type Translations, useI18n } from '@/i18n'
|
||||
import { sessionTitle } from '@/lib/chat-runtime'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { modKey } from '@/lib/keybinds/combo'
|
||||
import { handoffOriginSource, sessionSourceLabel } from '@/lib/session-source'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $attentionSessionIds } from '@/store/session'
|
||||
@@ -133,11 +134,11 @@ export function SidebarSessionRow({
|
||||
return
|
||||
}
|
||||
|
||||
// ⌘-click (mac) / ⌃-click (win/linux) pops the chat into its own
|
||||
// ⌘-click (mac) / Ctrl-click (win/linux) pops the chat into its own
|
||||
// window — the universal "open in a new window" gesture. Archive
|
||||
// lives in the row's ⋯ and right-click menus. Falls through to a
|
||||
// normal resume when standalone windows aren't available (web embed).
|
||||
if ((event.metaKey || event.ctrlKey) && canOpenSessionWindow()) {
|
||||
if (event[modKey] && canOpenSessionWindow()) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
triggerHaptic('selection')
|
||||
|
||||
@@ -91,6 +91,7 @@ import { CommandPalette } from './command-palette'
|
||||
import { useGatewayBoot } from './gateway/hooks/use-gateway-boot'
|
||||
import { useGatewayRequest } from './gateway/hooks/use-gateway-request'
|
||||
import { useKeybinds } from './hooks/use-keybinds'
|
||||
import { modKey } from '@/lib/keybinds/combo'
|
||||
import { SIDEBAR_COLLAPSE_MEDIA_QUERY } from './layout-constants'
|
||||
import { ModelPickerOverlay } from './model-picker-overlay'
|
||||
import { ModelVisibilityOverlay } from './model-visibility-overlay'
|
||||
@@ -271,7 +272,7 @@ export function DesktopController() {
|
||||
return
|
||||
}
|
||||
|
||||
if ((event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === 'w') {
|
||||
if (event[modKey] && !event.altKey && !event.shiftKey && event.key.toLowerCase() === 'w') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
closeActiveRightRailTab()
|
||||
|
||||
@@ -69,7 +69,7 @@ export function TerminalTab({ cwd, onAddSelectionToChat }: TerminalTabProps) {
|
||||
variant="secondary"
|
||||
>
|
||||
{t.rightSidebar.addToChat}
|
||||
<span className="ml-1 text-[0.6rem] text-(--ui-text-tertiary)">{addSelectionShortcutLabel()}</span>
|
||||
<span className="ml-1 text-[0.6rem] text-(--ui-text-tertiary)">{addSelectionShortcutLabel}</span>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ITheme, Terminal } from '@xterm/xterm'
|
||||
import type { CSSProperties } from 'react'
|
||||
|
||||
import { formatCombo, modKey } from '@/lib/keybinds/combo'
|
||||
import type { DesktopTerminalPalette } from '@/themes/types'
|
||||
|
||||
// VS Code's default integrated-terminal palette (terminalColorRegistry.ts) — a
|
||||
@@ -97,12 +98,10 @@ export function resolveSurfaceColor(fallback: string): string {
|
||||
return resolved && resolved !== 'rgba(0, 0, 0, 0)' ? resolved : fallback
|
||||
}
|
||||
|
||||
export const isMacPlatform = () => navigator.platform.toLowerCase().includes('mac')
|
||||
|
||||
export const addSelectionShortcutLabel = () => (isMacPlatform() ? '⌘L' : 'Ctrl+L')
|
||||
export const addSelectionShortcutLabel = formatCombo('mod+l')
|
||||
|
||||
export function isAddSelectionShortcut(event: KeyboardEvent) {
|
||||
const mod = isMacPlatform() ? event.metaKey : event.ctrlKey
|
||||
const mod = event[modKey]
|
||||
|
||||
return mod && !event.shiftKey && event.key.toLowerCase() === 'l'
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
type KeybindActionMeta,
|
||||
type KeybindReadonly
|
||||
} from '@/lib/keybinds/actions'
|
||||
import { formatCombo } from '@/lib/keybinds/combo'
|
||||
import { formatCombo, formatFakeCombo } from '@/lib/keybinds/combo'
|
||||
import { arraysEqual } from '@/lib/storage'
|
||||
import {
|
||||
$bindings,
|
||||
@@ -210,7 +210,7 @@ function ReadonlyRow({ shortcut }: { shortcut: KeybindReadonly }) {
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{shortcut.keys.map(key => (
|
||||
<span className="kbd-cap" key={key}>
|
||||
{formatCombo(key)}
|
||||
{formatFakeCombo(key)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -16,6 +16,7 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigge
|
||||
import { useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { ChevronDown, Loader2 } from '@/lib/icons'
|
||||
import { formatCombo } from '@/lib/keybinds/combo'
|
||||
import { $gateway } from '@/store/gateway'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import { $approvalRequest, type ApprovalRequest, clearApprovalRequest } from '@/store/prompts'
|
||||
@@ -50,8 +51,6 @@ export const PendingToolApproval: FC<{ part: ToolPart }> = ({ part }) => {
|
||||
return <ApprovalBar request={request} />
|
||||
}
|
||||
|
||||
const isMac = typeof navigator !== 'undefined' && /Mac|iP(hone|ad|od)/.test(navigator.platform)
|
||||
|
||||
const ApprovalBar: FC<{ request: ApprovalRequest }> = ({ request }) => {
|
||||
const { t } = useI18n()
|
||||
const copy = t.assistant.approval
|
||||
@@ -127,7 +126,7 @@ const ApprovalBar: FC<{ request: ApprovalRequest }> = ({ request }) => {
|
||||
variant="ghost"
|
||||
>
|
||||
{submitting === 'once' ? <Loader2 className="size-3 animate-spin" /> : copy.run}
|
||||
{submitting !== 'once' && <span className="text-[0.625rem] text-primary/60">{isMac ? '⌘⏎' : 'Ctrl⏎'}</span>}
|
||||
{submitting !== 'once' && <span className="text-[0.625rem] text-primary/60">{formatCombo('mod+enter')}</span>}
|
||||
</Button>
|
||||
<span aria-hidden className="w-px self-stretch bg-primary/20" />
|
||||
<DropdownMenu>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { FIELD_DESCRIPTIONS, FIELD_LABELS } from '@/app/settings/constants'
|
||||
import { formatCombo } from '@/lib/keybinds/combo'
|
||||
|
||||
import type { Translations } from './types'
|
||||
|
||||
@@ -518,7 +519,7 @@ export const en: Translations = {
|
||||
loading: 'Loading archived sessions…',
|
||||
archivedTitle: 'Archived sessions',
|
||||
archivedIntro:
|
||||
'Archived chats are hidden from the sidebar but keep all their messages. Ctrl/⌘-click a chat in the sidebar to archive it.',
|
||||
`Archived chats are hidden from the sidebar but keep all their messages. ${formatCombo('mod')}-click a chat in the sidebar to archive it.`,
|
||||
emptyArchivedTitle: 'Nothing archived',
|
||||
emptyArchivedDesc: 'Archive a chat to hide it here.',
|
||||
unarchive: 'Unarchive',
|
||||
@@ -529,7 +530,7 @@ export const en: Translations = {
|
||||
defaultDirTitle: 'Default project directory',
|
||||
defaultDirDesc:
|
||||
'New sessions start in this folder unless you pick another. Leave it unset to use your home directory.',
|
||||
defaultDirUpdated: 'Default project directory updated — start a new chat (Ctrl/⌘+N) for it to take effect',
|
||||
defaultDirUpdated: `Default project directory updated — start a new chat (${formatCombo('mod+n')}) for it to take effect`,
|
||||
defaultsTo: label => `Defaults to ${label}.`,
|
||||
change: 'Change',
|
||||
choose: 'Choose',
|
||||
@@ -1677,7 +1678,7 @@ export const en: Translations = {
|
||||
loadingQuestion: 'Loading question…',
|
||||
other: 'Other (type your answer)',
|
||||
placeholder: 'Type your answer…',
|
||||
shortcut: '⌘/Ctrl + Enter to send',
|
||||
shortcut: `${formatCombo('mod+enter')} to send`,
|
||||
back: 'Back',
|
||||
skip: 'Skip',
|
||||
send: 'Send'
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { defineFieldCopy } from '@/app/settings/field-copy'
|
||||
import { formatCombo } from '@/lib/keybinds/combo'
|
||||
|
||||
import { defineLocale } from './define-locale'
|
||||
|
||||
@@ -642,7 +643,7 @@ export const ja = defineLocale({
|
||||
loading: 'アーカイブ済みセッションを読み込み中…',
|
||||
archivedTitle: 'アーカイブ済みセッション',
|
||||
archivedIntro:
|
||||
'アーカイブ済みチャットはサイドバーでは非表示になりますが、すべてのメッセージは保持されます。サイドバーのチャットを Ctrl/⌘ クリックするとアーカイブできます。',
|
||||
`アーカイブ済みチャットはサイドバーでは非表示になりますが、すべてのメッセージは保持されます。サイドバーのチャットを ${formatCombo('mod')} クリックするとアーカイブできます。`,
|
||||
emptyArchivedTitle: 'アーカイブがありません',
|
||||
emptyArchivedDesc: 'チャットをアーカイブするとここに表示されます。',
|
||||
unarchive: 'アーカイブを解除',
|
||||
@@ -1811,7 +1812,7 @@ export const ja = defineLocale({
|
||||
loadingQuestion: '質問を読み込み中…',
|
||||
other: 'その他(回答を入力)',
|
||||
placeholder: '回答を入力…',
|
||||
shortcut: '⌘/Ctrl + Enter で送信',
|
||||
shortcut: `${formatCombo('mod+enter')} で送信`,
|
||||
back: '戻る',
|
||||
skip: 'スキップ',
|
||||
send: '送信'
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { defineFieldCopy } from '@/app/settings/field-copy'
|
||||
import { formatCombo } from '@/lib/keybinds/combo'
|
||||
|
||||
import { defineLocale } from './define-locale'
|
||||
|
||||
@@ -627,7 +628,7 @@ export const zhHant = defineLocale({
|
||||
loading: '正在載入已封存工作階段…',
|
||||
archivedTitle: '已封存工作階段',
|
||||
archivedIntro:
|
||||
'已封存的聊天會從側邊欄隱藏,但保留全部訊息。在側邊欄 Ctrl/⌘ 點擊聊天即可封存。',
|
||||
`已封存的聊天會從側邊欄隱藏,但保留全部訊息。在側邊欄 ${formatCombo('mod')} 點擊聊天即可封存。`,
|
||||
emptyArchivedTitle: '暫無封存',
|
||||
emptyArchivedDesc: '封存一個聊天後會顯示在這裡。',
|
||||
unarchive: '取消封存',
|
||||
@@ -1772,7 +1773,7 @@ export const zhHant = defineLocale({
|
||||
loadingQuestion: '正在載入問題…',
|
||||
other: '其他(輸入您的答案)',
|
||||
placeholder: '輸入您的答案…',
|
||||
shortcut: '⌘/Ctrl + Enter 傳送',
|
||||
shortcut: `${formatCombo('mod+enter')} 傳送`,
|
||||
back: '返回',
|
||||
skip: '略過',
|
||||
send: '傳送'
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { defineFieldCopy } from '@/app/settings/field-copy'
|
||||
import { formatCombo } from '@/lib/keybinds/combo'
|
||||
|
||||
import type { Translations } from './types'
|
||||
|
||||
@@ -712,7 +713,7 @@ export const zh: Translations = {
|
||||
sessions: {
|
||||
loading: '正在加载已归档会话…',
|
||||
archivedTitle: '已归档会话',
|
||||
archivedIntro: '已归档对话会从侧边栏隐藏,但会保留全部消息。在侧边栏 Ctrl/⌘ 点击对话即可归档。',
|
||||
archivedIntro: `已归档对话会从侧边栏隐藏,但会保留全部消息。在侧边栏 ${formatCombo('mod')} 点击对话即可归档。`,
|
||||
emptyArchivedTitle: '暂无归档',
|
||||
emptyArchivedDesc: '归档一个对话后会显示在这里。',
|
||||
unarchive: '取消归档',
|
||||
@@ -1856,7 +1857,7 @@ export const zh: Translations = {
|
||||
loadingQuestion: '正在加载问题…',
|
||||
other: '其他 (输入你的答案)',
|
||||
placeholder: '输入你的答案…',
|
||||
shortcut: '⌘/Ctrl + Enter 发送',
|
||||
shortcut: `${formatCombo('mod+enter')} 发送`,
|
||||
back: '返回',
|
||||
skip: '跳过',
|
||||
send: '发送'
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
// like navigate / theme); labels come from i18n (`t.keybinds.actions[id]`). To
|
||||
// add a hotkey, add a row here and a handler there — nothing else.
|
||||
|
||||
import type { Combo, FakeCombo } from "./combo";
|
||||
|
||||
|
||||
export type KeybindCategory = 'composer' | 'profiles' | 'session' | 'navigation' | 'view'
|
||||
|
||||
// The self-referential opener — bound + dispatched like any action, but shown in
|
||||
@@ -27,15 +30,16 @@ export interface KeybindActionMeta {
|
||||
// `profile.default`) — ⌘` is macOS-reserved (window cycling) and ⌘0 is reset-zoom.
|
||||
export const PROFILE_SLOT_COUNT = 18
|
||||
|
||||
function comboForSlot(slot: number): string {
|
||||
return slot <= 9 ? `mod+${slot}` : `mod+alt+${slot - 9}`
|
||||
}
|
||||
const PROFILE_SWITCH_ACTIONS: KeybindActionMeta[] = Array.from({ length: PROFILE_SLOT_COUNT }, (_, i) => {
|
||||
const slot = i+1
|
||||
const combo = (slot <= 9 ? `mod+${slot}` : `mod+alt+${slot - 9}`) as Combo
|
||||
|
||||
const PROFILE_SWITCH_ACTIONS: KeybindActionMeta[] = Array.from({ length: PROFILE_SLOT_COUNT }, (_, i) => ({
|
||||
id: `profile.switch.${i + 1}`,
|
||||
category: 'profiles' as const,
|
||||
defaults: [comboForSlot(i + 1)]
|
||||
}))
|
||||
return ({
|
||||
id: `profile.switch.${i + 1}`,
|
||||
category: 'profiles' as const,
|
||||
defaults: [combo]
|
||||
})
|
||||
})
|
||||
|
||||
// ⌘` on macOS / Ctrl+` elsewhere (the `~` key), plus the Shift/tilde variant.
|
||||
// `mod` keeps one binding cross-platform; on macOS this shadows the system
|
||||
@@ -104,10 +108,12 @@ export function keybindAction(id: string): KeybindActionMeta | undefined {
|
||||
return ACTION_BY_ID.get(id)
|
||||
}
|
||||
|
||||
export type KeybindBindings = Record<string, string[]>
|
||||
export type KeybindBindings = Record<string, Combo[]>
|
||||
|
||||
export function defaultBindings(): KeybindBindings {
|
||||
return Object.fromEntries(KEYBIND_ACTIONS.map(action => [action.id, [...action.defaults]]))
|
||||
return Object.fromEntries<string, Combo[]>(
|
||||
KEYBIND_ACTIONS.map(action => [action.id, [...action.defaults] as Combo[]])
|
||||
)
|
||||
}
|
||||
|
||||
// Fixed, non-rebindable shortcuts surfaced read-only in the panel so the map is
|
||||
@@ -117,7 +123,7 @@ export function defaultBindings(): KeybindBindings {
|
||||
export interface KeybindReadonly {
|
||||
id: string
|
||||
category: KeybindCategory
|
||||
keys: readonly string[]
|
||||
keys: readonly FakeCombo[]
|
||||
}
|
||||
|
||||
export const KEYBIND_READONLY: readonly KeybindReadonly[] = [
|
||||
|
||||
@@ -10,11 +10,13 @@
|
||||
// Control+Tab. Off macOS, Control already *is* `mod`, so `canonicalizeCombo`
|
||||
// folds `ctrl` → `mod`.
|
||||
|
||||
export const IS_MAC = typeof navigator !== 'undefined' && /mac/i.test(navigator.platform || navigator.userAgent || '')
|
||||
const IS_MAC = typeof navigator !== 'undefined' && /mac/i.test(navigator.platform || navigator.userAgent || '')
|
||||
|
||||
export const modKey = IS_MAC ? 'metaKey' as const : 'ctrlKey' as const
|
||||
|
||||
// event.code → canonical base token. Letters/digits map to their lowercase
|
||||
// character; everything else uses an explicit name so combos read cleanly.
|
||||
const CODE_TO_KEY: Record<string, string> = {
|
||||
const CODE_TO_KEY = {
|
||||
Backquote: '`',
|
||||
Backslash: '\\',
|
||||
BracketLeft: '[',
|
||||
@@ -35,8 +37,50 @@ const CODE_TO_KEY: Record<string, string> = {
|
||||
ArrowDown: 'down',
|
||||
ArrowLeft: 'left',
|
||||
ArrowRight: 'right'
|
||||
} as const satisfies Record<Capitalize<string>, Lowercase<string>>
|
||||
|
||||
type SpecialKey = typeof CODE_TO_KEY[keyof typeof CODE_TO_KEY]
|
||||
|
||||
type Alpha = 'a'|'b'|'c'|'d'|'e'|'f'|'g'|'h'|'i'|'j'|'k'|'l'|'m'
|
||||
| 'n'|'o'|'p'|'q'|'r'|'s'|'t'|'u'|'v'|'w'|'x'|'y'|'z'
|
||||
|
||||
export type Digit = '0'|'1'|'2'|'3'|'4'|'5'|'6'|'7'|'8'|'9'
|
||||
|
||||
|
||||
type FKey =
|
||||
| 'f1' | 'f2' | 'f3' | 'f4' | 'f5' | 'f6'
|
||||
| 'f7' | 'f8' | 'f9' | 'f10' | 'f11' | 'f12'
|
||||
| 'f13' | 'f14' | 'f15' | 'f16' | 'f17' | 'f18'
|
||||
| 'f19' | 'f20' | 'f21' | 'f22' | 'f23' | 'f24'
|
||||
|
||||
type BaseKey = Alpha | Digit | FKey | SpecialKey
|
||||
|
||||
// subset of https://developer.mozilla.org/en-US/docs/Web/API/UI_Events/Keyboard_event_code_values
|
||||
type KeyCode = Uppercase<FKey> | `Digit${Digit}` | `Key${Uppercase<Alpha>}` | keyof typeof CODE_TO_KEY
|
||||
|
||||
function baseKeyFromCode(code: KeyCode): BaseKey | null {
|
||||
if (code.startsWith('Key')) {
|
||||
return code.slice(3).toLowerCase() as Alpha
|
||||
}
|
||||
|
||||
if (code.startsWith('Digit')) {
|
||||
return code.slice(5) as Digit
|
||||
}
|
||||
|
||||
if (code.startsWith('Numpad')) {
|
||||
const rest = code.slice(6)
|
||||
|
||||
return /^[0-9]$/.test(rest) ? rest as Digit : null
|
||||
}
|
||||
|
||||
if (code.startsWith('F') && /^F\d{1,2}$/.test(code)) {
|
||||
return code.toLowerCase() as FKey
|
||||
}
|
||||
|
||||
return CODE_TO_KEY[code as keyof typeof CODE_TO_KEY] ?? null
|
||||
}
|
||||
|
||||
|
||||
const MODIFIER_CODES = new Set([
|
||||
'AltLeft',
|
||||
'AltRight',
|
||||
@@ -48,42 +92,20 @@ const MODIFIER_CODES = new Set([
|
||||
'ShiftRight'
|
||||
])
|
||||
|
||||
function baseKeyFromCode(code: string): string | null {
|
||||
if (code.startsWith('Key')) {
|
||||
return code.slice(3).toLowerCase()
|
||||
}
|
||||
|
||||
if (code.startsWith('Digit')) {
|
||||
return code.slice(5)
|
||||
}
|
||||
|
||||
if (code.startsWith('Numpad')) {
|
||||
const rest = code.slice(6)
|
||||
|
||||
return /^[0-9]$/.test(rest) ? rest : null
|
||||
}
|
||||
|
||||
if (code.startsWith('F') && /^F\d{1,2}$/.test(code)) {
|
||||
return code.toLowerCase()
|
||||
}
|
||||
|
||||
return CODE_TO_KEY[code] ?? null
|
||||
}
|
||||
|
||||
// Returns the canonical combo for a keydown, or null while only modifiers are
|
||||
// held (so capture mode keeps waiting for a real key).
|
||||
export function comboFromEvent(event: KeyboardEvent): string | null {
|
||||
export function comboFromEvent(event: KeyboardEvent): Combo | null {
|
||||
if (MODIFIER_CODES.has(event.code)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const base = baseKeyFromCode(event.code)
|
||||
const base = baseKeyFromCode(event.code as KeyCode)
|
||||
|
||||
if (!base) {
|
||||
return null
|
||||
}
|
||||
|
||||
const parts: string[] = []
|
||||
const parts: Combo[] = []
|
||||
|
||||
// macOS reports Cmd (`mod`) and Control (`ctrl`) separately; elsewhere
|
||||
// Control IS the accelerator, so it folds into `mod`.
|
||||
@@ -105,7 +127,7 @@ export function comboFromEvent(event: KeyboardEvent): string | null {
|
||||
|
||||
parts.push(base)
|
||||
|
||||
return parts.join('+')
|
||||
return parts.join('+') as Combo
|
||||
}
|
||||
|
||||
// Rewrites a binding to the form `comboFromEvent` emits, so it indexes under
|
||||
@@ -115,7 +137,14 @@ export function canonicalizeCombo(combo: string): string {
|
||||
return IS_MAC ? combo : combo.replace(/\bctrl\b/g, 'mod')
|
||||
}
|
||||
|
||||
const TOKEN_LABELS: Record<string, string> = {
|
||||
const MOD_LABELS = {
|
||||
mod: IS_MAC ? '⌘' : 'Ctrl',
|
||||
ctrl: IS_MAC ? '⌃' : 'Ctrl',
|
||||
alt: IS_MAC ? '⌥' : 'Alt',
|
||||
shift: IS_MAC ? '⇧' : 'Shift'
|
||||
} as const
|
||||
|
||||
const FANCY_KEY_LABELS = {
|
||||
enter: '↵',
|
||||
escape: 'Esc',
|
||||
backspace: '⌫',
|
||||
@@ -124,39 +153,47 @@ const TOKEN_LABELS: Record<string, string> = {
|
||||
up: '↑',
|
||||
down: '↓',
|
||||
left: '←',
|
||||
right: '→'
|
||||
right: '→',
|
||||
} as const
|
||||
|
||||
const TOKEN_LABELS: Record<string, string> = {
|
||||
...MOD_LABELS,
|
||||
...FANCY_KEY_LABELS
|
||||
}
|
||||
|
||||
function labelForBase(base: string): string {
|
||||
if (TOKEN_LABELS[base]) {
|
||||
return TOKEN_LABELS[base]
|
||||
function labelForToken(token: string): string {
|
||||
if (TOKEN_LABELS[token]) {
|
||||
return TOKEN_LABELS[token]
|
||||
}
|
||||
|
||||
if (/^f\d{1,2}$/.test(base)) {
|
||||
return base.toUpperCase()
|
||||
if (/^f\d{1,2}$/.test(token)) {
|
||||
return token.toUpperCase()
|
||||
}
|
||||
|
||||
return base.length === 1 ? base.toUpperCase() : base
|
||||
return token.length === 1 ? token.toUpperCase() : token
|
||||
}
|
||||
|
||||
function labelForMod(mod: string): string {
|
||||
if (mod === 'mod') {
|
||||
return IS_MAC ? '⌘' : 'Ctrl'
|
||||
}
|
||||
//
|
||||
|
||||
if (mod === 'ctrl') {
|
||||
return IS_MAC ? '⌃' : 'Ctrl'
|
||||
}
|
||||
type ModKey = keyof typeof MOD_LABELS
|
||||
|
||||
if (mod === 'alt') {
|
||||
return IS_MAC ? '⌥' : 'Alt'
|
||||
}
|
||||
type ModPrefix = `${'mod+'|''}${'alt+'|''}${'shift+'|''}`
|
||||
|
||||
if (mod === 'shift') {
|
||||
return IS_MAC ? '⇧' : 'Shift'
|
||||
}
|
||||
type ModPrefixedCombo<Suffix extends string> =
|
||||
| `${ModPrefix}${Suffix}`
|
||||
| ModKey
|
||||
| 'mod+alt' | 'mod+shift' | 'alt+shift' | 'mod+alt+shift'
|
||||
| 'ctrl+tab' | 'ctrl+shift+tab'
|
||||
| `ctrl+${Digit}`
|
||||
|
||||
return mod
|
||||
export type Combo = ModPrefixedCombo<BaseKey>
|
||||
export type FakeCombo = ModPrefixedCombo<BaseKey | '@' | '?'>
|
||||
|
||||
// Human-readable keys, e.g. "mod+shift+k" returns ["⌘","⇧","K"] on macos, ["Ctrl","Shift","K"] elsewhere.
|
||||
export function normalizeCombo(combo: Combo): string[] {
|
||||
const parts = combo.split('+')
|
||||
|
||||
return parts.map(p => labelForToken(p.trim()))
|
||||
}
|
||||
|
||||
// Per-key display tokens, e.g. ["⌘", "K"] on macOS, ["Ctrl", "K"] elsewhere —
|
||||
@@ -165,14 +202,18 @@ export function comboTokens(combo: string): string[] {
|
||||
const parts = combo.split('+')
|
||||
const base = parts.pop() ?? ''
|
||||
|
||||
return [...parts.map(labelForMod), labelForBase(base)]
|
||||
return [...parts.map(labelForToken), labelForToken(base)]
|
||||
}
|
||||
|
||||
// Human-readable label, e.g. "⌘⇧K" on macOS, "Ctrl+Shift+K" elsewhere.
|
||||
export function formatCombo(combo: string): string {
|
||||
const tokens = comboTokens(combo)
|
||||
// Human-readable label, e.g. "mod+shift+k" returns "⌘⇧K" on macOS, "Ctrl+Shift+K" elsewhere.
|
||||
export function formatCombo(combo: Combo): string {
|
||||
return normalizeCombo(combo).join(IS_MAC ? '' : '+')
|
||||
}
|
||||
|
||||
return IS_MAC ? tokens.join('') : tokens.join('+')
|
||||
|
||||
// like `formatCombo` but allows any input like `@`
|
||||
export function formatFakeCombo(combo: FakeCombo): string {
|
||||
return normalizeCombo(combo as Combo).join(IS_MAC ? '' : '+')
|
||||
}
|
||||
|
||||
// True when focus is in a text-entry surface, so bare-key shortcuts don't fire
|
||||
@@ -190,6 +231,6 @@ export function isEditableTarget(target: EventTarget | null): boolean {
|
||||
|
||||
// A primary modifier (Cmd/Ctrl/Control) fires even while typing (e.g. ⌘K or
|
||||
// ⌃Tab from the composer); bare/Shift-only combos are suppressed in inputs.
|
||||
export function comboAllowedInInput(combo: string): boolean {
|
||||
export function comboAllowedInInput(combo: Combo): boolean {
|
||||
return /^(?:mod|ctrl)(?:\+|$)/.test(combo)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
type KeybindBindings
|
||||
} from '@/lib/keybinds/actions'
|
||||
import { canonicalizeCombo } from '@/lib/keybinds/combo'
|
||||
import type { Combo } from '@/lib/keybinds/combo'
|
||||
import { arraysEqual, persistString, storedString } from '@/lib/storage'
|
||||
|
||||
const STORAGE_KEY = 'hermes.desktop.keybinds'
|
||||
@@ -28,7 +29,7 @@ function loadBindings(): KeybindBindings {
|
||||
const value = parsed[id]
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
base[id] = value.filter((combo): combo is string => typeof combo === 'string')
|
||||
base[id] = value.filter((combo): combo is string => typeof combo === 'string') as Combo[]
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
@@ -78,7 +79,7 @@ export const $comboIndex = computed($bindings, bindings => {
|
||||
return index
|
||||
})
|
||||
|
||||
export function setBinding(actionId: string, combos: string[]): void {
|
||||
export function setBinding(actionId: string, combos: Combo[]): void {
|
||||
if (!keybindAction(actionId)) {
|
||||
return
|
||||
}
|
||||
@@ -101,7 +102,7 @@ export function resetAllBindings(): void {
|
||||
}
|
||||
|
||||
// Other actions that already use `combo` (excluding `actionId` itself).
|
||||
export function conflictsFor(actionId: string, combo: string): string[] {
|
||||
export function conflictsFor(actionId: string, combo: Combo): string[] {
|
||||
const bindings = $bindings.get()
|
||||
|
||||
return KEYBIND_ACTION_IDS.filter(id => id !== actionId && (bindings[id] ?? []).includes(combo))
|
||||
|
||||
Reference in New Issue
Block a user