fix: restore complete source code and fix launchers

- Copy complete source code packages from original CodeNomad project
- Add root package.json with npm workspace configuration
- Include electron-app, server, ui, tauri-app, and opencode-config packages
- Fix Launch-Windows.bat and Launch-Dev-Windows.bat to work with correct npm scripts
- Fix Launch-Unix.sh to work with correct npm scripts
- Launchers now correctly call npm run dev:electron which launches Electron app
This commit is contained in:
Gemini AI
2025-12-23 12:57:55 +04:00
Unverified
parent f365d64c97
commit b448d11991
249 changed files with 48776 additions and 0 deletions

27
package.json Normal file
View File

@@ -0,0 +1,27 @@
{
"name": "codenomad-workspace",
"version": "0.4.0",
"private": true,
"description": "CodeNomad monorepo workspace",
"workspaces": {
"packages": [
"packages/*"
]
},
"scripts": {
"dev": "npm run dev --workspace @neuralnomads/codenomad-electron-app",
"dev:electron": "npm run dev --workspace @neuralnomads/codenomad-electron-app",
"dev:tauri": "npm run dev --workspace @codenomad/tauri-app",
"build": "npm run build --workspace @neuralnomads/codenomad-electron-app",
"build:tauri": "npm run build --workspace @codenomad/tauri-app",
"build:ui": "npm run build --workspace @codenomad/ui",
"build:mac-x64": "npm run build:mac-x64 --workspace @neuralnomads/codenomad-electron-app",
"build:binaries": "npm run build:binaries --workspace @neuralnomads/codenomad-electron-app",
"typecheck": "npm run typecheck --workspace @codenomad/ui && npm run typecheck --workspace @neuralnomads/codenomad-electron-app",
"bumpVersion": "npm version --workspaces --include-workspace-root --no-git-tag-version"
},
"dependencies": {
"7zip-bin": "^5.2.0",
"google-auth-library": "^10.5.0"
}
}

4
packages/electron-app/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
node_modules/
dist/
release/
.vite/

View File

@@ -0,0 +1,40 @@
# CodeNomad App
This package contains the native desktop application shell for CodeNomad, built with [Electron](https://www.electronjs.org/).
## Overview
The Electron app wraps the CodeNomad UI and Server into a standalone executable. It provides deeper system integration, such as:
- Native window management
- Global keyboard shortcuts
- Application menu integration
## Development
To run the Electron app in development mode:
```bash
npm run dev
```
This will start the renderer (UI) and the main process with hot reloading.
## Building
To build the application for your current platform:
```bash
npm run build
```
To build for specific platforms (requires appropriate build tools):
- **macOS**: `npm run build:mac`
- **Windows**: `npm run build:win`
- **Linux**: `npm run build:linux`
## Structure
- `electron/main`: Main process code (window creation, IPC).
- `electron/preload`: Preload scripts for secure bridge between main and renderer.
- `electron/resources`: Static assets like icons.

View File

@@ -0,0 +1,72 @@
import { defineConfig, externalizeDepsPlugin } from "electron-vite"
import solid from "vite-plugin-solid"
import { resolve } from "path"
const uiRoot = resolve(__dirname, "../ui")
const uiSrc = resolve(uiRoot, "src")
const uiRendererRoot = resolve(uiRoot, "src/renderer")
const uiRendererEntry = resolve(uiRendererRoot, "index.html")
const uiRendererLoadingEntry = resolve(uiRendererRoot, "loading.html")
export default defineConfig({
main: {
plugins: [externalizeDepsPlugin()],
build: {
outDir: "dist/main",
lib: {
entry: resolve(__dirname, "electron/main/main.ts"),
},
rollupOptions: {
external: ["electron"],
},
},
},
preload: {
plugins: [externalizeDepsPlugin()],
build: {
outDir: "dist/preload",
lib: {
entry: resolve(__dirname, "electron/preload/index.cjs"),
formats: ["cjs"],
fileName: () => "index.js",
},
rollupOptions: {
external: ["electron"],
output: {
entryFileNames: "index.js",
},
},
},
},
renderer: {
root: uiRendererRoot,
plugins: [solid()],
css: {
postcss: resolve(uiRoot, "postcss.config.js"),
},
resolve: {
alias: {
"@": uiSrc,
},
},
server: {
port: 3000,
},
build: {
minify: false,
cssMinify: false,
sourcemap: true,
outDir: resolve(__dirname, "dist/renderer"),
rollupOptions: {
input: {
main: uiRendererEntry,
loading: uiRendererLoadingEntry,
},
output: {
compact: false,
minifyInternalExports: false,
},
},
},
},
})

View File

@@ -0,0 +1,65 @@
import { BrowserWindow, dialog, ipcMain, type OpenDialogOptions } from "electron"
import type { CliProcessManager, CliStatus } from "./process-manager"
interface DialogOpenRequest {
mode: "directory" | "file"
title?: string
defaultPath?: string
filters?: Array<{ name?: string; extensions: string[] }>
}
interface DialogOpenResult {
canceled: boolean
paths: string[]
}
export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessManager) {
cliManager.on("status", (status: CliStatus) => {
if (!mainWindow.isDestroyed()) {
mainWindow.webContents.send("cli:status", status)
}
})
cliManager.on("ready", (status: CliStatus) => {
if (!mainWindow.isDestroyed()) {
mainWindow.webContents.send("cli:ready", status)
}
})
cliManager.on("error", (error: Error) => {
if (!mainWindow.isDestroyed()) {
mainWindow.webContents.send("cli:error", { message: error.message })
}
})
ipcMain.handle("cli:getStatus", async () => cliManager.getStatus())
ipcMain.handle("cli:restart", async () => {
const devMode = process.env.NODE_ENV === "development"
await cliManager.stop()
return cliManager.start({ dev: devMode })
})
ipcMain.handle("dialog:open", async (_, request: DialogOpenRequest): Promise<DialogOpenResult> => {
const properties: OpenDialogOptions["properties"] =
request.mode === "directory" ? ["openDirectory", "createDirectory"] : ["openFile"]
const filters = request.filters?.map((filter) => ({
name: filter.name ?? "Files",
extensions: filter.extensions,
}))
const windowTarget = mainWindow.isDestroyed() ? undefined : mainWindow
const dialogOptions: OpenDialogOptions = {
title: request.title,
defaultPath: request.defaultPath,
properties,
filters,
}
const result = windowTarget
? await dialog.showOpenDialog(windowTarget, dialogOptions)
: await dialog.showOpenDialog(dialogOptions)
return { canceled: result.canceled, paths: result.filePaths }
})
}

View File

@@ -0,0 +1,381 @@
import { app, BrowserView, BrowserWindow, nativeImage, session, shell } from "electron"
import { existsSync } from "fs"
import { dirname, join } from "path"
import { fileURLToPath } from "url"
import { createApplicationMenu } from "./menu"
import { setupCliIPC } from "./ipc"
import { CliProcessManager } from "./process-manager"
const mainFilename = fileURLToPath(import.meta.url)
const mainDirname = dirname(mainFilename)
const isMac = process.platform === "darwin"
const cliManager = new CliProcessManager()
let mainWindow: BrowserWindow | null = null
let currentCliUrl: string | null = null
let pendingCliUrl: string | null = null
let showingLoadingScreen = false
let preloadingView: BrowserView | null = null
if (isMac) {
app.commandLine.appendSwitch("disable-spell-checking")
}
function getIconPath() {
if (app.isPackaged) {
return join(process.resourcesPath, "icon.png")
}
return join(mainDirname, "../resources/icon.png")
}
type LoadingTarget =
| { type: "url"; source: string }
| { type: "file"; source: string }
function resolveDevLoadingUrl(): string | null {
if (app.isPackaged) {
return null
}
const devBase = process.env.VITE_DEV_SERVER_URL || process.env.ELECTRON_RENDERER_URL
if (!devBase) {
return null
}
try {
const normalized = devBase.endsWith("/") ? devBase : `${devBase}/`
return new URL("loading.html", normalized).toString()
} catch (error) {
console.warn("[cli] failed to construct dev loading URL", devBase, error)
return null
}
}
function resolveLoadingTarget(): LoadingTarget {
const devUrl = resolveDevLoadingUrl()
if (devUrl) {
return { type: "url", source: devUrl }
}
const filePath = resolveLoadingFilePath()
return { type: "file", source: filePath }
}
function resolveLoadingFilePath() {
const candidates = [
join(app.getAppPath(), "dist/renderer/loading.html"),
join(process.resourcesPath, "dist/renderer/loading.html"),
join(mainDirname, "../dist/renderer/loading.html"),
]
for (const candidate of candidates) {
if (existsSync(candidate)) {
return candidate
}
}
return join(app.getAppPath(), "dist/renderer/loading.html")
}
function loadLoadingScreen(window: BrowserWindow) {
const target = resolveLoadingTarget()
const loader =
target.type === "url"
? window.loadURL(target.source)
: window.loadFile(target.source)
loader.catch((error) => {
console.error("[cli] failed to load loading screen:", error)
})
}
function getAllowedRendererOrigins(): string[] {
const origins = new Set<string>()
const rendererCandidates = [currentCliUrl, process.env.VITE_DEV_SERVER_URL, process.env.ELECTRON_RENDERER_URL]
for (const candidate of rendererCandidates) {
if (!candidate) {
continue
}
try {
origins.add(new URL(candidate).origin)
} catch (error) {
console.warn("[cli] failed to parse origin for", candidate, error)
}
}
return Array.from(origins)
}
function shouldOpenExternally(url: string): boolean {
try {
const parsed = new URL(url)
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
return true
}
const allowedOrigins = getAllowedRendererOrigins()
return !allowedOrigins.includes(parsed.origin)
} catch {
return false
}
}
function setupNavigationGuards(window: BrowserWindow) {
const handleExternal = (url: string) => {
shell.openExternal(url).catch((error) => console.error("[cli] failed to open external URL", url, error))
}
window.webContents.setWindowOpenHandler(({ url }) => {
if (shouldOpenExternally(url)) {
handleExternal(url)
return { action: "deny" }
}
return { action: "allow" }
})
window.webContents.on("will-navigate", (event, url) => {
if (shouldOpenExternally(url)) {
event.preventDefault()
handleExternal(url)
}
})
}
let cachedPreloadPath: string | null = null
function getPreloadPath() {
if (cachedPreloadPath && existsSync(cachedPreloadPath)) {
return cachedPreloadPath
}
const candidates = [
join(process.resourcesPath, "preload/index.js"),
join(mainDirname, "../preload/index.js"),
join(mainDirname, "../preload/index.cjs"),
join(mainDirname, "../../preload/index.cjs"),
join(mainDirname, "../../electron/preload/index.cjs"),
join(app.getAppPath(), "preload/index.cjs"),
join(app.getAppPath(), "electron/preload/index.cjs"),
]
for (const candidate of candidates) {
if (existsSync(candidate)) {
cachedPreloadPath = candidate
return candidate
}
}
return join(mainDirname, "../preload/index.js")
}
function destroyPreloadingView(target?: BrowserView | null) {
const view = target ?? preloadingView
if (!view) {
return
}
try {
const contents = view.webContents as any
contents?.destroy?.()
} catch (error) {
console.warn("[cli] failed to destroy preloading view", error)
}
if (!target || view === preloadingView) {
preloadingView = null
}
}
function createWindow() {
const prefersDark = true
const backgroundColor = prefersDark ? "#1a1a1a" : "#ffffff"
const iconPath = getIconPath()
mainWindow = new BrowserWindow({
width: 1400,
height: 900,
minWidth: 800,
minHeight: 600,
backgroundColor,
icon: iconPath,
webPreferences: {
preload: getPreloadPath(),
contextIsolation: true,
nodeIntegration: false,
spellcheck: !isMac,
},
})
setupNavigationGuards(mainWindow)
if (isMac) {
mainWindow.webContents.session.setSpellCheckerEnabled(false)
}
showingLoadingScreen = true
currentCliUrl = null
loadLoadingScreen(mainWindow)
if (process.env.NODE_ENV === "development") {
mainWindow.webContents.openDevTools({ mode: "detach" })
}
createApplicationMenu(mainWindow)
setupCliIPC(mainWindow, cliManager)
mainWindow.on("closed", () => {
destroyPreloadingView()
mainWindow = null
currentCliUrl = null
pendingCliUrl = null
showingLoadingScreen = false
})
if (pendingCliUrl) {
const url = pendingCliUrl
pendingCliUrl = null
startCliPreload(url)
}
}
function showLoadingScreen(force = false) {
if (!mainWindow || mainWindow.isDestroyed()) {
return
}
if (showingLoadingScreen && !force) {
return
}
destroyPreloadingView()
showingLoadingScreen = true
currentCliUrl = null
pendingCliUrl = null
loadLoadingScreen(mainWindow)
}
function startCliPreload(url: string) {
if (!mainWindow || mainWindow.isDestroyed()) {
pendingCliUrl = url
return
}
if (currentCliUrl === url && !showingLoadingScreen) {
return
}
pendingCliUrl = url
destroyPreloadingView()
if (!showingLoadingScreen) {
showLoadingScreen(true)
}
const view = new BrowserView({
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
spellcheck: !isMac,
},
})
preloadingView = view
view.webContents.once("did-finish-load", () => {
if (preloadingView !== view) {
destroyPreloadingView(view)
return
}
finalizeCliSwap(url)
})
view.webContents.loadURL(url).catch((error) => {
console.error("[cli] failed to preload CLI view:", error)
if (preloadingView === view) {
destroyPreloadingView(view)
}
})
}
function finalizeCliSwap(url: string) {
destroyPreloadingView()
if (!mainWindow || mainWindow.isDestroyed()) {
pendingCliUrl = url
return
}
showingLoadingScreen = false
currentCliUrl = url
pendingCliUrl = null
mainWindow.loadURL(url).catch((error) => console.error("[cli] failed to load CLI view:", error))
}
async function startCli() {
try {
const devMode = process.env.NODE_ENV === "development"
console.info("[cli] start requested (dev mode:", devMode, ")")
await cliManager.start({ dev: devMode })
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
console.error("[cli] start failed:", message)
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send("cli:error", { message })
}
}
}
cliManager.on("ready", (status) => {
if (!status.url) {
return
}
startCliPreload(status.url)
})
cliManager.on("status", (status) => {
if (status.state !== "ready") {
showLoadingScreen()
}
})
if (isMac) {
app.on("web-contents-created", (_, contents) => {
contents.session.setSpellCheckerEnabled(false)
})
}
app.whenReady().then(() => {
startCli()
if (isMac) {
session.defaultSession.setSpellCheckerEnabled(false)
app.on("browser-window-created", (_, window) => {
window.webContents.session.setSpellCheckerEnabled(false)
})
if (app.dock) {
const dockIcon = nativeImage.createFromPath(getIconPath())
if (!dockIcon.isEmpty()) {
app.dock.setIcon(dockIcon)
}
}
}
createWindow()
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow()
}
})
})
app.on("before-quit", async (event) => {
event.preventDefault()
await cliManager.stop().catch(() => {})
app.exit(0)
})
app.on("window-all-closed", () => {
if (process.platform !== "darwin") {
app.quit()
}
})

View File

@@ -0,0 +1,84 @@
import { Menu, BrowserWindow, MenuItemConstructorOptions } from "electron"
export function createApplicationMenu(mainWindow: BrowserWindow) {
const isMac = process.platform === "darwin"
const template: MenuItemConstructorOptions[] = [
...(isMac
? [
{
label: "CodeNomad",
submenu: [
{ role: "about" as const },
{ type: "separator" as const },
{ role: "hide" as const },
{ role: "hideOthers" as const },
{ role: "unhide" as const },
{ type: "separator" as const },
{ role: "quit" as const },
],
},
]
: []),
{
label: "File",
submenu: [
{
label: "New Instance",
accelerator: "CmdOrCtrl+N",
click: () => {
mainWindow.webContents.send("menu:newInstance")
},
},
{ type: "separator" as const },
isMac ? { role: "close" as const } : { role: "quit" as const },
],
},
{
label: "Edit",
submenu: [
{ role: "undo" as const },
{ role: "redo" as const },
{ type: "separator" as const },
{ role: "cut" as const },
{ role: "copy" as const },
{ role: "paste" as const },
...(isMac
? [{ role: "pasteAndMatchStyle" as const }, { role: "delete" as const }, { role: "selectAll" as const }]
: [{ role: "delete" as const }, { type: "separator" as const }, { role: "selectAll" as const }]),
],
},
{
label: "View",
submenu: [
{ role: "reload" as const },
{ role: "forceReload" as const },
{ role: "toggleDevTools" as const },
{ type: "separator" as const },
{ role: "resetZoom" as const },
{ role: "zoomIn" as const },
{ role: "zoomOut" as const },
{ type: "separator" as const },
{ role: "togglefullscreen" as const },
],
},
{
label: "Window",
submenu: [
{ role: "minimize" as const },
{ role: "zoom" as const },
...(isMac
? [
{ type: "separator" as const },
{ role: "front" as const },
{ type: "separator" as const },
{ role: "window" as const },
]
: [{ role: "close" as const }]),
],
},
]
const menu = Menu.buildFromTemplate(template)
Menu.setApplicationMenu(menu)
}

View File

@@ -0,0 +1,364 @@
import { spawn, type ChildProcess } from "child_process"
import { app } from "electron"
import { createRequire } from "module"
import { EventEmitter } from "events"
import { existsSync, readFileSync } from "fs"
import os from "os"
import path from "path"
import { buildUserShellCommand, getUserShellEnv, supportsUserShell } from "./user-shell"
const nodeRequire = createRequire(import.meta.url)
type CliState = "starting" | "ready" | "error" | "stopped"
type ListeningMode = "local" | "all"
export interface CliStatus {
state: CliState
pid?: number
port?: number
url?: string
error?: string
}
export interface CliLogEntry {
stream: "stdout" | "stderr"
message: string
}
interface StartOptions {
dev: boolean
}
interface CliEntryResolution {
entry: string
runner: "node" | "tsx"
runnerPath?: string
}
const DEFAULT_CONFIG_PATH = "~/.config/codenomad/config.json"
function resolveConfigPath(configPath?: string): string {
const target = configPath && configPath.trim().length > 0 ? configPath : DEFAULT_CONFIG_PATH
if (target.startsWith("~/")) {
return path.join(os.homedir(), target.slice(2))
}
return path.resolve(target)
}
function resolveHostForMode(mode: ListeningMode): string {
return mode === "local" ? "127.0.0.1" : "0.0.0.0"
}
function readListeningModeFromConfig(): ListeningMode {
try {
const configPath = resolveConfigPath(process.env.CLI_CONFIG)
if (!existsSync(configPath)) return "local"
const content = readFileSync(configPath, "utf-8")
const parsed = JSON.parse(content)
const mode = parsed?.preferences?.listeningMode
if (mode === "local" || mode === "all") {
return mode
}
} catch (error) {
console.warn("[cli] failed to read listening mode from config", error)
}
return "local"
}
export declare interface CliProcessManager {
on(event: "status", listener: (status: CliStatus) => void): this
on(event: "ready", listener: (status: CliStatus) => void): this
on(event: "log", listener: (entry: CliLogEntry) => void): this
on(event: "exit", listener: (status: CliStatus) => void): this
on(event: "error", listener: (error: Error) => void): this
}
export class CliProcessManager extends EventEmitter {
private child?: ChildProcess
private status: CliStatus = { state: "stopped" }
private stdoutBuffer = ""
private stderrBuffer = ""
async start(options: StartOptions): Promise<CliStatus> {
if (this.child) {
await this.stop()
}
this.stdoutBuffer = ""
this.stderrBuffer = ""
this.updateStatus({ state: "starting", port: undefined, pid: undefined, url: undefined, error: undefined })
const cliEntry = this.resolveCliEntry(options)
const listeningMode = this.resolveListeningMode()
const host = resolveHostForMode(listeningMode)
const args = this.buildCliArgs(options, host)
console.info(
`[cli] launching CodeNomad CLI (${options.dev ? "dev" : "prod"}) using ${cliEntry.runner} at ${cliEntry.entry} (host=${host})`,
)
const env = supportsUserShell() ? getUserShellEnv() : { ...process.env }
env.ELECTRON_RUN_AS_NODE = "1"
const spawnDetails = supportsUserShell()
? buildUserShellCommand(`ELECTRON_RUN_AS_NODE=1 exec ${this.buildCommand(cliEntry, args)}`)
: this.buildDirectSpawn(cliEntry, args)
const child = spawn(spawnDetails.command, spawnDetails.args, {
cwd: process.cwd(),
stdio: ["ignore", "pipe", "pipe"],
env,
shell: false,
})
console.info(`[cli] spawn command: ${spawnDetails.command} ${spawnDetails.args.join(" ")}`)
if (!child.pid) {
console.error("[cli] spawn failed: no pid")
}
this.child = child
this.updateStatus({ pid: child.pid ?? undefined })
child.stdout?.on("data", (data: Buffer) => {
this.handleStream(data.toString(), "stdout")
})
child.stderr?.on("data", (data: Buffer) => {
this.handleStream(data.toString(), "stderr")
})
child.on("error", (error) => {
console.error("[cli] failed to start CLI:", error)
this.updateStatus({ state: "error", error: error.message })
this.emit("error", error)
})
child.on("exit", (code, signal) => {
const failed = this.status.state !== "ready"
const error = failed ? this.status.error ?? `CLI exited with code ${code ?? 0}${signal ? ` (${signal})` : ""}` : undefined
console.info(`[cli] exit (code=${code}, signal=${signal || ""})${error ? ` error=${error}` : ""}`)
this.updateStatus({ state: failed ? "error" : "stopped", error })
if (failed && error) {
this.emit("error", new Error(error))
}
this.emit("exit", this.status)
this.child = undefined
})
return new Promise<CliStatus>((resolve, reject) => {
const timeout = setTimeout(() => {
this.handleTimeout()
reject(new Error("CLI startup timeout"))
}, 60000)
this.once("ready", (status) => {
clearTimeout(timeout)
resolve(status)
})
this.once("error", (error) => {
clearTimeout(timeout)
reject(error)
})
})
}
async stop(): Promise<void> {
const child = this.child
if (!child) {
this.updateStatus({ state: "stopped" })
return
}
return new Promise((resolve) => {
const killTimeout = setTimeout(() => {
child.kill("SIGKILL")
}, 4000)
child.on("exit", () => {
clearTimeout(killTimeout)
this.child = undefined
console.info("[cli] CLI process exited")
this.updateStatus({ state: "stopped" })
resolve()
})
child.kill("SIGTERM")
})
}
getStatus(): CliStatus {
return { ...this.status }
}
private resolveListeningMode(): ListeningMode {
return readListeningModeFromConfig()
}
private handleTimeout() {
if (this.child) {
this.child.kill("SIGKILL")
this.child = undefined
}
this.updateStatus({ state: "error", error: "CLI did not start in time" })
this.emit("error", new Error("CLI did not start in time"))
}
private handleStream(chunk: string, stream: "stdout" | "stderr") {
if (stream === "stdout") {
this.stdoutBuffer += chunk
this.processBuffer("stdout")
} else {
this.stderrBuffer += chunk
this.processBuffer("stderr")
}
}
private processBuffer(stream: "stdout" | "stderr") {
const buffer = stream === "stdout" ? this.stdoutBuffer : this.stderrBuffer
const lines = buffer.split("\n")
const trailing = lines.pop() ?? ""
if (stream === "stdout") {
this.stdoutBuffer = trailing
} else {
this.stderrBuffer = trailing
}
for (const line of lines) {
if (!line.trim()) continue
console.info(`[cli][${stream}] ${line}`)
this.emit("log", { stream, message: line })
const port = this.extractPort(line)
if (port && this.status.state === "starting") {
const url = `http://127.0.0.1:${port}`
console.info(`[cli] ready on ${url}`)
this.updateStatus({ state: "ready", port, url })
this.emit("ready", this.status)
}
}
}
private extractPort(line: string): number | null {
const readyMatch = line.match(/CodeNomad Server is ready at http:\/\/[^:]+:(\d+)/i)
if (readyMatch) {
return parseInt(readyMatch[1], 10)
}
if (line.toLowerCase().includes("http server listening")) {
const httpMatch = line.match(/:(\d{2,5})(?!.*:\d)/)
if (httpMatch) {
return parseInt(httpMatch[1], 10)
}
try {
const parsed = JSON.parse(line)
if (typeof parsed.port === "number") {
return parsed.port
}
} catch {
// not JSON, ignore
}
}
return null
}
private updateStatus(patch: Partial<CliStatus>) {
this.status = { ...this.status, ...patch }
this.emit("status", this.status)
}
private buildCliArgs(options: StartOptions, host: string): string[] {
const args = ["serve", "--host", host, "--port", "0"]
if (options.dev) {
args.push("--ui-dev-server", "http://localhost:3000", "--log-level", "debug")
}
return args
}
private buildCommand(cliEntry: CliEntryResolution, args: string[]): string {
const parts = [JSON.stringify(process.execPath)]
if (cliEntry.runner === "tsx" && cliEntry.runnerPath) {
parts.push(JSON.stringify(cliEntry.runnerPath))
}
parts.push(JSON.stringify(cliEntry.entry))
args.forEach((arg) => parts.push(JSON.stringify(arg)))
return parts.join(" ")
}
private buildDirectSpawn(cliEntry: CliEntryResolution, args: string[]) {
if (cliEntry.runner === "tsx") {
return { command: process.execPath, args: [cliEntry.runnerPath!, cliEntry.entry, ...args] }
}
return { command: process.execPath, args: [cliEntry.entry, ...args] }
}
private resolveCliEntry(options: StartOptions): CliEntryResolution {
if (options.dev) {
const tsxPath = this.resolveTsx()
if (!tsxPath) {
throw new Error("tsx is required to run the CLI in development mode. Please install dependencies.")
}
const devEntry = this.resolveDevEntry()
return { entry: devEntry, runner: "tsx", runnerPath: tsxPath }
}
const distEntry = this.resolveProdEntry()
return { entry: distEntry, runner: "node" }
}
private resolveTsx(): string | null {
const candidates: Array<string | (() => string)> = [
() => nodeRequire.resolve("tsx/cli"),
() => nodeRequire.resolve("tsx/dist/cli.mjs"),
() => nodeRequire.resolve("tsx/dist/cli.cjs"),
path.resolve(process.cwd(), "node_modules", "tsx", "dist", "cli.mjs"),
path.resolve(process.cwd(), "node_modules", "tsx", "dist", "cli.cjs"),
path.resolve(process.cwd(), "..", "node_modules", "tsx", "dist", "cli.mjs"),
path.resolve(process.cwd(), "..", "node_modules", "tsx", "dist", "cli.cjs"),
path.resolve(process.cwd(), "..", "..", "node_modules", "tsx", "dist", "cli.mjs"),
path.resolve(process.cwd(), "..", "..", "node_modules", "tsx", "dist", "cli.cjs"),
path.resolve(app.getAppPath(), "..", "node_modules", "tsx", "dist", "cli.mjs"),
path.resolve(app.getAppPath(), "..", "node_modules", "tsx", "dist", "cli.cjs"),
]
for (const candidate of candidates) {
try {
const resolved = typeof candidate === "function" ? candidate() : candidate
if (resolved && existsSync(resolved)) {
return resolved
}
} catch {
continue
}
}
return null
}
private resolveDevEntry(): string {
const entry = path.resolve(process.cwd(), "..", "server", "src", "index.ts")
if (!existsSync(entry)) {
throw new Error(`Dev CLI entry not found at ${entry}. Run npm run dev:electron from the repository root after installing dependencies.`)
}
return entry
}
private resolveProdEntry(): string {
try {
const entry = nodeRequire.resolve("@neuralnomads/codenomad/dist/bin.js")
if (existsSync(entry)) {
return entry
}
} catch {
// fall through to error below
}
throw new Error("Unable to locate CodeNomad CLI build (dist/bin.js). Run npm run build --workspace @neuralnomads/codenomad.")
}
}

View File

@@ -0,0 +1,121 @@
import { app, ipcMain } from "electron"
import { join } from "path"
import { readFile, writeFile, mkdir, unlink, stat } from "fs/promises"
import { existsSync } from "fs"
const CONFIG_DIR = join(app.getPath("home"), ".config", "codenomad")
const CONFIG_FILE = join(CONFIG_DIR, "config.json")
const INSTANCES_DIR = join(CONFIG_DIR, "instances")
// File watching for config changes
let configWatchers = new Set<number>()
let configLastModified = 0
let configCache: string | null = null
async function ensureDirectories() {
try {
await mkdir(CONFIG_DIR, { recursive: true })
await mkdir(INSTANCES_DIR, { recursive: true })
} catch (error) {
console.error("Failed to create directories:", error)
}
}
async function readConfigWithCache(): Promise<string> {
try {
const stats = await stat(CONFIG_FILE)
const currentModified = stats.mtime.getTime()
// If file hasn't been modified since last read, return cache
if (configCache && configLastModified >= currentModified) {
return configCache
}
const content = await readFile(CONFIG_FILE, "utf-8")
configCache = content
configLastModified = currentModified
return content
} catch (error) {
// File doesn't exist or can't be read
configCache = null
configLastModified = 0
throw error
}
}
function invalidateConfigCache() {
configCache = null
configLastModified = 0
}
export function setupStorageIPC() {
ensureDirectories()
ipcMain.handle("storage:getConfigPath", async () => CONFIG_FILE)
ipcMain.handle("storage:getInstancesDir", async () => INSTANCES_DIR)
ipcMain.handle("storage:readConfigFile", async () => {
try {
return await readConfigWithCache()
} catch (error) {
// Return empty config if file doesn't exist
return JSON.stringify({ preferences: { showThinkingBlocks: false, thinkingBlocksExpansion: "expanded" }, recentFolders: [] }, null, 2)
}
})
ipcMain.handle("storage:writeConfigFile", async (_, content: string) => {
try {
await writeFile(CONFIG_FILE, content, "utf-8")
invalidateConfigCache()
// Notify other renderer processes about config change
const windows = require("electron").BrowserWindow.getAllWindows()
windows.forEach((win: any) => {
if (win.webContents && !win.webContents.isDestroyed()) {
win.webContents.send("storage:configChanged")
}
})
} catch (error) {
console.error("Failed to write config file:", error)
throw error
}
})
ipcMain.handle("storage:readInstanceFile", async (_, filename: string) => {
const instanceFile = join(INSTANCES_DIR, `${filename}.json`)
try {
return await readFile(instanceFile, "utf-8")
} catch (error) {
// Return empty instance data if file doesn't exist
return JSON.stringify({ messageHistory: [] }, null, 2)
}
})
ipcMain.handle("storage:writeInstanceFile", async (_, filename: string, content: string) => {
const instanceFile = join(INSTANCES_DIR, `${filename}.json`)
try {
await writeFile(instanceFile, content, "utf-8")
} catch (error) {
console.error(`Failed to write instance file for ${filename}:`, error)
throw error
}
})
ipcMain.handle("storage:deleteInstanceFile", async (_, filename: string) => {
const instanceFile = join(INSTANCES_DIR, `${filename}.json`)
try {
if (existsSync(instanceFile)) {
await unlink(instanceFile)
}
} catch (error) {
console.error(`Failed to delete instance file for ${filename}:`, error)
throw error
}
})
}
// Clean up on app quit
app.on("before-quit", () => {
configCache = null
configLastModified = 0
})

View File

@@ -0,0 +1,139 @@
import { spawn, spawnSync } from "child_process"
import path from "path"
interface ShellCommand {
command: string
args: string[]
}
const isWindows = process.platform === "win32"
function getDefaultShellPath(): string {
if (process.env.SHELL && process.env.SHELL.trim().length > 0) {
return process.env.SHELL
}
if (process.platform === "darwin") {
return "/bin/zsh"
}
return "/bin/bash"
}
function wrapCommandForShell(command: string, shellPath: string): string {
const shellName = path.basename(shellPath)
if (shellName.includes("bash")) {
return 'if [ -f ~/.bashrc ]; then source ~/.bashrc >/dev/null 2>&1; fi; ' + command
}
if (shellName.includes("zsh")) {
return 'if [ -f ~/.zshrc ]; then source ~/.zshrc >/dev/null 2>&1; fi; ' + command
}
return command
}
function buildShellArgs(shellPath: string): string[] {
const shellName = path.basename(shellPath)
if (shellName.includes("zsh")) {
return ["-l", "-i", "-c"]
}
return ["-l", "-c"]
}
function sanitizeShellEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
const cleaned = { ...env }
delete cleaned.npm_config_prefix
delete cleaned.NPM_CONFIG_PREFIX
return cleaned
}
export function supportsUserShell(): boolean {
return !isWindows
}
export function buildUserShellCommand(userCommand: string): ShellCommand {
if (!supportsUserShell()) {
throw new Error("User shell invocation is only supported on POSIX platforms")
}
const shellPath = getDefaultShellPath()
const script = wrapCommandForShell(userCommand, shellPath)
const args = buildShellArgs(shellPath)
return {
command: shellPath,
args: [...args, script],
}
}
export function getUserShellEnv(): NodeJS.ProcessEnv {
if (!supportsUserShell()) {
throw new Error("User shell invocation is only supported on POSIX platforms")
}
return sanitizeShellEnv(process.env)
}
export function runUserShellCommand(userCommand: string, timeoutMs = 5000): Promise<string> {
if (!supportsUserShell()) {
return Promise.reject(new Error("User shell invocation is only supported on POSIX platforms"))
}
const { command, args } = buildUserShellCommand(userCommand)
const env = getUserShellEnv()
return new Promise((resolve, reject) => {
const child = spawn(command, args, {
stdio: ["ignore", "pipe", "pipe"],
env,
})
let stdout = ""
let stderr = ""
const timeout = setTimeout(() => {
child.kill("SIGTERM")
reject(new Error(`Shell command timed out after ${timeoutMs}ms`))
}, timeoutMs)
child.stdout?.on("data", (data) => {
stdout += data.toString()
})
child.stderr?.on("data", (data) => {
stderr += data.toString()
})
child.on("error", (error) => {
clearTimeout(timeout)
reject(error)
})
child.on("close", (code) => {
clearTimeout(timeout)
if (code === 0) {
resolve(stdout.trim())
} else {
reject(new Error(stderr.trim() || `Shell command exited with code ${code}`))
}
})
})
}
export function runUserShellCommandSync(userCommand: string): string {
if (!supportsUserShell()) {
throw new Error("User shell invocation is only supported on POSIX platforms")
}
const { command, args } = buildUserShellCommand(userCommand)
const env = getUserShellEnv()
const result = spawnSync(command, args, { encoding: "utf-8", env })
if (result.status !== 0) {
const stderr = (result.stderr || "").toString().trim()
throw new Error(stderr || "Shell command failed")
}
return (result.stdout || "").toString().trim()
}

View File

@@ -0,0 +1,17 @@
const { contextBridge, ipcRenderer } = require("electron")
const electronAPI = {
onCliStatus: (callback) => {
ipcRenderer.on("cli:status", (_, data) => callback(data))
return () => ipcRenderer.removeAllListeners("cli:status")
},
onCliError: (callback) => {
ipcRenderer.on("cli:error", (_, data) => callback(data))
return () => ipcRenderer.removeAllListeners("cli:error")
},
getCliStatus: () => ipcRenderer.invoke("cli:getStatus"),
restartCli: () => ipcRenderer.invoke("cli:restart"),
openDialog: (options) => ipcRenderer.invoke("dialog:open", options),
}
contextBridge.exposeInMainWorld("electronAPI", electronAPI)

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 422 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

View File

@@ -0,0 +1,7 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"noEmit": true
},
"include": ["./**/*.ts", "./**/*.tsx"]
}

View File

