Compare commits

..

93 Commits

Author SHA1 Message Date
Shantur Rathore
ed769911d6 bump to version v0.2.3 2025-11-23 19:37:41 +00:00
Shantur Rathore
dd6efee900 disable SSE body timeouts and ignore workspace-stopped disconnects 2025-11-23 19:34:14 +00:00
Shantur Rathore
48a16a6702 ignore expected workspace stops when showing disconnect modal 2025-11-23 19:17:53 +00:00
Shantur Rathore
841b9daa1f only show disconnect modal on final status 2025-11-23 19:13:22 +00:00
Shantur Rathore
1741e49568 aggregate instance SSE streams through server bus so UI uses single connection 2025-11-23 19:07:10 +00:00
Shantur Rathore
8577b3d1e6 show loading status only for errors 2025-11-23 14:42:09 +00:00
Shantur Rathore
011533b3c4 improve prompt submission history handling 2025-11-23 14:41:49 +00:00
Shantur Rathore
002efad9ad cap CLI proxy concurrency 2025-11-23 14:40:37 +00:00
Shantur Rathore
3ce5569b82 route CLI logs to host processes only 2025-11-23 13:38:50 +00:00
Shantur Rathore
d7c0c225b9 chore: align monorepo package versions with 0.2.2 2025-11-23 12:05:36 +00:00
Shantur Rathore
f4de0103a8 Resolve CLI binary metadata for UI 2025-11-23 11:59:12 +00:00
Shantur Rathore
0a9b7fafed Align Tauri dev flow with shared renderer 2025-11-23 10:37:45 +00:00
Shantur Rathore
073604c9f5 Force dark theme defaults across shells 2025-11-23 10:00:16 +00:00
Shantur Rathore
4062b43380 Enable native dialogs across shells 2025-11-23 00:36:43 +00:00
Shantur Rathore
00bd9f9c1c Allow proxy streams to stay open 2025-11-22 21:50:04 +00:00
Shantur Rathore
3edb0ac09e Add runtime environment detection 2025-11-22 21:46:53 +00:00
Shantur Rathore
e9f3c4ee52 Unify loader assets across shells 2025-11-22 21:20:29 +00:00
Shantur Rathore
92420d9e02 Move screenshots to correct folder 2025-11-21 21:59:58 +00:00
Shantur Rathore
3688be06ee Bump version to 0.2.1 2025-11-21 21:56:29 +00:00
Shantur Rathore
8b2be441fc Ensure Tauri is bundled 2025-11-21 21:19:38 +00:00
Shantur Rathore
b2493a3a53 Use reusable publish workflow with explicit versions 2025-11-21 21:01:22 +00:00
Shantur Rathore
4eb3dbf492 Route npm publish through reusable workflow 2025-11-21 20:54:59 +00:00
Shantur Rathore
adbe0399b2 Fix tauri conf 2025-11-21 20:53:40 +00:00
Shantur Rathore
e2461661f7 Test npm publish 2025-11-21 20:44:54 +00:00
Shantur Rathore
cc012094b4 Test npm publish 2025-11-21 20:42:11 +00:00
Shantur Rathore
968a6f3cab Test npm publish 2025-11-21 20:40:25 +00:00
Shantur Rathore
f0d8634a83 Test npm publish 2025-11-21 20:36:46 +00:00
Shantur Rathore
9f862d5afc Require all linux tauri bundles and upload raw artifacts 2025-11-21 20:17:41 +00:00
Shantur Rathore
08f3d75015 Package tauri linux multi-target and zip windows app folder 2025-11-21 20:15:23 +00:00
Shantur Rathore
d9adab3022 Bundle linux tauri as appimage 2025-11-21 20:10:54 +00:00
Shantur Rathore
9019f7622e Failure on missing tauri linux bundle 2025-11-21 19:30:46 +00:00
Shantur Rathore
631b5002e7 Use non-native alert and confirm dialogs 2025-11-21 19:28:53 +00:00
Shantur Rathore
b1987008c7 Add id token for npmjs 2025-11-21 19:20:46 +00:00
Shantur Rathore
3338109d51 Try to fix linux-arm 2025-11-21 18:58:07 +00:00
Shantur Rathore
c0616df704 Ensure tauri build bundles server 2025-11-21 18:57:19 +00:00
Shantur Rathore
ca4030e86e Fix electron loading screen and linux arm build 2025-11-21 18:50:19 +00:00
Shantur Rathore
0a2d57624c Enable trusted npm publish for server 2025-11-21 18:38:37 +00:00
Shantur Rathore
dbbed94381 Standardize artifact uploads and enable tauri arm64 cross-build 2025-11-21 18:31:45 +00:00
Shantur Rathore
a088b948b4 Fix tauri-linux 2025-11-21 18:08:24 +00:00
Shantur Rathore
87faa32c7c Use npm tauri on wiindows 2025-11-21 18:06:59 +00:00
Shantur Rathore
873be2d6c1 Fix tauri app package.json JSON 2025-11-21 17:59:31 +00:00
Shantur Rathore
5fafed31d0 Use npm Tauri CLI in CI 2025-11-21 17:56:40 +00:00
Shantur Rathore
a763831b83 Use npm @tauri-apps/cli 2.9.4 and npm tauri scripts 2025-11-21 17:55:00 +00:00
Shantur Rathore
076aa4ff9a Use available tauri-cli 2.9.4 in CI 2025-11-21 17:25:13 +00:00
Shantur Rathore
31cbb9cc53 Align Tauri to published 2.5.2 and CLI 2.5.4 2025-11-21 17:23:21 +00:00
Shantur Rathore
ee6db23b14 Pin Tauri to 2.9.3 available on crates.io 2025-11-21 17:19:33 +00:00
Shantur Rathore
9fe3dcdc6d Upgrade Tauri tooling to 2.9.4 2025-11-21 17:16:09 +00:00
Shantur Rathore
ea644a7d0f Switch mac build to zip-only 2025-11-21 16:59:02 +00:00
Shantur Rathore
2f7ddd57dd Remove universal mac build outputs 2025-11-21 16:58:04 +00:00
Shantur Rathore
f2f108c14e Correct macOS Tauri targets and restore jobs 2025-11-21 16:45:16 +00:00
Shantur Rathore
731885f54a Deduplicate Tauri Windows job and update runners 2025-11-21 16:27:11 +00:00
Shantur Rathore
3c11a1bfcb Fix Tauri CI runners and prebuild portability 2025-11-21 16:24:45 +00:00
Shantur Rathore
166dd3f719 Update README for Tauri 2025-11-21 16:13:00 +00:00
Shantur Rathore
123da12468 Use cargo-binstall for tauri-cli in CI 2025-11-21 16:02:01 +00:00
Shantur Rathore
2a7cdbae42 Add arm64 Tauri build jobs 2025-11-21 15:53:33 +00:00
Shantur Rathore
88952a140a Install tauri-cli in CI for all platforms 2025-11-21 15:49:01 +00:00
Shantur Rathore
b64ea411e6 Install rollup native for Tauri builds on macOS/Linux 2025-11-21 15:38:47 +00:00
Shantur Rathore
44b2bb1b68 Fix Windows Tauri build rollup dependency 2025-11-21 15:37:49 +00:00
Shantur Rathore
efabf83a26 Add Tauri build pipeline to releases 2025-11-21 15:31:11 +00:00
Shantur Rathore
ac540a18f2 Add Tauri app sources 2025-11-21 15:22:59 +00:00
Shantur Rathore
4bad384ca0 Switch server publish to npm trusted publisher (OIDC) 2025-11-21 15:20:45 +00:00
Shantur Rathore
0eb00901b9 Bundle server assets into Tauri app build 2025-11-21 15:19:28 +00:00
Shantur Rathore
459b950ab6 Install rollup native binary before server publish 2025-11-21 14:58:33 +00:00
Shantur Rathore
d7edd4cf4a Use non-timestamped dev tag/release names 2025-11-21 14:55:21 +00:00
Shantur Rathore
e0bd5ccc92 Updated browser screenshot 2025-11-21 14:48:28 +00:00
Shantur Rathore
5e82fc4e5d Add repository and homepage metadata for electron build 2025-11-21 14:40:06 +00:00
Shantur Rathore
1b2c775348 Avoid duplicate icon in electron build resources 2025-11-21 14:31:43 +00:00
Shantur Rathore
16e9cb21da Work around rollup native missing in CI builds 2025-11-21 14:21:03 +00:00
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
119 changed files with 16014 additions and 2128 deletions

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

