From 5f8bc8d4413defa7958dc0edc73f5895ec2e23f4 Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Wed, 22 Jul 2026 05:41:50 -0700 Subject: [PATCH 01/16] Add Emacs readline editing mode --- apps/web/src/AppRoot.test.tsx | 10 +- apps/web/src/AppRoot.tsx | 2 + .../src/components/EmacsReadlineBindings.tsx | 17 + .../components/settings/SettingsPanels.tsx | 51 ++ apps/web/src/emacsReadlineBindings.test.ts | 124 +++++ apps/web/src/emacsReadlineBindings.ts | 484 ++++++++++++++++++ packages/contracts/src/settings.test.ts | 15 + packages/contracts/src/settings.ts | 8 + 8 files changed, 707 insertions(+), 4 deletions(-) create mode 100644 apps/web/src/components/EmacsReadlineBindings.tsx create mode 100644 apps/web/src/emacsReadlineBindings.test.ts create mode 100644 apps/web/src/emacsReadlineBindings.ts diff --git a/apps/web/src/AppRoot.test.tsx b/apps/web/src/AppRoot.test.tsx index d6d7434769e..a23f978304c 100644 --- a/apps/web/src/AppRoot.test.tsx +++ b/apps/web/src/AppRoot.test.tsx @@ -3,6 +3,7 @@ import { RouterProvider } from "@tanstack/react-router"; import { describe, expect, it } from "vite-plus/test"; import { ElectronBrowserHost } from "./browser/ElectronBrowserHost"; +import { EmacsReadlineBindings } from "./components/EmacsReadlineBindings"; import { PreviewAutomationHosts } from "./components/preview/PreviewAutomationHosts"; import { AppAtomRegistryProvider } from "./rpc/atomRegistry"; import type { AppRouter } from "./router"; @@ -16,9 +17,10 @@ describe("AppRoot", () => { const children = Children.toArray( (root as ReactElement<{ readonly children: ReactNode }>).props.children, ); - expect(children).toHaveLength(3); - expect(isValidElement(children[0]) && children[0].type).toBe(RouterProvider); - expect(isValidElement(children[1]) && children[1].type).toBe(PreviewAutomationHosts); - expect(isValidElement(children[2]) && children[2].type).toBe(ElectronBrowserHost); + expect(children).toHaveLength(4); + expect(isValidElement(children[0]) && children[0].type).toBe(EmacsReadlineBindings); + expect(isValidElement(children[1]) && children[1].type).toBe(RouterProvider); + expect(isValidElement(children[2]) && children[2].type).toBe(PreviewAutomationHosts); + expect(isValidElement(children[3]) && children[3].type).toBe(ElectronBrowserHost); }); }); diff --git a/apps/web/src/AppRoot.tsx b/apps/web/src/AppRoot.tsx index b1fd21f84fa..b5a50256f1b 100644 --- a/apps/web/src/AppRoot.tsx +++ b/apps/web/src/AppRoot.tsx @@ -1,6 +1,7 @@ import { RouterProvider } from "@tanstack/react-router"; import { ElectronBrowserHost } from "./browser/ElectronBrowserHost"; +import { EmacsReadlineBindings } from "./components/EmacsReadlineBindings"; import { PreviewAutomationHosts } from "./components/preview/PreviewAutomationHosts"; import { AppAtomRegistryProvider } from "./rpc/atomRegistry"; import type { AppRouter } from "./router"; @@ -13,6 +14,7 @@ import type { AppRouter } from "./router"; export function AppRoot({ router }: { readonly router: AppRouter }) { return ( + diff --git a/apps/web/src/components/EmacsReadlineBindings.tsx b/apps/web/src/components/EmacsReadlineBindings.tsx new file mode 100644 index 00000000000..564f21c5f63 --- /dev/null +++ b/apps/web/src/components/EmacsReadlineBindings.tsx @@ -0,0 +1,17 @@ +import { useEffect } from "react"; + +import { createEmacsReadlineKeydownHandler } from "../emacsReadlineBindings"; +import { useClientSettings } from "../hooks/useSettings"; + +export function EmacsReadlineBindings() { + const enabled = useClientSettings((settings) => settings.keyboardEditingMode === "emacs"); + + useEffect(() => { + if (!enabled) return; + const handleKeyDown = createEmacsReadlineKeydownHandler(); + window.addEventListener("keydown", handleKeyDown, true); + return () => window.removeEventListener("keydown", handleKeyDown, true); + }, [enabled]); + + return null; +} diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 5b29c5494cc..5c1dd95630b 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -110,6 +110,11 @@ const TIMESTAMP_FORMAT_LABELS = { "24-hour": "24-hour", } as const; +const KEYBOARD_EDITING_MODE_LABELS = { + default: "Browser default", + emacs: "Emacs / readline", +} as const; + const DEFAULT_DRIVER_KIND = ProviderDriverKind.make("codex"); function withoutProviderInstanceKey( @@ -392,6 +397,9 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.timestampFormat !== DEFAULT_UNIFIED_SETTINGS.timestampFormat ? ["Time format"] : []), + ...(settings.keyboardEditingMode !== DEFAULT_UNIFIED_SETTINGS.keyboardEditingMode + ? ["Keyboard editing mode"] + : []), ...(settings.sidebarThreadPreviewCount !== DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount ? ["Visible threads"] : []), @@ -443,6 +451,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.automaticGitFetchInterval, settings.enableAssistantStreaming, settings.enableProviderUpdateChecks, + settings.keyboardEditingMode, settings.sidebarThreadPreviewCount, settings.timestampFormat, settings.wordWrap, @@ -463,6 +472,7 @@ export function useSettingsRestore(onRestored?: () => void) { setTheme("system"); updateSettings({ timestampFormat: DEFAULT_UNIFIED_SETTINGS.timestampFormat, + keyboardEditingMode: DEFAULT_UNIFIED_SETTINGS.keyboardEditingMode, wordWrap: DEFAULT_UNIFIED_SETTINGS.wordWrap, diffIgnoreWhitespace: DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace, sidebarThreadPreviewCount: DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount, @@ -559,6 +569,47 @@ export function GeneralSettingsPanel() { } /> + + updateSettings({ + keyboardEditingMode: DEFAULT_UNIFIED_SETTINGS.keyboardEditingMode, + }) + } + /> + ) : null + } + control={ + + } + /> + & Pick): KeyboardEvent { + const { key, ...overrides } = input; + return { + altKey: false, + code: key.length === 1 ? `Key${key.toUpperCase()}` : key, + ctrlKey: false, + defaultPrevented: false, + isComposing: false, + key, + metaKey: false, + shiftKey: false, + ...overrides, + } as KeyboardEvent; +} + +describe("resolveEmacsReadlineAction", () => { + it.each([ + ["a", "beginning-of-line"], + ["b", "backward-char"], + ["d", "delete-forward"], + ["e", "end-of-line"], + ["f", "forward-char"], + ["h", "delete-backward"], + ["k", "kill-line"], + ["n", "forward-line"], + ["p", "previous-line"], + ["t", "transpose-chars"], + ["u", "unix-line-discard"], + ["w", "backward-kill-word"], + ["y", "yank"], + ] as const)("maps Control-%s to %s", (key, action) => { + expect(resolveEmacsReadlineAction(keyboardEvent({ key, ctrlKey: true }))).toBe(action); + }); + + it("uses physical letter codes for Alt bindings on macOS keyboard events", () => { + expect( + resolveEmacsReadlineAction(keyboardEvent({ altKey: true, code: "KeyB", key: "∫" })), + ).toBe("backward-word"); + }); + + it("does not capture modified chords outside the mode", () => { + expect(resolveEmacsReadlineAction(keyboardEvent({ key: "a", metaKey: true }))).toBeNull(); + expect( + resolveEmacsReadlineAction(keyboardEvent({ key: "a", ctrlKey: true, shiftKey: true })), + ).toBeNull(); + }); +}); + +describe("applyEmacsReadlineActionToPlainText", () => { + const apply = ( + action: Parameters[0]["action"], + value: string, + selectionStart: number, + selectionEnd = selectionStart, + yankText = "", + ) => + applyEmacsReadlineActionToPlainText({ + action, + value, + selectionStart, + selectionEnd, + yankText, + }); + + it("moves by character, word, and logical line", () => { + expect(apply("backward-char", "hello", 3).selectionStart).toBe(2); + expect(apply("forward-char", "hello", 3).selectionStart).toBe(4); + expect(apply("backward-word", "one, two", 8).selectionStart).toBe(5); + expect(apply("forward-word", "one, two", 0).selectionStart).toBe(3); + expect(apply("beginning-of-line", "one\ntwo", 6).selectionStart).toBe(4); + expect(apply("end-of-line", "one\ntwo", 4).selectionStart).toBe(7); + }); + + it("preserves the logical column for Control-N and Control-P", () => { + const value = "abcd\nxy\n12345"; + expect(apply("forward-line", value, 3).selectionStart).toBe(7); + expect(apply("forward-line", value, 6).selectionStart).toBe(9); + expect(apply("previous-line", value, 11).selectionStart).toBe(7); + }); + + it("implements readline kill, discard, yank, and deletion behavior", () => { + expect(apply("kill-line", "one two\nthree", 4)).toMatchObject({ + value: "one \nthree", + killedText: "two", + selectionStart: 4, + }); + expect(apply("kill-line", "one\ntwo", 3)).toMatchObject({ + value: "onetwo", + killedText: "\n", + }); + expect(apply("unix-line-discard", "one two", 7)).toMatchObject({ + value: "", + killedText: "one two", + }); + expect(apply("backward-kill-word", "one two ", 9)).toMatchObject({ + value: "one ", + killedText: "two ", + }); + expect(apply("yank", "one ", 4, 4, "two")).toMatchObject({ + value: "one two", + selectionStart: 7, + }); + expect(apply("delete-backward", "abc", 2).value).toBe("ac"); + expect(apply("delete-forward", "abc", 1).value).toBe("ac"); + }); + + it("transposes the characters around point", () => { + expect(apply("transpose-chars", "abdc", 3)).toMatchObject({ + value: "abcd", + selectionStart: 4, + }); + expect(apply("transpose-chars", "acb", 3)).toMatchObject({ + value: "abc", + selectionStart: 3, + }); + }); +}); diff --git a/apps/web/src/emacsReadlineBindings.ts b/apps/web/src/emacsReadlineBindings.ts new file mode 100644 index 00000000000..de987a24a0a --- /dev/null +++ b/apps/web/src/emacsReadlineBindings.ts @@ -0,0 +1,484 @@ +export type EmacsReadlineAction = + | "backward-char" + | "backward-kill-word" + | "backward-word" + | "beginning-of-line" + | "delete-backward" + | "delete-forward" + | "end-of-line" + | "forward-char" + | "forward-kill-word" + | "forward-line" + | "forward-word" + | "kill-line" + | "previous-line" + | "transpose-chars" + | "unix-line-discard" + | "yank"; + +export interface PlainTextEdit { + readonly inputType?: string; + readonly killedText?: string; + readonly selectionEnd: number; + readonly selectionStart: number; + readonly value: string; +} + +const CANDIDATE_SURFACE_SELECTOR = [ + '[data-slot="command-dialog-popup"]', + '[data-slot="command-list"]', + '[data-slot="combobox-popup"]', + '[data-slot="autocomplete-popup"]', + '[data-slot="select-popup"]', + '[data-slot="menu-popup"]', + '[role="listbox"]', +].join(","); + +function keyLetter(event: KeyboardEvent): string { + if (event.code.startsWith("Key")) return event.code.slice(3).toLowerCase(); + return event.key.length === 1 ? event.key.toLowerCase() : ""; +} + +export function resolveEmacsReadlineAction(event: KeyboardEvent): EmacsReadlineAction | null { + if (event.defaultPrevented || event.isComposing || event.metaKey || event.shiftKey) return null; + + const letter = keyLetter(event); + if (event.ctrlKey && !event.altKey) { + switch (letter) { + case "a": + return "beginning-of-line"; + case "b": + return "backward-char"; + case "d": + return "delete-forward"; + case "e": + return "end-of-line"; + case "f": + return "forward-char"; + case "h": + return "delete-backward"; + case "k": + return "kill-line"; + case "n": + return "forward-line"; + case "p": + return "previous-line"; + case "t": + return "transpose-chars"; + case "u": + return "unix-line-discard"; + case "w": + return "backward-kill-word"; + case "y": + return "yank"; + default: + return null; + } + } + + if (event.altKey && !event.ctrlKey) { + switch (letter) { + case "b": + return "backward-word"; + case "d": + return "forward-kill-word"; + case "f": + return "forward-word"; + default: + return null; + } + } + + return null; +} + +function lineStart(value: string, position: number): number { + return value.lastIndexOf("\n", Math.max(0, position - 1)) + 1; +} + +function lineEnd(value: string, position: number): number { + const end = value.indexOf("\n", position); + return end === -1 ? value.length : end; +} + +function previousLinePosition(value: string, position: number): number { + const currentStart = lineStart(value, position); + if (currentStart === 0) return position; + const column = position - currentStart; + const previousEnd = currentStart - 1; + const previousStart = lineStart(value, previousEnd); + return Math.min(previousStart + column, previousEnd); +} + +function nextLinePosition(value: string, position: number): number { + const currentStart = lineStart(value, position); + const currentEnd = lineEnd(value, position); + if (currentEnd === value.length) return position; + const column = position - currentStart; + const nextStart = currentEnd + 1; + return Math.min(nextStart + column, lineEnd(value, nextStart)); +} + +function isWordCharacter(character: string): boolean { + return /[\p{L}\p{N}_]/u.test(character); +} + +function backwardWordPosition(value: string, position: number): number { + let next = position; + while (next > 0 && !isWordCharacter(value[next - 1] ?? "")) next -= 1; + while (next > 0 && isWordCharacter(value[next - 1] ?? "")) next -= 1; + return next; +} + +function forwardWordPosition(value: string, position: number): number { + let next = position; + while (next < value.length && !isWordCharacter(value[next] ?? "")) next += 1; + while (next < value.length && isWordCharacter(value[next] ?? "")) next += 1; + return next; +} + +function backwardKillWordPosition(value: string, position: number): number { + let next = position; + while (next > 0 && /\s/u.test(value[next - 1] ?? "")) next -= 1; + while (next > 0 && !/\s/u.test(value[next - 1] ?? "")) next -= 1; + return next; +} + +function movement(value: string, position: number): PlainTextEdit { + return { value, selectionStart: position, selectionEnd: position }; +} + +function replacement( + value: string, + start: number, + end: number, + insertedText: string, + inputType: string, + killedText?: string, +): PlainTextEdit { + const nextPosition = start + insertedText.length; + return { + value: value.slice(0, start) + insertedText + value.slice(end), + selectionStart: nextPosition, + selectionEnd: nextPosition, + inputType, + ...(killedText === undefined ? {} : { killedText }), + }; +} + +export function applyEmacsReadlineActionToPlainText(input: { + readonly action: EmacsReadlineAction; + readonly selectionEnd: number; + readonly selectionStart: number; + readonly value: string; + readonly yankText?: string; +}): PlainTextEdit { + const { action, value } = input; + const start = Math.min(input.selectionStart, input.selectionEnd); + const end = Math.max(input.selectionStart, input.selectionEnd); + + switch (action) { + case "beginning-of-line": + return movement(value, lineStart(value, start)); + case "end-of-line": + return movement(value, lineEnd(value, end)); + case "backward-char": + return movement(value, start === end ? Math.max(0, start - 1) : start); + case "forward-char": + return movement(value, start === end ? Math.min(value.length, end + 1) : end); + case "previous-line": + return movement(value, previousLinePosition(value, start)); + case "forward-line": + return movement(value, nextLinePosition(value, end)); + case "backward-word": + return movement(value, backwardWordPosition(value, start)); + case "forward-word": + return movement(value, forwardWordPosition(value, end)); + case "delete-backward": { + const deleteStart = start === end ? Math.max(0, start - 1) : start; + return replacement(value, deleteStart, end, "", "deleteContentBackward"); + } + case "delete-forward": { + const deleteEnd = start === end ? Math.min(value.length, end + 1) : end; + return replacement(value, start, deleteEnd, "", "deleteContentForward"); + } + case "kill-line": { + const killEnd = + start !== end + ? end + : lineEnd(value, start) === start && start < value.length + ? start + 1 + : lineEnd(value, start); + return replacement( + value, + start, + killEnd, + "", + "deleteContentForward", + value.slice(start, killEnd), + ); + } + case "unix-line-discard": { + const killStart = start !== end ? start : lineStart(value, start); + return replacement( + value, + killStart, + end, + "", + "deleteContentBackward", + value.slice(killStart, end), + ); + } + case "backward-kill-word": { + const killStart = start !== end ? start : backwardKillWordPosition(value, start); + return replacement( + value, + killStart, + end, + "", + "deleteWordBackward", + value.slice(killStart, end), + ); + } + case "forward-kill-word": { + const killEnd = start !== end ? end : forwardWordPosition(value, end); + return replacement( + value, + start, + killEnd, + "", + "deleteWordForward", + value.slice(start, killEnd), + ); + } + case "yank": + return replacement(value, start, end, input.yankText ?? "", "insertText"); + case "transpose-chars": { + if (start !== end || value.length < 2 || start === 0) return movement(value, end); + const left = start === value.length ? start - 2 : start - 1; + const right = left + 1; + const transposed = value.slice(0, left) + value[right] + value[left] + value.slice(right + 1); + const nextPosition = Math.min(value.length, start === value.length ? start : start + 1); + return { + value: transposed, + selectionStart: nextPosition, + selectionEnd: nextPosition, + inputType: "insertTranspose", + }; + } + } +} + +function isTerminalTarget(target: EventTarget | null): boolean { + return target instanceof Element && target.closest("[data-terminal-owner]") !== null; +} + +function isCandidateSelectionOpen(document: Document): boolean { + return Array.from(document.querySelectorAll(CANDIDATE_SURFACE_SELECTOR)).some( + (element) => !element.hidden && element.getAttribute("aria-hidden") !== "true", + ); +} + +function dispatchCandidateNavigation(event: KeyboardEvent, key: "ArrowDown" | "ArrowUp"): void { + event.preventDefault(); + event.stopImmediatePropagation(); + const document = (event.target as Node | null)?.ownerDocument ?? globalThis.document; + const target = + document.activeElement instanceof HTMLElement ? document.activeElement : event.target; + if (!(target instanceof EventTarget)) return; + const KeyboardEventConstructor = document.defaultView?.KeyboardEvent ?? KeyboardEvent; + target.dispatchEvent( + new KeyboardEventConstructor("keydown", { + bubbles: true, + cancelable: true, + code: key, + key, + repeat: event.repeat, + }), + ); +} + +type PlainTextControl = HTMLInputElement | HTMLTextAreaElement; + +function getPlainTextControl(target: EventTarget | null): PlainTextControl | null { + if (target instanceof HTMLTextAreaElement) return target; + if (!(target instanceof HTMLInputElement)) return null; + try { + return target.selectionStart === null ? null : target; + } catch { + return null; + } +} + +function getContentEditableHost(target: EventTarget | null): HTMLElement | null { + if (!(target instanceof Element)) return null; + return target.closest( + '[contenteditable="true"], [contenteditable="plaintext-only"]', + ); +} + +function dispatchInputEvent(control: PlainTextControl, inputType: string): void { + const InputEventConstructor = control.ownerDocument.defaultView?.InputEvent ?? InputEvent; + control.dispatchEvent( + new InputEventConstructor("input", { bubbles: true, inputType, data: null }), + ); +} + +function applyActionToPlainTextControl( + control: PlainTextControl, + action: EmacsReadlineAction, + yankText: string, +): { readonly handled: boolean; readonly killedText?: string } { + if (control.disabled || control.readOnly) return { handled: false }; + const selectionStart = control.selectionStart; + const selectionEnd = control.selectionEnd; + if (selectionStart === null || selectionEnd === null) return { handled: false }; + + const edit = applyEmacsReadlineActionToPlainText({ + action, + value: control.value, + selectionStart, + selectionEnd, + yankText, + }); + if (edit.inputType) { + control.setRangeText(edit.value, 0, control.value.length, "end"); + control.setSelectionRange(edit.selectionStart, edit.selectionEnd); + dispatchInputEvent(control, edit.inputType); + } else { + control.setSelectionRange(edit.selectionStart, edit.selectionEnd); + } + return { + handled: true, + ...(control.type === "password" || edit.killedText === undefined + ? {} + : { killedText: edit.killedText }), + }; +} + +type SelectionWithModify = Selection & { + modify(alter: "extend" | "move", direction: "backward" | "forward", granularity: string): void; +}; + +function selectionBelongsToHost(selection: Selection, host: HTMLElement): boolean { + const anchor = selection.anchorNode; + return anchor !== null && (anchor === host || host.contains(anchor)); +} + +function applyActionToContentEditable( + host: HTMLElement, + action: EmacsReadlineAction, + yankText: string, +): { readonly handled: boolean; readonly killedText?: string } { + const document = host.ownerDocument; + const selection = document.getSelection() as SelectionWithModify | null; + if ( + !selection || + !selectionBelongsToHost(selection, host) || + typeof selection.modify !== "function" + ) { + return { handled: false }; + } + + const move = (direction: "backward" | "forward", granularity: string) => { + selection.modify("move", direction, granularity); + }; + const extendAndDelete = (direction: "backward" | "forward", granularity: string) => { + if (selection.isCollapsed) selection.modify("extend", direction, granularity); + if (selection.isCollapsed && direction === "forward" && granularity === "lineboundary") { + selection.modify("extend", "forward", "character"); + } + const killedText = selection.toString(); + document.execCommand("delete"); + return killedText; + }; + + let killedText: string | undefined; + switch (action) { + case "beginning-of-line": + move("backward", "lineboundary"); + break; + case "end-of-line": + move("forward", "lineboundary"); + break; + case "backward-char": + move("backward", "character"); + break; + case "forward-char": + move("forward", "character"); + break; + case "previous-line": + move("backward", "line"); + break; + case "forward-line": + move("forward", "line"); + break; + case "backward-word": + move("backward", "word"); + break; + case "forward-word": + move("forward", "word"); + break; + case "delete-backward": + if (selection.isCollapsed) selection.modify("extend", "backward", "character"); + document.execCommand("delete"); + break; + case "delete-forward": + if (selection.isCollapsed) selection.modify("extend", "forward", "character"); + document.execCommand("delete"); + break; + case "kill-line": + killedText = extendAndDelete("forward", "lineboundary"); + break; + case "unix-line-discard": + killedText = extendAndDelete("backward", "lineboundary"); + break; + case "backward-kill-word": + killedText = extendAndDelete("backward", "word"); + break; + case "forward-kill-word": + killedText = extendAndDelete("forward", "word"); + break; + case "yank": + document.execCommand("insertText", false, yankText); + break; + case "transpose-chars": + // Native contenteditable does not expose a reliable atomic transpose + // operation. Consume the browser shortcut here; plain inputs and + // textareas implement the full readline behavior above. + break; + } + + return { handled: true, ...(killedText === undefined ? {} : { killedText }) }; +} + +export function createEmacsReadlineKeydownHandler(): (event: KeyboardEvent) => void { + let killRingText = ""; + + return (event) => { + const action = resolveEmacsReadlineAction(event); + if (!action || isTerminalTarget(event.target)) return; + + const document = (event.target as Node | null)?.ownerDocument ?? globalThis.document; + if ( + (action === "forward-line" || action === "previous-line") && + isCandidateSelectionOpen(document) + ) { + dispatchCandidateNavigation(event, action === "forward-line" ? "ArrowDown" : "ArrowUp"); + return; + } + + const control = getPlainTextControl(event.target); + const editableHost = control ? null : getContentEditableHost(event.target); + const result = control + ? applyActionToPlainTextControl(control, action, killRingText) + : editableHost + ? applyActionToContentEditable(editableHost, action, killRingText) + : { handled: false }; + + if (!result.handled) return; + event.preventDefault(); + event.stopImmediatePropagation(); + if (result.killedText) killRingText = result.killedText; + }; +} diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 001daf1b239..1625561b91d 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -33,6 +33,21 @@ describe("ClientSettings word wrap", () => { }); }); +describe("ClientSettings keyboard editing mode", () => { + it("defaults to native browser editing", () => { + expect(decodeClientSettings({}).keyboardEditingMode).toBe("default"); + }); + + it("accepts Emacs/readline editing", () => { + expect(decodeClientSettings({ keyboardEditingMode: "emacs" }).keyboardEditingMode).toBe( + "emacs", + ); + expect(decodeClientSettingsPatch({ keyboardEditingMode: "emacs" }).keyboardEditingMode).toBe( + "emacs", + ); + }); +}); + describe("ClientSettings sidebar v2", () => { it("defaults the beta off with a three-day auto-settle threshold", () => { const settings = decodeClientSettings({}); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 2f3c1aafe69..2f8c133bff7 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -13,6 +13,10 @@ export const TimestampFormat = Schema.Literals(["locale", "12-hour", "24-hour"]) export type TimestampFormat = typeof TimestampFormat.Type; export const DEFAULT_TIMESTAMP_FORMAT: TimestampFormat = "locale"; +export const KeyboardEditingMode = Schema.Literals(["default", "emacs"]); +export type KeyboardEditingMode = typeof KeyboardEditingMode.Type; +export const DEFAULT_KEYBOARD_EDITING_MODE: KeyboardEditingMode = "default"; + export const SidebarProjectSortOrder = Schema.Literals(["updated_at", "created_at", "manual"]); export type SidebarProjectSortOrder = typeof SidebarProjectSortOrder.Type; export const DEFAULT_SIDEBAR_PROJECT_SORT_ORDER: SidebarProjectSortOrder = "updated_at"; @@ -73,6 +77,9 @@ export const ClientSettingsSchema = Schema.Struct({ model: TrimmedNonEmptyString, }), ).pipe(Schema.withDecodingDefault(Effect.succeed([]))), + keyboardEditingMode: KeyboardEditingMode.pipe( + Schema.withDecodingDefault(Effect.succeed(DEFAULT_KEYBOARD_EDITING_MODE)), + ), providerModelPreferences: Schema.Record( ProviderInstanceId, Schema.Struct({ @@ -568,6 +575,7 @@ export const ClientSettingsPatch = Schema.Struct({ }), ), ), + keyboardEditingMode: Schema.optionalKey(KeyboardEditingMode), providerModelPreferences: Schema.optionalKey( Schema.Record( ProviderInstanceId, From 0ee5cebed1c3843eb0d26aab648d7c9f9b9c6136 Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Wed, 22 Jul 2026 06:11:10 -0700 Subject: [PATCH 02/16] Fix Emacs readline editor integration --- .../settings/DesktopClientSettings.test.ts | 1 + .../src/components/ComposerPromptEditor.tsx | 131 ++++++++++++++++++ .../src/components/EmacsReadlineBindings.tsx | 11 +- apps/web/src/emacsReadlineBindings.test.ts | 123 +++++++++++++++- apps/web/src/emacsReadlineBindings.ts | 82 +++++++++-- 5 files changed, 328 insertions(+), 20 deletions(-) diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 47800f3192d..9881bf71a40 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -19,6 +19,7 @@ const clientSettings: ClientSettings = { dismissedProviderUpdateNotificationKeys: [], diffIgnoreWhitespace: true, favorites: [], + keyboardEditingMode: "emacs", providerModelPreferences: {}, sidebarAutoSettleAfterDays: 3, sidebarProjectGroupingMode: "repository_path", diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index 169126788ae..5d8a7b950cf 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -54,6 +54,13 @@ import { useRef, } from "react"; +import { + applyEmacsReadlineActionToPlainText, + getEmacsReadlineKillRingText, + resolveEmacsReadlineAction, + storeEmacsReadlineKilledText, +} from "~/emacsReadlineBindings"; +import { useClientSettings } from "~/hooks/useSettings"; import { clampCollapsedComposerCursor, collapseExpandedComposerCursor, @@ -719,6 +726,28 @@ function $getComposerRootLength(): number { return children.reduce((sum, child) => sum + getComposerNodeTextLength(child), 0); } +const COMPOSER_INLINE_TOKEN_LOGICAL_CHARACTER = "\uFFFC"; + +function getComposerNodeLogicalText(node: LexicalNode): string { + if (isComposerInlineTokenNode(node)) { + return COMPOSER_INLINE_TOKEN_LOGICAL_CHARACTER; + } + if ($isTextNode(node)) { + return node.getTextContent(); + } + if ($isLineBreakNode(node)) { + return "\n"; + } + if ($isElementNode(node)) { + return node.getChildren().map(getComposerNodeLogicalText).join(""); + } + return ""; +} + +function $getComposerLogicalText(): string { + return $getRoot().getChildren().map(getComposerNodeLogicalText).join(""); +} + function $setSelectionAtComposerOffset(nextOffset: number): void { const root = $getRoot(); const composerLength = $getComposerRootLength(); @@ -758,6 +787,23 @@ function $setSelectionRangeAtComposerOffsets(startOffset: number, endOffset: num $setSelection(selection); } +function getSelectionRangeForComposerOffsets(selection: ReturnType): { + start: number; + end: number; +} | null { + if (!$isRangeSelection(selection)) { + return null; + } + const anchorNode = selection.anchor.getNode(); + const focusNode = selection.focus.getNode(); + const anchorOffset = getAbsoluteOffsetForPoint(anchorNode, selection.anchor.offset); + const focusOffset = getAbsoluteOffsetForPoint(focusNode, selection.focus.offset); + return { + start: Math.min(anchorOffset, focusOffset), + end: Math.max(anchorOffset, focusOffset), + }; +} + function getSelectionRangeForExpandedComposerOffsets(selection: ReturnType): { start: number; end: number; @@ -1075,6 +1121,89 @@ function ComposerHomeEndKeyPlugin() { return null; } +function ComposerEmacsReadlinePlugin() { + const [editor] = useLexicalComposerContext(); + const enabled = useClientSettings((settings) => settings.keyboardEditingMode === "emacs"); + const { onRemoveTerminalContext } = use(ComposerTerminalContextActionsContext); + + useEffect(() => { + if (!enabled) return; + + return editor.registerCommand( + KEY_DOWN_COMMAND, + (event) => { + const action = resolveEmacsReadlineAction(event); + if (!action) return false; + + const selection = $getSelection(); + const range = getSelectionRangeForComposerOffsets(selection); + if (!$isRangeSelection(selection) || !range) return false; + + const logicalText = $getComposerLogicalText(); + const edit = applyEmacsReadlineActionToPlainText({ + action, + value: logicalText, + selectionStart: range.start, + selectionEnd: range.end, + yankText: getEmacsReadlineKillRingText(), + }); + + if (edit.inputType) { + const replacementStart = edit.replacementStart; + const replacementEnd = edit.replacementEnd; + if (replacementStart === undefined || replacementEnd === undefined) return false; + + // Inline chips represent one logical cursor unit but serialize to + // longer prompt text. Transposing across one would corrupt that + // representation, so keep the chord as an intentional no-op there. + if ( + action === "transpose-chars" && + logicalText + .slice(replacementStart, replacementEnd) + .includes(COMPOSER_INLINE_TOKEN_LOGICAL_CHARACTER) + ) { + event.preventDefault(); + event.stopPropagation(); + return true; + } + + $setSelectionRangeAtComposerOffsets(replacementStart, replacementEnd); + const replacementSelection = $getSelection(); + if (!$isRangeSelection(replacementSelection)) return false; + + const removedTerminalContextIds = new Set(); + for (const node of replacementSelection.getNodes()) { + if (node instanceof ComposerTerminalContextNode) { + removedTerminalContextIds.add(node.__context.id); + } + } + if (edit.killedText !== undefined) { + storeEmacsReadlineKilledText(replacementSelection.getTextContent()); + } + + replacementSelection.insertRawText(edit.insertedText ?? ""); + for (const contextId of removedTerminalContextIds) { + onRemoveTerminalContext(contextId); + } + } else if ( + edit.selectionStart !== range.start || + edit.selectionEnd !== range.end || + range.start === range.end + ) { + $setSelectionRangeAtComposerOffsets(edit.selectionStart, edit.selectionEnd); + } + + event.preventDefault(); + event.stopPropagation(); + return true; + }, + COMMAND_PRIORITY_HIGH, + ); + }, [editor, enabled, onRemoveTerminalContext]); + + return null; +} + function ComposerInlineTokenSelectionNormalizePlugin() { const [editor] = useLexicalComposerContext(); @@ -1756,6 +1885,7 @@ function ComposerPromptEditorInner({ className, )} data-testid="composer-editor" + data-emacs-readline-managed="" aria-placeholder={placeholder} placeholder={} onPaste={onPaste} @@ -1773,6 +1903,7 @@ function ComposerPromptEditorInner({ + diff --git a/apps/web/src/components/EmacsReadlineBindings.tsx b/apps/web/src/components/EmacsReadlineBindings.tsx index 564f21c5f63..6fe64de16cc 100644 --- a/apps/web/src/components/EmacsReadlineBindings.tsx +++ b/apps/web/src/components/EmacsReadlineBindings.tsx @@ -1,17 +1,24 @@ +import { useAtomValue } from "@effect/atom-react"; import { useEffect } from "react"; import { createEmacsReadlineKeydownHandler } from "../emacsReadlineBindings"; import { useClientSettings } from "../hooks/useSettings"; +import { resolveShortcutCommand } from "../keybindings"; +import { primaryServerKeybindingsAtom } from "../state/server"; export function EmacsReadlineBindings() { const enabled = useClientSettings((settings) => settings.keyboardEditingMode === "emacs"); + const keybindings = useAtomValue(primaryServerKeybindingsAtom); useEffect(() => { if (!enabled) return; - const handleKeyDown = createEmacsReadlineKeydownHandler(); + const handleKeyDown = createEmacsReadlineKeydownHandler({ + shouldYieldToApplicationShortcut: (event) => + resolveShortcutCommand(event, keybindings) !== null, + }); window.addEventListener("keydown", handleKeyDown, true); return () => window.removeEventListener("keydown", handleKeyDown, true); - }, [enabled]); + }, [enabled, keybindings]); return null; } diff --git a/apps/web/src/emacsReadlineBindings.test.ts b/apps/web/src/emacsReadlineBindings.test.ts index c46e34db2be..8b692a7c53a 100644 --- a/apps/web/src/emacsReadlineBindings.test.ts +++ b/apps/web/src/emacsReadlineBindings.test.ts @@ -1,7 +1,9 @@ -import { describe, expect, it } from "vite-plus/test"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import { + applyEmacsReadlineActionToContentEditable, applyEmacsReadlineActionToPlainText, + createEmacsReadlineKeydownHandler, resolveEmacsReadlineAction, } from "./emacsReadlineBindings"; @@ -21,6 +23,10 @@ function keyboardEvent(input: Partial & Pick { + afterEach(() => { + vi.unstubAllGlobals(); + }); + it.each([ ["a", "beginning-of-line"], ["b", "backward-char"], @@ -39,10 +45,16 @@ describe("resolveEmacsReadlineAction", () => { expect(resolveEmacsReadlineAction(keyboardEvent({ key, ctrlKey: true }))).toBe(action); }); - it("uses physical letter codes for Alt bindings on macOS keyboard events", () => { + it("uses the produced letter rather than the physical key position", () => { expect( - resolveEmacsReadlineAction(keyboardEvent({ altKey: true, code: "KeyB", key: "∫" })), + resolveEmacsReadlineAction(keyboardEvent({ ctrlKey: true, code: "KeyQ", key: "a" })), + ).toBe("beginning-of-line"); + expect( + resolveEmacsReadlineAction(keyboardEvent({ altKey: true, code: "KeyN", key: "b" })), ).toBe("backward-word"); + expect( + resolveEmacsReadlineAction(keyboardEvent({ altKey: true, code: "KeyB", key: "∫" })), + ).toBeNull(); }); it("does not capture modified chords outside the mode", () => { @@ -51,6 +63,30 @@ describe("resolveEmacsReadlineAction", () => { resolveEmacsReadlineAction(keyboardEvent({ key: "a", ctrlKey: true, shiftKey: true })), ).toBeNull(); }); + + it("yields before editing for app shortcuts and keybinding capture fields", () => { + class TestElement { + closest(selector: string): TestElement | null { + return selector === "[data-keybinding-capture]" ? this : null; + } + } + vi.stubGlobal("Element", TestElement); + + const yieldToAppShortcut = vi.fn(() => true); + createEmacsReadlineKeydownHandler({ + shouldYieldToApplicationShortcut: yieldToAppShortcut, + })(keyboardEvent({ key: "b", ctrlKey: true })); + expect(yieldToAppShortcut).toHaveBeenCalledOnce(); + + const captureYield = vi.fn(() => false); + createEmacsReadlineKeydownHandler({ + shouldYieldToApplicationShortcut: captureYield, + })({ + ...keyboardEvent({ key: "a", ctrlKey: true }), + target: new TestElement(), + } as unknown as KeyboardEvent); + expect(captureYield).not.toHaveBeenCalled(); + }); }); describe("applyEmacsReadlineActionToPlainText", () => { @@ -111,6 +147,27 @@ describe("applyEmacsReadlineActionToPlainText", () => { expect(apply("delete-forward", "abc", 1).value).toBe("ac"); }); + it("does not replace a selection when the kill ring is empty", () => { + expect(apply("yank", "selected text", 0, 8)).toEqual({ + value: "selected text", + selectionStart: 0, + selectionEnd: 8, + }); + }); + + it("represents boundary deletions as empty replacement ranges", () => { + expect(apply("delete-forward", "abc", 3)).toMatchObject({ + value: "abc", + replacementStart: 3, + replacementEnd: 3, + }); + expect(apply("unix-line-discard", "one\ntwo", 4)).toMatchObject({ + value: "one\ntwo", + replacementStart: 4, + replacementEnd: 4, + }); + }); + it("transposes the characters around point", () => { expect(apply("transpose-chars", "abdc", 3)).toMatchObject({ value: "abcd", @@ -122,3 +179,63 @@ describe("applyEmacsReadlineActionToPlainText", () => { }); }); }); + +describe("applyEmacsReadlineActionToContentEditable", () => { + function harness(input?: { readonly focusOutside?: boolean; readonly isCollapsed?: boolean }) { + const inside = {} as Node; + const outside = {} as Node; + const modify = vi.fn(); + const selection = { + anchorNode: inside, + focusNode: input?.focusOutside ? outside : inside, + isCollapsed: input?.isCollapsed ?? true, + modify, + toString: () => "selected", + } as unknown as Selection & { + modify( + alter: "extend" | "move", + direction: "backward" | "forward", + granularity: string, + ): void; + }; + const execCommand = vi.fn(() => true); + const document = { + execCommand, + getSelection: () => selection, + } as unknown as Document; + const host = { + contains: (node: Node) => node === inside, + ownerDocument: document, + } as unknown as HTMLElement; + return { execCommand, host, modify }; + } + + it("rejects selections whose focus endpoint escapes the host", () => { + const { execCommand, host, modify } = harness({ focusOutside: true }); + expect(applyEmacsReadlineActionToContentEditable(host, "delete-forward", "")).toEqual({ + handled: false, + }); + expect(modify).not.toHaveBeenCalled(); + expect(execCommand).not.toHaveBeenCalled(); + }); + + it.each(["delete-forward", "kill-line", "unix-line-discard", "forward-kill-word"] as const)( + "does not turn a collapsed boundary %s into a backward deletion", + (action) => { + const { execCommand, host } = harness({ isCollapsed: true }); + expect(applyEmacsReadlineActionToContentEditable(host, action, "").handled).toBe(true); + expect(execCommand).not.toHaveBeenCalled(); + }, + ); + + it("preserves the selection for an empty yank and yields unsupported transpose", () => { + const { execCommand, host } = harness({ isCollapsed: false }); + expect(applyEmacsReadlineActionToContentEditable(host, "yank", "")).toEqual({ + handled: true, + }); + expect(applyEmacsReadlineActionToContentEditable(host, "transpose-chars", "")).toEqual({ + handled: false, + }); + expect(execCommand).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/emacsReadlineBindings.ts b/apps/web/src/emacsReadlineBindings.ts index de987a24a0a..39f0913126b 100644 --- a/apps/web/src/emacsReadlineBindings.ts +++ b/apps/web/src/emacsReadlineBindings.ts @@ -18,7 +18,10 @@ export type EmacsReadlineAction = export interface PlainTextEdit { readonly inputType?: string; + readonly insertedText?: string; readonly killedText?: string; + readonly replacementEnd?: number; + readonly replacementStart?: number; readonly selectionEnd: number; readonly selectionStart: number; readonly value: string; @@ -35,7 +38,6 @@ const CANDIDATE_SURFACE_SELECTOR = [ ].join(","); function keyLetter(event: KeyboardEvent): string { - if (event.code.startsWith("Key")) return event.code.slice(3).toLowerCase(); return event.key.length === 1 ? event.key.toLowerCase() : ""; } @@ -162,6 +164,9 @@ function replacement( selectionStart: nextPosition, selectionEnd: nextPosition, inputType, + insertedText, + replacementStart: start, + replacementEnd: end, ...(killedText === undefined ? {} : { killedText }), }; } @@ -252,9 +257,22 @@ export function applyEmacsReadlineActionToPlainText(input: { ); } case "yank": + if (!input.yankText) { + return { + value, + selectionStart: input.selectionStart, + selectionEnd: input.selectionEnd, + }; + } return replacement(value, start, end, input.yankText ?? "", "insertText"); case "transpose-chars": { - if (start !== end || value.length < 2 || start === 0) return movement(value, end); + if (start !== end || value.length < 2 || start === 0) { + return { + value, + selectionStart: input.selectionStart, + selectionEnd: input.selectionEnd, + }; + } const left = start === value.length ? start - 2 : start - 1; const right = left + 1; const transposed = value.slice(0, left) + value[right] + value[left] + value.slice(right + 1); @@ -264,6 +282,9 @@ export function applyEmacsReadlineActionToPlainText(input: { selectionStart: nextPosition, selectionEnd: nextPosition, inputType: "insertTranspose", + insertedText: `${value[right]}${value[left]}`, + replacementStart: left, + replacementEnd: right + 1, }; } } @@ -317,6 +338,10 @@ function getContentEditableHost(target: EventTarget | null): HTMLElement | null ); } +function isKeybindingCaptureTarget(target: EventTarget | null): boolean { + return target instanceof Element && target.closest("[data-keybinding-capture]") !== null; +} + function dispatchInputEvent(control: PlainTextControl, inputType: string): void { const InputEventConstructor = control.ownerDocument.defaultView?.InputEvent ?? InputEvent; control.dispatchEvent( @@ -362,10 +387,16 @@ type SelectionWithModify = Selection & { function selectionBelongsToHost(selection: Selection, host: HTMLElement): boolean { const anchor = selection.anchorNode; - return anchor !== null && (anchor === host || host.contains(anchor)); + const focus = selection.focusNode; + return ( + anchor !== null && + focus !== null && + (anchor === host || host.contains(anchor)) && + (focus === host || host.contains(focus)) + ); } -function applyActionToContentEditable( +export function applyEmacsReadlineActionToContentEditable( host: HTMLElement, action: EmacsReadlineAction, yankText: string, @@ -388,6 +419,7 @@ function applyActionToContentEditable( if (selection.isCollapsed && direction === "forward" && granularity === "lineboundary") { selection.modify("extend", "forward", "character"); } + if (selection.isCollapsed) return ""; const killedText = selection.toString(); document.execCommand("delete"); return killedText; @@ -421,11 +453,11 @@ function applyActionToContentEditable( break; case "delete-backward": if (selection.isCollapsed) selection.modify("extend", "backward", "character"); - document.execCommand("delete"); + if (!selection.isCollapsed) document.execCommand("delete"); break; case "delete-forward": if (selection.isCollapsed) selection.modify("extend", "forward", "character"); - document.execCommand("delete"); + if (!selection.isCollapsed) document.execCommand("delete"); break; case "kill-line": killedText = extendAndDelete("forward", "lineboundary"); @@ -440,24 +472,41 @@ function applyActionToContentEditable( killedText = extendAndDelete("forward", "word"); break; case "yank": + if (!yankText) break; document.execCommand("insertText", false, yankText); break; case "transpose-chars": - // Native contenteditable does not expose a reliable atomic transpose - // operation. Consume the browser shortcut here; plain inputs and - // textareas implement the full readline behavior above. - break; + // Rich editors need to own this operation so their internal document + // model and undo history stay synchronized. + return { handled: false }; } return { handled: true, ...(killedText === undefined ? {} : { killedText }) }; } -export function createEmacsReadlineKeydownHandler(): (event: KeyboardEvent) => void { - let killRingText = ""; +let killRingText = ""; + +export function getEmacsReadlineKillRingText(): string { + return killRingText; +} + +export function storeEmacsReadlineKilledText(text: string): void { + if (text) killRingText = text; +} +export function createEmacsReadlineKeydownHandler(options?: { + readonly shouldYieldToApplicationShortcut?: (event: KeyboardEvent) => boolean; +}): (event: KeyboardEvent) => void { return (event) => { const action = resolveEmacsReadlineAction(event); - if (!action || isTerminalTarget(event.target)) return; + if ( + !action || + isTerminalTarget(event.target) || + isKeybindingCaptureTarget(event.target) || + options?.shouldYieldToApplicationShortcut?.(event) + ) { + return; + } const document = (event.target as Node | null)?.ownerDocument ?? globalThis.document; if ( @@ -470,15 +519,18 @@ export function createEmacsReadlineKeydownHandler(): (event: KeyboardEvent) => v const control = getPlainTextControl(event.target); const editableHost = control ? null : getContentEditableHost(event.target); + // Managed editors such as Lexical must update their own selection and + // document state. Their editor plugin handles the original keydown. + if (editableHost?.hasAttribute("data-emacs-readline-managed")) return; const result = control ? applyActionToPlainTextControl(control, action, killRingText) : editableHost - ? applyActionToContentEditable(editableHost, action, killRingText) + ? applyEmacsReadlineActionToContentEditable(editableHost, action, killRingText) : { handled: false }; if (!result.handled) return; event.preventDefault(); event.stopImmediatePropagation(); - if (result.killedText) killRingText = result.killedText; + if (result.killedText) storeEmacsReadlineKilledText(result.killedText); }; } From 95ee45411a3f71c157f1dd02e1261ed50912cec8 Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Wed, 22 Jul 2026 06:12:52 -0700 Subject: [PATCH 03/16] Preserve Lexical state on boundary no-ops --- apps/web/src/components/ComposerPromptEditor.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index 5d8a7b950cf..2dcddb40a8a 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -1153,6 +1153,12 @@ function ComposerEmacsReadlinePlugin() { const replacementEnd = edit.replacementEnd; if (replacementStart === undefined || replacementEnd === undefined) return false; + if (replacementStart === replacementEnd && !edit.insertedText) { + event.preventDefault(); + event.stopPropagation(); + return true; + } + // Inline chips represent one logical cursor unit but serialize to // longer prompt text. Transposing across one would corrupt that // representation, so keep the chord as an intentional no-op there. From 3c8f52c7b0232ff739d6fd5dac9874a8c9096738 Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Wed, 22 Jul 2026 06:17:48 -0700 Subject: [PATCH 04/16] Harden readline movement and shortcut precedence --- .../src/components/ComposerPromptEditor.tsx | 7 +- apps/web/src/emacsReadlineBindings.test.ts | 34 ++++++- apps/web/src/emacsReadlineBindings.ts | 97 +++++++++++++++---- 3 files changed, 118 insertions(+), 20 deletions(-) diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index 2dcddb40a8a..6ae2ce06ea1 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -1,3 +1,4 @@ +import { useAtomValue } from "@effect/atom-react"; import { LexicalComposer, type InitialConfigType } from "@lexical/react/LexicalComposer"; import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; import { ContentEditable } from "@lexical/react/LexicalContentEditable"; @@ -61,6 +62,7 @@ import { storeEmacsReadlineKilledText, } from "~/emacsReadlineBindings"; import { useClientSettings } from "~/hooks/useSettings"; +import { resolveShortcutCommand } from "~/keybindings"; import { clampCollapsedComposerCursor, collapseExpandedComposerCursor, @@ -77,6 +79,7 @@ import { } from "~/lib/terminalContext"; import { cn, isMacPlatform } from "~/lib/utils"; import { basenameOfPath } from "~/pierre-icons"; +import { primaryServerKeybindingsAtom } from "~/state/server"; import { COMPOSER_INLINE_CHIP_ICON_CLASS_NAME, COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME, @@ -1124,6 +1127,7 @@ function ComposerHomeEndKeyPlugin() { function ComposerEmacsReadlinePlugin() { const [editor] = useLexicalComposerContext(); const enabled = useClientSettings((settings) => settings.keyboardEditingMode === "emacs"); + const keybindings = useAtomValue(primaryServerKeybindingsAtom); const { onRemoveTerminalContext } = use(ComposerTerminalContextActionsContext); useEffect(() => { @@ -1132,6 +1136,7 @@ function ComposerEmacsReadlinePlugin() { return editor.registerCommand( KEY_DOWN_COMMAND, (event) => { + if (resolveShortcutCommand(event, keybindings) !== null) return false; const action = resolveEmacsReadlineAction(event); if (!action) return false; @@ -1205,7 +1210,7 @@ function ComposerEmacsReadlinePlugin() { }, COMMAND_PRIORITY_HIGH, ); - }, [editor, enabled, onRemoveTerminalContext]); + }, [editor, enabled, keybindings, onRemoveTerminalContext]); return null; } diff --git a/apps/web/src/emacsReadlineBindings.test.ts b/apps/web/src/emacsReadlineBindings.test.ts index 8b692a7c53a..d373b8a7c8d 100644 --- a/apps/web/src/emacsReadlineBindings.test.ts +++ b/apps/web/src/emacsReadlineBindings.test.ts @@ -178,6 +178,18 @@ describe("applyEmacsReadlineActionToPlainText", () => { selectionStart: 3, }); }); + + it("moves, deletes, and transposes whole Unicode code points", () => { + expect(apply("forward-char", "😀x", 0).selectionStart).toBe(2); + expect(apply("backward-char", "😀x", 2).selectionStart).toBe(0); + expect(apply("delete-forward", "😀x", 0).value).toBe("x"); + expect(apply("delete-backward", "😀x", 2).value).toBe("x"); + expect(apply("transpose-chars", "😀a", 3)).toMatchObject({ + value: "a😀", + selectionStart: 3, + }); + expect(apply("forward-word", "𝒜x", 0).selectionStart).toBe(3); + }); }); describe("applyEmacsReadlineActionToContentEditable", () => { @@ -187,8 +199,18 @@ describe("applyEmacsReadlineActionToContentEditable", () => { const modify = vi.fn(); const selection = { anchorNode: inside, + anchorOffset: 1, focusNode: input?.focusOutside ? outside : inside, + focusOffset: 4, isCollapsed: input?.isCollapsed ?? true, + rangeCount: 1, + collapse: vi.fn(), + getRangeAt: () => ({ + startContainer: inside, + startOffset: 1, + endContainer: inside, + endOffset: 4, + }), modify, toString: () => "selected", } as unknown as Selection & { @@ -207,7 +229,7 @@ describe("applyEmacsReadlineActionToContentEditable", () => { contains: (node: Node) => node === inside, ownerDocument: document, } as unknown as HTMLElement; - return { execCommand, host, modify }; + return { collapse: selection.collapse, execCommand, host, modify }; } it("rejects selections whose focus endpoint escapes the host", () => { @@ -238,4 +260,14 @@ describe("applyEmacsReadlineActionToContentEditable", () => { }); expect(execCommand).not.toHaveBeenCalled(); }); + + it.each([ + ["backward-char", "backward", 1], + ["forward-char", "forward", 4], + ] as const)("collapses a selection to the %s movement boundary", (action, direction, offset) => { + const { collapse, host, modify } = harness({ isCollapsed: false }); + expect(applyEmacsReadlineActionToContentEditable(host, action, "").handled).toBe(true); + expect(collapse).toHaveBeenCalledWith(expect.anything(), offset); + expect(modify).toHaveBeenCalledWith("move", direction, "character"); + }); }); diff --git a/apps/web/src/emacsReadlineBindings.ts b/apps/web/src/emacsReadlineBindings.ts index 39f0913126b..4888ff70582 100644 --- a/apps/web/src/emacsReadlineBindings.ts +++ b/apps/web/src/emacsReadlineBindings.ts @@ -125,24 +125,66 @@ function isWordCharacter(character: string): boolean { return /[\p{L}\p{N}_]/u.test(character); } +function previousCodePointPosition(value: string, position: number): number { + if (position <= 0) return 0; + const previous = position - 1; + const previousUnit = value.charCodeAt(previous); + if (previousUnit >= 0xdc00 && previousUnit <= 0xdfff && previous > 0) { + const leadingUnit = value.charCodeAt(previous - 1); + if (leadingUnit >= 0xd800 && leadingUnit <= 0xdbff) return previous - 1; + } + return previous; +} + +function nextCodePointPosition(value: string, position: number): number { + if (position >= value.length) return value.length; + const currentUnit = value.charCodeAt(position); + if (currentUnit >= 0xd800 && currentUnit <= 0xdbff && position + 1 < value.length) { + const trailingUnit = value.charCodeAt(position + 1); + if (trailingUnit >= 0xdc00 && trailingUnit <= 0xdfff) return position + 2; + } + return position + 1; +} + +function codePointBefore(value: string, position: number): string { + const previous = previousCodePointPosition(value, position); + return value.slice(previous, position); +} + +function codePointAt(value: string, position: number): string { + return value.slice(position, nextCodePointPosition(value, position)); +} + function backwardWordPosition(value: string, position: number): number { let next = position; - while (next > 0 && !isWordCharacter(value[next - 1] ?? "")) next -= 1; - while (next > 0 && isWordCharacter(value[next - 1] ?? "")) next -= 1; + while (next > 0 && !isWordCharacter(codePointBefore(value, next))) { + next = previousCodePointPosition(value, next); + } + while (next > 0 && isWordCharacter(codePointBefore(value, next))) { + next = previousCodePointPosition(value, next); + } return next; } function forwardWordPosition(value: string, position: number): number { let next = position; - while (next < value.length && !isWordCharacter(value[next] ?? "")) next += 1; - while (next < value.length && isWordCharacter(value[next] ?? "")) next += 1; + while (next < value.length && !isWordCharacter(codePointAt(value, next))) { + next = nextCodePointPosition(value, next); + } + while (next < value.length && isWordCharacter(codePointAt(value, next))) { + next = nextCodePointPosition(value, next); + } return next; } function backwardKillWordPosition(value: string, position: number): number { let next = position; - while (next > 0 && /\s/u.test(value[next - 1] ?? "")) next -= 1; - while (next > 0 && !/\s/u.test(value[next - 1] ?? "")) next -= 1; + while (next > 0 && /\s/u.test(codePointBefore(value, next))) { + next = previousCodePointPosition(value, next); + } + while (next > 0 && !/\s/u.test(codePointBefore(value, next))) { + next = previousCodePointPosition(value, next); + } return next; } @@ -188,9 +230,9 @@ export function applyEmacsReadlineActionToPlainText(input: { case "end-of-line": return movement(value, lineEnd(value, end)); case "backward-char": - return movement(value, start === end ? Math.max(0, start - 1) : start); + return movement(value, start === end ? previousCodePointPosition(value, start) : start); case "forward-char": - return movement(value, start === end ? Math.min(value.length, end + 1) : end); + return movement(value, start === end ? nextCodePointPosition(value, end) : end); case "previous-line": return movement(value, previousLinePosition(value, start)); case "forward-line": @@ -200,11 +242,11 @@ export function applyEmacsReadlineActionToPlainText(input: { case "forward-word": return movement(value, forwardWordPosition(value, end)); case "delete-backward": { - const deleteStart = start === end ? Math.max(0, start - 1) : start; + const deleteStart = start === end ? previousCodePointPosition(value, start) : start; return replacement(value, deleteStart, end, "", "deleteContentBackward"); } case "delete-forward": { - const deleteEnd = start === end ? Math.min(value.length, end + 1) : end; + const deleteEnd = start === end ? nextCodePointPosition(value, end) : end; return replacement(value, start, deleteEnd, "", "deleteContentForward"); } case "kill-line": { @@ -266,25 +308,36 @@ export function applyEmacsReadlineActionToPlainText(input: { } return replacement(value, start, end, input.yankText ?? "", "insertText"); case "transpose-chars": { - if (start !== end || value.length < 2 || start === 0) { + if (start !== end || start === 0) { + return { + value, + selectionStart: input.selectionStart, + selectionEnd: input.selectionEnd, + }; + } + const rightStart = start === value.length ? previousCodePointPosition(value, start) : start; + const leftStart = previousCodePointPosition(value, rightStart); + if (leftStart === rightStart || rightStart >= value.length) { return { value, selectionStart: input.selectionStart, selectionEnd: input.selectionEnd, }; } - const left = start === value.length ? start - 2 : start - 1; - const right = left + 1; - const transposed = value.slice(0, left) + value[right] + value[left] + value.slice(right + 1); - const nextPosition = Math.min(value.length, start === value.length ? start : start + 1); + const rightEnd = nextCodePointPosition(value, rightStart); + const leftCharacter = value.slice(leftStart, rightStart); + const rightCharacter = value.slice(rightStart, rightEnd); + const transposed = + value.slice(0, leftStart) + rightCharacter + leftCharacter + value.slice(rightEnd); + const nextPosition = start === value.length ? start : rightEnd; return { value: transposed, selectionStart: nextPosition, selectionEnd: nextPosition, inputType: "insertTranspose", - insertedText: `${value[right]}${value[left]}`, - replacementStart: left, - replacementEnd: right + 1, + insertedText: `${rightCharacter}${leftCharacter}`, + replacementStart: leftStart, + replacementEnd: rightEnd, }; } } @@ -406,12 +459,20 @@ export function applyEmacsReadlineActionToContentEditable( if ( !selection || !selectionBelongsToHost(selection, host) || + selection.rangeCount === 0 || typeof selection.modify !== "function" ) { return { handled: false }; } const move = (direction: "backward" | "forward", granularity: string) => { + if (!selection.isCollapsed) { + const range = selection.getRangeAt(0); + selection.collapse( + direction === "backward" ? range.startContainer : range.endContainer, + direction === "backward" ? range.startOffset : range.endOffset, + ); + } selection.modify("move", direction, granularity); }; const extendAndDelete = (direction: "backward" | "forward", granularity: string) => { From c22028bb2132e7b0a38f9e7f813cbea53d8a2328 Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Wed, 22 Jul 2026 06:20:59 -0700 Subject: [PATCH 05/16] Preserve candidate and transpose shortcuts --- apps/web/src/emacsReadlineBindings.test.ts | 119 ++++++++++++++++++++- apps/web/src/emacsReadlineBindings.ts | 65 +++++++++-- 2 files changed, 173 insertions(+), 11 deletions(-) diff --git a/apps/web/src/emacsReadlineBindings.test.ts b/apps/web/src/emacsReadlineBindings.test.ts index d373b8a7c8d..46e9d673759 100644 --- a/apps/web/src/emacsReadlineBindings.test.ts +++ b/apps/web/src/emacsReadlineBindings.test.ts @@ -87,6 +87,51 @@ describe("resolveEmacsReadlineAction", () => { } as unknown as KeyboardEvent); expect(captureYield).not.toHaveBeenCalled(); }); + + it("gives an open candidate list precedence over an application shortcut", () => { + class TestElement extends EventTarget { + ownerDocument!: Document; + + closest(): null { + return null; + } + } + class TestKeyboardEvent extends Event { + readonly key: string; + + constructor(type: string, init: KeyboardEventInit) { + super(type, init); + this.key = init.key ?? ""; + } + } + vi.stubGlobal("Element", TestElement); + vi.stubGlobal("HTMLElement", TestElement); + + const target = new TestElement(); + const document = { + activeElement: target, + defaultView: { KeyboardEvent: TestKeyboardEvent }, + querySelectorAll: () => [{ getAttribute: () => null, hidden: false }], + } as unknown as Document; + target.ownerDocument = document; + const dispatchedKeys: string[] = []; + target.addEventListener("keydown", (event) => { + dispatchedKeys.push((event as unknown as KeyboardEvent).key); + }); + const yieldToAppShortcut = vi.fn(() => true); + createEmacsReadlineKeydownHandler({ + shouldYieldToApplicationShortcut: yieldToAppShortcut, + })({ + ...keyboardEvent({ key: "n", ctrlKey: true }), + preventDefault: vi.fn(), + repeat: false, + stopImmediatePropagation: vi.fn(), + target, + } as unknown as KeyboardEvent); + + expect(dispatchedKeys).toEqual(["ArrowDown"]); + expect(yieldToAppShortcut).not.toHaveBeenCalled(); + }); }); describe("applyEmacsReadlineActionToPlainText", () => { @@ -250,17 +295,87 @@ describe("applyEmacsReadlineActionToContentEditable", () => { }, ); - it("preserves the selection for an empty yank and yields unsupported transpose", () => { + it("preserves selections for an empty yank and transpose", () => { const { execCommand, host } = harness({ isCollapsed: false }); expect(applyEmacsReadlineActionToContentEditable(host, "yank", "")).toEqual({ handled: true, }); expect(applyEmacsReadlineActionToContentEditable(host, "transpose-chars", "")).toEqual({ - handled: false, + handled: true, }); expect(execCommand).not.toHaveBeenCalled(); }); + it("transposes the characters around a collapsed caret", () => { + const textNode = {} as Node; + class TestRange { + startContainer = textNode; + endContainer = textNode; + + constructor( + public startOffset: number, + public endOffset: number, + ) {} + + cloneRange(): TestRange { + return new TestRange(this.startOffset, this.endOffset); + } + + collapse(toStart: boolean): void { + if (toStart) this.endOffset = this.startOffset; + else this.startOffset = this.endOffset; + } + + setStart(_node: Node, offset: number): void { + this.startOffset = offset; + } + + setEnd(_node: Node, offset: number): void { + this.endOffset = offset; + } + } + let currentRange = new TestRange(1, 1); + const selection = { + anchorNode: textNode, + focusNode: textNode, + get isCollapsed() { + return currentRange.startOffset === currentRange.endOffset; + }, + rangeCount: 1, + addRange: (range: Range) => { + currentRange = range as unknown as TestRange; + }, + getRangeAt: () => currentRange, + modify: (_alter: string, direction: "backward" | "forward") => { + if (direction === "backward") currentRange.startOffset -= 1; + else currentRange.endOffset += 1; + }, + removeAllRanges: () => undefined, + toString: () => "ab".slice(currentRange.startOffset, currentRange.endOffset), + } as unknown as Selection & { + modify( + alter: "extend" | "move", + direction: "backward" | "forward", + granularity: string, + ): void; + }; + const execCommand = vi.fn(() => true); + const document = { + createRange: () => new TestRange(0, 0), + execCommand, + getSelection: () => selection, + } as unknown as Document; + const host = { + contains: (node: Node) => node === textNode, + ownerDocument: document, + } as unknown as HTMLElement; + + expect(applyEmacsReadlineActionToContentEditable(host, "transpose-chars", "")).toEqual({ + handled: true, + }); + expect(execCommand).toHaveBeenCalledWith("insertText", false, "ba"); + }); + it.each([ ["backward-char", "backward", 1], ["forward-char", "forward", 4], diff --git a/apps/web/src/emacsReadlineBindings.ts b/apps/web/src/emacsReadlineBindings.ts index 4888ff70582..6ea13beb8f5 100644 --- a/apps/web/src/emacsReadlineBindings.ts +++ b/apps/web/src/emacsReadlineBindings.ts @@ -485,6 +485,57 @@ export function applyEmacsReadlineActionToContentEditable( document.execCommand("delete"); return killedText; }; + const restoreRange = (range: Range) => { + selection.removeAllRanges(); + selection.addRange(range); + }; + const adjacentCharacter = ( + origin: Range, + direction: "backward" | "forward", + ): { readonly range: Range; readonly text: string } | null => { + restoreRange(origin.cloneRange()); + selection.modify("extend", direction, "character"); + if (selection.isCollapsed || selection.rangeCount === 0) return null; + const text = selection.toString(); + return text ? { range: selection.getRangeAt(0).cloneRange(), text } : null; + }; + const transposeCharacters = (): void => { + if (!selection.isCollapsed) return; + const original = selection.getRangeAt(0).cloneRange(); + const previous = adjacentCharacter(original, "backward"); + if (!previous) { + restoreRange(original); + return; + } + + const next = adjacentCharacter(original, "forward"); + let before: { readonly range: Range; readonly text: string }; + let after: { readonly range: Range; readonly text: string }; + let endContainer = original.endContainer; + let endOffset = original.endOffset; + if (next) { + before = previous; + after = next; + endContainer = next.range.endContainer; + endOffset = next.range.endOffset; + } else { + const previousOrigin = previous.range.cloneRange(); + previousOrigin.collapse(true); + const previousPrevious = adjacentCharacter(previousOrigin, "backward"); + if (!previousPrevious) { + restoreRange(original); + return; + } + before = previousPrevious; + after = previous; + } + + const transposeRange = document.createRange(); + transposeRange.setStart(before.range.startContainer, before.range.startOffset); + transposeRange.setEnd(endContainer, endOffset); + restoreRange(transposeRange); + document.execCommand("insertText", false, `${after.text}${before.text}`); + }; let killedText: string | undefined; switch (action) { @@ -537,9 +588,8 @@ export function applyEmacsReadlineActionToContentEditable( document.execCommand("insertText", false, yankText); break; case "transpose-chars": - // Rich editors need to own this operation so their internal document - // model and undo history stay synchronized. - return { handled: false }; + transposeCharacters(); + break; } return { handled: true, ...(killedText === undefined ? {} : { killedText }) }; @@ -560,12 +610,7 @@ export function createEmacsReadlineKeydownHandler(options?: { }): (event: KeyboardEvent) => void { return (event) => { const action = resolveEmacsReadlineAction(event); - if ( - !action || - isTerminalTarget(event.target) || - isKeybindingCaptureTarget(event.target) || - options?.shouldYieldToApplicationShortcut?.(event) - ) { + if (!action || isTerminalTarget(event.target) || isKeybindingCaptureTarget(event.target)) { return; } @@ -578,6 +623,8 @@ export function createEmacsReadlineKeydownHandler(options?: { return; } + if (options?.shouldYieldToApplicationShortcut?.(event)) return; + const control = getPlainTextControl(event.target); const editableHost = control ? null : getContentEditableHost(event.target); // Managed editors such as Lexical must update their own selection and From b1198742aaacc156838161c3403afe8fc94d2ddc Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Wed, 22 Jul 2026 06:33:45 -0700 Subject: [PATCH 06/16] Prioritize readline over default app shortcuts --- .../src/components/ComposerPromptEditor.tsx | 4 +- .../src/components/EmacsReadlineBindings.tsx | 22 +++--- .../settings/KeybindingsSettings.logic.ts | 12 +--- apps/web/src/emacsReadlineBindings.test.ts | 15 ++-- apps/web/src/emacsReadlineBindings.ts | 4 +- apps/web/src/keybindings.test.ts | 17 +++++ apps/web/src/keybindings.ts | 70 ++++++++++++++++++- 7 files changed, 115 insertions(+), 29 deletions(-) diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index 6ae2ce06ea1..17e4118fd87 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -62,7 +62,7 @@ import { storeEmacsReadlineKilledText, } from "~/emacsReadlineBindings"; import { useClientSettings } from "~/hooks/useSettings"; -import { resolveShortcutCommand } from "~/keybindings"; +import { resolveCustomShortcutCommand } from "~/keybindings"; import { clampCollapsedComposerCursor, collapseExpandedComposerCursor, @@ -1136,7 +1136,7 @@ function ComposerEmacsReadlinePlugin() { return editor.registerCommand( KEY_DOWN_COMMAND, (event) => { - if (resolveShortcutCommand(event, keybindings) !== null) return false; + if (resolveCustomShortcutCommand(event, keybindings) !== null) return false; const action = resolveEmacsReadlineAction(event); if (!action) return false; diff --git a/apps/web/src/components/EmacsReadlineBindings.tsx b/apps/web/src/components/EmacsReadlineBindings.tsx index 6fe64de16cc..27ac9251e27 100644 --- a/apps/web/src/components/EmacsReadlineBindings.tsx +++ b/apps/web/src/components/EmacsReadlineBindings.tsx @@ -1,24 +1,28 @@ import { useAtomValue } from "@effect/atom-react"; -import { useEffect } from "react"; +import { useEffectEvent, useLayoutEffect } from "react"; import { createEmacsReadlineKeydownHandler } from "../emacsReadlineBindings"; import { useClientSettings } from "../hooks/useSettings"; -import { resolveShortcutCommand } from "../keybindings"; +import { resolveCustomShortcutCommand } from "../keybindings"; import { primaryServerKeybindingsAtom } from "../state/server"; export function EmacsReadlineBindings() { const enabled = useClientSettings((settings) => settings.keyboardEditingMode === "emacs"); const keybindings = useAtomValue(primaryServerKeybindingsAtom); - - useEffect(() => { + const handleKeyDown = useEffectEvent((event: KeyboardEvent) => { if (!enabled) return; - const handleKeyDown = createEmacsReadlineKeydownHandler({ - shouldYieldToApplicationShortcut: (event) => - resolveShortcutCommand(event, keybindings) !== null, - }); + createEmacsReadlineKeydownHandler({ + shouldYieldToApplicationShortcut: (candidate) => + resolveCustomShortcutCommand(candidate, keybindings) !== null, + })(event); + }); + + useLayoutEffect(() => { + // Register once so settings hydration cannot move this capture listener + // behind application shortcut handlers. window.addEventListener("keydown", handleKeyDown, true); return () => window.removeEventListener("keydown", handleKeyDown, true); - }, [enabled, keybindings]); + }, []); return null; } diff --git a/apps/web/src/components/settings/KeybindingsSettings.logic.ts b/apps/web/src/components/settings/KeybindingsSettings.logic.ts index da54e86e42e..95f17a9cca0 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.logic.ts +++ b/apps/web/src/components/settings/KeybindingsSettings.logic.ts @@ -11,6 +11,7 @@ import { } from "@t3tools/shared/keybindings"; import { isMacPlatform } from "../../lib/utils"; +import { isDefaultResolvedKeybinding } from "../../keybindings"; export type KeybindingSource = "Default" | "Custom" | "Project"; @@ -94,16 +95,7 @@ function sourceForBinding(binding: ResolvedKeybindingRule): KeybindingSource { return "Project"; } - const bindingKey = shortcutToKeybindingInput(binding.shortcut); - const bindingWhen = whenAstToExpression(binding.whenAst); - const isDefault = DEFAULT_RESOLVED_KEYBINDINGS.some( - (entry) => - entry.command === binding.command && - shortcutToKeybindingInput(entry.shortcut) === bindingKey && - whenAstToExpression(entry.whenAst) === bindingWhen, - ); - - return isDefault ? "Default" : "Custom"; + return isDefaultResolvedKeybinding(binding) ? "Default" : "Custom"; } function defaultBindingForBinding( diff --git a/apps/web/src/emacsReadlineBindings.test.ts b/apps/web/src/emacsReadlineBindings.test.ts index 46e9d673759..d85515baef6 100644 --- a/apps/web/src/emacsReadlineBindings.test.ts +++ b/apps/web/src/emacsReadlineBindings.test.ts @@ -377,12 +377,19 @@ describe("applyEmacsReadlineActionToContentEditable", () => { }); it.each([ - ["backward-char", "backward", 1], - ["forward-char", "forward", 4], - ] as const)("collapses a selection to the %s movement boundary", (action, direction, offset) => { + ["backward-char", 1], + ["forward-char", 4], + ] as const)("collapses a selection to the %s movement boundary", (action, offset) => { const { collapse, host, modify } = harness({ isCollapsed: false }); expect(applyEmacsReadlineActionToContentEditable(host, action, "").handled).toBe(true); expect(collapse).toHaveBeenCalledWith(expect.anything(), offset); - expect(modify).toHaveBeenCalledWith("move", direction, "character"); + expect(modify).not.toHaveBeenCalled(); + }); + + it("moves by one character when the selection is already collapsed", () => { + const { collapse, host, modify } = harness({ isCollapsed: true }); + expect(applyEmacsReadlineActionToContentEditable(host, "backward-char", "").handled).toBe(true); + expect(collapse).not.toHaveBeenCalled(); + expect(modify).toHaveBeenCalledWith("move", "backward", "character"); }); }); diff --git a/apps/web/src/emacsReadlineBindings.ts b/apps/web/src/emacsReadlineBindings.ts index 6ea13beb8f5..10284cdf688 100644 --- a/apps/web/src/emacsReadlineBindings.ts +++ b/apps/web/src/emacsReadlineBindings.ts @@ -466,13 +466,15 @@ export function applyEmacsReadlineActionToContentEditable( } const move = (direction: "backward" | "forward", granularity: string) => { - if (!selection.isCollapsed) { + const hadSelection = !selection.isCollapsed; + if (hadSelection) { const range = selection.getRangeAt(0); selection.collapse( direction === "backward" ? range.startContainer : range.endContainer, direction === "backward" ? range.startOffset : range.endOffset, ); } + if (hadSelection && granularity === "character") return; selection.modify("move", direction, granularity); }; const extendAndDelete = (direction: "backward" | "forward", granularity: string) => { diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index c0d326edd55..eea84e0673a 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -20,6 +20,7 @@ import { isTerminalSplitShortcut, isTerminalSplitVerticalShortcut, isTerminalToggleShortcut, + resolveCustomShortcutCommand, resolveShortcutCommand, shouldShowModelPickerJumpHints, shouldShowThreadJumpHints, @@ -604,6 +605,22 @@ describe("resolveShortcutCommand", () => { ); }); + it("distinguishes custom shortcuts from built-in editing conflicts", () => { + assert.isNull( + resolveCustomShortcutCommand(event({ key: "b", ctrlKey: true }), DEFAULT_BINDINGS, { + platform: "Linux", + }), + ); + + const customBindings = compile([{ shortcut: modShortcut("b"), command: "terminal.toggle" }]); + assert.strictEqual( + resolveCustomShortcutCommand(event({ key: "b", ctrlKey: true }), customBindings, { + platform: "Linux", + }), + "terminal.toggle", + ); + }); + it("matches bracket shortcuts using the physical key code", () => { assert.strictEqual( resolveShortcutCommand( diff --git a/apps/web/src/keybindings.ts b/apps/web/src/keybindings.ts index 9d6109a7780..2e2fcc21f5b 100644 --- a/apps/web/src/keybindings.ts +++ b/apps/web/src/keybindings.ts @@ -2,12 +2,14 @@ import { type KeybindingCommand, type KeybindingShortcut, type KeybindingWhenNode, + type ResolvedKeybindingRule, MODEL_PICKER_JUMP_KEYBINDING_COMMANDS, type ResolvedKeybindingsConfig, THREAD_JUMP_KEYBINDING_COMMANDS, type ModelPickerJumpKeybindingCommand, type ThreadJumpKeybindingCommand, } from "@t3tools/contracts"; +import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; import { isMacPlatform } from "./lib/utils"; export interface ShortcutEventLike { @@ -198,11 +200,11 @@ function matchesCommandShortcut( return resolveShortcutCommand(event, keybindings, options) === command; } -export function resolveShortcutCommand( +function resolveShortcutBinding( event: ShortcutEventLike, keybindings: ResolvedKeybindingsConfig, options?: ShortcutMatchOptions, -): KeybindingCommand | null { +): ResolvedKeybindingRule | null { const platform = resolvePlatform(options); const context = resolveContext(options); @@ -211,11 +213,73 @@ export function resolveShortcutCommand( if (!binding) continue; if (!matchesWhenClause(binding.whenAst, context)) continue; if (!matchesShortcut(event, binding.shortcut, platform)) continue; - return binding.command; + return binding; } return null; } +export function resolveShortcutCommand( + event: ShortcutEventLike, + keybindings: ResolvedKeybindingsConfig, + options?: ShortcutMatchOptions, +): KeybindingCommand | null { + return resolveShortcutBinding(event, keybindings, options)?.command ?? null; +} + +function keybindingWhenNodesEqual( + left: KeybindingWhenNode | undefined, + right: KeybindingWhenNode | undefined, +): boolean { + if (left === undefined || right === undefined) return left === right; + if (left.type !== right.type) return false; + switch (left.type) { + case "identifier": + return right.type === "identifier" && left.name === right.name; + case "not": + return right.type === "not" && keybindingWhenNodesEqual(left.node, right.node); + case "and": + case "or": + return ( + right.type === left.type && + keybindingWhenNodesEqual(left.left, right.left) && + keybindingWhenNodesEqual(left.right, right.right) + ); + } +} + +function keybindingShortcutsEqual(left: KeybindingShortcut, right: KeybindingShortcut): boolean { + return ( + left.key === right.key && + left.metaKey === right.metaKey && + left.ctrlKey === right.ctrlKey && + left.shiftKey === right.shiftKey && + left.altKey === right.altKey && + left.modKey === right.modKey + ); +} + +export function isDefaultResolvedKeybinding(binding: ResolvedKeybindingRule): boolean { + return DEFAULT_RESOLVED_KEYBINDINGS.some( + (candidate) => + candidate.command === binding.command && + keybindingShortcutsEqual(candidate.shortcut, binding.shortcut) && + keybindingWhenNodesEqual(candidate.whenAst, binding.whenAst), + ); +} + +/** + * Resolves only explicit/custom bindings. Built-in Mod shortcuts intentionally + * yield to platform editing conventions when focus is inside a text editor. + */ +export function resolveCustomShortcutCommand( + event: ShortcutEventLike, + keybindings: ResolvedKeybindingsConfig, + options?: ShortcutMatchOptions, +): KeybindingCommand | null { + const binding = resolveShortcutBinding(event, keybindings, options); + return binding && !isDefaultResolvedKeybinding(binding) ? binding.command : null; +} + function formatShortcutKeyLabel(key: string): string { if (key === " ") return "Space"; if (key.length === 1) return key.toUpperCase(); From df9d66079554d90ad8a9d0a072e760d98385ad62 Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Wed, 22 Jul 2026 07:49:58 -0700 Subject: [PATCH 07/16] Respect contextual custom shortcuts in readline mode --- apps/web/src/AppRoot.test.tsx | 6 +- apps/web/src/AppRoot.tsx | 2 +- .../src/components/ComposerPromptEditor.tsx | 9 +- .../components/EmacsReadlineBindings.test.ts | 84 +++++++++++++++++++ .../src/components/EmacsReadlineBindings.tsx | 46 +++++++++- apps/web/src/emacsReadlineBindings.test.ts | 73 +++++++++++++++- apps/web/src/emacsReadlineBindings.ts | 19 ++++- apps/web/src/keybindings.ts | 2 +- 8 files changed, 225 insertions(+), 16 deletions(-) create mode 100644 apps/web/src/components/EmacsReadlineBindings.test.ts diff --git a/apps/web/src/AppRoot.test.tsx b/apps/web/src/AppRoot.test.tsx index a23f978304c..47d4ffcb1db 100644 --- a/apps/web/src/AppRoot.test.tsx +++ b/apps/web/src/AppRoot.test.tsx @@ -11,7 +11,8 @@ import { AppRoot } from "./AppRoot"; describe("AppRoot", () => { it("shares the application atom registry with routed UI and renderer-wide desktop hosts", () => { - const root = AppRoot({ router: {} as AppRouter }); + const router = {} as AppRouter; + const root = AppRoot({ router }); expect(root.type).toBe(AppAtomRegistryProvider); const children = Children.toArray( @@ -19,6 +20,9 @@ describe("AppRoot", () => { ); expect(children).toHaveLength(4); expect(isValidElement(children[0]) && children[0].type).toBe(EmacsReadlineBindings); + expect( + isValidElement<{ readonly router: AppRouter }>(children[0]) && children[0].props.router, + ).toBe(router); expect(isValidElement(children[1]) && children[1].type).toBe(RouterProvider); expect(isValidElement(children[2]) && children[2].type).toBe(PreviewAutomationHosts); expect(isValidElement(children[3]) && children[3].type).toBe(ElectronBrowserHost); diff --git a/apps/web/src/AppRoot.tsx b/apps/web/src/AppRoot.tsx index b5a50256f1b..fb02caecc2e 100644 --- a/apps/web/src/AppRoot.tsx +++ b/apps/web/src/AppRoot.tsx @@ -14,7 +14,7 @@ import type { AppRouter } from "./router"; export function AppRoot({ router }: { readonly router: AppRouter }) { return ( - + diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index 17e4118fd87..53e6f396c0c 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -1,4 +1,3 @@ -import { useAtomValue } from "@effect/atom-react"; import { LexicalComposer, type InitialConfigType } from "@lexical/react/LexicalComposer"; import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; import { ContentEditable } from "@lexical/react/LexicalContentEditable"; @@ -57,12 +56,12 @@ import { import { applyEmacsReadlineActionToPlainText, + didEmacsReadlineYieldToApplicationShortcut, getEmacsReadlineKillRingText, resolveEmacsReadlineAction, storeEmacsReadlineKilledText, } from "~/emacsReadlineBindings"; import { useClientSettings } from "~/hooks/useSettings"; -import { resolveCustomShortcutCommand } from "~/keybindings"; import { clampCollapsedComposerCursor, collapseExpandedComposerCursor, @@ -79,7 +78,6 @@ import { } from "~/lib/terminalContext"; import { cn, isMacPlatform } from "~/lib/utils"; import { basenameOfPath } from "~/pierre-icons"; -import { primaryServerKeybindingsAtom } from "~/state/server"; import { COMPOSER_INLINE_CHIP_ICON_CLASS_NAME, COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME, @@ -1127,7 +1125,6 @@ function ComposerHomeEndKeyPlugin() { function ComposerEmacsReadlinePlugin() { const [editor] = useLexicalComposerContext(); const enabled = useClientSettings((settings) => settings.keyboardEditingMode === "emacs"); - const keybindings = useAtomValue(primaryServerKeybindingsAtom); const { onRemoveTerminalContext } = use(ComposerTerminalContextActionsContext); useEffect(() => { @@ -1136,7 +1133,7 @@ function ComposerEmacsReadlinePlugin() { return editor.registerCommand( KEY_DOWN_COMMAND, (event) => { - if (resolveCustomShortcutCommand(event, keybindings) !== null) return false; + if (didEmacsReadlineYieldToApplicationShortcut(event)) return false; const action = resolveEmacsReadlineAction(event); if (!action) return false; @@ -1210,7 +1207,7 @@ function ComposerEmacsReadlinePlugin() { }, COMMAND_PRIORITY_HIGH, ); - }, [editor, enabled, keybindings, onRemoveTerminalContext]); + }, [editor, enabled, onRemoveTerminalContext]); return null; } diff --git a/apps/web/src/components/EmacsReadlineBindings.test.ts b/apps/web/src/components/EmacsReadlineBindings.test.ts new file mode 100644 index 00000000000..8fc0ca03113 --- /dev/null +++ b/apps/web/src/components/EmacsReadlineBindings.test.ts @@ -0,0 +1,84 @@ +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { EnvironmentId, ThreadId, type ResolvedKeybindingsConfig } from "@t3tools/contracts"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; + +import type { AppRouter } from "../router"; +import { useTerminalUiStateStore } from "../terminalUiStateStore"; +import { + getActiveShortcutMatchOptions, + shouldYieldToCustomApplicationShortcut, +} from "./EmacsReadlineBindings"; + +const threadRef = scopeThreadRef(EnvironmentId.make("environment-a"), ThreadId.make("thread-a")); + +afterEach(() => { + useTerminalUiStateStore.getState().removeTerminalUiState(threadRef); + vi.unstubAllGlobals(); +}); + +describe("EmacsReadlineBindings", () => { + it("reads the active thread's terminal state into shortcut match options", () => { + class TestElement { + readonly testElement = true; + } + vi.stubGlobal("HTMLElement", TestElement); + vi.stubGlobal("document", { activeElement: null }); + useTerminalUiStateStore.getState().setTerminalOpen(threadRef, true); + const router = { + state: { + matches: [ + { + params: { + environmentId: threadRef.environmentId, + threadId: threadRef.threadId, + }, + }, + ], + }, + } as unknown as AppRouter; + + expect(getActiveShortcutMatchOptions(router).context).toMatchObject({ + terminalFocus: false, + terminalOpen: true, + previewFocus: false, + previewOpen: false, + }); + }); + + it("honors when-guard context while resolving custom shortcut precedence", () => { + const keybindings: ResolvedKeybindingsConfig = [ + { + command: "terminal.toggle", + shortcut: { + key: "k", + metaKey: false, + ctrlKey: true, + shiftKey: false, + altKey: false, + modKey: false, + }, + whenAst: { type: "identifier", name: "terminalOpen" }, + }, + ]; + const event = { + altKey: false, + ctrlKey: true, + key: "k", + metaKey: false, + shiftKey: false, + } as KeyboardEvent; + + expect( + shouldYieldToCustomApplicationShortcut(event, keybindings, { + platform: "Linux", + context: { terminalOpen: false }, + }), + ).toBe(false); + expect( + shouldYieldToCustomApplicationShortcut(event, keybindings, { + platform: "Linux", + context: { terminalOpen: true }, + }), + ).toBe(true); + }); +}); diff --git a/apps/web/src/components/EmacsReadlineBindings.tsx b/apps/web/src/components/EmacsReadlineBindings.tsx index 27ac9251e27..5e1aacd97dc 100644 --- a/apps/web/src/components/EmacsReadlineBindings.tsx +++ b/apps/web/src/components/EmacsReadlineBindings.tsx @@ -1,19 +1,59 @@ import { useAtomValue } from "@effect/atom-react"; +import type { ResolvedKeybindingsConfig } from "@t3tools/contracts"; import { useEffectEvent, useLayoutEffect } from "react"; import { createEmacsReadlineKeydownHandler } from "../emacsReadlineBindings"; import { useClientSettings } from "../hooks/useSettings"; -import { resolveCustomShortcutCommand } from "../keybindings"; +import { isPreviewFocused } from "../lib/previewFocus"; +import { isTerminalFocused } from "../lib/terminalFocus"; +import { resolveCustomShortcutCommand, type ShortcutMatchOptions } from "../keybindings"; +import { selectActiveRightPanel, useRightPanelStore } from "../rightPanelStore"; import { primaryServerKeybindingsAtom } from "../state/server"; +import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; +import { resolveThreadRouteRef } from "../threadRoutes"; +import type { AppRouter } from "../router"; -export function EmacsReadlineBindings() { +export function getActiveShortcutMatchOptions(router: AppRouter): ShortcutMatchOptions { + const matches = router.state.matches; + const routeParams = matches[matches.length - 1]?.params ?? {}; + const threadRef = resolveThreadRouteRef(routeParams); + const terminalOpen = selectThreadTerminalUiState( + useTerminalUiStateStore.getState().terminalUiStateByThreadKey, + threadRef, + ).terminalOpen; + const previewOpen = + selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, threadRef) === "preview"; + + return { + context: { + terminalFocus: isTerminalFocused(), + terminalOpen, + previewFocus: isPreviewFocused(), + previewOpen, + }, + }; +} + +export function shouldYieldToCustomApplicationShortcut( + event: KeyboardEvent, + keybindings: ResolvedKeybindingsConfig, + options: ShortcutMatchOptions, +): boolean { + return resolveCustomShortcutCommand(event, keybindings, options) !== null; +} + +export function EmacsReadlineBindings({ router }: { readonly router: AppRouter }) { const enabled = useClientSettings((settings) => settings.keyboardEditingMode === "emacs"); const keybindings = useAtomValue(primaryServerKeybindingsAtom); const handleKeyDown = useEffectEvent((event: KeyboardEvent) => { if (!enabled) return; createEmacsReadlineKeydownHandler({ shouldYieldToApplicationShortcut: (candidate) => - resolveCustomShortcutCommand(candidate, keybindings) !== null, + shouldYieldToCustomApplicationShortcut( + candidate, + keybindings, + getActiveShortcutMatchOptions(router), + ), })(event); }); diff --git a/apps/web/src/emacsReadlineBindings.test.ts b/apps/web/src/emacsReadlineBindings.test.ts index d85515baef6..9e3a34d07f7 100644 --- a/apps/web/src/emacsReadlineBindings.test.ts +++ b/apps/web/src/emacsReadlineBindings.test.ts @@ -4,6 +4,7 @@ import { applyEmacsReadlineActionToContentEditable, applyEmacsReadlineActionToPlainText, createEmacsReadlineKeydownHandler, + didEmacsReadlineYieldToApplicationShortcut, resolveEmacsReadlineAction, } from "./emacsReadlineBindings"; @@ -73,10 +74,12 @@ describe("resolveEmacsReadlineAction", () => { vi.stubGlobal("Element", TestElement); const yieldToAppShortcut = vi.fn(() => true); + const yieldedEvent = keyboardEvent({ key: "b", ctrlKey: true }); createEmacsReadlineKeydownHandler({ shouldYieldToApplicationShortcut: yieldToAppShortcut, - })(keyboardEvent({ key: "b", ctrlKey: true })); + })(yieldedEvent); expect(yieldToAppShortcut).toHaveBeenCalledOnce(); + expect(didEmacsReadlineYieldToApplicationShortcut(yieldedEvent)).toBe(true); const captureYield = vi.fn(() => false); createEmacsReadlineKeydownHandler({ @@ -132,6 +135,74 @@ describe("resolveEmacsReadlineAction", () => { expect(dispatchedKeys).toEqual(["ArrowDown"]); expect(yieldToAppShortcut).not.toHaveBeenCalled(); }); + + it("lets managed editors claim readline chords before capture-phase app shortcuts", () => { + class TestElement { + readonly ownerDocument = { querySelectorAll: () => [] } as unknown as Document; + + closest(selector: string): TestElement | null { + return selector.includes("contenteditable") ? this : null; + } + + hasAttribute(name: string): boolean { + return name === "data-emacs-readline-managed"; + } + } + class TestInputElement { + readonly testElement = true; + } + vi.stubGlobal("Element", TestElement); + vi.stubGlobal("HTMLElement", TestElement); + vi.stubGlobal("HTMLInputElement", TestInputElement); + vi.stubGlobal("HTMLTextAreaElement", TestInputElement); + + let defaultPrevented = false; + const event = { + ...keyboardEvent({ ctrlKey: true, key: "b" }), + preventDefault: vi.fn(() => { + defaultPrevented = true; + }), + stopImmediatePropagation: vi.fn(), + target: new TestElement(), + } as unknown as KeyboardEvent; + const appShortcut = vi.fn(); + const editorHandler = vi.fn(); + + createEmacsReadlineKeydownHandler()(event); + if (!defaultPrevented) appShortcut(); + editorHandler(); + + expect(event.preventDefault).toHaveBeenCalledOnce(); + expect(event.stopImmediatePropagation).not.toHaveBeenCalled(); + expect(appShortcut).not.toHaveBeenCalled(); + expect(editorHandler).toHaveBeenCalledOnce(); + }); + + it("leaves readline chords outside editable hosts available to app shortcuts", () => { + class TestElement { + closest(): null { + return null; + } + } + class TestInputElement { + readonly testElement = true; + } + vi.stubGlobal("Element", TestElement); + vi.stubGlobal("HTMLInputElement", TestInputElement); + vi.stubGlobal("HTMLTextAreaElement", TestInputElement); + + const appShortcut = vi.fn(); + const preventDefault = vi.fn(); + createEmacsReadlineKeydownHandler()({ + ...keyboardEvent({ ctrlKey: true, key: "d" }), + preventDefault, + target: new TestElement(), + } as unknown as KeyboardEvent); + if (preventDefault.mock.calls.length === 0) appShortcut(); + + expect(preventDefault).not.toHaveBeenCalled(); + expect(appShortcut).toHaveBeenCalledOnce(); + }); }); describe("applyEmacsReadlineActionToPlainText", () => { diff --git a/apps/web/src/emacsReadlineBindings.ts b/apps/web/src/emacsReadlineBindings.ts index 10284cdf688..1b8b94448a4 100644 --- a/apps/web/src/emacsReadlineBindings.ts +++ b/apps/web/src/emacsReadlineBindings.ts @@ -598,6 +598,11 @@ export function applyEmacsReadlineActionToContentEditable( } let killRingText = ""; +const applicationShortcutYieldEvents = new WeakSet(); + +export function didEmacsReadlineYieldToApplicationShortcut(event: KeyboardEvent): boolean { + return applicationShortcutYieldEvents.has(event); +} export function getEmacsReadlineKillRingText(): string { return killRingText; @@ -625,13 +630,21 @@ export function createEmacsReadlineKeydownHandler(options?: { return; } - if (options?.shouldYieldToApplicationShortcut?.(event)) return; + if (options?.shouldYieldToApplicationShortcut?.(event)) { + applicationShortcutYieldEvents.add(event); + return; + } const control = getPlainTextControl(event.target); const editableHost = control ? null : getContentEditableHost(event.target); // Managed editors such as Lexical must update their own selection and - // document state. Their editor plugin handles the original keydown. - if (editableHost?.hasAttribute("data-emacs-readline-managed")) return; + // document state. Prevent capture-phase application shortcuts from + // claiming the chord, but allow the original keydown to keep propagating + // to the editor plugin. + if (editableHost?.hasAttribute("data-emacs-readline-managed")) { + event.preventDefault(); + return; + } const result = control ? applyActionToPlainTextControl(control, action, killRingText) : editableHost diff --git a/apps/web/src/keybindings.ts b/apps/web/src/keybindings.ts index 2e2fcc21f5b..2dc94439cc9 100644 --- a/apps/web/src/keybindings.ts +++ b/apps/web/src/keybindings.ts @@ -37,7 +37,7 @@ export interface ShortcutMatchContext { [key: string]: boolean; } -interface ShortcutMatchOptions { +export interface ShortcutMatchOptions { platform?: string; context?: Partial; } From b281ffdb4d80b8fbfaf158b8789e4972aa4246ba Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Wed, 22 Jul 2026 08:10:16 -0700 Subject: [PATCH 08/16] Harden Emacs readline editor behavior --- apps/server/src/keybindings.test.ts | 3 +- .../src/components/ComposerPromptEditor.tsx | 20 ++++++--- apps/web/src/composerEmacsReadline.test.ts | 43 +++++++++++++++++++ apps/web/src/composerEmacsReadline.ts | 40 +++++++++++++++++ apps/web/src/emacsReadlineBindings.test.ts | 17 +++++++- apps/web/src/emacsReadlineBindings.ts | 36 ++++++++++++---- apps/web/src/keybindings.test.ts | 15 +++++++ apps/web/src/keybindings.ts | 1 + packages/contracts/src/keybindings.test.ts | 2 + packages/contracts/src/keybindings.ts | 1 + packages/shared/src/keybindings.ts | 15 +++++-- 11 files changed, 174 insertions(+), 19 deletions(-) create mode 100644 apps/web/src/composerEmacsReadline.test.ts create mode 100644 apps/web/src/composerEmacsReadline.ts diff --git a/apps/server/src/keybindings.test.ts b/apps/server/src/keybindings.test.ts index 2eef6ac8416..1b83ff2aebf 100644 --- a/apps/server/src/keybindings.test.ts +++ b/apps/server/src/keybindings.test.ts @@ -88,6 +88,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => { assert.deepEqual(compiled, { command: "terminal.split", + source: "user", shortcut: { key: "d", metaKey: false, @@ -219,7 +220,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => { assert.deepEqual( configState.keybindings, - Keybindings.compileResolvedKeybindingsConfig(Keybindings.DEFAULT_KEYBINDINGS), + Keybindings.compileResolvedKeybindingsConfig(Keybindings.DEFAULT_KEYBINDINGS, "default"), ); assert.deepEqual(configState.issues, [ { diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index 53e6f396c0c..30d501ce016 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -58,9 +58,10 @@ import { applyEmacsReadlineActionToPlainText, didEmacsReadlineYieldToApplicationShortcut, getEmacsReadlineKillRingText, - resolveEmacsReadlineAction, + resolveManagedEmacsReadlineAction, storeEmacsReadlineKilledText, } from "~/emacsReadlineBindings"; +import { resolveComposerReadlineReplacement } from "~/composerEmacsReadline"; import { useClientSettings } from "~/hooks/useSettings"; import { clampCollapsedComposerCursor, @@ -1133,8 +1134,9 @@ function ComposerEmacsReadlinePlugin() { return editor.registerCommand( KEY_DOWN_COMMAND, (event) => { + if (!editor.isEditable()) return false; if (didEmacsReadlineYieldToApplicationShortcut(event)) return false; - const action = resolveEmacsReadlineAction(event); + const action = resolveManagedEmacsReadlineAction(event); if (!action) return false; const selection = $getSelection(); @@ -1180,16 +1182,24 @@ function ComposerEmacsReadlinePlugin() { if (!$isRangeSelection(replacementSelection)) return false; const removedTerminalContextIds = new Set(); + const removedTerminalContextTexts: string[] = []; for (const node of replacementSelection.getNodes()) { if (node instanceof ComposerTerminalContextNode) { removedTerminalContextIds.add(node.__context.id); + removedTerminalContextTexts.push(node.__context.text); } } - if (edit.killedText !== undefined) { - storeEmacsReadlineKilledText(replacementSelection.getTextContent()); + const replacement = resolveComposerReadlineReplacement({ + edit, + selectedText: replacementSelection.getTextContent(), + terminalContextTexts: removedTerminalContextTexts, + }); + if (replacement.killedText !== undefined) { + storeEmacsReadlineKilledText(replacement.killedText); } - replacementSelection.insertRawText(edit.insertedText ?? ""); + replacementSelection.insertRawText(replacement.insertedText); + $setSelectionAtComposerOffset(replacement.caretOffset); for (const contextId of removedTerminalContextIds) { onRemoveTerminalContext(contextId); } diff --git a/apps/web/src/composerEmacsReadline.test.ts b/apps/web/src/composerEmacsReadline.test.ts new file mode 100644 index 00000000000..a4f5e51de63 --- /dev/null +++ b/apps/web/src/composerEmacsReadline.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { resolveComposerReadlineReplacement } from "./composerEmacsReadline"; +import { applyEmacsReadlineActionToPlainText } from "./emacsReadlineBindings"; +import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER } from "./lib/terminalContext"; + +describe("resolveComposerReadlineReplacement", () => { + it("keeps the logical caret after yanking text that will reparse into a chip", () => { + const mention = "[file.ts](src/file.ts)"; + const edit = applyEmacsReadlineActionToPlainText({ + action: "yank", + value: "a z", + selectionStart: 2, + selectionEnd: 2, + yankText: mention, + }); + + expect( + resolveComposerReadlineReplacement({ edit, selectedText: "", terminalContextTexts: [] }), + ).toMatchObject({ + insertedText: mention, + caretOffset: 3, + }); + }); + + it("stores readable terminal text instead of object-replacement placeholders", () => { + const selectedText = `before ${INLINE_TERMINAL_CONTEXT_PLACEHOLDER} after`; + const edit = applyEmacsReadlineActionToPlainText({ + action: "kill-line", + value: `before \uFFFC after`, + selectionStart: 0, + selectionEnd: selectedText.length, + }); + + expect( + resolveComposerReadlineReplacement({ + edit, + selectedText, + terminalContextTexts: ["terminal output"], + }).killedText, + ).toBe("before terminal output after"); + }); +}); diff --git a/apps/web/src/composerEmacsReadline.ts b/apps/web/src/composerEmacsReadline.ts new file mode 100644 index 00000000000..03bed6b9482 --- /dev/null +++ b/apps/web/src/composerEmacsReadline.ts @@ -0,0 +1,40 @@ +import type { PlainTextEdit } from "./emacsReadlineBindings"; +import { collapseExpandedComposerCursor } from "./composer-logic"; +import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER } from "./lib/terminalContext"; + +export interface ComposerReadlineReplacement { + readonly caretOffset: number; + readonly insertedText: string; + readonly killedText?: string; +} + +function expandTerminalContextPlaceholders( + text: string, + terminalContextTexts: readonly string[], +): string { + let terminalContextIndex = 0; + return text.replaceAll(INLINE_TERMINAL_CONTEXT_PLACEHOLDER, () => { + const contextText = terminalContextTexts[terminalContextIndex] ?? ""; + terminalContextIndex += 1; + return contextText; + }); +} + +export function resolveComposerReadlineReplacement(input: { + readonly edit: PlainTextEdit; + readonly selectedText: string; + readonly terminalContextTexts: readonly string[]; +}): ComposerReadlineReplacement { + return { + caretOffset: collapseExpandedComposerCursor(input.edit.value, input.edit.selectionStart), + insertedText: input.edit.insertedText ?? "", + ...(input.edit.killedText === undefined + ? {} + : { + killedText: expandTerminalContextPlaceholders( + input.selectedText, + input.terminalContextTexts, + ), + }), + }; +} diff --git a/apps/web/src/emacsReadlineBindings.test.ts b/apps/web/src/emacsReadlineBindings.test.ts index 9e3a34d07f7..6b6dd2d40d5 100644 --- a/apps/web/src/emacsReadlineBindings.test.ts +++ b/apps/web/src/emacsReadlineBindings.test.ts @@ -5,7 +5,9 @@ import { applyEmacsReadlineActionToPlainText, createEmacsReadlineKeydownHandler, didEmacsReadlineYieldToApplicationShortcut, + inputEventDataForPlainTextEdit, resolveEmacsReadlineAction, + resolveManagedEmacsReadlineAction, } from "./emacsReadlineBindings"; function keyboardEvent(input: Partial & Pick): KeyboardEvent { @@ -46,7 +48,7 @@ describe("resolveEmacsReadlineAction", () => { expect(resolveEmacsReadlineAction(keyboardEvent({ key, ctrlKey: true }))).toBe(action); }); - it("uses the produced letter rather than the physical key position", () => { + it("uses produced letters, with a physical fallback for Option symbols", () => { expect( resolveEmacsReadlineAction(keyboardEvent({ ctrlKey: true, code: "KeyQ", key: "a" })), ).toBe("beginning-of-line"); @@ -55,7 +57,7 @@ describe("resolveEmacsReadlineAction", () => { ).toBe("backward-word"); expect( resolveEmacsReadlineAction(keyboardEvent({ altKey: true, code: "KeyB", key: "∫" })), - ).toBeNull(); + ).toBe("backward-word"); }); it("does not capture modified chords outside the mode", () => { @@ -159,6 +161,9 @@ describe("resolveEmacsReadlineAction", () => { let defaultPrevented = false; const event = { ...keyboardEvent({ ctrlKey: true, key: "b" }), + get defaultPrevented() { + return defaultPrevented; + }, preventDefault: vi.fn(() => { defaultPrevented = true; }), @@ -176,6 +181,8 @@ describe("resolveEmacsReadlineAction", () => { expect(event.stopImmediatePropagation).not.toHaveBeenCalled(); expect(appShortcut).not.toHaveBeenCalled(); expect(editorHandler).toHaveBeenCalledOnce(); + expect(resolveEmacsReadlineAction(event)).toBeNull(); + expect(resolveManagedEmacsReadlineAction(event)).toBe("backward-char"); }); it("leaves readline chords outside editable hosts available to app shortcuts", () => { @@ -295,6 +302,12 @@ describe("applyEmacsReadlineActionToPlainText", () => { }); }); + it("reports inserted text through InputEvent data", () => { + expect(inputEventDataForPlainTextEdit(apply("yank", "one ", 4, 4, "two"))).toBe("two"); + expect(inputEventDataForPlainTextEdit(apply("transpose-chars", "abdc", 3))).toBe("cd"); + expect(inputEventDataForPlainTextEdit(apply("delete-forward", "abc", 1))).toBeNull(); + }); + it("moves, deletes, and transposes whole Unicode code points", () => { expect(apply("forward-char", "😀x", 0).selectionStart).toBe(2); expect(apply("backward-char", "😀x", 2).selectionStart).toBe(0); diff --git a/apps/web/src/emacsReadlineBindings.ts b/apps/web/src/emacsReadlineBindings.ts index 1b8b94448a4..ff588d33a44 100644 --- a/apps/web/src/emacsReadlineBindings.ts +++ b/apps/web/src/emacsReadlineBindings.ts @@ -38,11 +38,13 @@ const CANDIDATE_SURFACE_SELECTOR = [ ].join(","); function keyLetter(event: KeyboardEvent): string { - return event.key.length === 1 ? event.key.toLowerCase() : ""; + if (/^[a-z]$/iu.test(event.key)) return event.key.toLowerCase(); + if (event.altKey) return event.code.match(/^Key([A-Z])$/)?.[1]?.toLowerCase() ?? ""; + return ""; } -export function resolveEmacsReadlineAction(event: KeyboardEvent): EmacsReadlineAction | null { - if (event.defaultPrevented || event.isComposing || event.metaKey || event.shiftKey) return null; +function resolveUnclaimedEmacsReadlineAction(event: KeyboardEvent): EmacsReadlineAction | null { + if (event.isComposing || event.metaKey || event.shiftKey) return null; const letter = keyLetter(event); if (event.ctrlKey && !event.altKey) { @@ -94,6 +96,10 @@ export function resolveEmacsReadlineAction(event: KeyboardEvent): EmacsReadlineA return null; } +export function resolveEmacsReadlineAction(event: KeyboardEvent): EmacsReadlineAction | null { + return event.defaultPrevented ? null : resolveUnclaimedEmacsReadlineAction(event); +} + function lineStart(value: string, position: number): number { return value.lastIndexOf("\n", Math.max(0, position - 1)) + 1; } @@ -395,11 +401,17 @@ function isKeybindingCaptureTarget(target: EventTarget | null): boolean { return target instanceof Element && target.closest("[data-keybinding-capture]") !== null; } -function dispatchInputEvent(control: PlainTextControl, inputType: string): void { +export function inputEventDataForPlainTextEdit(edit: PlainTextEdit): string | null { + return edit.inputType?.startsWith("insert") ? (edit.insertedText ?? "") : null; +} + +function dispatchInputEvent( + control: PlainTextControl, + inputType: string, + data: string | null, +): void { const InputEventConstructor = control.ownerDocument.defaultView?.InputEvent ?? InputEvent; - control.dispatchEvent( - new InputEventConstructor("input", { bubbles: true, inputType, data: null }), - ); + control.dispatchEvent(new InputEventConstructor("input", { bubbles: true, inputType, data })); } function applyActionToPlainTextControl( @@ -422,7 +434,7 @@ function applyActionToPlainTextControl( if (edit.inputType) { control.setRangeText(edit.value, 0, control.value.length, "end"); control.setSelectionRange(edit.selectionStart, edit.selectionEnd); - dispatchInputEvent(control, edit.inputType); + dispatchInputEvent(control, edit.inputType, inputEventDataForPlainTextEdit(edit)); } else { control.setSelectionRange(edit.selectionStart, edit.selectionEnd); } @@ -599,11 +611,18 @@ export function applyEmacsReadlineActionToContentEditable( let killRingText = ""; const applicationShortcutYieldEvents = new WeakSet(); +const managedEditorEvents = new WeakSet(); export function didEmacsReadlineYieldToApplicationShortcut(event: KeyboardEvent): boolean { return applicationShortcutYieldEvents.has(event); } +export function resolveManagedEmacsReadlineAction( + event: KeyboardEvent, +): EmacsReadlineAction | null { + return managedEditorEvents.has(event) ? resolveUnclaimedEmacsReadlineAction(event) : null; +} + export function getEmacsReadlineKillRingText(): string { return killRingText; } @@ -642,6 +661,7 @@ export function createEmacsReadlineKeydownHandler(options?: { // claiming the chord, but allow the original keydown to keep propagating // to the editor plugin. if (editableHost?.hasAttribute("data-emacs-readline-managed")) { + managedEditorEvents.add(event); event.preventDefault(); return; } diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index eea84e0673a..34a1ee1bcb1 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -75,6 +75,7 @@ interface TestBinding { shortcut: KeybindingShortcut; command: KeybindingCommand; whenAst?: KeybindingWhenNode; + source?: "default" | "user"; } function compile(bindings: TestBinding[]): ResolvedKeybindingsConfig { @@ -82,6 +83,7 @@ function compile(bindings: TestBinding[]): ResolvedKeybindingsConfig { command: binding.command, shortcut: binding.shortcut, ...(binding.whenAst ? { whenAst: binding.whenAst } : {}), + ...(binding.source ? { source: binding.source } : {}), })); } @@ -621,6 +623,19 @@ describe("resolveShortcutCommand", () => { ); }); + it("honors an explicit user rule even when it is identical to a default", () => { + const defaultRule = DEFAULT_BINDINGS.find((binding) => binding.command === "sidebar.toggle"); + assert.isDefined(defaultRule); + assert.strictEqual( + resolveCustomShortcutCommand( + event({ key: "b", ctrlKey: true }), + [{ ...defaultRule, source: "user" }], + { platform: "Linux" }, + ), + "sidebar.toggle", + ); + }); + it("matches bracket shortcuts using the physical key code", () => { assert.strictEqual( resolveShortcutCommand( diff --git a/apps/web/src/keybindings.ts b/apps/web/src/keybindings.ts index 2dc94439cc9..f63892f3b59 100644 --- a/apps/web/src/keybindings.ts +++ b/apps/web/src/keybindings.ts @@ -259,6 +259,7 @@ function keybindingShortcutsEqual(left: KeybindingShortcut, right: KeybindingSho } export function isDefaultResolvedKeybinding(binding: ResolvedKeybindingRule): boolean { + if (binding.source !== undefined) return binding.source === "default"; return DEFAULT_RESOLVED_KEYBINDINGS.some( (candidate) => candidate.command === binding.command && diff --git a/packages/contracts/src/keybindings.test.ts b/packages/contracts/src/keybindings.test.ts index 33ecd38039f..73977ca0a7f 100644 --- a/packages/contracts/src/keybindings.test.ts +++ b/packages/contracts/src/keybindings.test.ts @@ -138,8 +138,10 @@ it.effect("parses resolved keybinding rules", () => node: { type: "identifier", name: "terminalFocus" }, }, }, + source: "user", }); assert.strictEqual(parsed.shortcut.key, "d"); + assert.strictEqual(parsed.source, "user"); }), ); diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index c7cff9943cd..3473950bdd5 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -149,6 +149,7 @@ export const ResolvedKeybindingRule = Schema.Struct({ command: KeybindingCommand, shortcut: KeybindingShortcut, whenAst: Schema.optional(KeybindingWhenNode), + source: Schema.optional(Schema.Literals(["default", "user"])), }).annotate({ parseOptions: { onExcessProperty: "ignore" } }); export type ResolvedKeybindingRule = typeof ResolvedKeybindingRule.Type; diff --git a/packages/shared/src/keybindings.ts b/packages/shared/src/keybindings.ts index b6bdd7b4783..84aa61a6ad9 100644 --- a/packages/shared/src/keybindings.ts +++ b/packages/shared/src/keybindings.ts @@ -257,7 +257,10 @@ export function parseKeybindingWhenExpression(expression: string): KeybindingWhe return ast; } -export function compileResolvedKeybindingRule(rule: KeybindingRule): ResolvedKeybindingRule | null { +export function compileResolvedKeybindingRule( + rule: KeybindingRule, + source: "default" | "user" = "user", +): ResolvedKeybindingRule | null { const shortcut = parseKeybindingShortcut(rule.key); if (!shortcut) return null; @@ -268,21 +271,24 @@ export function compileResolvedKeybindingRule(rule: KeybindingRule): ResolvedKey command: rule.command, shortcut, whenAst, + source, }; } return { command: rule.command, shortcut, + source, }; } export function compileResolvedKeybindingsConfig( config: ReadonlyArray, + source: "default" | "user" = "user", ): ResolvedKeybindingsConfig { const compiled: ResolvedKeybindingRule[] = []; for (const rule of config) { - const result = compileResolvedKeybindingRule(rule); + const result = compileResolvedKeybindingRule(rule, source); if (result) { compiled.push(result); } @@ -290,4 +296,7 @@ export function compileResolvedKeybindingsConfig( return compiled.slice(-MAX_KEYBINDINGS_COUNT); } -export const DEFAULT_RESOLVED_KEYBINDINGS = compileResolvedKeybindingsConfig(DEFAULT_KEYBINDINGS); +export const DEFAULT_RESOLVED_KEYBINDINGS = compileResolvedKeybindingsConfig( + DEFAULT_KEYBINDINGS, + "default", +); From 889e5c2cbe38d5f129b72477c6177fb0d0b8c4c9 Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Wed, 22 Jul 2026 08:15:20 -0700 Subject: [PATCH 09/16] Allow readline navigation in read-only controls --- apps/web/src/emacsReadlineBindings.test.ts | 37 ++++++++++++++++++++++ apps/web/src/emacsReadlineBindings.ts | 19 +++++++++-- 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/apps/web/src/emacsReadlineBindings.test.ts b/apps/web/src/emacsReadlineBindings.test.ts index 6b6dd2d40d5..510d292d701 100644 --- a/apps/web/src/emacsReadlineBindings.test.ts +++ b/apps/web/src/emacsReadlineBindings.test.ts @@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import { applyEmacsReadlineActionToContentEditable, applyEmacsReadlineActionToPlainText, + applyEmacsReadlineActionToPlainTextControl, createEmacsReadlineKeydownHandler, didEmacsReadlineYieldToApplicationShortcut, inputEventDataForPlainTextEdit, @@ -321,6 +322,42 @@ describe("applyEmacsReadlineActionToPlainText", () => { }); }); +describe("applyEmacsReadlineActionToPlainTextControl", () => { + function control(input?: { readonly disabled?: boolean; readonly readOnly?: boolean }) { + return { + disabled: input?.disabled ?? false, + readOnly: input?.readOnly ?? false, + selectionStart: 3, + selectionEnd: 3, + value: "hello", + type: "text", + setRangeText: vi.fn(), + setSelectionRange: vi.fn(), + } as unknown as HTMLInputElement; + } + + it("allows navigation but blocks edits in read-only controls", () => { + const readOnlyControl = control({ readOnly: true }); + + expect( + applyEmacsReadlineActionToPlainTextControl(readOnlyControl, "backward-char", ""), + ).toEqual({ handled: true }); + expect(readOnlyControl.setSelectionRange).toHaveBeenCalledWith(2, 2); + expect( + applyEmacsReadlineActionToPlainTextControl(readOnlyControl, "delete-backward", ""), + ).toEqual({ handled: false }); + expect(readOnlyControl.setRangeText).not.toHaveBeenCalled(); + }); + + it("blocks navigation in disabled controls", () => { + const disabledControl = control({ disabled: true }); + expect( + applyEmacsReadlineActionToPlainTextControl(disabledControl, "backward-char", ""), + ).toEqual({ handled: false }); + expect(disabledControl.setSelectionRange).not.toHaveBeenCalled(); + }); +}); + describe("applyEmacsReadlineActionToContentEditable", () => { function harness(input?: { readonly focusOutside?: boolean; readonly isCollapsed?: boolean }) { const inside = {} as Node; diff --git a/apps/web/src/emacsReadlineBindings.ts b/apps/web/src/emacsReadlineBindings.ts index ff588d33a44..76006d1e86b 100644 --- a/apps/web/src/emacsReadlineBindings.ts +++ b/apps/web/src/emacsReadlineBindings.ts @@ -27,6 +27,17 @@ export interface PlainTextEdit { readonly value: string; } +const MUTATING_ACTIONS: ReadonlySet = new Set([ + "backward-kill-word", + "delete-backward", + "delete-forward", + "forward-kill-word", + "kill-line", + "transpose-chars", + "unix-line-discard", + "yank", +]); + const CANDIDATE_SURFACE_SELECTOR = [ '[data-slot="command-dialog-popup"]', '[data-slot="command-list"]', @@ -414,12 +425,14 @@ function dispatchInputEvent( control.dispatchEvent(new InputEventConstructor("input", { bubbles: true, inputType, data })); } -function applyActionToPlainTextControl( +export function applyEmacsReadlineActionToPlainTextControl( control: PlainTextControl, action: EmacsReadlineAction, yankText: string, ): { readonly handled: boolean; readonly killedText?: string } { - if (control.disabled || control.readOnly) return { handled: false }; + if (control.disabled || (control.readOnly && MUTATING_ACTIONS.has(action))) { + return { handled: false }; + } const selectionStart = control.selectionStart; const selectionEnd = control.selectionEnd; if (selectionStart === null || selectionEnd === null) return { handled: false }; @@ -666,7 +679,7 @@ export function createEmacsReadlineKeydownHandler(options?: { return; } const result = control - ? applyActionToPlainTextControl(control, action, killRingText) + ? applyEmacsReadlineActionToPlainTextControl(control, action, killRingText) : editableHost ? applyEmacsReadlineActionToContentEditable(editableHost, action, killRingText) : { handled: false }; From 923d55ef6737fb19f2c437dcf67f66aa902f7a41 Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Wed, 22 Jul 2026 08:24:13 -0700 Subject: [PATCH 10/16] Reparse readline chip replacements atomically --- .../src/components/ComposerPromptEditor.tsx | 22 +++++- apps/web/src/composerEmacsReadline.test.ts | 79 +++++++++++++++---- apps/web/src/composerEmacsReadline.ts | 15 +++- 3 files changed, 93 insertions(+), 23 deletions(-) diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index 30d501ce016..7c7c5278bb8 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -1123,10 +1123,14 @@ function ComposerHomeEndKeyPlugin() { return null; } -function ComposerEmacsReadlinePlugin() { +function ComposerEmacsReadlinePlugin(props: { + terminalContexts: ReadonlyArray; + skills: ReadonlyArray; +}) { const [editor] = useLexicalComposerContext(); const enabled = useClientSettings((settings) => settings.keyboardEditingMode === "emacs"); const { onRemoveTerminalContext } = use(ComposerTerminalContextActionsContext); + const skillMetadata = useMemo(() => skillMetadataByName(props.skills), [props.skills]); useEffect(() => { if (!enabled) return; @@ -1180,6 +1184,9 @@ function ComposerEmacsReadlinePlugin() { $setSelectionRangeAtComposerOffsets(replacementStart, replacementEnd); const replacementSelection = $getSelection(); if (!$isRangeSelection(replacementSelection)) return false; + const expandedReplacementRange = + getSelectionRangeForExpandedComposerOffsets(replacementSelection); + if (!expandedReplacementRange) return false; const removedTerminalContextIds = new Set(); const removedTerminalContextTexts: string[] = []; @@ -1191,14 +1198,21 @@ function ComposerEmacsReadlinePlugin() { } const replacement = resolveComposerReadlineReplacement({ edit, + expandedReplacementStart: expandedReplacementRange.start, + expandedReplacementEnd: expandedReplacementRange.end, selectedText: replacementSelection.getTextContent(), + serializedValue: $getRoot().getTextContent(), terminalContextTexts: removedTerminalContextTexts, }); if (replacement.killedText !== undefined) { storeEmacsReadlineKilledText(replacement.killedText); } - replacementSelection.insertRawText(replacement.insertedText); + $setComposerEditorPrompt( + replacement.value, + props.terminalContexts.filter((context) => !removedTerminalContextIds.has(context.id)), + skillMetadata, + ); $setSelectionAtComposerOffset(replacement.caretOffset); for (const contextId of removedTerminalContextIds) { onRemoveTerminalContext(contextId); @@ -1217,7 +1231,7 @@ function ComposerEmacsReadlinePlugin() { }, COMMAND_PRIORITY_HIGH, ); - }, [editor, enabled, onRemoveTerminalContext]); + }, [editor, enabled, onRemoveTerminalContext, props.terminalContexts, skillMetadata]); return null; } @@ -1921,7 +1935,7 @@ function ComposerPromptEditorInner({ - + diff --git a/apps/web/src/composerEmacsReadline.test.ts b/apps/web/src/composerEmacsReadline.test.ts index a4f5e51de63..5ad88721633 100644 --- a/apps/web/src/composerEmacsReadline.test.ts +++ b/apps/web/src/composerEmacsReadline.test.ts @@ -1,27 +1,71 @@ import { describe, expect, it } from "vite-plus/test"; import { resolveComposerReadlineReplacement } from "./composerEmacsReadline"; +import { splitPromptIntoComposerSegments } from "./composer-editor-mentions"; import { applyEmacsReadlineActionToPlainText } from "./emacsReadlineBindings"; import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER } from "./lib/terminalContext"; describe("resolveComposerReadlineReplacement", () => { - it("keeps the logical caret after yanking text that will reparse into a chip", () => { - const mention = "[file.ts](src/file.ts)"; - const edit = applyEmacsReadlineActionToPlainText({ - action: "yank", - value: "a z", - selectionStart: 2, - selectionEnd: 2, - yankText: mention, - }); + it.each([ + { + name: "mention", + serializedToken: "[file.ts](src/file.ts)", + expectedSegment: { type: "mention", path: "src/file.ts" }, + }, + { + name: "skill", + serializedToken: "$review", + expectedSegment: { type: "skill", name: "review" }, + }, + ])( + "keeps the caret after a killed and yanked $name chip", + ({ serializedToken, expectedSegment }) => { + const prefix = "before "; + const suffix = " after"; + const logicalValue = `${prefix}${INLINE_TERMINAL_CONTEXT_PLACEHOLDER}${suffix}`; + const serializedValue = `${prefix}${serializedToken}${suffix}`; + const killEdit = applyEmacsReadlineActionToPlainText({ + action: "kill-line", + value: logicalValue, + selectionStart: prefix.length, + selectionEnd: prefix.length + 1, + }); + const killed = resolveComposerReadlineReplacement({ + edit: killEdit, + expandedReplacementStart: prefix.length, + expandedReplacementEnd: prefix.length + serializedToken.length, + selectedText: serializedToken, + serializedValue, + terminalContextTexts: [], + }); + expect(killed.value).toBe(`${prefix}${suffix}`); + expect(killed.killedText).toBe(serializedToken); + const killedText = killed.killedText; + if (killedText === undefined) throw new Error("Expected killed chip text"); - expect( - resolveComposerReadlineReplacement({ edit, selectedText: "", terminalContextTexts: [] }), - ).toMatchObject({ - insertedText: mention, - caretOffset: 3, - }); - }); + const yankEdit = applyEmacsReadlineActionToPlainText({ + action: "yank", + value: killed.value, + selectionStart: prefix.length, + selectionEnd: prefix.length, + yankText: killedText, + }); + const yanked = resolveComposerReadlineReplacement({ + edit: yankEdit, + expandedReplacementStart: prefix.length, + expandedReplacementEnd: prefix.length, + selectedText: "", + serializedValue: killed.value, + terminalContextTexts: [], + }); + + expect(yanked.value).toBe(serializedValue); + expect(yanked.caretOffset).toBe(prefix.length + 1); + expect(splitPromptIntoComposerSegments(yanked.value)).toContainEqual( + expect.objectContaining(expectedSegment), + ); + }, + ); it("stores readable terminal text instead of object-replacement placeholders", () => { const selectedText = `before ${INLINE_TERMINAL_CONTEXT_PLACEHOLDER} after`; @@ -35,7 +79,10 @@ describe("resolveComposerReadlineReplacement", () => { expect( resolveComposerReadlineReplacement({ edit, + expandedReplacementStart: 0, + expandedReplacementEnd: selectedText.length, selectedText, + serializedValue: selectedText, terminalContextTexts: ["terminal output"], }).killedText, ).toBe("before terminal output after"); diff --git a/apps/web/src/composerEmacsReadline.ts b/apps/web/src/composerEmacsReadline.ts index 03bed6b9482..1006e1ed438 100644 --- a/apps/web/src/composerEmacsReadline.ts +++ b/apps/web/src/composerEmacsReadline.ts @@ -4,8 +4,8 @@ import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER } from "./lib/terminalContext"; export interface ComposerReadlineReplacement { readonly caretOffset: number; - readonly insertedText: string; readonly killedText?: string; + readonly value: string; } function expandTerminalContextPlaceholders( @@ -22,12 +22,21 @@ function expandTerminalContextPlaceholders( export function resolveComposerReadlineReplacement(input: { readonly edit: PlainTextEdit; + readonly expandedReplacementEnd: number; + readonly expandedReplacementStart: number; readonly selectedText: string; + readonly serializedValue: string; readonly terminalContextTexts: readonly string[]; }): ComposerReadlineReplacement { + const insertedText = input.edit.insertedText ?? ""; + const value = + input.serializedValue.slice(0, input.expandedReplacementStart) + + insertedText + + input.serializedValue.slice(input.expandedReplacementEnd); + const expandedCaretOffset = input.expandedReplacementStart + insertedText.length; return { - caretOffset: collapseExpandedComposerCursor(input.edit.value, input.edit.selectionStart), - insertedText: input.edit.insertedText ?? "", + caretOffset: collapseExpandedComposerCursor(value, expandedCaretOffset), + value, ...(input.edit.killedText === undefined ? {} : { From e6eb23c3147ae6a94e7999e07fdd59381c6d7775 Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Wed, 22 Jul 2026 08:37:08 -0700 Subject: [PATCH 11/16] Preserve readline chips during edits --- .../components/ComposerPromptEditor.test.ts | 61 ++++++++++++++++ .../src/components/ComposerPromptEditor.tsx | 71 ++++++++++++++----- apps/web/src/composerEmacsReadline.test.ts | 41 ++++------- apps/web/src/composerEmacsReadline.ts | 63 ++++++++++++---- 4 files changed, 177 insertions(+), 59 deletions(-) diff --git a/apps/web/src/components/ComposerPromptEditor.test.ts b/apps/web/src/components/ComposerPromptEditor.test.ts index 0aab8fb0c2d..02c2c02323d 100644 --- a/apps/web/src/components/ComposerPromptEditor.test.ts +++ b/apps/web/src/components/ComposerPromptEditor.test.ts @@ -4,6 +4,7 @@ import { $createTextNode, $getRoot, $getSelection, + $isElementNode, $isRangeSelection, COMMAND_PRIORITY_EDITOR, createEditor, @@ -11,6 +12,66 @@ import { } from "lexical"; import { registerComposerInlineTokenPaste } from "./composerInlineTokenPaste"; +import { $replaceComposerReadlineSelection } from "../composerEmacsReadline"; + +describe("$replaceComposerReadlineSelection", () => { + it.each([ + { + boundary: "at EOF", + serializedToken: "[file.ts](src/file.ts)", + suffix: "", + tokenType: "mention", + }, + { + boundary: "before punctuation", + serializedToken: "[file.ts](src/file.ts)", + suffix: ".", + tokenType: "mention", + }, + { + boundary: "at EOF", + serializedToken: "$review", + suffix: "", + tokenType: "skill", + }, + { + boundary: "before punctuation", + serializedToken: "$review", + suffix: ".", + tokenType: "skill", + }, + ])( + "preserves an existing $tokenType chip $boundary during a readline mutation", + ({ serializedToken, suffix }) => { + const editor = createEditor(); + let tokenKey = ""; + + editor.update( + () => { + const paragraph = $createParagraphNode(); + const prefix = $createTextNode("before"); + const token = $createTextNode(serializedToken).setMode("token"); + tokenKey = token.getKey(); + paragraph.append(prefix, token); + if (suffix.length > 0) paragraph.append($createTextNode(suffix)); + $getRoot().append(paragraph); + + $replaceComposerReadlineSelection(prefix.select(0, 1), [$createTextNode("B")]); + }, + { discrete: true }, + ); + + editor.getEditorState().read(() => { + const paragraph = $getRoot().getFirstChild(); + expect($isElementNode(paragraph)).toBe(true); + if (!$isElementNode(paragraph)) return; + const token = paragraph.getChildren().find((node) => node.getKey() === tokenKey); + expect(token?.getTextContent()).toBe(serializedToken); + expect($getRoot().getTextContent()).toBe(`Before${serializedToken}${suffix}`); + }); + }, + ); +}); class TestClipboardEvent extends Event { readonly clipboardData: DataTransfer; diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index 7c7c5278bb8..d2b321fcad6 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -61,7 +61,11 @@ import { resolveManagedEmacsReadlineAction, storeEmacsReadlineKilledText, } from "~/emacsReadlineBindings"; -import { resolveComposerReadlineReplacement } from "~/composerEmacsReadline"; +import { + $replaceComposerReadlineSelection, + resolveComposerReadlineReplacement, + splitComposerReadlineInsertion, +} from "~/composerEmacsReadline"; import { useClientSettings } from "~/hooks/useSettings"; import { clampCollapsedComposerCursor, @@ -865,6 +869,48 @@ function $appendTextWithLineBreaks(parent: ElementNode, text: string): void { } } +function $createComposerReadlineInsertion( + text: string, + skillMetadata: ReadonlyMap, +): { readonly logicalLength: number; readonly nodes: LexicalNode[] } { + const nodes: LexicalNode[] = []; + let logicalLength = 0; + + for (const segment of splitComposerReadlineInsertion(text)) { + if (segment.type === "mention") { + nodes.push($createComposerMentionNode(segment.path)); + logicalLength += 1; + continue; + } + if (segment.type === "skill") { + const metadata = skillMetadata.get(segment.name); + nodes.push( + $createComposerSkillNode( + segment.name, + metadata?.label ?? formatProviderSkillDisplayName({ name: segment.name }), + metadata?.description ?? null, + ), + ); + logicalLength += 1; + continue; + } + + const lines = segment.text.split("\n"); + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index] ?? ""; + if (line.length > 0) { + nodes.push($createTextNode(line)); + } + if (index < lines.length - 1) { + nodes.push($createLineBreakNode()); + } + } + logicalLength += segment.text.length; + } + + return { logicalLength, nodes }; +} + function $setComposerEditorPrompt( prompt: string, terminalContexts: ReadonlyArray, @@ -1123,10 +1169,7 @@ function ComposerHomeEndKeyPlugin() { return null; } -function ComposerEmacsReadlinePlugin(props: { - terminalContexts: ReadonlyArray; - skills: ReadonlyArray; -}) { +function ComposerEmacsReadlinePlugin(props: { skills: ReadonlyArray }) { const [editor] = useLexicalComposerContext(); const enabled = useClientSettings((settings) => settings.keyboardEditingMode === "emacs"); const { onRemoveTerminalContext } = use(ComposerTerminalContextActionsContext); @@ -1184,9 +1227,6 @@ function ComposerEmacsReadlinePlugin(props: { $setSelectionRangeAtComposerOffsets(replacementStart, replacementEnd); const replacementSelection = $getSelection(); if (!$isRangeSelection(replacementSelection)) return false; - const expandedReplacementRange = - getSelectionRangeForExpandedComposerOffsets(replacementSelection); - if (!expandedReplacementRange) return false; const removedTerminalContextIds = new Set(); const removedTerminalContextTexts: string[] = []; @@ -1198,22 +1238,19 @@ function ComposerEmacsReadlinePlugin(props: { } const replacement = resolveComposerReadlineReplacement({ edit, - expandedReplacementStart: expandedReplacementRange.start, - expandedReplacementEnd: expandedReplacementRange.end, selectedText: replacementSelection.getTextContent(), - serializedValue: $getRoot().getTextContent(), terminalContextTexts: removedTerminalContextTexts, }); if (replacement.killedText !== undefined) { storeEmacsReadlineKilledText(replacement.killedText); } - $setComposerEditorPrompt( - replacement.value, - props.terminalContexts.filter((context) => !removedTerminalContextIds.has(context.id)), + const insertion = $createComposerReadlineInsertion( + replacement.insertedText, skillMetadata, ); - $setSelectionAtComposerOffset(replacement.caretOffset); + $replaceComposerReadlineSelection(replacementSelection, insertion.nodes); + $setSelectionAtComposerOffset(replacementStart + insertion.logicalLength); for (const contextId of removedTerminalContextIds) { onRemoveTerminalContext(contextId); } @@ -1231,7 +1268,7 @@ function ComposerEmacsReadlinePlugin(props: { }, COMMAND_PRIORITY_HIGH, ); - }, [editor, enabled, onRemoveTerminalContext, props.terminalContexts, skillMetadata]); + }, [editor, enabled, onRemoveTerminalContext, skillMetadata]); return null; } @@ -1935,7 +1972,7 @@ function ComposerPromptEditorInner({ - + diff --git a/apps/web/src/composerEmacsReadline.test.ts b/apps/web/src/composerEmacsReadline.test.ts index 5ad88721633..069682235e9 100644 --- a/apps/web/src/composerEmacsReadline.test.ts +++ b/apps/web/src/composerEmacsReadline.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from "vite-plus/test"; -import { resolveComposerReadlineReplacement } from "./composerEmacsReadline"; -import { splitPromptIntoComposerSegments } from "./composer-editor-mentions"; +import { + resolveComposerReadlineReplacement, + splitComposerReadlineInsertion, +} from "./composerEmacsReadline"; import { applyEmacsReadlineActionToPlainText } from "./emacsReadlineBindings"; import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER } from "./lib/terminalContext"; @@ -18,52 +20,38 @@ describe("resolveComposerReadlineReplacement", () => { expectedSegment: { type: "skill", name: "review" }, }, ])( - "keeps the caret after a killed and yanked $name chip", + "reconstructs a killed and yanked $name chip at the kill-ring boundary", ({ serializedToken, expectedSegment }) => { - const prefix = "before "; - const suffix = " after"; - const logicalValue = `${prefix}${INLINE_TERMINAL_CONTEXT_PLACEHOLDER}${suffix}`; - const serializedValue = `${prefix}${serializedToken}${suffix}`; const killEdit = applyEmacsReadlineActionToPlainText({ action: "kill-line", - value: logicalValue, - selectionStart: prefix.length, - selectionEnd: prefix.length + 1, + value: INLINE_TERMINAL_CONTEXT_PLACEHOLDER, + selectionStart: 0, + selectionEnd: 1, }); const killed = resolveComposerReadlineReplacement({ edit: killEdit, - expandedReplacementStart: prefix.length, - expandedReplacementEnd: prefix.length + serializedToken.length, selectedText: serializedToken, - serializedValue, terminalContextTexts: [], }); - expect(killed.value).toBe(`${prefix}${suffix}`); + expect(killed.insertedText).toBe(""); expect(killed.killedText).toBe(serializedToken); const killedText = killed.killedText; if (killedText === undefined) throw new Error("Expected killed chip text"); const yankEdit = applyEmacsReadlineActionToPlainText({ action: "yank", - value: killed.value, - selectionStart: prefix.length, - selectionEnd: prefix.length, + value: "", + selectionStart: 0, + selectionEnd: 0, yankText: killedText, }); const yanked = resolveComposerReadlineReplacement({ edit: yankEdit, - expandedReplacementStart: prefix.length, - expandedReplacementEnd: prefix.length, selectedText: "", - serializedValue: killed.value, terminalContextTexts: [], }); - expect(yanked.value).toBe(serializedValue); - expect(yanked.caretOffset).toBe(prefix.length + 1); - expect(splitPromptIntoComposerSegments(yanked.value)).toContainEqual( - expect.objectContaining(expectedSegment), - ); + expect(splitComposerReadlineInsertion(yanked.insertedText)).toEqual([expectedSegment]); }, ); @@ -79,10 +67,7 @@ describe("resolveComposerReadlineReplacement", () => { expect( resolveComposerReadlineReplacement({ edit, - expandedReplacementStart: 0, - expandedReplacementEnd: selectedText.length, selectedText, - serializedValue: selectedText, terminalContextTexts: ["terminal output"], }).killedText, ).toBe("before terminal output after"); diff --git a/apps/web/src/composerEmacsReadline.ts b/apps/web/src/composerEmacsReadline.ts index 1006e1ed438..3e3f0ac8808 100644 --- a/apps/web/src/composerEmacsReadline.ts +++ b/apps/web/src/composerEmacsReadline.ts @@ -1,11 +1,56 @@ +import { collectComposerInlineTokens } from "@t3tools/shared/composerInlineTokens"; +import type { LexicalNode, RangeSelection } from "lexical"; + import type { PlainTextEdit } from "./emacsReadlineBindings"; -import { collapseExpandedComposerCursor } from "./composer-logic"; import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER } from "./lib/terminalContext"; export interface ComposerReadlineReplacement { - readonly caretOffset: number; + readonly insertedText: string; readonly killedText?: string; - readonly value: string; +} + +export type ComposerReadlineInsertionSegment = + | { readonly type: "text"; readonly text: string } + | { readonly type: "mention"; readonly path: string } + | { readonly type: "skill"; readonly name: string }; + +export function splitComposerReadlineInsertion( + text: string, +): readonly ComposerReadlineInsertionSegment[] { + // A virtual delimiter recognizes a confirmed killed chip even when its + // serialized source ends at the kill-ring boundary. + const tokens = collectComposerInlineTokens(`${text}\n`).filter( + (token) => token.end <= text.length, + ); + const segments: ComposerReadlineInsertionSegment[] = []; + let cursor = 0; + for (const token of tokens) { + if (token.start < cursor) continue; + if (token.start > cursor) { + segments.push({ type: "text", text: text.slice(cursor, token.start) }); + } + segments.push( + token.type === "mention" + ? { type: "mention", path: token.value } + : { type: "skill", name: token.value }, + ); + cursor = token.end; + } + if (cursor < text.length) { + segments.push({ type: "text", text: text.slice(cursor) }); + } + return segments; +} + +export function $replaceComposerReadlineSelection( + selection: RangeSelection, + nodes: readonly LexicalNode[], +): void { + if (nodes.length > 0) { + selection.insertNodes([...nodes]); + } else { + selection.removeText(); + } } function expandTerminalContextPlaceholders( @@ -22,21 +67,11 @@ function expandTerminalContextPlaceholders( export function resolveComposerReadlineReplacement(input: { readonly edit: PlainTextEdit; - readonly expandedReplacementEnd: number; - readonly expandedReplacementStart: number; readonly selectedText: string; - readonly serializedValue: string; readonly terminalContextTexts: readonly string[]; }): ComposerReadlineReplacement { - const insertedText = input.edit.insertedText ?? ""; - const value = - input.serializedValue.slice(0, input.expandedReplacementStart) + - insertedText + - input.serializedValue.slice(input.expandedReplacementEnd); - const expandedCaretOffset = input.expandedReplacementStart + insertedText.length; return { - caretOffset: collapseExpandedComposerCursor(value, expandedCaretOffset), - value, + insertedText: input.edit.insertedText ?? "", ...(input.edit.killedText === undefined ? {} : { From 551b77c1d0c1407bdf529a86391173f6514f9d5b Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Wed, 22 Jul 2026 08:48:40 -0700 Subject: [PATCH 12/16] Preserve readline chip edit semantics --- apps/web/src/components/ComposerPromptEditor.tsx | 4 +++- apps/web/src/composerEmacsReadline.test.ts | 14 +++++++++++++- apps/web/src/composerEmacsReadline.ts | 10 ++++++++++ 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index d2b321fcad6..d50b5165eb8 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -872,11 +872,12 @@ function $appendTextWithLineBreaks(parent: ElementNode, text: string): void { function $createComposerReadlineInsertion( text: string, skillMetadata: ReadonlyMap, + options: { readonly parseInlineTokens?: boolean } = {}, ): { readonly logicalLength: number; readonly nodes: LexicalNode[] } { const nodes: LexicalNode[] = []; let logicalLength = 0; - for (const segment of splitComposerReadlineInsertion(text)) { + for (const segment of splitComposerReadlineInsertion(text, options)) { if (segment.type === "mention") { nodes.push($createComposerMentionNode(segment.path)); logicalLength += 1; @@ -1248,6 +1249,7 @@ function ComposerEmacsReadlinePlugin(props: { skills: ReadonlyArray { terminalContextTexts: [], }); - expect(splitComposerReadlineInsertion(yanked.insertedText)).toEqual([expectedSegment]); + expect(splitComposerReadlineInsertion(yanked.insertedText)).toEqual([ + expectedSegment, + { type: "text", text: " " }, + ]); }, ); + it("keeps transpose insertions literal instead of creating inline chips", () => { + expect(splitComposerReadlineInsertion("$a", { parseInlineTokens: false })).toEqual([ + { type: "text", text: "$a" }, + ]); + expect( + splitComposerReadlineInsertion("[file.ts](src/file.ts)", { parseInlineTokens: false }), + ).toEqual([{ type: "text", text: "[file.ts](src/file.ts)" }]); + }); + it("stores readable terminal text instead of object-replacement placeholders", () => { const selectedText = `before ${INLINE_TERMINAL_CONTEXT_PLACEHOLDER} after`; const edit = applyEmacsReadlineActionToPlainText({ diff --git a/apps/web/src/composerEmacsReadline.ts b/apps/web/src/composerEmacsReadline.ts index 3e3f0ac8808..0ef6f0fa175 100644 --- a/apps/web/src/composerEmacsReadline.ts +++ b/apps/web/src/composerEmacsReadline.ts @@ -16,7 +16,12 @@ export type ComposerReadlineInsertionSegment = export function splitComposerReadlineInsertion( text: string, + options: { readonly parseInlineTokens?: boolean } = {}, ): readonly ComposerReadlineInsertionSegment[] { + if (options.parseInlineTokens === false) { + return text ? [{ type: "text", text }] : []; + } + // A virtual delimiter recognizes a confirmed killed chip even when its // serialized source ends at the kill-ring boundary. const tokens = collectComposerInlineTokens(`${text}\n`).filter( @@ -38,6 +43,11 @@ export function splitComposerReadlineInsertion( } if (cursor < text.length) { segments.push({ type: "text", text: text.slice(cursor) }); + } else if (segments.at(-1)?.type !== "text") { + // Inline-token grammar requires trailing whitespace. Preserve the chip + // reconstructed at the kill-ring boundary by adding the same delimiter + // used by autocomplete and paste insertion. + segments.push({ type: "text", text: " " }); } return segments; } From 635324678ed6dbb889125917e464ecc84044cf32 Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Wed, 22 Jul 2026 09:05:00 -0700 Subject: [PATCH 13/16] Harden readline context and candidate handling --- .../src/components/ComposerPromptEditor.tsx | 12 ++- apps/web/src/emacsReadlineBindings.test.ts | 84 ++++++++++++++++++- apps/web/src/emacsReadlineBindings.ts | 38 ++++++++- 3 files changed, 123 insertions(+), 11 deletions(-) diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index d50b5165eb8..c03d104e731 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -1173,7 +1173,6 @@ function ComposerHomeEndKeyPlugin() { function ComposerEmacsReadlinePlugin(props: { skills: ReadonlyArray }) { const [editor] = useLexicalComposerContext(); const enabled = useClientSettings((settings) => settings.keyboardEditingMode === "emacs"); - const { onRemoveTerminalContext } = use(ComposerTerminalContextActionsContext); const skillMetadata = useMemo(() => skillMetadataByName(props.skills), [props.skills]); useEffect(() => { @@ -1229,11 +1228,9 @@ function ComposerEmacsReadlinePlugin(props: { skills: ReadonlyArray(); const removedTerminalContextTexts: string[] = []; for (const node of replacementSelection.getNodes()) { if (node instanceof ComposerTerminalContextNode) { - removedTerminalContextIds.add(node.__context.id); removedTerminalContextTexts.push(node.__context.text); } } @@ -1253,9 +1250,10 @@ function ComposerEmacsReadlinePlugin(props: { skills: ReadonlyArray { it("gives an open candidate list precedence over an application shortcut", () => { class TestElement extends EventTarget { ownerDocument!: Document; + parentElement: TestElement | null = null; + hidden = false; + id = ""; + + contains(candidate: unknown): boolean { + for ( + let current = candidate as TestElement | null; + current; + current = current.parentElement + ) { + if (current === this) return true; + } + return false; + } + + getAttribute(): null { + return null; + } closest(): null { return null; @@ -114,10 +132,15 @@ describe("resolveEmacsReadlineAction", () => { vi.stubGlobal("HTMLElement", TestElement); const target = new TestElement(); + const localContainer = new TestElement(); + const candidateList = new TestElement(); + target.parentElement = localContainer; + candidateList.parentElement = localContainer; const document = { activeElement: target, + body: new TestElement(), defaultView: { KeyboardEvent: TestKeyboardEvent }, - querySelectorAll: () => [{ getAttribute: () => null, hidden: false }], + querySelectorAll: () => [candidateList], } as unknown as Document; target.ownerDocument = document; const dispatchedKeys: string[] = []; @@ -139,6 +162,65 @@ describe("resolveEmacsReadlineAction", () => { expect(yieldToAppShortcut).not.toHaveBeenCalled(); }); + it("does not send candidate navigation to an unrelated visible picker", () => { + class TestElement extends EventTarget { + ownerDocument!: Document; + parentElement: TestElement | null = null; + hidden = false; + id = ""; + + contains(candidate: unknown): boolean { + for ( + let current = candidate as TestElement | null; + current; + current = current.parentElement + ) { + if (current === this) return true; + } + return false; + } + + getAttribute(): null { + return null; + } + + closest(): null { + return null; + } + } + vi.stubGlobal("Element", TestElement); + vi.stubGlobal("HTMLElement", TestElement); + + const body = new TestElement(); + const focusedContainer = new TestElement(); + focusedContainer.parentElement = body; + const target = new TestElement(); + target.parentElement = focusedContainer; + const pickerContainer = new TestElement(); + pickerContainer.parentElement = body; + const unrelatedPicker = new TestElement(); + unrelatedPicker.parentElement = pickerContainer; + const document = { + activeElement: target, + body, + querySelectorAll: () => [unrelatedPicker], + } as unknown as Document; + target.ownerDocument = document; + const yieldToAppShortcut = vi.fn(() => true); + const event = { + ...keyboardEvent({ key: "n", ctrlKey: true }), + preventDefault: vi.fn(), + target, + } as unknown as KeyboardEvent; + + createEmacsReadlineKeydownHandler({ shouldYieldToApplicationShortcut: yieldToAppShortcut })( + event, + ); + + expect(event.preventDefault).not.toHaveBeenCalled(); + expect(yieldToAppShortcut).toHaveBeenCalledOnce(); + }); + it("lets managed editors claim readline chords before capture-phase app shortcuts", () => { class TestElement { readonly ownerDocument = { querySelectorAll: () => [] } as unknown as Document; diff --git a/apps/web/src/emacsReadlineBindings.ts b/apps/web/src/emacsReadlineBindings.ts index 76006d1e86b..18fe955d3b5 100644 --- a/apps/web/src/emacsReadlineBindings.ts +++ b/apps/web/src/emacsReadlineBindings.ts @@ -364,9 +364,41 @@ function isTerminalTarget(target: EventTarget | null): boolean { return target instanceof Element && target.closest("[data-terminal-owner]") !== null; } -function isCandidateSelectionOpen(document: Document): boolean { +function candidateSurfaceBelongsToFocus( + surface: HTMLElement, + focusedElement: Element | null, + document: Document, +): boolean { + if (!focusedElement) return false; + if (surface.contains(focusedElement)) return true; + + const controlledIds = focusedElement.getAttribute("aria-controls")?.split(/\s+/) ?? []; + if (surface.id && controlledIds.includes(surface.id)) return true; + + // Composer suggestions keep focus in the editor while rendering their list + // immediately above it. Accept a nearby shared container, but never climb + // as far as body where an unrelated open picker would also appear related. + let container = surface.parentElement; + for (let depth = 0; container && depth < 6; depth += 1) { + if (container === document.body) return false; + if (container.contains(focusedElement)) return true; + container = container.parentElement; + } + return false; +} + +function isCandidateSelectionOpen(document: Document, eventTarget: EventTarget | null): boolean { + const focusedElement = + document.activeElement instanceof Element + ? document.activeElement + : eventTarget instanceof Element + ? eventTarget + : null; return Array.from(document.querySelectorAll(CANDIDATE_SURFACE_SELECTOR)).some( - (element) => !element.hidden && element.getAttribute("aria-hidden") !== "true", + (element) => + !element.hidden && + element.getAttribute("aria-hidden") !== "true" && + candidateSurfaceBelongsToFocus(element, focusedElement, document), ); } @@ -656,7 +688,7 @@ export function createEmacsReadlineKeydownHandler(options?: { const document = (event.target as Node | null)?.ownerDocument ?? globalThis.document; if ( (action === "forward-line" || action === "previous-line") && - isCandidateSelectionOpen(document) + isCandidateSelectionOpen(document, event.target) ) { dispatchCandidateNavigation(event, action === "forward-line" ? "ArrowDown" : "ArrowUp"); return; From cdb86471323d98da43c2183c4295cda3110e1a21 Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Wed, 22 Jul 2026 09:27:53 -0700 Subject: [PATCH 14/16] Fix readline Unicode and default binding edge cases --- apps/server/src/keybindings.test.ts | 13 +++++++++ apps/server/src/keybindings.ts | 22 +++++++++++++-- apps/web/src/composerEmacsReadline.test.ts | 4 +++ apps/web/src/composerEmacsReadline.ts | 2 +- apps/web/src/emacsReadlineBindings.test.ts | 20 +++++++++++++ apps/web/src/emacsReadlineBindings.ts | 33 ++++++++++++++++++---- 6 files changed, 85 insertions(+), 9 deletions(-) diff --git a/apps/server/src/keybindings.test.ts b/apps/server/src/keybindings.test.ts index 1b83ff2aebf..0ca5c737a0f 100644 --- a/apps/server/src/keybindings.test.ts +++ b/apps/server/src/keybindings.test.ts @@ -188,6 +188,19 @@ it.layer(NodeServices.layer)("keybindings", (it) => { }).pipe(Effect.provide(makeKeybindingsLayer())), ); + it.effect("marks startup-written defaults as defaults in runtime snapshots", () => + Effect.gen(function* () { + const snapshot = yield* Effect.gen(function* () { + const keybindings = yield* Keybindings.Keybindings; + yield* keybindings.syncDefaultKeybindingsOnStartup; + return yield* keybindings.loadConfigState; + }); + + assert.equal(snapshot.keybindings.length, Keybindings.DEFAULT_KEYBINDINGS.length); + assert.isTrue(snapshot.keybindings.every((binding) => binding.source === "default")); + }).pipe(Effect.provide(makeKeybindingsLayer())), + ); + it.effect("ships configurable thread navigation defaults", () => Effect.sync(() => { const defaultsByCommand = new Map( diff --git a/apps/server/src/keybindings.ts b/apps/server/src/keybindings.ts index 18a78fe3623..9b03128f213 100644 --- a/apps/server/src/keybindings.ts +++ b/apps/server/src/keybindings.ts @@ -109,6 +109,22 @@ function isSameKeybindingRule(left: KeybindingRule, right: KeybindingRule): bool ); } +function compileRuntimeKeybindingsConfig( + config: ReadonlyArray, +): ResolvedKeybindingsConfig { + const compiled: ResolvedKeybindingRule[] = []; + for (const rule of config) { + const source = DEFAULT_KEYBINDINGS.some((defaultRule) => + isSameKeybindingRule(rule, defaultRule), + ) + ? "default" + : "user"; + const resolved = compileResolvedKeybindingRule(rule, source); + if (resolved) compiled.push(resolved); + } + return compiled.slice(-MAX_KEYBINDINGS_COUNT); +} + function keybindingShortcutContext(rule: KeybindingRule): string | null { const parsed = parseKeybindingShortcut(rule.key); if (!parsed) return null; @@ -448,7 +464,7 @@ const make = Effect.gen(function* () { const loadConfigStateFromDisk = loadRuntimeCustomKeybindingsConfig().pipe( Effect.map(({ keybindings, issues }) => ({ - keybindings: mergeWithDefaultKeybindings(compileResolvedKeybindingsConfig(keybindings)), + keybindings: mergeWithDefaultKeybindings(compileRuntimeKeybindingsConfig(keybindings)), issues, })), ); @@ -664,7 +680,7 @@ const make = Effect.gen(function* () { } yield* writeConfigAtomically(cappedConfig); const nextResolved = mergeWithDefaultKeybindings( - compileResolvedKeybindingsConfig(cappedConfig), + compileRuntimeKeybindingsConfig(cappedConfig), ); yield* Cache.set(resolvedConfigCache, resolvedConfigCacheKey, { keybindings: nextResolved, @@ -685,7 +701,7 @@ const make = Effect.gen(function* () { const nextConfig = customConfig.filter((entry) => !isSameKeybindingRule(entry, target)); yield* writeConfigAtomically(nextConfig); const nextResolved = mergeWithDefaultKeybindings( - compileResolvedKeybindingsConfig(nextConfig), + compileRuntimeKeybindingsConfig(nextConfig), ); yield* Cache.set(resolvedConfigCache, resolvedConfigCacheKey, { keybindings: nextResolved, diff --git a/apps/web/src/composerEmacsReadline.test.ts b/apps/web/src/composerEmacsReadline.test.ts index 57e4036e5ea..9f77680c0e1 100644 --- a/apps/web/src/composerEmacsReadline.test.ts +++ b/apps/web/src/composerEmacsReadline.test.ts @@ -67,6 +67,10 @@ describe("resolveComposerReadlineReplacement", () => { ).toEqual([{ type: "text", text: "[file.ts](src/file.ts)" }]); }); + it("keeps an empty inline-token insertion empty", () => { + expect(splitComposerReadlineInsertion("")).toEqual([]); + }); + it("stores readable terminal text instead of object-replacement placeholders", () => { const selectedText = `before ${INLINE_TERMINAL_CONTEXT_PLACEHOLDER} after`; const edit = applyEmacsReadlineActionToPlainText({ diff --git a/apps/web/src/composerEmacsReadline.ts b/apps/web/src/composerEmacsReadline.ts index 0ef6f0fa175..5ba1a4c8d74 100644 --- a/apps/web/src/composerEmacsReadline.ts +++ b/apps/web/src/composerEmacsReadline.ts @@ -43,7 +43,7 @@ export function splitComposerReadlineInsertion( } if (cursor < text.length) { segments.push({ type: "text", text: text.slice(cursor) }); - } else if (segments.at(-1)?.type !== "text") { + } else if (segments.length > 0 && segments.at(-1)?.type !== "text") { // Inline-token grammar requires trailing whitespace. Preserve the chip // reconstructed at the kill-ring boundary by adding the same delimiter // used by autocomplete and paste insertion. diff --git a/apps/web/src/emacsReadlineBindings.test.ts b/apps/web/src/emacsReadlineBindings.test.ts index 35df3746d19..7273c1aab75 100644 --- a/apps/web/src/emacsReadlineBindings.test.ts +++ b/apps/web/src/emacsReadlineBindings.test.ts @@ -327,6 +327,11 @@ describe("applyEmacsReadlineActionToPlainText", () => { expect(apply("previous-line", value, 11).selectionStart).toBe(7); }); + it("preserves code-point columns without landing inside surrogate pairs", () => { + expect(apply("forward-line", "a\n😀x", 1).selectionStart).toBe(4); + expect(apply("previous-line", "😀x\nab", 5).selectionStart).toBe(2); + }); + it("implements readline kill, discard, yank, and deletion behavior", () => { expect(apply("kill-line", "one two\nthree", 4)).toMatchObject({ value: "one \nthree", @@ -438,6 +443,21 @@ describe("applyEmacsReadlineActionToPlainTextControl", () => { ).toEqual({ handled: false }); expect(disabledControl.setSelectionRange).not.toHaveBeenCalled(); }); + + it("does not mutate or dispatch input for a boundary deletion no-op", () => { + const boundaryControl = control(); + boundaryControl.selectionStart = boundaryControl.value.length; + boundaryControl.selectionEnd = boundaryControl.value.length; + const dispatchEvent = vi.fn(); + Object.assign(boundaryControl, { dispatchEvent }); + + expect( + applyEmacsReadlineActionToPlainTextControl(boundaryControl, "delete-forward", ""), + ).toEqual({ handled: true }); + expect(boundaryControl.setRangeText).not.toHaveBeenCalled(); + expect(boundaryControl.setSelectionRange).toHaveBeenCalledWith(5, 5); + expect(dispatchEvent).not.toHaveBeenCalled(); + }); }); describe("applyEmacsReadlineActionToContentEditable", () => { diff --git a/apps/web/src/emacsReadlineBindings.ts b/apps/web/src/emacsReadlineBindings.ts index 18fe955d3b5..f6706129aab 100644 --- a/apps/web/src/emacsReadlineBindings.ts +++ b/apps/web/src/emacsReadlineBindings.ts @@ -123,19 +123,19 @@ function lineEnd(value: string, position: number): number { function previousLinePosition(value: string, position: number): number { const currentStart = lineStart(value, position); if (currentStart === 0) return position; - const column = position - currentStart; + const column = codePointColumn(value, currentStart, position); const previousEnd = currentStart - 1; const previousStart = lineStart(value, previousEnd); - return Math.min(previousStart + column, previousEnd); + return positionAtCodePointColumn(value, previousStart, previousEnd, column); } function nextLinePosition(value: string, position: number): number { const currentStart = lineStart(value, position); const currentEnd = lineEnd(value, position); if (currentEnd === value.length) return position; - const column = position - currentStart; + const column = codePointColumn(value, currentStart, position); const nextStart = currentEnd + 1; - return Math.min(nextStart + column, lineEnd(value, nextStart)); + return positionAtCodePointColumn(value, nextStart, lineEnd(value, nextStart), column); } function isWordCharacter(character: string): boolean { @@ -163,6 +163,29 @@ function nextCodePointPosition(value: string, position: number): number { return position + 1; } +function codePointColumn(value: string, start: number, position: number): number { + let cursor = start; + let column = 0; + while (cursor < position) { + cursor = nextCodePointPosition(value, cursor); + column += 1; + } + return column; +} + +function positionAtCodePointColumn( + value: string, + start: number, + end: number, + column: number, +): number { + let cursor = start; + for (let currentColumn = 0; currentColumn < column && cursor < end; currentColumn += 1) { + cursor = Math.min(nextCodePointPosition(value, cursor), end); + } + return cursor; +} + function codePointBefore(value: string, position: number): string { const previous = previousCodePointPosition(value, position); return value.slice(previous, position); @@ -476,7 +499,7 @@ export function applyEmacsReadlineActionToPlainTextControl( selectionEnd, yankText, }); - if (edit.inputType) { + if (edit.inputType && edit.value !== control.value) { control.setRangeText(edit.value, 0, control.value.length, "end"); control.setSelectionRange(edit.selectionStart, edit.selectionEnd); dispatchInputEvent(control, edit.inputType, inputEventDataForPlainTextEdit(edit)); From be89fcd12bc4cf8e5281bc1a7c7ec5a93a8da1aa Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Wed, 22 Jul 2026 18:28:43 -0700 Subject: [PATCH 15/16] Fix readline shortcut arbitration edge cases --- apps/server/src/keybindings.test.ts | 26 +++++++++++- apps/server/src/keybindings.ts | 22 +++++----- .../src/components/ComposerPromptEditor.tsx | 36 +++++++++++++++- .../components/EmacsReadlineBindings.test.ts | 18 +++++++- .../src/components/EmacsReadlineBindings.tsx | 2 + .../components/chat/ComposerCommandMenu.tsx | 1 + apps/web/src/emacsReadlineBindings.test.ts | 41 ++++++++++++++++++- apps/web/src/emacsReadlineBindings.ts | 2 + packages/contracts/src/keybindings.test.ts | 2 + packages/contracts/src/keybindings.ts | 1 + 10 files changed, 134 insertions(+), 17 deletions(-) diff --git a/apps/server/src/keybindings.test.ts b/apps/server/src/keybindings.test.ts index 0ca5c737a0f..6f41c1fecd6 100644 --- a/apps/server/src/keybindings.test.ts +++ b/apps/server/src/keybindings.test.ts @@ -184,7 +184,10 @@ it.layer(NodeServices.layer)("keybindings", (it) => { }); const persisted = yield* readKeybindingsConfig(keybindingsConfigPath); - assert.deepEqual(persisted, Keybindings.DEFAULT_KEYBINDINGS); + assert.deepEqual( + persisted, + Keybindings.DEFAULT_KEYBINDINGS.map((rule) => ({ ...rule, source: "default" })), + ); }).pipe(Effect.provide(makeKeybindingsLayer())), ); @@ -201,6 +204,27 @@ it.layer(NodeServices.layer)("keybindings", (it) => { }).pipe(Effect.provide(makeKeybindingsLayer())), ); + it.effect("preserves explicit user intent when a rule is identical to a default", () => + Effect.gen(function* () { + const { keybindingsConfigPath } = yield* ServerConfig.ServerConfig; + const sidebarDefault = Keybindings.DEFAULT_KEYBINDINGS.find( + (rule) => rule.command === "sidebar.toggle", + ); + assert.isDefined(sidebarDefault); + yield* writeKeybindingsConfig(keybindingsConfigPath, [sidebarDefault]); + + const snapshot = yield* Effect.gen(function* () { + const keybindings = yield* Keybindings.Keybindings; + return yield* keybindings.loadConfigState; + }); + const sidebarBinding = snapshot.keybindings.find( + (binding) => binding.command === "sidebar.toggle", + ); + + assert.equal(sidebarBinding?.source, "user"); + }).pipe(Effect.provide(makeKeybindingsLayer())), + ); + it.effect("ships configurable thread navigation defaults", () => Effect.sync(() => { const defaultsByCommand = new Map( diff --git a/apps/server/src/keybindings.ts b/apps/server/src/keybindings.ts index 9b03128f213..6ffe5128706 100644 --- a/apps/server/src/keybindings.ts +++ b/apps/server/src/keybindings.ts @@ -64,7 +64,7 @@ export const ResolvedKeybindingFromConfig = KeybindingRule.pipe( Schema.toType(ResolvedKeybindingRule), SchemaTransformation.transformOrFail({ decode: (rule) => - Effect.succeed(compileResolvedKeybindingRule(rule)).pipe( + Effect.succeed(compileResolvedKeybindingRule(rule, rule.source ?? "user")).pipe( Effect.filterOrFail( Predicate.isNotNull, () => @@ -91,6 +91,7 @@ export const ResolvedKeybindingFromConfig = KeybindingRule.pipe( key, command: resolved.command, when, + ...(resolved.source === undefined ? {} : { source: resolved.source }), }; }), }), @@ -101,6 +102,10 @@ export const ResolvedKeybindingsFromConfig = Schema.Array(ResolvedKeybindingFrom Schema.isMaxLength(MAX_KEYBINDINGS_COUNT), ); +const persistedDefaultKeybindings: ReadonlyArray = DEFAULT_KEYBINDINGS.map( + (rule) => ({ ...rule, source: "default" }), +); + function isSameKeybindingRule(left: KeybindingRule, right: KeybindingRule): boolean { return ( left.command === right.command && @@ -114,12 +119,7 @@ function compileRuntimeKeybindingsConfig( ): ResolvedKeybindingsConfig { const compiled: ResolvedKeybindingRule[] = []; for (const rule of config) { - const source = DEFAULT_KEYBINDINGS.some((defaultRule) => - isSameKeybindingRule(rule, defaultRule), - ) - ? "default" - : "user"; - const resolved = compileResolvedKeybindingRule(rule, source); + const resolved = compileResolvedKeybindingRule(rule, rule.source ?? "user"); if (resolved) compiled.push(resolved); } return compiled.slice(-MAX_KEYBINDINGS_COUNT); @@ -142,8 +142,8 @@ function hasSameShortcutContext(left: KeybindingRule, right: KeybindingRule): bo function keybindingRuleFromUpsertInput(input: ServerUpsertKeybindingInput): KeybindingRule { return input.when === undefined - ? { key: input.key, command: input.command } - : { key: input.key, command: input.command, when: input.when }; + ? { key: input.key, command: input.command, source: "user" } + : { key: input.key, command: input.command, when: input.when, source: "user" }; } function replaceTargetFromUpsertInput(input: ServerUpsertKeybindingInput): KeybindingRule | null { @@ -492,7 +492,7 @@ const make = Effect.gen(function* () { Effect.gen(function* () { const configExists = yield* readConfigExists; if (!configExists) { - yield* writeConfigAtomically(DEFAULT_KEYBINDINGS); + yield* writeConfigAtomically(persistedDefaultKeybindings); yield* Cache.invalidate(resolvedConfigCache, resolvedConfigCacheKey); return; } @@ -534,7 +534,7 @@ const make = Effect.gen(function* () { }); continue; } - missingDefaults.push(defaultRule); + missingDefaults.push({ ...defaultRule, source: "default" }); } for (const conflict of shortcutConflictWarnings) { yield* Effect.logWarning("skipping default keybinding due to shortcut conflict", { diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index c03d104e731..0664d2fb973 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -1176,9 +1176,35 @@ function ComposerEmacsReadlinePlugin(props: { skills: ReadonlyArray skillMetadataByName(props.skills), [props.skills]); useEffect(() => { - if (!enabled) return; + const rootElement = editor.getRootElement(); + const syncReadlineReadiness = (editorState: EditorState = editor.getEditorState()) => { + let ready = false; + if (enabled && editor.isEditable()) { + editorState.read(() => { + const selection = $getSelection(); + ready = + $isRangeSelection(selection) && getSelectionRangeForComposerOffsets(selection) !== null; + }); + } + rootElement?.toggleAttribute("data-emacs-readline-ready", ready); + }; + syncReadlineReadiness(); + const unregisterUpdate = editor.registerUpdateListener(({ editorState }) => { + syncReadlineReadiness(editorState); + }); + const unregisterEditable = editor.registerEditableListener(() => { + syncReadlineReadiness(); + }); - return editor.registerCommand( + if (!enabled) { + return () => { + unregisterUpdate(); + unregisterEditable(); + rootElement?.removeAttribute("data-emacs-readline-ready"); + }; + } + + const unregisterCommand = editor.registerCommand( KEY_DOWN_COMMAND, (event) => { if (!editor.isEditable()) return false; @@ -1268,6 +1294,12 @@ function ComposerEmacsReadlinePlugin(props: { skills: ReadonlyArray { + unregisterCommand(); + unregisterUpdate(); + unregisterEditable(); + rootElement?.removeAttribute("data-emacs-readline-ready"); + }; }, [editor, enabled, skillMetadata]); return null; diff --git a/apps/web/src/components/EmacsReadlineBindings.test.ts b/apps/web/src/components/EmacsReadlineBindings.test.ts index 8fc0ca03113..bd3f9d9b14a 100644 --- a/apps/web/src/components/EmacsReadlineBindings.test.ts +++ b/apps/web/src/components/EmacsReadlineBindings.test.ts @@ -22,7 +22,7 @@ describe("EmacsReadlineBindings", () => { readonly testElement = true; } vi.stubGlobal("HTMLElement", TestElement); - vi.stubGlobal("document", { activeElement: null }); + vi.stubGlobal("document", { activeElement: null, querySelector: () => null }); useTerminalUiStateStore.getState().setTerminalOpen(threadRef, true); const router = { state: { @@ -42,9 +42,25 @@ describe("EmacsReadlineBindings", () => { terminalOpen: true, previewFocus: false, previewOpen: false, + modelPickerOpen: false, }); }); + it("includes model-picker visibility in shortcut match options", () => { + class TestElement { + readonly testElement = true; + } + vi.stubGlobal("HTMLElement", TestElement); + vi.stubGlobal("document", { + activeElement: null, + querySelector: (selector: string) => + selector === "[data-model-picker-content]" ? new TestElement() : null, + }); + const router = { state: { matches: [] } } as unknown as AppRouter; + + expect(getActiveShortcutMatchOptions(router).context?.modelPickerOpen).toBe(true); + }); + it("honors when-guard context while resolving custom shortcut precedence", () => { const keybindings: ResolvedKeybindingsConfig = [ { diff --git a/apps/web/src/components/EmacsReadlineBindings.tsx b/apps/web/src/components/EmacsReadlineBindings.tsx index 5e1aacd97dc..025b200fa0b 100644 --- a/apps/web/src/components/EmacsReadlineBindings.tsx +++ b/apps/web/src/components/EmacsReadlineBindings.tsx @@ -6,6 +6,7 @@ import { createEmacsReadlineKeydownHandler } from "../emacsReadlineBindings"; import { useClientSettings } from "../hooks/useSettings"; import { isPreviewFocused } from "../lib/previewFocus"; import { isTerminalFocused } from "../lib/terminalFocus"; +import { isModelPickerOpen } from "../modelPickerVisibility"; import { resolveCustomShortcutCommand, type ShortcutMatchOptions } from "../keybindings"; import { selectActiveRightPanel, useRightPanelStore } from "../rightPanelStore"; import { primaryServerKeybindingsAtom } from "../state/server"; @@ -30,6 +31,7 @@ export function getActiveShortcutMatchOptions(router: AppRouter): ShortcutMatchO terminalOpen, previewFocus: isPreviewFocused(), previewOpen, + modelPickerOpen: isModelPickerOpen(), }, }; } diff --git a/apps/web/src/components/chat/ComposerCommandMenu.tsx b/apps/web/src/components/chat/ComposerCommandMenu.tsx index 8354412ce11..b6eadff0548 100644 --- a/apps/web/src/components/chat/ComposerCommandMenu.tsx +++ b/apps/web/src/components/chat/ComposerCommandMenu.tsx @@ -141,6 +141,7 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: { >
{props.items.length > 0 ? ( diff --git a/apps/web/src/emacsReadlineBindings.test.ts b/apps/web/src/emacsReadlineBindings.test.ts index 7273c1aab75..ecebd8d3c81 100644 --- a/apps/web/src/emacsReadlineBindings.test.ts +++ b/apps/web/src/emacsReadlineBindings.test.ts @@ -136,11 +136,15 @@ describe("resolveEmacsReadlineAction", () => { const candidateList = new TestElement(); target.parentElement = localContainer; candidateList.parentElement = localContainer; + let candidateSelector = ""; const document = { activeElement: target, body: new TestElement(), defaultView: { KeyboardEvent: TestKeyboardEvent }, - querySelectorAll: () => [candidateList], + querySelectorAll: (selector: string) => { + candidateSelector = selector; + return [candidateList]; + }, } as unknown as Document; target.ownerDocument = document; const dispatchedKeys: string[] = []; @@ -159,6 +163,7 @@ describe("resolveEmacsReadlineAction", () => { } as unknown as KeyboardEvent); expect(dispatchedKeys).toEqual(["ArrowDown"]); + expect(candidateSelector).toContain("[data-composer-command-menu]"); expect(yieldToAppShortcut).not.toHaveBeenCalled(); }); @@ -230,7 +235,7 @@ describe("resolveEmacsReadlineAction", () => { } hasAttribute(name: string): boolean { - return name === "data-emacs-readline-managed"; + return name === "data-emacs-readline-managed" || name === "data-emacs-readline-ready"; } } class TestInputElement { @@ -268,6 +273,38 @@ describe("resolveEmacsReadlineAction", () => { expect(resolveManagedEmacsReadlineAction(event)).toBe("backward-char"); }); + it("does not consume managed-editor chords until the editor can handle them", () => { + class TestElement { + readonly ownerDocument = { querySelectorAll: () => [] } as unknown as Document; + + closest(selector: string): TestElement | null { + return selector.includes("contenteditable") ? this : null; + } + + hasAttribute(name: string): boolean { + return name === "data-emacs-readline-managed"; + } + } + class TestInputElement { + readonly testElement = true; + } + vi.stubGlobal("Element", TestElement); + vi.stubGlobal("HTMLElement", TestElement); + vi.stubGlobal("HTMLInputElement", TestInputElement); + vi.stubGlobal("HTMLTextAreaElement", TestInputElement); + + const event = { + ...keyboardEvent({ ctrlKey: true, key: "b" }), + preventDefault: vi.fn(), + target: new TestElement(), + } as unknown as KeyboardEvent; + + createEmacsReadlineKeydownHandler()(event); + + expect(event.preventDefault).not.toHaveBeenCalled(); + expect(resolveManagedEmacsReadlineAction(event)).toBeNull(); + }); + it("leaves readline chords outside editable hosts available to app shortcuts", () => { class TestElement { closest(): null { diff --git a/apps/web/src/emacsReadlineBindings.ts b/apps/web/src/emacsReadlineBindings.ts index f6706129aab..5f8bd5ca927 100644 --- a/apps/web/src/emacsReadlineBindings.ts +++ b/apps/web/src/emacsReadlineBindings.ts @@ -39,6 +39,7 @@ const MUTATING_ACTIONS: ReadonlySet = new Set([ ]); const CANDIDATE_SURFACE_SELECTOR = [ + "[data-composer-command-menu]", '[data-slot="command-dialog-popup"]', '[data-slot="command-list"]', '[data-slot="combobox-popup"]', @@ -729,6 +730,7 @@ export function createEmacsReadlineKeydownHandler(options?: { // claiming the chord, but allow the original keydown to keep propagating // to the editor plugin. if (editableHost?.hasAttribute("data-emacs-readline-managed")) { + if (!editableHost.hasAttribute("data-emacs-readline-ready")) return; managedEditorEvents.add(event); event.preventDefault(); return; diff --git a/packages/contracts/src/keybindings.test.ts b/packages/contracts/src/keybindings.test.ts index 73977ca0a7f..195c9672e0f 100644 --- a/packages/contracts/src/keybindings.test.ts +++ b/packages/contracts/src/keybindings.test.ts @@ -32,8 +32,10 @@ it.effect("parses keybinding rules", () => const parsedSidebarToggle = yield* decode(KeybindingRule, { key: "mod+b", command: "sidebar.toggle", + source: "user", }); assert.strictEqual(parsedSidebarToggle.command, "sidebar.toggle"); + assert.strictEqual(parsedSidebarToggle.source, "user"); const parsedRightPanelToggle = yield* decode(KeybindingRule, { key: "mod+alt+b", diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index 3473950bdd5..08310e306a2 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -98,6 +98,7 @@ export const KeybindingRule = Schema.Struct({ key: KeybindingValue, command: KeybindingCommand, when: Schema.optional(KeybindingWhen), + source: Schema.optional(Schema.Literals(["default", "user"])), }); export type KeybindingRule = typeof KeybindingRule.Type; From 85d1678228e6f734f5d01cae2b3545e2e529febc Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Wed, 22 Jul 2026 18:36:52 -0700 Subject: [PATCH 16/16] Preserve legacy keybinding default sources --- apps/server/src/keybindings.test.ts | 25 ++++++++++++++++++++++++- apps/server/src/keybindings.ts | 10 +++++++++- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/apps/server/src/keybindings.test.ts b/apps/server/src/keybindings.test.ts index 6f41c1fecd6..5caee6a7305 100644 --- a/apps/server/src/keybindings.test.ts +++ b/apps/server/src/keybindings.test.ts @@ -204,7 +204,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => { }).pipe(Effect.provide(makeKeybindingsLayer())), ); - it.effect("preserves explicit user intent when a rule is identical to a default", () => + it.effect("classifies a legacy sourceless default as a default", () => Effect.gen(function* () { const { keybindingsConfigPath } = yield* ServerConfig.ServerConfig; const sidebarDefault = Keybindings.DEFAULT_KEYBINDINGS.find( @@ -221,7 +221,30 @@ it.layer(NodeServices.layer)("keybindings", (it) => { (binding) => binding.command === "sidebar.toggle", ); + assert.equal(sidebarBinding?.source, "default"); + }).pipe(Effect.provide(makeKeybindingsLayer())), + ); + + it.effect("preserves explicit user intent when a rule is identical to a default", () => + Effect.gen(function* () { + const { keybindingsConfigPath } = yield* ServerConfig.ServerConfig; + const sidebarDefault = Keybindings.DEFAULT_KEYBINDINGS.find( + (rule) => rule.command === "sidebar.toggle", + ); + assert.isDefined(sidebarDefault); + yield* writeKeybindingsConfig(keybindingsConfigPath, [ + { ...sidebarDefault, source: "default" }, + ]); + + const snapshot = yield* Effect.gen(function* () { + const keybindings = yield* Keybindings.Keybindings; + return yield* keybindings.upsertKeybindingRule(sidebarDefault); + }); + const persisted = yield* readKeybindingsConfig(keybindingsConfigPath); + const sidebarBinding = snapshot.find((binding) => binding.command === "sidebar.toggle"); + assert.equal(sidebarBinding?.source, "user"); + assert.equal(persisted.find((rule) => rule.command === "sidebar.toggle")?.source, "user"); }).pipe(Effect.provide(makeKeybindingsLayer())), ); diff --git a/apps/server/src/keybindings.ts b/apps/server/src/keybindings.ts index 6ffe5128706..fc44f67c725 100644 --- a/apps/server/src/keybindings.ts +++ b/apps/server/src/keybindings.ts @@ -119,7 +119,15 @@ function compileRuntimeKeybindingsConfig( ): ResolvedKeybindingsConfig { const compiled: ResolvedKeybindingRule[] = []; for (const rule of config) { - const resolved = compileResolvedKeybindingRule(rule, rule.source ?? "user"); + // Files written before source metadata was introduced contain generated + // defaults without a source. Preserve that legacy meaning, while an + // explicit source written by newer upserts remains authoritative. + const source = + rule.source ?? + (DEFAULT_KEYBINDINGS.some((defaultRule) => isSameKeybindingRule(rule, defaultRule)) + ? "default" + : "user"); + const resolved = compileResolvedKeybindingRule(rule, source); if (resolved) compiled.push(resolved); } return compiled.slice(-MAX_KEYBINDINGS_COUNT);