chore: rebrand client and add icon tooling

This commit is contained in:
Shantur Rathore
2025-11-13 22:58:57 +00:00
parent 8233aee7a8
commit a71c8ab34f
27 changed files with 432 additions and 205 deletions

View File

@@ -1,6 +1,6 @@
# Building OpenCode Client Binaries
# Building CodeNomad Binaries
This guide explains how to build distributable binaries for OpenCode Client.
This guide explains how to build distributable binaries for CodeNomad.
## Prerequisites
@@ -81,17 +81,17 @@ Binaries are generated in the `release/` directory:
```
release/
├── OpenCode Client-0.1.0-mac-universal.dmg
├── OpenCode Client-0.1.0-mac-universal.zip
├── OpenCode Client-0.1.0-win-x64.exe
├── OpenCode Client-0.1.0-linux-x64.AppImage
├── CodeNomad-0.1.0-mac-universal.dmg
├── CodeNomad-0.1.0-mac-universal.zip
├── CodeNomad-0.1.0-win-x64.exe
├── CodeNomad-0.1.0-linux-x64.AppImage
└── ...
```
## File Naming Convention
```
OpenCode Client-{version}-{os}-{arch}.{ext}
CodeNomad-{version}-{os}-{arch}.{ext}
```
- **version**: From package.json (e.g., `0.1.0`)
@@ -215,6 +215,16 @@ Edit `package.json` → `build` section to customize:
See [electron-builder docs](https://www.electron.build/) for details.
## Brand Assets
- `images/CodeNomad-Icon.png` — primary asset for in-app logo placements and the 1024×1024 master icon used to generate packaged app icons
To update the binaries:
1. Run `node scripts/generate-icons.js images/CodeNomad-Icon.png electron/resources` to round the corners and emit fresh `icon.icns`, `icon.ico`, and `icon.png` files.
2. (Optional) Pass `--radius` to tweak the corner curvature or `--name` to change the filename prefix.
3. If you prefer manual control, export `images/CodeNomad-Icon.png` with your tool of choice and place the generated files in `electron/resources/`.
## Clean Build
Remove previous builds:

View File

@@ -1,4 +1,4 @@
# OpenCode Client - Development Progress
# CodeNomad - Development Progress
## Completed Tasks

View File

@@ -1,10 +1,10 @@
# OpenCode Client
# CodeNomad
A cross-platform desktop application for interacting with OpenCode servers, built with Electron and SolidJS.
## Overview
OpenCode Client provides a multi-instance, multi-session interface for working with AI-powered coding assistants. It manages OpenCode server processes, handles real-time message streaming, and provides an intuitive UI for coding with AI.
CodeNomad provides a multi-instance, multi-session interface for working with AI-powered coding assistants. It manages OpenCode server processes, handles real-time message streaming, and provides an intuitive UI for coding with AI.
**🎯 MVP Focus:** This project prioritizes functionality over performance. Performance optimization is intentionally deferred to post-MVP phases. See [docs/MVP-PRINCIPLES.md](docs/MVP-PRINCIPLES.md) for details.

View File

@@ -1,6 +1,6 @@
# Tool Call Rendering Implementation
This document describes how tool calls are rendered in the OpenCode Client, following the patterns established in the TUI.
This document describes how tool calls are rendered in the CodeNomad, following the patterns established in the TUI.
## Overview

View File

@@ -298,7 +298,7 @@ When the time comes:
> **Make it work, then make it better, then make it fast.**
For OpenCode Client MVP:
For CodeNomad MVP:
- **Phase 1-7:** Make it work, make it better
- **Phase 8+:** Make it fast

View File

@@ -1,4 +1,4 @@
# OpenCode Client - Project Summary
# CodeNomad - Project Summary
## Current Status
@@ -6,7 +6,7 @@ We have completed the MVP milestones (Phases 1-3) and are now operating in post-
## What We've Created
A comprehensive specification and task breakdown for building the OpenCode Client desktop application.
A comprehensive specification and task breakdown for building the CodeNomad desktop application.
## Directory Structure

View File

@@ -1,8 +1,8 @@
# OpenCode Client Architecture
# CodeNomad Architecture
## Overview
OpenCode Client is a cross-platform desktop application built with Electron that provides a multi-instance, multi-session interface for interacting with OpenCode servers. Each instance manages its own OpenCode server process and can handle multiple concurrent sessions.
CodeNomad is a cross-platform desktop application built with Electron that provides a multi-instance, multi-session interface for interacting with OpenCode servers. Each instance manages its own OpenCode server process and can handle multiple concurrent sessions.
## High-Level Architecture

View File

@@ -1,8 +1,8 @@
# OpenCode Client Build Roadmap
# CodeNomad Build Roadmap
## Overview
This document outlines the phased approach to building the OpenCode Client desktop application. Each phase builds incrementally on the previous, with clear deliverables and milestones.
This document outlines the phased approach to building the CodeNomad desktop application. Each phase builds incrementally on the previous, with clear deliverables and milestones.
**Status:** MVP (Phases 1-3) is complete. Focus now shifts to post-MVP phases starting with multi-instance support and advanced input refinements.

View File

@@ -2,7 +2,7 @@
## Overview
The OpenCode Client interface consists of a two-level tabbed layout with instance tabs at the top and session tabs below. Each session displays a message stream and prompt input.
The CodeNomad interface consists of a two-level tabbed layout with instance tabs at the top and session tabs below. Each session displays a message stream and prompt input.
## Layout Structure
@@ -346,7 +346,7 @@ Appears when instance starts:
│ │
│ [Folder Icon] │
│ │
Welcome to OpenCode Client
Start Coding with AI
│ │
│ Select a folder to start coding with AI │
│ │

View File

@@ -1,4 +1,4 @@
import { app, BrowserWindow, dialog, ipcMain, nativeTheme, session } from "electron"
import { app, BrowserWindow, dialog, ipcMain, nativeImage, nativeTheme, session } from "electron"
import { join } from "path"
import { createApplicationMenu } from "./menu"
import { setupInstanceIPC } from "./ipc"
@@ -15,9 +15,18 @@ 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,
@@ -25,6 +34,7 @@ function createWindow() {
minWidth: 800,
minHeight: 600,
backgroundColor,
icon: iconPath,
webPreferences: {
preload: join(__dirname, "../preload/index.js"),
contextIsolation: true,
@@ -65,6 +75,13 @@ app.whenReady().then(() => {
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())

View File

@@ -7,7 +7,7 @@ export function createApplicationMenu(mainWindow: BrowserWindow) {
...(isMac
? [
{
label: "OpenCode Client",
label: "CodeNomad",
submenu: [
{ role: "about" as const },
{ type: "separator" as const },

Binary file not shown.

BIN
electron/resources/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 422 KiB

BIN
electron/resources/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

BIN
images/CodeNomad-Icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

85
package-lock.json generated
View File

@@ -26,6 +26,8 @@
"electron": "39.0.0",
"electron-builder": "^24.0.0",
"electron-vite": "4.0.1",
"png2icons": "^2.0.1",
"pngjs": "^7.0.0",
"postcss": "8.5.6",
"tailwindcss": "3",
"typescript": "^5.3.0",
@@ -77,7 +79,6 @@
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5",
@@ -2288,7 +2289,6 @@
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
@@ -2464,6 +2464,7 @@
"integrity": "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"archiver-utils": "^2.1.0",
"async": "^3.2.4",
@@ -2483,6 +2484,7 @@
"integrity": "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"glob": "^7.1.4",
"graceful-fs": "^4.2.0",
@@ -2505,6 +2507,7 @@
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
@@ -2520,7 +2523,8 @@
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"dev": true,
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/archiver-utils/node_modules/string_decoder": {
"version": "1.1.1",
@@ -2528,6 +2532,7 @@
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"safe-buffer": "~5.1.0"
}
@@ -2746,6 +2751,7 @@
"integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"buffer": "^5.5.0",
"inherits": "^2.0.4",
@@ -2821,7 +2827,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.19",
"caniuse-lite": "^1.0.30001751",
@@ -3285,6 +3290,7 @@
"integrity": "sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"buffer-crc32": "^0.2.13",
"crc32-stream": "^4.0.2",
@@ -3391,6 +3397,7 @@
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"crc32": "bin/crc32.njs"
},
@@ -3404,6 +3411,7 @@
"integrity": "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"crc-32": "^1.2.0",
"readable-stream": "^3.4.0"
@@ -3636,7 +3644,6 @@
"integrity": "sha512-rcJUkMfnJpfCboZoOOPf4L29TRtEieHNOeAbYPWPxlaBw/Z1RKrRA86dOI9rwaI4tQSc/RD82zTNHprfUHXsoQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"app-builder-lib": "24.13.3",
"builder-util": "24.13.1",
@@ -3821,6 +3828,7 @@
"integrity": "sha512-oHkV0iogWfyK+ah9ZIvMDpei1m9ZRpdXcvde1wTpra2U8AFDNNpqJdnin5z+PM1GbQ5BoaKCWas2HSjtR0HwMg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"app-builder-lib": "24.13.3",
"archiver": "^5.3.1",
@@ -3834,6 +3842,7 @@
"integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1",
@@ -3849,6 +3858,7 @@
"integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"universalify": "^2.0.0"
},
@@ -3862,6 +3872,7 @@
"integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 10.0.0"
}
@@ -4343,7 +4354,8 @@
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
"dev": true,
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/fs-extra": {
"version": "8.1.0",
@@ -5062,7 +5074,8 @@
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"dev": true,
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/isbinaryfile": {
"version": "5.0.6",
@@ -5124,7 +5137,6 @@
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"jiti": "bin/jiti.js"
}
@@ -5230,6 +5242,7 @@
"integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"readable-stream": "^2.0.5"
},
@@ -5243,6 +5256,7 @@
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
@@ -5258,7 +5272,8 @@
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"dev": true,
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/lazystream/node_modules/string_decoder": {
"version": "1.1.1",
@@ -5266,6 +5281,7 @@
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"safe-buffer": "~5.1.0"
}
@@ -5302,35 +5318,40 @@
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
"integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",
"dev": true,
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/lodash.difference": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.difference/-/lodash.difference-4.5.0.tgz",
"integrity": "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==",
"dev": true,
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/lodash.flatten": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz",
"integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==",
"dev": true,
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/lodash.isplainobject": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
"dev": true,
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/lodash.union": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/lodash.union/-/lodash.union-4.6.0.tgz",
"integrity": "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==",
"dev": true,
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/lowercase-keys": {
"version": "2.0.0",
@@ -5985,6 +6006,26 @@
"node": ">=10.4.0"
}
},
"node_modules/png2icons": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/png2icons/-/png2icons-2.0.1.tgz",
"integrity": "sha512-GDEQJr8OG4e6JMp7mABtXFSEpgJa1CCpbQiAR+EjhkHJHnUL9zPPtbOrjsMD8gUbikgv3j7x404b0YJsV3aVFA==",
"dev": true,
"license": "MIT",
"bin": {
"png2icons": "png2icons-cli.js"
}
},
"node_modules/pngjs": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz",
"integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.19.0"
}
},
"node_modules/postcss": {
"version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
@@ -6005,7 +6046,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -6154,7 +6194,8 @@
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"dev": true,
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/progress": {
"version": "2.0.3",
@@ -6303,6 +6344,7 @@
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
@@ -6318,6 +6360,7 @@
"integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"minimatch": "^5.1.0"
}
@@ -6535,7 +6578,8 @@
"url": "https://feross.org/support"
}
],
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/safer-buffer": {
"version": "2.1.2",
@@ -6601,7 +6645,6 @@
"resolved": "https://registry.npmjs.org/seroval/-/seroval-1.3.2.tgz",
"integrity": "sha512-RbcPH1n5cfwKrru7v7+zrZvjLurgHhGyso3HTyGtRivGWgYjbOmGuivCQaORNELjNONoK35nj28EoWul9sb1zQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10"
}
@@ -6729,7 +6772,6 @@
"resolved": "https://registry.npmjs.org/solid-js/-/solid-js-1.9.10.tgz",
"integrity": "sha512-Coz956cos/EPDlhs6+jsdTxKuJDPT7B5SVIWgABwROyxjY7Xbr8wkzD68Et+NxnV7DLJ3nJdAC2r9InuV/4Jew==",
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.1.0",
"seroval": "~1.3.0",
@@ -6849,6 +6891,7 @@
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"safe-buffer": "~5.2.0"
}
@@ -7106,6 +7149,7 @@
"integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"bl": "^4.0.3",
"end-of-stream": "^1.4.1",
@@ -7489,7 +7533,6 @@
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.21.3",
"postcss": "^8.4.43",
@@ -8152,6 +8195,7 @@
"integrity": "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"archiver-utils": "^3.0.4",
"compress-commons": "^4.1.2",
@@ -8167,6 +8211,7 @@
"integrity": "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"glob": "^7.2.3",
"graceful-fs": "^4.2.0",

