diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx index bc2ea348b4d2a..cd01804f8efe3 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx @@ -26,6 +26,22 @@ import { type ComposerPromptEditorProps, } from "./ComposerPromptEditorTiptap"; +vi.mock(import("../state/server"), async (original) => { + const { Atom } = await import("effect/reactivity"); + const { compileResolvedKeybindingsConfig, DEFAULT_KEYBINDINGS } = + await import("@t3tools/shared/keybindings"); + return { + ...(await original()), + primaryServerKeybindingsAtom: Atom.make( + compileResolvedKeybindingsConfig([ + ...DEFAULT_KEYBINDINGS, + { key: "mod+arrowup", command: "thread.previous" }, + { key: "mod+arrowdown", command: "thread.next" }, + ]), + ), + }; +}); + const babysit: ServerProviderSkill = { name: "babysit", path: "/skills/babysit/SKILL.md", @@ -271,6 +287,29 @@ it.each([ }, ); +// The mocked server keybindings bind Command-Up and Command-Down to thread navigation. +it("leaves Command-arrow thread navigation bindings to the app on macOS", async () => { + using _platform = vi.spyOn(navigator, "platform", "get").mockReturnValue("MacIntel"); + await using screen = await mountEditor("first\nsecond"); + const { editor } = screen; + await act(async () => { + editor.commands.focus("end"); + }); + await vi.waitFor(() => expect(document.activeElement).toBe(editor.view.dom)); + const reachedApp: string[] = []; + const onWindowKeyDown = (event: KeyboardEvent) => { + if (!event.defaultPrevented && event.key.startsWith("Arrow")) reachedApp.push(event.key); + }; + window.addEventListener("keydown", onWindowKeyDown); + using _listener = { + [Symbol.dispose]: () => window.removeEventListener("keydown", onWindowKeyDown), + }; + + await userEvent.keyboard("{Meta>}{ArrowUp}{ArrowDown}{/Meta}"); + + expect(reachedApp).toEqual(["ArrowUp", "ArrowDown"]); +}); + it.each([ ["e", false, false], ["i", false, false], diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index a974c9308844f..5a036e34611ed 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -114,6 +114,9 @@ import { collectInlineContextIds } from "~/lib/composerContextReferences"; import { resolveDiffThemeName } from "~/lib/diffRendering"; import { isRichTextFormattingShortcut } from "~/keybindings"; import { cn, isMacPlatform } from "~/lib/utils"; +import { isThreadNavigationShortcut } from "~/keybindings"; +import { appAtomRegistry } from "~/rpc/atomRegistry"; +import { primaryServerKeybindingsAtom } from "~/state/server"; import { hasCommandForKeyDown } from "~/shortcuts/dispatcher"; import { basenameOfPath } from "~/pierre-icons"; import { WorkspaceEntryTooltip } from "./chat/WorkspaceEntryIcon"; @@ -1143,6 +1146,13 @@ function ComposerPromptEditorTiptapInner( !event.ctrlKey && !event.isComposing ) { + if ( + isThreadNavigationShortcut(event, appAtomRegistry.get(primaryServerKeybindingsAtom), { + context: { editableFocus: true }, + }) + ) { + return false; + } const selection = window.getSelection(); if ( selection?.anchorNode && diff --git a/apps/web/src/components/ThreadTerminalDrawer.browser.test.tsx b/apps/web/src/components/ThreadTerminalDrawer.browser.test.tsx index 9fd2758747533..850b9cb8174f5 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.browser.test.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.browser.test.tsx @@ -4,10 +4,14 @@ import { ThreadId, type ScopedThreadRef, type FilesystemEntryMetadata, + type ResolvedKeybindingsConfig, } from "@t3tools/contracts"; +import { compileResolvedKeybindingsConfig, DEFAULT_KEYBINDINGS } from "@t3tools/shared/keybindings"; import { Atom, AsyncResult } from "effect/reactivity"; import type { GhosttyTerminalSurfaceOptions } from "~/terminal/ghostty/surface"; import { AppAtomRegistryProvider, appAtomRegistry } from "../rpc/atomRegistry"; +import { useRightPanelStore } from "../rightPanelStore"; +import { useTerminalUiStateStore } from "../terminalUiStateStore"; import { extractTerminalLinks } from "../terminal-links"; import { expect, it, vi } from "vite-plus/test"; import { act, fakeTimers, render } from "../../test/render"; @@ -110,6 +114,7 @@ function Drawer(props: { threadRef: ScopedThreadRef; cwd?: string; onCloseTerminal: (terminalId: string, threadRef?: ScopedThreadRef) => void; + keybindings?: ResolvedKeybindingsConfig; }) { return ( {}} onAddTerminalContext={() => {}} - keybindings={[]} + keybindings={props.keybindings ?? []} /> ); } @@ -149,6 +154,8 @@ function setup() { fixture.pendingSurfaces = []; fixture.surfaceOptions = []; fixture.metadata.clear(); + useRightPanelStore.setState(useRightPanelStore.getInitialState()); + useTerminalUiStateStore.setState(useTerminalUiStateStore.getInitialState()); return { original, other }; } @@ -170,6 +177,42 @@ it("closes an exited terminal on its own thread after navigating to one reusing expect(onCloseTerminal.mock.calls).toEqual([["term-1", original]]); }); +// Thread navigation defaults have no `!terminalFocus`, so they apply in a focused terminal. +it("hands thread navigation shortcuts from a focused terminal back to the app", async () => { + const { other: threadRef } = setup(); + using _platform = vi.spyOn(navigator, "platform", "get").mockReturnValue("Linux x86_64"); + useRightPanelStore.getState().openBrowser(threadRef, null); + await using _screen = await render( + {}} + keybindings={compileResolvedKeybindingsConfig([ + ...DEFAULT_KEYBINDINGS, + { key: "ctrl+r", command: "thread.next", when: "!previewOpen" }, + { key: "ctrl+g", command: "thread.previous", when: "terminalOpen" }, + ])} + />, + ); + await act(async () => { + fixture.pendingSurfaces.shift()?.(); + }); + const terminalHandles = (key: string, code: string, shiftKey = true) => + fixture.surfaceOptions + .at(-1)! + .beforeKey(new KeyboardEvent("keydown", { key, code, ctrlKey: true, shiftKey })); + + expect([terminalHandles("{", "BracketLeft"), terminalHandles("}", "BracketRight")]).toEqual([ + false, + false, + ]); + expect(terminalHandles("C", "KeyC")).toBe(true); + // Sidebar sees the open preview and the closed drawer and rejects these, so the shell keeps them. + expect([terminalHandles("r", "KeyR", false), terminalHandles("g", "KeyG", false)]).toEqual([ + true, + true, + ]); +}); + it("only activates terminal paths confirmed to exist on their environment", async () => { const { other: threadRef } = setup(); const key = (path: string) => JSON.stringify([threadRef.environmentId, path]); diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 6312f88bb2754..de2dc970466d9 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -66,6 +66,7 @@ import { isTerminalSplitShortcut, isTerminalSplitVerticalShortcut, isTerminalToggleShortcut, + isThreadNavigationShortcut, terminalDeleteShortcutData, terminalNavigationShortcutData, } from "../keybindings"; @@ -91,6 +92,7 @@ import { openTerminalLinkInPreview } from "./preview/openTerminalLinkInPreview"; import { useAtomCommand } from "../state/use-atom-command"; import { preventTerminalCloseShortcut } from "../lib/terminalCloseShortcut"; import { observeResize } from "../lib/observeResize"; +import { threadShortcutContext } from "./Sidebar.logic"; import { consumePendingTerminalFocus, focusTerminal, @@ -575,6 +577,7 @@ function TerminalSessionViewport({ const hasHandledExitRef = useRef(false); const selectionActionRequestIdRef = useRef(0); const readKeybindings = useEffectEvent(() => keybindings); + const readThreadShortcutContext = useEffectEvent(() => threadShortcutContext(threadRef)); const handleSessionExited = useEffectEvent(() => { if (hasTerminalWriteAccess()) onSessionExited(); }); @@ -938,7 +941,11 @@ function TerminalSessionViewport({ isTerminalSplitShortcut(event, currentKeybindings, options) || isTerminalSplitVerticalShortcut(event, currentKeybindings, options) || isTerminalNewShortcut(event, currentKeybindings, options) || - isDiffToggleShortcut(event, currentKeybindings, options) + isDiffToggleShortcut(event, currentKeybindings, options) || + // Match Sidebar's context, or a key it will reject skips the shell. + isThreadNavigationShortcut(event, currentKeybindings, { + context: { ...readThreadShortcutContext(), terminalFocus: true }, + }) ) { return false; } diff --git a/apps/web/src/keybindings.ts b/apps/web/src/keybindings.ts index d2f6a5e650417..849183b7a1a69 100644 --- a/apps/web/src/keybindings.ts +++ b/apps/web/src/keybindings.ts @@ -281,12 +281,24 @@ export function threadTraversalDirectionFromCommand( return null; } +export function isThreadNavigationShortcut( + event: ShortcutEventLike, + keybindings: ResolvedKeybindingsConfig, + options?: ShortcutMatchOptions, +): boolean { + const command = resolveShortcutCommand(event, keybindings, options); + return ( + threadTraversalDirectionFromCommand(command) !== null || + threadJumpIndexFromCommand(command ?? "") !== null + ); +} + export function shouldShowThreadJumpHintsForModifiers( modifiers: ShortcutModifierStateLike, keybindings: ResolvedKeybindingsConfig, options?: ShortcutMatchOptions, ): boolean { - // Ghostty can write keys to the shell before window shortcuts run, regardless of the when clause. + // Holding a modifier is routine in a shell (Ctrl+C, Ctrl+R), so don't flash hints over it. if (resolveContext(options).terminalFocus) { return false; }