Split workspace into electron and ui packages

This commit is contained in:
Shantur Rathore
2025-11-17 12:06:58 +00:00
parent aa77ca2931
commit 89bd32814f
137 changed files with 407 additions and 1371 deletions

View File

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

View File

@@ -0,0 +1,68 @@
export interface KeyboardShortcut {
key: string
meta?: boolean
ctrl?: boolean
shift?: boolean
alt?: boolean
}
export interface Command {
id: string
label: string | (() => 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 label = typeof cmd.label === "function" ? cmd.label() : cmd.label
const labelMatch = 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>

View File

@@ -0,0 +1,50 @@
const HUNK_PATTERN = /(^|\n)@@/m
const FILE_MARKER_PATTERN = /(^|\n)(diff --git |--- |\+\+\+)/
const BEGIN_PATCH_PATTERN = /^\*\*\* (Begin|End) Patch/
const UPDATE_FILE_PATTERN = /^\*\*\* Update File: (.+)$/
function stripCodeFence(value: string): string {
const trimmed = value.trim()
if (!trimmed.startsWith("```")) return trimmed
const lines = trimmed.split("\n")
if (lines.length < 2) return ""
const lastLine = lines[lines.length - 1]
if (!lastLine.startsWith("```")) return trimmed
return lines.slice(1, -1).join("\n")
}
export function normalizeDiffText(raw: string): string {
if (!raw) return ""
const withoutFence = stripCodeFence(raw.replace(/\r\n/g, "\n"))
const lines = withoutFence.split("\n").map((line) => line.replace(/\s+$/u, ""))
let pendingFilePath: string | null = null
const cleanedLines: string[] = []
for (const line of lines) {
if (!line) continue
if (BEGIN_PATCH_PATTERN.test(line)) {
continue
}
const updateMatch = line.match(UPDATE_FILE_PATTERN)
if (updateMatch) {
pendingFilePath = updateMatch[1]?.trim() || null
continue
}
cleanedLines.push(line)
}
if (pendingFilePath && !FILE_MARKER_PATTERN.test(cleanedLines.join("\n"))) {
cleanedLines.unshift(`+++ b/${pendingFilePath}`)
cleanedLines.unshift(`--- a/${pendingFilePath}`)
}
return cleanedLines.join("\n").trim()
}
export function isRenderableDiffText(raw?: string | null): raw is string {
if (!raw) return false
const normalized = normalizeDiffText(raw)
if (!normalized) return false
return HUNK_PATTERN.test(normalized)
}

View File

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

View File

@@ -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<void>
handleNewSession: (instanceId: string) => Promise<void>
handleCloseSession: (instanceId: string, sessionId: string) => Promise<void>
showFolderSelection: Accessor<boolean>
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)
})
})
}

View File

@@ -0,0 +1,450 @@
import { createSignal, onMount } from "solid-js"
import type { Accessor } from "solid-js"
import type { Preferences, ExpansionPreference } 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<Preferences>
toggleShowThinkingBlocks: () => void
setDiffViewMode: (mode: "split" | "unified") => void
setToolOutputExpansion: (mode: ExpansionPreference) => void
setDiagnosticsExpansion: (mode: ExpansionPreference) => void
handleNewInstanceRequest: () => void
handleCloseInstance: (instanceId: string) => Promise<void>
handleNewSession: (instanceId: string) => Promise<void>
handleCloseSession: (instanceId: string, sessionId: string) => Promise<void>
getActiveInstance: () => Instance | null
getActiveSessionIdForInstance: () => string | null
}
export function useCommands(options: UseCommandsOptions) {
const commandRegistry = createCommandRegistry()
const [commands, setCommands] = createSignal<Command[]>([])
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: "tool-output-default-visibility",
label: () => {
const mode = options.preferences().toolOutputExpansion || "expanded"
return `Tool Outputs Default · ${mode === "expanded" ? "Expanded" : "Collapsed"}`
},
description: "Toggle default expansion for tool outputs",
category: "System",
keywords: ["tool", "output", "expand", "collapse"],
action: () => {
const mode = options.preferences().toolOutputExpansion || "expanded"
const next: ExpansionPreference = mode === "expanded" ? "collapsed" : "expanded"
options.setToolOutputExpansion(next)
},
})
commandRegistry.register({
id: "diagnostics-default-visibility",
label: () => {
const mode = options.preferences().diagnosticsExpansion || "expanded"
return `Diagnostics Default · ${mode === "expanded" ? "Expanded" : "Collapsed"}`
},
description: "Toggle default expansion for diagnostics output",
category: "System",
keywords: ["diagnostics", "expand", "collapse"],
action: () => {
const mode = options.preferences().diagnosticsExpansion || "expanded"
const next: ExpansionPreference = mode === "expanded" ? "collapsed" : "expanded"
options.setDiagnosticsExpansion(next)
},
})
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,
}
}

View File

@@ -0,0 +1,73 @@
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 shortcutKey = shortcut.key.toLowerCase()
const eventKey = event.key ? event.key.toLowerCase() : ""
const eventCode = event.code ? event.code.toLowerCase() : ""
const keyMatch = eventKey === shortcutKey || eventCode === shortcutKey
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()

View File

