diff --git a/frontend/src/app/AppShell.test.tsx b/frontend/src/app/AppShell.test.tsx
index 56f32b0a..d753ab19 100644
--- a/frontend/src/app/AppShell.test.tsx
+++ b/frontend/src/app/AppShell.test.tsx
@@ -137,7 +137,9 @@ describe('index route renders the default view without changing the URL', () =>
const { router } = renderShellAt('/')
// The real ChatPage renders in place (its full-bleed thread region), not the
// Overview view — proving the mobile index default resolves to chat.
- await waitFor(() => expect(document.querySelector('.chat-thread')).not.toBeNull())
+ await waitFor(() => expect(document.querySelector('.chat-thread')).not.toBeNull(), {
+ timeout: 5000,
+ })
expect(router.state.location.pathname).toBe('/')
// On mobile the closed drawer is aria-hidden/inert, so the nav link is not
// in the accessibility tree; assert the highlight via the DOM node instead.
diff --git a/frontend/src/app/providers.tsx b/frontend/src/app/providers.tsx
index 8ba446d5..7764bf82 100644
--- a/frontend/src/app/providers.tsx
+++ b/frontend/src/app/providers.tsx
@@ -6,6 +6,7 @@ import { useConnection } from '@/stores/connection'
import { initTheme } from '@/stores/theme'
import { approvalMonitor } from '@/services/approval-monitor'
import type { RpcState } from '@/lib/ws-rpc'
+import { KeyboardShortcutProvider } from '@/components/KeyboardShortcuts'
const WS_URL_KEY = 'agentos.wsUrl'
const WS_TOKEN_KEY = 'agentos.wsToken'
@@ -89,7 +90,9 @@ export function AppProviders({ children }: { children: ReactNode }) {
return (
- {children}
+
+ {children}
+
)
diff --git a/frontend/src/components/KeyboardShortcuts.css b/frontend/src/components/KeyboardShortcuts.css
new file mode 100644
index 00000000..13aef69d
--- /dev/null
+++ b/frontend/src/components/KeyboardShortcuts.css
@@ -0,0 +1,128 @@
+@layer components {
+ .help-modal__overlay {
+ position: fixed;
+ inset: 0;
+ z-index: var(--z-critical-approval);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 24px;
+ background: rgba(3, 6, 10, 0.75);
+ backdrop-filter: blur(12px);
+ }
+
+ .help-modal {
+ width: 100%;
+ max-width: 520px;
+ max-height: calc(100dvh - 48px);
+ display: flex;
+ flex-direction: column;
+ background: var(--surface, #0e0e13);
+ border: 1px solid var(--hairline, rgba(204, 255, 0, 0.1));
+ border-radius: var(--radius-dialog, 18px);
+ box-shadow: 0 32px 80px -20px rgba(0, 0, 0, 0.8);
+ overflow: hidden;
+ }
+
+ .help-modal__head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 20px 24px;
+ border-bottom: 1px solid var(--hairline, rgba(204, 255, 0, 0.1));
+ }
+
+ .help-modal__title {
+ font-size: 1.125rem;
+ font-weight: 600;
+ color: var(--foreground, #ececef);
+ letter-spacing: -0.01em;
+ }
+
+ .help-modal__close {
+ background: transparent;
+ border: none;
+ color: var(--dim, #93939e);
+ cursor: pointer;
+ padding: 4px;
+ border-radius: var(--radius-compact, 6px);
+ transition: all 0.15s ease;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ }
+
+ .help-modal__close:hover {
+ color: var(--foreground, #ececef);
+ background: var(--elevated, #17171d);
+ }
+
+ .help-modal__body {
+ padding: 24px;
+ overflow-y: auto;
+ display: flex;
+ flex-direction: column;
+ gap: 24px;
+ }
+
+ .help-modal__section {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ }
+
+ .help-modal__section-title {
+ font-size: 0.75rem;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ color: var(--dim, #93939e);
+ margin-bottom: 4px;
+ }
+
+ .help-modal__shortcut-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 6px 0;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.02);
+ }
+
+ .help-modal__shortcut-row:last-child {
+ border-bottom: none;
+ }
+
+ .help-modal__shortcut-desc {
+ font-size: 0.875rem;
+ color: var(--foreground, #ececef);
+ }
+
+ .help-modal__kbd-list {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ }
+
+ .help-modal__kbd {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 24px;
+ height: 24px;
+ padding: 0 6px;
+ font-family: var(--font-mono, ui-monospace, monospace);
+ font-size: 0.75rem;
+ font-weight: 600;
+ background: var(--elevated, #17171d);
+ border: 1px solid var(--hairline, rgba(204, 255, 0, 0.1));
+ border-radius: 4px;
+ color: var(--foreground, #ececef);
+ box-shadow: 0 2px 0 0 rgba(0, 0, 0, 0.2);
+ }
+
+ .help-modal__plus {
+ font-size: 0.75rem;
+ color: var(--dim, #93939e);
+ font-weight: 500;
+ }
+}
diff --git a/frontend/src/components/KeyboardShortcuts.test.tsx b/frontend/src/components/KeyboardShortcuts.test.tsx
new file mode 100644
index 00000000..1b49ac9d
--- /dev/null
+++ b/frontend/src/components/KeyboardShortcuts.test.tsx
@@ -0,0 +1,146 @@
+import { render, screen, fireEvent, waitFor } from '@testing-library/react'
+import { useState } from 'react'
+import { describe, expect, it } from 'vitest'
+import {
+ formatShortcutKey,
+ getEventCombo,
+ KeyboardShortcutProvider,
+ useKeyboardShortcut,
+} from './KeyboardShortcuts'
+
+describe('KeyboardShortcuts Utilities', () => {
+ it('formats shortcut keys platform-awarely', () => {
+ // Mock userAgent for non-mac
+ const originalUserAgent = navigator.userAgent
+ Object.defineProperty(navigator, 'userAgent', {
+ value: 'Windows NT 10.0',
+ configurable: true,
+ })
+
+ expect(formatShortcutKey('mod+shift+o')).toBe('Ctrl+Shift+O')
+ expect(formatShortcutKey('alt+arrowup')).toBe('Alt+↑')
+ expect(formatShortcutKey('escape')).toBe('Esc')
+
+ // Mock userAgent for mac
+ Object.defineProperty(navigator, 'userAgent', {
+ value: 'Macintosh; Intel Mac OS X 10_15_7',
+ configurable: true,
+ })
+
+ expect(formatShortcutKey('mod+shift+o')).toBe('⌘⇧O')
+ expect(formatShortcutKey('alt+arrowup')).toBe('⌥↑')
+ expect(formatShortcutKey('escape')).toBe('Esc')
+
+ // Restore
+ Object.defineProperty(navigator, 'userAgent', {
+ value: originalUserAgent,
+ configurable: true,
+ })
+ })
+
+ it('determines the correct event combo key', () => {
+ const e1 = {
+ key: 'o',
+ code: 'KeyO',
+ ctrlKey: true,
+ shiftKey: true,
+ metaKey: false,
+ altKey: false,
+ } as KeyboardEvent
+ expect(getEventCombo(e1)).toBe('mod+shift+o')
+
+ const e2 = {
+ key: 'ArrowUp',
+ ctrlKey: false,
+ shiftKey: false,
+ metaKey: false,
+ altKey: true,
+ } as KeyboardEvent
+ expect(getEventCombo(e2)).toBe('alt+arrowup')
+
+ const e3 = {
+ key: '?',
+ ctrlKey: false,
+ shiftKey: true,
+ metaKey: false,
+ altKey: false,
+ } as KeyboardEvent
+ expect(getEventCombo(e3)).toBe('?')
+ })
+})
+
+function TestComponent() {
+ const [pressed, setPressed] = useState(false)
+
+ useKeyboardShortcut(
+ {
+ key: 'mod+k',
+ description: 'Test shortcut description',
+ category: 'Test Category',
+ },
+ (e) => {
+ e.preventDefault()
+ setPressed(true)
+ },
+ )
+
+ return
{pressed ? 'Pressed' : 'Not Pressed'}
+}
+
+describe('KeyboardShortcutProvider', () => {
+ it('registers and triggers a global shortcut', async () => {
+ render(
+
+
+ ,
+ )
+
+ expect(screen.getByText('Not Pressed')).toBeInTheDocument()
+
+ // Trigger Mod+K (Ctrl+K or Cmd+K)
+ const event = new KeyboardEvent('keydown', {
+ key: 'k',
+ ctrlKey: true,
+ bubbles: true,
+ })
+ document.dispatchEvent(event)
+
+ await waitFor(() => {
+ expect(screen.getByText('Pressed')).toBeInTheDocument()
+ })
+ })
+
+ it('toggles the help modal on "?" keydown', async () => {
+ render(
+
+
+ ,
+ )
+
+ // Initially modal is not open
+ expect(screen.queryByText('Keyboard Shortcuts')).not.toBeInTheDocument()
+
+ // Dispatch "?" keydown
+ const event = new KeyboardEvent('keydown', {
+ key: '?',
+ bubbles: true,
+ })
+ document.dispatchEvent(event)
+
+ // Modal should be open and display the title and test description
+ await waitFor(() => {
+ expect(screen.getByText('Keyboard Shortcuts')).toBeInTheDocument()
+ })
+ expect(screen.getByText('Test shortcut description')).toBeInTheDocument()
+ expect(screen.getByText('Test Category')).toBeInTheDocument()
+
+ // Close the modal
+ const closeBtn = screen.getByRole('button', { name: 'Close dialog' })
+ fireEvent.click(closeBtn)
+
+ // Modal should be closed
+ await waitFor(() => {
+ expect(screen.queryByText('Keyboard Shortcuts')).not.toBeInTheDocument()
+ })
+ })
+})
diff --git a/frontend/src/components/KeyboardShortcuts.tsx b/frontend/src/components/KeyboardShortcuts.tsx
new file mode 100644
index 00000000..7e1c7ff2
--- /dev/null
+++ b/frontend/src/components/KeyboardShortcuts.tsx
@@ -0,0 +1,363 @@
+import React, {
+ createContext,
+ useContext,
+ useEffect,
+ useRef,
+ useState,
+ useCallback,
+ useId,
+ useMemo,
+} from 'react'
+import { AnimatePresence } from 'motion/react'
+import { X } from 'lucide-react'
+import { ModalShell } from '@/components/ModalShell'
+import './KeyboardShortcuts.css'
+
+export interface KeyboardShortcut {
+ key: string // e.g. "mod+shift+o"
+ description: string
+ category: string // e.g. "Global", "Composer"
+ allowInInputs?: boolean
+ allowWithOverlays?: boolean
+ documentationOnly?: boolean
+}
+
+interface ShortcutRegistryItem {
+ id: string
+ config: KeyboardShortcut
+ handler: (e: KeyboardEvent) => void
+}
+
+interface KeyboardShortcutContextType {
+ register: (
+ id: string,
+ config: KeyboardShortcut,
+ handler: (e: KeyboardEvent) => void,
+ ) => () => void
+ shortcuts: ShortcutRegistryItem[]
+ isHelpOpen: boolean
+ setIsHelpOpen: (open: boolean) => void
+}
+
+const KeyboardShortcutContext = createContext(null)
+
+export function isMac(): boolean {
+ if (typeof navigator === 'undefined') return false
+ return /mac/i.test(navigator.userAgent)
+}
+
+export function formatShortcutKey(keyCombo: string): string {
+ const parts = keyCombo.toLowerCase().split('+')
+ const mac = isMac()
+
+ return parts
+ .map((part) => {
+ if (part === 'mod') {
+ return mac ? '⌘' : 'Ctrl'
+ }
+ if (part === 'shift') {
+ return mac ? '⇧' : 'Shift'
+ }
+ if (part === 'alt') {
+ return mac ? '⌥' : 'Alt'
+ }
+ if (part === 'arrowup' || part === 'up') {
+ return '↑'
+ }
+ if (part === 'arrowdown' || part === 'down') {
+ return '↓'
+ }
+ if (part === 'enter') {
+ return 'Enter'
+ }
+ if (part === 'escape' || part === 'esc') {
+ return 'Esc'
+ }
+ if (part.length === 1) {
+ return part.toUpperCase()
+ }
+ return part.charAt(0).toUpperCase() + part.slice(1)
+ })
+ .join(mac ? '' : '+')
+}
+
+export function getEventCombo(e: KeyboardEvent | React.KeyboardEvent): string {
+ const parts: string[] = []
+
+ let key = (e.key || '').toLowerCase()
+ if (!key || key === 'unidentified') {
+ if (e.code) {
+ if (e.code.startsWith('Key') && e.code.length === 4) {
+ key = e.code.charAt(3).toLowerCase()
+ } else if (e.code.startsWith('Digit') && e.code.length === 6) {
+ key = e.code.charAt(5)
+ } else {
+ key = e.code.toLowerCase()
+ }
+ }
+ }
+
+ if (e.metaKey || e.ctrlKey) {
+ parts.push('mod')
+ }
+ if (e.altKey) {
+ parts.push('alt')
+ }
+
+ // Only add 'shift' modifier if key itself isn't a symbol produced by shift (like '?')
+ // and key itself isn't 'shift'.
+ if (e.shiftKey && key !== '?' && key !== 'shift') {
+ parts.push('shift')
+ }
+
+ if (key !== 'control' && key !== 'meta' && key !== 'alt' && key !== 'shift') {
+ parts.push(key)
+ }
+
+ return parts.join('+')
+}
+
+export function KeyboardShortcutProvider({ children }: { children: React.ReactNode }) {
+ const [shortcuts, setShortcuts] = useState([])
+ const [isHelpOpen, setIsHelpOpen] = useState(false)
+
+ const shortcutsRef = useRef([])
+
+ useEffect(() => {
+ shortcutsRef.current = shortcuts
+ }, [shortcuts])
+
+ const register = useCallback(
+ (id: string, config: KeyboardShortcut, handler: (e: KeyboardEvent) => void) => {
+ setShortcuts((prev) => [...prev, { id, config, handler }])
+ return () => {
+ setShortcuts((prev) => prev.filter((item) => item.id !== id))
+ }
+ },
+ [],
+ )
+
+ // Handle global shortcuts dispatching
+ useEffect(() => {
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (e.defaultPrevented) return
+
+ const combo = getEventCombo(e)
+ if (!combo) return
+
+ const hasOverlay = !!document.querySelector(
+ '.modal-backdrop, .chat-session-popover, .chat-session-actions-menu, .help-modal__overlay, .ag-modal__overlay, .sess-modal__overlay, .sk-modal__overlay, .cron-modal__overlay',
+ )
+
+ const target = e.target as HTMLElement | null
+ const isEditable =
+ !!target &&
+ (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)
+
+ // Handle the helper shortcut "?" statically
+ if (combo === '?' && !isEditable && !hasOverlay) {
+ e.preventDefault()
+ setIsHelpOpen((prev) => !prev)
+ return
+ }
+
+ const matches = shortcutsRef.current.filter((item) => {
+ return (
+ !item.config.documentationOnly && item.config.key.toLowerCase() === combo.toLowerCase()
+ )
+ })
+
+ if (matches.length === 0) return
+
+ // Evaluate matches in reverse order of registration (latest first - stack behavior)
+ for (let i = matches.length - 1; i >= 0; i--) {
+ const match = matches[i]!
+
+ if (hasOverlay && !match.config.allowWithOverlays) {
+ continue
+ }
+
+ if (isEditable && !match.config.allowInInputs) {
+ continue
+ }
+
+ match.handler(e)
+
+ if (e.defaultPrevented) {
+ break
+ }
+ }
+ }
+
+ document.addEventListener('keydown', handleKeyDown)
+ return () => document.removeEventListener('keydown', handleKeyDown)
+ }, [])
+
+ const contextValue = useMemo(
+ () => ({
+ register,
+ shortcuts,
+ isHelpOpen,
+ setIsHelpOpen,
+ }),
+ [register, shortcuts, isHelpOpen],
+ )
+
+ return (
+
+ {children}
+
+ {isHelpOpen && (
+ setIsHelpOpen(false)} shortcuts={shortcuts} />
+ )}
+
+
+ )
+}
+
+export function useKeyboardShortcut(config: KeyboardShortcut, handler: (e: KeyboardEvent) => void) {
+ const context = useContext(KeyboardShortcutContext)
+
+ const id = useId()
+ const handlerRef = useRef(handler)
+
+ useEffect(() => {
+ handlerRef.current = handler
+ }, [handler])
+
+ const { key, description, category, allowInInputs, allowWithOverlays, documentationOnly } = config
+ const register = context?.register
+
+ useEffect(() => {
+ if (!register) return
+
+ const stableHandler = (e: KeyboardEvent) => {
+ handlerRef.current(e)
+ }
+
+ return register(
+ id,
+ { key, description, category, allowInInputs, allowWithOverlays, documentationOnly },
+ stableHandler,
+ )
+ }, [
+ register,
+ id,
+ key,
+ description,
+ category,
+ allowInInputs,
+ allowWithOverlays,
+ documentationOnly,
+ ])
+}
+
+function KeyboardShortcutHelpModal({
+ onClose,
+ shortcuts,
+}: {
+ onClose: () => void
+ shortcuts: ShortcutRegistryItem[]
+}) {
+ const categories: Record = {}
+
+ // Initialize with helper shortcut
+ categories['Global'] = [
+ {
+ id: 'global-help-shortcut',
+ config: {
+ key: '?',
+ description: 'Show keyboard shortcuts',
+ category: 'Global',
+ },
+ handler: () => {},
+ },
+ ]
+
+ shortcuts.forEach((item) => {
+ const cat = item.config.category || 'Other'
+ if (!categories[cat]) {
+ categories[cat] = []
+ }
+ const exists = categories[cat].some(
+ (existing) =>
+ existing.config.key.toLowerCase() === item.config.key.toLowerCase() &&
+ existing.config.description === item.config.description,
+ )
+ if (!exists) {
+ categories[cat].push(item)
+ }
+ })
+
+ const renderKbdParts = (keyCombo: string) => {
+ const parts = keyCombo.toLowerCase().split('+')
+ const mac = isMac()
+
+ return parts.map((part, idx) => {
+ let label = part
+ if (part === 'mod') {
+ label = mac ? '⌘' : 'Ctrl'
+ } else if (part === 'shift') {
+ label = mac ? '⇧' : 'Shift'
+ } else if (part === 'alt') {
+ label = mac ? '⌥' : 'Alt'
+ } else if (part === 'arrowup' || part === 'up') {
+ label = '↑'
+ } else if (part === 'arrowdown' || part === 'down') {
+ label = '↓'
+ } else if (part === 'enter') {
+ label = 'Enter'
+ } else if (part === 'escape' || part === 'esc') {
+ label = 'Esc'
+ } else if (part.length === 1) {
+ label = part.toUpperCase()
+ } else {
+ label = part.charAt(0).toUpperCase() + part.slice(1)
+ }
+
+ return (
+
+ {idx > 0 && !mac && +}
+ {label}
+
+ )
+ })
+ }
+
+ return (
+
+
+
+ Keyboard Shortcuts
+
+
+
+
+ {Object.entries(categories).map(([category, items]) => (
+
+
{category}
+ {items.map((item) => (
+
+
{item.config.description}
+
{renderKbdParts(item.config.key)}
+
+ ))}
+
+ ))}
+
+
+ )
+}
diff --git a/frontend/src/views/chat/ChatPage.test.tsx b/frontend/src/views/chat/ChatPage.test.tsx
index 6d1d730f..a551b827 100644
--- a/frontend/src/views/chat/ChatPage.test.tsx
+++ b/frontend/src/views/chat/ChatPage.test.tsx
@@ -5,6 +5,7 @@ import { focusManager, QueryClient, QueryClientProvider } from '@tanstack/react-
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { toast } from 'sonner'
import { ChatPage } from './ChatPage'
+import { KeyboardShortcutProvider } from '@/components/KeyboardShortcuts'
import * as logicModule from './logic'
vi.mock('sonner', () => ({
@@ -94,8 +95,10 @@ function renderPage(initialEntry = '/chat') {
-
-
+
+
+
+
,
)
diff --git a/frontend/src/views/chat/ChatPage.tsx b/frontend/src/views/chat/ChatPage.tsx
index b613f028..55b140d9 100644
--- a/frontend/src/views/chat/ChatPage.tsx
+++ b/frontend/src/views/chat/ChatPage.tsx
@@ -8,6 +8,7 @@ import { AnimatePresence } from 'motion/react'
import { useRpc } from '@/app/providers'
import { ShellHeaderPortal, ShellPrimaryActionPortal } from '@/app/ShellHeaderSlot'
import { ModalShell } from '@/components/ModalShell'
+import { useKeyboardShortcut, formatShortcutKey } from '@/components/KeyboardShortcuts'
import { Attachments, useAttachments } from './Attachments'
import { Composer, type ComposerHandle } from './Composer'
import {
@@ -524,37 +525,31 @@ export function ChatPage() {
toast.info('Exported as Markdown')
}, [containerRef, sessionKey])
- const shortcutHint = /mac/i.test(navigator.userAgent) ? '⌘⇧O' : 'Ctrl+Shift+O'
-
- // chat.js:2518-2539 `_onDocKeydown` — document-level keyboard shortcuts.
- // Cmd/Ctrl+Shift+O mirrors the New chat button from anywhere in the app,
- // while Escape keeps the legacy priority chain:
- // 1. streaming → abort the turn (which recovers pending).
- // 2. pending non-empty → recover the whole queue into the composer.
- // Visible overlays own their shortcuts, and Escape inside other editable
- // targets remains handled by those elements. Unlike Escape, the New chat
- // shortcut intentionally works even when focus is inside the composer or
- // another editable field.
- useEffect(() => {
- const onDocKeydown = (e: KeyboardEvent) => {
- if (e.defaultPrevented) return
- const isNewChatShortcut = (e.metaKey || e.ctrlKey) && e.shiftKey && e.code === 'KeyO'
- const isEscape = e.key === 'Escape'
- if (!isNewChatShortcut && !isEscape) return
- const hasOverlay = !!document.querySelector(
- '.modal-backdrop, .chat-session-popover, .chat-session-actions-menu',
- )
- if (hasOverlay) return
- if (isNewChatShortcut) {
- e.preventDefault()
- startNewChat()
- return
- }
- const target = e.target as HTMLElement | null
- const isEditable =
- !!target &&
- (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)
- if (isEditable) return
+ const shortcutHint = formatShortcutKey('mod+shift+o')
+
+ // Register New Chat shortcut with registry
+ useKeyboardShortcut(
+ {
+ key: 'mod+shift+o',
+ description: 'New chat',
+ category: 'Global',
+ allowInInputs: true,
+ },
+ (e) => {
+ e.preventDefault()
+ startNewChat()
+ },
+ )
+
+ // Register Escape shortcut with registry (global priority chain: abort streaming or recover pending queue)
+ useKeyboardShortcut(
+ {
+ key: 'escape',
+ description: 'Abort streaming turn / recover pending queue',
+ category: 'Global',
+ allowInInputs: false,
+ },
+ (e) => {
if (busy) {
e.preventDefault()
abortAndRecover('webui_escape')
@@ -564,10 +559,8 @@ export function ChatPage() {
e.preventDefault()
pending.popAllIntoComposer()
}
- }
- document.addEventListener('keydown', onDocKeydown)
- return () => document.removeEventListener('keydown', onDocKeydown)
- }, [busy, abortAndRecover, pending, startNewChat])
+ },
+ )
return (
diff --git a/frontend/src/views/chat/Composer.tsx b/frontend/src/views/chat/Composer.tsx
index e210cb59..33ce23aa 100644
--- a/frontend/src/views/chat/Composer.tsx
+++ b/frontend/src/views/chat/Composer.tsx
@@ -3,6 +3,7 @@ import { ArrowUpIcon, PaperclipIcon, SlidersHorizontalIcon, SquareIcon, XIcon }
import { AnimatePresence, motion, useReducedMotion } from 'motion/react'
import { toast } from 'sonner'
import { MAX_PENDING, sendButtonState, shouldAutofocusComposer } from './logic'
+import { useKeyboardShortcut } from '@/components/KeyboardShortcuts'
/**
* The chat command line (React).
@@ -168,6 +169,78 @@ export function Composer({
const toolbarCloseRef = useRef(null)
const reduceMotion = useReducedMotion()
+ // Register composer shortcuts for documentation
+ useKeyboardShortcut(
+ {
+ key: 'enter',
+ description: 'Send message',
+ category: 'Composer',
+ allowInInputs: true,
+ documentationOnly: true,
+ },
+ () => {},
+ )
+ useKeyboardShortcut(
+ {
+ key: 'shift+enter',
+ description: 'Insert newline',
+ category: 'Composer',
+ allowInInputs: true,
+ documentationOnly: true,
+ },
+ () => {},
+ )
+ useKeyboardShortcut(
+ {
+ key: 'escape',
+ description: 'Clear input / recover queue / abort turn',
+ category: 'Composer',
+ allowInInputs: true,
+ documentationOnly: true,
+ },
+ () => {},
+ )
+ useKeyboardShortcut(
+ {
+ key: 'alt+arrowup',
+ description: 'Pop the most-recent pending item into the composer for editing',
+ category: 'Composer',
+ allowInInputs: true,
+ documentationOnly: true,
+ },
+ () => {},
+ )
+ useKeyboardShortcut(
+ {
+ key: 'alt+arrowdown',
+ description: 'Enqueue the current composer text',
+ category: 'Composer',
+ allowInInputs: true,
+ documentationOnly: true,
+ },
+ () => {},
+ )
+ useKeyboardShortcut(
+ {
+ key: 'arrowup',
+ description: 'Walk backwards through sent history',
+ category: 'Composer',
+ allowInInputs: true,
+ documentationOnly: true,
+ },
+ () => {},
+ )
+ useKeyboardShortcut(
+ {
+ key: 'arrowdown',
+ description: 'Walk forward through sent history',
+ category: 'Composer',
+ allowInInputs: true,
+ documentationOnly: true,
+ },
+ () => {},
+ )
+
// Close the composer-settings popover on an outside click / Escape (it
// previously stayed open until the toolbar trigger was clicked again). Bound only
// while open; a mousedown outside the toolbar wrap or an Escape key closes it.