@@ -0,0 +1,137 @@
{
"name": "@neuralnomads/codenomad-electron-app",
"version": "0.4.0",
"description": "CodeNomad - AI coding assistant",
"author": {
"name": "Neural Nomads",
"email": "codenomad@neuralnomads.ai"
},
"type": "module",
"main": "dist/main/main.js",
"repository": {
"type": "git",
"url": "https://github.com/NeuralNomadsAI/CodeNomad.git"
},
"homepage": "https://github.com/NeuralNomadsAI/CodeNomad",
"scripts": {
"dev": "electron-vite dev",
"dev:electron": "NODE_ENV=development ELECTRON_ENABLE_LOGGING=1 NODE_OPTIONS=\"--import tsx\" electron electron/main/main.ts",
"build": "electron-vite build",
"typecheck": "tsc --noEmit -p tsconfig.json",
"preview": "electron-vite preview",
"build:binaries": "node scripts/build.js",
"build:mac": "node scripts/build.js mac",
"build:mac-x64": "node scripts/build.js mac-x64",
"build:mac-arm64": "node scripts/build.js mac-arm64",
"build:win": "node scripts/build.js win",
"build:win-arm64": "node scripts/build.js win-arm64",
"build:linux": "node scripts/build.js linux",
"build:linux-arm64": "node scripts/build.js linux-arm64",
"build:linux-rpm": "node scripts/build.js linux-rpm",
"build:all": "node scripts/build.js all",
"package:mac": "electron-builder --mac",
"package:win": "electron-builder --win",
"package:linux": "electron-builder --linux"
},
"dependencies": {
"@neuralnomads/codenomad": "file:../server",
"@codenomad/ui": "file:../ui"
},
"devDependencies": {
"7zip-bin": "^5.2.0",
"app-builder-bin": "^4.2.0",
"electron": "39.0.0",
"electron-builder": "^24.0.0",
"electron-vite": "4.0.1",
"png2icons": "^2.0.1",
"pngjs": "^7.0.0",
"tsx": "^4.20.6",
"typescript": "^5.3.0",
"vite": "^5.0.0",
"vite-plugin-solid": "^2.10.0"
},
"build": {
"appId": "ai.opencode.client",
"productName": "CodeNomad",
"directories": {
"output": "release",
"buildResources": "electron/resources"
},
"files": [
"dist/**/*",
"package.json"
],
"extraResources": [
{
"from": "electron/resources",
"to": "",
"filter": [
"!icon.icns",
"!icon.ico"
]
}
],
"mac": {
"category": "public.app-category.developer-tools",
"target": [
{
"target": "zip",
"arch": [
"x64",
"arm64"
]
}
],
"artifactName": "CodeNomad-${version}-${os}-${arch}.${ext}",
"icon": "electron/resources/icon.icns"
},
"dmg": {
"contents": [
{
"x": 130,
"y": 220
},
{
"x": 410,
"y": 220,
"type": "link",
"path": "/Applications"
}
]
},
"win": {
"target": [
{
"target": "zip",
"arch": [
"x64",
"arm64"
]
}
],
"artifactName": "CodeNomad-${version}-${os}-${arch}.${ext}",
"icon": "electron/resources/icon.ico"
},
"nsis": {
"oneClick": false,
"allowToChangeInstallationDirectory": true,
"createDesktopShortcut": true,
"createStartMenuShortcut": true
},
"linux": {
"target": [
{
"target": "zip",
"arch": [
"x64",
"arm64"
]
}
],
"artifactName": "CodeNomad-${version}-${os}-${arch}.${ext}",
"category": "Development",
"icon": "electron/resources/icon.png"
}
},
"private": true
}

View File

@@ -0,0 +1,131 @@
#!/usr/bin/env node
import { spawn } from "child_process"
import { existsSync } from "fs"
import { join } from "path"
import { fileURLToPath } from "url"
const __dirname = fileURLToPath(new URL(".", import.meta.url))
const appDir = join(__dirname, "..")
const workspaceRoot = join(appDir, "..", "..")
const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm"
const npxCmd = process.platform === "win32" ? "npx.cmd" : "npx"
const nodeModulesPath = join(appDir, "node_modules")
const workspaceNodeModulesPath = join(workspaceRoot, "node_modules")
const platforms = {
mac: {
args: ["--mac", "--x64", "--arm64"],
description: "macOS (Intel & Apple Silicon)",
},
"mac-x64": {
args: ["--mac", "--x64"],
description: "macOS (Intel only)",
},
"mac-arm64": {
args: ["--mac", "--arm64"],
description: "macOS (Apple Silicon only)",
},
win: {
args: ["--win", "--x64"],
description: "Windows (x64)",
},
"win-arm64": {
args: ["--win", "--arm64"],
description: "Windows (ARM64)",
},
linux: {
args: ["--linux", "--x64"],
description: "Linux (x64)",
},
"linux-arm64": {
args: ["--linux", "--arm64"],
description: "Linux (ARM64)",
},
"linux-rpm": {
args: ["--linux", "rpm", "--x64", "--arm64"],
description: "Linux RPM packages (x64 & ARM64)",
},
all: {
args: ["--mac", "--win", "--linux", "--x64", "--arm64"],
description: "All platforms (macOS, Windows, Linux)",
},
}
function run(command, args, options = {}) {
return new Promise((resolve, reject) => {
const spawnOptions = {
cwd: appDir,
stdio: "inherit",
shell: process.platform === "win32",
...options,
env: { ...process.env, NODE_PATH: nodeModulesPath, ...(options.env || {}) },
}
const child = spawn(command, args, spawnOptions)
child.on("error", reject)
child.on("exit", (code) => {
if (code === 0) {
resolve(undefined)
} else {
reject(new Error(`${command} ${args.join(" ")} exited with code ${code}`))
}
})
})
}
function printAvailablePlatforms() {
console.error(`\nAvailable platforms:`)
for (const [name, cfg] of Object.entries(platforms)) {
console.error(` - ${name.padEnd(12)} : ${cfg.description}`)
}
}
async function build(platform) {
const config = platforms[platform]
if (!config) {
console.error(`❌ Unknown platform: ${platform}`)
printAvailablePlatforms()
process.exit(1)
}
console.log(`\n🔨 Building for: ${config.description}\n`)
try {
console.log("📦 Step 1/3: Building CLI dependency...\n")
await run(npmCmd, ["run", "build", "--workspace", "@neuralnomads/codenomad"], {
cwd: workspaceRoot,
env: { NODE_PATH: workspaceNodeModulesPath },
})
console.log("\n📦 Step 2/3: Building Electron app...\n")
await run(npmCmd, ["run", "build"])
console.log("\n📦 Step 3/3: Packaging binaries...\n")
const distPath = join(appDir, "dist")
if (!existsSync(distPath)) {
throw new Error("dist/ directory not found. Build failed.")
}
await run(npxCmd, ["electron-builder", "--publish=never", ...config.args])
console.log("\n✅ Build complete!")
console.log(`📁 Binaries available in: ${join(appDir, "release")}\n`)
} catch (error) {
console.error("\n❌ Build failed:", error)
process.exit(1)
}
}
const platform = process.argv[2] || "mac"
console.log(`
╔════════════════════════════════════════╗
║ CodeNomad - Binary Builder ║
╚════════════════════════════════════════╝
`)
await build(platform)

View File

@@ -0,0 +1,30 @@
#!/usr/bin/env bash
set -euo pipefail
# ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
if ! command -v node >/dev/null 2>&1; then
echo "Node.js is required to run the development environment." >&2
exit 1
fi
# Resolve the Electron binary via Node to avoid Bun resolution hiccups
ELECTRON_EXEC_PATH="$(node -p "require('electron')")"
if [[ -z "${ELECTRON_EXEC_PATH}" ]]; then
echo "Failed to resolve the Electron binary path." >&2
exit 1
fi
export NODE_ENV="${NODE_ENV:-development}"
export ELECTRON_EXEC_PATH
# ELECTRON_VITE_BIN="$ROOT_DIR/node_modules/.bin/electron-vite"
if [[ ! -x "${ELECTRON_VITE_BIN}" ]]; then
echo "electron-vite binary not found. Have you installed dependencies?" >&2
exit 1
fi
exec "${ELECTRON_VITE_BIN}" dev "$@"

View File

@@ -0,0 +1,155 @@
#!/usr/bin/env node
import { mkdirSync, readFileSync, writeFileSync } from "fs"
import { resolve, join, basename } from "path"
import { PNG } from "pngjs"
import png2icons from "png2icons"
function printUsage() {
console.log(`\nUsage: node scripts/generate-icons.js <input.png> [outputDir] [--name icon] [--radius 0.22]\n\nOptions:\n --name Base filename for generated assets (default: icon)\n --radius Corner radius ratio between 0 and 0.5 (default: 0.22)\n --help Show this message\n`)
}
function parseArgs(argv) {
const args = [...argv]
const options = {
name: "icon",
radius: 0.22,
}
for (let i = 0; i < args.length; i++) {
const token = args[i]
if (token === "--help" || token === "-h") {
options.help = true
continue
}
if (token === "--name" && i + 1 < args.length) {
options.name = args[i + 1]
i++
continue
}
if (token === "--radius" && i + 1 < args.length) {
options.radius = Number(args[i + 1])
i++
continue
}
if (!options.input) {
options.input = token
continue
}
if (!options.output) {
options.output = token
continue
}
}
return options
}
function applyRoundedCorners(png, ratio) {
const { width, height, data } = png
const clamped = Math.max(0, Math.min(ratio, 0.5))
if (clamped === 0) return png
const radius = Math.max(1, Math.min(width, height) * clamped)
const radiusSq = radius * radius
const rightThreshold = width - radius
const bottomThreshold = height - radius
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const idx = (width * y + x) * 4
if (data[idx + 3] === 0) continue
const px = x + 0.5
const py = y + 0.5
const inLeft = px < radius
const inRight = px > rightThreshold
const inTop = py < radius
const inBottom = py > bottomThreshold
let outside = false
if (inLeft && inTop) {
outside = (px - radius) ** 2 + (py - radius) ** 2 > radiusSq
} else if (inRight && inTop) {
outside = (px - rightThreshold) ** 2 + (py - radius) ** 2 > radiusSq
} else if (inLeft && inBottom) {
outside = (px - radius) ** 2 + (py - bottomThreshold) ** 2 > radiusSq
} else if (inRight && inBottom) {
outside = (px - rightThreshold) ** 2 + (py - bottomThreshold) ** 2 > radiusSq
}
if (outside) {
data[idx + 3] = 0
}
}
}
return png
}
async function main() {
const args = parseArgs(process.argv.slice(2))
if (args.help || !args.input) {
printUsage()
process.exit(args.help ? 0 : 1)
}
const inputPath = resolve(args.input)
const outputDir = resolve(args.output || "electron/resources")
const baseName = args.name || basename(inputPath, ".png")
const radiusRatio = Number.isFinite(args.radius) ? args.radius : 0.22
let buffer
try {
buffer = readFileSync(inputPath)
} catch (error) {
console.error(`Failed to read ${inputPath}:`, error.message)
process.exit(1)
}
let png
try {
png = PNG.sync.read(buffer)
} catch (error) {
console.error("Input must be a valid PNG:", error.message)
process.exit(1)
}
applyRoundedCorners(png, radiusRatio)
const roundedBuffer = PNG.sync.write(png)
try {
mkdirSync(outputDir, { recursive: true })
} catch (error) {
console.error("Failed to create output directory:", error.message)
process.exit(1)
}
const pngPath = join(outputDir, `${baseName}.png`)
writeFileSync(pngPath, roundedBuffer)
const icns = png2icons.createICNS(roundedBuffer, png2icons.BICUBIC, false)
if (!icns) {
console.error("Failed to create ICNS file. Make sure the source PNG is at least 256x256.")
process.exit(1)
}
writeFileSync(join(outputDir, `${baseName}.icns`), icns)
const ico = png2icons.createICO(roundedBuffer, png2icons.BICUBIC, false)
if (!ico) {
console.error("Failed to create ICO file. Make sure the source PNG is at least 256x256.")
process.exit(1)
}
writeFileSync(join(outputDir, `${baseName}.ico`), ico)
console.log(`\nGenerated assets in ${outputDir}:`)
console.log(`- ${baseName}.png`)
console.log(`- ${baseName}.icns`)
console.log(`- ${baseName}.ico`)
}
main()

View File

@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"lib": ["ES2020"],
"moduleResolution": "bundler",
"resolveJsonModule": true,
"allowImportingTsExtensions": true,
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"noEmit": true
},
"include": ["electron/**/*.ts", "electron.vite.config.ts"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -0,0 +1,3 @@
{
"$schema": "https://opencode.ai/config.json"
}

View File

@@ -0,0 +1,18 @@
import { tool } from "@opencode-ai/plugin/tool"
export async function HelloPlugin() {
return {
tool: {
hello: tool({
description: "Return a friendly greeting",
args: {
name: tool.schema.string().optional().describe("Name to greet"),
},
async execute(args) {
const target = args.name?.trim() || "CodeNomad"
return `Hello, ${target}!`
},
}),
},
}
}

1
packages/server/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
public/

View File

@@ -0,0 +1,5 @@
node_modules
scripts/
src/
tsconfig.json
*.tsbuildinfo

58
packages/server/README.md Normal file
View File

@@ -0,0 +1,58 @@
# CodeNomad Server
**CodeNomad Server** is the high-performance engine behind the CodeNomad cockpit. It transforms your machine into a robust development host, managing the lifecycle of multiple OpenCode instances and providing the low-latency data streams that long-haul builders demand. It bridges your local filesystem with the UI, ensuring that whether you are on localhost or a remote tunnel, you have the speed, clarity, and control of a native workspace.
## Features & Capabilities
### 🌍 Deployment Freedom
- **Remote Access**: Host CodeNomad on a powerful workstation and access it from your lightweight laptop.
- **Code Anywhere**: Tunnel in via VPN or SSH to code securely from coffee shops or while traveling.
- **Multi-Device**: The responsive web client works on tablets and iPads, turning any screen into a dev terminal.
- **Always-On**: Run as a background service so your sessions are always ready when you connect.
### ⚡️ Workspace Power
- **Multi-Instance**: Juggle multiple OpenCode sessions side-by-side with per-instance tabs.
- **Long-Context Native**: Scroll through massive transcripts without hitches.
- **Deep Task Awareness**: Monitor background tasks and child sessions without losing your flow.
- **Command Palette**: A single, global palette to jump tabs, launch tools, and fire shortcuts.
## Prerequisites
- **OpenCode**: `opencode` must be installed and configured on your system.
- Node.js 18+ and npm (for running or building from source).
- A workspace folder on disk you want to serve.
- Optional: a Chromium-based browser if you want `--launch` to open the UI automatically.
## Usage
### Run via npx (Recommended)
You can run CodeNomad directly without installing it:
```sh
npx @neuralnomads/codenomad --launch
```
### Install Globally
Or install it globally to use the `codenomad` command:
```sh
npm install -g @neuralnomads/codenomad
codenomad --launch
```
### Common Flags
You can configure the server using flags or environment variables:
| Flag | Env Variable | Description |
|------|--------------|-------------|
| `--port <number>` | `CLI_PORT` | HTTP port (default 9898) |
| `--host <addr>` | `CLI_HOST` | Interface to bind (default 127.0.0.1) |
| `--workspace-root <path>` | `CLI_WORKSPACE_ROOT` | Default root for new workspaces |
| `--unrestricted-root` | `CLI_UNRESTRICTED_ROOT` | Allow full-filesystem browsing |
| `--config <path>` | `CLI_CONFIG` | Config file location |
| `--launch` | `CLI_LAUNCH` | Open the UI in a Chromium-based browser |
| `--log-level <level>` | `CLI_LOG_LEVEL` | Logging level (trace, debug, info, warn, error) |
### Data Storage
- **Config**: `~/.config/codenomad/config.json`
- **Instance Data**: `~/.config/codenomad/instances` (chat history, etc.)

1333
packages/server/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,43 @@
{
"name": "@neuralnomads/codenomad",
"version": "0.4.0",
"description": "CodeNomad Server",
"author": {
"name": "Neural Nomads",
"email": "codenomad@neuralnomads.ai"
},
"repository": {
"type": "git",
"url": "https://github.com/NeuralNomadsAI/CodeNomad.git"
},
"type": "module",
"main": "dist/index.js",
"bin": {
"codenomad": "dist/bin.js"
},
"scripts": {
"build": "npm run build:ui && npm run prepare-ui && tsc -p tsconfig.json && npm run prepare-config",
"build:ui": "npm run build --prefix ../ui",
"prepare-ui": "node ./scripts/copy-ui-dist.mjs",
"prepare-config": "node ./scripts/copy-opencode-config.mjs",
"dev": "cross-env CODENOMAD_DEV=1 CLI_UI_DEV_SERVER=http://localhost:3000 tsx src/index.ts",
"typecheck": "tsc --noEmit -p tsconfig.json"
},
"dependencies": {
"@fastify/cors": "^8.5.0",
"@fastify/reply-from": "^9.8.0",
"@fastify/static": "^7.0.4",
"commander": "^12.1.0",
"fastify": "^4.28.1",
"fuzzysort": "^2.0.4",
"pino": "^9.4.0",
"undici": "^6.19.8",
"zod": "^3.23.8"
},
"devDependencies": {
"cross-env": "^7.0.3",
"ts-node": "^10.9.2",
"tsx": "^4.20.6",
"typescript": "^5.6.3"
}
}

View File

@@ -0,0 +1,21 @@
#!/usr/bin/env node
import { cpSync, existsSync, mkdirSync, rmSync } from "fs"
import path from "path"
import { fileURLToPath } from "url"
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const cliRoot = path.resolve(__dirname, "..")
const sourceDir = path.resolve(cliRoot, "../opencode-config")
const targetDir = path.resolve(cliRoot, "dist/opencode-config")
if (!existsSync(sourceDir)) {
console.error(`[copy-opencode-config] Missing source directory at ${sourceDir}`)
process.exit(1)
}
rmSync(targetDir, { recursive: true, force: true })
mkdirSync(path.dirname(targetDir), { recursive: true })
cpSync(sourceDir, targetDir, { recursive: true })
console.log(`[copy-opencode-config] Copied ${sourceDir} -> ${targetDir}`)

View File

@@ -0,0 +1,21 @@
#!/usr/bin/env node
import { cpSync, existsSync, mkdirSync, rmSync } from "fs"
import path from "path"
import { fileURLToPath } from "url"
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const cliRoot = path.resolve(__dirname, "..")
const uiDistDir = path.resolve(cliRoot, "../ui/src/renderer/dist")
const targetDir = path.resolve(cliRoot, "public")
if (!existsSync(uiDistDir)) {
console.error(`[copy-ui-dist] Expected UI build artifacts at ${uiDistDir}. Run the UI build before bundling the CLI.`)
process.exit(1)
}
rmSync(targetDir, { recursive: true, force: true })
mkdirSync(targetDir, { recursive: true })
cpSync(uiDistDir, targetDir, { recursive: true })
console.log(`[copy-ui-dist] Copied UI bundle from ${uiDistDir} -> ${targetDir}`)

View File

@@ -0,0 +1,228 @@
import type {
AgentModelSelection,
AgentModelSelections,
ConfigFile,
ModelPreference,
OpenCodeBinary,
Preferences,
RecentFolder,
} from "./config/schema"
/**
* Canonical HTTP/SSE contract for the CLI server.
* These types are consumed by both the CLI implementation and any UI clients.
*/
export type WorkspaceStatus = "starting" | "ready" | "stopped" | "error"
export interface WorkspaceDescriptor {
id: string
/** Absolute path on the server host. */
path: string
name?: string
status: WorkspaceStatus
/** PID/port are populated when the workspace is running. */
pid?: number
port?: number
/** Canonical proxy path the CLI exposes for this instance. */
proxyPath: string
/** Identifier of the binary resolved from config. */
binaryId: string
binaryLabel: string
binaryVersion?: string
createdAt: string
updatedAt: string
/** Present when `status` is "error". */
error?: string
}
export interface WorkspaceCreateRequest {
path: string
name?: string
}
export type WorkspaceCreateResponse = WorkspaceDescriptor
export type WorkspaceListResponse = WorkspaceDescriptor[]
export type WorkspaceDetailResponse = WorkspaceDescriptor
export interface WorkspaceDeleteResponse {
id: string
status: WorkspaceStatus
}
export type LogLevel = "debug" | "info" | "warn" | "error"
export interface WorkspaceLogEntry {
workspaceId: string
timestamp: string
level: LogLevel
message: string
}
export interface FileSystemEntry {
name: string
/** Path relative to the CLI server root ("." represents the root itself). */
path: string
/** Absolute path when available (unrestricted listings). */
absolutePath?: string
type: "file" | "directory"
size?: number
/** ISO timestamp of last modification when available. */
modifiedAt?: string
}
export type FileSystemScope = "restricted" | "unrestricted"
export type FileSystemPathKind = "relative" | "absolute" | "drives"
export interface FileSystemListingMetadata {
scope: FileSystemScope
/** Canonical identifier of the current view ("." for restricted roots, absolute paths otherwise). */
currentPath: string
/** Optional parent path if navigation upward is allowed. */
parentPath?: string
/** Absolute path representing the root or origin point for this listing. */
rootPath: string
/** Absolute home directory of the CLI host (useful defaults for unrestricted mode). */
homePath: string
/** Human-friendly label for the current path. */
displayPath: string
/** Indicates whether entry paths are relative, absolute, or represent drive roots. */
pathKind: FileSystemPathKind
}
export interface FileSystemListResponse {
entries: FileSystemEntry[]
metadata: FileSystemListingMetadata
}
export const WINDOWS_DRIVES_ROOT = "__drives__"
export interface WorkspaceFileResponse {
workspaceId: string
relativePath: string
/** UTF-8 file contents; binary files should be base64 encoded by the caller. */
contents: string
}
export type WorkspaceFileSearchResponse = FileSystemEntry[]
export interface InstanceData {
messageHistory: string[]
agentModelSelections: AgentModelSelection
}
export type InstanceStreamStatus = "connecting" | "connected" | "error" | "disconnected"
export interface InstanceStreamEvent {
type: string
properties?: Record<string, unknown>
[key: string]: unknown
}
export interface BinaryRecord {
id: string
path: string
label: string
version?: string
/** Indicates that this binary will be picked when workspaces omit an explicit choice. */
isDefault: boolean
lastValidatedAt?: string
validationError?: string
}
export type AppConfig = ConfigFile
export type AppConfigResponse = AppConfig
export type AppConfigUpdateRequest = Partial<AppConfig>
export interface BinaryListResponse {
binaries: BinaryRecord[]
}
export interface BinaryCreateRequest {
path: string
label?: string
makeDefault?: boolean
}
export interface BinaryUpdateRequest {
label?: string
makeDefault?: boolean
}
export interface BinaryValidationResult {
valid: boolean
version?: string
error?: string
}
export type WorkspaceEventType =
| "workspace.created"
| "workspace.started"
| "workspace.error"
| "workspace.stopped"
| "workspace.log"
| "config.appChanged"
| "config.binariesChanged"
| "instance.dataChanged"
| "instance.event"
| "instance.eventStatus"
| "app.releaseAvailable"
export type WorkspaceEventPayload =
| { type: "workspace.created"; workspace: WorkspaceDescriptor }
| { type: "workspace.started"; workspace: WorkspaceDescriptor }
| { type: "workspace.error"; workspace: WorkspaceDescriptor }
| { type: "workspace.stopped"; workspaceId: string }
| { type: "workspace.log"; entry: WorkspaceLogEntry }
| { type: "config.appChanged"; config: AppConfig }
| { type: "config.binariesChanged"; binaries: BinaryRecord[] }
| { type: "instance.dataChanged"; instanceId: string; data: InstanceData }
| { type: "instance.event"; instanceId: string; event: InstanceStreamEvent }
| { type: "instance.eventStatus"; instanceId: string; status: InstanceStreamStatus; reason?: string }
| { type: "app.releaseAvailable"; release: LatestReleaseInfo }
export interface NetworkAddress {
ip: string
family: "ipv4" | "ipv6"
scope: "external" | "internal" | "loopback"
url: string
}
export interface LatestReleaseInfo {
version: string
tag: string
url: string
channel: "stable" | "dev"
publishedAt?: string
notes?: string
}
export interface ServerMeta {
/** Base URL clients should target for REST calls (useful for Electron embedding). */
httpBaseUrl: string
/** SSE endpoint advertised to clients (`/api/events` by default). */
eventsUrl: string
/** Host the server is bound to (e.g., 127.0.0.1 or 0.0.0.0). */
host: string
/** Listening mode derived from host binding. */
listeningMode: "local" | "all"
/** Actual port in use after binding. */
port: number
/** Display label for the host (e.g., hostname or friendly name). */
hostLabel: string
/** Absolute path of the filesystem root exposed to clients. */
workspaceRoot: string
/** Reachable addresses for this server, external first. */
addresses: NetworkAddress[]
/** Optional metadata about the most recent public release. */
latestRelease?: LatestReleaseInfo
}
export type {
Preferences,
ModelPreference,
AgentModelSelections,
RecentFolder,
OpenCodeBinary,
}

View File

@@ -0,0 +1,29 @@
#!/usr/bin/env node
import { spawn } from "child_process"
import path from "path"
import { fileURLToPath, pathToFileURL } from "url"
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const cliEntry = path.join(__dirname, "index.js")
const loaderFileUrl = pathToFileURL(path.join(__dirname, "loader.js")).href
const registerScript = `import { register } from "node:module"; import { pathToFileURL } from "node:url"; register("${encodeURI(loaderFileUrl)}", pathToFileURL("./"));`
const loaderArg = `data:text/javascript,${registerScript}`
const child = spawn(process.execPath, ["--import", loaderArg, cliEntry, ...process.argv.slice(2)], {
stdio: "inherit",
})
child.on("exit", (code, signal) => {
if (signal) {
process.kill(process.pid, signal)
return
}
process.exit(code ?? 0)
})
child.on("error", (error) => {
console.error("Failed to launch CLI runtime", error)
process.exit(1)
})

View File

@@ -0,0 +1,156 @@
import {
BinaryCreateRequest,
BinaryRecord,
BinaryUpdateRequest,
BinaryValidationResult,
} from "../api-types"
import { ConfigStore } from "./store"
import { EventBus } from "../events/bus"
import type { ConfigFile } from "./schema"
import { Logger } from "../logger"
export class BinaryRegistry {
constructor(
private readonly configStore: ConfigStore,
private readonly eventBus: EventBus | undefined,
private readonly logger: Logger,
) {}
list(): BinaryRecord[] {
return this.mapRecords()
}
resolveDefault(): BinaryRecord {
const binaries = this.mapRecords()
if (binaries.length === 0) {
this.logger.warn("No configured binaries found, falling back to opencode")
return this.buildFallbackRecord("opencode")
}
return binaries.find((binary) => binary.isDefault) ?? binaries[0]
}
create(request: BinaryCreateRequest): BinaryRecord {
this.logger.debug({ path: request.path }, "Registering OpenCode binary")
const entry = {
path: request.path,
version: undefined,
lastUsed: Date.now(),
label: request.label,
}
const config = this.configStore.get()
const nextConfig = this.cloneConfig(config)
const deduped = nextConfig.opencodeBinaries.filter((binary) => binary.path !== request.path)
nextConfig.opencodeBinaries = [entry, ...deduped]
if (request.makeDefault) {
nextConfig.preferences.lastUsedBinary = request.path
}
this.configStore.replace(nextConfig)
const record = this.getById(request.path)
this.emitChange()
return record
}
update(id: string, updates: BinaryUpdateRequest): BinaryRecord {
this.logger.debug({ id }, "Updating OpenCode binary")
const config = this.configStore.get()
const nextConfig = this.cloneConfig(config)
nextConfig.opencodeBinaries = nextConfig.opencodeBinaries.map((binary) =>
binary.path === id ? { ...binary, label: updates.label ?? binary.label } : binary,
)
if (updates.makeDefault) {
nextConfig.preferences.lastUsedBinary = id
}
this.configStore.replace(nextConfig)
const record = this.getById(id)
this.emitChange()
return record
}
remove(id: string) {
this.logger.debug({ id }, "Removing OpenCode binary")
const config = this.configStore.get()
const nextConfig = this.cloneConfig(config)
const remaining = nextConfig.opencodeBinaries.filter((binary) => binary.path !== id)
nextConfig.opencodeBinaries = remaining
if (nextConfig.preferences.lastUsedBinary === id) {
nextConfig.preferences.lastUsedBinary = remaining[0]?.path
}
this.configStore.replace(nextConfig)
this.emitChange()
}
validatePath(path: string): BinaryValidationResult {
this.logger.debug({ path }, "Validating OpenCode binary path")
return this.validateRecord({
id: path,
path,
label: this.prettyLabel(path),
isDefault: false,
})
}
private cloneConfig(config: ConfigFile): ConfigFile {
return JSON.parse(JSON.stringify(config)) as ConfigFile
}
private mapRecords(): BinaryRecord[] {
const config = this.configStore.get()
const configuredBinaries = config.opencodeBinaries.map<BinaryRecord>((binary) => ({
id: binary.path,
path: binary.path,
label: binary.label ?? this.prettyLabel(binary.path),
version: binary.version,
isDefault: false,
}))
const defaultPath = config.preferences.lastUsedBinary ?? configuredBinaries[0]?.path ?? "opencode"
const annotated = configuredBinaries.map((binary) => ({
...binary,
isDefault: binary.path === defaultPath,
}))
if (!annotated.some((binary) => binary.path === defaultPath)) {
annotated.unshift(this.buildFallbackRecord(defaultPath))
}
return annotated
}
private getById(id: string): BinaryRecord {
return this.mapRecords().find((binary) => binary.id === id) ?? this.buildFallbackRecord(id)
}
private emitChange() {
this.logger.debug("Emitting binaries changed event")
this.eventBus?.publish({ type: "config.binariesChanged", binaries: this.mapRecords() })
}
private validateRecord(record: BinaryRecord): BinaryValidationResult {
// TODO: call actual binary -v check.
return { valid: true, version: record.version }
}
private buildFallbackRecord(path: string): BinaryRecord {
return {
id: path,
path,
label: this.prettyLabel(path),
isDefault: true,
}
}
private prettyLabel(path: string) {
const parts = path.split(/[\\/]/)
const last = parts[parts.length - 1] || path
return last || path
}
}

View File

@@ -0,0 +1,64 @@
import { z } from "zod"
const ModelPreferenceSchema = z.object({
providerId: z.string(),
modelId: z.string(),
})
const AgentModelSelectionSchema = z.record(z.string(), ModelPreferenceSchema)
const AgentModelSelectionsSchema = z.record(z.string(), AgentModelSelectionSchema)
const PreferencesSchema = z.object({
showThinkingBlocks: z.boolean().default(false),
thinkingBlocksExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
showTimelineTools: z.boolean().default(true),
lastUsedBinary: z.string().optional(),
environmentVariables: z.record(z.string()).default({}),
modelRecents: z.array(ModelPreferenceSchema).default([]),
diffViewMode: z.enum(["split", "unified"]).default("split"),
toolOutputExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
diagnosticsExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
showUsageMetrics: z.boolean().default(true),
autoCleanupBlankSessions: z.boolean().default(true),
listeningMode: z.enum(["local", "all"]).default("local"),
})
const RecentFolderSchema = z.object({
path: z.string(),
lastAccessed: z.number().nonnegative(),
})
const OpenCodeBinarySchema = z.object({
path: z.string(),
version: z.string().optional(),
lastUsed: z.number().nonnegative(),
label: z.string().optional(),
})
const ConfigFileSchema = z.object({
preferences: PreferencesSchema.default({}),
recentFolders: z.array(RecentFolderSchema).default([]),
opencodeBinaries: z.array(OpenCodeBinarySchema).default([]),
theme: z.enum(["light", "dark", "system"]).optional(),
})
const DEFAULT_CONFIG = ConfigFileSchema.parse({})
export {
ModelPreferenceSchema,
AgentModelSelectionSchema,
AgentModelSelectionsSchema,
PreferencesSchema,
RecentFolderSchema,
OpenCodeBinarySchema,
ConfigFileSchema,
DEFAULT_CONFIG,
}
export type ModelPreference = z.infer<typeof ModelPreferenceSchema>
export type AgentModelSelection = z.infer<typeof AgentModelSelectionSchema>
export type AgentModelSelections = z.infer<typeof AgentModelSelectionsSchema>
export type Preferences = z.infer<typeof PreferencesSchema>
export type RecentFolder = z.infer<typeof RecentFolderSchema>
export type OpenCodeBinary = z.infer<typeof OpenCodeBinarySchema>
export type ConfigFile = z.infer<typeof ConfigFileSchema>

View File

@@ -0,0 +1,78 @@
import fs from "fs"
import path from "path"
import { EventBus } from "../events/bus"
import { Logger } from "../logger"
import { ConfigFile, ConfigFileSchema, DEFAULT_CONFIG } from "./schema"
export class ConfigStore {
private cache: ConfigFile = DEFAULT_CONFIG
private loaded = false
constructor(
private readonly configPath: string,
private readonly eventBus: EventBus | undefined,
private readonly logger: Logger,
) {}
load(): ConfigFile {
if (this.loaded) {
return this.cache
}
try {
const resolved = this.resolvePath(this.configPath)
if (fs.existsSync(resolved)) {
const content = fs.readFileSync(resolved, "utf-8")
const parsed = JSON.parse(content)
this.cache = ConfigFileSchema.parse(parsed)
this.logger.debug({ resolved }, "Loaded existing config file")
} else {
this.cache = DEFAULT_CONFIG
this.logger.debug({ resolved }, "No config file found, using defaults")
}
} catch (error) {
this.logger.warn({ err: error }, "Failed to load config, using defaults")
this.cache = DEFAULT_CONFIG
}
this.loaded = true
return this.cache
}
get(): ConfigFile {
return this.load()
}
replace(config: ConfigFile) {
const validated = ConfigFileSchema.parse(config)
this.commit(validated)
}
private commit(next: ConfigFile) {
this.cache = next
this.loaded = true
this.persist()
const published = Boolean(this.eventBus)
this.eventBus?.publish({ type: "config.appChanged", config: this.cache })
this.logger.debug({ broadcast: published }, "Config SSE event emitted")
this.logger.trace({ config: this.cache }, "Config payload")
}
private persist() {
try {
const resolved = this.resolvePath(this.configPath)
fs.mkdirSync(path.dirname(resolved), { recursive: true })
fs.writeFileSync(resolved, JSON.stringify(this.cache, null, 2), "utf-8")
this.logger.debug({ resolved }, "Persisted config file")
} catch (error) {
this.logger.warn({ err: error }, "Failed to persist config")
}
}
private resolvePath(filePath: string) {
if (filePath.startsWith("~/")) {
return path.join(process.env.HOME ?? "", filePath.slice(2))
}
return path.resolve(filePath)
}
}

View File

@@ -0,0 +1,47 @@
import { EventEmitter } from "events"
import { WorkspaceEventPayload } from "../api-types"
import { Logger } from "../logger"
export class EventBus extends EventEmitter {
constructor(private readonly logger?: Logger) {
super()
}
publish(event: WorkspaceEventPayload): boolean {
if (event.type !== "instance.event" && event.type !== "instance.eventStatus") {
this.logger?.debug({ type: event.type }, "Publishing workspace event")
if (this.logger?.isLevelEnabled("trace")) {
this.logger.trace({ event }, "Workspace event payload")
}
}
return super.emit(event.type, event)
}
onEvent(listener: (event: WorkspaceEventPayload) => void) {
const handler = (event: WorkspaceEventPayload) => listener(event)
this.on("workspace.created", handler)
this.on("workspace.started", handler)
this.on("workspace.error", handler)
this.on("workspace.stopped", handler)
this.on("workspace.log", handler)
this.on("config.appChanged", handler)
this.on("config.binariesChanged", handler)
this.on("instance.dataChanged", handler)
this.on("instance.event", handler)
this.on("instance.eventStatus", handler)
this.on("app.releaseAvailable", handler)
return () => {
this.off("workspace.created", handler)
this.off("workspace.started", handler)
this.off("workspace.error", handler)
this.off("workspace.stopped", handler)
this.off("workspace.log", handler)
this.off("config.appChanged", handler)
this.off("config.binariesChanged", handler)
this.off("instance.dataChanged", handler)
this.off("instance.event", handler)
this.off("instance.eventStatus", handler)
this.off("app.releaseAvailable", handler)
}
}
}

View File

@@ -0,0 +1,61 @@
import assert from "node:assert/strict"
import { beforeEach, describe, it } from "node:test"
import type { FileSystemEntry } from "../../api-types"
import {
clearWorkspaceSearchCache,
getWorkspaceCandidates,
refreshWorkspaceCandidates,
WORKSPACE_CANDIDATE_CACHE_TTL_MS,
} from "../search-cache"
describe("workspace search cache", () => {
beforeEach(() => {
clearWorkspaceSearchCache()
})
it("expires cached candidates after the TTL", () => {
const workspacePath = "/tmp/workspace"
const startTime = 1_000
refreshWorkspaceCandidates(workspacePath, () => [createEntry("file-a")], startTime)
const beforeExpiry = getWorkspaceCandidates(
workspacePath,
startTime + WORKSPACE_CANDIDATE_CACHE_TTL_MS - 1,
)
assert.ok(beforeExpiry)
assert.equal(beforeExpiry.length, 1)
assert.equal(beforeExpiry[0].name, "file-a")
const afterExpiry = getWorkspaceCandidates(
workspacePath,
startTime + WORKSPACE_CANDIDATE_CACHE_TTL_MS + 1,
)
assert.equal(afterExpiry, undefined)
})
it("replaces cached entries when manually refreshed", () => {
const workspacePath = "/tmp/workspace"
refreshWorkspaceCandidates(workspacePath, () => [createEntry("file-a")], 5_000)
const initial = getWorkspaceCandidates(workspacePath)
assert.ok(initial)
assert.equal(initial[0].name, "file-a")
refreshWorkspaceCandidates(workspacePath, () => [createEntry("file-b")], 6_000)
const refreshed = getWorkspaceCandidates(workspacePath)
assert.ok(refreshed)
assert.equal(refreshed[0].name, "file-b")
})
})
function createEntry(name: string): FileSystemEntry {
return {
name,
path: name,
absolutePath: `/tmp/${name}`,
type: "file",
size: 1,
modifiedAt: new Date().toISOString(),
}
}

View File

@@ -0,0 +1,295 @@
import fs from "fs"
import os from "os"
import path from "path"
import {
FileSystemEntry,
FileSystemListResponse,
FileSystemListingMetadata,
WINDOWS_DRIVES_ROOT,
} from "../api-types"
interface FileSystemBrowserOptions {
rootDir: string
unrestricted?: boolean
}
interface DirectoryReadOptions {
includeFiles: boolean
formatPath: (entryName: string) => string
formatAbsolutePath: (entryName: string) => string
}
const WINDOWS_DRIVE_LETTERS = Array.from({ length: 26 }, (_, i) => String.fromCharCode(65 + i))
export class FileSystemBrowser {
private readonly root: string
private readonly unrestricted: boolean
private readonly homeDir: string
private readonly isWindows: boolean
constructor(options: FileSystemBrowserOptions) {
this.root = path.resolve(options.rootDir)
this.unrestricted = Boolean(options.unrestricted)
this.homeDir = os.homedir()
this.isWindows = process.platform === "win32"
}
list(relativePath = ".", options: { includeFiles?: boolean } = {}): FileSystemEntry[] {
if (this.unrestricted) {
throw new Error("Relative listing is unavailable when running with unrestricted root")
}
const includeFiles = options.includeFiles ?? true
const normalizedPath = this.normalizeRelativePath(relativePath)
const absolutePath = this.toRestrictedAbsolute(normalizedPath)
return this.readDirectoryEntries(absolutePath, {
includeFiles,
formatPath: (entryName) => this.buildRelativePath(normalizedPath, entryName),
formatAbsolutePath: (entryName) => this.resolveRestrictedAbsoluteChild(normalizedPath, entryName),
})
}
browse(targetPath?: string, options: { includeFiles?: boolean } = {}): FileSystemListResponse {
const includeFiles = options.includeFiles ?? true
if (this.unrestricted) {
return this.listUnrestricted(targetPath, includeFiles)
}
return this.listRestrictedWithMetadata(targetPath, includeFiles)
}
readFile(relativePath: string): string {
if (this.unrestricted) {
throw new Error("readFile is not available in unrestricted mode")
}
const resolved = this.toRestrictedAbsolute(relativePath)
return fs.readFileSync(resolved, "utf-8")
}
private listRestrictedWithMetadata(relativePath: string | undefined, includeFiles: boolean): FileSystemListResponse {
const normalizedPath = this.normalizeRelativePath(relativePath)
const absolutePath = this.toRestrictedAbsolute(normalizedPath)
const entries = this.readDirectoryEntries(absolutePath, {
includeFiles,
formatPath: (entryName) => this.buildRelativePath(normalizedPath, entryName),
formatAbsolutePath: (entryName) => this.resolveRestrictedAbsoluteChild(normalizedPath, entryName),
})
const metadata: FileSystemListingMetadata = {
scope: "restricted",
currentPath: normalizedPath,
parentPath: normalizedPath === "." ? undefined : this.getRestrictedParent(normalizedPath),
rootPath: this.root,
homePath: this.homeDir,
displayPath: this.resolveRestrictedAbsolute(normalizedPath),
pathKind: "relative",
}
return { entries, metadata }
}
private listUnrestricted(targetPath: string | undefined, includeFiles: boolean): FileSystemListResponse {
const resolvedPath = this.resolveUnrestrictedPath(targetPath)
if (this.isWindows && resolvedPath === WINDOWS_DRIVES_ROOT) {
return this.listWindowsDrives()
}
const entries = this.readDirectoryEntries(resolvedPath, {
includeFiles,
formatPath: (entryName) => this.resolveAbsoluteChild(resolvedPath, entryName),
formatAbsolutePath: (entryName) => this.resolveAbsoluteChild(resolvedPath, entryName),
})
const parentPath = this.getUnrestrictedParent(resolvedPath)
const metadata: FileSystemListingMetadata = {
scope: "unrestricted",
currentPath: resolvedPath,
parentPath,
rootPath: this.homeDir,
homePath: this.homeDir,
displayPath: resolvedPath,
pathKind: "absolute",
}
return { entries, metadata }
}
private listWindowsDrives(): FileSystemListResponse {
if (!this.isWindows) {
throw new Error("Drive listing is only supported on Windows hosts")
}
const entries: FileSystemEntry[] = []
for (const letter of WINDOWS_DRIVE_LETTERS) {
const drivePath = `${letter}:\\`
try {
if (fs.existsSync(drivePath)) {
entries.push({
name: `${letter}:`,
path: drivePath,
absolutePath: drivePath,
type: "directory",
})
}
} catch {
// Ignore inaccessible drives
}
}
// Provide a generic UNC root entry so users can navigate to network shares manually.
entries.push({
name: "UNC Network",
path: "\\\\",
absolutePath: "\\\\",
type: "directory",
})
const metadata: FileSystemListingMetadata = {
scope: "unrestricted",
currentPath: WINDOWS_DRIVES_ROOT,
parentPath: undefined,
rootPath: this.homeDir,
homePath: this.homeDir,
displayPath: "Drives",
pathKind: "drives",
}
return { entries, metadata }
}
private readDirectoryEntries(directory: string, options: DirectoryReadOptions): FileSystemEntry[] {
const dirents = fs.readdirSync(directory, { withFileTypes: true })
const results: FileSystemEntry[] = []
for (const entry of dirents) {
if (!options.includeFiles && !entry.isDirectory()) {
continue
}
const absoluteEntryPath = path.join(directory, entry.name)
let stats: fs.Stats
try {
stats = fs.statSync(absoluteEntryPath)
} catch {
// Skip entries we cannot stat (insufficient permissions, etc.)
continue
}
const isDirectory = entry.isDirectory()
if (!options.includeFiles && !isDirectory) {
continue
}
results.push({
name: entry.name,
path: options.formatPath(entry.name),
absolutePath: options.formatAbsolutePath(entry.name),
type: isDirectory ? "directory" : "file",
size: isDirectory ? undefined : stats.size,
modifiedAt: stats.mtime.toISOString(),
})
}
return results.sort((a, b) => a.name.localeCompare(b.name))
}
private normalizeRelativePath(input: string | undefined) {
if (!input || input === "." || input === "./" || input === "/") {
return "."
}
let normalized = input.replace(/\\+/g, "/")
if (normalized.startsWith("./")) {
normalized = normalized.replace(/^\.\/+/, "")
}
if (normalized.startsWith("/")) {
normalized = normalized.replace(/^\/+/g, "")
}
return normalized === "" ? "." : normalized
}
private buildRelativePath(parent: string, child: string) {
if (!parent || parent === ".") {
return this.normalizeRelativePath(child)
}
return this.normalizeRelativePath(`${parent}/${child}`)
}
private resolveRestrictedAbsolute(relativePath: string) {
return this.toRestrictedAbsolute(relativePath)
}
private resolveRestrictedAbsoluteChild(parent: string, child: string) {
const normalized = this.buildRelativePath(parent, child)
return this.toRestrictedAbsolute(normalized)
}
private toRestrictedAbsolute(relativePath: string) {
const normalized = this.normalizeRelativePath(relativePath)
const target = path.resolve(this.root, normalized)
const relativeToRoot = path.relative(this.root, target)
if (relativeToRoot.startsWith("..") || path.isAbsolute(relativeToRoot) && relativeToRoot !== "") {
throw new Error("Access outside of root is not allowed")
}
return target
}
private resolveUnrestrictedPath(input: string | undefined): string {
if (!input || input === "." || input === "./") {
return this.homeDir
}
if (this.isWindows) {
if (input === WINDOWS_DRIVES_ROOT) {
return WINDOWS_DRIVES_ROOT
}
const normalized = path.win32.normalize(input)
if (/^[a-zA-Z]:/.test(normalized) || normalized.startsWith("\\\\")) {
return normalized
}
return path.win32.resolve(this.homeDir, normalized)
}
if (input.startsWith("/")) {
return path.posix.normalize(input)
}
return path.posix.resolve(this.homeDir, input)
}
private resolveAbsoluteChild(parent: string, child: string) {
if (this.isWindows) {
return path.win32.normalize(path.win32.join(parent, child))
}
return path.posix.normalize(path.posix.join(parent, child))
}
private getRestrictedParent(relativePath: string) {
const normalized = this.normalizeRelativePath(relativePath)
if (normalized === ".") {
return undefined
}
const segments = normalized.split("/")
segments.pop()
return segments.length === 0 ? "." : segments.join("/")
}
private getUnrestrictedParent(currentPath: string) {
if (this.isWindows) {
const normalized = path.win32.normalize(currentPath)
const parsed = path.win32.parse(normalized)
if (normalized === WINDOWS_DRIVES_ROOT) {
return undefined
}
if (normalized === parsed.root) {
return WINDOWS_DRIVES_ROOT
}
return path.win32.dirname(normalized)
}
const normalized = path.posix.normalize(currentPath)
if (normalized === "/") {
return undefined
}
return path.posix.dirname(normalized)
}
}

View File

@@ -0,0 +1,66 @@
import path from "path"
import type { FileSystemEntry } from "../api-types"
export const WORKSPACE_CANDIDATE_CACHE_TTL_MS = 30_000
interface WorkspaceCandidateCacheEntry {
expiresAt: number
candidates: FileSystemEntry[]
}
const workspaceCandidateCache = new Map<string, WorkspaceCandidateCacheEntry>()
export function getWorkspaceCandidates(rootDir: string, now = Date.now()): FileSystemEntry[] | undefined {
const key = normalizeKey(rootDir)
const cached = workspaceCandidateCache.get(key)
if (!cached) {
return undefined
}
if (cached.expiresAt <= now) {
workspaceCandidateCache.delete(key)
return undefined
}
return cloneEntries(cached.candidates)
}
export function refreshWorkspaceCandidates(
rootDir: string,
builder: () => FileSystemEntry[],
now = Date.now(),
): FileSystemEntry[] {
const key = normalizeKey(rootDir)
const freshCandidates = builder()
if (!freshCandidates || freshCandidates.length === 0) {
workspaceCandidateCache.delete(key)
return []
}
const storedCandidates = cloneEntries(freshCandidates)
workspaceCandidateCache.set(key, {
expiresAt: now + WORKSPACE_CANDIDATE_CACHE_TTL_MS,
candidates: storedCandidates,
})
return cloneEntries(storedCandidates)
}
export function clearWorkspaceSearchCache(rootDir?: string) {
if (typeof rootDir === "undefined") {
workspaceCandidateCache.clear()
return
}
const key = normalizeKey(rootDir)
workspaceCandidateCache.delete(key)
}
function cloneEntries(entries: FileSystemEntry[]): FileSystemEntry[] {
return entries.map((entry) => ({ ...entry }))
}
function normalizeKey(rootDir: string) {
return path.resolve(rootDir)
}

View File

@@ -0,0 +1,184 @@
import fs from "fs"
import path from "path"
import fuzzysort from "fuzzysort"
import type { FileSystemEntry } from "../api-types"
import { clearWorkspaceSearchCache, getWorkspaceCandidates, refreshWorkspaceCandidates } from "./search-cache"
const DEFAULT_LIMIT = 100
const MAX_LIMIT = 200
const MAX_CANDIDATES = 8000
const IGNORED_DIRECTORIES = new Set(
[".git", ".hg", ".svn", "node_modules", "dist", "build", ".next", ".nuxt", ".turbo", ".cache", "coverage"].map(
(name) => name.toLowerCase(),
),
)
export type WorkspaceFileSearchType = "all" | "file" | "directory"
export interface WorkspaceFileSearchOptions {
limit?: number
type?: WorkspaceFileSearchType
refresh?: boolean
}
interface CandidateEntry {
entry: FileSystemEntry
key: string
}
export function searchWorkspaceFiles(
rootDir: string,
query: string,
options: WorkspaceFileSearchOptions = {},
): FileSystemEntry[] {
const trimmedQuery = query.trim()
if (!trimmedQuery) {
throw new Error("Search query is required")
}
const normalizedRoot = path.resolve(rootDir)
const limit = normalizeLimit(options.limit)
const typeFilter: WorkspaceFileSearchType = options.type ?? "all"
const refreshRequested = options.refresh === true
let entries: FileSystemEntry[] | undefined
try {
if (!refreshRequested) {
entries = getWorkspaceCandidates(normalizedRoot)
}
if (!entries) {
entries = refreshWorkspaceCandidates(normalizedRoot, () => collectCandidates(normalizedRoot))
}
} catch (error) {
clearWorkspaceSearchCache(normalizedRoot)
throw error
}
if (!entries || entries.length === 0) {
clearWorkspaceSearchCache(normalizedRoot)
return []
}
const candidates = buildCandidateEntries(entries, typeFilter)
if (candidates.length === 0) {
return []
}
const matches = fuzzysort.go<CandidateEntry>(trimmedQuery, candidates, {
key: "key",
limit,
})
if (!matches || matches.length === 0) {
return []
}
return matches.map((match) => match.obj.entry)
}
function collectCandidates(rootDir: string): FileSystemEntry[] {
const queue: string[] = [""]
const entries: FileSystemEntry[] = []
while (queue.length > 0 && entries.length < MAX_CANDIDATES) {
const relativeDir = queue.pop() || ""
const absoluteDir = relativeDir ? path.join(rootDir, relativeDir) : rootDir
let dirents: fs.Dirent[]
try {
dirents = fs.readdirSync(absoluteDir, { withFileTypes: true })
} catch {
continue
}
for (const dirent of dirents) {
const entryName = dirent.name
const lowerName = entryName.toLowerCase()
const relativePath = relativeDir ? `${relativeDir}/${entryName}` : entryName
const absolutePath = path.join(absoluteDir, entryName)
if (dirent.isDirectory() && IGNORED_DIRECTORIES.has(lowerName)) {
continue
}
let stats: fs.Stats
try {
stats = fs.statSync(absolutePath)
} catch {
continue
}
const isDirectory = stats.isDirectory()
if (isDirectory && !IGNORED_DIRECTORIES.has(lowerName)) {
if (entries.length < MAX_CANDIDATES) {
queue.push(relativePath)
}
}
const entryType: FileSystemEntry["type"] = isDirectory ? "directory" : "file"
const normalizedPath = normalizeRelativeEntryPath(relativePath)
const entry: FileSystemEntry = {
name: entryName,
path: normalizedPath,
absolutePath: path.resolve(rootDir, normalizedPath === "." ? "" : normalizedPath),
type: entryType,
size: entryType === "file" ? stats.size : undefined,
modifiedAt: stats.mtime.toISOString(),
}
entries.push(entry)
if (entries.length >= MAX_CANDIDATES) {
break
}
}
}
return entries
}
function buildCandidateEntries(entries: FileSystemEntry[], filter: WorkspaceFileSearchType): CandidateEntry[] {
const filtered: CandidateEntry[] = []
for (const entry of entries) {
if (!shouldInclude(entry.type, filter)) {
continue
}
filtered.push({ entry, key: buildSearchKey(entry) })
}
return filtered
}
function normalizeLimit(limit?: number) {
if (!limit || Number.isNaN(limit)) {
return DEFAULT_LIMIT
}
const clamped = Math.min(Math.max(limit, 1), MAX_LIMIT)
return clamped
}
function shouldInclude(entryType: FileSystemEntry["type"], filter: WorkspaceFileSearchType) {
return filter === "all" || entryType === filter
}
function normalizeRelativeEntryPath(relativePath: string): string {
if (!relativePath) {
return "."
}
let normalized = relativePath.replace(/\\+/g, "/")
if (normalized.startsWith("./")) {
normalized = normalized.replace(/^\.\/+/, "")
}
if (normalized.startsWith("/")) {
normalized = normalized.replace(/^\/+/g, "")
}
return normalized || "."
}
function buildSearchKey(entry: FileSystemEntry) {
return entry.path.toLowerCase()
}

View File

@@ -0,0 +1,227 @@
/**
* CLI entry point.
* For now this only wires the typed modules together; actual command handling comes later.
*/
import { Command, InvalidArgumentError, Option } from "commander"
import path from "path"
import { fileURLToPath } from "url"
import { createRequire } from "module"
import { createHttpServer } from "./server/http-server"
import { WorkspaceManager } from "./workspaces/manager"
import { ConfigStore } from "./config/store"
import { BinaryRegistry } from "./config/binaries"
import { FileSystemBrowser } from "./filesystem/browser"
import { EventBus } from "./events/bus"
import { ServerMeta } from "./api-types"
import { InstanceStore } from "./storage/instance-store"
import { InstanceEventBridge } from "./workspaces/instance-events"
import { createLogger } from "./logger"
import { launchInBrowser } from "./launcher"
import { startReleaseMonitor } from "./releases/release-monitor"
const require = createRequire(import.meta.url)
const packageJson = require("../package.json") as { version: string }
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const DEFAULT_UI_STATIC_DIR = path.resolve(__dirname, "../public")
interface CliOptions {
port: number
host: string
rootDir: string
configPath: string
unrestrictedRoot: boolean
logLevel?: string
logDestination?: string
uiStaticDir: string
uiDevServer?: string
launch: boolean
}
const DEFAULT_PORT = 9898
const DEFAULT_HOST = "127.0.0.1"
const DEFAULT_CONFIG_PATH = "~/.config/codenomad/config.json"
function parseCliOptions(argv: string[]): CliOptions {
const program = new Command()
.name("codenomad")
.description("CodeNomad CLI server")
.version(packageJson.version, "-v, --version", "Show the CLI version")
.addOption(new Option("--host <host>", "Host interface to bind").env("CLI_HOST").default(DEFAULT_HOST))
.addOption(new Option("--port <number>", "Port for the HTTP server").env("CLI_PORT").default(DEFAULT_PORT).argParser(parsePort))
.addOption(
new Option("--workspace-root <path>", "Workspace root directory").env("CLI_WORKSPACE_ROOT").default(process.cwd()),
)
.addOption(new Option("--root <path>").env("CLI_ROOT").hideHelp(true))
.addOption(new Option("--unrestricted-root", "Allow browsing the full filesystem").env("CLI_UNRESTRICTED_ROOT").default(false))
.addOption(new Option("--config <path>", "Path to the config file").env("CLI_CONFIG").default(DEFAULT_CONFIG_PATH))
.addOption(new Option("--log-level <level>", "Log level (trace|debug|info|warn|error)").env("CLI_LOG_LEVEL"))
.addOption(new Option("--log-destination <path>", "Log destination file (defaults to stdout)").env("CLI_LOG_DESTINATION"))
.addOption(
new Option("--ui-dir <path>", "Directory containing the built UI bundle").env("CLI_UI_DIR").default(DEFAULT_UI_STATIC_DIR),
)
.addOption(new Option("--ui-dev-server <url>", "Proxy UI requests to a running dev server").env("CLI_UI_DEV_SERVER"))
.addOption(new Option("--launch", "Launch the UI in a browser after start").env("CLI_LAUNCH").default(false))
program.parse(argv, { from: "user" })
const parsed = program.opts<{
host: string
port: number
workspaceRoot?: string
root?: string
unrestrictedRoot?: boolean
config: string
logLevel?: string
logDestination?: string
uiDir: string
uiDevServer?: string
launch?: boolean
}>()
const resolvedRoot = parsed.workspaceRoot ?? parsed.root ?? process.cwd()
const normalizedHost = resolveHost(parsed.host)
return {
port: parsed.port,
host: normalizedHost,
rootDir: resolvedRoot,
configPath: parsed.config,
unrestrictedRoot: Boolean(parsed.unrestrictedRoot),
logLevel: parsed.logLevel,
logDestination: parsed.logDestination,
uiStaticDir: parsed.uiDir,
uiDevServer: parsed.uiDevServer,
launch: Boolean(parsed.launch),
}
}
function parsePort(input: string): number {
const value = Number(input)
if (!Number.isInteger(value) || value < 0 || value > 65535) {
throw new InvalidArgumentError("Port must be an integer between 0 and 65535")
}
return value
}
function resolveHost(input: string | undefined): string {
if (input && input.trim() === "0.0.0.0") {
return "0.0.0.0"
}
return DEFAULT_HOST
}
async function main() {
const options = parseCliOptions(process.argv.slice(2))
const logger = createLogger({ level: options.logLevel, destination: options.logDestination, component: "app" })
const workspaceLogger = logger.child({ component: "workspace" })
const configLogger = logger.child({ component: "config" })
const eventLogger = logger.child({ component: "events" })
logger.info({ options }, "Starting CodeNomad CLI server")
const eventBus = new EventBus(eventLogger)
const configStore = new ConfigStore(options.configPath, eventBus, configLogger)
const binaryRegistry = new BinaryRegistry(configStore, eventBus, configLogger)
const workspaceManager = new WorkspaceManager({
rootDir: options.rootDir,
configStore,
binaryRegistry,
eventBus,
logger: workspaceLogger,
})
const fileSystemBrowser = new FileSystemBrowser({ rootDir: options.rootDir, unrestricted: options.unrestrictedRoot })
const instanceStore = new InstanceStore()
const instanceEventBridge = new InstanceEventBridge({
workspaceManager,
eventBus,
logger: logger.child({ component: "instance-events" }),
})
const serverMeta: ServerMeta = {
httpBaseUrl: `http://${options.host}:${options.port}`,
eventsUrl: `/api/events`,
host: options.host,
listeningMode: options.host === "0.0.0.0" ? "all" : "local",
port: options.port,
hostLabel: options.host,
workspaceRoot: options.rootDir,
addresses: [],
}
const releaseMonitor = startReleaseMonitor({
currentVersion: packageJson.version,
logger: logger.child({ component: "release-monitor" }),
onUpdate: (release) => {
if (release) {
serverMeta.latestRelease = release
eventBus.publish({ type: "app.releaseAvailable", release })
} else {
delete serverMeta.latestRelease
}
},
})
const server = createHttpServer({
host: options.host,
port: options.port,
workspaceManager,
configStore,
binaryRegistry,
fileSystemBrowser,
eventBus,
serverMeta,
instanceStore,
uiStaticDir: options.uiStaticDir,
uiDevServerUrl: options.uiDevServer,
logger,
})
const startInfo = await server.start()
logger.info({ port: startInfo.port, host: options.host }, "HTTP server listening")
console.log(`CodeNomad Server is ready at ${startInfo.url}`)
if (options.launch) {
await launchInBrowser(startInfo.url, logger.child({ component: "launcher" }))
}
let shuttingDown = false
const shutdown = async () => {
if (shuttingDown) {
logger.info("Shutdown already in progress, ignoring signal")
return
}
shuttingDown = true
logger.info("Received shutdown signal, closing server")
try {
await server.stop()
logger.info("HTTP server stopped")
} catch (error) {
logger.error({ err: error }, "Failed to stop HTTP server")
}
try {
instanceEventBridge.shutdown()
await workspaceManager.shutdown()
logger.info("Workspace manager shutdown complete")
} catch (error) {
logger.error({ err: error }, "Workspace manager shutdown failed")
}
releaseMonitor.stop()
logger.info("Exiting process")
process.exit(0)
}
process.on("SIGINT", shutdown)
process.on("SIGTERM", shutdown)
}
main().catch((error) => {
const logger = createLogger({ component: "app" })
logger.error({ err: error }, "CLI server crashed")
process.exit(1)
})

View File

@@ -0,0 +1,177 @@
import { spawn } from "child_process"
import os from "os"
import path from "path"
import type { Logger } from "./logger"
interface BrowserCandidate {
name: string
command: string
args: (url: string) => string[]
}
const APP_ARGS = (url: string) => [`--app=${url}`, "--new-window"]
export async function launchInBrowser(url: string, logger: Logger): Promise<boolean> {
const { platform, candidates, manualExamples } = buildPlatformCandidates(url)
console.log(`Attempting to launch browser (${platform}) using:`)
candidates.forEach((candidate) => console.log(` - ${candidate.name}: ${candidate.command}`))
for (const candidate of candidates) {
const success = await tryLaunch(candidate, url, logger)
if (success) {
return true
}
}
console.error(
"No supported browser found to launch. Run without --launch and use one of the commands below or install a compatible browser.",
)
if (manualExamples.length > 0) {
console.error("Manual launch commands:")
manualExamples.forEach((line) => console.error(` ${line}`))
}
return false
}
async function tryLaunch(candidate: BrowserCandidate, url: string, logger: Logger): Promise<boolean> {
return new Promise((resolve) => {
let resolved = false
try {
const args = candidate.args(url)
const child = spawn(candidate.command, args, { stdio: "ignore", detached: true })
child.once("error", (error) => {
if (resolved) return
resolved = true
logger.debug({ err: error, candidate: candidate.name, command: candidate.command, args }, "Browser launch failed")
resolve(false)
})
child.once("spawn", () => {
if (resolved) return
resolved = true
logger.info(
{
browser: candidate.name,
command: candidate.command,
args,
fullCommand: [candidate.command, ...args].join(" "),
},
"Launched browser in app mode",
)
child.unref()
resolve(true)
})
} catch (error) {
if (resolved) return
resolved = true
logger.debug({ err: error, candidate: candidate.name, command: candidate.command }, "Browser spawn threw")
resolve(false)
}
})
}
function buildPlatformCandidates(url: string) {
switch (os.platform()) {
case "darwin":
return {
platform: "macOS",
candidates: buildMacCandidates(),
manualExamples: buildMacManualExamples(url),
}
case "win32":
return {
platform: "Windows",
candidates: buildWindowsCandidates(),
manualExamples: buildWindowsManualExamples(url),
}
default:
return {
platform: "Linux",
candidates: buildLinuxCandidates(),
manualExamples: buildLinuxManualExamples(url),
}
}
}
function buildMacCandidates(): BrowserCandidate[] {
const apps = [
{ name: "Google Chrome", path: "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" },
{ name: "Google Chrome Canary", path: "/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary" },
{ name: "Microsoft Edge", path: "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge" },
{ name: "Brave Browser", path: "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser" },
{ name: "Chromium", path: "/Applications/Chromium.app/Contents/MacOS/Chromium" },
{ name: "Vivaldi", path: "/Applications/Vivaldi.app/Contents/MacOS/Vivaldi" },
{ name: "Arc", path: "/Applications/Arc.app/Contents/MacOS/Arc" },
]
return apps.map((entry) => ({ name: entry.name, command: entry.path, args: APP_ARGS }))
}
function buildWindowsCandidates(): BrowserCandidate[] {
const programFiles = process.env["ProgramFiles"]
const programFilesX86 = process.env["ProgramFiles(x86)"]
const localAppData = process.env["LocalAppData"]
const paths = [
[programFiles, "Google/Chrome/Application/chrome.exe", "Google Chrome"],
[programFilesX86, "Google/Chrome/Application/chrome.exe", "Google Chrome (x86)"],
[localAppData, "Google/Chrome/Application/chrome.exe", "Google Chrome (User)"],
[programFiles, "Microsoft/Edge/Application/msedge.exe", "Microsoft Edge"],
[programFilesX86, "Microsoft/Edge/Application/msedge.exe", "Microsoft Edge (x86)"],
[localAppData, "Microsoft/Edge/Application/msedge.exe", "Microsoft Edge (User)"],
[programFiles, "BraveSoftware/Brave-Browser/Application/brave.exe", "Brave"],
[localAppData, "BraveSoftware/Brave-Browser/Application/brave.exe", "Brave (User)"],
[programFiles, "Chromium/Application/chromium.exe", "Chromium"],
] as const
return paths
.filter(([root]) => Boolean(root))
.map(([root, rel, name]) => ({
name,
command: path.join(root as string, rel),
args: APP_ARGS,
}))
}
function buildLinuxCandidates(): BrowserCandidate[] {
const names = [
"google-chrome",
"google-chrome-stable",
"chromium",
"chromium-browser",
"brave-browser",
"microsoft-edge",
"microsoft-edge-stable",
"vivaldi",
]
return names.map((name) => ({ name, command: name, args: APP_ARGS }))
}
function buildMacManualExamples(url: string) {
return [
`"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --app="${url}" --new-window`,
`"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge" --app="${url}" --new-window`,
`"/Applications/Brave Browser.app/Contents/MacOS/Brave Browser" --app="${url}" --new-window`,
]
}
function buildWindowsManualExamples(url: string) {
return [
`"%ProgramFiles%\\Google\\Chrome\\Application\\chrome.exe" --app="${url}" --new-window`,
`"%ProgramFiles%\\Microsoft\\Edge\\Application\\msedge.exe" --app="${url}" --new-window`,
`"%ProgramFiles%\\BraveSoftware\\Brave-Browser\\Application\\brave.exe" --app="${url}" --new-window`,
]
}
function buildLinuxManualExamples(url: string) {
return [
`google-chrome --app="${url}" --new-window`,
`chromium --app="${url}" --new-window`,
`brave-browser --app="${url}" --new-window`,
`microsoft-edge --app="${url}" --new-window`,
]
}

View File

@@ -0,0 +1,21 @@
export async function resolve(specifier: string, context: any, defaultResolve: any) {
try {
return await defaultResolve(specifier, context, defaultResolve)
} catch (error: any) {
if (shouldRetry(specifier, error)) {
const retried = specifier.endsWith(".js") ? specifier : `${specifier}.js`
return defaultResolve(retried, context, defaultResolve)
}
throw error
}
}
function shouldRetry(specifier: string, error: any) {
if (!error || error.code !== "ERR_MODULE_NOT_FOUND") {
return false
}
if (specifier.startsWith("./") || specifier.startsWith("../")) {
return true
}
return false
}

View File

@@ -0,0 +1,133 @@
import { Transform } from "node:stream"
import pino, { Logger as PinoLogger } from "pino"
export type Logger = PinoLogger
interface LoggerOptions {
level?: string
destination?: string
component?: string
}
const LEVEL_LABELS: Record<number, string> = {
10: "trace",
20: "debug",
30: "info",
40: "warn",
50: "error",
60: "fatal",
}
const LIFECYCLE_COMPONENTS = new Set(["app", "workspace"])
const OMITTED_FIELDS = new Set(["time", "msg", "level", "component", "module"])
export function createLogger(options: LoggerOptions = {}): Logger {
const level = (options.level ?? process.env.CLI_LOG_LEVEL ?? "info").toLowerCase()
const destination = options.destination ?? process.env.CLI_LOG_DESTINATION ?? "stdout"
const baseComponent = options.component ?? "app"
const loggerOptions = {
level,
base: { component: baseComponent },
timestamp: false,
} as const
if (destination && destination !== "stdout") {
const stream = pino.destination({ dest: destination, mkdir: true, sync: false })
return pino(loggerOptions, stream)
}
const lifecycleStream = new LifecycleLogStream({ restrictInfoToLifecycle: level === "info" })
lifecycleStream.pipe(process.stdout)
return pino(loggerOptions, lifecycleStream)
}
interface LifecycleStreamOptions {
restrictInfoToLifecycle: boolean
}
class LifecycleLogStream extends Transform {
private buffer = ""
constructor(private readonly options: LifecycleStreamOptions) {
super()
}
_transform(chunk: Buffer, _encoding: BufferEncoding, callback: () => void) {
this.buffer += chunk.toString()
let newlineIndex = this.buffer.indexOf("\n")
while (newlineIndex >= 0) {
const line = this.buffer.slice(0, newlineIndex)
this.buffer = this.buffer.slice(newlineIndex + 1)
this.pushFormatted(line)
newlineIndex = this.buffer.indexOf("\n")
}
callback()
}
_flush(callback: () => void) {
if (this.buffer.length > 0) {
this.pushFormatted(this.buffer)
this.buffer = ""
}
callback()
}
private pushFormatted(line: string) {
if (!line.trim()) {
return
}
let entry: Record<string, unknown>
try {
entry = JSON.parse(line)
} catch {
return
}
const levelNumber = typeof entry.level === "number" ? entry.level : 30
const levelLabel = LEVEL_LABELS[levelNumber] ?? "info"
const component = (entry.component as string | undefined) ?? (entry.module as string | undefined) ?? "app"
if (this.options.restrictInfoToLifecycle && levelNumber <= 30 && !LIFECYCLE_COMPONENTS.has(component)) {
return
}
const message = typeof entry.msg === "string" ? entry.msg : ""
const metadata = this.formatMetadata(entry)
const formatted = metadata.length > 0 ? `[${levelLabel.toUpperCase()}] [${component}] ${message} ${metadata}` : `[${levelLabel.toUpperCase()}] [${component}] ${message}`
this.push(`${formatted}\n`)
}
private formatMetadata(entry: Record<string, unknown>): string {
const pairs: string[] = []
for (const [key, value] of Object.entries(entry)) {
if (OMITTED_FIELDS.has(key)) {
continue
}
if (key === "err" && value && typeof value === "object") {
const err = value as { type?: string; message?: string; stack?: string }
const errLabel = err.type ?? "Error"
const errMessage = err.message ? `: ${err.message}` : ""
pairs.push(`err=${errLabel}${errMessage}`)
if (err.stack) {
pairs.push(`stack="${err.stack}"`)
}
continue
}
pairs.push(`${key}=${this.stringifyValue(value)}`)
}
return pairs.join(" ").trim()
}
private stringifyValue(value: unknown): string {
if (value === undefined) return "undefined"
if (value === null) return "null"
if (typeof value === "string") return value
if (typeof value === "number" || typeof value === "boolean") return String(value)
if (value instanceof Error) return value.message ?? value.name
return JSON.stringify(value)
}
}

View File

@@ -0,0 +1,36 @@
import { cpSync, existsSync, mkdirSync, rmSync } from "fs"
import os from "os"
import path from "path"
import { fileURLToPath } from "url"
import { createLogger } from "./logger"
const log = createLogger({ component: "opencode-config" })
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const devTemplateDir = path.resolve(__dirname, "../../opencode-config")
const prodTemplateDir = path.resolve(__dirname, "opencode-config")
const isDevBuild = Boolean(process.env.CODENOMAD_DEV ?? process.env.CLI_UI_DEV_SERVER) || existsSync(devTemplateDir)
const templateDir = isDevBuild ? devTemplateDir : prodTemplateDir
const userConfigDir = path.join(os.homedir(), ".config", "codenomad", "opencode-config")
export function getOpencodeConfigDir(): string {
if (!existsSync(templateDir)) {
throw new Error(`CodeNomad Opencode config template missing at ${templateDir}`)
}
if (isDevBuild) {
log.debug({ templateDir }, "Using Opencode config template directly (dev mode)")
return templateDir
}
refreshUserConfig()
return userConfigDir
}
function refreshUserConfig() {
log.debug({ templateDir, userConfigDir }, "Syncing Opencode config template")
rmSync(userConfigDir, { recursive: true, force: true })
mkdirSync(path.dirname(userConfigDir), { recursive: true })
cpSync(templateDir, userConfigDir, { recursive: true })
}

View File

@@ -0,0 +1,141 @@
import { fetch } from "undici"
import type { LatestReleaseInfo } from "../api-types"
import type { Logger } from "../logger"
const RELEASES_API_URL = "https://api.github.com/repos/NeuralNomadsAI/CodeNomad/releases/latest"
interface ReleaseMonitorOptions {
currentVersion: string
logger: Logger
onUpdate: (release: LatestReleaseInfo | null) => void
}
interface GithubReleaseResponse {
tag_name?: string
name?: string
html_url?: string
body?: string
published_at?: string
created_at?: string
prerelease?: boolean
}
interface NormalizedVersion {
major: number
minor: number
patch: number
prerelease: string | null
}
export interface ReleaseMonitor {
stop(): void
}
export function startReleaseMonitor(options: ReleaseMonitorOptions): ReleaseMonitor {
let stopped = false
const refreshRelease = async () => {
if (stopped) return
try {
const release = await fetchLatestRelease(options)
options.onUpdate(release)
} catch (error) {
options.logger.warn({ err: error }, "Failed to refresh release information")
}
}
void refreshRelease()
return {
stop() {
stopped = true
},
}
}
async function fetchLatestRelease(options: ReleaseMonitorOptions): Promise<LatestReleaseInfo | null> {
const response = await fetch(RELEASES_API_URL, {
headers: {
Accept: "application/vnd.github+json",
"User-Agent": "CodeNomad-CLI",
},
})
if (!response.ok) {
throw new Error(`Release API responded with ${response.status}`)
}
const json = (await response.json()) as GithubReleaseResponse
const tagFromServer = json.tag_name || json.name
if (!tagFromServer) {
return null
}
const normalizedVersion = stripTagPrefix(tagFromServer)
if (!normalizedVersion) {
return null
}
const current = parseVersion(options.currentVersion)
const remote = parseVersion(normalizedVersion)
if (compareVersions(remote, current) <= 0) {
return null
}
return {
version: normalizedVersion,
tag: tagFromServer,
url: json.html_url ?? `https://github.com/NeuralNomadsAI/CodeNomad/releases/tag/${encodeURIComponent(tagFromServer)}`,
channel: json.prerelease || normalizedVersion.includes("-") ? "dev" : "stable",
publishedAt: json.published_at ?? json.created_at,
notes: json.body,
}
}
function stripTagPrefix(tag: string | undefined): string | null {
if (!tag) return null
const trimmed = tag.trim()
if (!trimmed) return null
return trimmed.replace(/^v/i, "")
}
function parseVersion(value: string): NormalizedVersion {
const normalized = stripTagPrefix(value) ?? "0.0.0"
const [core, prerelease = null] = normalized.split("-", 2)
const [major = 0, minor = 0, patch = 0] = core.split(".").map((segment) => {
const parsed = Number.parseInt(segment, 10)
return Number.isFinite(parsed) ? parsed : 0
})
return {
major,
minor,
patch,
prerelease,
}
}
function compareVersions(a: NormalizedVersion, b: NormalizedVersion): number {
if (a.major !== b.major) {
return a.major > b.major ? 1 : -1
}
if (a.minor !== b.minor) {
return a.minor > b.minor ? 1 : -1
}
if (a.patch !== b.patch) {
return a.patch > b.patch ? 1 : -1
}
const aPre = a.prerelease && a.prerelease.length > 0 ? a.prerelease : null
const bPre = b.prerelease && b.prerelease.length > 0 ? b.prerelease : null
if (aPre === bPre) {
return 0
}
if (!aPre) {
return 1
}
if (!bPre) {
return -1
}
return aPre.localeCompare(bPre)
}

View File

@@ -0,0 +1,364 @@
import Fastify, { type FastifyInstance, type FastifyReply, type FastifyRequest } from "fastify"
import cors from "@fastify/cors"
import fastifyStatic from "@fastify/static"
import replyFrom from "@fastify/reply-from"
import fs from "fs"
import path from "path"
import { fetch } from "undici"
import type { Logger } from "../logger"
import { WorkspaceManager } from "../workspaces/manager"
import { ConfigStore } from "../config/store"
import { BinaryRegistry } from "../config/binaries"
import { FileSystemBrowser } from "../filesystem/browser"
import { EventBus } from "../events/bus"
import { registerWorkspaceRoutes } from "./routes/workspaces"
import { registerConfigRoutes } from "./routes/config"
import { registerFilesystemRoutes } from "./routes/filesystem"
import { registerMetaRoutes } from "./routes/meta"
import { registerEventRoutes } from "./routes/events"
import { registerStorageRoutes } from "./routes/storage"
import { ServerMeta } from "../api-types"
import { InstanceStore } from "../storage/instance-store"
interface HttpServerDeps {
host: string
port: number
workspaceManager: WorkspaceManager
configStore: ConfigStore
binaryRegistry: BinaryRegistry
fileSystemBrowser: FileSystemBrowser
eventBus: EventBus
serverMeta: ServerMeta
instanceStore: InstanceStore
uiStaticDir: string
uiDevServerUrl?: string
logger: Logger
}
interface HttpServerStartResult {
port: number
url: string
displayHost: string
}
const DEFAULT_HTTP_PORT = 9898
export function createHttpServer(deps: HttpServerDeps) {
const app = Fastify({ logger: false })
const proxyLogger = deps.logger.child({ component: "proxy" })
const apiLogger = deps.logger.child({ component: "http" })
const sseLogger = deps.logger.child({ component: "sse" })
const sseClients = new Set<() => void>()
const registerSseClient = (cleanup: () => void) => {
sseClients.add(cleanup)
return () => sseClients.delete(cleanup)
}
const closeSseClients = () => {
for (const cleanup of Array.from(sseClients)) {
cleanup()
}
sseClients.clear()
}
app.addHook("onRequest", (request, _reply, done) => {
;(request as FastifyRequest & { __logMeta?: { start: bigint } }).__logMeta = {
start: process.hrtime.bigint(),
}
done()
})
app.addHook("onResponse", (request, reply, done) => {
const meta = (request as FastifyRequest & { __logMeta?: { start: bigint } }).__logMeta
const durationMs = meta ? Number((process.hrtime.bigint() - meta.start) / BigInt(1_000_000)) : undefined
const base = {
method: request.method,
url: request.url,
status: reply.statusCode,
durationMs,
}
apiLogger.debug(base, "HTTP request completed")
if (apiLogger.isLevelEnabled("trace")) {
apiLogger.trace({ ...base, params: request.params, query: request.query, body: request.body }, "HTTP request payload")
}
done()
})
app.register(cors, {
origin: true,
credentials: true,
})
app.register(replyFrom, {
contentTypesToEncode: [],
undici: {
connections: 16,
pipelining: 1,
bodyTimeout: 0,
headersTimeout: 0,
},
})
registerWorkspaceRoutes(app, { workspaceManager: deps.workspaceManager })
registerConfigRoutes(app, { configStore: deps.configStore, binaryRegistry: deps.binaryRegistry })
registerFilesystemRoutes(app, { fileSystemBrowser: deps.fileSystemBrowser })
registerMetaRoutes(app, { serverMeta: deps.serverMeta })
registerEventRoutes(app, { eventBus: deps.eventBus, registerClient: registerSseClient, logger: sseLogger })
registerStorageRoutes(app, {
instanceStore: deps.instanceStore,
eventBus: deps.eventBus,
workspaceManager: deps.workspaceManager,
})
registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger })
if (deps.uiDevServerUrl) {
setupDevProxy(app, deps.uiDevServerUrl)
} else {
setupStaticUi(app, deps.uiStaticDir)
}
return {
instance: app,
start: async (): Promise<HttpServerStartResult> => {
const attemptListen = async (requestedPort: number) => {
const addressInfo = await app.listen({ port: requestedPort, host: deps.host })
return { addressInfo, requestedPort }
}
const autoPortRequested = deps.port === 0
const primaryPort = autoPortRequested ? DEFAULT_HTTP_PORT : deps.port
const shouldRetryWithEphemeral = (error: unknown) => {
if (!autoPortRequested) return false
const err = error as NodeJS.ErrnoException | undefined
return Boolean(err && err.code === "EADDRINUSE")
}
let listenResult
try {
listenResult = await attemptListen(primaryPort)
} catch (error) {
if (!shouldRetryWithEphemeral(error)) {
throw error
}
deps.logger.warn({ err: error, port: primaryPort }, "Preferred port unavailable, retrying on ephemeral port")
listenResult = await attemptListen(0)
}
let actualPort = listenResult.requestedPort
if (typeof listenResult.addressInfo === "string") {
try {
const parsed = new URL(listenResult.addressInfo)
actualPort = Number(parsed.port) || listenResult.requestedPort
} catch {
actualPort = listenResult.requestedPort
}
} else {
const address = app.server.address()
if (typeof address === "object" && address) {
actualPort = address.port
}
}
const displayHost = deps.host === "0.0.0.0" ? "127.0.0.1" : deps.host === "127.0.0.1" ? "localhost" : deps.host
const serverUrl = `http://${displayHost}:${actualPort}`
deps.serverMeta.httpBaseUrl = serverUrl
deps.serverMeta.host = deps.host
deps.serverMeta.port = actualPort
deps.serverMeta.listeningMode = deps.host === "0.0.0.0" ? "all" : "local"
deps.logger.info({ port: actualPort, host: deps.host }, "HTTP server listening")
console.log(`CodeNomad Server is ready at ${serverUrl}`)
return { port: actualPort, url: serverUrl, displayHost }
},
stop: () => {
closeSseClients()
return app.close()
},
}
}
interface InstanceProxyDeps {
workspaceManager: WorkspaceManager
logger: Logger
}
function registerInstanceProxyRoutes(app: FastifyInstance, deps: InstanceProxyDeps) {
app.register(async (instance) => {
instance.removeAllContentTypeParsers()
instance.addContentTypeParser("*", (req, body, done) => done(null, body))
const proxyBaseHandler = async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => {
await proxyWorkspaceRequest({
request,
reply,
workspaceManager: deps.workspaceManager,
pathSuffix: "",
logger: deps.logger,
})
}
const proxyWildcardHandler = async (
request: FastifyRequest<{ Params: { id: string; "*": string } }>,
reply: FastifyReply,
) => {
await proxyWorkspaceRequest({
request,
reply,
workspaceManager: deps.workspaceManager,
pathSuffix: request.params["*"] ?? "",
logger: deps.logger,
})
}
instance.all("/workspaces/:id/instance", proxyBaseHandler)
instance.all("/workspaces/:id/instance/*", proxyWildcardHandler)
})
}
const INSTANCE_PROXY_HOST = "127.0.0.1"
async function proxyWorkspaceRequest(args: {
request: FastifyRequest
reply: FastifyReply
workspaceManager: WorkspaceManager
logger: Logger
pathSuffix?: string
}) {
const { request, reply, workspaceManager, logger } = args
const workspaceId = (request.params as { id: string }).id
const workspace = workspaceManager.get(workspaceId)
if (!workspace) {
reply.code(404).send({ error: "Workspace not found" })
return
}
const port = workspaceManager.getInstancePort(workspaceId)
if (!port) {
reply.code(502).send({ error: "Workspace instance is not ready" })
return
}
const normalizedSuffix = normalizeInstanceSuffix(args.pathSuffix)
const queryIndex = (request.raw.url ?? "").indexOf("?")
const search = queryIndex >= 0 ? (request.raw.url ?? "").slice(queryIndex) : ""
const targetUrl = `http://${INSTANCE_PROXY_HOST}:${port}${normalizedSuffix}${search}`
logger.debug({ workspaceId, method: request.method, targetUrl }, "Proxying request to instance")
if (logger.isLevelEnabled("trace")) {
logger.trace({ workspaceId, targetUrl, body: request.body }, "Instance proxy payload")
}
return reply.from(targetUrl, {
onError: (proxyReply, { error }) => {
logger.error({ err: error, workspaceId, targetUrl }, "Failed to proxy workspace request")
if (!proxyReply.sent) {
proxyReply.code(502).send({ error: "Workspace instance proxy failed" })
}
},
})
}
function normalizeInstanceSuffix(pathSuffix: string | undefined) {
if (!pathSuffix || pathSuffix === "/") {
return "/"
}
const trimmed = pathSuffix.replace(/^\/+/, "")
return trimmed.length === 0 ? "/" : `/${trimmed}`
}
function setupStaticUi(app: FastifyInstance, uiDir: string) {
if (!uiDir) {
app.log.warn("UI static directory not provided; API endpoints only")
return
}
if (!fs.existsSync(uiDir)) {
app.log.warn({ uiDir }, "UI static directory missing; API endpoints only")
return
}
app.register(fastifyStatic, {
root: uiDir,
prefix: "/",
decorateReply: false,
})
const indexPath = path.join(uiDir, "index.html")
app.setNotFoundHandler((request: FastifyRequest, reply: FastifyReply) => {
const url = request.raw.url ?? ""
if (isApiRequest(url)) {
reply.code(404).send({ message: "Not Found" })
return
}
if (fs.existsSync(indexPath)) {
reply.type("text/html").send(fs.readFileSync(indexPath, "utf-8"))
} else {
reply.code(404).send({ message: "UI bundle missing" })
}
})
}
function setupDevProxy(app: FastifyInstance, upstreamBase: string) {
app.log.info({ upstreamBase }, "Proxying UI requests to development server")
app.setNotFoundHandler((request: FastifyRequest, reply: FastifyReply) => {
const url = request.raw.url ?? ""
if (isApiRequest(url)) {
reply.code(404).send({ message: "Not Found" })
return
}
void proxyToDevServer(request, reply, upstreamBase)
})
}
async function proxyToDevServer(request: FastifyRequest, reply: FastifyReply, upstreamBase: string) {
try {
const targetUrl = new URL(request.raw.url ?? "/", upstreamBase)
const response = await fetch(targetUrl, {
method: request.method,
headers: buildProxyHeaders(request.headers),
})
response.headers.forEach((value, key) => {
reply.header(key, value)
})
reply.code(response.status)
if (!response.body || request.method === "HEAD") {
reply.send()
return
}
const buffer = Buffer.from(await response.arrayBuffer())
reply.send(buffer)
} catch (error) {
request.log.error({ err: error }, "Failed to proxy UI request to dev server")
if (!reply.sent) {
reply.code(502).send("UI dev server is unavailable")
}
}
}
function isApiRequest(rawUrl: string | null | undefined) {
if (!rawUrl) return false
const pathname = rawUrl.split("?")[0] ?? ""
return pathname === "/api" || pathname.startsWith("/api/")
}
function buildProxyHeaders(headers: FastifyRequest["headers"]): Record<string, string> {
const result: Record<string, string> = {}
for (const [key, value] of Object.entries(headers ?? {})) {
if (!value || key.toLowerCase() === "host") continue
result[key] = Array.isArray(value) ? value.join(",") : value
}
return result
}

View File

@@ -0,0 +1,62 @@
import { FastifyInstance } from "fastify"
import { z } from "zod"
import { ConfigStore } from "../../config/store"
import { BinaryRegistry } from "../../config/binaries"
import { ConfigFileSchema } from "../../config/schema"
interface RouteDeps {
configStore: ConfigStore
binaryRegistry: BinaryRegistry
}
const BinaryCreateSchema = z.object({
path: z.string(),
label: z.string().optional(),
makeDefault: z.boolean().optional(),
})
const BinaryUpdateSchema = z.object({
label: z.string().optional(),
makeDefault: z.boolean().optional(),
})
const BinaryValidateSchema = z.object({
path: z.string(),
})
export function registerConfigRoutes(app: FastifyInstance, deps: RouteDeps) {
app.get("/api/config/app", async () => deps.configStore.get())
app.put("/api/config/app", async (request) => {
const body = ConfigFileSchema.parse(request.body ?? {})
deps.configStore.replace(body)
return deps.configStore.get()
})
app.get("/api/config/binaries", async () => {
return { binaries: deps.binaryRegistry.list() }
})
app.post("/api/config/binaries", async (request, reply) => {
const body = BinaryCreateSchema.parse(request.body ?? {})
const binary = deps.binaryRegistry.create(body)
reply.code(201)
return { binary }
})
app.patch<{ Params: { id: string } }>("/api/config/binaries/:id", async (request) => {
const body = BinaryUpdateSchema.parse(request.body ?? {})
const binary = deps.binaryRegistry.update(request.params.id, body)
return { binary }
})
app.delete<{ Params: { id: string } }>("/api/config/binaries/:id", async (request, reply) => {
deps.binaryRegistry.remove(request.params.id)
reply.code(204)
})
app.post("/api/config/binaries/validate", async (request) => {
const body = BinaryValidateSchema.parse(request.body ?? {})
return deps.binaryRegistry.validatePath(body.path)
})
}

View File

@@ -0,0 +1,61 @@
import { FastifyInstance } from "fastify"
import { EventBus } from "../../events/bus"
import { WorkspaceEventPayload } from "../../api-types"
import { Logger } from "../../logger"
interface RouteDeps {
eventBus: EventBus
registerClient: (cleanup: () => void) => () => void
logger: Logger
}
let nextClientId = 0
export function registerEventRoutes(app: FastifyInstance, deps: RouteDeps) {
app.get("/api/events", (request, reply) => {
const clientId = ++nextClientId
deps.logger.debug({ clientId }, "SSE client connected")
const origin = request.headers.origin ?? "*"
reply.raw.setHeader("Access-Control-Allow-Origin", origin)
reply.raw.setHeader("Access-Control-Allow-Credentials", "true")
reply.raw.setHeader("Content-Type", "text/event-stream")
reply.raw.setHeader("Cache-Control", "no-cache")
reply.raw.setHeader("Connection", "keep-alive")
reply.raw.flushHeaders?.()
reply.hijack()
const send = (event: WorkspaceEventPayload) => {
deps.logger.debug({ clientId, type: event.type }, "SSE event dispatched")
if (deps.logger.isLevelEnabled("trace")) {
deps.logger.trace({ clientId, event }, "SSE event payload")
}
reply.raw.write(`data: ${JSON.stringify(event)}\n\n`)
}
const unsubscribe = deps.eventBus.onEvent(send)
const heartbeat = setInterval(() => {
reply.raw.write(`:hb ${Date.now()}\n\n`)
}, 15000)
let closed = false
const close = () => {
if (closed) return
closed = true
clearInterval(heartbeat)
unsubscribe()
reply.raw.end?.()
deps.logger.debug({ clientId }, "SSE client disconnected")
}
const unregister = deps.registerClient(close)
const handleClose = () => {
close()
unregister()
}
request.raw.on("close", handleClose)
request.raw.on("error", handleClose)
})
}

View File

@@ -0,0 +1,27 @@
import { FastifyInstance } from "fastify"
import { z } from "zod"
import { FileSystemBrowser } from "../../filesystem/browser"
interface RouteDeps {
fileSystemBrowser: FileSystemBrowser
}
const FilesystemQuerySchema = z.object({
path: z.string().optional(),
includeFiles: z.coerce.boolean().optional(),
})
export function registerFilesystemRoutes(app: FastifyInstance, deps: RouteDeps) {
app.get("/api/filesystem", async (request, reply) => {
const query = FilesystemQuerySchema.parse(request.query ?? {})
try {
return deps.fileSystemBrowser.browse(query.path, {
includeFiles: query.includeFiles,
})
} catch (error) {
reply.code(400)
return { error: (error as Error).message }
}
})
}

View File

@@ -0,0 +1,104 @@
import { FastifyInstance } from "fastify"
import os from "os"
import { NetworkAddress, ServerMeta } from "../../api-types"
interface RouteDeps {
serverMeta: ServerMeta
}
export function registerMetaRoutes(app: FastifyInstance, deps: RouteDeps) {
app.get("/api/meta", async () => buildMetaResponse(deps.serverMeta))
}
function buildMetaResponse(meta: ServerMeta): ServerMeta {
const port = resolvePort(meta)
const addresses = port > 0 ? resolveAddresses(port, meta.host) : []
return {
...meta,
port,
listeningMode: meta.host === "0.0.0.0" ? "all" : "local",
addresses,
}
}
function resolvePort(meta: ServerMeta): number {
if (Number.isInteger(meta.port) && meta.port > 0) {
return meta.port
}
try {
const parsed = new URL(meta.httpBaseUrl)
const port = Number(parsed.port)
return Number.isInteger(port) && port > 0 ? port : 0
} catch {
return 0
}
}
function resolveAddresses(port: number, host: string): NetworkAddress[] {
const interfaces = os.networkInterfaces()
const seen = new Set<string>()
const results: NetworkAddress[] = []
const addAddress = (ip: string, scope: NetworkAddress["scope"]) => {
if (!ip || ip === "0.0.0.0") return
const key = `ipv4-${ip}`
if (seen.has(key)) return
seen.add(key)
results.push({ ip, family: "ipv4", scope, url: `http://${ip}:${port}` })
}
const normalizeFamily = (value: string | number) => {
if (typeof value === "string") {
const lowered = value.toLowerCase()
if (lowered === "ipv4") {
return "ipv4" as const
}
}
if (value === 4) return "ipv4" as const
return null
}
if (host === "0.0.0.0") {
// Enumerate system interfaces (IPv4 only)
for (const entries of Object.values(interfaces)) {
if (!entries) continue
for (const entry of entries) {
const family = normalizeFamily(entry.family)
if (!family) continue
if (!entry.address || entry.address === "0.0.0.0") continue
const scope: NetworkAddress["scope"] = entry.internal ? "loopback" : "external"
addAddress(entry.address, scope)
}
}
}
// Always include loopback address
addAddress("127.0.0.1", "loopback")
// Include explicitly configured host if it was IPv4
if (isIPv4Address(host) && host !== "0.0.0.0") {
const isLoopback = host.startsWith("127.")
addAddress(host, isLoopback ? "loopback" : "external")
}
const scopeWeight: Record<NetworkAddress["scope"], number> = { external: 0, internal: 1, loopback: 2 }
return results.sort((a, b) => {
const scopeDelta = scopeWeight[a.scope] - scopeWeight[b.scope]
if (scopeDelta !== 0) return scopeDelta
return a.ip.localeCompare(b.ip)
})
}
function isIPv4Address(value: string | undefined): value is string {
if (!value) return false
const parts = value.split(".")
if (parts.length !== 4) return false
return parts.every((part) => {
if (part.length === 0 || part.length > 3) return false
if (!/^[0-9]+$/.test(part)) return false
const num = Number(part)
return Number.isInteger(num) && num >= 0 && num <= 255
})
}

View File

@@ -0,0 +1,66 @@
import { FastifyInstance } from "fastify"
import { z } from "zod"
import { InstanceStore } from "../../storage/instance-store"
import { EventBus } from "../../events/bus"
import { ModelPreferenceSchema } from "../../config/schema"
import type { InstanceData } from "../../api-types"
import { WorkspaceManager } from "../../workspaces/manager"
interface RouteDeps {
instanceStore: InstanceStore
eventBus: EventBus
workspaceManager: WorkspaceManager
}
const InstanceDataSchema = z.object({
messageHistory: z.array(z.string()).default([]),
agentModelSelections: z.record(z.string(), ModelPreferenceSchema).default({}),
})
const EMPTY_INSTANCE_DATA: InstanceData = {
messageHistory: [],
agentModelSelections: {},
}
export function registerStorageRoutes(app: FastifyInstance, deps: RouteDeps) {
const resolveStorageKey = (instanceId: string): string => {
const workspace = deps.workspaceManager.get(instanceId)
return workspace?.path ?? instanceId
}
app.get<{ Params: { id: string } }>("/api/storage/instances/:id", async (request, reply) => {
try {
const storageId = resolveStorageKey(request.params.id)
const data = await deps.instanceStore.read(storageId)
return data
} catch (error) {
reply.code(500)
return { error: error instanceof Error ? error.message : "Failed to read instance data" }
}
})
app.put<{ Params: { id: string } }>("/api/storage/instances/:id", async (request, reply) => {
try {
const body = InstanceDataSchema.parse(request.body ?? {})
const storageId = resolveStorageKey(request.params.id)
await deps.instanceStore.write(storageId, body)
deps.eventBus.publish({ type: "instance.dataChanged", instanceId: request.params.id, data: body })
reply.code(204)
} catch (error) {
reply.code(400)
return { error: error instanceof Error ? error.message : "Failed to save instance data" }
}
})
app.delete<{ Params: { id: string } }>("/api/storage/instances/:id", async (request, reply) => {
try {
const storageId = resolveStorageKey(request.params.id)
await deps.instanceStore.delete(storageId)
deps.eventBus.publish({ type: "instance.dataChanged", instanceId: request.params.id, data: EMPTY_INSTANCE_DATA })
reply.code(204)
} catch (error) {
reply.code(500)
return { error: error instanceof Error ? error.message : "Failed to delete instance data" }
}
})
}

View File

@@ -0,0 +1,113 @@
import { FastifyInstance, FastifyReply } from "fastify"
import { z } from "zod"
import { WorkspaceManager } from "../../workspaces/manager"
interface RouteDeps {
workspaceManager: WorkspaceManager
}
const WorkspaceCreateSchema = z.object({
path: z.string(),
name: z.string().optional(),
})
const WorkspaceFilesQuerySchema = z.object({
path: z.string().optional(),
})
const WorkspaceFileContentQuerySchema = z.object({
path: z.string(),
})
const WorkspaceFileSearchQuerySchema = z.object({
q: z.string().trim().min(1, "Query is required"),
limit: z.coerce.number().int().positive().max(200).optional(),
type: z.enum(["all", "file", "directory"]).optional(),
refresh: z
.string()
.optional()
.transform((value) => (value === undefined ? undefined : value === "true")),
})
export function registerWorkspaceRoutes(app: FastifyInstance, deps: RouteDeps) {
app.get("/api/workspaces", async () => {
return deps.workspaceManager.list()
})
app.post("/api/workspaces", async (request, reply) => {
try {
const body = WorkspaceCreateSchema.parse(request.body ?? {})
const workspace = await deps.workspaceManager.create(body.path, body.name)
reply.code(201)
return workspace
} catch (error) {
request.log.error({ err: error }, "Failed to create workspace")
const message = error instanceof Error ? error.message : "Failed to create workspace"
reply.code(400).type("text/plain").send(message)
}
})
app.get<{ Params: { id: string } }>("/api/workspaces/:id", async (request, reply) => {
const workspace = deps.workspaceManager.get(request.params.id)
if (!workspace) {
reply.code(404)
return { error: "Workspace not found" }
}
return workspace
})
app.delete<{ Params: { id: string } }>("/api/workspaces/:id", async (request, reply) => {
await deps.workspaceManager.delete(request.params.id)
reply.code(204)
})
app.get<{
Params: { id: string }
Querystring: { path?: string }
}>("/api/workspaces/:id/files", async (request, reply) => {
try {
const query = WorkspaceFilesQuerySchema.parse(request.query ?? {})
return deps.workspaceManager.listFiles(request.params.id, query.path ?? ".")
} catch (error) {
return handleWorkspaceError(error, reply)
}
})
app.get<{
Params: { id: string }
Querystring: { q?: string; limit?: string; type?: "all" | "file" | "directory"; refresh?: string }
}>("/api/workspaces/:id/files/search", async (request, reply) => {
try {
const query = WorkspaceFileSearchQuerySchema.parse(request.query ?? {})
return deps.workspaceManager.searchFiles(request.params.id, query.q, {
limit: query.limit,
type: query.type,
refresh: query.refresh,
})
} catch (error) {
return handleWorkspaceError(error, reply)
}
})
app.get<{
Params: { id: string }
Querystring: { path?: string }
}>("/api/workspaces/:id/files/content", async (request, reply) => {
try {
const query = WorkspaceFileContentQuerySchema.parse(request.query ?? {})
return deps.workspaceManager.readFile(request.params.id, query.path)
} catch (error) {
return handleWorkspaceError(error, reply)
}
})
}
function handleWorkspaceError(error: unknown, reply: FastifyReply) {
if (error instanceof Error && error.message === "Workspace not found") {
reply.code(404)
return { error: "Workspace not found" }
}
reply.code(400)
return { error: error instanceof Error ? error.message : "Unable to fulfill request" }
}

View File

@@ -0,0 +1,64 @@
import fs from "fs"
import { promises as fsp } from "fs"
import os from "os"
import path from "path"
import type { InstanceData } from "../api-types"
const DEFAULT_INSTANCE_DATA: InstanceData = {
messageHistory: [],
agentModelSelections: {},
}
export class InstanceStore {
private readonly instancesDir: string
constructor(baseDir = path.join(os.homedir(), ".config", "codenomad", "instances")) {
this.instancesDir = baseDir
fs.mkdirSync(this.instancesDir, { recursive: true })
}
async read(id: string): Promise<InstanceData> {
try {
const filePath = this.resolvePath(id)
const content = await fsp.readFile(filePath, "utf-8")
const parsed = JSON.parse(content)
return { ...DEFAULT_INSTANCE_DATA, ...parsed }
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return DEFAULT_INSTANCE_DATA
}
throw error
}
}
async write(id: string, data: InstanceData): Promise<void> {
const filePath = this.resolvePath(id)
await fsp.mkdir(path.dirname(filePath), { recursive: true })
await fsp.writeFile(filePath, JSON.stringify(data, null, 2), "utf-8")
}
async delete(id: string): Promise<void> {
try {
const filePath = this.resolvePath(id)
await fsp.unlink(filePath)
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
throw error
}
}
}
private resolvePath(id: string): string {
const filename = this.sanitizeId(id)
return path.join(this.instancesDir, `${filename}.json`)
}
private sanitizeId(id: string): string {
return id
.replace(/[\\/]/g, "_")
.replace(/[^a-zA-Z0-9_.-]/g, "_")
.replace(/_{2,}/g, "_")
.replace(/^_|_$/g, "")
.toLowerCase()
}
}

View File

@@ -0,0 +1,195 @@
import { Agent, fetch } from "undici"
import { Agent as UndiciAgent } from "undici"
import { EventBus } from "../events/bus"
import { Logger } from "../logger"
import { WorkspaceManager } from "./manager"
import { InstanceStreamEvent, InstanceStreamStatus } from "../api-types"
const INSTANCE_HOST = "127.0.0.1"
const STREAM_AGENT = new UndiciAgent({ bodyTimeout: 0, headersTimeout: 0 })
const RECONNECT_DELAY_MS = 1000
interface InstanceEventBridgeOptions {
workspaceManager: WorkspaceManager
eventBus: EventBus
logger: Logger
}
interface ActiveStream {
controller: AbortController
task: Promise<void>
}
export class InstanceEventBridge {
private readonly streams = new Map<string, ActiveStream>()
constructor(private readonly options: InstanceEventBridgeOptions) {
const bus = this.options.eventBus
bus.on("workspace.started", (event) => this.startStream(event.workspace.id))
bus.on("workspace.stopped", (event) => this.stopStream(event.workspaceId, "workspace stopped"))
bus.on("workspace.error", (event) => this.stopStream(event.workspace.id, "workspace error"))
}
shutdown() {
for (const [id, active] of this.streams) {
active.controller.abort()
this.publishStatus(id, "disconnected")
}
this.streams.clear()
}
private startStream(workspaceId: string) {
if (this.streams.has(workspaceId)) {
return
}
const controller = new AbortController()
const task = this.runStream(workspaceId, controller.signal)
.catch((error) => {
if (!controller.signal.aborted) {
this.options.logger.warn({ workspaceId, err: error }, "Instance event stream failed")
this.publishStatus(workspaceId, "error", error instanceof Error ? error.message : String(error))
}
})
.finally(() => {
const active = this.streams.get(workspaceId)
if (active?.controller === controller) {
this.streams.delete(workspaceId)
}
})
this.streams.set(workspaceId, { controller, task })
}
private stopStream(workspaceId: string, reason?: string) {
const active = this.streams.get(workspaceId)
if (!active) {
return
}
active.controller.abort()
this.streams.delete(workspaceId)
this.publishStatus(workspaceId, "disconnected", reason)
}
private async runStream(workspaceId: string, signal: AbortSignal) {
while (!signal.aborted) {
const port = this.options.workspaceManager.getInstancePort(workspaceId)
if (!port) {
await this.delay(RECONNECT_DELAY_MS, signal)
continue
}
this.publishStatus(workspaceId, "connecting")
try {
await this.consumeStream(workspaceId, port, signal)
} catch (error) {
if (signal.aborted) {
break
}
this.options.logger.warn({ workspaceId, err: error }, "Instance event stream disconnected")
this.publishStatus(workspaceId, "error", error instanceof Error ? error.message : String(error))
await this.delay(RECONNECT_DELAY_MS, signal)
}
}
}
private async consumeStream(workspaceId: string, port: number, signal: AbortSignal) {
const url = `http://${INSTANCE_HOST}:${port}/event`
const response = await fetch(url, {
headers: { Accept: "text/event-stream" },
signal,
dispatcher: STREAM_AGENT,
})
if (!response.ok || !response.body) {
throw new Error(`Instance event stream unavailable (${response.status})`)
}
this.publishStatus(workspaceId, "connected")
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ""
while (!signal.aborted) {
const { done, value } = await reader.read()
if (done || !value) {
break
}
buffer += decoder.decode(value, { stream: true })
buffer = this.flushEvents(buffer, workspaceId)
}
}
private flushEvents(buffer: string, workspaceId: string) {
let separatorIndex = buffer.indexOf("\n\n")
while (separatorIndex >= 0) {
const chunk = buffer.slice(0, separatorIndex)
buffer = buffer.slice(separatorIndex + 2)
this.processChunk(chunk, workspaceId)
separatorIndex = buffer.indexOf("\n\n")
}
return buffer
}
private processChunk(chunk: string, workspaceId: string) {
const lines = chunk.split(/\r?\n/)
const dataLines: string[] = []
for (const line of lines) {
if (line.startsWith(":")) {
continue
}
if (line.startsWith("data:")) {
dataLines.push(line.slice(5).trimStart())
}
}
if (dataLines.length === 0) {
return
}
const payload = dataLines.join("\n").trim()
if (!payload) {
return
}
try {
const event = JSON.parse(payload) as InstanceStreamEvent
this.options.logger.debug({ workspaceId, eventType: event.type }, "Instance SSE event received")
if (this.options.logger.isLevelEnabled("trace")) {
this.options.logger.trace({ workspaceId, event }, "Instance SSE event payload")
}
this.options.eventBus.publish({ type: "instance.event", instanceId: workspaceId, event })
} catch (error) {
this.options.logger.warn({ workspaceId, chunk: payload, err: error }, "Failed to parse instance SSE payload")
}
}
private publishStatus(instanceId: string, status: InstanceStreamStatus, reason?: string) {
this.options.logger.debug({ instanceId, status, reason }, "Instance SSE status updated")
this.options.eventBus.publish({ type: "instance.eventStatus", instanceId, status, reason })
}
private delay(duration: number, signal: AbortSignal) {
if (duration <= 0) {
return Promise.resolve()
}
return new Promise<void>((resolve) => {
const timeout = setTimeout(() => {
signal.removeEventListener("abort", onAbort)
resolve()
}, duration)
const onAbort = () => {
clearTimeout(timeout)
resolve()
}
signal.addEventListener("abort", onAbort, { once: true })
})
}
}

View File

@@ -0,0 +1,422 @@
import path from "path"
import { spawnSync } from "child_process"
import { connect } from "net"
import { EventBus } from "../events/bus"
import { ConfigStore } from "../config/store"
import { BinaryRegistry } from "../config/binaries"
import { FileSystemBrowser } from "../filesystem/browser"
import { searchWorkspaceFiles, WorkspaceFileSearchOptions } from "../filesystem/search"
import { clearWorkspaceSearchCache } from "../filesystem/search-cache"
import { WorkspaceDescriptor, WorkspaceFileResponse, FileSystemEntry } from "../api-types"
import { WorkspaceRuntime, ProcessExitInfo } from "./runtime"
import { Logger } from "../logger"
import { getOpencodeConfigDir } from "../opencode-config"
const STARTUP_STABILITY_DELAY_MS = 1500
interface WorkspaceManagerOptions {
rootDir: string
configStore: ConfigStore
binaryRegistry: BinaryRegistry
eventBus: EventBus
logger: Logger
}
interface WorkspaceRecord extends WorkspaceDescriptor {}
export class WorkspaceManager {
private readonly workspaces = new Map<string, WorkspaceRecord>()
private readonly runtime: WorkspaceRuntime
private readonly opencodeConfigDir: string
constructor(private readonly options: WorkspaceManagerOptions) {
this.runtime = new WorkspaceRuntime(this.options.eventBus, this.options.logger)
this.opencodeConfigDir = getOpencodeConfigDir()
}
list(): WorkspaceDescriptor[] {
return Array.from(this.workspaces.values())
}
get(id: string): WorkspaceDescriptor | undefined {
return this.workspaces.get(id)
}
getInstancePort(id: string): number | undefined {
return this.workspaces.get(id)?.port
}
listFiles(workspaceId: string, relativePath = "."): FileSystemEntry[] {
const workspace = this.requireWorkspace(workspaceId)
const browser = new FileSystemBrowser({ rootDir: workspace.path })
return browser.list(relativePath)
}
searchFiles(workspaceId: string, query: string, options?: WorkspaceFileSearchOptions): FileSystemEntry[] {
const workspace = this.requireWorkspace(workspaceId)
return searchWorkspaceFiles(workspace.path, query, options)
}
readFile(workspaceId: string, relativePath: string): WorkspaceFileResponse {
const workspace = this.requireWorkspace(workspaceId)
const browser = new FileSystemBrowser({ rootDir: workspace.path })
const contents = browser.readFile(relativePath)
return {
workspaceId,
relativePath,
contents,
}
}
async create(folder: string, name?: string): Promise<WorkspaceDescriptor> {
const id = `${Date.now().toString(36)}`
const binary = this.options.binaryRegistry.resolveDefault()
const resolvedBinaryPath = this.resolveBinaryPath(binary.path)
const workspacePath = path.isAbsolute(folder) ? folder : path.resolve(this.options.rootDir, folder)
clearWorkspaceSearchCache(workspacePath)
this.options.logger.info({ workspaceId: id, folder: workspacePath, binary: resolvedBinaryPath }, "Creating workspace")
const proxyPath = `/workspaces/${id}/instance`
const descriptor: WorkspaceRecord = {
id,
path: workspacePath,
name,
status: "starting",
proxyPath,
binaryId: resolvedBinaryPath,
binaryLabel: binary.label,
binaryVersion: binary.version,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}
if (!descriptor.binaryVersion) {
descriptor.binaryVersion = this.detectBinaryVersion(resolvedBinaryPath)
}
this.workspaces.set(id, descriptor)
this.options.eventBus.publish({ type: "workspace.created", workspace: descriptor })
const preferences = this.options.configStore.get().preferences ?? {}
const userEnvironment = preferences.environmentVariables ?? {}
const environment = {
...userEnvironment,
OPENCODE_CONFIG_DIR: this.opencodeConfigDir,
}
try {
const { pid, port, exitPromise, getLastOutput } = await this.runtime.launch({
workspaceId: id,
folder: workspacePath,
binaryPath: resolvedBinaryPath,
environment,
onExit: (info) => this.handleProcessExit(info.workspaceId, info),
})
await this.waitForWorkspaceReadiness({ workspaceId: id, port, exitPromise, getLastOutput })
descriptor.pid = pid
descriptor.port = port
descriptor.status = "ready"
descriptor.updatedAt = new Date().toISOString()
this.options.eventBus.publish({ type: "workspace.started", workspace: descriptor })
this.options.logger.info({ workspaceId: id, port }, "Workspace ready")
return descriptor
} catch (error) {
descriptor.status = "error"
descriptor.error = error instanceof Error ? error.message : String(error)
descriptor.updatedAt = new Date().toISOString()
this.options.eventBus.publish({ type: "workspace.error", workspace: descriptor })
this.options.logger.error({ workspaceId: id, err: error }, "Workspace failed to start")
throw error
}
}
async delete(id: string): Promise<WorkspaceDescriptor | undefined> {
const workspace = this.workspaces.get(id)
if (!workspace) return undefined
this.options.logger.info({ workspaceId: id }, "Stopping workspace")
const wasRunning = Boolean(workspace.pid)
if (wasRunning) {
await this.runtime.stop(id).catch((error) => {
this.options.logger.warn({ workspaceId: id, err: error }, "Failed to stop workspace process cleanly")
})
}
this.workspaces.delete(id)
clearWorkspaceSearchCache(workspace.path)
if (!wasRunning) {
this.options.eventBus.publish({ type: "workspace.stopped", workspaceId: id })
}
return workspace
}
async shutdown() {
this.options.logger.info("Shutting down all workspaces")
for (const [id, workspace] of this.workspaces) {
if (workspace.pid) {
this.options.logger.info({ workspaceId: id }, "Stopping workspace during shutdown")
await this.runtime.stop(id).catch((error) => {
this.options.logger.error({ workspaceId: id, err: error }, "Failed to stop workspace during shutdown")
})
} else {
this.options.logger.debug({ workspaceId: id }, "Workspace already stopped")
}
}
this.workspaces.clear()
this.options.logger.info("All workspaces cleared")
}
private requireWorkspace(id: string): WorkspaceRecord {
const workspace = this.workspaces.get(id)
if (!workspace) {
throw new Error("Workspace not found")
}
return workspace
}
private resolveBinaryPath(identifier: string): string {
if (!identifier) {
return identifier
}
const looksLikePath = identifier.includes("/") || identifier.includes("\\") || identifier.startsWith(".")
if (path.isAbsolute(identifier) || looksLikePath) {
return identifier
}
const locator = process.platform === "win32" ? "where" : "which"
try {
const result = spawnSync(locator, [identifier], { encoding: "utf8" })
if (result.status === 0 && result.stdout) {
const resolved = result.stdout
.split(/\r?\n/)
.map((line) => line.trim())
.find((line) => line.length > 0)
if (resolved) {
this.options.logger.debug({ identifier, resolved }, "Resolved binary path from system PATH")
return resolved
}
} else if (result.error) {
this.options.logger.warn({ identifier, err: result.error }, "Failed to resolve binary path via locator command")
}
} catch (error) {
this.options.logger.warn({ identifier, err: error }, "Failed to resolve binary path from system PATH")
}
return identifier
}
private detectBinaryVersion(resolvedPath: string): string | undefined {
if (!resolvedPath) {
return undefined
}
try {
const result = spawnSync(resolvedPath, ["--version"], { encoding: "utf8" })
if (result.status === 0 && result.stdout) {
const line = result.stdout.split(/\r?\n/).find((entry) => entry.trim().length > 0)
if (line) {
const normalized = line.trim()
const versionMatch = normalized.match(/([0-9]+\.[0-9]+\.[0-9A-Za-z.-]+)/)
if (versionMatch) {
const version = versionMatch[1]
this.options.logger.debug({ binary: resolvedPath, version }, "Detected binary version")
return version
}
this.options.logger.debug({ binary: resolvedPath, reported: normalized }, "Binary reported version string")
return normalized
}
} else if (result.error) {
this.options.logger.warn({ binary: resolvedPath, err: result.error }, "Failed to read binary version")
}
} catch (error) {
this.options.logger.warn({ binary: resolvedPath, err: error }, "Failed to detect binary version")
}
return undefined
}
private async waitForWorkspaceReadiness(params: {
workspaceId: string
port: number
exitPromise: Promise<ProcessExitInfo>
getLastOutput: () => string
}) {
await Promise.race([
this.waitForPortAvailability(params.port),
params.exitPromise.then((info) => {
throw this.buildStartupError(
params.workspaceId,
"exited before becoming ready",
info,
params.getLastOutput(),
)
}),
])
await this.waitForInstanceHealth(params)
await Promise.race([
this.delay(STARTUP_STABILITY_DELAY_MS),
params.exitPromise.then((info) => {
throw this.buildStartupError(
params.workspaceId,
"exited shortly after start",
info,
params.getLastOutput(),
)
}),
])
}
private async waitForInstanceHealth(params: {
workspaceId: string
port: number
exitPromise: Promise<ProcessExitInfo>
getLastOutput: () => string
}) {
const probeResult = await Promise.race([
this.probeInstance(params.workspaceId, params.port),
params.exitPromise.then((info) => {
throw this.buildStartupError(
params.workspaceId,
"exited during health checks",
info,
params.getLastOutput(),
)
}),
])
if (probeResult.ok) {
return
}
const latestOutput = params.getLastOutput().trim()
const outputDetails = latestOutput ? ` Last output: ${latestOutput}` : ""
const reason = probeResult.reason ?? "Health check failed"
throw new Error(`Workspace ${params.workspaceId} failed health check: ${reason}.${outputDetails}`)
}
private async probeInstance(workspaceId: string, port: number): Promise<{ ok: boolean; reason?: string }> {
const url = `http://127.0.0.1:${port}/project/current`
try {
const response = await fetch(url)
if (!response.ok) {
const reason = `health probe returned HTTP ${response.status}`
this.options.logger.debug({ workspaceId, status: response.status }, "Health probe returned server error")
return { ok: false, reason }
}
return { ok: true }
} catch (error) {
const reason = error instanceof Error ? error.message : String(error)
this.options.logger.debug({ workspaceId, err: error }, "Health probe failed")
return { ok: false, reason }
}
}
private buildStartupError(
workspaceId: string,
phase: string,
exitInfo: ProcessExitInfo,
lastOutput: string,
): Error {
const exitDetails = this.describeExit(exitInfo)
const trimmedOutput = lastOutput.trim()
const outputDetails = trimmedOutput ? ` Last output: ${trimmedOutput}` : ""
return new Error(`Workspace ${workspaceId} ${phase} (${exitDetails}).${outputDetails}`)
}
private waitForPortAvailability(port: number, timeoutMs = 5000): Promise<void> {
return new Promise((resolve, reject) => {
const deadline = Date.now() + timeoutMs
let settled = false
let retryTimer: NodeJS.Timeout | null = null
const cleanup = () => {
settled = true
if (retryTimer) {
clearTimeout(retryTimer)
retryTimer = null
}
}
const tryConnect = () => {
if (settled) {
return
}
const socket = connect({ port, host: "127.0.0.1" }, () => {
cleanup()
socket.end()
resolve()
})
socket.once("error", () => {
socket.destroy()
if (settled) {
return
}
if (Date.now() >= deadline) {
cleanup()
reject(new Error(`Workspace port ${port} did not become ready within ${timeoutMs}ms`))
} else {
retryTimer = setTimeout(() => {
retryTimer = null
tryConnect()
}, 100)
}
})
}
tryConnect()
})
}
private delay(durationMs: number): Promise<void> {
if (durationMs <= 0) {
return Promise.resolve()
}
return new Promise((resolve) => setTimeout(resolve, durationMs))
}
private describeExit(info: ProcessExitInfo): string {
if (info.signal) {
return `signal ${info.signal}`
}
if (info.code !== null) {
return `code ${info.code}`
}
return "unknown reason"
}
private handleProcessExit(workspaceId: string, info: { code: number | null; requested: boolean }) {
const workspace = this.workspaces.get(workspaceId)
if (!workspace) return
this.options.logger.info({ workspaceId, ...info }, "Workspace process exited")
workspace.pid = undefined
workspace.port = undefined
workspace.updatedAt = new Date().toISOString()
if (info.requested || info.code === 0) {
workspace.status = "stopped"
workspace.error = undefined
this.options.eventBus.publish({ type: "workspace.stopped", workspaceId })
} else {
workspace.status = "error"
workspace.error = `Process exited with code ${info.code}`
this.options.eventBus.publish({ type: "workspace.error", workspace })
}
}
}

View File

@@ -0,0 +1,241 @@
import { ChildProcess, spawn } from "child_process"
import { existsSync, statSync } from "fs"
import path from "path"
import { EventBus } from "../events/bus"
import { LogLevel, WorkspaceLogEntry } from "../api-types"
import { Logger } from "../logger"
interface LaunchOptions {
workspaceId: string
folder: string
binaryPath: string
environment?: Record<string, string>
onExit?: (info: ProcessExitInfo) => void
}
export interface ProcessExitInfo {
workspaceId: string
code: number | null
signal: NodeJS.Signals | null
requested: boolean
}
interface ManagedProcess {
child: ChildProcess
requestedStop: boolean
}
export class WorkspaceRuntime {
private processes = new Map<string, ManagedProcess>()
constructor(private readonly eventBus: EventBus, private readonly logger: Logger) {}
async launch(options: LaunchOptions): Promise<{ pid: number; port: number; exitPromise: Promise<ProcessExitInfo>; getLastOutput: () => string }> {
this.validateFolder(options.folder)
const args = ["serve", "--port", "0", "--print-logs", "--log-level", "DEBUG"]
const env = { ...process.env, ...(options.environment ?? {}) }
let exitResolve: ((info: ProcessExitInfo) => void) | null = null
const exitPromise = new Promise<ProcessExitInfo>((resolveExit) => {
exitResolve = resolveExit
})
let lastOutput = ""
return new Promise((resolve, reject) => {
this.logger.info(
{ workspaceId: options.workspaceId, folder: options.folder, binary: options.binaryPath },
"Launching OpenCode process",
)
const child = spawn(options.binaryPath, args, {
cwd: options.folder,
env,
stdio: ["ignore", "pipe", "pipe"],
})
const managed: ManagedProcess = { child, requestedStop: false }
this.processes.set(options.workspaceId, managed)
let stdoutBuffer = ""
let stderrBuffer = ""
let portFound = false
let warningTimer: NodeJS.Timeout | null = null
const startWarningTimer = () => {
warningTimer = setInterval(() => {
this.logger.warn({ workspaceId: options.workspaceId }, "Workspace runtime has not reported a port yet")
}, 10000)
}
const stopWarningTimer = () => {
if (warningTimer) {
clearInterval(warningTimer)
warningTimer = null
}
}
startWarningTimer()
const cleanupStreams = () => {
stopWarningTimer()
child.stdout?.removeAllListeners()
child.stderr?.removeAllListeners()
}
const handleExit = (code: number | null, signal: NodeJS.Signals | null) => {
this.logger.info({ workspaceId: options.workspaceId, code, signal }, "OpenCode process exited")
this.processes.delete(options.workspaceId)
cleanupStreams()
child.removeListener("error", handleError)
child.removeListener("exit", handleExit)
const exitInfo: ProcessExitInfo = {
workspaceId: options.workspaceId,
code,
signal,
requested: managed.requestedStop,
}
if (exitResolve) {
exitResolve(exitInfo)
exitResolve = null
}
if (!portFound) {
const reason = stderrBuffer || `Process exited with code ${code}`
reject(new Error(reason))
} else {
options.onExit?.(exitInfo)
}
}
const handleError = (error: Error) => {
cleanupStreams()
child.removeListener("exit", handleExit)
this.processes.delete(options.workspaceId)
this.logger.error({ workspaceId: options.workspaceId, err: error }, "Workspace runtime error")
if (exitResolve) {
exitResolve({ workspaceId: options.workspaceId, code: null, signal: null, requested: managed.requestedStop })
exitResolve = null
}
reject(error)
}
child.on("error", handleError)
child.on("exit", handleExit)
child.stdout?.on("data", (data: Buffer) => {
const text = data.toString()
stdoutBuffer += text
const lines = stdoutBuffer.split("\n")
stdoutBuffer = lines.pop() ?? ""
for (const line of lines) {
const trimmed = line.trim()
if (!trimmed) continue
lastOutput = trimmed
this.emitLog(options.workspaceId, "info", line)
if (!portFound) {
const portMatch = line.match(/opencode server listening on http:\/\/.+:(\d+)/i)
if (portMatch) {
portFound = true
child.removeListener("error", handleError)
const port = parseInt(portMatch[1], 10)
this.logger.info({ workspaceId: options.workspaceId, port }, "Workspace runtime allocated port")
const getLastOutput = () => lastOutput.trim()
resolve({ pid: child.pid!, port, exitPromise, getLastOutput })
}
}
}
})
child.stderr?.on("data", (data: Buffer) => {
const text = data.toString()
stderrBuffer += text
const lines = stderrBuffer.split("\n")
stderrBuffer = lines.pop() ?? ""
for (const line of lines) {
const trimmed = line.trim()
if (!trimmed) continue
lastOutput = `[stderr] ${trimmed}`
this.emitLog(options.workspaceId, "error", line)
}
})
})
}
async stop(workspaceId: string): Promise<void> {
const managed = this.processes.get(workspaceId)
if (!managed) return
managed.requestedStop = true
const child = managed.child
this.logger.info({ workspaceId }, "Stopping OpenCode process")
await new Promise<void>((resolve, reject) => {
const cleanup = () => {
child.removeListener("exit", onExit)
child.removeListener("error", onError)
}
const onExit = () => {
cleanup()
resolve()
}
const onError = (error: Error) => {
cleanup()
reject(error)
}
const resolveIfAlreadyExited = () => {
if (child.exitCode !== null || child.signalCode !== null) {
this.logger.debug({ workspaceId, exitCode: child.exitCode, signal: child.signalCode }, "Process already exited")
cleanup()
resolve()
return true
}
return false
}
child.once("exit", onExit)
child.once("error", onError)
if (resolveIfAlreadyExited()) {
return
}
this.logger.debug({ workspaceId }, "Sending SIGTERM to workspace process")
child.kill("SIGTERM")
setTimeout(() => {
if (!child.killed) {
this.logger.warn({ workspaceId }, "Process did not stop after SIGTERM, force killing")
child.kill("SIGKILL")
} else {
this.logger.debug({ workspaceId }, "Workspace process stopped gracefully before SIGKILL timeout")
}
}, 2000)
})
}
private emitLog(workspaceId: string, level: LogLevel, message: string) {
const entry: WorkspaceLogEntry = {
workspaceId,
timestamp: new Date().toISOString(),
level,
message: message.trim(),
}
this.eventBus.publish({ type: "workspace.log", entry })
}
private validateFolder(folder: string) {
const resolved = path.resolve(folder)
if (!existsSync(resolved)) {
throw new Error(`Folder does not exist: ${resolved}`)
}
const stats = statSync(resolved)
if (!stats.isDirectory()) {
throw new Error(`Path is not a directory: ${resolved}`)
}
}
}

View File

@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "Node",
"strict": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"outDir": "dist",
"rootDir": "src",
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["dist", "node_modules"]
}

7
packages/tauri-app/.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
src-tauri/target
src-tauri/Cargo.lock
src-tauri/resources/
target
node_modules
dist
.DS_Store

5589
packages/tauri-app/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,3 @@
[workspace]
members = ["src-tauri"]
resolver = "2"

View File

@@ -0,0 +1,17 @@
{
"name": "@codenomad/tauri-app",
"version": "0.4.0",
"private": true,
"scripts": {
"dev": "npx --yes @tauri-apps/cli@^2.9.4 dev",
"dev:ui": "npm run dev --workspace @codenomad/ui",
"dev:prep": "node ./scripts/dev-prep.js",
"dev:bootstrap": "npm run dev:prep && npm run dev:ui",
"prebuild": "node ./scripts/prebuild.js",
"bundle:server": "npm run prebuild",
"build": "npx --yes @tauri-apps/cli@^2.9.4 build"
},
"devDependencies": {
"@tauri-apps/cli": "^2.9.4"
}
}

View File

@@ -0,0 +1,46 @@
#!/usr/bin/env node
const fs = require("fs")
const path = require("path")
const { execSync } = require("child_process")
const root = path.resolve(__dirname, "..")
const workspaceRoot = path.resolve(root, "..", "..")
const uiRoot = path.resolve(root, "..", "ui")
const uiDist = path.resolve(uiRoot, "src", "renderer", "dist")
const uiLoadingDest = path.resolve(root, "src-tauri", "resources", "ui-loading")
function ensureUiBuild() {
const loadingHtml = path.join(uiDist, "loading.html")
if (fs.existsSync(loadingHtml)) {
return
}
console.log("[dev-prep] UI loader build missing; running workspace build…")
execSync("npm --workspace @codenomad/ui run build", {
cwd: workspaceRoot,
stdio: "inherit",
})
if (!fs.existsSync(loadingHtml)) {
throw new Error("[dev-prep] failed to produce loading.html after UI build")
}
}
function copyUiLoadingAssets() {
const loadingSource = path.join(uiDist, "loading.html")
const assetsSource = path.join(uiDist, "assets")
fs.rmSync(uiLoadingDest, { recursive: true, force: true })
fs.mkdirSync(uiLoadingDest, { recursive: true })
fs.copyFileSync(loadingSource, path.join(uiLoadingDest, "loading.html"))
if (fs.existsSync(assetsSource)) {
fs.cpSync(assetsSource, path.join(uiLoadingDest, "assets"), { recursive: true })
}
console.log(`[dev-prep] copied loader bundle from ${uiDist}`)
}
ensureUiBuild()
copyUiLoadingAssets()

View File

@@ -0,0 +1,195 @@
#!/usr/bin/env node
const fs = require("fs")
const path = require("path")
const { execSync } = require("child_process")
const root = path.resolve(__dirname, "..")
const workspaceRoot = path.resolve(root, "..", "..")
const serverRoot = path.resolve(root, "..", "server")
const uiRoot = path.resolve(root, "..", "ui")
const uiDist = path.resolve(uiRoot, "src", "renderer", "dist")
const serverDest = path.resolve(root, "src-tauri", "resources", "server")
const uiLoadingDest = path.resolve(root, "src-tauri", "resources", "ui-loading")
const sources = ["dist", "public", "node_modules", "package.json"]
const serverInstallCommand =
"npm install --omit=dev --ignore-scripts --workspaces=false --package-lock=false --install-strategy=shallow --fund=false --audit=false"
const serverDevInstallCommand =
"npm install --workspace @neuralnomads/codenomad --include-workspace-root=false --install-strategy=nested --fund=false --audit=false"
const uiDevInstallCommand =
"npm install --workspace @codenomad/ui --include-workspace-root=false --install-strategy=nested --fund=false --audit=false"
const envWithRootBin = {
...process.env,
PATH: `${path.join(workspaceRoot, "node_modules/.bin")}:${process.env.PATH}`,
}
const braceExpansionPath = path.join(
serverRoot,
"node_modules",
"@fastify",
"static",
"node_modules",
"brace-expansion",
"package.json",
)
const viteBinPath = path.join(uiRoot, "node_modules", ".bin", "vite")
function ensureServerBuild() {
const distPath = path.join(serverRoot, "dist")
const publicPath = path.join(serverRoot, "public")
if (fs.existsSync(distPath) && fs.existsSync(publicPath)) {
return
}
console.log("[prebuild] server build missing; running workspace build...")
execSync("npm --workspace @neuralnomads/codenomad run build", {
cwd: workspaceRoot,
stdio: "inherit",
env: {
...process.env,
PATH: `${path.join(workspaceRoot, "node_modules/.bin")}:${process.env.PATH}`,
},
})
if (!fs.existsSync(distPath) || !fs.existsSync(publicPath)) {
throw new Error("[prebuild] server artifacts still missing after build")
}
}
function ensureUiBuild() {
const loadingHtml = path.join(uiDist, "loading.html")
if (fs.existsSync(loadingHtml)) {
return
}
console.log("[prebuild] ui build missing; running workspace build...")
execSync("npm --workspace @codenomad/ui run build", {
cwd: workspaceRoot,
stdio: "inherit",
})
if (!fs.existsSync(loadingHtml)) {
throw new Error("[prebuild] ui loading assets missing after build")
}
}
function ensureServerDevDependencies() {
if (fs.existsSync(braceExpansionPath)) {
return
}
console.log("[prebuild] ensuring server build dependencies (with dev)...")
execSync(serverDevInstallCommand, {
cwd: workspaceRoot,
stdio: "inherit",
env: envWithRootBin,
})
}
function ensureServerDependencies() {
if (fs.existsSync(braceExpansionPath)) {
return
}
console.log("[prebuild] ensuring server production dependencies...")
execSync(serverInstallCommand, {
cwd: serverRoot,
stdio: "inherit",
})
}
function ensureUiDevDependencies() {
if (fs.existsSync(viteBinPath)) {
return
}
console.log("[prebuild] ensuring ui build dependencies...")
execSync(uiDevInstallCommand, {
cwd: workspaceRoot,
stdio: "inherit",
env: envWithRootBin,
})
}
function ensureRollupPlatformBinary() {
const platformKey = `${process.platform}-${process.arch}`
const platformPackages = {
"linux-x64": "@rollup/rollup-linux-x64-gnu",
"linux-arm64": "@rollup/rollup-linux-arm64-gnu",
"darwin-arm64": "@rollup/rollup-darwin-arm64",
"darwin-x64": "@rollup/rollup-darwin-x64",
"win32-x64": "@rollup/rollup-win32-x64-msvc",
}
const pkgName = platformPackages[platformKey]
if (!pkgName) {
return
}
const platformPackagePath = path.join(workspaceRoot, "node_modules", "@rollup", pkgName.split("/").pop())
if (fs.existsSync(platformPackagePath)) {
return
}
let rollupVersion = ""
try {
rollupVersion = require(path.join(workspaceRoot, "node_modules", "rollup", "package.json")).version
} catch (error) {
// leave version empty; fallback install will use latest compatible
}
const packageSpec = rollupVersion ? `${pkgName}@${rollupVersion}` : pkgName
console.log("[prebuild] installing rollup platform binary (optional dep workaround)...")
execSync(`npm install ${packageSpec} --no-save --ignore-scripts --fund=false --audit=false`, {
cwd: workspaceRoot,
stdio: "inherit",
})
}
function copyServerArtifacts() {
fs.rmSync(serverDest, { recursive: true, force: true })
fs.mkdirSync(serverDest, { recursive: true })
for (const name of sources) {
const from = path.join(serverRoot, name)
const to = path.join(serverDest, name)
if (!fs.existsSync(from)) {
console.warn(`[prebuild] skipped missing ${from}`)
continue
}
fs.cpSync(from, to, { recursive: true, dereference: true })
console.log(`[prebuild] copied ${from} -> ${to}`)
}
}
function copyUiLoadingAssets() {
const loadingSource = path.join(uiDist, "loading.html")
const assetsSource = path.join(uiDist, "assets")
if (!fs.existsSync(loadingSource)) {
throw new Error("[prebuild] cannot find built loading.html")
}
fs.rmSync(uiLoadingDest, { recursive: true, force: true })
fs.mkdirSync(uiLoadingDest, { recursive: true })
fs.copyFileSync(loadingSource, path.join(uiLoadingDest, "loading.html"))
if (fs.existsSync(assetsSource)) {
fs.cpSync(assetsSource, path.join(uiLoadingDest, "assets"), { recursive: true })
}
console.log(`[prebuild] prepared UI loading assets from ${uiDist}`)
}
ensureServerDevDependencies()
ensureUiDevDependencies()
ensureRollupPlatformBinary()
ensureServerDependencies()
ensureServerBuild()
ensureUiBuild()
copyServerArtifacts()
copyUiLoadingAssets()

View File

@@ -0,0 +1,23 @@
[package]
name = "codenomad-tauri"
version = "0.1.0"
edition = "2021"
[build-dependencies]
tauri-build = { version = "2.5.2", features = [] }
[dependencies]
tauri = { version = "2.5.2", features = [ "devtools"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
regex = "1"
once_cell = "1"
parking_lot = "0.12"
thiserror = "1"
anyhow = "1"
which = "4"
libc = "0.2"
tauri-plugin-dialog = "2"
dirs = "5"
tauri-plugin-opener = "2"
url = "2"

View File

@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

View File

@@ -0,0 +1,16 @@
{
"$schema": "https://schema.tauri.app/capabilities.json",
"identifier": "main-window-native-dialogs",
"description": "Grant the main window access to required core features and native dialog commands.",
"remote": {
"urls": ["http://127.0.0.1:*", "http://localhost:*"]
},
"windows": ["main"],
"permissions": [
"core:default",
"core:menu:default",
"dialog:allow-open",
"opener:allow-default-urls",
"core:webview:allow-set-webview-zoom"
]
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
{"main-window-native-dialogs":{"identifier":"main-window-native-dialogs","description":"Grant the main window access to required core features and native dialog commands.","remote":{"urls":["http://127.0.0.1:*","http://localhost:*"]},"local":true,"windows":["main"],"permissions":["core:default","core:menu:default","dialog:allow-open","opener:allow-default-urls","core:webview:allow-set-webview-zoom"]}}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 422 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

View File

@@ -0,0 +1,712 @@
use dirs::home_dir;
use parking_lot::Mutex;
use regex::Regex;
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::collections::VecDeque;
use std::env;
use std::ffi::OsStr;
use std::fs;
use std::io::{BufRead, BufReader};
use std::path::PathBuf;
use std::process::{Child, Command, Stdio};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::thread;
use std::time::{Duration, Instant};
use tauri::{AppHandle, Emitter, Manager, Url};
fn log_line(message: &str) {
println!("[tauri-cli] {message}");
}
fn workspace_root() -> Option<PathBuf> {
std::env::current_dir().ok().and_then(|mut dir| {
for _ in 0..3 {
if let Some(parent) = dir.parent() {
dir = parent.to_path_buf();
}
}
Some(dir)
})
}
fn navigate_main(app: &AppHandle, url: &str) {
if let Some(win) = app.webview_windows().get("main") {
log_line(&format!("navigating main to {url}"));
if let Ok(parsed) = Url::parse(url) {
let _ = win.navigate(parsed);
} else {
log_line("failed to parse URL for navigation");
}
} else {
log_line("main window not found for navigation");
}
}
const DEFAULT_CONFIG_PATH: &str = "~/.config/codenomad/config.json";
#[derive(Debug, Deserialize)]
struct PreferencesConfig {
#[serde(rename = "listeningMode")]
listening_mode: Option<String>,
}
#[derive(Debug, Deserialize)]
struct AppConfig {
preferences: Option<PreferencesConfig>,
}
fn resolve_config_path() -> PathBuf {
let raw = env::var("CLI_CONFIG")
.ok()
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| DEFAULT_CONFIG_PATH.to_string());
expand_home(&raw)
}
fn expand_home(path: &str) -> PathBuf {
if path.starts_with("~/") {
if let Some(home) = home_dir().or_else(|| env::var("HOME").ok().map(PathBuf::from)) {
return home.join(path.trim_start_matches("~/"));
}
}
PathBuf::from(path)
}
fn resolve_listening_mode() -> String {
let path = resolve_config_path();
if let Ok(content) = fs::read_to_string(path) {
if let Ok(config) = serde_json::from_str::<AppConfig>(&content) {
if let Some(mode) = config
.preferences
.as_ref()
.and_then(|prefs| prefs.listening_mode.as_ref())
{
if mode == "local" {
return "local".to_string();
}
if mode == "all" {
return "all".to_string();
}
}
}
}
"local".to_string()
}
fn resolve_listening_host() -> String {
let mode = resolve_listening_mode();
if mode == "local" {
"127.0.0.1".to_string()
} else {
"0.0.0.0".to_string()
}
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum CliState {
Starting,
Ready,
Error,
Stopped,
}
#[derive(Debug, Clone, Serialize)]
pub struct CliStatus {
pub state: CliState,
pub pid: Option<u32>,
pub port: Option<u16>,
pub url: Option<String>,
pub error: Option<String>,
}
impl Default for CliStatus {
fn default() -> Self {
Self {
state: CliState::Stopped,
pid: None,
port: None,
url: None,
error: None,
}
}
}
#[derive(Debug, Clone)]
pub struct CliProcessManager {
status: Arc<Mutex<CliStatus>>,
child: Arc<Mutex<Option<Child>>>,
ready: Arc<AtomicBool>,
}
impl CliProcessManager {
pub fn new() -> Self {
Self {
status: Arc::new(Mutex::new(CliStatus::default())),
child: Arc::new(Mutex::new(None)),
ready: Arc::new(AtomicBool::new(false)),
}
}
pub fn start(&self, app: AppHandle, dev: bool) -> anyhow::Result<()> {
log_line(&format!("start requested (dev={dev})"));
self.stop()?;
self.ready.store(false, Ordering::SeqCst);
{
let mut status = self.status.lock();
status.state = CliState::Starting;
status.port = None;
status.url = None;
status.error = None;
status.pid = None;
}
Self::emit_status(&app, &self.status.lock());
let status_arc = self.status.clone();
let child_arc = self.child.clone();
let ready_flag = self.ready.clone();
thread::spawn(move || {
if let Err(err) = Self::spawn_cli(app.clone(), status_arc.clone(), child_arc, ready_flag, dev) {
log_line(&format!("cli spawn failed: {err}"));
let mut locked = status_arc.lock();
locked.state = CliState::Error;
locked.error = Some(err.to_string());
let snapshot = locked.clone();
drop(locked);
let _ = app.emit("cli:error", json!({"message": err.to_string()}));
let _ = app.emit("cli:status", snapshot);
}
});
Ok(())
}
pub fn stop(&self) -> anyhow::Result<()> {
let mut child_opt = self.child.lock();
if let Some(mut child) = child_opt.take() {
#[cfg(unix)]
unsafe {
libc::kill(child.id() as i32, libc::SIGTERM);
}
#[cfg(windows)]
{
let _ = child.kill();
}
let start = Instant::now();
loop {
match child.try_wait() {
Ok(Some(_)) => break,
Ok(None) => {
if start.elapsed() > Duration::from_secs(4) {
#[cfg(unix)]
unsafe {
libc::kill(child.id() as i32, libc::SIGKILL);
}
#[cfg(windows)]
{
let _ = child.kill();
}
break;
}
thread::sleep(Duration::from_millis(50));
}
Err(_) => break,
}
}
}
let mut status = self.status.lock();
status.state = CliState::Stopped;
status.pid = None;
status.port = None;
status.url = None;
status.error = None;
Ok(())
}
pub fn status(&self) -> CliStatus {
self.status.lock().clone()
}
fn spawn_cli(
app: AppHandle,
status: Arc<Mutex<CliStatus>>,
child_holder: Arc<Mutex<Option<Child>>>,
ready: Arc<AtomicBool>,
dev: bool,
) -> anyhow::Result<()> {
log_line("resolving CLI entry");
let resolution = CliEntry::resolve(&app, dev)?;
let host = resolve_listening_host();
log_line(&format!(
"resolved CLI entry runner={:?} entry={} host={}",
resolution.runner, resolution.entry, host
));
let args = resolution.build_args(dev, &host);
log_line(&format!("CLI args: {:?}", args));
if dev {
log_line("development mode: will prefer tsx + source if present");
}
let cwd = workspace_root();
if let Some(ref c) = cwd {
log_line(&format!("using cwd={}", c.display()));
}
let command_info = if supports_user_shell() {
log_line("spawning via user shell");
ShellCommandType::UserShell(build_shell_command_string(&resolution, &args)?)
} else {
log_line("spawning directly with node");
ShellCommandType::Direct(DirectCommand {
program: resolution.node_binary.clone(),
args: resolution.runner_args(&args),
})
};
if !supports_user_shell() {
if which::which(&resolution.node_binary).is_err() {
return Err(anyhow::anyhow!("Node binary not found. Make sure Node.js is installed."));
}
}
let child = match &command_info {
ShellCommandType::UserShell(cmd) => {
log_line(&format!("spawn command: {} {:?}", cmd.shell, cmd.args));
let mut c = Command::new(&cmd.shell);
c.args(&cmd.args)
.env("ELECTRON_RUN_AS_NODE", "1")
.stdout(Stdio::piped())
.stderr(Stdio::piped());
if let Some(ref cwd) = cwd {
c.current_dir(cwd);
}
c.spawn()?
}
ShellCommandType::Direct(cmd) => {
log_line(&format!("spawn command: {} {:?}", cmd.program, cmd.args));
let mut c = Command::new(&cmd.program);
c.args(&cmd.args)
.env("ELECTRON_RUN_AS_NODE", "1")
.stdout(Stdio::piped())
.stderr(Stdio::piped());
if let Some(ref cwd) = cwd {
c.current_dir(cwd);
}
c.spawn()?
}
};
let pid = child.id();
log_line(&format!("spawned pid={pid}"));
{
let mut locked = status.lock();
locked.pid = Some(pid);
}
Self::emit_status(&app, &status.lock());
{
let mut holder = child_holder.lock();
*holder = Some(child);
}
let child_clone = child_holder.clone();
let status_clone = status.clone();
let app_clone = app.clone();
let ready_clone = ready.clone();
thread::spawn(move || {
let stdout = child_clone
.lock()
.as_mut()
.and_then(|c| c.stdout.take())
.map(BufReader::new);
let stderr = child_clone
.lock()
.as_mut()
.and_then(|c| c.stderr.take())
.map(BufReader::new);
if let Some(reader) = stdout {
Self::process_stream(reader, "stdout", &app_clone, &status_clone, &ready_clone);
}
if let Some(reader) = stderr {
Self::process_stream(reader, "stderr", &app_clone, &status_clone, &ready_clone);
}
});
let app_clone = app.clone();
let status_clone = status.clone();
let ready_clone = ready.clone();
let child_holder_clone = child_holder.clone();
thread::spawn(move || {
let timeout = Duration::from_secs(60);
thread::sleep(timeout);
if ready_clone.load(Ordering::SeqCst) {
return;
}
let mut locked = status_clone.lock();
locked.state = CliState::Error;
locked.error = Some("CLI did not start in time".to_string());
log_line("timeout waiting for CLI readiness");
if let Some(child) = child_holder_clone.lock().as_mut() {
let _ = child.kill();
}
let _ = app_clone.emit("cli:error", json!({"message": "CLI did not start in time"}));
Self::emit_status(&app_clone, &locked);
});
let status_clone = status.clone();
let app_clone = app.clone();
thread::spawn(move || {
let code = {
let mut guard = child_holder.lock();
if let Some(child) = guard.as_mut() {
child.wait().ok()
} else {
None
}
};
let mut locked = status_clone.lock();
let failed = locked.state != CliState::Ready;
let err_msg = if failed {
Some(match code {
Some(status) => format!("CLI exited early: {status}"),
None => "CLI exited early".to_string(),
})
} else {
None
};
if failed {
locked.state = CliState::Error;
if locked.error.is_none() {
locked.error = err_msg.clone();
}
log_line(&format!("cli process exited before ready: {:?}", locked.error));
let _ = app_clone.emit("cli:error", json!({"message": locked.error.clone().unwrap_or_default()}));
} else {
locked.state = CliState::Stopped;
log_line("cli process stopped cleanly");
}
Self::emit_status(&app_clone, &locked);
});
Ok(())
}
fn process_stream<R: BufRead>(
mut reader: R,
stream: &str,
app: &AppHandle,
status: &Arc<Mutex<CliStatus>>,
ready: &Arc<AtomicBool>,
) {
let mut buffer = String::new();
let port_regex = Regex::new(r"CodeNomad Server is ready at http://[^:]+:(\d+)").ok();
let http_regex = Regex::new(r":(\d{2,5})(?!.*:\d)").ok();
loop {
buffer.clear();
match reader.read_line(&mut buffer) {
Ok(0) => break,
Ok(_) => {
let line = buffer.trim_end();
if !line.is_empty() {
log_line(&format!("[cli][{}] {}", stream, line));
if ready.load(Ordering::SeqCst) {
continue;
}
if let Some(port) = port_regex
.as_ref()
.and_then(|re| re.captures(line).and_then(|c| c.get(1)))
.and_then(|m| m.as_str().parse::<u16>().ok())
{
Self::mark_ready(app, status, ready, port);
continue;
}
if line.to_lowercase().contains("http server listening") {
if let Some(port) = http_regex
.as_ref()
.and_then(|re| re.captures(line).and_then(|c| c.get(1)))
.and_then(|m| m.as_str().parse::<u16>().ok())
{
Self::mark_ready(app, status, ready, port);
continue;
}
if let Ok(value) = serde_json::from_str::<serde_json::Value>(line) {
if let Some(port) = value.get("port").and_then(|p| p.as_u64()) {
Self::mark_ready(app, status, ready, port as u16);
continue;
}
}
}
}
}
Err(_) => break,
}
}
}
fn mark_ready(app: &AppHandle, status: &Arc<Mutex<CliStatus>>, ready: &Arc<AtomicBool>, port: u16) {
ready.store(true, Ordering::SeqCst);
let mut locked = status.lock();
let url = format!("http://127.0.0.1:{port}");
locked.port = Some(port);
locked.url = Some(url.clone());
locked.state = CliState::Ready;
locked.error = None;
log_line(&format!("cli ready on {url}"));
navigate_main(app, &url);
let _ = app.emit("cli:ready", locked.clone());
Self::emit_status(app, &locked);
}
fn emit_status(app: &AppHandle, status: &CliStatus) {
let _ = app.emit("cli:status", status.clone());
}
}
fn supports_user_shell() -> bool {
cfg!(unix)
}
#[derive(Debug)]
struct ShellCommand {
shell: String,
args: Vec<String>,
}
#[derive(Debug)]
struct DirectCommand {
program: String,
args: Vec<String>,
}
#[derive(Debug)]
enum ShellCommandType {
UserShell(ShellCommand),
Direct(DirectCommand),
}
#[derive(Debug)]
struct CliEntry {
entry: String,
runner: Runner,
runner_path: Option<String>,
node_binary: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Runner {
Node,
Tsx,
}
impl CliEntry {
fn resolve(app: &AppHandle, dev: bool) -> anyhow::Result<Self> {
let node_binary = std::env::var("NODE_BINARY").unwrap_or_else(|_| "node".to_string());
if dev {
if let Some(tsx_path) = resolve_tsx(app) {
if let Some(entry) = resolve_dev_entry(app) {
return Ok(Self {
entry,
runner: Runner::Tsx,
runner_path: Some(tsx_path),
node_binary,
});
}
}
}
if let Some(entry) = resolve_dist_entry(app) {
return Ok(Self {
entry,
runner: Runner::Node,
runner_path: None,
node_binary,
});
}
Err(anyhow::anyhow!(
"Unable to locate CodeNomad CLI build (dist/bin.js). Please build @neuralnomads/codenomad."
))
}
fn build_args(&self, dev: bool, host: &str) -> Vec<String> {
let mut args = vec![
"serve".to_string(),
"--host".to_string(),
host.to_string(),
"--port".to_string(),
"0".to_string(),
];
if dev {
args.push("--ui-dev-server".to_string());
args.push("http://localhost:3000".to_string());
args.push("--log-level".to_string());
args.push("debug".to_string());
}
args
}
fn runner_args(&self, cli_args: &[String]) -> Vec<String> {
let mut args = VecDeque::new();
if self.runner == Runner::Tsx {
if let Some(path) = &self.runner_path {
args.push_back(path.clone());
}
}
args.push_back(self.entry.clone());
for arg in cli_args {
args.push_back(arg.clone());
}
args.into_iter().collect()
}
}
fn resolve_tsx(_app: &AppHandle) -> Option<String> {
let candidates = vec![
std::env::current_dir()
.ok()
.map(|p| p.join("node_modules/tsx/dist/cli.js")),
std::env::current_exe()
.ok()
.and_then(|ex| ex.parent().map(|p| p.join("../node_modules/tsx/dist/cli.js"))),
];
first_existing(candidates)
}
fn resolve_dev_entry(_app: &AppHandle) -> Option<String> {
let candidates = vec![
std::env::current_dir()
.ok()
.map(|p| p.join("packages/server/src/index.ts")),
std::env::current_dir()
.ok()
.map(|p| p.join("../server/src/index.ts")),
];
first_existing(candidates)
}
fn resolve_dist_entry(_app: &AppHandle) -> Option<String> {
let base = workspace_root();
let mut candidates: Vec<Option<PathBuf>> = vec![
base.as_ref().map(|p| p.join("packages/server/dist/bin.js")),
base.as_ref().map(|p| p.join("packages/server/dist/index.js")),
base.as_ref().map(|p| p.join("server/dist/bin.js")),
base.as_ref().map(|p| p.join("server/dist/index.js")),
];
if let Ok(exe) = std::env::current_exe() {
if let Some(dir) = exe.parent() {
let resources = dir.join("../Resources");
candidates.push(Some(resources.join("server/dist/bin.js")));
candidates.push(Some(resources.join("server/dist/index.js")));
candidates.push(Some(resources.join("server/dist/server/bin.js")));
candidates.push(Some(resources.join("server/dist/server/index.js")));
candidates.push(Some(resources.join("resources/server/dist/bin.js")));
candidates.push(Some(resources.join("resources/server/dist/index.js")));
candidates.push(Some(resources.join("resources/server/dist/server/bin.js")));
candidates.push(Some(resources.join("resources/server/dist/server/index.js")));
let linux_resource_roots = [dir.join("../lib/CodeNomad"), dir.join("../lib/codenomad")];
for root in linux_resource_roots {
candidates.push(Some(root.join("server/dist/bin.js")));
candidates.push(Some(root.join("server/dist/index.js")));
candidates.push(Some(root.join("server/dist/server/bin.js")));
candidates.push(Some(root.join("server/dist/server/index.js")));
candidates.push(Some(root.join("resources/server/dist/bin.js")));
candidates.push(Some(root.join("resources/server/dist/index.js")));
candidates.push(Some(root.join("resources/server/dist/server/bin.js")));
candidates.push(Some(root.join("resources/server/dist/server/index.js")));
}
}
}
first_existing(candidates)
}
fn build_shell_command_string(entry: &CliEntry, cli_args: &[String]) -> anyhow::Result<ShellCommand> {
let shell = default_shell();
let mut quoted: Vec<String> = Vec::new();
quoted.push(shell_escape(&entry.node_binary));
for arg in entry.runner_args(cli_args) {
quoted.push(shell_escape(&arg));
}
let command = format!("ELECTRON_RUN_AS_NODE=1 exec {}", quoted.join(" "));
let args = build_shell_args(&shell, &command);
log_line(&format!("user shell command: {} {:?}", shell, args));
Ok(ShellCommand { shell, args })
}
fn default_shell() -> String {
if let Ok(shell) = std::env::var("SHELL") {
if !shell.trim().is_empty() {
return shell;
}
}
if cfg!(target_os = "macos") {
"/bin/zsh".to_string()
} else {
"/bin/bash".to_string()
}
}
fn shell_escape(input: &str) -> String {
if input.is_empty() {
"''".to_string()
} else if !input
.chars()
.any(|c| matches!(c, ' ' | '"' | '\'' | '$' | '`' | '!' ))
{
input.to_string()
} else {
let escaped = input.replace('\'', "'\\''");
format!("'{}'", escaped)
}
}
fn build_shell_args(shell: &str, command: &str) -> Vec<String> {
let shell_name = std::path::Path::new(shell)
.file_name()
.and_then(OsStr::to_str)
.unwrap_or("")
.to_lowercase();
if shell_name.contains("zsh") {
vec!["-l".into(), "-i".into(), "-c".into(), command.into()]
} else {
vec!["-l".into(), "-c".into(), command.into()]
}
}
fn first_existing(paths: Vec<Option<PathBuf>>) -> Option<String> {
paths
.into_iter()
.flatten()
.find(|p| p.exists())
.map(|p| normalize_path(p))
}
fn normalize_path(path: PathBuf) -> String {
if let Ok(clean) = path.canonicalize() {
clean.to_string_lossy().to_string()
} else {
path.to_string_lossy().to_string()
}
}

View File

@@ -0,0 +1,267 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
mod cli_manager;
use cli_manager::{CliProcessManager, CliStatus};
use serde_json::json;
use tauri::menu::{MenuBuilder, MenuItem, SubmenuBuilder};
use tauri::plugin::{Builder as PluginBuilder, TauriPlugin};
use tauri::webview::Webview;
use tauri::{AppHandle, Emitter, Manager, Runtime, Wry};
use tauri_plugin_opener::OpenerExt;
use url::Url;
#[derive(Clone)]
pub struct AppState {
pub manager: CliProcessManager,
}
#[tauri::command]
fn cli_get_status(state: tauri::State<AppState>) -> CliStatus {
state.manager.status()
}
#[tauri::command]
fn cli_restart(app: AppHandle, state: tauri::State<AppState>) -> Result<CliStatus, String> {
let dev_mode = is_dev_mode();
state.manager.stop().map_err(|e| e.to_string())?;
state
.manager
.start(app, dev_mode)
.map_err(|e| e.to_string())?;
Ok(state.manager.status())
}
fn is_dev_mode() -> bool {
cfg!(debug_assertions) || std::env::var("TAURI_DEV").is_ok()
}
fn should_allow_internal(url: &Url) -> bool {
match url.scheme() {
"tauri" | "asset" | "file" => true,
"http" | "https" => matches!(url.host_str(), Some("127.0.0.1" | "localhost")),
_ => false,
}
}
fn intercept_navigation<R: Runtime>(webview: &Webview<R>, url: &Url) -> bool {
if should_allow_internal(url) {
return true;
}
if let Err(err) = webview
.app_handle()
.opener()
.open_url(url.as_str(), None::<&str>)
{
eprintln!("[tauri] failed to open external link {}: {}", url, err);
}
false
}
fn main() {
let navigation_guard: TauriPlugin<Wry, ()> = PluginBuilder::new("external-link-guard")
.on_navigation(|webview, url| intercept_navigation(webview, url))
.build();
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_opener::init())
.plugin(navigation_guard)
.manage(AppState {
manager: CliProcessManager::new(),
})
.setup(|app| {
build_menu(&app.handle())?;
let dev_mode = is_dev_mode();
let app_handle = app.handle().clone();
let manager = app.state::<AppState>().manager.clone();
std::thread::spawn(move || {
if let Err(err) = manager.start(app_handle.clone(), dev_mode) {
let _ = app_handle.emit("cli:error", json!({"message": err.to_string()}));
}
});
Ok(())
})
.invoke_handler(tauri::generate_handler![cli_get_status, cli_restart])
.on_menu_event(|app_handle, event| {
match event.id().0.as_str() {
// File menu
"new_instance" => {
if let Some(window) = app_handle.get_webview_window("main") {
let _ = window.emit("menu:newInstance", ());
}
}
"close" => {
if let Some(window) = app_handle.get_webview_window("main") {
let _ = window.close();
}
}
"quit" => {
app_handle.exit(0);
}
// View menu
"reload" => {
if let Some(window) = app_handle.get_webview_window("main") {
let _ = window.eval("window.location.reload()");
}
}
"force_reload" => {
if let Some(window) = app_handle.get_webview_window("main") {
let _ = window.eval("window.location.reload(true)");
}
}
"toggle_devtools" => {
if let Some(window) = app_handle.get_webview_window("main") {
window.open_devtools();
}
}
"toggle_fullscreen" => {
if let Some(window) = app_handle.get_webview_window("main") {
let _ = window.set_fullscreen(!window.is_fullscreen().unwrap_or(false));
}
}
// Window menu
"minimize" => {
if let Some(window) = app_handle.get_webview_window("main") {
let _ = window.minimize();
}
}
"zoom" => {
if let Some(window) = app_handle.get_webview_window("main") {
let _ = window.maximize();
}
}
// App menu (macOS)
"about" => {
// TODO: Implement about dialog
println!("About menu item clicked");
}
"hide" => {
if let Some(window) = app_handle.get_webview_window("main") {
let _ = window.hide();
}
}
"hide_others" => {
// TODO: Hide other app windows
println!("Hide Others menu item clicked");
}
"show_all" => {
// TODO: Show all app windows
println!("Show All menu item clicked");
}
_ => {
println!("Unhandled menu event: {}", event.id().0);
}
}
})
.build(tauri::generate_context!())
.expect("error while building tauri application")
.run(|app_handle, event| match event {
tauri::RunEvent::ExitRequested { .. } => {
let app = app_handle.clone();
std::thread::spawn(move || {
if let Some(state) = app.try_state::<AppState>() {
let _ = state.manager.stop();
}
app.exit(0);
});
}
tauri::RunEvent::WindowEvent {
event: tauri::WindowEvent::Destroyed,
..
} => {
if app_handle.webview_windows().len() <= 1 {
let app = app_handle.clone();
std::thread::spawn(move || {
if let Some(state) = app.try_state::<AppState>() {
let _ = state.manager.stop();
}
app.exit(0);
});
}
}
_ => {}
});
}
fn build_menu(app: &AppHandle) -> tauri::Result<()> {
let is_mac = cfg!(target_os = "macos");
// Create submenus
let mut submenus = Vec::new();
// App menu (macOS only)
if is_mac {
let app_menu = SubmenuBuilder::new(app, "CodeNomad")
.text("about", "About CodeNomad")
.separator()
.text("hide", "Hide CodeNomad")
.text("hide_others", "Hide Others")
.text("show_all", "Show All")
.separator()
.text("quit", "Quit CodeNomad")
.build()?;
submenus.push(app_menu);
}
// File menu - create New Instance with accelerator
let new_instance_item = MenuItem::with_id(
app,
"new_instance",
"New Instance",
true,
Some("CmdOrCtrl+N")
)?;
let file_menu = SubmenuBuilder::new(app, "File")
.item(&new_instance_item)
.separator()
.text(if is_mac { "close" } else { "quit" }, if is_mac { "Close" } else { "Quit" })
.build()?;
submenus.push(file_menu);
// Edit menu with predefined items for standard functionality
let edit_menu = SubmenuBuilder::new(app, "Edit")
.undo()
.redo()
.separator()
.cut()
.copy()
.paste()
.separator()
.select_all()
.build()?;
submenus.push(edit_menu);
// View menu
let view_menu = SubmenuBuilder::new(app, "View")
.text("reload", "Reload")
.text("force_reload", "Force Reload")
.text("toggle_devtools", "Toggle Developer Tools")
.separator()
.separator()
.text("toggle_fullscreen", "Toggle Full Screen")
.build()?;
submenus.push(view_menu);
// Window menu
let window_menu = SubmenuBuilder::new(app, "Window")
.text("minimize", "Minimize")
.text("zoom", "Zoom")
.build()?;
submenus.push(window_menu);
// Build the main menu with all submenus
let submenu_refs: Vec<&dyn tauri::menu::IsMenuItem<_>> = submenus.iter().map(|s| s as &dyn tauri::menu::IsMenuItem<_>).collect();
let menu = MenuBuilder::new(app).items(&submenu_refs).build()?;
app.set_menu(menu)?;
Ok(())
}

View File

@@ -0,0 +1,50 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "CodeNomad",
"version": "0.1.0",
"identifier": "ai.opencode.client",
"build": {
"beforeDevCommand": "npm run dev:bootstrap",
"beforeBuildCommand": "npm run bundle:server",
"frontendDist": "resources/ui-loading"
},
"app": {
"withGlobalTauri": true,
"windows": [
{
"label": "main",
"title": "CodeNomad",
"url": "loading.html",
"width": 1400,
"height": 900,
"minWidth": 800,
"minHeight": 600,
"center": true,
"resizable": true,
"fullscreen": false,
"decorations": true,
"theme": "Dark",
"backgroundColor": "#1a1a1a",
"zoomHotkeysEnabled": true
}
],
"security": {
"assetProtocol": {
"scope": ["**"]
},
"capabilities": ["main-window-native-dialogs"]
}
},
"bundle": {
"active": true,
"resources": [
"resources/server",
"resources/ui-loading"
],
"icon": ["icon.icns", "icon.ico", "icon.png"],
"targets": ["app", "appimage", "deb", "rpm", "nsis"]
}
}

3
packages/ui/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
node_modules/
dist/
.vite/

54
packages/ui/README.md Normal file
View File

@@ -0,0 +1,54 @@
# CodeNomad UI
This package contains the frontend user interface for CodeNomad, built with [SolidJS](https://www.solidjs.com/) and [Tailwind CSS](https://tailwindcss.com/).
## Overview
The UI is designed to be a high-performance, low-latency cockpit for managing OpenCode sessions. It connects to the CodeNomad server (either running locally via CLI or embedded in the Electron app).
## Features
- **SolidJS**: Fine-grained reactivity for high performance.
- **Tailwind CSS**: Utility-first styling for rapid development.
- **Vite**: Fast build tool and dev server.
## Development
To run the UI in standalone mode (connected to a running server):
```bash
npm run dev
```
This starts the Vite dev server at `http://localhost:3000`.
## Building
To build the production assets:
```
npm run build
```
The output will be generated in the `dist` directory, which is then consumed by the Server or Electron app.
## Debug Logging
The UI now routes all logging through a lightweight wrapper around [`debug`](https://github.com/debug-js/debug). The logger exposes four namespaces that can be toggled at runtime:
- `sse` Server-sent event transport and handlers
- `api` HTTP/API calls and workspace lifecycle
- `session` Session/model state, prompt handling, tool calls
- `actions` User-driven interactions in UI components
You can enable or disable namespaces from DevTools (in dev or production builds) via the global `window.codenomadLogger` helpers:
```js
window.codenomadLogger?.listLoggerNamespaces() // => [{ name: "sse", enabled: false }, ...]
window.codenomadLogger?.enableLogger("sse") // turn on SSE logs
window.codenomadLogger?.disableLogger("sse") // turn them off again
window.codenomadLogger?.enableAllLoggers() // optional helper
```
Enabled namespaces are persisted in `localStorage` under `opencode:logger:namespaces`, so your preference survives reloads.

37
packages/ui/package.json Normal file
View File

@@ -0,0 +1,37 @@
{
"name": "@codenomad/ui",
"version": "0.4.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit -p tsconfig.json"
},
"dependencies": {
"@git-diff-view/solid": "^0.0.8",
"@kobalte/core": "0.13.11",
"@opencode-ai/sdk": "^1.0.138",
"@solidjs/router": "^0.13.0",
"@suid/icons-material": "^0.9.0",
"@suid/material": "^0.19.0",
"@suid/system": "^0.14.0",
"debug": "^4.4.3",
"github-markdown-css": "^5.8.1",
"lucide-solid": "^0.300.0",
"marked": "^12.0.0",
"qrcode": "^1.5.3",
"shiki": "^3.13.0",
"solid-js": "^1.8.0",
"solid-toast": "^0.5.0"
},
"devDependencies": {
"autoprefixer": "10.4.21",
"postcss": "8.5.6",
"tailwindcss": "3",
"typescript": "^5.3.0",
"vite": "^5.0.0",
"vite-plugin-solid": "^2.10.0"
}
}

View File

@@ -0,0 +1,11 @@
import { fileURLToPath } from "url"
import { dirname, resolve } from "path"
const __dirname = dirname(fileURLToPath(import.meta.url))
export default {
plugins: {
tailwindcss: { config: resolve(__dirname, "tailwind.config.js") },
autoprefixer: {},
},
}

466
packages/ui/src/App.tsx Normal file
View File

@@ -0,0 +1,466 @@
import { Component, For, Show, createMemo, createEffect, createSignal, onMount, onCleanup } from "solid-js"
import { Dialog } from "@kobalte/core/dialog"
import { Toaster } from "solid-toast"
import AlertDialog from "./components/alert-dialog"
import FolderSelectionView from "./components/folder-selection-view"
import { showConfirmDialog } from "./stores/alerts"
import InstanceTabs from "./components/instance-tabs"
import InstanceDisconnectedModal from "./components/instance-disconnected-modal"
import InstanceShell from "./components/instance/instance-shell2"
import { RemoteAccessOverlay } from "./components/remote-access-overlay"
import { InstanceMetadataProvider } from "./lib/contexts/instance-metadata-context"
import { initMarkdown } from "./lib/markdown"
import { useTheme } from "./lib/theme"
import { useCommands } from "./lib/hooks/use-commands"
import { useAppLifecycle } from "./lib/hooks/use-app-lifecycle"
import { getLogger } from "./lib/logger"
import { initReleaseNotifications } from "./stores/releases"
import { runtimeEnv } from "./lib/runtime-env"
import {
hasInstances,
isSelectingFolder,
setIsSelectingFolder,
showFolderSelection,
setShowFolderSelection,
} from "./stores/ui"
import { useConfig } from "./stores/preferences"
import {
createInstance,
instances,
activeInstanceId,
setActiveInstanceId,
stopInstance,
getActiveInstance,
disconnectedInstance,
acknowledgeDisconnectedInstance,
} from "./stores/instances"
import {
getSessions,
activeSessionId,
setActiveParentSession,
clearActiveParentSession,
createSession,
fetchSessions,
updateSessionAgent,
updateSessionModel,
} from "./stores/sessions"
const log = getLogger("actions")
const App: Component = () => {
const { isDark } = useTheme()
const {
preferences,
recordWorkspaceLaunch,
toggleShowThinkingBlocks,
toggleShowTimelineTools,
toggleAutoCleanupBlankSessions,
toggleUsageMetrics,
setDiffViewMode,
setToolOutputExpansion,
setDiagnosticsExpansion,
setThinkingBlocksExpansion,
} = useConfig()
const [escapeInDebounce, setEscapeInDebounce] = createSignal(false)
interface LaunchErrorState {
message: string
binaryPath: string
missingBinary: boolean
}
const [launchError, setLaunchError] = createSignal<LaunchErrorState | null>(null)
const [isAdvancedSettingsOpen, setIsAdvancedSettingsOpen] = createSignal(false)
const [remoteAccessOpen, setRemoteAccessOpen] = createSignal(false)
const [instanceTabBarHeight, setInstanceTabBarHeight] = createSignal(0)
const updateInstanceTabBarHeight = () => {
if (typeof document === "undefined") return
const element = document.querySelector<HTMLElement>(".tab-bar-instance")
setInstanceTabBarHeight(element?.offsetHeight ?? 0)
}
createEffect(() => {
void initMarkdown(isDark()).catch((error) => log.error("Failed to initialize markdown", error))
})
createEffect(() => {
initReleaseNotifications()
})
createEffect(() => {
instances()
hasInstances()
requestAnimationFrame(() => updateInstanceTabBarHeight())
})
onMount(() => {
updateInstanceTabBarHeight()
const handleResize = () => updateInstanceTabBarHeight()
window.addEventListener("resize", handleResize)
onCleanup(() => window.removeEventListener("resize", handleResize))
})
const activeInstance = createMemo(() => getActiveInstance())
const activeSessionIdForInstance = createMemo(() => {
const instance = activeInstance()
if (!instance) return null
return activeSessionId().get(instance.id) || null
})
const launchErrorPath = () => {
const value = launchError()?.binaryPath
if (!value) return "opencode"
return value.trim() || "opencode"
}
const launchErrorMessage = () => launchError()?.message ?? ""
const formatLaunchErrorMessage = (error: unknown): string => {
if (!error) {
return "Failed to launch workspace"
}
const raw = typeof error === "string" ? error : error instanceof Error ? error.message : String(error)
try {
const parsed = JSON.parse(raw)
if (parsed && typeof parsed.error === "string") {
return parsed.error
}
} catch {
// ignore JSON parse errors
}
return raw
}
const isMissingBinaryMessage = (message: string): boolean => {
const normalized = message.toLowerCase()
return (
normalized.includes("opencode binary not found") ||
normalized.includes("binary not found") ||
normalized.includes("no such file or directory") ||
normalized.includes("binary is not executable") ||
normalized.includes("enoent")
)
}
const clearLaunchError = () => setLaunchError(null)
async function handleSelectFolder(folderPath: string, binaryPath?: string) {
if (!folderPath) {
return
}
setIsSelectingFolder(true)
const selectedBinary = binaryPath || preferences().lastUsedBinary || "opencode"
try {
recordWorkspaceLaunch(folderPath, selectedBinary)
clearLaunchError()
const instanceId = await createInstance(folderPath, selectedBinary)
setShowFolderSelection(false)
setIsAdvancedSettingsOpen(false)
log.info("Created instance", {
instanceId,
port: instances().get(instanceId)?.port,
})
} catch (error) {
const message = formatLaunchErrorMessage(error)
const missingBinary = isMissingBinaryMessage(message)
setLaunchError({
message,
binaryPath: selectedBinary,
missingBinary,
})
log.error("Failed to create instance", error)
} finally {
setIsSelectingFolder(false)
}
}
function handleLaunchErrorClose() {
clearLaunchError()
}
function handleLaunchErrorAdvanced() {
clearLaunchError()
setIsAdvancedSettingsOpen(true)
}
function handleNewInstanceRequest() {
if (hasInstances()) {
setShowFolderSelection(true)
}
}
async function handleDisconnectedInstanceClose() {
try {
await acknowledgeDisconnectedInstance()
} catch (error) {
log.error("Failed to finalize disconnected instance", error)
}
}
async function handleCloseInstance(instanceId: string) {
const confirmed = await showConfirmDialog(
"Stop OpenCode instance? This will stop the server.",
{
title: "Stop instance",
variant: "warning",
confirmLabel: "Stop",
cancelLabel: "Keep running",
},
)
if (!confirmed) return
await stopInstance(instanceId)
}
async function handleNewSession(instanceId: string) {
try {
const session = await createSession(instanceId)
setActiveParentSession(instanceId, session.id)
} catch (error) {
log.error("Failed to create session", error)
}
}
async function handleCloseSession(instanceId: string, sessionId: string) {
const sessions = getSessions(instanceId)
const session = sessions.find((s) => s.id === sessionId)
if (!session) {
return
}
const parentSessionId = session.parentId ?? session.id
const parentSession = sessions.find((s) => s.id === parentSessionId)
if (!parentSession || parentSession.parentId !== null) {
return
}
clearActiveParentSession(instanceId)
try {
await fetchSessions(instanceId)
} catch (error) {
log.error("Failed to refresh sessions after closing", error)
}
}
const handleSidebarAgentChange = async (instanceId: string, sessionId: string, agent: string) => {
if (!instanceId || !sessionId || sessionId === "info") return
await updateSessionAgent(instanceId, sessionId, agent)
}
const handleSidebarModelChange = async (
instanceId: string,
sessionId: string,
model: { providerId: string; modelId: string },
) => {
if (!instanceId || !sessionId || sessionId === "info") return
await updateSessionModel(instanceId, sessionId, model)
}
const { commands: paletteCommands, executeCommand } = useCommands({
preferences,
toggleAutoCleanupBlankSessions,
toggleShowThinkingBlocks,
toggleShowTimelineTools,
toggleUsageMetrics,
setDiffViewMode,
setToolOutputExpansion,
setDiagnosticsExpansion,
setThinkingBlocksExpansion,
handleNewInstanceRequest,
handleCloseInstance,
handleNewSession,
handleCloseSession,
getActiveInstance: activeInstance,
getActiveSessionIdForInstance: activeSessionIdForInstance,
})
useAppLifecycle({
setEscapeInDebounce,
handleNewInstanceRequest,
handleCloseInstance,
handleNewSession,
handleCloseSession,
showFolderSelection,
setShowFolderSelection,
getActiveInstance: activeInstance,
getActiveSessionIdForInstance: activeSessionIdForInstance,
})
// Listen for Tauri menu events
onMount(() => {
if (runtimeEnv.host === "tauri") {
const tauriBridge = (window as { __TAURI__?: { event?: { listen: (event: string, handler: (event: { payload: unknown }) => void) => Promise<() => void> } } }).__TAURI__
if (tauriBridge?.event) {
let unlistenMenu: (() => void) | null = null
tauriBridge.event.listen("menu:newInstance", () => {
handleNewInstanceRequest()
}).then((unlisten) => {
unlistenMenu = unlisten
}).catch((error) => {
log.error("Failed to listen for menu:newInstance event", error)
})
onCleanup(() => {
unlistenMenu?.()
})
}
}
})
return (
<>
<InstanceDisconnectedModal
open={Boolean(disconnectedInstance())}
folder={disconnectedInstance()?.folder}
reason={disconnectedInstance()?.reason}
onClose={handleDisconnectedInstanceClose}
/>
<Dialog open={Boolean(launchError())} modal>
<Dialog.Portal>
<Dialog.Overlay class="modal-overlay" />
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<Dialog.Content class="modal-surface w-full max-w-md p-6 flex flex-col gap-6">
<div>
<Dialog.Title class="text-xl font-semibold text-primary">Unable to launch OpenCode</Dialog.Title>
<Dialog.Description class="text-sm text-secondary mt-2 break-words">
We couldn't start the selected OpenCode binary. Review the error output below or choose a different
binary from Advanced Settings.
</Dialog.Description>
</div>
<div class="rounded-lg border border-base bg-surface-secondary p-4">
<p class="text-xs font-medium text-muted uppercase tracking-wide mb-1">Binary path</p>
<p class="text-sm font-mono text-primary break-all">{launchErrorPath()}</p>
</div>
<Show when={launchErrorMessage()}>
<div class="rounded-lg border border-base bg-surface-secondary p-4">
<p class="text-xs font-medium text-muted uppercase tracking-wide mb-1">Error output</p>
<pre class="text-sm font-mono text-primary whitespace-pre-wrap break-words max-h-48 overflow-y-auto">{launchErrorMessage()}</pre>
</div>
</Show>
<div class="flex justify-end gap-2">
<Show when={launchError()?.missingBinary}>
<button
type="button"
class="selector-button selector-button-secondary"
onClick={handleLaunchErrorAdvanced}
>
Open Advanced Settings
</button>
</Show>
<button type="button" class="selector-button selector-button-primary" onClick={handleLaunchErrorClose}>
Close
</button>
</div>
</Dialog.Content>
</div>
</Dialog.Portal>
</Dialog>
<div class="h-screen w-screen flex flex-col">
<Show
when={!hasInstances()}
fallback={
<>
<InstanceTabs
instances={instances()}
activeInstanceId={activeInstanceId()}
onSelect={setActiveInstanceId}
onClose={handleCloseInstance}
onNew={handleNewInstanceRequest}
onOpenRemoteAccess={() => setRemoteAccessOpen(true)}
/>
<For each={Array.from(instances().values())}>
{(instance) => {
const isActiveInstance = () => activeInstanceId() === instance.id
const isVisible = () => isActiveInstance() && !showFolderSelection()
return (
<div class="flex-1 min-h-0 overflow-hidden" style={{ display: isVisible() ? "flex" : "none" }}>
<InstanceMetadataProvider instance={instance}>
<InstanceShell
instance={instance}
escapeInDebounce={escapeInDebounce()}
paletteCommands={paletteCommands}
onCloseSession={(sessionId) => handleCloseSession(instance.id, sessionId)}
onNewSession={() => handleNewSession(instance.id)}
handleSidebarAgentChange={(sessionId, agent) => handleSidebarAgentChange(instance.id, sessionId, agent)}
handleSidebarModelChange={(sessionId, model) => handleSidebarModelChange(instance.id, sessionId, model)}
onExecuteCommand={executeCommand}
tabBarOffset={instanceTabBarHeight()}
/>
</InstanceMetadataProvider>
</div>
)
}}
</For>
</>
}
>
<FolderSelectionView
onSelectFolder={handleSelectFolder}
isLoading={isSelectingFolder()}
advancedSettingsOpen={isAdvancedSettingsOpen()}
onAdvancedSettingsOpen={() => setIsAdvancedSettingsOpen(true)}
onAdvancedSettingsClose={() => setIsAdvancedSettingsOpen(false)}
onOpenRemoteAccess={() => setRemoteAccessOpen(true)}
/>
</Show>
<Show when={showFolderSelection()}>
<div class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center">
<div class="w-full h-full relative">
<button
onClick={() => {
setShowFolderSelection(false)
setIsAdvancedSettingsOpen(false)
clearLaunchError()
}}
class="absolute top-4 right-4 z-10 p-2 bg-white dark:bg-gray-800 rounded-lg shadow-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
title="Close (Esc)"
>
<svg class="w-5 h-5 text-gray-600 dark:text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<FolderSelectionView
onSelectFolder={handleSelectFolder}
isLoading={isSelectingFolder()}
advancedSettingsOpen={isAdvancedSettingsOpen()}
onAdvancedSettingsOpen={() => setIsAdvancedSettingsOpen(true)}
onAdvancedSettingsClose={() => setIsAdvancedSettingsOpen(false)}
/>
</div>
</div>
</Show>
<RemoteAccessOverlay open={remoteAccessOpen()} onClose={() => setRemoteAccessOpen(false)} />
<AlertDialog />
<Toaster
position="top-right"
gutter={16}
toastOptions={{
duration: 8000,
className: "bg-transparent border-none shadow-none p-0",
}}
/>
</div>
</>
)
}
export default App

View File

@@ -0,0 +1,60 @@
import { Component } from "solid-js"
import { Dialog } from "@kobalte/core/dialog"
import OpenCodeBinarySelector from "./opencode-binary-selector"
import EnvironmentVariablesEditor from "./environment-variables-editor"
interface AdvancedSettingsModalProps {
open: boolean
onClose: () => void
selectedBinary: string
onBinaryChange: (binary: string) => void
isLoading?: boolean
}
const AdvancedSettingsModal: Component<AdvancedSettingsModalProps> = (props) => {
return (
<Dialog open={props.open} onOpenChange={(open) => !open && props.onClose()}>
<Dialog.Portal>
<Dialog.Overlay class="modal-overlay" />
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<Dialog.Content class="modal-surface w-full max-w-5xl max-h-[90vh] flex flex-col overflow-hidden">
<header class="px-6 py-4 border-b" style={{ "border-color": "var(--border-base)" }}>
<Dialog.Title class="text-xl font-semibold text-primary">Advanced Settings</Dialog.Title>
</header>
<div class="flex-1 overflow-y-auto p-6 space-y-6">
<OpenCodeBinarySelector
selectedBinary={props.selectedBinary}
onBinaryChange={props.onBinaryChange}
disabled={Boolean(props.isLoading)}
isVisible={props.open}
/>
<div class="panel">
<div class="panel-header">
<h3 class="panel-title">Environment Variables</h3>
<p class="panel-subtitle">Applied whenever a new OpenCode instance starts</p>
</div>
<div class="panel-body">
<EnvironmentVariablesEditor disabled={Boolean(props.isLoading)} />
</div>
</div>
</div>
<div class="px-6 py-4 border-t flex justify-end" style={{ "border-color": "var(--border-base)" }}>
<button
type="button"
class="selector-button selector-button-secondary"
onClick={props.onClose}
>
Close
</button>
</div>
</Dialog.Content>
</div>
</Dialog.Portal>
</Dialog>
)
}
export default AdvancedSettingsModal

View File

@@ -0,0 +1,124 @@
import { Select } from "@kobalte/core/select"
import { For, Show, createEffect, createMemo } from "solid-js"
import { agents, fetchAgents, sessions } from "../stores/sessions"
import { ChevronDown } from "lucide-solid"
import type { Agent } from "../types/session"
import { getLogger } from "../lib/logger"
const log = getLogger("session")
interface AgentSelectorProps {
instanceId: string
sessionId: string
currentAgent: string
onAgentChange: (agent: string) => Promise<void>
}
export default function AgentSelector(props: AgentSelectorProps) {
const instanceAgents = () => agents().get(props.instanceId) || []
const session = createMemo(() => {
const instanceSessions = sessions().get(props.instanceId)
return instanceSessions?.get(props.sessionId)
})
const isChildSession = createMemo(() => {
return session()?.parentId !== null && session()?.parentId !== undefined
})
const availableAgents = createMemo(() => {
const allAgents = instanceAgents()
if (isChildSession()) {
return allAgents
}
const filtered = allAgents.filter((agent) => agent.mode !== "subagent")
const currentAgent = allAgents.find((a) => a.name === props.currentAgent)
if (currentAgent && !filtered.find((a) => a.name === props.currentAgent)) {
return [currentAgent, ...filtered]
}
return filtered
})
createEffect(() => {
const list = availableAgents()
if (list.length === 0) return
if (!list.some((agent) => agent.name === props.currentAgent)) {
void props.onAgentChange(list[0].name)
}
})
createEffect(() => {
if (instanceAgents().length === 0) {
fetchAgents(props.instanceId).catch((error) => log.error("Failed to fetch agents", error))
}
})
const handleChange = async (value: Agent | null) => {
if (value && value.name !== props.currentAgent) {
await props.onAgentChange(value.name)
}
}
return (
<div class="sidebar-selector">
<Select
value={availableAgents().find((a) => a.name === props.currentAgent)}
onChange={handleChange}
options={availableAgents()}
optionValue="name"
optionTextValue="name"
placeholder="Select agent..."
itemComponent={(itemProps) => (
<Select.Item
item={itemProps.item}
class="selector-option"
>
<div class="flex flex-col flex-1 min-w-0">
<Select.ItemLabel class="selector-option-label flex items-center gap-2">
<span>{itemProps.item.rawValue.name}</span>
<Show when={itemProps.item.rawValue.mode === "subagent"}>
<span class="neutral-badge">subagent</span>
</Show>
</Select.ItemLabel>
<Show when={itemProps.item.rawValue.description}>
<Select.ItemDescription class="selector-option-description">
{itemProps.item.rawValue.description.length > 50
? itemProps.item.rawValue.description.slice(0, 50) + "..."
: itemProps.item.rawValue.description}
</Select.ItemDescription>
</Show>
</div>
</Select.Item>
)}
>
<Select.Trigger
data-agent-selector
class="selector-trigger"
>
<Select.Value<Agent>>
{(state) => (
<div class="selector-trigger-label">
<span class="selector-trigger-primary">
Agent: {state.selectedOption()?.name ?? "None"}
</span>
</div>
)}
</Select.Value>
<Select.Icon class="selector-trigger-icon">
<ChevronDown class="w-3 h-3" />
</Select.Icon>
</Select.Trigger>
<Select.Portal>
<Select.Content class="selector-popover max-h-80 overflow-auto p-1">
<Select.Listbox class="selector-listbox" />
</Select.Content>
</Select.Portal>
</Select>
</div>
)
}

View File

@@ -0,0 +1,132 @@
import { Dialog } from "@kobalte/core/dialog"
import { Component, Show, createEffect } from "solid-js"
import { alertDialogState, dismissAlertDialog } from "../stores/alerts"
import type { AlertVariant, AlertDialogState } from "../stores/alerts"
const variantAccent: Record<AlertVariant, { badgeBg: string; badgeBorder: string; badgeText: string; symbol: string; fallbackTitle: string }> = {
info: {
badgeBg: "var(--badge-neutral-bg)",
badgeBorder: "var(--border-base)",
badgeText: "var(--accent-primary)",
symbol: "i",
fallbackTitle: "Heads up",
},
warning: {
badgeBg: "rgba(255, 152, 0, 0.14)",
badgeBorder: "var(--status-warning)",
badgeText: "var(--status-warning)",
symbol: "!",
fallbackTitle: "Please review",
},
error: {
badgeBg: "var(--danger-soft-bg)",
badgeBorder: "var(--status-error)",
badgeText: "var(--status-error)",
symbol: "!",
fallbackTitle: "Something went wrong",
},
}
function dismiss(confirmed: boolean, payload?: AlertDialogState | null) {
const current = payload ?? alertDialogState()
if (current?.type === "confirm") {
if (confirmed) {
current.onConfirm?.()
} else {
current.onCancel?.()
}
current.resolve?.(confirmed)
} else if (confirmed) {
current?.onConfirm?.()
}
dismissAlertDialog()
}
const AlertDialog: Component = () => {
let primaryButtonRef: HTMLButtonElement | undefined
createEffect(() => {
if (alertDialogState()) {
queueMicrotask(() => {
primaryButtonRef?.focus()
})
}
})
return (
<Show when={alertDialogState()} keyed>
{(payload) => {
const variant = payload.variant ?? "info"
const accent = variantAccent[variant]
const title = payload.title || accent.fallbackTitle
const isConfirm = payload.type === "confirm"
const confirmLabel = payload.confirmLabel || (isConfirm ? "Confirm" : "OK")
const cancelLabel = payload.cancelLabel || "Cancel"
return (
<Dialog
open
modal
onOpenChange={(open) => {
if (!open) {
dismiss(false, payload)
}
}}
>
<Dialog.Portal>
<Dialog.Overlay class="modal-overlay" />
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<Dialog.Content class="modal-surface w-full max-w-sm p-6 border border-base shadow-2xl" tabIndex={-1}>
<div class="flex items-start gap-3">
<div
class="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl border text-base font-semibold"
style={{
"background-color": accent.badgeBg,
"border-color": accent.badgeBorder,
color: accent.badgeText,
}}
aria-hidden
>
{accent.symbol}
</div>
<div class="flex-1 min-w-0">
<Dialog.Title class="text-lg font-semibold text-primary">{title}</Dialog.Title>
<Dialog.Description class="text-sm text-secondary mt-1 whitespace-pre-line break-words">
{payload.message}
{payload.detail && <p class="mt-2 text-secondary">{payload.detail}</p>}
</Dialog.Description>
</div>
</div>
<div class="mt-6 flex justify-end gap-3">
{isConfirm && (
<button
type="button"
class="button-secondary"
onClick={() => dismiss(false, payload)}
>
{cancelLabel}
</button>
)}
<button
type="button"
class="button-primary"
ref={(el) => {
primaryButtonRef = el
}}
onClick={() => dismiss(true, payload)}
>
{confirmLabel}
</button>
</div>
</Dialog.Content>
</div>
</Dialog.Portal>
</Dialog>
)
}}
</Show>
)
}
export default AlertDialog

View File

@@ -0,0 +1,27 @@
import { Component } from "solid-js"
import type { Attachment } from "../types/attachment"
interface AttachmentChipProps {
attachment: Attachment
onRemove: () => void
}
const AttachmentChip: Component<AttachmentChipProps> = (props) => {
return (
<div
class="attachment-chip"
title={props.attachment.source.type === "file" ? props.attachment.source.path : undefined}
>
<span class="font-mono">{props.attachment.display}</span>
<button
onClick={props.onRemove}
class="attachment-remove"
aria-label="Remove attachment"
>
×
</button>
</div>
)
}
export default AttachmentChip

View File

@@ -0,0 +1,107 @@
import { createSignal, onMount, Show, createEffect } from "solid-js"
import type { Highlighter } from "shiki/bundle/full"
import { useTheme } from "../lib/theme"
import { getSharedHighlighter, escapeHtml } from "../lib/markdown"
const inlineLoadedLanguages = new Set<string>()
type LoadLanguageArg = Parameters<Highlighter["loadLanguage"]>[0]
type CodeToHtmlOptions = Parameters<Highlighter["codeToHtml"]>[1]
interface CodeBlockInlineProps {
code: string
language?: string
}
export function CodeBlockInline(props: CodeBlockInlineProps) {
const { isDark } = useTheme()
const [html, setHtml] = createSignal("")
const [copied, setCopied] = createSignal(false)
const [ready, setReady] = createSignal(false)
let highlighter: Highlighter | null = null
onMount(async () => {
highlighter = await getSharedHighlighter()
setReady(true)
await updateHighlight()
})
createEffect(() => {
if (ready()) {
isDark()
props.code
props.language
void updateHighlight()
}
})
const updateHighlight = async () => {
if (!highlighter) return
if (!props.language) {
setHtml(`<pre><code>${escapeHtml(props.code)}</code></pre>`)
return
}
try {
const language = props.language as LoadLanguageArg
if (!inlineLoadedLanguages.has(props.language)) {
await highlighter.loadLanguage(language)
inlineLoadedLanguages.add(props.language)
}
const highlighted = highlighter.codeToHtml(props.code, {
lang: props.language as CodeToHtmlOptions["lang"],
theme: isDark() ? "github-dark" : "github-light",
})
setHtml(highlighted)
} catch {
setHtml(`<pre><code>${escapeHtml(props.code)}</code></pre>`)
}
}
const copyCode = async () => {
await navigator.clipboard.writeText(props.code)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
return (
<Show
when={ready()}
fallback={
<pre class="tool-call-content">
<code>{props.code}</code>
</pre>
}
>
<div class="code-block-inline">
<div class="code-block-header">
<Show when={props.language}>
<span class="code-block-language">{props.language}</span>
</Show>
<button onClick={copyCode} class="code-block-copy">
<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">
<Show when={copied()} fallback="Copy">
Copied!
</Show>
</span>
</button>
</div>
<div innerHTML={html()} />
</div>
</Show>
)
}

View File

@@ -0,0 +1,287 @@
import { Component, createSignal, For, Show, createEffect, createMemo } from "solid-js"
import { Dialog } from "@kobalte/core/dialog"
import type { Command } from "../lib/commands"
import Kbd from "./kbd"
interface CommandPaletteProps {
open: boolean
onClose: () => void
commands: Command[]
onExecute: (command: Command) => void
}
function buildShortcutString(shortcut: Command["shortcut"]): string {
if (!shortcut) return ""
const parts: string[] = []
if (shortcut.meta || shortcut.ctrl) parts.push("cmd")
if (shortcut.shift) parts.push("shift")
if (shortcut.alt) parts.push("alt")
parts.push(shortcut.key)
return parts.join("+")
}
const CommandPalette: Component<CommandPaletteProps> = (props) => {
const [query, setQuery] = createSignal("")
const [selectedCommandId, setSelectedCommandId] = createSignal<string | null>(null)
const [isPointerSelecting, setIsPointerSelecting] = createSignal(false)
let inputRef: HTMLInputElement | undefined
let listRef: HTMLDivElement | undefined
const categoryOrder = ["Custom Commands", "Instance", "Session", "Agent & Model", "Input & Focus", "System", "Other"] as const
type CommandGroup = { category: string; commands: Command[]; startIndex: number }
type ProcessedCommands = { groups: CommandGroup[]; ordered: Command[] }
const processedCommands = createMemo<ProcessedCommands>(() => {
const source = props.commands ?? []
const q = query().trim().toLowerCase()
const filtered = q
? source.filter((cmd) => {
const label = typeof cmd.label === "function" ? cmd.label() : cmd.label
const labelMatch = label.toLowerCase().includes(q)
const descMatch = cmd.description.toLowerCase().includes(q)
const keywordMatch = cmd.keywords?.some((k) => k.toLowerCase().includes(q))
const categoryMatch = cmd.category?.toLowerCase().includes(q)
return labelMatch || descMatch || keywordMatch || categoryMatch
})
: source
const groupsMap = new Map<string, Command[]>()
for (const cmd of filtered) {
const category = cmd.category || "Other"
const list = groupsMap.get(category)
if (list) {
list.push(cmd)
} else {
groupsMap.set(category, [cmd])
}
}
const groups: CommandGroup[] = []
const ordered: Command[] = []
const processedCategories = new Set<string>()
const addGroup = (category: string) => {
const cmds = groupsMap.get(category)
if (!cmds || cmds.length === 0 || processedCategories.has(category)) return
groups.push({ category, commands: cmds, startIndex: ordered.length })
ordered.push(...cmds)
processedCategories.add(category)
}
for (const category of categoryOrder) {
addGroup(category)
}
for (const [category] of groupsMap) {
addGroup(category)
}
return { groups, ordered }
})
const groupedCommandList = () => processedCommands().groups
const orderedCommands = () => processedCommands().ordered
const selectedIndex = createMemo(() => {
const ordered = orderedCommands()
if (ordered.length === 0) return -1
const id = selectedCommandId()
if (!id) return 0
const index = ordered.findIndex((cmd) => cmd.id === id)
return index >= 0 ? index : 0
})
createEffect(() => {
if (props.open) {
setQuery("")
setSelectedCommandId(null)
setIsPointerSelecting(false)
setTimeout(() => inputRef?.focus(), 100)
}
})
createEffect(() => {
const ordered = orderedCommands()
if (ordered.length === 0) {
if (selectedCommandId() !== null) {
setSelectedCommandId(null)
}
return
}
const currentId = selectedCommandId()
if (!currentId || !ordered.some((cmd) => cmd.id === currentId)) {
setSelectedCommandId(ordered[0].id)
}
})
createEffect(() => {
const index = selectedIndex()
if (!listRef || index < 0) return
const selectedButton = listRef.querySelector(`[data-command-index="${index}"]`) as HTMLElement
if (selectedButton) {
selectedButton.scrollIntoView({ block: "nearest", behavior: "smooth" })
}
})
function handleKeyDown(e: KeyboardEvent) {
const ordered = orderedCommands()
if (e.key === "Escape") {
e.preventDefault()
e.stopPropagation()
props.onClose()
return
}
if (ordered.length === 0) {
if (e.key === "ArrowDown" || e.key === "ArrowUp" || e.key === "Enter") {
e.preventDefault()
e.stopPropagation()
}
return
}
if (e.key === "ArrowDown") {
e.preventDefault()
e.stopPropagation()
setIsPointerSelecting(false)
const current = selectedIndex()
const nextIndex = Math.min((current < 0 ? 0 : current) + 1, ordered.length - 1)
setSelectedCommandId(ordered[nextIndex]?.id ?? null)
} else if (e.key === "ArrowUp") {
e.preventDefault()
e.stopPropagation()
setIsPointerSelecting(false)
const current = selectedIndex()
const nextIndex = current <= 0 ? ordered.length - 1 : current - 1
setSelectedCommandId(ordered[nextIndex]?.id ?? null)
} else if (e.key === "Enter") {
e.preventDefault()
e.stopPropagation()
const index = selectedIndex()
if (index < 0 || index >= ordered.length) return
const command = ordered[index]
if (!command) return
props.onExecute(command)
props.onClose()
}
}
function handleCommandClick(command: Command) {
props.onExecute(command)
props.onClose()
}
function handlePointerLeave() {
setIsPointerSelecting(false)
}
return (
<Dialog open={props.open} onOpenChange={(open) => !open && props.onClose()}>
<Dialog.Portal>
<Dialog.Overlay class="modal-overlay" />
<div class="fixed inset-0 z-50 flex items-start justify-center pt-[20vh]">
<Dialog.Content
class="modal-surface w-full max-w-2xl max-h-[60vh]"
onKeyDown={handleKeyDown}
>
<Dialog.Title class="sr-only">Command Palette</Dialog.Title>
<Dialog.Description class="sr-only">Search and execute commands</Dialog.Description>
<div class="modal-search-container">
<div class="flex items-center gap-3">
<svg class="w-5 h-5 modal-search-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
<input
ref={inputRef}
type="text"
value={query()}
onInput={(e) => {
setQuery(e.currentTarget.value)
setSelectedCommandId(null)
}}
placeholder="Type a command or search..."
class="modal-search-input"
/>
</div>
</div>
<div
ref={listRef}
class="modal-list-container"
data-pointer-mode={isPointerSelecting() ? "pointer" : "keyboard"}
onPointerLeave={handlePointerLeave}
>
<Show
when={orderedCommands().length > 0}
fallback={<div class="modal-empty-state">No commands found for "{query()}"</div>}
>
<For each={groupedCommandList()}>
{(group) => (
<div class="py-2">
<div class="modal-section-header">
{group.category}
</div>
<For each={group.commands}>
{(command, localIndex) => {
const commandIndex = group.startIndex + localIndex()
return (
<button
type="button"
data-command-index={commandIndex}
onClick={() => handleCommandClick(command)}
class={`modal-item ${selectedCommandId() === command.id ? "modal-item-highlight" : ""}`}
onPointerMove={(event) => {
if (event.movementX === 0 && event.movementY === 0) return
if (event.pointerType === "mouse" || event.pointerType === "pen" || event.pointerType === "touch") {
if (!isPointerSelecting()) {
setIsPointerSelecting(true)
}
setSelectedCommandId(command.id)
}
}}
>
<div class="flex-1 min-w-0">
<div class="modal-item-label">
{typeof command.label === "function" ? command.label() : command.label}
</div>
<div class="modal-item-description">
{command.description}
</div>
</div>
<Show when={command.shortcut}>
<div class="mt-1">
<Kbd shortcut={buildShortcutString(command.shortcut)} />
</div>
</Show>
</button>
)
}}
</For>
</div>
)}
</For>
</Show>
</div>
</Dialog.Content>
</div>
</Dialog.Portal>
</Dialog>
)
}
export default CommandPalette

View File

@@ -0,0 +1,137 @@
import { createMemo, Show, createEffect, onCleanup } from "solid-js"
import { DiffView, DiffModeEnum } from "@git-diff-view/solid"
import { disableCache } from "@git-diff-view/core"
import type { DiffHighlighterLang } from "@git-diff-view/core"
import { ErrorBoundary } from "solid-js"
import { getLanguageFromPath } from "../lib/markdown"
import { normalizeDiffText } from "../lib/diff-utils"
import { setCacheEntry } from "../lib/global-cache"
import type { CacheEntryParams } from "../lib/global-cache"
import type { DiffViewMode } from "../stores/preferences"
import { getLogger } from "../lib/logger"
const log = getLogger("session")
disableCache()
interface ToolCallDiffViewerProps {
diffText: string
filePath?: string
theme: "light" | "dark"
mode: DiffViewMode
onRendered?: () => void
cachedHtml?: string
cacheEntryParams?: CacheEntryParams
}
type DiffData = {
oldFile?: { fileName?: string | null; fileLang?: string | null; content?: string | null }
newFile?: { fileName?: string | null; fileLang?: string | null; content?: string | null }
hunks: string[]
}
type CaptureContext = {
theme: ToolCallDiffViewerProps["theme"]
mode: DiffViewMode
diffText: string
cacheEntryParams?: CacheEntryParams
}
export function ToolCallDiffViewer(props: ToolCallDiffViewerProps) {
const diffData = createMemo<DiffData | null>(() => {
const normalized = normalizeDiffText(props.diffText)
if (!normalized) {
return null
}
const language = getLanguageFromPath(props.filePath) || "text"
const fileName = props.filePath || "diff"
return {
oldFile: {
fileName,
fileLang: (language || "text") as DiffHighlighterLang | null,
},
newFile: {
fileName,
fileLang: (language || "text") as DiffHighlighterLang | null,
},
hunks: [normalized],
}
})
let diffContainerRef: HTMLDivElement | undefined
let lastCapturedKey: string | undefined
const contextKey = createMemo(() => {
const data = diffData()
if (!data) return ""
return `${props.theme}|${props.mode}|${props.diffText}`
})
createEffect(() => {
const cachedHtml = props.cachedHtml
if (cachedHtml) {
// When we are given cached HTML, we rely on the caller's cache
// and simply notify once rendered.
props.onRendered?.()
return
}
const key = contextKey()
if (!key) return
if (!diffContainerRef) return
if (lastCapturedKey === key) return
requestAnimationFrame(() => {
if (!diffContainerRef) return
const markup = diffContainerRef.innerHTML
if (!markup) return
lastCapturedKey = key
if (props.cacheEntryParams) {
setCacheEntry(props.cacheEntryParams, {
text: props.diffText,
html: markup,
theme: props.theme,
mode: props.mode,
})
}
props.onRendered?.()
})
})
return (
<div class="tool-call-diff-viewer">
<Show
when={props.cachedHtml}
fallback={
<div ref={diffContainerRef}>
<Show
when={diffData()}
fallback={<pre class="tool-call-diff-fallback">{props.diffText}</pre>}
>
{(data) => (
<ErrorBoundary fallback={(error) => {
log.warn("Failed to render diff view", error)
return <pre class="tool-call-diff-fallback">{props.diffText}</pre>
}}>
<DiffView
data={data()}
diffViewMode={props.mode === "split" ? DiffModeEnum.Split : DiffModeEnum.Unified}
diffViewTheme={props.theme}
diffViewHighlight
diffViewWrap={false}
diffViewFontSize={13}
/>
</ErrorBoundary>
)}
</Show>
</div>
}
>
<div innerHTML={props.cachedHtml} />
</Show>
</div>
)
}

View File

@@ -0,0 +1,375 @@
import { Component, Show, For, createSignal, createMemo, createEffect, onCleanup } from "solid-js"
import { ArrowUpLeft, Folder as FolderIcon, Loader2, X } from "lucide-solid"
import type { FileSystemEntry, FileSystemListingMetadata } from "../../../server/src/api-types"
import { WINDOWS_DRIVES_ROOT } from "../../../server/src/api-types"
import { serverApi } from "../lib/api-client"
function normalizePathKey(input?: string | null) {
if (!input || input === "." || input === "./") {
return "."
}
if (input === WINDOWS_DRIVES_ROOT) {
return WINDOWS_DRIVES_ROOT
}
let normalized = input.replace(/\\/g, "/")
if (/^[a-zA-Z]:/.test(normalized)) {
const [drive, rest = ""] = normalized.split(":")
const suffix = rest.startsWith("/") ? rest : rest ? `/${rest}` : "/"
return `${drive.toUpperCase()}:${suffix.replace(/\/+/g, "/")}`
}
if (normalized.startsWith("//")) {
return `//${normalized.slice(2).replace(/\/+/g, "/")}`
}
if (normalized.startsWith("/")) {
return `/${normalized.slice(1).replace(/\/+/g, "/")}`
}
normalized = normalized.replace(/^\.\/+/, "").replace(/\/+/g, "/")
return normalized === "" ? "." : normalized
}
function isAbsolutePathLike(input: string) {
return input.startsWith("/") || /^[a-zA-Z]:/.test(input) || input.startsWith("\\\\")
}
interface DirectoryBrowserDialogProps {
open: boolean
title: string
description?: string
onSelect: (absolutePath: string) => void
onClose: () => void
}
function resolveAbsolutePath(root: string, relativePath: string) {
if (!root) {
return relativePath
}
if (!relativePath || relativePath === "." || relativePath === "./") {
return root
}
if (isAbsolutePathLike(relativePath)) {
return relativePath
}
const separator = root.includes("\\") ? "\\" : "/"
const trimmedRoot = root.endsWith(separator) ? root : `${root}${separator}`
const normalized = relativePath.replace(/[\\/]+/g, separator).replace(/^[\\/]+/, "")
return `${trimmedRoot}${normalized}`
}
type FolderRow =
| { type: "up"; path: string }
| { type: "folder"; entry: FileSystemEntry }
const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) => {
const [rootPath, setRootPath] = createSignal("")
const [loading, setLoading] = createSignal(false)
const [error, setError] = createSignal<string | null>(null)
const [directoryChildren, setDirectoryChildren] = createSignal<Map<string, FileSystemEntry[]>>(new Map())
const [loadingPaths, setLoadingPaths] = createSignal<Set<string>>(new Set())
const [currentPathKey, setCurrentPathKey] = createSignal<string | null>(null)
const [currentMetadata, setCurrentMetadata] = createSignal<FileSystemListingMetadata | null>(null)
const metadataCache = new Map<string, FileSystemListingMetadata>()
const inFlightRequests = new Map<string, Promise<FileSystemListingMetadata>>()
function resetState() {
setDirectoryChildren(new Map<string, FileSystemEntry[]>())
setLoadingPaths(new Set<string>())
setCurrentPathKey(null)
setCurrentMetadata(null)
metadataCache.clear()
inFlightRequests.clear()
setError(null)
}
createEffect(() => {
if (!props.open) {
return
}
resetState()
void initialize()
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
event.preventDefault()
props.onClose()
}
}
window.addEventListener("keydown", handleKeyDown)
onCleanup(() => {
window.removeEventListener("keydown", handleKeyDown)
})
})
async function initialize() {
setLoading(true)
try {
const metadata = await loadDirectory()
applyMetadata(metadata)
} catch (err) {
const message = err instanceof Error ? err.message : "Unable to load filesystem"
setError(message)
} finally {
setLoading(false)
}
}
function applyMetadata(metadata: FileSystemListingMetadata) {
const key = normalizePathKey(metadata.currentPath)
setCurrentPathKey(key)
setCurrentMetadata(metadata)
setRootPath(metadata.rootPath)
}
async function loadDirectory(targetPath?: string): Promise<FileSystemListingMetadata> {
const key = targetPath ? normalizePathKey(targetPath) : undefined
if (key) {
const cached = metadataCache.get(key)
if (cached) {
return cached
}
const pending = inFlightRequests.get(key)
if (pending) {
return pending
}
}
const request = (async () => {
if (key) {
setLoadingPaths((prev) => {
const next = new Set(prev)
next.add(key)
return next
})
}
const response = await serverApi.listFileSystem(targetPath, { includeFiles: false })
const canonicalKey = normalizePathKey(response.metadata.currentPath)
const directories = response.entries
.filter((entry) => entry.type === "directory")
.sort((a, b) => a.name.localeCompare(b.name))
setDirectoryChildren((prev) => {
const next = new Map(prev)
next.set(canonicalKey, directories)
return next
})
metadataCache.set(canonicalKey, response.metadata)
setLoadingPaths((prev) => {
const next = new Set(prev)
if (key) {
next.delete(key)
}
next.delete(canonicalKey)
return next
})
return response.metadata
})()
.catch((err) => {
if (key) {
setLoadingPaths((prev) => {
const next = new Set(prev)
next.delete(key)
return next
})
}
throw err
})
.finally(() => {
if (key) {
inFlightRequests.delete(key)
}
})
if (key) {
inFlightRequests.set(key, request)
}
return request
}
async function navigateTo(path?: string) {
setError(null)
try {
const metadata = await loadDirectory(path)
applyMetadata(metadata)
} catch (err) {
const message = err instanceof Error ? err.message : "Unable to load filesystem"
setError(message)
}
}
const folderRows = createMemo<FolderRow[]>(() => {
const rows: FolderRow[] = []
const metadata = currentMetadata()
if (metadata?.parentPath) {
rows.push({ type: "up", path: metadata.parentPath })
}
const key = currentPathKey()
if (!key) {
return rows
}
const children = directoryChildren().get(key) ?? []
for (const entry of children) {
rows.push({ type: "folder", entry })
}
return rows
})
function handleNavigateTo(path: string) {
void navigateTo(path)
}
function handleNavigateUp() {
const parent = currentMetadata()?.parentPath
if (parent) {
void navigateTo(parent)
}
}
const currentAbsolutePath = createMemo(() => {
const metadata = currentMetadata()
if (!metadata) {
return ""
}
if (metadata.pathKind === "drives") {
return ""
}
if (metadata.pathKind === "relative") {
return resolveAbsolutePath(metadata.rootPath, metadata.currentPath)
}
return metadata.displayPath
})
const canSelectCurrent = createMemo(() => Boolean(currentAbsolutePath()))
function handleEntrySelect(entry: FileSystemEntry) {
const absolutePath = entry.absolutePath
? entry.absolutePath
: isAbsolutePathLike(entry.path)
? entry.path
: resolveAbsolutePath(rootPath(), entry.path)
props.onSelect(absolutePath)
}
function isPathLoading(path: string) {
return loadingPaths().has(normalizePathKey(path))
}
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 directory-browser-modal" role="dialog" aria-modal="true">
<div class="panel directory-browser-panel">
<div class="directory-browser-header">
<div class="directory-browser-heading">
<h3 class="directory-browser-title">{props.title}</h3>
<p class="directory-browser-description">
{props.description || "Browse folders under the configured workspace root."}
</p>
</div>
<button type="button" class="directory-browser-close" aria-label="Close" onClick={props.onClose}>
<X class="w-5 h-5" />
</button>
</div>
<div class="panel-body directory-browser-body">
<Show when={rootPath()}>
<div class="directory-browser-current">
<div class="directory-browser-current-meta">
<span class="directory-browser-current-label">Current folder</span>
<span class="directory-browser-current-path">{currentAbsolutePath()}</span>
</div>
<button
type="button"
class="selector-button selector-button-secondary directory-browser-select directory-browser-current-select"
disabled={!canSelectCurrent()}
onClick={() => {
const absolute = currentAbsolutePath()
if (absolute) {
props.onSelect(absolute)
}
}}
>
Select Current
</button>
</div>
</Show>
<Show
when={!loading() && !error()}
fallback={
<div class="panel-empty-state flex-1">
<Show when={loading()} fallback={<span class="text-red-500">{error()}</span>}>
<div class="directory-browser-loading">
<Loader2 class="w-5 h-5 animate-spin" />
<span>Loading folders</span>
</div>
</Show>
</div>
}
>
<Show
when={folderRows().length > 0}
fallback={<div class="panel-empty-state flex-1">No folders available.</div>}
>
<div class="panel-list panel-list--fill flex-1 min-h-0 overflow-auto directory-browser-list" role="listbox">
<For each={folderRows()}>
{(item) => {
const isFolder = item.type === "folder"
const label = isFolder ? item.entry.name || item.entry.path : "Up one level"
const navigate = () => (isFolder ? handleNavigateTo(item.entry.path) : handleNavigateUp())
return (
<div class="panel-list-item" role="option">
<div class="panel-list-item-content directory-browser-row">
<button type="button" class="directory-browser-row-main" onClick={navigate}>
<div class="directory-browser-row-icon">
<Show when={!isFolder} fallback={<FolderIcon class="w-4 h-4" />}>
<ArrowUpLeft class="w-4 h-4" />
</Show>
</div>
<div class="directory-browser-row-text">
<span class="directory-browser-row-name">{label}</span>
</div>
<Show when={isFolder && isPathLoading(item.entry.path)}>
<Loader2 class="directory-browser-row-spinner animate-spin" />
</Show>
</button>
{isFolder ? (
<button
type="button"
class="selector-button selector-button-secondary directory-browser-select"
onClick={(event) => {
event.stopPropagation()
handleEntrySelect(item.entry)
}}
>
Select
</button>
) : null}
</div>
</div>
)
}}
</For>
</div>
</Show>
</Show>
</div>
</div>
</div>
</div>
</Show>
)
}
export default DirectoryBrowserDialog

