Compare commits
24 Commits
split
...
v0.2.0-dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2052c5566e | ||
|
|
2486af2808 | ||
|
|
881afbba0a | ||
|
|
b6d48bfb69 | ||
|
|
d9596f7b4b | ||
|
|
6467bdfe7c | ||
|
|
4fdd299919 | ||
|
|
2de2d26043 | ||
|
|
70e6052dc8 | ||
|
|
2ff51c1866 | ||
|
|
d6fdef68d9 | ||
|
|
30b075e4ba | ||
|
|
3f46d73a31 | ||
|
|
038cf3c762 | ||
|
|
85c0632719 | ||
|
|
c4c2c92974 | ||
|
|
c5fd5694ee | ||
|
|
bc5423ce14 | ||
|
|
8fab34e356 | ||
|
|
d3ee15dcd7 | ||
|
|
45dca7a7f0 | ||
|
|
885059b0aa | ||
|
|
629d098add | ||
|
|
7e95005d8c |
178
.github/workflows/build-and-upload.yml
vendored
Normal file
178
.github/workflows/build-and-upload.yml
vendored
Normal file
@@ -0,0 +1,178 @@
|
||||
name: Build and Upload Binaries
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
version:
|
||||
description: "Version to apply to workspace packages"
|
||||
required: true
|
||||
type: string
|
||||
tag:
|
||||
description: "Git tag to upload assets to"
|
||||
required: true
|
||||
type: string
|
||||
release_name:
|
||||
description: "Release name (unused here, for context)"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
env:
|
||||
NODE_VERSION: 20
|
||||
|
||||
jobs:
|
||||
build-macos:
|
||||
runs-on: macos-13
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VERSION: ${{ inputs.version }}
|
||||
TAG: ${{ inputs.tag }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: npm
|
||||
|
||||
- name: Set workspace versions
|
||||
run: npm pkg set version=${VERSION} --workspaces --include-workspace-root
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --workspaces
|
||||
|
||||
- name: Build macOS binaries
|
||||
run: npm run build:mac --workspace @neuralnomads/codenomad-electron-app
|
||||
|
||||
- name: Upload release assets
|
||||
run: |
|
||||
shopt -s nullglob
|
||||
for file in packages/electron-app/release/*; do
|
||||
[ -f "$file" ] || continue
|
||||
case "$file" in
|
||||
*.dmg|*.zip)
|
||||
gh release upload "$TAG" "$file" --clobber
|
||||
;;
|
||||
*)
|
||||
echo "Skipping non-installer asset: $file"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
build-windows:
|
||||
runs-on: windows-latest
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VERSION: ${{ inputs.version }}
|
||||
TAG: ${{ inputs.tag }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: npm
|
||||
|
||||
- name: Set workspace versions
|
||||
run: npm pkg set version=${{ env.VERSION }} --workspaces --include-workspace-root
|
||||
shell: bash
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --workspaces
|
||||
|
||||
- name: Build Windows binaries
|
||||
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 {
|
||||
gh release upload $env:TAG $_.FullName --clobber
|
||||
}
|
||||
|
||||
build-linux:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VERSION: ${{ inputs.version }}
|
||||
TAG: ${{ inputs.tag }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: npm
|
||||
|
||||
- name: Set workspace versions
|
||||
run: npm pkg set version=${VERSION} --workspaces --include-workspace-root
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --workspaces
|
||||
|
||||
- name: Build Linux binaries
|
||||
run: npm run build:linux --workspace @neuralnomads/codenomad-electron-app
|
||||
|
||||
- name: Upload release assets
|
||||
run: |
|
||||
shopt -s nullglob
|
||||
for file in packages/electron-app/release/*; do
|
||||
[ -f "$file" ] || continue
|
||||
case "$file" in
|
||||
*.AppImage|*.deb|*.tar.gz)
|
||||
gh release upload "$TAG" "$file" --clobber
|
||||
;;
|
||||
*)
|
||||
echo "Skipping non-installer asset: $file"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
build-linux-rpm:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VERSION: ${{ inputs.version }}
|
||||
TAG: ${{ inputs.tag }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: npm
|
||||
|
||||
- name: Install rpm packaging dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y rpm ruby ruby-dev build-essential
|
||||
sudo gem install --no-document fpm
|
||||
|
||||
- name: Set workspace versions
|
||||
run: npm pkg set version=${VERSION} --workspaces --include-workspace-root
|
||||
|
||||
- name: Install project dependencies
|
||||
run: npm ci --workspaces
|
||||
|
||||
- name: Build Linux RPM binaries
|
||||
run: npm run build:linux-rpm --workspace @neuralnomads/codenomad-electron-app
|
||||
|
||||
- name: Upload RPM release assets
|
||||
run: |
|
||||
shopt -s nullglob
|
||||
for file in packages/electron-app/release/*.rpm; do
|
||||
[ -f "$file" ] || continue
|
||||
gh release upload "$TAG" "$file" --clobber
|
||||
done
|
||||
86
.github/workflows/dev-release.yml
vendored
Normal file
86
.github/workflows/dev-release.yml
vendored
Normal file
@@ -0,0 +1,86 @@
|
||||
name: Dev Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
env:
|
||||
NODE_VERSION: 20
|
||||
|
||||
jobs:
|
||||
prepare-dev:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.versions.outputs.version }}
|
||||
tag: ${{ steps.versions.outputs.tag }}
|
||||
release_name: ${{ steps.versions.outputs.release_name }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
|
||||
- name: Compute dev versions
|
||||
id: versions
|
||||
run: |
|
||||
BASE_VERSION=$(node -p "require('./package.json').version")
|
||||
DEV_VERSION="${BASE_VERSION}-dev"
|
||||
TIMESTAMP=$(date -u +%y%m%d-%H%M)
|
||||
TAG="v${DEV_VERSION}-${TIMESTAMP}"
|
||||
echo "version=$DEV_VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
||||
echo "release_name=$TAG" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Create GitHub release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAG: ${{ steps.versions.outputs.tag }}
|
||||
run: |
|
||||
if gh release view "$TAG" >/dev/null 2>&1; then
|
||||
echo "Release $TAG already exists"
|
||||
else
|
||||
gh release create "$TAG" --title "$TAG" --generate-notes
|
||||
fi
|
||||
|
||||
build-and-upload:
|
||||
needs: prepare-dev
|
||||
uses: ./.github/workflows/build-and-upload.yml
|
||||
with:
|
||||
version: ${{ needs.prepare-dev.outputs.version }}
|
||||
tag: ${{ needs.prepare-dev.outputs.tag }}
|
||||
release_name: ${{ needs.prepare-dev.outputs.release_name }}
|
||||
secrets: inherit
|
||||
|
||||
publish-server:
|
||||
needs: 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 pkg set version=${VERSION} --workspaces --include-workspace-root
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --workspaces
|
||||
|
||||
- name: Build server package
|
||||
run: npm run build --workspace @neuralnomads/codenomad
|
||||
|
||||
- name: Publish server package to dev tag
|
||||
run: npm publish --workspace @neuralnomads/codenomad --access public --tag dev
|
||||
148
.github/workflows/release.yml
vendored
148
.github/workflows/release.yml
vendored
@@ -63,84 +63,21 @@ jobs:
|
||||
gh release create "$TAG" --title "CodeNomad v${VERSION}" --generate-notes
|
||||
fi
|
||||
|
||||
build-macos:
|
||||
build-and-upload:
|
||||
needs: prepare-release
|
||||
runs-on: macos-13
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: ./.github/workflows/build-and-upload.yml
|
||||
with:
|
||||
version: ${{ needs.prepare-release.outputs.version }}
|
||||
tag: ${{ needs.prepare-release.outputs.tag }}
|
||||
release_name: CodeNomad v${{ needs.prepare-release.outputs.version }}
|
||||
secrets: inherit
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --workspaces
|
||||
|
||||
- name: Build macOS binaries
|
||||
run: npm run build:mac --workspace @codenomad/electron-app
|
||||
|
||||
- name: Upload release assets
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAG: ${{ needs.prepare-release.outputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
shopt -s nullglob
|
||||
for file in packages/electron-app/release/*; do
|
||||
[ -f "$file" ] || continue
|
||||
case "$file" in
|
||||
*.dmg|*.zip)
|
||||
gh release upload "$TAG" "$file" --clobber
|
||||
;;
|
||||
*)
|
||||
echo "Skipping non-installer asset: $file"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
build-windows:
|
||||
needs: prepare-release
|
||||
runs-on: windows-latest
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --workspaces
|
||||
|
||||
- name: Build Windows binaries
|
||||
run: npm run build:win --workspace @codenomad/electron-app
|
||||
|
||||
- name: Upload release assets
|
||||
shell: pwsh
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAG: ${{ needs.prepare-release.outputs.tag }}
|
||||
run: |
|
||||
Get-ChildItem -Path "packages/electron-app/release" -File | Where-Object {
|
||||
$_.Name -match '\.(exe|zip)$'
|
||||
} | ForEach-Object {
|
||||
gh release upload $env:TAG $_.FullName --clobber
|
||||
}
|
||||
|
||||
build-linux:
|
||||
needs: prepare-release
|
||||
publish-server:
|
||||
needs: build-and-upload
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
NODE_VERSION: 20
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -154,63 +91,8 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: npm ci --workspaces
|
||||
|
||||
- name: Build Linux binaries
|
||||
run: npm run build:linux --workspace @codenomad/electron-app
|
||||
- name: Build server package
|
||||
run: npm run build --workspace @neuralnomads/codenomad
|
||||
|
||||
- name: Upload release assets
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAG: ${{ needs.prepare-release.outputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
shopt -s nullglob
|
||||
for file in packages/electron-app/release/*; do
|
||||
[ -f "$file" ] || continue
|
||||
case "$file" in
|
||||
*.AppImage|*.deb|*.tar.gz)
|
||||
gh release upload "$TAG" "$file" --clobber
|
||||
;;
|
||||
*)
|
||||
echo "Skipping non-installer asset: $file"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
build-linux-rpm:
|
||||
needs: prepare-release
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: npm
|
||||
|
||||
- name: Install rpm packaging dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y rpm ruby ruby-dev build-essential
|
||||
sudo gem install --no-document fpm
|
||||
|
||||
- name: Install project dependencies
|
||||
run: npm ci --workspaces
|
||||
|
||||
- name: Build Linux RPM binaries
|
||||
run: npm run build:linux-rpm --workspace @codenomad/electron-app
|
||||
|
||||
- name: Upload RPM release assets
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAG: ${{ needs.prepare-release.outputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
shopt -s nullglob
|
||||
for file in packages/electron-app/release/*.rpm; do
|
||||
[ -f "$file" ] || continue
|
||||
gh release upload "$TAG" "$file" --clobber
|
||||
done
|
||||
- name: Publish server package
|
||||
run: npm publish --workspace @neuralnomads/codenomad --access public --tag latest
|
||||
|
||||
6
BUILD.md
6
BUILD.md
@@ -13,7 +13,7 @@ This guide explains how to build distributable binaries for CodeNomad.
|
||||
All commands now run inside the workspace packages. From the repo root you can target the Electron app package directly:
|
||||
|
||||
```bash
|
||||
npm run build --workspace @codenomad/electron-app
|
||||
npm run build --workspace @neuralnomads/codenomad-electron-app
|
||||
```
|
||||
|
||||
### Build for Current Platform (macOS default)
|
||||
@@ -77,8 +77,8 @@ bun run build:all
|
||||
|
||||
The build script performs these steps:
|
||||
|
||||
1. **Compile TypeScript** → Electron app (main, preload, renderer)
|
||||
2. **Bundle with Vite** → Optimized production build
|
||||
1. **Build @neuralnomads/codenomad** → Produces the CLI `dist/` bundle (also rebuilds the UI assets it serves)
|
||||
2. **Compile TypeScript + bundle with Vite** → Electron main, preload, and renderer output in `dist/`
|
||||
3. **Package with electron-builder** → Platform-specific binaries
|
||||
|
||||
## Output
|
||||
|
||||
92
README.md
92
README.md
@@ -1,58 +1,70 @@
|
||||
# CodeNomad
|
||||
## A fast, multi-instance desktop client for running OpenCode sessions the way long-haul builders actually work.
|
||||
|
||||
## What is CodeNomad?
|
||||
## A fast, multi-instance workspace for running OpenCode sessions.
|
||||
|
||||
CodeNomad is built for people who live inside OpenCode for hours on end and need a cockpit, not a kiosk. When terminals get unwieldy and web clients feel laggy, CodeNomad delivers a desktop-native workspace that favors speed, clarity, and direct control. It runs on macOS, Windows, and Linux using Electron + SolidJS, with prebuilt binaries so you can get started immediately.
|
||||
CodeNomad is built for people who live inside OpenCode for hours on end and need a cockpit, not a kiosk. It delivers a premium, low-latency workspace that favors speed, clarity, and direct control.
|
||||
|
||||

|
||||
_Manage multiple OpenCode sessions side-by-side._
|
||||
|
||||
<details>
|
||||
<summary>📸 More Screenshots</summary>
|
||||
|
||||

|
||||
_Global command palette for keyboard-first control._
|
||||
|
||||

|
||||
_Rich media previews for images and assets._
|
||||
|
||||

|
||||
_Browser support via CodeNomad Server._
|
||||
|
||||
</details>
|
||||
|
||||
## Getting Started
|
||||
|
||||
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.
|
||||
|
||||
- **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.
|
||||
|
||||
### 💻 CodeNomad Server
|
||||
Run CodeNomad as a local server and access it via your web browser. Perfect for remote development (SSH/VPN) or running as a service.
|
||||
|
||||
```bash
|
||||
npx @neuralnomads/codenomad --launch
|
||||
```
|
||||
|
||||
This command starts the server and opens the web client in your default browser.
|
||||
|
||||
## Highlights
|
||||
|
||||
- **Long-session native** – scroll through massive transcripts without hitches and keep full context visible.
|
||||
- **Multiple instances, one window** – juggle several OpenCode instances side-by-side with per-instance tabs.
|
||||
- **Deep task awareness** – jump into sub/child sessions (Tasks tool) instantly, monitor their status, and answer directly without losing your flow.
|
||||
- **Keyboard first** – the full UI is optimized for shortcuts so you can stay mouse-free when you want to.
|
||||
- **Command palette superpowers** – summon a single, global palette to jump tabs, launch tools, tweak preferences, or fire shortcuts. Every action is categorized, fuzzy searchable, and previewed so you can chain moves together in seconds. It keeps your workflow predictable and fast whether you are juggling one session or ten.
|
||||
- **Developer-friendly rendering** – syntax highlighting, inline diffs, and thoughtful presentation keep the signal high.
|
||||
- **Multi-Instance**: Juggle several OpenCode sessions side-by-side with tabs.
|
||||
- **Long-Session Native**: Scroll through massive transcripts without hitches.
|
||||
- **Command Palette**: A single global palette to jump tabs, launch tools, and control everything.
|
||||
- **Deep Task Awareness**: Monitor background tasks and child sessions without losing flow.
|
||||
|
||||
## Requirements
|
||||
|
||||
- [OpenCode CLI](https://opencode.ai) installed and available in your `PATH`, or point CodeNomad to a local binary through Advanced Settings.
|
||||
- **[OpenCode CLI](https://opencode.ai)**: Must be installed and available in your `PATH`.
|
||||
- **Node.js 18+**: Required if running the CLI server or building from source.
|
||||
|
||||
## Repository Layout
|
||||
## Architecture & Development
|
||||
|
||||
CodeNomad now ships as a small workspace with two packages:
|
||||
CodeNomad is a monorepo split into specialized packages. If you want to contribute or build from source, check out the individual package documentation:
|
||||
|
||||
- `packages/ui` — SolidJS renderer, Tailwind styles, and standalone Vite configuration for building the UI bundle independently.
|
||||
- `packages/electron-app` — Electron main/preload processes plus packaging scripts. It consumes the UI package during development/build via `electron-vite`.
|
||||
| Package | Description |
|
||||
|---------|-------------|
|
||||
| **[packages/electron-app](packages/electron-app/README.md)** | The native desktop application shell. Wraps the UI and Server. |
|
||||
| **[packages/server](packages/server/README.md)** | The core logic and CLI. Manages workspaces, proxies OpenCode, and serves the API. |
|
||||
| **[packages/ui](packages/ui/README.md)** | The SolidJS-based frontend. Fast, reactive, and beautiful. |
|
||||
|
||||
Use `npm run dev --workspace @codenomad/electron-app` for the Electron shell and `npm run dev --workspace @codenomad/ui` for UI-only work. Working with the workspace requires Node.js 18+ with npm 7 or newer so the workspace protocol is available.
|
||||
|
||||
## Downloads
|
||||
|
||||
Grab the latest build for macOS, Windows, and Linux from the [GitHub Releases page](https://github.com/shantur/CodeNomad/releases).
|
||||
|
||||
## Quick Start
|
||||
|
||||
1. Install the OpenCode CLI and confirm it is reachable via your terminal.
|
||||
2. Download the CodeNomad build for your platform and launch the app.
|
||||
3. Connect to one or more OpenCode instances, set keyboard shortcuts in preferences, and start a session.
|
||||
4. Use tabs to swap between instances, the task sidebar to dive into child sessions, and the prompt input to keep shipping.
|
||||
|
||||
## CLI Server Flags
|
||||
|
||||
The bundled CLI server (`@codenomad/cli`) controls which folders the UI can browse when you pick a workspace:
|
||||
|
||||
- `--workspace-root <path>` (default: current working directory) scopes browsing to a safe subtree. The UI can only see folders beneath this root.
|
||||
- `--unrestricted-root` explicitly allows full-machine browsing for the current process. In this mode the UI starts from the host home directory, adds a "parent" option so you can reach `/` on macOS/Linux, and lists drives/UNC paths on Windows. The flag is runtime-only—restart the CLI without it to go back to restricted mode.
|
||||
- `--ui-dev-server <url>` proxies UI asset requests to a running Vite dev server while the CLI continues to expose its REST APIs and workspace proxies from the same port. Point this at `http://localhost:3000` when developing the renderer to keep hot reloads without sacrificing the single entry point.
|
||||
|
||||
Use unrestricted mode only when you trust the host; the CLI will skip directories it cannot read and never persists the opt-in.
|
||||
|
||||
### Single Port Proxying
|
||||
|
||||
Every OpenCode instance now tunnels through the CLI port. Each workspace descriptor publishes a stable `proxyPath` (e.g., `/workspaces/<id>/instance`), and the CLI exposes `GET/POST/...` + SSE at `http(s)://<cli-host>:<cli-port>${proxyPath}`. That means the UI, Electron shell, and browser clients only need firewall access to the CLI; instance ports stay private on `127.0.0.1`. In development, the `--ui-dev-server` flag still routes UI traffic through the CLI proxy so all instance calls share the same origin.
|
||||
### Quick Build
|
||||
To build the Desktop App from source:
|
||||
|
||||
1. Clone the repo.
|
||||
2. Run `npm install` (requires pnpm or npm 7+ for workspaces).
|
||||
3. Run `npm run build --workspace @neuralnomads/codenomad-electron-app`.
|
||||
|
||||
BIN
images/browser-support.png
Normal file
BIN
images/browser-support.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
BIN
images/image-previews.png
Normal file
BIN
images/image-previews.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
37
package-lock.json
generated
37
package-lock.json
generated
@@ -313,11 +313,11 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codenomad/cli": {
|
||||
"resolved": "packages/cli",
|
||||
"node_modules/@neuralnomads/codenomad": {
|
||||
"resolved": "packages/server",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@codenomad/electron-app": {
|
||||
"node_modules/@neuralnomads/codenomad-electron-app": {
|
||||
"resolved": "packages/electron-app",
|
||||
"link": true
|
||||
},
|
||||
@@ -5000,15 +5000,6 @@
|
||||
],
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/ignore": {
|
||||
"version": "7.0.5",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
|
||||
"integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/inflight": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
|
||||
@@ -8399,8 +8390,8 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@codenomad/cli",
|
||||
"packages/server": {
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^8.5.0",
|
||||
@@ -8408,12 +8399,13 @@
|
||||
"@fastify/static": "^7.0.4",
|
||||
"commander": "^12.1.0",
|
||||
"fastify": "^4.28.1",
|
||||
"fuzzysort": "^2.0.4",
|
||||
"pino": "^9.4.0",
|
||||
"undici": "^6.19.8",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"bin": {
|
||||
"codenomad-cli": "dist/bin.js"
|
||||
"codenomad": "dist/bin.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"cross-env": "^7.0.3",
|
||||
@@ -8422,7 +8414,7 @@
|
||||
"typescript": "^5.6.3"
|
||||
}
|
||||
},
|
||||
"packages/cli/node_modules/commander": {
|
||||
"packages/server/node_modules/commander": {
|
||||
"version": "12.1.0",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz",
|
||||
"integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==",
|
||||
@@ -8431,12 +8423,18 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"packages/server/node_modules/fuzzysort": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/fuzzysort/-/fuzzysort-2.0.4.tgz",
|
||||
"integrity": "sha512-Api1mJL+Ad7W7vnDZnWq5pGaXJjyencT+iKGia2PlHUcSsSzWwIQ3S1isiMpwpavjYtGd2FzhUIhnnhOULZgDw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"packages/electron-app": {
|
||||
"name": "@codenomad/electron-app",
|
||||
"name": "@neuralnomads/codenomad-electron-app",
|
||||
"version": "0.1.2",
|
||||
"dependencies": {
|
||||
"@codenomad/ui": "file:../ui",
|
||||
"ignore": "7.0.5"
|
||||
"@neuralnomads/codenomad": "file:../server",
|
||||
"@codenomad/ui": "file:../ui"
|
||||
},
|
||||
"devDependencies": {
|
||||
"7zip-bin": "^5.2.0",
|
||||
@@ -8446,6 +8444,7 @@
|
||||
"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"
|
||||
|
||||
18
package.json
18
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "codenomad-workspace",
|
||||
"version": "0.1.2",
|
||||
"version": "0.2.0",
|
||||
"private": true,
|
||||
"description": "CodeNomad monorepo workspace",
|
||||
"workspaces": {
|
||||
@@ -9,14 +9,16 @@
|
||||
]
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "npm run dev --workspace @codenomad/electron-app",
|
||||
"dev:electron": "npm run dev --workspace @codenomad/electron-app",
|
||||
"dev:ui": "npm run dev --workspace @codenomad/ui",
|
||||
"build": "npm run build --workspace @codenomad/electron-app",
|
||||
"dev": "npm run dev --workspace @neuralnomads/codenomad-electron-app",
|
||||
"dev:electron": "npm run dev --workspace @neuralnomads/codenomad-electron-app",
|
||||
"dev:tauri": "npm run dev --workspace @codenomad/tauri-app",
|
||||
"build": "npm run build --workspace @neuralnomads/codenomad-electron-app",
|
||||
"build:tauri": "npm run build --workspace @codenomad/tauri-app",
|
||||
"build:ui": "npm run build --workspace @codenomad/ui",
|
||||
"build:mac-x64": "npm run build:mac-x64 --workspace @codenomad/electron-app",
|
||||
"build:binaries": "npm run build:binaries --workspace @codenomad/electron-app",
|
||||
"typecheck": "npm run typecheck --workspace @codenomad/ui && npm run typecheck --workspace @codenomad/electron-app"
|
||||
"build:mac-x64": "npm run build:mac-x64 --workspace @neuralnomads/codenomad-electron-app",
|
||||
"build:binaries": "npm run build:binaries --workspace @neuralnomads/codenomad-electron-app",
|
||||
"typecheck": "npm run typecheck --workspace @codenomad/ui && npm run typecheck --workspace @neuralnomads/codenomad-electron-app",
|
||||
"bumpVersion": "npm version --workspaces --include-workspace-root --no-git-tag-version"
|
||||
},
|
||||
"dependencies": {
|
||||
"7zip-bin": "^5.2.0",
|
||||
|
||||
40
packages/electron-app/README.md
Normal file
40
packages/electron-app/README.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# CodeNomad App
|
||||
|
||||
This package contains the native desktop application shell for CodeNomad, built with [Electron](https://www.electronjs.org/).
|
||||
|
||||
## Overview
|
||||
|
||||
The Electron app wraps the CodeNomad UI and Server into a standalone executable. It provides deeper system integration, such as:
|
||||
- Native window management
|
||||
- Global keyboard shortcuts
|
||||
- Application menu integration
|
||||
|
||||
## Development
|
||||
|
||||
To run the Electron app in development mode:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
This will start the renderer (UI) and the main process with hot reloading.
|
||||
|
||||
## Building
|
||||
|
||||
To build the application for your current platform:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
To build for specific platforms (requires appropriate build tools):
|
||||
|
||||
- **macOS**: `npm run build:mac`
|
||||
- **Windows**: `npm run build:win`
|
||||
- **Linux**: `npm run build:linux`
|
||||
|
||||
## Structure
|
||||
|
||||
- `electron/main`: Main process code (window creation, IPC).
|
||||
- `electron/preload`: Preload scripts for secure bridge between main and renderer.
|
||||
- `electron/resources`: Static assets like icons.
|
||||
@@ -25,7 +25,7 @@ export default defineConfig({
|
||||
build: {
|
||||
outDir: "dist/preload",
|
||||
lib: {
|
||||
entry: resolve(__dirname, "electron/preload/index.ts"),
|
||||
entry: resolve(__dirname, "electron/preload/index.cjs"),
|
||||
formats: ["cjs"],
|
||||
fileName: () => "index.js",
|
||||
},
|
||||
|
||||
@@ -1,243 +1,30 @@
|
||||
import { ipcMain, BrowserWindow, dialog } from "electron"
|
||||
import { processManager } from "./process-manager"
|
||||
import { randomBytes } from "crypto"
|
||||
import * as fs from "fs"
|
||||
import * as path from "path"
|
||||
import { spawn } from "child_process"
|
||||
import ignore from "ignore"
|
||||
import { BrowserWindow, ipcMain } from "electron"
|
||||
import type { CliLogEntry, CliProcessManager, CliStatus } from "./process-manager"
|
||||
|
||||
interface Instance {
|
||||
id: string
|
||||
folder: string
|
||||
port: number
|
||||
pid: number
|
||||
status: "starting" | "ready" | "error" | "stopped"
|
||||
error?: string
|
||||
}
|
||||
|
||||
const instances = new Map<string, Instance>()
|
||||
|
||||
function generateId(): string {
|
||||
return randomBytes(16).toString("hex")
|
||||
}
|
||||
|
||||
function runBinaryVersion(binaryPath: string, timeoutMs = 5000): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(binaryPath, ["-v"], {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
})
|
||||
|
||||
let stdout = ""
|
||||
let stderr = ""
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
child.kill("SIGTERM")
|
||||
reject(new Error("Version check timed out"))
|
||||
}, timeoutMs)
|
||||
|
||||
child.stdout?.on("data", (data) => {
|
||||
stdout += data.toString()
|
||||
})
|
||||
|
||||
child.stderr?.on("data", (data) => {
|
||||
stderr += data.toString()
|
||||
})
|
||||
|
||||
child.on("error", (error) => {
|
||||
clearTimeout(timeout)
|
||||
reject(error)
|
||||
})
|
||||
|
||||
child.on("close", (code) => {
|
||||
clearTimeout(timeout)
|
||||
if (code === 0) {
|
||||
resolve(stdout.trim())
|
||||
} else {
|
||||
reject(new Error(stderr.trim() || `Binary exited with code ${code}`))
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export function setupInstanceIPC(mainWindow: BrowserWindow) {
|
||||
processManager.setMainWindow(mainWindow)
|
||||
|
||||
ipcMain.handle("dialog:selectFolder", async () => {
|
||||
const result = await dialog.showOpenDialog(mainWindow!, {
|
||||
title: "Select Project Folder",
|
||||
properties: ["openDirectory"],
|
||||
})
|
||||
|
||||
if (result.canceled || !result.filePaths.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return result.filePaths[0]
|
||||
})
|
||||
|
||||
ipcMain.handle(
|
||||
"instance:create",
|
||||
async (event, id: string, folder: string, binaryPath?: string, environmentVariables?: Record<string, string>) => {
|
||||
const instance: Instance = {
|
||||
id,
|
||||
folder,
|
||||
port: 0,
|
||||
pid: 0,
|
||||
status: "starting",
|
||||
}
|
||||
|
||||
instances.set(id, instance)
|
||||
|
||||
try {
|
||||
const {
|
||||
pid,
|
||||
port,
|
||||
binaryPath: actualBinaryPath,
|
||||
} = await processManager.spawn(folder, id, binaryPath, environmentVariables)
|
||||
|
||||
instance.port = port
|
||||
instance.pid = pid
|
||||
instance.status = "ready"
|
||||
|
||||
mainWindow.webContents.send("instance:started", { id, port, pid, binaryPath: actualBinaryPath })
|
||||
|
||||
const meta = processManager.getAllProcesses().get(pid)
|
||||
if (meta) {
|
||||
meta.childProcess.on("exit", (code, signal) => {
|
||||
instance.status = "stopped"
|
||||
mainWindow.webContents.send("instance:stopped", { id })
|
||||
})
|
||||
}
|
||||
|
||||
return { id, port, pid, binaryPath: actualBinaryPath }
|
||||
} catch (error) {
|
||||
instance.status = "error"
|
||||
instance.error = error instanceof Error ? error.message : String(error)
|
||||
|
||||
mainWindow.webContents.send("instance:error", {
|
||||
id,
|
||||
error: instance.error,
|
||||
})
|
||||
|
||||
throw error
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
ipcMain.handle("instance:stop", async (event, pid: number) => {
|
||||
await processManager.kill(pid)
|
||||
|
||||
for (const [id, instance] of instances.entries()) {
|
||||
if (instance.pid === pid) {
|
||||
instance.status = "stopped"
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle("instance:status", async (event, pid: number) => {
|
||||
return processManager.getStatus(pid)
|
||||
})
|
||||
|
||||
ipcMain.handle("instance:list", async () => {
|
||||
return Array.from(instances.values())
|
||||
})
|
||||
|
||||
ipcMain.handle("fs:scanDirectory", async (event, workspaceFolder: string) => {
|
||||
const ig = ignore()
|
||||
ig.add([".git", "node_modules"])
|
||||
|
||||
const gitignorePath = path.join(workspaceFolder, ".gitignore")
|
||||
if (fs.existsSync(gitignorePath)) {
|
||||
const content = fs.readFileSync(gitignorePath, "utf-8")
|
||||
ig.add(content)
|
||||
}
|
||||
|
||||
function scanDir(dirPath: string, baseDir: string): string[] {
|
||||
const results: string[] = []
|
||||
|
||||
try {
|
||||
const entries = fs.readdirSync(dirPath, { withFileTypes: true })
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dirPath, entry.name)
|
||||
const relativePath = path.relative(baseDir, fullPath)
|
||||
|
||||
if (ig.ignores(relativePath)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
const dirWithSlash = relativePath + "/"
|
||||
if (!ig.ignores(dirWithSlash)) {
|
||||
results.push(dirWithSlash)
|
||||
const subFiles = scanDir(fullPath, baseDir)
|
||||
results.push(...subFiles)
|
||||
}
|
||||
} else {
|
||||
results.push(relativePath)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Error scanning ${dirPath}:`, error)
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
return scanDir(workspaceFolder, workspaceFolder)
|
||||
})
|
||||
|
||||
// OpenCode binary operations
|
||||
ipcMain.handle("dialog:selectOpenCodeBinary", async () => {
|
||||
const result = await dialog.showOpenDialog(mainWindow!, {
|
||||
title: "Select OpenCode Binary",
|
||||
filters: [
|
||||
{ name: "Executable Files", extensions: ["exe", "cmd", "bat", "sh", "command", "app", ""] },
|
||||
{ name: "All Files", extensions: ["*"] },
|
||||
],
|
||||
properties: ["openFile"],
|
||||
})
|
||||
|
||||
if (result.canceled || !result.filePaths.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return result.filePaths[0]
|
||||
})
|
||||
|
||||
ipcMain.handle("opencode:validateBinary", async (event, binaryPath: string) => {
|
||||
try {
|
||||
// Special handling for system PATH binary
|
||||
const isSystemPath = binaryPath === "opencode"
|
||||
|
||||
if (!isSystemPath) {
|
||||
// Check if file exists and is executable for custom paths
|
||||
if (!fs.existsSync(binaryPath)) {
|
||||
return { valid: false, error: "File does not exist" }
|
||||
}
|
||||
|
||||
const stats = fs.statSync(binaryPath)
|
||||
if (!stats.isFile()) {
|
||||
return { valid: false, error: "Path is not a file" }
|
||||
}
|
||||
}
|
||||
|
||||
// Try to get version once via -v flag
|
||||
try {
|
||||
const version = await runBinaryVersion(binaryPath)
|
||||
return { valid: true, version }
|
||||
} catch (error) {
|
||||
return {
|
||||
valid: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
valid: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}
|
||||
}
|
||||
})
|
||||
export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessManager) {
|
||||
cliManager.on("status", (status: CliStatus) => {
|
||||
if (!mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send("cli:status", status)
|
||||
}
|
||||
})
|
||||
|
||||
cliManager.on("ready", (status: CliStatus) => {
|
||||
if (!mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send("cli:ready", status)
|
||||
}
|
||||
})
|
||||
|
||||
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 })
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle("cli:getStatus", async () => cliManager.getStatus())
|
||||
}
|
||||
|
||||
@@ -1,30 +1,99 @@
|
||||
import { app, BrowserWindow, dialog, ipcMain, nativeImage, nativeTheme, session } from "electron"
|
||||
import { join } from "path"
|
||||
import { app, BrowserView, BrowserWindow, nativeImage, session } from "electron"
|
||||
import { existsSync } from "fs"
|
||||
import { dirname, join } from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
import { createApplicationMenu } from "./menu"
|
||||
import { setupInstanceIPC } from "./ipc"
|
||||
import { setupStorageIPC } from "./storage"
|
||||
import { setupCliIPC } from "./ipc"
|
||||
import { CliProcessManager } from "./process-manager"
|
||||
|
||||
const mainFilename = fileURLToPath(import.meta.url)
|
||||
const mainDirname = dirname(mainFilename)
|
||||
|
||||
const isMac = process.platform === "darwin"
|
||||
|
||||
const cliManager = new CliProcessManager()
|
||||
let mainWindow: BrowserWindow | null = null
|
||||
let currentCliUrl: string | null = null
|
||||
let pendingCliUrl: string | null = null
|
||||
let showingLoadingScreen = false
|
||||
let preloadingView: BrowserView | null = null
|
||||
|
||||
if (isMac) {
|
||||
app.commandLine.appendSwitch("disable-spell-checking")
|
||||
}
|
||||
|
||||
// Setup IPC handlers before creating windows
|
||||
setupStorageIPC()
|
||||
|
||||
let mainWindow: BrowserWindow | null = null
|
||||
|
||||
function getIconPath() {
|
||||
if (app.isPackaged) {
|
||||
return join(process.resourcesPath, "icon.png")
|
||||
}
|
||||
|
||||
return join(app.getAppPath(), "electron/resources/icon.png")
|
||||
return join(mainDirname, "../resources/icon.png")
|
||||
}
|
||||
|
||||
function getLoadingHtmlPath() {
|
||||
if (app.isPackaged) {
|
||||
return join(process.resourcesPath, "loading.html")
|
||||
}
|
||||
|
||||
const distResources = join(mainDirname, "../resources/loading.html")
|
||||
if (existsSync(distResources)) {
|
||||
return distResources
|
||||
}
|
||||
|
||||
const devResources = join(mainDirname, "../electron/resources/loading.html")
|
||||
if (existsSync(devResources)) {
|
||||
return devResources
|
||||
}
|
||||
|
||||
return join(process.cwd(), "electron/resources/loading.html")
|
||||
}
|
||||
|
||||
let cachedPreloadPath: string | null = null
|
||||
function getPreloadPath() {
|
||||
if (cachedPreloadPath && existsSync(cachedPreloadPath)) {
|
||||
return cachedPreloadPath
|
||||
}
|
||||
|
||||
const candidates = [
|
||||
join(process.resourcesPath, "preload/index.js"),
|
||||
join(mainDirname, "../preload/index.js"),
|
||||
join(mainDirname, "../preload/index.cjs"),
|
||||
join(mainDirname, "../../preload/index.cjs"),
|
||||
join(mainDirname, "../../electron/preload/index.cjs"),
|
||||
join(app.getAppPath(), "preload/index.cjs"),
|
||||
join(app.getAppPath(), "electron/preload/index.cjs"),
|
||||
]
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (existsSync(candidate)) {
|
||||
cachedPreloadPath = candidate
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
||||
return join(mainDirname, "../preload/index.js")
|
||||
}
|
||||
|
||||
function destroyPreloadingView(target?: BrowserView | null) {
|
||||
const view = target ?? preloadingView
|
||||
if (!view) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const contents = view.webContents as any
|
||||
contents?.destroy?.()
|
||||
} catch (error) {
|
||||
console.warn("[cli] failed to destroy preloading view", error)
|
||||
}
|
||||
|
||||
if (!target || view === preloadingView) {
|
||||
preloadingView = null
|
||||
}
|
||||
}
|
||||
|
||||
function createWindow() {
|
||||
const prefersDark = true //nativeTheme.shouldUseDarkColors
|
||||
const prefersDark = true
|
||||
const backgroundColor = prefersDark ? "#1a1a1a" : "#ffffff"
|
||||
const iconPath = getIconPath()
|
||||
|
||||
@@ -36,7 +105,7 @@ function createWindow() {
|
||||
backgroundColor,
|
||||
icon: iconPath,
|
||||
webPreferences: {
|
||||
preload: join(__dirname, "../preload/index.js"),
|
||||
preload: getPreloadPath(),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
spellcheck: !isMac,
|
||||
@@ -44,25 +113,138 @@ function createWindow() {
|
||||
})
|
||||
|
||||
if (isMac) {
|
||||
// Disable macOS spell server to avoid input lag
|
||||
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))
|
||||
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
mainWindow.loadURL("http://localhost:3000")
|
||||
mainWindow.webContents.openDevTools()
|
||||
} else {
|
||||
mainWindow.loadFile(join(__dirname, "../renderer/index.html"))
|
||||
mainWindow.webContents.openDevTools({ mode: "detach" })
|
||||
}
|
||||
|
||||
createApplicationMenu(mainWindow)
|
||||
setupInstanceIPC(mainWindow)
|
||||
setupCliIPC(mainWindow, cliManager)
|
||||
|
||||
mainWindow.on("closed", () => {
|
||||
destroyPreloadingView()
|
||||
mainWindow = null
|
||||
currentCliUrl = null
|
||||
pendingCliUrl = null
|
||||
showingLoadingScreen = false
|
||||
})
|
||||
|
||||
if (pendingCliUrl) {
|
||||
const url = pendingCliUrl
|
||||
pendingCliUrl = null
|
||||
startCliPreload(url)
|
||||
}
|
||||
}
|
||||
|
||||
function showLoadingScreen(force = false) {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
|
||||
if (showingLoadingScreen && !force) {
|
||||
return
|
||||
}
|
||||
|
||||
destroyPreloadingView()
|
||||
showingLoadingScreen = true
|
||||
currentCliUrl = null
|
||||
pendingCliUrl = null
|
||||
const loadingHtml = getLoadingHtmlPath()
|
||||
mainWindow.loadFile(loadingHtml).catch((error) => console.error("[cli] failed to load loading screen:", error))
|
||||
}
|
||||
|
||||
function startCliPreload(url: string) {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||
pendingCliUrl = url
|
||||
return
|
||||
}
|
||||
|
||||
if (currentCliUrl === url && !showingLoadingScreen) {
|
||||
return
|
||||
}
|
||||
|
||||
pendingCliUrl = url
|
||||
destroyPreloadingView()
|
||||
|
||||
if (!showingLoadingScreen) {
|
||||
showLoadingScreen(true)
|
||||
}
|
||||
|
||||
const view = new BrowserView({
|
||||
webPreferences: {
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
spellcheck: !isMac,
|
||||
},
|
||||
})
|
||||
|
||||
preloadingView = view
|
||||
|
||||
view.webContents.once("did-finish-load", () => {
|
||||
if (preloadingView !== view) {
|
||||
destroyPreloadingView(view)
|
||||
return
|
||||
}
|
||||
finalizeCliSwap(url)
|
||||
})
|
||||
|
||||
view.webContents.loadURL(url).catch((error) => {
|
||||
console.error("[cli] failed to preload CLI view:", error)
|
||||
if (preloadingView === view) {
|
||||
destroyPreloadingView(view)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function finalizeCliSwap(url: string) {
|
||||
destroyPreloadingView()
|
||||
|
||||
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||
pendingCliUrl = url
|
||||
return
|
||||
}
|
||||
|
||||
showingLoadingScreen = false
|
||||
currentCliUrl = url
|
||||
pendingCliUrl = null
|
||||
mainWindow.loadURL(url).catch((error) => console.error("[cli] failed to load CLI view:", error))
|
||||
}
|
||||
|
||||
|
||||
async function startCli() {
|
||||
try {
|
||||
const devMode = process.env.NODE_ENV === "development"
|
||||
console.info("[cli] start requested (dev mode:", devMode, ")")
|
||||
await cliManager.start({ dev: devMode })
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
console.error("[cli] start failed:", message)
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send("cli:error", { message })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cliManager.on("ready", (status) => {
|
||||
if (!status.url) {
|
||||
return
|
||||
}
|
||||
startCliPreload(status.url)
|
||||
})
|
||||
|
||||
cliManager.on("status", (status) => {
|
||||
if (status.state !== "ready") {
|
||||
showLoadingScreen()
|
||||
}
|
||||
})
|
||||
|
||||
if (isMac) {
|
||||
app.on("web-contents-created", (_, contents) => {
|
||||
contents.session.setSpellCheckerEnabled(false)
|
||||
@@ -70,6 +252,8 @@ if (isMac) {
|
||||
}
|
||||
|
||||
app.whenReady().then(() => {
|
||||
startCli()
|
||||
|
||||
if (isMac) {
|
||||
session.defaultSession.setSpellCheckerEnabled(false)
|
||||
app.on("browser-window-created", (_, window) => {
|
||||
@@ -84,8 +268,6 @@ app.whenReady().then(() => {
|
||||
}
|
||||
}
|
||||
|
||||
console.log("[spellcheck] default session enabled:", session.defaultSession.isSpellCheckerEnabled())
|
||||
|
||||
createWindow()
|
||||
|
||||
app.on("activate", () => {
|
||||
@@ -95,6 +277,12 @@ app.whenReady().then(() => {
|
||||
})
|
||||
})
|
||||
|
||||
app.on("before-quit", async (event) => {
|
||||
event.preventDefault()
|
||||
await cliManager.stop().catch(() => {})
|
||||
app.exit(0)
|
||||
})
|
||||
|
||||
app.on("window-all-closed", () => {
|
||||
if (process.platform !== "darwin") {
|
||||
app.quit()
|
||||
|
||||
@@ -1,218 +1,152 @@
|
||||
import { spawn, execSync, ChildProcess } from "child_process"
|
||||
import { app, BrowserWindow } from "electron"
|
||||
import { existsSync, statSync } from "fs"
|
||||
import { buildUserShellCommand, getUserShellEnv, runUserShellCommandSync, supportsUserShell } from "./user-shell"
|
||||
import { spawn, type ChildProcess } from "child_process"
|
||||
import { app } from "electron"
|
||||
import { createRequire } from "module"
|
||||
import { EventEmitter } from "events"
|
||||
import { existsSync } from "fs"
|
||||
import path from "path"
|
||||
import { buildUserShellCommand, getUserShellEnv, supportsUserShell } from "./user-shell"
|
||||
|
||||
export interface ProcessInfo {
|
||||
pid: number
|
||||
port: number
|
||||
binaryPath: string
|
||||
const nodeRequire = createRequire(import.meta.url)
|
||||
|
||||
|
||||
type CliState = "starting" | "ready" | "error" | "stopped"
|
||||
|
||||
export interface CliStatus {
|
||||
state: CliState
|
||||
pid?: number
|
||||
port?: number
|
||||
url?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
interface ProcessMeta {
|
||||
pid: number
|
||||
port: number
|
||||
folder: string
|
||||
startTime: number
|
||||
childProcess: ChildProcess
|
||||
logs: string[]
|
||||
instanceId: string
|
||||
export interface CliLogEntry {
|
||||
stream: "stdout" | "stderr"
|
||||
message: string
|
||||
}
|
||||
|
||||
class ProcessManager {
|
||||
private processes = new Map<number, ProcessMeta>()
|
||||
private mainWindow: BrowserWindow | null = null
|
||||
interface StartOptions {
|
||||
dev: boolean
|
||||
}
|
||||
|
||||
setMainWindow(window: BrowserWindow) {
|
||||
this.mainWindow = window
|
||||
}
|
||||
interface CliEntryResolution {
|
||||
entry: string
|
||||
runner: "node" | "tsx"
|
||||
runnerPath?: string
|
||||
}
|
||||
|
||||
private parseLogLevel(message: string): "info" | "error" | "warn" | "debug" {
|
||||
const upperMessage = message.toUpperCase()
|
||||
if (upperMessage.includes("[ERROR]") || upperMessage.includes("ERROR:")) return "error"
|
||||
if (upperMessage.includes("[WARN]") || upperMessage.includes("WARN:")) return "warn"
|
||||
if (upperMessage.includes("[DEBUG]") || upperMessage.includes("DEBUG:")) return "debug"
|
||||
if (upperMessage.includes("[INFO]") || upperMessage.includes("INFO:")) return "info"
|
||||
return "info"
|
||||
}
|
||||
export declare interface CliProcessManager {
|
||||
on(event: "status", listener: (status: CliStatus) => void): this
|
||||
on(event: "ready", listener: (status: CliStatus) => void): this
|
||||
on(event: "log", listener: (entry: CliLogEntry) => void): this
|
||||
on(event: "exit", listener: (status: CliStatus) => void): this
|
||||
on(event: "error", listener: (error: Error) => void): this
|
||||
}
|
||||
|
||||
private sendLog(instanceId: string, level: "info" | "error" | "warn" | "debug", message: string) {
|
||||
if (this.mainWindow && message.trim()) {
|
||||
const parsedLevel = this.parseLogLevel(message)
|
||||
this.mainWindow.webContents.send("instance:log", {
|
||||
id: instanceId,
|
||||
entry: {
|
||||
timestamp: Date.now(),
|
||||
level: parsedLevel,
|
||||
message: message.trim(),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
export class CliProcessManager extends EventEmitter {
|
||||
private child?: ChildProcess
|
||||
private status: CliStatus = { state: "stopped" }
|
||||
private stdoutBuffer = ""
|
||||
private stderrBuffer = ""
|
||||
|
||||
async spawn(
|
||||
folder: string,
|
||||
instanceId: string,
|
||||
binaryPath?: string,
|
||||
environmentVariables?: Record<string, string>,
|
||||
): Promise<ProcessInfo> {
|
||||
this.validateFolder(folder)
|
||||
const useUserShell = supportsUserShell()
|
||||
const logAttempt = (message: string) => {
|
||||
console.info(`[ProcessManager] ${message}`)
|
||||
this.sendLog(instanceId, "debug", message)
|
||||
async start(options: StartOptions): Promise<CliStatus> {
|
||||
if (this.child) {
|
||||
await this.stop()
|
||||
}
|
||||
|
||||
const env = useUserShell ? getUserShellEnv() : { ...process.env }
|
||||
if (environmentVariables) {
|
||||
Object.assign(env, environmentVariables)
|
||||
this.sendLog(
|
||||
instanceId,
|
||||
"info",
|
||||
`Using ${Object.keys(environmentVariables).length} custom environment variables:`,
|
||||
)
|
||||
this.stdoutBuffer = ""
|
||||
this.stderrBuffer = ""
|
||||
this.updateStatus({ state: "starting", port: undefined, pid: undefined, url: undefined, error: undefined })
|
||||
|
||||
// Log each environment variable
|
||||
for (const [key, value] of Object.entries(environmentVariables)) {
|
||||
this.sendLog(instanceId, "info", ` ${key}=${value}`)
|
||||
}
|
||||
}
|
||||
const cliEntry = this.resolveCliEntry(options)
|
||||
const args = this.buildCliArgs(options)
|
||||
|
||||
let targetBinary: string
|
||||
if (!binaryPath || binaryPath === "opencode") {
|
||||
targetBinary = useUserShell ? "opencode" : this.validateOpenCodeBinary(logAttempt)
|
||||
} else {
|
||||
targetBinary = this.validateCustomBinary(binaryPath, logAttempt)
|
||||
}
|
||||
|
||||
const spawnCommand = useUserShell
|
||||
? this.buildShellServeCommand(targetBinary)
|
||||
: { command: targetBinary, args: this.buildServeArgs() }
|
||||
|
||||
const launchDetail = `${spawnCommand.command} ${spawnCommand.args.join(" ")}`.trim()
|
||||
this.sendLog(instanceId, "debug", `Launching process with: ${launchDetail}`)
|
||||
|
||||
this.sendLog(
|
||||
instanceId,
|
||||
"info",
|
||||
`Starting OpenCode server for ${folder} using ${targetBinary}...`,
|
||||
console.info(
|
||||
`[cli] launching CodeNomad CLI (${options.dev ? "dev" : "prod"}) using ${cliEntry.runner} at ${cliEntry.entry}`,
|
||||
)
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(spawnCommand.command, spawnCommand.args, {
|
||||
cwd: folder,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
env,
|
||||
shell: false,
|
||||
})
|
||||
const env = supportsUserShell() ? getUserShellEnv() : { ...process.env }
|
||||
env.ELECTRON_RUN_AS_NODE = "1"
|
||||
|
||||
const spawnDetails = supportsUserShell()
|
||||
? buildUserShellCommand(`ELECTRON_RUN_AS_NODE=1 exec ${this.buildCommand(cliEntry, args)}`)
|
||||
: this.buildDirectSpawn(cliEntry, args)
|
||||
|
||||
const child = spawn(spawnDetails.command, spawnDetails.args, {
|
||||
cwd: process.cwd(),
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
env,
|
||||
shell: false,
|
||||
})
|
||||
|
||||
console.info(`[cli] spawn command: ${spawnDetails.command} ${spawnDetails.args.join(" ")}`)
|
||||
if (!child.pid) {
|
||||
console.error("[cli] spawn failed: no pid")
|
||||
}
|
||||
|
||||
this.child = child
|
||||
this.updateStatus({ pid: child.pid ?? undefined })
|
||||
|
||||
child.stdout?.on("data", (data: Buffer) => {
|
||||
this.handleStream(data.toString(), "stdout")
|
||||
})
|
||||
|
||||
child.stderr?.on("data", (data: Buffer) => {
|
||||
this.handleStream(data.toString(), "stderr")
|
||||
})
|
||||
|
||||
child.on("error", (error) => {
|
||||
console.error("[cli] failed to start CLI:", error)
|
||||
this.updateStatus({ state: "error", error: error.message })
|
||||
this.emit("error", error)
|
||||
})
|
||||
|
||||
child.on("exit", (code, signal) => {
|
||||
const failed = this.status.state !== "ready"
|
||||
const error = failed ? this.status.error ?? `CLI exited with code ${code ?? 0}${signal ? ` (${signal})` : ""}` : undefined
|
||||
console.info(`[cli] exit (code=${code}, signal=${signal || ""})${error ? ` error=${error}` : ""}`)
|
||||
this.updateStatus({ state: failed ? "error" : "stopped", error })
|
||||
if (failed && error) {
|
||||
this.emit("error", new Error(error))
|
||||
}
|
||||
this.emit("exit", this.status)
|
||||
this.child = undefined
|
||||
})
|
||||
|
||||
return new Promise<CliStatus>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
child.kill("SIGKILL")
|
||||
this.sendLog(instanceId, "error", "Server startup timeout (10s exceeded)")
|
||||
reject(new Error("Server startup timeout (10s exceeded)"))
|
||||
}, 10000)
|
||||
this.handleTimeout()
|
||||
reject(new Error("CLI startup timeout"))
|
||||
}, 15000)
|
||||
|
||||
let stdoutBuffer = ""
|
||||
let stderrBuffer = ""
|
||||
let portFound = false
|
||||
|
||||
child.stdout?.on("data", (data: Buffer) => {
|
||||
const text = data.toString()
|
||||
stdoutBuffer += text
|
||||
|
||||
const lines = stdoutBuffer.split("\n")
|
||||
stdoutBuffer = lines.pop() || ""
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue
|
||||
|
||||
this.sendLog(instanceId, "info", line)
|
||||
|
||||
const portMatch = line.match(/opencode server listening on http:\/\/[^:]+:(\d+)/)
|
||||
if (portMatch && !portFound) {
|
||||
portFound = true
|
||||
const port = parseInt(portMatch[1], 10)
|
||||
clearTimeout(timeout)
|
||||
|
||||
const meta: ProcessMeta = {
|
||||
pid: child.pid!,
|
||||
port,
|
||||
folder,
|
||||
startTime: Date.now(),
|
||||
childProcess: child,
|
||||
logs: [line],
|
||||
instanceId,
|
||||
}
|
||||
|
||||
this.processes.set(child.pid!, meta)
|
||||
resolve({ pid: child.pid!, port, binaryPath: targetBinary })
|
||||
}
|
||||
|
||||
const meta = this.processes.get(child.pid!)
|
||||
if (meta) {
|
||||
meta.logs.push(line)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
child.stderr?.on("data", (data: Buffer) => {
|
||||
const text = data.toString()
|
||||
stderrBuffer += text
|
||||
|
||||
const lines = stderrBuffer.split("\n")
|
||||
stderrBuffer = lines.pop() || ""
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue
|
||||
|
||||
this.sendLog(instanceId, "error", line)
|
||||
|
||||
const meta = this.processes.get(child.pid!)
|
||||
if (meta) {
|
||||
meta.logs.push(line)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
child.on("error", (error) => {
|
||||
this.once("ready", (status) => {
|
||||
clearTimeout(timeout)
|
||||
if (error.message.includes("ENOENT")) {
|
||||
reject(new Error("opencode binary not found in PATH"))
|
||||
} else {
|
||||
reject(error)
|
||||
}
|
||||
resolve(status)
|
||||
})
|
||||
|
||||
child.on("exit", (code, signal) => {
|
||||
this.once("error", (error) => {
|
||||
clearTimeout(timeout)
|
||||
this.processes.delete(child.pid!)
|
||||
|
||||
if (!portFound) {
|
||||
const errorMsg = stderrBuffer || `Process exited with code ${code}`
|
||||
reject(new Error(errorMsg))
|
||||
}
|
||||
reject(error)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async kill(pid: number): Promise<void> {
|
||||
const meta = this.processes.get(pid)
|
||||
if (!meta) {
|
||||
// Treat unknown processes as already stopped so tabs close cleanly
|
||||
async stop(): Promise<void> {
|
||||
const child = this.child
|
||||
if (!child) {
|
||||
this.updateStatus({ state: "stopped" })
|
||||
return
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = meta.childProcess
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const killTimeout = setTimeout(() => {
|
||||
child.kill("SIGKILL")
|
||||
}, 2000)
|
||||
}, 4000)
|
||||
|
||||
child.on("exit", () => {
|
||||
clearTimeout(killTimeout)
|
||||
this.processes.delete(pid)
|
||||
this.child = undefined
|
||||
console.info("[cli] CLI process exited")
|
||||
this.updateStatus({ state: "stopped" })
|
||||
resolve()
|
||||
})
|
||||
|
||||
@@ -220,134 +154,167 @@ class ProcessManager {
|
||||
})
|
||||
}
|
||||
|
||||
getStatus(pid: number): "running" | "stopped" | "unknown" {
|
||||
if (!this.processes.has(pid)) {
|
||||
return "unknown"
|
||||
}
|
||||
getStatus(): CliStatus {
|
||||
return { ...this.status }
|
||||
}
|
||||
|
||||
try {
|
||||
process.kill(pid, 0)
|
||||
return "running"
|
||||
} catch {
|
||||
return "stopped"
|
||||
private handleTimeout() {
|
||||
if (this.child) {
|
||||
this.child.kill("SIGKILL")
|
||||
this.child = undefined
|
||||
}
|
||||
this.updateStatus({ state: "error", error: "CLI did not start in time" })
|
||||
this.emit("error", new Error("CLI did not start in time"))
|
||||
}
|
||||
|
||||
private handleStream(chunk: string, stream: "stdout" | "stderr") {
|
||||
if (stream === "stdout") {
|
||||
this.stdoutBuffer += chunk
|
||||
this.processBuffer("stdout")
|
||||
} else {
|
||||
this.stderrBuffer += chunk
|
||||
this.processBuffer("stderr")
|
||||
}
|
||||
}
|
||||
|
||||
getAllProcesses(): Map<number, ProcessMeta> {
|
||||
return new Map(this.processes)
|
||||
}
|
||||
private processBuffer(stream: "stdout" | "stderr") {
|
||||
const buffer = stream === "stdout" ? this.stdoutBuffer : this.stderrBuffer
|
||||
const lines = buffer.split("\n")
|
||||
const trailing = lines.pop() ?? ""
|
||||
|
||||
async cleanup(): Promise<void> {
|
||||
const killPromises = Array.from(this.processes.keys()).map((pid) => this.kill(pid).catch(() => {}))
|
||||
await Promise.all(killPromises)
|
||||
}
|
||||
|
||||
private validateFolder(folder: string): void {
|
||||
if (!existsSync(folder)) {
|
||||
throw new Error(`Folder does not exist: ${folder}`)
|
||||
if (stream === "stdout") {
|
||||
this.stdoutBuffer = trailing
|
||||
} else {
|
||||
this.stderrBuffer = trailing
|
||||
}
|
||||
|
||||
const stats = statSync(folder)
|
||||
if (!stats.isDirectory()) {
|
||||
throw new Error(`Path is not a directory: ${folder}`)
|
||||
}
|
||||
}
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue
|
||||
console.info(`[cli][${stream}] ${line}`)
|
||||
this.emit("log", { stream, message: line })
|
||||
|
||||
private validateOpenCodeBinary(logAttempt?: (message: string) => void): string {
|
||||
const log = logAttempt ?? ((message: string) => console.info(`[ProcessManager] ${message}`))
|
||||
|
||||
if (process.platform === "win32") {
|
||||
log("Checking PATH via 'where opencode'")
|
||||
return this.resolveBinaryViaLocator("where opencode", log)
|
||||
}
|
||||
|
||||
const shellCheck = buildUserShellCommand("command -v opencode")
|
||||
const shellPreview = [shellCheck.command, ...shellCheck.args].join(" ")
|
||||
log(`Checking PATH via shell: ${shellPreview}`)
|
||||
|
||||
try {
|
||||
const resolved = runUserShellCommandSync("command -v opencode")
|
||||
const path = this.pickFirstPath(resolved)
|
||||
if (path) {
|
||||
log(`Shell located opencode at ${path}`)
|
||||
return path
|
||||
}
|
||||
throw new Error("Empty result from shell lookup")
|
||||
} catch (shellError) {
|
||||
const message = shellError instanceof Error ? shellError.message : String(shellError)
|
||||
log(`Shell lookup failed: ${message}`)
|
||||
try {
|
||||
log("Fallback to 'which opencode'")
|
||||
return this.resolveBinaryViaLocator("which opencode", log)
|
||||
} catch (locatorError) {
|
||||
const locatorMessage = locatorError instanceof Error ? locatorError.message : String(locatorError)
|
||||
log(`Locator fallback failed: ${locatorMessage}`)
|
||||
throw new Error(
|
||||
"opencode binary not found in PATH. Please install OpenCode CLI first: npm install -g @opencode/cli",
|
||||
)
|
||||
const port = this.extractPort(line)
|
||||
if (port && this.status.state === "starting") {
|
||||
const url = `http://127.0.0.1:${port}`
|
||||
console.info(`[cli] ready on ${url}`)
|
||||
this.updateStatus({ state: "ready", port, url })
|
||||
this.emit("ready", this.status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private validateCustomBinary(binaryPath: string, log?: (message: string) => void): string {
|
||||
log?.(`Validating custom binary at ${binaryPath}`)
|
||||
|
||||
if (!existsSync(binaryPath)) {
|
||||
throw new Error(`OpenCode binary not found: ${binaryPath}`)
|
||||
private extractPort(line: string): number | null {
|
||||
const readyMatch = line.match(/CodeNomad Server is ready at http:\/\/[^:]+:(\d+)/i)
|
||||
if (readyMatch) {
|
||||
return parseInt(readyMatch[1], 10)
|
||||
}
|
||||
|
||||
const stats = statSync(binaryPath)
|
||||
if (!stats.isFile()) {
|
||||
throw new Error(`Path is not a file: ${binaryPath}`)
|
||||
}
|
||||
|
||||
// Check if executable (on Unix systems)
|
||||
if (process.platform !== "win32") {
|
||||
if (line.toLowerCase().includes("http server listening")) {
|
||||
const httpMatch = line.match(/:(\d{2,5})(?!.*:\d)/)
|
||||
if (httpMatch) {
|
||||
return parseInt(httpMatch[1], 10)
|
||||
}
|
||||
try {
|
||||
execSync(`test -x "${binaryPath}"`, { stdio: "pipe" })
|
||||
const parsed = JSON.parse(line)
|
||||
if (typeof parsed.port === "number") {
|
||||
return parsed.port
|
||||
}
|
||||
} catch {
|
||||
throw new Error(`Binary is not executable: ${binaryPath}`)
|
||||
// not JSON, ignore
|
||||
}
|
||||
}
|
||||
|
||||
return binaryPath
|
||||
return null
|
||||
}
|
||||
|
||||
private resolveBinaryViaLocator(command: string, log?: (message: string) => void): string {
|
||||
log?.(`Running locator command: ${command}`)
|
||||
const output = execSync(command, { stdio: "pipe", encoding: "utf-8" })
|
||||
log?.(`Locator output: ${output.trim() || "<empty>"}`)
|
||||
const path = this.pickFirstPath(output)
|
||||
if (!path) {
|
||||
throw new Error("opencode binary not found in PATH")
|
||||
private updateStatus(patch: Partial<CliStatus>) {
|
||||
this.status = { ...this.status, ...patch }
|
||||
this.emit("status", this.status)
|
||||
}
|
||||
|
||||
private buildCliArgs(options: StartOptions): string[] {
|
||||
const args = ["serve", "--host", "127.0.0.1", "--port", "0"]
|
||||
|
||||
if (options.dev) {
|
||||
args.push("--ui-dev-server", "http://localhost:3000", "--log-level", "debug")
|
||||
}
|
||||
return path
|
||||
|
||||
return args
|
||||
}
|
||||
|
||||
private pickFirstPath(output: string): string | null {
|
||||
const line = output
|
||||
.split("\n")
|
||||
.map((entry) => entry.trim())
|
||||
.find((entry) => entry.length > 0)
|
||||
return line ?? null
|
||||
private buildCommand(cliEntry: CliEntryResolution, args: string[]): string {
|
||||
const parts = [JSON.stringify(process.execPath)]
|
||||
if (cliEntry.runner === "tsx" && cliEntry.runnerPath) {
|
||||
parts.push(JSON.stringify(cliEntry.runnerPath))
|
||||
}
|
||||
parts.push(JSON.stringify(cliEntry.entry))
|
||||
args.forEach((arg) => parts.push(JSON.stringify(arg)))
|
||||
return parts.join(" ")
|
||||
}
|
||||
|
||||
private buildServeArgs(): string[] {
|
||||
return ["serve", "--port", "0", "--print-logs", "--log-level", "DEBUG"]
|
||||
private buildDirectSpawn(cliEntry: CliEntryResolution, args: string[]) {
|
||||
if (cliEntry.runner === "tsx") {
|
||||
return { command: process.execPath, args: [cliEntry.runnerPath!, cliEntry.entry, ...args] }
|
||||
}
|
||||
|
||||
return { command: process.execPath, args: [cliEntry.entry, ...args] }
|
||||
}
|
||||
|
||||
private buildShellServeCommand(binaryPath: string): { command: string; args: string[] } {
|
||||
const args = this.buildServeArgs()
|
||||
.map((arg) => JSON.stringify(arg))
|
||||
.join(" ")
|
||||
return buildUserShellCommand(`exec ${JSON.stringify(binaryPath)} ${args}`)
|
||||
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 }
|
||||
}
|
||||
}
|
||||
|
||||
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.")
|
||||
}
|
||||
|
||||
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"),
|
||||
]
|
||||
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
const resolved = typeof candidate === "function" ? candidate() : candidate
|
||||
if (resolved && existsSync(resolved)) {
|
||||
return resolved
|
||||
}
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export const processManager = new ProcessManager()
|
||||
|
||||
app.on("before-quit", async (event) => {
|
||||
event.preventDefault()
|
||||
await processManager.cleanup()
|
||||
app.exit(0)
|
||||
})
|
||||
|
||||
19
packages/electron-app/electron/preload/index.cjs
Normal file
19
packages/electron-app/electron/preload/index.cjs
Normal file
@@ -0,0 +1,19 @@
|
||||
const { contextBridge, ipcRenderer } = require("electron")
|
||||
|
||||
const electronAPI = {
|
||||
onCliStatus: (callback) => {
|
||||
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"),
|
||||
}
|
||||
|
||||
contextBridge.exposeInMainWorld("electronAPI", electronAPI)
|
||||
@@ -1,49 +0,0 @@
|
||||
import { contextBridge, ipcRenderer } from "electron"
|
||||
import type { ElectronAPI } from "../../../ui/src/types/electron-api"
|
||||
|
||||
const electronAPI: ElectronAPI = {
|
||||
selectFolder: () => ipcRenderer.invoke("dialog:selectFolder"),
|
||||
createInstance: (id: string, folder: string, binaryPath?: string, environmentVariables?: Record<string, string>) =>
|
||||
ipcRenderer.invoke("instance:create", id, folder, binaryPath, environmentVariables),
|
||||
stopInstance: (pid: number) => ipcRenderer.invoke("instance:stop", pid),
|
||||
onInstanceStarted: (callback) => {
|
||||
ipcRenderer.on("instance:started", (_, data) => callback(data))
|
||||
},
|
||||
onInstanceError: (callback) => {
|
||||
ipcRenderer.on("instance:error", (_, data) => callback(data))
|
||||
},
|
||||
onInstanceStopped: (callback) => {
|
||||
ipcRenderer.on("instance:stopped", (_, data) => callback(data))
|
||||
},
|
||||
onInstanceLog: (callback) => {
|
||||
ipcRenderer.on("instance:log", (_, data) => callback(data))
|
||||
},
|
||||
onNewInstance: (callback) => {
|
||||
ipcRenderer.on("menu:newInstance", () => callback())
|
||||
},
|
||||
scanDirectory: (workspaceFolder: string) => ipcRenderer.invoke("fs:scanDirectory", workspaceFolder),
|
||||
// OpenCode binary operations
|
||||
selectOpenCodeBinary: () => ipcRenderer.invoke("dialog:selectOpenCodeBinary"),
|
||||
validateOpenCodeBinary: (path: string) => ipcRenderer.invoke("opencode:validateBinary", path),
|
||||
// Storage operations
|
||||
getConfigPath: () => ipcRenderer.invoke("storage:getConfigPath"),
|
||||
getInstancesDir: () => ipcRenderer.invoke("storage:getInstancesDir"),
|
||||
readConfigFile: () => ipcRenderer.invoke("storage:readConfigFile"),
|
||||
writeConfigFile: (content: string) => ipcRenderer.invoke("storage:writeConfigFile", content),
|
||||
readInstanceFile: (filename: string) => ipcRenderer.invoke("storage:readInstanceFile", filename),
|
||||
writeInstanceFile: (filename: string, content: string) =>
|
||||
ipcRenderer.invoke("storage:writeInstanceFile", filename, content),
|
||||
deleteInstanceFile: (filename: string) => ipcRenderer.invoke("storage:deleteInstanceFile", filename),
|
||||
onConfigChanged: (callback: () => void) => {
|
||||
ipcRenderer.on("storage:configChanged", () => callback())
|
||||
return () => ipcRenderer.removeAllListeners("storage:configChanged")
|
||||
},
|
||||
}
|
||||
|
||||
contextBridge.exposeInMainWorld("electronAPI", electronAPI)
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
electronAPI: ElectronAPI
|
||||
}
|
||||
}
|
||||
206
packages/electron-app/electron/resources/loading.html
Normal file
206
packages/electron-app/electron/resources/loading.html
Normal file
@@ -0,0 +1,206 @@
|
||||
<!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 AI’s 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>
|
||||
@@ -1,16 +1,16 @@
|
||||
{
|
||||
"name": "@codenomad/electron-app",
|
||||
"version": "0.1.2",
|
||||
"name": "@neuralnomads/codenomad-electron-app",
|
||||
"version": "0.2.0",
|
||||
"description": "CodeNomad - AI coding assistant",
|
||||
"author": {
|
||||
"name": "Shantur Rathore",
|
||||
"email": "codenomad@shantur.com"
|
||||
"name": "Neural Nomads",
|
||||
"email": "codenomad@neuralnomads.ai"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "dist/main/main.js",
|
||||
"scripts": {
|
||||
"dev": "electron-vite dev",
|
||||
"dev:electron": "NODE_ENV=development electron .",
|
||||
"dev:electron": "NODE_ENV=development ELECTRON_ENABLE_LOGGING=1 NODE_OPTIONS=\"--import tsx\" electron electron/main/main.ts",
|
||||
"build": "electron-vite build",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json",
|
||||
"preview": "electron-vite preview",
|
||||
@@ -29,8 +29,8 @@
|
||||
"package:linux": "electron-builder --linux"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codenomad/ui": "file:../ui",
|
||||
"ignore": "7.0.5"
|
||||
"@neuralnomads/codenomad": "file:../server",
|
||||
"@codenomad/ui": "file:../ui"
|
||||
},
|
||||
"devDependencies": {
|
||||
"7zip-bin": "^5.2.0",
|
||||
@@ -40,6 +40,7 @@
|
||||
"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"
|
||||
@@ -55,6 +56,12 @@
|
||||
"dist/**/*",
|
||||
"package.json"
|
||||
],
|
||||
"extraResources": [
|
||||
{
|
||||
"from": "electron/resources",
|
||||
"to": ""
|
||||
}
|
||||
],
|
||||
"mac": {
|
||||
"category": "public.app-category.developer-tools",
|
||||
"target": [
|
||||
@@ -67,7 +74,7 @@
|
||||
"arch": ["x64", "arm64", "universal"]
|
||||
}
|
||||
],
|
||||
"artifactName": "${productName}-${version}-${os}-${arch}.${ext}",
|
||||
"artifactName": "CodeNomadApp-${version}-${os}-${arch}.${ext}",
|
||||
"icon": "electron/resources/icon.icns"
|
||||
},
|
||||
"dmg": {
|
||||
@@ -87,7 +94,7 @@
|
||||
"arch": ["x64", "arm64"]
|
||||
}
|
||||
],
|
||||
"artifactName": "${productName}-${version}-${os}-${arch}.${ext}",
|
||||
"artifactName": "CodeNomadApp-${version}-${os}-${arch}.${ext}",
|
||||
"icon": "electron/resources/icon.ico"
|
||||
},
|
||||
"nsis": {
|
||||
@@ -115,7 +122,7 @@
|
||||
"arch": ["x64", "arm64"]
|
||||
}
|
||||
],
|
||||
"artifactName": "${productName}-${version}-${os}-${arch}.${ext}",
|
||||
"artifactName": "CodeNomadApp-${version}-${os}-${arch}.${ext}",
|
||||
"category": "Development",
|
||||
"icon": "electron/resources/icon.png"
|
||||
}
|
||||
|
||||
@@ -7,10 +7,12 @@ import { fileURLToPath } from "url"
|
||||
|
||||
const __dirname = fileURLToPath(new URL(".", import.meta.url))
|
||||
const appDir = join(__dirname, "..")
|
||||
const workspaceRoot = join(appDir, "..", "..")
|
||||
|
||||
const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm"
|
||||
const npxCmd = process.platform === "win32" ? "npx.cmd" : "npx"
|
||||
const nodeModulesPath = join(appDir, "node_modules")
|
||||
const workspaceNodeModulesPath = join(workspaceRoot, "node_modules")
|
||||
|
||||
const platforms = {
|
||||
mac: {
|
||||
@@ -93,10 +95,16 @@ async function build(platform) {
|
||||
console.log(`\n🔨 Building for: ${config.description}\n`)
|
||||
|
||||
try {
|
||||
console.log("📦 Step 1/2: Building Electron app...\n")
|
||||
console.log("📦 Step 1/3: Building CLI dependency...\n")
|
||||
await run(npmCmd, ["run", "build", "--workspace", "@neuralnomads/codenomad"], {
|
||||
cwd: workspaceRoot,
|
||||
env: { NODE_PATH: workspaceNodeModulesPath },
|
||||
})
|
||||
|
||||
console.log("\n📦 Step 2/3: Building Electron app...\n")
|
||||
await run(npmCmd, ["run", "build"])
|
||||
|
||||
console.log("\n📦 Step 2/2: Packaging binaries...\n")
|
||||
console.log("\n📦 Step 3/3: Packaging binaries...\n")
|
||||
const distPath = join(appDir, "dist")
|
||||
if (!existsSync(distPath)) {
|
||||
throw new Error("dist/ directory not found. Build failed.")
|
||||
|
||||
58
packages/server/README.md
Normal file
58
packages/server/README.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# CodeNomad Server
|
||||
|
||||
**CodeNomad Server** is the high-performance engine behind the CodeNomad cockpit. It transforms your machine into a robust development host, managing the lifecycle of multiple OpenCode instances and providing the low-latency data streams that long-haul builders demand. It bridges your local filesystem with the UI, ensuring that whether you are on localhost or a remote tunnel, you have the speed, clarity, and control of a native workspace.
|
||||
|
||||
## Features & Capabilities
|
||||
|
||||
### 🌍 Deployment Freedom
|
||||
- **Remote Access**: Host CodeNomad on a powerful workstation and access it from your lightweight laptop.
|
||||
- **Code Anywhere**: Tunnel in via VPN or SSH to code securely from coffee shops or while traveling.
|
||||
- **Multi-Device**: The responsive web client works on tablets and iPads, turning any screen into a dev terminal.
|
||||
- **Always-On**: Run as a background service so your sessions are always ready when you connect.
|
||||
|
||||
### ⚡️ Workspace Power
|
||||
- **Multi-Instance**: Juggle multiple OpenCode sessions side-by-side with per-instance tabs.
|
||||
- **Long-Context Native**: Scroll through massive transcripts without hitches.
|
||||
- **Deep Task Awareness**: Monitor background tasks and child sessions without losing your flow.
|
||||
- **Command Palette**: A single, global palette to jump tabs, launch tools, and fire shortcuts.
|
||||
|
||||
## Prerequisites
|
||||
- **OpenCode**: `opencode` must be installed and configured on your system.
|
||||
- Node.js 18+ and npm (for running or building from source).
|
||||
- A workspace folder on disk you want to serve.
|
||||
- Optional: a Chromium-based browser if you want `--launch` to open the UI automatically.
|
||||
|
||||
## Usage
|
||||
|
||||
### Run via npx (Recommended)
|
||||
You can run CodeNomad directly without installing it:
|
||||
|
||||
```sh
|
||||
npx @neuralnomads/codenomad --launch
|
||||
```
|
||||
|
||||
### Install Globally
|
||||
Or install it globally to use the `codenomad` command:
|
||||
|
||||
```sh
|
||||
npm install -g @neuralnomads/codenomad
|
||||
codenomad --launch
|
||||
```
|
||||
|
||||
### Common Flags
|
||||
You can configure the server using flags or environment variables:
|
||||
|
||||
| Flag | Env Variable | Description |
|
||||
|------|--------------|-------------|
|
||||
| `--port <number>` | `CLI_PORT` | HTTP port (default 9898) |
|
||||
| `--host <addr>` | `CLI_HOST` | Interface to bind (default 127.0.0.1) |
|
||||
| `--workspace-root <path>` | `CLI_WORKSPACE_ROOT` | Default root for new workspaces |
|
||||
| `--unrestricted-root` | `CLI_UNRESTRICTED_ROOT` | Allow full-filesystem browsing |
|
||||
| `--config <path>` | `CLI_CONFIG` | Config file location |
|
||||
| `--launch` | `CLI_LAUNCH` | Open the UI in a Chromium-based browser |
|
||||
| `--log-level <level>` | `CLI_LOG_LEVEL` | Logging level (trace, debug, info, warn, error) |
|
||||
|
||||
### Data Storage
|
||||
- **Config**: `~/.config/codenomad/config.json`
|
||||
- **Instance Data**: `~/.config/codenomad/instances` (chat history, etc.)
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "@codenomad/cli",
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@codenomad/cli",
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^8.5.0",
|
||||
@@ -1,11 +1,15 @@
|
||||
{
|
||||
"name": "@codenomad/cli",
|
||||
"version": "0.1.0",
|
||||
"description": "CodeNomad CLI server for HTTP/SSE control plane",
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.2.0",
|
||||
"description": "CodeNomad Server",
|
||||
"author": {
|
||||
"name": "Neural Nomads",
|
||||
"email": "codenomad@neuralnomads.ai"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"bin": {
|
||||
"codenomad-cli": "dist/bin.js"
|
||||
"codenomad": "dist/bin.js"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "npm run build:ui && npm run prepare-ui && tsc -p tsconfig.json",
|
||||
@@ -20,6 +24,7 @@
|
||||
"@fastify/static": "^7.0.4",
|
||||
"commander": "^12.1.0",
|
||||
"fastify": "^4.28.1",
|
||||
"fuzzysort": "^2.0.4",
|
||||
"pino": "^9.4.0",
|
||||
"undici": "^6.19.8",
|
||||
"zod": "^3.23.8"
|
||||
@@ -1,4 +1,5 @@
|
||||
import type {
|
||||
AgentModelSelection,
|
||||
AgentModelSelections,
|
||||
ConfigFile,
|
||||
ModelPreference,
|
||||
@@ -103,8 +104,11 @@ export interface WorkspaceFileResponse {
|
||||
contents: string
|
||||
}
|
||||
|
||||
export type WorkspaceFileSearchResponse = FileSystemEntry[]
|
||||
|
||||
export interface InstanceData {
|
||||
messageHistory: string[]
|
||||
agentModelSelections: AgentModelSelection
|
||||
}
|
||||
|
||||
export interface BinaryRecord {
|
||||
@@ -112,6 +116,7 @@ export interface BinaryRecord {
|
||||
path: string
|
||||
label: string
|
||||
version?: string
|
||||
|
||||
/** Indicates that this binary will be picked when workspaces omit an explicit choice. */
|
||||
isDefault: boolean
|
||||
lastValidatedAt?: string
|
||||
@@ -151,6 +156,7 @@ export type WorkspaceEventType =
|
||||
| "workspace.log"
|
||||
| "config.appChanged"
|
||||
| "config.binariesChanged"
|
||||
| "instance.dataChanged"
|
||||
|
||||
export type WorkspaceEventPayload =
|
||||
| { type: "workspace.created"; workspace: WorkspaceDescriptor }
|
||||
@@ -160,6 +166,7 @@ export type WorkspaceEventPayload =
|
||||
| { type: "workspace.log"; entry: WorkspaceLogEntry }
|
||||
| { type: "config.appChanged"; config: AppConfig }
|
||||
| { type: "config.binariesChanged"; binaries: BinaryRecord[] }
|
||||
| { type: "instance.dataChanged"; instanceId: string; data: InstanceData }
|
||||
|
||||
export interface ServerMeta {
|
||||
/** Base URL clients should target for REST calls (useful for Electron embedding). */
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
} from "../api-types"
|
||||
import { ConfigStore } from "./store"
|
||||
import { EventBus } from "../events/bus"
|
||||
import type { ConfigFileUpdate } from "./schema"
|
||||
import type { ConfigFile } from "./schema"
|
||||
import { Logger } from "../logger"
|
||||
|
||||
export class BinaryRegistry {
|
||||
@@ -39,17 +39,15 @@ export class BinaryRegistry {
|
||||
}
|
||||
|
||||
const config = this.configStore.get()
|
||||
const deduped = config.opencodeBinaries.filter((binary) => binary.path !== request.path)
|
||||
|
||||
const update: ConfigFileUpdate = {
|
||||
opencodeBinaries: [entry, ...deduped],
|
||||
}
|
||||
const nextConfig = this.cloneConfig(config)
|
||||
const deduped = nextConfig.opencodeBinaries.filter((binary) => binary.path !== request.path)
|
||||
nextConfig.opencodeBinaries = [entry, ...deduped]
|
||||
|
||||
if (request.makeDefault) {
|
||||
update.preferences = { lastUsedBinary: request.path }
|
||||
nextConfig.preferences.lastUsedBinary = request.path
|
||||
}
|
||||
|
||||
this.configStore.update(update)
|
||||
this.configStore.replace(nextConfig)
|
||||
const record = this.getById(request.path)
|
||||
this.emitChange()
|
||||
return record
|
||||
@@ -58,19 +56,16 @@ export class BinaryRegistry {
|
||||
update(id: string, updates: BinaryUpdateRequest): BinaryRecord {
|
||||
this.logger.debug({ id }, "Updating OpenCode binary")
|
||||
const config = this.configStore.get()
|
||||
const updatedEntries = config.opencodeBinaries.map((binary) =>
|
||||
const nextConfig = this.cloneConfig(config)
|
||||
nextConfig.opencodeBinaries = nextConfig.opencodeBinaries.map((binary) =>
|
||||
binary.path === id ? { ...binary, label: updates.label ?? binary.label } : binary,
|
||||
)
|
||||
|
||||
const update: ConfigFileUpdate = {
|
||||
opencodeBinaries: updatedEntries,
|
||||
}
|
||||
|
||||
if (updates.makeDefault) {
|
||||
update.preferences = { lastUsedBinary: id }
|
||||
nextConfig.preferences.lastUsedBinary = id
|
||||
}
|
||||
|
||||
this.configStore.update(update)
|
||||
this.configStore.replace(nextConfig)
|
||||
const record = this.getById(id)
|
||||
this.emitChange()
|
||||
return record
|
||||
@@ -79,14 +74,15 @@ export class BinaryRegistry {
|
||||
remove(id: string) {
|
||||
this.logger.debug({ id }, "Removing OpenCode binary")
|
||||
const config = this.configStore.get()
|
||||
const remaining = config.opencodeBinaries.filter((binary) => binary.path !== id)
|
||||
const update: ConfigFileUpdate = { opencodeBinaries: remaining }
|
||||
const nextConfig = this.cloneConfig(config)
|
||||
const remaining = nextConfig.opencodeBinaries.filter((binary) => binary.path !== id)
|
||||
nextConfig.opencodeBinaries = remaining
|
||||
|
||||
if (config.preferences.lastUsedBinary === id) {
|
||||
update.preferences = { lastUsedBinary: remaining[0]?.path }
|
||||
if (nextConfig.preferences.lastUsedBinary === id) {
|
||||
nextConfig.preferences.lastUsedBinary = remaining[0]?.path
|
||||
}
|
||||
|
||||
this.configStore.update(update)
|
||||
this.configStore.replace(nextConfig)
|
||||
this.emitChange()
|
||||
}
|
||||
|
||||
@@ -100,7 +96,12 @@ export class BinaryRegistry {
|
||||
})
|
||||
}
|
||||
|
||||
private cloneConfig(config: ConfigFile): ConfigFile {
|
||||
return JSON.parse(JSON.stringify(config)) as ConfigFile
|
||||
}
|
||||
|
||||
private mapRecords(): BinaryRecord[] {
|
||||
|
||||
const config = this.configStore.get()
|
||||
const configuredBinaries = config.opencodeBinaries.map<BinaryRecord>((binary) => ({
|
||||
id: binary.path,
|
||||
@@ -13,23 +13,11 @@ const PreferencesSchema = z.object({
|
||||
lastUsedBinary: z.string().optional(),
|
||||
environmentVariables: z.record(z.string()).default({}),
|
||||
modelRecents: z.array(ModelPreferenceSchema).default([]),
|
||||
agentModelSelections: AgentModelSelectionsSchema.default({}),
|
||||
diffViewMode: z.enum(["split", "unified"]).default("split"),
|
||||
toolOutputExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
|
||||
diagnosticsExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
|
||||
})
|
||||
|
||||
const PreferencesUpdateSchema = z.object({
|
||||
showThinkingBlocks: z.boolean().optional(),
|
||||
lastUsedBinary: z.string().optional(),
|
||||
environmentVariables: z.record(z.string()).optional(),
|
||||
modelRecents: z.array(ModelPreferenceSchema).optional(),
|
||||
agentModelSelections: AgentModelSelectionsSchema.optional(),
|
||||
diffViewMode: z.enum(["split", "unified"]).optional(),
|
||||
toolOutputExpansion: z.enum(["expanded", "collapsed"]).optional(),
|
||||
diagnosticsExpansion: z.enum(["expanded", "collapsed"]).optional(),
|
||||
})
|
||||
|
||||
const RecentFolderSchema = z.object({
|
||||
path: z.string(),
|
||||
lastAccessed: z.number().nonnegative(),
|
||||
@@ -49,13 +37,6 @@ const ConfigFileSchema = z.object({
|
||||
theme: z.enum(["light", "dark", "system"]).optional(),
|
||||
})
|
||||
|
||||
const ConfigFileUpdateSchema = z.object({
|
||||
preferences: PreferencesUpdateSchema.optional(),
|
||||
recentFolders: z.array(RecentFolderSchema).optional(),
|
||||
opencodeBinaries: z.array(OpenCodeBinarySchema).optional(),
|
||||
theme: z.enum(["light", "dark", "system"]).optional(),
|
||||
})
|
||||
|
||||
const DEFAULT_CONFIG = ConfigFileSchema.parse({})
|
||||
|
||||
export {
|
||||
@@ -66,7 +47,6 @@ export {
|
||||
RecentFolderSchema,
|
||||
OpenCodeBinarySchema,
|
||||
ConfigFileSchema,
|
||||
ConfigFileUpdateSchema,
|
||||
DEFAULT_CONFIG,
|
||||
}
|
||||
|
||||
@@ -77,4 +57,3 @@ export type Preferences = z.infer<typeof PreferencesSchema>
|
||||
export type RecentFolder = z.infer<typeof RecentFolderSchema>
|
||||
export type OpenCodeBinary = z.infer<typeof OpenCodeBinarySchema>
|
||||
export type ConfigFile = z.infer<typeof ConfigFileSchema>
|
||||
export type ConfigFileUpdate = z.infer<typeof ConfigFileUpdateSchema>
|
||||
@@ -2,14 +2,7 @@ import fs from "fs"
|
||||
import path from "path"
|
||||
import { EventBus } from "../events/bus"
|
||||
import { Logger } from "../logger"
|
||||
import {
|
||||
AgentModelSelections,
|
||||
ConfigFile,
|
||||
ConfigFileUpdate,
|
||||
ConfigFileSchema,
|
||||
ConfigFileUpdateSchema,
|
||||
DEFAULT_CONFIG,
|
||||
} from "./schema"
|
||||
import { ConfigFile, ConfigFileSchema, DEFAULT_CONFIG } from "./schema"
|
||||
|
||||
export class ConfigStore {
|
||||
private cache: ConfigFile = DEFAULT_CONFIG
|
||||
@@ -50,54 +43,18 @@ export class ConfigStore {
|
||||
return this.load()
|
||||
}
|
||||
|
||||
update(partial: ConfigFile | ConfigFileUpdate) {
|
||||
const safePartial =
|
||||
"recentFolders" in partial && "opencodeBinaries" in partial
|
||||
? ConfigFileSchema.parse(partial)
|
||||
: ConfigFileUpdateSchema.parse(partial ?? {})
|
||||
const merged = this.mergeConfig(this.load(), safePartial)
|
||||
this.cache = ConfigFileSchema.parse(merged)
|
||||
replace(config: ConfigFile) {
|
||||
const validated = ConfigFileSchema.parse(config)
|
||||
this.commit(validated)
|
||||
}
|
||||
|
||||
private commit(next: ConfigFile) {
|
||||
this.cache = next
|
||||
this.loaded = true
|
||||
this.persist()
|
||||
this.eventBus?.publish({ type: "config.appChanged", config: this.cache })
|
||||
this.logger.debug("Config updated")
|
||||
}
|
||||
|
||||
private mergeConfig(current: ConfigFile, partial: ConfigFile | ConfigFileUpdate): ConfigFile {
|
||||
const mergedPreferences = {
|
||||
...current.preferences,
|
||||
...partial.preferences,
|
||||
environmentVariables: {
|
||||
...current.preferences.environmentVariables,
|
||||
...(partial.preferences?.environmentVariables ?? {}),
|
||||
},
|
||||
agentModelSelections: this.mergeAgentSelections(
|
||||
current.preferences.agentModelSelections,
|
||||
partial.preferences?.agentModelSelections,
|
||||
),
|
||||
}
|
||||
|
||||
return {
|
||||
...current,
|
||||
...partial,
|
||||
preferences: mergedPreferences,
|
||||
recentFolders: partial.recentFolders ?? current.recentFolders,
|
||||
opencodeBinaries: partial.opencodeBinaries ?? current.opencodeBinaries,
|
||||
}
|
||||
}
|
||||
|
||||
private mergeAgentSelections(base: AgentModelSelections, update?: AgentModelSelections) {
|
||||
if (!update) {
|
||||
return base
|
||||
}
|
||||
|
||||
const result: AgentModelSelections = { ...base }
|
||||
for (const [instanceId, agentMap] of Object.entries(update)) {
|
||||
result[instanceId] = {
|
||||
...(base[instanceId] ?? {}),
|
||||
...agentMap,
|
||||
}
|
||||
}
|
||||
return result
|
||||
this.logger.info("Config updated")
|
||||
this.logger.debug({ config: this.cache }, "Config payload")
|
||||
}
|
||||
|
||||
private persist() {
|
||||
@@ -21,6 +21,7 @@ export class EventBus extends EventEmitter {
|
||||
this.on("workspace.log", handler)
|
||||
this.on("config.appChanged", handler)
|
||||
this.on("config.binariesChanged", handler)
|
||||
this.on("instance.dataChanged", handler)
|
||||
return () => {
|
||||
this.off("workspace.created", handler)
|
||||
this.off("workspace.started", handler)
|
||||
@@ -29,6 +30,7 @@ export class EventBus extends EventEmitter {
|
||||
this.off("workspace.log", handler)
|
||||
this.off("config.appChanged", handler)
|
||||
this.off("config.binariesChanged", handler)
|
||||
this.off("instance.dataChanged", handler)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { beforeEach, describe, it } from "node:test"
|
||||
import type { FileSystemEntry } from "../../api-types"
|
||||
import {
|
||||
clearWorkspaceSearchCache,
|
||||
getWorkspaceCandidates,
|
||||
refreshWorkspaceCandidates,
|
||||
WORKSPACE_CANDIDATE_CACHE_TTL_MS,
|
||||
} from "../search-cache"
|
||||
|
||||
describe("workspace search cache", () => {
|
||||
beforeEach(() => {
|
||||
clearWorkspaceSearchCache()
|
||||
})
|
||||
|
||||
it("expires cached candidates after the TTL", () => {
|
||||
const workspacePath = "/tmp/workspace"
|
||||
const startTime = 1_000
|
||||
|
||||
refreshWorkspaceCandidates(workspacePath, () => [createEntry("file-a")], startTime)
|
||||
|
||||
const beforeExpiry = getWorkspaceCandidates(
|
||||
workspacePath,
|
||||
startTime + WORKSPACE_CANDIDATE_CACHE_TTL_MS - 1,
|
||||
)
|
||||
assert.ok(beforeExpiry)
|
||||
assert.equal(beforeExpiry.length, 1)
|
||||
assert.equal(beforeExpiry[0].name, "file-a")
|
||||
|
||||
const afterExpiry = getWorkspaceCandidates(
|
||||
workspacePath,
|
||||
startTime + WORKSPACE_CANDIDATE_CACHE_TTL_MS + 1,
|
||||
)
|
||||
assert.equal(afterExpiry, undefined)
|
||||
})
|
||||
|
||||
it("replaces cached entries when manually refreshed", () => {
|
||||
const workspacePath = "/tmp/workspace"
|
||||
|
||||
refreshWorkspaceCandidates(workspacePath, () => [createEntry("file-a")], 5_000)
|
||||
const initial = getWorkspaceCandidates(workspacePath)
|
||||
assert.ok(initial)
|
||||
assert.equal(initial[0].name, "file-a")
|
||||
|
||||
refreshWorkspaceCandidates(workspacePath, () => [createEntry("file-b")], 6_000)
|
||||
const refreshed = getWorkspaceCandidates(workspacePath)
|
||||
assert.ok(refreshed)
|
||||
assert.equal(refreshed[0].name, "file-b")
|
||||
})
|
||||
})
|
||||
|
||||
function createEntry(name: string): FileSystemEntry {
|
||||
return {
|
||||
name,
|
||||
path: name,
|
||||
absolutePath: `/tmp/${name}`,
|
||||
type: "file",
|
||||
size: 1,
|
||||
modifiedAt: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
66
packages/server/src/filesystem/search-cache.ts
Normal file
66
packages/server/src/filesystem/search-cache.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import path from "path"
|
||||
import type { FileSystemEntry } from "../api-types"
|
||||
|
||||
export const WORKSPACE_CANDIDATE_CACHE_TTL_MS = 30_000
|
||||
|
||||
interface WorkspaceCandidateCacheEntry {
|
||||
expiresAt: number
|
||||
candidates: FileSystemEntry[]
|
||||
}
|
||||
|
||||
const workspaceCandidateCache = new Map<string, WorkspaceCandidateCacheEntry>()
|
||||
|
||||
export function getWorkspaceCandidates(rootDir: string, now = Date.now()): FileSystemEntry[] | undefined {
|
||||
const key = normalizeKey(rootDir)
|
||||
const cached = workspaceCandidateCache.get(key)
|
||||
if (!cached) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (cached.expiresAt <= now) {
|
||||
workspaceCandidateCache.delete(key)
|
||||
return undefined
|
||||
}
|
||||
|
||||
return cloneEntries(cached.candidates)
|
||||
}
|
||||
|
||||
export function refreshWorkspaceCandidates(
|
||||
rootDir: string,
|
||||
builder: () => FileSystemEntry[],
|
||||
now = Date.now(),
|
||||
): FileSystemEntry[] {
|
||||
const key = normalizeKey(rootDir)
|
||||
const freshCandidates = builder()
|
||||
|
||||
if (!freshCandidates || freshCandidates.length === 0) {
|
||||
workspaceCandidateCache.delete(key)
|
||||
return []
|
||||
}
|
||||
|
||||
const storedCandidates = cloneEntries(freshCandidates)
|
||||
workspaceCandidateCache.set(key, {
|
||||
expiresAt: now + WORKSPACE_CANDIDATE_CACHE_TTL_MS,
|
||||
candidates: storedCandidates,
|
||||
})
|
||||
|
||||
return cloneEntries(storedCandidates)
|
||||
}
|
||||
|
||||
export function clearWorkspaceSearchCache(rootDir?: string) {
|
||||
if (typeof rootDir === "undefined") {
|
||||
workspaceCandidateCache.clear()
|
||||
return
|
||||
}
|
||||
|
||||
const key = normalizeKey(rootDir)
|
||||
workspaceCandidateCache.delete(key)
|
||||
}
|
||||
|
||||
function cloneEntries(entries: FileSystemEntry[]): FileSystemEntry[] {
|
||||
return entries.map((entry) => ({ ...entry }))
|
||||
}
|
||||
|
||||
function normalizeKey(rootDir: string) {
|
||||
return path.resolve(rootDir)
|
||||
}
|
||||
184
packages/server/src/filesystem/search.ts
Normal file
184
packages/server/src/filesystem/search.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import fs from "fs"
|
||||
import path from "path"
|
||||
import fuzzysort from "fuzzysort"
|
||||
import type { FileSystemEntry } from "../api-types"
|
||||
import { clearWorkspaceSearchCache, getWorkspaceCandidates, refreshWorkspaceCandidates } from "./search-cache"
|
||||
|
||||
const DEFAULT_LIMIT = 100
|
||||
const MAX_LIMIT = 200
|
||||
const MAX_CANDIDATES = 8000
|
||||
const IGNORED_DIRECTORIES = new Set(
|
||||
[".git", ".hg", ".svn", "node_modules", "dist", "build", ".next", ".nuxt", ".turbo", ".cache", "coverage"].map(
|
||||
(name) => name.toLowerCase(),
|
||||
),
|
||||
)
|
||||
|
||||
export type WorkspaceFileSearchType = "all" | "file" | "directory"
|
||||
|
||||
export interface WorkspaceFileSearchOptions {
|
||||
limit?: number
|
||||
type?: WorkspaceFileSearchType
|
||||
refresh?: boolean
|
||||
}
|
||||
|
||||
interface CandidateEntry {
|
||||
entry: FileSystemEntry
|
||||
key: string
|
||||
}
|
||||
|
||||
export function searchWorkspaceFiles(
|
||||
rootDir: string,
|
||||
query: string,
|
||||
options: WorkspaceFileSearchOptions = {},
|
||||
): FileSystemEntry[] {
|
||||
const trimmedQuery = query.trim()
|
||||
if (!trimmedQuery) {
|
||||
throw new Error("Search query is required")
|
||||
}
|
||||
|
||||
const normalizedRoot = path.resolve(rootDir)
|
||||
const limit = normalizeLimit(options.limit)
|
||||
const typeFilter: WorkspaceFileSearchType = options.type ?? "all"
|
||||
const refreshRequested = options.refresh === true
|
||||
|
||||
let entries: FileSystemEntry[] | undefined
|
||||
|
||||
try {
|
||||
if (!refreshRequested) {
|
||||
entries = getWorkspaceCandidates(normalizedRoot)
|
||||
}
|
||||
|
||||
if (!entries) {
|
||||
entries = refreshWorkspaceCandidates(normalizedRoot, () => collectCandidates(normalizedRoot))
|
||||
}
|
||||
} catch (error) {
|
||||
clearWorkspaceSearchCache(normalizedRoot)
|
||||
throw error
|
||||
}
|
||||
|
||||
if (!entries || entries.length === 0) {
|
||||
clearWorkspaceSearchCache(normalizedRoot)
|
||||
return []
|
||||
}
|
||||
|
||||
const candidates = buildCandidateEntries(entries, typeFilter)
|
||||
|
||||
if (candidates.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const matches = fuzzysort.go<CandidateEntry>(trimmedQuery, candidates, {
|
||||
key: "key",
|
||||
limit,
|
||||
})
|
||||
|
||||
if (!matches || matches.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
return matches.map((match) => match.obj.entry)
|
||||
}
|
||||
|
||||
|
||||
function collectCandidates(rootDir: string): FileSystemEntry[] {
|
||||
const queue: string[] = [""]
|
||||
const entries: FileSystemEntry[] = []
|
||||
|
||||
while (queue.length > 0 && entries.length < MAX_CANDIDATES) {
|
||||
const relativeDir = queue.pop() || ""
|
||||
const absoluteDir = relativeDir ? path.join(rootDir, relativeDir) : rootDir
|
||||
|
||||
let dirents: fs.Dirent[]
|
||||
try {
|
||||
dirents = fs.readdirSync(absoluteDir, { withFileTypes: true })
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
|
||||
for (const dirent of dirents) {
|
||||
const entryName = dirent.name
|
||||
const lowerName = entryName.toLowerCase()
|
||||
const relativePath = relativeDir ? `${relativeDir}/${entryName}` : entryName
|
||||
const absolutePath = path.join(absoluteDir, entryName)
|
||||
|
||||
if (dirent.isDirectory() && IGNORED_DIRECTORIES.has(lowerName)) {
|
||||
continue
|
||||
}
|
||||
|
||||
let stats: fs.Stats
|
||||
try {
|
||||
stats = fs.statSync(absolutePath)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
|
||||
const isDirectory = stats.isDirectory()
|
||||
|
||||
if (isDirectory && !IGNORED_DIRECTORIES.has(lowerName)) {
|
||||
if (entries.length < MAX_CANDIDATES) {
|
||||
queue.push(relativePath)
|
||||
}
|
||||
}
|
||||
|
||||
const entryType: FileSystemEntry["type"] = isDirectory ? "directory" : "file"
|
||||
const normalizedPath = normalizeRelativeEntryPath(relativePath)
|
||||
const entry: FileSystemEntry = {
|
||||
name: entryName,
|
||||
path: normalizedPath,
|
||||
absolutePath: path.resolve(rootDir, normalizedPath === "." ? "" : normalizedPath),
|
||||
type: entryType,
|
||||
size: entryType === "file" ? stats.size : undefined,
|
||||
modifiedAt: stats.mtime.toISOString(),
|
||||
}
|
||||
|
||||
entries.push(entry)
|
||||
|
||||
if (entries.length >= MAX_CANDIDATES) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return entries
|
||||
}
|
||||
|
||||
function buildCandidateEntries(entries: FileSystemEntry[], filter: WorkspaceFileSearchType): CandidateEntry[] {
|
||||
const filtered: CandidateEntry[] = []
|
||||
for (const entry of entries) {
|
||||
if (!shouldInclude(entry.type, filter)) {
|
||||
continue
|
||||
}
|
||||
filtered.push({ entry, key: buildSearchKey(entry) })
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
function normalizeLimit(limit?: number) {
|
||||
if (!limit || Number.isNaN(limit)) {
|
||||
return DEFAULT_LIMIT
|
||||
}
|
||||
const clamped = Math.min(Math.max(limit, 1), MAX_LIMIT)
|
||||
return clamped
|
||||
}
|
||||
|
||||
function shouldInclude(entryType: FileSystemEntry["type"], filter: WorkspaceFileSearchType) {
|
||||
return filter === "all" || entryType === filter
|
||||
}
|
||||
|
||||
function normalizeRelativeEntryPath(relativePath: string): string {
|
||||
if (!relativePath) {
|
||||
return "."
|
||||
}
|
||||
let normalized = relativePath.replace(/\\+/g, "/")
|
||||
if (normalized.startsWith("./")) {
|
||||
normalized = normalized.replace(/^\.\/+/, "")
|
||||
}
|
||||
if (normalized.startsWith("/")) {
|
||||
normalized = normalized.replace(/^\/+/g, "")
|
||||
}
|
||||
return normalized || "."
|
||||
}
|
||||
|
||||
function buildSearchKey(entry: FileSystemEntry) {
|
||||
return entry.path.toLowerCase()
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import { EventBus } from "./events/bus"
|
||||
import { ServerMeta } from "./api-types"
|
||||
import { InstanceStore } from "./storage/instance-store"
|
||||
import { createLogger } from "./logger"
|
||||
import { launchInBrowser } from "./launcher"
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const packageJson = require("../package.json") as { version: string }
|
||||
@@ -32,6 +33,7 @@ interface CliOptions {
|
||||
logDestination?: string
|
||||
uiStaticDir: string
|
||||
uiDevServer?: string
|
||||
launch: boolean
|
||||
}
|
||||
|
||||
const DEFAULT_PORT = 9898
|
||||
@@ -40,7 +42,7 @@ const DEFAULT_CONFIG_PATH = "~/.config/codenomad/config.json"
|
||||
|
||||
function parseCliOptions(argv: string[]): CliOptions {
|
||||
const program = new Command()
|
||||
.name("codenomad-cli")
|
||||
.name("codenomad")
|
||||
.description("CodeNomad CLI server")
|
||||
.version(packageJson.version, "-v, --version", "Show the CLI version")
|
||||
.addOption(new Option("--host <host>", "Host interface to bind").env("CLI_HOST").default(DEFAULT_HOST))
|
||||
@@ -57,6 +59,7 @@ function parseCliOptions(argv: string[]): CliOptions {
|
||||
new Option("--ui-dir <path>", "Directory containing the built UI bundle").env("CLI_UI_DIR").default(DEFAULT_UI_STATIC_DIR),
|
||||
)
|
||||
.addOption(new Option("--ui-dev-server <url>", "Proxy UI requests to a running dev server").env("CLI_UI_DEV_SERVER"))
|
||||
.addOption(new Option("--launch", "Launch the UI in a browser after start").env("CLI_LAUNCH").default(false))
|
||||
|
||||
program.parse(argv, { from: "user" })
|
||||
const parsed = program.opts<{
|
||||
@@ -70,6 +73,7 @@ function parseCliOptions(argv: string[]): CliOptions {
|
||||
logDestination?: string
|
||||
uiDir: string
|
||||
uiDevServer?: string
|
||||
launch?: boolean
|
||||
}>()
|
||||
|
||||
const resolvedRoot = parsed.workspaceRoot ?? parsed.root ?? process.cwd()
|
||||
@@ -84,13 +88,14 @@ function parseCliOptions(argv: string[]): CliOptions {
|
||||
logDestination: parsed.logDestination,
|
||||
uiStaticDir: parsed.uiDir,
|
||||
uiDevServer: parsed.uiDevServer,
|
||||
launch: Boolean(parsed.launch),
|
||||
}
|
||||
}
|
||||
|
||||
function parsePort(input: string): number {
|
||||
const value = Number(input)
|
||||
if (!Number.isInteger(value) || value < 1 || value > 65535) {
|
||||
throw new InvalidArgumentError("Port must be an integer between 1 and 65535")
|
||||
if (!Number.isInteger(value) || value < 0 || value > 65535) {
|
||||
throw new InvalidArgumentError("Port must be an integer between 0 and 65535")
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -139,11 +144,13 @@ async function main() {
|
||||
logger,
|
||||
})
|
||||
|
||||
const startInfo = await server.start()
|
||||
logger.info({ port: startInfo.port, host: options.host }, "HTTP server listening")
|
||||
console.log(`CodeNomad Server is ready at ${startInfo.url}`)
|
||||
|
||||
await server.start()
|
||||
logger.info({ port: options.port, host: options.host }, "HTTP server listening")
|
||||
const displayHost = options.host === "127.0.0.1" || options.host === "0.0.0.0" ? "localhost" : options.host
|
||||
console.log(`CodeNomad Server is ready at http://${displayHost}:${options.port}`)
|
||||
if (options.launch) {
|
||||
await launchInBrowser(startInfo.url, logger.child({ component: "launcher" }))
|
||||
}
|
||||
|
||||
let shuttingDown = false
|
||||
|
||||
177
packages/server/src/launcher.ts
Normal file
177
packages/server/src/launcher.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import { spawn } from "child_process"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import type { Logger } from "./logger"
|
||||
|
||||
interface BrowserCandidate {
|
||||
name: string
|
||||
command: string
|
||||
args: (url: string) => string[]
|
||||
}
|
||||
|
||||
const APP_ARGS = (url: string) => [`--app=${url}`, "--new-window"]
|
||||
|
||||
export async function launchInBrowser(url: string, logger: Logger): Promise<boolean> {
|
||||
const { platform, candidates, manualExamples } = buildPlatformCandidates(url)
|
||||
|
||||
console.log(`Attempting to launch browser (${platform}) using:`)
|
||||
candidates.forEach((candidate) => console.log(` - ${candidate.name}: ${candidate.command}`))
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const success = await tryLaunch(candidate, url, logger)
|
||||
if (success) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
console.error(
|
||||
"No supported browser found to launch. Run without --launch and use one of the commands below or install a compatible browser.",
|
||||
)
|
||||
if (manualExamples.length > 0) {
|
||||
console.error("Manual launch commands:")
|
||||
manualExamples.forEach((line) => console.error(` ${line}`))
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
async function tryLaunch(candidate: BrowserCandidate, url: string, logger: Logger): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
let resolved = false
|
||||
try {
|
||||
const args = candidate.args(url)
|
||||
const child = spawn(candidate.command, args, { stdio: "ignore", detached: true })
|
||||
|
||||
child.once("error", (error) => {
|
||||
if (resolved) return
|
||||
resolved = true
|
||||
logger.debug({ err: error, candidate: candidate.name, command: candidate.command, args }, "Browser launch failed")
|
||||
resolve(false)
|
||||
})
|
||||
|
||||
child.once("spawn", () => {
|
||||
if (resolved) return
|
||||
resolved = true
|
||||
logger.info(
|
||||
{
|
||||
browser: candidate.name,
|
||||
command: candidate.command,
|
||||
args,
|
||||
fullCommand: [candidate.command, ...args].join(" "),
|
||||
},
|
||||
"Launched browser in app mode",
|
||||
)
|
||||
child.unref()
|
||||
resolve(true)
|
||||
})
|
||||
} catch (error) {
|
||||
if (resolved) return
|
||||
resolved = true
|
||||
logger.debug({ err: error, candidate: candidate.name, command: candidate.command }, "Browser spawn threw")
|
||||
resolve(false)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function buildPlatformCandidates(url: string) {
|
||||
switch (os.platform()) {
|
||||
case "darwin":
|
||||
return {
|
||||
platform: "macOS",
|
||||
candidates: buildMacCandidates(),
|
||||
manualExamples: buildMacManualExamples(url),
|
||||
}
|
||||
case "win32":
|
||||
return {
|
||||
platform: "Windows",
|
||||
candidates: buildWindowsCandidates(),
|
||||
manualExamples: buildWindowsManualExamples(url),
|
||||
}
|
||||
default:
|
||||
return {
|
||||
platform: "Linux",
|
||||
candidates: buildLinuxCandidates(),
|
||||
manualExamples: buildLinuxManualExamples(url),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildMacCandidates(): BrowserCandidate[] {
|
||||
const apps = [
|
||||
{ name: "Google Chrome", path: "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" },
|
||||
{ name: "Google Chrome Canary", path: "/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary" },
|
||||
{ name: "Microsoft Edge", path: "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge" },
|
||||
{ name: "Brave Browser", path: "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser" },
|
||||
{ name: "Chromium", path: "/Applications/Chromium.app/Contents/MacOS/Chromium" },
|
||||
{ name: "Vivaldi", path: "/Applications/Vivaldi.app/Contents/MacOS/Vivaldi" },
|
||||
{ name: "Arc", path: "/Applications/Arc.app/Contents/MacOS/Arc" },
|
||||
]
|
||||
|
||||
return apps.map((entry) => ({ name: entry.name, command: entry.path, args: APP_ARGS }))
|
||||
}
|
||||
|
||||
function buildWindowsCandidates(): BrowserCandidate[] {
|
||||
const programFiles = process.env["ProgramFiles"]
|
||||
const programFilesX86 = process.env["ProgramFiles(x86)"]
|
||||
const localAppData = process.env["LocalAppData"]
|
||||
|
||||
const paths = [
|
||||
[programFiles, "Google/Chrome/Application/chrome.exe", "Google Chrome"],
|
||||
[programFilesX86, "Google/Chrome/Application/chrome.exe", "Google Chrome (x86)"],
|
||||
[localAppData, "Google/Chrome/Application/chrome.exe", "Google Chrome (User)"],
|
||||
[programFiles, "Microsoft/Edge/Application/msedge.exe", "Microsoft Edge"],
|
||||
[programFilesX86, "Microsoft/Edge/Application/msedge.exe", "Microsoft Edge (x86)"],
|
||||
[localAppData, "Microsoft/Edge/Application/msedge.exe", "Microsoft Edge (User)"],
|
||||
[programFiles, "BraveSoftware/Brave-Browser/Application/brave.exe", "Brave"],
|
||||
[localAppData, "BraveSoftware/Brave-Browser/Application/brave.exe", "Brave (User)"],
|
||||
[programFiles, "Chromium/Application/chromium.exe", "Chromium"],
|
||||
] as const
|
||||
|
||||
return paths
|
||||
.filter(([root]) => Boolean(root))
|
||||
.map(([root, rel, name]) => ({
|
||||
name,
|
||||
command: path.join(root as string, rel),
|
||||
args: APP_ARGS,
|
||||
}))
|
||||
}
|
||||
|
||||
function buildLinuxCandidates(): BrowserCandidate[] {
|
||||
const names = [
|
||||
"google-chrome",
|
||||
"google-chrome-stable",
|
||||
"chromium",
|
||||
"chromium-browser",
|
||||
"brave-browser",
|
||||
"microsoft-edge",
|
||||
"microsoft-edge-stable",
|
||||
"vivaldi",
|
||||
]
|
||||
|
||||
return names.map((name) => ({ name, command: name, args: APP_ARGS }))
|
||||
}
|
||||
|
||||
function buildMacManualExamples(url: string) {
|
||||
return [
|
||||
`"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --app="${url}" --new-window`,
|
||||
`"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge" --app="${url}" --new-window`,
|
||||
`"/Applications/Brave Browser.app/Contents/MacOS/Brave Browser" --app="${url}" --new-window`,
|
||||
]
|
||||
}
|
||||
|
||||
function buildWindowsManualExamples(url: string) {
|
||||
return [
|
||||
`"%ProgramFiles%\\Google\\Chrome\\Application\\chrome.exe" --app="${url}" --new-window`,
|
||||
`"%ProgramFiles%\\Microsoft\\Edge\\Application\\msedge.exe" --app="${url}" --new-window`,
|
||||
`"%ProgramFiles%\\BraveSoftware\\Brave-Browser\\Application\\brave.exe" --app="${url}" --new-window`,
|
||||
]
|
||||
}
|
||||
|
||||
function buildLinuxManualExamples(url: string) {
|
||||
return [
|
||||
`google-chrome --app="${url}" --new-window`,
|
||||
`chromium --app="${url}" --new-window`,
|
||||
`brave-browser --app="${url}" --new-window`,
|
||||
`microsoft-edge --app="${url}" --new-window`,
|
||||
]
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import Fastify, { type FastifyInstance, type FastifyReply, type FastifyRequest } from "fastify"
|
||||
import cors from "@fastify/cors"
|
||||
import fastifyStatic from "@fastify/static"
|
||||
import replyFrom, { type FastifyReplyFromOptions } from "@fastify/reply-from"
|
||||
import replyFrom from "@fastify/reply-from"
|
||||
import fs from "fs"
|
||||
import path from "path"
|
||||
import { fetch } from "undici"
|
||||
@@ -36,6 +36,11 @@ interface HttpServerDeps {
|
||||
logger: Logger
|
||||
}
|
||||
|
||||
interface HttpServerStartResult {
|
||||
port: number
|
||||
url: string
|
||||
displayHost: string
|
||||
}
|
||||
|
||||
export function createHttpServer(deps: HttpServerDeps) {
|
||||
const app = Fastify({ logger: false })
|
||||
@@ -67,9 +72,14 @@ export function createHttpServer(deps: HttpServerDeps) {
|
||||
registerFilesystemRoutes(app, { fileSystemBrowser: deps.fileSystemBrowser })
|
||||
registerMetaRoutes(app, { serverMeta: deps.serverMeta })
|
||||
registerEventRoutes(app, { eventBus: deps.eventBus, registerClient: registerSseClient })
|
||||
registerStorageRoutes(app, { instanceStore: deps.instanceStore })
|
||||
registerStorageRoutes(app, {
|
||||
instanceStore: deps.instanceStore,
|
||||
eventBus: deps.eventBus,
|
||||
workspaceManager: deps.workspaceManager,
|
||||
})
|
||||
registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger })
|
||||
|
||||
|
||||
if (deps.uiDevServerUrl) {
|
||||
setupDevProxy(app, deps.uiDevServerUrl)
|
||||
} else {
|
||||
@@ -78,7 +88,34 @@ export function createHttpServer(deps: HttpServerDeps) {
|
||||
|
||||
return {
|
||||
instance: app,
|
||||
start: () => app.listen({ port: deps.port, host: deps.host }),
|
||||
start: async (): Promise<HttpServerStartResult> => {
|
||||
const addressInfo = await app.listen({ port: deps.port, host: deps.host })
|
||||
|
||||
let actualPort = deps.port
|
||||
|
||||
if (typeof addressInfo === "string") {
|
||||
try {
|
||||
const parsed = new URL(addressInfo)
|
||||
actualPort = Number(parsed.port) || deps.port
|
||||
} catch {
|
||||
actualPort = deps.port
|
||||
}
|
||||
} else {
|
||||
const address = app.server.address()
|
||||
if (typeof address === "object" && address) {
|
||||
actualPort = address.port
|
||||
}
|
||||
}
|
||||
|
||||
const displayHost = deps.host === "0.0.0.0" ? "127.0.0.1" : deps.host === "127.0.0.1" ? "localhost" : deps.host
|
||||
const serverUrl = `http://${displayHost}:${actualPort}`
|
||||
|
||||
deps.serverMeta.httpBaseUrl = serverUrl
|
||||
deps.logger.info({ port: actualPort, host: deps.host }, "HTTP server listening")
|
||||
console.log(`CodeNomad Server is ready at ${serverUrl}`)
|
||||
|
||||
return { port: actualPort, url: serverUrl, displayHost }
|
||||
},
|
||||
stop: () => {
|
||||
closeSseClients()
|
||||
return app.close()
|
||||
@@ -2,7 +2,7 @@ import { FastifyInstance } from "fastify"
|
||||
import { z } from "zod"
|
||||
import { ConfigStore } from "../../config/store"
|
||||
import { BinaryRegistry } from "../../config/binaries"
|
||||
import { ConfigFileSchema, ConfigFileUpdateSchema } from "../../config/schema"
|
||||
import { ConfigFileSchema } from "../../config/schema"
|
||||
|
||||
interface RouteDeps {
|
||||
configStore: ConfigStore
|
||||
@@ -29,13 +29,7 @@ export function registerConfigRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
|
||||
app.put("/api/config/app", async (request) => {
|
||||
const body = ConfigFileSchema.parse(request.body ?? {})
|
||||
deps.configStore.update(body)
|
||||
return deps.configStore.get()
|
||||
})
|
||||
|
||||
app.patch("/api/config/app", async (request) => {
|
||||
const body = ConfigFileUpdateSchema.parse(request.body ?? {})
|
||||
deps.configStore.update(body)
|
||||
deps.configStore.replace(body)
|
||||
return deps.configStore.get()
|
||||
})
|
||||
|
||||
@@ -1,19 +1,37 @@
|
||||
import { FastifyInstance } from "fastify"
|
||||
import { z } from "zod"
|
||||
import { InstanceStore } from "../../storage/instance-store"
|
||||
import { EventBus } from "../../events/bus"
|
||||
import { ModelPreferenceSchema } from "../../config/schema"
|
||||
import type { InstanceData } from "../../api-types"
|
||||
import { WorkspaceManager } from "../../workspaces/manager"
|
||||
|
||||
interface RouteDeps {
|
||||
instanceStore: InstanceStore
|
||||
eventBus: EventBus
|
||||
workspaceManager: WorkspaceManager
|
||||
}
|
||||
|
||||
const InstanceDataSchema = z.object({
|
||||
messageHistory: z.array(z.string()).default([]),
|
||||
agentModelSelections: z.record(z.string(), ModelPreferenceSchema).default({}),
|
||||
})
|
||||
|
||||
const EMPTY_INSTANCE_DATA: InstanceData = {
|
||||
messageHistory: [],
|
||||
agentModelSelections: {},
|
||||
}
|
||||
|
||||
export function registerStorageRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
const resolveStorageKey = (instanceId: string): string => {
|
||||
const workspace = deps.workspaceManager.get(instanceId)
|
||||
return workspace?.path ?? instanceId
|
||||
}
|
||||
|
||||
app.get<{ Params: { id: string } }>("/api/storage/instances/:id", async (request, reply) => {
|
||||
try {
|
||||
const data = await deps.instanceStore.read(request.params.id)
|
||||
const storageId = resolveStorageKey(request.params.id)
|
||||
const data = await deps.instanceStore.read(storageId)
|
||||
return data
|
||||
} catch (error) {
|
||||
reply.code(500)
|
||||
@@ -24,7 +42,9 @@ export function registerStorageRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
app.put<{ Params: { id: string } }>("/api/storage/instances/:id", async (request, reply) => {
|
||||
try {
|
||||
const body = InstanceDataSchema.parse(request.body ?? {})
|
||||
await deps.instanceStore.write(request.params.id, body)
|
||||
const storageId = resolveStorageKey(request.params.id)
|
||||
await deps.instanceStore.write(storageId, body)
|
||||
deps.eventBus.publish({ type: "instance.dataChanged", instanceId: request.params.id, data: body })
|
||||
reply.code(204)
|
||||
} catch (error) {
|
||||
reply.code(400)
|
||||
@@ -34,7 +54,9 @@ export function registerStorageRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
|
||||
app.delete<{ Params: { id: string } }>("/api/storage/instances/:id", async (request, reply) => {
|
||||
try {
|
||||
await deps.instanceStore.delete(request.params.id)
|
||||
const storageId = resolveStorageKey(request.params.id)
|
||||
await deps.instanceStore.delete(storageId)
|
||||
deps.eventBus.publish({ type: "instance.dataChanged", instanceId: request.params.id, data: EMPTY_INSTANCE_DATA })
|
||||
reply.code(204)
|
||||
} catch (error) {
|
||||
reply.code(500)
|
||||
@@ -19,6 +19,16 @@ const WorkspaceFileContentQuerySchema = z.object({
|
||||
path: z.string(),
|
||||
})
|
||||
|
||||
const WorkspaceFileSearchQuerySchema = z.object({
|
||||
q: z.string().trim().min(1, "Query is required"),
|
||||
limit: z.coerce.number().int().positive().max(200).optional(),
|
||||
type: z.enum(["all", "file", "directory"]).optional(),
|
||||
refresh: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((value) => (value === undefined ? undefined : value === "true")),
|
||||
})
|
||||
|
||||
export function registerWorkspaceRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
app.get("/api/workspaces", async () => {
|
||||
return deps.workspaceManager.list()
|
||||
@@ -57,6 +67,22 @@ export function registerWorkspaceRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
}
|
||||
})
|
||||
|
||||
app.get<{
|
||||
Params: { id: string }
|
||||
Querystring: { q?: string; limit?: string; type?: "all" | "file" | "directory"; refresh?: string }
|
||||
}>("/api/workspaces/:id/files/search", async (request, reply) => {
|
||||
try {
|
||||
const query = WorkspaceFileSearchQuerySchema.parse(request.query ?? {})
|
||||
return deps.workspaceManager.searchFiles(request.params.id, query.q, {
|
||||
limit: query.limit,
|
||||
type: query.type,
|
||||
refresh: query.refresh,
|
||||
})
|
||||
} catch (error) {
|
||||
return handleWorkspaceError(error, reply)
|
||||
}
|
||||
})
|
||||
|
||||
app.get<{
|
||||
Params: { id: string }
|
||||
Querystring: { path?: string }
|
||||
@@ -70,6 +96,7 @@ export function registerWorkspaceRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
function handleWorkspaceError(error: unknown, reply: FastifyReply) {
|
||||
if (error instanceof Error && error.message === "Workspace not found") {
|
||||
reply.code(404)
|
||||
@@ -6,6 +6,7 @@ import type { InstanceData } from "../api-types"
|
||||
|
||||
const DEFAULT_INSTANCE_DATA: InstanceData = {
|
||||
messageHistory: [],
|
||||
agentModelSelections: {},
|
||||
}
|
||||
|
||||
export class InstanceStore {
|
||||
@@ -3,6 +3,8 @@ import { EventBus } from "../events/bus"
|
||||
import { ConfigStore } from "../config/store"
|
||||
import { BinaryRegistry } from "../config/binaries"
|
||||
import { FileSystemBrowser } from "../filesystem/browser"
|
||||
import { searchWorkspaceFiles, WorkspaceFileSearchOptions } from "../filesystem/search"
|
||||
import { clearWorkspaceSearchCache } from "../filesystem/search-cache"
|
||||
import { WorkspaceDescriptor, WorkspaceFileResponse, FileSystemEntry } from "../api-types"
|
||||
import { WorkspaceRuntime } from "./runtime"
|
||||
import { Logger } from "../logger"
|
||||
@@ -43,6 +45,11 @@ export class WorkspaceManager {
|
||||
return browser.list(relativePath)
|
||||
}
|
||||
|
||||
searchFiles(workspaceId: string, query: string, options?: WorkspaceFileSearchOptions): FileSystemEntry[] {
|
||||
const workspace = this.requireWorkspace(workspaceId)
|
||||
return searchWorkspaceFiles(workspace.path, query, options)
|
||||
}
|
||||
|
||||
readFile(workspaceId: string, relativePath: string): WorkspaceFileResponse {
|
||||
const workspace = this.requireWorkspace(workspaceId)
|
||||
const browser = new FileSystemBrowser({ rootDir: workspace.path })
|
||||
@@ -55,14 +62,17 @@ export class WorkspaceManager {
|
||||
}
|
||||
|
||||
async create(folder: string, name?: string): Promise<WorkspaceDescriptor> {
|
||||
|
||||
const id = `${Date.now().toString(36)}`
|
||||
const binary = this.options.binaryRegistry.resolveDefault()
|
||||
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")
|
||||
|
||||
const proxyPath = `/workspaces/${id}/instance`
|
||||
|
||||
|
||||
const descriptor: WorkspaceRecord = {
|
||||
id,
|
||||
path: workspacePath,
|
||||
@@ -120,6 +130,7 @@ export class WorkspaceManager {
|
||||
}
|
||||
|
||||
this.workspaces.delete(id)
|
||||
clearWorkspaceSearchCache(workspace.path)
|
||||
if (!wasRunning) {
|
||||
this.options.eventBus.publish({ type: "workspace.stopped", workspaceId: id })
|
||||
}
|
||||
33
packages/ui/README.md
Normal file
33
packages/ui/README.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# CodeNomad UI
|
||||
|
||||
This package contains the frontend user interface for CodeNomad, built with [SolidJS](https://www.solidjs.com/) and [Tailwind CSS](https://tailwindcss.com/).
|
||||
|
||||
## Overview
|
||||
|
||||
The UI is designed to be a high-performance, low-latency cockpit for managing OpenCode sessions. It connects to the CodeNomad server (either running locally via CLI or embedded in the Electron app).
|
||||
|
||||
## Features
|
||||
|
||||
- **SolidJS**: Fine-grained reactivity for high performance.
|
||||
- **Tailwind CSS**: Utility-first styling for rapid development.
|
||||
- **Vite**: Fast build tool and dev server.
|
||||
|
||||
## Development
|
||||
|
||||
To run the UI in standalone mode (connected to a running server):
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
This starts the Vite dev server at `http://localhost:3000`.
|
||||
|
||||
## Building
|
||||
|
||||
To build the production assets:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
The output will be generated in the `dist` directory, which is then consumed by the Server or Electron app.
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@codenomad/ui",
|
||||
"version": "0.1.2",
|
||||
"version": "0.2.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -43,7 +43,7 @@ const App: Component = () => {
|
||||
const { isDark } = useTheme()
|
||||
const {
|
||||
preferences,
|
||||
addRecentFolder,
|
||||
recordWorkspaceLaunch,
|
||||
toggleShowThinkingBlocks,
|
||||
setDiffViewMode,
|
||||
setToolOutputExpansion,
|
||||
@@ -92,7 +92,7 @@ const App: Component = () => {
|
||||
setIsSelectingFolder(true)
|
||||
const selectedBinary = binaryPath || preferences().lastUsedBinary || "opencode"
|
||||
try {
|
||||
addRecentFolder(folderPath)
|
||||
recordWorkspaceLaunch(folderPath, selectedBinary)
|
||||
clearLaunchError()
|
||||
const instanceId = await createInstance(folderPath, selectedBinary)
|
||||
setHasInstances(true)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Component, Show, For, createSignal, createMemo, createEffect, onCleanup } from "solid-js"
|
||||
import { ArrowUpLeft, Folder as FolderIcon, Loader2, X } from "lucide-solid"
|
||||
import type { FileSystemEntry, FileSystemListingMetadata } from "../../../cli/src/api-types"
|
||||
import { WINDOWS_DRIVES_ROOT } from "../../../cli/src/api-types"
|
||||
import { cliApi } from "../lib/api-client"
|
||||
import type { FileSystemEntry, FileSystemListingMetadata } from "../../../server/src/api-types"
|
||||
import { WINDOWS_DRIVES_ROOT } from "../../../server/src/api-types"
|
||||
import { serverApi } from "../lib/api-client"
|
||||
|
||||
function normalizePathKey(input?: string | null) {
|
||||
if (!input || input === "." || input === "./") {
|
||||
@@ -144,7 +144,7 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
|
||||
})
|
||||
}
|
||||
|
||||
const response = await cliApi.listFileSystem(targetPath, { includeFiles: false })
|
||||
const response = await serverApi.listFileSystem(targetPath, { includeFiles: false })
|
||||
const canonicalKey = normalizePathKey(response.metadata.currentPath)
|
||||
const directories = response.entries
|
||||
.filter((entry) => entry.type === "directory")
|
||||
|
||||
@@ -1,222 +0,0 @@
|
||||
import { Component, createSignal, createEffect, For, Show, onCleanup } from "solid-js"
|
||||
|
||||
import type { OpencodeClient } from "@opencode-ai/sdk/client"
|
||||
import { cliApi } from "../lib/api-client"
|
||||
|
||||
interface FileItem {
|
||||
path: string
|
||||
added?: number
|
||||
removed?: number
|
||||
isGitFile: boolean
|
||||
}
|
||||
|
||||
interface FilePickerProps {
|
||||
open: boolean
|
||||
onSelect: (path: string) => void
|
||||
onNavigate: (direction: "up" | "down") => void
|
||||
onClose: () => void
|
||||
instanceClient: OpencodeClient
|
||||
searchQuery: string
|
||||
textareaRef?: HTMLTextAreaElement
|
||||
workspaceId: string
|
||||
}
|
||||
|
||||
const FilePicker: Component<FilePickerProps> = (props) => {
|
||||
const [files, setFiles] = createSignal<FileItem[]>([])
|
||||
const [selectedIndex, setSelectedIndex] = createSignal(0)
|
||||
const [loading, setLoading] = createSignal(false)
|
||||
const [allFiles, setAllFiles] = createSignal<FileItem[]>([])
|
||||
const [isInitialized, setIsInitialized] = createSignal(false)
|
||||
|
||||
let containerRef: HTMLDivElement | undefined
|
||||
let scrollContainerRef: HTMLDivElement | undefined
|
||||
|
||||
async function fetchFiles(searchQuery: string) {
|
||||
console.log(`[FilePicker] Fetching files for query: "${searchQuery}"`)
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
if (allFiles().length === 0) {
|
||||
console.log(`[FilePicker] Scanning workspace: ${props.workspaceId}`)
|
||||
const entries = await cliApi.listWorkspaceFiles(props.workspaceId)
|
||||
const scannedFiles: FileItem[] = entries.map<FileItem>((entry) => ({
|
||||
path: entry.path,
|
||||
isGitFile: false,
|
||||
}))
|
||||
setAllFiles(scannedFiles)
|
||||
console.log(`[FilePicker] Found ${scannedFiles.length} files`)
|
||||
}
|
||||
|
||||
const filteredFiles = searchQuery.trim()
|
||||
? allFiles().filter((f) => f.path.toLowerCase().includes(searchQuery.toLowerCase()))
|
||||
: allFiles()
|
||||
|
||||
console.log(`[FilePicker] Showing ${filteredFiles.length} files`)
|
||||
setFiles(filteredFiles)
|
||||
setSelectedIndex(0)
|
||||
|
||||
setTimeout(() => {
|
||||
if (scrollContainerRef) {
|
||||
scrollContainerRef.scrollTop = 0
|
||||
}
|
||||
}, 0)
|
||||
} catch (error) {
|
||||
console.error(`[FilePicker] Failed to fetch files:`, error)
|
||||
setFiles([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
let lastQuery = ""
|
||||
|
||||
createEffect(() => {
|
||||
console.log(
|
||||
`[FilePicker] Effect triggered - open: ${props.open}, query: "${props.searchQuery}", isInitialized: ${isInitialized()}`,
|
||||
)
|
||||
|
||||
if (props.open && !isInitialized()) {
|
||||
setIsInitialized(true)
|
||||
console.log("[FilePicker] First open - fetching files")
|
||||
fetchFiles(props.searchQuery)
|
||||
lastQuery = props.searchQuery
|
||||
return
|
||||
}
|
||||
|
||||
if (props.open && props.searchQuery !== lastQuery) {
|
||||
console.log(`[FilePicker] Query changed from "${lastQuery}" to "${props.searchQuery}"`)
|
||||
lastQuery = props.searchQuery
|
||||
fetchFiles(props.searchQuery)
|
||||
}
|
||||
})
|
||||
|
||||
function scrollToSelected() {
|
||||
setTimeout(() => {
|
||||
const selectedElement = containerRef?.querySelector('[data-file-selected="true"]')
|
||||
if (selectedElement) {
|
||||
selectedElement.scrollIntoView({ block: "nearest", behavior: "smooth" })
|
||||
}
|
||||
}, 0)
|
||||
}
|
||||
|
||||
function handleSelect(path: string) {
|
||||
props.onSelect(path)
|
||||
}
|
||||
|
||||
function handleNavigateUp() {
|
||||
setSelectedIndex((prev) => {
|
||||
const next = Math.max(prev - 1, 0)
|
||||
scrollToSelected()
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function handleNavigateDown() {
|
||||
setSelectedIndex((prev) => {
|
||||
const next = Math.min(prev + 1, files().length - 1)
|
||||
scrollToSelected()
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (!props.open) return
|
||||
const listener = (e: KeyboardEvent) => {
|
||||
if (!props.open) return
|
||||
const fileList = files()
|
||||
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
props.onClose()
|
||||
return
|
||||
}
|
||||
|
||||
if (fileList.length === 0) return
|
||||
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
handleNavigateDown()
|
||||
props.onNavigate("down")
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
handleNavigateUp()
|
||||
props.onNavigate("up")
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (fileList[selectedIndex()]) {
|
||||
handleSelect(fileList[selectedIndex()].path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", listener, true)
|
||||
onCleanup(() => document.removeEventListener("keydown", listener, true))
|
||||
})
|
||||
|
||||
return (
|
||||
<Show when={props.open}>
|
||||
<div
|
||||
ref={containerRef}
|
||||
class="dropdown-surface bottom-full left-0 mb-2 max-w-2xl rounded-lg"
|
||||
style={{ "z-index": 100 }}
|
||||
>
|
||||
<div ref={scrollContainerRef} class="dropdown-content max-h-96">
|
||||
<Show
|
||||
when={!loading() && isInitialized()}
|
||||
fallback={
|
||||
<div class="dropdown-loading">
|
||||
<div class="spinner inline-block h-4 w-4 mr-2"></div>
|
||||
<span>Loading files...</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={files().length > 0}
|
||||
fallback={<div class="dropdown-empty">No matching files</div>}
|
||||
>
|
||||
<For each={files()}>
|
||||
{(file, index) => (
|
||||
<div
|
||||
data-file-selected={index() === selectedIndex()}
|
||||
class={`dropdown-item border-b px-4 py-2 font-mono text-sm ${
|
||||
index() === selectedIndex() ? "dropdown-item-highlight" : ""
|
||||
}`}
|
||||
style="border-color: var(--border-muted)"
|
||||
onClick={() => handleSelect(file.path)}
|
||||
onMouseEnter={() => setSelectedIndex(index())}
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<span>{file.path}</span>
|
||||
<Show when={file.isGitFile && (file.added || file.removed)}>
|
||||
<div class="flex gap-2">
|
||||
<Show when={file.added}>
|
||||
<span class="dropdown-diff-added">+{file.added}</span>
|
||||
</Show>
|
||||
<Show when={file.removed}>
|
||||
<span class="dropdown-diff-removed">-{file.removed}</span>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class="dropdown-footer p-2">
|
||||
<div class="flex items-center justify-between px-2">
|
||||
<span>↑↓ Navigate • Enter Select • Esc Close</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
export default FilePicker
|
||||
@@ -1,197 +1,23 @@
|
||||
import { Component, Show, For, createSignal, createMemo, createEffect, onCleanup, onMount } from "solid-js"
|
||||
import { Folder as FolderIcon, File as FileIcon, Loader2, Search, X } from "lucide-solid"
|
||||
import type { FileSystemEntry } from "../../../cli/src/api-types"
|
||||
import { cliApi } from "../lib/api-client"
|
||||
import { getServerMeta } from "../lib/server-meta"
|
||||
import { Component, Show, For, createSignal, createMemo, createEffect, onCleanup } from "solid-js"
|
||||
import { Folder as FolderIcon, File as FileIcon, Loader2, Search, X, ArrowUpLeft } from "lucide-solid"
|
||||
import type { FileSystemEntry, FileSystemListingMetadata } from "../../../server/src/api-types"
|
||||
import { serverApi } from "../lib/api-client"
|
||||
|
||||
const MAX_RESULTS = 200
|
||||
|
||||
type CacheListener = (entries: FileSystemEntry[]) => void
|
||||
|
||||
interface FileSystemCacheState {
|
||||
entriesMap: Map<string, FileSystemEntry>
|
||||
entriesList: FileSystemEntry[]
|
||||
loadedDirectories: Set<string>
|
||||
loadingPromises: Map<string, Promise<void>>
|
||||
pendingDirectories: string[]
|
||||
listeners: Set<CacheListener>
|
||||
queueActive: boolean
|
||||
}
|
||||
|
||||
const fileSystemCache: FileSystemCacheState = {
|
||||
entriesMap: new Map(),
|
||||
entriesList: [],
|
||||
loadedDirectories: new Set(),
|
||||
loadingPromises: new Map(),
|
||||
pendingDirectories: [],
|
||||
listeners: new Set(),
|
||||
queueActive: false,
|
||||
}
|
||||
|
||||
let cacheWorkspaceRoot: string | null = null
|
||||
|
||||
function normalizeEntryPath(path: string): string {
|
||||
if (!path || path === ".") {
|
||||
function normalizeEntryPath(path: string | undefined): string {
|
||||
if (!path || path === "." || path === "./") {
|
||||
return "."
|
||||
}
|
||||
const cleaned = path.replace(/\\/g, "/").replace(/^\.\/+/, "").replace(/\/+/g, "/")
|
||||
return cleaned || "."
|
||||
}
|
||||
|
||||
function updateCache(entries: FileSystemEntry[]): boolean {
|
||||
let changed = false
|
||||
for (const entry of entries) {
|
||||
const normalizedPath = normalizeEntryPath(entry.path)
|
||||
const normalizedEntry = normalizedPath === entry.path ? entry : { ...entry, path: normalizedPath }
|
||||
const existing = fileSystemCache.entriesMap.get(normalizedPath)
|
||||
|
||||
if (
|
||||
!existing ||
|
||||
existing.name !== normalizedEntry.name ||
|
||||
existing.type !== normalizedEntry.type ||
|
||||
existing.size !== normalizedEntry.size ||
|
||||
existing.modifiedAt !== normalizedEntry.modifiedAt
|
||||
) {
|
||||
fileSystemCache.entriesMap.set(normalizedPath, normalizedEntry)
|
||||
changed = true
|
||||
}
|
||||
let cleaned = path.replace(/\\/g, "/")
|
||||
if (cleaned.startsWith("./")) {
|
||||
cleaned = cleaned.replace(/^\.\/+/, "")
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
fileSystemCache.entriesList = Array.from(fileSystemCache.entriesMap.values()).sort((a, b) =>
|
||||
a.path.localeCompare(b.path),
|
||||
)
|
||||
if (cleaned.startsWith("/")) {
|
||||
cleaned = cleaned.replace(/^\/+/, "")
|
||||
}
|
||||
|
||||
return changed
|
||||
}
|
||||
|
||||
function notifyCacheListeners() {
|
||||
for (const listener of fileSystemCache.listeners) {
|
||||
listener(fileSystemCache.entriesList)
|
||||
}
|
||||
}
|
||||
|
||||
function subscribeToCache(listener: CacheListener) {
|
||||
fileSystemCache.listeners.add(listener)
|
||||
listener(fileSystemCache.entriesList)
|
||||
return () => fileSystemCache.listeners.delete(listener)
|
||||
}
|
||||
|
||||
function resetFileSystemCache() {
|
||||
fileSystemCache.entriesMap.clear()
|
||||
fileSystemCache.entriesList = []
|
||||
fileSystemCache.loadedDirectories.clear()
|
||||
fileSystemCache.loadingPromises.clear()
|
||||
fileSystemCache.pendingDirectories = []
|
||||
fileSystemCache.queueActive = false
|
||||
notifyCacheListeners()
|
||||
}
|
||||
|
||||
function enqueueDirectory(path: string, priority = false) {
|
||||
const normalized = normalizeEntryPath(path)
|
||||
if (normalized === "." || fileSystemCache.loadedDirectories.has(normalized) || fileSystemCache.loadingPromises.has(normalized)) {
|
||||
return
|
||||
}
|
||||
|
||||
const existingIndex = fileSystemCache.pendingDirectories.indexOf(normalized)
|
||||
if (existingIndex !== -1) {
|
||||
if (priority) {
|
||||
fileSystemCache.pendingDirectories.splice(existingIndex, 1)
|
||||
fileSystemCache.pendingDirectories.unshift(normalized)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (priority) {
|
||||
fileSystemCache.pendingDirectories.unshift(normalized)
|
||||
} else {
|
||||
fileSystemCache.pendingDirectories.push(normalized)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDirectory(path: string): Promise<void> {
|
||||
const normalized = normalizeEntryPath(path)
|
||||
if (fileSystemCache.loadedDirectories.has(normalized)) {
|
||||
return
|
||||
}
|
||||
|
||||
const existing = fileSystemCache.loadingPromises.get(normalized)
|
||||
if (existing) {
|
||||
await existing
|
||||
return
|
||||
}
|
||||
|
||||
const promise = cliApi
|
||||
.listFileSystem(normalized === "." ? "." : normalized)
|
||||
.then(({ entries }) => {
|
||||
const changed = updateCache(entries)
|
||||
fileSystemCache.loadedDirectories.add(normalized)
|
||||
for (const entry of entries) {
|
||||
if (entry.type === "directory") {
|
||||
enqueueDirectory(entry.path)
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
notifyCacheListeners()
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
fileSystemCache.loadingPromises.delete(normalized)
|
||||
})
|
||||
|
||||
fileSystemCache.loadingPromises.set(normalized, promise)
|
||||
await promise
|
||||
}
|
||||
|
||||
async function processDirectoryQueue() {
|
||||
if (fileSystemCache.queueActive) {
|
||||
return
|
||||
}
|
||||
fileSystemCache.queueActive = true
|
||||
try {
|
||||
while (fileSystemCache.pendingDirectories.length > 0) {
|
||||
const next = fileSystemCache.pendingDirectories.shift()
|
||||
if (!next) continue
|
||||
try {
|
||||
await loadDirectory(next)
|
||||
} catch (error) {
|
||||
console.warn("Failed to load directory", next, error)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
fileSystemCache.queueActive = false
|
||||
}
|
||||
}
|
||||
|
||||
function startBackgroundLoading() {
|
||||
void processDirectoryQueue()
|
||||
}
|
||||
|
||||
function prioritizeDirectoriesForQuery(query: string) {
|
||||
const normalized = query.replace(/\\/g, "/").trim()
|
||||
if (!normalized) {
|
||||
return
|
||||
}
|
||||
const segments = normalized.split("/").filter(Boolean)
|
||||
let prefix = ""
|
||||
for (const segment of segments) {
|
||||
prefix = prefix ? `${prefix}/${segment}` : segment
|
||||
enqueueDirectory(prefix, true)
|
||||
}
|
||||
startBackgroundLoading()
|
||||
}
|
||||
|
||||
async function ensureWorkspaceFilesystemLoaded(workspaceRoot: string) {
|
||||
if (cacheWorkspaceRoot && cacheWorkspaceRoot !== workspaceRoot) {
|
||||
cacheWorkspaceRoot = workspaceRoot
|
||||
resetFileSystemCache()
|
||||
} else if (!cacheWorkspaceRoot) {
|
||||
cacheWorkspaceRoot = workspaceRoot
|
||||
}
|
||||
|
||||
await loadDirectory(".")
|
||||
startBackgroundLoading()
|
||||
cleaned = cleaned.replace(/\/+/g, "/")
|
||||
return cleaned === "" ? "." : cleaned
|
||||
}
|
||||
|
||||
function resolveAbsolutePath(root: string, relativePath: string): string {
|
||||
@@ -207,11 +33,6 @@ function resolveAbsolutePath(root: string, relativePath: string): string {
|
||||
return `${trimmedRoot}${normalized}`
|
||||
}
|
||||
|
||||
function formatRootLabel(root: string): string {
|
||||
if (!root) return "Workspace Root"
|
||||
const parts = root.split(/[/\\]/).filter(Boolean)
|
||||
return parts[parts.length - 1] || root || "Workspace Root"
|
||||
}
|
||||
|
||||
interface FileSystemBrowserDialogProps {
|
||||
open: boolean
|
||||
@@ -222,73 +43,174 @@ interface FileSystemBrowserDialogProps {
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
type FolderRow = { type: "up"; path: string } | { type: "entry"; entry: FileSystemEntry }
|
||||
|
||||
const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props) => {
|
||||
const [entries, setEntries] = createSignal<FileSystemEntry[]>([])
|
||||
const [rootPath, setRootPath] = createSignal("")
|
||||
const [loading, setLoading] = createSignal(false)
|
||||
const [entries, setEntries] = createSignal<FileSystemEntry[]>([])
|
||||
const [currentMetadata, setCurrentMetadata] = createSignal<FileSystemListingMetadata | null>(null)
|
||||
const [loadingPath, setLoadingPath] = createSignal<string | null>(null)
|
||||
const [error, setError] = createSignal<string | null>(null)
|
||||
const [searchQuery, setSearchQuery] = createSignal("")
|
||||
const [selectedIndex, setSelectedIndex] = createSignal(0)
|
||||
|
||||
let searchInputRef: HTMLInputElement | undefined
|
||||
|
||||
onMount(() => {
|
||||
const unsubscribe = subscribeToCache((items) => setEntries(items))
|
||||
onCleanup(unsubscribe)
|
||||
})
|
||||
const directoryCache = new Map<string, FileSystemEntry[]>()
|
||||
const metadataCache = new Map<string, FileSystemListingMetadata>()
|
||||
const inFlightLoads = new Map<string, Promise<FileSystemListingMetadata>>()
|
||||
|
||||
createEffect(() => {
|
||||
const query = searchQuery().trim()
|
||||
if (!query) {
|
||||
return
|
||||
function resetDialogState() {
|
||||
directoryCache.clear()
|
||||
metadataCache.clear()
|
||||
inFlightLoads.clear()
|
||||
setEntries([])
|
||||
setCurrentMetadata(null)
|
||||
setLoadingPath(null)
|
||||
}
|
||||
|
||||
async function fetchDirectory(path: string, makeCurrent = false): Promise<FileSystemListingMetadata> {
|
||||
const normalized = normalizeEntryPath(path)
|
||||
|
||||
if (directoryCache.has(normalized) && metadataCache.has(normalized)) {
|
||||
if (makeCurrent) {
|
||||
setCurrentMetadata(metadataCache.get(normalized) ?? null)
|
||||
setEntries(directoryCache.get(normalized) ?? [])
|
||||
}
|
||||
return metadataCache.get(normalized) as FileSystemListingMetadata
|
||||
}
|
||||
prioritizeDirectoriesForQuery(query)
|
||||
})
|
||||
|
||||
if (inFlightLoads.has(normalized)) {
|
||||
const metadata = await inFlightLoads.get(normalized)!
|
||||
if (makeCurrent) {
|
||||
setCurrentMetadata(metadata)
|
||||
setEntries(directoryCache.get(normalized) ?? [])
|
||||
}
|
||||
return metadata
|
||||
}
|
||||
|
||||
const loadPromise = (async () => {
|
||||
setLoadingPath(normalized)
|
||||
const response = await serverApi.listFileSystem(normalized === "." ? "." : normalized, {
|
||||
includeFiles: props.mode === "files",
|
||||
})
|
||||
directoryCache.set(normalized, response.entries)
|
||||
metadataCache.set(normalized, response.metadata)
|
||||
if (!rootPath()) {
|
||||
setRootPath(response.metadata.rootPath)
|
||||
}
|
||||
if (loadingPath() === normalized) {
|
||||
setLoadingPath(null)
|
||||
}
|
||||
return response.metadata
|
||||
})().catch((err) => {
|
||||
if (loadingPath() === normalized) {
|
||||
setLoadingPath(null)
|
||||
}
|
||||
throw err
|
||||
})
|
||||
|
||||
inFlightLoads.set(normalized, loadPromise)
|
||||
try {
|
||||
const metadata = await loadPromise
|
||||
if (makeCurrent) {
|
||||
const key = normalizeEntryPath(metadata.currentPath)
|
||||
setCurrentMetadata(metadata)
|
||||
setEntries(directoryCache.get(key) ?? directoryCache.get(normalized) ?? [])
|
||||
}
|
||||
return metadata
|
||||
} finally {
|
||||
inFlightLoads.delete(normalized)
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshEntries() {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
resetDialogState()
|
||||
try {
|
||||
const meta = await getServerMeta()
|
||||
setRootPath(meta.workspaceRoot)
|
||||
await ensureWorkspaceFilesystemLoaded(meta.workspaceRoot)
|
||||
const metadata = await fetchDirectory(".", true)
|
||||
setRootPath(metadata.rootPath)
|
||||
setEntries(directoryCache.get(normalizeEntryPath(metadata.currentPath)) ?? [])
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Unable to load filesystem"
|
||||
setError(message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
function describeLoadingPath() {
|
||||
const path = loadingPath()
|
||||
if (!path) {
|
||||
return "filesystem"
|
||||
}
|
||||
if (path === ".") {
|
||||
return rootPath() || "workspace root"
|
||||
}
|
||||
return resolveAbsolutePath(rootPath(), path)
|
||||
}
|
||||
|
||||
function currentAbsolutePath(): string {
|
||||
const metadata = currentMetadata()
|
||||
if (!metadata) {
|
||||
return rootPath()
|
||||
}
|
||||
if (metadata.pathKind === "relative") {
|
||||
return resolveAbsolutePath(rootPath(), metadata.currentPath)
|
||||
}
|
||||
return metadata.displayPath
|
||||
}
|
||||
|
||||
function handleOverlayClick(event: MouseEvent) {
|
||||
if (event.target === event.currentTarget) {
|
||||
props.onClose()
|
||||
}
|
||||
}
|
||||
|
||||
function handleEntrySelect(entry: FileSystemEntry) {
|
||||
const absolute = resolveAbsolutePath(rootPath(), entry.path)
|
||||
props.onSelect(absolute)
|
||||
}
|
||||
|
||||
function handleNavigateTo(path: string) {
|
||||
void fetchDirectory(path, true).catch((err) => {
|
||||
console.error("Failed to open directory", err)
|
||||
setError(err instanceof Error ? err.message : "Unable to open directory")
|
||||
})
|
||||
}
|
||||
|
||||
function handleNavigateUp() {
|
||||
const parent = currentMetadata()?.parentPath
|
||||
if (!parent) {
|
||||
return
|
||||
}
|
||||
handleNavigateTo(parent)
|
||||
}
|
||||
|
||||
const filteredEntries = createMemo(() => {
|
||||
const query = searchQuery().trim().toLowerCase()
|
||||
const mode = props.mode
|
||||
const root = rootPath()
|
||||
const matchesType = entries().filter((entry) => (mode === "directories" ? entry.type === "directory" : entry.type === "file"))
|
||||
|
||||
const baseEntries = mode === "directories" && root
|
||||
? [
|
||||
{
|
||||
name: formatRootLabel(root),
|
||||
path: ".",
|
||||
type: "directory" as const,
|
||||
},
|
||||
...matchesType,
|
||||
]
|
||||
: matchesType
|
||||
|
||||
const subset = entries().filter((entry) => (props.mode === "directories" ? entry.type === "directory" : true))
|
||||
if (!query) {
|
||||
return baseEntries
|
||||
return subset
|
||||
}
|
||||
|
||||
return baseEntries.filter((entry) => {
|
||||
const absolute = resolveAbsolutePath(root, entry.path)
|
||||
return subset.filter((entry) => {
|
||||
const absolute = resolveAbsolutePath(rootPath(), entry.path)
|
||||
return absolute.toLowerCase().includes(query) || entry.name.toLowerCase().includes(query)
|
||||
})
|
||||
})
|
||||
|
||||
const visibleEntries = createMemo(() => filteredEntries().slice(0, MAX_RESULTS))
|
||||
|
||||
const folderRows = createMemo<FolderRow[]>(() => {
|
||||
const rows: FolderRow[] = []
|
||||
const metadata = currentMetadata()
|
||||
if (metadata?.parentPath) {
|
||||
rows.push({ type: "up", path: metadata.parentPath })
|
||||
}
|
||||
for (const entry of visibleEntries()) {
|
||||
rows.push({ type: "entry", entry })
|
||||
}
|
||||
return rows
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const list = visibleEntries()
|
||||
if (list.length === 0) {
|
||||
@@ -338,20 +260,12 @@ const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props)
|
||||
window.addEventListener("keydown", handleKeyDown)
|
||||
onCleanup(() => {
|
||||
window.removeEventListener("keydown", handleKeyDown)
|
||||
resetDialogState()
|
||||
setRootPath("")
|
||||
setError(null)
|
||||
})
|
||||
})
|
||||
|
||||
function handleEntrySelect(entry: FileSystemEntry) {
|
||||
const absolute = resolveAbsolutePath(rootPath(), entry.path)
|
||||
props.onSelect(absolute)
|
||||
}
|
||||
|
||||
function handleOverlayClick(event: MouseEvent) {
|
||||
if (event.target === event.currentTarget) {
|
||||
props.onClose()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Show when={props.open}>
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-6" onClick={handleOverlayClick}>
|
||||
@@ -360,9 +274,7 @@ const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props)
|
||||
<div class="panel-header flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h3 class="panel-title">{props.title}</h3>
|
||||
<p class="panel-subtitle">
|
||||
{props.description || "Search for a path under the configured workspace root."}
|
||||
</p>
|
||||
<p class="panel-subtitle">{props.description || "Search for a path under the configured workspace root."}</p>
|
||||
<Show when={rootPath()}>
|
||||
<p class="text-xs text-muted mt-1 font-mono break-all">Root: {rootPath()}</p>
|
||||
</Show>
|
||||
@@ -392,56 +304,117 @@ const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={props.mode === "directories"}>
|
||||
<div class="px-4 pb-2">
|
||||
<div class="flex items-center justify-between gap-3 rounded-md border border-border-subtle px-4 py-3">
|
||||
<div>
|
||||
<p class="text-xs text-secondary uppercase tracking-wide">Current folder</p>
|
||||
<p class="text-sm font-mono text-primary break-all">{currentAbsolutePath()}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="selector-button selector-button-secondary whitespace-nowrap"
|
||||
onClick={() => props.onSelect(currentAbsolutePath())}
|
||||
>
|
||||
Select Current
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="panel-list panel-list--fill max-h-96 overflow-auto">
|
||||
<Show
|
||||
when={!loading() && !error()}
|
||||
when={entries().length > 0}
|
||||
fallback={
|
||||
<div class="flex items-center justify-center py-6 text-sm text-secondary">
|
||||
<Show
|
||||
when={loading()}
|
||||
when={loadingPath() !== null}
|
||||
fallback={<span class="text-red-500">{error()}</span>}
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<Loader2 class="w-4 h-4 animate-spin" />
|
||||
<span>Loading filesystem…</span>
|
||||
<span>Loading {describeLoadingPath()}…</span>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Show when={loadingPath()}>
|
||||
<div class="flex items-center gap-2 px-4 py-2 text-xs text-secondary">
|
||||
<Loader2 class="w-3.5 h-3.5 animate-spin" />
|
||||
<span>Loading {describeLoadingPath()}…</span>
|
||||
</div>
|
||||
</Show>
|
||||
<Show
|
||||
when={visibleEntries().length > 0}
|
||||
when={folderRows().length > 0}
|
||||
fallback={
|
||||
<div class="flex flex-col items-center justify-center gap-2 py-10 text-sm text-secondary">
|
||||
<p>No matches.</p>
|
||||
<Show when={searchQuery().trim().length === 0}>
|
||||
<button type="button" class="selector-button selector-button-secondary" onClick={refreshEntries}>
|
||||
Retry
|
||||
</button>
|
||||
</Show>
|
||||
<p>No entries found.</p>
|
||||
<button type="button" class="selector-button selector-button-secondary" onClick={refreshEntries}>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<For each={visibleEntries()}>
|
||||
{(entry, index) => (
|
||||
<button
|
||||
type="button"
|
||||
class="panel-list-item flex items-center gap-3 text-left"
|
||||
classList={{ "panel-list-item-highlight": selectedIndex() === index() }}
|
||||
onMouseEnter={() => setSelectedIndex(index())}
|
||||
onClick={() => handleEntrySelect(entry)}
|
||||
>
|
||||
<div class="flex h-8 w-8 items-center justify-center rounded-md bg-surface-secondary text-muted">
|
||||
<Show when={entry.type === "directory"} fallback={<FileIcon class="w-4 h-4" />}>
|
||||
<FolderIcon class="w-4 h-4" />
|
||||
</Show>
|
||||
<For each={folderRows()}>
|
||||
{(row) => {
|
||||
if (row.type === "up") {
|
||||
return (
|
||||
<div class="panel-list-item" role="button">
|
||||
<div class="panel-list-item-content directory-browser-row">
|
||||
<button type="button" class="directory-browser-row-main" onClick={handleNavigateUp}>
|
||||
<div class="directory-browser-row-icon">
|
||||
<ArrowUpLeft class="w-4 h-4" />
|
||||
</div>
|
||||
<div class="directory-browser-row-text">
|
||||
<span class="directory-browser-row-name">Up one level</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const entry = row.entry
|
||||
const selectEntry = () => handleEntrySelect(entry)
|
||||
const activateEntry = () => {
|
||||
if (entry.type === "directory") {
|
||||
handleNavigateTo(entry.path)
|
||||
} else {
|
||||
selectEntry()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="panel-list-item" role="listitem">
|
||||
<div class="panel-list-item-content directory-browser-row">
|
||||
<button type="button" class="directory-browser-row-main" onClick={activateEntry}>
|
||||
<div class="directory-browser-row-icon">
|
||||
<Show when={entry.type === "directory"} fallback={<FileIcon class="w-4 h-4" />}>
|
||||
<FolderIcon class="w-4 h-4" />
|
||||
</Show>
|
||||
</div>
|
||||
<div class="directory-browser-row-text">
|
||||
<span class="directory-browser-row-name">{entry.name || entry.path}</span>
|
||||
<span class="directory-browser-row-sub">
|
||||
{resolveAbsolutePath(rootPath(), entry.path)}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="selector-button selector-button-secondary directory-browser-select"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
selectEntry()
|
||||
}}
|
||||
>
|
||||
Select
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<span class="text-sm font-medium text-primary">{entry.name || entry.path}</span>
|
||||
<span class="text-xs font-mono text-muted">{resolveAbsolutePath(rootPath(), entry.path)}</span>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</Show>
|
||||
</Show>
|
||||
@@ -472,3 +445,4 @@ const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props)
|
||||
}
|
||||
|
||||
export default FileSystemBrowserDialog
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ interface FolderSelectionViewProps {
|
||||
}
|
||||
|
||||
const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
const { recentFolders, removeRecentFolder, preferences, updateLastUsedBinary } = useConfig()
|
||||
const { recentFolders, removeRecentFolder, preferences } = useConfig()
|
||||
const [selectedIndex, setSelectedIndex] = createSignal(0)
|
||||
const [focusMode, setFocusMode] = createSignal<"recent" | "new" | null>("recent")
|
||||
const [selectedBinary, setSelectedBinary] = createSignal(preferences().lastUsedBinary || "opencode")
|
||||
@@ -169,7 +169,6 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
|
||||
function handleFolderSelect(path: string) {
|
||||
if (isLoading()) return
|
||||
updateLastUsedBinary(selectedBinary())
|
||||
props.onSelectFolder(path, selectedBinary())
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,79 @@ export default function MessageItem(props: MessageItemProps) {
|
||||
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
|
||||
}
|
||||
|
||||
const messageParts = () => props.parts ?? props.message.parts
|
||||
type FilePart = Extract<ClientPart, { type: "file" }> & {
|
||||
url?: string
|
||||
mime?: string
|
||||
filename?: string
|
||||
}
|
||||
|
||||
const displayParts = () => props.parts ?? props.message.parts
|
||||
|
||||
const fileAttachments = () =>
|
||||
props.message.parts.filter((part): part is FilePart => part?.type === "file" && typeof (part as FilePart).url === "string")
|
||||
|
||||
const getAttachmentName = (part: FilePart) => {
|
||||
if (part.filename && part.filename.trim().length > 0) {
|
||||
return part.filename
|
||||
}
|
||||
const url = part.url || ""
|
||||
if (url.startsWith("data:")) {
|
||||
return "attachment"
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(url)
|
||||
const segments = parsed.pathname.split("/")
|
||||
return segments.pop() || "attachment"
|
||||
} catch (error) {
|
||||
const fallback = url.split("/").pop()
|
||||
return fallback && fallback.length > 0 ? fallback : "attachment"
|
||||
}
|
||||
}
|
||||
|
||||
const isImageAttachment = (part: FilePart) => {
|
||||
if (part.mime && typeof part.mime === "string" && part.mime.startsWith("image/")) {
|
||||
return true
|
||||
}
|
||||
return typeof part.url === "string" && part.url.startsWith("data:image/")
|
||||
}
|
||||
|
||||
const handleAttachmentDownload = async (part: FilePart) => {
|
||||
const url = part.url
|
||||
if (!url) return
|
||||
|
||||
const filename = getAttachmentName(part)
|
||||
const directDownload = (href: string) => {
|
||||
const anchor = document.createElement("a")
|
||||
anchor.href = href
|
||||
anchor.download = filename
|
||||
anchor.target = "_blank"
|
||||
anchor.rel = "noopener"
|
||||
document.body.appendChild(anchor)
|
||||
anchor.click()
|
||||
document.body.removeChild(anchor)
|
||||
}
|
||||
|
||||
if (url.startsWith("data:")) {
|
||||
directDownload(url)
|
||||
return
|
||||
}
|
||||
|
||||
if (url.startsWith("file://")) {
|
||||
window.open(url, "_blank", "noopener")
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) throw new Error(`Failed to fetch attachment: ${response.status}`)
|
||||
const blob = await response.blob()
|
||||
const objectUrl = URL.createObjectURL(blob)
|
||||
directDownload(objectUrl)
|
||||
URL.revokeObjectURL(objectUrl)
|
||||
} catch (error) {
|
||||
directDownload(url)
|
||||
}
|
||||
}
|
||||
|
||||
const errorMessage = () => {
|
||||
const info = props.messageInfo
|
||||
@@ -48,7 +120,7 @@ export default function MessageItem(props: MessageItemProps) {
|
||||
return true
|
||||
}
|
||||
|
||||
return messageParts().some((part) => partHasRenderableText(part))
|
||||
return displayParts().some((part) => partHasRenderableText(part))
|
||||
}
|
||||
|
||||
const isGenerating = () => {
|
||||
@@ -141,17 +213,64 @@ export default function MessageItem(props: MessageItemProps) {
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<For each={messageParts()}>{(part) => (
|
||||
<MessagePart
|
||||
part={part}
|
||||
messageType={props.message.type}
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
/>
|
||||
)}</For>
|
||||
<For each={displayParts()}>
|
||||
{(part) => (
|
||||
<MessagePart
|
||||
part={part}
|
||||
messageType={props.message.type}
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
<Show when={fileAttachments().length > 0}>
|
||||
<div class="message-attachments">
|
||||
<For each={fileAttachments()}>
|
||||
{(attachment) => {
|
||||
const name = getAttachmentName(attachment)
|
||||
const isImage = isImageAttachment(attachment)
|
||||
return (
|
||||
<div class={`attachment-chip ${isImage ? "attachment-chip-image" : ""}`} title={name}>
|
||||
<Show when={isImage} fallback={
|
||||
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"
|
||||
/>
|
||||
</svg>
|
||||
}>
|
||||
<img src={attachment.url} alt={name} class="h-5 w-5 rounded object-cover" />
|
||||
</Show>
|
||||
<span class="truncate max-w-[180px]">{name}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleAttachmentDownload(attachment)}
|
||||
class="attachment-download"
|
||||
aria-label={`Download ${name}`}
|
||||
>
|
||||
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v2a2 2 0 002 2h12a2 2 0 002-2v-2" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12l4 4 4-4m-4-8v12" />
|
||||
</svg>
|
||||
</button>
|
||||
<Show when={isImage}>
|
||||
<div class="attachment-chip-preview">
|
||||
<img src={attachment.url} alt={name} />
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={props.message.status === "sending"}>
|
||||
|
||||
<div class="message-sending">
|
||||
<span class="generating-spinner">●</span> Sending...
|
||||
</div>
|
||||
|
||||
@@ -628,9 +628,11 @@ export default function MessageStream(props: MessageStreamProps) {
|
||||
|
||||
const toolPart = item.toolPart
|
||||
|
||||
const toolState = toolPart.state
|
||||
const hasToolState = isToolStateRunning(toolState) || isToolStateCompleted(toolState) || isToolStateError(toolState)
|
||||
const taskSessionId =
|
||||
(isToolStateRunning(toolPart.state) || isToolStateCompleted(toolPart.state) || isToolStateError(toolPart.state))
|
||||
? toolPart.state.metadata?.sessionId === "string" ? toolPart.state.metadata.sessionId : ""
|
||||
hasToolState && typeof toolState?.metadata?.sessionId === "string"
|
||||
? toolState.metadata.sessionId
|
||||
: ""
|
||||
const taskLocation = taskSessionId ? findTaskSessionLocation(taskSessionId) : null
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Component, For, Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
|
||||
import { FolderOpen, Trash2, Check, AlertCircle, Loader2, Plus } from "lucide-solid"
|
||||
import { useConfig } from "../stores/preferences"
|
||||
import { cliApi } from "../lib/api-client"
|
||||
import { serverApi } from "../lib/api-client"
|
||||
import FileSystemBrowserDialog from "./filesystem-browser-dialog"
|
||||
|
||||
interface BinaryOption {
|
||||
@@ -105,7 +105,7 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
|
||||
setValidating(true)
|
||||
setValidationError(null)
|
||||
|
||||
const result = await cliApi.validateBinary(path)
|
||||
const result = await serverApi.validateBinary(path)
|
||||
|
||||
if (result.valid && result.version) {
|
||||
const updatedVersionInfo = new Map(versionInfo())
|
||||
|
||||
@@ -573,7 +573,14 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
setAtPosition(null)
|
||||
}
|
||||
|
||||
function handlePickerSelect(item: { type: "agent"; agent: Agent } | { type: "file"; file: { path: string; isGitFile: boolean } }) {
|
||||
function handlePickerSelect(
|
||||
item:
|
||||
| { type: "agent"; agent: Agent }
|
||||
| {
|
||||
type: "file"
|
||||
file: { path: string; relativePath?: string; isGitFile: boolean; isDirectory?: boolean }
|
||||
},
|
||||
) {
|
||||
if (item.type === "agent") {
|
||||
const agentName = item.agent.name
|
||||
const existingAttachments = attachments()
|
||||
@@ -605,25 +612,26 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
}, 0)
|
||||
}
|
||||
} else if (item.type === "file") {
|
||||
const path = item.file.path
|
||||
const isFolder = path.endsWith("/")
|
||||
const filename = path.split("/").pop() || path
|
||||
const displayPath = item.file.path
|
||||
const relativePath = item.file.relativePath ?? displayPath
|
||||
const isFolder = item.file.isDirectory ?? displayPath.endsWith("/")
|
||||
|
||||
if (isFolder) {
|
||||
const currentPrompt = prompt()
|
||||
const pos = atPosition()
|
||||
const cursorPos = textareaRef?.selectionStart || 0
|
||||
const folderMention = relativePath === "." || relativePath === "" ? "/" : displayPath
|
||||
|
||||
if (pos !== null) {
|
||||
const before = currentPrompt.substring(0, pos + 1)
|
||||
const after = currentPrompt.substring(cursorPos)
|
||||
const newPrompt = before + path + after
|
||||
const newPrompt = before + folderMention + after
|
||||
setPrompt(newPrompt)
|
||||
setSearchQuery(path)
|
||||
setSearchQuery(folderMention)
|
||||
|
||||
setTimeout(() => {
|
||||
if (textareaRef) {
|
||||
const newCursorPos = pos + 1 + path.length
|
||||
const newCursorPos = pos + 1 + folderMention.length
|
||||
textareaRef.setSelectionRange(newCursorPos, newCursorPos)
|
||||
}
|
||||
}, 0)
|
||||
@@ -632,11 +640,20 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
return
|
||||
}
|
||||
|
||||
const normalizedPath = relativePath.replace(/\/+$/, "") || relativePath
|
||||
const pathSegments = normalizedPath.split("/")
|
||||
const filename = (() => {
|
||||
const candidate = pathSegments[pathSegments.length - 1] || normalizedPath
|
||||
return candidate === "." ? "/" : candidate
|
||||
})()
|
||||
|
||||
const existingAttachments = attachments()
|
||||
const alreadyAttached = existingAttachments.some((att) => att.source.type === "file" && att.source.path === path)
|
||||
const alreadyAttached = existingAttachments.some(
|
||||
(att) => att.source.type === "file" && att.source.path === normalizedPath,
|
||||
)
|
||||
|
||||
if (!alreadyAttached) {
|
||||
const attachment = createFileAttachment(path, filename, "text/plain", undefined, props.instanceFolder)
|
||||
const attachment = createFileAttachment(normalizedPath, filename, "text/plain", undefined, props.instanceFolder)
|
||||
addAttachment(props.instanceId, props.sessionId, attachment)
|
||||
}
|
||||
|
||||
@@ -703,8 +720,31 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
const filename = file.name
|
||||
const mime = file.type || "text/plain"
|
||||
|
||||
const attachment = createFileAttachment(path, filename, mime, undefined, props.instanceFolder)
|
||||
addAttachment(props.instanceId, props.sessionId, attachment)
|
||||
const createAndStoreAttachment = (previewUrl?: string) => {
|
||||
const attachment = createFileAttachment(path, filename, mime, undefined, props.instanceFolder)
|
||||
if (previewUrl && (mime.startsWith("image/") || mime.startsWith("text/"))) {
|
||||
attachment.url = previewUrl
|
||||
}
|
||||
addAttachment(props.instanceId, props.sessionId, attachment)
|
||||
}
|
||||
|
||||
if (mime.startsWith("image/") && typeof FileReader !== "undefined") {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
const result = typeof reader.result === "string" ? reader.result : undefined
|
||||
createAndStoreAttachment(result)
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
} else if (mime.startsWith("text/") && typeof FileReader !== "undefined") {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
const dataUrl = typeof reader.result === "string" ? reader.result : undefined
|
||||
createAndStoreAttachment(dataUrl)
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
} else {
|
||||
createAndStoreAttachment()
|
||||
}
|
||||
}
|
||||
|
||||
textareaRef?.focus()
|
||||
@@ -755,7 +795,7 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
{(attachment) => {
|
||||
const isImage = attachment.mediaType.startsWith("image/")
|
||||
return (
|
||||
<div class="attachment-chip">
|
||||
<div class={`attachment-chip ${isImage ? "attachment-chip-image" : ""}`}>
|
||||
<Show
|
||||
when={isImage}
|
||||
fallback={
|
||||
@@ -814,6 +854,11 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<Show when={isImage}>
|
||||
<div class="attachment-chip-preview">
|
||||
<img src={attachment.url} alt={attachment.filename} />
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
|
||||
@@ -346,11 +346,11 @@ export default function ToolCall(props: ToolCallProps) {
|
||||
const { preferences, setDiffViewMode } = useConfig()
|
||||
const { isDark } = useTheme()
|
||||
const toolCallId = () => props.toolCallId || props.toolCall?.id || ""
|
||||
const expanded = () => isToolCallExpanded(toolCallId())
|
||||
const pendingPermission = createMemo(() => props.toolCall.pendingPermission)
|
||||
const expanded = () => (pendingPermission() ? true : isToolCallExpanded(toolCallId()))
|
||||
const toolOutputDefaultExpanded = createMemo(() => (preferences().toolOutputExpansion || "expanded") === "expanded")
|
||||
const diagnosticsDefaultExpanded = createMemo(() => (preferences().diagnosticsExpansion || "expanded") === "expanded")
|
||||
const [appliedPreference, setAppliedPreference] = createSignal<boolean | null>(null)
|
||||
const pendingPermission = createMemo(() => props.toolCall.pendingPermission)
|
||||
const permissionDetails = createMemo(() => pendingPermission()?.permission)
|
||||
const isPermissionActive = createMemo(() => pendingPermission()?.active === true)
|
||||
const activePermissionKey = createMemo(() => {
|
||||
@@ -416,13 +416,6 @@ export default function ToolCall(props: ToolCallProps) {
|
||||
setAppliedPreference((prev) => (prev === null ? prev : null))
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!pendingPermission()) return
|
||||
const id = toolCallId()
|
||||
if (!id) return
|
||||
setToolCallExpanded(id, true)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const permission = permissionDetails()
|
||||
if (!permission) {
|
||||
@@ -564,24 +557,73 @@ export default function ToolCall(props: ToolCallProps) {
|
||||
}
|
||||
}
|
||||
|
||||
const getTodoTitle = () => {
|
||||
const state = props.toolCall?.state || {}
|
||||
if (state.status !== "completed") return "Plan"
|
||||
type TodoViewStatus = "pending" | "in_progress" | "completed" | "cancelled"
|
||||
|
||||
const metadata = state.metadata || {}
|
||||
const todos = metadata.todos || []
|
||||
interface TodoViewItem {
|
||||
id: string
|
||||
content: string
|
||||
status: TodoViewStatus
|
||||
}
|
||||
|
||||
if (!Array.isArray(todos) || todos.length === 0) return "Plan"
|
||||
function normalizeTodoStatus(rawStatus: unknown): TodoViewStatus {
|
||||
if (rawStatus === "completed" || rawStatus === "in_progress" || rawStatus === "cancelled") return rawStatus
|
||||
return "pending"
|
||||
}
|
||||
|
||||
const counts = { pending: 0, completed: 0 }
|
||||
for (const todo of todos) {
|
||||
const status = todo.status || "pending"
|
||||
if (status in counts) counts[status as keyof typeof counts]++
|
||||
function extractTodosFromState(state: ToolState | undefined): TodoViewItem[] {
|
||||
if (!state) return []
|
||||
const metadata = (isToolStateRunning(state) || isToolStateCompleted(state) || isToolStateError(state))
|
||||
? state.metadata || {}
|
||||
: {}
|
||||
const todos = Array.isArray((metadata as any).todos) ? (metadata as any).todos : []
|
||||
const items: TodoViewItem[] = []
|
||||
|
||||
for (let index = 0; index < todos.length; index++) {
|
||||
const todo = todos[index]
|
||||
const content = typeof todo?.content === "string" ? todo.content.trim() : ""
|
||||
if (!content) continue
|
||||
const status = normalizeTodoStatus((todo as any).status)
|
||||
const id = typeof todo?.id === "string" && todo.id.length > 0 ? todo.id : `${index}-${content}`
|
||||
items.push({ id, content, status })
|
||||
}
|
||||
|
||||
const total = todos.length
|
||||
if (counts.pending === total) return "Creating plan"
|
||||
if (counts.completed === total) return "Completing plan"
|
||||
return items
|
||||
}
|
||||
|
||||
function summarizeTodos(todos: TodoViewItem[]) {
|
||||
return todos.reduce(
|
||||
(acc, todo) => {
|
||||
acc.total += 1
|
||||
acc[todo.status] = (acc[todo.status] || 0) + 1
|
||||
return acc
|
||||
},
|
||||
{ total: 0, pending: 0, in_progress: 0, completed: 0, cancelled: 0 } as Record<TodoViewStatus | "total", number>,
|
||||
)
|
||||
}
|
||||
|
||||
function getTodoStatusLabel(status: TodoViewStatus): string {
|
||||
switch (status) {
|
||||
case "completed":
|
||||
return "Completed"
|
||||
case "in_progress":
|
||||
return "In progress"
|
||||
case "cancelled":
|
||||
return "Cancelled"
|
||||
default:
|
||||
return "Pending"
|
||||
}
|
||||
}
|
||||
|
||||
const getTodoTitle = () => {
|
||||
const state = props.toolCall?.state
|
||||
if (!state) return "Plan"
|
||||
|
||||
const todos = extractTodosFromState(state)
|
||||
if (state.status !== "completed" || todos.length === 0) return "Plan"
|
||||
|
||||
const counts = summarizeTodos(todos)
|
||||
if (counts.pending === counts.total) return "Creating plan"
|
||||
if (counts.completed === counts.total) return "Completing plan"
|
||||
return "Updating plan"
|
||||
}
|
||||
|
||||
@@ -646,7 +688,7 @@ export default function ToolCall(props: ToolCallProps) {
|
||||
return getTodoTitle()
|
||||
|
||||
case "todoread":
|
||||
return "Plan"
|
||||
return getTodoTitle()
|
||||
|
||||
case "invalid":
|
||||
if (typeof input.tool === "string") {
|
||||
@@ -663,18 +705,14 @@ export default function ToolCall(props: ToolCallProps) {
|
||||
const toolName = props.toolCall?.tool || ""
|
||||
const state = props.toolCall?.state || {}
|
||||
|
||||
if (toolName === "todoread") {
|
||||
return null
|
||||
if (toolName === "todoread" || toolName === "todowrite") {
|
||||
return renderTodoTool()
|
||||
}
|
||||
|
||||
if (state.status === "pending") {
|
||||
return null
|
||||
}
|
||||
|
||||
if (toolName === "todowrite") {
|
||||
return renderTodowriteTool()
|
||||
}
|
||||
|
||||
if (toolName === "task") {
|
||||
return renderTaskTool()
|
||||
}
|
||||
@@ -945,65 +983,46 @@ export default function ToolCall(props: ToolCallProps) {
|
||||
return null
|
||||
}
|
||||
|
||||
const renderTodowriteTool = () => {
|
||||
const renderTodoTool = () => {
|
||||
const state = props.toolCall?.state
|
||||
if (!state) return null
|
||||
|
||||
const metadata = (isToolStateRunning(state) || isToolStateCompleted(state) || isToolStateError(state))
|
||||
? state.metadata || {}
|
||||
: {}
|
||||
const todos = metadata.todos || []
|
||||
|
||||
if (!Array.isArray(todos) || todos.length === 0) {
|
||||
return null
|
||||
const todos = extractTodosFromState(state)
|
||||
const counts = summarizeTodos(todos)
|
||||
|
||||
if (counts.total === 0) {
|
||||
return <div class="tool-call-todo-empty">No plan items yet.</div>
|
||||
}
|
||||
|
||||
const getStatusLabel = (status: string): string => {
|
||||
switch (status) {
|
||||
case "completed":
|
||||
return "Completed"
|
||||
case "in_progress":
|
||||
return "In progress"
|
||||
case "cancelled":
|
||||
return "Cancelled"
|
||||
default:
|
||||
return "Pending"
|
||||
}
|
||||
}
|
||||
|
||||
const shouldShowTag = (status: string) => status === "cancelled"
|
||||
|
||||
return (
|
||||
<div class="tool-call-todos" role="list">
|
||||
<For each={todos}>
|
||||
{(todo) => {
|
||||
const content = typeof todo.content === "string" ? todo.content.trim() : ""
|
||||
if (!content) return null
|
||||
<div class="tool-call-todo-region">
|
||||
<div class="tool-call-todos" role="list">
|
||||
<For each={todos}>
|
||||
{(todo) => {
|
||||
const label = getTodoStatusLabel(todo.status)
|
||||
|
||||
const status = typeof todo.status === "string" ? todo.status : "pending"
|
||||
const label = getStatusLabel(status)
|
||||
|
||||
return (
|
||||
<div
|
||||
class="tool-call-todo-item"
|
||||
classList={{
|
||||
"tool-call-todo-item-completed": status === "completed",
|
||||
"tool-call-todo-item-cancelled": status === "cancelled",
|
||||
"tool-call-todo-item-active": status === "in_progress",
|
||||
}}
|
||||
role="listitem"
|
||||
>
|
||||
<span class="tool-call-todo-checkbox" data-status={status} aria-label={label}></span>
|
||||
<div class="tool-call-todo-body">
|
||||
<span class="tool-call-todo-text">{content}</span>
|
||||
<Show when={shouldShowTag(status)}>
|
||||
<span class="tool-call-todo-tag">{label}</span>
|
||||
</Show>
|
||||
return (
|
||||
<div
|
||||
class="tool-call-todo-item"
|
||||
classList={{
|
||||
"tool-call-todo-item-completed": todo.status === "completed",
|
||||
"tool-call-todo-item-cancelled": todo.status === "cancelled",
|
||||
"tool-call-todo-item-active": todo.status === "in_progress",
|
||||
}}
|
||||
role="listitem"
|
||||
>
|
||||
<span class="tool-call-todo-checkbox" data-status={todo.status} aria-label={label}></span>
|
||||
<div class="tool-call-todo-body">
|
||||
<div class="tool-call-todo-heading">
|
||||
<span class="tool-call-todo-text">{todo.content}</span>
|
||||
<span class={`tool-call-todo-status tool-call-todo-status-${todo.status}`}>{label}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,13 +1,67 @@
|
||||
import { Component, createSignal, createEffect, For, Show, onCleanup } from "solid-js"
|
||||
import type { Agent } from "../types/session"
|
||||
import type { OpencodeClient } from "@opencode-ai/sdk/client"
|
||||
import { cliApi } from "../lib/api-client"
|
||||
import { serverApi } from "../lib/api-client"
|
||||
|
||||
const SEARCH_RESULT_LIMIT = 100
|
||||
const SEARCH_DEBOUNCE_MS = 200
|
||||
|
||||
type LoadingState = "idle" | "listing" | "search"
|
||||
|
||||
interface FileItem {
|
||||
path: string
|
||||
relativePath: string
|
||||
added?: number
|
||||
removed?: number
|
||||
isGitFile: boolean
|
||||
isDirectory: boolean
|
||||
}
|
||||
|
||||
function formatDisplayPath(basePath: string, isDirectory: boolean) {
|
||||
if (!isDirectory) {
|
||||
return basePath
|
||||
}
|
||||
const trimmed = basePath.replace(/\/+$/, "")
|
||||
return trimmed.length > 0 ? `${trimmed}/` : "./"
|
||||
}
|
||||
|
||||
function isRootPath(value: string) {
|
||||
return value === "." || value === "./" || value === "/"
|
||||
}
|
||||
|
||||
function normalizeRelativePath(basePath: string, isDirectory: boolean) {
|
||||
if (isRootPath(basePath)) {
|
||||
return "."
|
||||
}
|
||||
const withoutPrefix = basePath.replace(/^\.\/+/, "")
|
||||
if (isDirectory) {
|
||||
const trimmed = withoutPrefix.replace(/\/+$/, "")
|
||||
return trimmed || "."
|
||||
}
|
||||
return withoutPrefix
|
||||
}
|
||||
|
||||
function normalizeQuery(rawQuery: string) {
|
||||
const trimmed = rawQuery.trim()
|
||||
if (!trimmed) {
|
||||
return ""
|
||||
}
|
||||
if (trimmed === "." || trimmed === "./") {
|
||||
return ""
|
||||
}
|
||||
return trimmed.replace(/^(\.\/)+/, "").replace(/^\/+/, "")
|
||||
}
|
||||
|
||||
function mapEntriesToFileItems(entries: { path: string; type: "file" | "directory" }[]): FileItem[] {
|
||||
return entries.map((entry) => {
|
||||
const isDirectory = entry.type === "directory"
|
||||
return {
|
||||
path: formatDisplayPath(entry.path, isDirectory),
|
||||
relativePath: normalizeRelativePath(entry.path, isDirectory),
|
||||
isDirectory,
|
||||
isGitFile: false,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type PickerItem = { type: "agent"; agent: Agent } | { type: "file"; file: FileItem }
|
||||
@@ -27,62 +81,182 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
|
||||
const [files, setFiles] = createSignal<FileItem[]>([])
|
||||
const [filteredAgents, setFilteredAgents] = createSignal<Agent[]>([])
|
||||
const [selectedIndex, setSelectedIndex] = createSignal(0)
|
||||
const [loading, setLoading] = createSignal(false)
|
||||
const [loadingState, setLoadingState] = createSignal<LoadingState>("idle")
|
||||
const [allFiles, setAllFiles] = createSignal<FileItem[]>([])
|
||||
const [isInitialized, setIsInitialized] = createSignal(false)
|
||||
|
||||
const [cachedWorkspaceId, setCachedWorkspaceId] = createSignal<string | null>(null)
|
||||
|
||||
let containerRef: HTMLDivElement | undefined
|
||||
let scrollContainerRef: HTMLDivElement | undefined
|
||||
|
||||
async function fetchFiles(searchQuery: string) {
|
||||
setLoading(true)
|
||||
let lastWorkspaceId: string | null = null
|
||||
let lastQuery = ""
|
||||
let inflightWorkspaceId: string | null = null
|
||||
let inflightSnapshotPromise: Promise<FileItem[]> | null = null
|
||||
let activeRequestId = 0
|
||||
let queryDebounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function resetScrollPosition() {
|
||||
setTimeout(() => {
|
||||
if (scrollContainerRef) {
|
||||
scrollContainerRef.scrollTop = 0
|
||||
}
|
||||
}, 0)
|
||||
}
|
||||
|
||||
function applyFileResults(nextFiles: FileItem[]) {
|
||||
setFiles(nextFiles)
|
||||
setSelectedIndex(0)
|
||||
resetScrollPosition()
|
||||
}
|
||||
|
||||
async function fetchWorkspaceSnapshot(workspaceId: string): Promise<FileItem[]> {
|
||||
if (inflightWorkspaceId === workspaceId && inflightSnapshotPromise) {
|
||||
return inflightSnapshotPromise
|
||||
}
|
||||
|
||||
inflightWorkspaceId = workspaceId
|
||||
inflightSnapshotPromise = serverApi
|
||||
.listWorkspaceFiles(workspaceId)
|
||||
.then((entries) => mapEntriesToFileItems(entries))
|
||||
.then((snapshot) => {
|
||||
setAllFiles(snapshot)
|
||||
setCachedWorkspaceId(workspaceId)
|
||||
return snapshot
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(`[UnifiedPicker] Failed to load workspace files:`, error)
|
||||
setAllFiles([])
|
||||
setCachedWorkspaceId(null)
|
||||
throw error
|
||||
})
|
||||
.finally(() => {
|
||||
if (inflightWorkspaceId === workspaceId) {
|
||||
inflightWorkspaceId = null
|
||||
inflightSnapshotPromise = null
|
||||
}
|
||||
})
|
||||
|
||||
return inflightSnapshotPromise
|
||||
}
|
||||
|
||||
async function ensureWorkspaceSnapshot(workspaceId: string) {
|
||||
if (cachedWorkspaceId() === workspaceId && allFiles().length > 0) {
|
||||
return allFiles()
|
||||
}
|
||||
|
||||
return fetchWorkspaceSnapshot(workspaceId)
|
||||
}
|
||||
|
||||
async function loadFilesForQuery(rawQuery: string, workspaceId: string) {
|
||||
const normalizedQuery = normalizeQuery(rawQuery)
|
||||
const requestId = ++activeRequestId
|
||||
const hasCachedSnapshot =
|
||||
!normalizedQuery && cachedWorkspaceId() === workspaceId && allFiles().length > 0
|
||||
const mode: LoadingState = normalizedQuery ? "search" : hasCachedSnapshot ? "idle" : "listing"
|
||||
if (mode !== "idle") {
|
||||
setLoadingState(mode)
|
||||
} else {
|
||||
setLoadingState("idle")
|
||||
}
|
||||
|
||||
try {
|
||||
if (allFiles().length === 0) {
|
||||
const entries = await cliApi.listWorkspaceFiles(props.workspaceId)
|
||||
const scannedFiles: FileItem[] = entries.map<FileItem>((entry) => ({
|
||||
path: entry.path,
|
||||
isGitFile: false,
|
||||
}))
|
||||
setAllFiles(scannedFiles)
|
||||
if (!normalizedQuery) {
|
||||
const snapshot = await ensureWorkspaceSnapshot(workspaceId)
|
||||
if (!shouldApplyResults(requestId, workspaceId)) {
|
||||
return
|
||||
}
|
||||
applyFileResults(snapshot)
|
||||
return
|
||||
}
|
||||
|
||||
const filteredFiles = searchQuery.trim()
|
||||
? allFiles().filter((f) => f.path.toLowerCase().includes(searchQuery.toLowerCase()))
|
||||
: allFiles()
|
||||
|
||||
setFiles(filteredFiles)
|
||||
setSelectedIndex(0)
|
||||
|
||||
setTimeout(() => {
|
||||
if (scrollContainerRef) {
|
||||
scrollContainerRef.scrollTop = 0
|
||||
}
|
||||
}, 0)
|
||||
const results = await serverApi.searchWorkspaceFiles(workspaceId, normalizedQuery, {
|
||||
limit: SEARCH_RESULT_LIMIT,
|
||||
})
|
||||
if (!shouldApplyResults(requestId, workspaceId)) {
|
||||
return
|
||||
}
|
||||
applyFileResults(mapEntriesToFileItems(results))
|
||||
} catch (error) {
|
||||
console.error(`[UnifiedPicker] Failed to fetch files:`, error)
|
||||
setFiles([])
|
||||
if (workspaceId === props.workspaceId) {
|
||||
console.error(`[UnifiedPicker] Failed to fetch files:`, error)
|
||||
if (shouldApplyResults(requestId, workspaceId)) {
|
||||
applyFileResults([])
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
if (shouldFinalizeRequest(requestId, workspaceId)) {
|
||||
setLoadingState("idle")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let lastQuery = ""
|
||||
function clearQueryDebounce() {
|
||||
if (queryDebounceTimer) {
|
||||
clearTimeout(queryDebounceTimer)
|
||||
queryDebounceTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleLoadFilesForQuery(rawQuery: string, workspaceId: string, immediate = false) {
|
||||
clearQueryDebounce()
|
||||
const normalizedQuery = normalizeQuery(rawQuery)
|
||||
const shouldDebounce = !immediate && normalizedQuery.length > 0
|
||||
if (shouldDebounce) {
|
||||
queryDebounceTimer = setTimeout(() => {
|
||||
queryDebounceTimer = null
|
||||
void loadFilesForQuery(rawQuery, workspaceId)
|
||||
}, SEARCH_DEBOUNCE_MS)
|
||||
return
|
||||
}
|
||||
void loadFilesForQuery(rawQuery, workspaceId)
|
||||
}
|
||||
|
||||
function shouldApplyResults(requestId: number, workspaceId: string) {
|
||||
return props.open && workspaceId === props.workspaceId && requestId === activeRequestId
|
||||
}
|
||||
|
||||
|
||||
function shouldFinalizeRequest(requestId: number, workspaceId: string) {
|
||||
return workspaceId === props.workspaceId && requestId === activeRequestId
|
||||
}
|
||||
|
||||
function resetPickerState() {
|
||||
clearQueryDebounce()
|
||||
setFiles([])
|
||||
setAllFiles([])
|
||||
setCachedWorkspaceId(null)
|
||||
setIsInitialized(false)
|
||||
setSelectedIndex(0)
|
||||
setLoadingState("idle")
|
||||
lastWorkspaceId = null
|
||||
lastQuery = ""
|
||||
activeRequestId = 0
|
||||
}
|
||||
|
||||
onCleanup(() => {
|
||||
clearQueryDebounce()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (props.open && !isInitialized()) {
|
||||
setIsInitialized(true)
|
||||
fetchFiles(props.searchQuery)
|
||||
lastQuery = props.searchQuery
|
||||
if (!props.open) {
|
||||
resetPickerState()
|
||||
return
|
||||
}
|
||||
|
||||
if (props.open && props.searchQuery !== lastQuery) {
|
||||
const workspaceChanged = lastWorkspaceId !== props.workspaceId
|
||||
const queryChanged = lastQuery !== props.searchQuery
|
||||
|
||||
if (!isInitialized() || workspaceChanged || queryChanged) {
|
||||
setIsInitialized(true)
|
||||
lastWorkspaceId = props.workspaceId
|
||||
lastQuery = props.searchQuery
|
||||
fetchFiles(props.searchQuery)
|
||||
const shouldSkipDebounce = workspaceChanged || normalizeQuery(props.searchQuery).length === 0
|
||||
scheduleLoadFilesForQuery(props.searchQuery, props.workspaceId, shouldSkipDebounce)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
|
||||
createEffect(() => {
|
||||
if (!props.open) return
|
||||
|
||||
@@ -154,8 +328,19 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
|
||||
|
||||
const agentCount = () => filteredAgents().length
|
||||
const fileCount = () => files().length
|
||||
|
||||
const isLoading = () => loadingState() !== "idle"
|
||||
const loadingMessage = () => {
|
||||
if (loadingState() === "search") {
|
||||
return "Searching..."
|
||||
}
|
||||
if (loadingState() === "listing") {
|
||||
return "Loading workspace..."
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
<Show when={props.open}>
|
||||
<div
|
||||
ref={containerRef}
|
||||
@@ -164,8 +349,8 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
|
||||
<div class="dropdown-header">
|
||||
<div class="dropdown-header-title">
|
||||
Select Agent or File
|
||||
<Show when={loading()}>
|
||||
<span class="ml-2">Loading...</span>
|
||||
<Show when={isLoading()}>
|
||||
<span class="ml-2">{loadingMessage()}</span>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
@@ -236,8 +421,10 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
|
||||
</div>
|
||||
<For each={files()}>
|
||||
{(file) => {
|
||||
const itemIndex = allItems().findIndex((item) => item.type === "file" && item.file.path === file.path)
|
||||
const isFolder = file.path.endsWith("/")
|
||||
const itemIndex = allItems().findIndex(
|
||||
(item) => item.type === "file" && item.file.relativePath === file.relativePath,
|
||||
)
|
||||
const isFolder = file.isDirectory
|
||||
return (
|
||||
<div
|
||||
class={`dropdown-item py-1.5 ${
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type {
|
||||
AppConfig,
|
||||
AppConfigUpdateRequest,
|
||||
BinaryCreateRequest,
|
||||
BinaryListResponse,
|
||||
BinaryUpdateRequest,
|
||||
@@ -9,14 +8,15 @@ import type {
|
||||
FileSystemListResponse,
|
||||
InstanceData,
|
||||
ServerMeta,
|
||||
|
||||
WorkspaceCreateRequest,
|
||||
WorkspaceDescriptor,
|
||||
WorkspaceFileResponse,
|
||||
WorkspaceFileSearchResponse,
|
||||
|
||||
WorkspaceLogEntry,
|
||||
WorkspaceEventPayload,
|
||||
WorkspaceEventType,
|
||||
} from "../../../cli/src/api-types"
|
||||
} from "../../../server/src/api-types"
|
||||
|
||||
const FALLBACK_API_BASE = "http://127.0.0.1:9898"
|
||||
const RUNTIME_BASE = typeof window !== "undefined" ? window.location?.origin : undefined
|
||||
@@ -80,7 +80,7 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
}
|
||||
|
||||
|
||||
export const cliApi = {
|
||||
export const serverApi = {
|
||||
fetchWorkspaces(): Promise<WorkspaceDescriptor[]> {
|
||||
return request<WorkspaceDescriptor[]>("/api/workspaces")
|
||||
},
|
||||
@@ -100,12 +100,33 @@ export const cliApi = {
|
||||
const params = new URLSearchParams({ path: relativePath })
|
||||
return request<FileSystemEntry[]>(`/api/workspaces/${encodeURIComponent(id)}/files?${params.toString()}`)
|
||||
},
|
||||
searchWorkspaceFiles(
|
||||
id: string,
|
||||
query: string,
|
||||
opts?: { limit?: number; type?: "file" | "directory" | "all" },
|
||||
): Promise<WorkspaceFileSearchResponse> {
|
||||
const trimmed = query.trim()
|
||||
if (!trimmed) {
|
||||
return Promise.resolve([])
|
||||
}
|
||||
const params = new URLSearchParams({ q: trimmed })
|
||||
if (opts?.limit) {
|
||||
params.set("limit", String(opts.limit))
|
||||
}
|
||||
if (opts?.type) {
|
||||
params.set("type", opts.type)
|
||||
}
|
||||
return request<WorkspaceFileSearchResponse>(
|
||||
`/api/workspaces/${encodeURIComponent(id)}/files/search?${params.toString()}`,
|
||||
)
|
||||
},
|
||||
readWorkspaceFile(id: string, relativePath: string): Promise<WorkspaceFileResponse> {
|
||||
const params = new URLSearchParams({ path: relativePath })
|
||||
return request<WorkspaceFileResponse>(
|
||||
`/api/workspaces/${encodeURIComponent(id)}/files/content?${params.toString()}`,
|
||||
)
|
||||
},
|
||||
|
||||
fetchConfig(): Promise<AppConfig> {
|
||||
return request<AppConfig>("/api/config/app")
|
||||
},
|
||||
@@ -115,12 +136,6 @@ export const cliApi = {
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
},
|
||||
patchConfig(payload: AppConfigUpdateRequest): Promise<AppConfig> {
|
||||
return request<AppConfig>("/api/config/app", {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
},
|
||||
listBinaries(): Promise<BinaryListResponse> {
|
||||
return request<BinaryListResponse>("/api/config/binaries")
|
||||
},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { WorkspaceEventPayload, WorkspaceEventType } from "../../../cli/src/api-types"
|
||||
import { cliApi } from "./api-client"
|
||||
import type { WorkspaceEventPayload, WorkspaceEventType } from "../../../server/src/api-types"
|
||||
import { serverApi } from "./api-client"
|
||||
|
||||
const RETRY_BASE_DELAY = 1000
|
||||
const RETRY_MAX_DELAY = 10000
|
||||
@@ -13,7 +13,7 @@ function logSse(message: string, context?: Record<string, unknown>) {
|
||||
console.log(`${SSE_PREFIX} ${message}`)
|
||||
}
|
||||
|
||||
class CliEvents {
|
||||
class ServerEvents {
|
||||
private handlers = new Map<WorkspaceEventType | "*", Set<(event: WorkspaceEventPayload) => void>>()
|
||||
private source: EventSource | null = null
|
||||
private retryDelay = RETRY_BASE_DELAY
|
||||
@@ -27,7 +27,7 @@ class CliEvents {
|
||||
this.source.close()
|
||||
}
|
||||
logSse("Connecting to backend events stream")
|
||||
this.source = cliApi.connectEvents((event) => this.dispatch(event), () => this.scheduleReconnect())
|
||||
this.source = serverApi.connectEvents((event) => this.dispatch(event), () => this.scheduleReconnect())
|
||||
this.source.onopen = () => {
|
||||
logSse("Events stream connected")
|
||||
this.retryDelay = RETRY_BASE_DELAY
|
||||
@@ -62,4 +62,4 @@ class CliEvents {
|
||||
}
|
||||
}
|
||||
|
||||
export const cliEvents = new CliEvents()
|
||||
export const serverEvents = new ServerEvents()
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ServerMeta } from "../../../cli/src/api-types"
|
||||
import { cliApi } from "./api-client"
|
||||
import type { ServerMeta } from "../../../server/src/api-types"
|
||||
import { serverApi } from "./api-client"
|
||||
|
||||
let cachedMeta: ServerMeta | null = null
|
||||
let pendingMeta: Promise<ServerMeta> | null = null
|
||||
@@ -11,7 +11,7 @@ export async function getServerMeta(): Promise<ServerMeta> {
|
||||
if (pendingMeta) {
|
||||
return pendingMeta
|
||||
}
|
||||
pendingMeta = cliApi.fetchServerMeta().then((meta) => {
|
||||
pendingMeta = serverApi.fetchServerMeta().then((meta) => {
|
||||
cachedMeta = meta
|
||||
pendingMeta = null
|
||||
return meta
|
||||
|
||||
@@ -56,7 +56,7 @@ const [connectionStatus, setConnectionStatus] = createSignal<
|
||||
|
||||
class SSEManager {
|
||||
private connections = new Map<string, SSEConnection>()
|
||||
private static readonly MAX_RECONNECT_ATTEMPTS = 3
|
||||
private static readonly MAX_RECONNECT_DELAY_MS = 5000
|
||||
|
||||
connect(instanceId: string, proxyPath: string, reconnectAttempts = 0): void {
|
||||
const existing = this.connections.get(instanceId)
|
||||
@@ -165,13 +165,8 @@ class SSEManager {
|
||||
|
||||
connection.eventSource.close()
|
||||
|
||||
if (connection.reconnectAttempts >= SSEManager.MAX_RECONNECT_ATTEMPTS) {
|
||||
this.handleConnectionLost(instanceId, reason)
|
||||
return
|
||||
}
|
||||
|
||||
const nextAttempt = connection.reconnectAttempts + 1
|
||||
const delay = Math.min(nextAttempt * 1000, 5000)
|
||||
const delay = Math.min(nextAttempt * 1000, SSEManager.MAX_RECONNECT_DELAY_MS)
|
||||
|
||||
connection.reconnectAttempts = nextAttempt
|
||||
connection.status = "connecting"
|
||||
@@ -185,18 +180,6 @@ class SSEManager {
|
||||
}, delay)
|
||||
}
|
||||
|
||||
private handleConnectionLost(instanceId: string, reason: string): void {
|
||||
const connection = this.connections.get(instanceId)
|
||||
if (!connection) return
|
||||
|
||||
this.clearReconnectTimer(connection)
|
||||
connection.eventSource.close()
|
||||
this.connections.delete(instanceId)
|
||||
connection.status = "disconnected"
|
||||
this.updateConnectionStatus(instanceId, "disconnected")
|
||||
this.onConnectionLost?.(instanceId, reason)
|
||||
}
|
||||
|
||||
private clearReconnectTimer(connection: SSEConnection): void {
|
||||
if (connection.reconnectTimer) {
|
||||
clearTimeout(connection.reconnectTimer)
|
||||
|
||||
@@ -1,46 +1,182 @@
|
||||
import type { AppConfig, InstanceData } from "../../../cli/src/api-types"
|
||||
import { cliApi } from "./api-client"
|
||||
import { cliEvents } from "./cli-events"
|
||||
import type { AppConfig, InstanceData } from "../../../server/src/api-types"
|
||||
import { serverApi } from "./api-client"
|
||||
import { serverEvents } from "./server-events"
|
||||
|
||||
export type ConfigData = AppConfig
|
||||
|
||||
const DEFAULT_INSTANCE_DATA: InstanceData = {
|
||||
messageHistory: [],
|
||||
agentModelSelections: {},
|
||||
}
|
||||
|
||||
function isDeepEqual(a: unknown, b: unknown): boolean {
|
||||
if (a === b) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (typeof a === "object" && a !== null && typeof b === "object" && b !== null) {
|
||||
|
||||
try {
|
||||
return JSON.stringify(a) === JSON.stringify(b)
|
||||
} catch (error) {
|
||||
console.warn("Failed to compare config objects", error)
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export class ServerStorage {
|
||||
private configChangeListeners: Set<() => void> = new Set()
|
||||
private configChangeListeners: Set<(config: ConfigData) => void> = new Set()
|
||||
private configCache: ConfigData | null = null
|
||||
private loadPromise: Promise<ConfigData> | null = null
|
||||
private instanceDataCache = new Map<string, InstanceData>()
|
||||
private instanceDataListeners = new Map<string, Set<(data: InstanceData) => void>>()
|
||||
private instanceLoadPromises = new Map<string, Promise<InstanceData>>()
|
||||
|
||||
constructor() {
|
||||
cliEvents.on("config.appChanged", () => this.notifyConfigChanged())
|
||||
serverEvents.on("config.appChanged", (event) => {
|
||||
if (event.type !== "config.appChanged") return
|
||||
this.setConfigCache(event.config)
|
||||
})
|
||||
|
||||
serverEvents.on("instance.dataChanged", (event) => {
|
||||
if (event.type !== "instance.dataChanged") return
|
||||
this.setInstanceDataCache(event.instanceId, event.data)
|
||||
})
|
||||
}
|
||||
|
||||
async loadConfig(): Promise<ConfigData> {
|
||||
const config = await cliApi.fetchConfig()
|
||||
return config
|
||||
if (this.configCache) {
|
||||
return this.configCache
|
||||
}
|
||||
|
||||
if (!this.loadPromise) {
|
||||
this.loadPromise = serverApi
|
||||
.fetchConfig()
|
||||
.then((config) => {
|
||||
this.setConfigCache(config)
|
||||
return config
|
||||
})
|
||||
.finally(() => {
|
||||
this.loadPromise = null
|
||||
})
|
||||
}
|
||||
|
||||
return this.loadPromise
|
||||
}
|
||||
|
||||
async saveConfig(config: ConfigData): Promise<void> {
|
||||
await cliApi.updateConfig(config)
|
||||
async updateConfig(next: ConfigData): Promise<ConfigData> {
|
||||
const nextConfig = await serverApi.updateConfig(next)
|
||||
this.setConfigCache(nextConfig)
|
||||
return nextConfig
|
||||
}
|
||||
|
||||
async loadInstanceData(instanceId: string): Promise<InstanceData> {
|
||||
return cliApi.readInstanceData(instanceId)
|
||||
const cached = this.instanceDataCache.get(instanceId)
|
||||
if (cached) {
|
||||
return cached
|
||||
}
|
||||
|
||||
if (!this.instanceLoadPromises.has(instanceId)) {
|
||||
const promise = serverApi
|
||||
.readInstanceData(instanceId)
|
||||
.then((data) => {
|
||||
const normalized = this.normalizeInstanceData(data)
|
||||
this.setInstanceDataCache(instanceId, normalized)
|
||||
return normalized
|
||||
})
|
||||
.finally(() => {
|
||||
this.instanceLoadPromises.delete(instanceId)
|
||||
})
|
||||
|
||||
this.instanceLoadPromises.set(instanceId, promise)
|
||||
}
|
||||
|
||||
return this.instanceLoadPromises.get(instanceId)!
|
||||
}
|
||||
|
||||
async saveInstanceData(instanceId: string, data: InstanceData): Promise<void> {
|
||||
await cliApi.writeInstanceData(instanceId, data)
|
||||
const normalized = this.normalizeInstanceData(data)
|
||||
await serverApi.writeInstanceData(instanceId, normalized)
|
||||
this.setInstanceDataCache(instanceId, normalized)
|
||||
}
|
||||
|
||||
async deleteInstanceData(instanceId: string): Promise<void> {
|
||||
await cliApi.deleteInstanceData(instanceId)
|
||||
await serverApi.deleteInstanceData(instanceId)
|
||||
this.setInstanceDataCache(instanceId, DEFAULT_INSTANCE_DATA)
|
||||
}
|
||||
|
||||
onConfigChanged(listener: () => void): () => void {
|
||||
onConfigChanged(listener: (config: ConfigData) => void): () => void {
|
||||
this.configChangeListeners.add(listener)
|
||||
if (this.configCache) {
|
||||
listener(this.configCache)
|
||||
}
|
||||
return () => this.configChangeListeners.delete(listener)
|
||||
}
|
||||
|
||||
private notifyConfigChanged() {
|
||||
onInstanceDataChanged(instanceId: string, listener: (data: InstanceData) => void): () => void {
|
||||
if (!this.instanceDataListeners.has(instanceId)) {
|
||||
this.instanceDataListeners.set(instanceId, new Set())
|
||||
}
|
||||
const bucket = this.instanceDataListeners.get(instanceId)!
|
||||
bucket.add(listener)
|
||||
const cached = this.instanceDataCache.get(instanceId)
|
||||
if (cached) {
|
||||
listener(cached)
|
||||
}
|
||||
return () => {
|
||||
bucket.delete(listener)
|
||||
if (bucket.size === 0) {
|
||||
this.instanceDataListeners.delete(instanceId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private setConfigCache(config: ConfigData) {
|
||||
if (this.configCache && isDeepEqual(this.configCache, config)) {
|
||||
this.configCache = config
|
||||
return
|
||||
}
|
||||
this.configCache = config
|
||||
this.notifyConfigChanged(config)
|
||||
}
|
||||
|
||||
private notifyConfigChanged(config: ConfigData) {
|
||||
for (const listener of this.configChangeListeners) {
|
||||
listener()
|
||||
listener(config)
|
||||
}
|
||||
}
|
||||
|
||||
private normalizeInstanceData(data?: InstanceData | null): InstanceData {
|
||||
const source = data ?? DEFAULT_INSTANCE_DATA
|
||||
const messageHistory = Array.isArray(source.messageHistory) ? [...source.messageHistory] : []
|
||||
const agentModelSelections = { ...(source.agentModelSelections ?? {}) }
|
||||
return {
|
||||
...source,
|
||||
messageHistory,
|
||||
agentModelSelections,
|
||||
}
|
||||
}
|
||||
|
||||
private setInstanceDataCache(instanceId: string, data: InstanceData) {
|
||||
const normalized = this.normalizeInstanceData(data)
|
||||
const previous = this.instanceDataCache.get(instanceId)
|
||||
if (previous && isDeepEqual(previous, normalized)) {
|
||||
this.instanceDataCache.set(instanceId, normalized)
|
||||
return
|
||||
}
|
||||
this.instanceDataCache.set(instanceId, normalized)
|
||||
this.notifyInstanceDataChanged(instanceId, normalized)
|
||||
}
|
||||
|
||||
private notifyInstanceDataChanged(instanceId: string, data: InstanceData) {
|
||||
const listeners = this.instanceDataListeners.get(instanceId)
|
||||
if (!listeners) {
|
||||
return
|
||||
}
|
||||
for (const listener of listeners) {
|
||||
listener(data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createContext, createSignal, useContext, onMount, createEffect, type JSX } from "solid-js"
|
||||
import { storage, type ConfigData } from "./storage"
|
||||
import { createContext, createEffect, createSignal, onMount, useContext, type JSX } from "solid-js"
|
||||
import { useConfig } from "../stores/preferences"
|
||||
|
||||
interface ThemeContextValue {
|
||||
isDark: () => boolean
|
||||
@@ -20,64 +20,30 @@ function applyTheme(dark: boolean) {
|
||||
|
||||
export function ThemeProvider(props: { children: JSX.Element }) {
|
||||
const systemPrefersDark = window.matchMedia("(prefers-color-scheme: dark)")
|
||||
const [isDark, setIsDarkSignal] = createSignal(true) //systemPrefersDark.matches)
|
||||
let themePreference: "system" | "dark" | "light" = "dark"
|
||||
const { themePreference, setThemePreference } = useConfig()
|
||||
const [isDark, setIsDarkSignal] = createSignal(false)
|
||||
|
||||
applyTheme(true) //systemPrefersDark.matches)
|
||||
|
||||
async function loadTheme() {
|
||||
try {
|
||||
const config = await storage.loadConfig()
|
||||
const savedTheme = config.theme
|
||||
let themeDark: boolean
|
||||
|
||||
if (savedTheme === "system") {
|
||||
themePreference = "system"
|
||||
themeDark = systemPrefersDark.matches
|
||||
} else if (savedTheme === "dark") {
|
||||
themePreference = "dark"
|
||||
themeDark = true
|
||||
} else if (savedTheme === "light") {
|
||||
themePreference = "light"
|
||||
themeDark = false
|
||||
} else {
|
||||
themePreference = "dark"
|
||||
themeDark = true
|
||||
}
|
||||
|
||||
setIsDarkSignal(themeDark)
|
||||
applyTheme(themeDark)
|
||||
} catch (error) {
|
||||
console.warn("Failed to load theme from config:", error)
|
||||
themePreference = "dark"
|
||||
const themeDark = true
|
||||
setIsDarkSignal(themeDark)
|
||||
applyTheme(themeDark)
|
||||
const resolveDarkTheme = () => {
|
||||
const preference = themePreference()
|
||||
if (preference === "system") {
|
||||
return systemPrefersDark.matches
|
||||
}
|
||||
return preference === "dark"
|
||||
}
|
||||
|
||||
async function saveTheme(dark: boolean) {
|
||||
try {
|
||||
const config = await storage.loadConfig()
|
||||
const nextPreference = dark ? "dark" : "light"
|
||||
config.theme = nextPreference
|
||||
themePreference = nextPreference
|
||||
await storage.saveConfig(config)
|
||||
} catch (error) {
|
||||
console.warn("Failed to save theme to config:", error)
|
||||
}
|
||||
const applyResolvedTheme = () => {
|
||||
const dark = resolveDarkTheme()
|
||||
setIsDarkSignal(dark)
|
||||
applyTheme(dark)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
applyResolvedTheme()
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
loadTheme()
|
||||
|
||||
const unsubscribe = storage.onConfigChanged(() => {
|
||||
loadTheme()
|
||||
})
|
||||
|
||||
// Listen for system theme changes
|
||||
const handleSystemThemeChange = (event: MediaQueryListEvent) => {
|
||||
if (themePreference === "system") {
|
||||
if (themePreference() === "system") {
|
||||
setIsDarkSignal(event.matches)
|
||||
applyTheme(event.matches)
|
||||
}
|
||||
@@ -86,19 +52,12 @@ export function ThemeProvider(props: { children: JSX.Element }) {
|
||||
systemPrefersDark.addEventListener("change", handleSystemThemeChange)
|
||||
|
||||
return () => {
|
||||
unsubscribe()
|
||||
systemPrefersDark.removeEventListener("change", handleSystemThemeChange)
|
||||
}
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
applyTheme(isDark())
|
||||
})
|
||||
|
||||
const setTheme = (dark: boolean) => {
|
||||
setIsDarkSignal(dark)
|
||||
applyTheme(dark)
|
||||
saveTheme(dark)
|
||||
setThemePreference(dark ? "dark" : "light")
|
||||
}
|
||||
|
||||
const toggleTheme = () => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { render } from "solid-js/web"
|
||||
import App from "./App"
|
||||
import { ThemeProvider } from "./lib/theme"
|
||||
import { ConfigProvider } from "./stores/preferences"
|
||||
import { InstanceConfigProvider } from "./stores/instance-config"
|
||||
import "./index.css"
|
||||
import "@git-diff-view/solid/styles/diff-view-pure.css"
|
||||
|
||||
@@ -14,9 +15,11 @@ if (!root) {
|
||||
render(
|
||||
() => (
|
||||
<ConfigProvider>
|
||||
<ThemeProvider>
|
||||
<App />
|
||||
</ThemeProvider>
|
||||
<InstanceConfigProvider>
|
||||
<ThemeProvider>
|
||||
<App />
|
||||
</ThemeProvider>
|
||||
</InstanceConfigProvider>
|
||||
</ConfigProvider>
|
||||
),
|
||||
root,
|
||||
|
||||
138
packages/ui/src/stores/instance-config.tsx
Normal file
138
packages/ui/src/stores/instance-config.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import { createContext, createMemo, createSignal, onCleanup, type Accessor, type ParentComponent, useContext } from "solid-js"
|
||||
import type { InstanceData } from "../../../server/src/api-types"
|
||||
import { storage } from "../lib/storage"
|
||||
|
||||
const DEFAULT_INSTANCE_DATA: InstanceData = { messageHistory: [], agentModelSelections: {} }
|
||||
|
||||
const [instanceDataMap, setInstanceDataMap] = createSignal<Map<string, InstanceData>>(new Map())
|
||||
const loadPromises = new Map<string, Promise<void>>()
|
||||
const instanceSubscriptions = new Map<string, () => void>()
|
||||
|
||||
function cloneInstanceData(data?: InstanceData | null): InstanceData {
|
||||
const source = data ?? DEFAULT_INSTANCE_DATA
|
||||
return {
|
||||
...source,
|
||||
messageHistory: Array.isArray(source.messageHistory) ? [...source.messageHistory] : [],
|
||||
agentModelSelections: { ...(source.agentModelSelections ?? {}) },
|
||||
}
|
||||
}
|
||||
|
||||
function attachSubscription(instanceId: string) {
|
||||
if (instanceSubscriptions.has(instanceId)) return
|
||||
const unsubscribe = storage.onInstanceDataChanged(instanceId, (data) => {
|
||||
setInstanceData(instanceId, data)
|
||||
})
|
||||
instanceSubscriptions.set(instanceId, unsubscribe)
|
||||
}
|
||||
|
||||
function detachSubscription(instanceId: string) {
|
||||
const unsubscribe = instanceSubscriptions.get(instanceId)
|
||||
if (!unsubscribe) return
|
||||
unsubscribe()
|
||||
instanceSubscriptions.delete(instanceId)
|
||||
}
|
||||
|
||||
function setInstanceData(instanceId: string, data: InstanceData) {
|
||||
setInstanceDataMap((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.set(instanceId, cloneInstanceData(data))
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
async function ensureInstanceConfig(instanceId: string): Promise<void> {
|
||||
if (!instanceId) return
|
||||
if (instanceDataMap().has(instanceId)) return
|
||||
if (loadPromises.has(instanceId)) {
|
||||
await loadPromises.get(instanceId)
|
||||
return
|
||||
}
|
||||
const promise = storage
|
||||
.loadInstanceData(instanceId)
|
||||
.then((data) => {
|
||||
setInstanceData(instanceId, data)
|
||||
attachSubscription(instanceId)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn("Failed to load instance data:", error)
|
||||
setInstanceData(instanceId, DEFAULT_INSTANCE_DATA)
|
||||
attachSubscription(instanceId)
|
||||
})
|
||||
.finally(() => {
|
||||
loadPromises.delete(instanceId)
|
||||
})
|
||||
loadPromises.set(instanceId, promise)
|
||||
await promise
|
||||
}
|
||||
|
||||
async function updateInstanceConfig(instanceId: string, mutator: (draft: InstanceData) => void): Promise<void> {
|
||||
if (!instanceId) return
|
||||
await ensureInstanceConfig(instanceId)
|
||||
const current = instanceDataMap().get(instanceId) ?? DEFAULT_INSTANCE_DATA
|
||||
const draft = cloneInstanceData(current)
|
||||
mutator(draft)
|
||||
try {
|
||||
await storage.saveInstanceData(instanceId, draft)
|
||||
} catch (error) {
|
||||
console.warn("Failed to persist instance data:", error)
|
||||
}
|
||||
setInstanceData(instanceId, draft)
|
||||
}
|
||||
|
||||
function getInstanceConfig(instanceId: string): InstanceData {
|
||||
return instanceDataMap().get(instanceId) ?? DEFAULT_INSTANCE_DATA
|
||||
}
|
||||
|
||||
function useInstanceConfig(instanceId: string): Accessor<InstanceData> {
|
||||
const context = useContext(InstanceConfigContext)
|
||||
if (!context) {
|
||||
throw new Error("useInstanceConfig must be used within InstanceConfigProvider")
|
||||
}
|
||||
return createMemo(() => instanceDataMap().get(instanceId) ?? DEFAULT_INSTANCE_DATA)
|
||||
}
|
||||
|
||||
function clearInstanceConfig(instanceId: string): void {
|
||||
setInstanceDataMap((prev) => {
|
||||
if (!prev.has(instanceId)) return prev
|
||||
const next = new Map(prev)
|
||||
next.delete(instanceId)
|
||||
return next
|
||||
})
|
||||
detachSubscription(instanceId)
|
||||
}
|
||||
|
||||
interface InstanceConfigContextValue {
|
||||
getInstanceConfig: typeof getInstanceConfig
|
||||
ensureInstanceConfig: typeof ensureInstanceConfig
|
||||
updateInstanceConfig: typeof updateInstanceConfig
|
||||
clearInstanceConfig: typeof clearInstanceConfig
|
||||
}
|
||||
|
||||
const InstanceConfigContext = createContext<InstanceConfigContextValue>()
|
||||
|
||||
const contextValue: InstanceConfigContextValue = {
|
||||
getInstanceConfig,
|
||||
ensureInstanceConfig,
|
||||
updateInstanceConfig,
|
||||
clearInstanceConfig,
|
||||
}
|
||||
|
||||
const InstanceConfigProvider: ParentComponent = (props) => {
|
||||
onCleanup(() => {
|
||||
for (const unsubscribe of instanceSubscriptions.values()) {
|
||||
unsubscribe()
|
||||
}
|
||||
instanceSubscriptions.clear()
|
||||
})
|
||||
|
||||
return <InstanceConfigContext.Provider value={contextValue}>{props.children}</InstanceConfigContext.Provider>
|
||||
}
|
||||
|
||||
export {
|
||||
InstanceConfigProvider,
|
||||
useInstanceConfig,
|
||||
ensureInstanceConfig as ensureInstanceConfigLoaded,
|
||||
getInstanceConfig,
|
||||
updateInstanceConfig,
|
||||
clearInstanceConfig,
|
||||
}
|
||||
@@ -4,9 +4,10 @@ import type { LspStatus, Permission } from "@opencode-ai/sdk"
|
||||
import type { ClientPart, Message } from "../types/message"
|
||||
import { sdkManager } from "../lib/sdk-manager"
|
||||
import { sseManager } from "../lib/sse-manager"
|
||||
import { cliApi } from "../lib/api-client"
|
||||
import { cliEvents } from "../lib/cli-events"
|
||||
import type { WorkspaceDescriptor, WorkspaceEventPayload, WorkspaceLogEntry } from "../../../cli/src/api-types"
|
||||
import { serverApi } from "../lib/api-client"
|
||||
import { serverEvents } from "../lib/server-events"
|
||||
import type { WorkspaceDescriptor, WorkspaceEventPayload, WorkspaceLogEntry } from "../../../server/src/api-types"
|
||||
import { ensureInstanceConfigLoaded } from "./instance-config"
|
||||
import {
|
||||
fetchSessions,
|
||||
fetchAgents,
|
||||
@@ -15,11 +16,12 @@ import {
|
||||
clearInstanceDraftPrompts,
|
||||
} from "./sessions"
|
||||
import { fetchCommands, clearCommands } from "./commands"
|
||||
import { preferences, updateLastUsedBinary } from "./preferences"
|
||||
import { preferences } from "./preferences"
|
||||
import { computeDisplayParts } from "./session-messages"
|
||||
import { withSession, setSessionPendingPermission } from "./session-state"
|
||||
import { setHasInstances } from "./ui"
|
||||
|
||||
|
||||
const [instances, setInstances] = createSignal<Map<string, Instance>>(new Map())
|
||||
const [activeInstanceId, setActiveInstanceId] = createSignal<string | null>(null)
|
||||
const [instanceLogs, setInstanceLogs] = createSignal<Map<string, LogEntry[]>>(new Map())
|
||||
@@ -116,6 +118,7 @@ async function hydrateInstanceData(instanceId: string) {
|
||||
await fetchSessions(instanceId)
|
||||
await fetchAgents(instanceId)
|
||||
await fetchProviders(instanceId)
|
||||
await ensureInstanceConfigLoaded(instanceId)
|
||||
const instance = instances().get(instanceId)
|
||||
if (!instance?.client) return
|
||||
await fetchCommands(instanceId, instance.client)
|
||||
@@ -126,7 +129,7 @@ async function hydrateInstanceData(instanceId: string) {
|
||||
|
||||
void (async function initializeWorkspaces() {
|
||||
try {
|
||||
const workspaces = await cliApi.fetchWorkspaces()
|
||||
const workspaces = await serverApi.fetchWorkspaces()
|
||||
workspaces.forEach((workspace) => upsertWorkspace(workspace))
|
||||
if (workspaces.length === 0) {
|
||||
setHasInstances(false)
|
||||
@@ -136,7 +139,7 @@ void (async function initializeWorkspaces() {
|
||||
}
|
||||
})()
|
||||
|
||||
cliEvents.on("*", (event) => handleWorkspaceEvent(event))
|
||||
serverEvents.on("*", (event) => handleWorkspaceEvent(event))
|
||||
|
||||
function handleWorkspaceEvent(event: WorkspaceEventPayload) {
|
||||
switch (event.type) {
|
||||
@@ -294,13 +297,9 @@ function removeInstance(id: string) {
|
||||
clearInstanceDraftPrompts(id)
|
||||
}
|
||||
|
||||
async function createInstance(folder: string, binaryPath?: string): Promise<string> {
|
||||
if (binaryPath) {
|
||||
updateLastUsedBinary(binaryPath)
|
||||
}
|
||||
|
||||
async function createInstance(folder: string, _binaryPath?: string): Promise<string> {
|
||||
try {
|
||||
const workspace = await cliApi.createWorkspace({ path: folder })
|
||||
const workspace = await serverApi.createWorkspace({ path: folder })
|
||||
upsertWorkspace(workspace)
|
||||
setActiveInstanceId(workspace.id)
|
||||
return workspace.id
|
||||
@@ -317,7 +316,7 @@ async function stopInstance(id: string) {
|
||||
releaseInstanceResources(id)
|
||||
|
||||
try {
|
||||
await cliApi.deleteWorkspace(id)
|
||||
await serverApi.deleteWorkspace(id)
|
||||
} catch (error) {
|
||||
console.error("Failed to stop workspace", error)
|
||||
}
|
||||
|
||||
@@ -1,59 +1,35 @@
|
||||
import { storage } from "../lib/storage"
|
||||
import type { InstanceData } from "../../../server/src/api-types"
|
||||
import {
|
||||
ensureInstanceConfigLoaded,
|
||||
getInstanceConfig,
|
||||
updateInstanceConfig,
|
||||
} from "./instance-config"
|
||||
|
||||
const MAX_HISTORY = 100
|
||||
|
||||
const instanceHistories = new Map<string, string[]>()
|
||||
const historyLoaded = new Set<string>()
|
||||
|
||||
export async function addToHistory(instanceId: string, text: string): Promise<void> {
|
||||
await ensureHistoryLoaded(instanceId)
|
||||
|
||||
const history = instanceHistories.get(instanceId) || []
|
||||
|
||||
history.unshift(text)
|
||||
|
||||
if (history.length > MAX_HISTORY) {
|
||||
history.length = MAX_HISTORY
|
||||
}
|
||||
|
||||
instanceHistories.set(instanceId, history)
|
||||
|
||||
try {
|
||||
await storage.saveInstanceData(instanceId, { messageHistory: history })
|
||||
} catch (err) {
|
||||
console.warn("Failed to persist message history:", err)
|
||||
}
|
||||
if (!instanceId || !text) return
|
||||
await ensureInstanceConfigLoaded(instanceId)
|
||||
await updateInstanceConfig(instanceId, (draft) => {
|
||||
const nextHistory = [text, ...(draft.messageHistory ?? [])]
|
||||
if (nextHistory.length > MAX_HISTORY) {
|
||||
nextHistory.length = MAX_HISTORY
|
||||
}
|
||||
draft.messageHistory = nextHistory
|
||||
})
|
||||
}
|
||||
|
||||
export async function getHistory(instanceId: string): Promise<string[]> {
|
||||
await ensureHistoryLoaded(instanceId)
|
||||
return instanceHistories.get(instanceId) || []
|
||||
if (!instanceId) return []
|
||||
await ensureInstanceConfigLoaded(instanceId)
|
||||
const data = getInstanceConfig(instanceId)
|
||||
return [...(data.messageHistory ?? [])]
|
||||
}
|
||||
|
||||
export async function clearHistory(instanceId: string): Promise<void> {
|
||||
instanceHistories.delete(instanceId)
|
||||
historyLoaded.delete(instanceId)
|
||||
|
||||
try {
|
||||
await storage.saveInstanceData(instanceId, { messageHistory: [] })
|
||||
} catch (error) {
|
||||
console.warn("Failed to clear history:", error)
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureHistoryLoaded(instanceId: string): Promise<void> {
|
||||
if (historyLoaded.has(instanceId)) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await storage.loadInstanceData(instanceId)
|
||||
const history = Array.isArray(data.messageHistory) ? data.messageHistory : []
|
||||
instanceHistories.set(instanceId, history)
|
||||
historyLoaded.add(instanceId)
|
||||
} catch (error) {
|
||||
console.warn("Failed to load history:", error)
|
||||
instanceHistories.set(instanceId, [])
|
||||
historyLoaded.add(instanceId)
|
||||
}
|
||||
if (!instanceId) return
|
||||
await ensureInstanceConfigLoaded(instanceId)
|
||||
await updateInstanceConfig(instanceId, (draft) => {
|
||||
draft.messageHistory = []
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,6 +1,19 @@
|
||||
import { createContext, createSignal, onMount, useContext } from "solid-js"
|
||||
import { createContext, createMemo, createSignal, onMount, useContext } from "solid-js"
|
||||
import type { Accessor, ParentComponent } from "solid-js"
|
||||
import { storage, type ConfigData } from "../lib/storage"
|
||||
import {
|
||||
ensureInstanceConfigLoaded,
|
||||
getInstanceConfig,
|
||||
updateInstanceConfig as updateInstanceData,
|
||||
} from "./instance-config"
|
||||
|
||||
type DeepReadonly<T> = T extends (...args: any[]) => unknown
|
||||
? T
|
||||
: T extends Array<infer U>
|
||||
? ReadonlyArray<DeepReadonly<U>>
|
||||
: T extends object
|
||||
? { readonly [K in keyof T]: DeepReadonly<T[K]> }
|
||||
: T
|
||||
|
||||
export interface ModelPreference {
|
||||
providerId: string
|
||||
@@ -19,7 +32,6 @@ export interface Preferences {
|
||||
lastUsedBinary?: string
|
||||
environmentVariables: Record<string, string>
|
||||
modelRecents: ModelPreference[]
|
||||
agentModelSelections: AgentModelSelections
|
||||
diffViewMode: DiffViewMode
|
||||
toolOutputExpansion: ExpansionPreference
|
||||
diagnosticsExpansion: ExpansionPreference
|
||||
@@ -36,6 +48,8 @@ export interface RecentFolder {
|
||||
lastAccessed: number
|
||||
}
|
||||
|
||||
export type ThemePreference = NonNullable<ConfigData["theme"]>
|
||||
|
||||
const MAX_RECENT_FOLDERS = 20
|
||||
const MAX_RECENT_MODELS = 5
|
||||
|
||||
@@ -43,108 +57,198 @@ const defaultPreferences: Preferences = {
|
||||
showThinkingBlocks: false,
|
||||
environmentVariables: {},
|
||||
modelRecents: [],
|
||||
agentModelSelections: {},
|
||||
diffViewMode: "split",
|
||||
toolOutputExpansion: "expanded",
|
||||
diagnosticsExpansion: "expanded",
|
||||
}
|
||||
|
||||
function normalizePreferences(pref?: Partial<Preferences>): Preferences {
|
||||
function deepEqual(a: unknown, b: unknown): boolean {
|
||||
if (a === b) return true
|
||||
if (typeof a === "object" && a !== null && typeof b === "object" && b !== null) {
|
||||
try {
|
||||
return JSON.stringify(a) === JSON.stringify(b)
|
||||
} catch (error) {
|
||||
console.warn("Failed to compare preference values", error)
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function normalizePreferences(pref?: Partial<Preferences> & { agentModelSelections?: unknown }): Preferences {
|
||||
const sanitized = pref ?? {}
|
||||
const environmentVariables = {
|
||||
...defaultPreferences.environmentVariables,
|
||||
...(pref?.environmentVariables ?? {}),
|
||||
...(sanitized.environmentVariables ?? {}),
|
||||
}
|
||||
|
||||
const sourceModelRecents = pref?.modelRecents ?? defaultPreferences.modelRecents
|
||||
const sourceModelRecents = sanitized.modelRecents ?? defaultPreferences.modelRecents
|
||||
const modelRecents = sourceModelRecents.map((item) => ({ ...item }))
|
||||
|
||||
const sourceAgentSelections = pref?.agentModelSelections ?? defaultPreferences.agentModelSelections
|
||||
const agentModelSelections: AgentModelSelections = {}
|
||||
for (const [instanceId, selections] of Object.entries(sourceAgentSelections)) {
|
||||
agentModelSelections[instanceId] = Object.fromEntries(
|
||||
Object.entries(selections).map(([agentId, selection]) => [agentId, { ...selection }]),
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
showThinkingBlocks: pref?.showThinkingBlocks ?? defaultPreferences.showThinkingBlocks,
|
||||
lastUsedBinary: pref?.lastUsedBinary ?? defaultPreferences.lastUsedBinary,
|
||||
showThinkingBlocks: sanitized.showThinkingBlocks ?? defaultPreferences.showThinkingBlocks,
|
||||
lastUsedBinary: sanitized.lastUsedBinary ?? defaultPreferences.lastUsedBinary,
|
||||
environmentVariables,
|
||||
modelRecents,
|
||||
agentModelSelections,
|
||||
diffViewMode: pref?.diffViewMode ?? defaultPreferences.diffViewMode,
|
||||
toolOutputExpansion: pref?.toolOutputExpansion ?? defaultPreferences.toolOutputExpansion,
|
||||
diagnosticsExpansion: pref?.diagnosticsExpansion ?? defaultPreferences.diagnosticsExpansion,
|
||||
diffViewMode: sanitized.diffViewMode ?? defaultPreferences.diffViewMode,
|
||||
toolOutputExpansion: sanitized.toolOutputExpansion ?? defaultPreferences.toolOutputExpansion,
|
||||
diagnosticsExpansion: sanitized.diagnosticsExpansion ?? defaultPreferences.diagnosticsExpansion,
|
||||
}
|
||||
}
|
||||
|
||||
const [preferences, setPreferences] = createSignal<Preferences>(normalizePreferences())
|
||||
const [recentFolders, setRecentFolders] = createSignal<RecentFolder[]>([])
|
||||
const [opencodeBinaries, setOpenCodeBinaries] = createSignal<OpenCodeBinary[]>([])
|
||||
const [internalConfig, setInternalConfig] = createSignal<ConfigData>(buildFallbackConfig())
|
||||
const config = createMemo<DeepReadonly<ConfigData>>(() => internalConfig())
|
||||
const [isConfigLoaded, setIsConfigLoaded] = createSignal(false)
|
||||
let cachedConfig: ConfigData = {
|
||||
preferences: normalizePreferences(),
|
||||
recentFolders: [],
|
||||
opencodeBinaries: [],
|
||||
}
|
||||
const preferences = createMemo<Preferences>(() => internalConfig().preferences)
|
||||
const recentFolders = createMemo<RecentFolder[]>(() => internalConfig().recentFolders ?? [])
|
||||
const opencodeBinaries = createMemo<OpenCodeBinary[]>(() => internalConfig().opencodeBinaries ?? [])
|
||||
const themePreference = createMemo<ThemePreference>(() => internalConfig().theme ?? "dark")
|
||||
let loadPromise: Promise<void> | null = null
|
||||
|
||||
async function loadConfig(): Promise<void> {
|
||||
function normalizeConfig(config?: ConfigData | null): ConfigData {
|
||||
return {
|
||||
preferences: normalizePreferences(config?.preferences),
|
||||
recentFolders: (config?.recentFolders ?? []).map((folder) => ({ ...folder })),
|
||||
opencodeBinaries: (config?.opencodeBinaries ?? []).map((binary) => ({ ...binary })),
|
||||
theme: config?.theme ?? "dark",
|
||||
}
|
||||
}
|
||||
|
||||
function buildFallbackConfig(): ConfigData {
|
||||
return normalizeConfig()
|
||||
}
|
||||
|
||||
function removeLegacyAgentSelections(config?: ConfigData | null): { cleaned: ConfigData; migrated: boolean } {
|
||||
const migrated = Boolean((config?.preferences as { agentModelSelections?: unknown } | undefined)?.agentModelSelections)
|
||||
const cleanedConfig = normalizeConfig(config)
|
||||
return { cleaned: cleanedConfig, migrated }
|
||||
}
|
||||
|
||||
async function syncConfig(source?: ConfigData): Promise<void> {
|
||||
try {
|
||||
const config = await storage.loadConfig()
|
||||
cachedConfig = {
|
||||
...config,
|
||||
preferences: normalizePreferences(config.preferences),
|
||||
recentFolders: config.recentFolders ?? [],
|
||||
opencodeBinaries: config.opencodeBinaries ?? [],
|
||||
const loaded = source ?? (await storage.loadConfig())
|
||||
const { cleaned, migrated } = removeLegacyAgentSelections(loaded)
|
||||
applyConfig(cleaned)
|
||||
if (migrated) {
|
||||
void storage.updateConfig(cleaned).catch((error: unknown) => {
|
||||
console.error("Failed to persist legacy config cleanup:", error)
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load config:", error)
|
||||
cachedConfig = {
|
||||
...cachedConfig,
|
||||
preferences: normalizePreferences(),
|
||||
recentFolders: [],
|
||||
opencodeBinaries: [],
|
||||
}
|
||||
applyConfig(buildFallbackConfig())
|
||||
}
|
||||
}
|
||||
|
||||
setPreferences(cachedConfig.preferences)
|
||||
setRecentFolders(cachedConfig.recentFolders)
|
||||
setOpenCodeBinaries(cachedConfig.opencodeBinaries)
|
||||
function applyConfig(next: ConfigData) {
|
||||
setInternalConfig(normalizeConfig(next))
|
||||
setIsConfigLoaded(true)
|
||||
}
|
||||
|
||||
async function saveConfig(): Promise<void> {
|
||||
function cloneConfigForUpdate(): ConfigData {
|
||||
return normalizeConfig(internalConfig())
|
||||
}
|
||||
|
||||
function logConfigDiff(previous: ConfigData, next: ConfigData) {
|
||||
if (deepEqual(previous, next)) {
|
||||
return
|
||||
}
|
||||
const changes = diffObjects(previous, next)
|
||||
if (changes.length > 0) {
|
||||
console.debug("[Config] Changes", changes)
|
||||
}
|
||||
}
|
||||
|
||||
function diffObjects(previous: unknown, next: unknown, path: string[] = []): string[] {
|
||||
if (previous === next) {
|
||||
return []
|
||||
}
|
||||
|
||||
if (typeof previous !== "object" || previous === null || typeof next !== "object" || next === null) {
|
||||
return [path.join(".")]
|
||||
}
|
||||
|
||||
const prevKeys = Object.keys(previous as Record<string, unknown>)
|
||||
const nextKeys = Object.keys(next as Record<string, unknown>)
|
||||
const allKeys = new Set([...prevKeys, ...nextKeys])
|
||||
const changes: string[] = []
|
||||
|
||||
for (const key of allKeys) {
|
||||
const childPath = [...path, key]
|
||||
const prevValue = (previous as Record<string, unknown>)[key]
|
||||
const nextValue = (next as Record<string, unknown>)[key]
|
||||
changes.push(...diffObjects(prevValue, nextValue, childPath))
|
||||
}
|
||||
|
||||
return changes
|
||||
}
|
||||
|
||||
function updateConfig(mutator: (draft: ConfigData) => void): void {
|
||||
const previous = internalConfig()
|
||||
const draft = cloneConfigForUpdate()
|
||||
mutator(draft)
|
||||
logConfigDiff(previous, draft)
|
||||
applyConfig(draft)
|
||||
void persistFullConfig(draft)
|
||||
}
|
||||
|
||||
async function persistFullConfig(next: ConfigData): Promise<void> {
|
||||
try {
|
||||
await ensureConfigLoaded()
|
||||
const config: ConfigData = {
|
||||
...cachedConfig,
|
||||
preferences: preferences(),
|
||||
recentFolders: recentFolders(),
|
||||
opencodeBinaries: opencodeBinaries(),
|
||||
}
|
||||
cachedConfig = config
|
||||
await storage.saveConfig(config)
|
||||
await storage.updateConfig(next)
|
||||
} catch (error) {
|
||||
console.error("Failed to save config:", error)
|
||||
void syncConfig().catch((syncError: unknown) => {
|
||||
console.error("Failed to refresh config:", syncError)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function setThemePreference(preference: ThemePreference): void {
|
||||
if (themePreference() === preference) {
|
||||
return
|
||||
}
|
||||
updateConfig((draft) => {
|
||||
draft.theme = preference
|
||||
})
|
||||
}
|
||||
|
||||
async function ensureConfigLoaded(): Promise<void> {
|
||||
if (isConfigLoaded()) return
|
||||
if (!loadPromise) {
|
||||
loadPromise = loadConfig().finally(() => {
|
||||
loadPromise = syncConfig().finally(() => {
|
||||
loadPromise = null
|
||||
})
|
||||
}
|
||||
await loadPromise
|
||||
}
|
||||
|
||||
function buildRecentFolderList(path: string, source: RecentFolder[]): RecentFolder[] {
|
||||
const folders = source.filter((f) => f.path !== path)
|
||||
folders.unshift({ path, lastAccessed: Date.now() })
|
||||
return folders.slice(0, MAX_RECENT_FOLDERS)
|
||||
}
|
||||
|
||||
function buildBinaryList(path: string, version: string | undefined, source: OpenCodeBinary[]): OpenCodeBinary[] {
|
||||
const timestamp = Date.now()
|
||||
const existing = source.find((b) => b.path === path)
|
||||
if (existing) {
|
||||
const updatedEntry: OpenCodeBinary = { ...existing, lastUsed: timestamp }
|
||||
const remaining = source.filter((b) => b.path !== path)
|
||||
return [updatedEntry, ...remaining]
|
||||
}
|
||||
const nextEntry: OpenCodeBinary = version ? { path, version, lastUsed: timestamp } : { path, lastUsed: timestamp }
|
||||
return [nextEntry, ...source].slice(0, 10)
|
||||
}
|
||||
|
||||
function updatePreferences(updates: Partial<Preferences>): void {
|
||||
const updated = normalizePreferences({ ...preferences(), ...updates })
|
||||
setPreferences(updated)
|
||||
saveConfig().catch(console.error)
|
||||
const current = internalConfig().preferences
|
||||
const merged = normalizePreferences({ ...current, ...updates })
|
||||
if (deepEqual(current, merged)) {
|
||||
return
|
||||
}
|
||||
updateConfig((draft) => {
|
||||
draft.preferences = merged
|
||||
})
|
||||
}
|
||||
|
||||
function setDiffViewMode(mode: DiffViewMode): void {
|
||||
@@ -167,54 +271,44 @@ function toggleShowThinkingBlocks(): void {
|
||||
}
|
||||
|
||||
function addRecentFolder(path: string): void {
|
||||
const folders = recentFolders().filter((f) => f.path !== path)
|
||||
folders.unshift({ path, lastAccessed: Date.now() })
|
||||
|
||||
const trimmed = folders.slice(0, MAX_RECENT_FOLDERS)
|
||||
setRecentFolders(trimmed)
|
||||
saveConfig().catch(console.error)
|
||||
updateConfig((draft) => {
|
||||
draft.recentFolders = buildRecentFolderList(path, draft.recentFolders)
|
||||
})
|
||||
}
|
||||
|
||||
function removeRecentFolder(path: string): void {
|
||||
const folders = recentFolders().filter((f) => f.path !== path)
|
||||
setRecentFolders(folders)
|
||||
saveConfig().catch(console.error)
|
||||
updateConfig((draft) => {
|
||||
draft.recentFolders = draft.recentFolders.filter((f) => f.path !== path)
|
||||
})
|
||||
}
|
||||
|
||||
function addOpenCodeBinary(path: string, version?: string): void {
|
||||
const binaries = opencodeBinaries().filter((b) => b.path !== path)
|
||||
const lastUsed = Date.now()
|
||||
const binaryEntry: OpenCodeBinary = version ? { path, version, lastUsed } : { path, lastUsed }
|
||||
binaries.unshift(binaryEntry)
|
||||
|
||||
const trimmed = binaries.slice(0, 10) // Keep max 10 binaries
|
||||
setOpenCodeBinaries(trimmed)
|
||||
saveConfig().catch(console.error)
|
||||
updateConfig((draft) => {
|
||||
draft.opencodeBinaries = buildBinaryList(path, version, draft.opencodeBinaries)
|
||||
})
|
||||
}
|
||||
|
||||
function removeOpenCodeBinary(path: string): void {
|
||||
const binaries = opencodeBinaries().filter((b) => b.path !== path)
|
||||
setOpenCodeBinaries(binaries)
|
||||
saveConfig().catch(console.error)
|
||||
updateConfig((draft) => {
|
||||
draft.opencodeBinaries = draft.opencodeBinaries.filter((b) => b.path !== path)
|
||||
})
|
||||
}
|
||||
|
||||
function updateLastUsedBinary(path: string): void {
|
||||
updatePreferences({ lastUsedBinary: path })
|
||||
const target = path || preferences().lastUsedBinary || "opencode"
|
||||
updateConfig((draft) => {
|
||||
draft.preferences = normalizePreferences({ ...draft.preferences, lastUsedBinary: target })
|
||||
draft.opencodeBinaries = buildBinaryList(target, undefined, draft.opencodeBinaries)
|
||||
})
|
||||
}
|
||||
|
||||
const binaries = opencodeBinaries()
|
||||
let binary = binaries.find((b) => b.path === path)
|
||||
|
||||
// If binary not found in list, add it (for system PATH "opencode")
|
||||
if (!binary) {
|
||||
addOpenCodeBinary(path)
|
||||
binary = { path, lastUsed: Date.now() }
|
||||
} else {
|
||||
binary.lastUsed = Date.now()
|
||||
// Move to front
|
||||
const sorted = [binary, ...binaries.filter((b) => b.path !== path)]
|
||||
setOpenCodeBinaries(sorted)
|
||||
saveConfig().catch(console.error)
|
||||
}
|
||||
function recordWorkspaceLaunch(folderPath: string, binaryPath?: string): void {
|
||||
updateConfig((draft) => {
|
||||
const targetBinary = binaryPath && binaryPath.trim().length > 0 ? binaryPath : draft.preferences.lastUsedBinary || "opencode"
|
||||
draft.recentFolders = buildRecentFolderList(folderPath, draft.recentFolders)
|
||||
draft.preferences = normalizePreferences({ ...draft.preferences, lastUsedBinary: targetBinary })
|
||||
draft.opencodeBinaries = buildBinaryList(targetBinary, undefined, draft.opencodeBinaries)
|
||||
})
|
||||
}
|
||||
|
||||
function updateEnvironmentVariables(envVars: Record<string, string>): void {
|
||||
@@ -241,38 +335,40 @@ function addRecentModelPreference(model: ModelPreference): void {
|
||||
updatePreferences({ modelRecents: updated })
|
||||
}
|
||||
|
||||
function setAgentModelPreference(instanceId: string, agent: string, model: ModelPreference): void {
|
||||
async function setAgentModelPreference(instanceId: string, agent: string, model: ModelPreference): Promise<void> {
|
||||
if (!instanceId || !agent || !model.providerId || !model.modelId) return
|
||||
const selections = preferences().agentModelSelections ?? {}
|
||||
const instanceSelections = selections[instanceId] ?? {}
|
||||
const existing = instanceSelections[agent]
|
||||
if (existing && existing.providerId === model.providerId && existing.modelId === model.modelId) {
|
||||
return
|
||||
}
|
||||
updatePreferences({
|
||||
agentModelSelections: {
|
||||
...selections,
|
||||
[instanceId]: {
|
||||
...instanceSelections,
|
||||
[agent]: model,
|
||||
},
|
||||
},
|
||||
await ensureInstanceConfigLoaded(instanceId)
|
||||
await updateInstanceData(instanceId, (draft) => {
|
||||
const selections = { ...(draft.agentModelSelections ?? {}) }
|
||||
const existing = selections[agent]
|
||||
if (existing && existing.providerId === model.providerId && existing.modelId === model.modelId) {
|
||||
return
|
||||
}
|
||||
selections[agent] = model
|
||||
draft.agentModelSelections = selections
|
||||
})
|
||||
}
|
||||
|
||||
function getAgentModelPreference(instanceId: string, agent: string): ModelPreference | undefined {
|
||||
return preferences().agentModelSelections?.[instanceId]?.[agent]
|
||||
async function getAgentModelPreference(instanceId: string, agent: string): Promise<ModelPreference | undefined> {
|
||||
if (!instanceId || !agent) return undefined
|
||||
await ensureInstanceConfigLoaded(instanceId)
|
||||
const selections = getInstanceConfig(instanceId).agentModelSelections ?? {}
|
||||
return selections[agent]
|
||||
}
|
||||
|
||||
void ensureConfigLoaded().catch((error) => {
|
||||
void ensureConfigLoaded().catch((error: unknown) => {
|
||||
console.error("Failed to initialize config:", error)
|
||||
})
|
||||
|
||||
interface ConfigContextValue {
|
||||
isLoaded: Accessor<boolean>
|
||||
config: typeof config
|
||||
preferences: typeof preferences
|
||||
recentFolders: typeof recentFolders
|
||||
opencodeBinaries: typeof opencodeBinaries
|
||||
themePreference: typeof themePreference
|
||||
setThemePreference: typeof setThemePreference
|
||||
updateConfig: typeof updateConfig
|
||||
toggleShowThinkingBlocks: typeof toggleShowThinkingBlocks
|
||||
setDiffViewMode: typeof setDiffViewMode
|
||||
setToolOutputExpansion: typeof setToolOutputExpansion
|
||||
@@ -282,6 +378,7 @@ interface ConfigContextValue {
|
||||
addOpenCodeBinary: typeof addOpenCodeBinary
|
||||
removeOpenCodeBinary: typeof removeOpenCodeBinary
|
||||
updateLastUsedBinary: typeof updateLastUsedBinary
|
||||
recordWorkspaceLaunch: typeof recordWorkspaceLaunch
|
||||
updatePreferences: typeof updatePreferences
|
||||
updateEnvironmentVariables: typeof updateEnvironmentVariables
|
||||
addEnvironmentVariable: typeof addEnvironmentVariable
|
||||
@@ -295,9 +392,13 @@ const ConfigContext = createContext<ConfigContextValue>()
|
||||
|
||||
const configContextValue: ConfigContextValue = {
|
||||
isLoaded: isConfigLoaded,
|
||||
config,
|
||||
preferences,
|
||||
recentFolders,
|
||||
opencodeBinaries,
|
||||
themePreference,
|
||||
setThemePreference,
|
||||
updateConfig,
|
||||
toggleShowThinkingBlocks,
|
||||
setDiffViewMode,
|
||||
setToolOutputExpansion,
|
||||
@@ -307,6 +408,7 @@ const configContextValue: ConfigContextValue = {
|
||||
addOpenCodeBinary,
|
||||
removeOpenCodeBinary,
|
||||
updateLastUsedBinary,
|
||||
recordWorkspaceLaunch,
|
||||
updatePreferences,
|
||||
updateEnvironmentVariables,
|
||||
addEnvironmentVariable,
|
||||
@@ -318,12 +420,12 @@ const configContextValue: ConfigContextValue = {
|
||||
|
||||
const ConfigProvider: ParentComponent = (props) => {
|
||||
onMount(() => {
|
||||
ensureConfigLoaded().catch((error) => {
|
||||
ensureConfigLoaded().catch((error: unknown) => {
|
||||
console.error("Failed to initialize config:", error)
|
||||
})
|
||||
|
||||
const unsubscribe = storage.onConfigChanged(() => {
|
||||
loadConfig().catch((error) => {
|
||||
const unsubscribe = storage.onConfigChanged((config) => {
|
||||
syncConfig(config).catch((error: unknown) => {
|
||||
console.error("Failed to refresh config:", error)
|
||||
})
|
||||
})
|
||||
@@ -347,7 +449,9 @@ function useConfig(): ConfigContextValue {
|
||||
export {
|
||||
ConfigProvider,
|
||||
useConfig,
|
||||
config,
|
||||
preferences,
|
||||
updateConfig,
|
||||
updatePreferences,
|
||||
toggleShowThinkingBlocks,
|
||||
recentFolders,
|
||||
@@ -366,4 +470,7 @@ export {
|
||||
setDiffViewMode,
|
||||
setToolOutputExpansion,
|
||||
setDiagnosticsExpansion,
|
||||
themePreference,
|
||||
setThemePreference,
|
||||
recordWorkspaceLaunch,
|
||||
}
|
||||
|
||||
@@ -306,7 +306,7 @@ async function updateSessionAgent(instanceId: string, sessionId: string, agent:
|
||||
})
|
||||
|
||||
if (agent && shouldApplyModel) {
|
||||
setAgentModelPreference(instanceId, agent, nextModel)
|
||||
await setAgentModelPreference(instanceId, agent, nextModel)
|
||||
}
|
||||
|
||||
if (shouldApplyModel) {
|
||||
@@ -335,7 +335,7 @@ async function updateSessionModel(
|
||||
})
|
||||
|
||||
if (session.agent) {
|
||||
setAgentModelPreference(instanceId, session.agent, model)
|
||||
await setAgentModelPreference(instanceId, session.agent, model)
|
||||
}
|
||||
addRecentModelPreference(model)
|
||||
|
||||
|
||||
@@ -159,7 +159,7 @@ async function createSession(instanceId: string, agent?: string): Promise<Sessio
|
||||
const defaultModel = await getDefaultModel(instanceId, selectedAgent)
|
||||
|
||||
if (selectedAgent && isModelValid(instanceId, defaultModel)) {
|
||||
setAgentModelPreference(instanceId, selectedAgent, defaultModel)
|
||||
await setAgentModelPreference(instanceId, selectedAgent, defaultModel)
|
||||
}
|
||||
|
||||
setLoading((prev) => {
|
||||
|
||||
@@ -32,13 +32,6 @@ async function getDefaultModel(
|
||||
const instanceProviders = providers().get(instanceId) || []
|
||||
const instanceAgents = agents().get(instanceId) || []
|
||||
|
||||
if (agentName) {
|
||||
const stored = getAgentModelPreference(instanceId, agentName)
|
||||
if (isModelValid(instanceId, stored)) {
|
||||
return stored
|
||||
}
|
||||
}
|
||||
|
||||
if (agentName) {
|
||||
const agent = instanceAgents.find((a) => a.name === agentName)
|
||||
if (agent && agent.model && isModelValid(instanceId, agent.model)) {
|
||||
@@ -47,6 +40,11 @@ async function getDefaultModel(
|
||||
modelId: agent.model.modelId,
|
||||
}
|
||||
}
|
||||
|
||||
const stored = await getAgentModelPreference(instanceId, agentName)
|
||||
if (isModelValid(instanceId, stored)) {
|
||||
return stored
|
||||
}
|
||||
}
|
||||
|
||||
const recent = getRecentModelPreferenceForInstance(instanceId)
|
||||
|
||||
@@ -31,6 +31,11 @@
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.message-attachments {
|
||||
@apply flex flex-wrap gap-1.5 pt-2 mt-1;
|
||||
border-top: 1px solid var(--border-base);
|
||||
}
|
||||
|
||||
.message-error {
|
||||
@apply text-xs mt-1;
|
||||
color: var(--status-error);
|
||||
|
||||
@@ -103,10 +103,41 @@
|
||||
ring-color: var(--attachment-chip-ring);
|
||||
}
|
||||
|
||||
.attachment-remove {
|
||||
.attachment-chip-image {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.attachment-chip-preview {
|
||||
display: none;
|
||||
position: absolute;
|
||||
bottom: calc(100% + 6px);
|
||||
left: 0;
|
||||
padding: 8px;
|
||||
background-color: var(--surface-base);
|
||||
border: 1px solid var(--border-base);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.25);
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.attachment-chip-preview img {
|
||||
display: block;
|
||||
max-width: 320px;
|
||||
max-height: 320px;
|
||||
border-radius: 8px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.attachment-chip-image:hover .attachment-chip-preview {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.attachment-remove,
|
||||
.attachment-download {
|
||||
@apply ml-0.5 flex h-4 w-4 items-center justify-center rounded transition-colors;
|
||||
}
|
||||
|
||||
.attachment-remove:hover {
|
||||
.attachment-remove:hover,
|
||||
.attachment-download:hover {
|
||||
background-color: var(--attachment-chip-ring);
|
||||
}
|
||||
|
||||
@@ -645,16 +645,26 @@
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
.tool-call-todo-region {
|
||||
@apply flex flex-col;
|
||||
}
|
||||
|
||||
.tool-call-todo-empty {
|
||||
@apply text-sm text-muted;
|
||||
padding: 0.75rem 0;
|
||||
}
|
||||
|
||||
.tool-call-todos {
|
||||
@apply my-2 flex flex-col gap-2;
|
||||
@apply flex flex-col gap-0;
|
||||
list-style: none;
|
||||
padding: 4px 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.tool-call-todo-item {
|
||||
@apply flex items-start gap-3;
|
||||
border: 1px solid var(--border-base);
|
||||
border-radius: 8px;
|
||||
border-radius: 0;
|
||||
padding: 10px 12px;
|
||||
background-color: var(--surface-secondary);
|
||||
}
|
||||
@@ -715,7 +725,37 @@
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.tool-call-todo-heading {
|
||||
@apply flex items-start justify-between gap-3;
|
||||
}
|
||||
|
||||
.tool-call-todo-status {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
border-radius: 9999px;
|
||||
padding: 2px 8px;
|
||||
background-color: var(--surface-hover);
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tool-call-todo-status-completed {
|
||||
background-color: var(--badge-success-bg);
|
||||
color: var(--status-success);
|
||||
}
|
||||
|
||||
.tool-call-todo-status-in_progress {
|
||||
background-color: var(--badge-neutral-bg);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.tool-call-todo-status-cancelled {
|
||||
background-color: var(--status-error-bg);
|
||||
color: var(--status-error);
|
||||
}
|
||||
|
||||
.tool-call-todo-text {
|
||||
|
||||
Reference in New Issue
Block a user