Add CLI server and move UI to HTTP API

This commit is contained in:
Shantur Rathore
2025-11-17 18:18:45 +00:00
parent 89bd32814f
commit 08d81f8bb5
40 changed files with 3153 additions and 462 deletions

View File

@@ -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> {

View File

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

View File

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