View File

@@ -0,0 +1,51 @@
import { Component } from "solid-js"
import { Loader2 } from "lucide-solid"
const codeNomadIcon = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
interface EmptyStateProps {
onSelectFolder: () => void
isLoading?: boolean
}
const EmptyState: Component<EmptyStateProps> = (props) => {
return (
<div class="flex h-full w-full items-center justify-center bg-surface-secondary">
<div class="max-w-[500px] px-8 py-12 text-center">
<div class="mb-8 flex justify-center">
<img src={codeNomadIcon} alt="CodeNomad logo" class="h-24 w-auto" loading="lazy" />
</div>
<h1 class="mb-3 text-3xl font-semibold text-primary">CodeNomad</h1>
<p class="mb-8 text-base text-secondary">Select a folder to start coding with AI</p>
<button
onClick={props.onSelectFolder}
disabled={props.isLoading}
class="mb-4 button-primary"
>
{props.isLoading ? (
<>
<Loader2 class="h-4 w-4 animate-spin" />
Selecting...
</>
) : (
"Select Folder"
)}
</button>
<p class="text-sm text-muted">
Keyboard shortcut: {navigator.platform.includes("Mac") ? "Cmd" : "Ctrl"}+N
</p>
<div class="mt-6 space-y-1 text-sm text-muted">
<p>Examples: ~/projects/my-app</p>
<p>You can have multiple instances of the same folder</p>
</div>
</div>
</div>
)
}
export default EmptyState

View File

@@ -0,0 +1,148 @@
import { Component, createSignal, For, Show } from "solid-js"
import { Plus, Trash2, Key, Globe } from "lucide-solid"
import { useConfig } from "../stores/preferences"
interface EnvironmentVariablesEditorProps {
disabled?: boolean
}
const EnvironmentVariablesEditor: Component<EnvironmentVariablesEditorProps> = (props) => {
const {
preferences,
addEnvironmentVariable,
removeEnvironmentVariable,
updateEnvironmentVariables,
} = useConfig()
const [envVars, setEnvVars] = createSignal<Record<string, string>>(preferences().environmentVariables || {})
const [newKey, setNewKey] = createSignal("")
const [newValue, setNewValue] = createSignal("")
const entries = () => Object.entries(envVars())
function handleAddVariable() {
const key = newKey().trim()
const value = newValue().trim()
if (!key) return
addEnvironmentVariable(key, value)
setEnvVars({ ...envVars(), [key]: value })
setNewKey("")
setNewValue("")
}
function handleRemoveVariable(key: string) {
removeEnvironmentVariable(key)
const { [key]: removed, ...rest } = envVars()
setEnvVars(rest)
}
function handleUpdateVariable(key: string, value: string) {
const updated = { ...envVars(), [key]: value }
setEnvVars(updated)
updateEnvironmentVariables(updated)
}
function handleKeyPress(e: KeyboardEvent) {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
handleAddVariable()
}
}
return (
<div class="space-y-3">
<div class="flex items-center gap-2 mb-3">
<Globe class="w-4 h-4 icon-muted" />
<span class="text-sm font-medium text-secondary">Environment Variables</span>
<span class="text-xs text-muted">
({entries().length} variable{entries().length !== 1 ? "s" : ""})
</span>
</div>
{/* Existing variables */}
<Show when={entries().length > 0}>
<div class="space-y-2">
<For each={entries()}>
{([key, value]) => (
<div class="flex items-center gap-2">
<div class="flex-1 flex items-center gap-2">
<Key class="w-3.5 h-3.5 icon-muted flex-shrink-0" />
<input
type="text"
value={key}
disabled={props.disabled}
class="flex-1 px-2.5 py-1.5 text-sm bg-surface-secondary border border-base rounded text-muted cursor-not-allowed"
placeholder="Variable name"
title="Variable name (read-only)"
/>
<input
type="text"
value={value}
disabled={props.disabled}
onInput={(e) => handleUpdateVariable(key, e.currentTarget.value)}
class="flex-1 px-2.5 py-1.5 text-sm bg-surface-base border border-base rounded text-primary focus-ring-accent disabled:opacity-50 disabled:cursor-not-allowed"
placeholder="Variable value"
/>
</div>
<button
onClick={() => handleRemoveVariable(key)}
disabled={props.disabled}
class="p-1.5 icon-muted icon-danger-hover disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
title="Remove variable"
>
<Trash2 class="w-3.5 h-3.5" />
</button>
</div>
)}
</For>
</div>
</Show>
{/* Add new variable */}
<div class="flex items-center gap-2 pt-2 border-t border-base">
<div class="flex-1 flex items-center gap-2">
<Key class="w-3.5 h-3.5 icon-muted flex-shrink-0" />
<input
type="text"
value={newKey()}
onInput={(e) => setNewKey(e.currentTarget.value)}
onKeyPress={handleKeyPress}
disabled={props.disabled}
class="flex-1 px-2.5 py-1.5 text-sm bg-surface-base border border-base rounded text-primary focus-ring-accent disabled:opacity-50 disabled:cursor-not-allowed"
placeholder="Variable name"
/>
<input
type="text"
value={newValue()}
onInput={(e) => setNewValue(e.currentTarget.value)}
onKeyPress={handleKeyPress}
disabled={props.disabled}
class="flex-1 px-2.5 py-1.5 text-sm bg-surface-base border border-base rounded text-primary focus-ring-accent disabled:opacity-50 disabled:cursor-not-allowed"
placeholder="Variable value"
/>
</div>
<button
onClick={handleAddVariable}
disabled={props.disabled || !newKey().trim()}
class="p-1.5 icon-muted icon-accent-hover disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
title="Add variable"
>
<Plus class="w-3.5 h-3.5" />
</button>
</div>
<Show when={entries().length === 0}>
<div class="text-xs text-muted text-center py-2">
No environment variables configured. Add variables above to customize the OpenCode environment.
</div>
</Show>
<div class="text-xs text-muted mt-2">
These variables will be available in the OpenCode environment when starting instances.
</div>
</div>
)
}
export default EnvironmentVariablesEditor

View File

