How Tabby Supports Multi-Chord Shortcuts: A Technical Deep Dive
Tabby implements multi-chord shortcuts by treating user input as an ordered sequence of keystroke chords, storing these sequences as nested string arrays in the configuration, and matching them against a rolling history of recent keystrokes centralized in the HotkeysService.
Tabby, the open-source terminal emulator by Eugeny, provides an extensible hotkey framework that handles both simple key combinations and complex multi-chord shortcuts like Ctrl-K → C. The system is designed around the concept of chords—simultaneous key combinations that can be chained together into sequences. This architecture allows power users to create Emacs-style keybindings while keeping the implementation maintainable in tabby-core/src/services/hotkeys.service.ts.
Chords vs. Multi-Chord Sequences
In Tabby's terminology, a chord represents a single simultaneous key combination (e.g., Ctrl+K or Alt+Shift+T). A multi-chord shortcut is an ordered list of such chords that must be pressed in sequence, such as Ctrl+K followed by P.
The configuration layer supports three distinct formats for defining these shortcuts:
- String – A single chord (e.g.,
"Ctrl+C"). - Array of strings – A multi-chord sequence (e.g.,
["Ctrl+K", "P"]). - Array of arrays – Multiple alternative sequences for the same action (e.g.,
[["Ctrl+K", "C"], ["Alt+X", "Y"]]).
When Tabby initializes, HotkeysService.getHotkeysConfigRecursive recursively normalizes these nested objects into a flat, predictable map where every value is an array of possible sequences. This normalization ensures the matching logic can treat single chords and multi-chord sequences uniformly.
Event Capture and Keystroke History
Every keyboard event flows through HotkeysService.pushKeyEvent, which maintains two critical pieces of state:
pressedKeys– A set tracking currently depressed keys.lastKeystrokes– A rolling history of recently completed chords.
The helper functions in tabby-core/src/services/hotkeys.util.ts handle canonical naming. getKeystrokeName converts raw keydown/keyup events into normalized strings like "Ctrl-Shift-A", ensuring consistent matching regardless of platform-specific key naming.
On each keyup event, the service builds a canonical name for the just-released combination and pushes it onto lastKeystrokes. This history preservation is essential for multi-chord detection, as the system must remember that Ctrl+K was pressed several seconds before the subsequent C key.
The Matching Algorithm
The core matching logic resides in HotkeysService.matchActiveHotkey. This method constructs the current sequence by combining historical keystrokes with the active one:
const currentSequence = this.getCurrentKeystrokes(); // past keystrokes + current one
The algorithm then iterates over every configured hotkey and its associated sequences:
- It discards sequences longer than the available history.
- It verifies that the last element of the sequence matches
this.pressedKeystroke. - It walks backward through
currentSequenceto confirm earlier chords appear in the correct order (allowing unrelated keystrokes between sequence elements).
When a match is found, the service emits the hotkey ID via the _hotkey subject. For multi-chord sequences, clearCurrentKeystrokes() resets the history immediately after firing, ensuring the next shortcut starts with a clean slate.
Components subscribe to hotkey$ (which filters out input fields) or unfilteredHotkey$ to react to these events, regardless of whether the trigger was a single chord or a five-chord sequence.
Configuring Multi-Chord Shortcuts
User Configuration Format
Define multi-chord shortcuts in Tabby's config file as nested arrays:
{
"hotkeys": {
"open-devtools": ["Ctrl+Shift+I"],
"toggle-panel": ["Ctrl+K", "P"],
"custom-action": [["Ctrl+K", "C"], ["Alt+X", "Y"]]
}
}
After getHotkeysConfigRecursive processes this, all entries become arrays of arrays, unifying the internal representation.
Reacting to Shortcuts in Components
Subscribe to the observable to handle specific multi-chord triggers:
import { Component, NgZone } from '@angular/core';
import { HotkeysService } from 'tabby-core';
@Component({ selector: 'app-example' })
export class ExampleComponent {
constructor(
private hotkeys: HotkeysService,
private zone: NgZone,
) {
this.hotkeys.hotkey$.subscribe(id => {
if (id === 'toggle-panel') {
this.zone.run(() => this.toggleSidePanel());
}
});
}
toggleSidePanel() {
// Implementation here
}
}
Registering New Actions
Expose new hotkey IDs by implementing HotkeyProvider (defined in tabby-core/src/api/hotkeyProvider.ts):
import { Injectable } from '@angular/core';
import { HotkeyDescription, HotkeyProvider, TranslateService } from 'tabby-core';
@Injectable()
export class MyHotkeyProvider extends HotkeyProvider {
private hotkeys: HotkeyDescription[] = [{
id: 'my-multi-chord-action',
name: this.translate.instant('Execute custom workflow')
}];
constructor(private translate: TranslateService) { super(); }
async provide(): Promise<HotkeyDescription[]> {
return this.hotkeys;
}
}
Once registered, users can bind "my-multi-chord-action" to sequences like ["Ctrl+K", "O"] through the settings UI, and HotkeysService will recognize it automatically.
Key Source Files
tabby-core/src/services/hotkeys.service.ts– Core service capturing events, building keystroke history, and matching sequences.tabby-core/src/services/hotkeys.util.ts– Utilities for canonical key naming and ordering.tabby-core/src/api/hotkeyProvider.ts– Interface for modules exposing hotkey descriptions.tabby-terminal/src/hotkeys.ts– ExampleHotkeyProviderimplementation listing built-in terminal actions.tabby-settings/src/components/hotkeySettingsTab.component.ts– UI component for editing hotkeys and parsing user input into the configuration format.
Summary
- Tabby treats shortcuts as sequences of chords, where each chord is a simultaneous key combination.
- Configuration supports nested arrays to define multi-chord sequences and alternative bindings, normalized by
getHotkeysConfigRecursive. HotkeysServicemaintains a rolling keystroke history vialastKeystrokesand matches against it inmatchActiveHotkey.- The system allows arbitrary sequence lengths and clears the history after successful multi-chord matches to prevent state contamination.
- Components consume shortcuts through RxJS observables (
hotkey$), decoupling input handling from business logic.
Frequently Asked Questions
How many chords can a multi-chord shortcut contain?
Tabby imposes no hardcoded limit on sequence length in matchActiveHotkey. The algorithm simply checks if the sequence fits within the available history buffer. Practically, sequences longer than three or four chords become difficult for users to remember and execute, but the architecture supports theoretically unlimited depth.
Does Tabby's multi-chord system require a timeout between chords?
No explicit timeout is enforced in the current implementation. The system maintains the keystroke history indefinitely until a match occurs or a new chord begins. However, clearCurrentKeystrokes() resets the buffer immediately after a successful multi-chord match, ensuring subsequent shortcuts start fresh.
How does this compare to Emacs-style key chords?
Tabby's implementation closely mirrors Emacs' approach, where prefixes like Ctrl+X (C-x) open a keymap for subsequent chords. The critical difference is that Tabby uses a flat sequence array rather than nested keymaps. In tabby-core/src/services/hotkeys.service.ts, the matching logic linearly searches all configured sequences against the history, whereas Emacs uses a tree-based dispatch system.
Can extensions define default multi-chord shortcuts?
Yes. When creating a HotkeyProvider implementation, you can suggest default bindings in your module's configuration schema. However, user settings in store.hotkeys always override provider defaults. The HotkeysService merges these configurations during initialization, giving users final control over multi-chord assignments.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →