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

@@ -85,22 +85,16 @@ const App: Component = () => {
const clearLaunchError = () => setLaunchErrorBinary(null)
async function handleSelectFolder(folderPath?: string, binaryPath?: string) {
async function handleSelectFolder(folderPath: string, binaryPath?: string) {
if (!folderPath) {
return
}
setIsSelectingFolder(true)
const selectedBinary = binaryPath || preferences().lastUsedBinary || "opencode"
try {
let folder: string | null | undefined = folderPath
if (!folder) {
folder = await window.electronAPI.selectFolder()
if (!folder) {
return
}
}
addRecentFolder(folder)
addRecentFolder(folderPath)
clearLaunchError()
const instanceId = await createInstance(folder, selectedBinary)
const instanceId = await createInstance(folderPath, selectedBinary)
setHasInstances(true)
setShowFolderSelection(false)
setIsAdvancedSettingsOpen(false)
@@ -129,8 +123,6 @@ const App: Component = () => {
function handleNewInstanceRequest() {
if (hasInstances()) {
setShowFolderSelection(true)
} else {
void handleSelectFolder()
}
}

View File

@@ -1,6 +1,7 @@
import { Component, createSignal, createEffect, For, Show, onCleanup } from "solid-js"
import type { OpencodeClient } from "@opencode-ai/sdk/client"
import { cliApi } from "../lib/api-client"
interface FileItem {
path: string
@@ -17,7 +18,7 @@ interface FilePickerProps {
instanceClient: OpencodeClient
searchQuery: string
textareaRef?: HTMLTextAreaElement
workspaceFolder: string
workspaceId: string
}
const FilePicker: Component<FilePickerProps> = (props) => {
@@ -36,10 +37,10 @@ const FilePicker: Component<FilePickerProps> = (props) => {
try {
if (allFiles().length === 0) {
console.log(`[FilePicker] Scanning workspace: ${props.workspaceFolder}`)
const scannedPaths = await window.electronAPI.scanDirectory(props.workspaceFolder)
const scannedFiles: FileItem[] = scannedPaths.map((path) => ({
path,
console.log(`[FilePicker] Scanning workspace: ${props.workspaceId}`)
const entries = await cliApi.listWorkspaceFiles(props.workspaceId)
const scannedFiles: FileItem[] = entries.map<FileItem>((entry) => ({
path: entry.path,
isGitFile: false,
}))
setAllFiles(scannedFiles)

View File

@@ -0,0 +1,297 @@
import { Component, Show, For, createSignal, createMemo, createEffect, onCleanup } from "solid-js"
import { Folder as FolderIcon, File as FileIcon, Loader2, Search, X } from "lucide-solid"
import type { FileSystemEntry } from "../../../cli/src/api-types"
import { cliApi } from "../lib/api-client"
import { getServerMeta } from "../lib/server-meta"
const MAX_RESULTS = 200
let cachedEntries: FileSystemEntry[] | null = null
let entriesPromise: Promise<FileSystemEntry[]> | null = null
async function loadFileSystemEntries(): Promise<FileSystemEntry[]> {
if (cachedEntries) {
return cachedEntries
}
if (entriesPromise) {
return entriesPromise
}
entriesPromise = cliApi
.listFileSystem(".")
.then((entries) => {
cachedEntries = entries.slice().sort((a, b) => a.path.localeCompare(b.path))
entriesPromise = null
return cachedEntries
})
.catch((error) => {
entriesPromise = null
throw error
})
return entriesPromise
}
function resolveAbsolutePath(root: string, relativePath: string): string {
if (!root) {
return relativePath
}
if (!relativePath || relativePath === "." || relativePath === "./") {
return root
}
const separator = root.includes("\\") ? "\\" : "/"
const trimmedRoot = root.endsWith(separator) ? root : `${root}${separator}`
const normalized = relativePath.replace(/[\\/]+/g, separator).replace(/^[\\/]+/, "")
return `${trimmedRoot}${normalized}`
}
function formatRootLabel(root: string): string {
if (!root) return "Workspace Root"
const parts = root.split(/[/\\]/).filter(Boolean)
return parts[parts.length - 1] || root || "Workspace Root"
}
interface FileSystemBrowserDialogProps {
open: boolean
mode: "directories" | "files"
title: string
description?: string
onSelect: (absolutePath: string) => void
onClose: () => void
}
const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props) => {
const [entries, setEntries] = createSignal<FileSystemEntry[]>([])
const [rootPath, setRootPath] = createSignal("")
const [loading, setLoading] = createSignal(false)
const [error, setError] = createSignal<string | null>(null)
const [searchQuery, setSearchQuery] = createSignal("")
const [selectedIndex, setSelectedIndex] = createSignal(0)
let searchInputRef: HTMLInputElement | undefined
async function refreshEntries() {
setLoading(true)
setError(null)
try {
const [items, meta] = await Promise.all([loadFileSystemEntries(), getServerMeta()])
setEntries(items)
setRootPath(meta.workspaceRoot)
} catch (err) {
const message = err instanceof Error ? err.message : "Unable to load filesystem"
setError(message)
} finally {
setLoading(false)
}
}
const filteredEntries = createMemo(() => {
const query = searchQuery().trim().toLowerCase()
const mode = props.mode
const root = rootPath()
const matchesType = entries().filter((entry) => (mode === "directories" ? entry.type === "directory" : entry.type === "file"))
const baseEntries = mode === "directories" && root
? [
{
name: formatRootLabel(root),
path: ".",
type: "directory" as const,
},
...matchesType,
]
: matchesType
if (!query) {
return baseEntries
}
return baseEntries.filter((entry) => {
const absolute = resolveAbsolutePath(root, entry.path)
return absolute.toLowerCase().includes(query) || entry.name.toLowerCase().includes(query)
})
})
const visibleEntries = createMemo(() => filteredEntries().slice(0, MAX_RESULTS))
createEffect(() => {
const list = visibleEntries()
if (list.length === 0) {
setSelectedIndex(0)
return
}
if (selectedIndex() >= list.length) {
setSelectedIndex(list.length - 1)
}
})
createEffect(() => {
if (!props.open) {
return
}
setSearchQuery("")
setSelectedIndex(0)
void refreshEntries()
setTimeout(() => searchInputRef?.focus(), 50)
const handleKeyDown = (event: KeyboardEvent) => {
if (!props.open) return
const results = visibleEntries()
if (event.key === "Escape") {
event.preventDefault()
props.onClose()
return
}
if (results.length === 0) {
return
}
if (event.key === "ArrowDown") {
event.preventDefault()
setSelectedIndex((prev) => Math.min(prev + 1, results.length - 1))
} else if (event.key === "ArrowUp") {
event.preventDefault()
setSelectedIndex((prev) => Math.max(prev - 1, 0))
} else if (event.key === "Enter") {
event.preventDefault()
const entry = results[selectedIndex()]
if (entry) {
handleEntrySelect(entry)
}
}
}
window.addEventListener("keydown", handleKeyDown)
onCleanup(() => {
window.removeEventListener("keydown", handleKeyDown)
})
})
function handleEntrySelect(entry: FileSystemEntry) {
const absolute = resolveAbsolutePath(rootPath(), entry.path)
props.onSelect(absolute)
}
function handleOverlayClick(event: MouseEvent) {
if (event.target === event.currentTarget) {
props.onClose()
}
}
return (
<Show when={props.open}>
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-6" onClick={handleOverlayClick}>
<div class="modal-surface max-h-full w-full max-w-3xl overflow-hidden rounded-xl bg-surface p-0" role="dialog" aria-modal="true">
<div class="panel flex flex-col">
<div class="panel-header flex items-start justify-between gap-4">
<div>
<h3 class="panel-title">{props.title}</h3>
<p class="panel-subtitle">
{props.description || "Search for a path under the configured workspace root."}
</p>
<Show when={rootPath()}>
<p class="text-xs text-muted mt-1 font-mono break-all">Root: {rootPath()}</p>
</Show>
</div>
<button type="button" class="selector-button selector-button-secondary" onClick={props.onClose}>
<X class="w-4 h-4" />
Close
</button>
</div>
<div class="panel-body">
<label class="w-full text-sm text-secondary mb-2 block">Filter</label>
<div class="selector-input-group">
<div class="flex items-center gap-2 px-3 text-muted">
<Search class="w-4 h-4" />
</div>
<input
ref={(el) => {
searchInputRef = el
}}
type="text"
value={searchQuery()}
onInput={(event) => setSearchQuery(event.currentTarget.value)}
placeholder={props.mode === "directories" ? "Search for folders" : "Search for files"}
class="selector-input"
/>
</div>
</div>
<div class="panel-list panel-list--fill max-h-96 overflow-auto">
<Show
when={!loading() && !error()}
fallback={
<div class="flex items-center justify-center py-6 text-sm text-secondary">
<Show
when={loading()}
fallback={<span class="text-red-500">{error()}</span>}
>
<div class="flex items-center gap-2">
<Loader2 class="w-4 h-4 animate-spin" />
<span>Loading filesystem</span>
</div>
</Show>
</div>
}
>
<Show
when={visibleEntries().length > 0}
fallback={
<div class="flex flex-col items-center justify-center gap-2 py-10 text-sm text-secondary">
<p>No matches.</p>
<Show when={searchQuery().trim().length === 0}>
<button type="button" class="selector-button selector-button-secondary" onClick={refreshEntries}>
Retry
</button>
</Show>
</div>
}
>
<For each={visibleEntries()}>
{(entry, index) => (
<button
type="button"
class="panel-list-item flex items-center gap-3 text-left"
classList={{ "panel-list-item-highlight": selectedIndex() === index() }}
onMouseEnter={() => setSelectedIndex(index())}
onClick={() => handleEntrySelect(entry)}
>
<div class="flex h-8 w-8 items-center justify-center rounded-md bg-surface-secondary text-muted">
<Show when={entry.type === "directory"} fallback={<FileIcon class="w-4 h-4" />}>
<FolderIcon class="w-4 h-4" />
</Show>
</div>
<div class="flex flex-col">
<span class="text-sm font-medium text-primary">{entry.name || entry.path}</span>
<span class="text-xs font-mono text-muted">{resolveAbsolutePath(rootPath(), entry.path)}</span>
</div>
</button>
)}
</For>
</Show>
</Show>
</div>
<div class="panel-footer">
<div class="panel-footer-hints">
<div class="flex items-center gap-1.5">
<kbd class="kbd"></kbd>
<kbd class="kbd"></kbd>
<span>Navigate</span>
</div>
<div class="flex items-center gap-1.5">
<kbd class="kbd">Enter</kbd>
<span>Select</span>
</div>
<div class="flex items-center gap-1.5">
<kbd class="kbd">Esc</kbd>
<span>Close</span>
</div>
</div>
</div>
</div>
</div>
</div>
</Show>
)
}
export default FileSystemBrowserDialog

View File

@@ -2,12 +2,13 @@ import { Component, createSignal, Show, For, onMount, onCleanup, createEffect }
import { Folder, Clock, Trash2, FolderPlus, Settings, ChevronRight } from "lucide-solid"
import { useConfig } from "../stores/preferences"
import AdvancedSettingsModal from "./advanced-settings-modal"
import FileSystemBrowserDialog from "./filesystem-browser-dialog"
import Kbd from "./kbd"
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
interface FolderSelectionViewProps {
onSelectFolder: (folder?: string, binaryPath?: string) => void
onSelectFolder: (folder: string, binaryPath?: string) => void
isLoading?: boolean
advancedSettingsOpen?: boolean
onAdvancedSettingsOpen?: () => void
@@ -19,6 +20,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
const [selectedIndex, setSelectedIndex] = createSignal(0)
const [focusMode, setFocusMode] = createSignal<"recent" | "new" | null>("recent")
const [selectedBinary, setSelectedBinary] = createSignal(preferences().lastUsedBinary || "opencode")
const [isFolderBrowserOpen, setIsFolderBrowserOpen] = createSignal(false)
let recentListRef: HTMLDivElement | undefined
const folders = () => recentFolders()
@@ -173,12 +175,17 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
function handleBrowse() {
if (isLoading()) return
updateLastUsedBinary(selectedBinary())
props.onSelectFolder(undefined, selectedBinary())
setFocusMode("new")
setIsFolderBrowserOpen(true)
}
function handleBrowserSelect(path: string) {
setIsFolderBrowserOpen(false)
handleFolderSelect(path)
}
function handleBinaryChange(binary: string) {
setSelectedBinary(binary)
}
@@ -378,6 +385,15 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
onBinaryChange={handleBinaryChange}
isLoading={props.isLoading}
/>
<FileSystemBrowserDialog
open={isFolderBrowserOpen()}
mode="directories"
title="Browse for Folder"
description="Select any directory exposed by the CLI server."
onClose={() => setIsFolderBrowserOpen(false)}
onSelect={handleBrowserSelect}
/>
</>
)
}

View File

@@ -1,6 +1,8 @@
import { Component, For, Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
import { FolderOpen, Trash2, Check, AlertCircle, Loader2, Plus } from "lucide-solid"
import { useConfig } from "../stores/preferences"
import { cliApi } from "../lib/api-client"
import FileSystemBrowserDialog from "./filesystem-browser-dialog"
interface BinaryOption {
path: string
@@ -29,6 +31,7 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
const [validationError, setValidationError] = createSignal<string | null>(null)
const [versionInfo, setVersionInfo] = createSignal<Map<string, string>>(new Map<string, string>())
const [validatingPaths, setValidatingPaths] = createSignal<Set<string>>(new Set<string>())
const [isBinaryBrowserOpen, setIsBinaryBrowserOpen] = createSignal(false)
const binaries = () => opencodeBinaries()
const lastUsedBinary = () => preferences().lastUsedBinary
@@ -102,7 +105,7 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
setValidating(true)
setValidationError(null)
const result = await window.electronAPI.validateOpenCodeBinary(path)
const result = await cliApi.validateBinary(path)
if (result.valid && result.version) {
const updatedVersionInfo = new Map(versionInfo())
@@ -125,18 +128,12 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
}
}
async function handleBrowseBinary() {
try {
const path = await window.electronAPI.selectOpenCodeBinary()
if (!path) return
setCustomPath(path)
await handleValidateAndAdd(path)
} catch (error) {
setValidationError(error instanceof Error ? error.message : "Failed to select binary")
}
function handleBrowseBinary() {
if (props.disabled) return
setValidationError(null)
setIsBinaryBrowserOpen(true)
}
async function handleValidateAndAdd(path: string) {
const validation = await validateBinary(path)
@@ -150,8 +147,15 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
setValidationError(validation.error || "Invalid OpenCode binary")
}
}
function handleBinaryBrowserSelect(path: string) {
setIsBinaryBrowserOpen(false)
setCustomPath(path)
void handleValidateAndAdd(path)
}
async function handleCustomPathSubmit() {
const path = customPath().trim()
if (!path) return
await handleValidateAndAdd(path)
@@ -197,128 +201,140 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
const isPathValidating = (path: string) => validatingPaths().has(path)
return (
<div class="panel">
<div class="panel-header flex items-center justify-between gap-3">
<div>
<h3 class="panel-title">OpenCode Binary</h3>
<p class="panel-subtitle">Choose which executable OpenCode should run</p>
</div>
<Show when={validating()}>
<div class="selector-loading text-xs">
<Loader2 class="selector-loading-spinner" />
<span>Checking versions</span>
<>
<div class="panel">
<div class="panel-header flex items-center justify-between gap-3">
<div>
<h3 class="panel-title">OpenCode Binary</h3>
<p class="panel-subtitle">Choose which executable OpenCode should run</p>
</div>
<Show when={validating()}>
<div class="selector-loading text-xs">
<Loader2 class="selector-loading-spinner" />
<span>Checking versions</span>
</div>
</Show>
</div>
<div class="panel-body space-y-3">
<div class="selector-input-group">
<input
type="text"
value={customPath()}
onInput={(e) => setCustomPath(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault()
handleCustomPathSubmit()
}
}}
disabled={props.disabled}
placeholder="Enter path to opencode binary…"
class="selector-input"
/>
<button
type="button"
onClick={handleCustomPathSubmit}
disabled={props.disabled || !customPath().trim()}
class="selector-button selector-button-primary"
>
<Plus class="w-4 h-4" />
Add
</button>
</div>
</Show>
</div>
<div class="panel-body space-y-3">
<div class="selector-input-group">
<input
type="text"
value={customPath()}
onInput={(e) => setCustomPath(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault()
handleCustomPathSubmit()
}
}}
disabled={props.disabled}
placeholder="Enter path to opencode binary…"
class="selector-input"
/>
<button
type="button"
onClick={handleCustomPathSubmit}
disabled={props.disabled || !customPath().trim()}
class="selector-button selector-button-primary"
onClick={handleBrowseBinary}
disabled={props.disabled}
class="selector-button selector-button-secondary w-full flex items-center justify-center gap-2"
>
<Plus class="w-4 h-4" />
Add
<FolderOpen class="w-4 h-4" />
Browse for Binary
</button>
<Show when={validationError()}>
<div class="selector-validation-error">
<div class="selector-validation-error-content">
<AlertCircle class="selector-validation-error-icon" />
<span class="selector-validation-error-text">{validationError()}</span>
</div>
</div>
</Show>
</div>
<button
type="button"
onClick={handleBrowseBinary}
disabled={props.disabled}
class="selector-button selector-button-secondary w-full flex items-center justify-center gap-2"
>
<FolderOpen class="w-4 h-4" />
Browse for Binary
</button>
<div class="panel-list panel-list--fill max-h-80 overflow-y-auto">
<For each={binaryOptions()}>
{(binary) => {
const isDefault = binary.isDefault
const versionLabel = () => versionInfo().get(binary.path) ?? binary.version
<Show when={validationError()}>
<div class="selector-validation-error">
<div class="selector-validation-error-content">
<AlertCircle class="selector-validation-error-icon" />
<span class="selector-validation-error-text">{validationError()}</span>
</div>
</div>
</Show>
</div>
<div class="panel-list panel-list--fill max-h-80 overflow-y-auto">
<For each={binaryOptions()}>
{(binary) => {
const isDefault = binary.isDefault
const versionLabel = () => versionInfo().get(binary.path) ?? binary.version
return (
<div
class="panel-list-item flex items-center"
classList={{ "panel-list-item-highlight": currentSelectionPath() === binary.path }}
>
<button
type="button"
class="panel-list-item-content flex-1"
onClick={() => handleSelectBinary(binary.path)}
disabled={props.disabled}
return (
<div
class="panel-list-item flex items-center"
classList={{ "panel-list-item-highlight": currentSelectionPath() === binary.path }}
>
<div class="flex flex-col flex-1 min-w-0 gap-1.5">
<div class="flex items-center gap-2">
<Check
class={`w-4 h-4 transition-opacity ${currentSelectionPath() === binary.path ? "opacity-100" : "opacity-0"}`}
/>
<span class="text-sm font-medium truncate text-primary">{getDisplayName(binary.path)}</span>
</div>
<Show when={!isDefault}>
<div class="text-xs font-mono truncate pl-6 text-muted">{binary.path}</div>
</Show>
<div class="flex items-center gap-2 text-xs text-muted pl-6 flex-wrap">
<Show when={versionLabel()}>
<span class="selector-badge-version">v{versionLabel()}</span>
</Show>
<Show when={isPathValidating(binary.path)}>
<span class="selector-badge-time">Checking</span>
</Show>
<Show when={!isDefault && binary.lastUsed}>
<span class="selector-badge-time">{formatRelativeTime(binary.lastUsed)}</span>
</Show>
<Show when={isDefault}>
<span class="selector-badge-time">Use binary from system PATH</span>
</Show>
</div>
</div>
</button>
<Show when={!isDefault}>
<button
type="button"
class="p-2 text-muted hover:text-primary"
onClick={(event) => handleRemoveBinary(binary.path, event)}
class="panel-list-item-content flex-1"
onClick={() => handleSelectBinary(binary.path)}
disabled={props.disabled}
title="Remove binary"
>
<Trash2 class="w-3.5 h-3.5" />
<div class="flex flex-col flex-1 min-w-0 gap-1.5">
<div class="flex items-center gap-2">
<Check
class={`w-4 h-4 transition-opacity ${currentSelectionPath() === binary.path ? "opacity-100" : "opacity-0"}`}
/>
<span class="text-sm font-medium truncate text-primary">{getDisplayName(binary.path)}</span>
</div>
<Show when={!isDefault}>
<div class="text-xs font-mono truncate pl-6 text-muted">{binary.path}</div>
</Show>
<div class="flex items-center gap-2 text-xs text-muted pl-6 flex-wrap">
<Show when={versionLabel()}>
<span class="selector-badge-version">v{versionLabel()}</span>
</Show>
<Show when={isPathValidating(binary.path)}>
<span class="selector-badge-time">Checking</span>
</Show>
<Show when={!isDefault && binary.lastUsed}>
<span class="selector-badge-time">{formatRelativeTime(binary.lastUsed)}</span>
</Show>
<Show when={isDefault}>
<span class="selector-badge-time">Use binary from system PATH</span>
</Show>
</div>
</div>
</button>
</Show>
</div>
)
}}
</For>
<Show when={!isDefault}>
<button
type="button"
class="p-2 text-muted hover:text-primary"
onClick={(event) => handleRemoveBinary(binary.path, event)}
disabled={props.disabled}
title="Remove binary"
>
<Trash2 class="w-3.5 h-3.5" />
</button>
</Show>
</div>
)
}}
</For>
</div>
</div>
</div>
<FileSystemBrowserDialog
open={isBinaryBrowserOpen()}
mode="files"
title="Select OpenCode Binary"
description="Browse files exposed by the CLI server."
onClose={() => setIsBinaryBrowserOpen(false)}
onSelect={handleBinaryBrowserSelect}
/>
</>
)
}
export default OpenCodeBinarySelector

View File

@@ -26,7 +26,7 @@ export default function PromptInput(props: PromptInputProps) {
const [history, setHistory] = createSignal<string[]>([])
const [historyIndex, setHistoryIndex] = createSignal(-1)
const [historyDraft, setHistoryDraft] = createSignal<string | null>(null)
const [isFocused, setIsFocused] = createSignal(false)
const [, setIsFocused] = createSignal(false)
const [showPicker, setShowPicker] = createSignal(false)
const [searchQuery, setSearchQuery] = createSignal("")
const [atPosition, setAtPosition] = createSignal<number | null>(null)
@@ -744,7 +744,7 @@ export default function PromptInput(props: PromptInputProps) {
instanceClient={instance()!.client}
searchQuery={searchQuery()}
textareaRef={textareaRef}
workspaceFolder={props.instanceFolder}
workspaceId={props.instanceId}
/>
</Show>

View File

@@ -1,6 +1,7 @@
import { Component, createSignal, createEffect, For, Show, onCleanup } from "solid-js"
import type { Agent } from "../types/session"
import type { OpencodeClient } from "@opencode-ai/sdk/client"
import { cliApi } from "../lib/api-client"
interface FileItem {
path: string
@@ -19,7 +20,7 @@ interface UnifiedPickerProps {
instanceClient: OpencodeClient | null
searchQuery: string
textareaRef?: HTMLTextAreaElement
workspaceFolder: string
workspaceId: string
}
const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
@@ -38,9 +39,9 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
try {
if (allFiles().length === 0) {
const scannedPaths = await window.electronAPI.scanDirectory(props.workspaceFolder)
const scannedFiles: FileItem[] = scannedPaths.map((path) => ({
path,
const entries = await cliApi.listWorkspaceFiles(props.workspaceId)
const scannedFiles: FileItem[] = entries.map<FileItem>((entry) => ({
path: entry.path,
isGitFile: false,
}))
setAllFiles(scannedFiles)

View File

@@ -0,0 +1,143 @@
import type {
AppConfig,
AppConfigUpdateRequest,
BinaryCreateRequest,
BinaryListResponse,
BinaryUpdateRequest,
BinaryValidationResult,
FileSystemEntry,
InstanceData,
ServerMeta,
WorkspaceCreateRequest,
WorkspaceDescriptor,
WorkspaceFileResponse,
WorkspaceLogEntry,
WorkspaceEventPayload,
WorkspaceEventType,
} from "../../../cli/src/api-types"
const DEFAULT_BASE = typeof window !== "undefined" ? window.__CODENOMAD_API_BASE__ ?? "" : ""
const DEFAULT_EVENTS_URL = typeof window !== "undefined" ? window.__CODENOMAD_EVENTS_URL__ ?? "/api/events" : "/api/events"
const API_BASE = import.meta.env.VITE_CODENOMAD_API_BASE ?? DEFAULT_BASE
const EVENTS_URL = API_BASE ? `${API_BASE}${DEFAULT_EVENTS_URL}` : DEFAULT_EVENTS_URL
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const url = API_BASE ? new URL(path, API_BASE).toString() : path
const headers: HeadersInit = {
"Content-Type": "application/json",
...(init?.headers ?? {}),
}
const response = await fetch(url, { ...init, headers })
if (!response.ok) {
const message = await response.text()
throw new Error(message || `Request failed with ${response.status}`)
}
if (response.status === 204) {
return undefined as T
}
return (await response.json()) as T
}
export const cliApi = {
fetchWorkspaces(): Promise<WorkspaceDescriptor[]> {
return request<WorkspaceDescriptor[]>("/api/workspaces")
},
createWorkspace(payload: WorkspaceCreateRequest): Promise<WorkspaceDescriptor> {
return request<WorkspaceDescriptor>("/api/workspaces", {
method: "POST",
body: JSON.stringify(payload),
})
},
fetchServerMeta(): Promise<ServerMeta> {
return request<ServerMeta>("/api/meta")
},
deleteWorkspace(id: string): Promise<void> {
return request(`/api/workspaces/${encodeURIComponent(id)}`, { method: "DELETE" })
},
listWorkspaceFiles(id: string, relativePath = "."): Promise<FileSystemEntry[]> {
const params = new URLSearchParams({ path: relativePath })
return request<FileSystemEntry[]>(`/api/workspaces/${encodeURIComponent(id)}/files?${params.toString()}`)
},
readWorkspaceFile(id: string, relativePath: string): Promise<WorkspaceFileResponse> {
const params = new URLSearchParams({ path: relativePath })
return request<WorkspaceFileResponse>(
`/api/workspaces/${encodeURIComponent(id)}/files/content?${params.toString()}`,
)
},
fetchConfig(): Promise<AppConfig> {
return request<AppConfig>("/api/config/app")
},
updateConfig(payload: AppConfig): Promise<AppConfig> {
return request<AppConfig>("/api/config/app", {
method: "PUT",
body: JSON.stringify(payload),
})
},
patchConfig(payload: AppConfigUpdateRequest): Promise<AppConfig> {
return request<AppConfig>("/api/config/app", {
method: "PATCH",
body: JSON.stringify(payload),
})
},
listBinaries(): Promise<BinaryListResponse> {
return request<BinaryListResponse>("/api/config/binaries")
},
createBinary(payload: BinaryCreateRequest) {
return request<{ binary: BinaryListResponse["binaries"][number] }>("/api/config/binaries", {
method: "POST",
body: JSON.stringify(payload),
})
},
updateBinary(id: string, updates: BinaryUpdateRequest) {
return request<{ binary: BinaryListResponse["binaries"][number] }>(`/api/config/binaries/${encodeURIComponent(id)}`, {
method: "PATCH",
body: JSON.stringify(updates),
})
},
deleteBinary(id: string): Promise<void> {
return request(`/api/config/binaries/${encodeURIComponent(id)}`, { method: "DELETE" })
},
validateBinary(path: string): Promise<BinaryValidationResult> {
return request<BinaryValidationResult>("/api/config/binaries/validate", {
method: "POST",
body: JSON.stringify({ path }),
})
},
listFileSystem(relativePath = "."): Promise<FileSystemEntry[]> {
const params = new URLSearchParams({ path: relativePath })
return request<FileSystemEntry[]>(`/api/filesystem?${params.toString()}`)
},
readInstanceData(id: string): Promise<InstanceData> {
return request<InstanceData>(`/api/storage/instances/${encodeURIComponent(id)}`)
},
writeInstanceData(id: string, data: InstanceData): Promise<void> {
return request(`/api/storage/instances/${encodeURIComponent(id)}`, {
method: "PUT",
body: JSON.stringify(data),
})
},
deleteInstanceData(id: string): Promise<void> {
return request(`/api/storage/instances/${encodeURIComponent(id)}`, { method: "DELETE" })
},
connectEvents(onEvent: (event: WorkspaceEventPayload) => void, onError?: () => void) {
const source = new EventSource(EVENTS_URL)
source.onmessage = (event) => {
try {
const payload = JSON.parse(event.data) as WorkspaceEventPayload
onEvent(payload)
} catch (error) {
console.error("Failed to parse SSE event", error)
}
}
source.onerror = () => {
onError?.()
}
return source
},
}
export type { WorkspaceDescriptor, WorkspaceLogEntry, WorkspaceEventPayload, WorkspaceEventType }

View File

@@ -0,0 +1,52 @@
import type { WorkspaceEventPayload, WorkspaceEventType } from "../../../cli/src/api-types"
import { cliApi } from "./api-client"
const RETRY_BASE_DELAY = 1000
const RETRY_MAX_DELAY = 10000
class CliEvents {
private handlers = new Map<WorkspaceEventType | "*", Set<(event: WorkspaceEventPayload) => void>>()
private source: EventSource | null = null
private retryDelay = RETRY_BASE_DELAY
constructor() {
this.connect()
}
private connect() {
if (this.source) {
this.source.close()
}
this.source = cliApi.connectEvents((event) => this.dispatch(event), () => this.scheduleReconnect())
this.source.onopen = () => {
this.retryDelay = RETRY_BASE_DELAY
}
}
private scheduleReconnect() {
if (this.source) {
this.source.close()
this.source = null
}
setTimeout(() => {
this.retryDelay = Math.min(this.retryDelay * 2, RETRY_MAX_DELAY)
this.connect()
}, this.retryDelay)
}
private dispatch(event: WorkspaceEventPayload) {
this.handlers.get("*")?.forEach((handler) => handler(event))
this.handlers.get(event.type)?.forEach((handler) => handler(event))
}
on(type: WorkspaceEventType | "*", handler: (event: WorkspaceEventPayload) => void): () => void {
if (!this.handlers.has(type)) {
this.handlers.set(type, new Set())
}
const bucket = this.handlers.get(type)!
bucket.add(handler)
return () => bucket.delete(handler)
}
}
export const cliEvents = new CliEvents()

View File

@@ -7,7 +7,6 @@ import { registerEscapeShortcut, setEscapeStateChangeHandler } from "../shortcut
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 {
@@ -148,29 +147,6 @@ export function useAppLifecycle(options: UseAppLifecycleOptions) {
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,20 @@
import type { ServerMeta } from "../../../cli/src/api-types"
import { cliApi } from "./api-client"
let cachedMeta: ServerMeta | null = null
let pendingMeta: Promise<ServerMeta> | null = null
export async function getServerMeta(): Promise<ServerMeta> {
if (cachedMeta) {
return cachedMeta
}
if (pendingMeta) {
return pendingMeta
}
pendingMeta = cliApi.fetchServerMeta().then((meta) => {
cachedMeta = meta
pendingMeta = null
return meta
})
return pendingMeta
}

View File

@@ -1,162 +1,48 @@
import type { Preferences, RecentFolder, OpenCodeBinary } from "../stores/preferences"
import type { AppConfig, InstanceData } from "../../../cli/src/api-types"
import { cliApi } from "./api-client"
import { cliEvents } from "./cli-events"
export interface ConfigData {
preferences: Preferences
recentFolders: RecentFolder[]
opencodeBinaries: OpenCodeBinary[]
theme?: "light" | "dark" | "system"
}
export type ConfigData = AppConfig
export interface InstanceData {
messageHistory: string[]
}
export class FileStorage {
private configPath: string | undefined
private instancesDir: string | undefined
export class ServerStorage {
private configChangeListeners: Set<() => void> = new Set()
private initialized = false
constructor() {
this.initialize()
cliEvents.on("config.appChanged", () => this.notifyConfigChanged())
}
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: [],
}
}
const config = await cliApi.fetchConfig()
return config
}
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
}
await cliApi.updateConfig(config)
}
// 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: [],
}
}
return cliApi.readInstanceData(instanceId)
}
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
}
await cliApi.writeInstanceData(instanceId, data)
}
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
}
await cliApi.deleteInstanceData(instanceId)
}
// 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)
}
private notifyConfigChanged() {
for (const listener of this.configChangeListeners) {
listener()
}
}
}
// Singleton instance
export const storage = new FileStorage()
export const storage = new ServerStorage()

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

View File

@@ -1,31 +0,0 @@
export interface ElectronAPI {
selectFolder: () => Promise<string | null>
createInstance: (
id: string,
folder: string,
binaryPath?: string,
environmentVariables?: Record<string, string>,
) => Promise<{ id: string; port: number; pid: number; binaryPath: string }>
stopInstance: (pid: number) => Promise<void>
onInstanceStarted: (callback: (data: { id: string; port: number; pid: number; binaryPath: string }) => void) => void
onInstanceError: (callback: (data: { id: string; error: string }) => void) => void
onInstanceStopped: (callback: (data: { id: string }) => void) => void
onInstanceLog: (
callback: (data: {
id: string
entry: { timestamp: number; level: "info" | "error" | "warn" | "debug"; message: string }
}) => void,
) => void
onNewInstance: (callback: () => void) => void
scanDirectory: (workspaceFolder: string) => Promise<string[]>
selectOpenCodeBinary: () => Promise<string | null>
validateOpenCodeBinary: (path: string) => Promise<{ valid: boolean; version?: string; error?: string }>
getConfigPath: () => Promise<string>
getInstancesDir: () => Promise<string>
readConfigFile: () => Promise<string>
writeConfigFile: (content: string) => Promise<void>
readInstanceFile: (instanceId: string) => Promise<string>
writeInstanceFile: (instanceId: string, content: string) => Promise<void>
deleteInstanceFile: (instanceId: string) => Promise<void>
onConfigChanged: (callback: () => void) => () => void
}

View File

@@ -1,9 +0,0 @@
import type { ElectronAPI } from "./electron-api"
declare global {
interface Window {
electronAPI: ElectronAPI
}
}
export {}

8
packages/ui/src/types/global.d.ts vendored Normal file
View File

@@ -0,0 +1,8 @@
export {}
declare global {
interface Window {
__CODENOMAD_API_BASE__?: string
__CODENOMAD_EVENTS_URL__?: string
}
}

1
packages/ui/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />