#13610 · @dl-alexandre · opened Feb 14, 2026 at 3:00 PM UTC · last updated Mar 21, 2026 at 1:53 PM UTC

feat(desktop): add keyboard shortcuts to switch projects (Cmd+1-9)

desktopfeat
68
+52818 files

Score breakdown

Impact

8.0

Clarity

9.0

Urgency

4.0

Ease Of Review

8.0

Guidelines

8.0

Readiness

8.0

Size

6.0

Trust

7.0

Traction

4.0

Summary

This PR introduces keyboard shortcuts (Mod+1-9) for switching between projects in the desktop application. It's a rework of a previous attempt, focusing on a cross-platform approach and integrating with existing command and settings systems.

Open in GitHub

Description

What does this PR do?

Adds Mod+1 through Mod+9 keyboard shortcuts to switch between sidebar projects, similar to browser tab
switching (Chrome, VS Code).

This is a rework of #11847 based on review feedback from @alexyaroshuk. The original approach used a macOS-only Window menu with CustomEvent — this version instead:

  • Registers 9 commands (project.switch.0–project.switch.8) via the existing command.register() system in layout.tsx
  • Uses mod+1–mod+9 keybinds which are cross-platform (Cmd on macOS, Ctrl on Windows/Linux)
  • Integrates with Settings > Shortcuts for user customization
  • Adds TooltipKeybind on each sidebar project tile showing the project name and keybind
  • Adds command.project.switch i18n string across all 15 supported languages

Closes #11837 Supersedes #11847

How did you verify your code works?

  • Typecheck passes (12/12 packages)
  • Tested switching between projects using Mod+1-9 in the desktop app
  • Verified tooltips appear on hover with correct keybind displayed
<img width="141" height="61" alt="Screenshot 2026-02-14 at 7 00 03 AM" src="https://github.com/user-attachments/assets/c0e9a931-6709-4cfe-bda9-e2fd62c620b9" />

Linked Issues

#11837 feat(desktop): Add keyboard shortcuts to switch projects (Cmd+1, Cmd+2, etc.)

View issue

Comments

PR comments

ndaemy

Nice work on this! One concern about the keybind scope:

The implementation lives in packages/app, which is shared across desktop (Tauri/Electron) and the web version (opencode web / app.opencode.ai proxy). mod+1–9 conflicts with browser tab switching on all platforms.

Would it make sense to gate these keybinds behind a platform check so they only register on desktop?

Changed Files

packages/app/src/i18n/ar.ts

