Add CLI server and move UI to HTTP API
This commit is contained in:
@@ -4,6 +4,9 @@ import type { LspStatus, Permission } from "@opencode-ai/sdk"
|
||||
import type { ClientPart, Message } from "../types/message"
|
||||
import { sdkManager } from "../lib/sdk-manager"
|
||||
import { sseManager } from "../lib/sse-manager"
|
||||
import { cliApi } from "../lib/api-client"
|
||||
import { cliEvents } from "../lib/cli-events"
|
||||
import type { WorkspaceDescriptor, WorkspaceEventPayload, WorkspaceLogEntry } from "../../../cli/src/api-types"
|
||||
import {
|
||||
fetchSessions,
|
||||
fetchAgents,
|
||||
@@ -35,6 +38,133 @@ const [disconnectedInstance, setDisconnectedInstance] = createSignal<Disconnecte
|
||||
|
||||
const MAX_LOG_ENTRIES = 1000
|
||||
|
||||
function workspaceDescriptorToInstance(descriptor: WorkspaceDescriptor): Instance {
|
||||
const existing = instances().get(descriptor.id)
|
||||
return {
|
||||
id: descriptor.id,
|
||||
folder: descriptor.path,
|
||||
port: descriptor.port ?? existing?.port ?? 0,
|
||||
pid: descriptor.pid ?? existing?.pid ?? 0,
|
||||
status: descriptor.status,
|
||||
error: descriptor.error,
|
||||
client: existing?.client ?? null,
|
||||
metadata: existing?.metadata,
|
||||
binaryPath: descriptor.binaryLabel,
|
||||
environmentVariables: existing?.environmentVariables ?? preferences().environmentVariables ?? {},
|
||||
}
|
||||
}
|
||||
|
||||
function upsertWorkspace(descriptor: WorkspaceDescriptor) {
|
||||
const mapped = workspaceDescriptorToInstance(descriptor)
|
||||
if (instances().has(descriptor.id)) {
|
||||
updateInstance(descriptor.id, mapped)
|
||||
} else {
|
||||
addInstance(mapped)
|
||||
setHasInstances(true)
|
||||
}
|
||||
|
||||
if (descriptor.status === "ready" && descriptor.port) {
|
||||
attachClient(descriptor.id, descriptor.port)
|
||||
}
|
||||
}
|
||||
|
||||
function attachClient(instanceId: string, port: number) {
|
||||
const instance = instances().get(instanceId)
|
||||
if (!instance) return
|
||||
|
||||
if (instance.port === port && instance.client) {
|
||||
return
|
||||
}
|
||||
|
||||
if (instance.port && instance.client) {
|
||||
sdkManager.destroyClient(instance.port)
|
||||
sseManager.disconnect(instanceId)
|
||||
}
|
||||
|
||||
const client = sdkManager.createClient(port)
|
||||
updateInstance(instanceId, {
|
||||
client,
|
||||
port,
|
||||
status: "ready",
|
||||
})
|
||||
sseManager.connect(instanceId, port)
|
||||
void hydrateInstanceData(instanceId).catch((error) => {
|
||||
console.error("Failed to hydrate instance data", error)
|
||||
})
|
||||
}
|
||||
|
||||
function releaseInstanceResources(instanceId: string) {
|
||||
const instance = instances().get(instanceId)
|
||||
if (!instance) return
|
||||
|
||||
if (instance.port) {
|
||||
sdkManager.destroyClient(instance.port)
|
||||
}
|
||||
sseManager.disconnect(instanceId)
|
||||
}
|
||||
|
||||
async function hydrateInstanceData(instanceId: string) {
|
||||
try {
|
||||
await fetchSessions(instanceId)
|
||||
await fetchAgents(instanceId)
|
||||
await fetchProviders(instanceId)
|
||||
const instance = instances().get(instanceId)
|
||||
if (!instance?.client) return
|
||||
await fetchCommands(instanceId, instance.client)
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch initial data:", error)
|
||||
}
|
||||
}
|
||||
|
||||
void (async function initializeWorkspaces() {
|
||||
try {
|
||||
const workspaces = await cliApi.fetchWorkspaces()
|
||||
workspaces.forEach((workspace) => upsertWorkspace(workspace))
|
||||
if (workspaces.length === 0) {
|
||||
setHasInstances(false)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load workspaces", error)
|
||||
}
|
||||
})()
|
||||
|
||||
cliEvents.on("*", (event) => handleWorkspaceEvent(event))
|
||||
|
||||
function handleWorkspaceEvent(event: WorkspaceEventPayload) {
|
||||
switch (event.type) {
|
||||
case "workspace.created":
|
||||
upsertWorkspace(event.workspace)
|
||||
break
|
||||
case "workspace.started":
|
||||
upsertWorkspace(event.workspace)
|
||||
break
|
||||
case "workspace.error":
|
||||
upsertWorkspace(event.workspace)
|
||||
break
|
||||
case "workspace.stopped":
|
||||
releaseInstanceResources(event.workspaceId)
|
||||
removeInstance(event.workspaceId)
|
||||
if (instances().size === 0) {
|
||||
setHasInstances(false)
|
||||
}
|
||||
break
|
||||
case "workspace.log":
|
||||
handleWorkspaceLog(event.entry)
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
function handleWorkspaceLog(entry: WorkspaceLogEntry) {
|
||||
const logEntry: LogEntry = {
|
||||
timestamp: new Date(entry.timestamp).getTime(),
|
||||
level: (entry.level as LogEntry["level"]) ?? "info",
|
||||
message: entry.message,
|
||||
}
|
||||
addLog(entry.workspaceId, logEntry)
|
||||
}
|
||||
|
||||
function ensureLogContainer(id: string) {
|
||||
setInstanceLogs((prev) => {
|
||||
if (prev.has(id)) {
|
||||
@@ -157,61 +287,17 @@ function removeInstance(id: string) {
|
||||
}
|
||||
|
||||
async function createInstance(folder: string, binaryPath?: string): Promise<string> {
|
||||
const id = `instance-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
|
||||
|
||||
const instance: Instance = {
|
||||
id,
|
||||
folder,
|
||||
port: 0,
|
||||
pid: 0,
|
||||
status: "starting",
|
||||
client: null,
|
||||
environmentVariables: preferences().environmentVariables ?? {},
|
||||
}
|
||||
|
||||
addInstance(instance)
|
||||
|
||||
// Update last used binary
|
||||
if (binaryPath) {
|
||||
updateLastUsedBinary(binaryPath)
|
||||
}
|
||||
|
||||
try {
|
||||
const {
|
||||
id: returnedId,
|
||||
port,
|
||||
pid,
|
||||
binaryPath: actualBinaryPath,
|
||||
} = await window.electronAPI.createInstance(id, folder, binaryPath, preferences().environmentVariables)
|
||||
|
||||
const client = sdkManager.createClient(port)
|
||||
|
||||
updateInstance(id, {
|
||||
port,
|
||||
pid,
|
||||
client,
|
||||
status: "ready",
|
||||
binaryPath: actualBinaryPath,
|
||||
})
|
||||
|
||||
setActiveInstanceId(id)
|
||||
sseManager.connect(id, port)
|
||||
|
||||
try {
|
||||
await fetchSessions(id)
|
||||
await fetchAgents(id)
|
||||
await fetchProviders(id)
|
||||
await fetchCommands(id, client)
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch initial data:", error)
|
||||
}
|
||||
|
||||
return id
|
||||
const workspace = await cliApi.createWorkspace({ path: folder })
|
||||
upsertWorkspace(workspace)
|
||||
setActiveInstanceId(workspace.id)
|
||||
return workspace.id
|
||||
} catch (error) {
|
||||
updateInstance(id, {
|
||||
status: "error",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
console.error("Failed to create workspace", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -220,17 +306,18 @@ async function stopInstance(id: string) {
|
||||
const instance = instances().get(id)
|
||||
if (!instance) return
|
||||
|
||||
sseManager.disconnect(id)
|
||||
releaseInstanceResources(id)
|
||||
|
||||
if (instance.port) {
|
||||
sdkManager.destroyClient(instance.port)
|
||||
}
|
||||
|
||||
if (instance.pid) {
|
||||
await window.electronAPI.stopInstance(instance.pid)
|
||||
try {
|
||||
await cliApi.deleteWorkspace(id)
|
||||
} catch (error) {
|
||||
console.error("Failed to stop workspace", error)
|
||||
}
|
||||
|
||||
removeInstance(id)
|
||||
if (instances().size === 0) {
|
||||
setHasInstances(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchLspStatus(instanceId: string): Promise<LspStatus[] | undefined> {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { storage, type InstanceData } from "../lib/storage"
|
||||
import { storage } from "../lib/storage"
|
||||
|
||||
const MAX_HISTORY = 100
|
||||
|
||||
@@ -48,7 +48,8 @@ async function ensureHistoryLoaded(instanceId: string): Promise<void> {
|
||||
|
||||
try {
|
||||
const data = await storage.loadInstanceData(instanceId)
|
||||
instanceHistories.set(instanceId, data.messageHistory)
|
||||
const history = Array.isArray(data.messageHistory) ? data.messageHistory : []
|
||||
instanceHistories.set(instanceId, history)
|
||||
historyLoaded.add(instanceId)
|
||||
} catch (error) {
|
||||
console.warn("Failed to load history:", error)
|
||||
|
||||
@@ -17,12 +17,12 @@ export type ExpansionPreference = "expanded" | "collapsed"
|
||||
export interface Preferences {
|
||||
showThinkingBlocks: boolean
|
||||
lastUsedBinary?: string
|
||||
environmentVariables?: Record<string, string>
|
||||
modelRecents?: ModelPreference[]
|
||||
agentModelSelections?: AgentModelSelections
|
||||
diffViewMode?: DiffViewMode
|
||||
toolOutputExpansion?: ExpansionPreference
|
||||
diagnosticsExpansion?: ExpansionPreference
|
||||
environmentVariables: Record<string, string>
|
||||
modelRecents: ModelPreference[]
|
||||
agentModelSelections: AgentModelSelections
|
||||
diffViewMode: DiffViewMode
|
||||
toolOutputExpansion: ExpansionPreference
|
||||
diagnosticsExpansion: ExpansionPreference
|
||||
}
|
||||
|
||||
export interface OpenCodeBinary {
|
||||
@@ -41,6 +41,7 @@ const MAX_RECENT_MODELS = 5
|
||||
|
||||
const defaultPreferences: Preferences = {
|
||||
showThinkingBlocks: false,
|
||||
environmentVariables: {},
|
||||
modelRecents: [],
|
||||
agentModelSelections: {},
|
||||
diffViewMode: "split",
|
||||
@@ -48,12 +49,41 @@ const defaultPreferences: Preferences = {
|
||||
diagnosticsExpansion: "expanded",
|
||||
}
|
||||
|
||||
const [preferences, setPreferences] = createSignal<Preferences>(defaultPreferences)
|
||||
function normalizePreferences(pref?: Partial<Preferences>): Preferences {
|
||||
const environmentVariables = {
|
||||
...defaultPreferences.environmentVariables,
|
||||
...(pref?.environmentVariables ?? {}),
|
||||
}
|
||||
|
||||
const sourceModelRecents = pref?.modelRecents ?? defaultPreferences.modelRecents
|
||||
const modelRecents = sourceModelRecents.map((item) => ({ ...item }))
|
||||
|
||||
const sourceAgentSelections = pref?.agentModelSelections ?? defaultPreferences.agentModelSelections
|
||||
const agentModelSelections: AgentModelSelections = {}
|
||||
for (const [instanceId, selections] of Object.entries(sourceAgentSelections)) {
|
||||
agentModelSelections[instanceId] = Object.fromEntries(
|
||||
Object.entries(selections).map(([agentId, selection]) => [agentId, { ...selection }]),
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
showThinkingBlocks: pref?.showThinkingBlocks ?? defaultPreferences.showThinkingBlocks,
|
||||
lastUsedBinary: pref?.lastUsedBinary ?? defaultPreferences.lastUsedBinary,
|
||||
environmentVariables,
|
||||
modelRecents,
|
||||
agentModelSelections,
|
||||
diffViewMode: pref?.diffViewMode ?? defaultPreferences.diffViewMode,
|
||||
toolOutputExpansion: pref?.toolOutputExpansion ?? defaultPreferences.toolOutputExpansion,
|
||||
diagnosticsExpansion: pref?.diagnosticsExpansion ?? defaultPreferences.diagnosticsExpansion,
|
||||
}
|
||||
}
|
||||
|
||||
const [preferences, setPreferences] = createSignal<Preferences>(normalizePreferences())
|
||||
const [recentFolders, setRecentFolders] = createSignal<RecentFolder[]>([])
|
||||
const [opencodeBinaries, setOpenCodeBinaries] = createSignal<OpenCodeBinary[]>([])
|
||||
const [isConfigLoaded, setIsConfigLoaded] = createSignal(false)
|
||||
let cachedConfig: ConfigData = {
|
||||
preferences: defaultPreferences,
|
||||
preferences: normalizePreferences(),
|
||||
recentFolders: [],
|
||||
opencodeBinaries: [],
|
||||
}
|
||||
@@ -64,15 +94,15 @@ async function loadConfig(): Promise<void> {
|
||||
const config = await storage.loadConfig()
|
||||
cachedConfig = {
|
||||
...config,
|
||||
preferences: { ...defaultPreferences, ...config.preferences },
|
||||
recentFolders: config.recentFolders || [],
|
||||
opencodeBinaries: config.opencodeBinaries || [],
|
||||
preferences: normalizePreferences(config.preferences),
|
||||
recentFolders: config.recentFolders ?? [],
|
||||
opencodeBinaries: config.opencodeBinaries ?? [],
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load config:", error)
|
||||
cachedConfig = {
|
||||
...cachedConfig,
|
||||
preferences: { ...defaultPreferences },
|
||||
preferences: normalizePreferences(),
|
||||
recentFolders: [],
|
||||
opencodeBinaries: [],
|
||||
}
|
||||
@@ -112,7 +142,7 @@ async function ensureConfigLoaded(): Promise<void> {
|
||||
|
||||
|
||||
function updatePreferences(updates: Partial<Preferences>): void {
|
||||
const updated = { ...preferences(), ...updates }
|
||||
const updated = normalizePreferences({ ...preferences(), ...updates })
|
||||
setPreferences(updated)
|
||||
saveConfig().catch(console.error)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user