Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 39 additions & 25 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,18 +29,21 @@ _Browser support via CodeNomad Server._
Choose the way that fits your workflow:

### 🖥️ Desktop App (Recommended)

The best experience. A native application (Electron-based) 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.

### 🦀 Tauri App (Experimental)

We are also working on a lightweight, high-performance version built with [Tauri](https://tauri.app). It is currently in active development.

- **Download**: Experimental builds are available on the [Releases Page](https://github.com/shantur/CodeNomad/releases).
- **Source**: Check out `packages/tauri-app` if you're interested in contributing.

### 💻 Build from Source

Run CodeNomad as a local server by building from source. Perfect for remote development (SSH/VPN) or running as a service.

```bash
Expand Down Expand Up @@ -69,24 +72,32 @@ This will start the server and you can access it at http://localhost:3000

We've replaced the standard `question` tool with a native **Model Context Protocol (MCP)** implementation called `ask_user`.

| Feature | Legacy `question` Tool | New `ask_user` MCP Tool |
| :--- | :--- | :--- |
| **Cost** | Consumes premium requests per answer | **Zero** premium request consumption |
| **Architecture** | Remote API loop | Local IPC + MCP Server |
| **Timeout** | Short default timeout | **5-minute timeout** (configurable) |
| **UX** | Standard | Rich Markdown, Minimizable Wizard |
| Feature | Legacy `question` Tool | New `ask_user` MCP Tool |
| :--------------- | :----------------------------------- | :----------------------------------- |
| **Cost** | Consumes premium requests per answer | **Zero** premium request consumption |
| **Architecture** | Remote API loop | Local IPC + MCP Server |
| **Timeout** | Short default timeout | **5-minute timeout** (configurable) |
| **UX** | Standard | Rich Markdown, Minimizable Wizard |

> [!TIP]
> **Adjusting Timeout Duration**
> You can configure the `ask_user` timeout in the **Advanced Settings** menu found on the start screen (before opening a project).
>
> 1. Launch CodeNomad.
> 2. On the welcome screen, click **Advanced Settings**.
> 3. Scroll to **Timeout Settings** and adjust the value (default: 300s).

This change is critical for users on metered plans (like GitHub Copilot), effectively "unlocking" unlimited user interactions without draining quotas.

### 🔄 Upstream v0.9.2 Synced

This fork stays synchronized with the core CodeNomad experience.

| Category | New in v0.9.2 |
| :--- | :--- |
| **🌍 Internationalization** | Full UI support for **English, Spanish, French, Japanese, Russian, and Chinese** |
| **🧠 Model UX** | **Pin favorite models**, toggle "thinking" models, and use inline selector shortcuts |
| **🔧 Reliability** | Enhanced shutdown safeguards and improved process management |
| Category | New in v0.9.2 |
| :------------------------- | :----------------------------------------------------------------------------------- |
| **🌍 Internationalization** | Full UI support for **English, Spanish, French, Japanese, Russian, and Chinese** |
| **🧠 Model UX** | **Pin favorite models**, toggle "thinking" models, and use inline selector shortcuts |
| **🔧 Reliability** | Enhanced shutdown safeguards and improved process management |

## Requirements

Expand All @@ -97,15 +108,15 @@ This fork stays synchronized with the core CodeNomad experience.

This fork includes several major enhancements not available in the upstream repository:

| Feature | Key Capabilities |
| :--- | :--- |
| **🎯 Native MCP** | • **Zero-Cost Interactions**: No premium usage for questions<br>• **Reliability**: 5-minute timeout with auto-retry logic<br>• **Rich UI**: Minimizable markdown wizard & mobile optimization |
| **📂 Source Control** | • **Git Integration**: Built-in status, diff viewer, and branch management<br>• **Smart Previews**: View untracked files with binary detection<br>• **Actions**: Publish branches and delete files directly |
| **🔔 Notifications** | • **Persistent**: Error banner for timed-out questions/tasks<br>• **Recovery**: One-click retry without losing context<br>• **State**: Notifications persist across restarts |
| **🔍 Chat Search** | • **Deep Search**: Query entire history with debounced input<br>• **Visual**: Result highlighting and auto-expansion of collapsed blocks |
| **🌳 Folder Tree** | • **Navigation**: VSCode-style file explorer for workspaces<br>• **Preview**: Instant GitHub-style markdown rendering |
| **📝 Enhanced Input** | • **Editor**: Expandable multi-line chat input<br>• **Smart Attachments**: Tab-key file selection & auto-collapse |
| **🎨 Polish & Perf** | • **Visual**: Seamless dark mode, improved split-view diffs<br>• **Speed**: 10x faster dev icon loading via Vite optimization |
| Feature | Key Capabilities |
| :------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **🎯 Native MCP** | • **Zero-Cost Interactions**: No premium usage for questions<br>• **Reliability**: 5-minute timeout with auto-retry logic<br>• **Rich UI**: Minimizable markdown wizard & mobile optimization |
| **📂 Source Control** | • **Git Integration**: Built-in status, diff viewer, and branch management<br>• **Smart Previews**: View untracked files with binary detection<br>• **Actions**: Publish branches and delete files directly |
| **🔔 Notifications** | • **Persistent**: Error banner for timed-out questions/tasks<br>• **Recovery**: One-click retry without losing context<br>• **Rich Details**: Markdown-rendered failed question panel<br>• **State**: Notifications persist across restarts |
| **🔍 Chat Search** | • **Deep Search**: Query entire history with debounced input<br>• **Visual**: Result highlighting and auto-expansion of collapsed blocks |
| **🌳 Folder Tree** | • **Navigation**: VSCode-style file explorer for workspaces<br>• **Preview**: Instant GitHub-style markdown rendering |
| **📝 Enhanced Input** | • **Editor**: Expandable multi-line chat input<br>• **Smart Attachments**: Tab-key file selection & auto-collapse |
| **🎨 Polish & Perf** | • **Visual**: Seamless dark mode, improved split-view diffs<br>• **Speed**: 10x faster dev icon loading via Vite optimization |

> [!NOTE]
> These features are not included in upstream and represent divergent functionality from the original CodeNomad repository.
Expand All @@ -121,6 +132,7 @@ To prevent these failures, the workflows are configured to **skip** these specif
## Troubleshooting

### macOS says the app is damaged

If macOS reports that "CodeNomad.app is damaged and can't be opened," Gatekeeper flagged the download because the app is not yet notarized. You can clear the quarantine flag after moving CodeNomad into `/Applications`:

```bash
Expand All @@ -131,6 +143,7 @@ xattr -dr com.apple.quarantine /Applications/CodeNomad.app
After removing the quarantine attribute, launch the app normally. On Intel Macs you may also need to approve CodeNomad from **System Settings → Privacy & Security** the first time you run it.

### Linux (Wayland + NVIDIA): Tauri AppImage closes immediately

On some Wayland compositor + NVIDIA driver setups, WebKitGTK can fail to initialize its DMA-BUF/GBM path and the Tauri build may exit right away.

Try running with one of these environment variables:
Expand All @@ -157,13 +170,14 @@ Upstream tracking: https://github.com/tauri-apps/tauri/issues/10702

CodeNomad is a monorepo split into specialized packages. If you want to contribute or build from source, check out the individual package documentation:

| 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. |
| 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. |

### Quick Build

To build the Desktop App from source:

1. Clone the repo.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "codenomad-workspace",
"version": "0.9.2-patch.2",
"version": "0.9.2-patch.3",
"private": true,
"description": "CodeNomad monorepo workspace",
"workspaces": {
Expand Down
35 changes: 29 additions & 6 deletions packages/electron-app/electron/main/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,23 +14,34 @@ interface DialogOpenResult {
}

export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessManager) {
cliManager.on("status", (status: CliStatus) => {
// Define listeners
const onStatus = (status: CliStatus) => {
if (!mainWindow.isDestroyed()) {
mainWindow.webContents.send("cli:status", status)
}
})
}

cliManager.on("ready", (status: CliStatus) => {
const onReady = (status: CliStatus) => {
if (!mainWindow.isDestroyed()) {
mainWindow.webContents.send("cli:ready", status)
}
})
}

cliManager.on("error", (error: Error) => {
const onError = (error: Error) => {
if (!mainWindow.isDestroyed()) {
mainWindow.webContents.send("cli:error", { message: error.message })
}
})
}

// Register listeners
cliManager.on("status", onStatus)
cliManager.on("ready", onReady)
cliManager.on("error", onError)

// Clean up existing handlers if any (though usually we clean up on window close)
ipcMain.removeHandler("cli:getStatus")
ipcMain.removeHandler("cli:restart")
ipcMain.removeHandler("dialog:open")

ipcMain.handle("cli:getStatus", async () => cliManager.getStatus())

Expand Down Expand Up @@ -62,4 +73,16 @@ export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessMan

return { canceled: result.canceled, paths: result.filePaths }
})

// Return cleanup function
return () => {
cliManager.removeListener("status", onStatus)
cliManager.removeListener("ready", onReady)
cliManager.removeListener("error", onError)

ipcMain.removeHandler("cli:getStatus")
ipcMain.removeHandler("cli:restart")
ipcMain.removeHandler("dialog:open")
}
}

