diff --git a/src/App.tsx b/src/App.tsx index 4243d8db..ae5cf3f7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,26 +1,14 @@ -import { Component, onMount, onCleanup, Show, createMemo, createEffect, createSignal } from "solid-js" +import { Component, Show, createMemo, createEffect, createSignal } from "solid-js" import { Dialog } from "@kobalte/core/dialog" import { Toaster } from "solid-toast" -import type { Session } from "./types/session" -import type { Attachment } from "./types/attachment" -import type { SDKPart, ClientPart } from "./types/message" -import type { Permission, Command as SDKCommand } from "@opencode-ai/sdk" 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 InstanceDisconnectedModal from "./components/instance-disconnected-modal" +import InstanceShell from "./components/instance/instance-shell" import { initMarkdown } from "./lib/markdown" import { useTheme } from "./lib/theme" -import { createCommandRegistry } from "./lib/commands" -import type { Command } from "./lib/commands" +import { useCommands } from "./lib/hooks/use-commands" +import { useAppLifecycle } from "./lib/hooks/use-app-lifecycle" import { hasInstances, isSelectingFolder, @@ -33,332 +21,42 @@ import { useConfig } from "./stores/preferences" import { createInstance, instances, - updateInstance, activeInstanceId, setActiveInstanceId, stopInstance, getActiveInstance, - addLog, - getActivePermission, - sendPermissionResponse, disconnectedInstance, acknowledgeDisconnectedInstance, } from "./stores/instances" import { getSessions, activeSessionId, - setActiveSession, setActiveParentSession, clearActiveParentSession, createSession, - forkSession, - deleteSession, - getSessionFamily, - activeParentSessionId, - getParentSessions, - loadMessages, - sendMessage, - abortSession, + fetchSessions, updateSessionAgent, updateSessionModel, - agents, - getSessionInfo, - isSessionMessagesLoading, - fetchSessions, - executeCustomCommand, } from "./stores/sessions" -import { isSessionBusy } from "./stores/session-status" -import { setupTabKeyboardShortcuts } from "./lib/keyboard" -import { isOpen as isCommandPaletteOpen, showCommandPalette, hideCommandPalette } from "./stores/command-palette" -import { getCommands as getInstanceCommands } from "./stores/commands" -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" -import type { KeyboardShortcut } from "./lib/keyboard-registry" -import { setSessionCompactionState } from "./stores/session-compaction" - -const SessionView: Component<{ - sessionId: string - activeSessions: Map - instanceId: string - instanceFolder: string - escapeInDebounce: boolean -}> = (props) => { - const session = () => props.activeSessions.get(props.sessionId) - const messagesLoading = createMemo(() => isSessionMessagesLoading(props.instanceId, 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) - } - - function getUserMessageText(messageId: string): string | null { - const currentSession = session() - if (!currentSession) return null - - const targetMessage = currentSession.messages.find((m) => m.id === messageId) - const targetInfo = currentSession.messagesInfo.get(messageId) - if (!targetMessage || targetInfo?.role !== "user") { - return null - } - - const textParts = targetMessage.parts.filter((p): p is ClientPart & { type: "text"; text: string } => p.type === "text") - if (textParts.length === 0) { - return null - } - - return textParts.map((p) => p.text).join("\n") - } - - 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 }, - }) - - const restoredText = getUserMessageText(messageId) - if (restoredText) { - const textarea = document.querySelector(".prompt-input") as HTMLTextAreaElement - if (textarea) { - textarea.value = restoredText - 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") - } - } - - async function handleFork(messageId?: string) { - if (!messageId) { - console.warn("Fork requires a user message id") - return - } - - const restoredText = getUserMessageText(messageId) - - try { - const forkedSession = await forkSession(props.instanceId, props.sessionId, { messageId }) - - const parentToActivate = forkedSession.parentId ?? forkedSession.id - setActiveParentSession(props.instanceId, parentToActivate) - if (forkedSession.parentId) { - setActiveSession(props.instanceId, forkedSession.id) - } - - await loadMessages(props.instanceId, forkedSession.id).catch(console.error) - - if (restoredText) { - const textarea = document.querySelector(".prompt-input") as HTMLTextAreaElement - if (textarea) { - textarea.value = restoredText - textarea.dispatchEvent(new Event("input", { bubbles: true })) - textarea.focus() - } - } - } catch (error) { - console.error("Failed to fork session:", error) - alert("Failed to fork session") - } - } - - - const activePermission = createMemo(() => getActivePermission(props.instanceId)) - - async function handlePermissionResponse(response: "once" | "always" | "reject") { - const permission = activePermission() - if (!permission) return - - try { - await sendPermissionResponse(props.instanceId, props.sessionId, permission.id, response) - } catch (error) { - console.error("Failed to send permission response:", error) - } - } - - // Handle permission keyboard shortcuts - createEffect(() => { - const permission = activePermission() - if (!permission) return - - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === "Enter") { - event.preventDefault() - handlePermissionResponse("once") - } else if (event.key === "a" || event.key === "A") { - event.preventDefault() - handlePermissionResponse("always") - } else if (event.key === "d" || event.key === "D") { - event.preventDefault() - handlePermissionResponse("reject") - } - } - - document.addEventListener("keydown", handleKeyDown) - onCleanup(() => document.removeEventListener("keydown", handleKeyDown)) - }) - - return ( - -
Session not found
- - } - > - {(s) => ( -
- - - - {(permission) => ( -
-
-
-
- - - -
-
-
-
- Permission Required - {permission().type} -
-
- {permission().title} -
-
- Enter - Accept once - a - Accept always - d - Deny -
-
-
-
- )} -
- - -
- )} -
- ) -} - -const formatTokenTotal = (value: number) => { - if (value >= 1_000_000) { - return `${(value / 1_000_000).toFixed(1)}M` - } - if (value >= 1_000) { - return `${(value / 1_000).toFixed(0)}K` - } - return value.toLocaleString() -} - -const ContextUsagePanel: Component<{ instanceId: string; sessionId: string }> = (props) => { - const info = createMemo( - () => - getSessionInfo(props.instanceId, props.sessionId) ?? { - tokens: 0, - cost: 0, - contextWindow: 0, - isSubscriptionModel: false, - contextUsageTokens: 0, - contextUsagePercent: null, - }, - ) - - const tokens = createMemo(() => info().tokens) - const contextUsageTokens = createMemo(() => info().contextUsageTokens ?? 0) - const contextWindow = createMemo(() => info().contextWindow) - const contextUsagePercent = createMemo(() => info().contextUsagePercent) - - const costLabel = createMemo(() => { - if (info().isSubscriptionModel || info().cost <= 0) return "Included in plan" - return `$${info().cost.toFixed(2)} spent` - }) - - return ( -
-
-
-
Tokens (last call)
-
{formatTokenTotal(tokens())}
-
-
{costLabel()}
-
-
-
-
Context window usage
-
{contextUsagePercent() !== null ? `${contextUsagePercent()}%` : "--"}
-
-
- {contextWindow() - ? `${formatTokenTotal(contextUsageTokens())} of ${formatTokenTotal(contextWindow())}` - : "Window size unavailable"} -
-
-
-
-
-
- ) -} const App: Component = () => { const { isDark } = useTheme() const { preferences, addRecentFolder, toggleShowThinkingBlocks, setDiffViewMode } = useConfig() - const commandRegistry = createCommandRegistry() const [escapeInDebounce, setEscapeInDebounce] = createSignal(false) - const [paletteCommands, setPaletteCommands] = createSignal([]) const [launchErrorBinary, setLaunchErrorBinary] = createSignal(null) const [isAdvancedSettingsOpen, setIsAdvancedSettingsOpen] = createSignal(false) + createEffect(() => { + void initMarkdown(isDark()).catch(console.error) + }) + + const activeInstance = createMemo(() => getActiveInstance()) + const activeSessionIdForInstance = createMemo(() => { + const instance = activeInstance() + if (!instance) return null + return activeSessionId().get(instance.id) || null + }) + const launchErrorPath = () => { const value = launchErrorBinary() if (!value) return "opencode" @@ -380,57 +78,6 @@ const App: Component = () => { const clearLaunchError = () => setLaunchErrorBinary(null) - const refreshCommandPalette = () => { - setPaletteCommands(commandRegistry.getAll()) - } - - 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) const selectedBinary = binaryPath || preferences().lastUsedBinary || "opencode" @@ -444,10 +91,6 @@ const App: Component = () => { } } - if (!folder) { - return - } - addRecentFolder(folder) clearLaunchError() const instanceId = await createInstance(folder, selectedBinary) @@ -480,7 +123,7 @@ const App: Component = () => { if (hasInstances()) { setShowFolderSelection(true) } else { - handleSelectFolder() + void handleSelectFolder() } } @@ -534,539 +177,42 @@ const App: Component = () => { } } - 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: "Instance Info", - description: "Open the instance overview for logs and status", - category: "Instance", - keywords: ["info", "logs", "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 { - setSessionCompactionState(instance.id, sessionId, true) - console.log("Compacting session...") - await instance.client.session.summarize({ - path: { id: sessionId }, - body: { - providerID: session.model.providerId, - modelID: session.model.modelId, - }, - }) - } catch (error: unknown) { - setSessionCompactionState(instance.id, sessionId, false) - console.error("Failed to compact session:", error) - const message = error instanceof Error ? 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): p is ClientPart & { type: "text"; text: string } => p.type === "text") - if (textParts.length > 0) { - const textarea = document.querySelector(".prompt-input") as HTMLTextAreaElement - if (textarea) { - textarea.value = textParts.map((p) => 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: "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: "diff-view-split", - label: () => `${(preferences().diffViewMode || "split") === "split" ? "✓ " : ""}Use Split Diff View`, - description: "Display tool-call diffs side-by-side", - category: "System", - keywords: ["diff", "split", "view"], - action: () => setDiffViewMode("split"), - }) - - commandRegistry.register({ - id: "diff-view-unified", - label: () => `${(preferences().diffViewMode || "split") === "unified" ? "✓ " : ""}Use Unified Diff View`, - description: "Display tool-call diffs inline", - category: "System", - keywords: ["diff", "unified", "view"], - action: () => setDiffViewMode("unified"), - }) - - 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)") - }, - }) - - refreshCommandPalette() + const handleSidebarAgentChange = async (instanceId: string, sessionId: string, agent: string) => { + if (!instanceId || !sessionId || sessionId === "info") return + await updateSessionAgent(instanceId, sessionId, agent) } - function handleExecuteCommand(command: Command) { - try { - const result = command.action?.() - if (result instanceof Promise) { - void result.catch((error) => { - console.error("Command execution failed:", error) - }) - } - } catch (error) { - console.error("Command execution failed:", error) - } + const handleSidebarModelChange = async ( + instanceId: string, + sessionId: string, + model: { providerId: string; modelId: string }, + ) => { + if (!instanceId || !sessionId || sessionId === "info") return + await updateSessionModel(instanceId, sessionId, model) } + const { commands: paletteCommands, executeCommand } = useCommands({ + preferences, + toggleShowThinkingBlocks, + setDiffViewMode, + handleNewInstanceRequest, + handleCloseInstance, + handleNewSession, + handleCloseSession, + getActiveInstance: activeInstance, + getActiveSessionIdForInstance: activeSessionIdForInstance, + }) - - onMount(() => { - setEscapeStateChangeHandler(setEscapeInDebounce) - - setupCommands() - - setupTabKeyboardShortcuts( - handleNewInstanceRequest, - handleCloseInstance, - handleNewSession, - handleCloseSession, - () => { - const instance = activeInstance() - if (instance) { - showCommandPalette(instance.id) - } - }, - ) - - 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) - }) + useAppLifecycle({ + setEscapeInDebounce, + handleNewInstanceRequest, + handleCloseInstance, + handleNewSession, + handleCloseSession, + showFolderSelection, + setShowFolderSelection, + getActiveInstance: activeInstance, + getActiveSessionIdForInstance: activeSessionIdForInstance, }) return ( @@ -1097,11 +243,7 @@ const App: Component = () => {
- - setIsAdvancedSettingsOpen(true)} - onAdvancedSettingsClose={() => setIsAdvancedSettingsOpen(false)} - /> + + + + + setIsAdvancedSettingsOpen(true)} + onAdvancedSettingsClose={() => setIsAdvancedSettingsOpen(false)} + /> +
- - + - - + + ) } -function commandRequiresArguments(template?: string) { - if (!template) return false - return /\$(?:\d+|ARGUMENTS)/.test(template) -} - -function promptForCommandArguments(command: SDKCommand) { - if (!commandRequiresArguments(command.template)) { - return "" - } - const input = window.prompt(`Arguments for /${command.name}`, "") - if (input === null) { - return null - } - return input -} - -function formatCommandLabel(name: string) { - if (!name) return "" - return name.charAt(0).toUpperCase() + name.slice(1) -} - -function buildCustomCommandEntries(instanceId: string, commands: SDKCommand[]): Command[] { - return commands.map((cmd) => ({ - id: `custom:${instanceId}:${cmd.name}`, - label: formatCommandLabel(cmd.name), - description: cmd.description ?? "Custom command", - category: "Custom Commands", - keywords: [cmd.name, ...(cmd.description ? cmd.description.split(/\s+/).filter(Boolean) : [])], - action: async () => { - const sessionId = activeSessionId().get(instanceId) - if (!sessionId || sessionId === "info") { - alert("Select a session before running a custom command.") - return - } - const args = promptForCommandArguments(cmd) - if (args === null) { - return - } - try { - await executeCustomCommand(instanceId, sessionId, cmd.name, args) - } catch (error) { - console.error("Failed to run custom command:", error) - alert("Failed to run custom command. Check the console for details.") - } - }, - })) -} - export default App diff --git a/src/components/instance/instance-shell.tsx b/src/components/instance/instance-shell.tsx new file mode 100644 index 00000000..08996641 --- /dev/null +++ b/src/components/instance/instance-shell.tsx @@ -0,0 +1,173 @@ +import { Show, createMemo, createSignal, type Component } from "solid-js" +import type { Accessor } from "solid-js" +import type { Instance } from "../../types/instance" +import type { Command } from "../../lib/commands" +import { activeParentSessionId, activeSessionId as activeSessionMap, getSessionFamily, setActiveSession } from "../../stores/sessions" +import { keyboardRegistry, type KeyboardShortcut } from "../../lib/keyboard-registry" +import { buildCustomCommandEntries } from "../../lib/command-utils" +import { getCommands as getInstanceCommands } from "../../stores/commands" +import { isOpen as isCommandPaletteOpen, hideCommandPalette } from "../../stores/command-palette" +import SessionList from "../session-list" +import KeyboardHint from "../keyboard-hint" +import InstanceWelcomeView from "../instance-welcome-view" +import InfoView from "../info-view" +import AgentSelector from "../agent-selector" +import ModelSelector from "../model-selector" +import CommandPalette from "../command-palette" +import ContextUsagePanel from "../session/context-usage-panel" +import SessionView from "../session/session-view" + +interface InstanceShellProps { + instance: Instance + escapeInDebounce: boolean + paletteCommands: Accessor + onCloseSession: (sessionId: string) => Promise | void + onNewSession: () => Promise | void + handleSidebarAgentChange: (sessionId: string, agent: string) => Promise + handleSidebarModelChange: (sessionId: string, model: { providerId: string; modelId: string }) => Promise + onExecuteCommand: (command: Command) => void +} + +const DEFAULT_SESSION_SIDEBAR_WIDTH = 280 + +const InstanceShell: Component = (props) => { + const [sessionSidebarWidth, setSessionSidebarWidth] = createSignal(DEFAULT_SESSION_SIDEBAR_WIDTH) + + const activeSessions = createMemo(() => { + const parentId = activeParentSessionId().get(props.instance.id) + if (!parentId) return new Map[number]>() + const sessionFamily = getSessionFamily(props.instance.id, parentId) + return new Map(sessionFamily.map((s) => [s.id, s])) + }) + + const activeSessionIdForInstance = createMemo(() => { + return activeSessionMap().get(props.instance.id) || null + }) + + const activeSessionForInstance = createMemo(() => { + const sessionId = activeSessionIdForInstance() + if (!sessionId || sessionId === "info") return null + return activeSessions().get(sessionId) ?? null + }) + + const customCommands = createMemo(() => buildCustomCommandEntries(props.instance.id, getInstanceCommands(props.instance.id))) + const instancePaletteCommands = createMemo(() => [...props.paletteCommands(), ...customCommands()]) + const paletteOpen = createMemo(() => isCommandPaletteOpen(props.instance.id)) + + const keyboardShortcuts = createMemo(() => + [keyboardRegistry.get("session-prev"), keyboardRegistry.get("session-next")].filter( + (shortcut): shortcut is KeyboardShortcut => Boolean(shortcut), + ), + ) + + const handleSessionSelect = (sessionId: string) => { + setActiveSession(props.instance.id, sessionId) + } + + return ( + <> + 0} fallback={}> +
+
+ { + const result = props.onCloseSession(id) + if (result instanceof Promise) { + void result.catch((error) => console.error("Failed to close session:", error)) + } + }} + onNew={() => { + const result = props.onNewSession() + if (result instanceof Promise) { + void result.catch((error) => console.error("Failed to create session:", error)) + } + }} + showHeader + showFooter={false} + headerContent={ +
+ Sessions +
+ {keyboardShortcuts().length ? ( + + ) : null} +
+
+ } + onWidthChange={setSessionSidebarWidth} + /> + +
+ + {(activeSession) => ( + <> + +
+ props.handleSidebarAgentChange(activeSession().id, agent)} + /> + + props.handleSidebarModelChange(activeSession().id, model)} + /> +
+ + )} +
+
+ +
+ +
+

