Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions apps/cli/src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
Expand Down Expand Up @@ -336,6 +384,8 @@ export const BUILTIN_COMMANDS: SlashCommand[] = [
McpCommand,
TodosCommand,
PluginsCommand,
KeybindingsCommand,
VimCommand,
];

// ──────────────────────────────────────────────────────────────────────────
Expand Down
15 changes: 15 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
143 changes: 143 additions & 0 deletions packages/core/src/keybindings/index.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading
Loading