authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-03-28 18:23:30-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-03-28 18:23:30-07:00
log58ba4f12633ef05f7a8ab707cba4cd7ccaad0d58
treeac847e05ac0eaac9a77075c7de40d6e51b84ee27
parent5513845fd56acf7a019d24e72fc4e11bf5654f50
signature Signed by PGP key B5690EEEBB952194

Add keyboard shortcuts for jumping to sidebar threads (#1456)

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,6 +165,19 @@ it.layer(NodeServices.layer)("keybindings", (it) => {
165 }).pipe(Effect.provide(makeKeybindingsLayer())),165 }).pipe(Effect.provide(makeKeybindingsLayer())),
166 );166 );
167167
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 it.effect("uses defaults in runtime when config is malformed without overriding file", () =>181 it.effect("uses defaults in runtime when config is malformed without overriding file", () =>
169 Effect.gen(function* () {182 Effect.gen(function* () {
170 const fs = yield* FileSystem.FileSystem;183 const fs = yield* FileSystem.FileSystem;
apps/server/src/keybindings.ts+7
...@@ -15,6 +15,7 @@ import {...@@ -15,6 +15,7 @@ import {
15 MAX_WHEN_EXPRESSION_DEPTH,15 MAX_WHEN_EXPRESSION_DEPTH,
16 ResolvedKeybindingRule,16 ResolvedKeybindingRule,
17 ResolvedKeybindingsConfig,17 ResolvedKeybindingsConfig,
18 THREAD_JUMP_KEYBINDING_COMMANDS,
18 type ServerConfigIssue,19 type ServerConfigIssue,
19} from "@t3tools/contracts";20} from "@t3tools/contracts";
20import { Mutable } from "effect/Types";21import { Mutable } from "effect/Types";
...@@ -76,6 +77,12 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [...@@ -76,6 +77,12 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [
76 { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" },77 { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" },
77 { key: "mod+shift+n", command: "chat.newLocal", when: "!terminalFocus" },78 { key: "mod+shift+n", command: "chat.newLocal", when: "!terminalFocus" },
78 { key: "mod+o", command: "editor.openFavorite" },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];
8087
81function normalizeKeyToken(token: string): string {88function normalizeKeyToken(token: string): string {
apps/web/src/components/ChatView.tsx+26-8
...@@ -1162,25 +1162,43 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1162,25 +1162,43 @@ export default function ChatView({ threadId }: ChatViewProps) {
1162 }, [activeProjectCwd, activeThreadWorktreePath]);1162 }, [activeProjectCwd, activeThreadWorktreePath]);
1163 // Default true while loading to avoid toolbar flicker.1163 // Default true while loading to avoid toolbar flicker.
1164 const isGitRepo = branchesQuery.data?.isRepo ?? true;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 const terminalToggleShortcutLabel = useMemo(1183 const terminalToggleShortcutLabel = useMemo(
1166 () => shortcutLabelForCommand(keybindings, "terminal.toggle"),1184 () => shortcutLabelForCommand(keybindings, "terminal.toggle"),
1167 [keybindings],1185 [keybindings],
1168 );1186 );
1169 const splitTerminalShortcutLabel = useMemo(1187 const splitTerminalShortcutLabel = useMemo(
1170 () => shortcutLabelForCommand(keybindings, "terminal.split"),1188 () => shortcutLabelForCommand(keybindings, "terminal.split", terminalShortcutLabelOptions),
1171 [keybindings],1189 [keybindings, terminalShortcutLabelOptions],
1172 );1190 );
1173 const newTerminalShortcutLabel = useMemo(1191 const newTerminalShortcutLabel = useMemo(
1174 () => shortcutLabelForCommand(keybindings, "terminal.new"),1192 () => shortcutLabelForCommand(keybindings, "terminal.new", terminalShortcutLabelOptions),
1175 [keybindings],1193 [keybindings, terminalShortcutLabelOptions],
1176 );1194 );
1177 const closeTerminalShortcutLabel = useMemo(1195 const closeTerminalShortcutLabel = useMemo(
1178 () => shortcutLabelForCommand(keybindings, "terminal.close"),1196 () => shortcutLabelForCommand(keybindings, "terminal.close", terminalShortcutLabelOptions),
1179 [keybindings],1197 [keybindings, terminalShortcutLabelOptions],
1180 );1198 );
1181 const diffPanelShortcutLabel = useMemo(1199 const diffPanelShortcutLabel = useMemo(
1182 () => shortcutLabelForCommand(keybindings, "diff.toggle"),1200 () => shortcutLabelForCommand(keybindings, "diff.toggle", nonTerminalShortcutLabelOptions),
1183 [keybindings],1201 [keybindings, nonTerminalShortcutLabelOptions],
1184 );1202 );
1185 const onToggleDiff = useCallback(() => {1203 const onToggleDiff = useCallback(() => {
1186 void navigate({1204 void navigate({
apps/web/src/components/Sidebar.logic.test.ts+76
...@@ -1,6 +1,8 @@...@@ -1,6 +1,8 @@
1import { describe, expect, it } from "vitest";1import { describe, expect, it } from "vitest";
22
3import {3import {
4 getVisibleSidebarThreadIds,
5 resolveAdjacentThreadId,
4 getFallbackThreadIdAfterDelete,6 getFallbackThreadIdAfterDelete,
5 getVisibleThreadsForProject,7 getVisibleThreadsForProject,
6 getProjectSortTimestamp,8 getProjectSortTimestamp,
...@@ -97,6 +99,80 @@ describe("resolveSidebarNewThreadEnvMode", () => {...@@ -97,6 +99,80 @@ describe("resolveSidebarNewThreadEnvMode", () => {
97 });99 });
98});100});
99101
102describe("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
148describe("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
100describe("isContextMenuPointerDown", () => {176describe("isContextMenuPointerDown", () => {
101 it("treats secondary-button presses as context menu gestures on all platforms", () => {177 it("treats secondary-button presses as context menu gestures on all platforms", () => {
102 expect(178 expect(
apps/web/src/components/Sidebar.logic.ts+41
...@@ -17,6 +17,8 @@ type SidebarProject = {...@@ -17,6 +17,8 @@ type SidebarProject = {
17};17};
18type SidebarThreadSortInput = Pick<Thread, "createdAt" | "updatedAt" | "messages">;18type SidebarThreadSortInput = Pick<Thread, "createdAt" | "updatedAt" | "messages">;
1919
20export type ThreadTraversalDirection = "previous" | "next";
21
20export interface ThreadStatusPill {22export interface ThreadStatusPill {
21 label:23 label:
22 | "Working"24 | "Working"
...@@ -67,6 +69,45 @@ export function resolveSidebarNewThreadEnvMode(input: {...@@ -67,6 +69,45 @@ export function resolveSidebarNewThreadEnvMode(input: {
67 return input.requestedEnvMode ?? input.defaultEnvMode;69 return input.requestedEnvMode ?? input.defaultEnvMode;
68}70}
6971
72export 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
84export 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
70export function isContextMenuPointerDown(input: {111export function isContextMenuPointerDown(input: {
71 button: number;112 button: number;
72 ctrlKey: boolean;113 ctrlKey: boolean;
apps/web/src/components/Sidebar.tsx+260-58
...@@ -53,9 +53,17 @@ import {...@@ -53,9 +53,17 @@ import {
53} from "@t3tools/contracts/settings";53} from "@t3tools/contracts/settings";
54import { isElectron } from "../env";54import { isElectron } from "../env";
55import { APP_STAGE_LABEL, APP_VERSION } from "../branding";55import { APP_STAGE_LABEL, APP_VERSION } from "../branding";
56import { isTerminalFocused } from "../lib/terminalFocus";
56import { isLinuxPlatform, isMacPlatform, newCommandId, newProjectId } from "../lib/utils";57import { isLinuxPlatform, isMacPlatform, newCommandId, newProjectId } from "../lib/utils";
57import { useStore } from "../store";58import { useStore } from "../store";
58import { shortcutLabelForCommand } from "../keybindings";59import {
60 resolveShortcutCommand,
61 shortcutLabelForCommand,
62 shouldShowThreadJumpHints,
63 threadJumpCommandForIndex,
64 threadJumpIndexFromCommand,
65 threadTraversalDirectionFromCommand,
66} from "../keybindings";
59import { derivePendingApprovals, derivePendingUserInputs } from "../session-logic";67import { derivePendingApprovals, derivePendingUserInputs } from "../session-logic";
60import { gitStatusQueryOptions } from "../lib/gitReactQuery";68import { gitStatusQueryOptions } from "../lib/gitReactQuery";
61import { serverConfigQueryOptions } from "../lib/serverReactQuery";69import { serverConfigQueryOptions } from "../lib/serverReactQuery";
...@@ -100,7 +108,9 @@ import {...@@ -100,7 +108,9 @@ import {
100import { useThreadSelectionStore } from "../threadSelectionStore";108import { useThreadSelectionStore } from "../threadSelectionStore";
101import { isNonEmpty as isNonEmptyString } from "effect/String";109import { isNonEmpty as isNonEmptyString } from "effect/String";
102import {110import {
111 getVisibleSidebarThreadIds,
103 getVisibleThreadsForProject,112 getVisibleThreadsForProject,
113 resolveAdjacentThreadId,
104 isContextMenuPointerDown,114 isContextMenuPointerDown,
105 resolveProjectStatusIndicator,115 resolveProjectStatusIndicator,
106 resolveSidebarNewThreadEnvMode,116 resolveSidebarNewThreadEnvMode,
...@@ -353,6 +363,7 @@ export default function Sidebar() {...@@ -353,6 +363,7 @@ export default function Sidebar() {
353 const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState<363 const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState<
354 ReadonlySet<ProjectId>364 ReadonlySet<ProjectId>
355 >(() => new Set());365 >(() => new Set());
366 const [showThreadJumpHints, setShowThreadJumpHints] = useState(false);
356 const renamingCommittedRef = useRef(false);367 const renamingCommittedRef = useRef(false);
357 const renamingInputRef = useRef<HTMLInputElement | null>(null);368 const renamingInputRef = useRef<HTMLInputElement | null>(null);
358 const dragInProgressRef = useRef(false);369 const dragInProgressRef = useRef(false);
...@@ -366,12 +377,26 @@ export default function Sidebar() {...@@ -366,12 +377,26 @@ export default function Sidebar() {
366 const removeFromSelection = useThreadSelectionStore((s) => s.removeFromSelection);377 const removeFromSelection = useThreadSelectionStore((s) => s.removeFromSelection);
367 const setSelectionAnchor = useThreadSelectionStore((s) => s.setAnchor);378 const setSelectionAnchor = useThreadSelectionStore((s) => s.setAnchor);
368 const isLinuxDesktop = isElectron && isLinuxPlatform(navigator.platform);379 const isLinuxDesktop = isElectron && isLinuxPlatform(navigator.platform);
380 const platform = navigator.platform;
369 const shouldBrowseForProjectImmediately = isElectron && !isLinuxDesktop;381 const shouldBrowseForProjectImmediately = isElectron && !isLinuxDesktop;
370 const shouldShowProjectPathEntry = addingProject && !shouldBrowseForProjectImmediately;382 const shouldShowProjectPathEntry = addingProject && !shouldBrowseForProjectImmediately;
371 const projectCwdById = useMemo(383 const projectCwdById = useMemo(
372 () => new Map(projects.map((project) => [project.id, project.cwd] as const)),384 () => new Map(projects.map((project) => [project.id, project.cwd] as const)),
373 [projects],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 const threadGitTargets = useMemo(400 const threadGitTargets = useMemo(
376 () =>401 () =>
377 threads.map((thread) => ({402 threads.map((thread) => ({
...@@ -823,6 +848,20 @@ export default function Sidebar() {...@@ -823,6 +848,20 @@ export default function Sidebar() {
823 ],848 ],
824 );849 );
825850
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 const handleProjectContextMenu = useCallback(865 const handleProjectContextMenu = useCallback(
827 async (projectId: ProjectId, position: { x: number; y: number }) => {866 async (projectId: ProjectId, position: { x: number; y: number }) => {
828 const api = readNativeApi();867 const api = readNativeApi();
...@@ -979,45 +1018,201 @@ export default function Sidebar() {...@@ -979,45 +1018,201 @@ export default function Sidebar() {
979 [appSettings.sidebarProjectSortOrder, projects, visibleThreads],1018 [appSettings.sidebarProjectSortOrder, projects, visibleThreads],
980 );1019 );
981 const isManualProjectSorting = appSettings.sidebarProjectSortOrder === "manual";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 ]);
9821196
983 function renderProjectItem(1197 function renderProjectItem(
984 project: (typeof sortedProjects)[number],1198 renderedProject: (typeof renderedProjects)[number],
985 dragHandleProps: SortableProjectHandleProps | null,1199 dragHandleProps: SortableProjectHandleProps | null,
986 ) {1200 ) {
987 const projectThreads = sortThreadsForSidebar(1201 const {
988 visibleThreads.filter((thread) => thread.projectId === project.id),1202 hasHiddenThreads,
989 appSettings.sidebarThreadSortOrder,1203 orderedProjectThreadIds,
990 );1204 project,
991 const projectStatus = resolveProjectStatusIndicator(1205 projectStatus,
992 projectThreads.map((thread) =>1206 projectThreads,
993 resolveThreadStatusPill({1207 renderedThreads,
994 thread,1208 shouldShowThreadPanel,
995 hasPendingApprovals: derivePendingApprovals(thread.activities).length > 0,1209 isThreadListExpanded,
996 hasPendingUserInput: derivePendingUserInputs(thread.activities).length > 0,1210 } = renderedProject;
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;
1017 const renderThreadRow = (thread: (typeof projectThreads)[number]) => {1211 const renderThreadRow = (thread: (typeof projectThreads)[number]) => {
1018 const isActive = routeThreadId === thread.id;1212 const isActive = routeThreadId === thread.id;
1019 const isSelected = selectedThreadIds.has(thread.id);1213 const isSelected = selectedThreadIds.has(thread.id);
1020 const isHighlighted = isActive || isSelected;1214 const isHighlighted = isActive || isSelected;
1215 const jumpLabel = threadJumpLabelById.get(thread.id) ?? null;
1021 const isThreadRunning =1216 const isThreadRunning =
1022 thread.session?.status === "running" && thread.session.activeTurnId != null;1217 thread.session?.status === "running" && thread.session.activeTurnId != null;
1023 const threadStatus = resolveThreadStatusPill({1218 const threadStatus = resolveThreadStatusPill({
...@@ -1057,14 +1252,7 @@ export default function Sidebar() {...@@ -1057,14 +1252,7 @@ export default function Sidebar() {
1057 onKeyDown={(event) => {1252 onKeyDown={(event) => {
1058 if (event.key !== "Enter" && event.key !== " ") return;1253 if (event.key !== "Enter" && event.key !== " ") return;
1059 event.preventDefault();1254 event.preventDefault();
1060 if (selectedThreadIds.size > 0) {1255 navigateToThread(thread.id);
1061 clearSelection();
1062 }
1063 setSelectionAnchor(thread.id);
1064 void navigate({
1065 to: "/$threadId",
1066 params: { threadId: thread.id },
1067 });
1068 }}1256 }}
1069 onContextMenu={(event) => {1257 onContextMenu={(event) => {
1070 event.preventDefault();1258 event.preventDefault();
...@@ -1234,15 +1422,26 @@ export default function Sidebar() {...@@ -1234,15 +1422,26 @@ export default function Sidebar() {
1234 </Tooltip>1422 </Tooltip>
1235 )1423 )
1236 ) : (1424 ) : (
1237 <span1425 <>
1238 className={`text-[10px] ${1426 {showThreadJumpHints && jumpLabel ? (
1239 isHighlighted1427 <span
1240 ? "text-foreground/72 dark:text-foreground/82"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"
1241 : "text-muted-foreground/40"1429 title={jumpLabel}
1242 }`}1430 >
1243 >1431 {jumpLabel}
1244 {formatRelativeTimeLabel(thread.updatedAt ?? thread.createdAt)}1432 </span>
1245 </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 </div>1446 </div>
1248 </div>1447 </div>
...@@ -1490,8 +1689,8 @@ export default function Sidebar() {...@@ -1490,8 +1689,8 @@ export default function Sidebar() {
1490 ? "text-rose-500 animate-pulse"1689 ? "text-rose-500 animate-pulse"
1491 : "text-amber-500 animate-pulse";1690 : "text-amber-500 animate-pulse";
1492 const newThreadShortcutLabel =1691 const newThreadShortcutLabel =
1493 shortcutLabelForCommand(keybindings, "chat.newLocal") ??1692 shortcutLabelForCommand(keybindings, "chat.newLocal", sidebarShortcutLabelOptions) ??
1494 shortcutLabelForCommand(keybindings, "chat.new");1693 shortcutLabelForCommand(keybindings, "chat.new", sidebarShortcutLabelOptions);
14951694
1496 const handleDesktopUpdateButtonClick = useCallback(() => {1695 const handleDesktopUpdateButtonClick = useCallback(() => {
1497 const bridge = window.desktopBridge;1696 const bridge = window.desktopBridge;
...@@ -1773,12 +1972,15 @@ export default function Sidebar() {...@@ -1773,12 +1972,15 @@ export default function Sidebar() {
1773 >1972 >
1774 <SidebarMenu>1973 <SidebarMenu>
1775 <SortableContext1974 <SortableContext
1776 items={sortedProjects.map((project) => project.id)}1975 items={renderedProjects.map((renderedProject) => renderedProject.project.id)}
1777 strategy={verticalListSortingStrategy}1976 strategy={verticalListSortingStrategy}
1778 >1977 >
1779 {sortedProjects.map((project) => (1978 {renderedProjects.map((renderedProject) => (
1780 <SortableProjectItem key={project.id} projectId={project.id}>1979 <SortableProjectItem
1781 {(dragHandleProps) => renderProjectItem(project, dragHandleProps)}1980 key={renderedProject.project.id}
1981 projectId={renderedProject.project.id}
1982 >
1983 {(dragHandleProps) => renderProjectItem(renderedProject, dragHandleProps)}
1782 </SortableProjectItem>1984 </SortableProjectItem>
1783 ))}1985 ))}
1784 </SortableContext>1986 </SortableContext>
...@@ -1786,9 +1988,9 @@ export default function Sidebar() {...@@ -1786,9 +1988,9 @@ export default function Sidebar() {
1786 </DndContext>1988 </DndContext>
1787 ) : (1989 ) : (
1788 <SidebarMenu ref={attachProjectListAutoAnimateRef}>1990 <SidebarMenu ref={attachProjectListAutoAnimateRef}>
1789 {sortedProjects.map((project) => (1991 {renderedProjects.map((renderedProject) => (
1790 <SidebarMenuItem key={project.id} className="rounded-md">1992 <SidebarMenuItem key={renderedProject.project.id} className="rounded-md">
1791 {renderProjectItem(project, null)}1993 {renderProjectItem(renderedProject, null)}
1792 </SidebarMenuItem>1994 </SidebarMenuItem>
1793 ))}1995 ))}
1794 </SidebarMenu>1996 </SidebarMenu>
apps/web/src/keybindings.test.ts+124-3
...@@ -18,8 +18,12 @@ import {...@@ -18,8 +18,12 @@ import {
18 isTerminalSplitShortcut,18 isTerminalSplitShortcut,
19 isTerminalToggleShortcut,19 isTerminalToggleShortcut,
20 resolveShortcutCommand,20 resolveShortcutCommand,
21 shouldShowThreadJumpHints,
21 shortcutLabelForCommand,22 shortcutLabelForCommand,
22 terminalNavigationShortcutData,23 terminalNavigationShortcutData,
24 threadJumpCommandForIndex,
25 threadJumpIndexFromCommand,
26 threadTraversalDirectionFromCommand,
23 type ShortcutEventLike,27 type ShortcutEventLike,
24} from "./keybindings";28} from "./keybindings";
2529
...@@ -100,6 +104,11 @@ const DEFAULT_BINDINGS = compile([...@@ -100,6 +104,11 @@ const DEFAULT_BINDINGS = compile([
100 { shortcut: modShortcut("o", { shiftKey: true }), command: "chat.new" },104 { shortcut: modShortcut("o", { shiftKey: true }), command: "chat.new" },
101 { shortcut: modShortcut("n", { shiftKey: true }), command: "chat.newLocal" },105 { shortcut: modShortcut("n", { shiftKey: true }), command: "chat.newLocal" },
102 { shortcut: modShortcut("o"), command: "editor.openFavorite" },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]);
104113
105describe("isTerminalToggleShortcut", () => {114describe("isTerminalToggleShortcut", () => {
...@@ -215,7 +224,7 @@ describe("split/new/close terminal shortcuts", () => {...@@ -215,7 +224,7 @@ describe("split/new/close terminal shortcuts", () => {
215});224});
216225
217describe("shortcutLabelForCommand", () => {226describe("shortcutLabelForCommand", () => {
218 it("returns the most recent binding label", () => {227 it("returns the effective binding label", () => {
219 const bindings = compile([228 const bindings = compile([
220 {229 {
221 shortcut: modShortcut("\\"),230 shortcut: modShortcut("\\"),
...@@ -229,18 +238,107 @@ describe("shortcutLabelForCommand", () => {...@@ -229,18 +238,107 @@ describe("shortcutLabelForCommand", () => {
229 },238 },
230 ]);239 ]);
231 assert.strictEqual(240 assert.strictEqual(
232 shortcutLabelForCommand(bindings, "terminal.split", "Linux"),241 shortcutLabelForCommand(bindings, "terminal.split", {
242 platform: "Linux",
243 context: { terminalFocus: false },
244 }),
233 "Ctrl+Shift+\\",245 "Ctrl+Shift+\\",
234 );246 );
235 });247 });
236248
237 it("returns labels for non-terminal commands", () => {249 it("returns effective labels for non-terminal commands", () => {
238 assert.strictEqual(shortcutLabelForCommand(DEFAULT_BINDINGS, "chat.new", "MacIntel"), "⇧⌘O");250 assert.strictEqual(shortcutLabelForCommand(DEFAULT_BINDINGS, "chat.new", "MacIntel"), "⇧⌘O");
239 assert.strictEqual(shortcutLabelForCommand(DEFAULT_BINDINGS, "diff.toggle", "Linux"), "Ctrl+D");251 assert.strictEqual(shortcutLabelForCommand(DEFAULT_BINDINGS, "diff.toggle", "Linux"), "Ctrl+D");
240 assert.strictEqual(252 assert.strictEqual(
241 shortcutLabelForCommand(DEFAULT_BINDINGS, "editor.openFavorite", "Linux"),253 shortcutLabelForCommand(DEFAULT_BINDINGS, "editor.openFavorite", "Linux"),
242 "Ctrl+O",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
309describe("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});
246344
...@@ -373,6 +471,29 @@ describe("resolveShortcutCommand", () => {...@@ -373,6 +471,29 @@ describe("resolveShortcutCommand", () => {
373 "script.setup.run",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});
377498
378describe("formatShortcutLabel", () => {499describe("formatShortcutLabel", () => {
apps/web/src/keybindings.ts+127-11
...@@ -3,11 +3,14 @@ import {...@@ -3,11 +3,14 @@ import {
3 type KeybindingShortcut,3 type KeybindingShortcut,
4 type KeybindingWhenNode,4 type KeybindingWhenNode,
5 type ResolvedKeybindingsConfig,5 type ResolvedKeybindingsConfig,
6 THREAD_JUMP_KEYBINDING_COMMANDS,
7 type ThreadJumpKeybindingCommand,
6} from "@t3tools/contracts";8} from "@t3tools/contracts";
7import { isMacPlatform } from "./lib/utils";9import { isMacPlatform } from "./lib/utils";
810
9export interface ShortcutEventLike {11export interface ShortcutEventLike {
10 type?: string;12 type?: string;
13 code?: string;
11 key: string;14 key: string;
12 metaKey: boolean;15 metaKey: boolean;
13 ctrlKey: boolean;16 ctrlKey: boolean;
...@@ -26,10 +29,28 @@ interface ShortcutMatchOptions {...@@ -26,10 +29,28 @@ interface ShortcutMatchOptions {
26 context?: Partial<ShortcutMatchContext>;29 context?: Partial<ShortcutMatchContext>;
27}30}
2831
32interface ResolvedShortcutLabelOptions extends ShortcutMatchOptions {
33 platform?: string;
34}
35
29const TERMINAL_WORD_BACKWARD = "\u001bb";36const TERMINAL_WORD_BACKWARD = "\u001bb";
30const TERMINAL_WORD_FORWARD = "\u001bf";37const TERMINAL_WORD_FORWARD = "\u001bf";
31const TERMINAL_LINE_START = "\u0001";38const TERMINAL_LINE_START = "\u0001";
32const TERMINAL_LINE_END = "\u0005";39const TERMINAL_LINE_END = "\u0005";
40const 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};
3354
34function normalizeEventKey(key: string): string {55function normalizeEventKey(key: string): string {
35 const normalized = key.toLowerCase();56 const normalized = key.toLowerCase();
...@@ -37,14 +58,22 @@ function normalizeEventKey(key: string): string {...@@ -37,14 +58,22 @@ function normalizeEventKey(key: string): string {
37 return normalized;58 return normalized;
38}59}
3960
40function matchesShortcut(61function 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
72function matchesShortcutModifiers(
41 event: ShortcutEventLike,73 event: ShortcutEventLike,
42 shortcut: KeybindingShortcut,74 shortcut: KeybindingShortcut,
43 platform = navigator.platform,75 platform = navigator.platform,
44): boolean {76): boolean {
45 const key = normalizeEventKey(event.key);
46 if (key !== shortcut.key) return false;
47
48 const useMetaForMod = isMacPlatform(platform);77 const useMetaForMod = isMacPlatform(platform);
49 const expectedMeta = shortcut.metaKey || (shortcut.modKey && useMetaForMod);78 const expectedMeta = shortcut.metaKey || (shortcut.modKey && useMetaForMod);
50 const expectedCtrl = shortcut.ctrlKey || (shortcut.modKey && !useMetaForMod);79 const expectedCtrl = shortcut.ctrlKey || (shortcut.modKey && !useMetaForMod);
...@@ -56,6 +85,15 @@ function matchesShortcut(...@@ -56,6 +85,15 @@ function matchesShortcut(
56 );85 );
57}86}
5887
88function 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
59function resolvePlatform(options: ShortcutMatchOptions | undefined): string {97function resolvePlatform(options: ShortcutMatchOptions | undefined): string {
60 return options?.platform ?? navigator.platform;98 return options?.platform ?? navigator.platform;
61}99}
...@@ -91,6 +129,48 @@ function matchesWhenClause(...@@ -91,6 +129,48 @@ function matchesWhenClause(
91 return evaluateWhenNode(whenAst, context);129 return evaluateWhenNode(whenAst, context);
92}130}
93131
132function 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
146function 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
94function matchesCommandShortcut(174function matchesCommandShortcut(
95 event: ShortcutEventLike,175 event: ShortcutEventLike,
96 keybindings: ResolvedKeybindingsConfig,176 keybindings: ResolvedKeybindingsConfig,
...@@ -104,7 +184,7 @@ export function resolveShortcutCommand(...@@ -104,7 +184,7 @@ export function resolveShortcutCommand(
104 event: ShortcutEventLike,184 event: ShortcutEventLike,
105 keybindings: ResolvedKeybindingsConfig,185 keybindings: ResolvedKeybindingsConfig,
106 options?: ShortcutMatchOptions,186 options?: ShortcutMatchOptions,
107): string | null {187): KeybindingCommand | null {
108 const platform = resolvePlatform(options);188 const platform = resolvePlatform(options);
109 const context = resolveContext(options);189 const context = resolveContext(options);
110190
...@@ -156,16 +236,52 @@ export function formatShortcutLabel(...@@ -156,16 +236,52 @@ export function formatShortcutLabel(
156export function shortcutLabelForCommand(236export function shortcutLabelForCommand(
157 keybindings: ResolvedKeybindingsConfig,237 keybindings: ResolvedKeybindingsConfig,
158 command: KeybindingCommand,238 command: KeybindingCommand,
159 platform = navigator.platform,239 options?: string | ResolvedShortcutLabelOptions,
160): string | null {240): string | null {
161 for (let index = keybindings.length - 1; index >= 0; index -= 1) {241 const resolvedOptions =
162 const binding = keybindings[index];242 typeof options === "string"
163 if (!binding || binding.command !== command) continue;243 ? ({ platform: options } satisfies ResolvedShortcutLabelOptions)
164 return formatShortcutLabel(binding.shortcut, platform);244 : options;
165 }245 const platform = resolvePlatform(resolvedOptions);
246 const shortcut = findEffectiveShortcutForCommand(keybindings, command, resolvedOptions);
247 return shortcut ? formatShortcutLabel(shortcut, platform) : null;
248}
249
250export function threadJumpCommandForIndex(index: number): ThreadJumpKeybindingCommand | null {
251 return THREAD_JUMP_KEYBINDING_COMMANDS[index] ?? null;
252}
253
254export 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
259export 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 return null;264 return null;
167}265}
168266
267export 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
169export function isTerminalToggleShortcut(285export function isTerminalToggleShortcut(
170 event: ShortcutEventLike,286 event: ShortcutEventLike,
171 keybindings: ResolvedKeybindingsConfig,287 keybindings: ResolvedKeybindingsConfig,
packages/contracts/src/keybindings.test.ts+18-1
...@@ -46,6 +46,12 @@ it.effect("parses keybinding rules", () =>...@@ -46,6 +46,12 @@ it.effect("parses keybinding rules", () =>
46 command: "chat.newLocal",46 command: "chat.newLocal",
47 });47 });
48 assert.strictEqual(parsedLocal.command, "chat.newLocal");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);
5157
...@@ -120,8 +126,19 @@ it.effect("parses resolved keybindings arrays", () =>...@@ -120,8 +126,19 @@ it.effect("parses resolved keybindings arrays", () =>
120 modKey: true,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);
127144
packages/contracts/src/keybindings.ts+21
...@@ -7,6 +7,26 @@ export const MAX_WHEN_EXPRESSION_DEPTH = 64;...@@ -7,6 +7,26 @@ export const MAX_WHEN_EXPRESSION_DEPTH = 64;
7export const MAX_SCRIPT_ID_LENGTH = 24;7export const MAX_SCRIPT_ID_LENGTH = 24;
8export const MAX_KEYBINDINGS_COUNT = 256;8export const MAX_KEYBINDINGS_COUNT = 256;
99
10export 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;
21export type ThreadJumpKeybindingCommand = (typeof THREAD_JUMP_KEYBINDING_COMMANDS)[number];
22
23export const THREAD_KEYBINDING_COMMANDS = [
24 "thread.previous",
25 "thread.next",
26 ...THREAD_JUMP_KEYBINDING_COMMANDS,
27] as const;
28export type ThreadKeybindingCommand = (typeof THREAD_KEYBINDING_COMMANDS)[number];
29
10const STATIC_KEYBINDING_COMMANDS = [30const STATIC_KEYBINDING_COMMANDS = [
11 "terminal.toggle",31 "terminal.toggle",
12 "terminal.split",32 "terminal.split",
...@@ -16,6 +36,7 @@ const STATIC_KEYBINDING_COMMANDS = [...@@ -16,6 +36,7 @@ const STATIC_KEYBINDING_COMMANDS = [
16 "chat.new",36 "chat.new",
17 "chat.newLocal",37 "chat.newLocal",
18 "editor.openFavorite",38 "editor.openFavorite",
39 ...THREAD_KEYBINDING_COMMANDS,
19] as const;40] as const;
2041
21export const SCRIPT_RUN_COMMAND_PATTERN = Schema.TemplateLiteral([42export const SCRIPT_RUN_COMMAND_PATTERN = Schema.TemplateLiteral([