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
}
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) => { cliManager.on("ready", (status: CliStatus) => {
return processManager.getStatus(pid) if (!mainWindow.isDestroyed()) {
}) mainWindow.webContents.send("cli:ready", status)
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),
}
} }
}) })
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 { 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) {
this.mainWindow = window
} }
private parseLogLevel(message: string): "info" | "error" | "warn" | "debug" { interface CliEntryResolution {
const upperMessage = message.toUpperCase() entry: string
if (upperMessage.includes("[ERROR]") || upperMessage.includes("ERROR:")) return "error" runner: "node" | "tsx"
if (upperMessage.includes("[WARN]") || upperMessage.includes("WARN:")) return "warn" runnerPath?: string
if (upperMessage.includes("[DEBUG]") || upperMessage.includes("DEBUG:")) return "debug"
if (upperMessage.includes("[INFO]") || upperMessage.includes("INFO:")) return "info"
return "info"
} }
private sendLog(instanceId: string, level: "info" | "error" | "warn" | "debug", message: string) { export declare interface CliProcessManager {
if (this.mainWindow && message.trim()) { on(event: "status", listener: (status: CliStatus) => void): this
const parsedLevel = this.parseLogLevel(message) on(event: "ready", listener: (status: CliStatus) => void): this
this.mainWindow.webContents.send("instance:log", { on(event: "log", listener: (entry: CliLogEntry) => void): this
id: instanceId, on(event: "exit", listener: (status: CliStatus) => void): this
entry: { on(event: "error", listener: (error: Error) => void): this
timestamp: Date.now(),
level: parsedLevel,
message: message.trim(),
},
})
}
} }
async spawn( export class CliProcessManager extends EventEmitter {
folder: string, private child?: ChildProcess
instanceId: string, private status: CliStatus = { state: "stopped" }
binaryPath?: string, private stdoutBuffer = ""
environmentVariables?: Record<string, string>, private stderrBuffer = ""
): Promise<ProcessInfo> {
this.validateFolder(folder) async start(options: StartOptions): Promise<CliStatus> {
const useUserShell = supportsUserShell() if (this.child) {
const logAttempt = (message: string) => { await this.stop()
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, const cliEntry = this.resolveCliEntry(options)
"info", const args = this.buildCliArgs(options)
`Using ${Object.keys(environmentVariables).length} custom environment variables:`,
console.info(
`[cli] launching CodeNomad CLI (${options.dev ? "dev" : "prod"}) using ${cliEntry.runner} at ${cliEntry.entry}`,
) )
// Log each environment variable const env = supportsUserShell() ? getUserShellEnv() : { ...process.env }
for (const [key, value] of Object.entries(environmentVariables)) { env.ELECTRON_RUN_AS_NODE = "1"
this.sendLog(instanceId, "info", ` ${key}=${value}`)
}
}
let targetBinary: string const spawnDetails = supportsUserShell()
if (!binaryPath || binaryPath === "opencode") { ? buildUserShellCommand(`ELECTRON_RUN_AS_NODE=1 exec ${this.buildCommand(cliEntry, args)}`)
targetBinary = useUserShell ? "opencode" : this.validateOpenCodeBinary(logAttempt) : this.buildDirectSpawn(cliEntry, args)
} else {
targetBinary = this.validateCustomBinary(binaryPath, logAttempt)
}
const spawnCommand = useUserShell const child = spawn(spawnDetails.command, spawnDetails.args, {
? this.buildShellServeCommand(targetBinary) cwd: process.cwd(),
: { 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 child = spawn(spawnCommand.command, spawnCommand.args, {
cwd: folder,
stdio: ["ignore", "pipe", "pipe"], stdio: ["ignore", "pipe", "pipe"],
env, env,
shell: false, shell: false,
}) })
console.info(`[cli] spawn command: ${spawnDetails.command} ${spawnDetails.args.join(" ")}`)
if (!child.pid) {
console.error("[cli] spawn failed: no pid")
}
const timeout = setTimeout(() => { this.child = child
child.kill("SIGKILL") this.updateStatus({ pid: child.pid ?? undefined })
this.sendLog(instanceId, "error", "Server startup timeout (10s exceeded)")
reject(new Error("Server startup timeout (10s exceeded)"))
}, 10000)
let stdoutBuffer = ""
let stderrBuffer = ""
let portFound = false
child.stdout?.on("data", (data: Buffer) => { child.stdout?.on("data", (data: Buffer) => {
const text = data.toString() this.handleStream(data.toString(), "stdout")
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) => { child.stderr?.on("data", (data: Buffer) => {
const text = data.toString() this.handleStream(data.toString(), "stderr")
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) => { child.on("error", (error) => {
clearTimeout(timeout) console.error("[cli] failed to start CLI:", error)
if (error.message.includes("ENOENT")) { this.updateStatus({ state: "error", error: error.message })
reject(new Error("opencode binary not found in PATH")) this.emit("error", error)
} else {
reject(error)
}
}) })
child.on("exit", (code, signal) => { child.on("exit", (code, signal) => {
const failed = this.status.state !== "ready"
const error = failed ? this.status.error ?? `CLI exited with code ${code ?? 0}${signal ? ` (${signal})` : ""}` : undefined
console.info(`[cli] exit (code=${code}, signal=${signal || ""})${error ? ` error=${error}` : ""}`)
this.updateStatus({ state: failed ? "error" : "stopped", error })
if (failed && error) {
this.emit("error", new Error(error))
}
this.emit("exit", this.status)
this.child = undefined
})
return new Promise<CliStatus>((resolve, reject) => {
const timeout = setTimeout(() => {
this.handleTimeout()
reject(new Error("CLI startup timeout"))
}, 15000)
this.once("ready", (status) => {
clearTimeout(timeout) clearTimeout(timeout)
this.processes.delete(child.pid!) resolve(status)
})
if (!portFound) { this.once("error", (error) => {
const errorMsg = stderrBuffer || `Process exited with code ${code}` clearTimeout(timeout)
reject(new Error(errorMsg)) reject(error)
}
}) })
}) })
} }
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"
} }
private handleTimeout() {
if (this.child) {
this.child.kill("SIGKILL")
this.child = undefined
}
this.updateStatus({ state: "error", error: "CLI did not start in time" })
this.emit("error", new Error("CLI did not start in time"))
}
private handleStream(chunk: string, stream: "stdout" | "stderr") {
if (stream === "stdout") {
this.stdoutBuffer += chunk
this.processBuffer("stdout")
} else {
this.stderrBuffer += chunk
this.processBuffer("stderr")
}
}
private processBuffer(stream: "stdout" | "stderr") {
const buffer = stream === "stdout" ? this.stdoutBuffer : this.stderrBuffer
const lines = buffer.split("\n")
const trailing = lines.pop() ?? ""
if (stream === "stdout") {
this.stdoutBuffer = trailing
} else {
this.stderrBuffer = trailing
}
for (const line of lines) {
if (!line.trim()) continue
console.info(`[cli][${stream}] ${line}`)
this.emit("log", { stream, message: line })
const port = this.extractPort(line)
if (port && this.status.state === "starting") {
const url = `http://127.0.0.1:${port}`
console.info(`[cli] ready on ${url}`)
this.updateStatus({ state: "ready", port, url })
this.emit("ready", this.status)
}
}
}
private extractPort(line: string): number | null {
const readyMatch = line.match(/CodeNomad Server is ready at http:\/\/[^:]+:(\d+)/i)
if (readyMatch) {
return parseInt(readyMatch[1], 10)
}
if (line.toLowerCase().includes("http server listening")) {
const httpMatch = line.match(/:(\d{2,5})(?!.*:\d)/)
if (httpMatch) {
return parseInt(httpMatch[1], 10)
}
try { try {
process.kill(pid, 0) const parsed = JSON.parse(line)
return "running" if (typeof parsed.port === "number") {
return parsed.port
}
} catch { } catch {
return "stopped" // not JSON, ignore
} }
} }
getAllProcesses(): Map<number, ProcessMeta> { return null
return new Map(this.processes)
} }
async cleanup(): Promise<void> { private updateStatus(patch: Partial<CliStatus>) {
const killPromises = Array.from(this.processes.keys()).map((pid) => this.kill(pid).catch(() => {})) this.status = { ...this.status, ...patch }
await Promise.all(killPromises) this.emit("status", this.status)
} }
private validateFolder(folder: string): void { private buildCliArgs(options: StartOptions): string[] {
if (!existsSync(folder)) { const args = ["serve", "--host", "127.0.0.1", "--port", "0"]
throw new Error(`Folder does not exist: ${folder}`)
if (options.dev) {
args.push("--ui-dev-server", "http://localhost:3000", "--log-level", "debug")
} }
const stats = statSync(folder) return args
if (!stats.isDirectory()) { }
throw new Error(`Path is not a directory: ${folder}`)
private buildCommand(cliEntry: CliEntryResolution, args: string[]): string {
const parts = [JSON.stringify(process.execPath)]
if (cliEntry.runner === "tsx" && cliEntry.runnerPath) {
parts.push(JSON.stringify(cliEntry.runnerPath))
}
parts.push(JSON.stringify(cliEntry.entry))
args.forEach((arg) => parts.push(JSON.stringify(arg)))
return parts.join(" ")
}
private buildDirectSpawn(cliEntry: CliEntryResolution, args: string[]) {
if (cliEntry.runner === "tsx") {
return { command: process.execPath, args: [cliEntry.runnerPath!, cliEntry.entry, ...args] }
}
return { command: process.execPath, args: [cliEntry.entry, ...args] }
}
private resolveCliEntry(options: StartOptions): CliEntryResolution {
if (options.dev) {
const tsxPath = this.resolveTsx()
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 }
} }
} }
private validateOpenCodeBinary(logAttempt?: (message: string) => void): string { const dist = this.tryResolveDist()
const log = logAttempt ?? ((message: string) => console.info(`[ProcessManager] ${message}`)) if (dist) {
return { entry: dist, runner: "node" }
if (process.platform === "win32") {
log("Checking PATH via 'where opencode'")
return this.resolveBinaryViaLocator("where opencode", log)
} }
const shellCheck = buildUserShellCommand("command -v opencode") throw new Error("Unable to locate CodeNomad CLI build (dist/bin.js). Please build @codenomad/cli.")
const shellPreview = [shellCheck.command, ...shellCheck.args].join(" ") }
log(`Checking PATH via shell: ${shellPreview}`)
private resolveTsx(): string | null {
try { try {
const resolved = runUserShellCommandSync("command -v opencode") const resolved = require.resolve("tsx/dist/cli.js")
const path = this.pickFirstPath(resolved) if (resolved && existsSync(resolved)) {
if (path) { return resolved
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 {
log?.(`Validating custom binary at ${binaryPath}`)
if (!existsSync(binaryPath)) {
throw new Error(`OpenCode binary not found: ${binaryPath}`)
}
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") {
try {
execSync(`test -x "${binaryPath}"`, { stdio: "pipe" })
} catch { } catch {
throw new Error(`Binary is not executable: ${binaryPath}`) 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 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")
}
return path
}
private pickFirstPath(output: string): string | null {
const line = output
.split("\n")
.map((entry) => entry.trim())
.find((entry) => entry.length > 0)
return line ?? null
}
private buildServeArgs(): string[] {
return ["serve", "--port", "0", "--print-logs", "--log-level", "DEBUG"]
}
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}`)
} }
} }
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
if (!Array.isArray(todos) || todos.length === 0) return "Plan" status: TodoViewStatus
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]++
} }
const total = todos.length function normalizeTodoStatus(rawStatus: unknown): TodoViewStatus {
if (counts.pending === total) return "Creating plan" if (rawStatus === "completed" || rawStatus === "in_progress" || rawStatus === "cancelled") return rawStatus
if (counts.completed === total) return "Completing plan" return "pending"
}
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 })
}
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" 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,66 +983,66 @@ 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)) const todos = extractTodosFromState(state)
? state.metadata || {} const counts = summarizeTodos(todos)
: {}
const todos = metadata.todos || []
if (!Array.isArray(todos) || todos.length === 0) { if (counts.total === 0) {
return null 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-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"> <div class="tool-call-todos" role="list">
<For each={todos}> <For each={todos}>
{(todo) => { {(todo) => {
const content = typeof todo.content === "string" ? todo.content.trim() : "" const label = getTodoStatusLabel(todo.status)
if (!content) return null
const status = typeof todo.status === "string" ? todo.status : "pending"
const label = getStatusLabel(status)
return ( return (
<div <div
class="tool-call-todo-item" class="tool-call-todo-item"
classList={{ classList={{
"tool-call-todo-item-completed": status === "completed", "tool-call-todo-item-completed": todo.status === "completed",
"tool-call-todo-item-cancelled": status === "cancelled", "tool-call-todo-item-cancelled": todo.status === "cancelled",
"tool-call-todo-item-active": status === "in_progress", "tool-call-todo-item-active": todo.status === "in_progress",
}} }}
role="listitem" role="listitem"
> >
<span class="tool-call-todo-checkbox" data-status={status} aria-label={label}></span> <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-body">
<span class="tool-call-todo-text">{content}</span> <div class="tool-call-todo-heading">
<Show when={shouldShowTag(status)}> <span class="tool-call-todo-text">{todo.content}</span>
<span class="tool-call-todo-tag">{label}</span> <span class={`tool-call-todo-status tool-call-todo-status-${todo.status}`}>{label}</span>
</Show> </div>
</div> </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 {