Skip to content

Keybindings

The desktop app's keyboard actions, their default bindings and the user's overrides, a global dispatcher, and keycaps that draw a binding.

zidane-ui/keybindings holds the desktop app's keyboard actions: the catalog (every action with its default binding, label and description), a dispatcher that fires your handlers on the document, and KeyHint / FooterHint to draw a binding as keycaps. A KeybindingsHost keeps the user's overrides. The previews below run on a fixture host that rebinds two actions.

KeyHint: one chord, alternatives, punctuation
Command KControl WorCommand WControl `Shift Command I
FooterHint legends
Up ArrowDown ArrowNavigateReturnOpenEscapeClose
The catalog with two overrides (sidebar, new session)
  • paletteCommand K
  • sidebarCommand B
  • new sessionControl NorCommand N
  • close tabControl WorCommand W
  • terminalControl `orCommand J
  • keybindingsControl Y
Live: the dispatcher fires on this page
PressControl Y0 presses

Mount it#

TSX
import { createBrowserKeybindingsHost } from 'zidane-ui/adapters'
import { KeybindingsProvider, useKeybindings } from 'zidane-ui/keybindings'

const keybindings = createBrowserKeybindingsHost()

function Shortcuts() {
  useKeybindings({ toggleSidebar: () => toggle(), openPalette: () => openPalette() })
  return null
}

export function Root() {
  return (
    <KeybindingsProvider host={keybindings}>
      <Shortcuts />
      <App />
    </KeybindingsProvider>
  )
}

Without a provider the defaults apply. AppHostProvider (zidane-ui/app) puts host.keybindings in a KeybindingsProvider for you.

Register commands#

An app's shortcuts come from many parts: the session on show, the side panel, the zoom. Each part registers its handlers with the key command registry, and one dispatcher fires them. AppHostProvider mounts a KeyCommandsProvider; mount one yourself without it. The desktop app and the web app register the same commands this way, so their shortcuts match.

Registered: one part always, the session actions while a session is open
    Live: a registered handler fires on this page
    PressControl Shift X0 presses
    TSX
    import { useMemo } from 'react'
    import { useAppHost, useConfirm } from 'zidane-ui/app'
    import type { ChatSession } from 'zidane-ui/chat/headless'
    import { useKeyCommands } from 'zidane-ui/keybindings'
    import { usePaletteActions } from 'zidane-ui/palette'
    import { createSessionActions, sessionKeyCommands } from 'zidane-ui/session/headless'
    import { useWorkspaceKeyCommands } from 'zidane-ui/workspace'
    
    function SessionKeys({ session }: { session: ChatSession | null }) {
      const host = useAppHost()
      const confirm = useConfirm()
      const palette = usePaletteActions()
      const actions = useMemo(() => createSessionActions({ session: host.session, sessions: host.chat?.sessions, confirm }), [host, confirm])
      const panel = useWorkspaceKeyCommands({ scope: !!session, session: !!session })
      useKeyCommands({ ...sessionKeyCommands(actions, session), ...panel, openPalette: palette.toggle })
      return null
    }
    • Registering re-renders nothing. Handlers are read at press time; the dispatcher re-attaches only when the set of bound actions or the user's overrides change.
    • Parts merge in the order they mounted. A later part's handler for an action wins.
    • useKeyCommandActions() re-renders only when the set changes. useActionsPaletteSource() (zidane-ui/palette) lists the registered actions in the palette with their chords.
    • createKeyCommandRegistry() is the React-free registry, for code outside React.
    • useHelpKey(open, blocked) opens your shortcut reference on a bare ?, never while the user types or blocked() says a modal owns input.
    CommandsFrom
    sessionKeyCommands(actions, session)zidane-ui/session/headless: generate a title, compact, export, delete (asked first), favorite, copy the id
    useWorkspaceKeyCommands(options)zidane-ui/workspace: the panel and Todos toggles, tab cycling, ⌥⌘←/→ where the focus is, close the tab in reach
    zoomKeyCommands(prefs)zidane-ui/appearance/headless: zoom in, out, reset
    useSelectTurnKeys(options)zidane-ui/chat/transcript: enterSelectTurnMode, then the mode's keys and pointer
    useTranscriptFindKey(options)zidane-ui/chat/transcript: ⌘F to Files or Diffs in reach, else the conversation

    How the dispatcher matches#

    • A binding matches exactly: 'o' does not fire ctrl+o, and ctrl+o does not fire on ctrl+shift+o.
    • A spec may list alternatives: ctrl+w | cmd+w. The grammar is zidane's, so a keybindings.json reads the same in the terminal.
    • Bare keys are ignored while the user types in a field or the code editor. keyScopeOf(event) says who owns a key press: editor, input, menu or app. An editor host opts in with data-zg-keyscope="editor".
    • In the editor, ctrl plus a letter belongs to the editor (bindingAllowedIn), except closing the tab.
    • The tab keys (tabNext, tabPrev) match before the focused widget sees them.
    • Handlers are read at press time, so a fresh object each render costs nothing.

    matchesBinding(event, spec) is the matcher, for a listener of your own. Punctuation matches by its physical key where a layout types a stand-in.

    Pick a host#

    HostFromKeeps
    createBrowserKeybindingsHost()zidane-ui/adapterslocalStorage (zg:keybindings:v1). zidane-server keeps no keybindings.
    createFixtureKeybindingsHost(options)zidane-ui/keybindings/testingMemory, for demos and tests
    Your ownzidane-ui/keybindings/headlessAnything. createKeybindingOverrides(read) keeps the known actions of what read answers.
    MemberGives
    overrides, refresh()The user's specs per action. The first useKeybindings reads them once; refresh re-reads them and every dispatcher rebinds.
    save(map)Replace the overrides
    openInEditor(), location()Open the file they live in, and say where it is (the desktop app's ~/.zidane/keybindings.json)

    getMergedKeybindings(host) and useMergedKeybindings() give the bindings in effect.

    Parts#

    PartDoes
    KeyHintA binding as keycaps (size="sm" in dense rows). Alternatives are separated by the keyHintOr label.
    FooterHintA legend entry: the keys, then what they do
    KEYBINDING_DEFS, DEFAULT_KEYBINDINGSThe catalog and the default map

    Test your host#

    TypeScript
    import { describeKeybindingsHostContract } from 'zidane-ui/keybindings/testing/contract'
    
    describeKeybindingsHostContract('my host', backend => makeHarness(backend), ['save'])
    Entry pointHolds
    zidane-ui/keybindingsKeybindingsProvider, useKeybindings, useMergedKeybindings, KeyCommandsProvider, useKeyCommands, useKeyCommandRegistry, useKeyCommandActions, useHelpKey, bindKeyListeners, matchesBinding, KeyHint, FooterHint
    zidane-ui/keybindings/headlessThe catalog, KeybindingsHost, the overrides model, createKeyCommandRegistry, keyScopeOf, the labels
    zidane-ui/keybindings/testingcreateFixtureKeybindingsHost
    zidane-ui/keybindings/testing/contractdescribeKeybindingsHostContract