feat(ui): numbered context-panel surface switching with configurable prefix

- Add switch_context_surface shortcut (default Cmd/Ctrl + 1..9, 0 for the 10th
  surface) that opens/closes/switches context panel rail surfaces by their
  visible order, configurable and persisted in Settings -> Shortcuts.
- Show order-number badges on rail icons while the modifier is held >500ms;
  dismiss on release, blur, or a number press until the next press-and-hold.
- Remove the legacy mod+2/3/4 (diff/terminal/git) and switch_tab_1..9 bindings
  so numbered surface switching goes only through the new mechanism.
- Replace the help-dialog 'Switch Project' row with the surface-switch row and
  update the shortcuts footer/header icons to the command icon.
This commit is contained in:
Bohdan Triapitsyn
2026-08-04 20:39:45 +03:00
parent 67965ced2f
commit f3dd894209
32 changed files with 574 additions and 124 deletions
@@ -1043,6 +1043,8 @@ export const settingsDict = {
'settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label': 'Rechte Seitenleiste umschalten',
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label': 'Git-Tab der rechten Seitenleiste öffnen',
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_files.label': 'Datei-Tab der rechten Seitenleiste öffnen',
'settings.openchamber.keyboardShortcuts.action.switch_context_surface.label': 'Kontextpanel-Oberfläche wechseln',
'settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix': ' + 1…0',
'settings.openchamber.keyboardShortcuts.action.new_chat.label': 'Neue Sitzung',
'settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label': 'Neuer Worktree-Entwurf',
'settings.openchamber.keyboardShortcuts.action.new_mini_chat.label': 'Neues Mini-Chat-Fenster',
+1 -1
View File
@@ -1558,7 +1558,7 @@ export const dict = {
'helpDialog.item.toggleTerminalExpanded': 'Terminal erweitert umschalten',
'helpDialog.item.togglePlanContextPanel': 'Plan-Kontext-Panel umschalten',
'helpDialog.item.cycleTheme': 'Thema wechseln (Hell → Dunkel → System)',
'helpDialog.item.switchProject': 'Projekt wechseln',
'helpDialog.item.switchContextSurface': 'Kontextpanel-Oberfläche wechseln (Zahlentaste)',
'helpDialog.item.toggleServicesMenu': 'Dienstemenü umschalten',
'helpDialog.item.cycleServicesTab': 'Dienste-Registerkarte durchgehen',
'helpDialog.item.openSettings': 'Einstellungen öffnen',
@@ -1108,6 +1108,8 @@ export const settingsDict = {
'settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label': 'Toggle context panel',
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label': 'Open Git surface',
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_files.label': 'Open Files surface',
'settings.openchamber.keyboardShortcuts.action.switch_context_surface.label': 'Switch context panel surface',
'settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix': ' + 1…0',
'settings.openchamber.keyboardShortcuts.action.new_chat.label': 'New session',
'settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label': 'New worktree draft',
'settings.openchamber.keyboardShortcuts.action.new_mini_chat.label': 'New Mini Chat window',
+1 -1
View File
@@ -1710,8 +1710,8 @@ export const dict = {
'helpDialog.item.toggleTerminalDock': 'Toggle Terminal Dock',
'helpDialog.item.toggleTerminalExpanded': 'Toggle Terminal Expanded',
'helpDialog.item.togglePlanContextPanel': 'Toggle Plan Context Panel',
'helpDialog.item.switchContextSurface': 'Switch Context Panel Surface (number key)',
'helpDialog.item.cycleTheme': 'Cycle Theme (Light → Dark → System)',
'helpDialog.item.switchProject': 'Switch Project',
'helpDialog.item.toggleServicesMenu': 'Toggle Services Menu',
'helpDialog.item.cycleServicesTab': 'Cycle Services Tab',
'helpDialog.item.openSettings': 'Open Settings',
@@ -1075,6 +1075,8 @@ export const settingsDict = {
"settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label": 'Alternar panel de contexto',
"settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label": 'Abrir superficie de Git',
"settings.openchamber.keyboardShortcuts.action.open_right_sidebar_files.label": 'Abrir superficie de archivos',
"settings.openchamber.keyboardShortcuts.action.switch_context_surface.label": "Cambiar superficie del panel de contexto",
"settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix": " + 1…0",
"settings.openchamber.keyboardShortcuts.action.new_chat.label": "Nueva sesión",
"settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label": "Nuevo borrador de worktree",
"settings.openchamber.keyboardShortcuts.action.new_mini_chat.label": "Nueva ventana Mini Chat",
+1 -1
View File
@@ -1689,7 +1689,7 @@ export const dict: Record<I18nKey, string> = {
"helpDialog.item.toggleTerminalExpanded": "Expandir o contraer terminal",
"helpDialog.item.togglePlanContextPanel": "Alternar panel de contexto del plan",
"helpDialog.item.cycleTheme": "Cambiar tema (Claro → Oscuro → Sistema)",
"helpDialog.item.switchProject": "Cambiar proyecto",
"helpDialog.item.switchContextSurface": "Cambiar superficie del panel de contexto (tecla numérica)",
"helpDialog.item.toggleServicesMenu": "Mostrar u ocultar menú de servicios",
"helpDialog.item.cycleServicesTab": "Cambiar pestaña de servicios",
"helpDialog.item.openSettings": "Abrir configuración",
@@ -996,6 +996,8 @@ export const settingsDict = {
'settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label': 'Afficher/masquer le panneau de contexte',
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label': 'Ouvrir la surface Git',
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_files.label': 'Ouvrir la surface Fichiers',
'settings.openchamber.keyboardShortcuts.action.switch_context_surface.label': 'Basculer la surface du panneau contextuel',
'settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix': ' + 1…0',
'settings.openchamber.keyboardShortcuts.action.new_chat.label': 'Nouvelle session',
'settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label': 'Nouvelle ébauche d\'worktree',
'settings.openchamber.keyboardShortcuts.action.new_mini_chat.label': 'Nouvelle fenêtre de mini-chat',
+1 -1
View File
@@ -1524,7 +1524,7 @@ export const dict = {
'helpDialog.item.toggleTerminalExpanded': 'Terminal à bascule étendu',
'helpDialog.item.togglePlanContextPanel': 'Toggle Panneau contextuel du plan',
'helpDialog.item.cycleTheme': 'Basculer le thème (clair → sombre → système)',
'helpDialog.item.switchProject': 'Changer de projet',
'helpDialog.item.switchContextSurface': 'Basculer la surface du panneau contextuel (touche numérique)',
'helpDialog.item.toggleServicesMenu': 'Basculer le menu des services',
'helpDialog.item.cycleServicesTab': 'Onglet Services de vélo',
'helpDialog.item.openSettings': 'Ouvrir les paramètres',
@@ -1108,6 +1108,8 @@ export const settingsDict = {
'settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label': 'コンテキストパネルの表示切替',
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label': 'Git サーフェスを開く',
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_files.label': 'ファイルサーフェスを開く',
'settings.openchamber.keyboardShortcuts.action.switch_context_surface.label': 'コンテキストパネルのサーフェスを切り替え',
'settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix': ' + 1…0',
'settings.openchamber.keyboardShortcuts.action.new_chat.label': '新しい Session',
'settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label': '新しい Worktree 下書き',
'settings.openchamber.keyboardShortcuts.action.new_mini_chat.label': '新しいミニチャットウィンドウ',
+1 -1
View File
@@ -1707,7 +1707,7 @@ export const dict: Record<I18nKey, string> = {
'helpDialog.item.toggleTerminalExpanded': 'ターミナル展開の切り替え',
'helpDialog.item.togglePlanContextPanel': '計画コンテキストパネルの切り替え',
'helpDialog.item.cycleTheme': 'テーマ切り替え(ライト→ダーク→システム)',
'helpDialog.item.switchProject': 'プロジェクトを切り替え',
'helpDialog.item.switchContextSurface': 'コンテキストパネルのサーフェスを切り替え(数字キー)',
'helpDialog.item.toggleServicesMenu': 'サービスの切り替え',
'helpDialog.item.cycleServicesTab': 'サービス変数の切り替え',
'helpDialog.item.openSettings': '設定を開く',
@@ -1075,6 +1075,8 @@ export const settingsDict = {
'settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label': '컨텍스트 패널 표시 전환',
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label': 'Git 서피스 열기',
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_files.label': '파일 서피스 열기',
'settings.openchamber.keyboardShortcuts.action.switch_context_surface.label': '컨텍스트 패널 서피스 전환',
'settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix': ' + 1…0',
'settings.openchamber.keyboardShortcuts.action.new_chat.label': '새 세션',
'settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label': '새 worktree 초안',
'settings.openchamber.keyboardShortcuts.action.new_mini_chat.label': '새 Mini Chat 창',
+1 -1
View File
@@ -1713,7 +1713,7 @@ export const dict: Record<I18nKey, string> = {
'helpDialog.item.toggleTerminalExpanded': '터미널 펼치기/접기',
'helpDialog.item.togglePlanContextPanel': '플랜 컨텍스트 패널 전환',
'helpDialog.item.cycleTheme': '테마 순환(라이트 → 다크 → 시스템)',
'helpDialog.item.switchProject': '프로젝트 전환',
'helpDialog.item.switchContextSurface': '컨텍스트 패널 서피스 전환(숫자 키)',
'helpDialog.item.toggleServicesMenu': '서비스 메뉴 전환',
'helpDialog.item.cycleServicesTab': '서비스 탭 순환',
'helpDialog.item.openSettings': '설정 열기',
@@ -820,6 +820,8 @@ export const settingsDict = {
'settings.openchamber.keyboardShortcuts.action.open_go_to_line.label': 'Przejdź do linii (edytor plików)',
'settings.openchamber.keyboardShortcuts.action.open_help.label': 'Otwórz skróty klawiszowe',
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_files.label': 'Otwórz powierzchnię plików',
'settings.openchamber.keyboardShortcuts.action.switch_context_surface.label': 'Przełącz powierzchnię panelu kontekstu',
'settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix': ' + 1…0',
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label': 'Otwórz powierzchnię Git',
'settings.openchamber.keyboardShortcuts.action.open_settings.label': 'Otwórz ustawienia',
'settings.openchamber.keyboardShortcuts.action.toggle_context_plan.label': 'Przełącz panel kontekstu planu',
+1 -1
View File
@@ -2333,7 +2333,7 @@ export const dict: Record<I18nKey, string> = {
'helpDialog.item.openRightSidebarGitTab': 'Otwórz powierzchnię Git',
'helpDialog.item.openSettings': 'Otwórz ustawienia',
'helpDialog.item.showKeyboardShortcuts': 'Pokaż skróty klawiaturowe (to okno)',
'helpDialog.item.switchProject': 'Przełącz projekt',
'helpDialog.item.switchContextSurface': 'Przełącz powierzchnię panelu kontekstu (klawisz liczbowy)',
'helpDialog.item.togglePlanContextPanel': 'Przełącz panel kontekstu planu',
'helpDialog.item.toggleRightSidebar': 'Przełącz panel kontekstu',
'helpDialog.item.toggleServicesMenu': 'Przełącz menu usług',
@@ -1075,6 +1075,8 @@ export const settingsDict = {
"settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label": 'Alternar painel de contexto',
"settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label": 'Abrir superfície do Git',
"settings.openchamber.keyboardShortcuts.action.open_right_sidebar_files.label": 'Abrir superfície de arquivos',
"settings.openchamber.keyboardShortcuts.action.switch_context_surface.label": "Alternar superfície do painel de contexto",
"settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix": " + 1…0",
"settings.openchamber.keyboardShortcuts.action.new_chat.label": "Nova sessão",
"settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label": "Novo rascunho de worktree",
"settings.openchamber.keyboardShortcuts.action.new_mini_chat.label": "Nova janela Mini Chat",
+1 -1
View File
@@ -1689,7 +1689,7 @@ export const dict: Record<I18nKey, string> = {
"helpDialog.item.toggleTerminalExpanded": "Expandir ou recolher o terminal",
"helpDialog.item.togglePlanContextPanel": "Alternar painel de contexto do plano",
"helpDialog.item.cycleTheme": "Alternar tema (Claro → Escuro → Sistema)",
"helpDialog.item.switchProject": "Alternar projeto",
"helpDialog.item.switchContextSurface": "Alternar superfície do painel de contexto (tecla numérica)",
"helpDialog.item.toggleServicesMenu": "Mostrar ou ocultar menu de serviços",
"helpDialog.item.cycleServicesTab": "Alternar aba de serviços",
"helpDialog.item.openSettings": "Abrir configurações",
@@ -1075,6 +1075,8 @@ export const settingsDict = {
"settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label": 'Перемкнути контекстну панель',
"settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label": 'Відкрити поверхню Git',
"settings.openchamber.keyboardShortcuts.action.open_right_sidebar_files.label": 'Відкрити поверхню файлів',
"settings.openchamber.keyboardShortcuts.action.switch_context_surface.label": "Перемкнути поверхню панелі контексту",
"settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix": " + 1…0",
"settings.openchamber.keyboardShortcuts.action.new_chat.label": "Нова сесія",
"settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label": "Нова чернетка worktree",
"settings.openchamber.keyboardShortcuts.action.new_mini_chat.label": "Нове вікно Mini Chat",
+1 -1
View File
@@ -1689,7 +1689,7 @@ export const dict: Record<I18nKey, string> = {
"helpDialog.item.toggleTerminalExpanded": "Розгорнути або згорнути термінал",
"helpDialog.item.togglePlanContextPanel": "Перемкнути панель контексту плану",
"helpDialog.item.cycleTheme": "Перемкнути тему (Світла → Темна → Системна)",
"helpDialog.item.switchProject": "Перемкнути проєкт",
"helpDialog.item.switchContextSurface": "Перемкнути поверхню панелі контексту (цифрова клавіша)",
"helpDialog.item.toggleServicesMenu": "Перемкнути меню сервісів",
"helpDialog.item.cycleServicesTab": "Перемкнути вкладку сервісів",
"helpDialog.item.openSettings": "Відкрити налаштування",
@@ -1075,6 +1075,8 @@ export const settingsDict = {
'settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label': '切换上下文面板',
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label': '打开 Git 界面',
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_files.label': '打开文件界面',
'settings.openchamber.keyboardShortcuts.action.switch_context_surface.label': '切换上下文面板界面',
'settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix': ' + 1…0',
'settings.openchamber.keyboardShortcuts.action.new_chat.label': '新建会话',
'settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label': '新建工作树草稿',
'settings.openchamber.keyboardShortcuts.action.new_mini_chat.label': '新建 Mini Chat 窗口',
+1 -1
View File
@@ -1677,7 +1677,7 @@ export const dict: Record<I18nKey, string> = {
'helpDialog.item.toggleTerminalExpanded': '切换终端展开状态',
'helpDialog.item.togglePlanContextPanel': '切换计划上下文面板',
'helpDialog.item.cycleTheme': '循环切换主题(浅色 → 深色 → 跟随系统)',
'helpDialog.item.switchProject': '切换项目',
'helpDialog.item.switchContextSurface': '切换上下文面板界面(数字键)',
'helpDialog.item.toggleServicesMenu': '切换服务菜单',
'helpDialog.item.cycleServicesTab': '循环服务标签',
'helpDialog.item.openSettings': '打开设置',
@@ -982,6 +982,8 @@
'settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label': '切換上下文面板',
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label': '開啟 Git 介面',
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_files.label': '開啟檔案介面',
'settings.openchamber.keyboardShortcuts.action.switch_context_surface.label': '切換上下文面板介面',
'settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix': ' + 1…0',
'settings.openchamber.keyboardShortcuts.action.new_chat.label': '新建工作階段',
'settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label': '新增 worktree 草稿',
'settings.openchamber.keyboardShortcuts.action.new_mini_chat.label': '新建 Mini Chat 視窗',
+1 -1
View File
@@ -1681,7 +1681,7 @@ export const dict: Record<I18nKey, string> = {
'helpDialog.item.toggleTerminalExpanded': '切換終端機展開狀態',
'helpDialog.item.togglePlanContextPanel': '切換計畫上下文面板',
'helpDialog.item.cycleTheme': '循環切換主題(淺色 → 深色 → 跟隨系統)',
'helpDialog.item.switchProject': '切換專案',
'helpDialog.item.switchContextSurface': '切換上下文面板介面(數字鍵)',
'helpDialog.item.toggleServicesMenu': '切換服務選單',
'helpDialog.item.cycleServicesTab': '循環服務標籤',
'helpDialog.item.openSettings': '開啟設定',
+80
View File
@@ -0,0 +1,80 @@
import { describe, expect, test } from 'bun:test';
import {
eventMatchesShortcutPrefix,
getEffectiveShortcutPrefix,
isShortcutPrefixHeld,
UNASSIGNED_SHORTCUT,
} from './shortcuts';
describe('getEffectiveShortcutPrefix', () => {
test('falls back to the action default (bare mod) when unset', () => {
expect(getEffectiveShortcutPrefix('switch_context_surface', {})).toBe('mod');
});
test('honors modifier + key overrides', () => {
expect(getEffectiveShortcutPrefix('switch_context_surface', { switch_context_surface: 'mod+p' })).toBe('mod+p');
});
test('honors modifier-only overrides', () => {
expect(getEffectiveShortcutPrefix('switch_context_surface', { switch_context_surface: 'shift' })).toBe('shift');
});
test('returns UNASSIGNED for an explicit unassignment', () => {
expect(
getEffectiveShortcutPrefix('switch_context_surface', { switch_context_surface: UNASSIGNED_SHORTCUT }),
).toBe(UNASSIGNED_SHORTCUT);
});
test('returns empty string for an unknown action', () => {
expect(getEffectiveShortcutPrefix('does_not_exist', {})).toBe('');
});
});
describe('isShortcutPrefixHeld', () => {
test('false for an unassigned prefix', () => {
expect(isShortcutPrefixHeld(UNASSIGNED_SHORTCUT, new Set(['control']))).toBe(false);
});
test('requires the prefix primary key to be held', () => {
expect(isShortcutPrefixHeld('mod+p', new Set(['control']))).toBe(false);
expect(isShortcutPrefixHeld('mod+p', new Set(['control', 'p']))).toBe(true);
});
test('requires every prefix modifier to be held', () => {
expect(isShortcutPrefixHeld('mod+shift', new Set(['control']))).toBe(false);
expect(isShortcutPrefixHeld('mod+shift', new Set(['control', 'shift']))).toBe(true);
});
});
const keydown = (key: string, mods: { meta?: boolean; ctrl?: boolean; shift?: boolean; alt?: boolean }): KeyboardEvent =>
({
key,
metaKey: mods.meta ?? false,
ctrlKey: mods.ctrl ?? false,
shiftKey: mods.shift ?? false,
altKey: mods.alt ?? false,
}) as KeyboardEvent;
describe('eventMatchesShortcutPrefix', () => {
test('matches a bare mod prefix when the primary modifier is held', () => {
expect(eventMatchesShortcutPrefix(keydown('1', { ctrl: true }), 'mod')).toBe(true);
});
test('rejects a bare mod prefix without the primary modifier', () => {
expect(eventMatchesShortcutPrefix(keydown('1', {}), 'mod')).toBe(false);
});
test('rejects when the event carries modifiers the prefix does not expect', () => {
expect(eventMatchesShortcutPrefix(keydown('1', { ctrl: true, shift: true }), 'mod')).toBe(false);
});
test('requires the prefix primary key to be held at match time', () => {
expect(eventMatchesShortcutPrefix(keydown('1', { ctrl: true }), 'mod+p', new Set(['control']))).toBe(false);
expect(eventMatchesShortcutPrefix(keydown('1', { ctrl: true }), 'mod+p', new Set(['control', 'p']))).toBe(true);
});
test('false for an unassigned prefix', () => {
expect(eventMatchesShortcutPrefix(keydown('1', { ctrl: true }), UNASSIGNED_SHORTCUT)).toBe(false);
});
});
+141 -72
View File
@@ -39,6 +39,17 @@ const DISPLAY_LABEL_MAP: Record<ShortcutModifier, string> = {
'ctrl': '⌃',
};
// Physical `event.key` values (lowercased) that satisfy each modifier while a
// chord is being held. `mod` maps to the platform primary key; on web macOS it
// accepts either Meta or Ctrl, matching eventMatchesShortcut.
const MODIFIER_KEY_ALIASES: Record<ShortcutModifier, readonly string[]> = {
'mod': isMacOS() && isDesktopShell() ? ['meta'] : isMacOS() ? ['meta', 'control'] : ['control'],
'shift': ['shift'],
'alt': ['alt'],
'option': ['alt'],
'ctrl': ['control'],
};
const KEY_LABEL_MAP: Record<string, string> = {
'comma': ',',
'period': '.',
@@ -207,6 +218,13 @@ const SHORTCUT_ACTIONS: ReadonlyArray<ShortcutAction> = [
description: 'Open right sidebar and select Files',
customizable: true,
},
{
id: 'switch_context_surface',
defaultCombo: 'mod',
label: 'Switch context panel surface',
description: 'Hold the modifier and press a number to open or close the matching rail icon',
customizable: true,
},
{
id: 'new_chat',
defaultCombo: 'mod+n',
@@ -240,24 +258,6 @@ const SHORTCUT_ACTIONS: ReadonlyArray<ShortcutAction> = [
label: 'Clear input',
description: 'Clear the input field',
},
{
id: 'open_diff_panel',
defaultCombo: 'mod+2',
label: 'Open diff panel',
description: 'Switch to the diff panel',
},
{
id: 'open_terminal_panel',
defaultCombo: 'mod+3',
label: 'Open terminal panel',
description: 'Switch to the terminal panel',
},
{
id: 'open_git_panel',
defaultCombo: 'mod+4',
label: 'Open git panel',
description: 'Switch to the git panel',
},
{
id: 'open_help',
defaultCombo: 'mod+.',
@@ -347,60 +347,6 @@ const SHORTCUT_ACTIONS: ReadonlyArray<ShortcutAction> = [
label: 'Abort active run',
description: 'Abort the currently running task (double press)',
},
{
id: 'switch_tab_1',
defaultCombo: 'mod+1',
label: 'Switch to tab 1',
description: 'Switch to the first tab or project',
},
{
id: 'switch_tab_2',
defaultCombo: 'mod+2',
label: 'Switch to tab 2',
description: 'Switch to the second tab or project',
},
{
id: 'switch_tab_3',
defaultCombo: 'mod+3',
label: 'Switch to tab 3',
description: 'Switch to the third tab or project',
},
{
id: 'switch_tab_4',
defaultCombo: 'mod+4',
label: 'Switch to tab 4',
description: 'Switch to the fourth tab or project',
},
{
id: 'switch_tab_5',
defaultCombo: 'mod+5',
label: 'Switch to tab 5',
description: 'Switch to the fifth tab or project',
},
{
id: 'switch_tab_6',
defaultCombo: 'mod+6',
label: 'Switch to tab 6',
description: 'Switch to the sixth tab or project',
},
{
id: 'switch_tab_7',
defaultCombo: 'mod+7',
label: 'Switch to tab 7',
description: 'Switch to the seventh tab or project',
},
{
id: 'switch_tab_8',
defaultCombo: 'mod+8',
label: 'Switch to tab 8',
description: 'Switch to the eighth tab or project',
},
{
id: 'switch_tab_9',
defaultCombo: 'mod+9',
label: 'Switch to tab 9',
description: 'Switch to the ninth tab or project',
},
] as const;
export function normalizeCombo(combo: ShortcutCombo): ShortcutCombo {
@@ -610,3 +556,126 @@ export function eventMatchesShortcut(
export function getModifierLabel(): string {
return isMacOS() && isDesktopShell() ? '⌘' : 'Ctrl';
}
/**
* Resolves the configurable prefix for chord-style shortcuts such as
* "switch context panel surface", where a trailing digit key completes the
* combo. Unlike getEffectiveShortcutCombo, modifier-only overrides (e.g. the
* bare `mod` primary key) are honored so the prefix can omit a primary key.
* Returns UNASSIGNED_SHORTCUT when the user explicitly unassigned the prefix.
*/
export function getEffectiveShortcutPrefix(
actionId: string,
overrides?: Record<string, ShortcutCombo>,
): ShortcutCombo {
const action = getShortcutAction(actionId);
if (!action) {
return '';
}
const override = overrides?.[actionId];
if (typeof override === 'string' && override.trim() !== '') {
const normalized = normalizeCombo(override);
if (normalized === UNASSIGNED_SHORTCUT) {
return UNASSIGNED_SHORTCUT;
}
if (normalized) {
const parsed = parseShortcut(normalized);
if (parsed.modifiers.size > 0 || parsed.key) {
return normalized;
}
}
}
return action.defaultCombo;
}
/**
* True when the physical keys required to "arm" a prefix combo are currently
* held. For modifiers with multiple aliases (e.g. `mod` on web macOS), at
* least one alias must be held.
*/
export function isShortcutPrefixHeld(prefixCombo: ShortcutCombo, heldKeys: ReadonlySet<string>): boolean {
if (isUnassignedShortcut(prefixCombo)) {
return false;
}
const parsed = parseShortcut(prefixCombo);
for (const modifier of parsed.modifiers) {
const aliases = MODIFIER_KEY_ALIASES[modifier];
if (!aliases.some((alias) => heldKeys.has(alias))) {
return false;
}
}
if (parsed.key && !heldKeys.has(parsed.key.toLowerCase())) {
return false;
}
return true;
}
/**
* Matches an activating keydown (the caller checks the event's own key, e.g. a
* digit) against a chord prefix: the event's modifier state must match the
* prefix's modifiers, and when the prefix has a primary key that key must
* currently be held.
*/
export function eventMatchesShortcutPrefix(
event: KeyboardEvent | React.KeyboardEvent,
prefixCombo: ShortcutCombo,
heldKeys?: ReadonlySet<string>,
): boolean {
if (isUnassignedShortcut(prefixCombo)) {
return false;
}
const parsed = parseShortcut(prefixCombo);
const expectedMod = parsed.modifiers.has('mod');
const expectedShift = parsed.modifiers.has('shift');
const expectedAlt = parsed.modifiers.has('alt');
const expectedCtrl = parsed.modifiers.has('ctrl');
const isDesktopMac = isMacOS() && isDesktopShell();
const isMac = isMacOS();
const modMatches = isDesktopMac
? event.metaKey
: isMac
? (event.metaKey || event.ctrlKey)
: event.ctrlKey;
if (expectedMod && !modMatches) {
return false;
}
if (!expectedMod && event.metaKey) {
return false;
}
if (expectedShift !== event.shiftKey) {
return false;
}
if (expectedAlt !== event.altKey) {
return false;
}
if (expectedCtrl) {
if (!event.ctrlKey) {
return false;
}
} else {
const ctrlUsedAsMod = expectedMod && !isDesktopMac && event.ctrlKey;
if (event.ctrlKey && !ctrlUsedAsMod) {
return false;
}
}
if (parsed.key && (!heldKeys || !heldKeys.has(parsed.key.toLowerCase()))) {
return false;
}
return true;
}
@@ -20,6 +20,13 @@ edge (`components/layout/ContextPanelRail.tsx`) and rendered by
- Rail order is user-reorderable and persisted globally in
`useUIStore.contextRailOrder`; `sortContextSurfaces` applies it on top of the
registry's default order and appends any missing surfaces.
- `getVisibleContextRailSurfaces` is the single visibility filter shared by the
rail and the global surface-switch shortcut (`switch_context_surface` in
`lib/shortcuts.ts`): it drops the plan surface unless plan mode is enabled,
drops the walkthrough on VS Code and below `WALKTHROUGH_MIN_WIDTH`, and hides
`has-content` surfaces until a tab of their mode exists. Both consumers use
it so the digit shown on a rail badge always maps to the same surface the
shortcut opens.
## Adding a surface
@@ -0,0 +1,53 @@
import { describe, expect, test } from 'bun:test';
import {
CONTEXT_SURFACES,
getVisibleContextRailSurfaces,
WALKTHROUGH_MIN_WIDTH,
} from './registry';
const baseOptions = {
railOrder: [],
planModeEnabled: true,
isVSCode: false,
screenWidth: 1200,
tabs: [],
} as const;
describe('getVisibleContextRailSurfaces', () => {
test('hides the plan surface while plan mode is disabled', () => {
const surfaces = getVisibleContextRailSurfaces({ ...baseOptions, planModeEnabled: false });
expect(surfaces.some((surface) => surface.id === 'plan')).toBe(false);
expect(surfaces.some((surface) => surface.id === 'context')).toBe(true);
});
test('shows the plan surface while plan mode is enabled', () => {
const surfaces = getVisibleContextRailSurfaces({ ...baseOptions, planModeEnabled: true });
expect(surfaces.some((surface) => surface.id === 'plan')).toBe(true);
});
test('hides the walkthrough on VS Code and below the min width', () => {
expect(getVisibleContextRailSurfaces({ ...baseOptions, isVSCode: true }).some((s) => s.id === 'walkthrough')).toBe(false);
expect(
getVisibleContextRailSurfaces({ ...baseOptions, screenWidth: WALKTHROUGH_MIN_WIDTH - 1 }).some((s) => s.id === 'walkthrough'),
).toBe(false);
expect(
getVisibleContextRailSurfaces({ ...baseOptions, screenWidth: WALKTHROUGH_MIN_WIDTH }).some((s) => s.id === 'walkthrough'),
).toBe(true);
});
test('hides content-driven surfaces until a matching tab exists', () => {
const preview = CONTEXT_SURFACES.find((surface) => surface.id === 'preview');
if (!preview) {
throw new Error('preview surface missing from registry');
}
expect(preview.availability).toBe('has-content');
expect(getVisibleContextRailSurfaces(baseOptions).some((s) => s.id === 'preview')).toBe(false);
expect(getVisibleContextRailSurfaces({ ...baseOptions, tabs: [{ mode: preview.mode }] }).some((s) => s.id === 'preview')).toBe(true);
});
test('respects the persisted user rail order', () => {
const surfaces = getVisibleContextRailSurfaces({ ...baseOptions, railOrder: ['git', 'context'] });
expect(surfaces.slice(0, 2).map((surface) => surface.id)).toEqual(['git', 'context']);
});
});
+37
View File
@@ -152,6 +152,10 @@ export const CONTEXT_SURFACES: readonly ContextSurfaceDescriptor[] = [
const SURFACE_BY_ID = new Map(CONTEXT_SURFACES.map((surface) => [surface.id, surface]));
const FRACTION_BY_MODE = new Map(CONTEXT_SURFACES.map((surface) => [surface.mode, surface.defaultWidthFraction]));
// Tablet width and up: below this the walkthrough cannot show a stop and its
// code side by side, which is the whole point of the surface.
export const WALKTHROUGH_MIN_WIDTH = 768;
export const getContextSurfaceWidthFraction = (mode: ContextPanelMode): number => {
return FRACTION_BY_MODE.get(mode) ?? 1 / 2;
};
@@ -187,3 +191,36 @@ export const sortContextSurfaces = (railOrder: readonly string[]): ContextSurfac
return ordered;
};
type VisibleRailSurfacesOptions = {
railOrder: readonly string[];
planModeEnabled: boolean;
isVSCode: boolean;
screenWidth: number;
tabs: readonly { mode: ContextPanelMode }[];
};
/**
* The context panel rail's visible, user-ordered surfaces. Shared by the rail
* (for rendering and number badges) and the global surface-switch shortcut so
* both agree on which surface each digit maps to.
*
* Content-driven surfaces are hidden (not disabled) until content exists; an
* existing tab keeps them visible even if the content source went away.
*/
export const getVisibleContextRailSurfaces = (options: VisibleRailSurfacesOptions): ContextSurfaceDescriptor[] => {
return sortContextSurfaces(options.railOrder).filter((surface) => {
if (surface.id === 'plan' && !options.planModeEnabled) {
return false;
}
// The walkthrough needs room for a stop list beside real code, and its
// diffs come from OpenChamber's Git routes, which VS Code does not serve.
if (surface.id === 'walkthrough' && (options.isVSCode || options.screenWidth < WALKTHROUGH_MIN_WIDTH)) {
return false;
}
if (surface.availability === 'has-content') {
return options.tabs.some((tab) => tab.mode === surface.mode);
}
return true;
});
};