View File

@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/client",
"version": "0.1.0",
"description": "OpenCode desktop client - multi-instance, multi-session AI coding interface",
"description": "CodeNomad desktop client - multi-instance, multi-session AI coding interface",
"author": "OpenCode Team",
"type": "module",
"main": "dist/main/main.js",
@@ -41,6 +41,8 @@
"@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",
@@ -51,7 +53,7 @@
},
"build": {
"appId": "ai.opencode.client",
"productName": "OpenCode Client",
"productName": "CodeNomad",
"directories": {
"output": "release",
"buildResources": "electron/resources"

View File

@@ -111,7 +111,7 @@ const platform = process.argv[2] || "mac"
console.log(`
╔════════════════════════════════════════╗
OpenCode Client - Binary Builder ║
CodeNomad - Binary Builder
╚════════════════════════════════════════╝
`)

155
scripts/generate-icons.js Normal file
View File

@@ -0,0 +1,155 @@
#!/usr/bin/env node
import { mkdirSync, readFileSync, writeFileSync } from "fs"
import { resolve, join, basename } from "path"
import { PNG } from "pngjs"
import png2icons from "png2icons"
function printUsage() {
console.log(`\nUsage: node scripts/generate-icons.js <input.png> [outputDir] [--name icon] [--radius 0.22]\n\nOptions:\n --name Base filename for generated assets (default: icon)\n --radius Corner radius ratio between 0 and 0.5 (default: 0.22)\n --help Show this message\n`)
}
function parseArgs(argv) {
const args = [...argv]
const options = {
name: "icon",
radius: 0.22,
}
for (let i = 0; i < args.length; i++) {
const token = args[i]
if (token === "--help" || token === "-h") {
options.help = true
continue
}
if (token === "--name" && i + 1 < args.length) {
options.name = args[i + 1]
i++
continue
}
if (token === "--radius" && i + 1 < args.length) {
options.radius = Number(args[i + 1])
i++
continue
}
if (!options.input) {
options.input = token
continue
}
if (!options.output) {
options.output = token
continue
}
}
return options
}
function applyRoundedCorners(png, ratio) {
const { width, height, data } = png
const clamped = Math.max(0, Math.min(ratio, 0.5))
if (clamped === 0) return png
const radius = Math.max(1, Math.min(width, height) * clamped)
const radiusSq = radius * radius
const rightThreshold = width - radius
const bottomThreshold = height - radius
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const idx = (width * y + x) * 4
if (data[idx + 3] === 0) continue
const px = x + 0.5
const py = y + 0.5
const inLeft = px < radius
const inRight = px > rightThreshold
const inTop = py < radius
const inBottom = py > bottomThreshold
let outside = false
if (inLeft && inTop) {
outside = (px - radius) ** 2 + (py - radius) ** 2 > radiusSq
} else if (inRight && inTop) {
outside = (px - rightThreshold) ** 2 + (py - radius) ** 2 > radiusSq
} else if (inLeft && inBottom) {
outside = (px - radius) ** 2 + (py - bottomThreshold) ** 2 > radiusSq
} else if (inRight && inBottom) {
outside = (px - rightThreshold) ** 2 + (py - bottomThreshold) ** 2 > radiusSq
}
if (outside) {
data[idx + 3] = 0
}
}
}
return png
}
async function main() {
const args = parseArgs(process.argv.slice(2))
if (args.help || !args.input) {
printUsage()
process.exit(args.help ? 0 : 1)
}
const inputPath = resolve(args.input)
const outputDir = resolve(args.output || "electron/resources")
const baseName = args.name || basename(inputPath, ".png")
const radiusRatio = Number.isFinite(args.radius) ? args.radius : 0.22
let buffer
try {
buffer = readFileSync(inputPath)
} catch (error) {
console.error(`Failed to read ${inputPath}:`, error.message)
process.exit(1)
}
let png
try {
png = PNG.sync.read(buffer)
} catch (error) {
console.error("Input must be a valid PNG:", error.message)
process.exit(1)
}
applyRoundedCorners(png, radiusRatio)
const roundedBuffer = PNG.sync.write(png)
try {
mkdirSync(outputDir, { recursive: true })
} catch (error) {
console.error("Failed to create output directory:", error.message)
process.exit(1)
}
const pngPath = join(outputDir, `${baseName}.png`)
writeFileSync(pngPath, roundedBuffer)
const icns = png2icons.createICNS(roundedBuffer, png2icons.BICUBIC, false)
if (!icns) {
console.error("Failed to create ICNS file. Make sure the source PNG is at least 256x256.")
process.exit(1)
}
writeFileSync(join(outputDir, `${baseName}.icns`), icns)
const ico = png2icons.createICO(roundedBuffer, png2icons.BICUBIC, false)
if (!ico) {
console.error("Failed to create ICO file. Make sure the source PNG is at least 256x256.")
process.exit(1)
}
writeFileSync(join(outputDir, `${baseName}.ico`), ico)
console.log(`\nGenerated assets in ${outputDir}:`)
console.log(`- ${baseName}.png`)
console.log(`- ${baseName}.icns`)
console.log(`- ${baseName}.ico`)
}
main()