@@ -0,0 +1,519 @@
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:
id-token: write
contents: write
env:
NODE_VERSION: 20
jobs:
build-macos:
runs-on: macos-15-intel
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: Ensure rollup native binary
run: npm install @rollup/rollup-darwin-x64 --no-save
- name: Build macOS binaries (Electron)
run: npm run build:mac --workspace @neuralnomads/codenomad-electron-app
- name: Upload release assets
run: |
set -euo pipefail
shopt -s nullglob
for file in packages/electron-app/release/*.zip; do
[ -f "$file" ] || continue
echo "Uploading $file"
gh release upload "$TAG" "$file" --clobber
done
build-windows:
runs-on: windows-2025
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: Ensure rollup native binary
run: npm install @rollup/rollup-win32-x64-msvc --no-save
- name: Build Windows binaries (Electron)
run: npm run build:win --workspace @neuralnomads/codenomad-electron-app
- name: Upload release assets
shell: pwsh
run: |
Get-ChildItem -Path "packages/electron-app/release" -Filter *.zip -File | ForEach-Object {
Write-Host "Uploading $($_.FullName)"
gh release upload $env:TAG $_.FullName --clobber
}
build-linux:
runs-on: ubuntu-24.04
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: Ensure rollup native binary
run: npm install @rollup/rollup-linux-x64-gnu --no-save
- name: Build Linux binaries (Electron)
run: npm run build:linux --workspace @neuralnomads/codenomad-electron-app
- name: Upload release assets
run: |
set -euo pipefail
shopt -s nullglob
for file in packages/electron-app/release/*.zip; do
[ -f "$file" ] || continue
echo "Uploading $file"
gh release upload "$TAG" "$file" --clobber
done
build-tauri-macos:
runs-on: macos-15-intel
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: Setup Rust (Tauri)
uses: dtolnay/rust-toolchain@stable
- 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: Ensure rollup native binary
run: npm install @rollup/rollup-darwin-x64 --no-save
- name: Build macOS bundle (Tauri)
run: npm run build --workspace @codenomad/tauri-app
- name: Package Tauri artifacts (macOS)
run: |
set -euo pipefail
BUNDLE_ROOT="packages/tauri-app/target/release/bundle"
ARTIFACT_DIR="packages/tauri-app/release-tauri"
rm -rf "$ARTIFACT_DIR"
mkdir -p "$ARTIFACT_DIR"
if [ -d "$BUNDLE_ROOT/macos/CodeNomad.app" ]; then
ditto -ck --sequesterRsrc --keepParent "$BUNDLE_ROOT/macos/CodeNomad.app" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-macos-x64.zip"
fi
- name: Upload Tauri release assets (macOS)
run: |
set -euo pipefail
shopt -s nullglob
for file in packages/tauri-app/release-tauri/*.zip; do
[ -f "$file" ] || continue
echo "Uploading $file"
gh release upload "$TAG" "$file" --clobber
done
build-tauri-macos-arm64:
runs-on: macos-26
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: Setup Rust (Tauri)
uses: dtolnay/rust-toolchain@stable
- 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: Ensure rollup native binary
run: npm install @rollup/rollup-darwin-arm64 --no-save
- name: Build macOS bundle (Tauri, arm64)
run: npm run build --workspace @codenomad/tauri-app
- name: Package Tauri artifacts (macOS arm64)
run: |
set -euo pipefail
BUNDLE_ROOT="packages/tauri-app/target/release/bundle"
ARTIFACT_DIR="packages/tauri-app/release-tauri"
rm -rf "$ARTIFACT_DIR"
mkdir -p "$ARTIFACT_DIR"
if [ -d "$BUNDLE_ROOT/macos/CodeNomad.app" ]; then
ditto -ck --sequesterRsrc --keepParent "$BUNDLE_ROOT/macos/CodeNomad.app" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-macos-arm64.zip"
fi
- name: Upload Tauri release assets (macOS arm64)
run: |
set -euo pipefail
shopt -s nullglob
for file in packages/tauri-app/release-tauri/*.zip; do
[ -f "$file" ] || continue
echo "Uploading $file"
gh release upload "$TAG" "$file" --clobber
done
build-tauri-windows:
runs-on: windows-2025
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: Setup Rust (Tauri)
uses: dtolnay/rust-toolchain@stable
- 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: Ensure rollup native binary
run: npm install @rollup/rollup-win32-x64-msvc --no-save
- name: Build Windows bundle (Tauri)
run: npm run build --workspace @codenomad/tauri-app
- name: Package Tauri artifacts (Windows)
shell: pwsh
run: |
$bundleRoot = "packages/tauri-app/target/release/bundle"
$artifactDir = "packages/tauri-app/release-tauri"
if (Test-Path $artifactDir) { Remove-Item $artifactDir -Recurse -Force }
New-Item -ItemType Directory -Path $artifactDir | Out-Null
$exe = Get-ChildItem -Path $bundleRoot -Recurse -File -Filter *.exe | Select-Object -First 1
if ($null -ne $exe) {
$dest = Join-Path $artifactDir ("CodeNomad-Tauri-$env:VERSION-windows-x64.zip")
Compress-Archive -Path $exe.Directory.FullName -DestinationPath $dest -Force
}
- name: Upload Tauri release assets (Windows)
shell: pwsh
run: |
if (Test-Path "packages/tauri-app/release-tauri") {
Get-ChildItem -Path "packages/tauri-app/release-tauri" -Filter *.zip -File | ForEach-Object {
Write-Host "Uploading $($_.FullName)"
gh release upload $env:TAG $_.FullName --clobber
}
}
build-tauri-linux:
runs-on: ubuntu-24.04
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: Setup Rust (Tauri)
uses: dtolnay/rust-toolchain@stable
- name: Install Linux build dependencies (Tauri)
run: |
sudo apt-get update
sudo apt-get install -y \
build-essential \
pkg-config \
libgtk-3-dev \
libglib2.0-dev \
libwebkit2gtk-4.1-dev \
libsoup-3.0-dev \
libayatana-appindicator3-dev \
librsvg2-dev
- 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: Ensure rollup native binary
run: npm install @rollup/rollup-linux-x64-gnu --no-save
- name: Build Linux bundle (Tauri)
run: npm run build --workspace @codenomad/tauri-app
- name: Package Tauri artifacts (Linux)
run: |
set -euo pipefail
SEARCH_ROOT="packages/tauri-app/target"
ARTIFACT_DIR="packages/tauri-app/release-tauri"
rm -rf "$ARTIFACT_DIR"
mkdir -p "$ARTIFACT_DIR"
shopt -s nullglob globstar
find_one() {
find "$SEARCH_ROOT" -type f -iname "$1" | head -n1
}
appimage=$(find_one "*.AppImage")
deb=$(find_one "*.deb")
rpm=$(find_one "*.rpm")
if [ -z "$appimage" ] || [ -z "$deb" ] || [ -z "$rpm" ]; then
echo "Missing bundle(s): appimage=${appimage:-none} deb=${deb:-none} rpm=${rpm:-none}" >&2
exit 1
fi
cp "$appimage" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-linux-x64.AppImage"
cp "$deb" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-linux-x64.deb"
cp "$rpm" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-linux-x64.rpm"
- name: Upload Tauri release assets (Linux)
run: |
set -euo pipefail
shopt -s nullglob
for file in packages/tauri-app/release-tauri/*; do
[ -f "$file" ] || continue
echo "Uploading $file"
gh release upload "$TAG" "$file" --clobber
done
build-tauri-linux-arm64:
if: ${{ false }}
runs-on: ubuntu-24.04
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ inputs.version }}
TAG: ${{ inputs.tag }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup QEMU
uses: docker/setup-qemu-action@v3
with:
platforms: linux/arm64
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm
- name: Setup Rust (Tauri)
uses: dtolnay/rust-toolchain@stable
with:
targets: aarch64-unknown-linux-gnu
- name: Install Linux build dependencies (Tauri)
run: |
sudo dpkg --add-architecture arm64
sudo tee /etc/apt/sources.list.d/arm64.list >/dev/null <<'EOF'
deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports noble main restricted universe multiverse
deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports noble-updates main restricted universe multiverse
deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports noble-security main restricted universe multiverse
deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports noble-backports main restricted universe multiverse
EOF
sudo apt-get update
sudo apt-get install -y \
build-essential \
pkg-config \
gcc-aarch64-linux-gnu \
g++-aarch64-linux-gnu \
libgtk-3-dev:arm64 \
libglib2.0-dev:arm64 \
libwebkit2gtk-4.1-dev:arm64 \
libsoup-3.0-dev:arm64 \
libayatana-appindicator3-dev:arm64 \
librsvg2-dev:arm64
- 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: Ensure rollup native binary
run: npm install @rollup/rollup-linux-arm64-gnu --no-save
- name: Build Linux bundle (Tauri arm64)
env:
TAURI_BUILD_TARGET: aarch64-unknown-linux-gnu
PKG_CONFIG_PATH: /usr/lib/aarch64-linux-gnu/pkgconfig
CC_aarch64_unknown_linux_gnu: aarch64-linux-gnu-gcc
CXX_aarch64_unknown_linux_gnu: aarch64-linux-gnu-g++
AR_aarch64_unknown_linux_gnu: aarch64-linux-gnu-ar
run: npm run build --workspace @codenomad/tauri-app
- name: Package Tauri artifacts (Linux arm64)
run: |
set -euo pipefail
SEARCH_ROOT="packages/tauri-app/target"
ARTIFACT_DIR="packages/tauri-app/release-tauri"
rm -rf "$ARTIFACT_DIR"
mkdir -p "$ARTIFACT_DIR"
shopt -s nullglob globstar
first_artifact=$(find "$SEARCH_ROOT" -type f \( -name "*.AppImage" -o -name "*.deb" -o -name "*.rpm" -o -name "*.tar.gz" \) | head -n1)
fallback_bin="$SEARCH_ROOT/release/codenomad-tauri"
if [ -n "$first_artifact" ]; then
zip -j "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-linux-x64.zip" "$first_artifact"
elif [ -f "$fallback_bin" ]; then
zip -j "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-linux-x64.zip" "$fallback_bin"
else
echo "No bundled artifact found under $SEARCH_ROOT and no binary at $fallback_bin" >&2
exit 1
fi
- name: Upload Tauri release assets (Linux arm64)
run: |
set -euo pipefail
shopt -s nullglob
for file in packages/tauri-app/release-tauri/*.zip; do
[ -f "$file" ] || continue
echo "Uploading $file"
gh release upload "$TAG" "$file" --clobber
done
build-linux-rpm:
runs-on: ubuntu-24.04
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: Ensure rollup native binary
run: npm install @rollup/rollup-linux-x64-gnu --no-save
- name: Build Linux RPM binaries
run: npm run build:linux-rpm --workspace @neuralnomads/codenomad-electron-app
- name: Upload RPM release assets
run: |
set -euo pipefail
shopt -s nullglob
for file in packages/electron-app/release/*.rpm; do
[ -f "$file" ] || continue
echo "Uploading $file"
gh release upload "$TAG" "$file" --clobber
done

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

@@ -0,0 +1,65 @@
name: Dev Release
on:
workflow_dispatch:
permissions:
id-token: write
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"
TAG="v${DEV_VERSION}"
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: prepare-dev
uses: ./.github/workflows/manual-npm-publish.yml
with:
version: ${{ needs.prepare-dev.outputs.version }}
dist_tag: dev
secrets: inherit

View File

@@ -0,0 +1,74 @@
name: Manual NPM Publish
on:
workflow_dispatch:
inputs:
version:
description: "Version to publish (e.g. 0.2.0-dev)"
required: false
type: string
dist_tag:
description: "npm dist-tag"
required: false
default: dev
type: string
workflow_call:
inputs:
version:
required: true
type: string
dist_tag:
required: false
type: string
default: dev
permissions:
contents: read
id-token: write
jobs:
publish:
runs-on: ubuntu-latest
env:
NODE_VERSION: 20
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
registry-url: https://registry.npmjs.org
- name: Ensure npm >=11.5.1
run: npm install -g npm@latest
- name: Install dependencies
run: npm ci --workspaces
- name: Ensure rollup native binary
run: npm install @rollup/rollup-linux-x64-gnu --no-save
- name: Build server package (includes UI bundling)
run: npm run build --workspace @neuralnomads/codenomad
- name: Set publish metadata
shell: bash
run: |
VERSION_INPUT="${{ inputs.version }}"
if [ -z "$VERSION_INPUT" ]; then
VERSION_INPUT=$(node -p "require('./package.json').version")
fi
echo "VERSION=$VERSION_INPUT" >> "$GITHUB_ENV"
echo "DIST_TAG=${{ inputs.dist_tag || 'dev' }}" >> "$GITHUB_ENV"
- name: Bump package version for publish
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
- name: Publish server package with provenance
env:
NPM_CONFIG_PROVENANCE: true
NPM_CONFIG_REGISTRY: https://registry.npmjs.org
run: |
npm publish --workspace @neuralnomads/codenomad --access public --tag ${DIST_TAG} --provenance

View File

@@ -6,6 +6,7 @@ on:
- main - main
permissions: permissions:
id-token: write
contents: write contents: write
env: env:
@@ -63,154 +64,19 @@ jobs:
gh release create "$TAG" --title "CodeNomad v${VERSION}" --generate-notes gh release create "$TAG" --title "CodeNomad v${VERSION}" --generate-notes
fi fi
build-macos: build-and-upload:
needs: prepare-release needs: prepare-release
runs-on: macos-13 uses: ./.github/workflows/build-and-upload.yml
env: with:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} version: ${{ needs.prepare-release.outputs.version }}
steps: tag: ${{ needs.prepare-release.outputs.tag }}
- name: Checkout release_name: CodeNomad v${{ needs.prepare-release.outputs.version }}
uses: actions/checkout@v4 secrets: inherit
- name: Setup Node publish-server:
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm
- name: Install dependencies
run: npm ci --workspaces
- name: Build macOS binaries
run: npm run build:mac --workspace @codenomad/electron-app
- 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 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:
needs: prepare-release needs: prepare-release
runs-on: windows-latest uses: ./.github/workflows/manual-npm-publish.yml
env: with:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} version: ${{ needs.prepare-release.outputs.version }}
steps: dist_tag: latest
- name: Checkout secrets: inherit
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 --workspaces
- name: Build Windows binaries
run: npm run build:win --workspace @codenomad/electron-app
- name: Upload release assets
shell: pwsh
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ needs.prepare-release.outputs.tag }}
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:
needs: prepare-release
runs-on: ubuntu-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 --workspaces
- name: Build Linux binaries
run: npm run build:linux --workspace @codenomad/electron-app
- 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 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:
needs: prepare-release
runs-on: ubuntu-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 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: Install project dependencies
run: npm ci --workspaces
- name: Build Linux RPM binaries
run: npm run build:linux-rpm --workspace @codenomad/electron-app
- name: Upload RPM release assets
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ needs.prepare-release.outputs.tag }}
run: |
set -euo pipefail
shopt -s nullglob
for file in packages/electron-app/release/*.rpm; do
[ -f "$file" ] || continue
gh release upload "$TAG" "$file" --clobber
done

View File

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

View File

@@ -1,58 +1,76 @@
# CodeNomad # 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) ![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) ![Command palette overlay](docs/screenshots/command-palette.png)
_Global command palette for keyboard-first control._
![Image Previews](docs/screenshots/image-previews.png)
_Rich media previews for images and assets._
![Browser Support](docs/screenshots/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 (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.
### 💻 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 ## Highlights
- **Long-session native** scroll through massive transcripts without hitches and keep full context visible. - **Multi-Instance**: Juggle several OpenCode sessions side-by-side with tabs.
- **Multiple instances, one window** juggle several OpenCode instances side-by-side with per-instance tabs. - **Long-Session Native**: Scroll through massive transcripts without hitches.
- **Deep task awareness** jump into sub/child sessions (Tasks tool) instantly, monitor their status, and answer directly without losing your flow. - **Command Palette**: A single global palette to jump tabs, launch tools, and control everything.
- **Keyboard first** the full UI is optimized for shortcuts so you can stay mouse-free when you want to. - **Deep Task Awareness**: Monitor background tasks and child sessions without losing flow.
- **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.
## Requirements ## 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.
## Repository Layout ## Architecture & Development
CodeNomad now ships as a small workspace with two packages: CodeNomad is a monorepo split into specialized packages. If you want to contribute or build from source, check out the individual package documentation:
- `packages/ui` — SolidJS renderer, Tailwind styles, and standalone Vite configuration for building the UI bundle independently. | Package | Description |
- `packages/electron-app` — Electron main/preload processes plus packaging scripts. It consumes the UI package during development/build via `electron-vite`. |---------|-------------|
| **[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. |
Use `npm run dev --workspace @codenomad/electron-app` for the Electron shell and `npm run dev --workspace @codenomad/ui` for UI-only work. Working with the workspace requires Node.js 18+ with npm 7 or newer so the workspace protocol is available. ### Quick Build
To build the Desktop App from source:
## Downloads
Grab the latest build for macOS, Windows, and Linux from the [GitHub Releases page](https://github.com/shantur/CodeNomad/releases).
## Quick Start
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.
## CLI Server Flags
The bundled CLI server (`@codenomad/cli`) controls which folders the UI can browse when you pick a workspace:
- `--workspace-root <path>` (default: current working directory) scopes browsing to a safe subtree. The UI can only see folders beneath this root.
- `--unrestricted-root` explicitly allows full-machine browsing for the current process. In this mode the UI starts from the host home directory, adds a "parent" option so you can reach `/` on macOS/Linux, and lists drives/UNC paths on Windows. The flag is runtime-only—restart the CLI without it to go back to restricted mode.
- `--ui-dev-server <url>` proxies UI asset requests to a running Vite dev server while the CLI continues to expose its REST APIs and workspace proxies from the same port. Point this at `http://localhost:3000` when developing the renderer to keep hot reloads without sacrificing the single entry point.
Use unrestricted mode only when you trust the host; the CLI will skip directories it cannot read and never persists the opt-in.
### Single Port Proxying
Every OpenCode instance now tunnels through the CLI port. Each workspace descriptor publishes a stable `proxyPath` (e.g., `/workspaces/<id>/instance`), and the CLI exposes `GET/POST/...` + SSE at `http(s)://<cli-host>:<cli-port>${proxyPath}`. That means the UI, Electron shell, and browser clients only need firewall access to the CLI; instance ports stay private on `127.0.0.1`. In development, the `--ui-dev-server` flag still routes UI traffic through the CLI proxy so all instance calls share the same origin.
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`.

Binary file not shown.

After

Width:  |  Height:  |  Size: 845 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

333
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "codenomad-workspace", "name": "codenomad-workspace",
"version": "0.1.2", "version": "0.2.3",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "codenomad-workspace", "name": "codenomad-workspace",
"version": "0.1.2", "version": "0.2.3",
"dependencies": { "dependencies": {
"7zip-bin": "^5.2.0", "7zip-bin": "^5.2.0",
"google-auth-library": "^10.5.0" "google-auth-library": "^10.5.0"
@@ -313,12 +313,8 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@codenomad/cli": { "node_modules/@codenomad/tauri-app": {
"resolved": "packages/cli", "resolved": "packages/tauri-app",
"link": true
},
"node_modules/@codenomad/electron-app": {
"resolved": "packages/electron-app",
"link": true "link": true
}, },
"node_modules/@codenomad/ui": { "node_modules/@codenomad/ui": {
@@ -1233,6 +1229,14 @@
"node": ">= 10.0.0" "node": ">= 10.0.0"
} }
}, },
"node_modules/@neuralnomads/codenomad": {
"resolved": "packages/server",
"link": true
},
"node_modules/@neuralnomads/codenomad-electron-app": {
"resolved": "packages/electron-app",
"link": true
},
"node_modules/@nodelib/fs.scandir": { "node_modules/@nodelib/fs.scandir": {
"version": "2.1.5", "version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -1549,6 +1553,223 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/@tauri-apps/cli": {
"version": "2.9.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.9.4.tgz",
"integrity": "sha512-pvylWC9QckrOS9ATWXIXcgu7g2hKK5xTL5ZQyZU/U0n9l88SEFGcWgLQNa8WZmd+wWIOWhkxOFcOl3i6ubDNNw==",
"dev": true,
"license": "Apache-2.0 OR MIT",
"bin": {
"tauri": "tauri.js"
},
"engines": {
"node": ">= 10"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/tauri"
},
"optionalDependencies": {
"@tauri-apps/cli-darwin-arm64": "2.9.4",
"@tauri-apps/cli-darwin-x64": "2.9.4",
"@tauri-apps/cli-linux-arm-gnueabihf": "2.9.4",
"@tauri-apps/cli-linux-arm64-gnu": "2.9.4",
"@tauri-apps/cli-linux-arm64-musl": "2.9.4",
"@tauri-apps/cli-linux-riscv64-gnu": "2.9.4",
"@tauri-apps/cli-linux-x64-gnu": "2.9.4",
"@tauri-apps/cli-linux-x64-musl": "2.9.4",
"@tauri-apps/cli-win32-arm64-msvc": "2.9.4",
"@tauri-apps/cli-win32-ia32-msvc": "2.9.4",
"@tauri-apps/cli-win32-x64-msvc": "2.9.4"
}
},
"node_modules/@tauri-apps/cli-darwin-arm64": {
"version": "2.9.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.9.4.tgz",
"integrity": "sha512-9rHkMVtbMhe0AliVbrGpzMahOBg3rwV46JYRELxR9SN6iu1dvPOaMaiC4cP6M/aD1424ziXnnMdYU06RAH8oIw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-darwin-x64": {
"version": "2.9.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.9.4.tgz",
"integrity": "sha512-VT9ymNuT06f5TLjCZW2hfSxbVtZDhORk7CDUDYiq5TiSYQdxkl8MVBy0CCFFcOk4QAkUmqmVUA9r3YZ/N/vPRQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-arm-gnueabihf": {
"version": "2.9.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.9.4.tgz",
"integrity": "sha512-tTWkEPig+2z3Rk0zqZYfjUYcgD+aSm72wdrIhdYobxbQZOBw0zfn50YtWv+av7bm0SHvv75f0l7JuwgZM1HFow==",
"cpu": [
"arm"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-arm64-gnu": {
"version": "2.9.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.9.4.tgz",
"integrity": "sha512-ql6vJ611qoqRYHxkKPnb2vHa27U+YRKRmIpLMMBeZnfFtZ938eao7402AQCH1mO2+/8ioUhbpy9R/ZcLTXVmkg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-arm64-musl": {
"version": "2.9.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.9.4.tgz",
"integrity": "sha512-vg7yNn7ICTi6hRrcA/6ff2UpZQP7un3xe3SEld5QM0prgridbKAiXGaCKr3BnUBx/rGXegQlD/wiLcWdiiraSw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-riscv64-gnu": {
"version": "2.9.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.9.4.tgz",
"integrity": "sha512-l8L+3VxNk6yv5T/Z/gv5ysngmIpsai40B9p6NQQyqYqxImqYX37pqREoEBl1YwG7szGnDibpWhidPrWKR59OJA==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-x64-gnu": {
"version": "2.9.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.9.4.tgz",
"integrity": "sha512-PepPhCXc/xVvE3foykNho46OmCyx47E/aG676vKTVp+mqin5d+IBqDL6wDKiGNT5OTTxKEyNlCQ81Xs2BQhhqA==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-x64-musl": {
"version": "2.9.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.9.4.tgz",
"integrity": "sha512-zcd1QVffh5tZs1u1SCKUV/V7RRynebgYUNWHuV0FsIF1MjnULUChEXhAhug7usCDq4GZReMJOoXa6rukEozWIw==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-win32-arm64-msvc": {
"version": "2.9.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.9.4.tgz",
"integrity": "sha512-/7ZhnP6PY04bEob23q8MH/EoDISdmR1wuNm0k9d5HV7TDMd2GGCDa8dPXA4vJuglJKXIfXqxFmZ4L+J+MO42+w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-win32-ia32-msvc": {
"version": "2.9.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.9.4.tgz",
"integrity": "sha512-1LmAfaC4Cq+3O1Ir1ksdhczhdtFSTIV51tbAGtbV/mr348O+M52A/xwCCXQank0OcdBxy5BctqkMtuZnQvA8uQ==",
"cpu": [
"ia32"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-win32-x64-msvc": {
"version": "2.9.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.9.4.tgz",
"integrity": "sha512-EdYd4c9wGvtPB95kqtEyY+bUR+k4kRw3IA30mAQ1jPH6z57AftT8q84qwv0RDp6kkEqOBKxeInKfqi4BESYuqg==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tootallnate/once": { "node_modules/@tootallnate/once": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz",
@@ -5000,15 +5221,6 @@
], ],
"license": "BSD-3-Clause" "license": "BSD-3-Clause"
}, },
"node_modules/ignore": {
"version": "7.0.5",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
"integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
"license": "MIT",
"engines": {
"node": ">= 4"
}
},
"node_modules/inflight": { "node_modules/inflight": {
"version": "1.0.6", "version": "1.0.6",
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
@@ -8399,44 +8611,12 @@
"url": "https://github.com/sponsors/wooorm" "url": "https://github.com/sponsors/wooorm"
} }
}, },
"packages/cli": {
"name": "@codenomad/cli",
"version": "0.1.0",
"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",
"pino": "^9.4.0",
"undici": "^6.19.8",
"zod": "^3.23.8"
},
"bin": {
"codenomad-cli": "dist/bin.js"
},
"devDependencies": {
"cross-env": "^7.0.3",
"ts-node": "^10.9.2",
"tsx": "^4.20.6",
"typescript": "^5.6.3"
}
},
"packages/cli/node_modules/commander": {
"version": "12.1.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz",
"integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"packages/electron-app": { "packages/electron-app": {
"name": "@codenomad/electron-app", "name": "@neuralnomads/codenomad-electron-app",
"version": "0.1.2", "version": "0.2.3",
"dependencies": { "dependencies": {
"@codenomad/ui": "file:../ui", "@codenomad/ui": "file:../ui",
"ignore": "7.0.5" "@neuralnomads/codenomad": "file:../server"
}, },
"devDependencies": { "devDependencies": {
"7zip-bin": "^5.2.0", "7zip-bin": "^5.2.0",
@@ -8446,6 +8626,7 @@
"electron-vite": "4.0.1", "electron-vite": "4.0.1",
"png2icons": "^2.0.1", "png2icons": "^2.0.1",
"pngjs": "^7.0.0", "pngjs": "^7.0.0",
"tsx": "^4.20.6",
"typescript": "^5.3.0", "typescript": "^5.3.0",
"vite": "^5.0.0", "vite": "^5.0.0",
"vite-plugin-solid": "^2.10.0" "vite-plugin-solid": "^2.10.0"
@@ -8458,9 +8639,55 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"packages/server": {
"name": "@neuralnomads/codenomad",
"version": "0.2.3",
"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"
},
"bin": {
"codenomad": "dist/bin.js"
},
"devDependencies": {
"cross-env": "^7.0.3",
"ts-node": "^10.9.2",
"tsx": "^4.20.6",
"typescript": "^5.6.3"
}
},
"packages/server/node_modules/commander": {
"version": "12.1.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz",
"integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"packages/server/node_modules/fuzzysort": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/fuzzysort/-/fuzzysort-2.0.4.tgz",
"integrity": "sha512-Api1mJL+Ad7W7vnDZnWq5pGaXJjyencT+iKGia2PlHUcSsSzWwIQ3S1isiMpwpavjYtGd2FzhUIhnnhOULZgDw==",
"license": "MIT"
},
"packages/tauri-app": {
"name": "@codenomad/tauri-app",
"version": "0.2.3",
"devDependencies": {
"@tauri-apps/cli": "^2.9.4"
}
},
"packages/ui": { "packages/ui": {
"name": "@codenomad/ui", "name": "@codenomad/ui",
"version": "0.1.2", "version": "0.2.3",
"dependencies": { "dependencies": {
"@git-diff-view/solid": "^0.0.8", "@git-diff-view/solid": "^0.0.8",
"@kobalte/core": "0.13.11", "@kobalte/core": "0.13.11",

View File

@@ -1,6 +1,6 @@
{ {
"name": "codenomad-workspace", "name": "codenomad-workspace",
"version": "0.1.2", "version": "0.2.3",
"private": true, "private": true,
"description": "CodeNomad monorepo workspace", "description": "CodeNomad monorepo workspace",
"workspaces": { "workspaces": {
@@ -9,14 +9,16 @@
] ]
}, },
"scripts": { "scripts": {
"dev": "npm run dev --workspace @codenomad/electron-app", "dev": "npm run dev --workspace @neuralnomads/codenomad-electron-app",
"dev:electron": "npm run dev --workspace @codenomad/electron-app", "dev:electron": "npm run dev --workspace @neuralnomads/codenomad-electron-app",
"dev:ui": "npm run dev --workspace @codenomad/ui", "dev:tauri": "npm run dev --workspace @codenomad/tauri-app",
"build": "npm run build --workspace @codenomad/electron-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:ui": "npm run build --workspace @codenomad/ui",
"build:mac-x64": "npm run build:mac-x64 --workspace @codenomad/electron-app", "build:mac-x64": "npm run build:mac-x64 --workspace @neuralnomads/codenomad-electron-app",
"build:binaries": "npm run build:binaries --workspace @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 @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": { "dependencies": {
"7zip-bin": "^5.2.0", "7zip-bin": "^5.2.0",

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

@@ -6,6 +6,7 @@ const uiRoot = resolve(__dirname, "../ui")
const uiSrc = resolve(uiRoot, "src") const uiSrc = resolve(uiRoot, "src")
const uiRendererRoot = resolve(uiRoot, "src/renderer") const uiRendererRoot = resolve(uiRoot, "src/renderer")
const uiRendererEntry = resolve(uiRendererRoot, "index.html") const uiRendererEntry = resolve(uiRendererRoot, "index.html")
const uiRendererLoadingEntry = resolve(uiRendererRoot, "loading.html")
export default defineConfig({ export default defineConfig({
main: { main: {
@@ -25,7 +26,7 @@ export default defineConfig({
build: { build: {
outDir: "dist/preload", outDir: "dist/preload",
lib: { lib: {
entry: resolve(__dirname, "electron/preload/index.ts"), entry: resolve(__dirname, "electron/preload/index.cjs"),
formats: ["cjs"], formats: ["cjs"],
fileName: () => "index.js", fileName: () => "index.js",
}, },
@@ -54,7 +55,10 @@ export default defineConfig({
build: { build: {
outDir: resolve(__dirname, "dist/renderer"), outDir: resolve(__dirname, "dist/renderer"),
rollupOptions: { rollupOptions: {
input: uiRendererEntry, input: {
main: uiRendererEntry,
loading: uiRendererLoadingEntry,
},
}, },
}, },
}, },

View File

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

View File

@@ -1,30 +1,140 @@
import { app, BrowserWindow, dialog, ipcMain, nativeImage, nativeTheme, session } from "electron" import { app, BrowserView, BrowserWindow, nativeImage, session } from "electron"
import { join } from "path" import { existsSync } from "fs"
import { dirname, join } from "path"
import { fileURLToPath } from "url"
import { createApplicationMenu } from "./menu" import { createApplicationMenu } from "./menu"
import { setupInstanceIPC } from "./ipc" import { setupCliIPC } from "./ipc"
import { setupStorageIPC } from "./storage" import { CliProcessManager } from "./process-manager"
const mainFilename = fileURLToPath(import.meta.url)
const mainDirname = dirname(mainFilename)
const isMac = process.platform === "darwin" 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) { if (isMac) {
app.commandLine.appendSwitch("disable-spell-checking") app.commandLine.appendSwitch("disable-spell-checking")
} }
// Setup IPC handlers before creating windows
setupStorageIPC()
let mainWindow: BrowserWindow | null = null
function getIconPath() { function getIconPath() {
if (app.isPackaged) { if (app.isPackaged) {
return join(process.resourcesPath, "icon.png") return join(process.resourcesPath, "icon.png")
} }
return join(app.getAppPath(), "electron/resources/icon.png") return join(mainDirname, "../resources/icon.png")
}
type LoadingTarget =
| { type: "url"; source: string }
| { type: "file"; source: string }
function resolveDevLoadingUrl(): string | null {
if (app.isPackaged) {
return null
}
const devBase = process.env.VITE_DEV_SERVER_URL || process.env.ELECTRON_RENDERER_URL
if (!devBase) {
return null
}
try {
const normalized = devBase.endsWith("/") ? devBase : `${devBase}/`
return new URL("loading.html", normalized).toString()
} catch (error) {
console.warn("[cli] failed to construct dev loading URL", devBase, error)
return null
}
}
function resolveLoadingTarget(): LoadingTarget {
const devUrl = resolveDevLoadingUrl()
if (devUrl) {
return { type: "url", source: devUrl }
}
const filePath = resolveLoadingFilePath()
return { type: "file", source: filePath }
}
function resolveLoadingFilePath() {
const candidates = [
join(app.getAppPath(), "dist/renderer/loading.html"),
join(process.resourcesPath, "dist/renderer/loading.html"),
join(mainDirname, "../dist/renderer/loading.html"),
]
for (const candidate of candidates) {
if (existsSync(candidate)) {
return candidate
}
}
return join(app.getAppPath(), "dist/renderer/loading.html")
}
function loadLoadingScreen(window: BrowserWindow) {
const target = resolveLoadingTarget()
const loader =
target.type === "url"
? window.loadURL(target.source)
: window.loadFile(target.source)
loader.catch((error) => {
console.error("[cli] failed to load loading screen:", error)
})
}
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() { function createWindow() {
const prefersDark = true //nativeTheme.shouldUseDarkColors const prefersDark = true
const backgroundColor = prefersDark ? "#1a1a1a" : "#ffffff" const backgroundColor = prefersDark ? "#1a1a1a" : "#ffffff"
const iconPath = getIconPath() const iconPath = getIconPath()
@@ -36,7 +146,7 @@ function createWindow() {
backgroundColor, backgroundColor,
icon: iconPath, icon: iconPath,
webPreferences: { webPreferences: {
preload: join(__dirname, "../preload/index.js"), preload: getPreloadPath(),
contextIsolation: true, contextIsolation: true,
nodeIntegration: false, nodeIntegration: false,
spellcheck: !isMac, spellcheck: !isMac,
@@ -44,25 +154,136 @@ function createWindow() {
}) })
if (isMac) { if (isMac) {
// Disable macOS spell server to avoid input lag
mainWindow.webContents.session.setSpellCheckerEnabled(false) mainWindow.webContents.session.setSpellCheckerEnabled(false)
} }
showingLoadingScreen = true
currentCliUrl = null
loadLoadingScreen(mainWindow)
if (process.env.NODE_ENV === "development") { if (process.env.NODE_ENV === "development") {
mainWindow.loadURL("http://localhost:3000") mainWindow.webContents.openDevTools({ mode: "detach" })
mainWindow.webContents.openDevTools()
} else {
mainWindow.loadFile(join(__dirname, "../renderer/index.html"))
} }
createApplicationMenu(mainWindow) createApplicationMenu(mainWindow)
setupInstanceIPC(mainWindow) setupCliIPC(mainWindow, cliManager)
mainWindow.on("closed", () => { mainWindow.on("closed", () => {
destroyPreloadingView()
mainWindow = null 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
loadLoadingScreen(mainWindow)
}
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) { if (isMac) {
app.on("web-contents-created", (_, contents) => { app.on("web-contents-created", (_, contents) => {
contents.session.setSpellCheckerEnabled(false) contents.session.setSpellCheckerEnabled(false)
@@ -70,6 +291,8 @@ if (isMac) {
} }
app.whenReady().then(() => { app.whenReady().then(() => {
startCli()
if (isMac) { if (isMac) {
session.defaultSession.setSpellCheckerEnabled(false) session.defaultSession.setSpellCheckerEnabled(false)
app.on("browser-window-created", (_, window) => { app.on("browser-window-created", (_, window) => {
@@ -84,8 +307,6 @@ app.whenReady().then(() => {
} }
} }
console.log("[spellcheck] default session enabled:", session.defaultSession.isSpellCheckerEnabled())
createWindow() createWindow()
app.on("activate", () => { app.on("activate", () => {
@@ -95,6 +316,12 @@ app.whenReady().then(() => {
}) })
}) })
app.on("before-quit", async (event) => {
event.preventDefault()
await cliManager.stop().catch(() => {})
app.exit(0)
})
app.on("window-all-closed", () => { app.on("window-all-closed", () => {
if (process.platform !== "darwin") { if (process.platform !== "darwin") {
app.quit() app.quit()

View File

@@ -1,218 +1,152 @@
import { spawn, execSync, ChildProcess } from "child_process" import { spawn, type ChildProcess } from "child_process"
import { app, BrowserWindow } from "electron" import { app } from "electron"
import { existsSync, statSync } from "fs" import { createRequire } from "module"
import { buildUserShellCommand, getUserShellEnv, runUserShellCommandSync, supportsUserShell } from "./user-shell" import { EventEmitter } from "events"
import { existsSync } from "fs"
import path from "path"
import { buildUserShellCommand, getUserShellEnv, supportsUserShell } from "./user-shell"
export interface ProcessInfo { const nodeRequire = createRequire(import.meta.url)
pid: number
port: number
binaryPath: string type CliState = "starting" | "ready" | "error" | "stopped"
export interface CliStatus {
state: CliState
pid?: number
port?: number
url?: string
error?: string
} }
interface ProcessMeta { export interface CliLogEntry {
pid: number stream: "stdout" | "stderr"
port: number message: string
folder: string
startTime: number
childProcess: ChildProcess
logs: string[]
instanceId: string
} }
class ProcessManager { interface StartOptions {
private processes = new Map<number, ProcessMeta>() dev: boolean
private mainWindow: BrowserWindow | null = null }
setMainWindow(window: BrowserWindow) { interface CliEntryResolution {
this.mainWindow = window entry: string
} runner: "node" | "tsx"
runnerPath?: string
}
private parseLogLevel(message: string): "info" | "error" | "warn" | "debug" { export declare interface CliProcessManager {
const upperMessage = message.toUpperCase() on(event: "status", listener: (status: CliStatus) => void): this
if (upperMessage.includes("[ERROR]") || upperMessage.includes("ERROR:")) return "error" on(event: "ready", listener: (status: CliStatus) => void): this
if (upperMessage.includes("[WARN]") || upperMessage.includes("WARN:")) return "warn" on(event: "log", listener: (entry: CliLogEntry) => void): this
if (upperMessage.includes("[DEBUG]") || upperMessage.includes("DEBUG:")) return "debug" on(event: "exit", listener: (status: CliStatus) => void): this
if (upperMessage.includes("[INFO]") || upperMessage.includes("INFO:")) return "info" on(event: "error", listener: (error: Error) => void): this
return "info" }
}
private sendLog(instanceId: string, level: "info" | "error" | "warn" | "debug", message: string) { export class CliProcessManager extends EventEmitter {
if (this.mainWindow && message.trim()) { private child?: ChildProcess
const parsedLevel = this.parseLogLevel(message) private status: CliStatus = { state: "stopped" }
this.mainWindow.webContents.send("instance:log", { private stdoutBuffer = ""
id: instanceId, private stderrBuffer = ""
entry: {
timestamp: Date.now(),
level: parsedLevel,
message: message.trim(),
},
})
}
}
async spawn( async start(options: StartOptions): Promise<CliStatus> {
folder: string, if (this.child) {
instanceId: string, await this.stop()
binaryPath?: string,
environmentVariables?: Record<string, string>,
): Promise<ProcessInfo> {
this.validateFolder(folder)
const useUserShell = supportsUserShell()
const logAttempt = (message: string) => {
console.info(`[ProcessManager] ${message}`)
this.sendLog(instanceId, "debug", message)
} }
const env = useUserShell ? getUserShellEnv() : { ...process.env } this.stdoutBuffer = ""
if (environmentVariables) { this.stderrBuffer = ""
Object.assign(env, environmentVariables) this.updateStatus({ state: "starting", port: undefined, pid: undefined, url: undefined, error: undefined })
this.sendLog(
instanceId,
"info",
`Using ${Object.keys(environmentVariables).length} custom environment variables:`,
)
// Log each environment variable const cliEntry = this.resolveCliEntry(options)
for (const [key, value] of Object.entries(environmentVariables)) { const args = this.buildCliArgs(options)
this.sendLog(instanceId, "info", ` ${key}=${value}`)
}
}
let targetBinary: string console.info(
if (!binaryPath || binaryPath === "opencode") { `[cli] launching CodeNomad CLI (${options.dev ? "dev" : "prod"}) using ${cliEntry.runner} at ${cliEntry.entry}`,
targetBinary = useUserShell ? "opencode" : this.validateOpenCodeBinary(logAttempt)
} else {
targetBinary = this.validateCustomBinary(binaryPath, logAttempt)
}
const spawnCommand = useUserShell
? this.buildShellServeCommand(targetBinary)
: { command: targetBinary, args: this.buildServeArgs() }
const launchDetail = `${spawnCommand.command} ${spawnCommand.args.join(" ")}`.trim()
this.sendLog(instanceId, "debug", `Launching process with: ${launchDetail}`)
this.sendLog(
instanceId,
"info",
`Starting OpenCode server for ${folder} using ${targetBinary}...`,
) )
return new Promise((resolve, reject) => { const env = supportsUserShell() ? getUserShellEnv() : { ...process.env }
const child = spawn(spawnCommand.command, spawnCommand.args, { env.ELECTRON_RUN_AS_NODE = "1"
cwd: folder,
stdio: ["ignore", "pipe", "pipe"],
env,
shell: false,
})
const spawnDetails = supportsUserShell()
? buildUserShellCommand(`ELECTRON_RUN_AS_NODE=1 exec ${this.buildCommand(cliEntry, args)}`)
: this.buildDirectSpawn(cliEntry, args)
const child = spawn(spawnDetails.command, spawnDetails.args, {
cwd: process.cwd(),
stdio: ["ignore", "pipe", "pipe"],
env,
shell: false,
})
console.info(`[cli] spawn command: ${spawnDetails.command} ${spawnDetails.args.join(" ")}`)
if (!child.pid) {
console.error("[cli] spawn failed: no pid")
}
this.child = child
this.updateStatus({ pid: child.pid ?? undefined })
child.stdout?.on("data", (data: Buffer) => {
this.handleStream(data.toString(), "stdout")
})
child.stderr?.on("data", (data: Buffer) => {
this.handleStream(data.toString(), "stderr")
})
child.on("error", (error) => {
console.error("[cli] failed to start CLI:", error)
this.updateStatus({ state: "error", error: error.message })
this.emit("error", error)
})
child.on("exit", (code, signal) => {
const failed = this.status.state !== "ready"
const error = failed ? this.status.error ?? `CLI exited with code ${code ?? 0}${signal ? ` (${signal})` : ""}` : undefined
console.info(`[cli] exit (code=${code}, signal=${signal || ""})${error ? ` error=${error}` : ""}`)
this.updateStatus({ state: failed ? "error" : "stopped", error })
if (failed && error) {
this.emit("error", new Error(error))
}
this.emit("exit", this.status)
this.child = undefined
})
return new Promise<CliStatus>((resolve, reject) => {
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
child.kill("SIGKILL") this.handleTimeout()
this.sendLog(instanceId, "error", "Server startup timeout (10s exceeded)") reject(new Error("CLI startup timeout"))
reject(new Error("Server startup timeout (10s exceeded)")) }, 15000)
}, 10000)
let stdoutBuffer = "" this.once("ready", (status) => {
let stderrBuffer = ""
let portFound = false
child.stdout?.on("data", (data: Buffer) => {
const text = data.toString()
stdoutBuffer += text
const lines = stdoutBuffer.split("\n")
stdoutBuffer = lines.pop() || ""
for (const line of lines) {
if (!line.trim()) continue
this.sendLog(instanceId, "info", line)
const portMatch = line.match(/opencode server listening on http:\/\/[^:]+:(\d+)/)
if (portMatch && !portFound) {
portFound = true
const port = parseInt(portMatch[1], 10)
clearTimeout(timeout)
const meta: ProcessMeta = {
pid: child.pid!,
port,
folder,
startTime: Date.now(),
childProcess: child,
logs: [line],
instanceId,
}
this.processes.set(child.pid!, meta)
resolve({ pid: child.pid!, port, binaryPath: targetBinary })
}
const meta = this.processes.get(child.pid!)
if (meta) {
meta.logs.push(line)
}
}
})
child.stderr?.on("data", (data: Buffer) => {
const text = data.toString()
stderrBuffer += text
const lines = stderrBuffer.split("\n")
stderrBuffer = lines.pop() || ""
for (const line of lines) {
if (!line.trim()) continue
this.sendLog(instanceId, "error", line)
const meta = this.processes.get(child.pid!)
if (meta) {
meta.logs.push(line)
}
}
})
child.on("error", (error) => {
clearTimeout(timeout) clearTimeout(timeout)
if (error.message.includes("ENOENT")) { resolve(status)
reject(new Error("opencode binary not found in PATH"))
} else {
reject(error)
}
}) })
child.on("exit", (code, signal) => { this.once("error", (error) => {
clearTimeout(timeout) clearTimeout(timeout)
this.processes.delete(child.pid!) reject(error)
if (!portFound) {
const errorMsg = stderrBuffer || `Process exited with code ${code}`
reject(new Error(errorMsg))
}
}) })
}) })
} }
async kill(pid: number): Promise<void> { async stop(): Promise<void> {
const meta = this.processes.get(pid) const child = this.child
if (!meta) { if (!child) {
// Treat unknown processes as already stopped so tabs close cleanly this.updateStatus({ state: "stopped" })
return return
} }
return new Promise((resolve, reject) => { return new Promise((resolve) => {
const child = meta.childProcess
const killTimeout = setTimeout(() => { const killTimeout = setTimeout(() => {
child.kill("SIGKILL") child.kill("SIGKILL")
}, 2000) }, 4000)
child.on("exit", () => { child.on("exit", () => {
clearTimeout(killTimeout) clearTimeout(killTimeout)
this.processes.delete(pid) this.child = undefined
console.info("[cli] CLI process exited")
this.updateStatus({ state: "stopped" })
resolve() resolve()
}) })
@@ -220,134 +154,173 @@ class ProcessManager {
}) })
} }
getStatus(pid: number): "running" | "stopped" | "unknown" { getStatus(): CliStatus {
if (!this.processes.has(pid)) { return { ...this.status }
return "unknown" }
}
try { private handleTimeout() {
process.kill(pid, 0) if (this.child) {
return "running" this.child.kill("SIGKILL")
} catch { this.child = undefined
return "stopped" }
this.updateStatus({ state: "error", error: "CLI did not start in time" })
this.emit("error", new Error("CLI did not start in time"))
}
private handleStream(chunk: string, stream: "stdout" | "stderr") {
if (stream === "stdout") {
this.stdoutBuffer += chunk
this.processBuffer("stdout")
} else {
this.stderrBuffer += chunk
this.processBuffer("stderr")
} }
} }
getAllProcesses(): Map<number, ProcessMeta> { private processBuffer(stream: "stdout" | "stderr") {
return new Map(this.processes) const buffer = stream === "stdout" ? this.stdoutBuffer : this.stderrBuffer
} const lines = buffer.split("\n")
const trailing = lines.pop() ?? ""
async cleanup(): Promise<void> { if (stream === "stdout") {
const killPromises = Array.from(this.processes.keys()).map((pid) => this.kill(pid).catch(() => {})) this.stdoutBuffer = trailing
await Promise.all(killPromises) } else {
} this.stderrBuffer = trailing
private validateFolder(folder: string): void {
if (!existsSync(folder)) {
throw new Error(`Folder does not exist: ${folder}`)
} }
const stats = statSync(folder) for (const line of lines) {
if (!stats.isDirectory()) { if (!line.trim()) continue
throw new Error(`Path is not a directory: ${folder}`) console.info(`[cli][${stream}] ${line}`)
} this.emit("log", { stream, message: line })
}
private validateOpenCodeBinary(logAttempt?: (message: string) => void): string { const port = this.extractPort(line)
const log = logAttempt ?? ((message: string) => console.info(`[ProcessManager] ${message}`)) if (port && this.status.state === "starting") {
const url = `http://127.0.0.1:${port}`
if (process.platform === "win32") { console.info(`[cli] ready on ${url}`)
log("Checking PATH via 'where opencode'") this.updateStatus({ state: "ready", port, url })
return this.resolveBinaryViaLocator("where opencode", log) this.emit("ready", this.status)
}
const shellCheck = buildUserShellCommand("command -v opencode")
const shellPreview = [shellCheck.command, ...shellCheck.args].join(" ")
log(`Checking PATH via shell: ${shellPreview}`)
try {
const resolved = runUserShellCommandSync("command -v opencode")
const path = this.pickFirstPath(resolved)
if (path) {
log(`Shell located opencode at ${path}`)
return path
}
throw new Error("Empty result from shell lookup")
} catch (shellError) {
const message = shellError instanceof Error ? shellError.message : String(shellError)
log(`Shell lookup failed: ${message}`)
try {
log("Fallback to 'which opencode'")
return this.resolveBinaryViaLocator("which opencode", log)
} catch (locatorError) {
const locatorMessage = locatorError instanceof Error ? locatorError.message : String(locatorError)
log(`Locator fallback failed: ${locatorMessage}`)
throw new Error(
"opencode binary not found in PATH. Please install OpenCode CLI first: npm install -g @opencode/cli",
)
} }
} }
} }
private validateCustomBinary(binaryPath: string, log?: (message: string) => void): string { private extractPort(line: string): number | null {
log?.(`Validating custom binary at ${binaryPath}`) const readyMatch = line.match(/CodeNomad Server is ready at http:\/\/[^:]+:(\d+)/i)
if (readyMatch) {
if (!existsSync(binaryPath)) { return parseInt(readyMatch[1], 10)
throw new Error(`OpenCode binary not found: ${binaryPath}`)
} }
const stats = statSync(binaryPath) if (line.toLowerCase().includes("http server listening")) {
if (!stats.isFile()) { const httpMatch = line.match(/:(\d{2,5})(?!.*:\d)/)
throw new Error(`Path is not a file: ${binaryPath}`) if (httpMatch) {
} return parseInt(httpMatch[1], 10)
}
// Check if executable (on Unix systems)
if (process.platform !== "win32") {
try { try {
execSync(`test -x "${binaryPath}"`, { stdio: "pipe" }) const parsed = JSON.parse(line)
if (typeof parsed.port === "number") {
return parsed.port
}
} catch { } catch {
throw new Error(`Binary is not executable: ${binaryPath}`) // not JSON, ignore
} }
} }
return binaryPath return null
} }
private resolveBinaryViaLocator(command: string, log?: (message: string) => void): string { private updateStatus(patch: Partial<CliStatus>) {
log?.(`Running locator command: ${command}`) this.status = { ...this.status, ...patch }
const output = execSync(command, { stdio: "pipe", encoding: "utf-8" }) this.emit("status", this.status)
log?.(`Locator output: ${output.trim() || "<empty>"}`) }
const path = this.pickFirstPath(output)
if (!path) { private buildCliArgs(options: StartOptions): string[] {
throw new Error("opencode binary not found in PATH") const args = ["serve", "--host", "127.0.0.1", "--port", "0"]
if (options.dev) {
args.push("--ui-dev-server", "http://localhost:3000", "--log-level", "debug")
} }
return path
return args
} }
private pickFirstPath(output: string): string | null { private buildCommand(cliEntry: CliEntryResolution, args: string[]): string {
const line = output const parts = [JSON.stringify(process.execPath)]
.split("\n") if (cliEntry.runner === "tsx" && cliEntry.runnerPath) {
.map((entry) => entry.trim()) parts.push(JSON.stringify(cliEntry.runnerPath))
.find((entry) => entry.length > 0) }
return line ?? null parts.push(JSON.stringify(cliEntry.entry))
args.forEach((arg) => parts.push(JSON.stringify(arg)))
return parts.join(" ")
} }
private buildServeArgs(): string[] { private buildDirectSpawn(cliEntry: CliEntryResolution, args: string[]) {
return ["serve", "--port", "0", "--print-logs", "--log-level", "DEBUG"] if (cliEntry.runner === "tsx") {
return { command: process.execPath, args: [cliEntry.runnerPath!, cliEntry.entry, ...args] }
}
return { command: process.execPath, args: [cliEntry.entry, ...args] }
} }
private buildShellServeCommand(binaryPath: string): { command: string; args: string[] } { private resolveCliEntry(options: StartOptions): CliEntryResolution {
const args = this.buildServeArgs() if (options.dev) {
.map((arg) => JSON.stringify(arg)) const tsxPath = this.resolveTsx()
.join(" ") if (!tsxPath) {
return buildUserShellCommand(`exec ${JSON.stringify(binaryPath)} ${args}`) throw new Error("tsx is required to run the CLI in development mode. Please install dependencies.")
}
const devEntry = this.resolveDevEntry()
return { entry: devEntry, runner: "tsx", runnerPath: tsxPath }
}
const distEntry = this.resolveProdEntry()
return { entry: distEntry, runner: "node" }
}
private resolveTsx(): string | null {
const candidates: Array<string | (() => string)> = [
() => nodeRequire.resolve("tsx/cli"),
() => nodeRequire.resolve("tsx/dist/cli.mjs"),
() => nodeRequire.resolve("tsx/dist/cli.cjs"),
path.resolve(process.cwd(), "node_modules", "tsx", "dist", "cli.mjs"),
path.resolve(process.cwd(), "node_modules", "tsx", "dist", "cli.cjs"),
path.resolve(process.cwd(), "..", "node_modules", "tsx", "dist", "cli.mjs"),
path.resolve(process.cwd(), "..", "node_modules", "tsx", "dist", "cli.cjs"),
path.resolve(process.cwd(), "..", "..", "node_modules", "tsx", "dist", "cli.mjs"),
path.resolve(process.cwd(), "..", "..", "node_modules", "tsx", "dist", "cli.cjs"),
path.resolve(app.getAppPath(), "..", "node_modules", "tsx", "dist", "cli.mjs"),
path.resolve(app.getAppPath(), "..", "node_modules", "tsx", "dist", "cli.cjs"),
]
for (const candidate of candidates) {
try {
const resolved = typeof candidate === "function" ? candidate() : candidate
if (resolved && existsSync(resolved)) {
return resolved
}
} catch {
continue
}
}
return null
}
private resolveDevEntry(): string {
const entry = path.resolve(process.cwd(), "..", "server", "src", "index.ts")
if (!existsSync(entry)) {
throw new Error(`Dev CLI entry not found at ${entry}. Run npm run dev:electron from the repository root after installing dependencies.`)
}
return entry
}
private resolveProdEntry(): string {
try {
const entry = nodeRequire.resolve("@neuralnomads/codenomad/dist/bin.js")
if (existsSync(entry)) {
return entry
}
} catch {
// fall through to error below
}
throw new Error("Unable to locate CodeNomad CLI build (dist/bin.js). Run npm run build --workspace @neuralnomads/codenomad.")
} }
} }
export const processManager = new ProcessManager()
app.on("before-quit", async (event) => {
event.preventDefault()
await processManager.cleanup()
app.exit(0)
})

View File

@@ -0,0 +1,16 @@
const { contextBridge, ipcRenderer } = require("electron")
const electronAPI = {
onCliStatus: (callback) => {
ipcRenderer.on("cli:status", (_, data) => callback(data))
return () => ipcRenderer.removeAllListeners("cli:status")
},
onCliError: (callback) => {
ipcRenderer.on("cli:error", (_, data) => callback(data))
return () => ipcRenderer.removeAllListeners("cli:error")
},
getCliStatus: () => ipcRenderer.invoke("cli:getStatus"),
openDialog: (options) => ipcRenderer.invoke("dialog:open", options),
}
contextBridge.exposeInMainWorld("electronAPI", electronAPI)

View File

@@ -1,49 +0,0 @@
import { contextBridge, ipcRenderer } from "electron"
import type { ElectronAPI } from "../../../ui/src/types/electron-api"
const electronAPI: ElectronAPI = {
selectFolder: () => ipcRenderer.invoke("dialog:selectFolder"),
createInstance: (id: string, folder: string, binaryPath?: string, environmentVariables?: Record<string, string>) =>
ipcRenderer.invoke("instance:create", id, folder, binaryPath, environmentVariables),
stopInstance: (pid: number) => ipcRenderer.invoke("instance:stop", pid),
onInstanceStarted: (callback) => {
ipcRenderer.on("instance:started", (_, data) => callback(data))
},
onInstanceError: (callback) => {
ipcRenderer.on("instance:error", (_, data) => callback(data))
},
onInstanceStopped: (callback) => {
ipcRenderer.on("instance:stopped", (_, data) => callback(data))
},
onInstanceLog: (callback) => {
ipcRenderer.on("instance:log", (_, data) => callback(data))
},
onNewInstance: (callback) => {
ipcRenderer.on("menu:newInstance", () => callback())
},
scanDirectory: (workspaceFolder: string) => ipcRenderer.invoke("fs:scanDirectory", workspaceFolder),
// OpenCode binary operations
selectOpenCodeBinary: () => ipcRenderer.invoke("dialog:selectOpenCodeBinary"),
validateOpenCodeBinary: (path: string) => ipcRenderer.invoke("opencode:validateBinary", path),
// Storage operations
getConfigPath: () => ipcRenderer.invoke("storage:getConfigPath"),
getInstancesDir: () => ipcRenderer.invoke("storage:getInstancesDir"),
readConfigFile: () => ipcRenderer.invoke("storage:readConfigFile"),
writeConfigFile: (content: string) => ipcRenderer.invoke("storage:writeConfigFile", content),
readInstanceFile: (filename: string) => ipcRenderer.invoke("storage:readInstanceFile", filename),
writeInstanceFile: (filename: string, content: string) =>
ipcRenderer.invoke("storage:writeInstanceFile", filename, content),
deleteInstanceFile: (filename: string) => ipcRenderer.invoke("storage:deleteInstanceFile", filename),
onConfigChanged: (callback: () => void) => {
ipcRenderer.on("storage:configChanged", () => callback())
return () => ipcRenderer.removeAllListeners("storage:configChanged")
},
}
contextBridge.exposeInMainWorld("electronAPI", electronAPI)
declare global {
interface Window {
electronAPI: ElectronAPI
}
}

View File

@@ -1,16 +1,21 @@
{ {
"name": "@codenomad/electron-app", "name": "@neuralnomads/codenomad-electron-app",
"version": "0.1.2", "version": "0.2.3",
"description": "CodeNomad - AI coding assistant", "description": "CodeNomad - AI coding assistant",
"author": { "author": {
"name": "Shantur Rathore", "name": "Neural Nomads",
"email": "codenomad@shantur.com" "email": "codenomad@neuralnomads.ai"
}, },
"type": "module", "type": "module",
"main": "dist/main/main.js", "main": "dist/main/main.js",
"repository": {
"type": "git",
"url": "https://github.com/NeuralNomadsAI/CodeNomad.git"
},
"homepage": "https://github.com/NeuralNomadsAI/CodeNomad",
"scripts": { "scripts": {
"dev": "electron-vite dev", "dev": "electron-vite dev",
"dev:electron": "NODE_ENV=development electron .", "dev:electron": "NODE_ENV=development ELECTRON_ENABLE_LOGGING=1 NODE_OPTIONS=\"--import tsx\" electron electron/main/main.ts",
"build": "electron-vite build", "build": "electron-vite build",
"typecheck": "tsc --noEmit -p tsconfig.json", "typecheck": "tsc --noEmit -p tsconfig.json",
"preview": "electron-vite preview", "preview": "electron-vite preview",
@@ -29,8 +34,8 @@
"package:linux": "electron-builder --linux" "package:linux": "electron-builder --linux"
}, },
"dependencies": { "dependencies": {
"@codenomad/ui": "file:../ui", "@neuralnomads/codenomad": "file:../server",
"ignore": "7.0.5" "@codenomad/ui": "file:../ui"
}, },
"devDependencies": { "devDependencies": {
"7zip-bin": "^5.2.0", "7zip-bin": "^5.2.0",
@@ -40,6 +45,7 @@
"electron-vite": "4.0.1", "electron-vite": "4.0.1",
"png2icons": "^2.0.1", "png2icons": "^2.0.1",
"pngjs": "^7.0.0", "pngjs": "^7.0.0",
"tsx": "^4.20.6",
"typescript": "^5.3.0", "typescript": "^5.3.0",
"vite": "^5.0.0", "vite": "^5.0.0",
"vite-plugin-solid": "^2.10.0" "vite-plugin-solid": "^2.10.0"
@@ -55,39 +61,55 @@
"dist/**/*", "dist/**/*",
"package.json" "package.json"
], ],
"extraResources": [
{
"from": "electron/resources",
"to": "",
"filter": [
"!icon.icns",
"!icon.ico"
]
}
],
"mac": { "mac": {
"category": "public.app-category.developer-tools", "category": "public.app-category.developer-tools",
"target": [ "target": [
{
"target": "dmg",
"arch": ["x64", "arm64", "universal"]
},
{ {
"target": "zip", "target": "zip",
"arch": ["x64", "arm64", "universal"] "arch": [
"x64",
"arm64"
]
} }
], ],
"artifactName": "${productName}-${version}-${os}-${arch}.${ext}", "artifactName": "CodeNomad-${version}-${os}-${arch}.${ext}",
"icon": "electron/resources/icon.icns" "icon": "electron/resources/icon.icns"
}, },
"dmg": { "dmg": {
"contents": [ "contents": [
{ "x": 130, "y": 220 }, {
{ "x": 410, "y": 220, "type": "link", "path": "/Applications" } "x": 130,
"y": 220
},
{
"x": 410,
"y": 220,
"type": "link",
"path": "/Applications"
}
] ]
}, },
"win": { "win": {
"target": [ "target": [
{
"target": "nsis",
"arch": ["x64", "arm64"]
},
{ {
"target": "zip", "target": "zip",
"arch": ["x64", "arm64"] "arch": [
"x64",
"arm64"
]
} }
], ],
"artifactName": "${productName}-${version}-${os}-${arch}.${ext}", "artifactName": "CodeNomad-${version}-${os}-${arch}.${ext}",
"icon": "electron/resources/icon.ico" "icon": "electron/resources/icon.ico"
}, },
"nsis": { "nsis": {
@@ -99,23 +121,14 @@
"linux": { "linux": {
"target": [ "target": [
{ {
"target": "AppImage", "target": "zip",
"arch": ["x64", "arm64"] "arch": [
}, "x64",
{ "arm64"
"target": "deb", ]
"arch": ["x64", "arm64"]
},
{
"target": "rpm",
"arch": ["x64", "arm64"]
},
{
"target": "tar.gz",
"arch": ["x64", "arm64"]
} }
], ],
"artifactName": "${productName}-${version}-${os}-${arch}.${ext}", "artifactName": "CodeNomad-${version}-${os}-${arch}.${ext}",
"category": "Development", "category": "Development",
"icon": "electron/resources/icon.png" "icon": "electron/resources/icon.png"
} }

View File

@@ -7,15 +7,17 @@ import { fileURLToPath } from "url"
const __dirname = fileURLToPath(new URL(".", import.meta.url)) const __dirname = fileURLToPath(new URL(".", import.meta.url))
const appDir = join(__dirname, "..") const appDir = join(__dirname, "..")
const workspaceRoot = join(appDir, "..", "..")
const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm" const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm"
const npxCmd = process.platform === "win32" ? "npx.cmd" : "npx" const npxCmd = process.platform === "win32" ? "npx.cmd" : "npx"
const nodeModulesPath = join(appDir, "node_modules") const nodeModulesPath = join(appDir, "node_modules")
const workspaceNodeModulesPath = join(workspaceRoot, "node_modules")
const platforms = { const platforms = {
mac: { mac: {
args: ["--mac", "--x64", "--arm64", "--universal"], args: ["--mac", "--x64", "--arm64"],
description: "macOS (Intel, Apple Silicon, Universal)", description: "macOS (Intel & Apple Silicon)",
}, },
"mac-x64": { "mac-x64": {
args: ["--mac", "--x64"], args: ["--mac", "--x64"],
@@ -93,10 +95,16 @@ async function build(platform) {
console.log(`\n🔨 Building for: ${config.description}\n`) console.log(`\n🔨 Building for: ${config.description}\n`)
try { 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"]) 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") const distPath = join(appDir, "dist")
if (!existsSync(distPath)) { if (!existsSync(distPath)) {
throw new Error("dist/ directory not found. Build failed.") throw new Error("dist/ directory not found. Build failed.")

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

View File

@@ -1,12 +1,12 @@
{ {
"name": "@codenomad/cli", "name": "@neuralnomads/codenomad",
"version": "0.1.0", "version": "0.2.3",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@codenomad/cli", "name": "@neuralnomads/codenomad",
"version": "0.1.0", "version": "0.2.3",
"dependencies": { "dependencies": {
"@fastify/cors": "^8.5.0", "@fastify/cors": "^8.5.0",
"commander": "^12.1.0", "commander": "^12.1.0",

View File

@@ -1,11 +1,19 @@
{ {
"name": "@codenomad/cli", "name": "@neuralnomads/codenomad",
"version": "0.1.0", "version": "0.2.3",
"description": "CodeNomad CLI server for HTTP/SSE control plane", "description": "CodeNomad Server",
"author": {
"name": "Neural Nomads",
"email": "codenomad@neuralnomads.ai"
},
"repository": {
"type": "git",
"url": "https://github.com/NeuralNomadsAI/CodeNomad.git"
},
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",
"bin": { "bin": {
"codenomad-cli": "dist/bin.js" "codenomad": "dist/bin.js"
}, },
"scripts": { "scripts": {
"build": "npm run build:ui && npm run prepare-ui && tsc -p tsconfig.json", "build": "npm run build:ui && npm run prepare-ui && tsc -p tsconfig.json",
@@ -20,6 +28,7 @@
"@fastify/static": "^7.0.4", "@fastify/static": "^7.0.4",
"commander": "^12.1.0", "commander": "^12.1.0",
"fastify": "^4.28.1", "fastify": "^4.28.1",
"fuzzysort": "^2.0.4",
"pino": "^9.4.0", "pino": "^9.4.0",
"undici": "^6.19.8", "undici": "^6.19.8",
"zod": "^3.23.8" "zod": "^3.23.8"

View File

@@ -1,4 +1,5 @@
import type { import type {
AgentModelSelection,
AgentModelSelections, AgentModelSelections,
ConfigFile, ConfigFile,
ModelPreference, ModelPreference,
@@ -103,8 +104,19 @@ export interface WorkspaceFileResponse {
contents: string contents: string
} }
export type WorkspaceFileSearchResponse = FileSystemEntry[]
export interface InstanceData { export interface InstanceData {
messageHistory: string[] messageHistory: string[]
agentModelSelections: AgentModelSelection
}
export type InstanceStreamStatus = "connecting" | "connected" | "error" | "disconnected"
export interface InstanceStreamEvent {
type: string
properties?: Record<string, unknown>
[key: string]: unknown
} }
export interface BinaryRecord { export interface BinaryRecord {
@@ -112,6 +124,7 @@ export interface BinaryRecord {
path: string path: string
label: string label: string
version?: string version?: string
/** Indicates that this binary will be picked when workspaces omit an explicit choice. */ /** Indicates that this binary will be picked when workspaces omit an explicit choice. */
isDefault: boolean isDefault: boolean
lastValidatedAt?: string lastValidatedAt?: string
@@ -151,6 +164,9 @@ export type WorkspaceEventType =
| "workspace.log" | "workspace.log"
| "config.appChanged" | "config.appChanged"
| "config.binariesChanged" | "config.binariesChanged"
| "instance.dataChanged"
| "instance.event"
| "instance.eventStatus"
export type WorkspaceEventPayload = export type WorkspaceEventPayload =
| { type: "workspace.created"; workspace: WorkspaceDescriptor } | { type: "workspace.created"; workspace: WorkspaceDescriptor }
@@ -160,6 +176,9 @@ export type WorkspaceEventPayload =
| { type: "workspace.log"; entry: WorkspaceLogEntry } | { type: "workspace.log"; entry: WorkspaceLogEntry }
| { type: "config.appChanged"; config: AppConfig } | { type: "config.appChanged"; config: AppConfig }
| { type: "config.binariesChanged"; binaries: BinaryRecord[] } | { type: "config.binariesChanged"; binaries: BinaryRecord[] }
| { type: "instance.dataChanged"; instanceId: string; data: InstanceData }
| { type: "instance.event"; instanceId: string; event: InstanceStreamEvent }
| { type: "instance.eventStatus"; instanceId: string; status: InstanceStreamStatus; reason?: string }
export interface ServerMeta { export interface ServerMeta {
/** Base URL clients should target for REST calls (useful for Electron embedding). */ /** Base URL clients should target for REST calls (useful for Electron embedding). */

View File

@@ -6,7 +6,7 @@ import {
} from "../api-types" } from "../api-types"
import { ConfigStore } from "./store" import { ConfigStore } from "./store"
import { EventBus } from "../events/bus" import { EventBus } from "../events/bus"
import type { ConfigFileUpdate } from "./schema" import type { ConfigFile } from "./schema"
import { Logger } from "../logger" import { Logger } from "../logger"
export class BinaryRegistry { export class BinaryRegistry {
@@ -39,17 +39,15 @@ export class BinaryRegistry {
} }
const config = this.configStore.get() const config = this.configStore.get()
const deduped = config.opencodeBinaries.filter((binary) => binary.path !== request.path) const nextConfig = this.cloneConfig(config)
const deduped = nextConfig.opencodeBinaries.filter((binary) => binary.path !== request.path)
const update: ConfigFileUpdate = { nextConfig.opencodeBinaries = [entry, ...deduped]
opencodeBinaries: [entry, ...deduped],
}
if (request.makeDefault) { if (request.makeDefault) {
update.preferences = { lastUsedBinary: request.path } nextConfig.preferences.lastUsedBinary = request.path
} }
this.configStore.update(update) this.configStore.replace(nextConfig)
const record = this.getById(request.path) const record = this.getById(request.path)
this.emitChange() this.emitChange()
return record return record
@@ -58,19 +56,16 @@ export class BinaryRegistry {
update(id: string, updates: BinaryUpdateRequest): BinaryRecord { update(id: string, updates: BinaryUpdateRequest): BinaryRecord {
this.logger.debug({ id }, "Updating OpenCode binary") this.logger.debug({ id }, "Updating OpenCode binary")
const config = this.configStore.get() const config = this.configStore.get()
const updatedEntries = config.opencodeBinaries.map((binary) => const nextConfig = this.cloneConfig(config)
nextConfig.opencodeBinaries = nextConfig.opencodeBinaries.map((binary) =>
binary.path === id ? { ...binary, label: updates.label ?? binary.label } : binary, binary.path === id ? { ...binary, label: updates.label ?? binary.label } : binary,
) )
const update: ConfigFileUpdate = {
opencodeBinaries: updatedEntries,
}
if (updates.makeDefault) { if (updates.makeDefault) {
update.preferences = { lastUsedBinary: id } nextConfig.preferences.lastUsedBinary = id
} }
this.configStore.update(update) this.configStore.replace(nextConfig)
const record = this.getById(id) const record = this.getById(id)
this.emitChange() this.emitChange()
return record return record
@@ -79,14 +74,15 @@ export class BinaryRegistry {
remove(id: string) { remove(id: string) {
this.logger.debug({ id }, "Removing OpenCode binary") this.logger.debug({ id }, "Removing OpenCode binary")
const config = this.configStore.get() const config = this.configStore.get()
const remaining = config.opencodeBinaries.filter((binary) => binary.path !== id) const nextConfig = this.cloneConfig(config)
const update: ConfigFileUpdate = { opencodeBinaries: remaining } const remaining = nextConfig.opencodeBinaries.filter((binary) => binary.path !== id)
nextConfig.opencodeBinaries = remaining
if (config.preferences.lastUsedBinary === id) { if (nextConfig.preferences.lastUsedBinary === id) {
update.preferences = { lastUsedBinary: remaining[0]?.path } nextConfig.preferences.lastUsedBinary = remaining[0]?.path
} }
this.configStore.update(update) this.configStore.replace(nextConfig)
this.emitChange() this.emitChange()
} }
@@ -100,7 +96,12 @@ export class BinaryRegistry {
}) })
} }
private cloneConfig(config: ConfigFile): ConfigFile {
return JSON.parse(JSON.stringify(config)) as ConfigFile
}
private mapRecords(): BinaryRecord[] { private mapRecords(): BinaryRecord[] {
const config = this.configStore.get() const config = this.configStore.get()
const configuredBinaries = config.opencodeBinaries.map<BinaryRecord>((binary) => ({ const configuredBinaries = config.opencodeBinaries.map<BinaryRecord>((binary) => ({
id: binary.path, id: binary.path,

View File

@@ -13,23 +13,11 @@ const PreferencesSchema = z.object({
lastUsedBinary: z.string().optional(), lastUsedBinary: z.string().optional(),
environmentVariables: z.record(z.string()).default({}), environmentVariables: z.record(z.string()).default({}),
modelRecents: z.array(ModelPreferenceSchema).default([]), modelRecents: z.array(ModelPreferenceSchema).default([]),
agentModelSelections: AgentModelSelectionsSchema.default({}),
diffViewMode: z.enum(["split", "unified"]).default("split"), diffViewMode: z.enum(["split", "unified"]).default("split"),
toolOutputExpansion: z.enum(["expanded", "collapsed"]).default("expanded"), toolOutputExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
diagnosticsExpansion: z.enum(["expanded", "collapsed"]).default("expanded"), diagnosticsExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
}) })
const PreferencesUpdateSchema = z.object({
showThinkingBlocks: z.boolean().optional(),
lastUsedBinary: z.string().optional(),
environmentVariables: z.record(z.string()).optional(),
modelRecents: z.array(ModelPreferenceSchema).optional(),
agentModelSelections: AgentModelSelectionsSchema.optional(),
diffViewMode: z.enum(["split", "unified"]).optional(),
toolOutputExpansion: z.enum(["expanded", "collapsed"]).optional(),
diagnosticsExpansion: z.enum(["expanded", "collapsed"]).optional(),
})
const RecentFolderSchema = z.object({ const RecentFolderSchema = z.object({
path: z.string(), path: z.string(),
lastAccessed: z.number().nonnegative(), lastAccessed: z.number().nonnegative(),
@@ -49,13 +37,6 @@ const ConfigFileSchema = z.object({
theme: z.enum(["light", "dark", "system"]).optional(), theme: z.enum(["light", "dark", "system"]).optional(),
}) })
const ConfigFileUpdateSchema = z.object({
preferences: PreferencesUpdateSchema.optional(),
recentFolders: z.array(RecentFolderSchema).optional(),
opencodeBinaries: z.array(OpenCodeBinarySchema).optional(),
theme: z.enum(["light", "dark", "system"]).optional(),
})
const DEFAULT_CONFIG = ConfigFileSchema.parse({}) const DEFAULT_CONFIG = ConfigFileSchema.parse({})
export { export {
@@ -66,7 +47,6 @@ export {
RecentFolderSchema, RecentFolderSchema,
OpenCodeBinarySchema, OpenCodeBinarySchema,
ConfigFileSchema, ConfigFileSchema,
ConfigFileUpdateSchema,
DEFAULT_CONFIG, DEFAULT_CONFIG,
} }
@@ -77,4 +57,3 @@ export type Preferences = z.infer<typeof PreferencesSchema>
export type RecentFolder = z.infer<typeof RecentFolderSchema> export type RecentFolder = z.infer<typeof RecentFolderSchema>
export type OpenCodeBinary = z.infer<typeof OpenCodeBinarySchema> export type OpenCodeBinary = z.infer<typeof OpenCodeBinarySchema>
export type ConfigFile = z.infer<typeof ConfigFileSchema> export type ConfigFile = z.infer<typeof ConfigFileSchema>
export type ConfigFileUpdate = z.infer<typeof ConfigFileUpdateSchema>

View File

@@ -2,14 +2,7 @@ import fs from "fs"
import path from "path" import path from "path"
import { EventBus } from "../events/bus" import { EventBus } from "../events/bus"
import { Logger } from "../logger" import { Logger } from "../logger"
import { import { ConfigFile, ConfigFileSchema, DEFAULT_CONFIG } from "./schema"
AgentModelSelections,
ConfigFile,
ConfigFileUpdate,
ConfigFileSchema,
ConfigFileUpdateSchema,
DEFAULT_CONFIG,
} from "./schema"
export class ConfigStore { export class ConfigStore {
private cache: ConfigFile = DEFAULT_CONFIG private cache: ConfigFile = DEFAULT_CONFIG
@@ -50,54 +43,18 @@ export class ConfigStore {
return this.load() return this.load()
} }
update(partial: ConfigFile | ConfigFileUpdate) { replace(config: ConfigFile) {
const safePartial = const validated = ConfigFileSchema.parse(config)
"recentFolders" in partial && "opencodeBinaries" in partial this.commit(validated)
? ConfigFileSchema.parse(partial) }
: ConfigFileUpdateSchema.parse(partial ?? {})
const merged = this.mergeConfig(this.load(), safePartial) private commit(next: ConfigFile) {
this.cache = ConfigFileSchema.parse(merged) this.cache = next
this.loaded = true
this.persist() this.persist()
this.eventBus?.publish({ type: "config.appChanged", config: this.cache }) this.eventBus?.publish({ type: "config.appChanged", config: this.cache })
this.logger.debug("Config updated") this.logger.info("Config updated")
} this.logger.debug({ config: this.cache }, "Config payload")
private mergeConfig(current: ConfigFile, partial: ConfigFile | ConfigFileUpdate): ConfigFile {
const mergedPreferences = {
...current.preferences,
...partial.preferences,
environmentVariables: {
...current.preferences.environmentVariables,
...(partial.preferences?.environmentVariables ?? {}),
},
agentModelSelections: this.mergeAgentSelections(
current.preferences.agentModelSelections,
partial.preferences?.agentModelSelections,
),
}
return {
...current,
...partial,
preferences: mergedPreferences,
recentFolders: partial.recentFolders ?? current.recentFolders,
opencodeBinaries: partial.opencodeBinaries ?? current.opencodeBinaries,
}
}
private mergeAgentSelections(base: AgentModelSelections, update?: AgentModelSelections) {
if (!update) {
return base
}
const result: AgentModelSelections = { ...base }
for (const [instanceId, agentMap] of Object.entries(update)) {
result[instanceId] = {
...(base[instanceId] ?? {}),
...agentMap,
}
}
return result
} }
private persist() { private persist() {

View File

@@ -8,7 +8,9 @@ export class EventBus extends EventEmitter {
} }
publish(event: WorkspaceEventPayload): boolean { publish(event: WorkspaceEventPayload): boolean {
this.logger?.debug({ event }, "Publishing workspace event") if (event.type !== "instance.event" && event.type !== "instance.eventStatus") {
this.logger?.debug({ event }, "Publishing workspace event")
}
return super.emit(event.type, event) return super.emit(event.type, event)
} }
@@ -21,6 +23,9 @@ export class EventBus extends EventEmitter {
this.on("workspace.log", handler) this.on("workspace.log", handler)
this.on("config.appChanged", handler) this.on("config.appChanged", handler)
this.on("config.binariesChanged", handler) this.on("config.binariesChanged", handler)
this.on("instance.dataChanged", handler)
this.on("instance.event", handler)
this.on("instance.eventStatus", handler)
return () => { return () => {
this.off("workspace.created", handler) this.off("workspace.created", handler)
this.off("workspace.started", handler) this.off("workspace.started", handler)
@@ -29,6 +34,9 @@ export class EventBus extends EventEmitter {
this.off("workspace.log", handler) this.off("workspace.log", handler)
this.off("config.appChanged", handler) this.off("config.appChanged", handler)
this.off("config.binariesChanged", handler) this.off("config.binariesChanged", handler)
this.off("instance.dataChanged", handler)
this.off("instance.event", handler)
this.off("instance.eventStatus", 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,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

@@ -14,9 +14,12 @@ import { FileSystemBrowser } from "./filesystem/browser"
import { EventBus } from "./events/bus" import { EventBus } from "./events/bus"
import { ServerMeta } from "./api-types" import { ServerMeta } from "./api-types"
import { InstanceStore } from "./storage/instance-store" import { InstanceStore } from "./storage/instance-store"
import { InstanceEventBridge } from "./workspaces/instance-events"
import { createLogger } from "./logger" import { createLogger } from "./logger"
import { launchInBrowser } from "./launcher"
const require = createRequire(import.meta.url) const require = createRequire(import.meta.url)
const packageJson = require("../package.json") as { version: string } const packageJson = require("../package.json") as { version: string }
const __filename = fileURLToPath(import.meta.url) const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename) const __dirname = path.dirname(__filename)
@@ -32,6 +35,7 @@ interface CliOptions {
logDestination?: string logDestination?: string
uiStaticDir: string uiStaticDir: string
uiDevServer?: string uiDevServer?: string
launch: boolean
} }
const DEFAULT_PORT = 9898 const DEFAULT_PORT = 9898
@@ -40,7 +44,7 @@ const DEFAULT_CONFIG_PATH = "~/.config/codenomad/config.json"
function parseCliOptions(argv: string[]): CliOptions { function parseCliOptions(argv: string[]): CliOptions {
const program = new Command() const program = new Command()
.name("codenomad-cli") .name("codenomad")
.description("CodeNomad CLI server") .description("CodeNomad CLI server")
.version(packageJson.version, "-v, --version", "Show the CLI version") .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("--host <host>", "Host interface to bind").env("CLI_HOST").default(DEFAULT_HOST))
@@ -57,6 +61,7 @@ function parseCliOptions(argv: string[]): CliOptions {
new Option("--ui-dir <path>", "Directory containing the built UI bundle").env("CLI_UI_DIR").default(DEFAULT_UI_STATIC_DIR), 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("--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" }) program.parse(argv, { from: "user" })
const parsed = program.opts<{ const parsed = program.opts<{
@@ -70,6 +75,7 @@ function parseCliOptions(argv: string[]): CliOptions {
logDestination?: string logDestination?: string
uiDir: string uiDir: string
uiDevServer?: string uiDevServer?: string
launch?: boolean
}>() }>()
const resolvedRoot = parsed.workspaceRoot ?? parsed.root ?? process.cwd() const resolvedRoot = parsed.workspaceRoot ?? parsed.root ?? process.cwd()
@@ -84,13 +90,14 @@ function parseCliOptions(argv: string[]): CliOptions {
logDestination: parsed.logDestination, logDestination: parsed.logDestination,
uiStaticDir: parsed.uiDir, uiStaticDir: parsed.uiDir,
uiDevServer: parsed.uiDevServer, uiDevServer: parsed.uiDevServer,
launch: Boolean(parsed.launch),
} }
} }
function parsePort(input: string): number { function parsePort(input: string): number {
const value = Number(input) const value = Number(input)
if (!Number.isInteger(value) || value < 1 || value > 65535) { if (!Number.isInteger(value) || value < 0 || value > 65535) {
throw new InvalidArgumentError("Port must be an integer between 1 and 65535") throw new InvalidArgumentError("Port must be an integer between 0 and 65535")
} }
return value return value
} }
@@ -116,6 +123,11 @@ async function main() {
}) })
const fileSystemBrowser = new FileSystemBrowser({ rootDir: options.rootDir, unrestricted: options.unrestrictedRoot }) const fileSystemBrowser = new FileSystemBrowser({ rootDir: options.rootDir, unrestricted: options.unrestrictedRoot })
const instanceStore = new InstanceStore() const instanceStore = new InstanceStore()
const instanceEventBridge = new InstanceEventBridge({
workspaceManager,
eventBus,
logger: logger.child({ component: "instance-events" }),
})
const serverMeta: ServerMeta = { const serverMeta: ServerMeta = {
httpBaseUrl: `http://${options.host}:${options.port}`, httpBaseUrl: `http://${options.host}:${options.port}`,
@@ -139,11 +151,13 @@ async function main() {
logger, 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}`)
await server.start() if (options.launch) {
logger.info({ port: options.port, host: options.host }, "HTTP server listening") await launchInBrowser(startInfo.url, logger.child({ component: "launcher" }))
const displayHost = options.host === "127.0.0.1" || options.host === "0.0.0.0" ? "localhost" : options.host }
console.log(`CodeNomad Server is ready at http://${displayHost}:${options.port}`)
let shuttingDown = false let shuttingDown = false
@@ -162,6 +176,7 @@ async function main() {
} }
try { try {
instanceEventBridge.shutdown()
await workspaceManager.shutdown() await workspaceManager.shutdown()
logger.info("Workspace manager shutdown complete") logger.info("Workspace manager shutdown complete")
} catch (error) { } catch (error) {

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

@@ -1,7 +1,7 @@
import Fastify, { type FastifyInstance, type FastifyReply, type FastifyRequest } from "fastify" import Fastify, { type FastifyInstance, type FastifyReply, type FastifyRequest } from "fastify"
import cors from "@fastify/cors" import cors from "@fastify/cors"
import fastifyStatic from "@fastify/static" import fastifyStatic from "@fastify/static"
import replyFrom, { type FastifyReplyFromOptions } from "@fastify/reply-from" import replyFrom from "@fastify/reply-from"
import fs from "fs" import fs from "fs"
import path from "path" import path from "path"
import { fetch } from "undici" import { fetch } from "undici"
@@ -36,6 +36,11 @@ interface HttpServerDeps {
logger: Logger logger: Logger
} }
interface HttpServerStartResult {
port: number
url: string
displayHost: string
}
export function createHttpServer(deps: HttpServerDeps) { export function createHttpServer(deps: HttpServerDeps) {
const app = Fastify({ logger: false }) const app = Fastify({ logger: false })
@@ -60,6 +65,12 @@ export function createHttpServer(deps: HttpServerDeps) {
app.register(replyFrom, { app.register(replyFrom, {
contentTypesToEncode: [], contentTypesToEncode: [],
undici: {
connections: 16,
pipelining: 1,
bodyTimeout: 0,
headersTimeout: 0,
},
}) })
registerWorkspaceRoutes(app, { workspaceManager: deps.workspaceManager }) registerWorkspaceRoutes(app, { workspaceManager: deps.workspaceManager })
@@ -67,9 +78,14 @@ export function createHttpServer(deps: HttpServerDeps) {
registerFilesystemRoutes(app, { fileSystemBrowser: deps.fileSystemBrowser }) registerFilesystemRoutes(app, { fileSystemBrowser: deps.fileSystemBrowser })
registerMetaRoutes(app, { serverMeta: deps.serverMeta }) registerMetaRoutes(app, { serverMeta: deps.serverMeta })
registerEventRoutes(app, { eventBus: deps.eventBus, registerClient: registerSseClient }) registerEventRoutes(app, { eventBus: deps.eventBus, registerClient: registerSseClient })
registerStorageRoutes(app, { instanceStore: deps.instanceStore }) registerStorageRoutes(app, {
instanceStore: deps.instanceStore,
eventBus: deps.eventBus,
workspaceManager: deps.workspaceManager,
})
registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger }) registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger })
if (deps.uiDevServerUrl) { if (deps.uiDevServerUrl) {
setupDevProxy(app, deps.uiDevServerUrl) setupDevProxy(app, deps.uiDevServerUrl)
} else { } else {
@@ -78,7 +94,34 @@ export function createHttpServer(deps: HttpServerDeps) {
return { return {
instance: app, instance: app,
start: () => app.listen({ port: deps.port, host: deps.host }), 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: () => { stop: () => {
closeSseClients() closeSseClients()
return app.close() return app.close()

View File

@@ -2,7 +2,7 @@ import { FastifyInstance } from "fastify"
import { z } from "zod" import { z } from "zod"
import { ConfigStore } from "../../config/store" import { ConfigStore } from "../../config/store"
import { BinaryRegistry } from "../../config/binaries" import { BinaryRegistry } from "../../config/binaries"
import { ConfigFileSchema, ConfigFileUpdateSchema } from "../../config/schema" import { ConfigFileSchema } from "../../config/schema"
interface RouteDeps { interface RouteDeps {
configStore: ConfigStore configStore: ConfigStore
@@ -29,13 +29,7 @@ export function registerConfigRoutes(app: FastifyInstance, deps: RouteDeps) {
app.put("/api/config/app", async (request) => { app.put("/api/config/app", async (request) => {
const body = ConfigFileSchema.parse(request.body ?? {}) const body = ConfigFileSchema.parse(request.body ?? {})
deps.configStore.update(body) deps.configStore.replace(body)
return deps.configStore.get()
})
app.patch("/api/config/app", async (request) => {
const body = ConfigFileUpdateSchema.parse(request.body ?? {})
deps.configStore.update(body)
return deps.configStore.get() return deps.configStore.get()
}) })

View File

@@ -1,19 +1,37 @@
import { FastifyInstance } from "fastify" import { FastifyInstance } from "fastify"
import { z } from "zod" import { z } from "zod"
import { InstanceStore } from "../../storage/instance-store" 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 { interface RouteDeps {
instanceStore: InstanceStore instanceStore: InstanceStore
eventBus: EventBus
workspaceManager: WorkspaceManager
} }
const InstanceDataSchema = z.object({ const InstanceDataSchema = z.object({
messageHistory: z.array(z.string()).default([]), 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) { 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) => { app.get<{ Params: { id: string } }>("/api/storage/instances/:id", async (request, reply) => {
try { try {
const data = await deps.instanceStore.read(request.params.id) const storageId = resolveStorageKey(request.params.id)
const data = await deps.instanceStore.read(storageId)
return data return data
} catch (error) { } catch (error) {
reply.code(500) reply.code(500)
@@ -24,7 +42,9 @@ export function registerStorageRoutes(app: FastifyInstance, deps: RouteDeps) {
app.put<{ Params: { id: string } }>("/api/storage/instances/:id", async (request, reply) => { app.put<{ Params: { id: string } }>("/api/storage/instances/:id", async (request, reply) => {
try { try {
const body = InstanceDataSchema.parse(request.body ?? {}) const body = InstanceDataSchema.parse(request.body ?? {})
await deps.instanceStore.write(request.params.id, 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) reply.code(204)
} catch (error) { } catch (error) {
reply.code(400) reply.code(400)
@@ -34,7 +54,9 @@ export function registerStorageRoutes(app: FastifyInstance, deps: RouteDeps) {
app.delete<{ Params: { id: string } }>("/api/storage/instances/:id", async (request, reply) => { app.delete<{ Params: { id: string } }>("/api/storage/instances/:id", async (request, reply) => {
try { try {
await deps.instanceStore.delete(request.params.id) 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) reply.code(204)
} catch (error) { } catch (error) {
reply.code(500) reply.code(500)

View File

@@ -19,6 +19,16 @@ const WorkspaceFileContentQuerySchema = z.object({
path: z.string(), 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) { export function registerWorkspaceRoutes(app: FastifyInstance, deps: RouteDeps) {
app.get("/api/workspaces", async () => { app.get("/api/workspaces", async () => {
return deps.workspaceManager.list() return deps.workspaceManager.list()
@@ -57,6 +67,22 @@ export function registerWorkspaceRoutes(app: FastifyInstance, deps: RouteDeps) {
} }
}) })
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<{ app.get<{
Params: { id: string } Params: { id: string }
Querystring: { path?: string } Querystring: { path?: string }
@@ -70,6 +96,7 @@ export function registerWorkspaceRoutes(app: FastifyInstance, deps: RouteDeps) {
}) })
} }
function handleWorkspaceError(error: unknown, reply: FastifyReply) { function handleWorkspaceError(error: unknown, reply: FastifyReply) {
if (error instanceof Error && error.message === "Workspace not found") { if (error instanceof Error && error.message === "Workspace not found") {
reply.code(404) reply.code(404)

View File

@@ -6,6 +6,7 @@ import type { InstanceData } from "../api-types"
const DEFAULT_INSTANCE_DATA: InstanceData = { const DEFAULT_INSTANCE_DATA: InstanceData = {
messageHistory: [], messageHistory: [],
agentModelSelections: {},
} }
export class InstanceStore { export class InstanceStore {

View File

@@ -0,0 +1,190 @@
import { Agent, fetch } from "undici"
import { Agent as UndiciAgent } from "undici"
import { EventBus } from "../events/bus"
import { Logger } from "../logger"
import { WorkspaceManager } from "./manager"
import { InstanceStreamEvent, InstanceStreamStatus } from "../api-types"
const INSTANCE_HOST = "127.0.0.1"
const STREAM_AGENT = new UndiciAgent({ bodyTimeout: 0, headersTimeout: 0 })
const RECONNECT_DELAY_MS = 1000
interface InstanceEventBridgeOptions {
workspaceManager: WorkspaceManager
eventBus: EventBus
logger: Logger
}
interface ActiveStream {
controller: AbortController
task: Promise<void>
}
export class InstanceEventBridge {
private readonly streams = new Map<string, ActiveStream>()
constructor(private readonly options: InstanceEventBridgeOptions) {
const bus = this.options.eventBus
bus.on("workspace.started", (event) => this.startStream(event.workspace.id))
bus.on("workspace.stopped", (event) => this.stopStream(event.workspaceId, "workspace stopped"))
bus.on("workspace.error", (event) => this.stopStream(event.workspace.id, "workspace error"))
}
shutdown() {
for (const [id, active] of this.streams) {
active.controller.abort()
this.publishStatus(id, "disconnected")
}
this.streams.clear()
}
private startStream(workspaceId: string) {
if (this.streams.has(workspaceId)) {
return
}
const controller = new AbortController()
const task = this.runStream(workspaceId, controller.signal)
.catch((error) => {
if (!controller.signal.aborted) {
this.options.logger.warn({ workspaceId, err: error }, "Instance event stream failed")
this.publishStatus(workspaceId, "error", error instanceof Error ? error.message : String(error))
}
})
.finally(() => {
const active = this.streams.get(workspaceId)
if (active?.controller === controller) {
this.streams.delete(workspaceId)
}
})
this.streams.set(workspaceId, { controller, task })
}
private stopStream(workspaceId: string, reason?: string) {
const active = this.streams.get(workspaceId)
if (!active) {
return
}
active.controller.abort()
this.streams.delete(workspaceId)
this.publishStatus(workspaceId, "disconnected", reason)
}
private async runStream(workspaceId: string, signal: AbortSignal) {
while (!signal.aborted) {
const port = this.options.workspaceManager.getInstancePort(workspaceId)
if (!port) {
await this.delay(RECONNECT_DELAY_MS, signal)
continue
}
this.publishStatus(workspaceId, "connecting")
try {
await this.consumeStream(workspaceId, port, signal)
} catch (error) {
if (signal.aborted) {
break
}
this.options.logger.warn({ workspaceId, err: error }, "Instance event stream disconnected")
this.publishStatus(workspaceId, "error", error instanceof Error ? error.message : String(error))
await this.delay(RECONNECT_DELAY_MS, signal)
}
}
}
private async consumeStream(workspaceId: string, port: number, signal: AbortSignal) {
const url = `http://${INSTANCE_HOST}:${port}/event`
const response = await fetch(url, {
headers: { Accept: "text/event-stream" },
signal,
dispatcher: STREAM_AGENT,
})
if (!response.ok || !response.body) {
throw new Error(`Instance event stream unavailable (${response.status})`)
}
this.publishStatus(workspaceId, "connected")
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ""
while (!signal.aborted) {
const { done, value } = await reader.read()
if (done || !value) {
break
}
buffer += decoder.decode(value, { stream: true })
buffer = this.flushEvents(buffer, workspaceId)
}
}
private flushEvents(buffer: string, workspaceId: string) {
let separatorIndex = buffer.indexOf("\n\n")
while (separatorIndex >= 0) {
const chunk = buffer.slice(0, separatorIndex)
buffer = buffer.slice(separatorIndex + 2)
this.processChunk(chunk, workspaceId)
separatorIndex = buffer.indexOf("\n\n")
}
return buffer
}
private processChunk(chunk: string, workspaceId: string) {
const lines = chunk.split(/\r?\n/)
const dataLines: string[] = []
for (const line of lines) {
if (line.startsWith(":")) {
continue
}
if (line.startsWith("data:")) {
dataLines.push(line.slice(5).trimStart())
}
}
if (dataLines.length === 0) {
return
}
const payload = dataLines.join("\n").trim()
if (!payload) {
return
}
try {
const event = JSON.parse(payload) as InstanceStreamEvent
this.options.eventBus.publish({ type: "instance.event", instanceId: workspaceId, event })
} catch (error) {
this.options.logger.warn({ workspaceId, chunk: payload, err: error }, "Failed to parse instance SSE payload")
}
}
private publishStatus(instanceId: string, status: InstanceStreamStatus, reason?: string) {
this.options.eventBus.publish({ type: "instance.eventStatus", instanceId, status, reason })
}
private delay(duration: number, signal: AbortSignal) {
if (duration <= 0) {
return Promise.resolve()
}
return new Promise<void>((resolve) => {
const timeout = setTimeout(() => {
signal.removeEventListener("abort", onAbort)
resolve()
}, duration)
const onAbort = () => {
clearTimeout(timeout)
resolve()
}
signal.addEventListener("abort", onAbort, { once: true })
})
}
}

View File

@@ -1,8 +1,11 @@
import path from "path" import path from "path"
import { spawnSync } from "child_process"
import { EventBus } from "../events/bus" import { EventBus } from "../events/bus"
import { ConfigStore } from "../config/store" import { ConfigStore } from "../config/store"
import { BinaryRegistry } from "../config/binaries" import { BinaryRegistry } from "../config/binaries"
import { FileSystemBrowser } from "../filesystem/browser" 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 { WorkspaceDescriptor, WorkspaceFileResponse, FileSystemEntry } from "../api-types"
import { WorkspaceRuntime } from "./runtime" import { WorkspaceRuntime } from "./runtime"
import { Logger } from "../logger" import { Logger } from "../logger"
@@ -43,6 +46,11 @@ export class WorkspaceManager {
return browser.list(relativePath) 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 { readFile(workspaceId: string, relativePath: string): WorkspaceFileResponse {
const workspace = this.requireWorkspace(workspaceId) const workspace = this.requireWorkspace(workspaceId)
const browser = new FileSystemBrowser({ rootDir: workspace.path }) const browser = new FileSystemBrowser({ rootDir: workspace.path })
@@ -55,28 +63,38 @@ export class WorkspaceManager {
} }
async create(folder: string, name?: string): Promise<WorkspaceDescriptor> { async create(folder: string, name?: string): Promise<WorkspaceDescriptor> {
const id = `${Date.now().toString(36)}` const id = `${Date.now().toString(36)}`
const binary = this.options.binaryRegistry.resolveDefault() const binary = this.options.binaryRegistry.resolveDefault()
const resolvedBinaryPath = this.resolveBinaryPath(binary.path)
const workspacePath = path.isAbsolute(folder) ? folder : path.resolve(this.options.rootDir, folder) 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") this.options.logger.info({ workspaceId: id, folder: workspacePath, binary: resolvedBinaryPath }, "Creating workspace")
const proxyPath = `/workspaces/${id}/instance` const proxyPath = `/workspaces/${id}/instance`
const descriptor: WorkspaceRecord = { const descriptor: WorkspaceRecord = {
id, id,
path: workspacePath, path: workspacePath,
name, name,
status: "starting", status: "starting",
proxyPath, proxyPath,
binaryId: binary.id, binaryId: resolvedBinaryPath,
binaryLabel: binary.label, binaryLabel: binary.label,
binaryVersion: binary.version, binaryVersion: binary.version,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
} }
if (!descriptor.binaryVersion) {
descriptor.binaryVersion = this.detectBinaryVersion(resolvedBinaryPath)
}
this.workspaces.set(id, descriptor) this.workspaces.set(id, descriptor)
this.options.eventBus.publish({ type: "workspace.created", workspace: descriptor }) this.options.eventBus.publish({ type: "workspace.created", workspace: descriptor })
const environment = this.options.configStore.get().preferences.environmentVariables ?? {} const environment = this.options.configStore.get().preferences.environmentVariables ?? {}
@@ -85,7 +103,7 @@ export class WorkspaceManager {
const { pid, port } = await this.runtime.launch({ const { pid, port } = await this.runtime.launch({
workspaceId: id, workspaceId: id,
folder: workspacePath, folder: workspacePath,
binaryPath: binary.path, binaryPath: resolvedBinaryPath,
environment, environment,
onExit: (info) => this.handleProcessExit(info.workspaceId, info), onExit: (info) => this.handleProcessExit(info.workspaceId, info),
}) })
@@ -120,6 +138,7 @@ export class WorkspaceManager {
} }
this.workspaces.delete(id) this.workspaces.delete(id)
clearWorkspaceSearchCache(workspace.path)
if (!wasRunning) { if (!wasRunning) {
this.options.eventBus.publish({ type: "workspace.stopped", workspaceId: id }) this.options.eventBus.publish({ type: "workspace.stopped", workspaceId: id })
} }
@@ -150,6 +169,70 @@ export class WorkspaceManager {
return workspace return workspace
} }
private resolveBinaryPath(identifier: string): string {
if (!identifier) {
return identifier
}
const looksLikePath = identifier.includes("/") || identifier.includes("\\") || identifier.startsWith(".")
if (path.isAbsolute(identifier) || looksLikePath) {
return identifier
}
const locator = process.platform === "win32" ? "where" : "which"
try {
const result = spawnSync(locator, [identifier], { encoding: "utf8" })
if (result.status === 0 && result.stdout) {
const resolved = result.stdout
.split(/\r?\n/)
.map((line) => line.trim())
.find((line) => line.length > 0)
if (resolved) {
this.options.logger.debug({ identifier, resolved }, "Resolved binary path from system PATH")
return resolved
}
} else if (result.error) {
this.options.logger.warn({ identifier, err: result.error }, "Failed to resolve binary path via locator command")
}
} catch (error) {
this.options.logger.warn({ identifier, err: error }, "Failed to resolve binary path from system PATH")
}
return identifier
}
private detectBinaryVersion(resolvedPath: string): string | undefined {
if (!resolvedPath) {
return undefined
}
try {
const result = spawnSync(resolvedPath, ["--version"], { encoding: "utf8" })
if (result.status === 0 && result.stdout) {
const line = result.stdout.split(/\r?\n/).find((entry) => entry.trim().length > 0)
if (line) {
const normalized = line.trim()
const versionMatch = normalized.match(/([0-9]+\.[0-9]+\.[0-9A-Za-z.-]+)/)
if (versionMatch) {
const version = versionMatch[1]
this.options.logger.debug({ binary: resolvedPath, version }, "Detected binary version")
return version
}
this.options.logger.debug({ binary: resolvedPath, reported: normalized }, "Binary reported version string")
return normalized
}
} else if (result.error) {
this.options.logger.warn({ binary: resolvedPath, err: result.error }, "Failed to read binary version")
}
} catch (error) {
this.options.logger.warn({ binary: resolvedPath, err: error }, "Failed to detect binary version")
}
return undefined
}
private handleProcessExit(workspaceId: string, info: { code: number | null; requested: boolean }) { private handleProcessExit(workspaceId: string, info: { code: number | null; requested: boolean }) {
const workspace = this.workspaces.get(workspaceId) const workspace = this.workspaces.get(workspaceId)
if (!workspace) return if (!workspace) return

View File

@@ -37,7 +37,10 @@ export class WorkspaceRuntime {
const env = { ...process.env, ...(options.environment ?? {}) } const env = { ...process.env, ...(options.environment ?? {}) }
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
this.logger.info({ workspaceId: options.workspaceId, folder: options.folder }, "Launching OpenCode process") this.logger.info(
{ workspaceId: options.workspaceId, folder: options.folder, binary: options.binaryPath },
"Launching OpenCode process",
)
const child = spawn(options.binaryPath, args, { const child = spawn(options.binaryPath, args, {
cwd: options.folder, cwd: options.folder,
env, env,

7
packages/tauri-app/.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
src-tauri/target
src-tauri/Cargo.lock
src-tauri/resources/
target
node_modules
dist
.DS_Store

5282
packages/tauri-app/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,3 @@
[workspace]
members = ["src-tauri"]
resolver = "2"

View File

@@ -0,0 +1,17 @@
{
"name": "@codenomad/tauri-app",
"version": "0.2.3",
"private": true,
"scripts": {
"dev": "tauri dev",
"dev:ui": "npm run dev --workspace @codenomad/ui",
"dev:prep": "node ./scripts/dev-prep.js",
"dev:bootstrap": "npm run dev:prep && npm run dev:ui",
"prebuild": "node ./scripts/prebuild.js",
"bundle:server": "npm --workspace @neuralnomads/codenomad run build && npm run prebuild",
"build": "tauri build"
},
"devDependencies": {
"@tauri-apps/cli": "^2.9.4"
}
}

View File

@@ -0,0 +1,46 @@
#!/usr/bin/env node
const fs = require("fs")
const path = require("path")
const { execSync } = require("child_process")
const root = path.resolve(__dirname, "..")
const workspaceRoot = path.resolve(root, "..", "..")
const uiRoot = path.resolve(root, "..", "ui")
const uiDist = path.resolve(uiRoot, "src", "renderer", "dist")
const uiLoadingDest = path.resolve(root, "src-tauri", "resources", "ui-loading")
function ensureUiBuild() {
const loadingHtml = path.join(uiDist, "loading.html")
if (fs.existsSync(loadingHtml)) {
return
}
console.log("[dev-prep] UI loader build missing; running workspace build…")
execSync("npm --workspace @codenomad/ui run build", {
cwd: workspaceRoot,
stdio: "inherit",
})
if (!fs.existsSync(loadingHtml)) {
throw new Error("[dev-prep] failed to produce loading.html after UI build")
}
}
function copyUiLoadingAssets() {
const loadingSource = path.join(uiDist, "loading.html")
const assetsSource = path.join(uiDist, "assets")
fs.rmSync(uiLoadingDest, { recursive: true, force: true })
fs.mkdirSync(uiLoadingDest, { recursive: true })
fs.copyFileSync(loadingSource, path.join(uiLoadingDest, "loading.html"))
if (fs.existsSync(assetsSource)) {
fs.cpSync(assetsSource, path.join(uiLoadingDest, "assets"), { recursive: true })
}
console.log(`[dev-prep] copied loader bundle from ${uiDist}`)
}
ensureUiBuild()
copyUiLoadingAssets()

View File

@@ -0,0 +1,89 @@
#!/usr/bin/env node
const fs = require("fs")
const path = require("path")
const { execSync } = require("child_process")
const root = path.resolve(__dirname, "..")
const workspaceRoot = path.resolve(root, "..", "..")
const serverRoot = path.resolve(root, "..", "server")
const uiRoot = path.resolve(root, "..", "ui")
const uiDist = path.resolve(uiRoot, "src", "renderer", "dist")
const serverDest = path.resolve(root, "src-tauri", "resources", "server")
const uiLoadingDest = path.resolve(root, "src-tauri", "resources", "ui-loading")
const sources = ["dist", "public", "node_modules", "package.json"]
function ensureServerBuild() {
const distPath = path.join(serverRoot, "dist")
const publicPath = path.join(serverRoot, "public")
if (fs.existsSync(distPath) && fs.existsSync(publicPath)) {
return
}
console.log("[prebuild] server build missing; running workspace build...")
execSync("npm --workspace @neuralnomads/codenomad run build", {
cwd: workspaceRoot,
stdio: "inherit",
})
if (!fs.existsSync(distPath) || !fs.existsSync(publicPath)) {
throw new Error("[prebuild] server artifacts still missing after build")
}
}
function ensureUiBuild() {
const loadingHtml = path.join(uiDist, "loading.html")
if (fs.existsSync(loadingHtml)) {
return
}
console.log("[prebuild] ui build missing; running workspace build...")
execSync("npm --workspace @codenomad/ui run build", {
cwd: workspaceRoot,
stdio: "inherit",
})
if (!fs.existsSync(loadingHtml)) {
throw new Error("[prebuild] ui loading assets missing after build")
}
}
function copyServerArtifacts() {
fs.rmSync(serverDest, { recursive: true, force: true })
fs.mkdirSync(serverDest, { recursive: true })
for (const name of sources) {
const from = path.join(serverRoot, name)
const to = path.join(serverDest, name)
if (!fs.existsSync(from)) {
console.warn(`[prebuild] skipped missing ${from}`)
continue
}
fs.cpSync(from, to, { recursive: true })
console.log(`[prebuild] copied ${from} -> ${to}`)
}
}
function copyUiLoadingAssets() {
const loadingSource = path.join(uiDist, "loading.html")
const assetsSource = path.join(uiDist, "assets")
if (!fs.existsSync(loadingSource)) {
throw new Error("[prebuild] cannot find built loading.html")
}
fs.rmSync(uiLoadingDest, { recursive: true, force: true })
fs.mkdirSync(uiLoadingDest, { recursive: true })
fs.copyFileSync(loadingSource, path.join(uiLoadingDest, "loading.html"))
if (fs.existsSync(assetsSource)) {
fs.cpSync(assetsSource, path.join(uiLoadingDest, "assets"), { recursive: true })
}
console.log(`[prebuild] prepared UI loading assets from ${uiDist}`)
}
ensureServerBuild()
ensureUiBuild()
copyServerArtifacts()
copyUiLoadingAssets()

View File

@@ -0,0 +1,20 @@
[package]
name = "codenomad-tauri"
version = "0.1.0"
edition = "2021"
[build-dependencies]
tauri-build = { version = "2.5.2", features = [] }
[dependencies]
tauri = { version = "2.5.2", features = [ "devtools"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
regex = "1"
once_cell = "1"
parking_lot = "0.12"
thiserror = "1"
anyhow = "1"
which = "4"
libc = "0.2"
tauri-plugin-dialog = "2"

View File

@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

View File

@@ -0,0 +1,16 @@
{
"$schema": "https://schema.tauri.app/capabilities.json",
"identifier": "main-window-native-dialogs",
"description": "Grant the main window access to required core features and native dialog commands.",
"remote": {
"urls": [
"http://127.0.0.1:*",
"http://localhost:*"
]
},
"windows": ["main"],
"permissions": [
"core:default",
"dialog:allow-open"
]
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
{"main-window-native-dialogs":{"identifier":"main-window-native-dialogs","description":"Grant the main window access to required core features and native dialog commands.","remote":{"urls":["http://127.0.0.1:*","http://localhost:*"]},"local":true,"windows":["main"],"permissions":["core:default","dialog:allow-open"]}}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 422 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

View File

@@ -0,0 +1,636 @@
use parking_lot::Mutex;
use regex::Regex;
use serde::Serialize;
use serde_json::json;
use std::collections::VecDeque;
use std::ffi::OsStr;
use std::io::{BufRead, BufReader};
use std::path::PathBuf;
use std::process::{Child, Command, Stdio};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::thread;
use std::time::{Duration, Instant};
use tauri::{AppHandle, Emitter, Manager, Url};
fn log_line(message: &str) {
println!("[tauri-cli] {message}");
}
fn workspace_root() -> Option<PathBuf> {
std::env::current_dir().ok().and_then(|mut dir| {
for _ in 0..3 {
if let Some(parent) = dir.parent() {
dir = parent.to_path_buf();
}
}
Some(dir)
})
}
fn navigate_main(app: &AppHandle, url: &str) {
if let Some(win) = app.webview_windows().get("main") {
log_line(&format!("navigating main to {url}"));
if let Ok(parsed) = Url::parse(url) {
let _ = win.navigate(parsed);
} else {
log_line("failed to parse URL for navigation");
}
} else {
log_line("main window not found for navigation");
}
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum CliState {
Starting,
Ready,
Error,
Stopped,
}
#[derive(Debug, Clone, Serialize)]
pub struct CliStatus {
pub state: CliState,
pub pid: Option<u32>,
pub port: Option<u16>,
pub url: Option<String>,
pub error: Option<String>,
}
impl Default for CliStatus {
fn default() -> Self {
Self {
state: CliState::Stopped,
pid: None,
port: None,
url: None,
error: None,
}
}
}
#[derive(Debug, Clone)]
pub struct CliProcessManager {
status: Arc<Mutex<CliStatus>>,
child: Arc<Mutex<Option<Child>>>,
ready: Arc<AtomicBool>,
}
impl CliProcessManager {
pub fn new() -> Self {
Self {
status: Arc::new(Mutex::new(CliStatus::default())),
child: Arc::new(Mutex::new(None)),
ready: Arc::new(AtomicBool::new(false)),
}
}
pub fn start(&self, app: AppHandle, dev: bool) -> anyhow::Result<()> {
log_line(&format!("start requested (dev={dev})"));
self.stop()?;
self.ready.store(false, Ordering::SeqCst);
{
let mut status = self.status.lock();
status.state = CliState::Starting;
status.port = None;
status.url = None;
status.error = None;
status.pid = None;
}
Self::emit_status(&app, &self.status.lock());
let status_arc = self.status.clone();
let child_arc = self.child.clone();
let ready_flag = self.ready.clone();
thread::spawn(move || {
if let Err(err) = Self::spawn_cli(app.clone(), status_arc.clone(), child_arc, ready_flag, dev) {
log_line(&format!("cli spawn failed: {err}"));
let mut locked = status_arc.lock();
locked.state = CliState::Error;
locked.error = Some(err.to_string());
let snapshot = locked.clone();
drop(locked);
let _ = app.emit("cli:error", json!({"message": err.to_string()}));
let _ = app.emit("cli:status", snapshot);
}
});
Ok(())
}
pub fn stop(&self) -> anyhow::Result<()> {
let mut child_opt = self.child.lock();
if let Some(mut child) = child_opt.take() {
#[cfg(unix)]
unsafe {
libc::kill(child.id() as i32, libc::SIGTERM);
}
#[cfg(windows)]
{
let _ = child.kill();
}
let start = Instant::now();
loop {
match child.try_wait() {
Ok(Some(_)) => break,
Ok(None) => {
if start.elapsed() > Duration::from_secs(4) {
#[cfg(unix)]
unsafe {
libc::kill(child.id() as i32, libc::SIGKILL);
}
#[cfg(windows)]
{
let _ = child.kill();
}
break;
}
thread::sleep(Duration::from_millis(50));
}
Err(_) => break,
}
}
}
let mut status = self.status.lock();
status.state = CliState::Stopped;
status.pid = None;
status.port = None;
status.url = None;
status.error = None;
Ok(())
}
pub fn status(&self) -> CliStatus {
self.status.lock().clone()
}
fn spawn_cli(
app: AppHandle,
status: Arc<Mutex<CliStatus>>,
child_holder: Arc<Mutex<Option<Child>>>,
ready: Arc<AtomicBool>,
dev: bool,
) -> anyhow::Result<()> {
log_line("resolving CLI entry");
let resolution = CliEntry::resolve(&app, dev)?;
log_line(&format!(
"resolved CLI entry runner={:?} entry={}",
resolution.runner, resolution.entry
));
let args = resolution.build_args(dev);
log_line(&format!("CLI args: {:?}", args));
if dev {
log_line("development mode: will prefer tsx + source if present");
}
let cwd = workspace_root();
if let Some(ref c) = cwd {
log_line(&format!("using cwd={}", c.display()));
}
let command_info = if supports_user_shell() {
log_line("spawning via user shell");
ShellCommandType::UserShell(build_shell_command_string(&resolution, &args)?)
} else {
log_line("spawning directly with node");
ShellCommandType::Direct(DirectCommand {
program: resolution.node_binary.clone(),
args: resolution.runner_args(&args),
})
};
if !supports_user_shell() {
if which::which(&resolution.node_binary).is_err() {
return Err(anyhow::anyhow!("Node binary not found. Make sure Node.js is installed."));
}
}
let child = match &command_info {
ShellCommandType::UserShell(cmd) => {
log_line(&format!("spawn command: {} {:?}", cmd.shell, cmd.args));
let mut c = Command::new(&cmd.shell);
c.args(&cmd.args)
.env("ELECTRON_RUN_AS_NODE", "1")
.stdout(Stdio::piped())
.stderr(Stdio::piped());
if let Some(ref cwd) = cwd {
c.current_dir(cwd);
}
c.spawn()?
}
ShellCommandType::Direct(cmd) => {
log_line(&format!("spawn command: {} {:?}", cmd.program, cmd.args));
let mut c = Command::new(&cmd.program);
c.args(&cmd.args)
.env("ELECTRON_RUN_AS_NODE", "1")
.stdout(Stdio::piped())
.stderr(Stdio::piped());
if let Some(ref cwd) = cwd {
c.current_dir(cwd);
}
c.spawn()?
}
};
let pid = child.id();
log_line(&format!("spawned pid={pid}"));
{
let mut locked = status.lock();
locked.pid = Some(pid);
}
Self::emit_status(&app, &status.lock());
{
let mut holder = child_holder.lock();
*holder = Some(child);
}
let child_clone = child_holder.clone();
let status_clone = status.clone();
let app_clone = app.clone();
let ready_clone = ready.clone();
thread::spawn(move || {
let stdout = child_clone
.lock()
.as_mut()
.and_then(|c| c.stdout.take())
.map(BufReader::new);
let stderr = child_clone
.lock()
.as_mut()
.and_then(|c| c.stderr.take())
.map(BufReader::new);
if let Some(reader) = stdout {
Self::process_stream(reader, "stdout", &app_clone, &status_clone, &ready_clone);
}
if let Some(reader) = stderr {
Self::process_stream(reader, "stderr", &app_clone, &status_clone, &ready_clone);
}
});
let app_clone = app.clone();
let status_clone = status.clone();
let ready_clone = ready.clone();
let child_holder_clone = child_holder.clone();
thread::spawn(move || {
let timeout = Duration::from_secs(15);
thread::sleep(timeout);
if ready_clone.load(Ordering::SeqCst) {
return;
}
let mut locked = status_clone.lock();
locked.state = CliState::Error;
locked.error = Some("CLI did not start in time".to_string());
log_line("timeout waiting for CLI readiness");
if let Some(child) = child_holder_clone.lock().as_mut() {
let _ = child.kill();
}
let _ = app_clone.emit("cli:error", json!({"message": "CLI did not start in time"}));
Self::emit_status(&app_clone, &locked);
});
let status_clone = status.clone();
let app_clone = app.clone();
thread::spawn(move || {
let code = {
let mut guard = child_holder.lock();
if let Some(child) = guard.as_mut() {
child.wait().ok()
} else {
None
}
};
let mut locked = status_clone.lock();
let failed = locked.state != CliState::Ready;
let err_msg = if failed {
Some(match code {
Some(status) => format!("CLI exited early: {status}"),
None => "CLI exited early".to_string(),
})
} else {
None
};
if failed {
locked.state = CliState::Error;
if locked.error.is_none() {
locked.error = err_msg.clone();
}
log_line(&format!("cli process exited before ready: {:?}", locked.error));
let _ = app_clone.emit("cli:error", json!({"message": locked.error.clone().unwrap_or_default()}));
} else {
locked.state = CliState::Stopped;
log_line("cli process stopped cleanly");
}
Self::emit_status(&app_clone, &locked);
});
Ok(())
}
fn process_stream<R: BufRead>(
mut reader: R,
stream: &str,
app: &AppHandle,
status: &Arc<Mutex<CliStatus>>,
ready: &Arc<AtomicBool>,
) {
let mut buffer = String::new();
let port_regex = Regex::new(r"CodeNomad Server is ready at http://[^:]+:(\d+)").ok();
let http_regex = Regex::new(r":(\d{2,5})(?!.*:\d)").ok();
loop {
buffer.clear();
match reader.read_line(&mut buffer) {
Ok(0) => break,
Ok(_) => {
let line = buffer.trim_end();
if !line.is_empty() {
log_line(&format!("[cli][{}] {}", stream, line));
if ready.load(Ordering::SeqCst) {
continue;
}
if let Some(port) = port_regex
.as_ref()
.and_then(|re| re.captures(line).and_then(|c| c.get(1)))
.and_then(|m| m.as_str().parse::<u16>().ok())
{
Self::mark_ready(app, status, ready, port);
continue;
}
if line.to_lowercase().contains("http server listening") {
if let Some(port) = http_regex
.as_ref()
.and_then(|re| re.captures(line).and_then(|c| c.get(1)))
.and_then(|m| m.as_str().parse::<u16>().ok())
{
Self::mark_ready(app, status, ready, port);
continue;
}
if let Ok(value) = serde_json::from_str::<serde_json::Value>(line) {
if let Some(port) = value.get("port").and_then(|p| p.as_u64()) {
Self::mark_ready(app, status, ready, port as u16);
continue;
}
}
}
}
}
Err(_) => break,
}
}
}
fn mark_ready(app: &AppHandle, status: &Arc<Mutex<CliStatus>>, ready: &Arc<AtomicBool>, port: u16) {
ready.store(true, Ordering::SeqCst);
let mut locked = status.lock();
let url = format!("http://127.0.0.1:{port}");
locked.port = Some(port);
locked.url = Some(url.clone());
locked.state = CliState::Ready;
locked.error = None;
log_line(&format!("cli ready on {url}"));
navigate_main(app, &url);
let _ = app.emit("cli:ready", locked.clone());
Self::emit_status(app, &locked);
}
fn emit_status(app: &AppHandle, status: &CliStatus) {
let _ = app.emit("cli:status", status.clone());
}
}
fn supports_user_shell() -> bool {
cfg!(unix)
}
#[derive(Debug)]
struct ShellCommand {
shell: String,
args: Vec<String>,
}
#[derive(Debug)]
struct DirectCommand {
program: String,
args: Vec<String>,
}
#[derive(Debug)]
enum ShellCommandType {
UserShell(ShellCommand),
Direct(DirectCommand),
}
#[derive(Debug)]
struct CliEntry {
entry: String,
runner: Runner,
runner_path: Option<String>,
node_binary: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Runner {
Node,
Tsx,
}
impl CliEntry {
fn resolve(app: &AppHandle, dev: bool) -> anyhow::Result<Self> {
let node_binary = std::env::var("NODE_BINARY").unwrap_or_else(|_| "node".to_string());
if dev {
if let Some(tsx_path) = resolve_tsx(app) {
if let Some(entry) = resolve_dev_entry(app) {
return Ok(Self {
entry,
runner: Runner::Tsx,
runner_path: Some(tsx_path),
node_binary,
});
}
}
}
if let Some(entry) = resolve_dist_entry(app) {
return Ok(Self {
entry,
runner: Runner::Node,
runner_path: None,
node_binary,
});
}
Err(anyhow::anyhow!(
"Unable to locate CodeNomad CLI build (dist/bin.js). Please build @neuralnomads/codenomad."
))
}
fn build_args(&self, dev: bool) -> Vec<String> {
let mut args = vec![
"serve".to_string(),
"--host".to_string(),
"127.0.0.1".to_string(),
"--port".to_string(),
"0".to_string(),
];
if dev {
args.push("--ui-dev-server".to_string());
args.push("http://localhost:3000".to_string());
args.push("--log-level".to_string());
args.push("debug".to_string());
}
args
}
fn runner_args(&self, cli_args: &[String]) -> Vec<String> {
let mut args = VecDeque::new();
if self.runner == Runner::Tsx {
if let Some(path) = &self.runner_path {
args.push_back(path.clone());
}
}
args.push_back(self.entry.clone());
for arg in cli_args {
args.push_back(arg.clone());
}
args.into_iter().collect()
}
}
fn resolve_tsx(_app: &AppHandle) -> Option<String> {
let candidates = vec![
std::env::current_dir()
.ok()
.map(|p| p.join("node_modules/tsx/dist/cli.js")),
std::env::current_exe()
.ok()
.and_then(|ex| ex.parent().map(|p| p.join("../node_modules/tsx/dist/cli.js"))),
];
first_existing(candidates)
}
fn resolve_dev_entry(_app: &AppHandle) -> Option<String> {
let candidates = vec![
std::env::current_dir()
.ok()
.map(|p| p.join("packages/server/src/index.ts")),
std::env::current_dir()
.ok()
.map(|p| p.join("../server/src/index.ts")),
];
first_existing(candidates)
}
fn resolve_dist_entry(_app: &AppHandle) -> Option<String> {
let base = workspace_root();
let mut candidates: Vec<Option<PathBuf>> = vec![
base.as_ref().map(|p| p.join("packages/server/dist/bin.js")),
base.as_ref().map(|p| p.join("packages/server/dist/index.js")),
base.as_ref().map(|p| p.join("server/dist/bin.js")),
base.as_ref().map(|p| p.join("server/dist/index.js")),
];
if let Ok(exe) = std::env::current_exe() {
if let Some(dir) = exe.parent() {
let resources = dir.join("../Resources");
candidates.push(Some(resources.join("server/dist/bin.js")));
candidates.push(Some(resources.join("server/dist/index.js")));
candidates.push(Some(resources.join("server/dist/server/bin.js")));
candidates.push(Some(resources.join("server/dist/server/index.js")));
candidates.push(Some(resources.join("resources/server/dist/bin.js")));
candidates.push(Some(resources.join("resources/server/dist/index.js")));
candidates.push(Some(resources.join("resources/server/dist/server/bin.js")));
candidates.push(Some(resources.join("resources/server/dist/server/index.js")));
}
}
first_existing(candidates)
}
fn build_shell_command_string(entry: &CliEntry, cli_args: &[String]) -> anyhow::Result<ShellCommand> {
let shell = default_shell();
let mut quoted: Vec<String> = Vec::new();
quoted.push(shell_escape(&entry.node_binary));
for arg in entry.runner_args(cli_args) {
quoted.push(shell_escape(&arg));
}
let command = format!("ELECTRON_RUN_AS_NODE=1 exec {}", quoted.join(" "));
let args = build_shell_args(&shell, &command);
log_line(&format!("user shell command: {} {:?}", shell, args));
Ok(ShellCommand { shell, args })
}
fn default_shell() -> String {
if let Ok(shell) = std::env::var("SHELL") {
if !shell.trim().is_empty() {
return shell;
}
}
if cfg!(target_os = "macos") {
"/bin/zsh".to_string()
} else {
"/bin/bash".to_string()
}
}
fn shell_escape(input: &str) -> String {
if input.is_empty() {
"''".to_string()
} else if !input
.chars()
.any(|c| matches!(c, ' ' | '"' | '\'' | '$' | '`' | '!' ))
{
input.to_string()
} else {
let escaped = input.replace('\'', "'\\''");
format!("'{}'", escaped)
}
}
fn build_shell_args(shell: &str, command: &str) -> Vec<String> {
let shell_name = std::path::Path::new(shell)
.file_name()
.and_then(OsStr::to_str)
.unwrap_or("")
.to_lowercase();
if shell_name.contains("zsh") {
vec!["-l".into(), "-i".into(), "-c".into(), command.into()]
} else {
vec!["-l".into(), "-c".into(), command.into()]
}
}
fn first_existing(paths: Vec<Option<PathBuf>>) -> Option<String> {
paths
.into_iter()
.flatten()
.find(|p| p.exists())
.map(|p| normalize_path(p))
}
fn normalize_path(path: PathBuf) -> String {
if let Ok(clean) = path.canonicalize() {
clean.to_string_lossy().to_string()
} else {
path.to_string_lossy().to_string()
}
}

View File

@@ -0,0 +1,79 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
mod cli_manager;
use cli_manager::{CliProcessManager, CliStatus};
use serde_json::json;
use tauri::menu::Menu;
use tauri::{AppHandle, Emitter, Manager};
#[derive(Clone)]
pub struct AppState {
pub manager: CliProcessManager,
}
#[tauri::command]
fn cli_get_status(state: tauri::State<AppState>) -> CliStatus {
state.manager.status()
}
fn main() {
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
.manage(AppState {
manager: CliProcessManager::new(),
})
.setup(|app| {
build_menu(&app.handle())?;
let dev_mode = cfg!(debug_assertions) || std::env::var("TAURI_DEV").is_ok();
let app_handle = app.handle().clone();
let manager = app.state::<AppState>().manager.clone();
std::thread::spawn(move || {
if let Err(err) = manager.start(app_handle.clone(), dev_mode) {
let _ = app_handle.emit(
"cli:error",
json!({"message": err.to_string()}),
);
}
});
Ok(())
})
.invoke_handler(tauri::generate_handler![cli_get_status])
.on_menu_event(|_app_handle, _event| {
// No menu items defined currently
})
.build(tauri::generate_context!())
.expect("error while building tauri application")
.run(|app_handle, event| {
match event {
tauri::RunEvent::ExitRequested { .. } => {
let app = app_handle.clone();
std::thread::spawn(move || {
if let Some(state) = app.try_state::<AppState>() {
let _ = state.manager.stop();
}
app.exit(0);
});
}
tauri::RunEvent::WindowEvent { event: tauri::WindowEvent::Destroyed, .. } => {
if app_handle.webview_windows().len() <= 1 {
let app = app_handle.clone();
std::thread::spawn(move || {
if let Some(state) = app.try_state::<AppState>() {
let _ = state.manager.stop();
}
app.exit(0);
});
}
}
_ => {}
}
});
}
fn build_menu(app: &AppHandle) -> tauri::Result<()> {
// Minimal empty menu for now (Tauri v2 menu API differs from v1 roles).
let menu = Menu::new(app)?;
app.set_menu(menu)?;
Ok(())
}

View File

@@ -0,0 +1,49 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "CodeNomad",
"version": "0.1.0",
"identifier": "ai.opencode.client",
"build": {
"beforeDevCommand": "npm run dev:bootstrap",
"beforeBuildCommand": "npm run bundle:server",
"frontendDist": "resources/ui-loading"
},
"app": {
"withGlobalTauri": true,
"windows": [
{
"label": "main",
"title": "CodeNomad",
"url": "loading.html",
"width": 1400,
"height": 900,
"minWidth": 800,
"minHeight": 600,
"center": true,
"resizable": true,
"fullscreen": false,
"decorations": true,
"theme": "Dark",
"backgroundColor": "#1a1a1a"
}
],
"security": {
"assetProtocol": {
"scope": ["**"]
},
"capabilities": ["main-window-native-dialogs"]
}
},
"bundle": {
"active": true,
"resources": [
"resources/server",
"resources/ui-loading"
],
"icon": ["icon.icns", "icon.ico", "icon.png"],
"targets": ["app", "appimage", "deb", "rpm", "nsis"]
}
}

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.

View File

@@ -1,6 +1,6 @@
{ {
"name": "@codenomad/ui", "name": "@codenomad/ui",
"version": "0.1.2", "version": "0.2.3",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {

View File

@@ -1,7 +1,9 @@
import { Component, Show, createMemo, createEffect, createSignal } from "solid-js" import { Component, Show, createMemo, createEffect, createSignal } from "solid-js"
import { Dialog } from "@kobalte/core/dialog" import { Dialog } from "@kobalte/core/dialog"
import { Toaster } from "solid-toast" import { Toaster } from "solid-toast"
import AlertDialog from "./components/alert-dialog"
import FolderSelectionView from "./components/folder-selection-view" import FolderSelectionView from "./components/folder-selection-view"
import { showConfirmDialog } from "./stores/alerts"
import InstanceTabs from "./components/instance-tabs" import InstanceTabs from "./components/instance-tabs"
import InstanceDisconnectedModal from "./components/instance-disconnected-modal" import InstanceDisconnectedModal from "./components/instance-disconnected-modal"
import InstanceShell from "./components/instance/instance-shell" import InstanceShell from "./components/instance/instance-shell"
@@ -43,7 +45,7 @@ const App: Component = () => {
const { isDark } = useTheme() const { isDark } = useTheme()
const { const {
preferences, preferences,
addRecentFolder, recordWorkspaceLaunch,
toggleShowThinkingBlocks, toggleShowThinkingBlocks,
setDiffViewMode, setDiffViewMode,
setToolOutputExpansion, setToolOutputExpansion,
@@ -92,7 +94,7 @@ const App: Component = () => {
setIsSelectingFolder(true) setIsSelectingFolder(true)
const selectedBinary = binaryPath || preferences().lastUsedBinary || "opencode" const selectedBinary = binaryPath || preferences().lastUsedBinary || "opencode"
try { try {
addRecentFolder(folderPath) recordWorkspaceLaunch(folderPath, selectedBinary)
clearLaunchError() clearLaunchError()
const instanceId = await createInstance(folderPath, selectedBinary) const instanceId = await createInstance(folderPath, selectedBinary)
setHasInstances(true) setHasInstances(true)
@@ -135,11 +137,21 @@ const App: Component = () => {
} }
async function handleCloseInstance(instanceId: string) { async function handleCloseInstance(instanceId: string) {
if (confirm("Stop OpenCode instance? This will stop the server.")) { const confirmed = await showConfirmDialog(
await stopInstance(instanceId) "Stop OpenCode instance? This will stop the server.",
if (instances().size === 0) { {
setHasInstances(false) title: "Stop instance",
} variant: "warning",
confirmLabel: "Stop",
cancelLabel: "Keep running",
},
)
if (!confirmed) return
await stopInstance(instanceId)
if (instances().size === 0) {
setHasInstances(false)
} }
} }
@@ -321,6 +333,8 @@ const App: Component = () => {
</div> </div>
</Show> </Show>
<AlertDialog />
<Toaster <Toaster
position="top-right" position="top-right"
gutter={16} gutter={16}

View File

@@ -0,0 +1,132 @@
import { Dialog } from "@kobalte/core/dialog"
import { Component, Show, createEffect } from "solid-js"
import { alertDialogState, dismissAlertDialog } from "../stores/alerts"
import type { AlertVariant, AlertDialogState } from "../stores/alerts"
const variantAccent: Record<AlertVariant, { badgeBg: string; badgeBorder: string; badgeText: string; symbol: string; fallbackTitle: string }> = {
info: {
badgeBg: "var(--badge-neutral-bg)",
badgeBorder: "var(--border-base)",
badgeText: "var(--accent-primary)",
symbol: "i",
fallbackTitle: "Heads up",
},
warning: {
badgeBg: "rgba(255, 152, 0, 0.14)",
badgeBorder: "var(--status-warning)",
badgeText: "var(--status-warning)",
symbol: "!",
fallbackTitle: "Please review",
},
error: {
badgeBg: "var(--danger-soft-bg)",
badgeBorder: "var(--status-error)",
badgeText: "var(--status-error)",
symbol: "!",
fallbackTitle: "Something went wrong",
},
}
function dismiss(confirmed: boolean, payload?: AlertDialogState | null) {
const current = payload ?? alertDialogState()
if (current?.type === "confirm") {
if (confirmed) {
current.onConfirm?.()
} else {
current.onCancel?.()
}
current.resolve?.(confirmed)
} else if (confirmed) {
current?.onConfirm?.()
}
dismissAlertDialog()
}
const AlertDialog: Component = () => {
let primaryButtonRef: HTMLButtonElement | undefined
createEffect(() => {
if (alertDialogState()) {
queueMicrotask(() => {
primaryButtonRef?.focus()
})
}
})
return (
<Show when={alertDialogState()} keyed>
{(payload) => {
const variant = payload.variant ?? "info"
const accent = variantAccent[variant]
const title = payload.title || accent.fallbackTitle
const isConfirm = payload.type === "confirm"
const confirmLabel = payload.confirmLabel || (isConfirm ? "Confirm" : "OK")
const cancelLabel = payload.cancelLabel || "Cancel"
return (
<Dialog
open
modal
onOpenChange={(open) => {
if (!open) {
dismiss(false, payload)
}
}}
>
<Dialog.Portal>
<Dialog.Overlay class="modal-overlay" />
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<Dialog.Content class="modal-surface w-full max-w-sm p-6 border border-base shadow-2xl" tabIndex={-1}>
<div class="flex items-start gap-3">
<div
class="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl border text-base font-semibold"
style={{
"background-color": accent.badgeBg,
"border-color": accent.badgeBorder,
color: accent.badgeText,
}}
aria-hidden
>
{accent.symbol}
</div>
<div class="flex-1">
<Dialog.Title class="text-lg font-semibold text-primary">{title}</Dialog.Title>
<Dialog.Description class="text-sm text-secondary mt-1 whitespace-pre-line">
{payload.message}
{payload.detail && <p class="mt-2 text-secondary">{payload.detail}</p>}
</Dialog.Description>
</div>
</div>
<div class="mt-6 flex justify-end gap-3">
{isConfirm && (
<button
type="button"
class="button-secondary"
onClick={() => dismiss(false, payload)}
>
{cancelLabel}
</button>
)}
<button
type="button"
class="button-primary"
ref={(el) => {
primaryButtonRef = el
}}
onClick={() => dismiss(true, payload)}
>
{confirmLabel}
</button>
</div>
</Dialog.Content>
</div>
</Dialog.Portal>
</Dialog>
)
}}
</Show>
)
}
export default AlertDialog

View File

@@ -1,8 +1,8 @@
import { Component, Show, For, createSignal, createMemo, createEffect, onCleanup } from "solid-js" import { Component, Show, For, createSignal, createMemo, createEffect, onCleanup } from "solid-js"
import { ArrowUpLeft, Folder as FolderIcon, Loader2, X } from "lucide-solid" import { ArrowUpLeft, Folder as FolderIcon, Loader2, X } from "lucide-solid"
import type { FileSystemEntry, FileSystemListingMetadata } from "../../../cli/src/api-types" import type { FileSystemEntry, FileSystemListingMetadata } from "../../../server/src/api-types"
import { WINDOWS_DRIVES_ROOT } from "../../../cli/src/api-types" import { WINDOWS_DRIVES_ROOT } from "../../../server/src/api-types"
import { cliApi } from "../lib/api-client" import { serverApi } from "../lib/api-client"
function normalizePathKey(input?: string | null) { function normalizePathKey(input?: string | null) {
if (!input || input === "." || input === "./") { if (!input || input === "." || input === "./") {
@@ -144,7 +144,7 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
}) })
} }
const response = await cliApi.listFileSystem(targetPath, { includeFiles: false }) const response = await serverApi.listFileSystem(targetPath, { includeFiles: false })
const canonicalKey = normalizePathKey(response.metadata.currentPath) const canonicalKey = normalizePathKey(response.metadata.currentPath)
const directories = response.entries const directories = response.entries
.filter((entry) => entry.type === "directory") .filter((entry) => entry.type === "directory")

View File

@@ -1,222 +0,0 @@
import { Component, createSignal, createEffect, For, Show, onCleanup } from "solid-js"
import type { OpencodeClient } from "@opencode-ai/sdk/client"
import { cliApi } from "../lib/api-client"
interface FileItem {
path: string
added?: number
removed?: number
isGitFile: boolean
}
interface FilePickerProps {
open: boolean
onSelect: (path: string) => void
onNavigate: (direction: "up" | "down") => void
onClose: () => void
instanceClient: OpencodeClient
searchQuery: string
textareaRef?: HTMLTextAreaElement
workspaceId: string
}
const FilePicker: Component<FilePickerProps> = (props) => {
const [files, setFiles] = createSignal<FileItem[]>([])
const [selectedIndex, setSelectedIndex] = createSignal(0)
const [loading, setLoading] = createSignal(false)
const [allFiles, setAllFiles] = createSignal<FileItem[]>([])
const [isInitialized, setIsInitialized] = createSignal(false)
let containerRef: HTMLDivElement | undefined
let scrollContainerRef: HTMLDivElement | undefined
async function fetchFiles(searchQuery: string) {
console.log(`[FilePicker] Fetching files for query: "${searchQuery}"`)
setLoading(true)
try {
if (allFiles().length === 0) {
console.log(`[FilePicker] Scanning workspace: ${props.workspaceId}`)
const entries = await cliApi.listWorkspaceFiles(props.workspaceId)
const scannedFiles: FileItem[] = entries.map<FileItem>((entry) => ({
path: entry.path,
isGitFile: false,
}))
setAllFiles(scannedFiles)
console.log(`[FilePicker] Found ${scannedFiles.length} files`)
}
const filteredFiles = searchQuery.trim()
? allFiles().filter((f) => f.path.toLowerCase().includes(searchQuery.toLowerCase()))
: allFiles()
console.log(`[FilePicker] Showing ${filteredFiles.length} files`)
setFiles(filteredFiles)
setSelectedIndex(0)
setTimeout(() => {
if (scrollContainerRef) {
scrollContainerRef.scrollTop = 0
}
}, 0)
} catch (error) {
console.error(`[FilePicker] Failed to fetch files:`, error)
setFiles([])
} finally {
setLoading(false)
}
}
let lastQuery = ""
createEffect(() => {
console.log(
`[FilePicker] Effect triggered - open: ${props.open}, query: "${props.searchQuery}", isInitialized: ${isInitialized()}`,
)
if (props.open && !isInitialized()) {
setIsInitialized(true)
console.log("[FilePicker] First open - fetching files")
fetchFiles(props.searchQuery)
lastQuery = props.searchQuery
return
}
if (props.open && props.searchQuery !== lastQuery) {
console.log(`[FilePicker] Query changed from "${lastQuery}" to "${props.searchQuery}"`)
lastQuery = props.searchQuery
fetchFiles(props.searchQuery)
}
})
function scrollToSelected() {
setTimeout(() => {
const selectedElement = containerRef?.querySelector('[data-file-selected="true"]')
if (selectedElement) {
selectedElement.scrollIntoView({ block: "nearest", behavior: "smooth" })
}
}, 0)
}
function handleSelect(path: string) {
props.onSelect(path)
}
function handleNavigateUp() {
setSelectedIndex((prev) => {
const next = Math.max(prev - 1, 0)
scrollToSelected()
return next
})
}
function handleNavigateDown() {
setSelectedIndex((prev) => {
const next = Math.min(prev + 1, files().length - 1)
scrollToSelected()
return next
})
}
createEffect(() => {
if (!props.open) return
const listener = (e: KeyboardEvent) => {
if (!props.open) return
const fileList = files()
if (e.key === "Escape") {
e.preventDefault()
e.stopPropagation()
props.onClose()
return
}
if (fileList.length === 0) return
if (e.key === "ArrowDown") {
e.preventDefault()
e.stopPropagation()
handleNavigateDown()
props.onNavigate("down")
} else if (e.key === "ArrowUp") {
e.preventDefault()
e.stopPropagation()
handleNavigateUp()
props.onNavigate("up")
} else if (e.key === "Enter") {
e.preventDefault()
e.stopPropagation()
if (fileList[selectedIndex()]) {
handleSelect(fileList[selectedIndex()].path)
}
}
}
document.addEventListener("keydown", listener, true)
onCleanup(() => document.removeEventListener("keydown", listener, true))
})
return (
<Show when={props.open}>
<div
ref={containerRef}
class="dropdown-surface bottom-full left-0 mb-2 max-w-2xl rounded-lg"
style={{ "z-index": 100 }}
>
<div ref={scrollContainerRef} class="dropdown-content max-h-96">
<Show
when={!loading() && isInitialized()}
fallback={
<div class="dropdown-loading">
<div class="spinner inline-block h-4 w-4 mr-2"></div>
<span>Loading files...</span>
</div>
}
>
<Show
when={files().length > 0}
fallback={<div class="dropdown-empty">No matching files</div>}
>
<For each={files()}>
{(file, index) => (
<div
data-file-selected={index() === selectedIndex()}
class={`dropdown-item border-b px-4 py-2 font-mono text-sm ${
index() === selectedIndex() ? "dropdown-item-highlight" : ""
}`}
style="border-color: var(--border-muted)"
onClick={() => handleSelect(file.path)}
onMouseEnter={() => setSelectedIndex(index())}
>
<div class="flex items-center justify-between">
<span>{file.path}</span>
<Show when={file.isGitFile && (file.added || file.removed)}>
<div class="flex gap-2">
<Show when={file.added}>
<span class="dropdown-diff-added">+{file.added}</span>
</Show>
<Show when={file.removed}>
<span class="dropdown-diff-removed">-{file.removed}</span>
</Show>
</div>
</Show>
</div>
</div>
)}
</For>
</Show>
</Show>
</div>
<div class="dropdown-footer p-2">
<div class="flex items-center justify-between px-2">
<span> Navigate Enter Select Esc Close</span>
</div>
</div>
</div>
</Show>
)
}
export default FilePicker

View File

@@ -1,197 +1,23 @@
import { Component, Show, For, createSignal, createMemo, createEffect, onCleanup, onMount } from "solid-js" import { Component, Show, For, createSignal, createMemo, createEffect, onCleanup } from "solid-js"
import { Folder as FolderIcon, File as FileIcon, Loader2, Search, X } from "lucide-solid" import { Folder as FolderIcon, File as FileIcon, Loader2, Search, X, ArrowUpLeft } from "lucide-solid"
import type { FileSystemEntry } from "../../../cli/src/api-types" import type { FileSystemEntry, FileSystemListingMetadata } from "../../../server/src/api-types"
import { cliApi } from "../lib/api-client" import { serverApi } from "../lib/api-client"
import { getServerMeta } from "../lib/server-meta"
const MAX_RESULTS = 200 const MAX_RESULTS = 200
type CacheListener = (entries: FileSystemEntry[]) => void function normalizeEntryPath(path: string | undefined): string {
if (!path || path === "." || path === "./") {
interface FileSystemCacheState {
entriesMap: Map<string, FileSystemEntry>
entriesList: FileSystemEntry[]
loadedDirectories: Set<string>
loadingPromises: Map<string, Promise<void>>
pendingDirectories: string[]
listeners: Set<CacheListener>
queueActive: boolean
}
const fileSystemCache: FileSystemCacheState = {
entriesMap: new Map(),
entriesList: [],
loadedDirectories: new Set(),
loadingPromises: new Map(),
pendingDirectories: [],
listeners: new Set(),
queueActive: false,
}
let cacheWorkspaceRoot: string | null = null
function normalizeEntryPath(path: string): string {
if (!path || path === ".") {
return "." return "."
} }
const cleaned = path.replace(/\\/g, "/").replace(/^\.\/+/, "").replace(/\/+/g, "/") let cleaned = path.replace(/\\/g, "/")
return cleaned || "." if (cleaned.startsWith("./")) {
} cleaned = cleaned.replace(/^\.\/+/, "")
function updateCache(entries: FileSystemEntry[]): boolean {
let changed = false
for (const entry of entries) {
const normalizedPath = normalizeEntryPath(entry.path)
const normalizedEntry = normalizedPath === entry.path ? entry : { ...entry, path: normalizedPath }
const existing = fileSystemCache.entriesMap.get(normalizedPath)
if (
!existing ||
existing.name !== normalizedEntry.name ||
existing.type !== normalizedEntry.type ||
existing.size !== normalizedEntry.size ||
existing.modifiedAt !== normalizedEntry.modifiedAt
) {
fileSystemCache.entriesMap.set(normalizedPath, normalizedEntry)
changed = true
}
} }
if (cleaned.startsWith("/")) {
if (changed) { cleaned = cleaned.replace(/^\/+/, "")
fileSystemCache.entriesList = Array.from(fileSystemCache.entriesMap.values()).sort((a, b) =>
a.path.localeCompare(b.path),
)
} }
cleaned = cleaned.replace(/\/+/g, "/")
return changed return cleaned === "" ? "." : cleaned
}
function notifyCacheListeners() {
for (const listener of fileSystemCache.listeners) {
listener(fileSystemCache.entriesList)
}
}
function subscribeToCache(listener: CacheListener) {
fileSystemCache.listeners.add(listener)
listener(fileSystemCache.entriesList)
return () => fileSystemCache.listeners.delete(listener)
}
function resetFileSystemCache() {
fileSystemCache.entriesMap.clear()
fileSystemCache.entriesList = []
fileSystemCache.loadedDirectories.clear()
fileSystemCache.loadingPromises.clear()
fileSystemCache.pendingDirectories = []
fileSystemCache.queueActive = false
notifyCacheListeners()
}
function enqueueDirectory(path: string, priority = false) {
const normalized = normalizeEntryPath(path)
if (normalized === "." || fileSystemCache.loadedDirectories.has(normalized) || fileSystemCache.loadingPromises.has(normalized)) {
return
}
const existingIndex = fileSystemCache.pendingDirectories.indexOf(normalized)
if (existingIndex !== -1) {
if (priority) {
fileSystemCache.pendingDirectories.splice(existingIndex, 1)
fileSystemCache.pendingDirectories.unshift(normalized)
}
return
}
if (priority) {
fileSystemCache.pendingDirectories.unshift(normalized)
} else {
fileSystemCache.pendingDirectories.push(normalized)
}
}
async function loadDirectory(path: string): Promise<void> {
const normalized = normalizeEntryPath(path)
if (fileSystemCache.loadedDirectories.has(normalized)) {
return
}
const existing = fileSystemCache.loadingPromises.get(normalized)
if (existing) {
await existing
return
}
const promise = cliApi
.listFileSystem(normalized === "." ? "." : normalized)
.then(({ entries }) => {
const changed = updateCache(entries)
fileSystemCache.loadedDirectories.add(normalized)
for (const entry of entries) {
if (entry.type === "directory") {
enqueueDirectory(entry.path)
}
}
if (changed) {
notifyCacheListeners()
}
})
.finally(() => {
fileSystemCache.loadingPromises.delete(normalized)
})
fileSystemCache.loadingPromises.set(normalized, promise)
await promise
}
async function processDirectoryQueue() {
if (fileSystemCache.queueActive) {
return
}
fileSystemCache.queueActive = true
try {
while (fileSystemCache.pendingDirectories.length > 0) {
const next = fileSystemCache.pendingDirectories.shift()
if (!next) continue
try {
await loadDirectory(next)
} catch (error) {
console.warn("Failed to load directory", next, error)
}
}
} finally {
fileSystemCache.queueActive = false
}
}
function startBackgroundLoading() {
void processDirectoryQueue()
}
function prioritizeDirectoriesForQuery(query: string) {
const normalized = query.replace(/\\/g, "/").trim()
if (!normalized) {
return
}
const segments = normalized.split("/").filter(Boolean)
let prefix = ""
for (const segment of segments) {
prefix = prefix ? `${prefix}/${segment}` : segment
enqueueDirectory(prefix, true)
}
startBackgroundLoading()
}
async function ensureWorkspaceFilesystemLoaded(workspaceRoot: string) {
if (cacheWorkspaceRoot && cacheWorkspaceRoot !== workspaceRoot) {
cacheWorkspaceRoot = workspaceRoot
resetFileSystemCache()
} else if (!cacheWorkspaceRoot) {
cacheWorkspaceRoot = workspaceRoot
}
await loadDirectory(".")
startBackgroundLoading()
} }
function resolveAbsolutePath(root: string, relativePath: string): string { function resolveAbsolutePath(root: string, relativePath: string): string {
@@ -207,11 +33,6 @@ function resolveAbsolutePath(root: string, relativePath: string): string {
return `${trimmedRoot}${normalized}` return `${trimmedRoot}${normalized}`
} }
function formatRootLabel(root: string): string {
if (!root) return "Workspace Root"
const parts = root.split(/[/\\]/).filter(Boolean)
return parts[parts.length - 1] || root || "Workspace Root"
}
interface FileSystemBrowserDialogProps { interface FileSystemBrowserDialogProps {
open: boolean open: boolean
@@ -222,73 +43,174 @@ interface FileSystemBrowserDialogProps {
onClose: () => void onClose: () => void
} }
type FolderRow = { type: "up"; path: string } | { type: "entry"; entry: FileSystemEntry }
const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props) => { const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props) => {
const [entries, setEntries] = createSignal<FileSystemEntry[]>([])
const [rootPath, setRootPath] = createSignal("") const [rootPath, setRootPath] = createSignal("")
const [loading, setLoading] = createSignal(false) 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 [error, setError] = createSignal<string | null>(null)
const [searchQuery, setSearchQuery] = createSignal("") const [searchQuery, setSearchQuery] = createSignal("")
const [selectedIndex, setSelectedIndex] = createSignal(0) const [selectedIndex, setSelectedIndex] = createSignal(0)
let searchInputRef: HTMLInputElement | undefined let searchInputRef: HTMLInputElement | undefined
onMount(() => { const directoryCache = new Map<string, FileSystemEntry[]>()
const unsubscribe = subscribeToCache((items) => setEntries(items)) const metadataCache = new Map<string, FileSystemListingMetadata>()
onCleanup(unsubscribe) const inFlightLoads = new Map<string, Promise<FileSystemListingMetadata>>()
})
createEffect(() => { function resetDialogState() {
const query = searchQuery().trim() directoryCache.clear()
if (!query) { metadataCache.clear()
return 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
} }
prioritizeDirectoriesForQuery(query)
}) 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() { async function refreshEntries() {
setLoading(true)
setError(null) setError(null)
resetDialogState()
try { try {
const meta = await getServerMeta() const metadata = await fetchDirectory(".", true)
setRootPath(meta.workspaceRoot) setRootPath(metadata.rootPath)
await ensureWorkspaceFilesystemLoaded(meta.workspaceRoot) setEntries(directoryCache.get(normalizeEntryPath(metadata.currentPath)) ?? [])
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : "Unable to load filesystem" const message = err instanceof Error ? err.message : "Unable to load filesystem"
setError(message) setError(message)
} finally {
setLoading(false)
} }
} }
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 filteredEntries = createMemo(() => {
const query = searchQuery().trim().toLowerCase() const query = searchQuery().trim().toLowerCase()
const mode = props.mode const subset = entries().filter((entry) => (props.mode === "directories" ? entry.type === "directory" : true))
const root = rootPath()
const matchesType = entries().filter((entry) => (mode === "directories" ? entry.type === "directory" : entry.type === "file"))
const baseEntries = mode === "directories" && root
? [
{
name: formatRootLabel(root),
path: ".",
type: "directory" as const,
},
...matchesType,
]
: matchesType
if (!query) { if (!query) {
return baseEntries return subset
} }
return subset.filter((entry) => {
return baseEntries.filter((entry) => { const absolute = resolveAbsolutePath(rootPath(), entry.path)
const absolute = resolveAbsolutePath(root, entry.path)
return absolute.toLowerCase().includes(query) || entry.name.toLowerCase().includes(query) return absolute.toLowerCase().includes(query) || entry.name.toLowerCase().includes(query)
}) })
}) })
const visibleEntries = createMemo(() => filteredEntries().slice(0, MAX_RESULTS)) 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(() => { createEffect(() => {
const list = visibleEntries() const list = visibleEntries()
if (list.length === 0) { if (list.length === 0) {
@@ -338,20 +260,12 @@ const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props)
window.addEventListener("keydown", handleKeyDown) window.addEventListener("keydown", handleKeyDown)
onCleanup(() => { onCleanup(() => {
window.removeEventListener("keydown", handleKeyDown) window.removeEventListener("keydown", handleKeyDown)
resetDialogState()
setRootPath("")
setError(null)
}) })
}) })
function handleEntrySelect(entry: FileSystemEntry) {
const absolute = resolveAbsolutePath(rootPath(), entry.path)
props.onSelect(absolute)
}
function handleOverlayClick(event: MouseEvent) {
if (event.target === event.currentTarget) {
props.onClose()
}
}
return ( return (
<Show when={props.open}> <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="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-6" onClick={handleOverlayClick}>
@@ -360,9 +274,7 @@ const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props)
<div class="panel-header flex items-start justify-between gap-4"> <div class="panel-header flex items-start justify-between gap-4">
<div> <div>
<h3 class="panel-title">{props.title}</h3> <h3 class="panel-title">{props.title}</h3>
<p class="panel-subtitle"> <p class="panel-subtitle">{props.description || "Search for a path under the configured workspace root."}</p>
{props.description || "Search for a path under the configured workspace root."}
</p>
<Show when={rootPath()}> <Show when={rootPath()}>
<p class="text-xs text-muted mt-1 font-mono break-all">Root: {rootPath()}</p> <p class="text-xs text-muted mt-1 font-mono break-all">Root: {rootPath()}</p>
</Show> </Show>
@@ -392,56 +304,117 @@ const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props)
</div> </div>
</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"> <div class="panel-list panel-list--fill max-h-96 overflow-auto">
<Show <Show
when={!loading() && !error()} when={entries().length > 0}
fallback={ fallback={
<div class="flex items-center justify-center py-6 text-sm text-secondary"> <div class="flex items-center justify-center py-6 text-sm text-secondary">
<Show <Show
when={loading()} when={loadingPath() !== null}
fallback={<span class="text-red-500">{error()}</span>} fallback={<span class="text-red-500">{error()}</span>}
> >
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Loader2 class="w-4 h-4 animate-spin" /> <Loader2 class="w-4 h-4 animate-spin" />
<span>Loading filesystem</span> <span>Loading {describeLoadingPath()}</span>
</div> </div>
</Show> </Show>
</div> </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 <Show
when={visibleEntries().length > 0} when={folderRows().length > 0}
fallback={ fallback={
<div class="flex flex-col items-center justify-center gap-2 py-10 text-sm text-secondary"> <div class="flex flex-col items-center justify-center gap-2 py-10 text-sm text-secondary">
<p>No matches.</p> <p>No entries found.</p>
<Show when={searchQuery().trim().length === 0}> <button type="button" class="selector-button selector-button-secondary" onClick={refreshEntries}>
<button type="button" class="selector-button selector-button-secondary" onClick={refreshEntries}> Retry
Retry </button>
</button>
</Show>
</div> </div>
} }
> >
<For each={visibleEntries()}> <For each={folderRows()}>
{(entry, index) => ( {(row) => {
<button if (row.type === "up") {
type="button" return (
class="panel-list-item flex items-center gap-3 text-left" <div class="panel-list-item" role="button">
classList={{ "panel-list-item-highlight": selectedIndex() === index() }} <div class="panel-list-item-content directory-browser-row">
onMouseEnter={() => setSelectedIndex(index())} <button type="button" class="directory-browser-row-main" onClick={handleNavigateUp}>
onClick={() => handleEntrySelect(entry)} <div class="directory-browser-row-icon">
> <ArrowUpLeft class="w-4 h-4" />
<div class="flex h-8 w-8 items-center justify-center rounded-md bg-surface-secondary text-muted"> </div>
<Show when={entry.type === "directory"} fallback={<FileIcon class="w-4 h-4" />}> <div class="directory-browser-row-text">
<FolderIcon class="w-4 h-4" /> <span class="directory-browser-row-name">Up one level</span>
</Show> </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> </div>
<div class="flex flex-col"> )
<span class="text-sm font-medium text-primary">{entry.name || entry.path}</span> }}
<span class="text-xs font-mono text-muted">{resolveAbsolutePath(rootPath(), entry.path)}</span>
</div>
</button>
)}
</For> </For>
</Show> </Show>
</Show> </Show>
@@ -472,3 +445,4 @@ const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props)
} }
export default FileSystemBrowserDialog export default FileSystemBrowserDialog

View File

@@ -4,6 +4,7 @@ import { useConfig } from "../stores/preferences"
import AdvancedSettingsModal from "./advanced-settings-modal" import AdvancedSettingsModal from "./advanced-settings-modal"
import DirectoryBrowserDialog from "./directory-browser-dialog" import DirectoryBrowserDialog from "./directory-browser-dialog"
import Kbd from "./kbd" import Kbd from "./kbd"
import { openNativeFolderDialog, supportsNativeDialogs } from "../lib/native/native-functions"
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
@@ -16,11 +17,12 @@ interface FolderSelectionViewProps {
} }
const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => { const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
const { recentFolders, removeRecentFolder, preferences, updateLastUsedBinary } = useConfig() const { recentFolders, removeRecentFolder, preferences } = useConfig()
const [selectedIndex, setSelectedIndex] = createSignal(0) const [selectedIndex, setSelectedIndex] = createSignal(0)
const [focusMode, setFocusMode] = createSignal<"recent" | "new" | null>("recent") const [focusMode, setFocusMode] = createSignal<"recent" | "new" | null>("recent")
const [selectedBinary, setSelectedBinary] = createSignal(preferences().lastUsedBinary || "opencode") const [selectedBinary, setSelectedBinary] = createSignal(preferences().lastUsedBinary || "opencode")
const [isFolderBrowserOpen, setIsFolderBrowserOpen] = createSignal(false) const [isFolderBrowserOpen, setIsFolderBrowserOpen] = createSignal(false)
const nativeDialogsAvailable = supportsNativeDialogs()
let recentListRef: HTMLDivElement | undefined let recentListRef: HTMLDivElement | undefined
const folders = () => recentFolders() const folders = () => recentFolders()
@@ -29,9 +31,8 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
// Update selected binary when preferences change // Update selected binary when preferences change
createEffect(() => { createEffect(() => {
const lastUsed = preferences().lastUsedBinary const lastUsed = preferences().lastUsedBinary
if (lastUsed && lastUsed !== selectedBinary()) { if (!lastUsed) return
setSelectedBinary(lastUsed) setSelectedBinary((current) => (current === lastUsed ? current : lastUsed))
}
}) })
@@ -78,7 +79,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
if (isBrowseShortcut) { if (isBrowseShortcut) {
e.preventDefault() e.preventDefault()
handleBrowse() void handleBrowse()
return return
} }
@@ -169,13 +170,23 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
function handleFolderSelect(path: string) { function handleFolderSelect(path: string) {
if (isLoading()) return if (isLoading()) return
updateLastUsedBinary(selectedBinary())
props.onSelectFolder(path, selectedBinary()) props.onSelectFolder(path, selectedBinary())
} }
function handleBrowse() { async function handleBrowse() {
if (isLoading()) return if (isLoading()) return
setFocusMode("new") setFocusMode("new")
if (nativeDialogsAvailable) {
const fallbackPath = folders()[0]?.path
const selected = await openNativeFolderDialog({
title: "Select Workspace",
defaultPath: fallbackPath,
})
if (selected) {
handleFolderSelect(selected)
}
return
}
setIsFolderBrowserOpen(true) setIsFolderBrowserOpen(true)
} }
@@ -314,7 +325,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
<div class="panel-body"> <div class="panel-body">
<button <button
onClick={handleBrowse} onClick={() => void handleBrowse()}
disabled={props.isLoading} disabled={props.isLoading}
class="button-primary w-full flex items-center justify-center text-sm disabled:cursor-not-allowed" class="button-primary w-full flex items-center justify-center text-sm disabled:cursor-not-allowed"
onMouseEnter={() => setFocusMode("new")} onMouseEnter={() => setFocusMode("new")}

View File

@@ -48,6 +48,7 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
const [isLoadingMetadata, setIsLoadingMetadata] = createSignal(true) const [isLoadingMetadata, setIsLoadingMetadata] = createSignal(true)
const metadata = () => props.instance.metadata const metadata = () => props.instance.metadata
const binaryVersion = () => props.instance.binaryVersion || metadata()?.version
const mcpServers = () => { const mcpServers = () => {
const status = metadata()?.mcpStatus const status = metadata()?.mcpStatus
return status ? parseMcpStatus(status) : [] return status ? parseMcpStatus(status) : []
@@ -104,11 +105,12 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
...(lspStatus ? { lspStatus } : {}), ...(lspStatus ? { lspStatus } : {}),
} }
if (!nextMetadata.version) { if (!nextMetadata.version && instance.binaryVersion) {
nextMetadata.version = "0.15.8" nextMetadata.version = instance.binaryVersion
} }
updateInstance(instanceId, { metadata: nextMetadata }) updateInstance(instanceId, { metadata: nextMetadata })
} catch (error) { } catch (error) {
if (!cancelled) { if (!cancelled) {
console.error("Failed to load instance metadata:", error) console.error("Failed to load instance metadata:", error)
@@ -173,13 +175,13 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
)} )}
</Show> </Show>
<Show when={metadata()?.version}> <Show when={binaryVersion()}>
<div> <div>
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1"> <div class="text-xs font-medium text-muted uppercase tracking-wide mb-1">
OpenCode Version OpenCode Version
</div> </div>
<div class="text-xs px-2 py-1.5 rounded border bg-surface-secondary border-base text-primary"> <div class="text-xs px-2 py-1.5 rounded border bg-surface-secondary border-base text-primary">
v{metadata()?.version} v{binaryVersion()}
</div> </div>
</div> </div>
</Show> </Show>

View File

@@ -21,7 +21,79 @@ export default function MessageItem(props: MessageItemProps) {
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) 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 errorMessage = () => {
const info = props.messageInfo const info = props.messageInfo
@@ -48,7 +120,7 @@ export default function MessageItem(props: MessageItemProps) {
return true return true
} }
return messageParts().some((part) => partHasRenderableText(part)) return displayParts().some((part) => partHasRenderableText(part))
} }
const isGenerating = () => { const isGenerating = () => {
@@ -141,17 +213,64 @@ export default function MessageItem(props: MessageItemProps) {
</div> </div>
</Show> </Show>
<For each={messageParts()}>{(part) => ( <For each={displayParts()}>
<MessagePart {(part) => (
part={part} <MessagePart
messageType={props.message.type} part={part}
instanceId={props.instanceId} messageType={props.message.type}
sessionId={props.sessionId} instanceId={props.instanceId}
/> sessionId={props.sessionId}
)}</For> />
)}
</For>
</div> </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"}> <Show when={props.message.status === "sending"}>
<div class="message-sending"> <div class="message-sending">
<span class="generating-spinner"></span> Sending... <span class="generating-spinner"></span> Sending...
</div> </div>

View File

@@ -628,9 +628,11 @@ export default function MessageStream(props: MessageStreamProps) {
const toolPart = item.toolPart const toolPart = item.toolPart
const toolState = toolPart.state
const hasToolState = isToolStateRunning(toolState) || isToolStateCompleted(toolState) || isToolStateError(toolState)
const taskSessionId = const taskSessionId =
(isToolStateRunning(toolPart.state) || isToolStateCompleted(toolPart.state) || isToolStateError(toolPart.state)) hasToolState && typeof toolState?.metadata?.sessionId === "string"
? toolPart.state.metadata?.sessionId === "string" ? toolPart.state.metadata.sessionId : "" ? toolState.metadata.sessionId
: "" : ""
const taskLocation = taskSessionId ? findTaskSessionLocation(taskSessionId) : null const taskLocation = taskSessionId ? findTaskSessionLocation(taskSessionId) : null

View File

@@ -1,8 +1,9 @@
import { Component, For, Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js" import { Component, For, Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
import { FolderOpen, Trash2, Check, AlertCircle, Loader2, Plus } from "lucide-solid" import { FolderOpen, Trash2, Check, AlertCircle, Loader2, Plus } from "lucide-solid"
import { useConfig } from "../stores/preferences" import { useConfig } from "../stores/preferences"
import { cliApi } from "../lib/api-client" import { serverApi } from "../lib/api-client"
import FileSystemBrowserDialog from "./filesystem-browser-dialog" import FileSystemBrowserDialog from "./filesystem-browser-dialog"
import { openNativeFileDialog, supportsNativeDialogs } from "../lib/native/native-functions"
interface BinaryOption { interface BinaryOption {
path: string path: string
@@ -32,8 +33,10 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
const [versionInfo, setVersionInfo] = createSignal<Map<string, string>>(new Map<string, string>()) const [versionInfo, setVersionInfo] = createSignal<Map<string, string>>(new Map<string, string>())
const [validatingPaths, setValidatingPaths] = createSignal<Set<string>>(new Set<string>()) const [validatingPaths, setValidatingPaths] = createSignal<Set<string>>(new Set<string>())
const [isBinaryBrowserOpen, setIsBinaryBrowserOpen] = createSignal(false) const [isBinaryBrowserOpen, setIsBinaryBrowserOpen] = createSignal(false)
const nativeDialogsAvailable = supportsNativeDialogs()
const binaries = () => opencodeBinaries() const binaries = () => opencodeBinaries()
const lastUsedBinary = () => preferences().lastUsedBinary const lastUsedBinary = () => preferences().lastUsedBinary
const customBinaries = createMemo(() => binaries().filter((binary) => binary.path !== "opencode")) const customBinaries = createMemo(() => binaries().filter((binary) => binary.path !== "opencode"))
@@ -105,7 +108,7 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
setValidating(true) setValidating(true)
setValidationError(null) setValidationError(null)
const result = await cliApi.validateBinary(path) const result = await serverApi.validateBinary(path)
if (result.valid && result.version) { if (result.valid && result.version) {
const updatedVersionInfo = new Map(versionInfo()) const updatedVersionInfo = new Map(versionInfo())
@@ -128,9 +131,19 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
} }
} }
function handleBrowseBinary() { async function handleBrowseBinary() {
if (props.disabled) return if (props.disabled) return
setValidationError(null) setValidationError(null)
if (nativeDialogsAvailable) {
const selected = await openNativeFileDialog({
title: "Select OpenCode Binary",
})
if (selected) {
setCustomPath(selected)
void handleValidateAndAdd(selected)
}
return
}
setIsBinaryBrowserOpen(true) setIsBinaryBrowserOpen(true)
} }
@@ -245,7 +258,7 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
<button <button
type="button" type="button"
onClick={handleBrowseBinary} onClick={() => void handleBrowseBinary()}
disabled={props.disabled} disabled={props.disabled}
class="selector-button selector-button-secondary w-full flex items-center justify-center gap-2" class="selector-button selector-button-secondary w-full flex items-center justify-center gap-2"
> >

View File

@@ -10,6 +10,7 @@ import Kbd from "./kbd"
import HintRow from "./hint-row" import HintRow from "./hint-row"
import { getActiveInstance } from "../stores/instances" import { getActiveInstance } from "../stores/instances"
import { agents, getSessionDraftPrompt, setSessionDraftPrompt, clearSessionDraftPrompt } from "../stores/sessions" import { agents, getSessionDraftPrompt, setSessionDraftPrompt, clearSessionDraftPrompt } from "../stores/sessions"
import { showAlertDialog } from "../stores/alerts"
interface PromptInputProps { interface PromptInputProps {
instanceId: string instanceId: string
@@ -24,6 +25,7 @@ interface PromptInputProps {
export default function PromptInput(props: PromptInputProps) { export default function PromptInput(props: PromptInputProps) {
const [prompt, setPromptInternal] = createSignal("") const [prompt, setPromptInternal] = createSignal("")
const [history, setHistory] = createSignal<string[]>([]) const [history, setHistory] = createSignal<string[]>([])
const HISTORY_LIMIT = 100
const [historyIndex, setHistoryIndex] = createSignal(-1) const [historyIndex, setHistoryIndex] = createSignal(-1)
const [historyDraft, setHistoryDraft] = createSignal<string | null>(null) const [historyDraft, setHistoryDraft] = createSignal<string | null>(null)
const [, setIsFocused] = createSignal(false) const [, setIsFocused] = createSignal(false)
@@ -498,11 +500,27 @@ export default function PromptInput(props: PromptInputProps) {
async function handleSend() { async function handleSend() {
const text = prompt().trim() const text = prompt().trim()
const currentAttachments = attachments() const currentAttachments = attachments()
if (props.disabled || !text) return if (props.disabled || (!text && currentAttachments.length === 0)) return
const resolvedPrompt = resolvePastedPlaceholders(text, currentAttachments) const resolvedPrompt = resolvePastedPlaceholders(text, currentAttachments)
const isShellMode = mode() === "shell" const isShellMode = mode() === "shell"
const refreshHistory = async () => {
try {
await addToHistory(props.instanceFolder, resolvedPrompt)
setHistory((prev) => {
const next = [resolvedPrompt, ...prev]
if (next.length > HISTORY_LIMIT) {
next.length = HISTORY_LIMIT
}
return next
})
setHistoryIndex(-1)
} catch (historyError) {
console.error("Failed to update prompt history:", historyError)
}
}
clearPrompt() clearPrompt()
clearAttachments(props.instanceId, props.sessionId) clearAttachments(props.instanceId, props.sessionId)
setIgnoredAtPositions(new Set<number>()) setIgnoredAtPositions(new Set<number>())
@@ -511,10 +529,6 @@ export default function PromptInput(props: PromptInputProps) {
setHistoryDraft(null) setHistoryDraft(null)
try { try {
await addToHistory(props.instanceFolder, resolvedPrompt)
const updated = await getHistory(props.instanceFolder)
setHistory(updated)
setHistoryIndex(-1)
if (isShellMode) { if (isShellMode) {
if (props.onRunShell) { if (props.onRunShell) {
await props.onRunShell(resolvedPrompt) await props.onRunShell(resolvedPrompt)
@@ -522,11 +536,16 @@ export default function PromptInput(props: PromptInputProps) {
await props.onSend(resolvedPrompt, []) await props.onSend(resolvedPrompt, [])
} }
} else { } else {
await props.onSend(text, currentAttachments) await props.onSend(resolvedPrompt, currentAttachments)
} }
void refreshHistory()
} catch (error) { } catch (error) {
console.error("Failed to send message:", error) console.error("Failed to send message:", error)
alert("Failed to send message: " + (error instanceof Error ? error.message : String(error))) showAlertDialog("Failed to send message", {
title: "Send failed",
detail: error instanceof Error ? error.message : String(error),
variant: "error",
})
} finally { } finally {
textareaRef?.focus() textareaRef?.focus()
} }
@@ -573,7 +592,14 @@ export default function PromptInput(props: PromptInputProps) {
setAtPosition(null) setAtPosition(null)
} }
function handlePickerSelect(item: { type: "agent"; agent: Agent } | { type: "file"; file: { path: string; isGitFile: boolean } }) { function handlePickerSelect(
item:
| { type: "agent"; agent: Agent }
| {
type: "file"
file: { path: string; relativePath?: string; isGitFile: boolean; isDirectory?: boolean }
},
) {
if (item.type === "agent") { if (item.type === "agent") {
const agentName = item.agent.name const agentName = item.agent.name
const existingAttachments = attachments() const existingAttachments = attachments()
@@ -605,25 +631,26 @@ export default function PromptInput(props: PromptInputProps) {
}, 0) }, 0)
} }
} else if (item.type === "file") { } else if (item.type === "file") {
const path = item.file.path const displayPath = item.file.path
const isFolder = path.endsWith("/") const relativePath = item.file.relativePath ?? displayPath
const filename = path.split("/").pop() || path const isFolder = item.file.isDirectory ?? displayPath.endsWith("/")
if (isFolder) { if (isFolder) {
const currentPrompt = prompt() const currentPrompt = prompt()
const pos = atPosition() const pos = atPosition()
const cursorPos = textareaRef?.selectionStart || 0 const cursorPos = textareaRef?.selectionStart || 0
const folderMention = relativePath === "." || relativePath === "" ? "/" : displayPath
if (pos !== null) { if (pos !== null) {
const before = currentPrompt.substring(0, pos + 1) const before = currentPrompt.substring(0, pos + 1)
const after = currentPrompt.substring(cursorPos) const after = currentPrompt.substring(cursorPos)
const newPrompt = before + path + after const newPrompt = before + folderMention + after
setPrompt(newPrompt) setPrompt(newPrompt)
setSearchQuery(path) setSearchQuery(folderMention)
setTimeout(() => { setTimeout(() => {
if (textareaRef) { if (textareaRef) {
const newCursorPos = pos + 1 + path.length const newCursorPos = pos + 1 + folderMention.length
textareaRef.setSelectionRange(newCursorPos, newCursorPos) textareaRef.setSelectionRange(newCursorPos, newCursorPos)
} }
}, 0) }, 0)
@@ -632,11 +659,20 @@ export default function PromptInput(props: PromptInputProps) {
return return
} }
const normalizedPath = relativePath.replace(/\/+$/, "") || relativePath
const pathSegments = normalizedPath.split("/")
const filename = (() => {
const candidate = pathSegments[pathSegments.length - 1] || normalizedPath
return candidate === "." ? "/" : candidate
})()
const existingAttachments = attachments() const existingAttachments = attachments()
const alreadyAttached = existingAttachments.some((att) => att.source.type === "file" && att.source.path === path) const alreadyAttached = existingAttachments.some(
(att) => att.source.type === "file" && att.source.path === normalizedPath,
)
if (!alreadyAttached) { if (!alreadyAttached) {
const attachment = createFileAttachment(path, filename, "text/plain", undefined, props.instanceFolder) const attachment = createFileAttachment(normalizedPath, filename, "text/plain", undefined, props.instanceFolder)
addAttachment(props.instanceId, props.sessionId, attachment) addAttachment(props.instanceId, props.sessionId, attachment)
} }
@@ -703,8 +739,31 @@ export default function PromptInput(props: PromptInputProps) {
const filename = file.name const filename = file.name
const mime = file.type || "text/plain" const mime = file.type || "text/plain"
const attachment = createFileAttachment(path, filename, mime, undefined, props.instanceFolder) const createAndStoreAttachment = (previewUrl?: string) => {
addAttachment(props.instanceId, props.sessionId, attachment) const attachment = createFileAttachment(path, filename, mime, undefined, props.instanceFolder)
if (previewUrl && (mime.startsWith("image/") || mime.startsWith("text/"))) {
attachment.url = previewUrl
}
addAttachment(props.instanceId, props.sessionId, attachment)
}
if (mime.startsWith("image/") && typeof FileReader !== "undefined") {
const reader = new FileReader()
reader.onload = () => {
const result = typeof reader.result === "string" ? reader.result : undefined
createAndStoreAttachment(result)
}
reader.readAsDataURL(file)
} else if (mime.startsWith("text/") && typeof FileReader !== "undefined") {
const reader = new FileReader()
reader.onload = () => {
const dataUrl = typeof reader.result === "string" ? reader.result : undefined
createAndStoreAttachment(dataUrl)
}
reader.readAsDataURL(file)
} else {
createAndStoreAttachment()
}
} }
textareaRef?.focus() textareaRef?.focus()
@@ -755,7 +814,7 @@ export default function PromptInput(props: PromptInputProps) {
{(attachment) => { {(attachment) => {
const isImage = attachment.mediaType.startsWith("image/") const isImage = attachment.mediaType.startsWith("image/")
return ( return (
<div class="attachment-chip"> <div class={`attachment-chip ${isImage ? "attachment-chip-image" : ""}`}>
<Show <Show
when={isImage} when={isImage}
fallback={ fallback={
@@ -814,6 +873,11 @@ export default function PromptInput(props: PromptInputProps) {
/> />
</svg> </svg>
</button> </button>
<Show when={isImage}>
<div class="attachment-chip-preview">
<img src={attachment.url} alt={attachment.filename} />
</div>
</Show>
</div> </div>
) )
}} }}

View File

@@ -6,6 +6,7 @@ import MessageStream from "../message-stream"
import PromptInput from "../prompt-input" import PromptInput from "../prompt-input"
import { instances } from "../../stores/instances" import { instances } from "../../stores/instances"
import { loadMessages, sendMessage, forkSession, isSessionMessagesLoading, setActiveParentSession, setActiveSession, runShellCommand } from "../../stores/sessions" import { loadMessages, sendMessage, forkSession, isSessionMessagesLoading, setActiveParentSession, setActiveSession, runShellCommand } from "../../stores/sessions"
import { showAlertDialog } from "../../stores/alerts"
interface SessionViewProps { interface SessionViewProps {
sessionId: string sessionId: string
@@ -73,7 +74,10 @@ export const SessionView: Component<SessionViewProps> = (props) => {
} }
} catch (error) { } catch (error) {
console.error("Failed to revert:", error) console.error("Failed to revert:", error)
alert("Failed to revert to message") showAlertDialog("Failed to revert to message", {
title: "Revert failed",
variant: "error",
})
} }
} }
@@ -106,7 +110,10 @@ export const SessionView: Component<SessionViewProps> = (props) => {
} }
} catch (error) { } catch (error) {
console.error("Failed to fork session:", error) console.error("Failed to fork session:", error)
alert("Failed to fork session") showAlertDialog("Failed to fork session", {
title: "Fork failed",
variant: "error",
})
} }
} }

View File

@@ -346,11 +346,11 @@ export default function ToolCall(props: ToolCallProps) {
const { preferences, setDiffViewMode } = useConfig() const { preferences, setDiffViewMode } = useConfig()
const { isDark } = useTheme() const { isDark } = useTheme()
const toolCallId = () => props.toolCallId || props.toolCall?.id || "" const toolCallId = () => props.toolCallId || props.toolCall?.id || ""
const expanded = () => isToolCallExpanded(toolCallId()) const pendingPermission = createMemo(() => props.toolCall.pendingPermission)
const expanded = () => (pendingPermission() ? true : isToolCallExpanded(toolCallId()))
const toolOutputDefaultExpanded = createMemo(() => (preferences().toolOutputExpansion || "expanded") === "expanded") const toolOutputDefaultExpanded = createMemo(() => (preferences().toolOutputExpansion || "expanded") === "expanded")
const diagnosticsDefaultExpanded = createMemo(() => (preferences().diagnosticsExpansion || "expanded") === "expanded") const diagnosticsDefaultExpanded = createMemo(() => (preferences().diagnosticsExpansion || "expanded") === "expanded")
const [appliedPreference, setAppliedPreference] = createSignal<boolean | null>(null) const [appliedPreference, setAppliedPreference] = createSignal<boolean | null>(null)
const pendingPermission = createMemo(() => props.toolCall.pendingPermission)
const permissionDetails = createMemo(() => pendingPermission()?.permission) const permissionDetails = createMemo(() => pendingPermission()?.permission)
const isPermissionActive = createMemo(() => pendingPermission()?.active === true) const isPermissionActive = createMemo(() => pendingPermission()?.active === true)
const activePermissionKey = createMemo(() => { const activePermissionKey = createMemo(() => {
@@ -416,13 +416,6 @@ export default function ToolCall(props: ToolCallProps) {
setAppliedPreference((prev) => (prev === null ? prev : null)) setAppliedPreference((prev) => (prev === null ? prev : null))
}) })
createEffect(() => {
if (!pendingPermission()) return
const id = toolCallId()
if (!id) return
setToolCallExpanded(id, true)
})
createEffect(() => { createEffect(() => {
const permission = permissionDetails() const permission = permissionDetails()
if (!permission) { if (!permission) {
@@ -564,24 +557,73 @@ export default function ToolCall(props: ToolCallProps) {
} }
} }
const getTodoTitle = () => { type TodoViewStatus = "pending" | "in_progress" | "completed" | "cancelled"
const state = props.toolCall?.state || {}
if (state.status !== "completed") return "Plan"
const metadata = state.metadata || {} interface TodoViewItem {
const todos = metadata.todos || [] id: string
content: string
status: TodoViewStatus
}
if (!Array.isArray(todos) || todos.length === 0) return "Plan" function normalizeTodoStatus(rawStatus: unknown): TodoViewStatus {
if (rawStatus === "completed" || rawStatus === "in_progress" || rawStatus === "cancelled") return rawStatus
return "pending"
}
const counts = { pending: 0, completed: 0 } function extractTodosFromState(state: ToolState | undefined): TodoViewItem[] {
for (const todo of todos) { if (!state) return []
const status = todo.status || "pending" const metadata = (isToolStateRunning(state) || isToolStateCompleted(state) || isToolStateError(state))
if (status in counts) counts[status as keyof typeof counts]++ ? state.metadata || {}
: {}
const todos = Array.isArray((metadata as any).todos) ? (metadata as any).todos : []
const items: TodoViewItem[] = []
for (let index = 0; index < todos.length; index++) {
const todo = todos[index]
const content = typeof todo?.content === "string" ? todo.content.trim() : ""
if (!content) continue
const status = normalizeTodoStatus((todo as any).status)
const id = typeof todo?.id === "string" && todo.id.length > 0 ? todo.id : `${index}-${content}`
items.push({ id, content, status })
} }
const total = todos.length return items
if (counts.pending === total) return "Creating plan" }
if (counts.completed === total) return "Completing plan"
function summarizeTodos(todos: TodoViewItem[]) {
return todos.reduce(
(acc, todo) => {
acc.total += 1
acc[todo.status] = (acc[todo.status] || 0) + 1
return acc
},
{ total: 0, pending: 0, in_progress: 0, completed: 0, cancelled: 0 } as Record<TodoViewStatus | "total", number>,
)
}
function getTodoStatusLabel(status: TodoViewStatus): string {
switch (status) {
case "completed":
return "Completed"
case "in_progress":
return "In progress"
case "cancelled":
return "Cancelled"
default:
return "Pending"
}
}
const getTodoTitle = () => {
const state = props.toolCall?.state
if (!state) return "Plan"
const todos = extractTodosFromState(state)
if (state.status !== "completed" || todos.length === 0) return "Plan"
const counts = summarizeTodos(todos)
if (counts.pending === counts.total) return "Creating plan"
if (counts.completed === counts.total) return "Completing plan"
return "Updating plan" return "Updating plan"
} }
@@ -646,7 +688,7 @@ export default function ToolCall(props: ToolCallProps) {
return getTodoTitle() return getTodoTitle()
case "todoread": case "todoread":
return "Plan" return getTodoTitle()
case "invalid": case "invalid":
if (typeof input.tool === "string") { if (typeof input.tool === "string") {
@@ -663,18 +705,14 @@ export default function ToolCall(props: ToolCallProps) {
const toolName = props.toolCall?.tool || "" const toolName = props.toolCall?.tool || ""
const state = props.toolCall?.state || {} const state = props.toolCall?.state || {}
if (toolName === "todoread") { if (toolName === "todoread" || toolName === "todowrite") {
return null return renderTodoTool()
} }
if (state.status === "pending") { if (state.status === "pending") {
return null return null
} }
if (toolName === "todowrite") {
return renderTodowriteTool()
}
if (toolName === "task") { if (toolName === "task") {
return renderTaskTool() return renderTaskTool()
} }
@@ -945,65 +983,46 @@ export default function ToolCall(props: ToolCallProps) {
return null return null
} }
const renderTodowriteTool = () => { const renderTodoTool = () => {
const state = props.toolCall?.state const state = props.toolCall?.state
if (!state) return null if (!state) return null
const metadata = (isToolStateRunning(state) || isToolStateCompleted(state) || isToolStateError(state))
? state.metadata || {}
: {}
const todos = metadata.todos || []
if (!Array.isArray(todos) || todos.length === 0) { const todos = extractTodosFromState(state)
return null const counts = summarizeTodos(todos)
if (counts.total === 0) {
return <div class="tool-call-todo-empty">No plan items yet.</div>
} }
const getStatusLabel = (status: string): string => {
switch (status) {
case "completed":
return "Completed"
case "in_progress":
return "In progress"
case "cancelled":
return "Cancelled"
default:
return "Pending"
}
}
const shouldShowTag = (status: string) => status === "cancelled"
return ( return (
<div class="tool-call-todos" role="list"> <div class="tool-call-todo-region">
<For each={todos}> <div class="tool-call-todos" role="list">
{(todo) => { <For each={todos}>
const content = typeof todo.content === "string" ? todo.content.trim() : "" {(todo) => {
if (!content) return null const label = getTodoStatusLabel(todo.status)
const status = typeof todo.status === "string" ? todo.status : "pending" return (
const label = getStatusLabel(status) <div
class="tool-call-todo-item"
return ( classList={{
<div "tool-call-todo-item-completed": todo.status === "completed",
class="tool-call-todo-item" "tool-call-todo-item-cancelled": todo.status === "cancelled",
classList={{ "tool-call-todo-item-active": todo.status === "in_progress",
"tool-call-todo-item-completed": status === "completed", }}
"tool-call-todo-item-cancelled": status === "cancelled", role="listitem"
"tool-call-todo-item-active": status === "in_progress", >
}} <span class="tool-call-todo-checkbox" data-status={todo.status} aria-label={label}></span>
role="listitem" <div class="tool-call-todo-body">
> <div class="tool-call-todo-heading">
<span class="tool-call-todo-checkbox" data-status={status} aria-label={label}></span> <span class="tool-call-todo-text">{todo.content}</span>
<div class="tool-call-todo-body"> <span class={`tool-call-todo-status tool-call-todo-status-${todo.status}`}>{label}</span>
<span class="tool-call-todo-text">{content}</span> </div>
<Show when={shouldShowTag(status)}> </div>
<span class="tool-call-todo-tag">{label}</span>
</Show>
</div> </div>
</div> )
) }}
}} </For>
</For> </div>
</div> </div>
) )
} }

View File

@@ -1,13 +1,67 @@
import { Component, createSignal, createEffect, For, Show, onCleanup } from "solid-js" import { Component, createSignal, createEffect, For, Show, onCleanup } from "solid-js"
import type { Agent } from "../types/session" import type { Agent } from "../types/session"
import type { OpencodeClient } from "@opencode-ai/sdk/client" import type { OpencodeClient } from "@opencode-ai/sdk/client"
import { cliApi } from "../lib/api-client" import { serverApi } from "../lib/api-client"
const SEARCH_RESULT_LIMIT = 100
const SEARCH_DEBOUNCE_MS = 200
type LoadingState = "idle" | "listing" | "search"
interface FileItem { interface FileItem {
path: string path: string
relativePath: string
added?: number added?: number
removed?: number removed?: number
isGitFile: boolean isGitFile: boolean
isDirectory: boolean
}
function formatDisplayPath(basePath: string, isDirectory: boolean) {
if (!isDirectory) {
return basePath
}
const trimmed = basePath.replace(/\/+$/, "")
return trimmed.length > 0 ? `${trimmed}/` : "./"
}
function isRootPath(value: string) {
return value === "." || value === "./" || value === "/"
}
function normalizeRelativePath(basePath: string, isDirectory: boolean) {
if (isRootPath(basePath)) {
return "."
}
const withoutPrefix = basePath.replace(/^\.\/+/, "")
if (isDirectory) {
const trimmed = withoutPrefix.replace(/\/+$/, "")
return trimmed || "."
}
return withoutPrefix
}
function normalizeQuery(rawQuery: string) {
const trimmed = rawQuery.trim()
if (!trimmed) {
return ""
}
if (trimmed === "." || trimmed === "./") {
return ""
}
return trimmed.replace(/^(\.\/)+/, "").replace(/^\/+/, "")
}
function mapEntriesToFileItems(entries: { path: string; type: "file" | "directory" }[]): FileItem[] {
return entries.map((entry) => {
const isDirectory = entry.type === "directory"
return {
path: formatDisplayPath(entry.path, isDirectory),
relativePath: normalizeRelativePath(entry.path, isDirectory),
isDirectory,
isGitFile: false,
}
})
} }
type PickerItem = { type: "agent"; agent: Agent } | { type: "file"; file: FileItem } type PickerItem = { type: "agent"; agent: Agent } | { type: "file"; file: FileItem }
@@ -27,62 +81,182 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
const [files, setFiles] = createSignal<FileItem[]>([]) const [files, setFiles] = createSignal<FileItem[]>([])
const [filteredAgents, setFilteredAgents] = createSignal<Agent[]>([]) const [filteredAgents, setFilteredAgents] = createSignal<Agent[]>([])
const [selectedIndex, setSelectedIndex] = createSignal(0) const [selectedIndex, setSelectedIndex] = createSignal(0)
const [loading, setLoading] = createSignal(false) const [loadingState, setLoadingState] = createSignal<LoadingState>("idle")
const [allFiles, setAllFiles] = createSignal<FileItem[]>([]) const [allFiles, setAllFiles] = createSignal<FileItem[]>([])
const [isInitialized, setIsInitialized] = createSignal(false) const [isInitialized, setIsInitialized] = createSignal(false)
const [cachedWorkspaceId, setCachedWorkspaceId] = createSignal<string | null>(null)
let containerRef: HTMLDivElement | undefined let containerRef: HTMLDivElement | undefined
let scrollContainerRef: HTMLDivElement | undefined let scrollContainerRef: HTMLDivElement | undefined
let lastWorkspaceId: string | null = null
async function fetchFiles(searchQuery: string) { let lastQuery = ""
setLoading(true) let inflightWorkspaceId: string | null = null
let inflightSnapshotPromise: Promise<FileItem[]> | null = null
let activeRequestId = 0
let queryDebounceTimer: ReturnType<typeof setTimeout> | null = null
function resetScrollPosition() {
setTimeout(() => {
if (scrollContainerRef) {
scrollContainerRef.scrollTop = 0
}
}, 0)
}
function applyFileResults(nextFiles: FileItem[]) {
setFiles(nextFiles)
setSelectedIndex(0)
resetScrollPosition()
}
async function fetchWorkspaceSnapshot(workspaceId: string): Promise<FileItem[]> {
if (inflightWorkspaceId === workspaceId && inflightSnapshotPromise) {
return inflightSnapshotPromise
}
inflightWorkspaceId = workspaceId
inflightSnapshotPromise = serverApi
.listWorkspaceFiles(workspaceId)
.then((entries) => mapEntriesToFileItems(entries))
.then((snapshot) => {
setAllFiles(snapshot)
setCachedWorkspaceId(workspaceId)
return snapshot
})
.catch((error) => {
console.error(`[UnifiedPicker] Failed to load workspace files:`, error)
setAllFiles([])
setCachedWorkspaceId(null)
throw error
})
.finally(() => {
if (inflightWorkspaceId === workspaceId) {
inflightWorkspaceId = null
inflightSnapshotPromise = null
}
})
return inflightSnapshotPromise
}
async function ensureWorkspaceSnapshot(workspaceId: string) {
if (cachedWorkspaceId() === workspaceId && allFiles().length > 0) {
return allFiles()
}
return fetchWorkspaceSnapshot(workspaceId)
}
async function loadFilesForQuery(rawQuery: string, workspaceId: string) {
const normalizedQuery = normalizeQuery(rawQuery)
const requestId = ++activeRequestId
const hasCachedSnapshot =
!normalizedQuery && cachedWorkspaceId() === workspaceId && allFiles().length > 0
const mode: LoadingState = normalizedQuery ? "search" : hasCachedSnapshot ? "idle" : "listing"
if (mode !== "idle") {
setLoadingState(mode)
} else {
setLoadingState("idle")
}
try { try {
if (allFiles().length === 0) { if (!normalizedQuery) {
const entries = await cliApi.listWorkspaceFiles(props.workspaceId) const snapshot = await ensureWorkspaceSnapshot(workspaceId)
const scannedFiles: FileItem[] = entries.map<FileItem>((entry) => ({ if (!shouldApplyResults(requestId, workspaceId)) {
path: entry.path, return
isGitFile: false, }
})) applyFileResults(snapshot)
setAllFiles(scannedFiles) return
} }
const filteredFiles = searchQuery.trim() const results = await serverApi.searchWorkspaceFiles(workspaceId, normalizedQuery, {
? allFiles().filter((f) => f.path.toLowerCase().includes(searchQuery.toLowerCase())) limit: SEARCH_RESULT_LIMIT,
: allFiles() })
if (!shouldApplyResults(requestId, workspaceId)) {
setFiles(filteredFiles) return
setSelectedIndex(0) }
applyFileResults(mapEntriesToFileItems(results))
setTimeout(() => {
if (scrollContainerRef) {
scrollContainerRef.scrollTop = 0
}
}, 0)
} catch (error) { } catch (error) {
console.error(`[UnifiedPicker] Failed to fetch files:`, error) if (workspaceId === props.workspaceId) {
setFiles([]) console.error(`[UnifiedPicker] Failed to fetch files:`, error)
if (shouldApplyResults(requestId, workspaceId)) {
applyFileResults([])
}
}
} finally { } finally {
setLoading(false) if (shouldFinalizeRequest(requestId, workspaceId)) {
setLoadingState("idle")
}
} }
} }
let lastQuery = "" function clearQueryDebounce() {
if (queryDebounceTimer) {
clearTimeout(queryDebounceTimer)
queryDebounceTimer = null
}
}
function scheduleLoadFilesForQuery(rawQuery: string, workspaceId: string, immediate = false) {
clearQueryDebounce()
const normalizedQuery = normalizeQuery(rawQuery)
const shouldDebounce = !immediate && normalizedQuery.length > 0
if (shouldDebounce) {
queryDebounceTimer = setTimeout(() => {
queryDebounceTimer = null
void loadFilesForQuery(rawQuery, workspaceId)
}, SEARCH_DEBOUNCE_MS)
return
}
void loadFilesForQuery(rawQuery, workspaceId)
}
function shouldApplyResults(requestId: number, workspaceId: string) {
return props.open && workspaceId === props.workspaceId && requestId === activeRequestId
}
function shouldFinalizeRequest(requestId: number, workspaceId: string) {
return workspaceId === props.workspaceId && requestId === activeRequestId
}
function resetPickerState() {
clearQueryDebounce()
setFiles([])
setAllFiles([])
setCachedWorkspaceId(null)
setIsInitialized(false)
setSelectedIndex(0)
setLoadingState("idle")
lastWorkspaceId = null
lastQuery = ""
activeRequestId = 0
}
onCleanup(() => {
clearQueryDebounce()
})
createEffect(() => { createEffect(() => {
if (props.open && !isInitialized()) { if (!props.open) {
setIsInitialized(true) resetPickerState()
fetchFiles(props.searchQuery)
lastQuery = props.searchQuery
return return
} }
if (props.open && props.searchQuery !== lastQuery) { const workspaceChanged = lastWorkspaceId !== props.workspaceId
const queryChanged = lastQuery !== props.searchQuery
if (!isInitialized() || workspaceChanged || queryChanged) {
setIsInitialized(true)
lastWorkspaceId = props.workspaceId
lastQuery = props.searchQuery lastQuery = props.searchQuery
fetchFiles(props.searchQuery) const shouldSkipDebounce = workspaceChanged || normalizeQuery(props.searchQuery).length === 0
scheduleLoadFilesForQuery(props.searchQuery, props.workspaceId, shouldSkipDebounce)
} }
}) })
createEffect(() => { createEffect(() => {
if (!props.open) return if (!props.open) return
@@ -154,8 +328,19 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
const agentCount = () => filteredAgents().length const agentCount = () => filteredAgents().length
const fileCount = () => files().length const fileCount = () => files().length
const isLoading = () => loadingState() !== "idle"
const loadingMessage = () => {
if (loadingState() === "search") {
return "Searching..."
}
if (loadingState() === "listing") {
return "Loading workspace..."
}
return ""
}
return ( return (
<Show when={props.open}> <Show when={props.open}>
<div <div
ref={containerRef} ref={containerRef}
@@ -164,8 +349,8 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
<div class="dropdown-header"> <div class="dropdown-header">
<div class="dropdown-header-title"> <div class="dropdown-header-title">
Select Agent or File Select Agent or File
<Show when={loading()}> <Show when={isLoading()}>
<span class="ml-2">Loading...</span> <span class="ml-2">{loadingMessage()}</span>
</Show> </Show>
</div> </div>
</div> </div>
@@ -236,8 +421,10 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
</div> </div>
<For each={files()}> <For each={files()}>
{(file) => { {(file) => {
const itemIndex = allItems().findIndex((item) => item.type === "file" && item.file.path === file.path) const itemIndex = allItems().findIndex(
const isFolder = file.path.endsWith("/") (item) => item.type === "file" && item.file.relativePath === file.relativePath,
)
const isFolder = file.isDirectory
return ( return (
<div <div
class={`dropdown-item py-1.5 ${ class={`dropdown-item py-1.5 ${

View File

@@ -1,6 +1,5 @@
import type { import type {
AppConfig, AppConfig,
AppConfigUpdateRequest,
BinaryCreateRequest, BinaryCreateRequest,
BinaryListResponse, BinaryListResponse,
BinaryUpdateRequest, BinaryUpdateRequest,
@@ -9,14 +8,15 @@ import type {
FileSystemListResponse, FileSystemListResponse,
InstanceData, InstanceData,
ServerMeta, ServerMeta,
WorkspaceCreateRequest, WorkspaceCreateRequest,
WorkspaceDescriptor, WorkspaceDescriptor,
WorkspaceFileResponse, WorkspaceFileResponse,
WorkspaceFileSearchResponse,
WorkspaceLogEntry, WorkspaceLogEntry,
WorkspaceEventPayload, WorkspaceEventPayload,
WorkspaceEventType, WorkspaceEventType,
} from "../../../cli/src/api-types" } from "../../../server/src/api-types"
const FALLBACK_API_BASE = "http://127.0.0.1:9898" const FALLBACK_API_BASE = "http://127.0.0.1:9898"
const RUNTIME_BASE = typeof window !== "undefined" ? window.location?.origin : undefined const RUNTIME_BASE = typeof window !== "undefined" ? window.location?.origin : undefined
@@ -80,7 +80,7 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
} }
export const cliApi = { export const serverApi = {
fetchWorkspaces(): Promise<WorkspaceDescriptor[]> { fetchWorkspaces(): Promise<WorkspaceDescriptor[]> {
return request<WorkspaceDescriptor[]>("/api/workspaces") return request<WorkspaceDescriptor[]>("/api/workspaces")
}, },
@@ -100,12 +100,33 @@ export const cliApi = {
const params = new URLSearchParams({ path: relativePath }) const params = new URLSearchParams({ path: relativePath })
return request<FileSystemEntry[]>(`/api/workspaces/${encodeURIComponent(id)}/files?${params.toString()}`) return request<FileSystemEntry[]>(`/api/workspaces/${encodeURIComponent(id)}/files?${params.toString()}`)
}, },
searchWorkspaceFiles(
id: string,
query: string,
opts?: { limit?: number; type?: "file" | "directory" | "all" },
): Promise<WorkspaceFileSearchResponse> {
const trimmed = query.trim()
if (!trimmed) {
return Promise.resolve([])
}
const params = new URLSearchParams({ q: trimmed })
if (opts?.limit) {
params.set("limit", String(opts.limit))
}
if (opts?.type) {
params.set("type", opts.type)
}
return request<WorkspaceFileSearchResponse>(
`/api/workspaces/${encodeURIComponent(id)}/files/search?${params.toString()}`,
)
},
readWorkspaceFile(id: string, relativePath: string): Promise<WorkspaceFileResponse> { readWorkspaceFile(id: string, relativePath: string): Promise<WorkspaceFileResponse> {
const params = new URLSearchParams({ path: relativePath }) const params = new URLSearchParams({ path: relativePath })
return request<WorkspaceFileResponse>( return request<WorkspaceFileResponse>(
`/api/workspaces/${encodeURIComponent(id)}/files/content?${params.toString()}`, `/api/workspaces/${encodeURIComponent(id)}/files/content?${params.toString()}`,
) )
}, },
fetchConfig(): Promise<AppConfig> { fetchConfig(): Promise<AppConfig> {
return request<AppConfig>("/api/config/app") return request<AppConfig>("/api/config/app")
}, },
@@ -115,12 +136,6 @@ export const cliApi = {
body: JSON.stringify(payload), body: JSON.stringify(payload),
}) })
}, },
patchConfig(payload: AppConfigUpdateRequest): Promise<AppConfig> {
return request<AppConfig>("/api/config/app", {
method: "PATCH",
body: JSON.stringify(payload),
})
},
listBinaries(): Promise<BinaryListResponse> { listBinaries(): Promise<BinaryListResponse> {
return request<BinaryListResponse>("/api/config/binaries") return request<BinaryListResponse>("/api/config/binaries")
}, },

View File

@@ -1,5 +1,6 @@
import type { Command } from "./commands" import type { Command } from "./commands"
import type { Command as SDKCommand } from "@opencode-ai/sdk" import type { Command as SDKCommand } from "@opencode-ai/sdk"
import { showAlertDialog } from "../stores/alerts"
import { activeSessionId, executeCustomCommand } from "../stores/sessions" import { activeSessionId, executeCustomCommand } from "../stores/sessions"
export function commandRequiresArguments(template?: string): boolean { export function commandRequiresArguments(template?: string): boolean {
@@ -33,7 +34,10 @@ export function buildCustomCommandEntries(instanceId: string, commands: SDKComma
action: async () => { action: async () => {
const sessionId = activeSessionId().get(instanceId) const sessionId = activeSessionId().get(instanceId)
if (!sessionId || sessionId === "info") { if (!sessionId || sessionId === "info") {
alert("Select a session before running a custom command.") showAlertDialog("Select a session before running a custom command.", {
title: "Session required",
variant: "warning",
})
return return
} }
const args = promptForCommandArguments(cmd) const args = promptForCommandArguments(cmd)
@@ -44,7 +48,10 @@ export function buildCustomCommandEntries(instanceId: string, commands: SDKComma
await executeCustomCommand(instanceId, sessionId, cmd.name, args) await executeCustomCommand(instanceId, sessionId, cmd.name, args)
} catch (error) { } catch (error) {
console.error("Failed to run custom command:", error) console.error("Failed to run custom command:", error)
alert("Failed to run custom command. Check the console for details.") showAlertDialog("Failed to run custom command. Check the console for details.", {
title: "Command failed",
variant: "error",
})
} }
}, },
})) }))

View File

@@ -11,6 +11,7 @@ import {
setActiveSession, setActiveSession,
} from "../../stores/sessions" } from "../../stores/sessions"
import { setSessionCompactionState } from "../../stores/session-compaction" import { setSessionCompactionState } from "../../stores/session-compaction"
import { showAlertDialog } from "../../stores/alerts"
import type { Instance } from "../../types/instance" import type { Instance } from "../../types/instance"
export interface UseCommandsOptions { export interface UseCommandsOptions {
@@ -207,7 +208,10 @@ export function useCommands(options: UseCommandsOptions) {
setSessionCompactionState(instance.id, sessionId, false) setSessionCompactionState(instance.id, sessionId, false)
console.error("Failed to compact session:", error) console.error("Failed to compact session:", error)
const message = error instanceof Error ? error.message : "Failed to compact session" const message = error instanceof Error ? error.message : "Failed to compact session"
alert(`Compact failed: ${message}`) showAlertDialog(`Compact failed: ${message}`, {
title: "Compact failed",
variant: "error",
})
} }
}, },
}) })
@@ -256,7 +260,10 @@ export function useCommands(options: UseCommandsOptions) {
} }
if (!messageID) { if (!messageID) {
alert("Nothing to undo") showAlertDialog("Nothing to undo", {
title: "No actions to undo",
variant: "info",
})
return return
} }
@@ -282,7 +289,10 @@ export function useCommands(options: UseCommandsOptions) {
} }
} catch (error) { } catch (error) {
console.error("Failed to revert message:", error) console.error("Failed to revert message:", error)
alert("Failed to revert message") showAlertDialog("Failed to revert message", {
title: "Undo failed",
variant: "error",
})
} }
}, },
}) })

View File

@@ -0,0 +1,39 @@
import type { NativeDialogOptions } from "../native-functions"
interface ElectronDialogResult {
canceled?: boolean
paths?: string[]
path?: string | null
}
interface ElectronAPI {
openDialog?: (options: NativeDialogOptions) => Promise<ElectronDialogResult>
}
function coerceFirstPath(result?: ElectronDialogResult | null): string | null {
if (!result || result.canceled) {
return null
}
const paths = Array.isArray(result.paths) ? result.paths : result.path ? [result.path] : []
if (paths.length === 0) {
return null
}
return paths[0] ?? null
}
export async function openElectronNativeDialog(options: NativeDialogOptions): Promise<string | null> {
if (typeof window === "undefined") {
return null
}
const api = (window as Window & { electronAPI?: ElectronAPI }).electronAPI
if (!api?.openDialog) {
return null
}
try {
const result = await api.openDialog(options)
return coerceFirstPath(result)
} catch (error) {
console.error("[native] electron dialog failed", error)
return null
}
}

View File

@@ -0,0 +1,37 @@
import { runtimeEnv } from "../runtime-env"
import type { NativeDialogOptions } from "./types"
import { openElectronNativeDialog } from "./electron/functions"
import { openTauriNativeDialog } from "./tauri/functions"
export type { NativeDialogOptions, NativeDialogFilter, NativeDialogMode } from "./types"
function resolveNativeHandler(): ((options: NativeDialogOptions) => Promise<string | null>) | null {
switch (runtimeEnv.host) {
case "electron":
return openElectronNativeDialog
case "tauri":
return openTauriNativeDialog
default:
return null
}
}
export function supportsNativeDialogs(): boolean {
return resolveNativeHandler() !== null
}
async function openNativeDialog(options: NativeDialogOptions): Promise<string | null> {
const handler = resolveNativeHandler()
if (!handler) {
return null
}
return handler(options)
}
export async function openNativeFolderDialog(options?: Omit<NativeDialogOptions, "mode">): Promise<string | null> {
return openNativeDialog({ mode: "directory", ...(options ?? {}) })
}
export async function openNativeFileDialog(options?: Omit<NativeDialogOptions, "mode">): Promise<string | null> {
return openNativeDialog({ mode: "file", ...(options ?? {}) })
}

View File

@@ -0,0 +1,55 @@
import type { NativeDialogOptions } from "../native-functions"
interface TauriDialogModule {
open?: (
options: {
title?: string
defaultPath?: string
filters?: { name?: string; extensions: string[] }[]
directory?: boolean
multiple?: boolean
},
) => Promise<string | string[] | null>
}
interface TauriBridge {
dialog?: TauriDialogModule
}
export async function openTauriNativeDialog(options: NativeDialogOptions): Promise<string | null> {
if (typeof window === "undefined") {
return null
}
const tauriBridge = (window as Window & { __TAURI__?: TauriBridge }).__TAURI__
const dialogApi = tauriBridge?.dialog
if (!dialogApi?.open) {
return null
}
try {
const response = await dialogApi.open({
title: options.title,
defaultPath: options.defaultPath,
directory: options.mode === "directory",
multiple: false,
filters: options.filters?.map((filter) => ({
name: filter.name,
extensions: filter.extensions,
})),
})
if (!response) {
return null
}
if (Array.isArray(response)) {
return response[0] ?? null
}
return response
} catch (error) {
console.error("[native] tauri dialog failed", error)
return null
}
}

View File

@@ -0,0 +1,13 @@
export type NativeDialogMode = "directory" | "file"
export interface NativeDialogFilter {
name?: string
extensions: string[]
}
export interface NativeDialogOptions {
mode: NativeDialogMode
title?: string
defaultPath?: string
filters?: NativeDialogFilter[]
}

View File

@@ -0,0 +1,86 @@
export type HostRuntime = "electron" | "tauri" | "web"
export type PlatformKind = "desktop" | "mobile"
export interface RuntimeEnvironment {
host: HostRuntime
platform: PlatformKind
}
declare global {
interface Window {
electronAPI?: unknown
__TAURI__?: {
invoke?: <T = unknown>(cmd: string, args?: Record<string, unknown>) => Promise<T>
event?: {
listen: (event: string, handler: (payload: { payload: unknown }) => void) => Promise<() => void>
}
dialog?: {
open?: (options: Record<string, unknown>) => Promise<string | string[] | null>
save?: (options: Record<string, unknown>) => Promise<string | null>
}
}
}
}
function detectHost(): HostRuntime {
if (typeof window === "undefined") {
return "web"
}
const win = window as Window & { electronAPI?: unknown }
if (typeof win.electronAPI !== "undefined") {
return "electron"
}
if (typeof win.__TAURI__ !== "undefined") {
return "tauri"
}
if (typeof navigator !== "undefined" && /tauri/i.test(navigator.userAgent)) {
return "tauri"
}
return "web"
}
function detectPlatform(): PlatformKind {
if (typeof navigator === "undefined") {
return "desktop"
}
const uaData = (navigator as any).userAgentData
if (uaData?.mobile) {
return "mobile"
}
const ua = navigator.userAgent.toLowerCase()
if (/android|iphone|ipad|ipod|blackberry|mini|windows phone|mobile|silk/.test(ua)) {
return "mobile"
}
return "desktop"
}
let cachedEnv: RuntimeEnvironment | null = null
export function detectRuntimeEnvironment(): RuntimeEnvironment {
if (cachedEnv) {
return cachedEnv
}
cachedEnv = {
host: detectHost(),
platform: detectPlatform(),
}
if (typeof console !== "undefined") {
const message = `[runtime] host=${cachedEnv.host} platform=${cachedEnv.platform}`
console.info(message)
}
return cachedEnv
}
export const runtimeEnv = detectRuntimeEnvironment()
export const isElectronHost = () => runtimeEnv.host === "electron"
export const isTauriHost = () => runtimeEnv.host === "tauri"
export const isWebHost = () => runtimeEnv.host === "web"
export const isMobilePlatform = () => runtimeEnv.platform === "mobile"

View File

@@ -1,5 +1,5 @@
import type { WorkspaceEventPayload, WorkspaceEventType } from "../../../cli/src/api-types" import type { WorkspaceEventPayload, WorkspaceEventType } from "../../../server/src/api-types"
import { cliApi } from "./api-client" import { serverApi } from "./api-client"
const RETRY_BASE_DELAY = 1000 const RETRY_BASE_DELAY = 1000
const RETRY_MAX_DELAY = 10000 const RETRY_MAX_DELAY = 10000
@@ -13,7 +13,7 @@ function logSse(message: string, context?: Record<string, unknown>) {
console.log(`${SSE_PREFIX} ${message}`) console.log(`${SSE_PREFIX} ${message}`)
} }
class CliEvents { class ServerEvents {
private handlers = new Map<WorkspaceEventType | "*", Set<(event: WorkspaceEventPayload) => void>>() private handlers = new Map<WorkspaceEventType | "*", Set<(event: WorkspaceEventPayload) => void>>()
private source: EventSource | null = null private source: EventSource | null = null
private retryDelay = RETRY_BASE_DELAY private retryDelay = RETRY_BASE_DELAY
@@ -27,7 +27,7 @@ class CliEvents {
this.source.close() this.source.close()
} }
logSse("Connecting to backend events stream") logSse("Connecting to backend events stream")
this.source = cliApi.connectEvents((event) => this.dispatch(event), () => this.scheduleReconnect()) this.source = serverApi.connectEvents((event) => this.dispatch(event), () => this.scheduleReconnect())
this.source.onopen = () => { this.source.onopen = () => {
logSse("Events stream connected") logSse("Events stream connected")
this.retryDelay = RETRY_BASE_DELAY this.retryDelay = RETRY_BASE_DELAY
@@ -62,4 +62,4 @@ class CliEvents {
} }
} }
export const cliEvents = new CliEvents() export const serverEvents = new ServerEvents()

View File

@@ -1,5 +1,5 @@
import type { ServerMeta } from "../../../cli/src/api-types" import type { ServerMeta } from "../../../server/src/api-types"
import { cliApi } from "./api-client" import { serverApi } from "./api-client"
let cachedMeta: ServerMeta | null = null let cachedMeta: ServerMeta | null = null
let pendingMeta: Promise<ServerMeta> | null = null let pendingMeta: Promise<ServerMeta> | null = null
@@ -11,7 +11,7 @@ export async function getServerMeta(): Promise<ServerMeta> {
if (pendingMeta) { if (pendingMeta) {
return pendingMeta return pendingMeta
} }
pendingMeta = cliApi.fetchServerMeta().then((meta) => { pendingMeta = serverApi.fetchServerMeta().then((meta) => {
cachedMeta = meta cachedMeta = meta
pendingMeta = null pendingMeta = null
return meta return meta

View File

@@ -14,16 +14,15 @@ import type {
EventSessionIdle, EventSessionIdle,
EventSessionUpdated, EventSessionUpdated,
} from "@opencode-ai/sdk" } from "@opencode-ai/sdk"
import { CODENOMAD_API_BASE } from "./api-client" import { serverEvents } from "./server-events"
import type {
InstanceStreamEvent,
InstanceStreamStatus,
WorkspaceEventPayload,
} from "../../../server/src/api-types"
interface SSEConnection { type InstanceEventPayload = Extract<WorkspaceEventPayload, { type: "instance.event" }>
instanceId: string type InstanceStatusPayload = Extract<WorkspaceEventPayload, { type: "instance.eventStatus" }>
proxyPath: string
eventSource: EventSource
status: "connecting" | "connected" | "disconnected" | "error"
reconnectAttempts: number
reconnectTimer?: ReturnType<typeof setTimeout>
}
interface TuiToastEvent { interface TuiToastEvent {
type: "tui.toast.show" type: "tui.toast.show"
@@ -35,7 +34,7 @@ interface TuiToastEvent {
} }
} }
type SSEEvent = type SSEEvent =
| MessageUpdateEvent | MessageUpdateEvent
| MessageRemovedEvent | MessageRemovedEvent
| MessagePartUpdatedEvent | MessagePartUpdatedEvent
@@ -48,73 +47,43 @@ type SSEEvent =
| EventPermissionReplied | EventPermissionReplied
| EventLspUpdated | EventLspUpdated
| TuiToastEvent | TuiToastEvent
| { type: string; properties?: Record<string, unknown> } // Fallback for unknown event types | { type: string; properties?: Record<string, unknown> }
const [connectionStatus, setConnectionStatus] = createSignal< type ConnectionStatus = InstanceStreamStatus
Map<string, "connecting" | "connected" | "disconnected" | "error">
>(new Map()) const [connectionStatus, setConnectionStatus] = createSignal<Map<string, ConnectionStatus>>(new Map())
class SSEManager { class SSEManager {
private connections = new Map<string, SSEConnection>() constructor() {
private static readonly MAX_RECONNECT_ATTEMPTS = 3 serverEvents.on("instance.eventStatus", (event) => {
const payload = event as InstanceStatusPayload
connect(instanceId: string, proxyPath: string, reconnectAttempts = 0): void { this.updateConnectionStatus(payload.instanceId, payload.status)
const existing = this.connections.get(instanceId) if (payload.status === "disconnected") {
if (existing) { if (payload.reason === "workspace stopped") {
this.clearReconnectTimer(existing) return
existing.eventSource.close() }
} const reason = payload.reason ?? "Instance disconnected"
void this.onConnectionLost?.(payload.instanceId, reason)
const url = buildInstanceEventsUrl(proxyPath)
const eventSource = new EventSource(url)
const connection: SSEConnection = {
instanceId,
proxyPath,
eventSource,
status: "connecting",
reconnectAttempts,
}
this.connections.set(instanceId, connection)
this.updateConnectionStatus(instanceId, "connecting")
eventSource.onopen = () => {
connection.status = "connected"
connection.reconnectAttempts = 0
this.updateConnectionStatus(instanceId, "connected")
console.log(`[SSE] Connected to instance ${instanceId}`)
}
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data)
this.handleEvent(instanceId, data)
} catch (error) {
console.error("[SSE] Failed to parse event:", error)
} }
} })
eventSource.onerror = () => { serverEvents.on("instance.event", (event) => {
connection.status = "error" const payload = event as InstanceEventPayload
this.updateConnectionStatus(instanceId, "error") this.updateConnectionStatus(payload.instanceId, "connected")
console.error(`[SSE] Connection error for instance ${instanceId}`) this.handleEvent(payload.instanceId, payload.event as SSEEvent)
this.handleConnectionError(instanceId, "Connection to instance lost") })
}
} }
disconnect(instanceId: string): void { seedStatus(instanceId: string, status: ConnectionStatus) {
const connection = this.connections.get(instanceId) this.updateConnectionStatus(instanceId, status)
if (connection) {
this.clearReconnectTimer(connection)
connection.eventSource.close()
this.connections.delete(instanceId)
this.updateConnectionStatus(instanceId, "disconnected")
console.log(`[SSE] Disconnected from instance ${instanceId}`)
}
} }
private handleEvent(instanceId: string, event: SSEEvent): void { private handleEvent(instanceId: string, event: SSEEvent | InstanceStreamEvent): void {
if (!event || typeof event !== "object" || typeof (event as { type?: unknown }).type !== "string") {
console.warn("[SSE] Dropping malformed event", event)
return
}
console.log("[SSE] Received event:", event.type, event) console.log("[SSE] Received event:", event.type, event)
switch (event.type) { switch (event.type) {
@@ -159,52 +128,7 @@ class SSEManager {
} }
} }
private handleConnectionError(instanceId: string, reason: string): void { private updateConnectionStatus(instanceId: string, status: ConnectionStatus): void {
const connection = this.connections.get(instanceId)
if (!connection) return
connection.eventSource.close()
if (connection.reconnectAttempts >= SSEManager.MAX_RECONNECT_ATTEMPTS) {
this.handleConnectionLost(instanceId, reason)
return
}
const nextAttempt = connection.reconnectAttempts + 1
const delay = Math.min(nextAttempt * 1000, 5000)
connection.reconnectAttempts = nextAttempt
connection.status = "connecting"
this.updateConnectionStatus(instanceId, "connecting")
console.warn(`[SSE] Attempting reconnect ${nextAttempt} for instance ${instanceId}`)
connection.reconnectTimer = setTimeout(() => {
connection.reconnectTimer = undefined
this.connect(instanceId, connection.proxyPath, nextAttempt)
}, delay)
}
private handleConnectionLost(instanceId: string, reason: string): void {
const connection = this.connections.get(instanceId)
if (!connection) return
this.clearReconnectTimer(connection)
connection.eventSource.close()
this.connections.delete(instanceId)
connection.status = "disconnected"
this.updateConnectionStatus(instanceId, "disconnected")
this.onConnectionLost?.(instanceId, reason)
}
private clearReconnectTimer(connection: SSEConnection): void {
if (connection.reconnectTimer) {
clearTimeout(connection.reconnectTimer)
connection.reconnectTimer = undefined
}
}
private updateConnectionStatus(instanceId: string, status: SSEConnection["status"]): void {
setConnectionStatus((prev) => { setConnectionStatus((prev) => {
const next = new Map(prev) const next = new Map(prev)
next.set(instanceId, status) next.set(instanceId, status)
@@ -226,7 +150,7 @@ class SSEManager {
onLspUpdated?: (instanceId: string, event: EventLspUpdated) => void onLspUpdated?: (instanceId: string, event: EventLspUpdated) => void
onConnectionLost?: (instanceId: string, reason: string) => void | Promise<void> onConnectionLost?: (instanceId: string, reason: string) => void | Promise<void>
getStatus(instanceId: string): "connecting" | "connected" | "disconnected" | "error" | null { getStatus(instanceId: string): ConnectionStatus | null {
return connectionStatus().get(instanceId) ?? null return connectionStatus().get(instanceId) ?? null
} }
@@ -235,19 +159,4 @@ class SSEManager {
} }
} }
function buildInstanceEventsUrl(proxyPath: string): string {
const normalized = normalizeProxyPath(proxyPath)
const base = stripTrailingSlashes(CODENOMAD_API_BASE)
return `${base}${normalized}/event`
}
function normalizeProxyPath(proxyPath: string): string {
const withLeading = proxyPath.startsWith("/") ? proxyPath : `/${proxyPath}`
return withLeading.replace(/\/+/g, "/").replace(/\/+$/, "")
}
function stripTrailingSlashes(input: string): string {
return input.replace(/\/+$/, "")
}
export const sseManager = new SSEManager() export const sseManager = new SSEManager()

View File

@@ -1,46 +1,182 @@
import type { AppConfig, InstanceData } from "../../../cli/src/api-types" import type { AppConfig, InstanceData } from "../../../server/src/api-types"
import { cliApi } from "./api-client" import { serverApi } from "./api-client"
import { cliEvents } from "./cli-events" import { serverEvents } from "./server-events"
export type ConfigData = AppConfig export type ConfigData = AppConfig
const DEFAULT_INSTANCE_DATA: InstanceData = {
messageHistory: [],
agentModelSelections: {},
}
function isDeepEqual(a: unknown, b: unknown): boolean {
if (a === b) {
return true
}
if (typeof a === "object" && a !== null && typeof b === "object" && b !== null) {
try {
return JSON.stringify(a) === JSON.stringify(b)
} catch (error) {
console.warn("Failed to compare config objects", error)
}
}
return false
}
export class ServerStorage { export class ServerStorage {
private configChangeListeners: Set<() => void> = new Set() private configChangeListeners: Set<(config: ConfigData) => void> = new Set()
private configCache: ConfigData | null = null
private loadPromise: Promise<ConfigData> | null = null
private instanceDataCache = new Map<string, InstanceData>()
private instanceDataListeners = new Map<string, Set<(data: InstanceData) => void>>()
private instanceLoadPromises = new Map<string, Promise<InstanceData>>()
constructor() { constructor() {
cliEvents.on("config.appChanged", () => this.notifyConfigChanged()) serverEvents.on("config.appChanged", (event) => {
if (event.type !== "config.appChanged") return
this.setConfigCache(event.config)
})
serverEvents.on("instance.dataChanged", (event) => {
if (event.type !== "instance.dataChanged") return
this.setInstanceDataCache(event.instanceId, event.data)
})
} }
async loadConfig(): Promise<ConfigData> { async loadConfig(): Promise<ConfigData> {
const config = await cliApi.fetchConfig() if (this.configCache) {
return config return this.configCache
}
if (!this.loadPromise) {
this.loadPromise = serverApi
.fetchConfig()
.then((config) => {
this.setConfigCache(config)
return config
})
.finally(() => {
this.loadPromise = null
})
}
return this.loadPromise
} }
async saveConfig(config: ConfigData): Promise<void> { async updateConfig(next: ConfigData): Promise<ConfigData> {
await cliApi.updateConfig(config) const nextConfig = await serverApi.updateConfig(next)
this.setConfigCache(nextConfig)
return nextConfig
} }
async loadInstanceData(instanceId: string): Promise<InstanceData> { async loadInstanceData(instanceId: string): Promise<InstanceData> {
return cliApi.readInstanceData(instanceId) const cached = this.instanceDataCache.get(instanceId)
if (cached) {
return cached
}
if (!this.instanceLoadPromises.has(instanceId)) {
const promise = serverApi
.readInstanceData(instanceId)
.then((data) => {
const normalized = this.normalizeInstanceData(data)
this.setInstanceDataCache(instanceId, normalized)
return normalized
})
.finally(() => {
this.instanceLoadPromises.delete(instanceId)
})
this.instanceLoadPromises.set(instanceId, promise)
}
return this.instanceLoadPromises.get(instanceId)!
} }
async saveInstanceData(instanceId: string, data: InstanceData): Promise<void> { async saveInstanceData(instanceId: string, data: InstanceData): Promise<void> {
await cliApi.writeInstanceData(instanceId, data) const normalized = this.normalizeInstanceData(data)
await serverApi.writeInstanceData(instanceId, normalized)
this.setInstanceDataCache(instanceId, normalized)
} }
async deleteInstanceData(instanceId: string): Promise<void> { async deleteInstanceData(instanceId: string): Promise<void> {
await cliApi.deleteInstanceData(instanceId) await serverApi.deleteInstanceData(instanceId)
this.setInstanceDataCache(instanceId, DEFAULT_INSTANCE_DATA)
} }
onConfigChanged(listener: () => void): () => void { onConfigChanged(listener: (config: ConfigData) => void): () => void {
this.configChangeListeners.add(listener) this.configChangeListeners.add(listener)
if (this.configCache) {
listener(this.configCache)
}
return () => this.configChangeListeners.delete(listener) return () => this.configChangeListeners.delete(listener)
} }
private notifyConfigChanged() { onInstanceDataChanged(instanceId: string, listener: (data: InstanceData) => void): () => void {
if (!this.instanceDataListeners.has(instanceId)) {
this.instanceDataListeners.set(instanceId, new Set())
}
const bucket = this.instanceDataListeners.get(instanceId)!
bucket.add(listener)
const cached = this.instanceDataCache.get(instanceId)
if (cached) {
listener(cached)
}
return () => {
bucket.delete(listener)
if (bucket.size === 0) {
this.instanceDataListeners.delete(instanceId)
}
}
}
private setConfigCache(config: ConfigData) {
if (this.configCache && isDeepEqual(this.configCache, config)) {
this.configCache = config
return
}
this.configCache = config
this.notifyConfigChanged(config)
}
private notifyConfigChanged(config: ConfigData) {
for (const listener of this.configChangeListeners) { for (const listener of this.configChangeListeners) {
listener() listener(config)
}
}
private normalizeInstanceData(data?: InstanceData | null): InstanceData {
const source = data ?? DEFAULT_INSTANCE_DATA
const messageHistory = Array.isArray(source.messageHistory) ? [...source.messageHistory] : []
const agentModelSelections = { ...(source.agentModelSelections ?? {}) }
return {
...source,
messageHistory,
agentModelSelections,
}
}
private setInstanceDataCache(instanceId: string, data: InstanceData) {
const normalized = this.normalizeInstanceData(data)
const previous = this.instanceDataCache.get(instanceId)
if (previous && isDeepEqual(previous, normalized)) {
this.instanceDataCache.set(instanceId, normalized)
return
}
this.instanceDataCache.set(instanceId, normalized)
this.notifyInstanceDataChanged(instanceId, normalized)
}
private notifyInstanceDataChanged(instanceId: string, data: InstanceData) {
const listeners = this.instanceDataListeners.get(instanceId)
if (!listeners) {
return
}
for (const listener of listeners) {
listener(data)
} }
} }
} }

View File

@@ -1,5 +1,5 @@
import { createContext, createSignal, useContext, onMount, createEffect, type JSX } from "solid-js" import { createContext, createEffect, createSignal, onMount, useContext, type JSX } from "solid-js"
import { storage, type ConfigData } from "./storage" import { useConfig } from "../stores/preferences"
interface ThemeContextValue { interface ThemeContextValue {
isDark: () => boolean isDark: () => boolean
@@ -20,89 +20,42 @@ function applyTheme(dark: boolean) {
export function ThemeProvider(props: { children: JSX.Element }) { export function ThemeProvider(props: { children: JSX.Element }) {
const systemPrefersDark = window.matchMedia("(prefers-color-scheme: dark)") const systemPrefersDark = window.matchMedia("(prefers-color-scheme: dark)")
const [isDark, setIsDarkSignal] = createSignal(true) //systemPrefersDark.matches) const { themePreference, setThemePreference } = useConfig()
let themePreference: "system" | "dark" | "light" = "dark" const [isDark, setIsDarkSignal] = createSignal(true)
applyTheme(true) //systemPrefersDark.matches) const resolveDarkTheme = () => {
themePreference()
async function loadTheme() { return true
try {
const config = await storage.loadConfig()
const savedTheme = config.theme
let themeDark: boolean
if (savedTheme === "system") {
themePreference = "system"
themeDark = systemPrefersDark.matches
} else if (savedTheme === "dark") {
themePreference = "dark"
themeDark = true
} else if (savedTheme === "light") {
themePreference = "light"
themeDark = false
} else {
themePreference = "dark"
themeDark = true
}
setIsDarkSignal(themeDark)
applyTheme(themeDark)
} catch (error) {
console.warn("Failed to load theme from config:", error)
themePreference = "dark"
const themeDark = true
setIsDarkSignal(themeDark)
applyTheme(themeDark)
}
} }
async function saveTheme(dark: boolean) { const applyResolvedTheme = () => {
try { const dark = resolveDarkTheme()
const config = await storage.loadConfig() setIsDarkSignal(dark)
const nextPreference = dark ? "dark" : "light" applyTheme(dark)
config.theme = nextPreference
themePreference = nextPreference
await storage.saveConfig(config)
} catch (error) {
console.warn("Failed to save theme to config:", error)
}
} }
createEffect(() => {
applyResolvedTheme()
})
onMount(() => { onMount(() => {
loadTheme() const handleSystemThemeChange = () => {
applyResolvedTheme()
const unsubscribe = storage.onConfigChanged(() => {
loadTheme()
})
// Listen for system theme changes
const handleSystemThemeChange = (event: MediaQueryListEvent) => {
if (themePreference === "system") {
setIsDarkSignal(event.matches)
applyTheme(event.matches)
}
} }
systemPrefersDark.addEventListener("change", handleSystemThemeChange) systemPrefersDark.addEventListener("change", handleSystemThemeChange)
return () => { return () => {
unsubscribe()
systemPrefersDark.removeEventListener("change", handleSystemThemeChange) systemPrefersDark.removeEventListener("change", handleSystemThemeChange)
} }
}) })
createEffect(() => { const setTheme = (_dark: boolean) => {
applyTheme(isDark()) setThemePreference("dark")
})
const setTheme = (dark: boolean) => {
setIsDarkSignal(dark)
applyTheme(dark)
saveTheme(dark)
} }
const toggleTheme = () => { const toggleTheme = () => {
setTheme(!isDark()) setTheme(true)
} }
return <ThemeContext.Provider value={{ isDark, toggleTheme, setTheme }}>{props.children}</ThemeContext.Provider> return <ThemeContext.Provider value={{ isDark, toggleTheme, setTheme }}>{props.children}</ThemeContext.Provider>

View File

@@ -2,6 +2,8 @@ import { render } from "solid-js/web"
import App from "./App" import App from "./App"
import { ThemeProvider } from "./lib/theme" import { ThemeProvider } from "./lib/theme"
import { ConfigProvider } from "./stores/preferences" import { ConfigProvider } from "./stores/preferences"
import { InstanceConfigProvider } from "./stores/instance-config"
import { runtimeEnv } from "./lib/runtime-env"
import "./index.css" import "./index.css"
import "@git-diff-view/solid/styles/diff-view-pure.css" import "@git-diff-view/solid/styles/diff-view-pure.css"
@@ -11,12 +13,19 @@ if (!root) {
throw new Error("Root element not found") throw new Error("Root element not found")
} }
if (typeof document !== "undefined") {
document.documentElement.dataset.runtimeHost = runtimeEnv.host
document.documentElement.dataset.runtimePlatform = runtimeEnv.platform
}
render( render(
() => ( () => (
<ConfigProvider> <ConfigProvider>
<ThemeProvider> <InstanceConfigProvider>
<App /> <ThemeProvider>
</ThemeProvider> <App />
</ThemeProvider>
</InstanceConfigProvider>
</ConfigProvider> </ConfigProvider>
), ),
root, root,

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