Compare commits

..

No commits in common. "main" and "v1.0.3" have entirely different histories.
main ... v1.0.3

153 changed files with 11702 additions and 35156 deletions

View file

@ -130,50 +130,16 @@ jobs:
with:
fetch-depth: 0
# Build tools required by vcpkg ports (autotools for gperf, fontconfig, etc.)
# These are compile-time only — they do NOT create runtime dylib dependencies.
- name: Install build tools
run: brew install autoconf autoconf-archive automake libtool pkg-config
# Static linking via vcpkg — avoids runtime dependency on Homebrew dylibs.
# Ref: https://tectonic-typesetting.github.io/book/latest/howto/build-tectonic/
# Ref: https://learn.microsoft.com/en-us/vcpkg/users/triplets (arm64-osx defaults to static)
- name: Setup vcpkg
- name: Install macOS dependencies
run: |
git clone --depth 1 https://github.com/microsoft/vcpkg $HOME/vcpkg
$HOME/vcpkg/bootstrap-vcpkg.sh
echo "VCPKG_ROOT=$HOME/vcpkg" >> $GITHUB_ENV
- name: Restore vcpkg cache
id: vcpkg-cache
uses: actions/cache/restore@v4
with:
path: ~/vcpkg/installed
key: vcpkg-macos-arm64-v1
- name: Install macOS dependencies (vcpkg)
if: steps.vcpkg-cache.outputs.cache-hit != 'true'
env:
VCPKG_BINARY_SOURCES: "clear"
run: |
$HOME/vcpkg/vcpkg install \
"harfbuzz[graphite2]:arm64-osx" \
fontconfig:arm64-osx \
freetype:arm64-osx \
icu:arm64-osx
- name: Save vcpkg cache
if: steps.vcpkg-cache.outputs.cache-hit != 'true'
uses: actions/cache/save@v4
with:
path: ~/vcpkg/installed
key: vcpkg-macos-arm64-v1
- name: Set macOS build environment
run: |
echo "TECTONIC_DEP_BACKEND=vcpkg" >> $GITHUB_ENV
echo "CXXFLAGS=-std=c++17" >> $GITHUB_ENV
echo "CFLAGS=" >> $GITHUB_ENV
brew install icu4c harfbuzz pkg-config graphite2 freetype fontconfig
INCLUDES="-I$(brew --prefix harfbuzz)/include -I$(brew --prefix freetype)/include -I$(brew --prefix graphite2)/include -I$(brew --prefix icu4c)/include"
{
echo "PKG_CONFIG_PATH=$(brew --prefix icu4c)/lib/pkgconfig:$(brew --prefix harfbuzz)/lib/pkgconfig:$(brew --prefix graphite2)/lib/pkgconfig:$(brew --prefix freetype)/lib/pkgconfig:$(brew --prefix libpng)/lib/pkgconfig:$(brew --prefix fontconfig)/lib/pkgconfig"
echo "CXXFLAGS=-std=c++17 $INCLUDES"
echo "CFLAGS=$INCLUDES"
echo "LDFLAGS=-L$(brew --prefix harfbuzz)/lib -L$(brew --prefix freetype)/lib -L$(brew --prefix graphite2)/lib -L$(brew --prefix icu4c)/lib"
} >> $GITHUB_ENV
- name: Import Apple certificate
env:
@ -220,6 +186,7 @@ jobs:
ZOTERO_CONSUMER_KEY: ${{ secrets.ZOTERO_CONSUMER_KEY }}
ZOTERO_CONSUMER_SECRET: ${{ secrets.ZOTERO_CONSUMER_SECRET }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
TECTONIC_DEP_BACKEND: pkg-config
run: pnpm --filter @claude-prism/desktop tauri build --target aarch64-apple-darwin
- name: Notarize DMG
@ -275,163 +242,6 @@ jobs:
sig: ${{ steps.collect.outputs.sig }}
url: ${{ steps.collect.outputs.url }}
# ──────────────────────────────────────────────
# macOS (Intel x86_64)
# Ref: https://github.com/actions/runner-images/issues/13045
# macos-13 retired 2025-12-04, macos-15-intel is the replacement
# Intel runners available until Fall 2027
# Ref: https://v2.tauri.app/distribute/pipelines/github/
# Ref: https://v2.tauri.app/plugin/updater/ (platform key: darwin-x86_64)
# ──────────────────────────────────────────────
build-macos-intel:
runs-on: macos-15-intel
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
# Build tools required by vcpkg ports (autotools for gperf, fontconfig, etc.)
- name: Install build tools
run: brew install autoconf autoconf-archive automake libtool pkg-config
# Static linking via vcpkg — same approach as Apple Silicon and Windows.
- name: Setup vcpkg
run: |
git clone --depth 1 https://github.com/microsoft/vcpkg $HOME/vcpkg
$HOME/vcpkg/bootstrap-vcpkg.sh
echo "VCPKG_ROOT=$HOME/vcpkg" >> $GITHUB_ENV
- name: Restore vcpkg cache
id: vcpkg-cache
uses: actions/cache/restore@v4
with:
path: ~/vcpkg/installed
key: vcpkg-macos-x64-v1
- name: Install macOS dependencies (vcpkg)
if: steps.vcpkg-cache.outputs.cache-hit != 'true'
env:
VCPKG_BINARY_SOURCES: "clear"
run: |
$HOME/vcpkg/vcpkg install \
"harfbuzz[graphite2]:x64-osx" \
fontconfig:x64-osx \
freetype:x64-osx \
icu:x64-osx
- name: Save vcpkg cache
if: steps.vcpkg-cache.outputs.cache-hit != 'true'
uses: actions/cache/save@v4
with:
path: ~/vcpkg/installed
key: vcpkg-macos-x64-v1
- name: Set macOS build environment
run: |
echo "TECTONIC_DEP_BACKEND=vcpkg" >> $GITHUB_ENV
echo "CXXFLAGS=-std=c++17" >> $GITHUB_ENV
echo "CFLAGS=" >> $GITHUB_ENV
- name: Import Apple certificate
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
run: |
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
KEYCHAIN_PASSWORD=$(openssl rand -base64 32)
# Create temporary keychain
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
# Import certificate
echo "$APPLE_CERTIFICATE" | base64 --decode > $RUNNER_TEMP/certificate.p12
security import $RUNNER_TEMP/certificate.p12 \
-k "$KEYCHAIN_PATH" \
-P "$APPLE_CERTIFICATE_PASSWORD" \
-T /usr/bin/codesign \
-T /usr/bin/security
security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security list-keychains -d user -s "$KEYCHAIN_PATH" login.keychain-db
- uses: dtolnay/rust-toolchain@stable
with:
targets: x86_64-apple-darwin
- uses: swatinem/rust-cache@v2
with:
workspaces: apps/desktop/src-tauri -> target
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install
- name: Build Tauri app
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ZOTERO_CONSUMER_KEY: ${{ secrets.ZOTERO_CONSUMER_KEY }}
ZOTERO_CONSUMER_SECRET: ${{ secrets.ZOTERO_CONSUMER_SECRET }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
run: pnpm --filter @claude-prism/desktop tauri build --target x86_64-apple-darwin
- name: Notarize DMG
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
run: |
TARGET="x86_64-apple-darwin"
BUNDLE="apps/desktop/src-tauri/target/$TARGET/release/bundle"
DMG_PATH=$(find "$BUNDLE/dmg" -name '*.dmg' | head -1)
APP_PATH="$BUNDLE/macos/ClaudePrism.app"
if [ -z "$DMG_PATH" ]; then
echo "Error: DMG not found"
exit 1
fi
echo "==> Notarizing $DMG_PATH ..."
xcrun notarytool submit "$DMG_PATH" \
--apple-id "$APPLE_ID" \
--team-id "$APPLE_TEAM_ID" \
--password "$APPLE_PASSWORD" \
--wait --timeout 30m
echo "==> Stapling..."
xcrun stapler staple "$DMG_PATH"
xcrun stapler staple "$APP_PATH"
- name: Collect updater artifacts
id: collect
run: |
BUNDLE="apps/desktop/src-tauri/target/x86_64-apple-darwin/release/bundle"
TAR_SIG=$(find "$BUNDLE/macos" -name '*.app.tar.gz.sig' 2>/dev/null | head -1)
TAR_GZ=$(find "$BUNDLE/macos" -name '*.app.tar.gz' ! -name '*.sig' 2>/dev/null | head -1)
if [ -n "$TAR_SIG" ] && [ -n "$TAR_GZ" ]; then
echo "sig=$(cat "$TAR_SIG")" >> $GITHUB_OUTPUT
echo "url=$(basename "$TAR_GZ")" >> $GITHUB_OUTPUT
fi
- name: Upload build artifacts
uses: actions/upload-artifact@v4
with:
name: desktop-macos-intel
path: |
apps/desktop/src-tauri/target/x86_64-apple-darwin/release/bundle/**/*.dmg
apps/desktop/src-tauri/target/x86_64-apple-darwin/release/bundle/**/*.app.tar.gz
apps/desktop/src-tauri/target/x86_64-apple-darwin/release/bundle/**/*.sig
if-no-files-found: warn
outputs:
sig: ${{ steps.collect.outputs.sig }}
url: ${{ steps.collect.outputs.url }}
# ──────────────────────────────────────────────
# Linux
# ──────────────────────────────────────────────
@ -447,9 +257,7 @@ jobs:
sudo apt-get update
sudo apt-get install -y \
libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf \
autoconf autoconf-archive automake libtool pkg-config \
libgraphite2-dev libicu-dev libfontconfig1-dev libharfbuzz-dev \
libssl-dev libfreetype-dev libpng-dev zlib1g-dev
libicu-dev libgraphite2-dev libharfbuzz-dev libfreetype-dev libfontconfig-dev
- uses: dtolnay/rust-toolchain@stable
with:
@ -472,10 +280,7 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ZOTERO_CONSUMER_KEY: ${{ secrets.ZOTERO_CONSUMER_KEY }}
ZOTERO_CONSUMER_SECRET: ${{ secrets.ZOTERO_CONSUMER_SECRET }}
# Use pkg-config (same as tectonic's own CI) with semi-static linking.
# AppImage bundles .so files automatically for portable distribution.
TECTONIC_DEP_BACKEND: pkg-config
TECTONIC_PKGCONFIG_FORCE_SEMI_STATIC: "true"
CXXFLAGS: "-std=c++17"
CFLAGS: ""
run: pnpm --filter @claude-prism/desktop tauri build --target x86_64-unknown-linux-gnu
@ -511,7 +316,7 @@ jobs:
# Publish: generate latest.json & upload to release
# ──────────────────────────────────────────────
publish:
needs: [build-windows, build-macos, build-macos-intel, build-linux]
needs: [build-windows, build-macos, build-linux]
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/v') || inputs.release_tag != ''
@ -539,8 +344,6 @@ jobs:
WIN_URL: ${{ needs.build-windows.outputs.url }}
MAC_SIG: ${{ needs.build-macos.outputs.sig }}
MAC_URL: ${{ needs.build-macos.outputs.url }}
MAC_INTEL_SIG: ${{ needs.build-macos-intel.outputs.sig }}
MAC_INTEL_URL: ${{ needs.build-macos-intel.outputs.url }}
LIN_SIG: ${{ needs.build-linux.outputs.sig }}
LIN_URL: ${{ needs.build-linux.outputs.url }}
run: |
@ -553,8 +356,6 @@ jobs:
const platforms = {};
if (process.env.MAC_SIG)
platforms['darwin-aarch64'] = { signature: process.env.MAC_SIG, url: BASE + '/ClaudePrism-macOS.app.tar.gz' };
if (process.env.MAC_INTEL_SIG)
platforms['darwin-x86_64'] = { signature: process.env.MAC_INTEL_SIG, url: BASE + '/ClaudePrism-macOS-Intel.app.tar.gz' };
if (process.env.LIN_SIG)
platforms['linux-x86_64'] = { signature: process.env.LIN_SIG, url: BASE + '/ClaudePrism-Linux.AppImage' };
if (process.env.WIN_SIG)
@ -586,19 +387,13 @@ jobs:
mkdir -p upload
# Rename platform artifacts to version-free names
# macOS Apple Silicon (from desktop-macos artifact)
find artifacts/desktop-macos -name "*.dmg" 2>/dev/null | head -1 | xargs -I{} cp {} upload/ClaudePrism-macOS.dmg
find artifacts/desktop-macos -name "*.app.tar.gz" 2>/dev/null | head -1 | xargs -I{} cp {} upload/ClaudePrism-macOS.app.tar.gz
# macOS Intel (from desktop-macos-intel artifact)
find artifacts/desktop-macos-intel -name "*.dmg" 2>/dev/null | head -1 | xargs -I{} cp {} upload/ClaudePrism-macOS-Intel.dmg
find artifacts/desktop-macos-intel -name "*.app.tar.gz" 2>/dev/null | head -1 | xargs -I{} cp {} upload/ClaudePrism-macOS-Intel.app.tar.gz
# Windows
find artifacts/desktop-windows -name "*-setup.exe" 2>/dev/null | head -1 | xargs -I{} cp {} upload/ClaudePrism-Windows-setup.exe
find artifacts/desktop-windows -name "*.msi" 2>/dev/null | head -1 | xargs -I{} cp {} upload/ClaudePrism-Windows.msi
# Linux
find artifacts/desktop-linux -name "*.deb" 2>/dev/null | head -1 | xargs -I{} cp {} upload/ClaudePrism-Linux.deb
find artifacts/desktop-linux -name "*.rpm" 2>/dev/null | head -1 | xargs -I{} cp {} upload/ClaudePrism-Linux.rpm
find artifacts/desktop-linux -name "*.AppImage" 2>/dev/null | head -1 | xargs -I{} cp {} upload/ClaudePrism-Linux.AppImage
find artifacts -name "*.dmg" | head -1 | xargs -I{} cp {} upload/ClaudePrism-macOS.dmg
find artifacts -name "*.app.tar.gz" | head -1 | xargs -I{} cp {} upload/ClaudePrism-macOS.app.tar.gz
find artifacts -name "*-setup.exe" | head -1 | xargs -I{} cp {} upload/ClaudePrism-Windows-setup.exe
find artifacts -name "*.msi" | head -1 | xargs -I{} cp {} upload/ClaudePrism-Windows.msi
find artifacts -name "*.deb" | head -1 | xargs -I{} cp {} upload/ClaudePrism-Linux.deb
find artifacts -name "*.rpm" | head -1 | xargs -I{} cp {} upload/ClaudePrism-Linux.rpm
find artifacts -name "*.AppImage" | head -1 | xargs -I{} cp {} upload/ClaudePrism-Linux.AppImage
cp latest.json upload/
@ -609,4 +404,9 @@ jobs:
gh release upload "$TAG" "$file" --clobber
done
# Release stays as draft — publish manually from GitHub Releases UI
- name: Publish release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
TAG="${{ steps.tag.outputs.tag }}"
gh release edit "$TAG" --draft=false

View file

@ -1,24 +0,0 @@
name: Lint
on:
pull_request:
branches: [main]
push:
branches: [main]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Biome CI
run: pnpm exec biome ci

2
.gitignore vendored
View file

@ -31,7 +31,7 @@ tsconfig.tsbuildinfo
*.tsbuildinfo
# Rust
apps/desktop/src-tauri/.cargo/config.toml
Cargo.lock
# Tauri auto-generated schemas
apps/desktop/src-tauri/gen/schemas/

View file

@ -1 +0,0 @@
pnpm exec biome check --staged --write --no-errors-on-unmatched

View file

@ -136,25 +136,15 @@ pnpm lint:fix # auto-fix
Rust code follows standard `rustfmt` conventions.
### Pre-commit Hook
A [Husky](https://typicode.github.io/husky/) pre-commit hook runs automatically on every commit. It checks and auto-fixes staged files via `biome check --staged --write`, so lint issues are caught before they reach the repository.
The hook is set up automatically when you run `pnpm install`.
### CI
A GitHub Actions workflow runs `biome ci` on every pull request and push to `main`. PRs that fail lint checks cannot be merged.
## Pull Request Process
1. Fork the repository
2. Create a feature branch (`git checkout -b feat/my-feature`)
3. Make your changes
4. Run tests: `pnpm test` (frontend) and `cargo test` (Rust)
5. Commit — the pre-commit hook will auto-fix lint issues on staged files
6. Push to your fork and open a PR
7. CI will verify lint and tests pass
5. Run `pnpm lint` to ensure code quality
6. Commit with a descriptive message
7. Push to your fork and open a PR
### Commit Convention

View file

@ -24,17 +24,14 @@
<a href="https://claudeprism.delibae.dev?utm_source=github&utm_medium=readme&utm_campaign=launch_v054">
<img src="https://img.shields.io/badge/Website-claudeprism.dev-blue?style=flat-square&logo=googlechrome&logoColor=white" alt="Website" />
</a>&nbsp;
<a href="https://github.com/delibae/claude-prism/releases/latest/download/ClaudePrism-macOS.dmg">
<img src="https://img.shields.io/badge/Download-macOS_(Apple_Silicon)-black?style=for-the-badge&logo=apple&logoColor=white" alt="macOS (Apple Silicon) をダウンロード" />
<a href="https://github.com/delibae/claude-prism/releases/latest">
<img src="https://img.shields.io/badge/Download-macOS_(Apple_Silicon)-black?style=for-the-badge&logo=apple&logoColor=white" alt="macOS 版をダウンロード" />
</a>&nbsp;
<a href="https://github.com/delibae/claude-prism/releases/latest/download/ClaudePrism-macOS-Intel.dmg">
<img src="https://img.shields.io/badge/Download-macOS_(Intel)-555555?style=for-the-badge&logo=apple&logoColor=white" alt="macOS (Intel) をダウンロード" />
</a>&nbsp;
<a href="https://github.com/delibae/claude-prism/releases/latest/download/ClaudePrism-Windows-setup.exe">
<a href="https://github.com/delibae/claude-prism/releases/latest">
<img src="https://img.shields.io/badge/Download-Windows-0078D4?style=for-the-badge&logo=windows&logoColor=white" alt="Windows 版をダウンロード" />
</a>&nbsp;
<a href="https://github.com/delibae/claude-prism/releases/latest/download/ClaudePrism-Linux.AppImage">
<img src="https://img.shields.io/badge/Download-Linux_(AppImage)-FCC624?style=for-the-badge&logo=linux&logoColor=black" alt="Linux 版をダウンロード" />
<a href="https://github.com/delibae/claude-prism/releases/latest">
<img src="https://img.shields.io/badge/Download-Linux_(deb)-FCC624?style=for-the-badge&logo=linux&logoColor=black" alt="Linux 版をダウンロード" />
</a>
</p>
<p align="center">

View file

@ -24,17 +24,14 @@
<a href="https://claudeprism.delibae.dev?utm_source=github&utm_medium=readme&utm_campaign=launch_v054">
<img src="https://img.shields.io/badge/Website-claudeprism.dev-blue?style=flat-square&logo=googlechrome&logoColor=white" alt="Website" />
</a>&nbsp;
<a href="https://github.com/delibae/claude-prism/releases/latest/download/ClaudePrism-macOS.dmg">
<img src="https://img.shields.io/badge/Download-macOS_(Apple_Silicon)-black?style=for-the-badge&logo=apple&logoColor=white" alt="macOS (Apple Silicon) 다운로드" />
<a href="https://github.com/delibae/claude-prism/releases/latest">
<img src="https://img.shields.io/badge/Download-macOS_(Apple_Silicon)-black?style=for-the-badge&logo=apple&logoColor=white" alt="macOS 다운로드" />
</a>&nbsp;
<a href="https://github.com/delibae/claude-prism/releases/latest/download/ClaudePrism-macOS-Intel.dmg">
<img src="https://img.shields.io/badge/Download-macOS_(Intel)-555555?style=for-the-badge&logo=apple&logoColor=white" alt="macOS (Intel) 다운로드" />
</a>&nbsp;
<a href="https://github.com/delibae/claude-prism/releases/latest/download/ClaudePrism-Windows-setup.exe">
<a href="https://github.com/delibae/claude-prism/releases/latest">
<img src="https://img.shields.io/badge/Download-Windows-0078D4?style=for-the-badge&logo=windows&logoColor=white" alt="Windows 다운로드" />
</a>&nbsp;
<a href="https://github.com/delibae/claude-prism/releases/latest/download/ClaudePrism-Linux.AppImage">
<img src="https://img.shields.io/badge/Download-Linux_(AppImage)-FCC624?style=for-the-badge&logo=linux&logoColor=black" alt="Linux 다운로드" />
<a href="https://github.com/delibae/claude-prism/releases/latest">
<img src="https://img.shields.io/badge/Download-Linux_(deb)-FCC624?style=for-the-badge&logo=linux&logoColor=black" alt="Linux 다운로드" />
</a>
</p>
<p align="center">

View file

@ -24,17 +24,14 @@
<a href="https://claudeprism.delibae.dev?utm_source=github&utm_medium=readme&utm_campaign=launch_v054">
<img src="https://img.shields.io/badge/Website-claudeprism.dev-blue?style=flat-square&logo=googlechrome&logoColor=white" alt="Website" />
</a>&nbsp;
<a href="https://github.com/delibae/claude-prism/releases/latest/download/ClaudePrism-macOS.dmg">
<img src="https://img.shields.io/badge/Download-macOS_(Apple_Silicon)-black?style=for-the-badge&logo=apple&logoColor=white" alt="Download for macOS (Apple Silicon)" />
<a href="https://github.com/delibae/claude-prism/releases/latest">
<img src="https://img.shields.io/badge/Download-macOS_(Apple_Silicon)-black?style=for-the-badge&logo=apple&logoColor=white" alt="Download for macOS" />
</a>&nbsp;
<a href="https://github.com/delibae/claude-prism/releases/latest/download/ClaudePrism-macOS-Intel.dmg">
<img src="https://img.shields.io/badge/Download-macOS_(Intel)-555555?style=for-the-badge&logo=apple&logoColor=white" alt="Download for macOS (Intel)" />
</a>&nbsp;
<a href="https://github.com/delibae/claude-prism/releases/latest/download/ClaudePrism-Windows-setup.exe">
<a href="https://github.com/delibae/claude-prism/releases/latest">
<img src="https://img.shields.io/badge/Download-Windows-0078D4?style=for-the-badge&logo=windows&logoColor=white" alt="Download for Windows" />
</a>&nbsp;
<a href="https://github.com/delibae/claude-prism/releases/latest/download/ClaudePrism-Linux.AppImage">
<img src="https://img.shields.io/badge/Download-Linux_(AppImage)-FCC624?style=for-the-badge&logo=linux&logoColor=black" alt="Download for Linux" />
<a href="https://github.com/delibae/claude-prism/releases/latest">
<img src="https://img.shields.io/badge/Download-Linux_(deb)-FCC624?style=for-the-badge&logo=linux&logoColor=black" alt="Download for Linux" />
</a>
</p>
<p align="center">

View file

@ -22,10 +22,9 @@
<p align="center">
<a href="https://claudeprism.delibae.dev?utm_source=github&utm_medium=readme&utm_campaign=launch_v054">官网</a> ·
<a href="https://github.com/delibae/claude-prism/releases/latest/download/ClaudePrism-macOS.dmg">macOS (Apple Silicon)</a> ·
<a href="https://github.com/delibae/claude-prism/releases/latest/download/ClaudePrism-macOS-Intel.dmg">macOS (Intel)</a> ·
<a href="https://github.com/delibae/claude-prism/releases/latest/download/ClaudePrism-Windows-setup.exe">Windows</a> ·
<a href="https://github.com/delibae/claude-prism/releases/latest/download/ClaudePrism-Linux.AppImage">Linux</a> ·
<a href="https://github.com/delibae/claude-prism/releases/latest">macOS</a> ·
<a href="https://github.com/delibae/claude-prism/releases/latest">Windows</a> ·
<a href="https://github.com/delibae/claude-prism/releases/latest">Linux</a> ·
<a href="https://github.com/delibae/claude-prism/releases">所有版本</a>
</p>

View file

@ -1,6 +1,6 @@
{
"name": "@claude-prism/desktop",
"version": "1.3.0",
"version": "1.0.3",
"private": true,
"type": "module",
"scripts": {
@ -26,12 +26,11 @@
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/utilities": "^3.2.2",
"@radix-ui/react-slot": "^1.2.4",
"@replit/codemirror-vim": "^6.3.0",
"@tauri-apps/api": "^2.11.0",
"@tauri-apps/plugin-dialog": "^2.7.1",
"@tauri-apps/plugin-fs": "^2.5.1",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-shell": "^2.3.5",
"@tauri-apps/api": "^2.5.0",
"@tauri-apps/plugin-dialog": "^2.2.2",
"@tauri-apps/plugin-fs": "^2.3.0",
"@tauri-apps/plugin-process": "^2.2.2",
"@tauri-apps/plugin-shell": "^2.2.2",
"@tauri-apps/plugin-updater": "~2.10.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@ -68,8 +67,7 @@
"jsdom": "^26.1.0",
"tailwindcss": "^4.1.18",
"typescript": "^5.9.3",
"vite": "^6.4.2",
"vite-plugin-top-level-await": "^1.6.0",
"vitest": "^4.1.0"
"vite": "^6.3.5",
"vitest": "^3.1.1"
}
}

View file

@ -104,9 +104,7 @@ async function main() {
if (fs.existsSync(pdfPath)) {
fs.copyFileSync(pdfPath, path.join(exampleDir, pdfName));
const sizeKb = Math.round(
fs.statSync(path.join(exampleDir, pdfName)).size / 1024,
);
const sizeKb = Math.round(fs.statSync(path.join(exampleDir, pdfName)).size / 1024);
console.log(`OK (${sizeKb} KB)`);
successCount++;
} else {
@ -114,8 +112,7 @@ async function main() {
failCount++;
}
} catch (err) {
const msg =
err instanceof Error ? err.message.slice(0, 120) : String(err);
const msg = err instanceof Error ? err.message.slice(0, 120) : String(err);
console.log(`FAIL: ${msg}`);
failCount++;
} finally {
@ -128,9 +125,7 @@ async function main() {
console.log(
"Note: Failed templates may require document classes not installed in your TeX distribution.",
);
console.log(
"The gallery will show CSS fallback thumbnails for those templates.",
);
console.log("The gallery will show CSS fallback thumbnails for those templates.");
}
}

View file

@ -0,0 +1,12 @@
# Tectonic build configuration for local macOS development (Homebrew).
# ICU is keg-only in Homebrew, so pkg-config can't find it without this path.
# HarfBuzz headers need the parent include directory for #include <harfbuzz/hb.h>.
# ICU 78+ requires C++17.
# On Linux, system packages are in standard paths — these extra paths are harmless.
#
# CI overrides: force=false means CI env vars (TECTONIC_DEP_BACKEND=vcpkg,
# VCPKG_ROOT, CXXFLAGS, CFLAGS) take precedence over these defaults.
[env]
PKG_CONFIG_PATH = { value = "/opt/homebrew/opt/icu4c/lib/pkgconfig", force = false }
CXXFLAGS = { value = "-I/opt/homebrew/include -std=c++17", force = false }
CFLAGS = { value = "-I/opt/homebrew/include", force = false }

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
[package]
name = "claude-prism-desktop"
version = "1.3.0"
version = "1.0.3"
description = "AI-powered LaTeX writing workspace"
edition = "2021"
@ -23,7 +23,7 @@ serde_json = "1"
serde_yaml = "0.9"
tokio = { version = "1", features = ["full"] }
dirs = "5"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream", "system-proxy", "socks"] }
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
hmac = "0.12"
sha1 = "0.10"
base64 = "0.22"
@ -52,9 +52,5 @@ objc2 = "0.6"
objc2-app-kit = { version = "0.3", features = ["NSApplication", "NSImage", "NSRunningApplication"] }
objc2-foundation = { version = "0.3", features = ["NSData"] }
[target.'cfg(target_os = "windows")'.dependencies]
winreg = "0.55"
windows-sys = { version = "0.61", features = ["Win32_System_Environment"] }
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
tauri-plugin-updater = "2"

View file

@ -6,20 +6,5 @@ fn main() {
println!("cargo:rustc-env={key}={val}");
}
}
// On Linux, apply a version script to hide statically linked ICU/HarfBuzz/
// FreeType/Fontconfig symbols from the dynamic symbol table. This prevents
// symbol collisions with the system copies loaded by WebKit2GTK (segfault).
// See: https://github.com/delibae/claude-prism/issues/100
#[cfg(target_os = "linux")]
{
let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR");
println!(
"cargo:rustc-link-arg=-Wl,--version-script={}/symbols.map",
manifest_dir
);
println!("cargo:rerun-if-changed=symbols.map");
}
tauri_build::build()
}

View file

@ -15,132 +15,28 @@
"core:window:allow-toggle-maximize",
"core:window:allow-close",
"core:webview:allow-create-webview-window",
"core:webview:allow-set-webview-zoom",
"dialog:default",
"dialog:allow-open",
"dialog:allow-save",
"dialog:allow-message",
"dialog:allow-ask",
"fs:default",
"fs:allow-read",
"fs:allow-write",
"fs:allow-exists",
"fs:allow-mkdir",
"fs:allow-remove",
"fs:allow-rename",
"fs:allow-copy-file",
"fs:allow-read-dir",
"fs:allow-read-text-file",
"fs:allow-write-text-file",
"fs:allow-read-file",
"fs:allow-write-file",
"fs:allow-stat",
"fs:allow-watch",
{
"identifier": "fs:allow-read",
"allow": [
{ "path": "$HOME/**" },
{ "path": "$DOCUMENT/**" },
{ "path": "$DESKTOP/**" },
{ "path": "$TEMP/**" }
]
},
{
"identifier": "fs:allow-write",
"allow": [
{ "path": "$HOME/**" },
{ "path": "$DOCUMENT/**" },
{ "path": "$DESKTOP/**" },
{ "path": "$TEMP/**" }
]
},
{
"identifier": "fs:allow-exists",
"allow": [
{ "path": "$HOME/**" },
{ "path": "$DOCUMENT/**" },
{ "path": "$DESKTOP/**" },
{ "path": "$TEMP/**" }
]
},
{
"identifier": "fs:allow-mkdir",
"allow": [
{ "path": "$HOME/**" },
{ "path": "$DOCUMENT/**" },
{ "path": "$DESKTOP/**" },
{ "path": "$TEMP/**" }
]
},
{
"identifier": "fs:allow-remove",
"allow": [
{ "path": "$HOME/**" },
{ "path": "$DOCUMENT/**" },
{ "path": "$DESKTOP/**" },
{ "path": "$TEMP/**" }
]
},
{
"identifier": "fs:allow-rename",
"allow": [
{ "path": "$HOME/**" },
{ "path": "$DOCUMENT/**" },
{ "path": "$DESKTOP/**" },
{ "path": "$TEMP/**" }
]
},
{
"identifier": "fs:allow-copy-file",
"allow": [
{ "path": "$HOME/**" },
{ "path": "$DOCUMENT/**" },
{ "path": "$DESKTOP/**" },
{ "path": "$TEMP/**" }
]
},
{
"identifier": "fs:allow-read-dir",
"allow": [
{ "path": "$HOME/**" },
{ "path": "$DOCUMENT/**" },
{ "path": "$DESKTOP/**" },
{ "path": "$TEMP/**" }
]
},
{
"identifier": "fs:allow-read-text-file",
"allow": [
{ "path": "$HOME/**" },
{ "path": "$DOCUMENT/**" },
{ "path": "$DESKTOP/**" },
{ "path": "$TEMP/**" }
]
},
{
"identifier": "fs:allow-write-text-file",
"allow": [
{ "path": "$HOME/**" },
{ "path": "$DOCUMENT/**" },
{ "path": "$DESKTOP/**" },
{ "path": "$TEMP/**" }
]
},
{
"identifier": "fs:allow-read-file",
"allow": [
{ "path": "$HOME/**" },
{ "path": "$DOCUMENT/**" },
{ "path": "$DESKTOP/**" },
{ "path": "$TEMP/**" }
]
},
{
"identifier": "fs:allow-write-file",
"allow": [
{ "path": "$HOME/**" },
{ "path": "$DOCUMENT/**" },
{ "path": "$DESKTOP/**" },
{ "path": "$TEMP/**" }
]
},
{
"identifier": "fs:allow-stat",
"allow": [
{ "path": "$HOME/**" },
{ "path": "$DOCUMENT/**" },
{ "path": "$DESKTOP/**" },
{ "path": "$TEMP/**" }
]
},
{
"identifier": "fs:allow-watch",
"identifier": "fs:scope",
"allow": [
{ "path": "$HOME/**" },
{ "path": "$DOCUMENT/**" },

View file

@ -1,6 +1,14 @@
{
"identifier": "desktop-capability",
"platforms": ["macOS", "windows", "linux"],
"windows": ["main"],
"permissions": ["updater:default"]
}
"platforms": [
"macOS",
"windows",
"linux"
],
"windows": [
"main"
],
"permissions": [
"updater:default"
]
}

View file

@ -1,614 +0,0 @@
mod messages;
mod providers;
mod stream;
mod tools;
mod transformers;
use self::messages::{anthropic_to_openai_request, openai_to_anthropic_message};
use self::providers::apply_provider_request_transforms;
use self::stream::{sse_response, stream_openai_sse_to_anthropic};
use self::transformers::ProxyTransformerChain;
use serde_json::{json, Value};
use std::net::SocketAddr;
use std::sync::Arc;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
#[derive(Clone, Debug)]
pub(crate) struct OpenAiProxyCredential {
pub(crate) api_key: String,
pub(crate) base_url: String,
pub(crate) model: String,
pub(crate) transformers: Vec<String>,
pub(crate) model_transformers: Vec<String>,
}
pub(crate) async fn start_openai_anthropic_proxy(
credential: OpenAiProxyCredential,
) -> Result<String, String> {
let listener = TcpListener::bind(("127.0.0.1", 0))
.await
.map_err(|err| format!("Failed to start local provider proxy: {}", err))?;
let addr = listener
.local_addr()
.map_err(|err| format!("Failed to read local provider proxy address: {}", err))?;
let credential = Arc::new(credential);
tokio::spawn(async move {
loop {
let Ok((stream, _)) = listener.accept().await else {
break;
};
let credential = Arc::clone(&credential);
tokio::spawn(async move {
if let Err(err) = handle_connection(stream, credential).await {
eprintln!("[anthropic-proxy] request failed: {}", err);
}
});
}
});
Ok(format!("http://{}", addr))
}
async fn handle_connection(
mut stream: TcpStream,
credential: Arc<OpenAiProxyCredential>,
) -> Result<(), String> {
let request = read_http_request(&mut stream).await?;
let path = request_path_without_query(&request.path);
if request.method == "POST" && is_messages_path(path) {
match handle_messages_to_stream(&request, &credential, &mut stream).await {
Ok(()) => {
let _ = stream.shutdown().await;
return Ok(());
}
Err(err) => {
let response = json_response(
502,
&json!({
"type": "error",
"error": {
"type": "api_error",
"message": err,
},
}),
);
stream
.write_all(response.as_bytes())
.await
.map_err(|err| format!("Failed to write proxy error response: {}", err))?;
let _ = stream.shutdown().await;
return Ok(());
}
}
}
let response = route_request(&request).await;
stream
.write_all(response.as_bytes())
.await
.map_err(|err| format!("Failed to write proxy response: {}", err))?;
let _ = stream.shutdown().await;
Ok(())
}
struct HttpRequest {
method: String,
path: String,
body: Vec<u8>,
}
async fn read_http_request(stream: &mut TcpStream) -> Result<HttpRequest, String> {
let mut buffer = Vec::new();
let mut temp = [0_u8; 8192];
let header_end = loop {
let n = stream
.read(&mut temp)
.await
.map_err(|err| format!("Failed to read proxy request: {}", err))?;
if n == 0 {
return Err("Connection closed before HTTP headers were received".to_string());
}
buffer.extend_from_slice(&temp[..n]);
if let Some(index) = find_header_end(&buffer) {
break index;
}
if buffer.len() > 1024 * 1024 {
return Err("Proxy request headers are too large".to_string());
}
};
let header_text = String::from_utf8_lossy(&buffer[..header_end]);
let mut lines = header_text.lines();
let request_line = lines
.next()
.ok_or_else(|| "Proxy request is missing request line".to_string())?;
let mut request_parts = request_line.split_whitespace();
let method = request_parts
.next()
.ok_or_else(|| "Proxy request is missing method".to_string())?
.to_string();
let path = request_parts
.next()
.ok_or_else(|| "Proxy request is missing path".to_string())?
.to_string();
let content_length = lines
.filter_map(|line| line.split_once(':'))
.find(|(key, _)| key.eq_ignore_ascii_case("content-length"))
.and_then(|(_, value)| value.trim().parse::<usize>().ok())
.unwrap_or(0);
let body_start = header_end + 4;
let mut body = buffer.get(body_start..).unwrap_or_default().to_vec();
while body.len() < content_length {
let n = stream
.read(&mut temp)
.await
.map_err(|err| format!("Failed to read proxy request body: {}", err))?;
if n == 0 {
break;
}
body.extend_from_slice(&temp[..n]);
}
body.truncate(content_length);
Ok(HttpRequest { method, path, body })
}
fn find_header_end(buffer: &[u8]) -> Option<usize> {
buffer.windows(4).position(|window| window == b"\r\n\r\n")
}
async fn route_request(request: &HttpRequest) -> String {
let path = request_path_without_query(&request.path);
if request.method == "GET" && path == "/" {
return json_response(
200,
&json!({ "ok": true, "service": "claude-prism-anthropic-proxy" }),
);
}
if request.method == "POST" && is_count_tokens_path(path) {
return handle_count_tokens(request);
}
json_response(
400,
&json!({
"type": "error",
"error": {
"type": "invalid_request_error",
"message": format!("Unsupported Anthropic proxy endpoint: {} {}", request.method, request.path),
},
}),
)
}
fn request_path_without_query(path: &str) -> &str {
path.split_once('?').map(|(path, _)| path).unwrap_or(path)
}
fn is_count_tokens_path(path: &str) -> bool {
path.ends_with("/count_tokens")
}
fn is_messages_path(path: &str) -> bool {
path.ends_with("/messages")
}
fn handle_count_tokens(request: &HttpRequest) -> String {
let body = serde_json::from_slice::<Value>(&request.body).unwrap_or(Value::Null);
let approx_chars = body.to_string().chars().count();
json_response(
200,
&json!({
"input_tokens": (approx_chars / 4).max(1),
}),
)
}
async fn handle_messages_to_stream(
request: &HttpRequest,
credential: &OpenAiProxyCredential,
stream: &mut TcpStream,
) -> Result<(), String> {
let anthropic_request: Value = serde_json::from_slice(&request.body)
.map_err(|err| format!("Claude Code sent invalid Anthropic JSON: {}", err))?;
let wants_stream = anthropic_request
.get("stream")
.and_then(|value| value.as_bool())
.unwrap_or(false);
let transformers = ProxyTransformerChain::for_credential(credential, wants_stream);
let mut openai_request =
anthropic_to_openai_request(&anthropic_request, credential, &transformers)?;
openai_request["stream"] = Value::Bool(wants_stream);
apply_provider_request_transforms(
&mut openai_request,
&anthropic_request,
credential,
wants_stream,
&transformers,
);
if request_contains_openai_image_parts(&openai_request)
&& provider_rejects_openai_image_parts(credential)
{
return Err(format!(
"{} does not accept OpenAI-style image_url message parts. Switch to Claude Code or a vision-capable OpenAI-compatible endpoint for image questions.",
credential.model
));
}
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(300))
.build()
.map_err(|err| format!("Failed to create provider client: {}", err))?;
let request = client
.post(openai_chat_completions_url(&credential.base_url))
.header("Content-Type", "application/json")
.body(openai_request.to_string());
let response = with_optional_bearer_auth(request, &credential.api_key)
.send()
.await
.map_err(|err| format!("Provider request failed: {}", err))?;
let status = response.status();
if !status.is_success() {
let response_text = response
.text()
.await
.map_err(|err| format!("Failed to read provider error response: {}", err))?;
return Err(format!(
"Provider returned HTTP {}: {}",
status,
compact_error_text(&response_text)
));
}
if wants_stream {
let content_type = response
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.unwrap_or_default()
.to_ascii_lowercase();
if content_type.contains("stream") {
stream_openai_sse_to_anthropic(stream, response, &anthropic_request, credential).await
} else {
let response_text = response
.text()
.await
.map_err(|err| format!("Failed to read provider response: {}", err))?;
let openai_response: Value = serde_json::from_str(&response_text)
.map_err(|err| format!("Provider returned invalid JSON: {}", err))?;
let anthropic_response =
openai_to_anthropic_message(&anthropic_request, &openai_response, credential)?;
stream
.write_all(sse_response(&anthropic_response).as_bytes())
.await
.map_err(|err| format!("Failed to write proxy SSE response: {}", err))
}
} else {
let response_text = response
.text()
.await
.map_err(|err| format!("Failed to read provider response: {}", err))?;
let openai_response: Value = serde_json::from_str(&response_text)
.map_err(|err| format!("Provider returned invalid JSON: {}", err))?;
let anthropic_response =
openai_to_anthropic_message(&anthropic_request, &openai_response, credential)?;
stream
.write_all(json_response(200, &anthropic_response).as_bytes())
.await
.map_err(|err| format!("Failed to write proxy JSON response: {}", err))
}
}
fn openai_chat_completions_url(base_url: &str) -> String {
let clean = base_url.trim_end_matches('/');
if clean.ends_with("/chat/completions") {
clean.to_string()
} else if openai_compatible_base_url_has_chat_root(clean) {
format!("{}/chat/completions", clean)
} else {
format!("{}/v1/chat/completions", clean)
}
}
fn with_optional_bearer_auth(
request: reqwest::RequestBuilder,
api_key: &str,
) -> reqwest::RequestBuilder {
if api_key.trim().is_empty() {
request
} else {
request.bearer_auth(api_key)
}
}
fn request_contains_openai_image_parts(value: &Value) -> bool {
match value {
Value::Array(values) => values.iter().any(request_contains_openai_image_parts),
Value::Object(object) => {
object.get("type").and_then(Value::as_str) == Some("image_url")
|| object.values().any(request_contains_openai_image_parts)
}
_ => false,
}
}
fn provider_rejects_openai_image_parts(credential: &OpenAiProxyCredential) -> bool {
let base_url = credential.base_url.to_ascii_lowercase();
base_url == "https://api.deepseek.com" || base_url.starts_with("https://api.deepseek.com/")
}
fn openai_compatible_base_url_has_chat_root(base_url: &str) -> bool {
let lower = base_url.to_ascii_lowercase();
if lower == "https://api.deepseek.com" {
return true;
}
let path = lower
.split_once("://")
.and_then(|(_, rest)| rest.split_once('/').map(|(_, path)| path))
.unwrap_or("")
.trim_matches('/');
if path.is_empty() {
return false;
}
let segments = path.split('/').collect::<Vec<_>>();
let last = segments.last().copied().unwrap_or_default();
matches!(last, "v1" | "v2" | "v3" | "v4" | "beta")
|| path.ends_with("/openai")
|| path.ends_with("compatible-mode/v1")
}
fn json_response(status: u16, value: &Value) -> String {
http_response(
status,
"application/json; charset=utf-8",
&value.to_string(),
)
}
fn http_response(status: u16, content_type: &str, body: &str) -> String {
let reason = match status {
200 => "OK",
400 => "Bad Request",
404 => "Not Found",
502 => "Bad Gateway",
_ => "Internal Server Error",
};
format!(
"HTTP/1.1 {} {}\r\nContent-Type: {}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
status,
reason,
content_type,
body.as_bytes().len(),
body
)
}
fn compact_error_text(text: &str) -> String {
let compact = text.split_whitespace().collect::<Vec<_>>().join(" ");
if compact.chars().count() <= 1000 {
compact
} else {
format!("{}...", compact.chars().take(1000).collect::<String>())
}
}
#[allow(dead_code)]
fn _assert_local_addr(_: SocketAddr) {}
#[cfg(test)]
mod tests {
use super::transformers::ProxyTransformerChain;
use super::*;
#[test]
fn recognizes_anthropic_messages_paths_with_query_strings() {
let path = request_path_without_query("/v1/messages?beta=tools");
assert_eq!(path, "/v1/messages");
assert!(is_messages_path(path));
assert!(is_count_tokens_path("/v1/messages/count_tokens"));
}
#[test]
fn detects_openai_image_parts_for_provider_guard() {
assert!(request_contains_openai_image_parts(&json!({
"messages": [{
"role": "user",
"content": [
{ "type": "text", "text": "what is this?" },
{ "type": "image_url", "image_url": { "url": "data:image/png;base64,abc" } }
]
}]
})));
assert!(!request_contains_openai_image_parts(&json!({
"messages": [{ "role": "user", "content": "text only" }]
})));
}
#[test]
fn converts_tool_use_and_tool_result_messages() {
let credential = OpenAiProxyCredential {
api_key: "sk-test".to_string(),
base_url: "https://api.example.com/v1".to_string(),
model: "qwen-test".to_string(),
transformers: Vec::new(),
model_transformers: Vec::new(),
};
let request = json!({
"system": "system prompt",
"messages": [
{
"role": "assistant",
"content": [{
"type": "tool_use",
"id": "toolu_1",
"name": "Read",
"input": { "file_path": "main.tex" }
}]
},
{
"role": "user",
"content": [{
"type": "tool_result",
"tool_use_id": "toolu_1",
"content": "file text"
}]
}
],
"tools": [{
"name": "Read",
"description": "Read a file",
"input_schema": { "type": "object" }
}]
});
let converted = anthropic_to_openai_request(
&request,
&credential,
&ProxyTransformerChain::from_names(&[]),
)
.unwrap();
assert_eq!(converted["model"], "qwen-test");
assert_eq!(converted["messages"][0]["role"], "system");
assert_eq!(
converted["messages"][1]["tool_calls"][0]["function"]["name"],
"Read"
);
assert_eq!(converted["messages"][2]["role"], "tool");
assert_eq!(converted["tools"][0]["function"]["name"], "Read");
}
#[test]
fn keeps_tool_results_immediately_after_tool_calls() {
let credential = OpenAiProxyCredential {
api_key: "sk-test".to_string(),
base_url: "https://api.example.com/v1".to_string(),
model: "qwen-test".to_string(),
transformers: Vec::new(),
model_transformers: Vec::new(),
};
let request = json!({
"messages": [
{
"role": "assistant",
"content": [{
"type": "tool_use",
"id": "toolu_1",
"name": "Read",
"input": { "file_path": "main.tex" }
}]
},
{
"role": "user",
"content": [
{
"type": "text",
"text": "Now explain it."
},
{
"type": "tool_result",
"tool_use_id": "toolu_1",
"content": "file text"
}
]
}
]
});
let converted = anthropic_to_openai_request(
&request,
&credential,
&ProxyTransformerChain::from_names(&[]),
)
.unwrap();
assert_eq!(converted["messages"][0]["role"], "assistant");
assert_eq!(converted["messages"][1]["role"], "tool");
assert_eq!(converted["messages"][1]["tool_call_id"], "toolu_1");
assert_eq!(converted["messages"][2]["role"], "user");
assert_eq!(converted["messages"][2]["content"], "Now explain it.");
}
#[test]
fn synthesizes_missing_tool_results_before_user_messages() {
let credential = OpenAiProxyCredential {
api_key: "sk-test".to_string(),
base_url: "https://api.example.com/v1".to_string(),
model: "qwen-test".to_string(),
transformers: Vec::new(),
model_transformers: Vec::new(),
};
let request = json!({
"messages": [
{
"role": "assistant",
"content": [{
"type": "tool_use",
"id": "toolu_missing",
"name": "Read",
"input": { "file_path": "main.tex" }
}]
},
{
"role": "user",
"content": "continue"
}
]
});
let converted = anthropic_to_openai_request(
&request,
&credential,
&ProxyTransformerChain::from_names(&[]),
)
.unwrap();
assert_eq!(converted["messages"][0]["role"], "assistant");
assert_eq!(converted["messages"][1]["role"], "tool");
assert_eq!(converted["messages"][1]["tool_call_id"], "toolu_missing");
assert_eq!(converted["messages"][2]["role"], "user");
assert_eq!(converted["messages"][2]["content"], "continue");
}
#[test]
fn converts_openai_tool_call_to_anthropic_message() {
let credential = OpenAiProxyCredential {
api_key: "sk-test".to_string(),
base_url: "https://api.example.com/v1".to_string(),
model: "deepseek-test".to_string(),
transformers: Vec::new(),
model_transformers: Vec::new(),
};
let request = json!({ "model": "claude-sonnet-4" });
let response = json!({
"id": "chatcmpl_1",
"choices": [{
"message": {
"role": "assistant",
"content": null,
"tool_calls": [{
"id": "call_1",
"type": "function",
"function": {
"name": "Grep",
"arguments": "{\"pattern\":\"FastVID\"}"
}
}]
},
"finish_reason": "tool_calls"
}],
"usage": { "prompt_tokens": 10, "completion_tokens": 3 }
});
let converted = openai_to_anthropic_message(&request, &response, &credential).unwrap();
assert_eq!(converted["stop_reason"], "tool_use");
assert_eq!(converted["content"][0]["type"], "tool_use");
assert_eq!(converted["content"][0]["input"]["pattern"], "FastVID");
}
}

View file

@ -1,899 +0,0 @@
use super::tools::{normalized_tool_call_id, repair_tool_arguments, repaired_tool_arguments_value};
use super::transformers::ProxyTransformerChain;
use super::OpenAiProxyCredential;
use serde_json::{json, Value};
const EXIT_TOOL_NAME: &str = "ExitTool";
pub(super) fn anthropic_to_openai_request(
request: &Value,
credential: &OpenAiProxyCredential,
transformers: &ProxyTransformerChain,
) -> Result<Value, String> {
let mut messages = Vec::new();
if let Some(system) = request.get("system").and_then(flatten_anthropic_content) {
if !system.trim().is_empty() {
messages.push(json!({ "role": "system", "content": system }));
}
}
for message in request
.get("messages")
.and_then(|value| value.as_array())
.ok_or_else(|| "Anthropic request is missing messages[]".to_string())?
{
append_openai_messages_for_anthropic_message(&mut messages, message);
}
let messages = normalize_openai_tool_message_pairs(messages);
let mut body = json!({
"model": credential.model,
"messages": messages,
"stream": false,
});
copy_number_field(request, &mut body, "temperature");
copy_number_field(request, &mut body, "top_p");
copy_number_field(request, &mut body, "top_k");
copy_number_field(request, &mut body, "max_tokens");
if let Some(stop) = request.get("stop_sequences") {
body["stop"] = stop.clone();
}
if let Some(tools) = request.get("tools").and_then(|value| value.as_array()) {
let converted = tools
.iter()
.filter_map(anthropic_tool_to_openai_tool)
.collect::<Vec<_>>();
if !converted.is_empty() {
let tool_choice = if transformers.has_tooluse() {
Value::String("required".to_string())
} else {
openai_tool_choice(request.get("tool_choice"))
};
let mut converted = converted;
if tool_choice == Value::String("required".to_string()) {
append_exit_tool(&mut converted);
append_exit_tool_reminder(&mut body);
}
body["tools"] = Value::Array(converted);
body["tool_choice"] = tool_choice;
}
}
Ok(body)
}
pub(super) fn openai_to_anthropic_message(
anthropic_request: &Value,
openai_response: &Value,
credential: &OpenAiProxyCredential,
) -> Result<Value, String> {
let message = openai_response
.pointer("/choices/0/message")
.ok_or_else(|| "Provider response is missing choices[0].message".to_string())?;
let mut content = Vec::new();
if let Some(reasoning) = openai_message_thinking(message) {
content.push(json!({ "type": "thinking", "thinking": reasoning }));
}
if let Some(text) = openai_message_text(message).filter(|value| !value.trim().is_empty()) {
content.push(json!({ "type": "text", "text": text }));
}
if let Some(tool_calls) = message.get("tool_calls").and_then(|value| value.as_array()) {
for call in tool_calls {
let function = call.get("function").unwrap_or(&Value::Null);
let name = function
.get("name")
.and_then(|value| value.as_str())
.unwrap_or("unknown");
let arguments = function
.get("arguments")
.and_then(|value| value.as_str())
.unwrap_or("{}");
if name == EXIT_TOOL_NAME {
if let Some(response) = exit_tool_response(arguments) {
content.push(json!({ "type": "text", "text": response }));
}
continue;
}
let input = repaired_tool_arguments_value(arguments);
let id = normalized_tool_call_id(call.get("id").and_then(|value| value.as_str()));
content.push(json!({
"type": "tool_use",
"id": id,
"name": name,
"input": input,
}));
}
}
if content.is_empty() {
content.push(json!({ "type": "text", "text": "" }));
}
let finish_reason = openai_response
.pointer("/choices/0/finish_reason")
.and_then(|value| value.as_str());
let stop_reason = if content
.iter()
.any(|block| block.get("type").and_then(|value| value.as_str()) == Some("tool_use"))
{
"tool_use"
} else {
match finish_reason {
Some("length") => "max_tokens",
Some("tool_calls") if !contains_only_exit_tool(message) => "tool_use",
_ => "end_turn",
}
};
let usage = openai_response.get("usage").unwrap_or(&Value::Null);
Ok(json!({
"id": openai_response
.get("id")
.and_then(|value| value.as_str())
.map(str::to_string)
.unwrap_or_else(|| format!("msg_{}", uuid::Uuid::new_v4().simple())),
"type": "message",
"role": "assistant",
"model": anthropic_request
.get("model")
.and_then(|value| value.as_str())
.unwrap_or(&credential.model),
"content": content,
"stop_reason": stop_reason,
"stop_sequence": Value::Null,
"usage": {
"input_tokens": usage_token(usage, &["prompt_tokens", "input_tokens", "prompt_token_count"]),
"output_tokens": usage_token(usage, &["completion_tokens", "output_tokens", "completion_token_count"]),
},
}))
}
fn append_openai_messages_for_anthropic_message(messages: &mut Vec<Value>, message: &Value) {
let role = message
.get("role")
.and_then(|value| value.as_str())
.unwrap_or("user");
let content = message.get("content").unwrap_or(&Value::Null);
if role == "assistant" {
let (text, tool_calls, thinking) = assistant_content_to_openai(content);
let mut openai_message = json!({
"role": "assistant",
"content": if text.trim().is_empty() { Value::Null } else { Value::String(text) },
});
if !tool_calls.is_empty() {
openai_message["tool_calls"] = Value::Array(tool_calls);
}
if let Some(thinking) = thinking {
openai_message["thinking"] = thinking;
}
messages.push(openai_message);
return;
}
if let Some(blocks) = content.as_array() {
let content_parts = user_content_blocks_to_openai_parts(blocks);
if !content_parts.is_empty() {
let content = if content_parts.len() == 1
&& content_parts[0]
.get("type")
.and_then(|value| value.as_str())
== Some("text")
{
content_parts[0]
.get("text")
.cloned()
.unwrap_or_else(|| json!(""))
} else {
Value::Array(content_parts)
};
messages.push(json!({ "role": role, "content": content }));
}
for block in blocks {
if block.get("type").and_then(|value| value.as_str()) != Some("tool_result") {
continue;
}
let tool_call_id = block
.get("tool_use_id")
.and_then(|value| value.as_str())
.unwrap_or("toolu_unknown");
let (content, image_parts) =
tool_result_content_to_openai(block.get("content").unwrap_or(&Value::Null));
let content = if content.trim().is_empty() && !image_parts.is_empty() {
"Tool returned image content.".to_string()
} else {
content
};
messages.push(json!({
"role": "tool",
"tool_call_id": tool_call_id,
"content": content,
}));
if !image_parts.is_empty() {
let mut content_parts = vec![json!({
"type": "text",
"text": format!(
"Tool result for {} included image content. Use the attached image when answering.",
tool_call_id
),
})];
content_parts.extend(image_parts);
messages.push(json!({
"role": "user",
"content": content_parts,
}));
}
}
return;
}
let text = content
.as_str()
.map(str::to_string)
.unwrap_or_else(|| content.to_string());
messages.push(json!({ "role": role, "content": text }));
}
fn user_content_blocks_to_openai_parts(blocks: &[Value]) -> Vec<Value> {
blocks
.iter()
.filter_map(
|block| match block.get("type").and_then(|value| value.as_str()) {
Some("text") => block
.get("text")
.and_then(|value| value.as_str())
.filter(|value| !value.is_empty())
.map(|text| json!({ "type": "text", "text": text })),
Some("image") => anthropic_image_block_to_openai_part(block),
_ => None,
},
)
.collect()
}
fn anthropic_image_block_to_openai_part(block: &Value) -> Option<Value> {
let source = block.get("source")?;
let url = match source.get("type").and_then(|value| value.as_str()) {
Some("base64") => {
let media_type = source
.get("media_type")
.and_then(|value| value.as_str())
.unwrap_or("image/png");
let data = source.get("data").and_then(|value| value.as_str())?;
format!("data:{};base64,{}", media_type, data)
}
Some("url") => source
.get("url")
.and_then(|value| value.as_str())?
.to_string(),
_ => return None,
};
Some(json!({
"type": "image_url",
"image_url": {
"url": url,
"detail": "high",
},
}))
}
fn normalize_openai_tool_message_pairs(messages: Vec<Value>) -> Vec<Value> {
let mut normalized = Vec::with_capacity(messages.len());
let mut consumed = vec![false; messages.len()];
for index in 0..messages.len() {
if consumed[index] {
continue;
}
let message = &messages[index];
let tool_call_ids = openai_assistant_tool_call_ids(message);
if !tool_call_ids.is_empty() {
consumed[index] = true;
normalized.push(message.clone());
for tool_call_id in tool_call_ids {
if let Some(tool_index) =
find_following_tool_message(&messages, &consumed, index + 1, &tool_call_id)
{
consumed[tool_index] = true;
normalized.push(messages[tool_index].clone());
} else {
normalized.push(json!({
"role": "tool",
"tool_call_id": tool_call_id,
"content": "Tool result unavailable in the prior Claude Code transcript.",
}));
}
}
continue;
}
consumed[index] = true;
if openai_message_role(message) == Some("tool") {
normalized.push(orphan_tool_message_to_user_message(message));
} else {
normalized.push(message.clone());
}
}
normalized
}
fn openai_assistant_tool_call_ids(message: &Value) -> Vec<String> {
if openai_message_role(message) != Some("assistant") {
return Vec::new();
}
message
.get("tool_calls")
.and_then(|value| value.as_array())
.map(|tool_calls| {
tool_calls
.iter()
.filter_map(|tool_call| tool_call.get("id").and_then(|value| value.as_str()))
.map(str::to_string)
.collect()
})
.unwrap_or_default()
}
fn find_following_tool_message(
messages: &[Value],
consumed: &[bool],
start: usize,
tool_call_id: &str,
) -> Option<usize> {
for index in start..messages.len() {
if consumed[index] {
continue;
}
let message = &messages[index];
if openai_message_role(message) == Some("assistant") {
break;
}
if openai_tool_message_id(message) == Some(tool_call_id) {
return Some(index);
}
}
None
}
fn orphan_tool_message_to_user_message(message: &Value) -> Value {
let tool_call_id = openai_tool_message_id(message).unwrap_or("unknown");
let content = message
.get("content")
.and_then(|value| value.as_str())
.map(str::to_string)
.unwrap_or_else(|| {
message
.get("content")
.cloned()
.unwrap_or(Value::Null)
.to_string()
});
json!({
"role": "user",
"content": format!("Tool result for {}:\n{}", tool_call_id, content),
})
}
fn openai_message_role(message: &Value) -> Option<&str> {
message.get("role").and_then(|value| value.as_str())
}
fn openai_tool_message_id(message: &Value) -> Option<&str> {
if openai_message_role(message) != Some("tool") {
return None;
}
message.get("tool_call_id").and_then(|value| value.as_str())
}
fn flatten_anthropic_content(value: &Value) -> Option<String> {
if let Some(text) = value.as_str() {
return Some(text.to_string());
}
value.as_array().map(|blocks| {
blocks
.iter()
.filter_map(|block| {
block
.get("text")
.and_then(|value| value.as_str())
.or_else(|| block.get("content").and_then(|value| value.as_str()))
})
.collect::<Vec<_>>()
.join("\n\n")
})
}
fn assistant_content_to_openai(content: &Value) -> (String, Vec<Value>, Option<Value>) {
let Some(blocks) = content.as_array() else {
return (
content
.as_str()
.map(str::to_string)
.unwrap_or_else(|| content.to_string()),
Vec::new(),
None,
);
};
let mut text = Vec::new();
let mut tool_calls = Vec::new();
let mut thinking = None;
for block in blocks {
match block.get("type").and_then(|value| value.as_str()) {
Some("text") => {
if let Some(value) = block.get("text").and_then(|value| value.as_str()) {
text.push(value);
}
}
Some("tool_use") => {
let id = block
.get("id")
.and_then(|value| value.as_str())
.unwrap_or("toolu_unknown");
let name = block
.get("name")
.and_then(|value| value.as_str())
.unwrap_or("unknown");
let input = block.get("input").cloned().unwrap_or_else(|| json!({}));
tool_calls.push(json!({
"id": id,
"type": "function",
"function": {
"name": name,
"arguments": input.to_string(),
},
}));
}
Some("thinking") => {
if let Some(value) = block.get("thinking").and_then(|value| value.as_str()) {
let mut thinking_value = json!({ "content": value });
if let Some(signature) = block.get("signature").and_then(|value| value.as_str())
{
thinking_value["signature"] = Value::String(signature.to_string());
}
thinking = Some(thinking_value);
}
}
_ => {}
}
}
(text.join("\n\n"), tool_calls, thinking)
}
fn tool_result_content_to_openai(content: &Value) -> (String, Vec<Value>) {
if let Some(text) = content.as_str() {
return (text.to_string(), Vec::new());
}
if let Some(blocks) = content.as_array() {
let mut text = Vec::new();
let mut image_parts = Vec::new();
for block in blocks {
if let Some(value) = block
.get("text")
.and_then(|value| value.as_str())
.or_else(|| block.get("content").and_then(|value| value.as_str()))
{
text.push(value);
continue;
}
if block.get("type").and_then(|value| value.as_str()) == Some("image") {
if let Some(part) = anthropic_image_block_to_openai_part(block) {
image_parts.push(part);
}
}
}
return (text.join("\n\n"), image_parts);
}
(content.to_string(), Vec::new())
}
fn anthropic_tool_to_openai_tool(tool: &Value) -> Option<Value> {
let name = tool.get("name")?.as_str()?;
let description = tool
.get("description")
.and_then(|value| value.as_str())
.unwrap_or_default();
let parameters = tool
.get("input_schema")
.cloned()
.unwrap_or_else(|| json!({ "type": "object", "properties": {} }));
Some(json!({
"type": "function",
"function": {
"name": name,
"description": description,
"parameters": parameters,
},
}))
}
fn append_exit_tool(tools: &mut Vec<Value>) {
if tools.iter().any(|tool| {
tool.pointer("/function/name")
.and_then(|value| value.as_str())
== Some(EXIT_TOOL_NAME)
}) {
return;
}
tools.push(json!({
"type": "function",
"function": {
"name": EXIT_TOOL_NAME,
"description": "Use this when tool mode is active and no remaining tool call is needed. This is the valid way to exit tool mode with a final answer.",
"parameters": {
"type": "object",
"properties": {
"response": {
"type": "string",
"description": "Final response to show the user exactly as written."
}
},
"required": ["response"]
}
}
}));
}
fn append_exit_tool_reminder(body: &mut Value) {
let Some(messages) = body
.get_mut("messages")
.and_then(|value| value.as_array_mut())
else {
return;
};
messages.push(json!({
"role": "system",
"content": "<system-reminder>Tool mode is active. The user expects you to proactively execute the most suitable tool to help complete the task. Before invoking a tool, carefully evaluate whether it matches the current task. If no available tool is appropriate, or the task is complete, call ExitTool with the final response instead of inventing another tool call.</system-reminder>",
}));
}
fn openai_tool_choice(choice: Option<&Value>) -> Value {
let Some(choice) = choice else {
return Value::String("auto".to_string());
};
match choice.get("type").and_then(|value| value.as_str()) {
Some("auto") => Value::String("auto".to_string()),
Some("any") => Value::String("required".to_string()),
Some("tool") => {
let name = choice
.get("name")
.and_then(|value| value.as_str())
.unwrap_or_default();
json!({
"type": "function",
"function": { "name": name },
})
}
_ => Value::String("auto".to_string()),
}
}
fn copy_number_field(source: &Value, target: &mut Value, key: &str) {
if let Some(value) = source.get(key).filter(|value| value.is_number()) {
target[key] = value.clone();
}
}
fn openai_message_text(message: &Value) -> Option<String> {
let content = message.get("content")?;
if let Some(text) = content.as_str() {
return Some(text.to_string());
}
content.as_array().map(|parts| {
parts
.iter()
.filter_map(|part| {
part.get("text")
.and_then(|value| value.as_str())
.or_else(|| {
if part.get("type").and_then(|value| value.as_str()) == Some("text") {
part.get("content").and_then(|value| value.as_str())
} else {
None
}
})
})
.collect::<Vec<_>>()
.join("\n")
})
}
fn openai_message_thinking(message: &Value) -> Option<String> {
message
.get("reasoning_content")
.or_else(|| message.get("reasoning"))
.and_then(|value| value.as_str())
.filter(|value| !value.trim().is_empty())
.map(str::to_string)
.or_else(|| {
message
.get("thinking")
.and_then(|value| {
value
.get("content")
.and_then(|content| content.as_str())
.or_else(|| value.as_str())
})
.filter(|value| !value.trim().is_empty())
.map(str::to_string)
})
}
fn exit_tool_response(arguments: &str) -> Option<String> {
let repaired = repair_tool_arguments(arguments);
serde_json::from_str::<Value>(&repaired)
.ok()
.and_then(|value| {
value
.get("response")
.and_then(|value| value.as_str())
.map(str::to_string)
})
.filter(|value| !value.trim().is_empty())
}
fn contains_only_exit_tool(message: &Value) -> bool {
let Some(tool_calls) = message.get("tool_calls").and_then(|value| value.as_array()) else {
return false;
};
!tool_calls.is_empty()
&& tool_calls.iter().all(|call| {
call.pointer("/function/name")
.and_then(|value| value.as_str())
== Some(EXIT_TOOL_NAME)
})
}
fn usage_token(usage: &Value, keys: &[&str]) -> u64 {
keys.iter()
.find_map(|key| usage.get(*key).and_then(|value| value.as_u64()))
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
fn credential() -> OpenAiProxyCredential {
OpenAiProxyCredential {
api_key: "sk-test".to_string(),
base_url: "https://api.example.com/v1".to_string(),
model: "qwen-test".to_string(),
transformers: Vec::new(),
model_transformers: Vec::new(),
}
}
fn transformers(names: &[&str]) -> ProxyTransformerChain {
ProxyTransformerChain::from_names(names)
}
#[test]
fn preserves_anthropic_image_blocks_as_openai_image_url_parts() {
let request = json!({
"messages": [{
"role": "user",
"content": [
{ "type": "text", "text": "what is this?" },
{
"type": "image",
"source": {
"type": "base64",
"media_type": "image/png",
"data": "abcd"
}
}
]
}]
});
let converted =
anthropic_to_openai_request(&request, &credential(), &transformers(&[])).unwrap();
assert_eq!(converted["messages"][0]["content"][0]["type"], "text");
assert_eq!(converted["messages"][0]["content"][1]["type"], "image_url");
assert_eq!(
converted["messages"][0]["content"][1]["image_url"]["url"],
"data:image/png;base64,abcd"
);
assert_eq!(
converted["messages"][0]["content"][1]["image_url"]["detail"],
"high"
);
}
#[test]
fn preserves_tool_result_images_as_follow_up_user_image_parts() {
let request = json!({
"messages": [
{
"role": "assistant",
"content": [{
"type": "tool_use",
"id": "toolu_read_image",
"name": "Read",
"input": { "file_path": "attachments/figure.png" }
}]
},
{
"role": "user",
"content": [{
"type": "tool_result",
"tool_use_id": "toolu_read_image",
"content": [
{ "type": "text", "text": "Image read successfully." },
{
"type": "image",
"source": {
"type": "base64",
"media_type": "image/png",
"data": "abcd"
}
}
]
}]
}
]
});
let converted =
anthropic_to_openai_request(&request, &credential(), &transformers(&[])).unwrap();
assert_eq!(converted["messages"][0]["role"], "assistant");
assert_eq!(converted["messages"][1]["role"], "tool");
assert_eq!(converted["messages"][1]["tool_call_id"], "toolu_read_image");
assert_eq!(
converted["messages"][1]["content"],
"Image read successfully."
);
assert_eq!(converted["messages"][2]["role"], "user");
assert_eq!(converted["messages"][2]["content"][0]["type"], "text");
assert_eq!(converted["messages"][2]["content"][1]["type"], "image_url");
assert_eq!(
converted["messages"][2]["content"][1]["image_url"]["url"],
"data:image/png;base64,abcd"
);
assert_eq!(
converted["messages"][2]["content"][1]["image_url"]["detail"],
"high"
);
}
#[test]
fn preserves_assistant_thinking_for_provider_context() {
let request = json!({
"messages": [{
"role": "assistant",
"content": [
{
"type": "thinking",
"thinking": "I inspected the files.",
"signature": "sig_1"
},
{
"type": "text",
"text": "Done."
}
]
}]
});
let converted =
anthropic_to_openai_request(&request, &credential(), &transformers(&[])).unwrap();
assert_eq!(
converted["messages"][0]["thinking"]["content"],
"I inspected the files."
);
assert_eq!(converted["messages"][0]["thinking"]["signature"], "sig_1");
}
#[test]
fn adds_exit_tool_when_tool_choice_requires_a_tool() {
let request = json!({
"messages": [{ "role": "user", "content": "finish" }],
"tool_choice": { "type": "any" },
"tools": [{
"name": "Read",
"description": "Read a file",
"input_schema": { "type": "object" }
}]
});
let converted =
anthropic_to_openai_request(&request, &credential(), &transformers(&[])).unwrap();
let tool_names = converted["tools"]
.as_array()
.unwrap()
.iter()
.filter_map(|tool| {
tool.pointer("/function/name")
.and_then(|value| value.as_str())
})
.collect::<Vec<_>>();
assert!(tool_names.contains(&"Read"));
assert!(tool_names.contains(&EXIT_TOOL_NAME));
assert_eq!(converted["tool_choice"], "required");
}
#[test]
fn tooluse_transformer_forces_exit_tool_like_ccr() {
let request = json!({
"messages": [{ "role": "user", "content": "finish" }],
"tools": [{
"name": "Read",
"description": "Read a file",
"input_schema": { "type": "object" }
}]
});
let converted =
anthropic_to_openai_request(&request, &credential(), &transformers(&["tooluse"]))
.unwrap();
let tool_names = converted["tools"]
.as_array()
.unwrap()
.iter()
.filter_map(|tool| {
tool.pointer("/function/name")
.and_then(|value| value.as_str())
})
.collect::<Vec<_>>();
assert_eq!(converted["tool_choice"], "required");
assert!(tool_names.contains(&"Read"));
assert!(tool_names.contains(&EXIT_TOOL_NAME));
assert!(converted["messages"]
.as_array()
.unwrap()
.iter()
.any(|message| message
.get("content")
.and_then(|value| value.as_str())
.is_some_and(|content| content.contains("Tool mode is active"))));
}
#[test]
fn converts_exit_tool_response_to_final_text() {
let request = json!({ "model": "claude-sonnet-4" });
let response = json!({
"id": "chatcmpl_1",
"choices": [{
"message": {
"role": "assistant",
"content": null,
"tool_calls": [{
"id": "call_exit",
"type": "function",
"function": {
"name": "ExitTool",
"arguments": "{\"response\":\"done\"}"
}
}]
},
"finish_reason": "tool_calls"
}],
"usage": { "prompt_tokens": 5, "completion_tokens": 2 }
});
let converted = openai_to_anthropic_message(&request, &response, &credential()).unwrap();
assert_eq!(converted["stop_reason"], "end_turn");
assert_eq!(converted["content"][0]["type"], "text");
assert_eq!(converted["content"][0]["text"], "done");
}
}

View file

@ -1,214 +0,0 @@
use super::transformers::ProxyTransformerChain;
use super::OpenAiProxyCredential;
use serde_json::{json, Value};
const DEEPSEEK_MAX_TOKENS: u64 = 8192;
pub(super) fn apply_provider_request_transforms(
openai_request: &mut Value,
anthropic_request: &Value,
credential: &OpenAiProxyCredential,
wants_stream: bool,
transformers: &ProxyTransformerChain,
) {
if transformers.has_cleancache() {
clean_cache_control(openai_request);
}
if wants_stream && transformers.has_streamoptions() {
openai_request["stream_options"] = json!({ "include_usage": true });
}
if transformers.has_deepseek() {
cap_number_field(openai_request, "max_tokens", DEEPSEEK_MAX_TOKENS);
}
apply_reasoning_budget(openai_request, anthropic_request);
apply_max_completion_tokens_compat(openai_request, credential);
clean_null_optional_fields(openai_request);
}
fn cap_number_field(body: &mut Value, key: &str, max: u64) {
let Some(value) = body.get(key).and_then(|value| value.as_u64()) else {
return;
};
if value > max {
body[key] = Value::Number(max.into());
}
}
fn apply_reasoning_budget(openai_request: &mut Value, anthropic_request: &Value) {
let Some(thinking) = anthropic_request.get("thinking") else {
return;
};
if thinking.get("type").and_then(|value| value.as_str()) != Some("enabled") {
return;
}
let Some(budget_tokens) = thinking
.get("budget_tokens")
.and_then(|value| value.as_u64())
else {
return;
};
if budget_tokens > 0 {
openai_request["reasoning"] = json!({ "max_tokens": budget_tokens });
}
}
fn apply_max_completion_tokens_compat(
openai_request: &mut Value,
credential: &OpenAiProxyCredential,
) {
if !uses_max_completion_tokens(credential) {
return;
}
let Some(max_tokens) = openai_request.get("max_tokens").cloned() else {
return;
};
openai_request["max_completion_tokens"] = max_tokens;
if let Some(object) = openai_request.as_object_mut() {
object.remove("max_tokens");
}
}
fn uses_max_completion_tokens(credential: &OpenAiProxyCredential) -> bool {
let base_url = credential.base_url.to_ascii_lowercase();
let model = credential.model.to_ascii_lowercase();
let is_openai = base_url.contains("api.openai.com") || base_url.contains("openai.azure.com");
is_openai
&& (model.starts_with("o1")
|| model.starts_with("o3")
|| model.starts_with("o4")
|| model.starts_with("gpt-5"))
}
fn clean_cache_control(value: &mut Value) {
match value {
Value::Array(values) => {
for value in values {
clean_cache_control(value);
}
}
Value::Object(object) => {
object.remove("cache_control");
for value in object.values_mut() {
clean_cache_control(value);
}
}
_ => {}
}
}
fn clean_null_optional_fields(value: &mut Value) {
let Some(object) = value.as_object_mut() else {
return;
};
for key in ["tool_choice", "stop", "stream_options", "reasoning"] {
if object.get(key).is_some_and(Value::is_null) {
object.remove(key);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn credential(base_url: &str, model: &str) -> OpenAiProxyCredential {
OpenAiProxyCredential {
api_key: "sk-test".to_string(),
base_url: base_url.to_string(),
model: model.to_string(),
transformers: Vec::new(),
model_transformers: Vec::new(),
}
}
#[test]
fn adds_usage_stream_options_for_streaming_requests() {
let mut body = json!({ "stream": true });
apply_provider_request_transforms(
&mut body,
&json!({}),
&credential("https://api.example.com/v1", "qwen-test"),
true,
&ProxyTransformerChain::from_names(&["streamoptions"]),
);
assert_eq!(body["stream_options"]["include_usage"], true);
}
#[test]
fn caps_deepseek_max_tokens() {
let mut body = json!({ "max_tokens": 20000 });
apply_provider_request_transforms(
&mut body,
&json!({}),
&credential("https://api.deepseek.com", "deepseek-chat"),
false,
&ProxyTransformerChain::from_names(&["deepseek"]),
);
assert_eq!(body["max_tokens"], DEEPSEEK_MAX_TOKENS);
}
#[test]
fn copies_anthropic_thinking_budget_as_reasoning() {
let mut body = json!({});
apply_provider_request_transforms(
&mut body,
&json!({
"thinking": {
"type": "enabled",
"budget_tokens": 4096
}
}),
&credential("https://api.example.com/v1", "qwen-test"),
false,
&ProxyTransformerChain::from_names(&[]),
);
assert_eq!(body["reasoning"]["max_tokens"], 4096);
}
#[test]
fn converts_openai_reasoning_models_to_max_completion_tokens() {
let mut body = json!({ "max_tokens": 12000 });
apply_provider_request_transforms(
&mut body,
&json!({}),
&credential("https://api.openai.com/v1", "o3"),
false,
&ProxyTransformerChain::from_names(&[]),
);
assert!(body.get("max_tokens").is_none());
assert_eq!(body["max_completion_tokens"], 12000);
}
#[test]
fn strips_cache_control_from_openai_compatible_requests() {
let mut body = json!({
"messages": [{
"role": "user",
"content": [{
"type": "text",
"text": "hello",
"cache_control": { "type": "ephemeral" }
}]
}]
});
apply_provider_request_transforms(
&mut body,
&json!({}),
&credential("https://api.example.com/v1", "qwen-test"),
false,
&ProxyTransformerChain::from_names(&["cleancache"]),
);
assert!(body["messages"][0]["content"][0]
.get("cache_control")
.is_none());
}
}

View file

@ -1,925 +0,0 @@
use super::tools::{normalized_tool_call_id, repair_tool_arguments};
use super::{http_response, OpenAiProxyCredential};
use serde_json::{json, Value};
use std::collections::HashMap;
use tokio::io::AsyncWriteExt;
use tokio::net::TcpStream;
#[derive(Default)]
struct OpenAiStreamState {
message_started: bool,
completed: bool,
message_id: Option<String>,
model: Option<String>,
next_block_index: usize,
text_block_index: Option<usize>,
thinking_block_index: Option<usize>,
tool_blocks: HashMap<i64, StreamToolBlock>,
stop_reason: Option<String>,
output_tokens: u64,
}
#[derive(Default)]
struct StreamToolBlock {
id: Option<String>,
name: Option<String>,
buffered_arguments: String,
}
pub(super) async fn stream_openai_sse_to_anthropic(
stream: &mut TcpStream,
mut response: reqwest::Response,
anthropic_request: &Value,
credential: &OpenAiProxyCredential,
) -> Result<(), String> {
stream
.write_all(streaming_http_headers().as_bytes())
.await
.map_err(|err| format!("Failed to write proxy stream headers: {}", err))?;
let mut state = OpenAiStreamState::default();
let mut buffer = String::new();
while let Some(chunk) = match response.chunk().await {
Ok(chunk) => chunk,
Err(err) => {
let rendered =
anthropic_stream_error_sse(&format!("Provider stream ended unexpectedly: {}", err));
let _ = write_stream_body(stream, &rendered, "provider stream error").await;
return Ok(());
}
} {
buffer.push_str(&String::from_utf8_lossy(&chunk));
while let Some((event, rest)) = take_next_sse_event(&buffer) {
buffer = rest;
let rendered =
openai_sse_event_to_anthropic(&mut state, &event, anthropic_request, credential);
if !write_stream_body(stream, &rendered, "proxy stream event").await {
return Ok(());
}
}
}
if !buffer.trim().is_empty() {
let rendered =
openai_sse_event_to_anthropic(&mut state, &buffer, anthropic_request, credential);
if !write_stream_body(stream, &rendered, "final proxy stream event").await {
return Ok(());
}
}
let rendered = finish_anthropic_stream(&mut state);
let _ = write_stream_body(stream, &rendered, "proxy stream completion").await;
Ok(())
}
fn streaming_http_headers() -> String {
"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream; charset=utf-8\r\nCache-Control: no-cache\r\nConnection: close\r\n\r\n"
.to_string()
}
async fn write_stream_body(stream: &mut TcpStream, body: &str, context: &str) -> bool {
if body.is_empty() {
return true;
}
match stream.write_all(body.as_bytes()).await {
Ok(()) => true,
Err(err) => {
eprintln!("[anthropic-proxy] failed to write {}: {}", context, err);
false
}
}
}
fn anthropic_stream_error_sse(message: &str) -> String {
let mut body = String::new();
push_sse(
&mut body,
"error",
&json!({
"type": "error",
"error": {
"type": "api_error",
"message": message,
},
}),
);
body
}
fn take_next_sse_event(buffer: &str) -> Option<(String, String)> {
if let Some(index) = buffer.find("\n\n") {
let event = buffer[..index].to_string();
let rest = buffer[index + 2..].to_string();
return Some((event, rest));
}
if let Some(index) = buffer.find("\r\n\r\n") {
let event = buffer[..index].to_string();
let rest = buffer[index + 4..].to_string();
return Some((event, rest));
}
None
}
fn openai_sse_event_to_anthropic(
state: &mut OpenAiStreamState,
event: &str,
anthropic_request: &Value,
credential: &OpenAiProxyCredential,
) -> String {
let Some(data) = sse_event_data(event) else {
return String::new();
};
if data.trim() == "[DONE]" {
return finish_anthropic_stream(state);
}
let Ok(chunk) = serde_json::from_str::<Value>(&data) else {
return String::new();
};
openai_stream_chunk_to_anthropic(state, &chunk, anthropic_request, credential)
}
fn sse_event_data(event: &str) -> Option<String> {
let mut parts = Vec::new();
for line in event.lines() {
let line = line.trim_end_matches('\r');
if let Some(data) = line.strip_prefix("data:") {
parts.push(data.trim_start());
}
}
if parts.is_empty() {
None
} else {
Some(parts.join("\n"))
}
}
fn openai_stream_chunk_to_anthropic(
state: &mut OpenAiStreamState,
chunk: &Value,
anthropic_request: &Value,
credential: &OpenAiProxyCredential,
) -> String {
let mut body = String::new();
ensure_stream_message_started(state, &mut body, chunk, anthropic_request, credential);
if let Some(usage) = chunk.get("usage") {
state.output_tokens = usage_token(
usage,
&[
"completion_tokens",
"output_tokens",
"completion_token_count",
],
);
}
let Some(choice) = chunk
.get("choices")
.and_then(|value| value.as_array())
.and_then(|choices| choices.first())
else {
return body;
};
let delta = choice.get("delta").unwrap_or(&Value::Null);
if let Some(reasoning) = delta_text(delta, &["reasoning_content", "reasoning"]) {
push_stream_text_delta(state, &mut body, "thinking", &reasoning);
}
if let Some(thinking) = delta
.get("thinking")
.and_then(|value| {
value
.get("content")
.and_then(|content| content.as_str())
.or_else(|| value.as_str())
})
.filter(|value| !value.is_empty())
{
push_stream_text_delta(state, &mut body, "thinking", thinking);
}
if let Some(content) = delta_text(delta, &["content"]) {
push_stream_text_delta(state, &mut body, "text", &content);
}
if let Some(tool_calls) = delta.get("tool_calls").and_then(|value| value.as_array()) {
for call in tool_calls {
push_stream_tool_delta(state, call);
}
}
if let Some(finish_reason) = choice.get("finish_reason").and_then(|value| value.as_str()) {
if !finish_reason.is_empty() {
state.stop_reason = Some(map_openai_finish_reason(finish_reason).to_string());
}
}
body
}
fn ensure_stream_message_started(
state: &mut OpenAiStreamState,
body: &mut String,
chunk: &Value,
anthropic_request: &Value,
credential: &OpenAiProxyCredential,
) {
if state.message_started {
return;
}
state.message_started = true;
state.message_id = chunk
.get("id")
.and_then(|value| value.as_str())
.map(str::to_string)
.or_else(|| Some(format!("msg_{}", uuid::Uuid::new_v4().simple())));
state.model = anthropic_request
.get("model")
.and_then(|value| value.as_str())
.map(str::to_string)
.or_else(|| {
chunk
.get("model")
.and_then(|value| value.as_str())
.map(str::to_string)
})
.or_else(|| Some(credential.model.clone()));
push_sse(
body,
"message_start",
&json!({
"type": "message_start",
"message": {
"id": state.message_id.clone().unwrap_or_else(|| format!("msg_{}", uuid::Uuid::new_v4().simple())),
"type": "message",
"role": "assistant",
"model": state.model.clone().unwrap_or_else(|| credential.model.clone()),
"content": [],
"stop_reason": Value::Null,
"stop_sequence": Value::Null,
"usage": {
"input_tokens": 0,
"output_tokens": 0,
},
},
}),
);
}
fn delta_text(delta: &Value, keys: &[&str]) -> Option<String> {
keys.iter()
.find_map(|key| delta.get(*key).and_then(|value| value.as_str()))
.filter(|value| !value.is_empty())
.map(str::to_string)
}
fn push_stream_text_delta(
state: &mut OpenAiStreamState,
body: &mut String,
block_type: &str,
text: &str,
) {
if block_type != "thinking" {
close_thinking_block_if_open(state, body);
}
let block_index = if block_type == "thinking" {
if let Some(index) = state.thinking_block_index {
index
} else {
let index = state.next_block_index;
state.next_block_index += 1;
state.thinking_block_index = Some(index);
push_sse(
body,
"content_block_start",
&json!({
"type": "content_block_start",
"index": index,
"content_block": {
"type": "thinking",
"thinking": "",
},
}),
);
index
}
} else if let Some(index) = state.text_block_index {
index
} else {
let index = state.next_block_index;
state.next_block_index += 1;
state.text_block_index = Some(index);
push_sse(
body,
"content_block_start",
&json!({
"type": "content_block_start",
"index": index,
"content_block": {
"type": "text",
"text": "",
},
}),
);
index
};
let (delta_type, key) = if block_type == "thinking" {
("thinking_delta", "thinking")
} else {
("text_delta", "text")
};
push_sse(
body,
"content_block_delta",
&json!({
"type": "content_block_delta",
"index": block_index,
"delta": {
"type": delta_type,
key: text,
},
}),
);
}
fn push_stream_tool_delta(state: &mut OpenAiStreamState, call: &Value) {
let openai_index = call
.get("index")
.and_then(|value| value.as_i64())
.unwrap_or(0);
let block = state.tool_blocks.entry(openai_index).or_default();
if let Some(id) = call.get("id").and_then(|value| value.as_str()) {
block.id = Some(normalized_tool_call_id(Some(id)));
}
let function = call.get("function").unwrap_or(&Value::Null);
if let Some(name) = function.get("name").and_then(|value| value.as_str()) {
if !name.is_empty() {
block.name = Some(name.to_string());
}
}
if let Some(arguments) = function.get("arguments").and_then(|value| value.as_str()) {
block.buffered_arguments.push_str(arguments);
}
}
fn close_thinking_block_if_open(state: &mut OpenAiStreamState, body: &mut String) {
if let Some(index) = state.thinking_block_index.take() {
push_sse(
body,
"content_block_delta",
&json!({
"type": "content_block_delta",
"index": index,
"delta": {
"type": "signature_delta",
"signature": format!("ccr_{}", uuid::Uuid::new_v4().simple()),
},
}),
);
push_content_block_stop(body, index);
}
}
fn finish_anthropic_stream(state: &mut OpenAiStreamState) -> String {
if state.completed {
return String::new();
}
state.completed = true;
let mut body = String::new();
if !state.message_started {
state.message_started = true;
let message_id = format!("msg_{}", uuid::Uuid::new_v4().simple());
state.message_id = Some(message_id.clone());
push_sse(
&mut body,
"message_start",
&json!({
"type": "message_start",
"message": {
"id": message_id,
"type": "message",
"role": "assistant",
"model": state.model.clone().unwrap_or_else(|| "claude-prism-proxy".to_string()),
"content": [],
"stop_reason": Value::Null,
"stop_sequence": Value::Null,
"usage": {
"input_tokens": 0,
"output_tokens": 0,
},
},
}),
);
}
close_thinking_block_if_open(state, &mut body);
if let Some(index) = state.text_block_index.take() {
push_content_block_stop(&mut body, index);
}
let mut tool_blocks = state
.tool_blocks
.iter()
.map(|(openai_index, block)| (*openai_index, block))
.collect::<Vec<_>>();
tool_blocks.sort_by_key(|(openai_index, _)| *openai_index);
let exit_tool_response = if tool_blocks
.iter()
.all(|(_, block)| block.name.as_deref() == Some("ExitTool"))
{
tool_blocks
.iter()
.find_map(|(_, block)| exit_tool_response(&block.buffered_arguments))
} else {
None
};
if let Some(response) = exit_tool_response {
push_stream_text_delta(state, &mut body, "text", &response);
if let Some(index) = state.text_block_index.take() {
push_content_block_stop(&mut body, index);
}
state.tool_blocks.clear();
state.stop_reason = Some("end_turn".to_string());
}
let mut tool_blocks = state
.tool_blocks
.iter()
.map(|(openai_index, block)| (*openai_index, block))
.collect::<Vec<_>>();
tool_blocks.sort_by_key(|(openai_index, _)| *openai_index);
for (_, block) in tool_blocks {
let index = state.next_block_index;
state.next_block_index += 1;
push_sse(
&mut body,
"content_block_start",
&json!({
"type": "content_block_start",
"index": index,
"content_block": {
"type": "tool_use",
"id": block.id.clone().unwrap_or_else(|| normalized_tool_call_id(None)),
"name": block.name.clone().unwrap_or_else(|| "unknown".to_string()),
"input": {},
},
}),
);
let repaired_arguments = repair_tool_arguments(&block.buffered_arguments);
if repaired_arguments != "{}" || !block.buffered_arguments.trim().is_empty() {
push_sse(
&mut body,
"content_block_delta",
&json!({
"type": "content_block_delta",
"index": index,
"delta": {
"type": "input_json_delta",
"partial_json": repaired_arguments,
},
}),
);
}
push_content_block_stop(&mut body, index);
}
let stop_reason = if state.tool_blocks.is_empty() {
state
.stop_reason
.clone()
.unwrap_or_else(|| "end_turn".to_string())
} else {
"tool_use".to_string()
};
push_sse(
&mut body,
"message_delta",
&json!({
"type": "message_delta",
"delta": {
"stop_reason": stop_reason,
"stop_sequence": Value::Null,
},
"usage": {
"output_tokens": state.output_tokens,
},
}),
);
push_sse(
&mut body,
"message_stop",
&json!({ "type": "message_stop" }),
);
body
}
fn exit_tool_response(arguments: &str) -> Option<String> {
let repaired = repair_tool_arguments(arguments);
serde_json::from_str::<Value>(&repaired)
.ok()
.and_then(|value| {
value
.get("response")
.and_then(|value| value.as_str())
.map(str::to_string)
})
.filter(|value| !value.trim().is_empty())
}
fn push_content_block_stop(body: &mut String, index: usize) {
push_sse(
body,
"content_block_stop",
&json!({
"type": "content_block_stop",
"index": index,
}),
);
}
fn map_openai_finish_reason(reason: &str) -> &str {
match reason {
"length" => "max_tokens",
"tool_calls" => "tool_use",
_ => "end_turn",
}
}
pub(super) fn sse_response(message: &Value) -> String {
let content = message
.get("content")
.and_then(|value| value.as_array())
.cloned()
.unwrap_or_default();
let input_tokens = message
.pointer("/usage/input_tokens")
.and_then(|value| value.as_u64())
.unwrap_or(0);
let output_tokens = message
.pointer("/usage/output_tokens")
.and_then(|value| value.as_u64())
.unwrap_or(0);
let start = json!({
"type": "message_start",
"message": {
"id": message.get("id").cloned().unwrap_or_else(|| json!(format!("msg_{}", uuid::Uuid::new_v4().simple()))),
"type": "message",
"role": "assistant",
"model": message.get("model").cloned().unwrap_or_else(|| json!("claude-prism-proxy")),
"content": [],
"stop_reason": Value::Null,
"stop_sequence": Value::Null,
"usage": {
"input_tokens": input_tokens,
"output_tokens": 0,
},
},
});
let mut body = String::new();
push_sse(&mut body, "message_start", &start);
for (index, block) in content.iter().enumerate() {
let block_type = block
.get("type")
.and_then(|value| value.as_str())
.unwrap_or("text");
match block_type {
"tool_use" => {
let content_block = json!({
"type": "tool_use",
"id": block.get("id").cloned().unwrap_or_else(|| json!(format!("toolu_{}", uuid::Uuid::new_v4().simple()))),
"name": block.get("name").cloned().unwrap_or_else(|| json!("unknown")),
"input": {},
});
push_sse(
&mut body,
"content_block_start",
&json!({
"type": "content_block_start",
"index": index,
"content_block": content_block,
}),
);
let input = block.get("input").cloned().unwrap_or_else(|| json!({}));
push_sse(
&mut body,
"content_block_delta",
&json!({
"type": "content_block_delta",
"index": index,
"delta": {
"type": "input_json_delta",
"partial_json": input.to_string(),
},
}),
);
}
"thinking" => {
push_text_like_sse_block(
&mut body,
index,
"thinking",
block
.get("thinking")
.and_then(|value| value.as_str())
.unwrap_or_default(),
);
}
_ => {
push_text_like_sse_block(
&mut body,
index,
"text",
block
.get("text")
.and_then(|value| value.as_str())
.unwrap_or_default(),
);
}
}
push_content_block_stop(&mut body, index);
}
push_sse(
&mut body,
"message_delta",
&json!({
"type": "message_delta",
"delta": {
"stop_reason": message.get("stop_reason").cloned().unwrap_or_else(|| json!("end_turn")),
"stop_sequence": Value::Null,
},
"usage": {
"output_tokens": output_tokens,
},
}),
);
push_sse(
&mut body,
"message_stop",
&json!({ "type": "message_stop" }),
);
http_response(
200,
"text/event-stream; charset=utf-8",
&format!("{}{}", body, "\n"),
)
}
fn push_text_like_sse_block(body: &mut String, index: usize, block_type: &str, text: &str) {
let content_block = if block_type == "thinking" {
json!({
"type": "thinking",
"thinking": "",
})
} else {
json!({
"type": "text",
"text": "",
})
};
push_sse(
body,
"content_block_start",
&json!({
"type": "content_block_start",
"index": index,
"content_block": content_block,
}),
);
if !text.is_empty() {
let delta_type = if block_type == "thinking" {
"thinking_delta"
} else {
"text_delta"
};
let delta_key = if block_type == "thinking" {
"thinking"
} else {
"text"
};
push_sse(
body,
"content_block_delta",
&json!({
"type": "content_block_delta",
"index": index,
"delta": {
"type": delta_type,
delta_key: text,
},
}),
);
}
if block_type == "thinking" {
push_sse(
body,
"content_block_delta",
&json!({
"type": "content_block_delta",
"index": index,
"delta": {
"type": "signature_delta",
"signature": format!("ccr_{}", uuid::Uuid::new_v4().simple()),
},
}),
);
}
}
fn push_sse(body: &mut String, event: &str, data: &Value) {
body.push_str("event: ");
body.push_str(event);
body.push('\n');
body.push_str("data: ");
body.push_str(&data.to_string());
body.push_str("\n\n");
}
fn usage_token(usage: &Value, keys: &[&str]) -> u64 {
keys.iter()
.find_map(|key| usage.get(*key).and_then(|value| value.as_u64()))
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
fn credential() -> OpenAiProxyCredential {
OpenAiProxyCredential {
api_key: "sk-test".to_string(),
base_url: "https://api.example.com/v1".to_string(),
model: "qwen-test".to_string(),
transformers: Vec::new(),
model_transformers: Vec::new(),
}
}
#[test]
fn renders_provider_stream_errors_as_anthropic_sse_errors() {
let rendered = anthropic_stream_error_sse("provider stream broke");
assert!(rendered.contains("event: error"));
assert!(rendered.contains("\"type\":\"error\""));
assert!(rendered.contains("\"type\":\"api_error\""));
assert!(rendered.contains("\"message\":\"provider stream broke\""));
}
#[test]
fn streams_openai_text_delta_as_anthropic_sse() {
let request = json!({ "model": "claude-sonnet-4" });
let mut state = OpenAiStreamState::default();
let chunk = json!({
"id": "chatcmpl_1",
"model": "qwen-test",
"choices": [{
"delta": { "content": "Hello" },
"finish_reason": null
}]
});
let first = openai_stream_chunk_to_anthropic(&mut state, &chunk, &request, &credential());
let done = finish_anthropic_stream(&mut state);
let combined = format!("{}{}", first, done);
assert!(combined.contains("event: message_start"));
assert!(combined.contains("\"model\":\"claude-sonnet-4\""));
assert!(combined.contains("\"type\":\"text_delta\""));
assert!(combined.contains("\"text\":\"Hello\""));
assert!(combined.contains("\"stop_reason\":\"end_turn\""));
assert!(finish_anthropic_stream(&mut state).is_empty());
}
#[test]
fn streams_reasoning_content_as_thinking_delta() {
let request = json!({ "model": "claude-sonnet-4" });
let mut state = OpenAiStreamState::default();
let chunk = json!({
"id": "chatcmpl_1",
"choices": [{
"delta": { "reasoning_content": "I should inspect files." },
"finish_reason": null
}]
});
let rendered =
openai_stream_chunk_to_anthropic(&mut state, &chunk, &request, &credential());
assert!(rendered.contains("\"type\":\"thinking\""));
assert!(rendered.contains("\"type\":\"thinking_delta\""));
assert!(rendered.contains("\"thinking\":\"I should inspect files.\""));
let done = finish_anthropic_stream(&mut state);
assert!(done.contains("\"type\":\"signature_delta\""));
}
#[test]
fn buffers_and_repairs_streamed_tool_arguments() {
let request = json!({ "model": "claude-sonnet-4" });
let mut state = OpenAiStreamState::default();
let first_chunk = json!({
"id": "chatcmpl_1",
"choices": [{
"delta": {
"tool_calls": [{
"index": 0,
"id": "call_1",
"type": "function",
"function": {
"name": "Read",
"arguments": "{\"file_path\":"
}
}]
},
"finish_reason": null
}]
});
let second_chunk = json!({
"id": "chatcmpl_1",
"choices": [{
"delta": {
"tool_calls": [{
"index": 0,
"function": { "arguments": "\"main.tex\"" }
}]
},
"finish_reason": "tool_calls"
}]
});
let first =
openai_stream_chunk_to_anthropic(&mut state, &first_chunk, &request, &credential());
let second =
openai_stream_chunk_to_anthropic(&mut state, &second_chunk, &request, &credential());
let done = finish_anthropic_stream(&mut state);
let combined = format!("{}{}{}", first, second, done);
assert!(!first.contains("\"type\":\"tool_use\""));
assert!(!second.contains("\"type\":\"tool_use\""));
assert!(combined.contains("\"type\":\"tool_use\""));
assert!(combined.contains("\"id\":\"call_1\""));
assert!(combined.contains("\"name\":\"Read\""));
assert!(combined.contains("\"type\":\"input_json_delta\""));
assert!(combined.contains("{\\\"file_path\\\":\\\"main.tex\\\"}"));
assert!(combined.contains("\"stop_reason\":\"tool_use\""));
}
#[test]
fn normalizes_numeric_streamed_tool_call_ids() {
let request = json!({ "model": "claude-sonnet-4" });
let mut state = OpenAiStreamState::default();
let chunk = json!({
"id": "chatcmpl_1",
"choices": [{
"delta": {
"tool_calls": [{
"index": 0,
"id": "123",
"type": "function",
"function": {
"name": "Read",
"arguments": "{\"file_path\":\"main.tex\"}"
}
}]
},
"finish_reason": "tool_calls"
}]
});
openai_stream_chunk_to_anthropic(&mut state, &chunk, &request, &credential());
let done = finish_anthropic_stream(&mut state);
assert!(done.contains("\"id\":\"call_"));
assert!(!done.contains("\"id\":\"123\""));
}
#[test]
fn streams_exit_tool_as_final_text() {
let request = json!({ "model": "claude-sonnet-4" });
let mut state = OpenAiStreamState::default();
let chunk = json!({
"id": "chatcmpl_1",
"choices": [{
"delta": {
"tool_calls": [{
"index": 0,
"id": "call_exit",
"type": "function",
"function": {
"name": "ExitTool",
"arguments": "{\"response\":\"all done\"}"
}
}]
},
"finish_reason": "tool_calls"
}]
});
openai_stream_chunk_to_anthropic(&mut state, &chunk, &request, &credential());
let done = finish_anthropic_stream(&mut state);
assert!(done.contains("\"type\":\"text_delta\""));
assert!(done.contains("\"text\":\"all done\""));
assert!(done.contains("\"stop_reason\":\"end_turn\""));
assert!(!done.contains("\"type\":\"tool_use\""));
}
}

View file

@ -1,561 +0,0 @@
use serde_json::{json, Value};
pub(super) fn repaired_tool_arguments_value(arguments: &str) -> Value {
serde_json::from_str::<Value>(&repair_tool_arguments(arguments)).unwrap_or_else(|_| json!({}))
}
pub(super) fn normalized_tool_call_id(id: Option<&str>) -> String {
let id = id.unwrap_or_default().trim();
if id.is_empty() || id.chars().all(|ch| ch.is_ascii_digit()) {
format!("call_{}", uuid::Uuid::new_v4().simple())
} else {
id.to_string()
}
}
pub(super) fn repair_tool_arguments(arguments: &str) -> String {
let trimmed = trim_code_fence(arguments.trim());
if trimmed.is_empty() || trimmed == "{}" {
return "{}".to_string();
}
let mut candidates = Vec::new();
push_candidate(&mut candidates, trimmed.to_string());
if let Some(extracted) = extract_json_like(trimmed) {
push_candidate(&mut candidates, extracted);
}
let seeds = candidates.clone();
for candidate in seeds {
let without_comments = strip_json_comments(&candidate);
push_candidate(&mut candidates, without_comments.clone());
let without_trailing_commas = remove_trailing_commas(&without_comments);
push_candidate(&mut candidates, without_trailing_commas.clone());
let json5_like =
normalize_single_quoted_strings(&quote_unquoted_object_keys(&without_trailing_commas));
push_candidate(&mut candidates, json5_like.clone());
if let Some(with_commas) = insert_missing_commas_between_fields(&json5_like) {
push_candidate(&mut candidates, with_commas.clone());
if let Some(balanced) = repair_balanced_json(with_commas) {
push_candidate(&mut candidates, balanced);
}
}
if let Some(balanced) = repair_balanced_json(json5_like) {
push_candidate(&mut candidates, balanced);
}
if let Some(balanced) = repair_balanced_json(without_trailing_commas) {
push_candidate(&mut candidates, balanced);
}
}
for candidate in candidates {
if let Some(repaired) = parse_tool_arguments_candidate(&candidate) {
return repaired;
}
}
"{}".to_string()
}
fn push_candidate(candidates: &mut Vec<String>, value: String) {
let value = value.trim().to_string();
if value.is_empty() || candidates.iter().any(|candidate| candidate == &value) {
return;
}
candidates.push(value);
}
fn parse_tool_arguments_candidate(value: &str) -> Option<String> {
serde_json::from_str::<Value>(value)
.ok()
.or_else(|| serde_yaml::from_str::<Value>(value).ok())
.and_then(canonical_tool_arguments)
}
fn canonical_tool_arguments(value: Value) -> Option<String> {
match value {
Value::Object(map) => {
if map.keys().any(|key| key.contains(':')) {
return None;
}
Some(Value::Object(map).to_string())
}
Value::Array(_) => Some(value.to_string()),
_ => None,
}
}
fn trim_code_fence(value: &str) -> &str {
let value = value.trim();
if !value.starts_with("```") {
return value;
}
let Some(first_newline) = value.find('\n') else {
return value;
};
let value = &value[first_newline + 1..];
value
.strip_suffix("```")
.map(str::trim)
.unwrap_or(value.trim())
}
fn extract_json_like(value: &str) -> Option<String> {
let object_start = value.find('{');
let array_start = value.find('[');
let start = match (object_start, array_start) {
(Some(object), Some(array)) => object.min(array),
(Some(object), None) => object,
(None, Some(array)) => array,
(None, None) => return None,
};
let end = value.rfind('}').or_else(|| value.rfind(']'))?;
if end <= start {
return None;
}
Some(value[start..=end].to_string())
}
fn repair_balanced_json(value: String) -> Option<String> {
let mut output = String::with_capacity(value.len() + 8);
let mut stack = Vec::new();
let mut in_string = false;
let mut escaped = false;
for ch in value.chars() {
output.push(ch);
if in_string {
if escaped {
escaped = false;
} else if ch == '\\' {
escaped = true;
} else if ch == '"' {
in_string = false;
}
continue;
}
match ch {
'"' => in_string = true,
'{' => stack.push('}'),
'[' => stack.push(']'),
'}' | ']' => {
if stack.last().copied() == Some(ch) {
stack.pop();
}
}
_ => {}
}
}
if in_string {
output.push('"');
}
while let Some(ch) = stack.pop() {
output.push(ch);
}
Some(output)
}
fn remove_trailing_commas(value: &str) -> String {
let mut output = String::with_capacity(value.len());
let mut chars = value.chars().peekable();
let mut in_string = false;
let mut escaped = false;
while let Some(ch) = chars.next() {
if in_string {
output.push(ch);
if escaped {
escaped = false;
} else if ch == '\\' {
escaped = true;
} else if ch == '"' {
in_string = false;
}
continue;
}
if ch == '"' {
in_string = true;
output.push(ch);
continue;
}
if ch == ',' {
let mut lookahead = chars.clone();
while matches!(lookahead.peek(), Some(next) if next.is_whitespace()) {
lookahead.next();
}
if matches!(lookahead.peek(), Some('}' | ']')) {
continue;
}
}
output.push(ch);
}
output
}
fn strip_json_comments(value: &str) -> String {
let mut output = String::with_capacity(value.len());
let mut chars = value.chars().peekable();
let mut in_double_string = false;
let mut in_single_string = false;
let mut escaped = false;
while let Some(ch) = chars.next() {
if in_double_string || in_single_string {
output.push(ch);
if escaped {
escaped = false;
} else if ch == '\\' {
escaped = true;
} else if in_double_string && ch == '"' {
in_double_string = false;
} else if in_single_string && ch == '\'' {
in_single_string = false;
}
continue;
}
match ch {
'"' => {
in_double_string = true;
output.push(ch);
}
'\'' => {
in_single_string = true;
output.push(ch);
}
'/' if chars.peek() == Some(&'/') => {
chars.next();
for next in chars.by_ref() {
if next == '\n' {
output.push('\n');
break;
}
}
}
'/' if chars.peek() == Some(&'*') => {
chars.next();
let mut previous = '\0';
for next in chars.by_ref() {
if previous == '*' && next == '/' {
break;
}
previous = next;
}
}
_ => output.push(ch),
}
}
output
}
fn quote_unquoted_object_keys(value: &str) -> String {
let mut output = String::with_capacity(value.len() + 16);
let chars = value.chars().collect::<Vec<_>>();
let mut index = 0;
let mut in_double_string = false;
let mut in_single_string = false;
let mut escaped = false;
let mut expects_key = false;
while index < chars.len() {
let ch = chars[index];
if in_double_string || in_single_string {
output.push(ch);
if escaped {
escaped = false;
} else if ch == '\\' {
escaped = true;
} else if in_double_string && ch == '"' {
in_double_string = false;
} else if in_single_string && ch == '\'' {
in_single_string = false;
}
index += 1;
continue;
}
match ch {
'"' => {
in_double_string = true;
output.push(ch);
expects_key = false;
index += 1;
}
'\'' => {
in_single_string = true;
output.push(ch);
expects_key = false;
index += 1;
}
'{' | ',' => {
expects_key = true;
output.push(ch);
index += 1;
}
'}' | ']' => {
expects_key = false;
output.push(ch);
index += 1;
}
ch if expects_key && ch.is_whitespace() => {
output.push(ch);
index += 1;
}
ch if expects_key && is_identifier_start(ch) => {
let start = index;
index += 1;
while index < chars.len() && is_identifier_continue(chars[index]) {
index += 1;
}
let mut lookahead = index;
while lookahead < chars.len() && chars[lookahead].is_whitespace() {
lookahead += 1;
}
if lookahead < chars.len() && chars[lookahead] == ':' {
output.push('"');
for key_ch in &chars[start..index] {
output.push(*key_ch);
}
output.push('"');
expects_key = false;
} else {
for key_ch in &chars[start..index] {
output.push(*key_ch);
}
expects_key = false;
}
}
_ => {
output.push(ch);
index += 1;
}
}
}
output
}
fn normalize_single_quoted_strings(value: &str) -> String {
let mut output = String::with_capacity(value.len());
let mut chars = value.chars().peekable();
let mut in_double_string = false;
let mut in_single_string = false;
let mut escaped = false;
while let Some(ch) = chars.next() {
if in_double_string {
output.push(ch);
if escaped {
escaped = false;
} else if ch == '\\' {
escaped = true;
} else if ch == '"' {
in_double_string = false;
}
continue;
}
if in_single_string {
if escaped {
match ch {
'\'' => output.push('\''),
'"' => {
output.push('\\');
output.push('"');
}
'\\' => output.push('\\'),
_ => {
output.push('\\');
output.push(ch);
}
}
escaped = false;
} else if ch == '\\' {
escaped = true;
} else if ch == '\'' {
output.push('"');
in_single_string = false;
} else if ch == '"' {
output.push('\\');
output.push('"');
} else {
output.push(ch);
}
continue;
}
if ch == '"' {
in_double_string = true;
output.push(ch);
} else if ch == '\'' {
in_single_string = true;
output.push('"');
} else {
output.push(ch);
}
}
if in_single_string {
output.push('"');
}
output
}
fn insert_missing_commas_between_fields(value: &str) -> Option<String> {
let mut output = String::with_capacity(value.len() + 8);
let chars = value.chars().collect::<Vec<_>>();
let mut index = 0;
let mut changed = false;
let mut in_string = false;
let mut escaped = false;
while index < chars.len() {
let ch = chars[index];
output.push(ch);
if in_string {
if escaped {
escaped = false;
} else if ch == '\\' {
escaped = true;
} else if ch == '"' {
in_string = false;
}
index += 1;
continue;
}
if ch == '"' {
in_string = true;
index += 1;
continue;
}
if matches!(ch, '"' | '}' | ']' | '0'..='9' | 'e' | 'E' | 'l') {
let mut lookahead = index + 1;
while lookahead < chars.len() && chars[lookahead].is_whitespace() {
lookahead += 1;
}
if lookahead < chars.len()
&& chars[lookahead] == '"'
&& previous_non_whitespace(&chars, index) != Some(':')
{
output.push(',');
changed = true;
}
}
index += 1;
}
changed.then_some(output)
}
fn previous_non_whitespace(chars: &[char], index: usize) -> Option<char> {
if index == 0 {
return None;
}
let mut cursor = index - 1;
loop {
if !chars[cursor].is_whitespace() {
return Some(chars[cursor]);
}
if cursor == 0 {
return None;
}
cursor -= 1;
}
}
fn is_identifier_start(ch: char) -> bool {
ch == '_' || ch == '$' || ch.is_ascii_alphabetic()
}
fn is_identifier_continue(ch: char) -> bool {
is_identifier_start(ch) || ch.is_ascii_digit() || ch == '-' || ch == '.'
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn repairs_partial_tool_arguments() {
assert_eq!(
repair_tool_arguments("{\"file_path\":\"main.tex\""),
"{\"file_path\":\"main.tex\"}"
);
}
#[test]
fn repairs_fenced_tool_arguments() {
assert_eq!(
repair_tool_arguments("```json\n{\"pattern\":\"FastVID\",}\n```"),
"{\"pattern\":\"FastVID\"}"
);
}
#[test]
fn repairs_json5_style_tool_arguments_like_ccr_enhancetool() {
let repaired: Value = serde_json::from_str(&repair_tool_arguments(
"{file_path:'main.tex', replace_all:false,}",
))
.unwrap();
assert_eq!(
repaired,
json!({ "file_path": "main.tex", "replace_all": false })
);
}
#[test]
fn repairs_commented_tool_arguments() {
let repaired: Value = serde_json::from_str(&repair_tool_arguments(
"{\n // target file\n file_path: 'main.tex',\n old_string: 'A',\n new_string: 'B',\n}",
))
.unwrap();
assert_eq!(
repaired,
json!({ "file_path": "main.tex", "old_string": "A", "new_string": "B" })
);
}
#[test]
fn repairs_mixed_quote_tool_arguments() {
let repaired: Value = serde_json::from_str(&repair_tool_arguments(
"{\"file_path\": 'main.tex', \"pattern\": 'FastVID'}",
))
.unwrap();
assert_eq!(
repaired,
json!({ "file_path": "main.tex", "pattern": "FastVID" })
);
}
#[test]
fn falls_back_to_empty_object_for_unrepairable_arguments() {
assert_eq!(repair_tool_arguments("not json at all"), "{}");
}
#[test]
fn normalizes_numeric_tool_call_ids() {
let id = normalized_tool_call_id(Some("123"));
assert!(id.starts_with("call_"));
assert_ne!(id, "123");
}
#[test]
fn preserves_provider_tool_call_ids() {
assert_eq!(normalized_tool_call_id(Some("call_abc")), "call_abc");
}
}

View file

@ -1,148 +0,0 @@
use super::OpenAiProxyCredential;
const CLEANCACHE: &str = "cleancache";
const DEEPSEEK: &str = "deepseek";
const ENHANCETOOL: &str = "enhancetool";
const STREAMOPTIONS: &str = "streamoptions";
const TOOLUSE: &str = "tooluse";
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub(super) struct ProxyTransformerChain {
names: Vec<String>,
}
impl ProxyTransformerChain {
pub(super) fn for_credential(credential: &OpenAiProxyCredential, wants_stream: bool) -> Self {
let mut chain = Self::default();
chain.push(CLEANCACHE);
if wants_stream {
chain.push(STREAMOPTIONS);
}
if is_deepseek_credential(credential) {
chain.push(DEEPSEEK);
}
// ClaudePrism already buffers and repairs tool-call arguments before
// returning them to Claude Code. Naming it here keeps the behavior
// traceable to Claude Code Router's enhancetool transformer.
chain.push(ENHANCETOOL);
for name in &credential.transformers {
chain.push(name);
}
for name in &credential.model_transformers {
chain.push(name);
}
for name in configured_transformer_names() {
chain.push(&name);
}
chain
}
pub(super) fn has(&self, name: &str) -> bool {
self.names
.iter()
.any(|candidate| candidate.eq_ignore_ascii_case(name))
}
pub(super) fn has_tooluse(&self) -> bool {
self.has(TOOLUSE)
}
pub(super) fn has_cleancache(&self) -> bool {
self.has(CLEANCACHE)
}
pub(super) fn has_deepseek(&self) -> bool {
self.has(DEEPSEEK)
}
pub(super) fn has_streamoptions(&self) -> bool {
self.has(STREAMOPTIONS)
}
#[cfg(test)]
pub(super) fn from_names(names: &[&str]) -> Self {
let mut chain = Self::default();
for name in names {
chain.push(name);
}
chain
}
fn push(&mut self, name: &str) {
let name = name.trim();
if name.is_empty() || self.has(name) {
return;
}
self.names.push(name.to_ascii_lowercase());
}
}
fn configured_transformer_names() -> Vec<String> {
std::env::var("CLAUDE_PRISM_PROXY_TRANSFORMERS")
.ok()
.into_iter()
.flat_map(|value| {
value
.split(',')
.map(str::trim)
.filter(|name| !name.is_empty())
.map(str::to_string)
.collect::<Vec<_>>()
})
.collect()
}
fn is_deepseek_credential(credential: &OpenAiProxyCredential) -> bool {
let base_url = credential.base_url.to_ascii_lowercase();
let model = credential.model.to_ascii_lowercase();
base_url.contains("deepseek") || model.contains("deepseek")
}
#[cfg(test)]
mod tests {
use super::*;
fn credential(base_url: &str, model: &str) -> OpenAiProxyCredential {
OpenAiProxyCredential {
api_key: "sk-test".to_string(),
base_url: base_url.to_string(),
model: model.to_string(),
transformers: Vec::new(),
model_transformers: Vec::new(),
}
}
#[test]
fn includes_ccr_style_defaults_for_common_provider_adapters() {
let chain = ProxyTransformerChain::for_credential(
&credential("https://api.deepseek.com", "deepseek-chat"),
true,
);
assert!(chain.has_cleancache());
assert!(chain.has_streamoptions());
assert!(chain.has_deepseek());
assert!(chain.has(ENHANCETOOL));
}
#[test]
fn does_not_enable_tooluse_unless_configured() {
let chain = ProxyTransformerChain::for_credential(
&credential("https://api.example.com/v1", "qwen"),
false,
);
assert!(!chain.has_tooluse());
}
#[test]
fn accepts_explicit_model_transformers() {
let mut credential = credential("https://api.example.com/v1", "qwen");
credential.model_transformers = vec!["tooluse".to_string()];
let chain = ProxyTransformerChain::for_credential(&credential, false);
assert!(chain.has_tooluse());
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,368 +0,0 @@
use std::collections::HashMap;
use std::sync::Arc;
use tauri::{Emitter, Manager, WebviewWindow};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::process::{Child, Command};
use tokio::sync::Mutex;
#[cfg(windows)]
const CREATE_NO_WINDOW: u32 = 0x08000000;
#[derive(Clone)]
pub struct ClaudeProcessState {
pub processes: Arc<Mutex<HashMap<String, Child>>>,
}
impl Default for ClaudeProcessState {
fn default() -> Self {
Self {
processes: Arc::new(Mutex::new(HashMap::new())),
}
}
}
#[derive(Clone, serde::Serialize)]
struct ClaudeOutputEvent {
tab_id: String,
data: String,
}
#[derive(Clone, serde::Serialize)]
struct ClaudeCompleteEvent {
tab_id: String,
success: bool,
}
#[derive(Clone, serde::Serialize)]
struct ClaudeErrorEvent {
tab_id: String,
data: String,
}
#[derive(Clone)]
pub struct SpawnProviderMetadata {
pub provider: &'static str,
pub provider_credential_id: String,
pub model: String,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ClaudeStopMode {
/// User pressed Stop; terminate the run immediately.
Terminate,
/// User wants to guide the next turn; prefer a graceful interrupt so
/// Claude Code can persist session state before the frontend resumes it.
Interrupt,
}
fn process_key(window_label: &str, tab_id: &str) -> String {
format!("{}:{}", window_label, tab_id)
}
/// Spawn the Claude CLI process and stream output via Tauri events.
/// Events are emitted only to the originating window, tagged with tab_id.
pub async fn spawn_claude_process(
window: WebviewWindow,
mut cmd: Command,
tab_id: String,
stdin_payload: Option<String>,
provider_metadata: Option<SpawnProviderMetadata>,
) -> Result<(), String> {
let window_label = window.label().to_string();
let process_key = process_key(&window_label, &tab_id);
if stdin_payload.is_some() {
cmd.stdin(std::process::Stdio::piped());
}
let mut child = cmd.spawn().map_err(|e| {
eprintln!(
"[claude-spawn] Failed to spawn process for tab {}: {}",
tab_id, e
);
format!(
"Failed to spawn Claude process: {}. Is Claude Code CLI installed?",
e
)
})?;
if let Some(payload) = stdin_payload {
let mut stdin = child
.stdin
.take()
.ok_or_else(|| "Failed to acquire stdin for Claude process".to_string())?;
stdin
.write_all(payload.as_bytes())
.await
.map_err(|e| format!("Failed to write prompt to Claude process stdin: {}", e))?;
stdin
.shutdown()
.await
.map_err(|e| format!("Failed to close Claude process stdin: {}", e))?;
}
let stdout = child.stdout.take().ok_or("Failed to capture stdout")?;
let stderr = child.stderr.take().ok_or("Failed to capture stderr")?;
let process_arc = window
.state::<ClaudeProcessState>()
.inner()
.processes
.clone();
{
let mut processes = process_arc.lock().await;
if let Some(mut existing) = processes.remove(&process_key) {
let _ = existing.kill().await;
}
processes.insert(process_key.clone(), child);
}
let stdout_reader = BufReader::new(stdout);
let stderr_reader = BufReader::new(stderr);
let result_success_holder: Arc<std::sync::Mutex<Option<bool>>> =
Arc::new(std::sync::Mutex::new(None));
let start_time = std::time::Instant::now();
let win_stdout = window.clone();
let result_success_stdout = result_success_holder.clone();
let tab_id_stdout = tab_id.clone();
let provider_metadata_stdout = provider_metadata.clone();
let stdout_task = tokio::spawn(async move {
let mut lines = stdout_reader.lines();
let mut line_count: u64 = 0;
while let Ok(Some(mut line)) = lines.next_line().await {
line_count += 1;
let elapsed = start_time.elapsed().as_secs_f64();
if let Ok(mut msg) = serde_json::from_str::<serde_json::Value>(&line) {
let msg_type = msg.get("type").and_then(|v| v.as_str()).unwrap_or("?");
let msg_sub = msg.get("subtype").and_then(|v| v.as_str()).unwrap_or("");
eprintln!(
"[claude-stdout] [{}] +{:.1}s #{} type={} sub={} len={}",
tab_id_stdout,
elapsed,
line_count,
msg_type,
msg_sub,
line.len()
);
if msg.get("type").and_then(|v| v.as_str()) == Some("system")
&& msg.get("subtype").and_then(|v| v.as_str()) == Some("init")
{
if let Some(metadata) = provider_metadata_stdout.as_ref() {
if let Some(object) = msg.as_object_mut() {
object.insert(
"provider".to_string(),
serde_json::Value::String(metadata.provider.to_string()),
);
object.insert(
"provider_credential_id".to_string(),
serde_json::Value::String(metadata.provider_credential_id.clone()),
);
object.insert(
"model".to_string(),
serde_json::Value::String(metadata.model.clone()),
);
}
line = msg.to_string();
}
}
if msg.get("type").and_then(|v| v.as_str()) == Some("result") {
let is_success = msg.get("subtype").and_then(|v| v.as_str()) == Some("success");
if let Ok(mut guard) = result_success_stdout.lock() {
*guard = Some(is_success);
}
}
}
let _ = win_stdout.emit(
"claude-output",
ClaudeOutputEvent {
tab_id: tab_id_stdout.clone(),
data: line,
},
);
}
eprintln!(
"[claude-stdout] [{}] stream ended after {} lines ({:.1}s)",
tab_id_stdout,
line_count,
start_time.elapsed().as_secs_f64()
);
});
let win_stderr = window.clone();
let tab_id_stderr = tab_id.clone();
let stderr_task = tokio::spawn(async move {
let mut lines = stderr_reader.lines();
while let Ok(Some(line)) = lines.next_line().await {
eprintln!(
"[claude-stderr] [{}] +{:.1}s {}",
tab_id_stderr,
start_time.elapsed().as_secs_f64(),
&line[..line.len().min(200)]
);
let _ = win_stderr.emit(
"claude-error",
ClaudeErrorEvent {
tab_id: tab_id_stderr.clone(),
data: line,
},
);
}
});
let process_arc_wait = process_arc.clone();
let win_wait = window;
let process_key_wait = process_key;
let tab_id_wait = tab_id;
let result_success_wait = result_success_holder.clone();
tokio::spawn(async move {
let _ = stdout_task.await;
let _ = stderr_task.await;
let mut processes = process_arc_wait.lock().await;
let success = if let Some(mut child) = processes.remove(&process_key_wait) {
match child.wait().await {
Ok(status) => {
let exit_success = status.success();
let result_success = result_success_wait.lock().ok().and_then(|guard| *guard);
let success = exit_success || result_success == Some(true);
eprintln!(
"[claude-process] [{}] exited with status={} result_success={:?} final_success={} ({:.1}s)",
tab_id_wait,
status,
result_success,
success,
start_time.elapsed().as_secs_f64()
);
success
}
Err(e) => {
eprintln!(
"[claude-process] [{}] wait error: {} ({:.1}s)",
tab_id_wait,
e,
start_time.elapsed().as_secs_f64()
);
false
}
}
} else {
eprintln!(
"[claude-process] [{}] no child found in map ({:.1}s)",
tab_id_wait,
start_time.elapsed().as_secs_f64()
);
false
};
drop(processes);
let _ = win_wait.emit(
"claude-complete",
ClaudeCompleteEvent {
tab_id: tab_id_wait,
success,
},
);
});
Ok(())
}
pub async fn stop_claude_process(
window: WebviewWindow,
tab_id: String,
mode: ClaudeStopMode,
) -> Result<bool, String> {
let window_label = window.label().to_string();
let process_key = process_key(&window_label, &tab_id);
let claude_state = window.state::<ClaudeProcessState>();
let mut processes = claude_state.processes.lock().await;
if let Some(mut child) = processes.remove(&process_key) {
drop(processes);
let stopped = match mode {
ClaudeStopMode::Terminate => {
terminate_process_tree(&mut child).await;
true
}
ClaudeStopMode::Interrupt => interrupt_or_terminate(&mut child).await,
};
return Ok(stopped);
}
drop(processes);
let _ = window.emit(
"claude-complete",
ClaudeCompleteEvent {
tab_id,
success: false,
},
);
Ok(false)
}
#[cfg(unix)]
async fn interrupt_or_terminate(child: &mut Child) -> bool {
if let Some(pid) = child.id() {
let status = tokio::process::Command::new("kill")
.arg("-INT")
.arg(pid.to_string())
.status()
.await;
if matches!(status, Ok(status) if status.success()) {
return true;
}
}
terminate_process_tree(child).await;
true
}
#[cfg(not(unix))]
async fn interrupt_or_terminate(child: &mut Child) -> bool {
// Windows GUI processes do not have a reliable console-control path from
// Tauri without a PTY/ConPTY session. For guided follow-ups, fall back to
// terminating the current run so the frontend can immediately continue the
// same tab with the queued guidance.
terminate_process_tree(child).await;
true
}
#[cfg(windows)]
async fn terminate_process_tree(child: &mut Child) {
if let Some(pid) = child.id() {
let _ = Command::new("taskkill")
.creation_flags(CREATE_NO_WINDOW)
.args(["/PID", &pid.to_string(), "/T", "/F"])
.status()
.await;
}
let _ = child.start_kill();
}
#[cfg(not(windows))]
async fn terminate_process_tree(child: &mut Child) {
let _ = child.start_kill();
}
/// Kill all Claude processes associated with a specific window label.
/// Called when a window is destroyed.
pub async fn kill_process_for_window(state: &ClaudeProcessState, window_label: &str) {
let mut processes = state.processes.lock().await;
let prefix = format!("{}:", window_label);
let keys_to_remove: Vec<String> = processes
.keys()
.filter(|k| k.starts_with(&prefix))
.cloned()
.collect();
for key in keys_to_remove {
if let Some(mut child) = processes.remove(&key) {
let _ = child.kill().await;
}
}
}

View file

@ -6,14 +6,6 @@ use tokio::sync::{Mutex, Semaphore};
const MAX_CONCURRENT: usize = 3;
/// Windows CREATE_NO_WINDOW flag to prevent console windows from flashing
/// when spawning TeXLive/Tectonic child processes from the GUI app.
#[cfg(target_os = "windows")]
const CREATE_NO_WINDOW: u32 = 0x08000000;
#[cfg(target_os = "windows")]
use std::os::windows::process::CommandExt;
struct BuildInfo {
work_dir: PathBuf,
main_file_name: String,
@ -51,34 +43,18 @@ fn extract_error_lines(log: &str) -> String {
return String::new();
}
let lines: Vec<&str> = log.lines().collect();
// Extract real errors first — they take priority over "No pages of output"
let error_lines: Vec<&str> = log
.lines()
.filter(|l| l.starts_with('!') || l.contains("Error:") || l.contains("error:"))
.take(10)
.collect();
let mut blocks: Vec<String> = Vec::new();
let mut i = 0;
while i < lines.len() && blocks.len() < 5 {
let line = lines[i];
let is_error_start =
line.starts_with('!') || line.contains("Error:") || line.contains("error:");
if is_error_start {
let end = (i + 14).min(lines.len());
blocks.push(lines[i..end].join("\n"));
i = end;
continue;
}
i += 1;
if !error_lines.is_empty() {
return error_lines.join("\n");
}
if !blocks.is_empty() {
let mut result = blocks.join("\n\n");
result.push_str("\n\n---- Engine output ----\n");
let tail_start = lines.len().saturating_sub(20);
result.push_str(&lines[tail_start..].join("\n"));
return result;
}
if lines.iter().any(|l| l.contains("No pages of output")) {
if log.lines().any(|l| l.contains("No pages of output")) {
return "No pages of output. Add visible content to the document body.".to_string();
}
@ -126,102 +102,6 @@ fn detect_tex_engine(content: &str) -> Option<TexEngine> {
None
}
#[derive(Debug, PartialEq)]
enum BibTool {
Biber,
BibTeX,
None,
}
/// Detect which bibliography tool is needed by scanning .tex content.
fn detect_bib_tool(content: &str) -> BibTool {
for line in content.lines() {
let trimmed = line.trim();
if trimmed.starts_with('%') {
continue;
}
if trimmed.contains("\\usepackage") && trimmed.contains("biblatex") {
return BibTool::Biber;
}
}
for line in content.lines() {
let trimmed = line.trim();
if trimmed.starts_with('%') {
continue;
}
if trimmed.contains("\\bibliography{") || trimmed.contains("\\addbibresource{") {
return BibTool::BibTeX;
}
}
BibTool::None
}
/// Resolve a TeXLive engine binary to its full path.
/// GUI apps on macOS lack the user's shell PATH, so we check standard
/// TeXLive installation locations and fall back to a login-shell query.
fn find_texlive_binary(name: &str) -> Result<PathBuf, String> {
// 1. Try PATH (works when launched from terminal)
if let Ok(path) = which::which(name) {
return Ok(path);
}
// 2. Check standard TeXLive locations
#[cfg(not(target_os = "windows"))]
{
let standard_paths = [
format!("/Library/TeX/texbin/{}", name),
format!("/usr/local/texlive/2025/bin/universal-darwin/{}", name),
format!("/usr/local/texlive/2024/bin/universal-darwin/{}", name),
format!("/usr/local/texlive/2025/bin/x86_64-linux/{}", name),
format!("/usr/local/texlive/2024/bin/x86_64-linux/{}", name),
format!("/opt/homebrew/bin/{}", name),
format!("/usr/bin/{}", name),
];
for path_str in &standard_paths {
let p = PathBuf::from(path_str);
if p.exists() {
return Ok(p);
}
}
}
#[cfg(target_os = "windows")]
{
let standard_paths = [
format!("C:\\texlive\\2025\\bin\\windows\\{}.exe", name),
format!("C:\\texlive\\2024\\bin\\windows\\{}.exe", name),
];
for path_str in &standard_paths {
let p = PathBuf::from(path_str);
if p.exists() {
return Ok(p);
}
}
}
// 3. macOS: ask login shell for PATH
#[cfg(target_os = "macos")]
{
if let Ok(output) = std::process::Command::new("/bin/zsh")
.args(["-l", "-c", &format!("which {}", name)])
.output()
{
if output.status.success() {
let resolved = String::from_utf8_lossy(&output.stdout).trim().to_string();
let p = PathBuf::from(&resolved);
if p.exists() {
return Ok(p);
}
}
}
}
Err(format!(
"{} not found. Install TeXLive or add it to your PATH.",
name
))
}
fn copy_dir_recursive(src: &Path, dst: &Path) -> std::io::Result<()> {
if !dst.exists() {
std::fs::create_dir_all(dst)?;
@ -254,12 +134,10 @@ fn sync_source_files(src: &Path, dst: &Path) -> std::io::Result<()> {
for entry in std::fs::read_dir(src)? {
let entry = entry?;
let src_path = entry.path();
let file_name = entry.file_name();
let dst_path = dst.join(&file_name);
let dst_path = dst.join(entry.file_name());
if src_path.is_dir() {
let name = file_name.to_string_lossy();
if name.starts_with('.') || matches!(name.as_ref(), "node_modules" | "target" | "dist")
{
let name = entry.file_name();
if name.to_string_lossy().starts_with('.') {
continue;
}
sync_source_files(&src_path, &dst_path)?;
@ -295,21 +173,6 @@ fn sync_source_files(src: &Path, dst: &Path) -> std::io::Result<()> {
// Cloud storage (Dropbox/iCloud) may keep files as online-only
// placeholders with 0 bytes. Reading the file forces a download.
let metadata = std::fs::metadata(&src_path)?;
if metadata.len() > 0 {
if let Ok(dst_meta) = std::fs::metadata(&dst_path) {
if metadata.len() == dst_meta.len() {
if let (Ok(src_m), Ok(dst_m)) =
(metadata.modified(), dst_meta.modified())
{
if src_m == dst_m {
continue;
}
}
}
}
}
if metadata.len() == 0 {
// Attempt to materialize the file by reading it
let data = std::fs::read(&src_path)?;
@ -357,7 +220,7 @@ fn lower_thread_priority() {
// --- Tectonic Compilation ---
pub(crate) fn compile_with_tectonic(work_dir: &Path, main_file: &str) -> Result<(), String> {
fn compile_with_tectonic(work_dir: &Path, main_file: &str) -> Result<(), String> {
use tectonic::config::PersistentConfig;
use tectonic::driver::{OutputFormat, PassSetting, ProcessingSessionBuilder};
use tectonic::status::NoopStatusBackend;
@ -402,225 +265,6 @@ pub(crate) fn compile_with_tectonic(work_dir: &Path, main_file: &str) -> Result<
Ok(())
}
/// Run tectonic compilation in an isolated subprocess.
///
/// This avoids the font cache assertion failure (`font_cache.fonts == NULL`)
/// that occurs when tectonic is called multiple times in the same process.
/// The C-level static `font_cache` in `dpx-pdffont.c` is not cleaned up
/// on compilation failure, causing subsequent calls to abort.
///
/// By spawning a subprocess, each compilation gets a fresh process with
/// clean global state, and cleanup happens automatically on process exit.
fn compile_with_tectonic_subprocess(work_dir: &Path, main_file: &str) -> Result<(), String> {
let exe = std::env::current_exe()
.map_err(|e| format!("Failed to get current executable path: {}", e))?;
let mut cmd = std::process::Command::new(&exe);
cmd.args(["--tectonic-compile", &work_dir.to_string_lossy(), main_file])
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
#[cfg(target_os = "windows")]
cmd.creation_flags(CREATE_NO_WINDOW);
let output = cmd
.output()
.map_err(|e| format!("Failed to spawn tectonic subprocess: {}", e))?;
if output.status.success() {
Ok(())
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
Err(stderr.trim().to_string())
}
}
// --- TeXLive Compilation ---
/// Build a PATH that includes the TeXLive bin directory so that xelatex
/// can find xdvipdfmx, kpsewhich, and other tools it invokes internally.
/// GUI apps on macOS have a minimal PATH that doesn't include TeXLive.
fn texlive_env_path(engine: &Path) -> String {
let texbin = engine
.parent()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default();
let current_path = std::env::var("PATH").unwrap_or_default();
if current_path.contains(&texbin) {
current_path
} else {
#[cfg(target_os = "windows")]
{
format!("{};{}", texbin, current_path)
}
#[cfg(not(target_os = "windows"))]
{
format!("{}:{}", texbin, current_path)
}
}
}
/// Run a single TeX engine pass. Never returns `Err` for a non-zero exit
/// code — TeXLive returns non-zero for warnings, font substitutions, etc.
/// The only `Err` is when the process cannot be *spawned* at all.
/// The caller decides success by checking whether the PDF was produced.
fn run_texlive_pass(
engine: &Path,
args: &[&str],
main_file: &Path,
work_dir: &Path,
) -> Result<(), String> {
let mut cmd = std::process::Command::new(engine);
cmd.args(args)
.arg(main_file)
.current_dir(work_dir)
.env("PATH", texlive_env_path(engine))
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
#[cfg(target_os = "windows")]
cmd.creation_flags(CREATE_NO_WINDOW);
let output = cmd
.output()
.map_err(|e| format!("Failed to launch {}: {}", engine.display(), e))?;
// TeXLive returns non-zero on warnings too — don't fail here.
// The caller decides success by checking whether the PDF was produced.
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if !stderr.trim().is_empty() {
eprintln!("[texlive] engine stderr: {}", stderr.trim());
}
}
Ok(())
}
fn compile_with_texlive(
work_dir: &Path,
main_file: &str,
engine: Option<TexEngine>,
tex_content: &str,
) -> Result<(), String> {
let engine_name = match engine {
Some(TexEngine::XeLaTeX) | None => "xelatex",
Some(TexEngine::Latex) => "pdflatex",
Some(TexEngine::LuaLaTeX) => "lualatex",
};
let engine_path = find_texlive_binary(engine_name)?;
let env_path = texlive_env_path(&engine_path);
eprintln!(
"[texlive] backend: {} ({})",
engine_name,
engine_path.display()
);
let bib_tool = detect_bib_tool(tex_content);
// Use "." as output-directory since current_dir is already work_dir.
// Absolute paths break when they contain ~ (e.g. iCloud's com~apple~CloudDocs)
// because TeX interprets ~ as a home directory shortcut.
let output_dir_arg = "-output-directory=.".to_string();
// Do NOT use -halt-on-error: xelatex is a pipeline (xetex → .xdv → xdvipdfmx → .pdf).
// With -halt-on-error, recoverable warnings (e.g. missing font shapes) cause xetex to
// exit non-zero, and the xelatex wrapper skips the xdvipdfmx step — producing .xdv but
// no .pdf. -interaction=nonstopmode alone is sufficient to avoid interactive prompts.
let common_args: Vec<&str> = vec!["-synctex=1", "-interaction=nonstopmode", &output_dir_arg];
let main_file_path = Path::new(main_file);
// Pass 1
run_texlive_pass(&engine_path, &common_args, main_file_path, work_dir)?;
// Bib pass (if needed)
let main_stem = Path::new(main_file)
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("document");
match bib_tool {
BibTool::Biber => {
let biber_path = find_texlive_binary("biber")?;
let mut cmd = std::process::Command::new(&biber_path);
cmd.arg(main_stem)
.current_dir(work_dir)
.env("PATH", &env_path)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
#[cfg(target_os = "windows")]
cmd.creation_flags(CREATE_NO_WINDOW);
let output = cmd
.output()
.map_err(|e| format!("Failed to run biber: {}", e))?;
if !output.status.success() {
eprintln!(
"[texlive] biber warning: {}",
String::from_utf8_lossy(&output.stderr)
);
}
}
BibTool::BibTeX => {
let bibtex_path = find_texlive_binary("bibtex")?;
let aux_file = work_dir.join(format!("{}.aux", main_stem));
let mut cmd = std::process::Command::new(&bibtex_path);
cmd.arg(&aux_file)
.current_dir(work_dir)
.env("PATH", &env_path)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
#[cfg(target_os = "windows")]
cmd.creation_flags(CREATE_NO_WINDOW);
let output = cmd
.output()
.map_err(|e| format!("Failed to run bibtex: {}", e))?;
if !output.status.success() {
eprintln!(
"[texlive] bibtex warning: {}",
String::from_utf8_lossy(&output.stderr)
);
}
}
BibTool::None => {}
}
// Pass 2: resolve references / TOC
run_texlive_pass(&engine_path, &common_args, &main_file_path, work_dir)?;
// Pass 3: stabilize citations (only if bib was used)
if !matches!(bib_tool, BibTool::None) {
run_texlive_pass(&engine_path, &common_args, &main_file_path, work_dir)?;
}
let pdf_path = work_dir.join(format!("{}.pdf", main_stem));
let xdv_path = work_dir.join(format!("{}.xdv", main_stem));
// Fallback: if xelatex produced .xdv but no .pdf (e.g. xdvipdfmx was skipped due to
// warnings), manually run xdvipdfmx to convert .xdv → .pdf.
if !pdf_path.exists() && xdv_path.exists() {
eprintln!("[texlive] .xdv exists but no .pdf — running xdvipdfmx manually");
if let Ok(xdvipdfmx) = find_texlive_binary("xdvipdfmx") {
let mut cmd = std::process::Command::new(&xdvipdfmx);
cmd.args(["-o", &pdf_path.to_string_lossy()])
.arg(&xdv_path)
.current_dir(work_dir)
.env("PATH", &env_path)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
#[cfg(target_os = "windows")]
cmd.creation_flags(CREATE_NO_WINDOW);
let output = cmd
.output()
.map_err(|e| format!("Failed to launch xdvipdfmx: {}", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if !stderr.trim().is_empty() {
eprintln!("[texlive] xdvipdfmx stderr: {}", stderr.trim());
}
}
}
}
// Success is determined by whether the PDF exists, not by exit codes.
// The caller (compile_latex) checks pdf_path.exists() and reads the log for errors.
Ok(())
}
// --- SyncTeX Native Parser ---
struct SynctexNode {
@ -763,50 +407,11 @@ fn parse_synctex_node(s: &str, factor: f64, x_offset: f64, y_offset: f64) -> Opt
// --- Tauri Commands ---
#[derive(serde::Serialize)]
pub struct TexliveStatus {
pub available: bool,
pub engines: Vec<String>,
pub version: Option<String>,
}
#[tauri::command]
pub fn detect_texlive() -> TexliveStatus {
let engines_to_check = ["pdflatex", "xelatex", "lualatex"];
let mut found_engines = Vec::new();
for name in &engines_to_check {
if find_texlive_binary(name).is_ok() {
found_engines.push(name.to_string());
}
}
let version = find_texlive_binary("pdflatex").ok().and_then(|path| {
let mut cmd = std::process::Command::new(&path);
cmd.arg("--version")
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
#[cfg(target_os = "windows")]
cmd.creation_flags(CREATE_NO_WINDOW);
cmd.output().ok().and_then(|o| {
let stdout = String::from_utf8_lossy(&o.stdout);
stdout.lines().next().map(|l| l.to_string())
})
});
TexliveStatus {
available: !found_engines.is_empty(),
engines: found_engines,
version,
}
}
#[tauri::command]
pub async fn compile_latex(
state: tauri::State<'_, LatexCompilerState>,
project_dir: String,
main_file: String,
use_texlive: Option<bool>,
) -> Result<tauri::ipc::Response, String> {
// Acquire semaphore permit (non-blocking)
let _permit = state
@ -826,7 +431,6 @@ pub async fn compile_latex(
let _project_guard = project_lock.lock().await;
let t0 = std::time::Instant::now();
let use_texlive = use_texlive.unwrap_or(false);
let main_file_name = Path::new(&main_file)
.file_stem()
@ -857,15 +461,14 @@ pub async fn compile_latex(
}
eprintln!(
"[latex] +{:.0}ms {} ({}, backend={})",
"[latex] +{:.0}ms {} ({})",
t0.elapsed().as_millis(),
if is_reuse {
"sync source files"
} else {
"full copy"
},
if is_reuse { "reuse" } else { "first build" },
if use_texlive { "texlive" } else { "tectonic" }
if is_reuse { "reuse" } else { "first build" }
);
// Remove stale PDF so a failed compile doesn't return the previous result.
@ -882,70 +485,41 @@ pub async fn compile_latex(
}
// Detect TeX engine from magic comment
let main_tex_content = std::fs::read_to_string(&main_tex_path).unwrap_or_default();
let engine = detect_tex_engine(&main_tex_content);
// Save engine name before `engine` is moved into the spawn_blocking closure
let engine_name_for_label = match &engine {
Some(TexEngine::XeLaTeX) | None => "xelatex",
Some(TexEngine::Latex) => "pdflatex",
Some(TexEngine::LuaLaTeX) => "lualatex",
};
let backend_label = if use_texlive {
format!("TeXLive/{}", engine_name_for_label)
} else {
"Tectonic".to_string()
};
if !use_texlive {
if let Some(TexEngine::LuaLaTeX) = engine {
return Err(
"Compilation failed\n\nThis document requires LuaLaTeX (% !TEX program = lualatex), \
which is not supported. Prism uses a XeTeX-based engine (Tectonic). \
Please switch to XeLaTeX or remove the magic comment."
.to_string(),
);
if let Ok(content) = std::fs::read_to_string(&main_tex_path) {
if let Some(engine) = detect_tex_engine(&content) {
if engine == TexEngine::LuaLaTeX {
return Err(
"Compilation failed\n\nThis document requires LuaLaTeX (% !TEX program = lualatex), \
which is not supported. Prism uses a XeTeX-based engine (Tectonic). \
Please switch to XeLaTeX or remove the magic comment."
.to_string(),
);
}
// XeLaTeX → native (Tectonic is XeTeX-based), pdflatex → mostly compatible
}
}
let compile_result = if use_texlive {
let work_dir_clone = work_dir.clone();
let main_file_clone = main_file.clone();
let result = tokio::task::spawn_blocking(move || {
lower_thread_priority();
compile_with_texlive(&work_dir_clone, &main_file_clone, engine, &main_tex_content)
})
.await
.map_err(|e| format!("Compilation task panicked: {}", e))?;
eprintln!(
"[latex] +{:.0}ms texlive done (ok={})",
t0.elapsed().as_millis(),
result.is_ok()
);
result
} else {
// Run Tectonic in a subprocess to isolate C-level global state (font cache, etc.).
let work_dir_clone = work_dir.clone();
let main_file_clone = main_file.clone();
let result = tokio::task::spawn_blocking(move || {
lower_thread_priority();
compile_with_tectonic_subprocess(&work_dir_clone, &main_file_clone)
})
.await
.map_err(|e| format!("Compilation task panicked: {}", e))?;
eprintln!(
"[latex] +{:.0}ms tectonic done (ok={})",
t0.elapsed().as_millis(),
result.is_ok()
);
result
};
// Run Tectonic in a blocking task with reduced priority so the UI stays responsive.
let work_dir_clone = work_dir.clone();
let main_file_clone = main_file.clone();
let compile_result = tokio::task::spawn_blocking(move || {
lower_thread_priority();
compile_with_tectonic(&work_dir_clone, &main_file_clone)
})
.await
.map_err(|e| format!("Compilation task panicked: {}", e))?;
eprintln!(
"[latex] +{:.0}ms tectonic done (ok={})",
t0.elapsed().as_millis(),
compile_result.is_ok()
);
let log_path = work_dir.join(format!("{}.log", main_file_name));
// Handle "No pages of output" — retry with \AtEndDocument{\null} injection (Tectonic only).
// TeXLive multi-pass handles this differently; the injection is Tectonic-specific.
if !use_texlive && !pdf_path.exists() {
// Handle "No pages of output" — retry with \AtEndDocument{\null} injection
// Skip retry if there are real errors (e.g. missing packages) — retrying won't help.
if !pdf_path.exists() {
let log_path_clone = log_path.clone();
let main_tex = work_dir.join(&main_file);
let pdf_path_clone = pdf_path.clone();
@ -976,7 +550,7 @@ pub async fn compile_latex(
if needs_retry {
let retry_result = tokio::task::spawn_blocking(move || {
compile_with_tectonic_subprocess(&work_dir_clone, &main_file_clone)
compile_with_tectonic(&work_dir_clone, &main_file_clone)
})
.await
.map_err(|e| format!("Retry task panicked: {}", e))?;
@ -1007,10 +581,9 @@ pub async fn compile_latex(
.map_err(|e| format!("PDF read task panicked: {}", e))?
.map_err(|e| format!("Failed to read PDF: {}", e))?;
eprintln!(
"[latex] +{:.0}ms total (reuse={}, backend={}) pdf_size={}KB",
"[latex] +{:.0}ms total (reuse={}) pdf_size={}KB",
t0.elapsed().as_millis(),
is_reuse,
backend_label,
pdf_bytes.len() / 1024
);
Ok(tauri::ipc::Response::new(pdf_bytes))
@ -1025,7 +598,7 @@ pub async fn compile_latex(
} else {
details
};
Err(format!("Compilation failed ({})\n\n{}", backend_label, msg))
Err(format!("Compilation failed\n\n{}", msg))
}
}
@ -1103,45 +676,6 @@ pub async fn cleanup_all_builds(state: &LatexCompilerState) {
mod tests {
use super::*;
// --- detect_bib_tool ---
#[test]
fn test_detect_bib_tool_biber() {
let content =
"\\documentclass{article}\n\\usepackage{biblatex}\n\\begin{document}\n\\end{document}";
assert_eq!(detect_bib_tool(content), BibTool::Biber);
}
#[test]
fn test_detect_bib_tool_biblatex_with_options() {
let content = "\\documentclass{article}\n\\usepackage[style=apa,backend=biber]{biblatex}\n\\begin{document}";
assert_eq!(detect_bib_tool(content), BibTool::Biber);
}
#[test]
fn test_detect_bib_tool_bibtex() {
let content = "\\documentclass{article}\n\\bibliography{refs}\n\\end{document}";
assert_eq!(detect_bib_tool(content), BibTool::BibTeX);
}
#[test]
fn test_detect_bib_tool_addbibresource() {
let content = "\\documentclass{article}\n\\addbibresource{refs.bib}\n\\end{document}";
assert_eq!(detect_bib_tool(content), BibTool::BibTeX);
}
#[test]
fn test_detect_bib_tool_none() {
let content = "\\documentclass{article}\n\\begin{document}\nHello\n\\end{document}";
assert_eq!(detect_bib_tool(content), BibTool::None);
}
#[test]
fn test_detect_bib_tool_commented_out() {
let content = "\\documentclass{article}\n% \\bibliography{refs}\n% \\usepackage{biblatex}\n\\end{document}";
assert_eq!(detect_bib_tool(content), BibTool::None);
}
// --- extract_error_lines ---
#[test]
@ -1189,9 +723,8 @@ mod tests {
log.push_str(&format!("! Error number {}\n", i));
}
let result = extract_error_lines(&log);
assert!(result.contains("---- Engine output ----"));
let count = result.lines().count();
assert!(count <= 120);
assert!(count <= 10);
}
// --- persistent_build_dir ---

View file

@ -1,8 +1,4 @@
#![recursion_limit = "512"]
mod anthropic_proxy;
mod claude;
mod claude_process;
mod history;
mod latex;
mod skills;
@ -11,15 +7,7 @@ mod uv;
mod zotero;
use std::path::Path;
use tauri::{Emitter, Manager, WebviewUrl, WebviewWindowBuilder};
use tauri_plugin_fs::FsExt;
/// Entry point for the `--tectonic-compile` subprocess mode.
/// Runs tectonic compilation in an isolated process so that C-level global state
/// (font cache, etc.) is cleaned up on exit, preventing assertion failures on retry.
pub fn tectonic_compile_subprocess(work_dir: &Path, main_file: &str) -> Result<(), String> {
latex::compile_with_tectonic(work_dir, main_file)
}
use tauri::{Manager, WebviewUrl, WebviewWindowBuilder};
// --- External editor detection & opening ---
@ -182,12 +170,10 @@ fn create_new_window(app: tauri::AppHandle) -> Result<(), String> {
.as_millis()
);
#[allow(unused_mut)]
let mut builder = WebviewWindowBuilder::new(&app, &label, WebviewUrl::default())
.title("ClaudePrism")
.inner_size(1400.0, 900.0)
.min_inner_size(800.0, 600.0)
.zoom_hotkeys_enabled(true)
.visible(false);
#[cfg(target_os = "macos")]
@ -204,241 +190,6 @@ fn create_new_window(app: tauri::AppHandle) -> Result<(), String> {
Ok(())
}
#[cfg(target_os = "windows")]
fn apply_windows_titlebar_theme(window: &tauri::WebviewWindow, dark: bool) -> Result<(), String> {
use std::ffi::c_void;
#[link(name = "dwmapi")]
extern "system" {
#[link_name = "DwmSetWindowAttribute"]
fn dwm_set_window_attribute(
hwnd: isize,
dwattribute: u32,
pvattribute: *const c_void,
cbattribute: u32,
) -> i32;
}
#[link(name = "user32")]
extern "system" {
#[link_name = "SetWindowPos"]
fn set_window_pos(
hwnd: isize,
hwnd_insert_after: isize,
x: i32,
y: i32,
cx: i32,
cy: i32,
flags: u32,
) -> i32;
}
const DWMWA_USE_IMMERSIVE_DARK_MODE: u32 = 20;
const SWP_NOSIZE: u32 = 0x0001;
const SWP_NOMOVE: u32 = 0x0002;
const SWP_NOZORDER: u32 = 0x0004;
const SWP_NOACTIVATE: u32 = 0x0010;
const SWP_FRAMECHANGED: u32 = 0x0020;
let hwnd = window
.hwnd()
.map_err(|e| format!("Failed to resolve native window handle: {}", e))?;
let hwnd = hwnd.0 as isize;
let dark_value: i32 = if dark { 1 } else { 0 };
let attr_size = std::mem::size_of_val(&dark_value) as u32;
let mut result = unsafe {
dwm_set_window_attribute(
hwnd,
DWMWA_USE_IMMERSIVE_DARK_MODE,
&dark_value as *const _ as *const _,
attr_size,
)
};
if result < 0 {
// Older Windows 10 builds used attribute 19 before Microsoft documented 20.
result = unsafe {
dwm_set_window_attribute(hwnd, 19, &dark_value as *const _ as *const _, attr_size)
};
}
// Windows 11 honors explicit caption/text colors more reliably than the
// immersive flag alone, especially after runtime theme switches.
const DWMWA_CAPTION_COLOR: u32 = 35;
const DWMWA_TEXT_COLOR: u32 = 36;
let caption_color: u32 = if dark { 0x0010_1010 } else { 0x00F9_F9F9 };
let text_color: u32 = if dark { 0x00FF_FFFF } else { 0x0000_0000 };
unsafe {
let _ = dwm_set_window_attribute(
hwnd,
DWMWA_CAPTION_COLOR,
&caption_color as *const _ as *const _,
std::mem::size_of_val(&caption_color) as u32,
);
let _ = dwm_set_window_attribute(
hwnd,
DWMWA_TEXT_COLOR,
&text_color as *const _ as *const _,
std::mem::size_of_val(&text_color) as u32,
);
let _ = set_window_pos(
hwnd,
0,
0,
0,
0,
0,
SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE | SWP_FRAMECHANGED,
);
}
if result < 0 {
return Err(format!(
"Failed to update Windows title bar theme: HRESULT 0x{:08X}",
result as u32
));
}
Ok(())
}
#[tauri::command]
fn set_native_window_theme(window: tauri::WebviewWindow, theme: String) -> Result<(), String> {
let theme = theme.trim().to_ascii_lowercase();
let dark = theme == "dark";
let tauri_theme = if dark {
tauri::Theme::Dark
} else {
tauri::Theme::Light
};
window
.set_theme(Some(tauri_theme))
.map_err(|e| format!("Failed to set window theme: {}", e))?;
#[cfg(target_os = "windows")]
apply_windows_titlebar_theme(&window, dark)?;
Ok(())
}
#[tauri::command]
fn allow_project_directory(app: tauri::AppHandle, root_path: String) -> Result<(), String> {
let fs_scope = app.fs_scope();
fs_scope
.allow_directory(&root_path, true)
.map_err(|e| format!("Failed to allow project directory: {}", e))?;
let asset_scope = app.state::<tauri::scope::Scopes>();
asset_scope
.allow_directory(&root_path, true)
.map_err(|e| format!("Failed to allow project assets: {}", e))?;
Ok(())
}
#[derive(serde::Serialize)]
struct ProjectCandidate {
path: String,
name: String,
last_modified: u64,
has_main_tex: bool,
}
fn modified_ms(path: &Path) -> u64 {
std::fs::metadata(path)
.and_then(|metadata| metadata.modified())
.ok()
.and_then(|modified| modified.duration_since(std::time::UNIX_EPOCH).ok())
.map(|duration| duration.as_millis() as u64)
.unwrap_or(0)
}
fn has_tex_file(dir: &Path) -> bool {
if dir.join("main.tex").is_file() || dir.join("document.tex").is_file() {
return true;
}
std::fs::read_dir(dir)
.ok()
.into_iter()
.flat_map(|entries| entries.flatten())
.any(|entry| {
let path = entry.path();
if !path.is_file() {
return false;
}
matches!(
path.extension()
.and_then(|ext| ext.to_str())
.map(|ext| ext.to_ascii_lowercase())
.as_deref(),
Some("tex" | "ltx")
)
})
}
fn project_modified_ms(dir: &Path) -> u64 {
let mut latest = modified_ms(dir);
for relative in [
"main.tex",
"document.tex",
".prism/build/main.pdf",
".claudeprism/history.git/.git/refs/heads/master",
] {
latest = latest.max(modified_ms(&dir.join(relative)));
}
if let Ok(entries) = std::fs::read_dir(dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_file() {
latest = latest.max(modified_ms(&path));
}
}
}
latest
}
#[tauri::command]
fn list_default_projects() -> Result<Vec<ProjectCandidate>, String> {
let Some(home) = dirs::home_dir() else {
return Ok(Vec::new());
};
let base = home.join("Documents").join("ClaudePrism");
if !base.is_dir() {
return Ok(Vec::new());
}
let mut projects = Vec::new();
let entries = std::fs::read_dir(&base)
.map_err(|e| format!("Failed to read default project directory: {}", e))?;
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
let name = entry.file_name().to_string_lossy().to_string();
if name.starts_with('.') || !has_tex_file(&path) {
continue;
}
projects.push(ProjectCandidate {
path: path.to_string_lossy().to_string(),
name,
last_modified: project_modified_ms(&path),
has_main_tex: path.join("main.tex").is_file() || path.join("document.tex").is_file(),
});
}
projects.sort_by(|a, b| b.last_modified.cmp(&a.last_modified));
Ok(projects)
}
// --- Debug logging from JS (survives white-screen crashes) ---
#[tauri::command]
@ -446,67 +197,6 @@ fn js_log(msg: String) {
eprintln!("[js] {}", msg);
}
// --- Debug window ---
#[tauri::command]
fn open_debug_window(app: tauri::AppHandle) -> Result<(), String> {
// If a debug window already exists, just focus it
if let Some(win) = app.get_webview_window("debug") {
win.set_focus().map_err(|e| e.to_string())?;
return Ok(());
}
let url = WebviewUrl::App("index.html?debug=1".into());
WebviewWindowBuilder::new(&app, "debug", url)
.title("ClaudePrism — Debug")
.inner_size(560.0, 700.0)
.min_inner_size(400.0, 400.0)
.zoom_hotkeys_enabled(true)
.visible(true)
.build()
.map_err(|e| format!("Failed to create debug window: {}", e))?;
Ok(())
}
// --- System info for debug panel & bug reports ---
#[derive(serde::Serialize)]
struct SystemInfo {
os: String,
os_version: String,
arch: String,
app_version: String,
}
#[tauri::command]
fn get_system_info(app: tauri::AppHandle) -> SystemInfo {
// Get OS version from uname on unix, or fallback to "unknown"
let os_version = {
#[cfg(unix)]
{
std::process::Command::new("uname")
.arg("-r")
.output()
.ok()
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.trim().to_string())
.unwrap_or_else(|| "unknown".to_string())
}
#[cfg(not(unix))]
{
"unknown".to_string()
}
};
SystemInfo {
os: std::env::consts::OS.to_string(),
os_version,
arch: std::env::consts::ARCH.to_string(),
app_version: app.package_info().version.to_string(),
}
}
// --- Clipboard file paths (for Cmd+V paste in file tree) ---
#[tauri::command]
@ -568,61 +258,27 @@ pub fn run() {
.manage(claude::ClaudeProcessState::default())
.manage(latex::LatexCompilerState::default())
.manage(zotero::ZoteroOAuthState::default())
.setup(|app| {
// Safety net: force-show the main window after a timeout if the
// frontend JS never calls `getCurrentWindow().show()`.
// This prevents the window from staying permanently hidden when
// WKWebView fails to execute JS (e.g. WebKit top-level-await bug
// on macOS 12). See https://bugs.webkit.org/show_bug.cgi?id=242740
let handle = app.handle().clone();
tauri::async_runtime::spawn(async move {
tokio::time::sleep(std::time::Duration::from_secs(8)).await;
if let Some(window) = handle.get_webview_window("main") {
if !window.is_visible().unwrap_or(true) {
eprintln!("[safety] Main window still hidden after 8s, force-showing");
let _ = window.show();
let _ = window.set_focus();
}
}
});
Ok(())
})
.setup(|_app| Ok(()))
.invoke_handler(tauri::generate_handler![
create_new_window,
set_native_window_theme,
allow_project_directory,
list_default_projects,
detect_editors,
open_in_editor,
js_log,
read_clipboard_file_paths,
latex::compile_latex,
latex::synctex_edit,
latex::detect_texlive,
claude::check_claude_status,
claude::install_claude_cli,
claude::login_claude,
claude::save_anthropic_api_key,
claude::verify_openai_compatible_api_key,
claude::list_openai_compatible_models,
claude::list_openai_compatible_credential_models,
claude::clear_anthropic_api_key,
claude::list_openai_compatible_credentials,
claude::delete_openai_compatible_credential,
claude::set_active_openai_compatible_credential,
claude::execute_claude_code,
claude::continue_claude_code,
claude::resume_claude_code,
claude::cancel_claude_execution,
claude::interrupt_claude_execution,
claude::run_shell_command,
claude::migrate_project_sessions,
claude::get_claude_fast_mode,
claude::set_claude_fast_mode,
claude::list_claude_sessions,
claude::generate_claude_session_title,
claude::load_session_history,
claude::delete_claude_session,
zotero::zotero_start_oauth,
zotero::zotero_complete_oauth,
zotero::zotero_cancel_oauth,
@ -640,10 +296,8 @@ pub fn run() {
slash_commands::slash_command_delete,
skills::install_scientific_skills,
skills::install_scientific_skills_global,
skills::import_skill_from_folder,
skills::check_skills_installed,
skills::list_installed_skills,
skills::delete_installed_skill,
skills::uninstall_scientific_skills,
skills::get_skill_categories,
skills::get_skill_content,
@ -652,8 +306,6 @@ pub fn run() {
uv::setup_project_venv,
uv::uv_add_packages,
uv::uv_run_command,
get_system_info,
open_debug_window,
])
.build(tauri::generate_context!())
.expect("error while building tauri application");
@ -667,42 +319,6 @@ pub fn run() {
tauri::RunEvent::Ready => {
set_macos_app_icon();
}
// Workaround: WKWebView sometimes fails to repaint after the app
// returns from background, leaving a black screen. We apply two
// complementary fixes on focus-restore:
// 1. Nudge the window size by 1 px and back (forces native
// compositing layer to re-composite).
// 2. Trigger a DOM reflow via JS (forces WKWebView render tree
// rebuild without losing app state).
// Either one alone may not cover all cases.
// See https://github.com/tauri-apps/tauri/issues/5226
// https://github.com/tauri-apps/tauri/issues/14843
tauri::RunEvent::WindowEvent {
ref label,
event: tauri::WindowEvent::Focused(true),
..
} => {
if let Some(window) = app_handle.get_webview_window(label) {
// macOS: nudge window size to fix black screen after wake/focus
// See https://github.com/tauri-apps/tauri/issues/5226
#[cfg(target_os = "macos")]
{
if let Ok(size) = window.inner_size() {
let _ = window.set_size(tauri::Size::Physical(tauri::PhysicalSize {
width: size.width + 1,
height: size.height,
}));
let _ = window.set_size(tauri::Size::Physical(size));
}
let _ = window.eval(
"document.body.style.display='none';\
document.body.offsetHeight;\
document.body.style.display='';",
);
}
let _ = window.emit("window-focus-restored", ());
}
}
tauri::RunEvent::WindowEvent {
label,
event: tauri::WindowEvent::Destroyed,

View file

@ -2,21 +2,5 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
// Hidden CLI mode: when invoked with `--tectonic-compile <work_dir> <main_file>`,
// run tectonic in this subprocess and exit. This isolates tectonic's global C state
// so that a failed compilation doesn't poison the font cache for subsequent runs.
let args: Vec<String> = std::env::args().collect();
if args.len() >= 4 && args[1] == "--tectonic-compile" {
let work_dir = std::path::Path::new(&args[2]);
let main_file = &args[3];
match claude_prism_desktop_lib::tectonic_compile_subprocess(work_dir, main_file) {
Ok(()) => std::process::exit(0),
Err(e) => {
eprintln!("{}", e);
std::process::exit(1);
}
}
}
claude_prism_desktop_lib::run()
}

File diff suppressed because it is too large Load diff

View file

@ -200,16 +200,23 @@ fn load_skills_from_dir(dir: &Path, scope: &str) -> Vec<SlashCommand> {
return Vec::new();
}
let mut skill_dirs = Vec::new();
collect_skill_dirs(dir, &mut skill_dirs);
skill_dirs.sort();
let mut skills = Vec::new();
let mut seen_ids = std::collections::HashSet::new();
let entries = match fs::read_dir(dir) {
Ok(e) => e,
Err(_) => return Vec::new(),
};
for path in skill_dirs {
let Some(skill_md) = find_skill_md(&path) else {
let mut skills = Vec::new();
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
};
}
let skill_md = path.join("SKILL.md");
if !skill_md.exists() {
continue;
}
let content = match fs::read_to_string(&skill_md) {
Ok(c) => c,
@ -241,9 +248,6 @@ fn load_skills_from_dir(dir: &Path, scope: &str) -> Vec<SlashCommand> {
.map(|d| d.chars().take(200).collect());
let id = format!("skill-{}", folder_name);
if !seen_ids.insert(id.clone()) {
continue;
}
skills.push(SlashCommand {
id,
@ -265,51 +269,6 @@ fn load_skills_from_dir(dir: &Path, scope: &str) -> Vec<SlashCommand> {
skills
}
fn find_skill_md(skill_dir: &Path) -> Option<PathBuf> {
for name in ["SKILL.md", "skill.md"] {
let candidate = skill_dir.join(name);
if candidate.is_file() {
return Some(candidate);
}
}
let entries = fs::read_dir(skill_dir).ok()?;
for entry in entries.flatten() {
let path = entry.path();
if path.is_file()
&& path
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.eq_ignore_ascii_case("SKILL.md"))
{
return Some(path);
}
}
None
}
fn collect_skill_dirs(root: &Path, output: &mut Vec<PathBuf>) {
if find_skill_md(root).is_some() {
output.push(root.to_path_buf());
return;
}
let Ok(entries) = fs::read_dir(root) else {
return;
};
for entry in entries.flatten() {
let Ok(file_type) = entry.file_type() else {
continue;
};
if file_type.is_symlink() || !file_type.is_dir() {
continue;
}
collect_skill_dirs(&entry.path(), output);
}
}
fn create_default_commands() -> Vec<SlashCommand> {
vec![
SlashCommand {

View file

@ -1,4 +1,4 @@
use std::path::{Path, PathBuf};
use std::path::PathBuf;
use tauri::{Emitter, WebviewWindow};
use tokio::io::{AsyncBufReadExt, BufReader};
@ -7,9 +7,6 @@ use tokio::io::{AsyncBufReadExt, BufReader};
#[cfg(target_os = "windows")]
const CREATE_NO_WINDOW: u32 = 0x08000000;
#[cfg(target_os = "windows")]
use std::os::windows::process::CommandExt;
// ─── Binary Discovery ───
/// Discover the uv binary on the system.
@ -113,28 +110,6 @@ fn venv_python(venv_dir: &std::path::Path) -> PathBuf {
}
}
fn venv_pip(venv_dir: &std::path::Path) -> PathBuf {
#[cfg(not(target_os = "windows"))]
{
venv_bin_dir(venv_dir).join("pip")
}
#[cfg(target_os = "windows")]
{
venv_bin_dir(venv_dir).join("pip.exe")
}
}
fn venv_pip_shim(venv_dir: &std::path::Path) -> PathBuf {
#[cfg(not(target_os = "windows"))]
{
venv_bin_dir(venv_dir).join("pip")
}
#[cfg(target_os = "windows")]
{
venv_bin_dir(venv_dir).join("pip.cmd")
}
}
fn path_with_venv(venv_dir: &std::path::Path) -> String {
let bin = venv_bin_dir(venv_dir);
let current = std::env::var("PATH").unwrap_or_default();
@ -145,73 +120,6 @@ fn path_with_venv(venv_dir: &std::path::Path) -> String {
format!("{}{}{}", bin.to_string_lossy(), sep, current)
}
fn write_pip_shim(venv_dir: &Path) -> Result<(), String> {
let uv_bin = find_uv_binary().unwrap_or_else(|_| "uv".to_string());
let shim_path = venv_pip_shim(venv_dir);
#[cfg(target_os = "windows")]
{
let content = format!(
"@echo off\r\nset \"VIRTUAL_ENV={}\"\r\n\"{}\" pip %*\r\n",
venv_dir.to_string_lossy(),
uv_bin
);
std::fs::write(&shim_path, &content)
.map_err(|e| format!("Failed to create pip shim: {}", e))?;
let pip3_path = venv_bin_dir(venv_dir).join("pip3.cmd");
let _ = std::fs::write(pip3_path, content);
}
#[cfg(not(target_os = "windows"))]
{
let content = format!(
"#!/bin/sh\nVIRTUAL_ENV=\"{}\" exec \"{}\" pip \"$@\"\n",
venv_dir.to_string_lossy(),
uv_bin
);
std::fs::write(&shim_path, content)
.map_err(|e| format!("Failed to create pip shim: {}", e))?;
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(&shim_path)
.map_err(|e| format!("Failed to stat pip shim: {}", e))?
.permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&shim_path, perms)
.map_err(|e| format!("Failed to mark pip shim executable: {}", e))?;
}
Ok(())
}
async fn ensure_venv_pip(venv_dir: &Path) -> Result<(), String> {
if venv_pip(venv_dir).exists() || venv_pip_shim(venv_dir).exists() {
return Ok(());
}
let python = venv_python(venv_dir);
if !python.exists() {
return Err(format!(
"Project .venv is missing Python at {}",
python.display()
));
}
let mut ensure_cmd = tokio::process::Command::new(&python);
ensure_cmd.args(["-m", "ensurepip", "--upgrade"]);
ensure_cmd.env("VIRTUAL_ENV", venv_dir);
ensure_cmd.env("PATH", path_with_venv(venv_dir));
ensure_cmd.env("PYTHONNOUSERSITE", "1");
#[cfg(target_os = "windows")]
{
ensure_cmd.creation_flags(CREATE_NO_WINDOW);
}
match ensure_cmd.output().await {
Ok(output) if output.status.success() && venv_pip(venv_dir).exists() => Ok(()),
_ => write_pip_shim(venv_dir),
}
}
// ─── Tauri Commands ───
#[tauri::command]
@ -232,6 +140,7 @@ pub async fn check_uv_status() -> Result<UvStatus, String> {
version_cmd.arg("--version");
#[cfg(target_os = "windows")]
{
use std::os::windows::process::CommandExt;
version_cmd.creation_flags(CREATE_NO_WINDOW);
}
let version_output = version_cmd.output();
@ -302,6 +211,7 @@ pub async fn install_uv(window: WebviewWindow) -> Result<(), String> {
};
#[cfg(target_os = "windows")]
let mut cmd = {
use std::os::windows::process::CommandExt;
let mut c = tokio::process::Command::new("powershell");
c.creation_flags(CREATE_NO_WINDOW);
c.args([
@ -317,13 +227,38 @@ pub async fn install_uv(window: WebviewWindow) -> Result<(), String> {
cmd.stdout(std::process::Stdio::piped());
cmd.stderr(std::process::Stdio::piped());
// Inherit essential environment variables (shared helper handles case-insensitive matching)
// Inherit essential environment variables
for (key, value) in std::env::vars() {
if key.eq_ignore_ascii_case("PATH") || crate::claude::is_essential_env_var(&key) {
if key == "PATH"
|| key == "HOME"
|| key == "USER"
|| key == "SHELL"
|| key == "LANG"
|| key.starts_with("LC_")
|| key == "HOMEBREW_PREFIX"
|| key == "HOMEBREW_CELLAR"
|| key == "HTTP_PROXY"
|| key == "HTTPS_PROXY"
|| key == "NO_PROXY"
|| key == "ALL_PROXY"
// Windows-essential variables
|| key == "USERPROFILE"
|| key == "LOCALAPPDATA"
|| key == "APPDATA"
|| key == "TEMP"
|| key == "TMP"
|| key == "SystemRoot"
|| key == "WINDIR"
|| key == "PROGRAMFILES"
|| key == "PROGRAMFILES(X86)"
|| key == "COMMONPROGRAMFILES"
|| key == "SystemDrive"
|| key == "COMPUTERNAME"
|| key == "USERNAME"
{
cmd.env(&key, &value);
}
}
crate::claude::apply_proxy_env_to_command(&mut cmd, Some(&window));
let mut child = cmd
.spawn()
@ -377,7 +312,6 @@ pub async fn setup_project_venv(project_path: String) -> Result<VenvInfo, String
// If venv already exists, just return info
if venv_dir.exists() {
ensure_venv_pip(&venv_dir).await?;
let python = venv_python(&venv_dir);
return Ok(VenvInfo {
venv_path: venv_dir.to_string_lossy().to_string(),
@ -390,11 +324,11 @@ pub async fn setup_project_venv(project_path: String) -> Result<VenvInfo, String
// Create venv: uv venv <project_path>/.venv
let mut venv_cmd = tokio::process::Command::new(&uv_bin);
let venv_arg = venv_dir.to_string_lossy().to_string();
venv_cmd.args(["venv", "--seed", venv_arg.as_str()]);
venv_cmd.args(["venv", &venv_dir.to_string_lossy()]);
venv_cmd.current_dir(project);
#[cfg(target_os = "windows")]
{
use std::os::windows::process::CommandExt;
venv_cmd.creation_flags(CREATE_NO_WINDOW);
}
let output = venv_cmd
@ -408,7 +342,6 @@ pub async fn setup_project_venv(project_path: String) -> Result<VenvInfo, String
}
let python = venv_python(&venv_dir);
ensure_venv_pip(&venv_dir).await?;
Ok(VenvInfo {
venv_path: venv_dir.to_string_lossy().to_string(),
@ -436,11 +369,10 @@ pub async fn uv_add_packages(
pip_cmd.args(&args);
pip_cmd.current_dir(&project_path);
pip_cmd.env("VIRTUAL_ENV", &venv_dir);
pip_cmd.env("UV_PROJECT_ENVIRONMENT", &venv_dir);
pip_cmd.env("PYTHONNOUSERSITE", "1");
pip_cmd.env("PATH", path_with_venv(&venv_dir));
#[cfg(target_os = "windows")]
{
use std::os::windows::process::CommandExt;
pip_cmd.creation_flags(CREATE_NO_WINDOW);
}
let output = pip_cmd
@ -481,12 +413,10 @@ pub async fn uv_run_command(
run_cmd.args(args);
run_cmd.current_dir(&project_path);
run_cmd.env("VIRTUAL_ENV", &venv_dir);
run_cmd.env("UV_PROJECT_ENVIRONMENT", &venv_dir);
run_cmd.env("PYTHONNOUSERSITE", "1");
run_cmd.env("PIP_REQUIRE_VIRTUALENV", "true");
run_cmd.env("PATH", path_with_venv(&venv_dir));
#[cfg(target_os = "windows")]
{
use std::os::windows::process::CommandExt;
run_cmd.creation_flags(CREATE_NO_WINDOW);
}
let output = run_cmd

View file

@ -1,50 +0,0 @@
/* Version script to hide statically linked library symbols.
*
* Prevents symbol collisions between our statically linked
* ICU/HarfBuzz/FreeType/Fontconfig (from vcpkg, used by Tectonic)
* and the dynamically linked versions used by WebKit2GTK at runtime.
*
* Without this, the ELF dynamic linker resolves WebKit2GTK's ICU
* calls to our static symbols (different version), causing a segfault
* at startup. Fixes #100; preserves #91 fix (static ICU = no
* libicuuc.so.70 not-found error on newer distros).
*/
{
local:
/* ICU4C */
u_*;
ucnv_*;
ucol_*;
ures_*;
udata_*;
ucal_*;
udat_*;
unum_*;
uset_*;
ustr_*;
ubidi_*;
ubrk_*;
uidna_*;
uloc_*;
unorm_*;
unorm2_*;
utrans_*;
icu_*;
UCNV_*;
/* HarfBuzz */
hb_*;
/* FreeType */
FT_*;
ft_*;
/* Fontconfig */
Fc*;
/* Graphite2 */
gr_*;
/* libpng (often pulled in by FreeType) */
png_*;
};

View file

@ -2,10 +2,10 @@
"$schema": "https://raw.githubusercontent.com/nicegui-unofficial/nicegui-tauri-template/main/src-tauri/tauri.conf-v2-schema.json",
"identifier": "com.claude-prism.desktop",
"productName": "ClaudePrism",
"version": "1.3.0",
"version": "1.0.3",
"build": {
"beforeDevCommand": "corepack pnpm dev",
"beforeBuildCommand": "corepack pnpm build",
"beforeDevCommand": "pnpm dev",
"beforeBuildCommand": "pnpm build",
"devUrl": "http://localhost:1420",
"frontendDist": "../dist"
},
@ -23,7 +23,6 @@
"visible": false,
"titleBarStyle": "Overlay",
"hiddenTitle": true,
"zoomHotkeysEnabled": true,
"trafficLightPosition": {
"x": 12,
"y": 12

View file

@ -1,5 +0,0 @@
{
"bundle": {
"createUpdaterArtifacts": false
}
}

View file

@ -1,4 +1,4 @@
import { ThemeProvider, useTheme } from "next-themes";
import { ThemeProvider } from "next-themes";
import { ErrorBoundary } from "react-error-boundary";
import { Toaster } from "@/components/ui/sonner";
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
@ -7,79 +7,20 @@ import { useDocumentStore } from "@/stores/document-store";
import { useClaudeChatStore } from "@/stores/claude-chat-store";
import { ProjectPicker } from "@/components/project-picker";
import { WorkspaceLayout } from "@/components/workspace/workspace-layout";
import { lazy, Suspense, useEffect, useRef, useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import { useEffect, useState } from "react";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { TooltipProvider } from "@/components/ui/tooltip";
import {
ScientificSkillsOnboarding,
shouldShowOnboarding,
} from "@/components/scientific-skills/scientific-skills-onboarding";
import { useUvSetupStore } from "@/stores/uv-setup-store";
import { ErrorFallback } from "@/components/error-fallback";
import { createLogger } from "@/lib/debug/logger";
import { EnvironmentOnboarding } from "@/components/environment-onboarding";
const log = createLogger("app");
const LazyDebugPage = lazy(() =>
import("@/components/debug/debug-page").then((m) => ({
default: m.DebugPage,
})),
);
interface ClaudeSessionInfo {
session_id: string;
title: string;
last_modified: number;
}
function NativeWindowThemeBridge() {
const { resolvedTheme, theme } = useTheme();
useEffect(() => {
const syncNativeTheme = () => {
const isDark =
document.documentElement.classList.contains("dark") ||
resolvedTheme === "dark";
const nativeTheme = isDark ? "dark" : "light";
document.documentElement.style.colorScheme = nativeTheme;
invoke("set_native_window_theme", { theme: nativeTheme })
.catch((err) => {
log.warn("Failed to sync native window theme via Rust command", {
error: String(err),
});
return getCurrentWindow().setTheme(nativeTheme);
})
.catch((err) => {
log.warn("Failed to sync native window theme via JS API", {
error: String(err),
});
});
};
syncNativeTheme();
const observer = new MutationObserver(syncNativeTheme);
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
});
const systemThemeQuery = window.matchMedia("(prefers-color-scheme: dark)");
systemThemeQuery.addEventListener("change", syncNativeTheme);
return () => {
observer.disconnect();
systemThemeQuery.removeEventListener("change", syncNativeTheme);
};
}, [resolvedTheme, theme]);
return null;
}
function WorkspaceWithClaude() {
const projectRoot = useDocumentStore((s) => s.projectRoot);
const initialized = useDocumentStore((s) => s.initialized);
const autoResumedProjectRef = useRef<string | null>(null);
const chatProjectRef = useRef<string | null>(null);
const [showSkillsOnboarding, setShowSkillsOnboarding] = useState(false);
// Update window title
useEffect(() => {
@ -89,11 +30,15 @@ function WorkspaceWithClaude() {
}
}, [projectRoot]);
// Show scientific skills onboarding on first launch
useEffect(() => {
if (chatProjectRef.current === projectRoot) return;
chatProjectRef.current = projectRoot;
useClaudeChatStore.getState().resetForProject(projectRoot ?? null);
}, [projectRoot]);
if (!initialized) return;
if (shouldShowOnboarding()) {
// Small delay so the workspace renders first
const timer = setTimeout(() => setShowSkillsOnboarding(true), 800);
return () => clearTimeout(timer);
}
}, [initialized]);
// Auto-setup Python venv when project opens
useEffect(() => {
@ -108,71 +53,16 @@ function WorkspaceWithClaude() {
}
})
.catch((err) => {
log.error("Failed to setup Python venv", { error: String(err) });
console.error("Failed to setup Python venv:", err);
});
}, [initialized, projectRoot]);
// Open the most recent chat when entering a project.
useEffect(() => {
if (!projectRoot) {
autoResumedProjectRef.current = null;
return;
}
if (!initialized) return;
if (autoResumedProjectRef.current === projectRoot) return;
const chatState = useClaudeChatStore.getState();
if (chatState.pendingInitialPrompt) return;
autoResumedProjectRef.current = projectRoot;
let cancelled = false;
invoke<ClaudeSessionInfo[]>("list_claude_sessions", {
projectPath: projectRoot,
generateTitles: false,
})
.then((sessions) => {
if (cancelled) return;
const latest = sessions
.slice()
.sort((a, b) => b.last_modified - a.last_modified)[0];
const current = useClaudeChatStore.getState();
if (current.pendingInitialPrompt || current.isStreaming) {
return;
}
if (!latest?.session_id) {
current.newSession();
return;
}
current.resumeSession(latest.session_id, latest.title).catch((err) => {
log.warn("Failed to auto-resume latest chat session", {
sessionId: latest.session_id,
error: String(err),
});
});
})
.catch((err) => {
log.warn("Failed to auto-resume latest chat session", {
error: String(err),
});
});
return () => {
cancelled = true;
};
}, [initialized, projectRoot]);
// Consume pending initial prompt from project wizard
useEffect(() => {
if (!initialized) return;
// Delay to let ClaudeChatDrawer mount and register event listeners
const timer = setTimeout(() => {
const prompt = useClaudeChatStore
.getState()
.consumePendingInitialPrompt();
const prompt = useClaudeChatStore.getState().consumePendingInitialPrompt();
if (prompt) {
useClaudeChatStore.getState().sendPrompt(prompt);
}
@ -180,87 +70,39 @@ function WorkspaceWithClaude() {
return () => clearTimeout(timer);
}, [initialized]);
return <WorkspaceLayout />;
return (
<>
<WorkspaceLayout />
{showSkillsOnboarding && (
<ScientificSkillsOnboarding
onClose={() => setShowSkillsOnboarding(false)}
/>
)}
</>
);
}
export function App({ onReady }: { onReady?: () => void }) {
const projectRoot = useDocumentStore((s) => s.projectRoot);
const [showDebug, setShowDebug] = useState(false);
// Register global keyboard shortcuts (Cmd+S, Cmd+N) at the app level
useKeyboardShortcuts();
useEffect(() => {
const preventNativeContextMenu = (event: MouseEvent) => {
if (event.defaultPrevented) return;
event.preventDefault();
};
document.addEventListener("contextmenu", preventNativeContextMenu);
return () => {
document.removeEventListener("contextmenu", preventNativeContextMenu);
};
}, []);
useEffect(() => {
onReady?.();
}, [onReady]);
useEffect(() => {
if (!projectRoot) {
getCurrentWindow().setTitle("ClaudePrism");
}
}, [projectRoot]);
// Listen for debug panel toggle (Ctrl+Shift+D)
useEffect(() => {
const handler = () => setShowDebug((prev) => !prev);
window.addEventListener("toggle-debug-panel", handler);
return () => window.removeEventListener("toggle-debug-panel", handler);
}, []);
return (
<ErrorBoundary FallbackComponent={ErrorFallback}>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<ThemeProvider attribute="class" defaultTheme="light" enableSystem>
<TooltipProvider>
<NativeWindowThemeBridge />
{/* Global macOS titlebar drag region — sits above all content */}
<div
data-tauri-drag-region
className="fixed inset-x-0 top-0 z-[9999] h-[var(--titlebar-height)]"
/>
{projectRoot ? <WorkspaceWithClaude /> : <ProjectPicker />}
<EnvironmentOnboarding />
{showDebug && (
<div className="fixed inset-0 z-[9998] flex items-end justify-center">
<div
className="absolute inset-0 bg-black/20"
onClick={() => setShowDebug(false)}
/>
<div className="relative h-[60vh] w-full border-border border-t bg-background shadow-lg">
<div className="flex h-8 items-center justify-between border-border border-b bg-muted/50 px-3">
<span className="font-medium text-xs">Debug Panel</span>
<button
className="text-muted-foreground text-xs hover:text-foreground"
onClick={() => setShowDebug(false)}
>
Close (Ctrl+Shift+D)
</button>
</div>
<div className="h-[calc(60vh-2rem)] overflow-auto">
<Suspense
fallback={
<div className="p-4 text-muted-foreground text-sm">
Loading...
</div>
}
>
<LazyDebugPage />
</Suspense>
</div>
</div>
</div>
)}
<Toaster />
</TooltipProvider>
</ThemeProvider>

View file

@ -1,184 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { getCurrentWebview } from "@tauri-apps/api/webview";
import {
APP_ZOOM_STORAGE_KEY,
DEFAULT_APP_ZOOM,
LOCAL_ZOOM_SHORTCUTS_ATTR,
MAX_APP_ZOOM,
MIN_APP_ZOOM,
clampAppZoom,
getAppZoomAction,
initializeAppZoom,
persistAppZoom,
readStoredAppZoom,
resetAppZoom,
shouldHandleAppZoomShortcut,
zoomInApp,
zoomOutApp,
} from "@/lib/app-zoom";
describe("app zoom", () => {
beforeEach(() => {
localStorage.clear();
vi.clearAllMocks();
});
it("falls back to the default zoom when storage is empty or invalid", () => {
expect(readStoredAppZoom()).toBe(DEFAULT_APP_ZOOM);
localStorage.setItem(APP_ZOOM_STORAGE_KEY, "not-a-number");
expect(readStoredAppZoom()).toBe(DEFAULT_APP_ZOOM);
});
it("clamps stored zoom values into the supported range", () => {
localStorage.setItem(APP_ZOOM_STORAGE_KEY, "999");
expect(readStoredAppZoom()).toBe(MAX_APP_ZOOM);
localStorage.setItem(APP_ZOOM_STORAGE_KEY, "0.1");
expect(readStoredAppZoom()).toBe(MIN_APP_ZOOM);
});
it("applies persisted zoom through the webview API", async () => {
const webview = getCurrentWebview();
await persistAppZoom(1.25);
expect(webview.setZoom).toHaveBeenCalledWith(1.25);
expect(localStorage.getItem(APP_ZOOM_STORAGE_KEY)).toBe("1.25");
});
it("resets stale global zoom on startup", async () => {
const webview = getCurrentWebview();
localStorage.setItem(APP_ZOOM_STORAGE_KEY, "1.4");
await initializeAppZoom();
expect(webview.setZoom).toHaveBeenCalledWith(DEFAULT_APP_ZOOM);
expect(localStorage.getItem(APP_ZOOM_STORAGE_KEY)).toBeNull();
});
it("zooms in, zooms out, and resets around the stored value", async () => {
const webview = getCurrentWebview();
localStorage.setItem(APP_ZOOM_STORAGE_KEY, "1.2");
await zoomInApp();
expect(webview.setZoom).toHaveBeenLastCalledWith(1.3);
expect(localStorage.getItem(APP_ZOOM_STORAGE_KEY)).toBe("1.3");
await zoomOutApp();
expect(webview.setZoom).toHaveBeenLastCalledWith(1.2);
expect(localStorage.getItem(APP_ZOOM_STORAGE_KEY)).toBe("1.2");
await resetAppZoom();
expect(webview.setZoom).toHaveBeenLastCalledWith(DEFAULT_APP_ZOOM);
expect(localStorage.getItem(APP_ZOOM_STORAGE_KEY)).toBe(
DEFAULT_APP_ZOOM.toString(),
);
});
it("rounds and clamps zoom values consistently", () => {
expect(clampAppZoom(1.234)).toBe(1.23);
expect(clampAppZoom(10)).toBe(MAX_APP_ZOOM);
expect(clampAppZoom(0.01)).toBe(MIN_APP_ZOOM);
});
});
describe("getAppZoomAction", () => {
it("detects zoom-in shortcuts", () => {
expect(
getAppZoomAction({
metaKey: true,
ctrlKey: false,
altKey: false,
key: "+",
code: "Equal",
}),
).toBe("in");
expect(
getAppZoomAction({
metaKey: false,
ctrlKey: true,
altKey: false,
key: "=",
code: "Equal",
}),
).toBe("in");
});
it("detects zoom-out shortcuts", () => {
expect(
getAppZoomAction({
metaKey: true,
ctrlKey: false,
altKey: false,
key: "-",
code: "Minus",
}),
).toBe("out");
expect(
getAppZoomAction({
metaKey: false,
ctrlKey: true,
altKey: false,
key: "_",
code: "Minus",
}),
).toBe("out");
});
it("detects zoom reset shortcuts", () => {
expect(
getAppZoomAction({
metaKey: true,
ctrlKey: false,
altKey: false,
key: "0",
code: "Digit0",
}),
).toBe("reset");
});
it("ignores unrelated shortcuts", () => {
expect(
getAppZoomAction({
metaKey: false,
ctrlKey: false,
altKey: false,
key: "+",
code: "Equal",
}),
).toBeNull();
expect(
getAppZoomAction({
metaKey: true,
ctrlKey: false,
altKey: true,
key: "+",
code: "Equal",
}),
).toBeNull();
});
});
describe("shouldHandleAppZoomShortcut", () => {
it("handles global zoom when the target is outside a local zoom surface", () => {
const target = document.createElement("div");
expect(shouldHandleAppZoomShortcut(target)).toBe(true);
});
it("skips global zoom inside local zoom surfaces", () => {
const wrapper = document.createElement("div");
wrapper.setAttribute(LOCAL_ZOOM_SHORTCUTS_ATTR, "true");
const target = document.createElement("button");
wrapper.appendChild(target);
expect(shouldHandleAppZoomShortcut(target)).toBe(false);
});
it("defaults to handling zoom for non-element targets", () => {
expect(shouldHandleAppZoomShortcut(null)).toBe(true);
});
});

View file

@ -1,67 +0,0 @@
import { describe, expect, it } from "vitest";
import {
getModelCapabilities,
isChatModelOption,
} from "@/lib/model-capabilities";
describe("model capabilities", () => {
it("uses provider metadata for vision capability", () => {
expect(
getModelCapabilities({
model: "custom-model",
metadata: { model_info: { supports_vision: true } },
}).vision,
).toBe(true);
expect(
getModelCapabilities({
label: "Qwen",
model: "qwen3.6-flash",
metadata: { supports_vision: false },
}).vision,
).toBe(false);
});
it("recognizes recent Qwen chat models as vision-capable when metadata is missing", () => {
expect(
getModelCapabilities({
label: "Qwen",
baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
model: "qwen3.5-flash",
}).vision,
).toBe(true);
expect(
getModelCapabilities({
label: "Qwen",
baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
model: "qwen3.6-flash",
}).vision,
).toBe(true);
expect(
getModelCapabilities({
label: "Qwen",
baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
model: "qwen3.6-plus",
}).vision,
).toBe(true);
expect(
getModelCapabilities({
label: "Qwen",
baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
model: "qwen3.5-coder",
}).vision,
).toBe(false);
});
it("filters non-chat model families", () => {
expect(
isChatModelOption({
label: "Qwen",
model: "text-embedding-v4",
}),
).toBe(false);
});
});

View file

@ -1,52 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
copyFileToProject: vi.fn(),
}));
vi.mock("@/lib/tauri/fs", () => ({
copyFileToProject: mocks.copyFileToProject,
}));
import {
buildReferenceFilesSection,
importReferenceFiles,
} from "@/lib/project-attachments";
describe("project attachment helpers", () => {
beforeEach(() => {
mocks.copyFileToProject.mockReset();
});
it("imports PDFs without creating extracted text files", async () => {
mocks.copyFileToProject.mockResolvedValueOnce("attachments/paper.pdf");
const files = await importReferenceFiles("C:/project", [
"C:/source/paper.pdf",
]);
expect(mocks.copyFileToProject).toHaveBeenCalledWith(
"C:/project",
"C:/source/paper.pdf",
"attachments/paper.pdf",
);
expect(files).toEqual([
{
relativePath: "attachments/paper.pdf",
},
]);
});
it("builds a prompt section that keeps PDF references as PDFs", () => {
const section = buildReferenceFilesSection([
{ relativePath: "attachments/paper.pdf" },
{ relativePath: "attachments/data.csv" },
]);
expect(section).toContain("### Reference Files");
expect(section).toContain("`attachments/paper.pdf` (PDF)");
expect(section).toContain("`attachments/data.csv`");
expect(section).not.toContain("extracted text");
expect(section).not.toContain(".pdf.txt");
});
});

View file

@ -1,46 +0,0 @@
import { describe, expect, it } from "vitest";
import {
getProviderDisplayName,
getProviderIconSrc,
} from "@/lib/provider-icons";
describe("getProviderDisplayName", () => {
it("derives provider names from old custom labels", () => {
expect(
getProviderDisplayName({
label: "Custom OpenAI API",
baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
model: "qwen3.7-plus",
}),
).toBe("Qwen");
expect(
getProviderDisplayName({
label: "Custom OpenAI API",
baseUrl: "https://open.bigmodel.cn/api/paas/v4",
model: "glm-5.1",
}),
).toBe("GLM");
});
it("keeps meaningful provider labels for unknown endpoints", () => {
expect(
getProviderDisplayName({
label: "Acme AI",
baseUrl: "https://models.example.test/v1",
model: "acme-large",
}),
).toBe("Acme AI");
});
it("recognizes local Ollama endpoints", () => {
const provider = {
label: "Custom OpenAI API",
baseUrl: "http://localhost:11434/v1",
model: "llama3.2",
};
expect(getProviderDisplayName(provider)).toBe("Ollama");
expect(getProviderIconSrc(provider)).toContain("ollama");
});
});

View file

@ -33,7 +33,7 @@ describe("Tauri IPC error message extraction", () => {
// This is what Tauri invoke() rejects with for Rust Err(String)
const error = "Compilation failed\n\n! Undefined control sequence.";
expect(extractErrorMessage(error)).toBe(
"Compilation failed\n\n! Undefined control sequence.",
"Compilation failed\n\n! Undefined control sequence."
);
});

View file

@ -1,109 +1,110 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { readDir, stat } from "@tauri-apps/plugin-fs";
import {
getProjectFileType,
scanProjectFolder,
shouldSkipProjectDirectory,
} from "@/lib/tauri/fs";
import { describe, it, expect } from "vitest";
describe("tauri fs helpers", () => {
beforeEach(() => {
vi.clearAllMocks();
// getFileType is not exported, so we test via the module's behavior.
// We need to import from the source and test the classification logic.
// Since getFileType is private, we'll extract the logic into a testable pattern.
// For now, test the exported types and the classification indirectly.
// We can test the file type classification logic by reimplementing the same
// pattern as the source and verifying consistency, or we test via scanProjectFolder.
// Since scanProjectFolder requires Tauri filesystem mocks with complex async behavior,
// let's test the pure classification logic directly by accessing the private function
// via a small wrapper test.
// Actually, the simplest approach: the getFileType function is module-private.
// We'll test it by examining the constants and logic as documented.
describe("getFileType logic", () => {
// Replicate the classification logic for testing
const IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".gif", ".svg", ".bmp", ".webp"]);
const STYLE_EXTENSIONS = new Set([".sty", ".cls", ".bst", ".def", ".cfg", ".fd", ".dtx", ".ins"]);
const IGNORED_EXTENSIONS = new Set([
".aux", ".log", ".out", ".toc", ".lof", ".lot", ".fls",
".fdb_latexmk", ".synctex.gz", ".synctex", ".blg", ".bbl",
".nav", ".snm", ".vrb", ".run.xml", ".bcf",
// Binary / non-text files
".hwp", ".hwpx", ".doc", ".docx", ".xls", ".xlsx", ".xlsm",
".ppt", ".pptx", ".accdb", ".mdb",
".zip", ".rar", ".7z", ".tar", ".gz",
".exe", ".dll", ".so", ".dylib", ".o", ".obj",
".bin", ".dat", ".iso", ".dmg", ".msi",
".mp3", ".mp4", ".avi", ".mov", ".mkv", ".wav", ".flac",
".psd", ".ai", ".sketch", ".fig",
".sqlite", ".db",
]);
function getFileType(name: string): string | null {
const lower = name.toLowerCase();
for (const ext of IGNORED_EXTENSIONS) {
if (lower.endsWith(ext)) return null;
}
if (lower.endsWith(".tex") || lower.endsWith(".ltx")) return "tex";
if (lower.endsWith(".bib")) return "bib";
if (lower.endsWith(".pdf")) return "pdf";
for (const ext of IMAGE_EXTENSIONS) {
if (lower.endsWith(ext)) return "image";
}
for (const ext of STYLE_EXTENSIONS) {
if (lower.endsWith(ext)) return "style";
}
return "other";
}
it("classifies .tex files", () => {
expect(getFileType("main.tex")).toBe("tex");
expect(getFileType("chapter.TEX")).toBe("tex");
expect(getFileType("doc.ltx")).toBe("tex");
});
describe("getProjectFileType", () => {
it("classifies editable project files", () => {
expect(getProjectFileType("main.tex")).toBe("tex");
expect(getProjectFileType("chapter.TEX")).toBe("tex");
expect(getProjectFileType("refs.bib")).toBe("bib");
expect(getProjectFileType("output.pdf")).toBe("pdf");
expect(getProjectFileType("figure.png")).toBe("image");
expect(getProjectFileType("custom.sty")).toBe("style");
expect(getProjectFileType("notes.md")).toBe("other");
expect(getProjectFileType("script.py")).toBe("other");
});
it("ignores LaTeX and compiled build artifacts", () => {
expect(getProjectFileType("main.aux")).toBeNull();
expect(getProjectFileType("main.synctex.gz")).toBeNull();
expect(getProjectFileType("module.pyc")).toBeNull();
expect(getProjectFileType("native.pyd")).toBeNull();
expect(getProjectFileType("libnative.so")).toBeNull();
});
it("keeps imported files with arbitrary extensions visible", () => {
expect(getProjectFileType("archive.zip")).toBe("other");
expect(getProjectFileType("paper.docx")).toBe("other");
expect(getProjectFileType("data.xlsx")).toBe("other");
expect(getProjectFileType("movie.mp4")).toBe("other");
});
it("classifies .bib files", () => {
expect(getFileType("refs.bib")).toBe("bib");
});
describe("shouldSkipProjectDirectory", () => {
it("skips hidden and generated dependency directories", () => {
expect(shouldSkipProjectDirectory(".git")).toBe(true);
expect(shouldSkipProjectDirectory(".venv")).toBe(true);
expect(shouldSkipProjectDirectory("node_modules")).toBe(true);
expect(shouldSkipProjectDirectory("__pycache__")).toBe(true);
expect(shouldSkipProjectDirectory("venv")).toBe(true);
expect(shouldSkipProjectDirectory("ENV")).toBe(true);
});
it("keeps normal project folders visible", () => {
expect(shouldSkipProjectDirectory("chapters")).toBe(false);
expect(shouldSkipProjectDirectory("figures")).toBe(false);
expect(shouldSkipProjectDirectory("attachments")).toBe(false);
});
it("classifies .pdf files", () => {
expect(getFileType("output.pdf")).toBe("pdf");
});
describe("scanProjectFolder", () => {
it("does not recurse into generated cache directories", async () => {
vi.mocked(readDir).mockImplementation(async (dir: string | URL) => {
const dirPath = String(dir);
if (dirPath === "/project") {
return [
{ name: "__pycache__", isDirectory: true },
{ name: "node_modules", isDirectory: true },
{ name: "main.tex", isDirectory: false },
{ name: "chapters", isDirectory: true },
] as any;
}
it("classifies image files", () => {
expect(getFileType("fig.png")).toBe("image");
expect(getFileType("photo.jpg")).toBe("image");
expect(getFileType("icon.svg")).toBe("image");
expect(getFileType("anim.gif")).toBe("image");
expect(getFileType("pic.webp")).toBe("image");
});
if (dirPath === "/project/chapters") {
return [{ name: "intro.tex", isDirectory: false }] as any;
}
it("classifies style files", () => {
expect(getFileType("custom.sty")).toBe("style");
expect(getFileType("report.cls")).toBe("style");
expect(getFileType("plain.bst")).toBe("style");
});
throw new Error(`Unexpected readDir path: ${dirPath}`);
});
it("ignores build artifacts", () => {
expect(getFileType("main.aux")).toBeNull();
expect(getFileType("main.log")).toBeNull();
expect(getFileType("main.toc")).toBeNull();
expect(getFileType("main.synctex.gz")).toBeNull();
expect(getFileType("main.fdb_latexmk")).toBeNull();
expect(getFileType("main.bbl")).toBeNull();
});
const result = await scanProjectFolder("/project");
it("ignores binary and non-text files", () => {
expect(getFileType("document.docx")).toBeNull();
expect(getFileType("spreadsheet.xlsx")).toBeNull();
expect(getFileType("report.hwp")).toBeNull();
expect(getFileType("data.accdb")).toBeNull();
expect(getFileType("archive.zip")).toBeNull();
expect(getFileType("app.exe")).toBeNull();
expect(getFileType("song.mp3")).toBeNull();
expect(getFileType("video.mp4")).toBeNull();
expect(getFileType("image.psd")).toBeNull();
expect(getFileType("database.sqlite")).toBeNull();
expect(getFileType("library.dll")).toBeNull();
expect(getFileType("presentation.pptx")).toBeNull();
});
expect(readDir).toHaveBeenCalledWith("/project");
expect(readDir).toHaveBeenCalledWith("/project/chapters");
expect(readDir).not.toHaveBeenCalledWith("/project/__pycache__");
expect(readDir).not.toHaveBeenCalledWith("/project/node_modules");
expect(result.folders).toEqual(["chapters"]);
expect(result.files.map((file) => file.relativePath)).toEqual([
"main.tex",
"chapters/intro.tex",
]);
});
it("keeps arbitrary file formats visible as other files", async () => {
vi.mocked(readDir).mockResolvedValue([
{ name: "worker.py", isDirectory: false },
{ name: "notes.txt", isDirectory: false },
] as any);
vi.mocked(stat).mockResolvedValue({ size: 128 } as any);
const result = await scanProjectFolder("/project");
expect(result.files.map((file) => file.relativePath)).toEqual([
"worker.py",
"notes.txt",
]);
expect(stat).toHaveBeenCalledTimes(2);
expect(result.files.every((file) => file.type === "other")).toBe(true);
});
it("classifies unknown extensions as other", () => {
expect(getFileType("readme.txt")).toBe("other");
expect(getFileType("notes.md")).toBe("other");
expect(getFileType("data.csv")).toBe("other");
});
});

View file

@ -67,12 +67,7 @@ describe("template-registry", () => {
});
it("returns templates for each category", () => {
for (const cat of [
"academic",
"professional",
"creative",
"starter",
] as const) {
for (const cat of ["academic", "professional", "creative", "starter"] as const) {
expect(getTemplatesByCategory(cat).length).toBeGreaterThan(0);
}
});

View file

@ -1,54 +1,11 @@
import { vi } from "vitest";
const storageData = new Map<string, string>();
const mockStorage = {
getItem: vi.fn((key: string) => storageData.get(key) ?? null),
setItem: vi.fn((key: string, value: string) => {
storageData.set(key, value);
}),
removeItem: vi.fn((key: string) => {
storageData.delete(key);
}),
clear: vi.fn(() => {
storageData.clear();
}),
key: vi.fn((index: number) => Array.from(storageData.keys())[index] ?? null),
get length() {
return storageData.size;
},
};
const mockWebview = {
setZoom: vi.fn(() => Promise.resolve()),
onDragDropEvent: vi.fn(() => Promise.resolve(() => {})),
};
Object.defineProperty(window, "localStorage", {
value: mockStorage,
configurable: true,
});
Object.defineProperty(globalThis, "localStorage", {
value: mockStorage,
configurable: true,
});
// Mock @tauri-apps/api/core
vi.mock("@tauri-apps/api/core", () => ({
invoke: vi.fn(),
convertFileSrc: vi.fn((path: string) => `asset://localhost/${path}`),
}));
// Mock @tauri-apps/api/event
vi.mock("@tauri-apps/api/event", () => ({
emit: vi.fn(() => Promise.resolve()),
listen: vi.fn(() => Promise.resolve(() => {})),
}));
// Mock @tauri-apps/api/webview
vi.mock("@tauri-apps/api/webview", () => ({
getCurrentWebview: vi.fn(() => mockWebview),
}));
// Mock @tauri-apps/api/path
vi.mock("@tauri-apps/api/path", () => ({
join: vi.fn((...args: string[]) => Promise.resolve(args.join("/"))),
@ -59,7 +16,6 @@ vi.mock("@tauri-apps/plugin-fs", () => ({
readTextFile: vi.fn(),
writeTextFile: vi.fn(),
readDir: vi.fn(),
stat: vi.fn(),
exists: vi.fn(),
mkdir: vi.fn(),
readFile: vi.fn(),

View file

@ -1,444 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { invoke } from "@tauri-apps/api/core";
const { mockDocumentState, getDocumentState, createSnapshotMock } = vi.hoisted(
() => ({
mockDocumentState: {} as any,
getDocumentState: vi.fn(),
createSnapshotMock: vi.fn(() => Promise.resolve(null)),
}),
);
vi.mock("@/stores/document-store", () => ({
useDocumentStore: {
getState: getDocumentState,
},
}));
vi.mock("@/stores/history-store", () => ({
useHistoryStore: {
getState: vi.fn(() => ({
createSnapshot: createSnapshotMock,
})),
},
}));
import {
CLAUDE_CODE_PROVIDER_ID,
useClaudeChatStore,
} from "@/stores/claude-chat-store";
function resetClaudeChatStore() {
useClaudeChatStore.setState({
messages: [],
sessionId: null,
isStreaming: false,
streamingStartedAt: null,
error: null,
totalInputTokens: 0,
totalOutputTokens: 0,
tabs: [
{
id: "tab-default",
title: "New Chat",
projectPath: "/project",
sessionId: null,
providerKey: CLAUDE_CODE_PROVIDER_ID,
sessionProviderKey: null,
messages: [],
isStreaming: false,
streamingStartedAt: null,
error: null,
totalInputTokens: 0,
totalOutputTokens: 0,
draft: { input: "", pinnedContexts: [] },
},
],
activeTabId: "tab-default",
activeProjectPath: "/project",
pendingInitialPrompt: null,
pendingAttachments: [],
pendingPinnedContextRemovalLabels: [],
selectedModel: "opus",
selectedProviderCredentialId: CLAUDE_CODE_PROVIDER_ID,
selectedProviderModels: {},
effortLevel: "medium",
_cancelledByUser: false,
});
}
function setMockDocumentState(overrides: Partial<any> = {}) {
const content = ["Line 1", "Line 2", "Line 3", "Line 4"].join("\n");
const state = {
projectRoot: "/project",
files: [
{
id: "main.tex",
name: "main.tex",
relativePath: "main.tex",
absolutePath: "/project/main.tex",
type: "tex",
content,
isDirty: false,
},
],
activeFileId: "main.tex",
selectionRange: null,
saveAllFiles: vi.fn(() => Promise.resolve()),
refreshFiles: vi.fn(() => Promise.resolve()),
reloadFile: vi.fn(() => Promise.resolve()),
...overrides,
};
Object.keys(mockDocumentState).forEach(
(key) => delete mockDocumentState[key],
);
Object.assign(mockDocumentState, state);
getDocumentState.mockImplementation(() => mockDocumentState);
return state;
}
describe("useClaudeChatStore.sendPrompt context assembly", () => {
beforeEach(() => {
vi.clearAllMocks();
resetClaudeChatStore();
setMockDocumentState();
});
it("uses a plain file label and full file content for whole-file mentions", async () => {
const wholeFileText =
"\\section{Intro}\nThis is the full file.\n\\textbf{Important note}";
await useClaudeChatStore.getState().sendPrompt("Please revise this", {
label: "@main.tex",
filePath: "main.tex",
selectedText: wholeFileText,
});
expect(invoke).toHaveBeenCalledWith(
"execute_claude_code",
expect.objectContaining({
projectPath: "/project",
tabId: "tab-default",
prompt: expect.stringContaining("[Selection: @main.tex]"),
}),
);
const prompt = (vi.mocked(invoke).mock.calls[0]?.[1] as any)
?.prompt as string;
expect(prompt).toContain("[Currently open file: main.tex]");
expect(prompt).toContain("[Selection: @main.tex]");
expect(prompt).toContain(wholeFileText);
const userText =
useClaudeChatStore.getState().messages[0].message?.content?.[0].text;
expect(userText).toBe("@main.tex\nPlease revise this");
});
it("uses a line-range label and only the selected slice for selection context", async () => {
const state = setMockDocumentState({
files: [
{
id: "main.tex",
name: "main.tex",
relativePath: "main.tex",
absolutePath: "/project/main.tex",
type: "tex",
content: "alpha\nbeta\ngamma\ndelta",
isDirty: false,
},
],
selectionRange: { start: 6, end: 16 },
});
await useClaudeChatStore.getState().sendPrompt("Please revise this");
expect(invoke).toHaveBeenCalledWith(
"execute_claude_code",
expect.objectContaining({
projectPath: "/project",
tabId: "tab-default",
prompt: expect.stringContaining("[Selection: @main.tex:2:1-3:6]"),
}),
);
const prompt = (vi.mocked(invoke).mock.calls[0]?.[1] as any)
?.prompt as string;
expect(prompt).toContain("[Currently open file: main.tex]");
expect(prompt).toContain("[Selection: @main.tex:2:1-3:6]");
expect(prompt).toContain("[Selected text:\nbeta\ngamma\n]");
expect(prompt).not.toContain("alpha\na");
expect(prompt).not.toContain("\ndelta");
const userText =
useClaudeChatStore.getState().messages[0].message?.content?.[0].text;
expect(userText).toBe("@main.tex:2:1-3:6\nPlease revise this");
expect(state.saveAllFiles).not.toHaveBeenCalled();
expect(createSnapshotMock).toHaveBeenCalledWith(
"/project",
"[claude] Before Claude edit",
);
});
it("sends Claude Code when the Claude provider option is selected", async () => {
useClaudeChatStore.setState({
selectedProviderCredentialId: CLAUDE_CODE_PROVIDER_ID,
});
await useClaudeChatStore.getState().sendPrompt("Use Claude");
expect(invoke).toHaveBeenCalledWith(
"execute_claude_code",
expect.objectContaining({
providerCredentialId: null,
providerModelOverride: null,
}),
);
});
it("starts Claude Code with prior context when switching from a direct provider", async () => {
useClaudeChatStore.setState((state) => ({
sessionId: "qwen-session",
selectedProviderCredentialId: CLAUDE_CODE_PROVIDER_ID,
tabs: state.tabs.map((tab) =>
tab.id === "tab-default"
? {
...tab,
sessionId: "qwen-session",
providerKey: CLAUDE_CODE_PROVIDER_ID,
sessionProviderKey: "openai-compatible:qwen-cred",
messages: [
{
type: "user",
message: {
content: [{ type: "text", text: "Old DS question" }],
},
},
{
type: "assistant",
message: {
content: [{ type: "text", text: "Old DS answer" }],
},
},
],
}
: tab,
),
}));
await useClaudeChatStore.getState().sendPrompt("Use Claude now");
expect(invoke).toHaveBeenCalledWith(
"execute_claude_code",
expect.objectContaining({
providerCredentialId: null,
providerModelOverride: null,
prompt: expect.stringContaining("[Provider switch context]"),
}),
);
const prompt = (vi.mocked(invoke).mock.calls[0]?.[1] as any).prompt;
expect(prompt).toContain("Old DS question");
expect(prompt).toContain("Old DS answer");
expect(prompt).toContain("Use Claude now");
expect(
vi
.mocked(invoke)
.mock.calls.some(([command]) => command === "resume_claude_code"),
).toBe(false);
});
it("keeps the same backend session when switching between OpenAI-compatible providers", async () => {
useClaudeChatStore.setState((state) => ({
sessionId: "shared-session",
selectedProviderCredentialId: "deepseek-cred",
selectedProviderModels: { "deepseek-cred": "deepseek-chat" },
tabs: state.tabs.map((tab) =>
tab.id === "tab-default"
? {
...tab,
sessionId: "shared-session",
providerKey: "openai-compatible:deepseek-cred",
sessionProviderKey: "openai-compatible:qwen-cred",
}
: tab,
),
}));
await useClaudeChatStore.getState().sendPrompt("Use DeepSeek now");
expect(invoke).toHaveBeenCalledWith(
"resume_claude_code",
expect.objectContaining({
sessionId: "shared-session",
providerCredentialId: "deepseek-cred",
providerModelOverride: "deepseek-chat",
}),
);
});
it("passes an OpenAI-compatible model override with the provider credential", async () => {
useClaudeChatStore.getState().setSelectedProviderCredentialId("qwen-cred");
useClaudeChatStore.setState({
selectedProviderModels: { "qwen-cred": "qwen3.7-plus" },
});
await useClaudeChatStore.getState().sendPrompt("Use Qwen");
expect(invoke).toHaveBeenCalledWith(
"execute_claude_code",
expect.objectContaining({
providerCredentialId: "qwen-cred",
providerModelOverride: "qwen3.7-plus",
}),
);
});
});
describe("useClaudeChatStore.resumeSession", () => {
beforeEach(() => {
vi.clearAllMocks();
resetClaudeChatStore();
setMockDocumentState();
});
it("restores token totals from loaded session history", async () => {
vi.mocked(invoke).mockResolvedValueOnce([
{
type: "user",
message: { content: [{ type: "text", text: "hello" }] },
},
{
type: "assistant",
message: {
content: [{ type: "text", text: "hi" }],
usage: { input_tokens: 11, output_tokens: 7 },
},
},
{
type: "result",
subtype: "success",
usage: { input_tokens: 13, output_tokens: 5 },
},
]);
await useClaudeChatStore.getState().resumeSession("session-123");
expect(invoke).toHaveBeenCalledWith("load_session_history", {
projectPath: "/project",
sessionId: "session-123",
});
const state = useClaudeChatStore.getState();
expect(state.sessionId).toBe("session-123");
expect(state.messages).toHaveLength(3);
expect(state.totalInputTokens).toBe(24);
expect(state.totalOutputTokens).toBe(12);
});
it("does not reuse a tab from another project with the same session id", async () => {
vi.mocked(invoke).mockResolvedValueOnce([
{
type: "user",
message: { content: [{ type: "text", text: "from current project" }] },
},
]);
useClaudeChatStore.setState((state) => {
const baseTab = state.tabs[0];
return {
tabs: [
{
...baseTab,
id: "tab-current",
projectPath: "/project",
sessionId: null,
messages: [],
},
{
...baseTab,
id: "tab-other",
title: "Other project",
projectPath: "/other-project",
sessionId: "shared-session-id",
messages: [
{
type: "user",
message: {
content: [{ type: "text", text: "from another project" }],
},
},
],
},
],
activeTabId: "tab-current",
activeProjectPath: "/project",
messages: [],
sessionId: null,
};
});
await useClaudeChatStore.getState().resumeSession("shared-session-id");
expect(invoke).toHaveBeenCalledWith("load_session_history", {
projectPath: "/project",
sessionId: "shared-session-id",
});
const state = useClaudeChatStore.getState();
const otherProjectTab = state.tabs.find((tab) => tab.id === "tab-other");
expect(state.activeTabId).toBe("tab-current");
expect(state.activeProjectPath).toBe("/project");
expect(state.messages[0].message?.content?.[0].text).toBe(
"from current project",
);
expect(otherProjectTab?.messages[0].message?.content?.[0].text).toBe(
"from another project",
);
});
it("hides internal file and pasted-image context when restoring history", async () => {
const tempImagePath = [
"C:\\Temp",
"ClaudePrism",
"chat-pastes",
"1781110224092-1-paste-1781110223586-1.png",
].join("\\");
const restoredPrompt = [
"[Currently open file: main.tex]",
"[Selection: Pasted image]",
"[Selected text:",
`[Temporary pasted image: ${tempImagePath}]`,
"Use this image file as visual context for the user's message.",
"]",
"",
"Please inspect this image",
].join("\n");
vi.mocked(invoke).mockResolvedValueOnce([
{
type: "user",
message: {
content: restoredPrompt,
},
},
{
type: "assistant",
message: {
content: [{ type: "text", text: "OK" }],
},
},
]);
await useClaudeChatStore.getState().resumeSession("session-with-image");
const state = useClaudeChatStore.getState();
const userContent = state.messages[0].message?.content as any;
const activeTab = state.tabs.find((tab) => tab.id === state.activeTabId);
expect(userContent).toBe("Pasted image\nPlease inspect this image");
expect(userContent).not.toContain("[Currently open file:");
expect(userContent).not.toContain("[Temporary pasted image:");
expect(activeTab?.title).toBe("Please inspect this image");
});
});

View file

@ -1,17 +1,5 @@
import { beforeEach, describe, expect, it } from "vitest";
import {
CLAUDE_CODE_PROVIDER_ID,
SELECTED_PROVIDER_CREDENTIAL_STORAGE_KEY,
loadSelectedProviderCredentialId,
offsetToLineCol,
useClaudeChatStore,
} from "@/stores/claude-chat-store";
beforeEach(() => {
localStorage.clear();
sessionStorage.clear();
useClaudeChatStore.setState({ selectedProviderCredentialId: null });
});
import { describe, it, expect } from "vitest";
import { offsetToLineCol } from "@/stores/claude-chat-store";
describe("offsetToLineCol", () => {
it("returns line 1, col 1 for offset 0 on empty string", () => {
@ -50,258 +38,3 @@ describe("offsetToLineCol", () => {
expect(offsetToLineCol("\n\n", 2)).toEqual({ line: 3, col: 1 });
});
});
describe("provider selection persistence", () => {
it("persists Claude Code as an explicit provider selection", () => {
useClaudeChatStore
.getState()
.setSelectedProviderCredentialId(CLAUDE_CODE_PROVIDER_ID);
expect(
sessionStorage.getItem(SELECTED_PROVIDER_CREDENTIAL_STORAGE_KEY),
).toBe(CLAUDE_CODE_PROVIDER_ID);
expect(
localStorage.getItem(SELECTED_PROVIDER_CREDENTIAL_STORAGE_KEY),
).toBeNull();
expect(loadSelectedProviderCredentialId()).toBe(CLAUDE_CODE_PROVIDER_ID);
});
it("persists and clears OpenAI-compatible provider selections", () => {
useClaudeChatStore.getState().setSelectedProviderCredentialId("qwen");
expect(
sessionStorage.getItem(SELECTED_PROVIDER_CREDENTIAL_STORAGE_KEY),
).toBe("qwen");
expect(
localStorage.getItem(SELECTED_PROVIDER_CREDENTIAL_STORAGE_KEY),
).toBeNull();
useClaudeChatStore.getState().setSelectedProviderCredentialId(null);
expect(
sessionStorage.getItem(SELECTED_PROVIDER_CREDENTIAL_STORAGE_KEY),
).toBeNull();
expect(loadSelectedProviderCredentialId()).toBeNull();
});
it("keeps provider selections isolated between chat tabs", () => {
const store = useClaudeChatStore.getState();
const firstTabId = store.activeTabId;
store.setSelectedProviderCredentialId("qwen");
const secondTabId = store.createTab();
useClaudeChatStore.getState().setSelectedProviderCredentialId("gemini");
expect(useClaudeChatStore.getState().selectedProviderCredentialId).toBe(
"gemini",
);
useClaudeChatStore.getState().setActiveTab(firstTabId);
expect(useClaudeChatStore.getState().selectedProviderCredentialId).toBe(
"qwen",
);
useClaudeChatStore.getState().setActiveTab(secondTabId);
expect(useClaudeChatStore.getState().selectedProviderCredentialId).toBe(
"gemini",
);
});
});
describe("project-scoped chat state", () => {
it("resets tabs for a new project without clearing a pending initial prompt", () => {
useClaudeChatStore.setState((state) => {
const baseTab = state.tabs[0];
const message = {
type: "user" as const,
message: { content: [{ type: "text" as const, text: "old project" }] },
};
return {
pendingInitialPrompt: "build this project",
pendingAttachments: [
{
label: "old attachment",
filePath: "/project-a/old.png",
selectedText: "old",
},
],
pendingPinnedContextRemovalLabels: ["@old.tex"],
activeProjectPath: "/project-a",
activeTabId: "tab-project-a",
sessionId: "session-project-a",
messages: [message],
tabs: [
{
...baseTab,
id: "tab-project-a",
title: "Old project chat",
projectPath: "/project-a",
sessionId: "session-project-a",
messages: [message],
},
],
};
});
useClaudeChatStore.getState().resetForProject("/project-b");
const state = useClaudeChatStore.getState();
const activeTab = state.tabs.find((tab) => tab.id === state.activeTabId);
expect(state.activeProjectPath).toBe("/project-b");
expect(activeTab?.projectPath).toBe("/project-b");
expect(state.sessionId).toBeNull();
expect(state.messages).toEqual([]);
expect(state.pendingAttachments).toEqual([]);
expect(state.pendingPinnedContextRemovalLabels).toEqual([]);
expect(state.pendingInitialPrompt).toBe("build this project");
});
});
describe("pinned context removal requests", () => {
it("queues and consumes pinned context labels to remove", () => {
const chat = useClaudeChatStore.getState();
chat.requestPinnedContextRemoval(["@main.tex:1:1-1:5"]);
chat.requestPinnedContextRemoval(["@main.tex:2:1-2:5"]);
expect(
useClaudeChatStore.getState().pendingPinnedContextRemovalLabels,
).toEqual(["@main.tex:1:1-1:5", "@main.tex:2:1-2:5"]);
expect(
useClaudeChatStore.getState().consumePendingPinnedContextRemovals(),
).toEqual(["@main.tex:1:1-1:5", "@main.tex:2:1-2:5"]);
expect(
useClaudeChatStore.getState().pendingPinnedContextRemovalLabels,
).toEqual([]);
});
});
describe("queued guidance", () => {
it("queues and consumes guidance for the active tab", () => {
const chat = useClaudeChatStore.getState();
const tabId = chat.activeTabId;
chat.clearQueuedGuidance(tabId);
chat.queueGuidance(tabId, "please focus on the API key deletion flow", {
label: "@main.tex:1:1-1:8",
filePath: "main.tex",
selectedText: "selected",
});
expect(
useClaudeChatStore.getState().tabs.find((tab) => tab.id === tabId)
?.queuedGuidance,
).toHaveLength(1);
const queued = useClaudeChatStore.getState().consumeQueuedGuidance(tabId);
expect(queued?.prompt).toBe("please focus on the API key deletion flow");
expect(queued?.contextOverride?.filePath).toBe("main.tex");
expect(
useClaudeChatStore.getState().tabs.find((tab) => tab.id === tabId)
?.queuedGuidance,
).toHaveLength(0);
});
it("can remove and consume a specific queued guidance item", () => {
const chat = useClaudeChatStore.getState();
const tabId = chat.activeTabId;
chat.clearQueuedGuidance(tabId);
chat.queueGuidance(tabId, "first");
chat.queueGuidance(tabId, "second");
chat.queueGuidance(tabId, "third");
const queue = useClaudeChatStore
.getState()
.tabs.find((tab) => tab.id === tabId)?.queuedGuidance;
expect(queue?.map((item) => item.prompt)).toEqual([
"first",
"second",
"third",
]);
chat.removeQueuedGuidance(tabId, queue![1].id);
expect(
useClaudeChatStore
.getState()
.tabs.find((tab) => tab.id === tabId)
?.queuedGuidance?.map((item) => item.prompt),
).toEqual(["first", "third"]);
const thirdId = useClaudeChatStore
.getState()
.tabs.find((tab) => tab.id === tabId)?.queuedGuidance?.[1].id;
const selected = chat.consumeQueuedGuidance(tabId, thirdId);
expect(selected?.prompt).toBe("third");
expect(
useClaudeChatStore
.getState()
.tabs.find((tab) => tab.id === tabId)
?.queuedGuidance?.map((item) => item.prompt),
).toEqual(["first"]);
});
it("marks multiple queued guidance items as displayed in chat", () => {
const chat = useClaudeChatStore.getState();
const tabId = chat.activeTabId;
chat.clearQueuedGuidance(tabId);
chat.queueGuidance(tabId, "first");
chat.queueGuidance(tabId, "second");
chat.queueGuidance(tabId, "third");
const queue = useClaudeChatStore
.getState()
.tabs.find((tab) => tab.id === tabId)?.queuedGuidance;
const secondId = queue?.[1].id;
const thirdId = queue?.[2].id;
expect(
useClaudeChatStore
.getState()
.displayQueuedGuidanceInChat(tabId, secondId),
).toBe(secondId);
expect(
useClaudeChatStore.getState().displayQueuedGuidanceInChat(tabId, thirdId),
).toBe(thirdId);
expect(
useClaudeChatStore
.getState()
.tabs.find((tab) => tab.id === tabId)
?.queuedGuidance?.map((item) => ({
prompt: item.prompt,
displayedInChat: item.displayedInChat ?? false,
})),
).toEqual([
{ prompt: "first", displayedInChat: false },
{ prompt: "second", displayedInChat: true },
{ prompt: "third", displayedInChat: true },
]);
});
it("consumes displayed guidance before ordinary queued guidance", () => {
const chat = useClaudeChatStore.getState();
const tabId = chat.activeTabId;
chat.clearQueuedGuidance(tabId);
chat.queueGuidance(tabId, "first");
chat.queueGuidance(tabId, "second");
const secondId = useClaudeChatStore
.getState()
.tabs.find((tab) => tab.id === tabId)?.queuedGuidance?.[1].id;
useClaudeChatStore.getState().displayQueuedGuidanceInChat(tabId, secondId);
const selected = chat.consumeQueuedGuidance(tabId);
expect(selected?.prompt).toBe("second");
expect(
useClaudeChatStore
.getState()
.tabs.find((tab) => tab.id === tabId)
?.queuedGuidance?.map((item) => item.prompt),
).toEqual(["first"]);
});
});

View file

@ -1,6 +1,4 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { invoke } from "@tauri-apps/api/core";
import { useClaudeSetupStore } from "@/stores/claude-setup-store";
import { describe, it, expect } from "vitest";
// advanceSteps is module-private — replicate for testing
type StepStatus = "pending" | "active" | "complete" | "error";
@ -11,11 +9,7 @@ interface StepInfo {
status: StepStatus;
}
function advanceSteps(
steps: StepInfo[],
targetId: string,
order: string[],
): StepInfo[] {
function advanceSteps(steps: StepInfo[], targetId: string, order: string[]): StepInfo[] {
const targetIdx = order.indexOf(targetId);
return steps.map((s) => {
const thisIdx = order.indexOf(s.id);
@ -97,384 +91,3 @@ describe("advanceSteps", () => {
expect(result[2].status).toBe("pending");
});
});
describe("useClaudeSetupStore.saveApiKey", () => {
beforeEach(() => {
vi.clearAllMocks();
useClaudeSetupStore.setState({
status: "ready",
isInstalling: false,
isLoggingIn: false,
isSavingApiKey: false,
isClearingApiKey: false,
error: null,
version: "1.0.0",
providerKind: "claude-code",
accountEmail: null,
providerModel: null,
providerBaseUrl: null,
openAiCredentials: [],
activeOpenAiCredentialId: null,
installSteps: [],
installLogs: [],
installLogsVisible: false,
loginSteps: [],
});
});
it("requires Claude Code before saving provider credentials", async () => {
useClaudeSetupStore.setState({ status: "not-installed" });
const success = await useClaudeSetupStore
.getState()
.saveApiKey(
"sk-test",
"https://api.deepseek.com/anthropic",
"openai-compatible",
"deepseek-v4-pro",
);
expect(success).toBe(false);
expect(invoke).not.toHaveBeenCalled();
expect(useClaudeSetupStore.getState().error).toBe(
"Install Claude Code before configuring an AI provider.",
);
});
it("verifies OpenAI-compatible credentials before saving them", async () => {
vi.mocked(invoke).mockImplementation(async (command) => {
if (command === "check_claude_status") {
return {
installed: true,
authenticated: true,
binary_path: null,
version: "OpenAI-compatible provider",
provider_kind: "openai-compatible",
account_email: null,
provider_model: "deepseek-v4-pro",
provider_base_url: "https://api.deepseek.com/anthropic",
missing_git: false,
};
}
if (command === "list_openai_compatible_credentials") {
return [
{
id: "cred-1",
label: "DeepSeek",
model: "deepseek-v4-pro",
base_url: "https://api.deepseek.com/anthropic",
},
];
}
return null;
});
const success = await useClaudeSetupStore
.getState()
.saveApiKey(
"sk-test",
"https://api.deepseek.com/anthropic",
"openai-compatible",
"deepseek-v4-pro",
);
expect(success).toBe(true);
expect(invoke).toHaveBeenNthCalledWith(
1,
"verify_openai_compatible_api_key",
{
apiKey: "sk-test",
baseUrl: "https://api.deepseek.com/anthropic",
model: "deepseek-v4-pro",
},
);
expect(invoke).toHaveBeenNthCalledWith(2, "save_anthropic_api_key", {
apiKey: "sk-test",
baseUrl: "https://api.deepseek.com/anthropic",
provider: "openai-compatible",
model: "deepseek-v4-pro",
credentialLabel: null,
});
});
it("normalizes legacy DeepSeek root URLs to the native Anthropic endpoint", async () => {
vi.mocked(invoke).mockResolvedValue(null);
const success = await useClaudeSetupStore
.getState()
.saveApiKey(
"sk-test",
"https://api.deepseek.com",
"openai-compatible",
"deepseek-v4-pro",
);
expect(success).toBe(true);
expect(invoke).toHaveBeenNthCalledWith(
1,
"verify_openai_compatible_api_key",
{
apiKey: "sk-test",
baseUrl: "https://api.deepseek.com/anthropic",
model: "deepseek-v4-pro",
},
);
expect(invoke).toHaveBeenNthCalledWith(2, "save_anthropic_api_key", {
apiKey: "sk-test",
baseUrl: "https://api.deepseek.com/anthropic",
provider: "openai-compatible",
model: "deepseek-v4-pro",
credentialLabel: null,
});
});
it("normalizes legacy Qwen compatible URLs to the native Anthropic endpoint", async () => {
vi.mocked(invoke).mockResolvedValue(null);
const success = await useClaudeSetupStore
.getState()
.saveApiKey(
"sk-test",
"https://dashscope.aliyuncs.com/compatible-mode/v1",
"openai-compatible",
"qwen3-max-2026-01-23",
);
expect(success).toBe(true);
expect(invoke).toHaveBeenNthCalledWith(
1,
"verify_openai_compatible_api_key",
{
apiKey: "sk-test",
baseUrl: "https://dashscope.aliyuncs.com/apps/anthropic",
model: "qwen3-max-2026-01-23",
},
);
expect(invoke).toHaveBeenNthCalledWith(2, "save_anthropic_api_key", {
apiKey: "sk-test",
baseUrl: "https://dashscope.aliyuncs.com/apps/anthropic",
provider: "openai-compatible",
model: "qwen3-max-2026-01-23",
credentialLabel: null,
});
});
it("preserves Qwen native Anthropic URLs when saving credentials", async () => {
vi.mocked(invoke).mockResolvedValue(null);
const success = await useClaudeSetupStore
.getState()
.saveApiKey(
"sk-test",
"https://dashscope.aliyuncs.com/apps/anthropic/v1",
"openai-compatible",
"qwen3-max-2026-01-23",
);
expect(success).toBe(true);
expect(invoke).toHaveBeenNthCalledWith(
1,
"verify_openai_compatible_api_key",
{
apiKey: "sk-test",
baseUrl: "https://dashscope.aliyuncs.com/apps/anthropic",
model: "qwen3-max-2026-01-23",
},
);
expect(invoke).toHaveBeenNthCalledWith(2, "save_anthropic_api_key", {
apiKey: "sk-test",
baseUrl: "https://dashscope.aliyuncs.com/apps/anthropic",
provider: "openai-compatible",
model: "qwen3-max-2026-01-23",
credentialLabel: null,
});
});
it("normalizes Moonshot compatible URLs to the native Anthropic endpoint", async () => {
vi.mocked(invoke).mockResolvedValue(null);
const success = await useClaudeSetupStore
.getState()
.saveApiKey(
"sk-test",
"https://api.moonshot.cn/v1",
"openai-compatible",
"kimi-k2.5",
);
expect(success).toBe(true);
expect(invoke).toHaveBeenNthCalledWith(
1,
"verify_openai_compatible_api_key",
{
apiKey: "sk-test",
baseUrl: "https://api.moonshot.ai/anthropic",
model: "kimi-k2.5",
},
);
expect(invoke).toHaveBeenNthCalledWith(2, "save_anthropic_api_key", {
apiKey: "sk-test",
baseUrl: "https://api.moonshot.ai/anthropic",
provider: "openai-compatible",
model: "kimi-k2.5",
credentialLabel: null,
});
});
it("preserves Moonshot Anthropic-looking URLs when saving credentials", async () => {
vi.mocked(invoke).mockResolvedValue(null);
const success = await useClaudeSetupStore
.getState()
.saveApiKey(
"sk-test",
"https://api.moonshot.ai/anthropic/v1",
"openai-compatible",
"kimi-k2.5",
);
expect(success).toBe(true);
expect(invoke).toHaveBeenNthCalledWith(
1,
"verify_openai_compatible_api_key",
{
apiKey: "sk-test",
baseUrl: "https://api.moonshot.ai/anthropic",
model: "kimi-k2.5",
},
);
expect(invoke).toHaveBeenNthCalledWith(2, "save_anthropic_api_key", {
apiKey: "sk-test",
baseUrl: "https://api.moonshot.ai/anthropic",
provider: "openai-compatible",
model: "kimi-k2.5",
credentialLabel: null,
});
});
it("allows local OpenAI-compatible providers without an API key", async () => {
vi.mocked(invoke).mockImplementation(async (command) => {
if (command === "check_claude_status") {
return {
installed: true,
authenticated: true,
binary_path: null,
version: "OpenAI-compatible provider",
provider_kind: "openai-compatible",
account_email: null,
provider_model: "llama3.2",
provider_base_url: "http://localhost:11434/v1",
missing_git: false,
};
}
if (command === "list_openai_compatible_credentials") {
return [
{
id: "ollama-cred",
label: "Ollama",
model: "llama3.2",
base_url: "http://localhost:11434/v1",
},
];
}
return null;
});
const success = await useClaudeSetupStore
.getState()
.saveApiKey(
"",
"http://localhost:11434/v1",
"openai-compatible",
"llama3.2",
"Ollama",
);
expect(success).toBe(true);
expect(invoke).toHaveBeenNthCalledWith(
1,
"verify_openai_compatible_api_key",
{
apiKey: "",
baseUrl: "http://localhost:11434/v1",
model: "llama3.2",
},
);
expect(invoke).toHaveBeenNthCalledWith(2, "save_anthropic_api_key", {
apiKey: "",
baseUrl: "http://localhost:11434/v1",
provider: "openai-compatible",
model: "llama3.2",
credentialLabel: "Ollama",
});
});
it("does not save OpenAI-compatible credentials when verification fails", async () => {
vi.mocked(invoke).mockRejectedValueOnce(
new Error("Invalid provider API key"),
);
const success = await useClaudeSetupStore
.getState()
.saveApiKey(
"sk-test",
"https://api.deepseek.com/anthropic",
"openai-compatible",
"deepseek-v4-pro",
);
expect(success).toBe(false);
expect(invoke).toHaveBeenCalledTimes(1);
expect(invoke).toHaveBeenCalledWith("verify_openai_compatible_api_key", {
apiKey: "sk-test",
baseUrl: "https://api.deepseek.com/anthropic",
model: "deepseek-v4-pro",
});
expect(useClaudeSetupStore.getState().error).toBe(
"Invalid provider API key",
);
});
it("clears saved credentials and refreshes status", async () => {
useClaudeSetupStore.setState({
status: "ready",
version: "OpenAI-compatible provider",
providerKind: "openai-compatible",
providerModel: "qwen3-coder-plus",
providerBaseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
});
vi.mocked(invoke).mockImplementation(async (command) => {
if (command === "check_claude_status") {
return {
installed: true,
authenticated: false,
binary_path: null,
version: "1.0.0",
provider_kind: "claude-code",
account_email: null,
provider_model: null,
provider_base_url: null,
missing_git: false,
};
}
if (command === "list_openai_compatible_credentials") {
return [];
}
return null;
});
const success = await useClaudeSetupStore.getState().clearApiKey();
expect(success).toBe(true);
expect(invoke).toHaveBeenNthCalledWith(1, "clear_anthropic_api_key");
expect(invoke).toHaveBeenNthCalledWith(2, "check_claude_status");
expect(invoke).toHaveBeenNthCalledWith(
3,
"list_openai_compatible_credentials",
);
expect(useClaudeSetupStore.getState().status).toBe("not-authenticated");
expect(useClaudeSetupStore.getState().providerModel).toBeNull();
expect(useClaudeSetupStore.getState().providerBaseUrl).toBeNull();
expect(useClaudeSetupStore.getState().isClearingApiKey).toBe(false);
});
});

View file

@ -1,20 +1,6 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { invoke } from "@tauri-apps/api/core";
import {
readDir,
readTextFile,
rename,
stat,
writeTextFile,
} from "@tauri-apps/plugin-fs";
import {
useDocumentStore,
getCurrentPdfBytes,
getCurrentPdfRootId,
clearPdfBytesCache,
type ProjectFile,
} from "@/stores/document-store";
import { useProjectStore } from "@/stores/project-store";
import { writeTextFile } from "@tauri-apps/plugin-fs";
import { useDocumentStore, getCurrentPdfBytes, clearPdfBytesCache, type ProjectFile } from "@/stores/document-store";
// Mock history store
vi.mock("@/stores/history-store", () => ({
@ -23,7 +9,6 @@ vi.mock("@/stores/history-store", () => ({
init: vi.fn(() => Promise.resolve()),
loadSnapshots: vi.fn(() => Promise.resolve()),
createSnapshot: vi.fn(() => Promise.resolve()),
reset: vi.fn(),
})),
},
}));
@ -52,7 +37,6 @@ function makeFile(overrides: Partial<ProjectFile> = {}): ProjectFile {
describe("useDocumentStore", () => {
beforeEach(() => {
vi.clearAllMocks();
clearPdfBytesCache();
useDocumentStore.setState({
projectRoot: "/project",
@ -70,10 +54,6 @@ describe("useDocumentStore", () => {
isSaving: false,
initialized: true,
});
useProjectStore.setState({
recentProjects: [],
lastProjectFolder: null,
});
});
describe("getActiveFile logic", () => {
@ -81,9 +61,7 @@ describe("useDocumentStore", () => {
// Validates that getActiveFile() correctly resolves the active file
// by confirming insertAtCursor modifies the right file's content
useDocumentStore.getState().insertAtCursor("!");
const file = useDocumentStore
.getState()
.files.find((f) => f.id === "main.tex")!;
const file = useDocumentStore.getState().files.find((f) => f.id === "main.tex")!;
expect(file.content).toBe("Hello! World");
});
@ -91,149 +69,11 @@ describe("useDocumentStore", () => {
useDocumentStore.setState({ activeFileId: "nonexistent" });
useDocumentStore.getState().insertAtCursor("text");
// Files should not be modified
const file = useDocumentStore
.getState()
.files.find((f) => f.id === "main.tex")!;
const file = useDocumentStore.getState().files.find((f) => f.id === "main.tex")!;
expect(file.content).toBe("Hello World");
});
});
describe("openProject", () => {
it("re-authorizes the project directory before scanning", async () => {
const projectPath = "E:\\overleaf-cache\\论文项目";
let resolveAuthorization!: () => void;
const authorizationPromise = new Promise<void>((resolve) => {
resolveAuthorization = resolve;
});
vi.mocked(invoke).mockReturnValue(
authorizationPromise as ReturnType<typeof invoke>,
);
vi.mocked(readDir).mockResolvedValue([
{ name: "main.tex", isDirectory: false },
] as any);
vi.mocked(readTextFile).mockResolvedValue("\\documentclass{article}");
const openProjectPromise = useDocumentStore
.getState()
.openProject(projectPath);
expect(invoke).toHaveBeenCalledWith("allow_project_directory", {
rootPath: projectPath,
});
expect(readDir).not.toHaveBeenCalled();
resolveAuthorization();
await openProjectPromise;
expect(readDir).toHaveBeenCalled();
});
it("skips Python cache directories and bytecode files during open", async () => {
vi.mocked(invoke).mockResolvedValue(undefined as never);
vi.mocked(readDir).mockImplementation(async (dir: string | URL) => {
const dirPath = String(dir);
if (dirPath === "/project") {
return [
{ name: "__pycache__", isDirectory: true },
{ name: "main.tex", isDirectory: false },
{ name: "tool.py", isDirectory: false },
{ name: "compiled.pyc", isDirectory: false },
] as any;
}
throw new Error(`Unexpected readDir path: ${dirPath}`);
});
vi.mocked(stat).mockResolvedValue({ size: 32 } as any);
vi.mocked(readTextFile).mockImplementation(async (path: string | URL) => {
const filePath = String(path);
if (filePath === "/project/main.tex") {
return "\\documentclass{article}";
}
if (filePath === "/project/tool.py") {
return "print('hello')";
}
throw new Error(`Unexpected readTextFile path: ${filePath}`);
});
await useDocumentStore.getState().openProject("/project");
expect(readDir).toHaveBeenCalledWith("/project");
expect(readDir).not.toHaveBeenCalledWith("/project/__pycache__");
expect(stat).toHaveBeenCalledTimes(1);
expect(stat).toHaveBeenCalledWith("/project/tool.py");
expect(readTextFile).toHaveBeenCalledTimes(2);
expect(readTextFile).not.toHaveBeenCalledWith("/project/compiled.pyc");
expect(
useDocumentStore.getState().files.map((file) => file.relativePath),
).toEqual(["main.tex", "tool.py"]);
});
});
describe("renameProject", () => {
it("renames the project folder and reopens the new path", async () => {
vi.mocked(invoke).mockResolvedValue(undefined as never);
vi.mocked(readDir).mockResolvedValue([
{ name: "main.tex", isDirectory: false },
] as any);
vi.mocked(readTextFile).mockResolvedValue("\\documentclass{article}");
useProjectStore.setState({
recentProjects: [{ path: "/work/old", name: "old", lastOpened: 1 }],
lastProjectFolder: "/work",
});
useDocumentStore.setState({
projectRoot: "/work/old",
files: [makeFile({ absolutePath: "/work/old/main.tex" })],
});
await useDocumentStore.getState().renameProject("renamed");
expect(rename).toHaveBeenCalledWith("/work/old", "/work/renamed");
expect(invoke).toHaveBeenCalledWith("migrate_project_sessions", {
oldProjectPath: "/work/old",
newProjectPath: "/work/renamed",
});
expect(invoke).toHaveBeenCalledWith("allow_project_directory", {
rootPath: "/work/renamed",
});
expect(useDocumentStore.getState().projectRoot).toBe("/work/renamed");
expect(useProjectStore.getState().recentProjects[0]).toMatchObject({
path: "/work/renamed",
name: "renamed",
});
expect(
useProjectStore
.getState()
.recentProjects.some((project) => project.path === "/work/old"),
).toBe(false);
expect(useProjectStore.getState().lastProjectFolder).toBe("/work");
});
it("saves dirty files before renaming the project folder", async () => {
vi.mocked(invoke).mockResolvedValue(undefined as never);
vi.mocked(writeTextFile).mockResolvedValue(undefined);
vi.mocked(readDir).mockResolvedValue([
{ name: "main.tex", isDirectory: false },
] as any);
vi.mocked(readTextFile).mockResolvedValue("\\documentclass{article}");
useDocumentStore.setState({
projectRoot: "/work/old",
files: [
makeFile({
absolutePath: "/work/old/main.tex",
content: "dirty",
isDirty: true,
}),
],
});
await useDocumentStore.getState().renameProject("renamed");
expect(writeTextFile).toHaveBeenCalledWith("/work/old/main.tex", "dirty");
expect(rename).toHaveBeenCalledWith("/work/old", "/work/renamed");
});
});
describe("insertAtCursor", () => {
it("inserts text at cursor position", () => {
useDocumentStore.getState().insertAtCursor(", Beautiful");
@ -251,18 +91,14 @@ describe("useDocumentStore", () => {
it("inserts at beginning when cursor is at 0", () => {
useDocumentStore.setState({ cursorPosition: 0 });
useDocumentStore.getState().insertAtCursor(">> ");
const file = useDocumentStore
.getState()
.files.find((f) => f.id === "main.tex")!;
const file = useDocumentStore.getState().files.find((f) => f.id === "main.tex")!;
expect(file.content).toBe(">> Hello World");
});
it("inserts at end when cursor is at content length", () => {
useDocumentStore.setState({ cursorPosition: 11 }); // "Hello World".length
useDocumentStore.getState().insertAtCursor("!");
const file = useDocumentStore
.getState()
.files.find((f) => f.id === "main.tex")!;
const file = useDocumentStore.getState().files.find((f) => f.id === "main.tex")!;
expect(file.content).toBe("Hello World!");
});
@ -290,9 +126,7 @@ describe("useDocumentStore", () => {
it("replaces a range of text", () => {
// Replace "World" (indices 6-11) with "Universe"
useDocumentStore.getState().replaceSelection(6, 11, "Universe");
const file = useDocumentStore
.getState()
.files.find((f) => f.id === "main.tex")!;
const file = useDocumentStore.getState().files.find((f) => f.id === "main.tex")!;
expect(file.content).toBe("Hello Universe");
expect(file.isDirty).toBe(true);
});
@ -304,17 +138,13 @@ describe("useDocumentStore", () => {
it("can delete text (empty replacement)", () => {
useDocumentStore.getState().replaceSelection(5, 11, "");
const file = useDocumentStore
.getState()
.files.find((f) => f.id === "main.tex")!;
const file = useDocumentStore.getState().files.find((f) => f.id === "main.tex")!;
expect(file.content).toBe("Hello");
});
it("can insert at a point (start === end)", () => {
useDocumentStore.getState().replaceSelection(5, 5, " Beautiful");
const file = useDocumentStore
.getState()
.files.find((f) => f.id === "main.tex")!;
const file = useDocumentStore.getState().files.find((f) => f.id === "main.tex")!;
expect(file.content).toBe("Hello Beautiful World");
});
@ -329,13 +159,9 @@ describe("useDocumentStore", () => {
describe("findAndReplace", () => {
it("replaces first occurrence", () => {
const result = useDocumentStore
.getState()
.findAndReplace("World", "Universe");
const result = useDocumentStore.getState().findAndReplace("World", "Universe");
expect(result).toBe(true);
const file = useDocumentStore
.getState()
.files.find((f) => f.id === "main.tex")!;
const file = useDocumentStore.getState().files.find((f) => f.id === "main.tex")!;
expect(file.content).toBe("Hello Universe");
expect(file.isDirty).toBe(true);
});
@ -343,9 +169,7 @@ describe("useDocumentStore", () => {
it("returns false when find string is not found", () => {
const result = useDocumentStore.getState().findAndReplace("xyz", "abc");
expect(result).toBe(false);
const file = useDocumentStore
.getState()
.files.find((f) => f.id === "main.tex")!;
const file = useDocumentStore.getState().files.find((f) => f.id === "main.tex")!;
expect(file.isDirty).toBe(false); // not modified
});
@ -362,13 +186,9 @@ describe("useDocumentStore", () => {
useDocumentStore.setState({
files: [makeFile({ content: "price is $10.00" })],
});
const result = useDocumentStore
.getState()
.findAndReplace("$10.00", "€12.00");
const result = useDocumentStore.getState().findAndReplace("$10.00", "€12.00");
expect(result).toBe(true);
expect(useDocumentStore.getState().files[0].content).toBe(
"price is €12.00",
);
expect(useDocumentStore.getState().files[0].content).toBe("price is €12.00");
});
it("does nothing for image files", () => {
@ -383,9 +203,7 @@ describe("useDocumentStore", () => {
describe("updateFileContent", () => {
it("updates content and marks dirty", () => {
useDocumentStore.getState().updateFileContent("main.tex", "New content");
const file = useDocumentStore
.getState()
.files.find((f) => f.id === "main.tex")!;
const file = useDocumentStore.getState().files.find((f) => f.id === "main.tex")!;
expect(file.content).toBe("New content");
expect(file.isDirty).toBe(true);
});
@ -394,18 +212,11 @@ describe("useDocumentStore", () => {
useDocumentStore.setState({
files: [
makeFile(),
makeFile({
id: "other.tex",
name: "other.tex",
relativePath: "other.tex",
content: "Other",
}),
makeFile({ id: "other.tex", name: "other.tex", relativePath: "other.tex", content: "Other" }),
],
});
useDocumentStore.getState().updateFileContent("main.tex", "Changed");
const other = useDocumentStore
.getState()
.files.find((f) => f.id === "other.tex")!;
const other = useDocumentStore.getState().files.find((f) => f.id === "other.tex")!;
expect(other.content).toBe("Other");
expect(other.isDirty).toBe(false);
});
@ -444,39 +255,6 @@ describe("useDocumentStore", () => {
expect(state.cursorPosition).toBe(100);
expect(state.selectionRange).toBeNull();
});
it("keeps the current PDF when switching to a non-tex file", () => {
const pdfBytes = new Uint8Array([1, 2, 3]);
useDocumentStore.setState({
files: [
makeFile({
content:
"\\documentclass{article}\\begin{document}Hi\\end{document}",
}),
makeFile({
id: "analysis.py",
name: "analysis.py",
relativePath: "analysis.py",
absolutePath: "/project/analysis.py",
type: "other",
content: "print('hello')",
}),
],
activeFileId: "main.tex",
selectionRange: { start: 0, end: 3 },
});
useDocumentStore.getState().setPdfData(pdfBytes, "main.tex");
const revisionBefore = useDocumentStore.getState().pdfRevision;
useDocumentStore.getState().setActiveFile("analysis.py");
const state = useDocumentStore.getState();
expect(state.activeFileId).toBe("analysis.py");
expect(state.selectionRange).toBeNull();
expect(state.pdfRevision).toBe(revisionBefore);
expect(getCurrentPdfRootId()).toBe("main.tex");
expect(getCurrentPdfBytes()).toEqual(pdfBytes);
});
});
describe("saveFile", () => {
@ -490,10 +268,7 @@ describe("useDocumentStore", () => {
files: [makeFile({ isDirty: true, content: "saved content" })],
});
await useDocumentStore.getState().saveFile("main.tex");
expect(writeTextFile).toHaveBeenCalledWith(
"/project/main.tex",
"saved content",
);
expect(writeTextFile).toHaveBeenCalledWith("/project/main.tex", "saved content");
expect(useDocumentStore.getState().files[0].isDirty).toBe(false);
});
@ -508,9 +283,7 @@ describe("useDocumentStore", () => {
it("skips saving when content is null", async () => {
useDocumentStore.setState({
files: [
makeFile({ isDirty: true, content: null as unknown as string }),
],
files: [makeFile({ isDirty: true, content: null as unknown as string })],
});
await useDocumentStore.getState().saveFile("main.tex");
expect(writeTextFile).not.toHaveBeenCalled();
@ -535,36 +308,19 @@ describe("useDocumentStore", () => {
useDocumentStore.setState({
files: [
makeFile({ isDirty: true, content: "dirty content" }),
makeFile({
id: "clean.tex",
name: "clean.tex",
absolutePath: "/project/clean.tex",
relativePath: "clean.tex",
isDirty: false,
content: "clean",
}),
makeFile({ id: "clean.tex", name: "clean.tex", absolutePath: "/project/clean.tex", relativePath: "clean.tex", isDirty: false, content: "clean" }),
],
});
await useDocumentStore.getState().saveAllFiles();
expect(writeTextFile).toHaveBeenCalledTimes(1);
expect(writeTextFile).toHaveBeenCalledWith(
"/project/main.tex",
"dirty content",
);
expect(writeTextFile).toHaveBeenCalledWith("/project/main.tex", "dirty content");
});
it("saves dirty files with empty string content (regression: empty string is not falsy-skipped)", async () => {
useDocumentStore.setState({
files: [
makeFile({ isDirty: true, content: "" }),
makeFile({
id: "slide.tex",
name: "slide.tex",
absolutePath: "/project/slide.tex",
relativePath: "slide.tex",
isDirty: true,
content: "",
}),
makeFile({ id: "slide.tex", name: "slide.tex", absolutePath: "/project/slide.tex", relativePath: "slide.tex", isDirty: true, content: "" }),
],
});
await useDocumentStore.getState().saveAllFiles();
@ -597,11 +353,9 @@ describe("useDocumentStore", () => {
});
it("setCompileError stores the error string (regression: Tauri string errors must be preserved)", () => {
useDocumentStore
.getState()
.setCompileError("Compilation failed\n\n! Undefined control sequence.");
useDocumentStore.getState().setCompileError("Compilation failed\n\n! Undefined control sequence.");
expect(useDocumentStore.getState().compileError).toBe(
"Compilation failed\n\n! Undefined control sequence.",
"Compilation failed\n\n! Undefined control sequence."
);
});
});

View file

@ -36,29 +36,11 @@ function resetStores() {
messages: [],
sessionId: null,
isStreaming: false,
streamingStartedAt: null,
error: null,
totalInputTokens: 0,
totalOutputTokens: 0,
tabs: [
{
id: "tab-default",
title: "New Chat",
projectPath: "/project",
sessionId: null,
providerKey: null,
sessionProviderKey: null,
messages: [],
isStreaming: false,
streamingStartedAt: null,
error: null,
totalInputTokens: 0,
totalOutputTokens: 0,
draft: { input: "", pinnedContexts: [] },
},
],
tabs: [{ id: "tab-default", title: "New Chat", sessionId: null, messages: [], isStreaming: false, error: null, totalInputTokens: 0, totalOutputTokens: 0, draft: { input: "", pinnedContexts: [] } }],
activeTabId: "tab-default",
activeProjectPath: "/project",
_cancelledByUser: false,
});
useProposedChangesStore.setState({ changes: [] });
@ -90,9 +72,7 @@ describe("Multi-tab merge triggers", () => {
chat.setActiveTab(tabB);
// Proposed change should still be visible — it's file-scoped, not tab-scoped
const change = useProposedChangesStore
.getState()
.getChangeForFile("main.tex");
const change = useProposedChangesStore.getState().getChangeForFile("main.tex");
expect(change).toBeDefined();
expect(change!.id).toBe("tool-from-tab-a");
expect(change!.newContent).toBe("edited by tab A");
@ -182,30 +162,9 @@ describe("Multi-tab merge triggers", () => {
it("three sequential edits to the same file all preserve the original baseline", () => {
const store = useProposedChangesStore.getState();
store.addChange({
id: "edit-1",
filePath: "doc.tex",
absolutePath: "/project/doc.tex",
oldContent: "baseline",
newContent: "v1",
toolName: "Edit",
});
store.addChange({
id: "edit-2",
filePath: "doc.tex",
absolutePath: "/project/doc.tex",
oldContent: "v1",
newContent: "v2",
toolName: "Edit",
});
store.addChange({
id: "edit-3",
filePath: "doc.tex",
absolutePath: "/project/doc.tex",
oldContent: "v2",
newContent: "v3",
toolName: "MultiEdit",
});
store.addChange({ id: "edit-1", filePath: "doc.tex", absolutePath: "/project/doc.tex", oldContent: "baseline", newContent: "v1", toolName: "Edit" });
store.addChange({ id: "edit-2", filePath: "doc.tex", absolutePath: "/project/doc.tex", oldContent: "v1", newContent: "v2", toolName: "Edit" });
store.addChange({ id: "edit-3", filePath: "doc.tex", absolutePath: "/project/doc.tex", oldContent: "v2", newContent: "v3", toolName: "MultiEdit" });
const { changes } = useProposedChangesStore.getState();
expect(changes).toHaveLength(1);
@ -223,17 +182,13 @@ describe("Multi-tab merge triggers", () => {
// Tab A starts streaming
useClaudeChatStore.setState((s) => ({
tabs: s.tabs.map((t) =>
t.id === "tab-default" ? { ...t, isStreaming: true } : t,
),
tabs: s.tabs.map((t) => t.id === "tab-default" ? { ...t, isStreaming: true } : t),
isStreaming: s.activeTabId === "tab-default",
}));
// Tab B can also be streaming independently
useClaudeChatStore.setState((s) => ({
tabs: s.tabs.map((t) =>
t.id === tabB ? { ...t, isStreaming: true } : t,
),
tabs: s.tabs.map((t) => t.id === tabB ? { ...t, isStreaming: true } : t),
isStreaming: s.activeTabId === tabB,
}));
@ -244,41 +199,13 @@ describe("Multi-tab merge triggers", () => {
expect(tabBState.isStreaming).toBe(true);
});
it("preserves streaming start time when switching tabs", () => {
const chat = useClaudeChatStore.getState();
const tabB = chat.createTab();
const startedAt = Date.now() - 12_000;
useClaudeChatStore.setState((s) => ({
tabs: s.tabs.map((t) =>
t.id === "tab-default"
? { ...t, isStreaming: true, streamingStartedAt: startedAt }
: t,
),
activeTabId: "tab-default",
isStreaming: true,
streamingStartedAt: startedAt,
}));
chat.setActiveTab(tabB);
chat.setActiveTab("tab-default");
const state = useClaudeChatStore.getState();
expect(state.streamingStartedAt).toBe(startedAt);
expect(
state.tabs.find((t) => t.id === "tab-default")!.streamingStartedAt,
).toBe(startedAt);
});
it("_appendMessage routes to the specified tab, not the active tab", () => {
const chat = useClaudeChatStore.getState();
const tabB = chat.createTab();
// Mark tab A as streaming
useClaudeChatStore.setState((s) => ({
tabs: s.tabs.map((t) =>
t.id === "tab-default" ? { ...t, isStreaming: true } : t,
),
tabs: s.tabs.map((t) => t.id === "tab-default" ? { ...t, isStreaming: true } : t),
}));
// User is viewing tab B (active), but message is for tab A
@ -294,98 +221,13 @@ describe("Multi-tab merge triggers", () => {
const tabBState = state.tabs.find((t) => t.id === tabB)!;
expect(tabAState.messages).toHaveLength(1);
expect(tabAState.messages[0].message?.content?.[0].text).toBe(
"Hello from stream",
);
expect(tabAState.messages[0].message?.content?.[0].text).toBe("Hello from stream");
expect(tabBState.messages).toHaveLength(0);
// Top-level projected messages should reflect the active tab (tab B) — empty
expect(state.messages).toHaveLength(0);
});
it("_appendMessage merges direct provider streaming deltas", () => {
const chat = useClaudeChatStore.getState();
chat._appendMessage("tab-default", {
type: "assistant",
subtype: "streaming_delta",
message: { content: [{ type: "text", text: "Hello" }] },
});
chat._appendMessage("tab-default", {
type: "assistant",
subtype: "streaming_delta",
message: { content: [{ type: "text", text: " world" }] },
});
const messages = useClaudeChatStore.getState().messages;
expect(messages).toHaveLength(1);
expect(messages[0].message?.content?.[0].text).toBe("Hello world");
});
it("_appendMessage merges direct provider streaming thinking deltas", () => {
const chat = useClaudeChatStore.getState();
chat._appendMessage("tab-default", {
type: "assistant",
subtype: "streaming_delta",
message: {
content: [
{ type: "thinking", thinking: "Reason A. " },
{ type: "text", text: "Hello" },
],
},
});
chat._appendMessage("tab-default", {
type: "assistant",
subtype: "streaming_delta",
message: {
content: [
{ type: "thinking", thinking: "Reason B." },
{ type: "text", text: " world" },
],
},
});
const messages = useClaudeChatStore.getState().messages;
expect(messages).toHaveLength(1);
expect(messages[0].message?.content?.[0].type).toBe("thinking");
expect(messages[0].message?.content?.[0].thinking).toBe(
"Reason A. Reason B.",
);
expect(messages[0].message?.content?.[1].text).toBe("Hello world");
});
it("_appendMessage replaces streaming deltas with final direct provider message", () => {
const chat = useClaudeChatStore.getState();
chat._appendMessage("tab-default", {
type: "assistant",
subtype: "streaming_delta",
message: { content: [{ type: "text", text: "Draft" }] },
});
chat._appendMessage("tab-default", {
type: "assistant",
subtype: "streaming_final",
message: {
content: [
{ type: "text", text: "Final" },
{
type: "tool_use",
id: "tool-1",
name: "Read",
input: { file_path: "main.tex" },
},
],
},
});
const messages = useClaudeChatStore.getState().messages;
expect(messages).toHaveLength(1);
expect(messages[0].subtype).toBe("streaming_final");
expect(messages[0].message?.content?.[0].text).toBe("Final");
expect(messages[0].message?.content?.[1].type).toBe("tool_use");
});
it("_setSessionId routes to the specified tab", () => {
const chat = useClaudeChatStore.getState();
const tabB = chat.createTab();
@ -415,9 +257,7 @@ describe("Multi-tab merge triggers", () => {
chat._setStreaming("tab-default", false);
const state = useClaudeChatStore.getState();
expect(state.tabs.find((t) => t.id === "tab-default")!.isStreaming).toBe(
false,
);
expect(state.tabs.find((t) => t.id === "tab-default")!.isStreaming).toBe(false);
expect(state.tabs.find((t) => t.id === tabB)!.isStreaming).toBe(true);
});
@ -487,9 +327,7 @@ describe("Multi-tab merge triggers", () => {
chat.setActiveTab(tabB);
expect(useProposedChangesStore.getState().changes).toHaveLength(1);
expect(useProposedChangesStore.getState().changes[0].newContent).toBe(
"after",
);
expect(useProposedChangesStore.getState().changes[0].newContent).toBe("after");
});
it("keepAll clears all changes regardless of which tab is active", () => {
@ -497,20 +335,12 @@ describe("Multi-tab merge triggers", () => {
const tabB = chat.createTab();
useProposedChangesStore.getState().addChange({
id: "edit-1",
filePath: "main.tex",
absolutePath: "/project/main.tex",
oldContent: "old-main",
newContent: "new-main",
toolName: "Edit",
id: "edit-1", filePath: "main.tex", absolutePath: "/project/main.tex",
oldContent: "old-main", newContent: "new-main", toolName: "Edit",
});
useProposedChangesStore.getState().addChange({
id: "edit-2",
filePath: "refs.bib",
absolutePath: "/project/refs.bib",
oldContent: "old-bib",
newContent: "new-bib",
toolName: "Write",
id: "edit-2", filePath: "refs.bib", absolutePath: "/project/refs.bib",
oldContent: "old-bib", newContent: "new-bib", toolName: "Write",
});
chat.setActiveTab(tabB);
@ -538,9 +368,7 @@ describe("Multi-tab merge triggers", () => {
chat.closeTab(tabB);
expect(useProposedChangesStore.getState().changes).toHaveLength(1);
expect(useProposedChangesStore.getState().changes[0].id).toBe(
"from-tab-b",
);
expect(useProposedChangesStore.getState().changes[0].id).toBe("from-tab-b");
});
it("creating a new tab does not clear existing proposed changes", () => {

View file

@ -30,18 +30,6 @@ describe("useProjectStore", () => {
expect(recentProjects[1].path).toBe("/b");
});
it("normalizes trailing separators when deduplicating", () => {
const store = useProjectStore.getState();
store.addRecentProject("C:\\Projects\\ClaudePrism\\paper\\");
store.addRecentProject("C:\\Projects\\ClaudePrism\\paper");
const { recentProjects } = useProjectStore.getState();
expect(recentProjects).toHaveLength(1);
expect(recentProjects[0]).toMatchObject({
path: "C:\\Projects\\ClaudePrism\\paper",
name: "paper",
});
});
it("limits to MAX_RECENT (10) entries", () => {
const store = useProjectStore.getState();
for (let i = 0; i < 12; i++) {
@ -55,16 +43,12 @@ describe("useProjectStore", () => {
it("extracts name from path correctly", () => {
useProjectStore.getState().addRecentProject("/a/b/c/deep-folder");
expect(useProjectStore.getState().recentProjects[0].name).toBe(
"deep-folder",
);
expect(useProjectStore.getState().recentProjects[0].name).toBe("deep-folder");
});
it("uses full path as name if no segments", () => {
useProjectStore.getState().addRecentProject("standalone");
expect(useProjectStore.getState().recentProjects[0].name).toBe(
"standalone",
);
expect(useProjectStore.getState().recentProjects[0].name).toBe("standalone");
});
});
@ -79,38 +63,4 @@ describe("useProjectStore", () => {
expect(recentProjects[0].path).toBe("/b");
});
});
describe("renameRecentProject", () => {
it("replaces the old recent project path with the new folder path", () => {
const store = useProjectStore.getState();
store.addRecentProject("/work/old");
store.addRecentProject("/work/other");
store.renameRecentProject("/work/old", "/work/new");
const { recentProjects } = useProjectStore.getState();
expect(recentProjects[0]).toMatchObject({
path: "/work/new",
name: "new",
});
expect(
recentProjects.some((project) => project.path === "/work/old"),
).toBe(false);
expect(
recentProjects.some((project) => project.path === "/work/other"),
).toBe(true);
});
it("matches renamed paths even when the old recent path has a trailing slash", () => {
const store = useProjectStore.getState();
store.addRecentProject("/work/old/");
store.renameRecentProject("/work/old", "/work/new/");
const { recentProjects } = useProjectStore.getState();
expect(recentProjects).toHaveLength(1);
expect(recentProjects[0]).toMatchObject({
path: "/work/new",
name: "new",
});
});
});
});

View file

@ -111,17 +111,13 @@ describe("useProposedChangesStore", () => {
newContent: "b",
toolName: "Edit",
});
const change = useProposedChangesStore
.getState()
.getChangeForFile("main.tex");
const change = useProposedChangesStore.getState().getChangeForFile("main.tex");
expect(change).toBeDefined();
expect(change!.id).toBe("tool-1");
});
it("returns undefined for unknown file", () => {
const change = useProposedChangesStore
.getState()
.getChangeForFile("nonexistent.tex");
const change = useProposedChangesStore.getState().getChangeForFile("nonexistent.tex");
expect(change).toBeUndefined();
});
});

View file

@ -35,9 +35,7 @@ describe("useTemplateStore", () => {
useTemplateStore.getState().setSelectedCategory("academic");
const { filteredTemplates } = useTemplateStore.getState();
expect(filteredTemplates.length).toBeGreaterThan(0);
expect(filteredTemplates.every((t) => t.category === "academic")).toBe(
true,
);
expect(filteredTemplates.every((t) => t.category === "academic")).toBe(true);
});
it("clears category filter with null", () => {
@ -55,9 +53,7 @@ describe("useTemplateStore", () => {
useTemplateStore.getState().setSelectedCategory("academic");
const { filteredTemplates } = useTemplateStore.getState();
expect(filteredTemplates.length).toBeGreaterThan(0);
expect(filteredTemplates.every((t) => t.category === "academic")).toBe(
true,
);
expect(filteredTemplates.every((t) => t.category === "academic")).toBe(true);
});
});
});

View file

@ -9,10 +9,7 @@ function storeKey(collectionKey: string | null): string {
}
function sanitizeFileName(name: string): string {
return name
.replace(/[^a-zA-Z0-9_\-\s]/g, "")
.replace(/\s+/g, "-")
.toLowerCase();
return name.replace(/[^a-zA-Z0-9_\-\s]/g, "").replace(/\s+/g, "-").toLowerCase();
}
function parseBibEntries(content: string): Map<string, string> {

View file

@ -1,6 +1,6 @@
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { readFileSync } from "fs";
import { resolve } from "path";
/**
* Validates tauri.conf.json CSP (Content Security Policy) configuration.

View file

@ -1,3 +0,0 @@
<svg width="248" height="248" viewBox="0 0 248 248" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M52.4285 162.873L98.7844 136.879L99.5485 134.602L98.7844 133.334H96.4921L88.7237 132.862L62.2346 132.153L39.3113 131.207L17.0249 130.026L11.4214 128.844L6.2 121.873L6.7094 118.447L11.4214 115.257L18.171 115.847L33.0711 116.911L55.485 118.447L71.6586 119.392L95.728 121.873H99.5485L100.058 120.337L98.7844 119.392L97.7656 118.447L74.5877 102.732L49.4995 86.1905L36.3823 76.62L29.3779 71.7757L25.8121 67.2858L24.2839 57.3608L30.6515 50.2716L39.3113 50.8623L41.4763 51.4531L50.2636 58.1879L68.9842 72.7209L93.4357 90.6804L97.0015 93.6343L98.4374 92.6652L98.6571 91.9801L97.0015 89.2625L83.757 65.2772L69.621 40.8192L63.2534 30.6579L61.5978 24.632C60.9565 22.1032 60.579 20.0111 60.579 17.4246L67.8381 7.49965L71.9133 6.19995L81.7193 7.49965L85.7946 11.0443L91.9074 24.9865L101.714 46.8451L116.996 76.62L121.453 85.4816L123.873 93.6343L124.764 96.1155H126.292V94.6976L127.566 77.9197L129.858 57.3608L132.15 30.8942L132.915 23.4505L136.608 14.4708L143.994 9.62643L149.725 12.344L154.437 19.0788L153.8 23.4505L150.998 41.6463L145.522 70.1215L141.957 89.2625H143.994L146.414 86.7813L156.093 74.0206L172.266 53.698L179.398 45.6635L187.803 36.802L193.152 32.5484H203.34L210.726 43.6549L207.415 55.1159L196.972 68.3492L188.312 79.5739L175.896 96.2095L168.191 109.585L168.882 110.689L170.738 110.53L198.755 104.504L213.91 101.787L231.994 98.7149L240.144 102.496L241.036 106.395L237.852 114.311L218.495 119.037L195.826 123.645L162.07 131.592L161.696 131.893L162.137 132.547L177.36 133.925L183.855 134.279H199.774L229.447 136.524L237.215 141.605L241.8 147.867L241.036 152.711L229.065 158.737L213.019 154.956L175.45 145.977L162.587 142.787H160.805V143.85L171.502 154.366L191.242 172.089L215.82 195.011L217.094 200.682L213.91 205.172L210.599 204.699L188.949 188.394L180.544 181.069L161.696 165.118H160.422V166.772L164.752 173.152L187.803 207.771L188.949 218.405L187.294 221.832L181.308 223.959L174.813 222.777L161.187 203.754L147.305 182.486L136.098 163.345L134.745 164.2L128.075 235.42L125.019 239.082L117.887 241.8L111.902 237.31L108.718 229.984L111.902 215.452L115.722 196.547L118.779 181.541L121.58 162.873L123.291 156.636L123.14 156.219L121.773 156.449L107.699 175.752L86.304 204.699L69.3663 222.777L65.291 224.431L58.2867 220.768L58.9235 214.27L62.8713 208.48L86.304 178.705L100.44 160.155L109.551 149.507L109.462 147.967L108.959 147.924L46.6977 188.512L35.6182 189.93L30.7788 185.44L31.4156 178.115L33.7079 175.752L52.4285 162.873Z" fill="#D97757"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.5 KiB

View file

@ -1,14 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 377.1 277.86">
<defs>
<style>
.cls-1 {
fill: #4d6bfe;
stroke-width: 0px;
}
</style>
</defs>
<g id="Layer_1-2" data-name="Layer 1">
<path id="path" class="cls-1" d="M373.15,23.32c-4-1.95-5.72,1.77-8.06,3.66-.79.62-1.47,1.43-2.14,2.14-5.85,6.26-12.67,10.36-21.57,9.86-13.04-.71-24.16,3.38-33.99,13.37-2.09-12.31-9.04-19.66-19.6-24.38-5.54-2.45-11.13-4.9-14.99-10.23-2.71-3.78-3.44-8-4.81-12.16-.85-2.51-1.72-5.09-4.6-5.52-3.13-.5-4.36,2.14-5.58,4.34-4.93,8.99-6.82,18.92-6.65,28.97.43,22.58,9.97,40.56,28.89,53.37,2.16,1.46,2.71,2.95,2.03,5.09-1.29,4.4-2.82,8.68-4.19,13.09-.85,2.82-2.14,3.44-5.15,2.2-10.39-4.34-19.37-10.76-27.29-18.55-13.46-13.02-25.63-27.41-40.81-38.67-3.57-2.64-7.12-5.09-10.81-7.41-15.49-15.07,2.03-27.45,6.08-28.9,4.25-1.52,1.47-6.79-12.23-6.73-13.69.06-26.24,4.65-42.21,10.76-2.34.93-4.79,1.61-7.32,2.14-14.5-2.73-29.55-3.35-45.29-1.58-29.62,3.32-53.28,17.34-70.68,41.28C1.29,88.2-3.63,120.88,2.39,155c6.33,35.91,24.64,65.68,52.8,88.94,29.18,24.1,62.8,35.91,101.15,33.65,23.29-1.33,49.23-4.46,78.48-29.24,7.38,3.66,15.12,5.12,27.97,6.23,9.89.93,19.41-.5,26.79-2.02,11.55-2.45,10.75-13.15,6.58-15.13-33.87-15.78-26.44-9.36-33.2-14.54,17.21-20.41,43.15-41.59,53.3-110.19.79-5.46.11-8.87,0-13.3-.06-2.67.54-3.72,3.61-4.03,8.48-.96,16.72-3.29,24.28-7.47,21.94-12,30.78-31.69,32.87-55.33.31-3.6-.06-7.35-3.86-9.24ZM181.96,235.97c-32.83-25.83-48.74-34.33-55.31-33.96-6.14.34-5.04,7.38-3.69,11.97,1.41,4.53,3.26,7.66,5.85,11.63,1.78,2.64,3.01,6.57-1.78,9.49-10.57,6.58-28.95-2.2-29.82-2.64-21.38-12.59-39.26-29.24-51.87-52.01-12.16-21.92-19.23-45.43-20.39-70.52-.31-6.08,1.47-8.22,7.49-9.3,7.92-1.46,16.11-1.77,24.03-.62,33.49,4.9,62.01,19.91,85.9,43.63,13.65,13.55,23.97,29.71,34.61,45.49,11.3,16.78,23.48,32.75,38.97,45.84,5.46,4.59,9.83,8.09,14,10.67-12.59,1.4-33.62,1.71-47.99-9.68ZM197.69,134.65c0-2.7,2.15-4.84,4.87-4.84.6,0,1.16.12,1.66.31.67.25,1.29.62,1.77,1.18.87.84,1.36,2.08,1.36,3.35,0,2.7-2.15,4.84-4.85,4.84s-4.81-2.14-4.81-4.84ZM246.55,159.77c-3.13,1.27-6.26,2.39-9.27,2.51-4.67.22-9.77-1.68-12.55-4-4.3-3.6-7.36-5.61-8.67-11.94-.54-2.7-.23-6.85.25-9.24,1.12-5.15-.12-8.44-3.74-11.44-2.96-2.45-6.7-3.1-10.82-3.1-1.54,0-2.95-.68-4-1.24-1.72-.87-3.13-3.01-1.78-5.64.43-.84,2.53-2.92,3.02-3.29,5.58-3.19,12.03-2.14,18,.25,5.54,2.26,9.71,6.42,15.72,12.28,6.16,7.1,7.26,9.09,10.76,14.39,2.76,4.19,5.29,8.47,7.01,13.37,1.04,3.04-.31,5.55-3.94,7.1Z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.5 KiB

View file

@ -1 +0,0 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Gemini</title><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="#3186FF"></path><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="url(#lobe-icons-gemini-0-_R_0_)"></path><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="url(#lobe-icons-gemini-1-_R_0_)"></path><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="url(#lobe-icons-gemini-2-_R_0_)"></path><defs><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-gemini-0-_R_0_" x1="7" x2="11" y1="15.5" y2="12"><stop stop-color="#08B962"></stop><stop offset="1" stop-color="#08B962" stop-opacity="0"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-gemini-1-_R_0_" x1="8" x2="11.5" y1="5.5" y2="11"><stop stop-color="#F94543"></stop><stop offset="1" stop-color="#F94543" stop-opacity="0"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-gemini-2-_R_0_" x1="3.5" x2="17.5" y1="13.5" y2="12"><stop stop-color="#FABC12"></stop><stop offset=".46" stop-color="#FABC12" stop-opacity="0"></stop></linearGradient></defs></svg>

Before

Width:  |  Height:  |  Size: 2.8 KiB

View file

@ -1 +0,0 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>MoonshotAI</title><path d="M1.052 16.916l9.539 2.552a21.007 21.007 0 00.06 2.033l5.956 1.593a11.997 11.997 0 01-5.586.865l-.18-.016-.044-.004-.084-.009-.094-.01a11.605 11.605 0 01-.157-.02l-.107-.014-.11-.016a11.962 11.962 0 01-.32-.051l-.042-.008-.075-.013-.107-.02-.07-.015-.093-.019-.075-.016-.095-.02-.097-.023-.094-.022-.068-.017-.088-.022-.09-.024-.095-.025-.082-.023-.109-.03-.062-.02-.084-.025-.093-.028-.105-.034-.058-.019-.08-.026-.09-.031-.066-.024a6.293 6.293 0 01-.044-.015l-.068-.025-.101-.037-.057-.022-.08-.03-.087-.035-.088-.035-.079-.032-.095-.04-.063-.028-.063-.027a5.655 5.655 0 01-.041-.018l-.066-.03-.103-.047-.052-.024-.096-.046-.062-.03-.084-.04-.086-.044-.093-.047-.052-.027-.103-.055-.057-.03-.058-.032a6.49 6.49 0 01-.046-.026l-.094-.053-.06-.034-.051-.03-.072-.041-.082-.05-.093-.056-.052-.032-.084-.053-.061-.039-.079-.05-.07-.047-.053-.035a7.785 7.785 0 01-.054-.036l-.044-.03-.044-.03a6.066 6.066 0 01-.04-.028l-.057-.04-.076-.054-.069-.05-.074-.054-.056-.042-.076-.057-.076-.059-.086-.067-.045-.035-.064-.052-.074-.06-.089-.073-.046-.039-.046-.039a7.516 7.516 0 01-.043-.037l-.045-.04-.061-.053-.07-.062-.068-.06-.062-.058-.067-.062-.053-.05-.088-.084a13.28 13.28 0 01-.099-.097l-.029-.028-.041-.042-.069-.07-.05-.051-.05-.053a6.457 6.457 0 01-.168-.179l-.08-.088-.062-.07-.071-.08-.042-.049-.053-.062-.058-.068-.046-.056a7.175 7.175 0 01-.027-.033l-.045-.055-.066-.082-.041-.052-.05-.064-.02-.025a11.99 11.99 0 01-1.44-2.402zm-1.02-5.794l11.353 3.037a20.468 20.468 0 00-.469 2.011l10.817 2.894a12.076 12.076 0 01-1.845 2.005L.657 15.923l-.016-.046-.035-.104a11.965 11.965 0 01-.05-.153l-.007-.023a11.896 11.896 0 01-.207-.741l-.03-.126-.018-.08-.021-.097-.018-.081-.018-.09-.017-.084-.018-.094c-.026-.141-.05-.283-.071-.426l-.017-.118-.011-.083-.013-.102a12.01 12.01 0 01-.019-.161l-.005-.047a12.12 12.12 0 01-.034-2.145zm1.593-5.15l11.948 3.196c-.368.605-.705 1.231-1.01 1.875l11.295 3.022c-.142.82-.368 1.612-.668 2.365l-11.55-3.09L.124 10.26l.015-.1.008-.049.01-.067.015-.087.018-.098c.026-.148.056-.295.088-.442l.028-.124.02-.085.024-.097c.022-.09.045-.18.07-.268l.028-.102.023-.083.03-.1.025-.082.03-.096.026-.082.031-.095a11.896 11.896 0 011.01-2.232zm4.442-4.4L17.352 4.59a20.77 20.77 0 00-1.688 1.721l7.823 2.093c.267.852.442 1.744.513 2.665L2.106 5.213l.045-.065.027-.04.04-.055.046-.065.055-.076.054-.072.064-.086.05-.065.057-.073.055-.07.06-.074.055-.069.065-.077.054-.066.066-.077.053-.06.072-.082.053-.06.067-.074.054-.058.073-.078.058-.06.063-.067.168-.17.1-.098.059-.056.076-.071a12.084 12.084 0 012.272-1.677zM12.017 0h.097l.082.001.069.001.054.002.068.002.046.001.076.003.047.002.06.003.054.002.087.005.105.007.144.011.088.007.044.004.077.008.082.008.047.005.102.012.05.006.108.014.081.01.042.006.065.01.207.032.07.012.065.011.14.026.092.018.11.022.046.01.075.016.041.01L14.7.3l.042.01.065.015.049.012.071.017.096.024.112.03.113.03.113.032.05.015.07.02.078.024.073.023.05.016.05.016.076.025.099.033.102.036.048.017.064.023.093.034.11.041.116.045.1.04.047.02.06.024.041.018.063.026.04.018.057.025.11.048.1.046.074.035.075.036.06.028.092.046.091.045.102.052.053.028.049.026.046.024.06.033.041.022.052.029.088.05.106.06.087.051.057.034.053.032.096.059.088.055.098.062.036.024.064.041.084.056.04.027.062.042.062.043.023.017c.054.037.108.075.161.114l.083.06.065.048.056.043.086.065.082.064.04.03.05.041.086.069.079.065.085.071c.712.6 1.353 1.283 1.909 2.031L7.222.994l.062-.027.065-.028.081-.034.086-.035c.113-.045.227-.09.341-.131l.096-.035.093-.033.084-.03.096-.031c.087-.03.176-.058.264-.085l.091-.027.086-.025.102-.03.085-.023.1-.026L9.04.37l.09-.023.091-.022.095-.022.09-.02.098-.021.091-.02.095-.018.092-.018.1-.018.091-.016.098-.017.092-.014.097-.015.092-.013.102-.013.091-.012.105-.012.09-.01.105-.01c.093-.01.186-.018.28-.024l.106-.008.09-.005.11-.006.093-.004.1-.004.097-.002.099-.002.197-.002z"></path></svg>

Before

Width:  |  Height:  |  Size: 3.9 KiB

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" viewBox="0 0 512 512"><circle cx="256" cy="256" r="256" style="fill:#fff"/><defs><circle id="ollama_svg__a" cx="256" cy="256" r="256"/></defs><clipPath id="ollama_svg__b"><use xlink:href="#ollama_svg__a" style="overflow:visible"/></clipPath><g style="clip-path:url(#ollama_svg__b)"><path d="M157.3 35.9c-4.3.7-9.5 3-13.1 5.7-11 8.4-19.6 26.2-23.2 48.4-1.4 8.4-2.3 20-2.3 28.9 0 10.5 1.2 23.9 3 33.1.4 2.1.6 3.9.4 4-.1.1-1.8 1.5-3.6 2.9-6.2 5-13.4 12.6-18.3 19.6-9.4 13.4-15.5 28.6-18.1 45-1 6.5-1.3 19.6-.5 26.1 1.8 15 6.3 27.7 14 39.3l2.5 3.7-.7 1.2c-5.2 8.7-9.6 21.3-11.6 33.3-1.6 9.6-1.8 12.1-1.8 24.9 0 12.9.2 15.5 1.7 24.4 1.8 10.7 5.5 22 9.7 29.5 1.4 2.5 4.7 7.6 5.1 7.8.1.1-.3 1.3-.9 2.7-4.8 10.5-8.9 24.4-10.6 36.1-1.2 8-1.4 10.6-1.4 19.1 0 10.8.6 16 2.9 24.6l.3 1.3h28.4l-.9-1.8c-5.7-10.6-6.3-30.3-1.3-50 2.3-9.1 4.8-15.8 9.6-24.9l2.9-5.6v-3.4c0-3.2-.1-3.5-1.1-5.6-.8-1.6-1.9-3-3.7-4.8-3.2-3.1-5.5-6.4-7.4-10.5-8.2-17.7-9.8-44-4-66.5 2.4-9.4 6.3-17.7 10.5-22.2 2.8-3.1 4.3-6.6 4.3-10.2 0-3.7-1.3-6.8-4.3-10.1-8.6-9.2-13.8-20.3-15.7-33.3-2.7-18.5 2.2-38.6 13.3-54.6 10.8-15.7 26.1-25.7 43.1-28.4 3.8-.6 10.9-.5 14.9.2 4.3.8 7.1.5 9.9-.8 3.5-1.6 5.2-3.6 7.2-8.3 1.8-4.1 3.2-6.4 6.9-11.1 4.5-5.6 8.9-9.4 15.8-14 8-5.2 17-9 26-10.8 3.3-.7 4.8-.8 10.9-.8s7.7.1 10.9.8c13.2 2.7 26.4 9.5 36.9 19.2 2.3 2.1 7.7 8.8 9.4 11.6.7 1.1 1.8 3.4 2.6 5.1 2 4.6 3.7 6.7 7.2 8.3 2.7 1.3 5.5 1.6 9.7.9 6.6-1.1 11.7-1 18.1.3 22 4.4 41.2 22.6 49.7 46.9 7.4 21.3 5.3 43.7-5.7 60.7-1.9 2.9-3.7 5.2-6.4 8.1-5.8 6.2-5.8 13.9 0 20.3 9.5 10.4 15.4 35.9 13.6 58.5-1.2 14.9-5 28.2-10.3 35.7-.9 1.3-2.9 3.6-4.3 5-1.9 1.9-3 3.2-3.7 4.8-1 2.1-1.1 2.5-1.1 5.6v3.4l2.9 5.6c4.8 9.2 7.3 15.9 9.6 24.9 4.9 19.4 4.4 38.7-1.1 49.7-.5.9-.9 1.8-.9 1.9s6.3.2 14.1.2h14.1l.4-1.4c.2-.8.5-1.9.7-2.6.4-1.5 1.1-5.8 1.7-9.9.6-4.2.6-19.6 0-24.2-2.1-16.9-5.7-30.2-11.5-42.9-.6-1.4-1-2.7-.9-2.7.2-.1 1.1-1.4 2.1-2.9 7.2-10.9 11.7-24.7 13.9-42.9.6-5 .6-26.5 0-31.4-1.6-12.4-3.5-20.8-6.7-29.4-1.3-3.5-4.8-11-6.3-13.5l-.7-1.2 2.5-3.7c7.7-11.6 12.2-24.3 14-39.3.8-6.5.5-19.6-.5-26.1-2.6-16.5-8.7-31.6-18.1-45-4.9-7-12-14.7-18.3-19.6-1.8-1.5-3.5-2.8-3.6-2.9-.2-.1 0-2 .4-4 4-20.9 3.9-47-.3-67.4-3.6-17.8-10.3-31.9-18.8-40.1-6.8-6.5-13.8-9.3-22.2-8.8-19.2 1.1-34.6 23.2-40.7 58-1 5.6-1.9 12.2-1.9 14 0 .7-.1 1.3-.3 1.3s-1.5-.7-2.9-1.5C288.5 98.8 272 94.1 256 94.1s-32.5 4.7-47.3 13.4c-1.4.8-2.7 1.5-2.9 1.5s-.3-.6-.3-1.3c0-1.9-.9-8.6-1.9-14-5.5-31.2-18.2-51.9-35.1-57.1-2.2-.6-8.8-1.1-11.2-.7m5.6 27c4.8 3.8 10.1 14.6 13.1 26.7.6 2.2 1.2 4.7 1.3 5.6s.5 2.9.8 4.5c1.3 7 1.9 14.6 2 23.9v9.1l-2.3 3.4-2.3 3.4h-5.3c-6.2 0-12.4.8-18.4 2.4-2.1.5-4.2 1.1-4.6 1.2-.6.1-.7-.1-1.1-2.8-2-14.8-1.9-31.1.3-44.7 2.4-15.2 8-28.9 13.4-32.9 1.4-1 1.6-1 3.1.2m189.2-.2c3.3 2.4 6.9 8.9 9.6 17.1 5.4 16.5 6.9 39 4.1 60.5-.4 2.7-.5 2.9-1.1 2.8-.4-.1-2.5-.6-4.6-1.2-5.9-1.6-12.1-2.4-18.4-2.4h-5.3l-2.3-3.4-2.3-3.4v-9.1c.1-12.9 1.3-22.9 4.1-34.1 3-12 8.4-22.8 13.1-26.6 1.6-1.2 1.8-1.2 3.1-.2"/><path d="M250.9 229.6c-7.2.7-9.2 1-12.6 1.7-5.6 1.2-13.1 3.7-18.3 6.3-18.1 8.9-30.6 23.6-34.4 40.7-.8 3.4-.9 4.5-.9 10.2 0 5.6.1 6.9.8 10.1 5.1 22.3 25.6 38.8 52.3 41.8 5.8.6 30.7.6 36.5 0 21.4-2.4 39.7-14 48-30.3 2.2-4.3 3.3-7.2 4.2-11.6.7-3.2.8-4.4.8-10.1s-.1-6.8-.9-10.2c-5.5-24.8-29.6-44.4-59.2-48.1-3.7-.3-13.8-.7-16.3-.5m12.4 18.1c9.9 1.1 19.8 4.6 27.7 9.9 4.3 2.9 10.3 8.8 12.9 12.7 3.2 4.8 5 9.8 5.8 15.8.4 2.8.2 4.8-.8 9.3-1.6 6.6-6.4 13.6-12.9 18.4-3.1 2.2-9.4 5.4-13.3 6.7-7.4 2.4-12.2 2.8-29.4 2.7-11.2-.1-13.2-.2-16.4-.8-11-2.1-19.7-6.4-26-13.1-5.1-5.4-7.4-10.3-8.7-18.2-.6-3.7.5-9.8 2.7-14.9 2.6-6.3 9.4-14.1 16.1-18.5 7.8-5.2 18-8.9 27.4-9.9 3.6-.5 11.2-.5 14.9-.1"/><path d="M243.3 271.9c-2.5 1.4-4.3 4.8-3.7 7.4.6 2.8 3 5.5 6.8 7.8 2 1.2 2.2 1.4 2.3 2.6.1.7-.2 2.8-.6 4.7-.4 1.8-.7 3.7-.7 4.3 0 1.4 1.4 3.7 2.8 4.9 1.2 1 1.5 1 4.9 1.1 3.2.1 3.8 0 5.1-.6 3.3-1.6 4.1-4.5 2.9-10.1-1-4.7-.8-5.4 1.7-6.8 2.6-1.5 5.4-4.2 6.2-6 1.6-3.5.1-7.4-3.4-9.3-.9-.4-1.9-.6-3.5-.6-2.4 0-4 .6-6.8 2.4l-1.6 1-1-.6c-4.2-2.5-5-2.8-7.5-2.8-2 0-3 .1-3.9.6m-80.5-38.5c-5.9 1.9-10.3 6.2-12.5 12.3-1.1 2.9-1.6 7.5-1.2 10 1.1 5.9 6 11.3 11.5 12.8 7 1.8 12.2.6 16.8-3.9 2.7-2.6 4.1-4.9 5.6-8.6 1.1-2.6 1.1-3.1 1.1-6.8v-4l-1.4-2.9c-2.2-4.5-6.2-7.9-10.9-9.1-2.5-.6-6.7-.6-9 .2m177.2-.1c-4.5 1.2-8.6 4.6-10.7 9.1l-1.4 2.9v4c0 3.7.1 4.2 1.1 6.8 1.5 3.7 2.9 6 5.6 8.6 4.6 4.6 9.8 5.8 16.8 3.9 4-1.1 8-4.4 10-8.4 1.7-3.4 2.1-5.8 1.5-9.6-1.2-8.7-6.3-15.1-13.9-17.3-2.3-.7-6.6-.7-9 0"/></g></svg>

Before

Width:  |  Height:  |  Size: 4.4 KiB

View file

@ -1 +0,0 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>OpenAI</title><path d="M9.205 8.658v-2.26c0-.19.072-.333.238-.428l4.543-2.616c.619-.357 1.356-.523 2.117-.523 2.854 0 4.662 2.212 4.662 4.566 0 .167 0 .357-.024.547l-4.71-2.759a.797.797 0 00-.856 0l-5.97 3.473zm10.609 8.8V12.06c0-.333-.143-.57-.429-.737l-5.97-3.473 1.95-1.118a.433.433 0 01.476 0l4.543 2.617c1.309.76 2.189 2.378 2.189 3.948 0 1.808-1.07 3.473-2.76 4.163zM7.802 12.703l-1.95-1.142c-.167-.095-.239-.238-.239-.428V5.899c0-2.545 1.95-4.472 4.591-4.472 1 0 1.927.333 2.712.928L8.23 5.067c-.285.166-.428.404-.428.737v6.898zM12 15.128l-2.795-1.57v-3.33L12 8.658l2.795 1.57v3.33L12 15.128zm1.796 7.23c-1 0-1.927-.332-2.712-.927l4.686-2.712c.285-.166.428-.404.428-.737v-6.898l1.974 1.142c.167.095.238.238.238.428v5.233c0 2.545-1.974 4.472-4.614 4.472zm-5.637-5.303l-4.544-2.617c-1.308-.761-2.188-2.378-2.188-3.948A4.482 4.482 0 014.21 6.327v5.423c0 .333.143.571.428.738l5.947 3.449-1.95 1.118a.432.432 0 01-.476 0zm-.262 3.9c-2.688 0-4.662-2.021-4.662-4.519 0-.19.024-.38.047-.57l4.686 2.71c.286.167.571.167.856 0l5.97-3.448v2.26c0 .19-.07.333-.237.428l-4.543 2.616c-.619.357-1.356.523-2.117.523zm5.899 2.83a5.947 5.947 0 005.827-4.756C22.287 18.339 24 15.84 24 13.296c0-1.665-.713-3.282-1.998-4.448.119-.5.19-.999.19-1.498 0-3.401-2.759-5.947-5.946-5.947-.642 0-1.26.095-1.88.31A5.962 5.962 0 0010.205 0a5.947 5.947 0 00-5.827 4.757C1.713 5.447 0 7.945 0 10.49c0 1.666.713 3.283 1.998 4.448-.119.5-.19 1-.19 1.499 0 3.401 2.759 5.946 5.946 5.946.642 0 1.26-.095 1.88-.309a5.96 5.96 0 004.162 1.713z"></path></svg>

Before

Width:  |  Height:  |  Size: 1.6 KiB

View file

@ -1,15 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="27.55 17.52 147.28 145.51">
<path d="M174.82 108.75L155.38 75L165.64 57.75C166.46 56.31 166.46 54.53 165.64 53.09L155.38 35.84C154.86 34.91 153.87 34.33 152.78 34.33H114.88L106.14 19.03C105.62 18.1 104.63 17.52 103.54 17.52H83.3C82.21 17.52 81.22 18.1 80.7 19.03L61.26 52.77H41.02C39.93 52.77 38.94 53.35 38.42 54.28L28.16 71.53C27.34 72.97 27.34 74.75 28.16 76.19L45.52 107.5L36.78 122.8C35.96 124.24 35.96 126.02 36.78 127.46L47.04 144.71C47.56 145.64 48.55 146.22 49.64 146.22H87.54L96.28 161.52C96.8 162.45 97.79 163.03 98.88 163.03H119.12C120.21 163.03 121.2 162.45 121.72 161.52L141.16 127.78H158.52C159.61 127.78 160.6 127.2 161.12 126.27L171.38 109.02C172.2 107.58 172.2 105.8 171.38 104.36L174.82 108.75Z" fill="url(#paint0_radial)"/>
<path d="M119.12 163.03H98.88L87.54 144.71H49.64L61.26 126.39H80.7L38.42 55.29H61.26L83.3 19.03L93.56 37.35L83.3 55.29H161.58L151.32 72.54L170.76 106.28H151.32L141.16 88.34L101.18 163.03H119.12Z" fill="white"/>
<path d="M127.86 79.83H76.14L101.18 122.11L127.86 79.83Z" fill="url(#paint1_radial)"/>
<defs>
<radialGradient id="paint0_radial" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(100 100) rotate(90) scale(100)">
<stop stop-color="#665CEE"/>
<stop offset="1" stop-color="#332E91"/>
</radialGradient>
<radialGradient id="paint1_radial" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(100 100) rotate(90) scale(100)">
<stop stop-color="#665CEE"/>
<stop offset="1" stop-color="#332E91"/>
</radialGradient>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 1.6 KiB

View file

@ -1 +0,0 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Zhipu</title><path d="M11.991 23.503a.24.24 0 00-.244.248.24.24 0 00.244.249.24.24 0 00.245-.249.24.24 0 00-.22-.247l-.025-.001zM9.671 5.365a1.697 1.697 0 011.099 2.132l-.071.172-.016.04-.018.054c-.07.16-.104.32-.104.498-.035.71.47 1.279 1.186 1.314h.366c1.309.053 2.338 1.173 2.286 2.523-.052 1.332-1.152 2.38-2.478 2.327h-.174c-.715.018-1.274.64-1.239 1.368 0 .124.018.23.053.337.209.373.54.658.96.8.75.23 1.517-.125 1.9-.782l.018-.035c.402-.64 1.17-.96 1.92-.711.854.284 1.378 1.226 1.099 2.167a1.661 1.661 0 01-2.077 1.102 1.711 1.711 0 01-.907-.711l-.017-.035c-.2-.323-.463-.58-.851-.711l-.056-.018a1.646 1.646 0 00-1.954.746 1.66 1.66 0 01-1.065.764 1.677 1.677 0 01-1.989-1.279c-.209-.906.332-1.83 1.257-2.043a1.51 1.51 0 01.296-.035h.018c.68-.071 1.151-.622 1.116-1.333a1.307 1.307 0 00-.227-.693 2.515 2.515 0 01-.366-1.403 2.39 2.39 0 01.366-1.208c.14-.195.21-.444.227-.693.018-.71-.506-1.261-1.186-1.332l-.07-.018a1.43 1.43 0 01-.299-.07l-.05-.019a1.7 1.7 0 01-1.047-2.114 1.68 1.68 0 012.094-1.101zm-5.575 10.11c.26-.264.639-.367.994-.27.355.096.633.379.728.74.095.362-.007.748-.267 1.013-.402.41-1.053.41-1.455 0a1.062 1.062 0 010-1.482zm14.845-.294c.359-.09.738.024.992.297.254.274.344.665.237 1.025-.107.36-.396.634-.756.718-.551.128-1.1-.22-1.23-.781a1.05 1.05 0 01.757-1.26zm-.064-4.39c.314.32.49.753.49 1.206 0 .452-.176.886-.49 1.206-.315.32-.74.5-1.185.5-.444 0-.87-.18-1.184-.5a1.727 1.727 0 010-2.412 1.654 1.654 0 012.369 0zm-11.243.163c.364.484.447 1.128.218 1.691a1.665 1.665 0 01-2.188.923c-.855-.36-1.26-1.358-.907-2.228a1.68 1.68 0 011.33-1.038c.593-.08 1.183.169 1.547.652zm11.545-4.221c.368 0 .708.2.892.524.184.324.184.724 0 1.048a1.026 1.026 0 01-.892.524c-.568 0-1.03-.47-1.03-1.048 0-.579.462-1.048 1.03-1.048zm-14.358 0c.368 0 .707.2.891.524.184.324.184.724 0 1.048a1.026 1.026 0 01-.891.524c-.569 0-1.03-.47-1.03-1.048 0-.579.461-1.048 1.03-1.048zm10.031-1.475c.925 0 1.675.764 1.675 1.706s-.75 1.705-1.675 1.705-1.674-.763-1.674-1.705c0-.942.75-1.706 1.674-1.706zm-2.626-.684c.362-.082.653-.356.761-.718a1.062 1.062 0 00-.238-1.028 1.017 1.017 0 00-.996-.294c-.547.14-.881.7-.752 1.257.13.558.675.907 1.225.783zm0 16.876c.359-.087.644-.36.75-.72a1.062 1.062 0 00-.237-1.019 1.018 1.018 0 00-.985-.301 1.037 1.037 0 00-.762.717c-.108.361-.017.754.239 1.028.245.263.606.377.953.305l.043-.01zM17.19 3.5a.631.631 0 00.628-.64c0-.355-.279-.64-.628-.64a.631.631 0 00-.628.64c0 .355.28.64.628.64zm-10.38 0a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64a.631.631 0 00-.628.64c0 .355.279.64.628.64zm-5.182 7.852a.631.631 0 00-.628.64c0 .354.28.639.628.639a.63.63 0 00.627-.606l.001-.034a.62.62 0 00-.628-.64zm5.182 9.13a.631.631 0 00-.628.64c0 .355.279.64.628.64a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64zm10.38.018a.631.631 0 00-.628.64c0 .355.28.64.628.64a.631.631 0 00.628-.64c0-.355-.279-.64-.628-.64zm5.182-9.148a.631.631 0 00-.628.64c0 .354.279.639.628.639a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64zm-.384-4.992a.24.24 0 00.244-.249.24.24 0 00-.244-.249.24.24 0 00-.244.249c0 .142.122.249.244.249zM11.991.497a.24.24 0 00.245-.248A.24.24 0 0011.99 0a.24.24 0 00-.244.249c0 .133.108.236.223.247l.021.001zM2.011 6.36a.24.24 0 00.245-.249.24.24 0 00-.244-.249.24.24 0 00-.244.249.24.24 0 00.244.249zm0 11.263a.24.24 0 00-.243.248.24.24 0 00.244.249.24.24 0 00.244-.249.252.252 0 00-.244-.248zm19.995-.018a.24.24 0 00-.245.248.24.24 0 00.245.25.24.24 0 00.244-.25.252.252 0 00-.244-.248z" fill="#3859FF" fill-rule="nonzero"></path></svg>

Before

Width:  |  Height:  |  Size: 3.5 KiB

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128"><mask id="a" width="128" height="128" x="0" y="0" maskUnits="userSpaceOnUse" style="mask-type:alpha"><path fill="#fff" fill-rule="evenodd" d="M90.767 127.126a7.968 7.968 0 0 0 6.35-.244l26.353-12.681a8 8 0 0 0 4.53-7.209V21.009a8 8 0 0 0-4.53-7.21L97.117 1.12a7.97 7.97 0 0 0-9.093 1.548l-50.45 46.026L15.6 32.013a5.328 5.328 0 0 0-6.807.302l-7.048 6.411a5.335 5.335 0 0 0-.006 7.888L20.796 64 1.74 81.387a5.336 5.336 0 0 0 .006 7.887l7.048 6.411a5.327 5.327 0 0 0 6.807.303l21.974-16.68 50.45 46.025a7.96 7.96 0 0 0 2.743 1.793Zm5.252-92.183L57.74 64l38.28 29.058V34.943Z" clip-rule="evenodd"/></mask><g mask="url(#a)"><path fill="#0065A9" d="M123.471 13.82 97.097 1.12A7.973 7.973 0 0 0 88 2.668L1.662 81.387a5.333 5.333 0 0 0 .006 7.887l7.052 6.411a5.333 5.333 0 0 0 6.811.303l103.971-78.875c3.488-2.646 8.498-.158 8.498 4.22v-.306a8.001 8.001 0 0 0-4.529-7.208Z"/><g filter="url(#b)"><path fill="#007ACC" d="m123.471 114.181-26.374 12.698A7.973 7.973 0 0 1 88 125.333L1.662 46.613a5.333 5.333 0 0 1 .006-7.887l7.052-6.411a5.333 5.333 0 0 1 6.811-.303l103.971 78.874c3.488 2.647 8.498.159 8.498-4.219v.306a8.001 8.001 0 0 1-4.529 7.208Z"/></g><g filter="url(#c)"><path fill="#1F9CF0" d="M97.098 126.882A7.977 7.977 0 0 1 88 125.333c2.952 2.952 8 .861 8-3.314V5.98c0-4.175-5.048-6.266-8-3.313a7.977 7.977 0 0 1 9.098-1.549L123.467 13.8A8 8 0 0 1 128 21.01v85.982a8 8 0 0 1-4.533 7.21l-26.369 12.681Z"/></g><path fill="url(#d)" fill-rule="evenodd" d="M90.69 127.126a7.968 7.968 0 0 0 6.349-.244l26.353-12.681a8 8 0 0 0 4.53-7.21V21.009a8 8 0 0 0-4.53-7.21L97.039 1.12a7.97 7.97 0 0 0-9.093 1.548l-50.45 46.026-21.974-16.68a5.328 5.328 0 0 0-6.807.302l-7.048 6.411a5.336 5.336 0 0 0-.006 7.888L20.718 64 1.662 81.386a5.335 5.335 0 0 0 .006 7.888l7.048 6.411a5.328 5.328 0 0 0 6.807.303l21.975-16.681 50.45 46.026a7.959 7.959 0 0 0 2.742 1.793Zm5.252-92.184L57.662 64l38.28 29.057V34.943Z" clip-rule="evenodd" opacity="0.25" style="mix-blend-mode:overlay"/></g><defs><filter id="b" width="144.744" height="113.408" x="-8.41115" y="22.5944" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feColorMatrix in="SourceAlpha" result="hardAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/><feOffset/><feGaussianBlur stdDeviation="4.16667"/><feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/><feBlend in2="BackgroundImageFix" mode="overlay" result="effect1_dropShadow_1_36"/><feBlend in="SourceGraphic" in2="effect1_dropShadow_1_36" result="shape"/></filter><filter id="c" width="56.6667" height="144.007" x="79.6667" y="-8.0035" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feColorMatrix in="SourceAlpha" result="hardAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/><feOffset/><feGaussianBlur stdDeviation="4.16667"/><feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/><feBlend in2="BackgroundImageFix" mode="overlay" result="effect1_dropShadow_1_36"/><feBlend in="SourceGraphic" in2="effect1_dropShadow_1_36" result="shape"/></filter><linearGradient id="d" x1="63.9222" x2="63.9222" y1="0.329902" y2="127.67" gradientUnits="userSpaceOnUse"><stop stop-color="#fff"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></linearGradient></defs></svg>

Before

Width:  |  Height:  |  Size: 3.3 KiB

View file

@ -1,3 +1,5 @@
import { ComponentPropsWithRef, forwardRef } from "react";
import { Slottable } from "@radix-ui/react-slot";

File diff suppressed because it is too large Load diff

View file

@ -1,125 +1,46 @@
import { type FC, memo, useEffect, useMemo, useRef, useState } from "react";
import {
AlertCircleIcon,
CheckIcon,
CopyIcon,
CornerDownRightIcon,
} from "lucide-react";
import {
useClaudeChatStore,
type ClaudeStreamMessage,
type ContentBlock,
type QueuedGuidance,
} from "@/stores/claude-chat-store";
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
import { cn } from "@/lib/utils";
import { AlertCircleIcon } from "lucide-react";
import { useClaudeChatStore, type ClaudeStreamMessage, type ContentBlock } from "@/stores/claude-chat-store";
import { MarkdownRenderer } from "./markdown-renderer";
import { ThinkingWidget, ToolWidget } from "./tool-widgets";
// ─── Streaming Indicator (isolated to prevent re-render storms) ───
const StreamingIndicator: FC<{ startedAt: number | null }> = memo(
({ startedAt }) => {
const calculateElapsed = () =>
startedAt ? Math.max(0, Math.floor((Date.now() - startedAt) / 1000)) : 0;
const StreamingIndicator: FC = memo(() => {
const [elapsed, setElapsed] = useState(0);
const startRef = useRef(Date.now());
const [elapsed, setElapsed] = useState(calculateElapsed);
useEffect(() => {
setElapsed(calculateElapsed());
const timer = setInterval(() => {
setElapsed(calculateElapsed());
}, 1000);
return () => clearInterval(timer);
}, [startedAt]);
return (
<div className="flex items-center gap-1.5 px-1 py-1.5 text-muted-foreground">
<div className="flex gap-0.5">
<span
className="size-1.5 animate-bounce rounded-full bg-muted-foreground/50"
style={{ animationDelay: "0ms" }}
/>
<span
className="size-1.5 animate-bounce rounded-full bg-muted-foreground/50"
style={{ animationDelay: "150ms" }}
/>
<span
className="size-1.5 animate-bounce rounded-full bg-muted-foreground/50"
style={{ animationDelay: "300ms" }}
/>
</div>
<span className="text-sm">
Thinking...
{elapsed >= 3 && (
<span className="ml-1 text-muted-foreground/60 text-xs">
{elapsed}s
</span>
)}
</span>
</div>
);
},
);
const EMPTY_PENDING_GUIDANCE: QueuedGuidance[] = [];
const THREAD_MAX_WIDTH = "max-w-[44rem]";
const MessageActions: FC<{
text: string;
align?: "left" | "right";
}> = ({ text, align = "left" }) => {
const [copied, setCopied] = useState(false);
const canCopy = text.trim().length > 0;
const handleCopy = async () => {
if (!canCopy) return;
await navigator.clipboard.writeText(text);
setCopied(true);
window.setTimeout(() => setCopied(false), 1200);
};
if (!canCopy) return null;
useEffect(() => {
startRef.current = Date.now();
setElapsed(0);
const timer = setInterval(() => {
setElapsed(Math.floor((Date.now() - startRef.current) / 1000));
}, 1000);
return () => clearInterval(timer);
}, []);
return (
<div
className={cn(
"flex gap-1 text-muted-foreground",
align === "right" ? "justify-end" : "justify-start",
)}
>
<TooltipIconButton
tooltip={copied ? "Copied" : "Copy"}
side="top"
variant="ghost"
size="icon"
className="size-8 rounded-md text-muted-foreground hover:bg-muted hover:text-foreground"
onClick={handleCopy}
>
{copied ? (
<CheckIcon className="fade-in zoom-in-50 size-4 animate-in duration-200" />
) : (
<CopyIcon className="fade-in zoom-in-75 size-4 animate-in duration-150" />
<div className="flex items-center gap-1.5 px-1 py-1.5 text-muted-foreground">
<div className="flex gap-0.5">
<span className="size-1.5 animate-bounce rounded-full bg-muted-foreground/50" style={{ animationDelay: "0ms" }} />
<span className="size-1.5 animate-bounce rounded-full bg-muted-foreground/50" style={{ animationDelay: "150ms" }} />
<span className="size-1.5 animate-bounce rounded-full bg-muted-foreground/50" style={{ animationDelay: "300ms" }} />
</div>
<span className="text-sm">
Thinking...
{elapsed >= 3 && (
<span className="ml-1 text-xs text-muted-foreground/60">{elapsed}s</span>
)}
</TooltipIconButton>
</span>
</div>
);
};
});
// ─── Chat Messages (main component) ───
export const ChatMessages: FC = () => {
const messages = useClaudeChatStore((s) => s.messages) ?? [];
const isStreaming = useClaudeChatStore((s) => s.isStreaming);
const streamingStartedAt = useClaudeChatStore((s) => s.streamingStartedAt);
const queuedGuidance =
useClaudeChatStore(
(s) => s.tabs.find((tab) => tab.id === s.activeTabId)?.queuedGuidance,
) ?? EMPTY_PENDING_GUIDANCE;
const pendingGuidance = useMemo(
() => queuedGuidance.filter((guidance) => guidance.displayedInChat),
[queuedGuidance],
);
const viewportRef = useRef<HTMLDivElement>(null);
const shouldAutoScrollRef = useRef(true);
const userHasScrolledRef = useRef(false);
@ -155,16 +76,11 @@ export const ChatMessages: FC = () => {
return messages.filter((msg) => {
if (msg.type === "system" && msg.subtype === "init") return false;
if (
msg.type !== "user" &&
msg.type !== "assistant" &&
msg.type !== "result"
)
return false;
if (msg.type !== "user" && msg.type !== "assistant" && msg.type !== "result") return false;
if (msg.type === "user" && msg.message?.content) {
if (Array.isArray(msg.message.content)) {
const hasOnlyToolResults = msg.message.content.every(
(b: any) => b.type === "tool_result",
(b: any) => b.type === "tool_result"
);
if (hasOnlyToolResults) return false;
}
@ -184,7 +100,7 @@ export const ChatMessages: FC = () => {
behavior: "smooth",
});
}
}, [displayMessages, pendingGuidance]);
}, [displayMessages]);
// Reset auto-scroll when streaming stops
useEffect(() => {
@ -197,8 +113,7 @@ export const ChatMessages: FC = () => {
const handleScroll = () => {
if (!viewportRef.current) return;
const el = viewportRef.current;
const isAtBottom =
Math.abs(el.scrollHeight - el.scrollTop - el.clientHeight) < 50;
const isAtBottom = Math.abs(el.scrollHeight - el.scrollTop - el.clientHeight) < 50;
if (!isAtBottom) {
userHasScrolledRef.current = true;
shouldAutoScrollRef.current = false;
@ -212,36 +127,23 @@ export const ChatMessages: FC = () => {
<div
ref={viewportRef}
onScroll={handleScroll}
className="absolute inset-0 overflow-y-auto scroll-smooth px-4 pt-4"
className="absolute inset-0 overflow-y-auto scroll-smooth px-4 py-2"
>
{displayMessages.length === 0 &&
pendingGuidance.length === 0 &&
!isStreaming && (
<div className="flex h-full items-center justify-center text-muted-foreground text-sm">
Ask Claude about your LaTeX document...
</div>
)}
{displayMessages.map((msg, idx) => (
<div key={idx} className={cn("mx-auto w-full", THREAD_MAX_WIDTH)}>
<MessageBubble message={msg} toolResultMap={toolResultMap} />
</div>
))}
{isStreaming && (
<div className={cn("mx-auto w-full px-2", THREAD_MAX_WIDTH)}>
<StreamingIndicator startedAt={streamingStartedAt} />
{displayMessages.length === 0 && !isStreaming && (
<div className="flex h-full items-center justify-center text-muted-foreground text-sm">
Ask Claude about your LaTeX document...
</div>
)}
{pendingGuidance.map((guidance) => (
<div
key={guidance.id}
className={cn("mx-auto w-full", THREAD_MAX_WIDTH)}
>
<PendingGuidanceMessage guidance={guidance} />
</div>
{displayMessages.map((msg, idx) => (
<MessageBubble
key={idx}
message={msg}
toolResultMap={toolResultMap}
/>
))}
{isStreaming && <StreamingIndicator />}
</div>
);
};
@ -279,28 +181,23 @@ const UserMessage: FC<{ message: ClaudeStreamMessage }> = ({ message }) => {
if (!textContent) return null;
const firstLineMatch = textContent.match(/^([^\n]+)\n([\s\S]*)$/);
const firstLine = firstLineMatch?.[1]?.trim() ?? "";
const hasContextLabel =
firstLine.startsWith("@") ||
firstLine.startsWith("~@") ||
/^Pasted image(?: \d+)?(?:, Pasted image(?: \d+)?)*$/.test(firstLine);
const contextLabel = hasContextLabel ? firstLine : null;
const bodyText =
hasContextLabel && firstLineMatch ? firstLineMatch[2] : textContent;
// Parse leading @file:line:col or ~@file:line context reference
const contextMatch = textContent.match(/^(~?@[^\n]+)\n([\s\S]*)$/);
const contextLabel = contextMatch?.[1] ?? null;
const bodyText = contextMatch ? contextMatch[2] : textContent;
// Parse error block patterns for styled rendering:
// Lint single: "[Lint error in FILE:LINE]\n[Error: MSG]\n\nPrompt"
// Lint multi: "[Lint errors in FILE]\n- FILE:LINE — MSG\n...\n\nPrompt"
// Compile: "[Compilation errors]\n- error1\n- error2\n...\n\nPrompt"
const lintSingleMatch = bodyText.match(
/^\[Lint error in ([^\]]+)\]\n\[Error: ([^\]]+)\]\n\n([\s\S]*)$/,
/^\[Lint error in ([^\]]+)\]\n\[Error: ([^\]]+)\]\n\n([\s\S]*)$/
);
const lintMultiMatch = bodyText.match(
/^\[Lint errors in ([^\]]+)\]\n((?:- .+\n?)+)\n([\s\S]*)$/,
/^\[Lint errors in ([^\]]+)\]\n((?:- .+\n?)+)\n([\s\S]*)$/
);
const compileErrorMatch = bodyText.match(
/^\[Compilation errors\]\n((?:- .+\n?)+)\n([\s\S]*)$/,
/^\[Compilation errors\]\n((?:- .+\n?)+)\n([\s\S]*)$/
);
// Shared error block renderer
@ -309,34 +206,23 @@ const UserMessage: FC<{ message: ClaudeStreamMessage }> = ({ message }) => {
errors: { message: string; location?: string }[],
prompt: string,
) => (
<div className="fade-in slide-in-from-bottom-1 grid w-full animate-in auto-rows-auto grid-cols-[minmax(72px,1fr)_auto] content-start gap-y-2 px-2 py-3 duration-150 [&:where(>*)]:col-start-2">
<div className="relative col-start-2 min-w-0">
<div className="wrap-break-word rounded-xl bg-muted px-4 py-2 text-foreground text-sm empty:hidden">
<div className="mb-2 rounded-lg border border-red-500/20 bg-red-500/10 px-2.5 py-2">
<div className="mb-1.5 font-medium text-red-400 text-xs">
{title}
</div>
<div className="space-y-1">
{errors.map((e, i) => (
<div key={i} className="flex items-start gap-1.5">
<AlertCircleIcon className="mt-0.5 size-3 shrink-0 text-red-400/70" />
<span className="flex-1 text-foreground/80 text-xs">
{e.message}
</span>
{e.location && (
<span className="shrink-0 font-mono text-muted-foreground text-xs">
{e.location}
</span>
)}
</div>
))}
</div>
<div className="flex w-full flex-col items-end py-1.5">
<div className="max-w-[85%] rounded-xl bg-muted px-3 py-2 text-foreground text-sm">
<div className="mb-2 rounded-lg border border-red-500/20 bg-red-500/10 px-2.5 py-2">
<div className="mb-1.5 text-xs font-medium text-red-400">{title}</div>
<div className="space-y-1">
{errors.map((e, i) => (
<div key={i} className="flex items-start gap-1.5">
<AlertCircleIcon className="mt-0.5 size-3 shrink-0 text-red-400/70" />
<span className="flex-1 text-xs text-foreground/80">{e.message}</span>
{e.location && (
<span className="shrink-0 font-mono text-xs text-muted-foreground">{e.location}</span>
)}
</div>
))}
</div>
<span className="text-muted-foreground">{prompt}</span>
</div>
</div>
<div className="col-span-full col-start-1 row-start-2 -mr-1 flex justify-end">
<MessageActions text={bodyText} align="right" />
<span className="text-muted-foreground">{prompt}</span>
</div>
</div>
);
@ -352,26 +238,18 @@ const UserMessage: FC<{ message: ClaudeStreamMessage }> = ({ message }) => {
if (lintMultiMatch) {
const [, fileName, errorLines, prompt] = lintMultiMatch;
const errors = errorLines
.trim()
.split("\n")
.map((line) => {
const m = line.match(/^- (.+?):(\d+) — (.+)$/);
return m
? { message: m[3], location: `${m[1]}:${m[2]}` }
: { message: line.replace(/^- /, "") };
});
const errors = errorLines.trim().split("\n").map((line) => {
const m = line.match(/^- (.+?):(\d+) — (.+)$/);
return m ? { message: m[3], location: `${m[1]}:${m[2]}` } : { message: line.replace(/^- /, "") };
});
return renderErrorBlock(`Lint Errors — ${fileName}`, errors, prompt);
}
if (compileErrorMatch) {
const [, errorLines, prompt] = compileErrorMatch;
const errors = errorLines
.trim()
.split("\n")
.map((line) => ({
message: line.replace(/^- /, ""),
}));
const errors = errorLines.trim().split("\n").map((line) => ({
message: line.replace(/^- /, ""),
}));
return renderErrorBlock(
`Compilation ${errors.length === 1 ? "Error" : "Errors"}`,
errors,
@ -380,23 +258,18 @@ const UserMessage: FC<{ message: ClaudeStreamMessage }> = ({ message }) => {
}
return (
<div className="fade-in slide-in-from-bottom-1 grid w-full animate-in auto-rows-auto grid-cols-[minmax(72px,1fr)_auto] content-start gap-y-2 px-2 py-3 duration-150 [&:where(>*)]:col-start-2">
<div className="relative col-start-2 min-w-0">
<div className="wrap-break-word rounded-xl bg-muted px-4 py-2 text-foreground text-sm empty:hidden">
{contextLabel && (
<span className="mb-1 inline-flex items-center rounded-md bg-background/60 px-1.5 py-0.5 font-mono text-muted-foreground text-xs">
{contextLabel}
</span>
)}
{contextLabel && bodyText && <br />}
<MarkdownRenderer
content={bodyText}
className="prose prose-sm dark:prose-invert max-w-none [&>*:first-child]:mt-0 [&>*:last-child]:mb-0"
/>
</div>
</div>
<div className="col-span-full col-start-1 row-start-2 -mr-1 flex justify-end">
<MessageActions text={textContent} align="right" />
<div className="flex w-full flex-col items-end py-1.5">
<div className="max-w-[85%] rounded-xl bg-muted px-3 py-1.5 text-foreground text-sm">
{contextLabel && (
<span className="mb-1 inline-flex items-center rounded-md bg-background/60 px-1.5 py-0.5 font-mono text-xs text-muted-foreground">
{contextLabel}
</span>
)}
{contextLabel && bodyText && <br />}
<MarkdownRenderer
content={bodyText}
className="prose prose-sm dark:prose-invert max-w-none [&>*:first-child]:mt-0 [&>*:last-child]:mb-0"
/>
</div>
</div>
);
@ -404,40 +277,6 @@ const UserMessage: FC<{ message: ClaudeStreamMessage }> = ({ message }) => {
// ─── Assistant Message ───
const PendingGuidanceMessage: FC<{ guidance: QueuedGuidance }> = ({
guidance,
}) => {
const contextLabel = guidance.contextOverride?.label ?? null;
const copyText = contextLabel
? `${contextLabel}\n${guidance.prompt}`
: guidance.prompt;
return (
<div className="fade-in slide-in-from-bottom-1 grid w-full animate-in auto-rows-auto grid-cols-[minmax(72px,1fr)_auto] content-start gap-y-2 px-2 py-3 duration-150 [&:where(>*)]:col-start-2">
<div className="relative col-start-2 min-w-0">
<div className="wrap-break-word rounded-xl bg-muted px-4 py-2 text-foreground text-sm empty:hidden">
{contextLabel && (
<span className="mb-1 inline-flex items-center rounded-md bg-background/60 px-1.5 py-0.5 font-mono text-muted-foreground text-xs">
{contextLabel}
</span>
)}
{contextLabel && guidance.prompt && <br />}
<div className="flex min-w-0 items-start gap-2">
<CornerDownRightIcon className="mt-0.5 size-3.5 shrink-0 text-muted-foreground/70" />
<MarkdownRenderer
content={guidance.prompt}
className="prose prose-sm dark:prose-invert min-w-0 max-w-none flex-1 break-words [&>*:first-child]:mt-0 [&>*:last-child]:mb-0"
/>
</div>
</div>
</div>
<div className="col-span-full col-start-1 row-start-2 -mr-1 flex justify-end">
<MessageActions text={copyText} align="right" />
</div>
</div>
);
};
const AssistantMessage: FC<{
message: ClaudeStreamMessage;
toolResultMap: Map<string, ContentBlock>;
@ -449,20 +288,24 @@ const AssistantMessage: FC<{
(block) =>
(block.type === "text" && block.text) ||
(block.type === "thinking" && block.thinking) ||
(block.type === "tool_use" && block.id),
(block.type === "tool_use" && block.id)
);
if (!hasRenderableContent) return null;
const copyText = content
.filter((block) => block.type === "text" && block.text)
.map((block) => block.text)
.join("\n\n");
return (
<div className="fade-in slide-in-from-bottom-1 relative mx-auto w-full animate-in py-3 duration-150">
<div className="wrap-break-word px-2 text-foreground text-sm leading-relaxed">
<div className="w-full py-1.5">
<div className="px-1 text-foreground text-sm leading-relaxed">
{content.map((block, idx) => {
if (block.type === "thinking" && block.thinking) {
return (
<ThinkingWidget
key={idx}
thinking={block.thinking}
signature={block.signature}
/>
);
}
if (block.type === "text" && block.text) {
return (
<MarkdownRenderer
@ -474,23 +317,17 @@ const AssistantMessage: FC<{
}
if (block.type === "tool_use" && block.id) {
const result = toolResultMap.get(block.id);
return <ToolWidget key={idx} toolUse={block} toolResult={result} />;
}
if (block.type === "thinking" && block.thinking) {
return (
<ThinkingWidget
<ToolWidget
key={idx}
thinking={block.thinking}
signature={block.signature}
toolUse={block}
toolResult={result}
/>
);
}
return null;
})}
</div>
<div className="-mb-7.5 ml-2 flex min-h-7.5 items-center pt-1.5">
<MessageActions text={copyText} />
</div>
</div>
);
};
@ -504,8 +341,8 @@ const ResultMessage: FC<{ message: ClaudeStreamMessage }> = ({ message }) => {
if (!resultText) return null;
return (
<div className="fade-in slide-in-from-bottom-1 relative mx-auto w-full animate-in py-3 duration-150">
<div className="wrap-break-word px-2 text-foreground text-sm leading-relaxed">
<div className="w-full py-1.5">
<div className="px-1 text-foreground text-sm leading-relaxed">
{isError ? (
<div className="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-destructive text-sm">
{resultText}
@ -517,9 +354,6 @@ const ResultMessage: FC<{ message: ClaudeStreamMessage }> = ({ message }) => {
/>
)}
</div>
<div className="-mb-7.5 ml-2 flex min-h-7.5 items-center pt-1.5">
<MessageActions text={resultText} />
</div>
{message.cost_usd != null && (
<div className="mt-1 px-1 text-right text-muted-foreground text-xs">
Cost: ${message.cost_usd.toFixed(4)}

View file

@ -14,14 +14,8 @@ export function ChatTabBar() {
// Scroll active tab into view when it changes
useEffect(() => {
const el = scrollRef.current?.querySelector(
`[data-tab-id="${activeTabId}"]`,
);
el?.scrollIntoView({
behavior: "smooth",
block: "nearest",
inline: "nearest",
});
const el = scrollRef.current?.querySelector(`[data-tab-id="${activeTabId}"]`);
el?.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "nearest" });
}, [activeTabId]);
// Keyboard shortcuts: Ctrl+Tab / Ctrl+Shift+Tab to switch tabs, Ctrl+T new, Ctrl+W close
@ -78,10 +72,10 @@ export function ChatTabBar() {
);
return (
<div className="flex items-center border-border border-b">
<div className="flex items-center border-b border-border">
<div
ref={scrollRef}
className="scrollbar-none flex min-w-0 flex-1 items-center overflow-x-auto"
className="flex min-w-0 flex-1 items-center overflow-x-auto scrollbar-none"
>
{tabs.map((tab) => (
<TabButton
@ -95,11 +89,11 @@ export function ChatTabBar() {
/>
))}
</div>
<div className="flex shrink-0 items-center gap-0.5 pr-3">
<div className="flex shrink-0 items-center">
<button
type="button"
onClick={handleCreate}
className="flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
className="flex size-7 items-center justify-center text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
aria-label="New tab"
>
<PlusIcon className="size-3.5" />

View file

@ -13,16 +13,14 @@ import { ChatMessages } from "./chat-messages";
import { ChatComposer } from "./chat-composer";
import { ChatTabBar } from "./chat-tab-bar";
const MIN_HEIGHT = 260;
const MIN_HEIGHT = 150;
const DEFAULT_HEIGHT = 360;
export function ClaudeChatDrawer() {
// Initialize event listeners for Claude streaming
useClaudeEvents();
const anyStreaming = useClaudeChatStore((s) =>
s.tabs.some((t) => t.isStreaming),
);
const anyStreaming = useClaudeChatStore((s) => s.tabs.some((t) => t.isStreaming));
const error = useClaudeChatStore((s) => s.error);
const [isOpen, setIsOpen] = useState(false);
@ -43,58 +41,50 @@ export function ClaudeChatDrawer() {
if (shouldOpen && !isOpen) {
setIsOpen(true);
const parent = containerRef.current?.parentElement;
const maxHeight = parent
? Math.max(MIN_HEIGHT, parent.clientHeight * 0.5)
: 400;
const nextHeight = Math.max(maxHeight, MIN_HEIGHT);
setHeight(nextHeight);
heightRef.current = nextHeight;
const maxHeight = parent ? parent.clientHeight * 0.5 : 400;
setHeight(maxHeight);
heightRef.current = maxHeight;
if (panelRef.current) {
panelRef.current.style.height = `${nextHeight}px`;
panelRef.current.style.height = `${maxHeight}px`;
}
}
}, [anyStreaming, isOpen, pendingAttachments]);
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
if (isExpanded) return;
const handleMouseDown = useCallback((e: React.MouseEvent) => {
if (isExpanded) return;
e.preventDefault();
setIsDragging(true);
hasDraggedRef.current = false;
e.preventDefault();
setIsDragging(true);
hasDraggedRef.current = false;
const startY = e.clientY;
const startHeight = heightRef.current;
const startY = e.clientY;
const startHeight = heightRef.current;
const handleMouseMove = (e: MouseEvent) => {
hasDraggedRef.current = true;
const parent = containerRef.current?.parentElement;
const maxHeight = parent
? Math.max(MIN_HEIGHT, parent.clientHeight * 0.5)
: 400;
const delta = startY - e.clientY;
const newHeight = Math.min(
Math.max(startHeight + delta, MIN_HEIGHT),
maxHeight,
);
heightRef.current = newHeight;
if (panelRef.current) {
panelRef.current.style.height = `${newHeight}px`;
}
};
const handleMouseMove = (e: MouseEvent) => {
hasDraggedRef.current = true;
const parent = containerRef.current?.parentElement;
const maxHeight = parent ? parent.clientHeight * 0.5 : 400;
const delta = startY - e.clientY;
const newHeight = Math.min(
Math.max(startHeight + delta, MIN_HEIGHT),
maxHeight
);
heightRef.current = newHeight;
if (panelRef.current) {
panelRef.current.style.height = `${newHeight}px`;
}
};
const handleMouseUp = () => {
setIsDragging(false);
setHeight(heightRef.current);
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
const handleMouseUp = () => {
setIsDragging(false);
setHeight(heightRef.current);
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
},
[isExpanded],
);
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
}, [isExpanded]);
// Compute expanded dimensions from parent
const getExpandedDimensions = useCallback(() => {
@ -113,12 +103,7 @@ export function ClaudeChatDrawer() {
const dims = getExpandedDimensions();
return { height: dims.height, maxWidth: dims.width, borderRadius: 0 };
}
return {
height: Math.max(height, MIN_HEIGHT),
minHeight: MIN_HEIGHT,
maxWidth: 672,
borderRadius: 24,
};
return { height, maxWidth: 672, borderRadius: 24 };
};
return (
@ -126,7 +111,7 @@ export function ClaudeChatDrawer() {
ref={containerRef}
className={cn(
"pointer-events-none absolute inset-0 z-10 flex items-end justify-center transition-[padding] duration-300 ease-out",
isExpanded ? "p-0" : "px-4 pt-4 pb-6",
isExpanded ? "p-0" : "px-4 pb-6 pt-4"
)}
>
{/* Floating toggle button */}
@ -137,7 +122,7 @@ export function ClaudeChatDrawer() {
"pointer-events-auto absolute right-4 bottom-6 flex size-12 items-center justify-center rounded-full border border-border bg-background shadow-lg transition-all duration-300 ease-out hover:scale-105 hover:shadow-xl",
isOpen
? "pointer-events-none scale-50 opacity-0"
: "scale-100 opacity-100",
: "scale-100 opacity-100"
)}
aria-label="Open AI Assistant"
>
@ -149,20 +134,18 @@ export function ClaudeChatDrawer() {
ref={panelRef}
className={cn(
"pointer-events-auto flex w-full flex-col overflow-hidden border bg-background transition-[height,max-width,border-radius,border-color,box-shadow,opacity,transform] duration-300 ease-out",
isExpanded
? "border-transparent shadow-none"
: "border-border shadow-2xl",
isExpanded ? "border-transparent shadow-none" : "border-border shadow-2xl",
isOpen
? "scale-100 opacity-100"
: "pointer-events-none origin-bottom scale-95 opacity-0",
isDragging && "!transition-none",
isDragging && "!transition-none"
)}
style={panelStyle()}
>
{/* Header with drag handle, tab bar, and session selector */}
{isExpanded ? (
<>
<div className="flex items-center justify-start border-border border-b px-2 py-1">
<div className="flex items-center justify-start border-b border-border px-2 py-1">
<button
type="button"
onClick={() => setIsExpanded(false)}
@ -206,7 +189,7 @@ export function ClaudeChatDrawer() {
{/* Error banner */}
{error && (
<div className="mx-3 mt-2 mb-1 rounded-lg border border-destructive/50 bg-destructive/10 px-3 py-1.5 text-destructive text-xs">
<div className="mx-3 mb-1 rounded-lg border border-destructive/50 bg-destructive/10 px-3 py-1.5 text-destructive text-xs">
{error}
</div>
)}

View file

@ -17,48 +17,19 @@ import { invoke } from "@tauri-apps/api/core";
import "katex/dist/katex.min.css";
import { useDocumentStore } from "@/stores/document-store";
import { cn } from "@/lib/utils";
// ─── Shell Detection ───
const SHELL_LANGUAGES = new Set([
"bash",
"sh",
"shell",
"zsh",
"fish",
"terminal",
"console",
"bash", "sh", "shell", "zsh", "fish", "terminal", "console",
]);
function looksLikeShellCommand(code: string): boolean {
const firstLine = code
.trim()
.split("\n")[0]
.replace(/^[$#]\s*/, "")
.trim();
const firstLine = code.trim().split("\n")[0].replace(/^[\$#]\s*/, "").trim();
const prefixes = [
"wget",
"curl",
"tlmgr",
"apt",
"brew",
"npm",
"pip",
"sudo",
"mkdir",
"cd ",
"cp ",
"mv ",
"rm ",
"git ",
"make",
"tar ",
"unzip",
"latexmk",
"pdflatex",
"xelatex",
"bibtex",
"wget", "curl", "tlmgr", "apt", "brew", "npm", "pip", "sudo",
"mkdir", "cd ", "cp ", "mv ", "rm ", "git ", "make", "tar ", "unzip",
"latexmk", "pdflatex", "xelatex", "bibtex",
];
return prefixes.some((p) => firstLine.startsWith(p));
}
@ -84,73 +55,21 @@ export const MarkdownRenderer: FC<MarkdownRendererProps> = ({
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkMath]}
rehypePlugins={[rehypeKatex]}
className={cn(
"min-w-0 max-w-full break-words [overflow-wrap:anywhere] [&_*]:max-w-full",
className ?? "prose prose-sm dark:prose-invert max-w-none",
)}
className={className ?? "prose prose-sm dark:prose-invert max-w-none"}
components={{
pre({ children }) {
return <>{children}</>;
},
table({ children, node, ...props }) {
return (
<div className="my-3 w-full overflow-x-auto rounded-lg border border-border">
<table
className="m-0 w-full border-collapse text-left text-sm"
{...props}
>
{children}
</table>
</div>
);
},
thead({ children, node, ...props }) {
return (
<thead className="bg-muted/70" {...props}>
{children}
</thead>
);
},
th({ children, node, ...props }) {
return (
<th
className="border-border border-r border-b px-3 py-2 font-medium text-foreground last:border-r-0"
{...props}
>
{children}
</th>
);
},
td({ children, node, ...props }) {
return (
<td
className="border-border border-t border-r px-3 py-2 align-top text-foreground last:border-r-0"
{...props}
>
{children}
</td>
);
},
hr({ node, ...props }) {
return <hr className="my-5 border-border border-t" {...props} />;
},
code({ className: codeClassName, children, node, ...props }) {
const match = /language-(\w+)/.exec(codeClassName || "");
const language = match?.[1];
const code = String(children).replace(/\n$/, "");
const isBlock =
node?.position &&
const isBlock = node?.position &&
node.position.start.line !== node.position.end.line;
if (!match && !isBlock) {
return (
<code
className={cn(
"break-words [overflow-wrap:anywhere]",
codeClassName,
)}
{...props}
>
<code className={codeClassName} {...props}>
{children}
</code>
);
@ -219,12 +138,9 @@ const CodeBlock: FC<{ language: string; code: string }> = ({
stderr: result.stderr,
});
// Refresh file tree to pick up any new/deleted files
useDocumentStore
.getState()
.refreshFiles()
.catch((err) => {
console.error("Failed to refresh files:", err);
});
useDocumentStore.getState().refreshFiles().catch((err) => {
console.error("Failed to refresh files:", err);
});
} catch (err: any) {
setRunState({ status: "error", message: err?.message || String(err) });
}
@ -270,10 +186,7 @@ const CodeBlock: FC<{ language: string; code: string }> = ({
<div className="mb-1.5 flex items-center gap-1.5 text-muted-foreground">
<AlertTriangleIcon className="size-3.5 text-yellow-500" />
<span className="text-xs">
Run in{" "}
<code className="rounded bg-muted px-1 text-xs">
{projectRoot?.split(/[/\\]/).pop()}/
</code>
Run in <code className="rounded bg-muted px-1 text-xs">{projectRoot?.split("/").pop()}/</code>
</span>
</div>
<div className="flex gap-2">
@ -300,9 +213,7 @@ const CodeBlock: FC<{ language: string; code: string }> = ({
{runState.status === "running" && (
<div className="mt-1 flex items-center gap-2 rounded-lg border border-border bg-[#1e1e2e] px-3 py-2 text-sm">
<LoaderIcon className="size-3.5 animate-spin text-muted-foreground" />
<span className="font-mono text-muted-foreground text-xs">
Running...
</span>
<span className="font-mono text-muted-foreground text-xs">Running...</span>
</div>
)}
@ -335,9 +246,9 @@ const CommandOutput: FC<{
}> = ({ exitCode, stdout, stderr }) => {
const [expanded, setExpanded] = useState(true);
const success = exitCode === 0;
const output = (stdout + (stderr ? `\n${stderr}` : "")).trim();
const output = (stdout + (stderr ? "\n" + stderr : "")).trim();
const truncated =
output.length > 2000 ? `${output.slice(0, 2000)}\n...` : output;
output.length > 2000 ? output.slice(0, 2000) + "\n..." : output;
return (
<div className="mt-1 rounded-lg border border-border bg-[#1e1e2e] text-sm">
@ -365,8 +276,8 @@ const CommandOutput: FC<{
</span>
</button>
{expanded && truncated && (
<div className="max-h-40 overflow-auto border-border/50 border-t px-3 py-2">
<pre className="whitespace-pre-wrap font-mono text-gray-300 text-xs">
<div className="max-h-40 overflow-auto border-t border-border/50 px-3 py-2">
<pre className="whitespace-pre-wrap font-mono text-xs text-gray-300">
{truncated}
</pre>
</div>

View file

@ -1,4 +1,4 @@
import { type FC, useEffect, useRef, useState } from "react";
import { type FC } from "react";
import { Check, X } from "lucide-react";
import { type ProposedChange } from "@/stores/proposed-changes-store";
@ -17,79 +17,42 @@ export const ProposedChangesPanel: FC<ProposedChangesPanelProps> = ({
onKeep,
onUndo,
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const [containerWidth, setContainerWidth] = useState(0);
const oldLines = change.oldContent.split("\n").length;
const newLines = change.newContent.split("\n").length;
const added = Math.max(0, newLines - oldLines);
const removed = Math.max(0, oldLines - newLines);
const compactTitle = containerWidth > 0 && containerWidth < 680;
const hideToolName = containerWidth > 0 && containerWidth < 920;
useEffect(() => {
const element = containerRef.current;
if (!element) return;
const updateWidth = () => setContainerWidth(element.clientWidth);
updateWidth();
const observer = new ResizeObserver(updateWidth);
observer.observe(element);
return () => observer.disconnect();
}, []);
return (
<div
ref={containerRef}
className="grid h-9 min-w-0 shrink-0 grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-3 overflow-hidden border-border border-t bg-muted/50 px-3"
>
<div className="flex shrink-0 items-center gap-2 text-sm">
<span className="whitespace-nowrap font-medium text-foreground">
{compactTitle ? "Changes" : "Proposed Changes"}
</span>
<div className="flex items-center justify-between border-border border-t bg-muted/50 px-3 py-1.5">
<div className="flex items-center gap-2 text-sm">
<span className="font-medium text-foreground">Proposed Changes</span>
{totalChanges > 1 && (
<span className="shrink-0 whitespace-nowrap rounded bg-violet-500/15 px-1.5 py-0.5 font-medium text-violet-600 text-xs dark:text-violet-400">
<span className="rounded bg-violet-500/15 px-1.5 py-0.5 text-xs font-medium text-violet-600 dark:text-violet-400">
{changeIndex + 1}/{totalChanges} files
</span>
)}
<span className="text-muted-foreground">{change.filePath}</span>
<span className="text-muted-foreground">{change.toolName}</span>
{added > 0 && <span className="text-green-400">+{added}</span>}
{removed > 0 && <span className="text-red-400">-{removed}</span>}
</div>
<div className="flex min-w-0 items-center gap-2 overflow-hidden text-sm">
<span className="min-w-0 truncate text-muted-foreground">
{change.filePath}
</span>
{!hideToolName && (
<span className="shrink-0 text-muted-foreground">
{change.toolName}
</span>
)}
{added > 0 && <span className="shrink-0 text-green-400">+{added}</span>}
{removed > 0 && (
<span className="shrink-0 text-red-400">-{removed}</span>
)}
</div>
<div className="flex shrink-0 items-center gap-1.5">
<div className="flex items-center gap-1.5">
<button
type="button"
onClick={onKeep}
className="flex items-center justify-center gap-1 rounded-md bg-green-600/20 px-2.5 py-1 text-green-400 text-xs transition-colors hover:bg-green-600/30"
title="Keep All"
aria-label="Keep All"
className="flex items-center gap-1 rounded-md bg-green-600/20 px-2.5 py-1 text-green-400 text-xs hover:bg-green-600/30 transition-colors"
>
<Check className="size-3.5" />
<span className="whitespace-nowrap">Keep All</span>
Keep All
<kbd className="ml-1 rounded bg-green-600/20 px-1 py-0.5 font-mono text-[10px]">
⌘Y
</kbd>
</button>
<button
type="button"
onClick={onUndo}
className="flex items-center justify-center gap-1 rounded-md bg-red-600/20 px-2.5 py-1 text-red-400 text-xs transition-colors hover:bg-red-600/30"
title="Undo All"
aria-label="Undo All"
className="flex items-center gap-1 rounded-md bg-red-600/20 px-2.5 py-1 text-red-400 text-xs hover:bg-red-600/30 transition-colors"
>
<X className="size-3.5" />
<span className="whitespace-nowrap">Undo All</span>
Undo All
<kbd className="ml-1 rounded bg-red-600/20 px-1 py-0.5 font-mono text-[10px]">
⌘N
</kbd>

View file

@ -1,12 +1,6 @@
import { useState, useCallback, useMemo } from "react";
import { useState, useCallback } from "react";
import { invoke } from "@tauri-apps/api/core";
import {
HistoryIcon,
PlusIcon,
CheckIcon,
Loader2Icon,
Trash2Icon,
} from "lucide-react";
import { HistoryIcon, PlusIcon, CheckIcon, Loader2Icon } from "lucide-react";
import {
DropdownMenu,
DropdownMenuTrigger,
@ -15,20 +9,8 @@ import {
DropdownMenuSeparator,
DropdownMenuLabel,
} from "@/components/ui/dropdown-menu";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { useClaudeChatStore } from "@/stores/claude-chat-store";
import { useDocumentStore } from "@/stores/document-store";
import { createLogger } from "@/lib/debug/logger";
const log = createLogger("session-selector");
interface ClaudeSessionInfo {
session_id: string;
@ -52,53 +34,30 @@ function formatRelativeTime(unixSeconds: number): string {
export function SessionSelector() {
const [sessions, setSessions] = useState<ClaudeSessionInfo[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [deletingId, setDeletingId] = useState<string | null>(null);
const [deleteTarget, setDeleteTarget] = useState<ClaudeSessionInfo | null>(
null,
);
const [deleteError, setDeleteError] = useState<string | null>(null);
const sessionId = useClaudeChatStore((s) => s.sessionId);
const tabs = useClaudeChatStore((s) => s.tabs);
const isStreaming = useClaudeChatStore((s) => s.isStreaming);
const newSession = useClaudeChatStore((s) => s.newSession);
const resumeSession = useClaudeChatStore((s) => s.resumeSession);
const setSessionTitle = useClaudeChatStore((s) => s._setSessionTitle);
const projectRoot = useDocumentStore((s) => s.projectRoot);
const streamingSessionIds = useMemo(
() =>
new Set(
tabs
.filter(
(tab) =>
tab.projectPath === projectRoot &&
tab.isStreaming &&
tab.sessionId,
)
.map((tab) => tab.sessionId as string),
),
[projectRoot, tabs],
);
const loadSessions = useCallback(async () => {
if (!projectRoot) return;
setIsLoading(true);
log.debug(`loading sessions for projectRoot: ${projectRoot}`);
console.log("[session-selector] loading sessions for projectRoot:", projectRoot);
try {
const result = await invoke<ClaudeSessionInfo[]>("list_claude_sessions", {
projectPath: projectRoot,
generateTitles: false,
});
log.debug("loaded sessions", { count: result.length });
const result = await invoke<ClaudeSessionInfo[]>(
"list_claude_sessions",
{ projectPath: projectRoot },
);
console.log("[session-selector] loaded sessions:", result);
setSessions(result);
for (const session of result) {
setSessionTitle(session.session_id, session.title);
}
} catch (err) {
log.error("Failed to load sessions", { error: String(err) });
console.error("[session-selector] Failed to load sessions:", err);
setSessions([]);
} finally {
setIsLoading(false);
}
}, [projectRoot, setSessionTitle]);
}, [projectRoot]);
const handleOpenChange = useCallback(
(open: boolean) => {
@ -110,204 +69,77 @@ export function SessionSelector() {
);
const handleSelectSession = useCallback(
(session: ClaudeSessionInfo) => {
if (deletingId === session.session_id) return;
if (session.session_id === sessionId) return;
log.debug(`selecting session: ${session.session_id}`);
resumeSession(session.session_id, session.title);
(sid: string) => {
if (isStreaming) return;
if (sid === sessionId) return;
console.log("[session-selector] selecting session:", sid);
resumeSession(sid);
},
[deletingId, sessionId, resumeSession],
);
const handleDeleteSession = useCallback(
async (sid: string) => {
if (deletingId || !projectRoot || streamingSessionIds.has(sid)) return;
setDeleteError(null);
setDeletingId(sid);
try {
await invoke("delete_claude_session", {
projectPath: projectRoot,
sessionId: sid,
});
setSessions((prev) => prev.filter((item) => item.session_id !== sid));
if (sid === sessionId) {
newSession();
}
setDeleteTarget((current) =>
current?.session_id === sid ? null : current,
);
} catch (err) {
log.error("Failed to delete session", {
sessionId: sid,
error: String(err),
});
setDeleteError(err instanceof Error ? err.message : String(err));
} finally {
setDeletingId((current) => (current === sid ? null : current));
}
},
[deletingId, newSession, projectRoot, sessionId, streamingSessionIds],
[isStreaming, sessionId, resumeSession],
);
const handleNewChat = useCallback(() => {
if (isStreaming) return;
newSession();
}, [newSession]);
}, [isStreaming, newSession]);
return (
<>
<DropdownMenu onOpenChange={handleOpenChange}>
<DropdownMenuTrigger asChild>
<button
type="button"
className="flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
aria-label="Session history"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
>
<HistoryIcon className="size-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
side="bottom"
className="max-h-80 w-72 overflow-y-auto"
<DropdownMenu onOpenChange={handleOpenChange}>
<DropdownMenuTrigger asChild>
<button
type="button"
className="flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
aria-label="Session history"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
>
<DropdownMenuLabel>Sessions</DropdownMenuLabel>
<HistoryIcon className="size-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuItem onSelect={handleNewChat}>
<PlusIcon className="size-4" />
<span>New Chat</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
{isLoading ? (
<div className="flex items-center justify-center py-4">
<Loader2Icon className="size-4 animate-spin text-muted-foreground" />
</div>
) : sessions.length === 0 ? (
<div className="px-2 py-4 text-center text-muted-foreground text-sm">
No previous sessions
</div>
) : (
sessions.map((session) => {
const sessionIsStreaming = streamingSessionIds.has(
session.session_id,
);
return (
<DropdownMenuItem
key={session.session_id}
onSelect={() => handleSelectSession(session)}
disabled={deletingId === session.session_id}
className="group flex items-start gap-2"
>
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-sm">{session.title}</span>
<span className="text-muted-foreground text-xs">
{formatRelativeTime(session.last_modified)}
</span>
</div>
<div className="flex shrink-0 items-center gap-1">
{sessionIsStreaming ? (
<Loader2Icon className="size-4 animate-spin text-primary" />
) : (
session.session_id === sessionId && (
<CheckIcon className="size-4 text-primary" />
)
)}
<button
type="button"
className="flex size-6 items-center justify-center rounded-sm text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-40"
aria-label={`Delete ${session.title}`}
title={
sessionIsStreaming
? "Cannot delete a running session"
: "Delete session"
}
disabled={
sessionIsStreaming || deletingId === session.session_id
}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
setDeleteError(null);
setDeleteTarget(session);
}}
>
{deletingId === session.session_id ? (
<Loader2Icon className="size-3.5 animate-spin" />
) : (
<Trash2Icon className="size-3.5" />
)}
</button>
</div>
</DropdownMenuItem>
);
})
)}
</DropdownMenuContent>
</DropdownMenu>
<Dialog
open={!!deleteTarget}
onOpenChange={(open) => {
if (!open && !deletingId) {
setDeleteTarget(null);
setDeleteError(null);
}
}}
<DropdownMenuContent
align="end"
side="bottom"
className="max-h-80 w-72 overflow-y-auto"
>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>Delete Chat</DialogTitle>
<DialogDescription>
Delete "{deleteTarget?.title || "this session"}" from this
project?
</DialogDescription>
</DialogHeader>
{deleteError && (
<p className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-destructive text-xs">
{deleteError}
</p>
)}
<DialogFooter>
<Button
variant="outline"
onClick={() => {
if (deletingId) return;
setDeleteTarget(null);
setDeleteError(null);
}}
disabled={!!deletingId}
<DropdownMenuLabel>Sessions</DropdownMenuLabel>
<DropdownMenuItem onSelect={handleNewChat} disabled={isStreaming}>
<PlusIcon className="size-4" />
<span>New Chat</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
{isLoading ? (
<div className="flex items-center justify-center py-4">
<Loader2Icon className="size-4 animate-spin text-muted-foreground" />
</div>
) : sessions.length === 0 ? (
<div className="px-2 py-4 text-center text-sm text-muted-foreground">
No previous sessions
</div>
) : (
sessions.map((session) => (
<DropdownMenuItem
key={session.session_id}
onSelect={() => handleSelectSession(session.session_id)}
disabled={isStreaming}
className="flex items-start gap-2"
>
Cancel
</Button>
<Button
variant="destructive"
onClick={() => {
if (deleteTarget) {
void handleDeleteSession(deleteTarget.session_id);
}
}}
disabled={
!deleteTarget ||
!!deletingId ||
streamingSessionIds.has(deleteTarget.session_id)
}
>
{deletingId ? (
<Loader2Icon className="size-3.5 animate-spin" />
) : (
<Trash2Icon className="size-3.5" />
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-sm">{session.title}</span>
<span className="text-xs text-muted-foreground">
{formatRelativeTime(session.last_modified)}
</span>
</div>
{session.session_id === sessionId && (
<CheckIcon className="size-4 shrink-0 text-primary" />
)}
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
</DropdownMenuItem>
))
)}
</DropdownMenuContent>
</DropdownMenu>
);
}

View file

@ -1,27 +1,7 @@
import {
type FC,
type RefObject,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { type FC, type RefObject, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { invoke } from "@tauri-apps/api/core";
import {
CommandIcon,
FolderOpenIcon,
GlobeIcon,
TerminalIcon,
FileCodeIcon,
ZapIcon,
XIcon,
SearchIcon,
FlaskConicalIcon,
ChevronRightIcon,
ChevronLeftIcon,
} from "lucide-react";
import { CommandIcon, FolderOpenIcon, GlobeIcon, TerminalIcon, FileCodeIcon, ZapIcon, XIcon, SearchIcon, FlaskConicalIcon, ChevronRightIcon, ChevronLeftIcon } from "lucide-react";
import { cn } from "@/lib/utils";
export interface SlashCommand {
@ -63,22 +43,12 @@ function scopeToTab(scope: string): Tab {
}
function getCommandIcon(command: SlashCommand) {
if (command.scope === "skill")
return (
<FlaskConicalIcon className="size-3.5 shrink-0 text-muted-foreground" />
);
if (command.has_bash_commands)
return <TerminalIcon className="size-3.5 shrink-0 text-muted-foreground" />;
if (command.has_file_references)
return <FileCodeIcon className="size-3.5 shrink-0 text-muted-foreground" />;
if (command.scope === "project")
return (
<FolderOpenIcon className="size-3.5 shrink-0 text-muted-foreground" />
);
if (command.scope === "user")
return <GlobeIcon className="size-3.5 shrink-0 text-muted-foreground" />;
if (command.scope === "default")
return <CommandIcon className="size-3.5 shrink-0 text-muted-foreground" />;
if (command.scope === "skill") return <FlaskConicalIcon className="size-3.5 shrink-0 text-muted-foreground" />;
if (command.has_bash_commands) return <TerminalIcon className="size-3.5 shrink-0 text-muted-foreground" />;
if (command.has_file_references) return <FileCodeIcon className="size-3.5 shrink-0 text-muted-foreground" />;
if (command.scope === "project") return <FolderOpenIcon className="size-3.5 shrink-0 text-muted-foreground" />;
if (command.scope === "user") return <GlobeIcon className="size-3.5 shrink-0 text-muted-foreground" />;
if (command.scope === "default") return <CommandIcon className="size-3.5 shrink-0 text-muted-foreground" />;
return <ZapIcon className="size-3.5 shrink-0 text-muted-foreground" />;
}
@ -94,13 +64,9 @@ const SCORE_MATCH_CAPITAL = 0.7;
const SCORE_MATCH_DOT = 0.6;
const SCORE_MAX_LEADING_GAP = -0.05;
function _isWordBoundary(prev: string, curr: string): boolean {
function isWordBoundary(prev: string, curr: string): boolean {
return (
prev === "-" ||
prev === "_" ||
prev === " " ||
prev === "/" ||
prev === "." ||
prev === "-" || prev === "_" || prev === " " || prev === "/" || prev === "." ||
(prev === prev.toLowerCase() && curr === curr.toUpperCase())
);
}
@ -109,8 +75,7 @@ function bonusFor(prev: string, curr: string): number {
if (prev === "/") return SCORE_MATCH_SLASH;
if (prev === "-" || prev === "_" || prev === " ") return SCORE_MATCH_WORD;
if (prev === ".") return SCORE_MATCH_DOT;
if (prev === prev.toLowerCase() && curr === curr.toUpperCase())
return SCORE_MATCH_CAPITAL;
if (prev === prev.toLowerCase() && curr === curr.toUpperCase()) return SCORE_MATCH_CAPITAL;
return 0;
}
@ -152,17 +117,14 @@ function fuzzyScore(query: string, candidate: string): number {
if (i === 0) {
// First char of query
score =
j === 0
? SCORE_MATCH_CONSECUTIVE // start of string
: Math.max(SCORE_MAX_LEADING_GAP, SCORE_GAP_LEADING * j) +
bonusFor(candidate[j - 1], candidate[j]);
score = j === 0
? SCORE_MATCH_CONSECUTIVE // start of string
: Math.max(SCORE_MAX_LEADING_GAP, SCORE_GAP_LEADING * j) + bonusFor(candidate[j - 1], candidate[j]);
} else if (j > 0) {
// Consecutive match bonus
const consecutive = D[i - 1][j - 1] + SCORE_MATCH_CONSECUTIVE;
// Non-consecutive: gap penalty from best previous match
const boundary =
M[i - 1][j - 1] + bonusFor(candidate[j - 1], candidate[j]);
const boundary = M[i - 1][j - 1] + bonusFor(candidate[j - 1], candidate[j]);
score = Math.max(consecutive, boundary);
}
@ -245,7 +207,7 @@ function scoreCommand(cmd: SlashCommand, q: string): number {
// 2. Description: only substring (contains) match to avoid false positives on long text
let descScore = -Infinity;
if (cmd.description?.toLowerCase().includes(q.toLowerCase())) {
if (cmd.description && cmd.description.toLowerCase().includes(q.toLowerCase())) {
descScore = q.length * 0.3; // modest score for description-only match
}
@ -253,7 +215,10 @@ function scoreCommand(cmd: SlashCommand, q: string): number {
if (fuzzy > -Infinity) return fuzzy;
// 3. Typo-tolerant fallback on command key / name (handles bioarxiv → biorxiv)
const typo = Math.max(typoScore(q, cmdKey), typoScore(q, cmd.name));
const typo = Math.max(
typoScore(q, cmdKey),
typoScore(q, cmd.name),
);
return typo;
}
@ -287,58 +252,23 @@ function SkillPreview({ content }: { content: string }) {
}
return (
<div className="prose prose-sm prose-invert max-w-none text-muted-foreground text-xs leading-relaxed">
<div className="prose prose-sm prose-invert max-w-none text-xs leading-relaxed text-muted-foreground">
{body.split("\n").map((line, i) => {
const trimmed = line.trimEnd();
if (trimmed.startsWith("# ")) {
return (
<h3
key={i}
className="mt-3 mb-1 font-semibold text-foreground text-sm"
>
{trimmed.slice(2)}
</h3>
);
return <h3 key={i} className="text-sm font-semibold text-foreground mt-3 mb-1">{trimmed.slice(2)}</h3>;
}
if (trimmed.startsWith("## ")) {
return (
<h4
key={i}
className="mt-2.5 mb-0.5 font-semibold text-foreground text-xs"
>
{trimmed.slice(3)}
</h4>
);
return <h4 key={i} className="text-xs font-semibold text-foreground mt-2.5 mb-0.5">{trimmed.slice(3)}</h4>;
}
if (trimmed.startsWith("### ")) {
return (
<h5
key={i}
className="mt-2 mb-0.5 font-medium text-foreground text-xs"
>
{trimmed.slice(4)}
</h5>
);
return <h5 key={i} className="text-xs font-medium text-foreground mt-2 mb-0.5">{trimmed.slice(4)}</h5>;
}
if (trimmed.startsWith("- ") || trimmed.startsWith("* ")) {
return (
<div
key={i}
className="pl-3 before:mr-1.5 before:text-muted-foreground/50 before:content-['·']"
>
{trimmed.slice(2)}
</div>
);
return <div key={i} className="pl-3 before:content-['·'] before:mr-1.5 before:text-muted-foreground/50">{trimmed.slice(2)}</div>;
}
if (trimmed.startsWith("```")) {
return (
<div
key={i}
className="font-mono text-[10px] text-muted-foreground/70"
>
{trimmed}
</div>
);
return <div key={i} className="font-mono text-[10px] text-muted-foreground/70">{trimmed}</div>;
}
if (trimmed === "") {
return <div key={i} className="h-1.5" />;
@ -362,11 +292,7 @@ export const SlashCommandPicker: FC<SlashCommandPickerProps> = ({
const [activeTab, setActiveTab] = useState<Tab>("skills");
const [showPreview, setShowPreview] = useState(false);
const listRef = useRef<HTMLDivElement>(null);
const [pos, setPos] = useState<{
left: number;
right: number;
bottom: number;
}>({ left: 0, right: 0, bottom: 0 });
const [pos, setPos] = useState<{ left: number; right: number; bottom: number }>({ left: 0, right: 0, bottom: 0 });
const isSearching = query.length > 0;
@ -426,16 +352,9 @@ export const SlashCommandPicker: FC<SlashCommandPickerProps> = ({
const searchGroups = useMemo(() => {
if (!isSearching) return null;
const groups: {
label: string;
items: { cmd: SlashCommand; globalIndex: number }[];
}[] = [];
const groups: { label: string; items: { cmd: SlashCommand; globalIndex: number }[] }[] = [];
const order: Tab[] = ["skills", "default", "custom"];
const labels: Record<Tab, string> = {
skills: "Skills",
default: "Default",
custom: "Custom",
};
const labels: Record<Tab, string> = { skills: "Skills", default: "Default", custom: "Custom" };
for (const tab of order) {
const items: { cmd: SlashCommand; globalIndex: number }[] = [];
@ -511,9 +430,7 @@ export const SlashCommandPicker: FC<SlashCommandPickerProps> = ({
// Scroll selected item into view
useEffect(() => {
if (listRef.current) {
const selected = listRef.current.querySelector(
`[data-index="${selectedIndex}"]`,
);
const selected = listRef.current.querySelector(`[data-index="${selectedIndex}"]`);
selected?.scrollIntoView({ block: "nearest", behavior: "smooth" });
}
}, [selectedIndex]);
@ -526,7 +443,7 @@ export const SlashCommandPicker: FC<SlashCommandPickerProps> = ({
key={cmd.id}
data-index={index}
className={cn(
"flex w-full items-center gap-2.5 rounded-md px-3 py-1.5 text-left transition-colors",
"flex w-full items-center gap-2.5 px-3 py-1.5 rounded-md text-left transition-colors",
isSelected ? "bg-accent text-accent-foreground" : "hover:bg-muted",
)}
onMouseDown={(e) => {
@ -536,9 +453,9 @@ export const SlashCommandPicker: FC<SlashCommandPickerProps> = ({
onMouseEnter={() => setSelectedIndex(index)}
>
{getCommandIcon(cmd)}
<span className="truncate font-mono text-sm">{cmd.full_command}</span>
<span className="font-mono text-sm truncate">{cmd.full_command}</span>
{cmd.description && (
<span className="min-w-0 flex-1 truncate text-muted-foreground text-xs">
<span className="truncate text-xs text-muted-foreground flex-1 min-w-0">
{cmd.description}
</span>
)}
@ -565,34 +482,29 @@ export const SlashCommandPicker: FC<SlashCommandPickerProps> = ({
if (isSearching) {
return (
<div className="flex flex-col items-center justify-center py-8">
<SearchIcon className="mb-2 size-6 text-muted-foreground" />
<span className="text-muted-foreground text-sm">
No results for "{query}"
</span>
<SearchIcon className="size-6 text-muted-foreground mb-2" />
<span className="text-sm text-muted-foreground">No results for "{query}"</span>
</div>
);
}
const hints: Record<Tab, React.ReactNode> = {
skills: (
<p className="mt-1 px-4 text-center text-muted-foreground text-xs">
<p className="text-xs text-muted-foreground mt-1 text-center px-4">
Install scientific skills from the sidebar menu.
</p>
),
default: null,
custom: (
<p className="mt-1 px-4 text-center text-muted-foreground text-xs">
Add commands in <code className="px-1">.claude/commands/</code> or{" "}
<code className="px-1">~/.claude/commands/</code>
<p className="text-xs text-muted-foreground mt-1 text-center px-4">
Add commands in <code className="px-1">.claude/commands/</code> or <code className="px-1">~/.claude/commands/</code>
</p>
),
};
return (
<div className="flex flex-col items-center justify-center py-8">
<span className="text-muted-foreground text-sm">
No commands available
</span>
<span className="text-sm text-muted-foreground">No commands available</span>
{hints[activeTab]}
</div>
);
@ -602,7 +514,7 @@ export const SlashCommandPicker: FC<SlashCommandPickerProps> = ({
<div className="flex-1 overflow-y-auto" ref={listRef}>
{isLoading && (
<div className="flex items-center justify-center py-8">
<span className="text-muted-foreground text-sm">Loading...</span>
<span className="text-sm text-muted-foreground">Loading...</span>
</div>
)}
@ -614,7 +526,7 @@ export const SlashCommandPicker: FC<SlashCommandPickerProps> = ({
<div className="space-y-2">
{searchGroups.map((group) => (
<div key={group.label}>
<h3 className="px-3 py-1 font-semibold text-[10px] text-muted-foreground uppercase tracking-wider">
<h3 className="px-3 py-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
{group.label}
</h3>
<div className="space-y-0.5">
@ -647,18 +559,16 @@ export const SlashCommandPicker: FC<SlashCommandPickerProps> = ({
}}
>
{/* Left side: list */}
<div
className={cn(
"flex flex-col overflow-hidden transition-all",
showPreview ? "w-[45%] min-w-[200px]" : "w-full",
)}
>
<div className={cn(
"flex flex-col overflow-hidden transition-all",
showPreview ? "w-[45%] min-w-[200px]" : "w-full",
)}>
{/* Header */}
<div className="shrink-0 border-border border-b px-3 pt-2.5 pb-2">
<div className="mb-2 flex items-center justify-between">
<div className="border-b border-border px-3 pt-2.5 pb-2 shrink-0">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<CommandIcon className="size-4 text-muted-foreground" />
<span className="font-medium text-sm">
<span className="text-sm font-medium">
{isSearching ? `Search: "${query}"` : "Commands"}
</span>
</div>
@ -680,7 +590,7 @@ export const SlashCommandPicker: FC<SlashCommandPickerProps> = ({
<button
key={tab}
className={cn(
"flex items-center gap-1.5 rounded-md px-2.5 py-1 font-medium text-xs transition-colors",
"flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium transition-colors",
activeTab === tab
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground hover:bg-muted/80",
@ -690,20 +600,14 @@ export const SlashCommandPicker: FC<SlashCommandPickerProps> = ({
setActiveTab(tab);
}}
>
{tab === "skills"
? "Skills"
: tab === "default"
? "Default"
: "Custom"}
{tab === "skills" ? "Skills" : tab === "default" ? "Default" : "Custom"}
{tabCounts[tab] > 0 && (
<span
className={cn(
"rounded-full px-1.5 text-[10px] tabular-nums leading-4",
activeTab === tab
? "bg-primary-foreground/20 text-primary-foreground"
: "bg-muted-foreground/15 text-muted-foreground",
)}
>
<span className={cn(
"rounded-full px-1.5 text-[10px] leading-4 tabular-nums",
activeTab === tab
? "bg-primary-foreground/20 text-primary-foreground"
: "bg-muted-foreground/15 text-muted-foreground",
)}>
{tabCounts[tab]}
</span>
)}
@ -716,19 +620,18 @@ export const SlashCommandPicker: FC<SlashCommandPickerProps> = ({
{renderList()}
{/* Footer */}
<div className="shrink-0 border-border border-t px-3 py-1">
<div className="border-t border-border px-3 py-1 shrink-0">
<span className="text-[10px] text-muted-foreground">
↑↓ Navigate · Enter Select · {canPreview ? "→ Preview · " : ""}Esc
Close
↑↓ Navigate · Enter Select · {canPreview ? "→ Preview · " : ""}Esc Close
</span>
</div>
</div>
{/* Right side: preview panel */}
{showPreview && selectedCommand && (
<div className="flex w-[55%] flex-col border-border border-l">
<div className="flex flex-col w-[55%] border-l border-border">
{/* Preview header */}
<div className="flex shrink-0 items-center gap-2 border-border border-b px-3 py-2">
<div className="flex items-center gap-2 border-b border-border px-3 py-2 shrink-0">
<button
aria-label="Close preview"
onMouseDown={(e) => {
@ -740,9 +643,7 @@ export const SlashCommandPicker: FC<SlashCommandPickerProps> = ({
<ChevronLeftIcon className="size-3.5 text-muted-foreground" />
</button>
<FlaskConicalIcon className="size-3.5 text-muted-foreground" />
<span className="truncate font-medium font-mono text-sm">
{selectedCommand.full_command}
</span>
<span className="font-mono text-sm font-medium truncate">{selectedCommand.full_command}</span>
</div>
{/* Preview body */}

View file

@ -1,9 +1,10 @@
import { type FC, useState } from "react";
import {
BrainIcon,
BotIcon,
CheckIcon,
ChevronDownIcon,
ChevronRightIcon,
CircleDotIcon,
CircleIcon,
ClockIcon,
FileEditIcon,
@ -16,10 +17,7 @@ import {
TerminalIcon,
WrenchIcon,
} from "lucide-react";
import {
useClaudeChatStore,
type ContentBlock,
} from "@/stores/claude-chat-store";
import { useClaudeChatStore, type ContentBlock } from "@/stores/claude-chat-store";
interface ToolWidgetProps {
toolUse: ContentBlock;
@ -29,36 +27,16 @@ interface ToolWidgetProps {
export const ToolWidget: FC<ToolWidgetProps> = ({ toolUse, toolResult }) => {
const name = toolUse.name?.toLowerCase() || "";
if (name === "write")
return <WriteWidget input={toolUse.input} result={toolResult} />;
if (name === "edit" || name === "multiedit")
return <EditWidget input={toolUse.input} result={toolResult} />;
if (name === "read")
return <ReadWidget input={toolUse.input} result={toolResult} />;
if (name === "bash")
return <BashWidget input={toolUse.input} result={toolResult} />;
if (name === "powershell" || name === "pwsh")
return (
<BashWidget input={toolUse.input} result={toolResult} prefix="PS>" />
);
if (name === "glob")
return <GlobWidget input={toolUse.input} result={toolResult} />;
if (name === "grep")
return <GrepWidget input={toolUse.input} result={toolResult} />;
if (name === "askuserquestion")
return <AskUserQuestionWidget input={toolUse.input} result={toolResult} />;
if (name === "exitplanmode")
return <ExitPlanModeWidget input={toolUse.input} result={toolResult} />;
if (name === "todowrite")
return <TodoWriteWidget input={toolUse.input} result={toolResult} />;
if (name === "write") return <WriteWidget input={toolUse.input} result={toolResult} />;
if (name === "edit" || name === "multiedit") return <EditWidget input={toolUse.input} result={toolResult} />;
if (name === "read") return <ReadWidget input={toolUse.input} result={toolResult} />;
if (name === "bash") return <BashWidget input={toolUse.input} result={toolResult} />;
if (name === "glob") return <GlobWidget input={toolUse.input} result={toolResult} />;
if (name === "grep") return <GrepWidget input={toolUse.input} result={toolResult} />;
if (name === "askuserquestion") return <AskUserQuestionWidget input={toolUse.input} result={toolResult} />;
if (name === "todowrite") return <TodoWriteWidget input={toolUse.input} result={toolResult} />;
return (
<GenericWidget
name={toolUse.name || "unknown"}
input={toolUse.input}
result={toolResult}
/>
);
return <GenericWidget name={toolUse.name || "unknown"} input={toolUse.input} result={toolResult} />;
};
// ─── Status Icon ───
@ -70,31 +48,24 @@ const StatusIcon: FC<{ result?: ContentBlock }> = ({ result }) => {
// Tool was cancelled (stop pressed) — show stopped state
return <CircleIcon className="size-3.5 text-muted-foreground" />;
}
return (
<LoaderIcon className="size-3.5 animate-spin text-muted-foreground" />
);
return <LoaderIcon className="size-3.5 animate-spin text-muted-foreground" />;
}
if (result.is_error) {
return <span className="text-destructive text-sm">!</span>;
return <span className="text-sm text-destructive">!</span>;
}
return <CheckIcon className="size-3.5 text-green-600" />;
};
// ─── Write Widget ───
const WriteWidget: FC<{ input: any; result?: ContentBlock }> = ({
input,
result,
}) => {
const WriteWidget: FC<{ input: any; result?: ContentBlock }> = ({ input, result }) => {
return (
<div className="my-1.5 flex items-center gap-2 rounded-lg border border-border bg-muted/50 px-3 py-2 text-sm">
<StatusIcon result={result} />
<FileOutputIcon className="size-3.5 shrink-0 text-muted-foreground" />
<span className="min-w-0 truncate text-muted-foreground">
{result ? "Wrote" : "Writing"}{" "}
<code className="rounded bg-muted px-1 text-xs">
{input?.file_path}
</code>
<code className="rounded bg-muted px-1 text-xs">{input?.file_path}</code>
</span>
</div>
);
@ -102,10 +73,7 @@ const WriteWidget: FC<{ input: any; result?: ContentBlock }> = ({
// ─── Edit Widget ───
const EditWidget: FC<{ input: any; result?: ContentBlock }> = ({
input,
result,
}) => {
const EditWidget: FC<{ input: any; result?: ContentBlock }> = ({ input, result }) => {
const [expanded, setExpanded] = useState(false);
return (
@ -119,25 +87,18 @@ const EditWidget: FC<{ input: any; result?: ContentBlock }> = ({
<FileEditIcon className="size-3.5 shrink-0 text-muted-foreground" />
<span className="min-w-0 truncate text-muted-foreground">
{result ? "Edited" : "Editing"}{" "}
<code className="rounded bg-muted px-1 text-xs">
{input?.file_path}
</code>
<code className="rounded bg-muted px-1 text-xs">{input?.file_path}</code>
</span>
{(input?.old_string || input?.edits) &&
(expanded ? (
<ChevronDownIcon className="ml-auto size-3.5 text-muted-foreground" />
) : (
<ChevronRightIcon className="ml-auto size-3.5 text-muted-foreground" />
))}
{(input?.old_string || input?.edits) && (
expanded
? <ChevronDownIcon className="ml-auto size-3.5 text-muted-foreground" />
: <ChevronRightIcon className="ml-auto size-3.5 text-muted-foreground" />
)}
</button>
{expanded && input?.old_string && (
<div className="border-border border-t px-3 py-2 font-mono text-xs">
<div className="mb-1 text-red-500">
- {truncate(input.old_string, 200)}
</div>
<div className="text-green-500">
+ {truncate(input.new_string, 200)}
</div>
<div className="border-t border-border px-3 py-2 font-mono text-xs">
<div className="mb-1 text-red-500">- {truncate(input.old_string, 200)}</div>
<div className="text-green-500">+ {truncate(input.new_string, 200)}</div>
</div>
)}
</div>
@ -146,19 +107,14 @@ const EditWidget: FC<{ input: any; result?: ContentBlock }> = ({
// ─── Read Widget ───
const ReadWidget: FC<{ input: any; result?: ContentBlock }> = ({
input,
result,
}) => {
const ReadWidget: FC<{ input: any; result?: ContentBlock }> = ({ input, result }) => {
return (
<div className="my-1.5 flex items-center gap-2 rounded-lg border border-border bg-muted/50 px-3 py-2 text-sm">
<StatusIcon result={result} />
<FileIcon className="size-3.5 shrink-0 text-muted-foreground" />
<span className="min-w-0 truncate text-muted-foreground">
{result ? "Read" : "Reading"}{" "}
<code className="rounded bg-muted px-1 text-xs">
{input?.file_path}
</code>
<code className="rounded bg-muted px-1 text-xs">{input?.file_path}</code>
</span>
</div>
);
@ -166,38 +122,30 @@ const ReadWidget: FC<{ input: any; result?: ContentBlock }> = ({
// ─── Bash Widget ───
const BashWidget: FC<{
input: any;
result?: ContentBlock;
prefix?: string;
}> = ({ input, result, prefix = "$" }) => {
const BashWidget: FC<{ input: any; result?: ContentBlock }> = ({ input, result }) => {
const [expanded, setExpanded] = useState(false);
const command = input?.command || input?.description || "";
const resultContent =
typeof result?.content === "string" ? result.content : "";
const resultContent = typeof result?.content === "string" ? result.content : "";
return (
<div className="my-1.5 rounded-lg border border-border bg-muted/70 text-sm dark:bg-neutral-900">
<div className="my-1.5 rounded-lg border border-border bg-[#1e1e2e] text-sm">
<button
type="button"
className="flex w-full items-center gap-2 px-3 py-2"
onClick={() => setExpanded(!expanded)}
>
<StatusIcon result={result} />
<TerminalIcon className="size-3.5 shrink-0 text-emerald-600 dark:text-emerald-400" />
<code className="min-w-0 truncate text-emerald-700 text-xs dark:text-emerald-300">
{prefix} {truncate(command, 80)}
</code>
{result &&
(expanded ? (
<ChevronDownIcon className="ml-auto size-3.5 text-muted-foreground" />
) : (
<ChevronRightIcon className="ml-auto size-3.5 text-muted-foreground" />
))}
<TerminalIcon className="size-3.5 shrink-0 text-green-400" />
<code className="min-w-0 truncate text-xs text-green-300">$ {truncate(command, 80)}</code>
{result && (
expanded
? <ChevronDownIcon className="ml-auto size-3.5 text-muted-foreground" />
: <ChevronRightIcon className="ml-auto size-3.5 text-muted-foreground" />
)}
</button>
{expanded && resultContent && (
<div className="max-h-40 overflow-auto border-border/50 border-t px-3 py-2">
<pre className="whitespace-pre-wrap font-mono text-muted-foreground text-xs">
<div className="max-h-40 overflow-auto border-t border-border/50 px-3 py-2">
<pre className="whitespace-pre-wrap font-mono text-xs text-gray-300">
{truncate(resultContent, 2000)}
</pre>
</div>
@ -208,10 +156,7 @@ const BashWidget: FC<{
// ─── Glob Widget ───
const GlobWidget: FC<{ input: any; result?: ContentBlock }> = ({
input,
result,
}) => {
const GlobWidget: FC<{ input: any; result?: ContentBlock }> = ({ input, result }) => {
return (
<div className="my-1.5 flex items-center gap-2 rounded-lg border border-border bg-muted/50 px-3 py-2 text-sm">
<StatusIcon result={result} />
@ -226,10 +171,7 @@ const GlobWidget: FC<{ input: any; result?: ContentBlock }> = ({
// ─── Grep Widget ───
const GrepWidget: FC<{ input: any; result?: ContentBlock }> = ({
input,
result,
}) => {
const GrepWidget: FC<{ input: any; result?: ContentBlock }> = ({ input, result }) => {
return (
<div className="my-1.5 flex items-center gap-2 rounded-lg border border-border bg-muted/50 px-3 py-2 text-sm">
<StatusIcon result={result} />
@ -244,10 +186,7 @@ const GrepWidget: FC<{ input: any; result?: ContentBlock }> = ({
// ─── AskUserQuestion Widget ───
const AskUserQuestionWidget: FC<{ input: any; result?: ContentBlock }> = ({
input,
result,
}) => {
const AskUserQuestionWidget: FC<{ input: any; result?: ContentBlock }> = ({ input, result }) => {
const questions: any[] = input?.questions || [];
const [answered, setAnswered] = useState(false);
@ -255,10 +194,9 @@ const AskUserQuestionWidget: FC<{ input: any; result?: ContentBlock }> = ({
// The process is killed when AskUserQuestion is detected, so result may be undefined.
// Options are clickable when there's no result or an error result.
const isStreaming = useClaudeChatStore((s) => s.isStreaming);
const needsUserAnswer =
!answered && !isStreaming && (!result || result.is_error);
const needsUserAnswer = !answered && !isStreaming && (!result || result.is_error);
const handleOptionClick = (_question: string, label: string) => {
const handleOptionClick = (question: string, label: string) => {
const { sendPrompt, isStreaming } = useClaudeChatStore.getState();
if (isStreaming) return;
setAnswered(true);
@ -287,13 +225,11 @@ const AskUserQuestionWidget: FC<{ input: any; result?: ContentBlock }> = ({
: "Question answered";
return (
<div
className={`my-1.5 rounded-lg border text-sm ${
needsUserAnswer
? "border-blue-500/40 bg-blue-500/10"
: "border-blue-500/20 bg-blue-500/5"
}`}
>
<div className={`my-1.5 rounded-lg border text-sm ${
needsUserAnswer
? "border-blue-500/40 bg-blue-500/10"
: "border-blue-500/20 bg-blue-500/5"
}`}>
<div className="flex items-center gap-2 px-3 py-2">
{needsUserAnswer ? (
<MessageCircleQuestionIcon className="size-3.5 text-blue-500" />
@ -307,24 +243,22 @@ const AskUserQuestionWidget: FC<{ input: any; result?: ContentBlock }> = ({
{headerLabel}
</span>
</div>
<div className="space-y-3 border-blue-500/20 border-t px-3 py-2.5">
<div className="space-y-3 border-t border-blue-500/20 px-3 py-2.5">
{questions.map((q: any, qIdx: number) => (
<div key={qIdx} className="space-y-1.5">
{q.header && (
<span className="inline-block rounded-full bg-blue-500/15 px-2 py-0.5 font-medium text-blue-600 text-xs dark:text-blue-400">
<span className="inline-block rounded-full bg-blue-500/15 px-2 py-0.5 text-xs font-medium text-blue-600 dark:text-blue-400">
{q.header}
</span>
)}
<p className="font-medium text-foreground text-sm">{q.question}</p>
<p className="text-sm font-medium text-foreground">{q.question}</p>
<div className="space-y-1 pl-1">
{q.options?.map((opt: any, oIdx: number) => (
<button
type="button"
key={oIdx}
disabled={!needsUserAnswer}
onClick={() =>
needsUserAnswer && handleOptionClick(q.question, opt.label)
}
onClick={() => needsUserAnswer && handleOptionClick(q.question, opt.label)}
className={`flex w-full items-start gap-2 rounded-md px-2 py-1.5 text-left transition-colors ${
needsUserAnswer
? "cursor-pointer hover:bg-blue-500/15"
@ -332,26 +266,18 @@ const AskUserQuestionWidget: FC<{ input: any; result?: ContentBlock }> = ({
}`}
>
<div className="mt-0.5">
<CircleIcon
className={`size-3.5 ${
needsUserAnswer
? "text-blue-500/50"
: "text-muted-foreground/40"
}`}
/>
<CircleIcon className={`size-3.5 ${
needsUserAnswer ? "text-blue-500/50" : "text-muted-foreground/40"
}`} />
</div>
<div className="min-w-0 flex-1">
<span
className={`text-sm ${
needsUserAnswer
? "text-foreground"
: "text-muted-foreground"
}`}
>
<div className="flex-1 min-w-0">
<span className={`text-sm ${
needsUserAnswer ? "text-foreground" : "text-muted-foreground"
}`}>
{opt.label}
</span>
{opt.description && (
<p className="mt-0.5 text-muted-foreground/70 text-xs">
<p className="text-xs text-muted-foreground/70 mt-0.5">
{opt.description}
</p>
)}
@ -366,84 +292,9 @@ const AskUserQuestionWidget: FC<{ input: any; result?: ContentBlock }> = ({
);
};
// ExitPlanMode Widget
const ExitPlanModeWidget: FC<{ input: any; result?: ContentBlock }> = ({
input,
result,
}) => {
const [answered, setAnswered] = useState(false);
const isStreaming = useClaudeChatStore((s) => s.isStreaming);
const needsApproval =
!answered && !isStreaming && (!result || result.is_error);
const plan = input?.plan || input?.content || "";
const sendPlanResponse = (text: string) => {
const { sendPrompt, isStreaming } = useClaudeChatStore.getState();
if (isStreaming) return;
setAnswered(true);
sendPrompt(text);
};
return (
<div
className={`my-1.5 rounded-lg border text-sm ${
needsApproval
? "border-amber-500/40 bg-amber-500/10"
: "border-amber-500/20 bg-amber-500/5"
}`}
>
<div className="flex items-center gap-2 px-3 py-2">
<SparklesIcon className="size-3.5 text-amber-500" />
<span className="font-medium text-amber-700 dark:text-amber-300">
{needsApproval
? "Plan needs approval"
: answered
? "Plan response sent"
: "Plan handled"}
</span>
</div>
{plan && (
<div className="border-amber-500/20 border-t px-3 py-2">
<pre className="max-h-56 overflow-auto whitespace-pre-wrap text-foreground text-xs leading-relaxed">
{plan}
</pre>
</div>
)}
{needsApproval && (
<div className="flex flex-wrap gap-2 border-amber-500/20 border-t px-3 py-2">
<button
type="button"
className="rounded-md bg-amber-500 px-2.5 py-1 font-medium text-background text-xs hover:bg-amber-500/90"
onClick={() =>
sendPlanResponse("Approved. Continue implementing the plan.")
}
>
Approve
</button>
<button
type="button"
className="rounded-md border border-amber-500/30 px-2.5 py-1 text-amber-700 text-xs hover:bg-amber-500/10 dark:text-amber-300"
onClick={() =>
sendPlanResponse(
"Revise the plan before implementing. Keep it concise and address any missing risks.",
)
}
>
Revise
</button>
</div>
)}
</div>
);
};
// ─── TodoWrite Widget ───
const TodoWriteWidget: FC<{ input: any; result?: ContentBlock }> = ({
input,
result,
}) => {
const TodoWriteWidget: FC<{ input: any; result?: ContentBlock }> = ({ input, result }) => {
const [expanded, setExpanded] = useState(true);
const todos: any[] = Array.isArray(input?.todos) ? input.todos : [];
@ -472,14 +323,12 @@ const TodoWriteWidget: FC<{ input: any; result?: ContentBlock }> = ({
<span className="text-muted-foreground">
Todos ({completedCount}/{todos.length})
</span>
{expanded ? (
<ChevronDownIcon className="ml-auto size-3.5 text-muted-foreground" />
) : (
<ChevronRightIcon className="ml-auto size-3.5 text-muted-foreground" />
)}
{expanded
? <ChevronDownIcon className="ml-auto size-3.5 text-muted-foreground" />
: <ChevronRightIcon className="ml-auto size-3.5 text-muted-foreground" />}
</button>
{expanded && todos.length > 0 && (
<div className="space-y-0.5 border-border border-t px-3 py-2">
<div className="space-y-0.5 border-t border-border px-3 py-2">
{todos.map((todo, idx) => (
<div
key={idx}
@ -491,14 +340,14 @@ const TodoWriteWidget: FC<{ input: any; result?: ContentBlock }> = ({
<span
className={`text-xs ${
todo.status === "completed"
? "text-muted-foreground line-through"
? "line-through text-muted-foreground"
: todo.status === "in_progress"
? "font-medium text-foreground"
: "text-muted-foreground"
}`}
>
{todo.status === "in_progress"
? todo.activeForm || todo.content
? (todo.activeForm || todo.content)
: todo.content}
</span>
</div>
@ -511,11 +360,11 @@ const TodoWriteWidget: FC<{ input: any; result?: ContentBlock }> = ({
// ─── Generic Widget ───
const GenericWidget: FC<{
name: string;
input: any;
result?: ContentBlock;
}> = ({ name, input, result }) => {
const GenericWidget: FC<{ name: string; input: any; result?: ContentBlock }> = ({
name,
input,
result,
}) => {
const [expanded, setExpanded] = useState(false);
return (
@ -530,15 +379,13 @@ const GenericWidget: FC<{
<span className="text-muted-foreground">
{result ? "Ran" : "Running"} <code className="text-xs">{name}</code>
</span>
{expanded ? (
<ChevronDownIcon className="ml-auto size-3.5 text-muted-foreground" />
) : (
<ChevronRightIcon className="ml-auto size-3.5 text-muted-foreground" />
)}
{expanded
? <ChevronDownIcon className="ml-auto size-3.5 text-muted-foreground" />
: <ChevronRightIcon className="ml-auto size-3.5 text-muted-foreground" />}
</button>
{expanded && input && (
<div className="max-h-32 overflow-auto border-border border-t px-3 py-2">
<pre className="whitespace-pre-wrap font-mono text-muted-foreground text-xs">
<div className="max-h-32 overflow-auto border-t border-border px-3 py-2">
<pre className="whitespace-pre-wrap font-mono text-xs text-muted-foreground">
{JSON.stringify(input, null, 2)}
</pre>
</div>
@ -549,34 +396,29 @@ const GenericWidget: FC<{
// ─── Thinking Widget ───
export const ThinkingWidget: FC<{ thinking: string; signature?: string }> = ({
thinking,
}) => {
export const ThinkingWidget: FC<{ thinking: string; signature?: string }> = ({ thinking }) => {
const [expanded, setExpanded] = useState(false);
const trimmed = thinking.trim();
return (
<div className="my-1.5 overflow-hidden rounded-lg border border-muted-foreground/20 bg-muted-foreground/5">
<div className="my-1.5 rounded-lg border border-muted-foreground/20 bg-muted-foreground/5 overflow-hidden">
<button
type="button"
onClick={() => setExpanded(!expanded)}
className="flex w-full items-center justify-between px-3 py-2 transition-colors hover:bg-muted-foreground/10"
className="flex w-full items-center justify-between px-3 py-2 hover:bg-muted-foreground/10 transition-colors"
>
<div className="flex items-center gap-2">
<div className="flex size-5 items-center justify-center rounded-full bg-muted-foreground/10">
<BrainIcon className="size-3.5 text-muted-foreground" />
<div className="relative">
<BotIcon className="size-4 text-muted-foreground" />
<SparklesIcon className="size-2.5 text-muted-foreground/70 absolute -top-1 -right-1 animate-pulse" />
</div>
<span className="font-medium text-muted-foreground text-sm italic">
Thinking...
</span>
<span className="text-sm font-medium text-muted-foreground italic">Thinking...</span>
</div>
<ChevronRightIcon
className={`size-4 text-muted-foreground transition-transform ${expanded ? "rotate-90" : ""}`}
/>
<ChevronRightIcon className={`size-4 text-muted-foreground transition-transform ${expanded ? "rotate-90" : ""}`} />
</button>
{expanded && (
<div className="border-muted-foreground/20 border-t px-3 pt-2 pb-3">
<pre className="whitespace-pre-wrap rounded-lg bg-muted-foreground/5 p-3 font-mono text-muted-foreground text-xs italic">
<div className="border-t border-muted-foreground/20 px-3 pb-3 pt-2">
<pre className="whitespace-pre-wrap rounded-lg bg-muted-foreground/5 p-3 font-mono text-xs text-muted-foreground italic">
{trimmed}
</pre>
</div>
@ -589,5 +431,5 @@ export const ThinkingWidget: FC<{ thinking: string; signature?: string }> = ({
function truncate(str: string, max: number): string {
if (!str) return "";
return str.length > max ? `${str.slice(0, max)}...` : str;
return str.length > max ? str.slice(0, max) + "..." : str;
}

File diff suppressed because it is too large Load diff

View file

@ -1,310 +0,0 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { invoke } from "@tauri-apps/api/core";
import { BugIcon, CopyIcon, TrashIcon, CheckIcon } from "lucide-react";
import {
useLogStore,
getGpuRenderer,
getVisibilityLogs,
APP_VISIBILITY_RESTORED,
type LogLevel,
type SystemInfo,
} from "@/lib/debug/log-store";
import { generateBugReport } from "@/lib/debug/bug-report";
const LEVEL_COLORS: Record<LogLevel, string> = {
debug: "text-neutral-400",
info: "text-blue-500",
warn: "text-yellow-500",
error: "text-red-500",
};
type Tab = "logs" | "system" | "visibility";
export function DebugPage() {
const [tab, setTab] = useState<Tab>("logs");
const [levelFilter, setLevelFilter] = useState<LogLevel | "all">("all");
const [sourceFilter, setSourceFilter] = useState("");
const [search, setSearch] = useState("");
const [systemInfo, setSystemInfo] = useState<SystemInfo | null>(null);
const [visibilityCount, setVisibilityCount] = useState(0);
const [copied, setCopied] = useState(false);
const logEndRef = useRef<HTMLDivElement>(null);
const logContainerRef = useRef<HTMLDivElement>(null);
const version = useLogStore((s) => s.version);
const store = useLogStore.getState();
const entries = store.getEntries();
// Track visibility restore events
useEffect(() => {
const handler = () => setVisibilityCount((c) => c + 1);
window.addEventListener(APP_VISIBILITY_RESTORED, handler);
return () => window.removeEventListener(APP_VISIBILITY_RESTORED, handler);
}, []);
// Fetch system info once
useEffect(() => {
invoke<SystemInfo>("get_system_info")
.then(setSystemInfo)
.catch(() => {});
}, []);
// Auto-scroll logs only if already scrolled to bottom.
// Use rAF to wait for the DOM to update after React re-render.
const wasAtBottomRef = useRef(true);
useEffect(() => {
if (tab !== "logs") return;
const container = logContainerRef.current;
if (!container) return;
// Check before new content is painted
const gap =
container.scrollHeight - container.scrollTop - container.clientHeight;
wasAtBottomRef.current = gap < 40;
}); // runs every render, before paint
useEffect(() => {
if (tab !== "logs" || !wasAtBottomRef.current) return;
requestAnimationFrame(() => {
logEndRef.current?.scrollIntoView({ behavior: "instant" });
});
}, [version, tab]);
const filteredEntries = store.getFilteredLogs(
levelFilter === "all" && !sourceFilter && !search
? undefined
: {
level: levelFilter === "all" ? undefined : levelFilter,
source: sourceFilter || undefined,
search: search || undefined,
},
);
const sources = [...new Set(entries.map((e) => e.source))].sort();
const handleCopyReport = useCallback(async () => {
const report = await generateBugReport();
await navigator.clipboard.writeText(report);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}, []);
const formatTime = (ts: number) => {
const d = new Date(ts);
return (
d.toLocaleTimeString("en-US", { hour12: false }) +
"." +
String(d.getMilliseconds()).padStart(3, "0")
);
};
return (
<div className="flex h-screen flex-col bg-background text-foreground">
{/* Header */}
<div className="flex items-center justify-between border-b px-4 py-3">
<div className="flex items-center gap-2">
<BugIcon className="size-4" />
<h1 className="font-semibold text-sm">Debug</h1>
</div>
<button
type="button"
onClick={handleCopyReport}
className="flex items-center gap-1.5 rounded-md bg-primary px-3 py-1 font-medium text-primary-foreground text-xs hover:bg-primary/90"
>
{copied ? (
<CheckIcon className="size-3.5" />
) : (
<CopyIcon className="size-3.5" />
)}
{copied ? "Copied!" : "Copy Bug Report"}
</button>
</div>
{/* Tab bar */}
<div className="flex gap-1 border-b px-4">
{(["logs", "system", "visibility"] as Tab[]).map((t) => (
<button
key={t}
type="button"
onClick={() => setTab(t)}
className={`border-b-2 px-3 py-1.5 font-medium text-xs capitalize transition-colors ${
tab === t
? "border-primary text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
>
{t}
</button>
))}
</div>
{/* Tab content */}
<div className="flex-1 overflow-hidden px-4 py-2">
{tab === "logs" && (
<div className="flex h-full flex-col gap-2">
<div className="flex gap-2">
<select
value={levelFilter}
onChange={(e) =>
setLevelFilter(e.target.value as LogLevel | "all")
}
className="rounded border bg-background px-2 py-1 text-xs"
>
<option value="all">All levels</option>
<option value="debug">Debug</option>
<option value="info">Info</option>
<option value="warn">Warn</option>
<option value="error">Error</option>
</select>
<select
value={sourceFilter}
onChange={(e) => setSourceFilter(e.target.value)}
className="rounded border bg-background px-2 py-1 text-xs"
>
<option value="">All sources</option>
{sources.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
<input
type="text"
placeholder="Search..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="flex-1 rounded border bg-background px-2 py-1 text-xs"
/>
<button
type="button"
onClick={store.clear}
title="Clear logs"
className="rounded border p-1 text-muted-foreground hover:text-foreground"
>
<TrashIcon className="size-3.5" />
</button>
</div>
<div
ref={logContainerRef}
className="flex-1 overflow-auto rounded border bg-muted/30 p-2 font-mono text-[11px]"
>
{filteredEntries.length === 0 && (
<p className="py-4 text-center text-muted-foreground">
No log entries
</p>
)}
{filteredEntries.map((entry, i) => (
<div key={i} className="flex gap-2 py-0.5 hover:bg-muted/50">
<span className="shrink-0 text-muted-foreground">
{formatTime(entry.timestamp)}
</span>
<span
className={`w-10 shrink-0 font-semibold uppercase ${LEVEL_COLORS[entry.level]}`}
>
{entry.level}
</span>
<span className="shrink-0 text-muted-foreground">
[{entry.source}]
</span>
<span className="break-all text-foreground">
{entry.message}
</span>
</div>
))}
<div ref={logEndRef} />
</div>
<p className="text-[10px] text-muted-foreground">
{entries.length} entries ({filteredEntries.length} shown)
</p>
</div>
)}
{tab === "system" && (
<div className="space-y-3">
<h3 className="font-semibold text-muted-foreground text-xs uppercase">
System Information
</h3>
{systemInfo ? (
<div className="space-y-1 text-sm">
<Row label="OS" value={systemInfo.os} />
<Row label="OS Version" value={systemInfo.os_version} />
<Row label="Architecture" value={systemInfo.arch} />
<Row label="App Version" value={systemInfo.app_version} />
</div>
) : (
<p className="text-muted-foreground text-sm">Loading...</p>
)}
<h3 className="pt-4 font-semibold text-muted-foreground text-xs uppercase">
Browser / WebView
</h3>
<div className="space-y-1 text-sm">
<Row label="User Agent" value={navigator.userAgent} />
<Row
label="Device Pixel Ratio"
value={String(window.devicePixelRatio)}
/>
<Row label="GPU Renderer" value={getGpuRenderer()} />
</div>
</div>
)}
{tab === "visibility" && (
<div className="space-y-3">
<h3 className="font-semibold text-muted-foreground text-xs uppercase">
Visibility State
</h3>
<div className="space-y-1 text-sm">
<Row
label="Current State"
value={document.visibilityState}
valueClass={
document.visibilityState === "visible"
? "text-green-500"
: "text-yellow-500"
}
/>
<Row
label="Window Focused"
value={document.hasFocus() ? "Yes" : "No"}
/>
<Row label="Restore Events" value={String(visibilityCount)} />
</div>
<h3 className="pt-4 font-semibold text-muted-foreground text-xs uppercase">
Recent Visibility Logs
</h3>
<div className="max-h-48 overflow-auto rounded border bg-muted/30 p-2 font-mono text-[11px]">
{getVisibilityLogs().map((entry, i) => (
<div key={i} className="py-0.5">
<span className="text-muted-foreground">
{formatTime(entry.timestamp)}
</span>{" "}
<span>{entry.message}</span>
</div>
))}
</div>
</div>
)}
</div>
</div>
);
}
function Row({
label,
value,
valueClass,
}: {
label: string;
value: string;
valueClass?: string;
}) {
return (
<div className="flex gap-2">
<span className="w-32 shrink-0 text-muted-foreground">{label}:</span>
<span className={`break-all ${valueClass ?? ""}`}>{value}</span>
</div>
);
}

View file

@ -1,509 +0,0 @@
import {
type ComponentType,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { open as shellOpen } from "@tauri-apps/plugin-shell";
import {
AlertCircleIcon,
CheckCircle2Icon,
CircleIcon,
DownloadIcon,
FlaskConicalIcon,
GitBranchIcon,
KeyRoundIcon,
Loader2Icon,
RefreshCwIcon,
TerminalIcon,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { ClaudeSetup } from "@/components/claude-setup";
import { useClaudeSetupStore } from "@/stores/claude-setup-store";
import { useUvSetupStore } from "@/stores/uv-setup-store";
import { cn } from "@/lib/utils";
type SetupItemState = "ready" | "loading" | "blocked" | "error";
interface SkillsStatus {
installed: boolean;
skill_count: number;
location: string;
}
export function EnvironmentOnboarding() {
const [initialCheckComplete, setInitialCheckComplete] = useState(false);
const keepOpenDuringCheckRef = useRef(false);
const [hasOpenedForSetup, setHasOpenedForSetup] = useState(false);
const [completedDismissed, setCompletedDismissed] = useState(false);
const [providerDialogOpen, setProviderDialogOpen] = useState(false);
const [skillsStatus, setSkillsStatus] = useState<SkillsStatus | null>(null);
const [skillsChecking, setSkillsChecking] = useState(true);
const [skillsError, setSkillsError] = useState<string | null>(null);
const [skillsDialogOpen, setSkillsDialogOpen] = useState(false);
const [SkillsOnboardingComponent, setSkillsOnboardingComponent] =
useState<ComponentType<{ onClose: () => void }> | null>(null);
const claudeStatus = useClaudeSetupStore((s) => s.status);
const claudeVersion = useClaudeSetupStore((s) => s.version);
const claudeError = useClaudeSetupStore((s) => s.error);
const isClaudeInstalling = useClaudeSetupStore((s) => s.isInstalling);
const providerKind = useClaudeSetupStore((s) => s.providerKind);
const claudeProviderConfigured = useClaudeSetupStore(
(s) => s.claudeProviderConfigured,
);
const openAiCredentials = useClaudeSetupStore((s) => s.openAiCredentials);
const checkClaudeStatus = useClaudeSetupStore((s) => s.checkStatus);
const installClaude = useClaudeSetupStore((s) => s.install);
const uvStatus = useUvSetupStore((s) => s.status);
const uvVersion = useUvSetupStore((s) => s.version);
const uvError = useUvSetupStore((s) => s.error);
const isUvInstalling = useUvSetupStore((s) => s.isInstalling);
const checkUvStatus = useUvSetupStore((s) => s.checkStatus);
const installUv = useUvSetupStore((s) => s.install);
const finishUvInstall = useUvSetupStore((s) => s._finishInstall);
const checkSkillsStatus = useCallback(async () => {
setSkillsChecking(true);
setSkillsError(null);
try {
const status = await invoke<SkillsStatus>("check_skills_installed", {
projectPath: null,
});
setSkillsStatus(status);
} catch (err) {
setSkillsStatus(null);
setSkillsError(String(err));
} finally {
setSkillsChecking(false);
}
}, []);
useEffect(() => {
let cancelled = false;
Promise.allSettled([
checkClaudeStatus(),
checkUvStatus(),
checkSkillsStatus(),
]).finally(() => {
if (!cancelled) {
setInitialCheckComplete(true);
}
});
return () => {
cancelled = true;
};
}, [checkClaudeStatus, checkSkillsStatus, checkUvStatus]);
useEffect(() => {
const unlisten = listen<boolean>("uv-install-complete", (event) => {
finishUvInstall(event.payload);
});
return () => {
unlisten.then((fn) => fn());
};
}, [finishUvInstall]);
const isClaudeInstalled =
claudeStatus === "ready" || claudeStatus === "not-authenticated";
const isClaudeReady = claudeStatus === "ready";
const isUvReady = uvStatus === "ready";
const isSkillsReady = !!skillsStatus?.installed;
const claudeNeedsAttention =
isClaudeInstalling || (claudeStatus !== "checking" && !isClaudeReady);
const uvNeedsAttention =
isUvInstalling || (uvStatus !== "checking" && !isUvReady);
const skillsNeedsAttention =
!skillsChecking && (!isSkillsReady || !!skillsError);
const needsAttention =
claudeNeedsAttention || uvNeedsAttention || skillsNeedsAttention;
const isCheckingSetup =
claudeStatus === "checking" || uvStatus === "checking" || skillsChecking;
const setupComplete =
initialCheckComplete && !needsAttention && !isCheckingSetup;
const shouldShow =
initialCheckComplete &&
!completedDismissed &&
(needsAttention ||
(isCheckingSetup && keepOpenDuringCheckRef.current) ||
hasOpenedForSetup);
useEffect(() => {
if (needsAttention) {
keepOpenDuringCheckRef.current = true;
setHasOpenedForSetup(true);
setCompletedDismissed(false);
return;
}
if (!isCheckingSetup) {
keepOpenDuringCheckRef.current = false;
}
}, [isCheckingSetup, needsAttention]);
const handleDone = () => {
if (!setupComplete) return;
keepOpenDuringCheckRef.current = false;
setHasOpenedForSetup(false);
setCompletedDismissed(true);
};
const openSkillsDialog = () => {
setSkillsDialogOpen(true);
if (!SkillsOnboardingComponent) {
import(
"@/components/scientific-skills/scientific-skills-onboarding"
).then((mod) =>
setSkillsOnboardingComponent(() => mod.ScientificSkillsOnboarding),
);
}
};
const providerDetail = useMemo(() => {
if (!isClaudeInstalled) {
return "Install Claude Code before adding a provider";
}
if (!isClaudeReady) {
return "Add an API key or sign in";
}
const openAiProviderCount = Math.max(
openAiCredentials.length,
providerKind === "openai-compatible" ? 1 : 0,
);
const includesClaudeProvider =
claudeProviderConfigured || providerKind === "claude-code";
const count = openAiProviderCount + (includesClaudeProvider ? 1 : 0);
return `${count} provider${count === 1 ? "" : "s"} configured`;
}, [
claudeProviderConfigured,
isClaudeInstalled,
isClaudeReady,
openAiCredentials.length,
providerKind,
]);
return (
<>
<Dialog open={shouldShow} onOpenChange={() => undefined}>
<DialogContent
showCloseButton={false}
onEscapeKeyDown={(event) => event.preventDefault()}
onInteractOutside={(event) => event.preventDefault()}
className="w-[min(29rem,calc(100vw-2rem))] gap-0 overflow-hidden rounded-2xl border-border/70 p-0 shadow-xl sm:max-w-none"
>
<div className="flex flex-col items-center px-6 pt-6 pb-4 text-center">
<img
src="/icon-192.png"
alt="ClaudePrism"
className="size-14 object-contain"
/>
<DialogHeader className="mt-3 items-center gap-1.5 text-center">
<DialogTitle className="font-semibold text-xl">
ClaudePrism
</DialogTitle>
<DialogDescription className="max-w-sm text-sm leading-relaxed">
Set up the local tools and model provider required before
entering the workspace.
</DialogDescription>
</DialogHeader>
</div>
<div className="px-4.5 pb-3">
<div className="space-y-1.5">
<SetupItem
state={
isClaudeInstalling || claudeStatus === "checking"
? "loading"
: claudeStatus === "error"
? "error"
: isClaudeInstalled
? "ready"
: "blocked"
}
icon={TerminalIcon}
title="Claude Code"
detail={
isClaudeInstalling
? "Installing..."
: claudeStatus === "checking"
? "Checking..."
: claudeStatus === "missing-git"
? "Git for Windows is required first"
: claudeStatus === "not-installed"
? "Required for AI writing"
: claudeStatus === "error"
? claudeError || "Installation needs attention"
: claudeVersion
? `Installed ${claudeVersion}`
: "Installed"
}
action={
claudeStatus === "missing-git"
? {
label: "Git",
icon: GitBranchIcon,
onClick: () => {
shellOpen("https://git-scm.com/downloads/win");
},
}
: claudeStatus === "not-installed" ||
claudeStatus === "error"
? {
label: isClaudeInstalling ? "Installing" : "Install",
icon: isClaudeInstalling ? Loader2Icon : DownloadIcon,
loading: isClaudeInstalling,
onClick: installClaude,
}
: {
label: "Check",
icon: RefreshCwIcon,
onClick: checkClaudeStatus,
}
}
/>
<SetupItem
state={
isUvInstalling || uvStatus === "checking"
? "loading"
: uvStatus === "error"
? "error"
: isUvReady
? "ready"
: "blocked"
}
icon={TerminalIcon}
title="Python (uv)"
detail={
isUvInstalling
? "Installing..."
: uvStatus === "checking"
? "Checking..."
: uvStatus === "not-installed"
? "Required for Python workflows"
: uvStatus === "error"
? uvError || "Installation needs attention"
: uvVersion || "Installed"
}
action={
uvStatus === "not-installed" || uvStatus === "error"
? {
label: isUvInstalling ? "Installing" : "Install",
icon: isUvInstalling ? Loader2Icon : DownloadIcon,
loading: isUvInstalling,
onClick: installUv,
}
: {
label: "Check",
icon: RefreshCwIcon,
onClick: checkUvStatus,
}
}
/>
<SetupItem
state={
claudeStatus === "checking"
? "loading"
: claudeStatus === "error"
? "error"
: isClaudeReady
? "ready"
: "blocked"
}
icon={KeyRoundIcon}
title="AI Provider"
detail={providerDetail}
action={
isClaudeInstalled
? {
label: isClaudeReady ? "Manage" : "Configure",
icon: KeyRoundIcon,
onClick: () => setProviderDialogOpen(true),
}
: {
label: "Locked",
icon: KeyRoundIcon,
disabled: true,
}
}
/>
<SetupItem
state={
skillsChecking
? "loading"
: skillsError
? "error"
: isSkillsReady
? "ready"
: "blocked"
}
icon={FlaskConicalIcon}
title="Scientific Skills"
detail={
skillsChecking
? "Checking..."
: skillsError
? skillsError
: isSkillsReady
? `${skillsStatus?.skill_count ?? 0} skills installed`
: "Required for scientific writing"
}
action={
skillsError
? {
label: "Check",
icon: RefreshCwIcon,
onClick: checkSkillsStatus,
}
: {
label: isSkillsReady ? "Manage" : "Install",
icon: isSkillsReady ? FlaskConicalIcon : DownloadIcon,
onClick: openSkillsDialog,
disabled: skillsChecking,
}
}
/>
</div>
</div>
<div className="flex justify-center px-6 pt-1 pb-4">
<Button
disabled={!setupComplete}
className="h-10 min-w-28 justify-center rounded-full px-7"
onClick={handleDone}
>
Done
</Button>
</div>
</DialogContent>
</Dialog>
<Dialog open={providerDialogOpen} onOpenChange={setProviderDialogOpen}>
<DialogContent className="max-h-[85vh] w-[min(42rem,calc(100vw-2rem))] overflow-y-auto overflow-x-hidden sm:max-w-none">
<DialogHeader>
<DialogTitle>Add AI Provider</DialogTitle>
<DialogDescription>
Configure Anthropic or another model provider for this project.
</DialogDescription>
</DialogHeader>
<ClaudeSetup
variant="provider-dialog"
onCancel={() => setProviderDialogOpen(false)}
onSaved={() => {
setProviderDialogOpen(false);
checkClaudeStatus();
}}
/>
</DialogContent>
</Dialog>
{skillsDialogOpen && SkillsOnboardingComponent && (
<SkillsOnboardingComponent
onClose={() => {
setSkillsDialogOpen(false);
checkSkillsStatus();
}}
/>
)}
</>
);
}
function SetupItem({
state,
icon: Icon,
title,
detail,
action,
}: {
state: SetupItemState;
icon: typeof TerminalIcon;
title: string;
detail: string;
action?: {
label: string;
icon: typeof TerminalIcon;
onClick?: () => void;
loading?: boolean;
disabled?: boolean;
};
}) {
const ActionIcon = action?.icon;
return (
<div className="grid min-h-[3.75rem] w-full max-w-full grid-cols-[2.25rem_minmax(0,1fr)_auto] items-center gap-2.5 overflow-hidden rounded-xl bg-muted/35 px-3 py-2.5 transition-colors hover:bg-muted/50">
<div
className={cn(
"flex size-9 shrink-0 items-center justify-center",
state === "ready" && "text-green-600 dark:text-green-400",
state === "loading" && "text-muted-foreground",
state === "blocked" && "text-muted-foreground/75",
state === "error" && "text-destructive",
)}
>
{state === "ready" ? (
<CheckCircle2Icon className="size-6" />
) : state === "loading" ? (
<Loader2Icon className="size-5 animate-spin" />
) : state === "error" ? (
<AlertCircleIcon className="size-5" />
) : (
<Icon className="size-5" />
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate font-semibold text-sm">{title}</span>
{state === "blocked" && (
<CircleIcon className="size-2.5 shrink-0 text-muted-foreground/50" />
)}
</div>
<p
className={cn(
"mt-0.5 truncate text-sm",
state === "error" ? "text-destructive" : "text-muted-foreground",
)}
title={detail}
>
{detail}
</p>
</div>
{action && (
<Button
type="button"
variant={state === "ready" ? "outline" : "default"}
size="sm"
className="h-8 max-w-24 shrink-0 gap-1.5 rounded-lg px-2.5 text-sm"
onClick={action.onClick}
disabled={action.disabled || action.loading}
>
{ActionIcon && (
<ActionIcon
className={cn("size-3.5", action.loading && "animate-spin")}
/>
)}
{action.label}
</Button>
)}
</div>
);
}

View file

@ -3,15 +3,15 @@ import type { FallbackProps } from "react-error-boundary";
export function ErrorFallback({ error, resetErrorBoundary }: FallbackProps) {
return (
<div className="flex h-screen w-screen items-center justify-center bg-background p-8">
<div className="w-full max-w-2xl space-y-4">
<h1 className="font-bold text-2xl text-destructive">
<div className="max-w-2xl w-full space-y-4">
<h1 className="text-2xl font-bold text-destructive">
Something went wrong
</h1>
<p className="text-muted-foreground text-sm">
<p className="text-sm text-muted-foreground">
An unexpected error occurred. You can try again or reload the app.
</p>
<pre className="max-h-64 overflow-auto whitespace-pre-wrap rounded-md border bg-muted p-4 text-xs">
<pre className="max-h-64 overflow-auto rounded-md border bg-muted p-4 text-xs whitespace-pre-wrap">
{error instanceof Error
? `${error.message}${error.stack ? `\n\n${error.stack}` : ""}`
: String(error)}
@ -21,14 +21,14 @@ export function ErrorFallback({ error, resetErrorBoundary }: FallbackProps) {
<button
type="button"
onClick={resetErrorBoundary}
className="rounded-md bg-primary px-4 py-2 font-medium text-primary-foreground text-sm hover:bg-primary/90"
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
>
Try again
</button>
<button
type="button"
onClick={() => window.location.reload()}
className="rounded-md border px-4 py-2 font-medium text-sm hover:bg-accent"
className="rounded-md border px-4 py-2 text-sm font-medium hover:bg-accent"
>
Reload
</button>

View file

@ -1,38 +0,0 @@
import { EyeIcon, MessageCircleIcon } from "lucide-react";
import { getModelCapabilities } from "@/lib/model-capabilities";
import { cn } from "@/lib/utils";
interface ModelCapabilityBadgesProps {
label?: string | null;
baseUrl?: string | null;
model?: string | null;
className?: string;
}
export function ModelCapabilityBadges({
label,
baseUrl,
model,
className,
}: ModelCapabilityBadgesProps) {
const capabilities = getModelCapabilities({ label, baseUrl, model });
const badgeClass =
"inline-flex h-5 items-center justify-center rounded-md border border-neutral-300 bg-white text-neutral-600 dark:border-neutral-600 dark:bg-neutral-950 dark:text-neutral-300";
return (
<span
className={cn("inline-flex shrink-0 items-center gap-0.5", className)}
>
{capabilities.chat && (
<span className={cn(badgeClass, "min-w-5 px-1")} title="Chat model">
<MessageCircleIcon className="size-3" />
</span>
)}
{capabilities.vision && (
<span className={cn(badgeClass, "min-w-5 px-1")} title="Vision input">
<EyeIcon className="size-3" />
</span>
)}
</span>
);
}

File diff suppressed because it is too large Load diff

View file

@ -3,7 +3,6 @@ import { open } from "@tauri-apps/plugin-dialog";
import { mkdir, writeTextFile } from "@tauri-apps/plugin-fs";
import { getCurrentWebview } from "@tauri-apps/api/webview";
import { homeDir } from "@tauri-apps/api/path";
import { toast } from "sonner";
import {
ArrowLeftIcon,
FolderOpenIcon,
@ -23,21 +22,21 @@ import { useProjectStore } from "@/stores/project-store";
import { useDocumentStore } from "@/stores/document-store";
import { useClaudeChatStore } from "@/stores/claude-chat-store";
import { exists, join } from "@/lib/tauri/fs";
import {
getTemplateById,
getTemplateSkeleton,
BIB_TEMPLATE,
} from "@/lib/template-registry";
import { getTemplateById, getTemplateSkeleton, BIB_TEMPLATE } from "@/lib/template-registry";
import { TemplateGallery } from "@/components/template-gallery";
import { DEFAULT_CLAUDE_MD } from "@/lib/default-claude-md";
import {
buildReferenceFilesSection,
importReferenceFiles,
} from "@/lib/project-attachments";
import { getProjectNameError, normalizeProjectName } from "@/lib/project-name";
// ─── Helpers ───
function randomProjectName(): string {
const adjectives = ["swift", "bright", "calm", "bold", "keen", "warm", "pure", "vast", "deep", "fair"];
const nouns = ["paper", "draft", "thesis", "note", "study", "essay", "report", "brief", "folio", "opus"];
const adj = adjectives[Math.floor(Math.random() * adjectives.length)];
const noun = nouns[Math.floor(Math.random() * nouns.length)];
const id = Math.random().toString(36).slice(2, 6);
return `${adj}-${noun}-${id}`;
}
// ─── Wizard Component ───
export type CreationMode = "template" | "scratch";
@ -53,13 +52,8 @@ export function ProjectWizard({ mode, onBack }: ProjectWizardProps) {
if (mode === "template") {
return (
<div className="flex h-full flex-col bg-background">
<div className="flex h-[calc(48px+var(--titlebar-height))] shrink-0 items-center gap-3 border-border/60 border-b px-4 pt-[var(--titlebar-height)]">
<Button
variant="ghost"
size="icon"
className="size-7 rounded-lg"
onClick={onBack}
>
<div className="flex shrink-0 items-center gap-3 border-b border-border/60 px-4 pt-[var(--titlebar-height)] h-[calc(48px+var(--titlebar-height))]">
<Button variant="ghost" size="icon" className="size-7 rounded-lg" onClick={onBack}>
<ArrowLeftIcon className="size-4" />
</Button>
<span className="font-semibold text-sm">Choose a Template</span>
@ -81,14 +75,12 @@ function ScratchForm({ onBack }: { onBack: () => void }) {
const [purpose, setPurpose] = useState("");
const [attachments, setAttachments] = useState<string[]>([]);
const [projectFolder, setProjectFolder] = useState<string | null>(null);
const [projectName, setProjectName] = useState("");
const [projectNameError, setProjectNameError] = useState("");
const [projectName, setProjectName] = useState(randomProjectName);
const [isCreating, setIsCreating] = useState(false);
const [isDragOver, setIsDragOver] = useState(false);
const [refFilesOpen, setRefFilesOpen] = useState(false);
const [locationOpen, setLocationOpen] = useState(false);
const projectNameRef = useRef<HTMLInputElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const addRecentProject = useProjectStore((s) => s.addRecentProject);
@ -99,7 +91,7 @@ function ScratchForm({ onBack }: { onBack: () => void }) {
const template = getTemplateById("blank")!;
useEffect(() => {
const timer = setTimeout(() => projectNameRef.current?.focus(), 100);
const timer = setTimeout(() => textareaRef.current?.focus(), 100);
return () => clearTimeout(timer);
}, []);
@ -108,24 +100,15 @@ function ScratchForm({ onBack }: { onBack: () => void }) {
if (lastProjectFolder) {
setProjectFolder(lastProjectFolder);
} else {
homeDir()
.then((home) => join(home, "Documents", "ClaudePrism"))
.then(async (dir) => {
await mkdir(dir, { recursive: true }).catch(() => {});
setProjectFolder(dir);
})
.catch((err) =>
console.warn("Failed to resolve default project folder:", err),
);
homeDir().then((home) => join(home, "Documents", "ClaudePrism")).then((dir) => {
mkdir(dir, { recursive: true }).catch(() => {});
setProjectFolder(dir);
}).catch(() => {});
}
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const handleChooseFolder = useCallback(async () => {
const selected = await open({
directory: true,
multiple: false,
title: "Choose Location for New Project",
});
const selected = await open({ directory: true, multiple: false, title: "Choose Location for New Project" });
if (selected) {
setProjectFolder(selected);
setLastProjectFolder(selected);
@ -136,13 +119,14 @@ function ScratchForm({ onBack }: { onBack: () => void }) {
const selected = await open({
multiple: true,
title: "Add Reference Files",
filters: [{
name: "Documents & Images",
extensions: ["pdf", "tex", "bib", "txt", "md", "png", "jpg", "jpeg", "gif", "svg", "csv", "tsv", "json"],
}],
});
if (selected) {
const paths = Array.isArray(selected) ? selected : [selected];
setAttachments((prev) => [
...prev,
...paths.filter((p) => !prev.includes(p)),
]);
setAttachments((prev) => [...prev, ...paths.filter((p) => !prev.includes(p))]);
}
}, []);
@ -166,43 +150,25 @@ function ScratchForm({ onBack }: { onBack: () => void }) {
setIsDragOver(false);
const paths = (event.payload as { paths: string[] }).paths;
if (paths?.length > 0) {
setAttachments((prev) => [
...prev,
...paths.filter((p) => !prev.includes(p)),
]);
setAttachments((prev) => [...prev, ...paths.filter((p) => !prev.includes(p))]);
}
} else if (type === "leave") {
setIsDragOver(false);
}
})
.then((fn) => {
if (cancelled) fn();
else unlisten = fn;
})
.then((fn) => { if (cancelled) fn(); else unlisten = fn; })
.catch(() => {});
return () => {
cancelled = true;
unlisten?.();
};
return () => { cancelled = true; unlisten?.(); };
}, []);
const handleCreate = async () => {
const name = normalizeProjectName(projectName);
const nameError = getProjectNameError(projectName);
if (!template || !projectFolder || nameError) {
setProjectNameError(nameError ?? "");
return;
}
if (!template || !projectFolder || !projectName.trim()) return;
setIsCreating(true);
try {
const projectPath = await join(projectFolder, name);
if (await exists(projectPath)) {
setProjectNameError("A folder with this name already exists here");
return;
}
await mkdir(projectPath, { recursive: true });
const projectPath = await join(projectFolder, projectName.trim());
await mkdir(projectPath, { recursive: true }).catch(() => {});
// Create CLAUDE.md for Claude Code context
const claudeMdPath = await join(projectPath, "CLAUDE.md");
@ -225,13 +191,16 @@ function ScratchForm({ onBack }: { onBack: () => void }) {
}
}
const referenceFiles =
attachments.length > 0
? await importReferenceFiles(projectPath, attachments)
: [];
if (attachments.length > 0) {
const attachmentsDir = await join(projectPath, "attachments");
await mkdir(attachmentsDir, { recursive: true }).catch(() => {});
}
if (purpose.trim()) {
const attachmentSection = buildReferenceFilesSection(referenceFiles);
const attachmentNames = attachments.map((p) => p.split("/").pop()).filter(Boolean);
const attachmentSection = attachmentNames.length > 0
? `\n### Reference Files\n${attachmentNames.map((n) => `- \`${n}\``).join("\n")}\n\nPlease review them and incorporate relevant information.\n`
: "";
const prompt = [
`## New ${template.name} Project`,
@ -257,30 +226,24 @@ function ScratchForm({ onBack }: { onBack: () => void }) {
setLastProjectFolder(projectFolder);
addRecentProject(projectPath);
await openProject(projectPath);
if (attachments.length > 0) {
await useDocumentStore.getState().importFiles(attachments, "attachments");
}
} catch (err) {
console.error("Failed to create project:", err);
toast.error("Failed to create project", {
description: err instanceof Error ? err.message : String(err),
});
} finally {
setIsCreating(false);
}
};
const canCreate = Boolean(
template && projectFolder && !getProjectNameError(projectName),
);
const canCreate = template && projectFolder && projectName.trim();
return (
<div className="flex h-full flex-col bg-background">
{/* Header */}
<div className="flex h-[calc(48px+var(--titlebar-height))] shrink-0 items-center gap-3 border-border/60 border-b px-4 pt-[var(--titlebar-height)]">
<Button
variant="ghost"
size="icon"
className="size-7 rounded-lg"
onClick={onBack}
>
<div className="flex shrink-0 items-center gap-3 border-b border-border/60 px-4 pt-[var(--titlebar-height)] h-[calc(48px+var(--titlebar-height))]">
<Button variant="ghost" size="icon" className="size-7 rounded-lg" onClick={onBack}>
<ArrowLeftIcon className="size-4" />
</Button>
<span className="font-semibold text-sm">New Document</span>
@ -289,41 +252,12 @@ function ScratchForm({ onBack }: { onBack: () => void }) {
{/* Form */}
<div className="flex-1 overflow-y-auto">
<div className="mx-auto w-full max-w-[520px] space-y-4 px-6 py-10">
{/* Project name */}
<div className="space-y-2.5">
<div>
<span className="font-semibold text-sm">Project name</span>
<p className="mt-0.5 text-muted-foreground text-xs leading-relaxed">
This becomes the folder name on disk.
</p>
</div>
<Input
ref={projectNameRef}
placeholder="e.g., flashvid-paper"
value={projectName}
onChange={(e) => {
setProjectName(e.target.value);
setProjectNameError("");
}}
onBlur={() =>
setProjectNameError(getProjectNameError(projectName) ?? "")
}
className="rounded-xl border-border/60 bg-card/30 text-sm focus-visible:bg-card/50"
/>
{projectNameError && (
<p className="text-destructive text-xs">{projectNameError}</p>
)}
</div>
{/* Purpose */}
<div className="space-y-2.5">
<div>
<span className="font-semibold text-sm">
What are you writing?
</span>
<label className="font-semibold text-sm">What are you writing?</label>
<p className="mt-0.5 text-muted-foreground text-xs leading-relaxed">
Describe your document and Claude will generate tailored
content.
Describe your document and Claude will generate tailored content.
</p>
</div>
<Textarea
@ -337,7 +271,7 @@ function ScratchForm({ onBack }: { onBack: () => void }) {
</div>
{/* Collapsible sections */}
<div className="divide-y divide-border/40 overflow-hidden rounded-xl border border-border/60 bg-card/30">
<div className="rounded-xl border border-border/60 bg-card/30 divide-y divide-border/40 overflow-hidden">
{/* Reference files */}
<div>
<button
@ -347,10 +281,10 @@ function ScratchForm({ onBack }: { onBack: () => void }) {
<div className="flex size-7 shrink-0 items-center justify-center rounded-lg bg-muted/50">
<FileTextIcon className="size-3.5 text-muted-foreground" />
</div>
<div className="min-w-0 flex-1">
<span className="font-medium text-sm">Reference files</span>
<div className="flex-1 min-w-0">
<span className="text-sm font-medium">Reference files</span>
{attachments.length > 0 && (
<span className="ml-2 inline-flex items-center justify-center rounded-full bg-primary/15 px-1.5 py-0.5 font-semibold text-[10px] text-primary leading-none">
<span className="ml-2 inline-flex items-center justify-center rounded-full bg-primary/15 px-1.5 py-0.5 text-[10px] font-semibold leading-none text-primary">
{attachments.length}
</span>
)}
@ -360,18 +294,16 @@ function ScratchForm({ onBack }: { onBack: () => void }) {
/>
</button>
{refFilesOpen && (
<div className="space-y-3 px-4 pb-4">
<div className="px-4 pb-4 space-y-3">
{attachments.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{attachments.map((path) => (
<div
key={path}
className="flex items-center gap-1.5 rounded-lg border border-border/50 bg-muted/40 py-1 pr-1.5 pl-2.5 text-xs transition-colors hover:bg-muted/60"
className="flex items-center gap-1.5 rounded-lg border border-border/50 bg-muted/40 pl-2.5 pr-1.5 py-1 text-xs transition-colors hover:bg-muted/60"
>
<PaperclipIcon className="size-3 shrink-0 text-muted-foreground/70" />
<span className="max-w-[140px] truncate text-foreground/80">
{path.split(/[/\\]/).pop()}
</span>
<span className="max-w-[140px] truncate text-foreground/80">{path.split("/").pop()}</span>
<button
onClick={() => handleRemoveAttachment(path)}
className="flex size-4 shrink-0 items-center justify-center rounded-md text-muted-foreground/50 transition-colors hover:bg-destructive/10 hover:text-destructive"
@ -385,27 +317,23 @@ function ScratchForm({ onBack }: { onBack: () => void }) {
<div
className={`flex flex-col items-center gap-2 rounded-lg border border-dashed p-4 transition-all ${
isDragOver
? "scale-[1.01] border-primary bg-primary/5"
? "border-primary bg-primary/5 scale-[1.01]"
: "border-border/60 hover:border-border hover:bg-muted/20"
}`}
>
{isDragOver ? (
<>
<UploadIcon className="size-5 text-primary" />
<span className="font-medium text-primary text-xs">
Drop to add
</span>
<span className="text-xs font-medium text-primary">Drop to add</span>
</>
) : (
<>
<UploadIcon className="size-5 text-muted-foreground/40" />
<div className="text-center">
<span className="text-muted-foreground/70 text-xs">
Drag & drop or{" "}
</span>
<span className="text-xs text-muted-foreground/70">Drag & drop or </span>
<button
onClick={handleAddAttachments}
className="font-medium text-foreground/70 text-xs underline decoration-border underline-offset-2 transition-colors hover:text-foreground hover:decoration-foreground/50"
className="text-xs font-medium text-foreground/70 underline underline-offset-2 decoration-border hover:text-foreground hover:decoration-foreground/50 transition-colors"
>
browse files
</button>
@ -429,13 +357,12 @@ function ScratchForm({ onBack }: { onBack: () => void }) {
<div className="flex size-7 shrink-0 items-center justify-center rounded-lg bg-muted/50">
<MapPinIcon className="size-3.5 text-muted-foreground" />
</div>
<div className="min-w-0 flex-1">
<span className="font-medium text-sm">Project location</span>
<div className="flex-1 min-w-0">
<span className="text-sm font-medium">Project location</span>
</div>
{!locationOpen && projectFolder && projectName.trim() && (
<span className="min-w-0 max-w-[180px] truncate rounded-md bg-muted/40 px-2 py-0.5 font-mono text-[11px] text-muted-foreground/60">
.../{projectFolder.split(/[/\\]/).pop()}/
{normalizeProjectName(projectName)}
<span className="min-w-0 max-w-[180px] truncate rounded-md bg-muted/40 px-2 py-0.5 text-[11px] font-mono text-muted-foreground/60">
.../{projectFolder.split("/").pop()}/{projectName.trim()}
</span>
)}
<ChevronDownIcon
@ -443,13 +370,14 @@ function ScratchForm({ onBack }: { onBack: () => void }) {
/>
</button>
{locationOpen && (
<div className="space-y-2.5 px-4 pb-4">
<div className="flex items-center gap-2">
<p className="min-w-0 flex-1 truncate rounded-md bg-muted/30 px-2.5 py-1.5 font-mono text-[11px] text-muted-foreground/60">
{projectFolder
? `${projectFolder}/${normalizeProjectName(projectName) || "..."}`
: "Choose a location"}
</p>
<div className="px-4 pb-4 space-y-2.5">
<div className="flex gap-2">
<Input
placeholder="Project name"
value={projectName}
onChange={(e) => setProjectName(e.target.value)}
className="flex-1 rounded-lg border-border/60 bg-background/50"
/>
<Button
variant="outline"
size="sm"
@ -460,6 +388,11 @@ function ScratchForm({ onBack }: { onBack: () => void }) {
{projectFolder ? "Change" : "Choose"}
</Button>
</div>
{projectFolder && (
<p className="truncate rounded-md bg-muted/30 px-2.5 py-1.5 font-mono text-[11px] text-muted-foreground/60">
{projectFolder}/{projectName.trim() || "..."}
</p>
)}
</div>
)}
</div>

View file

@ -1,33 +1,21 @@
import { useMemo } from "react";
import { useEffect, useRef, useState } from "react";
import { listen } from "@tauri-apps/api/event";
import { Progress } from "@/components/ui/progress";
import { ScrollArea } from "@/components/ui/scroll-area";
const PHASE_MAP: Record<string, number> = {
"Preparing installer": 0,
"Checking directory permissions...": 5,
"Directory permissions OK": 10,
"Git available": 15,
"cloning repository": 20,
"Downloading skills": 20,
"downloading tarball": 20,
"Download complete": 60,
"Copying skills": 70,
Copied: 90,
"Copied": 90,
"Cleanup complete": 95,
};
function pctFromLog(log: string): number | null {
const downloadMatch = log.match(/^Download progress\s+(\d+)%/i);
if (downloadMatch?.[1]) {
const downloadPct = Math.max(0, Math.min(100, Number(downloadMatch[1])));
return Math.round(20 + downloadPct * 0.4);
}
const downloadedMatch = log.match(/^Downloaded\s+(\d+)\s+MiB/i);
if (downloadedMatch?.[1]) {
const mib = Math.max(0, Number(downloadedMatch[1]));
return Math.min(55, 20 + mib);
}
for (const [key, pct] of Object.entries(PHASE_MAP)) {
if (log.toLowerCase().includes(key.toLowerCase())) return pct;
}
@ -38,21 +26,37 @@ interface InstallProgressProps {
isInstalling: boolean;
isComplete: boolean;
error: string | null;
logs: string[];
}
export function InstallProgress({
isInstalling,
isComplete,
error,
logs,
}: InstallProgressProps) {
const pct = useMemo(() => {
if (isComplete) return 100;
return logs.reduce((current, line) => {
const next = pctFromLog(line);
return next === null ? current : Math.max(current, next);
}, 0);
}, [isComplete, logs]);
const [logs, setLogs] = useState<string[]>([]);
const [pct, setPct] = useState(0);
const scrollRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!isInstalling) return;
const unlisten = listen<string>("skills-install-log", (event) => {
setLogs((prev) => [...prev, event.payload]);
const p = pctFromLog(event.payload);
if (p !== null) setPct(p);
});
return () => {
unlisten.then((fn) => fn());
};
}, [isInstalling]);
useEffect(() => {
if (isComplete) setPct(100);
}, [isComplete]);
// Auto-scroll to bottom
useEffect(() => {
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight });
}, [logs]);
const label = isComplete
? "Done"
@ -66,13 +70,22 @@ export function InstallProgress({
<div className="space-y-2 py-1">
<Progress value={pct} />
<div className="flex items-center justify-between">
<p className="max-w-[80%] truncate text-muted-foreground text-xs">
<p className="text-muted-foreground text-xs truncate max-w-[80%]">
{label}
</p>
<p className="font-mono text-muted-foreground text-xs tabular-nums">
{pct}%
</p>
</div>
{logs.length > 0 && (
<ScrollArea className="h-28 rounded-md border border-border/60 bg-muted/30">
<div ref={scrollRef} className="p-2 font-mono text-[11px] leading-relaxed text-muted-foreground">
{logs.map((line, i) => (
<div key={i}>{line}</div>
))}
</div>
</ScrollArea>
)}
</div>
);
}

View file

@ -1,8 +1,5 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { open } from "@tauri-apps/plugin-dialog";
import { toast } from "sonner";
import {
FlaskConicalIcon,
DownloadIcon,
@ -13,8 +10,6 @@ import {
Trash2Icon,
Loader2Icon,
ChevronLeftIcon,
FolderPlusIcon,
XIcon,
} from "lucide-react";
import {
Dialog,
@ -50,14 +45,6 @@ interface SkillsStatus {
location: string;
}
interface SkillInfo {
id: string;
name: string;
domain: string;
description: string;
folder: string;
}
interface ScientificSkillsOnboardingProps {
onClose: () => void;
}
@ -69,60 +56,20 @@ export function ScientificSkillsOnboarding({
const [selectedId, setSelectedId] = useState<string | null>(null);
const [isInstalling, setIsInstalling] = useState(false);
const [isComplete, setIsComplete] = useState(false);
const [installLogs, setInstallLogs] = useState<string[]>([]);
const [installResult, setInstallResult] = useState<InstallResult | null>(
null,
);
const [installResult, setInstallResult] = useState<InstallResult | null>(null);
const [error, setError] = useState<string | null>(null);
const [status, setStatus] = useState<SkillsStatus | null>(null);
const [installedSkills, setInstalledSkills] = useState<SkillInfo[]>([]);
const [isUninstalling, setIsUninstalling] = useState(false);
const [isImporting, setIsImporting] = useState(false);
const [confirmUninstallAllOpen, setConfirmUninstallAllOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<SkillEntryData | null>(null);
const [deletingSkillFolder, setDeletingSkillFolder] = useState<string | null>(
null,
);
const mountedRef = useRef(true);
const installBackendLogSeenRef = useRef(false);
useEffect(() => {
return () => {
mountedRef.current = false;
};
}, []);
useEffect(() => {
const unlisten = listen<string>("skills-install-log", (event) => {
installBackendLogSeenRef.current = true;
setInstallLogs((previous) => {
const last = previous[previous.length - 1];
if (last === event.payload) return previous;
return [...previous, event.payload];
});
});
return () => {
unlisten.then((fn) => fn());
};
}, []);
// Skills are global Claude Code assets under ~/.claude/skills.
// Check global install status
const checkStatus = useCallback(async () => {
try {
const [gs, skills] = await Promise.all([
invoke<SkillsStatus>("check_skills_installed", {
projectPath: null,
}),
invoke<SkillInfo[]>("list_installed_skills", {
projectPath: null,
}),
]);
const gs = await invoke<SkillsStatus>("check_skills_installed", {
projectPath: null,
});
setStatus(gs);
setInstalledSkills(skills);
} catch {
setStatus(null);
setInstalledSkills([]);
}
}, []);
@ -139,88 +86,22 @@ export function ScientificSkillsOnboarding({
.catch(console.error);
}, []);
const knownSkillFolders = new Set(
categories.flatMap((category) =>
category.skills.map((skill) => skill.folder),
),
);
const importedSkills = installedSkills.filter(
(skill) => !knownSkillFolders.has(skill.folder),
);
const installedSkillFolders = new Set(
installedSkills.map((skill) => skill.folder),
);
const displayCategories: SkillCategoryData[] =
importedSkills.length > 0
? [
...categories,
{
id: "imported",
name: "Imported Skills",
icon: "settings",
skill_count: importedSkills.length,
skills: importedSkills.map((skill) => ({
name: skill.name,
folder: skill.folder,
})),
},
]
: categories;
const totalSkills = displayCategories.reduce(
(sum, c) => sum + c.skill_count,
0,
);
const selected =
displayCategories.find((c) => c.id === selectedId) ??
displayCategories[0] ??
null;
const totalSkills = categories.reduce((sum, c) => sum + c.skill_count, 0);
const selected = categories.find((c) => c.id === selectedId) ?? null;
const isInstalled = status?.installed ?? false;
const handleInstall = useCallback(async () => {
installBackendLogSeenRef.current = false;
let noBackendLogTimer: number | undefined;
setInstallLogs(["Preparing installer..."]);
setIsInstalling(true);
setIsComplete(false);
setInstallResult(null);
setError(null);
try {
noBackendLogTimer = window.setTimeout(() => {
if (installBackendLogSeenRef.current || !mountedRef.current) return;
setInstallLogs((previous) => {
const hasBackendLog = previous.some(
(line) => !line.startsWith("Preparing installer"),
);
if (hasBackendLog) return previous;
return [...previous, "Waiting for the installer command to start..."];
});
}, 2500);
await new Promise((resolve) => window.setTimeout(resolve, 150));
if (!mountedRef.current) return;
const result = await invoke<InstallResult>(
"install_scientific_skills_global",
{},
);
if (noBackendLogTimer !== undefined) {
window.clearTimeout(noBackendLogTimer);
}
if (!mountedRef.current) return;
const result = await invoke<InstallResult>("install_scientific_skills_global");
setInstallResult(result);
setIsComplete(true);
localStorage.setItem(STORAGE_KEY, "true");
await checkStatus();
} catch (e) {
if (noBackendLogTimer !== undefined) {
window.clearTimeout(noBackendLogTimer);
}
if (!mountedRef.current) return;
const message = String(e);
setInstallLogs((previous) => [...previous, message]);
setError(message);
setError(String(e));
setIsInstalling(false);
}
}, [checkStatus]);
@ -228,125 +109,36 @@ export function ScientificSkillsOnboarding({
const handleUninstall = useCallback(async () => {
setIsUninstalling(true);
try {
await invoke("uninstall_scientific_skills", {
projectPath: null,
});
await invoke("uninstall_scientific_skills", { projectPath: null });
await checkStatus();
const gsAfter = await invoke<SkillsStatus>("check_skills_installed", {
projectPath: null,
});
const gsAfter = await invoke<SkillsStatus>("check_skills_installed", { projectPath: null });
if (!gsAfter.installed) {
localStorage.removeItem(STORAGE_KEY);
}
toast.success("All skills uninstalled");
} catch (e) {
console.error("Failed to uninstall:", e);
toast.error("Failed to uninstall skills", {
description: String(e),
});
} finally {
setIsUninstalling(false);
}
}, [checkStatus]);
const handleImportSkill = useCallback(async () => {
setIsImporting(true);
try {
const selectedFolder = await open({
directory: true,
multiple: false,
title: "Import Claude Skill Folder",
});
if (typeof selectedFolder !== "string") return;
const skills = await invoke<SkillInfo[]>("import_skill_from_folder", {
sourcePath: selectedFolder,
});
localStorage.setItem(STORAGE_KEY, "true");
await checkStatus();
setSelectedId("imported");
toast.success("Skill imported", {
description:
skills.length === 1
? skills[0]?.name
: `${skills.length} skills imported`,
});
} catch (e) {
toast.error("Failed to import skill", {
description: String(e),
});
} finally {
setIsImporting(false);
}
}, [checkStatus]);
const handleConfirmDeleteSkill = useCallback(async () => {
if (!deleteTarget) return;
setDeletingSkillFolder(deleteTarget.folder);
try {
await invoke("delete_installed_skill", {
skillFolder: deleteTarget.folder,
});
toast.success("Skill deleted", {
description: deleteTarget.name,
});
setDeleteTarget(null);
await checkStatus();
} catch (e) {
toast.error("Failed to delete skill", {
description: String(e),
});
} finally {
setDeletingSkillFolder(null);
}
}, [checkStatus, deleteTarget]);
// ─── Installing / Complete state ───
if (isInstalling || isComplete || error) {
if (isInstalling || isComplete) {
return (
<Dialog
open
onOpenChange={(open) => {
if (!open) onClose();
}}
>
<DialogContent
showCloseButton={false}
className="gap-3 px-6 pt-6 pb-4 sm:max-w-md"
>
<button
type="button"
aria-label="Close"
className="absolute top-4 right-4 z-10 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
onClose();
}}
>
<XIcon className="size-4" />
</button>
<Dialog open onOpenChange={(open) => { if (!open && (isComplete || error)) onClose(); }}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-sm">
{isComplete ? (
<CheckCircle2Icon className="size-5 text-foreground" />
) : error ? (
<AlertCircleIcon className="size-5 text-destructive" />
) : (
<FlaskConicalIcon className="size-5 text-muted-foreground" />
)}
{isComplete
? "Installation Complete"
: error
? "Installation Failed"
: "Installing Skills"}
{isComplete ? "Installation Complete" : "Installing Skills"}
</DialogTitle>
{isComplete && (
<DialogDescription>
{installResult?.skills_installed ?? 0} scientific skills are now
available.
{installResult?.skills_installed ?? 0} scientific skills are now available.
</DialogDescription>
)}
</DialogHeader>
@ -355,15 +147,12 @@ export function ScientificSkillsOnboarding({
isInstalling={isInstalling}
isComplete={isComplete}
error={error}
logs={installLogs}
/>
{error && (
<div className="flex items-start gap-2 rounded-lg border border-destructive/30 bg-destructive/5 p-3">
<AlertCircleIcon className="mt-0.5 size-4 shrink-0 text-destructive" />
<p className="text-muted-foreground text-xs leading-relaxed">
{error}
</p>
<p className="text-xs leading-relaxed text-muted-foreground">{error}</p>
</div>
)}
@ -372,7 +161,7 @@ export function ScientificSkillsOnboarding({
<Button
variant="outline"
size="sm"
onClick={handleInstall}
onClick={() => { setError(null); setIsInstalling(false); }}
className="gap-1.5"
>
<RefreshCwIcon className="size-3.5" />
@ -392,254 +181,132 @@ export function ScientificSkillsOnboarding({
// ─── Browse state — two-column layout ───
return (
<>
<Dialog
open
onOpenChange={(open) => {
if (!open) onClose();
}}
<Dialog open onOpenChange={(open) => { if (!open) onClose(); }}>
<DialogContent
showCloseButton={false}
className="flex max-w-none flex-col gap-0 overflow-hidden p-0 sm:max-w-none w-[min(56rem,calc(100vw-4rem))] h-[min(36rem,calc(100vh-6rem))]"
>
<DialogContent
showCloseButton={false}
className="flex h-[min(36rem,calc(100vh-6rem))] w-[min(56rem,calc(100vw-4rem))] max-w-none flex-col gap-0 overflow-hidden p-0 sm:max-w-none"
>
{/* Header */}
<DialogHeader className="shrink-0 border-border border-b px-6 py-3">
<div className="flex items-center gap-4">
<div className="min-w-0 flex-1">
<DialogTitle className="text-sm">Skills</DialogTitle>
<DialogDescription className="mt-0.5 text-xs">
{totalSkills} skills across {displayCategories.length} groups
- install curated scientific skills or import a local Claude
skill. Curated set powered by{" "}
<a
href="https://github.com/K-Dense-AI/scientific-agent-skills"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-0.5 underline decoration-border underline-offset-2 hover:text-foreground"
>
K-Dense
<ExternalLinkIcon className="size-2.5" />
</a>
</DialogDescription>
</div>
<div className="flex shrink-0 items-center gap-2">
{isInstalled ? (
<>
<Badge variant="secondary" className="gap-1 text-xs">
<CheckCircle2Icon className="size-3" />
{status?.skill_count} installed
</Badge>
<Button
variant="outline"
size="sm"
onClick={handleInstall}
className="gap-1.5"
>
<RefreshCwIcon className="size-3.5" />
Update
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setConfirmUninstallAllOpen(true)}
disabled={isUninstalling}
className="gap-1.5 text-destructive hover:text-destructive"
>
{isUninstalling ? (
<Loader2Icon className="size-3.5 animate-spin" />
) : (
<Trash2Icon className="size-3.5" />
)}
Uninstall
</Button>
</>
) : (
<Button size="sm" onClick={handleInstall} className="gap-1.5">
<DownloadIcon className="size-3.5" />
Install All
</Button>
)}
<Button
variant="outline"
size="sm"
onClick={handleImportSkill}
disabled={isImporting || isInstalling || isUninstalling}
className="gap-1.5 border-border/70 bg-muted/30 text-foreground shadow-none hover:bg-muted/60 hover:text-foreground"
{/* Header */}
<DialogHeader className="shrink-0 border-b border-border px-6 py-3">
<div className="flex items-center gap-4">
<div className="min-w-0 flex-1">
<DialogTitle className="text-sm">Scientific Skills</DialogTitle>
<DialogDescription className="mt-0.5 text-xs">
{totalSkills} AI skills across {categories.length} domains — powered by{" "}
<a
href="https://github.com/K-Dense-AI/claude-scientific-skills"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-0.5 underline decoration-border underline-offset-2 hover:text-foreground"
>
{isImporting ? (
<Loader2Icon className="size-3.5 animate-spin text-muted-foreground" />
) : (
<FolderPlusIcon className="size-3.5 text-muted-foreground" />
)}
Import Skill
</Button>
</div>
K-Dense
<ExternalLinkIcon className="size-2.5" />
</a>
</DialogDescription>
</div>
</DialogHeader>
<div className="flex shrink-0 items-center gap-3">
{isInstalled ? (
<>
<Badge variant="secondary" className="gap-1 text-xs">
<CheckCircle2Icon className="size-3" />
{status?.skill_count} installed
</Badge>
<Button
variant="outline"
size="sm"
onClick={handleInstall}
className="gap-1.5"
>
<RefreshCwIcon className="size-3.5" />
Update
</Button>
<Button
variant="outline"
size="sm"
onClick={handleUninstall}
disabled={isUninstalling}
className="gap-1.5 text-destructive hover:text-destructive"
>
{isUninstalling ? (
<Loader2Icon className="size-3.5 animate-spin" />
) : (
<Trash2Icon className="size-3.5" />
)}
Uninstall
</Button>
</>
) : (
<Button size="sm" onClick={handleInstall} className="gap-1.5">
<DownloadIcon className="size-3.5" />
Install All
</Button>
)}
</div>
</div>
</DialogHeader>
{/* Body — sidebar + detail */}
<div className="flex flex-1 overflow-hidden">
{/* Category sidebar */}
<nav className="w-64 max-w-64 shrink-0 overflow-hidden border-border border-r">
<ScrollArea className="h-full w-full overflow-hidden [&_[data-slot=scroll-area-scrollbar]]:hidden">
<div className="box-border flex w-full min-w-0 flex-col gap-0.5 overflow-x-hidden p-2">
{displayCategories.map((cat) => {
const Icon = ICON_MAP[cat.icon] || FlaskConicalIcon;
const isActive = selectedId === cat.id;
return (
<button
key={cat.id}
onClick={() => setSelectedId(cat.id)}
className={cn(
"box-border grid w-full min-w-0 max-w-full grid-cols-[1rem_minmax(0,1fr)] items-center gap-2.5 overflow-hidden rounded-lg px-3 py-2 text-left text-sm transition-colors",
isActive
? "bg-accent font-medium text-accent-foreground"
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
)}
title={cat.name}
>
<Icon className="size-4 shrink-0" />
<span className="block min-w-0 truncate">
{cat.name}
</span>
</button>
);
})}
{/* Body — sidebar + detail */}
<div className="flex flex-1 overflow-hidden">
{/* Category sidebar */}
<nav className="w-64 shrink-0 overflow-hidden border-r border-border">
<ScrollArea className="h-full">
<div className="flex flex-col gap-0.5 p-2">
{categories.map((cat) => {
const Icon = ICON_MAP[cat.icon] || FlaskConicalIcon;
const isActive = selectedId === cat.id;
return (
<button
key={cat.id}
onClick={() => setSelectedId(cat.id)}
className={cn(
"flex items-center gap-2.5 overflow-hidden rounded-lg px-3 py-2 text-left text-sm transition-colors",
isActive
? "bg-accent font-medium text-accent-foreground"
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
)}
>
<Icon className="size-4 shrink-0" />
<span className="min-w-0 flex-1 truncate">{cat.name}</span>
<span className="text-xs tabular-nums text-muted-foreground">
{cat.skill_count}
</span>
</button>
);
})}
</div>
</ScrollArea>
</nav>
{/* Detail panel */}
<div className="flex flex-1 flex-col overflow-hidden">
{selected ? (
<ScrollArea className="flex-1">
<div className="p-6">
<CategoryDetail
category={selected}
isInstalled={isInstalled}
/>
</div>
</ScrollArea>
</nav>
) : (
<div className="flex flex-1 items-center justify-center text-muted-foreground text-sm">
Select a category
</div>
)}
</div>
</div>
{/* Detail panel */}
<div className="flex flex-1 flex-col overflow-hidden">
{selected ? (
<ScrollArea className="flex-1">
<div className="p-6">
<CategoryDetail
category={selected}
isInstalled={isInstalled}
installedSkillFolders={installedSkillFolders}
deletingSkillFolder={deletingSkillFolder}
onDeleteSkill={setDeleteTarget}
/>
</div>
</ScrollArea>
) : (
<div className="flex flex-1 items-center justify-center text-muted-foreground text-sm">
Select a category
</div>
)}
</div>
</div>
{/* Footer */}
<div className="flex shrink-0 items-center justify-between border-border border-t bg-muted/20 px-6 py-2.5">
<p className="font-mono text-[11px] text-muted-foreground/60">
{status?.location ?? "~/.claude/skills/"}
</p>
<Button
variant="ghost"
size="sm"
onClick={onClose}
className="text-muted-foreground"
>
Close
</Button>
</div>
</DialogContent>
</Dialog>
<Dialog
open={deleteTarget !== null}
onOpenChange={(open) => {
if (!open && !deletingSkillFolder) setDeleteTarget(null);
}}
>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>Delete Skill</DialogTitle>
<DialogDescription>
Delete {deleteTarget?.name ?? "this skill"} from ~/.claude/skills.
This cannot be undone.
</DialogDescription>
</DialogHeader>
<div className="rounded-lg border border-border/60 bg-muted/30 px-3 py-2 font-mono text-muted-foreground text-xs">
{deleteTarget?.folder}
</div>
<div className="flex justify-end gap-2">
<Button
variant="outline"
size="sm"
disabled={deletingSkillFolder !== null}
onClick={() => setDeleteTarget(null)}
>
Cancel
</Button>
<Button
variant="destructive"
size="sm"
disabled={deletingSkillFolder !== null}
onClick={handleConfirmDeleteSkill}
className="gap-1.5"
>
{deletingSkillFolder ? (
<Loader2Icon className="size-3.5 animate-spin" />
) : (
<Trash2Icon className="size-3.5" />
)}
Delete
</Button>
</div>
</DialogContent>
</Dialog>
<Dialog
open={confirmUninstallAllOpen}
onOpenChange={(open) => {
if (!open && !isUninstalling) setConfirmUninstallAllOpen(false);
}}
>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>Uninstall All Skills</DialogTitle>
<DialogDescription>
This will delete every skill under ~/.claude/skills, including
imported local skills. This cannot be undone.
</DialogDescription>
</DialogHeader>
<div className="rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-destructive text-xs">
{status?.skill_count ?? 0} installed skills will be removed.
</div>
<div className="flex justify-end gap-2">
<Button
variant="outline"
size="sm"
disabled={isUninstalling}
onClick={() => setConfirmUninstallAllOpen(false)}
>
Cancel
</Button>
<Button
variant="destructive"
size="sm"
disabled={isUninstalling}
onClick={async () => {
await handleUninstall();
setConfirmUninstallAllOpen(false);
}}
className="gap-1.5"
>
{isUninstalling ? (
<Loader2Icon className="size-3.5 animate-spin" />
) : (
<Trash2Icon className="size-3.5" />
)}
Uninstall All
</Button>
</div>
</DialogContent>
</Dialog>
</>
{/* Footer */}
<div className="flex shrink-0 items-center justify-between border-t border-border bg-muted/20 px-6 py-2.5">
<p className="font-mono text-muted-foreground/60 text-[11px]">
{isInstalled ? status?.location : "~/.claude/skills/"}
</p>
<Button variant="ghost" size="sm" onClick={onClose} className="text-muted-foreground">
Close
</Button>
</div>
</DialogContent>
</Dialog>
);
}
@ -648,20 +315,12 @@ export function ScientificSkillsOnboarding({
function CategoryDetail({
category,
isInstalled,
installedSkillFolders,
deletingSkillFolder,
onDeleteSkill,
}: {
category: SkillCategoryData;
isInstalled: boolean;
installedSkillFolders: Set<string>;
deletingSkillFolder: string | null;
onDeleteSkill: (skill: SkillEntryData) => void;
}) {
const Icon = ICON_MAP[category.icon] || FlaskConicalIcon;
const [selectedSkill, setSelectedSkill] = useState<SkillEntryData | null>(
null,
);
const [selectedSkill, setSelectedSkill] = useState<SkillEntryData | null>(null);
const [skillContent, setSkillContent] = useState<string | null>(null);
const [loadingContent, setLoadingContent] = useState(false);
const [fetchError, setFetchError] = useState<string | null>(null);
@ -705,12 +364,8 @@ function CategoryDetail({
return (
<div>
<button
onClick={() => {
setSelectedSkill(null);
setSkillContent(null);
setFetchError(null);
}}
className="mb-3 flex items-center gap-1 text-muted-foreground text-xs transition-colors hover:text-foreground"
onClick={() => { setSelectedSkill(null); setSkillContent(null); setFetchError(null); }}
className="mb-3 flex items-center gap-1 text-xs text-muted-foreground transition-colors hover:text-foreground"
>
<ChevronLeftIcon className="size-3.5" />
{category.name}
@ -726,40 +381,22 @@ function CategoryDetail({
{selectedSkill.folder}
</p>
</div>
{installedSkillFolders.has(selectedSkill.folder) && (
<Button
variant="ghost"
size="icon"
className="size-8 shrink-0 text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
disabled={deletingSkillFolder === selectedSkill.folder}
onClick={() => onDeleteSkill(selectedSkill)}
title="Delete skill"
>
{deletingSkillFolder === selectedSkill.folder ? (
<Loader2Icon className="size-4 animate-spin" />
) : (
<Trash2Icon className="size-4" />
)}
</Button>
)}
</div>
<Separator className="my-4" />
{loadingContent ? (
<div className="flex items-center gap-2 py-4 text-muted-foreground text-xs">
<div className="flex items-center gap-2 py-4 text-xs text-muted-foreground">
<Loader2Icon className="size-3.5 animate-spin" />
Loading skill content…
</div>
) : fetchError ? (
<div className="flex items-start gap-2 rounded-lg border border-destructive/30 bg-destructive/5 p-3">
<AlertCircleIcon className="mt-0.5 size-4 shrink-0 text-destructive" />
<p className="text-muted-foreground text-xs leading-relaxed">
{fetchError}
</p>
<p className="text-xs leading-relaxed text-muted-foreground">{fetchError}</p>
</div>
) : skillContent ? (
<div className="whitespace-pre-wrap rounded-lg border border-border/60 bg-muted/30 p-4 font-mono text-foreground/80 text-xs leading-relaxed">
<div className="whitespace-pre-wrap rounded-lg border border-border/60 bg-muted/30 p-4 font-mono text-xs leading-relaxed text-foreground/80">
{skillContent}
</div>
) : null}
@ -798,44 +435,16 @@ function CategoryDetail({
Skills
</h4>
<div className="grid grid-cols-2 gap-1.5">
{category.skills.map((skill) => {
const canDelete = installedSkillFolders.has(skill.folder);
const isDeleting = deletingSkillFolder === skill.folder;
return (
<div
key={skill.folder}
className="group flex min-w-0 items-center rounded-lg border border-border/60 bg-card/30 transition-colors hover:border-border hover:bg-accent/30"
>
<button
type="button"
onClick={() => handleSkillClick(skill)}
className="flex min-w-0 flex-1 items-center gap-2 px-3 py-2 text-left text-sm"
>
<span className="size-1.5 shrink-0 rounded-full bg-foreground/40" />
<span className="min-w-0 truncate">{skill.name}</span>
</button>
{canDelete && (
<button
type="button"
aria-label={`Delete ${skill.name}`}
title="Delete skill"
disabled={isDeleting}
onClick={(event) => {
event.stopPropagation();
onDeleteSkill(skill);
}}
className="mr-1 flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground opacity-70 transition-colors hover:bg-destructive/10 hover:text-destructive disabled:opacity-50 group-hover:opacity-100"
>
{isDeleting ? (
<Loader2Icon className="size-3.5 animate-spin" />
) : (
<Trash2Icon className="size-3.5" />
)}
</button>
)}
</div>
);
})}
{category.skills.map((skill) => (
<button
key={skill.folder}
onClick={() => handleSkillClick(skill)}
className="flex items-center gap-2 rounded-lg border border-border/60 bg-card/30 px-3 py-2 text-left text-sm transition-colors hover:border-border hover:bg-accent/30"
>
<span className="size-1.5 shrink-0 rounded-full bg-foreground/40" />
{skill.name}
</button>
))}
</div>
</div>
</div>

View file

@ -17,6 +17,8 @@ import {
type TemplateCategory,
type TemplateSubcategory,
CATEGORY_LABELS,
SUBCATEGORY_LABELS,
CATEGORY_SUBCATEGORIES,
getCategories,
getAllTemplates,
getTemplatesByCategory,
@ -30,7 +32,7 @@ const CATEGORY_ICONS: Record<TemplateCategory, React.ReactNode> = {
starter: <SparklesIcon className="size-4" />,
};
const _SUBCATEGORY_ICONS: Record<TemplateSubcategory, React.ReactNode> = {
const SUBCATEGORY_ICONS: Record<TemplateSubcategory, React.ReactNode> = {
papers: <FileTextIcon className="size-3.5" />,
theses: <GraduationCapIcon className="size-3.5" />,
presentations: <MonitorIcon className="size-3.5" />,
@ -61,9 +63,7 @@ export function CategorySidebar() {
>
<SparklesIcon className="size-4" />
<span className="flex-1">All Templates</span>
<span className="text-muted-foreground text-xs tabular-nums">
{allCount}
</span>
<span className="text-xs tabular-nums text-muted-foreground">{allCount}</span>
</button>
<div className="my-1.5 h-px bg-border" />
@ -84,9 +84,7 @@ export function CategorySidebar() {
>
{CATEGORY_ICONS[cat]}
<span className="flex-1">{CATEGORY_LABELS[cat]}</span>
<span className="text-muted-foreground text-xs tabular-nums">
{count}
</span>
<span className="text-xs tabular-nums text-muted-foreground">{count}</span>
</button>
);
})}

View file

@ -15,20 +15,14 @@ import {
export function ThumbnailPaper({ color }: { color: string }) {
return (
<div className="flex h-full w-full flex-col items-center px-4 py-3">
<div
className="mb-1.5 h-1.5 w-12 rounded-full"
style={{ backgroundColor: color }}
/>
<div className="mb-1.5 h-1.5 w-12 rounded-full" style={{ backgroundColor: color }} />
<div className="mb-3 h-1 w-8 rounded-full bg-muted-foreground/20" />
<div className="mb-2 w-full rounded-sm bg-muted-foreground/8 p-1.5">
<div className="mb-1 h-0.5 w-full rounded-full bg-muted-foreground/15" />
<div className="mb-1 h-0.5 w-full rounded-full bg-muted-foreground/15" />
<div className="h-0.5 w-3/4 rounded-full bg-muted-foreground/15" />
</div>
<div
className="mb-1.5 h-1 w-10 self-start rounded-full"
style={{ backgroundColor: color, opacity: 0.6 }}
/>
<div className="mb-1.5 h-1 w-10 self-start rounded-full" style={{ backgroundColor: color, opacity: 0.6 }} />
<div className="mb-1 h-0.5 w-full rounded-full bg-muted-foreground/12" />
<div className="mb-1 h-0.5 w-full rounded-full bg-muted-foreground/12" />
<div className="mb-1 h-0.5 w-11/12 rounded-full bg-muted-foreground/12" />
@ -41,17 +35,11 @@ export function ThumbnailSlides({ color }: { color: string }) {
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-1.5 px-3 py-2">
<div className="flex w-full flex-1 flex-col items-center justify-center rounded-sm border border-muted-foreground/10 bg-muted-foreground/5 p-1">
<div
className="mb-0.5 h-1 w-10 rounded-full"
style={{ backgroundColor: color }}
/>
<div className="mb-0.5 h-1 w-10 rounded-full" style={{ backgroundColor: color }} />
<div className="h-0.5 w-6 rounded-full bg-muted-foreground/20" />
</div>
<div className="flex w-full flex-1 flex-col rounded-sm border border-muted-foreground/10 bg-muted-foreground/5 p-1">
<div
className="mb-0.5 h-0.5 w-6 rounded-full"
style={{ backgroundColor: color, opacity: 0.6 }}
/>
<div className="mb-0.5 h-0.5 w-6 rounded-full" style={{ backgroundColor: color, opacity: 0.6 }} />
<div className="mb-0.5 h-0.5 w-full rounded-full bg-muted-foreground/12" />
<div className="h-0.5 w-3/4 rounded-full bg-muted-foreground/12" />
</div>
@ -59,7 +47,7 @@ export function ThumbnailSlides({ color }: { color: string }) {
);
}
export function ThumbnailPoster({ color: _color }: { color: string }) {
export function ThumbnailPoster({ color }: { color: string }) {
return (
<div className="flex h-full w-full flex-col px-2 py-2">
<div className="mb-2 h-1 w-10 self-center rounded-full bg-muted-foreground/20" />
@ -82,7 +70,7 @@ export function ThumbnailPoster({ color: _color }: { color: string }) {
export function ThumbnailBlank(_props: { color: string }) {
return (
<div className="flex h-full w-full items-center justify-center">
<div className="font-medium text-muted-foreground/20 text-xs">Empty</div>
<div className="text-muted-foreground/20 text-xs font-medium">Empty</div>
</div>
);
}
@ -98,13 +86,11 @@ export const THUMBNAIL_MAP: Record<string, React.FC<{ color: string }>> = {
"letter-formal": ThumbnailPaper,
"report-technical": ThumbnailPaper,
"book-standard": ThumbnailPaper,
newsletter: ThumbnailPaper,
blank: ThumbnailBlank,
"newsletter": ThumbnailPaper,
"blank": ThumbnailBlank,
};
export function getFallbackThumbnail(
template: TemplateDefinition,
): React.FC<{ color: string }> {
export function getFallbackThumbnail(template: TemplateDefinition): React.FC<{ color: string }> {
return THUMBNAIL_MAP[template.id] || ThumbnailPaper;
}
@ -118,12 +104,14 @@ export function TemplateCard({ template }: TemplateCardProps) {
const openPreview = useTemplateStore((s) => s.openPreview);
const FallbackThumbnail = getFallbackThumbnail(template);
const thumbnailUrl = useSyncExternalStore(subscribeThumbnails, () =>
getThumbnail(template.id),
const thumbnailUrl = useSyncExternalStore(
subscribeThumbnails,
() => getThumbnail(template.id),
);
const failed = useSyncExternalStore(subscribeThumbnails, () =>
isThumbnailFailed(template.id),
const failed = useSyncExternalStore(
subscribeThumbnails,
() => isThumbnailFailed(template.id),
);
useEffect(() => {
@ -161,7 +149,7 @@ export function TemplateCard({ template }: TemplateCardProps) {
</div>
<div className="mt-2 px-0.5">
<div className="font-medium text-sm leading-tight">{template.name}</div>
<div className="mt-0.5 line-clamp-2 text-muted-foreground text-xs leading-snug">
<div className="mt-0.5 text-muted-foreground text-xs leading-snug line-clamp-2">
{template.description}
</div>
</div>

View file

@ -53,7 +53,7 @@ export function TemplateGallery() {
return (
<div className="flex h-full flex-col">
{/* Search bar */}
<div className="shrink-0 border-border border-b px-4 py-3">
<div className="shrink-0 border-b border-border px-4 py-3">
<div className="relative mx-auto max-w-xl">
<SearchIcon className="absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
@ -61,7 +61,7 @@ export function TemplateGallery() {
placeholder="Search templates... ⌘K"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pr-8 pl-9"
className="pl-9 pr-8"
/>
{searchQuery && (
<button
@ -77,7 +77,7 @@ export function TemplateGallery() {
{/* Main content: sidebar + grid */}
<div className="flex flex-1 overflow-hidden">
{/* Category sidebar */}
<div className="shrink-0 border-border border-r pt-2 pl-3">
<div className="shrink-0 border-r border-border pl-3 pt-2">
<CategorySidebar />
</div>
@ -86,10 +86,8 @@ export function TemplateGallery() {
{filteredTemplates.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-center">
<SearchIcon className="mb-3 size-8 text-muted-foreground/40" />
<p className="font-medium text-muted-foreground text-sm">
No templates found
</p>
<p className="mt-1 text-muted-foreground/70 text-xs">
<p className="font-medium text-sm text-muted-foreground">No templates found</p>
<p className="mt-1 text-xs text-muted-foreground/70">
Try a different search term or category
</p>
</div>
@ -97,9 +95,7 @@ export function TemplateGallery() {
<GroupedGrid />
) : (
<>
<h2 className="mb-4 font-medium text-muted-foreground text-sm">
{heading}
</h2>
<h2 className="mb-4 font-medium text-sm text-muted-foreground">{heading}</h2>
<div className="grid grid-cols-2 gap-5 sm:grid-cols-3 lg:grid-cols-4">
{filteredTemplates.map((t) => (
<TemplateCard key={t.id} template={t} />
@ -122,12 +118,7 @@ function GroupedGrid() {
const filteredTemplates = useTemplateStore((s) => s.filteredTemplates);
// Group by category preserving order
const categories: TemplateCategory[] = [
"academic",
"professional",
"creative",
"starter",
];
const categories: TemplateCategory[] = ["academic", "professional", "creative", "starter"];
const groups = categories
.map((cat) => ({
category: cat,

View file

@ -2,8 +2,7 @@ import { useState, useCallback, useEffect, useRef } from "react";
import { open } from "@tauri-apps/plugin-dialog";
import { mkdir, writeTextFile } from "@tauri-apps/plugin-fs";
import { getCurrentWebview } from "@tauri-apps/api/webview";
import { homeDir } from "@tauri-apps/api/path";
import { toast } from "sonner";
import { documentDir } from "@tauri-apps/api/path";
import {
ChevronLeftIcon,
ChevronRightIcon,
@ -33,26 +32,23 @@ import { useTemplateStore } from "@/stores/template-store";
import { useProjectStore } from "@/stores/project-store";
import { useDocumentStore } from "@/stores/document-store";
import { useClaudeChatStore } from "@/stores/claude-chat-store";
import {
getTemplateById,
getTemplateSkeleton,
BIB_TEMPLATE,
} from "@/lib/template-registry";
import { getTemplateById, getTemplateSkeleton, BIB_TEMPLATE } from "@/lib/template-registry";
import { getTemplatePdfUrl } from "@/lib/template-preview-cache";
import { getMupdfClient } from "@/lib/mupdf/mupdf-client";
import { exists, join } from "@/lib/tauri/fs";
import type { PageSize } from "@/lib/mupdf/types";
import { createLogger } from "@/lib/debug/logger";
import {
buildReferenceFilesSection,
importReferenceFiles,
} from "@/lib/project-attachments";
import { getProjectNameError, normalizeProjectName } from "@/lib/project-name";
const log = createLogger("template-preview");
// ─── Helpers ───
function randomProjectName(): string {
const adjectives = ["swift", "bright", "calm", "bold", "keen", "warm", "pure", "vast", "deep", "fair"];
const nouns = ["paper", "draft", "thesis", "note", "study", "essay", "report", "brief", "folio", "opus"];
const adj = adjectives[Math.floor(Math.random() * adjectives.length)];
const noun = nouns[Math.floor(Math.random() * nouns.length)];
const id = Math.random().toString(36).slice(2, 6);
return `${adj}-${noun}-${id}`;
}
// ─── Component ───
type ModalStep = "preview" | "details";
@ -60,9 +56,7 @@ type ModalStep = "preview" | "details";
export function TemplatePreview() {
const previewTemplateId = useTemplateStore((s) => s.previewTemplateId);
const closePreview = useTemplateStore((s) => s.closePreview);
const template = previewTemplateId
? getTemplateById(previewTemplateId)
: null;
const template = previewTemplateId ? getTemplateById(previewTemplateId) : null;
const [modalStep, setModalStep] = useState<ModalStep>("preview");
@ -82,13 +76,11 @@ export function TemplatePreview() {
const [purpose, setPurpose] = useState("");
const [attachments, setAttachments] = useState<string[]>([]);
const [projectFolder, setProjectFolder] = useState<string | null>(null);
const [projectName, setProjectName] = useState("");
const [projectNameError, setProjectNameError] = useState("");
const [projectName, setProjectName] = useState(randomProjectName);
const [isCreating, setIsCreating] = useState(false);
const [isDragOver, setIsDragOver] = useState(false);
const [refFilesOpen, setRefFilesOpen] = useState(false);
const [locationOpen, setLocationOpen] = useState(false);
const projectNameRef = useRef<HTMLInputElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
// ── Store access ──
@ -108,9 +100,7 @@ export function TemplatePreview() {
setIsLandscape(false);
setError(false);
if (docIdRef.current > 0) {
getMupdfClient()
.closeDocument(docIdRef.current)
.catch(() => {});
getMupdfClient().closeDocument(docIdRef.current).catch(() => {});
docIdRef.current = 0;
}
}
@ -124,8 +114,7 @@ export function TemplatePreview() {
setModalStep("preview");
setPurpose("");
setAttachments([]);
setProjectName("");
setProjectNameError("");
setProjectName(randomProjectName());
setRefFilesOpen(false);
setLocationOpen(false);
}
@ -137,22 +126,14 @@ export function TemplatePreview() {
if (lastProjectFolder) {
setProjectFolder(lastProjectFolder);
} else {
homeDir()
.then((home) => join(home, "Documents", "ClaudePrism"))
.then(async (dir) => {
await mkdir(dir, { recursive: true }).catch(() => {});
setProjectFolder(dir);
})
.catch((err) =>
console.warn("Failed to resolve default project folder:", err),
);
documentDir().then((dir) => setProjectFolder(dir)).catch(() => {});
}
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// Auto-focus textarea in details step
useEffect(() => {
if (modalStep === "details") {
const timer = setTimeout(() => projectNameRef.current?.focus(), 150);
const timer = setTimeout(() => textareaRef.current?.focus(), 150);
return () => clearTimeout(timer);
}
}, [modalStep]);
@ -206,7 +187,7 @@ export function TemplatePreview() {
setLoading(false);
} catch (err) {
if (gen !== loadGenRef.current) return;
log.warn("load error", { error: String(err) });
console.warn("[template-preview] load error:", err);
setLoading(false);
setError(true);
}
@ -215,13 +196,7 @@ export function TemplatePreview() {
// ── Render current page ──
useEffect(() => {
if (
docIdRef.current <= 0 ||
numPages === 0 ||
!canvasRef.current ||
!containerRef.current
)
return;
if (docIdRef.current <= 0 || numPages === 0 || !canvasRef.current || !containerRef.current) return;
const pageIndex = currentPage - 1;
const size = pageSizesRef.current[pageIndex];
@ -245,21 +220,18 @@ export function TemplatePreview() {
const dpi = (displayW / size.width) * 72 * dpr;
const client = getMupdfClient();
client
.drawPage(docIdRef.current, pageIndex, dpi)
.then((imageData) => {
const canvas = canvasRef.current;
if (!canvas) return;
canvas.width = imageData.width;
canvas.height = imageData.height;
canvas.style.width = `${displayW}px`;
canvas.style.height = `${displayH}px`;
const ctx = canvas.getContext("2d")!;
ctx.putImageData(imageData, 0, 0);
})
.catch((err) => {
log.warn("render error", { error: String(err) });
});
client.drawPage(docIdRef.current, pageIndex, dpi).then((imageData) => {
const canvas = canvasRef.current;
if (!canvas) return;
canvas.width = imageData.width;
canvas.height = imageData.height;
canvas.style.width = `${displayW}px`;
canvas.style.height = `${displayH}px`;
const ctx = canvas.getContext("2d")!;
ctx.putImageData(imageData, 0, 0);
}).catch((err) => {
console.warn("[template-preview] render error:", err);
});
}, [currentPage, numPages, isLandscape]);
// ── Page navigation ──
@ -276,13 +248,8 @@ export function TemplatePreview() {
if (!previewTemplateId || modalStep !== "preview") return;
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "ArrowLeft") {
e.preventDefault();
goToPrevPage();
} else if (e.key === "ArrowRight") {
e.preventDefault();
goToNextPage();
}
if (e.key === "ArrowLeft") { e.preventDefault(); goToPrevPage(); }
else if (e.key === "ArrowRight") { e.preventDefault(); goToNextPage(); }
}
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
@ -305,25 +272,16 @@ export function TemplatePreview() {
setIsDragOver(false);
const paths = (event.payload as { paths: string[] }).paths;
if (paths?.length > 0) {
setAttachments((prev) => [
...prev,
...paths.filter((p) => !prev.includes(p)),
]);
setAttachments((prev) => [...prev, ...paths.filter((p) => !prev.includes(p))]);
}
} else if (type === "leave") {
setIsDragOver(false);
}
})
.then((fn) => {
if (cancelled) fn();
else unlisten = fn;
})
.then((fn) => { if (cancelled) fn(); else unlisten = fn; })
.catch(() => {});
return () => {
cancelled = true;
unlisten?.();
};
return () => { cancelled = true; unlisten?.(); };
}, [modalStep]);
// ── File handlers ──
@ -331,13 +289,14 @@ export function TemplatePreview() {
const selected = await open({
multiple: true,
title: "Add Reference Files",
filters: [{
name: "Documents & Images",
extensions: ["pdf", "tex", "bib", "txt", "md", "png", "jpg", "jpeg", "gif", "svg", "csv", "tsv", "json"],
}],
});
if (selected) {
const paths = Array.isArray(selected) ? selected : [selected];
setAttachments((prev) => [
...prev,
...paths.filter((p) => !prev.includes(p)),
]);
setAttachments((prev) => [...prev, ...paths.filter((p) => !prev.includes(p))]);
}
}, []);
@ -346,11 +305,7 @@ export function TemplatePreview() {
};
const handleChooseFolder = useCallback(async () => {
const selected = await open({
directory: true,
multiple: false,
title: "Choose Location for New Project",
});
const selected = await open({ directory: true, multiple: false, title: "Choose Location for New Project" });
if (selected) {
setProjectFolder(selected);
setLastProjectFolder(selected);
@ -359,21 +314,12 @@ export function TemplatePreview() {
// ── Create project ──
const handleCreate = async () => {
const name = normalizeProjectName(projectName);
const nameError = getProjectNameError(projectName);
if (!template || !projectFolder || nameError) {
setProjectNameError(nameError ?? "");
return;
}
if (!template || !projectFolder || !projectName.trim()) return;
setIsCreating(true);
try {
const projectPath = await join(projectFolder, name);
if (await exists(projectPath)) {
setProjectNameError("A folder with this name already exists here");
return;
}
await mkdir(projectPath, { recursive: true });
const projectPath = await join(projectFolder, projectName.trim());
await mkdir(projectPath, { recursive: true }).catch(() => {});
const mainTexPath = await join(projectPath, template.mainFileName);
const mainExists = await exists(mainTexPath);
@ -389,13 +335,16 @@ export function TemplatePreview() {
}
}
const referenceFiles =
attachments.length > 0
? await importReferenceFiles(projectPath, attachments)
: [];
if (attachments.length > 0) {
const attachmentsDir = await join(projectPath, "attachments");
await mkdir(attachmentsDir, { recursive: true }).catch(() => {});
}
if (purpose.trim()) {
const attachmentSection = buildReferenceFilesSection(referenceFiles);
const attachmentNames = attachments.map((p) => p.split("/").pop()).filter(Boolean);
const attachmentSection = attachmentNames.length > 0
? `\n### Reference Files\n${attachmentNames.map((n) => `- \`${n}\``).join("\n")}\n\nPlease review them and incorporate relevant information.\n`
: "";
const prompt = [
`## New ${template.name} Project`,
@ -422,49 +371,46 @@ export function TemplatePreview() {
addRecentProject(projectPath);
await openProject(projectPath);
if (attachments.length > 0) {
await useDocumentStore.getState().importFiles(attachments, "attachments");
}
// Close modal on success
closePreview();
} catch (err) {
console.error("Failed to create project:", err);
toast.error("Failed to create project", {
description: err instanceof Error ? err.message : String(err),
});
} finally {
setIsCreating(false);
}
};
const canCreate = Boolean(
template && projectFolder && !getProjectNameError(projectName),
);
const canCreate = template && projectFolder && projectName.trim();
if (!template) return null;
// ── Modal width depends on step ──
const modalWidth =
modalStep === "preview"
? isLandscape
? "w-[min(72rem,calc(100vw-4rem))]"
: "w-[min(48rem,calc(100vw-6rem))]"
: "w-[min(32rem,calc(100vw-4rem))]";
const modalWidth = modalStep === "preview"
? isLandscape
? "w-[min(72rem,calc(100vw-4rem))]"
: "w-[min(48rem,calc(100vw-6rem))]"
: "w-[min(32rem,calc(100vw-4rem))]";
return (
<Dialog open={!!previewTemplateId} onOpenChange={handleOpenChange}>
<DialogContent
showCloseButton={false}
className={`flex max-w-none flex-col gap-0 overflow-hidden p-0 transition-[width] duration-300 sm:max-w-none ${modalWidth} ${modalStep === "preview" ? "h-[70vh]" : "max-h-[80vh]"}`}
className={`flex max-w-none sm:max-w-none flex-col gap-0 overflow-hidden p-0 transition-[width] duration-300 ${modalWidth} ${modalStep === "preview" ? "h-[70vh]" : "max-h-[80vh]"}`}
>
{modalStep === "preview" ? (
/* ═══════════════════ PREVIEW STEP ═══════════════════ */
<>
<DialogHeader className="shrink-0 border-border border-b px-6 py-3">
<DialogHeader className="shrink-0 border-b border-border px-6 py-3">
<div className="flex items-center gap-4">
<div className="min-w-0 flex-1">
<DialogTitle className="text-sm">{template.name}</DialogTitle>
<DialogDescription className="mt-0.5 truncate text-xs">
{template.description} — {template.documentClass}
{template.packages.length > 0 &&
` — ${template.packages.length} packages`}
{template.packages.length > 0 && ` — ${template.packages.length} packages`}
</DialogDescription>
</div>
<div className="flex shrink-0 items-center gap-2">
@ -482,10 +428,7 @@ export function TemplatePreview() {
<div className="flex flex-1 overflow-hidden">
<div className="relative flex flex-1 flex-col">
<div
ref={containerRef}
className="flex flex-1 items-center justify-center overflow-hidden bg-muted/30 p-6"
>
<div ref={containerRef} className="flex flex-1 items-center justify-center overflow-hidden bg-muted/30 p-6">
{loading && (
<div className="flex flex-col items-center gap-2 text-muted-foreground">
<LoaderIcon className="size-5 animate-spin" />
@ -506,26 +449,14 @@ export function TemplatePreview() {
</div>
{numPages > 0 && (
<div className="flex shrink-0 items-center justify-center gap-3 border-border border-t bg-background py-2.5">
<Button
variant="ghost"
size="icon"
className="size-7"
onClick={goToPrevPage}
disabled={currentPage <= 1}
>
<div className="flex shrink-0 items-center justify-center gap-3 border-t border-border bg-background py-2.5">
<Button variant="ghost" size="icon" className="size-7" onClick={goToPrevPage} disabled={currentPage <= 1}>
<ChevronLeftIcon className="size-4" />
</Button>
<span className="min-w-16 text-center text-muted-foreground text-xs tabular-nums">
<span className="min-w-16 text-center text-xs tabular-nums text-muted-foreground">
{numPages > 1 ? `${currentPage} / ${numPages}` : "1 page"}
</span>
<Button
variant="ghost"
size="icon"
className="size-7"
onClick={goToNextPage}
disabled={currentPage >= numPages}
>
<Button variant="ghost" size="icon" className="size-7" onClick={goToNextPage} disabled={currentPage >= numPages}>
<ChevronRightIcon className="size-4" />
</Button>
</div>
@ -537,12 +468,12 @@ export function TemplatePreview() {
/* ═══════════════════ DETAILS STEP ═══════════════════ */
<>
{/* Header */}
<DialogHeader className="shrink-0 border-border/60 border-b px-5 py-3">
<DialogHeader className="shrink-0 border-b border-border/60 px-5 py-3">
<div className="flex items-center gap-3">
<Button
variant="ghost"
size="icon"
className="size-7 shrink-0 rounded-lg"
className="size-7 rounded-lg shrink-0"
onClick={() => setModalStep("preview")}
>
<ArrowLeftIcon className="size-4" />
@ -559,43 +490,12 @@ export function TemplatePreview() {
{/* Form content */}
<div className="flex-1 overflow-y-auto">
<div className="space-y-4 p-5">
<div className="space-y-2">
<div>
<span className="font-semibold text-sm">Project name</span>
<p className="mt-0.5 text-muted-foreground text-xs leading-relaxed">
This becomes the folder name on disk.
</p>
</div>
<Input
ref={projectNameRef}
placeholder="e.g., conference-paper"
value={projectName}
onChange={(e) => {
setProjectName(e.target.value);
setProjectNameError("");
}}
onBlur={() =>
setProjectNameError(
getProjectNameError(projectName) ?? "",
)
}
className="rounded-xl border-border/60 bg-card/30 text-sm focus-visible:bg-card/50"
/>
{projectNameError && (
<p className="text-destructive text-xs">
{projectNameError}
</p>
)}
</div>
{/* Purpose — hero element */}
<div className="space-y-2">
<div>
<span className="font-semibold text-sm">
What are you writing?
</span>
<label className="font-semibold text-sm">What are you writing?</label>
<p className="mt-0.5 text-muted-foreground text-xs leading-relaxed">
Describe your document and Claude will generate tailored
content.
Describe your document and Claude will generate tailored content.
</p>
</div>
<Textarea
@ -609,7 +509,7 @@ export function TemplatePreview() {
</div>
{/* Collapsible sections */}
<div className="divide-y divide-border/40 overflow-hidden rounded-xl border border-border/60 bg-card/30">
<div className="rounded-xl border border-border/60 bg-card/30 divide-y divide-border/40 overflow-hidden">
{/* Reference files */}
<div>
<button
@ -619,12 +519,10 @@ export function TemplatePreview() {
<div className="flex size-6 shrink-0 items-center justify-center rounded-md bg-muted/50">
<FileTextIcon className="size-3 text-muted-foreground" />
</div>
<div className="min-w-0 flex-1">
<span className="font-medium text-sm">
Reference files
</span>
<div className="flex-1 min-w-0">
<span className="text-sm font-medium">Reference files</span>
{attachments.length > 0 && (
<span className="ml-2 inline-flex items-center justify-center rounded-full bg-primary/15 px-1.5 py-0.5 font-semibold text-[10px] text-primary leading-none">
<span className="ml-2 inline-flex items-center justify-center rounded-full bg-primary/15 px-1.5 py-0.5 text-[10px] font-semibold leading-none text-primary">
{attachments.length}
</span>
)}
@ -634,18 +532,16 @@ export function TemplatePreview() {
/>
</button>
{refFilesOpen && (
<div className="space-y-2.5 px-4 pb-3">
<div className="px-4 pb-3 space-y-2.5">
{attachments.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{attachments.map((path) => (
<div
key={path}
className="flex items-center gap-1.5 rounded-lg border border-border/50 bg-muted/40 py-1 pr-1.5 pl-2.5 text-xs transition-colors hover:bg-muted/60"
className="flex items-center gap-1.5 rounded-lg border border-border/50 bg-muted/40 pl-2.5 pr-1.5 py-1 text-xs transition-colors hover:bg-muted/60"
>
<PaperclipIcon className="size-3 shrink-0 text-muted-foreground/70" />
<span className="max-w-30 truncate text-foreground/80">
{path.split(/[/\\]/).pop()}
</span>
<span className="max-w-30 truncate text-foreground/80">{path.split("/").pop()}</span>
<button
onClick={() => handleRemoveAttachment(path)}
className="flex size-4 shrink-0 items-center justify-center rounded-md text-muted-foreground/50 transition-colors hover:bg-destructive/10 hover:text-destructive"
@ -666,20 +562,16 @@ export function TemplatePreview() {
{isDragOver ? (
<>
<UploadIcon className="size-4 text-primary" />
<span className="font-medium text-primary text-xs">
Drop to add
</span>
<span className="text-xs font-medium text-primary">Drop to add</span>
</>
) : (
<>
<UploadIcon className="size-4 text-muted-foreground/40" />
<div className="text-center">
<span className="text-muted-foreground/70 text-xs">
Drag & drop or{" "}
</span>
<span className="text-xs text-muted-foreground/70">Drag & drop or </span>
<button
onClick={handleAddAttachments}
className="font-medium text-foreground/70 text-xs underline decoration-border underline-offset-2 transition-colors hover:text-foreground"
className="text-xs font-medium text-foreground/70 underline underline-offset-2 decoration-border hover:text-foreground transition-colors"
>
browse files
</button>
@ -700,15 +592,12 @@ export function TemplatePreview() {
<div className="flex size-6 shrink-0 items-center justify-center rounded-md bg-muted/50">
<MapPinIcon className="size-3 text-muted-foreground" />
</div>
<div className="min-w-0 flex-1">
<span className="font-medium text-sm">
Project location
</span>
<div className="flex-1 min-w-0">
<span className="text-sm font-medium">Project location</span>
</div>
{!locationOpen && projectFolder && projectName.trim() && (
<span className="min-w-0 max-w-35 truncate rounded-md bg-muted/40 px-2 py-0.5 font-mono text-[11px] text-muted-foreground/60">
.../{projectFolder.split(/[/\\]/).pop()}/
{normalizeProjectName(projectName)}
<span className="min-w-0 max-w-35 truncate rounded-md bg-muted/40 px-2 py-0.5 text-[11px] font-mono text-muted-foreground/60">
.../{projectFolder.split("/").pop()}/{projectName.trim()}
</span>
)}
<ChevronDownIcon
@ -716,13 +605,14 @@ export function TemplatePreview() {
/>
</button>
{locationOpen && (
<div className="space-y-2 px-4 pb-3">
<div className="flex items-center gap-2">
<p className="min-w-0 flex-1 truncate rounded-md bg-muted/30 px-2.5 py-1.5 font-mono text-[11px] text-muted-foreground/60">
{projectFolder
? `${projectFolder}/${normalizeProjectName(projectName) || "..."}`
: "Choose a location"}
</p>
<div className="px-4 pb-3 space-y-2">
<div className="flex gap-2">
<Input
placeholder="Project name"
value={projectName}
onChange={(e) => setProjectName(e.target.value)}
className="flex-1 rounded-lg border-border/60 bg-background/50"
/>
<Button
variant="outline"
size="sm"
@ -733,6 +623,11 @@ export function TemplatePreview() {
{projectFolder ? "Change" : "Choose"}
</Button>
</div>
{projectFolder && (
<p className="truncate rounded-md bg-muted/30 px-2.5 py-1.5 font-mono text-[11px] text-muted-foreground/60">
{projectFolder}/{projectName.trim() || "..."}
</p>
)}
</div>
)}
</div>
@ -741,7 +636,7 @@ export function TemplatePreview() {
</div>
{/* Create button — sticky footer */}
<div className="shrink-0 border-border/60 border-t px-5 py-4">
<div className="shrink-0 border-t border-border/60 px-5 py-4">
<Button
className="w-full gap-2 rounded-xl font-semibold shadow-sm transition-all hover:shadow-md active:scale-[0.99]"
size="lg"

View file

@ -4,12 +4,14 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex items-center rounded-md border px-2 py-0.5 font-medium text-xs transition-colors focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2",
"inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-medium transition-colors focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default: "border-transparent bg-primary text-primary-foreground",
secondary: "border-transparent bg-secondary text-secondary-foreground",
default:
"border-transparent bg-primary text-primary-foreground",
secondary:
"border-transparent bg-secondary text-secondary-foreground",
destructive:
"border-transparent bg-destructive text-destructive-foreground",
outline: "text-foreground",

View file

@ -14,7 +14,6 @@ function ContextMenuTrigger({
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
return (
<ContextMenuPrimitive.Trigger
data-app-context-menu
data-slot="context-menu-trigger"
{...props}
/>
@ -28,7 +27,6 @@ function ContextMenuContent({
return (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
data-app-context-menu
data-slot="context-menu-content"
className={cn(
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-context-menu-content-available-height) min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=closed]:animate-out data-[state=open]:animate-in",

View file

@ -1,3 +1,5 @@
import * as React from "react";
import { Dialog as DialogPrimitive } from "radix-ui";
import { XIcon } from "lucide-react";
@ -37,7 +39,7 @@ function DialogOverlay({
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/35 backdrop-blur-sm data-[state=closed]:animate-out data-[state=open]:animate-in dark:bg-black/45",
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=open]:animate-in",
className,
)}
{...props}

View file

@ -1,3 +1,5 @@
import * as React from "react";
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui";
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";

View file

@ -1,3 +1,5 @@
import * as React from "react";
import { Label as LabelPrimitive } from "radix-ui";

View file

@ -1,3 +1,5 @@
import * as React from "react";
import { Select as SelectPrimitive } from "radix-ui";
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";

View file

@ -1,3 +1,5 @@
import * as React from "react";
import { Separator as SeparatorPrimitive } from "radix-ui";

View file

@ -1,3 +1,5 @@
import * as React from "react";
import { Dialog as SheetPrimitive } from "radix-ui";
import { XIcon } from "lucide-react";

View file

@ -1,3 +1,5 @@
import {
CircleCheckIcon,
InfoIcon,

View file

@ -40,7 +40,7 @@ function TabsTrigger({
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 font-medium text-sm ring-offset-background transition-all focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
className,
)}
{...props}

View file

@ -1,3 +1,5 @@
import * as React from "react";
import { Toggle as TogglePrimitive } from "radix-ui";
import { cva, type VariantProps } from "class-variance-authority";

View file

@ -1,3 +1,5 @@
import * as React from "react";
import { Tooltip as TooltipPrimitive } from "radix-ui";

View file

@ -71,22 +71,22 @@ export function UvSetupDialog({ open, onClose }: UvSetupDialogProps) {
return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="w-full max-w-[34rem] overflow-hidden sm:max-w-[34rem]">
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex min-w-0 items-center gap-2 pr-8">
<TerminalIcon className="size-5 shrink-0" />
<span className="min-w-0 truncate">Python Environment (uv)</span>
<DialogTitle className="flex items-center gap-2">
<TerminalIcon className="size-5" />
Python Environment (uv)
</DialogTitle>
<DialogDescription>
Manage the Python virtual environment for this project.
</DialogDescription>
</DialogHeader>
<div className="min-w-0 space-y-4 py-2">
<div className="space-y-4 py-2">
{/* uv status */}
<div className="flex min-w-0 items-center gap-3 overflow-hidden rounded-lg border p-3">
<div className="flex items-center gap-3 rounded-lg border p-3">
<StatusIcon status={status} isInstalling={isInstalling} />
<div className="min-w-0 flex-1 overflow-hidden">
<div className="flex-1 min-w-0">
<div className="font-medium text-sm">
{status === "checking"
? "Checking uv..."
@ -97,14 +97,12 @@ export function UvSetupDialog({ open, onClose }: UvSetupDialogProps) {
: "Error"}
</div>
{version && (
<div className="truncate text-muted-foreground text-xs">
<div className="text-muted-foreground text-xs truncate">
{version}
</div>
)}
{error && (
<div className="mt-1 break-words text-destructive text-xs">
{error}
</div>
<div className="text-destructive text-xs mt-1">{error}</div>
)}
</div>
{status === "not-installed" && !isInstalling && (
@ -123,37 +121,29 @@ export function UvSetupDialog({ open, onClose }: UvSetupDialogProps) {
{/* venv status — only show when uv is ready */}
{status === "ready" && (
<div className="flex min-w-0 items-center gap-3 overflow-hidden rounded-lg border p-3">
<div className="flex items-center gap-3 rounded-lg border p-3">
<div
className={cn(
"flex size-8 shrink-0 items-center justify-center rounded-full",
"flex size-8 items-center justify-center rounded-full",
venvReady
? "bg-accent text-accent-foreground"
: "bg-muted text-muted-foreground",
: "bg-muted text-muted-foreground"
)}
>
<FolderIcon className="size-4" />
</div>
<div className="min-w-0 flex-1 overflow-hidden">
<div className="flex-1 min-w-0">
<div className="font-medium text-sm">
{venvReady
? "Virtual Environment Active"
: "No Virtual Environment"}
{venvReady ? "Virtual Environment Active" : "No Virtual Environment"}
</div>
{venvPath && (
<div
className="max-w-full break-all text-muted-foreground text-xs leading-snug"
title={venvPath}
>
<div className="text-muted-foreground text-xs truncate" title={venvPath}>
{venvPath}
</div>
)}
{pythonPath && (
<div
className="truncate text-muted-foreground text-xs"
title={pythonPath}
>
Python: {pythonPath.split(/[/\\]/).pop()}
<div className="text-muted-foreground text-xs truncate" title={pythonPath}>
Python: {pythonPath.split("/").pop() || pythonPath.split("\\").pop()}
</div>
)}
</div>
@ -167,12 +157,10 @@ export function UvSetupDialog({ open, onClose }: UvSetupDialogProps) {
{/* Info text */}
{status === "ready" && venvReady && (
<p className="max-w-full break-words text-muted-foreground text-xs leading-relaxed">
Claude Code and ClaudePrism terminal tools use this environment
when running Python code. OpenAI-compatible providers use it
through PowerShell/Bash tool calls. Use{" "}
<code className="text-foreground">uv pip install</code> to add
packages.
<p className="text-muted-foreground text-xs leading-relaxed">
Claude Code will automatically use this environment when running
Python code. Use <code className="text-foreground">uv pip install</code> to
add packages.
</p>
)}
</div>
@ -190,28 +178,28 @@ function StatusIcon({
}) {
if (isInstalling || status === "checking") {
return (
<div className="flex size-8 shrink-0 items-center justify-center rounded-full bg-muted">
<div className="flex size-8 items-center justify-center rounded-full bg-muted">
<Loader2Icon className="size-4 animate-spin text-muted-foreground" />
</div>
);
}
if (status === "ready") {
return (
<div className="flex size-8 shrink-0 items-center justify-center rounded-full bg-accent text-accent-foreground">
<div className="flex size-8 items-center justify-center rounded-full bg-accent text-accent-foreground">
<CheckCircle2Icon className="size-4" />
</div>
);
}
if (status === "error") {
return (
<div className="flex size-8 shrink-0 items-center justify-center rounded-full bg-destructive/10 text-destructive">
<div className="flex size-8 items-center justify-center rounded-full bg-destructive/10 text-destructive">
<AlertCircleIcon className="size-4" />
</div>
);
}
// not-installed
return (
<div className="flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
<div className="flex size-8 items-center justify-center rounded-full bg-muted text-muted-foreground">
<TerminalIcon className="size-4" />
</div>
);

View file

@ -19,7 +19,6 @@ import {
} from "lucide-react";
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
import { Button } from "@/components/ui/button";
import vscodeIcon from "@/assets/vscode.svg";
import {
DropdownMenu,
DropdownMenuContent,
@ -34,7 +33,6 @@ import {
SelectValue,
} from "@/components/ui/select";
import { useDocumentStore } from "@/stores/document-store";
import { useSettingsStore } from "@/stores/settings-store";
interface EditorInfo {
id: string;
@ -52,28 +50,6 @@ const ZOOM_OPTIONS = [
{ value: "4", label: "400%" },
];
function OpenEditorIcon({ editor }: { editor: EditorInfo }) {
if (editor.id === "vscode") {
return (
<img
src={vscodeIcon}
alt=""
aria-hidden="true"
draggable={false}
className="size-5"
/>
);
}
return <ExternalLinkIcon className="size-4" />;
}
function getOpenEditorButtonClassName(editor: EditorInfo) {
return editor.id === "vscode"
? "h-7 w-7 border border-border/70 bg-muted/30 p-1 hover:bg-muted/50"
: undefined;
}
interface EditorToolbarProps {
editorView: RefObject<EditorView | null>;
fileType?: "tex" | "image";
@ -91,9 +67,6 @@ export function EditorToolbar({
cropMode,
onCropToggle,
}: EditorToolbarProps) {
const vimMode = useSettingsStore((s) => s.vimMode);
const setVimMode = useSettingsStore((s) => s.setVimMode);
const fileName = useDocumentStore((s) => {
const activeFile = s.files.find((f) => f.id === s.activeFileId);
return activeFile?.name ?? "main.tex";
@ -107,9 +80,7 @@ export function EditorToolbar({
const [editors, setEditors] = useState<EditorInfo[]>([]);
useEffect(() => {
invoke<EditorInfo[]>("detect_editors")
.then(setEditors)
.catch(() => {});
invoke<EditorInfo[]>("detect_editors").then(setEditors).catch(() => {});
}, []);
const openInEditor = useCallback(
@ -159,13 +130,10 @@ export function EditorToolbar({
if (fileType === "image") {
return (
<div className="flex h-[calc(var(--workspace-topbar-height)+var(--titlebar-height))] min-w-0 items-center justify-between border-border border-b bg-muted/30 px-2">
<div className="flex min-w-0 max-w-[min(18rem,35vw)] items-center gap-1.5">
<ImageIcon className="size-4 shrink-0 text-muted-foreground" />
<span
className="min-w-0 truncate font-medium text-muted-foreground text-sm"
title={activeFilePath ?? fileName}
>
<div className="flex items-center justify-between border-border border-b bg-muted/30 px-2 pt-[var(--titlebar-height)] h-[calc(36px+var(--titlebar-height))]">
<div className="flex items-center gap-1">
<ImageIcon className="size-4 text-muted-foreground" />
<span className="font-medium text-muted-foreground text-sm">
{fileName}
</span>
</div>
@ -222,9 +190,8 @@ export function EditorToolbar({
<TooltipIconButton
tooltip={`Open in ${editors[0].name}`}
onClick={() => openInEditor(editors[0].id)}
className={getOpenEditorButtonClassName(editors[0])}
>
<OpenEditorIcon editor={editors[0]} />
<ExternalLinkIcon className="size-4" />
</TooltipIconButton>
)}
{editors.length > 1 && (
@ -257,17 +224,12 @@ export function EditorToolbar({
}
return (
<div className="flex h-[calc(var(--workspace-topbar-height)+var(--titlebar-height))] min-w-0 items-center gap-1 border-border border-b bg-muted/30 px-2">
<div className="flex min-w-0 max-w-[min(18rem,35vw)] shrink items-center gap-1.5">
<FileTextIcon className="size-4 shrink-0 text-muted-foreground" />
<span
className="min-w-0 truncate font-medium text-muted-foreground text-sm"
title={activeFilePath ?? fileName}
>
{fileName}
</span>
</div>
<div className="mx-2 h-4 w-px shrink-0 bg-border" />
<div className="flex items-center gap-1 border-border border-b bg-muted/30 px-2 pt-[var(--titlebar-height)] h-[calc(36px+var(--titlebar-height))]">
<FileTextIcon className="size-4 text-muted-foreground" />
<span className="mr-2 font-medium text-muted-foreground text-sm">
{fileName}
</span>
<div className="mx-2 h-4 w-px bg-border" />
<TooltipIconButton
tooltip="Bold (\\textbf)"
onClick={() => insertText("\\textbf{", "}")}
@ -325,24 +287,13 @@ export function EditorToolbar({
>
<BookMarkedIcon className="size-4" />
</TooltipIconButton>
<div className="mx-2 h-4 w-px bg-border" />
<Button
variant={vimMode ? "default" : "ghost"}
size="sm"
className="h-6 px-2 font-mono text-xs"
onClick={() => setVimMode(!vimMode)}
title="Toggle Vim mode"
>
VIM
</Button>
<div data-tauri-drag-region className="flex-1 self-stretch" />
{editors.length === 1 && (
<TooltipIconButton
tooltip={`Open in ${editors[0].name}`}
onClick={() => openInEditor(editors[0].id)}
className={getOpenEditorButtonClassName(editors[0])}
>
<OpenEditorIcon editor={editors[0]} />
<ExternalLinkIcon className="size-4" />
</TooltipIconButton>
)}
{editors.length > 1 && (

View file

@ -1,9 +1,8 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { CheckIcon, XIcon } from "lucide-react";
import { ImageIcon, CheckIcon, XIcon } from "lucide-react";
import { writeFile } from "@tauri-apps/plugin-fs";
import { toast } from "sonner";
import { useDocumentStore, type ProjectFile } from "@/stores/document-store";
import { LOCAL_ZOOM_SHORTCUTS_ATTR } from "@/lib/app-zoom";
import { getAssetUrl } from "@/lib/tauri/fs";
import { Button } from "@/components/ui/button";
@ -51,17 +50,9 @@ export function ImagePreview({
// Crop state
const [cropRect, setCropRect] = useState<CropRect | null>(null);
const [dragStart, setDragStart] = useState<{ x: number; y: number } | null>(
null,
);
const [activeHandle, setActiveHandle] = useState<HandleId | "move" | null>(
null,
);
const [handleDragStart, setHandleDragStart] = useState<{
x: number;
y: number;
rect: CropRect;
} | null>(null);
const [dragStart, setDragStart] = useState<{ x: number; y: number } | null>(null);
const [activeHandle, setActiveHandle] = useState<HandleId | "move" | null>(null);
const [handleDragStart, setHandleDragStart] = useState<{ x: number; y: number; rect: CropRect } | null>(null);
const [isSaving, setIsSaving] = useState(false);
// Reset crop rect when exiting crop mode
@ -130,15 +121,18 @@ export function ImagePreview({
}, [scale, onScaleChange, cropMode]);
// Get coordinates relative to the displayed image
const getImageRelativeCoords = useCallback((e: React.MouseEvent) => {
const img = imgRef.current;
if (!img) return null;
const rect = img.getBoundingClientRect();
return {
x: Math.max(0, Math.min(rect.width, e.clientX - rect.left)),
y: Math.max(0, Math.min(rect.height, e.clientY - rect.top)),
};
}, []);
const getImageRelativeCoords = useCallback(
(e: React.MouseEvent) => {
const img = imgRef.current;
if (!img) return null;
const rect = img.getBoundingClientRect();
return {
x: Math.max(0, Math.min(rect.width, e.clientX - rect.left)),
y: Math.max(0, Math.min(rect.height, e.clientY - rect.top)),
};
},
[],
);
// --- Crop drag: new selection ---
const handleCropMouseDown = useCallback(
@ -157,11 +151,7 @@ export function ImagePreview({
coords.y <= cropRect.y + cropRect.h
) {
setActiveHandle("move");
setHandleDragStart({
x: coords.x,
y: coords.y,
rect: { ...cropRect },
});
setHandleDragStart({ x: coords.x, y: coords.y, rect: { ...cropRect } });
e.preventDefault();
return;
}
@ -240,21 +230,11 @@ export function ImagePreview({
setCropRect({ x, y, w, h });
}
},
[
cropMode,
dragStart,
activeHandle,
handleDragStart,
getImageRelativeCoords,
],
[cropMode, dragStart, activeHandle, handleDragStart, getImageRelativeCoords],
);
const handleCropMouseUp = useCallback(() => {
if (
dragStart &&
cropRect &&
(cropRect.w < MIN_CROP_SIZE || cropRect.h < MIN_CROP_SIZE)
) {
if (dragStart && cropRect && (cropRect.w < MIN_CROP_SIZE || cropRect.h < MIN_CROP_SIZE)) {
setCropRect(null);
}
setDragStart(null);
@ -348,20 +328,14 @@ export function ImagePreview({
// Use dataUrl if available (in-memory), otherwise fall back to asset URL (large images)
const imageSrc = file.dataUrl || getAssetUrl(file.absolutePath);
// Crop requires dataUrl (canvas manipulation needs same-origin data)
const _canCrop = !!file.dataUrl;
const canCrop = !!file.dataUrl;
return (
<div
ref={containerRef}
tabIndex={-1}
{...{ [LOCAL_ZOOM_SHORTCUTS_ATTR]: "true" }}
className="relative h-full overflow-auto bg-muted/50 p-4 outline-none"
style={
cropMode
? { cursor: cropRect && !dragStart ? "default" : "crosshair" }
: undefined
}
onMouseDownCapture={() => containerRef.current?.focus()}
style={cropMode ? { cursor: cropRect && !dragStart ? "default" : "crosshair" } : undefined}
onMouseMove={cropMode ? handleCropMouseMove : undefined}
onMouseUp={cropMode ? handleCropMouseUp : undefined}
onMouseLeave={cropMode ? handleCropMouseUp : undefined}
@ -374,10 +348,7 @@ export function ImagePreview({
)}
{/* Wrapper width = scale * 100% of container → CSS handles fit, no JS needed */}
<div
className="relative"
style={{ width: `${scale * 100}%`, margin: "0 auto" }}
>
<div className="relative" style={{ width: `${scale * 100}%`, margin: "0 auto" }}>
<img
ref={imgRef}
src={imageSrc}
@ -392,40 +363,13 @@ export function ImagePreview({
<>
{/* Dark overlay: 4 divs around the crop area */}
{/* Top */}
<div
className="pointer-events-none absolute z-10 bg-black/50"
style={{ left: 0, top: 0, right: 0, height: cropRect.y }}
/>
<div className="pointer-events-none absolute z-10 bg-black/50" style={{ left: 0, top: 0, right: 0, height: cropRect.y }} />
{/* Bottom */}
<div
className="pointer-events-none absolute z-10 bg-black/50"
style={{
left: 0,
top: cropRect.y + cropRect.h,
right: 0,
bottom: 0,
}}
/>
<div className="pointer-events-none absolute z-10 bg-black/50" style={{ left: 0, top: cropRect.y + cropRect.h, right: 0, bottom: 0 }} />
{/* Left */}
<div
className="pointer-events-none absolute z-10 bg-black/50"
style={{
left: 0,
top: cropRect.y,
width: cropRect.x,
height: cropRect.h,
}}
/>
<div className="pointer-events-none absolute z-10 bg-black/50" style={{ left: 0, top: cropRect.y, width: cropRect.x, height: cropRect.h }} />
{/* Right */}
<div
className="pointer-events-none absolute z-10 bg-black/50"
style={{
left: cropRect.x + cropRect.w,
top: cropRect.y,
right: 0,
height: cropRect.h,
}}
/>
<div className="pointer-events-none absolute z-10 bg-black/50" style={{ left: cropRect.x + cropRect.w, top: cropRect.y, right: 0, height: cropRect.h }} />
{/* Crop border */}
<div
@ -444,11 +388,7 @@ export function ImagePreview({
const coords = getImageRelativeCoords(e);
if (!coords) return;
setActiveHandle("move");
setHandleDragStart({
x: coords.x,
y: coords.y,
rect: { ...cropRect },
});
setHandleDragStart({ x: coords.x, y: coords.y, rect: { ...cropRect } });
}}
>
{/* Resize handles */}
@ -457,18 +397,8 @@ export function ImagePreview({
key={h.id}
className="absolute z-20 size-2.5 rounded-sm border border-gray-400 bg-white shadow-sm"
style={{
left:
h.x === 0
? -5
: h.x === 0.5
? "calc(50% - 5px)"
: "calc(100% - 5px)",
top:
h.y === 0
? -5
: h.y === 0.5
? "calc(50% - 5px)"
: "calc(100% - 5px)",
left: h.x === 0 ? -5 : h.x === 0.5 ? "calc(50% - 5px)" : "calc(100% - 5px)",
top: h.y === 0 ? -5 : h.y === 0.5 ? "calc(50% - 5px)" : "calc(100% - 5px)",
cursor: h.cursor,
}}
onMouseDown={(e) => handleHandleMouseDown(e, h.id)}

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