Compare commits

...

74 Commits

Author SHA1 Message Date
Shantur Rathore
9313b2bd6c Add showUsageMetrics to Prefs schema 2025-11-25 16:06:14 +00:00
Shantur Rathore
d25cb09714 Align selector shortcuts and widen sidebar 2025-11-25 16:04:19 +00:00
Shantur Rathore
0d0d1271c3 Move assistant usage chips 2025-11-25 13:12:54 +00:00
Shantur Rathore
1fd3b2e75c Add toggle for usage metrics 2025-11-25 12:26:38 +00:00
Shantur Rathore
bf32fcf136 Refine session usage tracking 2025-11-25 12:03:33 +00:00
Shantur Rathore
48eb6b8982 bump version 0.2.4 2025-11-25 08:56:51 +00:00
Shantur Rathore
797fafe854 Normalize host when parsing CLI 2025-11-25 00:52:46 +00:00
Shantur Rathore
b342660ed0 Improve welcome mobile layout 2025-11-25 00:50:21 +00:00
Shantur Rathore
169d5ddeb9 Use npx tauri for workspace builds 2025-11-24 20:16:49 +00:00
Shantur Rathore
38642b60e9 add command palette button 2025-11-24 14:37:15 +00:00
Shantur Rathore
01effb8924 refine prompt overlay layout 2025-11-24 14:16:25 +00:00
Shantur Rathore
b434bfd3e9 Ensure tauri bundle includes server deps 2025-11-24 11:20:27 +00:00
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
91 changed files with 13755 additions and 807 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:
@@ -56,33 +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: Ensure rollup native binary
run: npm install @rollup/rollup-linux-x64-gnu --no-save
- 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,29 +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: Ensure rollup native binary
run: npm install @rollup/rollup-linux-x64-gnu --no-save
- 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.

View File

Before

Width:  |  Height:  |  Size: 845 KiB

After

Width:  |  Height:  |  Size: 845 KiB

View File

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 1.4 MiB

298
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "codenomad-workspace",
"version": "0.1.2",
"version": "0.2.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "codenomad-workspace",
"version": "0.1.2",
"version": "0.2.4",
"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.4",
"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.4",
"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.4",
"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.4",
"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.4",
"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.4",
"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.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@neuralnomads/codenomad",
"version": "0.1.0",
"version": "0.2.4",
"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.4",
"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

