Add keyboard shortcuts system with reusable hint components

- Implement centralized keyboard registry with 20+ shortcuts
- Add instance navigation (Cmd+[1-9], Cmd+[/])
- Add session navigation (Cmd+Shift+[1-9], Cmd+Shift+[/])
- Add agent/model cycling (Tab, Cmd+Shift+M)
- Add input shortcuts (Cmd+P focus, Cmd+K clear, ↑↓ history)
- Add command palette (Cmd+Shift+P) with 8 MVP commands
- Implement message history per folder in IndexedDB (max 100)
- Create reusable Kbd and HintRow components
- Replace all keyboard hint rendering with consistent components
- Use text-based shortcuts (Cmd+Shift+M) for clarity
This commit is contained in:
Shantur Rathore
2025-10-23 20:18:45 +01:00
parent 3c5c4755b8
commit 4c98a3df06
21 changed files with 1302 additions and 143 deletions

67
src/lib/commands.ts Normal file
View File

@@ -0,0 +1,67 @@
export interface KeyboardShortcut {
key: string
meta?: boolean
ctrl?: boolean
shift?: boolean
alt?: boolean
}
export interface Command {
id: string
label: string
description: string
keywords?: string[]
shortcut?: KeyboardShortcut
action: () => void | Promise<void>
category?: string
}
export function createCommandRegistry() {
const commands = new Map<string, Command>()
function register(command: Command) {
commands.set(command.id, command)
}
function unregister(id: string) {
commands.delete(id)
}
function get(id: string) {
return commands.get(id)
}
function getAll() {
return Array.from(commands.values())
}
function execute(id: string) {
const command = commands.get(id)
if (command) {
return command.action()
}
}
function search(query: string) {
if (!query) return getAll()
const lowerQuery = query.toLowerCase()
return getAll().filter((cmd) => {
const labelMatch = cmd.label.toLowerCase().includes(lowerQuery)
const descMatch = cmd.description.toLowerCase().includes(lowerQuery)
const keywordMatch = cmd.keywords?.some((k) => k.toLowerCase().includes(lowerQuery))
return labelMatch || descMatch || keywordMatch
})
}
return {
register,
unregister,
get,
getAll,
execute,
search,
}
}
export type CommandRegistry = ReturnType<typeof createCommandRegistry>

68
src/lib/db.ts Normal file
View File

@@ -0,0 +1,68 @@
const DB_NAME = "opencode-client"
const DB_VERSION = 1
const HISTORY_STORE = "message-history"
let db: IDBDatabase | null = null
async function getDB(): Promise<IDBDatabase> {
if (db) return db
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION)
request.onerror = () => reject(request.error)
request.onsuccess = () => {
db = request.result
resolve(db)
}
request.onupgradeneeded = (event) => {
const database = (event.target as IDBOpenDBRequest).result
if (!database.objectStoreNames.contains(HISTORY_STORE)) {
database.createObjectStore(HISTORY_STORE)
}
}
})
}
export async function saveHistory(instanceId: string, history: string[]): Promise<void> {
const database = await getDB()
return new Promise((resolve, reject) => {
const tx = database.transaction(HISTORY_STORE, "readwrite")
const store = tx.objectStore(HISTORY_STORE)
const request = store.put(history, instanceId)
request.onerror = () => reject(request.error)
request.onsuccess = () => resolve()
})
}
export async function loadHistory(instanceId: string): Promise<string[]> {
try {
const database = await getDB()
return new Promise((resolve, reject) => {
const tx = database.transaction(HISTORY_STORE, "readonly")
const store = tx.objectStore(HISTORY_STORE)
const request = store.get(instanceId)
request.onerror = () => reject(request.error)
request.onsuccess = () => resolve(request.result || [])
})
} catch (error) {
console.warn("Failed to load history from IndexedDB:", error)
return []
}
}
export async function deleteHistory(instanceId: string): Promise<void> {
const database = await getDB()
return new Promise((resolve, reject) => {
const tx = database.transaction(HISTORY_STORE, "readwrite")
const store = tx.objectStore(HISTORY_STORE)
const request = store.delete(instanceId)
request.onerror = () => reject(request.error)
request.onsuccess = () => resolve()
})
}

View File

@@ -0,0 +1,69 @@
export interface KeyboardShortcut {
id: string
key: string
modifiers: {
ctrl?: boolean
meta?: boolean
shift?: boolean
alt?: boolean
}
handler: () => void
description: string
context?: "global" | "input" | "messages"
condition?: () => boolean
}
class KeyboardRegistry {
private shortcuts = new Map<string, KeyboardShortcut>()
register(shortcut: KeyboardShortcut) {
this.shortcuts.set(shortcut.id, shortcut)
}
unregister(id: string) {
this.shortcuts.delete(id)
}
get(id: string) {
return this.shortcuts.get(id)
}
findMatch(event: KeyboardEvent): KeyboardShortcut | null {
for (const shortcut of this.shortcuts.values()) {
if (this.matches(event, shortcut)) {
if (shortcut.context === "input" && !this.isInputFocused()) continue
if (shortcut.context === "messages" && this.isInputFocused()) continue
if (shortcut.condition && !shortcut.condition()) continue
return shortcut
}
}
return null
}
private matches(event: KeyboardEvent, shortcut: KeyboardShortcut): boolean {
const keyMatch = event.key.toLowerCase() === shortcut.key.toLowerCase()
const ctrlMatch = event.ctrlKey === (shortcut.modifiers.ctrl ?? false)
const metaMatch = event.metaKey === (shortcut.modifiers.meta ?? false)
const shiftMatch = event.shiftKey === (shortcut.modifiers.shift ?? false)
const altMatch = event.altKey === (shortcut.modifiers.alt ?? false)
return keyMatch && ctrlMatch && metaMatch && shiftMatch && altMatch
}
private isInputFocused(): boolean {
const active = document.activeElement
return (
active?.tagName === "TEXTAREA" ||
active?.tagName === "INPUT" ||
(active?.hasAttribute("contenteditable") ?? false)
)
}
getByContext(context: string): KeyboardShortcut[] {
return Array.from(this.shortcuts.values()).filter((s) => !s.context || s.context === context)
}
}
export const keyboardRegistry = new KeyboardRegistry()

30
src/lib/keyboard-utils.ts Normal file
View File

@@ -0,0 +1,30 @@
import type { KeyboardShortcut } from "./keyboard-registry"
export const isMac = () => navigator.platform.toLowerCase().includes("mac")
export const modKey = (event?: KeyboardEvent) => {
if (!event) return isMac() ? "metaKey" : "ctrlKey"
return isMac() ? event.metaKey : event.ctrlKey
}
export const modKeyPressed = (event: KeyboardEvent) => {
return isMac() ? event.metaKey : event.ctrlKey
}
export const formatShortcut = (shortcut: KeyboardShortcut): string => {
const parts: string[] = []
if (shortcut.modifiers.ctrl || shortcut.modifiers.meta) {
parts.push(isMac() ? "Cmd" : "Ctrl")
}
if (shortcut.modifiers.shift) {
parts.push("Shift")
}
if (shortcut.modifiers.alt) {
parts.push(isMac() ? "Option" : "Alt")
}
parts.push(shortcut.key.toUpperCase())
return parts.join("+")
}

View File

