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

View File

@@ -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<string, Session>
instanceId: string
instanceFolder: string
}> = (props) => {
const session = () => props.activeSessions.get(props.sessionId)
@@ -90,6 +101,7 @@ const SessionView: Component<{
/>
<PromptInput
instanceId={props.instanceId}
instanceFolder={props.instanceFolder}
sessionId={s().id}
onSend={handleSendMessage}
agent={s().agent}
@@ -104,6 +116,8 @@ const SessionView: Component<{
}
const App: 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}
/>
</Show>
}
@@ -280,6 +528,13 @@ const App: Component = () => {
<Show when={sessionPickerInstance()}>
{(instanceId) => <SessionPicker instanceId={instanceId()} open={true} onClose={hideSessionPicker} />}
</Show>
<CommandPalette
open={isCommandPaletteOpen()}
onClose={hideCommandPalette}
commands={commandRegistry.getAll()}
onExecute={handleExecuteCommand}
/>
</div>
)
}

View File

@@ -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 (
<Select
value={availableAgents().find((a) => a.name === props.currentAgent)}
onChange={handleChange}
options={availableAgents()}
optionValue="name"
optionTextValue="name"
placeholder="Select agent..."
itemComponent={(itemProps) => (
<Select.Item
item={itemProps.item}
class="px-3 py-2 cursor-pointer hover:bg-gray-100 rounded outline-none focus:bg-gray-100"
>
<div class="flex flex-col">
<Select.ItemLabel class="font-medium text-sm text-gray-900 flex items-center gap-2">
<span>{itemProps.item.rawValue.name}</span>
<Show when={itemProps.item.rawValue.mode === "subagent"}>
<span class="text-xs font-normal text-blue-600 bg-blue-50 px-1.5 py-0.5 rounded">subagent</span>
<div class="flex items-center gap-2">
<Select
value={availableAgents().find((a) => a.name === props.currentAgent)}
onChange={handleChange}
options={availableAgents()}
optionValue="name"
optionTextValue="name"
placeholder="Select agent..."
itemComponent={(itemProps) => (
<Select.Item
item={itemProps.item}
class="px-3 py-2 cursor-pointer hover:bg-gray-100 rounded outline-none focus:bg-gray-100"
>
<div class="flex flex-col">
<Select.ItemLabel class="font-medium text-sm text-gray-900 flex items-center gap-2">
<span>{itemProps.item.rawValue.name}</span>
<Show when={itemProps.item.rawValue.mode === "subagent"}>
<span class="text-xs font-normal text-blue-600 bg-blue-50 px-1.5 py-0.5 rounded">subagent</span>
</Show>
</Select.ItemLabel>
<Show when={itemProps.item.rawValue.description}>
<Select.ItemDescription class="text-xs text-gray-600">
{itemProps.item.rawValue.description.length > 50
? itemProps.item.rawValue.description.slice(0, 50) + "..."
: itemProps.item.rawValue.description}
</Select.ItemDescription>
</Show>
</Select.ItemLabel>
<Show when={itemProps.item.rawValue.description}>
<Select.ItemDescription class="text-xs text-gray-600">
{itemProps.item.rawValue.description.length > 50
? itemProps.item.rawValue.description.slice(0, 50) + "..."
: itemProps.item.rawValue.description}
</Select.ItemDescription>
</Show>
</div>
</Select.Item>
)}
>
<Select.Trigger class="inline-flex items-center justify-between gap-2 px-2 py-1 bg-white border border-gray-300 rounded hover:bg-gray-50 outline-none focus:ring-2 focus:ring-blue-500 text-xs min-w-[100px]">
<Select.Value<Agent>>
{(state) => <span class="text-gray-700">Agent: {state.selectedOption()?.name ?? "None"}</span>}
</Select.Value>
<Select.Icon>
<ChevronDown class="w-3 h-3 text-gray-500" />
</Select.Icon>
</Select.Trigger>
</div>
</Select.Item>
)}
>
<Select.Trigger
data-agent-selector
class="inline-flex items-center justify-between gap-2 px-2 py-1 bg-white border border-gray-300 rounded hover:bg-gray-50 outline-none focus:ring-2 focus:ring-blue-500 text-xs min-w-[100px]"
>
<Select.Value<Agent>>
{(state) => <span class="text-gray-700">Agent: {state.selectedOption()?.name ?? "None"}</span>}
</Select.Value>
<Select.Icon>
<ChevronDown class="w-3 h-3 text-gray-500" />
</Select.Icon>
</Select.Trigger>
<Select.Portal>
<Select.Content class="bg-white border border-gray-300 rounded-md shadow-lg max-h-80 overflow-auto p-1 z-50">
<Select.Listbox />
</Select.Content>
</Select.Portal>
</Select>
<Select.Portal>
<Select.Content class="bg-white border border-gray-300 rounded-md shadow-lg max-h-80 overflow-auto p-1 z-50">
<Select.Listbox />
</Select.Content>
</Select.Portal>
</Select>
<Show when={availableAgents().length > 1}>
<span class="text-xs text-gray-400">
<Kbd>Tab</Kbd>
</span>
</Show>
</div>
)
}

