diff --git a/README.md b/README.md index 11345f1..0a53ef6 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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. @@ -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). | diff --git a/design.md b/design.md index 3108f4c..f6c0cd9 100644 --- a/design.md +++ b/design.md @@ -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 @@ -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. + ## 7. Documents and Buffer ### 7.1 Data model diff --git a/packages/cli/src/commandPaletteKeybindings.test.ts b/packages/cli/src/commandPaletteKeybindings.test.ts index 8b3bcea..17d7ee2 100644 --- a/packages/cli/src/commandPaletteKeybindings.test.ts +++ b/packages/cli/src/commandPaletteKeybindings.test.ts @@ -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 }); @@ -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 }); diff --git a/packages/cli/src/keyRouting.test.ts b/packages/cli/src/keyRouting.test.ts index 3952e00..193f845 100644 --- a/packages/cli/src/keyRouting.test.ts +++ b/packages/cli/src/keyRouting.test.ts @@ -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 }); @@ -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 }); @@ -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 }); @@ -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 }); @@ -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 ?? [], }; } diff --git a/packages/cli/src/keybindingPresetConfigSync.test.ts b/packages/cli/src/keybindingPresetConfigSync.test.ts new file mode 100644 index 0000000..365df94 --- /dev/null +++ b/packages/cli/src/keybindingPresetConfigSync.test.ts @@ -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 = {}): { + config: Pick; + 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: (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; 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 = { + 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(); + }); +}); diff --git a/packages/cli/src/keybindingPresetConfigSync.ts b/packages/cli/src/keybindingPresetConfigSync.ts new file mode 100644 index 0000000..be411cf --- /dev/null +++ b/packages/cli/src/keybindingPresetConfigSync.ts @@ -0,0 +1,133 @@ +/** + * `applyConfiguredKeybindingPreset`/`wireKeybindingPresetConfigSync` (Req + * 4.8, design.md §6.6, Issue #81 Phase 2): keeps `KeymapState`'s + * `preset` layer in sync with the `keybindings.preset` setting — mirrors + * `@tecode/core`'s `ui/themeConfigSync.ts`'s `applyConfiguredTheme`/ + * `wireThemeConfigSync` pair for `workbench.colorTheme` almost exactly, + * just with `KeymapState.setPresetEntries` standing in for + * `ThemeService.setTheme`, and living HERE, in `packages/cli`, rather than + * in `@tecode/core` — `KeymapState` (`keymapState.ts`) is a `cli`-local + * type with no `@tecode/core` equivalent (`core` has `BindingTable`/ + * `KeymapLayers`, but nothing that owns the mutable `defaults`/`fallback`/ + * `extension`/`preset`/`user` state the way `KeymapState` does), so a + * reusable helper over it cannot live in `core` alongside + * `themeConfigSync.ts` the way `wireThemeConfigSync` does for + * `ThemeService`. + * + * **Same "two call sites, one helper, deliberately NOT auto-synced at wire + * time" shape `ui/themeConfigSync.ts`'s TSDoc documents for + * `workbench.colorTheme`**: `ConfigService`'s initial load fires no + * `onDidChange` at all (`config/service.ts`'s `initialLoad` TSDoc), so + * reading `config.get(...)` before `config.ready` settles would only ever + * see the schema default (`"default"`) — harmless here specifically, + * since that default resolves to `[]` anyway, but {@link + * applyConfiguredKeybindingPreset} is still called explicitly by + * `main.ts`'s `runTecode` AFTER `await root.config.ready`, exactly like + * `applyConfiguredTheme`, rather than eagerly at + * {@link wireKeybindingPresetConfigSync}'s own construction time — so the + * two functions' contracts stay symmetric with their theme counterparts, + * not just individually correct. Unlike `workbench.colorTheme`, a preset + * name never depends on anything `loadExtensions`/discovery resolves + * later (`keymap/presetKeybindings.ts`'s fixed, closed + * `KEYBINDING_PRESET_NAMES` set), so there is no `runDeferredPhase` + * equivalent of `applyConfiguredTheme`'s second, retroactive call. + */ + +import type { HostLog } from "@tecode/core"; +import { resolveKeybindingPreset } from "@tecode/core"; +import type { Disposable, KeybindingContribution } from "@tecode/api"; +import type { ConfigService } from "@tecode/core"; +import type { KeymapState } from "./keymapState"; + +const KEYBINDINGS_PRESET_CONFIG_KEY = "keybindings.preset"; + +/** Render a caught `unknown` value as a message string, matching + * `main.ts`'s own module-level `describeError` (design.md §5). Duplicated + * locally (house style: small, non-shared per-module helpers — this + * codebase's own `fallbackKeybindings.ts`/`bindingTable.ts` each keep + * their own copy too) rather than importing `main.ts`'s, which would + * create a reverse (`main.ts` already imports this module) circular + * import. */ +function describeError(err: unknown): string { + try { + if (err instanceof Error) return err.message; + return String(err); + } catch { + return "Unknown error"; + } +} + +/** Dependencies shared by {@link applyConfiguredKeybindingPreset} and + * {@link wireKeybindingPresetConfigSync}. */ +export interface KeybindingPresetConfigSyncDeps { + config: Pick; + keymap: Pick; + log: HostLog; + /** Overrides the config key watched — defaults to + * `"keybindings.preset"`. Test-only knob; production never sets this. */ + configKey?: string; +} + +/** + * Read `keybindings.preset` from `deps.config` and feed the resolved + * entries into `deps.keymap.setPresetEntries` (Req 4.8). A + * non-string (or missing/not-yet-ready) config value falls back to + * `"default"` — the schema default, resolving to `[]`. Never throws: + * `config.get`/`resolveKeybindingPreset` are both guarded defensively + * (house style, matching `main.ts`'s `applyKittyKeyboardVerdict`), each + * degrading to the empty preset layer on any unexpected failure rather + * than propagating. + */ +export function applyConfiguredKeybindingPreset(deps: KeybindingPresetConfigSyncDeps): void { + const key = deps.configKey ?? KEYBINDINGS_PRESET_CONFIG_KEY; + + let presetName = "default"; + try { + const raw = deps.config.get(key); + if (typeof raw === "string") presetName = raw; + } catch (cause) { + deps.log.append("error", { + message: `applyConfiguredKeybindingPreset: config.get threw: ${describeError(cause)}`, + }); + } + + let entries: KeybindingContribution[]; + try { + entries = resolveKeybindingPreset(presetName, { log: deps.log }); + } catch (cause) { + deps.log.append("error", { + message: `applyConfiguredKeybindingPreset: resolveKeybindingPreset threw: ${describeError(cause)}`, + }); + entries = []; + } + + deps.keymap.setPresetEntries(entries); +} + +/** + * Subscribe `deps.keymap` to live `keybindings.preset` config changes (Req + * 4.8) — see this module's TSDoc for why the INITIAL value is applied by + * the composition root calling {@link applyConfiguredKeybindingPreset} + * directly, not by this function on construction. Returns a + * {@link Disposable} that stops the subscription; idempotent. + */ +export function wireKeybindingPresetConfigSync( + deps: KeybindingPresetConfigSyncDeps, +): Disposable { + const key = deps.configKey ?? KEYBINDINGS_PRESET_CONFIG_KEY; + + const sub = deps.config.onDidChange((event) => { + if (event.affectsConfiguration(key)) { + applyConfiguredKeybindingPreset(deps); + } + }); + + let disposed = false; + return { + dispose() { + if (disposed) return; + disposed = true; + sub.dispose(); + }, + }; +} diff --git a/packages/cli/src/keybindingPresets.test.ts b/packages/cli/src/keybindingPresets.test.ts new file mode 100644 index 0000000..0df07b0 --- /dev/null +++ b/packages/cli/src/keybindingPresets.test.ts @@ -0,0 +1,276 @@ +/** + * Completeness/correctness tests for `@tecode/core`'s bundled keybinding + * presets (Req 4.8; design.md §6.6; Issue #81 Phase 2: + * `keymap/presets/emacs.json`/`windows.json`). Lives in `packages/cli` + * rather than beside the presets in `packages/core/src/keymap/` (matching + * `sampleConfig.test.ts`/`fallbackKeybindingsCompleteness.test.ts`'s own + * precedent) because it needs `@tecode/builtin`'s real manifests to derive + * the valid command-id set and to build a REALISTIC layered table — `core` + * may not import `builtin` (`config/coreDefaults.ts`'s TSDoc explains the + * same one-directional layering constraint), but `cli` is the one place + * that legitimately depends on both. + * + * Five things are proven here, against REAL production code, not + * hand-rolled assertions about the files' text: + * + * 1. Each preset's on-disk JSON parses via the repo's real `parseJsonc` + * into an array. + * 2. Each preset's entries compile through a REAL `createBindingTable` + * (layered under the same `defaults`/`fallback`/`extension` layers + * `main.ts` builds in a real run) with ZERO warnings. + * 3. Every `command` any preset references — including a `-command` + * removal's target — actually exists as a real command id somewhere in + * the app (`MODAL_DEFAULT_KEYBINDINGS`/`TAB_DEFAULT_KEYBINDINGS`'s own + * commands, or some built-in manifest's `contributes.commands`). A + * typo'd id here would otherwise be a silently-dead binding. + * 4. THE critical regression this phase exists to prevent: pressing plain + * `ctrl+k` under the Emacs preset resolves DIRECTLY to + * `editor.action.deleteLine` — it does not enter chord-pending state + * waiting for a second stroke. `keybindings-editor`'s manifest binds + * `ctrl+k ctrl+s` -> `keybindings.open` with no `when` clause, and + * `chords.ts`'s `handleIdleStroke` checks `hasSequencePrefix` + * UNCONDITIONALLY before ever trying an exact match ("prefix wins", + * design.md §6.3) — so without `presets/emacs.json`'s own `-command` + * removal of that chord, `ctrl+k` would silently never fire Emacs's + * kill-line binding. This test presses the REAL `ChordStateMachine`, + * not just the table, so a regression here would actually manifest as + * a stuck keystroke, not merely a missing table entry. + * 5. No `"vim"` preset exists anywhere — Issue #81's author explicitly + * dropped it (this codebase's `when` contexts are purely focus-based, + * with no mode concept a non-modal "vim" preset could honestly claim). + */ + +import { describe, expect, test } from "bun:test"; +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { builtinManifests } from "@tecode/builtin"; +import { + BUNDLED_FALLBACK_KEYBINDINGS, + createBindingTable, + createChordStateMachine, + createContextService, + createHostLog, + DEFAULT_KEYBINDING_PRESET, + DEFAULT_KEYBINDING_PRESET_NAME, + EMACS_KEYBINDING_PRESET, + KEYBINDING_PRESET_NAMES, + MODAL_DEFAULT_KEYBINDINGS, + parseJsonc, + resolveKeybindingPreset, + TAB_DEFAULT_KEYBINDINGS, + WINDOWS_KEYBINDING_PRESET, + type BindingLayer, + type HostLog, + type KeymapLayers, +} from "@tecode/core"; +import type { KeybindingContribution } from "@tecode/api"; + +const REPO_ROOT = resolve(import.meta.dir, "../../.."); +const EMACS_PRESET_PATH = resolve(REPO_ROOT, "packages/core/src/keymap/presets/emacs.json"); +const WINDOWS_PRESET_PATH = resolve(REPO_ROOT, "packages/core/src/keymap/presets/windows.json"); + +/** Every real command id known to a full startup (`main.ts`'s own + * composition — same two core-defaults arrays plus every built-in + * manifest's `contributes.commands`, mirroring + * `fallbackKeybindingsCompleteness.test.ts`'s identical "derive from the + * real sources, don't hand-copy a list" approach). */ +const ALL_COMMAND_IDS = new Set([ + ...MODAL_DEFAULT_KEYBINDINGS.map((e) => e.command), + ...TAB_DEFAULT_KEYBINDINGS.map((e) => e.command), + ...builtinManifests.flatMap((m) => (m.contributes.commands ?? []).map((c) => c.id)), +]); + +/** The target command of a `KeybindingContribution.command`, stripping a + * leading `"-"` removal marker if present (`bindingTable.ts`'s + * `compileEntry` does the same before validating). */ +function targetCommand(raw: string): string { + return raw.startsWith("-") ? raw.slice(1) : raw; +} + +/** Build a REALISTIC full layered table (mirrors `main.ts`'s own + * composition, and `sampleConfig.test.ts`'s identical pattern) with + * exactly one preset active in the `preset` layer, exactly like a real + * run only ever has one `keybindings.preset` value active at a time. */ +function buildRealTable(preset: KeybindingContribution[], log: HostLog) { + const layers: KeymapLayers = { + defaults: [...MODAL_DEFAULT_KEYBINDINGS, ...TAB_DEFAULT_KEYBINDINGS], + fallback: BUNDLED_FALLBACK_KEYBINDINGS, + extension: builtinManifests.flatMap((m) => m.contributes.keybindings ?? []), + preset, + user: [], + }; + return createBindingTable(layers, { log }); +} + +describe("presets/emacs.json, presets/windows.json — parse as JSONC arrays", () => { + test("emacs.json parses as a JSONC array via the repo's real parser", async () => { + const raw = await readFile(EMACS_PRESET_PATH, "utf8"); + const parsed = parseJsonc(raw); + expect(parsed.ok).toBe(true); + if (parsed.ok) expect(Array.isArray(parsed.value)).toBe(true); + }); + + test("windows.json parses as a JSONC array via the repo's real parser", async () => { + const raw = await readFile(WINDOWS_PRESET_PATH, "utf8"); + const parsed = parseJsonc(raw); + expect(parsed.ok).toBe(true); + if (parsed.ok) expect(Array.isArray(parsed.value)).toBe(true); + }); +}); + +describe("every preset compiles through a real BindingTable with zero warnings", () => { + test("emacs preset, layered under real defaults/fallback/extension bindings", () => { + const log = createHostLog(); + buildRealTable(EMACS_KEYBINDING_PRESET, log); + expect(log.entries()).toEqual([]); + }); + + test("windows preset, layered under real defaults/fallback/extension bindings", () => { + const log = createHostLog(); + buildRealTable(WINDOWS_KEYBINDING_PRESET, log); + expect(log.entries()).toEqual([]); + }); +}); + +describe("every command a preset references actually exists (Req 4.8)", () => { + test("sanity: the derived command-id set actually contains something (the test below isn't vacuous)", () => { + expect(ALL_COMMAND_IDS.size).toBeGreaterThan(10); + expect(ALL_COMMAND_IDS.has("editor.action.deleteLine")).toBe(true); + }); + + test("every emacs.json entry's command (or -removal target) is a real command id", () => { + for (const entry of EMACS_KEYBINDING_PRESET) { + const command = targetCommand(entry.command); + expect(ALL_COMMAND_IDS.has(command)).toBe(true); + } + }); + + test("every windows.json entry's command is a real command id", () => { + for (const entry of WINDOWS_KEYBINDING_PRESET) { + const command = targetCommand(entry.command); + expect(ALL_COMMAND_IDS.has(command)).toBe(true); + } + }); +}); + +describe("layer precedence: preset beats defaults, loses to user (Req 4.8)", () => { + test("a preset entry beats a defaults-layer binding on the same key", () => { + const log = createHostLog(); + const table = createBindingTable( + { + defaults: [{ key: "ctrl+z", command: "defaults.command" }], + fallback: [], + extension: [], + preset: [{ key: "ctrl+z", command: "preset.command" }], + user: [], + }, + { log }, + ); + const resolved = table.lookup("ctrl+z", () => undefined); + expect(resolved?.command).toBe("preset.command"); + expect(resolved?.layer).toBe("preset"); + }); + + test("a user entry beats a preset entry on the same key", () => { + const log = createHostLog(); + const table = createBindingTable( + { + defaults: [], + fallback: [], + extension: [], + preset: [{ key: "ctrl+z", command: "preset.command" }], + user: [{ key: "ctrl+z", command: "user.command" }], + }, + { log }, + ); + const resolved = table.lookup("ctrl+z", () => undefined); + expect(resolved?.command).toBe("user.command"); + expect(resolved?.layer).toBe("user" as BindingLayer); + }); +}); + +describe("the ctrl+k chord-shadowing hazard (THE reason preset must outrank extension)", () => { + function contextOf(values: Record) { + return (key: string) => values[key]; + } + + test("sanity: WITHOUT the emacs preset, ctrl+k IS a live chord prefix (keybindings-editor's ctrl+k ctrl+s)", () => { + const log = createHostLog(); + const table = buildRealTable([], log); + expect(table.hasSequencePrefix("ctrl+k", contextOf({}))).toBe(true); + }); + + test("WITH the emacs preset active, ctrl+k is no longer a live chord prefix", () => { + const log = createHostLog(); + const table = buildRealTable(EMACS_KEYBINDING_PRESET, log); + expect(table.hasSequencePrefix("ctrl+k", contextOf({ editorTextFocus: true }))).toBe(false); + }); + + test("pressing plain ctrl+k under the Emacs preset resolves DIRECTLY to editor.action.deleteLine — not a pending chord (Req 4.8)", () => { + const log = createHostLog(); + const table = buildRealTable(EMACS_KEYBINDING_PRESET, log); + const context = createContextService(); + context.set("editorTextFocus", true); + + const executed: string[] = []; + const pendingStates: Array = []; + const machine = createChordStateMachine({ + table, + execute: (id) => { + executed.push(id); + }, + getContext: (key) => context.get(key), + log, + }); + machine.onDidChangePending((prefix) => pendingStates.push(prefix)); + + const result = machine.handleStroke("ctrl+k"); + + expect(result).toBe("consumed"); + expect(executed).toEqual(["editor.action.deleteLine"]); + // Never entered pending state at all — a regression here would show up + // as a `"ctrl+k"` entry in this array (chord-pending, waiting for a + // second stroke) instead of a direct execution. + expect(pendingStates).toEqual([]); + }); + + test("keybindings.open itself is still reachable via the chord when the Emacs preset is NOT active", () => { + const log = createHostLog(); + const table = buildRealTable([], log); + const context = createContextService(); + const executed: string[] = []; + const machine = createChordStateMachine({ + table, + execute: (id) => { + executed.push(id); + }, + getContext: (key) => context.get(key), + log, + }); + + expect(machine.handleStroke("ctrl+k")).toBe("consumed"); + expect(machine.handleStroke("ctrl+s")).toBe("consumed"); + expect(executed).toEqual(["keybindings.open"]); + }); +}); + +describe("no vim preset exists anywhere (Issue #81's scope was explicitly narrowed to Emacs + Windows)", () => { + test("KEYBINDING_PRESET_NAMES has exactly 3 entries, none of them vim", () => { + const names: string[] = [...KEYBINDING_PRESET_NAMES]; + expect(names).toHaveLength(3); + expect(names).not.toContain("vim"); + }); + + test('resolveKeybindingPreset("vim") is treated as an unknown name, not a real preset', () => { + const log = createHostLog(); + expect(resolveKeybindingPreset("vim", { log })).toEqual([]); + expect(log.entries().some((e) => e.level === "warning")).toBe(true); + }); +}); + +test("config/coreDefaults.ts's DEFAULT_KEYBINDING_PRESET literal stays in sync with keymap/presetKeybindings.ts's DEFAULT_KEYBINDING_PRESET_NAME", () => { + // These two constants are intentionally duplicated (no `config -> keymap` + // import edge exists for one literal string, `coreDefaults.ts`'s own + // TSDoc) — this is the drift guard that duplication's TSDoc promises. + expect(DEFAULT_KEYBINDING_PRESET).toBe(DEFAULT_KEYBINDING_PRESET_NAME); +}); diff --git a/packages/cli/src/keymapState.test.ts b/packages/cli/src/keymapState.test.ts index a0b3151..6fd13fa 100644 --- a/packages/cli/src/keymapState.test.ts +++ b/packages/cli/src/keymapState.test.ts @@ -163,3 +163,60 @@ test("a user keybindings.json entry still rebinds tab.close (a core-reserved com expect(resolved?.command).toBe(TAB_CLOSE_COMMAND); expect(resolved?.layer).toBe("user"); }); + +// --- Issue #81 Phase 2: the `preset` layer (Req 12.2, design.md §6.6). +// `preset` sits ABOVE `extension`, deliberately (`@tecode/core`'s +// `bindingTable.ts`'s `KeymapLayers` TSDoc spells out why) — the tests +// below prove that ordering directly, not just that the layer exists. + +test("setPresetEntries rebuilds the table with the preset layer (Req 12.2)", () => { + const log = createHostLog(); + const state = createKeymapState(log); + state.setPresetEntries([{ key: "ctrl+e", command: "editor.action.cursorEnd" }]); + + const resolved = state.getTable().lookup("ctrl+e", () => undefined); + expect(resolved?.command).toBe("editor.action.cursorEnd"); + expect(resolved?.layer).toBe("preset"); +}); + +test("a preset entry outranks a defaults-layer binding on the same key", () => { + const log = createHostLog(); + const state = createKeymapState(log, [{ key: "ctrl+k", command: "defaults.command" }]); + state.setPresetEntries([{ key: "ctrl+k", command: "editor.action.deleteLine" }]); + + const resolved = state.getTable().lookup("ctrl+k", () => undefined); + expect(resolved?.command).toBe("editor.action.deleteLine"); + expect(resolved?.layer).toBe("preset"); +}); + +test("a preset entry outranks an EXTENSION entry on the same key — the whole point of a bundled preset (Req 12.2)", () => { + const log = createHostLog(); + const state = createKeymapState(log); + state.setExtensionEntries([{ key: "ctrl+f", command: "editor.action.find" }]); + state.setPresetEntries([{ key: "ctrl+f", command: "editor.action.cursorRight" }]); + + const resolved = state.getTable().lookup("ctrl+f", () => undefined); + expect(resolved?.command).toBe("editor.action.cursorRight"); + expect(resolved?.layer).toBe("preset"); +}); + +test("a user entry outranks a preset entry on the same key — user bindings always win (Req 12.2)", () => { + const log = createHostLog(); + const state = createKeymapState(log); + state.setPresetEntries([{ key: "ctrl+f", command: "editor.action.cursorRight" }]); + state.setUserEntries([{ key: "ctrl+f", command: "user.command" }]); + + const resolved = state.getTable().lookup("ctrl+f", () => undefined); + expect(resolved?.command).toBe("user.command"); + expect(resolved?.layer).toBe("user"); +}); + +test("later setPresetEntries calls fully replace the previous preset layer", () => { + const log = createHostLog(); + const state = createKeymapState(log); + state.setPresetEntries([{ key: "ctrl+e", command: "emacs.one" }]); + state.setPresetEntries([{ key: "ctrl+a", command: "emacs.two" }]); + + expect(state.getTable().lookup("ctrl+e", () => undefined)).toBeUndefined(); + expect(state.getTable().lookup("ctrl+a", () => undefined)?.command).toBe("emacs.two"); +}); diff --git a/packages/cli/src/keymapState.ts b/packages/cli/src/keymapState.ts index fa0b10e..374245d 100644 --- a/packages/cli/src/keymapState.ts +++ b/packages/cli/src/keymapState.ts @@ -34,6 +34,22 @@ * itself has no such restriction, though; it is a plain replace, callable * any number of times, exactly like its two siblings, which is what lets * tests call it directly without needing a fake terminal. + * + * **`preset` — the bundled keybinding preset overlay** (Req 4.8, design.md + * §6.6, Issue #81 Phase 2): starts `[]` and is rebuilt via + * {@link setPresetEntries} — `main.ts`'s `buildAssemblyRoot` resolves the + * INITIAL `keybindings.preset` value once `config.ready` settles (same + * "schema default only, until ready" caveat `ui/themeConfigSync.ts`'s + * TSDoc documents for `workbench.colorTheme`) and calls it once, then + * again on every live `keybindings.preset` config change via + * `ConfigService.onDidChange` — unlike `fallback` (fixed for the run) but + * exactly like `user`/`extension`, this setter is expected to be called + * an arbitrary number of times over the app's lifetime. Sits ABOVE + * `extension` in precedence (`@tecode/core`'s `bindingTable.ts`'s + * `KeymapLayers` TSDoc explains why that specific position is + * load-bearing, not just "somewhere in the middle") — a preset can + * override, or `-command`-remove, an extension's own default binding, but + * never a `user` one. */ import { createBindingTable, type BindingTable, type HostLog } from "@tecode/core"; @@ -66,6 +82,15 @@ export interface KeymapState { * certainly the user's own `keybindings.json`, always wins over this * overlay on the same key. */ setFallbackEntries(entries: readonly KeybindingContribution[]): void; + /** Rebuild with a new `preset` layer (Req 4.8, design.md §6.6, Issue #81 + * Phase 2) — called once with the initially-configured `keybindings.preset` + * value, then again on every live change to that setting + * (`main.ts`'s `buildAssemblyRoot`, mirroring `ui/themeConfigSync.ts`'s + * `workbench.colorTheme` wiring). Sits ABOVE `extension` in precedence + * (`@tecode/core`'s `bindingTable.ts`'s `KeymapLayers` TSDoc) — a plain + * replace, callable any number of times, exactly like + * `setUserEntries`/`setExtensionEntries`. */ + setPresetEntries(entries: readonly KeybindingContribution[]): void; } /** Build a {@link KeymapState} (Req 4.1-4.3). `defaults` seeds the @@ -81,6 +106,7 @@ export function createKeymapState( let userEntries: KeybindingContribution[] = []; let extensionEntries: KeybindingContribution[] = []; let fallbackEntries: KeybindingContribution[] = []; + let presetEntries: KeybindingContribution[] = []; let table = build(); function build(): BindingTable { @@ -89,6 +115,7 @@ export function createKeymapState( defaults: defaultEntries, fallback: fallbackEntries, extension: extensionEntries, + preset: presetEntries, user: userEntries, }, { log }, @@ -109,5 +136,9 @@ export function createKeymapState( fallbackEntries = entries.slice(); table = build(); }, + setPresetEntries(entries) { + presetEntries = entries.slice(); + table = build(); + }, }; } diff --git a/packages/cli/src/main.ts b/packages/cli/src/main.ts index dbd11af..84ae5f6 100644 --- a/packages/cli/src/main.ts +++ b/packages/cli/src/main.ts @@ -84,6 +84,10 @@ import { import { join as joinPath } from "node:path"; import { resolveConfigDirOverride, resolveStartupTarget, type StartupTarget } from "./argv"; import { buildExtensionDirMap, buildExtensionRecords } from "./extensionRecords"; +import { + applyConfiguredKeybindingPreset as applyConfiguredKeybindingPresetImpl, + wireKeybindingPresetConfigSync, +} from "./keybindingPresetConfigSync"; import { createKeymapState, type KeymapState } from "./keymapState"; import { createBuiltinLanguageAssetsFs } from "./languageAssetsFs"; import { renderShellHeadless, renderShellToTerminal, type RenderShell } from "./renderShell"; @@ -288,6 +292,32 @@ export interface AssemblyRoot { * empty fallback layer. */ applyKittyKeyboardVerdict(isKittyCapable: boolean): Promise; + /** + * Re-resolve the `keybindings.preset` setting (Req 4.8, design.md + * §6.6, Issue #81 Phase 2) via `@tecode/core`'s `resolveKeybindingPreset` + * and feed the result into `keymap`'s `preset` layer via + * `keymap.setPresetEntries` — the SAME two-call-site pattern + * `ui/themeConfigSync.ts`'s `applyConfiguredTheme` uses for + * `workbench.colorTheme`: `runTecode` calls this once, explicitly, after + * `config.ready` settles (reading `config.get(...)` any earlier would + * only ever see the schema default, `"default"` — `ThemeConfigSync`'s own + * TSDoc explains why), and {@link keybindingPresetConfigSync} below calls + * it again on every live `keybindings.preset` change. A non-string or + * missing config value falls back to `"default"` + * (`config/coreDefaults.ts`'s `DEFAULT_KEYBINDING_PRESET`). + * Synchronous and never throws: `resolveKeybindingPreset` itself never + * throws (that function's own TSDoc), and any other unexpected failure + * is caught and logged rather than propagated, degrading to an empty + * preset layer — matching {@link applyKittyKeyboardVerdict}'s own + * defensive posture for the sibling `fallback` layer. + */ + applyConfiguredKeybindingPreset(): void; + /** Live `keybindings.preset` config-change subscription (Req 4.8, + * design.md §6.6) — mirrors {@link themeConfigSync}'s + * `workbench.colorTheme` wiring, just for the `preset` layer instead of + * the active theme. Disposed alongside every other startup-owned + * subscription in {@link wireProcessExit}. */ + keybindingPresetConfigSync: Disposable; /** The live two-stroke chord state machine (Req 4.4, design.md §6.1, * §6.3), built once here against a small forwarding view over `keymap` * (see this function's TSDoc's "Live keymap table view") so it always @@ -682,6 +712,16 @@ export function buildAssemblyRoot( // just called once from the composition root instead of from manifest // registration (`config/coreDefaults.ts`'s TSDoc). registerCoreConfiguration(config); + + // `AssemblyRoot.applyConfiguredKeybindingPreset`/`keybindingPresetConfigSync` + // — see those fields' TSDoc. `keybindingPresetConfigSync.ts`'s own TSDoc + // explains why this pair lives in a dedicated `cli`-local module (mirroring + // `ui/themeConfigSync.ts`'s `applyConfiguredTheme`/`wireThemeConfigSync`) + // rather than being defined inline here. + const applyConfiguredKeybindingPreset = () => + applyConfiguredKeybindingPresetImpl({ config, keymap, log }); + const keybindingPresetConfigSync = wireKeybindingPresetConfigSync({ config, keymap, log }); + const context = createContextService(); const layoutState = createLayoutStateService({ log, sink }); @@ -915,6 +955,8 @@ export function buildAssemblyRoot( workspaceRoot, keymap, applyKittyKeyboardVerdict, + applyConfiguredKeybindingPreset, + keybindingPresetConfigSync, chordMachine, chordPendingIndicator, editorSession, @@ -1126,6 +1168,7 @@ function wireProcessExit(root: AssemblyRoot): void { root.editorSession.dispose(); root.editorLangIdSync.dispose(); root.themeConfigSync.dispose(); + root.keybindingPresetConfigSync.dispose(); root.themeSelectCommand.dispose(); root.openFileCommand.dispose(); root.tabCommands.dispose(); @@ -1254,6 +1297,17 @@ export async function runTecode( // `loadContributions` settles). applyConfiguredTheme(root.config, root.themeService); + // Apply the ACTUAL configured `keybindings.preset` now that `config.ready` + // has settled (Req 4.8, design.md §6.6) — same "schema default + // only, until ready" reasoning as `workbench.colorTheme` above + // (`AssemblyRoot.applyConfiguredKeybindingPreset`'s TSDoc), but with no + // `themesReadyPromise`-equivalent second call needed: unlike a theme id, a + // preset name never depends on anything `loadExtensions`/discovery + // discovers (`keymap/presetKeybindings.ts`'s fixed, closed + // `KEYBINDING_PRESET_NAMES` set) — this one call is the whole of the + // initial application. + root.applyConfiguredKeybindingPreset(); + wireProcessExit(root); const renderShell = options.renderShell ?? (headless ? renderShellHeadless : renderShellToTerminal); @@ -1363,6 +1417,7 @@ export async function runTecode( root.editorSession.dispose(); root.editorLangIdSync.dispose(); root.themeConfigSync.dispose(); + root.keybindingPresetConfigSync.dispose(); root.themeSelectCommand.dispose(); root.openFileCommand.dispose(); root.tabCommands.dispose(); diff --git a/packages/cli/src/sampleConfig.test.ts b/packages/cli/src/sampleConfig.test.ts index e40394f..d0a422e 100644 --- a/packages/cli/src/sampleConfig.test.ts +++ b/packages/cli/src/sampleConfig.test.ts @@ -188,6 +188,7 @@ async function loadSamplesThroughRealPath(settingsText: string, keybindingsText: defaults: [...MODAL_DEFAULT_KEYBINDINGS, ...TAB_DEFAULT_KEYBINDINGS], fallback: BUNDLED_FALLBACK_KEYBINDINGS, extension: loadResult.extensionKeybindings, + preset: [], user: userKeybindingEntries as KeybindingContribution[], }, { log: bindingTableLog }, diff --git a/packages/core/src/config/coreDefaults.test.ts b/packages/core/src/config/coreDefaults.test.ts index 2728d63..4f0ae40 100644 --- a/packages/core/src/config/coreDefaults.test.ts +++ b/packages/core/src/config/coreDefaults.test.ts @@ -8,7 +8,12 @@ import { describe, expect, test } from "bun:test"; import { createHostLog } from "../host/errors"; import { createConfigService, type ConfigServiceFs } from "./service"; -import { CORE_CONFIGURATION, DEFAULT_COLOR_THEME_ID, registerCoreConfiguration } from "./coreDefaults"; +import { + CORE_CONFIGURATION, + DEFAULT_COLOR_THEME_ID, + DEFAULT_KEYBINDING_PRESET, + registerCoreConfiguration, +} from "./coreDefaults"; /** A {@link ConfigServiceFs} with no files on disk, so every layer besides * the defaults layer stays empty (matches other suites' hermetic fs stubs). */ @@ -37,13 +42,15 @@ describe("registerCoreConfiguration (Req 9.5)", () => { expect(config.get("editor.tabSize")).toBe(4); expect(config.get("editor.insertSpaces")).toBe(true); expect(config.get("workbench.colorTheme")).toBe(DEFAULT_COLOR_THEME_ID); + expect(config.get("keybindings.preset")).toBe(DEFAULT_KEYBINDING_PRESET); }); - test("CORE_CONFIGURATION declares exactly the four documented keys", () => { + test("CORE_CONFIGURATION declares exactly the five documented keys", () => { expect(Object.keys(CORE_CONFIGURATION.properties).sort()).toEqual([ "editor.insertSpaces", "editor.lineNumbers", "editor.tabSize", + "keybindings.preset", "workbench.colorTheme", ]); expect(CORE_CONFIGURATION.properties["editor.lineNumbers"]).toMatchObject({ @@ -62,6 +69,10 @@ describe("registerCoreConfiguration (Req 9.5)", () => { type: "string", default: DEFAULT_COLOR_THEME_ID, }); + expect(CORE_CONFIGURATION.properties["keybindings.preset"]).toMatchObject({ + type: "string", + default: DEFAULT_KEYBINDING_PRESET, + }); }); test("disposing the registration removes the defaults", async () => { diff --git a/packages/core/src/config/coreDefaults.ts b/packages/core/src/config/coreDefaults.ts index 1a842c9..3a436e1 100644 --- a/packages/core/src/config/coreDefaults.ts +++ b/packages/core/src/config/coreDefaults.ts @@ -37,6 +37,25 @@ import type { ConfigurationContribution, Disposable } from "@tecode/api"; */ export const DEFAULT_COLOR_THEME_ID = "tecode.dark-modern"; +/** + * `keybindings.preset`'s default value (Req 4.8, design.md §6.6, Issue + * #81 Phase 2) — the `keymap/presetKeybindings.ts`'s + * `DEFAULT_KEYBINDING_PRESET_NAME` value, DUPLICATED here as a literal + * string for the SAME reason {@link DEFAULT_COLOR_THEME_ID} above + * duplicates `themes-default`'s theme id rather than importing it: unlike + * that case this isn't a `core`/`builtin` layering constraint (both + * `config/` and `keymap/` live inside `core`), but `config/` has no + * EXISTING import from `keymap/` anywhere in this codebase today — only + * the reverse (`keymap/fallbackKeybindings.ts`'s `../config/jsonc`) — and + * introducing a brand-new `config -> keymap` edge for one literal string + * is not worth it. Kept in sync by hand; + * `packages/cli/src/keybindingPresets.test.ts` asserts this literal + * equals the real `DEFAULT_KEYBINDING_PRESET_NAME` export, so a drift + * between the two fails a test rather than silently resolving to the + * wrong default. + */ +export const DEFAULT_KEYBINDING_PRESET = "default"; + /** The narrow slice of `ConfigService` {@link registerCoreConfiguration} * needs — the same shape as `host/registration.ts`'s `ConfigRegistrar`, * duplicated locally rather than imported so `config/` never depends on @@ -61,7 +80,17 @@ export interface CoreConfigRegistrar { * rather than `ThemeRegistry`'s bare `BASE_THEME_ID` fallback palette, so * a fresh install with no `settings.json` entry still resolves to a real, * always-present, VS-Code-equivalent theme (Req 11.4) from the very first - * frame. */ + * frame; `keybindings.preset` (Req 4.8, design.md §6.6, Issue #81 + * Phase 2) selects a bundled keybinding scheme by name + * (`keymap/presetKeybindings.ts`'s `KEYBINDING_PRESET_NAMES` — + * `"default"`/`"emacs"`/`"windows"`), defaulting to + * {@link DEFAULT_KEYBINDING_PRESET} (no preset — the `preset` layer + * resolves to `[]`), applied and live-reloaded by `packages/cli/src/ + * main.ts`'s `buildAssemblyRoot` exactly like `workbench.colorTheme` + * (`ui/themeConfigSync.ts`'s wiring pattern). An unrecognized value + * degrades to no preset with a logged warning + * (`resolveKeybindingPreset`'s own TSDoc) rather than throwing or + * crashing startup. */ export const CORE_CONFIGURATION: ConfigurationContribution = { title: "Editor", properties: { @@ -85,6 +114,12 @@ export const CORE_CONFIGURATION: ConfigurationContribution = { default: DEFAULT_COLOR_THEME_ID, description: "The id of the active color theme.", }, + "keybindings.preset": { + type: "string", + default: DEFAULT_KEYBINDING_PRESET, + description: + 'A bundled keybinding scheme to layer over the defaults: "default" (none), "emacs", or "windows".', + }, }, }; diff --git a/packages/core/src/config/index.ts b/packages/core/src/config/index.ts index 8467a0e..2ed47b1 100644 --- a/packages/core/src/config/index.ts +++ b/packages/core/src/config/index.ts @@ -17,6 +17,7 @@ export { export { CORE_CONFIGURATION, DEFAULT_COLOR_THEME_ID, + DEFAULT_KEYBINDING_PRESET, registerCoreConfiguration, type CoreConfigRegistrar, } from "./coreDefaults"; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 87a5708..7fc61df 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -54,11 +54,16 @@ export { createBindingTable, createChordStateMachine, createContextService, + DEFAULT_KEYBINDING_PRESET_NAME, + EMACS_KEYBINDING_PRESET, + KEYBINDING_PRESET_NAMES, keyEventToStroke, loadFallbackKeybindings, normalizeKey, normalizeKeySequence, + resolveKeybindingPreset, WhenParseError, + WINDOWS_KEYBINDING_PRESET, type BindingLayer, type BindingTable, type BindingTableDeps, @@ -68,10 +73,12 @@ export { type CompiledWhen, type ContextService, type FallbackKeybindingsFs, + type KeybindingPresetName, type KeyEventLike, type KeymapLayers, type LoadFallbackKeybindingsDeps, type ResolvedBinding, + type ResolveKeybindingPresetDeps, type WhenAndNode, type WhenContextGetter, type WhenEqNode, @@ -307,6 +314,7 @@ export { CORE_CONFIGURATION, createConfigService, DEFAULT_COLOR_THEME_ID, + DEFAULT_KEYBINDING_PRESET, parseJsonc, registerCoreConfiguration, type ConfigService, diff --git a/packages/core/src/keymap/bindingTable.test.ts b/packages/core/src/keymap/bindingTable.test.ts index 7e0ceb3..89079bc 100644 --- a/packages/core/src/keymap/bindingTable.test.ts +++ b/packages/core/src/keymap/bindingTable.test.ts @@ -16,6 +16,7 @@ function layersOf(partial: Partial): KeymapLayers { defaults: partial.defaults ?? [], fallback: partial.fallback ?? [], extension: partial.extension ?? [], + preset: partial.preset ?? [], user: partial.user ?? [], }; } @@ -399,6 +400,7 @@ test("non-string key or command entries are skipped with a warning, never thrown defaults: malformed, fallback: [], extension: [], + preset: [], user: [{ key: "ctrl+p", command: "quickOpen.show" }], }, { log }, @@ -528,6 +530,7 @@ test("a removal entry with a when clause is skipped with a warning, leaving lowe defaults: [{ key: "ctrl+p", command: "quickOpen.show" }], fallback: [], extension: [], + preset: [], user: [ { key: "ctrl+p", command: "-quickOpen.show", when: "editorFocus" }, ], diff --git a/packages/core/src/keymap/bindingTable.ts b/packages/core/src/keymap/bindingTable.ts index ea48530..27f3af9 100644 --- a/packages/core/src/keymap/bindingTable.ts +++ b/packages/core/src/keymap/bindingTable.ts @@ -5,7 +5,12 @@ * `keybindings.json`"). Task 1.5 adds a fourth layer ahead of defaults — * the terminal-capability fallback keymap (Req 4.7, design.md §6.5), * populated starting Task 4.2 but already part of the shape here so - * callers don't need to migrate later. + * callers don't need to migrate later. Issue #81 Phase 2 (Req 4.8, design.md + * §6.6) adds a fifth layer, `preset` — a bundled keybinding scheme + * (Emacs/Windows) selected via `keybindings.preset` — sitting ABOVE + * `extension`; see {@link KeymapLayers}'s own TSDoc for why that specific + * position (not between `defaults` and `fallback`, where an earlier plan + * placed it) is load-bearing. * * `when` clauses are compiled once at build time via {@link compileWhen} * (design.md §6.2, §6.4: "Clauses are parsed once at registration into an @@ -30,12 +35,37 @@ import type { HostLog } from "../host/errors"; import { normalizeKeySequence } from "./normalize"; import { compileWhen, WhenParseError, type CompiledWhen, type WhenContextGetter } from "./when"; -/** The four binding layers, in ascending precedence order (design.md §6.2, - * §6.5): defaults < fallback < extension < user. `fallback` is the - * terminal-capability overlay (Req 4.7) — legitimately empty until it is - * populated in Task 4.2, so every layer is required here rather than - * optional, keeping precedence order a fact about array position, not - * about which fields happen to be present. */ +/** The five binding layers, in ascending precedence order (design.md §6.2, + * §6.5, §6.6 — Issue #81 Phase 2's `preset` layer): defaults < fallback < + * extension < preset < user. `fallback` is the terminal-capability overlay + * (Req 4.7) — legitimately empty until it is populated in Task 4.2, so + * every layer is required here rather than optional, keeping precedence + * order a fact about array position, not about which fields happen to be + * present. + * + * **`preset` sits ABOVE `extension`, not below it** (Req 4.8, design.md + * §6.6) — deliberately corrected from an earlier plan that placed it + * between `defaults` and `fallback`. A bundled keybinding preset (Emacs, + * Windows — `keymap/presets/`) exists specifically to override an + * extension's own default bindings on keys the user opted into remapping + * (e.g. Emacs's `ctrl+f`/`ctrl+s` overriding `editor-core`'s + * find/save) — that only works if `preset`'s entries have a HIGHER + * `order` than `extension`'s in {@link createBindingTable}'s build loop, + * since both `lookup`'s "last, when-passing entry wins" rule and + * `visibleEntries`'s removal-masking rule ("a removal masks only + * STRICTLY LOWER `order` bindings of the same command" — that function's + * own TSDoc) only let a LATER layer override or remove an EARLIER one, + * never the reverse. Concretely, this is what lets the Emacs preset's `-` + * removal of `keybindings-editor`'s unconditional `ctrl+k ctrl+s` + * binding (`presets/emacs.json`'s own TSDoc) actually take effect — with + * `preset` below `extension`, that removal would be silently inert (its + * `order` would be lower than the binding it's trying to mask), and + * `hasSequencePrefix("ctrl+k", ...)` would keep reporting `true` forever, + * making the preset's own `ctrl+k` → delete-line binding unreachable + * (`chords.ts`'s `handleIdleStroke`: prefix-wins is checked + * unconditionally, before the exact match). Still below `user`: a user's + * own `keybindings.json` always wins over a bundled preset, exactly like + * every other layer. */ export interface KeymapLayers { /** Core default bindings. */ defaults: KeybindingContribution[]; @@ -45,12 +75,20 @@ export interface KeymapLayers { fallback: KeybindingContribution[]; /** Bindings contributed by extension manifests (`contributes.keybindings`). */ extension: KeybindingContribution[]; + /** The active bundled keybinding preset's entries (Req 4.8, design.md + * §6.6, Issue #81 Phase 2) — resolved from the `keybindings.preset` + * setting via `keymap/presetKeybindings.ts`'s `resolveKeybindingPreset`, + * empty for `"default"` (this module's own TSDoc explains why this + * layer sits ABOVE `extension`). Kept up to date across a live + * `keybindings.preset` config change by `cli/keymapState.ts`'s + * `setPresetEntries`, exactly like `fallback`/`extension`/`user`. */ + preset: KeybindingContribution[]; /** The user's `keybindings.json` entries — highest precedence. */ user: KeybindingContribution[]; } /** The layer a resolved binding (or `entries()` row) came from. */ -export type BindingLayer = "defaults" | "fallback" | "extension" | "user"; +export type BindingLayer = "defaults" | "fallback" | "extension" | "preset" | "user"; /** Dependencies {@link createBindingTable} reports through rather than * owning directly (design.md §5, §14) — a structured log for skipped, @@ -78,7 +116,7 @@ export interface ResolvedBinding { * extension id / user) per binding") — copied straight through from * `KeybindingContribution.extensionId` (`@tecode/api`'s `manifest.ts`), * which `host/registration.ts`'s `registerExtension` is the only thing - * that ever sets. Always `undefined` for `defaults`/`fallback`/`user` + * that ever sets. Always `undefined` for `defaults`/`fallback`/`preset`/`user` * entries — those layers have no notion of an "owning extension" at * all. */ extensionId?: string; @@ -161,7 +199,7 @@ function logSafely(log: HostLog, level: "error" | "warning", message: string): v } } -const LAYER_ORDER: readonly BindingLayer[] = ["defaults", "fallback", "extension", "user"]; +const LAYER_ORDER: readonly BindingLayer[] = ["defaults", "fallback", "extension", "preset", "user"]; /** * Build the layered keybinding table (Req 4.1-4.3, design.md §6.2). @@ -357,7 +395,7 @@ function compileEntry( // Only ever meaningful on the extension layer (`ResolvedBinding. // extensionId`'s TSDoc) — deliberately gated on `layer === "extension"` // rather than just forwarding `contribution.extensionId` unconditionally, - // so a `defaults`/`fallback`/`user` entry can never surface an + // so a `defaults`/`fallback`/`preset`/`user` entry can never surface an // `extensionId` here even if its raw JSON happened to carry a stray // one (`keybindings.json` is user-authored, untyped input). extensionId: layer === "extension" ? contribution.extensionId : undefined, diff --git a/packages/core/src/keymap/chords.test.ts b/packages/core/src/keymap/chords.test.ts index dda922a..7148f17 100644 --- a/packages/core/src/keymap/chords.test.ts +++ b/packages/core/src/keymap/chords.test.ts @@ -21,6 +21,7 @@ function layersOf(partial: Partial): KeymapLayers { defaults: partial.defaults ?? [], fallback: partial.fallback ?? [], extension: partial.extension ?? [], + preset: partial.preset ?? [], user: partial.user ?? [], }; } @@ -404,6 +405,7 @@ test("a disposed machine passes strokes through and executes nothing", () => { defaults: [{ key: "ctrl+p", command: "quickOpen.show" }], fallback: [], extension: [], + preset: [], user: [], }, { log: createHostLog() }, @@ -429,6 +431,7 @@ test("a scheduler whose set() throws does not break pending entry", () => { defaults: [{ key: "ctrl+k ctrl+s", command: "keybindings.open" }], fallback: [], extension: [], + preset: [], user: [], }, { log: createHostLog() }, diff --git a/packages/core/src/keymap/index.ts b/packages/core/src/keymap/index.ts index 07c53c3..8479e6b 100644 --- a/packages/core/src/keymap/index.ts +++ b/packages/core/src/keymap/index.ts @@ -40,3 +40,12 @@ export { type FallbackKeybindingsFs, type LoadFallbackKeybindingsDeps, } from "./fallbackKeybindings"; +export { + DEFAULT_KEYBINDING_PRESET_NAME, + EMACS_KEYBINDING_PRESET, + KEYBINDING_PRESET_NAMES, + resolveKeybindingPreset, + WINDOWS_KEYBINDING_PRESET, + type KeybindingPresetName, + type ResolveKeybindingPresetDeps, +} from "./presetKeybindings"; diff --git a/packages/core/src/keymap/presetKeybindings.test.ts b/packages/core/src/keymap/presetKeybindings.test.ts new file mode 100644 index 0000000..6f35472 --- /dev/null +++ b/packages/core/src/keymap/presetKeybindings.test.ts @@ -0,0 +1,108 @@ +/** + * Tests for {@link resolveKeybindingPreset} and its exported constants (Req + * 12.1, 12.2, design.md §6.6, Issue #81 Phase 2). Cross-package + * completeness checks — every referenced command actually exists, the + * `ctrl+k` chord-shadowing fix, layer-precedence proofs — live in + * `packages/cli/src/keybindingPresets.test.ts` instead, since they need + * `@tecode/builtin`'s real manifests and `core` may not import `builtin` + * (`config/coreDefaults.ts`'s own TSDoc explains the same one-directional + * layering constraint). + */ + +import { expect, test } from "bun:test"; +import { createHostLog } from "../host/errors"; +import { + DEFAULT_KEYBINDING_PRESET_NAME, + EMACS_KEYBINDING_PRESET, + KEYBINDING_PRESET_NAMES, + resolveKeybindingPreset, + WINDOWS_KEYBINDING_PRESET, +} from "./presetKeybindings"; + +test("KEYBINDING_PRESET_NAMES is exactly default/emacs/windows — no vim, deliberately (Issue #81's scope)", () => { + const names: string[] = [...KEYBINDING_PRESET_NAMES]; + expect(names.sort()).toEqual(["default", "emacs", "windows"].sort()); +}); + +test("DEFAULT_KEYBINDING_PRESET_NAME is 'default'", () => { + expect(DEFAULT_KEYBINDING_PRESET_NAME).toBe("default"); +}); + +test('resolveKeybindingPreset("default") resolves to [] and logs nothing (the expected no-op)', () => { + const log = createHostLog(); + expect(resolveKeybindingPreset("default", { log })).toEqual([]); + expect(log.entries()).toEqual([]); +}); + +test('resolveKeybindingPreset("emacs") resolves to the real bundled EMACS_KEYBINDING_PRESET, not a copy that happens to look equal', () => { + const log = createHostLog(); + const resolved = resolveKeybindingPreset("emacs", { log }); + expect(resolved).toEqual(EMACS_KEYBINDING_PRESET); + expect(resolved.length).toBeGreaterThan(0); + expect(log.entries()).toEqual([]); +}); + +test('resolveKeybindingPreset("windows") resolves to the real bundled WINDOWS_KEYBINDING_PRESET', () => { + const log = createHostLog(); + const resolved = resolveKeybindingPreset("windows", { log }); + expect(resolved).toEqual(WINDOWS_KEYBINDING_PRESET); + expect(resolved.length).toBeGreaterThan(0); + expect(log.entries()).toEqual([]); +}); + +test("resolveKeybindingPreset returns a fresh array each call — callers may safely mutate the result", () => { + const log = createHostLog(); + const a = resolveKeybindingPreset("emacs", { log }); + const b = resolveKeybindingPreset("emacs", { log }); + expect(a).not.toBe(b); + expect(a).not.toBe(EMACS_KEYBINDING_PRESET); +}); + +test('an unknown preset name resolves to [] and logs a warning naming the value and the valid set (not "default"\'s silent no-op)', () => { + const log = createHostLog(); + const resolved = resolveKeybindingPreset("vim", { log }); + expect(resolved).toEqual([]); + const warnings = log.entries().filter((e) => e.level === "warning"); + expect(warnings).toHaveLength(1); + expect(warnings[0]?.error.message).toContain("vim"); + expect(warnings[0]?.error.message).toContain("emacs"); + expect(warnings[0]?.error.message).toContain("windows"); +}); + +test("an empty-string preset name is treated as unknown, not as default", () => { + const log = createHostLog(); + expect(resolveKeybindingPreset("", { log })).toEqual([]); + expect(log.entries().some((e) => e.level === "warning")).toBe(true); +}); + +test("resolveKeybindingPreset never throws even when the injected log itself throws", () => { + const throwingLog = { + append: () => { + throw new Error("log is broken"); + }, + entries: () => [], + }; + expect(() => resolveKeybindingPreset("not-a-real-preset", { log: throwingLog as never })).not.toThrow(); +}); + +test("EMACS_KEYBINDING_PRESET removes keybindings-editor's ctrl+k ctrl+s chord via -command syntax", () => { + const removal = EMACS_KEYBINDING_PRESET.find( + (entry) => entry.key === "ctrl+k ctrl+s", + ); + expect(removal?.command).toBe("-keybindings.open"); +}); + +test("EMACS_KEYBINDING_PRESET binds ctrl+k to deleteLine (Emacs kill-line)", () => { + const killLine = EMACS_KEYBINDING_PRESET.find((entry) => entry.key === "ctrl+k"); + expect(killLine?.command).toBe("editor.action.deleteLine"); + expect(killLine?.when).toBe("editorTextFocus"); +}); + +test("every EMACS_KEYBINDING_PRESET/WINDOWS_KEYBINDING_PRESET entry has a non-empty string key and command", () => { + for (const entry of [...EMACS_KEYBINDING_PRESET, ...WINDOWS_KEYBINDING_PRESET]) { + expect(typeof entry.key).toBe("string"); + expect(entry.key.length).toBeGreaterThan(0); + expect(typeof entry.command).toBe("string"); + expect(entry.command.length).toBeGreaterThan(0); + } +}); diff --git a/packages/core/src/keymap/presetKeybindings.ts b/packages/core/src/keymap/presetKeybindings.ts new file mode 100644 index 0000000..cd65b12 --- /dev/null +++ b/packages/core/src/keymap/presetKeybindings.ts @@ -0,0 +1,221 @@ +/** + * Bundled keybinding presets (Req 4.8, design.md §6.6; Issue #81 + * Phase 2, 「例として、Emacsキーバインド...Windows風キーバインドのファイルを + * 用意してほしい」): `presets/emacs.json` and `presets/windows.json`, sitting + * right next to this module, are each a plain `KeybindingContribution[]` in + * the same on-disk shape as the user's `keybindings.json` — exactly + * `fallbackKeybindings.ts`'s own `keybindings.fallback.json` pattern, just + * one preset name away from a fourth (vim) that this issue's author + * explicitly asked NOT be built: a non-modal `"vim"` preset would be + * misleading, since this codebase's `when` contexts (`editorTextFocus`, + * `editorFocus`, `quickPickFocus`, `inputBoxFocus`, `findWidgetFocus`, + * `explorerFocus`, `editorLangId`) are purely focus-based, with no mode + * concept for a modal keymap to hook into. + * + * **Shipped in the binary, statically imported — no overlay-fs seam + * needed, and no user-override seam either** (unlike + * `fallbackKeybindings.ts`'s `loadFallbackKeybindings`, which checks + * `~/.config/tecode/keybindings.fallback.json` first): a preset is + * selected, not authored — {@link resolveKeybindingPreset} below is a pure + * `name -> entries` lookup with nothing to read from disk, so there is + * nothing that could ever be ENOENT or malformed the way a real user file + * could be. `fallbackKeybindings.ts`'s own TSDoc explains why Bun embeds a + * statically-imported JSON module's contents into the compiled binary at + * build time regardless of which package does the importing — this module + * relies on the exact same fact for {@link EMACS_KEYBINDING_PRESET}/ + * {@link WINDOWS_KEYBINDING_PRESET}. + * + * **A user who wants to tweak a preset still has the normal escape + * hatch**: `keybindings.json` (the `user` layer) sits above `preset` in + * `bindingTable.ts`'s `LAYER_ORDER`, so any entry here can be overridden or + * `-command`-removed from there exactly like a `defaults`/`fallback`/ + * `extension` entry — no separate per-preset override file is needed the + * way `fallbackKeybindings.ts` provides one for the terminal-capability + * overlay (that overlay has no `keybindings.json`-editing user in the loop + * at the moment it's chosen; a preset's user very much does). + * + * ## Preset content — what's bound and why + * + * **`emacs`** (`presets/emacs.json`) binds the handful of Emacs chords a + * long-time Emacs user reaches for reflexively, onto `editor-core`'s real + * commands (Req 4.8): `ctrl+a`/`ctrl+e` (`move-beginning-of-line`/ + * `move-end-of-line` -> `cursorHome`/`cursorEnd`), `ctrl+f`/`ctrl+b`/ + * `ctrl+n`/`ctrl+p` (`forward-char`/`backward-char`/`next-line`/ + * `previous-line` -> `cursorRight`/`cursorLeft`/`cursorDown`/`cursorUp`), + * `alt+f`/`alt+b` (`forward-word`/`backward-word` -> `cursorWordRight`/ + * `cursorWordLeft`), `ctrl+k` (`kill-line`, approximated as + * `editor.action.deleteLine` — there is no kill-ring/yank concept in this + * editor to model a true "kill" with), `ctrl+s` (`isearch-forward`, + * approximated as `editor.action.find` — the closest existing command), + * and `ctrl+x ctrl+s` (`save-buffer` -> `editor.action.save`, a brand-new + * chord; nothing else in any built-in manifest claims `ctrl+x` as a first + * stroke). Deliberately NOT bound: undo (Emacs's own `ctrl+/`/`ctrl+x u` + * would collide with `editor-core`'s `toggleLineComment`/introduce a + * second `ctrl+x` chord family for one binding) and yank/kill-ring + * navigation (no backing command exists) — "fewer, correct bindings" over + * a large half-right set. + * + * **Three DELIBERATE collisions with existing higher-*visibility* (not + * higher-*precedence* — `preset` outranks `extension` per + * `bindingTable.ts`'s `KeymapLayers` TSDoc) bindings**, each an intentional + * "that's the point of a preset" override, each scoped to + * `editorTextFocus` so the overridden command still works everywhere + * else: + * - `ctrl+f`: `editor-core`'s own default (`editor.action.find`) is + * shadowed by Emacs's `forward-char` while a text buffer is focused; + * Emacs's own find analog moves to `ctrl+s` instead. + * - `ctrl+s`: `editor-core`'s own default (`editor.action.save`) is + * shadowed by Emacs's `isearch-forward`; save moves to the `ctrl+x + * ctrl+s` chord instead, matching real Emacs. + * - `ctrl+p`: `command-palette`'s own default (`workbench.action. + * quickOpen`, bound with NO `when` clause at all — always visible) is + * shadowed by Emacs's `previous-line` specifically while a text buffer + * is focused; `workbench.action.quickOpen` still fires on `ctrl+p` + * everywhere else (any other focus state), since the preset's own entry + * fails its `editorTextFocus` clause there and `lookup` falls through. + * + * **One more, structural rather than a keymap choice — the + * `ctrl+k ctrl+s` chord-shadowing hazard**: `keybindings-editor`'s manifest + * binds `ctrl+k ctrl+s` -> `keybindings.open` with NO `when` clause (always + * visible). `chords.ts`'s `handleIdleStroke` checks + * `BindingTable.hasSequencePrefix` UNCONDITIONALLY, before ever trying an + * exact-match `lookup` ("prefix wins", design.md §6.3) — so as long as + * that chord is registered and visible, EVERY bare `ctrl+k` keystroke + * enters chord-pending state first, and Emacs's own `ctrl+k` -> + * `deleteLine` binding above would NEVER fire; the user would just see a + * `(ctrl+k)` pending indicator that times out after 3 seconds (or resolves + * to "open keybindings.json" if they happen to follow up with `ctrl+s`). + * `presets/emacs.json`'s trailing `{ "key": "ctrl+k ctrl+s", "command": + * "-keybindings.open" }` entry removes that chord outright, which is what + * makes `ctrl+k` resolve directly again. This removal is EXACTLY why + * `preset` must sit ABOVE `extension` in `LAYER_ORDER` + * (`bindingTable.ts`'s `KeymapLayers` TSDoc, `visibleEntries`'s own + * "a removal masks only STRICTLY LOWER `order` bindings" rule) — verified + * directly by `packages/cli/src/keybindingPresets.test.ts`'s + * `handleStroke("ctrl+k")` test, which presses the real chord machine + * rather than merely checking the table for an entry. + * + * **`windows`** (`presets/windows.json`) is deliberately small, and says so + * rather than padding itself: this codebase's defaults are already + * VS-Code-on-Windows/Linux-shaped (Ctrl-based shortcuts throughout), so + * there is little left to change. The one real, verified divergence in the + * shipped defaults is `editor-core/manifest.ts`'s move/duplicate-line + * bindings, which use `alt+meta+up`/`alt+meta+down`/`shift+alt+meta+down` + * — a macOS idiom (`meta` = Cmd; that manifest's own TSDoc explains why an + * Alt-held arrow always carries BOTH the `alt` and `meta` tokens on this + * terminal input pipeline). VS Code's real Windows/Linux defaults for the + * same three actions omit the Cmd/meta modifier entirely: `alt+up`/ + * `alt+down`/`shift+alt+down`. This preset ADDS those three keys for the + * same three commands (not a `-command` removal — the meta-flavored keys + * stay bound too, harmlessly unreachable on a real Windows/Linux terminal + * since there is no meta key to hold) rather than any User-facing rename; + * everything else in the default keymap (Ctrl+S/Ctrl+F/Ctrl+Z/Ctrl+Shift+K/ + * arrows/Home/End/Tab/...) already matches Windows/Linux convention with + * no divergence to bind. + */ + +import type { KeybindingContribution } from "@tecode/api"; +import type { HostLog } from "../host/errors"; +import emacsPresetJson from "./presets/emacs.json"; +import windowsPresetJson from "./presets/windows.json"; + +/** + * `presets/emacs.json`'s entries, typed as plain `KeybindingContribution[]` + * — same "trust nothing from JSON, let `bindingTable.ts`'s `compileEntry` + * defensively validate every field at build time" posture every other raw + * keybinding source in this codebase has (`fallbackKeybindings.ts`'s + * `BUNDLED_FALLBACK_KEYBINDINGS` TSDoc says the same). See this module's + * TSDoc for the full rationale behind every binding. + */ +export const EMACS_KEYBINDING_PRESET: KeybindingContribution[] = + emacsPresetJson as KeybindingContribution[]; + +/** + * `presets/windows.json`'s entries, typed as plain `KeybindingContribution[]` + * — same posture as {@link EMACS_KEYBINDING_PRESET}. See this module's + * TSDoc for why this preset is intentionally small. + */ +export const WINDOWS_KEYBINDING_PRESET: KeybindingContribution[] = + windowsPresetJson as KeybindingContribution[]; + +/** + * Every valid `keybindings.preset` setting value (Req 4.8, design.md + * §6.6), in no particular order — `"default"` is the explicit no-op + * (`resolveKeybindingPreset` returns `[]` for it, same as for any unknown + * name, just silently rather than with a warning). Exported so + * `config/coreDefaults.ts`'s schema registration and tests can enumerate + * the real set rather than duplicating a literal list that could drift. + */ +export const KEYBINDING_PRESET_NAMES = ["default", "emacs", "windows"] as const; + +/** One valid `keybindings.preset` value — see {@link KEYBINDING_PRESET_NAMES}. */ +export type KeybindingPresetName = (typeof KEYBINDING_PRESET_NAMES)[number]; + +/** The `keybindings.preset` schema default (Req 4.8) — no preset active, + * `resolveKeybindingPreset` returns `[]`. Duplicated as a literal + * `"default"` string in `config/coreDefaults.ts` rather than imported + * there, matching that module's own `DEFAULT_COLOR_THEME_ID` precedent: + * `config/` has no existing import from `keymap/` (only the reverse, + * `fallbackKeybindings.ts`'s `../config/jsonc`), and this single-string + * duplication is far cheaper than introducing a new reverse edge between + * the two for one literal. Kept in sync with + * `config/coreDefaults.ts`'s own default by + * `packages/cli/src/keybindingPresets.test.ts`, which asserts against + * BOTH constants rather than trusting them to agree by inspection. */ +export const DEFAULT_KEYBINDING_PRESET_NAME: KeybindingPresetName = "default"; + +function isKeybindingPresetName(value: string): value is KeybindingPresetName { + return (KEYBINDING_PRESET_NAMES as readonly string[]).includes(value); +} + +/** Guarded `log.append` (matches `fallbackKeybindings.ts`'s own + * `logSafely`): an injected log must not be able to break this loader. */ +function logSafely(log: HostLog, message: string): void { + try { + log.append("warning", { message }); + } catch { + // Swallowed: reporting a reporting failure has nowhere left to go. + } +} + +/** Dependencies for {@link resolveKeybindingPreset}. */ +export interface ResolveKeybindingPresetDeps { + log: HostLog; +} + +/** + * Resolve the `preset` layer's entries for the given `keybindings.preset` + * setting value (Req 4.8, design.md §6.6). `"default"` — the schema + * default — and any name this module does not recognize both resolve to + * `[]`, the only difference being that an unrecognized name is reported to + * `deps.log` first (a typo'd setting value is a silently-dead preset + * otherwise, worth surfacing; `"default"` is the deliberate, expected + * no-op and logging it would just be noise on every normal startup). + * Synchronous and NEVER throws — there is no filesystem or JSON parsing + * involved at all (this module's TSDoc's "nothing to read from disk"), so + * every path here is a plain, already-known-valid array lookup. Always + * returns a fresh array (`.slice()`) — callers (`cli/keymapState.ts`'s + * `setPresetEntries`) are free to treat the result as owned. + */ +export function resolveKeybindingPreset( + name: string, + deps: ResolveKeybindingPresetDeps, +): KeybindingContribution[] { + if (name === DEFAULT_KEYBINDING_PRESET_NAME) return []; + + if (!isKeybindingPresetName(name)) { + logSafely( + deps.log, + `Unknown keybindings.preset "${name}" — expected one of ${KEYBINDING_PRESET_NAMES.join(", ")}. Falling back to no preset.`, + ); + return []; + } + + // `name` is now narrowed to `KeybindingPresetName`, but `"default"` was + // already handled (and returned) above — these two `if`s, not a + // `switch`, avoid a spurious "not all code paths return" complaint from + // an exhaustiveness check that can't see the earlier early return. + if (name === "emacs") return EMACS_KEYBINDING_PRESET.slice(); + if (name === "windows") return WINDOWS_KEYBINDING_PRESET.slice(); + return []; +} diff --git a/packages/core/src/keymap/presets/emacs.json b/packages/core/src/keymap/presets/emacs.json new file mode 100644 index 0000000..86776ce --- /dev/null +++ b/packages/core/src/keymap/presets/emacs.json @@ -0,0 +1,14 @@ +[ + { "key": "ctrl+a", "command": "editor.action.cursorHome", "when": "editorTextFocus" }, + { "key": "ctrl+e", "command": "editor.action.cursorEnd", "when": "editorTextFocus" }, + { "key": "ctrl+f", "command": "editor.action.cursorRight", "when": "editorTextFocus" }, + { "key": "ctrl+b", "command": "editor.action.cursorLeft", "when": "editorTextFocus" }, + { "key": "ctrl+n", "command": "editor.action.cursorDown", "when": "editorTextFocus" }, + { "key": "ctrl+p", "command": "editor.action.cursorUp", "when": "editorTextFocus" }, + { "key": "alt+f", "command": "editor.action.cursorWordRight", "when": "editorTextFocus" }, + { "key": "alt+b", "command": "editor.action.cursorWordLeft", "when": "editorTextFocus" }, + { "key": "ctrl+k", "command": "editor.action.deleteLine", "when": "editorTextFocus" }, + { "key": "ctrl+s", "command": "editor.action.find", "when": "editorTextFocus" }, + { "key": "ctrl+x ctrl+s", "command": "editor.action.save", "when": "editorTextFocus" }, + { "key": "ctrl+k ctrl+s", "command": "-keybindings.open" } +] diff --git a/packages/core/src/keymap/presets/windows.json b/packages/core/src/keymap/presets/windows.json new file mode 100644 index 0000000..41377e7 --- /dev/null +++ b/packages/core/src/keymap/presets/windows.json @@ -0,0 +1,5 @@ +[ + { "key": "alt+up", "command": "editor.action.moveLinesUp", "when": "editorTextFocus" }, + { "key": "alt+down", "command": "editor.action.moveLinesDown", "when": "editorTextFocus" }, + { "key": "shift+alt+down", "command": "editor.action.duplicateLine", "when": "editorTextFocus" } +] diff --git a/packages/core/src/ui/chordPendingIndicator.test.ts b/packages/core/src/ui/chordPendingIndicator.test.ts index 6042c0c..98ab9df 100644 --- a/packages/core/src/ui/chordPendingIndicator.test.ts +++ b/packages/core/src/ui/chordPendingIndicator.test.ts @@ -23,6 +23,7 @@ function layersOf(partial: Partial): KeymapLayers { defaults: partial.defaults ?? [], fallback: partial.fallback ?? [], extension: partial.extension ?? [], + preset: partial.preset ?? [], user: partial.user ?? [], }; } diff --git a/packages/core/src/ui/keybindingsCommands.test.ts b/packages/core/src/ui/keybindingsCommands.test.ts index 8bd68c2..4c72ac5 100644 --- a/packages/core/src/ui/keybindingsCommands.test.ts +++ b/packages/core/src/ui/keybindingsCommands.test.ts @@ -57,6 +57,7 @@ function layersOf(partial: Partial): KeymapLayers { defaults: partial.defaults ?? [], fallback: partial.fallback ?? [], extension: partial.extension ?? [], + preset: partial.preset ?? [], user: partial.user ?? [], }; } @@ -202,7 +203,7 @@ describe("createKeybindingsCommandsHandlers — ensureFile (Req 4.2, 11.7)", () }); describe("createKeybindingsCommandsHandlers — resolveTable (Req 11.7)", () => { - test("flattens every visible binding across all four layers, sorted by key, with source-layer attribution", async () => { + test("flattens every visible binding across all five layers, sorted by key, with source-layer attribution", async () => { const { fs } = createFakeFs(); const table = createBindingTable( layersOf({ @@ -211,6 +212,7 @@ describe("createKeybindingsCommandsHandlers — resolveTable (Req 11.7)", () => extension: [ { key: "ctrl+shift+r", command: "demo.run", extensionId: "demo.ext" }, ], + preset: [{ key: "ctrl+e", command: "editor.action.cursorEnd" }], user: [{ key: "ctrl+alt+z", command: "user.command", when: "editorFocus" }], }), { log: createHostLog() }, @@ -225,6 +227,7 @@ describe("createKeybindingsCommandsHandlers — resolveTable (Req 11.7)", () => expect(rows).toEqual([ { key: "ctrl+alt+z", command: "user.command", layer: "user", when: "editorFocus" }, + { key: "ctrl+e", command: "editor.action.cursorEnd", layer: "preset" }, { key: "ctrl+s", command: "editor.action.save", layer: "defaults" }, { key: "ctrl+shift+alt+p", diff --git a/packages/core/src/ui/modalOverlay.test.tsx b/packages/core/src/ui/modalOverlay.test.tsx index d5a9b5a..4a3e16c 100644 --- a/packages/core/src/ui/modalOverlay.test.tsx +++ b/packages/core/src/ui/modalOverlay.test.tsx @@ -146,7 +146,7 @@ describe("ModalOverlay — end-to-end keyboard accept/cancel through the real mo const modalService = createModalService(); registerModalCommands(commands, modalService); const table = createBindingTable( - { defaults: MODAL_DEFAULT_KEYBINDINGS, fallback: [], extension: [], user: [] }, + { defaults: MODAL_DEFAULT_KEYBINDINGS, fallback: [], extension: [], preset: [], user: [] }, { log }, ); return { context, commands, modalService, table }; diff --git a/packages/core/src/ui/statusBarComposition.snapshot.test.tsx b/packages/core/src/ui/statusBarComposition.snapshot.test.tsx index d7720d9..4f59068 100644 --- a/packages/core/src/ui/statusBarComposition.snapshot.test.tsx +++ b/packages/core/src/ui/statusBarComposition.snapshot.test.tsx @@ -62,6 +62,7 @@ function layersOf(partial: Partial): KeymapLayers { defaults: partial.defaults ?? [], fallback: partial.fallback ?? [], extension: partial.extension ?? [], + preset: partial.preset ?? [], user: partial.user ?? [], }; } diff --git a/requirements.md b/requirements.md index c3fbf93..459c81b 100644 --- a/requirements.md +++ b/requirements.md @@ -78,13 +78,14 @@ The following points were open in the draft specification and are resolved here #### Acceptance Criteria -1. THE system SHALL resolve keybindings with this precedence: user `keybindings.json` first, then extension manifest keybindings, then core defaults. +1. THE system SHALL resolve keybindings with this precedence, highest first: user `keybindings.json`, then the selected bundled preset (4.8), then extension manifest keybindings, then the terminal-capability fallback overlay (4.7), then core defaults. 2. THE `keybindings.json` format SHALL be VS Code-compatible: an array of `{ "key", "command", "when"? }` entries. 3. WHEN an entry's command is prefixed with `-` (e.g. `"-editor.action.foo"`), THE system SHALL remove the matching default binding. 4. THE system SHALL support two-stroke chord sequences (e.g. `ctrl+k ctrl+s`) using the `@opentui/keymap` sequence engine. 5. THE system SHALL evaluate `when` clauses supporting: the context keys `editorFocus`, `editorTextFocus`, `terminalFocus`, `explorerFocus`; equality tests such as `editorLangId == 'ts'`; and the operators `&&`, `||`, `!`. No other expression syntax is required in the MVP. 6. THE system SHALL provide `tecode.context.set(key, value)` and `tecode.context.get(key)` so extensions can define context keys used in `when` clauses. 7. WHEN tecode starts on a terminal that does not support the Kitty Keyboard Protocol, THE system SHALL detect this and overlay the fallback keymap from `keybindings.fallback.json` so that otherwise-indistinguishable combinations (e.g. `ctrl+shift+*`) remain usable. +8. THE system SHALL provide bundled keybinding presets — `"emacs"` and `"windows"` — selectable via a `keybindings.preset` setting (default `"default"`, meaning no preset), layered above extension-contributed bindings and below the user's own `keybindings.json`, so a preset may override an extension's default binding but a user override always wins; changing the setting SHALL take effect immediately, without a restart. ### Requirement 5: Documents and Text Buffer diff --git a/samples/settings.json b/samples/settings.json index 7df71c5..81f8a2b 100644 --- a/samples/settings.json +++ b/samples/settings.json @@ -32,6 +32,12 @@ // instead of a literal "\t". "editor.insertSpaces": true, + // A bundled keybinding scheme layered over the defaults (Req 4.8): + // "default" (none, shown here), "emacs", or "windows". See README.md's + // "Bundled keybinding presets" section for exactly what each one binds. + // Takes effect immediately on save, no restart needed. + "keybindings.preset": "default", + // --- explorer (packages/builtin/explorer/manifest.ts) --- // Show hidden (dot-prefixed) and .gitignore-ignored files in the