diff --git a/apps/ui/src/keyboard/keymap.test.ts b/apps/ui/src/keyboard/keymap.test.ts index 5a472d3f..63c0d83a 100644 --- a/apps/ui/src/keyboard/keymap.test.ts +++ b/apps/ui/src/keyboard/keymap.test.ts @@ -1,6 +1,13 @@ import { describe, expect, test } from "bun:test"; import type { CommandId } from "./commands"; -import { shortcutFor, shortcutForAria } from "./keymap"; +import { + chordSegments, + formatChordForDisplay, + type KeymapEntry, + leaderPrefixes, + shortcutFor, + shortcutForAria, +} from "./keymap"; // shortcutFor (RIG-2483, A5/D4) — the single derivation for every shortcut chip: // the first DEFAULT_KEYMAP row bound to an id, resolveChord-resolved. Pure @@ -52,3 +59,49 @@ describe("shortcutForAria", () => { expect(shortcutForAria(id("nonexistent.command"), "other")).toBeUndefined(); }); }); + +// Sequence-grammar helpers (RIG-2484 T1) — pure, table-independent. Tested over +// a FIXTURE keymap because DEFAULT_KEYMAP carries no sequence rows until T2. + +const seqFixture: readonly KeymapEntry[] = [ + { chord: "Mod+B", commandId: id("view.bridge") }, + { chord: "G B", commandId: id("view.bridge") }, + { chord: "G L", commandId: id("view.backlog") }, +]; + +describe("chordSegments", () => { + test("splits a sequence on its single space", () => { + expect(chordSegments("G B")).toEqual(["G", "B"]); + }); + + test("a plain chord yields a one-element array", () => { + expect(chordSegments("Mod+B")).toEqual(["Mod+B"]); + expect(chordSegments("Shift+Enter")).toEqual(["Shift+Enter"]); + }); +}); + +describe("leaderPrefixes", () => { + test("collects the resolved first segment of every sequence row, and nothing else", () => { + const prefixes = leaderPrefixes(seqFixture, "other"); + expect([...prefixes]).toEqual(["G"]); + }); + + test("empty for a table with no sequence rows", () => { + const single: readonly KeymapEntry[] = [ + { chord: "Mod+B", commandId: id("view.bridge") }, + ]; + expect(leaderPrefixes(single, "other").size).toBe(0); + }); +}); + +describe("formatChordForDisplay", () => { + test("a single chord resolves platform-specifically (Mod→Cmd/Ctrl)", () => { + expect(formatChordForDisplay("Mod+B", "mac")).toBe("Cmd+B"); + expect(formatChordForDisplay("Mod+B", "other")).toBe("Ctrl+B"); + }); + + test("a sequence joins resolved segments with ' then '", () => { + expect(formatChordForDisplay("G B", "mac")).toBe("G then B"); + expect(formatChordForDisplay("G L", "other")).toBe("G then L"); + }); +}); diff --git a/apps/ui/src/keyboard/keymap.ts b/apps/ui/src/keyboard/keymap.ts index 91861949..4d5bfb41 100644 --- a/apps/ui/src/keyboard/keymap.ts +++ b/apps/ui/src/keyboard/keymap.ts @@ -47,6 +47,57 @@ export const resolveChord = (chord: string, platform: Platform): string => export const resolveChordAria = (chord: string, platform: Platform): string => chord.replaceAll(MOD, platform === "mac" ? "Meta" : "Control"); +/** + * Split a chord string into its sequence segments on a single space. A plain + * (single-press) chord yields a one-element array; a leader sequence like + * `"G B"` yields `["G", "B"]`. Space is unambiguous as the separator because + * the literal Space key normalizes to the multi-char token `"Space"` + * (`dispatch.ts`), so a raw `" "` never appears as a key name inside a chord. + */ +export function chordSegments(chord: string): string[] { + return chord.split(" "); +} + +/** + * The set of resolved leader keys for `platform`: the `resolveChord`-resolved + * FIRST segment of every multi-segment (sequence) row in `keymap`. Derived from + * the table so the dispatcher never hard-codes a leader key — adding a second + * leader later is a data change, not a runtime change. A single-chord row + * contributes nothing. + */ +export function leaderPrefixes( + keymap: readonly KeymapEntry[], + platform: Platform, +): ReadonlySet { + const prefixes = new Set(); + for (const entry of keymap) { + const segments = chordSegments(entry.chord); + if (segments.length > 1) { + prefixes.add(resolveChord(segments[0], platform)); + } + } + return prefixes; +} + +/** + * The display form of a chord for `platform`. A single chord resolves through + * `resolveChord` (`"Mod+B"` → `"Cmd+B"`); a leader sequence resolves each + * segment and joins them with `" then "` (`"G B"` → `"G then B"`), making + * press order explicit where a bare `"G B"` would read as one simultaneous + * chord. The single formatter behind every display surface (chips, titles, + * the shortcuts overlay). + */ +export function formatChordForDisplay( + chord: string, + platform: Platform, +): string { + const segments = chordSegments(chord); + if (segments.length === 1) return resolveChord(chord, platform); + return segments + .map((segment) => resolveChord(segment, platform)) + .join(" then "); +} + /** * The display chord for a command: the FIRST `DEFAULT_KEYMAP` row bound to `id`, * `resolveChord`-resolved for `platform` (Mod→Cmd/Ctrl). `undefined` when no row @@ -87,6 +138,18 @@ export function shortcutForAria( * scoped entry takes precedence while its zone is active (D5's ranking rule: * "scoped commands rank above global ones when their scope is active"); the * consumer applies that precedence rather than double-firing. + * + * A `chord` may be a LEADER SEQUENCE: two segments separated by one space + * (`"G B"` = press `G` then `B`), resolved for display by + * `formatChordForDisplay` (`"G then B"`) and split by `chordSegments`. The + * dispatcher's leader runtime resolves the completed sequence through the same + * tiers as a single chord. Authoring rules (enforced by a `DEFAULT_KEYMAP` + * invariant test once the first sequence rows land): a sequence is exactly two + * segments; every segment is + * modifier-less (so it inherits the editable-target guard — a modified segment + * would fire while a text field is focused); and a sequence's first segment + * (the leader) must not also be bound as a complete single chord (the leader + * key is reserved, which keeps the runtime's fall-through simple). */ export interface KeymapEntry { readonly chord: string;