View File

@@ -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<CommandPaletteProps> = (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 (
<Dialog open={props.open} onOpenChange={(open) => !open && props.onClose()}>
<Dialog.Portal>
<Dialog.Overlay class="fixed inset-0 bg-black/50 z-50" />
<div class="fixed inset-0 z-50 flex items-start justify-center pt-[20vh]">
<Dialog.Content
class="bg-white dark:bg-gray-800 rounded-lg shadow-2xl w-full max-w-2xl max-h-[60vh] flex flex-col"
onKeyDown={handleKeyDown}
>
<Dialog.Title class="sr-only">Command Palette</Dialog.Title>
<Dialog.Description class="sr-only">Search and execute commands</Dialog.Description>
<div class="p-4 border-b border-gray-200 dark:border-gray-700">
<div class="flex items-center gap-3">
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
<input
ref={inputRef}
type="text"
value={query()}
onInput={(e) => {
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"
/>
</div>
</div>
<div class="flex-1 overflow-y-auto">
<Show
when={filteredCommands().length > 0}
fallback={<div class="p-8 text-center text-gray-500">No commands found for "{query()}"</div>}
>
<For each={filteredCommands()}>
{(command, index) => (
<button
type="button"
onClick={() => handleCommandClick(command.id)}
class={`w-full px-4 py-3 flex items-start gap-3 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors cursor-pointer border-none text-left ${
index() === selectedIndex() ? "bg-blue-50 dark:bg-blue-900/20" : ""
}`}
onMouseEnter={() => setSelectedIndex(index())}
>
<div class="flex-1 min-w-0">
<div class="font-medium text-gray-900 dark:text-gray-100">{command.label}</div>
<div class="text-sm text-gray-600 dark:text-gray-400 mt-0.5">{command.description}</div>
</div>
<Show when={command.shortcut}>
<div class="mt-1">
<Kbd shortcut={buildShortcutString(command.shortcut)} />
</div>
</Show>
</button>
)}
</For>
</Show>
</div>
</Dialog.Content>
</div>
</Dialog.Portal>
</Dialog>
)
}
export default CommandPalette

View File

@@ -0,0 +1,12 @@
import { Component, JSX } from "solid-js"
interface HintRowProps {
children: JSX.Element
class?: string
}
const HintRow: Component<HintRowProps> = (props) => {
return <span class={`text-xs text-gray-500 dark:text-gray-400 ${props.class || ""}`}>{props.children}</span>
}
export default HintRow

View File

@@ -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<string, Instance>
@@ -14,25 +16,36 @@ interface InstanceTabsProps {
const InstanceTabs: Component<InstanceTabsProps> = (props) => {
return (
<div class="instance-tabs bg-gray-50 border-b border-gray-200">
<div class="tabs-container flex items-center gap-1 px-2 py-1 overflow-x-auto" role="tablist">
<For each={Array.from(props.instances.entries())}>
{([id, instance]) => (
<InstanceTab
instance={instance}
active={id === props.activeInstanceId}
onSelect={() => props.onSelect(id)}
onClose={() => props.onClose(id)}
<div class="tabs-container flex items-center justify-between gap-1 px-2 py-1 overflow-x-auto" role="tablist">
<div class="flex items-center gap-1 overflow-x-auto">
<For each={Array.from(props.instances.entries())}>
{([id, instance]) => (
<InstanceTab
instance={instance}
active={id === props.activeInstanceId}
onSelect={() => props.onSelect(id)}
onClose={() => props.onClose(id)}
/>
)}
</For>
<button
class="new-tab-button inline-flex items-center justify-center w-8 h-8 rounded-md text-gray-600 hover:bg-gray-200 transition-colors"
onClick={props.onNew}
title="New instance (Cmd/Ctrl+N)"
aria-label="New instance"
>
<Plus class="w-4 h-4" />
</button>
</div>
<Show when={Array.from(props.instances.entries()).length > 1}>
<div class="flex-shrink-0 ml-4">
<KeyboardHint
shortcuts={[keyboardRegistry.get("instance-prev")!, keyboardRegistry.get("instance-next")!].filter(
Boolean,
)}
/>
)}
</For>
<button
class="new-tab-button inline-flex items-center justify-center w-8 h-8 rounded-md text-gray-600 hover:bg-gray-200 transition-colors"
onClick={props.onNew}
title="New instance (Cmd/Ctrl+N)"
aria-label="New instance"
>
<Plus class="w-4 h-4" />
</button>
</div>
</Show>
</div>
</div>
)

54
src/components/kbd.tsx Normal file
View File

@@ -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<KbdProps> = (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 (
<kbd
class={`font-mono bg-gray-100 dark:bg-gray-800 px-1.5 py-0.5 rounded text-xs inline-flex items-center gap-0.5 ${props.class || ""}`}
>
<For each={parts()}>
{(part, index) => (
<>
{index() > 0 && <span class="opacity-50">+</span>}
<span>{part.text}</span>
</>
)}
</For>
</kbd>
)
}
export default Kbd

View File

@@ -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 (
<HintRow>
<For each={props.shortcuts}>
{(shortcut, i) => (
<>
{i() > 0 && <span class="mx-1">{props.separator || "•"}</span>}
<span class="mr-1">{shortcut.description}</span>
<Kbd shortcut={buildShortcutString(shortcut)} />
</>
)}
</For>
</HintRow>
)
}
export default KeyboardHint

View File

@@ -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 (
<Combobox
value={currentModelValue()}
onChange={handleChange}
options={allModels()}
optionValue={(m) => `${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) => (
<Combobox.Item
item={itemProps.item}
class="px-3 py-2 cursor-pointer hover:bg-gray-100 rounded outline-none focus:bg-gray-100 flex items-start gap-2"
<div class="flex items-center gap-2">
<Combobox
value={currentModelValue()}
onChange={handleChange}
options={allModels()}
optionValue={(m) => `${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) => (
<Combobox.Item
item={itemProps.item}
class="px-3 py-2 cursor-pointer hover:bg-gray-100 rounded outline-none focus:bg-gray-100 flex items-start gap-2"
>
<div class="flex flex-col flex-1 min-w-0">
<Combobox.ItemLabel class="font-medium text-sm text-gray-900">
{itemProps.item.rawValue.name}
</Combobox.ItemLabel>
<Combobox.ItemDescription class="text-xs text-gray-600">
{itemProps.item.rawValue.providerName} {itemProps.item.rawValue.providerId}/
{itemProps.item.rawValue.id}
</Combobox.ItemDescription>
</div>
<Combobox.ItemIndicator class="flex-shrink-0 mt-0.5">
<svg class="w-4 h-4 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
</Combobox.ItemIndicator>
</Combobox.Item>
)}
>
<Combobox.Control
data-model-selector
class="inline-flex items-center justify-between gap-1 px-2 py-1 bg-white border border-gray-300 rounded hover:bg-gray-50 outline-none focus-within:ring-2 focus-within:ring-blue-500 text-xs min-w-[140px]"
>
<div class="flex flex-col flex-1 min-w-0">
<Combobox.ItemLabel class="font-medium text-sm text-gray-900">
{itemProps.item.rawValue.name}
</Combobox.ItemLabel>
<Combobox.ItemDescription class="text-xs text-gray-600">
{itemProps.item.rawValue.providerName} {itemProps.item.rawValue.providerId}/{itemProps.item.rawValue.id}
</Combobox.ItemDescription>
</div>
<Combobox.ItemIndicator class="flex-shrink-0 mt-0.5">
<svg class="w-4 h-4 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
</Combobox.ItemIndicator>
</Combobox.Item>
)}
>
<Combobox.Control class="inline-flex items-center justify-between gap-1 px-2 py-1 bg-white border border-gray-300 rounded hover:bg-gray-50 outline-none focus-within:ring-2 focus-within:ring-blue-500 text-xs min-w-[140px]">
<Combobox.Input
ref={inputRef}
onFocus={handleFocus}
class="bg-transparent border-none outline-none text-xs text-gray-700 placeholder:text-gray-500 w-full min-w-0 px-0"
/>
<Combobox.Trigger class="flex items-center justify-center">
<Combobox.Icon>
<ChevronDown class="w-3 h-3 text-gray-500" />
</Combobox.Icon>
</Combobox.Trigger>
</Combobox.Control>
<Combobox.Input
ref={inputRef}
onFocus={handleFocus}
class="bg-transparent border-none outline-none text-xs text-gray-700 placeholder:text-gray-500 w-full min-w-0 px-0"
/>
<Combobox.Trigger class="flex items-center justify-center">
<Combobox.Icon>
<ChevronDown class="w-3 h-3 text-gray-500" />
</Combobox.Icon>
</Combobox.Trigger>
</Combobox.Control>
<Combobox.Portal>
<Combobox.Content class="bg-white border border-gray-300 rounded-md shadow-lg max-h-80 overflow-hidden p-1 z-50 min-w-[300px]">
<Combobox.Listbox ref={listboxRef} scrollRef={() => listboxRef} class="max-h-80 overflow-auto" />
</Combobox.Content>
</Combobox.Portal>
</Combobox>
<Combobox.Portal>
<Combobox.Content class="bg-white border border-gray-300 rounded-md shadow-lg max-h-80 overflow-hidden p-1 z-50 min-w-[300px]">
<Combobox.Listbox ref={listboxRef} scrollRef={() => listboxRef} class="max-h-80 overflow-auto" />
</Combobox.Content>
</Combobox.Portal>
</Combobox>
<span class="text-xs text-gray-400">
<Kbd shortcut="cmd+shift+m" />
</span>
</div>
)
}

