@sorrell/ink-command
v1.0.0
Published
Register and dispatch command keybinds in Ink applications.
Downloads
19
Readme
Ink Command
@sorrell/ink-command provides focus-aware keyboard commands for Ink applications. It separates keybinds from the components that handle them, routes commands through nested UI scopes, and can render the commands that are currently available.
Features
- Define semantic commands independently from their handlers.
- Route input to the focused scope and optionally bubble it through parent scopes.
- Enable, disable, or replace subscriptions as components mount and unmount.
- Normalize printable, modifier, and named keys across supported platforms.
- Render active keybinds with
KeybindsDisplayor the ready-madeKeybindsFooter. - Resolve shared keybinds with a configurable conflict policy.
Installation
Install the package with its Ink and React peer dependencies:
npm install @sorrell/ink-command ink reactQuick start
Define commands at the application root, subscribe to them inside a focusable scope, and render a footer so users can see what is available.
import * as Ink from "ink";
import * as React from "react";
import {
CommandScope,
InkCommandProvider,
Key,
Keybind,
KeybindsFooter,
useCommandSubscriptions
} from "@sorrell/ink-command";
function Editor(): React.ReactElement
{
const InkApp = Ink.useApp();
const [ Status, SetStatus ] = React.useState("Ready");
useCommandSubscriptions({
"Editor.Quit": InkApp.exit,
"Editor.Save": () => SetStatus("Saved")
});
return (
<Ink.Box flexDirection="column">
<Ink.Text>{ Status }</Ink.Text>
<KeybindsFooter Focusable />
</Ink.Box>
);
}
function App(): React.ReactElement
{
return (
<InkCommandProvider
Commands={ {
"Editor.Quit": Keybind.Of("q"),
"Editor.Save": Keybind.Of(Key.Control, "s")
} }>
<CommandScope
AutoFocus
Focusable
Id="Editor">
<Editor />
</CommandScope>
</InkCommandProvider>
);
}
Ink.render(<App />);Command names are plain strings. Names such as Editor.Save and Document.Copy make ownership clear as an application grows.
Commands and subscriptions
A command definition describes which keybinds invoke a semantic action. A subscription supplies the behavior for that action in the current scope. Keeping the two separate lets the same command use different handlers in different parts of an application.
Defining commands
Pass root definitions to InkCommandProvider, or register definitions from a descendant component with useCommandDefinitions.
The concise form accepts one keybind or an array of alternate keybinds:
<InkCommandProvider
Commands={ {
"Editor.Close": [
Keybind.Of(Key.Escape),
Keybind.Of(Key.Control, "w")
],
"Editor.Save": Keybind.Of(Key.Control, "s")
} }>
{ children }
</InkCommandProvider>Use a full definition when the command also needs metadata or an enabled state:
useCommandDefinitions({
"Editor.Format": {
Description: "Format the active document",
Group: "Editor",
IsEnabled: canFormat,
Keybinds: [ Keybind.Of(Key.Control, Key.Shift, "f") ]
}
});Definitions registered for the same command name are merged while their components are mounted. Keybinds are deduplicated by their normalized form.
Subscribing to commands
For the common case, map command names directly to callbacks:
useCommandSubscriptions({
"Editor.Copy": () => copySelection(),
"Editor.Save": (Event) => saveDocument(Event.TargetScopeId)
});Use a subscription object to control routing and display behavior:
const Failures = useCommandSubscriptions({
"Editor.Save": {
AllowChildren: true,
Bubble: false,
Display: SaveCommandLabel,
IsEnabled: isDirty,
OnInvoke: saveDocument
}
});AllowChildren, Bubble, and IsEnabled default to true. The hook returns a record whose values indicate whether an ancestor scope blocks each requested subscription.
A callback may accept a CommandEvent containing the command name, normalized keybind, raw input, platform, target scope, owning scope, and dispatch phase.
Focus and scopes
CommandScope creates a routing boundary. When a focusable scope receives Ink focus, matching commands target that scope and then follow their subscription bubbling rules through its ancestors.
<CommandScope
AutoFocus
Focusable
Id="Sidebar"
IsActive={ isSidebarOpen }>
<Sidebar />
</CommandScope>Focusableconnects the scope to Ink's focus manager.AutoFocusasks Ink to focus the scope when it mounts.Idgives the scope a stable identity; a React-generated ID is used when omitted.IsActiveremoves an inactive scope from focus and command routing.
Scopes may be nested. This is useful when a screen provides general commands while a focused panel or modal provides more specific handlers.
Displaying active keybinds
KeybindsDisplay renders commands available on the current focused path. KeybindsFooter wraps the same display in a bordered footer.
<KeybindsFooter
EmptyFallback="No commands available"
Focusable
MaxRows={ 2 }
ShowDisabled
/>When a focusable display overflows, it can receive focus and page with the left and right arrow keys. Set Width to override terminal-width pagination, or provide RenderKeybind and RenderCommand to customize individual items. Set Scoped to show commands registered in descendant scopes instead of the focused path.
Subscription objects may also provide a Display component for a command-specific label:
function SaveCommandLabel(): React.ReactElement
{
return <Ink.Text>Save document</Ink.Text>;
}
useCommandSubscriptions({
"Editor.Save": {
Display: SaveCommandLabel,
OnInvoke: saveDocument
}
});Creating and formatting keybinds
Keybind is exported as a namespace containing its model and constructors. Use strings for printable keys and Key for modifiers or named keys.
import { FormatKeybind, Key, Keybind } from "@sorrell/ink-command";
const Save = Keybind.Of(Key.Control, "s");
const PreviousPage = Keybind.Of(Key.PageUp);
FormatKeybind(Save); // "Ctrl+S"
FormatKeybind(Save, { Platform: "macos" });Printable keys are normalized to lowercase for matching. Available modifier keys are Alt, Control, Hyper, Meta, Shift, and Super. Named keys include arrows, Backspace, Delete, End, Enter, Escape, Home, PageDown, PageUp, and Tab.
The provider's Platform prop accepts "auto", "linux", "macos", or "windows". It affects platform-sensitive normalization and labels such as Option, Cmd, and Win. useKeyboardCapabilities returns the library's conservative terminal capability model when behavior must account for ambiguous or unsupported input.
Keybind conflicts
Use InkCommandProvider's ConflictPolicy prop when multiple command names share a keybind:
| Policy | Behavior |
| --- | --- |
| ClosestScopeWins | Invokes the command with the subscription closest to the focused scope. This is the default. |
| FirstRegisteredWins | Invokes the command whose definition was registered first. |
| LastRegisteredWins | Invokes the command whose definition was registered most recently. |
| Warn | Retains the conflict and dispatches every matching command. |
API overview
| API | Purpose |
| --- | --- |
| InkCommandProvider | Owns command state, captures Ink input, and provides root definitions. |
| CommandScope | Creates a focus and routing boundary. |
| useCommandDefinitions | Registers command definitions for the lifetime of a component. |
| useCommandSubscriptions | Registers scoped handlers and reports blocked subscriptions. |
| useKeyboardCapabilities | Reads the current terminal keyboard capability model. |
| KeybindsDisplay | Renders active commands with customizable item renderers. |
| KeybindsFooter | Renders KeybindsDisplay inside a bordered footer. |
| Key | Provides modifier and named-key symbols. |
| Keybind | Creates, compares, and formats immutable keybind values. |
| FormatKeybind | Formats a keybind for a selected platform. |
All public APIs are exported from @sorrell/ink-command.
Development
Install dependencies and run the repository checks with:
npm install
npm run check
npm testBuild the package with npm run build. The included footer example has a separate build and run flow:
npm run build:example
npm run demoThe example source is in Example/KeybindsFooterApp.tsx.
License
MIT © 2026 Gage Sorrell