View File

@@ -20,9 +20,6 @@ const AdvancedSettingsModal: Component<AdvancedSettingsModalProps> = (props) =>
<Dialog.Content class="modal-surface w-full max-w-5xl max-h-[90vh] flex flex-col overflow-hidden">
<header class="px-6 py-4 border-b" style={{ "border-color": "var(--border-base)" }}>
<Dialog.Title class="text-xl font-semibold text-primary">Advanced Settings</Dialog.Title>
<Dialog.Description class="text-sm text-secondary mt-1">
Configure the OpenCode binary and environment variables used when launching new instances.
</Dialog.Description>
</header>
<div class="flex-1 overflow-y-auto p-6 space-y-6">

View File

@@ -1,5 +1,7 @@
import { Component } from "solid-js"
import { Folder, Loader2 } from "lucide-solid"
import { Loader2 } from "lucide-solid"
const codeNomadIcon = new URL("../../images/CodeNomad-Icon.png", import.meta.url).href
interface EmptyStateProps {
onSelectFolder: () => void
@@ -11,13 +13,13 @@ const EmptyState: Component<EmptyStateProps> = (props) => {
<div class="flex h-full w-full items-center justify-center bg-surface-secondary">
<div class="max-w-[500px] px-8 py-12 text-center">
<div class="mb-8 flex justify-center">
<Folder class="h-16 w-16 icon-muted" />
<img src={codeNomadIcon} alt="CodeNomad logo" class="h-24 w-auto" loading="lazy" />
</div>
<h1 class="mb-4 text-2xl font-semibold text-primary">Welcome to OpenCode Client</h1>
<h1 class="mb-3 text-3xl font-semibold text-primary">CodeNomad</h1>
<p class="mb-8 text-base text-secondary">Select a folder to start coding with AI</p>
<button
onClick={props.onSelectFolder}
disabled={props.isLoading}

View File

@@ -4,6 +4,8 @@ import { recentFolders, removeRecentFolder, preferences, updateLastUsedBinary }
import AdvancedSettingsModal from "./advanced-settings-modal"
import Kbd from "./kbd"
const codeNomadLogo = new URL("../../images/CodeNomad-Icon.png", import.meta.url).href
interface FolderSelectionViewProps {
onSelectFolder: (folder?: string, binaryPath?: string) => void
isLoading?: boolean
@@ -202,171 +204,169 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
class="flex h-screen w-full items-start justify-center overflow-hidden py-6 relative"
style="background-color: var(--surface-secondary)"
>
<div
class="w-full max-w-3xl h-full max-h-[90vh] px-8 flex flex-col overflow-hidden"
aria-busy={isLoading() ? "true" : "false"}
>
<div
class="w-full max-w-3xl h-full px-8 pb-2 flex flex-col overflow-hidden"
aria-busy={isLoading() ? "true" : "false"}
>
<div class="mb-6 text-center shrink-0">
<div class="mb-3 flex justify-center">
<Folder class="h-16 w-16 icon-muted" />
<img src={codeNomadLogo} alt="CodeNomad logo" class="h-48 w-auto" loading="lazy" />
</div>
<h1 class="mb-2 text-2xl font-semibold text-primary">Welcome to OpenCode</h1>
<h1 class="mb-2 text-3xl font-semibold text-primary">CodeNomad</h1>
<p class="text-base text-secondary">Select a folder to start coding with AI</p>
</div>
<div class="space-y-4 flex-1 min-h-0 overflow-hidden flex flex-col">
<div class="space-y-4 flex-1 min-h-0 overflow-hidden flex flex-col">
<Show
when={folders().length > 0}
fallback={
<div class="panel panel-empty-state flex-1">
<div class="panel-empty-state-icon">
<Clock class="w-12 h-12 mx-auto" />
<Show
when={folders().length > 0}
fallback={
<div class="panel panel-empty-state flex-1">
<div class="panel-empty-state-icon">
<Clock class="w-12 h-12 mx-auto" />
</div>
<p class="panel-empty-state-title">No Recent Folders</p>
<p class="panel-empty-state-description">Browse for a folder to get started</p>
</div>
<p class="panel-empty-state-title">No Recent Folders</p>
<p class="panel-empty-state-description">Browse for a folder to get started</p>
</div>
}
>
<div class="panel flex flex-col flex-1 min-h-0">
<div class="panel-header">
<h2 class="panel-title">Recent Folders</h2>
<p class="panel-subtitle">
{folders().length} {folders().length === 1 ? "folder" : "folders"} available
</p>
</div>
<div class="panel-list panel-list--fill flex-1 min-h-0 overflow-auto" ref={(el) => (recentListRef = el)}>
<For each={folders()}>
{(folder, index) => (
<div
class="panel-list-item"
classList={{
"panel-list-item-highlight": focusMode() === "recent" && selectedIndex() === index(),
"panel-list-item-disabled": isLoading(),
}}
>
<div class="flex items-center gap-2 w-full px-1">
<button
data-folder-index={index()}
class="panel-list-item-content flex-1"
disabled={isLoading()}
onClick={() => handleFolderSelect(folder.path)}
onMouseEnter={() => {
if (isLoading()) return
setFocusMode("recent")
setSelectedIndex(index())
}}
>
<div class="flex items-center justify-between gap-3 w-full">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<Folder class="w-4 h-4 flex-shrink-0 icon-muted" />
<span class="text-sm font-medium truncate text-primary">
{folder.path.split("/").pop()}
</span>
</div>
<div class="text-xs font-mono truncate pl-6 text-muted">
{getDisplayPath(folder.path)}
</div>
<div class="text-xs mt-1 pl-6 text-muted">
{formatRelativeTime(folder.lastAccessed)}
}
>
<div class="panel flex flex-col flex-1 min-h-0">
<div class="panel-header">
<h2 class="panel-title">Recent Folders</h2>
<p class="panel-subtitle">
{folders().length} {folders().length === 1 ? "folder" : "folders"} available
</p>
</div>
<div class="panel-list panel-list--fill flex-1 min-h-0 overflow-auto" ref={(el) => (recentListRef = el)}>
<For each={folders()}>
{(folder, index) => (
<div
class="panel-list-item"
classList={{
"panel-list-item-highlight": focusMode() === "recent" && selectedIndex() === index(),
"panel-list-item-disabled": isLoading(),
}}
>
<div class="flex items-center gap-2 w-full px-1">
<button
data-folder-index={index()}
class="panel-list-item-content flex-1"
disabled={isLoading()}
onClick={() => handleFolderSelect(folder.path)}
onMouseEnter={() => {
if (isLoading()) return
setFocusMode("recent")
setSelectedIndex(index())
}}
>
<div class="flex items-center justify-between gap-3 w-full">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<Folder class="w-4 h-4 flex-shrink-0 icon-muted" />
<span class="text-sm font-medium truncate text-primary">
{folder.path.split("/").pop()}
</span>
</div>
<div class="text-xs font-mono truncate pl-6 text-muted">
{getDisplayPath(folder.path)}
</div>
<div class="text-xs mt-1 pl-6 text-muted">
{formatRelativeTime(folder.lastAccessed)}
</div>
</div>
<Show when={focusMode() === "recent" && selectedIndex() === index()}>
<kbd class="kbd"></kbd>
</Show>
</div>
<Show when={focusMode() === "recent" && selectedIndex() === index()}>
<kbd class="kbd"></kbd>
</Show>
</div>
</button>
<button
onClick={(e) => handleRemove(folder.path, e)}
disabled={isLoading()}
class="p-2 transition-all hover:bg-red-100 dark:hover:bg-red-900/30 opacity-70 hover:opacity-100 rounded"
title="Remove from recent"
>
<Trash2 class="w-3.5 h-3.5 transition-colors icon-muted hover:text-red-600 dark:hover:text-red-400" />
</button>
</button>
<button
onClick={(e) => handleRemove(folder.path, e)}
disabled={isLoading()}
class="p-2 transition-all hover:bg-red-100 dark:hover:bg-red-900/30 opacity-70 hover:opacity-100 rounded"
title="Remove from recent"
>
<Trash2 class="w-3.5 h-3.5 transition-colors icon-muted hover:text-red-600 dark:hover:text-red-400" />
</button>
</div>
</div>
</div>
)}
</For>
</div>
</div>
</Show>
<div class="panel shrink-0">
<div class="panel-header">
<h2 class="panel-title">Browse for Folder</h2>
<p class="panel-subtitle">Select any folder on your computer</p>
</div>
<div class="panel-body">
<button
onClick={handleBrowse}
disabled={props.isLoading}
class="button-primary w-full flex items-center justify-center text-sm disabled:cursor-not-allowed"
onMouseEnter={() => setFocusMode("new")}
>
<div class="flex items-center gap-2">
<FolderPlus class="w-4 h-4" />
<span>{props.isLoading ? "Opening..." : "Browse Folders"}</span>
)}
</For>
</div>
<Kbd shortcut="cmd+n" class="ml-2" />
</button>
</div>
{/* Advanced settings section */}
<div class="panel-section w-full">
<button
onClick={() => setIsAdvancedModalOpen(true)}
class="panel-section-header w-full justify-between"
>
<div class="flex items-center gap-2">
<Settings class="w-4 h-4 icon-muted" />
<span class="text-sm font-medium text-secondary">Advanced Settings</span>
</div>
<ChevronRight class="w-4 h-4 icon-muted" />
</button>
<div class="panel-section-content text-sm text-muted">
Configure the OpenCode binary and environment variables.
</div>
</div>
</div>
</div>
<div class="mt-4 panel panel-footer shrink-0">
<div class="panel-footer-hints">
<Show when={folders().length > 0}>
<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">Del</kbd>
<span>Remove</span>
</div>
</Show>
<div class="flex items-center gap-1.5">
<Kbd shortcut="cmd+n" />
<span>Browse</span>
<div class="panel shrink-0">
<div class="panel-header">
<h2 class="panel-title">Browse for Folder</h2>
<p class="panel-subtitle">Select any folder on your computer</p>
</div>
<div class="panel-body">
<button
onClick={handleBrowse}
disabled={props.isLoading}
class="button-primary w-full flex items-center justify-center text-sm disabled:cursor-not-allowed"
onMouseEnter={() => setFocusMode("new")}
>
<div class="flex items-center gap-2">
<FolderPlus class="w-4 h-4" />
<span>{props.isLoading ? "Opening..." : "Browse Folders"}</span>
</div>
<Kbd shortcut="cmd+n" class="ml-2" />
</button>
</div>
{/* Advanced settings section */}
<div class="panel-section w-full">
<button
onClick={() => setIsAdvancedModalOpen(true)}
class="panel-section-header w-full justify-between"
>
<div class="flex items-center gap-2">
<Settings class="w-4 h-4 icon-muted" />
<span class="text-sm font-medium text-secondary">Advanced Settings</span>
</div>
<ChevronRight class="w-4 h-4 icon-muted" />
</button>
</div>
</div>
</div>
<div class="mt-1 panel panel-footer shrink-0">
<div class="panel-footer-hints">
<Show when={folders().length > 0}>
<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">Del</kbd>
<span>Remove</span>
</div>
</Show>
<div class="flex items-center gap-1.5">
<Kbd shortcut="cmd+n" />
<span>Browse</span>
</div>
</div>
</div>
</div>
</div>
<Show when={isLoading()}>
<div class="folder-loading-overlay">
<div class="folder-loading-indicator">
<div class="spinner" />
<p class="folder-loading-text">Starting instance</p>
<p class="folder-loading-subtext">Hang tight while we prepare your workspace.</p>
<Show when={isLoading()}>
<div class="folder-loading-overlay">
<div class="folder-loading-indicator">
<div class="spinner" />
<p class="folder-loading-text">Starting instance</p>
<p class="folder-loading-subtext">Hang tight while we prepare your workspace.</p>
</div>
</div>
</div>
</Show>
</div>
</Show>
</div>
<AdvancedSettingsModal
open={isAdvancedModalOpen()}

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>OpenCode Client</title>
<title>CodeNomad</title>
<style>
:root {
color-scheme: light dark;

View File

@@ -1,6 +1,6 @@
# Task Management
This directory contains the task breakdown for building the OpenCode Client.
This directory contains the task breakdown for building CodeNomad.
## Structure

View File

@@ -173,7 +173,7 @@ packages/opencode-client/
**src/App.tsx:**
- Basic component with "Hello OpenCode Client"
- Basic component with "Hello CodeNomad"
- Display environment info
- Basic styling with TailwindCSS
@@ -203,7 +203,7 @@ packages/opencode-client/
**electron-builder.yml** or in package.json:
- appId: ai.opencode.client
- Product name: OpenCode Client
- Product name: CodeNomad
- Build resources: electron/resources
- Files to include: dist/, package.json
- Directories:
@@ -237,7 +237,7 @@ release/
1. Run `bun install`
2. Run `bun run dev`
3. Verify Electron window opens
4. Verify "Hello OpenCode Client" displays
4. Verify "Hello CodeNomad" displays
5. Make a change to App.tsx
6. Verify hot reload updates UI
7. Run `bun run typecheck`

View File

@@ -30,7 +30,6 @@ Create the initial empty state interface that appears when no instances are runn
- Centered container
- Large folder icon (from lucide-solid)
- Heading: "Welcome to OpenCode Client"
- Subheading: "Select a folder to start coding with AI"
- Primary button: "Select Folder"
- Helper text: "Keyboard shortcut: Cmd/Ctrl+N"