| author | |
| committer | |
| log | 58ba4f12633ef05f7a8ab707cba4cd7ccaad0d58 |
| tree | ac847e05ac0eaac9a77075c7de40d6e51b84ee27 |
| parent | 5513845fd56acf7a019d24e72fc4e11bf5654f50 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Julius Marminge <julius0216@outlook.com>10 files changed, 713 insertions(+), 81 deletions(-)
apps/server/src/keybindings.test.ts+13| ... | ... | @@ -165,6 +165,19 @@ it.layer(NodeServices.layer)("keybindings", (it) => { |
| 165 | 165 | }).pipe(Effect.provide(makeKeybindingsLayer())), |
| 166 | 166 | ); |
| 167 | 167 | |
| 168 | it.effect("ships configurable thread navigation defaults", () => | |
| 169 | Effect.sync(() => { | |
| 170 | const defaultsByCommand = new Map( | |
| 171 | DEFAULT_KEYBINDINGS.map((binding) => [binding.command, binding.key] as const), | |
| 172 | ); | |
| 173 | ||
| 174 | assert.equal(defaultsByCommand.get("thread.previous"), "mod+shift+["); | |
| 175 | assert.equal(defaultsByCommand.get("thread.next"), "mod+shift+]"); | |
| 176 | assert.equal(defaultsByCommand.get("thread.jump.1"), "mod+1"); | |
| 177 | assert.equal(defaultsByCommand.get("thread.jump.9"), "mod+9"); | |
| 178 | }), | |
| 179 | ); | |
| 180 | ||
| 168 | 181 | it.effect("uses defaults in runtime when config is malformed without overriding file", () => |
| 169 | 182 | Effect.gen(function* () { |
| 170 | 183 | const fs = yield* FileSystem.FileSystem; |
apps/server/src/keybindings.ts+7| ... | ... | @@ -15,6 +15,7 @@ import { |
| 15 | 15 | MAX_WHEN_EXPRESSION_DEPTH, |
| 16 | 16 | ResolvedKeybindingRule, |
| 17 | 17 | ResolvedKeybindingsConfig, |
| 18 | THREAD_JUMP_KEYBINDING_COMMANDS, | |
| 18 | 19 | type ServerConfigIssue, |
| 19 | 20 | } from "@t3tools/contracts"; |
| 20 | 21 | import { Mutable } from "effect/Types"; |
| ... | ... | @@ -76,6 +77,12 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [ |
| 76 | 77 | { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" }, |
| 77 | 78 | { key: "mod+shift+n", command: "chat.newLocal", when: "!terminalFocus" }, |
| 78 | 79 | { key: "mod+o", command: "editor.openFavorite" }, |
| 80 | { key: "mod+shift+[", command: "thread.previous" }, | |
| 81 | { key: "mod+shift+]", command: "thread.next" }, | |
| 82 | ...THREAD_JUMP_KEYBINDING_COMMANDS.map((command, index) => ({ | |
| 83 | key: `mod+${index + 1}`, | |
| 84 | command, | |
| 85 | })), | |
| 79 | 86 | ]; |
| 80 | 87 | |
| 81 | 88 | function normalizeKeyToken(token: string): string { |
apps/web/src/components/ChatView.tsx+26-8| ... | ... | @@ -1162,25 +1162,43 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1162 | 1162 | }, [activeProjectCwd, activeThreadWorktreePath]); |
| 1163 | 1163 | // Default true while loading to avoid toolbar flicker. |
| 1164 | 1164 | const isGitRepo = branchesQuery.data?.isRepo ?? true; |
| 1165 | const terminalShortcutLabelOptions = useMemo( | |
| 1166 | () => ({ | |
| 1167 | context: { | |
| 1168 | terminalFocus: true, | |
| 1169 | terminalOpen: Boolean(terminalState.terminalOpen), | |
| 1170 | }, | |
| 1171 | }), | |
| 1172 | [terminalState.terminalOpen], | |
| 1173 | ); | |
| 1174 | const nonTerminalShortcutLabelOptions = useMemo( | |
| 1175 | () => ({ | |
| 1176 | context: { | |
| 1177 | terminalFocus: false, | |
| 1178 | terminalOpen: Boolean(terminalState.terminalOpen), | |
| 1179 | }, | |
| 1180 | }), | |
| 1181 | [terminalState.terminalOpen], | |
| 1182 | ); | |
| 1165 | 1183 | const terminalToggleShortcutLabel = useMemo( |
| 1166 | 1184 | () => shortcutLabelForCommand(keybindings, "terminal.toggle"), |
| 1167 | 1185 | [keybindings], |
| 1168 | 1186 | ); |
| 1169 | 1187 | const splitTerminalShortcutLabel = useMemo( |
| 1170 | () => shortcutLabelForCommand(keybindings, "terminal.split"), | |
| 1171 | [keybindings], | |
| 1188 | () => shortcutLabelForCommand(keybindings, "terminal.split", terminalShortcutLabelOptions), | |
| 1189 | [keybindings, terminalShortcutLabelOptions], | |
| 1172 | 1190 | ); |
| 1173 | 1191 | const newTerminalShortcutLabel = useMemo( |
| 1174 | () => shortcutLabelForCommand(keybindings, "terminal.new"), | |
| 1175 | [keybindings], | |
| 1192 | () => shortcutLabelForCommand(keybindings, "terminal.new", terminalShortcutLabelOptions), | |
| 1193 | [keybindings, terminalShortcutLabelOptions], | |
| 1176 | 1194 | ); |
| 1177 | 1195 | const closeTerminalShortcutLabel = useMemo( |
| 1178 | () => shortcutLabelForCommand(keybindings, "terminal.close"), | |
| 1179 | [keybindings], | |
| 1196 | () => shortcutLabelForCommand(keybindings, "terminal.close", terminalShortcutLabelOptions), | |
| 1197 | [keybindings, terminalShortcutLabelOptions], | |
| 1180 | 1198 | ); |
| 1181 | 1199 | const diffPanelShortcutLabel = useMemo( |
| 1182 | () => shortcutLabelForCommand(keybindings, "diff.toggle"), | |
| 1183 | [keybindings], | |
| 1200 | () => shortcutLabelForCommand(keybindings, "diff.toggle", nonTerminalShortcutLabelOptions), | |
| 1201 | [keybindings, nonTerminalShortcutLabelOptions], | |
| 1184 | 1202 | ); |
| 1185 | 1203 | const onToggleDiff = useCallback(() => { |
| 1186 | 1204 | void navigate({ |
apps/web/src/components/Sidebar.logic.test.ts+76| ... | ... | @@ -1,6 +1,8 @@ |
| 1 | 1 | import { describe, expect, it } from "vitest"; |
| 2 | 2 | |
| 3 | 3 | import { |
| 4 | getVisibleSidebarThreadIds, | |
| 5 | resolveAdjacentThreadId, | |
| 4 | 6 | getFallbackThreadIdAfterDelete, |
| 5 | 7 | getVisibleThreadsForProject, |
| 6 | 8 | getProjectSortTimestamp, |
| ... | ... | @@ -97,6 +99,80 @@ describe("resolveSidebarNewThreadEnvMode", () => { |
| 97 | 99 | }); |
| 98 | 100 | }); |
| 99 | 101 | |
| 102 | describe("resolveAdjacentThreadId", () => { | |
| 103 | it("resolves adjacent thread ids in ordered sidebar traversal", () => { | |
| 104 | const threads = [ | |
| 105 | ThreadId.makeUnsafe("thread-1"), | |
| 106 | ThreadId.makeUnsafe("thread-2"), | |
| 107 | ThreadId.makeUnsafe("thread-3"), | |
| 108 | ]; | |
| 109 | ||
| 110 | expect( | |
| 111 | resolveAdjacentThreadId({ | |
| 112 | threadIds: threads, | |
| 113 | currentThreadId: threads[1] ?? null, | |
| 114 | direction: "previous", | |
| 115 | }), | |
| 116 | ).toBe(threads[0]); | |
| 117 | expect( | |
| 118 | resolveAdjacentThreadId({ | |
| 119 | threadIds: threads, | |
| 120 | currentThreadId: threads[1] ?? null, | |
| 121 | direction: "next", | |
| 122 | }), | |
| 123 | ).toBe(threads[2]); | |
| 124 | expect( | |
| 125 | resolveAdjacentThreadId({ | |
| 126 | threadIds: threads, | |
| 127 | currentThreadId: null, | |
| 128 | direction: "next", | |
| 129 | }), | |
| 130 | ).toBe(threads[0]); | |
| 131 | expect( | |
| 132 | resolveAdjacentThreadId({ | |
| 133 | threadIds: threads, | |
| 134 | currentThreadId: null, | |
| 135 | direction: "previous", | |
| 136 | }), | |
| 137 | ).toBe(threads[2]); | |
| 138 | expect( | |
| 139 | resolveAdjacentThreadId({ | |
| 140 | threadIds: threads, | |
| 141 | currentThreadId: threads[0] ?? null, | |
| 142 | direction: "previous", | |
| 143 | }), | |
| 144 | ).toBeNull(); | |
| 145 | }); | |
| 146 | }); | |
| 147 | ||
| 148 | describe("getVisibleSidebarThreadIds", () => { | |
| 149 | it("returns only the rendered visible thread order across projects", () => { | |
| 150 | expect( | |
| 151 | getVisibleSidebarThreadIds([ | |
| 152 | { | |
| 153 | renderedThreads: [ | |
| 154 | { id: ThreadId.makeUnsafe("thread-12") }, | |
| 155 | { id: ThreadId.makeUnsafe("thread-11") }, | |
| 156 | { id: ThreadId.makeUnsafe("thread-10") }, | |
| 157 | ], | |
| 158 | }, | |
| 159 | { | |
| 160 | renderedThreads: [ | |
| 161 | { id: ThreadId.makeUnsafe("thread-8") }, | |
| 162 | { id: ThreadId.makeUnsafe("thread-6") }, | |
| 163 | ], | |
| 164 | }, | |
| 165 | ]), | |
| 166 | ).toEqual([ | |
| 167 | ThreadId.makeUnsafe("thread-12"), | |
| 168 | ThreadId.makeUnsafe("thread-11"), | |
| 169 | ThreadId.makeUnsafe("thread-10"), | |
| 170 | ThreadId.makeUnsafe("thread-8"), | |
| 171 | ThreadId.makeUnsafe("thread-6"), | |
| 172 | ]); | |
| 173 | }); | |
| 174 | }); | |
| 175 | ||
| 100 | 176 | describe("isContextMenuPointerDown", () => { |
| 101 | 177 | it("treats secondary-button presses as context menu gestures on all platforms", () => { |
| 102 | 178 | expect( |
apps/web/src/components/Sidebar.logic.ts+41| ... | ... | @@ -17,6 +17,8 @@ type SidebarProject = { |
| 17 | 17 | }; |
| 18 | 18 | type SidebarThreadSortInput = Pick<Thread, "createdAt" | "updatedAt" | "messages">; |
| 19 | 19 | |
| 20 | export type ThreadTraversalDirection = "previous" | "next"; | |
| 21 | ||
| 20 | 22 | export interface ThreadStatusPill { |
| 21 | 23 | label: |
| 22 | 24 | | "Working" |
| ... | ... | @@ -67,6 +69,45 @@ export function resolveSidebarNewThreadEnvMode(input: { |
| 67 | 69 | return input.requestedEnvMode ?? input.defaultEnvMode; |
| 68 | 70 | } |
| 69 | 71 | |
| 72 | export function getVisibleSidebarThreadIds<TThreadId>( | |
| 73 | renderedProjects: readonly { | |
| 74 | renderedThreads: readonly { | |
| 75 | id: TThreadId; | |
| 76 | }[]; | |
| 77 | }[], | |
| 78 | ): TThreadId[] { | |
| 79 | return renderedProjects.flatMap((renderedProject) => | |
| 80 | renderedProject.renderedThreads.map((thread) => thread.id), | |
| 81 | ); | |
| 82 | } | |
| 83 | ||
| 84 | export function resolveAdjacentThreadId<T>(input: { | |
| 85 | threadIds: readonly T[]; | |
| 86 | currentThreadId: T | null; | |
| 87 | direction: ThreadTraversalDirection; | |
| 88 | }): T | null { | |
| 89 | const { currentThreadId, direction, threadIds } = input; | |
| 90 | ||
| 91 | if (threadIds.length === 0) { | |
| 92 | return null; | |
| 93 | } | |
| 94 | ||
| 95 | if (currentThreadId === null) { | |
| 96 | return direction === "previous" ? (threadIds.at(-1) ?? null) : (threadIds[0] ?? null); | |
| 97 | } | |
| 98 | ||
| 99 | const currentIndex = threadIds.indexOf(currentThreadId); | |
| 100 | if (currentIndex === -1) { | |
| 101 | return null; | |
| 102 | } | |
| 103 | ||
| 104 | if (direction === "previous") { | |
| 105 | return currentIndex > 0 ? (threadIds[currentIndex - 1] ?? null) : null; | |
| 106 | } | |
| 107 | ||
| 108 | return currentIndex < threadIds.length - 1 ? (threadIds[currentIndex + 1] ?? null) : null; | |
| 109 | } | |
| 110 | ||
| 70 | 111 | export function isContextMenuPointerDown(input: { |
| 71 | 112 | button: number; |
| 72 | 113 | ctrlKey: boolean; |
apps/web/src/components/Sidebar.tsx+260-58| ... | ... | @@ -53,9 +53,17 @@ import { |
| 53 | 53 | } from "@t3tools/contracts/settings"; |
| 54 | 54 | import { isElectron } from "../env"; |
| 55 | 55 | import { APP_STAGE_LABEL, APP_VERSION } from "../branding"; |
| 56 | import { isTerminalFocused } from "../lib/terminalFocus"; | |
| 56 | 57 | import { isLinuxPlatform, isMacPlatform, newCommandId, newProjectId } from "../lib/utils"; |
| 57 | 58 | import { useStore } from "../store"; |
| 58 | import { shortcutLabelForCommand } from "../keybindings"; | |
| 59 | import { | |
| 60 | resolveShortcutCommand, | |
| 61 | shortcutLabelForCommand, | |
| 62 | shouldShowThreadJumpHints, | |
| 63 | threadJumpCommandForIndex, | |
| 64 | threadJumpIndexFromCommand, | |
| 65 | threadTraversalDirectionFromCommand, | |
| 66 | } from "../keybindings"; | |
| 59 | 67 | import { derivePendingApprovals, derivePendingUserInputs } from "../session-logic"; |
| 60 | 68 | import { gitStatusQueryOptions } from "../lib/gitReactQuery"; |
| 61 | 69 | import { serverConfigQueryOptions } from "../lib/serverReactQuery"; |
| ... | ... | @@ -100,7 +108,9 @@ import { |
| 100 | 108 | import { useThreadSelectionStore } from "../threadSelectionStore"; |
| 101 | 109 | import { isNonEmpty as isNonEmptyString } from "effect/String"; |
| 102 | 110 | import { |
| 111 | getVisibleSidebarThreadIds, | |
| 103 | 112 | getVisibleThreadsForProject, |
| 113 | resolveAdjacentThreadId, | |
| 104 | 114 | isContextMenuPointerDown, |
| 105 | 115 | resolveProjectStatusIndicator, |
| 106 | 116 | resolveSidebarNewThreadEnvMode, |
| ... | ... | @@ -353,6 +363,7 @@ export default function Sidebar() { |
| 353 | 363 | const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState< |
| 354 | 364 | ReadonlySet<ProjectId> |
| 355 | 365 | >(() => new Set()); |
| 366 | const [showThreadJumpHints, setShowThreadJumpHints] = useState(false); | |
| 356 | 367 | const renamingCommittedRef = useRef(false); |
| 357 | 368 | const renamingInputRef = useRef<HTMLInputElement | null>(null); |
| 358 | 369 | const dragInProgressRef = useRef(false); |
| ... | ... | @@ -366,12 +377,26 @@ export default function Sidebar() { |
| 366 | 377 | const removeFromSelection = useThreadSelectionStore((s) => s.removeFromSelection); |
| 367 | 378 | const setSelectionAnchor = useThreadSelectionStore((s) => s.setAnchor); |
| 368 | 379 | const isLinuxDesktop = isElectron && isLinuxPlatform(navigator.platform); |
| 380 | const platform = navigator.platform; | |
| 369 | 381 | const shouldBrowseForProjectImmediately = isElectron && !isLinuxDesktop; |
| 370 | 382 | const shouldShowProjectPathEntry = addingProject && !shouldBrowseForProjectImmediately; |
| 371 | 383 | const projectCwdById = useMemo( |
| 372 | 384 | () => new Map(projects.map((project) => [project.id, project.cwd] as const)), |
| 373 | 385 | [projects], |
| 374 | 386 | ); |
| 387 | const routeTerminalOpen = routeThreadId | |
| 388 | ? selectThreadTerminalState(terminalStateByThreadId, routeThreadId).terminalOpen | |
| 389 | : false; | |
| 390 | const sidebarShortcutLabelOptions = useMemo( | |
| 391 | () => ({ | |
| 392 | platform, | |
| 393 | context: { | |
| 394 | terminalFocus: false, | |
| 395 | terminalOpen: routeTerminalOpen, | |
| 396 | }, | |
| 397 | }), | |
| 398 | [platform, routeTerminalOpen], | |
| 399 | ); | |
| 375 | 400 | const threadGitTargets = useMemo( |
| 376 | 401 | () => |
| 377 | 402 | threads.map((thread) => ({ |
| ... | ... | @@ -823,6 +848,20 @@ export default function Sidebar() { |
| 823 | 848 | ], |
| 824 | 849 | ); |
| 825 | 850 | |
| 851 | const navigateToThread = useCallback( | |
| 852 | (threadId: ThreadId) => { | |
| 853 | if (selectedThreadIds.size > 0) { | |
| 854 | clearSelection(); | |
| 855 | } | |
| 856 | setSelectionAnchor(threadId); | |
| 857 | void navigate({ | |
| 858 | to: "/$threadId", | |
| 859 | params: { threadId }, | |
| 860 | }); | |
| 861 | }, | |
| 862 | [clearSelection, navigate, selectedThreadIds.size, setSelectionAnchor], | |
| 863 | ); | |
| 864 | ||
| 826 | 865 | const handleProjectContextMenu = useCallback( |
| 827 | 866 | async (projectId: ProjectId, position: { x: number; y: number }) => { |
| 828 | 867 | const api = readNativeApi(); |
| ... | ... | @@ -979,45 +1018,201 @@ export default function Sidebar() { |
| 979 | 1018 | [appSettings.sidebarProjectSortOrder, projects, visibleThreads], |
| 980 | 1019 | ); |
| 981 | 1020 | const isManualProjectSorting = appSettings.sidebarProjectSortOrder === "manual"; |
| 1021 | const renderedProjects = useMemo( | |
| 1022 | () => | |
| 1023 | sortedProjects.map((project) => { | |
| 1024 | const projectThreads = sortThreadsForSidebar( | |
| 1025 | visibleThreads.filter((thread) => thread.projectId === project.id), | |
| 1026 | appSettings.sidebarThreadSortOrder, | |
| 1027 | ); | |
| 1028 | const projectStatus = resolveProjectStatusIndicator( | |
| 1029 | projectThreads.map((thread) => | |
| 1030 | resolveThreadStatusPill({ | |
| 1031 | thread, | |
| 1032 | hasPendingApprovals: derivePendingApprovals(thread.activities).length > 0, | |
| 1033 | hasPendingUserInput: derivePendingUserInputs(thread.activities).length > 0, | |
| 1034 | }), | |
| 1035 | ), | |
| 1036 | ); | |
| 1037 | const activeThreadId = routeThreadId ?? undefined; | |
| 1038 | const isThreadListExpanded = expandedThreadListsByProject.has(project.id); | |
| 1039 | const pinnedCollapsedThread = | |
| 1040 | !project.expanded && activeThreadId | |
| 1041 | ? (projectThreads.find((thread) => thread.id === activeThreadId) ?? null) | |
| 1042 | : null; | |
| 1043 | const shouldShowThreadPanel = project.expanded || pinnedCollapsedThread !== null; | |
| 1044 | const { hasHiddenThreads, visibleThreads: visibleProjectThreads } = | |
| 1045 | getVisibleThreadsForProject({ | |
| 1046 | threads: projectThreads, | |
| 1047 | activeThreadId, | |
| 1048 | isThreadListExpanded, | |
| 1049 | previewLimit: THREAD_PREVIEW_LIMIT, | |
| 1050 | }); | |
| 1051 | const orderedProjectThreadIds = projectThreads.map((thread) => thread.id); | |
| 1052 | const renderedThreads = pinnedCollapsedThread | |
| 1053 | ? [pinnedCollapsedThread] | |
| 1054 | : visibleProjectThreads; | |
| 1055 | ||
| 1056 | return { | |
| 1057 | hasHiddenThreads, | |
| 1058 | orderedProjectThreadIds, | |
| 1059 | project, | |
| 1060 | projectStatus, | |
| 1061 | projectThreads, | |
| 1062 | renderedThreads, | |
| 1063 | shouldShowThreadPanel, | |
| 1064 | isThreadListExpanded, | |
| 1065 | }; | |
| 1066 | }), | |
| 1067 | [ | |
| 1068 | appSettings.sidebarThreadSortOrder, | |
| 1069 | expandedThreadListsByProject, | |
| 1070 | routeThreadId, | |
| 1071 | sortedProjects, | |
| 1072 | visibleThreads, | |
| 1073 | ], | |
| 1074 | ); | |
| 1075 | const threadJumpCommandById = useMemo(() => { | |
| 1076 | const mapping = new Map<ThreadId, NonNullable<ReturnType<typeof threadJumpCommandForIndex>>>(); | |
| 1077 | let visibleThreadIndex = 0; | |
| 1078 | ||
| 1079 | for (const renderedProject of renderedProjects) { | |
| 1080 | for (const thread of renderedProject.renderedThreads) { | |
| 1081 | const jumpCommand = threadJumpCommandForIndex(visibleThreadIndex); | |
| 1082 | if (!jumpCommand) { | |
| 1083 | return mapping; | |
| 1084 | } | |
| 1085 | mapping.set(thread.id, jumpCommand); | |
| 1086 | visibleThreadIndex += 1; | |
| 1087 | } | |
| 1088 | } | |
| 1089 | ||
| 1090 | return mapping; | |
| 1091 | }, [renderedProjects]); | |
| 1092 | const threadJumpThreadIds = useMemo( | |
| 1093 | () => [...threadJumpCommandById.keys()], | |
| 1094 | [threadJumpCommandById], | |
| 1095 | ); | |
| 1096 | const threadJumpLabelById = useMemo(() => { | |
| 1097 | const mapping = new Map<ThreadId, string>(); | |
| 1098 | for (const [threadId, command] of threadJumpCommandById) { | |
| 1099 | const label = shortcutLabelForCommand(keybindings, command, sidebarShortcutLabelOptions); | |
| 1100 | if (label) { | |
| 1101 | mapping.set(threadId, label); | |
| 1102 | } | |
| 1103 | } | |
| 1104 | return mapping; | |
| 1105 | }, [keybindings, sidebarShortcutLabelOptions, threadJumpCommandById]); | |
| 1106 | const orderedSidebarThreadIds = useMemo( | |
| 1107 | () => getVisibleSidebarThreadIds(renderedProjects), | |
| 1108 | [renderedProjects], | |
| 1109 | ); | |
| 1110 | ||
| 1111 | useEffect(() => { | |
| 1112 | const getShortcutContext = () => ({ | |
| 1113 | terminalFocus: isTerminalFocused(), | |
| 1114 | terminalOpen: routeTerminalOpen, | |
| 1115 | }); | |
| 1116 | ||
| 1117 | const onWindowKeyDown = (event: KeyboardEvent) => { | |
| 1118 | setShowThreadJumpHints( | |
| 1119 | shouldShowThreadJumpHints(event, keybindings, { | |
| 1120 | platform, | |
| 1121 | context: getShortcutContext(), | |
| 1122 | }), | |
| 1123 | ); | |
| 1124 | ||
| 1125 | if (event.defaultPrevented || event.repeat) { | |
| 1126 | return; | |
| 1127 | } | |
| 1128 | ||
| 1129 | const command = resolveShortcutCommand(event, keybindings, { | |
| 1130 | platform, | |
| 1131 | context: getShortcutContext(), | |
| 1132 | }); | |
| 1133 | const traversalDirection = threadTraversalDirectionFromCommand(command); | |
| 1134 | if (traversalDirection !== null) { | |
| 1135 | const targetThreadId = resolveAdjacentThreadId({ | |
| 1136 | threadIds: orderedSidebarThreadIds, | |
| 1137 | currentThreadId: routeThreadId, | |
| 1138 | direction: traversalDirection, | |
| 1139 | }); | |
| 1140 | if (!targetThreadId) { | |
| 1141 | return; | |
| 1142 | } | |
| 1143 | ||
| 1144 | event.preventDefault(); | |
| 1145 | event.stopPropagation(); | |
| 1146 | navigateToThread(targetThreadId); | |
| 1147 | return; | |
| 1148 | } | |
| 1149 | ||
| 1150 | const jumpIndex = threadJumpIndexFromCommand(command ?? ""); | |
| 1151 | if (jumpIndex === null) { | |
| 1152 | return; | |
| 1153 | } | |
| 1154 | ||
| 1155 | const targetThreadId = threadJumpThreadIds[jumpIndex]; | |
| 1156 | if (!targetThreadId) { | |
| 1157 | return; | |
| 1158 | } | |
| 1159 | ||
| 1160 | event.preventDefault(); | |
| 1161 | event.stopPropagation(); | |
| 1162 | navigateToThread(targetThreadId); | |
| 1163 | }; | |
| 1164 | ||
| 1165 | const onWindowKeyUp = (event: KeyboardEvent) => { | |
| 1166 | setShowThreadJumpHints( | |
| 1167 | shouldShowThreadJumpHints(event, keybindings, { | |
| 1168 | platform, | |
| 1169 | context: getShortcutContext(), | |
| 1170 | }), | |
| 1171 | ); | |
| 1172 | }; | |
| 1173 | ||
| 1174 | const onWindowBlur = () => { | |
| 1175 | setShowThreadJumpHints(false); | |
| 1176 | }; | |
| 1177 | ||
| 1178 | window.addEventListener("keydown", onWindowKeyDown); | |
| 1179 | window.addEventListener("keyup", onWindowKeyUp); | |
| 1180 | window.addEventListener("blur", onWindowBlur); | |
| 1181 | ||
| 1182 | return () => { | |
| 1183 | window.removeEventListener("keydown", onWindowKeyDown); | |
| 1184 | window.removeEventListener("keyup", onWindowKeyUp); | |
| 1185 | window.removeEventListener("blur", onWindowBlur); | |
| 1186 | }; | |
| 1187 | }, [ | |
| 1188 | keybindings, | |
| 1189 | navigateToThread, | |
| 1190 | orderedSidebarThreadIds, | |
| 1191 | platform, | |
| 1192 | routeTerminalOpen, | |
| 1193 | routeThreadId, | |
| 1194 | threadJumpThreadIds, | |
| 1195 | ]); | |
| 982 | 1196 | |
| 983 | 1197 | function renderProjectItem( |
| 984 | project: (typeof sortedProjects)[number], | |
| 1198 | renderedProject: (typeof renderedProjects)[number], | |
| 985 | 1199 | dragHandleProps: SortableProjectHandleProps | null, |
| 986 | 1200 | ) { |
| 987 | const projectThreads = sortThreadsForSidebar( | |
| 988 | visibleThreads.filter((thread) => thread.projectId === project.id), | |
| 989 | appSettings.sidebarThreadSortOrder, | |
| 990 | ); | |
| 991 | const projectStatus = resolveProjectStatusIndicator( | |
| 992 | projectThreads.map((thread) => | |
| 993 | resolveThreadStatusPill({ | |
| 994 | thread, | |
| 995 | hasPendingApprovals: derivePendingApprovals(thread.activities).length > 0, | |
| 996 | hasPendingUserInput: derivePendingUserInputs(thread.activities).length > 0, | |
| 997 | }), | |
| 998 | ), | |
| 999 | ); | |
| 1000 | const activeThreadId = routeThreadId ?? undefined; | |
| 1001 | const isThreadListExpanded = expandedThreadListsByProject.has(project.id); | |
| 1002 | const pinnedCollapsedThread = | |
| 1003 | !project.expanded && activeThreadId | |
| 1004 | ? (projectThreads.find((thread) => thread.id === activeThreadId) ?? null) | |
| 1005 | : null; | |
| 1006 | const shouldShowThreadPanel = project.expanded || pinnedCollapsedThread !== null; | |
| 1007 | const { hasHiddenThreads, visibleThreads: visibleProjectThreads } = getVisibleThreadsForProject( | |
| 1008 | { | |
| 1009 | threads: projectThreads, | |
| 1010 | activeThreadId, | |
| 1011 | isThreadListExpanded, | |
| 1012 | previewLimit: THREAD_PREVIEW_LIMIT, | |
| 1013 | }, | |
| 1014 | ); | |
| 1015 | const orderedProjectThreadIds = projectThreads.map((thread) => thread.id); | |
| 1016 | const renderedThreads = pinnedCollapsedThread ? [pinnedCollapsedThread] : visibleProjectThreads; | |
| 1201 | const { | |
| 1202 | hasHiddenThreads, | |
| 1203 | orderedProjectThreadIds, | |
| 1204 | project, | |
| 1205 | projectStatus, | |
| 1206 | projectThreads, | |
| 1207 | renderedThreads, | |
| 1208 | shouldShowThreadPanel, | |
| 1209 | isThreadListExpanded, | |
| 1210 | } = renderedProject; | |
| 1017 | 1211 | const renderThreadRow = (thread: (typeof projectThreads)[number]) => { |
| 1018 | 1212 | const isActive = routeThreadId === thread.id; |
| 1019 | 1213 | const isSelected = selectedThreadIds.has(thread.id); |
| 1020 | 1214 | const isHighlighted = isActive || isSelected; |
| 1215 | const jumpLabel = threadJumpLabelById.get(thread.id) ?? null; | |
| 1021 | 1216 | const isThreadRunning = |
| 1022 | 1217 | thread.session?.status === "running" && thread.session.activeTurnId != null; |
| 1023 | 1218 | const threadStatus = resolveThreadStatusPill({ |
| ... | ... | @@ -1057,14 +1252,7 @@ export default function Sidebar() { |
| 1057 | 1252 | onKeyDown={(event) => { |
| 1058 | 1253 | if (event.key !== "Enter" && event.key !== " ") return; |
| 1059 | 1254 | event.preventDefault(); |
| 1060 | if (selectedThreadIds.size > 0) { | |
| 1061 | clearSelection(); | |
| 1062 | } | |
| 1063 | setSelectionAnchor(thread.id); | |
| 1064 | void navigate({ | |
| 1065 | to: "/$threadId", | |
| 1066 | params: { threadId: thread.id }, | |
| 1067 | }); | |
| 1255 | navigateToThread(thread.id); | |
| 1068 | 1256 | }} |
| 1069 | 1257 | onContextMenu={(event) => { |
| 1070 | 1258 | event.preventDefault(); |
| ... | ... | @@ -1234,15 +1422,26 @@ export default function Sidebar() { |
| 1234 | 1422 | </Tooltip> |
| 1235 | 1423 | ) |
| 1236 | 1424 | ) : ( |
| 1237 | <span | |
| 1238 | className={`text-[10px] ${ | |
| 1239 | isHighlighted | |
| 1240 | ? "text-foreground/72 dark:text-foreground/82" | |
| 1241 | : "text-muted-foreground/40" | |
| 1242 | }`} | |
| 1243 | > | |
| 1244 | {formatRelativeTimeLabel(thread.updatedAt ?? thread.createdAt)} | |
| 1245 | </span> | |
| 1425 | <> | |
| 1426 | {showThreadJumpHints && jumpLabel ? ( | |
| 1427 | <span | |
| 1428 | className="inline-flex h-5 items-center rounded-full border border-border/80 bg-background/90 px-1.5 font-mono text-[10px] font-medium tracking-tight text-foreground shadow-sm" | |
| 1429 | title={jumpLabel} | |
| 1430 | > | |
| 1431 | {jumpLabel} | |
| 1432 | </span> | |
| 1433 | ) : ( | |
| 1434 | <span | |
| 1435 | className={`text-[10px] ${ | |
| 1436 | isHighlighted | |
| 1437 | ? "text-foreground/72 dark:text-foreground/82" | |
| 1438 | : "text-muted-foreground/40" | |
| 1439 | }`} | |
| 1440 | > | |
| 1441 | {formatRelativeTimeLabel(thread.updatedAt ?? thread.createdAt)} | |
| 1442 | </span> | |
| 1443 | )} | |
| 1444 | </> | |
| 1246 | 1445 | )} |
| 1247 | 1446 | </div> |
| 1248 | 1447 | </div> |
| ... | ... | @@ -1490,8 +1689,8 @@ export default function Sidebar() { |
| 1490 | 1689 | ? "text-rose-500 animate-pulse" |
| 1491 | 1690 | : "text-amber-500 animate-pulse"; |
| 1492 | 1691 | const newThreadShortcutLabel = |
| 1493 | shortcutLabelForCommand(keybindings, "chat.newLocal") ?? | |
| 1494 | shortcutLabelForCommand(keybindings, "chat.new"); | |
| 1692 | shortcutLabelForCommand(keybindings, "chat.newLocal", sidebarShortcutLabelOptions) ?? | |
| 1693 | shortcutLabelForCommand(keybindings, "chat.new", sidebarShortcutLabelOptions); | |
| 1495 | 1694 | |
| 1496 | 1695 | const handleDesktopUpdateButtonClick = useCallback(() => { |
| 1497 | 1696 | const bridge = window.desktopBridge; |
| ... | ... | @@ -1773,12 +1972,15 @@ export default function Sidebar() { |
| 1773 | 1972 | > |
| 1774 | 1973 | <SidebarMenu> |
| 1775 | 1974 | <SortableContext |
| 1776 | items={sortedProjects.map((project) => project.id)} | |
| 1975 | items={renderedProjects.map((renderedProject) => renderedProject.project.id)} | |
| 1777 | 1976 | strategy={verticalListSortingStrategy} |
| 1778 | 1977 | > |
| 1779 | {sortedProjects.map((project) => ( | |
| 1780 | <SortableProjectItem key={project.id} projectId={project.id}> | |
| 1781 | {(dragHandleProps) => renderProjectItem(project, dragHandleProps)} | |
| 1978 | {renderedProjects.map((renderedProject) => ( | |
| 1979 | <SortableProjectItem | |
| 1980 | key={renderedProject.project.id} | |
| 1981 | projectId={renderedProject.project.id} | |
| 1982 | > | |
| 1983 | {(dragHandleProps) => renderProjectItem(renderedProject, dragHandleProps)} | |
| 1782 | 1984 | </SortableProjectItem> |
| 1783 | 1985 | ))} |
| 1784 | 1986 | </SortableContext> |
| ... | ... | @@ -1786,9 +1988,9 @@ export default function Sidebar() { |
| 1786 | 1988 | </DndContext> |
| 1787 | 1989 | ) : ( |
| 1788 | 1990 | <SidebarMenu ref={attachProjectListAutoAnimateRef}> |
| 1789 | {sortedProjects.map((project) => ( | |
| 1790 | <SidebarMenuItem key={project.id} className="rounded-md"> | |
| 1791 | {renderProjectItem(project, null)} | |
| 1991 | {renderedProjects.map((renderedProject) => ( | |
| 1992 | <SidebarMenuItem key={renderedProject.project.id} className="rounded-md"> | |
| 1993 | {renderProjectItem(renderedProject, null)} | |
| 1792 | 1994 | </SidebarMenuItem> |
| 1793 | 1995 | ))} |
| 1794 | 1996 | </SidebarMenu> |
apps/web/src/keybindings.test.ts+124-3| ... | ... | @@ -18,8 +18,12 @@ import { |
| 18 | 18 | isTerminalSplitShortcut, |
| 19 | 19 | isTerminalToggleShortcut, |
| 20 | 20 | resolveShortcutCommand, |
| 21 | shouldShowThreadJumpHints, | |
| 21 | 22 | shortcutLabelForCommand, |
| 22 | 23 | terminalNavigationShortcutData, |
| 24 | threadJumpCommandForIndex, | |
| 25 | threadJumpIndexFromCommand, | |
| 26 | threadTraversalDirectionFromCommand, | |
| 23 | 27 | type ShortcutEventLike, |
| 24 | 28 | } from "./keybindings"; |
| 25 | 29 | |
| ... | ... | @@ -100,6 +104,11 @@ const DEFAULT_BINDINGS = compile([ |
| 100 | 104 | { shortcut: modShortcut("o", { shiftKey: true }), command: "chat.new" }, |
| 101 | 105 | { shortcut: modShortcut("n", { shiftKey: true }), command: "chat.newLocal" }, |
| 102 | 106 | { shortcut: modShortcut("o"), command: "editor.openFavorite" }, |
| 107 | { shortcut: modShortcut("[", { shiftKey: true }), command: "thread.previous" }, | |
| 108 | { shortcut: modShortcut("]", { shiftKey: true }), command: "thread.next" }, | |
| 109 | { shortcut: modShortcut("1"), command: "thread.jump.1" }, | |
| 110 | { shortcut: modShortcut("2"), command: "thread.jump.2" }, | |
| 111 | { shortcut: modShortcut("3"), command: "thread.jump.3" }, | |
| 103 | 112 | ]); |
| 104 | 113 | |
| 105 | 114 | describe("isTerminalToggleShortcut", () => { |
| ... | ... | @@ -215,7 +224,7 @@ describe("split/new/close terminal shortcuts", () => { |
| 215 | 224 | }); |
| 216 | 225 | |
| 217 | 226 | describe("shortcutLabelForCommand", () => { |
| 218 | it("returns the most recent binding label", () => { | |
| 227 | it("returns the effective binding label", () => { | |
| 219 | 228 | const bindings = compile([ |
| 220 | 229 | { |
| 221 | 230 | shortcut: modShortcut("\\"), |
| ... | ... | @@ -229,18 +238,107 @@ describe("shortcutLabelForCommand", () => { |
| 229 | 238 | }, |
| 230 | 239 | ]); |
| 231 | 240 | assert.strictEqual( |
| 232 | shortcutLabelForCommand(bindings, "terminal.split", "Linux"), | |
| 241 | shortcutLabelForCommand(bindings, "terminal.split", { | |
| 242 | platform: "Linux", | |
| 243 | context: { terminalFocus: false }, | |
| 244 | }), | |
| 233 | 245 | "Ctrl+Shift+\\", |
| 234 | 246 | ); |
| 235 | 247 | }); |
| 236 | 248 | |
| 237 | it("returns labels for non-terminal commands", () => { | |
| 249 | it("returns effective labels for non-terminal commands", () => { | |
| 238 | 250 | assert.strictEqual(shortcutLabelForCommand(DEFAULT_BINDINGS, "chat.new", "MacIntel"), "⇧⌘O"); |
| 239 | 251 | assert.strictEqual(shortcutLabelForCommand(DEFAULT_BINDINGS, "diff.toggle", "Linux"), "Ctrl+D"); |
| 240 | 252 | assert.strictEqual( |
| 241 | 253 | shortcutLabelForCommand(DEFAULT_BINDINGS, "editor.openFavorite", "Linux"), |
| 242 | 254 | "Ctrl+O", |
| 243 | 255 | ); |
| 256 | assert.strictEqual( | |
| 257 | shortcutLabelForCommand(DEFAULT_BINDINGS, "thread.jump.3", "MacIntel"), | |
| 258 | "⌘3", | |
| 259 | ); | |
| 260 | assert.strictEqual( | |
| 261 | shortcutLabelForCommand(DEFAULT_BINDINGS, "thread.previous", "Linux"), | |
| 262 | "Ctrl+Shift+[", | |
| 263 | ); | |
| 264 | }); | |
| 265 | ||
| 266 | it("returns null for commands shadowed by a later conflicting shortcut", () => { | |
| 267 | const bindings = compile([ | |
| 268 | { shortcut: modShortcut("1", { shiftKey: true }), command: "thread.jump.1" }, | |
| 269 | { shortcut: modShortcut("1", { shiftKey: true }), command: "thread.jump.7" }, | |
| 270 | ]); | |
| 271 | ||
| 272 | assert.isNull(shortcutLabelForCommand(bindings, "thread.jump.1", "MacIntel")); | |
| 273 | assert.strictEqual(shortcutLabelForCommand(bindings, "thread.jump.7", "MacIntel"), "⇧⌘1"); | |
| 274 | }); | |
| 275 | ||
| 276 | it("respects when-context while resolving labels", () => { | |
| 277 | const bindings = compile([ | |
| 278 | { shortcut: modShortcut("d"), command: "diff.toggle" }, | |
| 279 | { | |
| 280 | shortcut: modShortcut("d"), | |
| 281 | command: "terminal.split", | |
| 282 | whenAst: whenIdentifier("terminalFocus"), | |
| 283 | }, | |
| 284 | ]); | |
| 285 | ||
| 286 | assert.strictEqual( | |
| 287 | shortcutLabelForCommand(bindings, "diff.toggle", { | |
| 288 | platform: "Linux", | |
| 289 | context: { terminalFocus: false }, | |
| 290 | }), | |
| 291 | "Ctrl+D", | |
| 292 | ); | |
| 293 | assert.isNull( | |
| 294 | shortcutLabelForCommand(bindings, "diff.toggle", { | |
| 295 | platform: "Linux", | |
| 296 | context: { terminalFocus: true }, | |
| 297 | }), | |
| 298 | ); | |
| 299 | assert.strictEqual( | |
| 300 | shortcutLabelForCommand(bindings, "terminal.split", { | |
| 301 | platform: "Linux", | |
| 302 | context: { terminalFocus: true }, | |
| 303 | }), | |
| 304 | "Ctrl+D", | |
| 305 | ); | |
| 306 | }); | |
| 307 | }); | |
| 308 | ||
| 309 | describe("thread navigation helpers", () => { | |
| 310 | it("maps jump commands to visible thread indices", () => { | |
| 311 | assert.strictEqual(threadJumpCommandForIndex(0), "thread.jump.1"); | |
| 312 | assert.strictEqual(threadJumpCommandForIndex(2), "thread.jump.3"); | |
| 313 | assert.isNull(threadJumpCommandForIndex(9)); | |
| 314 | assert.strictEqual(threadJumpIndexFromCommand("thread.jump.1"), 0); | |
| 315 | assert.strictEqual(threadJumpIndexFromCommand("thread.jump.3"), 2); | |
| 316 | assert.isNull(threadJumpIndexFromCommand("thread.next")); | |
| 317 | }); | |
| 318 | ||
| 319 | it("maps traversal commands to directions", () => { | |
| 320 | assert.strictEqual(threadTraversalDirectionFromCommand("thread.previous"), "previous"); | |
| 321 | assert.strictEqual(threadTraversalDirectionFromCommand("thread.next"), "next"); | |
| 322 | assert.isNull(threadTraversalDirectionFromCommand("thread.jump.1")); | |
| 323 | assert.isNull(threadTraversalDirectionFromCommand(null)); | |
| 324 | }); | |
| 325 | ||
| 326 | it("shows jump hints only when configured modifiers match", () => { | |
| 327 | assert.isTrue( | |
| 328 | shouldShowThreadJumpHints(event({ metaKey: true }), DEFAULT_BINDINGS, { | |
| 329 | platform: "MacIntel", | |
| 330 | }), | |
| 331 | ); | |
| 332 | assert.isFalse( | |
| 333 | shouldShowThreadJumpHints(event({ metaKey: true, shiftKey: true }), DEFAULT_BINDINGS, { | |
| 334 | platform: "MacIntel", | |
| 335 | }), | |
| 336 | ); | |
| 337 | assert.isTrue( | |
| 338 | shouldShowThreadJumpHints(event({ ctrlKey: true }), DEFAULT_BINDINGS, { | |
| 339 | platform: "Linux", | |
| 340 | }), | |
| 341 | ); | |
| 244 | 342 | }); |
| 245 | 343 | }); |
| 246 | 344 | |
| ... | ... | @@ -373,6 +471,29 @@ describe("resolveShortcutCommand", () => { |
| 373 | 471 | "script.setup.run", |
| 374 | 472 | ); |
| 375 | 473 | }); |
| 474 | ||
| 475 | it("matches bracket shortcuts using the physical key code", () => { | |
| 476 | assert.strictEqual( | |
| 477 | resolveShortcutCommand( | |
| 478 | event({ key: "{", code: "BracketLeft", metaKey: true, shiftKey: true }), | |
| 479 | DEFAULT_BINDINGS, | |
| 480 | { | |
| 481 | platform: "MacIntel", | |
| 482 | }, | |
| 483 | ), | |
| 484 | "thread.previous", | |
| 485 | ); | |
| 486 | assert.strictEqual( | |
| 487 | resolveShortcutCommand( | |
| 488 | event({ key: "}", code: "BracketRight", ctrlKey: true, shiftKey: true }), | |
| 489 | DEFAULT_BINDINGS, | |
| 490 | { | |
| 491 | platform: "Linux", | |
| 492 | }, | |
| 493 | ), | |
| 494 | "thread.next", | |
| 495 | ); | |
| 496 | }); | |
| 376 | 497 | }); |
| 377 | 498 | |
| 378 | 499 | describe("formatShortcutLabel", () => { |
apps/web/src/keybindings.ts+127-11| ... | ... | @@ -3,11 +3,14 @@ import { |
| 3 | 3 | type KeybindingShortcut, |
| 4 | 4 | type KeybindingWhenNode, |
| 5 | 5 | type ResolvedKeybindingsConfig, |
| 6 | THREAD_JUMP_KEYBINDING_COMMANDS, | |
| 7 | type ThreadJumpKeybindingCommand, | |
| 6 | 8 | } from "@t3tools/contracts"; |
| 7 | 9 | import { isMacPlatform } from "./lib/utils"; |
| 8 | 10 | |
| 9 | 11 | export interface ShortcutEventLike { |
| 10 | 12 | type?: string; |
| 13 | code?: string; | |
| 11 | 14 | key: string; |
| 12 | 15 | metaKey: boolean; |
| 13 | 16 | ctrlKey: boolean; |
| ... | ... | @@ -26,10 +29,28 @@ interface ShortcutMatchOptions { |
| 26 | 29 | context?: Partial<ShortcutMatchContext>; |
| 27 | 30 | } |
| 28 | 31 | |
| 32 | interface ResolvedShortcutLabelOptions extends ShortcutMatchOptions { | |
| 33 | platform?: string; | |
| 34 | } | |
| 35 | ||
| 29 | 36 | const TERMINAL_WORD_BACKWARD = "\u001bb"; |
| 30 | 37 | const TERMINAL_WORD_FORWARD = "\u001bf"; |
| 31 | 38 | const TERMINAL_LINE_START = "\u0001"; |
| 32 | 39 | const TERMINAL_LINE_END = "\u0005"; |
| 40 | const EVENT_CODE_KEY_ALIASES: Readonly<Record<string, readonly string[]>> = { | |
| 41 | BracketLeft: ["["], | |
| 42 | BracketRight: ["]"], | |
| 43 | Digit0: ["0"], | |
| 44 | Digit1: ["1"], | |
| 45 | Digit2: ["2"], | |
| 46 | Digit3: ["3"], | |
| 47 | Digit4: ["4"], | |
| 48 | Digit5: ["5"], | |
| 49 | Digit6: ["6"], | |
| 50 | Digit7: ["7"], | |
| 51 | Digit8: ["8"], | |
| 52 | Digit9: ["9"], | |
| 53 | }; | |
| 33 | 54 | |
| 34 | 55 | function normalizeEventKey(key: string): string { |
| 35 | 56 | const normalized = key.toLowerCase(); |
| ... | ... | @@ -37,14 +58,22 @@ function normalizeEventKey(key: string): string { |
| 37 | 58 | return normalized; |
| 38 | 59 | } |
| 39 | 60 | |
| 40 | function matchesShortcut( | |
| 61 | function resolveEventKeys(event: ShortcutEventLike): Set<string> { | |
| 62 | const keys = new Set([normalizeEventKey(event.key)]); | |
| 63 | const aliases = event.code ? EVENT_CODE_KEY_ALIASES[event.code] : undefined; | |
| 64 | if (!aliases) return keys; | |
| 65 | ||
| 66 | for (const alias of aliases) { | |
| 67 | keys.add(alias); | |
| 68 | } | |
| 69 | return keys; | |
| 70 | } | |
| 71 | ||
| 72 | function matchesShortcutModifiers( | |
| 41 | 73 | event: ShortcutEventLike, |
| 42 | 74 | shortcut: KeybindingShortcut, |
| 43 | 75 | platform = navigator.platform, |
| 44 | 76 | ): boolean { |
| 45 | const key = normalizeEventKey(event.key); | |
| 46 | if (key !== shortcut.key) return false; | |
| 47 | ||
| 48 | 77 | const useMetaForMod = isMacPlatform(platform); |
| 49 | 78 | const expectedMeta = shortcut.metaKey || (shortcut.modKey && useMetaForMod); |
| 50 | 79 | const expectedCtrl = shortcut.ctrlKey || (shortcut.modKey && !useMetaForMod); |
| ... | ... | @@ -56,6 +85,15 @@ function matchesShortcut( |
| 56 | 85 | ); |
| 57 | 86 | } |
| 58 | 87 | |
| 88 | function matchesShortcut( | |
| 89 | event: ShortcutEventLike, | |
| 90 | shortcut: KeybindingShortcut, | |
| 91 | platform = navigator.platform, | |
| 92 | ): boolean { | |
| 93 | if (!matchesShortcutModifiers(event, shortcut, platform)) return false; | |
| 94 | return resolveEventKeys(event).has(shortcut.key); | |
| 95 | } | |
| 96 | ||
| 59 | 97 | function resolvePlatform(options: ShortcutMatchOptions | undefined): string { |
| 60 | 98 | return options?.platform ?? navigator.platform; |
| 61 | 99 | } |
| ... | ... | @@ -91,6 +129,48 @@ function matchesWhenClause( |
| 91 | 129 | return evaluateWhenNode(whenAst, context); |
| 92 | 130 | } |
| 93 | 131 | |
| 132 | function shortcutConflictKey(shortcut: KeybindingShortcut, platform = navigator.platform): string { | |
| 133 | const useMetaForMod = isMacPlatform(platform); | |
| 134 | const metaKey = shortcut.metaKey || (shortcut.modKey && useMetaForMod); | |
| 135 | const ctrlKey = shortcut.ctrlKey || (shortcut.modKey && !useMetaForMod); | |
| 136 | ||
| 137 | return [ | |
| 138 | shortcut.key, | |
| 139 | metaKey ? "meta" : "", | |
| 140 | ctrlKey ? "ctrl" : "", | |
| 141 | shortcut.shiftKey ? "shift" : "", | |
| 142 | shortcut.altKey ? "alt" : "", | |
| 143 | ].join("|"); | |
| 144 | } | |
| 145 | ||
| 146 | function findEffectiveShortcutForCommand( | |
| 147 | keybindings: ResolvedKeybindingsConfig, | |
| 148 | command: KeybindingCommand, | |
| 149 | options?: ShortcutMatchOptions, | |
| 150 | ): KeybindingShortcut | null { | |
| 151 | const platform = resolvePlatform(options); | |
| 152 | const context = resolveContext(options); | |
| 153 | const claimedShortcuts = new Set<string>(); | |
| 154 | ||
| 155 | for (let index = keybindings.length - 1; index >= 0; index -= 1) { | |
| 156 | const binding = keybindings[index]; | |
| 157 | if (!binding) continue; | |
| 158 | if (!matchesWhenClause(binding.whenAst, context)) continue; | |
| 159 | ||
| 160 | const conflictKey = shortcutConflictKey(binding.shortcut, platform); | |
| 161 | if (claimedShortcuts.has(conflictKey)) { | |
| 162 | continue; | |
| 163 | } | |
| 164 | ||
| 165 | claimedShortcuts.add(conflictKey); | |
| 166 | if (binding.command === command) { | |
| 167 | return binding.shortcut; | |
| 168 | } | |
| 169 | } | |
| 170 | ||
| 171 | return null; | |
| 172 | } | |
| 173 | ||
| 94 | 174 | function matchesCommandShortcut( |
| 95 | 175 | event: ShortcutEventLike, |
| 96 | 176 | keybindings: ResolvedKeybindingsConfig, |
| ... | ... | @@ -104,7 +184,7 @@ export function resolveShortcutCommand( |
| 104 | 184 | event: ShortcutEventLike, |
| 105 | 185 | keybindings: ResolvedKeybindingsConfig, |
| 106 | 186 | options?: ShortcutMatchOptions, |
| 107 | ): string | null { | |
| 187 | ): KeybindingCommand | null { | |
| 108 | 188 | const platform = resolvePlatform(options); |
| 109 | 189 | const context = resolveContext(options); |
| 110 | 190 | |
| ... | ... | @@ -156,16 +236,52 @@ export function formatShortcutLabel( |
| 156 | 236 | export function shortcutLabelForCommand( |
| 157 | 237 | keybindings: ResolvedKeybindingsConfig, |
| 158 | 238 | command: KeybindingCommand, |
| 159 | platform = navigator.platform, | |
| 239 | options?: string | ResolvedShortcutLabelOptions, | |
| 160 | 240 | ): string | null { |
| 161 | for (let index = keybindings.length - 1; index >= 0; index -= 1) { | |
| 162 | const binding = keybindings[index]; | |
| 163 | if (!binding || binding.command !== command) continue; | |
| 164 | return formatShortcutLabel(binding.shortcut, platform); | |
| 165 | } | |
| 241 | const resolvedOptions = | |
| 242 | typeof options === "string" | |
| 243 | ? ({ platform: options } satisfies ResolvedShortcutLabelOptions) | |
| 244 | : options; | |
| 245 | const platform = resolvePlatform(resolvedOptions); | |
| 246 | const shortcut = findEffectiveShortcutForCommand(keybindings, command, resolvedOptions); | |
| 247 | return shortcut ? formatShortcutLabel(shortcut, platform) : null; | |
| 248 | } | |
| 249 | ||
| 250 | export function threadJumpCommandForIndex(index: number): ThreadJumpKeybindingCommand | null { | |
| 251 | return THREAD_JUMP_KEYBINDING_COMMANDS[index] ?? null; | |
| 252 | } | |
| 253 | ||
| 254 | export function threadJumpIndexFromCommand(command: string): number | null { | |
| 255 | const index = THREAD_JUMP_KEYBINDING_COMMANDS.indexOf(command as ThreadJumpKeybindingCommand); | |
| 256 | return index === -1 ? null : index; | |
| 257 | } | |
| 258 | ||
| 259 | export function threadTraversalDirectionFromCommand( | |
| 260 | command: string | null, | |
| 261 | ): "previous" | "next" | null { | |
| 262 | if (command === "thread.previous") return "previous"; | |
| 263 | if (command === "thread.next") return "next"; | |
| 166 | 264 | return null; |
| 167 | 265 | } |
| 168 | 266 | |
| 267 | export function shouldShowThreadJumpHints( | |
| 268 | event: ShortcutEventLike, | |
| 269 | keybindings: ResolvedKeybindingsConfig, | |
| 270 | options?: ShortcutMatchOptions, | |
| 271 | ): boolean { | |
| 272 | const platform = resolvePlatform(options); | |
| 273 | ||
| 274 | for (const command of THREAD_JUMP_KEYBINDING_COMMANDS) { | |
| 275 | const shortcut = findEffectiveShortcutForCommand(keybindings, command, options); | |
| 276 | if (!shortcut) continue; | |
| 277 | if (matchesShortcutModifiers(event, shortcut, platform)) { | |
| 278 | return true; | |
| 279 | } | |
| 280 | } | |
| 281 | ||
| 282 | return false; | |
| 283 | } | |
| 284 | ||
| 169 | 285 | export function isTerminalToggleShortcut( |
| 170 | 286 | event: ShortcutEventLike, |
| 171 | 287 | keybindings: ResolvedKeybindingsConfig, |
packages/contracts/src/keybindings.test.ts+18-1| ... | ... | @@ -46,6 +46,12 @@ it.effect("parses keybinding rules", () => |
| 46 | 46 | command: "chat.newLocal", |
| 47 | 47 | }); |
| 48 | 48 | assert.strictEqual(parsedLocal.command, "chat.newLocal"); |
| 49 | ||
| 50 | const parsedThreadPrevious = yield* decode(KeybindingRule, { | |
| 51 | key: "mod+shift+[", | |
| 52 | command: "thread.previous", | |
| 53 | }); | |
| 54 | assert.strictEqual(parsedThreadPrevious.command, "thread.previous"); | |
| 49 | 55 | }), |
| 50 | 56 | ); |
| 51 | 57 | |
| ... | ... | @@ -120,8 +126,19 @@ it.effect("parses resolved keybindings arrays", () => |
| 120 | 126 | modKey: true, |
| 121 | 127 | }, |
| 122 | 128 | }, |
| 129 | { | |
| 130 | command: "thread.jump.3", | |
| 131 | shortcut: { | |
| 132 | key: "3", | |
| 133 | metaKey: false, | |
| 134 | ctrlKey: false, | |
| 135 | shiftKey: false, | |
| 136 | altKey: false, | |
| 137 | modKey: true, | |
| 138 | }, | |
| 139 | }, | |
| 123 | 140 | ]); |
| 124 | assert.lengthOf(parsed, 1); | |
| 141 | assert.lengthOf(parsed, 2); | |
| 125 | 142 | }), |
| 126 | 143 | ); |
| 127 | 144 |
packages/contracts/src/keybindings.ts+21| ... | ... | @@ -7,6 +7,26 @@ export const MAX_WHEN_EXPRESSION_DEPTH = 64; |
| 7 | 7 | export const MAX_SCRIPT_ID_LENGTH = 24; |
| 8 | 8 | export const MAX_KEYBINDINGS_COUNT = 256; |
| 9 | 9 | |
| 10 | export const THREAD_JUMP_KEYBINDING_COMMANDS = [ | |
| 11 | "thread.jump.1", | |
| 12 | "thread.jump.2", | |
| 13 | "thread.jump.3", | |
| 14 | "thread.jump.4", | |
| 15 | "thread.jump.5", | |
| 16 | "thread.jump.6", | |
| 17 | "thread.jump.7", | |
| 18 | "thread.jump.8", | |
| 19 | "thread.jump.9", | |
| 20 | ] as const; | |
| 21 | export type ThreadJumpKeybindingCommand = (typeof THREAD_JUMP_KEYBINDING_COMMANDS)[number]; | |
| 22 | ||
| 23 | export const THREAD_KEYBINDING_COMMANDS = [ | |
| 24 | "thread.previous", | |
| 25 | "thread.next", | |
| 26 | ...THREAD_JUMP_KEYBINDING_COMMANDS, | |
| 27 | ] as const; | |
| 28 | export type ThreadKeybindingCommand = (typeof THREAD_KEYBINDING_COMMANDS)[number]; | |
| 29 | ||
| 10 | 30 | const STATIC_KEYBINDING_COMMANDS = [ |
| 11 | 31 | "terminal.toggle", |
| 12 | 32 | "terminal.split", |
| ... | ... | @@ -16,6 +36,7 @@ const STATIC_KEYBINDING_COMMANDS = [ |
| 16 | 36 | "chat.new", |
| 17 | 37 | "chat.newLocal", |
| 18 | 38 | "editor.openFavorite", |
| 39 | ...THREAD_KEYBINDING_COMMANDS, | |
| 19 | 40 | ] as const; |
| 20 | 41 | |
| 21 | 42 | export const SCRIPT_RUN_COMMAND_PATTERN = Schema.TemplateLiteral([ |