From 4c98a3df06000aed03658a02c63f59aa972033ac Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Thu, 23 Oct 2025 20:18:45 +0100 Subject: [PATCH] Add keyboard shortcuts system with reusable hint components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/App.tsx | 259 ++++++++++++++++++++++++++++- src/components/agent-selector.tsx | 97 ++++++----- src/components/command-palette.tsx | 157 +++++++++++++++++ src/components/hint-row.tsx | 12 ++ src/components/instance-tabs.tsx | 51 +++--- src/components/kbd.tsx | 54 ++++++ src/components/keyboard-hint.tsx | 43 +++++ src/components/model-selector.tsx | 108 ++++++------ src/components/prompt-input.tsx | 67 +++++++- src/components/session-tabs.tsx | 58 ++++--- src/lib/commands.ts | 67 ++++++++ src/lib/db.ts | 68 ++++++++ src/lib/keyboard-registry.ts | 69 ++++++++ src/lib/keyboard-utils.ts | 30 ++++ src/lib/keyboard.ts | 44 ++++- src/lib/shortcuts/agent.ts | 44 +++++ src/lib/shortcuts/escape.ts | 31 ++++ src/lib/shortcuts/input.ts | 23 +++ src/lib/shortcuts/navigation.ts | 95 +++++++++++ src/stores/command-palette.ts | 17 ++ src/stores/message-history.ts | 51 ++++++ 21 files changed, 1302 insertions(+), 143 deletions(-) create mode 100644 src/components/command-palette.tsx create mode 100644 src/components/hint-row.tsx create mode 100644 src/components/kbd.tsx create mode 100644 src/components/keyboard-hint.tsx create mode 100644 src/lib/commands.ts create mode 100644 src/lib/db.ts create mode 100644 src/lib/keyboard-registry.ts create mode 100644 src/lib/keyboard-utils.ts create mode 100644 src/lib/shortcuts/agent.ts create mode 100644 src/lib/shortcuts/escape.ts create mode 100644 src/lib/shortcuts/input.ts create mode 100644 src/lib/shortcuts/navigation.ts create mode 100644 src/stores/command-palette.ts create mode 100644 src/stores/message-history.ts diff --git a/src/App.tsx b/src/App.tsx index efa9d6f9..e266a94c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,13 +1,16 @@ -import { Component, onMount, Show, createMemo, createEffect } from "solid-js" +import { Component, onMount, onCleanup, Show, createMemo, createEffect } from "solid-js" import type { Session } from "./types/session" import EmptyState from "./components/empty-state" import SessionPicker from "./components/session-picker" +import CommandPalette from "./components/command-palette" import InstanceTabs from "./components/instance-tabs" import SessionTabs from "./components/session-tabs" import MessageStream from "./components/message-stream" import PromptInput from "./components/prompt-input" import LogsView from "./components/logs-view" import { initMarkdown } from "./lib/markdown" +import { createCommandRegistry } from "./lib/commands" +import type { Command } from "./lib/commands" import { hasInstances, isSelectingFolder, @@ -42,13 +45,21 @@ import { sendMessage, updateSessionAgent, updateSessionModel, + agents, } from "./stores/sessions" import { setupTabKeyboardShortcuts } from "./lib/keyboard" +import { isOpen as isCommandPaletteOpen, showCommandPalette, hideCommandPalette } from "./stores/command-palette" +import { registerNavigationShortcuts } from "./lib/shortcuts/navigation" +import { registerInputShortcuts } from "./lib/shortcuts/input" +import { registerAgentShortcuts } from "./lib/shortcuts/agent" +import { registerEscapeShortcut } from "./lib/shortcuts/escape" +import { keyboardRegistry } from "./lib/keyboard-registry" const SessionView: Component<{ sessionId: string activeSessions: Map instanceId: string + instanceFolder: string }> = (props) => { const session = () => props.activeSessions.get(props.sessionId) @@ -90,6 +101,7 @@ const SessionView: Component<{ /> { + const commandRegistry = createCommandRegistry() + const activeInstance = createMemo(() => getActiveInstance()) const activeSessions = createMemo(() => { @@ -175,10 +189,243 @@ const App: Component = () => { showSessionPicker(instanceId) } + function setupCommands() { + commandRegistry.register({ + id: "init", + label: "Initialize AGENTS.md", + description: "Create or update AGENTS.md file", + keywords: ["/init", "agents", "initialize"], + action: async () => { + const instance = activeInstance() + const sessionId = activeSessionIdForInstance() + if (!instance || !instance.client || !sessionId || sessionId === "logs") return + + try { + await instance.client.session.init({ path: { id: sessionId } }) + console.log("Initialized AGENTS.md") + } catch (error) { + console.error("Failed to initialize AGENTS.md:", error) + } + }, + }) + + commandRegistry.register({ + id: "compact", + label: "Compact Session", + description: "Summarize and compact the current session", + keywords: ["/compact", "summarize", "compress"], + action: async () => { + const instance = activeInstance() + const sessionId = activeSessionIdForInstance() + if (!instance || !instance.client || !sessionId || sessionId === "logs") return + + try { + await instance.client.session.summarize({ path: { id: sessionId } }) + console.log("Session compacted") + } catch (error) { + console.error("Failed to compact session:", error) + } + }, + }) + + commandRegistry.register({ + id: "undo", + label: "Undo Last Message", + description: "Revert the last message", + keywords: ["/undo", "revert", "undo"], + action: async () => { + const instance = activeInstance() + const sessionId = activeSessionIdForInstance() + if (!instance || !instance.client || !sessionId || sessionId === "logs") return + + try { + await instance.client.session.revert({ path: { id: sessionId } }) + console.log("Last message reverted") + } catch (error) { + console.error("Failed to revert message:", error) + } + }, + }) + + commandRegistry.register({ + id: "thinking", + label: "Toggle Thinking Blocks", + description: "Show/hide AI thinking process", + keywords: ["/thinking", "toggle", "show", "hide"], + action: () => { + console.log("Toggle thinking blocks (not implemented)") + }, + }) + + commandRegistry.register({ + id: "help", + label: "Show Help", + description: "Display keyboard shortcuts and help", + keywords: ["/help", "shortcuts", "help"], + action: () => { + console.log("Show help modal (not implemented)") + }, + }) + + commandRegistry.register({ + id: "new-session", + label: "New Session", + description: "Create a new session", + shortcut: { key: "N", meta: true, shift: true }, + action: async () => { + const instance = activeInstance() + if (!instance) return + await handleNewSession(instance.id) + }, + }) + + commandRegistry.register({ + id: "open-model-selector", + label: "Open Model Selector", + description: "Choose a different model", + shortcut: { key: "M", meta: true, shift: true }, + action: () => { + const modelInput = document.querySelector("[data-model-selector] input") as HTMLInputElement + modelInput?.focus() + }, + }) + + commandRegistry.register({ + id: "focus-prompt", + label: "Focus Prompt Input", + description: "Jump to the message input", + shortcut: { key: "P", meta: true }, + action: () => { + const textarea = document.querySelector(".prompt-input") as HTMLTextAreaElement + textarea?.focus() + }, + }) + + commandRegistry.register({ + id: "open-agent-selector", + label: "Open Agent Selector", + description: "Choose a different agent", + action: () => { + const agentButton = document.querySelector("[data-agent-selector]") as HTMLElement + agentButton?.click() + }, + }) + } + + function handleExecuteCommand(commandId: string) { + commandRegistry.execute(commandId) + } + + function handleCycleAgent() { + const instance = activeInstance() + const sessionId = activeSessionIdForInstance() + if (!instance || !sessionId || sessionId === "logs") return + + const sessions = getSessions(instance.id) + const session = sessions.find((s) => s.id === sessionId) + if (!session) return + + const instanceAgents = agents().get(instance.id) || [] + const availableAgents = + session.parentId === null ? instanceAgents.filter((a) => a.mode !== "subagent") : instanceAgents + + if (availableAgents.length === 0) return + + const currentIndex = availableAgents.findIndex((a) => a.name === session.agent) + const nextIndex = (currentIndex + 1) % availableAgents.length + const nextAgent = availableAgents[nextIndex] + + if (nextAgent) { + updateSessionAgent(instance.id, sessionId, nextAgent.name).catch(console.error) + } + } + + function handleCycleAgentReverse() { + const instance = activeInstance() + const sessionId = activeSessionIdForInstance() + if (!instance || !sessionId || sessionId === "logs") return + + const sessions = getSessions(instance.id) + const session = sessions.find((s) => s.id === sessionId) + if (!session) return + + const instanceAgents = agents().get(instance.id) || [] + const availableAgents = + session.parentId === null ? instanceAgents.filter((a) => a.mode !== "subagent") : instanceAgents + + if (availableAgents.length === 0) return + + const currentIndex = availableAgents.findIndex((a) => a.name === session.agent) + const prevIndex = currentIndex <= 0 ? availableAgents.length - 1 : currentIndex - 1 + const prevAgent = availableAgents[prevIndex] + + if (prevAgent) { + updateSessionAgent(instance.id, sessionId, prevAgent.name).catch(console.error) + } + } + onMount(() => { initMarkdown(false).catch(console.error) - setupTabKeyboardShortcuts(handleSelectFolder, handleNewSession, handleCloseSession) + setupCommands() + + setupTabKeyboardShortcuts( + handleSelectFolder, + handleCloseInstance, + handleNewSession, + handleCloseSession, + showCommandPalette, + ) + + registerNavigationShortcuts() + registerInputShortcuts( + () => { + const textarea = document.querySelector(".prompt-input") as HTMLTextAreaElement + if (textarea) textarea.value = "" + }, + () => { + const textarea = document.querySelector(".prompt-input") as HTMLTextAreaElement + textarea?.focus() + }, + ) + registerAgentShortcuts(handleCycleAgent, handleCycleAgentReverse, () => { + const modelInput = document.querySelector("[data-model-selector] input") as HTMLInputElement + modelInput?.focus() + }) + registerEscapeShortcut( + () => { + const instance = activeInstance() + if (!instance) return false + const sessions = getSessions(instance.id) + const sessionId = activeSessionIdForInstance() + const session = sessions.find((s) => s.id === sessionId) + if (!session) return false + const lastMessage = session.messages[session.messages.length - 1] + return lastMessage?.status === "streaming" + }, + () => { + console.log("Interrupt session (not implemented)") + }, + () => { + const active = document.activeElement as HTMLElement + active?.blur() + }, + hideCommandPalette, + ) + + const handleKeyDown = (e: KeyboardEvent) => { + const shortcut = keyboardRegistry.findMatch(e) + if (shortcut) { + e.preventDefault() + shortcut.handler() + } + } + + window.addEventListener("keydown", handleKeyDown) + + onCleanup(() => { + window.removeEventListener("keydown", handleKeyDown) + }) window.electronAPI.onNewInstance(() => { handleSelectFolder() @@ -260,6 +507,7 @@ const App: Component = () => { sessionId={activeSessionIdForInstance()!} activeSessions={activeSessions()} instanceId={activeInstance()!.id} + instanceFolder={activeInstance()!.folder} /> } @@ -280,6 +528,13 @@ const App: Component = () => { {(instanceId) => } + + ) } diff --git a/src/components/agent-selector.tsx b/src/components/agent-selector.tsx index 54be9023..1c0d43b7 100644 --- a/src/components/agent-selector.tsx +++ b/src/components/agent-selector.tsx @@ -3,6 +3,7 @@ import { For, Show, createEffect, createMemo } from "solid-js" import { agents, fetchAgents, sessions } from "../stores/sessions" import { ChevronDown } from "lucide-solid" import type { Agent } from "../types/session" +import Kbd from "./kbd" interface AgentSelectorProps { instanceId: string @@ -52,50 +53,60 @@ export default function AgentSelector(props: AgentSelectorProps) { } return ( - a.name === props.currentAgent)} + onChange={handleChange} + options={availableAgents()} + optionValue="name" + optionTextValue="name" + placeholder="Select agent..." + itemComponent={(itemProps) => ( + +
+ + {itemProps.item.rawValue.name} + + subagent + + + + + {itemProps.item.rawValue.description.length > 50 + ? itemProps.item.rawValue.description.slice(0, 50) + "..." + : itemProps.item.rawValue.description} + - - - - {itemProps.item.rawValue.description.length > 50 - ? itemProps.item.rawValue.description.slice(0, 50) + "..." - : itemProps.item.rawValue.description} - - -
-
- )} - > - - > - {(state) => Agent: {state.selectedOption()?.name ?? "None"}} - - - - - + + + )} + > + + > + {(state) => Agent: {state.selectedOption()?.name ?? "None"}} + + + + + - - - - - - + + + + + + + 1}> + + Tab + + + ) } diff --git a/src/components/command-palette.tsx b/src/components/command-palette.tsx new file mode 100644 index 00000000..bc1c9e09 --- /dev/null +++ b/src/components/command-palette.tsx @@ -0,0 +1,157 @@ +import { Component, createSignal, For, Show, onMount, createEffect } from "solid-js" +import { Dialog } from "@kobalte/core/dialog" +import type { Command } from "../lib/commands" +import Kbd from "./kbd" + +interface CommandPaletteProps { + open: boolean + onClose: () => void + commands: Command[] + onExecute: (commandId: string) => void +} + +function buildShortcutString(shortcut: Command["shortcut"]): string { + if (!shortcut) return "" + + const parts: string[] = [] + + if (shortcut.meta || shortcut.ctrl) parts.push("cmd") + if (shortcut.shift) parts.push("shift") + if (shortcut.alt) parts.push("alt") + parts.push(shortcut.key) + + return parts.join("+") +} + +const CommandPalette: Component = (props) => { + const [query, setQuery] = createSignal("") + const [selectedIndex, setSelectedIndex] = createSignal(0) + let inputRef: HTMLInputElement | undefined + + const filteredCommands = () => { + const q = query().toLowerCase() + if (!q) return props.commands + + return props.commands.filter((cmd) => { + const labelMatch = cmd.label.toLowerCase().includes(q) + const descMatch = cmd.description.toLowerCase().includes(q) + const keywordMatch = cmd.keywords?.some((k) => k.toLowerCase().includes(q)) + return labelMatch || descMatch || keywordMatch + }) + } + + createEffect(() => { + if (props.open) { + setQuery("") + setSelectedIndex(0) + setTimeout(() => inputRef?.focus(), 100) + } + }) + + createEffect(() => { + const max = Math.max(0, filteredCommands().length - 1) + if (selectedIndex() > max) { + setSelectedIndex(max) + } + }) + + function handleKeyDown(e: KeyboardEvent) { + const filtered = filteredCommands() + + if (e.key === "ArrowDown") { + e.preventDefault() + setSelectedIndex((i) => Math.min(i + 1, filtered.length - 1)) + } else if (e.key === "ArrowUp") { + e.preventDefault() + setSelectedIndex((i) => Math.max(i - 1, 0)) + } else if (e.key === "Enter") { + e.preventDefault() + const selected = filtered[selectedIndex()] + if (selected) { + props.onExecute(selected.id) + props.onClose() + } + } else if (e.key === "Escape") { + e.preventDefault() + props.onClose() + } + } + + function handleCommandClick(commandId: string) { + props.onExecute(commandId) + props.onClose() + } + + return ( + !open && props.onClose()}> + + +
+ + Command Palette + Search and execute commands + +
+
+ + + + { + setQuery(e.currentTarget.value) + setSelectedIndex(0) + }} + placeholder="Type a command or search..." + class="flex-1 bg-transparent outline-none text-gray-900 dark:text-gray-100 placeholder-gray-400" + /> +
+
+ +
+ 0} + fallback={
No commands found for "{query()}"
} + > + + {(command, index) => ( + + )} + +
+
+
+
+
+
+ ) +} + +export default CommandPalette diff --git a/src/components/hint-row.tsx b/src/components/hint-row.tsx new file mode 100644 index 00000000..47844ee5 --- /dev/null +++ b/src/components/hint-row.tsx @@ -0,0 +1,12 @@ +import { Component, JSX } from "solid-js" + +interface HintRowProps { + children: JSX.Element + class?: string +} + +const HintRow: Component = (props) => { + return {props.children} +} + +export default HintRow diff --git a/src/components/instance-tabs.tsx b/src/components/instance-tabs.tsx index cd18689f..7d0bb934 100644 --- a/src/components/instance-tabs.tsx +++ b/src/components/instance-tabs.tsx @@ -1,7 +1,9 @@ -import { Component, For } from "solid-js" +import { Component, For, Show } from "solid-js" import type { Instance } from "../types/instance" import InstanceTab from "./instance-tab" +import KeyboardHint from "./keyboard-hint" import { Plus } from "lucide-solid" +import { keyboardRegistry } from "../lib/keyboard-registry" interface InstanceTabsProps { instances: Map @@ -14,25 +16,36 @@ interface InstanceTabsProps { const InstanceTabs: Component = (props) => { return (
-
- - {([id, instance]) => ( - props.onSelect(id)} - onClose={() => props.onClose(id)} +
+
+ + {([id, instance]) => ( + props.onSelect(id)} + onClose={() => props.onClose(id)} + /> + )} + + +
+ 1}> +
+ - )} - - +
+
) diff --git a/src/components/kbd.tsx b/src/components/kbd.tsx new file mode 100644 index 00000000..02059a1c --- /dev/null +++ b/src/components/kbd.tsx @@ -0,0 +1,54 @@ +import { Component, JSX, For } from "solid-js" +import { isMac } from "../lib/keyboard-utils" + +interface KbdProps { + children?: JSX.Element + shortcut?: string + class?: string +} + +const Kbd: Component = (props) => { + const parts = () => { + if (props.children) return [{ text: props.children, isModifier: false }] + if (!props.shortcut) return [] + + const result: { text: string | JSX.Element; isModifier: boolean }[] = [] + const shortcut = props.shortcut.toLowerCase() + const tokens = shortcut.split("+") + + tokens.forEach((token, i) => { + const trimmed = token.trim() + + if (trimmed === "cmd" || trimmed === "command") { + result.push({ text: isMac() ? "Cmd" : "Ctrl", isModifier: false }) + } else if (trimmed === "shift") { + result.push({ text: "Shift", isModifier: false }) + } else if (trimmed === "alt" || trimmed === "option") { + result.push({ text: isMac() ? "Option" : "Alt", isModifier: false }) + } else if (trimmed === "ctrl") { + result.push({ text: "Ctrl", isModifier: false }) + } else { + result.push({ text: trimmed.toUpperCase(), isModifier: false }) + } + }) + + return result + } + + return ( + + + {(part, index) => ( + <> + {index() > 0 && +} + {part.text} + + )} + + + ) +} + +export default Kbd diff --git a/src/components/keyboard-hint.tsx b/src/components/keyboard-hint.tsx new file mode 100644 index 00000000..bba8177a --- /dev/null +++ b/src/components/keyboard-hint.tsx @@ -0,0 +1,43 @@ +import { Component, For } from "solid-js" +import { formatShortcut, isMac } from "../lib/keyboard-utils" +import type { KeyboardShortcut } from "../lib/keyboard-registry" +import Kbd from "./kbd" +import HintRow from "./hint-row" + +const KeyboardHint: Component<{ + shortcuts: KeyboardShortcut[] + separator?: string +}> = (props) => { + function buildShortcutString(shortcut: KeyboardShortcut): string { + const parts: string[] = [] + + if (shortcut.modifiers.ctrl || shortcut.modifiers.meta) { + parts.push("cmd") + } + if (shortcut.modifiers.shift) { + parts.push("shift") + } + if (shortcut.modifiers.alt) { + parts.push("alt") + } + parts.push(shortcut.key) + + return parts.join("+") + } + + return ( + + + {(shortcut, i) => ( + <> + {i() > 0 && {props.separator || "•"}} + {shortcut.description} + + + )} + + + ) +} + +export default KeyboardHint diff --git a/src/components/model-selector.tsx b/src/components/model-selector.tsx index 82cd2a45..483bf3e2 100644 --- a/src/components/model-selector.tsx +++ b/src/components/model-selector.tsx @@ -3,6 +3,7 @@ import { For, Show, createEffect, createMemo } from "solid-js" import { providers, fetchProviders } from "../stores/sessions" import { ChevronDown } from "lucide-solid" import type { Provider, Model } from "../types/session" +import Kbd from "./kbd" interface ModelSelectorProps { instanceId: string @@ -53,56 +54,65 @@ export default function ModelSelector(props: ModelSelectorProps) { } return ( - `${m.providerId}/${m.id}`} - optionTextValue={(m) => `${m.name} ${m.providerName} ${m.providerId}/${m.id}`} - optionLabel="name" - placeholder="Search models..." - defaultFilter="contains" - triggerMode="focus" - allowsEmptyCollection={false} - itemComponent={(itemProps) => ( - + `${m.providerId}/${m.id}`} + optionTextValue={(m) => `${m.name} ${m.providerName} ${m.providerId}/${m.id}`} + optionLabel="name" + placeholder="Search models..." + defaultFilter="contains" + triggerMode="focus" + allowsEmptyCollection={false} + itemComponent={(itemProps) => ( + +
+ + {itemProps.item.rawValue.name} + + + {itemProps.item.rawValue.providerName} • {itemProps.item.rawValue.providerId}/ + {itemProps.item.rawValue.id} + +
+ + + + + +
+ )} + > + -
- - {itemProps.item.rawValue.name} - - - {itemProps.item.rawValue.providerName} • {itemProps.item.rawValue.providerId}/{itemProps.item.rawValue.id} - -
- - - - - -
- )} - > - - - - - - - - + + + + + + + - - - listboxRef} class="max-h-80 overflow-auto" /> - - -
+ + + listboxRef} class="max-h-80 overflow-auto" /> + + + + + + +
) } diff --git a/src/components/prompt-input.tsx b/src/components/prompt-input.tsx index 5b42fefe..66e485d7 100644 --- a/src/components/prompt-input.tsx +++ b/src/components/prompt-input.tsx @@ -1,9 +1,14 @@ -import { createSignal, Show } from "solid-js" +import { createSignal, Show, onMount, createEffect } from "solid-js" import AgentSelector from "./agent-selector" import ModelSelector from "./model-selector" +import { addToHistory, getHistory } from "../stores/message-history" +import Kbd from "./kbd" +import HintRow from "./hint-row" +import { isMac } from "../lib/keyboard-utils" interface PromptInputProps { instanceId: string + instanceFolder: string sessionId: string onSend: (prompt: string) => Promise disabled?: boolean @@ -16,12 +21,56 @@ interface PromptInputProps { export default function PromptInput(props: PromptInputProps) { const [prompt, setPrompt] = createSignal("") const [sending, setSending] = createSignal(false) + const [history, setHistory] = createSignal([]) + const [historyIndex, setHistoryIndex] = createSignal(-1) + const [isFocused, setIsFocused] = createSignal(false) let textareaRef: HTMLTextAreaElement | undefined + onMount(async () => { + const loaded = await getHistory(props.instanceFolder) + setHistory(loaded) + }) + function handleKeyDown(e: KeyboardEvent) { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault() handleSend() + return + } + + const textarea = textareaRef + if (!textarea) return + + const atStart = textarea.selectionStart === 0 && textarea.selectionEnd === 0 + const currentHistory = history() + + if (e.key === "ArrowUp" && atStart && currentHistory.length > 0) { + e.preventDefault() + const newIndex = historyIndex() === -1 ? 0 : Math.min(historyIndex() + 1, currentHistory.length - 1) + setHistoryIndex(newIndex) + setPrompt(currentHistory[newIndex]) + setTimeout(() => { + textarea.style.height = "auto" + textarea.style.height = Math.min(textarea.scrollHeight, 200) + "px" + }, 0) + return + } + + if (e.key === "ArrowDown" && historyIndex() >= 0) { + e.preventDefault() + const newIndex = historyIndex() - 1 + if (newIndex >= 0) { + setHistoryIndex(newIndex) + setPrompt(currentHistory[newIndex]) + } else { + setHistoryIndex(-1) + setPrompt("") + } + setTimeout(() => { + textarea.style.height = "auto" + textarea.style.height = Math.min(textarea.scrollHeight, 200) + "px" + }, 0) + return } } @@ -31,6 +80,12 @@ export default function PromptInput(props: PromptInputProps) { setSending(true) try { + await addToHistory(props.instanceFolder, text) + + const updated = await getHistory(props.instanceFolder) + setHistory(updated) + setHistoryIndex(-1) + await props.onSend(text) setPrompt("") @@ -49,6 +104,7 @@ export default function PromptInput(props: PromptInputProps) { function handleInput(e: Event) { const target = e.target as HTMLTextAreaElement setPrompt(target.value) + setHistoryIndex(-1) target.style.height = "auto" target.style.height = Math.min(target.scrollHeight, 200) + "px" @@ -66,6 +122,8 @@ export default function PromptInput(props: PromptInputProps) { value={prompt()} onInput={handleInput} onKeyDown={handleKeyDown} + onFocus={() => setIsFocused(true)} + onBlur={() => setIsFocused(false)} disabled={sending() || props.disabled} rows={1} /> @@ -76,9 +134,10 @@ export default function PromptInput(props: PromptInputProps) {
- - Enter to send, Shift+Enter for new line - + + Enter to send • Shift+Enter for new line • ↑↓ for history •{" "} + to focus +
= (props) => { const sessionsList = () => Array.from(props.sessions.entries()) + const totalTabs = () => sessionsList().length + 1 return (
-
- - {([id, session]) => ( - props.onSelect(id)} - onClose={session.parentId === null ? () => props.onClose(id) : undefined} +
+
+ + {([id, session]) => ( + props.onSelect(id)} + onClose={session.parentId === null ? () => props.onClose(id) : undefined} + /> + )} + + props.onSelect("logs")} + /> + +
+ 1}> +
+ - )} - - props.onSelect("logs")} /> - +
+
) diff --git a/src/lib/commands.ts b/src/lib/commands.ts new file mode 100644 index 00000000..f09106ae --- /dev/null +++ b/src/lib/commands.ts @@ -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 + category?: string +} + +export function createCommandRegistry() { + const commands = new Map() + + 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 diff --git a/src/lib/db.ts b/src/lib/db.ts new file mode 100644 index 00000000..f7c8890f --- /dev/null +++ b/src/lib/db.ts @@ -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 { + 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 { + 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 { + 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 { + 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() + }) +} diff --git a/src/lib/keyboard-registry.ts b/src/lib/keyboard-registry.ts new file mode 100644 index 00000000..c2240c54 --- /dev/null +++ b/src/lib/keyboard-registry.ts @@ -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() + + 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() diff --git a/src/lib/keyboard-utils.ts b/src/lib/keyboard-utils.ts new file mode 100644 index 00000000..217b9854 --- /dev/null +++ b/src/lib/keyboard-utils.ts @@ -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("+") +} diff --git a/src/lib/keyboard.ts b/src/lib/keyboard.ts index ed29517f..1059cdfa 100644 --- a/src/lib/keyboard.ts +++ b/src/lib/keyboard.ts @@ -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 diff --git a/src/lib/shortcuts/agent.ts b/src/lib/shortcuts/agent.ts new file mode 100644 index 00000000..909b7abe --- /dev/null +++ b/src/lib/shortcuts/agent.ts @@ -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", + }) +} diff --git a/src/lib/shortcuts/escape.ts b/src/lib/shortcuts/escape.ts new file mode 100644 index 00000000..c0477207 --- /dev/null +++ b/src/lib/shortcuts/escape.ts @@ -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", + }) +} diff --git a/src/lib/shortcuts/input.ts b/src/lib/shortcuts/input.ts new file mode 100644 index 00000000..d4d11696 --- /dev/null +++ b/src/lib/shortcuts/input.ts @@ -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", + }) +} diff --git a/src/lib/shortcuts/navigation.ts b/src/lib/shortcuts/navigation.ts new file mode 100644 index 00000000..65bc4ad4 --- /dev/null +++ b/src/lib/shortcuts/navigation.ts @@ -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", + }) +} diff --git a/src/stores/command-palette.ts b/src/stores/command-palette.ts new file mode 100644 index 00000000..80e0fc24 --- /dev/null +++ b/src/stores/command-palette.ts @@ -0,0 +1,17 @@ +import { createSignal } from "solid-js" + +const [isOpen, setIsOpen] = createSignal(false) + +export function showCommandPalette() { + setIsOpen(true) +} + +export function hideCommandPalette() { + setIsOpen(false) +} + +export function toggleCommandPalette() { + setIsOpen(!isOpen()) +} + +export { isOpen } diff --git a/src/stores/message-history.ts b/src/stores/message-history.ts new file mode 100644 index 00000000..36b211f9 --- /dev/null +++ b/src/stores/message-history.ts @@ -0,0 +1,51 @@ +import { saveHistory, loadHistory, deleteHistory } from "../lib/db" + +const MAX_HISTORY = 100 + +const instanceHistories = new Map() +const historyLoaded = new Set() + +export async function addToHistory(instanceId: string, text: string): Promise { + await ensureHistoryLoaded(instanceId) + + const history = instanceHistories.get(instanceId) || [] + + history.unshift(text) + + if (history.length > MAX_HISTORY) { + history.length = MAX_HISTORY + } + + instanceHistories.set(instanceId, history) + + saveHistory(instanceId, history).catch((err) => { + console.warn("Failed to persist message history:", err) + }) +} + +export async function getHistory(instanceId: string): Promise { + await ensureHistoryLoaded(instanceId) + return instanceHistories.get(instanceId) || [] +} + +export async function clearHistory(instanceId: string): Promise { + instanceHistories.delete(instanceId) + historyLoaded.delete(instanceId) + await deleteHistory(instanceId) +} + +async function ensureHistoryLoaded(instanceId: string): Promise { + if (historyLoaded.has(instanceId)) { + return + } + + try { + const history = await loadHistory(instanceId) + instanceHistories.set(instanceId, history) + historyLoaded.add(instanceId) + } catch (error) { + console.warn("Failed to load history:", error) + instanceHistories.set(instanceId, []) + historyLoaded.add(instanceId) + } +}