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" "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": { "node_modules/inflight": {
"version": "1.0.6", "version": "1.0.6",
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
@@ -8442,8 +8433,8 @@
"name": "@codenomad/electron-app", "name": "@codenomad/electron-app",
"version": "0.1.2", "version": "0.1.2",
"dependencies": { "dependencies": {
"@codenomad/ui": "file:../ui", "@codenomad/cli": "file:../cli",
"ignore": "7.0.5" "@codenomad/ui": "file:../ui"
}, },
"devDependencies": { "devDependencies": {
"7zip-bin": "^5.2.0", "7zip-bin": "^5.2.0",
@@ -8453,6 +8444,7 @@
"electron-vite": "4.0.1", "electron-vite": "4.0.1",
"png2icons": "^2.0.1", "png2icons": "^2.0.1",
"pngjs": "^7.0.0", "pngjs": "^7.0.0",
"tsx": "^4.20.6",
"typescript": "^5.3.0", "typescript": "^5.3.0",
"vite": "^5.0.0", "vite": "^5.0.0",
"vite-plugin-solid": "^2.10.0" "vite-plugin-solid": "^2.10.0"

View File

@@ -89,8 +89,8 @@ function parseCliOptions(argv: string[]): CliOptions {
function parsePort(input: string): number { function parsePort(input: string): number {
const value = Number(input) const value = Number(input)
if (!Number.isInteger(value) || value < 1 || value > 65535) { if (!Number.isInteger(value) || value < 0 || value > 65535) {
throw new InvalidArgumentError("Port must be an integer between 1 and 65535") throw new InvalidArgumentError("Port must be an integer between 0 and 65535")
} }
return value return value
} }

View File

@@ -79,7 +79,32 @@ export function createHttpServer(deps: HttpServerDeps) {
return { return {
instance: app, 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: () => { stop: () => {
closeSseClients() closeSseClients()
return app.close() return app.close()

View File

@@ -1,243 +1,30 @@
import { ipcMain, BrowserWindow, dialog } from "electron" import { BrowserWindow, ipcMain } from "electron"
import { processManager } from "./process-manager" import type { CliLogEntry, CliProcessManager, CliStatus } 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"
interface Instance { export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessManager) {
id: string cliManager.on("status", (status: CliStatus) => {
folder: string if (!mainWindow.isDestroyed()) {
port: number mainWindow.webContents.send("cli:status", status)
pid: number }
status: "starting" | "ready" | "error" | "stopped" })
error?: string
} cliManager.on("ready", (status: CliStatus) => {
if (!mainWindow.isDestroyed()) {
const instances = new Map<string, Instance>() mainWindow.webContents.send("cli:ready", status)
}
function generateId(): string { })
return randomBytes(16).toString("hex")
} cliManager.on("log", (entry: CliLogEntry) => {
if (!mainWindow.isDestroyed()) {
function runBinaryVersion(binaryPath: string, timeoutMs = 5000): Promise<string> { mainWindow.webContents.send("cli:log", entry)
return new Promise((resolve, reject) => { }
const child = spawn(binaryPath, ["-v"], { })
stdio: ["ignore", "pipe", "pipe"],
}) cliManager.on("error", (error: Error) => {
if (!mainWindow.isDestroyed()) {
let stdout = "" mainWindow.webContents.send("cli:error", { message: error.message })
let stderr = "" }
})
const timeout = setTimeout(() => {
child.kill("SIGTERM") ipcMain.handle("cli:getStatus", async () => cliManager.getStatus())
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),
}
}
})
} }

View File

@@ -1,30 +1,39 @@
import { app, BrowserWindow, dialog, ipcMain, nativeImage, nativeTheme, session } from "electron" import { app, BrowserWindow, nativeImage, session } from "electron"
import { join } from "path" import { dirname, join } from "path"
import { fileURLToPath } from "url"
import { createApplicationMenu } from "./menu" import { createApplicationMenu } from "./menu"
import { setupInstanceIPC } from "./ipc" import { setupCliIPC } from "./ipc"
import { setupStorageIPC } from "./storage" import { CliProcessManager } from "./process-manager"
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
const isMac = process.platform === "darwin" const isMac = process.platform === "darwin"
const cliManager = new CliProcessManager()
let mainWindow: BrowserWindow | null = null
if (isMac) { if (isMac) {
app.commandLine.appendSwitch("disable-spell-checking") app.commandLine.appendSwitch("disable-spell-checking")
} }
// Setup IPC handlers before creating windows
setupStorageIPC()
let mainWindow: BrowserWindow | null = null
function getIconPath() { function getIconPath() {
if (app.isPackaged) { if (app.isPackaged) {
return join(process.resourcesPath, "icon.png") 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() { function createWindow() {
const prefersDark = true //nativeTheme.shouldUseDarkColors const prefersDark = true
const backgroundColor = prefersDark ? "#1a1a1a" : "#ffffff" const backgroundColor = prefersDark ? "#1a1a1a" : "#ffffff"
const iconPath = getIconPath() const iconPath = getIconPath()
@@ -36,7 +45,7 @@ function createWindow() {
backgroundColor, backgroundColor,
icon: iconPath, icon: iconPath,
webPreferences: { webPreferences: {
preload: join(__dirname, "../preload/index.js"), preload: join(__dirname, "../preload/index.cjs"),
contextIsolation: true, contextIsolation: true,
nodeIntegration: false, nodeIntegration: false,
spellcheck: !isMac, spellcheck: !isMac,
@@ -44,25 +53,45 @@ function createWindow() {
}) })
if (isMac) { if (isMac) {
// Disable macOS spell server to avoid input lag
mainWindow.webContents.session.setSpellCheckerEnabled(false) mainWindow.webContents.session.setSpellCheckerEnabled(false)
} }
const loadingHtml = getLoadingHtmlPath()
mainWindow.loadFile(loadingHtml)
if (process.env.NODE_ENV === "development") { if (process.env.NODE_ENV === "development") {
mainWindow.loadURL("http://localhost:3000") mainWindow.webContents.openDevTools({ mode: "detach" })
mainWindow.webContents.openDevTools()
} else {
mainWindow.loadFile(join(__dirname, "../renderer/index.html"))
} }
createApplicationMenu(mainWindow) createApplicationMenu(mainWindow)
setupInstanceIPC(mainWindow) setupCliIPC(mainWindow, cliManager)
mainWindow.on("closed", () => { mainWindow.on("closed", () => {
mainWindow = null 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) { if (isMac) {
app.on("web-contents-created", (_, contents) => { app.on("web-contents-created", (_, contents) => {
contents.session.setSpellCheckerEnabled(false) contents.session.setSpellCheckerEnabled(false)
@@ -70,6 +99,8 @@ if (isMac) {
} }
app.whenReady().then(() => { app.whenReady().then(() => {
startCli()
if (isMac) { if (isMac) {
session.defaultSession.setSpellCheckerEnabled(false) session.defaultSession.setSpellCheckerEnabled(false)
app.on("browser-window-created", (_, window) => { app.on("browser-window-created", (_, window) => {
@@ -84,8 +115,6 @@ app.whenReady().then(() => {
} }
} }
console.log("[spellcheck] default session enabled:", session.defaultSession.isSpellCheckerEnabled())
createWindow() createWindow()
app.on("activate", () => { 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", () => { app.on("window-all-closed", () => {
if (process.platform !== "darwin") { if (process.platform !== "darwin") {
app.quit() app.quit()

View File

@@ -1,218 +1,151 @@
import { spawn, execSync, ChildProcess } from "child_process" import { spawn, type ChildProcess } from "child_process"
import { app, BrowserWindow } from "electron" import { app } from "electron"
import { existsSync, statSync } from "fs" import { createRequire } from "module"
import { buildUserShellCommand, getUserShellEnv, runUserShellCommandSync, supportsUserShell } from "./user-shell" import { EventEmitter } from "events"
import { existsSync } from "fs"
import path from "path"
import { buildUserShellCommand, getUserShellEnv, supportsUserShell } from "./user-shell"
export interface ProcessInfo { const require = createRequire(import.meta.url)
pid: number
port: number type CliState = "starting" | "ready" | "error" | "stopped"
binaryPath: string
export interface CliStatus {
state: CliState
pid?: number
port?: number
url?: string
error?: string
} }
interface ProcessMeta { export interface CliLogEntry {
pid: number stream: "stdout" | "stderr"
port: number message: string
folder: string
startTime: number
childProcess: ChildProcess
logs: string[]
instanceId: string
} }
class ProcessManager { interface StartOptions {
private processes = new Map<number, ProcessMeta>() dev: boolean
private mainWindow: BrowserWindow | null = null }
setMainWindow(window: BrowserWindow) { interface CliEntryResolution {
this.mainWindow = window entry: string
} runner: "node" | "tsx"
runnerPath?: string
}
private parseLogLevel(message: string): "info" | "error" | "warn" | "debug" { export declare interface CliProcessManager {
const upperMessage = message.toUpperCase() on(event: "status", listener: (status: CliStatus) => void): this
if (upperMessage.includes("[ERROR]") || upperMessage.includes("ERROR:")) return "error" on(event: "ready", listener: (status: CliStatus) => void): this
if (upperMessage.includes("[WARN]") || upperMessage.includes("WARN:")) return "warn" on(event: "log", listener: (entry: CliLogEntry) => void): this
if (upperMessage.includes("[DEBUG]") || upperMessage.includes("DEBUG:")) return "debug" on(event: "exit", listener: (status: CliStatus) => void): this
if (upperMessage.includes("[INFO]") || upperMessage.includes("INFO:")) return "info" on(event: "error", listener: (error: Error) => void): this
return "info" }
}
private sendLog(instanceId: string, level: "info" | "error" | "warn" | "debug", message: string) { export class CliProcessManager extends EventEmitter {
if (this.mainWindow && message.trim()) { private child?: ChildProcess
const parsedLevel = this.parseLogLevel(message) private status: CliStatus = { state: "stopped" }
this.mainWindow.webContents.send("instance:log", { private stdoutBuffer = ""
id: instanceId, private stderrBuffer = ""
entry: {
timestamp: Date.now(),
level: parsedLevel,
message: message.trim(),
},
})
}
}
async spawn( async start(options: StartOptions): Promise<CliStatus> {
folder: string, if (this.child) {
instanceId: string, await this.stop()
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)
} }
const env = useUserShell ? getUserShellEnv() : { ...process.env } this.stdoutBuffer = ""
if (environmentVariables) { this.stderrBuffer = ""
Object.assign(env, environmentVariables) this.updateStatus({ state: "starting", port: undefined, pid: undefined, url: undefined, error: undefined })
this.sendLog(
instanceId,
"info",
`Using ${Object.keys(environmentVariables).length} custom environment variables:`,
)
// Log each environment variable const cliEntry = this.resolveCliEntry(options)
for (const [key, value] of Object.entries(environmentVariables)) { const args = this.buildCliArgs(options)
this.sendLog(instanceId, "info", ` ${key}=${value}`)
}
}
let targetBinary: string console.info(
if (!binaryPath || binaryPath === "opencode") { `[cli] launching CodeNomad CLI (${options.dev ? "dev" : "prod"}) using ${cliEntry.runner} at ${cliEntry.entry}`,
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}...`,
) )
return new Promise((resolve, reject) => { const env = supportsUserShell() ? getUserShellEnv() : { ...process.env }
const child = spawn(spawnCommand.command, spawnCommand.args, { env.ELECTRON_RUN_AS_NODE = "1"
cwd: folder,
stdio: ["ignore", "pipe", "pipe"],
env,
shell: false,
})
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(() => { const timeout = setTimeout(() => {
child.kill("SIGKILL") this.handleTimeout()
this.sendLog(instanceId, "error", "Server startup timeout (10s exceeded)") reject(new Error("CLI startup timeout"))
reject(new Error("Server startup timeout (10s exceeded)")) }, 15000)
}, 10000)
let stdoutBuffer = "" this.once("ready", (status) => {
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) => {
clearTimeout(timeout) clearTimeout(timeout)
if (error.message.includes("ENOENT")) { resolve(status)
reject(new Error("opencode binary not found in PATH"))
} else {
reject(error)
}
}) })
child.on("exit", (code, signal) => { this.once("error", (error) => {
clearTimeout(timeout) clearTimeout(timeout)
this.processes.delete(child.pid!) reject(error)
if (!portFound) {
const errorMsg = stderrBuffer || `Process exited with code ${code}`
reject(new Error(errorMsg))
}
}) })
}) })
} }
async kill(pid: number): Promise<void> { async stop(): Promise<void> {
const meta = this.processes.get(pid) const child = this.child
if (!meta) { if (!child) {
// Treat unknown processes as already stopped so tabs close cleanly this.updateStatus({ state: "stopped" })
return return
} }
return new Promise((resolve, reject) => { return new Promise((resolve) => {
const child = meta.childProcess
const killTimeout = setTimeout(() => { const killTimeout = setTimeout(() => {
child.kill("SIGKILL") child.kill("SIGKILL")
}, 2000) }, 4000)
child.on("exit", () => { child.on("exit", () => {
clearTimeout(killTimeout) clearTimeout(killTimeout)
this.processes.delete(pid) this.child = undefined
console.info("[cli] CLI process exited")
this.updateStatus({ state: "stopped" })
resolve() resolve()
}) })
@@ -220,134 +153,167 @@ class ProcessManager {
}) })
} }
getStatus(pid: number): "running" | "stopped" | "unknown" { getStatus(): CliStatus {
if (!this.processes.has(pid)) { return { ...this.status }
return "unknown" }
}
try { private handleTimeout() {
process.kill(pid, 0) if (this.child) {
return "running" this.child.kill("SIGKILL")
} catch { this.child = undefined
return "stopped" }
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> { private processBuffer(stream: "stdout" | "stderr") {
return new Map(this.processes) const buffer = stream === "stdout" ? this.stdoutBuffer : this.stderrBuffer
} const lines = buffer.split("\n")
const trailing = lines.pop() ?? ""
async cleanup(): Promise<void> { if (stream === "stdout") {
const killPromises = Array.from(this.processes.keys()).map((pid) => this.kill(pid).catch(() => {})) this.stdoutBuffer = trailing
await Promise.all(killPromises) } else {
} this.stderrBuffer = trailing
private validateFolder(folder: string): void {
if (!existsSync(folder)) {
throw new Error(`Folder does not exist: ${folder}`)
} }
const stats = statSync(folder) for (const line of lines) {
if (!stats.isDirectory()) { if (!line.trim()) continue
throw new Error(`Path is not a directory: ${folder}`) console.info(`[cli][${stream}] ${line}`)
} this.emit("log", { stream, message: line })
}
private validateOpenCodeBinary(logAttempt?: (message: string) => void): string { const port = this.extractPort(line)
const log = logAttempt ?? ((message: string) => console.info(`[ProcessManager] ${message}`)) if (port && this.status.state === "starting") {
const url = `http://127.0.0.1:${port}`
if (process.platform === "win32") { console.info(`[cli] ready on ${url}`)
log("Checking PATH via 'where opencode'") this.updateStatus({ state: "ready", port, url })
return this.resolveBinaryViaLocator("where opencode", log) this.emit("ready", this.status)
}
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",
)
} }
} }
} }
private validateCustomBinary(binaryPath: string, log?: (message: string) => void): string { private extractPort(line: string): number | null {
log?.(`Validating custom binary at ${binaryPath}`) const readyMatch = line.match(/CodeNomad Server is ready at http:\/\/[^:]+:(\d+)/i)
if (readyMatch) {
if (!existsSync(binaryPath)) { return parseInt(readyMatch[1], 10)
throw new Error(`OpenCode binary not found: ${binaryPath}`)
} }
const stats = statSync(binaryPath) if (line.toLowerCase().includes("http server listening")) {
if (!stats.isFile()) { const httpMatch = line.match(/:(\d{2,5})(?!.*:\d)/)
throw new Error(`Path is not a file: ${binaryPath}`) if (httpMatch) {
} return parseInt(httpMatch[1], 10)
}
// Check if executable (on Unix systems)
if (process.platform !== "win32") {
try { try {
execSync(`test -x "${binaryPath}"`, { stdio: "pipe" }) const parsed = JSON.parse(line)
if (typeof parsed.port === "number") {
return parsed.port
}
} catch { } 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 { private updateStatus(patch: Partial<CliStatus>) {
log?.(`Running locator command: ${command}`) this.status = { ...this.status, ...patch }
const output = execSync(command, { stdio: "pipe", encoding: "utf-8" }) this.emit("status", this.status)
log?.(`Locator output: ${output.trim() || "<empty>"}`) }
const path = this.pickFirstPath(output)
if (!path) { private buildCliArgs(options: StartOptions): string[] {
throw new Error("opencode binary not found in PATH") 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 { private buildCommand(cliEntry: CliEntryResolution, args: string[]): string {
const line = output const parts = [JSON.stringify(process.execPath)]
.split("\n") if (cliEntry.runner === "tsx" && cliEntry.runnerPath) {
.map((entry) => entry.trim()) parts.push(JSON.stringify(cliEntry.runnerPath))
.find((entry) => entry.length > 0) }
return line ?? null parts.push(JSON.stringify(cliEntry.entry))
args.forEach((arg) => parts.push(JSON.stringify(arg)))
return parts.join(" ")
} }
private buildServeArgs(): string[] { private buildDirectSpawn(cliEntry: CliEntryResolution, args: string[]) {
return ["serve", "--port", "0", "--print-logs", "--log-level", "DEBUG"] 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[] } { private resolveCliEntry(options: StartOptions): CliEntryResolution {
const args = this.buildServeArgs() if (options.dev) {
.map((arg) => JSON.stringify(arg)) const tsxPath = this.resolveTsx()
.join(" ") const sourceCandidates = [
return buildUserShellCommand(`exec ${JSON.stringify(binaryPath)} ${args}`) 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", "main": "dist/main/main.js",
"scripts": { "scripts": {
"dev": "electron-vite dev", "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", "build": "electron-vite build",
"typecheck": "tsc --noEmit -p tsconfig.json", "typecheck": "tsc --noEmit -p tsconfig.json",
"preview": "electron-vite preview", "preview": "electron-vite preview",
@@ -29,8 +29,8 @@
"package:linux": "electron-builder --linux" "package:linux": "electron-builder --linux"
}, },
"dependencies": { "dependencies": {
"@codenomad/ui": "file:../ui", "@codenomad/cli": "file:../cli",
"ignore": "7.0.5" "@codenomad/ui": "file:../ui"
}, },
"devDependencies": { "devDependencies": {
"7zip-bin": "^5.2.0", "7zip-bin": "^5.2.0",
@@ -40,6 +40,7 @@
"electron-vite": "4.0.1", "electron-vite": "4.0.1",
"png2icons": "^2.0.1", "png2icons": "^2.0.1",
"pngjs": "^7.0.0", "pngjs": "^7.0.0",
"tsx": "^4.20.6",
"typescript": "^5.3.0", "typescript": "^5.3.0",
"vite": "^5.0.0", "vite": "^5.0.0",
"vite-plugin-solid": "^2.10.0" "vite-plugin-solid": "^2.10.0"

View File

@@ -557,24 +557,73 @@ export default function ToolCall(props: ToolCallProps) {
} }
} }
const getTodoTitle = () => { type TodoViewStatus = "pending" | "in_progress" | "completed" | "cancelled"
const state = props.toolCall?.state || {}
if (state.status !== "completed") return "Plan"
const metadata = state.metadata || {} interface TodoViewItem {
const todos = metadata.todos || [] 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 } function extractTodosFromState(state: ToolState | undefined): TodoViewItem[] {
for (const todo of todos) { if (!state) return []
const status = todo.status || "pending" const metadata = (isToolStateRunning(state) || isToolStateCompleted(state) || isToolStateError(state))
if (status in counts) counts[status as keyof typeof counts]++ ? 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 return items
if (counts.pending === total) return "Creating plan" }
if (counts.completed === total) return "Completing plan"
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" return "Updating plan"
} }
@@ -639,7 +688,7 @@ export default function ToolCall(props: ToolCallProps) {
return getTodoTitle() return getTodoTitle()
case "todoread": case "todoread":
return "Plan" return getTodoTitle()
case "invalid": case "invalid":
if (typeof input.tool === "string") { if (typeof input.tool === "string") {
@@ -656,18 +705,14 @@ export default function ToolCall(props: ToolCallProps) {
const toolName = props.toolCall?.tool || "" const toolName = props.toolCall?.tool || ""
const state = props.toolCall?.state || {} const state = props.toolCall?.state || {}
if (toolName === "todoread") { if (toolName === "todoread" || toolName === "todowrite") {
return null return renderTodoTool()
} }
if (state.status === "pending") { if (state.status === "pending") {
return null return null
} }
if (toolName === "todowrite") {
return renderTodowriteTool()
}
if (toolName === "task") { if (toolName === "task") {
return renderTaskTool() return renderTaskTool()
} }
@@ -938,65 +983,65 @@ export default function ToolCall(props: ToolCallProps) {
return null return null
} }
const renderTodowriteTool = () => { const renderTodoTool = () => {
const state = props.toolCall?.state const state = props.toolCall?.state
if (!state) return null 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) { const todos = extractTodosFromState(state)
return null 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 => { const completionPercent = Math.round((counts.completed / counts.total) * 100)
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"
return ( return (
<div class="tool-call-todos" role="list"> <div class="tool-call-todo-region">
<For each={todos}> <div class="tool-call-todo-summary">
{(todo) => { <div class="tool-call-todo-metrics">
const content = typeof todo.content === "string" ? todo.content.trim() : "" <span class="tool-call-todo-metric"><span class="tool-call-todo-metric-value">{counts.completed}</span> done</span>
if (!content) return null <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" return (
const label = getStatusLabel(status) <div
class="tool-call-todo-item"
return ( classList={{
<div "tool-call-todo-item-completed": todo.status === "completed",
class="tool-call-todo-item" "tool-call-todo-item-cancelled": todo.status === "cancelled",
classList={{ "tool-call-todo-item-active": todo.status === "in_progress",
"tool-call-todo-item-completed": status === "completed", }}
"tool-call-todo-item-cancelled": status === "cancelled", role="listitem"
"tool-call-todo-item-active": status === "in_progress", >
}} <span class="tool-call-todo-checkbox" data-status={todo.status} aria-label={label}></span>
role="listitem" <div class="tool-call-todo-body">
> <div class="tool-call-todo-heading">
<span class="tool-call-todo-checkbox" data-status={status} aria-label={label}></span> <span class="tool-call-todo-text">{todo.content}</span>
<div class="tool-call-todo-body"> <span class={`tool-call-todo-status tool-call-todo-status-${todo.status}`}>{label}</span>
<span class="tool-call-todo-text">{content}</span> </div>
<Show when={shouldShowTag(status)}> </div>
<span class="tool-call-todo-tag">{label}</span>
</Show>
</div> </div>
</div> )
) }}
}} </For>
</For> </div>
</div> </div>
) )
} }

View File

@@ -645,6 +645,52 @@
font-size: inherit; 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 { .tool-call-todos {
@apply my-2 flex flex-col gap-2; @apply my-2 flex flex-col gap-2;
list-style: none; list-style: none;
@@ -715,7 +761,37 @@
flex: 1; flex: 1;
display: flex; display: flex;
flex-direction: column; 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 { .tool-call-todo-text {