import { Component, onMount, onCleanup, Show, createMemo, createEffect, createSignal } from "solid-js" import type { Session } from "./types/session" import type { Attachment } from "./types/attachment" import FolderSelectionView from "./components/folder-selection-view" import InstanceWelcomeView from "./components/instance-welcome-view" import CommandPalette from "./components/command-palette" import InstanceTabs from "./components/instance-tabs" import SessionList from "./components/session-list" import MessageStream from "./components/message-stream" import PromptInput from "./components/prompt-input" import InfoView from "./components/info-view" import AgentSelector from "./components/agent-selector" import ModelSelector from "./components/model-selector" import KeyboardHint from "./components/keyboard-hint" import { initMarkdown } from "./lib/markdown" import { useTheme } from "./lib/theme" import { createCommandRegistry } from "./lib/commands" import type { Command } from "./lib/commands" import { hasInstances, isSelectingFolder, setIsSelectingFolder, setHasInstances, showFolderSelection, setShowFolderSelection, } from "./stores/ui" import { toggleShowThinkingBlocks, preferences, addRecentFolder } from "./stores/preferences" import { createInstance, instances, updateInstance, activeInstanceId, setActiveInstanceId, stopInstance, getActiveInstance, addLog, } from "./stores/instances" import { getSessions, activeSessionId, setActiveSession, setActiveParentSession, clearActiveParentSession, createSession, deleteSession, getSessionFamily, activeParentSessionId, getParentSessions, loadMessages, sendMessage, abortSession, updateSessionAgent, updateSessionModel, agents, isSessionBusy, } 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, setEscapeStateChangeHandler } from "./lib/shortcuts/escape" import { keyboardRegistry } from "./lib/keyboard-registry" const SessionView: Component<{ sessionId: string activeSessions: Map instanceId: string instanceFolder: string escapeInDebounce: boolean }> = (props) => { const session = () => props.activeSessions.get(props.sessionId) createEffect(() => { const currentSession = session() if (currentSession) { loadMessages(props.instanceId, currentSession.id).catch(console.error) } }) async function handleSendMessage(prompt: string, attachments: Attachment[]) { await sendMessage(props.instanceId, props.sessionId, prompt, attachments) } async function handleRevert(messageId: string) { const instance = instances().get(props.instanceId) if (!instance || !instance.client) return try { console.log("Reverting to message:", messageId) await instance.client.session.revert({ path: { id: props.sessionId }, body: { messageID: messageId }, }) // Restore the message to input const currentSession = session() if (currentSession) { const revertedMessage = currentSession.messages.find((m) => m.id === messageId) const revertedInfo = currentSession.messagesInfo.get(messageId) if (revertedMessage && revertedInfo?.role === "user") { const textParts = revertedMessage.parts.filter((p: any) => p.type === "text") if (textParts.length > 0) { const textarea = document.querySelector(".prompt-input") as HTMLTextAreaElement if (textarea) { textarea.value = textParts.map((p: any) => p.text).join("\n") textarea.dispatchEvent(new Event("input", { bubbles: true })) textarea.focus() } } } } console.log("Reverted to message - UI will update via SSE") } catch (error) { console.error("Failed to revert:", error) alert("Failed to revert to message") } } return (
Session not found
} > {(s) => (
)}
) } const App: Component = () => { const { isDark } = useTheme() const commandRegistry = createCommandRegistry() const [escapeInDebounce, setEscapeInDebounce] = createSignal(false) createEffect(() => { void initMarkdown(isDark()).catch(console.error) }) const activeInstance = createMemo(() => getActiveInstance()) const activeSessions = createMemo(() => { const instance = activeInstance() if (!instance) return new Map() const instanceId = instance.id const parentId = activeParentSessionId().get(instanceId) if (!parentId) return new Map() const sessionFamily = getSessionFamily(instanceId, parentId) return new Map(sessionFamily.map((s) => [s.id, s])) }) const activeSessionIdForInstance = createMemo(() => { const instance = activeInstance() if (!instance) return null return activeSessionId().get(instance.id) || null }) const activeSessionForInstance = createMemo(() => { const sessionId = activeSessionIdForInstance() if (!sessionId || sessionId === "info") return null return activeSessions().get(sessionId) ?? null }) const handleSidebarAgentChange = async (agent: string) => { const instance = activeInstance() const sessionId = activeSessionIdForInstance() if (!instance || !sessionId || sessionId === "info") return await updateSessionAgent(instance.id, sessionId, agent) } const handleSidebarModelChange = async (model: { providerId: string; modelId: string }) => { const instance = activeInstance() const sessionId = activeSessionIdForInstance() if (!instance || !sessionId || sessionId === "info") return await updateSessionModel(instance.id, sessionId, model) } const DEFAULT_SESSION_SIDEBAR_WIDTH = 280 const [sessionSidebarWidth, setSessionSidebarWidth] = createSignal(DEFAULT_SESSION_SIDEBAR_WIDTH) async function handleSelectFolder(folderPath?: string, binaryPath?: string) { setIsSelectingFolder(true) try { let folder: string | null | undefined = folderPath if (!folder) { folder = await window.electronAPI.selectFolder() if (!folder) { return } } addRecentFolder(folder) const instanceId = await createInstance(folder, binaryPath) setHasInstances(true) setShowFolderSelection(false) console.log("Created instance:", instanceId, "Port:", instances().get(instanceId)?.port) } catch (error) { console.error("Failed to create instance:", error) } finally { setIsSelectingFolder(false) } } function handleNewInstanceRequest() { if (hasInstances()) { setShowFolderSelection(true) } else { handleSelectFolder() } } async function handleCloseInstance(instanceId: string) { if (confirm("Stop OpenCode instance? This will stop the server.")) { await stopInstance(instanceId) if (instances().size === 0) { setHasInstances(false) } } } async function handleNewSession(instanceId: string) { try { const session = await createSession(instanceId) setActiveParentSession(instanceId, session.id) } catch (error) { console.error("Failed to create session:", error) } } async function handleCloseSession(instanceId: string, sessionId: string) { const sessions = getSessions(instanceId) const session = sessions.find((s) => s.id === sessionId) const isParent = session?.parentId === null if (!isParent) { return } clearActiveParentSession(instanceId) } function setupCommands() { commandRegistry.register({ id: "new-instance", label: "New Instance", description: "Open folder picker to create new instance", category: "Instance", keywords: ["folder", "project", "workspace"], shortcut: { key: "N", meta: true }, action: handleNewInstanceRequest, }) commandRegistry.register({ id: "close-instance", label: "Close Instance", description: "Stop current instance's server", category: "Instance", keywords: ["stop", "quit", "close"], shortcut: { key: "W", meta: true }, action: async () => { const instance = activeInstance() if (!instance) return await handleCloseInstance(instance.id) }, }) commandRegistry.register({ id: "instance-next", label: "Next Instance", description: "Cycle to next instance tab", category: "Instance", keywords: ["switch", "navigate"], shortcut: { key: "]", meta: true }, action: () => { 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]) }, }) commandRegistry.register({ id: "instance-prev", label: "Previous Instance", description: "Cycle to previous instance tab", category: "Instance", keywords: ["switch", "navigate"], shortcut: { key: "[", meta: true }, action: () => { 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]) }, }) commandRegistry.register({ id: "new-session", label: "New Session", description: "Create a new parent session", category: "Session", keywords: ["create", "start"], shortcut: { key: "N", meta: true, shift: true }, action: async () => { const instance = activeInstance() if (!instance) return await handleNewSession(instance.id) }, }) commandRegistry.register({ id: "close-session", label: "Close Session", description: "Close current parent session", category: "Session", keywords: ["close", "stop"], shortcut: { key: "W", meta: true, shift: true }, action: async () => { const instance = activeInstance() const sessionId = activeSessionIdForInstance() if (!instance || !sessionId || sessionId === "info") return await handleCloseSession(instance.id, sessionId) }, }) commandRegistry.register({ id: "switch-to-info", label: "Switch to Info", description: "Jump to info view for current instance", category: "Session", keywords: ["info", "info", "console", "output"], shortcut: { key: "L", meta: true, shift: true }, action: () => { const instance = activeInstance() if (instance) setActiveSession(instance.id, "info") }, }) commandRegistry.register({ id: "session-next", label: "Next Session", description: "Cycle to next session tab", category: "Session", keywords: ["switch", "navigate"], shortcut: { key: "]", meta: true, shift: true }, action: () => { 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(["info"]) 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]) }, }) commandRegistry.register({ id: "session-prev", label: "Previous Session", description: "Cycle to previous session tab", category: "Session", keywords: ["switch", "navigate"], shortcut: { key: "[", meta: true, shift: true }, action: () => { 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(["info"]) 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]) }, }) commandRegistry.register({ id: "compact", label: "Compact Session", description: "Summarize and compact the current session", category: "Session", keywords: ["/compact", "summarize", "compress"], action: async () => { const instance = activeInstance() const sessionId = activeSessionIdForInstance() if (!instance || !instance.client || !sessionId || sessionId === "info") return const sessions = getSessions(instance.id) const session = sessions.find((s) => s.id === sessionId) if (!session) return try { console.log("Compacting session...") await instance.client.session.summarize({ path: { id: sessionId }, body: { providerID: session.model.providerId, modelID: session.model.modelId, }, }) } catch (error: any) { console.error("Failed to compact session:", error) const message = error?.message || "Failed to compact session" alert(`Compact failed: ${message}`) } }, }) commandRegistry.register({ id: "undo", label: "Undo Last Message", description: "Revert the last message", category: "Session", keywords: ["/undo", "revert", "undo"], action: async () => { const instance = activeInstance() const sessionId = activeSessionIdForInstance() if (!instance || !instance.client || !sessionId || sessionId === "info") return const sessions = getSessions(instance.id) const session = sessions.find((s) => s.id === sessionId) if (!session) return // Find the message to revert to (previous user message before revert point) let after = 0 const revert = session.revert if (revert?.messageID) { // Find the timestamp of the revert point for (let i = session.messages.length - 1; i >= 0; i--) { const msg = session.messages[i] const info = session.messagesInfo.get(msg.id) if (info?.id === revert.messageID) { after = info.time?.created || 0 break } } } // Find the previous user message let messageID = "" for (let i = session.messages.length - 1; i >= 0; i--) { const msg = session.messages[i] const info = session.messagesInfo.get(msg.id) if (msg.type === "user" && info?.time?.created) { if (after > 0 && info.time.created >= after) { continue } messageID = msg.id break } } if (!messageID) { alert("Nothing to undo") return } try { // Find the reverted message to restore to input const revertedMessage = session.messages.find((m) => m.id === messageID) const revertedInfo = session.messagesInfo.get(messageID) console.log("Reverting to message:", messageID) await instance.client.session.revert({ path: { id: sessionId }, body: { messageID }, }) console.log("Revert API call completed") // Restore the reverted user message to the prompt input if (revertedMessage && revertedInfo?.role === "user") { const textParts = revertedMessage.parts.filter((p: any) => p.type === "text") if (textParts.length > 0) { const textarea = document.querySelector(".prompt-input") as HTMLTextAreaElement if (textarea) { textarea.value = textParts.map((p: any) => p.text).join("\n") textarea.dispatchEvent(new Event("input", { bubbles: true })) textarea.focus() } } } console.log("Last message reverted - UI will update via SSE") } catch (error) { console.error("Failed to revert message:", error) alert("Failed to revert message") } }, }) commandRegistry.register({ id: "open-model-selector", label: "Open Model Selector", description: "Choose a different model", category: "Agent & Model", keywords: ["model", "llm", "ai"], shortcut: { key: "M", meta: true, shift: true }, action: () => { const modelInput = document.querySelector("[data-model-selector]") as HTMLInputElement if (modelInput) { modelInput.focus() setTimeout(() => { const event = new KeyboardEvent("keydown", { key: "ArrowDown", code: "ArrowDown", keyCode: 40, which: 40, bubbles: true, cancelable: true, }) modelInput.dispatchEvent(event) }, 10) } }, }) commandRegistry.register({ id: "open-agent-selector", label: "Open Agent Selector", description: "Choose a different agent", category: "Agent & Model", keywords: ["agent", "mode"], shortcut: { key: "A", meta: true, shift: true }, action: () => { const agentTrigger = document.querySelector("[data-agent-selector]") as HTMLElement if (agentTrigger) { agentTrigger.focus() setTimeout(() => { const event = new KeyboardEvent("keydown", { key: "Enter", code: "Enter", keyCode: 13, which: 13, bubbles: true, cancelable: true, }) agentTrigger.dispatchEvent(event) }, 50) } }, }) commandRegistry.register({ id: "init", label: "Initialize AGENTS.md", description: "Create or update AGENTS.md file", category: "Agent & Model", keywords: ["/init", "agents", "initialize"], action: async () => { const instance = activeInstance() const sessionId = activeSessionIdForInstance() if (!instance || !instance.client || !sessionId || sessionId === "info") return const sessions = getSessions(instance.id) const session = sessions.find((s) => s.id === sessionId) if (!session) return try { // Generate ID similar to server format: timestamp in hex + random chars const timestamp = Date.now() const timePart = (timestamp * 0x1000).toString(16).padStart(12, "0") const randomPart = Math.random().toString(16).substring(2, 16) const messageID = `msg_${timePart}${randomPart}` await instance.client.session.init({ path: { id: sessionId }, body: { messageID, providerID: session.model.providerId, modelID: session.model.modelId, }, }) console.log("Initializing AGENTS.md...") } catch (error) { console.error("Failed to initialize AGENTS.md:", error) } }, }) commandRegistry.register({ id: "clear-input", label: "Clear Input", description: "Clear the prompt textarea", category: "Input & Focus", keywords: ["clear", "reset"], shortcut: { key: "K", meta: true }, action: () => { const textarea = document.querySelector(".prompt-input") as HTMLTextAreaElement if (textarea) textarea.value = "" }, }) commandRegistry.register({ id: "thinking", label: () => `${preferences().showThinkingBlocks ? "Hide" : "Show"} Thinking Blocks`, description: "Show/hide AI thinking process", category: "System", keywords: ["/thinking", "toggle", "show", "hide"], action: toggleShowThinkingBlocks, }) commandRegistry.register({ id: "help", label: "Show Help", description: "Display keyboard shortcuts and help", category: "System", keywords: ["/help", "shortcuts", "help"], action: () => { console.log("Show help modal (not implemented)") }, }) } function handleExecuteCommand(commandId: string) { commandRegistry.execute(commandId) } onMount(() => { setEscapeStateChangeHandler(setEscapeInDebounce) setupCommands() setupTabKeyboardShortcuts( handleNewInstanceRequest, 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( () => { const modelInput = document.querySelector("[data-model-selector]") as HTMLInputElement if (modelInput) { modelInput.focus() setTimeout(() => { const event = new KeyboardEvent("keydown", { key: "ArrowDown", code: "ArrowDown", keyCode: 40, which: 40, bubbles: true, cancelable: true, }) modelInput.dispatchEvent(event) }, 10) } }, () => { const agentTrigger = document.querySelector("[data-agent-selector]") as HTMLElement if (agentTrigger) { agentTrigger.focus() setTimeout(() => { const event = new KeyboardEvent("keydown", { key: "Enter", code: "Enter", keyCode: 13, which: 13, bubbles: true, cancelable: true, }) agentTrigger.dispatchEvent(event) }, 50) } }, ) registerEscapeShortcut( () => { if (showFolderSelection()) return true const instance = activeInstance() if (!instance) return false const sessionId = activeSessionIdForInstance() if (!sessionId || sessionId === "info") return false const sessions = getSessions(instance.id) const session = sessions.find((s) => s.id === sessionId) if (!session) return false return isSessionBusy(instance.id, sessionId) }, async () => { if (showFolderSelection()) { setShowFolderSelection(false) return } const instance = activeInstance() const sessionId = activeSessionIdForInstance() if (!instance || !sessionId || sessionId === "info") return try { await abortSession(instance.id, sessionId) console.log("Session aborted successfully") } catch (error) { console.error("Failed to abort session:", error) } }, () => { const active = document.activeElement as HTMLElement active?.blur() }, hideCommandPalette, ) const handleKeyDown = (e: KeyboardEvent) => { const target = e.target as HTMLElement const isInCombobox = target.closest('[role="combobox"]') !== null const isInListbox = target.closest('[role="listbox"]') !== null const isInAgentSelect = target.closest('[role="button"][data-agent-selector]') !== null if (isInCombobox || isInListbox || isInAgentSelect) { return } const shortcut = keyboardRegistry.findMatch(e) if (shortcut) { e.preventDefault() shortcut.handler() } } window.addEventListener("keydown", handleKeyDown) onCleanup(() => { window.removeEventListener("keydown", handleKeyDown) }) window.electronAPI.onNewInstance(() => { handleNewInstanceRequest() }) window.electronAPI.onInstanceStarted(({ id, port, pid, binaryPath }) => { console.log("Instance started:", { id, port, pid, binaryPath }) updateInstance(id, { port, pid, status: "ready", binaryPath }) }) window.electronAPI.onInstanceError(({ id, error }) => { console.error("Instance error:", { id, error }) updateInstance(id, { status: "error", error }) }) window.electronAPI.onInstanceStopped(({ id }) => { console.log("Instance stopped:", id) updateInstance(id, { status: "stopped" }) }) window.electronAPI.onInstanceLog(({ id, entry }) => { addLog(id, entry) }) }) return (
{(instance) => ( <> 0} fallback={}>
{/* Session Sidebar */}
setActiveSession(instance().id, id)} onClose={(id) => handleCloseSession(instance().id, id)} onNew={() => handleNewSession(instance().id)} showHeader showFooter={false} headerContent={
Sessions
{(() => { const shortcut = keyboardRegistry.get("session-prev") return shortcut ? : null })()} {(() => { const shortcut = keyboardRegistry.get("session-next") return shortcut ? : null })()}
} onWidthChange={setSessionSidebarWidth} />
{(activeSession) => (
)}
{/* Main Content Area */}

No session selected

Select a session to view messages

} > {(sessionId) => ( )} } >
)}
} >
) } export default App