From c5fd5694ee504ead920680c29348c869f6d48a52 Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Thu, 20 Nov 2025 10:41:07 +0000 Subject: [PATCH] feat: make electron shell host CLI server --- package-lock.json | 14 +- packages/cli/src/index.ts | 4 +- packages/cli/src/server/http-server.ts | 27 +- packages/electron-app/electron/main/ipc.ts | 269 +-------- packages/electron-app/electron/main/main.ts | 75 ++- .../electron/main/process-manager.ts | 528 ++++++++---------- .../electron-app/electron/preload/index.cjs | 19 + .../electron-app/electron/preload/index.ts | 49 -- .../electron/resources/loading.html | 206 +++++++ packages/electron-app/package.json | 7 +- packages/ui/src/components/tool-call.tsx | 185 +++--- .../ui/src/styles/messaging/tool-call.css | 78 ++- 12 files changed, 782 insertions(+), 679 deletions(-) create mode 100644 packages/electron-app/electron/preload/index.cjs delete mode 100644 packages/electron-app/electron/preload/index.ts create mode 100644 packages/electron-app/electron/resources/loading.html diff --git a/package-lock.json b/package-lock.json index 2fac85a8..4cc1aab0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5000,15 +5000,6 @@ ], "license": "BSD-3-Clause" }, - "node_modules/ignore": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", - "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, "node_modules/inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", @@ -8442,8 +8433,8 @@ "name": "@codenomad/electron-app", "version": "0.1.2", "dependencies": { - "@codenomad/ui": "file:../ui", - "ignore": "7.0.5" + "@codenomad/cli": "file:../cli", + "@codenomad/ui": "file:../ui" }, "devDependencies": { "7zip-bin": "^5.2.0", @@ -8453,6 +8444,7 @@ "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" diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 3e56c6ee..83944268 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -89,8 +89,8 @@ function parseCliOptions(argv: string[]): CliOptions { function parsePort(input: string): number { const value = Number(input) - if (!Number.isInteger(value) || value < 1 || value > 65535) { - throw new InvalidArgumentError("Port must be an integer between 1 and 65535") + if (!Number.isInteger(value) || value < 0 || value > 65535) { + throw new InvalidArgumentError("Port must be an integer between 0 and 65535") } return value } diff --git a/packages/cli/src/server/http-server.ts b/packages/cli/src/server/http-server.ts index 8a1b7a5c..f4c04530 100644 --- a/packages/cli/src/server/http-server.ts +++ b/packages/cli/src/server/http-server.ts @@ -79,7 +79,32 @@ export function createHttpServer(deps: HttpServerDeps) { return { instance: app, - start: () => app.listen({ port: deps.port, host: deps.host }), + start: async () => { + const addressInfo = await app.listen({ port: deps.port, host: deps.host }) + let actualPort = deps.port + + if (typeof addressInfo === "string") { + try { + const parsed = new URL(addressInfo) + actualPort = Number(parsed.port) || deps.port + } catch { + actualPort = deps.port + } + } 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 + + deps.serverMeta.httpBaseUrl = `http://${displayHost}:${actualPort}` + deps.logger.info({ port: actualPort, host: deps.host }, "HTTP server listening") + console.log(`CodeNomad Server is ready at http://${displayHost}:${actualPort}`) + + return actualPort + }, stop: () => { closeSseClients() return app.close() diff --git a/packages/electron-app/electron/main/ipc.ts b/packages/electron-app/electron/main/ipc.ts index 7915079a..2b4d673b 100644 --- a/packages/electron-app/electron/main/ipc.ts +++ b/packages/electron-app/electron/main/ipc.ts @@ -1,243 +1,30 @@ -import { ipcMain, BrowserWindow, dialog } from "electron" -import { processManager } from "./process-manager" -import { randomBytes } from "crypto" -import * as fs from "fs" -import * as path from "path" -import { spawn } from "child_process" -import ignore from "ignore" +import { BrowserWindow, ipcMain } from "electron" +import type { CliLogEntry, CliProcessManager, CliStatus } from "./process-manager" -interface Instance { - id: string - folder: string - port: number - pid: number - status: "starting" | "ready" | "error" | "stopped" - error?: string -} - -const instances = new Map() - -function generateId(): string { - return randomBytes(16).toString("hex") -} - -function runBinaryVersion(binaryPath: string, timeoutMs = 5000): Promise { - return new Promise((resolve, reject) => { - const child = spawn(binaryPath, ["-v"], { - stdio: ["ignore", "pipe", "pipe"], - }) - - let stdout = "" - let stderr = "" - - const timeout = setTimeout(() => { - child.kill("SIGTERM") - reject(new Error("Version check timed out")) - }, 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() || `Binary exited with code ${code}`)) - } - }) - }) -} - -export function setupInstanceIPC(mainWindow: BrowserWindow) { - processManager.setMainWindow(mainWindow) - - ipcMain.handle("dialog:selectFolder", async () => { - const result = await dialog.showOpenDialog(mainWindow!, { - title: "Select Project Folder", - properties: ["openDirectory"], - }) - - if (result.canceled || !result.filePaths.length) { - return null - } - - return result.filePaths[0] - }) - - ipcMain.handle( - "instance:create", - async (event, id: string, folder: string, binaryPath?: string, environmentVariables?: Record) => { - const instance: Instance = { - id, - folder, - port: 0, - pid: 0, - status: "starting", - } - - instances.set(id, instance) - - try { - const { - pid, - port, - binaryPath: actualBinaryPath, - } = await processManager.spawn(folder, id, binaryPath, environmentVariables) - - instance.port = port - instance.pid = pid - instance.status = "ready" - - mainWindow.webContents.send("instance:started", { id, port, pid, binaryPath: actualBinaryPath }) - - const meta = processManager.getAllProcesses().get(pid) - if (meta) { - meta.childProcess.on("exit", (code, signal) => { - instance.status = "stopped" - mainWindow.webContents.send("instance:stopped", { id }) - }) - } - - return { id, port, pid, binaryPath: actualBinaryPath } - } catch (error) { - instance.status = "error" - instance.error = error instanceof Error ? error.message : String(error) - - mainWindow.webContents.send("instance:error", { - id, - error: instance.error, - }) - - throw error - } - }, - ) - - ipcMain.handle("instance:stop", async (event, pid: number) => { - await processManager.kill(pid) - - for (const [id, instance] of instances.entries()) { - if (instance.pid === pid) { - instance.status = "stopped" - break - } - } - }) - - ipcMain.handle("instance:status", async (event, pid: number) => { - return processManager.getStatus(pid) - }) - - ipcMain.handle("instance:list", async () => { - return Array.from(instances.values()) - }) - - ipcMain.handle("fs:scanDirectory", async (event, workspaceFolder: string) => { - const ig = ignore() - ig.add([".git", "node_modules"]) - - const gitignorePath = path.join(workspaceFolder, ".gitignore") - if (fs.existsSync(gitignorePath)) { - const content = fs.readFileSync(gitignorePath, "utf-8") - ig.add(content) - } - - function scanDir(dirPath: string, baseDir: string): string[] { - const results: string[] = [] - - try { - const entries = fs.readdirSync(dirPath, { withFileTypes: true }) - - for (const entry of entries) { - const fullPath = path.join(dirPath, entry.name) - const relativePath = path.relative(baseDir, fullPath) - - if (ig.ignores(relativePath)) { - continue - } - - if (entry.isDirectory()) { - const dirWithSlash = relativePath + "/" - if (!ig.ignores(dirWithSlash)) { - results.push(dirWithSlash) - const subFiles = scanDir(fullPath, baseDir) - results.push(...subFiles) - } - } else { - results.push(relativePath) - } - } - } catch (error) { - console.warn(`Error scanning ${dirPath}:`, error) - } - - return results - } - - return scanDir(workspaceFolder, workspaceFolder) - }) - - // OpenCode binary operations - ipcMain.handle("dialog:selectOpenCodeBinary", async () => { - const result = await dialog.showOpenDialog(mainWindow!, { - title: "Select OpenCode Binary", - filters: [ - { name: "Executable Files", extensions: ["exe", "cmd", "bat", "sh", "command", "app", ""] }, - { name: "All Files", extensions: ["*"] }, - ], - properties: ["openFile"], - }) - - if (result.canceled || !result.filePaths.length) { - return null - } - - return result.filePaths[0] - }) - - ipcMain.handle("opencode:validateBinary", async (event, binaryPath: string) => { - try { - // Special handling for system PATH binary - const isSystemPath = binaryPath === "opencode" - - if (!isSystemPath) { - // Check if file exists and is executable for custom paths - if (!fs.existsSync(binaryPath)) { - return { valid: false, error: "File does not exist" } - } - - const stats = fs.statSync(binaryPath) - if (!stats.isFile()) { - return { valid: false, error: "Path is not a file" } - } - } - - // Try to get version once via -v flag - try { - const version = await runBinaryVersion(binaryPath) - return { valid: true, version } - } catch (error) { - return { - valid: false, - error: error instanceof Error ? error.message : String(error), - } - } - } catch (error) { - return { - valid: false, - error: error instanceof Error ? error.message : String(error), - } - } - }) +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("log", (entry: CliLogEntry) => { + if (!mainWindow.isDestroyed()) { + mainWindow.webContents.send("cli:log", entry) + } + }) + + cliManager.on("error", (error: Error) => { + if (!mainWindow.isDestroyed()) { + mainWindow.webContents.send("cli:error", { message: error.message }) + } + }) + + ipcMain.handle("cli:getStatus", async () => cliManager.getStatus()) } diff --git a/packages/electron-app/electron/main/main.ts b/packages/electron-app/electron/main/main.ts index 145f0f50..e106e914 100644 --- a/packages/electron-app/electron/main/main.ts +++ b/packages/electron-app/electron/main/main.ts @@ -1,30 +1,39 @@ -import { app, BrowserWindow, dialog, ipcMain, nativeImage, nativeTheme, session } from "electron" -import { join } from "path" +import { app, BrowserWindow, nativeImage, session } from "electron" +import { dirname, join } from "path" +import { fileURLToPath } from "url" import { createApplicationMenu } from "./menu" -import { setupInstanceIPC } from "./ipc" -import { setupStorageIPC } from "./storage" +import { setupCliIPC } from "./ipc" +import { CliProcessManager } from "./process-manager" + +const __filename = fileURLToPath(import.meta.url) +const __dirname = dirname(__filename) const isMac = process.platform === "darwin" +const cliManager = new CliProcessManager() +let mainWindow: BrowserWindow | null = null if (isMac) { app.commandLine.appendSwitch("disable-spell-checking") } -// Setup IPC handlers before creating windows -setupStorageIPC() - -let mainWindow: BrowserWindow | null = null - function getIconPath() { if (app.isPackaged) { return join(process.resourcesPath, "icon.png") } - return join(app.getAppPath(), "electron/resources/icon.png") + return join(__dirname, "../resources/icon.png") +} + +function getLoadingHtmlPath() { + if (app.isPackaged) { + return join(process.resourcesPath, "loading.html") + } + + return join(__dirname, "../resources/loading.html") } function createWindow() { - const prefersDark = true //nativeTheme.shouldUseDarkColors + const prefersDark = true const backgroundColor = prefersDark ? "#1a1a1a" : "#ffffff" const iconPath = getIconPath() @@ -36,7 +45,7 @@ function createWindow() { backgroundColor, icon: iconPath, webPreferences: { - preload: join(__dirname, "../preload/index.js"), + preload: join(__dirname, "../preload/index.cjs"), contextIsolation: true, nodeIntegration: false, spellcheck: !isMac, @@ -44,25 +53,45 @@ function createWindow() { }) if (isMac) { - // Disable macOS spell server to avoid input lag mainWindow.webContents.session.setSpellCheckerEnabled(false) } + const loadingHtml = getLoadingHtmlPath() + mainWindow.loadFile(loadingHtml) + if (process.env.NODE_ENV === "development") { - mainWindow.loadURL("http://localhost:3000") - mainWindow.webContents.openDevTools() - } else { - mainWindow.loadFile(join(__dirname, "../renderer/index.html")) + mainWindow.webContents.openDevTools({ mode: "detach" }) } createApplicationMenu(mainWindow) - setupInstanceIPC(mainWindow) + setupCliIPC(mainWindow, cliManager) mainWindow.on("closed", () => { mainWindow = null }) } +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 && mainWindow && !mainWindow.isDestroyed()) { + console.info(`[cli] navigating main window to ${status.url}`) + mainWindow.loadURL(status.url) + } +}) + if (isMac) { app.on("web-contents-created", (_, contents) => { contents.session.setSpellCheckerEnabled(false) @@ -70,6 +99,8 @@ if (isMac) { } app.whenReady().then(() => { + startCli() + if (isMac) { session.defaultSession.setSpellCheckerEnabled(false) app.on("browser-window-created", (_, window) => { @@ -84,8 +115,6 @@ app.whenReady().then(() => { } } - console.log("[spellcheck] default session enabled:", session.defaultSession.isSpellCheckerEnabled()) - createWindow() app.on("activate", () => { @@ -95,6 +124,12 @@ app.whenReady().then(() => { }) }) +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() diff --git a/packages/electron-app/electron/main/process-manager.ts b/packages/electron-app/electron/main/process-manager.ts index f5f80f90..452d28d3 100644 --- a/packages/electron-app/electron/main/process-manager.ts +++ b/packages/electron-app/electron/main/process-manager.ts @@ -1,218 +1,151 @@ -import { spawn, execSync, ChildProcess } from "child_process" -import { app, BrowserWindow } from "electron" -import { existsSync, statSync } from "fs" -import { buildUserShellCommand, getUserShellEnv, runUserShellCommandSync, supportsUserShell } from "./user-shell" +import { spawn, type ChildProcess } from "child_process" +import { app } from "electron" +import { createRequire } from "module" +import { EventEmitter } from "events" +import { existsSync } from "fs" +import path from "path" +import { buildUserShellCommand, getUserShellEnv, supportsUserShell } from "./user-shell" -export interface ProcessInfo { - pid: number - port: number - binaryPath: string +const require = createRequire(import.meta.url) + +type CliState = "starting" | "ready" | "error" | "stopped" + +export interface CliStatus { + state: CliState + pid?: number + port?: number + url?: string + error?: string } -interface ProcessMeta { - pid: number - port: number - folder: string - startTime: number - childProcess: ChildProcess - logs: string[] - instanceId: string +export interface CliLogEntry { + stream: "stdout" | "stderr" + message: string } -class ProcessManager { - private processes = new Map() - private mainWindow: BrowserWindow | null = null +interface StartOptions { + dev: boolean +} - setMainWindow(window: BrowserWindow) { - this.mainWindow = window - } +interface CliEntryResolution { + entry: string + runner: "node" | "tsx" + runnerPath?: string +} - private parseLogLevel(message: string): "info" | "error" | "warn" | "debug" { - const upperMessage = message.toUpperCase() - if (upperMessage.includes("[ERROR]") || upperMessage.includes("ERROR:")) return "error" - if (upperMessage.includes("[WARN]") || upperMessage.includes("WARN:")) return "warn" - if (upperMessage.includes("[DEBUG]") || upperMessage.includes("DEBUG:")) return "debug" - if (upperMessage.includes("[INFO]") || upperMessage.includes("INFO:")) return "info" - return "info" - } +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 +} - private sendLog(instanceId: string, level: "info" | "error" | "warn" | "debug", message: string) { - if (this.mainWindow && message.trim()) { - const parsedLevel = this.parseLogLevel(message) - this.mainWindow.webContents.send("instance:log", { - id: instanceId, - entry: { - timestamp: Date.now(), - level: parsedLevel, - message: message.trim(), - }, - }) - } - } +export class CliProcessManager extends EventEmitter { + private child?: ChildProcess + private status: CliStatus = { state: "stopped" } + private stdoutBuffer = "" + private stderrBuffer = "" - async spawn( - folder: string, - instanceId: string, - binaryPath?: string, - environmentVariables?: Record, - ): Promise { - this.validateFolder(folder) - const useUserShell = supportsUserShell() - const logAttempt = (message: string) => { - console.info(`[ProcessManager] ${message}`) - this.sendLog(instanceId, "debug", message) + async start(options: StartOptions): Promise { + if (this.child) { + await this.stop() } - const env = useUserShell ? getUserShellEnv() : { ...process.env } - if (environmentVariables) { - Object.assign(env, environmentVariables) - this.sendLog( - instanceId, - "info", - `Using ${Object.keys(environmentVariables).length} custom environment variables:`, - ) + this.stdoutBuffer = "" + this.stderrBuffer = "" + this.updateStatus({ state: "starting", port: undefined, pid: undefined, url: undefined, error: undefined }) - // Log each environment variable - for (const [key, value] of Object.entries(environmentVariables)) { - this.sendLog(instanceId, "info", ` ${key}=${value}`) - } - } + const cliEntry = this.resolveCliEntry(options) + const args = this.buildCliArgs(options) - let targetBinary: string - if (!binaryPath || binaryPath === "opencode") { - targetBinary = useUserShell ? "opencode" : this.validateOpenCodeBinary(logAttempt) - } else { - targetBinary = this.validateCustomBinary(binaryPath, logAttempt) - } - - const spawnCommand = useUserShell - ? this.buildShellServeCommand(targetBinary) - : { command: targetBinary, args: this.buildServeArgs() } - - const launchDetail = `${spawnCommand.command} ${spawnCommand.args.join(" ")}`.trim() - this.sendLog(instanceId, "debug", `Launching process with: ${launchDetail}`) - - this.sendLog( - instanceId, - "info", - `Starting OpenCode server for ${folder} using ${targetBinary}...`, + console.info( + `[cli] launching CodeNomad CLI (${options.dev ? "dev" : "prod"}) using ${cliEntry.runner} at ${cliEntry.entry}`, ) - return new Promise((resolve, reject) => { - const child = spawn(spawnCommand.command, spawnCommand.args, { - cwd: folder, - stdio: ["ignore", "pipe", "pipe"], - env, - shell: false, - }) + 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((resolve, reject) => { const timeout = setTimeout(() => { - child.kill("SIGKILL") - this.sendLog(instanceId, "error", "Server startup timeout (10s exceeded)") - reject(new Error("Server startup timeout (10s exceeded)")) - }, 10000) + this.handleTimeout() + reject(new Error("CLI startup timeout")) + }, 15000) - let stdoutBuffer = "" - let stderrBuffer = "" - let portFound = false - - 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) { - if (!line.trim()) continue - - this.sendLog(instanceId, "info", line) - - const portMatch = line.match(/opencode server listening on http:\/\/[^:]+:(\d+)/) - if (portMatch && !portFound) { - portFound = true - const port = parseInt(portMatch[1], 10) - clearTimeout(timeout) - - const meta: ProcessMeta = { - pid: child.pid!, - port, - folder, - startTime: Date.now(), - childProcess: child, - logs: [line], - instanceId, - } - - this.processes.set(child.pid!, meta) - resolve({ pid: child.pid!, port, binaryPath: targetBinary }) - } - - const meta = this.processes.get(child.pid!) - if (meta) { - meta.logs.push(line) - } - } - }) - - 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) { - if (!line.trim()) continue - - this.sendLog(instanceId, "error", line) - - const meta = this.processes.get(child.pid!) - if (meta) { - meta.logs.push(line) - } - } - }) - - child.on("error", (error) => { + this.once("ready", (status) => { clearTimeout(timeout) - if (error.message.includes("ENOENT")) { - reject(new Error("opencode binary not found in PATH")) - } else { - reject(error) - } + resolve(status) }) - child.on("exit", (code, signal) => { + this.once("error", (error) => { clearTimeout(timeout) - this.processes.delete(child.pid!) - - if (!portFound) { - const errorMsg = stderrBuffer || `Process exited with code ${code}` - reject(new Error(errorMsg)) - } + reject(error) }) }) } - async kill(pid: number): Promise { - const meta = this.processes.get(pid) - if (!meta) { - // Treat unknown processes as already stopped so tabs close cleanly + async stop(): Promise { + const child = this.child + if (!child) { + this.updateStatus({ state: "stopped" }) return } - return new Promise((resolve, reject) => { - const child = meta.childProcess - + return new Promise((resolve) => { const killTimeout = setTimeout(() => { child.kill("SIGKILL") - }, 2000) + }, 4000) child.on("exit", () => { clearTimeout(killTimeout) - this.processes.delete(pid) + this.child = undefined + console.info("[cli] CLI process exited") + this.updateStatus({ state: "stopped" }) resolve() }) @@ -220,134 +153,167 @@ class ProcessManager { }) } - getStatus(pid: number): "running" | "stopped" | "unknown" { - if (!this.processes.has(pid)) { - return "unknown" - } + getStatus(): CliStatus { + return { ...this.status } + } - try { - process.kill(pid, 0) - return "running" - } catch { - return "stopped" + 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") } } - getAllProcesses(): Map { - return new Map(this.processes) - } + private processBuffer(stream: "stdout" | "stderr") { + const buffer = stream === "stdout" ? this.stdoutBuffer : this.stderrBuffer + const lines = buffer.split("\n") + const trailing = lines.pop() ?? "" - async cleanup(): Promise { - const killPromises = Array.from(this.processes.keys()).map((pid) => this.kill(pid).catch(() => {})) - await Promise.all(killPromises) - } - - private validateFolder(folder: string): void { - if (!existsSync(folder)) { - throw new Error(`Folder does not exist: ${folder}`) + if (stream === "stdout") { + this.stdoutBuffer = trailing + } else { + this.stderrBuffer = trailing } - const stats = statSync(folder) - if (!stats.isDirectory()) { - throw new Error(`Path is not a directory: ${folder}`) - } - } + for (const line of lines) { + if (!line.trim()) continue + console.info(`[cli][${stream}] ${line}`) + this.emit("log", { stream, message: line }) - private validateOpenCodeBinary(logAttempt?: (message: string) => void): string { - const log = logAttempt ?? ((message: string) => console.info(`[ProcessManager] ${message}`)) - - if (process.platform === "win32") { - log("Checking PATH via 'where opencode'") - return this.resolveBinaryViaLocator("where opencode", log) - } - - const shellCheck = buildUserShellCommand("command -v opencode") - const shellPreview = [shellCheck.command, ...shellCheck.args].join(" ") - log(`Checking PATH via shell: ${shellPreview}`) - - try { - const resolved = runUserShellCommandSync("command -v opencode") - const path = this.pickFirstPath(resolved) - if (path) { - log(`Shell located opencode at ${path}`) - return path - } - throw new Error("Empty result from shell lookup") - } catch (shellError) { - const message = shellError instanceof Error ? shellError.message : String(shellError) - log(`Shell lookup failed: ${message}`) - try { - log("Fallback to 'which opencode'") - return this.resolveBinaryViaLocator("which opencode", log) - } catch (locatorError) { - const locatorMessage = locatorError instanceof Error ? locatorError.message : String(locatorError) - log(`Locator fallback failed: ${locatorMessage}`) - throw new Error( - "opencode binary not found in PATH. Please install OpenCode CLI first: npm install -g @opencode/cli", - ) + 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 validateCustomBinary(binaryPath: string, log?: (message: string) => void): string { - log?.(`Validating custom binary at ${binaryPath}`) - - if (!existsSync(binaryPath)) { - throw new Error(`OpenCode binary not found: ${binaryPath}`) + 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) } - const stats = statSync(binaryPath) - if (!stats.isFile()) { - throw new Error(`Path is not a file: ${binaryPath}`) - } - - // Check if executable (on Unix systems) - if (process.platform !== "win32") { + if (line.toLowerCase().includes("http server listening")) { + const httpMatch = line.match(/:(\d{2,5})(?!.*:\d)/) + if (httpMatch) { + return parseInt(httpMatch[1], 10) + } try { - execSync(`test -x "${binaryPath}"`, { stdio: "pipe" }) + const parsed = JSON.parse(line) + if (typeof parsed.port === "number") { + return parsed.port + } } catch { - throw new Error(`Binary is not executable: ${binaryPath}`) + // not JSON, ignore } } - return binaryPath + return null } - private resolveBinaryViaLocator(command: string, log?: (message: string) => void): string { - log?.(`Running locator command: ${command}`) - const output = execSync(command, { stdio: "pipe", encoding: "utf-8" }) - log?.(`Locator output: ${output.trim() || ""}`) - const path = this.pickFirstPath(output) - if (!path) { - throw new Error("opencode binary not found in PATH") + private updateStatus(patch: Partial) { + this.status = { ...this.status, ...patch } + this.emit("status", this.status) + } + + private buildCliArgs(options: StartOptions): string[] { + const args = ["serve", "--host", "127.0.0.1", "--port", "0"] + + if (options.dev) { + args.push("--ui-dev-server", "http://localhost:3000", "--log-level", "debug") } - return path + + return args } - private pickFirstPath(output: string): string | null { - const line = output - .split("\n") - .map((entry) => entry.trim()) - .find((entry) => entry.length > 0) - return line ?? null + 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 buildServeArgs(): string[] { - return ["serve", "--port", "0", "--print-logs", "--log-level", "DEBUG"] + 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 buildShellServeCommand(binaryPath: string): { command: string; args: string[] } { - const args = this.buildServeArgs() - .map((arg) => JSON.stringify(arg)) - .join(" ") - return buildUserShellCommand(`exec ${JSON.stringify(binaryPath)} ${args}`) + private resolveCliEntry(options: StartOptions): CliEntryResolution { + if (options.dev) { + const tsxPath = this.resolveTsx() + const sourceCandidates = [ + path.resolve(app.getAppPath(), "..", "cli", "src", "index.ts"), + path.resolve(app.getAppPath(), "..", "packages", "cli", "src", "index.ts"), + path.resolve(process.cwd(), "packages", "cli", "src", "index.ts"), + ] + const sourceEntry = sourceCandidates.find((candidate) => existsSync(candidate)) + if (tsxPath && sourceEntry) { + return { entry: sourceEntry, runner: "tsx", runnerPath: tsxPath } + } + } + + const dist = this.tryResolveDist() + if (dist) { + return { entry: dist, runner: "node" } + } + + throw new Error("Unable to locate CodeNomad CLI build (dist/bin.js). Please build @codenomad/cli.") + } + + private resolveTsx(): string | null { + try { + const resolved = require.resolve("tsx/dist/cli.js") + if (resolved && existsSync(resolved)) { + return resolved + } + } catch { + return null + } + return null + } + + private tryResolveDist(): string | null { + const candidates: Array string)> = [ + () => require.resolve("@codenomad/cli/dist/bin.js"), + () => require.resolve("@codenomad/cli/dist/bin.js", { paths: [app.getAppPath()] }), + path.join(app.getAppPath(), "node_modules", "@codenomad", "cli", "dist", "bin.js"), + path.resolve(app.getAppPath(), "..", "cli", "dist", "bin.js"), + path.resolve(app.getAppPath(), "..", "packages", "cli", "dist", "bin.js"), + path.join(process.resourcesPath, "app.asar.unpacked", "node_modules", "@codenomad", "cli", "dist", "bin.js"), + ] + + for (const candidate of candidates) { + try { + const resolved = typeof candidate === "function" ? candidate() : candidate + if (resolved && existsSync(resolved)) { + return resolved + } + } catch { + continue + } + } + + return null } } - -export const processManager = new ProcessManager() - -app.on("before-quit", async (event) => { - event.preventDefault() - await processManager.cleanup() - app.exit(0) -}) diff --git a/packages/electron-app/electron/preload/index.cjs b/packages/electron-app/electron/preload/index.cjs new file mode 100644 index 00000000..8e7580eb --- /dev/null +++ b/packages/electron-app/electron/preload/index.cjs @@ -0,0 +1,19 @@ +const { contextBridge, ipcRenderer } = require("electron") + +const electronAPI = { + onCliStatus: (callback) => { + ipcRenderer.on("cli:status", (_, data) => callback(data)) + return () => ipcRenderer.removeAllListeners("cli:status") + }, + onCliLog: (callback) => { + ipcRenderer.on("cli:log", (_, data) => callback(data)) + return () => ipcRenderer.removeAllListeners("cli:log") + }, + onCliError: (callback) => { + ipcRenderer.on("cli:error", (_, data) => callback(data)) + return () => ipcRenderer.removeAllListeners("cli:error") + }, + getCliStatus: () => ipcRenderer.invoke("cli:getStatus"), +} + +contextBridge.exposeInMainWorld("electronAPI", electronAPI) diff --git a/packages/electron-app/electron/preload/index.ts b/packages/electron-app/electron/preload/index.ts deleted file mode 100644 index f430cbd3..00000000 --- a/packages/electron-app/electron/preload/index.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { contextBridge, ipcRenderer } from "electron" -import type { ElectronAPI } from "../../../ui/src/types/electron-api" - -const electronAPI: ElectronAPI = { - selectFolder: () => ipcRenderer.invoke("dialog:selectFolder"), - createInstance: (id: string, folder: string, binaryPath?: string, environmentVariables?: Record) => - ipcRenderer.invoke("instance:create", id, folder, binaryPath, environmentVariables), - stopInstance: (pid: number) => ipcRenderer.invoke("instance:stop", pid), - onInstanceStarted: (callback) => { - ipcRenderer.on("instance:started", (_, data) => callback(data)) - }, - onInstanceError: (callback) => { - ipcRenderer.on("instance:error", (_, data) => callback(data)) - }, - onInstanceStopped: (callback) => { - ipcRenderer.on("instance:stopped", (_, data) => callback(data)) - }, - onInstanceLog: (callback) => { - ipcRenderer.on("instance:log", (_, data) => callback(data)) - }, - onNewInstance: (callback) => { - ipcRenderer.on("menu:newInstance", () => callback()) - }, - scanDirectory: (workspaceFolder: string) => ipcRenderer.invoke("fs:scanDirectory", workspaceFolder), - // OpenCode binary operations - selectOpenCodeBinary: () => ipcRenderer.invoke("dialog:selectOpenCodeBinary"), - validateOpenCodeBinary: (path: string) => ipcRenderer.invoke("opencode:validateBinary", path), - // Storage operations - getConfigPath: () => ipcRenderer.invoke("storage:getConfigPath"), - getInstancesDir: () => ipcRenderer.invoke("storage:getInstancesDir"), - readConfigFile: () => ipcRenderer.invoke("storage:readConfigFile"), - writeConfigFile: (content: string) => ipcRenderer.invoke("storage:writeConfigFile", content), - readInstanceFile: (filename: string) => ipcRenderer.invoke("storage:readInstanceFile", filename), - writeInstanceFile: (filename: string, content: string) => - ipcRenderer.invoke("storage:writeInstanceFile", filename, content), - deleteInstanceFile: (filename: string) => ipcRenderer.invoke("storage:deleteInstanceFile", filename), - onConfigChanged: (callback: () => void) => { - ipcRenderer.on("storage:configChanged", () => callback()) - return () => ipcRenderer.removeAllListeners("storage:configChanged") - }, -} - -contextBridge.exposeInMainWorld("electronAPI", electronAPI) - -declare global { - interface Window { - electronAPI: ElectronAPI - } -} diff --git a/packages/electron-app/electron/resources/loading.html b/packages/electron-app/electron/resources/loading.html new file mode 100644 index 00000000..a8582a87 --- /dev/null +++ b/packages/electron-app/electron/resources/loading.html @@ -0,0 +1,206 @@ + + + + + + CodeNomad + + + +
+ +
+

CodeNomad

+
+
+
+ + Warming up the AI neurons… +
+
+ +
+
+
+ + + diff --git a/packages/electron-app/package.json b/packages/electron-app/package.json index 23263821..2086e7aa 100644 --- a/packages/electron-app/package.json +++ b/packages/electron-app/package.json @@ -10,7 +10,7 @@ "main": "dist/main/main.js", "scripts": { "dev": "electron-vite dev", - "dev:electron": "NODE_ENV=development electron .", + "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", @@ -29,8 +29,8 @@ "package:linux": "electron-builder --linux" }, "dependencies": { - "@codenomad/ui": "file:../ui", - "ignore": "7.0.5" + "@codenomad/cli": "file:../cli", + "@codenomad/ui": "file:../ui" }, "devDependencies": { "7zip-bin": "^5.2.0", @@ -40,6 +40,7 @@ "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" diff --git a/packages/ui/src/components/tool-call.tsx b/packages/ui/src/components/tool-call.tsx index 0c95e4af..09533f3e 100644 --- a/packages/ui/src/components/tool-call.tsx +++ b/packages/ui/src/components/tool-call.tsx @@ -557,24 +557,73 @@ export default function ToolCall(props: ToolCallProps) { } } - const getTodoTitle = () => { - const state = props.toolCall?.state || {} - if (state.status !== "completed") return "Plan" + type TodoViewStatus = "pending" | "in_progress" | "completed" | "cancelled" - const metadata = state.metadata || {} - const todos = metadata.todos || [] + interface TodoViewItem { + id: string + content: string + status: TodoViewStatus + } - if (!Array.isArray(todos) || todos.length === 0) return "Plan" + function normalizeTodoStatus(rawStatus: unknown): TodoViewStatus { + if (rawStatus === "completed" || rawStatus === "in_progress" || rawStatus === "cancelled") return rawStatus + return "pending" + } - const counts = { pending: 0, completed: 0 } - for (const todo of todos) { - const status = todo.status || "pending" - if (status in counts) counts[status as keyof typeof counts]++ + function extractTodosFromState(state: ToolState | undefined): TodoViewItem[] { + if (!state) return [] + const metadata = (isToolStateRunning(state) || isToolStateCompleted(state) || isToolStateError(state)) + ? state.metadata || {} + : {} + const todos = Array.isArray((metadata as any).todos) ? (metadata as any).todos : [] + const items: TodoViewItem[] = [] + + for (let index = 0; index < todos.length; index++) { + const todo = todos[index] + const content = typeof todo?.content === "string" ? todo.content.trim() : "" + if (!content) continue + const status = normalizeTodoStatus((todo as any).status) + const id = typeof todo?.id === "string" && todo.id.length > 0 ? todo.id : `${index}-${content}` + items.push({ id, content, status }) } - const total = todos.length - if (counts.pending === total) return "Creating plan" - if (counts.completed === total) return "Completing plan" + return items + } + + function summarizeTodos(todos: TodoViewItem[]) { + return todos.reduce( + (acc, todo) => { + acc.total += 1 + acc[todo.status] = (acc[todo.status] || 0) + 1 + return acc + }, + { total: 0, pending: 0, in_progress: 0, completed: 0, cancelled: 0 } as Record, + ) + } + + function getTodoStatusLabel(status: TodoViewStatus): string { + switch (status) { + case "completed": + return "Completed" + case "in_progress": + return "In progress" + case "cancelled": + return "Cancelled" + default: + return "Pending" + } + } + + const getTodoTitle = () => { + const state = props.toolCall?.state + if (!state) return "Plan" + + const todos = extractTodosFromState(state) + if (state.status !== "completed" || todos.length === 0) return "Plan" + + const counts = summarizeTodos(todos) + if (counts.pending === counts.total) return "Creating plan" + if (counts.completed === counts.total) return "Completing plan" return "Updating plan" } @@ -639,7 +688,7 @@ export default function ToolCall(props: ToolCallProps) { return getTodoTitle() case "todoread": - return "Plan" + return getTodoTitle() case "invalid": if (typeof input.tool === "string") { @@ -656,18 +705,14 @@ export default function ToolCall(props: ToolCallProps) { const toolName = props.toolCall?.tool || "" const state = props.toolCall?.state || {} - if (toolName === "todoread") { - return null + if (toolName === "todoread" || toolName === "todowrite") { + return renderTodoTool() } if (state.status === "pending") { return null } - if (toolName === "todowrite") { - return renderTodowriteTool() - } - if (toolName === "task") { return renderTaskTool() } @@ -938,65 +983,65 @@ export default function ToolCall(props: ToolCallProps) { return null } - const renderTodowriteTool = () => { + const renderTodoTool = () => { const state = props.toolCall?.state if (!state) return null - - const metadata = (isToolStateRunning(state) || isToolStateCompleted(state) || isToolStateError(state)) - ? state.metadata || {} - : {} - const todos = metadata.todos || [] - if (!Array.isArray(todos) || todos.length === 0) { - return null + const todos = extractTodosFromState(state) + const counts = summarizeTodos(todos) + + if (counts.total === 0) { + return
No plan items yet.
} - const getStatusLabel = (status: string): string => { - switch (status) { - case "completed": - return "Completed" - case "in_progress": - return "In progress" - case "cancelled": - return "Cancelled" - default: - return "Pending" - } - } - - const shouldShowTag = (status: string) => status === "cancelled" + const completionPercent = Math.round((counts.completed / counts.total) * 100) return ( -
- - {(todo) => { - const content = typeof todo.content === "string" ? todo.content.trim() : "" - if (!content) return null +
+
+
+ {counts.completed} done + {counts.in_progress} in progress + {counts.pending} pending +
+
+
+
+
+
+ + {(todo) => { + const label = getTodoStatusLabel(todo.status) - const status = typeof todo.status === "string" ? todo.status : "pending" - const label = getStatusLabel(status) - - return ( -
- -
- {content} - - {label} - + return ( +
+ +
+
+ {todo.content} + {label} +
+
-
- ) - }} - + ) + }} + +
) } diff --git a/packages/ui/src/styles/messaging/tool-call.css b/packages/ui/src/styles/messaging/tool-call.css index 08014d7a..8d301477 100644 --- a/packages/ui/src/styles/messaging/tool-call.css +++ b/packages/ui/src/styles/messaging/tool-call.css @@ -645,6 +645,52 @@ font-size: inherit; } +.tool-call-todo-region { + @apply flex flex-col gap-3; +} + +.tool-call-todo-summary { + @apply flex flex-col gap-2; + background-color: var(--surface-base); + border: 1px solid var(--border-base); + border-radius: 10px; + padding: 10px 12px; +} + +.tool-call-todo-metrics { + @apply flex flex-wrap items-center gap-3; + font-size: var(--font-size-xs); + color: var(--text-secondary); +} + +.tool-call-todo-metric-value { + color: var(--text-primary); + font-weight: var(--font-weight-semibold); + margin-right: 4px; +} + +.tool-call-todo-progress { + position: relative; + height: 8px; + border-radius: 9999px; + background-color: var(--surface-secondary); + overflow: hidden; + border: 1px solid var(--border-base); +} + +.tool-call-todo-progress-bar { + position: absolute; + inset: 0; + height: 100%; + background: linear-gradient(90deg, var(--accent-primary), var(--accent-secondary)); + transition: width 0.2s ease; +} + +.tool-call-todo-empty { + @apply text-sm text-muted; + padding: 0.75rem 0; +} + .tool-call-todos { @apply my-2 flex flex-col gap-2; list-style: none; @@ -715,7 +761,37 @@ flex: 1; display: flex; flex-direction: column; - gap: 4px; + gap: 6px; +} + +.tool-call-todo-heading { + @apply flex items-start justify-between gap-3; +} + +.tool-call-todo-status { + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.08em; + border-radius: 9999px; + padding: 2px 8px; + background-color: var(--surface-hover); + color: var(--text-muted); + white-space: nowrap; +} + +.tool-call-todo-status-completed { + background-color: var(--badge-success-bg); + color: var(--status-success); +} + +.tool-call-todo-status-in_progress { + background-color: var(--badge-neutral-bg); + color: var(--text-primary); +} + +.tool-call-todo-status-cancelled { + background-color: var(--status-error-bg); + color: var(--status-error); } .tool-call-todo-text {