Compare commits

...

65 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
74 changed files with 13005 additions and 611 deletions

View File

@@ -17,6 +17,7 @@ on:
type: string
permissions:
id-token: write
contents: write
env:
@@ -24,7 +25,7 @@ env:
jobs:
build-macos:
runs-on: macos-13
runs-on: macos-15-intel
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ inputs.version }}
@@ -48,26 +49,21 @@ jobs:
- name: Ensure rollup native binary
run: npm install @rollup/rollup-darwin-x64 --no-save
- name: Build macOS binaries
- 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/*; do
for file in packages/electron-app/release/*.zip; do
[ -f "$file" ] || continue
case "$file" in
*.dmg|*.zip)
gh release upload "$TAG" "$file" --clobber
;;
*)
echo "Skipping non-installer asset: $file"
;;
esac
echo "Uploading $file"
gh release upload "$TAG" "$file" --clobber
done
build-windows:
runs-on: windows-latest
runs-on: windows-2025
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ inputs.version }}
@@ -92,20 +88,19 @@ jobs:
- name: Ensure rollup native binary
run: npm install @rollup/rollup-win32-x64-msvc --no-save
- name: Build Windows binaries
- 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" -File | Where-Object {
$_.Name -match '\\.(exe|zip)$'
} | ForEach-Object {
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-latest
runs-on: ubuntu-24.04
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ inputs.version }}
@@ -129,26 +124,358 @@ jobs:
- name: Ensure rollup native binary
run: npm install @rollup/rollup-linux-x64-gnu --no-save
- name: Build Linux binaries
- 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/*; do
for file in packages/electron-app/release/*.zip; do
[ -f "$file" ] || continue
case "$file" in
*.AppImage|*.deb|*.tar.gz)
gh release upload "$TAG" "$file" --clobber
;;
*)
echo "Skipping non-installer asset: $file"
;;
esac
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-latest
runs-on: ubuntu-24.04
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ inputs.version }}
@@ -183,8 +510,10 @@ jobs:
- 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

View File

@@ -4,6 +4,7 @@ on:
workflow_dispatch:
permissions:
id-token: write
contents: write
env:
@@ -30,8 +31,7 @@ jobs:
run: |
BASE_VERSION=$(node -p "require('./package.json').version")
DEV_VERSION="${BASE_VERSION}-dev"
TIMESTAMP=$(date -u +%y%m%d-%H%M)
TAG="v${DEV_VERSION}-${TIMESTAMP}"
TAG="v${DEV_VERSION}"
echo "version=$DEV_VERSION" >> "$GITHUB_OUTPUT"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "release_name=$TAG" >> "$GITHUB_OUTPUT"
@@ -57,30 +57,9 @@ jobs:
secrets: inherit
publish-server:
needs: build-and-upload
runs-on: ubuntu-latest
env:
NODE_VERSION: 20
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
VERSION: ${{ needs.prepare-dev.outputs.version }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm
- name: Set workspace versions
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
- name: Install dependencies
run: npm ci --workspaces
- name: Build server package
run: npm run build --workspace @neuralnomads/codenomad
- name: Publish server package to dev tag
run: npm publish --workspace @neuralnomads/codenomad --access public --tag dev
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
permissions:
id-token: write
contents: write
env:
@@ -73,26 +74,9 @@ jobs:
secrets: inherit
publish-server:
needs: build-and-upload
runs-on: ubuntu-latest
env:
NODE_VERSION: 20
NODE_AUTH_TOKEN: ${{ secrets.NPM_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 server package
run: npm run build --workspace @neuralnomads/codenomad
- name: Publish server package
run: npm publish --workspace @neuralnomads/codenomad --access public --tag latest
needs: prepare-release
uses: ./.github/workflows/manual-npm-publish.yml
with:
version: ${{ needs.prepare-release.outputs.version }}
dist_tag: latest
secrets: inherit

View File

@@ -13,10 +13,10 @@ _Manage multiple OpenCode sessions side-by-side._
![Command palette overlay](docs/screenshots/command-palette.png)
_Global command palette for keyboard-first control._
![Image Previews](images/image-previews.png)
![Image Previews](docs/screenshots/image-previews.png)
_Rich media previews for images and assets._
![Browser Support](images/browser-support.png)
![Browser Support](docs/screenshots/browser-support.png)
_Browser support via CodeNomad Server._
</details>
@@ -26,11 +26,17 @@ _Browser support via CodeNomad Server._
Choose the way that fits your workflow:
### 🖥️ Desktop App (Recommended)
The best experience. A native application with global shortcuts, deeper system integration, and a dedicated window.
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.

Binary file not shown.

After

Width:  |  Height:  |  Size: 845 KiB

View File

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

298
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "codenomad-workspace",
"version": "0.1.2",
"version": "0.2.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "codenomad-workspace",
"version": "0.1.2",
"version": "0.2.3",
"dependencies": {
"7zip-bin": "^5.2.0",
"google-auth-library": "^10.5.0"
@@ -313,12 +313,8 @@
"node": ">=6.9.0"
}
},
"node_modules/@neuralnomads/codenomad": {
"resolved": "packages/server",
"link": true
},
"node_modules/@neuralnomads/codenomad-electron-app": {
"resolved": "packages/electron-app",
"node_modules/@codenomad/tauri-app": {
"resolved": "packages/tauri-app",
"link": true
},
"node_modules/@codenomad/ui": {
@@ -1233,6 +1229,14 @@
"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": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -1549,6 +1553,223 @@
"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": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz",
@@ -8390,9 +8611,37 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"packages/electron-app": {
"name": "@neuralnomads/codenomad-electron-app",
"version": "0.2.3",
"dependencies": {
"@codenomad/ui": "file:../ui",
"@neuralnomads/codenomad": "file:../server"
},
"devDependencies": {
"7zip-bin": "^5.2.0",
"app-builder-bin": "^4.2.0",
"electron": "39.0.0",
"electron-builder": "^24.0.0",
"electron-vite": "4.0.1",
"png2icons": "^2.0.1",
"pngjs": "^7.0.0",
"tsx": "^4.20.6",
"typescript": "^5.3.0",
"vite": "^5.0.0",
"vite-plugin-solid": "^2.10.0"
}
},
"packages/electron-app/node_modules/app-builder-bin": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/app-builder-bin/-/app-builder-bin-4.2.0.tgz",
"integrity": "sha512-PGXlkukQnroTgAaDZnnppdLzsRJmab6Rh/rJ5fKyYaYhd+FfaORH59/ArkB5dr2cAeYQU5lCeHFEwURaoBO8BA==",
"dev": true,
"license": "MIT"
},
"packages/server": {
"name": "@neuralnomads/codenomad",
"version": "0.1.0",
"version": "0.2.3",
"dependencies": {
"@fastify/cors": "^8.5.0",
"@fastify/reply-from": "^9.8.0",
@@ -8429,37 +8678,16 @@
"integrity": "sha512-Api1mJL+Ad7W7vnDZnWq5pGaXJjyencT+iKGia2PlHUcSsSzWwIQ3S1isiMpwpavjYtGd2FzhUIhnnhOULZgDw==",
"license": "MIT"
},
"packages/electron-app": {
"name": "@neuralnomads/codenomad-electron-app",
"version": "0.1.2",
"dependencies": {
"@neuralnomads/codenomad": "file:../server",
"@codenomad/ui": "file:../ui"
},
"packages/tauri-app": {
"name": "@codenomad/tauri-app",
"version": "0.2.3",
"devDependencies": {
"7zip-bin": "^5.2.0",
"app-builder-bin": "^4.2.0",
"electron": "39.0.0",
"electron-builder": "^24.0.0",
"electron-vite": "4.0.1",
"png2icons": "^2.0.1",
"pngjs": "^7.0.0",
"tsx": "^4.20.6",
"typescript": "^5.3.0",
"vite": "^5.0.0",
"vite-plugin-solid": "^2.10.0"
"@tauri-apps/cli": "^2.9.4"
}
},
"packages/electron-app/node_modules/app-builder-bin": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/app-builder-bin/-/app-builder-bin-4.2.0.tgz",
"integrity": "sha512-PGXlkukQnroTgAaDZnnppdLzsRJmab6Rh/rJ5fKyYaYhd+FfaORH59/ArkB5dr2cAeYQU5lCeHFEwURaoBO8BA==",
"dev": true,
"license": "MIT"
},
"packages/ui": {
"name": "@codenomad/ui",
"version": "0.1.2",
"version": "0.2.3",
"dependencies": {
"@git-diff-view/solid": "^0.0.8",
"@kobalte/core": "0.13.11",

View File

@@ -1,6 +1,6 @@
{
"name": "codenomad-workspace",
"version": "0.2.0",
"version": "0.2.3",
"private": true,
"description": "CodeNomad monorepo workspace",
"workspaces": {

View File

@@ -6,6 +6,7 @@ const uiRoot = resolve(__dirname, "../ui")
const uiSrc = resolve(uiRoot, "src")
const uiRendererRoot = resolve(uiRoot, "src/renderer")
const uiRendererEntry = resolve(uiRendererRoot, "index.html")
const uiRendererLoadingEntry = resolve(uiRendererRoot, "loading.html")
export default defineConfig({
main: {
@@ -54,7 +55,10 @@ export default defineConfig({
build: {
outDir: resolve(__dirname, "dist/renderer"),
rollupOptions: {
input: uiRendererEntry,
input: {
main: uiRendererEntry,
loading: uiRendererLoadingEntry,
},
},
},
},

View File

@@ -1,5 +1,17 @@
import { BrowserWindow, ipcMain } from "electron"
import type { CliLogEntry, CliProcessManager, CliStatus } from "./process-manager"
import { BrowserWindow, dialog, ipcMain, type OpenDialogOptions } from "electron"
import type { CliProcessManager, CliStatus } from "./process-manager"
interface DialogOpenRequest {
mode: "directory" | "file"
title?: string
defaultPath?: string
filters?: Array<{ name?: string; extensions: string[] }>
}
interface DialogOpenResult {
canceled: boolean
paths: string[]
}
export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessManager) {
cliManager.on("status", (status: CliStatus) => {
@@ -14,12 +26,6 @@ export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessMan
}
})
cliManager.on("log", (entry: CliLogEntry) => {
if (!mainWindow.isDestroyed()) {
mainWindow.webContents.send("cli:log", entry)
}
})
cliManager.on("error", (error: Error) => {
if (!mainWindow.isDestroyed()) {
mainWindow.webContents.send("cli:error", { message: error.message })
@@ -27,4 +33,27 @@ export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessMan
})
ipcMain.handle("cli:getStatus", async () => cliManager.getStatus())
ipcMain.handle("dialog:open", async (_, request: DialogOpenRequest): Promise<DialogOpenResult> => {
const properties: OpenDialogOptions["properties"] =
request.mode === "directory" ? ["openDirectory", "createDirectory"] : ["openFile"]
const filters = request.filters?.map((filter) => ({
name: filter.name ?? "Files",
extensions: filter.extensions,
}))
const windowTarget = mainWindow.isDestroyed() ? undefined : mainWindow
const dialogOptions: OpenDialogOptions = {
title: request.title,
defaultPath: request.defaultPath,
properties,
filters,
}
const result = windowTarget
? await dialog.showOpenDialog(windowTarget, dialogOptions)
: await dialog.showOpenDialog(dialogOptions)
return { canceled: result.canceled, paths: result.filePaths }
})
}

View File

@@ -30,22 +30,63 @@ function getIconPath() {
return join(mainDirname, "../resources/icon.png")
}
function getLoadingHtmlPath() {
type LoadingTarget =
| { type: "url"; source: string }
| { type: "file"; source: string }
function resolveDevLoadingUrl(): string | null {
if (app.isPackaged) {
return join(process.resourcesPath, "loading.html")
return null
}
const devBase = process.env.VITE_DEV_SERVER_URL || process.env.ELECTRON_RENDERER_URL
if (!devBase) {
return null
}
const distResources = join(mainDirname, "../resources/loading.html")
if (existsSync(distResources)) {
return distResources
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
}
}
const devResources = join(mainDirname, "../electron/resources/loading.html")
if (existsSync(devResources)) {
return devResources
}
return join(app.getAppPath(), "dist/renderer/loading.html")
}
return join(process.cwd(), "electron/resources/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
@@ -116,10 +157,9 @@ function createWindow() {
mainWindow.webContents.session.setSpellCheckerEnabled(false)
}
const loadingHtml = getLoadingHtmlPath()
showingLoadingScreen = true
currentCliUrl = null
mainWindow.loadFile(loadingHtml).catch((error) => console.error("[cli] failed to load loading screen:", error))
loadLoadingScreen(mainWindow)
if (process.env.NODE_ENV === "development") {
mainWindow.webContents.openDevTools({ mode: "detach" })
@@ -156,8 +196,7 @@ function showLoadingScreen(force = false) {
showingLoadingScreen = true
currentCliUrl = null
pendingCliUrl = null
const loadingHtml = getLoadingHtmlPath()
mainWindow.loadFile(loadingHtml).catch((error) => console.error("[cli] failed to load loading screen:", error))
loadLoadingScreen(mainWindow)
}
function startCliPreload(url: string) {

View File

@@ -263,47 +263,32 @@ export class CliProcessManager extends EventEmitter {
private resolveCliEntry(options: StartOptions): CliEntryResolution {
if (options.dev) {
const tsxPath = this.resolveTsx()
const sourceCandidates = [
path.resolve(app.getAppPath(), "..", "server", "src", "index.ts"),
path.resolve(app.getAppPath(), "..", "packages", "server", "src", "index.ts"),
path.resolve(process.cwd(), "packages", "server", "src", "index.ts"),
]
const sourceEntry = sourceCandidates.find((candidate) => existsSync(candidate))
if (tsxPath && sourceEntry) {
return { entry: sourceEntry, runner: "tsx", runnerPath: tsxPath }
if (!tsxPath) {
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 dist = this.tryResolveDist()
if (dist) {
return { entry: dist, runner: "node" }
}
throw new Error("Unable to locate CodeNomad CLI build (dist/bin.js). Please build @neuralnomads/codenomad.")
const distEntry = this.resolveProdEntry()
return { entry: distEntry, runner: "node" }
}
private resolveTsx(): string | null {
try {
const resolved = nodeRequire.resolve("tsx/dist/cli.js")
if (resolved && existsSync(resolved)) {
return resolved
}
} catch {
return null
}
return null
}
private tryResolveDist(): string | null {
const candidates: Array<string | (() => string)> = [
() => nodeRequire.resolve("@neuralnomads/codenomad/dist/bin.js"),
() => nodeRequire.resolve("@neuralnomads/codenomad/dist/bin.js", { paths: [app.getAppPath()] }),
path.join(app.getAppPath(), "node_modules", "@neuralnomads", "codenomad", "dist", "bin.js"),
path.resolve(app.getAppPath(), "..", "server", "dist", "bin.js"),
path.resolve(app.getAppPath(), "..", "packages", "server", "dist", "bin.js"),
path.join(process.resourcesPath, "app.asar.unpacked", "node_modules", "@neuralnomads", "codenomad", "dist", "bin.js"),
() => 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
@@ -314,7 +299,28 @@ export class CliProcessManager extends EventEmitter {
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.")
}
}

View File

@@ -5,15 +5,12 @@ const electronAPI = {
ipcRenderer.on("cli:status", (_, data) => callback(data))
return () => ipcRenderer.removeAllListeners("cli:status")
},
onCliLog: (callback) => {
ipcRenderer.on("cli:log", (_, data) => callback(data))
return () => ipcRenderer.removeAllListeners("cli:log")
},
onCliError: (callback) => {
ipcRenderer.on("cli:error", (_, data) => callback(data))
return () => ipcRenderer.removeAllListeners("cli:error")
},
getCliStatus: () => ipcRenderer.invoke("cli:getStatus"),
openDialog: (options) => ipcRenderer.invoke("dialog:open", options),
}
contextBridge.exposeInMainWorld("electronAPI", electronAPI)

View File

@@ -1,206 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CodeNomad</title>
<style>
:root {
color-scheme: dark;
}
body {
margin: 0;
min-height: 100vh;
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background-color: #1a1a1a;
color: #cfd4dc;
display: flex;
align-items: center;
justify-content: center;
padding: 32px;
text-align: center;
}
button {
border: none;
background: none;
font: inherit;
color: inherit;
}
.wrapper {
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
max-width: 520px;
}
.logo {
width: 180px;
height: auto;
filter: drop-shadow(0 15px 40px rgba(0, 0, 0, 0.35));
}
.title {
font-size: 2.7rem;
font-weight: 600;
margin: 0;
color: #f4f6fb;
}
.loading-card {
margin-top: 12px;
width: 100%;
max-width: 420px;
padding: 22px;
border-radius: 18px;
background: #151a23;
border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.45);
}
.loading-row {
display: flex;
align-items: center;
justify-content: center;
gap: 14px;
font-size: 0.95rem;
color: #cfd4dc;
}
.spinner {
width: 18px;
height: 18px;
border-radius: 50%;
border: 2px solid rgba(255, 255, 255, 0.18);
border-top-color: #6ce3ff;
animation: spin 0.9s linear infinite;
}
.phrase-controls {
margin-top: 12px;
font-size: 0.9rem;
color: #8f96a9;
display: flex;
justify-content: center;
gap: 8px;
}
.phrase-controls button {
color: #8fb5ff;
cursor: pointer;
}
.logo {
width: 180px;
height: auto;
filter: drop-shadow(0 15px 40px rgba(0, 0, 0, 0.45));
}
.title {
font-size: 2.7rem;
font-weight: 600;
margin: 0;
color: #f4f6fb;
}
.loading-card {
margin-top: 12px;
width: 100%;
padding: 22px;
border-radius: 18px;
background: #0f1421;
border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow: 0 25px 60px rgba(5, 6, 10, 0.6);
}
.wrapper {
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
max-width: 520px;
}
.logo {
width: 180px;
height: auto;
}
.title {
font-size: 2.7rem;
font-weight: 600;
margin: 0;
}
.subtitle {
margin: 0;
font-size: 1.1rem;
color: #aeb3c4;
}
.loading-card {
margin-top: 12px;
width: 100%;
padding: 20px;
border-radius: 14px;
background: rgba(13, 16, 24, 0.8);
border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow: 0 25px 60px rgba(0, 0, 0, 0.45);
}
.loading-row {
display: flex;
align-items: center;
justify-content: center;
gap: 14px;
font-size: 0.95rem;
color: #cad0dd;
}
.spinner {
width: 18px;
height: 18px;
border-radius: 50%;
border: 2px solid rgba(255, 255, 255, 0.18);
border-top-color: #6ce3ff;
animation: spin 0.9s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
</style>
</head>
<body>
<div class="wrapper" role="status" aria-live="polite">
<img src="./icon.png" alt="CodeNomad" class="logo" />
<div>
<h1 class="title">CodeNomad</h1>
</div>
<div class="loading-card">
<div class="loading-row">
<div class="spinner" aria-hidden="true"></div>
<span id="loading-phrase">Warming up the AI neurons…</span>
</div>
<div class="phrase-controls">
<button id="phrase-toggle" type="button">Show another</button>
</div>
</div>
</div>
<script>
const phrases = [
"Warming up the AI neurons…",
"Convincing the AI to stop daydreaming…",
"Polishing the AIs code goggles…",
"Asking the AI to stop reorganizing your files…",
"Feeding the AI additional coffee…",
"Teaching the AI not to delete node_modules (again)…",
"Telling the AI to act natural before you arrive…",
"Asking the AI to please stop rewriting history…",
"Letting the AI stretch before its coding sprint…",
"Persuading the AI to give you keyboard control…"
]
const phraseEl = document.getElementById("loading-phrase")
const button = document.getElementById("phrase-toggle")
function pickPhrase() {
const next = phrases[Math.floor(Math.random() * phrases.length)]
phraseEl.textContent = next
}
pickPhrase()
button?.addEventListener("click", pickPhrase)
</script>
</body>
</html>

View File

@@ -1,6 +1,6 @@
{
"name": "@neuralnomads/codenomad-electron-app",
"version": "0.2.0",
"version": "0.2.3",
"description": "CodeNomad - AI coding assistant",
"author": {
"name": "Neural Nomads",
@@ -65,42 +65,51 @@
{
"from": "electron/resources",
"to": "",
"filter": ["!icon.icns", "!icon.ico", "!icon.png"]
"filter": [
"!icon.icns",
"!icon.ico"
]
}
],
"mac": {
"category": "public.app-category.developer-tools",
"target": [
{
"target": "dmg",
"arch": ["x64", "arm64", "universal"]
},
{
"target": "zip",
"arch": ["x64", "arm64", "universal"]
"arch": [
"x64",
"arm64"
]
}
],
"artifactName": "CodeNomadApp-${version}-${os}-${arch}.${ext}",
"artifactName": "CodeNomad-${version}-${os}-${arch}.${ext}",
"icon": "electron/resources/icon.icns"
},
"dmg": {
"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": {
"target": [
{
"target": "nsis",
"arch": ["x64", "arm64"]
},
{
"target": "zip",
"arch": ["x64", "arm64"]
"arch": [
"x64",
"arm64"
]
}
],
"artifactName": "CodeNomadApp-${version}-${os}-${arch}.${ext}",
"artifactName": "CodeNomad-${version}-${os}-${arch}.${ext}",
"icon": "electron/resources/icon.ico"
},
"nsis": {
@@ -112,23 +121,14 @@
"linux": {
"target": [
{
"target": "AppImage",
"arch": ["x64", "arm64"]
},
{
"target": "deb",
"arch": ["x64", "arm64"]
},
{
"target": "rpm",
"arch": ["x64", "arm64"]
},
{
"target": "tar.gz",
"arch": ["x64", "arm64"]
"target": "zip",
"arch": [
"x64",
"arm64"
]
}
],
"artifactName": "CodeNomadApp-${version}-${os}-${arch}.${ext}",
"artifactName": "CodeNomad-${version}-${os}-${arch}.${ext}",
"category": "Development",
"icon": "electron/resources/icon.png"
}

View File

@@ -16,8 +16,8 @@ const workspaceNodeModulesPath = join(workspaceRoot, "node_modules")
const platforms = {
mac: {
args: ["--mac", "--x64", "--arm64", "--universal"],
description: "macOS (Intel, Apple Silicon, Universal)",
args: ["--mac", "--x64", "--arm64"],
description: "macOS (Intel & Apple Silicon)",
},
"mac-x64": {
args: ["--mac", "--x64"],

View File

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

View File

@@ -1,11 +1,15 @@
{
"name": "@neuralnomads/codenomad",
"version": "0.2.0",
"version": "0.2.3",
"description": "CodeNomad Server",
"author": {
"name": "Neural Nomads",
"email": "codenomad@neuralnomads.ai"
},
"repository": {
"type": "git",
"url": "https://github.com/NeuralNomadsAI/CodeNomad.git"
},
"type": "module",
"main": "dist/index.js",
"bin": {

View File

@@ -111,6 +111,14 @@ export interface InstanceData {
agentModelSelections: AgentModelSelection
}
export type InstanceStreamStatus = "connecting" | "connected" | "error" | "disconnected"
export interface InstanceStreamEvent {
type: string
properties?: Record<string, unknown>
[key: string]: unknown
}
export interface BinaryRecord {
id: string
path: string
@@ -157,6 +165,8 @@ export type WorkspaceEventType =
| "config.appChanged"
| "config.binariesChanged"
| "instance.dataChanged"
| "instance.event"
| "instance.eventStatus"
export type WorkspaceEventPayload =
| { type: "workspace.created"; workspace: WorkspaceDescriptor }
@@ -167,6 +177,8 @@ export type WorkspaceEventPayload =
| { type: "config.appChanged"; config: AppConfig }
| { 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 {
/** Base URL clients should target for REST calls (useful for Electron embedding). */