+10
@@ -29,6 +29,7 @@ export const dict = {
"command.session.previous.unseen": "الجلسة غير المقروءة السابقة",
"command.session.next.unseen": "الجلسة غير المقروءة التالية",
"command.session.archive": "أرشفة الجلسة",
"command.project.switch": "التبديل إلى المشروع {{number}}",
"command.palette": "لوحة الأوامر",
"command.theme.cycle": "تغيير السمة",
"command.theme.set": "استخدام السمة: {{theme}}",

packages/app/src/i18n/br.ts

+10
@@ -29,6 +29,7 @@ export const dict = {
"command.session.previous.unseen": "Sessão não lida anterior",
"command.session.next.unseen": "Próxima sessão não lida",
"command.session.archive": "Arquivar sessão",
"command.project.switch": "Mudar para o projeto {{number}}",
"command.palette": "Paleta de comandos",
"command.theme.cycle": "Alternar tema",
"command.theme.set": "Usar tema: {{theme}}",

packages/app/src/i18n/da.ts

+10
@@ -31,6 +31,7 @@ export const dict = {
"command.session.previous.unseen": "Forrige ulæste session",
"command.session.next.unseen": "Næste ulæste session",
"command.session.archive": "Arkivér session",
"command.project.switch": "Skift til projekt {{number}}",
"command.palette": "Kommandopalette",

packages/app/src/i18n/de.ts

+10
@@ -33,6 +33,7 @@ export const dict = {
"command.session.previous.unseen": "Vorherige ungelesene Sitzung",
"command.session.next.unseen": "Nächste ungelesene Sitzung",
"command.session.archive": "Sitzung archivieren",
"command.project.switch": "Zu Projekt {{number}} wechseln",
"command.palette": "Befehlspalette",
"command.theme.cycle": "Thema wechseln",
"command.theme.set": "Thema verwenden: {{theme}}",

packages/app/src/i18n/en.ts

+10
@@ -31,6 +31,7 @@ export const dict = {
"command.session.previous.unseen": "Previous unread session",
"command.session.next.unseen": "Next unread session",
"command.session.archive": "Archive session",
"command.project.switch": "Switch to project {{number}}",
"command.palette": "Command palette",

packages/app/src/i18n/es.ts

+10
@@ -31,6 +31,7 @@ export const dict = {
"command.session.previous.unseen": "Sesión no leída anterior",
"command.session.next.unseen": "Siguiente sesión no leída",
"command.session.archive": "Archivar sesión",
"command.project.switch": "Cambiar al proyecto {{number}}",
"command.palette": "Paleta de comandos",

packages/app/src/i18n/fr.ts

+10
@@ -29,6 +29,7 @@ export const dict = {
"command.session.previous.unseen": "Session non lue précédente",
"command.session.next.unseen": "Session non lue suivante",
"command.session.archive": "Archiver la session",
"command.project.switch": "Basculer vers le projet {{number}}",
"command.palette": "Palette de commandes",
"command.theme.cycle": "Changer de thème",
"command.theme.set": "Utiliser le thème : {{theme}}",

packages/app/src/i18n/ja.ts

+10
@@ -29,6 +29,7 @@ export const dict = {
"command.session.previous.unseen": "前の未読セッション",
"command.session.next.unseen": "次の未読セッション",
"command.session.archive": "セッションをアーカイブ",
"command.project.switch": "プロジェクト {{number}} に切り替え",
"command.palette": "コマンドパレット",
"command.theme.cycle": "テーマの切り替え",
"command.theme.set": "テーマを使用: {{theme}}",

packages/app/src/i18n/ko.ts

+10
@@ -33,6 +33,7 @@ export const dict = {
"command.session.previous.unseen": "이전 읽지 않은 세션",
"command.session.next.unseen": "다음 읽지 않은 세션",
"command.session.archive": "세션 보관",
"command.project.switch": "프로젝트 {{number}}로 전환",
"command.palette": "명령 팔레트",
"command.theme.cycle": "테마 순환",
"command.theme.set": "테마 사용: {{theme}}",

packages/app/src/i18n/no.ts

+10
@@ -34,6 +34,7 @@ export const dict = {
"command.session.previous.unseen": "Forrige uleste økt",
"command.session.next.unseen": "Neste uleste økt",
"command.session.archive": "Arkiver sesjon",
"command.project.switch": "Bytt til prosjekt {{number}}",
"command.palette": "Kommandopalett",

packages/app/src/i18n/pl.ts

+10
@@ -29,6 +29,7 @@ export const dict = {
"command.session.previous.unseen": "Poprzednia nieprzeczytana sesja",
"command.session.next.unseen": "Następna nieprzeczytana sesja",
"command.session.archive": "Zarchiwizuj sesję",
"command.project.switch": "Przełącz na projekt {{number}}",
"command.palette": "Paleta poleceń",
"command.theme.cycle": "Przełącz motyw",
"command.theme.set": "Użyj motywu: {{theme}}",

packages/app/src/i18n/ru.ts

+10
@@ -31,6 +31,7 @@ export const dict = {
"command.session.previous.unseen": "Предыдущая непрочитанная сессия",
"command.session.next.unseen": "Следующая непрочитанная сессия",
"command.session.archive": "Архивировать сессию",
"command.project.switch": "Переключиться на проект {{number}}",
"command.palette": "Палитра команд",

packages/app/src/i18n/th.ts

+10
@@ -31,6 +31,7 @@ export const dict = {
"command.session.previous.unseen": "เซสชันที่ยังไม่ได้อ่านก่อนหน้า",
"command.session.next.unseen": "เซสชันที่ยังไม่ได้อ่านถัดไป",
"command.session.archive": "จัดเก็บเซสชัน",
"command.project.switch": "สลับไปโปรเจกต์ {{number}}",
"command.palette": "คำสั่งค้นหา",

packages/app/src/i18n/zh.ts

+10
@@ -40,6 +40,7 @@ export const dict = {
"command.session.previous.unseen": "上一个未读会话",
"command.session.next.unseen": "下一个未读会话",
"command.session.archive": "归档会话",
"command.project.switch": "切换到项目 {{number}}",
"command.palette": "命令面板",

packages/app/src/i18n/zht.ts

+10
@@ -35,6 +35,7 @@ export const dict = {
"command.session.previous.unseen": "上一個未讀會話",
"command.session.next.unseen": "下一個未讀會話",
"command.session.archive": "封存工作階段",
"command.project.switch": "切換到專案 {{number}}",
"command.palette": "命令面板",

packages/app/src/pages/layout.tsx

+194
@@ -1078,6 +1078,21 @@ export default function Layout(props: ParentProps) {
})
}
const projects = layout.projects.list()
for (let i = 0; i < 9; i++) {
const project = projects[i]
commands.push({
id: `project.switch.${i}`,
title: language.t("command.project.switch", { number: i + 1 }),
category: language.t("command.category.project"),
keybind: `mod+${i + 1}`,
disabled: !project,
onSelect: () => {
if (project) navigateToProject(project.worktree)
},
})
}
return commands
})
@@ -1952,8 +1967,8 @@ export default function Layout(props: ParentProps) {
opened={() => layout.sidebar.opened()}
aimMove={aim.move}
projects={() => layout.projects.list()}
renderProject={(project) => (
<SortableProject ctx={projectSidebarCtx} project={project} sortNow={sortNow} />
renderProject={(project, index) => (
<SortableProject ctx={projectSidebarCtx} project={project} sortNow={sortNow} index={index} />
)}
handleDragStart={handleDragStart}

packages/app/src/pages/layout/sidebar-project.tsx

+162
@@ -5,9 +5,10 @@ import { ContextMenu } from "@opencode-ai/ui/context-menu"
import { HoverCard } from "@opencode-ai/ui/hover-card"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { createSortable } from "@thisbeyond/solid-dnd"
import { type LocalProject } from "@/context/layout"
import { useCommand } from "@/context/command"
import { useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language"
import { useNotification } from "@/context/notification"
@@ -268,9 +269,11 @@ export const SortableProject = (props: {
mobile?: boolean
ctx: ProjectSidebarContext
sortNow: Accessor<number>
index: number
}): JSX.Element => {
const globalSync = useGlobalSync()
const language = useLanguage()
const command = useCommand()
const sortable = createSortable(props.project.worktree)
const selected = createMemo(() =>
projectSelected(props.ctx.currentDir(), props.project.worktree, props.project.sandboxes),
@@ -346,7 +349,18 @@ export const SortableProject =

packages/app/src/pages/layout/sidebar-shell.tsx

+22
@@ -18,7 +18,7 @@ export const SidebarContent = (props: {
opened: Accessor<boolean>
aimMove: (event: MouseEvent) => void
projects: Accessor<LocalProject[]>
renderProject: (project: LocalProject) => JSX.Element
renderProject: (project: LocalProject, index: number) => JSX.Element
handleDragStart: (event: unknown) => void
handleDragEnd: () => void
handleDragOver: (event: DragEvent) => void
@@ -53,7 +53,7 @@ export const SidebarContent = (props: {
<ConstrainDragXAxis />
<div class="h-full w-full flex flex-col items-center gap-3 px-3 py-2 overflow-y-auto no-scrollbar">
<SortableProvider ids={props.projects().map((p) => p.worktree)}>
<For each={props.projects()}>{(project) => props.renderProject(project)}</For>
<For each={props.projects()}>{(project, index) => props.renderProject(project, index())}</For>
</SortableProvider>
<Tooltip
placement={placement()}