No session selected

+

Select a session to view messages

+
+
+ } + > + {(sessionId) => ( + + )} + + } + > + + +
+
+
+ + hideCommandPalette(props.instance.id)} + commands={instancePaletteCommands()} + onExecute={props.onExecuteCommand} + /> + + ) +} + +export default InstanceShell diff --git a/src/components/session/context-usage-panel.tsx b/src/components/session/context-usage-panel.tsx new file mode 100644 index 00000000..c1bff05f --- /dev/null +++ b/src/components/session/context-usage-panel.tsx @@ -0,0 +1,63 @@ +import { createMemo, type Component } from "solid-js" +import { getSessionInfo } from "../../stores/sessions" +import { formatTokenTotal } from "../../lib/formatters" + +interface ContextUsagePanelProps { + instanceId: string + sessionId: string +} + +const ContextUsagePanel: Component = (props) => { + const info = createMemo( + () => + getSessionInfo(props.instanceId, props.sessionId) ?? { + tokens: 0, + cost: 0, + contextWindow: 0, + isSubscriptionModel: false, + contextUsageTokens: 0, + contextUsagePercent: null, + }, + ) + + const tokens = createMemo(() => info().tokens) + const contextUsageTokens = createMemo(() => info().contextUsageTokens ?? 0) + const contextWindow = createMemo(() => info().contextWindow) + const contextUsagePercent = createMemo(() => info().contextUsagePercent) + + const costLabel = createMemo(() => { + if (info().isSubscriptionModel || info().cost <= 0) return "Included in plan" + return `$${info().cost.toFixed(2)} spent` + }) + + return ( +
+
+
+
Tokens (last call)
+
{formatTokenTotal(tokens())}
+
+
{costLabel()}
+
+
+
+
Context window usage
+
{contextUsagePercent() !== null ? `${contextUsagePercent()}%` : "--"}
+
+
+ {contextWindow() + ? `${formatTokenTotal(contextUsageTokens())} of ${formatTokenTotal(contextWindow())}` + : "Window size unavailable"} +
+
+
+
+
+
+ ) +} + +export default ContextUsagePanel diff --git a/src/components/session/session-view.tsx b/src/components/session/session-view.tsx new file mode 100644 index 00000000..6edfec1a --- /dev/null +++ b/src/components/session/session-view.tsx @@ -0,0 +1,211 @@ +import { Show, createMemo, createEffect, onCleanup, type Component } from "solid-js" +import type { Session } from "../../types/session" +import type { Attachment } from "../../types/attachment" +import type { ClientPart } from "../../types/message" +import MessageStream from "../message-stream" +import PromptInput from "../prompt-input" +import { instances, getActivePermission, sendPermissionResponse } from "../../stores/instances" +import { loadMessages, sendMessage, forkSession, isSessionMessagesLoading, setActiveParentSession, setActiveSession } from "../../stores/sessions" + +interface SessionViewProps { + sessionId: string + activeSessions: Map + instanceId: string + instanceFolder: string + escapeInDebounce: boolean +} + +export const SessionView: Component = (props) => { + const session = () => props.activeSessions.get(props.sessionId) + const messagesLoading = createMemo(() => isSessionMessagesLoading(props.instanceId, 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) + } + + function getUserMessageText(messageId: string): string | null { + const currentSession = session() + if (!currentSession) return null + + const targetMessage = currentSession.messages.find((m) => m.id === messageId) + const targetInfo = currentSession.messagesInfo.get(messageId) + if (!targetMessage || targetInfo?.role !== "user") { + return null + } + + const textParts = targetMessage.parts.filter((p): p is ClientPart & { type: "text"; text: string } => p.type === "text") + if (textParts.length === 0) { + return null + } + + return textParts.map((p) => p.text).join("\n") + } + + async function handleRevert(messageId: string) { + const instance = instances().get(props.instanceId) + if (!instance || !instance.client) return + + try { + await instance.client.session.revert({ + path: { id: props.sessionId }, + body: { messageID: messageId }, + }) + + const restoredText = getUserMessageText(messageId) + if (restoredText) { + const textarea = document.querySelector(".prompt-input") as HTMLTextAreaElement + if (textarea) { + textarea.value = restoredText + textarea.dispatchEvent(new Event("input", { bubbles: true })) + textarea.focus() + } + } + } catch (error) { + console.error("Failed to revert:", error) + alert("Failed to revert to message") + } + } + + async function handleFork(messageId?: string) { + if (!messageId) { + console.warn("Fork requires a user message id") + return + } + + const restoredText = getUserMessageText(messageId) + + try { + const forkedSession = await forkSession(props.instanceId, props.sessionId, { messageId }) + + const parentToActivate = forkedSession.parentId ?? forkedSession.id + setActiveParentSession(props.instanceId, parentToActivate) + if (forkedSession.parentId) { + setActiveSession(props.instanceId, forkedSession.id) + } + + await loadMessages(props.instanceId, forkedSession.id).catch(console.error) + + if (restoredText) { + const textarea = document.querySelector(".prompt-input") as HTMLTextAreaElement + if (textarea) { + textarea.value = restoredText + textarea.dispatchEvent(new Event("input", { bubbles: true })) + textarea.focus() + } + } + } catch (error) { + console.error("Failed to fork session:", error) + alert("Failed to fork session") + } + } + + const activePermission = createMemo(() => getActivePermission(props.instanceId)) + + async function handlePermissionResponse(response: "once" | "always" | "reject") { + const permission = activePermission() + if (!permission) return + + try { + await sendPermissionResponse(props.instanceId, props.sessionId, permission.id, response) + } catch (error) { + console.error("Failed to send permission response:", error) + } + } + + createEffect(() => { + const permission = activePermission() + if (!permission) return + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Enter") { + event.preventDefault() + handlePermissionResponse("once") + } else if (event.key === "a" || event.key === "A") { + event.preventDefault() + handlePermissionResponse("always") + } else if (event.key === "d" || event.key === "D") { + event.preventDefault() + handlePermissionResponse("reject") + } + } + + document.addEventListener("keydown", handleKeyDown) + onCleanup(() => document.removeEventListener("keydown", handleKeyDown)) + }) + + return ( + +
Session not found
+
+ } + > + {(s) => ( +
+ + + + {(permission) => ( +
+
+
+
+ + + +
+
+
+
+ Permission Required + {permission().type} +
+
+ {permission().title} +
+
+ Enter + Accept once + a + Accept always + d + Deny +
+
+
+
+ )} +
+ + +
+ )} + + ) +} + +export default SessionView diff --git a/src/lib/command-utils.ts b/src/lib/command-utils.ts new file mode 100644 index 00000000..b964e3bc --- /dev/null +++ b/src/lib/command-utils.ts @@ -0,0 +1,51 @@ +import type { Command } from "./commands" +import type { Command as SDKCommand } from "@opencode-ai/sdk" +import { activeSessionId, executeCustomCommand } from "../stores/sessions" + +export function commandRequiresArguments(template?: string): boolean { + if (!template) return false + return /\$(?:\d+|ARGUMENTS)/.test(template) +} + +export function promptForCommandArguments(command: SDKCommand): string | null { + if (!commandRequiresArguments(command.template)) { + return "" + } + const input = window.prompt(`Arguments for /${command.name}`, "") + if (input === null) { + return null + } + return input +} + +function formatCommandLabel(name: string): string { + if (!name) return "" + return name.charAt(0).toUpperCase() + name.slice(1) +} + +export function buildCustomCommandEntries(instanceId: string, commands: SDKCommand[]): Command[] { + return commands.map((cmd) => ({ + id: `custom:${instanceId}:${cmd.name}`, + label: formatCommandLabel(cmd.name), + description: cmd.description ?? "Custom command", + category: "Custom Commands", + keywords: [cmd.name, ...(cmd.description ? cmd.description.split(/\s+/).filter(Boolean) : [])], + action: async () => { + const sessionId = activeSessionId().get(instanceId) + if (!sessionId || sessionId === "info") { + alert("Select a session before running a custom command.") + return + } + const args = promptForCommandArguments(cmd) + if (args === null) { + return + } + try { + await executeCustomCommand(instanceId, sessionId, cmd.name, args) + } catch (error) { + console.error("Failed to run custom command:", error) + alert("Failed to run custom command. Check the console for details.") + } + }, + })) +} diff --git a/src/lib/formatters.ts b/src/lib/formatters.ts new file mode 100644 index 00000000..1753b26c --- /dev/null +++ b/src/lib/formatters.ts @@ -0,0 +1,9 @@ +export function formatTokenTotal(value: number): string { + if (value >= 1_000_000) { + return `${(value / 1_000_000).toFixed(1)}M` + } + if (value >= 1_000) { + return `${(value / 1_000).toFixed(0)}K` + } + return value.toLocaleString() +} diff --git a/src/lib/hooks/use-app-lifecycle.ts b/src/lib/hooks/use-app-lifecycle.ts new file mode 100644 index 00000000..1fe58f4f --- /dev/null +++ b/src/lib/hooks/use-app-lifecycle.ts @@ -0,0 +1,178 @@ +import { onMount, onCleanup, type Accessor } from "solid-js" +import { setupTabKeyboardShortcuts } from "../keyboard" +import { registerNavigationShortcuts } from "../shortcuts/navigation" +import { registerInputShortcuts } from "../shortcuts/input" +import { registerAgentShortcuts } from "../shortcuts/agent" +import { registerEscapeShortcut, setEscapeStateChangeHandler } from "../shortcuts/escape" +import { keyboardRegistry } from "../keyboard-registry" +import { abortSession, getSessions, isSessionBusy } from "../../stores/sessions" +import { showCommandPalette, hideCommandPalette } from "../../stores/command-palette" +import { addLog, updateInstance } from "../../stores/instances" +import type { Instance } from "../../types/instance" + +interface UseAppLifecycleOptions { + setEscapeInDebounce: (value: boolean) => void + handleNewInstanceRequest: () => void + handleCloseInstance: (instanceId: string) => Promise + handleNewSession: (instanceId: string) => Promise + handleCloseSession: (instanceId: string, sessionId: string) => Promise + showFolderSelection: Accessor + setShowFolderSelection: (value: boolean) => void + getActiveInstance: () => Instance | null + getActiveSessionIdForInstance: () => string | null +} + +export function useAppLifecycle(options: UseAppLifecycleOptions) { + onMount(() => { + setEscapeStateChangeHandler(options.setEscapeInDebounce) + + setupTabKeyboardShortcuts( + options.handleNewInstanceRequest, + options.handleCloseInstance, + options.handleNewSession, + options.handleCloseSession, + () => { + const instance = options.getActiveInstance() + if (instance) { + showCommandPalette(instance.id) + } + }, + ) + + 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 (options.showFolderSelection()) return true + + const instance = options.getActiveInstance() + if (!instance) return false + + const sessionId = options.getActiveSessionIdForInstance() + 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 (options.showFolderSelection()) { + options.setShowFolderSelection(false) + return + } + + const instance = options.getActiveInstance() + const sessionId = options.getActiveSessionIdForInstance() + 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) + + window.electronAPI.onNewInstance(() => { + options.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) + }) + + onCleanup(() => { + window.removeEventListener("keydown", handleKeyDown) + }) + }) +} diff --git a/src/lib/hooks/use-commands.ts b/src/lib/hooks/use-commands.ts new file mode 100644 index 00000000..3f2104c2 --- /dev/null +++ b/src/lib/hooks/use-commands.ts @@ -0,0 +1,416 @@ +import { createSignal, onMount } from "solid-js" +import type { Accessor } from "solid-js" +import type { Preferences } from "../../stores/preferences" +import { createCommandRegistry, type Command } from "../commands" +import { instances, activeInstanceId, setActiveInstanceId } from "../../stores/instances" +import { + activeParentSessionId, + activeSessionId as activeSessionMap, + getSessionFamily, + getSessions, + setActiveSession, +} from "../../stores/sessions" +import { setSessionCompactionState } from "../../stores/session-compaction" +import type { Instance } from "../../types/instance" + +export interface UseCommandsOptions { + preferences: Accessor + toggleShowThinkingBlocks: () => void + setDiffViewMode: (mode: "split" | "unified") => void + handleNewInstanceRequest: () => void + handleCloseInstance: (instanceId: string) => Promise + handleNewSession: (instanceId: string) => Promise + handleCloseSession: (instanceId: string, sessionId: string) => Promise + getActiveInstance: () => Instance | null + getActiveSessionIdForInstance: () => string | null +} + +export function useCommands(options: UseCommandsOptions) { + const commandRegistry = createCommandRegistry() + const [commands, setCommands] = createSignal([]) + + function refreshCommands() { + setCommands(commandRegistry.getAll()) + } + + function registerCommands() { + const activeInstance = options.getActiveInstance + const activeSessionIdForInstance = options.getActiveSessionIdForInstance + + 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: options.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 options.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 options.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 options.handleCloseSession(instance.id, sessionId) + }, + }) + + commandRegistry.register({ + id: "switch-to-info", + label: "Instance Info", + description: "Open the instance overview for logs and status", + category: "Instance", + keywords: ["info", "logs", "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(activeSessionMap().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(activeSessionMap().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 { + setSessionCompactionState(instance.id, sessionId, true) + await instance.client.session.summarize({ + path: { id: sessionId }, + body: { + providerID: session.model.providerId, + modelID: session.model.modelId, + }, + }) + } catch (error: unknown) { + setSessionCompactionState(instance.id, sessionId, false) + console.error("Failed to compact session:", error) + const message = error instanceof Error ? 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 + + let after = 0 + const revert = session.revert + + if (revert?.messageID) { + 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 + } + } + } + + 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 { + await instance.client.session.revert({ + path: { id: sessionId }, + body: { messageID }, + }) + + const revertedMessage = session.messages.find((m) => m.id === messageID) + const revertedInfo = session.messagesInfo.get(messageID) + + if (revertedMessage && revertedInfo?.role === "user") { + const textParts = revertedMessage.parts.filter((p) => 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() + } + } + } + } 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: "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: () => `${options.preferences().showThinkingBlocks ? "Hide" : "Show"} Thinking Blocks`, + description: "Show/hide AI thinking process", + category: "System", + keywords: ["/thinking", "toggle", "show", "hide"], + action: options.toggleShowThinkingBlocks, + }) + + commandRegistry.register({ + id: "diff-view-split", + label: () => `${(options.preferences().diffViewMode || "split") === "split" ? "✓ " : ""}Use Split Diff View`, + description: "Display tool-call diffs side-by-side", + category: "System", + keywords: ["diff", "split", "view"], + action: () => options.setDiffViewMode("split"), + }) + + commandRegistry.register({ + id: "diff-view-unified", + label: () => `${(options.preferences().diffViewMode || "split") === "unified" ? "✓ " : ""}Use Unified Diff View`, + description: "Display tool-call diffs inline", + category: "System", + keywords: ["diff", "unified", "view"], + action: () => options.setDiffViewMode("unified"), + }) + + 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 executeCommand(command: Command) { + try { + const result = command.action?.() + if (result instanceof Promise) { + void result.catch((error) => { + console.error("Command execution failed:", error) + }) + } + } catch (error) { + console.error("Command execution failed:", error) + } + } + + onMount(() => { + registerCommands() + refreshCommands() + }) + + return { + commands, + commandRegistry, + refreshCommands, + executeCommand, + } +}