@@ -0,0 +1,30 @@
import type { KeyboardShortcut } from "./keyboard-registry"
export const isMac = () => navigator.platform.toLowerCase().includes("mac")
export const modKey = (event?: KeyboardEvent) => {
if (!event) return isMac() ? "metaKey" : "ctrlKey"
return isMac() ? event.metaKey : event.ctrlKey
}
export const modKeyPressed = (event: KeyboardEvent) => {
return isMac() ? event.metaKey : event.ctrlKey
}
export const formatShortcut = (shortcut: KeyboardShortcut): string => {
const parts: string[] = []
if (shortcut.modifiers.ctrl || shortcut.modifiers.meta) {
parts.push(isMac() ? "Cmd" : "Ctrl")
}
if (shortcut.modifiers.shift) {
parts.push("Shift")
}
if (shortcut.modifiers.alt) {
parts.push(isMac() ? "Option" : "Alt")
}
parts.push(shortcut.key.toUpperCase())
return parts.join("+")
}

View File

@@ -0,0 +1,87 @@
import { instances, activeInstanceId, setActiveInstanceId } from "../stores/instances"
import { activeSessionId, setActiveSession, getSessions, activeParentSessionId } from "../stores/sessions"
import { keyboardRegistry } from "./keyboard-registry"
import { isMac } from "./keyboard-utils"
export function setupTabKeyboardShortcuts(
handleNewInstance: () => void,
handleCloseInstance: (instanceId: string) => void,
handleNewSession: (instanceId: string) => void,
handleCloseSession: (instanceId: string, sessionId: string) => void,
handleCommandPalette: () => void,
) {
keyboardRegistry.register({
id: "session-new",
key: "n",
modifiers: {
shift: true,
meta: isMac(),
ctrl: !isMac(),
},
handler: () => {
const instanceId = activeInstanceId()
if (instanceId) void handleNewSession(instanceId)
},
description: "New Session",
context: "global",
})
window.addEventListener("keydown", (e) => {
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key.toLowerCase() === "p") {
e.preventDefault()
handleCommandPalette()
return
}
if ((e.metaKey || e.ctrlKey) && !e.shiftKey && e.key >= "1" && e.key <= "9") {
e.preventDefault()
const index = parseInt(e.key) - 1
const instanceIds = Array.from(instances().keys())
if (instanceIds[index]) {
setActiveInstanceId(instanceIds[index])
}
}
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key >= "1" && e.key <= "9") {
e.preventDefault()
const instanceId = activeInstanceId()
if (!instanceId) return
const index = parseInt(e.key) - 1
const parentId = activeParentSessionId().get(instanceId)
if (!parentId) return
const sessions = getSessions(instanceId)
const sessionFamily = sessions.filter((s) => s.id === parentId || s.parentId === parentId)
const allTabs = sessionFamily.map((s) => s.id).concat(["logs"])
if (allTabs[index]) {
setActiveSession(instanceId, allTabs[index])
}
}
if ((e.metaKey || e.ctrlKey) && !e.shiftKey && e.key.toLowerCase() === "n") {
e.preventDefault()
handleNewInstance()
}
if ((e.metaKey || e.ctrlKey) && !e.shiftKey && e.key.toLowerCase() === "w") {
e.preventDefault()
const instanceId = activeInstanceId()
if (instanceId) {
handleCloseInstance(instanceId)
}
}
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key.toLowerCase() === "w") {
e.preventDefault()
const instanceId = activeInstanceId()
if (!instanceId) return
const sessionId = activeSessionId().get(instanceId)
if (sessionId && sessionId !== "logs") {
handleCloseSession(instanceId, sessionId)
}
}
})
}

View File

