diff --git a/apps/desktop/src/app/chat/close-tab.ts b/apps/desktop/src/app/chat/close-tab.ts new file mode 100644 index 000000000..100e14365 --- /dev/null +++ b/apps/desktop/src/app/chat/close-tab.ts @@ -0,0 +1,29 @@ +import { closeActiveTerminal } from '@/app/right-sidebar/terminal/terminals' +import { closeWorkspaceTab } from '@/components/pane-shell/tree/store' +import { isFocusWithin } from '@/lib/keybinds/combo' +import { $filePreviewTarget, $previewTarget, closeActiveRightRailTab } from '@/store/preview' + +/** + * ⌘W — close the tab of the context you're in, by precedence: + * 1. a focused terminal → its active terminal tab, + * 2. an open preview → its active preview tab (unchanged from pre-tiling), + * 3. the MAIN zone → its active tab (a session tile stacked into the workspace). + * Returns false when nothing closes, so ⌘W is a no-op — it never closes the + * window (a bare workspace stays put). Shared by the keyboard path (Win/Linux) + * and the macOS menu-accelerator IPC. + */ +export function closeActiveTab(): boolean { + if (isFocusWithin('[data-terminal]')) { + closeActiveTerminal() + + return true + } + + if ($filePreviewTarget.get() || $previewTarget.get()) { + closeActiveRightRailTab() + + return true + } + + return closeWorkspaceTab() +} diff --git a/apps/desktop/src/app/hooks/use-keybinds.ts b/apps/desktop/src/app/hooks/use-keybinds.ts index 379c719ad..fb0cf2ce3 100644 --- a/apps/desktop/src/app/hooks/use-keybinds.ts +++ b/apps/desktop/src/app/hooks/use-keybinds.ts @@ -1,18 +1,16 @@ import { useEffect, useRef } from 'react' import { useNavigate } from 'react-router-dom' +import { closeActiveTab } from '@/app/chat/close-tab' import { $terminalTakeover, setTerminalTakeover } from '@/app/right-sidebar/store' import { closeActiveTerminal, createTerminal, cycleTerminal } from '@/app/right-sidebar/terminal/terminals' -import { PANE_TOGGLE_REVEAL_EVENT } from '@/components/pane-shell' -import { matchesQuery } from '@/hooks/use-media-query' -import { PROFILE_SLOT_COUNT, SESSION_SLOT_COUNT } from '@/lib/keybinds/actions' +import { activateTreeTabSlot, cycleTreeTabInFocusedZone, layoutHasRootSide } from '@/components/pane-shell/tree/store' +import { contributedKeybindHandler, PROFILE_SLOT_COUNT, SESSION_SLOT_COUNT } from '@/lib/keybinds/actions' import { comboAllowedInInput, comboFromEvent, isEditableTarget } from '@/lib/keybinds/combo' import { $repoStatus } from '@/store/coding-status' import { toggleCommandPalette } from '@/store/command-palette' import { $capture, $comboIndex, endCapture, setBinding, toggleKeybindPanel } from '@/store/keybinds' import { - CHAT_SIDEBAR_PANE_ID, - FILE_BROWSER_PANE_ID, requestSessionSearchFocus, setFileBrowserOpen, toggleFileBrowserOpen, @@ -30,6 +28,7 @@ import { import { requestNewWorktree } from '@/store/projects' import { toggleReview } from '@/store/review' import { setModelPickerOpen } from '@/store/session' +import { reopenLastClosedTile } from '@/store/session-states' import { $switcherOpen, closeSwitcher, @@ -45,7 +44,6 @@ import { openNewSessionInNewWindow } from '@/store/windows' import { useTheme } from '@/themes/context' import { requestComposerFocus, requestVoiceToggle } from '../chat/composer/focus' -import { SIDEBAR_COLLAPSE_MEDIA_QUERY } from '../layout-constants' import { AGENTS_ROUTE, ARTIFACTS_ROUTE, @@ -62,6 +60,8 @@ export interface KeybindRuntimeDeps { toggleCommandCenter: () => void /** Drop to a fresh new-session draft. */ startFreshSession: () => void + /** Open a fresh session as a tab in the main zone (⌘T), leaving the primary. */ + openNewSessionTab: () => void /** Pin/unpin the active session. */ toggleSelectedPin: () => void } @@ -82,7 +82,13 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void { const profileSwitchHandlers: HandlerMap = {} for (let slot = 1; slot <= PROFILE_SLOT_COUNT; slot += 1) { - profileSwitchHandlers[`profile.switch.${slot}`] = () => switchProfileToSlot(slot) + // ⌘1…⌘9 switch the FOCUSED zone's tab when it's a real tab strip; only a + // single-pane (or unfocused) layout falls through to the profile switch. + profileSwitchHandlers[`profile.switch.${slot}`] = () => { + if (!activateTreeTabSlot(slot)) { + switchProfileToSlot(slot) + } + } } const goToSession = (sessionId: null | string) => { @@ -138,9 +144,12 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void { deps.startFreshSession() window.dispatchEvent(new CustomEvent('hermes:new-session-shortcut')) }, + 'session.newTab': () => deps.openNewSessionTab(), 'session.newWindow': () => void openNewSessionInNewWindow(), - 'session.next': () => stepSession(1), - 'session.prev': () => stepSession(-1), + // ⌃Tab cycles the focused session/main tab strip; only a non-tabbed focus + // falls through to the recent-session switcher. + 'session.next': () => void (cycleTreeTabInFocusedZone(1) || stepSession(1)), + 'session.prev': () => void (cycleTreeTabInFocusedZone(-1) || stepSession(-1)), ...sessionSlotHandlers, 'session.focusSearch': requestSessionSearchFocus, 'session.togglePin': deps.toggleSelectedPin, @@ -148,20 +157,13 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void { // through instead of silently doing nothing). 'workspace.newWorktree': () => $repoStatus.get() && requestNewWorktree(), - 'view.toggleSidebar': () => { - if (matchesQuery(SIDEBAR_COLLAPSE_MEDIA_QUERY)) { - window.dispatchEvent(new CustomEvent(PANE_TOGGLE_REVEAL_EVENT, { detail: { id: CHAT_SIDEBAR_PANE_ID } })) - } else { - toggleSidebarOpen() - } - }, - 'view.toggleRightSidebar': () => { - if (matchesQuery(SIDEBAR_COLLAPSE_MEDIA_QUERY)) { - window.dispatchEvent(new CustomEvent(PANE_TOGGLE_REVEAL_EVENT, { detail: { id: FILE_BROWSER_PANE_ID } })) - } else { - toggleFileBrowserOpen() - } - }, + // Narrow-viewport reveal is handled inside the store toggles now. + 'view.toggleSidebar': toggleSidebarOpen, + // ⌘J toggles the right sidebar — but a layout with no right side (e.g. + // terminal-on-bottom) would leave it a dead key, so it falls back to the + // terminal there. The single "secondary panel" toggle. + 'view.toggleRightSidebar': () => + layoutHasRootSide('right') ? toggleFileBrowserOpen() : setTerminalTakeover(!$terminalTakeover.get()), 'view.toggleReview': toggleReview, 'view.showFiles': showFiles, 'view.showTerminal': () => setTerminalTakeover(!$terminalTakeover.get()), @@ -177,6 +179,12 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void { 'view.prevTerminal': () => $terminalTakeover.get() && cycleTerminal(-1), 'view.closeTerminal': () => $terminalTakeover.get() && closeActiveTerminal(), 'view.flipPanes': togglePanesFlipped, + // ⌘W: close the focused tab (terminal / preview target / zone tree tab). + // On macOS the menu accelerator owns ⌘W and routes through the same + // closeActiveTab via IPC (see use-desktop-integrations); this binding is + // the Win/Linux path where ⌘W reaches the renderer directly. + 'view.closeTab': () => void closeActiveTab(), + 'view.reopenTab': reopenLastClosedTile, 'appearance.toggleMode': () => setMode(resolvedMode === 'dark' ? 'light' : 'dark'), @@ -242,7 +250,9 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void { return } - const handler = handlersRef.current[actionId] + // Built-in handlers first (they carry React context); contributed + // actions bring their own `run` through the registry. + const handler = handlersRef.current[actionId] ?? contributedKeybindHandler(actionId) if (!handler) { return diff --git a/apps/desktop/src/lib/keybinds/actions.ts b/apps/desktop/src/lib/keybinds/actions.ts index 913b7c96a..67a57b089 100644 --- a/apps/desktop/src/lib/keybinds/actions.ts +++ b/apps/desktop/src/lib/keybinds/actions.ts @@ -5,6 +5,8 @@ // like navigate / theme); labels come from i18n (`t.keybinds.actions[id]`). To // add a hotkey, add a row here and a handler there — nothing else. +import { registry } from '@/contrib/registry' + import { IS_MAC } from './combo' export type KeybindCategory = 'composer' | 'profiles' | 'session' | 'navigation' | 'view' @@ -22,6 +24,8 @@ export interface KeybindActionMeta { category: KeybindCategory /** Default combos. Empty = shipped unbound (user can assign one). */ defaults: readonly string[] + /** Display label for CONTRIBUTED actions (built-ins use i18n). */ + label?: string } // Positional switch slots for *named* profiles: ⌘1…⌘9 for profiles 1-9, then @@ -69,6 +73,7 @@ export const KEYBIND_ACTIONS: readonly KeybindActionMeta[] = [ // ── Session ────────────────────────────────────────────────────────────── { id: 'session.new', category: 'session', defaults: ['mod+n', 'shift+n'] }, + { id: 'session.newTab', category: 'session', defaults: ['mod+t'] }, { id: 'session.newWindow', category: 'session', defaults: ['mod+shift+n'] }, // ⌃Tab / ⌃⇧Tab — the universal tab-cycle chord. Literally Control, not Cmd // (macOS reserves Cmd+Tab for app switching); see `ctrl` in combo.ts. @@ -111,6 +116,11 @@ export const KEYBIND_ACTIONS: readonly KeybindActionMeta[] = [ { id: 'view.closeTerminal', category: 'view', defaults: ['ctrl+shift+w'] }, // ⌘\ — the backslash reads like a mirror line flipping the layout. { id: 'view.flipPanes', category: 'view', defaults: ['mod+\\'] }, + // ⌘W closes the focused zone's active tab — its own tab strip (preview) or + // the tree tab (session tiles, files, terminal). The uncloseable workspace + // is a no-op. ⌘⇧T reopens the last closed tab where it was. + { id: 'view.closeTab', category: 'view', defaults: ['mod+w'] }, + { id: 'view.reopenTab', category: 'view', defaults: ['mod+shift+t'] }, { id: 'appearance.toggleMode', category: 'view', defaults: ['shift+x'] }, { id: 'keybinds.openPanel', category: 'view', defaults: ['mod+/'] } ] @@ -119,14 +129,58 @@ export const KEYBIND_ACTION_IDS: readonly string[] = KEYBIND_ACTIONS.map(action const ACTION_BY_ID = new Map(KEYBIND_ACTIONS.map(action => [action.id, action])) +// ── Contributed actions — the `keybinds` registry area ────────────────────── +// Same declarative schema as every other surface: a data contribution carries +// the action's metadata AND its handler. Contributed actions are first-class: +// they dispatch, appear in the panel, are rebindable, and their overrides +// persist exactly like built-ins. Built-in ids can't be shadowed. + +export const KEYBINDS_AREA = 'keybinds' + +/** Payload of a `keybinds` data contribution. */ +export interface KeybindContribution { + id: string + /** Panel section. Defaults to `view`. */ + category?: KeybindCategory + /** Default combos (canonical form, e.g. `mod+shift+\\`). Empty = unbound. */ + defaults?: readonly string[] + label: string + run: () => void +} + +export function contributedKeybinds(): KeybindContribution[] { + return registry + .getArea(KEYBINDS_AREA) + .map(c => c.data as KeybindContribution) + .filter(k => Boolean(k?.id && k.label) && typeof k?.run === 'function' && !ACTION_BY_ID.has(k.id)) +} + +/** Built-ins + contributed, one metadata list (panel, bindings, conflicts). */ +export function allKeybindActions(): KeybindActionMeta[] { + return [ + ...KEYBIND_ACTIONS, + ...contributedKeybinds().map(k => ({ + id: k.id, + category: k.category ?? ('view' as const), + defaults: k.defaults ?? [], + label: k.label + })) + ] +} + export function keybindAction(id: string): KeybindActionMeta | undefined { - return ACTION_BY_ID.get(id) + return ACTION_BY_ID.get(id) ?? allKeybindActions().find(action => action.id === id) +} + +/** The contributed handler for an action id (built-ins wire theirs in use-keybinds). */ +export function contributedKeybindHandler(id: string): (() => void) | undefined { + return contributedKeybinds().find(k => k.id === id)?.run } export type KeybindBindings = Record export function defaultBindings(): KeybindBindings { - return Object.fromEntries(KEYBIND_ACTIONS.map(action => [action.id, [...action.defaults]])) + return Object.fromEntries(allKeybindActions().map(action => [action.id, [...action.defaults]])) } // Fixed, non-rebindable shortcuts surfaced read-only in the panel so the map is @@ -150,6 +204,5 @@ export const KEYBIND_READONLY: readonly KeybindReadonly[] = [ { id: 'composer.history', category: 'composer', keys: ['up', 'down'] }, { id: 'composer.cancel', category: 'composer', keys: ['escape'] }, // Fixed, context-local shortcuts surfaced for discoverability. - { id: 'view.terminalSelection', category: 'view', keys: ['mod+l'] }, - { id: 'view.closePreviewTab', category: 'view', keys: ['mod+w'] } + { id: 'view.terminalSelection', category: 'view', keys: ['mod+l'] } ]