Skip to main content

Lesson 5: Multi Key Sequences

Build Vim-style multi-key sequences with prefix matching and timeouts.

Two ways to handle sequences

duck-vim offers two systems for multi-key sequences, each suited for different use cases.

Register a binding like 'g+d' in the Registry. The KeyHandler detects G as a prefix, waits for the next key, and fires the command if D arrives in time.

registry.register('g+d', { name: 'Go Dashboard', execute: () => {} })
registry.register('g+d', { name: 'Go Dashboard', execute: () => {} })

Best for: Simple character-based sequences (no modifiers per step).

This lesson covers both.


Registry sequences in detail

How prefixes work

When you register 'g+d', the registry marks 'g' as a prefix. Here's what happens when the user types:

User presses G. The KeyHandler builds descriptor 'g', checks the registry.

'g' is not a full command, but it IS a prefix. The handler starts a timer.

User presses D within the timeout. The handler builds 'g+d', finds the match, executes.

If the user presses something other than D, or the timeout expires, the buffer resets.

Retry behavior

If the user presses G, then X (no match), the handler resets and retries X as a standalone binding. This means:

registry.register('g+d', { name: 'Go Dashboard', execute: dashFn })
registry.register('x', { name: 'Delete', execute: deleteFn })
registry.register('g+d', { name: 'Go Dashboard', execute: dashFn })
registry.register('x', { name: 'Delete', execute: deleteFn })

Pressing G, X will: fail the g+d sequence, reset, then match x and fire deleteFn.

Multiple sequences sharing a prefix

registry.register('g+d', goToDashboard)
registry.register('g+s', goToSettings)
registry.register('g+p', goToProfile)
registry.register('g+d', goToDashboard)
registry.register('g+s', goToSettings)
registry.register('g+p', goToProfile)

