diff --git a/apps/server/src/keybindings.test.ts b/apps/server/src/keybindings.test.ts index 846c3778b0..9cf0394142 100644 --- a/apps/server/src/keybindings.test.ts +++ b/apps/server/src/keybindings.test.ts @@ -165,6 +165,19 @@ it.layer(NodeServices.layer)("keybindings", (it) => { }).pipe(Effect.provide(makeKeybindingsLayer())), ); + it.effect("ships configurable thread navigation defaults", () => + Effect.sync(() => { + const defaultsByCommand = new Map( + DEFAULT_KEYBINDINGS.map((binding) => [binding.command, binding.key] as const), + ); + + assert.equal(defaultsByCommand.get("thread.previous"), "mod+shift+["); + assert.equal(defaultsByCommand.get("thread.next"), "mod+shift+]"); + assert.equal(defaultsByCommand.get("thread.jump.1"), "mod+1"); + assert.equal(defaultsByCommand.get("thread.jump.9"), "mod+9"); + }), + ); + it.effect("uses defaults in runtime when config is malformed without overriding file", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/keybindings.ts b/apps/server/src/keybindings.ts index 58363d2138..176e0300ad 100644 --- a/apps/server/src/keybindings.ts +++ b/apps/server/src/keybindings.ts @@ -15,6 +15,7 @@ import { MAX_WHEN_EXPRESSION_DEPTH, ResolvedKeybindingRule, ResolvedKeybindingsConfig, + THREAD_JUMP_KEYBINDING_COMMANDS, type ServerConfigIssue, } from "@t3tools/contracts"; import { Mutable } from "effect/Types"; @@ -76,6 +77,12 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" }, { key: "mod+shift+n", command: "chat.newLocal", when: "!terminalFocus" }, { key: "mod+o", command: "editor.openFavorite" }, + { key: "mod+shift+[", command: "thread.previous" }, + { key: "mod+shift+]", command: "thread.next" }, + ...THREAD_JUMP_KEYBINDING_COMMANDS.map((command, index) => ({ + key: `mod+${index + 1}`, + command, + })), ]; function normalizeKeyToken(token: string): string { diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index e572f3c470..fbd332354a 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1162,25 +1162,43 @@ export default function ChatView({ threadId }: ChatViewProps) { }, [activeProjectCwd, activeThreadWorktreePath]); // Default true while loading to avoid toolbar flicker. const isGitRepo = branchesQuery.data?.isRepo ?? true; + const terminalShortcutLabelOptions = useMemo( + () => ({ + context: { + terminalFocus: true, + terminalOpen: Boolean(terminalState.terminalOpen), + }, + }), + [terminalState.terminalOpen], + ); + const nonTerminalShortcutLabelOptions = useMemo( + () => ({ + context: { + terminalFocus: false, + terminalOpen: Boolean(terminalState.terminalOpen), + }, + }), + [terminalState.terminalOpen], + ); const terminalToggleShortcutLabel = useMemo( () => shortcutLabelForCommand(keybindings, "terminal.toggle"), [keybindings], ); const splitTerminalShortcutLabel = useMemo( - () => shortcutLabelForCommand(keybindings, "terminal.split"), - [keybindings], + () => shortcutLabelForCommand(keybindings, "terminal.split", terminalShortcutLabelOptions), + [keybindings, terminalShortcutLabelOptions], ); const newTerminalShortcutLabel = useMemo( - () => shortcutLabelForCommand(keybindings, "terminal.new"), - [keybindings], + () => shortcutLabelForCommand(keybindings, "terminal.new", terminalShortcutLabelOptions), + [keybindings, terminalShortcutLabelOptions], ); const closeTerminalShortcutLabel = useMemo( - () => shortcutLabelForCommand(keybindings, "terminal.close"), - [keybindings], + () => shortcutLabelForCommand(keybindings, "terminal.close", terminalShortcutLabelOptions), + [keybindings, terminalShortcutLabelOptions], ); const diffPanelShortcutLabel = useMemo( - () => shortcutLabelForCommand(keybindings, "diff.toggle"), - [keybindings], + () => shortcutLabelForCommand(keybindings, "diff.toggle", nonTerminalShortcutLabelOptions), + [keybindings, nonTerminalShortcutLabelOptions], ); const onToggleDiff = useCallback(() => { void navigate({ diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index cc49a82e69..b54ec1cb93 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from "vitest"; import { + getVisibleSidebarThreadIds, + resolveAdjacentThreadId, getFallbackThreadIdAfterDelete, getVisibleThreadsForProject, getProjectSortTimestamp, @@ -97,6 +99,80 @@ describe("resolveSidebarNewThreadEnvMode", () => { }); }); +describe("resolveAdjacentThreadId", () => { + it("resolves adjacent thread ids in ordered sidebar traversal", () => { + const threads = [ + ThreadId.makeUnsafe("thread-1"), + ThreadId.makeUnsafe("thread-2"), + ThreadId.makeUnsafe("thread-3"), + ]; + + expect( + resolveAdjacentThreadId({ + threadIds: threads, + currentThreadId: threads[1] ?? null, + direction: "previous", + }), + ).toBe(threads[0]); + expect( + resolveAdjacentThreadId({ + threadIds: threads, + currentThreadId: threads[1] ?? null, + direction: "next", + }), + ).toBe(threads[2]); + expect( + resolveAdjacentThreadId({ + threadIds: threads, + currentThreadId: null, + direction: "next", + }), + ).toBe(threads[0]); + expect( + resolveAdjacentThreadId({ + threadIds: threads, + currentThreadId: null, + direction: "previous", + }), + ).toBe(threads[2]); + expect( + resolveAdjacentThreadId({ + threadIds: threads, + currentThreadId: threads[0] ?? null, + direction: "previous", + }), + ).toBeNull(); + }); +}); + +describe("getVisibleSidebarThreadIds", () => { + it("returns only the rendered visible thread order across projects", () => { + expect( + getVisibleSidebarThreadIds([ + { + renderedThreads: [ + { id: ThreadId.makeUnsafe("thread-12") }, + { id: ThreadId.makeUnsafe("thread-11") }, + { id: ThreadId.makeUnsafe("thread-10") }, + ], + }, + { + renderedThreads: [ + { id: ThreadId.makeUnsafe("thread-8") }, + { id: ThreadId.makeUnsafe("thread-6") }, + ], + }, + ]), + ).toEqual([ + ThreadId.makeUnsafe("thread-12"), + ThreadId.makeUnsafe("thread-11"), + ThreadId.makeUnsafe("thread-10"), + ThreadId.makeUnsafe("thread-8"), + ThreadId.makeUnsafe("thread-6"), + ]); + }); +}); + describe("isContextMenuPointerDown", () => { it("treats secondary-button presses as context menu gestures on all platforms", () => { expect( diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 22a6268ac7..1e0871e0d2 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -17,6 +17,8 @@ type SidebarProject = { }; type SidebarThreadSortInput = Pick; +export type ThreadTraversalDirection = "previous" | "next"; + export interface ThreadStatusPill { label: | "Working" @@ -67,6 +69,45 @@ export function resolveSidebarNewThreadEnvMode(input: { return input.requestedEnvMode ?? input.defaultEnvMode; } +export function getVisibleSidebarThreadIds( + renderedProjects: readonly { + renderedThreads: readonly { + id: TThreadId; + }[]; + }[], +): TThreadId[] { + return renderedProjects.flatMap((renderedProject) => + renderedProject.renderedThreads.map((thread) => thread.id), + ); +} + +export function resolveAdjacentThreadId(input: { + threadIds: readonly T[]; + currentThreadId: T | null; + direction: ThreadTraversalDirection; +}): T | null { + const { currentThreadId, direction, threadIds } = input; + + if (threadIds.length === 0) { + return null; + } + + if (currentThreadId === null) { + return direction === "previous" ? (threadIds.at(-1) ?? null) : (threadIds[0] ?? null); + } + + const currentIndex = threadIds.indexOf(currentThreadId); + if (currentIndex === -1) { + return null; + } + + if (direction === "previous") { + return currentIndex > 0 ? (threadIds[currentIndex - 1] ?? null) : null; + } + + return currentIndex < threadIds.length - 1 ? (threadIds[currentIndex + 1] ?? null) : null; +} + export function isContextMenuPointerDown(input: { button: number; ctrlKey: boolean; diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index d6ef9fc819..89640ed5d2 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -53,9 +53,17 @@ import { } from "@t3tools/contracts/settings"; import { isElectron } from "../env"; import { APP_STAGE_LABEL, APP_VERSION } from "../branding"; +import { isTerminalFocused } from "../lib/terminalFocus"; import { isLinuxPlatform, isMacPlatform, newCommandId, newProjectId } from "../lib/utils"; import { useStore } from "../store"; -import { shortcutLabelForCommand } from "../keybindings"; +import { + resolveShortcutCommand, + shortcutLabelForCommand, + shouldShowThreadJumpHints, + threadJumpCommandForIndex, + threadJumpIndexFromCommand, + threadTraversalDirectionFromCommand, +} from "../keybindings"; import { derivePendingApprovals, derivePendingUserInputs } from "../session-logic"; import { gitStatusQueryOptions } from "../lib/gitReactQuery"; import { serverConfigQueryOptions } from "../lib/serverReactQuery"; @@ -100,7 +108,9 @@ import { import { useThreadSelectionStore } from "../threadSelectionStore"; import { isNonEmpty as isNonEmptyString } from "effect/String"; import { + getVisibleSidebarThreadIds, getVisibleThreadsForProject, + resolveAdjacentThreadId, isContextMenuPointerDown, resolveProjectStatusIndicator, resolveSidebarNewThreadEnvMode, @@ -353,6 +363,7 @@ export default function Sidebar() { const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState< ReadonlySet >(() => new Set()); + const [showThreadJumpHints, setShowThreadJumpHints] = useState(false); const renamingCommittedRef = useRef(false); const renamingInputRef = useRef(null); const dragInProgressRef = useRef(false); @@ -366,12 +377,26 @@ export default function Sidebar() { const removeFromSelection = useThreadSelectionStore((s) => s.removeFromSelection); const setSelectionAnchor = useThreadSelectionStore((s) => s.setAnchor); const isLinuxDesktop = isElectron && isLinuxPlatform(navigator.platform); + const platform = navigator.platform; const shouldBrowseForProjectImmediately = isElectron && !isLinuxDesktop; const shouldShowProjectPathEntry = addingProject && !shouldBrowseForProjectImmediately; const projectCwdById = useMemo( () => new Map(projects.map((project) => [project.id, project.cwd] as const)), [projects], ); + const routeTerminalOpen = routeThreadId + ? selectThreadTerminalState(terminalStateByThreadId, routeThreadId).terminalOpen + : false; + const sidebarShortcutLabelOptions = useMemo( + () => ({ + platform, + context: { + terminalFocus: false, + terminalOpen: routeTerminalOpen, + }, + }), + [platform, routeTerminalOpen], + ); const threadGitTargets = useMemo( () => threads.map((thread) => ({ @@ -823,6 +848,20 @@ export default function Sidebar() { ], ); + const navigateToThread = useCallback( + (threadId: ThreadId) => { + if (selectedThreadIds.size > 0) { + clearSelection(); + } + setSelectionAnchor(threadId); + void navigate({ + to: "/$threadId", + params: { threadId }, + }); + }, + [clearSelection, navigate, selectedThreadIds.size, setSelectionAnchor], + ); + const handleProjectContextMenu = useCallback( async (projectId: ProjectId, position: { x: number; y: number }) => { const api = readNativeApi(); @@ -979,45 +1018,201 @@ export default function Sidebar() { [appSettings.sidebarProjectSortOrder, projects, visibleThreads], ); const isManualProjectSorting = appSettings.sidebarProjectSortOrder === "manual"; + const renderedProjects = useMemo( + () => + sortedProjects.map((project) => { + const projectThreads = sortThreadsForSidebar( + visibleThreads.filter((thread) => thread.projectId === project.id), + appSettings.sidebarThreadSortOrder, + ); + const projectStatus = resolveProjectStatusIndicator( + projectThreads.map((thread) => + resolveThreadStatusPill({ + thread, + hasPendingApprovals: derivePendingApprovals(thread.activities).length > 0, + hasPendingUserInput: derivePendingUserInputs(thread.activities).length > 0, + }), + ), + ); + const activeThreadId = routeThreadId ?? undefined; + const isThreadListExpanded = expandedThreadListsByProject.has(project.id); + const pinnedCollapsedThread = + !project.expanded && activeThreadId + ? (projectThreads.find((thread) => thread.id === activeThreadId) ?? null) + : null; + const shouldShowThreadPanel = project.expanded || pinnedCollapsedThread !== null; + const { hasHiddenThreads, visibleThreads: visibleProjectThreads } = + getVisibleThreadsForProject({ + threads: projectThreads, + activeThreadId, + isThreadListExpanded, + previewLimit: THREAD_PREVIEW_LIMIT, + }); + const orderedProjectThreadIds = projectThreads.map((thread) => thread.id); + const renderedThreads = pinnedCollapsedThread + ? [pinnedCollapsedThread] + : visibleProjectThreads; + + return { + hasHiddenThreads, + orderedProjectThreadIds, + project, + projectStatus, + projectThreads, + renderedThreads, + shouldShowThreadPanel, + isThreadListExpanded, + }; + }), + [ + appSettings.sidebarThreadSortOrder, + expandedThreadListsByProject, + routeThreadId, + sortedProjects, + visibleThreads, + ], + ); + const threadJumpCommandById = useMemo(() => { + const mapping = new Map>>(); + let visibleThreadIndex = 0; + + for (const renderedProject of renderedProjects) { + for (const thread of renderedProject.renderedThreads) { + const jumpCommand = threadJumpCommandForIndex(visibleThreadIndex); + if (!jumpCommand) { + return mapping; + } + mapping.set(thread.id, jumpCommand); + visibleThreadIndex += 1; + } + } + + return mapping; + }, [renderedProjects]); + const threadJumpThreadIds = useMemo( + () => [...threadJumpCommandById.keys()], + [threadJumpCommandById], + ); + const threadJumpLabelById = useMemo(() => { + const mapping = new Map(); + for (const [threadId, command] of threadJumpCommandById) { + const label = shortcutLabelForCommand(keybindings, command, sidebarShortcutLabelOptions); + if (label) { + mapping.set(threadId, label); + } + } + return mapping; + }, [keybindings, sidebarShortcutLabelOptions, threadJumpCommandById]); + const orderedSidebarThreadIds = useMemo( + () => getVisibleSidebarThreadIds(renderedProjects), + [renderedProjects], + ); + + useEffect(() => { + const getShortcutContext = () => ({ + terminalFocus: isTerminalFocused(), + terminalOpen: routeTerminalOpen, + }); + + const onWindowKeyDown = (event: KeyboardEvent) => { + setShowThreadJumpHints( + shouldShowThreadJumpHints(event, keybindings, { + platform, + context: getShortcutContext(), + }), + ); + + if (event.defaultPrevented || event.repeat) { + return; + } + + const command = resolveShortcutCommand(event, keybindings, { + platform, + context: getShortcutContext(), + }); + const traversalDirection = threadTraversalDirectionFromCommand(command); + if (traversalDirection !== null) { + const targetThreadId = resolveAdjacentThreadId({ + threadIds: orderedSidebarThreadIds, + currentThreadId: routeThreadId, + direction: traversalDirection, + }); + if (!targetThreadId) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + navigateToThread(targetThreadId); + return; + } + + const jumpIndex = threadJumpIndexFromCommand(command ?? ""); + if (jumpIndex === null) { + return; + } + + const targetThreadId = threadJumpThreadIds[jumpIndex]; + if (!targetThreadId) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + navigateToThread(targetThreadId); + }; + + const onWindowKeyUp = (event: KeyboardEvent) => { + setShowThreadJumpHints( + shouldShowThreadJumpHints(event, keybindings, { + platform, + context: getShortcutContext(), + }), + ); + }; + + const onWindowBlur = () => { + setShowThreadJumpHints(false); + }; + + window.addEventListener("keydown", onWindowKeyDown); + window.addEventListener("keyup", onWindowKeyUp); + window.addEventListener("blur", onWindowBlur); + + return () => { + window.removeEventListener("keydown", onWindowKeyDown); + window.removeEventListener("keyup", onWindowKeyUp); + window.removeEventListener("blur", onWindowBlur); + }; + }, [ + keybindings, + navigateToThread, + orderedSidebarThreadIds, + platform, + routeTerminalOpen, + routeThreadId, + threadJumpThreadIds, + ]); function renderProjectItem( - project: (typeof sortedProjects)[number], + renderedProject: (typeof renderedProjects)[number], dragHandleProps: SortableProjectHandleProps | null, ) { - const projectThreads = sortThreadsForSidebar( - visibleThreads.filter((thread) => thread.projectId === project.id), - appSettings.sidebarThreadSortOrder, - ); - const projectStatus = resolveProjectStatusIndicator( - projectThreads.map((thread) => - resolveThreadStatusPill({ - thread, - hasPendingApprovals: derivePendingApprovals(thread.activities).length > 0, - hasPendingUserInput: derivePendingUserInputs(thread.activities).length > 0, - }), - ), - ); - const activeThreadId = routeThreadId ?? undefined; - const isThreadListExpanded = expandedThreadListsByProject.has(project.id); - const pinnedCollapsedThread = - !project.expanded && activeThreadId - ? (projectThreads.find((thread) => thread.id === activeThreadId) ?? null) - : null; - const shouldShowThreadPanel = project.expanded || pinnedCollapsedThread !== null; - const { hasHiddenThreads, visibleThreads: visibleProjectThreads } = getVisibleThreadsForProject( - { - threads: projectThreads, - activeThreadId, - isThreadListExpanded, - previewLimit: THREAD_PREVIEW_LIMIT, - }, - ); - const orderedProjectThreadIds = projectThreads.map((thread) => thread.id); - const renderedThreads = pinnedCollapsedThread ? [pinnedCollapsedThread] : visibleProjectThreads; + const { + hasHiddenThreads, + orderedProjectThreadIds, + project, + projectStatus, + projectThreads, + renderedThreads, + shouldShowThreadPanel, + isThreadListExpanded, + } = renderedProject; const renderThreadRow = (thread: (typeof projectThreads)[number]) => { const isActive = routeThreadId === thread.id; const isSelected = selectedThreadIds.has(thread.id); const isHighlighted = isActive || isSelected; + const jumpLabel = threadJumpLabelById.get(thread.id) ?? null; const isThreadRunning = thread.session?.status === "running" && thread.session.activeTurnId != null; const threadStatus = resolveThreadStatusPill({ @@ -1057,14 +1252,7 @@ export default function Sidebar() { onKeyDown={(event) => { if (event.key !== "Enter" && event.key !== " ") return; event.preventDefault(); - if (selectedThreadIds.size > 0) { - clearSelection(); - } - setSelectionAnchor(thread.id); - void navigate({ - to: "/$threadId", - params: { threadId: thread.id }, - }); + navigateToThread(thread.id); }} onContextMenu={(event) => { event.preventDefault(); @@ -1234,15 +1422,26 @@ export default function Sidebar() { ) ) : ( - - {formatRelativeTimeLabel(thread.updatedAt ?? thread.createdAt)} - + <> + {showThreadJumpHints && jumpLabel ? ( + + {jumpLabel} + + ) : ( + + {formatRelativeTimeLabel(thread.updatedAt ?? thread.createdAt)} + + )} + )} @@ -1490,8 +1689,8 @@ export default function Sidebar() { ? "text-rose-500 animate-pulse" : "text-amber-500 animate-pulse"; const newThreadShortcutLabel = - shortcutLabelForCommand(keybindings, "chat.newLocal") ?? - shortcutLabelForCommand(keybindings, "chat.new"); + shortcutLabelForCommand(keybindings, "chat.newLocal", sidebarShortcutLabelOptions) ?? + shortcutLabelForCommand(keybindings, "chat.new", sidebarShortcutLabelOptions); const handleDesktopUpdateButtonClick = useCallback(() => { const bridge = window.desktopBridge; @@ -1773,12 +1972,15 @@ export default function Sidebar() { > project.id)} + items={renderedProjects.map((renderedProject) => renderedProject.project.id)} strategy={verticalListSortingStrategy} > - {sortedProjects.map((project) => ( - - {(dragHandleProps) => renderProjectItem(project, dragHandleProps)} + {renderedProjects.map((renderedProject) => ( + + {(dragHandleProps) => renderProjectItem(renderedProject, dragHandleProps)} ))} @@ -1786,9 +1988,9 @@ export default function Sidebar() { ) : ( - {sortedProjects.map((project) => ( - - {renderProjectItem(project, null)} + {renderedProjects.map((renderedProject) => ( + + {renderProjectItem(renderedProject, null)} ))} diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index 0ecccf43f8..eba0bd3b46 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -18,8 +18,12 @@ import { isTerminalSplitShortcut, isTerminalToggleShortcut, resolveShortcutCommand, + shouldShowThreadJumpHints, shortcutLabelForCommand, terminalNavigationShortcutData, + threadJumpCommandForIndex, + threadJumpIndexFromCommand, + threadTraversalDirectionFromCommand, type ShortcutEventLike, } from "./keybindings"; @@ -100,6 +104,11 @@ const DEFAULT_BINDINGS = compile([ { shortcut: modShortcut("o", { shiftKey: true }), command: "chat.new" }, { shortcut: modShortcut("n", { shiftKey: true }), command: "chat.newLocal" }, { shortcut: modShortcut("o"), command: "editor.openFavorite" }, + { shortcut: modShortcut("[", { shiftKey: true }), command: "thread.previous" }, + { shortcut: modShortcut("]", { shiftKey: true }), command: "thread.next" }, + { shortcut: modShortcut("1"), command: "thread.jump.1" }, + { shortcut: modShortcut("2"), command: "thread.jump.2" }, + { shortcut: modShortcut("3"), command: "thread.jump.3" }, ]); describe("isTerminalToggleShortcut", () => { @@ -215,7 +224,7 @@ describe("split/new/close terminal shortcuts", () => { }); describe("shortcutLabelForCommand", () => { - it("returns the most recent binding label", () => { + it("returns the effective binding label", () => { const bindings = compile([ { shortcut: modShortcut("\\"), @@ -229,18 +238,107 @@ describe("shortcutLabelForCommand", () => { }, ]); assert.strictEqual( - shortcutLabelForCommand(bindings, "terminal.split", "Linux"), + shortcutLabelForCommand(bindings, "terminal.split", { + platform: "Linux", + context: { terminalFocus: false }, + }), "Ctrl+Shift+\\", ); }); - it("returns labels for non-terminal commands", () => { + it("returns effective labels for non-terminal commands", () => { assert.strictEqual(shortcutLabelForCommand(DEFAULT_BINDINGS, "chat.new", "MacIntel"), "⇧⌘O"); assert.strictEqual(shortcutLabelForCommand(DEFAULT_BINDINGS, "diff.toggle", "Linux"), "Ctrl+D"); assert.strictEqual( shortcutLabelForCommand(DEFAULT_BINDINGS, "editor.openFavorite", "Linux"), "Ctrl+O", ); + assert.strictEqual( + shortcutLabelForCommand(DEFAULT_BINDINGS, "thread.jump.3", "MacIntel"), + "⌘3", + ); + assert.strictEqual( + shortcutLabelForCommand(DEFAULT_BINDINGS, "thread.previous", "Linux"), + "Ctrl+Shift+[", + ); + }); + + it("returns null for commands shadowed by a later conflicting shortcut", () => { + const bindings = compile([ + { shortcut: modShortcut("1", { shiftKey: true }), command: "thread.jump.1" }, + { shortcut: modShortcut("1", { shiftKey: true }), command: "thread.jump.7" }, + ]); + + assert.isNull(shortcutLabelForCommand(bindings, "thread.jump.1", "MacIntel")); + assert.strictEqual(shortcutLabelForCommand(bindings, "thread.jump.7", "MacIntel"), "⇧⌘1"); + }); + + it("respects when-context while resolving labels", () => { + const bindings = compile([ + { shortcut: modShortcut("d"), command: "diff.toggle" }, + { + shortcut: modShortcut("d"), + command: "terminal.split", + whenAst: whenIdentifier("terminalFocus"), + }, + ]); + + assert.strictEqual( + shortcutLabelForCommand(bindings, "diff.toggle", { + platform: "Linux", + context: { terminalFocus: false }, + }), + "Ctrl+D", + ); + assert.isNull( + shortcutLabelForCommand(bindings, "diff.toggle", { + platform: "Linux", + context: { terminalFocus: true }, + }), + ); + assert.strictEqual( + shortcutLabelForCommand(bindings, "terminal.split", { + platform: "Linux", + context: { terminalFocus: true }, + }), + "Ctrl+D", + ); + }); +}); + +describe("thread navigation helpers", () => { + it("maps jump commands to visible thread indices", () => { + assert.strictEqual(threadJumpCommandForIndex(0), "thread.jump.1"); + assert.strictEqual(threadJumpCommandForIndex(2), "thread.jump.3"); + assert.isNull(threadJumpCommandForIndex(9)); + assert.strictEqual(threadJumpIndexFromCommand("thread.jump.1"), 0); + assert.strictEqual(threadJumpIndexFromCommand("thread.jump.3"), 2); + assert.isNull(threadJumpIndexFromCommand("thread.next")); + }); + + it("maps traversal commands to directions", () => { + assert.strictEqual(threadTraversalDirectionFromCommand("thread.previous"), "previous"); + assert.strictEqual(threadTraversalDirectionFromCommand("thread.next"), "next"); + assert.isNull(threadTraversalDirectionFromCommand("thread.jump.1")); + assert.isNull(threadTraversalDirectionFromCommand(null)); + }); + + it("shows jump hints only when configured modifiers match", () => { + assert.isTrue( + shouldShowThreadJumpHints(event({ metaKey: true }), DEFAULT_BINDINGS, { + platform: "MacIntel", + }), + ); + assert.isFalse( + shouldShowThreadJumpHints(event({ metaKey: true, shiftKey: true }), DEFAULT_BINDINGS, { + platform: "MacIntel", + }), + ); + assert.isTrue( + shouldShowThreadJumpHints(event({ ctrlKey: true }), DEFAULT_BINDINGS, { + platform: "Linux", + }), + ); }); }); @@ -373,6 +471,29 @@ describe("resolveShortcutCommand", () => { "script.setup.run", ); }); + + it("matches bracket shortcuts using the physical key code", () => { + assert.strictEqual( + resolveShortcutCommand( + event({ key: "{", code: "BracketLeft", metaKey: true, shiftKey: true }), + DEFAULT_BINDINGS, + { + platform: "MacIntel", + }, + ), + "thread.previous", + ); + assert.strictEqual( + resolveShortcutCommand( + event({ key: "}", code: "BracketRight", ctrlKey: true, shiftKey: true }), + DEFAULT_BINDINGS, + { + platform: "Linux", + }, + ), + "thread.next", + ); + }); }); describe("formatShortcutLabel", () => { diff --git a/apps/web/src/keybindings.ts b/apps/web/src/keybindings.ts index 09d9308aad..286454dc05 100644 --- a/apps/web/src/keybindings.ts +++ b/apps/web/src/keybindings.ts @@ -3,11 +3,14 @@ import { type KeybindingShortcut, type KeybindingWhenNode, type ResolvedKeybindingsConfig, + THREAD_JUMP_KEYBINDING_COMMANDS, + type ThreadJumpKeybindingCommand, } from "@t3tools/contracts"; import { isMacPlatform } from "./lib/utils"; export interface ShortcutEventLike { type?: string; + code?: string; key: string; metaKey: boolean; ctrlKey: boolean; @@ -26,10 +29,28 @@ interface ShortcutMatchOptions { context?: Partial; } +interface ResolvedShortcutLabelOptions extends ShortcutMatchOptions { + platform?: string; +} + const TERMINAL_WORD_BACKWARD = "\u001bb"; const TERMINAL_WORD_FORWARD = "\u001bf"; const TERMINAL_LINE_START = "\u0001"; const TERMINAL_LINE_END = "\u0005"; +const EVENT_CODE_KEY_ALIASES: Readonly> = { + BracketLeft: ["["], + BracketRight: ["]"], + Digit0: ["0"], + Digit1: ["1"], + Digit2: ["2"], + Digit3: ["3"], + Digit4: ["4"], + Digit5: ["5"], + Digit6: ["6"], + Digit7: ["7"], + Digit8: ["8"], + Digit9: ["9"], +}; function normalizeEventKey(key: string): string { const normalized = key.toLowerCase(); @@ -37,14 +58,22 @@ function normalizeEventKey(key: string): string { return normalized; } -function matchesShortcut( +function resolveEventKeys(event: ShortcutEventLike): Set { + const keys = new Set([normalizeEventKey(event.key)]); + const aliases = event.code ? EVENT_CODE_KEY_ALIASES[event.code] : undefined; + if (!aliases) return keys; + + for (const alias of aliases) { + keys.add(alias); + } + return keys; +} + +function matchesShortcutModifiers( event: ShortcutEventLike, shortcut: KeybindingShortcut, platform = navigator.platform, ): boolean { - const key = normalizeEventKey(event.key); - if (key !== shortcut.key) return false; - const useMetaForMod = isMacPlatform(platform); const expectedMeta = shortcut.metaKey || (shortcut.modKey && useMetaForMod); const expectedCtrl = shortcut.ctrlKey || (shortcut.modKey && !useMetaForMod); @@ -56,6 +85,15 @@ function matchesShortcut( ); } +function matchesShortcut( + event: ShortcutEventLike, + shortcut: KeybindingShortcut, + platform = navigator.platform, +): boolean { + if (!matchesShortcutModifiers(event, shortcut, platform)) return false; + return resolveEventKeys(event).has(shortcut.key); +} + function resolvePlatform(options: ShortcutMatchOptions | undefined): string { return options?.platform ?? navigator.platform; } @@ -91,6 +129,48 @@ function matchesWhenClause( return evaluateWhenNode(whenAst, context); } +function shortcutConflictKey(shortcut: KeybindingShortcut, platform = navigator.platform): string { + const useMetaForMod = isMacPlatform(platform); + const metaKey = shortcut.metaKey || (shortcut.modKey && useMetaForMod); + const ctrlKey = shortcut.ctrlKey || (shortcut.modKey && !useMetaForMod); + + return [ + shortcut.key, + metaKey ? "meta" : "", + ctrlKey ? "ctrl" : "", + shortcut.shiftKey ? "shift" : "", + shortcut.altKey ? "alt" : "", + ].join("|"); +} + +function findEffectiveShortcutForCommand( + keybindings: ResolvedKeybindingsConfig, + command: KeybindingCommand, + options?: ShortcutMatchOptions, +): KeybindingShortcut | null { + const platform = resolvePlatform(options); + const context = resolveContext(options); + const claimedShortcuts = new Set(); + + for (let index = keybindings.length - 1; index >= 0; index -= 1) { + const binding = keybindings[index]; + if (!binding) continue; + if (!matchesWhenClause(binding.whenAst, context)) continue; + + const conflictKey = shortcutConflictKey(binding.shortcut, platform); + if (claimedShortcuts.has(conflictKey)) { + continue; + } + + claimedShortcuts.add(conflictKey); + if (binding.command === command) { + return binding.shortcut; + } + } + + return null; +} + function matchesCommandShortcut( event: ShortcutEventLike, keybindings: ResolvedKeybindingsConfig, @@ -104,7 +184,7 @@ export function resolveShortcutCommand( event: ShortcutEventLike, keybindings: ResolvedKeybindingsConfig, options?: ShortcutMatchOptions, -): string | null { +): KeybindingCommand | null { const platform = resolvePlatform(options); const context = resolveContext(options); @@ -156,16 +236,52 @@ export function formatShortcutLabel( export function shortcutLabelForCommand( keybindings: ResolvedKeybindingsConfig, command: KeybindingCommand, - platform = navigator.platform, + options?: string | ResolvedShortcutLabelOptions, ): string | null { - for (let index = keybindings.length - 1; index >= 0; index -= 1) { - const binding = keybindings[index]; - if (!binding || binding.command !== command) continue; - return formatShortcutLabel(binding.shortcut, platform); - } + const resolvedOptions = + typeof options === "string" + ? ({ platform: options } satisfies ResolvedShortcutLabelOptions) + : options; + const platform = resolvePlatform(resolvedOptions); + const shortcut = findEffectiveShortcutForCommand(keybindings, command, resolvedOptions); + return shortcut ? formatShortcutLabel(shortcut, platform) : null; +} + +export function threadJumpCommandForIndex(index: number): ThreadJumpKeybindingCommand | null { + return THREAD_JUMP_KEYBINDING_COMMANDS[index] ?? null; +} + +export function threadJumpIndexFromCommand(command: string): number | null { + const index = THREAD_JUMP_KEYBINDING_COMMANDS.indexOf(command as ThreadJumpKeybindingCommand); + return index === -1 ? null : index; +} + +export function threadTraversalDirectionFromCommand( + command: string | null, +): "previous" | "next" | null { + if (command === "thread.previous") return "previous"; + if (command === "thread.next") return "next"; return null; } +export function shouldShowThreadJumpHints( + event: ShortcutEventLike, + keybindings: ResolvedKeybindingsConfig, + options?: ShortcutMatchOptions, +): boolean { + const platform = resolvePlatform(options); + + for (const command of THREAD_JUMP_KEYBINDING_COMMANDS) { + const shortcut = findEffectiveShortcutForCommand(keybindings, command, options); + if (!shortcut) continue; + if (matchesShortcutModifiers(event, shortcut, platform)) { + return true; + } + } + + return false; +} + export function isTerminalToggleShortcut( event: ShortcutEventLike, keybindings: ResolvedKeybindingsConfig, diff --git a/packages/contracts/src/keybindings.test.ts b/packages/contracts/src/keybindings.test.ts index 1b99362c53..c3a7d9f00e 100644 --- a/packages/contracts/src/keybindings.test.ts +++ b/packages/contracts/src/keybindings.test.ts @@ -46,6 +46,12 @@ it.effect("parses keybinding rules", () => command: "chat.newLocal", }); assert.strictEqual(parsedLocal.command, "chat.newLocal"); + + const parsedThreadPrevious = yield* decode(KeybindingRule, { + key: "mod+shift+[", + command: "thread.previous", + }); + assert.strictEqual(parsedThreadPrevious.command, "thread.previous"); }), ); @@ -120,8 +126,19 @@ it.effect("parses resolved keybindings arrays", () => modKey: true, }, }, + { + command: "thread.jump.3", + shortcut: { + key: "3", + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: false, + modKey: true, + }, + }, ]); - assert.lengthOf(parsed, 1); + assert.lengthOf(parsed, 2); }), ); diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index 48821b1824..067cba8804 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -7,6 +7,26 @@ export const MAX_WHEN_EXPRESSION_DEPTH = 64; export const MAX_SCRIPT_ID_LENGTH = 24; export const MAX_KEYBINDINGS_COUNT = 256; +export const THREAD_JUMP_KEYBINDING_COMMANDS = [ + "thread.jump.1", + "thread.jump.2", + "thread.jump.3", + "thread.jump.4", + "thread.jump.5", + "thread.jump.6", + "thread.jump.7", + "thread.jump.8", + "thread.jump.9", +] as const; +export type ThreadJumpKeybindingCommand = (typeof THREAD_JUMP_KEYBINDING_COMMANDS)[number]; + +export const THREAD_KEYBINDING_COMMANDS = [ + "thread.previous", + "thread.next", + ...THREAD_JUMP_KEYBINDING_COMMANDS, +] as const; +export type ThreadKeybindingCommand = (typeof THREAD_KEYBINDING_COMMANDS)[number]; + const STATIC_KEYBINDING_COMMANDS = [ "terminal.toggle", "terminal.split", @@ -16,6 +36,7 @@ const STATIC_KEYBINDING_COMMANDS = [ "chat.new", "chat.newLocal", "editor.openFavorite", + ...THREAD_KEYBINDING_COMMANDS, ] as const; export const SCRIPT_RUN_COMMAND_PATTERN = Schema.TemplateLiteral([