Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions web/src/components/session/chat/textarea-controls.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { matchesAction } from '../../../shared/keybindings.js';

export function setupTextareaControls({
windowImpl = window,
textarea,
Expand Down Expand Up @@ -35,11 +37,11 @@ export function setupTextareaControls({
event.preventDefault();
form?.requestSubmit?.();
}
if (event.key === 'Tab' && event.shiftKey) {
if (matchesAction('cycle-thinking-level', event)) {
event.preventDefault();
getThinkingSelector()?.cycle?.();
}
if (event.ctrlKey && (event.key.toLowerCase() === 'i' || event.key.toLowerCase() === 'l')) {
if (matchesAction('open-model-selector', event)) {
event.preventDefault();
getModelSelector()?.open?.();
}
Expand Down
5 changes: 3 additions & 2 deletions web/src/routes/SessionsPage.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import { createStatusEvents } from '../shared/status-events.js';
import { openSessionPalette, refreshSessionPalette } from '../shared/command-palette-runtime.js';
import { setupKeyboardNav } from '../shared/keyboard-nav.js';
import { matchesAction } from '../shared/keybindings.js';
import { toggleTheme, syncThemeIcons } from '../shared/theme.js';
import {
configureSettingsSync,
Expand Down Expand Up @@ -253,14 +254,14 @@
} catch {}

const keydown = (e) => {
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key.toLowerCase() === 'l') {
if (matchesAction('toggle-theme', e)) {
e.preventDefault();
e.stopPropagation();
toggleTheme(window, document);
syncThemeIcons(document);
return;
}
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
if (matchesAction('open-palette', e)) {
e.preventDefault();
openPalette();
return;
Expand Down
13 changes: 7 additions & 6 deletions web/src/session/session-globals.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import * as doneNotifier from './chat/done-notifier.js';
import * as sidebarApi from './ui/sidebar.js';
import { openSessionPalette } from '../shared/command-palette-runtime.js';
import { setupKeyboardNav } from '../shared/keyboard-nav.js';
import { matchesAction } from '../shared/keybindings.js';
import { openShortcuts } from './session-modals.svelte.js';
import { sessionRuntime } from './session-runtime.js';
import { toggleTheme, syncThemeIcons } from '../shared/theme.js';
Expand Down Expand Up @@ -42,15 +43,15 @@ export function setupSessionGlobals({ windowImpl, documentImpl }) {
// ── Global keyboard shortcuts ──────────────────────────────────────────────
// Cmd+K — session list palette
on(target, 'keydown', (e) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
if (matchesAction('open-palette', e)) {
e.preventDefault();
openSessionPalette();
}
});

// Cmd+B — toggle sidebar/tree
on(target, 'keydown', (e) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'b') {
if (matchesAction('toggle-sidebar', e)) {
e.preventDefault();
const sidebar = documentImpl.getElementById('sidebar');
if (sidebarApi.isMobileLayout({ windowImpl: target })) {
Expand All @@ -67,7 +68,7 @@ export function setupSessionGlobals({ windowImpl, documentImpl }) {

// Cmd+T — new session
on(target, 'keydown', (e) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 't') {
if (matchesAction('new-session', e)) {
e.preventDefault();
const newBtn = documentImpl.getElementById('new-btn');
if (newBtn) newBtn.click();
Expand All @@ -80,7 +81,7 @@ export function setupSessionGlobals({ windowImpl, documentImpl }) {
target,
'keydown',
(e) => {
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key.toLowerCase() === 'l') {
if (matchesAction('toggle-theme', e)) {
e.preventDefault();
e.stopPropagation();
toggleTheme(target, documentImpl);
Expand All @@ -92,7 +93,7 @@ export function setupSessionGlobals({ windowImpl, documentImpl }) {

// Cmd+Shift+N — toggle scratchpad (right sidebar)
on(target, 'keydown', (e) => {
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key.toLowerCase() === 'n') {
if (matchesAction('toggle-scratchpad', e)) {
e.preventDefault();
sessionRuntime.rightSidebar?.toggle();
}
Expand All @@ -101,7 +102,7 @@ export function setupSessionGlobals({ windowImpl, documentImpl }) {
// Cmd+/ — keyboard shortcuts help modal (the <ShortcutsModal> Svelte
// component, opened via the shared sessionModals store).
on(target, 'keydown', (e) => {
if ((e.metaKey || e.ctrlKey) && e.key === '/') {
if (matchesAction('open-shortcuts-help', e)) {
e.preventDefault();
openShortcuts();
}
Expand Down
9 changes: 5 additions & 4 deletions web/src/session/ui/search-filters.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { matchesAction } from '../../shared/keybindings.js';

export function setupSessionSearchAndFilters({
documentImpl = document,
getLeafId,
Expand Down Expand Up @@ -68,14 +70,13 @@ export function setupSessionKeyboardShortcuts({
return;
}

const key = e.key.toLowerCase();
if (key === 't') {
if (matchesAction('toggle-thinking', e)) {
e.preventDefault();
toggleThinking();
} else if (key === 'o') {
} else if (matchesAction('toggle-tools', e)) {
e.preventDefault();
toggleToolsVisibility();
} else if (key === 'p') {
} else if (matchesAction('toggle-tool-outputs', e)) {
e.preventDefault();
toggleToolOutputs();
}
Expand Down
133 changes: 133 additions & 0 deletions web/src/shared/keybindings.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
// Central registry of remappable keyboard actions.
//
// Before this module, every shortcut was an inline `e.key === '...'` check
// scattered across the nav, session, filter, and composer handlers. Those
// handlers now ask the registry whether an event matches a named action, so a
// single source of truth defines the default binding for each one — and a later
// change can layer user overrides on top without touching the handlers again.
//
// Scope: only the global / navigation / composer shortcuts live here. Structural
// modal keys (Escape-to-close, arrow navigation, Tab focus-traps) and the
// composer's Enter-to-submit stay hardcoded in their components — they are UI
// affordances, not preferences. The multi-key `g g` sequence also stays in
// keyboard-nav.js; the registry models single chords only.

// A combo is a `+`-joined string of optional modifiers followed by one key,
// e.g. `mod+k`, `mod+shift+l`, `shift+i`, `ctrl+i`, `j`.
//
// mod → the platform command key: metaKey OR ctrlKey (⌘ on macOS, Ctrl
// elsewhere). This mirrors the existing `e.metaKey || e.ctrlKey` guards.
// ctrl → ctrlKey specifically, independent of ⌘. Used by the composer
// model-selector, which historically checked only ctrlKey.
// shift → shiftKey. alt → altKey.
//
// The final token is the key, matched case-insensitively against event.key
// (so `shift+i` matches the 'I' that Shift+i produces).

export const KEY_ACTIONS = [
// General
{ id: 'open-palette', category: 'general', combo: 'mod+k' },
{ id: 'toggle-sidebar', category: 'general', combo: 'mod+b' },
{ id: 'new-session', category: 'general', combo: 'mod+t' },
{ id: 'toggle-theme', category: 'general', combo: 'mod+shift+l' },
{ id: 'toggle-scratchpad', category: 'general', combo: 'mod+shift+n' },
{ id: 'open-shortcuts-help', category: 'general', combo: 'mod+/' },
{ id: 'open-settings', category: 'general', combo: 'mod+,' },

// Navigation. The calling handler already excludes editable targets and
// command modifiers. These match the literal produced key (`j`, `k`, and the
// shifted `G`/`I`) exactly as the original `e.key === …` checks did, so
// Caps Lock and every other path behave identically.
{ id: 'scroll-down', category: 'navigation', combo: 'j', literalKey: true },
{ id: 'scroll-up', category: 'navigation', combo: 'k', literalKey: true },
{ id: 'scroll-bottom', category: 'navigation', combo: 'shift+g', literalKey: true },
{ id: 'focus-composer', category: 'navigation', combo: 'shift+i', literalKey: true },

// Entry toggles. Their original handler matched the key alone with no
// modifier guard, so these stay key-only (`plain`) to preserve that exactly.
{ id: 'toggle-thinking', category: 'toggles', combo: 't', plain: true },
{ id: 'toggle-tools', category: 'toggles', combo: 'o', plain: true },
{ id: 'toggle-tool-outputs', category: 'toggles', combo: 'p', plain: true },

// Composer
{ id: 'cycle-thinking-level', category: 'composer', combo: 'shift+tab' },
// Historically opened by Ctrl+I or Ctrl+L; both remain until the settings UI
// lets users pick one.
{ id: 'open-model-selector', category: 'composer', combo: 'ctrl+i', aliases: ['ctrl+l'] },
];

const ACTIONS_BY_ID = new Map(KEY_ACTIONS.map((a) => [a.id, a]));

// Default combo for an action id (throws in tests via the map miss if unknown).
export function defaultCombo(actionId) {
return ACTIONS_BY_ID.get(actionId)?.combo ?? null;
}

// Parse a combo string into required modifiers and a normalized key.
export function parseCombo(combo) {
const tokens = String(combo).split('+');
const key = tokens[tokens.length - 1].toLowerCase();
const mods = new Set(tokens.slice(0, -1));
return {
key,
mod: mods.has('mod'),
ctrl: mods.has('ctrl'),
shift: mods.has('shift'),
alt: mods.has('alt'),
};
}

// expectedEventKey returns the KeyboardEvent.key value a combo produces, for
// combos matched by their literal key: a shifted single letter arrives
// uppercased (`shift+g` → `G`), everything else unchanged (`j` → `j`).
export function expectedEventKey(combo) {
const { key, shift } = parseCombo(combo);
if (shift && /^[a-z]$/.test(key)) return key.toUpperCase();
return key;
}

// comboMatchesEvent reports whether a modifier-bearing chord matches an event.
// Modifiers not named in the combo must be absent, so `mod+k` never fires for
// `mod+shift+k`. `mod` accepts meta or ctrl; `ctrl` requires ctrl specifically.
export function comboMatchesEvent(combo, event) {
const want = parseCombo(combo);
const hasCommand = Boolean(event.metaKey || event.ctrlKey);

if (want.mod) {
if (!hasCommand) return false;
} else if (want.ctrl) {
if (!event.ctrlKey) return false;
} else if (hasCommand || event.altKey) {
// Plain chords (no command modifier requested) must not carry ⌘/Ctrl/Alt.
return false;
}
// Shift is only enforced where it changes meaning. For letters (compared
// case-insensitively) and named keys like Tab, event.key is the same with or
// without Shift, so `mod+k` must reject Cmd+Shift+K. For punctuation,
// event.key is already the shifted result — some layouts need Shift to type
// `/` at all — so an unrequested Shift there is layout noise, not a
// different chord.
const shiftChangesMeaning = /^[a-z]$/.test(want.key) || want.key.length > 1;
if (want.shift && !event.shiftKey) return false;
if (!want.shift && event.shiftKey && shiftChangesMeaning) return false;
if (want.alt !== Boolean(event.altKey)) return false;
return String(event.key).toLowerCase() === want.key;
}

// matchesAction reports whether an event triggers the named action under its
// current binding. Plain-key actions (`plain: true`) compare only the key, so
// the caller's own editable/modifier guard stays authoritative — preserving the
// exact behavior of the pre-registry handlers.
export function matchesAction(actionId, event, bindings = {}) {
const action = ACTIONS_BY_ID.get(actionId);
if (!action) return false;
const combo = bindings[actionId] || action.combo;
if (action.literalKey) {
return event.key === expectedEventKey(combo);
}
if (action.plain) {
return String(event.key).toLowerCase() === parseCombo(combo).key;
}
if (comboMatchesEvent(combo, event)) return true;
return (action.aliases || []).some((alias) => comboMatchesEvent(alias, event));
}
Loading