@@ -0,0 +1,451 @@
import { Component, Show, For, createSignal, createMemo, createEffect, onCleanup } from "solid-js"
import { Folder as FolderIcon, File as FileIcon, Loader2, Search, X, ArrowUpLeft } from "lucide-solid"
import type { FileSystemEntry, FileSystemListingMetadata } from "../../../server/src/api-types"
import { serverApi } from "../lib/api-client"
import { getLogger } from "../lib/logger"
const log = getLogger("actions")
const MAX_RESULTS = 200
function normalizeEntryPath(path: string | undefined): string {
if (!path || path === "." || path === "./") {
return "."
}
let cleaned = path.replace(/\\/g, "/")
if (cleaned.startsWith("./")) {
cleaned = cleaned.replace(/^\.\/+/, "")
}
if (cleaned.startsWith("/")) {
cleaned = cleaned.replace(/^\/+/, "")
}
cleaned = cleaned.replace(/\/+/g, "/")
return cleaned === "" ? "." : cleaned
}
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}`
}
interface FileSystemBrowserDialogProps {
open: boolean
mode: "directories" | "files"
title: string
description?: string
onSelect: (absolutePath: string) => void
onClose: () => void
}
type FolderRow = { type: "up"; path: string } | { type: "entry"; entry: FileSystemEntry }
const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props) => {
const [rootPath, setRootPath] = createSignal("")
const [entries, setEntries] = createSignal<FileSystemEntry[]>([])
const [currentMetadata, setCurrentMetadata] = createSignal<FileSystemListingMetadata | null>(null)
const [loadingPath, setLoadingPath] = createSignal<string | null>(null)
const [error, setError] = createSignal<string | null>(null)
const [searchQuery, setSearchQuery] = createSignal("")
const [selectedIndex, setSelectedIndex] = createSignal(0)
let searchInputRef: HTMLInputElement | undefined
const directoryCache = new Map<string, FileSystemEntry[]>()
const metadataCache = new Map<string, FileSystemListingMetadata>()
const inFlightLoads = new Map<string, Promise<FileSystemListingMetadata>>()
function resetDialogState() {
directoryCache.clear()
metadataCache.clear()
inFlightLoads.clear()
setEntries([])
setCurrentMetadata(null)
setLoadingPath(null)
}
async function fetchDirectory(path: string, makeCurrent = false): Promise<FileSystemListingMetadata> {
const normalized = normalizeEntryPath(path)
if (directoryCache.has(normalized) && metadataCache.has(normalized)) {
if (makeCurrent) {
setCurrentMetadata(metadataCache.get(normalized) ?? null)
setEntries(directoryCache.get(normalized) ?? [])
}
return metadataCache.get(normalized) as FileSystemListingMetadata
}
if (inFlightLoads.has(normalized)) {
const metadata = await inFlightLoads.get(normalized)!
if (makeCurrent) {
setCurrentMetadata(metadata)
setEntries(directoryCache.get(normalized) ?? [])
}
return metadata
}
const loadPromise = (async () => {
setLoadingPath(normalized)
const response = await serverApi.listFileSystem(normalized === "." ? "." : normalized, {
includeFiles: props.mode === "files",
})
directoryCache.set(normalized, response.entries)
metadataCache.set(normalized, response.metadata)
if (!rootPath()) {
setRootPath(response.metadata.rootPath)
}
if (loadingPath() === normalized) {
setLoadingPath(null)
}
return response.metadata
})().catch((err) => {
if (loadingPath() === normalized) {
setLoadingPath(null)
}
throw err
})
inFlightLoads.set(normalized, loadPromise)
try {
const metadata = await loadPromise
if (makeCurrent) {
const key = normalizeEntryPath(metadata.currentPath)
setCurrentMetadata(metadata)
setEntries(directoryCache.get(key) ?? directoryCache.get(normalized) ?? [])
}
return metadata
} finally {
inFlightLoads.delete(normalized)
}
}
async function refreshEntries() {
setError(null)
resetDialogState()
try {
const metadata = await fetchDirectory(".", true)
setRootPath(metadata.rootPath)
setEntries(directoryCache.get(normalizeEntryPath(metadata.currentPath)) ?? [])
} catch (err) {
const message = err instanceof Error ? err.message : "Unable to load filesystem"
setError(message)
}
}
function describeLoadingPath() {
const path = loadingPath()
if (!path) {
return "filesystem"
}
if (path === ".") {
return rootPath() || "workspace root"
}
return resolveAbsolutePath(rootPath(), path)
}
function currentAbsolutePath(): string {
const metadata = currentMetadata()
if (!metadata) {
return rootPath()
}
if (metadata.pathKind === "relative") {
return resolveAbsolutePath(rootPath(), metadata.currentPath)
}
return metadata.displayPath
}
function handleOverlayClick(event: MouseEvent) {
if (event.target === event.currentTarget) {
props.onClose()
}
}
function handleEntrySelect(entry: FileSystemEntry) {
const absolute = resolveAbsolutePath(rootPath(), entry.path)
props.onSelect(absolute)
}
function handleNavigateTo(path: string) {
void fetchDirectory(path, true).catch((err) => {
log.error("Failed to open directory", err)
setError(err instanceof Error ? err.message : "Unable to open directory")
})
}
function handleNavigateUp() {
const parent = currentMetadata()?.parentPath
if (!parent) {
return
}
handleNavigateTo(parent)
}
const filteredEntries = createMemo(() => {
const query = searchQuery().trim().toLowerCase()
const subset = entries().filter((entry) => (props.mode === "directories" ? entry.type === "directory" : true))
if (!query) {
return subset
}
return subset.filter((entry) => {
const absolute = resolveAbsolutePath(rootPath(), entry.path)
return absolute.toLowerCase().includes(query) || entry.name.toLowerCase().includes(query)
})
})
const visibleEntries = createMemo(() => filteredEntries().slice(0, MAX_RESULTS))
const folderRows = createMemo<FolderRow[]>(() => {
const rows: FolderRow[] = []
const metadata = currentMetadata()
if (metadata?.parentPath) {
rows.push({ type: "up", path: metadata.parentPath })
}
for (const entry of visibleEntries()) {
rows.push({ type: "entry", entry })
}
return rows
})
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)
resetDialogState()
setRootPath("")
setError(null)
})
})
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>
<Show when={props.mode === "directories"}>
<div class="px-4 pb-2">
<div class="flex items-center justify-between gap-3 rounded-md border border-border-subtle px-4 py-3">
<div>
<p class="text-xs text-secondary uppercase tracking-wide">Current folder</p>
<p class="text-sm font-mono text-primary break-all">{currentAbsolutePath()}</p>
</div>
<button
type="button"
class="selector-button selector-button-secondary whitespace-nowrap"
onClick={() => props.onSelect(currentAbsolutePath())}
>
Select Current
</button>
</div>
</div>
</Show>
<div class="panel-list panel-list--fill max-h-96 overflow-auto">
<Show
when={entries().length > 0}
fallback={
<div class="flex items-center justify-center py-6 text-sm text-secondary">
<Show
when={loadingPath() !== null}
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 {describeLoadingPath()}</span>
</div>
</Show>
</div>
}
>
<Show when={loadingPath()}>
<div class="flex items-center gap-2 px-4 py-2 text-xs text-secondary">
<Loader2 class="w-3.5 h-3.5 animate-spin" />
<span>Loading {describeLoadingPath()}</span>
</div>
</Show>
<Show
when={folderRows().length > 0}
fallback={
<div class="flex flex-col items-center justify-center gap-2 py-10 text-sm text-secondary">
<p>No entries found.</p>
<button type="button" class="selector-button selector-button-secondary" onClick={refreshEntries}>
Retry
</button>
</div>
}
>
<For each={folderRows()}>
{(row) => {
if (row.type === "up") {
return (
<div class="panel-list-item" role="button">
<div class="panel-list-item-content directory-browser-row">
<button type="button" class="directory-browser-row-main" onClick={handleNavigateUp}>
<div class="directory-browser-row-icon">
<ArrowUpLeft class="w-4 h-4" />
</div>
<div class="directory-browser-row-text">
<span class="directory-browser-row-name">Up one level</span>
</div>
</button>
</div>
</div>
)
}
const entry = row.entry
const selectEntry = () => handleEntrySelect(entry)
const activateEntry = () => {
if (entry.type === "directory") {
handleNavigateTo(entry.path)
} else {
selectEntry()
}
}
return (
<div class="panel-list-item" role="listitem">
<div class="panel-list-item-content directory-browser-row">
<button type="button" class="directory-browser-row-main" onClick={activateEntry}>
<div class="directory-browser-row-icon">
<Show when={entry.type === "directory"} fallback={<FileIcon class="w-4 h-4" />}>
<FolderIcon class="w-4 h-4" />
</Show>
</div>
<div class="directory-browser-row-text">
<span class="directory-browser-row-name">{entry.name || entry.path}</span>
<span class="directory-browser-row-sub">
{resolveAbsolutePath(rootPath(), entry.path)}
</span>
</div>
</button>
<button
type="button"
class="selector-button selector-button-secondary directory-browser-select"
onClick={(event) => {
event.stopPropagation()
selectEntry()
}}
>
Select
</button>
</div>
</div>
)
}}
</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

@@ -0,0 +1,426 @@
import { Component, createSignal, Show, For, onMount, onCleanup, createEffect } from "solid-js"
import { Folder, Clock, Trash2, FolderPlus, Settings, ChevronRight, MonitorUp } from "lucide-solid"
import { useConfig } from "../stores/preferences"
import AdvancedSettingsModal from "./advanced-settings-modal"
import DirectoryBrowserDialog from "./directory-browser-dialog"
import Kbd from "./kbd"
import { openNativeFolderDialog, supportsNativeDialogs } from "../lib/native/native-functions"
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
interface FolderSelectionViewProps {
onSelectFolder: (folder: string, binaryPath?: string) => void
isLoading?: boolean
advancedSettingsOpen?: boolean
onAdvancedSettingsOpen?: () => void
onAdvancedSettingsClose?: () => void
onOpenRemoteAccess?: () => void
}
const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
const { recentFolders, removeRecentFolder, preferences } = useConfig()
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)
const nativeDialogsAvailable = supportsNativeDialogs()
let recentListRef: HTMLDivElement | undefined
const folders = () => recentFolders()
const isLoading = () => Boolean(props.isLoading)
// Update selected binary when preferences change
createEffect(() => {
const lastUsed = preferences().lastUsedBinary
if (!lastUsed) return
setSelectedBinary((current) => (current === lastUsed ? current : lastUsed))
})
function scrollToIndex(index: number) {
const container = recentListRef
if (!container) return
const element = container.querySelector(`[data-folder-index="${index}"]`) as HTMLElement | null
if (!element) return
const containerRect = container.getBoundingClientRect()
const elementRect = element.getBoundingClientRect()
if (elementRect.top < containerRect.top) {
container.scrollTop -= containerRect.top - elementRect.top
} else if (elementRect.bottom > containerRect.bottom) {
container.scrollTop += elementRect.bottom - containerRect.bottom
}
}
function handleKeyDown(e: KeyboardEvent) {
const normalizedKey = e.key.toLowerCase()
const isBrowseShortcut = (e.metaKey || e.ctrlKey) && !e.shiftKey && normalizedKey === "n"
const blockedKeys = [
"ArrowDown",
"ArrowUp",
"PageDown",
"PageUp",
"Home",
"End",
"Enter",
"Backspace",
"Delete",
]
if (isLoading()) {
if (isBrowseShortcut || blockedKeys.includes(e.key)) {
e.preventDefault()
}
return
}
const folderList = folders()
if (isBrowseShortcut) {
e.preventDefault()
void handleBrowse()
return
}
if (folderList.length === 0) return
if (e.key === "ArrowDown") {
e.preventDefault()
const newIndex = Math.min(selectedIndex() + 1, folderList.length - 1)
setSelectedIndex(newIndex)
setFocusMode("recent")
scrollToIndex(newIndex)
} else if (e.key === "ArrowUp") {
e.preventDefault()
const newIndex = Math.max(selectedIndex() - 1, 0)
setSelectedIndex(newIndex)
setFocusMode("recent")
scrollToIndex(newIndex)
} else if (e.key === "PageDown") {
e.preventDefault()
const pageSize = 5
const newIndex = Math.min(selectedIndex() + pageSize, folderList.length - 1)
setSelectedIndex(newIndex)
setFocusMode("recent")
scrollToIndex(newIndex)
} else if (e.key === "PageUp") {
e.preventDefault()
const pageSize = 5
const newIndex = Math.max(selectedIndex() - pageSize, 0)
setSelectedIndex(newIndex)
setFocusMode("recent")
scrollToIndex(newIndex)
} else if (e.key === "Home") {
e.preventDefault()
setSelectedIndex(0)
setFocusMode("recent")
scrollToIndex(0)
} else if (e.key === "End") {
e.preventDefault()
const newIndex = folderList.length - 1
setSelectedIndex(newIndex)
setFocusMode("recent")
scrollToIndex(newIndex)
} else if (e.key === "Enter") {
e.preventDefault()
handleEnterKey()
} else if (e.key === "Backspace" || e.key === "Delete") {
e.preventDefault()
if (folderList.length > 0 && focusMode() === "recent") {
const folder = folderList[selectedIndex()]
if (folder) {
handleRemove(folder.path)
}
}
}
}
function handleEnterKey() {
if (isLoading()) return
const folderList = folders()
const index = selectedIndex()
const folder = folderList[index]
if (folder) {
handleFolderSelect(folder.path)
}
}
onMount(() => {
window.addEventListener("keydown", handleKeyDown)
onCleanup(() => {
window.removeEventListener("keydown", handleKeyDown)
})
})
function formatRelativeTime(timestamp: number): string {
const seconds = Math.floor((Date.now() - timestamp) / 1000)
const minutes = Math.floor(seconds / 60)
const hours = Math.floor(minutes / 60)
const days = Math.floor(hours / 24)
if (days > 0) return `${days}d ago`
if (hours > 0) return `${hours}h ago`
if (minutes > 0) return `${minutes}m ago`
return "just now"
}
function handleFolderSelect(path: string) {
if (isLoading()) return
props.onSelectFolder(path, selectedBinary())
}
async function handleBrowse() {
if (isLoading()) return
setFocusMode("new")
if (nativeDialogsAvailable) {
const fallbackPath = folders()[0]?.path
const selected = await openNativeFolderDialog({
title: "Select Workspace",
defaultPath: fallbackPath,
})
if (selected) {
handleFolderSelect(selected)
}
return
}
setIsFolderBrowserOpen(true)
}
function handleBrowserSelect(path: string) {
setIsFolderBrowserOpen(false)
handleFolderSelect(path)
}
function handleBinaryChange(binary: string) {
setSelectedBinary(binary)
}
function handleRemove(path: string, e?: Event) {
if (isLoading()) return
e?.stopPropagation()
removeRecentFolder(path)
const folderList = folders()
if (selectedIndex() >= folderList.length && folderList.length > 0) {
setSelectedIndex(folderList.length - 1)
}
}
function getDisplayPath(path: string): string {
if (path.startsWith("/Users/")) {
return path.replace(/^\/Users\/[^/]+/, "~")
}
return path
}
return (
<>
<div
class="flex h-screen w-full items-start justify-center overflow-hidden py-6 px-4 sm:px-6 relative"
style="background-color: var(--surface-secondary)"
>
<div
class="w-full max-w-3xl h-full px-4 sm:px-8 pb-2 flex flex-col overflow-hidden"
aria-busy={isLoading() ? "true" : "false"}
>
<Show when={props.onOpenRemoteAccess}>
<div class="absolute top-4 right-6">
<button
type="button"
class="selector-button selector-button-secondary inline-flex items-center justify-center"
onClick={() => props.onOpenRemoteAccess?.()}
>
<MonitorUp class="w-4 h-4" />
</button>
</div>
</Show>
<div class="mb-6 text-center shrink-0">
<div class="mb-3 flex justify-center">
<img src={codeNomadLogo} alt="CodeNomad logo" class="h-32 w-auto sm:h-48" loading="lazy" />
</div>
<h1 class="mb-2 text-3xl font-semibold text-primary">CodeNomad</h1>
<p class="text-base text-secondary">Select a folder to start coding with AI</p>
</div>
<div class="space-y-4 flex-1 min-h-0 overflow-hidden flex flex-col">
<Show
when={folders().length > 0}
fallback={
<div class="panel panel-empty-state flex-1">
<div class="panel-empty-state-icon">
<Clock class="w-12 h-12 mx-auto" />
</div>
<p class="panel-empty-state-title">No Recent Folders</p>
<p class="panel-empty-state-description">Browse for a folder to get started</p>
</div>
}
>
<div class="panel flex flex-col flex-1 min-h-0">
<div class="panel-header">
<h2 class="panel-title">Recent Folders</h2>
<p class="panel-subtitle">
{folders().length} {folders().length === 1 ? "folder" : "folders"} available
</p>
</div>
<div class="panel-list panel-list--fill flex-1 min-h-0 overflow-auto" ref={(el) => (recentListRef = el)}>
<For each={folders()}>
{(folder, index) => (
<div
class="panel-list-item"
classList={{
"panel-list-item-highlight": focusMode() === "recent" && selectedIndex() === index(),
"panel-list-item-disabled": isLoading(),
}}
>
<div class="flex items-center gap-2 w-full px-1">
<button
data-folder-index={index()}
class="panel-list-item-content flex-1"
disabled={isLoading()}
onClick={() => handleFolderSelect(folder.path)}
onMouseEnter={() => {
if (isLoading()) return
setFocusMode("recent")
setSelectedIndex(index())
}}
>
<div class="flex items-center justify-between gap-3 w-full">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<Folder class="w-4 h-4 flex-shrink-0 icon-muted" />
<span class="text-sm font-medium truncate text-primary">
{folder.path.split("/").pop()}
</span>
</div>
<div class="text-xs font-mono truncate pl-6 text-muted">
{getDisplayPath(folder.path)}
</div>
<div class="text-xs mt-1 pl-6 text-muted">
{formatRelativeTime(folder.lastAccessed)}
</div>
</div>
<Show when={focusMode() === "recent" && selectedIndex() === index()}>
<kbd class="kbd"></kbd>
</Show>
</div>
</button>
<button
onClick={(e) => handleRemove(folder.path, e)}
disabled={isLoading()}
class="p-2 transition-all hover:bg-red-100 dark:hover:bg-red-900/30 opacity-70 hover:opacity-100 rounded"
title="Remove from recent"
>
<Trash2 class="w-3.5 h-3.5 transition-colors icon-muted hover:text-red-600 dark:hover:text-red-400" />
</button>
</div>
</div>
)}
</For>
</div>
</div>
</Show>
<div class="panel shrink-0">
<div class="panel-header hidden sm:block">
<h2 class="panel-title">Browse for Folder</h2>
<p class="panel-subtitle">Select any folder on your computer</p>
</div>
<div class="panel-body">
<button
onClick={() => void handleBrowse()}
disabled={props.isLoading}
class="button-primary w-full flex items-center justify-center text-sm disabled:cursor-not-allowed"
onMouseEnter={() => setFocusMode("new")}
>
<div class="flex items-center gap-2">
<FolderPlus class="w-4 h-4" />
<span>{props.isLoading ? "Opening..." : "Browse Folders"}</span>
</div>
<Kbd shortcut="cmd+n" class="ml-2" />
</button>
</div>
{/* Advanced settings section */}
<div class="panel-section w-full">
<button
onClick={() => props.onAdvancedSettingsOpen?.()}
class="panel-section-header w-full justify-between"
>
<div class="flex items-center gap-2">
<Settings class="w-4 h-4 icon-muted" />
<span class="text-sm font-medium text-secondary">Advanced Settings</span>
</div>
<ChevronRight class="w-4 h-4 icon-muted" />
</button>
</div>
</div>
</div>
<div class="mt-1 panel panel-footer shrink-0 hidden sm:block">
<div class="panel-footer-hints">
<Show when={folders().length > 0}>
<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">Del</kbd>
<span>Remove</span>
</div>
</Show>
<div class="flex items-center gap-1.5">
<Kbd shortcut="cmd+n" />
<span>Browse</span>
</div>
</div>
</div>
</div>
<Show when={isLoading()}>
<div class="folder-loading-overlay">
<div class="folder-loading-indicator">
<div class="spinner" />
<p class="folder-loading-text">Starting instance</p>
<p class="folder-loading-subtext">Hang tight while we prepare your workspace.</p>
</div>
</div>
</Show>
</div>
<AdvancedSettingsModal
open={Boolean(props.advancedSettingsOpen)}
onClose={() => props.onAdvancedSettingsClose?.()}
selectedBinary={selectedBinary()}
onBinaryChange={handleBinaryChange}
isLoading={props.isLoading}
/>
<DirectoryBrowserDialog
open={isFolderBrowserOpen()}
title="Select Workspace"
description="Select workspace to start coding."
onClose={() => setIsFolderBrowserOpen(false)}
onSelect={handleBrowserSelect}
/>
</>
)
}
export default FolderSelectionView

View File

@@ -0,0 +1,12 @@
import { Component, JSX } from "solid-js"
interface HintRowProps {
children: JSX.Element
class?: string
}
const HintRow: Component<HintRowProps> = (props) => {
return <span class={`text-xs text-muted ${props.class || ""}`}>{props.children}</span>
}
export default HintRow

View File

@@ -0,0 +1,161 @@
import { Component, For, createSignal, createEffect, Show, onMount, onCleanup, createMemo } from "solid-js"
import { instances, getInstanceLogs, isInstanceLogStreaming, setInstanceLogStreaming } from "../stores/instances"
import { ChevronDown } from "lucide-solid"
import InstanceInfo from "./instance-info"
interface InfoViewProps {
instanceId: string
}
const logsScrollState = new Map<string, { scrollTop: number; autoScroll: boolean }>()
const InfoView: Component<InfoViewProps> = (props) => {
let scrollRef: HTMLDivElement | undefined
const savedState = logsScrollState.get(props.instanceId)
const [autoScroll, setAutoScroll] = createSignal(savedState?.autoScroll ?? false)
const instance = () => instances().get(props.instanceId)
const logs = createMemo(() => getInstanceLogs(props.instanceId))
const streamingEnabled = createMemo(() => isInstanceLogStreaming(props.instanceId))
const handleEnableLogs = () => setInstanceLogStreaming(props.instanceId, true)
const handleDisableLogs = () => setInstanceLogStreaming(props.instanceId, false)
onMount(() => {
if (scrollRef && savedState) {
scrollRef.scrollTop = savedState.scrollTop
}
})
onCleanup(() => {
if (scrollRef) {
logsScrollState.set(props.instanceId, {
scrollTop: scrollRef.scrollTop,
autoScroll: autoScroll(),
})
}
})
createEffect(() => {
if (autoScroll() && scrollRef && logs().length > 0) {
scrollRef.scrollTop = scrollRef.scrollHeight
}
})
const handleScroll = () => {
if (!scrollRef) return
const isAtBottom = scrollRef.scrollHeight - scrollRef.scrollTop <= scrollRef.clientHeight + 50
setAutoScroll(isAtBottom)
}
const scrollToBottom = () => {
if (scrollRef) {
scrollRef.scrollTop = scrollRef.scrollHeight
setAutoScroll(true)
}
}
const formatTime = (timestamp: number) => {
const date = new Date(timestamp)
return date.toLocaleTimeString("en-US", {
hour12: false,
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
})
}
const getLevelColor = (level: string) => {
switch (level) {
case "error":
return "log-level-error"
case "warn":
return "log-level-warn"
case "debug":
return "log-level-debug"
default:
return "log-level-default"
}
}
return (
<div class="log-container">
<div class="flex-1 flex flex-col lg:flex-row gap-4 p-4 overflow-hidden">
<div class="lg:w-80 flex-shrink-0 overflow-y-auto">
<Show when={instance()}>{(inst) => <InstanceInfo instance={inst()} />}</Show>
</div>
<div class="panel flex-1 flex flex-col min-h-0 overflow-hidden">
<div class="log-header">
<h2 class="panel-title">Server Logs</h2>
<div class="flex items-center gap-2">
<Show
when={streamingEnabled()}
fallback={
<button type="button" class="button-tertiary" onClick={handleEnableLogs}>
Show server logs
</button>
}
>
<button type="button" class="button-tertiary" onClick={handleDisableLogs}>
Hide server logs
</button>
</Show>
</div>
</div>
<div
ref={scrollRef}
onScroll={handleScroll}
class="log-content"
>
<Show
when={streamingEnabled()}
fallback={
<div class="log-paused-state">
<p class="log-paused-title">Server logs are paused</p>
<p class="log-paused-description">Enable streaming to watch your OpenCode server activity.</p>
<button type="button" class="button-primary" onClick={handleEnableLogs}>
Show server logs
</button>
</div>
}
>
<Show
when={logs().length > 0}
fallback={<div class="log-empty-state">Waiting for server output...</div>}
>
<For each={logs()}>
{(entry) => (
<div class="log-entry">
<span class="log-timestamp">
{formatTime(entry.timestamp)}
</span>
<span class={`log-message ${getLevelColor(entry.level)}`}>{entry.message}</span>
</div>
)}
</For>
</Show>
</Show>
</div>
<Show when={!autoScroll() && streamingEnabled()}>
<button
onClick={scrollToBottom}
class="scroll-to-bottom"
>
<ChevronDown class="w-4 h-4" />
Scroll to bottom
</button>
</Show>
</div>
</div>
</div>
)
}
export default InfoView

View File

@@ -0,0 +1,47 @@
import { Dialog } from "@kobalte/core/dialog"
interface InstanceDisconnectedModalProps {
open: boolean
folder?: string
reason?: string
onClose: () => void
}
export default function InstanceDisconnectedModal(props: InstanceDisconnectedModalProps) {
const folderLabel = props.folder || "this workspace"
const reasonLabel = props.reason || "The server stopped responding"
return (
<Dialog open={props.open} modal>
<Dialog.Portal>
<Dialog.Overlay class="modal-overlay" />
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<Dialog.Content class="modal-surface w-full max-w-md p-6 flex flex-col gap-6">
<div>
<Dialog.Title class="text-xl font-semibold text-primary">Instance Disconnected</Dialog.Title>
<Dialog.Description class="text-sm text-secondary mt-2 break-words">
{folderLabel} can no longer be reached. Close the tab to continue working.
</Dialog.Description>
</div>
<div class="rounded-lg border border-base bg-surface-secondary p-4 text-sm text-secondary">
<p class="font-medium text-primary">Details</p>
<p class="mt-2 text-secondary">{reasonLabel}</p>
{props.folder && (
<p class="mt-2 text-secondary">
Folder: <span class="font-mono text-primary break-all">{props.folder}</span>
</p>
)}
</div>
<div class="flex justify-end">
<button type="button" class="selector-button selector-button-primary" onClick={props.onClose}>
Close Instance
</button>
</div>
</Dialog.Content>
</div>
</Dialog.Portal>
</Dialog>
)
}

View File

@@ -0,0 +1,162 @@
import { Component, For, Show, createMemo } from "solid-js"
import type { Instance } from "../types/instance"
import { useOptionalInstanceMetadataContext } from "../lib/contexts/instance-metadata-context"
import InstanceServiceStatus from "./instance-service-status"
interface InstanceInfoProps {
instance: Instance
compact?: boolean
}
const InstanceInfo: Component<InstanceInfoProps> = (props) => {
const metadataContext = useOptionalInstanceMetadataContext()
const isLoadingMetadata = metadataContext?.isLoading ?? (() => false)
const instanceAccessor = metadataContext?.instance ?? (() => props.instance)
const metadataAccessor = metadataContext?.metadata ?? (() => props.instance.metadata)
const currentInstance = () => instanceAccessor()
const metadata = () => metadataAccessor()
const binaryVersion = () => currentInstance().binaryVersion || metadata()?.version
const environmentVariables = () => currentInstance().environmentVariables
const environmentEntries = createMemo(() => {
const env = environmentVariables()
return env ? Object.entries(env) : []
})
return (
<div class="panel">
<div class="panel-header">
<h2 class="panel-title">Instance Information</h2>
</div>
<div class="panel-body space-y-3">
<div>
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1">Folder</div>
<div class="text-xs text-primary font-mono break-all px-2 py-1.5 rounded border bg-surface-secondary border-base">
{currentInstance().folder}
</div>
</div>
<Show when={!isLoadingMetadata() && metadata()?.project}>
{(project) => (
<>
<div>
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1">
Project
</div>
<div class="text-xs font-mono px-2 py-1.5 rounded border truncate bg-surface-secondary border-base text-primary">
{project().id}
</div>
</div>
<Show when={project().vcs}>
<div>
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1">
Version Control
</div>
<div class="flex items-center gap-2 text-xs text-primary">
<svg
class="w-3.5 h-3.5"
style="color: var(--status-warning);"
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z" />
</svg>
<span class="capitalize">{project().vcs}</span>
</div>
</div>
</Show>
</>
)}
</Show>
<Show when={binaryVersion()}>
<div>
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1">
OpenCode Version
</div>
<div class="text-xs px-2 py-1.5 rounded border bg-surface-secondary border-base text-primary">
v{binaryVersion()}
</div>
</div>
</Show>
<Show when={currentInstance().binaryPath}>
<div>
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1">
Binary Path
</div>
<div class="text-xs font-mono break-all px-2 py-1.5 rounded border bg-surface-secondary border-base text-primary">
{currentInstance().binaryPath}
</div>
</div>
</Show>
<Show when={environmentEntries().length > 0}>
<div>
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1.5">
Environment Variables ({environmentEntries().length})
</div>
<div class="space-y-1">
<For each={environmentEntries()}>
{([key, value]) => (
<div class="flex items-center gap-2 px-2 py-1.5 rounded border bg-surface-secondary border-base">
<span class="text-xs font-mono font-medium flex-1 text-primary" title={key}>
{key}
</span>
<span class="text-xs font-mono flex-1 text-secondary" title={value}>
{value}
</span>
</div>
)}
</For>
</div>
</div>
</Show>
<InstanceServiceStatus initialInstance={props.instance} class="space-y-3" />
<Show when={isLoadingMetadata()}>
<div class="text-xs text-muted py-1">
<div class="flex items-center gap-1.5">
<svg class="animate-spin h-3 w-3 icon-muted" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
Loading...
</div>
</div>
</Show>
<div>
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1.5">Server</div>
<div class="space-y-1 text-xs">
<div class="flex justify-between items-center">
<span class="text-secondary">Port:</span>
<span class="text-primary font-mono">{currentInstance().port}</span>
</div>
<div class="flex justify-between items-center">
<span class="text-secondary">PID:</span>
<span class="text-primary font-mono">{currentInstance().pid}</span>
</div>
<div class="flex justify-between items-center">
<span class="text-secondary">Status:</span>
<span class={`status-badge ${currentInstance().status}`}>
<div
class={`status-dot ${currentInstance().status === "ready" ? "ready" : currentInstance().status === "starting" ? "starting" : currentInstance().status === "error" ? "error" : "stopped"} ${currentInstance().status === "ready" || currentInstance().status === "starting" ? "animate-pulse" : ""}`}
/>
{currentInstance().status}
</span>
</div>
</div>
</div>
</div>
</div>
)
}
export default InstanceInfo

View File

@@ -0,0 +1,224 @@
import { For, Show, createMemo, createSignal, type Component } from "solid-js"
import Switch from "@suid/material/Switch"
import type { Instance, RawMcpStatus } from "../types/instance"
import { useOptionalInstanceMetadataContext } from "../lib/contexts/instance-metadata-context"
import { getLogger } from "../lib/logger"
const log = getLogger("session")
type ServiceSection = "lsp" | "mcp"
interface InstanceServiceStatusProps {
sections?: ServiceSection[]
showSectionHeadings?: boolean
class?: string
initialInstance?: Instance
}
type ParsedMcpStatus = {
name: string
status: "running" | "stopped" | "error"
error?: string
}
function parseMcpStatus(status?: RawMcpStatus): ParsedMcpStatus[] {
if (!status || typeof status !== "object") return []
const result: ParsedMcpStatus[] = []
for (const [name, value] of Object.entries(status)) {
if (!value || typeof value !== "object") continue
const rawStatus = (value as { status?: string }).status
if (!rawStatus) continue
let mapped: ParsedMcpStatus["status"]
if (rawStatus === "connected") mapped = "running"
else if (rawStatus === "failed") mapped = "error"
else mapped = "stopped"
result.push({
name,
status: mapped,
error: typeof (value as { error?: unknown }).error === "string" ? (value as { error?: string }).error : undefined,
})
}
return result
}
const InstanceServiceStatus: Component<InstanceServiceStatusProps> = (props) => {
const metadataContext = useOptionalInstanceMetadataContext()
const instance = metadataContext?.instance ?? (() => {
if (props.initialInstance) {
return props.initialInstance
}
throw new Error("InstanceServiceStatus requires InstanceMetadataProvider or initialInstance prop")
})
const isLoading = metadataContext?.isLoading ?? (() => false)
const refreshMetadata = metadataContext?.refreshMetadata ?? (async () => Promise.resolve())
const sections = createMemo<ServiceSection[]>(() => props.sections ?? ["lsp", "mcp"])
const includeLsp = createMemo(() => sections().includes("lsp"))
const includeMcp = createMemo(() => sections().includes("mcp"))
const showHeadings = () => props.showSectionHeadings !== false
const metadataAccessor = metadataContext?.metadata ?? (() => instance().metadata)
const metadata = createMemo(() => metadataAccessor())
const hasLspMetadata = () => metadata()?.lspStatus !== undefined
const hasMcpMetadata = () => metadata()?.mcpStatus !== undefined
const lspServers = createMemo(() => metadata()?.lspStatus ?? [])
const mcpServers = createMemo(() => parseMcpStatus(metadata()?.mcpStatus ?? undefined))
const isLspLoading = () => isLoading() || !hasLspMetadata()
const isMcpLoading = () => isLoading() || !hasMcpMetadata()
const [pendingMcpActions, setPendingMcpActions] = createSignal<Record<string, "connect" | "disconnect">>({})
const setPendingMcpAction = (name: string, action?: "connect" | "disconnect") => {
setPendingMcpActions((prev) => {
const next = { ...prev }
if (action) next[name] = action
else delete next[name]
return next
})
}
const toggleMcpServer = async (serverName: string, shouldEnable: boolean) => {
const client = instance().client
if (!client?.mcp) return
const action: "connect" | "disconnect" = shouldEnable ? "connect" : "disconnect"
setPendingMcpAction(serverName, action)
try {
if (shouldEnable) {
await client.mcp.connect({ path: { name: serverName } })
} else {
await client.mcp.disconnect({ path: { name: serverName } })
}
await refreshMetadata()
} catch (error) {
log.error("Failed to toggle MCP server", { serverName, action, error })
} finally {
setPendingMcpAction(serverName)
}
}
const renderEmptyState = (message: string) => (
<p class="text-[11px] text-secondary italic" role="status">
{message}
</p>
)
const renderLspSection = () => (
<section class="space-y-1.5">
<Show when={showHeadings()}>
<div class="text-xs font-medium text-muted uppercase tracking-wide">
LSP Servers
</div>
</Show>
<Show
when={!isLspLoading() && lspServers().length > 0}
fallback={renderEmptyState(isLspLoading() ? "Loading LSP servers..." : "No LSP servers detected.")}
>
<div class="space-y-1.5">
<For each={lspServers()}>
{(server) => (
<div class="px-2 py-1.5 rounded border bg-surface-secondary border-base">
<div class="flex items-center justify-between gap-2">
<div class="flex flex-col flex-1 min-w-0">
<span class="text-xs text-primary font-medium truncate">{server.name ?? server.id}</span>
<span class="text-[11px] text-secondary truncate" title={server.root}>
{server.root}
</span>
</div>
<div class="flex items-center gap-1.5 flex-shrink-0 text-xs text-secondary">
<div class={`status-dot ${server.status === "connected" ? "ready animate-pulse" : "error"}`} />
<span>{server.status === "connected" ? "Connected" : "Error"}</span>
</div>
</div>
</div>
)}
</For>
</div>
</Show>
</section>
)
const renderMcpSection = () => (
<section class="space-y-1.5">
<Show when={showHeadings()}>
<div class="text-xs font-medium text-muted uppercase tracking-wide">
MCP Servers
</div>
</Show>
<Show
when={!isMcpLoading() && mcpServers().length > 0}
fallback={renderEmptyState(isMcpLoading() ? "Loading MCP servers..." : "No MCP servers detected.")}
>
<div class="space-y-1.5">
<For each={mcpServers()}>
{(server) => {
const pendingAction = () => pendingMcpActions()[server.name]
const isPending = () => Boolean(pendingAction())
const isRunning = () => server.status === "running"
const switchDisabled = () => isPending() || !instance().client
const statusDotClass = () => {
if (isPending()) return "status-dot animate-pulse"
if (server.status === "running") return "status-dot ready animate-pulse"
if (server.status === "error") return "status-dot error"
return "status-dot stopped"
}
const statusDotStyle = () => (isPending() ? { background: "var(--status-warning)" } : undefined)
return (
<div class="px-2 py-1.5 rounded border bg-surface-secondary border-base">
<div class="flex items-center justify-between gap-2">
<span class="text-xs text-primary font-medium truncate">{server.name}</span>
<div class="flex items-center gap-3 flex-shrink-0">
<div class="flex items-center gap-1.5 text-xs text-secondary">
<Show when={isPending()}>
<svg class="animate-spin h-3 w-3" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
</Show>
<div class={statusDotClass()} style={statusDotStyle()} />
</div>
<div class="flex items-center gap-1.5">
<Switch
checked={isRunning()}
disabled={switchDisabled()}
color="success"
size="small"
inputProps={{ "aria-label": `Toggle ${server.name} MCP server` }}
onChange={(_, checked) => {
if (switchDisabled()) return
void toggleMcpServer(server.name, Boolean(checked))
}}
/>
</div>
</div>
</div>
<Show when={server.error}>
{(error) => (
<div class="text-[11px] mt-1 break-words" style={{ color: "var(--status-error)" }}>
{error()}
</div>
)}
</Show>
</div>
)
}}
</For>
</div>
</Show>
</section>
)
return (
<div class={props.class}>
<Show when={includeLsp()}>{renderLspSection()}</Show>
<Show when={includeMcp()}>{renderMcpSection()}</Show>
</div>
)
}
export default InstanceServiceStatus

View File

@@ -0,0 +1,59 @@
import { Component } from "solid-js"
import type { Instance } from "../types/instance"
import { FolderOpen, X } from "lucide-solid"
interface InstanceTabProps {
instance: Instance
active: boolean
onSelect: () => void
onClose: () => void
}
function formatFolderName(path: string, instances: Instance[], currentInstance: Instance): string {
const name = path.split("/").pop() || path
const duplicates = instances.filter((i) => {
const iName = i.folder.split("/").pop() || i.folder
return iName === name
})
if (duplicates.length > 1) {
const index = duplicates.findIndex((i) => i.id === currentInstance.id)
return `~/${name} (${index + 1})`
}
return `~/${name}`
}
const InstanceTab: Component<InstanceTabProps> = (props) => {
return (
<div class="group">
<button
class={`tab-base ${props.active ? "tab-active" : "tab-inactive"}`}
onClick={props.onSelect}
title={props.instance.folder}
role="tab"
aria-selected={props.active}
>
<FolderOpen class="w-4 h-4 flex-shrink-0" />
<span class="tab-label">
{props.instance.folder.split("/").pop() || props.instance.folder}
</span>
<span
class="tab-close ml-auto"
onClick={(e) => {
e.stopPropagation()
props.onClose()
}}
role="button"
tabIndex={0}
aria-label="Close instance"
>
<X class="w-3 h-3" />
</span>
</button>
</div>
)
}
export default InstanceTab

View File

@@ -0,0 +1,71 @@
import { Component, For, Show } from "solid-js"
import type { Instance } from "../types/instance"
import InstanceTab from "./instance-tab"
import KeyboardHint from "./keyboard-hint"
import { Plus, MonitorUp } from "lucide-solid"
import { keyboardRegistry } from "../lib/keyboard-registry"
interface InstanceTabsProps {
instances: Map<string, Instance>
activeInstanceId: string | null
onSelect: (instanceId: string) => void
onClose: (instanceId: string) => void
onNew: () => void
onOpenRemoteAccess?: () => void
}
const InstanceTabs: Component<InstanceTabsProps> = (props) => {
return (
<div class="tab-bar tab-bar-instance">
<div class="tab-container" role="tablist">
<div class="tab-scroll">
<div class="tab-strip">
<div class="tab-strip-tabs">
<For each={Array.from(props.instances.entries())}>
{([id, instance]) => (
<InstanceTab
instance={instance}
active={id === props.activeInstanceId}
onSelect={() => props.onSelect(id)}
onClose={() => props.onClose(id)}
/>
)}
</For>
<button
class="new-tab-button"
onClick={props.onNew}
title="New instance (Cmd/Ctrl+N)"
aria-label="New instance"
>
<Plus class="w-4 h-4" />
</button>
</div>
<div class="tab-strip-spacer" />
<Show when={Array.from(props.instances.entries()).length > 1}>
<div class="tab-shortcuts">
<KeyboardHint
shortcuts={[keyboardRegistry.get("instance-prev")!, keyboardRegistry.get("instance-next")!].filter(
Boolean,
)}
/>
</div>
</Show>
<Show when={Boolean(props.onOpenRemoteAccess)}>
<button
class="new-tab-button tab-remote-button"
onClick={() => props.onOpenRemoteAccess?.()}
title="Remote connect"
aria-label="Remote connect"
>
<MonitorUp class="w-4 h-4" />
</button>
</Show>
</div>
</div>
</div>
</div>
)
}
export default InstanceTabs

Some files were not shown because too many files have changed in this diff Show More