mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
* feat(desktop): add useKeybindHint hook and TipKeybindLabel Add a shared hook that reads the current keybind combo for an action id from the $bindings store (rebindable) or KEYBIND_READONLY (fixed), returning a formatted string or null when unbound. Add TipKeybindLabel — a convenience component that auto-reads both its label (from i18n) and keybind combo from the action registry. Pass only actionId for the common case; pass text to override when the tooltip is context-dependent. * fix(desktop): replace native title= on buttons with themed Tip Migrate all <button>/<Button> elements using the native HTML title= attribute to the instant, themed <Tip> component. Native tooltips are unstyled, delayed (~500ms OS default), and visually inconsistent with the app's instant themed tooltips. Also adds <Tip> wrappers to icon-only buttons that were missing tooltips entirely (dialog close, search clear, overlay close, master-detail pane controls, keybind panel rebind/reset buttons). Adds an enforcement test (no-native-title.test.ts) that scans all .tsx files for <button>/<Button> with title= and fails if any are found. Updates DESIGN.md with the icon-only button tooltip rule and keybind hint guidance. * feat(desktop): wire keybind hints into button tooltips Add actionId to TitlebarTool, StatusbarItem, and SidebarNavItem so their tooltips show the current keybind combo via TipKeybindLabel. Fix the hardcoded NEW_SESSION_KBD in the sidebar to read from $bindings so it stays live on rebind. Wired surfaces: - Titlebar: sidebar toggle, flip panes, keybinds, settings - Statusbar: terminal toggle (view.showTerminal) - Status stack: open agents button (nav.agents) - Sidebar nav: new session, skills, messaging, artifacts * feat(desktop): move keybind panel to settings tab with search filter Move the keyboard shortcuts panel from a Radix Dialog into a proper Settings tab (/settings?tab=keybinds). The ⌘/ shortcut and titlebar keyboard button now navigate to this settings tab instead of toggling a dialog. Adds a search filter to filter shortcuts by label. - New: src/app/settings/keybind-settings.tsx (extracted from keybind-panel.tsx) - Delete: src/app/shell/keybind-panel.tsx (dialog wrapper removed) - Remove: $keybindPanelOpen atom and toggle/open/close functions - Add: IconKeyboard to lib/icons.ts - i18n: keybinds.search + settings.nav.keybinds (en, zh, zh-hant, ja) * refactor(desktop): unify worktree dialog into shared WorktreeDialog Extract the worktree creation dialog from StartWorkButton (sidebar) into a shared WorktreeDialog component. Both the sidebar's StartWorkButton and the composer's CodingStatusRow now use the same dialog, eliminating the duplicated UI. The shared dialog keeps the sidebar version's full feature set: - BaseBranchPicker (filterable base branch combobox) - Convert mode (check out an existing branch into a worktree) - Sanitized branch name input The coding row passes repoPath (from cwd) and onOpenWorktree (which carries the composer draft to the new session) so the unified dialog works everywhere there's a repo, not just inside an entered project.
274 lines
9 KiB
TypeScript
274 lines
9 KiB
TypeScript
import { useStore } from '@nanostores/react'
|
|
import { useMemo, useState } from 'react'
|
|
|
|
import { Codicon } from '@/components/ui/codicon'
|
|
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
|
|
import { Kbd, KbdCombo } from '@/components/ui/kbd'
|
|
import { SearchField } from '@/components/ui/search-field'
|
|
import { Tip } from '@/components/ui/tooltip'
|
|
import { useContributions } from '@/contrib/react/use-contributions'
|
|
import { useI18n } from '@/i18n'
|
|
import {
|
|
allKeybindActions,
|
|
KEYBIND_CATEGORIES,
|
|
KEYBIND_PANEL_ACTION,
|
|
KEYBIND_READONLY,
|
|
type KeybindActionMeta,
|
|
type KeybindReadonly,
|
|
KEYBINDS_AREA
|
|
} from '@/lib/keybinds/actions'
|
|
import { formatCombo } from '@/lib/keybinds/combo'
|
|
import { arraysEqual } from '@/lib/storage'
|
|
import {
|
|
$bindings,
|
|
$capture,
|
|
beginCapture,
|
|
bindingsFor,
|
|
conflictsFor,
|
|
endCapture,
|
|
resetAllBindings,
|
|
resetBinding
|
|
} from '@/store/keybinds'
|
|
|
|
import { SettingsContent } from './primitives'
|
|
|
|
export function KeybindSettings() {
|
|
const { t } = useI18n()
|
|
const bindings = useStore($bindings)
|
|
const k = t.keybinds
|
|
const [collapsed, setCollapsed] = useState<ReadonlySet<string>>(new Set())
|
|
// Subscribe so contributed actions appear/disappear live in the map.
|
|
useContributions(KEYBINDS_AREA)
|
|
const actionList = allKeybindActions()
|
|
const [query, setQuery] = useState('')
|
|
|
|
const openCombo = bindings[KEYBIND_PANEL_ACTION]?.[0]
|
|
|
|
const toggleCategory = (category: string) =>
|
|
setCollapsed(prev => {
|
|
const next = new Set(prev)
|
|
|
|
if (next.has(category)) {
|
|
next.delete(category)
|
|
} else {
|
|
next.add(category)
|
|
}
|
|
|
|
return next
|
|
})
|
|
|
|
// Filter actions and readonly shortcuts by label match against the query.
|
|
// When searching, categories auto-expand (collapsed state is ignored).
|
|
const isSearching = query.trim().length > 0
|
|
|
|
const filteredActions = useMemo(() => {
|
|
if (!isSearching) {
|
|
return null
|
|
}
|
|
|
|
const lower = query.toLowerCase()
|
|
|
|
return actionList.filter(action => {
|
|
if (action.id === KEYBIND_PANEL_ACTION) {
|
|
return false
|
|
}
|
|
|
|
const label = k.actions[action.id] ?? action.id
|
|
|
|
return label.toLowerCase().includes(lower) || action.id.includes(lower)
|
|
})
|
|
}, [actionList, isSearching, query, k.actions])
|
|
|
|
const filteredReadonly = useMemo(() => {
|
|
if (!isSearching) {
|
|
return null
|
|
}
|
|
|
|
const lower = query.toLowerCase()
|
|
|
|
return KEYBIND_READONLY.filter(shortcut => {
|
|
const label = k.actions[shortcut.id] ?? shortcut.id
|
|
|
|
return label.toLowerCase().includes(lower) || shortcut.id.includes(lower)
|
|
})
|
|
}, [isSearching, query, k.actions])
|
|
|
|
return (
|
|
<SettingsContent>
|
|
<div className="flex items-center justify-between gap-3 pb-3">
|
|
<div className="min-w-0">
|
|
<h2 className="text-sm font-semibold text-foreground">{k.title}</h2>
|
|
<p className="mt-0.5 text-[0.72rem] text-muted-foreground">
|
|
{k.subtitle(openCombo ? formatCombo(openCombo) : '')}
|
|
</p>
|
|
</div>
|
|
<button
|
|
className="flex shrink-0 items-center gap-1 rounded-md text-[0.72rem] text-muted-foreground hover:text-foreground"
|
|
onClick={resetAllBindings}
|
|
type="button"
|
|
>
|
|
<Codicon name="discard" size="0.8125rem" />
|
|
{k.resetAll}
|
|
</button>
|
|
</div>
|
|
|
|
<div className="pb-3">
|
|
<SearchField
|
|
aria-label={k.search}
|
|
containerClassName="w-full"
|
|
onChange={setQuery}
|
|
placeholder={k.search}
|
|
value={query}
|
|
/>
|
|
</div>
|
|
|
|
{isSearching ? (
|
|
<div className="px-2 py-1.5">
|
|
{filteredActions?.length === 0 && filteredReadonly?.length === 0 ? (
|
|
<p className="px-2.5 py-4 text-center text-[0.82rem] text-muted-foreground">—</p>
|
|
) : (
|
|
<>
|
|
{filteredActions?.map(action => (
|
|
<KeybindRow action={action} key={action.id} />
|
|
))}
|
|
{filteredReadonly?.map(shortcut => (
|
|
<ReadonlyRow key={shortcut.id} shortcut={shortcut} />
|
|
))}
|
|
</>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="px-2 py-1.5">
|
|
{KEYBIND_CATEGORIES.map(category => {
|
|
const actions = actionList.filter(
|
|
action => action.category === category && action.id !== KEYBIND_PANEL_ACTION
|
|
)
|
|
|
|
const readonly = KEYBIND_READONLY.filter(shortcut => shortcut.category === category)
|
|
|
|
if (actions.length === 0 && readonly.length === 0) {
|
|
return null
|
|
}
|
|
|
|
const sectionOpen = !collapsed.has(category)
|
|
|
|
return (
|
|
<section key={category}>
|
|
<CategoryHeader
|
|
label={k.categories[category] ?? category}
|
|
onToggle={() => toggleCategory(category)}
|
|
open={sectionOpen}
|
|
/>
|
|
{sectionOpen && actions.map(action => <KeybindRow action={action} key={action.id} />)}
|
|
{sectionOpen && readonly.map(shortcut => <ReadonlyRow key={shortcut.id} shortcut={shortcut} />)}
|
|
</section>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
</SettingsContent>
|
|
)
|
|
}
|
|
|
|
function CategoryHeader({ label, onToggle, open }: { label: string; onToggle: () => void; open: boolean }) {
|
|
return (
|
|
<button
|
|
className="group/kbd-cat flex w-fit items-center gap-1 px-2.5 pb-1 pt-3 text-left leading-none"
|
|
onClick={onToggle}
|
|
type="button"
|
|
>
|
|
<span className="text-[0.64rem] font-semibold uppercase tracking-[0.12em] text-muted-foreground/70">{label}</span>
|
|
<DisclosureCaret
|
|
className="text-(--ui-text-tertiary) opacity-0 transition group-hover/kbd-cat:opacity-100"
|
|
open={open}
|
|
size="0.6875rem"
|
|
/>
|
|
</button>
|
|
)
|
|
}
|
|
|
|
function KeybindRow({ action }: { action: KeybindActionMeta }) {
|
|
const { t } = useI18n()
|
|
const k = t.keybinds
|
|
const bindings = useStore($bindings)
|
|
const capture = useStore($capture)
|
|
|
|
// bindingsFor resolves stored overrides for late-registered (contributed)
|
|
// actions too — $bindings only carries built-ins, so a raw lookup would show
|
|
// the default instead of the user's rebinding for a plugin/contrib action.
|
|
const combos = bindingsFor(action.id, bindings)
|
|
const capturing = capture === action.id
|
|
const label = k.actions[action.id] ?? action.label ?? action.id
|
|
const isDefault = arraysEqual(combos, [...action.defaults])
|
|
|
|
const conflict = combos
|
|
.flatMap(combo => conflictsFor(action.id, combo).map(other => k.actions[other] ?? other))
|
|
.find(Boolean)
|
|
|
|
return (
|
|
<div className="group flex items-center gap-2.5 rounded-lg px-2.5 py-1 transition-colors hover:bg-(--chrome-action-hover)">
|
|
<span className="min-w-0 flex-1 truncate text-[0.82rem] text-foreground/90">{label}</span>
|
|
|
|
{conflict && (
|
|
<span className="flex size-4 items-center justify-center text-amber-500/90" title={k.conflictWith(conflict)}>
|
|
<Codicon name="warning" size="0.8125rem" />
|
|
</span>
|
|
)}
|
|
|
|
{/* Click the caps to rebind — the on-screen editor does the same thing. */}
|
|
<Tip label={k.rebind}>
|
|
<button
|
|
aria-label={k.rebind}
|
|
className="flex shrink-0 items-center gap-1 rounded-lg outline-none"
|
|
onClick={() => (capturing ? endCapture() : beginCapture(action.id))}
|
|
type="button"
|
|
>
|
|
{capturing ? (
|
|
<Kbd variant="capturing">{k.pressKey}</Kbd>
|
|
) : combos.length > 0 ? (
|
|
combos.map(combo => <KbdCombo combo={combo} key={combo} />)
|
|
) : (
|
|
<Kbd variant="ghost">{k.set}</Kbd>
|
|
)}
|
|
</button>
|
|
</Tip>
|
|
|
|
{/* Reset only shows once a binding diverges from its default; the spacer
|
|
holds the column otherwise so rows stay aligned. */}
|
|
{isDefault ? (
|
|
<span aria-hidden className="size-6 shrink-0" />
|
|
) : (
|
|
<Tip label={k.reset}>
|
|
<button
|
|
aria-label={k.reset}
|
|
className="grid size-6 shrink-0 place-items-center rounded-md text-muted-foreground/70 opacity-0 transition-all hover:bg-(--ui-control-active-background) hover:text-foreground group-hover:opacity-100"
|
|
onClick={() => resetBinding(action.id)}
|
|
type="button"
|
|
>
|
|
<Codicon name="discard" size="0.8125rem" />
|
|
</button>
|
|
</Tip>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// Fixed shortcut: same layout as KeybindRow but the caps aren't interactive and
|
|
// the trailing reset slot stays empty (spacer keeps the columns aligned).
|
|
function ReadonlyRow({ shortcut }: { shortcut: KeybindReadonly }) {
|
|
const { t } = useI18n()
|
|
const k = t.keybinds
|
|
const label = k.actions[shortcut.id] ?? shortcut.id
|
|
|
|
return (
|
|
<div className="flex items-center gap-2.5 rounded-lg px-2.5 py-1">
|
|
<span className="min-w-0 flex-1 truncate text-[0.82rem] text-foreground/75">{label}</span>
|
|
<div className="flex shrink-0 items-center gap-1">
|
|
{shortcut.keys.map(key => (
|
|
<KbdCombo combo={key} key={key} />
|
|
))}
|
|
</div>
|
|
<span aria-hidden className="size-6 shrink-0" />
|
|
</div>
|
|
)
|
|
}
|