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
60 changes: 55 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,13 +138,14 @@ currently code-sign or notarize release binaries.
## Keybindings reference

Default keybindings, VS Code-compatible (`{ key, command, when? }`, Req
4.1-4.2) and resolved in this precedence, lowest to highest (Req 4.1,
4.1-4.2) and resolved in this precedence, lowest to highest (Req 4.1, 4.8,
`packages/core/src/keymap/bindingTable.ts`): **core defaults** → the
**terminal-capability fallback keymap** (see "Fallback keymap" below) →
**extension-contributed** bindings → the **user's own `keybindings.json`**,
which always wins. Every key string below is already in this codebase's
canonical lowercase `mod+...+key` form (`keymap/normalize.ts`); `return`
is Enter's real key name, not `enter`.
**extension-contributed** bindings → the active **bundled keybinding
preset** (see "Bundled keybinding presets" below) → the **user's own
`keybindings.json`**, which always wins. Every key string below is
already in this codebase's canonical lowercase `mod+...+key` form
(`keymap/normalize.ts`); `return` is Enter's real key name, not `enter`.

This table includes two default-binding sources beyond the four built-in
extension manifests: `MODAL_DEFAULT_KEYBINDINGS` and
Expand Down Expand Up @@ -233,6 +234,54 @@ for the capability, not a specific shortcut per action).
|---|---|
| `ctrl+k ctrl+s` | Open Keyboard Shortcuts (JSON) — a two-stroke chord (Req 4.4) |

### Bundled keybinding presets (Req 4.8)

Set `keybindings.preset` in `settings.json` to layer a bundled keybinding
scheme over the defaults above, without hand-editing `keybindings.json`
yourself. Valid values: `"default"` (none — the schema default), `"emacs"`,
`"windows"`. Changing the setting takes effect immediately, no restart.
There is deliberately no `"vim"` preset: every `when` context in this
codebase (`editorTextFocus`, `editorFocus`, `quickPickFocus`,
`inputBoxFocus`, `findWidgetFocus`, `explorerFocus`, `editorLangId`) is
purely focus-based, with no mode concept a non-modal `"vim"` preset could
honestly model.

**`"emacs"`** (`packages/core/src/keymap/presets/emacs.json`), while an
editor text buffer is focused:

| Key | Command | Note |
|---|---|---|
| `ctrl+a` / `ctrl+e` | Cursor to line start / end | |
| `ctrl+f` / `ctrl+b` | Cursor right / left | Overrides the default `ctrl+f` (open find) |
| `ctrl+n` / `ctrl+p` | Cursor down / up | Overrides the default `ctrl+p` (quick-open) while editor text is focused |
| `alt+f` / `alt+b` | Cursor word right / left | |
| `ctrl+k` | Delete line (kill-line) | |
| `ctrl+s` | Open find (isearch-forward) | Overrides the default `ctrl+s` (save) |
| `ctrl+x ctrl+s` | Save file | Emacs's own save-buffer chord, replacing `ctrl+s` above |

Pressing plain `ctrl+k` under this preset deletes the line directly — it
does **not** wait for a second stroke. Making that true takes one more
entry the table above doesn't show: `keybindings-editor`'s own
`ctrl+k ctrl+s` chord (see "Keybindings editor" above) is removed via
`{ "key": "ctrl+k ctrl+s", "command": "-keybindings.open" }`, because a
chord's prefix always wins over a same-key exact match
(`packages/core/src/keymap/chords.ts`) — left in place, it would make
every `ctrl+k` press sit in a pending state waiting for `ctrl+s` instead
of ever reaching this preset's own kill-line binding.

**`"windows"`** (`packages/core/src/keymap/presets/windows.json`) is
intentionally small: this codebase's defaults are already
VS-Code-on-Windows/Linux-shaped throughout, so there is little left to
change. The one real difference is that the default line-move/duplicate
bindings above (`alt+meta+up` / `alt+meta+down` / `shift+alt+meta+down`)
carry a macOS-only `meta` (Cmd) modifier; this preset adds the
Windows/Linux-native equivalents alongside them:

| Key | Command |
|---|---|
| `alt+up` / `alt+down` | Move line up / down |
| `shift+alt+down` | Duplicate line |

### Quick pick / input box navigation (core `MODAL_DEFAULT_KEYBINDINGS`)

Active only while the command palette, quick-open, or an input box (e.g.
Expand Down Expand Up @@ -287,6 +336,7 @@ exist yet:
| `editor.tabSize` | number | `4` | core | The number of spaces a tab is equal to. |
| `editor.insertSpaces` | boolean | `true` | core | Insert spaces (up to the next tab stop) instead of a literal tab when pressing Tab. |
| `explorer.showHidden` | boolean | `false` | `explorer` built-in extension (`builtin/explorer/manifest.ts`) | Show hidden (dot-prefixed) and `.gitignore`-ignored files in the explorer sidebar. |
| `keybindings.preset` | string | `"default"` | core (`config/coreDefaults.ts`) | A bundled keybinding scheme layered over the defaults — `"default"` (none), `"emacs"`, or `"windows"` (Req 4.8). See "Bundled keybinding presets" above. |
| `editor.wordWrap` | — | — | **not implemented** | Named by Req 9.5. No `contributes.configuration` schema registers this key, and nothing in `packages/` reads `config.get("editor.wordWrap")` outside of test fixtures exercising the config-merge machinery in the abstract (`packages/core/src/config/service.test.ts`, `themeSettingsWriter.test.ts`) — those tests use the string purely as a generic example key, not as evidence of a real word-wrap feature. Verified by grepping the whole `packages/` tree for both the key string and any wrap-related rendering logic in `EditorView`; there is none. |
| `files.autoSave` | — | — | **not implemented** | Named by Req 9.5. No schema registers it, and no reader ever calls `config.get("files.autoSave")` anywhere in `packages/` (verified the same way as `editor.wordWrap` above — a plain grep for the key string found zero matches at all, not even in a test fixture). |

Expand Down
10 changes: 9 additions & 1 deletion design.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ The keymap service subscribes to OpenTUI's parsed key events at the shell root (

### 6.2 Resolution model

At load time the service builds a single ordered binding table from three layers (*Req 4.1*): core defaults, then extension manifest bindings, then user `keybindings.json` — later entries take precedence, and a user entry `{ key, command: "-x" }` inserts a *removal* record that masks earlier bindings of `x` on that key (*Req 4.3*). Lookup normalizes key strings (`ctrl+shift+p` — order-insensitive modifiers, lowercase key) into a canonical form used as the table key.
At load time the service builds a single ordered binding table from five layers (*Req 4.1*), lowest precedence first: core defaults, the terminal-capability `fallback` overlay (§6.5), extension manifest bindings, the selected `preset` (§6.6), and finally the user's own `keybindings.json` — later entries take precedence, and an entry `{ key, command: "-x" }` inserts a *removal* record that masks strictly-earlier bindings of `x` on that key (*Req 4.3*); because masking and override are both order-directional, a layer can only cancel or beat one BELOW it. Lookup normalizes key strings (`ctrl+shift+p` — order-insensitive modifiers, lowercase key) into a canonical form used as the table key.

### 6.3 Chords

Expand All @@ -152,6 +152,14 @@ Clauses are parsed once at registration into an AST and evaluated against the co

On startup the service performs Kitty Keyboard Protocol detection (query via OpenTUI; also honoring `$TERM`/`$TERM_PROGRAM` heuristics for tmux passthrough). If unsupported, it overlays `keybindings.fallback.json` — shipped in the binary, user-overridable from `~/.config/tecode/` — remapping bindings that need disambiguated modifiers (e.g. `ctrl+shift+p` → `ctrl+p p` chord alternatives) (*Req 4.7, 13.3*). The fallback layer sits between core defaults and extension bindings so explicit user bindings still win.

### 6.6 Keybinding presets (Issue #81 Phase 2)

A `keybindings.preset` setting (*Req 4.8*) selects a bundled keybinding scheme by name — `"default"` (no-op), `"emacs"`, or `"windows"`, resolved by `core/keymap/presetKeybindings.ts`'s `resolveKeybindingPreset` from statically-imported JSON assets under `core/keymap/presets/` (same "shipped in the compiled binary via Bun's static-JSON-import embedding" mechanism `keybindings.fallback.json` already uses — no filesystem read at all, since a preset is selected, not authored, so there is no user-override seam the way the fallback keymap has one). The resolved entries populate a fifth binding-table layer, `preset`, deliberately placed **above `extension`, below `user`** — *not* between `defaults` and `fallback` as an earlier draft of this design had it. That placement is load-bearing, not stylistic: a preset exists specifically to override an extension's own default binding on a key the user opted to remap (e.g. Emacs's `ctrl+f`/`ctrl+s` overriding `editor-core`'s find/save), and both `lookup`'s "highest-order, when-passing entry wins" rule and the removal-masking rule ("a `-command` removal masks only strictly-lower-order bindings of that command," `bindingTable.ts`'s `visibleEntries`) only let a *later* layer override or remove an *earlier* one. With `preset` below `extension`, an override would silently lose to the extension's own binding, and worse, a `-command` removal aimed at an extension binding would be inert.

That masking rule is also why the Emacs preset ships more than a plain remap: `keybindings-editor`'s manifest binds `ctrl+k ctrl+s` → `keybindings.open` unconditionally. §6.3's chord machine checks `hasSequencePrefix` before ever trying an exact match ("prefix wins"), so as long as that chord is registered and visible, every bare `ctrl+k` keystroke would enter chord-pending state first — permanently shadowing Emacs's own `ctrl+k` → kill-line (`editor.action.deleteLine`) binding. `presets/emacs.json` therefore also carries a `{ "key": "ctrl+k ctrl+s", "command": "-keybindings.open" }` removal record, which only takes effect because `preset` outranks `extension`.

The setting is applied and live-reloaded exactly like `workbench.colorTheme` (§11): the composition root (`cli/main.ts`) reads and resolves the initial value once `config.ready` settles, and `cli/keybindingPresetConfigSync.ts`'s `wireKeybindingPresetConfigSync` subscribes to `ConfigService.onDidChange` for every subsequent change, re-resolving with no restart. An unrecognized preset name (or `"default"`) resolves to `[]`; only an unrecognized name also logs a warning. `"vim"` is deliberately not one of the bundled presets — every `when` context in this design (`editorTextFocus`, `editorFocus`, `quickPickFocus`, `inputBoxFocus`, `findWidgetFocus`, `explorerFocus`, `editorLangId`) is purely focus-based, with no mode concept a non-modal "vim" preset could honestly model.
Comment thread
goofmint marked this conversation as resolved.

## 7. Documents and Buffer

### 7.1 Data model
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/commandPaletteKeybindings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ describe("command-palette's default keybindings (Task 3.2, Req 11.3)", () => {
defaults: [],
fallback: [],
extension: commandPaletteManifest.contributes.keybindings ?? [],
preset: [],
user: [],
};
const table = createBindingTable(layers, { log });
Expand All @@ -77,6 +78,7 @@ describe("command-palette's default keybindings (Task 3.2, Req 11.3)", () => {
defaults: [],
fallback: [],
extension: commandPaletteManifest.contributes.keybindings ?? [],
preset: [],
user: [],
};
createBindingTable(layers, { log });
Expand Down
5 changes: 5 additions & 0 deletions packages/cli/src/keyRouting.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ describe("handleKeyEvent (Task 2.2, design.md §6.1's full pipeline)", () => {
defaults: [],
fallback: [],
extension: editorCoreManifest.contributes.keybindings ?? [],
preset: [],
user: [],
};
const table = createBindingTable(layers, { log });
Expand Down Expand Up @@ -152,6 +153,7 @@ describe("handleKeyEvent (Task 2.2, design.md §6.1's full pipeline)", () => {
defaults: [],
fallback: [],
extension: editorCoreManifest.contributes.keybindings ?? [],
preset: [],
user: [],
};
const table = createBindingTable(layers, { log });
Expand Down Expand Up @@ -202,6 +204,7 @@ describe("editor-core's Task 2.4 keybindings — verified strokes (manifest.ts's
defaults: [],
fallback: [],
extension: editorCoreManifest.contributes.keybindings ?? [],
preset: [],
user: [],
};
const table = createBindingTable(layers, { log });
Expand Down Expand Up @@ -318,6 +321,7 @@ describe("editor-core's Task 2.5 find/replace keybindings (Req 11.1, manifest.ts
defaults: [],
fallback: [],
extension: editorCoreManifest.contributes.keybindings ?? [],
preset: [],
user: [],
};
const table = createBindingTable(layers, { log });
Expand Down Expand Up @@ -418,6 +422,7 @@ describe("handleKeyEvent — end to end against real keymap + editor services",
defaults: partial.defaults ?? [],
fallback: partial.fallback ?? [],
extension: partial.extension ?? [],
preset: partial.preset ?? [],
user: partial.user ?? [],
};
}
Expand Down
173 changes: 173 additions & 0 deletions packages/cli/src/keybindingPresetConfigSync.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
/**
* Tests for {@link applyConfiguredKeybindingPreset}/
* {@link wireKeybindingPresetConfigSync} (Req 4.8, design.md §6.6,
* Issue #81 Phase 2) — mirrors `@tecode/core`'s `ui/themeConfigSync.test.ts`
* almost exactly (fake `ConfigService`/`KeymapState`, no real filesystem),
* since this module is the `keybindings.preset` analog of that one's
* `workbench.colorTheme` wiring.
*/

import { describe, expect, test } from "bun:test";
import { createHostLog, type ConfigService } from "@tecode/core";
import type { KeymapState } from "./keymapState";
import {
applyConfiguredKeybindingPreset,
wireKeybindingPresetConfigSync,
} from "./keybindingPresetConfigSync";

/** A fake `ConfigService` slice: `get` reads from a plain mutable record,
* `onDidChange` fires a fake `ConfigChangeEvent` on demand via `trigger` —
* mirrors `themeConfigSync.test.ts`'s real-`ConfigService` harness, just
* with an in-memory fake instead (this module has no `config/service.ts`
* import to build a real one against without an unwanted `cli -> core`
* roundabout). */
function createFakeConfig(initial: Record<string, unknown> = {}): {
config: Pick<ConfigService, "get" | "onDidChange">;
set(key: string, value: unknown): void;
trigger(key: string): void;
} {
const values = { ...initial };
const listeners = new Set<(event: { affectsConfiguration(key: string): boolean }) => void>();
return {
set(key, value) {
values[key] = value;
},
trigger(key) {
for (const listener of listeners) {
listener({ affectsConfiguration: (k) => k === key });
}
},
config: {
get: <T,>(key: string) => values[key] as T | undefined,
onDidChange: (listener) => {
listeners.add(listener as never);
return {
dispose() {
listeners.delete(listener as never);
},
};
},
},
};
}

/** A fake `KeymapState` slice: just records every `setPresetEntries` call. */
function createFakeKeymap(): { keymap: Pick<KeymapState, "setPresetEntries">; calls: unknown[][] } {
const calls: unknown[][] = [];
return {
calls,
keymap: {
setPresetEntries: (entries) => {
calls.push([entries]);
},
},
};
}

describe("applyConfiguredKeybindingPreset (Req 4.8)", () => {
test('resolves "emacs" and feeds real Emacs entries into keymap.setPresetEntries', () => {
const { config } = createFakeConfig({ "keybindings.preset": "emacs" });
const { keymap, calls } = createFakeKeymap();
applyConfiguredKeybindingPreset({ config, keymap, log: createHostLog() });

expect(calls).toHaveLength(1);
const entries = calls[0]?.[0] as Array<{ key: string; command: string }>;
expect(entries.some((e) => e.key === "ctrl+k" && e.command === "editor.action.deleteLine")).toBe(
true,
);
});

test("a missing config value falls back to the default preset ([])", () => {
const { config } = createFakeConfig({});
const { keymap, calls } = createFakeKeymap();
applyConfiguredKeybindingPreset({ config, keymap, log: createHostLog() });

expect(calls).toEqual([[[]]]);
});

test("a non-string config value falls back to the default preset ([]) rather than throwing", () => {
const { config } = createFakeConfig({ "keybindings.preset": 42 });
const { keymap, calls } = createFakeKeymap();
expect(() =>
applyConfiguredKeybindingPreset({ config, keymap, log: createHostLog() }),
).not.toThrow();
expect(calls).toEqual([[[]]]);
});

test("an unknown preset name resolves to [] and logs a warning, never throws", () => {
const { config } = createFakeConfig({ "keybindings.preset": "vim" });
const { keymap, calls } = createFakeKeymap();
const log = createHostLog();
applyConfiguredKeybindingPreset({ config, keymap, log });

expect(calls).toEqual([[[]]]);
expect(log.entries().some((e) => e.level === "warning")).toBe(true);
});

test("a throwing config.get is caught, logged, and still degrades to the default preset", () => {
const keymapFake = createFakeKeymap();
const log = createHostLog();
const throwingConfig: Pick<ConfigService, "get" | "onDidChange"> = {
get: () => {
throw new Error("config is broken");
},
onDidChange: () => ({ dispose() {} }),
};

expect(() =>
applyConfiguredKeybindingPreset({ config: throwingConfig, keymap: keymapFake.keymap, log }),
).not.toThrow();
expect(keymapFake.calls).toEqual([[[]]]);
expect(log.entries().some((e) => e.level === "error")).toBe(true);
});
});

describe("wireKeybindingPresetConfigSync (Req 4.8, config-file-driven live switching)", () => {
test("a keybindings.preset config change live-reapplies the preset without a restart", () => {
const { config, set, trigger } = createFakeConfig({ "keybindings.preset": "default" });
const { keymap, calls } = createFakeKeymap();
const sub = wireKeybindingPresetConfigSync({ config, keymap, log: createHostLog() });

set("keybindings.preset", "windows");
trigger("keybindings.preset");

expect(calls).toHaveLength(1);
const entries = calls[0]?.[0] as Array<{ key: string; command: string }>;
expect(entries.some((e) => e.command === "editor.action.moveLinesUp")).toBe(true);
sub.dispose();
});

test("a config change to an unrelated key does not touch the preset layer", () => {
const { config, set, trigger } = createFakeConfig({ "keybindings.preset": "default" });
const { keymap, calls } = createFakeKeymap();
const sub = wireKeybindingPresetConfigSync({ config, keymap, log: createHostLog() });

set("editor.tabSize", 8);
trigger("editor.tabSize");

expect(calls).toHaveLength(0);
sub.dispose();
});

test("dispose() stops future config changes from affecting the preset layer", () => {
const { config, set, trigger } = createFakeConfig({ "keybindings.preset": "default" });
const { keymap, calls } = createFakeKeymap();
const sub = wireKeybindingPresetConfigSync({ config, keymap, log: createHostLog() });
sub.dispose();

set("keybindings.preset", "emacs");
trigger("keybindings.preset");

expect(calls).toHaveLength(0);
});

test("dispose() is idempotent", () => {
const { config } = createFakeConfig({});
const { keymap } = createFakeKeymap();
const sub = wireKeybindingPresetConfigSync({ config, keymap, log: createHostLog() });
expect(() => {
sub.dispose();
sub.dispose();
}).not.toThrow();
});
});
Loading
Loading