@@ -1,13 +1,21 @@
import { instances, activeInstanceId, setActiveInstanceId } from "../stores/instances"
import { activeSessionId, setActiveSession, getSessions } from "../stores/sessions"
import { activeSessionId, setActiveSession, getSessions, activeParentSessionId } from "../stores/sessions"
export function setupTabKeyboardShortcuts(
handleNewInstance: () => void,
handleCloseInstance: (instanceId: string) => void,
handleNewSession: (instanceId: string) => void,
handleCloseSession: (instanceId: string, sessionId: string) => void,
handleCommandPalette: () => void,
) {
window.addEventListener("keydown", (e) => {
if ((e.metaKey || e.ctrlKey) && e.key >= "1" && e.key <= "9") {
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key.toLowerCase() === "p") {
e.preventDefault()
handleCommandPalette()
return
}
if ((e.metaKey || e.ctrlKey) && !e.shiftKey && e.key >= "1" && e.key <= "9") {
e.preventDefault()
const index = parseInt(e.key) - 1
const instanceIds = Array.from(instances().keys())
@@ -16,12 +24,30 @@ export function setupTabKeyboardShortcuts(
}
}
if ((e.metaKey || e.ctrlKey) && e.key === "n") {
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key >= "1" && e.key <= "9") {
e.preventDefault()
const instanceId = activeInstanceId()
if (!instanceId) return
const index = parseInt(e.key) - 1
const parentId = activeParentSessionId().get(instanceId)
if (!parentId) return
const sessions = getSessions(instanceId)
const sessionFamily = sessions.filter((s) => s.id === parentId || s.parentId === parentId)
const allTabs = sessionFamily.map((s) => s.id).concat(["logs"])
if (allTabs[index]) {
setActiveSession(instanceId, allTabs[index])
}
}
if ((e.metaKey || e.ctrlKey) && !e.shiftKey && e.key.toLowerCase() === "n") {
e.preventDefault()
handleNewInstance()
}
if ((e.metaKey || e.ctrlKey) && e.key === "t") {
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key.toLowerCase() === "n") {
e.preventDefault()
const instanceId = activeInstanceId()
if (instanceId) {
@@ -29,7 +55,15 @@ export function setupTabKeyboardShortcuts(
}
}
if ((e.metaKey || e.ctrlKey) && e.key === "w") {
if ((e.metaKey || e.ctrlKey) && !e.shiftKey && e.key.toLowerCase() === "w") {
e.preventDefault()
const instanceId = activeInstanceId()
if (instanceId) {
handleCloseInstance(instanceId)
}
}
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key.toLowerCase() === "w") {
e.preventDefault()
const instanceId = activeInstanceId()
if (!instanceId) return

View File

@@ -0,0 +1,44 @@
import { keyboardRegistry } from "../keyboard-registry"
export function registerAgentShortcuts(
cycleAgent: () => void,
cycleAgentReverse: () => void,
focusModelSelector: () => void,
) {
const isMac = () => navigator.platform.toLowerCase().includes("mac")
keyboardRegistry.register({
id: "agent-next",
key: "Tab",
modifiers: {},
handler: cycleAgent,
description: "next agent",
context: "global",
condition: () => {
const active = document.activeElement
return !(active?.tagName === "TEXTAREA" || active?.tagName === "INPUT" || active?.hasAttribute("contenteditable"))
},
})
keyboardRegistry.register({
id: "agent-prev",
key: "Tab",
modifiers: { shift: true },
handler: cycleAgentReverse,
description: "previous agent",
context: "global",
condition: () => {
const active = document.activeElement
return !(active?.tagName === "TEXTAREA" || active?.tagName === "INPUT" || active?.hasAttribute("contenteditable"))
},
})
keyboardRegistry.register({
id: "focus-model",
key: "M",
modifiers: { ctrl: !isMac(), meta: isMac(), shift: true },
handler: focusModelSelector,
description: "focus model",
context: "global",
})
}

View File

@@ -0,0 +1,31 @@
import { keyboardRegistry } from "../keyboard-registry"
export function registerEscapeShortcut(
isSessionBusy: () => boolean,
interruptSession: () => void,
blurInput: () => void,
closeModal: () => void,
) {
keyboardRegistry.register({
id: "escape",
key: "Escape",
modifiers: {},
handler: () => {
const hasOpenModal = document.querySelector('[role="dialog"]') !== null
if (hasOpenModal) {
closeModal()
return
}
if (isSessionBusy()) {
interruptSession()
return
}
blurInput()
},
description: "cancel/close",
context: "global",
})
}

View File

@@ -0,0 +1,23 @@
import { keyboardRegistry } from "../keyboard-registry"
export function registerInputShortcuts(clearInput: () => void, focusInput: () => void) {
const isMac = () => navigator.platform.toLowerCase().includes("mac")
keyboardRegistry.register({
id: "clear-input",
key: "k",
modifiers: { ctrl: !isMac(), meta: isMac() },
handler: clearInput,
description: "clear input",
context: "global",
})
keyboardRegistry.register({
id: "focus-input",
key: "p",
modifiers: { ctrl: !isMac(), meta: isMac() },
handler: focusInput,
description: "focus input",
context: "global",
})
}

View File

@@ -0,0 +1,95 @@
import { keyboardRegistry } from "../keyboard-registry"
import { instances, activeInstanceId, setActiveInstanceId } from "../../stores/instances"
import { getSessionFamily, activeSessionId, setActiveSession, activeParentSessionId } from "../../stores/sessions"
export function registerNavigationShortcuts() {
const isMac = () => navigator.platform.toLowerCase().includes("mac")
keyboardRegistry.register({
id: "instance-prev",
key: "[",
modifiers: { ctrl: !isMac(), meta: isMac() },
handler: () => {
const ids = Array.from(instances().keys())
if (ids.length <= 1) return
const current = ids.indexOf(activeInstanceId() || "")
const prev = current <= 0 ? ids.length - 1 : current - 1
if (ids[prev]) setActiveInstanceId(ids[prev])
},
description: "previous instance",
context: "global",
})
keyboardRegistry.register({
id: "instance-next",
key: "]",
modifiers: { ctrl: !isMac(), meta: isMac() },
handler: () => {
const ids = Array.from(instances().keys())
if (ids.length <= 1) return
const current = ids.indexOf(activeInstanceId() || "")
const next = (current + 1) % ids.length
if (ids[next]) setActiveInstanceId(ids[next])
},
description: "next instance",
context: "global",
})
keyboardRegistry.register({
id: "session-prev",
key: "[",
modifiers: { ctrl: !isMac(), meta: isMac(), shift: true },
handler: () => {
const instanceId = activeInstanceId()
if (!instanceId) return
const parentId = activeParentSessionId().get(instanceId)
if (!parentId) return
const familySessions = getSessionFamily(instanceId, parentId)
const ids = familySessions.map((s) => s.id).concat(["logs"])
if (ids.length <= 1) return
const current = ids.indexOf(activeSessionId().get(instanceId) || "")
const prev = current <= 0 ? ids.length - 1 : current - 1
if (ids[prev]) setActiveSession(instanceId, ids[prev])
},
description: "previous session",
context: "global",
})
keyboardRegistry.register({
id: "session-next",
key: "]",
modifiers: { ctrl: !isMac(), meta: isMac(), shift: true },
handler: () => {
const instanceId = activeInstanceId()
if (!instanceId) return
const parentId = activeParentSessionId().get(instanceId)
if (!parentId) return
const familySessions = getSessionFamily(instanceId, parentId)
const ids = familySessions.map((s) => s.id).concat(["logs"])
if (ids.length <= 1) return
const current = ids.indexOf(activeSessionId().get(instanceId) || "")
const next = (current + 1) % ids.length
if (ids[next]) setActiveSession(instanceId, ids[next])
},
description: "next session",
context: "global",
})
keyboardRegistry.register({
id: "switch-to-logs",
key: "l",
modifiers: { ctrl: !isMac(), meta: isMac(), shift: true },
handler: () => {
const instanceId = activeInstanceId()
if (instanceId) setActiveSession(instanceId, "logs")
},
description: "logs tab",
context: "global",
})
}