Compare commits

..

162 commits
v1.0.0 ... main

Author SHA1 Message Date
Hanjin Bae
674e7c7561
Merge pull request #210 from delibae/fix/hide-compiled-build-artifacts
Some checks failed
Lint / lint (push) Has been cancelled
fix: hide compiled build artifacts from project file tree
2026-08-28 19:23:13 +09:00
delibae
28f61908a7 fix: hide compiled build artifacts from project file tree
PR #154 intentionally unhid user-imported reference files (.docx, .zip,
media, ...) but also dropped the ignore rules for compiled build
artifacts, contradicting the __pycache__ skip it kept and the test from
PR #129. Restore ignore rules for .pyc/.pyo/.pyd/.o/.obj/.so/.dylib/.dll
and align the tauri-fs tests with that behavior.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-28 19:22:55 +09:00
Hanjin Bae
67dde775ac
Merge pull request #209 from delibae/revert/previous-logo
revert: restore previous app logo and icon set
2026-08-28 17:07:45 +09:00
delibae
311caec278 revert: restore previous app logo and icon set
Restore icon assets (icon.png/.icns/.ico, 32/128/256px, favicon,
icon-192) to their pre-v1.2.0 versions and remove the unreferenced
Windows Square/Store, Android, and iOS icons generated from the new
logo.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-28 17:07:15 +09:00
GerardDevlin
973005e086
Improve Windows setup, Muti-model extension, API auth, sessions, and project recovery (#154)
Some checks failed
Lint / lint (push) Has been cancelled
* Improve Windows setup, API auth, sessions, and project recovery

* Restore recent project on startup

* Extract PDF attachment text with MuPDF

* Add OpenAI-compatible provider mode

* Add tool loop for OpenAI-compatible providers

* Stream OpenAI-compatible provider responses

* Fallback when compatible providers reject tools

* Surface compatible provider reasoning content

* Support legacy function calls for compatible providers

* Add OpenAI-compatible provider presets

* Add planning and listing tools for compatible providers

* Show direct provider model in chat composer

* Expose direct provider status fields

* Add direct provider task compatibility tools

* Persist direct provider task state

* Read PDF sidecars in direct provider

* Create PDF sidecars for project attachments

* Sanitize direct provider reasoning history

* Allow API provider setup without Claude CLI

* Repair auth settings when saving API keys

* Preserve DeepSeek reasoning history for direct provider

* Restore direct provider reasoning from sessions

* Parse compatible provider token usage

* Keep direct provider sessions consistent

* Pair cancelled direct tool calls

* Verify compatible provider keys before saving

* Support ModelGate Claude proxy setup

* Reject mismatched ModelGate proxy URLs

* Allow changing configured AI provider

* Expose AI provider management when ready

* Replace provider dropdowns with setup cards

* Support multiple direct provider credentials

* Fix provider credential string normalization

* Show provider logos in model selectors

* Add Anthropic and Zhipu provider logos

* Simplify OpenAI-compatible provider presets

* Derive provider labels from configured endpoints

* Remove custom Claude proxy preset

* Support chat provider and model switching

* Use Anthropic icon for Claude Code selector

* Add provider setup from chat picker

* Prevent provider dialog overflow

* Expand direct provider tool compatibility

* Bring direct provider runtime closer to Claude Code

* Raise Rust macro recursion limit

* Show direct provider API errors

* Force final answer after direct provider tool loops

* feat: add OpenAI-compatible provider support and polish desktop UX

- Add project-level OpenAI-compatible provider configuration for Qwen, DeepSeek, GLM, Gemini, Moonshot, and OpenAI
- Support provider/model switching inside the chat composer
- Preserve chat continuity when switching between direct providers
- Bridge direct-provider conversation context when switching back to Claude Code
- Improve direct-provider tool compatibility, Python/uv context, skills behavior, and PDF text extraction
- Replace app icons with the new ClaudePrism prism icon
- Improve project naming, rename handling, recent project/session migration, and session deletion UX
- Polish provider icons, chat error spacing, uv dialog layout, and light-mode native title bar

* feat: add OpenAI-compatible provider support and polish desktop UX

- Add project-level OpenAI-compatible provider configuration for Qwen, DeepSeek, GLM, Gemini, Moonshot, and OpenAI
- Support provider/model switching inside the chat composer
- Preserve chat continuity when switching between direct providers
- Bridge direct-provider conversation context when switching back to Claude Code
- Improve direct-provider tool compatibility, Python/uv context, skills behavior, and PDF text extraction
- Replace app icons with the new ClaudePrism prism icon
- Improve project naming, rename handling, recent project/session migration, and session deletion UX
- Polish provider icons, chat error spacing, uv dialog layout, and light-mode native title bar

* fix: resolve Dependabot security vulnerabilities

NPM (via pnpm.overrides):
- ws 8.19.0 → 8.21.0 (uninitialized memory disclosure)
- postcss 8.5.6 → 8.5.15 (XSS via unescaped style tags)
- uuid 10.0.0 → 14.0.0 (missing buffer bounds check)

Cargo (via tectonic 0.15 → 0.16 + cargo update):
- rand 0.8.5 → 0.8.6, 0.9.2 → 0.9.4 (unsound with custom logger)
- gix-validate 0.7.7 → 0.10.1 (submodule name validation bypass)
- gix-fs 0.18.2 remains at 0.18.x (upstream constraint, cannot
  reach 0.21.1 until watchexec/ignore-files updates gix deps)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: bump Linux vcpkg cache key for tectonic 0.16 compat

Force fresh vcpkg rebuild on Linux CI to resolve graphite2
gr_label_destroy linker error after tectonic 0.15 → 0.16 upgrade.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: use static vcpkg triplet for Linux build

The default x64-linux triplet builds dynamic libraries, causing
graphite2's gr_label_destroy symbol to be hidden and unlinkable
after tectonic 0.15 → 0.16 upgrade.

Switch to a custom x64-linux-static triplet (static library linkage)
to match the approach used by macOS and Windows builds.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: set VCPKGRS_TRIPLET for Linux static build

tectonic's vcpkg dep_support looks for packages under the default
x64-linux triplet, but we install them under x64-linux-static.
Set VCPKGRS_TRIPLET=x64-linux-static so the Rust vcpkg crate
finds libpng and other dependencies in the correct location.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: revert Linux to x64-linux vcpkg triplet and add system graphite2

Revert the custom static triplet (didn't fix the issue) and instead
install libgraphite2-dev via apt. The vcpkg-built graphite2 marks
gr_label_destroy as a hidden symbol, but tectonic 0.16's bridge
crate references it. The system graphite2 package exports it properly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Expand provider support and refine desktop UX

Add OpenAI-compatible provider workflows, improve Claude/provider switching behavior, refresh desktop window theming and app icons, smooth PDF toolbar and touchpad zoom handling, and add Windows-friendly build scripts.

* fix: revert tectonic to 0.15 to fix Linux build

tectonic 0.16 causes undefined hidden symbol gr_label_destroy on
Linux due to graphite2 visibility issues in vcpkg builds. Revert to
tectonic 0.15 which works correctly with the existing vcpkg setup.

Security fixes retained:
- rand 0.8.5 → 0.8.6, 0.9.2 → 0.9.4

Workflow: revert to x64-linux triplet with libgraphite2-dev, fresh
cache key v3.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: update render scale debounce time and enhance zoom anchor functionality in PDF viewer

* fix: revert tectonic to 0.15, keep tauri 2.11.1

Previous commit reverted Cargo.lock too far, bringing tauri back to
2.10.3 which mismatches NPM @tauri-apps/api 2.11.0. This commit
keeps tauri at 2.11.1 while reverting tectonic to 0.15.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: update tauri plugin crates to match NPM packages

tauri-plugin-dialog 2.6.0 → 2.7.1, tauri-plugin-fs 2.4.5 → 2.5.1
to match their NPM counterparts and fix version mismatch build error.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: restore Linux vcpkg cache key to v1

The graphite2 hidden symbol issue is caused by fresh vcpkg builds,
not by tectonic version. The old v1 cache had a working graphite2
build. Restore the cache key to reuse it.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: remove vcpkg graphite2 to use system library on Linux

vcpkg builds graphite2 with hidden symbol visibility, causing
undefined symbol errors at link time. Remove vcpkg's graphite2
before building so the linker falls back to the system
libgraphite2-dev which exports all symbols properly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: symlink system graphite2 into vcpkg path on Linux

tectonic's dep_support requires graphite2 at the vcpkg path.
Replace vcpkg's hidden-visibility graphite2 with symlinks to
the system libgraphite2 (from libgraphite2-dev) which exports
all symbols properly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: use objcopy to globalize hidden graphite2 symbols on Linux

vcpkg builds graphite2 with -fvisibility=hidden, making gr_* API
symbols invisible to the linker. Use objcopy --globalize-symbol to
make them visible again before the Tauri build.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: extract .a archive before objcopy to fix graphite2 symbols

objcopy cannot modify symbols inside .a archives directly. Extract
individual .o files with ar, globalize gr_* symbols in each, then
repack the archive.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: avoid process substitution in objcopy graphite2 fix

Use a temp file for symbol list instead of <() process substitution,
which fails in GitHub Actions shell. Also add explicit shell: bash.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: prevent grep exit code 1 from aborting objcopy script

grep returns exit 1 when no match found, which aborts the script
under set -e. Wrap with { ... || true; } to prevent this.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: use ld.bfd instead of lld for Linux build

rust-lld treats hidden symbol references as hard errors, but ld.bfd
resolves them. vcpkg's graphite2 has STV_HIDDEN on gr_* symbols which
lld refuses to link. Switch to ld.bfd via RUSTFLAGS.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Address provider security review and desktop UX regressions

* Polish workspace toolbar alignment

* Improve chat session recovery and titles

* Improve direct provider parity and session UX

Add Claude Code-style context compaction for OpenAI-compatible direct providers, including no-tools summaries and retry handling.

Generate cached model-backed session titles and auto-resume the latest project chat without delaying on title generation.

Polish editor search theming and keyboard navigation.

* feat(provider): run OpenAI-compatible models through Claude Code proxy

Replace the hand-written direct-provider runtime with a local Anthropic-to-OpenAI-compatible proxy so Qwen, DeepSeek, GLM, Gemini, and other compatible providers use the Claude Code CLI tool/session experience.

Normalize strict OpenAI tool_call/tool_result pairing to avoid provider 400 errors, keep session titles stable and synced, allow switching sessions while another chat is streaming, show running indicators, add provider deletion with confirmation, and auto-refresh the file sidebar with refresh feedback.

* feat(provider): run OpenAI-compatible models through Claude Code proxy

Replace the hand-written direct-provider runtime with a local Anthropic-to-OpenAI-compatible proxy so Qwen, DeepSeek, GLM, Gemini, and other compatible providers use the Claude Code CLI tool/session experience.

Normalize strict OpenAI tool_call/tool_result pairing to avoid provider 400 errors, keep session titles stable and synced, allow switching sessions while another chat is streaming, show running indicators, add provider deletion with confirmation, and auto-refresh the file sidebar with refresh feedback.

* fix(chat): enforce minimum composer drawer height

* feat(provider): add Ollama local model support

* feat(provider): add Ollama local model support

* chore: update version to 1.2.0 in package.json and Cargo files

* fix(BashWidget): update styles for terminal and result display

* feat(MarkdownRenderer): enhance table and horizontal rule rendering

* feat: stream compatible providers through Claude proxy

Stream OpenAI-compatible provider responses through the local Anthropic proxy instead of buffering full responses. Map reasoning and tool-call deltas into Claude Code-compatible SSE events, and cache generated session titles so opening history does not regenerate them.

* feat: align compatible proxy with Claude Code Router

Keep OpenAI-compatible providers on the native Claude Code pipeline while moving proxy transforms into dedicated modules. Add CCR-style message, provider, stream, and tool handling for thinking signatures, ExitTool fallback, streamed tool argument repair, cache cleanup, stream usage, and provider token compatibility.

* chore: bump version to 1.3.0 across all relevant files

* feat(chat): support queued guidance during active runs

Extract Claude CLI process lifecycle management into a dedicated claude_process module and expose an interrupt command for guided continuations.

Add per-tab queued guidance state so users can type follow-up direction while a response is streaming, force a selected guidance item into the next turn, and preserve the tab's provider when resuming.

Render pending guidance inline in the conversation, keep the composer send/stop behavior ergonomic during streaming, allow individual guidance removal, and polish the compact guidance row spacing and hover states.

Cover the queue, display, consume, and forced-guidance flows with store tests.

* fix(chat): allow multiple guided follow-ups

Keep guided follow-ups additive when users select multiple queued items during an active run instead of replacing the previously displayed guidance.

Prefer already-displayed guidance when continuing the next turn, preserve the first forced guidance item, and keep the forced queue state consistent when entries are removed or interrupted.

Add store coverage for displaying multiple guidance entries and consuming displayed guidance before ordinary queued items.

* fix(chat): interrupt active runs for guided follow-ups

Terminate the current Claude process on Windows when a guided follow-up is selected so queued guidance can take effect immediately instead of waiting for the active run to finish naturally.

Handle forced queued guidance before snapshot and file refresh work in the completion event, then resume the same tab with the preserved provider context.

* feat(messages): enhance handling of tool result images in OpenAI requests

* feat: add provider model capability metadata

Return OpenAI-compatible model metadata from the backend and preserve provider model ordering.

Add chat/vision capability badges, metadata-driven model filtering, Qwen vision fallbacks, and tests.

Keep pasted chat images as temporary context and block image sends for text-only provider models.

* fix: guard unsupported proxy image payloads

Send Anthropic image blocks as OpenAI image_url parts with high detail.

Detect endpoints known to reject OpenAI-style image parts and fail early with a clear provider error.

* feat: add multi-select file tree actions

Preserve app context menus while suppressing the browser menu elsewhere.

Support Ctrl/Cmd multi-select, batch drag moves, Delete/Backspace deletion, and a themed confirmation dialog for file tree items.

* fix: disable global webview zoom shortcuts

Disable Tauri zoom hotkeys for normal and debug windows.

Reset stale persisted app zoom on startup and consume Ctrl/Cmd zoom shortcuts without changing the whole webview scale.

* feat: implement cleanup for temporary files in chat composer and event hooks

* feat: enhance message handling to sanitize stored user prompts and improve context display

* refactor: clean up formatting and improve regex matching in chat store

* fix: restore smooth PDF touchpad and wheel zoom

Enable WebView2 pinch gestures by allowing zoom hotkeys at the native layer, while keeping app-level zoom shortcuts guarded in the frontend.

Rework PDF preview zoom handling to distinguish touchpad pinch from Ctrl/Cmd mouse-wheel zoom, preserve the cursor anchor, and make mouse wheel zoom respond on the first notch.

* refactor: remove PDF sidecar extraction and related logic from project attachments

* feat(providers): align compatible models with Claude Code runtime

Route DeepSeek and Qwen through native Anthropic-compatible Claude Code endpoints, including canonical base URL handling and model environment overrides.

Add CCR-style proxy transformer plumbing for remaining OpenAI-compatible providers and harden tool argument repair in the local Anthropic proxy.

Preserve per-session provider behavior, improve stop/guidance flow handling, and update setup/chat store tests for provider routing.

* feat(providers): route Kimi through Claude Code

Use Moonshot/Kimi Anthropic-compatible endpoints for the native Claude Code runtime.

Canonicalize legacy Moonshot OpenAI URLs to /anthropic while keeping model discovery on the provider's /v1/models endpoint.

Add setup-store and Rust route coverage for domestic and international Moonshot endpoints.

* fix(security): harden Claude Code provider routing

Remove the global Claude Code permission bypass so native Anthropic provider runs rely on Claude Code's normal approval model.

Clear inherited ANTHROPIC_* variables before proxy or native provider launches to prevent credential and endpoint leakage across routes.

Require HTTPS for known cloud provider endpoints, tighten DeepSeek proxy matching, and harden manual code-block shell execution with cwd validation, timeouts, and output limits.

* feat(pdf-viewer): enhance zoom functionality and add permission skip option

* feat(stream): improve error handling for provider stream and add SSE error rendering

* fix: align Kimi Claude Code native route

* feat(layout): enhance workspace components with responsive design adjustments

* refactor(tests): normalize recent project paths and improve readability
refactor(proposed-changes-panel): simplify JSX structure for added/removed indicators
refactor(pdf-viewer): format function parameters for better readability
fix(project-name): enhance validation for project names to prevent invalid characters
refactor(build): improve environment flag handling for better clarity
refactor(dev): streamline environment flag appending logic

* refactor(tests): update file paths in project and chat prompt tests for consistency

* feat(install): update install_claude_cli to return success status and handle timeout errors

* feat(skills): add timeout handling for skills installation and improve download logic

* feat(onboarding): add close button to the dialog in scientific skills onboarding

* feat: enhance skills management with import and delete functionality

- Added `import_skill_from_folder` command to import skills from a local directory.
- Implemented `delete_installed_skill` command to remove specific skills.
- Updated the skills installation process to provide detailed download progress.
- Refactored skills loading and management logic for better organization and efficiency.
- Enhanced the UI to support skill importing and deletion with appropriate user feedback.

* feat: enhance skills downloading and proxy configuration

* Add workspace layout controls

* Keep PDF preview for non-TeX selections

* Refine project picker experience

* Polish sidebar collapse animations

* Polish home and workspace interactions

* feat: enhance Claude setup with embedded variant and error handling

- Updated `useClaudeSetupStore` to require Claude Code installation before saving provider credentials.
- Modified `ClaudeSetup` component to support an embedded variant with improved UI for installation and configuration.
- Added detailed status messages for missing Git and installation prompts.
- Refactored project picker to include theme toggle and version display.
- Improved layout and styling for settings and environment status sections.

* feat: enhance anthropic response handling to include "thinking" block type

* Add required environment onboarding

* Polish environment onboarding completion flow

* Fix chat provider and attachment interactions

* Scope chat sessions by project

* feat: add file attachment functionality and improve message actions in chat

* style: update ChatComposer component styles for improved UI consistency

---------

Co-authored-by: Hanjin Bae <iwdhanjin@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Hanjin Bae <82585834+delibae@users.noreply.github.com>
2026-07-04 17:46:14 +09:00
Hanjin Bae
1939030e9a
Merge pull request #200 from delibae/chore/bump-v1.2.0
Some checks failed
Lint / lint (push) Has been cancelled
chore: bump version to 1.2.0
2026-06-10 01:46:49 +09:00
Hanjin Bae
f8d5b2a20a chore: bump version to 1.2.0
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-10 01:46:43 +09:00
Hanjin Bae
a5e33b6cc1
Merge pull request #199 from delibae/chore/bump-v1.1.8
chore: bump version to 1.1.8
2026-06-10 00:50:46 +09:00
Hanjin Bae
846ef90e16 chore: bump version to 1.1.8
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-10 00:50:40 +09:00
Hanjin Bae
d5337a5007
Merge pull request #198 from delibae/fix/linux-pkgconfig-semistatic
fix: Linux build via pkg-config + semi-static (matches tectonic CI)
2026-06-10 00:29:37 +09:00
Hanjin Bae
a81e0e06a9 fix: use pkg-config + semi-static for Linux (matches tectonic CI)
Switch Linux build from vcpkg to pkg-config, matching tectonic's
own CI approach. vcpkg on Linux had unsolvable issues:
- graphite2 hidden symbol visibility
- ICU .so files missing symbols
- Incompatible with rust-lld, ld.bfd, and mold

pkg-config + system packages is the tectonic-recommended approach.
Semi-static linking maximizes portability. AppImage bundles .so files
automatically for distribution across distros.

Removes vcpkg overlay ports (no longer needed).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-10 00:29:30 +09:00
Hanjin Bae
42f20163f1
Merge pull request #197 from delibae/fix/linux-icu-disable-renaming
fix: add U_DISABLE_RENAMING + ICU diagnostic on Linux
2026-06-10 00:12:05 +09:00
Hanjin Bae
6e34b8660f fix: add U_DISABLE_RENAMING for ICU compat + ICU symbol diagnostic
vcpkg ICU may be built with --disable-renaming, exporting plain
symbols (ucnv_fromUChars) while tectonic C++ code references
versioned symbols (ucnv_fromUChars_78). Pass -DU_DISABLE_RENAMING=1
to match. Also add nm diagnostic to verify.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-10 00:11:58 +09:00
Hanjin Bae
4f50c1a4b2
Merge pull request #196 from delibae/fix/commit-cargo-lock
fix: commit Cargo.lock with pinned versions
2026-06-09 23:54:57 +09:00
Hanjin Bae
b0126b26bf fix: commit Cargo.lock with pinned dependency versions
Remove Cargo.lock from .gitignore and commit lockfile with:
- tectonic sub-crates pinned to v1.1.6 build versions
- tauri 2.11.1 + plugins matching NPM packages

Without Cargo.lock, CI resolves latest sub-crate versions which
have breaking API changes, and tauri plugins may not match NPM.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-09 23:54:51 +09:00
Hanjin Bae
715753c8d3
Merge pull request #195 from delibae/fix/linux-build-mold-vcpkg
fix: Linux build with mold linker + graphite2 overlay
2026-06-09 23:43:41 +09:00
Hanjin Bae
c2e8e83081 fix: Linux build with mold linker + graphite2 overlay + vcpkg ICU
Three issues prevented Linux builds with Rust 1.95+ and vcpkg:

1. graphite2 hidden symbols: vcpkg builds with -fvisibility=hidden,
   rust-lld errors on STV_HIDDEN refs. Fix: overlay port that adds
   GRAPHITE2_EXPORTING and removes -fvisibility=hidden.

2. Rust version scripts: ld.bfd can't mix anonymous + named version
   tags. Fix: use mold linker which handles both correctly.

3. ICU version mismatch: system ICU (v70/74) found before vcpkg ICU
   (v78), causing _78 suffix undefined symbols. Fix: move system ICU
   .so out of linker path during build.

vcpkg dynamic libs remain for portable distribution (issue #91).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-09 23:43:35 +09:00
Hanjin Bae
9b1a8825d1
Merge pull request #156 from delibae/fix/tauri-api-version-mismatch
fix: update @tauri-apps NPM packages to match tauri 2.11
2026-06-08 23:39:19 +09:00
Hanjin Bae
e09e6ddfd2 fix: update @tauri-apps NPM packages to match tauri 2.11 crate
The Rust tauri crate was bumped to 2.11.1 in PR #146 but the NPM
@tauri-apps/api package stayed at 2.10.1, causing the Tauri build to
fail with a version mismatch error.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-08 23:39:02 +09:00
Hanjin Bae
87a89c4c2c
Merge pull request #155 from delibae/chore/bump-v1.1.7
chore: bump version to 1.1.7
2026-06-08 23:18:21 +09:00
Hanjin Bae
0dbd06e5ed chore: bump version to 1.1.7 and fix tauri type compat in tests
Update all version references to 1.1.7. Fix TypeScript errors in test
mocks caused by tauri 2.11 changing fs API signatures from string to
string | URL.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-08 23:17:58 +09:00
Hanjin Bae
97180c0fed
Merge pull request #148 from delibae/dependabot/npm_and_yarn/npm_and_yarn-6b5db82cdb
Reviewed: vitest major bump, CI passing
2026-06-08 23:14:05 +09:00
Hanjin Bae
1b2b7c3222
Merge pull request #146 from delibae/dependabot/cargo/apps/desktop/src-tauri/cargo-15d2aa67de
Reviewed: tauri patch update, Cargo.lock only
2026-06-08 23:14:02 +09:00
Hanjin Bae
17a9b3caaa
Merge pull request #153 from TimothyMerlin/fix/vim-selection-highlight
Reviewed: clean bugfix, adds drawSelection() for vim visual mode
2026-06-08 23:13:58 +09:00
Timotheus Merlin Scherer
8404e27e07 fix: show selection highlight in vim visual mode
Add drawSelection() so vim visual mode selections render with a visible
background. Without this extension, the .cm-selectionBackground CSS
rule had no elements to style.
2026-06-07 11:28:20 +02:00
dependabot[bot]
25d3247ce5
chore(deps): bump the npm_and_yarn group across 2 directories with 2 updates
Bumps the npm_and_yarn group with 1 update in the / directory: [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest).
Bumps the npm_and_yarn group with 1 update in the /apps/desktop directory: [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest).


Updates `vitest` from 3.2.4 to 4.1.0
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.0/packages/vitest)

Updates `picomatch` from 4.0.3 to 4.0.4
- [Release notes](https://github.com/micromatch/picomatch/releases)
- [Changelog](https://github.com/micromatch/picomatch/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/picomatch/compare/4.0.3...4.0.4)

Updates `vitest` from 3.2.4 to 4.1.0
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.0/packages/vitest)

---
updated-dependencies:
- dependency-name: vitest
  dependency-version: 4.1.0
  dependency-type: direct:development
  dependency-group: npm_and_yarn
- dependency-name: picomatch
  dependency-version: 4.0.4
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: vitest
  dependency-version: 4.1.0
  dependency-type: direct:development
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-01 21:04:03 +00:00
dependabot[bot]
c47a92f462
chore(deps): bump tauri
Bumps the cargo group with 1 update in the /apps/desktop/src-tauri directory: [tauri](https://github.com/tauri-apps/tauri).


Updates `tauri` from 2.10.3 to 2.11.1
- [Release notes](https://github.com/tauri-apps/tauri/releases)
- [Commits](https://github.com/tauri-apps/tauri/compare/tauri-v2.10.3...tauri-v2.11.1)

---
updated-dependencies:
- dependency-name: tauri
  dependency-version: 2.11.1
  dependency-type: direct:production
  dependency-group: cargo
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-30 14:19:42 +00:00
dependabot[bot]
dc5e9a5d24
chore(deps): bump the cargo group across 1 directory with 4 updates (#145)
Some checks failed
Lint / lint (push) Has been cancelled
Bumps the cargo group with 4 updates in the /apps/desktop/src-tauri directory: [tauri](https://github.com/tauri-apps/tauri), [openssl](https://github.com/rust-openssl/rust-openssl), [quinn-proto](https://github.com/quinn-rs/quinn) and [rustls-webpki](https://github.com/rustls/webpki).


Updates `tauri` from 2.10.2 to 2.10.3
- [Release notes](https://github.com/tauri-apps/tauri/releases)
- [Commits](https://github.com/tauri-apps/tauri/compare/tauri-v2.10.2...tauri-v2.10.3)

Updates `openssl` from 0.10.75 to 0.10.80
- [Release notes](https://github.com/rust-openssl/rust-openssl/releases)
- [Commits](https://github.com/rust-openssl/rust-openssl/compare/openssl-v0.10.75...openssl-v0.10.80)

Updates `quinn-proto` from 0.11.13 to 0.11.14
- [Release notes](https://github.com/quinn-rs/quinn/releases)
- [Commits](https://github.com/quinn-rs/quinn/compare/quinn-proto-0.11.13...quinn-proto-0.11.14)

Updates `rustls-webpki` from 0.103.9 to 0.103.13
- [Release notes](https://github.com/rustls/webpki/releases)
- [Commits](https://github.com/rustls/webpki/compare/v/0.103.9...v/0.103.13)

---
updated-dependencies:
- dependency-name: tauri
  dependency-version: 2.10.3
  dependency-type: direct:production
  dependency-group: cargo
- dependency-name: openssl
  dependency-version: 0.10.80
  dependency-type: indirect
  dependency-group: cargo
- dependency-name: quinn-proto
  dependency-version: 0.11.14
  dependency-type: indirect
  dependency-group: cargo
- dependency-name: rustls-webpki
  dependency-version: 0.103.13
  dependency-type: indirect
  dependency-group: cargo
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-30 23:17:03 +09:00
dependabot[bot]
e63e913849
chore(deps-dev): bump turbo in the npm_and_yarn group across 1 directory (#144)
Some checks are pending
Lint / lint (push) Waiting to run
Bumps the npm_and_yarn group with 1 update in the / directory: [turbo](https://github.com/vercel/turborepo).


Updates `turbo` from 2.7.6 to 2.9.14
- [Release notes](https://github.com/vercel/turborepo/releases)
- [Changelog](https://github.com/vercel/turborepo/blob/main/RELEASE.md)
- [Commits](https://github.com/vercel/turborepo/compare/v2.7.6...v2.9.14)

---
updated-dependencies:
- dependency-name: turbo
  dependency-version: 2.9.14
  dependency-type: direct:development
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-29 22:15:28 +09:00
opalsaints
6a1fdf650d
feat(workspace): add toggle for PDF preview pane (#141)
Adds the ability to hide/show the right-side PDF preview pane:
- Cmd+\ / Ctrl+\ keyboard shortcut
- Floating button at the editor's bottom-right corner (icon switches
  between PanelRightCloseIcon and PanelRightOpenIcon based on state)
- Editor expands to fill freed space when preview is hidden
- Preview defaults to visible (no behaviour change for existing users)

State is held in a small Zustand store (preview-store.ts).

Co-authored-by: opalsaints <242018735+opalsaints@users.noreply.github.com>
2026-05-29 22:14:43 +09:00
Merlin
1f4055a730
feat: add vim mode toggle to LaTeX editor (#140)
* feat: add vim mode toggle to LaTeX editor

* fix: re-apply vim mode when switching files in editor
2026-05-29 22:14:25 +09:00
Weilin Cai
eb1e300c81
fix: skip Python cache files during project scan (#129)
Some checks failed
Lint / lint (push) Has been cancelled
2026-04-10 13:42:28 +09:00
Hanjin Bae
c0c0ce4255
chore: bump version to 1.1.6 (#127)
Some checks failed
Lint / lint (push) Has been cancelled
2026-04-08 21:31:39 +09:00
Hanjin Bae
640f38e706
Merge pull request #123 from delibae/dependabot/npm_and_yarn/npm_and_yarn-672fb18816
Some checks are pending
Lint / lint (push) Waiting to run
chore(deps): bump the npm_and_yarn group across 1 directory with 2 updates
2026-04-07 20:35:15 +09:00
Hanjin Bae
7ea1bd89f6
Merge pull request #126 from WhiskyChoy/codex/dev-mupdf-wasm-fix
fix: resolve MuPDF wasm correctly in desktop dev
2026-04-07 20:35:01 +09:00
Hanjin Bae
313f47d969
Merge pull request #125 from WhiskyChoy/codex/windows-stdin-prompt
fix: send long Windows Claude prompts via stdin
2026-04-07 20:34:52 +09:00
Hanjin Bae
d9cf9d461e
Merge pull request #124 from WhiskyChoy/codex/windows-recent-project-scope
fix: re-authorize recent project directories before opening
2026-04-07 20:34:38 +09:00
Weilin Cai
7ab99b5b5e style: format MuPDF dev loading files 2026-04-07 16:19:01 +08:00
Weilin Cai
d4a79375ca style: format Claude prompt transport test 2026-04-07 16:18:30 +08:00
Weilin Cai
c8e9926c20 fix: surface Claude stdin transport failures 2026-04-07 16:13:46 +08:00
Weilin Cai
29f6fd936d test: strengthen recent project authorization coverage 2026-04-07 16:13:24 +08:00
Weilin Cai
1c4eaf3e6d fix: resolve MuPDF wasm correctly in desktop dev 2026-04-07 16:03:06 +08:00
Weilin Cai
cf672e44d5 fix: send long Windows Claude prompts via stdin 2026-04-07 16:02:39 +08:00
Weilin Cai
037e033f35 fix: re-authorize recent project directories before opening 2026-04-07 16:02:23 +08:00
dependabot[bot]
995acd18c9
chore(deps): bump the npm_and_yarn group across 1 directory with 2 updates
Bumps the npm_and_yarn group with 1 update in the / directory: [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite).


Updates `vite` from 6.4.1 to 6.4.2
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v6.4.2/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v6.4.2/packages/vite)

Updates `picomatch` from 2.3.1 to 4.0.3
- [Release notes](https://github.com/micromatch/picomatch/releases)
- [Changelog](https://github.com/micromatch/picomatch/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/picomatch/compare/2.3.1...4.0.3)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 6.4.2
  dependency-type: direct:development
  dependency-group: npm_and_yarn
- dependency-name: picomatch
  dependency-version: 4.0.3
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-07 05:23:54 +00:00
Hanjin Bae
0f2635ab7a
Merge pull request #120 from delibae/chore/bump-v1.1.5
chore: bump version to v1.1.5
2026-04-02 14:23:46 +09:00
delibae
fcbcbc4c2d chore: bump version to v1.1.5
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 14:22:39 +09:00
Hanjin Bae
e57162f7b1
Merge pull request #119 from WhiskyChoy/codex/macos-cli-discovery
fix: improve macOS Claude CLI discovery
2026-04-02 14:03:40 +09:00
Weilin Cai
9fa07c1b8b fix: improve macOS Claude CLI discovery 2026-04-02 00:25:41 +08:00
Hanjin Bae
9b6a662e16
Merge pull request #117 from WhiskyChoy/fix/windows-texlive-console-window
fix: hide Windows TeXLive compiler console windows
2026-04-01 13:18:37 +09:00
Weilin Cai
9318ea632e fix: hide Windows TeXLive compiler console windows 2026-04-01 12:01:26 +08:00
Hanjin Bae
4effb83cf6
Merge pull request #113 from delibae/chore/bump-v1.1.4
chore: bump version to 1.1.4
2026-03-30 23:24:50 +09:00
delibae
52cfd79e43 chore: bump version to 1.1.4
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 23:24:20 +09:00
Hanjin Bae
d152fa1d6a
Merge pull request #112 from GunwoongP/fix/windows-git-bash-detection
fix: preserve CLAUDE_CODE_GIT_BASH_PATH and auto-detect git-bash on W…
2026-03-30 23:23:49 +09:00
GunwoongP
6434f176e1 fix: address Copilot review — refactor git-bash detection and improve error handling
- Refactor: extract shared find_git_bash() function used by both
  create_command and check_claude_status (eliminates code duplication)
- Validate user-specified CLAUDE_CODE_GIT_BASH_PATH exists before using it,
  falling back to auto-detection if the path is invalid
- Check existing error state before setting stderr errors to avoid
  masking more specific earlier errors
- Make zero-output error message platform-aware (git-bash hint only on Windows)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 23:03:02 +09:00
GunwoongP
ef5cd94f4d fix: preserve CLAUDE_CODE_GIT_BASH_PATH and auto-detect git-bash on Windows
The env var cleanup logic was stripping CLAUDE_CODE_GIT_BASH_PATH along
with other CLAUDE_CODE_* vars, causing Claude Code to fail even when the
user had explicitly set it. This also adds auto-detection of git-bash
from common install paths and git.exe on PATH.

Additionally, surface git-bash related stderr errors to the UI and show
a clear error message when the process fails with zero output.

Closes #109

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 22:45:56 +09:00
Hanjin Bae
4971a7d91a
Merge pull request #111 from delibae/chore/bump-v1.1.3
chore: bump version to 1.1.3
2026-03-30 20:13:13 +09:00
delibae
863b62b826 chore: bump version to 1.1.3
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 20:12:42 +09:00
Hanjin Bae
e473ce0a66
Merge pull request #110 from GunwoongP/fix/windows-find-claude-binary
fix: find Claude CLI via Windows registry PATH for GUI apps
2026-03-30 20:12:13 +09:00
GunwoongP
083eed94f1 style: fix biome lint CSS class sort and formatting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 18:09:59 +09:00
GunwoongP
ce32bb071b fix: replace debug window with in-app panel to fix IPC issue
The separate debug webview failed in dev mode due to CORS preflight
blocking Tauri IPC calls, resulting in a blank window (#109 reporter
also experienced this).

Replace it with a bottom panel overlay in the main window:
- Same webview = no IPC issues, direct access to log buffer
- Toggle with Ctrl+Shift+D (same shortcut)
- Lazy-loaded DebugPage component
- Click backdrop or press shortcut again to close
- Revert log-store.ts cross-window emitTo back to emit

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 18:05:11 +09:00
GunwoongP
dd6ca0cb54 fix: expand registry PATH vars and fix debug window sync
- Expand %USERPROFILE% etc. via ExpandEnvironmentStringsW when reading
  registry PATH entries
- Use is_file() instead of exists() for binary detection
- Fix debug window showing blank: use emitTo() for cross-window events
  instead of emit() which only targets the current window
- Update step numbering and doc comments
- Improve error message to avoid double-wrapping

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 17:43:38 +09:00
GunwoongP
e37cf55367 fix: find Claude CLI via Windows registry PATH for GUI apps
GUI apps inherit a stale PATH from login time, so if Claude was
installed afterwards it won't be found by which::which(). Read the
real User + System PATH from the Windows registry (HKCU/HKLM) and
search for claude.exe / claude.cmd there.

Also adds fallback paths for Volta, Scoop, pnpm global, and standard
Node.js installs, and replaces the bare "claude" fallback with a
proper error.

Addresses #109

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 16:34:15 +09:00
Hanjin Bae
7e5e395e30
Merge pull request #107 from delibae/fix/multi-file-pdf-preview
fix: resolve PDF preview to correct root file in multi-file LaTeX projects
2026-03-30 12:09:23 +09:00
delibae
f69de4ef12 chore: bump version to 1.1.2
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 12:09:01 +09:00
delibae
cc4ad9d668 fix: resolve PDF preview to correct root file in multi-file LaTeX projects
resolveTexRoot now detects \documentclass to find the actual root file
instead of treating the active sub-file as the compilation target.
This fixes incorrect PDF preview when editing non-main .tex files.

Closes #106

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 12:08:46 +09:00
Hanjin Bae
69bcb494f4
Merge pull request #104 from L-N1988/feat/zoom-inout 2026-03-27 22:45:50 +09:00
L-N1988
593a7e2301 feat: enable zooom in/out via ctrl +/-. 2026-03-27 19:29:17 +08:00
Hanjin Bae
7c874a7139
Merge pull request #103 from delibae/chore/bump-v1.1.1
chore: bump version to 1.1.1
2026-03-27 15:54:32 +09:00
delibae
baea3e32a9 chore: bump version to 1.1.1
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 15:53:17 +09:00
Hanjin Bae
0f9755d7e1
Merge pull request #102 from delibae/fix/windows-project-creation-and-linux-segfault
fix: resolve Windows project creation failure (#101) and Linux segfault (#100)
2026-03-27 15:50:44 +09:00
delibae
37957aacb8 fix: resolve Windows project creation failure and Linux segfault
Windows (#101): Add explicit FS scopes to each permission in
default.json so Tauri's scope validator accepts Windows paths.
Remove silent .catch(() => {}) on mkdir/writeTextFile so errors
surface via toast instead of failing silently. Replace all
.split("/").pop() with .split(/[/\\]/).pop() (14 occurrences)
for correct path parsing on Windows.

Linux (#100): Add ELF linker version script (symbols.map) that
hides statically linked ICU/HarfBuzz/FreeType/Fontconfig/Graphite2
symbols from the dynamic symbol table. This prevents symbol
collisions with WebKit2GTK's own dynamic copies of these libraries,
which caused a segfault at startup. The static linking introduced
in v1.0.10 to fix #91 is preserved—ICU remains embedded in the
binary so it works across distros.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 15:50:11 +09:00
Hanjin Bae
a790fa8f30
Merge pull request #99 from cityuaaronzhu/contrib-latex-errors
Improve LaTeX error context and sync speed
2026-03-27 12:02:32 +09:00
Hanjin Bae
fd6725347b
Merge pull request #98 from Landon-Molt/feature/texlive-backend
feat: add local TeXLive as alternative LaTeX compilation backend
2026-03-27 12:02:21 +09:00
aaronzhu
5d61977df6 Improve LaTeX error context and sync speed 2026-03-27 09:51:29 +08:00
Peter-Bot
51b6d6abec feat: add local TeXLive as alternative LaTeX compilation backend
Tectonic's limited package support makes it unusable for academic work
with custom .sty/.cls files. This adds opt-in support for shelling out
to a locally installed TeXLive distribution.

Backend (latex.rs):
- compile_with_texlive(): multi-pass compilation (2-3 engine passes +
  optional bibtex/biber), with xdvipdfmx fallback for .xdv → .pdf
- find_texlive_binary(): resolves binaries via PATH, standard install
  locations, and macOS login-shell fallback for GUI apps
- detect_bib_tool(): scans .tex for biblatex/bibliography to choose
  biber vs bibtex
- texlive_env_path(): injects TeXLive bin dir into subprocess PATH so
  xelatex can find xdvipdfmx internally
- detect_texlive Tauri command: reports available engines and version
- compile_latex gains use_texlive parameter; error messages show which
  backend ran (e.g. "Compilation failed (TeXLive/xelatex)")
- All paths passed to TeX are relative to avoid tilde expansion in
  iCloud paths (com~apple~CloudDocs)

Frontend:
- New settings-store.ts with persisted compilerBackend preference
- Backend selector dropdown in PDF preview toolbar
- All compile call sites pass the setting through to the backend
- detectTexlive() and TexliveStatus for engine availability checks

Tectonic remains the default. TeXLive is opt-in via the toolbar
dropdown. All existing tests pass (152/152).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 00:02:38 +01:00
Hanjin Bae
0301f904bf
Merge pull request #97 from delibae/chore/bump-v1.1.0
chore: bump version to 1.1.0
2026-03-26 20:01:47 +09:00
delibae
a6ace1a8d2 chore: bump version to 1.1.0
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 20:00:50 +09:00
Hanjin Bae
3bce19d6a0
Merge pull request #96 from delibae/fix/macos12-window-not-showing
fix: resolve macOS 12 window not showing (WebKit TLA bug)
2026-03-26 19:59:18 +09:00
delibae
bdc57978ba fix: resolve macOS 12 window not showing due to WebKit top-level-await bug
On macOS 12 (Monterey), the app launches but no main window appears.
The root cause is WebKit bug #242740: Safari 15's WKWebView has a race
condition with top-level await in ES modules, causing silent JS failure
that prevents `getCurrentWindow().show()` from ever being called.

Three complementary fixes:

1. Change Vite build target from `esnext` to `safari14` (Tauri recommended)
   and add `vite-plugin-top-level-await` to transform TLA into compatible
   Promise chains, avoiding the WebKit bug entirely.

2. Replace top-level `await import("./App")` in main.tsx with a `.then()`
   chain, removing TLA dependency at the source level.

3. Add a Rust-side 8-second timeout safety net in `setup()` that
   force-shows the main window if the frontend JS fails to do so,
   preventing permanently hidden windows regardless of the failure cause.

Closes #95

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 19:57:52 +09:00
Hanjin Bae
5d0be68695
Merge pull request #93 from delibae/chore/bump-v1.0.10
chore: bump version to 1.0.10
2026-03-25 22:14:28 +09:00
delibae
2e623c217f chore: bump version to 1.0.10
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 22:14:05 +09:00
Hanjin Bae
ad9e718c31
Merge pull request #92 from delibae/fix/env-path-and-static-icu
fix: static-link ICU on Linux and augment PATH for GUI-launched processes
2026-03-25 22:12:55 +09:00
delibae
12a79b0981 fix: static-link ICU on Linux and augment PATH for GUI-launched child processes
Three related environment issues are addressed:

1. Linux ICU version mismatch (#91):
   The Linux build used pkg-config to dynamically link against the host's
   ICU (libicuuc.so.70 on Ubuntu 22.04). Users on newer distros (e.g.
   Ubuntu 25.10 with ICU 76) get "cannot open shared object" errors.
   Switch to vcpkg static linking, mirroring the macOS fix (commit 9566956).

2. MCP servers fail on macOS GUI app (#87):
   macOS GUI apps inherit a minimal PATH (/usr/bin:/bin) that lacks
   /opt/homebrew/bin, so MCP server commands like `uv` silently fail.
   Prepend common tool directories (~/.local/bin, ~/.cargo/bin,
   /opt/homebrew/bin, NVM paths, etc.) to the child process PATH in
   create_command().

3. Existing NVM-installed Claude Code ignored (#90):
   find_claude_binary() relied on `which` which fails in GUI apps without
   NVM in PATH. Add NVM_BIN env var check and login shell fallback
   (`$SHELL -l -c "which claude"`) to discover claude through the user's
   actual shell configuration.

Closes #87, closes #90, closes #91

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 22:10:19 +09:00
Hanjin Bae
b1dee623b6
Merge pull request #89 from GunwoongP/fix/windows-cli-install
fix: pass Windows environment variables during CLI install and login
2026-03-25 19:07:06 +09:00
GunwoongP
71a4035a31 fix: pass Windows environment variables during CLI install and login
- Extract shared is_essential_env_var() with case-insensitive matching
- Replace duplicated inline env var lists in install_claude_cli, login_claude, and install_uv
- Add 16 Windows-essential env vars (USERPROFILE, APPDATA, SYSTEMROOT, etc.)
- Use eq_ignore_ascii_case for PATH checks

Without these variables, PowerShell/npm cannot function correctly,
causing the "Installing CLI" step to fail on Windows.

Closes #88

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 15:10:55 +09:00
Hanjin Bae
ac26fc9524
Merge pull request #82 from delibae/fix/vcpkg-build-tools
fix: install autotools before vcpkg on macOS CI
2026-03-17 17:47:08 +09:00
delibae
03fcd08d34 fix: install autotools build deps before vcpkg on macOS
vcpkg ports (gperf, fontconfig) need autoconf/automake/libtool to
build from source. These are compile-time tools only — they don't
create runtime dylib dependencies.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 17:46:50 +09:00
Hanjin Bae
f9ff9bbf3f
Merge pull request #81 from delibae/fix/v1.0.9-static-macos
chore: bump version to v1.0.9
2026-03-17 17:42:02 +09:00
delibae
a772b5833c chore: bump version to 1.0.9
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 17:41:48 +09:00
Hanjin Bae
f900319d86
Merge pull request #80 from delibae/fix/static-link-macos-vcpkg
fix: static link macOS builds via vcpkg (closes #77)
2026-03-17 17:40:57 +09:00
Hanjin Bae
a477605af1
Merge pull request #78 from delibae/fix/remove-cargo-config
fix: remove .cargo/config.toml from git tracking
2026-03-17 17:40:54 +09:00
delibae
956695665f fix: switch macOS builds from Homebrew (dynamic) to vcpkg (static)
Replace `brew install icu4c harfbuzz ...` + pkg-config with vcpkg
for both Apple Silicon (arm64-osx) and Intel (x64-osx) macOS builds.

vcpkg's macOS triplets default to static linking, so the resulting
binary no longer depends on /opt/homebrew dylibs. This fixes the
crash reported in #77 where users without Homebrew get:
  Library not loaded: /opt/homebrew/*/libicuuc.78.dylib

The Windows build already used vcpkg — this makes all platforms
consistent.

Refs:
- https://tectonic-typesetting.github.io/book/latest/howto/build-tectonic/
- https://learn.microsoft.com/en-us/vcpkg/users/triplets
- https://github.com/orgs/Homebrew/discussions/4672

Closes #77

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 17:39:11 +09:00
delibae
37ca06db35 fix: remove .cargo/config.toml from git and add to .gitignore
This file hardcodes /opt/homebrew paths (Apple Silicon only) for local
development. It should not be tracked — each developer sets up their
own local paths. Added to .gitignore to prevent re-committing.

Relates to #77 (crash on start due to dynamically linked ICU).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 17:31:20 +09:00
Hanjin Bae
29d9935812
Merge pull request #76 from delibae/feat/landing-intel-mac
chore: remove landing submodule reference from parent repo
2026-03-17 04:03:22 +09:00
delibae
492ac17124 chore: remove landing submodule reference from parent repo
The apps/landing gitlink (160000) was tracked in the index without
a .gitmodules entry. Remove it so the parent repo no longer references
the landing page as a submodule.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 04:02:53 +09:00
Hanjin Bae
0eb69850e5
Merge pull request #75 from delibae/docs/readme-intel-mac
docs: add Intel Mac download link to READMEs
2026-03-17 03:51:32 +09:00
delibae
a1e01f19da docs: add Intel Mac download link to all READMEs
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 03:51:06 +09:00
Hanjin Bae
151cc1889b
Merge pull request #74 from delibae/feat/v1.0.8-intel-mac
feat: add Intel Mac build workflow, bump to v1.0.8
2026-03-17 03:19:44 +09:00
delibae
b5ced3a7fa feat: add Intel Mac build workflow, bump version to 1.0.8
Add build-macos-intel job using macos-15-intel runner with
x86_64-apple-darwin target. Includes code signing, notarization,
and updater support (darwin-x86_64 platform key in latest.json).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 03:19:05 +09:00
Hanjin Bae
54eddc1f11
Merge pull request #73 from delibae/fix/lint-format
style: fix biome formatting
2026-03-17 02:42:14 +09:00
delibae
4bd5bf3bc8 style: fix biome formatting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 02:42:03 +09:00
Hanjin Bae
c1e780948a
Merge pull request #72 from delibae/feat/v1.0.7-improvements
feat: v1.0.7 — subprocess tectonic, editor shortcuts, UX fixes
2026-03-17 02:40:36 +09:00
delibae
da2f76c51a chore: fix lint warnings, bump version to 1.0.7
- Remove unused imports (hasPdfData, contentGeneration, lastCompiledGenerations)
- Bump version 1.0.6 → 1.0.7

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 02:39:49 +09:00
delibae
63483a2d2c feat: subprocess tectonic, editor shortcuts, and UX improvements
- Isolate tectonic compilation in subprocess to prevent font cache
  assertion crash on retry (fixes #67)
- Fix unreachable pattern warning in lib.rs by merging duplicate
  WindowEvent::Focused match arms
- Add editor shortcuts: Cmd+B (bold), Cmd+I (italic), Cmd+/ (comment)
- Add Tab/Shift-Tab indent support in CodeMirror editor
- Fix WebKit rendering artifacts with translateZ(0) on .cm-scroller
- Force recompile on button click and Cmd+Enter (skip generation check)
- Add minimum 500ms compile spinner for visual feedback

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 02:38:56 +09:00
Hanjin Bae
11a75e0b3c
Merge pull request #71 from delibae/fix/quickstart-url
fix: update quickstart URL to include locale path
2026-03-17 01:53:26 +09:00
delibae
16d636592a fix: update quickstart URL to include locale path
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 01:53:04 +09:00
Hanjin Bae
e147711501
Merge pull request #70 from delibae/release/v1.0.6
chore: bump version to v1.0.6
2026-03-17 01:13:33 +09:00
delibae
90d351bca9 chore: bump version to 1.0.6
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 01:13:07 +09:00
Hanjin Bae
8f86109caf
Merge pull request #69 from delibae/fix/linux-appimage-compat
fix: improve Linux AppImage compatibility and login reliability
2026-03-17 01:11:35 +09:00
delibae
a10afacd6c fix: improve Linux AppImage compatibility and login reliability
- Add sanitize_appimage_env() to restore original LD_LIBRARY_PATH/PATH
  when spawning child processes from AppImage (fixes xdg-open, curl)
- Pass DISPLAY, WAYLAND_DISPLAY, XDG_*, DBUS_SESSION_BUS_ADDRESS to
  child processes for Linux desktop integration
- Add stdin(Stdio::null()) to install_claude_cli to prevent interactive
  prompt hangs
- Add 120s timeout to login_claude to prevent indefinite hang when
  browser fails to open
- Show "Setup Guide" link to code.claude.com/docs/quickstart on login
  failure
- Keep releases as draft in CI workflow for manual publish

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 01:11:02 +09:00
Hanjin Bae
f368a62859
Merge pull request #68 from delibae/fix/linux-download-appimage
fix: change Linux download link from .deb to .AppImage
2026-03-16 23:22:20 +09:00
delibae
8063fac03f fix: change Linux download link from .deb to .AppImage
AppImage is distribution-agnostic and works across most Linux distros,
making it a better default download option than .deb.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 23:21:54 +09:00
Hanjin Bae
3c08bc347b
Merge pull request #65 from delibae/chore/lint-cleanup
chore: fix all lint and formatting issues
2026-03-16 17:17:17 +09:00
delibae
16b4c469b5 chore: add husky pre-commit hook and CI lint workflow
- Add husky with biome check --staged --write pre-commit hook
- Add GitHub Actions lint workflow (biome ci on PR/push to main)
- Update CONTRIBUTING.md with pre-commit and CI documentation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 17:15:58 +09:00
delibae
95c4072ec1 chore: fix all lint and formatting issues (biome check)
- Fix unused imports and variables
- Apply biome formatting (template literals, semicolons, line width)
- Fix a11y issues (label associations, anchor content)
- Fix Node.js import protocol (node:fs, node:path)
- Use self.onmessage in web worker

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 17:04:36 +09:00
Minsoo Kim
ea04d34bb4
Merge pull request #63 from delibae/feat/debug-and-black-screen-fix
fix: black screen on app switch + debug infrastructure
2026-03-16 16:47:20 +09:00
Hanjin Bae
0c831856a2
Merge pull request #64 from delibae/release/v1.0.5
fix: macOS WKWebView black screen on focus restore & bump v1.0.5
2026-03-16 16:41:05 +09:00
delibae
2480715f22 fix: macOS WKWebView black screen on focus restore & bump v1.0.5
- Add dual workaround for WKWebView failing to repaint after returning
  from background (1px resize nudge + DOM reflow)
- Use version-free download links in landing page
- Resolve app version dynamically from Tauri in sidebar
- Bump version to 1.0.5

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 16:40:10 +09:00
Minsoo Kim
585c631a86 fix: debug window log sync, auto-scroll, and IPC efficiency
- Fix echo loop: only debug window listens for debug-log-entry events
- Gate emit on _debugWindowConnected flag (no IPC when debug window closed)
- Simplify bulk sync: replace buffer on open instead of timestamp dedup
- Auto-scroll: use instant behavior, only scroll when already at bottom
- Fix systemInfo useEffect to run once ([] deps instead of [systemInfo])
2026-03-16 16:31:54 +09:00
Minsoo Kim
363b8c3482 refactor: separate debug window, cross-window log sync, simplify
- Debug panel is now a separate Tauri window (survives main window
  black screen) opened via Cmd+Shift+D or open_debug_window command
- Cross-window log bridging via Tauri events: main window emits
  entries, debug window receives them + bulk sync on open
- Log store uses mutable ring buffer + version counter instead of
  array spread per log; caches localStorage debug flag
- Debounce app-visibility-restored (200ms) to coalesce duplicate
  visibilitychange + Tauri Focused events
- Single getImageData(2x2) call in isCanvasBlank instead of 3x 1x1
- Extract shared getGpuRenderer (cached), SystemInfo type,
  getVisibilityLogs into log-store.ts
- Event name constants (APP_VISIBILITY_RESTORED)
- Remove debug UI from error-fallback.tsx and App.tsx
- Fix misplaced import in document-store.ts

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 16:31:54 +09:00
Minsoo Kim
cc5011b98a feat: add structured logging, debug panel, and bug report generation
Add comprehensive debugging infrastructure:

- Structured log store (Zustand ring buffer, 2000 entries) with
  level/source filtering and auto-forwarding warn/error to Rust stderr
- createLogger() factory replacing ad-hoc console.log("[prefix]") calls
  across 16 files (claude-event, pdf-viewer, mupdf, sidebar, etc.)
- Debug panel UI (Cmd+Shift+D) with Logs/System/Visibility tabs
- Bug report generator collecting logs, system info, GPU renderer,
  and visibility events into copyable JSON
- "Copy Debug Info" button on error fallback screen
- get_system_info Tauri command for OS/arch/version data
2026-03-16 16:31:53 +09:00
Minsoo Kim
db97ea59ad fix: resolve black screen on app switch (macOS M1)
Add visibility/focus recovery to prevent black screen when returning
from another app. When macOS reclaims GPU/WebView resources during app
switching, canvas contexts are silently invalidated. This adds:

- visibilitychange + Tauri Focused event listeners in main.tsx
- Canvas blank detection and auto-re-render in mupdf-page.tsx
- IntersectionObserver reconnection via focusGen in pdf-viewer.tsx
- MuPDF worker error recovery and resetMupdfClient() in mupdf-client.ts
- Cmd+Shift+D debug panel keyboard shortcut
2026-03-16 16:31:53 +09:00
Hanjin Bae
990200a292
Merge pull request #62 from delibae/release/v1.0.4
chore: release v1.0.4
2026-03-16 16:13:21 +09:00
delibae
3d329af3a6 chore: bump version to v1.0.4
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 16:13:10 +09:00
Hanjin Bae
661af8bf51
Merge pull request #61 from delibae/fix/suppress-build-warnings
fix: resolve all platform-specific Rust build warnings
2026-03-16 16:12:12 +09:00
delibae
b6b7085db3 fix: resolve all platform-specific Rust warnings
- lib.rs: #[allow(unused_mut)] on builder (mut only needed on macOS)
- claude.rs: move CommandExt import to module level with #[cfg(windows)]
- claude.rs: #[allow(unused_variables)] on home (used only on Unix)
- claude.rs: add #[cfg(not(windows))] to Unix-only dir helper functions
- uv.rs: move CommandExt import to module level with #[cfg(windows)]
- skills.rs: fix unreachable Ok(()) after #[cfg] return branches

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 16:11:55 +09:00
Hanjin Bae
43bbace42d
Merge pull request #60 from delibae/fix/readme-download-links
fix: version-free direct download links in READMEs
2026-03-16 15:55:46 +09:00
delibae
dbb785fadc fix: use version-free direct download links in all READMEs
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 15:55:32 +09:00
Hanjin Bae
56976d9469
Merge pull request #59 from delibae/feat/versionless-filenames-v1.0.3
feat: version-free release filenames, inline updater UI (v1.0.3)
2026-03-16 15:40:29 +09:00
delibae
51e038495f feat: version-free release filenames, inline updater UI (v1.0.3)
- Rename build artifacts to fixed names (ClaudePrism-macOS.dmg, etc.)
- Update latest.json URLs to use fixed filenames
- Update README download links to /releases/latest
- Inline update UI on home screen with version badge
- Gitignore Tauri gen/schemas
- Bump to v1.0.3

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 15:40:18 +09:00
Hanjin Bae
523032ab76
Merge pull request #58 from delibae/feat/updater-ui-v1.0.2
feat: inline update UI on home screen (v1.0.2)
2026-03-16 15:26:39 +09:00
delibae
2f3276a80e feat: inline update UI on home screen, gitignore gen/schemas (v1.0.2)
- Replace toast-based updater with inline version badge on home screen
- Show version + check/update/progress states directly in ProjectPicker
- Move Toaster to App level for workspace toast support
- Remove auto-generated Tauri schemas from git tracking
- Bump to v1.0.2

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 15:26:21 +09:00
Hanjin Bae
156614b4cc
Merge pull request #57 from delibae/fix/toaster-and-version-v1.0.1
fix: move Toaster to App level, add version display (v1.0.1)
2026-03-16 15:04:06 +09:00
delibae
15b0a24103 fix: move Toaster to App level, add version display, set v1.0.1
Toaster was only in WorkspaceWithClaude so update toasts didn't show
on the home screen. Moved to App level. Also shows version on home.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 15:03:54 +09:00
Hanjin Bae
4270989dea
Merge pull request #56 from delibae/feat/version-display-v1.0.2
feat: show app version on home screen (v1.0.2)
2026-03-16 14:47:38 +09:00
delibae
4dfe6b1946 feat: show app version on home screen, bump to v1.0.2
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 14:47:11 +09:00
Hanjin Bae
25c9872025
Merge pull request #55 from delibae/fix/ci-latest-json-url
fix: export BASE_URL for latest.json URL generation
2026-03-16 14:36:36 +09:00
delibae
b393a9e14b fix: export BASE_URL so node can read it in latest.json generation
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 14:36:25 +09:00
Hanjin Bae
666ea8cc5b
Merge pull request #54 from delibae/fix/ci-windows-updater-artifact
fix: Windows updater artifact collection (.exe.sig)
2026-03-16 14:25:54 +09:00
delibae
6e8521cd6f fix: collect Windows updater artifacts from .exe.sig not .nsis.zip.sig
Tauri v2 signs the NSIS .exe directly, not a .nsis.zip bundle.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 14:25:39 +09:00
Hanjin Bae
da2b495a4e
Merge pull request #53 from delibae/fix/ci-signing-key-v3
fix: regenerate signing key with explicit password
2026-03-16 14:08:39 +09:00
delibae
ac47bf87fe fix: regenerate signing key with password, reference PASSWORD secret
Previous key generated with --ci had unusable password. Regenerated
with explicit password and updated pubkey + workflow.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 14:08:26 +09:00
Hanjin Bae
152fa2c53f
Merge pull request #52 from delibae/fix/ci-remove-password-env
fix: remove TAURI_SIGNING_PRIVATE_KEY_PASSWORD env
2026-03-16 13:50:15 +09:00
delibae
31a4eda97c fix: remove TAURI_SIGNING_PRIVATE_KEY_PASSWORD env (key has no password)
Empty string is interpreted as a password. Omit entirely instead.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 13:50:02 +09:00
Hanjin Bae
30f6864e39
Merge pull request #51 from delibae/fix/ci-macos-cxxflags
fix: merge brew include paths into CXXFLAGS for macOS CI
2026-03-16 13:40:49 +09:00
delibae
00e53db25b fix: merge brew include paths into CXXFLAGS/CFLAGS for macOS CI
cc-rs uses CXXFLAGS/CFLAGS, not CPPFLAGS. Move include paths there.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 13:40:37 +09:00
Hanjin Bae
251a82ddbb
Merge pull request #50 from delibae/fix/ci-macos-cppflags
fix: add CPPFLAGS/LDFLAGS for macOS CI harfbuzz
2026-03-16 13:39:25 +09:00
delibae
c5105c9bc8 fix: add CPPFLAGS/LDFLAGS for macOS CI harfbuzz include paths
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 13:39:11 +09:00
Hanjin Bae
01abd9f247
Merge pull request #49 from delibae/fix/ci-macos-pkgconfig
fix: set PKG_CONFIG_PATH for macOS CI brew deps
2026-03-16 13:33:48 +09:00
delibae
1058c40733 fix: set PKG_CONFIG_PATH for macOS CI brew dependencies
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 13:33:32 +09:00
Hanjin Bae
47f95b67f2
Merge pull request #48 from delibae/fix/ci-signing-key
fix: CI signing key password and macOS tectonic backend
2026-03-16 13:28:02 +09:00
delibae
634af53ac2 fix: macOS use pkg-config for tectonic deps, set empty signing key password
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 13:27:38 +09:00
Hanjin Bae
12faf8f8c1
Merge pull request #47 from delibae/feat/auto-updater
feat: auto-updater and macOS black screen fix (v1.0.1)
2026-03-16 13:17:03 +09:00
delibae
08e54f263d feat: add auto-updater and fix macOS black screen on focus loss (v1.0.1)
- Add tauri-plugin-updater with signed update artifacts
- Add backgroundThrottling: disabled to prevent WKWebView black screen
- Add update check UI with toast notifications (sonner)
- Update build scripts to generate latest.json for GitHub Releases
- Add multi-platform CI workflow (macOS/Linux/Windows) with draft releases
- Bump version to 1.0.1

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 13:16:39 +09:00
Hanjin Bae
9fd32ffbc7
docs: correct privacy claims in READMEs (closes #43) (#46)
- Remove "your files stay on your machine" unqualified claim
- Intro: "local-first alternative" with explicit API transmission caveat
- Section "Your Research, Your Machine" → "Data & Privacy"
- Bold disclaimer: prompts + file contents sent to Anthropic API
- Remove (cloud)/(local CLI) from AI Model row
- Remove "private" from tagline
- Update landing submodule with matching fixes
- Sync all languages (en, ko, ja, zh-CN)

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 12:21:59 +09:00
Hanjin Bae
94cb8414a0
docs: correct privacy claims in READMEs (closes #43) (#44)
- Remove "Nothing leaves your machine" and "No such mechanism" claims
- Remove Data Privacy row from comparison table (covered in dedicated section)
- Simplify OpenAI Prism description (no FUD, just facts)
- Add note clarifying both tools send data to cloud APIs for inference
- Sync all language versions (en, ko, ja, zh-CN)

Closes #43

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 11:18:32 +09:00
Hanjin Bae
a3a2282dd6
fix: adjust titlebar height for Windows overlay mode (#42)
Reduce from 32px to 12px — 32px was too much padding, pushing content
too far from the top edge.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 03:46:40 +09:00
Hanjin Bae
32397bb611
fix: show folder name only in sidebar on Windows (#41)
projectRoot.split("/") didn't handle Windows backslash paths, causing
the full path (C:\Users\...\project) to display in the sidebar header.
Now splits on both / and \ so only the folder name is shown.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 03:37:19 +09:00
Hanjin Bae
d32c732c36
fix: Windows titlebar, installer fixes, and responsive PDF toolbar (#40)
Changes missed from PR #37 squash merge:
- Set --titlebar-height to 32px on Windows (was 0px, content hidden
  behind native overlay titlebar)
- Add ExecutionPolicy Bypass to PowerShell installers (uv + Claude Code)
- Add ~/.local/bin/uv.exe to Windows uv binary search paths
- PDF toolbar: Export/History as icon-only buttons, Capture & Ask text
  hidden on narrow panels via container query

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 03:16:02 +09:00
Hanjin Bae
0b6fd62d0a
fix: move macOS icon setup to RunEvent::Ready to prevent SIGBUS (#38) (#39)
Setting the dock icon in setup() caused SIGBUS on first launch from
signed release binaries due to Gatekeeper App Translocation interfering
with ImageIO plugin initialization.

Moved set_macos_app_icon() to RunEvent::Ready, which fires after the
app is fully initialized — matching Tauri's own approach for dev icons.

Closes #38

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 03:12:30 +09:00
Hanjin Bae
ebcb190d7a
fix: detect missing Git for Windows and guide users to install it (#37)
Windows users without Git for Windows see "Install Claude Code" loop
because Claude Code requires git-bash but the app never checks for it.
Now check_claude_status reports missing_git on Windows, and the setup UI
shows a dedicated "Git for Windows Required" screen with a download link
and re-check button.

Also adds Windows-essential env vars to uv installer and improves the
CI workflow to support manual release_tag input.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 00:27:15 +09:00
163 changed files with 36234 additions and 30401 deletions

View file

@ -1,7 +1,12 @@
name: Build Windows
name: Build Desktop
on:
workflow_dispatch:
inputs:
release_tag:
description: "Upload to this release tag (e.g. v1.0.0). Leave empty for artifact-only build."
required: false
default: ""
push:
tags:
- "v*"
@ -9,13 +14,18 @@ on:
permissions:
contents: write
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
jobs:
# ──────────────────────────────────────────────
# Windows
# ──────────────────────────────────────────────
build-windows:
runs-on: windows-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- uses: actions/checkout@v4
with:
fetch-depth: 0
@ -59,64 +69,544 @@ jobs:
echo "CXXFLAGS=/std:c++17" >> $GITHUB_ENV
echo "CFLAGS=" >> $GITHUB_ENV
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@stable
with:
targets: x86_64-pc-windows-msvc
- name: Rust cache
uses: swatinem/rust-cache@v2
- uses: swatinem/rust-cache@v2
with:
workspaces: apps/desktop/src-tauri -> target
- name: Install pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- name: Install dependencies
run: pnpm install
- run: pnpm install
- name: Build Tauri app
uses: tauri-apps/tauri-action@v0
shell: bash
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ZOTERO_CONSUMER_KEY: ${{ secrets.ZOTERO_CONSUMER_KEY }}
ZOTERO_CONSUMER_SECRET: ${{ secrets.ZOTERO_CONSUMER_SECRET }}
with:
projectPath: apps/desktop
tauriScript: pnpm tauri
args: --target x86_64-pc-windows-msvc
run: pnpm --filter @claude-prism/desktop tauri build --target x86_64-pc-windows-msvc
- name: Upload to existing release
if: startsWith(github.ref, 'refs/tags/v')
- name: Collect updater artifacts
id: collect
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
TAG="${GITHUB_REF#refs/tags/}"
BUNDLE_DIR="apps/desktop/src-tauri/target/x86_64-pc-windows-msvc/release/bundle"
BUNDLE="apps/desktop/src-tauri/target/x86_64-pc-windows-msvc/release/bundle"
NSIS_SIG=$(find "$BUNDLE/nsis" -name '*-setup.exe.sig' 2>/dev/null | head -1)
NSIS_EXE=$(find "$BUNDLE/nsis" -name '*-setup.exe' ! -name '*.sig' 2>/dev/null | head -1)
# Create release if it doesn't exist yet, otherwise leave it alone
gh release view "$TAG" > /dev/null 2>&1 || \
gh release create "$TAG" --title "ClaudePrism $TAG" --generate-notes
if [ -n "$NSIS_SIG" ] && [ -n "$NSIS_EXE" ]; then
echo "sig=$(cat "$NSIS_SIG")" >> $GITHUB_OUTPUT
echo "url=$(basename "$NSIS_EXE")" >> $GITHUB_OUTPUT
fi
# Upload assets — overwrites if same filename already exists
find "$BUNDLE_DIR" -type f \( -name "*.exe" -o -name "*.msi" \) | while read -r file; do
echo "Uploading: $file"
gh release upload "$TAG" "$file" --clobber
done
- name: Upload artifacts (manual builds)
if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
- name: Upload build artifacts
uses: actions/upload-artifact@v4
with:
name: desktop-windows
path: |
apps/desktop/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/**/*.exe
apps/desktop/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/**/*.msi
apps/desktop/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/**/*.sig
if-no-files-found: warn
outputs:
sig: ${{ steps.collect.outputs.sig }}
url: ${{ steps.collect.outputs.url }}
# ──────────────────────────────────────────────
# macOS (Apple Silicon)
# ──────────────────────────────────────────────
build-macos:
runs-on: macos-14
steps:
- uses: actions/checkout@v4
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
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
- 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: aarch64-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 aarch64-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="aarch64-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/aarch64-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
path: |
apps/desktop/src-tauri/target/aarch64-apple-darwin/release/bundle/**/*.dmg
apps/desktop/src-tauri/target/aarch64-apple-darwin/release/bundle/**/*.app.tar.gz
apps/desktop/src-tauri/target/aarch64-apple-darwin/release/bundle/**/*.sig
if-no-files-found: warn
outputs:
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
# ──────────────────────────────────────────────
build-linux:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install Linux dependencies
run: |
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
- uses: dtolnay/rust-toolchain@stable
with:
targets: x86_64-unknown-linux-gnu
- 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 }}
# 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
- name: Collect updater artifacts
id: collect
run: |
BUNDLE="apps/desktop/src-tauri/target/x86_64-unknown-linux-gnu/release/bundle"
APPIMAGE_SIG=$(find "$BUNDLE/appimage" -name '*.AppImage.sig' 2>/dev/null | head -1)
APPIMAGE=$(find "$BUNDLE/appimage" -name '*.AppImage' ! -name '*.sig' 2>/dev/null | head -1)
if [ -n "$APPIMAGE_SIG" ] && [ -n "$APPIMAGE" ]; then
echo "sig=$(cat "$APPIMAGE_SIG")" >> $GITHUB_OUTPUT
echo "url=$(basename "$APPIMAGE")" >> $GITHUB_OUTPUT
fi
- name: Upload build artifacts
uses: actions/upload-artifact@v4
with:
name: desktop-linux
path: |
apps/desktop/src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/**/*.deb
apps/desktop/src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/**/*.rpm
apps/desktop/src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/**/*.AppImage
apps/desktop/src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/**/*.sig
if-no-files-found: warn
outputs:
sig: ${{ steps.collect.outputs.sig }}
url: ${{ steps.collect.outputs.url }}
# ──────────────────────────────────────────────
# Publish: generate latest.json & upload to release
# ──────────────────────────────────────────────
publish:
needs: [build-windows, build-macos, build-macos-intel, build-linux]
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/v') || inputs.release_tag != ''
steps:
- uses: actions/checkout@v4
- name: Determine tag
id: tag
run: |
if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
echo "tag=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
else
echo "tag=${{ inputs.release_tag }}" >> $GITHUB_OUTPUT
fi
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
- name: Generate latest.json
env:
TAG: ${{ steps.tag.outputs.tag }}
WIN_SIG: ${{ needs.build-windows.outputs.sig }}
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: |
VERSION="${TAG#v}"
PUB_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
export BASE_URL="https://github.com/delibae/claude-prism/releases/download/$TAG"
node -e "
const BASE = process.env.BASE_URL;
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)
platforms['windows-x86_64'] = { signature: process.env.WIN_SIG, url: BASE + '/ClaudePrism-Windows-setup.exe' };
const data = {
version: '$VERSION',
notes: 'ClaudePrism $TAG',
pub_date: '$PUB_DATE',
platforms
};
require('fs').writeFileSync('latest.json', JSON.stringify(data, null, 2));
console.log(JSON.stringify(data, null, 2));
"
- name: Create draft release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
TAG="${{ steps.tag.outputs.tag }}"
gh release view "$TAG" > /dev/null 2>&1 || \
gh release create "$TAG" --title "ClaudePrism $TAG" --generate-notes --draft
- name: Rename and upload assets
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
TAG="${{ steps.tag.outputs.tag }}"
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
cp latest.json upload/
echo "==> Uploading:"
ls -la upload/
for file in upload/*; do
gh release upload "$TAG" "$file" --clobber
done
# Release stays as draft — publish manually from GitHub Releases UI

24
.github/workflows/lint.yml vendored Normal file
View file

@ -0,0 +1,24 @@
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

5
.gitignore vendored
View file

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

1
.husky/pre-commit Normal file
View file

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

View file

@ -136,15 +136,25 @@ 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. Run `pnpm lint` to ensure code quality
6. Commit with a descriptive message
7. Push to your fork and open a PR
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
### Commit Convention

View file

@ -5,8 +5,8 @@
<h1 align="center">ClaudePrism</h1>
<p align="center">
Claude を活用した、プライベートかつオフラインファーストの論文執筆ワークスペース。<br/>
LaTeX + Python + 100 以上の専門スキル ── すべてローカルで動作します。
Claude を活用した、オフラインファーストの論文執筆ワークスペース。<br/>
LaTeX + Python + 100 以上の専門スキル ── デスクトップで動作します。
</p>
<p align="center">
@ -24,14 +24,17 @@
<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_1.0.0_aarch64.dmg">
<img src="https://img.shields.io/badge/Download-macOS_(Apple_Silicon)-black?style=for-the-badge&logo=apple&logoColor=white" alt="macOS 版をダウンロード" />
<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>&nbsp;
<a href="https://github.com/delibae/claude-prism/releases/latest/download/ClaudePrism_1.0.0_x64-setup.exe">
<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">
<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_1.0.0_amd64.deb">
<img src="https://img.shields.io/badge/Download-Linux_(deb)-FCC624?style=for-the-badge&logo=linux&logoColor=black" alt="Linux 版をダウンロード" />
<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>
</p>
<p align="center">
@ -44,41 +47,24 @@
## なぜ ClaudePrism なのか
[OpenAI Prism](https://openai.com/prism/) は研究者向けのクラウドベース LaTeX ワークスペースです。無料で高機能ですが、**未発表の研究データが OpenAI のサーバーに保存されます**。知的財産の漏洩リスクや、OpenAI が研究データに対して権利を主張する可能性について、懸念する声もあります。デフォルトでは、コンテンツが将来のモデル学習に利用される可能性があり、これを無効にするにはオプトアウトの設定が必要です。
[OpenAI Prism](https://openai.com/prism/) はクラウドベースの LaTeX ワークスペースです — 利用するにはすべてのファイルとデータを OpenAI のサーバーにアップロードする必要があります。
ClaudePrism は、これとは異なるアプローチを採用しています。**すべてがローカルマシン上で動作します。**
ClaudePrism は**ローカルファースト**の代替ツールです — ファイルはローカルディスクに保存し、オフラインでコンパイルします。AI 機能は Anthropic API にコンテンツを送信して推論を行います([データ使用ポリシー](https://code.claude.com/docs/en/data-usage)を参照)。
| | OpenAI Prism | ClaudePrism |
|---|:---:|:---:|
| AI モデル | GPT-5.2(クラウド) | **Claude Opus / Sonnet / Haiku(ローカル CLI)** |
| AI モデル | GPT-5.2 | **Claude Opus / Sonnet / Haiku** |
| 実行環境 | ブラウザ(クラウド) | **ネイティブデスクトップ(Tauri 2 + Rust)** |
| LaTeX | クラウドコンパイル | **Tectonic 内蔵(オフライン対応)** |
| Python 環境 | — | **uv + venv 内蔵 ── ワンクリックで科学計算向け Python 環境を構築** |
| 専門スキル | — | **100 以上の専門スキル(バイオインフォマティクス、ケモインフォマティクス、ML など)** |
| 導入 | アカウント設定が必要 | **インストール後すぐに利用可能 ── テンプレートギャラリー + プロジェクトウィザード** |
| バージョン管理 | — | **Git ベースの履歴管理(ラベル & diff)** |
| データプライバシー | クラウド保存、[オプトアウト可](https://help.openai.com/en/articles/8983082-how-do-i-turn-off-model-training-to-stop-openai-training-models-on-my-conversations) | **ローカル実行、[オプトアウト可](https://code.claude.com/docs/en/data-usage)** |
| ソースコード | プロプライエタリ | **オープンソース(MIT)** |
### 研究データは手元のマシンに保存
### データとプライバシー
ClaudePrism は **Claude Code** をローカルのサブプロセスとして呼び出します。ドキュメントはローカルディスクに保存され、AI 推論に必要なプロンプトだけが Claude API に送信されます。
**データ取り扱いの比較:**
| | OpenAI Prism | ClaudePrism(Claude Code 経由) |
|---|---|---|
| ドキュメントの保存先 | OpenAI クラウドサーバー | **ローカルディスクのみ** |
| モデル学習のオプトアウト | [可能](https://help.openai.com/en/articles/8983082-how-do-i-turn-off-model-training-to-stop-openai-training-models-on-my-conversations)(設定 > データ管理) | [可能](https://claude.ai/settings/data-privacy-controls)(プライバシー設定または API キー) |
| **オプトアウト後のデータ残留** | **ドキュメントは OpenAI サーバーに残る** | **データがマシン外へ送信されることはありません** |
| **フィードバック時のリスク** | **👍 / 👎 を押すと会話全体が学習データとして送信される** | **そのような仕組みはありません** |
| デフォルトの学習ポリシー | ON(個人アカウント) | ON(コンシューマー)、**OFF**(API キー / Team / Enterprise) |
| データ保持期間 | オプトアウト後 約30日 | 30日(オプトアウト)/ 5年(オプトイン) |
| ゼロデータ保持 | 非対応 | Enterprise で対応 |
| テレメトリ | 無効化不可 | `DISABLE_TELEMETRY=1` で完全無効化 |
| ソースコード | プロプライエタリ | オープンソース ── [自分で監査できます](https://github.com/delibae/claude-prism) |
> **どちらのツールもモデル学習のオプトアウトに対応しています。** 大きな違いは、ドキュメントがどこに保存されるかです。Prism では、学習設定にかかわらず未発表の研究が OpenAI のサーバー上に存在します。ClaudePrism では、ファイルがマシンの外に出ることはなく、AI 推論のためのプロンプトのみが送信されます。
ドキュメントはローカルで保存・コンパイルされ、リモートサーバーにアップロードされません。ただし、AI 機能を使用する際、**プロンプトと Claude が読み取ったファイル内容は Anthropic API に送信されます**。これはすべてのクラウドベース LLM ツールと同様です。保持ポリシーとオプトアウトオプションについては [Claude Code データ使用ポリシー](https://code.claude.com/docs/en/data-usage)をご覧ください。
---

View file

@ -5,8 +5,8 @@
<h1 align="center">ClaudePrism</h1>
<p align="center">
Claude 기반의 프라이빗, 오프라인 우선 과학 논문 작성 워크스페이스.<br/>
LaTeX + Python + 100개 이상의 과학 스킬 — 모두 로컬에서 실행됩니다.
Claude 기반의 오프라인 우선 과학 논문 작성 워크스페이스.<br/>
LaTeX + Python + 100개 이상의 과학 스킬 — 데스크톱에서 실행됩니다.
</p>
<p align="center">
@ -24,14 +24,17 @@
<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_1.0.0_aarch64.dmg">
<img src="https://img.shields.io/badge/Download-macOS_(Apple_Silicon)-black?style=for-the-badge&logo=apple&logoColor=white" alt="macOS 다운로드" />
<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>&nbsp;
<a href="https://github.com/delibae/claude-prism/releases/latest/download/ClaudePrism_1.0.0_x64-setup.exe">
<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">
<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_1.0.0_amd64.deb">
<img src="https://img.shields.io/badge/Download-Linux_(deb)-FCC624?style=for-the-badge&logo=linux&logoColor=black" alt="Linux 다운로드" />
<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>
</p>
<p align="center">
@ -44,41 +47,24 @@
## 왜 ClaudePrism인가?
[OpenAI Prism](https://openai.com/prism/)은 과학자를 위한 클라우드 기반 LaTeX 워크스페이스입니다 — 무료이고 강력하지만, **미발표 연구가 OpenAI 서버에 저장됩니다**. 전문가들은 지적재산권 노출과 OpenAI가 연구 데이터에 대한 권리를 주장할 가능성에 대해 우려를 표명했습니다. 기본적으로 콘텐츠는 향후 모델 학습에 사용될 수 있으며, 별도로 거부(opt-out)해야 합니다.
[OpenAI Prism](https://openai.com/prism/)은 클라우드 기반 LaTeX 워크스페이스입니다 — 사용하려면 모든 파일과 데이터를 OpenAI 서버에 업로드해야 합니다.
ClaudePrism은 다른 접근 방식을 취합니다: **모든 것이 로컬 머신에서 실행됩니다.**
ClaudePrism은 **로컬 우선** 대안입니다 — 파일은 로컬 디스크에 저장하고 오프라인으로 컴파일합니다. AI 기능은 Anthropic API로 콘텐츠를 전송하여 추론합니다 ([데이터 사용 정책](https://code.claude.com/docs/en/data-usage) 참고).
| | OpenAI Prism | ClaudePrism |
|---|:---:|:---:|
| AI 모델 | GPT-5.2 (클라우드) | **Claude Opus / Sonnet / Haiku (로컬 CLI)** |
| AI 모델 | GPT-5.2 | **Claude Opus / Sonnet / Haiku** |
| 실행 환경 | 브라우저 (클라우드) | **네이티브 데스크톱 (Tauri 2 + Rust)** |
| LaTeX | 클라우드 컴파일 | **Tectonic (내장, 오프라인)** |
| Python 환경 | — | **내장 uv + venv — 원클릭 과학 Python 환경** |
| 과학 스킬 | — | **100+ 도메인 스킬 (생물정보학, 화학정보학, ML 등)** |
| 시작하기 | 계정 설정 필요 | **설치 후 바로 사용 — 템플릿 갤러리 + 프로젝트 마법사** |
| 버전 관리 | — | **Git 기반 히스토리 (라벨 & diff)** |
| 데이터 프라이버시 | 클라우드 저장, [opt-out 가능](https://help.openai.com/en/articles/8983082-how-do-i-turn-off-model-training-to-stop-openai-training-models-on-my-conversations) | **로컬 실행, [opt-out 가능](https://code.claude.com/docs/en/data-usage)** |
| 소스 코드 | 프로프라이어터리 | **오픈소스 (MIT)** |
### 내 연구는 내 컴퓨터에서
### 데이터 및 개인정보
ClaudePrism은 **Claude Code**를 로컬 서브프로세스로 호출합니다. 문서는 디스크에 저장되며 — AI 추론을 위한 프롬프트만 Claude API로 전송됩니다.
**두 도구의 데이터 관련 비교:**
| | OpenAI Prism | ClaudePrism (Claude Code 경유) |
|---|---|---|
| 문서 저장 위치 | OpenAI 클라우드 서버 | **로컬 디스크에만 저장** |
| 모델 학습 opt-out | [가능](https://help.openai.com/en/articles/8983082-how-do-i-turn-off-model-training-to-stop-openai-training-models-on-my-conversations) (설정 > 데이터 제어) | [가능](https://claude.ai/settings/data-privacy-controls) (프라이버시 설정 또는 API 키) |
| **opt-out 이후 데이터 노출** | **문서가 OpenAI 서버에 그대로 남음** | **어떤 데이터도 외부로 나가지 않음** |
| **피드백 함정** | **👍/👎 클릭 시 전체 대화가 학습 데이터로 전송** | **해당 메커니즘 없음** |
| 기본 학습 정책 | ON (개인 계정) | ON (소비자), **OFF** (API 키 / 팀 / 엔터프라이즈) |
| 데이터 보존 기간 | opt-out 후 약 30일 | 30일 (opt-out) / 5년 (opt-in) |
| 제로 데이터 보존 | 불가 | 엔터프라이즈에서 가능 |
| 텔레메트리 | 비활성화 불가 | `DISABLE_TELEMETRY=1`로 완전 비활성화 |
| 소스 코드 | 프로프라이어터리 | 오픈소스 — [직접 감사하세요](https://github.com/delibae/claude-prism) |
> **두 도구 모두 모델 학습 거부(opt-out)를 허용합니다.** 핵심 차이는 문서가 어디에 저장되느냐입니다. Prism을 사용하면 미발표 연구가 학습 설정과 관계없이 OpenAI 서버에 존재합니다. ClaudePrism을 사용하면 파일은 절대 외부로 나가지 않으며 — AI 추론을 위한 프롬프트만 전송됩니다.
문서는 로컬에 저장·컴파일되며 원격 서버에 업로드되지 않습니다. 단, AI 기능 사용 시 **프롬프트와 Claude가 읽은 파일 내용이 Anthropic API로 전송됩니다**. 이는 모든 클라우드 기반 LLM 도구와 동일합니다. 보존 정책 및 opt-out 옵션은 [Claude Code 데이터 사용 정책](https://code.claude.com/docs/en/data-usage)을 참고하세요.
---

View file

@ -5,8 +5,8 @@
<h1 align="center">ClaudePrism</h1>
<p align="center">
A private, offline-first scientific writing workspace powered by Claude.<br/>
LaTeX + Python + 100 scientific skills — all running locally on your machine.
An offline-first scientific writing workspace powered by Claude.<br/>
LaTeX + Python + 100+ scientific skills — runs on your desktop.
</p>
<p align="center">
@ -24,14 +24,17 @@
<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_1.0.0_aarch64.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" />
<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>&nbsp;
<a href="https://github.com/delibae/claude-prism/releases/latest/download/ClaudePrism_1.0.0_x64-setup.exe">
<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">
<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_1.0.0_amd64.deb">
<img src="https://img.shields.io/badge/Download-Linux_(deb)-FCC624?style=for-the-badge&logo=linux&logoColor=black" alt="Download for Linux" />
<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>
</p>
<p align="center">
@ -44,41 +47,24 @@
## Why ClaudePrism?
[OpenAI Prism](https://openai.com/prism/) is a cloud-based LaTeX workspace for scientists — free, powerful, but **your unpublished research lives on OpenAI's servers**. Experts have raised concerns about intellectual property exposure and whether OpenAI could claim rights over researcher data. By default, content may be used to train future models unless you opt out.
[OpenAI Prism](https://openai.com/prism/) is a cloud-based LaTeX workspace — all your files and data must be uploaded to OpenAI's servers to use it.
ClaudePrism takes a different approach: **everything runs locally on your machine.**
ClaudePrism is a **local-first** alternative — your files are stored on your disk, compiled offline, and edited locally. AI features require sending content to Anthropic's API for inference (see [data usage](https://code.claude.com/docs/en/data-usage)).
| | OpenAI Prism | ClaudePrism |
|---|:---:|:---:|
| AI Model | GPT-5.2 (cloud) | **Claude Opus / Sonnet / Haiku (local CLI)** |
| AI Model | GPT-5.2 | **Claude Opus / Sonnet / Haiku** |
| Runtime | Browser (cloud) | **Native desktop (Tauri 2 + Rust)** |
| LaTeX | Cloud compilation | **Tectonic (embedded, offline)** |
| Python Environment | — | **Built-in uv + venv — one-click scientific Python setup** |
| Scientific Skills | — | **100+ domain skills (bioinformatics, cheminformatics, ML, ...)** |
| Getting Started | Account setup required | **Install and go — template gallery + project wizard** |
| Version Control | — | **Git-based history with labels & diff** |
| Data Privacy | Cloud storage, [opt-out available](https://help.openai.com/en/articles/8983082-how-do-i-turn-off-model-training-to-stop-openai-training-models-on-my-conversations) | **Local execution, [opt-out available](https://code.claude.com/docs/en/data-usage)** |
| Source Code | Proprietary | **Open source (MIT)** |
### Your Research, Your Machine
### Data & Privacy
ClaudePrism invokes **Claude Code** as a local subprocess. Your documents stay on disk — only prompts are sent to the Claude API for inference.
**How the two compare on data:**
| | OpenAI Prism | ClaudePrism (via Claude Code) |
|---|---|---|
| Where documents live | OpenAI cloud servers | **Your local disk only** |
| Model training opt-out | [Available](https://help.openai.com/en/articles/8983082-how-do-i-turn-off-model-training-to-stop-openai-training-models-on-my-conversations) (Settings > Data Controls) | [Available](https://claude.ai/settings/data-privacy-controls) (Privacy Settings or API key) |
| **Data exposure after opt-out** | **Documents remain on OpenAI servers** | **Nothing leaves your machine** |
| **Feedback pitfall** | **👍/👎 sends entire conversation to training** | **No such mechanism** |
| Default training policy | ON (personal accounts) | ON (Consumer), **OFF** (API key / Team / Enterprise) |
| Data retention | ~30 days after opt-out | 30 days (opt-out) / 5 years (opt-in) |
| Zero data retention | Not available | Available on Enterprise |
| Telemetry | Cannot disable | `DISABLE_TELEMETRY=1` to fully disable |
| Source code | Proprietary | Open source — [audit it yourself](https://github.com/delibae/claude-prism) |
> **Both tools allow opting out of model training.** The critical difference is where your documents live. With Prism, your unpublished research exists on OpenAI's servers regardless of training settings. With ClaudePrism, files never leave your machine — only prompts are sent for AI inference.
ClaudePrism stores and compiles your documents locally — nothing is uploaded to a remote server for storage. However, when you use AI features, **prompts and file contents that Claude reads are sent to Anthropic's API for inference**, just like any cloud-based LLM tool. See [Claude Code data usage](https://code.claude.com/docs/en/data-usage) for retention policies and opt-out options.
---

View file

@ -5,8 +5,8 @@
<h1 align="center">ClaudePrism</h1>
<p align="center">
由 Claude 驱动的私密、离线优先的科学写作工作区。<br/>
LaTeX + Python + 100 多个科学技能 — 全部在本地运行。
由 Claude 驱动的离线优先科学写作工作区。<br/>
LaTeX + Python + 100 多个科学技能 — 在桌面端运行。
</p>
<p align="center">
@ -22,9 +22,10 @@
<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_1.0.0_aarch64.dmg">macOS</a> ·
<a href="https://github.com/delibae/claude-prism/releases/latest/download/ClaudePrism_1.0.0_x64-setup.exe">Windows</a> ·
<a href="https://github.com/delibae/claude-prism/releases/latest/download/ClaudePrism_1.0.0_amd64.deb">Linux</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">所有版本</a>
</p>
@ -32,41 +33,24 @@
## 为什么选择 ClaudePrism?
[OpenAI Prism](https://openai.com/prism/) 是一个面向科学家的云端 LaTeX 工作区 — 免费、强大,但**您未发表的研究存储在 OpenAI 的服务器上**。专家们对知识产权暴露及 OpenAI 是否可能主张研究数据权利表示担忧。默认情况下,内容可能被用于训练未来的模型,除非您选择退出。
[OpenAI Prism](https://openai.com/prism/) 是一个云端 LaTeX 工作区 — 使用它需要将所有文件和数据上传到 OpenAI 的服务器。
ClaudePrism 采用不同的方式:**一切都在您的本地机器上运行。**
ClaudePrism 是**本地优先**的替代方案 — 文件存储在本地磁盘,离线编译。AI 功能通过 Anthropic API 发送内容进行推理(参见[数据使用政策](https://code.claude.com/docs/en/data-usage))。
| | OpenAI Prism | ClaudePrism |
|---|:---:|:---:|
| AI 模型 | GPT-5.2(云端) | **Claude Opus / Sonnet / Haiku(本地 CLI)** |
| AI 模型 | GPT-5.2 | **Claude Opus / Sonnet / Haiku** |
| 运行环境 | 浏览器(云端) | **原生桌面应用(Tauri 2 + Rust)** |
| LaTeX | 云端编译 | **Tectonic(内嵌,离线)** |
| Python 环境 | — | **内置 uv + venv — 一键科学 Python 环境** |
| 科学技能 | — | **100+ 领域技能(生物信息学、化学信息学、ML 等)** |
| 快速开始 | 需要注册账户 | **安装即用 — 模板库 + 项目向导** |
| 版本控制 | — | **基于 Git 的历史记录,支持标签和差异对比** |
| 数据隐私 | 云存储,[可选择退出](https://help.openai.com/en/articles/8983082-how-do-i-turn-off-model-training-to-stop-openai-training-models-on-my-conversations) | **本地执行,[可选择退出](https://code.claude.com/docs/en/data-usage)** |
| 源代码 | 专有 | **开源(MIT)** |
### 您的研究,您的机器
### 数据与隐私
ClaudePrism 将 **Claude Code** 作为本地子进程调用。您的文档保存在磁盘上 — 仅提示词被发送到 Claude API 进行推理。
**两者在数据方面的对比:**
| | OpenAI Prism | ClaudePrism(通过 Claude Code) |
|---|---|---|
| 文档存储位置 | OpenAI 云服务器 | **仅在您的本地磁盘** |
| 模型训练退出 | [可用](https://help.openai.com/en/articles/8983082-how-do-i-turn-off-model-training-to-stop-openai-training-models-on-my-conversations)(设置 > 数据控制) | [可用](https://claude.ai/settings/data-privacy-controls)(隐私设置或 API 密钥) |
| **退出后的数据暴露** | **文档仍保留在 OpenAI 服务器上** | **没有任何数据离开您的机器** |
| **反馈陷阱** | **点赞/点踩会将整个对话发送用于训练** | **无此机制** |
| 默认训练策略 | 开启(个人账户) | 开启(消费者),**关闭**(API 密钥 / 团队 / 企业) |
| 数据保留 | 退出后约 30 天 | 30 天(退出)/ 5 年(加入) |
| 零数据保留 | 不可用 | 企业版可用 |
| 遥测 | 无法禁用 | `DISABLE_TELEMETRY=1` 完全禁用 |
| 源代码 | 专有 | 开源 — [自行审计](https://github.com/delibae/claude-prism) |
> **两个工具都允许退出模型训练。** 关键区别在于您的文档存储在哪里。使用 Prism,您未发表的研究存在于 OpenAI 的服务器上,无论训练设置如何。使用 ClaudePrism,文件永远不会离开您的机器 — 仅提示词被发送用于 AI 推理。
文档在本地存储和编译,不会上传到远程服务器。但使用 AI 功能时,**提示词和 Claude 读取的文件内容会发送到 Anthropic API 进行推理**,这与所有云端 LLM 工具相同。有关保留政策和退出选项,请参阅 [Claude Code 数据使用政策](https://code.claude.com/docs/en/data-usage)。
---

View file

@ -1,6 +1,6 @@
{
"name": "@claude-prism/desktop",
"version": "1.0.0",
"version": "1.3.0",
"private": true,
"type": "module",
"scripts": {
@ -26,11 +26,13 @@
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/utilities": "^3.2.2",
"@radix-ui/react-slot": "^1.2.4",
"@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",
"@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/plugin-updater": "~2.10.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
@ -66,7 +68,8 @@
"jsdom": "^26.1.0",
"tailwindcss": "^4.1.18",
"typescript": "^5.9.3",
"vite": "^6.3.5",
"vitest": "^3.1.1"
"vite": "^6.4.2",
"vite-plugin-top-level-await": "^1.6.0",
"vitest": "^4.1.0"
}
}

View file

@ -104,7 +104,9 @@ 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 {
@ -112,7 +114,8 @@ 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 {
@ -125,7 +128,9 @@ 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

@ -1,12 +0,0 @@
# 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 }

7688
apps/desktop/src-tauri/Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
[package]
name = "claude-prism-desktop"
version = "1.0.0"
version = "1.3.0"
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"] }
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream", "system-proxy", "socks"] }
hmac = "0.12"
sha1 = "0.10"
base64 = "0.22"
@ -51,3 +51,10 @@ tempfile = "3"
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,5 +6,20 @@ 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,28 +15,132 @@
"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:scope",
"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",
"allow": [
{ "path": "$HOME/**" },
{ "path": "$DOCUMENT/**" },

View file

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

File diff suppressed because one or more lines are too long

View file

@ -1 +0,0 @@
{"default":{"identifier":"default","description":"Default capabilities for the main window","local":true,"windows":["*"],"permissions":["core:default","core:window:default","core:window:allow-create","core:window:allow-set-title","core:window:allow-show","core:window:allow-start-dragging","core:window:allow-minimize","core:window:allow-maximize","core:window:allow-toggle-maximize","core:window:allow-close","core:webview:allow-create-webview-window","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:scope","allow":[{"path":"$HOME/**"},{"path":"$DOCUMENT/**"},{"path":"$DESKTOP/**"},{"path":"$TEMP/**"}]},"shell:default","shell:allow-open","shell:allow-spawn","shell:allow-stdin-write","shell:allow-kill","process:default","process:allow-exit"]}}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,614 @@
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

@ -0,0 +1,899 @@
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

@ -0,0 +1,214 @@
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

@ -0,0 +1,925 @@
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

@ -0,0 +1,561 @@
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

@ -0,0 +1,148 @@
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

@ -0,0 +1,368 @@
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

@ -1,6 +1,4 @@
use git2::{
DiffOptions, IndexAddOption, Oid, Repository, RepositoryInitOptions, Signature, Sort,
};
use git2::{DiffOptions, IndexAddOption, Oid, Repository, RepositoryInitOptions, Signature, Sort};
use serde::Serialize;
use std::collections::HashMap;
use std::fs;
@ -49,10 +47,11 @@ fn tag_map(repo: &Repository) -> HashMap<Oid, Vec<String>> {
if let Ok(tags) = repo.tag_names(None) {
for tag_name in tags.iter().flatten() {
if let Ok(reference) = repo.revparse_single(tag_name) {
let oid = reference.peel_to_commit().map(|c| c.id()).unwrap_or(reference.id());
map.entry(oid)
.or_default()
.push(tag_name.to_string());
let oid = reference
.peel_to_commit()
.map(|c| c.id())
.unwrap_or(reference.id());
map.entry(oid).or_default().push(tag_name.to_string());
}
}
}
@ -107,10 +106,7 @@ Thumbs.db
}
// Configure the repo to use this excludes file
if let Ok(mut config) = repo.config() {
let _ = config.set_str(
"core.excludesFile",
&excludes_path.to_string_lossy(),
);
let _ = config.set_str("core.excludesFile", &excludes_path.to_string_lossy());
}
}
@ -122,8 +118,8 @@ pub fn history_init(project_root: String) -> Result<(), String> {
if git_dir.exists() {
// Already initialized — verify and ensure excludes
let repo = Repository::open(&git_dir)
.map_err(|e| format!("Corrupt history repo: {}", e))?;
let repo =
Repository::open(&git_dir).map_err(|e| format!("Corrupt history repo: {}", e))?;
ensure_excludes(&project_root, &repo);
return Ok(());
}
@ -166,14 +162,24 @@ pub fn history_init(project_root: String) -> Result<(), String> {
.map_err(|e| format!("Failed to find tree: {}", e))?;
let sig = default_signature()?;
repo.commit(Some("HEAD"), &sig, &sig, "[init] Project opened", &tree, &[])
.map_err(|e| format!("Failed to create initial commit: {}", e))?;
repo.commit(
Some("HEAD"),
&sig,
&sig,
"[init] Project opened",
&tree,
&[],
)
.map_err(|e| format!("Failed to create initial commit: {}", e))?;
Ok(())
}
#[tauri::command]
pub fn history_snapshot(project_root: String, message: String) -> Result<Option<SnapshotInfo>, String> {
pub fn history_snapshot(
project_root: String,
message: String,
) -> Result<Option<SnapshotInfo>, String> {
let repo = open_repo(&project_root)?;
let mut index = repo
@ -219,10 +225,7 @@ pub fn history_snapshot(project_root: String, message: String) -> Result<Option<
.map_err(|e| format!("Failed to find tree: {}", e))?;
let sig = default_signature()?;
let parent = repo
.head()
.ok()
.and_then(|h| h.peel_to_commit().ok());
let parent = repo.head().ok().and_then(|h| h.peel_to_commit().ok());
let parents: Vec<&git2::Commit> = parent.iter().collect();
let oid = repo
@ -236,7 +239,9 @@ pub fn history_snapshot(project_root: String, message: String) -> Result<Option<
.map(|d| {
d.deltas()
.filter_map(|delta| {
delta.new_file().path()
delta
.new_file()
.path()
.or_else(|| delta.old_file().path())
.map(|p| p.to_string_lossy().to_string())
})
@ -268,8 +273,12 @@ pub fn history_list(
let mut revwalk = repo
.revwalk()
.map_err(|e| format!("Failed to create revwalk: {}", e))?;
revwalk.push_head().map_err(|e| format!("Failed to push HEAD: {}", e))?;
revwalk.set_sorting(Sort::TIME).map_err(|e| format!("Sort error: {}", e))?;
revwalk
.push_head()
.map_err(|e| format!("Failed to push HEAD: {}", e))?;
revwalk
.set_sorting(Sort::TIME)
.map_err(|e| format!("Sort error: {}", e))?;
let mut snapshots = Vec::new();
let mut count = 0u32;
@ -301,7 +310,9 @@ pub fn history_list(
.map(|d| {
d.deltas()
.filter_map(|delta| {
delta.new_file().path()
delta
.new_file()
.path()
.or_else(|| delta.old_file().path())
.map(|p| p.to_string_lossy().to_string())
})
@ -347,9 +358,7 @@ pub fn history_diff(
let from_tree = from_commit
.tree()
.map_err(|e| format!("Tree error: {}", e))?;
let to_tree = to_commit
.tree()
.map_err(|e| format!("Tree error: {}", e))?;
let to_tree = to_commit.tree().map_err(|e| format!("Tree error: {}", e))?;
let mut diff_opts = DiffOptions::new();
let diff = repo
@ -421,9 +430,7 @@ pub fn history_file_at(
let commit = repo
.find_commit(oid)
.map_err(|e| format!("Commit not found: {}", e))?;
let tree = commit
.tree()
.map_err(|e| format!("Tree error: {}", e))?;
let tree = commit.tree().map_err(|e| format!("Tree error: {}", e))?;
let entry = tree
.get_path(Path::new(&file_path))
.map_err(|e| format!("File not found in snapshot: {}", e))?;
@ -439,18 +446,13 @@ pub fn history_file_at(
}
#[tauri::command]
pub fn history_restore(
project_root: String,
snapshot_id: String,
) -> Result<SnapshotInfo, String> {
pub fn history_restore(project_root: String, snapshot_id: String) -> Result<SnapshotInfo, String> {
let repo = open_repo(&project_root)?;
let oid = Oid::from_str(&snapshot_id).map_err(|e| format!("Invalid snapshot_id: {}", e))?;
let commit = repo
.find_commit(oid)
.map_err(|e| format!("Commit not found: {}", e))?;
let tree = commit
.tree()
.map_err(|e| format!("Tree error: {}", e))?;
let tree = commit.tree().map_err(|e| format!("Tree error: {}", e))?;
// Checkout the tree to working directory
repo.checkout_tree(
@ -460,9 +462,7 @@ pub fn history_restore(
.map_err(|e| format!("Checkout failed: {}", e))?;
// Create a new "restore" commit on HEAD (not moving HEAD to old commit)
let mut index = repo
.index()
.map_err(|e| format!("Index error: {}", e))?;
let mut index = repo.index().map_err(|e| format!("Index error: {}", e))?;
index
.add_all(["*"].iter(), IndexAddOption::DEFAULT, None)
.map_err(|e| format!("Add error: {}", e))?;
@ -476,10 +476,7 @@ pub fn history_restore(
.map_err(|e| format!("Find tree error: {}", e))?;
let sig = default_signature()?;
let head_commit = repo
.head()
.ok()
.and_then(|h| h.peel_to_commit().ok());
let head_commit = repo.head().ok().and_then(|h| h.peel_to_commit().ok());
let parents: Vec<&git2::Commit> = head_commit.iter().collect();
let short_id = &snapshot_id[..8.min(snapshot_id.len())];
@ -687,7 +684,9 @@ mod tests {
history_init(r.clone()).unwrap();
fs::write(dir.path().join("main.tex"), "new content").unwrap();
let snap = history_snapshot(r.clone(), "update".into()).unwrap().unwrap();
let snap = history_snapshot(r.clone(), "update".into())
.unwrap()
.unwrap();
let list = history_list(r.clone(), 10, 0).unwrap();
let from_id = list[1].id.clone(); // init
@ -708,7 +707,9 @@ mod tests {
history_init(r.clone()).unwrap();
fs::write(dir.path().join("b.tex"), "new file").unwrap();
let snap = history_snapshot(r.clone(), "add b".into()).unwrap().unwrap();
let snap = history_snapshot(r.clone(), "add b".into())
.unwrap()
.unwrap();
let list = history_list(r.clone(), 10, 0).unwrap();
let from_id = list[1].id.clone(); // init
@ -847,7 +848,10 @@ mod tests {
ensure_excludes(&r, &repo);
let content = fs::read_to_string(&excludes_path).unwrap();
assert!(content.contains(".prism/"), "should migrate to include .prism/");
assert!(
content.contains(".prism/"),
"should migrate to include .prism/"
);
}
// ─── edge cases ───
@ -861,7 +865,9 @@ mod tests {
// Delete a file
fs::remove_file(dir.path().join("b.tex")).unwrap();
let snap = history_snapshot(r.clone(), "delete b".into()).unwrap().unwrap();
let snap = history_snapshot(r.clone(), "delete b".into())
.unwrap()
.unwrap();
assert!(!snap.changed_files.is_empty());
}
@ -875,7 +881,9 @@ mod tests {
let init_id = list[0].id.clone();
fs::remove_file(dir.path().join("b.tex")).unwrap();
let snap = history_snapshot(r.clone(), "delete b".into()).unwrap().unwrap();
let snap = history_snapshot(r.clone(), "delete b".into())
.unwrap()
.unwrap();
let diffs = history_diff(r, init_id, snap.id).unwrap();
let d = diffs.iter().find(|d| d.file_path == "b.tex").unwrap();

View file

@ -6,6 +6,14 @@ 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,
@ -43,18 +51,34 @@ fn extract_error_lines(log: &str) -> String {
return String::new();
}
// 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 lines: Vec<&str> = log.lines().collect();
if !error_lines.is_empty() {
return error_lines.join("\n");
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 log.lines().any(|l| l.contains("No pages of output")) {
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")) {
return "No pages of output. Add visible content to the document body.".to_string();
}
@ -102,6 +126,102 @@ 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)?;
@ -134,24 +254,62 @@ 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 dst_path = dst.join(entry.file_name());
let file_name = entry.file_name();
let dst_path = dst.join(&file_name);
if src_path.is_dir() {
let name = entry.file_name();
if name.to_string_lossy().starts_with('.') {
let name = file_name.to_string_lossy();
if name.starts_with('.') || matches!(name.as_ref(), "node_modules" | "target" | "dist")
{
continue;
}
sync_source_files(&src_path, &dst_path)?;
} else {
let ext = src_path.extension().and_then(|e| e.to_str()).unwrap_or("");
let is_artifact = matches!(ext, "aux" | "log" | "toc" | "lof" | "lot" | "out"
| "nav" | "snm" | "vrb" | "bbl" | "blg" | "fls" | "fdb_latexmk"
| "synctex" | "idx" | "ind" | "ilg" | "glo" | "gls" | "glg"
| "fmt" | "xdv");
let is_artifact = matches!(
ext,
"aux"
| "log"
| "toc"
| "lof"
| "lot"
| "out"
| "nav"
| "snm"
| "vrb"
| "bbl"
| "blg"
| "fls"
| "fdb_latexmk"
| "synctex"
| "idx"
| "ind"
| "ilg"
| "glo"
| "gls"
| "glg"
| "fmt"
| "xdv"
);
let is_synctex = src_path.to_string_lossy().ends_with(".synctex.gz");
if !is_artifact && !is_synctex {
// 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)?;
@ -199,7 +357,7 @@ fn lower_thread_priority() {
// --- Tectonic Compilation ---
fn compile_with_tectonic(work_dir: &Path, main_file: &str) -> Result<(), String> {
pub(crate) 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;
@ -209,9 +367,12 @@ fn compile_with_tectonic(work_dir: &Path, main_file: &str) -> Result<(), String>
let config = PersistentConfig::open(false)
.map_err(|e| format!("Failed to open tectonic config: {}", e))?;
let bundle = config
.default_bundle(false, &mut status)
.map_err(|e| format!("Failed to load tectonic bundle (check network connection): {}", e))?;
let bundle = config.default_bundle(false, &mut status).map_err(|e| {
format!(
"Failed to load tectonic bundle (check network connection): {}",
e
)
})?;
let format_cache = config
.format_cache_path()
@ -236,13 +397,230 @@ fn compile_with_tectonic(work_dir: &Path, main_file: &str) -> Result<(), String>
.create(&mut status)
.map_err(|e| format!("Failed to create tectonic session: {}", e))?;
session
.run(&mut status)
.map_err(|e| format!("{}", e))?;
session.run(&mut status).map_err(|e| format!("{}", e))?;
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 {
@ -318,7 +696,10 @@ fn parse_synctex_data(
// Convert synctex internal units to PDF points (bp)
// 1 TeX pt = 65536 sp; 1 inch = 72.27 TeX pt = 72 PDF bp
let factor = unit * magnification / (1000.0 * 65536.0) * 72.0 / 72.27;
if let Some(node) = line.get(1..).and_then(|s| parse_synctex_node(s, factor, x_offset, y_offset)) {
if let Some(node) = line
.get(1..)
.and_then(|s| parse_synctex_node(s, factor, x_offset, y_offset))
{
nodes.push(node);
}
}
@ -350,12 +731,7 @@ fn parse_synctex_data(
/// Parse a synctex node record (after stripping the type character).
/// Format: `<tag>,<line>,<column>:<h>,<v>[:<W>,<H>,<D>]`
fn parse_synctex_node(
s: &str,
factor: f64,
x_offset: f64,
y_offset: f64,
) -> Option<SynctexNode> {
fn parse_synctex_node(s: &str, factor: f64, x_offset: f64, y_offset: f64) -> Option<SynctexNode> {
let colon_parts: Vec<&str> = s.splitn(4, ':').collect();
if colon_parts.len() < 2 {
return None;
@ -387,11 +763,50 @@ fn parse_synctex_node(
// --- 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
@ -411,6 +826,7 @@ 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()
@ -441,10 +857,15 @@ pub async fn compile_latex(
}
eprintln!(
"[latex] +{:.0}ms {} ({})",
"[latex] +{:.0}ms {} ({}, backend={})",
t0.elapsed().as_millis(),
if is_reuse { "sync source files" } else { "full copy" },
if is_reuse { "reuse" } else { "first build" }
if is_reuse {
"sync source files"
} else {
"full copy"
},
if is_reuse { "reuse" } else { "first build" },
if use_texlive { "texlive" } else { "tectonic" }
);
// Remove stale PDF so a failed compile doesn't return the previous result.
@ -461,41 +882,70 @@ pub async fn compile_latex(
}
// Detect TeX engine from magic comment
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 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(),
);
}
}
// 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 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
};
let log_path = work_dir.join(format!("{}.log", main_file_name));
// 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() {
// 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() {
let log_path_clone = log_path.clone();
let main_tex = work_dir.join(&main_file);
let pdf_path_clone = pdf_path.clone();
@ -526,7 +976,7 @@ pub async fn compile_latex(
if needs_retry {
let retry_result = tokio::task::spawn_blocking(move || {
compile_with_tectonic(&work_dir_clone, &main_file_clone)
compile_with_tectonic_subprocess(&work_dir_clone, &main_file_clone)
})
.await
.map_err(|e| format!("Retry task panicked: {}", e))?;
@ -557,9 +1007,10 @@ 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={}) pdf_size={}KB",
"[latex] +{:.0}ms total (reuse={}, backend={}) pdf_size={}KB",
t0.elapsed().as_millis(),
is_reuse,
backend_label,
pdf_bytes.len() / 1024
);
Ok(tauri::ipc::Response::new(pdf_bytes))
@ -574,7 +1025,7 @@ pub async fn compile_latex(
} else {
details
};
Err(format!("Compilation failed\n\n{}", msg))
Err(format!("Compilation failed ({})\n\n{}", backend_label, msg))
}
}
@ -652,6 +1103,45 @@ 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]
@ -663,7 +1153,10 @@ mod tests {
fn test_extract_error_lines_no_pages() {
let log = "Some preamble\nNo pages of output.\nSome trailing";
let result = extract_error_lines(log);
assert_eq!(result, "No pages of output. Add visible content to the document body.");
assert_eq!(
result,
"No pages of output. Add visible content to the document body."
);
}
#[test]
@ -696,8 +1189,9 @@ 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 <= 10);
assert!(count <= 120);
}
// --- persistent_build_dir ---
@ -920,8 +1414,15 @@ Postamble:
fn test_extract_error_lines_real_errors_over_no_pages() {
let log = "Some preamble\n! LaTeX Error: File `missing.sty' not found.\nNo pages of output.\nMore stuff";
let result = extract_error_lines(log);
assert!(result.contains("LaTeX Error"), "real error should be shown, got: {}", result);
assert!(!result.contains("Add visible content"), "No pages fallback should NOT appear");
assert!(
result.contains("LaTeX Error"),
"real error should be shown, got: {}",
result
);
assert!(
!result.contains("Add visible content"),
"No pages fallback should NOT appear"
);
}
// --- has_real_errors ---
@ -998,14 +1499,25 @@ Postamble:
std::fs::create_dir_all(src.path().join("sub").join("deep")).unwrap();
std::fs::write(src.path().join("top.tex"), "top").unwrap();
std::fs::write(src.path().join("sub").join("mid.tex"), "mid").unwrap();
std::fs::write(src.path().join("sub").join("deep").join("bottom.tex"), "bottom").unwrap();
std::fs::write(
src.path().join("sub").join("deep").join("bottom.tex"),
"bottom",
)
.unwrap();
copy_dir_recursive(src.path(), dst.path()).unwrap();
assert_eq!(std::fs::read_to_string(dst.path().join("top.tex")).unwrap(), "top");
assert_eq!(std::fs::read_to_string(dst.path().join("sub").join("mid.tex")).unwrap(), "mid");
assert_eq!(
std::fs::read_to_string(dst.path().join("sub").join("deep").join("bottom.tex")).unwrap(),
std::fs::read_to_string(dst.path().join("top.tex")).unwrap(),
"top"
);
assert_eq!(
std::fs::read_to_string(dst.path().join("sub").join("mid.tex")).unwrap(),
"mid"
);
assert_eq!(
std::fs::read_to_string(dst.path().join("sub").join("deep").join("bottom.tex"))
.unwrap(),
"bottom"
);
}
@ -1052,9 +1564,18 @@ Postamble:
sync_source_files(src.path(), dst.path()).unwrap();
assert_eq!(std::fs::read_to_string(dst.path().join("main.tex")).unwrap(), "doc");
assert_eq!(std::fs::read_to_string(dst.path().join("refs.bib")).unwrap(), "bib");
assert_eq!(std::fs::read_to_string(dst.path().join("style.sty")).unwrap(), "sty");
assert_eq!(
std::fs::read_to_string(dst.path().join("main.tex")).unwrap(),
"doc"
);
assert_eq!(
std::fs::read_to_string(dst.path().join("refs.bib")).unwrap(),
"bib"
);
assert_eq!(
std::fs::read_to_string(dst.path().join("style.sty")).unwrap(),
"sty"
);
}
#[test]

View file

@ -1,4 +1,8 @@
#![recursion_limit = "512"]
mod anthropic_proxy;
mod claude;
mod claude_process;
mod history;
mod latex;
mod skills;
@ -7,7 +11,15 @@ mod uv;
mod zotero;
use std::path::Path;
use tauri::{Manager, WebviewUrl, WebviewWindowBuilder};
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)
}
// --- External editor detection & opening ---
@ -24,10 +36,26 @@ struct EditorDef {
}
const KNOWN_EDITORS: &[EditorDef] = &[
EditorDef { id: "cursor", name: "Cursor", cli: "cursor" },
EditorDef { id: "vscode", name: "VS Code", cli: "code" },
EditorDef { id: "zed", name: "Zed", cli: "zed" },
EditorDef { id: "sublime", name: "Sublime Text", cli: "subl" },
EditorDef {
id: "cursor",
name: "Cursor",
cli: "cursor",
},
EditorDef {
id: "vscode",
name: "VS Code",
cli: "code",
},
EditorDef {
id: "zed",
name: "Zed",
cli: "zed",
},
EditorDef {
id: "sublime",
name: "Sublime Text",
cli: "subl",
},
];
#[cfg(target_os = "macos")]
@ -43,7 +71,10 @@ fn detect_editors() -> Vec<EditorInfo> {
KNOWN_EDITORS
.iter()
.filter(|e| is_editor_installed(e))
.map(|e| EditorInfo { id: e.id.to_string(), name: e.name.to_string() })
.map(|e| EditorInfo {
id: e.id.to_string(),
name: e.name.to_string(),
})
.collect()
}
@ -90,7 +121,8 @@ fn open_in_editor(
}
}
cmd.spawn().map_err(|e| format!("Failed to open {}: {}", editor.name, e))?;
cmd.spawn()
.map_err(|e| format!("Failed to open {}: {}", editor.name, e))?;
Ok(())
}
@ -124,12 +156,11 @@ fn resolve_editor_cli(cli: &str) -> Result<String, String> {
#[cfg(target_os = "macos")]
fn set_macos_app_icon() {
use objc2::{AnyThread, MainThreadMarker};
use objc2_foundation::NSData;
use objc2_app_kit::{NSApplication, NSImage};
use objc2_foundation::NSData;
let icon_bytes = include_bytes!("../icons/icon.png");
// We're in the setup callback which runs on the main thread
if let Some(mtm) = MainThreadMarker::new() {
unsafe {
let data = NSData::with_bytes(icon_bytes);
@ -143,15 +174,20 @@ fn set_macos_app_icon() {
#[tauri::command]
fn create_new_window(app: tauri::AppHandle) -> Result<(), String> {
let label = format!("window-{}", std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis());
let label = format!(
"window-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.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")]
@ -161,11 +197,248 @@ fn create_new_window(app: tauri::AppHandle) -> Result<(), String> {
.traffic_light_position(tauri::LogicalPosition::new(12.0, 12.0));
}
builder.build().map_err(|e| format!("Failed to create window: {}", e))?;
builder
.build()
.map_err(|e| format!("Failed to create window: {}", e))?;
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]
@ -173,6 +446,67 @@ 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]
@ -226,6 +560,7 @@ pub fn run() {
#[allow(clippy::expect_used)]
let app = tauri::Builder::default()
.plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_shell::init())
@ -233,32 +568,61 @@ pub fn run() {
.manage(claude::ClaudeProcessState::default())
.manage(latex::LatexCompilerState::default())
.manage(zotero::ZoteroOAuthState::default())
.setup(|_app| {
#[cfg(target_os = "macos")]
set_macos_app_icon();
.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(())
})
.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,
@ -276,8 +640,10 @@ 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,
@ -286,13 +652,62 @@ 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");
app.run(|app_handle, event| {
match event {
tauri::RunEvent::WindowEvent { label, event: tauri::WindowEvent::Destroyed, .. } => {
// Set the dock icon after the app is fully initialized.
// Doing this in setup() causes SIGBUS on first launch from signed
// binaries due to Gatekeeper App Translocation (#38).
#[cfg(target_os = "macos")]
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,
..
} => {
// Kill Claude process associated with this window
let claude_state = app_handle.state::<claude::ClaudeProcessState>();
let label_clone = label.clone();

View file

@ -2,5 +2,21 @@
#![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

@ -37,14 +37,22 @@ impl CommandFrontmatterRaw {
let allowed_tools = self.allowed_tools.and_then(|v| match v {
serde_yaml::Value::String(s) => {
let tools: Vec<String> = s.split_whitespace().map(|t| t.to_string()).collect();
if tools.is_empty() { None } else { Some(tools) }
if tools.is_empty() {
None
} else {
Some(tools)
}
}
serde_yaml::Value::Sequence(seq) => {
let tools: Vec<String> = seq
.into_iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect();
if tools.is_empty() { None } else { Some(tools) }
if tools.is_empty() {
None
} else {
Some(tools)
}
}
_ => None,
});
@ -73,7 +81,10 @@ fn parse_markdown_with_frontmatter(content: &str) -> (Option<CommandFrontmatter>
if let Some(end) = frontmatter_end {
let frontmatter_content = lines.get(1..end).map(|s| s.join("\n")).unwrap_or_default();
let body_content = lines.get((end + 1)..).map(|s| s.join("\n")).unwrap_or_default();
let body_content = lines
.get((end + 1)..)
.map(|s| s.join("\n"))
.unwrap_or_default();
match serde_yaml::from_str::<CommandFrontmatterRaw>(&frontmatter_content) {
Ok(raw) => (Some(raw.into_parsed()), body_content),
@ -101,18 +112,15 @@ fn extract_command_info(file_path: &Path, base_path: &Path) -> Option<(String, O
Some((components.first()?.to_string(), None))
} else {
let command_name = components.last()?.to_string();
let namespace = components.get(..components.len() - 1)
let namespace = components
.get(..components.len() - 1)
.map(|s| s.join(":"))
.unwrap_or_default();
Some((command_name, Some(namespace)))
}
}
fn load_command_from_file(
file_path: &Path,
base_path: &Path,
scope: &str,
) -> Option<SlashCommand> {
fn load_command_from_file(file_path: &Path, base_path: &Path, scope: &str) -> Option<SlashCommand> {
let content = fs::read_to_string(file_path).ok()?;
let (frontmatter, body) = parse_markdown_with_frontmatter(&content);
let (name, namespace) = extract_command_info(file_path, base_path)?;
@ -192,23 +200,16 @@ fn load_skills_from_dir(dir: &Path, scope: &str) -> Vec<SlashCommand> {
return Vec::new();
}
let entries = match fs::read_dir(dir) {
Ok(e) => e,
Err(_) => 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();
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
for path in skill_dirs {
let Some(skill_md) = find_skill_md(&path) else {
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,
@ -240,6 +241,9 @@ 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,
@ -261,6 +265,51 @@ 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 {
@ -339,9 +388,7 @@ pub async fn slash_commands_list(
let mut md_files = Vec::new();
find_markdown_files(&user_commands_dir, &mut md_files);
for file_path in md_files {
if let Some(cmd) =
load_command_from_file(&file_path, &user_commands_dir, "user")
{
if let Some(cmd) = load_command_from_file(&file_path, &user_commands_dir, "user") {
commands.push(cmd);
}
}
@ -574,7 +621,10 @@ mod tests {
"Frontmatter with unknown fields (license, metadata) should parse successfully"
);
let fm = fm.unwrap();
assert_eq!(fm.description.unwrap(), "Comprehensive molecular biology toolkit.");
assert_eq!(
fm.description.unwrap(),
"Comprehensive molecular biology toolkit."
);
assert_eq!(fm.name.unwrap(), "biopython");
assert!(body.contains("# Biopython"));
}
@ -644,17 +694,33 @@ mod tests {
let skills = load_skills_from_dir(dir.path(), "skill");
assert_eq!(skills.len(), 2, "Should find 2 skills, skip dir without SKILL.md");
assert_eq!(
skills.len(),
2,
"Should find 2 skills, skip dir without SKILL.md"
);
let bio = skills.iter().find(|s| s.full_command == "/biopython").unwrap();
let bio = skills
.iter()
.find(|s| s.full_command == "/biopython")
.unwrap();
assert_eq!(bio.name, "biopython");
assert_eq!(bio.description.as_deref(), Some("Molecular biology toolkit."));
assert_eq!(
bio.description.as_deref(),
Some("Molecular biology toolkit.")
);
assert_eq!(bio.scope, "skill");
assert!(bio.accepts_arguments);
let custom = skills.iter().find(|s| s.full_command == "/custom-tool").unwrap();
let custom = skills
.iter()
.find(|s| s.full_command == "/custom-tool")
.unwrap();
assert_eq!(custom.name, "Custom Tool");
assert!(custom.description.is_none(), "No frontmatter = no description");
assert!(
custom.description.is_none(),
"No frontmatter = no description"
);
}
#[test]
@ -719,7 +785,11 @@ mod tests {
fn test_load_command_from_file_simple() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("greet.md");
fs::write(&file, "Hello $ARGUMENTS, run !`echo hi` and check @main.tex").unwrap();
fs::write(
&file,
"Hello $ARGUMENTS, run !`echo hi` and check @main.tex",
)
.unwrap();
let cmd = load_command_from_file(&file, dir.path(), "project").unwrap();
assert_eq!(cmd.name, "greet");
@ -775,7 +845,10 @@ mod tests {
find_markdown_files(dir.path(), &mut files);
assert_eq!(files.len(), 2);
let names: Vec<String> = files.iter().map(|f| f.file_name().unwrap().to_string_lossy().to_string()).collect();
let names: Vec<String> = files
.iter()
.map(|f| f.file_name().unwrap().to_string_lossy().to_string())
.collect();
assert!(names.contains(&"cmd1.md".to_string()));
assert!(names.contains(&"cmd2.md".to_string()));
}
@ -792,7 +865,10 @@ mod tests {
find_markdown_files(dir.path(), &mut files);
assert_eq!(files.len(), 1);
assert_eq!(files[0].file_name().unwrap().to_str().unwrap(), "visible.md");
assert_eq!(
files[0].file_name().unwrap().to_str().unwrap(),
"visible.md"
);
}
#[test]
@ -807,7 +883,10 @@ mod tests {
find_markdown_files(dir.path(), &mut files);
assert_eq!(files.len(), 2);
let names: Vec<String> = files.iter().map(|f| f.file_name().unwrap().to_string_lossy().to_string()).collect();
let names: Vec<String> = files
.iter()
.map(|f| f.file_name().unwrap().to_string_lossy().to_string())
.collect();
assert!(names.contains(&"top.md".to_string()));
assert!(names.contains(&"nested.md".to_string()));
}
@ -822,7 +901,10 @@ mod tests {
remove_empty_dirs(&deep);
assert!(!dir.path().join("a").exists(), "entire empty chain should be removed");
assert!(
!dir.path().join("a").exists(),
"entire empty chain should be removed"
);
}
#[test]
@ -888,7 +970,11 @@ mod tests {
assert_eq!(cmd.content, "Do something");
// Verify file was created
let file = dir.path().join(".claude").join("commands").join("test-cmd.md");
let file = dir
.path()
.join(".claude")
.join("commands")
.join("test-cmd.md");
assert!(file.exists());
assert_eq!(fs::read_to_string(&file).unwrap(), "Do something");
}
@ -943,7 +1029,13 @@ mod tests {
assert_eq!(cmd.full_command, "/tools:rust:clippy");
// Verify nested directory structure
let file = dir.path().join(".claude").join("commands").join("tools").join("rust").join("clippy.md");
let file = dir
.path()
.join(".claude")
.join("commands")
.join("tools")
.join("rust")
.join("clippy.md");
assert!(file.exists());
}

View file

@ -1,4 +1,4 @@
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use tauri::{Emitter, WebviewWindow};
use tokio::io::{AsyncBufReadExt, BufReader};
@ -7,6 +7,9 @@ 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.
@ -26,13 +29,16 @@ fn find_uv_binary() -> Result<String, String> {
];
#[cfg(target_os = "windows")]
let user_paths = vec![
// uv's default install location (same as Claude Code)
home.join(".local").join("bin").join("uv.exe"),
home.join(".cargo").join("bin").join("uv.exe"),
// uv's default Windows install location
PathBuf::from(
std::env::var("LOCALAPPDATA").unwrap_or_else(|_| {
home.join("AppData").join("Local").to_string_lossy().to_string()
}),
)
// %LOCALAPPDATA%\uv\bin\uv.exe
PathBuf::from(std::env::var("LOCALAPPDATA").unwrap_or_else(|_| {
home.join("AppData")
.join("Local")
.to_string_lossy()
.to_string()
}))
.join("uv")
.join("bin")
.join("uv.exe"),
@ -48,11 +54,7 @@ fn find_uv_binary() -> Result<String, String> {
// 3. Check standard paths (Unix only)
#[cfg(not(target_os = "windows"))]
{
let standard_paths = [
"/usr/local/bin/uv",
"/opt/homebrew/bin/uv",
"/usr/bin/uv",
];
let standard_paths = ["/usr/local/bin/uv", "/opt/homebrew/bin/uv", "/usr/bin/uv"];
for path in &standard_paths {
if PathBuf::from(path).exists() {
return Ok(path.to_string());
@ -111,6 +113,28 @@ 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();
@ -121,6 +145,73 @@ 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]
@ -141,7 +232,6 @@ 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();
@ -178,12 +268,17 @@ pub async fn install_uv(window: WebviewWindow) -> Result<(), String> {
let local_dir = home.join(".local");
let script = format!(
"mkdir -p '{}' && chown -R {} '{}'",
local_bin.display(), user, local_dir.display()
local_bin.display(),
user,
local_dir.display()
);
let output = std::process::Command::new("osascript")
.args([
"-e",
&format!("do shell script \"{}\" with administrator privileges", script),
&format!(
"do shell script \"{}\" with administrator privileges",
script
),
])
.output()
.map_err(|e| format!("Failed to fix permissions for ~/.local: {}", e))?;
@ -207,11 +302,12 @@ 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([
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-Command",
"irm https://astral.sh/uv/install.ps1 | iex",
]);
@ -221,24 +317,13 @@ 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
// Inherit essential environment variables (shared helper handles case-insensitive matching)
for (key, value) in std::env::vars() {
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"
{
if key.eq_ignore_ascii_case("PATH") || crate::claude::is_essential_env_var(&key) {
cmd.env(&key, &value);
}
}
crate::claude::apply_proxy_env_to_command(&mut cmd, Some(&window));
let mut child = cmd
.spawn()
@ -292,6 +377,7 @@ 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(),
@ -304,11 +390,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);
venv_cmd.args(["venv", &venv_dir.to_string_lossy()]);
let venv_arg = venv_dir.to_string_lossy().to_string();
venv_cmd.args(["venv", "--seed", venv_arg.as_str()]);
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
@ -322,6 +408,7 @@ 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(),
@ -349,10 +436,11 @@ 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
@ -393,10 +481,12 @@ 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

@ -18,8 +18,11 @@ fn consumer_key() -> String {
}
fn consumer_secret() -> String {
std::env::var("ZOTERO_CONSUMER_SECRET")
.unwrap_or_else(|_| option_env!("ZOTERO_CONSUMER_SECRET").unwrap_or("").to_string())
std::env::var("ZOTERO_CONSUMER_SECRET").unwrap_or_else(|_| {
option_env!("ZOTERO_CONSUMER_SECRET")
.unwrap_or("")
.to_string()
})
}
const REQUEST_TOKEN_URL: &str = "https://www.zotero.org/oauth/request";
@ -81,8 +84,8 @@ fn get_timestamp() -> String {
}
fn hmac_sha1(key: &str, message: &str) -> Result<String, String> {
let mut mac = HmacSha1::new_from_slice(key.as_bytes())
.map_err(|e| format!("Invalid HMAC key: {}", e))?;
let mut mac =
HmacSha1::new_from_slice(key.as_bytes()).map_err(|e| format!("Invalid HMAC key: {}", e))?;
mac.update(message.as_bytes());
Ok(BASE64.encode(mac.finalize().into_bytes()))
}
@ -322,7 +325,8 @@ pub async fn zotero_start_oauth(
let listener = TcpListener::bind("127.0.0.1:0")
.await
.map_err(|e| format!("Failed to bind local server: {}", e))?;
let port = listener.local_addr()
let port = listener
.local_addr()
.map_err(|e| format!("Failed to get local address: {}", e))?
.port();
let callback_url = format!("http://127.0.0.1:{}/callback", port);
@ -363,9 +367,7 @@ pub async fn zotero_complete_oauth(
}
#[tauri::command]
pub async fn zotero_cancel_oauth(
state: tauri::State<'_, ZoteroOAuthState>,
) -> Result<(), String> {
pub async fn zotero_cancel_oauth(state: tauri::State<'_, ZoteroOAuthState>) -> Result<(), String> {
*state.lock().await = None;
Ok(())
}
@ -404,7 +406,9 @@ mod tests {
// HMAC-SHA1("key", "The quick brown fox jumps over the lazy dog") is a known value
assert!(!result.is_empty());
// Base64 encoded, should contain only valid base64 chars
assert!(result.chars().all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '/' || c == '='));
assert!(result
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '/' || c == '='));
}
#[test]
@ -412,14 +416,25 @@ mod tests {
let params = vec![
("oauth_consumer_key".to_string(), "key123".to_string()),
("oauth_nonce".to_string(), "nonce".to_string()),
("oauth_signature_method".to_string(), "HMAC-SHA1".to_string()),
(
"oauth_signature_method".to_string(),
"HMAC-SHA1".to_string(),
),
("oauth_timestamp".to_string(), "1234567890".to_string()),
("oauth_version".to_string(), "1.0".to_string()),
];
let sig = oauth_signature("POST", "https://example.com/api", &params, "consumer_secret", "token_secret");
let sig = oauth_signature(
"POST",
"https://example.com/api",
&params,
"consumer_secret",
"token_secret",
);
assert!(!sig.is_empty());
// Should be valid base64
assert!(sig.chars().all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '/' || c == '='));
assert!(sig
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '/' || c == '='));
}
#[test]

View file

@ -0,0 +1,50 @@
/* 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.0.0",
"version": "1.3.0",
"build": {
"beforeDevCommand": "pnpm dev",
"beforeBuildCommand": "pnpm build",
"beforeDevCommand": "corepack pnpm dev",
"beforeBuildCommand": "corepack pnpm build",
"devUrl": "http://localhost:1420",
"frontendDist": "../dist"
},
@ -23,10 +23,12 @@
"visible": false,
"titleBarStyle": "Overlay",
"hiddenTitle": true,
"zoomHotkeysEnabled": true,
"trafficLightPosition": {
"x": 12,
"y": 12
}
},
"backgroundThrottling": "disabled"
}
],
"security": {
@ -36,6 +38,7 @@
},
"bundle": {
"active": true,
"createUpdaterArtifacts": true,
"icon": [
"icons/32x32.png",
"icons/128x128.png",
@ -55,5 +58,12 @@
}
}
},
"plugins": {}
"plugins": {
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IENBNjAzOTI2NzdDQjdBNkMKUldSc2VzdDNKamxneXMzUnZIK0h3TzZ4WVo4QVFVQlRaSFA1TEhCcGtUYW53ZkhNaDluaWgwZWQK",
"endpoints": [
"https://github.com/delibae/claude-prism/releases/latest/download/latest.json"
]
}
}
}

View file

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

View file

@ -1,25 +1,85 @@
import { ThemeProvider } from "next-themes";
import { ThemeProvider, useTheme } from "next-themes";
import { ErrorBoundary } from "react-error-boundary";
import { Toaster } from "@/components/ui/sonner";
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
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 { useEffect, useState } from "react";
import { lazy, Suspense, useEffect, useRef, useState } from "react";
import { invoke } from "@tauri-apps/api/core";
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 [showSkillsOnboarding, setShowSkillsOnboarding] = useState(false);
const autoResumedProjectRef = useRef<string | null>(null);
const chatProjectRef = useRef<string | null>(null);
// Update window title
useEffect(() => {
@ -29,15 +89,11 @@ function WorkspaceWithClaude() {
}
}, [projectRoot]);
// Show scientific skills onboarding on first launch
useEffect(() => {
if (!initialized) return;
if (shouldShowOnboarding()) {
// Small delay so the workspace renders first
const timer = setTimeout(() => setShowSkillsOnboarding(true), 800);
return () => clearTimeout(timer);
}
}, [initialized]);
if (chatProjectRef.current === projectRoot) return;
chatProjectRef.current = projectRoot;
useClaudeChatStore.getState().resetForProject(projectRoot ?? null);
}, [projectRoot]);
// Auto-setup Python venv when project opens
useEffect(() => {
@ -52,16 +108,71 @@ function WorkspaceWithClaude() {
}
})
.catch((err) => {
console.error("Failed to setup Python venv:", err);
log.error("Failed to setup Python venv", { error: String(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);
}
@ -69,39 +180,88 @@ function WorkspaceWithClaude() {
return () => clearTimeout(timer);
}, [initialized]);
return (
<>
<WorkspaceLayout />
<Toaster />
{showSkillsOnboarding && (
<ScientificSkillsOnboarding
onClose={() => setShowSkillsOnboarding(false)}
/>
)}
</>
);
return <WorkspaceLayout />;
}
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="light" enableSystem>
<ThemeProvider attribute="class" defaultTheme="system" 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>
</ErrorBoundary>

View file

@ -0,0 +1,184 @@
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

@ -0,0 +1,67 @@
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

@ -0,0 +1,52 @@
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

@ -0,0 +1,46 @@
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,110 +1,109 @@
import { describe, it, expect } from "vitest";
import { describe, it, expect, beforeEach, vi } from "vitest";
import { readDir, stat } from "@tauri-apps/plugin-fs";
import {
getProjectFileType,
scanProjectFolder,
shouldSkipProjectDirectory,
} from "@/lib/tauri/fs";
// 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("tauri fs helpers", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("classifies .bib files", () => {
expect(getFileType("refs.bib")).toBe("bib");
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 .pdf files", () => {
expect(getFileType("output.pdf")).toBe("pdf");
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 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");
});
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 style files", () => {
expect(getFileType("custom.sty")).toBe("style");
expect(getFileType("report.cls")).toBe("style");
expect(getFileType("plain.bst")).toBe("style");
});
if (dirPath === "/project/chapters") {
return [{ name: "intro.tex", isDirectory: false }] as any;
}
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();
});
throw new Error(`Unexpected readDir path: ${dirPath}`);
});
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();
});
const result = await scanProjectFolder("/project");
it("classifies unknown extensions as other", () => {
expect(getFileType("readme.txt")).toBe("other");
expect(getFileType("notes.md")).toBe("other");
expect(getFileType("data.csv")).toBe("other");
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);
});
});
});

View file

@ -67,7 +67,12 @@ 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,11 +1,54 @@
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("/"))),
@ -16,6 +59,7 @@ 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

@ -0,0 +1,444 @@
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,5 +1,17 @@
import { describe, it, expect } from "vitest";
import { offsetToLineCol } from "@/stores/claude-chat-store";
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 });
});
describe("offsetToLineCol", () => {
it("returns line 1, col 1 for offset 0 on empty string", () => {
@ -38,3 +50,258 @@ 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,4 +1,6 @@
import { describe, it, expect } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { invoke } from "@tauri-apps/api/core";
import { useClaudeSetupStore } from "@/stores/claude-setup-store";
// advanceSteps is module-private — replicate for testing
type StepStatus = "pending" | "active" | "complete" | "error";
@ -9,7 +11,11 @@ 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);
@ -91,3 +97,384 @@ 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,6 +1,20 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { writeTextFile } from "@tauri-apps/plugin-fs";
import { useDocumentStore, getCurrentPdfBytes, clearPdfBytesCache, type ProjectFile } from "@/stores/document-store";
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";
// Mock history store
vi.mock("@/stores/history-store", () => ({
@ -9,6 +23,7 @@ vi.mock("@/stores/history-store", () => ({
init: vi.fn(() => Promise.resolve()),
loadSnapshots: vi.fn(() => Promise.resolve()),
createSnapshot: vi.fn(() => Promise.resolve()),
reset: vi.fn(),
})),
},
}));
@ -37,6 +52,7 @@ function makeFile(overrides: Partial<ProjectFile> = {}): ProjectFile {
describe("useDocumentStore", () => {
beforeEach(() => {
vi.clearAllMocks();
clearPdfBytesCache();
useDocumentStore.setState({
projectRoot: "/project",
@ -54,6 +70,10 @@ describe("useDocumentStore", () => {
isSaving: false,
initialized: true,
});
useProjectStore.setState({
recentProjects: [],
lastProjectFolder: null,
});
});
describe("getActiveFile logic", () => {
@ -61,7 +81,9 @@ 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");
});
@ -69,11 +91,149 @@ 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");
@ -91,14 +251,18 @@ 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!");
});
@ -126,7 +290,9 @@ 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);
});
@ -138,13 +304,17 @@ 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");
});
@ -159,9 +329,13 @@ 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);
});
@ -169,7 +343,9 @@ 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
});
@ -186,9 +362,13 @@ 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", () => {
@ -203,7 +383,9 @@ 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);
});
@ -212,11 +394,18 @@ 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);
});
@ -255,6 +444,39 @@ 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", () => {
@ -268,7 +490,10 @@ 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);
});
@ -283,7 +508,9 @@ 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();
@ -308,19 +535,36 @@ 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();
@ -353,9 +597,11 @@ 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,11 +36,29 @@ function resetStores() {
messages: [],
sessionId: null,
isStreaming: false,
streamingStartedAt: null,
error: null,
totalInputTokens: 0,
totalOutputTokens: 0,
tabs: [{ id: "tab-default", title: "New Chat", sessionId: null, messages: [], isStreaming: false, error: null, totalInputTokens: 0, totalOutputTokens: 0, draft: { input: "", pinnedContexts: [] } }],
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: [] },
},
],
activeTabId: "tab-default",
activeProjectPath: "/project",
_cancelledByUser: false,
});
useProposedChangesStore.setState({ changes: [] });
@ -72,7 +90,9 @@ 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");
@ -162,9 +182,30 @@ 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);
@ -182,13 +223,17 @@ 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,
}));
@ -199,13 +244,41 @@ 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
@ -221,13 +294,98 @@ 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();
@ -257,7 +415,9 @@ 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);
});
@ -327,7 +487,9 @@ 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", () => {
@ -335,12 +497,20 @@ 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);
@ -368,7 +538,9 @@ 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,6 +30,18 @@ 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++) {
@ -43,12 +55,16 @@ 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",
);
});
});
@ -63,4 +79,38 @@ 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,13 +111,17 @@ 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,7 +35,9 @@ 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", () => {
@ -53,7 +55,9 @@ 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,7 +9,10 @@ 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 "fs";
import { resolve } from "path";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
/**
* Validates tauri.conf.json CSP (Content Security Policy) configuration.

View file

@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 2.5 KiB

View file

@ -0,0 +1,14 @@
<?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>

After

Width:  |  Height:  |  Size: 2.5 KiB

View file

@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 2.8 KiB

View file

@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 3.9 KiB

View file

@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 4.4 KiB

View file

@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 1.6 KiB

View file

@ -0,0 +1,15 @@
<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>

After

Width:  |  Height:  |  Size: 1.6 KiB

View file

@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 3.5 KiB

View file

@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 3.3 KiB

View file

@ -1,5 +1,3 @@
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,46 +1,125 @@
import { type FC, memo, useEffect, useMemo, useRef, useState } from "react";
import { AlertCircleIcon } from "lucide-react";
import { useClaudeChatStore, type ClaudeStreamMessage, type ContentBlock } from "@/stores/claude-chat-store";
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 { MarkdownRenderer } from "./markdown-renderer";
import { ThinkingWidget, ToolWidget } from "./tool-widgets";
// ─── Streaming Indicator (isolated to prevent re-render storms) ───
const StreamingIndicator: FC = memo(() => {
const [elapsed, setElapsed] = useState(0);
const startRef = useRef(Date.now());
const StreamingIndicator: FC<{ startedAt: number | null }> = memo(
({ startedAt }) => {
const calculateElapsed = () =>
startedAt ? Math.max(0, Math.floor((Date.now() - startedAt) / 1000)) : 0;
useEffect(() => {
startRef.current = Date.now();
setElapsed(0);
const timer = setInterval(() => {
setElapsed(Math.floor((Date.now() - startRef.current) / 1000));
}, 1000);
return () => clearInterval(timer);
}, []);
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;
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-xs text-muted-foreground/60">{elapsed}s</span>
<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" />
)}
</span>
</TooltipIconButton>
</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);
@ -76,11 +155,16 @@ 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;
}
@ -100,7 +184,7 @@ export const ChatMessages: FC = () => {
behavior: "smooth",
});
}
}, [displayMessages]);
}, [displayMessages, pendingGuidance]);
// Reset auto-scroll when streaming stops
useEffect(() => {
@ -113,7 +197,8 @@ 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;
@ -127,23 +212,36 @@ export const ChatMessages: FC = () => {
<div
ref={viewportRef}
onScroll={handleScroll}
className="absolute inset-0 overflow-y-auto scroll-smooth px-4 py-2"
className="absolute inset-0 overflow-y-auto scroll-smooth px-4 pt-4"
>
{displayMessages.length === 0 && !isStreaming && (
<div className="flex h-full items-center justify-center text-muted-foreground text-sm">
Ask Claude about your LaTeX document...
{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} />
</div>
)}
{displayMessages.map((msg, idx) => (
<MessageBubble
key={idx}
message={msg}
toolResultMap={toolResultMap}
/>
{pendingGuidance.map((guidance) => (
<div
key={guidance.id}
className={cn("mx-auto w-full", THREAD_MAX_WIDTH)}
>
<PendingGuidanceMessage guidance={guidance} />
</div>
))}
{isStreaming && <StreamingIndicator />}
</div>
);
};
@ -181,23 +279,28 @@ const UserMessage: FC<{ message: ClaudeStreamMessage }> = ({ message }) => {
if (!textContent) return null;
// 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;
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 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
@ -206,23 +309,34 @@ const UserMessage: FC<{ message: ClaudeStreamMessage }> = ({ message }) => {
errors: { message: string; location?: string }[],
prompt: string,
) => (
<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 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>
<span className="text-muted-foreground">{prompt}</span>
</div>
<span className="text-muted-foreground">{prompt}</span>
</div>
<div className="col-span-full col-start-1 row-start-2 -mr-1 flex justify-end">
<MessageActions text={bodyText} align="right" />
</div>
</div>
);
@ -238,18 +352,26 @@ 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,
@ -258,18 +380,23 @@ const UserMessage: FC<{ message: ClaudeStreamMessage }> = ({ message }) => {
}
return (
<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 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>
</div>
);
@ -277,6 +404,40 @@ 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>;
@ -288,24 +449,20 @@ 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="w-full py-1.5">
<div className="px-1 text-foreground text-sm leading-relaxed">
<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">
{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
@ -317,17 +474,23 @@ 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 (
<ToolWidget
<ThinkingWidget
key={idx}
toolUse={block}
toolResult={result}
thinking={block.thinking}
signature={block.signature}
/>
);
}
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>
);
};
@ -341,8 +504,8 @@ const ResultMessage: FC<{ message: ClaudeStreamMessage }> = ({ message }) => {
if (!resultText) return null;
return (
<div className="w-full py-1.5">
<div className="px-1 text-foreground text-sm leading-relaxed">
<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">
{isError ? (
<div className="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-destructive text-sm">
{resultText}
@ -354,6 +517,9 @@ 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,8 +14,14 @@ 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
@ -72,10 +78,10 @@ export function ChatTabBar() {
);
return (
<div className="flex items-center border-b border-border">
<div className="flex items-center border-border border-b">
<div
ref={scrollRef}
className="flex min-w-0 flex-1 items-center overflow-x-auto scrollbar-none"
className="scrollbar-none flex min-w-0 flex-1 items-center overflow-x-auto"
>
{tabs.map((tab) => (
<TabButton
@ -89,11 +95,11 @@ export function ChatTabBar() {
/>
))}
</div>
<div className="flex shrink-0 items-center">
<div className="flex shrink-0 items-center gap-0.5 pr-3">
<button
type="button"
onClick={handleCreate}
className="flex size-7 items-center justify-center text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
className="flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
aria-label="New tab"
>
<PlusIcon className="size-3.5" />

View file

@ -13,14 +13,16 @@ import { ChatMessages } from "./chat-messages";
import { ChatComposer } from "./chat-composer";
import { ChatTabBar } from "./chat-tab-bar";
const MIN_HEIGHT = 150;
const MIN_HEIGHT = 260;
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);
@ -41,50 +43,58 @@ export function ClaudeChatDrawer() {
if (shouldOpen && !isOpen) {
setIsOpen(true);
const parent = containerRef.current?.parentElement;
const maxHeight = parent ? parent.clientHeight * 0.5 : 400;
setHeight(maxHeight);
heightRef.current = maxHeight;
const maxHeight = parent
? Math.max(MIN_HEIGHT, parent.clientHeight * 0.5)
: 400;
const nextHeight = Math.max(maxHeight, MIN_HEIGHT);
setHeight(nextHeight);
heightRef.current = nextHeight;
if (panelRef.current) {
panelRef.current.style.height = `${maxHeight}px`;
panelRef.current.style.height = `${nextHeight}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 ? 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
? 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 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(() => {
@ -103,7 +113,12 @@ export function ClaudeChatDrawer() {
const dims = getExpandedDimensions();
return { height: dims.height, maxWidth: dims.width, borderRadius: 0 };
}
return { height, maxWidth: 672, borderRadius: 24 };
return {
height: Math.max(height, MIN_HEIGHT),
minHeight: MIN_HEIGHT,
maxWidth: 672,
borderRadius: 24,
};
};
return (
@ -111,7 +126,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 pb-6 pt-4"
isExpanded ? "p-0" : "px-4 pt-4 pb-6",
)}
>
{/* Floating toggle button */}
@ -122,7 +137,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"
>
@ -134,18 +149,20 @@ 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-b border-border px-2 py-1">
<div className="flex items-center justify-start border-border border-b px-2 py-1">
<button
type="button"
onClick={() => setIsExpanded(false)}
@ -189,7 +206,7 @@ export function ClaudeChatDrawer() {
{/* Error banner */}
{error && (
<div className="mx-3 mb-1 rounded-lg border border-destructive/50 bg-destructive/10 px-3 py-1.5 text-destructive text-xs">
<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">
{error}
</div>
)}

View file

@ -17,19 +17,48 @@ 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));
}
@ -55,21 +84,73 @@ export const MarkdownRenderer: FC<MarkdownRendererProps> = ({
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkMath]}
rehypePlugins={[rehypeKatex]}
className={className ?? "prose prose-sm dark:prose-invert max-w-none"}
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",
)}
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={codeClassName} {...props}>
<code
className={cn(
"break-words [overflow-wrap:anywhere]",
codeClassName,
)}
{...props}
>
{children}
</code>
);
@ -138,9 +219,12 @@ 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) });
}
@ -186,7 +270,10 @@ 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">
@ -213,7 +300,9 @@ 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>
)}
@ -246,9 +335,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">
@ -276,8 +365,8 @@ const CommandOutput: FC<{
</span>
</button>
{expanded && truncated && (
<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">
<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">
{truncated}
</pre>
</div>

View file

@ -1,4 +1,4 @@
import { type FC } from "react";
import { type FC, useEffect, useRef, useState } from "react";
import { Check, X } from "lucide-react";
import { type ProposedChange } from "@/stores/proposed-changes-store";
@ -17,42 +17,79 @@ 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 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>
<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>
{totalChanges > 1 && (
<span className="rounded bg-violet-500/15 px-1.5 py-0.5 text-xs font-medium text-violet-600 dark:text-violet-400">
<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">
{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 items-center gap-1.5">
<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">
<button
type="button"
onClick={onKeep}
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"
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"
>
<Check className="size-3.5" />
Keep All
<span className="whitespace-nowrap">Keep All</span>
<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 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"
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"
>
<X className="size-3.5" />
Undo All
<span className="whitespace-nowrap">Undo All</span>
<kbd className="ml-1 rounded bg-red-600/20 px-1 py-0.5 font-mono text-[10px]">
⌘N
</kbd>

View file

@ -1,6 +1,12 @@
import { useState, useCallback } from "react";
import { useState, useCallback, useMemo } from "react";
import { invoke } from "@tauri-apps/api/core";
import { HistoryIcon, PlusIcon, CheckIcon, Loader2Icon } from "lucide-react";
import {
HistoryIcon,
PlusIcon,
CheckIcon,
Loader2Icon,
Trash2Icon,
} from "lucide-react";
import {
DropdownMenu,
DropdownMenuTrigger,
@ -9,8 +15,20 @@ 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;
@ -34,30 +52,53 @@ 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 isStreaming = useClaudeChatStore((s) => s.isStreaming);
const tabs = useClaudeChatStore((s) => s.tabs);
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);
console.log("[session-selector] loading sessions for projectRoot:", projectRoot);
log.debug(`loading sessions for projectRoot: ${projectRoot}`);
try {
const result = await invoke<ClaudeSessionInfo[]>(
"list_claude_sessions",
{ projectPath: projectRoot },
);
console.log("[session-selector] loaded sessions:", result);
const result = await invoke<ClaudeSessionInfo[]>("list_claude_sessions", {
projectPath: projectRoot,
generateTitles: false,
});
log.debug("loaded sessions", { count: result.length });
setSessions(result);
for (const session of result) {
setSessionTitle(session.session_id, session.title);
}
} catch (err) {
console.error("[session-selector] Failed to load sessions:", err);
log.error("Failed to load sessions", { error: String(err) });
setSessions([]);
} finally {
setIsLoading(false);
}
}, [projectRoot]);
}, [projectRoot, setSessionTitle]);
const handleOpenChange = useCallback(
(open: boolean) => {
@ -69,77 +110,204 @@ export function SessionSelector() {
);
const handleSelectSession = useCallback(
(sid: string) => {
if (isStreaming) return;
if (sid === sessionId) return;
console.log("[session-selector] selecting session:", sid);
resumeSession(sid);
(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);
},
[isStreaming, sessionId, resumeSession],
[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],
);
const handleNewChat = useCallback(() => {
if (isStreaming) return;
newSession();
}, [isStreaming, newSession]);
}, [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()}
<>
<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"
>
<HistoryIcon className="size-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuLabel>Sessions</DropdownMenuLabel>
<DropdownMenuContent
align="end"
side="bottom"
className="max-h-80 w-72 overflow-y-auto"
<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);
}
}}
>
<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"
<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}
>
<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" />
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" />
)}
</DropdownMenuItem>
))
)}
</DropdownMenuContent>
</DropdownMenu>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View file

@ -1,7 +1,27 @@
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 {
@ -43,12 +63,22 @@ 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" />;
}
@ -64,9 +94,13 @@ 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())
);
}
@ -75,7 +109,8 @@ 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;
}
@ -117,14 +152,17 @@ 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);
}
@ -207,7 +245,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 && cmd.description.toLowerCase().includes(q.toLowerCase())) {
if (cmd.description?.toLowerCase().includes(q.toLowerCase())) {
descScore = q.length * 0.3; // modest score for description-only match
}
@ -215,10 +253,7 @@ 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;
}
@ -252,23 +287,58 @@ function SkillPreview({ content }: { content: string }) {
}
return (
<div className="prose prose-sm prose-invert max-w-none text-xs leading-relaxed text-muted-foreground">
<div className="prose prose-sm prose-invert max-w-none text-muted-foreground text-xs leading-relaxed">
{body.split("\n").map((line, i) => {
const trimmed = line.trimEnd();
if (trimmed.startsWith("# ")) {
return <h3 key={i} className="text-sm font-semibold text-foreground mt-3 mb-1">{trimmed.slice(2)}</h3>;
return (
<h3
key={i}
className="mt-3 mb-1 font-semibold text-foreground text-sm"
>
{trimmed.slice(2)}
</h3>
);
}
if (trimmed.startsWith("## ")) {
return <h4 key={i} className="text-xs font-semibold text-foreground mt-2.5 mb-0.5">{trimmed.slice(3)}</h4>;
return (
<h4
key={i}
className="mt-2.5 mb-0.5 font-semibold text-foreground text-xs"
>
{trimmed.slice(3)}
</h4>
);
}
if (trimmed.startsWith("### ")) {
return <h5 key={i} className="text-xs font-medium text-foreground mt-2 mb-0.5">{trimmed.slice(4)}</h5>;
return (
<h5
key={i}
className="mt-2 mb-0.5 font-medium text-foreground text-xs"
>
{trimmed.slice(4)}
</h5>
);
}
if (trimmed.startsWith("- ") || trimmed.startsWith("* ")) {
return <div key={i} className="pl-3 before:content-['·'] before:mr-1.5 before:text-muted-foreground/50">{trimmed.slice(2)}</div>;
return (
<div
key={i}
className="pl-3 before:mr-1.5 before:text-muted-foreground/50 before:content-['·']"
>
{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" />;
@ -292,7 +362,11 @@ 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;
@ -352,9 +426,16 @@ 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 }[] = [];
@ -430,7 +511,9 @@ 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]);
@ -443,7 +526,7 @@ export const SlashCommandPicker: FC<SlashCommandPickerProps> = ({
key={cmd.id}
data-index={index}
className={cn(
"flex w-full items-center gap-2.5 px-3 py-1.5 rounded-md text-left transition-colors",
"flex w-full items-center gap-2.5 rounded-md px-3 py-1.5 text-left transition-colors",
isSelected ? "bg-accent text-accent-foreground" : "hover:bg-muted",
)}
onMouseDown={(e) => {
@ -453,9 +536,9 @@ export const SlashCommandPicker: FC<SlashCommandPickerProps> = ({
onMouseEnter={() => setSelectedIndex(index)}
>
{getCommandIcon(cmd)}
<span className="font-mono text-sm truncate">{cmd.full_command}</span>
<span className="truncate font-mono text-sm">{cmd.full_command}</span>
{cmd.description && (
<span className="truncate text-xs text-muted-foreground flex-1 min-w-0">
<span className="min-w-0 flex-1 truncate text-muted-foreground text-xs">
{cmd.description}
</span>
)}
@ -482,29 +565,34 @@ export const SlashCommandPicker: FC<SlashCommandPickerProps> = ({
if (isSearching) {
return (
<div className="flex flex-col items-center justify-center py-8">
<SearchIcon className="size-6 text-muted-foreground mb-2" />
<span className="text-sm text-muted-foreground">No results for "{query}"</span>
<SearchIcon className="mb-2 size-6 text-muted-foreground" />
<span className="text-muted-foreground text-sm">
No results for "{query}"
</span>
</div>
);
}
const hints: Record<Tab, React.ReactNode> = {
skills: (
<p className="text-xs text-muted-foreground mt-1 text-center px-4">
<p className="mt-1 px-4 text-center text-muted-foreground text-xs">
Install scientific skills from the sidebar menu.
</p>
),
default: null,
custom: (
<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 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>
),
};
return (
<div className="flex flex-col items-center justify-center py-8">
<span className="text-sm text-muted-foreground">No commands available</span>
<span className="text-muted-foreground text-sm">
No commands available
</span>
{hints[activeTab]}
</div>
);
@ -514,7 +602,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-sm text-muted-foreground">Loading...</span>
<span className="text-muted-foreground text-sm">Loading...</span>
</div>
)}
@ -526,7 +614,7 @@ export const SlashCommandPicker: FC<SlashCommandPickerProps> = ({
<div className="space-y-2">
{searchGroups.map((group) => (
<div key={group.label}>
<h3 className="px-3 py-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
<h3 className="px-3 py-1 font-semibold text-[10px] text-muted-foreground uppercase tracking-wider">
{group.label}
</h3>
<div className="space-y-0.5">
@ -559,16 +647,18 @@ 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="border-b border-border px-3 pt-2.5 pb-2 shrink-0">
<div className="flex items-center justify-between mb-2">
<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="flex items-center gap-2">
<CommandIcon className="size-4 text-muted-foreground" />
<span className="text-sm font-medium">
<span className="font-medium text-sm">
{isSearching ? `Search: "${query}"` : "Commands"}
</span>
</div>
@ -590,7 +680,7 @@ export const SlashCommandPicker: FC<SlashCommandPickerProps> = ({
<button
key={tab}
className={cn(
"flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium transition-colors",
"flex items-center gap-1.5 rounded-md px-2.5 py-1 font-medium text-xs transition-colors",
activeTab === tab
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground hover:bg-muted/80",
@ -600,14 +690,20 @@ 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] leading-4 tabular-nums",
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] tabular-nums leading-4",
activeTab === tab
? "bg-primary-foreground/20 text-primary-foreground"
: "bg-muted-foreground/15 text-muted-foreground",
)}
>
{tabCounts[tab]}
</span>
)}
@ -620,18 +716,19 @@ export const SlashCommandPicker: FC<SlashCommandPickerProps> = ({
{renderList()}
{/* Footer */}
<div className="border-t border-border px-3 py-1 shrink-0">
<div className="shrink-0 border-border border-t px-3 py-1">
<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 flex-col w-[55%] border-l border-border">
<div className="flex w-[55%] flex-col border-border border-l">
{/* Preview header */}
<div className="flex items-center gap-2 border-b border-border px-3 py-2 shrink-0">
<div className="flex shrink-0 items-center gap-2 border-border border-b px-3 py-2">
<button
aria-label="Close preview"
onMouseDown={(e) => {
@ -643,7 +740,9 @@ export const SlashCommandPicker: FC<SlashCommandPickerProps> = ({
<ChevronLeftIcon className="size-3.5 text-muted-foreground" />
</button>
<FlaskConicalIcon className="size-3.5 text-muted-foreground" />
<span className="font-mono text-sm font-medium truncate">{selectedCommand.full_command}</span>
<span className="truncate font-medium font-mono text-sm">
{selectedCommand.full_command}
</span>
</div>
{/* Preview body */}

View file

@ -1,10 +1,9 @@
import { type FC, useState } from "react";
import {
BotIcon,
BrainIcon,
CheckIcon,
ChevronDownIcon,
ChevronRightIcon,
CircleDotIcon,
CircleIcon,
ClockIcon,
FileEditIcon,
@ -17,7 +16,10 @@ 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;
@ -27,16 +29,36 @@ 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 === "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} />;
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} />;
return <GenericWidget name={toolUse.name || "unknown"} input={toolUse.input} result={toolResult} />;
return (
<GenericWidget
name={toolUse.name || "unknown"}
input={toolUse.input}
result={toolResult}
/>
);
};
// ─── Status Icon ───
@ -48,24 +70,31 @@ 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-sm text-destructive">!</span>;
return <span className="text-destructive text-sm">!</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>
);
@ -73,7 +102,10 @@ const WriteWidget: FC<{ input: any; result?: ContentBlock }> = ({ input, result
// ─── 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 (
@ -87,18 +119,25 @@ const EditWidget: FC<{ input: any; result?: ContentBlock }> = ({ input, result }
<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-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 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>
)}
</div>
@ -107,14 +146,19 @@ const EditWidget: FC<{ input: any; result?: ContentBlock }> = ({ input, result }
// ─── 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>
);
@ -122,30 +166,38 @@ const ReadWidget: FC<{ input: any; result?: ContentBlock }> = ({ input, result }
// ─── Bash Widget ───
const BashWidget: FC<{ input: any; result?: ContentBlock }> = ({ input, result }) => {
const BashWidget: FC<{
input: any;
result?: ContentBlock;
prefix?: string;
}> = ({ input, result, prefix = "$" }) => {
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-[#1e1e2e] text-sm">
<div className="my-1.5 rounded-lg border border-border bg-muted/70 text-sm dark:bg-neutral-900">
<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-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" />
)}
<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" />
))}
</button>
{expanded && resultContent && (
<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">
<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">
{truncate(resultContent, 2000)}
</pre>
</div>
@ -156,7 +208,10 @@ const BashWidget: FC<{ input: any; result?: ContentBlock }> = ({ input, result }
// ─── 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} />
@ -171,7 +226,10 @@ const GlobWidget: FC<{ input: any; result?: ContentBlock }> = ({ input, result }
// ─── 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} />
@ -186,7 +244,10 @@ const GrepWidget: FC<{ input: any; result?: ContentBlock }> = ({ input, result }
// ─── 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);
@ -194,9 +255,10 @@ const AskUserQuestionWidget: FC<{ input: any; result?: ContentBlock }> = ({ inpu
// 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);
@ -225,11 +287,13 @@ const AskUserQuestionWidget: FC<{ input: any; result?: ContentBlock }> = ({ inpu
: "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" />
@ -243,22 +307,24 @@ const AskUserQuestionWidget: FC<{ input: any; result?: ContentBlock }> = ({ inpu
{headerLabel}
</span>
</div>
<div className="space-y-3 border-t border-blue-500/20 px-3 py-2.5">
<div className="space-y-3 border-blue-500/20 border-t 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 text-xs font-medium text-blue-600 dark:text-blue-400">
<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">
{q.header}
</span>
)}
<p className="text-sm font-medium text-foreground">{q.question}</p>
<p className="font-medium text-foreground text-sm">{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"
@ -266,18 +332,26 @@ const AskUserQuestionWidget: FC<{ input: any; result?: ContentBlock }> = ({ inpu
}`}
>
<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="flex-1 min-w-0">
<span className={`text-sm ${
needsUserAnswer ? "text-foreground" : "text-muted-foreground"
}`}>
<div className="min-w-0 flex-1">
<span
className={`text-sm ${
needsUserAnswer
? "text-foreground"
: "text-muted-foreground"
}`}
>
{opt.label}
</span>
{opt.description && (
<p className="text-xs text-muted-foreground/70 mt-0.5">
<p className="mt-0.5 text-muted-foreground/70 text-xs">
{opt.description}
</p>
)}
@ -292,9 +366,84 @@ const AskUserQuestionWidget: FC<{ input: any; result?: ContentBlock }> = ({ inpu
);
};
// 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 : [];
@ -323,12 +472,14 @@ const TodoWriteWidget: FC<{ input: any; result?: ContentBlock }> = ({ input, res
<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-t border-border px-3 py-2">
<div className="space-y-0.5 border-border border-t px-3 py-2">
{todos.map((todo, idx) => (
<div
key={idx}
@ -340,14 +491,14 @@ const TodoWriteWidget: FC<{ input: any; result?: ContentBlock }> = ({ input, res
<span
className={`text-xs ${
todo.status === "completed"
? "line-through text-muted-foreground"
? "text-muted-foreground line-through"
: 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>
@ -360,11 +511,11 @@ const TodoWriteWidget: FC<{ input: any; result?: ContentBlock }> = ({ input, res
// ─── 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 (
@ -379,13 +530,15 @@ const GenericWidget: FC<{ name: string; input: any; result?: ContentBlock }> = (
<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-t border-border px-3 py-2">
<pre className="whitespace-pre-wrap font-mono text-xs text-muted-foreground">
<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">
{JSON.stringify(input, null, 2)}
</pre>
</div>
@ -396,29 +549,34 @@ const GenericWidget: FC<{ name: string; input: any; result?: ContentBlock }> = (
// ─── 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 rounded-lg border border-muted-foreground/20 bg-muted-foreground/5 overflow-hidden">
<div className="my-1.5 overflow-hidden rounded-lg border border-muted-foreground/20 bg-muted-foreground/5">
<button
type="button"
onClick={() => setExpanded(!expanded)}
className="flex w-full items-center justify-between px-3 py-2 hover:bg-muted-foreground/10 transition-colors"
className="flex w-full items-center justify-between px-3 py-2 transition-colors hover:bg-muted-foreground/10"
>
<div className="flex items-center gap-2">
<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 className="flex size-5 items-center justify-center rounded-full bg-muted-foreground/10">
<BrainIcon className="size-3.5 text-muted-foreground" />
</div>
<span className="text-sm font-medium text-muted-foreground italic">Thinking...</span>
<span className="font-medium text-muted-foreground text-sm 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-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">
<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">
{trimmed}
</pre>
</div>
@ -431,5 +589,5 @@ export const ThinkingWidget: FC<{ thinking: string; signature?: string }> = ({ t
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

@ -0,0 +1,310 @@
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

@ -0,0 +1,509 @@
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="max-w-2xl w-full space-y-4">
<h1 className="text-2xl font-bold text-destructive">
<div className="w-full max-w-2xl space-y-4">
<h1 className="font-bold text-2xl text-destructive">
Something went wrong
</h1>
<p className="text-sm text-muted-foreground">
<p className="text-muted-foreground text-sm">
An unexpected error occurred. You can try again or reload the app.
</p>
<pre className="max-h-64 overflow-auto rounded-md border bg-muted p-4 text-xs whitespace-pre-wrap">
<pre className="max-h-64 overflow-auto whitespace-pre-wrap rounded-md border bg-muted p-4 text-xs">
{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 text-sm font-medium text-primary-foreground hover:bg-primary/90"
className="rounded-md bg-primary px-4 py-2 font-medium text-primary-foreground text-sm hover:bg-primary/90"
>
Try again
</button>
<button
type="button"
onClick={() => window.location.reload()}
className="rounded-md border px-4 py-2 text-sm font-medium hover:bg-accent"
className="rounded-md border px-4 py-2 font-medium text-sm hover:bg-accent"
>
Reload
</button>

View file

@ -0,0 +1,38 @@
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,6 +3,7 @@ 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,
@ -22,21 +23,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";
@ -52,8 +53,13 @@ export function ProjectWizard({ mode, onBack }: ProjectWizardProps) {
if (mode === "template") {
return (
<div className="flex h-full flex-col bg-background">
<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}>
<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}
>
<ArrowLeftIcon className="size-4" />
</Button>
<span className="font-semibold text-sm">Choose a Template</span>
@ -75,12 +81,14 @@ 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(randomProjectName);
const [projectName, setProjectName] = useState("");
const [projectNameError, setProjectNameError] = useState("");
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);
@ -91,7 +99,7 @@ function ScratchForm({ onBack }: { onBack: () => void }) {
const template = getTemplateById("blank")!;
useEffect(() => {
const timer = setTimeout(() => textareaRef.current?.focus(), 100);
const timer = setTimeout(() => projectNameRef.current?.focus(), 100);
return () => clearTimeout(timer);
}, []);
@ -100,15 +108,24 @@ function ScratchForm({ onBack }: { onBack: () => void }) {
if (lastProjectFolder) {
setProjectFolder(lastProjectFolder);
} else {
homeDir().then((home) => join(home, "Documents", "ClaudePrism")).then((dir) => {
mkdir(dir, { recursive: true }).catch(() => {});
setProjectFolder(dir);
}).catch(() => {});
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),
);
}
}, []); // 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);
@ -119,14 +136,13 @@ 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)),
]);
}
}, []);
@ -150,25 +166,43 @@ 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 () => {
if (!template || !projectFolder || !projectName.trim()) return;
const name = normalizeProjectName(projectName);
const nameError = getProjectNameError(projectName);
if (!template || !projectFolder || nameError) {
setProjectNameError(nameError ?? "");
return;
}
setIsCreating(true);
try {
const projectPath = await join(projectFolder, projectName.trim());
await mkdir(projectPath, { recursive: true }).catch(() => {});
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 });
// Create CLAUDE.md for Claude Code context
const claudeMdPath = await join(projectPath, "CLAUDE.md");
@ -191,16 +225,13 @@ function ScratchForm({ onBack }: { onBack: () => void }) {
}
}
if (attachments.length > 0) {
const attachmentsDir = await join(projectPath, "attachments");
await mkdir(attachmentsDir, { recursive: true }).catch(() => {});
}
const referenceFiles =
attachments.length > 0
? await importReferenceFiles(projectPath, attachments)
: [];
if (purpose.trim()) {
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 attachmentSection = buildReferenceFilesSection(referenceFiles);
const prompt = [
`## New ${template.name} Project`,
@ -226,24 +257,30 @@ 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 = template && projectFolder && projectName.trim();
const canCreate = Boolean(
template && projectFolder && !getProjectNameError(projectName),
);
return (
<div className="flex h-full flex-col bg-background">
{/* Header */}
<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}>
<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}
>
<ArrowLeftIcon className="size-4" />
</Button>
<span className="font-semibold text-sm">New Document</span>
@ -252,12 +289,41 @@ 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>
<label className="font-semibold text-sm">What are you writing?</label>
<span className="font-semibold text-sm">
What are you writing?
</span>
<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
@ -271,7 +337,7 @@ function ScratchForm({ onBack }: { onBack: () => void }) {
</div>
{/* Collapsible sections */}
<div className="rounded-xl border border-border/60 bg-card/30 divide-y divide-border/40 overflow-hidden">
<div className="divide-y divide-border/40 overflow-hidden rounded-xl border border-border/60 bg-card/30">
{/* Reference files */}
<div>
<button
@ -281,10 +347,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="flex-1 min-w-0">
<span className="text-sm font-medium">Reference files</span>
<div className="min-w-0 flex-1">
<span className="font-medium text-sm">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 text-[10px] font-semibold leading-none text-primary">
<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">
{attachments.length}
</span>
)}
@ -294,16 +360,18 @@ function ScratchForm({ onBack }: { onBack: () => void }) {
/>
</button>
{refFilesOpen && (
<div className="px-4 pb-4 space-y-3">
<div className="space-y-3 px-4 pb-4">
{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 pl-2.5 pr-1.5 py-1 text-xs transition-colors hover:bg-muted/60"
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"
>
<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"
@ -317,23 +385,27 @@ function ScratchForm({ onBack }: { onBack: () => void }) {
<div
className={`flex flex-col items-center gap-2 rounded-lg border border-dashed p-4 transition-all ${
isDragOver
? "border-primary bg-primary/5 scale-[1.01]"
? "scale-[1.01] border-primary bg-primary/5"
: "border-border/60 hover:border-border hover:bg-muted/20"
}`}
>
{isDragOver ? (
<>
<UploadIcon className="size-5 text-primary" />
<span className="text-xs font-medium text-primary">Drop to add</span>
<span className="font-medium text-primary text-xs">
Drop to add
</span>
</>
) : (
<>
<UploadIcon className="size-5 text-muted-foreground/40" />
<div className="text-center">
<span className="text-xs text-muted-foreground/70">Drag & drop or </span>
<span className="text-muted-foreground/70 text-xs">
Drag & drop or{" "}
</span>
<button
onClick={handleAddAttachments}
className="text-xs font-medium text-foreground/70 underline underline-offset-2 decoration-border hover:text-foreground hover:decoration-foreground/50 transition-colors"
className="font-medium text-foreground/70 text-xs underline decoration-border underline-offset-2 transition-colors hover:text-foreground hover:decoration-foreground/50"
>
browse files
</button>
@ -357,12 +429,13 @@ 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="flex-1 min-w-0">
<span className="text-sm font-medium">Project location</span>
<div className="min-w-0 flex-1">
<span className="font-medium text-sm">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 text-[11px] font-mono text-muted-foreground/60">
.../{projectFolder.split("/").pop()}/{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>
)}
<ChevronDownIcon
@ -370,14 +443,13 @@ function ScratchForm({ onBack }: { onBack: () => void }) {
/>
</button>
{locationOpen && (
<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"
/>
<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>
<Button
variant="outline"
size="sm"
@ -388,11 +460,6 @@ 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,21 +1,33 @@
import { useEffect, useRef, useState } from "react";
import { listen } from "@tauri-apps/api/event";
import { useMemo } from "react";
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;
}
@ -26,37 +38,21 @@ interface InstallProgressProps {
isInstalling: boolean;
isComplete: boolean;
error: string | null;
logs: string[];
}
export function InstallProgress({
isInstalling,
isComplete,
error,
logs,
}: InstallProgressProps) {
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 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 label = isComplete
? "Done"
@ -70,22 +66,13 @@ export function InstallProgress({
<div className="space-y-2 py-1">
<Progress value={pct} />
<div className="flex items-center justify-between">
<p className="text-muted-foreground text-xs truncate max-w-[80%]">
<p className="max-w-[80%] truncate text-muted-foreground text-xs">
{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,5 +1,8 @@
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useRef, 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,
@ -10,6 +13,8 @@ import {
Trash2Icon,
Loader2Icon,
ChevronLeftIcon,
FolderPlusIcon,
XIcon,
} from "lucide-react";
import {
Dialog,
@ -45,6 +50,14 @@ interface SkillsStatus {
location: string;
}
interface SkillInfo {
id: string;
name: string;
domain: string;
description: string;
folder: string;
}
interface ScientificSkillsOnboardingProps {
onClose: () => void;
}
@ -56,20 +69,60 @@ export function ScientificSkillsOnboarding({
const [selectedId, setSelectedId] = useState<string | null>(null);
const [isInstalling, setIsInstalling] = useState(false);
const [isComplete, setIsComplete] = useState(false);
const [installResult, setInstallResult] = useState<InstallResult | null>(null);
const [installLogs, setInstallLogs] = useState<string[]>([]);
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);
// Check global install status
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.
const checkStatus = useCallback(async () => {
try {
const gs = await invoke<SkillsStatus>("check_skills_installed", {
projectPath: null,
});
const [gs, skills] = await Promise.all([
invoke<SkillsStatus>("check_skills_installed", {
projectPath: null,
}),
invoke<SkillInfo[]>("list_installed_skills", {
projectPath: null,
}),
]);
setStatus(gs);
setInstalledSkills(skills);
} catch {
setStatus(null);
setInstalledSkills([]);
}
}, []);
@ -86,22 +139,88 @@ export function ScientificSkillsOnboarding({
.catch(console.error);
}, []);
const totalSkills = categories.reduce((sum, c) => sum + c.skill_count, 0);
const selected = categories.find((c) => c.id === selectedId) ?? null;
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 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 {
const result = await invoke<InstallResult>("install_scientific_skills_global");
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;
setInstallResult(result);
setIsComplete(true);
localStorage.setItem(STORAGE_KEY, "true");
await checkStatus();
} catch (e) {
setError(String(e));
if (noBackendLogTimer !== undefined) {
window.clearTimeout(noBackendLogTimer);
}
if (!mountedRef.current) return;
const message = String(e);
setInstallLogs((previous) => [...previous, message]);
setError(message);
setIsInstalling(false);
}
}, [checkStatus]);
@ -109,36 +228,125 @@ 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) {
if (isInstalling || isComplete || error) {
return (
<Dialog open onOpenChange={(open) => { if (!open && (isComplete || error)) onClose(); }}>
<DialogContent className="sm:max-w-md">
<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>
<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" : "Installing Skills"}
{isComplete
? "Installation Complete"
: error
? "Installation Failed"
: "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>
@ -147,12 +355,15 @@ 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-xs leading-relaxed text-muted-foreground">{error}</p>
<p className="text-muted-foreground text-xs leading-relaxed">
{error}
</p>
</div>
)}
@ -161,7 +372,7 @@ export function ScientificSkillsOnboarding({
<Button
variant="outline"
size="sm"
onClick={() => { setError(null); setIsInstalling(false); }}
onClick={handleInstall}
className="gap-1.5"
>
<RefreshCwIcon className="size-3.5" />
@ -181,132 +392,254 @@ export function ScientificSkillsOnboarding({
// ─── Browse state — two-column layout ───
return (
<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))]"
<>
<Dialog
open
onOpenChange={(open) => {
if (!open) onClose();
}}
>
{/* 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"
<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"
>
K-Dense
<ExternalLinkIcon className="size-2.5" />
</a>
</DialogDescription>
</div>
<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
{isImporting ? (
<Loader2Icon className="size-3.5 animate-spin text-muted-foreground" />
) : (
<FolderPlusIcon className="size-3.5 text-muted-foreground" />
)}
Import Skill
</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>
);
})}
</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}
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>
</DialogHeader>
{/* 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>
) : (
<div className="flex flex-1 items-center justify-center text-muted-foreground text-sm">
Select a category
</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>
</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>
<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>
</>
);
}
@ -315,12 +648,20 @@ 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);
@ -364,8 +705,12 @@ function CategoryDetail({
return (
<div>
<button
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"
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"
>
<ChevronLeftIcon className="size-3.5" />
{category.name}
@ -381,22 +726,40 @@ 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-xs text-muted-foreground">
<div className="flex items-center gap-2 py-4 text-muted-foreground text-xs">
<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-xs leading-relaxed text-muted-foreground">{fetchError}</p>
<p className="text-muted-foreground text-xs leading-relaxed">
{fetchError}
</p>
</div>
) : skillContent ? (
<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">
<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">
{skillContent}
</div>
) : null}
@ -435,16 +798,44 @@ function CategoryDetail({
Skills
</h4>
<div className="grid grid-cols-2 gap-1.5">
{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>
))}
{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>
);
})}
</div>
</div>
</div>

View file

@ -17,8 +17,6 @@ import {
type TemplateCategory,
type TemplateSubcategory,
CATEGORY_LABELS,
SUBCATEGORY_LABELS,
CATEGORY_SUBCATEGORIES,
getCategories,
getAllTemplates,
getTemplatesByCategory,
@ -32,7 +30,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" />,
@ -63,7 +61,9 @@ export function CategorySidebar() {
>
<SparklesIcon className="size-4" />
<span className="flex-1">All Templates</span>
<span className="text-xs tabular-nums text-muted-foreground">{allCount}</span>
<span className="text-muted-foreground text-xs tabular-nums">
{allCount}
</span>
</button>
<div className="my-1.5 h-px bg-border" />
@ -84,7 +84,9 @@ export function CategorySidebar() {
>
{CATEGORY_ICONS[cat]}
<span className="flex-1">{CATEGORY_LABELS[cat]}</span>
<span className="text-xs tabular-nums text-muted-foreground">{count}</span>
<span className="text-muted-foreground text-xs tabular-nums">
{count}
</span>
</button>
);
})}

View file

@ -15,14 +15,20 @@ 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" />
@ -35,11 +41,17 @@ 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>
@ -47,7 +59,7 @@ export function ThumbnailSlides({ color }: { color: string }) {
);
}
export function ThumbnailPoster({ color }: { color: string }) {
export function ThumbnailPoster({ color: _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" />
@ -70,7 +82,7 @@ export function ThumbnailPoster({ color }: { color: string }) {
export function ThumbnailBlank(_props: { color: string }) {
return (
<div className="flex h-full w-full items-center justify-center">
<div className="text-muted-foreground/20 text-xs font-medium">Empty</div>
<div className="font-medium text-muted-foreground/20 text-xs">Empty</div>
</div>
);
}
@ -86,11 +98,13 @@ 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;
}
@ -104,14 +118,12 @@ 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(() => {
@ -149,7 +161,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 text-muted-foreground text-xs leading-snug line-clamp-2">
<div className="mt-0.5 line-clamp-2 text-muted-foreground text-xs leading-snug">
{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-b border-border px-4 py-3">
<div className="shrink-0 border-border border-b 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="pl-9 pr-8"
className="pr-8 pl-9"
/>
{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-r border-border pl-3 pt-2">
<div className="shrink-0 border-border border-r pt-2 pl-3">
<CategorySidebar />
</div>
@ -86,8 +86,10 @@ 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-sm text-muted-foreground">No templates found</p>
<p className="mt-1 text-xs text-muted-foreground/70">
<p className="font-medium text-muted-foreground text-sm">
No templates found
</p>
<p className="mt-1 text-muted-foreground/70 text-xs">
Try a different search term or category
</p>
</div>
@ -95,7 +97,9 @@ export function TemplateGallery() {
<GroupedGrid />
) : (
<>
<h2 className="mb-4 font-medium text-sm text-muted-foreground">{heading}</h2>
<h2 className="mb-4 font-medium text-muted-foreground text-sm">
{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} />
@ -118,7 +122,12 @@ 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,7 +2,8 @@ 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 { documentDir } from "@tauri-apps/api/path";
import { homeDir } from "@tauri-apps/api/path";
import { toast } from "sonner";
import {
ChevronLeftIcon,
ChevronRightIcon,
@ -32,23 +33,26 @@ 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";
@ -56,7 +60,9 @@ 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");
@ -76,11 +82,13 @@ export function TemplatePreview() {
const [purpose, setPurpose] = useState("");
const [attachments, setAttachments] = useState<string[]>([]);
const [projectFolder, setProjectFolder] = useState<string | null>(null);
const [projectName, setProjectName] = useState(randomProjectName);
const [projectName, setProjectName] = useState("");
const [projectNameError, setProjectNameError] = useState("");
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 ──
@ -100,7 +108,9 @@ export function TemplatePreview() {
setIsLandscape(false);
setError(false);
if (docIdRef.current > 0) {
getMupdfClient().closeDocument(docIdRef.current).catch(() => {});
getMupdfClient()
.closeDocument(docIdRef.current)
.catch(() => {});
docIdRef.current = 0;
}
}
@ -114,7 +124,8 @@ export function TemplatePreview() {
setModalStep("preview");
setPurpose("");
setAttachments([]);
setProjectName(randomProjectName());
setProjectName("");
setProjectNameError("");
setRefFilesOpen(false);
setLocationOpen(false);
}
@ -126,14 +137,22 @@ export function TemplatePreview() {
if (lastProjectFolder) {
setProjectFolder(lastProjectFolder);
} else {
documentDir().then((dir) => setProjectFolder(dir)).catch(() => {});
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),
);
}
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// Auto-focus textarea in details step
useEffect(() => {
if (modalStep === "details") {
const timer = setTimeout(() => textareaRef.current?.focus(), 150);
const timer = setTimeout(() => projectNameRef.current?.focus(), 150);
return () => clearTimeout(timer);
}
}, [modalStep]);
@ -187,7 +206,7 @@ export function TemplatePreview() {
setLoading(false);
} catch (err) {
if (gen !== loadGenRef.current) return;
console.warn("[template-preview] load error:", err);
log.warn("load error", { error: String(err) });
setLoading(false);
setError(true);
}
@ -196,7 +215,13 @@ 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];
@ -220,18 +245,21 @@ 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) => {
console.warn("[template-preview] render error:", 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) => {
log.warn("render error", { error: String(err) });
});
}, [currentPage, numPages, isLandscape]);
// ── Page navigation ──
@ -248,8 +276,13 @@ 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);
@ -272,16 +305,25 @@ 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 ──
@ -289,14 +331,13 @@ 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)),
]);
}
}, []);
@ -305,7 +346,11 @@ 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);
@ -314,12 +359,21 @@ export function TemplatePreview() {
// ── Create project ──
const handleCreate = async () => {
if (!template || !projectFolder || !projectName.trim()) return;
const name = normalizeProjectName(projectName);
const nameError = getProjectNameError(projectName);
if (!template || !projectFolder || nameError) {
setProjectNameError(nameError ?? "");
return;
}
setIsCreating(true);
try {
const projectPath = await join(projectFolder, projectName.trim());
await mkdir(projectPath, { recursive: true }).catch(() => {});
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 mainTexPath = await join(projectPath, template.mainFileName);
const mainExists = await exists(mainTexPath);
@ -335,16 +389,13 @@ export function TemplatePreview() {
}
}
if (attachments.length > 0) {
const attachmentsDir = await join(projectPath, "attachments");
await mkdir(attachmentsDir, { recursive: true }).catch(() => {});
}
const referenceFiles =
attachments.length > 0
? await importReferenceFiles(projectPath, attachments)
: [];
if (purpose.trim()) {
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 attachmentSection = buildReferenceFilesSection(referenceFiles);
const prompt = [
`## New ${template.name} Project`,
@ -371,46 +422,49 @@ 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 = template && projectFolder && projectName.trim();
const canCreate = Boolean(
template && projectFolder && !getProjectNameError(projectName),
);
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 sm:max-w-none flex-col gap-0 overflow-hidden p-0 transition-[width] duration-300 ${modalWidth} ${modalStep === "preview" ? "h-[70vh]" : "max-h-[80vh]"}`}
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]"}`}
>
{modalStep === "preview" ? (
/* ═══════════════════ PREVIEW STEP ═══════════════════ */
<>
<DialogHeader className="shrink-0 border-b border-border px-6 py-3">
<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">{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">
@ -428,7 +482,10 @@ 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" />
@ -449,14 +506,26 @@ export function TemplatePreview() {
</div>
{numPages > 0 && (
<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}>
<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}
>
<ChevronLeftIcon className="size-4" />
</Button>
<span className="min-w-16 text-center text-xs tabular-nums text-muted-foreground">
<span className="min-w-16 text-center text-muted-foreground text-xs tabular-nums">
{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>
@ -468,12 +537,12 @@ export function TemplatePreview() {
/* ═══════════════════ DETAILS STEP ═══════════════════ */
<>
{/* Header */}
<DialogHeader className="shrink-0 border-b border-border/60 px-5 py-3">
<DialogHeader className="shrink-0 border-border/60 border-b px-5 py-3">
<div className="flex items-center gap-3">
<Button
variant="ghost"
size="icon"
className="size-7 rounded-lg shrink-0"
className="size-7 shrink-0 rounded-lg"
onClick={() => setModalStep("preview")}
>
<ArrowLeftIcon className="size-4" />
@ -490,12 +559,43 @@ 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>
<label className="font-semibold text-sm">What are you writing?</label>
<span className="font-semibold text-sm">
What are you writing?
</span>
<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
@ -509,7 +609,7 @@ export function TemplatePreview() {
</div>
{/* Collapsible sections */}
<div className="rounded-xl border border-border/60 bg-card/30 divide-y divide-border/40 overflow-hidden">
<div className="divide-y divide-border/40 overflow-hidden rounded-xl border border-border/60 bg-card/30">
{/* Reference files */}
<div>
<button
@ -519,10 +619,12 @@ 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="flex-1 min-w-0">
<span className="text-sm font-medium">Reference files</span>
<div className="min-w-0 flex-1">
<span className="font-medium text-sm">
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 text-[10px] font-semibold leading-none text-primary">
<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">
{attachments.length}
</span>
)}
@ -532,16 +634,18 @@ export function TemplatePreview() {
/>
</button>
{refFilesOpen && (
<div className="px-4 pb-3 space-y-2.5">
<div className="space-y-2.5 px-4 pb-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 pl-2.5 pr-1.5 py-1 text-xs transition-colors hover:bg-muted/60"
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"
>
<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"
@ -562,16 +666,20 @@ export function TemplatePreview() {
{isDragOver ? (
<>
<UploadIcon className="size-4 text-primary" />
<span className="text-xs font-medium text-primary">Drop to add</span>
<span className="font-medium text-primary text-xs">
Drop to add
</span>
</>
) : (
<>
<UploadIcon className="size-4 text-muted-foreground/40" />
<div className="text-center">
<span className="text-xs text-muted-foreground/70">Drag & drop or </span>
<span className="text-muted-foreground/70 text-xs">
Drag & drop or{" "}
</span>
<button
onClick={handleAddAttachments}
className="text-xs font-medium text-foreground/70 underline underline-offset-2 decoration-border hover:text-foreground transition-colors"
className="font-medium text-foreground/70 text-xs underline decoration-border underline-offset-2 transition-colors hover:text-foreground"
>
browse files
</button>
@ -592,12 +700,15 @@ 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="flex-1 min-w-0">
<span className="text-sm font-medium">Project location</span>
<div className="min-w-0 flex-1">
<span className="font-medium text-sm">
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 text-[11px] font-mono text-muted-foreground/60">
.../{projectFolder.split("/").pop()}/{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>
)}
<ChevronDownIcon
@ -605,14 +716,13 @@ export function TemplatePreview() {
/>
</button>
{locationOpen && (
<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"
/>
<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>
<Button
variant="outline"
size="sm"
@ -623,11 +733,6 @@ 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>
@ -636,7 +741,7 @@ export function TemplatePreview() {
</div>
{/* Create button — sticky footer */}
<div className="shrink-0 border-t border-border/60 px-5 py-4">
<div className="shrink-0 border-border/60 border-t 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,14 +4,12 @@ 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 text-xs font-medium 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 font-medium text-xs 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,6 +14,7 @@ function ContextMenuTrigger({
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
return (
<ContextMenuPrimitive.Trigger
data-app-context-menu
data-slot="context-menu-trigger"
{...props}
/>
@ -27,6 +28,7 @@ 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,5 +1,3 @@
import * as React from "react";
import { Dialog as DialogPrimitive } from "radix-ui";
import { XIcon } from "lucide-react";
@ -39,7 +37,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/50 data-[state=closed]:animate-out data-[state=open]:animate-in",
"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",
className,
)}
{...props}

View file

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

View file

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

View file

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

View file

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

View file

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

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