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:
259
src/App.tsx
259
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 type { Session } from "./types/session"
|
||||||
import EmptyState from "./components/empty-state"
|
import EmptyState from "./components/empty-state"
|
||||||
import SessionPicker from "./components/session-picker"
|
import SessionPicker from "./components/session-picker"
|
||||||
|
import CommandPalette from "./components/command-palette"
|
||||||
import InstanceTabs from "./components/instance-tabs"
|
import InstanceTabs from "./components/instance-tabs"
|
||||||
import SessionTabs from "./components/session-tabs"
|
import SessionTabs from "./components/session-tabs"
|
||||||
import MessageStream from "./components/message-stream"
|
import MessageStream from "./components/message-stream"
|
||||||
import PromptInput from "./components/prompt-input"
|
import PromptInput from "./components/prompt-input"
|
||||||
import LogsView from "./components/logs-view"
|
import LogsView from "./components/logs-view"
|
||||||
import { initMarkdown } from "./lib/markdown"
|
import { initMarkdown } from "./lib/markdown"
|
||||||
|
import { createCommandRegistry } from "./lib/commands"
|
||||||
|
import type { Command } from "./lib/commands"
|
||||||
import {
|
import {
|
||||||
hasInstances,
|
hasInstances,
|
||||||
isSelectingFolder,
|
isSelectingFolder,
|
||||||
@@ -42,13 +45,21 @@ import {
|
|||||||
sendMessage,
|
sendMessage,
|
||||||
updateSessionAgent,
|
updateSessionAgent,
|
||||||
updateSessionModel,
|
updateSessionModel,
|
||||||
|
agents,
|
||||||
} from "./stores/sessions"
|
} from "./stores/sessions"
|
||||||
import { setupTabKeyboardShortcuts } from "./lib/keyboard"
|
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<{
|
const SessionView: Component<{
|
||||||
sessionId: string
|
sessionId: string
|
||||||
activeSessions: Map<string, Session>
|
activeSessions: Map<string, Session>
|
||||||
instanceId: string
|
instanceId: string
|
||||||
|
instanceFolder: string
|
||||||
}> = (props) => {
|
}> = (props) => {
|
||||||
const session = () => props.activeSessions.get(props.sessionId)
|
const session = () => props.activeSessions.get(props.sessionId)
|
||||||
|
|
||||||
@@ -90,6 +101,7 @@ const SessionView: Component<{
|
|||||||
/>
|
/>
|
||||||
<PromptInput
|
<PromptInput
|
||||||
instanceId={props.instanceId}
|
instanceId={props.instanceId}
|
||||||
|
instanceFolder={props.instanceFolder}
|
||||||
sessionId={s().id}
|
sessionId={s().id}
|
||||||
onSend={handleSendMessage}
|
onSend={handleSendMessage}
|
||||||
agent={s().agent}
|
agent={s().agent}
|
||||||
@@ -104,6 +116,8 @@ const SessionView: Component<{
|
|||||||
}
|
}
|
||||||
|
|
||||||
const App: Component = () => {
|
const App: Component = () => {
|
||||||
|
const commandRegistry = createCommandRegistry()
|
||||||
|
|
||||||
const activeInstance = createMemo(() => getActiveInstance())
|
const activeInstance = createMemo(() => getActiveInstance())
|
||||||
|
|
||||||
const activeSessions = createMemo(() => {
|
const activeSessions = createMemo(() => {
|
||||||
@@ -175,10 +189,243 @@ const App: Component = () => {
|
|||||||
showSessionPicker(instanceId)
|
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(() => {
|
onMount(() => {
|
||||||
initMarkdown(false).catch(console.error)
|
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(() => {
|
window.electronAPI.onNewInstance(() => {
|
||||||
handleSelectFolder()
|
handleSelectFolder()
|
||||||
@@ -260,6 +507,7 @@ const App: Component = () => {
|
|||||||
sessionId={activeSessionIdForInstance()!}
|
sessionId={activeSessionIdForInstance()!}
|
||||||
activeSessions={activeSessions()}
|
activeSessions={activeSessions()}
|
||||||
instanceId={activeInstance()!.id}
|
instanceId={activeInstance()!.id}
|
||||||
|
instanceFolder={activeInstance()!.folder}
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
}
|
}
|
||||||
@@ -280,6 +528,13 @@ const App: Component = () => {
|
|||||||
<Show when={sessionPickerInstance()}>
|
<Show when={sessionPickerInstance()}>
|
||||||
{(instanceId) => <SessionPicker instanceId={instanceId()} open={true} onClose={hideSessionPicker} />}
|
{(instanceId) => <SessionPicker instanceId={instanceId()} open={true} onClose={hideSessionPicker} />}
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
|
<CommandPalette
|
||||||
|
open={isCommandPaletteOpen()}
|
||||||
|
onClose={hideCommandPalette}
|
||||||
|
commands={commandRegistry.getAll()}
|
||||||
|
onExecute={handleExecuteCommand}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { For, Show, createEffect, createMemo } from "solid-js"
|
|||||||
import { agents, fetchAgents, sessions } from "../stores/sessions"
|
import { agents, fetchAgents, sessions } from "../stores/sessions"
|
||||||
import { ChevronDown } from "lucide-solid"
|
import { ChevronDown } from "lucide-solid"
|
||||||
import type { Agent } from "../types/session"
|
import type { Agent } from "../types/session"
|
||||||
|
import Kbd from "./kbd"
|
||||||
|
|
||||||
interface AgentSelectorProps {
|
interface AgentSelectorProps {
|
||||||
instanceId: string
|
instanceId: string
|
||||||
@@ -52,50 +53,60 @@ export default function AgentSelector(props: AgentSelectorProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Select
|
<div class="flex items-center gap-2">
|
||||||
value={availableAgents().find((a) => a.name === props.currentAgent)}
|
<Select
|
||||||
onChange={handleChange}
|
value={availableAgents().find((a) => a.name === props.currentAgent)}
|
||||||
options={availableAgents()}
|
onChange={handleChange}
|
||||||
optionValue="name"
|
options={availableAgents()}
|
||||||
optionTextValue="name"
|
optionValue="name"
|
||||||
placeholder="Select agent..."
|
optionTextValue="name"
|
||||||
itemComponent={(itemProps) => (
|
placeholder="Select agent..."
|
||||||
<Select.Item
|
itemComponent={(itemProps) => (
|
||||||
item={itemProps.item}
|
<Select.Item
|
||||||
class="px-3 py-2 cursor-pointer hover:bg-gray-100 rounded outline-none focus:bg-gray-100"
|
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">
|
<div class="flex flex-col">
|
||||||
<span>{itemProps.item.rawValue.name}</span>
|
<Select.ItemLabel class="font-medium text-sm text-gray-900 flex items-center gap-2">
|
||||||
<Show when={itemProps.item.rawValue.mode === "subagent"}>
|
<span>{itemProps.item.rawValue.name}</span>
|
||||||
<span class="text-xs font-normal text-blue-600 bg-blue-50 px-1.5 py-0.5 rounded">subagent</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>
|
</Show>
|
||||||
</Select.ItemLabel>
|
</div>
|
||||||
<Show when={itemProps.item.rawValue.description}>
|
</Select.Item>
|
||||||
<Select.ItemDescription class="text-xs text-gray-600">
|
)}
|
||||||
{itemProps.item.rawValue.description.length > 50
|
>
|
||||||
? itemProps.item.rawValue.description.slice(0, 50) + "..."
|
<Select.Trigger
|
||||||
: itemProps.item.rawValue.description}
|
data-agent-selector
|
||||||
</Select.ItemDescription>
|
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]"
|
||||||
</Show>
|
>
|
||||||
</div>
|
<Select.Value<Agent>>
|
||||||
</Select.Item>
|
{(state) => <span class="text-gray-700">Agent: {state.selectedOption()?.name ?? "None"}</span>}
|
||||||
)}
|
</Select.Value>
|
||||||
>
|
<Select.Icon>
|
||||||
<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]">
|
<ChevronDown class="w-3 h-3 text-gray-500" />
|
||||||
<Select.Value<Agent>>
|
</Select.Icon>
|
||||||
{(state) => <span class="text-gray-700">Agent: {state.selectedOption()?.name ?? "None"}</span>}
|
</Select.Trigger>
|
||||||
</Select.Value>
|
|
||||||
<Select.Icon>
|
|
||||||
<ChevronDown class="w-3 h-3 text-gray-500" />
|
|
||||||
</Select.Icon>
|
|
||||||
</Select.Trigger>
|
|
||||||
|
|
||||||
<Select.Portal>
|
<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.Content class="bg-white border border-gray-300 rounded-md shadow-lg max-h-80 overflow-auto p-1 z-50">
|
||||||
<Select.Listbox />
|
<Select.Listbox />
|
||||||
</Select.Content>
|
</Select.Content>
|
||||||
</Select.Portal>
|
</Select.Portal>
|
||||||
</Select>
|
</Select>
|
||||||
|
<Show when={availableAgents().length > 1}>
|
||||||
|
<span class="text-xs text-gray-400">
|
||||||
|
<Kbd>Tab</Kbd>
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
157
src/components/command-palette.tsx
Normal file
157
src/components/command-palette.tsx
Normal 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
|
||||||
12
src/components/hint-row.tsx
Normal file
12
src/components/hint-row.tsx
Normal 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
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import { Component, For } from "solid-js"
|
import { Component, For, Show } from "solid-js"
|
||||||
import type { Instance } from "../types/instance"
|
import type { Instance } from "../types/instance"
|
||||||
import InstanceTab from "./instance-tab"
|
import InstanceTab from "./instance-tab"
|
||||||
|
import KeyboardHint from "./keyboard-hint"
|
||||||
import { Plus } from "lucide-solid"
|
import { Plus } from "lucide-solid"
|
||||||
|
import { keyboardRegistry } from "../lib/keyboard-registry"
|
||||||
|
|
||||||
interface InstanceTabsProps {
|
interface InstanceTabsProps {
|
||||||
instances: Map<string, Instance>
|
instances: Map<string, Instance>
|
||||||
@@ -14,25 +16,36 @@ interface InstanceTabsProps {
|
|||||||
const InstanceTabs: Component<InstanceTabsProps> = (props) => {
|
const InstanceTabs: Component<InstanceTabsProps> = (props) => {
|
||||||
return (
|
return (
|
||||||
<div class="instance-tabs bg-gray-50 border-b border-gray-200">
|
<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">
|
<div class="tabs-container flex items-center justify-between gap-1 px-2 py-1 overflow-x-auto" role="tablist">
|
||||||
<For each={Array.from(props.instances.entries())}>
|
<div class="flex items-center gap-1 overflow-x-auto">
|
||||||
{([id, instance]) => (
|
<For each={Array.from(props.instances.entries())}>
|
||||||
<InstanceTab
|
{([id, instance]) => (
|
||||||
instance={instance}
|
<InstanceTab
|
||||||
active={id === props.activeInstanceId}
|
instance={instance}
|
||||||
onSelect={() => props.onSelect(id)}
|
active={id === props.activeInstanceId}
|
||||||
onClose={() => props.onClose(id)}
|
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,
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
</div>
|
||||||
</For>
|
</Show>
|
||||||
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
54
src/components/kbd.tsx
Normal file
54
src/components/kbd.tsx
Normal 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
|
||||||
43
src/components/keyboard-hint.tsx
Normal file
43
src/components/keyboard-hint.tsx
Normal 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
|
||||||
@@ -3,6 +3,7 @@ import { For, Show, createEffect, createMemo } from "solid-js"
|
|||||||
import { providers, fetchProviders } from "../stores/sessions"
|
import { providers, fetchProviders } from "../stores/sessions"
|
||||||
import { ChevronDown } from "lucide-solid"
|
import { ChevronDown } from "lucide-solid"
|
||||||
import type { Provider, Model } from "../types/session"
|
import type { Provider, Model } from "../types/session"
|
||||||
|
import Kbd from "./kbd"
|
||||||
|
|
||||||
interface ModelSelectorProps {
|
interface ModelSelectorProps {
|
||||||
instanceId: string
|
instanceId: string
|
||||||
@@ -53,56 +54,65 @@ export default function ModelSelector(props: ModelSelectorProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Combobox
|
<div class="flex items-center gap-2">
|
||||||
value={currentModelValue()}
|
<Combobox
|
||||||
onChange={handleChange}
|
value={currentModelValue()}
|
||||||
options={allModels()}
|
onChange={handleChange}
|
||||||
optionValue={(m) => `${m.providerId}/${m.id}`}
|
options={allModels()}
|
||||||
optionTextValue={(m) => `${m.name} ${m.providerName} ${m.providerId}/${m.id}`}
|
optionValue={(m) => `${m.providerId}/${m.id}`}
|
||||||
optionLabel="name"
|
optionTextValue={(m) => `${m.name} ${m.providerName} ${m.providerId}/${m.id}`}
|
||||||
placeholder="Search models..."
|
optionLabel="name"
|
||||||
defaultFilter="contains"
|
placeholder="Search models..."
|
||||||
triggerMode="focus"
|
defaultFilter="contains"
|
||||||
allowsEmptyCollection={false}
|
triggerMode="focus"
|
||||||
itemComponent={(itemProps) => (
|
allowsEmptyCollection={false}
|
||||||
<Combobox.Item
|
itemComponent={(itemProps) => (
|
||||||
item={itemProps.item}
|
<Combobox.Item
|
||||||
class="px-3 py-2 cursor-pointer hover:bg-gray-100 rounded outline-none focus:bg-gray-100 flex items-start gap-2"
|
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.Input
|
||||||
<Combobox.ItemLabel class="font-medium text-sm text-gray-900">
|
ref={inputRef}
|
||||||
{itemProps.item.rawValue.name}
|
onFocus={handleFocus}
|
||||||
</Combobox.ItemLabel>
|
class="bg-transparent border-none outline-none text-xs text-gray-700 placeholder:text-gray-500 w-full min-w-0 px-0"
|
||||||
<Combobox.ItemDescription class="text-xs text-gray-600">
|
/>
|
||||||
{itemProps.item.rawValue.providerName} • {itemProps.item.rawValue.providerId}/{itemProps.item.rawValue.id}
|
<Combobox.Trigger class="flex items-center justify-center">
|
||||||
</Combobox.ItemDescription>
|
<Combobox.Icon>
|
||||||
</div>
|
<ChevronDown class="w-3 h-3 text-gray-500" />
|
||||||
<Combobox.ItemIndicator class="flex-shrink-0 mt-0.5">
|
</Combobox.Icon>
|
||||||
<svg class="w-4 h-4 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
</Combobox.Trigger>
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
</Combobox.Control>
|
||||||
</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.Portal>
|
<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.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.Listbox ref={listboxRef} scrollRef={() => listboxRef} class="max-h-80 overflow-auto" />
|
||||||
</Combobox.Content>
|
</Combobox.Content>
|
||||||
</Combobox.Portal>
|
</Combobox.Portal>
|
||||||
</Combobox>
|
</Combobox>
|
||||||
|
<span class="text-xs text-gray-400">
|
||||||
|
<Kbd shortcut="cmd+shift+m" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
import { createSignal, Show } from "solid-js"
|
import { createSignal, Show, onMount, createEffect } from "solid-js"
|
||||||
import AgentSelector from "./agent-selector"
|
import AgentSelector from "./agent-selector"
|
||||||
import ModelSelector from "./model-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 {
|
interface PromptInputProps {
|
||||||
instanceId: string
|
instanceId: string
|
||||||
|
instanceFolder: string
|
||||||
sessionId: string
|
sessionId: string
|
||||||
onSend: (prompt: string) => Promise<void>
|
onSend: (prompt: string) => Promise<void>
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
@@ -16,12 +21,56 @@ interface PromptInputProps {
|
|||||||
export default function PromptInput(props: PromptInputProps) {
|
export default function PromptInput(props: PromptInputProps) {
|
||||||
const [prompt, setPrompt] = createSignal("")
|
const [prompt, setPrompt] = createSignal("")
|
||||||
const [sending, setSending] = createSignal(false)
|
const [sending, setSending] = createSignal(false)
|
||||||
|
const [history, setHistory] = createSignal<string[]>([])
|
||||||
|
const [historyIndex, setHistoryIndex] = createSignal(-1)
|
||||||
|
const [isFocused, setIsFocused] = createSignal(false)
|
||||||
let textareaRef: HTMLTextAreaElement | undefined
|
let textareaRef: HTMLTextAreaElement | undefined
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
const loaded = await getHistory(props.instanceFolder)
|
||||||
|
setHistory(loaded)
|
||||||
|
})
|
||||||
|
|
||||||
function handleKeyDown(e: KeyboardEvent) {
|
function handleKeyDown(e: KeyboardEvent) {
|
||||||
if (e.key === "Enter" && !e.shiftKey) {
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
handleSend()
|
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)
|
setSending(true)
|
||||||
try {
|
try {
|
||||||
|
await addToHistory(props.instanceFolder, text)
|
||||||
|
|
||||||
|
const updated = await getHistory(props.instanceFolder)
|
||||||
|
setHistory(updated)
|
||||||
|
setHistoryIndex(-1)
|
||||||
|
|
||||||
await props.onSend(text)
|
await props.onSend(text)
|
||||||
setPrompt("")
|
setPrompt("")
|
||||||
|
|
||||||
@@ -49,6 +104,7 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
function handleInput(e: Event) {
|
function handleInput(e: Event) {
|
||||||
const target = e.target as HTMLTextAreaElement
|
const target = e.target as HTMLTextAreaElement
|
||||||
setPrompt(target.value)
|
setPrompt(target.value)
|
||||||
|
setHistoryIndex(-1)
|
||||||
|
|
||||||
target.style.height = "auto"
|
target.style.height = "auto"
|
||||||
target.style.height = Math.min(target.scrollHeight, 200) + "px"
|
target.style.height = Math.min(target.scrollHeight, 200) + "px"
|
||||||
@@ -66,6 +122,8 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
value={prompt()}
|
value={prompt()}
|
||||||
onInput={handleInput}
|
onInput={handleInput}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
|
onFocus={() => setIsFocused(true)}
|
||||||
|
onBlur={() => setIsFocused(false)}
|
||||||
disabled={sending() || props.disabled}
|
disabled={sending() || props.disabled}
|
||||||
rows={1}
|
rows={1}
|
||||||
/>
|
/>
|
||||||
@@ -76,9 +134,10 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="prompt-input-hints">
|
<div class="prompt-input-hints">
|
||||||
<span class="hint">
|
<HintRow>
|
||||||
<kbd>Enter</kbd> to send, <kbd>Shift+Enter</kbd> for new line
|
<Kbd>Enter</Kbd> to send • <Kbd>Shift+Enter</Kbd> for new line • <Kbd>↑↓</Kbd> for history •{" "}
|
||||||
</span>
|
<Kbd shortcut="cmd+p" /> to focus
|
||||||
|
</HintRow>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<AgentSelector
|
<AgentSelector
|
||||||
instanceId={props.instanceId}
|
instanceId={props.instanceId}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { Component, For } from "solid-js"
|
import { Component, For, Show } from "solid-js"
|
||||||
import type { Session } from "../types/session"
|
import type { Session } from "../types/session"
|
||||||
import SessionTab from "./session-tab"
|
import SessionTab from "./session-tab"
|
||||||
|
import KeyboardHint from "./keyboard-hint"
|
||||||
import { Plus } from "lucide-solid"
|
import { Plus } from "lucide-solid"
|
||||||
|
import { keyboardRegistry } from "../lib/keyboard-registry"
|
||||||
|
|
||||||
interface SessionTabsProps {
|
interface SessionTabsProps {
|
||||||
instanceId: string
|
instanceId: string
|
||||||
@@ -14,30 +16,44 @@ interface SessionTabsProps {
|
|||||||
|
|
||||||
const SessionTabs: Component<SessionTabsProps> = (props) => {
|
const SessionTabs: Component<SessionTabsProps> = (props) => {
|
||||||
const sessionsList = () => Array.from(props.sessions.entries())
|
const sessionsList = () => Array.from(props.sessions.entries())
|
||||||
|
const totalTabs = () => sessionsList().length + 1
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="session-tabs bg-white border-b border-gray-200">
|
<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">
|
<div class="tabs-container flex items-center justify-between gap-1 px-2 py-1 overflow-x-auto" role="tablist">
|
||||||
<For each={sessionsList()}>
|
<div class="flex items-center gap-1 overflow-x-auto">
|
||||||
{([id, session]) => (
|
<For each={sessionsList()}>
|
||||||
<SessionTab
|
{([id, session]) => (
|
||||||
session={session}
|
<SessionTab
|
||||||
active={id === props.activeSessionId}
|
session={session}
|
||||||
isParent={session.parentId === null}
|
active={id === props.activeSessionId}
|
||||||
onSelect={() => props.onSelect(id)}
|
isParent={session.parentId === null}
|
||||||
onClose={session.parentId === null ? () => props.onClose(id) : undefined}
|
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)}
|
||||||
/>
|
/>
|
||||||
)}
|
</div>
|
||||||
</For>
|
</Show>
|
||||||
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
67
src/lib/commands.ts
Normal file
67
src/lib/commands.ts
Normal 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
68
src/lib/db.ts
Normal 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()
|
||||||
|
})
|
||||||
|
}
|
||||||
69
src/lib/keyboard-registry.ts
Normal file
69
src/lib/keyboard-registry.ts
Normal 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
30
src/lib/keyboard-utils.ts
Normal 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("+")
|
||||||
|
}
|
||||||
@@ -1,13 +1,21 @@
|
|||||||
import { instances, activeInstanceId, setActiveInstanceId } from "../stores/instances"
|
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(
|
export function setupTabKeyboardShortcuts(
|
||||||
handleNewInstance: () => void,
|
handleNewInstance: () => void,
|
||||||
|
handleCloseInstance: (instanceId: string) => void,
|
||||||
handleNewSession: (instanceId: string) => void,
|
handleNewSession: (instanceId: string) => void,
|
||||||
handleCloseSession: (instanceId: string, sessionId: string) => void,
|
handleCloseSession: (instanceId: string, sessionId: string) => void,
|
||||||
|
handleCommandPalette: () => void,
|
||||||
) {
|
) {
|
||||||
window.addEventListener("keydown", (e) => {
|
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()
|
e.preventDefault()
|
||||||
const index = parseInt(e.key) - 1
|
const index = parseInt(e.key) - 1
|
||||||
const instanceIds = Array.from(instances().keys())
|
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()
|
e.preventDefault()
|
||||||
handleNewInstance()
|
handleNewInstance()
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((e.metaKey || e.ctrlKey) && e.key === "t") {
|
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key.toLowerCase() === "n") {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const instanceId = activeInstanceId()
|
const instanceId = activeInstanceId()
|
||||||
if (instanceId) {
|
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()
|
e.preventDefault()
|
||||||
const instanceId = activeInstanceId()
|
const instanceId = activeInstanceId()
|
||||||
if (!instanceId) return
|
if (!instanceId) return
|
||||||
|
|||||||
44
src/lib/shortcuts/agent.ts
Normal file
44
src/lib/shortcuts/agent.ts
Normal 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",
|
||||||
|
})
|
||||||
|
}
|
||||||
31
src/lib/shortcuts/escape.ts
Normal file
31
src/lib/shortcuts/escape.ts
Normal 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",
|
||||||
|
})
|
||||||
|
}
|
||||||
23
src/lib/shortcuts/input.ts
Normal file
23
src/lib/shortcuts/input.ts
Normal 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",
|
||||||
|
})
|
||||||
|
}
|
||||||
95
src/lib/shortcuts/navigation.ts
Normal file
95
src/lib/shortcuts/navigation.ts
Normal 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",
|
||||||
|
})
|
||||||
|
}
|
||||||
17
src/stores/command-palette.ts
Normal file
17
src/stores/command-palette.ts
Normal 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 }
|
||||||
51
src/stores/message-history.ts
Normal file
51
src/stores/message-history.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user