momoi-keybind
v1.0.3
Published
A VSCode-like keybinding system for TypeScript projects
Maintainers
Readme
momoi-keybind
This project was built with Claude Code.
A VSCode-like keybinding system for TypeScript projects.
Features
- Standard shortcuts:
Ctrl+S,Ctrl+Shift+P, etc. - Chord (two-step input): VSCode-style sequences like
Ctrl+K Ctrl+C - Modifier key double-press:
Ctrl Ctrl,Shift Shift, etc. - When clauses: Context-based control like
editorFocus && !inputFocus - Mac/Windows support: Auto-switch with
CommandOrControl+S - User customization: Change shortcuts via JSON config files
- Preference API: Built-in logic for building keybinding settings UI
Installation
npm install momoi-keybindBasic Usage
1. Initialize InputService and Register Commands
import { InputService } from 'momoi-keybind'
const input = new InputService({
defaultKeybindings: [
{ key: 'Ctrl+S', command: 'file.save', when: 'editorFocus' },
{ key: 'Ctrl+K Ctrl+C', command: 'editor.commentLine' },
{ key: 'Ctrl Ctrl', command: 'quickOpen' },
{ key: 'Shift Shift', command: 'searchEverywhere' },
],
})
// Register commands
input.registerCommand('file.save', () => {
console.log('File saved')
})
input.registerCommand('quickOpen', () => {
console.log('Quick open')
})
// Set context
input.setContext('editorFocus', true)
// Start listening
input.start()2. Unregister via Disposer
const dispose = input.registerCommand('file.save', handler)
dispose() // unregister3. Event Subscription
input.on('commandExecuted', (commandId, args) => {
console.log(`Executed: ${commandId}`, args)
})
input.on('chordWaiting', (prefix) => {
showStatusBar(`(${prefix}) was pressed. Waiting for next key...`)
})
input.on('chordCancelled', () => {
hideStatusBar()
})4. Lifecycle
input.start() // Start listening
input.stop() // Stop listening (resumable)
input.dispose() // Full cleanupKey Format
| Type | Example | Description |
|---|---|---|
| Single key | "F1", "Escape" | Single key press |
| Modifier+key | "Ctrl+S", "Ctrl+Shift+P" | Combined with + |
| Chord | "Ctrl+K Ctrl+C" | Two-step input separated by space |
| Modifier double-press | "Ctrl Ctrl", "Shift Shift" | Same modifier key separated by space |
| Cross-platform | "CommandOrControl+S" | Cmd on macOS, Ctrl elsewhere |
Modifier Key Aliases
| Alias | Key |
|---|---|
| Ctrl, Control | Control |
| Shift | Shift |
| Alt, Option | Alt |
| Meta, Cmd, Command, Win, Super | Meta |
When Clauses
Same expression syntax as VSCode. Evaluates boolean expressions against context variables.
// Simple boolean
{ "key": "Ctrl+S", "command": "file.save", "when": "editorFocus" }
// Negation
{ "key": "Ctrl+P", "command": "quickOpen", "when": "!panelVisible" }
// AND
{ "key": "Ctrl+D", "command": "item.delete", "when": "listFocus && hasSelection" }
// OR
{ "key": "Enter", "command": "confirm", "when": "dialogOpen || menuOpen" }
// Comparison
{ "key": "Ctrl+Enter", "command": "editor.run", "when": "mode === 'edit'" }Setting context variables:
input.setContext('editorFocus', true)
input.setContext('mode', 'edit')
input.deleteContext('editorFocus')Switching Shortcuts per View
Just change context with setContext — when clauses are re-evaluated automatically on each key press.
const input = new InputService({
defaultKeybindings: [
{ key: 'Ctrl+Enter', command: 'editor.run', when: 'view === "editor"' },
{ key: 'Ctrl+Enter', command: 'chat.send', when: 'view === "chat"' },
],
})
// On navigation
function navigateTo(view: string) {
input.setContext('view', view)
}
navigateTo('editor') // Ctrl+Enter → editor.run
navigateTo('chat') // Ctrl+Enter → chat.sendUser Keybinding Customization (JSON Config)
Config File Format (keybindings.json)
VSCode-compatible JSONC format. Comments are supported.
[
// Override a key
{ "key": "Ctrl+Shift+S", "command": "file.save" },
// Add a new binding
{ "key": "Ctrl+N", "command": "file.new" },
// Remove a default binding (prefix command with "-")
{ "key": "", "command": "-edit.undo" }
]Loading and Applying
import {
InputService,
loadKeybindingsFromFile, // Node.js / Electron
loadKeybindingsFromStorage, // Browser localStorage
loadKeybindingsFromURL, // Browser fetch
parseKeybindingsJSON, // From JSON string
} from 'momoi-keybind'
// --- Node.js / Electron ---
const result = await loadKeybindingsFromFile('./keybindings.json')
if (result.errors.length > 0) {
console.warn('Config errors:', result.errors)
}
const input = new InputService({
defaultKeybindings: myDefaults,
userKeybindings: result.entries,
})
// --- Browser localStorage ---
const result = loadKeybindingsFromStorage('app-keybindings')
const input = new InputService({
defaultKeybindings: myDefaults,
userKeybindings: result.entries,
})Saving
import { saveKeybindingsToFile, saveKeybindingsToStorage } from 'momoi-keybind'
// Node.js
await saveKeybindingsToFile('./keybindings.json', entries)
// Browser
saveKeybindingsToStorage('app-keybindings', entries)Preference API (Building Settings UI)
KeybindingPreference is a headless logic layer for building keybinding settings UI.
Framework-agnostic — works with React, Vue, Svelte, or any other framework.
Basic Usage
const pref = input.createPreference()
// Get all bindings (with source, isOverridden, isRemoved info)
const all = pref.getAll()
// Search by command
const saves = pref.getByCommand('file.save')
// Search by key (partial match)
const ctrlK = pref.getByKey('Ctrl+K', true)Changing Keys
pref.changeKey('file.save', 'Ctrl+Shift+S', 'Ctrl+S')
// → Removes default Ctrl+S and re-registers with Ctrl+Shift+S
// → when clauses and args are automatically preservedShortcut Recording ("Press a new key" UI)
// Start recording
const { promise, dispose } = pref.recordShortcut()
// Show "Press a key..." in UI
const shortcut = await promise // Waits until user presses a key
// shortcut = "Ctrl+Shift+N" etc.
if (shortcut) {
// Conflict check
const conflict = pref.checkConflict(shortcut, 'file.save')
if (conflict && !conflict.disjointWhen) {
// Warn: "Ctrl+Shift+N is already assigned to file.new"
}
pref.changeKey('file.save', shortcut)
}
// On cancel button click
dispose() // → promise resolves with nullConflict Detection
// Detect all conflicts
const conflicts = pref.detectAllConflicts()
// → [{ key: "Ctrl+S", entries: [...], disjointWhen: false }]
// Check conflict for a specific key (excluding self)
const conflict = pref.checkConflict('Ctrl+S', 'my.command')
if (conflict && !conflict.disjointWhen) {
// Actually conflicting
}
// disjointWhen === true means when clauses are mutually exclusive, so no real conflict
// e.g., when="editorFocus" vs when="!editorFocus"Add / Remove / Reset Bindings
// Add
pref.addBinding({ key: 'Ctrl+N', command: 'file.new' })
// Remove (adds "-command" entry if it's a default binding)
pref.removeBinding('edit.undo')
// Reset a specific command to default
pref.resetToDefault('file.save')
// Clear all user settings
pref.resetAll()Saving User Settings
// Get as JSON string
const json = pref.toJSON()
// Save to file (Node.js)
await saveKeybindingsToFile('./keybindings.json', pref.getUserBindings())
// Save to localStorage (Browser)
saveKeybindingsToStorage('app-keybindings', pref.getUserBindings())Change Notification
pref.onChange((changes) => {
// changes: KeybindingChange[]
// type: 'add' | 'modify' | 'remove' | 'reset'
refreshUI()
})Settings UI Implementation Example
const pref = input.createPreference()
// 1. Render table
function renderTable() {
const bindings = pref.getAll()
for (const b of bindings) {
renderRow(b.key, b.command, b.when, b.source, b.isOverridden)
}
}
// 2. Change key button
async function onChangeKeyClicked(command: string) {
const { promise, dispose } = pref.recordShortcut()
showDialog('Press a new key...')
const shortcut = await promise
if (!shortcut) return
const conflict = pref.checkConflict(shortcut, command)
if (conflict && !conflict.disjointWhen) {
if (!confirm(`${shortcut} is already assigned to ${conflict.entries[0].command}. Override?`)) {
return
}
}
pref.changeKey(command, shortcut)
renderTable()
}
// 3. Save
async function onSave() {
await saveKeybindingsToFile('./keybindings.json', pref.getUserBindings())
}
// 4. Auto-save (browser)
pref.onChange(() => {
saveKeybindingsToStorage('app-keybindings', pref.getUserBindings())
})InputServiceOptions
interface InputServiceOptions {
/** Event listener target (default: document) */
target?: EventTarget
/** Listen in capture phase (default: false) */
capture?: boolean
/** Modifier double-press timeout in ms (default: 300) */
modifierDoublePressTimeout?: number
/** Default keybindings */
defaultKeybindings?: KeybindingEntry[]
/** User keybindings */
userKeybindings?: KeybindingEntry[]
}API Reference
Classes
| Class | Description |
|---|---|
| InputService | Main facade. Registers keybindings, listens for input, executes commands |
| KeybindingPreference | Headless logic for settings UI. Created via input.createPreference() |
| KeybindingRegistry | Merges default/user settings. Not typically used directly |
| ModifierDetector | Detects modifier key double-press. Not typically used directly |
Loader Functions
| Function | Environment | Description |
|---|---|---|
| parseKeybindingsJSON(json) | Universal | Parse JSONC string and validate |
| validateKeybindings(data) | Universal | Validate parsed array |
| loadKeybindingsFromFile(path) | Node.js | Load from file (returns empty on ENOENT) |
| saveKeybindingsToFile(path, entries) | Node.js | Save to file |
| loadKeybindingsFromURL(url) | Browser | Load via fetch |
| loadKeybindingsFromStorage(key) | Browser | Load from localStorage |
| saveKeybindingsToStorage(key, entries) | Browser | Save to localStorage |
Utilities
| Function | Description |
|---|---|
| createShortcutRecorder(options?) | Promise wrapper for ShoSho's recording |
| detectConflicts(bindings) | Detect all keybinding conflicts |
| checkKeyConflict(key, bindings, exclude?) | Check conflict for a specific key |
| evaluateWhen(expr, context) | Synchronously evaluate a when clause |
| isModifierOnlyShortcut(key) | Check if shortcut is modifier-only |
Type Definitions
All major types are importable from momoi-keybind.
import type {
KeybindingEntry, // { key, command, when?, args? }
ResolvedKeybinding, // KeybindingEntry + source
KeybindingView, // For preference display (isOverridden, isRemoved)
KeyConflict, // { key, entries, disjointWhen }
KeybindingChange, // Change diff (add | modify | remove | reset)
LoadResult, // { entries, errors }
LoadError, // { index, message, raw }
ModifierKey, // 'Control' | 'Shift' | 'Alt' | 'Meta'
InputServiceOptions,
InputServiceEvents,
CommandHandler,
ContextValues,
RecordShortcutOptions,
} from 'momoi-keybind'License
MIT