3 changes: 2 additions & 1 deletion packages/electron-app/electron/main/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,9 +239,10 @@ function createWindow() {
}

createApplicationMenu(mainWindow)
setupCliIPC(mainWindow, cliManager)
const cleanupIPC = setupCliIPC(mainWindow, cliManager)

mainWindow.on("closed", () => {
cleanupIPC()
destroyPreloadingView()
mainWindow = null
currentCliUrl = null
Expand Down
3 changes: 2 additions & 1 deletion packages/electron-app/electron/main/process-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -317,7 +317,8 @@ export class CliProcessManager extends EventEmitter {
const args = ["serve", "--host", host, "--port", "0", "--generate-token"]

if (options.dev) {
args.push("--ui-dev-server", "http://localhost:3000", "--log-level", "debug")
const devUrl = process.env.VITE_DEV_SERVER_URL || process.env.ELECTRON_RENDERER_URL || "http://localhost:3000"
args.push("--ui-dev-server", devUrl, "--log-level", "debug")
}

return args
Expand Down
15 changes: 9 additions & 6 deletions packages/electron-app/electron/preload/index.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,24 @@ const { contextBridge, ipcRenderer } = require("electron")

const electronAPI = {
onCliStatus: (callback) => {
ipcRenderer.on("cli:status", (_, data) => callback(data))
return () => ipcRenderer.removeAllListeners("cli:status")
const listener = (_, data) => callback(data)
ipcRenderer.on("cli:status", listener)
return () => ipcRenderer.removeListener("cli:status", listener)
},
onCliError: (callback) => {
ipcRenderer.on("cli:error", (_, data) => callback(data))
return () => ipcRenderer.removeAllListeners("cli:error")
const listener = (_, data) => callback(data)
ipcRenderer.on("cli:error", listener)
return () => ipcRenderer.removeListener("cli:error", listener)
},
getCliStatus: () => ipcRenderer.invoke("cli:getStatus"),
restartCli: () => ipcRenderer.invoke("cli:restart"),
openDialog: (options) => ipcRenderer.invoke("dialog:open", options),
// MCP bridge methods
mcpSend: (channel, data) => ipcRenderer.send(channel, data),
mcpOn: (channel, callback) => {
ipcRenderer.on(channel, (_, data) => callback(data))
return () => ipcRenderer.removeAllListeners(channel)
const listener = (_, data) => callback(data)
ipcRenderer.on(channel, listener)
return () => ipcRenderer.removeListener(channel, listener)
},
}

Expand Down
2 changes: 1 addition & 1 deletion packages/electron-app/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@neuralnomads/codenomad-electron-app",
"version": "0.9.2-patch.2",
"version": "0.9.2-patch.3",
"description": "CodeNomad - AI coding assistant",
"author": {
"name": "Neural Nomads",
Expand Down
37 changes: 30 additions & 7 deletions packages/mcp-server/src/bridge/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ export async function setupMcpBridge(mainWindow: BrowserWindow): Promise<void> {
logDebug(`[MCP IPC] Bridge window ${formatWindowInfo(mainWindow)}`);
emitRendererLog(mainWindow, 'info', 'Setting up main process bridge', { windowInfo: formatWindowInfo(mainWindow) });

let requestTimeout = 300000; // Default 5 minutes


// Attempt to import electron dynamically. If not available (e.g., in test env), skip attaching handlers.
let ipcMain: any = null
try {
Expand Down Expand Up @@ -124,6 +127,19 @@ export async function setupMcpBridge(mainWindow: BrowserWindow): Promise<void> {
}
});

// Handler: UI sends configuration update
ipcMain.on('mcp:config', (_event: any, data: any) => {
const { requestTimeout: timeout } = data;
if (typeof timeout === 'number' && timeout > 0) {
console.log(`[MCP IPC] Updating request timeout to ${timeout}ms`);
// Store this in a way accessible to the render handler.
// Since we are inside setupMcpBridge, we can use a closure variable if we define it above.
// See below for variable definition.
requestTimeout = timeout;
emitRendererLog(mainWindow, 'info', `Updated request timeout to ${timeout}ms`);
}
});

// Handler: UI confirms question was rendered/displayed
ipcMain.on('mcp:renderConfirmed', (_event: any, data: any) => {
const { requestId } = data;
Expand All @@ -142,16 +158,23 @@ export async function setupMcpBridge(mainWindow: BrowserWindow): Promise<void> {

const confirmed = globalPendingManager.confirmRender(requestId);
if (confirmed) {
console.log(`[MCP IPC] Render confirmed for ${requestId}, starting user response timer`);
emitRendererLog(mainWindow, 'info', 'Render confirmed, starting user response timer', { requestId });
console.log(`[MCP IPC] Render confirmed for ${requestId}, starting user response timer (${requestTimeout}ms)`);
emitRendererLog(mainWindow, 'info', 'Render confirmed, starting user response timer', { requestId, timeout: requestTimeout });

// Start the 5-minute user response timeout
// Start the user response timeout
const activePending = globalPendingManager.get(requestId);
if (activePending) {
activePending.timeout = setTimeout(() => {
console.log(`[MCP IPC] User response timeout for ${requestId}`);
// Notify UI that question timed out so it can clean up wizard and move to failed notifications
mainWindow.webContents.send('ask_user.rejected', {
requestId,
reason: 'timeout',
timedOut: true,
cancelled: false
});
globalPendingManager?.reject(requestId, new Error('Question timeout'));
}, 300000); // 5 minutes
}, requestTimeout);
}
} else {
console.warn(`[MCP IPC] No pending request for render confirmation: ${requestId}`);
Expand Down Expand Up @@ -234,15 +257,15 @@ export function createIpcBridge(mainWindow: BrowserWindow, pendingManager: Pendi
emitRendererLog(mainWindow, 'warn', 'Question not found in pending manager', { requestId });
}
},
onAnswer: (callback: (requestId: string, answers: QuestionAnswer[]) => void) => {
onAnswer: (_callback: (requestId: string, answers: QuestionAnswer[]) => void) => {
// Already handled via 'mcp:answer' IPC handler in setupMcpBridge
console.log('[MCP IPC] Answer handler registered (via IPC)');
},
onCancel: (callback: (requestId: string) => void) => {
onCancel: (_callback: (requestId: string) => void) => {
// Already handled via 'mcp:cancel' IPC handler in setupMcpBridge
console.log('[MCP IPC] Cancel handler registered (via IPC)');
},
onRenderConfirmed: (callback: (requestId: string) => void) => {
onRenderConfirmed: (_callback: (requestId: string) => void) => {
// Handled via 'mcp:renderConfirmed' IPC handler above
console.log('[MCP IPC] Render confirmation handler registered (via IPC)');
}
Expand Down
2 changes: 1 addition & 1 deletion packages/opencode-config/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,6 @@
"version": "0.5.0",
"private": true,
"dependencies": {
"@opencode-ai/plugin": "1.1.48"
"@opencode-ai/plugin": "1.1.51"
}
}
2 changes: 1 addition & 1 deletion packages/server/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@neuralnomads/codenomad",
"version": "0.9.2-patch.2",
"version": "0.9.2-patch.3",
"description": "CodeNomad Server",
"author": {
"name": "Neural Nomads",
Expand Down
1 change: 1 addition & 0 deletions packages/server/src/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const PreferencesSchema = z.object({
showUsageMetrics: z.boolean().default(true),
autoCleanupBlankSessions: z.boolean().default(true),
listeningMode: z.enum(["local", "all"]).default("local"),
askUserTimeout: z.number().default(300000),
})

const RecentFolderSchema = z.object({
Expand Down
2 changes: 1 addition & 1 deletion packages/ui/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@codenomad/ui",
"version": "0.9.2-patch.2",
"version": "0.9.2-patch.3",
"private": true,
"type": "module",
"scripts": {
Expand Down
Loading