All three share the prefix G. When the user presses G, the handler waits. The next key determines which command fires (or none, if it doesn't match).


SequenceManager in detail

Basic usage

import { SequenceManager } from '@gentleduck/vim/sequence'
 
const manager = new SequenceManager()
 
// Ctrl+K, then Ctrl+C
manager.register({
  steps: [
    { binding: 'ctrl+k' },
    { binding: 'ctrl+c' },
  ],
  handler: () => console.log('Comment!'),
  options: { timeout: 800 },
})
 
// You must wire up the event listener yourself
document.addEventListener('keydown', (e) => manager.handleKeyEvent(e))
import { SequenceManager } from '@gentleduck/vim/sequence'
 
const manager = new SequenceManager()
 
// Ctrl+K, then Ctrl+C
manager.register({
  steps: [
    { binding: 'ctrl+k' },
    { binding: 'ctrl+c' },
  ],
  handler: () => console.log('Comment!'),
  options: { timeout: 800 },
})
 
// You must wire up the event listener yourself
document.addEventListener('keydown', (e) => manager.handleKeyEvent(e))

Tracking state

The getState() method tells you how far along the most-advanced sequence is:

const state = manager.getState()
// { completedSteps: 1, totalSteps: 2, isMatching: true }
const state = manager.getState()
// { completedSteps: 1, totalSteps: 2, isMatching: true }

Use this to show a visual indicator:

function SequenceIndicator() {
  const ctx = useContext(KeyContext)
  const [state, setState] = useState({ isMatching: false, completedSteps: 0, totalSteps: 0 })
 
  useEffect(() => {
    const interval = setInterval(() => {
      if (ctx) setState(ctx.sequenceManager.getState())
    }, 50)
    return () => clearInterval(interval)
  }, [ctx])
 
  if (!state.isMatching) return null
 
  return (
    <div className="fixed bottom-4 right-4 bg-black text-white px-3 py-1 rounded text-sm">
      Step {state.completedSteps}/{state.totalSteps}
    </div>
  )
}
function SequenceIndicator() {
  const ctx = useContext(KeyContext)
  const [state, setState] = useState({ isMatching: false, completedSteps: 0, totalSteps: 0 })
 
  useEffect(() => {
    const interval = setInterval(() => {
      if (ctx) setState(ctx.sequenceManager.getState())
    }, 50)
    return () => clearInterval(interval)
  }, [ctx])
 
  if (!state.isMatching) return null
 
  return (
    <div className="fixed bottom-4 right-4 bg-black text-white px-3 py-1 rounded text-sm">
      Step {state.completedSteps}/{state.totalSteps}
    </div>
  )
}

The convenience function

For a single sequence without managing a SequenceManager instance:

import { createSequenceMatcher } from '@gentleduck/vim/sequence'
 
const matcher = createSequenceMatcher(
  ['g', 'd'],
  () => console.log('g+d!'),
  { timeout: 500 },
)
 
document.addEventListener('keydown', (e) => matcher.feed(e))
import { createSequenceMatcher } from '@gentleduck/vim/sequence'
 
const matcher = createSequenceMatcher(
  ['g', 'd'],
  () => console.log('g+d!'),
  { timeout: 500 },
)
 
document.addEventListener('keydown', (e) => matcher.feed(e))

React: useKeySequence

The React hook wraps SequenceManager:

import { useKeySequence } from '@gentleduck/vim/react'
 
function Editor() {
  // Two-key character sequence
  useKeySequence(['g', 'd'], () => navigate('/dashboard'))
 
  // Chord sequence with modifiers
  useKeySequence(['ctrl+k', 'ctrl+c'], () => commentSelection())
 
  // With options
  useKeySequence(['z', 'z'], () => centerView(), {
    timeout: 400,
    enabled: isEditorFocused,
  })
}
import { useKeySequence } from '@gentleduck/vim/react'
 
function Editor() {
  // Two-key character sequence
  useKeySequence(['g', 'd'], () => navigate('/dashboard'))
 
  // Chord sequence with modifiers
  useKeySequence(['ctrl+k', 'ctrl+c'], () => commentSelection())
 
  // With options
  useKeySequence(['z', 'z'], () => centerView(), {
    timeout: 400,
    enabled: isEditorFocused,
  })
}

Timeout tuning

ValueFeelGood for
300msSnappy, requires quick fingersGaming, power users
600msBalanced (default)Most applications
1000msForgivingAccessibility, complex sequences

You can set different timeouts per sequence in SequenceManager:

manager.register({
  steps: [...],
  handler: () => {},
  options: { timeout: 1000 }, // This sequence gets more time
})
manager.register({
  steps: [...],
  handler: () => {},
  options: { timeout: 1000 }, // This sequence gets more time
})

Common sequence patterns

registry.register('g+h', goHome)
registry.register('g+d', goDashboard)
registry.register('g+s', goSettings)
registry.register('g+p', goProfile)
registry.register('g+h', goHome)
registry.register('g+d', goDashboard)
registry.register('g+s', goSettings)
registry.register('g+p', goProfile)

VS Code chords (Ctrl+K prefix)

useKeySequence(['ctrl+k', 'ctrl+c'], commentLine)
useKeySequence(['ctrl+k', 'ctrl+u'], uncommentLine)
useKeySequence(['ctrl+k', 'ctrl+f'], formatDocument)
useKeySequence(['ctrl+k', 'ctrl+c'], commentLine)
useKeySequence(['ctrl+k', 'ctrl+u'], uncommentLine)
useKeySequence(['ctrl+k', 'ctrl+f'], formatDocument)

Vim-style double-tap

registry.register('d+d', deleteLine)
registry.register('y+y', yankLine)
registry.register('d+d', deleteLine)
registry.register('y+y', yankLine)

Exercises

  1. Register three navigation shortcuts that share the G prefix.
  2. Create a VS Code-style chord: Ctrl+K, Ctrl+S to save all files.
  3. Build a sequence state indicator that shows when a sequence is in progress.