feat: add workspace and tab navigation keyboard shortcuts
This commit is contained in:
@@ -14,14 +14,20 @@ export type MessageInputKeyboardActionKind =
|
||||
|
||||
export type KeyboardActionId =
|
||||
| "agent.new"
|
||||
| "workspace.tab.new"
|
||||
| "workspace.tab.close.current"
|
||||
| "workspace.tab.navigate.index"
|
||||
| "workspace.tab.navigate.relative"
|
||||
| "workspace.navigate.index"
|
||||
| "workspace.navigate.relative"
|
||||
| "sidebar.toggle.left"
|
||||
| "sidebar.toggle.right"
|
||||
| "sidebar.navigate.shortcut"
|
||||
| "command-center.toggle"
|
||||
| "shortcuts.dialog.toggle"
|
||||
| "message-input.action";
|
||||
|
||||
export type KeyboardShortcutPayload =
|
||||
| { digit: number }
|
||||
| { index: number }
|
||||
| { delta: 1 | -1 }
|
||||
| { kind: MessageInputKeyboardActionKind }
|
||||
| null;
|
||||
|
||||
@@ -32,6 +32,34 @@ function shortcutContext(
|
||||
}
|
||||
|
||||
describe("keyboard-shortcuts", () => {
|
||||
it("matches Mod+Shift+O to create new agent", () => {
|
||||
const match = resolveKeyboardShortcut({
|
||||
event: keyboardEvent({
|
||||
key: "O",
|
||||
code: "KeyO",
|
||||
metaKey: true,
|
||||
shiftKey: true,
|
||||
}),
|
||||
context: shortcutContext({ isMac: true }),
|
||||
});
|
||||
|
||||
expect(match?.action).toBe("agent.new");
|
||||
});
|
||||
|
||||
it("does not keep old Mod+Alt+N binding", () => {
|
||||
const match = resolveKeyboardShortcut({
|
||||
event: keyboardEvent({
|
||||
key: "n",
|
||||
code: "KeyN",
|
||||
metaKey: true,
|
||||
altKey: true,
|
||||
}),
|
||||
context: shortcutContext({ isMac: true }),
|
||||
});
|
||||
|
||||
expect(match).toBeNull();
|
||||
});
|
||||
|
||||
it("matches question-mark shortcut to toggle the shortcuts dialog", () => {
|
||||
const match = resolveKeyboardShortcut({
|
||||
event: keyboardEvent({
|
||||
@@ -58,6 +86,134 @@ describe("keyboard-shortcuts", () => {
|
||||
expect(match).toBeNull();
|
||||
});
|
||||
|
||||
it("matches workspace index jump on web via Alt+digit", () => {
|
||||
const match = resolveKeyboardShortcut({
|
||||
event: keyboardEvent({
|
||||
key: "2",
|
||||
code: "Digit2",
|
||||
altKey: true,
|
||||
}),
|
||||
context: shortcutContext({ isTauri: false }),
|
||||
});
|
||||
|
||||
expect(match?.action).toBe("workspace.navigate.index");
|
||||
expect(match?.payload).toEqual({ index: 2 });
|
||||
});
|
||||
|
||||
it("matches workspace index jump on tauri via Mod+digit", () => {
|
||||
const match = resolveKeyboardShortcut({
|
||||
event: keyboardEvent({
|
||||
key: "2",
|
||||
code: "Digit2",
|
||||
metaKey: true,
|
||||
}),
|
||||
context: shortcutContext({ isMac: true, isTauri: true }),
|
||||
});
|
||||
|
||||
expect(match?.action).toBe("workspace.navigate.index");
|
||||
expect(match?.payload).toEqual({ index: 2 });
|
||||
});
|
||||
|
||||
it("matches tab index jump on tauri via Alt+digit", () => {
|
||||
const match = resolveKeyboardShortcut({
|
||||
event: keyboardEvent({
|
||||
key: "2",
|
||||
code: "Digit2",
|
||||
altKey: true,
|
||||
}),
|
||||
context: shortcutContext({ isTauri: true }),
|
||||
});
|
||||
|
||||
expect(match?.action).toBe("workspace.tab.navigate.index");
|
||||
expect(match?.payload).toEqual({ index: 2 });
|
||||
});
|
||||
|
||||
it("matches tab index jump on web via Alt+Shift+digit", () => {
|
||||
const match = resolveKeyboardShortcut({
|
||||
event: keyboardEvent({
|
||||
key: "@",
|
||||
code: "Digit2",
|
||||
altKey: true,
|
||||
shiftKey: true,
|
||||
}),
|
||||
context: shortcutContext({ isTauri: false }),
|
||||
});
|
||||
|
||||
expect(match?.action).toBe("workspace.tab.navigate.index");
|
||||
expect(match?.payload).toEqual({ index: 2 });
|
||||
});
|
||||
|
||||
it("matches workspace relative navigation on web via Alt+[", () => {
|
||||
const match = resolveKeyboardShortcut({
|
||||
event: keyboardEvent({
|
||||
key: "[",
|
||||
code: "BracketLeft",
|
||||
altKey: true,
|
||||
}),
|
||||
context: shortcutContext({ isTauri: false }),
|
||||
});
|
||||
|
||||
expect(match?.action).toBe("workspace.navigate.relative");
|
||||
expect(match?.payload).toEqual({ delta: -1 });
|
||||
});
|
||||
|
||||
it("matches workspace relative navigation on tauri via Mod+]", () => {
|
||||
const match = resolveKeyboardShortcut({
|
||||
event: keyboardEvent({
|
||||
key: "]",
|
||||
code: "BracketRight",
|
||||
ctrlKey: true,
|
||||
}),
|
||||
context: shortcutContext({ isTauri: true }),
|
||||
});
|
||||
|
||||
expect(match?.action).toBe("workspace.navigate.relative");
|
||||
expect(match?.payload).toEqual({ delta: 1 });
|
||||
});
|
||||
|
||||
it("matches tab relative navigation via Alt+Shift+]", () => {
|
||||
const match = resolveKeyboardShortcut({
|
||||
event: keyboardEvent({
|
||||
key: "}",
|
||||
code: "BracketRight",
|
||||
altKey: true,
|
||||
shiftKey: true,
|
||||
}),
|
||||
context: shortcutContext(),
|
||||
});
|
||||
|
||||
expect(match?.action).toBe("workspace.tab.navigate.relative");
|
||||
expect(match?.payload).toEqual({ delta: 1 });
|
||||
});
|
||||
|
||||
it("matches Alt+Shift+T to open new tab", () => {
|
||||
const match = resolveKeyboardShortcut({
|
||||
event: keyboardEvent({
|
||||
key: "T",
|
||||
code: "KeyT",
|
||||
altKey: true,
|
||||
shiftKey: true,
|
||||
}),
|
||||
context: shortcutContext(),
|
||||
});
|
||||
|
||||
expect(match?.action).toBe("workspace.tab.new");
|
||||
});
|
||||
|
||||
it("matches Alt+Shift+W to close current tab", () => {
|
||||
const match = resolveKeyboardShortcut({
|
||||
event: keyboardEvent({
|
||||
key: "W",
|
||||
code: "KeyW",
|
||||
altKey: true,
|
||||
shiftKey: true,
|
||||
}),
|
||||
context: shortcutContext(),
|
||||
});
|
||||
|
||||
expect(match?.action).toBe("workspace.tab.close.current");
|
||||
});
|
||||
|
||||
it("matches Cmd+B sidebar toggle on macOS", () => {
|
||||
const match = resolveKeyboardShortcut({
|
||||
event: keyboardEvent({
|
||||
@@ -163,20 +319,6 @@ describe("keyboard-shortcuts", () => {
|
||||
expect(match?.preventDefault).toBe(false);
|
||||
expect(match?.stopPropagation).toBe(false);
|
||||
});
|
||||
|
||||
it("parses Alt+digit sidebar shortcut payload", () => {
|
||||
const match = resolveKeyboardShortcut({
|
||||
event: keyboardEvent({
|
||||
key: "2",
|
||||
code: "Digit2",
|
||||
altKey: true,
|
||||
}),
|
||||
context: shortcutContext(),
|
||||
});
|
||||
|
||||
expect(match?.action).toBe("sidebar.navigate.shortcut");
|
||||
expect(match?.payload).toEqual({ digit: 2 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("keyboard-shortcut help sections", () => {
|
||||
@@ -193,30 +335,30 @@ describe("keyboard-shortcut help sections", () => {
|
||||
return null;
|
||||
}
|
||||
|
||||
it("uses non-tauri defaults for new-agent and quick-open", () => {
|
||||
it("uses web defaults for workspace and tab jump", () => {
|
||||
const sections = buildKeyboardShortcutHelpSections({
|
||||
isMac: true,
|
||||
isTauri: false,
|
||||
});
|
||||
|
||||
expect(findRow(sections, "new-agent")?.keys).toEqual(["mod", "alt", "N"]);
|
||||
expect(findRow(sections, "quick-open-workspace")?.keys).toEqual([
|
||||
expect(findRow(sections, "new-agent")?.keys).toEqual(["mod", "shift", "O"]);
|
||||
expect(findRow(sections, "workspace-jump-index")?.keys).toEqual(["alt", "1-9"]);
|
||||
expect(findRow(sections, "workspace-tab-jump-index")?.keys).toEqual([
|
||||
"alt",
|
||||
"shift",
|
||||
"1-9",
|
||||
]);
|
||||
});
|
||||
|
||||
it("switches to tauri bindings in help rows", () => {
|
||||
it("uses tauri defaults for workspace and tab jump", () => {
|
||||
const sections = buildKeyboardShortcutHelpSections({
|
||||
isMac: true,
|
||||
isTauri: true,
|
||||
});
|
||||
|
||||
expect(findRow(sections, "new-agent")?.keys).toEqual(["mod", "N"]);
|
||||
expect(findRow(sections, "quick-open-workspace")?.keys).toEqual([
|
||||
"mod",
|
||||
"1-9",
|
||||
]);
|
||||
expect(findRow(sections, "new-agent")?.keys).toEqual(["mod", "shift", "O"]);
|
||||
expect(findRow(sections, "workspace-jump-index")?.keys).toEqual(["mod", "1-9"]);
|
||||
expect(findRow(sections, "workspace-tab-jump-index")?.keys).toEqual(["alt", "1-9"]);
|
||||
});
|
||||
|
||||
it("uses mod+period as non-mac left sidebar shortcut", () => {
|
||||
|
||||
@@ -109,41 +109,253 @@ function withMessageInputAction(
|
||||
return () => ({ kind });
|
||||
}
|
||||
|
||||
function withIndexPayload(event: KeyboardEvent): KeyboardShortcutPayload {
|
||||
const index = parseDigit(event);
|
||||
return index ? { index } : null;
|
||||
}
|
||||
|
||||
function withRelativeDelta(
|
||||
delta: 1 | -1
|
||||
): (event: KeyboardEvent) => KeyboardShortcutPayload {
|
||||
return () => ({ delta });
|
||||
}
|
||||
|
||||
const SHORTCUT_BINDINGS: readonly KeyboardShortcutBinding[] = [
|
||||
{
|
||||
id: "agent-new-mod-alt-n",
|
||||
id: "agent-new-mod-shift-o",
|
||||
action: "agent.new",
|
||||
matches: (event) =>
|
||||
isMod(event) &&
|
||||
event.altKey &&
|
||||
!event.shiftKey &&
|
||||
(event.code === "KeyN" || event.key.toLowerCase() === "n"),
|
||||
!event.altKey &&
|
||||
event.shiftKey &&
|
||||
(event.code === "KeyO" || event.key.toLowerCase() === "o"),
|
||||
when: () => true,
|
||||
help: {
|
||||
id: "new-agent",
|
||||
section: "global",
|
||||
label: "Create new agent",
|
||||
keys: ["mod", "alt", "N"],
|
||||
when: (context) => !context.isTauri,
|
||||
keys: ["mod", "shift", "O"],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "agent-new-tauri-mod-n",
|
||||
action: "agent.new",
|
||||
id: "workspace-tab-new-alt-shift-t",
|
||||
action: "workspace.tab.new",
|
||||
matches: (event) =>
|
||||
!event.metaKey &&
|
||||
!event.ctrlKey &&
|
||||
event.altKey &&
|
||||
event.shiftKey &&
|
||||
(event.code === "KeyT" || event.key.toLowerCase() === "t"),
|
||||
when: (context) => !context.commandCenterOpen,
|
||||
help: {
|
||||
id: "workspace-tab-new",
|
||||
section: "global",
|
||||
label: "New agent tab",
|
||||
keys: ["alt", "shift", "T"],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "workspace-tab-close-current-alt-shift-w",
|
||||
action: "workspace.tab.close.current",
|
||||
matches: (event) =>
|
||||
!event.metaKey &&
|
||||
!event.ctrlKey &&
|
||||
event.altKey &&
|
||||
event.shiftKey &&
|
||||
(event.code === "KeyW" || event.key.toLowerCase() === "w"),
|
||||
when: (context) => !context.commandCenterOpen,
|
||||
help: {
|
||||
id: "workspace-tab-close-current",
|
||||
section: "global",
|
||||
label: "Close current tab",
|
||||
keys: ["alt", "shift", "W"],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "workspace-navigate-index-mod-digit-tauri",
|
||||
action: "workspace.navigate.index",
|
||||
matches: (event) =>
|
||||
isMod(event) &&
|
||||
!event.altKey &&
|
||||
!event.shiftKey &&
|
||||
(event.code === "KeyN" || event.key.toLowerCase() === "n"),
|
||||
when: (context) => context.isTauri,
|
||||
hasDigit(event),
|
||||
payload: withIndexPayload,
|
||||
when: (context) => context.isTauri && !context.commandCenterOpen,
|
||||
help: {
|
||||
id: "new-agent",
|
||||
id: "workspace-jump-index",
|
||||
section: "global",
|
||||
label: "Create new agent",
|
||||
keys: ["mod", "N"],
|
||||
label: "Jump to workspace",
|
||||
keys: ["mod", "1-9"],
|
||||
when: (context) => context.isTauri,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "workspace-navigate-index-alt-digit-web",
|
||||
action: "workspace.navigate.index",
|
||||
matches: (event) =>
|
||||
!event.metaKey &&
|
||||
!event.ctrlKey &&
|
||||
event.altKey &&
|
||||
!event.shiftKey &&
|
||||
hasDigit(event),
|
||||
payload: withIndexPayload,
|
||||
when: (context) => !context.isTauri && !context.commandCenterOpen,
|
||||
help: {
|
||||
id: "workspace-jump-index",
|
||||
section: "global",
|
||||
label: "Jump to workspace",
|
||||
keys: ["alt", "1-9"],
|
||||
when: (context) => !context.isTauri,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "workspace-tab-navigate-index-alt-digit-tauri",
|
||||
action: "workspace.tab.navigate.index",
|
||||
matches: (event) =>
|
||||
!event.metaKey &&
|
||||
!event.ctrlKey &&
|
||||
event.altKey &&
|
||||
!event.shiftKey &&
|
||||
hasDigit(event),
|
||||
payload: withIndexPayload,
|
||||
when: (context) => context.isTauri && !context.commandCenterOpen,
|
||||
help: {
|
||||
id: "workspace-tab-jump-index",
|
||||
section: "global",
|
||||
label: "Jump to tab",
|
||||
keys: ["alt", "1-9"],
|
||||
when: (context) => context.isTauri,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "workspace-tab-navigate-index-alt-shift-digit-web",
|
||||
action: "workspace.tab.navigate.index",
|
||||
matches: (event) =>
|
||||
!event.metaKey &&
|
||||
!event.ctrlKey &&
|
||||
event.altKey &&
|
||||
event.shiftKey &&
|
||||
hasDigit(event),
|
||||
payload: withIndexPayload,
|
||||
when: (context) => !context.isTauri && !context.commandCenterOpen,
|
||||
help: {
|
||||
id: "workspace-tab-jump-index",
|
||||
section: "global",
|
||||
label: "Jump to tab",
|
||||
keys: ["alt", "shift", "1-9"],
|
||||
when: (context) => !context.isTauri,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "workspace-navigate-relative-mod-left",
|
||||
action: "workspace.navigate.relative",
|
||||
matches: (event) =>
|
||||
isMod(event) &&
|
||||
!event.altKey &&
|
||||
!event.shiftKey &&
|
||||
(event.code === "BracketLeft" || event.key === "["),
|
||||
payload: withRelativeDelta(-1),
|
||||
when: (context) => context.isTauri && !context.commandCenterOpen,
|
||||
help: {
|
||||
id: "workspace-prev",
|
||||
section: "global",
|
||||
label: "Previous workspace",
|
||||
keys: ["mod", "["],
|
||||
when: (context) => context.isTauri,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "workspace-navigate-relative-mod-right",
|
||||
action: "workspace.navigate.relative",
|
||||
matches: (event) =>
|
||||
isMod(event) &&
|
||||
!event.altKey &&
|
||||
!event.shiftKey &&
|
||||
(event.code === "BracketRight" || event.key === "]"),
|
||||
payload: withRelativeDelta(1),
|
||||
when: (context) => context.isTauri && !context.commandCenterOpen,
|
||||
help: {
|
||||
id: "workspace-next",
|
||||
section: "global",
|
||||
label: "Next workspace",
|
||||
keys: ["mod", "]"],
|
||||
when: (context) => context.isTauri,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "workspace-navigate-relative-alt-left",
|
||||
action: "workspace.navigate.relative",
|
||||
matches: (event) =>
|
||||
!event.metaKey &&
|
||||
!event.ctrlKey &&
|
||||
event.altKey &&
|
||||
!event.shiftKey &&
|
||||
(event.code === "BracketLeft" || event.key === "["),
|
||||
payload: withRelativeDelta(-1),
|
||||
when: (context) => !context.isTauri && !context.commandCenterOpen,
|
||||
help: {
|
||||
id: "workspace-prev",
|
||||
section: "global",
|
||||
label: "Previous workspace",
|
||||
keys: ["alt", "["],
|
||||
when: (context) => !context.isTauri,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "workspace-navigate-relative-alt-right",
|
||||
action: "workspace.navigate.relative",
|
||||
matches: (event) =>
|
||||
!event.metaKey &&
|
||||
!event.ctrlKey &&
|
||||
event.altKey &&
|
||||
!event.shiftKey &&
|
||||
(event.code === "BracketRight" || event.key === "]"),
|
||||
payload: withRelativeDelta(1),
|
||||
when: (context) => !context.isTauri && !context.commandCenterOpen,
|
||||
help: {
|
||||
id: "workspace-next",
|
||||
section: "global",
|
||||
label: "Next workspace",
|
||||
keys: ["alt", "]"],
|
||||
when: (context) => !context.isTauri,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "workspace-tab-navigate-relative-alt-shift-left",
|
||||
action: "workspace.tab.navigate.relative",
|
||||
matches: (event) =>
|
||||
!event.metaKey &&
|
||||
!event.ctrlKey &&
|
||||
event.altKey &&
|
||||
event.shiftKey &&
|
||||
event.code === "BracketLeft",
|
||||
payload: withRelativeDelta(-1),
|
||||
when: (context) => !context.commandCenterOpen,
|
||||
help: {
|
||||
id: "workspace-tab-prev",
|
||||
section: "global",
|
||||
label: "Previous tab",
|
||||
keys: ["alt", "shift", "["],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "workspace-tab-navigate-relative-alt-shift-right",
|
||||
action: "workspace.tab.navigate.relative",
|
||||
matches: (event) =>
|
||||
!event.metaKey &&
|
||||
!event.ctrlKey &&
|
||||
event.altKey &&
|
||||
event.shiftKey &&
|
||||
event.code === "BracketRight",
|
||||
payload: withRelativeDelta(1),
|
||||
when: (context) => !context.commandCenterOpen,
|
||||
help: {
|
||||
id: "workspace-tab-next",
|
||||
section: "global",
|
||||
label: "Next tab",
|
||||
keys: ["alt", "shift", "]"],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "command-center-toggle",
|
||||
action: "command-center.toggle",
|
||||
@@ -308,40 +520,6 @@ const SHORTCUT_BINDINGS: readonly KeyboardShortcutBinding[] = [
|
||||
keys: ["Space"],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "sidebar-shortcut-alt-digit",
|
||||
action: "sidebar.navigate.shortcut",
|
||||
matches: (event) => event.altKey && hasDigit(event),
|
||||
payload: (event) => {
|
||||
const digit = parseDigit(event);
|
||||
return digit ? { digit } : null;
|
||||
},
|
||||
when: (context) => !context.commandCenterOpen,
|
||||
help: {
|
||||
id: "quick-open-workspace",
|
||||
section: "global",
|
||||
label: "Open sidebar workspace shortcut",
|
||||
keys: ["alt", "1-9"],
|
||||
when: (context) => !context.isTauri,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "sidebar-shortcut-tauri-mod-digit",
|
||||
action: "sidebar.navigate.shortcut",
|
||||
matches: (event) => isMod(event) && hasDigit(event),
|
||||
payload: (event) => {
|
||||
const digit = parseDigit(event);
|
||||
return digit ? { digit } : null;
|
||||
},
|
||||
when: (context) => context.isTauri && !context.commandCenterOpen,
|
||||
help: {
|
||||
id: "quick-open-workspace",
|
||||
section: "global",
|
||||
label: "Open sidebar workspace shortcut",
|
||||
keys: ["mod", "1-9"],
|
||||
when: (context) => context.isTauri,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export function resolveKeyboardShortcut(input: {
|
||||
|
||||
Reference in New Issue
Block a user