@@ -0,0 +1,372 @@
import { marked } from "marked"
import { createHighlighter, type Highlighter, bundledLanguages } from "shiki/bundle/full"
let highlighter: Highlighter | null = null
let highlighterPromise: Promise<Highlighter> | null = null
let currentTheme: "light" | "dark" = "light"
let isInitialized = false
let highlightSuppressed = false
let rendererSetup = false
const extensionToLanguage: Record<string, string> = {
ts: "typescript",
tsx: "typescript",
js: "javascript",
jsx: "javascript",
py: "python",
sh: "bash",
bash: "bash",
json: "json",
html: "html",
css: "css",
md: "markdown",
yaml: "yaml",
yml: "yaml",
sql: "sql",
rs: "rust",
go: "go",
cpp: "cpp",
cc: "cpp",
cxx: "cpp",
hpp: "cpp",
h: "cpp",
c: "c",
java: "java",
cs: "csharp",
php: "php",
rb: "ruby",
swift: "swift",
kt: "kotlin",
}
export function getLanguageFromPath(path?: string | null): string | undefined {
if (!path) return undefined
const ext = path.split(".").pop()?.toLowerCase()
return ext ? extensionToLanguage[ext] : undefined
}
// Track loaded languages and queue for on-demand loading
const loadedLanguages = new Set<string>()
const queuedLanguages = new Set<string>()
const languageLoadQueue: Array<() => Promise<void>> = []
let isQueueRunning = false
// Pub/sub mechanism for language loading notifications
const languageListeners: Array<() => void> = []
export function onLanguagesLoaded(callback: () => void): () => void {
languageListeners.push(callback)
// Return cleanup function
return () => {
const index = languageListeners.indexOf(callback)
if (index > -1) {
languageListeners.splice(index, 1)
}
}
}
function triggerLanguageListeners() {
for (const listener of languageListeners) {
try {
listener()
} catch (error) {
console.error("Error in language listener:", error)
}
}
}
async function getOrCreateHighlighter() {
if (highlighter) {
return highlighter
}
if (highlighterPromise) {
return highlighterPromise
}
// Create highlighter with no preloaded languages
highlighterPromise = createHighlighter({
themes: ["github-light", "github-dark"],
langs: [],
})
highlighter = await highlighterPromise
highlighterPromise = null
return highlighter
}
function normalizeLanguageToken(token: string): string {
return token.trim().toLowerCase()
}
function resolveLanguage(token: string): { canonical: string | null; raw: string } {
const normalized = normalizeLanguageToken(token)
// Check if it's a direct key match
if (normalized in bundledLanguages) {
return { canonical: normalized, raw: normalized }
}
// Check aliases
for (const [key, lang] of Object.entries(bundledLanguages)) {
const aliases = (lang as { aliases?: string[] }).aliases
if (aliases?.includes(normalized)) {
return { canonical: key, raw: normalized }
}
}
return { canonical: null, raw: normalized }
}
async function ensureLanguages(content: string) {
if (highlightSuppressed) {
return
}
// Parse code fences to extract language tokens
// Updated regex to capture optional language tokens and handle trailing annotations
const codeBlockRegex = /```[ \t]*([A-Za-z0-9_.+#-]+)?[^`]*?```/g
const foundLanguages = new Set<string>()
let match
while ((match = codeBlockRegex.exec(content)) !== null) {
const langToken = match[1]
if (langToken && langToken.trim()) {
foundLanguages.add(langToken.trim())
}
}
// Queue language loading tasks
for (const token of foundLanguages) {
const { canonical, raw } = resolveLanguage(token)
const langKey = canonical || raw
// Skip "text" and aliases since Shiki handles plain text already
if (langKey === "text" || raw === "text") {
continue
}
// Skip if already loaded or queued
if (loadedLanguages.has(langKey) || queuedLanguages.has(langKey)) {
continue
}
queuedLanguages.add(langKey)
// Queue the language loading task
languageLoadQueue.push(async () => {
try {
const h = await getOrCreateHighlighter()
await h.loadLanguage(langKey as never)
loadedLanguages.add(langKey)
triggerLanguageListeners()
} catch {
// Quietly ignore errors
} finally {
queuedLanguages.delete(langKey)
}
})
}
// Trigger queue runner if not already running
if (languageLoadQueue.length > 0 && !isQueueRunning) {
runLanguageLoadQueue()
}
}
export function decodeHtmlEntities(content: string): string {
if (!content.includes("&")) {
return content
}
const entityPattern = /&(#x?[0-9a-fA-F]+|[a-zA-Z][a-zA-Z0-9]+);/g
const namedEntities: Record<string, string> = {
amp: "&",
lt: "<",
gt: ">",
quot: '"',
apos: "'",
nbsp: " ",
}
let result = content
let previous = ""
while (result.includes("&") && result !== previous) {
previous = result
result = result.replace(entityPattern, (match, entity) => {
if (!entity) {
return match
}
if (entity[0] === "#") {
const isHex = entity[1]?.toLowerCase() === "x"
const value = isHex ? parseInt(entity.slice(2), 16) : parseInt(entity.slice(1), 10)
if (!Number.isNaN(value)) {
try {
return String.fromCodePoint(value)
} catch {
return match
}
}
return match
}
const decoded = namedEntities[entity.toLowerCase()]
return decoded !== undefined ? decoded : match
})
}
return result
}
async function runLanguageLoadQueue() {
if (isQueueRunning || languageLoadQueue.length === 0) {
return
}
isQueueRunning = true
while (languageLoadQueue.length > 0) {
const task = languageLoadQueue.shift()
if (task) {
await task()
}
}
isQueueRunning = false
}
function setupRenderer(isDark: boolean) {
if (!highlighter || rendererSetup) return
currentTheme = isDark ? "dark" : "light"
marked.setOptions({
breaks: true,
gfm: true,
})
const renderer = new marked.Renderer()
renderer.code = (code: string, lang: string | undefined) => {
const decodedCode = decodeHtmlEntities(code)
const encodedCode = encodeURIComponent(decodedCode)
// Use "text" as default when no language is specified
const resolvedLang = lang && lang.trim() ? lang.trim() : "text"
const escapedLang = escapeHtml(resolvedLang)
const header = `
<div class="code-block-header">
<span class="code-block-language">${escapedLang}</span>
<button class="code-block-copy" data-code="${encodedCode}">
<svg class="copy-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>
<span class="copy-text">Copy</span>
</button>
</div>
`.trim()
if (highlightSuppressed) {
return `<div class="markdown-code-block" data-language="${escapedLang}" data-code="${encodedCode}">${header}<pre><code class="language-${escapedLang}">${escapeHtml(decodedCode)}</code></pre></div>`
}
// Skip highlighting for "text" language or when highlighter is not available
if (resolvedLang === "text" || !highlighter) {
return `<div class="markdown-code-block" data-language="${escapedLang}" data-code="${encodedCode}">${header}<pre><code>${escapeHtml(decodedCode)}</code></pre></div>`
}
// Resolve language and check if it's loaded
const { canonical, raw } = resolveLanguage(resolvedLang)
const langKey = canonical || raw
// Skip highlighting for "text" aliases
if (langKey === "text" || raw === "text") {
return `<div class="markdown-code-block" data-language="${escapedLang}" data-code="${encodedCode}">${header}<pre><code class="language-${escapedLang}">${escapeHtml(decodedCode)}</code></pre></div>`
}
// Use highlighting if language is loaded, otherwise fall back to plain code
if (loadedLanguages.has(langKey)) {
try {
const html = highlighter!.codeToHtml(decodedCode, {
lang: langKey,
theme: currentTheme === "dark" ? "github-dark" : "github-light",
})
return `<div class="markdown-code-block" data-language="${escapedLang}" data-code="${encodedCode}">${header}${html}</div>`
} catch {
// Fall through to plain code if highlighting fails
}
}
return `<div class="markdown-code-block" data-language="${escapedLang}" data-code="${encodedCode}">${header}<pre><code class="language-${escapedLang}">${escapeHtml(decodedCode)}</code></pre></div>`
}
renderer.link = (href: string, title: string | null | undefined, text: string) => {
const titleAttr = title ? ` title="${escapeHtml(title)}"` : ""
return `<a href="${escapeHtml(href)}" target="_blank" rel="noopener noreferrer"${titleAttr}>${text}</a>`
}
renderer.codespan = (code: string) => {
const decoded = decodeHtmlEntities(code)
return `<code class="inline-code">${escapeHtml(decoded)}</code>`
}
marked.use({ renderer })
rendererSetup = true
}
export async function initMarkdown(isDark: boolean) {
await getOrCreateHighlighter()
setupRenderer(isDark)
isInitialized = true
}
export function isMarkdownReady(): boolean {
return isInitialized && highlighter !== null
}
export async function renderMarkdown(
content: string,
options?: {
suppressHighlight?: boolean
},
): Promise<string> {
if (!isInitialized) {
await initMarkdown(currentTheme === "dark")
}
const suppressHighlight = options?.suppressHighlight ?? false
const decoded = decodeHtmlEntities(content)
if (!suppressHighlight) {
// Queue language loading but don't wait for it to complete
await ensureLanguages(decoded)
}
const previousSuppressed = highlightSuppressed
highlightSuppressed = suppressHighlight
try {
// Proceed to parse immediately - highlighting will be available on next render
return marked.parse(decoded) as Promise<string>
} finally {
highlightSuppressed = previousSuppressed
}
}
export async function getSharedHighlighter(): Promise<Highlighter> {
return getOrCreateHighlighter()
}
export function escapeHtml(text: string): string {
const map: Record<string, string> = {
"&": "&amp;",
"<": "&lt;",
'"': "&quot;",
"'": "&#039;",
}
return text.replace(/[&<"']/g, (m) => map[m])
}

View File

@@ -0,0 +1,71 @@
import toast from "solid-toast"
export type ToastVariant = "info" | "success" | "warning" | "error"
export type ToastPayload = {
title?: string
message: string
variant: ToastVariant
duration?: number
}
const variantAccent: Record<
ToastVariant,
{
badge: string
container: string
headline: string
body: string
}
> = {
info: {
badge: "bg-sky-500/40",
container: "bg-slate-900/95 border-slate-700 text-slate-100",
headline: "text-slate-50",
body: "text-slate-200/80",
},
success: {
badge: "bg-emerald-500/40",
container: "bg-emerald-950/90 border-emerald-800 text-emerald-50",
headline: "text-emerald-50",
body: "text-emerald-100/80",
},
warning: {
badge: "bg-amber-500/40",
container: "bg-amber-950/90 border-amber-800 text-amber-50",
headline: "text-amber-50",
body: "text-amber-100/80",
},
error: {
badge: "bg-rose-500/40",
container: "bg-rose-950/90 border-rose-800 text-rose-50",
headline: "text-rose-50",
body: "text-rose-100/80",
},
}
export function showToastNotification(payload: ToastPayload) {
const accent = variantAccent[payload.variant]
const duration = payload.duration ?? 10000
toast.custom(
() => (
<div class={`pointer-events-auto w-[320px] max-w-[360px] rounded-lg border px-4 py-3 shadow-xl ${accent.container}`}>
<div class="flex items-start gap-3">
<span class={`mt-1 inline-block h-2.5 w-2.5 rounded-full ${accent.badge}`} />
<div class="flex-1 text-sm leading-snug">
{payload.title && <p class={`font-semibold ${accent.headline}`}>{payload.title}</p>}
<p class={`${accent.body} ${payload.title ? "mt-1" : ""}`}>{payload.message}</p>
</div>
</div>
</div>
),
{
duration,
ariaProps: {
role: "status",
"aria-live": "polite",
},
},
)
}

View File

@@ -0,0 +1,36 @@
import type { Attachment } from "../types/attachment"
export function resolvePastedPlaceholders(prompt: string, attachments: Attachment[] = []): string {
if (!prompt || !prompt.includes("[pasted #")) {
return prompt
}
if (!attachments || attachments.length === 0) {
return prompt
}
const lookup = new Map<string, string>()
for (const attachment of attachments) {
const source = attachment?.source
if (!source || source.type !== "text") continue
const display = attachment?.display
const value = source.value
if (typeof display !== "string" || typeof value !== "string") continue
const match = display.match(/pasted #(\d+)/)
if (!match) continue
const placeholder = `[pasted #${match[1]}]`
if (!lookup.has(placeholder)) {
lookup.set(placeholder, value)
}
}
if (lookup.size === 0) {
return prompt
}
return prompt.replace(/\[pasted #(\d+)\]/g, (fullMatch) => {
const replacement = lookup.get(fullMatch)
return typeof replacement === "string" ? replacement : fullMatch
})
}

View File

@@ -0,0 +1,32 @@
import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk/client"
class SDKManager {
private clients = new Map<number, OpencodeClient>()
createClient(port: number): OpencodeClient {
if (this.clients.has(port)) {
return this.clients.get(port)!
}
const client = createOpencodeClient({
baseUrl: `http://localhost:${port}`,
})
this.clients.set(port, client)
return client
}
getClient(port: number): OpencodeClient | null {
return this.clients.get(port) || null
}
destroyClient(port: number): void {
this.clients.delete(port)
}
destroyAll(): void {
this.clients.clear()
}
}
export const sdkManager = new SDKManager()

View File

@@ -0,0 +1,23 @@
import { keyboardRegistry } from "../keyboard-registry"
export function registerAgentShortcuts(focusModelSelector: () => void, openAgentSelector: () => void) {
const isMac = () => navigator.platform.toLowerCase().includes("mac")
keyboardRegistry.register({
id: "focus-model",
key: "M",
modifiers: { ctrl: !isMac(), meta: isMac(), shift: true },
handler: focusModelSelector,
description: "focus model",
context: "global",
})
keyboardRegistry.register({
id: "open-agent-selector",
key: "A",
modifiers: { ctrl: !isMac(), meta: isMac(), shift: true },
handler: openAgentSelector,
description: "open agent",
context: "global",
})
}

View File

@@ -0,0 +1,67 @@
import { keyboardRegistry } from "../keyboard-registry"
type EscapeKeyState = "idle" | "firstPress"
const ESCAPE_DEBOUNCE_TIMEOUT = 1000
let escapeKeyState: EscapeKeyState = "idle"
let escapeTimeoutId: number | null = null
let onEscapeStateChange: ((inDebounce: boolean) => void) | null = null
export function setEscapeStateChangeHandler(handler: (inDebounce: boolean) => void) {
onEscapeStateChange = handler
}
function resetEscapeState() {
escapeKeyState = "idle"
if (escapeTimeoutId !== null) {
clearTimeout(escapeTimeoutId)
escapeTimeoutId = null
}
if (onEscapeStateChange) {
onEscapeStateChange(false)
}
}
export function registerEscapeShortcut(
isSessionBusy: () => boolean,
abortSession: () => Promise<void>,
blurInput: () => void,
closeModal: () => void,
) {
keyboardRegistry.register({
id: "escape",
key: "Escape",
modifiers: {},
handler: () => {
const hasOpenModal = document.querySelector('[role="dialog"]') !== null
if (hasOpenModal) {
closeModal()
resetEscapeState()
return
}
if (isSessionBusy()) {
if (escapeKeyState === "idle") {
escapeKeyState = "firstPress"
if (onEscapeStateChange) {
onEscapeStateChange(true)
}
escapeTimeoutId = window.setTimeout(() => {
resetEscapeState()
}, ESCAPE_DEBOUNCE_TIMEOUT)
} else if (escapeKeyState === "firstPress") {
resetEscapeState()
abortSession()
}
return
}
resetEscapeState()
blurInput()
},
description: "cancel/close",
context: "global",
})
}

View File

@@ -0,0 +1,23 @@
import { keyboardRegistry } from "../keyboard-registry"
export function registerInputShortcuts(clearInput: () => void, focusInput: () => void) {
const isMac = () => navigator.platform.toLowerCase().includes("mac")
keyboardRegistry.register({
id: "clear-input",
key: "k",
modifiers: { ctrl: !isMac(), meta: isMac() },
handler: clearInput,
description: "clear input",
context: "global",
})
keyboardRegistry.register({
id: "focus-input",
key: "p",
modifiers: { ctrl: !isMac(), meta: isMac() },
handler: focusInput,
description: "focus input",
context: "global",
})
}

View File

@@ -0,0 +1,118 @@
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")
const buildNavigationOrder = (instanceId: string): string[] => {
const parentId = activeParentSessionId().get(instanceId)
if (!parentId) return []
const familySessions = getSessionFamily(instanceId, parentId)
if (familySessions.length === 0) return []
const [parentSession, ...childSessions] = familySessions
if (!parentSession) return []
const sortedChildren = childSessions.slice().sort((a, b) => b.time.updated - a.time.updated)
return [parentSession.id, "info", ...sortedChildren.map((session) => session.id)]
}
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 navigationIds = buildNavigationOrder(instanceId)
if (navigationIds.length === 0) return
const currentActiveId = activeSessionId().get(instanceId)
let currentIndex = navigationIds.indexOf(currentActiveId || "")
if (currentIndex === -1) {
currentIndex = navigationIds.length - 1
}
const targetIndex = currentIndex <= 0 ? navigationIds.length - 1 : currentIndex - 1
const targetSessionId = navigationIds[targetIndex]
setActiveSession(instanceId, targetSessionId)
},
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 navigationIds = buildNavigationOrder(instanceId)
if (navigationIds.length === 0) return
const currentActiveId = activeSessionId().get(instanceId)
let currentIndex = navigationIds.indexOf(currentActiveId || "")
if (currentIndex === -1) {
currentIndex = 0
}
const targetIndex = (currentIndex + 1) % navigationIds.length
const targetSessionId = navigationIds[targetIndex]
setActiveSession(instanceId, targetSessionId)
},
description: "next session",
context: "global",
})
keyboardRegistry.register({
id: "switch-to-info",
key: "l",
modifiers: { ctrl: !isMac(), meta: isMac(), shift: true },
handler: () => {
const instanceId = activeInstanceId()
if (instanceId) setActiveSession(instanceId, "info")
},
description: "info tab",
context: "global",
})
}

View File

@@ -0,0 +1,237 @@
import { createSignal } from "solid-js"
import {
MessageUpdateEvent,
MessageRemovedEvent,
MessagePartUpdatedEvent,
MessagePartRemovedEvent
} from "../types/message"
import type {
EventLspUpdated,
EventPermissionReplied,
EventPermissionUpdated,
EventSessionCompacted,
EventSessionError,
EventSessionIdle,
EventSessionUpdated,
} from "@opencode-ai/sdk"
interface SSEConnection {
instanceId: string
port: number
eventSource: EventSource
status: "connecting" | "connected" | "disconnected" | "error"
reconnectAttempts: number
reconnectTimer?: ReturnType<typeof setTimeout>
}
interface TuiToastEvent {
type: "tui.toast.show"
properties: {
title?: string
message: string
variant: "info" | "success" | "warning" | "error"
duration?: number
}
}
type SSEEvent =
| MessageUpdateEvent
| MessageRemovedEvent
| MessagePartUpdatedEvent
| MessagePartRemovedEvent
| EventSessionUpdated
| EventSessionCompacted
| EventSessionError
| EventSessionIdle
| EventPermissionUpdated
| EventPermissionReplied
| EventLspUpdated
| TuiToastEvent
| { type: string; properties?: Record<string, unknown> } // Fallback for unknown event types
const [connectionStatus, setConnectionStatus] = createSignal<
Map<string, "connecting" | "connected" | "disconnected" | "error">
>(new Map())
class SSEManager {
private connections = new Map<string, SSEConnection>()
private static readonly MAX_RECONNECT_ATTEMPTS = 3
connect(instanceId: string, port: number, reconnectAttempts = 0): void {
const existing = this.connections.get(instanceId)
if (existing) {
this.clearReconnectTimer(existing)
existing.eventSource.close()
}
const url = `http://localhost:${port}/event`
const eventSource = new EventSource(url)
const connection: SSEConnection = {
instanceId,
port,
eventSource,
status: "connecting",
reconnectAttempts,
}
this.connections.set(instanceId, connection)
this.updateConnectionStatus(instanceId, "connecting")
eventSource.onopen = () => {
connection.status = "connected"
connection.reconnectAttempts = 0
this.updateConnectionStatus(instanceId, "connected")
console.log(`[SSE] Connected to instance ${instanceId}`)
}
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data)
this.handleEvent(instanceId, data)
} catch (error) {
console.error("[SSE] Failed to parse event:", error)
}
}
eventSource.onerror = () => {
connection.status = "error"
this.updateConnectionStatus(instanceId, "error")
console.error(`[SSE] Connection error for instance ${instanceId}`)
this.handleConnectionError(instanceId, "Connection to instance lost")
}
}
disconnect(instanceId: string): void {
const connection = this.connections.get(instanceId)
if (connection) {
this.clearReconnectTimer(connection)
connection.eventSource.close()
this.connections.delete(instanceId)
this.updateConnectionStatus(instanceId, "disconnected")
console.log(`[SSE] Disconnected from instance ${instanceId}`)
}
}
private handleEvent(instanceId: string, event: SSEEvent): void {
console.log("[SSE] Received event:", event.type, event)
switch (event.type) {
case "message.updated":
this.onMessageUpdate?.(instanceId, event as MessageUpdateEvent)
break
case "message.part.updated":
this.onMessagePartUpdated?.(instanceId, event as MessagePartUpdatedEvent)
break
case "message.removed":
this.onMessageRemoved?.(instanceId, event as MessageRemovedEvent)
break
case "message.part.removed":
this.onMessagePartRemoved?.(instanceId, event as MessagePartRemovedEvent)
break
case "session.updated":
this.onSessionUpdate?.(instanceId, event as EventSessionUpdated)
break
case "session.compacted":
this.onSessionCompacted?.(instanceId, event as EventSessionCompacted)
break
case "session.error":
this.onSessionError?.(instanceId, event as EventSessionError)
break
case "tui.toast.show":
this.onTuiToast?.(instanceId, event as TuiToastEvent)
break
case "session.idle":
this.onSessionIdle?.(instanceId, event as EventSessionIdle)
break
case "permission.updated":
this.onPermissionUpdated?.(instanceId, event as EventPermissionUpdated)
break
case "permission.replied":
this.onPermissionReplied?.(instanceId, event as EventPermissionReplied)
break
case "lsp.updated":
this.onLspUpdated?.(instanceId, event as EventLspUpdated)
break
default:
console.warn("[SSE] Unknown event type:", event.type)
}
}
private handleConnectionError(instanceId: string, reason: string): void {
const connection = this.connections.get(instanceId)
if (!connection) return
connection.eventSource.close()
if (connection.reconnectAttempts >= SSEManager.MAX_RECONNECT_ATTEMPTS) {
this.handleConnectionLost(instanceId, reason)
return
}
const nextAttempt = connection.reconnectAttempts + 1
const delay = Math.min(nextAttempt * 1000, 5000)
connection.reconnectAttempts = nextAttempt
connection.status = "connecting"
this.updateConnectionStatus(instanceId, "connecting")
console.warn(`[SSE] Attempting reconnect ${nextAttempt} for instance ${instanceId}`)
connection.reconnectTimer = setTimeout(() => {
connection.reconnectTimer = undefined
this.connect(instanceId, connection.port, nextAttempt)
}, delay)
}
private handleConnectionLost(instanceId: string, reason: string): void {
const connection = this.connections.get(instanceId)
if (!connection) return
this.clearReconnectTimer(connection)
connection.eventSource.close()
this.connections.delete(instanceId)
connection.status = "disconnected"
this.updateConnectionStatus(instanceId, "disconnected")
this.onConnectionLost?.(instanceId, reason)
}
private clearReconnectTimer(connection: SSEConnection): void {
if (connection.reconnectTimer) {
clearTimeout(connection.reconnectTimer)
connection.reconnectTimer = undefined
}
}
private updateConnectionStatus(instanceId: string, status: SSEConnection["status"]): void {
setConnectionStatus((prev) => {
const next = new Map(prev)
next.set(instanceId, status)
return next
})
}
onMessageUpdate?: (instanceId: string, event: MessageUpdateEvent) => void
onMessageRemoved?: (instanceId: string, event: MessageRemovedEvent) => void
onMessagePartUpdated?: (instanceId: string, event: MessagePartUpdatedEvent) => void
onMessagePartRemoved?: (instanceId: string, event: MessagePartRemovedEvent) => void
onSessionUpdate?: (instanceId: string, event: EventSessionUpdated) => void
onSessionCompacted?: (instanceId: string, event: EventSessionCompacted) => void
onSessionError?: (instanceId: string, event: EventSessionError) => void
onTuiToast?: (instanceId: string, event: TuiToastEvent) => void
onSessionIdle?: (instanceId: string, event: EventSessionIdle) => void
onPermissionUpdated?: (instanceId: string, event: EventPermissionUpdated) => void
onPermissionReplied?: (instanceId: string, event: EventPermissionReplied) => void
onLspUpdated?: (instanceId: string, event: EventLspUpdated) => void
onConnectionLost?: (instanceId: string, reason: string) => void | Promise<void>
getStatus(instanceId: string): "connecting" | "connected" | "disconnected" | "error" | null {
return connectionStatus().get(instanceId) ?? null
}
getStatuses() {
return connectionStatus()
}
}
export const sseManager = new SSEManager()

View File

@@ -0,0 +1,162 @@
import type { Preferences, RecentFolder, OpenCodeBinary } from "../stores/preferences"
export interface ConfigData {
preferences: Preferences
recentFolders: RecentFolder[]
opencodeBinaries: OpenCodeBinary[]
theme?: "light" | "dark" | "system"
}
export interface InstanceData {
messageHistory: string[]
}
export class FileStorage {
private configPath: string | undefined
private instancesDir: string | undefined
private configChangeListeners: Set<() => void> = new Set()
private initialized = false
constructor() {
this.initialize()
}
private async initialize() {
if (this.initialized) return
this.configPath = await window.electronAPI.getConfigPath()
this.instancesDir = await window.electronAPI.getInstancesDir()
// Listen for config changes from other instances
window.electronAPI.onConfigChanged(() => {
this.configChangeListeners.forEach((listener) => listener())
})
this.initialized = true
}
private async ensureInitialized() {
if (!this.initialized) {
await this.initialize()
}
}
private parseConfig(content: string): ConfigData {
const trimmed = content.trim()
try {
return JSON.parse(trimmed)
} catch (error) {
const firstBrace = trimmed.indexOf("{")
const lastBrace = trimmed.lastIndexOf("}")
if (firstBrace !== -1 && lastBrace !== -1 && lastBrace > firstBrace) {
const sanitized = trimmed.slice(firstBrace, lastBrace + 1)
if (sanitized.length !== trimmed.length) {
console.warn("Config file contained trailing data; attempting recovery")
}
try {
return JSON.parse(sanitized)
} catch {
// Fall through to rethrow original error below
}
}
throw error
}
}
// Config operations
async loadConfig(): Promise<ConfigData> {
await this.ensureInitialized()
try {
const content = await window.electronAPI.readConfigFile()
return this.parseConfig(content)
} catch (error) {
console.warn("Failed to load config, using defaults:", error)
return {
preferences: {
showThinkingBlocks: false,
environmentVariables: {},
modelRecents: [],
agentModelSelections: {},
diffViewMode: "split",
toolOutputExpansion: "expanded",
diagnosticsExpansion: "expanded",
},
recentFolders: [],
opencodeBinaries: [],
}
}
}
async saveConfig(config: ConfigData): Promise<void> {
await this.ensureInitialized()
try {
await window.electronAPI.writeConfigFile(JSON.stringify(config, null, 2))
} catch (error) {
console.error("Failed to save config:", error)
throw error
}
}
// Instance operations
async loadInstanceData(instanceId: string): Promise<InstanceData> {
await this.ensureInitialized()
try {
const filename = this.instanceIdToFilename(instanceId)
const content = await window.electronAPI.readInstanceFile(filename)
return JSON.parse(content)
} catch (error) {
console.warn(`Failed to load instance data for ${instanceId}, using defaults:`, error)
return {
messageHistory: [],
}
}
}
async saveInstanceData(instanceId: string, data: InstanceData): Promise<void> {
await this.ensureInitialized()
try {
const filename = this.instanceIdToFilename(instanceId)
await window.electronAPI.writeInstanceFile(filename, JSON.stringify(data, null, 2))
} catch (error) {
console.error(`Failed to save instance data for ${instanceId}:`, error)
throw error
}
}
async deleteInstanceData(instanceId: string): Promise<void> {
await this.ensureInitialized()
try {
const filename = this.instanceIdToFilename(instanceId)
await window.electronAPI.deleteInstanceFile(filename)
} catch (error) {
console.error(`Failed to delete instance data for ${instanceId}:`, error)
throw error
}
}
// Convert folder path to safe filename
private instanceIdToFilename(instanceId: string): string {
// Convert folder path to safe filename
// Replace path separators and other invalid characters
return instanceId
.replace(/[\\/]/g, "_") // Replace path separators
.replace(/[^a-zA-Z0-9_.-]/g, "_") // Replace other invalid chars
.replace(/_{2,}/g, "_") // Replace multiple underscores with single
.replace(/^_|_$/g, "") // Remove leading/trailing underscores
.toLowerCase()
}
// Config change listeners
onConfigChanged(listener: () => void): () => void {
this.configChangeListeners.add(listener)
return () => this.configChangeListeners.delete(listener)
}
}
// Singleton instance
export const storage = new FileStorage()

View File

@@ -0,0 +1,117 @@
import { createContext, createSignal, useContext, onMount, createEffect, type JSX } from "solid-js"
import { storage, type ConfigData } from "./storage"
interface ThemeContextValue {
isDark: () => boolean
toggleTheme: () => void
setTheme: (dark: boolean) => void
}
const ThemeContext = createContext<ThemeContextValue>()
function applyTheme(dark: boolean) {
if (dark) {
document.documentElement.setAttribute("data-theme", "dark")
return
}
document.documentElement.removeAttribute("data-theme")
}
export function ThemeProvider(props: { children: JSX.Element }) {
const systemPrefersDark = window.matchMedia("(prefers-color-scheme: dark)")
const [isDark, setIsDarkSignal] = createSignal(true) //systemPrefersDark.matches)
let themePreference: "system" | "dark" | "light" = "dark"
applyTheme(true) //systemPrefersDark.matches)
async function loadTheme() {
try {
const config = await storage.loadConfig()
const savedTheme = config.theme
let themeDark: boolean
if (savedTheme === "system") {
themePreference = "system"
themeDark = systemPrefersDark.matches
} else if (savedTheme === "dark") {
themePreference = "dark"
themeDark = true
} else if (savedTheme === "light") {
themePreference = "light"
themeDark = false
} else {
themePreference = "dark"
themeDark = true
}
setIsDarkSignal(themeDark)
applyTheme(themeDark)
} catch (error) {
console.warn("Failed to load theme from config:", error)
themePreference = "dark"
const themeDark = true
setIsDarkSignal(themeDark)
applyTheme(themeDark)
}
}
async function saveTheme(dark: boolean) {
try {
const config = await storage.loadConfig()
const nextPreference = dark ? "dark" : "light"
config.theme = nextPreference
themePreference = nextPreference
await storage.saveConfig(config)
} catch (error) {
console.warn("Failed to save theme to config:", error)
}
}
onMount(() => {
loadTheme()
const unsubscribe = storage.onConfigChanged(() => {
loadTheme()
})
// Listen for system theme changes
const handleSystemThemeChange = (event: MediaQueryListEvent) => {
if (themePreference === "system") {
setIsDarkSignal(event.matches)
applyTheme(event.matches)
}
}
systemPrefersDark.addEventListener("change", handleSystemThemeChange)
return () => {
unsubscribe()
systemPrefersDark.removeEventListener("change", handleSystemThemeChange)
}
})
createEffect(() => {
applyTheme(isDark())
})
const setTheme = (dark: boolean) => {
setIsDarkSignal(dark)
applyTheme(dark)
saveTheme(dark)
}
const toggleTheme = () => {
setTheme(!isDark())
}
return <ThemeContext.Provider value={{ isDark, toggleTheme, setTheme }}>{props.children}</ThemeContext.Provider>
}
export function useTheme() {
const context = useContext(ThemeContext)
if (!context) {
throw new Error("useTheme must be used within ThemeProvider")
}
return context
}

View File

@@ -0,0 +1,22 @@
import type { RenderCache } from "../types/message"
const toolRenderCache = new Map<string, RenderCache>()
export function getToolRenderCache(key?: string | null): RenderCache | undefined {
if (!key) return undefined
return toolRenderCache.get(key)
}
export function setToolRenderCache(key: string | undefined | null, cache?: RenderCache): void {
if (!key) return
if (cache) {
toolRenderCache.set(key, cache)
} else {
toolRenderCache.delete(key)
}
}
export function clearToolRenderCache(key?: string | null): void {
if (!key) return
toolRenderCache.delete(key)
}