diff --git a/src/components/projects/ProjectContextMenu.tsx b/src/components/projects/ProjectContextMenu.tsx
index 50995610a..57c3532e2 100644
--- a/src/components/projects/ProjectContextMenu.tsx
+++ b/src/components/projects/ProjectContextMenu.tsx
@@ -34,6 +34,7 @@ import { useProjectsStore } from '@/store/projects-store'
import { useUIStore } from '@/store/ui-store'
import { getEditorLabel, getTerminalLabel } from '@/types/preferences'
import { getFileManagerName } from '@/lib/platform'
+import { isNativeApp } from '@/lib/environment'
interface ProjectContextMenuProps {
project: Project
@@ -144,10 +145,12 @@ export function ProjectContextMenu({
Open in {getEditorLabel(preferences?.editor)}
-
-
- Open in {getFileManagerName()}
-
+ {isNativeApp() && (
+
+
+ Open in {getFileManagerName()}
+
+ )}
diff --git a/src/lib/platform.test.ts b/src/lib/platform.test.ts
index 7dd044327..dd081ad50 100644
--- a/src/lib/platform.test.ts
+++ b/src/lib/platform.test.ts
@@ -55,3 +55,21 @@ describe('openExternal', () => {
expect(openUrlMock).not.toHaveBeenCalled()
})
})
+
+describe('server platform detection', () => {
+ it('uses the Jean server platform instead of the browser platform when provided', async () => {
+ vi.stubGlobal('window', { open: vi.fn() })
+ vi.stubGlobal('navigator', { platform: 'Win32' })
+
+ const { getServerPlatform, isServerWindows, setServerPlatform } =
+ await import('./platform')
+
+ setServerPlatform('linux')
+
+ expect(getServerPlatform()).toBe('linux')
+ expect(isServerWindows()).toBe(false)
+
+ setServerPlatform('windows')
+ expect(isServerWindows()).toBe(true)
+ })
+})
diff --git a/src/lib/platform.ts b/src/lib/platform.ts
index 4b4493a92..80996363c 100644
--- a/src/lib/platform.ts
+++ b/src/lib/platform.ts
@@ -5,6 +5,28 @@ export const isMacOS = navigator.platform.includes('Mac')
export const isWindows = navigator.platform.includes('Win')
export const isLinux = navigator.platform.includes('Linux')
+export type PlatformName = 'mac' | 'windows' | 'linux'
+
+function browserPlatform(): PlatformName {
+ if (isMacOS) return 'mac'
+ if (isWindows) return 'windows'
+ return 'linux'
+}
+
+let serverPlatform: PlatformName = browserPlatform()
+
+export function setServerPlatform(platform: PlatformName): void {
+ serverPlatform = platform
+}
+
+export function getServerPlatform(): PlatformName {
+ return serverPlatform
+}
+
+export function isServerWindows(): boolean {
+ return serverPlatform === 'windows'
+}
+
/**
* Pre-open a blank browser tab synchronously during a user gesture.
* On mobile/web, calling window.open() after an async operation (e.g. WebSocket invoke)
diff --git a/src/lib/shell-escape.test.ts b/src/lib/shell-escape.test.ts
new file mode 100644
index 000000000..289b6e9bc
--- /dev/null
+++ b/src/lib/shell-escape.test.ts
@@ -0,0 +1,18 @@
+import { describe, expect, it } from 'vitest'
+import { setServerPlatform } from './platform'
+import { escapeCliCommand } from './shell-escape'
+
+describe('escapeCliCommand', () => {
+ it('uses the Jean server platform instead of the browser user agent', () => {
+ setServerPlatform('linux')
+
+ expect(escapeCliCommand('/home/me/my cli', '--help')).toBe(
+ "'/home/me/my cli' --help"
+ )
+
+ setServerPlatform('windows')
+ expect(escapeCliCommand('C:\\Tools\\my cli.exe', '--help')).toBe(
+ '& "C:\\Tools\\my cli.exe" --help'
+ )
+ })
+})
diff --git a/src/lib/shell-escape.ts b/src/lib/shell-escape.ts
index 3fd1b9b8b..dad1f4c7a 100644
--- a/src/lib/shell-escape.ts
+++ b/src/lib/shell-escape.ts
@@ -4,9 +4,10 @@
* - Windows (PowerShell): `& "path" args`
* - Unix (sh/zsh/bash): `'path' args` (with internal single-quote escaping)
*/
+import { isServerWindows } from './platform'
+
export function escapeCliCommand(path: string, args?: string): string {
- const isWindows = navigator.userAgent.includes('Windows')
- if (isWindows) {
+ if (isServerWindows()) {
return args ? `& "${path}" ${args}` : `& "${path}"`
}
const escaped = `'${path.replace(/'/g, "'\\''")}'`
diff --git a/src/lib/transport.ts b/src/lib/transport.ts
index cb718762d..f1e681f21 100644
--- a/src/lib/transport.ts
+++ b/src/lib/transport.ts
@@ -173,6 +173,7 @@ export interface InitialData {
preferences?: unknown
uiState?: unknown
appDataDir?: string
+ serverPlatform?: 'mac' | 'windows' | 'linux'
webBuildId?: string
appVersion?: string
}
diff --git a/src/services/preferences.test.ts b/src/services/preferences.test.ts
index 31c49176c..9d3d1a855 100644
--- a/src/services/preferences.test.ts
+++ b/src/services/preferences.test.ts
@@ -43,6 +43,8 @@ vi.mock('@/lib/platform', () => ({
isMacOS: true,
isWindows: false,
isLinux: false,
+ getServerPlatform: vi.fn(() => 'mac'),
+ isServerWindows: vi.fn(() => false),
getModifierSymbol: vi.fn(() => '⌘'),
getFileManagerName: vi.fn(() => 'Finder'),
openExternal: vi.fn(),
@@ -1200,3 +1202,4 @@ describe('preferences service', () => {
})
})
})
+
diff --git a/src/types/preferences-platform.test.ts b/src/types/preferences-platform.test.ts
new file mode 100644
index 000000000..ff356952d
--- /dev/null
+++ b/src/types/preferences-platform.test.ts
@@ -0,0 +1,18 @@
+import { describe, expect, it } from 'vitest'
+import { setServerPlatform } from '@/lib/platform'
+import { getTerminalOptions } from './preferences'
+
+describe('server platform option filtering', () => {
+ it('uses the Jean server platform for terminal options', () => {
+ setServerPlatform('linux')
+ expect(getTerminalOptions().map(option => option.value)).toEqual([
+ 'terminal',
+ 'ghostty',
+ ])
+
+ setServerPlatform('windows')
+ expect(getTerminalOptions().map(option => option.value)).toContain(
+ 'powershell'
+ )
+ })
+})
diff --git a/src/types/preferences.ts b/src/types/preferences.ts
index f5fe45842..25de4cde8 100644
--- a/src/types/preferences.ts
+++ b/src/types/preferences.ts
@@ -1,6 +1,6 @@
import type { ThinkingLevel, EffortLevel, ExecutionMode } from './chat'
import { DEFAULT_KEYBINDINGS, type KeybindingsMap } from './keybindings'
-import { isMacOS, isWindows } from '../lib/platform'
+import { getServerPlatform, isServerWindows } from '../lib/platform'
export type CodexGoalExecutionMode = Extract
@@ -1582,9 +1582,7 @@ export type TerminalApp =
type Platform = 'mac' | 'windows' | 'linux'
function getCurrentPlatform(): Platform {
- if (isMacOS) return 'mac'
- if (isWindows) return 'windows'
- return 'linux'
+ return getServerPlatform()
}
const allTerminalOptions: {
@@ -1604,8 +1602,14 @@ const allTerminalOptions: {
},
]
+export function getTerminalOptions(): { value: TerminalApp; label: string }[] {
+ return allTerminalOptions.filter(opt =>
+ opt.platforms.includes(getCurrentPlatform())
+ )
+}
+
export const terminalOptions: { value: TerminalApp; label: string }[] =
- allTerminalOptions.filter(opt => opt.platforms.includes(getCurrentPlatform()))
+ getTerminalOptions()
export type EditorApp = 'zed' | 'vscode' | 'cursor' | 'xcode' | 'intellij'
@@ -1633,8 +1637,14 @@ const allEditorOptions: {
},
]
+export function getEditorOptions(): { value: EditorApp; label: string }[] {
+ return allEditorOptions.filter(opt =>
+ opt.platforms.includes(getCurrentPlatform())
+ )
+}
+
export const editorOptions: { value: EditorApp; label: string }[] =
- allEditorOptions.filter(opt => opt.platforms.includes(getCurrentPlatform()))
+ getEditorOptions()
export type OpenInDefault = 'editor' | 'terminal' | 'finder' | 'github'
@@ -1879,7 +1889,7 @@ export const defaultPreferences: AppPreferences = {
selected_model: 'claude-opus-4-8[1m]',
thinking_level: 'ultrathink',
default_effort_level: 'high',
- terminal: isWindows ? 'powershell' : 'terminal',
+ terminal: isServerWindows() ? 'powershell' : 'terminal',
terminal_renderer: 'xterm',
terminal_font: 'jetbrains-mono',
terminal_font_size: 13,
From ea26e4f78ef812b407eb06fc584f18e9fb5fd1a5 Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Mon, 29 Jun 2026 11:51:39 +0200
Subject: [PATCH 008/359] feat(headless): add single-binary server mode
Embed frontend assets in the Rust server, add the jean-server entrypoint,
serve health/readiness routes, and document VPS/Lima deployment.
---
.gitignore | 1 +
docs/headless-server.md | 155 +++++++++++++++
eslint.config.js | 1 +
lima/jean-headless.yaml | 138 ++++++++++++++
src-tauri/Cargo.lock | 35 ++++
src-tauri/Cargo.toml | 1 +
src-tauri/src/bin/jean-server.rs | 4 +
src-tauri/src/http_server/assets.rs | 9 +
src-tauri/src/http_server/mod.rs | 1 +
src-tauri/src/http_server/server.rs | 279 ++++++++++++++++++++++-----
src-tauri/src/lib.rs | 280 +++++++++++++++++++++++-----
11 files changed, 811 insertions(+), 93 deletions(-)
create mode 100644 docs/headless-server.md
create mode 100644 lima/jean-headless.yaml
create mode 100644 src-tauri/src/bin/jean-server.rs
create mode 100644 src-tauri/src/http_server/assets.rs
diff --git a/.gitignore b/.gitignore
index 45a622a27..4bf30cbed 100644
--- a/.gitignore
+++ b/.gitignore
@@ -12,6 +12,7 @@ node_modules
package-lock.json
dist/*
!dist/.gitkeep
+src-tauri/dist/
dist-ssr
*.local
*.local.*
diff --git a/docs/headless-server.md b/docs/headless-server.md
new file mode 100644
index 000000000..823a926f8
--- /dev/null
+++ b/docs/headless-server.md
@@ -0,0 +1,155 @@
+# Jean Headless Server
+
+Jean can run as a browser-accessible server without creating a Tauri WebView/window. This is intended first for Linux VPS, systemd, Docker, and Tailscale deployments.
+
+## Start locally
+
+When running a debug binary directly with `cargo build` / `./target/debug/jean`,
+build the browser bundle first. Jean embeds `dist/` into the server binary at
+compile time, so production deploys only need the compiled binary.
+
+```bash
+bun run build
+cd src-tauri
+cargo build --bin jean --bin jean-server
+```
+
+```bash
+unset DISPLAY WAYLAND_DISPLAY
+./target/debug/jean --headless --host 127.0.0.1 --port 3456
+curl http://127.0.0.1:3456/healthz
+```
+
+You can also run the server entrypoint when packaged/available:
+
+```bash
+jean-server --host 127.0.0.1 --port 3456
+```
+
+For a production single-binary server:
+
+```bash
+bun run build
+cd src-tauri
+cargo build --release --bin jean-server
+./target/release/jean-server --host 0.0.0.0 --port 3456 --token "$JEAN_TOKEN"
+```
+
+After `cargo build --release --bin jean-server` finishes, `dist/` is no longer
+needed on the target server. Re-run `bun run build` before compiling whenever
+frontend code changes.
+
+## Options and environment
+
+| CLI | Environment | Default |
+| --- | --- | --- |
+| `--headless` | `JEAN_HEADLESS=1` | off |
+| `--host ` | `JEAN_HOST` | saved preference, normally `127.0.0.1` |
+| `--port ` | `JEAN_PORT` | `3456` |
+| `--token ` | `JEAN_TOKEN` | saved/generated token |
+| `--no-token` | `JEAN_NO_TOKEN=1` | token required |
+| `--allow-unsafe-no-token` | `JEAN_ALLOW_UNSAFE_NO_TOKEN=1` | off |
+| n/a | `JEAN_ALLOWED_ORIGINS` | same-origin only |
+
+`--token` and `--no-token` are mutually exclusive. Jean rejects `--no-token` with `--host 0.0.0.0` or `--host ::` unless `--allow-unsafe-no-token` is also set.
+
+## Health checks
+
+- `GET /healthz` — process is alive.
+- `GET /readyz` — HTTP server is initialized and WebSocket broadcaster state is ready.
+
+Authenticated endpoints accept either the existing `?token=...` query parameter or an HTTP bearer token:
+
+```bash
+curl -H "Authorization: Bearer $JEAN_TOKEN" http://127.0.0.1:3456/api/auth
+curl "http://127.0.0.1:3456/api/init?token=$JEAN_TOKEN"
+```
+
+The browser UI still uses `/api/init`, `/api/auth`, and `/ws` from the same origin, so reverse proxies do not need to rewrite paths.
+
+## systemd example
+
+```ini
+[Unit]
+Description=Jean headless server
+After=network-online.target
+Wants=network-online.target
+
+[Service]
+Type=simple
+User=jean
+Environment=JEAN_HEADLESS=1
+Environment=JEAN_HOST=127.0.0.1
+Environment=JEAN_PORT=3456
+Environment=JEAN_TOKEN=change-me-long-random-token
+ExecStart=/usr/local/bin/jean --headless
+Restart=on-failure
+RestartSec=5
+
+[Install]
+WantedBy=multi-user.target
+```
+
+## Docker notes
+
+- Bind to `0.0.0.0` inside the container, but keep token auth enabled.
+- Mount Jean's app-data directory as a volume so projects, preferences, and sessions persist.
+- Put TLS/auth in front of the container for internet exposure.
+
+Example command:
+
+```bash
+docker run --rm \
+ -e JEAN_HEADLESS=1 \
+ -e JEAN_HOST=0.0.0.0 \
+ -e JEAN_PORT=3456 \
+ -e JEAN_TOKEN=change-me-long-random-token \
+ -p 127.0.0.1:3456:3456 \
+ -v jean-data:/home/jean/.local/share/com.jean.desktop \
+ jean:latest
+```
+
+## Reverse proxy
+
+### Caddy
+
+```caddyfile
+jean.example.com {
+ encode zstd gzip
+ reverse_proxy 127.0.0.1:3456
+}
+```
+
+### Nginx
+
+```nginx
+server {
+ listen 443 ssl http2;
+ server_name jean.example.com;
+
+ location / {
+ proxy_pass http://127.0.0.1:3456;
+ proxy_http_version 1.1;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection "upgrade";
+ proxy_set_header Host $host;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ }
+}
+```
+
+## Tailscale binding
+
+Bind directly to the Tailscale IP and keep token auth enabled:
+
+```bash
+jean --headless --host 100.x.y.z --port 3456 --token "$JEAN_TOKEN"
+```
+
+## Security recommendations
+
+- Prefer `127.0.0.1` behind Caddy/Nginx, SSH tunnel, or Tailscale.
+- Keep token auth enabled for every non-localhost bind.
+- Use a long random token, for example `openssl rand -base64 32`.
+- Set `JEAN_ALLOWED_ORIGINS=https://jean.example.com` only when you need cross-origin browser access; otherwise keep the default same-origin behavior.
diff --git a/eslint.config.js b/eslint.config.js
index b4e3f584e..9ea9e13cb 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -98,6 +98,7 @@ export default tseslint.config(
{
ignores: [
'dist/**',
+ 'src-tauri/dist/**',
'node_modules/**',
'src-tauri/target/**',
'src-tauri/gen/**',
diff --git a/lima/jean-headless.yaml b/lima/jean-headless.yaml
new file mode 100644
index 000000000..9f80f005a
--- /dev/null
+++ b/lima/jean-headless.yaml
@@ -0,0 +1,138 @@
+# yaml-language-server: $schema=https://raw.githubusercontent.com/lima-vm/lima/master/schemas/lima.yaml.json
+# Lima VM for testing Jean headless mode from the current local checkout.
+#
+# Usage:
+# limactl start --name jean-headless ./lima/jean-headless.yaml
+# limactl shell jean-headless sudo cat /etc/jean-headless.env
+# open "http://127.0.0.1:3456/?token="
+#
+# Reinstall the latest local source after host-side changes:
+# limactl shell jean-headless /usr/local/bin/install-jean-local
+
+images:
+ - location: "https://cloud-images.ubuntu.com/jammy/current/jammy-server-cloudimg-arm64.img"
+ arch: "aarch64"
+ - location: "https://cloud-images.ubuntu.com/jammy/current/jammy-server-cloudimg-amd64.img"
+ arch: "x86_64"
+
+cpus: 4
+memory: "8GiB"
+disk: "40GiB"
+
+mounts:
+ - location: "{{.Dir}}/.."
+ mountPoint: "/workspaces/jean"
+ writable: true
+
+portForwards:
+ - guestPort: 3456
+ hostIP: "127.0.0.1"
+ hostPort: 3456
+
+provision:
+ - mode: system
+ script: |
+ #!/usr/bin/env bash
+ set -euo pipefail
+
+ export DEBIAN_FRONTEND=noninteractive
+ apt-get update
+ apt-get install -y \
+ build-essential \
+ ca-certificates \
+ curl \
+ git \
+ libayatana-appindicator3-dev \
+ libfuse2 \
+ libssl-dev \
+ libwebkit2gtk-4.1-dev \
+ patchelf \
+ pkg-config \
+ xdg-utils
+
+ cat >/usr/local/bin/install-jean-local <<'SCRIPT'
+ #!/usr/bin/env bash
+ set -euo pipefail
+
+ export BUN_INSTALL="${HOME}/.bun"
+ export PATH="${BUN_INSTALL}/bin:${HOME}/.cargo/bin:${PATH}"
+
+ if ! command -v bun >/dev/null 2>&1; then
+ curl -fsSL https://bun.sh/install | bash
+ fi
+
+ if ! command -v cargo >/dev/null 2>&1; then
+ curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
+ | sh -s -- -y --profile minimal
+ fi
+
+ cd /workspaces/jean
+ bun install --frozen-lockfile
+ bun run build
+
+ cd src-tauri
+ cargo build --bin jean-server --profile release-fast
+
+ sudo install -m 0755 target/release-fast/jean-server /usr/local/bin/jean-server
+ sudo ln -sf /usr/local/bin/jean-server /usr/local/bin/jean
+
+ if [ ! -f /etc/jean-headless.env ]; then
+ token="$(openssl rand -base64 32)"
+ sudo tee /etc/jean-headless.env >/dev/null </dev/null <<'UNIT'
+ [Unit]
+ Description=Jean headless server
+ After=network-online.target
+ Wants=network-online.target
+
+ [Service]
+ Type=simple
+ EnvironmentFile=/etc/jean-headless.env
+ ExecStart=/usr/local/bin/jean-server
+ Restart=on-failure
+ RestartSec=5
+
+ [Install]
+ WantedBy=multi-user.target
+ UNIT
+
+ sudo systemctl daemon-reload
+ sudo systemctl enable --now jean-headless.service
+ sudo systemctl restart jean-headless.service
+ SCRIPT
+ chmod 0755 /usr/local/bin/install-jean-local
+
+ - mode: user
+ script: |
+ #!/usr/bin/env bash
+ set -euo pipefail
+ /usr/local/bin/install-jean-local
+
+probes:
+ - script: |
+ #!/usr/bin/env bash
+ set -euo pipefail
+ curl -fsS http://127.0.0.1:3456/healthz >/dev/null
+ hint: |
+ Jean headless did not become healthy yet. Check logs with:
+ limactl shell jean-headless journalctl -u jean-headless -n 100 --no-pager
+
+message: |
+ Jean headless is running in the VM and forwarded to localhost:3456.
+
+ Get the token:
+ limactl shell {{.Name}} sudo sed -n 's/^JEAN_TOKEN=//p' /etc/jean-headless.env
+
+ Open:
+ http://127.0.0.1:3456/?token=
+
+ Reinstall latest local source:
+ limactl shell {{.Name}} /usr/local/bin/install-jean-local
diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock
index 1afa379bb..9f162fc9f 100644
--- a/src-tauri/Cargo.lock
+++ b/src-tauri/Cargo.lock
@@ -2510,6 +2510,7 @@ dependencies = [
"rand 0.8.5",
"regex",
"reqwest 0.12.28",
+ "rust-embed",
"serde",
"serde_json",
"serde_yaml",
@@ -4230,6 +4231,40 @@ dependencies = [
"syn 1.0.109",
]
+[[package]]
+name = "rust-embed"
+version = "8.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "04113cb9355a377d83f06ef1f0a45b8ab8cd7d8b1288160717d66df5c7988d27"
+dependencies = [
+ "rust-embed-impl",
+ "rust-embed-utils",
+ "walkdir",
+]
+
+[[package]]
+name = "rust-embed-impl"
+version = "8.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da0902e4c7c8e997159ab384e6d0fc91c221375f6894346ae107f47dd0f3ccaa"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "rust-embed-utils",
+ "syn 2.0.117",
+ "walkdir",
+]
+
+[[package]]
+name = "rust-embed-utils"
+version = "8.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5bcdef0be6fe7f6fa333b1073c949729274b05f123a0ad7efcb8efd878e5c3b1"
+dependencies = [
+ "sha2",
+ "walkdir",
+]
+
[[package]]
name = "rust_decimal"
version = "1.40.0"
diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml
index 3062dbfe8..24f3f6045 100644
--- a/src-tauri/Cargo.toml
+++ b/src-tauri/Cargo.toml
@@ -56,6 +56,7 @@ image = { version = "0.25", default-features = false, features = ["png", "jpeg",
arboard = { version = "3", features = ["wayland-data-control"] } # Native clipboard image read (Linux WebKitGTK fallback)
if-addrs = "0.13"
chrono = { version = "0.4", default-features = false, features = ["clock"] } # Local machine time for Auto Fix active-hours window
+rust-embed = { version = "8", features = ["debug-embed"] } # Embed web assets for single-binary headless server
[target.'cfg(unix)'.dependencies]
libc = "0.2"
diff --git a/src-tauri/src/bin/jean-server.rs b/src-tauri/src/bin/jean-server.rs
new file mode 100644
index 000000000..3496a4cfd
--- /dev/null
+++ b/src-tauri/src/bin/jean-server.rs
@@ -0,0 +1,4 @@
+fn main() {
+ std::env::set_var("JEAN_HEADLESS", "1");
+ jean_lib::run()
+}
diff --git a/src-tauri/src/http_server/assets.rs b/src-tauri/src/http_server/assets.rs
new file mode 100644
index 000000000..453401f6e
--- /dev/null
+++ b/src-tauri/src/http_server/assets.rs
@@ -0,0 +1,9 @@
+use std::borrow::Cow;
+
+#[derive(rust_embed::RustEmbed)]
+#[folder = "../dist"]
+pub struct FrontendAssets;
+
+pub fn get(path: &str) -> Option> {
+ FrontendAssets::get(path).map(|file| file.data)
+}
diff --git a/src-tauri/src/http_server/mod.rs b/src-tauri/src/http_server/mod.rs
index 41a0bad46..ced8e1ef4 100644
--- a/src-tauri/src/http_server/mod.rs
+++ b/src-tauri/src/http_server/mod.rs
@@ -1,3 +1,4 @@
+pub mod assets;
pub mod auth;
pub mod dispatch;
pub mod server;
diff --git a/src-tauri/src/http_server/server.rs b/src-tauri/src/http_server/server.rs
index 01e2709a0..d9c11d60c 100644
--- a/src-tauri/src/http_server/server.rs
+++ b/src-tauri/src/http_server/server.rs
@@ -1,7 +1,7 @@
use axum::{
body::Body,
extract::{ws::WebSocketUpgrade, Path as AxumPath, Query, State},
- http::{header, StatusCode, Uri},
+ http::{header, HeaderMap, HeaderValue, StatusCode, Uri},
response::{IntoResponse, Response},
routing::get,
Json, Router,
@@ -15,8 +15,9 @@ use std::sync::Arc;
use tauri::{AppHandle, Manager};
use tokio::sync::Mutex;
use tower_http::compression::CompressionLayer;
-use tower_http::cors::{Any, CorsLayer};
+use tower_http::cors::{AllowOrigin, Any, CorsLayer};
+use super::assets;
use super::auth;
use super::websocket::handle_ws_connection;
use super::EmitExt;
@@ -136,8 +137,10 @@ async fn read_web_build_info(dist_path: &std::path::Path) -> WebBuildInfo {
WebBuildInfo::default()
}),
Err(e) => {
- log::debug!("No web build info at {}: {e}", path.display());
- WebBuildInfo::default()
+ log::debug!("No filesystem web build info at {}: {e}", path.display());
+ assets::get("jean-build.json")
+ .and_then(|data| serde_json::from_slice::(&data).ok())
+ .unwrap_or_default()
}
}
}
@@ -223,12 +226,11 @@ pub async fn start_server(
dist_path: dist_path.clone(),
};
- let cors = CorsLayer::new()
- .allow_origin(Any)
- .allow_methods(Any)
- .allow_headers(Any);
+ let cors = cors_layer_from_env();
let router = Router::new()
+ .route("/healthz", get(health_handler))
+ .route("/readyz", get(ready_handler))
.route("/ws", get(ws_handler))
.route("/api/auth", get(auth_handler))
.route("/api/init", get(init_handler))
@@ -279,18 +281,66 @@ pub async fn start_server(
})
}
+fn cors_layer_from_env() -> CorsLayer {
+ let mut layer = CorsLayer::new().allow_methods(Any).allow_headers(Any);
+ let raw = std::env::var("JEAN_ALLOWED_ORIGINS").unwrap_or_default();
+ let origins: Vec = raw
+ .split(',')
+ .map(str::trim)
+ .filter(|origin| !origin.is_empty())
+ .filter_map(|origin| match HeaderValue::from_str(origin) {
+ Ok(value) => Some(value),
+ Err(e) => {
+ log::warn!("Ignoring invalid JEAN_ALLOWED_ORIGINS entry '{origin}': {e}");
+ None
+ }
+ })
+ .collect();
+
+ if raw.trim() == "*" {
+ layer = layer.allow_origin(AllowOrigin::any());
+ } else if !origins.is_empty() {
+ layer = layer.allow_origin(AllowOrigin::list(origins));
+ }
+
+ layer
+}
+
+async fn health_handler() -> Response {
+ Json(serde_json::json!({ "ok": true })).into_response()
+}
+
+async fn ready_handler(State(state): State) -> Response {
+ let broadcaster_ready = state.app.try_state::().is_some();
+ let status = if broadcaster_ready {
+ StatusCode::OK
+ } else {
+ StatusCode::SERVICE_UNAVAILABLE
+ };
+
+ (
+ status,
+ Json(serde_json::json!({
+ "ok": broadcaster_ready,
+ "http": true,
+ "websocket_broadcaster": broadcaster_ready,
+ })),
+ )
+ .into_response()
+}
+
/// WebSocket upgrade handler with token auth.
async fn ws_handler(
ws: WebSocketUpgrade,
+ headers: HeaderMap,
Query(params): Query,
State(state): State,
) -> Response {
// Validate token (skip if token not required)
- if state.token_required {
- let provided = params.token.as_deref().unwrap_or_default();
- if !auth::validate_token(provided, &state.token) {
- return (StatusCode::UNAUTHORIZED, "Invalid token").into_response();
- }
+ if state.token_required
+ && !request_is_authorized(params.token.as_deref(), &headers, &state.token)
+ {
+ return (StatusCode::UNAUTHORIZED, "Invalid token").into_response();
}
// Get broadcast receiver for this client
@@ -308,7 +358,11 @@ async fn ws_handler(
/// Token validation endpoint. Returns 200 with { ok: true } on success,
/// or 401 with { ok: false, error: "..." } on failure.
-async fn auth_handler(Query(params): Query, State(state): State) -> Response {
+async fn auth_handler(
+ headers: HeaderMap,
+ Query(params): Query,
+ State(state): State,
+) -> Response {
let build_info = read_web_build_info(&state.dist_path).await;
// If token not required, always return success
@@ -322,8 +376,7 @@ async fn auth_handler(Query(params): Query, State(state): State, State(state): State, State(state): State) -> Response {
- if state.token_required {
- let provided = params.token.as_deref().unwrap_or_default();
- if !auth::validate_token(provided, &state.token) {
- return (StatusCode::UNAUTHORIZED, "Invalid token").into_response();
- }
+async fn version_handler(
+ headers: HeaderMap,
+ Query(params): Query,
+ State(state): State,
+) -> Response {
+ if state.token_required
+ && !request_is_authorized(params.token.as_deref(), &headers, &state.token)
+ {
+ return (StatusCode::UNAUTHORIZED, "Invalid token").into_response();
}
Json(read_web_build_info(&state.dist_path).await).into_response()
@@ -583,13 +639,16 @@ async fn reconnect_init_response(params: WsAuth, state: AppState) -> Response {
/// user lands on (project list + currently-selected project's worktrees +
/// windowed messages for the focused session). Additional data is lazy-loaded
/// by the frontend via TanStack Query hooks when the user navigates.
-async fn init_handler(Query(params): Query, State(state): State) -> Response {
+async fn init_handler(
+ headers: HeaderMap,
+ Query(params): Query,
+ State(state): State,
+) -> Response {
// Validate token (skip if token not required)
- if state.token_required {
- let provided = params.token.as_deref().unwrap_or_default();
- if !auth::validate_token(provided, &state.token) {
- return (StatusCode::UNAUTHORIZED, "Invalid token").into_response();
- }
+ if state.token_required
+ && !request_is_authorized(params.token.as_deref(), &headers, &state.token)
+ {
+ return (StatusCode::UNAUTHORIZED, "Invalid token").into_response();
}
if params.is_reconnect() {
@@ -952,15 +1011,15 @@ fn mime_from_extension(path: &std::path::Path) -> &'static str {
/// that Tauri's asset:// protocol would serve in native mode.
async fn file_handler(
AxumPath(filepath): AxumPath,
+ headers: HeaderMap,
Query(params): Query,
State(state): State,
) -> Response {
// Validate token
- if state.token_required {
- let provided = params.token.unwrap_or_default();
- if !auth::validate_token(&provided, &state.token) {
- return (StatusCode::UNAUTHORIZED, "Invalid token").into_response();
- }
+ if state.token_required
+ && !request_is_authorized(params.token.as_deref(), &headers, &state.token)
+ {
+ return (StatusCode::UNAUTHORIZED, "Invalid token").into_response();
}
// Resolve app data directory
@@ -1011,12 +1070,11 @@ async fn file_handler(
}
}
-fn validate_token(params: &WsAuth, state: &AppState) -> Result<(), Response> {
- if state.token_required {
- let provided = params.token.clone().unwrap_or_default();
- if !auth::validate_token(&provided, &state.token) {
- return Err((StatusCode::UNAUTHORIZED, "Invalid token").into_response());
- }
+fn validate_token(params: &WsAuth, headers: &HeaderMap, state: &AppState) -> Result<(), Response> {
+ if state.token_required
+ && !request_is_authorized(params.token.as_deref(), headers, &state.token)
+ {
+ return Err((StatusCode::UNAUTHORIZED, "Invalid token").into_response());
}
Ok(())
}
@@ -1054,10 +1112,11 @@ fn path_is_in_known_roots(path: &std::path::Path, roots: &[std::path::PathBuf])
/// the native asset protocol's project directory allowlist.
async fn project_file_handler(
AxumPath(filepath): AxumPath,
+ headers: HeaderMap,
Query(params): Query,
State(state): State,
) -> Response {
- if let Err(response) = validate_token(¶ms, &state) {
+ if let Err(response) = validate_token(¶ms, &headers, &state) {
return response;
}
@@ -1117,11 +1176,22 @@ async fn static_handler(uri: Uri, State(state): State) -> Response {
return (StatusCode::FORBIDDEN, "Access denied").into_response();
}
- let index_path = state.dist_path.join("index.html");
+ if let Some(response) = try_static_filesystem_response(raw_path, &state.dist_path).await {
+ return response;
+ }
+
+ embedded_static_response(raw_path)
+}
+
+async fn try_static_filesystem_response(
+ raw_path: &str,
+ dist_path: &std::path::Path,
+) -> Option {
+ let index_path = dist_path.join("index.html");
let requested_path = if raw_path.is_empty() {
index_path.clone()
} else {
- state.dist_path.join(raw_path)
+ dist_path.join(raw_path)
};
let path = match tokio::fs::metadata(&requested_path).await {
@@ -1130,21 +1200,21 @@ async fn static_handler(uri: Uri, State(state): State) -> Response {
_ => index_path.clone(),
};
- let canonical_base = match tokio::fs::canonicalize(&state.dist_path).await {
+ let canonical_base = match tokio::fs::canonicalize(dist_path).await {
Ok(path) => path,
- Err(_) => return (StatusCode::NOT_FOUND, "Frontend dist not found").into_response(),
+ Err(_) => return None,
};
let canonical_path = match tokio::fs::canonicalize(&path).await {
Ok(path) => path,
- Err(_) => return (StatusCode::NOT_FOUND, "File not found").into_response(),
+ Err(_) => return None,
};
if !canonical_path.starts_with(canonical_base) {
- return (StatusCode::FORBIDDEN, "Access denied").into_response();
+ return Some((StatusCode::FORBIDDEN, "Access denied").into_response());
}
let bytes = match tokio::fs::read(&canonical_path).await {
Ok(bytes) => bytes,
- Err(_) => return (StatusCode::NOT_FOUND, "Cannot read file").into_response(),
+ Err(_) => return None,
};
let canonical_index = index_path.canonicalize().unwrap_or(index_path);
@@ -1155,13 +1225,53 @@ async fn static_handler(uri: Uri, State(state): State) -> Response {
"public, max-age=31536000, immutable"
};
+ Some(
+ Response::builder()
+ .header(
+ header::CONTENT_TYPE,
+ static_mime_from_extension(&canonical_path),
+ )
+ .header(header::CACHE_CONTROL, cache_control)
+ .body(Body::from(bytes))
+ .unwrap()
+ .into_response(),
+ )
+}
+
+fn embedded_asset_path_for_request(raw_path: &str) -> &str {
+ if raw_path.is_empty() || !raw_path.contains('.') {
+ "index.html"
+ } else {
+ raw_path
+ }
+}
+
+fn embedded_static_response(raw_path: &str) -> Response {
+ let asset_path = embedded_asset_path_for_request(raw_path);
+ let data = assets::get(asset_path).or_else(|| assets::get("index.html"));
+
+ let Some(data) = data else {
+ return (
+ StatusCode::NOT_FOUND,
+ "Frontend assets not found. Run `bun run build` before building jean-server.",
+ )
+ .into_response();
+ };
+
+ let is_index = asset_path == "index.html";
+ let cache_control = if is_index || asset_path == "jean-build.json" {
+ "no-store"
+ } else {
+ "public, max-age=31536000, immutable"
+ };
+
Response::builder()
.header(
header::CONTENT_TYPE,
- static_mime_from_extension(&canonical_path),
+ static_mime_from_extension(std::path::Path::new(asset_path)),
)
.header(header::CACHE_CONTROL, cache_control)
- .body(Body::from(bytes))
+ .body(Body::from(data.into_owned()))
.unwrap()
}
@@ -1249,6 +1359,26 @@ fn format_http_url(host: &str, port: u16) -> String {
}
}
+fn token_from_query_or_bearer(query_token: Option<&str>, headers: &HeaderMap) -> Option {
+ if let Some(token) = query_token.filter(|token| !token.is_empty()) {
+ return Some(token.to_string());
+ }
+
+ let value = headers.get(header::AUTHORIZATION)?.to_str().ok()?.trim();
+ value
+ .strip_prefix("Bearer ")
+ .or_else(|| value.strip_prefix("bearer "))
+ .map(str::trim)
+ .filter(|token| !token.is_empty())
+ .map(ToOwned::to_owned)
+}
+
+fn request_is_authorized(query_token: Option<&str>, headers: &HeaderMap, expected: &str) -> bool {
+ token_from_query_or_bearer(query_token, headers)
+ .as_deref()
+ .is_some_and(|provided| auth::validate_token(provided, expected))
+}
+
pub fn list_bind_host_options() -> Vec {
let mut seen = HashSet::from([
"127.0.0.1".to_string(),
@@ -1367,9 +1497,11 @@ pub async fn get_server_status(app: AppHandle) -> ServerStatus {
mod tests {
use super::{
bind_host_option_label, bind_host_option_rank, display_host_for_bind_ip,
- display_ip_for_bind_ip_with_candidates, format_http_url, is_tailscale_ipv4, parse_bind_ip,
- path_is_in_known_roots, validate_bind_host,
+ display_ip_for_bind_ip_with_candidates, embedded_asset_path_for_request, format_http_url,
+ is_tailscale_ipv4, parse_bind_ip, path_is_in_known_roots, token_from_query_or_bearer,
+ validate_bind_host,
};
+ use axum::http::{HeaderMap, HeaderValue};
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
#[test]
@@ -1388,6 +1520,34 @@ mod tests {
);
}
+ #[test]
+ fn token_auth_accepts_bearer_authorization_header() {
+ let mut headers = HeaderMap::new();
+ headers.insert(
+ axum::http::header::AUTHORIZATION,
+ HeaderValue::from_static("Bearer secret-token"),
+ );
+
+ assert_eq!(
+ token_from_query_or_bearer(None, &headers),
+ Some("secret-token".to_string())
+ );
+ }
+
+ #[test]
+ fn token_auth_prefers_query_token_for_browser_compatibility() {
+ let mut headers = HeaderMap::new();
+ headers.insert(
+ axum::http::header::AUTHORIZATION,
+ HeaderValue::from_static("Bearer header-token"),
+ );
+
+ assert_eq!(
+ token_from_query_or_bearer(Some("query-token"), &headers),
+ Some("query-token".to_string())
+ );
+ }
+
#[test]
fn parse_bind_ip_rejects_invalid_values() {
let error = parse_bind_ip("tailscale").unwrap_err();
@@ -1491,6 +1651,23 @@ mod tests {
assert_eq!(format_http_url("::1", 3456), "http://[::1]:3456");
}
+ #[test]
+ fn embedded_asset_path_maps_root_and_spa_routes_to_index() {
+ assert_eq!(embedded_asset_path_for_request(""), "index.html");
+ assert_eq!(
+ embedded_asset_path_for_request("projects/abc"),
+ "index.html"
+ );
+ }
+
+ #[test]
+ fn embedded_asset_path_keeps_asset_paths() {
+ assert_eq!(
+ embedded_asset_path_for_request("assets/app.js"),
+ "assets/app.js"
+ );
+ }
+
#[test]
fn wildcard_display_urls_never_use_unspecified_hosts() {
let ipv6_url = format_http_url(
diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs
index 905b5764d..52edc94d9 100644
--- a/src-tauri/src/lib.rs
+++ b/src-tauri/src/lib.rs
@@ -698,7 +698,11 @@ fn resolve_http_server_bind_host(prefs: &AppPreferences) -> String {
#[cfg(test)]
mod tests {
- use super::{default_global_system_prompt, resolve_http_server_bind_host, AppPreferences};
+ use super::{
+ default_global_system_prompt, parse_cli_args_from, resolve_headless_bind_host,
+ resolve_headless_token_required, resolve_http_server_bind_host, validate_headless_security,
+ AppPreferences,
+ };
use serde_json::json;
#[test]
@@ -745,6 +749,97 @@ mod tests {
assert_eq!(resolve_http_server_bind_host(&prefs), "0.0.0.0");
}
+ #[test]
+ fn parse_cli_args_reads_headless_env_defaults() {
+ let env = [
+ ("JEAN_HEADLESS", "1"),
+ ("JEAN_HOST", "127.0.0.1"),
+ ("JEAN_PORT", "4567"),
+ ("JEAN_TOKEN", "secret"),
+ ];
+
+ let args = parse_cli_args_from(["jean"], env).unwrap();
+
+ assert!(args.headless);
+ assert_eq!(args.host.as_deref(), Some("127.0.0.1"));
+ assert_eq!(args.port, Some(4567));
+ assert_eq!(args.token.as_deref(), Some("secret"));
+ assert!(!args.no_token);
+ }
+
+ #[test]
+ fn cli_args_override_env_defaults() {
+ let env = [
+ ("JEAN_HEADLESS", "1"),
+ ("JEAN_HOST", "127.0.0.1"),
+ ("JEAN_PORT", "4567"),
+ ("JEAN_TOKEN", "secret"),
+ ];
+
+ let args = parse_cli_args_from(
+ [
+ "jean",
+ "--host",
+ "100.64.0.1",
+ "--port",
+ "5678",
+ "--token",
+ "cli-secret",
+ ],
+ env,
+ )
+ .unwrap();
+
+ assert_eq!(args.host.as_deref(), Some("100.64.0.1"));
+ assert_eq!(args.port, Some(5678));
+ assert_eq!(args.token.as_deref(), Some("cli-secret"));
+ }
+
+ #[test]
+ fn no_token_and_token_are_mutually_exclusive_across_env_and_cli() {
+ let err = parse_cli_args_from(["jean", "--token", "secret"], [("JEAN_NO_TOKEN", "1")])
+ .unwrap_err();
+
+ assert!(err.contains("mutually exclusive"));
+ }
+
+ #[test]
+ fn headless_defaults_to_localhost_when_no_host_is_configured() {
+ let prefs = AppPreferences::default();
+ let host = resolve_headless_bind_host(&prefs, &None);
+
+ assert_eq!(host, "127.0.0.1");
+ }
+
+ #[test]
+ fn headless_rejects_no_token_on_wildcard_host_without_unsafe_flag() {
+ let err = validate_headless_security("0.0.0.0", true, false).unwrap_err();
+
+ assert!(err.contains("Refusing to disable token authentication"));
+ }
+
+ #[test]
+ fn headless_allows_no_token_on_wildcard_host_with_unsafe_flag() {
+ assert!(validate_headless_security("0.0.0.0", true, true).is_ok());
+ }
+
+ #[test]
+ fn explicit_headless_token_requires_auth_even_when_preference_disabled() {
+ let prefs = AppPreferences {
+ http_server_token_required: false,
+ ..Default::default()
+ };
+ let overrides = super::HttpServerOverrides {
+ host: None,
+ port: None,
+ token: Some("secret".to_string()),
+ no_token: false,
+ allow_unsafe_no_token: false,
+ };
+
+ assert!(resolve_headless_token_required(&prefs, &overrides));
+ }
+
#[test]
fn migrate_default_claude_model_keeps_standard_non_1m_models() {
assert_eq!(super::migrate_default_claude_model("claude-opus-4-8"), None);
@@ -2949,7 +3044,6 @@ async fn stop_http_server(app: AppHandle) -> Result<(), String> {
async fn start_http_server_headless(
app: AppHandle,
default_port: u16,
- bind_all_interfaces: bool,
overrides: &HttpServerOverrides,
) -> Result {
use std::sync::Arc;
@@ -2960,21 +3054,16 @@ async fn start_http_server_headless(
// Port: CLI override > preference
let port = overrides.port.unwrap_or(default_port);
- // Host: CLI --host overrides bind_all_interfaces and preference
- let bind_host = if let Some(ref host) = overrides.host {
- host.clone()
- } else if bind_all_interfaces {
- "0.0.0.0".to_string()
- } else {
- resolve_http_server_bind_host(&prefs)
- };
+ // Host: CLI/env override > saved preference.
+ let bind_host = resolve_headless_bind_host(&prefs, &overrides.host);
- // Token required: --no-token overrides preference
- let token_required = if overrides.no_token {
- false
- } else {
- prefs.http_server_token_required
- };
+ let token_required = resolve_headless_token_required(&prefs, overrides);
+
+ validate_headless_security(
+ &bind_host,
+ overrides.no_token,
+ overrides.allow_unsafe_no_token,
+ )?;
// Token: CLI --token used directly (not persisted), otherwise load/generate
let token = if let Some(ref t) = overrides.token {
@@ -3383,12 +3472,14 @@ pub fn fix_macos_path() {
}
/// Parsed CLI arguments for headless server mode.
+#[derive(Debug)]
struct CliArgs {
headless: bool,
host: Option,
port: Option,
token: Option,
no_token: bool,
+ allow_unsafe_no_token: bool,
}
/// CLI overrides for HTTP server configuration.
@@ -3398,6 +3489,7 @@ struct HttpServerOverrides {
port: Option,
token: Option,
no_token: bool,
+ allow_unsafe_no_token: bool,
}
fn print_cli_help() {
@@ -3408,14 +3500,18 @@ fn print_cli_help() {
println!();
println!("Options:");
println!(" --headless Run without GUI (HTTP server only)");
- println!(
- " --host Bind to an IP address or localhost (default: 0.0.0.0 in headless)"
- );
+ println!(" --host Bind to an IP address or localhost (default: 127.0.0.1)");
println!(" --port HTTP server port (overrides saved preference)");
println!(" --token Use specific auth token (not persisted)");
println!(" --no-token Disable token authentication");
+ println!(" --allow-unsafe-no-token");
+ println!(" Allow --no-token with a wildcard bind host");
println!(" --help Show this help message");
println!(" --version Show version");
+ println!();
+ println!("Environment:");
+ println!(" JEAN_HEADLESS=1 JEAN_HOST JEAN_PORT JEAN_TOKEN JEAN_NO_TOKEN=1");
+ println!(" JEAN_ALLOW_UNSAFE_NO_TOKEN=1");
}
fn parse_cli_args() -> CliArgs {
@@ -3430,51 +3526,107 @@ fn parse_cli_args() -> CliArgs {
std::process::exit(0);
}
- let headless = args.iter().any(|a| a == "--headless");
- let no_token = args.iter().any(|a| a == "--no-token");
+ match parse_cli_args_from(args, std::env::vars()) {
+ Ok(parsed) => parsed,
+ Err(e) => {
+ eprintln!("Error: {e}");
+ std::process::exit(1);
+ }
+ }
+}
+
+fn env_truthy(value: Option<&str>) -> bool {
+ matches!(
+ value.map(|v| v.trim().to_ascii_lowercase()),
+ Some(v) if matches!(v.as_str(), "1" | "true" | "yes" | "on")
+ )
+}
- let mut host = None;
- let mut port = None;
- let mut token = None;
+fn parse_cli_args_from(args: A, env: E) -> Result
+where
+ A: IntoIterator,
+ A::Item: AsRef,
+ E: IntoIterator- ,
+ K: AsRef,
+ V: AsRef,
+{
+ let args: Vec = args
+ .into_iter()
+ .map(|arg| arg.as_ref().to_string())
+ .collect();
+ let env: std::collections::HashMap = env
+ .into_iter()
+ .map(|(k, v)| (k.as_ref().to_string(), v.as_ref().to_string()))
+ .collect();
+
+ let mut headless = env_truthy(env.get("JEAN_HEADLESS").map(String::as_str));
+ let mut no_token = env_truthy(env.get("JEAN_NO_TOKEN").map(String::as_str));
+ let mut allow_unsafe_no_token =
+ env_truthy(env.get("JEAN_ALLOW_UNSAFE_NO_TOKEN").map(String::as_str));
+ let mut host = env
+ .get("JEAN_HOST")
+ .map(|h| h.trim().to_string())
+ .filter(|h| !h.is_empty());
+ let mut port = match env
+ .get("JEAN_PORT")
+ .map(|p| p.trim())
+ .filter(|p| !p.is_empty())
+ {
+ Some(value) => Some(
+ value
+ .parse::()
+ .map_err(|_| "JEAN_PORT must be a valid port number (1-65535)".to_string())?,
+ ),
+ None => None,
+ };
+ let mut token = env
+ .get("JEAN_TOKEN")
+ .map(|t| t.trim().to_string())
+ .filter(|t| !t.is_empty());
let mut iter = args.iter().skip(1);
while let Some(arg) = iter.next() {
match arg.as_str() {
+ "--headless" => {
+ headless = true;
+ }
"--host" => {
host = iter.next().cloned();
- if host.is_none() {
- eprintln!("Error: --host requires an address argument");
- std::process::exit(1);
- }
+ host.as_ref()
+ .filter(|h| !h.trim().is_empty())
+ .ok_or_else(|| "--host requires an address argument".to_string())?;
}
"--port" => {
if let Some(val) = iter.next() {
match val.parse::() {
Ok(p) => port = Some(p),
Err(_) => {
- eprintln!("Error: --port requires a valid port number (1-65535)");
- std::process::exit(1);
+ return Err("--port requires a valid port number (1-65535)".to_string());
}
}
} else {
- eprintln!("Error: --port requires a port number argument");
- std::process::exit(1);
+ return Err("--port requires a port number argument".to_string());
}
}
"--token" => {
token = iter.next().cloned();
- if token.is_none() {
- eprintln!("Error: --token requires a token argument");
- std::process::exit(1);
- }
+ token
+ .as_ref()
+ .filter(|t| !t.trim().is_empty())
+ .ok_or_else(|| "--token requires a token argument".to_string())?;
+ }
+ "--no-token" => {
+ no_token = true;
+ }
+ "--allow-unsafe-no-token" => {
+ allow_unsafe_no_token = true;
}
_ => {} // ignore unknown flags (Tauri/OS may pass their own)
}
}
if token.is_some() && no_token {
- eprintln!("Error: --token and --no-token are mutually exclusive");
- std::process::exit(1);
+ return Err("--token and --no-token are mutually exclusive".to_string());
}
if !headless && (host.is_some() || port.is_some() || token.is_some() || no_token) {
@@ -3483,12 +3635,50 @@ fn parse_cli_args() -> CliArgs {
);
}
- CliArgs {
+ Ok(CliArgs {
headless,
host,
port,
token,
no_token,
+ allow_unsafe_no_token,
+ })
+}
+
+fn resolve_headless_bind_host(prefs: &AppPreferences, override_host: &Option) -> String {
+ override_host
+ .as_deref()
+ .and_then(|host| normalize_http_bind_host(Some(host)))
+ .unwrap_or_else(|| resolve_http_server_bind_host(prefs))
+}
+
+fn is_wildcard_bind_host(host: &str) -> bool {
+ matches!(host.trim(), "0.0.0.0" | "::")
+}
+
+fn validate_headless_security(
+ bind_host: &str,
+ no_token: bool,
+ allow_unsafe_no_token: bool,
+) -> Result<(), String> {
+ if no_token && is_wildcard_bind_host(bind_host) && !allow_unsafe_no_token {
+ return Err(
+ "Refusing to disable token authentication while binding to all interfaces. Use a token, bind to 127.0.0.1, or pass --allow-unsafe-no-token.".to_string(),
+ );
+ }
+ Ok(())
+}
+
+fn resolve_headless_token_required(
+ prefs: &AppPreferences,
+ overrides: &HttpServerOverrides,
+) -> bool {
+ if overrides.no_token {
+ false
+ } else if overrides.token.is_some() {
+ true
+ } else {
+ prefs.http_server_token_required
}
}
@@ -3607,7 +3797,7 @@ pub fn run() {
// - JEAN_FORCE_X11=1 to force X11 backend in non-AppImage runs (default: no)
// - WEBKIT_DISABLE_COMPOSITING_MODE=0 to re-enable GPU compositing (risky)
#[cfg(target_os = "linux")]
- {
+ if !headless {
log::trace!("Setting WebKit compatibility fixes for Linux");
// Detect if running inside an AppImage
@@ -4142,6 +4332,7 @@ pub fn run() {
port: cli_args.port,
token: cli_args.token,
no_token: cli_args.no_token,
+ allow_unsafe_no_token: cli_args.allow_unsafe_no_token,
};
tauri::async_runtime::spawn(async move {
match load_preferences(app_handle_http.clone()).await {
@@ -4151,7 +4342,6 @@ pub fn run() {
match start_http_server_headless(
app_handle_http,
port,
- headless, // In headless mode, bind to 0.0.0.0
&server_overrides,
)
.await
@@ -4631,7 +4821,13 @@ pub fn run() {
opencode_server::stop_opencode_server,
opencode_server::get_opencode_server_status,
])
- .build(tauri::generate_context!())
+ .build({
+ let mut context = tauri::generate_context!();
+ if headless {
+ context.config_mut().app.windows.clear();
+ }
+ context
+ })
.expect("error building tauri application")
.run(move |app_handle, event| match &event {
tauri::RunEvent::Exit => {
From 57e7fc4ca49ef054eed0949a1e4c6a5306e94f29 Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Mon, 29 Jun 2026 12:10:39 +0200
Subject: [PATCH 009/359] fix(headless): reject disabled tokens on wildcard
hosts
Reject wildcard headless binds when token auth is disabled via saved
preferences, not only when --no-token is passed.
Update headless docs for jean-server and remove the Lima VM config.
---
docs/headless-server.md | 7 +-
lima/jean-headless.yaml | 138 ----------------------------------------
src-tauri/src/lib.rs | 29 ++++++++-
3 files changed, 29 insertions(+), 145 deletions(-)
delete mode 100644 lima/jean-headless.yaml
diff --git a/docs/headless-server.md b/docs/headless-server.md
index 823a926f8..ef3dc548a 100644
--- a/docs/headless-server.md
+++ b/docs/headless-server.md
@@ -47,11 +47,11 @@ frontend code changes.
| `--host ` | `JEAN_HOST` | saved preference, normally `127.0.0.1` |
| `--port ` | `JEAN_PORT` | `3456` |
| `--token ` | `JEAN_TOKEN` | saved/generated token |
-| `--no-token` | `JEAN_NO_TOKEN=1` | token required |
+| `--no-token` | `JEAN_NO_TOKEN=1` | off |
| `--allow-unsafe-no-token` | `JEAN_ALLOW_UNSAFE_NO_TOKEN=1` | off |
| n/a | `JEAN_ALLOWED_ORIGINS` | same-origin only |
-`--token` and `--no-token` are mutually exclusive. Jean rejects `--no-token` with `--host 0.0.0.0` or `--host ::` unless `--allow-unsafe-no-token` is also set.
+By default a token is required (using `--token`, `JEAN_TOKEN`, or an auto-generated one); pass `--no-token` to disable it. `--token` and `--no-token` are mutually exclusive. Jean rejects `--no-token` with `--host 0.0.0.0` or `--host ::` unless `--allow-unsafe-no-token` is also set.
## Health checks
@@ -78,11 +78,10 @@ Wants=network-online.target
[Service]
Type=simple
User=jean
-Environment=JEAN_HEADLESS=1
Environment=JEAN_HOST=127.0.0.1
Environment=JEAN_PORT=3456
Environment=JEAN_TOKEN=change-me-long-random-token
-ExecStart=/usr/local/bin/jean --headless
+ExecStart=/usr/local/bin/jean-server
Restart=on-failure
RestartSec=5
diff --git a/lima/jean-headless.yaml b/lima/jean-headless.yaml
deleted file mode 100644
index 9f80f005a..000000000
--- a/lima/jean-headless.yaml
+++ /dev/null
@@ -1,138 +0,0 @@
-# yaml-language-server: $schema=https://raw.githubusercontent.com/lima-vm/lima/master/schemas/lima.yaml.json
-# Lima VM for testing Jean headless mode from the current local checkout.
-#
-# Usage:
-# limactl start --name jean-headless ./lima/jean-headless.yaml
-# limactl shell jean-headless sudo cat /etc/jean-headless.env
-# open "http://127.0.0.1:3456/?token="
-#
-# Reinstall the latest local source after host-side changes:
-# limactl shell jean-headless /usr/local/bin/install-jean-local
-
-images:
- - location: "https://cloud-images.ubuntu.com/jammy/current/jammy-server-cloudimg-arm64.img"
- arch: "aarch64"
- - location: "https://cloud-images.ubuntu.com/jammy/current/jammy-server-cloudimg-amd64.img"
- arch: "x86_64"
-
-cpus: 4
-memory: "8GiB"
-disk: "40GiB"
-
-mounts:
- - location: "{{.Dir}}/.."
- mountPoint: "/workspaces/jean"
- writable: true
-
-portForwards:
- - guestPort: 3456
- hostIP: "127.0.0.1"
- hostPort: 3456
-
-provision:
- - mode: system
- script: |
- #!/usr/bin/env bash
- set -euo pipefail
-
- export DEBIAN_FRONTEND=noninteractive
- apt-get update
- apt-get install -y \
- build-essential \
- ca-certificates \
- curl \
- git \
- libayatana-appindicator3-dev \
- libfuse2 \
- libssl-dev \
- libwebkit2gtk-4.1-dev \
- patchelf \
- pkg-config \
- xdg-utils
-
- cat >/usr/local/bin/install-jean-local <<'SCRIPT'
- #!/usr/bin/env bash
- set -euo pipefail
-
- export BUN_INSTALL="${HOME}/.bun"
- export PATH="${BUN_INSTALL}/bin:${HOME}/.cargo/bin:${PATH}"
-
- if ! command -v bun >/dev/null 2>&1; then
- curl -fsSL https://bun.sh/install | bash
- fi
-
- if ! command -v cargo >/dev/null 2>&1; then
- curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
- | sh -s -- -y --profile minimal
- fi
-
- cd /workspaces/jean
- bun install --frozen-lockfile
- bun run build
-
- cd src-tauri
- cargo build --bin jean-server --profile release-fast
-
- sudo install -m 0755 target/release-fast/jean-server /usr/local/bin/jean-server
- sudo ln -sf /usr/local/bin/jean-server /usr/local/bin/jean
-
- if [ ! -f /etc/jean-headless.env ]; then
- token="$(openssl rand -base64 32)"
- sudo tee /etc/jean-headless.env >/dev/null </dev/null <<'UNIT'
- [Unit]
- Description=Jean headless server
- After=network-online.target
- Wants=network-online.target
-
- [Service]
- Type=simple
- EnvironmentFile=/etc/jean-headless.env
- ExecStart=/usr/local/bin/jean-server
- Restart=on-failure
- RestartSec=5
-
- [Install]
- WantedBy=multi-user.target
- UNIT
-
- sudo systemctl daemon-reload
- sudo systemctl enable --now jean-headless.service
- sudo systemctl restart jean-headless.service
- SCRIPT
- chmod 0755 /usr/local/bin/install-jean-local
-
- - mode: user
- script: |
- #!/usr/bin/env bash
- set -euo pipefail
- /usr/local/bin/install-jean-local
-
-probes:
- - script: |
- #!/usr/bin/env bash
- set -euo pipefail
- curl -fsS http://127.0.0.1:3456/healthz >/dev/null
- hint: |
- Jean headless did not become healthy yet. Check logs with:
- limactl shell jean-headless journalctl -u jean-headless -n 100 --no-pager
-
-message: |
- Jean headless is running in the VM and forwarded to localhost:3456.
-
- Get the token:
- limactl shell {{.Name}} sudo sed -n 's/^JEAN_TOKEN=//p' /etc/jean-headless.env
-
- Open:
- http://127.0.0.1:3456/?token=
-
- Reinstall latest local source:
- limactl shell {{.Name}} /usr/local/bin/install-jean-local
diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs
index 52edc94d9..1a856db4d 100644
--- a/src-tauri/src/lib.rs
+++ b/src-tauri/src/lib.rs
@@ -840,6 +840,29 @@ mod tests {
assert!(resolve_headless_token_required(&prefs, &overrides));
}
+ #[test]
+ fn headless_rejects_disabled_token_preference_on_wildcard_host() {
+ let prefs = AppPreferences {
+ http_server_token_required: false,
+ ..Default::default()
+ };
+ let overrides = super::HttpServerOverrides {
+ host: Some("0.0.0.0".to_string()),
+ port: None,
+ token: None,
+ no_token: false,
+ allow_unsafe_no_token: false,
+ };
+
+ let bind_host = resolve_headless_bind_host(&prefs, &overrides.host);
+ let token_required = resolve_headless_token_required(&prefs, &overrides);
+ let err =
+ validate_headless_security(&bind_host, !token_required, overrides.allow_unsafe_no_token)
+ .unwrap_err();
+
+ assert!(err.contains("Refusing to disable token authentication"));
+ }
+
#[test]
fn migrate_default_claude_model_keeps_standard_non_1m_models() {
assert_eq!(super::migrate_default_claude_model("claude-opus-4-8"), None);
@@ -3061,7 +3084,7 @@ async fn start_http_server_headless(
validate_headless_security(
&bind_host,
- overrides.no_token,
+ !token_required,
overrides.allow_unsafe_no_token,
)?;
@@ -3658,10 +3681,10 @@ fn is_wildcard_bind_host(host: &str) -> bool {
fn validate_headless_security(
bind_host: &str,
- no_token: bool,
+ token_auth_disabled: bool,
allow_unsafe_no_token: bool,
) -> Result<(), String> {
- if no_token && is_wildcard_bind_host(bind_host) && !allow_unsafe_no_token {
+ if token_auth_disabled && is_wildcard_bind_host(bind_host) && !allow_unsafe_no_token {
return Err(
"Refusing to disable token authentication while binding to all interfaces. Use a token, bind to 127.0.0.1, or pass --allow-unsafe-no-token.".to_string(),
);
From 4c41c9f584308913701da2e6b503cff3f5316a1d Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Mon, 29 Jun 2026 12:26:04 +0200
Subject: [PATCH 010/359] chore(cargo): set default binary to jean
---
src-tauri/Cargo.toml | 1 +
1 file changed, 1 insertion(+)
diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml
index 24f3f6045..9c39ef896 100644
--- a/src-tauri/Cargo.toml
+++ b/src-tauri/Cargo.toml
@@ -4,6 +4,7 @@ version = "0.1.60"
description = "Jean - AI Assistant"
authors = ["Andras Bacsai"]
edition = "2021"
+default-run = "jean"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
From 4afe90fd986e6ec1d04a21f3c101fe179eb44eab Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Mon, 29 Jun 2026 15:22:01 +0200
Subject: [PATCH 011/359] ci(server): publish headless server artifacts
Add a release workflow for Linux server binaries and GHCR Docker images.
Build the Docker image with Xvfb so jean-server can run headlessly.
---
.dockerignore | 10 ++
.github/workflows/server-release.yml | 158 +++++++++++++++++++++++++++
Dockerfile.server | 46 ++++++++
docs/headless-server.md | 19 +++-
package.json | 3 +-
scripts/docker-entrypoint.sh | 10 ++
scripts/server-ci-assets.test.mjs | 38 +++++++
7 files changed, 277 insertions(+), 7 deletions(-)
create mode 100644 .dockerignore
create mode 100644 .github/workflows/server-release.yml
create mode 100644 Dockerfile.server
create mode 100755 scripts/docker-entrypoint.sh
create mode 100644 scripts/server-ci-assets.test.mjs
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 000000000..96dc28b36
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,10 @@
+.git
+.github
+node_modules
+src-tauri/target
+dist
+.DS_Store
+*.log
+.env
+screenshots
+tmp
diff --git a/.github/workflows/server-release.yml b/.github/workflows/server-release.yml
new file mode 100644
index 000000000..0ff854b53
--- /dev/null
+++ b/.github/workflows/server-release.yml
@@ -0,0 +1,158 @@
+name: Server Release
+
+on:
+ release:
+ types: [published]
+ workflow_dispatch:
+ inputs:
+ version:
+ description: 'Release version/tag for manual runs (for example v0.1.60)'
+ required: false
+ type: string
+
+permissions:
+ contents: write
+ packages: write
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+env:
+ CARGO_TERM_COLOR: always
+ RUST_BACKTRACE: 1
+ REGISTRY: ghcr.io
+ IMAGE_NAME: ${{ github.repository }}-server
+
+jobs:
+ metadata:
+ name: Resolve version
+ runs-on: ubuntu-latest
+ outputs:
+ tag: ${{ steps.version.outputs.tag }}
+ version: ${{ steps.version.outputs.version }}
+ steps:
+ - name: Resolve release version
+ id: version
+ env:
+ RELEASE_TAG: ${{ github.event.release.tag_name || inputs.version }}
+ run: |
+ if [ -z "$RELEASE_TAG" ]; then
+ RELEASE_TAG="manual-${GITHUB_SHA::7}"
+ fi
+
+ VERSION="${RELEASE_TAG#v}"
+ echo "tag=$RELEASE_TAG" >> "$GITHUB_OUTPUT"
+ echo "version=$VERSION" >> "$GITHUB_OUTPUT"
+ echo "Building Jean server artifacts for $RELEASE_TAG"
+
+ build-binaries:
+ name: Build server binary (${{ matrix.arch }})
+ needs: metadata
+ runs-on: ${{ matrix.runner }}
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - runner: ubuntu-22.04
+ arch: amd64
+ asset: jean-server-linux-amd64
+ - runner: ubuntu-22.04-arm
+ arch: arm64
+ asset: jean-server-linux-arm64
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v5
+
+ - name: Install Linux dependencies
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf xdg-utils
+
+ - name: Setup Bun
+ uses: oven-sh/setup-bun@v2
+
+ - name: Install Rust stable
+ uses: dtolnay/rust-toolchain@stable
+
+ - name: Rust cache
+ uses: swatinem/rust-cache@v2
+ with:
+ workspaces: './src-tauri -> target'
+ shared-key: server-${{ matrix.arch }}
+
+ - name: Install frontend dependencies
+ run: bun install --frozen-lockfile
+
+ - name: Build frontend bundle
+ run: bun run build
+
+ - name: Build jean-server
+ run: cd src-tauri && cargo build --release --bin jean-server
+
+ - name: Package binary
+ env:
+ VERSION: ${{ needs.metadata.outputs.version }}
+ ASSET: ${{ matrix.asset }}
+ run: |
+ mkdir -p artifacts
+ cp src-tauri/target/release/jean-server "artifacts/${ASSET}"
+ tar -C artifacts -czf "artifacts/${ASSET}-${VERSION}.tar.gz" "${ASSET}"
+ shasum -a 256 "artifacts/${ASSET}-${VERSION}.tar.gz" > "artifacts/${ASSET}-${VERSION}.tar.gz.sha256"
+
+ - name: Upload workflow artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: jean-server-linux-${{ matrix.arch }}
+ path: |
+ artifacts/*.tar.gz
+ artifacts/*.sha256
+
+ - name: Upload release assets
+ if: github.event_name == 'release'
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ RELEASE_TAG: ${{ needs.metadata.outputs.tag }}
+ run: |
+ gh release upload "$RELEASE_TAG" artifacts/*.tar.gz artifacts/*.sha256 --clobber
+
+ docker:
+ name: Build and publish Docker image
+ needs: metadata
+ runs-on: ubuntu-22.04
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v5
+
+ - name: Set up QEMU
+ uses: docker/setup-qemu-action@v3
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Log in to GitHub Container Registry
+ uses: docker/login-action@v3
+ with:
+ registry: ${{ env.REGISTRY }}
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Docker metadata
+ id: meta
+ uses: docker/metadata-action@v5
+ with:
+ images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
+ tags: |
+ type=raw,value=latest,enable=${{ github.event_name == 'release' }}
+ type=raw,value=${{ needs.metadata.outputs.tag }}
+ type=sha,prefix=sha-
+
+ - name: Build and push Docker image
+ uses: docker/build-push-action@v6
+ with:
+ context: .
+ file: Dockerfile.server
+ platforms: linux/amd64,linux/arm64
+ push: true
+ tags: ${{ steps.meta.outputs.tags }}
+ labels: ${{ steps.meta.outputs.labels }}
diff --git a/Dockerfile.server b/Dockerfile.server
new file mode 100644
index 000000000..577d33caf
--- /dev/null
+++ b/Dockerfile.server
@@ -0,0 +1,46 @@
+FROM oven/bun:1.2.22 AS frontend
+WORKDIR /app
+COPY package.json bun.lock ./
+RUN bun install --frozen-lockfile
+COPY . .
+RUN bun run build
+
+FROM rust:1-bookworm AS builder
+WORKDIR /app
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ libwebkit2gtk-4.1-dev \
+ libappindicator3-dev \
+ librsvg2-dev \
+ patchelf \
+ xdg-utils \
+ && rm -rf /var/lib/apt/lists/*
+COPY src-tauri ./src-tauri
+COPY --from=frontend /app/dist ./dist
+RUN cd src-tauri && RUSTC_WRAPPER= cargo build --release --bin jean-server
+
+FROM debian:bookworm-slim AS runtime
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ ca-certificates \
+ curl \
+ git \
+ openssh-client \
+ libwebkit2gtk-4.1-0 \
+ libayatana-appindicator3-1 \
+ librsvg2-2 \
+ xdg-utils \
+ xvfb \
+ xauth \
+ && rm -rf /var/lib/apt/lists/* \
+ && useradd --create-home --shell /bin/bash jean \
+ && mkdir -p /home/jean/.cache /home/jean/.config /home/jean/.local/share/com.jean.desktop /tmp/.X11-unix \
+ && chown -R jean:jean /home/jean \
+ && chmod 1777 /tmp/.X11-unix
+COPY --from=builder /app/src-tauri/target/release/jean-server /usr/local/bin/jean-server
+COPY scripts/docker-entrypoint.sh /usr/local/bin/jean-server-entrypoint
+RUN chmod +x /usr/local/bin/jean-server-entrypoint
+USER jean
+ENV JEAN_HOST=0.0.0.0 \
+ JEAN_PORT=3456
+EXPOSE 3456
+VOLUME ["/home/jean/.local/share/com.jean.desktop"]
+ENTRYPOINT ["/usr/local/bin/jean-server-entrypoint"]
diff --git a/docs/headless-server.md b/docs/headless-server.md
index ef3dc548a..c8e704db0 100644
--- a/docs/headless-server.md
+++ b/docs/headless-server.md
@@ -1,6 +1,11 @@
# Jean Headless Server
-Jean can run as a browser-accessible server without creating a Tauri WebView/window. This is intended first for Linux VPS, systemd, Docker, and Tailscale deployments.
+Jean can run as a browser-accessible server without creating a visible Tauri WebView/window. This is intended first for Linux VPS, systemd, Docker, and Tailscale deployments.
+
+On Linux, the Tauri/GTK runtime still needs a display backend to initialize even
+when Jean is headless. The Docker image starts `Xvfb` automatically. For a raw
+Linux binary on a server without `DISPLAY`, run it under `xvfb-run` or provide an
+X/Wayland display.
## Start locally
@@ -15,8 +20,7 @@ cargo build --bin jean --bin jean-server
```
```bash
-unset DISPLAY WAYLAND_DISPLAY
-./target/debug/jean --headless --host 127.0.0.1 --port 3456
+xvfb-run -a ./target/debug/jean --headless --host 127.0.0.1 --port 3456
curl http://127.0.0.1:3456/healthz
```
@@ -32,7 +36,7 @@ For a production single-binary server:
bun run build
cd src-tauri
cargo build --release --bin jean-server
-./target/release/jean-server --host 0.0.0.0 --port 3456 --token "$JEAN_TOKEN"
+xvfb-run -a ./target/release/jean-server --host 0.0.0.0 --port 3456 --token "$JEAN_TOKEN"
```
After `cargo build --release --bin jean-server` finishes, `dist/` is no longer
@@ -81,7 +85,7 @@ User=jean
Environment=JEAN_HOST=127.0.0.1
Environment=JEAN_PORT=3456
Environment=JEAN_TOKEN=change-me-long-random-token
-ExecStart=/usr/local/bin/jean-server
+ExecStart=/usr/bin/xvfb-run -a /usr/local/bin/jean-server
Restart=on-failure
RestartSec=5
@@ -91,6 +95,9 @@ WantedBy=multi-user.target
## Docker notes
+- The server Docker image is published by the Server Release workflow as
+ `ghcr.io//-server:`.
+- The image starts `Xvfb` internally before launching `jean-server`.
- Bind to `0.0.0.0` inside the container, but keep token auth enabled.
- Mount Jean's app-data directory as a volume so projects, preferences, and sessions persist.
- Put TLS/auth in front of the container for internet exposure.
@@ -105,7 +112,7 @@ docker run --rm \
-e JEAN_TOKEN=change-me-long-random-token \
-p 127.0.0.1:3456:3456 \
-v jean-data:/home/jean/.local/share/com.jean.desktop \
- jean:latest
+ ghcr.io/OWNER/REPO-server:latest
```
## Reverse proxy
diff --git a/package.json b/package.json
index 4eaaa65ef..2fdbf7a9c 100644
--- a/package.json
+++ b/package.json
@@ -49,7 +49,8 @@
"task:complete": "node scripts/complete-task.js",
"task:rename-done": "node scripts/complete-task.js --rename-existing",
"version:bump": "node scripts/bump-version.js",
- "release:assert-version": "node scripts/assert-release-version.js"
+ "release:assert-version": "node scripts/assert-release-version.js",
+ "test:server-ci": "node --test scripts/server-ci-assets.test.mjs"
},
"dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "^1.8.1",
diff --git a/scripts/docker-entrypoint.sh b/scripts/docker-entrypoint.sh
new file mode 100755
index 000000000..ea1fe9727
--- /dev/null
+++ b/scripts/docker-entrypoint.sh
@@ -0,0 +1,10 @@
+#!/bin/sh
+set -eu
+
+if [ -z "${DISPLAY:-}" ]; then
+ Xvfb :99 -screen 0 1280x1024x24 -nolisten tcp &
+ export DISPLAY=:99
+ sleep 0.5
+fi
+
+exec jean-server "$@"
diff --git a/scripts/server-ci-assets.test.mjs b/scripts/server-ci-assets.test.mjs
new file mode 100644
index 000000000..f90697612
--- /dev/null
+++ b/scripts/server-ci-assets.test.mjs
@@ -0,0 +1,38 @@
+import assert from 'node:assert/strict'
+import { readFileSync } from 'node:fs'
+import test from 'node:test'
+
+const read = path => readFileSync(path, 'utf8')
+
+test('server release workflow builds binaries and publishes docker image', () => {
+ const workflow = read('.github/workflows/server-release.yml')
+
+ assert.match(workflow, /cargo build --release --bin jean-server/)
+ assert.match(workflow, /jean-server-linux-amd64/)
+ assert.match(workflow, /jean-server-linux-arm64/)
+ assert.match(workflow, /docker\/build-push-action@v6/)
+ assert.match(workflow, /ghcr\.io/)
+})
+
+test('Dockerfile builds and runs jean-server headlessly as non-root user', () => {
+ const dockerfile = read('Dockerfile.server')
+
+ assert.match(dockerfile, /bun run build/)
+ assert.match(dockerfile, /cargo build --release --bin jean-server/)
+ assert.match(dockerfile, /USER jean/)
+ assert.match(dockerfile, /chown -R jean:jean \/home\/jean/)
+ assert.match(dockerfile, /JEAN_HOST=0\.0\.0\.0/)
+ assert.match(dockerfile, /xvfb/)
+ assert.match(dockerfile, /jean-server-entrypoint/)
+ assert.match(dockerfile, /ENTRYPOINT \["\/usr\/local\/bin\/jean-server-entrypoint"\]/)
+})
+
+
+test('Docker entrypoint starts Xvfb before jean-server', () => {
+ const entrypoint = read('scripts/docker-entrypoint.sh')
+
+ assert.match(entrypoint, /Xvfb :99/)
+ assert.match(entrypoint, /export DISPLAY=:99/)
+ assert.match(entrypoint, /sleep 0\.5/)
+ assert.match(entrypoint, /exec jean-server "\$@"/)
+})
From 014a24e6d566eef4778c880960c2929a204572fc Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Mon, 29 Jun 2026 15:22:40 +0200
Subject: [PATCH 012/359] chore(release): bump version to 0.1.61
---
package.json | 2 +-
src-tauri/Cargo.toml | 2 +-
src-tauri/tauri.conf.json | 2 +-
3 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/package.json b/package.json
index 2fdbf7a9c..1a33d37e0 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "jean",
"private": true,
- "version": "0.1.60",
+ "version": "0.1.61",
"type": "module",
"author": "Andras Bacsai",
"copyright": "Copyright © 2025 Andras Bacsai. All rights reserved.",
diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml
index 9c39ef896..2342c0a6c 100644
--- a/src-tauri/Cargo.toml
+++ b/src-tauri/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "jean"
-version = "0.1.60"
+version = "0.1.61"
description = "Jean - AI Assistant"
authors = ["Andras Bacsai"]
edition = "2021"
diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json
index e17a44615..01d694a01 100644
--- a/src-tauri/tauri.conf.json
+++ b/src-tauri/tauri.conf.json
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Jean",
- "version": "0.1.60",
+ "version": "0.1.61",
"identifier": "com.jean.desktop",
"build": {
"removeUnusedCommands": true,
From 309ed13a82e277aaff8b1fcd14301929be616919 Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Mon, 29 Jun 2026 15:29:09 +0200
Subject: [PATCH 013/359] fix(wsl): find tools in common home bin paths
---
src-tauri/src/platform/wsl.rs | 52 +++++++++++++++++++++++++----------
1 file changed, 37 insertions(+), 15 deletions(-)
diff --git a/src-tauri/src/platform/wsl.rs b/src-tauri/src/platform/wsl.rs
index 64298d5bd..5d9a5aad3 100644
--- a/src-tauri/src/platform/wsl.rs
+++ b/src-tauri/src/platform/wsl.rs
@@ -346,25 +346,39 @@ fn select_wsl_which_candidate(output: &str, jean_managed: Option<&str>) -> Optio
.map(ToString::to_string)
}
-/// Resolve the Unix path of a tool inside a WSL distro via `type -P -a`
-/// in a login shell, optionally excluding Jean's managed binary.
-#[cfg(windows)]
-pub fn wsl_which(distro: &str, tool: &str, jean_managed: Option<&str>) -> Option {
- let script = if let Some(jean_path) = jean_managed.map(str::trim).filter(|p| !p.is_empty()) {
+#[cfg(any(windows, test))]
+fn build_wsl_which_script(tool: &str, jean_managed: Option<&str>) -> String {
+ let jean_init = if let Some(jean_path) = jean_managed.map(str::trim).filter(|p| !p.is_empty()) {
format!(
- "jean={jean}; \
- jean_real=$(readlink -f -- \"$jean\" 2>/dev/null || printf '%s' \"$jean\"); \
- while IFS= read -r candidate; do \
- candidate_real=$(readlink -f -- \"$candidate\" 2>/dev/null || printf '%s' \"$candidate\"); \
- if [ \"$candidate_real\" != \"$jean_real\" ]; then printf '%s\\n' \"$candidate\"; exit 0; fi; \
- done < <(type -P -a {tool} 2>/dev/null); \
- exit 1",
- jean = shell_single_quote(jean_path),
- tool = shell_single_quote(tool),
+ "jean={}; jean_real=$(readlink -f -- \"$jean\" 2>/dev/null || printf '%s' \"$jean\");",
+ shell_single_quote(jean_path)
)
} else {
- format!("type -P -a {}", shell_single_quote(tool))
+ "jean=''; jean_real='';".to_string()
};
+
+ format!(
+ "{jean_init} \
+ tool={tool}; \
+ emit_candidate() {{ \
+ candidate=\"$1\"; \
+ [ -n \"$candidate\" ] || return 0; \
+ [ -x \"$candidate\" ] || return 0; \
+ candidate_real=$(readlink -f -- \"$candidate\" 2>/dev/null || printf '%s' \"$candidate\"); \
+ if [ -z \"$jean_real\" ] || [ \"$candidate_real\" != \"$jean_real\" ]; then printf '%s\\n' \"$candidate\"; exit 0; fi; \
+ }}; \
+ while IFS= read -r candidate; do emit_candidate \"$candidate\"; done < <(type -P -a \"$tool\" 2>/dev/null); \
+ for dir in \"$HOME/.local/bin\" \"$HOME/.npm-global/bin\" \"$HOME/.bun/bin\"; do emit_candidate \"$dir/$tool\"; done; \
+ exit 1",
+ tool = shell_single_quote(tool),
+ )
+}
+
+/// Resolve the Unix path of a tool inside a WSL distro via `type -P -a`
+/// in a login shell, optionally excluding Jean's managed binary.
+#[cfg(windows)]
+pub fn wsl_which(distro: &str, tool: &str, jean_managed: Option<&str>) -> Option {
+ let script = build_wsl_which_script(tool, jean_managed);
let output = silent_command("wsl.exe")
.args(["-d", distro, "--", "bash", "-lc", &script])
.output()
@@ -821,6 +835,14 @@ mod tests {
);
}
+ #[test]
+ fn build_wsl_which_script_falls_back_to_home_local_bin() {
+ let script = build_wsl_which_script("claude", None);
+
+ assert!(script.contains("$HOME/.local/bin"));
+ assert!(script.contains("[ -x \"$candidate\" ]"));
+ }
+
#[test]
fn test_wsl_remove_path_script_quotes_path() {
let script = wsl_remove_path_script("/home/o'hara/.local/share/jean/claude-cli");
From 029d1bb39e69d87cba2de4ebac14526996448f29 Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Mon, 29 Jun 2026 15:29:12 +0200
Subject: [PATCH 014/359] fix(mcp): support Windows local IPC
---
src-tauri/src/chat/jean_mcp.rs | 2 +-
src-tauri/src/jean_mcp_socket.rs | 139 ++++++++++++++++++++++++++++++-
src-tauri/src/jean_mcp_stdio.rs | 51 +++++++++++-
3 files changed, 185 insertions(+), 7 deletions(-)
diff --git a/src-tauri/src/chat/jean_mcp.rs b/src-tauri/src/chat/jean_mcp.rs
index 1dcdce31b..a10e585c5 100644
--- a/src-tauri/src/chat/jean_mcp.rs
+++ b/src-tauri/src/chat/jean_mcp.rs
@@ -4,7 +4,7 @@
//! for callers that explicitly opt into runtime config assembly. Normal Jean
//! CLI sessions use the persistent config writers in `jean_mcp_config` so the
//! server is visible to users. The stdio helper proxies over a Jean-owned local
-//! Unix socket; no HTTP listener/port is required.
+//! local IPC; no HTTP listener/port is required.
use serde_json::{json, Value};
use tauri::AppHandle;
diff --git a/src-tauri/src/jean_mcp_socket.rs b/src-tauri/src/jean_mcp_socket.rs
index cba773aca..b992c4d3c 100644
--- a/src-tauri/src/jean_mcp_socket.rs
+++ b/src-tauri/src/jean_mcp_socket.rs
@@ -21,7 +21,37 @@ pub fn socket_path(app: &AppHandle) -> Result {
.path()
.app_data_dir()
.map_err(|e| format!("Failed to resolve app data dir: {e}"))?;
- Ok(dir.join("jean-mcp.sock"))
+ Ok(platform_socket_path(&dir))
+}
+
+#[cfg(unix)]
+fn platform_socket_path(app_data_dir: &std::path::Path) -> PathBuf {
+ app_data_dir.join("jean-mcp.sock")
+}
+
+#[cfg(windows)]
+fn platform_socket_path(app_data_dir: &std::path::Path) -> PathBuf {
+ windows_pipe_path_for_app_data(app_data_dir)
+}
+
+#[cfg(not(any(unix, windows)))]
+fn platform_socket_path(app_data_dir: &std::path::Path) -> PathBuf {
+ app_data_dir.join("jean-mcp.sock")
+}
+
+#[cfg(any(windows, test))]
+fn windows_pipe_path_for_app_data(app_data_dir: &std::path::Path) -> PathBuf {
+ use sha2::{Digest, Sha256};
+
+ let mut hasher = Sha256::new();
+ hasher.update(app_data_dir.to_string_lossy().as_bytes());
+ let digest = hasher.finalize();
+ let suffix = digest[..8]
+ .iter()
+ .map(|byte| format!("{byte:02x}"))
+ .collect::();
+
+ PathBuf::from(format!(r"\\.\pipe\jean-mcp-{suffix}"))
}
pub async fn get_socket_status(app: AppHandle) -> (bool, Option, Option) {
@@ -124,13 +154,95 @@ pub async fn start_socket_server(
})
}
-#[cfg(not(unix))]
+#[cfg(windows)]
+pub async fn start_socket_server(
+ app: AppHandle,
+ path: PathBuf,
+ token: String,
+) -> Result {
+ use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
+ use tokio::net::windows::named_pipe::{NamedPipeServer, ServerOptions};
+
+ async fn handle_pipe_connection(
+ app: AppHandle,
+ expected_token: String,
+ mut pipe: NamedPipeServer,
+ ) {
+ let mut line = String::new();
+ let response = {
+ let mut reader = BufReader::new(&mut pipe);
+ match tokio::time::timeout(
+ std::time::Duration::from_secs(30),
+ reader.read_line(&mut line),
+ )
+ .await
+ {
+ Ok(Ok(0)) => json!({"error":"empty request"}),
+ Ok(Ok(_)) => handle_socket_request(&app, &expected_token, &line).await,
+ Ok(Err(e)) => json!({"error": format!("read failed: {e}")}),
+ Err(_) => json!({"error":"read timeout"}),
+ }
+ };
+ if let Ok(encoded) = serde_json::to_string(&response) {
+ let _ = pipe.write_all(encoded.as_bytes()).await;
+ let _ = pipe.write_all(b"\n").await;
+ let _ = pipe.flush().await;
+ }
+ }
+
+ let pipe_name = path.to_string_lossy().to_string();
+ let mut server = ServerOptions::new()
+ .first_pipe_instance(true)
+ .create(&pipe_name)
+ .map_err(|e| format!("Failed to create Jean MCP named pipe {pipe_name}: {e}"))?;
+ let (shutdown_tx, mut shutdown_rx) = tokio::sync::oneshot::channel();
+ let path_for_task = path.clone();
+ let token_for_task = token.clone();
+
+ tokio::spawn(async move {
+ log::info!("Jean MCP proxy named pipe listening at {pipe_name}");
+ loop {
+ tokio::select! {
+ _ = &mut shutdown_rx => {
+ log::info!("Jean MCP proxy named pipe shutting down");
+ break;
+ }
+ connected = server.connect() => {
+ match connected {
+ Ok(()) => {
+ let next_server = match ServerOptions::new().create(&pipe_name) {
+ Ok(next) => next,
+ Err(e) => {
+ log::warn!("Jean MCP named pipe recreate failed: {e}");
+ break;
+ }
+ };
+ let connected_server = std::mem::replace(&mut server, next_server);
+ let app = app.clone();
+ let expected_token = token_for_task.clone();
+ tokio::spawn(handle_pipe_connection(app, expected_token, connected_server));
+ }
+ Err(e) => log::warn!("Jean MCP named pipe accept failed: {e}"),
+ }
+ }
+ }
+ }
+ });
+
+ Ok(JeanMcpSocketHandle {
+ shutdown_tx,
+ path: path_for_task,
+ token,
+ })
+}
+
+#[cfg(not(any(unix, windows)))]
pub async fn start_socket_server(
_app: AppHandle,
_path: PathBuf,
_token: String,
) -> Result {
- Err("Jean MCP currently requires Unix domain sockets".to_string())
+ Err("Jean MCP local IPC is not supported on this platform".to_string())
}
async fn handle_socket_request(app: &AppHandle, expected_token: &str, line: &str) -> Value {
@@ -158,3 +270,24 @@ async fn handle_socket_request(app: &AppHandle, expected_token: &str, line: &str
Err(e) => jsonrpc_error(None, e.code, &e.message),
}
}
+
+#[cfg(test)]
+mod tests {
+ use std::path::Path;
+
+ #[test]
+ fn windows_pipe_path_is_stable_and_named_pipe_safe() {
+ let one =
+ super::windows_pipe_path_for_app_data(Path::new(r"C:\Users\Ada\AppData\Roaming\Jean"));
+ let two =
+ super::windows_pipe_path_for_app_data(Path::new(r"C:\Users\Ada\AppData\Roaming\Jean"));
+ let other = super::windows_pipe_path_for_app_data(Path::new(
+ r"C:\Users\Ada\AppData\Roaming\JeanDev",
+ ));
+
+ assert_eq!(one, two);
+ assert_ne!(one, other);
+ assert!(one.to_string_lossy().starts_with(r"\\.\pipe\jean-mcp-"));
+ assert!(!one.to_string_lossy().contains(':'));
+ }
+}
diff --git a/src-tauri/src/jean_mcp_stdio.rs b/src-tauri/src/jean_mcp_stdio.rs
index a481f5637..a5d0c393c 100644
--- a/src-tauri/src/jean_mcp_stdio.rs
+++ b/src-tauri/src/jean_mcp_stdio.rs
@@ -1,7 +1,7 @@
//! Stdio MCP transport for Jean.
//!
//! This process is launched by a local CLI as an MCP server. It proxies
-//! tools/call requests over a Jean-owned local Unix socket to the already
+//! tools/call requests over Jean-owned local IPC to the already
//! running desktop app, avoiding HTTP ports while preserving in-process app
//! command dispatch in the parent.
@@ -103,9 +103,54 @@ fn proxy_to_parent(socket: &str, request: Value) -> Result {
Ok(response.get("result").cloned().unwrap_or(Value::Null))
}
-#[cfg(not(unix))]
+#[cfg(windows)]
+fn proxy_to_parent(socket: &str, request: Value) -> Result {
+ use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
+ use tokio::net::windows::named_pipe::ClientOptions;
+ use tokio::runtime::Builder;
+ use tokio::time::{timeout, Duration};
+
+ let encoded = serde_json::to_string(&request)
+ .map_err(|e| format!("Failed to encode Jean MCP pipe request: {e}"))?;
+ let runtime = Builder::new_current_thread()
+ .enable_io()
+ .enable_time()
+ .build()
+ .map_err(|e| format!("Failed to create Jean MCP pipe runtime: {e}"))?;
+
+ let response = runtime.block_on(async {
+ let mut pipe = ClientOptions::new()
+ .open(socket)
+ .map_err(|e| format!("Failed to connect Jean MCP named pipe {socket}: {e}"))?;
+ timeout(Duration::from_secs(30), async {
+ pipe.write_all(encoded.as_bytes()).await?;
+ pipe.write_all(b"\n").await?;
+ pipe.flush().await
+ })
+ .await
+ .map_err(|_| "Timed out writing Jean MCP pipe request".to_string())?
+ .map_err(|e| format!("Failed to write Jean MCP pipe request: {e}"))?;
+
+ let mut reader = BufReader::new(pipe);
+ let mut line = String::new();
+ timeout(Duration::from_secs(120), reader.read_line(&mut line))
+ .await
+ .map_err(|_| "Timed out reading Jean MCP pipe response".to_string())?
+ .map_err(|e| format!("Failed to read Jean MCP pipe response: {e}"))?;
+
+ serde_json::from_str::(&line)
+ .map_err(|e| format!("Failed to parse Jean MCP pipe response: {e}"))
+ })?;
+
+ if let Some(error) = parent_error_message(&response) {
+ return Err(error);
+ }
+ Ok(response.get("result").cloned().unwrap_or(Value::Null))
+}
+
+#[cfg(not(any(unix, windows)))]
fn proxy_to_parent(_socket: &str, _request: Value) -> Result {
- Err("Jean MCP currently requires a Unix domain socket".to_string())
+ Err("Jean MCP local IPC is not supported on this platform".to_string())
}
fn parent_error_message(response: &Value) -> Option {
From 13a0fe9d1a82a499b5f7d471a44f291404c1c016 Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Mon, 29 Jun 2026 15:29:20 +0200
Subject: [PATCH 015/359] fix(window): add Linux resize handles
---
src/components/layout/MainWindow.tsx | 5 +-
.../layout/WindowResizeHandles.test.tsx | 63 ++++++++++
src/components/layout/WindowResizeHandles.tsx | 112 ++++++++++++++++++
3 files changed, 179 insertions(+), 1 deletion(-)
create mode 100644 src/components/layout/WindowResizeHandles.test.tsx
create mode 100644 src/components/layout/WindowResizeHandles.tsx
diff --git a/src/components/layout/MainWindow.tsx b/src/components/layout/MainWindow.tsx
index 99e21abb9..958c5d1a6 100644
--- a/src/components/layout/MainWindow.tsx
+++ b/src/components/layout/MainWindow.tsx
@@ -20,6 +20,7 @@ import { CommandPalette } from '@/components/command-palette/CommandPalette'
import { QuitConfirmationDialog } from './QuitConfirmationDialog'
import { BranchConflictDialog } from '@/components/worktree/BranchConflictDialog'
import { TeardownOutputDialog } from '@/components/worktree/TeardownOutputDialog'
+import { WindowResizeHandles } from './WindowResizeHandles'
// Lazy-loaded heavy modals (code splitting)
const LeftSideBar = lazy(() =>
@@ -189,7 +190,7 @@ import {
useWorktreeEvents,
} from '@/services/projects'
import { isNativeApp } from '@/lib/environment'
-import { isWindows } from '@/lib/platform'
+import { isLinux, isWindows } from '@/lib/platform'
// Left sidebar resize constraints (pixels)
const MIN_SIDEBAR_WIDTH = 150
@@ -460,6 +461,8 @@ export function MainWindow() {
roundedClass
)}
>
+ {isNativeApp() && isLinux && }
+
{/* Touch swipe-down pull indicator */}
{isTouch && swipeDown.isSwiping && (
({
+ getCurrentWindow: () => ({ startResizeDragging }),
+}))
+
+let maximized = false
+
+vi.mock('@/hooks/use-window-maximized', () => ({
+ useWindowMaximized: () => maximized,
+}))
+
+describe('WindowResizeHandles', () => {
+ beforeEach(() => {
+ maximized = false
+ startResizeDragging.mockReset()
+ })
+
+ it('starts native resize dragging for each edge and corner', () => {
+ render(
)
+
+ const directions = [
+ 'NorthWest',
+ 'North',
+ 'NorthEast',
+ 'East',
+ 'SouthEast',
+ 'South',
+ 'SouthWest',
+ 'West',
+ ] as const
+
+ for (const direction of directions) {
+ fireEvent.mouseDown(screen.getByTestId(`window-resize-${direction}`), {
+ button: 0,
+ })
+ }
+
+ expect(startResizeDragging.mock.calls.map(call => call[0])).toEqual(
+ directions
+ )
+ })
+
+ it('ignores non-primary mouse buttons', () => {
+ render(
)
+
+ fireEvent.mouseDown(screen.getByTestId('window-resize-East'), { button: 1 })
+
+ expect(startResizeDragging).not.toHaveBeenCalled()
+ })
+
+ it('does not render handles while maximized', () => {
+ maximized = true
+
+ render(
)
+
+ expect(screen.queryByTestId('window-resize-East')).toBeNull()
+ })
+})
diff --git a/src/components/layout/WindowResizeHandles.tsx b/src/components/layout/WindowResizeHandles.tsx
new file mode 100644
index 000000000..44150243f
--- /dev/null
+++ b/src/components/layout/WindowResizeHandles.tsx
@@ -0,0 +1,112 @@
+import type React from 'react'
+import { getCurrentWindow } from '@tauri-apps/api/window'
+import { useWindowMaximized } from '@/hooks/use-window-maximized'
+
+type ResizeDirection =
+ | 'East'
+ | 'North'
+ | 'NorthEast'
+ | 'NorthWest'
+ | 'South'
+ | 'SouthEast'
+ | 'SouthWest'
+ | 'West'
+
+interface Handle {
+ direction: ResizeDirection
+ style: React.CSSProperties
+}
+
+const EDGE_SIZE = 6
+const CORNER_SIZE = 12
+
+const cursorByDirection: Record
= {
+ North: 'ns-resize',
+ South: 'ns-resize',
+ East: 'ew-resize',
+ West: 'ew-resize',
+ NorthEast: 'nesw-resize',
+ SouthWest: 'nesw-resize',
+ NorthWest: 'nwse-resize',
+ SouthEast: 'nwse-resize',
+}
+
+const handles: Handle[] = [
+ {
+ direction: 'NorthWest',
+ style: { top: 0, left: 0, width: CORNER_SIZE, height: CORNER_SIZE },
+ },
+ {
+ direction: 'North',
+ style: { top: 0, left: CORNER_SIZE, right: CORNER_SIZE, height: EDGE_SIZE },
+ },
+ {
+ direction: 'NorthEast',
+ style: { top: 0, right: 0, width: CORNER_SIZE, height: CORNER_SIZE },
+ },
+ {
+ direction: 'East',
+ style: {
+ top: CORNER_SIZE,
+ right: 0,
+ bottom: CORNER_SIZE,
+ width: EDGE_SIZE,
+ },
+ },
+ {
+ direction: 'SouthEast',
+ style: { right: 0, bottom: 0, width: CORNER_SIZE, height: CORNER_SIZE },
+ },
+ {
+ direction: 'South',
+ style: {
+ right: CORNER_SIZE,
+ bottom: 0,
+ left: CORNER_SIZE,
+ height: EDGE_SIZE,
+ },
+ },
+ {
+ direction: 'SouthWest',
+ style: { bottom: 0, left: 0, width: CORNER_SIZE, height: CORNER_SIZE },
+ },
+ {
+ direction: 'West',
+ style: { top: CORNER_SIZE, bottom: CORNER_SIZE, left: 0, width: EDGE_SIZE },
+ },
+]
+
+export function WindowResizeHandles() {
+ const isMaximized = useWindowMaximized()
+
+ if (isMaximized) return null
+
+ const startResize =
+ (direction: ResizeDirection) => (event: React.MouseEvent) => {
+ if (event.button !== 0) return
+
+ event.preventDefault()
+ event.stopPropagation()
+ void getCurrentWindow().startResizeDragging(direction)
+ }
+
+ return (
+ <>
+ {handles.map(handle => (
+
+ ))}
+ >
+ )
+}
From 7dec749b6288bbc5faf08b0fbfcd289a9754619c Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Mon, 29 Jun 2026 15:29:22 +0200
Subject: [PATCH 016/359] fix(claude): pass profile env to helper commands
---
src-tauri/src/chat/claude.rs | 81 ++++++++++++++++++++++++++++++
src-tauri/src/chat/commands.rs | 1 +
src-tauri/src/chat/naming.rs | 1 +
src-tauri/src/projects/commands.rs | 4 ++
4 files changed, 87 insertions(+)
diff --git a/src-tauri/src/chat/claude.rs b/src-tauri/src/chat/claude.rs
index 09b7b7e88..c3429f357 100644
--- a/src-tauri/src/chat/claude.rs
+++ b/src-tauri/src/chat/claude.rs
@@ -317,6 +317,64 @@ pub fn apply_custom_profile_settings(cmd: &mut std::process::Command, profile_na
}
}
+fn custom_profile_env_vars_from_settings(settings: &str) -> Result, String> {
+ let value: serde_json::Value =
+ serde_json::from_str(settings).map_err(|e| format!("Invalid CLI profile JSON: {e}"))?;
+
+ let Some(env) = value.get("env").and_then(|env| env.as_object()) else {
+ return Ok(Vec::new());
+ };
+
+ Ok(env
+ .iter()
+ .filter_map(|(key, value)| {
+ value
+ .as_str()
+ .map(|value| (key.to_string(), value.to_string()))
+ })
+ .collect())
+}
+
+fn load_custom_profile_env_vars(profile_name: Option<&str>) -> Vec<(String, String)> {
+ let Some(name) = profile_name.filter(|name| !name.is_empty()) else {
+ return Vec::new();
+ };
+
+ let Ok(path) = crate::get_cli_profile_path(name) else {
+ return Vec::new();
+ };
+
+ if !path.exists() {
+ return Vec::new();
+ }
+
+ match std::fs::read_to_string(&path)
+ .map_err(|e| format!("Failed to read CLI profile '{}': {e}", path.display()))
+ .and_then(|settings| custom_profile_env_vars_from_settings(&settings))
+ {
+ Ok(env_vars) => env_vars,
+ Err(error) => {
+ log::warn!("{error}");
+ Vec::new()
+ }
+ }
+}
+
+/// Apply custom CLI profile env vars to a Command.
+///
+/// Newer Claude CLI versions may check authentication before fully applying
+/// `--settings` env, so API-compatible providers need their ANTHROPIC_* vars
+/// present in the child process environment too.
+pub fn apply_custom_profile_env(cmd: &mut std::process::Command, profile_name: Option<&str>) {
+ for (key, value) in load_custom_profile_env_vars(profile_name) {
+ cmd.env(key, value);
+ }
+}
+
+fn should_mirror_custom_profile_env_for_detached() -> bool {
+ cfg!(windows) && !crate::platform::get_wsl_config().enabled
+}
+
/// Strip a `-fast` suffix from the model string.
/// Returns `(actual_model, is_fast)`.
/// E.g. `"opus-fast"` → `("opus", true)`, `"opus"` → `("opus", false)`.
@@ -462,6 +520,9 @@ fn build_claude_args(
}
}
}
+ if should_mirror_custom_profile_env_for_detached() {
+ env_vars.extend(load_custom_profile_env_vars(custom_profile_name));
+ }
// Thinking/effort settings: passed as separate --settings JSON (no secrets here)
let mut settings_json: Option = None;
@@ -2370,6 +2431,26 @@ mod tests {
);
}
+ #[test]
+ fn custom_profile_env_vars_reads_string_env_entries() {
+ let settings = r#"{
+ "env": {
+ "ANTHROPIC_BASE_URL": "https://api.minimax.io/anthropic",
+ "ANTHROPIC_AUTH_TOKEN": "secret",
+ "IGNORED_NON_STRING": 123
+ }
+ }"#;
+
+ let env_vars = custom_profile_env_vars_from_settings(settings).unwrap();
+
+ assert!(env_vars.contains(&(
+ "ANTHROPIC_BASE_URL".to_string(),
+ "https://api.minimax.io/anthropic".to_string()
+ )));
+ assert!(env_vars.contains(&("ANTHROPIC_AUTH_TOKEN".to_string(), "secret".to_string())));
+ assert!(!env_vars.iter().any(|(key, _)| key == "IGNORED_NON_STRING"));
+ }
+
#[test]
fn default_global_system_prompt_prefers_interactive_plan_questions() {
assert!(DEFAULT_GLOBAL_SYSTEM_PROMPT.contains("backend-native interactive question UI"));
diff --git a/src-tauri/src/chat/commands.rs b/src-tauri/src/chat/commands.rs
index 218aaeb73..18f1c3f33 100644
--- a/src-tauri/src/chat/commands.rs
+++ b/src-tauri/src/chat/commands.rs
@@ -6765,6 +6765,7 @@ fn execute_summarization_claude(
let mut cmd = crate::platform::cli_command(&cli_path.to_string_lossy(), None);
crate::chat::claude::apply_custom_profile_settings(&mut cmd, custom_profile_name);
+ crate::chat::claude::apply_custom_profile_env(&mut cmd, custom_profile_name);
cmd.args([
"--print",
"--input-format",
diff --git a/src-tauri/src/chat/naming.rs b/src-tauri/src/chat/naming.rs
index 6a2e14427..e855ab86f 100644
--- a/src-tauri/src/chat/naming.rs
+++ b/src-tauri/src/chat/naming.rs
@@ -380,6 +380,7 @@ fn generate_names(app: &AppHandle, request: &NamingRequest) -> Result
Date: Mon, 29 Jun 2026 15:29:28 +0200
Subject: [PATCH 017/359] fix(rtk): install CLI from verified release asset
Download platform-specific RTK release archives, verify checksums, extract the binary into app data, and use the managed binary for status checks.
---
src-tauri/src/opinionated/commands.rs | 482 +++++++++++++++++++++++---
1 file changed, 427 insertions(+), 55 deletions(-)
diff --git a/src-tauri/src/opinionated/commands.rs b/src-tauri/src/opinionated/commands.rs
index 197815013..778691400 100644
--- a/src-tauri/src/opinionated/commands.rs
+++ b/src-tauri/src/opinionated/commands.rs
@@ -1,7 +1,10 @@
use crate::platform::silent_command;
use serde::Serialize;
+use sha2::{Digest, Sha256};
+use std::ffi::{OsStr, OsString};
+use std::io::Read;
use std::path::{Path, PathBuf};
-use tauri::AppHandle;
+use tauri::{AppHandle, Manager};
#[derive(Debug, Serialize)]
pub struct PluginStatus {
@@ -13,6 +16,9 @@ const SUPERPOWERS_GIT_WORKTREE_SKILL: &str = "using-git-worktrees";
const SUPERPOWERS_REPO_URL: &str = "https://github.com/obra/superpowers";
const SUPERPOWERS_ARCHIVE_URL: &str =
"https://github.com/obra/superpowers/archive/refs/heads/main.zip";
+const RTK_RELEASE_LATEST_API: &str = "https://api.github.com/repos/rtk-ai/rtk/releases/latest";
+const RTK_CLI_DIR_NAME: &str = "rtk-cli";
+const RTK_BINARY_NAME: &str = if cfg!(windows) { "rtk.exe" } else { "rtk" };
fn superpowers_claude_plugin_target() -> &'static str {
"superpowers@claude-plugins-official"
@@ -29,7 +35,7 @@ pub async fn check_opinionated_plugin_status(
plugin_name: String,
) -> Result {
match plugin_name.as_str() {
- "rtk" => check_rtk_status().await,
+ "rtk" => check_rtk_status(&app).await,
"caveman" => check_caveman_status(&app).await,
"superpowers" => check_superpowers_status(&app).await,
_ => Err(format!("Unknown plugin: {plugin_name}")),
@@ -42,7 +48,7 @@ pub async fn install_opinionated_plugin(
plugin_name: String,
) -> Result {
match plugin_name.as_str() {
- "rtk" => install_rtk().await,
+ "rtk" => install_rtk(&app).await,
"caveman" => install_caveman(&app).await,
"superpowers" => install_superpowers(&app).await,
_ => Err(format!("Unknown plugin: {plugin_name}")),
@@ -64,10 +70,24 @@ pub async fn uninstall_opinionated_plugin(
}
}
-async fn check_rtk_status() -> Result {
- let result = tokio::task::spawn_blocking(|| silent_command("rtk").arg("--version").output())
- .await
- .map_err(|e| e.to_string())?;
+async fn check_rtk_status(app: &AppHandle) -> Result {
+ let managed_binary = rtk_binary_path(app).ok();
+ let result = tokio::task::spawn_blocking(move || {
+ let path_result = silent_command("rtk").arg("--version").output();
+ if matches!(&path_result, Ok(output) if output.status.success()) {
+ return path_result;
+ }
+
+ if let Some(binary) = managed_binary {
+ if binary.exists() {
+ return silent_command(binary).arg("--version").output();
+ }
+ }
+
+ path_result
+ })
+ .await
+ .map_err(|e| e.to_string())?;
match result {
Ok(output) if output.status.success() => {
@@ -110,62 +130,354 @@ async fn check_caveman_status(app: &AppHandle) -> Result {
Ok(PluginStatus { installed, version })
}
-async fn install_rtk() -> Result {
- // Try brew first on macOS
- let brew_result = tokio::task::spawn_blocking(|| {
- silent_command("brew")
- .args(["install", "rtk-ai/tap/rtk"])
- .output()
- })
- .await
- .map_err(|e| e.to_string())?;
+async fn install_rtk(app: &AppHandle) -> Result {
+ let asset = current_rtk_asset()?;
+ let binary_path = rtk_binary_path(app)?;
- let install_ok = match brew_result {
- Ok(output) if output.status.success() => true,
- _ => {
- // Fallback to curl installer
- let curl_result = tokio::task::spawn_blocking(|| {
- silent_command("sh")
- .args([
- "-c",
- "curl -fsSL https://raw.githubusercontent.com/rtk-ai/rtk/refs/heads/master/install.sh | sh",
- ])
- .output()
- })
- .await
- .map_err(|e| e.to_string())?;
+ let (archive, checksums) = download_rtk_release(&asset).await?;
+ verify_rtk_checksum(&archive, &checksums, asset.name)?;
- match curl_result {
- Ok(output) if output.status.success() => true,
- Ok(output) => {
- let stderr = String::from_utf8_lossy(&output.stderr);
- return Err(format!("RTK installation failed: {stderr}"));
- }
- Err(e) => return Err(format!("Failed to run installer: {e}")),
- }
+ let binary = match asset.format {
+ RtkArchiveFormat::Zip => extract_rtk_zip_binary(&archive, asset.binary_name)?,
+ RtkArchiveFormat::TarGz => extract_rtk_tar_gz_binary(&archive, asset.binary_name)?,
+ };
+
+ if let Some(parent) = binary_path.parent() {
+ std::fs::create_dir_all(parent)
+ .map_err(|e| format!("Failed to create RTK install directory: {e}"))?;
+ }
+ crate::platform::write_binary_file(&binary_path, &binary)
+ .map_err(|e| format!("Failed to install RTK binary: {e}"))?;
+ if let Some(parent) = binary_path.parent() {
+ add_dir_to_process_path(parent)?;
+ persist_rtk_dir_to_user_path(parent);
+ }
+
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::PermissionsExt;
+ let mut perms = std::fs::metadata(&binary_path)
+ .map_err(|e| format!("Failed to get RTK binary metadata: {e}"))?
+ .permissions();
+ perms.set_mode(0o755);
+ std::fs::set_permissions(&binary_path, perms)
+ .map_err(|e| format!("Failed to set RTK binary permissions: {e}"))?;
+ }
+
+ let verify_output = silent_command(&binary_path)
+ .arg("--version")
+ .output()
+ .map_err(|e| format!("Failed to verify RTK installation: {e}"))?;
+ if !verify_output.status.success() {
+ return Err(command_failure_message(
+ "RTK verification failed",
+ &verify_output,
+ ));
+ }
+
+ let init_result = silent_command(&binary_path).args(["init", "-g"]).output();
+ match init_result {
+ Ok(output) if output.status.success() => Ok(format!(
+ "RTK installed and initialized successfully at {}",
+ binary_path.display()
+ )),
+ Ok(output) => Ok(format!(
+ "RTK installed to {} but init had warnings: {}",
+ binary_path.display(),
+ command_output_detail(&output)
+ )),
+ Err(e) => Ok(format!(
+ "RTK installed to {} but init failed: {e}",
+ binary_path.display()
+ )),
+ }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum RtkArchiveFormat {
+ Zip,
+ TarGz,
+}
+
+#[derive(Debug, Clone, Copy)]
+struct RtkAsset {
+ name: &'static str,
+ binary_name: &'static str,
+ format: RtkArchiveFormat,
+}
+
+#[derive(Debug, serde::Deserialize)]
+struct RtkGitHubRelease {
+ assets: Vec,
+}
+
+#[derive(Debug, serde::Deserialize)]
+struct RtkGitHubAsset {
+ name: String,
+ browser_download_url: String,
+}
+
+fn rtk_binary_path(app: &AppHandle) -> Result {
+ let app_data_dir = app
+ .path()
+ .app_data_dir()
+ .map_err(|e| format!("Failed to get app data directory: {e}"))?;
+ Ok(app_data_dir.join(RTK_CLI_DIR_NAME).join(RTK_BINARY_NAME))
+}
+
+fn add_dir_to_process_path(dir: &Path) -> Result<(), String> {
+ let current = std::env::var_os("PATH");
+ let updated = path_with_prepended_dir(current.as_deref(), dir)?;
+ std::env::set_var("PATH", updated);
+ Ok(())
+}
+
+fn path_with_prepended_dir(current: Option<&OsStr>, dir: &Path) -> Result {
+ let existing: Vec = current
+ .map(std::env::split_paths)
+ .into_iter()
+ .flatten()
+ .collect();
+
+ if existing.iter().any(|path| path == dir) {
+ return current
+ .map(OsStr::to_os_string)
+ .ok_or_else(|| "PATH is empty".to_string());
+ }
+
+ let mut updated = Vec::with_capacity(existing.len() + 1);
+ updated.push(dir.to_path_buf());
+ updated.extend(existing);
+ std::env::join_paths(updated).map_err(|e| format!("Failed to update PATH for RTK: {e}"))
+}
+
+#[cfg(windows)]
+fn persist_rtk_dir_to_user_path(dir: &Path) {
+ let dir = dir.to_string_lossy().to_string();
+ let script = r#"
+$dir = $args[0]
+$old = [Environment]::GetEnvironmentVariable('Path', 'User')
+if ([string]::IsNullOrWhiteSpace($old)) {
+ [Environment]::SetEnvironmentVariable('Path', $dir, 'User')
+ exit 0
+}
+$parts = $old -split ';' | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }
+if ($parts -notcontains $dir) {
+ [Environment]::SetEnvironmentVariable('Path', ($old.TrimEnd(';') + ';' + $dir), 'User')
+}
+"#;
+ match silent_command("powershell")
+ .args([
+ "-NoProfile",
+ "-ExecutionPolicy",
+ "Bypass",
+ "-Command",
+ script,
+ &dir,
+ ])
+ .output()
+ {
+ Ok(output) if output.status.success() => {}
+ Ok(output) => log::warn!(
+ "Failed to persist RTK install dir to user PATH: {}",
+ command_output_detail(&output)
+ ),
+ Err(e) => log::warn!("Failed to run PowerShell while persisting RTK PATH: {e}"),
+ }
+}
+
+#[cfg(not(windows))]
+fn persist_rtk_dir_to_user_path(_dir: &Path) {}
+
+fn current_rtk_asset() -> Result {
+ rtk_asset_for_platform(std::env::consts::OS, std::env::consts::ARCH)
+}
+
+fn rtk_asset_for_platform(os: &str, arch: &str) -> Result {
+ let asset = match (os, arch) {
+ ("windows", "x86_64") => RtkAsset {
+ name: "rtk-x86_64-pc-windows-msvc.zip",
+ binary_name: "rtk.exe",
+ format: RtkArchiveFormat::Zip,
+ },
+ ("macos", "aarch64") => RtkAsset {
+ name: "rtk-aarch64-apple-darwin.tar.gz",
+ binary_name: "rtk",
+ format: RtkArchiveFormat::TarGz,
+ },
+ ("macos", "x86_64") => RtkAsset {
+ name: "rtk-x86_64-apple-darwin.tar.gz",
+ binary_name: "rtk",
+ format: RtkArchiveFormat::TarGz,
+ },
+ ("linux", "x86_64") => RtkAsset {
+ name: "rtk-x86_64-unknown-linux-musl.tar.gz",
+ binary_name: "rtk",
+ format: RtkArchiveFormat::TarGz,
+ },
+ ("linux", "aarch64") => RtkAsset {
+ name: "rtk-aarch64-unknown-linux-gnu.tar.gz",
+ binary_name: "rtk",
+ format: RtkArchiveFormat::TarGz,
+ },
+ _ => {
+ return Err(format!(
+ "Unsupported RTK platform: {os}/{arch}. Install manually from https://github.com/rtk-ai/rtk/releases"
+ ))
}
};
+ Ok(asset)
+}
- if install_ok {
- // Run post-install setup
- let init_result =
- tokio::task::spawn_blocking(|| silent_command("rtk").args(["init", "-g"]).output())
- .await
- .map_err(|e| e.to_string())?;
+async fn download_rtk_release(asset: &RtkAsset) -> Result<(Vec, String), String> {
+ let client = reqwest::Client::builder()
+ .user_agent("Jean-App/1.0")
+ .redirect(reqwest::redirect::Policy::limited(10))
+ .build()
+ .map_err(|e| format!("Failed to create HTTP client: {e}"))?;
- match init_result {
- Ok(output) if output.status.success() => {
- Ok("RTK installed and initialized successfully".to_string())
- }
- Ok(output) => {
- let stderr = String::from_utf8_lossy(&output.stderr);
- Ok(format!("RTK installed but init had warnings: {stderr}"))
- }
- Err(e) => Ok(format!("RTK installed but init failed: {e}")),
+ let release: RtkGitHubRelease = client
+ .get(RTK_RELEASE_LATEST_API)
+ .header("Accept", "application/vnd.github+json")
+ .send()
+ .await
+ .map_err(|e| format!("Failed to fetch RTK release info: {e}"))?
+ .error_for_status()
+ .map_err(|e| format!("Failed to fetch RTK release info: {e}"))?
+ .json()
+ .await
+ .map_err(|e| format!("Failed to parse RTK release info: {e}"))?;
+
+ let archive_url = release_asset_url(&release, asset.name)?;
+ let checksums_url = release_asset_url(&release, "checksums.txt")?;
+
+ let archive = client
+ .get(archive_url)
+ .send()
+ .await
+ .map_err(|e| format!("Failed to download RTK archive: {e}"))?
+ .error_for_status()
+ .map_err(|e| format!("Failed to download RTK archive: {e}"))?
+ .bytes()
+ .await
+ .map_err(|e| format!("Failed to read RTK archive: {e}"))?
+ .to_vec();
+
+ let checksums = client
+ .get(checksums_url)
+ .send()
+ .await
+ .map_err(|e| format!("Failed to download RTK checksums: {e}"))?
+ .error_for_status()
+ .map_err(|e| format!("Failed to download RTK checksums: {e}"))?
+ .text()
+ .await
+ .map_err(|e| format!("Failed to read RTK checksums: {e}"))?;
+
+ Ok((archive, checksums))
+}
+
+fn release_asset_url(release: &RtkGitHubRelease, name: &str) -> Result {
+ release
+ .assets
+ .iter()
+ .find(|asset| asset.name == name)
+ .map(|asset| asset.browser_download_url.clone())
+ .ok_or_else(|| format!("RTK release asset not found: {name}"))
+}
+
+fn rtk_expected_checksum<'a>(checksums: &'a str, asset_name: &str) -> Option<&'a str> {
+ checksums.lines().find_map(|line| {
+ let mut parts = line.split_whitespace();
+ let checksum = parts.next()?;
+ let name = parts.next()?;
+ (name == asset_name).then_some(checksum)
+ })
+}
+
+fn verify_rtk_checksum(archive: &[u8], checksums: &str, asset_name: &str) -> Result<(), String> {
+ let expected = rtk_expected_checksum(checksums, asset_name)
+ .ok_or_else(|| format!("Checksum for {asset_name} not found"))?;
+ let actual = format!("{:x}", Sha256::digest(archive));
+ if expected != actual {
+ return Err(format!(
+ "RTK checksum mismatch for {asset_name}: expected {expected}, got {actual}"
+ ));
+ }
+ Ok(())
+}
+
+fn extract_rtk_zip_binary(archive: &[u8], binary_name: &str) -> Result, String> {
+ let cursor = std::io::Cursor::new(archive);
+ let mut zip =
+ zip::ZipArchive::new(cursor).map_err(|e| format!("Failed to open RTK zip: {e}"))?;
+
+ for i in 0..zip.len() {
+ let mut file = zip
+ .by_index(i)
+ .map_err(|e| format!("Failed to read RTK zip entry: {e}"))?;
+ let Some(name) = file.enclosed_name().and_then(|path| {
+ path.file_name()
+ .and_then(|name| name.to_str())
+ .map(|name| name.to_string())
+ }) else {
+ continue;
+ };
+
+ if name == binary_name {
+ let mut binary = Vec::new();
+ file.read_to_end(&mut binary)
+ .map_err(|e| format!("Failed to read RTK binary from zip: {e}"))?;
+ return Ok(binary);
}
- } else {
- Err("RTK installation failed".to_string())
}
+
+ Err(format!("RTK binary {binary_name} not found in zip"))
+}
+
+fn extract_rtk_tar_gz_binary(archive: &[u8], binary_name: &str) -> Result, String> {
+ let cursor = std::io::Cursor::new(archive);
+ let decoder = flate2::read::GzDecoder::new(cursor);
+ let mut tar = tar::Archive::new(decoder);
+
+ for entry in tar
+ .entries()
+ .map_err(|e| format!("Failed to read RTK tar entries: {e}"))?
+ {
+ let mut entry = entry.map_err(|e| format!("Failed to read RTK tar entry: {e}"))?;
+ let path = entry
+ .path()
+ .map_err(|e| format!("Failed to read RTK tar path: {e}"))?;
+ let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
+ continue;
+ };
+
+ if name == binary_name {
+ let mut binary = Vec::new();
+ entry
+ .read_to_end(&mut binary)
+ .map_err(|e| format!("Failed to read RTK binary from tar: {e}"))?;
+ return Ok(binary);
+ }
+ }
+
+ Err(format!("RTK binary {binary_name} not found in tar.gz"))
+}
+
+fn command_failure_message(prefix: &str, output: &std::process::Output) -> String {
+ format!("{prefix}: {}", command_output_detail(output))
+}
+
+fn command_output_detail(output: &std::process::Output) -> String {
+ let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
+ if !stderr.is_empty() {
+ return stderr;
+ }
+ let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
+ if !stdout.is_empty() {
+ return stdout;
+ }
+ format!("exit code {}", output.status)
}
async fn install_caveman(app: &AppHandle) -> Result {
@@ -1251,6 +1563,66 @@ mod tests {
);
}
+ #[test]
+ fn selects_windows_rtk_release_asset() {
+ let asset = rtk_asset_for_platform("windows", "x86_64").expect("asset");
+
+ assert_eq!(asset.name, "rtk-x86_64-pc-windows-msvc.zip");
+ assert_eq!(asset.binary_name, "rtk.exe");
+ assert_eq!(asset.format, RtkArchiveFormat::Zip);
+ }
+
+ #[test]
+ fn parses_rtk_checksum_for_asset() {
+ let checksums = "\
+abc123 rtk-aarch64-apple-darwin.tar.gz\n\
+def456 rtk-x86_64-pc-windows-msvc.zip\n";
+
+ assert_eq!(
+ rtk_expected_checksum(checksums, "rtk-x86_64-pc-windows-msvc.zip"),
+ Some("def456")
+ );
+ }
+
+ #[test]
+ fn extracts_rtk_exe_from_zip_archive() {
+ use std::io::{Cursor, Write};
+
+ let mut archive_bytes = Cursor::new(Vec::new());
+ {
+ let mut writer = zip::ZipWriter::new(&mut archive_bytes);
+ let options = zip::write::SimpleFileOptions::default();
+ writer
+ .start_file("rtk.exe", options)
+ .expect("start rtk.exe");
+ writer.write_all(b"binary").expect("write rtk.exe");
+ writer.finish().expect("finish zip");
+ }
+
+ let binary = extract_rtk_zip_binary(archive_bytes.get_ref(), "rtk.exe").expect("extract");
+
+ assert_eq!(binary, b"binary");
+ }
+
+ #[test]
+ fn prepends_rtk_install_dir_to_path_once() {
+ let existing = std::env::join_paths([PathBuf::from("/usr/bin")]).expect("join");
+ let install_dir = PathBuf::from("/tmp/rtk-cli");
+
+ let updated = path_with_prepended_dir(Some(&existing), &install_dir).expect("path");
+ let updated_again = path_with_prepended_dir(Some(&updated), &install_dir).expect("path");
+ let parts: Vec<_> = std::env::split_paths(&updated_again).collect();
+
+ assert_eq!(parts[0], install_dir);
+ assert_eq!(
+ parts
+ .iter()
+ .filter(|part| **part == PathBuf::from("/tmp/rtk-cli"))
+ .count(),
+ 1
+ );
+ }
+
#[test]
fn startup_cleanup_removes_only_superpowers_git_worktree_skill() {
let temp = tempfile::tempdir().expect("tempdir");
From 778a02e7094a48121cf01dfb105284a46d72d8ec Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Mon, 29 Jun 2026 15:33:31 +0200
Subject: [PATCH 018/359] fix(chat): validate native CLI tracking metadata
---
src-tauri/src/chat/native_history.rs | 42 +++++++++++++-
.../chat/NativeCliSessionsModal.tsx | 6 +-
.../chat/NewSessionModeModal.test.tsx | 57 +++++++++++++++++++
3 files changed, 100 insertions(+), 5 deletions(-)
diff --git a/src-tauri/src/chat/native_history.rs b/src-tauri/src/chat/native_history.rs
index 2e7025a72..349fc4c15 100644
--- a/src-tauri/src/chat/native_history.rs
+++ b/src-tauri/src/chat/native_history.rs
@@ -8,8 +8,8 @@ use std::sync::{Mutex, OnceLock};
use std::time::{Duration, Instant, UNIX_EPOCH};
use tauri::AppHandle;
-use super::storage::with_existing_metadata_mut;
-use super::types::Backend;
+use super::storage::{load_metadata, with_existing_metadata_mut};
+use super::types::{Backend, SessionMetadata};
use crate::http_server::EmitExt;
const MAX_NATIVE_HISTORY_FILES: usize = 10_000;
@@ -148,6 +148,23 @@ fn persist_native_cli_session_id(
Ok(())
}
+fn validate_native_cli_tracking_metadata(
+ metadata: &SessionMetadata,
+ backend: &str,
+) -> Result<(), String> {
+ if metadata.primary_surface.as_deref() != Some("terminal") {
+ return Err(format!("Session {} is not a terminal session", metadata.id));
+ }
+
+ match (backend, &metadata.backend) {
+ ("codex", Backend::Codex) | ("opencode", Backend::Opencode) => Ok(()),
+ _ => Err(format!(
+ "Backend {backend} does not match session {}",
+ metadata.id
+ )),
+ }
+}
+
#[tauri::command]
pub async fn bind_native_cli_session(
app: AppHandle,
@@ -173,6 +190,9 @@ pub async fn track_native_cli_session(
"Native CLI tracking is not supported for backend {backend}"
));
}
+ let metadata = load_metadata(&app, &session_id)?
+ .ok_or_else(|| format!("Session {session_id} not found"))?;
+ validate_native_cli_tracking_metadata(&metadata, &backend)?;
let known_session_ids = load_native_session_ids_uncached(&worktree_path, &backend)?
.into_iter()
@@ -1030,6 +1050,24 @@ mod tests {
);
}
+ #[test]
+ fn native_session_tracking_validation_requires_matching_terminal_metadata() {
+ let mut metadata = crate::chat::types::SessionMetadata::new(
+ "jean-session".to_string(),
+ "worktree-1".to_string(),
+ "Codex".to_string(),
+ 0,
+ );
+ metadata.backend = Backend::Codex;
+ metadata.primary_surface = Some("terminal".to_string());
+
+ assert!(validate_native_cli_tracking_metadata(&metadata, "codex").is_ok());
+ assert!(validate_native_cli_tracking_metadata(&metadata, "opencode").is_err());
+
+ metadata.primary_surface = Some("chat".to_string());
+ assert!(validate_native_cli_tracking_metadata(&metadata, "codex").is_err());
+ }
+
#[test]
fn native_history_default_returns_latest_five_from_cache() {
let sessions = (0..10)
diff --git a/src/components/chat/NativeCliSessionsModal.tsx b/src/components/chat/NativeCliSessionsModal.tsx
index f26cd4a8a..e7c6e222e 100644
--- a/src/components/chat/NativeCliSessionsModal.tsx
+++ b/src/components/chat/NativeCliSessionsModal.tsx
@@ -351,6 +351,9 @@ export function NativeCliSessionsModal({
return
}
+ const commandArgs =
+ resumeLaunch?.args ??
+ (await resolveTerminalCommandArgs(session, launchMode === 'new'))
if (options?.trackNativeId && backend) {
try {
await invoke('track_native_cli_session', {
@@ -368,9 +371,6 @@ export function NativeCliSessionsModal({
}
}
- const commandArgs =
- resumeLaunch?.args ??
- (await resolveTerminalCommandArgs(session, launchMode === 'new'))
terminalId = terminalStore.addTerminal(
worktreeId,
resumeLaunch?.command ?? session.terminal_command ?? command,
diff --git a/src/components/chat/NewSessionModeModal.test.tsx b/src/components/chat/NewSessionModeModal.test.tsx
index 60db3f0ad..98d7dc488 100644
--- a/src/components/chat/NewSessionModeModal.test.tsx
+++ b/src/components/chat/NewSessionModeModal.test.tsx
@@ -557,6 +557,63 @@ describe('NewSessionModeModal', () => {
)
})
+ it('starts native CLI tracking only after terminal context preparation', async () => {
+ mutate.mockImplementation(
+ (
+ _args: unknown,
+ opts?: {
+ onSuccess?: (session: {
+ id: string
+ name: string
+ backend?: string
+ }) => void
+ }
+ ) => {
+ opts?.onSuccess?.({
+ id: 'session-codex-prepare-order',
+ name: 'Codex',
+ backend: 'codex',
+ })
+ }
+ )
+ useUIStore.getState().openNewSessionModeModal({
+ worktreeId: 'worktree-1',
+ worktreePath: '/tmp/worktree-1',
+ origin: 'chat',
+ })
+
+ render()
+ fireEvent.click(screen.getByText('Codex'))
+ fireEvent.click(screen.getByText('New Codex session'))
+
+ await waitFor(() => {
+ expect(invoke).toHaveBeenCalledWith('prepare_backend_terminal_context', {
+ sessionId: 'session-codex-prepare-order',
+ worktreeId: 'worktree-1',
+ backend: 'codex',
+ })
+ })
+ expect(invoke).toHaveBeenCalledWith('track_native_cli_session', {
+ worktreePath: '/tmp/worktree-1',
+ sessionId: 'session-codex-prepare-order',
+ backend: 'codex',
+ })
+ const prepareCallOrder =
+ invoke.mock.invocationCallOrder[
+ invoke.mock.calls.findIndex(
+ ([command]) => command === 'prepare_backend_terminal_context'
+ )
+ ]
+ const trackCallOrder =
+ invoke.mock.invocationCallOrder[
+ invoke.mock.calls.findIndex(
+ ([command]) => command === 'track_native_cli_session'
+ )
+ ]
+ expect(prepareCallOrder).toBeLessThan(trackCallOrder)
+ expect(useTerminalStore.getState().terminals['worktree-1']).toHaveLength(1)
+ })
+
it('opens a plain terminal session with shortcut 1', async () => {
mutate.mockImplementation(
(
From ef9a7a995fdf047e4a03517e919b95ae3f3c74ba Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Mon, 29 Jun 2026 15:37:14 +0200
Subject: [PATCH 019/359] fix(projects): default PR merge fallback to merge
---
src-tauri/src/projects/commands.rs | 76 +++++++++++++++++++++++++-----
1 file changed, 65 insertions(+), 11 deletions(-)
diff --git a/src-tauri/src/projects/commands.rs b/src-tauri/src/projects/commands.rs
index c87138722..03b0b757f 100644
--- a/src-tauri/src/projects/commands.rs
+++ b/src-tauri/src/projects/commands.rs
@@ -6719,7 +6719,7 @@ pub struct MergePrResponse {
///
/// Returns `--squash`, `--merge`, or `--rebase`, preferring squash. Repositories
/// often disable merge commits, so a hardcoded `--merge` would fail with exit
-/// code 1; this queries the repo settings and falls back to `--squash` when they
+/// code 1; this queries the repo settings and falls back to `--merge` when they
/// can't be read.
fn resolve_repo_merge_flag(gh: &std::path::Path, worktree_path: &str) -> &'static str {
let output = silent_command(gh)
@@ -6735,20 +6735,74 @@ fn resolve_repo_merge_flag(gh: &std::path::Path, worktree_path: &str) -> &'stati
if let Ok(output) = output {
if output.status.success() {
if let Ok(settings) = serde_json::from_slice::(&output.stdout) {
- if settings["squashMergeAllowed"].as_bool().unwrap_or(false) {
- return "--squash";
- }
- if settings["mergeCommitAllowed"].as_bool().unwrap_or(false) {
- return "--merge";
- }
- if settings["rebaseMergeAllowed"].as_bool().unwrap_or(false) {
- return "--rebase";
- }
+ return resolve_merge_flag_from_repo_settings(&settings);
}
}
}
- "--squash"
+ "--merge"
+}
+
+fn resolve_merge_flag_from_repo_settings(settings: &serde_json::Value) -> &'static str {
+ if settings["squashMergeAllowed"].as_bool().unwrap_or(false) {
+ return "--squash";
+ }
+ if settings["mergeCommitAllowed"].as_bool().unwrap_or(false) {
+ return "--merge";
+ }
+ if settings["rebaseMergeAllowed"].as_bool().unwrap_or(false) {
+ return "--rebase";
+ }
+
+ "--merge"
+}
+
+#[cfg(test)]
+mod merge_pr_tests {
+ use super::*;
+
+ #[test]
+ fn selects_squash_when_allowed() {
+ let settings = serde_json::json!({
+ "squashMergeAllowed": true,
+ "mergeCommitAllowed": true,
+ "rebaseMergeAllowed": true
+ });
+
+ assert_eq!(resolve_merge_flag_from_repo_settings(&settings), "--squash");
+ }
+
+ #[test]
+ fn selects_merge_when_squash_is_disabled() {
+ let settings = serde_json::json!({
+ "squashMergeAllowed": false,
+ "mergeCommitAllowed": true,
+ "rebaseMergeAllowed": true
+ });
+
+ assert_eq!(resolve_merge_flag_from_repo_settings(&settings), "--merge");
+ }
+
+ #[test]
+ fn selects_rebase_when_only_rebase_is_allowed() {
+ let settings = serde_json::json!({
+ "squashMergeAllowed": false,
+ "mergeCommitAllowed": false,
+ "rebaseMergeAllowed": true
+ });
+
+ assert_eq!(resolve_merge_flag_from_repo_settings(&settings), "--rebase");
+ }
+
+ #[test]
+ fn falls_back_to_merge_for_missing_or_malformed_settings() {
+ let settings = serde_json::json!({
+ "squashMergeAllowed": "yes",
+ "rebaseMergeAllowed": null
+ });
+
+ assert_eq!(resolve_merge_flag_from_repo_settings(&settings), "--merge");
+ }
}
/// Merge the open GitHub PR for the current branch using `gh pr merge`.
From 4b72dc8df4f820cbf8fbb05aef8f4fcf412564a1 Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Mon, 29 Jun 2026 23:41:14 +0200
Subject: [PATCH 020/359] feat(pi): restore tool-rich run history
Parse PI run logs into ordered assistant messages with merged text deltas,
tool calls, tool results, and usage so restored sessions match live runs.
Expose Jean-global skills for Pi, Command Code, and Grok, and show per-backend
opinionated plugin coverage in preferences.
---
src-tauri/src/chat/pi.rs | 324 +++++++++++---
src-tauri/src/chat/run_log.rs | 12 +
src-tauri/src/http_server/dispatch.rs | 12 +
src-tauri/src/lib.rs | 3 +
src-tauri/src/opinionated/commands.rs | 407 +++++++++++++++---
src-tauri/src/projects/commands.rs | 49 +++
.../chat/CompactMessageList.test.tsx | 43 ++
src/components/chat/CompactMessageList.tsx | 6 +-
.../chat/CompactStreamingTicker.test.tsx | 25 ++
.../chat/CompactStreamingTicker.tsx | 11 +-
.../chat/NewSessionModeModal.test.tsx | 7 +-
.../chat/hooks/useMessageSending.ts | 17 +-
.../panes/OpinionatedPane.test.tsx | 74 +++-
.../preferences/panes/OpinionatedPane.tsx | 111 ++++-
src/services/skills.ts | 73 +++-
tmp/random-agent-test.txt | 2 +
16 files changed, 1014 insertions(+), 162 deletions(-)
create mode 100644 tmp/random-agent-test.txt
diff --git a/src-tauri/src/chat/pi.rs b/src-tauri/src/chat/pi.rs
index 444f86d2f..3719eff1c 100644
--- a/src-tauri/src/chat/pi.rs
+++ b/src-tauri/src/chat/pi.rs
@@ -1,6 +1,6 @@
//! PI Coding Agent execution engine.
-use super::types::{ContentBlock, ToolCall, UsageData};
+use super::types::{ChatMessage, ContentBlock, MessageRole, RunEntry, ToolCall, UsageData};
use crate::http_server::EmitExt;
#[cfg(unix)]
use crate::platform::silent_command;
@@ -171,6 +171,21 @@ fn text_delta_from_value(value: &Value) -> Option<&str> {
.or_else(|| value.get("text").and_then(Value::as_str))
}
+fn push_text_block(content_blocks: &mut Vec, text: &str) {
+ if text.is_empty() {
+ return;
+ }
+
+ if let Some(ContentBlock::Text { text: existing }) = content_blocks.last_mut() {
+ existing.push_str(text);
+ return;
+ }
+
+ content_blocks.push(ContentBlock::Text {
+ text: text.to_string(),
+ });
+}
+
fn message_usage_from_value(value: &Value) -> Option {
value.get("message").and_then(usage_from_value)
}
@@ -181,9 +196,7 @@ fn merge_assistant_message(response: &mut PiResponse, message: &Value) {
Some("text") => {
if let Some(text) = block.get("text").and_then(Value::as_str) {
response.content.push_str(text);
- response.content_blocks.push(ContentBlock::Text {
- text: text.to_string(),
- });
+ push_text_block(&mut response.content_blocks, text);
}
}
Some("thinking") => {
@@ -255,6 +268,70 @@ fn pi_session_id_from_value(value: &Value) -> Option {
.map(ToOwned::to_owned)
}
+fn pi_tool_call_id(value: &Value) -> Option<&str> {
+ value
+ .get("toolCallId")
+ .or_else(|| value.get("tool_call_id"))
+ .or_else(|| value.get("id"))
+ .or_else(|| value.get("tool_use_id"))
+ .and_then(Value::as_str)
+ .filter(|id| !id.is_empty())
+}
+
+fn pi_tool_name(value: &Value) -> Option<&str> {
+ value
+ .get("toolName")
+ .or_else(|| value.get("tool_name"))
+ .or_else(|| value.get("name"))
+ .and_then(Value::as_str)
+ .filter(|name| !name.is_empty())
+}
+
+fn pi_tool_input(value: &Value) -> Value {
+ value
+ .get("args")
+ .or_else(|| value.get("input"))
+ .cloned()
+ .unwrap_or(Value::Null)
+}
+
+fn pi_tool_output(value: &Value) -> String {
+ let Some(output) = value
+ .get("result")
+ .or_else(|| value.get("partialResult"))
+ .or_else(|| value.get("output"))
+ .or_else(|| value.get("content"))
+ else {
+ return String::new();
+ };
+
+ if let Some(text) = output.as_str() {
+ return text.to_string();
+ }
+
+ if let Some(text) = output
+ .get("content")
+ .and_then(Value::as_array)
+ .map(|blocks| {
+ blocks
+ .iter()
+ .filter(|block| block.get("type").and_then(Value::as_str) == Some("text"))
+ .filter_map(|block| block.get("text").and_then(Value::as_str))
+ .collect::>()
+ .join("")
+ })
+ .filter(|text| !text.is_empty())
+ {
+ return text;
+ }
+
+ if let Some(text) = output.get("text").and_then(Value::as_str) {
+ return text.to_string();
+ }
+
+ output.to_string()
+}
+
/// Merge a single already-parsed PI JSON line into the accumulating response.
///
/// This is the per-line core of PI stream parsing. Both the batch parser
@@ -294,9 +371,7 @@ fn merge_pi_line(response: &mut PiResponse, value: &Value) {
if value.get("role").and_then(Value::as_str) != Some("user") {
if let Some(text) = text_delta_from_value(value) {
response.content.push_str(text);
- response.content_blocks.push(ContentBlock::Text {
- text: text.to_string(),
- });
+ push_text_block(&mut response.content_blocks, text);
}
}
}
@@ -305,31 +380,15 @@ fn merge_pi_line(response: &mut PiResponse, value: &Value) {
merge_assistant_message(response, message);
} else if let Some(text) = text_delta_from_value(value) {
response.content.push_str(text);
- response.content_blocks.push(ContentBlock::Text {
- text: text.to_string(),
- });
+ push_text_block(&mut response.content_blocks, text);
}
}
"tool_execution_start" | "tool_call" => {
- let id = value
- .get("id")
- .or_else(|| value.get("tool_use_id"))
- .and_then(Value::as_str)
- .unwrap_or("")
- .to_string();
- if id.is_empty() {
+ let Some(id) = pi_tool_call_id(value).map(ToOwned::to_owned) else {
return;
- }
- let raw_name = value
- .get("name")
- .or_else(|| value.get("tool_name"))
- .and_then(Value::as_str)
- .unwrap_or("");
- let input = value
- .get("input")
- .or_else(|| value.get("args"))
- .cloned()
- .unwrap_or(Value::Null);
+ };
+ let raw_name = pi_tool_name(value).unwrap_or("");
+ let input = pi_tool_input(value);
response.tool_calls.push(ToolCall {
id: id.clone(),
name: normalize_tool_name(raw_name),
@@ -341,22 +400,11 @@ fn merge_pi_line(response: &mut PiResponse, value: &Value) {
.content_blocks
.push(ContentBlock::ToolUse { tool_call_id: id });
}
- "tool_execution_end" | "tool_result" => {
- let id = value
- .get("id")
- .or_else(|| value.get("tool_use_id"))
- .and_then(Value::as_str)
- .unwrap_or("");
- let output = value
- .get("output")
- .or_else(|| value.get("result"))
- .or_else(|| value.get("content"))
- .map(|v| {
- v.as_str()
- .map(ToOwned::to_owned)
- .unwrap_or_else(|| v.to_string())
- })
- .unwrap_or_default();
+ "tool_execution_update" | "tool_execution_end" | "tool_result" => {
+ let Some(id) = pi_tool_call_id(value) else {
+ return;
+ };
+ let output = pi_tool_output(value);
if let Some(tool) = response.tool_calls.iter_mut().find(|tool| tool.id == id) {
tool.output = Some(output);
}
@@ -396,6 +444,33 @@ fn parse_pi_json_stream_inner(input: &str) -> PiResponse {
response
}
+pub(crate) fn parse_pi_run_to_message(
+ lines: &[String],
+ run: &RunEntry,
+) -> Result {
+ let response = parse_pi_json_stream_inner(&lines.join("\n"));
+ Ok(ChatMessage {
+ id: run
+ .assistant_message_id
+ .clone()
+ .unwrap_or_else(|| format!("assistant-{}", run.run_id)),
+ session_id: String::new(),
+ role: MessageRole::Assistant,
+ content: response.content,
+ timestamp: run.ended_at.unwrap_or(run.started_at),
+ tool_calls: response.tool_calls,
+ content_blocks: response.content_blocks,
+ cancelled: run.cancelled || response.cancelled,
+ plan_approved: false,
+ model: run.model.clone(),
+ execution_mode: run.execution_mode.clone(),
+ thinking_level: run.thinking_level.clone(),
+ effort_level: run.effort_level.clone(),
+ recovered: run.recovered,
+ usage: response.usage.or_else(|| run.usage.clone()),
+ })
+}
+
fn raw_pi_model(model: Option<&str>) -> Option<&str> {
model.map(|m| m.strip_prefix("pi/").unwrap_or(m))
}
@@ -417,8 +492,7 @@ fn pi_thinking_level(effort: Option<&super::types::EffortLevel>) -> Option<&'sta
fn pi_tools_for_mode(mode: &str) -> &'static str {
match mode {
"plan" => "read,grep,find,ls",
- "build" => "read,grep,find,ls,edit,write",
- _ => "all",
+ _ => "read,grep,find,ls,bash,edit,write",
}
}
@@ -859,11 +933,7 @@ fn parse_pi_stream(
value.get("type").and_then(Value::as_str),
Some("tool_execution_end" | "tool_result")
) {
- if let Some(tool_id) = value
- .get("id")
- .or_else(|| value.get("tool_use_id"))
- .and_then(Value::as_str)
- {
+ if let Some(tool_id) = pi_tool_call_id(&value) {
if let Some(tool) = response.tool_calls.iter().find(|tool| tool.id == tool_id) {
if let Some(output) = &tool.output {
let _ = app.emit_all(
@@ -954,11 +1024,7 @@ fn emit_pi_delta_events(
value.get("type").and_then(Value::as_str),
Some("tool_execution_end" | "tool_result")
) {
- if let Some(tool_id) = value
- .get("id")
- .or_else(|| value.get("tool_use_id"))
- .and_then(Value::as_str)
- {
+ if let Some(tool_id) = pi_tool_call_id(value) {
if let Some(tool) = response.tool_calls.iter().find(|tool| tool.id == tool_id) {
if let Some(output) = &tool.output {
let _ = app.emit_all(
@@ -1358,14 +1424,10 @@ mod tests {
assert_eq!(response.session_id, "pi-json-mode-session");
assert_eq!(response.content, "Hello!");
- assert_eq!(response.content_blocks.len(), 2);
+ assert_eq!(response.content_blocks.len(), 1);
assert!(matches!(
&response.content_blocks[0],
- ContentBlock::Text { text } if text == "Hello"
- ));
- assert!(matches!(
- &response.content_blocks[1],
- ContentBlock::Text { text } if text == "!"
+ ContentBlock::Text { text } if text == "Hello!"
));
let usage = response.usage.unwrap();
assert_eq!(usage.input_tokens, 2531);
@@ -1499,6 +1561,129 @@ mod tests {
assert_eq!(response.usage.as_ref().unwrap().output_tokens, 20);
}
+ #[test]
+ fn coalesces_pi_text_deltas_around_inline_code() {
+ let stream = r#"
+{"type":"session","session_id":"pi-session-inline-code"}
+{"type":"tool_execution_start","toolCallId":"write-1","toolName":"write","args":{"file_path":"tmp/test.txt"}}
+{"type":"tool_execution_end","toolCallId":"write-1","toolName":"write","result":{"content":[{"type":"text","text":"ok"}]}}
+{"type":"message_update","assistantMessageEvent":{"type":"text_delta","delta":"Created `"}}
+{"type":"message_update","assistantMessageEvent":{"type":"text_delta","delta":"tmp/test.txt"}}
+{"type":"message_update","assistantMessageEvent":{"type":"text_delta","delta":"`."}}
+"#;
+
+ let response = parse_pi_json_stream_inner(stream);
+
+ assert_eq!(response.content, "Created `tmp/test.txt`.");
+ assert_eq!(response.content_blocks.len(), 2);
+ assert!(matches!(
+ &response.content_blocks[0],
+ ContentBlock::ToolUse { tool_call_id } if tool_call_id == "write-1"
+ ));
+ assert!(matches!(
+ &response.content_blocks[1],
+ ContentBlock::Text { text } if text == "Created `tmp/test.txt`."
+ ));
+ }
+
+ #[test]
+ fn parses_pi_documented_tool_execution_events() {
+ let stream = r#"
+{"type":"session","session_id":"pi-session-docs"}
+{"type":"tool_execution_start","toolCallId":"call_abc123","toolName":"bash","args":{"command":"ls"}}
+{"type":"tool_execution_end","toolCallId":"call_abc123","toolName":"bash","result":{"stdout":"README.md\n","stderr":"","exitCode":0},"isError":false}
+"#;
+
+ let response = parse_pi_json_stream_inner(stream);
+
+ assert_eq!(response.tool_calls.len(), 1);
+ assert_eq!(response.tool_calls[0].id, "call_abc123");
+ assert_eq!(response.tool_calls[0].name, "Bash");
+ assert_eq!(
+ response.tool_calls[0].input,
+ serde_json::json!({"command":"ls"})
+ );
+ let output: Value =
+ serde_json::from_str(response.tool_calls[0].output.as_deref().unwrap()).unwrap();
+ assert_eq!(
+ output,
+ serde_json::json!({"stdout":"README.md\n","stderr":"","exitCode":0})
+ );
+ assert_eq!(response.content_blocks.len(), 1);
+ assert!(matches!(
+ &response.content_blocks[0],
+ ContentBlock::ToolUse { tool_call_id } if tool_call_id == "call_abc123"
+ ));
+ }
+
+ #[test]
+ fn parses_pi_tool_result_content_blocks_as_plain_output() {
+ let stream = r#"
+{"type":"tool_execution_start","toolCallId":"call_write","toolName":"write","args":{"path":"tmp/a.txt","content":"hi"}}
+{"type":"tool_execution_end","toolCallId":"call_write","toolName":"write","result":{"content":[{"type":"text","text":"Successfully wrote 2 bytes to tmp/a.txt"}]},"isError":false}
+"#;
+
+ let response = parse_pi_json_stream_inner(stream);
+
+ assert_eq!(response.tool_calls.len(), 1);
+ assert_eq!(response.tool_calls[0].name, "Write");
+ assert_eq!(
+ response.tool_calls[0].output.as_deref(),
+ Some("Successfully wrote 2 bytes to tmp/a.txt")
+ );
+ }
+
+ #[test]
+ fn parses_pi_run_log_to_chat_message_with_tool_calls() {
+ let run = RunEntry {
+ run_id: "run-pi".to_string(),
+ user_message_id: "user-pi".to_string(),
+ user_message: "list files".to_string(),
+ model: Some("pi/sonnet".to_string()),
+ execution_mode: Some("build".to_string()),
+ thinking_level: None,
+ effort_level: None,
+ backend: Some(super::super::types::Backend::Pi),
+ custom_profile_name: None,
+ started_at: 1,
+ ended_at: Some(2),
+ status: super::super::types::RunStatus::Completed,
+ assistant_message_id: Some("assistant-pi".to_string()),
+ cancelled: false,
+ recovered: false,
+ claude_session_id: None,
+ pid: None,
+ usage: None,
+ codex_thread_id: None,
+ codex_turn_id: None,
+ cursor_chat_id: None,
+ grok_session_id: None,
+ };
+
+ let lines = vec![
+ r#"{"type":"session","session_id":"pi-session-docs"}"#.to_string(),
+ r#"{"type":"tool_execution_start","toolCallId":"call_abc123","toolName":"bash","args":{"command":"ls"}}"#.to_string(),
+ r#"{"type":"message_update","assistantMessageEvent":{"type":"text_delta","delta":"Done"}}"#.to_string(),
+ r#"{"type":"tool_execution_end","toolCallId":"call_abc123","toolName":"bash","result":"README.md\n","isError":false}"#.to_string(),
+ ];
+
+ let message = parse_pi_run_to_message(&lines, &run).unwrap();
+
+ assert_eq!(message.id, "assistant-pi");
+ assert_eq!(message.content, "Done");
+ assert_eq!(message.tool_calls.len(), 1);
+ assert_eq!(message.tool_calls[0].name, "Bash");
+ assert_eq!(message.content_blocks.len(), 2);
+ assert!(matches!(
+ &message.content_blocks[0],
+ ContentBlock::ToolUse { tool_call_id } if tool_call_id == "call_abc123"
+ ));
+ assert!(matches!(
+ &message.content_blocks[1],
+ ContentBlock::Text { text } if text == "Done"
+ ));
+ }
+
#[test]
fn parses_pi_session_id_from_rpc_get_state_response() {
let stream = r#"
@@ -1579,7 +1764,7 @@ mod tests {
.any(|w| w == ["--model", "openai-codex/gpt-5.4"]));
assert!(args
.windows(2)
- .any(|w| w == ["--tools", "read,grep,find,ls,edit,write"]));
+ .any(|w| w == ["--tools", "read,grep,find,ls,bash,edit,write"]));
assert!(args
.windows(2)
.any(|w| w == ["--append-system-prompt", "System instructions"]));
@@ -1587,6 +1772,19 @@ mod tests {
assert_eq!(args.get(1).map(String::as_str), Some("rpc"));
}
+ #[test]
+ fn pi_tools_for_modes_match_pi_builtin_tool_names() {
+ assert_eq!(pi_tools_for_mode("plan"), "read,grep,find,ls");
+ assert_eq!(
+ pi_tools_for_mode("build"),
+ "read,grep,find,ls,bash,edit,write"
+ );
+ assert_eq!(
+ pi_tools_for_mode("yolo"),
+ "read,grep,find,ls,bash,edit,write"
+ );
+ }
+
#[test]
fn pi_rpc_args_do_not_resume_with_jean_session_id() {
let args = build_pi_rpc_args(
diff --git a/src-tauri/src/chat/run_log.rs b/src-tauri/src/chat/run_log.rs
index 93a47b162..680e44b5a 100644
--- a/src-tauri/src/chat/run_log.rs
+++ b/src-tauri/src/chat/run_log.rs
@@ -1152,6 +1152,16 @@ fn run_uses_codex_history_parser(metadata_backend: &Backend, run: &RunEntry) ->
}
}
+fn run_uses_pi_history_parser(metadata_backend: &Backend, run: &RunEntry) -> bool {
+ run.backend.as_ref() == Some(&Backend::Pi)
+ || run
+ .model
+ .as_deref()
+ .map(crate::is_pi_model)
+ .unwrap_or(false)
+ || (run.model.is_none() && metadata_backend == &Backend::Pi)
+}
+
fn cancelled_codex_run_has_visible_artifacts(
app: &tauri::AppHandle,
session_id: &str,
@@ -1260,6 +1270,8 @@ pub fn load_session_messages_window(
let use_codex_parser = run_uses_codex_history_parser(&metadata.backend, run);
let mut assistant_msg = if use_codex_parser {
super::codex::parse_codex_run_to_message(&lines, run)?
+ } else if run_uses_pi_history_parser(&metadata.backend, run) {
+ super::pi::parse_pi_run_to_message(&lines, run)?
} else {
parse_run_to_message(&lines, run)?
};
diff --git a/src-tauri/src/http_server/dispatch.rs b/src-tauri/src/http_server/dispatch.rs
index c5b3577eb..49025e27a 100644
--- a/src-tauri/src/http_server/dispatch.rs
+++ b/src-tauri/src/http_server/dispatch.rs
@@ -1722,6 +1722,18 @@ pub async fn dispatch_command(
let result = crate::projects::list_cursor_skills().await?;
to_value(result)
}
+ "list_pi_skills" => {
+ let result = crate::projects::list_pi_skills().await?;
+ to_value(result)
+ }
+ "list_commandcode_skills" => {
+ let result = crate::projects::list_commandcode_skills().await?;
+ to_value(result)
+ }
+ "list_grok_skills" => {
+ let result = crate::projects::list_grok_skills().await?;
+ to_value(result)
+ }
"list_plugin_skills" => {
let result = crate::projects::list_plugin_skills().await?;
to_value(result)
diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs
index 8a65ad5e8..35c1c7385 100644
--- a/src-tauri/src/lib.rs
+++ b/src-tauri/src/lib.rs
@@ -4551,6 +4551,9 @@ pub fn run() {
projects::list_codex_skills,
projects::list_opencode_skills,
projects::list_cursor_skills,
+ projects::list_pi_skills,
+ projects::list_commandcode_skills,
+ projects::list_grok_skills,
projects::list_plugin_skills,
// GitHub issues commands
projects::list_github_labels,
diff --git a/src-tauri/src/opinionated/commands.rs b/src-tauri/src/opinionated/commands.rs
index 778691400..767da6223 100644
--- a/src-tauri/src/opinionated/commands.rs
+++ b/src-tauri/src/opinionated/commands.rs
@@ -10,6 +10,15 @@ use tauri::{AppHandle, Manager};
pub struct PluginStatus {
pub installed: bool,
pub version: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub backends: Option>,
+}
+
+#[derive(Debug, Serialize, Clone, PartialEq)]
+pub struct BackendPluginStatus {
+ pub id: String,
+ pub label: String,
+ pub installed: bool,
}
const SUPERPOWERS_GIT_WORKTREE_SKILL: &str = "using-git-worktrees";
@@ -96,28 +105,25 @@ async fn check_rtk_status(app: &AppHandle) -> Result {
Ok(PluginStatus {
installed: true,
version,
+ backends: None,
})
}
_ => Ok(PluginStatus {
installed: false,
version: None,
+ backends: None,
}),
}
}
async fn check_caveman_status(app: &AppHandle) -> Result {
let home = dirs::home_dir().ok_or("Cannot determine home directory")?;
- let installed_backends = detected_jean_backends(app);
-
- let home_for_check = home.clone();
- let covered_backends = tokio::task::spawn_blocking(move || {
- installed_backends
- .into_iter()
- .filter(|backend| caveman_installed_for_backend(&home_for_check, backend))
- .collect::>()
- })
- .await
- .map_err(|e| e.to_string())?;
+ let statuses = opinionated_backend_statuses(&home, "caveman");
+ let covered_backends = statuses
+ .iter()
+ .filter(|backend| backend.installed)
+ .map(|backend| backend.id.as_str())
+ .collect::>();
let installed = caveman_status_installed(&covered_backends, &detected_jean_backends(app));
@@ -127,7 +133,11 @@ async fn check_caveman_status(app: &AppHandle) -> Result {
Some(covered_backends.join(", "))
};
- Ok(PluginStatus { installed, version })
+ Ok(PluginStatus {
+ installed,
+ version,
+ backends: Some(statuses),
+ })
}
async fn install_rtk(app: &AppHandle) -> Result {
@@ -481,60 +491,111 @@ fn command_output_detail(output: &std::process::Output) -> String {
}
async fn install_caveman(app: &AppHandle) -> Result {
- let backends = detected_jean_backends(app);
- if backends.is_empty() {
- return Err("Install at least one Jean AI backend before installing Caveman".to_string());
- }
-
- let mut args = vec![
- "-y".to_string(),
- "github:JuliusBrussee/caveman".to_string(),
- "--".to_string(),
- "--non-interactive".to_string(),
- "--with-init".to_string(),
- ];
+ let detected_backends = detected_jean_backends(app);
+ let backends = installable_jean_backends()
+ .iter()
+ .map(|(id, _)| *id)
+ .collect::>();
+ let native_backends = detected_backends
+ .iter()
+ .copied()
+ .filter(|backend| matches!(*backend, "claude" | "codex" | "opencode" | "cursor"))
+ .collect::>();
- for backend in &backends {
- args.push("--only".to_string());
- args.push((*backend).to_string());
- }
+ let install_result = if native_backends.is_empty() {
+ None
+ } else {
+ let mut args = vec![
+ "-y".to_string(),
+ "github:JuliusBrussee/caveman".to_string(),
+ "--".to_string(),
+ "--non-interactive".to_string(),
+ "--with-init".to_string(),
+ ];
+
+ for backend in &native_backends {
+ args.push("--only".to_string());
+ args.push((*backend).to_string());
+ }
- let install_result = tokio::task::spawn_blocking(move || {
- let mut command = silent_command("npx");
- command.args(args);
- command.output()
- })
- .await
- .map_err(|e| e.to_string())?;
+ Some(
+ tokio::task::spawn_blocking(move || {
+ let mut command = silent_command("npx");
+ command.args(args);
+ command.output()
+ })
+ .await
+ .map_err(|e| e.to_string())?,
+ )
+ };
match install_result {
- Ok(output) if output.status.success() => Ok(format!(
- "Caveman installed for Jean backends: {}",
- backends.join(", ")
- )),
- Ok(output) => {
+ Some(Ok(output)) if !output.status.success() => {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
let detail = if stderr.is_empty() { stdout } else { stderr };
Err(format!("Failed to install Caveman: {detail}"))
}
- Err(e) => Err(format!("Failed to run Caveman installer with npx: {e}")),
+ Some(Err(e)) => Err(format!("Failed to run Caveman installer with npx: {e}")),
+ _ => {
+ let home = dirs::home_dir().ok_or("Cannot determine home directory")?;
+ let backends_for_global = backends.clone();
+ let global_result = tokio::task::spawn_blocking(move || {
+ mirror_caveman_to_jean_global_backends(&home, &backends_for_global)
+ })
+ .await
+ .map_err(|e| e.to_string())?;
+
+ let mut warnings = Vec::new();
+ if let Err(e) = global_result {
+ warnings.push(e);
+ }
+
+ let home = dirs::home_dir().ok_or("Cannot determine home directory")?;
+ let statuses = opinionated_backend_statuses(&home, "caveman");
+ let missing = statuses
+ .iter()
+ .filter(|status| backends.contains(&status.id.as_str()) && !status.installed)
+ .map(|status| status.label.clone())
+ .collect::>();
+ let installed = statuses
+ .iter()
+ .filter(|status| backends.contains(&status.id.as_str()) && status.installed)
+ .map(|status| status.label.clone())
+ .collect::>();
+
+ if missing.is_empty() {
+ Ok(format!(
+ "Caveman installed for Jean backends: {}",
+ installed.join(", ")
+ ))
+ } else {
+ let mut message = format!(
+ "Caveman partially installed. Installed: {}. Missing: {}",
+ if installed.is_empty() {
+ "none".to_string()
+ } else {
+ installed.join(", ")
+ },
+ missing.join(", ")
+ );
+ if !warnings.is_empty() {
+ message.push_str(&format!(". Warnings: {}", warnings.join("; ")));
+ }
+ Ok(message)
+ }
+ }
}
}
async fn check_superpowers_status(app: &AppHandle) -> Result {
let home = dirs::home_dir().ok_or("Cannot determine home directory")?;
- let installed_backends = detected_jean_backends(app);
-
- let home_for_check = home.clone();
- let covered_backends = tokio::task::spawn_blocking(move || {
- installed_backends
- .into_iter()
- .filter(|backend| superpowers_installed_for_backend(&home_for_check, backend))
- .collect::>()
- })
- .await
- .map_err(|e| e.to_string())?;
+ let statuses = opinionated_backend_statuses(&home, "superpowers");
+ let covered_backends = statuses
+ .iter()
+ .filter(|backend| backend.installed)
+ .map(|backend| backend.id.as_str())
+ .collect::>();
let version = if covered_backends.is_empty() {
None
@@ -545,6 +606,7 @@ async fn check_superpowers_status(app: &AppHandle) -> Result Result, String> {
&home.join(".cursor").join("rules").join("caveman.mdc"),
&mut removed,
)?;
+ for backend in [
+ "claude",
+ "codex",
+ "opencode",
+ "cursor",
+ "pi",
+ "commandcode",
+ "grok",
+ ] {
+ remove_matching_skill_dirs(
+ &jean_global_backend_skills_dir(home, backend),
+ "caveman",
+ &mut removed,
+ )?;
+ }
Ok(removed)
}
@@ -880,6 +957,21 @@ fn uninstall_superpowers_from_home(home: &Path) -> Result, String> {
"superpowers",
&mut removed,
)?;
+ for backend in [
+ "claude",
+ "codex",
+ "opencode",
+ "cursor",
+ "pi",
+ "commandcode",
+ "grok",
+ ] {
+ remove_matching_skill_dirs(
+ &jean_global_backend_skills_dir(home, backend),
+ "superpowers",
+ &mut removed,
+ )?;
+ }
Ok(removed)
}
@@ -927,6 +1019,12 @@ fn detected_jean_backends(app: &AppHandle) -> Vec<&'static str> {
Some(crate::opencode_cli::resolve_cli_binary(app)),
),
("cursor", Some(crate::cursor_cli::resolve_cli_binary(app))),
+ ("pi", Some(crate::pi_cli::resolve_cli_binary(app))),
+ (
+ "commandcode",
+ Some(crate::commandcode_cli::resolve_cli_binary(app)),
+ ),
+ ("grok", Some(crate::grok_cli::resolve_cli_binary(app))),
];
candidates
@@ -935,18 +1033,61 @@ fn detected_jean_backends(app: &AppHandle) -> Vec<&'static str> {
.collect()
}
-fn caveman_status_installed(covered_backends: &[&str], _detected_backends: &[&str]) -> bool {
- !covered_backends.is_empty()
+fn installable_jean_backends() -> [(&'static str, &'static str); 7] {
+ [
+ ("claude", "Claude"),
+ ("codex", "Codex"),
+ ("opencode", "OpenCode"),
+ ("cursor", "Cursor"),
+ ("pi", "Pi"),
+ ("commandcode", "Command Code"),
+ ("grok", "Grok"),
+ ]
+}
+
+fn status_installed(covered_backends: &[&str], detected_backends: &[&str]) -> bool {
+ !detected_backends.is_empty()
+ && detected_backends
+ .iter()
+ .all(|backend| covered_backends.contains(backend))
+}
+
+fn caveman_status_installed(covered_backends: &[&str], detected_backends: &[&str]) -> bool {
+ status_installed(covered_backends, detected_backends)
+}
+
+fn superpowers_status_installed(covered_backends: &[&str], detected_backends: &[&str]) -> bool {
+ status_installed(covered_backends, detected_backends)
}
-fn superpowers_status_installed(covered_backends: &[&str], _detected_backends: &[&str]) -> bool {
- !covered_backends.is_empty()
+fn opinionated_backend_statuses(home: &Path, plugin_id: &str) -> Vec {
+ installable_jean_backends()
+ .into_iter()
+ .map(|(id, label)| {
+ let installed = match plugin_id {
+ "caveman" => caveman_installed_for_backend(home, id),
+ "superpowers" => superpowers_installed_for_backend(home, id),
+ _ => false,
+ };
+
+ BackendPluginStatus {
+ id: id.to_string(),
+ label: label.to_string(),
+ installed,
+ }
+ })
+ .collect()
}
fn caveman_installed_for_backend(home: &Path, backend: &str) -> bool {
+ let global_installed =
+ skill_installed_marker(&jean_global_backend_skills_dir(home, backend), "caveman");
match backend {
- "claude" => plugin_installed_marker(home, "caveman"),
- "codex" => skill_installed_marker(&home.join(".codex").join("skills"), "caveman"),
+ "claude" => plugin_installed_marker(home, "caveman") || global_installed,
+ "codex" => {
+ skill_installed_marker(&home.join(".codex").join("skills"), "caveman")
+ || global_installed
+ }
"opencode" => {
let config_dir = opencode_config_dir(home);
config_dir
@@ -956,6 +1097,7 @@ fn caveman_installed_for_backend(home: &Path, backend: &str) -> bool {
.exists()
|| skill_installed_marker(&config_dir.join("skills"), "caveman")
|| config_dir.join("commands").join("caveman.md").exists()
+ || global_installed
}
"cursor" => {
skill_installed_marker(&home.join(".cursor").join("skills-cursor"), "caveman")
@@ -964,14 +1106,20 @@ fn caveman_installed_for_backend(home: &Path, backend: &str) -> bool {
.join("rules")
.join("caveman.mdc")
.exists()
+ || global_installed
}
+ "pi" | "commandcode" | "grok" => global_installed,
_ => false,
}
}
fn superpowers_installed_for_backend(home: &Path, backend: &str) -> bool {
+ let global_installed = skill_installed_marker(
+ &jean_global_backend_skills_dir(home, backend),
+ "superpowers",
+ );
match backend {
- "claude" => plugin_installed_marker(home, "superpowers"),
+ "claude" => plugin_installed_marker(home, "superpowers") || global_installed,
"codex" => {
skill_installed_marker(&home.join(".codex").join("skills"), "superpowers")
|| codex_plugin_registered(home, "superpowers")
@@ -982,22 +1130,31 @@ fn superpowers_installed_for_backend(home: &Path, backend: &str) -> bool {
.join("openai-curated")
.join("superpowers")
.exists()
+ || global_installed
}
"opencode" => {
skill_installed_marker(&opencode_config_dir(home).join("skills"), "superpowers")
+ || global_installed
}
"cursor" => {
skill_installed_marker(&home.join(".cursor").join("skills-cursor"), "superpowers")
+ || global_installed
}
+ "pi" | "commandcode" | "grok" => global_installed,
_ => false,
}
}
+fn jean_global_backend_skills_dir(home: &Path, backend: &str) -> PathBuf {
+ home.join(".jean").join("skills").join(backend)
+}
+
fn backend_skills_dir(home: &Path, backend: &str) -> Option {
match backend {
"codex" => Some(home.join(".codex").join("skills")),
"opencode" => Some(opencode_config_dir(home).join("skills")),
"cursor" => Some(home.join(".cursor").join("skills-cursor")),
+ "pi" | "commandcode" | "grok" => Some(jean_global_backend_skills_dir(home, backend)),
_ => None,
}
}
@@ -1111,6 +1268,49 @@ fn copy_dir_recursive(source: &Path, target: &Path) -> Result<(), String> {
Ok(())
}
+fn find_caveman_skill_dir(home: &Path) -> Option {
+ for skills_dir in [
+ home.join(".claude").join("skills"),
+ home.join(".codex").join("skills"),
+ opencode_config_dir(home).join("skills"),
+ home.join(".cursor").join("skills-cursor"),
+ jean_global_backend_skills_dir(home, "pi"),
+ jean_global_backend_skills_dir(home, "commandcode"),
+ jean_global_backend_skills_dir(home, "grok"),
+ ] {
+ let Ok(entries) = std::fs::read_dir(skills_dir) else {
+ continue;
+ };
+ for entry in entries.flatten() {
+ let path = entry.path();
+ let name = entry.file_name().to_string_lossy().to_lowercase();
+ if path.is_dir() && path.join("SKILL.md").exists() && name.contains("caveman") {
+ return Some(path);
+ }
+ }
+ }
+
+ None
+}
+
+fn mirror_caveman_to_jean_global_backends(
+ home: &Path,
+ backends: &[&'static str],
+) -> Result {
+ let Some(source) = find_caveman_skill_dir(home) else {
+ return Err("No installed Caveman skill directory found to mirror globally".to_string());
+ };
+
+ let mut copied = 0;
+ for backend in backends {
+ let target = jean_global_backend_skills_dir(home, backend).join("caveman");
+ copy_dir_replace(&source, &target)?;
+ copied += 1;
+ }
+
+ Ok(copied)
+}
+
fn clone_superpowers_skills_dir() -> Result {
let temp = std::env::temp_dir().join(format!("jean-superpowers-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&temp)
@@ -1284,6 +1484,13 @@ fn remove_superpowers_git_worktree_skill(
home.join(".codex").join("skills"),
opencode_config_dir(home).join("skills"),
home.join(".cursor").join("skills-cursor"),
+ jean_global_backend_skills_dir(home, "claude"),
+ jean_global_backend_skills_dir(home, "codex"),
+ jean_global_backend_skills_dir(home, "opencode"),
+ jean_global_backend_skills_dir(home, "cursor"),
+ jean_global_backend_skills_dir(home, "pi"),
+ jean_global_backend_skills_dir(home, "commandcode"),
+ jean_global_backend_skills_dir(home, "grok"),
] {
for name in &blocked_names {
remove_path_if_exists(&skills_dir.join(name), removed)?;
@@ -1351,18 +1558,17 @@ fn remove_named_skill_dirs_under(
}
async fn install_superpowers(app: &AppHandle) -> Result {
- let backends = detected_jean_backends(app);
- if backends.is_empty() {
- return Err(
- "Install at least one Jean AI backend before installing Superpowers".to_string(),
- );
- }
+ let detected_backends = detected_jean_backends(app);
+ let backends = installable_jean_backends()
+ .iter()
+ .map(|(id, _)| *id)
+ .collect::>();
let home = dirs::home_dir().ok_or("Cannot determine home directory")?;
let mut installed = Vec::new();
let mut warnings = Vec::new();
- if backends.contains(&"claude") {
+ if detected_backends.contains(&"claude") {
let binary_path = crate::claude_cli::resolve_cli_binary(app);
let bin = binary_path.clone();
let add_result = tokio::task::spawn_blocking(move || {
@@ -1445,6 +1651,26 @@ async fn install_superpowers(app: &AppHandle) -> Result {
}
}
+ for backend in &backends {
+ let target_dir = jean_global_backend_skills_dir(&home, backend);
+ let source = source_skills_dir.clone();
+ let backend_name = *backend;
+ let result =
+ tokio::task::spawn_blocking(move || copy_superpowers_skills(&source, &target_dir))
+ .await
+ .map_err(|e| e.to_string())?;
+
+ match result {
+ Ok(count) if count > 0 && !installed.contains(&backend_name) => {
+ installed.push(backend_name)
+ }
+ Ok(_) => {}
+ Err(e) => warnings.push(format!(
+ "Failed to install global Superpowers skills for {backend_name}: {e}"
+ )),
+ }
+ }
+
if let Some(path) = cloned_repo_root {
let _ = std::fs::remove_dir_all(path);
}
@@ -1532,17 +1758,25 @@ mod tests {
}
#[test]
- fn caveman_status_is_installed_when_any_backend_is_covered() {
- assert!(caveman_status_installed(&["claude"], &["claude", "codex"]));
+ fn caveman_status_requires_every_detected_backend_covered() {
+ assert!(!caveman_status_installed(&["claude"], &["claude", "codex"]));
+ assert!(caveman_status_installed(
+ &["claude", "codex"],
+ &["claude", "codex"]
+ ));
assert!(!caveman_status_installed(&[], &["claude"]));
}
#[test]
- fn superpowers_status_is_installed_when_any_backend_is_covered() {
- assert!(superpowers_status_installed(
+ fn superpowers_status_requires_every_detected_backend_covered() {
+ assert!(!superpowers_status_installed(
&["codex"],
&["claude", "codex"]
));
+ assert!(superpowers_status_installed(
+ &["claude", "codex"],
+ &["claude", "codex"]
+ ));
assert!(!superpowers_status_installed(&[], &["claude"]));
}
@@ -1688,4 +1922,37 @@ def456 rtk-x86_64-pc-windows-msvc.zip\n";
assert!(caveman_installed_for_backend(temp.path(), "cursor"));
}
+
+ #[test]
+ fn opinionated_status_lists_each_installable_backend() {
+ let temp = tempfile::tempdir().expect("tempdir");
+ let skill_dir = temp.path().join(".codex").join("skills").join("caveman");
+ std::fs::create_dir_all(&skill_dir).expect("create codex skill dir");
+ std::fs::write(skill_dir.join("SKILL.md"), "# Caveman").expect("write skill");
+
+ let statuses = opinionated_backend_statuses(temp.path(), "caveman");
+
+ assert_eq!(
+ statuses
+ .iter()
+ .map(|status| status.id.as_str())
+ .collect::>(),
+ vec![
+ "claude",
+ "codex",
+ "opencode",
+ "cursor",
+ "pi",
+ "commandcode",
+ "grok"
+ ]
+ );
+ assert!(!statuses[0].installed);
+ assert!(statuses[1].installed);
+ assert!(!statuses[2].installed);
+ assert!(!statuses[3].installed);
+ assert!(!statuses[4].installed);
+ assert!(!statuses[5].installed);
+ assert!(!statuses[6].installed);
+ }
}
diff --git a/src-tauri/src/projects/commands.rs b/src-tauri/src/projects/commands.rs
index d1793df49..100ad63b6 100644
--- a/src-tauri/src/projects/commands.rs
+++ b/src-tauri/src/projects/commands.rs
@@ -11052,6 +11052,10 @@ pub async fn list_codex_skills() -> Result, String> {
if let Some(home) = get_home_dir() {
collect_skills_from_dir(&home.join(".codex").join("skills"), &mut skills_map);
+ collect_skills_from_dir(
+ &jean_global_backend_skills_dir(&home, "codex"),
+ &mut skills_map,
+ );
}
let mut skills: Vec = skills_map.into_values().collect();
@@ -11069,6 +11073,10 @@ pub async fn list_opencode_skills() -> Result, String> {
if let Some(home) = get_home_dir() {
collect_skills_from_dir(&opencode_config_dir(&home).join("skills"), &mut skills_map);
+ collect_skills_from_dir(
+ &jean_global_backend_skills_dir(&home, "opencode"),
+ &mut skills_map,
+ );
}
let mut skills: Vec = skills_map.into_values().collect();
@@ -11086,6 +11094,10 @@ pub async fn list_cursor_skills() -> Result, String> {
if let Some(home) = get_home_dir() {
collect_skills_from_dir(&home.join(".cursor").join("skills-cursor"), &mut skills_map);
+ collect_skills_from_dir(
+ &jean_global_backend_skills_dir(&home, "cursor"),
+ &mut skills_map,
+ );
}
let mut skills: Vec = skills_map.into_values().collect();
@@ -11094,6 +11106,43 @@ pub async fn list_cursor_skills() -> Result, String> {
Ok(skills)
}
+/// List Jean-global Pi skills.
+#[tauri::command]
+pub async fn list_pi_skills() -> Result, String> {
+ list_jean_global_backend_skills("pi").await
+}
+
+/// List Jean-global Command Code skills.
+#[tauri::command]
+pub async fn list_commandcode_skills() -> Result, String> {
+ list_jean_global_backend_skills("commandcode").await
+}
+
+/// List Jean-global Grok skills.
+#[tauri::command]
+pub async fn list_grok_skills() -> Result, String> {
+ list_jean_global_backend_skills("grok").await
+}
+
+async fn list_jean_global_backend_skills(backend: &str) -> Result, String> {
+ let mut skills_map = std::collections::HashMap::new();
+
+ if let Some(home) = get_home_dir() {
+ collect_skills_from_dir(
+ &jean_global_backend_skills_dir(&home, backend),
+ &mut skills_map,
+ );
+ }
+
+ let mut skills: Vec = skills_map.into_values().collect();
+ skills.sort_by(|a, b| a.name.cmp(&b.name));
+ Ok(skills)
+}
+
+fn jean_global_backend_skills_dir(home: &std::path::Path, backend: &str) -> std::path::PathBuf {
+ home.join(".jean").join("skills").join(backend)
+}
+
fn opencode_config_dir(home: &std::path::Path) -> std::path::PathBuf {
if let Ok(xdg_config_home) = std::env::var("XDG_CONFIG_HOME") {
return std::path::PathBuf::from(xdg_config_home).join("opencode");
diff --git a/src/components/chat/CompactMessageList.test.tsx b/src/components/chat/CompactMessageList.test.tsx
index 5dd90ddf4..5cf148c66 100644
--- a/src/components/chat/CompactMessageList.test.tsx
+++ b/src/components/chat/CompactMessageList.test.tsx
@@ -267,6 +267,49 @@ describe('CompactMessageList', () => {
expect(screen.queryByText('Polling VM.')).not.toBeInTheDocument()
})
+ it('summarizes fragmented PI text deltas as one meaningful line', () => {
+ renderCompact([
+ message('user-1', 'user', 100, 'create and edit a file'),
+ message(
+ 'assistant-1',
+ 'assistant',
+ 104,
+ 'Yes — created and edited `tmp/test.txt`.',
+ {
+ tool_calls: [
+ {
+ id: 'tool-1',
+ name: 'Write',
+ input: { file_path: 'tmp/test.txt' },
+ output: 'wrote file',
+ },
+ {
+ id: 'tool-2',
+ name: 'Edit',
+ input: { file_path: 'tmp/test.txt' },
+ output: 'edited file',
+ },
+ ],
+ content_blocks: [
+ { type: 'tool_use', tool_call_id: 'tool-1' },
+ { type: 'tool_use', tool_call_id: 'tool-2' },
+ { type: 'text', text: 'Yes — created and edited `' },
+ { type: 'text', text: 'tmp/test.txt' },
+ { type: 'text', text: '`.' },
+ ],
+ }
+ ),
+ ])
+
+ expect(
+ screen.getByRole('button', {
+ name: /Yes — created and edited `tmp\/test\.txt`\./,
+ })
+ ).toBeVisible()
+ expect(screen.queryByRole('button', { name: /^`\./ })).not.toBeInTheDocument()
+ expect(screen.queryByText('`.')).not.toBeInTheDocument()
+ })
+
it('surfaces steered user prompts as separate visible rows', () => {
renderCompact([
message('user-1', 'user', 100, 'do the work'),
diff --git a/src/components/chat/CompactMessageList.tsx b/src/components/chat/CompactMessageList.tsx
index 0d0f99e8f..f39f9bd95 100644
--- a/src/components/chat/CompactMessageList.tsx
+++ b/src/components/chat/CompactMessageList.tsx
@@ -34,7 +34,7 @@ import {
import { MessageItem } from './MessageItem'
import { AskUserQuestion } from './AskUserQuestion'
import { SteeredPromptGroup } from './SteeredPromptGroup'
-import { buildTimeline } from './tool-call-utils'
+import { buildTimeline, coalesceContentBlocks } from './tool-call-utils'
import { formatDuration, getAssistantDurationMs } from './time-utils'
import {
TOOL_CALL_ROW_CLASS,
@@ -229,7 +229,7 @@ function findLatestAssistantText(
const message = group[g]?.message
if (!message || message.role !== 'assistant') continue
- const blocks = message.content_blocks ?? []
+ const blocks = coalesceContentBlocks(message.content_blocks ?? [])
const texts: string[] = []
for (const block of blocks) {
if (block?.type === 'text' && block.text.trim()) {
@@ -320,7 +320,7 @@ function summarizeGroup(
for (let g = group.length - 1; g >= 0; g--) {
const message = group[g]?.message
if (!message) continue
- const blocks: ContentBlock[] = message.content_blocks ?? []
+ const blocks = coalesceContentBlocks(message.content_blocks ?? [])
for (let i = blocks.length - 1; i >= 0; i--) {
const block = blocks[i]
if (!block) continue
diff --git a/src/components/chat/CompactStreamingTicker.test.tsx b/src/components/chat/CompactStreamingTicker.test.tsx
index d1180bef0..6d27cb596 100644
--- a/src/components/chat/CompactStreamingTicker.test.tsx
+++ b/src/components/chat/CompactStreamingTicker.test.tsx
@@ -97,4 +97,29 @@ describe('CompactStreamingTicker', () => {
// Ticker still summarizes the latest activity, not the steered text
expect(screen.getByText('Bash')).toBeVisible()
})
+
+ it('summarizes fragmented PI text deltas as one meaningful line while streaming', () => {
+ render(
+
+ )
+
+ expect(screen.getByRole('button', { name: /Created `tmp\/test\.txt`\./ })).toBeVisible()
+ expect(screen.queryByRole('button', { name: /^`\./ })).not.toBeInTheDocument()
+ })
})
diff --git a/src/components/chat/CompactStreamingTicker.tsx b/src/components/chat/CompactStreamingTicker.tsx
index 97dad01b9..c8714fdc4 100644
--- a/src/components/chat/CompactStreamingTicker.tsx
+++ b/src/components/chat/CompactStreamingTicker.tsx
@@ -13,7 +13,11 @@ import {
} from './ToolCallInline'
import { StreamingMessage } from './StreamingMessage'
import { SteeredPromptGroup } from './SteeredPromptGroup'
-import { isDuplicatePlanTextBlock, resolvePlanContent } from './tool-call-utils'
+import {
+ coalesceContentBlocks,
+ isDuplicatePlanTextBlock,
+ resolvePlanContent,
+} from './tool-call-utils'
import type { ComponentProps } from 'react'
type StreamingMessageProps = ComponentProps
@@ -28,8 +32,9 @@ function summarizeLatest(
streamingContent: string
): { label: string; detail?: string } {
// Prefer the most recent content block (preserves order of text + tools).
- for (let i = contentBlocks.length - 1; i >= 0; i--) {
- const block = contentBlocks[i]
+ const normalizedBlocks = coalesceContentBlocks(contentBlocks)
+ for (let i = normalizedBlocks.length - 1; i >= 0; i--) {
+ const block = normalizedBlocks[i]
if (!block) continue
if (block.type === 'tool_use') {
const tc = toolCalls.find(t => t.id === block.tool_call_id)
diff --git a/src/components/chat/NewSessionModeModal.test.tsx b/src/components/chat/NewSessionModeModal.test.tsx
index 5e4af2924..5d2fb480c 100644
--- a/src/components/chat/NewSessionModeModal.test.tsx
+++ b/src/components/chat/NewSessionModeModal.test.tsx
@@ -609,9 +609,10 @@ describe('NewSessionModeModal', () => {
const prepareCallOrder = invoke.mock.invocationCallOrder[prepareCallIndex]
const trackCallOrder = invoke.mock.invocationCallOrder[trackCallIndex]
- expect(prepareCallOrder).toBeDefined()
- expect(trackCallOrder).toBeDefined()
- expect(prepareCallOrder!).toBeLessThan(trackCallOrder!)
+ if (prepareCallOrder === undefined || trackCallOrder === undefined) {
+ throw new Error('Expected both invoke calls to be recorded')
+ }
+ expect(prepareCallOrder).toBeLessThan(trackCallOrder)
expect(useTerminalStore.getState().terminals['worktree-1']).toHaveLength(1)
})
diff --git a/src/components/chat/hooks/useMessageSending.ts b/src/components/chat/hooks/useMessageSending.ts
index d5de117ea..55f05ca81 100644
--- a/src/components/chat/hooks/useMessageSending.ts
+++ b/src/components/chat/hooks/useMessageSending.ts
@@ -381,11 +381,26 @@ export function useMessageSending({
queryClient.getQueryData<{ name: string }[]>(
skillQueryKeys.cursorSkills()
) ?? []
+ const piSkills =
+ queryClient.getQueryData<{ name: string }[]>(
+ skillQueryKeys.piSkills()
+ ) ?? []
+ const commandcodeSkills =
+ queryClient.getQueryData<{ name: string }[]>(
+ skillQueryKeys.commandcodeSkills()
+ ) ?? []
+ const grokSkills =
+ queryClient.getQueryData<{ name: string }[]>(
+ skillQueryKeys.grokSkills()
+ ) ?? []
const isSkill =
claudeSkills.some(s => s.name === slashName) ||
codexSkills.some(s => s.name === slashName) ||
opencodeSkills.some(s => s.name === slashName) ||
- cursorSkills.some(s => s.name === slashName)
+ cursorSkills.some(s => s.name === slashName) ||
+ piSkills.some(s => s.name === slashName) ||
+ commandcodeSkills.some(s => s.name === slashName) ||
+ grokSkills.some(s => s.name === slashName)
if (!isSkill) {
const claudeCommands =
queryClient.getQueryData<{ name: string; path: string }[]>(
diff --git a/src/components/preferences/panes/OpinionatedPane.test.tsx b/src/components/preferences/panes/OpinionatedPane.test.tsx
index ca241c3c9..e0bf39aa1 100644
--- a/src/components/preferences/panes/OpinionatedPane.test.tsx
+++ b/src/components/preferences/panes/OpinionatedPane.test.tsx
@@ -25,7 +25,19 @@ describe('OpinionatedPane', () => {
vi.clearAllMocks()
vi.mocked(invoke).mockImplementation(async command => {
if (command === 'check_opinionated_plugin_status') {
- return { installed: true, version: 'codex' }
+ return {
+ installed: true,
+ version: 'codex',
+ backends: [
+ { id: 'claude', label: 'Claude', installed: false },
+ { id: 'codex', label: 'Codex', installed: true },
+ { id: 'opencode', label: 'OpenCode', installed: false },
+ { id: 'cursor', label: 'Cursor', installed: true },
+ { id: 'pi', label: 'Pi', installed: false },
+ { id: 'commandcode', label: 'Command Code', installed: false },
+ { id: 'grok', label: 'Grok', installed: false },
+ ],
+ }
}
if (command === 'uninstall_opinionated_plugin') {
return 'Uninstalled'
@@ -55,4 +67,64 @@ describe('OpinionatedPane', () => {
})
})
})
+
+ it('shows opinionated skill installation status for each backend', async () => {
+ render()
+
+ await screen.findByRole('button', { name: /Superpowers/i })
+
+ const superpowersLabel = screen.getByText('Superpowers')
+ const superpowersRow = superpowersLabel.closest('.rounded-lg')
+ if (!superpowersRow) throw new Error('Expected Superpowers row')
+
+ await userEvent.click(
+ within(superpowersRow as HTMLElement).getByText('Superpowers')
+ )
+
+ expect(screen.getByText('Backend status')).toBeInTheDocument()
+ expect(screen.getByText('Claude')).toBeInTheDocument()
+ expect(screen.getByText('Codex')).toBeInTheDocument()
+ expect(screen.getByText('OpenCode')).toBeInTheDocument()
+ expect(screen.getByText('Cursor')).toBeInTheDocument()
+ expect(screen.getByText('Pi')).toBeInTheDocument()
+ expect(screen.getByText('Command Code')).toBeInTheDocument()
+ expect(screen.getByText('Grok')).toBeInTheDocument()
+ expect(screen.getAllByText('Not installed').length).toBeGreaterThan(0)
+ expect(screen.getAllByText('Installed').length).toBeGreaterThan(0)
+ })
+
+ it('shows partial status when an installed skill pack is missing backends', async () => {
+ render()
+
+ await screen.findByRole('button', { name: /Caveman/i })
+
+ const cavemanLabel = screen.getByText('Caveman')
+ const cavemanHeader = cavemanLabel.closest('.rounded-lg')
+ if (!cavemanHeader) throw new Error('Expected Caveman card header')
+
+ expect(
+ within(cavemanHeader as HTMLElement).getByText('Partial')
+ ).toBeInTheDocument()
+ expect(
+ within(cavemanHeader as HTMLElement).getByText('Reinstall')
+ ).toBeInTheDocument()
+ })
+
+ it('uses a stacked, wrapping card header for narrow screens', async () => {
+ render()
+
+ await screen.findByRole('button', { name: /Superpowers/i })
+
+ const superpowersLabel = screen.getByText('Superpowers')
+ const header = superpowersLabel.closest('.rounded-lg')
+ if (!header) throw new Error('Expected Superpowers card header')
+
+ expect(header).toHaveClass('flex-col')
+ expect(header).toHaveClass('sm:flex-row')
+ expect(
+ within(header as HTMLElement)
+ .getByText('Reinstall')
+ .closest('span')
+ ).toHaveClass('flex-wrap')
+ })
})
diff --git a/src/components/preferences/panes/OpinionatedPane.tsx b/src/components/preferences/panes/OpinionatedPane.tsx
index e0bc665c8..0fa87e997 100644
--- a/src/components/preferences/panes/OpinionatedPane.tsx
+++ b/src/components/preferences/panes/OpinionatedPane.tsx
@@ -66,10 +66,18 @@ const PLUGINS: PluginDefinition[] = [
'Cross-backend skill/plugin that reduces output tokens by ~65-75% through terse, caveman-style communication while maintaining technical accuracy.',
githubUrl: 'https://github.com/JuliusBrussee/caveman',
scope: 'ai-backends',
- backends: ['Claude', 'Codex', 'OpenCode', 'Cursor'],
+ backends: [
+ 'Claude',
+ 'Codex',
+ 'OpenCode',
+ 'Cursor',
+ 'Pi',
+ 'Command Code',
+ 'Grok',
+ ],
usage: [
{
- note: "Installs through Caveman's unified installer for every Jean AI backend found on this machine: Claude, Codex, OpenCode, and Cursor.",
+ note: "Installs through Caveman's unified installer where supported, then mirrors Jean-global skills for every Jean AI backend found on this machine.",
},
{
note: 'Claude and OpenCode can auto-activate. Codex and Cursor expose skills for per-session activation with /caveman; Cursor also gets an always-on rule when the installer can write one.',
@@ -95,10 +103,18 @@ const PLUGINS: PluginDefinition[] = [
'Cross-backend skill pack. Adds brainstorming, TDD, systematic debugging, code review, plan writing/execution, parallel agent dispatch, and git worktree workflows.',
githubUrl: 'https://github.com/obra/superpowers',
scope: 'ai-backends',
- backends: ['Claude', 'Codex', 'OpenCode', 'Cursor'],
+ backends: [
+ 'Claude',
+ 'Codex',
+ 'OpenCode',
+ 'Cursor',
+ 'Pi',
+ 'Command Code',
+ 'Grok',
+ ],
usage: [
{
- note: 'Installs through Claude when available, then mirrors Superpowers skills into every installed Jean AI backend. Without Claude, Jean fetches the Superpowers repo directly.',
+ note: 'Installs through Claude when available, then mirrors Superpowers skills into every Jean AI backend. Without Claude, Jean fetches the Superpowers repo directly.',
},
{
label: 'Brainstorm a feature',
@@ -123,6 +139,13 @@ const PLUGINS: PluginDefinition[] = [
interface PluginStatus {
installed: boolean
version: string | null
+ backends?: BackendPluginStatus[]
+}
+
+interface BackendPluginStatus {
+ id: string
+ label: string
+ installed: boolean
}
function PluginCard({ plugin }: { plugin: PluginDefinition }) {
@@ -170,6 +193,11 @@ function PluginCard({ plugin }: { plugin: PluginDefinition }) {
}
}, [plugin.id, plugin.name, checkStatus])
+ const hasMissingBackend =
+ plugin.scope === 'ai-backends' &&
+ (status?.backends?.some(backend => !backend.installed) ?? false)
+ const statusLabel = hasMissingBackend ? 'Partial' : 'Installed'
+
const handleUninstall = useCallback(async () => {
setUninstalling(true)
const toastId = toast.loading(`Uninstalling ${plugin.name}...`)
@@ -191,12 +219,12 @@ function PluginCard({ plugin }: { plugin: PluginDefinition }) {
return (
-
+
-
+
{checking ? (
) : status?.installed ? (
@@ -238,12 +274,29 @@ function PluginCard({ plugin }: { plugin: PluginDefinition }) {
>
+ {plugin.scope === 'ai-backends' && (
+
+ )}
{plugin.scope === 'ai-backends' && (
+ {plugin.scope === 'ai-backends' && status?.backends && (
+
+
+ Backend status
+
+
+ {status.backends.map(backend => (
+
+ {backend.label}
+
+ {backend.installed ? 'Installed' : 'Not installed'}
+
+
+ ))}
+
+
+ )}
+
{plugin.usage.length > 0 && (
How to use
- {plugin.usage.map((step, idx) => (
+ {plugin.usage.map(step => (
-
{step.note &&
{step.note}
}
diff --git a/src/services/skills.ts b/src/services/skills.ts
index 409e7e1b9..05f3c97a8 100644
--- a/src/services/skills.ts
+++ b/src/services/skills.ts
@@ -25,6 +25,10 @@ export const skillQueryKeys = {
codexSkills: () => [...skillQueryKeys.all, 'codex', 'skills'] as const,
opencodeSkills: () => [...skillQueryKeys.all, 'opencode', 'skills'] as const,
cursorSkills: () => [...skillQueryKeys.all, 'cursor', 'skills'] as const,
+ piSkills: () => [...skillQueryKeys.all, 'pi', 'skills'] as const,
+ commandcodeSkills: () =>
+ [...skillQueryKeys.all, 'commandcode', 'skills'] as const,
+ grokSkills: () => [...skillQueryKeys.all, 'grok', 'skills'] as const,
pluginSkills: () => [...skillQueryKeys.all, 'plugin', 'skills'] as const,
}
@@ -77,8 +81,14 @@ export function useClaudeCommands(worktreePath?: string | null) {
}
function useBackendSkills(
- backend: 'codex' | 'opencode' | 'cursor',
- command: 'list_codex_skills' | 'list_opencode_skills' | 'list_cursor_skills',
+ backend: Exclude,
+ command:
+ | 'list_codex_skills'
+ | 'list_opencode_skills'
+ | 'list_cursor_skills'
+ | 'list_pi_skills'
+ | 'list_commandcode_skills'
+ | 'list_grok_skills',
queryKey: readonly unknown[],
label: string
) {
@@ -129,6 +139,33 @@ export function useCursorSkills() {
)
}
+export function usePiSkills() {
+ return useBackendSkills(
+ 'pi',
+ 'list_pi_skills',
+ skillQueryKeys.piSkills(),
+ 'Pi'
+ )
+}
+
+export function useCommandCodeSkills() {
+ return useBackendSkills(
+ 'commandcode',
+ 'list_commandcode_skills',
+ skillQueryKeys.commandcodeSkills(),
+ 'Command Code'
+ )
+}
+
+export function useGrokSkills() {
+ return useBackendSkills(
+ 'grok',
+ 'list_grok_skills',
+ skillQueryKeys.grokSkills(),
+ 'Grok'
+ )
+}
+
export interface BackendSkillsGroup {
backend: CliBackend
label: string
@@ -171,6 +208,9 @@ export function useAllBackendSkills(
const codexSkills = useCodexSkills()
const opencodeSkills = useOpenCodeSkills()
const cursorSkills = useCursorSkills()
+ const piSkills = usePiSkills()
+ const commandcodeSkills = useCommandCodeSkills()
+ const grokSkills = useGrokSkills()
const pluginSkillGroups = usePluginSkills()
return useMemo(() => {
@@ -229,6 +269,32 @@ export function useAllBackendSkills(
}
}
+ if (installed.has('pi')) {
+ const skills = piSkills.data ?? []
+ if (skills.length > 0) {
+ groups.push({ backend: 'pi', label: 'Pi', skills, commands: [] })
+ }
+ }
+
+ if (installed.has('commandcode')) {
+ const skills = commandcodeSkills.data ?? []
+ if (skills.length > 0) {
+ groups.push({
+ backend: 'commandcode',
+ label: 'Command Code',
+ skills,
+ commands: [],
+ })
+ }
+ }
+
+ if (installed.has('grok')) {
+ const skills = grokSkills.data ?? []
+ if (skills.length > 0) {
+ groups.push({ backend: 'grok', label: 'Grok', skills, commands: [] })
+ }
+ }
+
return groups
}, [
claudeSkills.data,
@@ -236,6 +302,9 @@ export function useAllBackendSkills(
codexSkills.data,
opencodeSkills.data,
cursorSkills.data,
+ piSkills.data,
+ commandcodeSkills.data,
+ grokSkills.data,
pluginSkillGroups.data,
installedBackends,
])
diff --git a/tmp/random-agent-test.txt b/tmp/random-agent-test.txt
new file mode 100644
index 000000000..837107737
--- /dev/null
+++ b/tmp/random-agent-test.txt
@@ -0,0 +1,2 @@
+This is a random test file created by the coding assistant.
+Status: edited
From 03a811db5db9db482da4f22282435882488245f4 Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Tue, 30 Jun 2026 15:49:49 +0200
Subject: [PATCH 021/359] fix(chat): show edited files for surfaced latest text
---
.../chat/CompactMessageList.test.tsx | 76 ++++++++++++++++++-
src/components/chat/CompactMessageList.tsx | 10 +++
2 files changed, 85 insertions(+), 1 deletion(-)
diff --git a/src/components/chat/CompactMessageList.test.tsx b/src/components/chat/CompactMessageList.test.tsx
index 5cf148c66..841a5cda1 100644
--- a/src/components/chat/CompactMessageList.test.tsx
+++ b/src/components/chat/CompactMessageList.test.tsx
@@ -267,6 +267,78 @@ describe('CompactMessageList', () => {
expect(screen.queryByText('Polling VM.')).not.toBeInTheDocument()
})
+ it('shows edited files after an externally surfaced recap', () => {
+ renderCompact([
+ message('user-1', 'user', 100, 'make the change'),
+ message(
+ 'assistant-1',
+ 'assistant',
+ 104,
+ 'Done.\n\n## Recap\n\nChanged chat UI.',
+ {
+ tool_calls: [
+ {
+ id: 'tool-1',
+ name: 'FileChange',
+ input: [
+ {
+ path: 'src/components/chat/CompactMessageList.tsx',
+ diff: '@@ -1 +1 @@\n-old\n+new\n',
+ },
+ ],
+ },
+ ],
+ content_blocks: [
+ { type: 'tool_use', tool_call_id: 'tool-1' },
+ { type: 'text', text: 'Done.\n\n## Recap\n\nChanged chat UI.' },
+ ],
+ }
+ ),
+ ])
+
+ const recapHeading = screen.getByText('Recap')
+ const editedFiles = screen.getByText('Edited 1 file:')
+
+ expect(editedFiles).toBeVisible()
+ expect(
+ recapHeading.compareDocumentPosition(editedFiles) &
+ Node.DOCUMENT_POSITION_FOLLOWING
+ ).toBeTruthy()
+ })
+
+ it('shows edited files after surfaced latest text when recap is absent', () => {
+ renderCompact([
+ message('user-1', 'user', 100, 'make the change'),
+ message('assistant-1', 'assistant', 104, 'Changed chat UI.', {
+ tool_calls: [
+ {
+ id: 'tool-1',
+ name: 'FileChange',
+ input: [
+ {
+ path: 'src/components/chat/CompactMessageList.tsx',
+ diff: '@@ -1 +1 @@\n-old\n+new\n',
+ },
+ ],
+ },
+ ],
+ content_blocks: [
+ { type: 'tool_use', tool_call_id: 'tool-1' },
+ { type: 'text', text: 'Changed chat UI.' },
+ ],
+ }),
+ ])
+
+ const latestText = screen.getAllByText('Changed chat UI.').at(-1)
+ const editedFiles = screen.getByText('Edited 1 file:')
+
+ expect(editedFiles).toBeVisible()
+ expect(
+ latestText!.compareDocumentPosition(editedFiles) &
+ Node.DOCUMENT_POSITION_FOLLOWING
+ ).toBeTruthy()
+ })
+
it('summarizes fragmented PI text deltas as one meaningful line', () => {
renderCompact([
message('user-1', 'user', 100, 'create and edit a file'),
@@ -306,7 +378,9 @@ describe('CompactMessageList', () => {
name: /Yes — created and edited `tmp\/test\.txt`\./,
})
).toBeVisible()
- expect(screen.queryByRole('button', { name: /^`\./ })).not.toBeInTheDocument()
+ expect(
+ screen.queryByRole('button', { name: /^`\./ })
+ ).not.toBeInTheDocument()
expect(screen.queryByText('`.')).not.toBeInTheDocument()
})
diff --git a/src/components/chat/CompactMessageList.tsx b/src/components/chat/CompactMessageList.tsx
index f39f9bd95..08d3bbb73 100644
--- a/src/components/chat/CompactMessageList.tsx
+++ b/src/components/chat/CompactMessageList.tsx
@@ -32,6 +32,7 @@ import {
normalizeQuestionMultipleField,
} from '@/types/chat'
import { MessageItem } from './MessageItem'
+import { EditedFilesDisplay } from './EditedFilesDisplay'
import { AskUserQuestion } from './AskUserQuestion'
import { SteeredPromptGroup } from './SteeredPromptGroup'
import { buildTimeline, coalesceContentBlocks } from './tool-call-utils'
@@ -1173,6 +1174,9 @@ export const CompactMessageList = memo(
Boolean(item.latestText) &&
!(latestTextIsRecap && latestRunHasPlan)
const surfaceRecap = latestTextIsRecap && showLatestText
+ const surfacedLatestToolCalls = showLatestText
+ ? item.messages.flatMap(({ message }) => message.tool_calls ?? [])
+ : []
return (
{item.latestText ?? ''}
+ {surfacedLatestToolCalls.length > 0 && (
+
+ )}
)}
From 9bbbebc28d51dcf155a1ddea450af5276985dd7f Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Tue, 30 Jun 2026 15:52:07 +0200
Subject: [PATCH 022/359] feat(chat): allow editing queued prompts (#463)
* feat(chat): allow editing queued prompts
* fix(chat): handle stale queued prompt edits
Remove a queued message locally when persistence reports it was already dequeued, and log failures when saving queued prompt edits.
---
src-tauri/src/chat/commands.rs | 51 ++++++
src-tauri/src/http_server/dispatch.rs | 17 ++
src-tauri/src/lib.rs | 1 +
src/components/chat/ChatWindow.tsx | 7 +-
.../chat/QueuedPromptsPanel.test.tsx | 54 ++++++-
src/components/chat/QueuedPromptsPanel.tsx | 153 +++++++++++++++---
.../chat/hooks/useQueuedPromptActions.test.ts | 65 ++++++++
.../chat/hooks/useQueuedPromptActions.ts | 52 +++++-
src/hooks/useMainWindowEventListeners.ts | 5 +-
src/services/chat.ts | 20 +++
src/store/chat-store.test.ts | 28 ++++
src/store/chat-store.ts | 24 +++
12 files changed, 443 insertions(+), 34 deletions(-)
diff --git a/src-tauri/src/chat/commands.rs b/src-tauri/src/chat/commands.rs
index 1a968a4b1..8fee3cfbb 100644
--- a/src-tauri/src/chat/commands.rs
+++ b/src-tauri/src/chat/commands.rs
@@ -8010,6 +8010,51 @@ pub async fn remove_queued_message(
Ok(())
}
+/// Update a specific queued message's text by its `id` field.
+/// Returns `false` when the message is no longer queued.
+/// Holds the metadata lock across the entire read-modify-write to prevent TOCTOU races.
+#[tauri::command]
+pub async fn update_queued_message(
+ app: AppHandle,
+ _worktree_id: String,
+ _worktree_path: String,
+ session_id: String,
+ message_id: String,
+ message: String,
+) -> Result
{
+ let (updated, queue) = with_existing_metadata_mut(&app, &session_id, |metadata| {
+ let Some(idx) = metadata
+ .queued_messages
+ .iter()
+ .position(|m| m.get("id").and_then(|v| v.as_str()) == Some(message_id.as_str()))
+ else {
+ return (false, metadata.queued_messages.clone());
+ };
+
+ if queued_message_supports_any_steering(&metadata.queued_messages[idx]) {
+ return (false, metadata.queued_messages.clone());
+ }
+
+ let queued = &mut metadata.queued_messages[idx];
+ if let Some(obj) = queued.as_object_mut() {
+ obj.insert("message".to_string(), serde_json::Value::String(message));
+ return (true, metadata.queued_messages.clone());
+ }
+
+ (false, metadata.queued_messages.clone())
+ })?;
+
+ if updated {
+ app.emit_all(
+ "queue:updated",
+ &serde_json::json!({ "sessionId": session_id, "queue": queue }),
+ )
+ .ok();
+ }
+
+ Ok(updated)
+}
+
/// Clear all queued messages for a session.
/// Holds the metadata lock across the entire read-modify-write to prevent TOCTOU races.
#[tauri::command]
@@ -8347,6 +8392,12 @@ fn queued_message_is_steerable_for_backend(msg: &serde_json::Value, backend: &st
})
}
+fn queued_message_supports_any_steering(msg: &serde_json::Value) -> bool {
+ queued_message_is_steerable_for_backend(msg, "codex")
+ || queued_message_is_steerable_for_backend(msg, "opencode")
+ || queued_message_is_steerable_for_backend(msg, "pi")
+}
+
/// Inject a user message into a running Pi RPC turn via the detached PI host.
#[tauri::command]
pub async fn steer_pi_turn(
diff --git a/src-tauri/src/http_server/dispatch.rs b/src-tauri/src/http_server/dispatch.rs
index 49025e27a..91fccef78 100644
--- a/src-tauri/src/http_server/dispatch.rs
+++ b/src-tauri/src/http_server/dispatch.rs
@@ -2623,6 +2623,23 @@ pub async fn dispatch_command(
.await?;
Ok(Value::Null)
}
+ "update_queued_message" => {
+ let worktree_id: String = field(&args, "worktreeId", "worktree_id")?;
+ let worktree_path: String = field(&args, "worktreePath", "worktree_path")?;
+ let session_id: String = field(&args, "sessionId", "session_id")?;
+ let message_id: String = field(&args, "messageId", "message_id")?;
+ let message: String = from_field(&args, "message")?;
+ let result = crate::chat::update_queued_message(
+ app.clone(),
+ worktree_id,
+ worktree_path,
+ session_id,
+ message_id,
+ message,
+ )
+ .await?;
+ to_value(result)
+ }
"clear_message_queue" => {
let worktree_id: String = field(&args, "worktreeId", "worktree_id")?;
let worktree_path: String = field(&args, "worktreePath", "worktree_path")?;
diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs
index 35c1c7385..09813a169 100644
--- a/src-tauri/src/lib.rs
+++ b/src-tauri/src/lib.rs
@@ -4697,6 +4697,7 @@ pub fn run() {
chat::enqueue_message,
chat::dequeue_message,
chat::remove_queued_message,
+ chat::update_queued_message,
chat::clear_message_queue,
chat::move_queued_message_front,
chat::steer_codex_turn,
diff --git a/src/components/chat/ChatWindow.tsx b/src/components/chat/ChatWindow.tsx
index 2b01853d1..47daf3738 100644
--- a/src/components/chat/ChatWindow.tsx
+++ b/src/components/chat/ChatWindow.tsx
@@ -2353,7 +2353,11 @@ export function ChatWindow({
)
// Queued prompts panel actions (remove / send-now)
- const { handleRemoveQueuedMessage, handleSendQueuedNow } =
+ const {
+ handleRemoveQueuedMessage,
+ handleEditQueuedMessage,
+ handleSendQueuedNow,
+ } =
useQueuedPromptActions()
// Pending attachment removal, slash command execution
@@ -3035,6 +3039,7 @@ export function ChatWindow({
isSessionBusy={isSending || isWaitingForInput}
onRemove={handleRemoveQueuedMessage}
onSendNow={handleSendQueuedNow}
+ onEdit={handleEditQueuedMessage}
/>
)}
{/* Input area - unified container with textarea and toolbar */}
diff --git a/src/components/chat/QueuedPromptsPanel.test.tsx b/src/components/chat/QueuedPromptsPanel.test.tsx
index e904ae23d..3be9e8c64 100644
--- a/src/components/chat/QueuedPromptsPanel.test.tsx
+++ b/src/components/chat/QueuedPromptsPanel.test.tsx
@@ -3,7 +3,11 @@ import { render, screen, fireEvent } from '@/test/test-utils'
import { QueuedPromptsPanel } from './QueuedPromptsPanel'
import type { QueuedMessage } from '@/types/chat'
-const createMessage = (id: string, message: string): QueuedMessage => ({
+const createMessage = (
+ id: string,
+ message: string,
+ overrides?: Partial
+): QueuedMessage => ({
id,
message,
pendingImages: [],
@@ -15,6 +19,7 @@ const createMessage = (id: string, message: string): QueuedMessage => ({
executionMode: 'plan',
thinkingLevel: 'off',
queuedAt: 0,
+ ...overrides,
})
describe('QueuedPromptsPanel', () => {
@@ -32,9 +37,11 @@ describe('QueuedPromptsPanel', () => {
messages?: QueuedMessage[]
onRemove?: (sessionId: string, messageId: string) => void
onSendNow?: (sessionId: string, messageId: string) => void
+ onEdit?: (sessionId: string, messageId: string, message: string) => void
}) => {
const onRemove = overrides?.onRemove ?? vi.fn()
const onSendNow = overrides?.onSendNow ?? vi.fn()
+ const onEdit = overrides?.onEdit ?? vi.fn()
const result = render(
{
isSessionBusy={false}
onRemove={onRemove}
onSendNow={onSendNow}
+ onEdit={onEdit}
/>
)
- return { ...result, onRemove, onSendNow }
+ return { ...result, onRemove, onSendNow, onEdit }
}
it('renders count badge and all queued prompts', () => {
@@ -135,6 +143,7 @@ describe('QueuedPromptsPanel', () => {
isSessionBusy={false}
onRemove={vi.fn()}
onSendNow={onSendNow}
+ onEdit={vi.fn()}
/>
)
@@ -169,4 +178,45 @@ describe('QueuedPromptsPanel', () => {
fireEvent.click(thirdSend as HTMLElement)
expect(onSendNow).toHaveBeenCalledWith('session-1', 'msg-3')
})
+
+ it('edits queued prompts when steering is not supported', () => {
+ const { onEdit } = renderPanel()
+
+ const editButton = screen.getAllByLabelText('Edit queued prompt')[0]
+ expect(editButton).toBeDefined()
+ fireEvent.click(editButton as HTMLElement)
+ const editor = screen.getByLabelText('Queued prompt text')
+ fireEvent.change(editor, { target: { value: 'Updated prompt' } })
+ fireEvent.click(screen.getByLabelText('Save queued prompt'))
+
+ expect(onEdit).toHaveBeenCalledWith('session-1', 'msg-1', 'Updated prompt')
+ })
+
+ it('does not show edit for steerable queued prompts', () => {
+ renderPanel({
+ messages: [
+ createMessage('msg-1', 'Codex prompt', {
+ backend: 'codex',
+ } as Partial),
+ createMessage('msg-2', 'OpenCode prompt', {
+ backend: 'opencode',
+ } as Partial),
+ ],
+ })
+
+ expect(screen.queryByLabelText('Edit queued prompt')).not.toBeInTheDocument()
+ })
+
+ it('shows edit for text-only steering backends when attachments prevent steering', () => {
+ renderPanel({
+ messages: [
+ createMessage('msg-1', 'Pi prompt', {
+ backend: 'pi',
+ pendingFiles: [{ id: 'file-1', path: '/tmp/file.txt' }] as never,
+ } as Partial),
+ ],
+ })
+
+ expect(screen.getByLabelText('Edit queued prompt')).toBeInTheDocument()
+ })
})
diff --git a/src/components/chat/QueuedPromptsPanel.tsx b/src/components/chat/QueuedPromptsPanel.tsx
index cc309daab..16c5a2e5b 100644
--- a/src/components/chat/QueuedPromptsPanel.tsx
+++ b/src/components/chat/QueuedPromptsPanel.tsx
@@ -1,5 +1,5 @@
import { memo, useCallback, useEffect, useRef, useState } from 'react'
-import { ChevronRight, Clock, Paperclip, Play, X } from 'lucide-react'
+import { Check, ChevronRight, Clock, Paperclip, Pencil, Play, X } from 'lucide-react'
import {
Collapsible,
CollapsibleContent,
@@ -20,6 +20,7 @@ interface QueuedPromptsPanelProps {
isSessionBusy: boolean
onRemove: (sessionId: string, messageId: string) => void
onSendNow: (sessionId: string, messageId: string) => void
+ onEdit: (sessionId: string, messageId: string, message: string) => void
}
function attachmentCount(msg: QueuedMessage): number {
@@ -31,6 +32,15 @@ function attachmentCount(msg: QueuedMessage): number {
)
}
+function canEditQueuedMessage(msg: QueuedMessage): boolean {
+ const backend = msg.backend ?? 'claude'
+ if (backend === 'codex') return false
+ if ((backend === 'opencode' || backend === 'pi') && attachmentCount(msg) === 0) {
+ return false
+ }
+ return true
+}
+
/**
* Collapsible list of queued prompts shown at the top of the chat window.
*
@@ -46,9 +56,12 @@ export const QueuedPromptsPanel = memo(function QueuedPromptsPanel({
isSessionBusy,
onRemove,
onSendNow,
+ onEdit,
}: QueuedPromptsPanelProps) {
const [isOpen, setIsOpen] = useState(true)
const [selectedIndex, setSelectedIndex] = useState(0)
+ const [editingId, setEditingId] = useState(null)
+ const [editingText, setEditingText] = useState('')
const listRef = useRef(null)
const rowRefs = useRef<(HTMLDivElement | null)[]>([])
@@ -109,6 +122,24 @@ export const QueuedPromptsPanel = memo(function QueuedPromptsPanel({
[messages, selectedIndex, sessionId, onRemove, onSendNow, scrollRowIntoView]
)
+ const startEditing = useCallback((msg: QueuedMessage) => {
+ setEditingId(msg.id)
+ setEditingText(msg.message)
+ }, [])
+
+ const cancelEditing = useCallback(() => {
+ setEditingId(null)
+ setEditingText('')
+ }, [])
+
+ const saveEditing = useCallback(() => {
+ if (!editingId) return
+ const next = editingText.trim()
+ if (!next) return
+ onEdit(sessionId, editingId, next)
+ cancelEditing()
+ }, [cancelEditing, editingId, editingText, onEdit, sessionId])
+
if (messages.length === 0) return null
return (
@@ -143,6 +174,7 @@ export const QueuedPromptsPanel = memo(function QueuedPromptsPanel({
{messages.map((msg, index) => {
const isSelected = index === selectedIndex
const attachments = attachmentCount(msg)
+ const isEditing = editingId === msg.id
return (
#{index + 1}
-
- {msg.message}
-
+ {isEditing ? (
+
)
diff --git a/src/components/chat/hooks/useQueuedPromptActions.test.ts b/src/components/chat/hooks/useQueuedPromptActions.test.ts
index 817054623..1595856c9 100644
--- a/src/components/chat/hooks/useQueuedPromptActions.test.ts
+++ b/src/components/chat/hooks/useQueuedPromptActions.test.ts
@@ -6,6 +6,7 @@ import {
cancelChatMessage,
persistMoveQueuedFront,
persistRemoveQueued,
+ persistUpdateQueued,
steerCodexTurn,
steerOpencodeTurn,
steerPiTurn,
@@ -16,6 +17,7 @@ vi.mock('@/services/chat', () => ({
cancelChatMessage: vi.fn().mockResolvedValue(true),
persistMoveQueuedFront: vi.fn().mockResolvedValue(true),
persistRemoveQueued: vi.fn(),
+ persistUpdateQueued: vi.fn().mockResolvedValue(true),
steerCodexTurn: vi.fn().mockResolvedValue(undefined),
steerOpencodeTurn: vi.fn().mockResolvedValue(undefined),
steerPiTurn: vi.fn().mockResolvedValue(undefined),
@@ -47,6 +49,7 @@ describe('useQueuedPromptActions', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(persistMoveQueuedFront).mockResolvedValue(true)
+ vi.mocked(persistUpdateQueued).mockResolvedValue(true)
vi.mocked(steerCodexTurn).mockResolvedValue(undefined)
vi.mocked(steerOpencodeTurn).mockResolvedValue(undefined)
vi.mocked(steerPiTurn).mockResolvedValue(undefined)
@@ -84,6 +87,68 @@ describe('useQueuedPromptActions', () => {
)
})
+ it('edits a queued message locally and persists update', async () => {
+ const { result } = renderHook(() => useQueuedPromptActions())
+
+ await act(async () => {
+ await result.current.handleEditQueuedMessage(
+ 'session-1',
+ 'msg-2',
+ 'updated prompt'
+ )
+ })
+
+ expect(
+ useChatStore.getState().messageQueues['session-1']?.map(m => m.message)
+ ).toEqual(['prompt msg-1', 'updated prompt', 'prompt msg-3'])
+ expect(persistUpdateQueued).toHaveBeenCalledWith(
+ 'worktree-1',
+ '/tmp/worktree-1',
+ 'session-1',
+ 'msg-2',
+ 'updated prompt'
+ )
+ })
+
+ it('does not edit when the queued message was already dequeued', async () => {
+ vi.mocked(persistUpdateQueued).mockResolvedValue(false)
+ const { result } = renderHook(() => useQueuedPromptActions())
+
+ await act(async () => {
+ await result.current.handleEditQueuedMessage(
+ 'session-1',
+ 'msg-2',
+ 'updated prompt'
+ )
+ })
+
+ expect(
+ useChatStore.getState().messageQueues['session-1']?.map(m => m.message)
+ ).toEqual(['prompt msg-1', 'prompt msg-2', 'prompt msg-3'])
+ })
+
+ it('does not edit queued messages that can be steered', async () => {
+ useChatStore.setState({
+ messageQueues: {
+ 'session-1': [createMessage('msg-1', { backend: 'codex' })],
+ },
+ })
+ const { result } = renderHook(() => useQueuedPromptActions())
+
+ await act(async () => {
+ await result.current.handleEditQueuedMessage(
+ 'session-1',
+ 'msg-1',
+ 'updated prompt'
+ )
+ })
+
+ expect(persistUpdateQueued).not.toHaveBeenCalled()
+ expect(useChatStore.getState().messageQueues['session-1']?.[0]?.message).toBe(
+ 'prompt msg-1'
+ )
+ })
+
it('idle session: promotes the message and force-processes the queue', async () => {
const { result } = renderHook(() => useQueuedPromptActions())
diff --git a/src/components/chat/hooks/useQueuedPromptActions.ts b/src/components/chat/hooks/useQueuedPromptActions.ts
index 9ff764f28..1aefc2987 100644
--- a/src/components/chat/hooks/useQueuedPromptActions.ts
+++ b/src/components/chat/hooks/useQueuedPromptActions.ts
@@ -3,6 +3,7 @@ import {
cancelChatMessage,
persistMoveQueuedFront,
persistRemoveQueued,
+ persistUpdateQueued,
steerCodexTurn,
steerOpencodeTurn,
steerPiTurn,
@@ -27,6 +28,12 @@ function hasAttachments(msg: QueuedMessage): boolean {
)
}
+function supportsSteering(msg: QueuedMessage): boolean {
+ const backend = msg.backend ?? 'claude'
+ if (backend === 'codex') return true
+ return (backend === 'opencode' || backend === 'pi') && !hasAttachments(msg)
+}
+
/**
* Actions for the queued prompts panel: remove a queued prompt, or send a
* specific queued prompt immediately.
@@ -53,6 +60,45 @@ export function useQueuedPromptActions() {
[]
)
+ const handleEditQueuedMessage = useCallback(
+ async (sessionId: string, messageId: string, message: string) => {
+ const trimmed = message.trim()
+ if (!trimmed) return
+
+ const store = useChatStore.getState()
+ const msg = store
+ .getQueuedMessages(sessionId)
+ .find(m => m.id === messageId)
+ if (!msg || supportsSteering(msg)) return
+
+ const { worktreeId, worktreePath } = resolveWorktree(sessionId)
+ if (!worktreeId || !worktreePath) return
+
+ try {
+ const updated = await persistUpdateQueued(
+ worktreeId,
+ worktreePath,
+ sessionId,
+ messageId,
+ trimmed
+ )
+ if (!updated) {
+ // Another client already dequeued it — drop the stale local copy.
+ store.removeQueuedMessage(sessionId, messageId)
+ return
+ }
+ store.updateQueuedMessage(sessionId, messageId, trimmed)
+ } catch (error) {
+ logger.error('Failed to persist queued edit', {
+ error,
+ sessionId,
+ messageId,
+ })
+ }
+ },
+ []
+ )
+
const handleSendQueuedNow = useCallback(
async (sessionId: string, messageId: string) => {
const store = useChatStore.getState()
@@ -138,5 +184,9 @@ export function useQueuedPromptActions() {
[handleRemoveQueuedMessage]
)
- return { handleRemoveQueuedMessage, handleSendQueuedNow }
+ return {
+ handleRemoveQueuedMessage,
+ handleEditQueuedMessage,
+ handleSendQueuedNow,
+ }
}
diff --git a/src/hooks/useMainWindowEventListeners.ts b/src/hooks/useMainWindowEventListeners.ts
index c93ecbb53..290fa92ba 100644
--- a/src/hooks/useMainWindowEventListeners.ts
+++ b/src/hooks/useMainWindowEventListeners.ts
@@ -934,10 +934,7 @@ export function useMainWindowEventListeners() {
const currentQueue =
useChatStore.getState().messageQueues[sessionId] ?? []
// Skip if the queue already matches (this client caused the event)
- if (
- currentQueue.length === queue.length &&
- currentQueue[0]?.id === queue[0]?.id
- )
+ if (JSON.stringify(currentQueue) === JSON.stringify(queue))
return
useChatStore.setState(state => ({
messageQueues: {
diff --git a/src/services/chat.ts b/src/services/chat.ts
index 261962db7..6d401b102 100644
--- a/src/services/chat.ts
+++ b/src/services/chat.ts
@@ -2664,6 +2664,26 @@ export function persistRemoveQueued(
})
}
+/**
+ * Persist a queued message text edit.
+ * Returns false when the message is no longer queued.
+ */
+export async function persistUpdateQueued(
+ worktreeId: string,
+ worktreePath: string,
+ sessionId: string,
+ messageId: string,
+ message: string
+): Promise
{
+ return invoke('update_queued_message', {
+ worktreeId,
+ worktreePath,
+ sessionId,
+ messageId,
+ message,
+ })
+}
+
/**
* Atomically move a specific queued message to the front of the persisted queue.
* Returns false when the message is no longer queued (another client dequeued
diff --git a/src/store/chat-store.test.ts b/src/store/chat-store.test.ts
index 31f1c9be4..1bc70169c 100644
--- a/src/store/chat-store.test.ts
+++ b/src/store/chat-store.test.ts
@@ -790,6 +790,34 @@ describe('ChatStore', () => {
moveQueuedMessageFront('session-1', 'msg-1')
expect(useChatStore.getState().messageQueues['session-1']).toBe(before)
})
+
+ it('updates a queued message text by id', () => {
+ const { enqueueMessage, updateQueuedMessage, getQueuedMessages } =
+ useChatStore.getState()
+
+ enqueueMessage('session-1', createMockMessage('msg-1', 'First'))
+ enqueueMessage('session-1', createMockMessage('msg-2', 'Second'))
+
+ updateQueuedMessage('session-1', 'msg-2', 'Updated second')
+
+ expect(getQueuedMessages('session-1').map(m => m.message)).toEqual([
+ 'First',
+ 'Updated second',
+ ])
+ })
+
+ it('queued message update is a no-op for unknown id or unchanged text', () => {
+ const { enqueueMessage, updateQueuedMessage } = useChatStore.getState()
+
+ enqueueMessage('session-1', createMockMessage('msg-1', 'First'))
+
+ const before = useChatStore.getState().messageQueues['session-1']
+ updateQueuedMessage('session-1', 'unknown-id', 'Updated')
+ expect(useChatStore.getState().messageQueues['session-1']).toBe(before)
+
+ updateQueuedMessage('session-1', 'msg-1', 'First')
+ expect(useChatStore.getState().messageQueues['session-1']).toBe(before)
+ })
})
describe('permission approvals', () => {
diff --git a/src/store/chat-store.ts b/src/store/chat-store.ts
index 55004f640..24ac7df13 100644
--- a/src/store/chat-store.ts
+++ b/src/store/chat-store.ts
@@ -561,6 +561,11 @@ interface ChatUIState {
enqueueMessage: (sessionId: string, message: QueuedMessage) => void
dequeueMessage: (sessionId: string) => QueuedMessage | undefined
removeQueuedMessage: (sessionId: string, messageId: string) => void
+ updateQueuedMessage: (
+ sessionId: string,
+ messageId: string,
+ message: string
+ ) => void
moveQueuedMessageFront: (sessionId: string, messageId: string) => void
clearQueue: (sessionId: string) => void
getQueueLength: (sessionId: string) => number
@@ -2543,6 +2548,25 @@ export const useChatStore = create()(
'removeQueuedMessage'
),
+ updateQueuedMessage: (sessionId, messageId, message) =>
+ set(
+ state => {
+ const queue = state.messageQueues[sessionId] ?? []
+ const idx = queue.findIndex(m => m.id === messageId)
+ if (idx === -1 || queue[idx]?.message === message) return state
+ return {
+ messageQueues: {
+ ...state.messageQueues,
+ [sessionId]: queue.map(m =>
+ m.id === messageId ? { ...m, message } : m
+ ),
+ },
+ }
+ },
+ undefined,
+ 'updateQueuedMessage'
+ ),
+
moveQueuedMessageFront: (sessionId, messageId) =>
set(
state => {
From 7e1c6f4fe016e8ea87fd5a47e23f636ae642fd8f Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Tue, 30 Jun 2026 15:52:08 +0200
Subject: [PATCH 023/359] feat(linear): display cached context images (#462)
* feat(linear): cache context images locally
Download trusted Linear image attachments when saving issue context and
rewrite markdown image paths so cached files render in chat.
* fix(linear): restrict trusted upload image hosts
---
src-tauri/src/projects/linear_issues.rs | 273 +++++++++++++++++++++++-
src/components/ui/markdown.test.tsx | 16 ++
src/components/ui/markdown.tsx | 9 +-
3 files changed, 296 insertions(+), 2 deletions(-)
diff --git a/src-tauri/src/projects/linear_issues.rs b/src-tauri/src/projects/linear_issues.rs
index 24d839ed7..21bbecb0b 100644
--- a/src-tauri/src/projects/linear_issues.rs
+++ b/src-tauri/src/projects/linear_issues.rs
@@ -1,4 +1,7 @@
use serde::{Deserialize, Serialize};
+use sha2::{Digest, Sha256};
+use std::collections::{HashMap, HashSet};
+use std::path::{Path, PathBuf};
use tauri::AppHandle;
use super::github_issues::{
@@ -117,6 +120,7 @@ pub struct LinearTeam {
// =============================================================================
const LINEAR_API_URL: &str = "https://api.linear.app/graphql";
+const MAX_LINEAR_CONTEXT_IMAGE_BYTES: usize = 15 * 1024 * 1024;
async fn linear_graphql(
api_key: &str,
@@ -263,6 +267,218 @@ pub fn format_linear_issue_context_markdown(detail: &LinearIssueDetail) -> Strin
content
}
+fn is_trusted_linear_image_url(url: &str) -> bool {
+ let Ok(parsed) = reqwest::Url::parse(url) else {
+ return false;
+ };
+ if parsed.scheme() != "https" {
+ return false;
+ }
+ parsed
+ .host_str()
+ .map(|host| host == "uploads.linear.app" || host.ends_with(".uploads.linear.app"))
+ .unwrap_or(false)
+}
+
+fn extract_linear_image_urls(markdown: &str) -> Vec {
+ let markdown_image_re = regex::Regex::new(r"!\[[^\]]*\]\(\s*([^)\s>]+)>?\s*\)").unwrap();
+ let html_image_re =
+ regex::Regex::new(r#"
]*\bsrc\s*=\s*["']([^"']+)["'][^>]*>"#).unwrap();
+ let mut seen = HashSet::new();
+ let mut urls = Vec::new();
+
+ for captures in markdown_image_re.captures_iter(markdown) {
+ if let Some(url) = captures.get(1).map(|m| m.as_str()) {
+ if is_trusted_linear_image_url(url) && seen.insert(url.to_string()) {
+ urls.push(url.to_string());
+ }
+ }
+ }
+
+ for captures in html_image_re.captures_iter(markdown) {
+ if let Some(url) = captures.get(1).map(|m| m.as_str()) {
+ if is_trusted_linear_image_url(url) && seen.insert(url.to_string()) {
+ urls.push(url.to_string());
+ }
+ }
+ }
+
+ urls
+}
+
+fn rewrite_linear_image_urls(markdown: &str, replacements: &HashMap) -> String {
+ let markdown_image_re = regex::Regex::new(r"!\[([^\]]*)\]\(\s*([^)\s>]+)>?\s*\)").unwrap();
+ let html_image_re =
+ regex::Regex::new(r#"(
]*\bsrc\s*=\s*["'])([^"']+)(["'][^>]*>)"#).unwrap();
+
+ let rewritten = markdown_image_re.replace_all(markdown, |captures: ®ex::Captures| {
+ let alt = captures.get(1).map(|m| m.as_str()).unwrap_or("");
+ let url = captures.get(2).map(|m| m.as_str()).unwrap_or("");
+ match replacements.get(url) {
+ Some(path) => format!("", path.to_string_lossy()),
+ None => captures
+ .get(0)
+ .map(|m| m.as_str())
+ .unwrap_or("")
+ .to_string(),
+ }
+ });
+
+ html_image_re
+ .replace_all(&rewritten, |captures: ®ex::Captures| {
+ let prefix = captures.get(1).map(|m| m.as_str()).unwrap_or("");
+ let url = captures.get(2).map(|m| m.as_str()).unwrap_or("");
+ let suffix = captures.get(3).map(|m| m.as_str()).unwrap_or("");
+ match replacements.get(url) {
+ Some(path) => format!("{prefix}{}{suffix}", path.to_string_lossy()),
+ None => captures
+ .get(0)
+ .map(|m| m.as_str())
+ .unwrap_or("")
+ .to_string(),
+ }
+ })
+ .to_string()
+}
+
+fn linear_image_extension(url: &str, content_type: Option<&str>) -> &'static str {
+ match content_type
+ .and_then(|ct| ct.split(';').next())
+ .unwrap_or("")
+ {
+ "image/png" => "png",
+ "image/jpeg" => "jpg",
+ "image/gif" => "gif",
+ "image/webp" => "webp",
+ "image/svg+xml" => "svg",
+ _ if url.to_lowercase().contains(".png") => "png",
+ _ if url.to_lowercase().contains(".jpg") || url.to_lowercase().contains(".jpeg") => "jpg",
+ _ if url.to_lowercase().contains(".gif") => "gif",
+ _ if url.to_lowercase().contains(".webp") => "webp",
+ _ if url.to_lowercase().contains(".svg") => "svg",
+ _ => "bin",
+ }
+}
+
+async fn download_linear_context_image(
+ client: &reqwest::Client,
+ api_key: &str,
+ url: &str,
+ cache_dir: &Path,
+) -> Result {
+ if !is_trusted_linear_image_url(url) {
+ return Err(format!("Refusing untrusted Linear image URL: {url}"));
+ }
+
+ let mut hasher = Sha256::new();
+ hasher.update(url.as_bytes());
+ let hash = format!("{:x}", hasher.finalize());
+
+ let mut response = client
+ .get(url)
+ .header("Authorization", api_key)
+ .send()
+ .await
+ .map_err(|e| format!("Failed to download Linear image: {e}"))?;
+
+ let status = response.status();
+ if !status.is_success() {
+ return Err(format!("Linear image download failed with status {status}"));
+ }
+
+ let content_type = response
+ .headers()
+ .get(reqwest::header::CONTENT_TYPE)
+ .and_then(|value| value.to_str().ok())
+ .map(|s| s.to_string());
+ if let Some(ct) = &content_type {
+ if !ct.starts_with("image/") {
+ return Err(format!(
+ "Linear image URL returned non-image content type: {ct}"
+ ));
+ }
+ }
+
+ let mut bytes = Vec::new();
+ while let Some(chunk) = response
+ .chunk()
+ .await
+ .map_err(|e| format!("Failed to read Linear image bytes: {e}"))?
+ {
+ bytes.extend_from_slice(&chunk);
+ if bytes.len() > MAX_LINEAR_CONTEXT_IMAGE_BYTES {
+ return Err("Linear image is too large to cache".to_string());
+ }
+ }
+
+ std::fs::create_dir_all(cache_dir)
+ .map_err(|e| format!("Failed to create Linear image cache directory: {e}"))?;
+ let extension = linear_image_extension(url, content_type.as_deref());
+ let path = cache_dir.join(format!("{hash}.{extension}"));
+ if !path.exists() {
+ std::fs::write(&path, bytes)
+ .map_err(|e| format!("Failed to write Linear image cache file: {e}"))?;
+ }
+ Ok(path)
+}
+
+async fn cache_linear_markdown_images(
+ markdown: &str,
+ client: &reqwest::Client,
+ api_key: &str,
+ cache_dir: &Path,
+ replacements: &mut HashMap,
+) -> String {
+ for url in extract_linear_image_urls(markdown) {
+ if replacements.contains_key(&url) {
+ continue;
+ }
+ match download_linear_context_image(client, api_key, &url, cache_dir).await {
+ Ok(path) => {
+ replacements.insert(url, path);
+ }
+ Err(e) => {
+ log::warn!("Failed to cache Linear context image: {e}");
+ }
+ }
+ }
+
+ rewrite_linear_image_urls(markdown, replacements)
+}
+
+async fn cache_linear_context_images(
+ detail: &mut LinearIssueDetail,
+ api_key: &str,
+ cache_dir: &Path,
+) {
+ let client = reqwest::Client::new();
+ let mut replacements = HashMap::new();
+
+ if let Some(description) = &detail.description {
+ detail.description = Some(
+ cache_linear_markdown_images(
+ description,
+ &client,
+ api_key,
+ cache_dir,
+ &mut replacements,
+ )
+ .await,
+ );
+ }
+
+ for comment in &mut detail.comments {
+ comment.body = cache_linear_markdown_images(
+ &comment.body,
+ &client,
+ api_key,
+ cache_dir,
+ &mut replacements,
+ )
+ .await;
+ }
+}
+
/// Linear config resolved from project + global preferences.
struct LinearConfig {
api_key: String,
@@ -697,7 +913,7 @@ pub async fn load_linear_issue_context(
let config = get_linear_config(&app, &project_id)?;
let project_name = config.project_name;
- let detail = get_linear_issue(app.clone(), project_id, issue_id).await?;
+ let mut detail = get_linear_issue(app.clone(), project_id, issue_id).await?;
// Write to shared git-context directory
let contexts_dir = get_github_contexts_dir(&app)?;
@@ -705,6 +921,12 @@ pub async fn load_linear_issue_context(
.map_err(|e| format!("Failed to create git-context directory: {e}"))?;
let identifier_lower = detail.identifier.to_lowercase();
+ let image_cache_dir = contexts_dir
+ .join("linear-context-images")
+ .join(&project_name)
+ .join(&identifier_lower);
+ cache_linear_context_images(&mut detail, &config.api_key, &image_cache_dir).await;
+
let context_file = contexts_dir.join(format!("{project_name}-linear-{identifier_lower}.md"));
let context_content = format_linear_issue_context_markdown(&detail);
@@ -1009,3 +1231,52 @@ pub fn linear_context_to_detail(ctx: &LinearIssueContext) -> LinearIssueDetail {
comments: ctx.comments.clone(),
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::collections::HashMap;
+ use std::path::Path;
+
+ #[test]
+ fn rewrite_linear_image_urls_replaces_markdown_and_html_images() {
+ let markdown = "Before  after
";
+ let mut replacements = HashMap::new();
+ replacements.insert(
+ "https://uploads.linear.app/a.png".to_string(),
+ Path::new("/tmp/linear/a.png").to_path_buf(),
+ );
+ replacements.insert(
+ "https://uploads.linear.app/b.jpg".to_string(),
+ Path::new("/tmp/linear/b.jpg").to_path_buf(),
+ );
+
+ let rewritten = rewrite_linear_image_urls(markdown, &replacements);
+
+ assert_eq!(
+ rewritten,
+ "Before ![shot]() after
"
+ );
+ }
+
+ #[test]
+ fn extract_linear_image_urls_only_returns_trusted_linear_images() {
+ let markdown = concat!(
+ "\n",
+ "
\n",
+ "\n",
+ "\n",
+ "\n"
+ );
+
+ let urls = extract_linear_image_urls(markdown);
+
+ assert_eq!(
+ urls,
+ vec![
+ "https://uploads.linear.app/a.png".to_string(),
+ "https://uploads.linear.app/b.jpg".to_string(),
+ ]
+ );
+ }
+}
diff --git a/src/components/ui/markdown.test.tsx b/src/components/ui/markdown.test.tsx
index 6a4f832b6..7655a17e6 100644
--- a/src/components/ui/markdown.test.tsx
+++ b/src/components/ui/markdown.test.tsx
@@ -56,4 +56,20 @@ describe('Markdown', () => {
expect(container.querySelectorAll('ol')).toHaveLength(1)
expect(container.querySelector('pre')).not.toBeNull()
})
+
+ it('converts app-data image paths into loadable file URLs', () => {
+ const { container } = render(
+
+ {
+ '![Linear screenshot]()'
+ }
+
+ )
+
+ const image = container.querySelector('img')
+
+ expect(image?.getAttribute('src')).toBe(
+ '/api/files/linear-context-images/ENG-123/image.png'
+ )
+ })
})
diff --git a/src/components/ui/markdown.tsx b/src/components/ui/markdown.tsx
index 7b3c0c5d8..742da97dc 100644
--- a/src/components/ui/markdown.tsx
+++ b/src/components/ui/markdown.tsx
@@ -28,6 +28,7 @@ import {
import { Checkbox } from '@/components/ui/checkbox'
import { cn } from '@/lib/utils'
import { useChatStore } from '@/store/chat-store'
+import { convertFileSrc } from '@/lib/transport'
interface MarkdownProps {
children: string
@@ -134,6 +135,12 @@ function tableToMarkdown(data: string[][]): string {
return [headerLine, separator, ...bodyLines].join('\n')
}
+function markdownImageSrc(src: string | undefined): string | undefined {
+ if (!src) return src
+ if (/^(https?:|data:|blob:|asset:|\/api\/|#)/i.test(src)) return src
+ return convertFileSrc(src)
+}
+
/**
* Prepend a leading checkbox cell into a row by cloning the tr element and
* injecting the new cell before the original children. `leading` must be a
@@ -386,7 +393,7 @@ const components: Components = {
// Images
img: ({ src, alt }) => (
From 0cd65781f33929375f848edef62a104878824d5b Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Tue, 30 Jun 2026 15:52:10 +0200
Subject: [PATCH 024/359] feat(commands): add active session rename command
(#464)
* feat(commands): add session rename command
* test(chat): harden rename behavior source assertions
---
.../SessionChatModal.removal-behavior.test.ts | 19 +++++++
src/components/chat/SessionChatModal.tsx | 51 ++++++++++++++++---
src/hooks/use-command-context.source.test.ts | 23 +++++++++
src/hooks/use-command-context.ts | 18 ++++---
src/lib/commands/commands.test.ts | 7 +++
src/lib/commands/project-commands.ts | 16 ++++++
6 files changed, 121 insertions(+), 13 deletions(-)
create mode 100644 src/hooks/use-command-context.source.test.ts
diff --git a/src/components/chat/SessionChatModal.removal-behavior.test.ts b/src/components/chat/SessionChatModal.removal-behavior.test.ts
index 317d9c823..47e3effff 100644
--- a/src/components/chat/SessionChatModal.removal-behavior.test.ts
+++ b/src/components/chat/SessionChatModal.removal-behavior.test.ts
@@ -6,6 +6,25 @@ const readSource = (path: string) =>
readFileSync(join(process.cwd(), path), 'utf8')
describe('SessionChatModal removal behavior', () => {
+ it('listens for command-palette session rename requests', () => {
+ const source = readSource('src/components/chat/SessionChatModal.tsx')
+
+ expect(source).toMatch(
+ /window\.addEventListener\(\s*'command:rename-session'/
+ )
+ expect(source).toMatch(
+ /window\.removeEventListener\(\s*'command:rename-session'/
+ )
+ })
+
+ it('keeps rename input out of the clickable tab button to avoid accidental close/cancel', () => {
+ const source = readSource('src/components/chat/SessionChatModal.tsx')
+
+ expect(source).not.toMatch(/ e.stopPropagation()}')
+ })
+
it('uses the delete-aware handler when removing non-last tabs', () => {
const source = readSource('src/components/chat/SessionChatModal.tsx')
const start = source.indexOf('const removeSessionTab = useCallback(')
diff --git a/src/components/chat/SessionChatModal.tsx b/src/components/chat/SessionChatModal.tsx
index 40e9e2ea1..7d0c989c0 100644
--- a/src/components/chat/SessionChatModal.tsx
+++ b/src/components/chat/SessionChatModal.tsx
@@ -396,6 +396,32 @@ export function SessionChatModal({
[handleRenameSubmit]
)
+ useEffect(() => {
+ if (!isOpen) return
+
+ const handleRenameSessionCommand = (
+ e: CustomEvent<{ sessionId?: string }>
+ ) => {
+ const sessionId = e.detail?.sessionId
+ if (!sessionId) return
+ const session = sessions.find(s => s.id === sessionId)
+ if (!session || session.archived_at) return
+
+ setRenameValue(session.name)
+ setRenamingSessionId(session.id)
+ }
+
+ window.addEventListener(
+ 'command:rename-session',
+ handleRenameSessionCommand as EventListener
+ )
+ return () =>
+ window.removeEventListener(
+ 'command:rename-session',
+ handleRenameSessionCommand as EventListener
+ )
+ }, [isOpen, sessions])
+
const renameInputRef = useCallback((node: HTMLInputElement | null) => {
if (node) {
node.focus()
@@ -474,7 +500,7 @@ export function SessionChatModal({
)
const handleTabAuxClick = useCallback(
- (e: MouseEvent, session: Session) => {
+ (e: MouseEvent, session: Session) => {
if (e.button !== 1) return
e.preventDefault()
e.stopPropagation()
@@ -542,10 +568,11 @@ export function SessionChatModal({
const handleTabClick = useCallback(
(sessionId: string) => {
+ if (renamingSessionId === sessionId) return
const { setActiveSession } = useChatStore.getState()
setActiveSession(worktreeId, sessionId)
},
- [worktreeId]
+ [renamingSessionId, worktreeId]
)
const reorderSessions = useReorderSessions()
@@ -588,7 +615,7 @@ export function SessionChatModal({
}, [cards])
const handleSessionDragStart = useCallback(
- (e: React.DragEvent, sessionId: string) => {
+ (e: React.DragEvent, sessionId: string) => {
setDraggedSessionId(sessionId)
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData('text/plain', sessionId)
@@ -597,7 +624,7 @@ export function SessionChatModal({
)
const handleSessionDragOver = useCallback(
- (e: React.DragEvent, targetSessionId: string) => {
+ (e: React.DragEvent, targetSessionId: string) => {
if (
draggedSessionId &&
buildReorderedSessionIdsWithinStatus(
@@ -614,7 +641,7 @@ export function SessionChatModal({
)
const handleSessionDrop = useCallback(
- (e: React.DragEvent, targetSessionId: string) => {
+ (e: React.DragEvent, targetSessionId: string) => {
e.preventDefault()
const sourceId =
draggedSessionId || e.dataTransfer.getData('text/plain') || null
@@ -1129,8 +1156,10 @@ export function SessionChatModal({
return (
-
handleSessionDragStart(e, session.id)
@@ -1142,6 +1171,13 @@ export function SessionChatModal({
onDragEnd={() => setDraggedSessionId(null)}
onClick={() => handleTabClick(session.id)}
onAuxClick={e => handleTabAuxClick(e, session)}
+ onKeyDown={e => {
+ if (renamingSessionId === session.id) return
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault()
+ handleTabClick(session.id)
+ }
+ }}
onDoubleClick={() =>
handleStartRenameImmediate(
session.id,
@@ -1178,6 +1214,7 @@ export function SessionChatModal({
onKeyDown={e =>
handleRenameKeyDown(e, session.id)
}
+ onPointerDown={e => e.stopPropagation()}
onClick={e => e.stopPropagation()}
className="w-full min-w-0 bg-transparent text-base outline-none md:text-xs"
/>
@@ -1209,7 +1246,7 @@ export function SessionChatModal({
size="xs"
/>
)}
-
+
+ readFileSync(join(process.cwd(), path), 'utf8')
+
+describe('useCommandContext session rename wiring', () => {
+ it('falls back to the selected worktree when dispatching rename-session', () => {
+ const source = readSource('src/hooks/use-command-context.ts')
+ const start = source.indexOf('const renameSession = useCallback(')
+ // Anchor on the callback's closing `}, [...])` rather than a comment or
+ // blank-line spacing, so harmless refactors above/below don't silently
+ // truncate the extracted body to '' and mask a real regression.
+ const close = source.indexOf('\n }, [', start)
+ const end = close === -1 ? -1 : source.indexOf(')', close)
+ const renameSession =
+ start === -1 || end === -1 ? '' : source.slice(start, end)
+
+ expect(renameSession).toContain('useProjectsStore.getState().selectedWorktreeId')
+ expect(renameSession).toContain("new CustomEvent('command:rename-session'")
+ })
+})
diff --git a/src/hooks/use-command-context.ts b/src/hooks/use-command-context.ts
index 55d55e2da..f09116438 100644
--- a/src/hooks/use-command-context.ts
+++ b/src/hooks/use-command-context.ts
@@ -192,13 +192,16 @@ export function useCommandContext(
// Sessions - Rename session
const renameSession = useCallback(() => {
- const { activeWorktreeId, getActiveSession } = useChatStore.getState()
- if (!activeWorktreeId) {
+ const chatState = useChatStore.getState()
+ const worktreeId =
+ chatState.activeWorktreeId ??
+ useProjectsStore.getState().selectedWorktreeId
+ if (!worktreeId) {
notify('No worktree selected', undefined, { type: 'error' })
return
}
- const sessionId = getActiveSession(activeWorktreeId)
+ const sessionId = chatState.getActiveSession(worktreeId)
if (!sessionId) {
notify('No session selected', undefined, { type: 'error' })
return
@@ -451,9 +454,12 @@ export function useCommandContext(
// State getters
const hasActiveSession = useCallback(() => {
- const { activeWorktreeId, getActiveSession } = useChatStore.getState()
- if (!activeWorktreeId) return false
- return !!getActiveSession(activeWorktreeId)
+ const chatState = useChatStore.getState()
+ const worktreeId =
+ chatState.activeWorktreeId ??
+ useProjectsStore.getState().selectedWorktreeId
+ if (!worktreeId) return false
+ return !!chatState.getActiveSession(worktreeId)
}, [])
const hasActiveWorktree = useCallback(() => {
diff --git a/src/lib/commands/commands.test.ts b/src/lib/commands/commands.test.ts
index 29fe37df8..48f9683d8 100644
--- a/src/lib/commands/commands.test.ts
+++ b/src/lib/commands/commands.test.ts
@@ -231,6 +231,13 @@ describe('Project Commands', () => {
expect(useUIStore.getState().onboardingDismissed).toBe(false)
expect(useUIStore.getState().featureTourOpen).toBe(false)
})
+
+ it('rename session command delegates to session rename wiring', async () => {
+ const result = await executeCommand('rename-session', mockContext)
+
+ expect(result.success).toBe(true)
+ expect(mockContext.renameSession).toHaveBeenCalled()
+ })
})
describe('Notification Commands', () => {
diff --git a/src/lib/commands/project-commands.ts b/src/lib/commands/project-commands.ts
index 78eece73b..b7a7bdb44 100644
--- a/src/lib/commands/project-commands.ts
+++ b/src/lib/commands/project-commands.ts
@@ -8,6 +8,7 @@ import {
RefreshCw,
BellDot,
Download,
+ Pencil,
} from 'lucide-react'
import type { AppCommand } from './types'
import { useUIStore } from '@/store/ui-store'
@@ -175,6 +176,21 @@ export const projectCommands: AppCommand[] = [
},
},
+ {
+ id: 'rename-session',
+ label: 'Rename Session',
+ description: 'Rename the current session tab',
+ icon: Pencil,
+ group: 'sessions',
+ keywords: ['session', 'title', 'name', 'rename', 'tab'],
+
+ isAvailable: context => context.hasActiveSession(),
+
+ execute: context => {
+ context.renameSession()
+ },
+ },
+
{
id: 'regenerate-session-title',
label: 'Regenerate Session Title',
From 85182f2b6122307c9c4983ebea11f2961683a675 Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Tue, 30 Jun 2026 20:43:37 +0200
Subject: [PATCH 025/359] feat(preferences): add Claude Sonnet 5 model option
---
src-tauri/src/lib.rs | 10 +++++++++-
src/components/preferences/panes/MagicPromptsPane.tsx | 3 ++-
src/services/preferences.test.ts | 5 +++--
src/types/preferences.ts | 4 +++-
4 files changed, 17 insertions(+), 5 deletions(-)
diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs
index 09813a169..f7ec23219 100644
--- a/src-tauri/src/lib.rs
+++ b/src-tauri/src/lib.rs
@@ -465,7 +465,7 @@ fn migrate_default_claude_model(model: &str) -> Option<&'static str> {
"claude-opus-4-7[1m]" => Some("claude-opus-4-8[1m]"),
"claude-opus-4-7[1m]-fast" => Some("claude-opus-4-8[1m]-fast"),
"claude-opus-4-6-fast" => Some("claude-opus-4-6[1m]-fast"),
- "sonnet" => Some("claude-sonnet-4-6[1m]"),
+ "sonnet" => Some("claude-sonnet-5"),
_ => None,
}
}
@@ -873,6 +873,14 @@ mod tests {
assert_eq!(super::migrate_default_claude_model("claude-opus-4-6"), None);
}
+ #[test]
+ fn migrate_default_claude_model_updates_sonnet_alias() {
+ assert_eq!(
+ super::migrate_default_claude_model("sonnet"),
+ Some("claude-sonnet-5")
+ );
+ }
+
#[test]
fn app_preferences_default_web_access_sounds_enabled_for_existing_prefs() {
let mut prefs_json = serde_json::to_value(AppPreferences::default()).unwrap();
diff --git a/src/components/preferences/panes/MagicPromptsPane.tsx b/src/components/preferences/panes/MagicPromptsPane.tsx
index 67d9119b0..bdcaeb43a 100644
--- a/src/components/preferences/panes/MagicPromptsPane.tsx
+++ b/src/components/preferences/panes/MagicPromptsPane.tsx
@@ -517,6 +517,7 @@ export function getMagicPromptItemId(key: keyof MagicPrompts): string {
}
const CLAUDE_MODEL_OPTIONS: { value: MagicPromptModel; label: string }[] = [
+ { value: 'claude-sonnet-5', label: 'Sonnet 5' },
{ value: 'claude-opus-4-8[1m]', label: 'Opus 4.8 (1M)' },
{ value: 'claude-opus-4-8', label: 'Opus 4.8' },
{ value: 'claude-opus-4-7[1m]', label: 'Opus 4.7 (1M)' },
@@ -525,7 +526,7 @@ const CLAUDE_MODEL_OPTIONS: { value: MagicPromptModel; label: string }[] = [
{ value: 'claude-opus-4-6', label: 'Opus 4.6' },
{ value: 'claude-sonnet-4-6[1m]', label: 'Sonnet 4.6 (1M)' },
{ value: 'claude-sonnet-4-6', label: 'Sonnet 4.6' },
- { value: 'sonnet', label: 'Sonnet 4.6 (provider alias)' },
+ { value: 'sonnet', label: 'Sonnet (provider alias)' },
{ value: 'haiku', label: 'Haiku' },
]
diff --git a/src/services/preferences.test.ts b/src/services/preferences.test.ts
index 9d3d1a855..237750ad7 100644
--- a/src/services/preferences.test.ts
+++ b/src/services/preferences.test.ts
@@ -98,6 +98,7 @@ describe('model option helpers', () => {
it('offers Claude 1M variants alongside standard context models', () => {
expect(modelOptions.map(option => option.value)).toEqual([
'claude-fable-5',
+ 'claude-sonnet-5',
'claude-opus-4-8[1m]',
'claude-opus-4-8',
'claude-opus-4-7[1m]',
@@ -109,8 +110,9 @@ describe('model option helpers', () => {
'claude-sonnet-4-6',
'haiku',
])
- expect(normalizeClaudeModel('sonnet')).toBe('claude-sonnet-4-6[1m]')
+ expect(normalizeClaudeModel('sonnet')).toBe('claude-sonnet-5')
expect(normalizeClaudeModel('claude-fable-5')).toBe('claude-fable-5')
+ expect(normalizeClaudeModel('claude-sonnet-5')).toBe('claude-sonnet-5')
expect(normalizeClaudeModel('claude-opus-4-8')).toBe('claude-opus-4-8')
expect(normalizeClaudeModel('claude-opus-4-7')).toBe('claude-opus-4-7')
expect(normalizeClaudeModel('claude-opus-4-6')).toBe('claude-opus-4-6')
@@ -1202,4 +1204,3 @@ describe('preferences service', () => {
})
})
})
-
diff --git a/src/types/preferences.ts b/src/types/preferences.ts
index 25de4cde8..326983fd7 100644
--- a/src/types/preferences.ts
+++ b/src/types/preferences.ts
@@ -1241,6 +1241,7 @@ export const fileEditModeOptions: { value: FileEditMode; label: string }[] = [
export type ClaudeModel =
| 'claude-fable-5'
+ | 'claude-sonnet-5'
| 'claude-opus-4-8'
| 'claude-opus-4-8[1m]'
| 'claude-opus-4-7'
@@ -1260,6 +1261,7 @@ export type ClaudeModel =
export const modelOptions: { value: ClaudeModel; label: string }[] = [
{ value: 'claude-fable-5', label: 'Claude Fable 5' },
+ { value: 'claude-sonnet-5', label: 'Claude Sonnet 5' },
{ value: 'claude-opus-4-8[1m]', label: 'Claude Opus 4.8 (1M)' },
{ value: 'claude-opus-4-8', label: 'Claude Opus 4.8' },
{ value: 'claude-opus-4-7[1m]', label: 'Claude Opus 4.7 (1M)' },
@@ -1274,7 +1276,7 @@ export const modelOptions: { value: ClaudeModel; label: string }[] = [
const legacyClaudeDefaultModelMap = {
'claude-opus-4-6-fast': 'claude-opus-4-6[1m]-fast',
- sonnet: 'claude-sonnet-4-6[1m]',
+ sonnet: 'claude-sonnet-5',
} as const satisfies Partial>
const knownClaudeModels = new Set([
From a1de352fcd314075b6a60c7b73bd524468939323 Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Tue, 30 Jun 2026 20:57:45 +0200
Subject: [PATCH 026/359] fix(chat): format future Claude models in mobile
picker
---
.../chat/MessageSettingsBadges.test.tsx | 16 ++++
.../chat/message-settings-labels.ts | 1 +
.../MobileBackendModelPickerSheet.test.tsx | 89 +++++++++++++++++++
.../toolbar/MobileBackendModelPickerSheet.tsx | 10 ++-
4 files changed, 113 insertions(+), 3 deletions(-)
create mode 100644 src/components/chat/toolbar/MobileBackendModelPickerSheet.test.tsx
diff --git a/src/components/chat/MessageSettingsBadges.test.tsx b/src/components/chat/MessageSettingsBadges.test.tsx
index 2f831747b..a83b95e1e 100644
--- a/src/components/chat/MessageSettingsBadges.test.tsx
+++ b/src/components/chat/MessageSettingsBadges.test.tsx
@@ -80,6 +80,22 @@ describe('MessageSettingsBadges', () => {
expect(screen.queryByText('claude-opus-4-6[1m]-fast')).toBeNull()
})
+ it('prefixes future Claude model ids with Claude in prompt badges', () => {
+ render(
+
+ )
+
+ expect(screen.getByText('Claude · claude-sonnet-5-1')).toBeVisible()
+ expect(screen.getByText('· Yolo')).toBeVisible()
+ expect(screen.getByText('· Megathink')).toBeVisible()
+ })
+
it('formats OpenCode slash models with backend prefix', () => {
render(
option.value === model)) return true
+ if (model.startsWith('claude-')) return true
const claudeFastInfo = getClaudeFastInfo(model)
return (
diff --git a/src/components/chat/toolbar/MobileBackendModelPickerSheet.test.tsx b/src/components/chat/toolbar/MobileBackendModelPickerSheet.test.tsx
new file mode 100644
index 000000000..c54bd96f4
--- /dev/null
+++ b/src/components/chat/toolbar/MobileBackendModelPickerSheet.test.tsx
@@ -0,0 +1,89 @@
+import { describe, expect, it, vi, beforeEach } from 'vitest'
+import { render, screen } from '@/test/test-utils'
+import { MobileBackendModelPickerSheet } from './MobileBackendModelPickerSheet'
+
+vi.mock('@/services/opencode-cli', () => ({
+ useAvailableOpencodeModels: () => ({ data: [] }),
+}))
+vi.mock('@/services/cursor-cli', () => ({
+ useAvailableCursorModels: () => ({ data: [] }),
+}))
+vi.mock('@/services/pi-cli', () => ({
+ useAvailablePiModels: () => ({ data: [] }),
+}))
+vi.mock('@/services/commandcode-cli', () => ({
+ useAvailableCommandCodeModels: () => ({ data: [] }),
+}))
+vi.mock('@/services/grok-cli', () => ({
+ useAvailableGrokModels: () => ({ data: [] }),
+}))
+vi.mock('@/services/preferences', () => ({
+ usePreferences: () => ({
+ data: { favorite_models: [], fast_mode_models: [] },
+ }),
+ usePatchPreferences: () => ({ mutate: vi.fn() }),
+}))
+vi.mock('@/services/model-catalog', () => ({
+ getCatalogModelFastInfo: () => ({ supportsFast: false, isFast: false }),
+ getCatalogModelOptions: (_catalog: unknown, backend: string) =>
+ backend === 'codex'
+ ? [{ value: 'gpt-5.5', label: 'GPT 5.5' }]
+ : [{ value: 'claude-opus-4-8[1m]', label: 'Claude Opus 4.8 (1M)' }],
+ useModelCatalog: () => ({ data: undefined }),
+ useRefreshModelCatalog: () => ({ mutateAsync: vi.fn(), isPending: false }),
+}))
+
+beforeEach(() => {
+ Object.defineProperty(window, 'innerWidth', {
+ configurable: true,
+ writable: true,
+ value: 390,
+ })
+ vi.stubGlobal(
+ 'matchMedia',
+ vi.fn().mockImplementation(() => ({
+ matches: true,
+ media: '(max-width: 767px)',
+ onchange: null,
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ }))
+ )
+ vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
+ cb(0)
+ return 1
+ })
+ vi.stubGlobal('cancelAnimationFrame', vi.fn())
+})
+
+describe('MobileBackendModelPickerSheet', () => {
+ it('constrains the bottom sheet and makes the model list the scroll container', () => {
+ render(
+
+ )
+
+ const sheet = screen.getByRole('dialog')
+ expect(sheet).toHaveClass('h-[75svh]', 'min-h-0', 'overflow-hidden')
+
+ const commandList = sheet.querySelector('[data-slot="command-list"]')
+ expect(commandList).toHaveClass(
+ '!max-h-none',
+ 'min-h-0',
+ 'flex-1',
+ 'overflow-y-auto'
+ )
+ })
+})
diff --git a/src/components/chat/toolbar/MobileBackendModelPickerSheet.tsx b/src/components/chat/toolbar/MobileBackendModelPickerSheet.tsx
index b637f9d47..b21ec59d1 100644
--- a/src/components/chat/toolbar/MobileBackendModelPickerSheet.tsx
+++ b/src/components/chat/toolbar/MobileBackendModelPickerSheet.tsx
@@ -1,6 +1,7 @@
import {
Sheet,
SheetContent,
+ SheetDescription,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet'
@@ -47,13 +48,16 @@ export function MobileBackendModelPickerSheet({
-
+
Select Backend & Model
+
+ Search and select the backend and model for this chat session.
+
onOpenChange(false)}
className="min-h-0"
- commandListClassName="max-h-none flex-1 pb-[calc(env(safe-area-inset-bottom)+0.5rem)]"
+ commandListClassName="!max-h-none min-h-0 flex-1 overflow-y-auto pb-[calc(env(safe-area-inset-bottom)+0.5rem)]"
/>
From 0cd2bd1fc42f2006441abe7934dde5747b62654f Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Tue, 30 Jun 2026 23:28:35 +0200
Subject: [PATCH 027/359] feat(chat): fork sessions into new worktrees (#465)
Add a backend command to create a worktree from a session and wire it
through chat, magic commands, and the mobile toolbar.
---
src-tauri/src/http_server/dispatch.rs | 13 +
src-tauri/src/lib.rs | 1 +
src-tauri/src/projects/commands.rs | 502 +++++++++++++++++-
src/components/chat/ChatWindow.tsx | 89 +++-
.../chat/CompactMessageList.test.tsx | 4 +-
.../chat/fork-session-navigation.test.ts | 81 +++
.../chat/fork-session-navigation.ts | 45 ++
.../chat/hooks/useMagicCommands.test.ts | 13 +
src/components/chat/hooks/useMagicCommands.ts | 7 +
.../chat/hooks/useQueuedPromptActions.test.ts | 4 +-
.../chat/toolbar/MobileToolbarMenu.test.tsx | 37 ++
.../chat/toolbar/MobileToolbarMenu.tsx | 23 +
src/components/magic/MagicModal.test.tsx | 8 +
src/components/magic/MagicModal.tsx | 9 +
14 files changed, 829 insertions(+), 7 deletions(-)
create mode 100644 src/components/chat/fork-session-navigation.test.ts
create mode 100644 src/components/chat/fork-session-navigation.ts
diff --git a/src-tauri/src/http_server/dispatch.rs b/src-tauri/src/http_server/dispatch.rs
index 91fccef78..53882b7f3 100644
--- a/src-tauri/src/http_server/dispatch.rs
+++ b/src-tauri/src/http_server/dispatch.rs
@@ -208,6 +208,19 @@ pub async fn dispatch_command(
// preventing WorktreeSetupCard from appearing on the canvas.
to_value(result)
}
+ "fork_session_to_worktree" => {
+ let source_worktree_id: String =
+ field(&args, "sourceWorktreeId", "source_worktree_id")?;
+ let source_session_id: String = field(&args, "sourceSessionId", "source_session_id")?;
+ let result = crate::projects::fork_session_to_worktree(
+ app.clone(),
+ source_worktree_id,
+ source_session_id,
+ )
+ .await?;
+ emit_cache_invalidation(app, &["projects", "sessions", "session"]);
+ to_value(result)
+ }
"delete_worktree" => {
let worktree_id: String = field(&args, "worktreeId", "worktree_id")?;
crate::projects::delete_worktree(app.clone(), worktree_id).await?;
diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs
index f7ec23219..48745fdb2 100644
--- a/src-tauri/src/lib.rs
+++ b/src-tauri/src/lib.rs
@@ -4480,6 +4480,7 @@ pub fn run() {
projects::get_worktree_diff,
projects::create_worktree,
projects::create_worktree_from_existing_branch,
+ projects::fork_session_to_worktree,
projects::checkout_pr,
projects::delete_worktree,
projects::create_base_session,
diff --git a/src-tauri/src/projects/commands.rs b/src-tauri/src/projects/commands.rs
index 100ad63b6..461b05fd2 100644
--- a/src-tauri/src/projects/commands.rs
+++ b/src-tauri/src/projects/commands.rs
@@ -3,6 +3,7 @@ use once_cell::sync::Lazy;
use regex::Regex;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
+use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::Stdio;
@@ -45,7 +46,7 @@ use super::types::{
WorktreeDeletingEvent, WorktreeOrigin, WorktreePathExistsEvent,
WorktreePermanentlyDeletedEvent, WorktreeSetupCompleteEvent, WorktreeUnarchivedEvent,
};
-use crate::chat::types::LabelData;
+use crate::chat::types::{LabelData, Session, SessionMetadata, WorktreeSessions};
use crate::claude_cli::resolve_cli_binary;
use crate::coderabbit_cli::resolve_coderabbit_binary;
use crate::codex_cli::resolve_cli_binary as resolve_codex_cli_binary;
@@ -102,6 +103,12 @@ const FAVICON_CANDIDATES: &[&str] = &[
".idea/icon.svg",
];
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct ForkSessionToWorktreeResponse {
+ pub worktree: Worktree,
+ pub session: Session,
+}
+
const ICON_SOURCE_FILES: &[&str] = &[
"index.html",
"public/index.html",
@@ -1007,6 +1014,217 @@ fn truncate_utf8(input: &str, max_bytes: usize) -> (String, bool) {
)
}
+fn run_git_with_input(repo_path: &Path, args: &[&str], input: &[u8]) -> Result<(), String> {
+ let mut child = silent_command("git")
+ .args(args)
+ .current_dir(repo_path)
+ .stdin(Stdio::piped())
+ .stdout(Stdio::piped())
+ .stderr(Stdio::piped())
+ .spawn()
+ .map_err(|e| format!("Failed to run git {}: {e}", args.join(" ")))?;
+
+ if let Some(mut stdin) = child.stdin.take() {
+ stdin
+ .write_all(input)
+ .map_err(|e| format!("Failed to write to git {} stdin: {e}", args.join(" ")))?;
+ }
+
+ let output = child
+ .wait_with_output()
+ .map_err(|e| format!("Failed to wait for git {}: {e}", args.join(" ")))?;
+ if output.status.success() {
+ Ok(())
+ } else {
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ Err(format!("git {} failed: {stderr}", args.join(" ")))
+ }
+}
+
+fn git_output_bytes(repo_path: &Path, args: &[&str]) -> Result, String> {
+ let output = silent_command("git")
+ .args(args)
+ .current_dir(repo_path)
+ .output()
+ .map_err(|e| format!("Failed to run git {}: {e}", args.join(" ")))?;
+ if !output.status.success() {
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ return Err(format!("git {} failed: {stderr}", args.join(" ")));
+ }
+ Ok(output.stdout)
+}
+
+fn copy_untracked_files(source_path: &Path, target_path: &Path) -> Result<(), String> {
+ let output = git_output_bytes(
+ source_path,
+ &["ls-files", "--others", "--exclude-standard", "-z"],
+ )?;
+ for raw_path in output
+ .split(|byte| *byte == 0)
+ .filter(|path| !path.is_empty())
+ {
+ let relative = std::str::from_utf8(raw_path)
+ .map_err(|e| format!("Git returned a non-UTF8 untracked path: {e}"))?;
+ let source_file = source_path.join(relative);
+ if !source_file.is_file() {
+ continue;
+ }
+ let target_file = target_path.join(relative);
+ if let Some(parent) = target_file.parent() {
+ fs::create_dir_all(parent)
+ .map_err(|e| format!("Failed to create {}: {e}", parent.display()))?;
+ }
+ fs::copy(&source_file, &target_file).map_err(|e| {
+ format!(
+ "Failed to copy untracked file {} to {}: {e}",
+ source_file.display(),
+ target_file.display()
+ )
+ })?;
+ }
+ Ok(())
+}
+
+fn copy_dirty_worktree_state(source_path: &Path, target_path: &Path) -> Result<(), String> {
+ let patch = git_output_bytes(source_path, &["diff", "--binary", "HEAD"])?;
+ if !patch.is_empty() {
+ run_git_with_input(
+ target_path,
+ &["apply", "--binary", "--whitespace=nowarn"],
+ &patch,
+ )?;
+ }
+ copy_untracked_files(source_path, target_path)
+}
+
+fn forked_session_name(source_name: &str) -> String {
+ let trimmed = source_name.trim();
+ if trimmed.is_empty() {
+ "Forked Session".to_string()
+ } else if trimmed.starts_with("Fork of ") {
+ trimmed.to_string()
+ } else {
+ format!("Fork of {trimmed}")
+ }
+}
+
+fn clear_session_runtime_state(session: &mut Session) {
+ session.claude_session_id = None;
+ session.codex_thread_id = None;
+ session.codex_goal = None;
+ session.opencode_session_id = None;
+ session.cursor_chat_id = None;
+ session.pi_session_id = None;
+ session.commandcode_session_id = None;
+ session.grok_session_id = None;
+ session.is_reviewing = false;
+ session.waiting_for_input = false;
+ session.waiting_for_input_type = None;
+ session.pending_permission_denials.clear();
+ session.pending_codex_permission_requests.clear();
+ session.pending_codex_command_approval_requests.clear();
+ session.pending_codex_user_input_requests.clear();
+ session.pending_codex_mcp_elicitation_requests.clear();
+ session.pending_codex_dynamic_tool_call_requests.clear();
+ session.denied_message_context = None;
+ session.queued_messages.clear();
+ session.scheduled_wakeup = None;
+ session.last_run_status = None;
+ session.last_run_execution_mode = None;
+ session.last_run_started_at = None;
+}
+
+fn prepare_forked_session(
+ source: &Session,
+ _new_worktree_id: &str,
+ order: u32,
+ created_at: u64,
+) -> Session {
+ let mut forked = source.clone();
+ forked.id = Uuid::new_v4().to_string();
+ forked.name = forked_session_name(&source.name);
+ forked.order = order;
+ forked.created_at = created_at;
+ forked.updated_at = created_at;
+ forked.last_opened_at = Some(created_at);
+ forked.archived_at = None;
+ forked.archived_by_base_close = None;
+ forked.session_naming_completed = false;
+ clear_session_runtime_state(&mut forked);
+ forked
+}
+
+fn prepare_forked_metadata(
+ source: Option,
+ forked_session: &Session,
+ new_worktree_id: &str,
+) -> SessionMetadata {
+ let mut metadata = source.unwrap_or_else(|| {
+ SessionMetadata::new(
+ forked_session.id.clone(),
+ new_worktree_id.to_string(),
+ forked_session.name.clone(),
+ forked_session.order,
+ )
+ });
+ metadata.id = forked_session.id.clone();
+ metadata.worktree_id = new_worktree_id.to_string();
+ metadata.name = forked_session.name.clone();
+ metadata.order = forked_session.order;
+ metadata.created_at = forked_session.created_at;
+ metadata.claude_session_id = None;
+ metadata.codex_thread_id = None;
+ metadata.codex_goal = None;
+ metadata.opencode_session_id = None;
+ metadata.cursor_chat_id = None;
+ metadata.pi_session_id = None;
+ metadata.commandcode_session_id = None;
+ metadata.grok_session_id = None;
+ metadata.session_naming_completed = false;
+ metadata.archived_at = None;
+ metadata.archived_by_base_close = None;
+ metadata.pending_permission_denials.clear();
+ metadata.pending_codex_permission_requests.clear();
+ metadata.pending_codex_command_approval_requests.clear();
+ metadata.pending_codex_user_input_requests.clear();
+ metadata.pending_codex_mcp_elicitation_requests.clear();
+ metadata.pending_codex_dynamic_tool_call_requests.clear();
+ metadata.denied_message_context = None;
+ metadata.is_reviewing = false;
+ metadata.waiting_for_input = false;
+ metadata.waiting_for_input_type = None;
+ metadata.queued_messages.clear();
+ metadata.scheduled_wakeup = None;
+ metadata
+}
+
+fn copy_session_run_files(
+ app: &AppHandle,
+ source_session_id: &str,
+ target_session_id: &str,
+) -> Result<(), String> {
+ let source_dir = crate::chat::storage::get_session_dir(app, source_session_id)?;
+ if !source_dir.exists() {
+ return Ok(());
+ }
+ let target_dir = crate::chat::storage::get_session_dir(app, target_session_id)?;
+ fs::create_dir_all(&target_dir)
+ .map_err(|e| format!("Failed to create forked session log directory: {e}"))?;
+ for entry in fs::read_dir(&source_dir)
+ .map_err(|e| format!("Failed to read source session log directory: {e}"))?
+ {
+ let entry = entry.map_err(|e| format!("Failed to read source session log entry: {e}"))?;
+ let file_type = entry
+ .file_type()
+ .map_err(|e| format!("Failed to read session log entry type: {e}"))?;
+ if file_type.is_file() {
+ fs::copy(entry.path(), target_dir.join(entry.file_name()))
+ .map_err(|e| format!("Failed to copy session log file: {e}"))?;
+ }
+ }
+ Ok(())
+}
+
/// Create a new worktree for a project (runs in background)
///
/// This command returns immediately with a "pending" worktree.
@@ -1935,6 +2153,196 @@ pub async fn create_worktree(
Ok(pending_worktree)
}
+/// Fork an existing Jean session into a new worktree and continue from there.
+///
+/// The new worktree is created at the source worktree's current HEAD. Dirty tracked changes
+/// are copied with a binary git patch, and untracked non-ignored files are copied directly.
+/// The Jean session metadata and run log files are copied, but backend-native resume IDs and
+/// pending runtime prompts are cleared so the next turn starts a fresh backend conversation in
+/// the forked working directory.
+#[tauri::command]
+pub async fn fork_session_to_worktree(
+ app: AppHandle,
+ source_worktree_id: String,
+ source_session_id: String,
+) -> Result {
+ log::trace!(
+ "Forking session {source_session_id} from worktree {source_worktree_id} into a new worktree"
+ );
+
+ let mut data = load_projects_data(&app)?;
+ let source_worktree = data
+ .find_worktree(&source_worktree_id)
+ .cloned()
+ .ok_or_else(|| format!("Worktree not found: {source_worktree_id}"))?;
+ let project = data
+ .find_project(&source_worktree.project_id)
+ .cloned()
+ .ok_or_else(|| format!("Project not found: {}", source_worktree.project_id))?;
+
+ let source_sessions =
+ crate::chat::storage::load_sessions(&app, &source_worktree.path, &source_worktree_id)?;
+ let source_session = source_sessions
+ .find_session(&source_session_id)
+ .cloned()
+ .ok_or_else(|| format!("Session not found: {source_session_id}"))?;
+
+ let source_path = Path::new(&source_worktree.path);
+ if !source_path.exists() {
+ return Err(format!(
+ "Source worktree path does not exist: {}",
+ source_worktree.path
+ ));
+ }
+
+ let source_head = git_output(&source_worktree.path, &["rev-parse", "--verify", "HEAD"])?
+ .trim()
+ .to_string();
+ if source_head.is_empty() {
+ return Err("Cannot fork session: source worktree has no HEAD commit".to_string());
+ }
+
+ let base_name = {
+ let sanitized = sanitize_folder_name(&format!("fork-{}", source_worktree.name));
+ if sanitized == "fork-" {
+ "fork-session".to_string()
+ } else {
+ sanitized
+ }
+ };
+ let name = generate_unique_suffix_name(&base_name, &project.path, &project.id, Some(&data));
+
+ let project_worktrees_dir =
+ get_project_worktrees_dir(&project.name, project.worktrees_dir.as_deref())?;
+ allow_project_in_asset_scope(&app, &project.path);
+ if let Some(worktrees_dir) = project_worktrees_dir.to_str() {
+ allow_project_in_asset_scope(&app, worktrees_dir);
+ }
+ let worktree_path = project_worktrees_dir.join(sanitize_folder_name(&name));
+ if worktree_path.exists() {
+ return Err(format!(
+ "Cannot fork session: target worktree path already exists: {}",
+ worktree_path.display()
+ ));
+ }
+ let worktree_path_str = worktree_path
+ .to_str()
+ .ok_or_else(|| "Invalid fork worktree path".to_string())?
+ .to_string();
+
+ git::create_worktree(&project.path, &worktree_path_str, &name, &source_head)?;
+
+ let created_at = now();
+ let max_order = data
+ .worktrees
+ .iter()
+ .filter(|w| w.project_id == project.id)
+ .map(|w| w.order)
+ .max()
+ .unwrap_or(0);
+ let worktree_id = Uuid::new_v4().to_string();
+ let worktree = Worktree {
+ id: worktree_id.clone(),
+ project_id: project.id.clone(),
+ name: name.clone(),
+ path: worktree_path_str.clone(),
+ branch: name.clone(),
+ base_branch: source_worktree
+ .base_branch
+ .clone()
+ .or(Some(project.default_branch.clone())),
+ created_at,
+ setup_output: None,
+ setup_script: None,
+ setup_success: None,
+ session_type: SessionType::Worktree,
+ pr_number: source_worktree.pr_number,
+ pr_url: source_worktree.pr_url.clone(),
+ issue_number: source_worktree.issue_number,
+ linear_issue_identifier: source_worktree.linear_issue_identifier.clone(),
+ security_alert_number: source_worktree.security_alert_number,
+ security_alert_url: source_worktree.security_alert_url.clone(),
+ advisory_ghsa_id: source_worktree.advisory_ghsa_id.clone(),
+ advisory_url: source_worktree.advisory_url.clone(),
+ cached_pr_status: None,
+ cached_check_status: None,
+ cached_behind_count: None,
+ cached_ahead_count: None,
+ cached_status_at: None,
+ cached_uncommitted_added: None,
+ cached_uncommitted_removed: None,
+ cached_branch_diff_added: None,
+ cached_branch_diff_removed: None,
+ cached_base_branch_ahead_count: None,
+ cached_base_branch_behind_count: None,
+ cached_worktree_ahead_count: None,
+ cached_unpushed_count: None,
+ pr_push_remote: source_worktree.pr_push_remote.clone(),
+ pr_push_branch: source_worktree.pr_push_branch.clone(),
+ order: max_order + 1,
+ origin: Some(WorktreeOrigin::Manual),
+ archived_at: None,
+ labels: source_worktree.labels.clone(),
+ label: source_worktree.label.clone(),
+ last_opened_at: Some(created_at),
+ };
+
+ let result = (|| -> Result {
+ copy_dirty_worktree_state(source_path, &worktree_path)?;
+
+ let mut forked_session =
+ prepare_forked_session(&source_session, &worktree_id, 0, created_at);
+ let source_metadata = crate::chat::storage::load_metadata(&app, &source_session_id)?;
+ copy_session_run_files(&app, &source_session_id, &forked_session.id)?;
+ let forked_metadata =
+ prepare_forked_metadata(source_metadata, &forked_session, &worktree_id);
+
+ let saved_session = crate::chat::storage::with_sessions_mut(
+ &app,
+ &worktree.path,
+ &worktree_id,
+ |sessions: &mut WorktreeSessions| {
+ sessions.sessions.clear();
+ sessions.sessions.push(forked_session.clone());
+ sessions.active_session_id = Some(forked_session.id.clone());
+ Ok(forked_session.clone())
+ },
+ )?;
+ crate::chat::storage::save_metadata(&app, &forked_metadata)?;
+ forked_session = saved_session;
+
+ data.add_worktree(worktree.clone());
+ save_projects_data(&app, &data)?;
+
+ let created_event = WorktreeCreatedEvent {
+ worktree: worktree.clone(),
+ auto_open_in_jean: false,
+ };
+ let _ = app.emit_all("worktree:created", &created_event);
+ let _ = app.emit_all(
+ "worktrees:changed",
+ &serde_json::json!({ "project_id": project.id }),
+ );
+ let _ = app.emit_all(
+ "cache:invalidate",
+ &serde_json::json!({ "keys": ["sessions", "projects"] }),
+ );
+
+ Ok(ForkSessionToWorktreeResponse {
+ worktree,
+ session: forked_session,
+ })
+ })();
+
+ if let Err(error) = result.as_ref() {
+ log::warn!("Fork session failed after git worktree creation; cleaning up {worktree_path_str}: {error}");
+ let _ = git::remove_worktree(&project.path, &worktree_path_str);
+ let _ = git::delete_branch(&project.path, &name);
+ }
+
+ result
+}
+
fn parse_worktree_origin(origin: Option<&str>) -> Result