From 698525320a8e9d143fbd154b6ad4f7b48247be7b Mon Sep 17 00:00:00 2001 From: oratis Date: Thu, 28 May 2026 13:54:39 +0800 Subject: [PATCH] =?UTF-8?q?feat(core,cli):=20M8=20=E2=80=94=20keybindings.?= =?UTF-8?q?json=20+=20Vim=20mode=20state=20machine?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit · packages/core/src/keybindings/index.ts (NEW, ~210 lines) - DEFAULT_KEYBINDINGS: 6 Emacs-style + 11 Vim defaults - loadKeybindings(home) — reads ~/.deepcode/keybindings.json + merges - saveKeybindings(config, home) - resolveKeyAction(chord, bindings, opts) — chord lookup w/ Vim `when` - normalizeChord() — modifier-order-insensitive normalization - VimState class — NORMAL/INSERT/VISUAL state machine with multi-char chord buffering (e.g. `gg`) · packages/core/src/keybindings/index.test.ts — 18 tests · apps/cli/src/commands.ts - /keybindings — lists configured bindings + path to override file - /vim — toggles config.vim and persists to ~/.deepcode/keybindings.json Tests: core 365 → 383 (+18); cli 47 (commands unchanged, /keybindings + /vim are slash registry entries verified via the existing list() test). Total 412 → 430 passing. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/cli/src/commands.ts | 50 +++++ packages/core/src/index.ts | 15 ++ packages/core/src/keybindings/index.test.ts | 143 +++++++++++++ packages/core/src/keybindings/index.ts | 223 ++++++++++++++++++++ 4 files changed, 431 insertions(+) create mode 100644 packages/core/src/keybindings/index.test.ts create mode 100644 packages/core/src/keybindings/index.ts diff --git a/apps/cli/src/commands.ts b/apps/cli/src/commands.ts index 5837ee4..e8f3f1f 100644 --- a/apps/cli/src/commands.ts +++ b/apps/cli/src/commands.ts @@ -287,6 +287,54 @@ export const TodosCommand: SlashCommand = { }, }; +export const KeybindingsCommand: SlashCommand = { + name: '/keybindings', + description: 'List configured key bindings.', + async run() { + const { loadKeybindings, DEFAULT_KEYBINDINGS } = await import('@deepcode/core'); + try { + const { config, bindings } = await loadKeybindings(); + const lines = [ + `Keybindings — enabled: ${config.enabled ? 'yes' : 'no'} · vim: ${config.vim ? 'on' : 'off'}`, + '', + `Defaults (${DEFAULT_KEYBINDINGS.length}):`, + ]; + for (const b of bindings.slice(0, 20)) { + const when = b.when ? ` [${b.when}]` : ''; + const desc = b.description ? ` — ${b.description}` : ''; + lines.push(` ${b.key.padEnd(14)} ${b.action}${when}${desc}`); + } + if (bindings.length > 20) lines.push(` ... and ${bindings.length - 20} more`); + lines.push(''); + lines.push('Edit ~/.deepcode/keybindings.json to add custom bindings.'); + return lines; + } catch (err) { + return [`(Error loading keybindings: ${(err as Error).message})`]; + } + }, +}; + +export const VimCommand: SlashCommand = { + name: '/vim', + description: 'Toggle Vim mode on/off (persisted to ~/.deepcode/keybindings.json).', + async run() { + const { loadKeybindings, saveKeybindings } = await import('@deepcode/core'); + try { + const { config } = await loadKeybindings(); + const next = !config.vim; + await saveKeybindings({ ...config, vim: next }); + return [ + `Vim mode is now ${next ? 'ON' : 'OFF'}.`, + next + ? 'Press Esc to enter NORMAL mode; press i / a / v to navigate.' + : 'Emacs-style bindings are active. Run /vim again to re-enable.', + ]; + } catch (err) { + return [`(Error toggling vim: ${(err as Error).message})`]; + } + }, +}; + export const PluginsCommand: SlashCommand = { name: '/plugins', description: 'List wired plugins and what they contribute.', @@ -336,6 +384,8 @@ export const BUILTIN_COMMANDS: SlashCommand[] = [ McpCommand, TodosCommand, PluginsCommand, + KeybindingsCommand, + VimCommand, ]; // ────────────────────────────────────────────────────────────────────────── diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 72cf565..daf053c 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -222,6 +222,21 @@ export { type PluginCapabilityBridge, } from './plugins/index.js'; +// Keybindings (M8 — ~/.deepcode/keybindings.json + Vim mode state machine) +export { + DEFAULT_KEYBINDINGS, + loadKeybindings, + saveKeybindings, + keybindingsPath, + resolveKeyAction, + normalizeChord, + VimState, + type KeyBinding, + type KeybindingsConfig, + type VimMode, + type KeyResolveOpts, +} from './keybindings/index.js'; + // System reminders (M3c-rest — date / cwd / todos / external file mods / AGENTS.md missing) export { buildSystemReminders, diff --git a/packages/core/src/keybindings/index.test.ts b/packages/core/src/keybindings/index.test.ts new file mode 100644 index 0000000..2fcc813 --- /dev/null +++ b/packages/core/src/keybindings/index.test.ts @@ -0,0 +1,143 @@ +import { promises as fs } from 'node:fs'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + DEFAULT_KEYBINDINGS, + loadKeybindings, + normalizeChord, + resolveKeyAction, + saveKeybindings, + VimState, +} from './index.js'; + +describe('normalizeChord', () => { + it('sorts modifiers consistently', () => { + expect(normalizeChord('Shift+Ctrl+A')).toBe(normalizeChord('ctrl+shift+a')); + expect(normalizeChord('Alt+Ctrl+Shift+B')).toBe(normalizeChord('ctrl+shift+alt+b')); + }); + it('preserves multi-chord sequences', () => { + expect(normalizeChord('esc esc')).toBe('esc esc'); + expect(normalizeChord('g g')).toBe('g g'); + }); +}); + +describe('resolveKeyAction', () => { + it('finds default ctrl+a', () => { + const b = resolveKeyAction('ctrl+a', DEFAULT_KEYBINDINGS); + expect(b?.action).toBe('cursor-line-start'); + }); + it('finds esc esc multi-chord', () => { + const b = resolveKeyAction('esc esc', DEFAULT_KEYBINDINGS); + expect(b?.action).toBe('/rewind'); + }); + it('respects vim `when` restriction', () => { + // `i` is INSERT-vim only when vim is enabled and mode is NORMAL + expect(resolveKeyAction('i', DEFAULT_KEYBINDINGS)).toBeUndefined(); + const b = resolveKeyAction('i', DEFAULT_KEYBINDINGS, { vim: true, vimMode: 'NORMAL' }); + expect(b?.action).toBe('vim-insert-mode'); + }); + it('returns undefined for unknown chord', () => { + expect(resolveKeyAction('ctrl+xyz', DEFAULT_KEYBINDINGS)).toBeUndefined(); + }); + it('later entries override earlier on conflict', () => { + const custom = [ + { key: 'ctrl+a', action: 'orig' }, + { key: 'ctrl+a', action: 'override' }, + ]; + expect(resolveKeyAction('ctrl+a', custom)?.action).toBe('override'); + }); +}); + +describe('loadKeybindings / saveKeybindings', () => { + let home: string; + beforeEach(async () => { + home = await mkdtemp(join(tmpdir(), 'dc-kb-')); + }); + afterEach(async () => { + await rm(home, { recursive: true, force: true }); + }); + + it('returns defaults when no file exists', async () => { + const { bindings, config } = await loadKeybindings(home); + expect(config.enabled).toBe(true); + expect(config.vim).toBe(false); + expect(bindings).toContain(DEFAULT_KEYBINDINGS[0]); + }); + + it('merges user bindings after defaults', async () => { + await fs.mkdir(join(home, '.deepcode'), { recursive: true }); + await saveKeybindings( + { enabled: true, vim: true, bindings: [{ key: 'ctrl+m', action: '/mode' }] }, + home, + ); + const { config, bindings } = await loadKeybindings(home); + expect(config.vim).toBe(true); + expect(resolveKeyAction('ctrl+m', bindings)?.action).toBe('/mode'); + }); + + it('rethrows on malformed JSON', async () => { + await fs.mkdir(join(home, '.deepcode'), { recursive: true }); + await fs.writeFile(join(home, '.deepcode', 'keybindings.json'), '{ broken', 'utf8'); + await expect(loadKeybindings(home)).rejects.toThrow(); + }); +}); + +describe('VimState', () => { + it('starts in INSERT mode', () => { + expect(new VimState().mode).toBe('INSERT'); + }); + + it('esc in INSERT switches to NORMAL', () => { + const s = new VimState(); + s.feed('esc', DEFAULT_KEYBINDINGS); + expect(s.mode).toBe('NORMAL'); + }); + + it('i in NORMAL switches to INSERT', () => { + const s = new VimState(); + s.mode = 'NORMAL'; + s.feed('i', DEFAULT_KEYBINDINGS); + expect(s.mode).toBe('INSERT'); + }); + + it('a in NORMAL switches to INSERT (append)', () => { + const s = new VimState(); + s.mode = 'NORMAL'; + s.feed('a', DEFAULT_KEYBINDINGS); + expect(s.mode).toBe('INSERT'); + }); + + it('v in NORMAL enters VISUAL', () => { + const s = new VimState(); + s.mode = 'NORMAL'; + s.feed('v', DEFAULT_KEYBINDINGS); + expect(s.mode).toBe('VISUAL'); + }); + + it('gg multi-chord resolves to cursor-buffer-start', () => { + const s = new VimState(); + s.mode = 'NORMAL'; + expect(s.feed('g', DEFAULT_KEYBINDINGS)).toBeUndefined(); // pending + expect(s.pending).toBe('g'); + const action = s.feed('g', DEFAULT_KEYBINDINGS); + expect(action).toBe('cursor-buffer-start'); + expect(s.pending).toBe(''); + }); + + it('unknown chord after a prefix clears pending', () => { + const s = new VimState(); + s.mode = 'NORMAL'; + s.feed('g', DEFAULT_KEYBINDINGS); + expect(s.pending).toBe('g'); + const action = s.feed('z', DEFAULT_KEYBINDINGS); + expect(action).toBeUndefined(); + expect(s.pending).toBe(''); + }); + + it('NORMAL-mode-only chord does not fire in INSERT', () => { + const s = new VimState(); // INSERT + expect(s.feed('i', DEFAULT_KEYBINDINGS)).toBeUndefined(); + }); +}); diff --git a/packages/core/src/keybindings/index.ts b/packages/core/src/keybindings/index.ts new file mode 100644 index 0000000..eaf826b --- /dev/null +++ b/packages/core/src/keybindings/index.ts @@ -0,0 +1,223 @@ +// Keybindings — ~/.deepcode/keybindings.json schema + loader + lookup. +// Spec: docs/DEVELOPMENT_PLAN.md §3.15 (M8) +// +// Each entry maps a key chord to an action. Actions can be: +// · A slash command (`"action": "/help"`) +// · A literal string insertion (`"action": "insert:hello"`) +// · A built-in action name (`"action": "clear-input"`) +// +// Key chord syntax: modifiers separated by `+`, key last. Examples: +// "ctrl+a" (start of line) +// "ctrl+shift+t" (open in new tab) +// "esc esc" (sequence — two escapes) +// "g g" (Vim — two g's; vim-mode only) +// +// We don't enforce uniqueness — the most-specific match wins, with later +// entries overriding earlier on tie. + +import { promises as fs } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +export interface KeyBinding { + /** Whitespace-separated chord sequence (e.g. "ctrl+a" or "esc esc"). */ + key: string; + /** Action — see header for the 3 forms. */ + action: string; + /** Optional Vim-mode restriction: NORMAL | INSERT | VISUAL. Falsy = all modes. */ + when?: 'NORMAL' | 'INSERT' | 'VISUAL'; + /** Free-text description shown in /keybindings. */ + description?: string; +} + +export interface KeybindingsConfig { + /** Top-level on/off. */ + enabled?: boolean; + /** Whether Vim mode is active. */ + vim?: boolean; + /** Custom bindings; merged after defaults. */ + bindings?: KeyBinding[]; +} + +export function keybindingsPath(home: string): string { + return join(home, '.deepcode', 'keybindings.json'); +} + +export const DEFAULT_KEYBINDINGS: KeyBinding[] = [ + { key: 'ctrl+a', action: 'cursor-line-start', description: 'Move to start of line.' }, + { key: 'ctrl+e', action: 'cursor-line-end', description: 'Move to end of line.' }, + { key: 'ctrl+k', action: 'kill-to-end', description: 'Kill from cursor to line end.' }, + { key: 'ctrl+u', action: 'kill-to-start', description: 'Kill from cursor to line start.' }, + { key: 'ctrl+l', action: '/clear', description: 'Clear conversation history.' }, + { key: 'esc esc', action: '/rewind', description: 'Open rewind picker.' }, + // Vim defaults (only fire when vim:true) + { key: 'esc', action: 'vim-normal-mode', when: 'INSERT', description: 'Switch to NORMAL mode.' }, + { key: 'i', action: 'vim-insert-mode', when: 'NORMAL', description: 'Switch to INSERT mode.' }, + { key: 'a', action: 'vim-append-mode', when: 'NORMAL', description: 'Append (insert after cursor).' }, + { key: 'v', action: 'vim-visual-mode', when: 'NORMAL', description: 'Enter VISUAL mode.' }, + { key: '0', action: 'cursor-line-start', when: 'NORMAL' }, + { key: '$', action: 'cursor-line-end', when: 'NORMAL' }, + { key: 'g g', action: 'cursor-buffer-start', when: 'NORMAL' }, + { key: 'shift+g', action: 'cursor-buffer-end', when: 'NORMAL' }, + { key: 'd d', action: 'kill-line', when: 'NORMAL' }, + { key: 'y y', action: 'yank-line', when: 'NORMAL' }, + { key: 'p', action: 'paste-after', when: 'NORMAL' }, + { key: 'u', action: 'undo', when: 'NORMAL' }, +]; + +export async function loadKeybindings(home: string = homedir()): Promise<{ + config: KeybindingsConfig; + bindings: KeyBinding[]; +}> { + let user: KeybindingsConfig = {}; + try { + const raw = await fs.readFile(keybindingsPath(home), 'utf8'); + user = JSON.parse(raw) as KeybindingsConfig; + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { + throw err; + } + } + const bindings = [...DEFAULT_KEYBINDINGS, ...(user.bindings ?? [])]; + return { + config: { enabled: user.enabled ?? true, vim: user.vim ?? false, bindings: user.bindings }, + bindings, + }; +} + +export async function saveKeybindings( + config: KeybindingsConfig, + home: string = homedir(), +): Promise { + const path = keybindingsPath(home); + await fs.mkdir(join(home, '.deepcode'), { recursive: true }); + await fs.writeFile(path, JSON.stringify(config, null, 2) + '\n', 'utf8'); +} + +export interface KeyResolveOpts { + /** Current Vim mode (when vim is enabled). */ + vimMode?: 'NORMAL' | 'INSERT' | 'VISUAL'; + /** Whether vim mode is enabled at all. */ + vim?: boolean; +} + +/** + * Look up the action for a chord. Returns undefined if no binding matches. + * When vim mode is on, `when` restrictions apply; later entries override + * earlier on identical chord+restriction. + */ +export function resolveKeyAction( + chord: string, + bindings: KeyBinding[], + opts: KeyResolveOpts = {}, +): KeyBinding | undefined { + const norm = normalizeChord(chord); + let match: KeyBinding | undefined; + for (const b of bindings) { + if (normalizeChord(b.key) !== norm) continue; + if (b.when && (!opts.vim || b.when !== opts.vimMode)) continue; + match = b; // later wins + } + return match; +} + +export function normalizeChord(chord: string): string { + return chord + .trim() + .split(/\s+/) + .map((part) => + part + .toLowerCase() + .split('+') + .map((s) => s.trim()) + .sort((a, b) => modOrder(a) - modOrder(b)) + .join('+'), + ) + .join(' '); +} + +function modOrder(s: string): number { + // Modifiers first (sorted), then the key + switch (s) { + case 'ctrl': + return 0; + case 'shift': + return 1; + case 'alt': + return 2; + case 'meta': + return 3; + default: + return 10; + } +} + +// ────────────────────────────────────────────────────────────────────────── +// Vim mode state machine — minimal NORMAL / INSERT / VISUAL +// ────────────────────────────────────────────────────────────────────────── + +export type VimMode = 'NORMAL' | 'INSERT' | 'VISUAL'; + +export class VimState { + mode: VimMode = 'INSERT'; + /** Buffer of pending chord chars (for multi-char NORMAL sequences like `gg`). */ + pending = ''; + /** Most recently yanked text. */ + yanked = ''; + + /** + * Feed one key event; return the resolved action label (or undefined if + * still accumulating). Pure function over (state, input). + */ + feed(chord: string, bindings: KeyBinding[]): string | undefined { + if (this.mode === 'INSERT') { + // INSERT mode only accepts the esc binding from defaults + const bind = resolveKeyAction(chord, bindings, { vim: true, vimMode: 'INSERT' }); + if (bind) { + this.applyAction(bind.action); + return bind.action; + } + return undefined; + } + // NORMAL or VISUAL — possibly multi-char chord + const combined = this.pending ? `${this.pending} ${chord}` : chord; + const bind = resolveKeyAction(combined, bindings, { + vim: true, + vimMode: this.mode, + }); + if (bind) { + this.pending = ''; + this.applyAction(bind.action); + return bind.action; + } + // No exact match — see if this is a prefix of any binding + const prefix = bindings.some( + (b) => + b.when === this.mode && normalizeChord(b.key).startsWith(normalizeChord(combined) + ' '), + ); + if (prefix) { + this.pending = combined; + return undefined; + } + this.pending = ''; + return undefined; + } + + private applyAction(action: string): void { + switch (action) { + case 'vim-normal-mode': + this.mode = 'NORMAL'; + break; + case 'vim-insert-mode': + this.mode = 'INSERT'; + break; + case 'vim-append-mode': + this.mode = 'INSERT'; + break; + case 'vim-visual-mode': + this.mode = 'VISUAL'; + break; + // Other actions don't change mode here; the host applies them. + } + } +}