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/server/src/keybindings.test.ts b/apps/server/src/keybindings.test.ts index 2eef6ac8416..5caee6a7305 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, @@ -183,7 +184,67 @@ 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())), + ); + + 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("classifies a legacy sourceless default as 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, "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())), ); @@ -219,7 +280,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/server/src/keybindings.ts b/apps/server/src/keybindings.ts index 18a78fe3623..fc44f67c725 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 && @@ -109,6 +114,25 @@ function isSameKeybindingRule(left: KeybindingRule, right: KeybindingRule): bool ); } +function compileRuntimeKeybindingsConfig( + config: ReadonlyArray, +): ResolvedKeybindingsConfig { + const compiled: ResolvedKeybindingRule[] = []; + for (const rule of config) { + // 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); +} + function keybindingShortcutContext(rule: KeybindingRule): string | null { const parsed = parseKeybindingShortcut(rule.key); if (!parsed) return null; @@ -126,8 +150,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 { @@ -448,7 +472,7 @@ const make = Effect.gen(function* () { const loadConfigStateFromDisk = loadRuntimeCustomKeybindingsConfig().pipe( Effect.map(({ keybindings, issues }) => ({ - keybindings: mergeWithDefaultKeybindings(compileResolvedKeybindingsConfig(keybindings)), + keybindings: mergeWithDefaultKeybindings(compileRuntimeKeybindingsConfig(keybindings)), issues, })), ); @@ -476,7 +500,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; } @@ -518,7 +542,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", { @@ -664,7 +688,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 +709,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/AppRoot.test.tsx b/apps/web/src/AppRoot.test.tsx index d6d7434769e..47d4ffcb1db 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"; @@ -10,15 +11,20 @@ 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( (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<{ 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 b1fd21f84fa..fb02caecc2e 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/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 169126788ae..0664d2fb973 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -54,6 +54,19 @@ import { useRef, } from "react"; +import { + applyEmacsReadlineActionToPlainText, + didEmacsReadlineYieldToApplicationShortcut, + getEmacsReadlineKillRingText, + resolveManagedEmacsReadlineAction, + storeEmacsReadlineKilledText, +} from "~/emacsReadlineBindings"; +import { + $replaceComposerReadlineSelection, + resolveComposerReadlineReplacement, + splitComposerReadlineInsertion, +} from "~/composerEmacsReadline"; +import { useClientSettings } from "~/hooks/useSettings"; import { clampCollapsedComposerCursor, collapseExpandedComposerCursor, @@ -719,6 +732,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 +793,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; @@ -817,6 +869,49 @@ 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, options)) { + 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, @@ -1075,6 +1170,141 @@ function ComposerHomeEndKeyPlugin() { return null; } +function ComposerEmacsReadlinePlugin(props: { skills: ReadonlyArray }) { + const [editor] = useLexicalComposerContext(); + const enabled = useClientSettings((settings) => settings.keyboardEditingMode === "emacs"); + const skillMetadata = useMemo(() => skillMetadataByName(props.skills), [props.skills]); + + useEffect(() => { + 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(); + }); + + if (!enabled) { + return () => { + unregisterUpdate(); + unregisterEditable(); + rootElement?.removeAttribute("data-emacs-readline-ready"); + }; + } + + const unregisterCommand = editor.registerCommand( + KEY_DOWN_COMMAND, + (event) => { + if (!editor.isEditable()) return false; + if (didEmacsReadlineYieldToApplicationShortcut(event)) return false; + const action = resolveManagedEmacsReadlineAction(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; + + 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. + 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 removedTerminalContextTexts: string[] = []; + for (const node of replacementSelection.getNodes()) { + if (node instanceof ComposerTerminalContextNode) { + removedTerminalContextTexts.push(node.__context.text); + } + } + const replacement = resolveComposerReadlineReplacement({ + edit, + selectedText: replacementSelection.getTextContent(), + terminalContextTexts: removedTerminalContextTexts, + }); + if (replacement.killedText !== undefined) { + storeEmacsReadlineKilledText(replacement.killedText); + } + + const insertion = $createComposerReadlineInsertion( + replacement.insertedText, + skillMetadata, + { parseInlineTokens: action === "yank" }, + ); + $replaceComposerReadlineSelection(replacementSelection, insertion.nodes); + $setSelectionAtComposerOffset(replacementStart + insertion.logicalLength); + // OnChangePlugin publishes both the mutated prompt and remaining + // terminal-context ids from this same Lexical update. Calling the + // legacy single-chip removal callback here would instead rewrite + // the pre-edit prompt and could restore text removed by the kill. + } 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, + ); + return () => { + unregisterCommand(); + unregisterUpdate(); + unregisterEditable(); + rootElement?.removeAttribute("data-emacs-readline-ready"); + }; + }, [editor, enabled, skillMetadata]); + + return null; +} + function ComposerInlineTokenSelectionNormalizePlugin() { const [editor] = useLexicalComposerContext(); @@ -1756,6 +1986,7 @@ function ComposerPromptEditorInner({ className, )} data-testid="composer-editor" + data-emacs-readline-managed="" aria-placeholder={placeholder} placeholder={} onPaste={onPaste} @@ -1773,6 +2004,7 @@ function ComposerPromptEditorInner({ + diff --git a/apps/web/src/components/EmacsReadlineBindings.test.ts b/apps/web/src/components/EmacsReadlineBindings.test.ts new file mode 100644 index 00000000000..bd3f9d9b14a --- /dev/null +++ b/apps/web/src/components/EmacsReadlineBindings.test.ts @@ -0,0 +1,100 @@ +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, querySelector: () => 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, + 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 = [ + { + 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 new file mode 100644 index 00000000000..025b200fa0b --- /dev/null +++ b/apps/web/src/components/EmacsReadlineBindings.tsx @@ -0,0 +1,70 @@ +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 { 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"; +import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; +import { resolveThreadRouteRef } from "../threadRoutes"; +import type { AppRouter } from "../router"; + +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, + modelPickerOpen: isModelPickerOpen(), + }, + }; +} + +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) => + shouldYieldToCustomApplicationShortcut( + candidate, + keybindings, + getActiveShortcutMatchOptions(router), + ), + })(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); + }, []); + + return null; +} 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/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/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={ + + } + /> + { + 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" }, + }, + ])( + "reconstructs a killed and yanked $name chip at the kill-ring boundary", + ({ serializedToken, expectedSegment }) => { + const killEdit = applyEmacsReadlineActionToPlainText({ + action: "kill-line", + value: INLINE_TERMINAL_CONTEXT_PLACEHOLDER, + selectionStart: 0, + selectionEnd: 1, + }); + const killed = resolveComposerReadlineReplacement({ + edit: killEdit, + selectedText: serializedToken, + terminalContextTexts: [], + }); + 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: "", + selectionStart: 0, + selectionEnd: 0, + yankText: killedText, + }); + const yanked = resolveComposerReadlineReplacement({ + edit: yankEdit, + selectedText: "", + terminalContextTexts: [], + }); + + 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("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({ + 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..5ba1a4c8d74 --- /dev/null +++ b/apps/web/src/composerEmacsReadline.ts @@ -0,0 +1,94 @@ +import { collectComposerInlineTokens } from "@t3tools/shared/composerInlineTokens"; +import type { LexicalNode, RangeSelection } from "lexical"; + +import type { PlainTextEdit } from "./emacsReadlineBindings"; +import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER } from "./lib/terminalContext"; + +export interface ComposerReadlineReplacement { + readonly insertedText: string; + readonly killedText?: 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, + 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( + (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) }); + } 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. + segments.push({ type: "text", text: " " }); + } + return segments; +} + +export function $replaceComposerReadlineSelection( + selection: RangeSelection, + nodes: readonly LexicalNode[], +): void { + if (nodes.length > 0) { + selection.insertNodes([...nodes]); + } else { + selection.removeText(); + } +} + +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 { + 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 new file mode 100644 index 00000000000..ecebd8d3c81 --- /dev/null +++ b/apps/web/src/emacsReadlineBindings.test.ts @@ -0,0 +1,655 @@ +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; + +import { + applyEmacsReadlineActionToContentEditable, + applyEmacsReadlineActionToPlainText, + applyEmacsReadlineActionToPlainTextControl, + createEmacsReadlineKeydownHandler, + didEmacsReadlineYieldToApplicationShortcut, + inputEventDataForPlainTextEdit, + resolveEmacsReadlineAction, + resolveManagedEmacsReadlineAction, +} from "./emacsReadlineBindings"; + +function keyboardEvent(input: Partial & 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", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + 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 produced letters, with a physical fallback for Option symbols", () => { + expect( + 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: "∫" })), + ).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(); + }); + + 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); + const yieldedEvent = keyboardEvent({ key: "b", ctrlKey: true }); + createEmacsReadlineKeydownHandler({ + shouldYieldToApplicationShortcut: yieldToAppShortcut, + })(yieldedEvent); + expect(yieldToAppShortcut).toHaveBeenCalledOnce(); + expect(didEmacsReadlineYieldToApplicationShortcut(yieldedEvent)).toBe(true); + + const captureYield = vi.fn(() => false); + createEmacsReadlineKeydownHandler({ + shouldYieldToApplicationShortcut: captureYield, + })({ + ...keyboardEvent({ key: "a", ctrlKey: true }), + target: new TestElement(), + } 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; + 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; + } + } + 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 localContainer = new TestElement(); + const candidateList = new TestElement(); + target.parentElement = localContainer; + candidateList.parentElement = localContainer; + let candidateSelector = ""; + const document = { + activeElement: target, + body: new TestElement(), + defaultView: { KeyboardEvent: TestKeyboardEvent }, + querySelectorAll: (selector: string) => { + candidateSelector = selector; + return [candidateList]; + }, + } 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(candidateSelector).toContain("[data-composer-command-menu]"); + 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; + + closest(selector: string): TestElement | null { + return selector.includes("contenteditable") ? this : null; + } + + hasAttribute(name: string): boolean { + return name === "data-emacs-readline-managed" || name === "data-emacs-readline-ready"; + } + } + 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" }), + get defaultPrevented() { + return defaultPrevented; + }, + 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(); + expect(resolveEmacsReadlineAction(event)).toBeNull(); + 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 { + 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", () => { + 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("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", + 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("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", + selectionStart: 4, + }); + expect(apply("transpose-chars", "acb", 3)).toMatchObject({ + value: "abc", + selectionStart: 3, + }); + }); + + 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); + 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("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(); + }); + + 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", () => { + 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, + 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 & { + 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 { collapse: selection.collapse, 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 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: 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", 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).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 new file mode 100644 index 00000000000..5f8bd5ca927 --- /dev/null +++ b/apps/web/src/emacsReadlineBindings.ts @@ -0,0 +1,749 @@ +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 insertedText?: string; + readonly killedText?: string; + readonly replacementEnd?: number; + readonly replacementStart?: number; + readonly selectionEnd: number; + readonly selectionStart: number; + 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-composer-command-menu]", + '[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 (/^[a-z]$/iu.test(event.key)) return event.key.toLowerCase(); + if (event.altKey) return event.code.match(/^Key([A-Z])$/)?.[1]?.toLowerCase() ?? ""; + return ""; +} + +function resolveUnclaimedEmacsReadlineAction(event: KeyboardEvent): EmacsReadlineAction | null { + if (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; +} + +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; +} + +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 = codePointColumn(value, currentStart, position); + const previousEnd = currentStart - 1; + const previousStart = lineStart(value, 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 = codePointColumn(value, currentStart, position); + const nextStart = currentEnd + 1; + return positionAtCodePointColumn(value, nextStart, lineEnd(value, nextStart), column); +} + +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 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); +} + +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(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(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(codePointBefore(value, next))) { + next = previousCodePointPosition(value, next); + } + while (next > 0 && !/\s/u.test(codePointBefore(value, next))) { + next = previousCodePointPosition(value, next); + } + 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, + insertedText, + replacementStart: start, + replacementEnd: end, + ...(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 ? previousCodePointPosition(value, start) : start); + case "forward-char": + return movement(value, start === end ? nextCodePointPosition(value, end) : 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 ? previousCodePointPosition(value, start) : start; + return replacement(value, deleteStart, end, "", "deleteContentBackward"); + } + case "delete-forward": { + const deleteEnd = start === end ? nextCodePointPosition(value, end) : 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": + 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 || 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 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: `${rightCharacter}${leftCharacter}`, + replacementStart: leftStart, + replacementEnd: rightEnd, + }; + } + } +} + +function isTerminalTarget(target: EventTarget | null): boolean { + return target instanceof Element && target.closest("[data-terminal-owner]") !== null; +} + +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" && + candidateSurfaceBelongsToFocus(element, focusedElement, document), + ); +} + +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 isKeybindingCaptureTarget(target: EventTarget | null): boolean { + return target instanceof Element && target.closest("[data-keybinding-capture]") !== null; +} + +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 })); +} + +export function applyEmacsReadlineActionToPlainTextControl( + control: PlainTextControl, + action: EmacsReadlineAction, + yankText: string, +): { readonly handled: boolean; readonly killedText?: string } { + 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 }; + + const edit = applyEmacsReadlineActionToPlainText({ + action, + value: control.value, + selectionStart, + selectionEnd, + yankText, + }); + 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)); + } 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; + const focus = selection.focusNode; + return ( + anchor !== null && + focus !== null && + (anchor === host || host.contains(anchor)) && + (focus === host || host.contains(focus)) + ); +} + +export function applyEmacsReadlineActionToContentEditable( + 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) || + selection.rangeCount === 0 || + typeof selection.modify !== "function" + ) { + return { handled: false }; + } + + const move = (direction: "backward" | "forward", granularity: string) => { + 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) => { + if (selection.isCollapsed) selection.modify("extend", direction, granularity); + 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; + }; + 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) { + 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"); + if (!selection.isCollapsed) document.execCommand("delete"); + break; + case "delete-forward": + if (selection.isCollapsed) selection.modify("extend", "forward", "character"); + if (!selection.isCollapsed) 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": + if (!yankText) break; + document.execCommand("insertText", false, yankText); + break; + case "transpose-chars": + transposeCharacters(); + break; + } + + return { handled: true, ...(killedText === undefined ? {} : { killedText }) }; +} + +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; +} + +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) || isKeybindingCaptureTarget(event.target)) { + return; + } + + const document = (event.target as Node | null)?.ownerDocument ?? globalThis.document; + if ( + (action === "forward-line" || action === "previous-line") && + isCandidateSelectionOpen(document, event.target) + ) { + dispatchCandidateNavigation(event, action === "forward-line" ? "ArrowDown" : "ArrowUp"); + 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. 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")) { + if (!editableHost.hasAttribute("data-emacs-readline-ready")) return; + managedEditorEvents.add(event); + event.preventDefault(); + return; + } + const result = control + ? applyEmacsReadlineActionToPlainTextControl(control, action, killRingText) + : editableHost + ? applyEmacsReadlineActionToContentEditable(editableHost, action, killRingText) + : { handled: false }; + + if (!result.handled) return; + event.preventDefault(); + event.stopImmediatePropagation(); + if (result.killedText) storeEmacsReadlineKilledText(result.killedText); + }; +} diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index c0d326edd55..34a1ee1bcb1 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, @@ -74,6 +75,7 @@ interface TestBinding { shortcut: KeybindingShortcut; command: KeybindingCommand; whenAst?: KeybindingWhenNode; + source?: "default" | "user"; } function compile(bindings: TestBinding[]): ResolvedKeybindingsConfig { @@ -81,6 +83,7 @@ function compile(bindings: TestBinding[]): ResolvedKeybindingsConfig { command: binding.command, shortcut: binding.shortcut, ...(binding.whenAst ? { whenAst: binding.whenAst } : {}), + ...(binding.source ? { source: binding.source } : {}), })); } @@ -604,6 +607,35 @@ 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("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 9d6109a7780..f63892f3b59 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 { @@ -35,7 +37,7 @@ export interface ShortcutMatchContext { [key: string]: boolean; } -interface ShortcutMatchOptions { +export interface ShortcutMatchOptions { platform?: string; context?: Partial; } @@ -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,74 @@ 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 { + if (binding.source !== undefined) return binding.source === "default"; + 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(); diff --git a/packages/contracts/src/keybindings.test.ts b/packages/contracts/src/keybindings.test.ts index 33ecd38039f..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", @@ -138,8 +140,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..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; @@ -149,6 +150,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/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, 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", +);