Compare commits

...

36 Commits

Author SHA1 Message Date
Shantur Rathore
cacfbc24cc Fix workspace version bumps in CI workflows 2025-11-21 13:52:45 +00:00
Shantur Rathore
2052c5566e Use root version for dev release workflow 2025-11-21 13:40:52 +00:00
Shantur Rathore
2486af2808 More screenshots 2025-11-21 13:30:59 +00:00
Shantur Rathore
881afbba0a UI Readme 2025-11-21 12:40:49 +00:00
Shantur Rathore
b6d48bfb69 Update Readmes 2025-11-21 12:37:24 +00:00
Shantur Rathore
d9596f7b4b Bump workspace and packages to 0.2.0 2025-11-21 10:51:31 +00:00
Shantur Rathore
6467bdfe7c Add reusable build workflows and dev prereleases 2025-11-21 09:48:25 +00:00
Shantur Rathore
4fdd299919 Version alias 2025-11-21 09:08:17 +00:00
Shantur Rathore
2de2d26043 Neural Nomads author 2025-11-21 07:46:31 +00:00
Shantur Rathore
70e6052dc8 Rename electron app package to @neuralnomads/codenomad-electron-app 2025-11-21 00:10:31 +00:00
Shantur Rathore
2ff51c1866 Use server naming for shared API/events 2025-11-21 00:04:01 +00:00
Shantur Rathore
d6fdef68d9 Rename CLI package to @neuralnomads/codenomad and bin codenomad 2025-11-20 23:51:44 +00:00
Shantur Rathore
30b075e4ba Improve CLI preload flow and SSE reconnects 2025-11-20 20:45:31 +00:00
Shantur Rathore
3f46d73a31 feat: add instance config provider and map storage ids 2025-11-20 14:46:13 +00:00
Shantur Rathore
038cf3c762 feat: preload cli browser view 2025-11-20 10:51:14 +00:00
Shantur Rathore
85c0632719 Remove padding from todo tool call list 2025-11-20 10:48:11 +00:00
Shantur Rathore
c4c2c92974 Simplify todo tool calls and tighten layout 2025-11-20 10:46:11 +00:00
Shantur Rathore
c5fd5694ee feat: make electron shell host CLI server 2025-11-20 10:41:07 +00:00
Shantur Rathore
bc5423ce14 Keep tool calls open while permissions pending and fix task session nav 2025-11-20 10:12:09 +00:00
Shantur Rathore
8fab34e356 Add attachment previews and data URLs for drops 2025-11-19 21:33:56 +00:00
Shantur Rathore
d3ee15dcd7 Add inline previews for prompt attachments 2025-11-19 18:49:50 +00:00
Shantur Rathore
45dca7a7f0 cache per-instance history via SSE 2025-11-19 17:48:07 +00:00
Shantur Rathore
885059b0aa refine filesystem dialog to load folders on demand 2025-11-19 17:13:35 +00:00
Shantur Rathore
629d098add add cached fuzzy file search and debounce unified picker 2025-11-19 16:43:28 +00:00
Shantur Rathore
7e95005d8c refine config provider and full replacement flow 2025-11-19 14:43:47 +00:00
Shantur Rathore
7aa94e7a88 Integrate reply-from for workspace proxy 2025-11-19 02:27:07 +00:00
Shantur Rathore
146eae5220 Add CLI instance proxy and route UI traffic through it 2025-11-19 02:03:15 +00:00
Shantur Rathore
defa637dbc Serve bundled or proxied UI from CLI 2025-11-18 14:33:48 +00:00
Shantur Rathore
a43a004e23 add unrestricted filesystem browsing mode 2025-11-17 23:40:02 +00:00
Shantur Rathore
a3f02befa7 Refine CLI args and lifecycle logging 2025-11-17 22:08:50 +00:00
Shantur Rathore
40e8c90bab Implement workspace-aware folder browser 2025-11-17 22:05:38 +00:00
Shantur Rathore
f53564bb06 Add depth-limited filesystem browsing 2025-11-17 21:16:10 +00:00
Shantur Rathore
719a9c9c74 Add structured logging and ensure CLI shuts down cleanly 2025-11-17 20:21:39 +00:00
Shantur Rathore
08d81f8bb5 Add CLI server and move UI to HTTP API 2025-11-17 18:18:45 +00:00
Shantur Rathore
89bd32814f Split workspace into electron and ui packages 2025-11-17 12:06:58 +00:00
Shantur Rathore
aa77ca2931 add linux rpm packaging pipeline 2025-11-17 01:50:16 +00:00
194 changed files with 10397 additions and 3558 deletions

178
.github/workflows/build-and-upload.yml vendored Normal file
View File

