feat: make electron shell host CLI server

This commit is contained in:
Shantur Rathore
2025-11-20 10:41:07 +00:00
parent bc5423ce14
commit c5fd5694ee
12 changed files with 782 additions and 679 deletions

14
package-lock.json generated
View File

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

View File

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

View File

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

View File

@@ -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<string, Instance>()
function generateId(): string {
return randomBytes(16).toString("hex")
}
function runBinaryVersion(binaryPath: string, timeoutMs = 5000): Promise<string> {
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<string, string>) => {
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())
}

View File

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

View File

@@ -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<number, ProcessMeta>()
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<string, string>,
): Promise<ProcessInfo> {
this.validateFolder(folder)
const useUserShell = supportsUserShell()
const logAttempt = (message: string) => {
console.info(`[ProcessManager] ${message}`)
this.sendLog(instanceId, "debug", message)
async start(options: StartOptions): Promise<CliStatus> {
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<CliStatus>((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<void> {
const meta = this.processes.get(pid)
if (!meta) {
// Treat unknown processes as already stopped so tabs close cleanly
async stop(): Promise<void> {
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<number, ProcessMeta> {
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<void> {
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() || "<empty>"}`)
const path = this.pickFirstPath(output)
if (!path) {
throw new Error("opencode binary not found in PATH")
private updateStatus(patch: Partial<CliStatus>) {
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 | (() => 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)
})

View File

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

View File

@@ -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<string, string>) =>
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
}
}

View File

@@ -0,0 +1,206 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CodeNomad</title>
<style>
:root {
color-scheme: dark;
}
body {
margin: 0;
min-height: 100vh;
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background-color: #1a1a1a;
color: #cfd4dc;
display: flex;
align-items: center;
justify-content: center;
padding: 32px;
text-align: center;
}
button {
border: none;
background: none;
font: inherit;
color: inherit;
}
.wrapper {
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
max-width: 520px;
}
.logo {
width: 180px;
height: auto;
filter: drop-shadow(0 15px 40px rgba(0, 0, 0, 0.35));
}
.title {
font-size: 2.7rem;
font-weight: 600;
margin: 0;
color: #f4f6fb;
}
.loading-card {
margin-top: 12px;
width: 100%;
max-width: 420px;
padding: 22px;
border-radius: 18px;
background: #151a23;
border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.45);
}
.loading-row {
display: flex;
align-items: center;
justify-content: center;
gap: 14px;
font-size: 0.95rem;
color: #cfd4dc;
}
.spinner {
width: 18px;
height: 18px;
border-radius: 50%;
border: 2px solid rgba(255, 255, 255, 0.18);
border-top-color: #6ce3ff;
animation: spin 0.9s linear infinite;
}
.phrase-controls {
margin-top: 12px;
font-size: 0.9rem;
color: #8f96a9;
display: flex;
justify-content: center;
gap: 8px;
}
.phrase-controls button {
color: #8fb5ff;
cursor: pointer;
}
.logo {
width: 180px;
height: auto;
filter: drop-shadow(0 15px 40px rgba(0, 0, 0, 0.45));
}
.title {
font-size: 2.7rem;
font-weight: 600;
margin: 0;
color: #f4f6fb;
}
.loading-card {
margin-top: 12px;
width: 100%;
padding: 22px;
border-radius: 18px;
background: #0f1421;
border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow: 0 25px 60px rgba(5, 6, 10, 0.6);
}
.wrapper {
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
max-width: 520px;
}
.logo {
width: 180px;
height: auto;
}
.title {
font-size: 2.7rem;
font-weight: 600;
margin: 0;
}
.subtitle {
margin: 0;
font-size: 1.1rem;
color: #aeb3c4;
}
.loading-card {
margin-top: 12px;
width: 100%;
padding: 20px;
border-radius: 14px;
background: rgba(13, 16, 24, 0.8);
border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow: 0 25px 60px rgba(0, 0, 0, 0.45);
}
.loading-row {
display: flex;
align-items: center;
justify-content: center;
gap: 14px;
font-size: 0.95rem;
color: #cad0dd;
}
.spinner {
width: 18px;
height: 18px;
border-radius: 50%;
border: 2px solid rgba(255, 255, 255, 0.18);
border-top-color: #6ce3ff;
animation: spin 0.9s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
</style>
</head>
<body>
<div class="wrapper" role="status" aria-live="polite">
<img src="./icon.png" alt="CodeNomad" class="logo" />
<div>
<h1 class="title">CodeNomad</h1>
</div>
<div class="loading-card">
<div class="loading-row">
<div class="spinner" aria-hidden="true"></div>
<span id="loading-phrase">Warming up the AI neurons…</span>
</div>
<div class="phrase-controls">
<button id="phrase-toggle" type="button">Show another</button>
</div>
</div>
</div>
<script>
const phrases = [
"Warming up the AI neurons…",
"Convincing the AI to stop daydreaming…",
"Polishing the AIs code goggles…",
"Asking the AI to stop reorganizing your files…",
"Feeding the AI additional coffee…",
"Teaching the AI not to delete node_modules (again)…",
"Telling the AI to act natural before you arrive…",
"Asking the AI to please stop rewriting history…",
"Letting the AI stretch before its coding sprint…",
"Persuading the AI to give you keyboard control…"
]
const phraseEl = document.getElementById("loading-phrase")
const button = document.getElementById("phrase-toggle")
function pickPhrase() {
const next = phrases[Math.floor(Math.random() * phrases.length)]
phraseEl.textContent = next
}
pickPhrase()
button?.addEventListener("click", pickPhrase)
</script>
</body>
</html>

View File

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

View File

@@ -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<TodoViewStatus | "total", number>,
)
}
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 <div class="tool-call-todo-empty">No plan items yet.</div>
}
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 (
<div class="tool-call-todos" role="list">
<For each={todos}>
{(todo) => {
const content = typeof todo.content === "string" ? todo.content.trim() : ""
if (!content) return null
<div class="tool-call-todo-region">
<div class="tool-call-todo-summary">
<div class="tool-call-todo-metrics">
<span class="tool-call-todo-metric"><span class="tool-call-todo-metric-value">{counts.completed}</span> done</span>
<span class="tool-call-todo-metric"><span class="tool-call-todo-metric-value">{counts.in_progress}</span> in progress</span>
<span class="tool-call-todo-metric"><span class="tool-call-todo-metric-value">{counts.pending}</span> pending</span>
</div>
<div
class="tool-call-todo-progress"
role="progressbar"
aria-valuemin="0"
aria-valuemax={counts.total}
aria-valuenow={counts.completed}
aria-label="Plan progress"
>
<div class="tool-call-todo-progress-bar" style={{ width: `${completionPercent}%` }} />
</div>
</div>
<div class="tool-call-todos" role="list">
<For each={todos}>
{(todo) => {
const label = getTodoStatusLabel(todo.status)
const status = typeof todo.status === "string" ? todo.status : "pending"
const label = getStatusLabel(status)
return (
<div
class="tool-call-todo-item"
classList={{
"tool-call-todo-item-completed": status === "completed",
"tool-call-todo-item-cancelled": status === "cancelled",
"tool-call-todo-item-active": status === "in_progress",
}}
role="listitem"
>
<span class="tool-call-todo-checkbox" data-status={status} aria-label={label}></span>
<div class="tool-call-todo-body">
<span class="tool-call-todo-text">{content}</span>
<Show when={shouldShowTag(status)}>
<span class="tool-call-todo-tag">{label}</span>
</Show>
return (
<div
class="tool-call-todo-item"
classList={{
"tool-call-todo-item-completed": todo.status === "completed",
"tool-call-todo-item-cancelled": todo.status === "cancelled",
"tool-call-todo-item-active": todo.status === "in_progress",
}}
role="listitem"
>
<span class="tool-call-todo-checkbox" data-status={todo.status} aria-label={label}></span>
<div class="tool-call-todo-body">
<div class="tool-call-todo-heading">
<span class="tool-call-todo-text">{todo.content}</span>
<span class={`tool-call-todo-status tool-call-todo-status-${todo.status}`}>{label}</span>
</div>
</div>
</div>
</div>
)
}}
</For>
)
}}
</For>
</div>
</div>
)
}

View File

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