View File

@@ -8,7 +8,9 @@ export class EventBus extends EventEmitter {
}
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)
}
@@ -22,6 +24,8 @@ export class EventBus extends EventEmitter {
this.on("config.appChanged", handler)
this.on("config.binariesChanged", handler)
this.on("instance.dataChanged", handler)
this.on("instance.event", handler)
this.on("instance.eventStatus", handler)
return () => {
this.off("workspace.created", handler)
this.off("workspace.started", handler)
@@ -31,6 +35,8 @@ export class EventBus extends EventEmitter {
this.off("config.appChanged", handler)
this.off("config.binariesChanged", handler)
this.off("instance.dataChanged", handler)
this.off("instance.event", handler)
this.off("instance.eventStatus", handler)
}
}
}

View File

@@ -14,10 +14,12 @@ import { FileSystemBrowser } from "./filesystem/browser"
import { EventBus } from "./events/bus"
import { ServerMeta } from "./api-types"
import { InstanceStore } from "./storage/instance-store"
import { InstanceEventBridge } from "./workspaces/instance-events"
import { createLogger } from "./logger"
import { launchInBrowser } from "./launcher"
const require = createRequire(import.meta.url)
const packageJson = require("../package.json") as { version: string }
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
@@ -121,6 +123,11 @@ async function main() {
})
const fileSystemBrowser = new FileSystemBrowser({ rootDir: options.rootDir, unrestricted: options.unrestrictedRoot })
const instanceStore = new InstanceStore()
const instanceEventBridge = new InstanceEventBridge({
workspaceManager,
eventBus,
logger: logger.child({ component: "instance-events" }),
})
const serverMeta: ServerMeta = {
httpBaseUrl: `http://${options.host}:${options.port}`,
@@ -169,6 +176,7 @@ async function main() {
}
try {
instanceEventBridge.shutdown()
await workspaceManager.shutdown()
logger.info("Workspace manager shutdown complete")
} catch (error) {

View File

@@ -65,6 +65,12 @@ export function createHttpServer(deps: HttpServerDeps) {
app.register(replyFrom, {
contentTypesToEncode: [],
undici: {
connections: 16,
pipelining: 1,
bodyTimeout: 0,
headersTimeout: 0,
},
})
registerWorkspaceRoutes(app, { workspaceManager: deps.workspaceManager })

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,4 +1,5 @@
import path from "path"
import { spawnSync } from "child_process"
import { EventBus } from "../events/bus"
import { ConfigStore } from "../config/store"
import { BinaryRegistry } from "../config/binaries"
@@ -65,10 +66,11 @@ export class WorkspaceManager {
const id = `${Date.now().toString(36)}`
const binary = this.options.binaryRegistry.resolveDefault()
const resolvedBinaryPath = this.resolveBinaryPath(binary.path)
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`
@@ -79,14 +81,20 @@ export class WorkspaceManager {
name,
status: "starting",
proxyPath,
binaryId: binary.id,
binaryId: resolvedBinaryPath,
binaryLabel: binary.label,
binaryVersion: binary.version,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}
if (!descriptor.binaryVersion) {
descriptor.binaryVersion = this.detectBinaryVersion(resolvedBinaryPath)
}
this.workspaces.set(id, descriptor)
this.options.eventBus.publish({ type: "workspace.created", workspace: descriptor })
const environment = this.options.configStore.get().preferences.environmentVariables ?? {}
@@ -95,7 +103,7 @@ export class WorkspaceManager {
const { pid, port } = await this.runtime.launch({
workspaceId: id,
folder: workspacePath,
binaryPath: binary.path,
binaryPath: resolvedBinaryPath,
environment,
onExit: (info) => this.handleProcessExit(info.workspaceId, info),
})
@@ -161,6 +169,70 @@ export class WorkspaceManager {
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 }) {
const workspace = this.workspaces.get(workspaceId)
if (!workspace) return

View File

@@ -37,7 +37,10 @@ export class WorkspaceRuntime {
const env = { ...process.env, ...(options.environment ?? {}) }
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, {
cwd: options.folder,
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"]
}
}

View File

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

View File

@@ -1,7 +1,9 @@
import { Component, Show, createMemo, createEffect, createSignal } from "solid-js"
import { Dialog } from "@kobalte/core/dialog"
import { Toaster } from "solid-toast"
import AlertDialog from "./components/alert-dialog"
import FolderSelectionView from "./components/folder-selection-view"
import { showConfirmDialog } from "./stores/alerts"
import InstanceTabs from "./components/instance-tabs"
import InstanceDisconnectedModal from "./components/instance-disconnected-modal"
import InstanceShell from "./components/instance/instance-shell"
@@ -135,11 +137,21 @@ const App: Component = () => {
}
async function handleCloseInstance(instanceId: string) {
if (confirm("Stop OpenCode instance? This will stop the server.")) {
await stopInstance(instanceId)
if (instances().size === 0) {
setHasInstances(false)
}
const confirmed = await showConfirmDialog(
"Stop OpenCode instance? This will stop the server.",
{
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>
</Show>
<AlertDialog />
<Toaster
position="top-right"
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

@@ -4,6 +4,7 @@ import { useConfig } from "../stores/preferences"
import AdvancedSettingsModal from "./advanced-settings-modal"
import DirectoryBrowserDialog from "./directory-browser-dialog"
import Kbd from "./kbd"
import { openNativeFolderDialog, supportsNativeDialogs } from "../lib/native/native-functions"
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
@@ -21,6 +22,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
const [focusMode, setFocusMode] = createSignal<"recent" | "new" | null>("recent")
const [selectedBinary, setSelectedBinary] = createSignal(preferences().lastUsedBinary || "opencode")
const [isFolderBrowserOpen, setIsFolderBrowserOpen] = createSignal(false)
const nativeDialogsAvailable = supportsNativeDialogs()
let recentListRef: HTMLDivElement | undefined
const folders = () => recentFolders()
@@ -29,9 +31,8 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
// Update selected binary when preferences change
createEffect(() => {
const lastUsed = preferences().lastUsedBinary
if (lastUsed && lastUsed !== selectedBinary()) {
setSelectedBinary(lastUsed)
}
if (!lastUsed) return
setSelectedBinary((current) => (current === lastUsed ? current : lastUsed))
})
@@ -78,7 +79,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
if (isBrowseShortcut) {
e.preventDefault()
handleBrowse()
void handleBrowse()
return
}
@@ -172,9 +173,20 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
props.onSelectFolder(path, selectedBinary())
}
function handleBrowse() {
async function handleBrowse() {
if (isLoading()) return
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)
}
@@ -313,7 +325,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
<div class="panel-body">
<button
onClick={handleBrowse}
onClick={() => void handleBrowse()}
disabled={props.isLoading}
class="button-primary w-full flex items-center justify-center text-sm disabled:cursor-not-allowed"
onMouseEnter={() => setFocusMode("new")}

View File

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

View File

@@ -3,6 +3,7 @@ import { FolderOpen, Trash2, Check, AlertCircle, Loader2, Plus } from "lucide-so
import { useConfig } from "../stores/preferences"
import { serverApi } from "../lib/api-client"
import FileSystemBrowserDialog from "./filesystem-browser-dialog"
import { openNativeFileDialog, supportsNativeDialogs } from "../lib/native/native-functions"
interface BinaryOption {
path: string
@@ -32,8 +33,10 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
const [versionInfo, setVersionInfo] = createSignal<Map<string, string>>(new Map<string, string>())
const [validatingPaths, setValidatingPaths] = createSignal<Set<string>>(new Set<string>())
const [isBinaryBrowserOpen, setIsBinaryBrowserOpen] = createSignal(false)
const nativeDialogsAvailable = supportsNativeDialogs()
const binaries = () => opencodeBinaries()
const lastUsedBinary = () => preferences().lastUsedBinary
const customBinaries = createMemo(() => binaries().filter((binary) => binary.path !== "opencode"))
@@ -128,9 +131,19 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
}
}
function handleBrowseBinary() {
async function handleBrowseBinary() {
if (props.disabled) return
setValidationError(null)
if (nativeDialogsAvailable) {
const selected = await openNativeFileDialog({
title: "Select OpenCode Binary",
})
if (selected) {
setCustomPath(selected)
void handleValidateAndAdd(selected)
}
return
}
setIsBinaryBrowserOpen(true)
}
@@ -245,7 +258,7 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
<button
type="button"
onClick={handleBrowseBinary}
onClick={() => void handleBrowseBinary()}
disabled={props.disabled}
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 { getActiveInstance } from "../stores/instances"
import { agents, getSessionDraftPrompt, setSessionDraftPrompt, clearSessionDraftPrompt } from "../stores/sessions"
import { showAlertDialog } from "../stores/alerts"
interface PromptInputProps {
instanceId: string
@@ -24,6 +25,7 @@ interface PromptInputProps {
export default function PromptInput(props: PromptInputProps) {
const [prompt, setPromptInternal] = createSignal("")
const [history, setHistory] = createSignal<string[]>([])
const HISTORY_LIMIT = 100
const [historyIndex, setHistoryIndex] = createSignal(-1)
const [historyDraft, setHistoryDraft] = createSignal<string | null>(null)
const [, setIsFocused] = createSignal(false)
@@ -498,11 +500,27 @@ export default function PromptInput(props: PromptInputProps) {
async function handleSend() {
const text = prompt().trim()
const currentAttachments = attachments()
if (props.disabled || !text) return
if (props.disabled || (!text && currentAttachments.length === 0)) return
const resolvedPrompt = resolvePastedPlaceholders(text, currentAttachments)
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()
clearAttachments(props.instanceId, props.sessionId)
setIgnoredAtPositions(new Set<number>())
@@ -511,10 +529,6 @@ export default function PromptInput(props: PromptInputProps) {
setHistoryDraft(null)
try {
await addToHistory(props.instanceFolder, resolvedPrompt)
const updated = await getHistory(props.instanceFolder)
setHistory(updated)
setHistoryIndex(-1)
if (isShellMode) {
if (props.onRunShell) {
await props.onRunShell(resolvedPrompt)
@@ -522,11 +536,16 @@ export default function PromptInput(props: PromptInputProps) {
await props.onSend(resolvedPrompt, [])
}
} else {
await props.onSend(text, currentAttachments)
await props.onSend(resolvedPrompt, currentAttachments)
}
void refreshHistory()
} catch (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 {
textareaRef?.focus()
}

View File

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

View File

@@ -1,5 +1,6 @@
import type { Command } from "./commands"
import type { Command as SDKCommand } from "@opencode-ai/sdk"
import { showAlertDialog } from "../stores/alerts"
import { activeSessionId, executeCustomCommand } from "../stores/sessions"
export function commandRequiresArguments(template?: string): boolean {
@@ -33,7 +34,10 @@ export function buildCustomCommandEntries(instanceId: string, commands: SDKComma
action: async () => {
const sessionId = activeSessionId().get(instanceId)
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
}
const args = promptForCommandArguments(cmd)
@@ -44,7 +48,10 @@ export function buildCustomCommandEntries(instanceId: string, commands: SDKComma
await executeCustomCommand(instanceId, sessionId, cmd.name, args)
} catch (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,
} from "../../stores/sessions"
import { setSessionCompactionState } from "../../stores/session-compaction"
import { showAlertDialog } from "../../stores/alerts"
import type { Instance } from "../../types/instance"
export interface UseCommandsOptions {
@@ -207,7 +208,10 @@ export function useCommands(options: UseCommandsOptions) {
setSessionCompactionState(instance.id, sessionId, false)
console.error("Failed to compact session:", error)
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) {
alert("Nothing to undo")
showAlertDialog("Nothing to undo", {
title: "No actions to undo",
variant: "info",
})
return
}
@@ -282,7 +289,10 @@ export function useCommands(options: UseCommandsOptions) {
}
} catch (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

@@ -14,16 +14,15 @@ import type {
EventSessionIdle,
EventSessionUpdated,
} 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 {
instanceId: string
proxyPath: string
eventSource: EventSource
status: "connecting" | "connected" | "disconnected" | "error"
reconnectAttempts: number
reconnectTimer?: ReturnType<typeof setTimeout>
}
type InstanceEventPayload = Extract<WorkspaceEventPayload, { type: "instance.event" }>
type InstanceStatusPayload = Extract<WorkspaceEventPayload, { type: "instance.eventStatus" }>
interface TuiToastEvent {
type: "tui.toast.show"
@@ -35,7 +34,7 @@ interface TuiToastEvent {
}
}
type SSEEvent =
type SSEEvent =
| MessageUpdateEvent
| MessageRemovedEvent
| MessagePartUpdatedEvent
@@ -48,73 +47,43 @@ type SSEEvent =
| EventPermissionReplied
| EventLspUpdated
| TuiToastEvent
| { type: string; properties?: Record<string, unknown> } // Fallback for unknown event types
| { type: string; properties?: Record<string, unknown> }
const [connectionStatus, setConnectionStatus] = createSignal<
Map<string, "connecting" | "connected" | "disconnected" | "error">
>(new Map())
type ConnectionStatus = InstanceStreamStatus
const [connectionStatus, setConnectionStatus] = createSignal<Map<string, ConnectionStatus>>(new Map())
class SSEManager {
private connections = new Map<string, SSEConnection>()
private static readonly MAX_RECONNECT_DELAY_MS = 5000
connect(instanceId: string, proxyPath: string, reconnectAttempts = 0): void {
const existing = this.connections.get(instanceId)
if (existing) {
this.clearReconnectTimer(existing)
existing.eventSource.close()
}
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)
constructor() {
serverEvents.on("instance.eventStatus", (event) => {
const payload = event as InstanceStatusPayload
this.updateConnectionStatus(payload.instanceId, payload.status)
if (payload.status === "disconnected") {
if (payload.reason === "workspace stopped") {
return
}
const reason = payload.reason ?? "Instance disconnected"
void this.onConnectionLost?.(payload.instanceId, reason)
}
}
})
eventSource.onerror = () => {
connection.status = "error"
this.updateConnectionStatus(instanceId, "error")
console.error(`[SSE] Connection error for instance ${instanceId}`)
this.handleConnectionError(instanceId, "Connection to instance lost")
}
serverEvents.on("instance.event", (event) => {
const payload = event as InstanceEventPayload
this.updateConnectionStatus(payload.instanceId, "connected")
this.handleEvent(payload.instanceId, payload.event as SSEEvent)
})
}
disconnect(instanceId: string): void {
const connection = this.connections.get(instanceId)
if (connection) {
this.clearReconnectTimer(connection)
connection.eventSource.close()
this.connections.delete(instanceId)
this.updateConnectionStatus(instanceId, "disconnected")
console.log(`[SSE] Disconnected from instance ${instanceId}`)
}
seedStatus(instanceId: string, status: ConnectionStatus) {
this.updateConnectionStatus(instanceId, status)
}
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)
switch (event.type) {
@@ -159,35 +128,7 @@ class SSEManager {
}
}
private handleConnectionError(instanceId: string, reason: string): void {
const connection = this.connections.get(instanceId)
if (!connection) return
connection.eventSource.close()
const nextAttempt = connection.reconnectAttempts + 1
const delay = Math.min(nextAttempt * 1000, SSEManager.MAX_RECONNECT_DELAY_MS)
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 clearReconnectTimer(connection: SSEConnection): void {
if (connection.reconnectTimer) {
clearTimeout(connection.reconnectTimer)
connection.reconnectTimer = undefined
}
}
private updateConnectionStatus(instanceId: string, status: SSEConnection["status"]): void {
private updateConnectionStatus(instanceId: string, status: ConnectionStatus): void {
setConnectionStatus((prev) => {
const next = new Map(prev)
next.set(instanceId, status)
@@ -209,7 +150,7 @@ class SSEManager {
onLspUpdated?: (instanceId: string, event: EventLspUpdated) => 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
}
@@ -218,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()

View File

@@ -21,14 +21,11 @@ function applyTheme(dark: boolean) {
export function ThemeProvider(props: { children: JSX.Element }) {
const systemPrefersDark = window.matchMedia("(prefers-color-scheme: dark)")
const { themePreference, setThemePreference } = useConfig()
const [isDark, setIsDarkSignal] = createSignal(false)
const [isDark, setIsDarkSignal] = createSignal(true)
const resolveDarkTheme = () => {
const preference = themePreference()
if (preference === "system") {
return systemPrefersDark.matches
}
return preference === "dark"
themePreference()
return true
}
const applyResolvedTheme = () => {
@@ -42,11 +39,8 @@ export function ThemeProvider(props: { children: JSX.Element }) {
})
onMount(() => {
const handleSystemThemeChange = (event: MediaQueryListEvent) => {
if (themePreference() === "system") {
setIsDarkSignal(event.matches)
applyTheme(event.matches)
}
const handleSystemThemeChange = () => {
applyResolvedTheme()
}
systemPrefersDark.addEventListener("change", handleSystemThemeChange)
@@ -56,12 +50,12 @@ export function ThemeProvider(props: { children: JSX.Element }) {
}
})
const setTheme = (dark: boolean) => {
setThemePreference(dark ? "dark" : "light")
const setTheme = (_dark: boolean) => {
setThemePreference("dark")
}
const toggleTheme = () => {
setTheme(!isDark())
setTheme(true)
}
return <ThemeContext.Provider value={{ isDark, toggleTheme, setTheme }}>{props.children}</ThemeContext.Provider>

View File

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

View File

@@ -6,30 +6,18 @@
<title>CodeNomad</title>
<style>
:root {
color-scheme: light dark;
color-scheme: dark;
}
/* html,
html,
body {
background-color: #ffffff;
color: #1a1a1a;
background-color: #1a1a1a;
color: #e0e0e0;
}
@media (prefers-color-scheme: dark) { */
html,
body {
background-color: #1a1a1a;
color: #e0e0e0;
}
/* } */
</style>
<script>
;(function () {
try {
const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches
// if (prefersDark) {
document.documentElement.setAttribute('data-theme', 'dark')
// } else {
// document.documentElement.removeAttribute('data-theme')
// }
document.documentElement.setAttribute('data-theme', 'dark')
} catch (error) {
console.warn('Failed to apply initial theme', error)
}

View File

@@ -0,0 +1,21 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CodeNomad</title>
<script>
;(function () {
try {
document.documentElement.setAttribute('data-theme', 'dark')
} catch (error) {
console.warn('Failed to apply initial theme', error)
}
})()
</script>
</head>
<body>
<div id="loading-root"></div>
<script type="module" src="./loading/main.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,111 @@
:root {
color-scheme: dark;
}
body {
margin: 0;
min-height: 100vh;
background-color: var(--surface-base, #0f141f);
color: var(--text-primary, #cfd4dc);
font-family: var(--font-family-sans, "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif);
display: flex;
align-items: center;
justify-content: center;
padding: 32px;
}
button {
border: none;
background: none;
font: inherit;
color: inherit;
}
.loading-wrapper {
display: flex;
flex-direction: column;
align-items: center;
gap: 24px;
max-width: 520px;
width: 100%;
text-align: center;
}
.loading-logo {
width: 180px;
height: auto;
filter: drop-shadow(0 20px 60px rgba(0, 0, 0, 0.45));
}
.loading-heading {
display: flex;
flex-direction: column;
gap: 4px;
}
.loading-title {
font-size: 2.8rem;
font-weight: 600;
margin: 0;
color: var(--text-primary, #f4f6fb);
}
.loading-status {
margin: 0;
font-size: 1rem;
color: var(--text-muted, #aeb3c4);
}
.loading-card {
margin-top: 12px;
width: 100%;
max-width: 420px;
padding: 22px;
border-radius: 18px;
background: rgba(13, 16, 24, 0.85);
border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow: 0 25px 60px rgba(0, 0, 0, 0.55);
}
.loading-row {
display: flex;
align-items: center;
justify-content: center;
gap: 14px;
font-size: 0.95rem;
}
.spinner {
width: 20px;
height: 20px;
border-radius: 50%;
border: 2px solid rgba(255, 255, 255, 0.18);
border-top-color: #6ce3ff;
animation: spin 0.9s linear infinite;
}
.phrase-controls {
margin-top: 12px;
font-size: 0.9rem;
color: var(--text-muted, #8f96a9);
}
.phrase-controls button {
color: #8fb5ff;
cursor: pointer;
}
.loading-error {
margin-top: 12px;
color: #ff9ea9;
font-size: 0.95rem;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}

View File

@@ -0,0 +1,166 @@
import { Show, createSignal, onCleanup, onMount } from "solid-js"
import { render } from "solid-js/web"
import iconUrl from "../../images/CodeNomad-Icon.png"
import { runtimeEnv, isTauriHost } from "../../lib/runtime-env"
import "../../index.css"
import "./loading.css"
const phrases = [
"Warming up the AI neurons…",
"Convincing the AI to stop daydreaming…",
"Polishing the AIs code goggles…",
"Asking the AI to stop reorganizing your files…",
"Feeding the AI additional coffee…",
"Teaching the AI not to delete node_modules (again)…",
"Telling the AI to act natural before you arrive…",
"Asking the AI to please stop rewriting history…",
"Letting the AI stretch before its coding sprint…",
"Persuading the AI to give you keyboard control…",
]
interface CliStatus {
state?: string
url?: string | null
error?: string | null
}
interface TauriBridge {
invoke?: <T = unknown>(cmd: string, args?: Record<string, unknown>) => Promise<T>
event?: {
listen: (event: string, handler: (payload: { payload: unknown }) => void) => Promise<() => void>
}
}
function pickPhrase(previous?: string) {
const filtered = phrases.filter((phrase) => phrase !== previous)
const source = filtered.length > 0 ? filtered : phrases
const index = Math.floor(Math.random() * source.length)
return source[index]
}
function navigateTo(url?: string | null) {
if (!url) return
window.location.replace(url)
}
function getTauriBridge(): TauriBridge | null {
if (typeof window === "undefined") {
return null
}
const bridge = (window as { __TAURI__?: TauriBridge }).__TAURI__
if (!bridge || !bridge.event || !bridge.invoke) {
return null
}
return bridge
}
function annotateDocument() {
if (typeof document === "undefined") {
return
}
document.documentElement.dataset.runtimeHost = runtimeEnv.host
document.documentElement.dataset.runtimePlatform = runtimeEnv.platform
}
function LoadingApp() {
const [phrase, setPhrase] = createSignal(pickPhrase())
const [error, setError] = createSignal<string | null>(null)
const [status, setStatus] = createSignal<string | null>(null)
const changePhrase = () => setPhrase(pickPhrase(phrase()))
onMount(() => {
annotateDocument()
setPhrase(pickPhrase())
const unsubscribers: Array<() => void> = []
async function bootstrapTauri(tauriBridge: TauriBridge | null) {
if (!tauriBridge || !tauriBridge.event || !tauriBridge.invoke) {
return
}
try {
const readyUnlisten = await tauriBridge.event.listen("cli:ready", (event) => {
const payload = (event?.payload as CliStatus) || {}
setError(null)
setStatus(null)
navigateTo(payload.url)
})
const errorUnlisten = await tauriBridge.event.listen("cli:error", (event) => {
const payload = (event?.payload as CliStatus) || {}
if (payload.error) {
setError(payload.error)
setStatus("Encountered an issue")
}
})
const statusUnlisten = await tauriBridge.event.listen("cli:status", (event) => {
const payload = (event?.payload as CliStatus) || {}
if (payload.state === "error" && payload.error) {
setError(payload.error)
setStatus("Encountered an issue")
return
}
if (payload.state && payload.state !== "ready") {
setError(null)
setStatus(null)
}
})
unsubscribers.push(readyUnlisten, errorUnlisten, statusUnlisten)
const result = await tauriBridge.invoke<CliStatus>("cli_get_status")
if (result?.state === "ready" && result.url) {
navigateTo(result.url)
} else if (result?.state === "error" && result.error) {
setError(result.error)
setStatus("Encountered an issue")
}
} catch (err) {
setError(String(err))
setStatus("Encountered an issue")
}
}
if (isTauriHost()) {
void bootstrapTauri(getTauriBridge())
}
onCleanup(() => {
unsubscribers.forEach((unsubscribe) => {
try {
unsubscribe()
} catch {
/* noop */
}
})
})
})
return (
<div class="loading-wrapper" role="status" aria-live="polite">
<img src={iconUrl} alt="CodeNomad" class="loading-logo" width="180" height="180" />
<div class="loading-heading">
<h1 class="loading-title">CodeNomad</h1>
<Show when={status()}>{(statusText) => <p class="loading-status">{statusText()}</p>}</Show>
</div>
<div class="loading-card">
<div class="loading-row">
<div class="spinner" aria-hidden="true" />
<span>{phrase()}</span>
</div>
<div class="phrase-controls">
<button type="button" onClick={changePhrase}>
Show another
</button>
</div>
{error() && <div class="loading-error">{error()}</div>}
</div>
</div>
)
}
const root = document.getElementById("loading-root")
if (!root) {
throw new Error("Loading root element not found")
}
render(() => <LoadingApp />, root)

View File

@@ -0,0 +1,46 @@
import { createSignal } from "solid-js"
export type AlertVariant = "info" | "warning" | "error"
export type AlertDialogState = {
type?: "alert" | "confirm"
title?: string
message: string
detail?: string
variant?: AlertVariant
confirmLabel?: string
cancelLabel?: string
onConfirm?: () => void
onCancel?: () => void
resolve?: (value: boolean) => void
}
const [alertDialogState, setAlertDialogState] = createSignal<AlertDialogState | null>(null)
export function showAlertDialog(message: string, options?: Omit<AlertDialogState, "message">) {
setAlertDialogState({
type: "alert",
message,
...options,
})
}
export function showConfirmDialog(message: string, options?: Omit<AlertDialogState, "message">): Promise<boolean> {
const activeElement = typeof document !== "undefined" ? (document.activeElement as HTMLElement | null) : null
activeElement?.blur()
return new Promise<boolean>((resolve) => {
setAlertDialogState({
type: "confirm",
message,
...options,
resolve,
})
})
}
export function dismissAlertDialog() {
setAlertDialogState(null)
}
export { alertDialogState }

View File

@@ -52,7 +52,9 @@ function workspaceDescriptorToInstance(descriptor: WorkspaceDescriptor): Instanc
error: descriptor.error,
client: existing?.client ?? null,
metadata: existing?.metadata,
binaryPath: descriptor.binaryLabel,
binaryPath: descriptor.binaryId ?? descriptor.binaryLabel ?? existing?.binaryPath,
binaryLabel: descriptor.binaryLabel,
binaryVersion: descriptor.binaryVersion ?? existing?.binaryVersion,
environmentVariables: existing?.environmentVariables ?? preferences().environmentVariables ?? {},
}
}
@@ -87,7 +89,6 @@ function attachClient(descriptor: WorkspaceDescriptor) {
if (instance.client) {
sdkManager.destroyClient(descriptor.id)
sseManager.disconnect(descriptor.id)
}
const client = sdkManager.createClient(descriptor.id, nextProxyPath)
@@ -97,7 +98,7 @@ function attachClient(descriptor: WorkspaceDescriptor) {
proxyPath: nextProxyPath,
status: "ready",
})
sseManager.connect(descriptor.id, nextProxyPath)
sseManager.seedStatus(descriptor.id, "connecting")
void hydrateInstanceData(descriptor.id).catch((error) => {
console.error("Failed to hydrate instance data", error)
})
@@ -110,7 +111,7 @@ function releaseInstanceResources(instanceId: string) {
if (instance.client) {
sdkManager.destroyClient(instanceId)
}
sseManager.disconnect(instanceId)
sseManager.seedStatus(instanceId, "disconnected")
}
async function hydrateInstanceData(instanceId: string) {

View File

@@ -16,6 +16,7 @@ import type {
import { showToastNotification, ToastVariant } from "../lib/notifications"
import { preferences } from "./preferences"
import { instances, addPermissionToQueue, removePermissionFromQueue, refreshPermissionsForSession } from "./instances"
import { showAlertDialog } from "./alerts"
import {
sessions,
setSessions,
@@ -441,7 +442,10 @@ function handleSessionError(_instanceId: string, event: EventSessionError): void
}
}
alert(`Error: ${message}`)
showAlertDialog(`Error: ${message}`, {
title: "Session error",
variant: "error",
})
}
function handleMessageRemoved(instanceId: string, event: MessageRemovedEvent): void {

View File

@@ -1,8 +1,46 @@
export {}
declare global {
interface ElectronDialogFilter {
name?: string
extensions: string[]
}
interface ElectronDialogOptions {
mode: "directory" | "file"
title?: string
defaultPath?: string
filters?: ElectronDialogFilter[]
}
interface ElectronDialogResult {
canceled?: boolean
paths?: string[]
path?: string | null
}
interface ElectronAPI {
onCliStatus?: (callback: (data: unknown) => void) => () => void
onCliError?: (callback: (data: unknown) => void) => () => void
getCliStatus?: () => Promise<unknown>
openDialog?: (options: ElectronDialogOptions) => Promise<ElectronDialogResult>
}
interface TauriDialogModule {
open?: (options: Record<string, unknown>) => Promise<string | string[] | null>
save?: (options: Record<string, unknown>) => Promise<string | null>
}
interface TauriBridge {
invoke?: <T = unknown>(cmd: string, args?: Record<string, unknown>) => Promise<T>
dialog?: TauriDialogModule
}
interface Window {
__CODENOMAD_API_BASE__?: string
__CODENOMAD_EVENTS_URL__?: string
electronAPI?: ElectronAPI
__TAURI__?: TauriBridge
}
}

View File

@@ -39,5 +39,7 @@ export interface Instance {
client: OpencodeClient | null
metadata?: InstanceMetadata
binaryPath?: string
binaryLabel?: string
binaryVersion?: string
environmentVariables?: Record<string, string>
}

View File

@@ -24,5 +24,11 @@ export default defineConfig({
},
build: {
outDir: "dist",
rollupOptions: {
input: {
main: resolve(__dirname, "./src/renderer/index.html"),
loading: resolve(__dirname, "./src/renderer/loading.html"),
},
},
},
})