View File

@@ -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<void>
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<string[]>([])
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) {
</button>
</div>
<div class="prompt-input-hints">
<span class="hint">
<kbd>Enter</kbd> to send, <kbd>Shift+Enter</kbd> for new line
</span>
<HintRow>
<Kbd>Enter</Kbd> to send <Kbd>Shift+Enter</Kbd> for new line <Kbd></Kbd> for history {" "}
<Kbd shortcut="cmd+p" /> to focus
</HintRow>
<div class="flex items-center gap-2">
<AgentSelector
instanceId={props.instanceId}

View File

@@ -1,7 +1,9 @@
import { Component, For } from "solid-js"
import { Component, For, Show } from "solid-js"
import type { Session } from "../types/session"
import SessionTab from "./session-tab"
import KeyboardHint from "./keyboard-hint"
import { Plus } from "lucide-solid"
import { keyboardRegistry } from "../lib/keyboard-registry"
interface SessionTabsProps {
instanceId: string
@@ -14,30 +16,44 @@ interface SessionTabsProps {
const SessionTabs: Component<SessionTabsProps> = (props) => {
const sessionsList = () => Array.from(props.sessions.entries())
const totalTabs = () => sessionsList().length + 1
return (
<div class="session-tabs bg-white border-b border-gray-200">
<div class="tabs-container flex items-center gap-1 px-2 py-1 overflow-x-auto" role="tablist">
<For each={sessionsList()}>
{([id, session]) => (
<SessionTab
session={session}
active={id === props.activeSessionId}
isParent={session.parentId === null}
onSelect={() => props.onSelect(id)}
onClose={session.parentId === null ? () => props.onClose(id) : undefined}
<div class="tabs-container flex items-center justify-between gap-1 px-2 py-1 overflow-x-auto" role="tablist">
<div class="flex items-center gap-1 overflow-x-auto">
<For each={sessionsList()}>
{([id, session]) => (
<SessionTab
session={session}
active={id === props.activeSessionId}
isParent={session.parentId === null}
onSelect={() => props.onSelect(id)}
onClose={session.parentId === null ? () => props.onClose(id) : undefined}
/>
)}
</For>
<SessionTab
special="logs"
active={props.activeSessionId === "logs"}
onSelect={() => props.onSelect("logs")}
/>
<button
class="new-tab-button inline-flex items-center justify-center w-8 h-8 rounded-md text-gray-600 hover:bg-gray-100 transition-colors"
onClick={props.onNew}
title="New parent session (Cmd/Ctrl+T)"
aria-label="New parent session"
>
<Plus class="w-4 h-4" />
</button>
</div>
<Show when={totalTabs() > 1}>
<div class="flex-shrink-0 ml-4">
<KeyboardHint
shortcuts={[keyboardRegistry.get("session-prev")!, keyboardRegistry.get("session-next")!].filter(Boolean)}
/>
)}
</For>
<SessionTab special="logs" active={props.activeSessionId === "logs"} onSelect={() => props.onSelect("logs")} />
<button
class="new-tab-button inline-flex items-center justify-center w-8 h-8 rounded-md text-gray-600 hover:bg-gray-100 transition-colors"
onClick={props.onNew}
title="New parent session (Cmd/Ctrl+T)"
aria-label="New parent session"
>
<Plus class="w-4 h-4" />
</button>
</div>
</Show>
</div>
</div>
)

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",
})
}

View File

@@ -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 }

View File

@@ -0,0 +1,51 @@
import { saveHistory, loadHistory, deleteHistory } from "../lib/db"
const MAX_HISTORY = 100
const instanceHistories = new Map<string, string[]>()
const historyLoaded = new Set<string>()
export async function addToHistory(instanceId: string, text: string): Promise<void> {
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<string[]> {
await ensureHistoryLoaded(instanceId)
return instanceHistories.get(instanceId) || []
}
export async function clearHistory(instanceId: string): Promise<void> {
instanceHistories.delete(instanceId)
historyLoaded.delete(instanceId)
await deleteHistory(instanceId)
}
async function ensureHistoryLoaded(instanceId: string): Promise<void> {
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)
}
}