Lesson 5: Multi Key Sequences
Build Vim-style multi-key sequences with prefix matching and timeouts.
Lesson 5 of 8 — 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
The timeout controls the maximum delay between steps. Finding the right value depends on your users.
| Value | Feel | Good for |
|---|---|---|
| 300ms | Snappy, requires quick fingers | Gaming, power users |
| 600ms | Balanced (default) | Most applications |
| 1000ms | Forgiving | Accessibility, 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
Navigation (G prefix)
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
- Register three navigation shortcuts that share the G prefix.
- Create a VS Code-style chord: Ctrl+K, Ctrl+S to save all files.
- Build a sequence state indicator that shows when a sequence is in progress.