/**
* Keybinding Manager — configurable keyboard shortcuts
*
* Loads overrides from ~/.swagmanager/keybindings.json.
* Only listed bindings are configurable; Enter, backspace, arrows stay hardcoded.
*/
import { existsSync, readFileSync } from "fs";
import { join } from "path";
import { homedir } from "os";
// ============================================================================
// TYPES
// ============================================================================
export interface KeybindingConfig {
cancel_stream?: string; // default: "escape"
toggle_expand?: string; // default: "ctrl+e"
toggle_thinking?: string; // default: "ctrl+t"
exit?: string; // default: "ctrl+c"
clear_line?: string; // default: "ctrl+u"
delete_word?: string; // default: "ctrl+w"
home?: string; // default: "ctrl+a"
}
export interface InkKey {
ctrl: boolean;
meta: boolean;
shift: boolean;
escape: boolean;
return: boolean;
tab: boolean;
backspace: boolean;
delete: boolean;
upArrow: boolean;
downArrow: boolean;
leftArrow: boolean;
rightArrow: boolean;
pageUp: boolean;
pageDown: boolean;
}
// ============================================================================
// DEFAULTS
// ============================================================================
const DEFAULTS: Required<KeybindingConfig> = {
cancel_stream: "escape",
toggle_expand: "ctrl+e",
toggle_thinking: "ctrl+t",
exit: "ctrl+c",
clear_line: "ctrl+u",
delete_word: "ctrl+w",
home: "ctrl+a",
};
// ============================================================================
// LOADER (cached at module level)
// ============================================================================
let _cached: KeybindingConfig | null = null;
export function loadKeybindings(): Required<KeybindingConfig> {
if (_cached !== null) return { ...DEFAULTS, ..._cached };
try {
const p = join(homedir(), ".swagmanager", "keybindings.json");
if (existsSync(p)) {
const raw = JSON.parse(readFileSync(p, "utf-8"));
// Only accept known keys
const filtered: KeybindingConfig = {};
for (const key of Object.keys(DEFAULTS) as (keyof KeybindingConfig)[]) {
if (typeof raw[key] === "string") {
filtered[key] = raw[key];
}
}
_cached = filtered;
} else {
_cached = {};
}
} catch {
_cached = {};
}
return { ...DEFAULTS, ..._cached };
}
// ============================================================================
// MATCHER — checks if a binding string matches an Ink key event
// ============================================================================
/**
* Check if an Ink useInput event matches a binding string.
*
* Binding format: "ctrl+<key>", "escape", "meta+<key>", or single char.
* Examples: "ctrl+e", "escape", "ctrl+c", "ctrl+t"
*/
export function matchesBinding(binding: string, input: string, key: Partial<InkKey>): boolean {
const parts = binding.toLowerCase().split("+");
if (parts.length === 1) {
// Simple key: "escape", "e", etc.
if (parts[0] === "escape") return !!key.escape;
return input === parts[0] && !key.ctrl && !key.meta;
}
if (parts.length === 2) {
const [modifier, char] = parts;
if (modifier === "ctrl") {
return !!key.ctrl && input === char;
}
if (modifier === "meta" || modifier === "alt") {
return !!key.meta && input === char;
}
}
return false;
}
// ============================================================================
// CONTROL CHAR CONVERTER — for raw stdin matching
// ============================================================================
const CTRL_MAP: Record<string, string> = {
"a": "\x01", "b": "\x02", "c": "\x03", "d": "\x04", "e": "\x05",
"f": "\x06", "g": "\x07", "h": "\x08", "i": "\x09", "j": "\x0a",
"k": "\x0b", "l": "\x0c", "m": "\x0d", "n": "\x0e", "o": "\x0f",
"p": "\x10", "q": "\x11", "r": "\x12", "s": "\x13", "t": "\x14",
"u": "\x15", "v": "\x16", "w": "\x17", "x": "\x18", "y": "\x19",
"z": "\x1a",
};
/**
* Convert a binding string to a raw control character for stdin matching.
* Returns the string as-is if not a ctrl+<key> binding.
*
* "ctrl+u" -> "\x15"
* "ctrl+w" -> "\x17"
* "escape" -> "\x1b"
*/
export function bindingToControlChar(binding: string): string {
const parts = binding.toLowerCase().split("+");
if (parts.length === 1) {
if (parts[0] === "escape") return "\x1b";
return parts[0];
}
if (parts.length === 2 && parts[0] === "ctrl") {
return CTRL_MAP[parts[1]] || binding;
}
return binding;
}
/**
* Reset cached keybindings (for testing or hot-reload).
*/
export function resetKeybindingCache(): void {
_cached = null;
}