@@ -16,6 +16,7 @@ const PreferencesSchema = z.object({
diffViewMode: z.enum(["split", "unified"]).default("split"),
toolOutputExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
diagnosticsExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
showUsageMetrics: z.boolean().default(true),
})
const RecentFolderSchema = z.object({

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)
@@ -78,9 +80,11 @@ function parseCliOptions(argv: string[]): CliOptions {
const resolvedRoot = parsed.workspaceRoot ?? parsed.root ?? process.cwd()
const normalizedHost = resolveHost(parsed.host)
return {
port: parsed.port,
host: parsed.host,
host: normalizedHost,
rootDir: resolvedRoot,
configPath: parsed.config,
unrestrictedRoot: Boolean(parsed.unrestrictedRoot),
@@ -100,6 +104,13 @@ function parsePort(input: string): number {
return value
}
function resolveHost(input: string | undefined): string {
if (input && input.trim() === "0.0.0.0") {
return "0.0.0.0"
}
return DEFAULT_HOST
}
async function main() {
const options = parseCliOptions(process.argv.slice(2))
const logger = createLogger({ level: options.logLevel, destination: options.logDestination, component: "app" })
@@ -121,6 +132,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 +185,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.4",
"private": true,
"scripts": {
"dev": "npx --yes @tauri-apps/cli@^2.9.4 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 run prebuild",
"build": "npx --yes @tauri-apps/cli@^2.9.4 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,140 @@
#!/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"]
const serverInstallCommand =
"npm install --omit=dev --ignore-scripts --workspaces=false --package-lock=false --install-strategy=shallow --fund=false --audit=false"
const serverDevInstallCommand =
"npm ci --workspace @neuralnomads/codenomad --include-workspace-root=false --install-strategy=nested --fund=false --audit=false"
const envWithRootBin = {
...process.env,
PATH: `${path.join(workspaceRoot, "node_modules/.bin")}:${process.env.PATH}`,
}
const braceExpansionPath = path.join(
serverRoot,
"node_modules",
"@fastify",
"static",
"node_modules",
"brace-expansion",
"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",
env: {
...process.env,
PATH: `${path.join(workspaceRoot, "node_modules/.bin")}:${process.env.PATH}`,
},
})
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 ensureServerDevDependencies() {
if (fs.existsSync(braceExpansionPath)) {
return
}
console.log("[prebuild] ensuring server build dependencies (with dev)...")
execSync(serverDevInstallCommand, {
cwd: workspaceRoot,
stdio: "inherit",
env: envWithRootBin,
})
}
function ensureServerDependencies() {
if (fs.existsSync(braceExpansionPath)) {
return
}
console.log("[prebuild] ensuring server production dependencies...")
execSync(serverInstallCommand, {
cwd: serverRoot,
stdio: "inherit",
})
}
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, dereference: 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}`)
}
ensureServerDevDependencies()
ensureServerBuild()
ensureUiBuild()
ensureServerDependencies()
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.4",
"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"
@@ -45,6 +47,7 @@ const App: Component = () => {
preferences,
recordWorkspaceLaunch,
toggleShowThinkingBlocks,
toggleUsageMetrics,
setDiffViewMode,
setToolOutputExpansion,
setDiagnosticsExpansion,
@@ -135,11 +138,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)
}
}
@@ -193,6 +206,7 @@ const App: Component = () => {
const { commands: paletteCommands, executeCommand } = useCommands({
preferences,
toggleShowThinkingBlocks,
toggleUsageMetrics,
setDiffViewMode,
setToolOutputExpansion,
setDiagnosticsExpansion,
@@ -321,6 +335,8 @@ const App: Component = () => {
</div>
</Show>
<AlertDialog />
<Toaster
position="top-right"
gutter={16}

View File

@@ -3,7 +3,6 @@ import { For, Show, createEffect, createMemo } from "solid-js"
import { agents, fetchAgents, sessions } from "../stores/sessions"
import { ChevronDown } from "lucide-solid"
import type { Agent } from "../types/session"
import Kbd from "./kbd"
interface AgentSelectorProps {
instanceId: string
@@ -116,9 +115,6 @@ export default function AgentSelector(props: AgentSelectorProps) {
</Select.Content>
</Select.Portal>
</Select>
<span class="hint sidebar-selector-hint">
<Kbd shortcut="cmd+shift+a" />
</span>
</div>
)
}

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)
}
@@ -219,7 +231,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
>
<div class="mb-6 text-center shrink-0">
<div class="mb-3 flex justify-center">
<img src={codeNomadLogo} alt="CodeNomad logo" class="h-48 w-auto" loading="lazy" />
<img src={codeNomadLogo} alt="CodeNomad logo" class="h-32 w-auto sm:h-48" loading="lazy" />
</div>
<h1 class="mb-2 text-3xl font-semibold text-primary">CodeNomad</h1>
<p class="text-base text-secondary">Select a folder to start coding with AI</p>
@@ -306,14 +318,14 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
</Show>
<div class="panel shrink-0">
<div class="panel-header">
<div class="panel-header hidden sm:block">
<h2 class="panel-title">Browse for Folder</h2>
<p class="panel-subtitle">Select any folder on your computer</p>
</div>
<div class="panel-body">
<button
onClick={handleBrowse}
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")}
@@ -342,7 +354,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
</div>
</div>
<div class="mt-1 panel panel-footer shrink-0">
<div class="mt-1 panel panel-footer shrink-0 hidden sm:block">
<div class="panel-footer-hints">
<Show when={folders().length > 0}>
<div class="flex items-center gap-1.5">

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

@@ -281,7 +281,7 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
</div>
</div>
<div class="panel-footer">
<div class="panel-footer hidden sm:block">
<div class="panel-footer-hints">
<div class="flex items-center gap-1.5">
<kbd class="kbd"></kbd>

View File

@@ -14,6 +14,7 @@ import InfoView from "../info-view"
import AgentSelector from "../agent-selector"
import ModelSelector from "../model-selector"
import CommandPalette from "../command-palette"
import Kbd from "../kbd"
import ContextUsagePanel from "../session/context-usage-panel"
import SessionView from "../session/session-view"
@@ -28,7 +29,7 @@ interface InstanceShellProps {
onExecuteCommand: (command: Command) => void
}
const DEFAULT_SESSION_SIDEBAR_WIDTH = 280
const DEFAULT_SESSION_SIDEBAR_WIDTH = 350
const InstanceShell: Component<InstanceShellProps> = (props) => {
const [sessionSidebarWidth, setSessionSidebarWidth] = createSignal(DEFAULT_SESSION_SIDEBAR_WIDTH)
@@ -114,12 +115,22 @@ const InstanceShell: Component<InstanceShellProps> = (props) => {
onAgentChange={(agent) => props.handleSidebarAgentChange(activeSession().id, agent)}
/>
<div class="sidebar-selector-hints" aria-hidden="true">
<span class="hint sidebar-selector-hint sidebar-selector-hint--left">
<Kbd shortcut="cmd+shift+a" />
</span>
<span class="hint sidebar-selector-hint sidebar-selector-hint--right">
<Kbd shortcut="cmd+shift+m" />
</span>
</div>
<ModelSelector
instanceId={props.instance.id}
sessionId={activeSession().id}
currentModel={activeSession().model}
onModelChange={(model) => props.handleSidebarModelChange(activeSession().id, model)}
/>
</div>
</>
)}

View File

@@ -1,6 +1,8 @@
import { For, Show } from "solid-js"
import { For, Show, createMemo } from "solid-js"
import type { Message, SDKPart, MessageInfo, ClientPart } from "../types/message"
import { partHasRenderableText } from "../types/message"
import { formatTokenTotal } from "../lib/formatters"
import { preferences } from "../stores/preferences"
import MessagePart from "./message-part"
interface MessageItemProps {
@@ -16,6 +18,7 @@ interface MessageItemProps {
export default function MessageItem(props: MessageItemProps) {
const isUser = () => props.message.type === "user"
const showUsageMetrics = () => preferences().showUsageMetrics ?? true
const timestamp = () => {
const date = new Date(props.message.timestamp)
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
@@ -138,6 +141,44 @@ export default function MessageItem(props: MessageItemProps) {
isUser()
? "message-item-base bg-[var(--message-user-bg)] border-l-4 border-[var(--message-user-border)]"
: "message-item-base assistant-message bg-[var(--message-assistant-bg)] border-l-4 border-[var(--message-assistant-border)]"
const statChipClass =
"inline-flex items-center gap-1 rounded-full border border-[var(--border-base)] px-2 py-0.5 text-[10px]"
const statLabelClass = "uppercase text-[9px] tracking-wide text-[var(--text-muted)]"
const statValueClass = "font-semibold text-[var(--text-primary)]"
const usageStats = createMemo(() => {
const info = props.messageInfo
if (!info || info.role !== "assistant" || !info.tokens) {
return null
}
if (!showUsageMetrics()) {
return null
}
const tokens = info.tokens
const input = tokens.input ?? 0
const output = tokens.output ?? 0
const reasoning = tokens.reasoning ?? 0
if (input === 0 && output === 0 && reasoning === 0) {
return null
}
return {
input,
output,
reasoning,
cacheRead: tokens.cache?.read ?? 0,
cacheWrite: tokens.cache?.write ?? 0,
cost: info.cost ?? 0,
}
})
const formatCostValue = (value: number) => {
if (!value) return "$0.00"
if (value < 0.01) return `$${value.toPrecision(2)}`
return `$${value.toFixed(2)}`
}
const agentIdentifier = () => {
if (isUser()) return ""
@@ -225,6 +266,7 @@ export default function MessageItem(props: MessageItemProps) {
</For>
</div>
<Show when={fileAttachments().length > 0}>
<div class="message-attachments">
<For each={fileAttachments()}>
@@ -269,8 +311,39 @@ export default function MessageItem(props: MessageItemProps) {
</div>
</Show>
<Show when={usageStats()}>
{(usage) => (
<div class="mt-3 flex flex-wrap items-center gap-1 text-[10px] text-[var(--text-muted)]">
<div class={statChipClass}>
<span class={statLabelClass}>Input</span>
<span class={statValueClass}>{formatTokenTotal(usage().input)}</span>
</div>
<div class={statChipClass}>
<span class={statLabelClass}>Output</span>
<span class={statValueClass}>{formatTokenTotal(usage().output)}</span>
</div>
<div class={statChipClass}>
<span class={statLabelClass}>Reasoning</span>
<span class={statValueClass}>{formatTokenTotal(usage().reasoning)}</span>
</div>
<div class={statChipClass}>
<span class={statLabelClass}>Cache Read</span>
<span class={statValueClass}>{formatTokenTotal(usage().cacheRead)}</span>
</div>
<div class={statChipClass}>
<span class={statLabelClass}>Cache Write</span>
<span class={statValueClass}>{formatTokenTotal(usage().cacheWrite)}</span>
</div>
<div class={statChipClass}>
<span class={statLabelClass}>Cost</span>
<span class={statValueClass}>{formatCostValue(usage().cost)}</span>
</div>
</div>
)}
</Show>
<Show when={props.message.status === "sending"}>
<div class="message-sending">
<span class="generating-spinner"></span> Sending...
</div>

View File

@@ -32,7 +32,9 @@ import { sseManager } from "../lib/sse-manager"
import Kbd from "./kbd"
import { useConfig } from "../stores/preferences"
import { getSessionInfo, computeDisplayParts, sessions, setActiveSession, setActiveParentSession } from "../stores/sessions"
import { formatTokenTotal } from "../lib/formatters"
import { setActiveInstanceId } from "../stores/instances"
import { showCommandPalette } from "../stores/command-palette"
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
const SCROLL_OFFSET = 64
@@ -71,26 +73,9 @@ function navigateToTaskSession(location: TaskSessionLocation) {
}
}
// Format tokens like TUI (e.g., "110K", "1.2M")
// Format tokens like session sidebar (comma-separated totals)
function formatTokens(tokens: number): string {
if (tokens >= 1000000) {
return `${(tokens / 1000000).toFixed(1)}M`
} else if (tokens >= 1000) {
return `${(tokens / 1000).toFixed(0)}K`
}
return tokens.toString()
}
// Format session info for the session view header
function formatSessionInfo(usageTokens: number, contextWindow: number, usagePercent: number | null): string {
if (contextWindow > 0) {
const windowStr = formatTokens(contextWindow)
const usageStr = formatTokens(usageTokens)
const percent = usagePercent ?? Math.min(100, Math.max(0, Math.round((usageTokens / contextWindow) * 100)))
return `${usageStr} of ${windowStr} (${percent}%)`
}
return formatTokens(usageTokens)
return formatTokenTotal(tokens)
}
interface MessageStreamProps {
@@ -186,6 +171,9 @@ export default function MessageStream(props: MessageStreamProps) {
const scrollStateKey = () => makeScrollKey(props.instanceId, props.sessionId)
const connectionStatus = () => sseManager.getStatus(props.instanceId)
const handleCommandPaletteClick = () => {
showCommandPalette(props.instanceId)
}
function createToolSignature(message: Message, toolPart: ClientPart, toolIndex: number, messageInfo?: MessageInfo): string {
const messageId = message.id
@@ -202,18 +190,27 @@ export default function MessageStream(props: MessageStreamProps) {
const sessionInfo = createMemo(() =>
getSessionInfo(props.instanceId, props.sessionId) ?? {
tokens: 0,
cost: 0,
contextWindow: 0,
isSubscriptionModel: false,
contextUsageTokens: 0,
contextUsagePercent: null,
inputTokens: 0,
outputTokens: 0,
reasoningTokens: 0,
actualUsageTokens: 0,
modelOutputLimit: 0,
contextAvailableTokens: null,
},
)
const formattedSessionInfo = createMemo(() => {
const tokenStats = createMemo(() => {
const info = sessionInfo()
return formatSessionInfo(info.contextUsageTokens, info.contextWindow, info.contextUsagePercent)
return {
input: info.inputTokens ?? 0,
output: info.outputTokens ?? 0,
cost: info.cost ?? 0,
used: info.actualUsageTokens ?? 0,
avail: info.contextAvailableTokens,
}
})
function isNearBottom(element: HTMLDivElement, offset = SCROLL_OFFSET) {
@@ -549,14 +546,42 @@ export default function MessageStream(props: MessageStreamProps) {
return (
<div class="message-stream-container">
<div class="connection-status">
<div class="connection-status-text connection-status-info flex items-center gap-2 text-sm font-medium">
<span>{formattedSessionInfo()}</span>
<div class="connection-status-text connection-status-info flex flex-wrap items-center gap-2 text-sm font-medium">
<div class="inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary">
<span class="uppercase text-[10px] tracking-wide text-primary/70">Used</span>
<span class="font-semibold text-primary">{formatTokens(sessionInfo().actualUsageTokens ?? 0)}</span>
</div>
<div class="inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary">
<span class="uppercase text-[10px] tracking-wide text-primary/70">Avail</span>
<span class="font-semibold text-primary">
{sessionInfo().contextAvailableTokens !== null ? formatTokens(sessionInfo().contextAvailableTokens ?? 0) : "--"}
</span>
</div>
</div>
<div class="connection-status-text connection-status-shortcut flex items-center gap-2 text-sm font-medium">
<span>Command Palette</span>
<Kbd shortcut="cmd+shift+p" />
<div class="connection-status-text connection-status-shortcut">
<div class="connection-status-shortcut-action">
<button
type="button"
class="connection-status-button"
onClick={handleCommandPaletteClick}
aria-label="Open command palette"
>
Command Palette
</button>
<span class="connection-status-shortcut-hint">
<Kbd shortcut="cmd+shift+p" />
</span>
</div>
</div>
<div class="connection-status-meta flex items-center justify-end gap-3">
<Show when={connectionStatus() === "connected"}>
<span class="status-indicator connected">
<span class="status-dot" />

View File

@@ -3,7 +3,6 @@ import { createEffect, createMemo, createSignal } from "solid-js"
import { providers, fetchProviders } from "../stores/sessions"
import { ChevronDown } from "lucide-solid"
import type { Model } from "../types/session"
import Kbd from "./kbd"
interface ModelSelectorProps {
instanceId: string
@@ -132,9 +131,6 @@ export default function ModelSelector(props: ModelSelectorProps) {
</Combobox.Content>
</Combobox.Portal>
</Combobox>
<span class="hint sidebar-selector-hint">
<Kbd shortcut="cmd+shift+m" />
</span>
</div>
)
}

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

@@ -7,9 +7,9 @@ import { createFileAttachment, createTextAttachment, createAgentAttachment } fro
import type { Attachment } from "../types/attachment"
import type { Agent } from "../types/session"
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 +24,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 +499,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 +528,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 +535,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()
}
@@ -758,6 +776,7 @@ export default function PromptInput(props: PromptInputProps) {
}
const shellHint = () => (mode() === "shell" ? { key: "Esc", text: "to exit shell mode" } : { key: "!", text: "for shell mode" })
const shouldShowOverlay = () => prompt().length === 0
const instance = () => getActiveInstance()
@@ -865,28 +884,62 @@ export default function PromptInput(props: PromptInputProps) {
</For>
</div>
</Show>
<textarea
ref={textareaRef}
class={`prompt-input ${mode() === "shell" ? "shell-mode" : ""}`}
placeholder={
mode() === "shell"
? "Run a shell command (Esc to exit)..."
: "Type your message, @file, @agent, or paste images and text..."
}
value={prompt()}
onInput={handleInput}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
disabled={props.disabled}
rows={4}
style={attachments().length > 0 ? { "padding-top": "8px" } : {}}
spellcheck={false}
autocorrect="off"
autoCapitalize="off"
autocomplete="off"
/>
<div class="prompt-input-field">
<textarea
ref={textareaRef}
class={`prompt-input ${mode() === "shell" ? "shell-mode" : ""}`}
placeholder={
mode() === "shell"
? "Run a shell command (Esc to exit)..."
: "Type your message, @file, @agent, or paste images and text..."
}
value={prompt()}
onInput={handleInput}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
disabled={props.disabled}
rows={4}
style={attachments().length > 0 ? { "padding-top": "8px" } : {}}
spellcheck={false}
autocorrect="off"
autoCapitalize="off"
autocomplete="off"
/>
<Show when={shouldShowOverlay()}>
<div class={`prompt-input-overlay ${mode() === "shell" ? "shell-mode" : ""}`}>
<Show
when={props.escapeInDebounce}
fallback={
<>
<span class="prompt-overlay-text">
<Kbd>Enter</Kbd> for new line <Kbd shortcut="cmd+enter" /> to send <Kbd>@</Kbd> for files/agents <Kbd></Kbd> for history
</span>
<Show when={attachments().length > 0}>
<span class="prompt-overlay-text prompt-overlay-muted"> {attachments().length} file(s) attached</span>
</Show>
<span class="prompt-overlay-text">
<Kbd>{shellHint().key}</Kbd> {shellHint().text}
</span>
<Show when={mode() === "shell"}>
<span class="prompt-overlay-shell-active">Shell mode active</span>
</Show>
</>
}
>
<>
<span class="prompt-overlay-text prompt-overlay-warning">
Press <Kbd>Esc</Kbd> again to abort session
</span>
<Show when={mode() === "shell"}>
<span class="prompt-overlay-shell-active">Shell mode active</span>
</Show>
</>
</Show>
</div>
</Show>
</div>
</div>
<button
@@ -906,33 +959,6 @@ export default function PromptInput(props: PromptInputProps) {
</Show>
</button>
</div>
<div class="prompt-input-hints">
<div class="flex justify-between w-full gap-4">
<HintRow>
<Show
when={props.escapeInDebounce}
fallback={
<>
<Kbd>Enter</Kbd> for new line <Kbd shortcut="cmd+enter" /> to send <Kbd>@</Kbd> for files/agents <Kbd></Kbd> for history
<Show when={attachments().length > 0}>
<span class="ml-2 text-xs" style="color: var(--text-muted);"> {attachments().length} file(s) attached</span>
</Show>
<span class="ml-2">
<Kbd>{shellHint().key}</Kbd> {shellHint().text}
</span>
</>
}
>
<span class="font-medium" style="color: var(--status-warning);">
Press <Kbd>Esc</Kbd> again to abort session
</span>
</Show>
</HintRow>
<Show when={mode() === "shell"}>
<HintRow>Shell mode active</HintRow>
</Show>
</div>
</div>
</div>
)
}

View File

@@ -24,9 +24,9 @@ interface SessionListProps {
}
const MIN_WIDTH = 200
const MAX_WIDTH = 500
const DEFAULT_WIDTH = 280
const STORAGE_KEY = "opencode-session-sidebar-width"
const MAX_WIDTH = 520
const DEFAULT_WIDTH = 350
const STORAGE_KEY = "opencode-session-sidebar-width-v7"
function formatSessionStatus(status: SessionStatus): string {
switch (status) {

View File

@@ -7,54 +7,72 @@ interface ContextUsagePanelProps {
sessionId: string
}
const chipClass = "inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary"
const chipLabelClass = "uppercase text-[10px] tracking-wide text-primary/70"
const headingClass = "text-xs font-semibold text-primary/70 uppercase tracking-wide"
const ContextUsagePanel: Component<ContextUsagePanelProps> = (props) => {
const info = createMemo(
() =>
getSessionInfo(props.instanceId, props.sessionId) ?? {
tokens: 0,
cost: 0,
contextWindow: 0,
isSubscriptionModel: false,
contextUsageTokens: 0,
contextUsagePercent: null,
inputTokens: 0,
outputTokens: 0,
reasoningTokens: 0,
actualUsageTokens: 0,
modelOutputLimit: 0,
contextAvailableTokens: null,
},
)
const tokens = createMemo(() => info().tokens)
const contextUsageTokens = createMemo(() => info().contextUsageTokens ?? 0)
const contextWindow = createMemo(() => info().contextWindow)
const contextUsagePercent = createMemo(() => info().contextUsagePercent)
const costLabel = createMemo(() => {
if (info().isSubscriptionModel || info().cost <= 0) return "Included in plan"
return `$${info().cost.toFixed(2)} spent`
const inputTokens = createMemo(() => info().inputTokens ?? 0)
const outputTokens = createMemo(() => info().outputTokens ?? 0)
const actualUsageTokens = createMemo(() => info().actualUsageTokens ?? 0)
const availableTokens = createMemo(() => info().contextAvailableTokens)
const outputLimit = createMemo(() => info().modelOutputLimit ?? 0)
const costValue = createMemo(() => {
const value = info().isSubscriptionModel ? 0 : info().cost
return value > 0 ? value : 0
})
const formatTokenValue = (value: number | null | undefined) => {
if (value === null || value === undefined) return "--"
return formatTokenTotal(value)
}
const costDisplay = createMemo(() => `$${costValue().toFixed(2)}`)
return (
<div class="session-context-panel border-r border-base border-b px-3 py-3">
<div class="flex items-center justify-between gap-4">
<div>
<div class="text-xs font-semibold text-primary/70 uppercase tracking-wide">Tokens (last call)</div>
<div class="text-lg font-semibold text-primary">{formatTokenTotal(tokens())}</div>
<div class="session-context-panel border-r border-base border-b px-3 py-3 space-y-3">
<div class="flex flex-wrap items-center gap-2 text-xs text-primary/90">
<div class={headingClass}>Tokens</div>
<div class={chipClass}>
<span class={chipLabelClass}>Input</span>
<span class="font-semibold text-primary">{formatTokenTotal(inputTokens())}</span>
</div>
<div class="text-xs text-primary/70 text-right leading-tight">{costLabel()}</div>
</div>
<div class="mt-4">
<div class="flex items-center justify-between mb-1">
<div class="text-xs font-semibold text-primary/70 uppercase tracking-wide">Context window usage</div>
<div class="text-sm font-medium text-primary">{contextUsagePercent() !== null ? `${contextUsagePercent()}%` : "--"}</div>
<div class={chipClass}>
<span class={chipLabelClass}>Output</span>
<span class="font-semibold text-primary">{formatTokenTotal(outputTokens())}</span>
</div>
<div class="text-sm text-primary/90">
{contextWindow()
? `${formatTokenTotal(contextUsageTokens())} of ${formatTokenTotal(contextWindow())}`
: "Window size unavailable"}
<div class={chipClass}>
<span class={chipLabelClass}>Cost</span>
<span class="font-semibold text-primary">{costDisplay()}</span>
</div>
</div>
<div class="mt-3 h-1.5 rounded-full bg-base relative overflow-hidden">
<div
class="absolute inset-y-0 left-0 rounded-full bg-accent-primary transition-[width]"
style={{ width: contextUsagePercent() === null ? "0%" : `${contextUsagePercent()}%` }}
/>
<div class="flex flex-wrap items-center gap-2 text-xs text-primary/90">
<div class={headingClass}>Context</div>
<div class={chipClass}>
<span class={chipLabelClass}>Used</span>
<span class="font-semibold text-primary">{formatTokenTotal(actualUsageTokens())}</span>
</div>
<div class={chipClass}>
<span class={chipLabelClass}>Avail</span>
<span class="font-semibold text-primary">{formatTokenValue(availableTokens())}</span>
</div>
</div>
</div>
)

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

@@ -1,4 +1,7 @@
export function formatTokenTotal(value: number): string {
if (value >= 1_000_000_000) {
return `${(value / 1_000_000_000).toFixed(1)}B`
}
if (value >= 1_000_000) {
return `${(value / 1_000_000).toFixed(1)}M`
}

View File

@@ -11,11 +11,13 @@ 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 {
preferences: Accessor<Preferences>
toggleShowThinkingBlocks: () => void
toggleUsageMetrics: () => void
setDiffViewMode: (mode: "split" | "unified") => void
setToolOutputExpansion: (mode: ExpansionPreference) => void
setDiagnosticsExpansion: (mode: ExpansionPreference) => void
@@ -207,7 +209,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 +261,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 +290,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",
})
}
},
})
@@ -411,9 +422,22 @@ export function useCommands(options: UseCommandsOptions) {
},
})
commandRegistry.register({
id: "token-usage-visibility",
label: () => {
const visible = options.preferences().showUsageMetrics ?? true
return `Token Usage Display · ${visible ? "Visible" : "Hidden"}`
},
description: "Show or hide token and cost stats for assistant messages",
category: "System",
keywords: ["token", "usage", "cost", "stats"],
action: options.toggleUsageMetrics,
})
commandRegistry.register({
id: "help",
label: "Show Help",
description: "Display keyboard shortcuts and help",
category: "System",
keywords: ["/help", "shortcuts", "help"],

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

@@ -35,6 +35,7 @@ export interface Preferences {
diffViewMode: DiffViewMode
toolOutputExpansion: ExpansionPreference
diagnosticsExpansion: ExpansionPreference
showUsageMetrics: boolean
}
export interface OpenCodeBinary {
@@ -60,6 +61,7 @@ const defaultPreferences: Preferences = {
diffViewMode: "split",
toolOutputExpansion: "expanded",
diagnosticsExpansion: "expanded",
showUsageMetrics: true,
}
function deepEqual(a: unknown, b: unknown): boolean {
@@ -92,6 +94,7 @@ function normalizePreferences(pref?: Partial<Preferences> & { agentModelSelectio
diffViewMode: sanitized.diffViewMode ?? defaultPreferences.diffViewMode,
toolOutputExpansion: sanitized.toolOutputExpansion ?? defaultPreferences.toolOutputExpansion,
diagnosticsExpansion: sanitized.diagnosticsExpansion ?? defaultPreferences.diagnosticsExpansion,
showUsageMetrics: sanitized.showUsageMetrics ?? defaultPreferences.showUsageMetrics,
}
}
@@ -270,6 +273,10 @@ function toggleShowThinkingBlocks(): void {
updatePreferences({ showThinkingBlocks: !preferences().showThinkingBlocks })
}
function toggleUsageMetrics(): void {
updatePreferences({ showUsageMetrics: !preferences().showUsageMetrics })
}
function addRecentFolder(path: string): void {
updateConfig((draft) => {
draft.recentFolders = buildRecentFolderList(path, draft.recentFolders)
@@ -370,6 +377,7 @@ interface ConfigContextValue {
setThemePreference: typeof setThemePreference
updateConfig: typeof updateConfig
toggleShowThinkingBlocks: typeof toggleShowThinkingBlocks
toggleUsageMetrics: typeof toggleUsageMetrics
setDiffViewMode: typeof setDiffViewMode
setToolOutputExpansion: typeof setToolOutputExpansion
setDiagnosticsExpansion: typeof setDiagnosticsExpansion
@@ -400,6 +408,7 @@ const configContextValue: ConfigContextValue = {
setThemePreference,
updateConfig,
toggleShowThinkingBlocks,
toggleUsageMetrics,
setDiffViewMode,
setToolOutputExpansion,
setDiagnosticsExpansion,
@@ -454,6 +463,7 @@ export {
updateConfig,
updatePreferences,
toggleShowThinkingBlocks,
toggleUsageMetrics,
recentFolders,
addRecentFolder,
removeRecentFolder,

View File

@@ -21,7 +21,7 @@ import {
loading,
setLoading,
} from "./session-state"
import { getDefaultModel, isModelValid } from "./session-models"
import { DEFAULT_MODEL_OUTPUT_LIMIT, getDefaultModel, isModelValid } from "./session-models"
import {
computeDisplayParts,
clearSessionIndex,
@@ -29,6 +29,7 @@ import {
initializePartVersion,
normalizeMessagePart,
rebuildSessionIndex,
rebuildSessionUsage,
updateSessionInfo,
} from "./session-messages"
@@ -212,18 +213,25 @@ async function createSession(instanceId: string, agent?: string): Promise<Sessio
const initialModel = initialProvider?.models.find((m) => m.id === session.model.modelId)
const initialContextWindow = initialModel?.limit?.context ?? 0
const initialSubscriptionModel = initialModel?.cost?.input === 0 && initialModel?.cost?.output === 0
const initialContextPercent = initialContextWindow > 0 ? 0 : null
const initialOutputLimit =
initialModel?.limit?.output && initialModel.limit.output > 0
? initialModel.limit.output
: DEFAULT_MODEL_OUTPUT_LIMIT
const initialContextAvailable = initialContextWindow > 0 ? initialContextWindow : null
setSessionInfoByInstance((prev) => {
const next = new Map(prev)
const instanceInfo = new Map(prev.get(instanceId))
instanceInfo.set(session.id, {
tokens: 0,
cost: 0,
contextWindow: initialContextWindow,
isSubscriptionModel: Boolean(initialSubscriptionModel),
contextUsageTokens: 0,
contextUsagePercent: initialContextPercent,
inputTokens: 0,
outputTokens: 0,
reasoningTokens: 0,
actualUsageTokens: 0,
modelOutputLimit: initialOutputLimit,
contextAvailableTokens: initialContextAvailable,
})
next.set(instanceId, instanceInfo)
return next
@@ -310,18 +318,23 @@ async function forkSession(
const forkModel = forkProvider?.models.find((m) => m.id === forkedSession.model.modelId)
const forkContextWindow = forkModel?.limit?.context ?? 0
const forkSubscriptionModel = forkModel?.cost?.input === 0 && forkModel?.cost?.output === 0
const forkContextPercent = forkContextWindow > 0 ? 0 : null
const forkOutputLimit =
forkModel?.limit?.output && forkModel.limit.output > 0 ? forkModel.limit.output : DEFAULT_MODEL_OUTPUT_LIMIT
const forkContextAvailable = forkContextWindow > 0 ? forkContextWindow : null
setSessionInfoByInstance((prev) => {
const next = new Map(prev)
const instanceInfo = new Map(prev.get(instanceId))
instanceInfo.set(forkedSession.id, {
tokens: 0,
cost: 0,
contextWindow: forkContextWindow,
isSubscriptionModel: Boolean(forkSubscriptionModel),
contextUsageTokens: 0,
contextUsagePercent: forkContextPercent,
inputTokens: 0,
outputTokens: 0,
reasoningTokens: 0,
actualUsageTokens: 0,
modelOutputLimit: forkOutputLimit,
contextAvailableTokens: forkContextAvailable,
})
next.set(instanceId, instanceInfo)
return next
@@ -587,6 +600,7 @@ async function loadMessages(instanceId: string, sessionId: string, force = false
})
rebuildSessionIndex(instanceId, sessionId, messages)
rebuildSessionUsage(instanceId, sessionId, messagesInfo)
setMessagesLoaded((prev) => {
const next = new Map(prev)
@@ -595,6 +609,7 @@ async function loadMessages(instanceId: string, sessionId: string, force = false
next.set(instanceId, loadedSet)
return next
})
} catch (error) {
console.error("Failed to load messages:", error)
throw error
@@ -608,17 +623,17 @@ async function loadMessages(instanceId: string, sessionId: string, force = false
return next
})
}
updateSessionInfo(instanceId, sessionId)
refreshPermissionsForSession(instanceId, sessionId)
}
updateSessionInfo(instanceId, sessionId)
refreshPermissionsForSession(instanceId, sessionId)
}
export {
createSession,
deleteSession,
fetchAgents,
fetchProviders,
fetchSessions,
forkSession,
loadMessages,

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,
@@ -29,6 +30,7 @@ import {
normalizeMessagePart,
rebuildSessionIndex,
updateSessionInfo,
updateUsageFromMessageInfo,
} from "./session-messages"
import { loadMessages } from "./session-api"
import { setSessionCompactionState } from "./session-compaction"
@@ -304,6 +306,7 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes
}
session.messagesInfo.set(info.id, info)
updateUsageFromMessageInfo(instanceId, info.sessionID, info)
withSession(instanceId, info.sessionID, () => {
/* ensure reactivity */
})
@@ -313,6 +316,7 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes
}
}
function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): void {
const info = event.properties?.info
if (!info) return
@@ -441,7 +445,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,9 +1,9 @@
import type { Message, MessageDisplayParts } from "../types/message"
import { partHasRenderableText } from "../types/message"
import { partHasRenderableText, type MessageInfo } from "../types/message"
import type { Provider } from "../types/session"
import { decodeHtmlEntities } from "../lib/markdown"
import { providers, sessions, setSessionInfoByInstance } from "./session-state"
import { providers, sessions, sessionInfoByInstance, setSessionInfoByInstance } from "./session-state"
import { DEFAULT_MODEL_OUTPUT_LIMIT } from "./session-models"
interface SessionIndexCache {
@@ -11,7 +11,153 @@ interface SessionIndexCache {
partIndex: Map<string, Map<string, number>>
}
interface AssistantUsageEntry {
info: MessageInfo
inputTokens: number
outputTokens: number
reasoningTokens: number
combinedTokens: number
cost: number
hasContextUsage: boolean
timestamp: number
}
interface SessionUsageState {
entries: Map<string, AssistantUsageEntry>
totalInputTokens: number
totalOutputTokens: number
totalReasoningTokens: number
totalCost: number
latestEntry: AssistantUsageEntry | null
}
const sessionIndexes = new Map<string, Map<string, SessionIndexCache>>()
const sessionUsageStates = new Map<string, Map<string, SessionUsageState>>()
function createEmptyUsageState(): SessionUsageState {
return {
entries: new Map(),
totalInputTokens: 0,
totalOutputTokens: 0,
totalReasoningTokens: 0,
totalCost: 0,
latestEntry: null,
}
}
function getUsageInstance(instanceId: string): Map<string, SessionUsageState> {
let usageMap = sessionUsageStates.get(instanceId)
if (!usageMap) {
usageMap = new Map()
sessionUsageStates.set(instanceId, usageMap)
}
return usageMap
}
function getSessionUsageState(instanceId: string, sessionId: string): SessionUsageState {
const usageMap = getUsageInstance(instanceId)
let state = usageMap.get(sessionId)
if (!state) {
state = createEmptyUsageState()
usageMap.set(sessionId, state)
}
return state
}
function recomputeLatestEntry(state: SessionUsageState) {
state.latestEntry = null
for (const entry of state.entries.values()) {
if (!state.latestEntry || entry.timestamp >= state.latestEntry.timestamp) {
state.latestEntry = entry
}
}
}
function extractAssistantUsage(info: MessageInfo): AssistantUsageEntry | null {
if (!info || info.role !== "assistant") return null
if (!info.tokens) return null
const tokens = info.tokens
const inputTokens = tokens.input ?? 0
const outputTokens = tokens.output ?? 0
const reasoningTokens = tokens.reasoning ?? 0
if (inputTokens === 0 && outputTokens === 0 && reasoningTokens === 0) {
return null
}
const cacheReadTokens = tokens.cache?.read ?? 0
const cacheWriteTokens = tokens.cache?.write ?? 0
const combinedTokens = info.summary
? outputTokens
: inputTokens + cacheReadTokens + cacheWriteTokens + outputTokens + reasoningTokens
const cost = info.cost ?? 0
const hasContextUsage = inputTokens + cacheReadTokens + cacheWriteTokens > 0
return {
info,
inputTokens,
outputTokens,
reasoningTokens,
combinedTokens,
cost,
hasContextUsage,
timestamp: info.time?.created ?? 0,
}
}
function removeUsageEntry(state: SessionUsageState, messageId: string | undefined) {
if (!messageId) return
const existing = state.entries.get(messageId)
if (!existing) return
state.entries.delete(messageId)
state.totalInputTokens -= existing.inputTokens
state.totalOutputTokens -= existing.outputTokens
state.totalReasoningTokens -= existing.reasoningTokens
state.totalCost -= existing.cost
if (state.latestEntry?.info.id === messageId) {
recomputeLatestEntry(state)
}
}
function addUsageEntry(state: SessionUsageState, entry: AssistantUsageEntry) {
state.entries.set(entry.info.id, entry)
state.totalInputTokens += entry.inputTokens
state.totalOutputTokens += entry.outputTokens
state.totalReasoningTokens += entry.reasoningTokens
state.totalCost += entry.cost
if (!state.latestEntry || entry.timestamp >= state.latestEntry.timestamp) {
state.latestEntry = entry
}
}
function updateUsageFromMessageInfo(instanceId: string, sessionId: string, info: MessageInfo) {
const messageId = typeof info.id === "string" ? info.id : undefined
if (!messageId) return
const state = getSessionUsageState(instanceId, sessionId)
removeUsageEntry(state, messageId)
const entry = extractAssistantUsage(info)
if (entry) {
addUsageEntry(state, entry)
}
}
function rebuildSessionUsage(instanceId: string, sessionId: string, messagesInfo: Map<string, MessageInfo>) {
const usageMap = getUsageInstance(instanceId)
const nextState = createEmptyUsageState()
for (const info of messagesInfo.values()) {
const entry = extractAssistantUsage(info)
if (entry) {
addUsageEntry(nextState, entry)
}
}
usageMap.set(sessionId, nextState)
}
function clearSessionUsage(instanceId: string, sessionId: string) {
const usageMap = sessionUsageStates.get(instanceId)
if (!usageMap) return
usageMap.delete(sessionId)
if (usageMap.size === 0) {
sessionUsageStates.delete(instanceId)
}
}
function decodeTextSegment(segment: any): any {
if (typeof segment === "string") {
@@ -163,10 +309,12 @@ function clearSessionIndex(instanceId: string, sessionId: string) {
sessionIndexes.delete(instanceId)
}
}
clearSessionUsage(instanceId, sessionId)
}
function removeSessionIndexes(instanceId: string) {
sessionIndexes.delete(instanceId)
sessionUsageStates.delete(instanceId)
}
function updateSessionInfo(instanceId: string, sessionId: string) {
@@ -176,52 +324,67 @@ function updateSessionInfo(instanceId: string, sessionId: string) {
const session = instanceSessions.get(sessionId)
if (!session) return
let tokens = 0
let cost = 0
let contextWindow = 0
let isSubscriptionModel = false
let modelID = ""
let providerID = ""
let actualUsageTokens = 0
let contextUsagePercent: number | null = null
let hasContextUsage = false
if (session.messagesInfo.size > 0) {
const messageArray = Array.from(session.messagesInfo.values()).reverse()
const usageState = getSessionUsageState(instanceId, sessionId)
const hasUsageEntries = usageState.entries.size > 0
for (const info of messageArray) {
if (info.role === "assistant" && info.tokens) {
const usage = info.tokens
let totalInputTokens = hasUsageEntries ? usageState.totalInputTokens : 0
let totalOutputTokens = hasUsageEntries ? usageState.totalOutputTokens : 0
let totalReasoningTokens = hasUsageEntries ? usageState.totalReasoningTokens : 0
let totalCost = hasUsageEntries ? usageState.totalCost : 0
if (usage.output > 0) {
const inputTokens = usage.input || 0
const reasoningTokens = usage.reasoning || 0
const cacheReadTokens = usage.cache?.read || 0
const cacheWriteTokens = usage.cache?.write || 0
const outputTokens = usage.output || 0
let latestAssistantInfo: MessageInfo | null = usageState.latestEntry?.info ?? null
let latestHasContextUsage = usageState.latestEntry?.hasContextUsage ?? false
const previousInfo = sessionInfoByInstance().get(instanceId)?.get(sessionId)
let contextAvailableTokens: number | null = null
let contextAvailableFromPrevious = false
if (info.summary) {
tokens = outputTokens
} else {
tokens = inputTokens + cacheReadTokens + cacheWriteTokens + outputTokens + reasoningTokens
}
if (latestAssistantInfo) {
const infoAny = latestAssistantInfo as any
actualUsageTokens = usageState.latestEntry?.combinedTokens ?? 0
modelID = infoAny.modelID || ""
providerID = infoAny.providerID || ""
} else if (previousInfo) {
totalInputTokens = previousInfo.inputTokens
totalOutputTokens = previousInfo.outputTokens
totalReasoningTokens = previousInfo.reasoningTokens
totalCost = previousInfo.cost
actualUsageTokens = previousInfo.actualUsageTokens
cost = info.cost || 0
actualUsageTokens = tokens
hasContextUsage = inputTokens + cacheReadTokens + cacheWriteTokens > 0
const previousContextWindow = previousInfo.contextWindow
const previousContextAvailable = previousInfo.contextAvailableTokens ?? null
const previousHasContextUsage =
previousContextAvailable !== null && previousContextWindow > 0
? previousContextAvailable < previousContextWindow
: false
modelID = info.modelID || ""
providerID = info.providerID || ""
isSubscriptionModel = cost === 0
break
}
}
if (contextWindow === 0) {
contextWindow = previousContextWindow
}
if (contextWindow !== previousContextWindow) {
contextAvailableTokens = null
contextAvailableFromPrevious = false
latestHasContextUsage = previousHasContextUsage
} else {
contextAvailableTokens = previousContextAvailable
contextAvailableFromPrevious = true
latestHasContextUsage = previousHasContextUsage
}
isSubscriptionModel = previousInfo.isSubscriptionModel
}
const instanceProviders = providers().get(instanceId) || []
const sessionModel = session.model
let selectedModel: Provider["models"][number] | undefined
@@ -252,30 +415,32 @@ function updateSessionInfo(instanceId: string, sessionId: string) {
}
const outputBudget = Math.min(modelOutputLimit, DEFAULT_MODEL_OUTPUT_LIMIT)
let contextUsageTokens = 0
if (hasContextUsage && actualUsageTokens > 0) {
contextUsageTokens = actualUsageTokens + outputBudget
if (!contextAvailableFromPrevious) {
if (contextWindow > 0) {
const percent = Math.round((contextUsageTokens / contextWindow) * 100)
contextUsagePercent = Math.min(100, Math.max(0, percent))
if (latestHasContextUsage && actualUsageTokens > 0) {
contextAvailableTokens = Math.max(contextWindow - (actualUsageTokens + outputBudget), 0)
} else {
contextAvailableTokens = contextWindow
}
} else {
contextUsagePercent = null
contextAvailableTokens = null
}
} else {
contextUsagePercent = contextWindow > 0 ? 0 : null
}
setSessionInfoByInstance((prev) => {
const next = new Map(prev)
const instanceInfo = new Map(prev.get(instanceId))
instanceInfo.set(sessionId, {
tokens,
cost,
cost: totalCost,
contextWindow,
isSubscriptionModel,
contextUsageTokens,
contextUsagePercent,
inputTokens: totalInputTokens,
outputTokens: totalOutputTokens,
reasoningTokens: totalReasoningTokens,
actualUsageTokens,
modelOutputLimit,
contextAvailableTokens,
})
next.set(instanceId, instanceInfo)
return next
@@ -290,6 +455,8 @@ export {
initializePartVersion,
normalizeMessagePart,
rebuildSessionIndex,
rebuildSessionUsage,
removeSessionIndexes,
updateSessionInfo,
updateUsageFromMessageInfo,
}

View File

@@ -3,12 +3,15 @@ import { createSignal } from "solid-js"
import type { Session, Agent, Provider } from "../types/session"
export interface SessionInfo {
tokens: number
cost: number
contextWindow: number
isSubscriptionModel: boolean
contextUsageTokens: number
contextUsagePercent: number | null
inputTokens: number
outputTokens: number
reasoningTokens: number
actualUsageTokens: number
modelOutputLimit: number
contextAvailableTokens: number | null
}
const [sessions, setSessions] = createSignal<Map<string, Map<string, Session>>>(new Map())

View File

@@ -27,6 +27,43 @@
color: var(--text-muted);
}
.connection-status-shortcut-action {
@apply flex items-center justify-center gap-2;
}
.connection-status-button {
@apply inline-flex items-center gap-2 px-3 py-1 text-sm font-medium border rounded-md transition-colors;
border-color: var(--border-base);
background-color: var(--surface-base);
color: var(--text-primary);
}
.connection-status-button:hover {
background-color: var(--surface-hover);
}
.connection-status-button:focus-visible {
@apply ring-2 ring-offset-1;
ring-color: var(--accent-primary);
ring-offset-color: var(--surface-secondary);
}
.connection-status-shortcut-hint {
@apply inline-flex items-center;
color: var(--text-secondary);
}
@media (pointer: coarse) {
.connection-status-shortcut-hint {
display: none;
}
.connection-status-button {
width: 100%;
justify-content: center;
}
}
.message-stream {
@apply flex-1 min-h-0 overflow-y-auto flex flex-col gap-1;
background-color: var(--surface-base);

View File

@@ -9,9 +9,13 @@
@apply flex items-end gap-2 p-3;
}
.prompt-input-field {
position: relative;
width: 100%;
}
.prompt-input {
@apply flex-1 min-h-[96px] max-h-[200px] p-2.5 border rounded-md text-sm resize-none outline-none transition-colors;
@apply flex-1 w-full min-h-[56px] max-h-[96px] px-3 pt-2.5 pb-12 border rounded-md text-sm resize-none outline-none transition-colors;
font-family: inherit;
background-color: var(--surface-base);
color: inherit;
@@ -19,6 +23,45 @@
line-height: var(--line-height-normal);
}
.prompt-input-overlay {
position: absolute;
bottom: 1rem;
left: 0.75rem;
right: 0.75rem;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
font-size: 0.75rem;
line-height: 1.3;
color: var(--text-muted);
pointer-events: none;
z-index: 1;
}
.prompt-input-overlay.shell-mode {
color: var(--text-primary);
}
.prompt-overlay-text {
display: inline-flex;
align-items: center;
gap: 0.35rem;
}
.prompt-overlay-warning {
color: var(--status-warning);
font-weight: 500;
}
.prompt-overlay-shell-active {
color: var(--status-success);
font-weight: 600;
}
.prompt-overlay-muted {
color: var(--text-muted);
}
.prompt-input.shell-mode {
border-color: var(--status-success);
box-shadow: inset 0 0 0 1px rgba(76, 175, 80, 0.4);
@@ -80,9 +123,6 @@
height: 1rem;
}
.prompt-input-hints {
@apply px-4 pb-2 flex justify-between items-center;
}
.hint {
@apply text-xs;
@@ -141,3 +181,26 @@
.attachment-download:hover {
background-color: var(--attachment-chip-ring);
}
@media (pointer: coarse) {
.prompt-input-overlay {
display: none;
}
.prompt-input {
padding-bottom: 1.5rem;
}
}
@media (max-width: 640px) {
.prompt-input {
min-height: 64px;
padding: 0.5rem 0.75rem;
padding-bottom: 2.25rem;
}
.prompt-input-wrapper {
gap: 0.75rem;
padding: 0.75rem;
}
}

View File

@@ -58,6 +58,24 @@ session-sidebar-controls .selector-trigger-primary {
color: var(--text-muted);
}
.sidebar-selector-hints {
@apply flex items-center gap-2 w-full;
justify-content: space-between;
}
.sidebar-selector-hint--left,
.sidebar-selector-hint--right {
@apply flex-1;
}
.sidebar-selector-hint--left {
justify-content: flex-start;
}
.sidebar-selector-hint--right {
justify-content: flex-end;
}
.session-header-hints {
@apply flex-shrink-0;
}

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

@@ -0,0 +1,37 @@
import { defineConfig } from "vite"
import solid from "vite-plugin-solid"
import { dirname, resolve } from "path"
import { fileURLToPath } from "url"
const __dirname = dirname(fileURLToPath(import.meta.url))
export default defineConfig({
root: "./src/renderer",
plugins: [solid()],
css: {
postcss: "./postcss.config.js",
},
resolve: {
alias: {
"@": resolve(__dirname, "./src"),
},
},
optimizeDeps: {
exclude: ["lucide-solid"],
},
ssr: {
noExternal: ["lucide-solid"],
},
server: {
port: 3000,
},
build: {
outDir: "dist",
rollupOptions: {
input: {
main: resolve(__dirname, "./src/renderer/index.html"),
loading: resolve(__dirname, "./src/renderer/loading.html"),
},
},
},
})

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"),
},
},
},
})