@@ -0,0 +1,178 @@
name: Build and Upload Binaries
on:
workflow_call:
inputs:
version:
description: "Version to apply to workspace packages"
required: true
type: string
tag:
description: "Git tag to upload assets to"
required: true
type: string
release_name:
description: "Release name (unused here, for context)"
required: true
type: string
permissions:
contents: write
env:
NODE_VERSION: 20
jobs:
build-macos:
runs-on: macos-13
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ inputs.version }}
TAG: ${{ inputs.tag }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm
- name: Set workspace versions
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
- name: Install dependencies
run: npm ci --workspaces
- name: Build macOS binaries
run: npm run build:mac --workspace @neuralnomads/codenomad-electron-app
- name: Upload release assets
run: |
shopt -s nullglob
for file in packages/electron-app/release/*; do
[ -f "$file" ] || continue
case "$file" in
*.dmg|*.zip)
gh release upload "$TAG" "$file" --clobber
;;
*)
echo "Skipping non-installer asset: $file"
;;
esac
done
build-windows:
runs-on: windows-latest
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ inputs.version }}
TAG: ${{ inputs.tag }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm
- name: Set workspace versions
run: npm version ${{ env.VERSION }} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
shell: bash
- name: Install dependencies
run: npm ci --workspaces
- name: Build Windows binaries
run: npm run build:win --workspace @neuralnomads/codenomad-electron-app
- name: Upload release assets
shell: pwsh
run: |
Get-ChildItem -Path "packages/electron-app/release" -File | Where-Object {
$_.Name -match '\\.(exe|zip)$'
} | ForEach-Object {
gh release upload $env:TAG $_.FullName --clobber
}
build-linux:
runs-on: ubuntu-latest
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ inputs.version }}
TAG: ${{ inputs.tag }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm
- name: Set workspace versions
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
- name: Install dependencies
run: npm ci --workspaces
- name: Build Linux binaries
run: npm run build:linux --workspace @neuralnomads/codenomad-electron-app
- name: Upload release assets
run: |
shopt -s nullglob
for file in packages/electron-app/release/*; do
[ -f "$file" ] || continue
case "$file" in
*.AppImage|*.deb|*.tar.gz)
gh release upload "$TAG" "$file" --clobber
;;
*)
echo "Skipping non-installer asset: $file"
;;
esac
done
build-linux-rpm:
runs-on: ubuntu-latest
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ inputs.version }}
TAG: ${{ inputs.tag }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm
- name: Install rpm packaging dependencies
run: |
sudo apt-get update
sudo apt-get install -y rpm ruby ruby-dev build-essential
sudo gem install --no-document fpm
- name: Set workspace versions
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
- name: Install project dependencies
run: npm ci --workspaces
- name: Build Linux RPM binaries
run: npm run build:linux-rpm --workspace @neuralnomads/codenomad-electron-app
- name: Upload RPM release assets
run: |
shopt -s nullglob
for file in packages/electron-app/release/*.rpm; do
[ -f "$file" ] || continue
gh release upload "$TAG" "$file" --clobber
done

86
.github/workflows/dev-release.yml vendored Normal file
View File

@@ -0,0 +1,86 @@
name: Dev Release
on:
workflow_dispatch:
permissions:
contents: write
env:
NODE_VERSION: 20
jobs:
prepare-dev:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.versions.outputs.version }}
tag: ${{ steps.versions.outputs.tag }}
release_name: ${{ steps.versions.outputs.release_name }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Compute dev versions
id: versions
run: |
BASE_VERSION=$(node -p "require('./package.json').version")
DEV_VERSION="${BASE_VERSION}-dev"
TIMESTAMP=$(date -u +%y%m%d-%H%M)
TAG="v${DEV_VERSION}-${TIMESTAMP}"
echo "version=$DEV_VERSION" >> "$GITHUB_OUTPUT"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "release_name=$TAG" >> "$GITHUB_OUTPUT"
- name: Create GitHub release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ steps.versions.outputs.tag }}
run: |
if gh release view "$TAG" >/dev/null 2>&1; then
echo "Release $TAG already exists"
else
gh release create "$TAG" --title "$TAG" --generate-notes
fi
build-and-upload:
needs: prepare-dev
uses: ./.github/workflows/build-and-upload.yml
with:
version: ${{ needs.prepare-dev.outputs.version }}
tag: ${{ needs.prepare-dev.outputs.tag }}
release_name: ${{ needs.prepare-dev.outputs.release_name }}
secrets: inherit
publish-server:
needs: build-and-upload
runs-on: ubuntu-latest
env:
NODE_VERSION: 20
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
VERSION: ${{ needs.prepare-dev.outputs.version }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm
- name: Set workspace versions
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
- name: Install dependencies
run: npm ci --workspaces
- name: Build server package
run: npm run build --workspace @neuralnomads/codenomad
- name: Publish server package to dev tag
run: npm publish --workspace @neuralnomads/codenomad --access public --tag dev

View File

@@ -63,84 +63,21 @@ jobs:
gh release create "$TAG" --title "CodeNomad v${VERSION}" --generate-notes
fi
build-macos:
build-and-upload:
needs: prepare-release
runs-on: macos-13
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- name: Checkout
uses: actions/checkout@v4
uses: ./.github/workflows/build-and-upload.yml
with:
version: ${{ needs.prepare-release.outputs.version }}
tag: ${{ needs.prepare-release.outputs.tag }}
release_name: CodeNomad v${{ needs.prepare-release.outputs.version }}
secrets: inherit
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm
- name: Install dependencies
run: npm ci
- name: Build macOS binaries
run: npm run build:mac
- name: Upload release assets
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ needs.prepare-release.outputs.tag }}
run: |
set -euo pipefail
shopt -s nullglob
for file in release/*; do
[ -f "$file" ] || continue
case "$file" in
*.dmg|*.zip)
gh release upload "$TAG" "$file" --clobber
;;
*)
echo "Skipping non-installer asset: $file"
;;
esac
done
build-windows:
needs: prepare-release
runs-on: windows-latest
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm
- name: Install dependencies
run: npm ci
- name: Build Windows binaries
run: npm run build:win
- name: Upload release assets
shell: pwsh
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ needs.prepare-release.outputs.tag }}
run: |
Get-ChildItem -Path "release" -File | Where-Object {
$_.Name -match '\.(exe|zip)$'
} | ForEach-Object {
gh release upload $env:TAG $_.FullName --clobber
}
build-linux:
needs: prepare-release
publish-server:
needs: build-and-upload
runs-on: ubuntu-latest
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NODE_VERSION: 20
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -152,26 +89,10 @@ jobs:
cache: npm
- name: Install dependencies
run: npm ci
run: npm ci --workspaces
- name: Build Linux binaries
run: npm run build:linux
- name: Build server package
run: npm run build --workspace @neuralnomads/codenomad
- name: Upload release assets
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ needs.prepare-release.outputs.tag }}
run: |
set -euo pipefail
shopt -s nullglob
for file in release/*; do
[ -f "$file" ] || continue
case "$file" in
*.AppImage|*.deb|*.tar.gz)
gh release upload "$TAG" "$file" --clobber
;;
*)
echo "Skipping non-installer asset: $file"
;;
esac
done
- name: Publish server package
run: npm publish --workspace @neuralnomads/codenomad --access public --tag latest

View File

@@ -14,3 +14,7 @@
- Enforce single responsibility; split large files when concerns diverge (state, actions, API, events, etc.).
- Prefer composable primitives (signals, hooks, utilities) over deep inheritance or implicit global state.
- When adding platform integrations (SSE, IPC, SDK), isolate them in thin adapters that surface typed events/actions.
## Tooling Preferences
- Use the `edit` tool for modifying existing files; prefer it over other editing methods.
- Use the `write` tool only when creating new files from scratch.

View File

@@ -10,6 +10,12 @@ This guide explains how to build distributable binaries for CodeNomad.
## Quick Start
All commands now run inside the workspace packages. From the repo root you can target the Electron app package directly:
```bash
npm run build --workspace @neuralnomads/codenomad-electron-app
```
### Build for Current Platform (macOS default)
```bash
@@ -71,8 +77,8 @@ bun run build:all
The build script performs these steps:
1. **Compile TypeScript** → Electron app (main, preload, renderer)
2. **Bundle with Vite** → Optimized production build
1. **Build @neuralnomads/codenomad** → Produces the CLI `dist/` bundle (also rebuilds the UI assets it serves)
2. **Compile TypeScript + bundle with Vite** → Electron main, preload, and renderer output in `dist/`
3. **Package with electron-builder** → Platform-specific binaries
## Output

View File

@@ -1,35 +1,70 @@
# CodeNomad
## A fast, multi-instance desktop client for running OpenCode sessions the way long-haul builders actually work.
## What is CodeNomad?
## A fast, multi-instance workspace for running OpenCode sessions.
CodeNomad is built for people who live inside OpenCode for hours on end and need a cockpit, not a kiosk. When terminals get unwieldy and web clients feel laggy, CodeNomad delivers a desktop-native workspace that favors speed, clarity, and direct control. It runs on macOS, Windows, and Linux using Electron + SolidJS, with prebuilt binaries so you can get started immediately.
CodeNomad is built for people who live inside OpenCode for hours on end and need a cockpit, not a kiosk. It delivers a premium, low-latency workspace that favors speed, clarity, and direct control.
![Multi-instance workspace](docs/screenshots/newSession.png)
_Manage multiple OpenCode sessions side-by-side._
<details>
<summary>📸 More Screenshots</summary>
![Command palette overlay](docs/screenshots/command-palette.png)
_Global command palette for keyboard-first control._
![Image Previews](images/image-previews.png)
_Rich media previews for images and assets._
![Browser Support](images/browser-support.png)
_Browser support via CodeNomad Server._
</details>
## Getting Started
Choose the way that fits your workflow:
### 🖥️ Desktop App (Recommended)
The best experience. A native application with global shortcuts, deeper system integration, and a dedicated window.
- **Download**: Grab the latest installer for macOS, Windows, or Linux from the [Releases Page](https://github.com/shantur/CodeNomad/releases).
- **Run**: Install and launch like any other app.
### 💻 CodeNomad Server
Run CodeNomad as a local server and access it via your web browser. Perfect for remote development (SSH/VPN) or running as a service.
```bash
npx @neuralnomads/codenomad --launch
```
This command starts the server and opens the web client in your default browser.
## Highlights
- **Long-session native** scroll through massive transcripts without hitches and keep full context visible.
- **Multiple instances, one window** juggle several OpenCode instances side-by-side with per-instance tabs.
- **Deep task awareness** jump into sub/child sessions (Tasks tool) instantly, monitor their status, and answer directly without losing your flow.
- **Keyboard first** the full UI is optimized for shortcuts so you can stay mouse-free when you want to.
- **Command palette superpowers** summon a single, global palette to jump tabs, launch tools, tweak preferences, or fire shortcuts. Every action is categorized, fuzzy searchable, and previewed so you can chain moves together in seconds. It keeps your workflow predictable and fast whether you are juggling one session or ten.
- **Developer-friendly rendering** syntax highlighting, inline diffs, and thoughtful presentation keep the signal high.
- **Multi-Instance**: Juggle several OpenCode sessions side-by-side with tabs.
- **Long-Session Native**: Scroll through massive transcripts without hitches.
- **Command Palette**: A single global palette to jump tabs, launch tools, and control everything.
- **Deep Task Awareness**: Monitor background tasks and child sessions without losing flow.
## Requirements
- [OpenCode CLI](https://opencode.ai) installed and available in your `PATH`, or point CodeNomad to a local binary through Advanced Settings.
- **[OpenCode CLI](https://opencode.ai)**: Must be installed and available in your `PATH`.
- **Node.js 18+**: Required if running the CLI server or building from source.
## Downloads
## Architecture & Development
Grab the latest build for macOS, Windows, and Linux from the [GitHub Releases page](https://github.com/shantur/CodeNomad/releases).
CodeNomad is a monorepo split into specialized packages. If you want to contribute or build from source, check out the individual package documentation:
## Quick Start
| Package | Description |
|---------|-------------|
| **[packages/electron-app](packages/electron-app/README.md)** | The native desktop application shell. Wraps the UI and Server. |
| **[packages/server](packages/server/README.md)** | The core logic and CLI. Manages workspaces, proxies OpenCode, and serves the API. |
| **[packages/ui](packages/ui/README.md)** | The SolidJS-based frontend. Fast, reactive, and beautiful. |
1. Install the OpenCode CLI and confirm it is reachable via your terminal.
2. Download the CodeNomad build for your platform and launch the app.
3. Connect to one or more OpenCode instances, set keyboard shortcuts in preferences, and start a session.
4. Use tabs to swap between instances, the task sidebar to dive into child sessions, and the prompt input to keep shipping.
### Quick Build
To build the Desktop App from source:
1. Clone the repo.
2. Run `npm install` (requires pnpm or npm 7+ for workspaces).
3. Run `npm run build --workspace @neuralnomads/codenomad-electron-app`.

View File

@@ -104,6 +104,12 @@ CodeNomad is a cross-platform desktop application built with Electron that provi
- Event type routing
- Reconnection logic
**CLI Proxy Paths:**
- The CLI server terminates all HTTP/SSE traffic and forwards it to the correct OpenCode instance.
- Each `WorkspaceDescriptor` exposes `proxyPath` (e.g., `/workspaces/<id>/instance`), which acts as the base URL for both REST and SSE calls.
- The renderer never touches the random per-instance port directly; it only talks to `window.location.origin + proxyPath` so a single CLI port can front every session.
## Data Flow
### Instance Creation Flow
@@ -144,6 +150,7 @@ instances: Map<instanceId, {
folder: string
port: number
pid: number
proxyPath: string // `/workspaces/:id/instance`
status: 'starting' | 'ready' | 'error' | 'stopped'
client: OpenCodeClient
eventSource: EventSource

View File

@@ -1,243 +0,0 @@
import { ipcMain, BrowserWindow, dialog } from "electron"
import { processManager } from "./process-manager"
import { randomBytes } from "crypto"
import * as fs from "fs"
import * as path from "path"
import { spawn } from "child_process"
import ignore from "ignore"
interface Instance {
id: string
folder: string
port: number
pid: number
status: "starting" | "ready" | "error" | "stopped"
error?: string
}
const instances = new Map<string, Instance>()
function generateId(): string {
return randomBytes(16).toString("hex")
}
function runBinaryVersion(binaryPath: string, timeoutMs = 5000): Promise<string> {
return new Promise((resolve, reject) => {
const child = spawn(binaryPath, ["-v"], {
stdio: ["ignore", "pipe", "pipe"],
})
let stdout = ""
let stderr = ""
const timeout = setTimeout(() => {
child.kill("SIGTERM")
reject(new Error("Version check timed out"))
}, timeoutMs)
child.stdout?.on("data", (data) => {
stdout += data.toString()
})
child.stderr?.on("data", (data) => {
stderr += data.toString()
})
child.on("error", (error) => {
clearTimeout(timeout)
reject(error)
})
child.on("close", (code) => {
clearTimeout(timeout)
if (code === 0) {
resolve(stdout.trim())
} else {
reject(new Error(stderr.trim() || `Binary exited with code ${code}`))
}
})
})
}
export function setupInstanceIPC(mainWindow: BrowserWindow) {
processManager.setMainWindow(mainWindow)
ipcMain.handle("dialog:selectFolder", async () => {
const result = await dialog.showOpenDialog(mainWindow!, {
title: "Select Project Folder",
properties: ["openDirectory"],
})
if (result.canceled || !result.filePaths.length) {
return null
}
return result.filePaths[0]
})
ipcMain.handle(
"instance:create",
async (event, id: string, folder: string, binaryPath?: string, environmentVariables?: Record<string, string>) => {
const instance: Instance = {
id,
folder,
port: 0,
pid: 0,
status: "starting",
}
instances.set(id, instance)
try {
const {
pid,
port,
binaryPath: actualBinaryPath,
} = await processManager.spawn(folder, id, binaryPath, environmentVariables)
instance.port = port
instance.pid = pid
instance.status = "ready"
mainWindow.webContents.send("instance:started", { id, port, pid, binaryPath: actualBinaryPath })
const meta = processManager.getAllProcesses().get(pid)
if (meta) {
meta.childProcess.on("exit", (code, signal) => {
instance.status = "stopped"
mainWindow.webContents.send("instance:stopped", { id })
})
}
return { id, port, pid, binaryPath: actualBinaryPath }
} catch (error) {
instance.status = "error"
instance.error = error instanceof Error ? error.message : String(error)
mainWindow.webContents.send("instance:error", {
id,
error: instance.error,
})
throw error
}
},
)
ipcMain.handle("instance:stop", async (event, pid: number) => {
await processManager.kill(pid)
for (const [id, instance] of instances.entries()) {
if (instance.pid === pid) {
instance.status = "stopped"
break
}
}
})
ipcMain.handle("instance:status", async (event, pid: number) => {
return processManager.getStatus(pid)
})
ipcMain.handle("instance:list", async () => {
return Array.from(instances.values())
})
ipcMain.handle("fs:scanDirectory", async (event, workspaceFolder: string) => {
const ig = ignore()
ig.add([".git", "node_modules"])
const gitignorePath = path.join(workspaceFolder, ".gitignore")
if (fs.existsSync(gitignorePath)) {
const content = fs.readFileSync(gitignorePath, "utf-8")
ig.add(content)
}
function scanDir(dirPath: string, baseDir: string): string[] {
const results: string[] = []
try {
const entries = fs.readdirSync(dirPath, { withFileTypes: true })
for (const entry of entries) {
const fullPath = path.join(dirPath, entry.name)
const relativePath = path.relative(baseDir, fullPath)
if (ig.ignores(relativePath)) {
continue
}
if (entry.isDirectory()) {
const dirWithSlash = relativePath + "/"
if (!ig.ignores(dirWithSlash)) {
results.push(dirWithSlash)
const subFiles = scanDir(fullPath, baseDir)
results.push(...subFiles)
}
} else {
results.push(relativePath)
}
}
} catch (error) {
console.warn(`Error scanning ${dirPath}:`, error)
}
return results
}
return scanDir(workspaceFolder, workspaceFolder)
})
// OpenCode binary operations
ipcMain.handle("dialog:selectOpenCodeBinary", async () => {
const result = await dialog.showOpenDialog(mainWindow!, {
title: "Select OpenCode Binary",
filters: [
{ name: "Executable Files", extensions: ["exe", "cmd", "bat", "sh", "command", "app", ""] },
{ name: "All Files", extensions: ["*"] },
],
properties: ["openFile"],
})
if (result.canceled || !result.filePaths.length) {
return null
}
return result.filePaths[0]
})
ipcMain.handle("opencode:validateBinary", async (event, binaryPath: string) => {
try {
// Special handling for system PATH binary
const isSystemPath = binaryPath === "opencode"
if (!isSystemPath) {
// Check if file exists and is executable for custom paths
if (!fs.existsSync(binaryPath)) {
return { valid: false, error: "File does not exist" }
}
const stats = fs.statSync(binaryPath)
if (!stats.isFile()) {
return { valid: false, error: "Path is not a file" }
}
}
// Try to get version once via -v flag
try {
const version = await runBinaryVersion(binaryPath)
return { valid: true, version }
} catch (error) {
return {
valid: false,
error: error instanceof Error ? error.message : String(error),
}
}
} catch (error) {
return {
valid: false,
error: error instanceof Error ? error.message : String(error),
}
}
})
}

View File

@@ -1,102 +0,0 @@
import { app, BrowserWindow, dialog, ipcMain, nativeImage, nativeTheme, session } from "electron"
import { join } from "path"
import { createApplicationMenu } from "./menu"
import { setupInstanceIPC } from "./ipc"
import { setupStorageIPC } from "./storage"
const isMac = process.platform === "darwin"
if (isMac) {
app.commandLine.appendSwitch("disable-spell-checking")
}
// Setup IPC handlers before creating windows
setupStorageIPC()
let mainWindow: BrowserWindow | null = null
function getIconPath() {
if (app.isPackaged) {
return join(process.resourcesPath, "icon.png")
}
return join(app.getAppPath(), "electron/resources/icon.png")
}
function createWindow() {
const prefersDark = true //nativeTheme.shouldUseDarkColors
const backgroundColor = prefersDark ? "#1a1a1a" : "#ffffff"
const iconPath = getIconPath()
mainWindow = new BrowserWindow({
width: 1400,
height: 900,
minWidth: 800,
minHeight: 600,
backgroundColor,
icon: iconPath,
webPreferences: {
preload: join(__dirname, "../preload/index.js"),
contextIsolation: true,
nodeIntegration: false,
spellcheck: !isMac,
},
})
if (isMac) {
// Disable macOS spell server to avoid input lag
mainWindow.webContents.session.setSpellCheckerEnabled(false)
}
if (process.env.NODE_ENV === "development") {
mainWindow.loadURL("http://localhost:3000")
mainWindow.webContents.openDevTools()
} else {
mainWindow.loadFile(join(__dirname, "../renderer/index.html"))
}
createApplicationMenu(mainWindow)
setupInstanceIPC(mainWindow)
mainWindow.on("closed", () => {
mainWindow = null
})
}
if (isMac) {
app.on("web-contents-created", (_, contents) => {
contents.session.setSpellCheckerEnabled(false)
})
}
app.whenReady().then(() => {
if (isMac) {
session.defaultSession.setSpellCheckerEnabled(false)
app.on("browser-window-created", (_, window) => {
window.webContents.session.setSpellCheckerEnabled(false)
})
if (app.dock) {
const dockIcon = nativeImage.createFromPath(getIconPath())
if (!dockIcon.isEmpty()) {
app.dock.setIcon(dockIcon)
}
}
}
console.log("[spellcheck] default session enabled:", session.defaultSession.isSpellCheckerEnabled())
createWindow()
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow()
}
})
})
app.on("window-all-closed", () => {
if (process.platform !== "darwin") {
app.quit()
}
})

View File

@@ -1,353 +0,0 @@
import { spawn, execSync, ChildProcess } from "child_process"
import { app, BrowserWindow } from "electron"
import { existsSync, statSync } from "fs"
import { buildUserShellCommand, getUserShellEnv, runUserShellCommandSync, supportsUserShell } from "./user-shell"
export interface ProcessInfo {
pid: number
port: number
binaryPath: string
}
interface ProcessMeta {
pid: number
port: number
folder: string
startTime: number
childProcess: ChildProcess
logs: string[]
instanceId: string
}
class ProcessManager {
private processes = new Map<number, ProcessMeta>()
private mainWindow: BrowserWindow | null = null
setMainWindow(window: BrowserWindow) {
this.mainWindow = window
}
private parseLogLevel(message: string): "info" | "error" | "warn" | "debug" {
const upperMessage = message.toUpperCase()
if (upperMessage.includes("[ERROR]") || upperMessage.includes("ERROR:")) return "error"
if (upperMessage.includes("[WARN]") || upperMessage.includes("WARN:")) return "warn"
if (upperMessage.includes("[DEBUG]") || upperMessage.includes("DEBUG:")) return "debug"
if (upperMessage.includes("[INFO]") || upperMessage.includes("INFO:")) return "info"
return "info"
}
private sendLog(instanceId: string, level: "info" | "error" | "warn" | "debug", message: string) {
if (this.mainWindow && message.trim()) {
const parsedLevel = this.parseLogLevel(message)
this.mainWindow.webContents.send("instance:log", {
id: instanceId,
entry: {
timestamp: Date.now(),
level: parsedLevel,
message: message.trim(),
},
})
}
}
async spawn(
folder: string,
instanceId: string,
binaryPath?: string,
environmentVariables?: Record<string, string>,
): Promise<ProcessInfo> {
this.validateFolder(folder)
const useUserShell = supportsUserShell()
const logAttempt = (message: string) => {
console.info(`[ProcessManager] ${message}`)
this.sendLog(instanceId, "debug", message)
}
const env = useUserShell ? getUserShellEnv() : { ...process.env }
if (environmentVariables) {
Object.assign(env, environmentVariables)
this.sendLog(
instanceId,
"info",
`Using ${Object.keys(environmentVariables).length} custom environment variables:`,
)
// Log each environment variable
for (const [key, value] of Object.entries(environmentVariables)) {
this.sendLog(instanceId, "info", ` ${key}=${value}`)
}
}
let targetBinary: string
if (!binaryPath || binaryPath === "opencode") {
targetBinary = useUserShell ? "opencode" : this.validateOpenCodeBinary(logAttempt)
} else {
targetBinary = this.validateCustomBinary(binaryPath, logAttempt)
}
const spawnCommand = useUserShell
? this.buildShellServeCommand(targetBinary)
: { command: targetBinary, args: this.buildServeArgs() }
const launchDetail = `${spawnCommand.command} ${spawnCommand.args.join(" ")}`.trim()
this.sendLog(instanceId, "debug", `Launching process with: ${launchDetail}`)
this.sendLog(
instanceId,
"info",
`Starting OpenCode server for ${folder} using ${targetBinary}...`,
)
return new Promise((resolve, reject) => {
const child = spawn(spawnCommand.command, spawnCommand.args, {
cwd: folder,
stdio: ["ignore", "pipe", "pipe"],
env,
shell: false,
})
const timeout = setTimeout(() => {
child.kill("SIGKILL")
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) => {
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)
if (error.message.includes("ENOENT")) {
reject(new Error("opencode binary not found in PATH"))
} else {
reject(error)
}
})
child.on("exit", (code, signal) => {
clearTimeout(timeout)
this.processes.delete(child.pid!)
if (!portFound) {
const errorMsg = stderrBuffer || `Process exited with code ${code}`
reject(new Error(errorMsg))
}
})
})
}
async kill(pid: number): Promise<void> {
const meta = this.processes.get(pid)
if (!meta) {
// Treat unknown processes as already stopped so tabs close cleanly
return
}
return new Promise((resolve, reject) => {
const child = meta.childProcess
const killTimeout = setTimeout(() => {
child.kill("SIGKILL")
}, 2000)
child.on("exit", () => {
clearTimeout(killTimeout)
this.processes.delete(pid)
resolve()
})
child.kill("SIGTERM")
})
}
getStatus(pid: number): "running" | "stopped" | "unknown" {
if (!this.processes.has(pid)) {
return "unknown"
}
try {
process.kill(pid, 0)
return "running"
} catch {
return "stopped"
}
}
getAllProcesses(): Map<number, ProcessMeta> {
return new Map(this.processes)
}
async cleanup(): Promise<void> {
const killPromises = Array.from(this.processes.keys()).map((pid) => this.kill(pid).catch(() => {}))
await Promise.all(killPromises)
}
private validateFolder(folder: string): void {
if (!existsSync(folder)) {
throw new Error(`Folder does not exist: ${folder}`)
}
const stats = statSync(folder)
if (!stats.isDirectory()) {
throw new Error(`Path is not a directory: ${folder}`)
}
}
private validateOpenCodeBinary(logAttempt?: (message: string) => void): string {
const log = logAttempt ?? ((message: string) => console.info(`[ProcessManager] ${message}`))
if (process.platform === "win32") {
log("Checking PATH via 'where opencode'")
return this.resolveBinaryViaLocator("where opencode", log)
}
const shellCheck = buildUserShellCommand("command -v opencode")
const shellPreview = [shellCheck.command, ...shellCheck.args].join(" ")
log(`Checking PATH via shell: ${shellPreview}`)
try {
const resolved = runUserShellCommandSync("command -v opencode")
const path = this.pickFirstPath(resolved)
if (path) {
log(`Shell located opencode at ${path}`)
return path
}
throw new Error("Empty result from shell lookup")
} catch (shellError) {
const message = shellError instanceof Error ? shellError.message : String(shellError)
log(`Shell lookup failed: ${message}`)
try {
log("Fallback to 'which opencode'")
return this.resolveBinaryViaLocator("which opencode", log)
} catch (locatorError) {
const locatorMessage = locatorError instanceof Error ? locatorError.message : String(locatorError)
log(`Locator fallback failed: ${locatorMessage}`)
throw new Error(
"opencode binary not found in PATH. Please install OpenCode CLI first: npm install -g @opencode/cli",
)
}
}
}
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 {
throw new Error(`Binary is not executable: ${binaryPath}`)
}
}
return binaryPath
}
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

@@ -1,82 +0,0 @@
import { contextBridge, ipcRenderer } from "electron"
export interface ElectronAPI {
selectFolder: () => Promise<string | null>
createInstance: (
id: string,
folder: string,
binaryPath?: string,
environmentVariables?: Record<string, string>,
) => Promise<{ id: string; port: number; pid: number; binaryPath: string }>
stopInstance: (pid: number) => Promise<void>
onInstanceStarted: (callback: (data: { id: string; port: number; pid: number; binaryPath: string }) => void) => void
onInstanceError: (callback: (data: { id: string; error: string }) => void) => void
onInstanceStopped: (callback: (data: { id: string }) => void) => void
onInstanceLog: (
callback: (data: {
id: string
entry: { timestamp: number; level: "info" | "error" | "warn" | "debug"; message: string }
}) => void,
) => void
onNewInstance: (callback: () => void) => void
scanDirectory: (workspaceFolder: string) => Promise<string[]>
// OpenCode binary operations
selectOpenCodeBinary: () => Promise<string | null>
validateOpenCodeBinary: (path: string) => Promise<{ valid: boolean; version?: string; error?: string }>
// Storage operations
getConfigPath: () => Promise<string>
getInstancesDir: () => Promise<string>
readConfigFile: () => Promise<string>
writeConfigFile: (content: string) => Promise<void>
readInstanceFile: (instanceId: string) => Promise<string>
writeInstanceFile: (instanceId: string, content: string) => Promise<void>
deleteInstanceFile: (instanceId: string) => Promise<void>
onConfigChanged: (callback: () => void) => () => void
}
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
}
}

BIN
images/browser-support.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

BIN
images/image-previews.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

2526
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,161 +1,27 @@
{
"name": "@shantur/codenomad",
"version": "0.1.2",
"description": "CodeNomad - AI coding assistant",
"author": {
"name": "Shantur Rathore",
"email": "codenomad@shantur.com"
"name": "codenomad-workspace",
"version": "0.2.0",
"private": true,
"description": "CodeNomad monorepo workspace",
"workspaces": {
"packages": [
"packages/*"
]
},
"type": "module",
"main": "dist/main/main.js",
"scripts": {
"dev": "electron-vite dev",
"dev:electron": "NODE_ENV=development electron .",
"build": "electron-vite build",
"typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.node.json",
"preview": "electron-vite preview",
"build:binaries": "node scripts/build.js",
"build:mac": "node scripts/build.js mac",
"build:mac-x64": "node scripts/build.js mac-x64",
"build:mac-arm64": "node scripts/build.js mac-arm64",
"build:win": "node scripts/build.js win",
"build:win-arm64": "node scripts/build.js win-arm64",
"build:linux": "node scripts/build.js linux",
"build:linux-arm64": "node scripts/build.js linux-arm64",
"build:all": "node scripts/build.js all",
"package:mac": "electron-builder --mac",
"package:win": "electron-builder --win",
"package:linux": "electron-builder --linux"
"dev": "npm run dev --workspace @neuralnomads/codenomad-electron-app",
"dev:electron": "npm run dev --workspace @neuralnomads/codenomad-electron-app",
"dev:tauri": "npm run dev --workspace @codenomad/tauri-app",
"build": "npm run build --workspace @neuralnomads/codenomad-electron-app",
"build:tauri": "npm run build --workspace @codenomad/tauri-app",
"build:ui": "npm run build --workspace @codenomad/ui",
"build:mac-x64": "npm run build:mac-x64 --workspace @neuralnomads/codenomad-electron-app",
"build:binaries": "npm run build:binaries --workspace @neuralnomads/codenomad-electron-app",
"typecheck": "npm run typecheck --workspace @codenomad/ui && npm run typecheck --workspace @neuralnomads/codenomad-electron-app",
"bumpVersion": "npm version --workspaces --include-workspace-root --no-git-tag-version"
},
"dependencies": {
"@git-diff-view/solid": "^0.0.8",
"@kobalte/core": "0.13.11",
"@opencode-ai/sdk": "1.0.68",
"@solidjs/router": "^0.13.0",
"github-markdown-css": "^5.8.1",
"ignore": "7.0.5",
"lucide-solid": "^0.300.0",
"marked": "^12.0.0",
"shiki": "^3.13.0",
"solid-js": "^1.8.0",
"solid-toast": "^0.5.0"
},
"devDependencies": {
"@tsconfig/bun": "^1.0.9",
"autoprefixer": "10.4.21",
"electron": "39.0.0",
"png2icons": "^2.0.1",
"pngjs": "^7.0.0",
"electron-builder": "^24.0.0",
"electron-vite": "4.0.1",
"postcss": "8.5.6",
"tailwindcss": "3",
"typescript": "^5.3.0",
"vite": "^5.0.0",
"vite-plugin-solid": "^2.10.0"
},
"build": {
"appId": "ai.opencode.client",
"productName": "CodeNomad",
"directories": {
"output": "release",
"buildResources": "electron/resources"
},
"files": [
"dist/**/*",
"package.json"
],
"mac": {
"category": "public.app-category.developer-tools",
"target": [
{
"target": "dmg",
"arch": [
"x64",
"arm64",
"universal"
]
},
{
"target": "zip",
"arch": [
"x64",
"arm64",
"universal"
]
}
],
"artifactName": "${productName}-${version}-${os}-${arch}.${ext}",
"icon": "electron/resources/icon.icns"
},
"dmg": {
"contents": [
{
"x": 130,
"y": 220
},
{
"x": 410,
"y": 220,
"type": "link",
"path": "/Applications"
}
]
},
"win": {
"target": [
{
"target": "nsis",
"arch": [
"x64",
"arm64"
]
},
{
"target": "zip",
"arch": [
"x64",
"arm64"
]
}
],
"artifactName": "${productName}-${version}-${os}-${arch}.${ext}",
"icon": "electron/resources/icon.ico"
},
"nsis": {
"oneClick": false,
"allowToChangeInstallationDirectory": true,
"createDesktopShortcut": true,
"createStartMenuShortcut": true
},
"linux": {
"target": [
{
"target": "AppImage",
"arch": [
"x64",
"arm64"
]
},
{
"target": "deb",
"arch": [
"x64",
"arm64"
]
},
{
"target": "tar.gz",
"arch": [
"x64",
"arm64"
]
}
],
"artifactName": "${productName}-${version}-${os}-${arch}.${ext}",
"category": "Development",
"icon": "electron/resources/icon.png"
}
},
"private": true
"7zip-bin": "^5.2.0",
"google-auth-library": "^10.5.0"
}
}

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

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

View File

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

View File

@@ -2,6 +2,11 @@ import { defineConfig, externalizeDepsPlugin } from "electron-vite"
import solid from "vite-plugin-solid"
import { resolve } from "path"
const uiRoot = resolve(__dirname, "../ui")
const uiSrc = resolve(uiRoot, "src")
const uiRendererRoot = resolve(uiRoot, "src/renderer")
const uiRendererEntry = resolve(uiRendererRoot, "index.html")
export default defineConfig({
main: {
plugins: [externalizeDepsPlugin()],
@@ -20,7 +25,7 @@ export default defineConfig({
build: {
outDir: "dist/preload",
lib: {
entry: resolve(__dirname, "electron/preload/index.ts"),
entry: resolve(__dirname, "electron/preload/index.cjs"),
formats: ["cjs"],
fileName: () => "index.js",
},
@@ -33,21 +38,24 @@ export default defineConfig({
},
},
renderer: {
root: "./src/renderer",
root: uiRendererRoot,
plugins: [solid()],
css: {
postcss: "./postcss.config.js",
postcss: resolve(uiRoot, "postcss.config.js"),
},
resolve: {
alias: {
"@": resolve(__dirname, "./src"),
"@": uiSrc,
},
},
server: {
port: 3000,
},
build: {
outDir: "dist/renderer",
outDir: resolve(__dirname, "dist/renderer"),
rollupOptions: {
input: uiRendererEntry,
},
},
},
})

View File

@@ -0,0 +1,30 @@
import { BrowserWindow, ipcMain } from "electron"
import type { CliLogEntry, CliProcessManager, CliStatus } from "./process-manager"
export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessManager) {
cliManager.on("status", (status: CliStatus) => {
if (!mainWindow.isDestroyed()) {
mainWindow.webContents.send("cli:status", status)
}
})
cliManager.on("ready", (status: CliStatus) => {
if (!mainWindow.isDestroyed()) {
mainWindow.webContents.send("cli:ready", status)
}
})
cliManager.on("log", (entry: CliLogEntry) => {
if (!mainWindow.isDestroyed()) {
mainWindow.webContents.send("cli:log", entry)
}
})
cliManager.on("error", (error: Error) => {
if (!mainWindow.isDestroyed()) {
mainWindow.webContents.send("cli:error", { message: error.message })
}
})
ipcMain.handle("cli:getStatus", async () => cliManager.getStatus())
}

View File

@@ -0,0 +1,290 @@
import { app, BrowserView, BrowserWindow, nativeImage, session } from "electron"
import { existsSync } from "fs"
import { dirname, join } from "path"
import { fileURLToPath } from "url"
import { createApplicationMenu } from "./menu"
import { setupCliIPC } from "./ipc"
import { CliProcessManager } from "./process-manager"
const mainFilename = fileURLToPath(import.meta.url)
const mainDirname = dirname(mainFilename)
const isMac = process.platform === "darwin"
const cliManager = new CliProcessManager()
let mainWindow: BrowserWindow | null = null
let currentCliUrl: string | null = null
let pendingCliUrl: string | null = null
let showingLoadingScreen = false
let preloadingView: BrowserView | null = null
if (isMac) {
app.commandLine.appendSwitch("disable-spell-checking")
}
function getIconPath() {
if (app.isPackaged) {
return join(process.resourcesPath, "icon.png")
}
return join(mainDirname, "../resources/icon.png")
}
function getLoadingHtmlPath() {
if (app.isPackaged) {
return join(process.resourcesPath, "loading.html")
}
const distResources = join(mainDirname, "../resources/loading.html")
if (existsSync(distResources)) {
return distResources
}
const devResources = join(mainDirname, "../electron/resources/loading.html")
if (existsSync(devResources)) {
return devResources
}
return join(process.cwd(), "electron/resources/loading.html")
}
let cachedPreloadPath: string | null = null
function getPreloadPath() {
if (cachedPreloadPath && existsSync(cachedPreloadPath)) {
return cachedPreloadPath
}
const candidates = [
join(process.resourcesPath, "preload/index.js"),
join(mainDirname, "../preload/index.js"),
join(mainDirname, "../preload/index.cjs"),
join(mainDirname, "../../preload/index.cjs"),
join(mainDirname, "../../electron/preload/index.cjs"),
join(app.getAppPath(), "preload/index.cjs"),
join(app.getAppPath(), "electron/preload/index.cjs"),
]
for (const candidate of candidates) {
if (existsSync(candidate)) {
cachedPreloadPath = candidate
return candidate
}
}
return join(mainDirname, "../preload/index.js")
}
function destroyPreloadingView(target?: BrowserView | null) {
const view = target ?? preloadingView
if (!view) {
return
}
try {
const contents = view.webContents as any
contents?.destroy?.()
} catch (error) {
console.warn("[cli] failed to destroy preloading view", error)
}
if (!target || view === preloadingView) {
preloadingView = null
}
}
function createWindow() {
const prefersDark = true
const backgroundColor = prefersDark ? "#1a1a1a" : "#ffffff"
const iconPath = getIconPath()
mainWindow = new BrowserWindow({
width: 1400,
height: 900,
minWidth: 800,
minHeight: 600,
backgroundColor,
icon: iconPath,
webPreferences: {
preload: getPreloadPath(),
contextIsolation: true,
nodeIntegration: false,
spellcheck: !isMac,
},
})
if (isMac) {
mainWindow.webContents.session.setSpellCheckerEnabled(false)
}
const loadingHtml = getLoadingHtmlPath()
showingLoadingScreen = true
currentCliUrl = null
mainWindow.loadFile(loadingHtml).catch((error) => console.error("[cli] failed to load loading screen:", error))
if (process.env.NODE_ENV === "development") {
mainWindow.webContents.openDevTools({ mode: "detach" })
}
createApplicationMenu(mainWindow)
setupCliIPC(mainWindow, cliManager)
mainWindow.on("closed", () => {
destroyPreloadingView()
mainWindow = null
currentCliUrl = null
pendingCliUrl = null
showingLoadingScreen = false
})
if (pendingCliUrl) {
const url = pendingCliUrl
pendingCliUrl = null
startCliPreload(url)
}
}
function showLoadingScreen(force = false) {
if (!mainWindow || mainWindow.isDestroyed()) {
return
}
if (showingLoadingScreen && !force) {
return
}
destroyPreloadingView()
showingLoadingScreen = true
currentCliUrl = null
pendingCliUrl = null
const loadingHtml = getLoadingHtmlPath()
mainWindow.loadFile(loadingHtml).catch((error) => console.error("[cli] failed to load loading screen:", error))
}
function startCliPreload(url: string) {
if (!mainWindow || mainWindow.isDestroyed()) {
pendingCliUrl = url
return
}
if (currentCliUrl === url && !showingLoadingScreen) {
return
}
pendingCliUrl = url
destroyPreloadingView()
if (!showingLoadingScreen) {
showLoadingScreen(true)
}
const view = new BrowserView({
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
spellcheck: !isMac,
},
})
preloadingView = view
view.webContents.once("did-finish-load", () => {
if (preloadingView !== view) {
destroyPreloadingView(view)
return
}
finalizeCliSwap(url)
})
view.webContents.loadURL(url).catch((error) => {
console.error("[cli] failed to preload CLI view:", error)
if (preloadingView === view) {
destroyPreloadingView(view)
}
})
}
function finalizeCliSwap(url: string) {
destroyPreloadingView()
if (!mainWindow || mainWindow.isDestroyed()) {
pendingCliUrl = url
return
}
showingLoadingScreen = false
currentCliUrl = url
pendingCliUrl = null
mainWindow.loadURL(url).catch((error) => console.error("[cli] failed to load CLI view:", error))
}
async function startCli() {
try {
const devMode = process.env.NODE_ENV === "development"
console.info("[cli] start requested (dev mode:", devMode, ")")
await cliManager.start({ dev: devMode })
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
console.error("[cli] start failed:", message)
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send("cli:error", { message })
}
}
}
cliManager.on("ready", (status) => {
if (!status.url) {
return
}
startCliPreload(status.url)
})
cliManager.on("status", (status) => {
if (status.state !== "ready") {
showLoadingScreen()
}
})
if (isMac) {
app.on("web-contents-created", (_, contents) => {
contents.session.setSpellCheckerEnabled(false)
})
}
app.whenReady().then(() => {
startCli()
if (isMac) {
session.defaultSession.setSpellCheckerEnabled(false)
app.on("browser-window-created", (_, window) => {
window.webContents.session.setSpellCheckerEnabled(false)
})
if (app.dock) {
const dockIcon = nativeImage.createFromPath(getIconPath())
if (!dockIcon.isEmpty()) {
app.dock.setIcon(dockIcon)
}
}
}
createWindow()
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow()
}
})
})
app.on("before-quit", async (event) => {
event.preventDefault()
await cliManager.stop().catch(() => {})
app.exit(0)
})
app.on("window-all-closed", () => {
if (process.platform !== "darwin") {
app.quit()
}
})

View File

@@ -0,0 +1,320 @@
import { spawn, type ChildProcess } from "child_process"
import { app } from "electron"
import { createRequire } from "module"
import { EventEmitter } from "events"
import { existsSync } from "fs"
import path from "path"
import { buildUserShellCommand, getUserShellEnv, supportsUserShell } from "./user-shell"
const nodeRequire = createRequire(import.meta.url)
type CliState = "starting" | "ready" | "error" | "stopped"
export interface CliStatus {
state: CliState
pid?: number
port?: number
url?: string
error?: string
}
export interface CliLogEntry {
stream: "stdout" | "stderr"
message: string
}
interface StartOptions {
dev: boolean
}
interface CliEntryResolution {
entry: string
runner: "node" | "tsx"
runnerPath?: string
}
export declare interface CliProcessManager {
on(event: "status", listener: (status: CliStatus) => void): this
on(event: "ready", listener: (status: CliStatus) => void): this
on(event: "log", listener: (entry: CliLogEntry) => void): this
on(event: "exit", listener: (status: CliStatus) => void): this
on(event: "error", listener: (error: Error) => void): this
}
export class CliProcessManager extends EventEmitter {
private child?: ChildProcess
private status: CliStatus = { state: "stopped" }
private stdoutBuffer = ""
private stderrBuffer = ""
async start(options: StartOptions): Promise<CliStatus> {
if (this.child) {
await this.stop()
}
this.stdoutBuffer = ""
this.stderrBuffer = ""
this.updateStatus({ state: "starting", port: undefined, pid: undefined, url: undefined, error: undefined })
const cliEntry = this.resolveCliEntry(options)
const args = this.buildCliArgs(options)
console.info(
`[cli] launching CodeNomad CLI (${options.dev ? "dev" : "prod"}) using ${cliEntry.runner} at ${cliEntry.entry}`,
)
const env = supportsUserShell() ? getUserShellEnv() : { ...process.env }
env.ELECTRON_RUN_AS_NODE = "1"
const spawnDetails = supportsUserShell()
? buildUserShellCommand(`ELECTRON_RUN_AS_NODE=1 exec ${this.buildCommand(cliEntry, args)}`)
: this.buildDirectSpawn(cliEntry, args)
const child = spawn(spawnDetails.command, spawnDetails.args, {
cwd: process.cwd(),
stdio: ["ignore", "pipe", "pipe"],
env,
shell: false,
})
console.info(`[cli] spawn command: ${spawnDetails.command} ${spawnDetails.args.join(" ")}`)
if (!child.pid) {
console.error("[cli] spawn failed: no pid")
}
this.child = child
this.updateStatus({ pid: child.pid ?? undefined })
child.stdout?.on("data", (data: Buffer) => {
this.handleStream(data.toString(), "stdout")
})
child.stderr?.on("data", (data: Buffer) => {
this.handleStream(data.toString(), "stderr")
})
child.on("error", (error) => {
console.error("[cli] failed to start CLI:", error)
this.updateStatus({ state: "error", error: error.message })
this.emit("error", error)
})
child.on("exit", (code, signal) => {
const failed = this.status.state !== "ready"
const error = failed ? this.status.error ?? `CLI exited with code ${code ?? 0}${signal ? ` (${signal})` : ""}` : undefined
console.info(`[cli] exit (code=${code}, signal=${signal || ""})${error ? ` error=${error}` : ""}`)
this.updateStatus({ state: failed ? "error" : "stopped", error })
if (failed && error) {
this.emit("error", new Error(error))
}
this.emit("exit", this.status)
this.child = undefined
})
return new Promise<CliStatus>((resolve, reject) => {
const timeout = setTimeout(() => {
this.handleTimeout()
reject(new Error("CLI startup timeout"))
}, 15000)
this.once("ready", (status) => {
clearTimeout(timeout)
resolve(status)
})
this.once("error", (error) => {
clearTimeout(timeout)
reject(error)
})
})
}
async stop(): Promise<void> {
const child = this.child
if (!child) {
this.updateStatus({ state: "stopped" })
return
}
return new Promise((resolve) => {
const killTimeout = setTimeout(() => {
child.kill("SIGKILL")
}, 4000)
child.on("exit", () => {
clearTimeout(killTimeout)
this.child = undefined
console.info("[cli] CLI process exited")
this.updateStatus({ state: "stopped" })
resolve()
})
child.kill("SIGTERM")
})
}
getStatus(): CliStatus {
return { ...this.status }
}
private handleTimeout() {
if (this.child) {
this.child.kill("SIGKILL")
this.child = undefined
}
this.updateStatus({ state: "error", error: "CLI did not start in time" })
this.emit("error", new Error("CLI did not start in time"))
}
private handleStream(chunk: string, stream: "stdout" | "stderr") {
if (stream === "stdout") {
this.stdoutBuffer += chunk
this.processBuffer("stdout")
} else {
this.stderrBuffer += chunk
this.processBuffer("stderr")
}
}
private processBuffer(stream: "stdout" | "stderr") {
const buffer = stream === "stdout" ? this.stdoutBuffer : this.stderrBuffer
const lines = buffer.split("\n")
const trailing = lines.pop() ?? ""
if (stream === "stdout") {
this.stdoutBuffer = trailing
} else {
this.stderrBuffer = trailing
}
for (const line of lines) {
if (!line.trim()) continue
console.info(`[cli][${stream}] ${line}`)
this.emit("log", { stream, message: line })
const port = this.extractPort(line)
if (port && this.status.state === "starting") {
const url = `http://127.0.0.1:${port}`
console.info(`[cli] ready on ${url}`)
this.updateStatus({ state: "ready", port, url })
this.emit("ready", this.status)
}
}
}
private extractPort(line: string): number | null {
const readyMatch = line.match(/CodeNomad Server is ready at http:\/\/[^:]+:(\d+)/i)
if (readyMatch) {
return parseInt(readyMatch[1], 10)
}
if (line.toLowerCase().includes("http server listening")) {
const httpMatch = line.match(/:(\d{2,5})(?!.*:\d)/)
if (httpMatch) {
return parseInt(httpMatch[1], 10)
}
try {
const parsed = JSON.parse(line)
if (typeof parsed.port === "number") {
return parsed.port
}
} catch {
// not JSON, ignore
}
}
return null
}
private updateStatus(patch: Partial<CliStatus>) {
this.status = { ...this.status, ...patch }
this.emit("status", this.status)
}
private buildCliArgs(options: StartOptions): string[] {
const args = ["serve", "--host", "127.0.0.1", "--port", "0"]
if (options.dev) {
args.push("--ui-dev-server", "http://localhost:3000", "--log-level", "debug")
}
return args
}
private buildCommand(cliEntry: CliEntryResolution, args: string[]): string {
const parts = [JSON.stringify(process.execPath)]
if (cliEntry.runner === "tsx" && cliEntry.runnerPath) {
parts.push(JSON.stringify(cliEntry.runnerPath))
}
parts.push(JSON.stringify(cliEntry.entry))
args.forEach((arg) => parts.push(JSON.stringify(arg)))
return parts.join(" ")
}
private buildDirectSpawn(cliEntry: CliEntryResolution, args: string[]) {
if (cliEntry.runner === "tsx") {
return { command: process.execPath, args: [cliEntry.runnerPath!, cliEntry.entry, ...args] }
}
return { command: process.execPath, args: [cliEntry.entry, ...args] }
}
private resolveCliEntry(options: StartOptions): CliEntryResolution {
if (options.dev) {
const tsxPath = this.resolveTsx()
const sourceCandidates = [
path.resolve(app.getAppPath(), "..", "server", "src", "index.ts"),
path.resolve(app.getAppPath(), "..", "packages", "server", "src", "index.ts"),
path.resolve(process.cwd(), "packages", "server", "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 @neuralnomads/codenomad.")
}
private resolveTsx(): string | null {
try {
const resolved = nodeRequire.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)> = [
() => nodeRequire.resolve("@neuralnomads/codenomad/dist/bin.js"),
() => nodeRequire.resolve("@neuralnomads/codenomad/dist/bin.js", { paths: [app.getAppPath()] }),
path.join(app.getAppPath(), "node_modules", "@neuralnomads", "codenomad", "dist", "bin.js"),
path.resolve(app.getAppPath(), "..", "server", "dist", "bin.js"),
path.resolve(app.getAppPath(), "..", "packages", "server", "dist", "bin.js"),
path.join(process.resourcesPath, "app.asar.unpacked", "node_modules", "@neuralnomads", "codenomad", "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
}
}

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

Before

Width:  |  Height:  |  Size: 422 KiB

After

Width:  |  Height:  |  Size: 422 KiB

View File

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

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

@@ -1,5 +1,5 @@
{
"extends": "../tsconfig.node.json",
"extends": "../tsconfig.json",
"compilerOptions": {
"noEmit": true
},

View File

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

View File

@@ -7,10 +7,12 @@ import { fileURLToPath } from "url"
const __dirname = fileURLToPath(new URL(".", import.meta.url))
const appDir = join(__dirname, "..")
const workspaceRoot = join(appDir, "..", "..")
const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm"
const npxCmd = process.platform === "win32" ? "npx.cmd" : "npx"
const nodeModulesPath = join(appDir, "node_modules")
const workspaceNodeModulesPath = join(workspaceRoot, "node_modules")
const platforms = {
mac: {
@@ -41,6 +43,10 @@ const platforms = {
args: ["--linux", "--arm64"],
description: "Linux (ARM64)",
},
"linux-rpm": {
args: ["--linux", "rpm", "--x64", "--arm64"],
description: "Linux RPM packages (x64 & ARM64)",
},
all: {
args: ["--mac", "--win", "--linux", "--x64", "--arm64"],
description: "All platforms (macOS, Windows, Linux)",
@@ -89,10 +95,16 @@ async function build(platform) {
console.log(`\n🔨 Building for: ${config.description}\n`)
try {
console.log("📦 Step 1/2: Building Electron app...\n")
console.log("📦 Step 1/3: Building CLI dependency...\n")
await run(npmCmd, ["run", "build", "--workspace", "@neuralnomads/codenomad"], {
cwd: workspaceRoot,
env: { NODE_PATH: workspaceNodeModulesPath },
})
console.log("\n📦 Step 2/3: Building Electron app...\n")
await run(npmCmd, ["run", "build"])
console.log("\n📦 Step 2/2: Packaging binaries...\n")
console.log("\n📦 Step 3/3: Packaging binaries...\n")
const distPath = join(appDir, "dist")
if (!existsSync(distPath)) {
throw new Error("dist/ directory not found. Build failed.")

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

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

View File

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

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

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

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,10 @@
import { FastifyInstance } from "fastify"
import { ServerMeta } from "../../api-types"
interface RouteDeps {
serverMeta: ServerMeta
}
export function registerMetaRoutes(app: FastifyInstance, deps: RouteDeps) {
app.get("/api/meta", async () => deps.serverMeta)
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,184 @@
import path from "path"
import { EventBus } from "../events/bus"
import { ConfigStore } from "../config/store"
import { BinaryRegistry } from "../config/binaries"
import { FileSystemBrowser } from "../filesystem/browser"
import { searchWorkspaceFiles, WorkspaceFileSearchOptions } from "../filesystem/search"
import { clearWorkspaceSearchCache } from "../filesystem/search-cache"
import { WorkspaceDescriptor, WorkspaceFileResponse, FileSystemEntry } from "../api-types"
import { WorkspaceRuntime } from "./runtime"
import { Logger } from "../logger"
interface WorkspaceManagerOptions {
rootDir: string
configStore: ConfigStore
binaryRegistry: BinaryRegistry
eventBus: EventBus
logger: Logger
}
interface WorkspaceRecord extends WorkspaceDescriptor {}
export class WorkspaceManager {
private readonly workspaces = new Map<string, WorkspaceRecord>()
private readonly runtime: WorkspaceRuntime
constructor(private readonly options: WorkspaceManagerOptions) {
this.runtime = new WorkspaceRuntime(this.options.eventBus, this.options.logger)
}
list(): WorkspaceDescriptor[] {
return Array.from(this.workspaces.values())
}
get(id: string): WorkspaceDescriptor | undefined {
return this.workspaces.get(id)
}
getInstancePort(id: string): number | undefined {
return this.workspaces.get(id)?.port
}
listFiles(workspaceId: string, relativePath = "."): FileSystemEntry[] {
const workspace = this.requireWorkspace(workspaceId)
const browser = new FileSystemBrowser({ rootDir: workspace.path })
return browser.list(relativePath)
}
searchFiles(workspaceId: string, query: string, options?: WorkspaceFileSearchOptions): FileSystemEntry[] {
const workspace = this.requireWorkspace(workspaceId)
return searchWorkspaceFiles(workspace.path, query, options)
}
readFile(workspaceId: string, relativePath: string): WorkspaceFileResponse {
const workspace = this.requireWorkspace(workspaceId)
const browser = new FileSystemBrowser({ rootDir: workspace.path })
const contents = browser.readFile(relativePath)
return {
workspaceId,
relativePath,
contents,
}
}
async create(folder: string, name?: string): Promise<WorkspaceDescriptor> {
const id = `${Date.now().toString(36)}`
const binary = this.options.binaryRegistry.resolveDefault()
const workspacePath = path.isAbsolute(folder) ? folder : path.resolve(this.options.rootDir, folder)
clearWorkspaceSearchCache(workspacePath)
this.options.logger.info({ workspaceId: id, folder: workspacePath, binary: binary.path }, "Creating workspace")
const proxyPath = `/workspaces/${id}/instance`
const descriptor: WorkspaceRecord = {
id,
path: workspacePath,
name,
status: "starting",
proxyPath,
binaryId: binary.id,
binaryLabel: binary.label,
binaryVersion: binary.version,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}
this.workspaces.set(id, descriptor)
this.options.eventBus.publish({ type: "workspace.created", workspace: descriptor })
const environment = this.options.configStore.get().preferences.environmentVariables ?? {}
try {
const { pid, port } = await this.runtime.launch({
workspaceId: id,
folder: workspacePath,
binaryPath: binary.path,
environment,
onExit: (info) => this.handleProcessExit(info.workspaceId, info),
})
descriptor.pid = pid
descriptor.port = port
descriptor.status = "ready"
descriptor.updatedAt = new Date().toISOString()
this.options.eventBus.publish({ type: "workspace.started", workspace: descriptor })
this.options.logger.info({ workspaceId: id, port }, "Workspace ready")
return descriptor
} catch (error) {
descriptor.status = "error"
descriptor.error = error instanceof Error ? error.message : String(error)
descriptor.updatedAt = new Date().toISOString()
this.options.eventBus.publish({ type: "workspace.error", workspace: descriptor })
this.options.logger.error({ workspaceId: id, err: error }, "Workspace failed to start")
throw error
}
}
async delete(id: string): Promise<WorkspaceDescriptor | undefined> {
const workspace = this.workspaces.get(id)
if (!workspace) return undefined
this.options.logger.info({ workspaceId: id }, "Stopping workspace")
const wasRunning = Boolean(workspace.pid)
if (wasRunning) {
await this.runtime.stop(id).catch((error) => {
this.options.logger.warn({ workspaceId: id, err: error }, "Failed to stop workspace process cleanly")
})
}
this.workspaces.delete(id)
clearWorkspaceSearchCache(workspace.path)
if (!wasRunning) {
this.options.eventBus.publish({ type: "workspace.stopped", workspaceId: id })
}
return workspace
}
async shutdown() {
this.options.logger.info("Shutting down all workspaces")
for (const [id, workspace] of this.workspaces) {
if (workspace.pid) {
this.options.logger.info({ workspaceId: id }, "Stopping workspace during shutdown")
await this.runtime.stop(id).catch((error) => {
this.options.logger.error({ workspaceId: id, err: error }, "Failed to stop workspace during shutdown")
})
} else {
this.options.logger.debug({ workspaceId: id }, "Workspace already stopped")
}
}
this.workspaces.clear()
this.options.logger.info("All workspaces cleared")
}
private requireWorkspace(id: string): WorkspaceRecord {
const workspace = this.workspaces.get(id)
if (!workspace) {
throw new Error("Workspace not found")
}
return workspace
}
private handleProcessExit(workspaceId: string, info: { code: number | null; requested: boolean }) {
const workspace = this.workspaces.get(workspaceId)
if (!workspace) return
this.options.logger.info({ workspaceId, ...info }, "Workspace process exited")
workspace.pid = undefined
workspace.port = undefined
workspace.updatedAt = new Date().toISOString()
if (info.requested || info.code === 0) {
workspace.status = "stopped"
workspace.error = undefined
this.options.eventBus.publish({ type: "workspace.stopped", workspaceId })
} else {
workspace.status = "error"
workspace.error = `Process exited with code ${info.code}`
this.options.eventBus.publish({ type: "workspace.error", workspace })
}
}
}

View File

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

View File

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

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

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

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

@@ -0,0 +1,33 @@
# CodeNomad UI
This package contains the frontend user interface for CodeNomad, built with [SolidJS](https://www.solidjs.com/) and [Tailwind CSS](https://tailwindcss.com/).
## Overview
The UI is designed to be a high-performance, low-latency cockpit for managing OpenCode sessions. It connects to the CodeNomad server (either running locally via CLI or embedded in the Electron app).
## Features
- **SolidJS**: Fine-grained reactivity for high performance.
- **Tailwind CSS**: Utility-first styling for rapid development.
- **Vite**: Fast build tool and dev server.
## Development
To run the UI in standalone mode (connected to a running server):
```bash
npm run dev
```
This starts the Vite dev server at `http://localhost:3000`.
## Building
To build the production assets:
```bash
npm run build
```
The output will be generated in the `dist` directory, which is then consumed by the Server or Electron app.

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

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

View File

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

View File

@@ -43,7 +43,7 @@ const App: Component = () => {
const { isDark } = useTheme()
const {
preferences,
addRecentFolder,
recordWorkspaceLaunch,
toggleShowThinkingBlocks,
setDiffViewMode,
setToolOutputExpansion,
@@ -85,22 +85,16 @@ const App: Component = () => {
const clearLaunchError = () => setLaunchErrorBinary(null)
async function handleSelectFolder(folderPath?: string, binaryPath?: string) {
async function handleSelectFolder(folderPath: string, binaryPath?: string) {
if (!folderPath) {
return
}
setIsSelectingFolder(true)
const selectedBinary = binaryPath || preferences().lastUsedBinary || "opencode"
try {
let folder: string | null | undefined = folderPath
if (!folder) {
folder = await window.electronAPI.selectFolder()
if (!folder) {
return
}
}
addRecentFolder(folder)
recordWorkspaceLaunch(folderPath, selectedBinary)
clearLaunchError()
const instanceId = await createInstance(folder, selectedBinary)
const instanceId = await createInstance(folderPath, selectedBinary)
setHasInstances(true)
setShowFolderSelection(false)
setIsAdvancedSettingsOpen(false)
@@ -129,8 +123,6 @@ const App: Component = () => {
function handleNewInstanceRequest() {
if (hasInstances()) {
setShowFolderSelection(true)
} else {
void handleSelectFolder()
}
}

View File

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

View File

@@ -1,7 +1,7 @@
import { Component } from "solid-js"
import { Loader2 } from "lucide-solid"
const codeNomadIcon = new URL("../../images/CodeNomad-Icon.png", import.meta.url).href
const codeNomadIcon = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
interface EmptyStateProps {
onSelectFolder: () => void

View File

@@ -0,0 +1,448 @@
import { Component, Show, For, createSignal, createMemo, createEffect, onCleanup } from "solid-js"
import { Folder as FolderIcon, File as FileIcon, Loader2, Search, X, ArrowUpLeft } from "lucide-solid"
import type { FileSystemEntry, FileSystemListingMetadata } from "../../../server/src/api-types"
import { serverApi } from "../lib/api-client"
const MAX_RESULTS = 200
function normalizeEntryPath(path: string | undefined): string {
if (!path || path === "." || path === "./") {
return "."
}
let cleaned = path.replace(/\\/g, "/")
if (cleaned.startsWith("./")) {
cleaned = cleaned.replace(/^\.\/+/, "")
}
if (cleaned.startsWith("/")) {
cleaned = cleaned.replace(/^\/+/, "")
}
cleaned = cleaned.replace(/\/+/g, "/")
return cleaned === "" ? "." : cleaned
}
function resolveAbsolutePath(root: string, relativePath: string): string {
if (!root) {
return relativePath
}
if (!relativePath || relativePath === "." || relativePath === "./") {
return root
}
const separator = root.includes("\\") ? "\\" : "/"
const trimmedRoot = root.endsWith(separator) ? root : `${root}${separator}`
const normalized = relativePath.replace(/[\\/]+/g, separator).replace(/^[\\/]+/, "")
return `${trimmedRoot}${normalized}`
}
interface FileSystemBrowserDialogProps {
open: boolean
mode: "directories" | "files"
title: string
description?: string
onSelect: (absolutePath: string) => void
onClose: () => void
}
type FolderRow = { type: "up"; path: string } | { type: "entry"; entry: FileSystemEntry }
const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props) => {
const [rootPath, setRootPath] = createSignal("")
const [entries, setEntries] = createSignal<FileSystemEntry[]>([])
const [currentMetadata, setCurrentMetadata] = createSignal<FileSystemListingMetadata | null>(null)
const [loadingPath, setLoadingPath] = createSignal<string | null>(null)
const [error, setError] = createSignal<string | null>(null)
const [searchQuery, setSearchQuery] = createSignal("")
const [selectedIndex, setSelectedIndex] = createSignal(0)
let searchInputRef: HTMLInputElement | undefined
const directoryCache = new Map<string, FileSystemEntry[]>()
const metadataCache = new Map<string, FileSystemListingMetadata>()
const inFlightLoads = new Map<string, Promise<FileSystemListingMetadata>>()
function resetDialogState() {
directoryCache.clear()
metadataCache.clear()
inFlightLoads.clear()
setEntries([])
setCurrentMetadata(null)
setLoadingPath(null)
}
async function fetchDirectory(path: string, makeCurrent = false): Promise<FileSystemListingMetadata> {
const normalized = normalizeEntryPath(path)
if (directoryCache.has(normalized) && metadataCache.has(normalized)) {
if (makeCurrent) {
setCurrentMetadata(metadataCache.get(normalized) ?? null)
setEntries(directoryCache.get(normalized) ?? [])
}
return metadataCache.get(normalized) as FileSystemListingMetadata
}
if (inFlightLoads.has(normalized)) {
const metadata = await inFlightLoads.get(normalized)!
if (makeCurrent) {
setCurrentMetadata(metadata)
setEntries(directoryCache.get(normalized) ?? [])
}
return metadata
}
const loadPromise = (async () => {
setLoadingPath(normalized)
const response = await serverApi.listFileSystem(normalized === "." ? "." : normalized, {
includeFiles: props.mode === "files",
})
directoryCache.set(normalized, response.entries)
metadataCache.set(normalized, response.metadata)
if (!rootPath()) {
setRootPath(response.metadata.rootPath)
}
if (loadingPath() === normalized) {
setLoadingPath(null)
}
return response.metadata
})().catch((err) => {
if (loadingPath() === normalized) {
setLoadingPath(null)
}
throw err
})
inFlightLoads.set(normalized, loadPromise)
try {
const metadata = await loadPromise
if (makeCurrent) {
const key = normalizeEntryPath(metadata.currentPath)
setCurrentMetadata(metadata)
setEntries(directoryCache.get(key) ?? directoryCache.get(normalized) ?? [])
}
return metadata
} finally {
inFlightLoads.delete(normalized)
}
}
async function refreshEntries() {
setError(null)
resetDialogState()
try {
const metadata = await fetchDirectory(".", true)
setRootPath(metadata.rootPath)
setEntries(directoryCache.get(normalizeEntryPath(metadata.currentPath)) ?? [])
} catch (err) {
const message = err instanceof Error ? err.message : "Unable to load filesystem"
setError(message)
}
}
function describeLoadingPath() {
const path = loadingPath()
if (!path) {
return "filesystem"
}
if (path === ".") {
return rootPath() || "workspace root"
}
return resolveAbsolutePath(rootPath(), path)
}
function currentAbsolutePath(): string {
const metadata = currentMetadata()
if (!metadata) {
return rootPath()
}
if (metadata.pathKind === "relative") {
return resolveAbsolutePath(rootPath(), metadata.currentPath)
}
return metadata.displayPath
}
function handleOverlayClick(event: MouseEvent) {
if (event.target === event.currentTarget) {
props.onClose()
}
}
function handleEntrySelect(entry: FileSystemEntry) {
const absolute = resolveAbsolutePath(rootPath(), entry.path)
props.onSelect(absolute)
}
function handleNavigateTo(path: string) {
void fetchDirectory(path, true).catch((err) => {
console.error("Failed to open directory", err)
setError(err instanceof Error ? err.message : "Unable to open directory")
})
}
function handleNavigateUp() {
const parent = currentMetadata()?.parentPath
if (!parent) {
return
}
handleNavigateTo(parent)
}
const filteredEntries = createMemo(() => {
const query = searchQuery().trim().toLowerCase()
const subset = entries().filter((entry) => (props.mode === "directories" ? entry.type === "directory" : true))
if (!query) {
return subset
}
return subset.filter((entry) => {
const absolute = resolveAbsolutePath(rootPath(), entry.path)
return absolute.toLowerCase().includes(query) || entry.name.toLowerCase().includes(query)
})
})
const visibleEntries = createMemo(() => filteredEntries().slice(0, MAX_RESULTS))
const folderRows = createMemo<FolderRow[]>(() => {
const rows: FolderRow[] = []
const metadata = currentMetadata()
if (metadata?.parentPath) {
rows.push({ type: "up", path: metadata.parentPath })
}
for (const entry of visibleEntries()) {
rows.push({ type: "entry", entry })
}
return rows
})
createEffect(() => {
const list = visibleEntries()
if (list.length === 0) {
setSelectedIndex(0)
return
}
if (selectedIndex() >= list.length) {
setSelectedIndex(list.length - 1)
}
})
createEffect(() => {
if (!props.open) {
return
}
setSearchQuery("")
setSelectedIndex(0)
void refreshEntries()
setTimeout(() => searchInputRef?.focus(), 50)
const handleKeyDown = (event: KeyboardEvent) => {
if (!props.open) return
const results = visibleEntries()
if (event.key === "Escape") {
event.preventDefault()
props.onClose()
return
}
if (results.length === 0) {
return
}
if (event.key === "ArrowDown") {
event.preventDefault()
setSelectedIndex((prev) => Math.min(prev + 1, results.length - 1))
} else if (event.key === "ArrowUp") {
event.preventDefault()
setSelectedIndex((prev) => Math.max(prev - 1, 0))
} else if (event.key === "Enter") {
event.preventDefault()
const entry = results[selectedIndex()]
if (entry) {
handleEntrySelect(entry)
}
}
}
window.addEventListener("keydown", handleKeyDown)
onCleanup(() => {
window.removeEventListener("keydown", handleKeyDown)
resetDialogState()
setRootPath("")
setError(null)
})
})
return (
<Show when={props.open}>
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-6" onClick={handleOverlayClick}>
<div class="modal-surface max-h-full w-full max-w-3xl overflow-hidden rounded-xl bg-surface p-0" role="dialog" aria-modal="true">
<div class="panel flex flex-col">
<div class="panel-header flex items-start justify-between gap-4">
<div>
<h3 class="panel-title">{props.title}</h3>
<p class="panel-subtitle">{props.description || "Search for a path under the configured workspace root."}</p>
<Show when={rootPath()}>
<p class="text-xs text-muted mt-1 font-mono break-all">Root: {rootPath()}</p>
</Show>
</div>
<button type="button" class="selector-button selector-button-secondary" onClick={props.onClose}>
<X class="w-4 h-4" />
Close
</button>
</div>
<div class="panel-body">
<label class="w-full text-sm text-secondary mb-2 block">Filter</label>
<div class="selector-input-group">
<div class="flex items-center gap-2 px-3 text-muted">
<Search class="w-4 h-4" />
</div>
<input
ref={(el) => {
searchInputRef = el
}}
type="text"
value={searchQuery()}
onInput={(event) => setSearchQuery(event.currentTarget.value)}
placeholder={props.mode === "directories" ? "Search for folders" : "Search for files"}
class="selector-input"
/>
</div>
</div>
<Show when={props.mode === "directories"}>
<div class="px-4 pb-2">
<div class="flex items-center justify-between gap-3 rounded-md border border-border-subtle px-4 py-3">
<div>
<p class="text-xs text-secondary uppercase tracking-wide">Current folder</p>
<p class="text-sm font-mono text-primary break-all">{currentAbsolutePath()}</p>
</div>
<button
type="button"
class="selector-button selector-button-secondary whitespace-nowrap"
onClick={() => props.onSelect(currentAbsolutePath())}
>
Select Current
</button>
</div>
</div>
</Show>
<div class="panel-list panel-list--fill max-h-96 overflow-auto">
<Show
when={entries().length > 0}
fallback={
<div class="flex items-center justify-center py-6 text-sm text-secondary">
<Show
when={loadingPath() !== null}
fallback={<span class="text-red-500">{error()}</span>}
>
<div class="flex items-center gap-2">
<Loader2 class="w-4 h-4 animate-spin" />
<span>Loading {describeLoadingPath()}</span>
</div>
</Show>
</div>
}
>
<Show when={loadingPath()}>
<div class="flex items-center gap-2 px-4 py-2 text-xs text-secondary">
<Loader2 class="w-3.5 h-3.5 animate-spin" />
<span>Loading {describeLoadingPath()}</span>
</div>
</Show>
<Show
when={folderRows().length > 0}
fallback={
<div class="flex flex-col items-center justify-center gap-2 py-10 text-sm text-secondary">
<p>No entries found.</p>
<button type="button" class="selector-button selector-button-secondary" onClick={refreshEntries}>
Retry
</button>
</div>
}
>
<For each={folderRows()}>
{(row) => {
if (row.type === "up") {
return (
<div class="panel-list-item" role="button">
<div class="panel-list-item-content directory-browser-row">
<button type="button" class="directory-browser-row-main" onClick={handleNavigateUp}>
<div class="directory-browser-row-icon">
<ArrowUpLeft class="w-4 h-4" />
</div>
<div class="directory-browser-row-text">
<span class="directory-browser-row-name">Up one level</span>
</div>
</button>
</div>
</div>
)
}
const entry = row.entry
const selectEntry = () => handleEntrySelect(entry)
const activateEntry = () => {
if (entry.type === "directory") {
handleNavigateTo(entry.path)
} else {
selectEntry()
}
}
return (
<div class="panel-list-item" role="listitem">
<div class="panel-list-item-content directory-browser-row">
<button type="button" class="directory-browser-row-main" onClick={activateEntry}>
<div class="directory-browser-row-icon">
<Show when={entry.type === "directory"} fallback={<FileIcon class="w-4 h-4" />}>
<FolderIcon class="w-4 h-4" />
</Show>
</div>
<div class="directory-browser-row-text">
<span class="directory-browser-row-name">{entry.name || entry.path}</span>
<span class="directory-browser-row-sub">
{resolveAbsolutePath(rootPath(), entry.path)}
</span>
</div>
</button>
<button
type="button"
class="selector-button selector-button-secondary directory-browser-select"
onClick={(event) => {
event.stopPropagation()
selectEntry()
}}
>
Select
</button>
</div>
</div>
)
}}
</For>
</Show>
</Show>
</div>
<div class="panel-footer">
<div class="panel-footer-hints">
<div class="flex items-center gap-1.5">
<kbd class="kbd"></kbd>
<kbd class="kbd"></kbd>
<span>Navigate</span>
</div>
<div class="flex items-center gap-1.5">
<kbd class="kbd">Enter</kbd>
<span>Select</span>
</div>
<div class="flex items-center gap-1.5">
<kbd class="kbd">Esc</kbd>
<span>Close</span>
</div>
</div>
</div>
</div>
</div>
</div>
</Show>
)
}
export default FileSystemBrowserDialog

View File

@@ -2,12 +2,13 @@ import { Component, createSignal, Show, For, onMount, onCleanup, createEffect }
import { Folder, Clock, Trash2, FolderPlus, Settings, ChevronRight } from "lucide-solid"
import { useConfig } from "../stores/preferences"
import AdvancedSettingsModal from "./advanced-settings-modal"
import DirectoryBrowserDialog from "./directory-browser-dialog"
import Kbd from "./kbd"
const codeNomadLogo = new URL("../../images/CodeNomad-Icon.png", import.meta.url).href
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
interface FolderSelectionViewProps {
onSelectFolder: (folder?: string, binaryPath?: string) => void
onSelectFolder: (folder: string, binaryPath?: string) => void
isLoading?: boolean
advancedSettingsOpen?: boolean
onAdvancedSettingsOpen?: () => void
@@ -15,10 +16,11 @@ interface FolderSelectionViewProps {
}
const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
const { recentFolders, removeRecentFolder, preferences, updateLastUsedBinary } = useConfig()
const { recentFolders, removeRecentFolder, preferences } = useConfig()
const [selectedIndex, setSelectedIndex] = createSignal(0)
const [focusMode, setFocusMode] = createSignal<"recent" | "new" | null>("recent")
const [selectedBinary, setSelectedBinary] = createSignal(preferences().lastUsedBinary || "opencode")
const [isFolderBrowserOpen, setIsFolderBrowserOpen] = createSignal(false)
let recentListRef: HTMLDivElement | undefined
const folders = () => recentFolders()
@@ -167,18 +169,22 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
function handleFolderSelect(path: string) {
if (isLoading()) return
updateLastUsedBinary(selectedBinary())
props.onSelectFolder(path, selectedBinary())
}
function handleBrowse() {
if (isLoading()) return
updateLastUsedBinary(selectedBinary())
props.onSelectFolder(undefined, selectedBinary())
setFocusMode("new")
setIsFolderBrowserOpen(true)
}
function handleBrowserSelect(path: string) {
setIsFolderBrowserOpen(false)
handleFolderSelect(path)
}
function handleBinaryChange(binary: string) {
setSelectedBinary(binary)
}
@@ -378,6 +384,14 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
onBinaryChange={handleBinaryChange}
isLoading={props.isLoading}
/>
<DirectoryBrowserDialog
open={isFolderBrowserOpen()}
title="Select Workspace"
description="Select workspace to start coding."
onClose={() => setIsFolderBrowserOpen(false)}
onSelect={handleBrowserSelect}
/>
</>
)
}

View File

@@ -21,7 +21,79 @@ export default function MessageItem(props: MessageItemProps) {
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
}
const messageParts = () => props.parts ?? props.message.parts
type FilePart = Extract<ClientPart, { type: "file" }> & {
url?: string
mime?: string
filename?: string
}
const displayParts = () => props.parts ?? props.message.parts
const fileAttachments = () =>
props.message.parts.filter((part): part is FilePart => part?.type === "file" && typeof (part as FilePart).url === "string")
const getAttachmentName = (part: FilePart) => {
if (part.filename && part.filename.trim().length > 0) {
return part.filename
}
const url = part.url || ""
if (url.startsWith("data:")) {
return "attachment"
}
try {
const parsed = new URL(url)
const segments = parsed.pathname.split("/")
return segments.pop() || "attachment"
} catch (error) {
const fallback = url.split("/").pop()
return fallback && fallback.length > 0 ? fallback : "attachment"
}
}
const isImageAttachment = (part: FilePart) => {
if (part.mime && typeof part.mime === "string" && part.mime.startsWith("image/")) {
return true
}
return typeof part.url === "string" && part.url.startsWith("data:image/")
}
const handleAttachmentDownload = async (part: FilePart) => {
const url = part.url
if (!url) return
const filename = getAttachmentName(part)
const directDownload = (href: string) => {
const anchor = document.createElement("a")
anchor.href = href
anchor.download = filename
anchor.target = "_blank"
anchor.rel = "noopener"
document.body.appendChild(anchor)
anchor.click()
document.body.removeChild(anchor)
}
if (url.startsWith("data:")) {
directDownload(url)
return
}
if (url.startsWith("file://")) {
window.open(url, "_blank", "noopener")
return
}
try {
const response = await fetch(url)
if (!response.ok) throw new Error(`Failed to fetch attachment: ${response.status}`)
const blob = await response.blob()
const objectUrl = URL.createObjectURL(blob)
directDownload(objectUrl)
URL.revokeObjectURL(objectUrl)
} catch (error) {
directDownload(url)
}
}
const errorMessage = () => {
const info = props.messageInfo
@@ -48,7 +120,7 @@ export default function MessageItem(props: MessageItemProps) {
return true
}
return messageParts().some((part) => partHasRenderableText(part))
return displayParts().some((part) => partHasRenderableText(part))
}
const isGenerating = () => {
@@ -141,17 +213,64 @@ export default function MessageItem(props: MessageItemProps) {
</div>
</Show>
<For each={messageParts()}>{(part) => (
<MessagePart
part={part}
messageType={props.message.type}
instanceId={props.instanceId}
sessionId={props.sessionId}
/>
)}</For>
<For each={displayParts()}>
{(part) => (
<MessagePart
part={part}
messageType={props.message.type}
instanceId={props.instanceId}
sessionId={props.sessionId}
/>
)}
</For>
</div>
<Show when={fileAttachments().length > 0}>
<div class="message-attachments">
<For each={fileAttachments()}>
{(attachment) => {
const name = getAttachmentName(attachment)
const isImage = isImageAttachment(attachment)
return (
<div class={`attachment-chip ${isImage ? "attachment-chip-image" : ""}`} title={name}>
<Show when={isImage} fallback={
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"
/>
</svg>
}>
<img src={attachment.url} alt={name} class="h-5 w-5 rounded object-cover" />
</Show>
<span class="truncate max-w-[180px]">{name}</span>
<button
type="button"
onClick={() => void handleAttachmentDownload(attachment)}
class="attachment-download"
aria-label={`Download ${name}`}
>
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v2a2 2 0 002 2h12a2 2 0 002-2v-2" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12l4 4 4-4m-4-8v12" />
</svg>
</button>
<Show when={isImage}>
<div class="attachment-chip-preview">
<img src={attachment.url} alt={name} />
</div>
</Show>
</div>
)
}}
</For>
</div>
</Show>
<Show when={props.message.status === "sending"}>
<div class="message-sending">
<span class="generating-spinner"></span> Sending...
</div>

View File

@@ -34,7 +34,7 @@ import { useConfig } from "../stores/preferences"
import { getSessionInfo, computeDisplayParts, sessions, setActiveSession, setActiveParentSession } from "../stores/sessions"
import { setActiveInstanceId } from "../stores/instances"
const codeNomadLogo = new URL("../../images/CodeNomad-Icon.png", import.meta.url).href
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
const SCROLL_OFFSET = 64
const SCROLL_DIRECTION_THRESHOLD = 10
@@ -628,9 +628,11 @@ export default function MessageStream(props: MessageStreamProps) {
const toolPart = item.toolPart
const toolState = toolPart.state
const hasToolState = isToolStateRunning(toolState) || isToolStateCompleted(toolState) || isToolStateError(toolState)
const taskSessionId =
(isToolStateRunning(toolPart.state) || isToolStateCompleted(toolPart.state) || isToolStateError(toolPart.state))
? toolPart.state.metadata?.sessionId === "string" ? toolPart.state.metadata.sessionId : ""
hasToolState && typeof toolState?.metadata?.sessionId === "string"
? toolState.metadata.sessionId
: ""
const taskLocation = taskSessionId ? findTaskSessionLocation(taskSessionId) : null

View File

@@ -1,6 +1,8 @@
import { Component, For, Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
import { FolderOpen, Trash2, Check, AlertCircle, Loader2, Plus } from "lucide-solid"
import { useConfig } from "../stores/preferences"
import { serverApi } from "../lib/api-client"
import FileSystemBrowserDialog from "./filesystem-browser-dialog"
interface BinaryOption {
path: string
@@ -29,6 +31,7 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
const [validationError, setValidationError] = createSignal<string | null>(null)
const [versionInfo, setVersionInfo] = createSignal<Map<string, string>>(new Map<string, string>())
const [validatingPaths, setValidatingPaths] = createSignal<Set<string>>(new Set<string>())
const [isBinaryBrowserOpen, setIsBinaryBrowserOpen] = createSignal(false)
const binaries = () => opencodeBinaries()
const lastUsedBinary = () => preferences().lastUsedBinary
@@ -102,7 +105,7 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
setValidating(true)
setValidationError(null)
const result = await window.electronAPI.validateOpenCodeBinary(path)
const result = await serverApi.validateBinary(path)
if (result.valid && result.version) {
const updatedVersionInfo = new Map(versionInfo())
@@ -125,18 +128,12 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
}
}
async function handleBrowseBinary() {
try {
const path = await window.electronAPI.selectOpenCodeBinary()
if (!path) return
setCustomPath(path)
await handleValidateAndAdd(path)
} catch (error) {
setValidationError(error instanceof Error ? error.message : "Failed to select binary")
}
function handleBrowseBinary() {
if (props.disabled) return
setValidationError(null)
setIsBinaryBrowserOpen(true)
}
async function handleValidateAndAdd(path: string) {
const validation = await validateBinary(path)
@@ -150,8 +147,15 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
setValidationError(validation.error || "Invalid OpenCode binary")
}
}
function handleBinaryBrowserSelect(path: string) {
setIsBinaryBrowserOpen(false)
setCustomPath(path)
void handleValidateAndAdd(path)
}
async function handleCustomPathSubmit() {
const path = customPath().trim()
if (!path) return
await handleValidateAndAdd(path)
@@ -197,128 +201,140 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
const isPathValidating = (path: string) => validatingPaths().has(path)
return (
<div class="panel">
<div class="panel-header flex items-center justify-between gap-3">
<div>
<h3 class="panel-title">OpenCode Binary</h3>
<p class="panel-subtitle">Choose which executable OpenCode should run</p>
</div>
<Show when={validating()}>
<div class="selector-loading text-xs">
<Loader2 class="selector-loading-spinner" />
<span>Checking versions</span>
<>
<div class="panel">
<div class="panel-header flex items-center justify-between gap-3">
<div>
<h3 class="panel-title">OpenCode Binary</h3>
<p class="panel-subtitle">Choose which executable OpenCode should run</p>
</div>
<Show when={validating()}>
<div class="selector-loading text-xs">
<Loader2 class="selector-loading-spinner" />
<span>Checking versions</span>
</div>
</Show>
</div>
<div class="panel-body space-y-3">
<div class="selector-input-group">
<input
type="text"
value={customPath()}
onInput={(e) => setCustomPath(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault()
handleCustomPathSubmit()
}
}}
disabled={props.disabled}
placeholder="Enter path to opencode binary…"
class="selector-input"
/>
<button
type="button"
onClick={handleCustomPathSubmit}
disabled={props.disabled || !customPath().trim()}
class="selector-button selector-button-primary"
>
<Plus class="w-4 h-4" />
Add
</button>
</div>
</Show>
</div>
<div class="panel-body space-y-3">
<div class="selector-input-group">
<input
type="text"
value={customPath()}
onInput={(e) => setCustomPath(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault()
handleCustomPathSubmit()
}
}}
disabled={props.disabled}
placeholder="Enter path to opencode binary…"
class="selector-input"
/>
<button
type="button"
onClick={handleCustomPathSubmit}
disabled={props.disabled || !customPath().trim()}
class="selector-button selector-button-primary"
onClick={handleBrowseBinary}
disabled={props.disabled}
class="selector-button selector-button-secondary w-full flex items-center justify-center gap-2"
>
<Plus class="w-4 h-4" />
Add
<FolderOpen class="w-4 h-4" />
Browse for Binary
</button>
<Show when={validationError()}>
<div class="selector-validation-error">
<div class="selector-validation-error-content">
<AlertCircle class="selector-validation-error-icon" />
<span class="selector-validation-error-text">{validationError()}</span>
</div>
</div>
</Show>
</div>
<button
type="button"
onClick={handleBrowseBinary}
disabled={props.disabled}
class="selector-button selector-button-secondary w-full flex items-center justify-center gap-2"
>
<FolderOpen class="w-4 h-4" />
Browse for Binary
</button>
<div class="panel-list panel-list--fill max-h-80 overflow-y-auto">
<For each={binaryOptions()}>
{(binary) => {
const isDefault = binary.isDefault
const versionLabel = () => versionInfo().get(binary.path) ?? binary.version
<Show when={validationError()}>
<div class="selector-validation-error">
<div class="selector-validation-error-content">
<AlertCircle class="selector-validation-error-icon" />
<span class="selector-validation-error-text">{validationError()}</span>
</div>
</div>
</Show>
</div>
<div class="panel-list panel-list--fill max-h-80 overflow-y-auto">
<For each={binaryOptions()}>
{(binary) => {
const isDefault = binary.isDefault
const versionLabel = () => versionInfo().get(binary.path) ?? binary.version
return (
<div
class="panel-list-item flex items-center"
classList={{ "panel-list-item-highlight": currentSelectionPath() === binary.path }}
>
<button
type="button"
class="panel-list-item-content flex-1"
onClick={() => handleSelectBinary(binary.path)}
disabled={props.disabled}
return (
<div
class="panel-list-item flex items-center"
classList={{ "panel-list-item-highlight": currentSelectionPath() === binary.path }}
>
<div class="flex flex-col flex-1 min-w-0 gap-1.5">
<div class="flex items-center gap-2">
<Check
class={`w-4 h-4 transition-opacity ${currentSelectionPath() === binary.path ? "opacity-100" : "opacity-0"}`}
/>
<span class="text-sm font-medium truncate text-primary">{getDisplayName(binary.path)}</span>
</div>
<Show when={!isDefault}>
<div class="text-xs font-mono truncate pl-6 text-muted">{binary.path}</div>
</Show>
<div class="flex items-center gap-2 text-xs text-muted pl-6 flex-wrap">
<Show when={versionLabel()}>
<span class="selector-badge-version">v{versionLabel()}</span>
</Show>
<Show when={isPathValidating(binary.path)}>
<span class="selector-badge-time">Checking</span>
</Show>
<Show when={!isDefault && binary.lastUsed}>
<span class="selector-badge-time">{formatRelativeTime(binary.lastUsed)}</span>
</Show>
<Show when={isDefault}>
<span class="selector-badge-time">Use binary from system PATH</span>
</Show>
</div>
</div>
</button>
<Show when={!isDefault}>
<button
type="button"
class="p-2 text-muted hover:text-primary"
onClick={(event) => handleRemoveBinary(binary.path, event)}
class="panel-list-item-content flex-1"
onClick={() => handleSelectBinary(binary.path)}
disabled={props.disabled}
title="Remove binary"
>
<Trash2 class="w-3.5 h-3.5" />
<div class="flex flex-col flex-1 min-w-0 gap-1.5">
<div class="flex items-center gap-2">
<Check
class={`w-4 h-4 transition-opacity ${currentSelectionPath() === binary.path ? "opacity-100" : "opacity-0"}`}
/>
<span class="text-sm font-medium truncate text-primary">{getDisplayName(binary.path)}</span>
</div>
<Show when={!isDefault}>
<div class="text-xs font-mono truncate pl-6 text-muted">{binary.path}</div>
</Show>
<div class="flex items-center gap-2 text-xs text-muted pl-6 flex-wrap">
<Show when={versionLabel()}>
<span class="selector-badge-version">v{versionLabel()}</span>
</Show>
<Show when={isPathValidating(binary.path)}>
<span class="selector-badge-time">Checking</span>
</Show>
<Show when={!isDefault && binary.lastUsed}>
<span class="selector-badge-time">{formatRelativeTime(binary.lastUsed)}</span>
</Show>
<Show when={isDefault}>
<span class="selector-badge-time">Use binary from system PATH</span>
</Show>
</div>
</div>
</button>
</Show>
</div>
)
}}
</For>
<Show when={!isDefault}>
<button
type="button"
class="p-2 text-muted hover:text-primary"
onClick={(event) => handleRemoveBinary(binary.path, event)}
disabled={props.disabled}
title="Remove binary"
>
<Trash2 class="w-3.5 h-3.5" />
</button>
</Show>
</div>
)
}}
</For>
</div>
</div>
</div>
<FileSystemBrowserDialog
open={isBinaryBrowserOpen()}
mode="files"
title="Select OpenCode Binary"
description="Browse files exposed by the CLI server."
onClose={() => setIsBinaryBrowserOpen(false)}
onSelect={handleBinaryBrowserSelect}
/>
</>
)
}
export default OpenCodeBinarySelector

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