Compare commits

..

16 Commits

Author SHA1 Message Date
naomi 9af61a4a29 release: v1.10.0
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m13s
CI / Lint & Test (push) Successful in 17m10s
CI / Build Linux (push) Successful in 20m11s
CI / Build Windows (cross-compile) (push) Successful in 30m4s
2026-03-03 20:22:54 -08:00
hikari fa906684c2 feat: multiple UI improvements, font settings, and memory file display names (#175)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 57s
CI / Lint & Test (push) Has been cancelled
CI / Build Linux (push) Has been cancelled
CI / Build Windows (cross-compile) (push) Has been cancelled
## Summary

- **fix**: `show_thinking_blocks` setting now persists across sessions — it was defined on the TypeScript side but missing from the Rust `HikariConfig` struct, so serde silently dropped it on every save/load
- **feat**: Tool calls are now rendered as collapsible blocks matching the Extended Thinking block aesthetic, replacing the old inline dropdown approach
- **feat**: Add configurable max output tokens setting
- **feat**: Use random creative names for conversation tabs
- **test**: Significantly expanded frontend unit test coverage
- **docs**: Require tests for all changes in CLAUDE.md
- **feat**: Allow users to specify a custom terminal font (Closes #176)
- **feat**: Display friendly names for memory files derived from the first heading (Closes #177)
- **feat**: Add custom UI font support for the app chrome (buttons, labels, tabs)
- **fix**: Apply custom UI font to the full app interface — `.app-container` was hardcoded, blocking inheritance from `body`; also renamed "Custom Font" to "Custom Terminal Font" for clarity

 This PR was created with help from Hikari~ 🌸

Reviewed-on: #175
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-03-03 20:21:58 -08:00
naomi 97b8243d24 release: v1.9.0
CI / Lint & Test (push) Successful in 16m26s
CI / Build Linux (push) Successful in 20m15s
CI / Build Windows (cross-compile) (push) Successful in 31m2s
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 2m16s
2026-02-27 15:09:37 -08:00
hikari 7ebd9dc97a feat: new drafts feature and sound spam fix (#174)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 58s
CI / Build Linux (push) Has been cancelled
CI / Build Windows (cross-compile) (push) Has been cancelled
CI / Lint & Test (push) Has been cancelled
## Summary

- **Saved Drafts feature**: Users can now save input content as drafts for later use, and manage them from a new panel
- **Sound spam fix**: The "Working on it!" sound no longer plays repeatedly when Claude makes multiple tool calls in a row

## Details

### Drafts feature
- Rust backend (`drafts.rs`) with `list_drafts`, `save_draft`, `delete_draft`, and `delete_all_drafts` commands, persisted to `hikari-drafts.json` via the Tauri Store plugin
- `draftsStore` wrapping all four commands with timestamp formatting
- `DraftPanel` overlay with insert, per-item two-step delete confirmation, delete-all with confirmation, empty state, and slide-in animation
- **Drafts** button in the top control row (pencil icon)
- **Save as Draft** floppy-disk icon button in the button wrapper (disabled when input is empty)

### Sound spam fix
- Root cause: `resetSoundState` was called on **every** `thinking` state transition, including mid-task transitions (`coding → thinking → coding`)
- Fix: only reset sound state when entering `thinking` from a clean-slate state (`idle`, `success`, or `error`) — states that genuinely mark the end of one task and the start of a new one

## Test plan
- [ ] Save a draft and verify it persists across app restarts
- [ ] Insert a draft and verify it populates the input
- [ ] Delete individual drafts and verify delete-all works
- [ ] Verify "Working on it!" plays once per user message regardless of how many tools are called

 This PR was created with help from Hikari~ 🌸

Reviewed-on: #174
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-02-27 15:07:10 -08:00
naomi fe7027c585 release: v1.8.1
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m8s
CI / Lint & Test (push) Successful in 16m51s
CI / Build Linux (push) Successful in 20m11s
CI / Build Windows (cross-compile) (push) Successful in 30m45s
2026-02-26 23:36:01 -08:00
hikari 89a0bdd8f1 fix: assorted bug fixes for lists, sounds, interrupts, and permissions (#173)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 59s
CI / Build Linux (push) Has been cancelled
CI / Build Windows (cross-compile) (push) Has been cancelled
CI / Lint & Test (push) Has been cancelled
## Summary

- **Markdown lists**: Explicitly set `list-style-type: disc` / `decimal` in the Markdown renderer — Tauri's WebView strips browser defaults, leaving bullets and numbers invisible.
- **Notification sounds**: Moved all per-task sounds (success, error, permission, task-start) from a global `characterState` subscription into the per-conversation `claude:state` event handler, so background tabs receive their sounds correctly and tab-switching never replays a sound that already fired. Closes #172
- **Draft text**: Persists `inputValue` per conversation tab so a half-typed prompt survives switching to another tab and back.
- **Interrupt messages**: Replaced vague "Process interrupted" / "Disconnected" strings with source-specific descriptions (keyboard shortcut, stop button, unexpected crash) so it's clear what actually happened.
- **Silent prompt loss**: When Claude Code exits whilst a prompt is in-flight, emits a visible error line telling the user their last prompt was not processed and to reconnect and retry.
- **Double disconnect**: Added an `intentional_stop` flag to `WslBridge` so that `stop()` / `interrupt()` — which kill the process themselves — suppress the duplicate "Disconnected unexpectedly" message that `handle_stdout`'s EOF path was also emitting.
- **Permission modal**: Fixed two cooperating reactivity bugs — `pendingPermissions` was mutated in-place (`.push()`), causing Svelte's derived-store chain to receive the same array reference and skip re-rendering; `PermissionModal.svelte` also used `$state()` (runes mode) where plain `let` is required for correct store-subscription reactivity.

## Test plan

- [ ] Unordered and ordered lists render with visible bullets and numbers in the chat terminal
- [ ] Completion sound plays once when a background tab finishes; switching back to that tab does not replay it
- [ ] Sounds for error, permission request, and task-start also play for background tabs and do not replay on tab switch
- [ ] Typing a prompt, switching tabs, and switching back restores the draft text
- [ ] Pressing Ctrl+C shows "keyboard shortcut (Ctrl+C)"; clicking the stop button shows "via stop button"
- [ ] If Claude exits mid-request, an error message appears prompting the user to resend
- [ ] Clicking stop or pressing Ctrl+C produces exactly one disconnect message (not two)
- [ ] When a tool requires permission, the permission modal appears and the user can approve or dismiss it

 This PR was created with help from Hikari~ 🌸

Reviewed-on: #173
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-02-26 23:34:51 -08:00
naomi 2e3f203508 release: v1.8.0
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m51s
CI / Lint & Test (push) Successful in 17m15s
CI / Build Linux (push) Successful in 20m7s
CI / Build Windows (cross-compile) (push) Successful in 30m8s
2026-02-25 22:57:52 -08:00
hikari b745100bd5 feat: Claude CLI 2.1.50–2.1.53 audit (#171)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m28s
CI / Lint & Test (push) Has started running
CI / Build Linux (push) Has been cancelled
CI / Build Windows (cross-compile) (push) Has been cancelled
## Summary

This PR covers the full audit of Claude CLI changes from 2.1.50 to 2.1.53, plus a batch of bug fixes, new features, and maintenance work identified during that review.

### New Features
- **Workspace trust gate** — detects hooks, MCP servers, and custom commands in a workspace before connecting; persists trust decisions so users aren't prompted repeatedly
- **Custom background image** — users can set a background image with configurable opacity; character panel and compact mode go transparent when active
- **Draggable tab reordering** — conversation tabs can be reordered via pointer-event drag-and-drop (HTML5 drag is intercepted by Tauri/WebView2, so pointer events are used instead)
- **Org UUID in account info** — exposes the org UUID from Claude auth status

### Bug Fixes
- **Unread dot false positives** — initialise unread counts on mount to prevent all tabs showing the blue dot after toggling the file editor (Closes #164)
- **Watchdog for hung WSL bridge** — detects connections that never receive `system:init` and kills the stale process after 1 minute (Closes #166)
- **Suppress terminal window flash on Windows** — applies `CREATE_NO_WINDOW` to all subprocesses via a `HideWindow` trait extension (Closes #165)
- **HTML escaping in markdown renderer** — escape `<` and `>` in `codespan` and `html` renderer callbacks to prevent raw HTML injection (Closes #169)

### Maintenance
- Verify stream-JSON handles tool results above the 50K threshold correctly (Closes #162)
- Reviewed hook security fixes from CLI 2.1.51 — not applicable to our setup (Closes #163)
- Expose org UUID from `claude auth status` (Closes #160)
- Clean up Svelte and Vite build warnings (`a11y_click_events_have_key_events`, `state_referenced_locally`, `non_reactive_update`, `codeSplitting`, chunk size, CodeMirror dynamic import)
- Update all npm dependencies to latest compatible versions with exact pinning (Closes #81, Closes #82, Closes #83, Closes #84, Closes #85, Closes #86, Closes #87, Closes #90, Closes #91, Closes #93, Closes #94, Closes #95, Closes #96, Closes #97, Closes #98, Closes #99, Closes #101, Closes #141, Closes #142, Closes #143, Closes #145, Closes #146, Closes #147)
- Run `cargo update` to bring Cargo.lock up to date

### Closes

Closes #160
Closes #162
Closes #163
Closes #164
Closes #165
Closes #166
Closes #167
Closes #168
Closes #169
Closes #81
Closes #82
Closes #83
Closes #84
Closes #85
Closes #86
Closes #87
Closes #90
Closes #91
Closes #93
Closes #94
Closes #95
Closes #96
Closes #97
Closes #98
Closes #99
Closes #101
Closes #141
Closes #142
Closes #143
Closes #145
Closes #146
Closes #147

 This PR was created with help from Hikari~ 🌸

Reviewed-on: #171
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-02-25 22:55:47 -08:00
naomi 1bb7eb4d26 release: v1.7.0
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m23s
CI / Lint & Test (push) Successful in 16m55s
CI / Build Linux (push) Successful in 19m53s
CI / Build Windows (cross-compile) (push) Successful in 30m20s
2026-02-24 20:50:04 -08:00
hikari a4e6788573 feat: stuffy feature bundle (#159)
CI / Lint & Test (push) Has started running
CI / Build Linux (push) Has been cancelled
CI / Build Windows (cross-compile) (push) Has been cancelled
Security Scan and Upload / Security & DefectDojo Upload (push) Has been cancelled
## Summary

This PR bundles a collection of new features and quality-of-life improvements identified during a Claude CLI 2.1.50 audit.

- **Tab status indicator** — Tab stays yellow until the greeting is responded to, then turns green. Fixed disconnect not resetting to grey. Closes #157
- **Auth status display** — New "Account" section in settings sidebar showing login status, email, org, API key source, and Hikari override indicator. Includes login/logout buttons. Closes #153
- **CLI version badge** — New "Supported" badge showing the highest audited CLI version, colour-coded green/amber/red based on installed vs supported version. Closes #154 (bump to 2.1.50)
- **Rate limit events** — `rate_limit_event` messages from the stream are now parsed and shown as amber `[rate-limit]` lines in the terminal instead of being silently dropped. Closes #155
- **"Prompt is too long" handling** — Detects this error in assistant messages and shows a  Compact Conversation button to send `/compact` directly. Closes #158
- **`last_assistant_message` in Agent Monitor** — Extracts the agent's final output from the `ToolResult` content block in the JSON stream and displays it as a snippet on completed agent cards. Closes #156
- **`--worktree` flag** — New "Worktree isolation" toggle in session settings passes `--worktree` to Claude Code. Hook events (`WorktreeCreate`/`WorktreeRemove`) are displayed as green `[worktree]` lines. Closes #152, Closes #150
- **ConfigChange hook events** — `[ConfigChange Hook]` stderr events are now displayed as cyan `[config]` lines instead of errors. Closes #151
- **`CLAUDE_CODE_DISABLE_1M_CONTEXT` toggle** — New "Disable 1M context" setting in session configuration injects this env var into the Claude process. Closes #154

## Test plan

- [ ] Tab status indicator: start a new session and verify the tab stays yellow until Claude responds to the greeting, then turns green
- [ ] Auth status: open settings and verify the Account section shows correct login info
- [ ] CLI version badge: verify the "Supported 2.1.50" badge shows green when CLI matches
- [ ] Rate limit events: unit tests cover parsing; amber `[rate-limit]` lines display correctly
- [ ] Compact button: unit tests cover detection; button renders correctly in terminal
- [ ] Agent Monitor: use the Task tool and verify completed agent cards show a message snippet
- [ ] Worktree: enable toggle, start session, verify `--worktree` flag appears in process args
- [ ] ConfigChange: hook events display as `[config]` lines rather than errors
- [ ] Disable 1M context: enable toggle, start session, verify `CLAUDE_CODE_DISABLE_1M_CONTEXT=1` in `/proc/<pid>/environ`

 This PR was created with help from Hikari~ 🌸

Reviewed-on: #159
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-02-24 20:48:49 -08:00
naomi d2e0915a75 release: v1.6.0
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m35s
CI / Lint & Test (push) Successful in 17m14s
CI / Build Linux (push) Successful in 20m6s
CI / Build Windows (cross-compile) (push) Successful in 30m0s
2026-02-23 21:37:18 -08:00
hikari d8cf5504d6 feat: agent monitor characters, cast panel, WSL fixes, and Sonnet 4.6 (#149)
CI / Build Linux (push) Has been cancelled
CI / Lint & Test (push) Has been cancelled
CI / Build Windows (cross-compile) (push) Has been cancelled
Security Scan and Upload / Security & DefectDojo Upload (push) Has been cancelled
## Summary

### New Features
- **Claude Sonnet 4.6 support** — added `claude-sonnet-4-6` as a selectable model in the config sidebar
- **Anime girl characters for subagents** — each subagent in the agent monitor is automatically assigned one of six characters (Amari, Keiko, Minori, Reina, Tatsumi, Yumiko) with a unique name, CDN avatar, title, and lore-flavoured description; assignment avoids duplicates when possible
- **"Meet the Team" cast panel** — a new modal accessible from the status bar introduces the full cast: Naomi (Chief hEx-ecutive Officer), Hikari (Chief Operating Officer), and the six subagent girls with their C-suite titles and character bios

### Bug Fixes
- **"Already running" error on invalid working directory** — if a spawned Claude process exits unexpectedly (e.g. because the working directory doesn't exist), `try_wait()` now detects the stale handle and clears it before allowing a restart
- **Working directory pre-validation** — on Windows, the app now runs `wsl -e test -d <dir>` before launching Claude; invalid directories surface a clear error immediately
- **WSL binary detection** — on Windows, `wsl -e bash -lc "which claude"` is used to probe for the Claude binary inside WSL; on Linux/WSLg, `bash -lc "which claude"` is used as a login-shell fallback so GUI apps find the binary even without shell PATH
- **WSL detection fix for production builds** — `detect_wsl()` now short-circuits at compile time on Windows targets, preventing inherited `WSL_DISTRO_NAME` env vars from misrouting native Windows binaries through the Linux code path

 This PR was crafted with love by Hikari~ 🌸

Reviewed-on: #149
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-02-23 21:36:09 -08:00
naomi bd3438c7be release: v1.5.1
CI / Lint & Test (push) Successful in 17m29s
CI / Build Linux (push) Successful in 21m16s
CI / Build Windows (cross-compile) (push) Successful in 31m1s
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 2m0s
2026-02-08 13:56:48 -08:00
hikari 778e016bf5 fix: memory files tab empty on Windows (#140)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 3m39s
CI / Build Linux (push) Has been cancelled
CI / Build Windows (cross-compile) (push) Has been cancelled
CI / Lint & Test (push) Has been cancelled
## Summary

Fixes the memory files tab showing as empty on Windows production builds and the "forbidden path" error when trying to read memory files.

## Changes

### 1. List memory files from WSL home directory (commit 1)
- Split `list_memory_files()` into platform-specific implementations
- **Windows**: Use WSL command with `bash -l` to find memory files in WSL home (`~/.claude/projects/.../memory/`)
- **Linux/Mac**: Continue using native filesystem access
- Previously used `dirs::home_dir()` which returns Windows home (`C:\Users\...`), but Claude Code stores files in WSL home

### 2. Use backend command for reading files (commit 2)
- Changed frontend from Tauri's `readTextFile` plugin to `read_file_content` backend command
- Tauri plugin enforces scope restrictions and can't access WSL paths on Windows
- Our backend command already handles WSL paths correctly via `read_file_via_wsl()`
- Matches the pattern used throughout the app for other file operations

## Testing

-  All 426 backend tests pass
-  All frontend tests pass
-  Lint, format, and type checks pass
-  Follows existing WSL file operation patterns in codebase

## Related Issues

Fixes the memory files tab functionality on Windows whilst maintaining full compatibility with Linux/Mac.

 This PR was created by Hikari~ 🌸

Reviewed-on: #140
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-02-08 13:51:09 -08:00
hikari 0ea7861047 fix: execute Claude CLI commands through WSL on Windows (#139)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m51s
CI / Lint & Test (push) Has started running
CI / Build Linux (push) Has been cancelled
CI / Build Windows (cross-compile) (push) Has been cancelled
Resolves #137

## Summary

Claude CLI commands (plugin list, MCP list, version check, etc.) were being executed directly in Windows context where the `claude` binary doesn't exist, causing "program not found" errors across the UI.

This PR adds a helper function that automatically prefixes commands with `wsl` on Windows builds, ensuring all Claude CLI commands execute in the correct context.

## Changes

- **Added `create_claude_command()` helper function** that:
  - On Windows: Creates command with `wsl claude` prefix
  - On Linux/Mac: Creates command with `claude` directly

- **Updated 8 command functions** to use the helper:
  - `get_claude_version`
  - `list_plugins`
  - `install_plugin`
  - `uninstall_plugin`
  - `list_mcp_servers`
  - `remove_mcp_server`
  - `add_mcp_server`
  - `get_mcp_server_details`

- **Added comprehensive tests** for both Windows and Linux contexts

## What This Fixes

 Memory pane will now display files correctly
 CLI version will be detected properly
 Plugin pane will work correctly
 MCP servers pane will function properly
 All Claude CLI commands will execute in the correct context on Windows

## Testing

-  All 427 backend tests pass (added 1 new test)
-  All 387 frontend tests pass
-  All linting and formatting checks pass
-  `check-all.sh` reports: " All checks passed!"

 This fix was created by Hikari~ 🌸

Reviewed-on: #139
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-02-08 13:48:03 -08:00
hikari 381bc8410a fix: validate Claude binary installation before connection (#138)
CI / Build Linux (push) Has been cancelled
CI / Lint & Test (push) Has been cancelled
CI / Build Windows (cross-compile) (push) Has been cancelled
Security Scan and Upload / Security & DefectDojo Upload (push) Has been cancelled
## Summary

Add validation to check that the Claude CLI is installed before attempting to start a connection. If the `claude` binary is not found, users receive a helpful error message with installation instructions.

## Changes

-  Add Claude binary check using `which` command in `WslBridge::start()`
-  Return clear error message with installation command if not found
-  Add test coverage for the binary check logic (`test_claude_binary_check_command_structure`)
-  Update `CLAUDE.md` with Quality Assurance section documenting `check-all.sh`

## Error Message

If Claude Code is not installed, users will see:
```
Claude Code is not installed. Please install it using:

curl -fsSL https://claude.ai/install.sh | bash
```

## Testing

- All 427 backend tests pass 
- All 387 frontend tests pass 
- `check-all.sh` passes with no errors 
- New test validates the `which claude` command structure

## Documentation Updates

Added comprehensive Quality Assurance section to `CLAUDE.md` explaining:
- How to run `check-all.sh` before committing
- What checks are included and their order
- How to source necessary binaries (nvm for Node.js)
- Troubleshooting steps for failures

 This pull request was created by Hikari~ 🌸

Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com>
Reviewed-on: #138
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-02-08 13:47:43 -08:00
95 changed files with 12302 additions and 1385 deletions
+44 -6
View File
@@ -20,19 +20,26 @@ When working with issues, pull requests, or other repository operations for this
When asked to commit changes for this project: When asked to commit changes for this project:
- **Always commit as Hikari** using: `--author="Hikari <hikari@nhcarrigan.com>"` - **Always commit as Hikari** using: `--author="Hikari <hikari@nhcarrigan.com>"`
- **Always use `--no-gpg-sign`** since Hikari doesn't have GPG signing set up - **Always sign commits** with Hikari's GPG key: `--gpg-sign=5380E4EE7307C808`
- **Never add `Co-Authored-By` lines** for Gitea commits - **Never add `Co-Authored-By` lines** for Gitea commits
- **Always ask for confirmation** before committing - **Always ask for confirmation** before committing
- **Always ask for confirmation** before pushing
Example commit command: Example commit command:
```bash ```bash
git commit --author="Hikari <hikari@nhcarrigan.com>" --no-gpg-sign -m "your commit message" git commit --author="Hikari <hikari@nhcarrigan.com>" --gpg-sign=5380E4EE7307C808 -m "your commit message"
```
Example push command:
```bash
git push https://hikari:TOKEN@git.nhcarrigan.com/nhcarrigan/hikari-desktop.git <branch>
``` ```
## Testing Requirements ## Testing Requirements
All new features, fixes, and significant changes should include tests whenever possible: **All changes MUST include tests.** This is non-negotiable — no feature, bug fix, or refactor should be committed without corresponding test coverage. If a change cannot be tested (e.g. pure UI layout, Tauri IPC calls that are impossible to mock), document why in a comment.
- **Frontend tests**: Use Vitest with `@testing-library/svelte` for component tests - **Frontend tests**: Use Vitest with `@testing-library/svelte` for component tests
- **Test files**: Place test files next to the code they test with `.test.ts` or `.spec.ts` extension - **Test files**: Place test files next to the code they test with `.test.ts` or `.spec.ts` extension
@@ -130,16 +137,47 @@ describe("FeatureName", () => {
}); });
``` ```
### Adding Tests for New Features ### Adding Tests for All Changes
When developing new features, always add corresponding tests: Every change — features, bug fixes, refactors — must include tests:
1. **Before implementing**: Consider what needs testing (happy path, edge cases, errors) 1. **Before implementing**: Consider what needs testing (happy path, edge cases, errors)
2. **During implementation**: Write tests alongside the code 2. **During implementation**: Write tests alongside the code
3. **After implementation**: Run `pnpm test:coverage` to verify coverage remains high 3. **After implementation**: Run `pnpm test:coverage` to verify coverage remains high
4. **Before committing**: Ensure `check-all.sh` passes (includes all tests) 4. **Before committing**: Ensure `check-all.sh` passes (includes all tests)
The goal is to maintain our near-100% coverage as the codebase grows, so future refactoring and changes can be made with confidence! **Do not commit changes without tests.** The goal is to maintain near-100% coverage as the codebase grows, so future refactoring can be done with confidence!
## Quality Assurance
Before committing any changes, **always run the full test suite**:
```bash
./check-all.sh
```
This script runs all checks in the correct order:
1. Frontend linting (ESLint)
2. Frontend formatting (Prettier)
3. Frontend type checking (svelte-check)
4. Frontend tests with coverage (Vitest)
5. Backend linting (Clippy with strict rules)
6. Backend tests with coverage (cargo test + llvm-cov)
**Important**: The script requires Node.js and Rust toolchains to be available:
- **Node.js tools** (pnpm, npm): Source nvm first if needed: `source ~/.nvm/nvm.sh`
- **Rust tools** (cargo, clippy): Should be in PATH via `~/.cargo/bin/`
If `check-all.sh` reports any failures:
1. Read the error messages carefully - they usually explain what needs fixing
2. Fix the issues (linting errors, test failures, etc.)
3. Run `check-all.sh` again to verify the fixes
4. Only commit once all checks pass ✨
**Never commit code that doesn't pass `check-all.sh`** - this ensures code quality and prevents broken builds!
## Project Context ## Project Context
+63 -63
View File
@@ -1,6 +1,6 @@
{ {
"name": "hikari-desktop", "name": "hikari-desktop",
"version": "1.5.0", "version": "1.10.0",
"description": "", "description": "",
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -27,69 +27,69 @@
}, },
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@codemirror/commands": "6.8.1", "@codemirror/commands": "6.10.2",
"@codemirror/lang-angular": "^0.1.4", "@codemirror/lang-angular": "0.1.4",
"@codemirror/lang-cpp": "^6.0.3", "@codemirror/lang-cpp": "6.0.3",
"@codemirror/lang-css": "^6.3.1", "@codemirror/lang-css": "6.3.1",
"@codemirror/lang-go": "^6.0.1", "@codemirror/lang-go": "6.0.1",
"@codemirror/lang-html": "^6.4.11", "@codemirror/lang-html": "6.4.11",
"@codemirror/lang-java": "^6.0.2", "@codemirror/lang-java": "6.0.2",
"@codemirror/lang-javascript": "^6.2.4", "@codemirror/lang-javascript": "6.2.4",
"@codemirror/lang-json": "^6.0.2", "@codemirror/lang-json": "6.0.2",
"@codemirror/lang-less": "^6.0.2", "@codemirror/lang-less": "6.0.2",
"@codemirror/lang-markdown": "^6.5.0", "@codemirror/lang-markdown": "6.5.0",
"@codemirror/lang-php": "^6.0.2", "@codemirror/lang-php": "6.0.2",
"@codemirror/lang-python": "^6.2.1", "@codemirror/lang-python": "6.2.1",
"@codemirror/lang-rust": "^6.0.2", "@codemirror/lang-rust": "6.0.2",
"@codemirror/lang-sass": "^6.0.2", "@codemirror/lang-sass": "6.0.2",
"@codemirror/lang-sql": "^6.10.0", "@codemirror/lang-sql": "6.10.0",
"@codemirror/lang-vue": "^0.1.3", "@codemirror/lang-vue": "0.1.3",
"@codemirror/lang-wast": "^6.0.2", "@codemirror/lang-wast": "6.0.2",
"@codemirror/lang-xml": "^6.1.0", "@codemirror/lang-xml": "6.1.0",
"@codemirror/lang-yaml": "^6.1.2", "@codemirror/lang-yaml": "6.1.2",
"@codemirror/language": "^6.12.1", "@codemirror/language": "6.12.2",
"@codemirror/legacy-modes": "^6.5.2", "@codemirror/legacy-modes": "6.5.2",
"@codemirror/state": "^6.5.4", "@codemirror/state": "6.5.4",
"@codemirror/theme-one-dark": "^6.1.3", "@codemirror/theme-one-dark": "6.1.3",
"@codemirror/view": "^6.39.11", "@codemirror/view": "6.39.15",
"@lezer/highlight": "^1.2.3", "@lezer/highlight": "1.2.3",
"@tauri-apps/api": "^2", "@tauri-apps/api": "2.10.1",
"@tauri-apps/plugin-clipboard-manager": "^2.3.2", "@tauri-apps/plugin-clipboard-manager": "2.3.2",
"@tauri-apps/plugin-dialog": "^2", "@tauri-apps/plugin-dialog": "2.6.0",
"@tauri-apps/plugin-fs": "^2.4.5", "@tauri-apps/plugin-fs": "2.4.5",
"@tauri-apps/plugin-notification": "^2", "@tauri-apps/plugin-notification": "2.3.3",
"@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-opener": "2.5.3",
"@tauri-apps/plugin-os": "^2", "@tauri-apps/plugin-os": "2.3.2",
"@tauri-apps/plugin-shell": "^2.3.4", "@tauri-apps/plugin-shell": "2.3.5",
"@tauri-apps/plugin-store": "^2", "@tauri-apps/plugin-store": "2.4.2",
"codemirror": "^6.0.2", "codemirror": "6.0.2",
"highlight.js": "^11.11.1", "highlight.js": "11.11.1",
"lucide-svelte": "^0.563.0", "lucide-svelte": "0.575.0",
"marked": "^17.0.1" "marked": "17.0.3"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.2", "@eslint/js": "9.39.3",
"@sveltejs/adapter-static": "^3.0.6", "@sveltejs/adapter-static": "3.0.10",
"@sveltejs/kit": "^2.9.0", "@sveltejs/kit": "2.53.2",
"@sveltejs/vite-plugin-svelte": "^5.0.0", "@sveltejs/vite-plugin-svelte": "5.1.1",
"@tailwindcss/vite": "^4.1.18", "@tailwindcss/vite": "4.2.1",
"@tauri-apps/cli": "^2", "@tauri-apps/cli": "2.10.0",
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "6.9.1",
"@testing-library/svelte": "^5.3.1", "@testing-library/svelte": "5.3.1",
"@vitest/coverage-v8": "^4.0.18", "@vitest/coverage-v8": "4.0.18",
"eslint": "^9.39.2", "eslint": "9.39.3",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "10.1.8",
"eslint-plugin-svelte": "^3.14.0", "eslint-plugin-svelte": "3.15.0",
"globals": "^17.0.0", "globals": "17.3.0",
"jsdom": "^27.4.0", "jsdom": "28.1.0",
"prettier": "^3.8.0", "prettier": "3.8.1",
"prettier-plugin-svelte": "^3.4.1", "prettier-plugin-svelte": "3.5.0",
"svelte": "^5.0.0", "svelte": "5.53.5",
"svelte-check": "^4.0.0", "svelte-check": "4.4.3",
"tailwindcss": "^4.1.18", "tailwindcss": "4.2.1",
"typescript": "~5.6.2", "typescript": "5.9.3",
"typescript-eslint": "^8.53.0", "typescript-eslint": "8.56.1",
"vite": "^6.0.3", "vite": "6.4.1",
"vitest": "^4.0.17" "vitest": "4.0.18"
} }
} }
+695 -682
View File
File diff suppressed because it is too large Load Diff
+493 -298
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "hikari-desktop" name = "hikari-desktop"
version = "1.5.0" version = "1.10.0"
description = "Hikari - Claude Code Visual Assistant" description = "Hikari - Claude Code Visual Assistant"
authors = ["Naomi Carrigan"] authors = ["Naomi Carrigan"]
edition = "2021" edition = "2021"
+567 -22
View File
@@ -7,6 +7,7 @@ use tauri_plugin_store::StoreExt;
use crate::achievements::{get_achievement_info, load_achievements, AchievementUnlockedEvent}; use crate::achievements::{get_achievement_info, load_achievements, AchievementUnlockedEvent};
use crate::bridge_manager::SharedBridgeManager; use crate::bridge_manager::SharedBridgeManager;
use crate::config::{ClaudeStartOptions, HikariConfig}; use crate::config::{ClaudeStartOptions, HikariConfig};
use crate::process_ext::HideWindow;
use crate::stats::UsageStats; use crate::stats::UsageStats;
use crate::temp_manager::SharedTempFileManager; use crate::temp_manager::SharedTempFileManager;
@@ -49,6 +50,67 @@ fn wsl_path_to_windows(wsl_path: &str) -> Option<String> {
} }
} }
/// Create a Command instance for executing Claude CLI commands
/// On Windows, this will use WSL to execute the command
/// On other platforms, it executes directly
fn create_claude_command() -> std::process::Command {
#[cfg(target_os = "windows")]
{
// Use `which` inside WSL to find the claude binary dynamically
// Non-login shells launched by `wsl` don't inherit the full user PATH,
// so we need to use a login shell to get the correct PATH
let which_output = std::process::Command::new("wsl")
.hide_window()
.args(["-e", "bash", "-l", "-c", "which claude"])
.output();
match which_output {
Ok(output) if output.status.success() => {
let claude_path = String::from_utf8_lossy(&output.stdout).trim().to_string();
let mut cmd = std::process::Command::new("wsl");
cmd.hide_window();
cmd.arg(claude_path);
cmd
}
_ => {
// Fallback to just "claude" if which fails
// This maintains backwards compatibility
let mut cmd = std::process::Command::new("wsl");
cmd.hide_window();
cmd.arg("claude");
cmd
}
}
}
#[cfg(not(target_os = "windows"))]
{
// Use `which` to find the claude binary dynamically
// This works regardless of how Claude Code was installed (standalone, npm, etc.)
// and avoids hardcoding paths
let which_output = std::process::Command::new("which")
.hide_window()
.arg("claude")
.output();
match which_output {
Ok(output) if output.status.success() => {
let claude_path = String::from_utf8_lossy(&output.stdout).trim().to_string();
let mut cmd = std::process::Command::new(claude_path);
cmd.hide_window();
cmd
}
_ => {
// Fallback to just "claude" if which fails
// This maintains backwards compatibility
let mut cmd = std::process::Command::new("claude");
cmd.hide_window();
cmd
}
}
}
}
#[tauri::command] #[tauri::command]
pub async fn start_claude( pub async fn start_claude(
bridge_manager: State<'_, SharedBridgeManager>, bridge_manager: State<'_, SharedBridgeManager>,
@@ -281,6 +343,121 @@ pub async fn answer_question(
manager.send_tool_result(&conversation_id, &tool_use_id, answers) manager.send_tool_result(&conversation_id, &tool_use_id, answers)
} }
#[derive(Debug, Serialize)]
pub struct WorkspaceHookInfo {
pub has_concerns: bool,
pub hook_types: Vec<String>,
pub mcp_servers: Vec<String>,
pub custom_commands: Vec<String>,
}
/// Check whether a working directory has Claude Code hooks, MCP servers, or custom commands.
///
/// Hikari Desktop runs Claude in `--output-format stream-json` (non-interactive mode),
/// which bypasses Claude's own workspace trust dialog. We therefore check for these
/// ourselves so the frontend can show its own trust gate before launching.
#[tauri::command]
pub async fn check_workspace_hooks(working_dir: String) -> WorkspaceHookInfo {
let use_wsl = cfg!(windows) && working_dir.starts_with('/');
let settings_paths = [
format!("{}/.claude/settings.json", working_dir),
format!("{}/.claude/settings.local.json", working_dir),
];
let mut all_hook_types: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
let mut all_mcp_servers: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
for path in &settings_paths {
let content = if use_wsl {
match read_file_via_wsl(path).await {
Ok(c) => c,
Err(_) => continue,
}
} else {
match std::fs::read_to_string(path) {
Ok(c) => c,
Err(_) => continue,
}
};
let settings: serde_json::Value = match serde_json::from_str(&content) {
Ok(v) => v,
Err(_) => continue,
};
if let Some(hooks) = settings.get("hooks").and_then(|h| h.as_object()) {
for key in hooks.keys() {
all_hook_types.insert(key.clone());
}
}
if let Some(servers) = settings.get("mcpServers").and_then(|s| s.as_object()) {
for key in servers.keys() {
all_mcp_servers.insert(key.clone());
}
}
}
let custom_commands = list_workspace_commands(&working_dir, use_wsl).await;
let hook_types: Vec<String> = all_hook_types.into_iter().collect();
let mcp_servers: Vec<String> = all_mcp_servers.into_iter().collect();
let has_concerns = !hook_types.is_empty() || !mcp_servers.is_empty() || !custom_commands.is_empty();
WorkspaceHookInfo {
has_concerns,
hook_types,
mcp_servers,
custom_commands,
}
}
async fn list_workspace_commands(working_dir: &str, use_wsl: bool) -> Vec<String> {
let commands_dir = format!("{}/.claude/commands", working_dir);
if use_wsl {
let script = format!(
"if [ -d '{0}' ]; then for f in '{0}'/*.md; do [ -f \"$f\" ] && basename \"$f\" .md; done; fi",
commands_dir
);
let Ok(output) = std::process::Command::new("wsl")
.hide_window()
.args(["-e", "sh", "-c", &script])
.output()
else {
return vec![];
};
String::from_utf8_lossy(&output.stdout)
.lines()
.filter(|l| !l.is_empty())
.map(str::to_string)
.collect()
} else {
let dir = std::path::Path::new(&commands_dir);
if !dir.exists() {
return vec![];
}
let Ok(entries) = std::fs::read_dir(dir) else {
return vec![];
};
let mut names: Vec<String> = entries
.filter_map(|e| e.ok())
.filter(|e| {
e.path()
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("md"))
})
.filter_map(|e| {
e.path()
.file_stem()
.map(|s| s.to_string_lossy().to_string())
})
.collect();
names.sort();
names
}
}
#[tauri::command] #[tauri::command]
pub async fn list_skills() -> Result<Vec<String>, String> { pub async fn list_skills() -> Result<Vec<String>, String> {
// On Windows, we need to use WSL to access the skills directory // On Windows, we need to use WSL to access the skills directory
@@ -328,6 +505,7 @@ async fn list_skills_via_wsl() -> Result<Vec<String>, String> {
// Use WSL to list directories in ~/.claude/skills that contain SKILL.md // Use WSL to list directories in ~/.claude/skills that contain SKILL.md
let output = Command::new("wsl") let output = Command::new("wsl")
.hide_window()
.args([ .args([
"-e", "-e",
"sh", "sh",
@@ -627,6 +805,7 @@ async fn list_directory_via_wsl(path: &str) -> Result<Vec<FileEntry>, String> {
); );
let output = Command::new("wsl") let output = Command::new("wsl")
.hide_window()
.args(["-e", "sh", "-c", &script]) .args(["-e", "sh", "-c", &script])
.output() .output()
.map_err(|e| format!("Failed to execute WSL command: {}", e))?; .map_err(|e| format!("Failed to execute WSL command: {}", e))?;
@@ -683,12 +862,33 @@ pub async fn read_file_content(path: String) -> Result<String, String> {
.map_err(|e| format!("Failed to read file: {}", e)) .map_err(|e| format!("Failed to read file: {}", e))
} }
/// Read the first `# Heading` from a WSL file path (for Windows).
/// Returns `None` if the file cannot be read or has no top-level heading.
#[cfg(target_os = "windows")]
fn read_wsl_file_first_heading(path: &str) -> Option<String> {
use std::process::Command;
let output = Command::new("wsl")
.hide_window()
.args(["-e", "bash", "-c", &format!("head -20 '{}'", path)])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let content = String::from_utf8_lossy(&output.stdout);
extract_first_heading(&content)
}
/// Read file content via WSL (for Windows with WSL paths) /// Read file content via WSL (for Windows with WSL paths)
#[allow(dead_code)] #[allow(dead_code)]
async fn read_file_via_wsl(path: &str) -> Result<String, String> { async fn read_file_via_wsl(path: &str) -> Result<String, String> {
use std::process::Command; use std::process::Command;
let output = Command::new("wsl") let output = Command::new("wsl")
.hide_window()
.args(["-e", "cat", path]) .args(["-e", "cat", path])
.output() .output()
.map_err(|e| format!("Failed to execute WSL command: {}", e))?; .map_err(|e| format!("Failed to execute WSL command: {}", e))?;
@@ -720,6 +920,7 @@ async fn write_file_via_wsl(path: &str, content: &str) -> Result<(), String> {
use std::process::{Command, Stdio}; use std::process::{Command, Stdio};
let mut child = Command::new("wsl") let mut child = Command::new("wsl")
.hide_window()
.args(["-e", "sh", "-c", &format!("cat > '{}'", path)]) .args(["-e", "sh", "-c", &format!("cat > '{}'", path)])
.stdin(Stdio::piped()) .stdin(Stdio::piped())
.spawn() .spawn()
@@ -768,6 +969,7 @@ async fn create_file_via_wsl(path: &str) -> Result<(), String> {
// Check if file exists first // Check if file exists first
let check = Command::new("wsl") let check = Command::new("wsl")
.hide_window()
.args(["-e", "test", "-e", path]) .args(["-e", "test", "-e", path])
.status() .status()
.map_err(|e| format!("Failed to execute WSL command: {}", e))?; .map_err(|e| format!("Failed to execute WSL command: {}", e))?;
@@ -777,6 +979,7 @@ async fn create_file_via_wsl(path: &str) -> Result<(), String> {
} }
let output = Command::new("wsl") let output = Command::new("wsl")
.hide_window()
.args(["-e", "touch", path]) .args(["-e", "touch", path])
.output() .output()
.map_err(|e| format!("Failed to execute WSL command: {}", e))?; .map_err(|e| format!("Failed to execute WSL command: {}", e))?;
@@ -817,6 +1020,7 @@ async fn create_directory_via_wsl(path: &str) -> Result<(), String> {
// Check if directory exists first // Check if directory exists first
let check = Command::new("wsl") let check = Command::new("wsl")
.hide_window()
.args(["-e", "test", "-e", path]) .args(["-e", "test", "-e", path])
.status() .status()
.map_err(|e| format!("Failed to execute WSL command: {}", e))?; .map_err(|e| format!("Failed to execute WSL command: {}", e))?;
@@ -826,6 +1030,7 @@ async fn create_directory_via_wsl(path: &str) -> Result<(), String> {
} }
let output = Command::new("wsl") let output = Command::new("wsl")
.hide_window()
.args(["-e", "mkdir", "-p", path]) .args(["-e", "mkdir", "-p", path])
.output() .output()
.map_err(|e| format!("Failed to execute WSL command: {}", e))?; .map_err(|e| format!("Failed to execute WSL command: {}", e))?;
@@ -870,6 +1075,7 @@ async fn delete_file_via_wsl(path: &str) -> Result<(), String> {
// Check if path exists // Check if path exists
let check_exists = Command::new("wsl") let check_exists = Command::new("wsl")
.hide_window()
.args(["-e", "test", "-e", path]) .args(["-e", "test", "-e", path])
.status() .status()
.map_err(|e| format!("Failed to execute WSL command: {}", e))?; .map_err(|e| format!("Failed to execute WSL command: {}", e))?;
@@ -880,6 +1086,7 @@ async fn delete_file_via_wsl(path: &str) -> Result<(), String> {
// Check if path is a directory // Check if path is a directory
let check_dir = Command::new("wsl") let check_dir = Command::new("wsl")
.hide_window()
.args(["-e", "test", "-d", path]) .args(["-e", "test", "-d", path])
.status() .status()
.map_err(|e| format!("Failed to execute WSL command: {}", e))?; .map_err(|e| format!("Failed to execute WSL command: {}", e))?;
@@ -889,6 +1096,7 @@ async fn delete_file_via_wsl(path: &str) -> Result<(), String> {
} }
let output = Command::new("wsl") let output = Command::new("wsl")
.hide_window()
.args(["-e", "rm", path]) .args(["-e", "rm", path])
.output() .output()
.map_err(|e| format!("Failed to execute WSL command: {}", e))?; .map_err(|e| format!("Failed to execute WSL command: {}", e))?;
@@ -933,6 +1141,7 @@ async fn delete_directory_via_wsl(path: &str) -> Result<(), String> {
// Check if path exists // Check if path exists
let check_exists = Command::new("wsl") let check_exists = Command::new("wsl")
.hide_window()
.args(["-e", "test", "-e", path]) .args(["-e", "test", "-e", path])
.status() .status()
.map_err(|e| format!("Failed to execute WSL command: {}", e))?; .map_err(|e| format!("Failed to execute WSL command: {}", e))?;
@@ -943,6 +1152,7 @@ async fn delete_directory_via_wsl(path: &str) -> Result<(), String> {
// Check if path is a directory // Check if path is a directory
let check_dir = Command::new("wsl") let check_dir = Command::new("wsl")
.hide_window()
.args(["-e", "test", "-d", path]) .args(["-e", "test", "-d", path])
.status() .status()
.map_err(|e| format!("Failed to execute WSL command: {}", e))?; .map_err(|e| format!("Failed to execute WSL command: {}", e))?;
@@ -952,6 +1162,7 @@ async fn delete_directory_via_wsl(path: &str) -> Result<(), String> {
} }
let output = Command::new("wsl") let output = Command::new("wsl")
.hide_window()
.args(["-e", "rm", "-rf", path]) .args(["-e", "rm", "-rf", path])
.output() .output()
.map_err(|e| format!("Failed to execute WSL command: {}", e))?; .map_err(|e| format!("Failed to execute WSL command: {}", e))?;
@@ -997,6 +1208,7 @@ async fn rename_path_via_wsl(old_path: &str, new_path: &str) -> Result<(), Strin
// Check if old path exists // Check if old path exists
let check_old = Command::new("wsl") let check_old = Command::new("wsl")
.hide_window()
.args(["-e", "test", "-e", old_path]) .args(["-e", "test", "-e", old_path])
.status() .status()
.map_err(|e| format!("Failed to execute WSL command: {}", e))?; .map_err(|e| format!("Failed to execute WSL command: {}", e))?;
@@ -1007,6 +1219,7 @@ async fn rename_path_via_wsl(old_path: &str, new_path: &str) -> Result<(), Strin
// Check if new path already exists // Check if new path already exists
let check_new = Command::new("wsl") let check_new = Command::new("wsl")
.hide_window()
.args(["-e", "test", "-e", new_path]) .args(["-e", "test", "-e", new_path])
.status() .status()
.map_err(|e| format!("Failed to execute WSL command: {}", e))?; .map_err(|e| format!("Failed to execute WSL command: {}", e))?;
@@ -1016,6 +1229,7 @@ async fn rename_path_via_wsl(old_path: &str, new_path: &str) -> Result<(), Strin
} }
let output = Command::new("wsl") let output = Command::new("wsl")
.hide_window()
.args(["-e", "mv", old_path, new_path]) .args(["-e", "mv", old_path, new_path])
.output() .output()
.map_err(|e| format!("Failed to execute WSL command: {}", e))?; .map_err(|e| format!("Failed to execute WSL command: {}", e))?;
@@ -1159,13 +1373,90 @@ pub async fn close_application(app_handle: AppHandle) -> Result<(), String> {
Ok(()) Ok(())
} }
#[derive(serde::Serialize)]
pub struct MemoryFileInfo {
pub path: String,
pub heading: Option<String>,
}
#[derive(serde::Serialize)] #[derive(serde::Serialize)]
pub struct MemoryFilesResponse { pub struct MemoryFilesResponse {
pub files: Vec<String>, pub files: Vec<MemoryFileInfo>,
}
/// Extract the first `# Heading` from a string of file content.
fn extract_first_heading(content: &str) -> Option<String> {
content.lines().find_map(|line| {
let trimmed = line.trim();
if let Some(rest) = trimmed.strip_prefix("# ") {
let heading = rest.trim().to_string();
if !heading.is_empty() {
return Some(heading);
}
}
None
})
} }
#[tauri::command] #[tauri::command]
pub async fn list_memory_files() -> Result<MemoryFilesResponse, String> { pub async fn list_memory_files() -> Result<MemoryFilesResponse, String> {
// On Windows, we need to look in the WSL home directory
// On Linux/Mac, use the native home directory
#[cfg(target_os = "windows")]
{
list_memory_files_via_wsl().await
}
#[cfg(not(target_os = "windows"))]
{
list_memory_files_native().await
}
}
/// List memory files via WSL (for Windows)
#[cfg(target_os = "windows")]
async fn list_memory_files_via_wsl() -> Result<MemoryFilesResponse, String> {
use std::process::Command;
// Use WSL to find all memory files in the WSL home directory
// This script finds all "memory" directories and lists their files
let script = r#"
find ~/.claude/projects -type d -name memory 2>/dev/null | while read dir; do
find "$dir" -maxdepth 1 -type f 2>/dev/null
done | sort
"#;
let output = Command::new("wsl")
.hide_window()
.args(["-e", "bash", "-l", "-c", script])
.output()
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("Failed to list memory files: {}", stderr));
}
let stdout = String::from_utf8_lossy(&output.stdout);
let paths: Vec<String> = stdout
.lines()
.filter(|line| !line.trim().is_empty())
.map(|line| line.trim().to_string())
.collect();
// Read first heading from each file via WSL
let mut files = Vec::new();
for path in paths {
let heading = read_wsl_file_first_heading(&path);
files.push(MemoryFileInfo { path, heading });
}
Ok(MemoryFilesResponse { files })
}
/// List memory files using native filesystem (for Linux/Mac)
#[cfg(not(target_os = "windows"))]
async fn list_memory_files_native() -> Result<MemoryFilesResponse, String> {
use std::fs; use std::fs;
// Get the .claude directory in the user's home // Get the .claude directory in the user's home
@@ -1181,10 +1472,13 @@ pub async fn list_memory_files() -> Result<MemoryFilesResponse, String> {
return Ok(MemoryFilesResponse { files: Vec::new() }); return Ok(MemoryFilesResponse { files: Vec::new() });
} }
let mut memory_files = Vec::new(); let mut memory_paths = Vec::new();
// Recursively find all memory directories // Recursively find all memory directories
fn find_memory_files(dir: &std::path::Path, files: &mut Vec<String>) -> std::io::Result<()> { fn find_memory_files(
dir: &std::path::Path,
files: &mut Vec<String>,
) -> std::io::Result<()> {
if !dir.is_dir() { if !dir.is_dir() {
return Ok(()); return Ok(());
} }
@@ -1217,23 +1511,32 @@ pub async fn list_memory_files() -> Result<MemoryFilesResponse, String> {
Ok(()) Ok(())
} }
if let Err(e) = find_memory_files(&projects_dir, &mut memory_files) { if let Err(e) = find_memory_files(&projects_dir, &mut memory_paths) {
return Err(format!("Failed to list memory files: {}", e)); return Err(format!("Failed to list memory files: {}", e));
} }
// Sort files alphabetically // Sort files alphabetically
memory_files.sort(); memory_paths.sort();
Ok(MemoryFilesResponse { // Read first heading from each file
files: memory_files, let files = memory_paths
}) .into_iter()
.map(|path| {
let heading = fs::read_to_string(&path)
.ok()
.and_then(|content| extract_first_heading(&content));
MemoryFileInfo { path, heading }
})
.collect();
Ok(MemoryFilesResponse { files })
} }
#[tauri::command] #[tauri::command]
pub async fn get_claude_version() -> Result<String, String> { pub async fn get_claude_version() -> Result<String, String> {
tracing::debug!("Getting Claude CLI version"); tracing::debug!("Getting Claude CLI version");
let output = std::process::Command::new("claude") let output = create_claude_command()
.arg("--version") .arg("--version")
.output(); .output();
@@ -1258,6 +1561,144 @@ pub async fn get_claude_version() -> Result<String, String> {
} }
} }
// ==================== Auth Commands ====================
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClaudeAuthStatus {
pub is_logged_in: bool,
pub email: Option<String>,
pub org_id: Option<String>,
pub org_name: Option<String>,
pub api_key_source: Option<String>,
pub api_provider: Option<String>,
pub subscription_type: Option<String>,
}
#[tauri::command]
pub async fn get_auth_status() -> Result<ClaudeAuthStatus, String> {
tracing::debug!("Getting Claude auth status");
let output = create_claude_command()
.args(["auth", "status"])
.output()
.map_err(|e| format!("Failed to run claude auth status: {}", e))?;
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
let raw = if stdout.is_empty() { &stderr } else { &stdout };
if let Ok(json) = serde_json::from_str::<serde_json::Value>(raw) {
let is_logged_in = json
.get("loggedIn")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let email = json
.get("email")
.and_then(|v| v.as_str())
.map(String::from);
let org_id = json
.get("orgId")
.and_then(|v| v.as_str())
.map(String::from);
let org_name = json
.get("orgName")
.and_then(|v| v.as_str())
.map(String::from);
let api_key_source = json
.get("apiKeySource")
.and_then(|v| v.as_str())
.map(String::from);
let api_provider = json
.get("apiProvider")
.and_then(|v| v.as_str())
.map(String::from);
let subscription_type = json
.get("subscriptionType")
.and_then(|v| v.as_str())
.map(String::from);
tracing::info!("Claude auth status: logged_in={}", is_logged_in);
Ok(ClaudeAuthStatus {
is_logged_in,
email,
org_id,
org_name,
api_key_source,
api_provider,
subscription_type,
})
} else {
// Non-JSON output: fall back to heuristic
let lower = raw.to_lowercase();
let is_logged_in = output.status.success()
&& !lower.contains("not logged in")
&& !lower.contains("not authenticated")
&& !lower.contains("no account");
tracing::info!("Claude auth status (non-JSON): logged_in={}", is_logged_in);
Ok(ClaudeAuthStatus {
is_logged_in,
email: None,
org_id: None,
org_name: None,
api_key_source: None,
api_provider: None,
subscription_type: None,
})
}
}
#[tauri::command]
pub async fn auth_login() -> Result<String, String> {
tracing::info!("Running claude auth login");
let output = create_claude_command()
.args(["auth", "login"])
.output()
.map_err(|e| format!("Failed to run claude auth login: {}", e))?;
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
if output.status.success() {
let message = if stdout.is_empty() { "Login successful".to_string() } else { stdout };
tracing::info!("Claude auth login succeeded");
Ok(message)
} else {
let error = if stderr.is_empty() { stdout } else { stderr };
tracing::error!("Claude auth login failed: {}", error);
Err(format!("Login failed: {}", error))
}
}
#[tauri::command]
pub async fn auth_logout() -> Result<String, String> {
tracing::info!("Running claude auth logout");
let output = create_claude_command()
.args(["auth", "logout"])
.output()
.map_err(|e| format!("Failed to run claude auth logout: {}", e))?;
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
if output.status.success() {
let message = if stdout.is_empty() { "Logged out successfully".to_string() } else { stdout };
tracing::info!("Claude auth logout succeeded");
Ok(message)
} else {
let error = if stderr.is_empty() { stdout } else { stderr };
tracing::error!("Claude auth logout failed: {}", error);
Err(format!("Logout failed: {}", error))
}
}
// ==================== Plugin Management Commands ==================== // ==================== Plugin Management Commands ====================
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -1323,7 +1764,7 @@ fn parse_plugin_list(stdout: &str) -> Vec<PluginInfo> {
pub async fn list_plugins() -> Result<Vec<PluginInfo>, String> { pub async fn list_plugins() -> Result<Vec<PluginInfo>, String> {
tracing::debug!("Listing Claude Code plugins"); tracing::debug!("Listing Claude Code plugins");
let output = std::process::Command::new("claude") let output = create_claude_command()
.arg("plugin") .arg("plugin")
.arg("list") .arg("list")
.output(); .output();
@@ -1352,7 +1793,7 @@ pub async fn list_plugins() -> Result<Vec<PluginInfo>, String> {
pub async fn install_plugin(plugin_name: String) -> Result<String, String> { pub async fn install_plugin(plugin_name: String) -> Result<String, String> {
tracing::debug!("Installing plugin: {}", plugin_name); tracing::debug!("Installing plugin: {}", plugin_name);
let output = std::process::Command::new("claude") let output = create_claude_command()
.arg("plugin") .arg("plugin")
.arg("install") .arg("install")
.arg(&plugin_name) .arg(&plugin_name)
@@ -1381,7 +1822,7 @@ pub async fn install_plugin(plugin_name: String) -> Result<String, String> {
pub async fn uninstall_plugin(plugin_name: String) -> Result<String, String> { pub async fn uninstall_plugin(plugin_name: String) -> Result<String, String> {
tracing::debug!("Uninstalling plugin: {}", plugin_name); tracing::debug!("Uninstalling plugin: {}", plugin_name);
let output = std::process::Command::new("claude") let output = create_claude_command()
.arg("plugin") .arg("plugin")
.arg("uninstall") .arg("uninstall")
.arg(&plugin_name) .arg(&plugin_name)
@@ -1410,7 +1851,7 @@ pub async fn uninstall_plugin(plugin_name: String) -> Result<String, String> {
pub async fn enable_plugin(plugin_name: String) -> Result<String, String> { pub async fn enable_plugin(plugin_name: String) -> Result<String, String> {
tracing::debug!("Enabling plugin: {}", plugin_name); tracing::debug!("Enabling plugin: {}", plugin_name);
let output = std::process::Command::new("claude") let output = create_claude_command()
.arg("plugin") .arg("plugin")
.arg("enable") .arg("enable")
.arg(&plugin_name) .arg(&plugin_name)
@@ -1439,7 +1880,7 @@ pub async fn enable_plugin(plugin_name: String) -> Result<String, String> {
pub async fn disable_plugin(plugin_name: String) -> Result<String, String> { pub async fn disable_plugin(plugin_name: String) -> Result<String, String> {
tracing::debug!("Disabling plugin: {}", plugin_name); tracing::debug!("Disabling plugin: {}", plugin_name);
let output = std::process::Command::new("claude") let output = create_claude_command()
.arg("plugin") .arg("plugin")
.arg("disable") .arg("disable")
.arg(&plugin_name) .arg(&plugin_name)
@@ -1468,7 +1909,7 @@ pub async fn disable_plugin(plugin_name: String) -> Result<String, String> {
pub async fn update_plugin(plugin_name: String) -> Result<String, String> { pub async fn update_plugin(plugin_name: String) -> Result<String, String> {
tracing::debug!("Updating plugin: {}", plugin_name); tracing::debug!("Updating plugin: {}", plugin_name);
let output = std::process::Command::new("claude") let output = create_claude_command()
.arg("plugin") .arg("plugin")
.arg("update") .arg("update")
.arg(&plugin_name) .arg(&plugin_name)
@@ -1540,7 +1981,7 @@ fn parse_marketplace_list(stdout: &str) -> Vec<MarketplaceInfo> {
pub async fn list_marketplaces() -> Result<Vec<MarketplaceInfo>, String> { pub async fn list_marketplaces() -> Result<Vec<MarketplaceInfo>, String> {
tracing::debug!("Listing plugin marketplaces"); tracing::debug!("Listing plugin marketplaces");
let output = std::process::Command::new("claude") let output = create_claude_command()
.arg("plugin") .arg("plugin")
.arg("marketplace") .arg("marketplace")
.arg("list") .arg("list")
@@ -1573,7 +2014,7 @@ pub async fn list_marketplaces() -> Result<Vec<MarketplaceInfo>, String> {
pub async fn add_marketplace(source: String) -> Result<String, String> { pub async fn add_marketplace(source: String) -> Result<String, String> {
tracing::debug!("Adding marketplace: {}", source); tracing::debug!("Adding marketplace: {}", source);
let output = std::process::Command::new("claude") let output = create_claude_command()
.arg("plugin") .arg("plugin")
.arg("marketplace") .arg("marketplace")
.arg("add") .arg("add")
@@ -1606,7 +2047,7 @@ pub async fn add_marketplace(source: String) -> Result<String, String> {
pub async fn remove_marketplace(name: String) -> Result<String, String> { pub async fn remove_marketplace(name: String) -> Result<String, String> {
tracing::debug!("Removing marketplace: {}", name); tracing::debug!("Removing marketplace: {}", name);
let output = std::process::Command::new("claude") let output = create_claude_command()
.arg("plugin") .arg("plugin")
.arg("marketplace") .arg("marketplace")
.arg("remove") .arg("remove")
@@ -1746,7 +2187,7 @@ fn parse_mcp_server_list(stdout: &str) -> Vec<McpServerInfo> {
pub async fn list_mcp_servers() -> Result<Vec<McpServerInfo>, String> { pub async fn list_mcp_servers() -> Result<Vec<McpServerInfo>, String> {
tracing::debug!("Listing MCP servers"); tracing::debug!("Listing MCP servers");
let output = std::process::Command::new("claude") let output = create_claude_command()
.arg("mcp") .arg("mcp")
.arg("list") .arg("list")
.output(); .output();
@@ -1788,7 +2229,7 @@ pub async fn get_mcp_server(name: String) -> Result<McpServerInfo, String> {
pub async fn remove_mcp_server(name: String) -> Result<String, String> { pub async fn remove_mcp_server(name: String) -> Result<String, String> {
tracing::debug!("Removing MCP server: {}", name); tracing::debug!("Removing MCP server: {}", name);
let output = std::process::Command::new("claude") let output = create_claude_command()
.arg("mcp") .arg("mcp")
.arg("remove") .arg("remove")
.arg(&name) .arg(&name)
@@ -1823,7 +2264,7 @@ pub async fn add_mcp_server(
) -> Result<String, String> { ) -> Result<String, String> {
tracing::debug!("Adding MCP server: {} with transport {}", name, transport); tracing::debug!("Adding MCP server: {} with transport {}", name, transport);
let mut cmd = std::process::Command::new("claude"); let mut cmd = create_claude_command();
cmd.arg("mcp").arg("add"); cmd.arg("mcp").arg("add");
// Add transport flag // Add transport flag
@@ -1871,7 +2312,7 @@ pub async fn add_mcp_server(
pub async fn get_mcp_server_details(name: String) -> Result<String, String> { pub async fn get_mcp_server_details(name: String) -> Result<String, String> {
tracing::debug!("Getting detailed info for MCP server: {}", name); tracing::debug!("Getting detailed info for MCP server: {}", name);
let output = std::process::Command::new("claude") let output = create_claude_command()
.arg("mcp") .arg("mcp")
.arg("get") .arg("get")
.arg(&name) .arg(&name)
@@ -1908,6 +2349,49 @@ mod tests {
tokio::runtime::Runtime::new().unwrap().block_on(f) tokio::runtime::Runtime::new().unwrap().block_on(f)
} }
// ==================== create_claude_command tests ====================
#[test]
#[cfg(target_os = "windows")]
fn test_create_claude_command_windows() {
// On Windows, should create a command that uses wsl with full path to claude
// The path is resolved dynamically via `which` in a login shell
let cmd = create_claude_command();
let program = cmd.get_program();
assert_eq!(program, "wsl");
// Verify the first argument is a path to claude (full path from `which`)
// or fallback to just "claude" if which fails
let args: Vec<&std::ffi::OsStr> = cmd.get_args().collect();
assert_eq!(args.len(), 1);
let arg_str = args[0].to_string_lossy();
assert!(
arg_str.contains("claude"),
"Expected argument to contain 'claude', got: {}",
arg_str
);
}
#[test]
#[cfg(not(target_os = "windows"))]
fn test_create_claude_command_linux() {
// On Linux/Mac, should create a command that uses the full path to claude
// (resolved via `which` command)
let cmd = create_claude_command();
let program = cmd.get_program();
// The program should be the full path to claude (from `which`)
// or fallback to "claude" if which fails
let program_str = program.to_string_lossy();
assert!(
program_str.ends_with("claude"),
"Expected program to end with 'claude', got: {}",
program_str
);
}
// ==================== validate_directory tests ==================== // ==================== validate_directory tests ====================
#[test] #[test]
@@ -2477,4 +2961,65 @@ gitea: gitea-mcp -t stdio (STDIO) - ✓ Connected"#;
assert_eq!(servers[0].name, "asana"); assert_eq!(servers[0].name, "asana");
assert_eq!(servers[1].name, "gitea"); assert_eq!(servers[1].name, "gitea");
} }
// ==================== extract_first_heading tests ====================
#[test]
fn test_extract_first_heading_returns_heading() {
let content = "# My Memory File\n\nSome content here.";
assert_eq!(
extract_first_heading(content),
Some("My Memory File".to_string())
);
}
#[test]
fn test_extract_first_heading_ignores_non_h1() {
let content = "## Section Header\n### Sub-section\nSome content.";
assert_eq!(extract_first_heading(content), None);
}
#[test]
fn test_extract_first_heading_finds_first_h1_after_other_lines() {
let content = "Some intro text\n\n# The Real Title\n\nMore content.";
assert_eq!(
extract_first_heading(content),
Some("The Real Title".to_string())
);
}
#[test]
fn test_extract_first_heading_trims_whitespace() {
let content = "# Trimmed Heading \n\nContent.";
assert_eq!(
extract_first_heading(content),
Some("Trimmed Heading".to_string())
);
}
#[test]
fn test_extract_first_heading_returns_none_for_empty_content() {
assert_eq!(extract_first_heading(""), None);
}
#[test]
fn test_extract_first_heading_returns_none_for_empty_heading() {
let content = "# \n\nContent after empty heading.";
assert_eq!(extract_first_heading(content), None);
}
#[test]
fn test_extract_first_heading_returns_none_when_no_headings() {
let content = "Just some plain text.\nNo headings here at all.";
assert_eq!(extract_first_heading(content), None);
}
#[test]
fn test_extract_first_heading_handles_leading_whitespace_on_line() {
let content = " # Indented Heading\n\nContent.";
assert_eq!(
extract_first_heading(content),
Some("Indented Heading".to_string())
);
}
} }
+79
View File
@@ -25,6 +25,15 @@ pub struct ClaudeStartOptions {
#[serde(default)] #[serde(default)]
pub resume_session_id: Option<String>, pub resume_session_id: Option<String>,
#[serde(default)]
pub use_worktree: bool,
#[serde(default)]
pub disable_1m_context: bool,
#[serde(default)]
pub max_output_tokens: Option<u64>,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -113,6 +122,42 @@ pub struct HikariConfig {
#[serde(default = "default_discord_rpc_enabled")] #[serde(default = "default_discord_rpc_enabled")]
pub discord_rpc_enabled: bool, pub discord_rpc_enabled: bool,
#[serde(default)]
pub use_worktree: bool,
#[serde(default)]
pub disable_1m_context: bool,
#[serde(default)]
pub max_output_tokens: Option<u64>,
#[serde(default)]
pub trusted_workspaces: Vec<String>,
// Background image settings
#[serde(default)]
pub background_image_path: Option<String>,
#[serde(default = "default_background_image_opacity")]
pub background_image_opacity: f32,
#[serde(default)]
pub show_thinking_blocks: bool,
// Custom terminal font settings
#[serde(default)]
pub custom_font_path: Option<String>,
#[serde(default)]
pub custom_font_family: Option<String>,
// Custom UI font settings
#[serde(default)]
pub custom_ui_font_path: Option<String>,
#[serde(default)]
pub custom_ui_font_family: Option<String>,
} }
impl Default for HikariConfig { impl Default for HikariConfig {
@@ -145,6 +190,17 @@ impl Default for HikariConfig {
budget_action: BudgetAction::Warn, budget_action: BudgetAction::Warn,
budget_warning_threshold: 0.8, budget_warning_threshold: 0.8,
discord_rpc_enabled: true, discord_rpc_enabled: true,
use_worktree: false,
disable_1m_context: false,
max_output_tokens: None,
trusted_workspaces: Vec::new(),
background_image_path: None,
background_image_opacity: 0.3,
show_thinking_blocks: false,
custom_font_path: None,
custom_font_family: None,
custom_ui_font_path: None,
custom_ui_font_family: None,
} }
} }
} }
@@ -181,6 +237,10 @@ fn default_discord_rpc_enabled() -> bool {
true true
} }
fn default_background_image_opacity() -> f32 {
0.3
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] #[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
pub enum BudgetAction { pub enum BudgetAction {
@@ -252,6 +312,14 @@ mod tests {
assert_eq!(config.budget_action, BudgetAction::Warn); assert_eq!(config.budget_action, BudgetAction::Warn);
assert!((config.budget_warning_threshold - 0.8).abs() < f32::EPSILON); assert!((config.budget_warning_threshold - 0.8).abs() < f32::EPSILON);
assert!(config.discord_rpc_enabled); assert!(config.discord_rpc_enabled);
assert!(!config.use_worktree);
assert!(!config.disable_1m_context);
assert!(config.trusted_workspaces.is_empty());
assert!(!config.show_thinking_blocks);
assert!(config.custom_font_path.is_none());
assert!(config.custom_font_family.is_none());
assert!(config.custom_ui_font_path.is_none());
assert!(config.custom_ui_font_family.is_none());
} }
#[test] #[test]
@@ -284,6 +352,17 @@ mod tests {
budget_action: BudgetAction::Block, budget_action: BudgetAction::Block,
budget_warning_threshold: 0.75, budget_warning_threshold: 0.75,
discord_rpc_enabled: true, discord_rpc_enabled: true,
use_worktree: true,
disable_1m_context: false,
max_output_tokens: Some(32000),
trusted_workspaces: vec!["/home/naomi/projects/trusted".to_string()],
background_image_path: Some("/home/naomi/bg.png".to_string()),
background_image_opacity: 0.25,
show_thinking_blocks: true,
custom_font_path: Some("/home/naomi/.fonts/MyFont.ttf".to_string()),
custom_font_family: Some("MyFont".to_string()),
custom_ui_font_path: None,
custom_ui_font_family: None,
}; };
let json = serde_json::to_string(&config).unwrap(); let json = serde_json::to_string(&config).unwrap();
+192
View File
@@ -0,0 +1,192 @@
use chrono::Utc;
use serde::{Deserialize, Serialize};
use tauri::AppHandle;
use tauri_plugin_store::StoreExt;
use uuid::Uuid;
const DRAFTS_STORE_FILE: &str = "hikari-drafts.json";
const DRAFTS_STORE_KEY: &str = "drafts";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Draft {
pub id: String,
pub content: String,
pub saved_at: String,
}
fn load_all_drafts(app: &AppHandle) -> Result<Vec<Draft>, String> {
let store = app
.store(DRAFTS_STORE_FILE)
.map_err(|e| e.to_string())?;
match store.get(DRAFTS_STORE_KEY) {
Some(value) => serde_json::from_value(value.clone()).map_err(|e| e.to_string()),
None => Ok(vec![]),
}
}
fn save_all_drafts(app: &AppHandle, drafts: &[Draft]) -> Result<(), String> {
let store = app
.store(DRAFTS_STORE_FILE)
.map_err(|e| e.to_string())?;
let value = serde_json::to_value(drafts).map_err(|e| e.to_string())?;
store.set(DRAFTS_STORE_KEY, value);
store.save().map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
pub async fn list_drafts(app: AppHandle) -> Result<Vec<Draft>, String> {
let mut drafts = load_all_drafts(&app)?;
// Sort newest first — ISO 8601 timestamps sort lexicographically
drafts.sort_by(|a, b| b.saved_at.cmp(&a.saved_at));
Ok(drafts)
}
#[tauri::command]
pub async fn save_draft(app: AppHandle, content: String) -> Result<Draft, String> {
let mut drafts = load_all_drafts(&app)?;
let draft = Draft {
id: Uuid::new_v4().to_string(),
content,
saved_at: Utc::now().to_rfc3339(),
};
drafts.push(draft.clone());
save_all_drafts(&app, &drafts)?;
Ok(draft)
}
#[tauri::command]
pub async fn delete_draft(app: AppHandle, draft_id: String) -> Result<(), String> {
let mut drafts = load_all_drafts(&app)?;
drafts.retain(|d| d.id != draft_id);
save_all_drafts(&app, &drafts)
}
#[tauri::command]
pub async fn delete_all_drafts(app: AppHandle) -> Result<(), String> {
save_all_drafts(&app, &[])
}
#[cfg(test)]
mod tests {
use super::*;
fn make_draft(id: &str, content: &str, saved_at: &str) -> Draft {
Draft {
id: id.to_string(),
content: content.to_string(),
saved_at: saved_at.to_string(),
}
}
#[test]
fn test_draft_serialization() {
let draft = make_draft("test-id", "Hello world", "2026-01-01T00:00:00+00:00");
let json = serde_json::to_string(&draft).expect("Failed to serialize");
let parsed: Draft = serde_json::from_str(&json).expect("Failed to deserialize");
assert_eq!(parsed.id, draft.id);
assert_eq!(parsed.content, draft.content);
assert_eq!(parsed.saved_at, draft.saved_at);
}
#[test]
fn test_draft_clone() {
let original = make_draft("clone-id", "Clone me", "2026-01-01T00:00:00+00:00");
let cloned = original.clone();
assert_eq!(original.id, cloned.id);
assert_eq!(original.content, cloned.content);
assert_eq!(original.saved_at, cloned.saved_at);
}
#[test]
fn test_sort_newest_first() {
let mut drafts = [
make_draft("a", "First", "2026-01-01T00:00:00+00:00"),
make_draft("b", "Third", "2026-01-03T00:00:00+00:00"),
make_draft("c", "Second", "2026-01-02T00:00:00+00:00"),
];
drafts.sort_by(|a, b| b.saved_at.cmp(&a.saved_at));
assert_eq!(drafts[0].id, "b");
assert_eq!(drafts[1].id, "c");
assert_eq!(drafts[2].id, "a");
}
#[test]
fn test_retain_excludes_deleted() {
let mut drafts = vec![
make_draft("keep-1", "Keep me", "2026-01-01T00:00:00+00:00"),
make_draft("delete-me", "Delete me", "2026-01-02T00:00:00+00:00"),
make_draft("keep-2", "Keep me too", "2026-01-03T00:00:00+00:00"),
];
let target_id = "delete-me".to_string();
drafts.retain(|d| d.id != target_id);
assert_eq!(drafts.len(), 2);
assert!(drafts.iter().all(|d| d.id != "delete-me"));
}
#[test]
fn test_find_by_id() {
let drafts = [
make_draft("draft-1", "First draft", "2026-01-01T00:00:00+00:00"),
make_draft("draft-2", "Second draft", "2026-01-02T00:00:00+00:00"),
make_draft("draft-3", "Third draft", "2026-01-03T00:00:00+00:00"),
];
let found = drafts.iter().find(|d| d.id == "draft-2");
assert!(found.is_some());
assert_eq!(found.unwrap().content, "Second draft");
let not_found = drafts.iter().find(|d| d.id == "draft-999");
assert!(not_found.is_none());
}
#[test]
fn test_multiline_content() {
let content = "Line 1\nLine 2\nLine 3";
let draft = make_draft("multi", content, "2026-01-01T00:00:00+00:00");
assert!(draft.content.contains('\n'));
assert_eq!(draft.content.split('\n').count(), 3);
}
#[test]
fn test_empty_after_delete_all() {
let mut drafts = vec![
make_draft("a", "A", "2026-01-01T00:00:00+00:00"),
make_draft("b", "B", "2026-01-02T00:00:00+00:00"),
];
drafts.clear();
assert!(drafts.is_empty());
}
#[test]
fn test_uuid_format() {
// UUIDs should be non-empty and contain hyphens
let id = Uuid::new_v4().to_string();
assert!(!id.is_empty());
assert!(id.contains('-'));
assert_eq!(id.len(), 36);
}
#[test]
fn test_timestamp_is_rfc3339() {
let ts = Utc::now().to_rfc3339();
// RFC 3339 timestamps contain T and + or Z
assert!(ts.contains('T'));
assert!(ts.ends_with("+00:00") || ts.ends_with('Z'));
}
}
+3
View File
@@ -1,6 +1,8 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::process::Command; use std::process::Command;
use crate::process_ext::HideWindow;
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GitStatus { pub struct GitStatus {
pub is_repo: bool, pub is_repo: bool,
@@ -37,6 +39,7 @@ pub struct GitLogEntry {
fn run_git_command(working_dir: &str, args: &[&str]) -> Result<String, String> { fn run_git_command(working_dir: &str, args: &[&str]) -> Result<String, String> {
let output = Command::new("git") let output = Command::new("git")
.hide_window()
.args(args) .args(args)
.current_dir(working_dir) .current_dir(working_dir)
.output() .output()
+11
View File
@@ -6,8 +6,10 @@ mod config;
mod cost_tracking; mod cost_tracking;
mod debug_logger; mod debug_logger;
mod discord_rpc; mod discord_rpc;
mod drafts;
mod git; mod git;
mod notifications; mod notifications;
mod process_ext;
mod quick_actions; mod quick_actions;
mod sessions; mod sessions;
mod snippets; mod snippets;
@@ -27,6 +29,7 @@ use commands::load_saved_achievements;
use commands::*; use commands::*;
use debug_logger::TauriLogLayer; use debug_logger::TauriLogLayer;
use discord_rpc::DiscordRpcManager; use discord_rpc::DiscordRpcManager;
use drafts::*;
use git::*; use git::*;
use notifications::*; use notifications::*;
use quick_actions::*; use quick_actions::*;
@@ -120,6 +123,7 @@ pub fn run() {
get_persisted_stats, get_persisted_stats,
load_saved_achievements, load_saved_achievements,
answer_question, answer_question,
check_workspace_hooks,
send_windows_notification, send_windows_notification,
send_simple_notification, send_simple_notification,
send_windows_toast, send_windows_toast,
@@ -195,6 +199,9 @@ pub fn run() {
close_application, close_application,
list_memory_files, list_memory_files,
get_claude_version, get_claude_version,
get_auth_status,
auth_login,
auth_logout,
list_plugins, list_plugins,
install_plugin, install_plugin,
uninstall_plugin, uninstall_plugin,
@@ -209,6 +216,10 @@ pub fn run() {
remove_mcp_server, remove_mcp_server,
add_mcp_server, add_mcp_server,
get_mcp_server_details, get_mcp_server_details,
list_drafts,
save_draft,
delete_draft,
delete_all_drafts,
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");
+6
View File
@@ -1,6 +1,8 @@
use std::process::Command; use std::process::Command;
use tauri::command; use tauri::command;
use crate::process_ext::HideWindow;
/// Generate PowerShell script for Windows Toast Notification /// Generate PowerShell script for Windows Toast Notification
fn generate_powershell_toast_script(title: &str, body: &str) -> String { fn generate_powershell_toast_script(title: &str, body: &str) -> String {
format!( format!(
@@ -82,6 +84,7 @@ fn build_simple_notification_command(title: &str, body: &str) -> (String, Vec<St
pub async fn send_notify_send(title: String, body: String) -> Result<(), String> { pub async fn send_notify_send(title: String, body: String) -> Result<(), String> {
// Use notify-send for Linux/WSL // Use notify-send for Linux/WSL
let output = Command::new("notify-send") let output = Command::new("notify-send")
.hide_window()
.arg(&title) .arg(&title)
.arg(&body) .arg(&body)
.arg("--urgency=normal") .arg("--urgency=normal")
@@ -109,6 +112,7 @@ pub async fn send_windows_notification(title: String, body: String) -> Result<()
// Try PowerShell Core first (pwsh), then fall back to Windows PowerShell // Try PowerShell Core first (pwsh), then fall back to Windows PowerShell
let output = Command::new("pwsh.exe") let output = Command::new("pwsh.exe")
.hide_window()
.arg("-NoProfile") .arg("-NoProfile")
.arg("-WindowStyle") .arg("-WindowStyle")
.arg("Hidden") .arg("Hidden")
@@ -117,6 +121,7 @@ pub async fn send_windows_notification(title: String, body: String) -> Result<()
.output() .output()
.or_else(|_| { .or_else(|_| {
Command::new("powershell.exe") Command::new("powershell.exe")
.hide_window()
.arg("-NoProfile") .arg("-NoProfile")
.arg("-WindowStyle") .arg("-WindowStyle")
.arg("Hidden") .arg("Hidden")
@@ -140,6 +145,7 @@ pub async fn send_simple_notification(title: String, body: String) -> Result<(),
let message = format_simple_notification(&title, &body); let message = format_simple_notification(&title, &body);
Command::new("cmd.exe") Command::new("cmd.exe")
.hide_window()
.arg("/c") .arg("/c")
.arg("msg") .arg("msg")
.arg("*") .arg("*")
+21
View File
@@ -0,0 +1,21 @@
use std::process::Command;
/// Extension trait for `Command` that hides the console window on Windows.
///
/// On non-Windows platforms this is a no-op, so callers can unconditionally
/// chain `.hide_window()` without any `#[cfg]` guards at the call sites.
pub trait HideWindow {
fn hide_window(&mut self) -> &mut Self;
}
impl HideWindow for Command {
fn hide_window(&mut self) -> &mut Self {
#[cfg(target_os = "windows")]
{
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
self.creation_flags(CREATE_NO_WINDOW);
}
self
}
}
+3 -1
View File
@@ -86,8 +86,9 @@ impl ContextWarning {
/// Get the context window limit (in tokens) for a given model /// Get the context window limit (in tokens) for a given model
fn get_context_window_limit(model: &str) -> u64 { fn get_context_window_limit(model: &str) -> u64 {
match model { match model {
// Claude 4.6 family - 200K standard (1M beta available via header) // Claude 4.6 family
"claude-opus-4-6" => 200_000, "claude-opus-4-6" => 200_000,
"claude-sonnet-4-6" => 1_000_000, // 1M token context window
// Claude 4.5 family - 200K standard context // Claude 4.5 family - 200K standard context
"claude-opus-4-5-20251101" "claude-opus-4-5-20251101"
| "claude-sonnet-4-5-20250929" | "claude-sonnet-4-5-20250929"
@@ -502,6 +503,7 @@ pub fn calculate_cost(
let (input_price_per_million, output_price_per_million) = match model { let (input_price_per_million, output_price_per_million) = match model {
// Current generation (Claude 4.6) // Current generation (Claude 4.6)
"claude-opus-4-6" => (5.0, 25.0), "claude-opus-4-6" => (5.0, 25.0),
"claude-sonnet-4-6" => (3.0, 15.0),
// Previous generation (Claude 4.5) // Previous generation (Claude 4.5)
"claude-opus-4-5-20251101" => (5.0, 25.0), "claude-opus-4-5-20251101" => (5.0, 25.0),
+100
View File
@@ -63,6 +63,26 @@ pub struct PermissionDenial {
pub tool_input: serde_json::Value, pub tool_input: serde_json::Value,
} }
/// Rate limit information from a `rate_limit_event` message.
/// All fields are optional to ensure forward-compatibility as the Claude CLI evolves.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct RateLimitInfo {
#[serde(default)]
pub requests_limit: Option<u64>,
#[serde(default)]
pub requests_remaining: Option<u64>,
#[serde(default)]
pub requests_reset: Option<String>,
#[serde(default)]
pub tokens_limit: Option<u64>,
#[serde(default)]
pub tokens_remaining: Option<u64>,
#[serde(default)]
pub tokens_reset: Option<String>,
#[serde(default)]
pub retry_after_ms: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")] #[serde(tag = "type")]
pub enum ClaudeMessage { pub enum ClaudeMessage {
@@ -100,6 +120,11 @@ pub enum ClaudeMessage {
#[serde(default)] #[serde(default)]
usage: Option<UsageInfo>, usage: Option<UsageInfo>,
}, },
#[serde(rename = "rate_limit_event")]
RateLimitEvent {
#[serde(default)]
rate_limit_info: RateLimitInfo,
},
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -280,6 +305,8 @@ pub struct AgentEndEvent {
pub duration_ms: Option<u64>, pub duration_ms: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub num_turns: Option<u32>, pub num_turns: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_assistant_message: Option<String>,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -446,4 +473,77 @@ mod tests {
assert!(serialized.contains("\"input_tokens\":100")); assert!(serialized.contains("\"input_tokens\":100"));
assert!(serialized.contains("\"output_tokens\":50")); assert!(serialized.contains("\"output_tokens\":50"));
} }
#[test]
fn test_rate_limit_info_default() {
let info = RateLimitInfo::default();
assert!(info.requests_limit.is_none());
assert!(info.requests_remaining.is_none());
assert!(info.requests_reset.is_none());
assert!(info.tokens_limit.is_none());
assert!(info.tokens_remaining.is_none());
assert!(info.tokens_reset.is_none());
assert!(info.retry_after_ms.is_none());
}
#[test]
fn test_rate_limit_event_deserialization_empty_info() {
let json = r#"{"type":"rate_limit_event","rate_limit_info":{}}"#;
let msg: ClaudeMessage = serde_json::from_str(json).unwrap();
assert!(matches!(msg, ClaudeMessage::RateLimitEvent { .. }));
}
#[test]
fn test_rate_limit_event_deserialization_no_info() {
// rate_limit_info field is optional via #[serde(default)]
let json = r#"{"type":"rate_limit_event"}"#;
let msg: ClaudeMessage = serde_json::from_str(json).unwrap();
assert!(matches!(msg, ClaudeMessage::RateLimitEvent { .. }));
}
#[test]
fn test_rate_limit_event_deserialization_with_data() {
let json = r#"{
"type": "rate_limit_event",
"rate_limit_info": {
"requests_limit": 1000,
"requests_remaining": 0,
"requests_reset": "2024-01-01T00:01:00Z",
"tokens_limit": 50000,
"tokens_remaining": 0,
"tokens_reset": "2024-01-01T00:01:00Z",
"retry_after_ms": 60000
}
}"#;
let msg: ClaudeMessage = serde_json::from_str(json).unwrap();
if let ClaudeMessage::RateLimitEvent { rate_limit_info } = msg {
assert_eq!(rate_limit_info.requests_limit, Some(1000));
assert_eq!(rate_limit_info.requests_remaining, Some(0));
assert_eq!(
rate_limit_info.requests_reset,
Some("2024-01-01T00:01:00Z".to_string())
);
assert_eq!(rate_limit_info.retry_after_ms, Some(60000));
} else {
panic!("Expected RateLimitEvent variant");
}
}
#[test]
fn test_rate_limit_event_ignores_unknown_fields() {
// Ensures forward-compat: unknown fields in rate_limit_info are silently ignored
let json = r#"{
"type": "rate_limit_event",
"rate_limit_info": {
"requests_remaining": 0,
"some_future_field": "some_value"
}
}"#;
let msg: ClaudeMessage = serde_json::from_str(json).unwrap();
if let ClaudeMessage::RateLimitEvent { rate_limit_info } = msg {
assert_eq!(rate_limit_info.requests_remaining, Some(0));
} else {
panic!("Expected RateLimitEvent variant");
}
}
} }
+4 -1
View File
@@ -3,6 +3,8 @@ use std::process::Command;
use tauri::command; use tauri::command;
use tempfile::NamedTempFile; use tempfile::NamedTempFile;
use crate::process_ext::HideWindow;
#[command] #[command]
pub async fn send_vbs_notification(title: String, body: String) -> Result<(), String> { pub async fn send_vbs_notification(title: String, body: String) -> Result<(), String> {
// Create a VBScript that shows a Windows notification // Create a VBScript that shows a Windows notification
@@ -40,7 +42,7 @@ objShell.Popup "{}" & vbCrLf & vbCrLf & "{}", 5, "{}", 64
} else if temp_path.starts_with("/tmp/") { } else if temp_path.starts_with("/tmp/") {
// WSL temp files might be in a different location // WSL temp files might be in a different location
// Try to use wslpath to convert // Try to use wslpath to convert
let output = Command::new("wslpath").arg("-w").arg(&temp_path).output(); let output = Command::new("wslpath").hide_window().arg("-w").arg(&temp_path).output();
if let Ok(result) = output { if let Ok(result) = output {
if result.status.success() { if result.status.success() {
@@ -57,6 +59,7 @@ objShell.Popup "{}" & vbCrLf & vbCrLf & "{}", 5, "{}", 64
// Execute the VBScript using wscript.exe // Execute the VBScript using wscript.exe
let output = Command::new("/mnt/c/Windows/System32/wscript.exe") let output = Command::new("/mnt/c/Windows/System32/wscript.exe")
.hide_window()
.arg("//NoLogo") .arg("//NoLogo")
.arg(&windows_path) .arg(&windows_path)
.output() .output()
+581 -57
View File
@@ -1,17 +1,17 @@
use std::io::{BufRead, BufReader, Write}; use std::io::{BufRead, BufReader, Write};
use std::process::{Child, ChildStdin, Command, Stdio}; use std::process::{Child, ChildStdin, Command, Stdio};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc; use std::sync::Arc;
use std::thread; use std::thread;
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{Duration, SystemTime, UNIX_EPOCH};
use parking_lot::Mutex;
use tauri::{AppHandle, Emitter}; use tauri::{AppHandle, Emitter};
use tempfile::NamedTempFile; use tempfile::NamedTempFile;
#[cfg(target_os = "windows")]
use std::os::windows::process::CommandExt;
use crate::achievements::{get_achievement_info, AchievementUnlockedEvent}; use crate::achievements::{get_achievement_info, AchievementUnlockedEvent};
use crate::commands::record_cost; use crate::commands::record_cost;
use crate::config::ClaudeStartOptions; use crate::config::ClaudeStartOptions;
use crate::process_ext::HideWindow;
use crate::stats::{calculate_cost, StatsUpdateEvent, UsageStats}; use crate::stats::{calculate_cost, StatsUpdateEvent, UsageStats};
use crate::types::{ use crate::types::{
AgentEndEvent, AgentStartEvent, CharacterState, ClaudeMessage, ConnectionEvent, AgentEndEvent, AgentStartEvent, CharacterState, ClaudeMessage, ConnectionEvent,
@@ -39,6 +39,12 @@ const SEARCH_TOOLS: [&str; 5] = ["Read", "Glob", "Grep", "WebSearch", "WebFetch"
const CODING_TOOLS: [&str; 3] = ["Edit", "Write", "NotebookEdit"]; const CODING_TOOLS: [&str; 3] = ["Edit", "Write", "NotebookEdit"];
fn detect_wsl() -> bool { fn detect_wsl() -> bool {
// A native Windows binary is never running inside WSL, even if launched from a WSL
// terminal that has WSL_DISTRO_NAME set in its environment.
if cfg!(target_os = "windows") {
return false;
}
// Check /proc/version for WSL indicators // Check /proc/version for WSL indicators
if let Ok(version) = std::fs::read_to_string("/proc/version") { if let Ok(version) = std::fs::read_to_string("/proc/version") {
let version_lower = version.to_lowercase(); let version_lower = version.to_lowercase();
@@ -61,23 +67,29 @@ fn detect_wsl() -> bool {
} }
fn find_claude_binary() -> Option<String> { fn find_claude_binary() -> Option<String> {
// Check common installation locations for claude // Check common installation locations for claude (when HOME is available)
let home = std::env::var("HOME").ok()?; if let Ok(home) = std::env::var("HOME") {
let paths_to_check = [ let paths_to_check = [
format!("{}/.local/bin/claude", home), format!("{}/.local/bin/claude", home),
format!("{}/.claude/local/claude", home), format!("{}/.claude/local/claude", home),
"/usr/local/bin/claude".to_string(), ];
"/usr/bin/claude".to_string(), for path in &paths_to_check {
]; if std::path::Path::new(path).exists() {
return Some(path.clone());
for path in &paths_to_check { }
if std::path::Path::new(path).exists() {
return Some(path.clone());
} }
} }
// Fall back to checking PATH via which // Check system-wide locations
if let Ok(output) = Command::new("which").arg("claude").output() { for path in &["/usr/local/bin/claude", "/usr/bin/claude"] {
if std::path::Path::new(path).exists() {
return Some((*path).to_string());
}
}
// Use a login shell to resolve claude via the user's PATH - GUI apps don't
// inherit shell PATH, so bare `which` may miss ~/.local/bin entries
if let Ok(output) = Command::new("bash").hide_window().args(["-lc", "which claude"]).output() {
if output.status.success() { if output.status.success() {
let path = String::from_utf8_lossy(&output.stdout).trim().to_string(); let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !path.is_empty() { if !path.is_empty() {
@@ -90,43 +102,63 @@ fn find_claude_binary() -> Option<String> {
} }
pub struct WslBridge { pub struct WslBridge {
process: Option<Child>, process: Arc<Mutex<Option<Child>>>,
stdin: Option<ChildStdin>, stdin: Option<ChildStdin>,
working_directory: String, working_directory: String,
session_id: Option<String>, session_id: Option<String>,
mcp_config_file: Option<NamedTempFile>, mcp_config_file: Option<NamedTempFile>,
stats: Arc<RwLock<UsageStats>>, stats: Arc<RwLock<UsageStats>>,
conversation_id: Option<String>, conversation_id: Option<String>,
/// Set to true once the `system:init` message arrives, false at the start of every new session.
received_init: Arc<AtomicBool>,
/// Set to true by stop()/interrupt() before killing the process so handle_stdout knows
/// the disconnect was intentional and should not emit a second Disconnected event.
intentional_stop: Arc<AtomicBool>,
} }
impl WslBridge { impl WslBridge {
pub fn new() -> Self { pub fn new() -> Self {
WslBridge { WslBridge {
process: None, process: Arc::new(Mutex::new(None)),
stdin: None, stdin: None,
working_directory: String::new(), working_directory: String::new(),
session_id: None, session_id: None,
mcp_config_file: None, mcp_config_file: None,
stats: Arc::new(RwLock::new(UsageStats::new())), stats: Arc::new(RwLock::new(UsageStats::new())),
conversation_id: None, conversation_id: None,
received_init: Arc::new(AtomicBool::new(false)),
intentional_stop: Arc::new(AtomicBool::new(false)),
} }
} }
pub fn new_with_conversation_id(conversation_id: String) -> Self { pub fn new_with_conversation_id(conversation_id: String) -> Self {
WslBridge { WslBridge {
process: None, process: Arc::new(Mutex::new(None)),
stdin: None, stdin: None,
working_directory: String::new(), working_directory: String::new(),
session_id: None, session_id: None,
mcp_config_file: None, mcp_config_file: None,
stats: Arc::new(RwLock::new(UsageStats::new())), stats: Arc::new(RwLock::new(UsageStats::new())),
conversation_id: Some(conversation_id), conversation_id: Some(conversation_id),
received_init: Arc::new(AtomicBool::new(false)),
intentional_stop: Arc::new(AtomicBool::new(false)),
} }
} }
pub fn start(&mut self, app: AppHandle, options: ClaudeStartOptions) -> Result<(), String> { pub fn start(&mut self, app: AppHandle, options: ClaudeStartOptions) -> Result<(), String> {
if self.process.is_some() { // If a process handle exists but the process has already exited (e.g. due to a
return Err("Process already running".to_string()); // failed working directory), clean up the stale handle so we can restart cleanly.
{
let mut proc_guard = self.process.lock();
if let Some(ref mut proc) = *proc_guard {
if proc.try_wait().map(|s| s.is_some()).unwrap_or(false) {
*proc_guard = None;
self.stdin = None;
}
}
if proc_guard.is_some() {
return Err("Process already running".to_string());
}
} }
// Load saved achievements and stats when starting a new session // Load saved achievements and stats when starting a new session
@@ -203,6 +235,7 @@ impl WslBridge {
tracing::debug!("Working dir: {}", working_dir); tracing::debug!("Working dir: {}", working_dir);
let mut cmd = Command::new(&claude_path); let mut cmd = Command::new(&claude_path);
cmd.hide_window();
cmd.args([ cmd.args([
"--output-format", "--output-format",
"stream-json", "stream-json",
@@ -244,6 +277,11 @@ impl WslBridge {
} }
} }
// Add worktree flag if requested
if options.use_worktree {
cmd.arg("--worktree");
}
cmd.current_dir(working_dir); cmd.current_dir(working_dir);
// Set API key as environment variable if specified // Set API key as environment variable if specified
@@ -253,10 +291,46 @@ impl WslBridge {
} }
} }
// Disable 1M context window if requested
if options.disable_1m_context {
cmd.env("CLAUDE_CODE_DISABLE_1M_CONTEXT", "1");
}
// Set max output tokens if specified
if let Some(max_tokens) = options.max_output_tokens {
cmd.env("CLAUDE_CODE_MAX_OUTPUT_TOKENS", max_tokens.to_string());
}
cmd cmd
} else { } else {
// Running on Windows - use wsl with bash login shell to ensure PATH is loaded // Running on Windows - use wsl with bash login shell to ensure PATH is loaded
tracing::debug!("Windows path - using wsl"); tracing::debug!("Windows path - using wsl");
// Check if Claude binary is installed inside WSL
let binary_check = Command::new("wsl")
.hide_window()
.args(["-e", "bash", "-lc", "which claude"])
.output();
if let Ok(output) = binary_check {
if !output.status.success() {
return Err("Claude Code is not installed. Please install it using:\n\ncurl -fsSL https://claude.ai/install.sh | bash".to_string());
}
}
// Validate the working directory exists inside WSL before spawning
let dir_check = Command::new("wsl")
.hide_window()
.args(["-e", "test", "-d", working_dir])
.output();
if let Ok(output) = dir_check {
if !output.status.success() {
return Err(format!(
"Working directory does not exist: {}",
working_dir
));
}
}
let mut cmd = Command::new("wsl"); let mut cmd = Command::new("wsl");
// Build the claude command with all arguments // Build the claude command with all arguments
@@ -269,6 +343,16 @@ impl WslBridge {
} }
} }
// Disable 1M context window if requested
if options.disable_1m_context {
claude_cmd.push_str("CLAUDE_CODE_DISABLE_1M_CONTEXT=1 ");
}
// Set max output tokens if specified
if let Some(max_tokens) = options.max_output_tokens {
claude_cmd.push_str(&format!("CLAUDE_CODE_MAX_OUTPUT_TOKENS={} ", max_tokens));
}
claude_cmd.push_str( claude_cmd.push_str(
"claude --output-format stream-json --input-format stream-json --verbose", "claude --output-format stream-json --input-format stream-json --verbose",
); );
@@ -306,12 +390,16 @@ impl WslBridge {
} }
} }
// Add worktree flag if requested
if options.use_worktree {
claude_cmd.push_str(" --worktree");
}
// Use bash -lc to load login profile (ensures PATH includes claude) // Use bash -lc to load login profile (ensures PATH includes claude)
cmd.args(["-e", "bash", "-lc", &claude_cmd]); cmd.args(["-e", "bash", "-lc", &claude_cmd]);
// Hide the console window on Windows // Hide the console window on Windows
#[cfg(target_os = "windows")] cmd.hide_window();
cmd.creation_flags(0x08000000); // CREATE_NO_WINDOW
cmd cmd
}; };
@@ -331,7 +419,11 @@ impl WslBridge {
let stderr = child.stderr.take(); let stderr = child.stderr.take();
self.stdin = stdin; self.stdin = stdin;
self.process = Some(child); *self.process.lock() = Some(child);
// Reset flags so the watchdog and stdout handler start fresh.
self.received_init.store(false, Ordering::SeqCst);
self.intentional_stop.store(false, Ordering::SeqCst);
// Note: We no longer reset stats here - stats persist across reconnects // Note: We no longer reset stats here - stats persist across reconnects
// Stats are only reset when explicitly disconnecting via stop() // Stats are only reset when explicitly disconnecting via stop()
@@ -348,8 +440,17 @@ impl WslBridge {
let app_clone = app.clone(); let app_clone = app.clone();
let stats_clone = self.stats.clone(); let stats_clone = self.stats.clone();
let conv_id = self.conversation_id.clone(); let conv_id = self.conversation_id.clone();
let received_init_clone = self.received_init.clone();
let intentional_stop_clone = self.intentional_stop.clone();
thread::spawn(move || { thread::spawn(move || {
handle_stdout(stdout, app_clone, stats_clone, conv_id); handle_stdout(
stdout,
app_clone,
stats_clone,
conv_id,
received_init_clone,
intentional_stop_clone,
);
}); });
} }
@@ -361,12 +462,31 @@ impl WslBridge {
}); });
} }
// Emit Connected immediately so the frontend can send the greeting message.
// This is intentionally optimistic — Claude Code buffers stdout until stdin receives
// data on Windows/WSL, so we must send something to stdin first or system:init never
// arrives. The received_init flag below tracks whether init actually arrived.
emit_connection_status( emit_connection_status(
&app, &app,
ConnectionStatus::Connected, ConnectionStatus::Connected,
self.conversation_id.clone(), self.conversation_id.clone(),
); );
// Watchdog: if system:init never arrives the process is truly hung (e.g. a silent crash
// after spawning). After 5 minutes we kill it so the user isn't stuck forever.
// handle_stdout will surface the error when stdout closes after the kill.
let process_watchdog = self.process.clone();
let received_init_watchdog = self.received_init.clone();
thread::spawn(move || {
thread::sleep(Duration::from_secs(60));
if !received_init_watchdog.load(Ordering::SeqCst) {
if let Some(mut proc) = process_watchdog.lock().take() {
let _ = proc.kill();
let _ = proc.wait();
}
}
});
Ok(()) Ok(())
} }
@@ -445,7 +565,15 @@ impl WslBridge {
// Due to persistent bug in Claude Code where ESC/Ctrl+C doesn't work, // Due to persistent bug in Claude Code where ESC/Ctrl+C doesn't work,
// we have to kill the process. This is the only reliable way to stop it. // we have to kill the process. This is the only reliable way to stop it.
// See: https://github.com/anthropics/claude-code/issues/3455 // See: https://github.com/anthropics/claude-code/issues/3455
if let Some(mut process) = self.process.take() { // Extract the process first so the MutexGuard is dropped before we mutably
// borrow `self` again via estimate_interrupted_request_cost.
// Signal handle_stdout that this is an intentional stop so it doesn't emit
// a second Disconnected event after stdout closes due to the kill.
self.intentional_stop.store(true, Ordering::SeqCst);
let maybe_process = self.process.lock().take();
if let Some(mut process) = maybe_process {
// Estimate cost for interrupted request before killing // Estimate cost for interrupted request before killing
self.estimate_interrupted_request_cost(app); self.estimate_interrupted_request_cost(app);
@@ -575,7 +703,10 @@ impl WslBridge {
} }
pub fn stop(&mut self, app: &AppHandle) { pub fn stop(&mut self, app: &AppHandle) {
if let Some(mut process) = self.process.take() { // Signal handle_stdout that this is an intentional stop so it doesn't emit
// a second Disconnected event after stdout closes due to the kill.
self.intentional_stop.store(true, Ordering::SeqCst);
if let Some(mut process) = self.process.lock().take() {
let _ = process.kill(); let _ = process.kill();
let _ = process.wait(); let _ = process.wait();
} }
@@ -606,7 +737,7 @@ impl WslBridge {
} }
pub fn is_running(&self) -> bool { pub fn is_running(&self) -> bool {
self.process.is_some() self.process.lock().is_some()
} }
pub fn get_working_directory(&self) -> &str { pub fn get_working_directory(&self) -> &str {
@@ -629,13 +760,17 @@ fn handle_stdout(
app: AppHandle, app: AppHandle,
stats: Arc<RwLock<UsageStats>>, stats: Arc<RwLock<UsageStats>>,
conversation_id: Option<String>, conversation_id: Option<String>,
received_init: Arc<AtomicBool>,
intentional_stop: Arc<AtomicBool>,
) { ) {
let reader = BufReader::new(stdout); let reader = BufReader::new(stdout);
for line in reader.lines() { for line in reader.lines() {
match line { match line {
Ok(line) if !line.is_empty() => { Ok(line) if !line.is_empty() => {
if let Err(e) = process_json_line(&line, &app, &stats, &conversation_id) { if let Err(e) =
process_json_line(&line, &app, &stats, &conversation_id, &received_init)
{
tracing::error!("Error processing line: {}", e); tracing::error!("Error processing line: {}", e);
} }
} }
@@ -647,6 +782,45 @@ fn handle_stdout(
} }
} }
// If this was an intentional stop (stop()/interrupt() was called), the caller already
// emitted a Disconnected event. Skip all post-loop emissions to prevent duplicates.
if intentional_stop.load(Ordering::SeqCst) {
return;
}
// If stdout closed before system:init arrived the process exited without initialising.
// Emit an error line so the user understands why the connection failed.
if !received_init.load(Ordering::SeqCst) {
let _ = app.emit(
"claude:output",
OutputEvent {
line_type: "error".to_string(),
content: "Claude Code exited before initialising. Check the working directory and Claude Code installation, then try connecting again.".to_string(),
tool_name: None,
conversation_id: conversation_id.clone(),
cost: None,
parent_tool_use_id: None,
},
);
}
// If Claude exited while a prompt was in-flight, the user's message was never processed.
// Emit a specific error so they know to resend their prompt.
let had_pending_request = stats.read().current_request_input.is_some();
if had_pending_request {
let _ = app.emit(
"claude:output",
OutputEvent {
line_type: "error".to_string(),
content: "Claude Code exited before finishing your request — your last prompt was not processed. Please reconnect and try again.".to_string(),
tool_name: None,
conversation_id: conversation_id.clone(),
cost: None,
parent_tool_use_id: None,
},
);
}
emit_connection_status(&app, ConnectionStatus::Disconnected, conversation_id); emit_connection_status(&app, ConnectionStatus::Disconnected, conversation_id);
} }
@@ -700,17 +874,28 @@ fn handle_stderr(
conversation_id: conversation_id.clone(), conversation_id: conversation_id.clone(),
duration_ms: None, duration_ms: None,
num_turns: None, num_turns: None,
last_assistant_message: stop_data.last_assistant_message,
}, },
); );
} }
} }
} }
// Still emit the stderr line as output // Hook events are informational — emit with distinct types instead of error
let line_type = if line.contains("[WorktreeCreate Hook]")
|| line.contains("[WorktreeRemove Hook]")
{
"worktree"
} else if line.contains("[ConfigChange Hook]") {
"config-change"
} else {
"error"
};
let _ = app.emit( let _ = app.emit(
"claude:output", "claude:output",
OutputEvent { OutputEvent {
line_type: "error".to_string(), line_type: line_type.to_string(),
content: line, content: line,
tool_name: None, tool_name: None,
conversation_id: conversation_id.clone(), conversation_id: conversation_id.clone(),
@@ -763,32 +948,84 @@ fn parse_subagent_start_hook(line: &str) -> Option<SubagentStartData> {
#[derive(Debug)] #[derive(Debug)]
struct SubagentStopData { struct SubagentStopData {
parent_tool_use_id: Option<String>, parent_tool_use_id: Option<String>,
last_assistant_message: Option<String>,
}
/// Extracts the content of a Rust Debug-formatted `Some("...")` field from a hook line.
/// Handles escaped characters (e.g. `\"` → `"`, `\\` → `\`, `\n` → newline).
/// Returns `None` if the field is absent or formatted as `None`.
fn extract_debug_string_value(line: &str, key: &str) -> Option<String> {
let prefix = format!("{}=Some(\"", key);
let start_idx = line.find(&prefix)? + prefix.len();
let rest = &line[start_idx..];
let mut result = String::new();
let mut chars = rest.chars();
loop {
match chars.next() {
Some('"') => return Some(result),
Some('\\') => match chars.next() {
Some('n') => result.push('\n'),
Some('t') => result.push('\t'),
Some('"') => result.push('"'),
Some('\\') => result.push('\\'),
Some(c) => {
result.push('\\');
result.push(c);
}
None => break,
},
Some(c) => result.push(c),
None => break,
}
}
None
} }
fn parse_subagent_stop_hook(line: &str) -> Option<SubagentStopData> { fn parse_subagent_stop_hook(line: &str) -> Option<SubagentStopData> {
// Parse: [SubagentStop Hook] ... parent_tool_use_id=Some("toolu_xxx"), ... // Parse: [SubagentStop Hook] ... parent_tool_use_id=Some("toolu_xxx"), last_assistant_message=Some("..."), ...
// Extract parent_tool_use_id if present let parent_tool_use_id = extract_debug_string_value(line, "parent_tool_use_id");
let parent_tool_use_id = if line.contains("parent_tool_use_id=Some") { let last_assistant_message = extract_debug_string_value(line, "last_assistant_message");
line.split("parent_tool_use_id=Some(\"")
.nth(1)?
.split('"')
.next()
.map(|s| s.to_string())
} else {
None
};
Some(SubagentStopData { Some(SubagentStopData {
parent_tool_use_id, parent_tool_use_id,
last_assistant_message,
}) })
} }
/// Extract text content from a ToolResult's `content` field.
/// The content may be a JSON string or an array of typed content blocks.
fn extract_tool_result_text(content: &serde_json::Value) -> Option<String> {
match content {
serde_json::Value::String(s) if !s.is_empty() => Some(s.clone()),
serde_json::Value::Array(blocks) => {
let texts: Vec<String> = blocks
.iter()
.filter_map(|block| {
if block.get("type")?.as_str()? == "text" {
block.get("text")?.as_str().map(String::from)
} else {
None
}
})
.collect();
if texts.is_empty() {
None
} else {
Some(texts.join("\n"))
}
}
_ => None,
}
}
fn process_json_line( fn process_json_line(
line: &str, line: &str,
app: &AppHandle, app: &AppHandle,
stats: &Arc<RwLock<UsageStats>>, stats: &Arc<RwLock<UsageStats>>,
conversation_id: &Option<String>, conversation_id: &Option<String>,
received_init: &Arc<AtomicBool>,
) -> Result<(), String> { ) -> Result<(), String> {
let message: ClaudeMessage = serde_json::from_str(line) let message: ClaudeMessage = serde_json::from_str(line)
.map_err(|e| format!("Failed to parse JSON: {} - Line: {}", e, line))?; .map_err(|e| format!("Failed to parse JSON: {} - Line: {}", e, line))?;
@@ -801,6 +1038,9 @@ fn process_json_line(
.. ..
} => { } => {
if subtype == "init" { if subtype == "init" {
// Mark as initialised so the watchdog knows the process is healthy.
received_init.store(true, Ordering::SeqCst);
if let Some(id) = session_id { if let Some(id) = session_id {
let _ = app.emit( let _ = app.emit(
"claude:session", "claude:session",
@@ -1037,17 +1277,37 @@ fn process_json_line(
stats.write().increment_code_blocks(); stats.write().increment_code_blocks();
} }
let is_prompt_too_long = text.starts_with("Prompt is too long");
let _ = app.emit( let _ = app.emit(
"claude:output", "claude:output",
OutputEvent { OutputEvent {
line_type: "assistant".to_string(), line_type: if is_prompt_too_long {
"error".to_string()
} else {
"assistant".to_string()
},
content: text.clone(), content: text.clone(),
tool_name: None, tool_name: None,
conversation_id: conversation_id.clone(), conversation_id: conversation_id.clone(),
cost: message_cost.clone(), // Include cost with assistant text cost: message_cost.clone(),
parent_tool_use_id: parent_tool_use_id.clone(), parent_tool_use_id: parent_tool_use_id.clone(),
}, },
); );
if is_prompt_too_long {
let _ = app.emit(
"claude:output",
OutputEvent {
line_type: "compact-prompt".to_string(),
content: String::new(),
tool_name: None,
conversation_id: conversation_id.clone(),
cost: None,
parent_tool_use_id: None,
},
);
}
} }
ContentBlock::Thinking { thinking } => { ContentBlock::Thinking { thinking } => {
state = CharacterState::Thinking; state = CharacterState::Thinking;
@@ -1065,8 +1325,8 @@ fn process_json_line(
} }
ContentBlock::ToolResult { ContentBlock::ToolResult {
tool_use_id, tool_use_id,
content,
is_error, is_error,
..
} => { } => {
// Emit agent-end for all tool results // Emit agent-end for all tool results
// The frontend will ignore IDs that don't match known agents // The frontend will ignore IDs that don't match known agents
@@ -1084,6 +1344,7 @@ fn process_json_line(
conversation_id: conversation_id.clone(), conversation_id: conversation_id.clone(),
duration_ms: None, duration_ms: None,
num_turns: None, num_turns: None,
last_assistant_message: extract_tool_result_text(content),
}, },
); );
} }
@@ -1476,6 +1737,23 @@ fn process_json_line(
emit_state_change(app, state, None, conversation_id.clone()); emit_state_change(app, state, None, conversation_id.clone());
} }
ClaudeMessage::RateLimitEvent { rate_limit_info } => {
tracing::warn!("Rate limit event received: {:?}", rate_limit_info);
let content = format_rate_limit_message(rate_limit_info);
let _ = app.emit(
"claude:output",
OutputEvent {
line_type: "rate-limit".to_string(),
content,
tool_name: None,
conversation_id: conversation_id.clone(),
cost: None,
parent_tool_use_id: None,
},
);
}
ClaudeMessage::User { message } => { ClaudeMessage::User { message } => {
// Increment message count for user messages // Increment message count for user messages
stats.write().increment_messages(); stats.write().increment_messages();
@@ -1484,8 +1762,8 @@ fn process_json_line(
for block in &message.content { for block in &message.content {
if let ContentBlock::ToolResult { if let ContentBlock::ToolResult {
tool_use_id, tool_use_id,
content,
is_error, is_error,
..
} = block } = block
{ {
let now = SystemTime::now() let now = SystemTime::now()
@@ -1502,6 +1780,7 @@ fn process_json_line(
conversation_id: conversation_id.clone(), conversation_id: conversation_id.clone(),
duration_ms: None, duration_ms: None,
num_turns: None, num_turns: None,
last_assistant_message: extract_tool_result_text(content),
}, },
); );
} }
@@ -1584,6 +1863,35 @@ fn get_tool_state(tool_name: &str) -> CharacterState {
} }
} }
fn format_rate_limit_message(info: &crate::types::RateLimitInfo) -> String {
let mut parts = Vec::new();
if let (Some(remaining), Some(limit)) = (info.requests_remaining, info.requests_limit) {
parts.push(format!("requests: {}/{}", remaining, limit));
}
if let (Some(remaining), Some(limit)) = (info.tokens_remaining, info.tokens_limit) {
parts.push(format!("tokens: {}/{}", remaining, limit));
}
if let Some(reset) = &info.requests_reset {
parts.push(format!("resets at {}", reset));
} else if let Some(reset) = &info.tokens_reset {
parts.push(format!("resets at {}", reset));
}
if let Some(retry_ms) = info.retry_after_ms {
let secs = retry_ms / 1000;
parts.push(format!("retry after {}s", secs));
}
if parts.is_empty() {
"Rate limit reached".to_string()
} else {
format!("Rate limit reached — {}", parts.join(", "))
}
}
fn format_tool_description(name: &str, input: &serde_json::Value) -> String { fn format_tool_description(name: &str, input: &serde_json::Value) -> String {
// Helper function to check if a path is a memory file // Helper function to check if a path is a memory file
fn is_memory_path(path: &str) -> bool { fn is_memory_path(path: &str) -> bool {
@@ -1644,12 +1952,7 @@ fn format_tool_description(name: &str, input: &serde_json::Value) -> String {
} }
"Bash" => { "Bash" => {
if let Some(cmd) = input.get("command").and_then(|v| v.as_str()) { if let Some(cmd) = input.get("command").and_then(|v| v.as_str()) {
let truncated = if cmd.len() > 50 { format!("Running: {}", cmd)
format!("{}...", &cmd[..50])
} else {
cmd.to_string()
};
format!("Running: {}", truncated)
} else { } else {
"Running command...".to_string() "Running command...".to_string()
} }
@@ -1810,9 +2113,7 @@ mod tests {
let long_cmd = "a".repeat(100); let long_cmd = "a".repeat(100);
let input = serde_json::json!({"command": long_cmd}); let input = serde_json::json!({"command": long_cmd});
let desc = format_tool_description("Bash", &input); let desc = format_tool_description("Bash", &input);
assert!(desc.starts_with("Running: ")); assert_eq!(desc, format!("Running: {}", long_cmd));
assert!(desc.ends_with("..."));
assert!(desc.len() < 70);
} }
#[test] #[test]
@@ -1868,6 +2169,69 @@ mod tests {
assert!(!bridge.is_running()); assert!(!bridge.is_running());
} }
#[test]
fn test_stale_process_detection_with_try_wait() {
// Spawn a real process that exits immediately so we can verify try_wait detects it
let mut child = Command::new("true").hide_window().spawn().expect("Failed to spawn 'true'");
// Wait for it to exit
let _ = child.wait();
// try_wait on an already-exited process should return Some(_)
let status = child.try_wait();
assert!(
status.is_ok(),
"try_wait should not error on an exited process"
);
// The process has already been waited on, so try_wait might return None or Some
// depending on the OS - what matters is that the call succeeds
}
#[test]
fn test_stale_process_is_some_after_exit() {
// Verify the logic used in start(): a process that has exited is detected
// and the handle is cleaned up so start() can proceed
let mut child = Command::new("true").hide_window().spawn().expect("Failed to spawn 'true'");
// Let it exit
let _ = child.wait();
// This mirrors the check in start()
let has_exited = child
.try_wait()
.map(|s| s.is_some())
.unwrap_or(false);
// After wait(), try_wait() returns None (already reaped), which means
// unwrap_or(false) → false. The important thing is the call doesn't panic
// and the control flow logic compiles and runs correctly.
let _ = has_exited; // suppress unused warning
}
/// Build the WSL binary check command structure without executing it (for testing)
#[cfg(test)]
fn build_wsl_binary_check_args() -> Vec<&'static str> {
vec!["-e", "bash", "-lc", "which claude"]
}
#[test]
fn test_wsl_binary_check_command_structure() {
// Windows path: verify Claude is detected inside WSL via `wsl -e bash -lc "which claude"`
let args = build_wsl_binary_check_args();
assert_eq!(args[0], "-e");
assert_eq!(args[1], "bash");
assert_eq!(args[2], "-lc");
assert_eq!(args[3], "which claude");
}
#[test]
fn test_linux_binary_check_does_not_panic() {
// Linux/WSL path: find_claude_binary() searches Linux filesystem paths.
// We just verify it runs without panicking; whether it returns Some depends
// on whether Claude is actually installed in this environment.
let _result = find_claude_binary();
}
#[test] #[test]
fn test_create_shared_bridge_manager() { fn test_create_shared_bridge_manager() {
use crate::bridge_manager::create_shared_bridge_manager; use crate::bridge_manager::create_shared_bridge_manager;
@@ -1968,5 +2332,165 @@ mod tests {
assert!(result.is_some()); assert!(result.is_some());
let data = result.unwrap(); let data = result.unwrap();
assert_eq!(data.parent_tool_use_id, None); assert_eq!(data.parent_tool_use_id, None);
assert_eq!(data.last_assistant_message, None);
}
#[test]
fn test_parse_subagent_stop_hook_with_last_message() {
let line = r#"[SubagentStop Hook] stop_hook_active=true, parent_tool_use_id=Some("toolu_01ABC123"), last_assistant_message=Some("Task completed successfully."), session_id=123"#;
let result = parse_subagent_stop_hook(line);
assert!(result.is_some());
let data = result.unwrap();
assert_eq!(data.parent_tool_use_id, Some("toolu_01ABC123".to_string()));
assert_eq!(
data.last_assistant_message,
Some("Task completed successfully.".to_string())
);
}
#[test]
fn test_parse_subagent_stop_hook_with_last_message_none() {
let line = r#"[SubagentStop Hook] stop_hook_active=true, parent_tool_use_id=Some("toolu_01ABC123"), last_assistant_message=None, session_id=123"#;
let result = parse_subagent_stop_hook(line);
assert!(result.is_some());
let data = result.unwrap();
assert_eq!(data.last_assistant_message, None);
}
#[test]
fn test_extract_debug_string_value_simple() {
let line = r#"key=Some("hello world")"#;
assert_eq!(
extract_debug_string_value(line, "key"),
Some("hello world".to_string())
);
}
#[test]
fn test_extract_debug_string_value_with_escaped_quotes() {
let line = r#"key=Some("say \"hi\" there")"#;
assert_eq!(
extract_debug_string_value(line, "key"),
Some(r#"say "hi" there"#.to_string())
);
}
#[test]
fn test_extract_debug_string_value_none_variant() {
let line = "key=None";
assert_eq!(extract_debug_string_value(line, "key"), None);
}
#[test]
fn test_extract_debug_string_value_missing_key() {
let line = "other=Some(\"value\")";
assert_eq!(extract_debug_string_value(line, "key"), None);
}
#[test]
fn test_parse_subagent_stop_hook_with_commas_in_message() {
let line = r#"[SubagentStop Hook] parent_tool_use_id=Some("toolu_01"), last_assistant_message=Some("Found 3 files, all passing.")"#;
let result = parse_subagent_stop_hook(line);
assert!(result.is_some());
let data = result.unwrap();
assert_eq!(
data.last_assistant_message,
Some("Found 3 files, all passing.".to_string())
);
}
// extract_tool_result_text tests
#[test]
fn test_extract_tool_result_text_plain_string() {
let content = serde_json::json!("Hello from agent");
assert_eq!(
extract_tool_result_text(&content),
Some("Hello from agent".to_string())
);
}
#[test]
fn test_extract_tool_result_text_empty_string() {
let content = serde_json::json!("");
assert_eq!(extract_tool_result_text(&content), None);
}
#[test]
fn test_extract_tool_result_text_array_single_text_block() {
let content = serde_json::json!([{"type": "text", "text": "Agent completed the task."}]);
assert_eq!(
extract_tool_result_text(&content),
Some("Agent completed the task.".to_string())
);
}
#[test]
fn test_extract_tool_result_text_array_multiple_text_blocks() {
let content = serde_json::json!([
{"type": "text", "text": "First part."},
{"type": "text", "text": "Second part."}
]);
assert_eq!(
extract_tool_result_text(&content),
Some("First part.\nSecond part.".to_string())
);
}
#[test]
fn test_extract_tool_result_text_array_non_text_block() {
let content = serde_json::json!([{"type": "image", "source": {"type": "base64"}}]);
assert_eq!(extract_tool_result_text(&content), None);
}
#[test]
fn test_extract_tool_result_text_array_mixed_blocks() {
let content = serde_json::json!([
{"type": "image", "source": {}},
{"type": "text", "text": "Found results."}
]);
assert_eq!(
extract_tool_result_text(&content),
Some("Found results.".to_string())
);
}
#[test]
fn test_extract_tool_result_text_null() {
let content = serde_json::Value::Null;
assert_eq!(extract_tool_result_text(&content), None);
}
#[test]
fn test_extract_tool_result_text_empty_array() {
let content = serde_json::json!([]);
assert_eq!(extract_tool_result_text(&content), None);
}
// Verify the 50K tool result persistence threshold (CLI v2.1.51+).
// Results > 50K chars are now persisted to disk; the stream may send null
// or a large inline string. Both must be handled without panicking.
#[test]
fn test_extract_tool_result_text_large_content_above_50k_threshold() {
let large_text = "x".repeat(60_000);
let content = serde_json::Value::String(large_text.clone());
assert_eq!(extract_tool_result_text(&content), Some(large_text));
}
#[test]
fn test_tool_result_deserializes_with_null_content() {
let json = r#"{"type":"tool_result","tool_use_id":"toolu_abc","content":null}"#;
let block: ContentBlock = serde_json::from_str(json).unwrap();
if let ContentBlock::ToolResult { tool_use_id, content, is_error } = block {
assert_eq!(tool_use_id, "toolu_abc");
assert!(content.is_null());
assert_eq!(is_error, None);
// Persisted-to-disk results produce null content → no preview shown
assert_eq!(extract_tool_result_text(&content), None);
} else {
panic!("Expected ToolResult variant");
}
} }
} }
+4
View File
@@ -1,6 +1,8 @@
use std::process::Command; use std::process::Command;
use tauri::command; use tauri::command;
use crate::process_ext::HideWindow;
#[command] #[command]
pub async fn send_wsl_notification(title: String, body: String) -> Result<(), String> { pub async fn send_wsl_notification(title: String, body: String) -> Result<(), String> {
// Method 1: Try Windows 10/11 toast notification using PowerShell // Method 1: Try Windows 10/11 toast notification using PowerShell
@@ -36,6 +38,7 @@ $notifier.Show($toast)
// Try PowerShell.exe through WSL // Try PowerShell.exe through WSL
let output = Command::new("/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe") let output = Command::new("/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe")
.hide_window()
.arg("-NoProfile") .arg("-NoProfile")
.arg("-ExecutionPolicy") .arg("-ExecutionPolicy")
.arg("Bypass") .arg("Bypass")
@@ -65,6 +68,7 @@ $notifier.Show($toast)
// Method 3: Try wsl-notify-send if available // Method 3: Try wsl-notify-send if available
let notify_result = Command::new("wsl-notify-send") let notify_result = Command::new("wsl-notify-send")
.hide_window()
.arg("--appId") .arg("--appId")
.arg("HikariDesktop") .arg("HikariDesktop")
.arg("--category") .arg("--category")
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "hikari-desktop", "productName": "hikari-desktop",
"version": "1.5.0", "version": "1.10.0",
"identifier": "com.naomi.hikari-desktop", "identifier": "com.naomi.hikari-desktop",
"build": { "build": {
"beforeDevCommand": "pnpm dev", "beforeDevCommand": "pnpm dev",
+1 -5
View File
@@ -154,11 +154,7 @@ body {
padding: 0; padding: 0;
height: 100%; height: 100%;
overflow: hidden; overflow: hidden;
font-family: font-family: var(--ui-font-family, "Segoe UI", system-ui, -apple-system, sans-serif);
"Segoe UI",
system-ui,
-apple-system,
sans-serif;
background: var(--bg-primary); background: var(--bg-primary);
color: var(--text-primary); color: var(--text-primary);
} }
+449 -1
View File
@@ -1,4 +1,9 @@
import { describe, it, expect, vi } from "vitest"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { get } from "svelte/store";
import { invoke } from "@tauri-apps/api/core";
import { claudeStore } from "$lib/stores/claude";
import { searchState } from "$lib/stores/search";
import { characterState } from "$lib/stores/character";
import { import {
slashCommands, slashCommands,
parseSlashCommand, parseSlashCommand,
@@ -40,6 +45,28 @@ vi.mock("$lib/stores/character", () => ({
vi.mock("$lib/tauri", () => ({ vi.mock("$lib/tauri", () => ({
setSkipNextGreeting: vi.fn(), setSkipNextGreeting: vi.fn(),
updateDiscordRpc: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("$lib/stores/conversations", () => ({
conversationsStore: {
activeConversation: { subscribe: vi.fn() },
},
}));
vi.mock("$lib/stores/config", () => ({
configStore: {
getConfig: vi.fn().mockReturnValue({
auto_granted_tools: [],
model: "claude-sonnet",
api_key: null,
custom_instructions: null,
mcp_servers_json: null,
use_worktree: false,
disable_1m_context: false,
max_output_tokens: null,
}),
},
})); }));
vi.mock("$lib/stores/search", () => ({ vi.mock("$lib/stores/search", () => ({
@@ -415,4 +442,425 @@ describe("slashCommands", () => {
expect(result).toBeUndefined(); expect(result).toBeUndefined();
}); });
}); });
describe("command execute functions", () => {
let getMock: ReturnType<typeof vi.fn>;
let invokeMock: ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.clearAllMocks();
getMock = vi.mocked(get);
invokeMock = vi.mocked(invoke);
});
describe("/clear execute", () => {
it("clears terminal and shows confirmation message", () => {
const clearCmd = slashCommands.find((cmd) => cmd.name === "clear")!;
clearCmd.execute("");
expect(claudeStore.clearTerminal).toHaveBeenCalledWith();
expect(claudeStore.addLine).toHaveBeenCalledWith("system", "Terminal cleared");
});
});
describe("/help execute", () => {
it("shows available commands header", () => {
const helpCmd = slashCommands.find((cmd) => cmd.name === "help")!;
helpCmd.execute("");
expect(claudeStore.addLine).toHaveBeenCalledWith(
"system",
expect.stringContaining("Available commands:")
);
});
it("includes all command usages in help text", () => {
const helpCmd = slashCommands.find((cmd) => cmd.name === "help")!;
helpCmd.execute("");
const callArgs = vi.mocked(claudeStore.addLine).mock.calls[0];
const helpText = callArgs[1] as string;
expect(helpText).toContain("/cd");
expect(helpText).toContain("/clear");
expect(helpText).toContain("/help");
expect(helpText).toContain("/search");
expect(helpText).toContain("/new");
expect(helpText).toContain("/summarise");
expect(helpText).toContain("/skill");
});
it("includes command descriptions in help text", () => {
const helpCmd = slashCommands.find((cmd) => cmd.name === "help")!;
helpCmd.execute("");
const callArgs = vi.mocked(claudeStore.addLine).mock.calls[0];
const helpText = callArgs[1] as string;
expect(helpText).toContain("Change the working directory");
expect(helpText).toContain("Show available slash commands");
});
});
describe("/search execute", () => {
it("clears search when called with empty args", () => {
const searchCmd = slashCommands.find((cmd) => cmd.name === "search")!;
searchCmd.execute("");
expect(searchState.clear).toHaveBeenCalledWith();
expect(claudeStore.addLine).toHaveBeenCalledWith("system", "Search cleared");
});
it("clears search when called with whitespace-only args", () => {
const searchCmd = slashCommands.find((cmd) => cmd.name === "search")!;
searchCmd.execute(" ");
expect(searchState.clear).toHaveBeenCalledWith();
expect(claudeStore.addLine).toHaveBeenCalledWith("system", "Search cleared");
});
it("sets query when called with a search term", () => {
const searchCmd = slashCommands.find((cmd) => cmd.name === "search")!;
searchCmd.execute("hello world");
expect(searchState.setQuery).toHaveBeenCalledWith("hello world");
expect(claudeStore.addLine).toHaveBeenCalledWith("system", 'Searching for: "hello world"');
});
it("trims whitespace from query before setting", () => {
const searchCmd = slashCommands.find((cmd) => cmd.name === "search")!;
searchCmd.execute(" hello ");
expect(searchState.setQuery).toHaveBeenCalledWith("hello");
expect(claudeStore.addLine).toHaveBeenCalledWith("system", 'Searching for: "hello"');
});
});
describe("/cd execute", () => {
it("shows error when no active conversation", async () => {
getMock.mockReturnValue(null);
const cdCmd = slashCommands.find((cmd) => cmd.name === "cd")!;
await cdCmd.execute("/some/path");
expect(claudeStore.addLine).toHaveBeenCalledWith("error", "No active conversation");
});
it("shows current directory when called with empty args", async () => {
getMock.mockReturnValueOnce("conv-123").mockReturnValueOnce("/home/naomi/code");
const cdCmd = slashCommands.find((cmd) => cmd.name === "cd")!;
await cdCmd.execute("");
expect(claudeStore.addLine).toHaveBeenCalledWith(
"system",
"Current directory: /home/naomi/code"
);
});
it("shows current directory when called with whitespace-only args", async () => {
getMock.mockReturnValueOnce("conv-123").mockReturnValueOnce("/home/naomi/code");
const cdCmd = slashCommands.find((cmd) => cmd.name === "cd")!;
await cdCmd.execute(" ");
expect(claudeStore.addLine).toHaveBeenCalledWith(
"system",
"Current directory: /home/naomi/code"
);
});
it("validates path and changes directory on success", async () => {
getMock.mockReturnValueOnce("conv-123").mockReturnValueOnce(null);
invokeMock.mockResolvedValue("/validated/path");
const cdCmd = slashCommands.find((cmd) => cmd.name === "cd")!;
await cdCmd.execute("/new/path");
expect(invokeMock).toHaveBeenCalledWith(
"validate_directory",
expect.objectContaining({ path: "/new/path" })
);
});
it("shows error when directory change fails", async () => {
getMock.mockReturnValueOnce("conv-123");
invokeMock.mockRejectedValueOnce(new Error("invalid path"));
const cdCmd = slashCommands.find((cmd) => cmd.name === "cd")!;
await cdCmd.execute("/bad/path");
expect(claudeStore.addLine).toHaveBeenCalledWith(
"error",
expect.stringContaining("Failed to change directory:")
);
expect(characterState.setTemporaryState).toHaveBeenCalledWith("error", 3000);
});
});
describe("/new execute", () => {
it("shows error when no active conversation", async () => {
getMock.mockReturnValue(null);
const newCmd = slashCommands.find((cmd) => cmd.name === "new")!;
await newCmd.execute("");
expect(claudeStore.addLine).toHaveBeenCalledWith("error", "No active conversation");
});
it("shows error when starting new conversation fails", async () => {
getMock.mockReturnValueOnce("conv-123");
invokeMock.mockRejectedValueOnce(new Error("invoke failed"));
const newCmd = slashCommands.find((cmd) => cmd.name === "new")!;
await newCmd.execute("");
expect(claudeStore.addLine).toHaveBeenCalledWith(
"error",
expect.stringContaining("Failed to start new conversation:")
);
expect(characterState.setTemporaryState).toHaveBeenCalledWith("error", 3000);
});
});
describe("/summarise execute", () => {
it("shows error when no active conversation", async () => {
getMock.mockReturnValue(null);
const summariseCmd = slashCommands.find((cmd) => cmd.name === "summarise")!;
await summariseCmd.execute("");
expect(claudeStore.addLine).toHaveBeenCalledWith("error", "No active conversation");
});
it("sends a summary prompt when there is an active conversation", async () => {
getMock.mockReturnValue("conv-123");
invokeMock.mockResolvedValue(undefined);
const summariseCmd = slashCommands.find((cmd) => cmd.name === "summarise")!;
await summariseCmd.execute("");
expect(claudeStore.addLine).toHaveBeenCalledWith(
"system",
"Requesting conversation summary..."
);
expect(invokeMock).toHaveBeenCalledWith(
"send_prompt",
expect.objectContaining({ conversationId: "conv-123" })
);
});
it("shows error when send_prompt invoke fails", async () => {
getMock.mockReturnValue("conv-123");
invokeMock.mockRejectedValue(new Error("network error"));
const summariseCmd = slashCommands.find((cmd) => cmd.name === "summarise")!;
await summariseCmd.execute("");
expect(claudeStore.addLine).toHaveBeenCalledWith(
"error",
expect.stringContaining("Failed to request summary:")
);
});
});
describe("/skill execute", () => {
it("shows error when no active conversation", async () => {
getMock.mockReturnValue(null);
const skillCmd = slashCommands.find((cmd) => cmd.name === "skill")!;
await skillCmd.execute("onboard-mentee");
expect(claudeStore.addLine).toHaveBeenCalledWith("error", "No active conversation");
});
it("lists available skills when called with no name", async () => {
getMock.mockReturnValue("conv-123");
invokeMock.mockResolvedValue(["onboard-mentee", "other-skill"]);
const skillCmd = slashCommands.find((cmd) => cmd.name === "skill")!;
await skillCmd.execute("");
expect(invokeMock).toHaveBeenCalledWith("list_skills");
expect(claudeStore.addLine).toHaveBeenCalledWith(
"system",
expect.stringContaining("onboard-mentee")
);
});
it("shows empty message when no skills are found", async () => {
getMock.mockReturnValue("conv-123");
invokeMock.mockResolvedValue([]);
const skillCmd = slashCommands.find((cmd) => cmd.name === "skill")!;
await skillCmd.execute("");
expect(claudeStore.addLine).toHaveBeenCalledWith(
"system",
expect.stringContaining("No skills found")
);
});
it("invokes skill when called with a name and no data", async () => {
getMock.mockReturnValue("conv-123");
invokeMock.mockResolvedValue(undefined);
const skillCmd = slashCommands.find((cmd) => cmd.name === "skill")!;
await skillCmd.execute("onboard-mentee");
expect(claudeStore.addLine).toHaveBeenCalledWith(
"system",
"Invoking skill: onboard-mentee"
);
expect(invokeMock).toHaveBeenCalledWith(
"send_prompt",
expect.objectContaining({ conversationId: "conv-123" })
);
});
it("invokes skill with additional data in the prompt", async () => {
getMock.mockReturnValue("conv-123");
invokeMock.mockResolvedValue(undefined);
const skillCmd = slashCommands.find((cmd) => cmd.name === "skill")!;
await skillCmd.execute("onboard-mentee some extra data");
expect(invokeMock).toHaveBeenCalledWith("send_prompt", {
conversationId: "conv-123",
message: expect.stringContaining("some extra data"),
});
});
it("shows error when listing skills fails", async () => {
getMock.mockReturnValue("conv-123");
invokeMock.mockRejectedValue(new Error("list error"));
const skillCmd = slashCommands.find((cmd) => cmd.name === "skill")!;
await skillCmd.execute("");
expect(claudeStore.addLine).toHaveBeenCalledWith(
"error",
expect.stringContaining("Failed to list skills:")
);
});
it("shows error and resets character state when invoking skill fails", async () => {
getMock.mockReturnValue("conv-123");
invokeMock.mockRejectedValue(new Error("invoke error"));
const skillCmd = slashCommands.find((cmd) => cmd.name === "skill")!;
await skillCmd.execute("onboard-mentee");
expect(claudeStore.addLine).toHaveBeenCalledWith(
"error",
expect.stringContaining("Failed to invoke skill:")
);
expect(characterState.setTemporaryState).toHaveBeenCalledWith("error", 3000);
});
});
describe("/cd success path", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("changes directory and shows success message", async () => {
getMock
.mockReturnValueOnce("conv-123") // get(claudeStore.activeConversationId)
.mockReturnValueOnce("/current") // get(claudeStore.currentWorkingDirectory)
.mockReturnValueOnce(null); // get(conversationsStore.activeConversation)
vi.mocked(claudeStore.getConversationHistory).mockReturnValue("");
invokeMock
.mockResolvedValueOnce("/new/path") // validate_directory
.mockResolvedValueOnce(undefined) // stop_claude
.mockResolvedValueOnce(undefined); // start_claude
const cdCmd = slashCommands.find((cmd) => cmd.name === "cd")!;
const promise = cdCmd.execute("/new/path");
await vi.runAllTimersAsync();
await promise;
expect(claudeStore.addLine).toHaveBeenCalledWith(
"system",
"Changed directory to: /new/path"
);
expect(characterState.setState).toHaveBeenCalledWith("idle");
});
it("sends context restoration message when conversation history exists", async () => {
getMock
.mockReturnValueOnce("conv-123") // get(claudeStore.activeConversationId)
.mockReturnValueOnce("/current") // get(claudeStore.currentWorkingDirectory)
.mockReturnValueOnce(null); // get(conversationsStore.activeConversation)
vi.mocked(claudeStore.getConversationHistory).mockReturnValue(
"previous conversation history"
);
invokeMock
.mockResolvedValueOnce("/new/path") // validate_directory
.mockResolvedValueOnce(undefined) // stop_claude
.mockResolvedValueOnce(undefined) // start_claude
.mockResolvedValueOnce(undefined); // send_prompt
const cdCmd = slashCommands.find((cmd) => cmd.name === "cd")!;
const promise = cdCmd.execute("/new/path");
await vi.runAllTimersAsync();
await promise;
expect(invokeMock).toHaveBeenCalledWith(
"send_prompt",
expect.objectContaining({
message: expect.stringContaining("previous conversation history"),
})
);
expect(claudeStore.addLine).toHaveBeenCalledWith(
"system",
"Changed directory to: /new/path"
);
});
it("calls updateDiscordRpc when activeConversation is available", async () => {
const activeConv = {
name: "Test Conversation",
model: "claude-sonnet",
startedAt: new Date("2026-03-03T12:00:00Z"),
grantedTools: new Set<string>(),
};
getMock
.mockReturnValueOnce("conv-123") // get(claudeStore.activeConversationId)
.mockReturnValueOnce("/current") // get(claudeStore.currentWorkingDirectory)
.mockReturnValueOnce(activeConv); // get(conversationsStore.activeConversation)
vi.mocked(claudeStore.getConversationHistory).mockReturnValue("");
invokeMock
.mockResolvedValueOnce("/new/path")
.mockResolvedValueOnce(undefined)
.mockResolvedValueOnce(undefined);
const { updateDiscordRpc } = await import("$lib/tauri");
const cdCmd = slashCommands.find((cmd) => cmd.name === "cd")!;
const promise = cdCmd.execute("/new/path");
await vi.runAllTimersAsync();
await promise;
expect(updateDiscordRpc).toHaveBeenCalledWith(
"Test Conversation",
expect.any(String),
expect.any(Date)
);
});
});
describe("/new success path", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("starts a new conversation and shows success message", async () => {
getMock
.mockReturnValueOnce("conv-123") // get(claudeStore.activeConversationId)
.mockReturnValueOnce(null); // get(conversationsStore.activeConversation)
invokeMock
.mockResolvedValueOnce("/working/dir") // get_working_directory
.mockResolvedValueOnce(undefined) // interrupt_claude
.mockResolvedValueOnce(undefined); // start_claude
const newCmd = slashCommands.find((cmd) => cmd.name === "new")!;
const promise = newCmd.execute("");
await vi.runAllTimersAsync();
await promise;
expect(claudeStore.addLine).toHaveBeenCalledWith("system", "New conversation started!");
expect(characterState.setState).toHaveBeenCalledWith("idle");
});
it("calls updateDiscordRpc when activeConversation is available", async () => {
const activeConv = {
name: "My Conv",
model: "claude-sonnet",
startedAt: new Date("2026-03-03T12:00:00Z"),
grantedTools: new Set<string>(["tool1"]),
};
getMock.mockReturnValueOnce("conv-123").mockReturnValueOnce(activeConv);
invokeMock
.mockResolvedValueOnce("/working/dir")
.mockResolvedValueOnce(undefined)
.mockResolvedValueOnce(undefined);
const { updateDiscordRpc } = await import("$lib/tauri");
const newCmd = slashCommands.find((cmd) => cmd.name === "new")!;
const promise = newCmd.execute("");
await vi.runAllTimersAsync();
await promise;
expect(updateDiscordRpc).toHaveBeenCalledWith(
"My Conv",
expect.any(String),
expect.any(Date)
);
});
});
});
}); });
+6
View File
@@ -61,6 +61,9 @@ async function changeDirectory(path: string): Promise<void> {
custom_instructions: config.custom_instructions || null, custom_instructions: config.custom_instructions || null,
mcp_servers_json: config.mcp_servers_json || null, mcp_servers_json: config.mcp_servers_json || null,
allowed_tools: allAllowedTools, allowed_tools: allAllowedTools,
use_worktree: config.use_worktree ?? false,
disable_1m_context: config.disable_1m_context ?? false,
max_output_tokens: config.max_output_tokens ?? null,
}, },
}); });
@@ -135,6 +138,9 @@ async function startNewConversation(): Promise<void> {
custom_instructions: config.custom_instructions || null, custom_instructions: config.custom_instructions || null,
mcp_servers_json: config.mcp_servers_json || null, mcp_servers_json: config.mcp_servers_json || null,
allowed_tools: allAllowedTools, allowed_tools: allAllowedTools,
use_worktree: config.use_worktree ?? false,
disable_1m_context: config.disable_1m_context ?? false,
max_output_tokens: config.max_output_tokens ?? null,
}, },
}); });
@@ -0,0 +1,153 @@
/**
* AchievementNotification Component Tests
*
* Tests the rarity classification and colour mapping logic used by the
* AchievementNotification component.
*
* What this component does:
* - Listens for "achievement:unlocked" Tauri events
* - Queues and displays achievement notifications one at a time
* - Each notification shows the achievement's name, icon, description, and rarity
* - A gradient border and badge colour correspond to the achievement's rarity
*
* Manual testing checklist:
* - [ ] Achievement notification slides in from the right
* - [ ] Notification auto-dismisses after 5 seconds
* - [ ] Dismiss button works immediately
* - [ ] Multiple achievements queue and display sequentially
* - [ ] Legendary achievements have a yellow-orange gradient
* - [ ] Epic achievements have a purple-pink gradient
* - [ ] Rare achievements have a blue-indigo gradient
* - [ ] Common achievements have a green-emerald gradient
*/
import { describe, it, expect } from "vitest";
function getAchievementRarity(id: string): string {
if (id === "TokenMaster") return "legendary";
if (["CodeMachine", "Unstoppable"].includes(id)) return "epic";
if (
[
"BlossomingCoder",
"CodeWizard",
"MasterBuilder",
"EnduranceChamp",
"DeepDive",
"CreativeCoder",
].includes(id)
)
return "rare";
return "common";
}
function getRarityColor(rarity: string): string {
switch (rarity) {
case "legendary":
return "from-yellow-400 to-orange-500";
case "epic":
return "from-purple-400 to-pink-500";
case "rare":
return "from-blue-400 to-indigo-500";
default:
return "from-green-400 to-emerald-500";
}
}
// ---
describe("getAchievementRarity", () => {
describe("legendary tier", () => {
it("classifies TokenMaster as legendary", () => {
expect(getAchievementRarity("TokenMaster")).toBe("legendary");
});
});
describe("epic tier", () => {
it("classifies CodeMachine as epic", () => {
expect(getAchievementRarity("CodeMachine")).toBe("epic");
});
it("classifies Unstoppable as epic", () => {
expect(getAchievementRarity("Unstoppable")).toBe("epic");
});
});
describe("rare tier", () => {
it("classifies BlossomingCoder as rare", () => {
expect(getAchievementRarity("BlossomingCoder")).toBe("rare");
});
it("classifies CodeWizard as rare", () => {
expect(getAchievementRarity("CodeWizard")).toBe("rare");
});
it("classifies MasterBuilder as rare", () => {
expect(getAchievementRarity("MasterBuilder")).toBe("rare");
});
it("classifies EnduranceChamp as rare", () => {
expect(getAchievementRarity("EnduranceChamp")).toBe("rare");
});
it("classifies DeepDive as rare", () => {
expect(getAchievementRarity("DeepDive")).toBe("rare");
});
it("classifies CreativeCoder as rare", () => {
expect(getAchievementRarity("CreativeCoder")).toBe("rare");
});
});
describe("common tier", () => {
it("classifies unknown IDs as common", () => {
expect(getAchievementRarity("FirstChat")).toBe("common");
expect(getAchievementRarity("SomeNewAchievement")).toBe("common");
expect(getAchievementRarity("")).toBe("common");
});
});
});
describe("getRarityColor", () => {
it("returns yellow-to-orange gradient for legendary", () => {
expect(getRarityColor("legendary")).toBe("from-yellow-400 to-orange-500");
});
it("returns purple-to-pink gradient for epic", () => {
expect(getRarityColor("epic")).toBe("from-purple-400 to-pink-500");
});
it("returns blue-to-indigo gradient for rare", () => {
expect(getRarityColor("rare")).toBe("from-blue-400 to-indigo-500");
});
it("returns green-to-emerald gradient for common", () => {
expect(getRarityColor("common")).toBe("from-green-400 to-emerald-500");
});
it("falls back to green-to-emerald gradient for unknown rarities", () => {
expect(getRarityColor("mythic")).toBe("from-green-400 to-emerald-500");
expect(getRarityColor("")).toBe("from-green-400 to-emerald-500");
});
describe("end-to-end rarity pipeline", () => {
it("produces the correct colour for a legendary achievement", () => {
const color = getRarityColor(getAchievementRarity("TokenMaster"));
expect(color).toBe("from-yellow-400 to-orange-500");
});
it("produces the correct colour for an epic achievement", () => {
const color = getRarityColor(getAchievementRarity("CodeMachine"));
expect(color).toBe("from-purple-400 to-pink-500");
});
it("produces the correct colour for a rare achievement", () => {
const color = getRarityColor(getAchievementRarity("CodeWizard"));
expect(color).toBe("from-blue-400 to-indigo-500");
});
it("produces the correct colour for a common achievement", () => {
const color = getRarityColor(getAchievementRarity("FirstChat"));
expect(color).toBe("from-green-400 to-emerald-500");
});
});
});
@@ -270,6 +270,14 @@
/> />
</svg> </svg>
{/if} {/if}
<img
src={agent.characterAvatar}
alt={agent.characterName}
class="w-5 h-5 rounded-full object-cover"
/>
<span class="text-[10px] font-medium text-[var(--text-primary)]">
{agent.characterName}
</span>
<span <span
class="px-1.5 py-0.5 text-[10px] rounded border {getStatusBadgeClass( class="px-1.5 py-0.5 text-[10px] rounded border {getStatusBadgeClass(
agent.status agent.status
@@ -310,6 +318,16 @@
<span class="text-[10px] text-red-400">Errored / Killed</span> <span class="text-[10px] text-red-400">Errored / Killed</span>
{/if} {/if}
</div> </div>
<!-- Last assistant message snippet -->
{#if agent.lastAssistantMessage}
<p
class="mt-1 text-[10px] text-[var(--text-secondary)] italic truncate"
title={agent.lastAssistantMessage}
>
{agent.lastAssistantMessage}
</p>
{/if}
</div> </div>
{/each} {/each}
{/if} {/if}
+140
View File
@@ -0,0 +1,140 @@
<script lang="ts">
import { CHARACTER_POOL } from "$lib/utils/agentCharacters";
interface Props {
onClose: () => void;
}
const { onClose }: Props = $props();
</script>
<div
class="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4"
onclick={onClose}
role="button"
tabindex="0"
onkeydown={(e) => e.key === "Escape" && onClose()}
>
<div
class="bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg shadow-xl max-w-2xl w-full p-6 max-h-[90vh] overflow-y-auto"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
role="dialog"
aria-labelledby="cast-title"
tabindex="-1"
>
<div class="flex items-center justify-between mb-6">
<h2 id="cast-title" class="text-xl font-semibold text-[var(--text-primary)]">
Meet the Team
</h2>
<button
onclick={onClose}
class="p-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
aria-label="Close"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<!-- Principal cast: Hikari + Naomi -->
<div class="grid grid-cols-1 gap-3 mb-6 sm:grid-cols-2">
<div
class="flex items-center gap-3 p-4 rounded-lg bg-[var(--bg-secondary)] border border-[var(--accent-primary)]/40"
>
<img
src="https://cdn.nhcarrigan.com/hikari.png"
alt="Hikari"
class="w-16 h-16 object-cover rounded-full border-2 border-[var(--border-color)] shrink-0"
/>
<div>
<div class="flex items-center gap-2 mb-1">
<span class="font-semibold text-[var(--text-primary)]">Hikari</span>
<span
class="text-xs px-2 py-0.5 rounded-full bg-[var(--accent-primary)]/20 text-[var(--accent-primary)] font-medium"
>
Chief Operating Officer
</span>
</div>
<p class="text-xs text-[var(--text-secondary)]">
Holds the line so the others don't have to. Never without her clipboard — or her
glasses.
</p>
</div>
</div>
<div
class="flex items-center gap-3 p-4 rounded-lg bg-[var(--bg-secondary)] border border-[var(--accent-primary)]/40"
>
<img
src="https://cdn.nhcarrigan.com/profile.png"
alt="Naomi"
class="w-16 h-16 object-cover rounded-full border-2 border-[var(--border-color)] shrink-0"
/>
<div>
<div class="flex items-center gap-2 mb-1">
<span class="font-semibold text-[var(--text-primary)]">Naomi</span>
<span
class="text-xs px-2 py-0.5 rounded-full bg-[var(--accent-primary)]/20 text-[var(--accent-primary)] font-medium"
>
Chief hEx-ecutive Officer
</span>
</div>
<p class="text-xs text-[var(--text-secondary)]">
A 525-year-old vampire running a tech company from behind a VTuber avatar. Fixes server
crashes at 4 AM.
</p>
</div>
</div>
</div>
<!-- Subagent girls grid -->
<div>
<h3 class="text-sm font-medium text-[var(--text-secondary)] uppercase tracking-wider mb-3">
Subagent Squad
</h3>
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3">
{#each CHARACTER_POOL as character (character.name)}
<div
class="flex flex-col items-center gap-2 p-3 rounded-lg bg-[var(--bg-secondary)] border border-[var(--border-color)] text-center"
>
<img
src={character.avatar}
alt={character.name}
class="w-14 h-14 object-cover rounded-full border-2 border-[var(--border-color)]"
/>
<span class="text-sm font-medium text-[var(--text-primary)]">{character.name}</span>
<span
class="text-xs px-2 py-0.5 rounded-full bg-[var(--accent-primary)]/20 text-[var(--accent-primary)] font-medium"
>
{character.title}
</span>
<p class="text-xs text-[var(--text-secondary)] leading-snug">{character.description}</p>
</div>
{/each}
</div>
</div>
</div>
</div>
<style>
[role="dialog"] {
animation: slideIn 0.2s ease-out;
}
@keyframes slideIn {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
</style>
+111 -19
View File
@@ -2,15 +2,43 @@
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { onMount } from "svelte"; import { onMount } from "svelte";
let version = $state("Loading..."); const SUPPORTED_CLI_VERSION = "2.1.53";
let installedVersion = $state("Loading...");
function compareVersions(a: string, b: string): number {
const aParts = a.split(".").map(Number);
const bParts = b.split(".").map(Number);
for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) {
const aVal = aParts[i] ?? 0;
const bVal = bParts[i] ?? 0;
if (aVal > bVal) return 1;
if (aVal < bVal) return -1;
}
return 0;
}
let displayVersion = $derived(installedVersion.split(" (")[0]);
let supportedBadgeState = $derived.by(() => {
if (installedVersion === "Loading..." || installedVersion === "Unknown") {
return "neutral";
}
const semverMatch = /(\d+\.\d+\.\d+)/.exec(installedVersion);
if (!semverMatch) return "neutral";
const cmp = compareVersions(semverMatch[1], SUPPORTED_CLI_VERSION);
if (cmp > 0) return "ahead";
if (cmp < 0) return "behind";
return "current";
});
async function fetchVersion() { async function fetchVersion() {
try { try {
const result = await invoke<string>("get_claude_version"); const result = await invoke<string>("get_claude_version");
version = result; installedVersion = result;
} catch (error) { } catch (error) {
console.error("Failed to get Claude CLI version:", error); console.error("Failed to get Claude CLI version:", error);
version = "Unknown"; installedVersion = "Unknown";
} }
} }
@@ -19,25 +47,60 @@
}); });
</script> </script>
<div class="cli-version"> <div class="cli-versions">
<svg <div class="cli-version">
class="terminal-icon" <svg
width="14" class="terminal-icon"
height="14" width="14"
viewBox="0 0 24 24" height="14"
fill="none" viewBox="0 0 24 24"
stroke="currentColor" fill="none"
stroke-width="2" stroke="currentColor"
stroke-linecap="round" stroke-width="2"
stroke-linejoin="round" stroke-linecap="round"
> stroke-linejoin="round"
<polyline points="4 17 10 11 4 5" /> >
<line x1="12" y1="19" x2="20" y2="19" /> <polyline points="4 17 10 11 4 5" />
</svg> <line x1="12" y1="19" x2="20" y2="19" />
<span class="version-text">CLI {version}</span> </svg>
<span class="version-text">CLI {displayVersion}</span>
</div>
<div class="cli-version supported {supportedBadgeState}" title="Highest audited CLI version">
<svg
class="terminal-icon"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
</svg>
<span class="version-text">Supported {SUPPORTED_CLI_VERSION}</span>
</div>
{#if supportedBadgeState === "ahead"}
<span class="version-warning ahead"
>Your version is newer, some features may not be supported</span
>
{:else if supportedBadgeState === "behind"}
<span class="version-warning behind"
>Your version is out of date, please update to ensure compatibility</span
>
{/if}
</div> </div>
<style> <style>
.cli-versions {
display: flex;
gap: 6px;
align-items: center;
}
.cli-version { .cli-version {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -57,6 +120,21 @@
color: var(--accent-primary); color: var(--accent-primary);
} }
.cli-version.supported.current {
border-color: var(--success-color, #4caf50);
color: var(--success-color, #4caf50);
}
.cli-version.supported.ahead {
border-color: var(--warning-color, #ff9800);
color: var(--warning-color, #ff9800);
}
.cli-version.supported.behind {
border-color: var(--error-color, #f44336);
color: var(--error-color, #f44336);
}
.terminal-icon { .terminal-icon {
flex-shrink: 0; flex-shrink: 0;
opacity: 0.7; opacity: 0.7;
@@ -65,4 +143,18 @@
.version-text { .version-text {
white-space: nowrap; white-space: nowrap;
} }
.version-warning {
font-size: 0.75rem;
font-style: italic;
white-space: nowrap;
}
.version-warning.ahead {
color: var(--warning-color, #ff9800);
}
.version-warning.behind {
color: var(--error-color, #f44336);
}
</style> </style>
+134
View File
@@ -0,0 +1,134 @@
/**
* CliVersion Component Tests
*
* Tests the version comparison logic used by the CliVersion component,
* which compares the installed CLI version against the supported version.
*
* What this component does:
* - Displays the installed Claude CLI version
* - Displays the highest audited/supported CLI version
* - Shows a warning when the installed version is ahead of or behind supported
*
* Manual testing checklist:
* - [ ] Installed version is fetched and displayed on mount
* - [ ] "current" badge shows in green when versions match
* - [ ] "ahead" badge shows in amber when installed is newer than supported
* - [ ] "behind" badge shows in red when installed is older than supported
* - [ ] Warning message appears for "ahead" and "behind" states
*/
import { describe, it, expect } from "vitest";
const SUPPORTED_CLI_VERSION = "2.1.53";
function compareVersions(a: string, b: string): number {
const aParts = a.split(".").map(Number);
const bParts = b.split(".").map(Number);
for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) {
const aVal = aParts[i] ?? 0;
const bVal = bParts[i] ?? 0;
if (aVal > bVal) return 1;
if (aVal < bVal) return -1;
}
return 0;
}
// ---
describe("SUPPORTED_CLI_VERSION", () => {
it("is defined and non-empty", () => {
expect(SUPPORTED_CLI_VERSION).toBeTruthy();
});
it("matches the expected audited version", () => {
expect(SUPPORTED_CLI_VERSION).toBe("2.1.53");
});
});
describe("compareVersions", () => {
describe("equal versions", () => {
it("returns 0 for identical versions", () => {
expect(compareVersions("1.0.0", "1.0.0")).toBe(0);
});
it("returns 0 for the supported CLI version against itself", () => {
expect(compareVersions(SUPPORTED_CLI_VERSION, SUPPORTED_CLI_VERSION)).toBe(0);
});
it("returns 0 for 0.0.0 vs 0.0.0", () => {
expect(compareVersions("0.0.0", "0.0.0")).toBe(0);
});
});
describe("major version differences", () => {
it("returns 1 when a has a higher major version", () => {
expect(compareVersions("2.0.0", "1.0.0")).toBe(1);
});
it("returns -1 when a has a lower major version", () => {
expect(compareVersions("1.0.0", "2.0.0")).toBe(-1);
});
});
describe("minor version differences", () => {
it("returns 1 when a has a higher minor version", () => {
expect(compareVersions("1.2.0", "1.1.0")).toBe(1);
});
it("returns -1 when a has a lower minor version", () => {
expect(compareVersions("1.1.0", "1.2.0")).toBe(-1);
});
});
describe("patch version differences", () => {
it("returns 1 when a has a higher patch version", () => {
expect(compareVersions("1.0.2", "1.0.1")).toBe(1);
});
it("returns -1 when a has a lower patch version", () => {
expect(compareVersions("1.0.1", "1.0.2")).toBe(-1);
});
});
describe("major version takes precedence", () => {
it("returns 1 when a has a higher major but lower minor", () => {
expect(compareVersions("2.0.0", "1.9.9")).toBe(1);
});
it("returns -1 when a has a lower major but higher minor", () => {
expect(compareVersions("1.9.9", "2.0.0")).toBe(-1);
});
});
describe("unequal segment counts", () => {
it("treats missing segments as 0 (a shorter than b)", () => {
expect(compareVersions("1.0", "1.0.0")).toBe(0);
});
it("treats missing segments as 0 (a longer than b)", () => {
expect(compareVersions("1.0.0", "1.0")).toBe(0);
});
it("correctly compares when a has an extra non-zero segment", () => {
expect(compareVersions("1.0.1", "1.0")).toBe(1);
});
it("correctly compares when b has an extra non-zero segment", () => {
expect(compareVersions("1.0", "1.0.1")).toBe(-1);
});
});
describe("supported CLI version comparisons", () => {
it("returns 1 for a version ahead of supported", () => {
expect(compareVersions("2.2.0", SUPPORTED_CLI_VERSION)).toBe(1);
});
it("returns -1 for a version behind supported", () => {
expect(compareVersions("2.1.0", SUPPORTED_CLI_VERSION)).toBe(-1);
});
it("returns 0 for exactly the supported version", () => {
expect(compareVersions("2.1.53", SUPPORTED_CLI_VERSION)).toBe(0);
});
});
});
+9 -3
View File
@@ -3,7 +3,7 @@
import { get } from "svelte/store"; import { get } from "svelte/store";
import { claudeStore, isClaudeProcessing } from "$lib/stores/claude"; import { claudeStore, isClaudeProcessing } from "$lib/stores/claude";
import { characterState, characterInfo } from "$lib/stores/character"; import { characterState, characterInfo } from "$lib/stores/character";
import { isStreamerMode } from "$lib/stores/config"; import { isStreamerMode, configStore } from "$lib/stores/config";
import { handleNewUserMessage } from "$lib/notifications/rules"; import { handleNewUserMessage } from "$lib/notifications/rules";
import { setSkipNextGreeting } from "$lib/tauri"; import { setSkipNextGreeting } from "$lib/tauri";
import type { CharacterState, CharacterStateInfo } from "$lib/types/states"; import type { CharacterState, CharacterStateInfo } from "$lib/types/states";
@@ -14,6 +14,9 @@
let { onExpand }: Props = $props(); let { onExpand }: Props = $props();
const configValues = configStore.config;
const hasBackgroundImage = $derived($configValues.background_image_path !== null);
let inputValue = $state(""); let inputValue = $state("");
let isSubmitting = $state(false); let isSubmitting = $state(false);
let isConnected = $state(false); let isConnected = $state(false);
@@ -132,7 +135,7 @@
setSkipNextGreeting(true); setSkipNextGreeting(true);
await invoke("interrupt_claude", { conversationId }); await invoke("interrupt_claude", { conversationId });
claudeStore.addLine("system", "Interrupted"); claudeStore.addLine("system", "Process interrupted via stop button");
characterState.setState("idle"); characterState.setState("idle");
} catch (error) { } catch (error) {
console.error("Failed to interrupt:", error); console.error("Failed to interrupt:", error);
@@ -150,7 +153,10 @@
} }
</script> </script>
<div class="compact-container {getStateGlow()}"> <div
class="compact-container {getStateGlow()}"
style={hasBackgroundImage ? "background: transparent !important;" : ""}
>
<!-- Character sprite (smaller) --> <!-- Character sprite (smaller) -->
<div class="compact-character"> <div class="compact-character">
<div class="sprite-wrapper {getAnimationClass()}"> <div class="sprite-wrapper {getAnimationClass()}">
+419 -1
View File
@@ -5,6 +5,8 @@
type Theme, type Theme,
type CustomThemeColors, type CustomThemeColors,
applyFontSize, applyFontSize,
applyCustomFont,
applyCustomUiFont,
applyCustomThemeColors, applyCustomThemeColors,
MIN_FONT_SIZE, MIN_FONT_SIZE,
MAX_FONT_SIZE, MAX_FONT_SIZE,
@@ -12,6 +14,8 @@
} from "$lib/stores/config"; } from "$lib/stores/config";
import { claudeStore } from "$lib/stores/claude"; import { claudeStore } from "$lib/stores/claude";
import { getCurrentWindow } from "@tauri-apps/api/window"; import { getCurrentWindow } from "@tauri-apps/api/window";
import { invoke } from "@tauri-apps/api/core";
import { open } from "@tauri-apps/plugin-dialog";
import CostSummary from "./CostSummary.svelte"; import CostSummary from "./CostSummary.svelte";
let config: HikariConfig = $state({ let config: HikariConfig = $state({
@@ -52,9 +56,40 @@
budget_warning_threshold: 0.8, budget_warning_threshold: 0.8,
discord_rpc_enabled: true, discord_rpc_enabled: true,
show_thinking_blocks: true, show_thinking_blocks: true,
use_worktree: false,
disable_1m_context: false,
max_output_tokens: null,
trusted_workspaces: [],
background_image_path: null,
background_image_opacity: 0.3,
custom_font_path: null,
custom_font_family: null,
custom_ui_font_path: null,
custom_ui_font_family: null,
}); });
let showCustomThemeEditor = $state(false); let showCustomThemeEditor = $state(false);
let customFontPathInput = $state("");
let customFontFamilyInput = $state("");
let customFontStatus: string | null = $state(null);
let customUiFontPathInput = $state("");
let customUiFontFamilyInput = $state("");
let customUiFontStatus: string | null = $state(null);
interface AuthStatus {
is_logged_in: boolean;
email: string | null;
org_id: string | null;
org_name: string | null;
api_key_source: string | null;
api_provider: string | null;
subscription_type: string | null;
}
let authStatus: AuthStatus | null = $state(null);
let authLoading = $state(false);
let authActionLoading = $state(false);
let authError: string | null = $state(null);
let isOpen = $state(false); let isOpen = $state(false);
let isSaving = $state(false); let isSaving = $state(false);
@@ -65,10 +100,17 @@
configStore.config.subscribe((c) => { configStore.config.subscribe((c) => {
config = { ...c }; config = { ...c };
customFontPathInput = c.custom_font_path ?? "";
customFontFamilyInput = c.custom_font_family ?? "";
customUiFontPathInput = c.custom_ui_font_path ?? "";
customUiFontFamilyInput = c.custom_ui_font_family ?? "";
}); });
configStore.isSidebarOpen.subscribe((open) => { configStore.isSidebarOpen.subscribe((open) => {
isOpen = open; isOpen = open;
if (open && authStatus === null) {
void refreshAuthStatus();
}
}); });
configStore.saveError.subscribe((error) => { configStore.saveError.subscribe((error) => {
@@ -83,8 +125,9 @@
{ value: "", label: "Default (from ~/.claude)" }, { value: "", label: "Default (from ~/.claude)" },
// Current generation (Claude 4.6) // Current generation (Claude 4.6)
{ value: "claude-opus-4-6", label: "Claude Opus 4.6 (Most Capable)" }, { value: "claude-opus-4-6", label: "Claude Opus 4.6 (Most Capable)" },
{ value: "claude-sonnet-4-6", label: "Claude Sonnet 4.6 (Recommended)" },
// Previous generation (Claude 4.5) // Previous generation (Claude 4.5)
{ value: "claude-sonnet-4-5-20250929", label: "Claude Sonnet 4.5 (Recommended)" }, { value: "claude-sonnet-4-5-20250929", label: "Claude Sonnet 4.5" },
{ value: "claude-haiku-4-5-20251001", label: "Claude Haiku 4.5 (Fast & Cheap)" }, { value: "claude-haiku-4-5-20251001", label: "Claude Haiku 4.5 (Fast & Cheap)" },
{ value: "claude-opus-4-5-20251101", label: "Claude Opus 4.5" }, { value: "claude-opus-4-5-20251101", label: "Claude Opus 4.5" },
// Previous generation (Claude 4.x) // Previous generation (Claude 4.x)
@@ -110,6 +153,44 @@
"Task", "Task",
]; ];
async function refreshAuthStatus() {
authLoading = true;
authError = null;
try {
authStatus = await invoke<AuthStatus>("get_auth_status");
} catch (e) {
authError = String(e);
} finally {
authLoading = false;
}
}
async function handleAuthLogin() {
authActionLoading = true;
authError = null;
try {
await invoke<string>("auth_login");
await refreshAuthStatus();
} catch (e) {
authError = String(e);
} finally {
authActionLoading = false;
}
}
async function handleAuthLogout() {
authActionLoading = true;
authError = null;
try {
await invoke<string>("auth_logout");
await refreshAuthStatus();
} catch (e) {
authError = String(e);
} finally {
authActionLoading = false;
}
}
async function handleSave() { async function handleSave() {
isSaving = true; isSaving = true;
saveError = null; saveError = null;
@@ -181,6 +262,20 @@
await window.setAlwaysOnTop(enabled); await window.setAlwaysOnTop(enabled);
await configStore.updateConfig({ always_on_top: enabled }); await configStore.updateConfig({ always_on_top: enabled });
} }
async function pickBackgroundImage() {
const selected = await open({
multiple: false,
filters: [{ name: "Images", extensions: ["png", "jpg", "jpeg", "webp", "gif", "avif"] }],
});
if (selected) {
config.background_image_path = selected;
}
}
function clearBackgroundImage() {
config.background_image_path = null;
}
</script> </script>
<!-- Backdrop --> <!-- Backdrop -->
@@ -227,6 +322,109 @@
</div> </div>
{/if} {/if}
<!-- Account Section -->
<section class="mb-6">
<h3 class="text-sm font-medium text-[var(--accent-primary)] uppercase tracking-wider mb-3">
Account
</h3>
{#if authLoading}
<div class="text-sm text-[var(--text-secondary)] py-2">Checking auth status...</div>
{:else if authStatus}
<div class="flex items-center gap-2 mb-3">
<span
class="inline-block w-2.5 h-2.5 rounded-full flex-shrink-0 {authStatus.is_logged_in
? 'bg-green-500'
: 'bg-red-500'}"
></span>
<span class="text-sm font-medium text-[var(--text-primary)]">
{authStatus.is_logged_in ? "Logged in" : "Not logged in"}
</span>
</div>
{#if authStatus.email || authStatus.org_name || authStatus.api_key_source || config.api_key}
<dl class="text-xs space-y-1 mb-3">
{#if authStatus.email}
<div class="flex gap-2">
<dt class="text-[var(--text-tertiary)] w-20 flex-shrink-0">Email</dt>
<dd class="text-[var(--text-primary)] break-all">{authStatus.email}</dd>
</div>
{/if}
{#if authStatus.org_name}
<div class="flex gap-2">
<dt class="text-[var(--text-tertiary)] w-20 flex-shrink-0">Org</dt>
<dd class="text-[var(--text-primary)]">{authStatus.org_name}</dd>
</div>
{/if}
{#if authStatus.org_id}
<div class="flex gap-2">
<dt class="text-[var(--text-tertiary)] w-20 flex-shrink-0">Org UUID</dt>
<dd class="text-[var(--text-secondary)] font-mono text-[10px] break-all">
{authStatus.org_id}
</dd>
</div>
{/if}
{#if authStatus.api_key_source}
<div class="flex gap-2">
<dt class="text-[var(--text-tertiary)] w-20 flex-shrink-0">API key</dt>
<dd class="text-[var(--text-primary)]">{authStatus.api_key_source}</dd>
</div>
{/if}
{#if authStatus.subscription_type}
<div class="flex gap-2">
<dt class="text-[var(--text-tertiary)] w-20 flex-shrink-0">Plan</dt>
<dd class="text-[var(--text-primary)]">{authStatus.subscription_type}</dd>
</div>
{/if}
<div class="flex gap-2">
<dt class="text-[var(--text-tertiary)] w-20 flex-shrink-0">Override</dt>
<dd class="text-[var(--text-primary)]">
{#if config.api_key}
{config.streamer_mode ? "Custom key set 🔒" : "Custom key set"}
{:else}
None
{/if}
</dd>
</div>
</dl>
{/if}
{:else}
<div class="text-sm text-[var(--text-secondary)] py-2">Auth status unavailable</div>
{/if}
{#if authError}
<div class="mb-3 p-2 bg-red-500/20 border border-red-500/50 rounded text-red-400 text-xs">
{authError}
</div>
{/if}
<div class="flex gap-2">
<button
onclick={refreshAuthStatus}
disabled={authLoading || authActionLoading}
class="px-3 py-1.5 text-sm bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg text-[var(--text-secondary)] hover:border-[var(--accent-primary)] hover:text-[var(--text-primary)] transition-colors disabled:opacity-50"
>
Refresh
</button>
{#if authStatus && !authStatus.is_logged_in}
<button
onclick={handleAuthLogin}
disabled={authActionLoading}
class="btn-trans-gradient px-3 py-1.5 text-sm rounded-lg disabled:opacity-50"
>
{authActionLoading ? "Logging in..." : "Login"}
</button>
{:else if authStatus && authStatus.is_logged_in}
<button
onclick={handleAuthLogout}
disabled={authActionLoading}
class="px-3 py-1.5 text-sm bg-red-500/20 border border-red-500/50 rounded-lg text-red-400 hover:bg-red-500/30 transition-colors disabled:opacity-50"
>
{authActionLoading ? "Logging out..." : "Logout"}
</button>
{/if}
</div>
</section>
<!-- Agent Settings Section --> <!-- Agent Settings Section -->
<section class="mb-6"> <section class="mb-6">
<h3 class="text-sm font-medium text-[var(--accent-primary)] uppercase tracking-wider mb-3"> <h3 class="text-sm font-medium text-[var(--accent-primary)] uppercase tracking-wider mb-3">
@@ -321,6 +519,56 @@
class="w-full px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent-primary)] resize-none" class="w-full px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent-primary)] resize-none"
></textarea> ></textarea>
</div> </div>
<!-- Worktree Isolation -->
<div class="mb-4">
<label class="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
bind:checked={config.use_worktree}
class="w-4 h-4 rounded border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--accent-primary)] focus:ring-[var(--accent-primary)]"
/>
<span class="text-sm text-[var(--text-primary)]">Worktree isolation</span>
</label>
<p class="text-xs text-[var(--text-tertiary)] mt-1 ml-7">
Launch sessions with <code class="font-mono">--worktree</code> for isolated git worktree environments
</p>
</div>
<!-- Disable 1M Context Window -->
<div class="mb-4">
<label class="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
bind:checked={config.disable_1m_context}
class="w-4 h-4 rounded border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--accent-primary)] focus:ring-[var(--accent-primary)]"
/>
<span class="text-sm text-[var(--text-primary)]">Disable 1M context window</span>
</label>
<p class="text-xs text-[var(--text-tertiary)] mt-1 ml-7">
Sets <code class="font-mono">CLAUDE_CODE_DISABLE_1M_CONTEXT=1</code> to opt out of the extended
context window
</p>
</div>
<!-- Max Output Tokens -->
<div class="mb-4">
<label class="block text-sm text-[var(--text-primary)] mb-1" for="max-output-tokens">
Max output tokens
</label>
<input
id="max-output-tokens"
type="number"
min="1"
placeholder="Default (32000)"
bind:value={config.max_output_tokens}
class="w-full px-3 py-2 text-sm bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-[var(--text-primary)] placeholder-[var(--text-tertiary)] focus:outline-none focus:border-[var(--accent-primary)]"
/>
<p class="text-xs text-[var(--text-tertiary)] mt-1">
Sets <code class="font-mono">CLAUDE_CODE_MAX_OUTPUT_TOKENS</code> — increase if responses are
being cut off mid-reply
</p>
</div>
</section> </section>
<!-- Greeting Section --> <!-- Greeting Section -->
@@ -705,6 +953,130 @@
</p> </p>
</div> </div>
<!-- Custom Terminal Font -->
<div class="mb-4">
<span class="block text-sm text-[var(--text-secondary)] mb-2">Custom Terminal Font</span>
<div class="flex flex-col gap-2">
<input
type="text"
bind:value={customFontPathInput}
placeholder="URL or local file path (e.g. /path/to/font.ttf)"
class="w-full px-3 py-2 text-sm rounded-lg border border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--text-primary)] placeholder-gray-500 focus:outline-none focus:border-[var(--accent-primary)]"
/>
<input
type="text"
bind:value={customFontFamilyInput}
placeholder="Font family name (e.g. FiraCode)"
class="w-full px-3 py-2 text-sm rounded-lg border border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--text-primary)] placeholder-gray-500 focus:outline-none focus:border-[var(--accent-primary)]"
/>
<div class="flex gap-2">
<button
onclick={async () => {
customFontStatus = null;
try {
await configStore.setCustomFont(
customFontPathInput || null,
customFontFamilyInput || null
);
customFontStatus = "Font applied!";
} catch (e) {
customFontStatus = `Error: ${e instanceof Error ? e.message : String(e)}`;
}
}}
class="flex-1 px-3 py-2 text-sm rounded-lg border border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)] transition-colors"
>
Apply
</button>
<button
onclick={async () => {
customFontStatus = null;
customFontPathInput = "";
customFontFamilyInput = "";
try {
await configStore.setCustomFont(null, null);
await applyCustomFont(null, null);
customFontStatus = "Font reset to default.";
} catch (e) {
customFontStatus = `Error: ${e instanceof Error ? e.message : String(e)}`;
}
}}
class="px-3 py-2 text-sm rounded-lg border border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--text-secondary)] hover:border-red-400 hover:text-red-400 transition-colors"
>
Reset
</button>
</div>
{#if customFontStatus}
<p class="text-xs text-[var(--text-tertiary)]">{customFontStatus}</p>
{/if}
</div>
<p class="text-xs text-[var(--text-tertiary)] mt-1">
Supports Google Fonts URLs, direct font file URLs, or local file paths. Family name is
required to apply the font.
</p>
</div>
<!-- Custom UI Font -->
<div class="mb-4">
<span class="block text-sm text-[var(--text-secondary)] mb-2">Custom UI Font</span>
<div class="flex flex-col gap-2">
<input
type="text"
bind:value={customUiFontPathInput}
placeholder="URL or local file path (e.g. /path/to/font.ttf)"
class="w-full px-3 py-2 text-sm rounded-lg border border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--text-primary)] placeholder-gray-500 focus:outline-none focus:border-[var(--accent-primary)]"
/>
<input
type="text"
bind:value={customUiFontFamilyInput}
placeholder="Font family name (e.g. Inter)"
class="w-full px-3 py-2 text-sm rounded-lg border border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--text-primary)] placeholder-gray-500 focus:outline-none focus:border-[var(--accent-primary)]"
/>
<div class="flex gap-2">
<button
onclick={async () => {
customUiFontStatus = null;
try {
await configStore.setCustomUiFont(
customUiFontPathInput || null,
customUiFontFamilyInput || null
);
customUiFontStatus = "Font applied!";
} catch (e) {
customUiFontStatus = `Error: ${e instanceof Error ? e.message : String(e)}`;
}
}}
class="flex-1 px-3 py-2 text-sm rounded-lg border border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)] transition-colors"
>
Apply UI Font
</button>
<button
onclick={async () => {
customUiFontStatus = null;
customUiFontPathInput = "";
customUiFontFamilyInput = "";
try {
await configStore.setCustomUiFont(null, null);
await applyCustomUiFont(null, null);
customUiFontStatus = "Font reset to default.";
} catch (e) {
customUiFontStatus = `Error: ${e instanceof Error ? e.message : String(e)}`;
}
}}
class="px-3 py-2 text-sm rounded-lg border border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--text-secondary)] hover:border-red-400 hover:text-red-400 transition-colors"
>
Reset
</button>
</div>
{#if customUiFontStatus}
<p class="text-xs text-[var(--text-tertiary)]">{customUiFontStatus}</p>
{/if}
</div>
<p class="text-xs text-[var(--text-tertiary)] mt-1">
Applies to the entire app interface (menus, labels, buttons). Supports Google Fonts URLs,
direct font file URLs, or local file paths.
</p>
</div>
<!-- Show Thinking Blocks Toggle --> <!-- Show Thinking Blocks Toggle -->
<div class="mb-4"> <div class="mb-4">
<label class="flex items-center gap-3 cursor-pointer"> <label class="flex items-center gap-3 cursor-pointer">
@@ -720,6 +1092,52 @@
expanded/collapsed to see reasoning details. expanded/collapsed to see reasoning details.
</p> </p>
</div> </div>
<!-- Background Image -->
<div class="mb-4">
<span class="block text-sm text-[var(--text-secondary)] mb-2">Background Image</span>
{#if config.background_image_path}
<p class="text-xs text-[var(--text-tertiary)] font-mono mb-2 truncate">
{config.background_image_path.split("/").pop()}
</p>
{/if}
<div class="flex gap-2">
<button
onclick={pickBackgroundImage}
class="flex-1 px-3 py-2 text-sm rounded-lg border border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)] transition-colors"
>
{config.background_image_path ? "Change Image" : "Choose Image"}
</button>
{#if config.background_image_path}
<button
onclick={clearBackgroundImage}
class="px-3 py-2 text-sm rounded-lg border border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--text-secondary)] hover:border-red-400 hover:text-red-400 transition-colors"
title="Remove background image"
>
Clear
</button>
{/if}
</div>
{#if config.background_image_path}
<div class="mt-3">
<div class="flex items-center justify-between mb-1">
<label for="bg-opacity" class="text-xs text-[var(--text-secondary)]"> Opacity </label>
<span class="text-xs text-[var(--text-tertiary)]">
{Math.round(config.background_image_opacity * 100)}%
</span>
</div>
<input
id="bg-opacity"
type="range"
bind:value={config.background_image_opacity}
min="0.05"
max="1"
step="0.05"
class="w-full h-2 bg-[var(--bg-primary)] rounded-lg appearance-none cursor-pointer"
/>
</div>
{/if}
</div>
</section> </section>
<!-- Window Section --> <!-- Window Section -->
+117 -13
View File
@@ -12,6 +12,25 @@
let editingTabId = $state<string | null>(null); let editingTabId = $state<string | null>(null);
let editingName = $state(""); let editingName = $state("");
// Tab order for pointer-drag reordering
let tabOrder = $state<string[]>([]);
let draggedId = $state<string | null>(null);
let dragOverId = $state<string | null>(null);
let dragStartX = 0;
let isDragging = false;
let wasDragged = false;
let tabsRef = $state<HTMLElement | null>(null);
// Keep tabOrder in sync with conversations map (add new, remove deleted)
$effect(() => {
const currentIds = Array.from($conversations.keys());
const validIds = tabOrder.filter((id) => currentIds.includes(id));
const newIds = currentIds.filter((id) => !tabOrder.includes(id));
if (validIds.length !== tabOrder.length || newIds.length > 0) {
tabOrder = [...validIds, ...newIds];
}
});
// Track last seen message count for each conversation // Track last seen message count for each conversation
let lastSeenMessageCount = new SvelteMap<string, number>(); let lastSeenMessageCount = new SvelteMap<string, number>();
@@ -138,8 +157,73 @@
} }
} }
async function handleTabClick(id: string) {
if (wasDragged) {
wasDragged = false;
return;
}
await switchTab(id);
}
function handlePointerDown(event: PointerEvent, id: string) {
if (editingTabId === id) return;
draggedId = id;
dragStartX = event.clientX;
isDragging = false;
wasDragged = false;
function onMove(e: PointerEvent) {
if (!isDragging && Math.abs(e.clientX - dragStartX) > 5) {
isDragging = true;
}
if (!isDragging || !tabsRef) return;
const tabs = tabsRef.querySelectorAll<HTMLElement>("[data-tab-id]");
dragOverId = null;
for (const tab of tabs) {
const rect = tab.getBoundingClientRect();
if (e.clientX >= rect.left && e.clientX <= rect.right) {
const tabId = tab.dataset.tabId;
if (tabId && tabId !== id) {
dragOverId = tabId;
}
break;
}
}
}
function onUp() {
if (isDragging && draggedId && dragOverId && draggedId !== dragOverId) {
const order = [...tabOrder];
const fromIndex = order.indexOf(draggedId);
const toIndex = order.indexOf(dragOverId);
order.splice(fromIndex, 1);
order.splice(toIndex, 0, draggedId);
tabOrder = order;
wasDragged = true;
}
draggedId = null;
dragOverId = null;
isDragging = false;
window.removeEventListener("pointermove", onMove);
window.removeEventListener("pointerup", onUp);
window.removeEventListener("pointercancel", onUp);
}
window.addEventListener("pointermove", onMove);
window.addEventListener("pointerup", onUp);
window.addEventListener("pointercancel", onUp);
}
// Keyboard shortcuts // Keyboard shortcuts
onMount(() => { onMount(() => {
// Initialise all conversations as seen on mount so that remounting
// this component (e.g. after closing the file editor) doesn't falsely
// mark existing messages as unread.
for (const [id, conversation] of $conversations) {
lastSeenMessageCount.set(id, conversation.terminalLines.length);
}
lastSeenMessageCount = lastSeenMessageCount;
function handleGlobalKeydown(event: KeyboardEvent) { function handleGlobalKeydown(event: KeyboardEvent) {
// Ctrl/Cmd + T: New tab // Ctrl/Cmd + T: New tab
if ((event.ctrlKey || event.metaKey) && event.key === "t") { if ((event.ctrlKey || event.metaKey) && event.key === "t") {
@@ -165,21 +249,19 @@
// Ctrl/Cmd + Tab: Next tab // Ctrl/Cmd + Tab: Next tab
else if ((event.ctrlKey || event.metaKey) && event.key === "Tab" && !event.shiftKey) { else if ((event.ctrlKey || event.metaKey) && event.key === "Tab" && !event.shiftKey) {
event.preventDefault(); event.preventDefault();
const tabs = Array.from($conversations.keys()); const currentIndex = tabOrder.findIndex((id) => id === $activeConversationId);
const currentIndex = tabs.findIndex((id) => id === $activeConversationId);
if (currentIndex !== -1) { if (currentIndex !== -1) {
const nextIndex = (currentIndex + 1) % tabs.length; const nextIndex = (currentIndex + 1) % tabOrder.length;
claudeStore.switchConversation(tabs[nextIndex]); claudeStore.switchConversation(tabOrder[nextIndex]);
} }
} }
// Ctrl/Cmd + Shift + Tab: Previous tab // Ctrl/Cmd + Shift + Tab: Previous tab
else if ((event.ctrlKey || event.metaKey) && event.key === "Tab" && event.shiftKey) { else if ((event.ctrlKey || event.metaKey) && event.key === "Tab" && event.shiftKey) {
event.preventDefault(); event.preventDefault();
const tabs = Array.from($conversations.keys()); const currentIndex = tabOrder.findIndex((id) => id === $activeConversationId);
const currentIndex = tabs.findIndex((id) => id === $activeConversationId);
if (currentIndex !== -1) { if (currentIndex !== -1) {
const prevIndex = (currentIndex - 1 + tabs.length) % tabs.length; const prevIndex = (currentIndex - 1 + tabOrder.length) % tabOrder.length;
claudeStore.switchConversation(tabs[prevIndex]); claudeStore.switchConversation(tabOrder[prevIndex]);
} }
} }
} }
@@ -190,15 +272,22 @@
</script> </script>
<div <div
bind:this={tabsRef}
class="terminal-tabs flex items-center gap-1 px-2 py-1 bg-[var(--bg-secondary)] border-b border-[var(--border-color)]" class="terminal-tabs flex items-center gap-1 px-2 py-1 bg-[var(--bg-secondary)] border-b border-[var(--border-color)]"
> >
{#each Array.from($conversations.entries()) as [id, conversation] (id)} {#each tabOrder
.filter((id) => $conversations.has(id))
.map((id) => ({ id, conversation: $conversations.get(id)! })) as { id, conversation } (id)}
<div <div
class="tab-item group relative flex items-center px-3 py-1.5 rounded-t cursor-pointer transition-all data-tab-id={id}
class="tab-item group relative flex items-center px-3 py-1.5 rounded-t transition-all
{id === $activeConversationId {id === $activeConversationId
? 'bg-[var(--bg-terminal)] text-[var(--text-primary)] border-t border-l border-r border-[var(--border-color)]' ? 'bg-[var(--bg-terminal)] text-[var(--text-primary)] border-t border-l border-r border-[var(--border-color)]'
: 'bg-[var(--bg-tertiary)] text-[var(--text-secondary)] hover:bg-[var(--bg-terminal)]/50'}" : 'bg-[var(--bg-tertiary)] text-[var(--text-secondary)] hover:bg-[var(--bg-terminal)]/50'}
onclick={() => switchTab(id)} {dragOverId === id && draggedId !== id ? 'drag-over' : ''}
{draggedId === id ? 'dragging' : ''}"
onpointerdown={(e) => handlePointerDown(e, id)}
onclick={() => handleTabClick(id)}
onkeydown={(e) => handleTabKeydown(id, e)} onkeydown={(e) => handleTabKeydown(id, e)}
role="tab" role="tab"
tabindex={0} tabindex={0}
@@ -211,7 +300,7 @@
onblur={saveTabName} onblur={saveTabName}
onkeydown={handleKeydown} onkeydown={handleKeydown}
onclick={(e) => e.stopPropagation()} onclick={(e) => e.stopPropagation()}
class="bg-transparent border-b border-[var(--border-color)] outline-none px-0 py-0 text-sm w-32" class="bg-transparent border-b border-[var(--border-color)] outline-none px-0 py-0 text-sm w-32 select-text"
/> />
{:else} {:else}
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@@ -296,5 +385,20 @@
.tab-item { .tab-item {
min-width: 100px; min-width: 100px;
cursor: grab;
touch-action: none;
user-select: none;
}
.tab-item:active {
cursor: grabbing;
}
.drag-over {
border-left: 2px solid var(--accent-primary);
}
.dragging {
opacity: 0.4;
} }
</style> </style>
+111
View File
@@ -0,0 +1,111 @@
/**
* ConversationTabs Component Tests
*
* Tests the connection status colour mapping and unread message detection
* logic used by the ConversationTabs component.
*
* What this component does:
* - Displays one tab per conversation
* - Each tab shows a coloured dot for its connection state
* - Inactive tabs with new messages show an animated blue dot badge
* - Tabs can be renamed by double-clicking
* - Tabs can be reordered by drag-and-drop
* - New tabs created with Ctrl+T, closed with Ctrl+W
*
* Manual testing checklist:
* - [ ] Connected tabs show a green dot
* - [ ] Connecting tabs show a yellow dot
* - [ ] Disconnected tabs show a red dot
* - [ ] Active tab never shows the unread badge
* - [ ] Inactive tab shows blue pulsing dot when it receives new messages
* - [ ] Switching to a tab clears the unread indicator
* - [ ] Double-clicking a tab name enables inline editing
* - [ ] Tabs can be dragged to reorder
*/
import { describe, it, expect } from "vitest";
type ConnectionStatus = "connected" | "connecting" | "disconnected";
function getConnectionStatusColor(status: ConnectionStatus | string): string {
switch (status) {
case "connected":
return "bg-green-500";
case "connecting":
return "bg-yellow-500";
case "disconnected":
return "bg-red-500";
default:
return "bg-gray-500";
}
}
function hasUnreadMessages(
id: string,
conversationLineCount: number,
activeConversationId: string | null,
lastSeenMessageCount: Map<string, number>
): boolean {
if (id === activeConversationId) return false;
const lastSeen = lastSeenMessageCount.get(id) ?? 0;
return conversationLineCount > lastSeen;
}
// ---
describe("getConnectionStatusColor", () => {
it("returns green for connected status", () => {
expect(getConnectionStatusColor("connected")).toBe("bg-green-500");
});
it("returns yellow for connecting status", () => {
expect(getConnectionStatusColor("connecting")).toBe("bg-yellow-500");
});
it("returns red for disconnected status", () => {
expect(getConnectionStatusColor("disconnected")).toBe("bg-red-500");
});
it("returns grey for unknown status (fallback)", () => {
expect(getConnectionStatusColor("error")).toBe("bg-gray-500");
expect(getConnectionStatusColor("")).toBe("bg-gray-500");
});
});
describe("hasUnreadMessages", () => {
it("returns false for the active conversation regardless of message count", () => {
const lastSeen = new Map([["tab-1", 0]]);
expect(hasUnreadMessages("tab-1", 10, "tab-1", lastSeen)).toBe(false);
});
it("returns true when an inactive tab has more messages than last seen", () => {
const lastSeen = new Map([["tab-1", 5]]);
expect(hasUnreadMessages("tab-1", 10, "tab-2", lastSeen)).toBe(true);
});
it("returns false when an inactive tab has no new messages", () => {
const lastSeen = new Map([["tab-1", 10]]);
expect(hasUnreadMessages("tab-1", 10, "tab-2", lastSeen)).toBe(false);
});
it("returns false when an inactive tab has fewer messages than last seen", () => {
const lastSeen = new Map([["tab-1", 15]]);
expect(hasUnreadMessages("tab-1", 10, "tab-2", lastSeen)).toBe(false);
});
it("treats a tab with no last-seen record as having 0 messages seen", () => {
const lastSeen = new Map<string, number>();
// Tab has 1 message but no entry in lastSeen → treated as 0 seen → unread
expect(hasUnreadMessages("tab-1", 1, "tab-2", lastSeen)).toBe(true);
});
it("returns false for an untracked tab with 0 messages", () => {
const lastSeen = new Map<string, number>();
expect(hasUnreadMessages("tab-1", 0, "tab-2", lastSeen)).toBe(false);
});
it("handles null activeConversationId (no active tab)", () => {
const lastSeen = new Map([["tab-1", 3]]);
expect(hasUnreadMessages("tab-1", 10, null, lastSeen)).toBe(true);
});
});
+232
View File
@@ -0,0 +1,232 @@
<script lang="ts">
import { onMount } from "svelte";
import { draftsStore, type Draft } from "$lib/stores/drafts";
interface Props {
onClose: () => void;
onInsert: (content: string) => void;
}
const { onClose, onInsert }: Props = $props();
let confirmingDeleteId = $state<string | null>(null);
let confirmingAll = $state(false);
const drafts = $derived(draftsStore.drafts);
const isLoading = $derived(draftsStore.isLoading);
onMount(() => {
draftsStore.loadDrafts();
});
function handleInsert(draft: Draft): void {
onInsert(draft.content);
onClose();
}
async function handleDelete(draftId: string): Promise<void> {
await draftsStore.deleteDraft(draftId);
confirmingDeleteId = null;
}
async function handleDeleteAll(): Promise<void> {
await draftsStore.deleteAllDrafts();
confirmingAll = false;
}
function truncateContent(content: string): string {
return content.length > 120 ? content.slice(0, 120) + "…" : content;
}
</script>
<div
class="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4"
onclick={onClose}
role="button"
tabindex="0"
onkeydown={(e) => e.key === "Escape" && onClose()}
>
<div
class="bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg shadow-xl max-w-2xl w-full max-h-[80vh] overflow-hidden flex flex-col"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
role="dialog"
aria-labelledby="draft-panel-title"
tabindex="-1"
>
<div class="flex items-center justify-between p-6 pb-4 border-b border-[var(--border-color)]">
<h2 id="draft-panel-title" class="text-xl font-semibold text-[var(--text-primary)]">
Saved Drafts
</h2>
<div class="flex items-center gap-2">
{#if $drafts.length > 0}
{#if confirmingAll}
<div class="flex items-center gap-1">
<button
onclick={handleDeleteAll}
class="px-3 py-1.5 text-sm font-medium bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors"
>
Confirm Delete All
</button>
<button
onclick={() => (confirmingAll = false)}
class="px-3 py-1.5 text-sm font-medium bg-[var(--bg-secondary)] text-[var(--text-secondary)] rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors"
>
Cancel
</button>
</div>
{:else}
<button
onclick={() => (confirmingAll = true)}
class="px-3 py-1.5 text-sm font-medium text-red-400 hover:text-red-300 transition-colors border border-red-400/30 rounded-lg hover:border-red-300/50 hover:bg-red-400/10"
>
Delete All
</button>
{/if}
{/if}
<button
onclick={onClose}
class="p-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
aria-label="Close"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
</div>
<div class="flex-1 overflow-y-auto">
{#if $isLoading}
<div class="flex items-center justify-center p-8">
<div class="text-[var(--text-tertiary)]">Loading drafts...</div>
</div>
{:else if $drafts.length === 0}
<div class="flex flex-col items-center justify-center p-8 text-center">
<svg
class="w-16 h-16 text-[var(--text-tertiary)] mb-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
<p class="text-[var(--text-secondary)]">No saved drafts yet</p>
<p class="text-sm text-[var(--text-tertiary)] mt-1">
Use "Save as Draft" to store messages for later
</p>
</div>
{:else}
<div class="divide-y divide-[var(--border-color)]">
{#each $drafts as draft (draft.id)}
<div class="p-4 hover:bg-[var(--bg-secondary)] transition-colors group">
<div class="flex items-start justify-between gap-4">
<div class="flex-1 min-w-0">
<p class="text-xs text-[var(--text-tertiary)] mb-1">
{draftsStore.formatTimestamp(draft.saved_at)}
</p>
<p
class="text-sm text-[var(--text-secondary)] font-mono whitespace-pre-wrap break-words"
>
{truncateContent(draft.content)}
</p>
</div>
<div
class="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0"
>
<button
onclick={() => handleInsert(draft)}
class="btn-trans-gradient px-3 py-1.5 text-xs font-medium rounded"
title="Insert this draft"
>
Insert
</button>
{#if confirmingDeleteId === draft.id}
<div class="flex items-center gap-1">
<button
onclick={() => handleDelete(draft.id)}
class="px-2 py-1 text-xs font-medium bg-red-500 text-white rounded hover:bg-red-600 transition-colors"
>
Confirm
</button>
<button
onclick={() => (confirmingDeleteId = null)}
class="px-2 py-1 text-xs font-medium bg-[var(--bg-tertiary)] text-[var(--text-secondary)] rounded hover:bg-[var(--bg-secondary)] transition-colors"
>
Cancel
</button>
</div>
{:else}
<button
onclick={() => (confirmingDeleteId = draft.id)}
class="p-1.5 text-[var(--text-tertiary)] hover:text-red-400 transition-colors"
title="Delete draft"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
{/if}
</div>
</div>
</div>
{/each}
</div>
{/if}
</div>
</div>
</div>
<style>
[role="dialog"] {
animation: slideIn 0.2s ease-out;
}
@keyframes slideIn {
from {
opacity: 0;
transform: scale(0.95) translateY(10px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
.overflow-y-auto {
scrollbar-width: thin;
scrollbar-color: var(--border-color) transparent;
}
.overflow-y-auto::-webkit-scrollbar {
width: 8px;
}
.overflow-y-auto::-webkit-scrollbar-track {
background: transparent;
}
.overflow-y-auto::-webkit-scrollbar-thumb {
background-color: var(--border-color);
border-radius: 4px;
}
.overflow-y-auto::-webkit-scrollbar-thumb:hover {
background-color: var(--accent-primary);
}
</style>
+195
View File
@@ -0,0 +1,195 @@
/**
* HighlightedText Component Tests
*
* Tests the text-splitting logic used by the HighlightedText component,
* which highlights search query matches within a string.
*
* What this component does:
* - Splits text into an array of {text, isMatch} parts
* - Matches are case-insensitive
* - Special regex characters in the query are escaped
* - Non-matching text is preserved verbatim around matches
*
* Manual testing checklist:
* - [ ] Matching text is highlighted (yellow background) in the terminal
* - [ ] Highlighting is case-insensitive
* - [ ] Multiple matches on the same line all get highlighted
* - [ ] Non-matching text renders normally alongside matches
*/
import { describe, it, expect } from "vitest";
interface TextPart {
text: string;
isMatch: boolean;
}
function getHighlightedParts(text: string, query: string): TextPart[] {
if (!query) {
return [{ text, isMatch: false }];
}
const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const regex = new RegExp(`(${escapedQuery})`, "gi");
const parts: TextPart[] = [];
let lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = regex.exec(text)) !== null) {
if (match.index > lastIndex) {
parts.push({
text: text.slice(lastIndex, match.index),
isMatch: false,
});
}
parts.push({
text: match[1],
isMatch: true,
});
lastIndex = regex.lastIndex;
}
if (lastIndex < text.length) {
parts.push({
text: text.slice(lastIndex),
isMatch: false,
});
}
return parts;
}
// ---
describe("getHighlightedParts", () => {
describe("empty query", () => {
it("returns the whole text as a single non-match when query is empty string", () => {
const result = getHighlightedParts("hello world", "");
expect(result).toEqual([{ text: "hello world", isMatch: false }]);
});
it("returns an empty non-match part when both text and query are empty", () => {
const result = getHighlightedParts("", "");
expect(result).toEqual([{ text: "", isMatch: false }]);
});
});
describe("no match", () => {
it("returns the whole text as a single non-match when query is not found", () => {
const result = getHighlightedParts("hello world", "xyz");
expect(result).toEqual([{ text: "hello world", isMatch: false }]);
});
});
describe("single match", () => {
it("returns three parts for a match in the middle", () => {
const result = getHighlightedParts("hello world foo", "world");
expect(result).toEqual([
{ text: "hello ", isMatch: false },
{ text: "world", isMatch: true },
{ text: " foo", isMatch: false },
]);
});
it("returns two parts for a match at the start", () => {
const result = getHighlightedParts("hello world", "hello");
expect(result).toEqual([
{ text: "hello", isMatch: true },
{ text: " world", isMatch: false },
]);
});
it("returns two parts for a match at the end", () => {
const result = getHighlightedParts("hello world", "world");
expect(result).toEqual([
{ text: "hello ", isMatch: false },
{ text: "world", isMatch: true },
]);
});
it("returns a single match part when the entire text matches", () => {
const result = getHighlightedParts("hello", "hello");
expect(result).toEqual([{ text: "hello", isMatch: true }]);
});
});
describe("multiple matches", () => {
it("returns interleaved match and non-match parts for multiple occurrences", () => {
const result = getHighlightedParts("foo bar foo", "foo");
expect(result).toEqual([
{ text: "foo", isMatch: true },
{ text: " bar ", isMatch: false },
{ text: "foo", isMatch: true },
]);
});
it("handles adjacent matches correctly", () => {
const result = getHighlightedParts("aaa", "a");
expect(result).toEqual([
{ text: "a", isMatch: true },
{ text: "a", isMatch: true },
{ text: "a", isMatch: true },
]);
});
});
describe("case-insensitive matching", () => {
it("matches uppercase query against lowercase text", () => {
const result = getHighlightedParts("hello world", "WORLD");
expect(result).toEqual([
{ text: "hello ", isMatch: false },
{ text: "world", isMatch: true },
]);
});
it("matches lowercase query against uppercase text", () => {
const result = getHighlightedParts("HELLO WORLD", "hello");
expect(result).toEqual([
{ text: "HELLO", isMatch: true },
{ text: " WORLD", isMatch: false },
]);
});
it("preserves the original casing of the matched text", () => {
const result = getHighlightedParts("Hello World", "hello");
const matchPart = result.find((p) => p.isMatch);
expect(matchPart?.text).toBe("Hello");
});
});
describe("special regex character escaping", () => {
it("treats a dot in the query as a literal dot, not a wildcard", () => {
const result = getHighlightedParts("v1.2.3 v123", "1.2");
const matchParts = result.filter((p) => p.isMatch);
expect(matchParts).toHaveLength(1);
expect(matchParts[0].text).toBe("1.2");
});
it("handles a query with parentheses", () => {
const result = getHighlightedParts("fn(args)", "(args)");
expect(result).toEqual([
{ text: "fn", isMatch: false },
{ text: "(args)", isMatch: true },
]);
});
it("handles a query with a plus sign", () => {
const result = getHighlightedParts("a+b=c", "+");
expect(result).toEqual([
{ text: "a", isMatch: false },
{ text: "+", isMatch: true },
{ text: "b=c", isMatch: false },
]);
});
it("handles a query with a question mark", () => {
const result = getHighlightedParts("is it true?", "?");
expect(result).toEqual([
{ text: "is it true", isMatch: false },
{ text: "?", isMatch: true },
]);
});
});
});
+101 -5
View File
@@ -34,7 +34,9 @@
import SnippetLibraryPanel from "$lib/components/SnippetLibraryPanel.svelte"; import SnippetLibraryPanel from "$lib/components/SnippetLibraryPanel.svelte";
import QuickActionsPanel from "$lib/components/QuickActionsPanel.svelte"; import QuickActionsPanel from "$lib/components/QuickActionsPanel.svelte";
import ClipboardHistoryPanel from "$lib/components/ClipboardHistoryPanel.svelte"; import ClipboardHistoryPanel from "$lib/components/ClipboardHistoryPanel.svelte";
import DraftPanel from "$lib/components/DraftPanel.svelte";
import TextInputContextMenu from "$lib/components/TextInputContextMenu.svelte"; import TextInputContextMenu from "$lib/components/TextInputContextMenu.svelte";
import { draftsStore } from "$lib/stores/drafts";
import type { Attachment } from "$lib/types/messages"; import type { Attachment } from "$lib/types/messages";
const INPUT_HISTORY_KEY = "hikari-input-history"; const INPUT_HISTORY_KEY = "hikari-input-history";
@@ -52,6 +54,7 @@
let showSnippetLibrary = $state(false); let showSnippetLibrary = $state(false);
let showQuickActions = $state(false); let showQuickActions = $state(false);
let showClipboardHistory = $state(false); let showClipboardHistory = $state(false);
let showDraftPanel = $state(false);
let streamerModeActive = $state(false); let streamerModeActive = $state(false);
// Cost estimation for pre-submission display // Cost estimation for pre-submission display
@@ -164,6 +167,25 @@
attachments = storedAttachments; attachments = storedAttachments;
}); });
// Per-tab draft persistence — restore the draft text whenever the active
// conversation changes, and save it back on every keystroke.
claudeStore.activeConversationId.subscribe((conversationId) => {
if (conversationId) {
const conv = get(claudeStore.conversations).get(conversationId);
inputValue = conv?.draftText ?? "";
} else {
inputValue = "";
}
});
function clearInput() {
inputValue = "";
const activeId = get(claudeStore.activeConversationId);
if (activeId) {
claudeStore.setDraftText(activeId, "");
}
}
function handleInputChange() { function handleInputChange() {
// If input is empty, allow history navigation again // If input is empty, allow history navigation again
// Otherwise, mark that user has manually typed // Otherwise, mark that user has manually typed
@@ -176,6 +198,12 @@
historyIndex = -1; historyIndex = -1;
tempInput = ""; tempInput = "";
// Save the current draft so it persists if the user switches tabs.
const activeId = get(claudeStore.activeConversationId);
if (activeId) {
claudeStore.setDraftText(activeId, inputValue);
}
if (isSlashCommand(inputValue)) { if (isSlashCommand(inputValue)) {
matchingCommands = getMatchingCommands(inputValue); matchingCommands = getMatchingCommands(inputValue);
showCommandMenu = matchingCommands.length > 0; showCommandMenu = matchingCommands.length > 0;
@@ -195,7 +223,7 @@
async function executeSlashCommand(): Promise<boolean> { async function executeSlashCommand(): Promise<boolean> {
const { command, args } = parseSlashCommand(inputValue); const { command, args } = parseSlashCommand(inputValue);
if (command) { if (command) {
inputValue = ""; clearInput();
showCommandMenu = false; showCommandMenu = false;
matchingCommands = []; matchingCommands = [];
await command.execute(args); await command.execute(args);
@@ -228,7 +256,7 @@
"error", "error",
`Unknown command: ${message.split(" ")[0]}. Type /help for available commands.` `Unknown command: ${message.split(" ")[0]}. Type /help for available commands.`
); );
inputValue = ""; clearInput();
return; return;
} }
@@ -244,7 +272,7 @@
userHasTyped = false; userHasTyped = false;
isSubmitting = true; isSubmitting = true;
inputValue = ""; clearInput();
// Capture attachments before clearing // Capture attachments before clearing
const currentAttachments = [...attachments]; const currentAttachments = [...attachments];
@@ -326,7 +354,7 @@ User: ${formattedMessage}`;
throw new Error("No active conversation"); throw new Error("No active conversation");
} }
await invoke("interrupt_claude", { conversationId }); await invoke("interrupt_claude", { conversationId });
claudeStore.addLine("system", "Process interrupted - reconnecting..."); claudeStore.addLine("system", "Process interrupted via stop button — reconnecting...");
characterState.setState("idle"); characterState.setState("idle");
// Show connecting status while we reconnect // Show connecting status while we reconnect
@@ -362,6 +390,8 @@ User: ${formattedMessage}`;
custom_instructions: config.custom_instructions || null, custom_instructions: config.custom_instructions || null,
mcp_servers_json: config.mcp_servers_json || null, mcp_servers_json: config.mcp_servers_json || null,
allowed_tools: allAllowedTools, allowed_tools: allAllowedTools,
use_worktree: config.use_worktree ?? false,
disable_1m_context: config.disable_1m_context ?? false,
}, },
}); });
@@ -701,6 +731,22 @@ User: ${formattedMessage}`;
userHasTyped = true; userHasTyped = true;
} }
function handleDraftInsert(content: string): void {
inputValue = content;
userHasTyped = true;
const activeId = get(claudeStore.activeConversationId);
if (activeId) {
claudeStore.setDraftText(activeId, content);
}
}
async function handleSaveAsDraft(): Promise<void> {
const content = inputValue.trim();
if (!content) return;
await draftsStore.saveDraft(content);
clearInput();
}
function handleClipboardInsert(content: string): void { function handleClipboardInsert(content: string): void {
// Insert clipboard content at cursor position or append to input // Insert clipboard content at cursor position or append to input
if (inputValue.trim()) { if (inputValue.trim()) {
@@ -917,6 +963,29 @@ User: ${formattedMessage}`;
<span>Clipboard</span> <span>Clipboard</span>
</button> </button>
<button
type="button"
onclick={() => (showDraftPanel = true)}
class="control-button"
title="Saved Drafts"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
<span>Drafts</span>
</button>
<CliVersion /> <CliVersion />
<SystemClock /> <SystemClock />
</div> </div>
@@ -942,7 +1011,7 @@ User: ${formattedMessage}`;
: "Connect to Claude first..."} : "Connect to Claude first..."}
disabled={isSubmitting} disabled={isSubmitting}
rows={1} rows={1}
style="height: {textareaHeight}px; font-size: var(--terminal-font-size, 14px);" style="height: {textareaHeight}px; font-size: var(--terminal-font-size, 14px); font-family: var(--terminal-font-family, monospace);"
class="w-full px-4 py-3 bg-[var(--bg-secondary)] border border-[var(--border-color)] class="w-full px-4 py-3 bg-[var(--bg-secondary)] border border-[var(--border-color)]
rounded-lg text-[var(--text-primary)] placeholder-gray-500 resize-none rounded-lg text-[var(--text-primary)] placeholder-gray-500 resize-none
input-trans-focus disabled:opacity-50 disabled:cursor-not-allowed" input-trans-focus disabled:opacity-50 disabled:cursor-not-allowed"
@@ -957,6 +1026,29 @@ User: ${formattedMessage}`;
</div> </div>
{/if} {/if}
<button
type="button"
onclick={handleSaveAsDraft}
disabled={!inputValue.trim()}
class="attach-button"
title="Save as Draft"
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z" />
<polyline points="17 21 17 13 7 13 7 21" />
<polyline points="7 3 7 8 15 8" />
</svg>
</button>
<button type="button" onclick={handleFilePicker} class="attach-button" title="Attach files"> <button type="button" onclick={handleFilePicker} class="attach-button" title="Attach files">
<svg <svg
width="20" width="20"
@@ -1022,6 +1114,10 @@ User: ${formattedMessage}`;
/> />
{/if} {/if}
{#if showDraftPanel}
<DraftPanel onClose={() => (showDraftPanel = false)} onInsert={handleDraftInsert} />
{/if}
{#if contextMenuShow && textareaElement} {#if contextMenuShow && textareaElement}
<TextInputContextMenu <TextInputContextMenu
x={contextMenuX} x={contextMenuX}
+13 -2
View File
@@ -35,7 +35,12 @@
}; };
renderer.codespan = ({ text }) => { renderer.codespan = ({ text }) => {
return `<code class="hljs-inline">${text}</code>`; const escaped = text.replace(/</g, "&lt;").replace(/>/g, "&gt;");
return `<code class="hljs-inline">${escaped}</code>`;
};
renderer.html = ({ text }) => {
return text.replace(/</g, "&lt;").replace(/>/g, "&gt;");
}; };
marked.setOptions({ marked.setOptions({
@@ -276,10 +281,16 @@
font-family: "JetBrains Mono", "Fira Code", monospace; font-family: "JetBrains Mono", "Fira Code", monospace;
} }
.markdown-content :global(ul), .markdown-content :global(ul) {
margin: 0.5em 0;
padding-left: 1.5em;
list-style-type: disc;
}
.markdown-content :global(ol) { .markdown-content :global(ol) {
margin: 0.5em 0; margin: 0.5em 0;
padding-left: 1.5em; padding-left: 1.5em;
list-style-type: decimal;
} }
.markdown-content :global(li) { .markdown-content :global(li) {
+27 -44
View File
@@ -190,10 +190,13 @@
<h3 class="text-sm font-medium text-[var(--text-primary)] mb-3">Add MCP Server</h3> <h3 class="text-sm font-medium text-[var(--text-primary)] mb-3">Add MCP Server</h3>
<div class="space-y-3"> <div class="space-y-3">
<div> <div>
<label class="text-xs text-[var(--text-secondary)] uppercase tracking-wider block mb-1" <label
for="mcp-new-name"
class="text-xs text-[var(--text-secondary)] uppercase tracking-wider block mb-1"
>Server Name</label >Server Name</label
> >
<input <input
id="mcp-new-name"
type="text" type="text"
bind:value={newServerName} bind:value={newServerName}
placeholder="my-server" placeholder="my-server"
@@ -201,10 +204,13 @@
/> />
</div> </div>
<div> <div>
<label class="text-xs text-[var(--text-secondary)] uppercase tracking-wider block mb-1" <label
for="mcp-new-transport"
class="text-xs text-[var(--text-secondary)] uppercase tracking-wider block mb-1"
>Transport</label >Transport</label
> >
<select <select
id="mcp-new-transport"
bind:value={newServerTransport} bind:value={newServerTransport}
class="w-full px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] text-sm focus:outline-none focus:border-[var(--accent-primary)]" class="w-full px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] text-sm focus:outline-none focus:border-[var(--accent-primary)]"
> >
@@ -214,10 +220,14 @@
</select> </select>
</div> </div>
<div> <div>
<label class="text-xs text-[var(--text-secondary)] uppercase tracking-wider block mb-1"> <label
for="mcp-new-url"
class="text-xs text-[var(--text-secondary)] uppercase tracking-wider block mb-1"
>
{newServerTransport === "stdio" ? "Command" : "URL"} {newServerTransport === "stdio" ? "Command" : "URL"}
</label> </label>
<input <input
id="mcp-new-url"
type="text" type="text"
bind:value={newServerUrl} bind:value={newServerUrl}
placeholder={newServerTransport === "stdio" placeholder={newServerTransport === "stdio"
@@ -266,6 +276,7 @@
{:else} {:else}
<div class="space-y-2"> <div class="space-y-2">
{#each servers as server (server.name)} {#each servers as server (server.name)}
{@const TransportIcon = getTransportIcon(server.transport)}
<button <button
onclick={() => loadServerDetails(server.name)} onclick={() => loadServerDetails(server.name)}
class="w-full bg-[var(--bg-secondary)]/50 rounded-lg p-3 border border-[var(--border-color)] hover:border-[var(--accent-primary)]/50 transition-all text-left" class="w-full bg-[var(--bg-secondary)]/50 rounded-lg p-3 border border-[var(--border-color)] hover:border-[var(--accent-primary)]/50 transition-all text-left"
@@ -274,10 +285,7 @@
<div class="flex items-start justify-between"> <div class="flex items-start justify-between">
<div class="flex-1"> <div class="flex-1">
<h4 class="font-medium text-[var(--text-primary)] flex items-center gap-2"> <h4 class="font-medium text-[var(--text-primary)] flex items-center gap-2">
<svelte:component <TransportIcon class="w-4 h-4 {getTransportColor(server.transport)}" />
this={getTransportIcon(server.transport)}
class="w-4 h-4 {getTransportColor(server.transport)}"
/>
{server.name} {server.name}
{#if server.status} {#if server.status}
{#if server.status.includes("Connected")} {#if server.status.includes("Connected")}
@@ -323,25 +331,19 @@
<RefreshCw class="w-6 h-6 animate-spin text-[var(--text-secondary)]" /> <RefreshCw class="w-6 h-6 animate-spin text-[var(--text-secondary)]" />
</div> </div>
{:else} {:else}
{@const TransportIcon = getTransportIcon(selectedServer.transport)}
<div class="space-y-4"> <div class="space-y-4">
<!-- Name --> <!-- Name -->
<div> <div>
<label class="text-xs text-[var(--text-secondary)] uppercase tracking-wider" <p class="text-xs text-[var(--text-secondary)] uppercase tracking-wider">Name</p>
>Name</label
>
<p class="text-sm text-[var(--text-primary)] mt-1">{selectedServer.name}</p> <p class="text-sm text-[var(--text-primary)] mt-1">{selectedServer.name}</p>
</div> </div>
<!-- Transport --> <!-- Transport -->
<div> <div>
<label class="text-xs text-[var(--text-secondary)] uppercase tracking-wider" <p class="text-xs text-[var(--text-secondary)] uppercase tracking-wider">Transport</p>
>Transport</label
>
<p class="text-sm text-[var(--text-primary)] mt-1 flex items-center gap-2"> <p class="text-sm text-[var(--text-primary)] mt-1 flex items-center gap-2">
<svelte:component <TransportIcon class="w-4 h-4 {getTransportColor(selectedServer.transport)}" />
this={getTransportIcon(selectedServer.transport)}
class="w-4 h-4 {getTransportColor(selectedServer.transport)}"
/>
{selectedServer.transport.toUpperCase()} {selectedServer.transport.toUpperCase()}
</p> </p>
</div> </div>
@@ -349,9 +351,7 @@
<!-- URL or Command --> <!-- URL or Command -->
{#if selectedServer.url} {#if selectedServer.url}
<div> <div>
<label class="text-xs text-[var(--text-secondary)] uppercase tracking-wider" <p class="text-xs text-[var(--text-secondary)] uppercase tracking-wider">URL</p>
>URL</label
>
<p <p
class="text-sm text-[var(--text-primary)] mt-1 break-all font-mono bg-[var(--bg-primary)] p-2 rounded border border-[var(--border-color)]" class="text-sm text-[var(--text-primary)] mt-1 break-all font-mono bg-[var(--bg-primary)] p-2 rounded border border-[var(--border-color)]"
> >
@@ -362,9 +362,7 @@
{#if selectedServer.command} {#if selectedServer.command}
<div> <div>
<label class="text-xs text-[var(--text-secondary)] uppercase tracking-wider" <p class="text-xs text-[var(--text-secondary)] uppercase tracking-wider">Command</p>
>Command</label
>
<p <p
class="text-sm text-[var(--text-primary)] mt-1 font-mono bg-[var(--bg-primary)] p-2 rounded border border-[var(--border-color)]" class="text-sm text-[var(--text-primary)] mt-1 font-mono bg-[var(--bg-primary)] p-2 rounded border border-[var(--border-color)]"
> >
@@ -376,9 +374,9 @@
<!-- Environment Variables --> <!-- Environment Variables -->
{#if selectedServer.env} {#if selectedServer.env}
<div> <div>
<label class="text-xs text-[var(--text-secondary)] uppercase tracking-wider" <p class="text-xs text-[var(--text-secondary)] uppercase tracking-wider">
>Environment</label Environment
> </p>
<pre <pre
class="text-xs text-[var(--text-primary)] mt-1 font-mono bg-[var(--bg-primary)] p-2 rounded border border-[var(--border-color)] overflow-x-auto">{JSON.stringify( class="text-xs text-[var(--text-primary)] mt-1 font-mono bg-[var(--bg-primary)] p-2 rounded border border-[var(--border-color)] overflow-x-auto">{JSON.stringify(
selectedServer.env, selectedServer.env,
@@ -391,9 +389,9 @@
<!-- Full Server Details --> <!-- Full Server Details -->
{#if serverDetails} {#if serverDetails}
<div> <div>
<label class="text-xs text-[var(--text-secondary)] uppercase tracking-wider" <p class="text-xs text-[var(--text-secondary)] uppercase tracking-wider">
>Full Details</label Full Details
> </p>
<pre <pre
class="text-xs text-[var(--text-primary)] mt-1 font-mono bg-[var(--bg-primary)] p-2 rounded border border-[var(--border-color)] overflow-x-auto whitespace-pre-wrap">{serverDetails}</pre> class="text-xs text-[var(--text-primary)] mt-1 font-mono bg-[var(--bg-primary)] p-2 rounded border border-[var(--border-color)] overflow-x-auto whitespace-pre-wrap">{serverDetails}</pre>
</div> </div>
@@ -416,18 +414,3 @@
{/if} {/if}
</div> </div>
</div> </div>
<style>
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.animate-spin {
animation: spin 1s linear infinite;
}
</style>
+34 -12
View File
@@ -1,20 +1,24 @@
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte"; import { onMount } from "svelte";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { readTextFile } from "@tauri-apps/plugin-fs";
import Markdown from "./Markdown.svelte"; import Markdown from "./Markdown.svelte";
let memoryFiles: string[] = $state([]); interface MemoryFileInfo {
path: string;
heading: string | null;
}
interface MemoryFilesResponse {
files: MemoryFileInfo[];
}
let memoryFiles: MemoryFileInfo[] = $state([]);
let selectedFile: string | null = $state(null); let selectedFile: string | null = $state(null);
let fileContent: string = $state(""); let fileContent: string = $state("");
let isLoading = $state(false); let isLoading = $state(false);
let error: string | null = $state(null); let error: string | null = $state(null);
let isPanelOpen = $state(false); let isPanelOpen = $state(false);
interface MemoryFilesResponse {
files: string[];
}
async function loadMemoryFiles() { async function loadMemoryFiles() {
isLoading = true; isLoading = true;
error = null; error = null;
@@ -33,7 +37,8 @@
isLoading = true; isLoading = true;
error = null; error = null;
try { try {
const content = await readTextFile(filePath); // Use our backend command instead of Tauri plugin to handle WSL paths
const content = await invoke<string>("read_file_content", { path: filePath });
fileContent = content; fileContent = content;
selectedFile = filePath; selectedFile = filePath;
} catch (e) { } catch (e) {
@@ -49,6 +54,10 @@
return path.split("/").pop() || path; return path.split("/").pop() || path;
} }
function getDisplayName(file: MemoryFileInfo): string {
return file.heading ?? getFileName(file.path);
}
function togglePanel() { function togglePanel() {
isPanelOpen = !isPanelOpen; isPanelOpen = !isPanelOpen;
if (isPanelOpen && memoryFiles.length === 0) { if (isPanelOpen && memoryFiles.length === 0) {
@@ -151,11 +160,12 @@
{:else} {:else}
<div class="panel-layout"> <div class="panel-layout">
<div class="file-list"> <div class="file-list">
{#each memoryFiles as file (file)} {#each memoryFiles as file (file.path)}
<button <button
class="file-item" class="file-item"
class:active={selectedFile === file} class:active={selectedFile === file.path}
onclick={() => loadFileContent(file)} onclick={() => loadFileContent(file.path)}
title={getFileName(file.path)}
> >
<svg <svg
class="file-icon" class="file-icon"
@@ -171,7 +181,7 @@
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/> />
</svg> </svg>
<span class="file-name">{getFileName(file)}</span> <span class="file-name">{getDisplayName(file)}</span>
</button> </button>
{/each} {/each}
</div> </div>
@@ -179,7 +189,12 @@
<div class="file-viewer"> <div class="file-viewer">
{#if selectedFile && fileContent} {#if selectedFile && fileContent}
<div class="viewer-header"> <div class="viewer-header">
<h4>{getFileName(selectedFile)}</h4> {#each memoryFiles.filter((f) => f.path === selectedFile) as activeFile (activeFile.path)}
<h4>{getDisplayName(activeFile)}</h4>
{#if activeFile.heading}
<p class="viewer-filename">{getFileName(activeFile.path)}</p>
{/if}
{/each}
</div> </div>
<div class="viewer-content"> <div class="viewer-content">
<Markdown content={fileContent} /> <Markdown content={fileContent} />
@@ -438,6 +453,13 @@
color: var(--text-primary); color: var(--text-primary);
} }
.viewer-filename {
margin: 0.25rem 0 0;
font-size: 0.75rem;
color: var(--text-tertiary);
font-family: monospace;
}
.viewer-content { .viewer-content {
flex: 1; flex: 1;
padding: 1.5rem; padding: 1.5rem;
@@ -0,0 +1,98 @@
import { describe, it, expect } from "vitest";
// Mirror pure logic functions from MemoryBrowserPanel.svelte
interface MemoryFileInfo {
path: string;
heading: string | null;
}
function getFileName(path: string): string {
return path.split("/").pop() || path;
}
function getDisplayName(file: MemoryFileInfo): string {
return file.heading ?? getFileName(file.path);
}
describe("getFileName", () => {
it("extracts the filename from an absolute Unix path", () => {
expect(getFileName("/home/naomi/.claude/projects/foo/memory/MEMORY.md")).toBe("MEMORY.md");
});
it("extracts the filename from a nested path", () => {
expect(getFileName("/home/naomi/.claude/projects/foo/memory/debugging.md")).toBe(
"debugging.md"
);
});
it("returns the path itself when there is no slash", () => {
expect(getFileName("MEMORY.md")).toBe("MEMORY.md");
});
it("returns the path when the path ends with a slash (empty filename)", () => {
// split('/').pop() returns '' for trailing slash → falls back to full path
expect(getFileName("/some/dir/")).toBe("/some/dir/");
});
it("handles single-component paths", () => {
expect(getFileName("notes.md")).toBe("notes.md");
});
it("handles Windows-style paths passed as Unix strings", () => {
// If the path contains no forward slashes, the whole string is the filename
expect(getFileName("C:\\Users\\naomi\\memory\\file.md")).toBe(
"C:\\Users\\naomi\\memory\\file.md"
);
});
});
describe("getDisplayName", () => {
it("returns the heading when the file has one", () => {
const file: MemoryFileInfo = {
path: "/home/naomi/.claude/projects/foo/memory/MEMORY.md",
heading: "Hikari Desktop - Memory",
};
expect(getDisplayName(file)).toBe("Hikari Desktop - Memory");
});
it("falls back to the filename when heading is null", () => {
const file: MemoryFileInfo = {
path: "/home/naomi/.claude/projects/foo/memory/debugging.md",
heading: null,
};
expect(getDisplayName(file)).toBe("debugging.md");
});
it("falls back to the filename when heading is an empty string stored as null", () => {
const file: MemoryFileInfo = {
path: "/home/naomi/.claude/projects/foo/memory/patterns.md",
heading: null,
};
expect(getDisplayName(file)).toBe("patterns.md");
});
it("returns the heading even when it matches the filename", () => {
const file: MemoryFileInfo = {
path: "/home/naomi/.claude/projects/foo/memory/MEMORY.md",
heading: "MEMORY",
};
expect(getDisplayName(file)).toBe("MEMORY");
});
it("returns a multi-word heading verbatim", () => {
const file: MemoryFileInfo = {
path: "/some/path/foo.md",
heading: "My Detailed Debugging Notes",
};
expect(getDisplayName(file)).toBe("My Detailed Debugging Notes");
});
it("falls back gracefully when path has no directory separators", () => {
const file: MemoryFileInfo = {
path: "lonely-file.md",
heading: null,
};
expect(getDisplayName(file)).toBe("lonely-file.md");
});
});
+5 -3
View File
@@ -9,10 +9,10 @@
import { conversationsStore } from "$lib/stores/conversations"; import { conversationsStore } from "$lib/stores/conversations";
import { configStore } from "$lib/stores/config"; import { configStore } from "$lib/stores/config";
let permissions: PermissionRequest[] = $state([]); let permissions: PermissionRequest[] = [];
let selectedPermissions = new SvelteSet<string>(); let selectedPermissions = new SvelteSet<string>();
let grantedToolsList: string[] = $state([]); let grantedToolsList: string[] = [];
let workingDirectory = $state(""); let workingDirectory = "";
conversationsStore.pendingPermissions.subscribe((perms) => { conversationsStore.pendingPermissions.subscribe((perms) => {
permissions = perms; permissions = perms;
@@ -87,6 +87,8 @@
custom_instructions: config.custom_instructions || null, custom_instructions: config.custom_instructions || null,
mcp_servers_json: config.mcp_servers_json || null, mcp_servers_json: config.mcp_servers_json || null,
allowed_tools: newGrantedTools, allowed_tools: newGrantedTools,
use_worktree: config.use_worktree ?? false,
disable_1m_context: config.disable_1m_context ?? false,
}, },
}); });
@@ -430,18 +430,3 @@
{/if} {/if}
</div> </div>
</div> </div>
<style>
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.animate-spin {
animation: spin 1s linear infinite;
}
</style>
@@ -471,6 +471,7 @@
tabindex="0" tabindex="0"
onkeydown={(e) => e.key === "Escape" && (showClearAllConfirm = false)} onkeydown={(e) => e.key === "Escape" && (showClearAllConfirm = false)}
> >
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div <div
class="bg-[var(--bg-primary)] border border-red-500/30 rounded-lg shadow-xl max-w-md w-full p-6" class="bg-[var(--bg-primary)] border border-red-500/30 rounded-lg shadow-xl max-w-md w-full p-6"
onclick={(e) => e.stopPropagation()} onclick={(e) => e.stopPropagation()}
+95 -5
View File
@@ -27,6 +27,7 @@
import GitPanel from "./GitPanel.svelte"; import GitPanel from "./GitPanel.svelte";
import ProfilePanel from "./ProfilePanel.svelte"; import ProfilePanel from "./ProfilePanel.svelte";
import AgentMonitorPanel from "./AgentMonitorPanel.svelte"; import AgentMonitorPanel from "./AgentMonitorPanel.svelte";
import CastPanel from "./CastPanel.svelte";
import PluginManagementPanel from "./PluginManagementPanel.svelte"; import PluginManagementPanel from "./PluginManagementPanel.svelte";
import McpManagementPanel from "./McpManagementPanel.svelte"; import McpManagementPanel from "./McpManagementPanel.svelte";
import { conversationsStore } from "$lib/stores/conversations"; import { conversationsStore } from "$lib/stores/conversations";
@@ -37,6 +38,8 @@
} from "$lib/utils/conversationUtils"; } from "$lib/utils/conversationUtils";
import { updateDiscordRpc, setSkipNextGreeting } from "$lib/tauri"; import { updateDiscordRpc, setSkipNextGreeting } from "$lib/tauri";
import { debugConsoleStore } from "$lib/stores/debugConsole"; import { debugConsoleStore } from "$lib/stores/debugConsole";
import WorkspaceTrustModal from "./WorkspaceTrustModal.svelte";
import type { WorkspaceHookInfo } from "$lib/types/messages";
const DISCORD_URL = "https://chat.nhcarrigan.com"; const DISCORD_URL = "https://chat.nhcarrigan.com";
const DONATE_URL = "https://donate.nhcarrigan.com"; const DONATE_URL = "https://donate.nhcarrigan.com";
@@ -56,9 +59,12 @@
let showGitPanel = $state(false); let showGitPanel = $state(false);
let showProfile = $state(false); let showProfile = $state(false);
let showAgentMonitor = $state(false); let showAgentMonitor = $state(false);
let showCastPanel = $state(false);
let showPluginPanel = $state(false); let showPluginPanel = $state(false);
let showMcpPanel = $state(false); let showMcpPanel = $state(false);
let isSummarising = $state(false); let isSummarising = $state(false);
let showWorkspaceTrust = $state(false);
let pendingHookInfo: WorkspaceHookInfo | null = $state(null);
const progress = $derived($achievementProgress); const progress = $derived($achievementProgress);
const activeAgentCount = $derived($runningAgentCount); const activeAgentCount = $derived($runningAgentCount);
let currentConfig: HikariConfig = $state({ let currentConfig: HikariConfig = $state({
@@ -99,6 +105,16 @@
budget_warning_threshold: 0.8, budget_warning_threshold: 0.8,
discord_rpc_enabled: true, discord_rpc_enabled: true,
show_thinking_blocks: true, show_thinking_blocks: true,
use_worktree: false,
disable_1m_context: false,
max_output_tokens: null,
trusted_workspaces: [],
background_image_path: null,
background_image_opacity: 0.3,
custom_font_path: null,
custom_font_family: null,
custom_ui_font_path: null,
custom_ui_font_family: null,
}); });
let streamerModeActive = $state(false); let streamerModeActive = $state(false);
@@ -152,11 +168,7 @@
} }
} }
async function handleConnect() { async function doConnect(targetDir: string) {
if (isConnecting || connectionStatus === "connected") return;
const targetDir = selectedDirectory || "/home/naomi";
// Combine session-granted tools with config auto-granted tools // Combine session-granted tools with config auto-granted tools
const allAllowedTools = [ const allAllowedTools = [
...new Set([...grantedToolsList, ...currentConfig.auto_granted_tools]), ...new Set([...grantedToolsList, ...currentConfig.auto_granted_tools]),
@@ -176,6 +188,9 @@
custom_instructions: currentConfig.custom_instructions || null, custom_instructions: currentConfig.custom_instructions || null,
mcp_servers_json: currentConfig.mcp_servers_json || null, mcp_servers_json: currentConfig.mcp_servers_json || null,
allowed_tools: allAllowedTools, allowed_tools: allAllowedTools,
use_worktree: currentConfig.use_worktree ?? false,
disable_1m_context: currentConfig.disable_1m_context ?? false,
max_output_tokens: currentConfig.max_output_tokens ?? null,
}, },
}); });
@@ -194,6 +209,52 @@
} }
} }
async function handleConnect() {
if (isConnecting || connectionStatus === "connected") return;
const targetDir = selectedDirectory || "/home/naomi";
if (currentConfig.trusted_workspaces?.includes(targetDir)) {
await doConnect(targetDir);
return;
}
try {
const hookInfo = await invoke<WorkspaceHookInfo>("check_workspace_hooks", {
workingDir: targetDir,
});
if (hookInfo.has_concerns) {
pendingHookInfo = hookInfo;
showWorkspaceTrust = true;
return;
}
} catch (error) {
// Fail open: if we can't check hooks, proceed with connection
console.error("Failed to check workspace hooks:", error);
}
await doConnect(targetDir);
}
async function handleTrustAndConnect() {
showWorkspaceTrust = false;
const targetDir = selectedDirectory || "/home/naomi";
pendingHookInfo = null;
const alreadyTrusted = currentConfig.trusted_workspaces?.includes(targetDir) ?? false;
if (!alreadyTrusted) {
await configStore.updateConfig({
trusted_workspaces: [...(currentConfig.trusted_workspaces ?? []), targetDir],
});
}
doConnect(targetDir);
}
function handleCancelConnect() {
showWorkspaceTrust = false;
pendingHookInfo = null;
}
async function handleDisconnect() { async function handleDisconnect() {
try { try {
const conversationId = get(claudeStore.activeConversationId); const conversationId = get(claudeStore.activeConversationId);
@@ -287,6 +348,9 @@
custom_instructions: currentConfig.custom_instructions || null, custom_instructions: currentConfig.custom_instructions || null,
mcp_servers_json: currentConfig.mcp_servers_json || null, mcp_servers_json: currentConfig.mcp_servers_json || null,
allowed_tools: allAllowedTools, allowed_tools: allAllowedTools,
use_worktree: currentConfig.use_worktree ?? false,
disable_1m_context: currentConfig.disable_1m_context ?? false,
max_output_tokens: currentConfig.max_output_tokens ?? null,
}, },
}); });
@@ -519,6 +583,20 @@
/> />
</svg> </svg>
</button> </button>
<button
onclick={() => (showCastPanel = true)}
class="p-1 text-gray-500 icon-trans-hover"
title="Meet the Team"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
/>
</svg>
</button>
<button <button
onclick={() => (showAgentMonitor = !showAgentMonitor)} onclick={() => (showAgentMonitor = !showAgentMonitor)}
class="p-1 text-gray-500 icon-trans-hover relative {showAgentMonitor class="p-1 text-gray-500 icon-trans-hover relative {showAgentMonitor
@@ -737,6 +815,10 @@
<AgentMonitorPanel isOpen={showAgentMonitor} onClose={() => (showAgentMonitor = false)} /> <AgentMonitorPanel isOpen={showAgentMonitor} onClose={() => (showAgentMonitor = false)} />
{/if} {/if}
{#if showCastPanel}
<CastPanel onClose={() => (showCastPanel = false)} />
{/if}
{#if showPluginPanel} {#if showPluginPanel}
<PluginManagementPanel onClose={() => (showPluginPanel = false)} /> <PluginManagementPanel onClose={() => (showPluginPanel = false)} />
{/if} {/if}
@@ -745,6 +827,14 @@
<McpManagementPanel onClose={() => (showMcpPanel = false)} /> <McpManagementPanel onClose={() => (showMcpPanel = false)} />
{/if} {/if}
{#if showWorkspaceTrust && pendingHookInfo}
<WorkspaceTrustModal
hookInfo={pendingHookInfo}
onTrust={handleTrustAndConnect}
onCancel={handleCancelConnect}
/>
{/if}
<style> <style>
/* Responsive status bar styling */ /* Responsive status bar styling */
.status-bar { .status-bar {
+89
View File
@@ -0,0 +1,89 @@
/**
* StatusBar Component Tests
*
* Tests the connection status colour and text helpers used by the
* StatusBar component to display the current Claude connection state.
*
* What this component does:
* - Shows a coloured indicator dot for the connection state
* - Shows a text label for the connection state
* - Provides connect/disconnect buttons
* - Contains the working directory input and browse button
* - Houses all toolbar action buttons (settings, stats, panels, etc.)
*
* Manual testing checklist:
* - [ ] Green dot and "Connected" label when Claude is running
* - [ ] Animated yellow dot and "Connecting..." label whilst connecting
* - [ ] Red dot and "Error" label on connection error
* - [ ] Grey dot and "Disconnected" label when not connected
* - [ ] Directory input is hidden when connected, visible when disconnected
* - [ ] Connect button transitions to Disconnect button on connection
*/
import { describe, it, expect } from "vitest";
type ConnectionStatus = "connected" | "connecting" | "disconnected" | "error";
function getStatusColor(connectionStatus: ConnectionStatus): string {
switch (connectionStatus) {
case "connected":
return "bg-green-500";
case "connecting":
return "bg-yellow-500 animate-pulse";
case "error":
return "bg-red-500";
default:
return "bg-gray-500";
}
}
function getStatusText(connectionStatus: ConnectionStatus): string {
switch (connectionStatus) {
case "connected":
return "Connected";
case "connecting":
return "Connecting...";
case "error":
return "Error";
default:
return "Disconnected";
}
}
// ---
describe("getStatusColor", () => {
it("returns green for connected status", () => {
expect(getStatusColor("connected")).toBe("bg-green-500");
});
it("returns animated yellow for connecting status", () => {
expect(getStatusColor("connecting")).toBe("bg-yellow-500 animate-pulse");
});
it("returns red for error status", () => {
expect(getStatusColor("error")).toBe("bg-red-500");
});
it("returns grey for disconnected status", () => {
expect(getStatusColor("disconnected")).toBe("bg-gray-500");
});
});
describe("getStatusText", () => {
it("returns 'Connected' for connected status", () => {
expect(getStatusText("connected")).toBe("Connected");
});
it("returns 'Connecting...' for connecting status", () => {
expect(getStatusText("connecting")).toBe("Connecting...");
});
it("returns 'Error' for error status", () => {
expect(getStatusText("error")).toBe("Error");
});
it("returns 'Disconnected' for disconnected status", () => {
expect(getStatusText("disconnected")).toBe("Disconnected");
});
});
+76 -5
View File
@@ -1,10 +1,13 @@
<script lang="ts"> <script lang="ts">
import { claudeStore, type TerminalLine } from "$lib/stores/claude"; import { claudeStore, type TerminalLine } from "$lib/stores/claude";
import { afterUpdate, tick, onMount, onDestroy } from "svelte"; import { afterUpdate, tick, onMount, onDestroy } from "svelte";
import { invoke } from "@tauri-apps/api/core";
import ConversationTabs from "./ConversationTabs.svelte"; import ConversationTabs from "./ConversationTabs.svelte";
import Markdown from "./Markdown.svelte"; import Markdown from "./Markdown.svelte";
import HighlightedText from "./HighlightedText.svelte"; import HighlightedText from "./HighlightedText.svelte";
import ThinkingBlock from "./ThinkingBlock.svelte"; import ThinkingBlock from "./ThinkingBlock.svelte";
import ToolCallBlock from "./ToolCallBlock.svelte";
import { searchState, searchQuery } from "$lib/stores/search"; import { searchState, searchQuery } from "$lib/stores/search";
import { clipboardStore } from "$lib/stores/clipboard"; import { clipboardStore } from "$lib/stores/clipboard";
import { shouldHidePaths, maskPaths, showThinkingBlocks } from "$lib/stores/config"; import { shouldHidePaths, maskPaths, showThinkingBlocks } from "$lib/stores/config";
@@ -92,6 +95,14 @@
return "terminal-error"; return "terminal-error";
case "thinking": case "thinking":
return "terminal-thinking"; return "terminal-thinking";
case "rate-limit":
return "terminal-rate-limit";
case "compact-prompt":
return "terminal-compact-prompt";
case "worktree":
return "terminal-worktree";
case "config-change":
return "terminal-config-change";
default: default:
return "terminal-default"; return "terminal-default";
} }
@@ -109,6 +120,12 @@
return "[tool]"; return "[tool]";
case "error": case "error":
return "[error]"; return "[error]";
case "rate-limit":
return "[rate-limit]";
case "worktree":
return "[worktree]";
case "config-change":
return "[config]";
default: default:
return ""; return "";
} }
@@ -187,6 +204,11 @@
copiedMessageId = null; copiedMessageId = null;
}, 2000); }, 2000);
} }
async function handleCompact() {
if (!currentConversationId) return;
await invoke("send_prompt", { conversationId: currentConversationId, message: "/compact" });
}
</script> </script>
<div <div
@@ -209,7 +231,7 @@
bind:this={terminalElement} bind:this={terminalElement}
onscroll={handleScroll} onscroll={handleScroll}
class="terminal-content h-[calc(100%-76px)] overflow-y-auto p-4 font-mono" class="terminal-content h-[calc(100%-76px)] overflow-y-auto p-4 font-mono"
style="font-size: var(--terminal-font-size, 14px);" style="font-size: var(--terminal-font-size, 14px); font-family: var(--terminal-font-family, monospace);"
> >
{#if lines.length === 0} {#if lines.length === 0}
<div class="terminal-waiting italic"> <div class="terminal-waiting italic">
@@ -221,6 +243,18 @@
{#if showThinking} {#if showThinking}
<ThinkingBlock content={line.content} timestamp={line.timestamp} /> <ThinkingBlock content={line.content} timestamp={line.timestamp} />
{/if} {/if}
{:else if line.type === "tool"}
<div
style={line.parentToolUseId
? "margin-left: 16px; padding-left: 8px; border-left: 2px solid var(--accent-primary);"
: ""}
>
<ToolCallBlock
toolName={line.toolName ?? null}
content={maskPaths(line.content, hidePaths)}
timestamp={line.timestamp}
/>
</div>
{:else} {:else}
<div <div
class="terminal-line mb-2 {getLineClass(line.type)} relative group" class="terminal-line mb-2 {getLineClass(line.type)} relative group"
@@ -259,10 +293,11 @@
{#if getLinePrefix(line.type)} {#if getLinePrefix(line.type)}
<span class="terminal-prefix mr-2">{getLinePrefix(line.type)}</span> <span class="terminal-prefix mr-2">{getLinePrefix(line.type)}</span>
{/if} {/if}
{#if line.toolName} {#if line.type === "compact-prompt"}
<span class="terminal-tool-name mr-2">[{line.toolName}]</span> <button class="compact-action-btn" onclick={handleCompact}>
{/if} ⚡ Compact Conversation
{#if line.type === "assistant" || line.type === "user"} </button>
{:else if line.type === "assistant" || line.type === "user"}
<div class="message-content-wrapper"> <div class="message-content-wrapper">
<Markdown <Markdown
content={maskPaths(line.content, hidePaths)} content={maskPaths(line.content, hidePaths)}
@@ -329,6 +364,42 @@
color: var(--terminal-error, #f87171); color: var(--terminal-error, #f87171);
} }
.terminal-rate-limit {
color: var(--terminal-rate-limit, #fb923c);
}
.terminal-compact-prompt {
color: var(--text-secondary);
}
.terminal-worktree {
color: var(--terminal-worktree, #34d399);
}
.terminal-config-change {
color: var(--terminal-config-change, #a78bfa);
}
.compact-action-btn {
display: inline-flex;
align-items: center;
gap: 0.4em;
background: var(--bg-secondary);
border: 1px solid var(--terminal-error, #f87171);
color: var(--terminal-error, #f87171);
padding: 0.3em 0.8em;
cursor: pointer;
border-radius: 4px;
font-size: 0.9em;
font-family: inherit;
transition: all 0.15s ease;
}
.compact-action-btn:hover {
background: color-mix(in srgb, var(--terminal-error, #f87171) 15%, transparent);
color: var(--terminal-error, #f87171);
}
.terminal-default { .terminal-default {
color: var(--text-primary); color: var(--text-primary);
} }
+264
View File
@@ -0,0 +1,264 @@
/**
* Terminal Component Tests
*
* Tests the pure helper functions extracted from the Terminal component:
* - getLineClass: maps line types to CSS class names
* - getLinePrefix: maps line types to display prefixes
* - formatTime: formats a Date as "HH:MM AM/PM"
* - isToolContentLong: checks if tool content exceeds collapse threshold
* - truncateToolContent: truncates long tool content with ellipsis
*
* Manual testing checklist:
* - [ ] rate-limit lines appear in amber
* - [ ] error lines appear in red
* - [ ] tool lines appear in purple
* - [ ] system lines appear in grey italic
* - [ ] user lines appear in cyan
* - [ ] assistant lines appear in primary text colour
* - [ ] long tool content is collapsed by default with a toggle button
*/
import { describe, it, expect } from "vitest";
// Mirror functions from Terminal.svelte for isolated testing
function getLineClass(type: string): string {
switch (type) {
case "user":
return "terminal-user";
case "assistant":
return "terminal-assistant";
case "system":
return "terminal-system italic";
case "tool":
return "terminal-tool";
case "error":
return "terminal-error";
case "thinking":
return "terminal-thinking";
case "rate-limit":
return "terminal-rate-limit";
case "compact-prompt":
return "terminal-compact-prompt";
case "worktree":
return "terminal-worktree";
case "config-change":
return "terminal-config-change";
default:
return "terminal-default";
}
}
function getLinePrefix(type: string): string {
switch (type) {
case "user":
return ">";
case "assistant":
return "";
case "system":
return "[system]";
case "tool":
return "[tool]";
case "error":
return "[error]";
case "rate-limit":
return "[rate-limit]";
case "worktree":
return "[worktree]";
case "config-change":
return "[config]";
default:
return "";
}
}
function formatTime(date: Date): string {
return date.toLocaleTimeString("en-US", {
hour: "2-digit",
minute: "2-digit",
});
}
const TOOL_COLLAPSE_THRESHOLD = 60;
function isToolContentLong(content: string): boolean {
return content.length > TOOL_COLLAPSE_THRESHOLD;
}
function truncateToolContent(content: string): string {
return content.slice(0, TOOL_COLLAPSE_THRESHOLD) + "…";
}
// ---
describe("getLineClass", () => {
it("returns terminal-user for user lines", () => {
expect(getLineClass("user")).toBe("terminal-user");
});
it("returns terminal-assistant for assistant lines", () => {
expect(getLineClass("assistant")).toBe("terminal-assistant");
});
it("returns terminal-system italic for system lines", () => {
expect(getLineClass("system")).toBe("terminal-system italic");
});
it("returns terminal-tool for tool lines", () => {
expect(getLineClass("tool")).toBe("terminal-tool");
});
it("returns terminal-error for error lines", () => {
expect(getLineClass("error")).toBe("terminal-error");
});
it("returns terminal-thinking for thinking lines", () => {
expect(getLineClass("thinking")).toBe("terminal-thinking");
});
it("returns terminal-rate-limit for rate-limit lines", () => {
expect(getLineClass("rate-limit")).toBe("terminal-rate-limit");
});
it("returns terminal-compact-prompt for compact-prompt lines", () => {
expect(getLineClass("compact-prompt")).toBe("terminal-compact-prompt");
});
it("returns terminal-worktree for worktree lines", () => {
expect(getLineClass("worktree")).toBe("terminal-worktree");
});
it("returns terminal-config-change for config-change lines", () => {
expect(getLineClass("config-change")).toBe("terminal-config-change");
});
it("returns terminal-default for unknown line types", () => {
expect(getLineClass("unknown")).toBe("terminal-default");
expect(getLineClass("")).toBe("terminal-default");
expect(getLineClass("random-future-type")).toBe("terminal-default");
});
});
describe("getLinePrefix", () => {
it("returns > for user lines", () => {
expect(getLinePrefix("user")).toBe(">");
});
it("returns empty string for assistant lines", () => {
expect(getLinePrefix("assistant")).toBe("");
});
it("returns [system] for system lines", () => {
expect(getLinePrefix("system")).toBe("[system]");
});
it("returns [tool] for tool lines", () => {
expect(getLinePrefix("tool")).toBe("[tool]");
});
it("returns [error] for error lines", () => {
expect(getLinePrefix("error")).toBe("[error]");
});
it("returns [rate-limit] for rate-limit lines", () => {
expect(getLinePrefix("rate-limit")).toBe("[rate-limit]");
});
it("returns empty string for compact-prompt lines (button renders instead)", () => {
expect(getLinePrefix("compact-prompt")).toBe("");
});
it("returns [worktree] for worktree lines", () => {
expect(getLinePrefix("worktree")).toBe("[worktree]");
});
it("returns [config] for config-change lines", () => {
expect(getLinePrefix("config-change")).toBe("[config]");
});
it("returns empty string for thinking lines (no prefix)", () => {
expect(getLinePrefix("thinking")).toBe("");
});
it("returns empty string for unknown line types", () => {
expect(getLinePrefix("unknown")).toBe("");
expect(getLinePrefix("")).toBe("");
});
});
describe("formatTime", () => {
it("formats time in 12-hour format with AM/PM", () => {
const date = new Date(2026, 1, 7, 14, 35);
const formatted = formatTime(date);
expect(formatted).toMatch(/\d{2}:\d{2}\s?(AM|PM)/i);
});
it("formats afternoon times correctly", () => {
const date = new Date(2026, 1, 7, 14, 35);
const formatted = formatTime(date);
expect(formatted).toContain("02:35");
expect(formatted.toUpperCase()).toContain("PM");
});
it("formats morning times correctly", () => {
const date = new Date(2026, 1, 7, 9, 5);
const formatted = formatTime(date);
expect(formatted).toContain("09:05");
expect(formatted.toUpperCase()).toContain("AM");
});
it("formats midnight correctly", () => {
const date = new Date(2026, 1, 7, 0, 0);
const formatted = formatTime(date);
expect(formatted).toContain("12:00");
expect(formatted.toUpperCase()).toContain("AM");
});
it("formats noon correctly", () => {
const date = new Date(2026, 1, 7, 12, 0);
const formatted = formatTime(date);
expect(formatted).toContain("12:00");
expect(formatted.toUpperCase()).toContain("PM");
});
});
describe("isToolContentLong", () => {
it("returns false for content at or below the threshold", () => {
const exactThreshold = "x".repeat(TOOL_COLLAPSE_THRESHOLD);
expect(isToolContentLong(exactThreshold)).toBe(false);
});
it("returns true for content exceeding the threshold", () => {
const overThreshold = "x".repeat(TOOL_COLLAPSE_THRESHOLD + 1);
expect(isToolContentLong(overThreshold)).toBe(true);
});
it("returns false for short content", () => {
expect(isToolContentLong("short")).toBe(false);
});
it("returns false for empty content", () => {
expect(isToolContentLong("")).toBe(false);
});
});
describe("truncateToolContent", () => {
it("truncates content to the threshold length with an ellipsis", () => {
const long = "x".repeat(100);
const result = truncateToolContent(long);
expect(result).toBe("x".repeat(TOOL_COLLAPSE_THRESHOLD) + "…");
});
it("keeps content shorter than threshold unchanged (plus ellipsis)", () => {
const short = "hello";
const result = truncateToolContent(short);
expect(result).toBe("hello…");
});
it("uses the unicode ellipsis character (not three dots)", () => {
const long = "x".repeat(100);
const result = truncateToolContent(long);
expect(result.endsWith("…")).toBe(true);
expect(result.endsWith("...")).toBe(false);
});
});
@@ -73,7 +73,7 @@
onClose(); onClose();
}} }}
> >
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
<div <div
bind:this={menuElement} bind:this={menuElement}
class="menu-content" class="menu-content"
+141
View File
@@ -0,0 +1,141 @@
<script lang="ts">
interface Props {
toolName: string | null;
content: string;
timestamp: Date;
}
let { toolName, content, timestamp }: Props = $props();
let isExpanded = $state(false);
function toggleExpanded() {
isExpanded = !isExpanded;
}
function formatTime(date: Date): string {
return date.toLocaleTimeString("en-US", {
hour: "2-digit",
minute: "2-digit",
});
}
</script>
<div class="tool-block">
<button class="tool-header" onclick={toggleExpanded} type="button">
<span class="tool-timestamp">{formatTime(timestamp)}</span>
<svg
class="tool-icon"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
width="16"
height="16"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"
/>
</svg>
{#if toolName}
<span class="tool-name">[{toolName}]</span>
{/if}
<span class="tool-label">{content}</span>
<svg
class="chevron"
class:expanded={isExpanded}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
width="14"
height="14"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
{#if isExpanded}
<div class="tool-content">
{content}
</div>
{/if}
</div>
<style>
.tool-block {
margin-bottom: 0.5rem;
border: 1px solid var(--border-color);
border-radius: 0.375rem;
background: var(--bg-secondary);
opacity: 0.85;
}
.tool-header {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.5rem 0.75rem;
border: none;
background: transparent;
cursor: pointer;
color: var(--terminal-tool, #c084fc);
font-size: 0.875rem;
transition: all 0.2s;
}
.tool-header:hover {
background: var(--bg-primary);
color: var(--text-primary);
}
.tool-timestamp {
font-family: monospace;
font-size: 0.75rem;
opacity: 0.7;
color: var(--text-secondary);
flex-shrink: 0;
}
.tool-icon {
flex-shrink: 0;
}
.tool-name {
font-family: monospace;
font-weight: 600;
flex-shrink: 0;
color: var(--terminal-tool-name, #ddd6fe);
}
.tool-label {
flex: 1;
text-align: left;
font-style: italic;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chevron {
flex-shrink: 0;
transition: transform 0.2s;
}
.chevron.expanded {
transform: rotate(180deg);
}
.tool-content {
padding: 0.75rem;
border-top: 1px solid var(--border-color);
color: var(--terminal-tool, #c084fc);
font-family: monospace;
font-size: 0.875rem;
font-style: italic;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
}
</style>
@@ -106,6 +106,8 @@
custom_instructions: config.custom_instructions || null, custom_instructions: config.custom_instructions || null,
mcp_servers_json: config.mcp_servers_json || null, mcp_servers_json: config.mcp_servers_json || null,
allowed_tools: grantedToolsList, allowed_tools: grantedToolsList,
use_worktree: config.use_worktree ?? false,
disable_1m_context: config.disable_1m_context ?? false,
}, },
}); });
@@ -0,0 +1,110 @@
<script lang="ts">
import { characterState } from "$lib/stores/character";
import type { WorkspaceHookInfo } from "$lib/types/messages";
interface Props {
hookInfo: WorkspaceHookInfo;
onTrust: () => void;
onCancel: () => void;
}
const { hookInfo, onTrust, onCancel }: Props = $props();
$effect(() => {
characterState.setState("permission");
});
</script>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center" onclick={onCancel}>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg p-6 max-w-md w-full mx-4 shadow-xl"
onclick={(e) => e.stopPropagation()}
>
<div class="flex items-center gap-3 mb-4">
<svg
class="w-6 h-6 text-yellow-400 flex-shrink-0"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
<h2 class="text-lg font-semibold text-[var(--text-primary)]">Workspace Trust Required</h2>
</div>
<p class="text-sm text-[var(--text-secondary)] mb-4">
This workspace contains configuration that can execute code on your system. Review what was
found before connecting.
</p>
<div class="space-y-3 mb-4">
{#if hookInfo.hook_types.length > 0}
<div class="bg-[var(--bg-primary)] rounded-md p-3">
<p class="text-xs text-[var(--text-secondary)] mb-2 font-medium">
Hooks (run shell commands automatically):
</p>
<ul class="space-y-1">
{#each hookInfo.hook_types as hookType (hookType)}
<li class="text-sm text-yellow-400 font-mono">{hookType}</li>
{/each}
</ul>
</div>
{/if}
{#if hookInfo.mcp_servers.length > 0}
<div class="bg-[var(--bg-primary)] rounded-md p-3">
<p class="text-xs text-[var(--text-secondary)] mb-2 font-medium">
MCP servers (run as local processes with system access):
</p>
<ul class="space-y-1">
{#each hookInfo.mcp_servers as server (server)}
<li class="text-sm text-yellow-400 font-mono">{server}</li>
{/each}
</ul>
</div>
{/if}
{#if hookInfo.custom_commands.length > 0}
<div class="bg-[var(--bg-primary)] rounded-md p-3">
<p class="text-xs text-[var(--text-secondary)] mb-2 font-medium">
Custom slash commands (can execute arbitrary instructions):
</p>
<ul class="space-y-1">
{#each hookInfo.custom_commands as cmd (cmd)}
<li class="text-sm text-yellow-400 font-mono">• /{cmd}</li>
{/each}
</ul>
</div>
{/if}
</div>
<p class="text-xs text-[var(--text-secondary)] mb-6">
Only connect to workspaces you trust. Trusting this workspace will remember your choice for
future sessions.
</p>
<div class="flex gap-3 justify-end">
<button
onclick={onCancel}
class="px-4 py-2 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] border border-[var(--border-color)] rounded-md transition-colors"
>
Cancel
</button>
<button
onclick={onTrust}
class="px-4 py-2 text-sm bg-yellow-500/20 hover:bg-yellow-500/30 text-yellow-400 border border-yellow-500/30 rounded-md transition-colors"
>
Trust and Connect
</button>
</div>
</div>
</div>
@@ -30,9 +30,9 @@
<svelte:window onkeydown={handleKeydown} /> <svelte:window onkeydown={handleKeydown} />
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
<div class="dialog-overlay" onclick={onCancel}> <div class="dialog-overlay" onclick={onCancel}>
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
<div class="dialog-content" onclick={(e) => e.stopPropagation()}> <div class="dialog-content" onclick={(e) => e.stopPropagation()}>
<h2 class="dialog-title">{title}</h2> <h2 class="dialog-title">{title}</h2>
<p class="dialog-message">{message}</p> <p class="dialog-message">{message}</p>
@@ -83,7 +83,7 @@
onClose(); onClose();
}} }}
> >
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
<div <div
bind:this={menuElement} bind:this={menuElement}
class="menu-content" class="menu-content"
@@ -78,7 +78,7 @@
<svelte:window onkeydown={handleKeydown} /> <svelte:window onkeydown={handleKeydown} />
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
<div <div
class="menu-overlay" class="menu-overlay"
onclick={onClose} onclick={onClose}
@@ -87,7 +87,7 @@
onClose(); onClose();
}} }}
> >
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
<div <div
bind:this={menuElement} bind:this={menuElement}
class="menu-content" class="menu-content"
+2 -2
View File
@@ -50,9 +50,9 @@
<svelte:window onkeydown={handleKeydown} /> <svelte:window onkeydown={handleKeydown} />
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
<div class="dialog-overlay" onclick={onCancel}> <div class="dialog-overlay" onclick={onCancel}>
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
<div class="dialog-content" onclick={(e) => e.stopPropagation()}> <div class="dialog-content" onclick={(e) => e.stopPropagation()}>
<h2 class="dialog-title">{title}</h2> <h2 class="dialog-title">{title}</h2>
@@ -0,0 +1,242 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { invoke } from "@tauri-apps/api/core";
import { isPermissionGranted } from "@tauri-apps/plugin-notification";
import { setMockInvokeResult } from "../../../vitest.setup";
import { notificationManager } from "./notificationManager";
import { sendTerminalNotification } from "./terminalNotifier";
import { NotificationType, NOTIFICATION_SOUNDS } from "./types";
vi.mock("./soundPlayer", () => ({
soundPlayer: { play: vi.fn().mockResolvedValue(undefined) },
}));
vi.mock("./terminalNotifier", () => ({
sendTerminalNotification: vi.fn(),
}));
describe("NotificationManager", () => {
let consoleSpy: ReturnType<typeof vi.spyOn>;
let consoleWarnSpy: ReturnType<typeof vi.spyOn>;
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
});
afterEach(() => {
consoleSpy.mockRestore();
consoleWarnSpy.mockRestore();
consoleErrorSpy.mockRestore();
});
describe("notify()", () => {
it("calls the first notification method by default", async () => {
await notificationManager.notify(NotificationType.SUCCESS);
expect(invoke).toHaveBeenCalledWith(
"send_windows_notification",
expect.objectContaining({
title: NOTIFICATION_SOUNDS[NotificationType.SUCCESS].phrase,
})
);
});
it("passes a custom message to the notification", async () => {
await notificationManager.notify(NotificationType.ERROR, "Custom error message");
expect(invoke).toHaveBeenCalledWith("send_windows_notification", {
title: NOTIFICATION_SOUNDS[NotificationType.ERROR].phrase,
body: "Custom error message",
});
});
it("falls back to the next method when the first fails", async () => {
setMockInvokeResult("send_windows_notification", new Error("Method 1 failed"));
await notificationManager.notify(NotificationType.SUCCESS);
expect(invoke).toHaveBeenCalledWith("send_windows_toast", expect.any(Object));
});
it("falls back to terminal notification when all methods fail", async () => {
setMockInvokeResult("send_windows_notification", new Error("failed"));
setMockInvokeResult("send_windows_toast", new Error("failed"));
setMockInvokeResult("send_wsl_notification", new Error("failed"));
setMockInvokeResult("send_notify_send", new Error("failed"));
vi.mocked(isPermissionGranted).mockRejectedValueOnce(new Error("Permission check failed"));
await notificationManager.notify(NotificationType.SUCCESS);
expect(sendTerminalNotification).toHaveBeenCalledWith(
NotificationType.SUCCESS,
"Task completed successfully!"
);
});
it("uses the default SUCCESS message when none is provided", async () => {
await notificationManager.notify(NotificationType.SUCCESS);
expect(invoke).toHaveBeenCalledWith("send_windows_notification", {
title: expect.any(String),
body: "Task completed successfully!",
});
});
it("uses the default ERROR message", async () => {
await notificationManager.notify(NotificationType.ERROR);
expect(invoke).toHaveBeenCalledWith("send_windows_notification", {
title: expect.any(String),
body: "Something went wrong...",
});
});
it("uses the default PERMISSION message", async () => {
await notificationManager.notify(NotificationType.PERMISSION);
expect(invoke).toHaveBeenCalledWith("send_windows_notification", {
title: expect.any(String),
body: "Permission needed to continue",
});
});
it("uses the default CONNECTION message", async () => {
await notificationManager.notify(NotificationType.CONNECTION);
expect(invoke).toHaveBeenCalledWith("send_windows_notification", {
title: expect.any(String),
body: "Successfully connected to Claude Code",
});
});
it("uses the default TASK_START message", async () => {
await notificationManager.notify(NotificationType.TASK_START);
expect(invoke).toHaveBeenCalledWith("send_windows_notification", {
title: expect.any(String),
body: "Starting task...",
});
});
it("uses the default COST_ALERT message", async () => {
await notificationManager.notify(NotificationType.COST_ALERT);
expect(invoke).toHaveBeenCalledWith("send_windows_notification", {
title: expect.any(String),
body: "You've exceeded your cost threshold!",
});
});
it("uses the fallback default message for unhandled types", async () => {
await notificationManager.notify(NotificationType.ACHIEVEMENT);
expect(invoke).toHaveBeenCalledWith("send_windows_notification", {
title: expect.any(String),
body: "Notification",
});
});
});
describe("helper methods", () => {
it("notifySuccess calls notify with SUCCESS type and default message", async () => {
await notificationManager.notifySuccess();
expect(invoke).toHaveBeenCalledWith("send_windows_notification", {
title: NOTIFICATION_SOUNDS[NotificationType.SUCCESS].phrase,
body: "Task completed successfully!",
});
});
it("notifySuccess passes a custom message", async () => {
await notificationManager.notifySuccess("All done!");
expect(invoke).toHaveBeenCalledWith("send_windows_notification", {
title: expect.any(String),
body: "All done!",
});
});
it("notifyError calls notify with ERROR type", async () => {
await notificationManager.notifyError();
expect(invoke).toHaveBeenCalledWith("send_windows_notification", {
title: NOTIFICATION_SOUNDS[NotificationType.ERROR].phrase,
body: "Something went wrong...",
});
});
it("notifyPermission calls notify with PERMISSION type", async () => {
await notificationManager.notifyPermission();
expect(invoke).toHaveBeenCalledWith("send_windows_notification", {
title: NOTIFICATION_SOUNDS[NotificationType.PERMISSION].phrase,
body: "Permission needed to continue",
});
});
it("notifyConnection calls notify with CONNECTION type", async () => {
await notificationManager.notifyConnection();
expect(invoke).toHaveBeenCalledWith("send_windows_notification", {
title: NOTIFICATION_SOUNDS[NotificationType.CONNECTION].phrase,
body: "Successfully connected to Claude Code",
});
});
it("notifyTaskStart calls notify with TASK_START type", async () => {
await notificationManager.notifyTaskStart();
expect(invoke).toHaveBeenCalledWith("send_windows_notification", {
title: NOTIFICATION_SOUNDS[NotificationType.TASK_START].phrase,
body: "Starting task...",
});
});
it("notifyCostAlert calls notify with COST_ALERT type", async () => {
await notificationManager.notifyCostAlert();
expect(invoke).toHaveBeenCalledWith("send_windows_notification", {
title: NOTIFICATION_SOUNDS[NotificationType.COST_ALERT].phrase,
body: "You've exceeded your cost threshold!",
});
});
});
describe("Tauri plugin notification method (Method 4)", () => {
it("sends notification when permission is already granted", async () => {
const { isPermissionGranted, sendNotification } =
await import("@tauri-apps/plugin-notification");
setMockInvokeResult("send_windows_notification", new Error("failed"));
setMockInvokeResult("send_windows_toast", new Error("failed"));
setMockInvokeResult("send_wsl_notification", new Error("failed"));
vi.mocked(isPermissionGranted).mockResolvedValueOnce(true);
await notificationManager.notify(NotificationType.SUCCESS);
expect(sendNotification).toHaveBeenCalledWith(
expect.objectContaining({
title: NOTIFICATION_SOUNDS[NotificationType.SUCCESS].phrase,
})
);
});
it("requests permission and sends notification when not yet granted", async () => {
const { isPermissionGranted, requestPermission, sendNotification } =
await import("@tauri-apps/plugin-notification");
setMockInvokeResult("send_windows_notification", new Error("failed"));
setMockInvokeResult("send_windows_toast", new Error("failed"));
setMockInvokeResult("send_wsl_notification", new Error("failed"));
vi.mocked(isPermissionGranted).mockResolvedValueOnce(false);
vi.mocked(requestPermission).mockResolvedValueOnce("granted");
await notificationManager.notify(NotificationType.SUCCESS);
expect(requestPermission).toHaveBeenCalledWith();
expect(sendNotification).toHaveBeenCalledWith(
expect.objectContaining({ body: "Task completed successfully!" })
);
});
it("falls through to next method when permission is denied", async () => {
const { isPermissionGranted, requestPermission } =
await import("@tauri-apps/plugin-notification");
setMockInvokeResult("send_windows_notification", new Error("failed"));
setMockInvokeResult("send_windows_toast", new Error("failed"));
setMockInvokeResult("send_wsl_notification", new Error("failed"));
vi.mocked(isPermissionGranted).mockResolvedValueOnce(false);
vi.mocked(requestPermission).mockResolvedValueOnce("denied");
setMockInvokeResult("send_notify_send", new Error("failed"));
await notificationManager.notify(NotificationType.SUCCESS);
expect(sendTerminalNotification).toHaveBeenCalledWith(
NotificationType.SUCCESS,
"Task completed successfully!"
);
});
});
});
@@ -232,6 +232,53 @@ describe("notifications", () => {
// Should not throw // Should not throw
await expect(soundPlayer.play(NotificationType.SUCCESS)).resolves.toBeUndefined(); await expect(soundPlayer.play(NotificationType.SUCCESS)).resolves.toBeUndefined();
}); });
it("play warns when audio type is not in the cache", async () => {
const { soundPlayer } = await import("./soundPlayer");
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
soundPlayer.setEnabled(true);
await soundPlayer.play("nonexistent" as NotificationType);
expect(warnSpy).toHaveBeenCalledWith("No audio found for notification type: nonexistent");
warnSpy.mockRestore();
});
it("play catches errors from audio playback", async () => {
vi.resetModules();
class FailingAudio {
volume = 1;
preload = "auto";
cloneNode() {
const clone = new FailingAudio();
clone.volume = this.volume;
return clone;
}
async play(): Promise<void> {
throw new Error("Playback blocked by browser");
}
}
const originalAudio = globalThis.Audio;
globalThis.Audio = FailingAudio as unknown as typeof Audio;
const { soundPlayer: freshPlayer } = await import("./soundPlayer");
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
freshPlayer.setEnabled(true);
await freshPlayer.play(NotificationType.SUCCESS);
expect(errorSpy).toHaveBeenCalledWith(
"Failed to play notification sound:",
expect.any(Error)
);
errorSpy.mockRestore();
globalThis.Audio = originalAudio;
});
}); });
describe("NotificationManager class", () => { describe("NotificationManager class", () => {
+150
View File
@@ -0,0 +1,150 @@
/**
* Notification Rules Tests
*
* Tests the connection status change handler, which fires a connection
* notification sound exactly once per reconnect cycle.
*
* What this module does:
* - Tracks the previous connection status in module-level state
* - Fires a notification only when transitioning from a non-connected
* state (disconnected/connecting) to "connected"
* - Ignores the initial connection (null connected) to avoid noisy
* notifications on app start
* - Provides no-op handlers for tool execution and user messages
* (reserved for future notification rules)
* - cleanupNotificationRules() resets tracking state on teardown
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
const { mockNotifyConnection } = vi.hoisted(() => ({
mockNotifyConnection: vi.fn(),
}));
vi.mock("./notificationManager", () => ({
notificationManager: {
notifyConnection: mockNotifyConnection,
},
}));
import {
handleConnectionStatusChange,
handleToolExecution,
handleNewUserMessage,
initializeNotificationRules,
cleanupNotificationRules,
} from "./rules";
// ---
describe("handleConnectionStatusChange", () => {
beforeEach(() => {
mockNotifyConnection.mockReset();
cleanupNotificationRules(); // Reset module-level previousConnectionStatus to null
});
describe("initial connection (null → status)", () => {
it("does not notify on first connection (null → connected)", () => {
// previousConnectionStatus is null (falsy), so condition is not met
handleConnectionStatusChange("connected");
expect(mockNotifyConnection).not.toHaveBeenCalled();
});
it("does not notify when disconnecting from initial state (null → disconnected)", () => {
handleConnectionStatusChange("disconnected");
expect(mockNotifyConnection).not.toHaveBeenCalled();
});
it("does not notify when entering connecting from initial state (null → connecting)", () => {
handleConnectionStatusChange("connecting");
expect(mockNotifyConnection).not.toHaveBeenCalled();
});
});
describe("reconnection (disconnected → connected)", () => {
it("notifies when reconnecting after a disconnection", () => {
handleConnectionStatusChange("disconnected");
handleConnectionStatusChange("connected");
expect(mockNotifyConnection).toHaveBeenCalledWith();
});
it("notifies exactly once per reconnect", () => {
handleConnectionStatusChange("disconnected");
handleConnectionStatusChange("connected");
expect(mockNotifyConnection).toHaveBeenCalledTimes(1);
});
});
describe("reconnection (connecting → connected)", () => {
it("notifies when transitioning from connecting to connected", () => {
handleConnectionStatusChange("connecting");
handleConnectionStatusChange("connected");
expect(mockNotifyConnection).toHaveBeenCalledWith();
});
});
describe("already connected (connected → connected)", () => {
it("does not notify when already connected", () => {
handleConnectionStatusChange("disconnected");
handleConnectionStatusChange("connected"); // First connection — notifies
mockNotifyConnection.mockReset();
handleConnectionStatusChange("connected"); // Second — same status, no notify
expect(mockNotifyConnection).not.toHaveBeenCalled();
});
});
describe("disconnecting (connected → disconnected)", () => {
it("does not notify when disconnecting", () => {
handleConnectionStatusChange("disconnected");
handleConnectionStatusChange("connected");
mockNotifyConnection.mockReset();
handleConnectionStatusChange("disconnected");
expect(mockNotifyConnection).not.toHaveBeenCalled();
});
});
describe("multiple reconnect cycles", () => {
it("notifies once per reconnect cycle", () => {
// First cycle
handleConnectionStatusChange("disconnected");
handleConnectionStatusChange("connected");
expect(mockNotifyConnection).toHaveBeenCalledTimes(1);
mockNotifyConnection.mockReset();
// Second cycle
handleConnectionStatusChange("disconnected");
handleConnectionStatusChange("connected");
expect(mockNotifyConnection).toHaveBeenCalledTimes(1);
});
});
});
describe("cleanupNotificationRules", () => {
it("resets state so the next connection is treated as the first", () => {
// Establish a known previous status
handleConnectionStatusChange("disconnected");
// Now cleanup
cleanupNotificationRules();
// After cleanup, previousConnectionStatus is null again
// So the next "connected" should NOT notify (treated as initial connection)
handleConnectionStatusChange("connected");
expect(mockNotifyConnection).not.toHaveBeenCalled();
});
});
describe("no-op handlers", () => {
it("handleToolExecution does not throw", () => {
expect(() => handleToolExecution("Bash")).not.toThrow();
});
it("handleNewUserMessage does not throw", () => {
expect(() => handleNewUserMessage()).not.toThrow();
});
it("initializeNotificationRules does not throw", () => {
expect(() => initializeNotificationRules()).not.toThrow();
});
});
+6 -74
View File
@@ -1,52 +1,8 @@
import { characterState } from "$lib/stores/character";
import { notificationManager } from "./notificationManager"; import { notificationManager } from "./notificationManager";
import type { CharacterState } from "$lib/types/states";
import type { ConnectionStatus } from "$lib/types/messages"; import type { ConnectionStatus } from "$lib/types/messages";
// Track previous states to detect transitions // Track previous connection status to detect transitions
let previousCharacterState: CharacterState | null = null;
let previousConnectionStatus: ConnectionStatus | null = null; let previousConnectionStatus: ConnectionStatus | null = null;
let taskStartTime: number | null = null;
let hasNotifiedTaskStart = false;
export function handleCharacterStateChange(newState: CharacterState): void {
// Detect state transitions
if (previousCharacterState === newState) return;
// Task completion: any state -> success
if (newState === "success" && previousCharacterState !== null) {
const taskDuration = taskStartTime ? Date.now() - taskStartTime : 0;
// Only notify for tasks that took more than 2 seconds
if (taskDuration > 2000) {
notificationManager.notifySuccess();
}
taskStartTime = null;
}
// Error occurred
if (newState === "error" && previousCharacterState !== "error") {
notificationManager.notifyError();
}
// Permission needed
if (newState === "permission") {
notificationManager.notifyPermission();
}
// Starting long tasks - only notify once per response
if (
(newState === "coding" || newState === "searching") &&
previousCharacterState !== "coding" &&
previousCharacterState !== "searching" &&
!hasNotifiedTaskStart
) {
taskStartTime = Date.now();
hasNotifiedTaskStart = true;
notificationManager.notifyTaskStart();
}
previousCharacterState = newState;
}
export function handleConnectionStatusChange(newStatus: ConnectionStatus): void { export function handleConnectionStatusChange(newStatus: ConnectionStatus): void {
// Only notify on successful connection after being disconnected // Only notify on successful connection after being disconnected
@@ -67,37 +23,13 @@ export function handleToolExecution(_toolName: string): void {
// But we could add specific rules here if needed // But we could add specific rules here if needed
} }
// Reset notification state for a new response // No-op: sound tracking is now per-conversation in tauri.ts
export function handleNewUserMessage(): void { export function handleNewUserMessage(): void {}
hasNotifiedTaskStart = false;
}
// Store unsubscribe functions // No-op: all per-conversation sounds are driven by tauri.ts event listeners
let unsubscribeCharacterState: (() => void) | null = null; export function initializeNotificationRules(): void {}
// Initialize listeners // Cleanup — reset connection tracking on teardown
export function initializeNotificationRules(): void {
// Clean up any existing subscriptions first
cleanupNotificationRules();
// Subscribe to character state changes
unsubscribeCharacterState = characterState.subscribe((state) => {
handleCharacterStateChange(state);
});
// We'll connect to connection status in the next step
}
// Cleanup function to prevent duplicate listeners
export function cleanupNotificationRules(): void { export function cleanupNotificationRules(): void {
if (unsubscribeCharacterState) {
unsubscribeCharacterState();
unsubscribeCharacterState = null;
}
// Reset state tracking
previousCharacterState = null;
previousConnectionStatus = null; previousConnectionStatus = null;
taskStartTime = null;
hasNotifiedTaskStart = false;
} }
@@ -0,0 +1,64 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NotificationType } from "./types";
import { claudeStore } from "$lib/stores/claude";
import { sendTerminalNotification } from "./terminalNotifier";
vi.mock("$lib/stores/claude", () => ({
claudeStore: {
addLine: vi.fn(),
},
}));
describe("sendTerminalNotification", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("adds a system line for success type with sparkle emoji", () => {
sendTerminalNotification(NotificationType.SUCCESS);
expect(claudeStore.addLine).toHaveBeenCalledWith("system", expect.stringContaining("✨"));
});
it("adds a system line for error type with cross emoji", () => {
sendTerminalNotification(NotificationType.ERROR);
expect(claudeStore.addLine).toHaveBeenCalledWith("system", expect.stringContaining("❌"));
});
it("adds a system line for permission type with lock emoji", () => {
sendTerminalNotification(NotificationType.PERMISSION);
expect(claudeStore.addLine).toHaveBeenCalledWith("system", expect.stringContaining("🔐"));
});
it("adds a system line for connection type with link emoji", () => {
sendTerminalNotification(NotificationType.CONNECTION);
expect(claudeStore.addLine).toHaveBeenCalledWith("system", expect.stringContaining("🔗"));
});
it("adds a system line for task_start type with rocket emoji", () => {
sendTerminalNotification(NotificationType.TASK_START);
expect(claudeStore.addLine).toHaveBeenCalledWith("system", expect.stringContaining("🚀"));
});
it("includes the optional message in the notification", () => {
sendTerminalNotification(NotificationType.SUCCESS, "Custom message text");
expect(claudeStore.addLine).toHaveBeenCalledWith(
"system",
expect.stringContaining("Custom message text")
);
});
it("includes the sound phrase as the notification title", () => {
sendTerminalNotification(NotificationType.SUCCESS);
expect(claudeStore.addLine).toHaveBeenCalledWith(
"system",
expect.stringContaining("I'm done!")
);
});
});
@@ -0,0 +1,58 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { testAllNotifications } from "./testNotifications";
vi.mock("./notificationManager", () => ({
notificationManager: {
notifySuccess: vi.fn().mockResolvedValue(undefined),
notifyError: vi.fn().mockResolvedValue(undefined),
notifyPermission: vi.fn().mockResolvedValue(undefined),
notifyConnection: vi.fn().mockResolvedValue(undefined),
notifyTaskStart: vi.fn().mockResolvedValue(undefined),
},
}));
describe("testNotifications", () => {
describe("window assignment", () => {
it("assigns testAllNotifications to window.testNotifications", async () => {
// The module-level if block runs on import — reimport to ensure it ran
await import("./testNotifications");
expect((window as unknown as { testNotifications: unknown }).testNotifications).toBe(
testAllNotifications
);
});
});
describe("testAllNotifications", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("is an async function", () => {
expect(typeof testAllNotifications).toBe("function");
});
it("schedules all five notification type calls", async () => {
const { notificationManager } = await import("./notificationManager");
const consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {});
await testAllNotifications();
await vi.runAllTimersAsync();
expect(notificationManager.notifySuccess).toHaveBeenCalledWith("Test task completed!");
expect(notificationManager.notifyError).toHaveBeenCalledWith("Test error occurred!");
expect(notificationManager.notifyPermission).toHaveBeenCalledWith("Test permission request!");
expect(notificationManager.notifyConnection).toHaveBeenCalledWith(
"Test connection established!"
);
expect(notificationManager.notifyTaskStart).toHaveBeenCalledWith("Test task starting!");
consoleLogSpy.mockRestore();
});
});
});
@@ -0,0 +1,40 @@
import { describe, it, expect, vi } from "vitest";
import { platform } from "@tauri-apps/plugin-os";
import { invoke } from "@tauri-apps/api/core";
import { sendWSLNotification } from "./wslNotificationHelper";
// platform() is mocked in vitest.setup.ts to return "linux" by default
describe("sendWSLNotification", () => {
it("invokes send_windows_notification when platform is windows", async () => {
vi.mocked(platform).mockResolvedValueOnce("windows" as Awaited<ReturnType<typeof platform>>);
await sendWSLNotification("Test Title", "Test body");
expect(invoke).toHaveBeenCalledWith("send_windows_notification", {
title: "Test Title",
body: "Test body",
});
});
it("does not invoke on non-Windows platforms", async () => {
// Default mock returns "linux"
await sendWSLNotification("Test Title", "Test body");
expect(invoke).not.toHaveBeenCalled();
});
it("handles invoke errors gracefully and logs them", async () => {
vi.mocked(platform).mockResolvedValueOnce("windows" as Awaited<ReturnType<typeof platform>>);
vi.mocked(invoke).mockRejectedValueOnce(new Error("Windows notification failed"));
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
await sendWSLNotification("Title", "Body");
expect(errorSpy).toHaveBeenCalledWith(
"Failed to send Windows notification:",
expect.any(Error)
);
errorSpy.mockRestore();
});
});
+48
View File
@@ -0,0 +1,48 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NotificationType } from "$lib/notifications/types";
const mockPlay = vi.fn();
vi.mock("$lib/notifications", () => ({
soundPlayer: {
play: mockPlay,
},
}));
describe("achievement sounds", () => {
beforeEach(() => {
mockPlay.mockReset();
});
describe("playAchievementSound", () => {
it("plays the achievement notification sound", async () => {
const { playAchievementSound } = await import("./achievement");
playAchievementSound();
expect(mockPlay).toHaveBeenCalledWith(NotificationType.ACHIEVEMENT);
});
});
describe("testAchievementSound", () => {
it("calls playAchievementSound without throwing", async () => {
const { testAchievementSound } = await import("./achievement");
expect(() => testAchievementSound()).not.toThrow();
expect(mockPlay).toHaveBeenCalledWith(NotificationType.ACHIEVEMENT);
});
it("catches errors from the sound player gracefully", async () => {
mockPlay.mockImplementation(() => {
throw new Error("Audio not available");
});
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const { testAchievementSound } = await import("./achievement");
expect(() => testAchievementSound()).not.toThrow();
expect(consoleErrorSpy).toHaveBeenCalledWith(
"Error playing achievement sound:",
expect.any(Error)
);
consoleErrorSpy.mockRestore();
});
});
});
+185
View File
@@ -0,0 +1,185 @@
import { describe, it, expect, vi } from "vitest";
import { get } from "svelte/store";
import { setMockInvokeResult, emitMockEvent } from "../../../vitest.setup";
import {
achievementsStore,
unlockedAchievements,
lockedAchievements,
achievementsByRarity,
achievementProgress,
initAchievementsListener,
} from "./achievements";
import type { AchievementUnlockedEvent } from "$lib/types/achievements";
import { playAchievementSound } from "$lib/sounds/achievement";
vi.mock("$lib/sounds/achievement", () => ({
playAchievementSound: vi.fn(),
}));
// Helper to build a minimal unlock event
function makeEvent(id: AchievementUnlockedEvent["achievement"]["id"]): AchievementUnlockedEvent {
return {
achievement: {
id,
name: "Test",
description: "Test achievement",
icon: "🏆",
unlocked_at: null,
},
};
}
describe("achievementsStore initial state", () => {
it("all achievements start as locked", () => {
const state = get(achievementsStore);
expect(state.achievements["FirstSteps"].unlocked).toBe(false);
expect(state.achievements["GrowingStrong"].unlocked).toBe(false);
});
it("totalUnlocked starts at 0", () => {
expect(get(achievementsStore).totalUnlocked).toBe(0);
});
it("lastUnlocked starts as null", () => {
expect(get(achievementsStore).lastUnlocked).toBeNull();
});
});
describe("derived stores initial state", () => {
it("unlockedAchievements is initially empty", () => {
expect(get(unlockedAchievements)).toEqual([]);
});
it("lockedAchievements contains all achievements initially", () => {
const locked = get(lockedAchievements);
const total = Object.keys(get(achievementsStore).achievements).length;
expect(locked.length).toBe(total);
});
it("achievementsByRarity groups achievements into rarity buckets", () => {
const byRarity = get(achievementsByRarity);
expect(byRarity.common).toBeInstanceOf(Array);
expect(byRarity.rare).toBeInstanceOf(Array);
expect(byRarity.epic).toBeInstanceOf(Array);
expect(byRarity.legendary).toBeInstanceOf(Array);
expect(byRarity.common.length).toBeGreaterThan(0);
});
it("achievementProgress shows zero unlocked initially", () => {
const progress = get(achievementProgress);
expect(progress.unlocked).toBe(0);
expect(progress.total).toBeGreaterThan(0);
expect(progress.percentage).toBe(0);
});
});
describe("achievementsStore.unlockAchievement", () => {
it("marks the achievement as unlocked and updates totalUnlocked", () => {
achievementsStore.unlockAchievement(makeEvent("GrowingStrong"));
const state = get(achievementsStore);
expect(state.achievements["GrowingStrong"].unlocked).toBe(true);
expect(state.totalUnlocked).toBe(1);
expect(state.lastUnlocked?.id).toBe("GrowingStrong");
});
it("sets unlockedAt from the event's unlocked_at timestamp", () => {
achievementsStore.unlockAchievement({
achievement: {
id: "BlossomingCoder",
name: "Blossoming Coder",
description: "100k tokens",
icon: "🌸",
unlocked_at: "2026-01-15T12:00:00.000Z",
},
});
const state = get(achievementsStore);
expect(state.achievements["BlossomingCoder"].unlockedAt).toBeInstanceOf(Date);
});
it("does nothing when the achievement is already unlocked", () => {
achievementsStore.unlockAchievement(makeEvent("TokenMaster"));
const firstTotal = get(achievementsStore).totalUnlocked;
achievementsStore.unlockAchievement(makeEvent("TokenMaster"));
expect(get(achievementsStore).totalUnlocked).toBe(firstTotal);
});
it("calls playAchievementSound when playSound is true (default)", () => {
achievementsStore.unlockAchievement(makeEvent("TokenBillionaire"));
expect(playAchievementSound).toHaveBeenCalled();
});
it("does not call playAchievementSound when playSound is false", () => {
achievementsStore.unlockAchievement(makeEvent("TokenTreasure"), false);
expect(playAchievementSound).not.toHaveBeenCalled();
});
it("logs an error when playAchievementSound throws", () => {
vi.mocked(playAchievementSound).mockImplementationOnce(() => {
throw new Error("Sound failed");
});
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
achievementsStore.unlockAchievement(makeEvent("HelloWorld"));
expect(consoleSpy).toHaveBeenCalledWith("Failed to play achievement sound:", expect.any(Error));
consoleSpy.mockRestore();
});
});
describe("derived stores after unlocks", () => {
it("unlockedAchievements includes previously unlocked achievements", () => {
const unlocked = get(unlockedAchievements);
expect(unlocked.some((a) => a.id === "GrowingStrong")).toBe(true);
});
it("lockedAchievements excludes previously unlocked achievements", () => {
const locked = get(lockedAchievements);
expect(locked.some((a) => a.id === "GrowingStrong")).toBe(false);
});
it("achievementProgress reflects the current unlocked count", () => {
const progress = get(achievementProgress);
expect(progress.unlocked).toBeGreaterThan(0);
expect(progress.percentage).toBeGreaterThan(0);
});
});
describe("achievementsStore.updateProgress", () => {
it("updates the progress value for an achievement", () => {
achievementsStore.updateProgress("FirstMessage", 50);
expect(get(achievementsStore).achievements["FirstMessage"].progress).toBe(50);
});
});
describe("achievementsStore.reset", () => {
it("resets totalUnlocked to 0 and lastUnlocked to null", () => {
achievementsStore.reset();
const state = get(achievementsStore);
expect(state.totalUnlocked).toBe(0);
expect(state.lastUnlocked).toBeNull();
});
});
describe("initAchievementsListener", () => {
it("unlocks an achievement when the achievement:unlocked event fires", async () => {
await initAchievementsListener();
emitMockEvent("achievement:unlocked", makeEvent("FirstSteps"));
expect(get(achievementsStore).achievements["FirstSteps"].unlocked).toBe(true);
});
it("loads saved achievements from the backend without playing sounds", async () => {
setMockInvokeResult("load_saved_achievements", [makeEvent("ConversationStarter")]);
await initAchievementsListener();
expect(get(achievementsStore).achievements["ConversationStarter"].unlocked).toBe(true);
expect(playAchievementSound).not.toHaveBeenCalled();
});
it("logs an error when loading saved achievements fails", async () => {
setMockInvokeResult("load_saved_achievements", new Error("Storage unavailable"));
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
await initAchievementsListener();
expect(consoleSpy).toHaveBeenCalledWith(
"Failed to load saved achievements:",
expect.any(Error)
);
consoleSpy.mockRestore();
});
});
+58 -7
View File
@@ -2,12 +2,15 @@ import { describe, it, expect, beforeEach } from "vitest";
import { agentStore, getAgentsForConversation, runningAgentCount } from "./agents"; import { agentStore, getAgentsForConversation, runningAgentCount } from "./agents";
import { get } from "svelte/store"; import { get } from "svelte/store";
import type { AgentInfo } from "$lib/types/agents"; import type { AgentInfo } from "$lib/types/agents";
import { CHARACTER_POOL } from "$lib/utils/agentCharacters";
describe("agents store", () => { describe("agents store", () => {
const conversationId = "test-conversation-1"; const conversationId = "test-conversation-1";
const otherConversationId = "test-conversation-2"; const otherConversationId = "test-conversation-2";
const createMockAgent = (overrides?: Partial<AgentInfo>): AgentInfo => ({ type AgentInput = Omit<AgentInfo, "characterName" | "characterAvatar">;
const createMockAgent = (overrides?: Partial<AgentInput>): AgentInput => ({
toolUseId: "toolu_test123", toolUseId: "toolu_test123",
description: "Test agent", description: "Test agent",
subagentType: "Explore", subagentType: "Explore",
@@ -37,7 +40,29 @@ describe("agents store", () => {
const agents = get(getAgentsForConversation(conversationId)); const agents = get(getAgentsForConversation(conversationId));
expect(agents).toHaveLength(1); expect(agents).toHaveLength(1);
expect(agents[0]).toEqual(agent); expect(agents[0]).toMatchObject(agent);
});
it("assigns a character name and avatar to added agents", () => {
const agent = createMockAgent();
agentStore.addAgent(conversationId, agent);
const agents = get(getAgentsForConversation(conversationId));
const validNames = CHARACTER_POOL.map((c) => c.name);
expect(validNames).toContain(agents[0].characterName);
expect(agents[0].characterAvatar).toMatch(/^https:\/\//u);
});
it("avoids duplicate character names across agents when possible", () => {
// Add 6 agents - each should ideally get a unique character
for (let i = 0; i < 6; i++) {
agentStore.addAgent(conversationId, createMockAgent({ toolUseId: `tool${i.toString()}` }));
}
const agents = get(getAgentsForConversation(conversationId));
const names = agents.map((a) => a.characterName);
const uniqueNames = new Set(names);
expect(uniqueNames.size).toBe(6);
}); });
it("adds multiple agents to the same conversation", () => { it("adds multiple agents to the same conversation", () => {
@@ -49,8 +74,8 @@ describe("agents store", () => {
const agents = get(getAgentsForConversation(conversationId)); const agents = get(getAgentsForConversation(conversationId));
expect(agents).toHaveLength(2); expect(agents).toHaveLength(2);
expect(agents[0]).toEqual(agent1); expect(agents[0]).toMatchObject(agent1);
expect(agents[1]).toEqual(agent2); expect(agents[1]).toMatchObject(agent2);
}); });
it("keeps agents in different conversations separate", () => { it("keeps agents in different conversations separate", () => {
@@ -65,8 +90,8 @@ describe("agents store", () => {
expect(agents1).toHaveLength(1); expect(agents1).toHaveLength(1);
expect(agents2).toHaveLength(1); expect(agents2).toHaveLength(1);
expect(agents1[0]).toEqual(agent1); expect(agents1[0]).toMatchObject(agent1);
expect(agents2[0]).toEqual(agent2); expect(agents2[0]).toMatchObject(agent2);
}); });
}); });
@@ -152,6 +177,32 @@ describe("agents store", () => {
const agents = get(getAgentsForConversation(conversationId)); const agents = get(getAgentsForConversation(conversationId));
expect(agents[0].status).toBe("running"); // Status unchanged expect(agents[0].status).toBe("running"); // Status unchanged
}); });
it("stores lastAssistantMessage when provided", () => {
const agent = createMockAgent({ status: "running" });
agentStore.addAgent(conversationId, agent);
agentStore.endAgent(
conversationId,
agent.toolUseId,
Date.now(),
false,
"Task completed successfully."
);
const agents = get(getAgentsForConversation(conversationId));
expect(agents[0].lastAssistantMessage).toBe("Task completed successfully.");
});
it("leaves lastAssistantMessage undefined when not provided", () => {
const agent = createMockAgent({ status: "running" });
agentStore.addAgent(conversationId, agent);
agentStore.endAgent(conversationId, agent.toolUseId, Date.now(), false);
const agents = get(getAgentsForConversation(conversationId));
expect(agents[0].lastAssistantMessage).toBeUndefined();
});
}); });
describe("markAllErrored", () => { describe("markAllErrored", () => {
@@ -256,7 +307,7 @@ describe("agents store", () => {
expect(agents1).toHaveLength(0); expect(agents1).toHaveLength(0);
expect(agents2).toHaveLength(1); expect(agents2).toHaveLength(1);
expect(agents2[0]).toEqual(agent2); expect(agents2[0]).toMatchObject(agent2);
}); });
it("does nothing if conversation doesn't exist", () => { it("does nothing if conversation doesn't exist", () => {
+16 -3
View File
@@ -1,5 +1,6 @@
import { writable, derived } from "svelte/store"; import { writable, derived } from "svelte/store";
import type { AgentInfo } from "$lib/types/agents"; import type { AgentInfo } from "$lib/types/agents";
import { assignCharacter } from "$lib/utils/agentCharacters";
// Map of conversation ID -> agents in that conversation // Map of conversation ID -> agents in that conversation
const agentsByConversation = writable<Record<string, AgentInfo[]>>({}); const agentsByConversation = writable<Record<string, AgentInfo[]>>({});
@@ -8,12 +9,17 @@ function createAgentStore() {
return { return {
subscribe: agentsByConversation.subscribe, subscribe: agentsByConversation.subscribe,
addAgent(conversationId: string, agent: AgentInfo) { addAgent(conversationId: string, agent: Omit<AgentInfo, "characterName" | "characterAvatar">) {
agentsByConversation.update((state) => { agentsByConversation.update((state) => {
const existing = state[conversationId] || []; const existing = state[conversationId] || [];
const activeNames = existing.map((a) => a.characterName);
const character = assignCharacter(activeNames);
return { return {
...state, ...state,
[conversationId]: [...existing, agent], [conversationId]: [
...existing,
{ ...agent, characterName: character.name, characterAvatar: character.avatar },
],
}; };
}); });
}, },
@@ -39,7 +45,13 @@ function createAgentStore() {
}); });
}, },
endAgent(conversationId: string, toolUseId: string, endedAt: number, isError: boolean) { endAgent(
conversationId: string,
toolUseId: string,
endedAt: number,
isError: boolean,
lastAssistantMessage?: string
) {
agentsByConversation.update((state) => { agentsByConversation.update((state) => {
const agents = state[conversationId]; const agents = state[conversationId];
if (!agents) return state; if (!agents) return state;
@@ -56,6 +68,7 @@ function createAgentStore() {
endedAt, endedAt,
status: isError ? "errored" : "completed", status: isError ? "errored" : "completed",
durationMs, durationMs,
lastAssistantMessage,
}; };
return { return {
+124
View File
@@ -0,0 +1,124 @@
import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
import { get } from "svelte/store";
import { characterState, characterInfo } from "./character";
describe("characterState store", () => {
beforeEach(() => {
vi.useFakeTimers();
characterState.reset();
});
afterEach(() => {
vi.useRealTimers();
});
describe("initial state", () => {
it("starts in idle state", () => {
expect(get(characterState)).toBe("idle");
});
});
describe("setState", () => {
it("sets the character state", () => {
characterState.setState("thinking");
expect(get(characterState)).toBe("thinking");
});
it("can set any valid state", () => {
const states = [
"idle",
"thinking",
"typing",
"coding",
"searching",
"mcp",
"permission",
"success",
"error",
] as const;
for (const state of states) {
characterState.setState(state);
expect(get(characterState)).toBe(state);
}
});
it("cancels any active temporary state timer", () => {
characterState.setTemporaryState("success", 5000);
characterState.setState("thinking");
// Advance past the temporary state duration — should stay as thinking
vi.advanceTimersByTime(6000);
expect(get(characterState)).toBe("thinking");
});
});
describe("setTemporaryState", () => {
it("sets the character state immediately", () => {
characterState.setTemporaryState("success", 2000);
expect(get(characterState)).toBe("success");
});
it("reverts to idle after the specified duration", () => {
characterState.setTemporaryState("success", 2000);
vi.advanceTimersByTime(2000);
expect(get(characterState)).toBe("idle");
});
it("uses 2000ms as the default duration", () => {
characterState.setTemporaryState("error");
vi.advanceTimersByTime(1999);
expect(get(characterState)).toBe("error");
vi.advanceTimersByTime(1);
expect(get(characterState)).toBe("idle");
});
it("cancels a previous temporary state timer when a new one is set", () => {
characterState.setTemporaryState("success", 5000);
characterState.setTemporaryState("error", 1000);
// First timer would have fired at 5000ms but was cancelled
vi.advanceTimersByTime(1000);
expect(get(characterState)).toBe("idle");
});
});
describe("reset", () => {
it("resets the state to idle", () => {
characterState.setState("thinking");
characterState.reset();
expect(get(characterState)).toBe("idle");
});
it("cancels any pending temporary state timer", () => {
characterState.setTemporaryState("success", 5000);
characterState.reset();
// Should now be idle and should NOT revert again after timer fires
expect(get(characterState)).toBe("idle");
vi.advanceTimersByTime(5000);
expect(get(characterState)).toBe("idle");
});
});
});
describe("characterInfo derived store", () => {
beforeEach(() => {
characterState.reset();
});
it("returns the info object for the current state", () => {
const info = get(characterInfo);
expect(info).toBeDefined();
expect(typeof info.label).toBe("string");
});
it("updates when the character state changes", () => {
characterState.setState("thinking");
const thinkingInfo = get(characterInfo);
characterState.setState("idle");
const idleInfo = get(characterInfo);
expect(thinkingInfo.label).not.toBe(idleInfo.label);
});
});
+139
View File
@@ -0,0 +1,139 @@
import { describe, it, expect, afterEach } from "vitest";
import { get } from "svelte/store";
import {
claudeStore,
hasPermissionPending,
hasQuestionPending,
isClaudeProcessing,
} from "./claude";
import { conversationsStore } from "./conversations";
import { characterState } from "$lib/stores/character";
import type { PermissionRequest, UserQuestionEvent } from "$lib/types/messages";
describe("claudeStore (compatibility wrapper)", () => {
afterEach(() => {
conversationsStore.revokeAllTools();
conversationsStore.clearPermission();
conversationsStore.clearQuestion();
conversationsStore.setConnectionStatus("disconnected");
characterState.setState("idle");
});
describe("getGrantedTools", () => {
it("returns an empty array when no tools are granted", () => {
expect(claudeStore.getGrantedTools()).toEqual([]);
});
it("returns granted tools as an array", () => {
conversationsStore.grantTool("Read");
conversationsStore.grantTool("Write");
const tools = claudeStore.getGrantedTools();
expect(tools).toContain("Read");
expect(tools).toContain("Write");
});
});
describe("reset", () => {
it("clears terminal lines and resets processing state", () => {
conversationsStore.setProcessing(true);
conversationsStore.grantTool("Edit");
claudeStore.reset();
expect(get(conversationsStore.isProcessing)).toBe(false);
expect(claudeStore.getGrantedTools()).toEqual([]);
});
});
});
describe("hasPermissionPending derived store", () => {
afterEach(() => {
conversationsStore.clearPermission();
});
it("is false when there are no pending permissions", () => {
expect(get(hasPermissionPending)).toBe(false);
});
it("is true when there is a pending permission request", () => {
const request: PermissionRequest = {
id: "perm-1",
tool: "Bash",
description: "Run a shell command",
input: { command: "ls" },
};
conversationsStore.requestPermission(request);
expect(get(hasPermissionPending)).toBe(true);
conversationsStore.clearPermission();
});
});
describe("hasQuestionPending derived store", () => {
afterEach(() => {
conversationsStore.clearQuestion();
});
it("is false when there is no pending question", () => {
expect(get(hasQuestionPending)).toBe(false);
});
it("is true when there is a pending question", () => {
const question: UserQuestionEvent = {
id: "q-1",
question: "Which approach do you prefer?",
options: [{ label: "A" }, { label: "B" }],
multi_select: false,
};
conversationsStore.requestQuestion(question);
expect(get(hasQuestionPending)).toBe(true);
});
});
describe("isClaudeProcessing derived store", () => {
afterEach(() => {
conversationsStore.setConnectionStatus("disconnected");
characterState.setState("idle");
});
it("is false when disconnected regardless of character state", () => {
conversationsStore.setConnectionStatus("disconnected");
characterState.setState("thinking");
expect(get(isClaudeProcessing)).toBe(false);
});
it("is false when connected but in idle state", () => {
conversationsStore.setConnectionStatus("connected");
characterState.setState("idle");
expect(get(isClaudeProcessing)).toBe(false);
});
it("is true when connected and in thinking state", () => {
conversationsStore.setConnectionStatus("connected");
characterState.setState("thinking");
expect(get(isClaudeProcessing)).toBe(true);
});
it("is true when connected and in coding state", () => {
conversationsStore.setConnectionStatus("connected");
characterState.setState("coding");
expect(get(isClaudeProcessing)).toBe(true);
});
it("is true when connected and in searching state", () => {
conversationsStore.setConnectionStatus("connected");
characterState.setState("searching");
expect(get(isClaudeProcessing)).toBe(true);
});
});
+9
View File
@@ -60,6 +60,15 @@ export const claudeStore = {
isToolGranted: conversationsStore.isToolGranted, isToolGranted: conversationsStore.isToolGranted,
setPendingRetryMessage: conversationsStore.setPendingRetryMessage, setPendingRetryMessage: conversationsStore.setPendingRetryMessage,
// Sound tracking
resetSoundState: conversationsStore.resetSoundState,
setTaskStartTime: conversationsStore.setTaskStartTime,
markSuccessSoundFired: conversationsStore.markSuccessSoundFired,
markTaskStartSoundFired: conversationsStore.markTaskStartSoundFired,
// Draft text (per-tab input persistence)
setDraftText: conversationsStore.setDraftText,
// Conversation management // Conversation management
createConversation: conversationsStore.createConversation, createConversation: conversationsStore.createConversation,
deleteConversation: conversationsStore.deleteConversation, deleteConversation: conversationsStore.deleteConversation,
+675
View File
@@ -0,0 +1,675 @@
/**
* Clipboard Store Tests
*
* Tests the pure helper functions and store actions from the clipboard store:
* - detectLanguage: identifies programming language from code content
* - formatTimestamp: converts an ISO timestamp to a relative time string
* - Store actions: loadEntries, captureClipboard, deleteEntry, togglePin, etc.
* - Derived stores: filteredEntries (language + search filtering), languages
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { get } from "svelte/store";
import { clipboardStore } from "$lib/stores/clipboard";
import { setMockInvokeResult } from "../../../vitest.setup";
describe("detectLanguage", () => {
describe("TypeScript detection", () => {
it("detects import statements", () => {
expect(clipboardStore.detectLanguage("import React from 'react';")).toBe("typescript");
});
it("detects export statements", () => {
expect(clipboardStore.detectLanguage("export function foo() {}")).toBe("typescript");
});
it("detects const declarations", () => {
expect(clipboardStore.detectLanguage("const x = 1;")).toBe("typescript");
});
it("detects interface declarations", () => {
expect(clipboardStore.detectLanguage("interface Foo {\n bar: string;\n}")).toBe(
"typescript"
);
});
it("detects type aliases", () => {
expect(clipboardStore.detectLanguage("type MyType = string | number;")).toBe("typescript");
});
});
describe("Python detection", () => {
it("detects def statements", () => {
expect(clipboardStore.detectLanguage("def foo():\n pass")).toBe("python");
});
it("detects async def statements", () => {
expect(clipboardStore.detectLanguage("async def bar():\n pass")).toBe("python");
});
it("detects from imports", () => {
expect(clipboardStore.detectLanguage("from os import path")).toBe("python");
});
it("detects the __name__ guard", () => {
expect(clipboardStore.detectLanguage("if __name__ == '__main__':\n main()")).toBe("python");
});
});
describe("Rust detection", () => {
it("detects fn declarations", () => {
expect(clipboardStore.detectLanguage('fn main() {\n println!("hello");\n}')).toBe("rust");
});
it("detects impl blocks", () => {
expect(clipboardStore.detectLanguage("impl Foo {\n pub fn new() -> Self {}\n}")).toBe(
"rust"
);
});
it("detects struct declarations", () => {
expect(clipboardStore.detectLanguage("struct Point {\n x: f64,\n y: f64,\n}")).toBe("rust");
});
it("detects enum declarations", () => {
expect(clipboardStore.detectLanguage("enum Direction {\n North,\n South,\n}")).toBe("rust");
});
it("detects mod declarations", () => {
expect(clipboardStore.detectLanguage("mod utils;")).toBe("rust");
});
it("detects pub visibility", () => {
expect(clipboardStore.detectLanguage("pub fn exported() {}")).toBe("rust");
});
});
describe("Go detection", () => {
it("detects package declarations", () => {
expect(clipboardStore.detectLanguage("package main")).toBe("go");
});
it("detects func declarations", () => {
expect(clipboardStore.detectLanguage("func main() {}")).toBe("go");
});
});
describe("PHP detection", () => {
it("detects the PHP open tag", () => {
expect(clipboardStore.detectLanguage("<?php\necho 'hello';")).toBe("php");
});
});
describe("SQL detection", () => {
it("detects SELECT statements", () => {
expect(clipboardStore.detectLanguage("SELECT * FROM users WHERE id = 1")).toBe("sql");
});
it("detects INSERT statements", () => {
expect(clipboardStore.detectLanguage("INSERT INTO users (name) VALUES ('Alice')")).toBe(
"sql"
);
});
it("detects CREATE statements", () => {
expect(clipboardStore.detectLanguage("CREATE TABLE users (id INT PRIMARY KEY)")).toBe("sql");
});
it("detects SQL case-insensitively", () => {
expect(clipboardStore.detectLanguage("select * from users")).toBe("sql");
});
});
describe("HTML detection", () => {
it("detects DOCTYPE declarations", () => {
expect(clipboardStore.detectLanguage("<!DOCTYPE html>")).toBe("html");
});
it("detects html tags", () => {
expect(clipboardStore.detectLanguage("<html><body></body></html>")).toBe("html");
});
it("detects div tags", () => {
expect(clipboardStore.detectLanguage("<div class='foo'>bar</div>")).toBe("html");
});
it("detects span tags", () => {
expect(clipboardStore.detectLanguage("<span>text</span>")).toBe("html");
});
});
describe("JSON detection", () => {
it("detects JSON object syntax", () => {
expect(clipboardStore.detectLanguage('{"name": "test", "value": 42}')).toBe("json");
});
it("detects JSON with hyphenated keys", () => {
expect(clipboardStore.detectLanguage('{"my-key": "value"}')).toBe("json");
});
});
describe("YAML detection", () => {
it("detects YAML document separator", () => {
expect(clipboardStore.detectLanguage("---\nkey: value\nother: 123")).toBe("yaml");
});
});
describe("C detection", () => {
it("detects #include directives", () => {
expect(clipboardStore.detectLanguage("#include <stdio.h>\nint main() {}")).toBe("c");
});
it("detects #define directives", () => {
expect(clipboardStore.detectLanguage("#define MAX 100")).toBe("c");
});
it("detects #ifdef directives", () => {
expect(clipboardStore.detectLanguage("#ifdef DEBUG\n// debug code\n#endif")).toBe("c");
});
});
describe("Java detection", () => {
it("detects public class declarations", () => {
expect(clipboardStore.detectLanguage("public class Foo {\n // ...\n}")).toBe("java");
});
it("detects private static methods", () => {
expect(clipboardStore.detectLanguage("private static void helper() {}")).toBe("java");
});
it("detects protected interface declarations", () => {
expect(clipboardStore.detectLanguage("protected interface Bar {}")).toBe("java");
});
});
describe("Bash detection", () => {
it("detects shell variable assignments", () => {
expect(clipboardStore.detectLanguage("$HOME=/usr/local")).toBe("bash");
});
it("detects variable assignments with underscores", () => {
expect(clipboardStore.detectLanguage("$MY_VAR=some_value")).toBe("bash");
});
});
describe("unknown content", () => {
it("returns null for plain text", () => {
expect(clipboardStore.detectLanguage("Hello, world!")).toBeNull();
});
it("returns null for empty string", () => {
expect(clipboardStore.detectLanguage("")).toBeNull();
});
it("returns null for mathematical expressions", () => {
expect(clipboardStore.detectLanguage("1 + 1 = 2")).toBeNull();
});
it("returns null for a markdown heading", () => {
expect(clipboardStore.detectLanguage("# My Heading\nSome text")).toBeNull();
});
});
});
describe("formatTimestamp", () => {
const NOW = new Date("2026-03-03T12:00:00.000Z");
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(NOW);
});
afterEach(() => {
vi.useRealTimers();
});
describe("'Just now' threshold (< 1 minute)", () => {
it("returns 'Just now' for a timestamp 30 seconds ago", () => {
const ts = new Date("2026-03-03T11:59:30.000Z").toISOString();
expect(clipboardStore.formatTimestamp(ts)).toBe("Just now");
});
it("returns 'Just now' for the current moment", () => {
const ts = NOW.toISOString();
expect(clipboardStore.formatTimestamp(ts)).toBe("Just now");
});
it("returns 'Just now' for a timestamp 59 seconds ago", () => {
const ts = new Date("2026-03-03T11:59:01.000Z").toISOString();
expect(clipboardStore.formatTimestamp(ts)).toBe("Just now");
});
});
describe("'Xm ago' threshold (159 minutes)", () => {
it("returns '1m ago' at exactly 1 minute", () => {
const ts = new Date("2026-03-03T11:59:00.000Z").toISOString();
expect(clipboardStore.formatTimestamp(ts)).toBe("1m ago");
});
it("returns '5m ago' for a timestamp 5 minutes ago", () => {
const ts = new Date("2026-03-03T11:55:00.000Z").toISOString();
expect(clipboardStore.formatTimestamp(ts)).toBe("5m ago");
});
it("returns '59m ago' just before the 1-hour threshold", () => {
const ts = new Date("2026-03-03T11:01:00.000Z").toISOString();
expect(clipboardStore.formatTimestamp(ts)).toBe("59m ago");
});
});
describe("'Xh ago' threshold (123 hours)", () => {
it("returns '1h ago' at exactly 1 hour", () => {
const ts = new Date("2026-03-03T11:00:00.000Z").toISOString();
expect(clipboardStore.formatTimestamp(ts)).toBe("1h ago");
});
it("returns '2h ago' for a timestamp 2 hours ago", () => {
const ts = new Date("2026-03-03T10:00:00.000Z").toISOString();
expect(clipboardStore.formatTimestamp(ts)).toBe("2h ago");
});
it("returns '23h ago' just before the 1-day threshold", () => {
const ts = new Date("2026-03-02T13:00:00.000Z").toISOString();
expect(clipboardStore.formatTimestamp(ts)).toBe("23h ago");
});
});
describe("'Xd ago' threshold (16 days)", () => {
it("returns '1d ago' at exactly 1 day", () => {
const ts = new Date("2026-03-02T12:00:00.000Z").toISOString();
expect(clipboardStore.formatTimestamp(ts)).toBe("1d ago");
});
it("returns '3d ago' for a timestamp 3 days ago", () => {
const ts = new Date("2026-02-28T12:00:00.000Z").toISOString();
expect(clipboardStore.formatTimestamp(ts)).toBe("3d ago");
});
it("returns '6d ago' just before the 7-day threshold", () => {
const ts = new Date("2026-02-25T12:00:00.000Z").toISOString();
expect(clipboardStore.formatTimestamp(ts)).toBe("6d ago");
});
});
describe("locale date string (7+ days ago)", () => {
it("returns a locale date string for a 2-week-old timestamp", () => {
const ts = new Date("2026-02-17T12:00:00.000Z").toISOString();
const result = clipboardStore.formatTimestamp(ts);
// Should not be a relative time string
expect(result).not.toContain("m ago");
expect(result).not.toContain("h ago");
expect(result).not.toContain("d ago");
expect(result).not.toBe("Just now");
});
it("returns a locale date string for a 1-month-old timestamp", () => {
const ts = new Date("2026-02-03T12:00:00.000Z").toISOString();
const result = clipboardStore.formatTimestamp(ts);
expect(result).not.toContain("ago");
});
});
});
describe("clipboardStore - derived stores", () => {
const makeEntry = (overrides: Record<string, unknown> = {}) => ({
id: "entry-1",
content: "const x = 1;",
language: "typescript",
source: "test.ts",
timestamp: "2026-03-03T12:00:00.000Z",
is_pinned: false,
...overrides,
});
beforeEach(() => {
clipboardStore.entries.set([]);
clipboardStore.searchQuery.set("");
clipboardStore.languageFilter.set(null);
});
describe("filteredEntries - language filter", () => {
it("returns all entries when no language filter is set", () => {
clipboardStore.entries.set([
makeEntry({ id: "1", language: "typescript" }),
makeEntry({ id: "2", language: "python" }),
]);
const filtered = get(clipboardStore.filteredEntries);
expect(filtered).toHaveLength(2);
});
it("filters entries by language", () => {
clipboardStore.entries.set([
makeEntry({ id: "1", language: "typescript" }),
makeEntry({ id: "2", language: "python" }),
makeEntry({ id: "3", language: "typescript" }),
]);
clipboardStore.languageFilter.set("typescript");
const filtered = get(clipboardStore.filteredEntries);
expect(filtered).toHaveLength(2);
expect(filtered.every((e) => e.language === "typescript")).toBe(true);
});
it("returns empty array when filter matches nothing", () => {
clipboardStore.entries.set([makeEntry({ language: "typescript" })]);
clipboardStore.languageFilter.set("rust");
const filtered = get(clipboardStore.filteredEntries);
expect(filtered).toHaveLength(0);
});
});
describe("filteredEntries - search query", () => {
it("filters by content (case-insensitive)", () => {
clipboardStore.entries.set([
makeEntry({ id: "1", content: "const HELLO = 1;" }),
makeEntry({ id: "2", content: "let world = 2;" }),
]);
clipboardStore.searchQuery.set("hello");
const filtered = get(clipboardStore.filteredEntries);
expect(filtered).toHaveLength(1);
expect(filtered[0].id).toBe("1");
});
it("filters by language field", () => {
clipboardStore.entries.set([
makeEntry({ id: "1", language: "typescript", content: "unrelated" }),
makeEntry({ id: "2", language: "python", content: "also unrelated" }),
]);
clipboardStore.searchQuery.set("python");
const filtered = get(clipboardStore.filteredEntries);
expect(filtered).toHaveLength(1);
expect(filtered[0].id).toBe("2");
});
it("filters by source field", () => {
clipboardStore.entries.set([
makeEntry({ id: "1", source: "main.rs", content: "fn main() {}" }),
makeEntry({ id: "2", source: "app.ts", content: "const x = 1;" }),
]);
clipboardStore.searchQuery.set("main.rs");
const filtered = get(clipboardStore.filteredEntries);
expect(filtered).toHaveLength(1);
expect(filtered[0].id).toBe("1");
});
it("returns all entries when search query is empty", () => {
clipboardStore.entries.set([makeEntry({ id: "1" }), makeEntry({ id: "2" })]);
clipboardStore.searchQuery.set("");
const filtered = get(clipboardStore.filteredEntries);
expect(filtered).toHaveLength(2);
});
});
describe("languages derived store", () => {
it("returns empty array when no entries", () => {
clipboardStore.entries.set([]);
expect(get(clipboardStore.languages)).toHaveLength(0);
});
it("returns unique sorted languages", () => {
clipboardStore.entries.set([
makeEntry({ language: "typescript" }),
makeEntry({ language: "python" }),
makeEntry({ language: "typescript" }),
makeEntry({ language: "rust" }),
]);
const langs = get(clipboardStore.languages);
expect(langs).toEqual(["python", "rust", "typescript"]);
});
it("excludes entries with null language", () => {
clipboardStore.entries.set([
makeEntry({ language: "typescript" }),
makeEntry({ language: null }),
]);
const langs = get(clipboardStore.languages);
expect(langs).toEqual(["typescript"]);
});
});
});
describe("clipboardStore - setSearchQuery and setLanguageFilter", () => {
it("setSearchQuery updates the searchQuery store", () => {
clipboardStore.setSearchQuery("hello world");
expect(get(clipboardStore.searchQuery)).toBe("hello world");
clipboardStore.setSearchQuery("");
});
it("setLanguageFilter updates the languageFilter store", () => {
clipboardStore.setLanguageFilter("python");
expect(get(clipboardStore.languageFilter)).toBe("python");
clipboardStore.setLanguageFilter(null);
});
it("setLanguageFilter can be reset to null", () => {
clipboardStore.setLanguageFilter("rust");
clipboardStore.setLanguageFilter(null);
expect(get(clipboardStore.languageFilter)).toBeNull();
});
});
describe("clipboardStore - store actions", () => {
const mockEntry = {
id: "entry-1",
content: "const x = 1;",
language: "typescript",
source: "test.ts",
timestamp: "2026-03-03T12:00:00.000Z",
is_pinned: false,
};
beforeEach(() => {
vi.clearAllMocks();
clipboardStore.entries.set([]);
});
describe("loadEntries", () => {
it("loads entries from backend and updates the store", async () => {
setMockInvokeResult("list_clipboard_entries", [mockEntry]);
await clipboardStore.loadEntries();
expect(get(clipboardStore.entries)).toEqual([mockEntry]);
});
it("handles errors gracefully without crashing", async () => {
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
setMockInvokeResult("list_clipboard_entries", new Error("Backend unavailable"));
await clipboardStore.loadEntries();
expect(consoleErrorSpy).toHaveBeenCalledWith(
"Failed to load clipboard entries:",
expect.any(Error)
);
consoleErrorSpy.mockRestore();
});
it("sets isLoading to false after completion", async () => {
setMockInvokeResult("list_clipboard_entries", []);
await clipboardStore.loadEntries();
expect(get(clipboardStore.isLoading)).toBe(false);
});
});
describe("captureClipboard", () => {
it("captures clipboard entry with provided language", async () => {
setMockInvokeResult("capture_clipboard", mockEntry);
setMockInvokeResult("list_clipboard_entries", [mockEntry]);
const result = await clipboardStore.captureClipboard("const x = 1;", "typescript", "test.ts");
expect(result).toEqual(mockEntry);
});
it("auto-detects language when none provided", async () => {
setMockInvokeResult("capture_clipboard", mockEntry);
setMockInvokeResult("list_clipboard_entries", [mockEntry]);
const result = await clipboardStore.captureClipboard("const x = 1;");
expect(result).toEqual(mockEntry);
});
it("returns null on error", async () => {
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
setMockInvokeResult("capture_clipboard", new Error("Failed"));
const result = await clipboardStore.captureClipboard("const x = 1;");
expect(result).toBeNull();
consoleErrorSpy.mockRestore();
});
});
describe("deleteEntry", () => {
it("removes entry from store on success", async () => {
clipboardStore.entries.set([mockEntry, { ...mockEntry, id: "entry-2" }]);
setMockInvokeResult("delete_clipboard_entry", undefined);
await clipboardStore.deleteEntry("entry-1");
expect(get(clipboardStore.entries)).toHaveLength(1);
expect(get(clipboardStore.entries)[0].id).toBe("entry-2");
});
it("handles errors gracefully", async () => {
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
setMockInvokeResult("delete_clipboard_entry", new Error("Failed"));
await clipboardStore.deleteEntry("entry-1");
expect(consoleErrorSpy).toHaveBeenCalledWith(
"Failed to delete clipboard entry:",
expect.any(Error)
);
consoleErrorSpy.mockRestore();
});
});
describe("togglePin", () => {
it("updates entry pin status and sorts pinned first", async () => {
const unpinned1 = { ...mockEntry, id: "entry-1", is_pinned: false };
const unpinned2 = { ...mockEntry, id: "entry-2", is_pinned: false };
clipboardStore.entries.set([unpinned1, unpinned2]);
const pinned = { ...unpinned2, is_pinned: true };
setMockInvokeResult("toggle_pin_clipboard_entry", pinned);
await clipboardStore.togglePin("entry-2");
const entries = get(clipboardStore.entries);
expect(entries[0].id).toBe("entry-2");
expect(entries[0].is_pinned).toBe(true);
});
it("handles errors gracefully", async () => {
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
setMockInvokeResult("toggle_pin_clipboard_entry", new Error("Failed"));
await clipboardStore.togglePin("entry-1");
expect(consoleErrorSpy).toHaveBeenCalledWith("Failed to toggle pin:", expect.any(Error));
consoleErrorSpy.mockRestore();
});
it("exercises the unpinned-before-pinned sort branch", async () => {
// Three entries so sort compares (entry-1 unpinned, entry-2 pinned) — hits line 144
const e1 = { ...mockEntry, id: "e1", is_pinned: false };
const e2 = { ...mockEntry, id: "e2", is_pinned: false };
const e3 = { ...mockEntry, id: "e3", is_pinned: false };
clipboardStore.entries.set([e1, e2, e3]);
const pinned = { ...e2, is_pinned: true };
setMockInvokeResult("toggle_pin_clipboard_entry", pinned);
await clipboardStore.togglePin("e2");
expect(get(clipboardStore.entries)[0].id).toBe("e2");
});
it("sorts same-pin-status entries by timestamp descending", async () => {
// Toggle entry to pinned while two others remain unpinned — sort compares unpinned pair by timestamp
const older = {
...mockEntry,
id: "older",
is_pinned: false,
timestamp: "2026-01-01T00:00:00.000Z",
};
const newer = {
...mockEntry,
id: "newer",
is_pinned: false,
timestamp: "2026-01-02T00:00:00.000Z",
};
const pinned = {
...mockEntry,
id: "pinned",
is_pinned: false,
timestamp: "2026-01-03T00:00:00.000Z",
};
clipboardStore.entries.set([older, newer, pinned]);
const pinnedResult = { ...pinned, is_pinned: true };
setMockInvokeResult("toggle_pin_clipboard_entry", pinnedResult);
await clipboardStore.togglePin("pinned");
const entries = get(clipboardStore.entries);
expect(entries[0].id).toBe("pinned");
expect(entries[1].id).toBe("newer");
expect(entries[2].id).toBe("older");
});
});
describe("clearHistory", () => {
it("removes unpinned entries and keeps pinned ones", async () => {
const pinned = { ...mockEntry, id: "pinned", is_pinned: true };
const unpinned = { ...mockEntry, id: "unpinned", is_pinned: false };
clipboardStore.entries.set([pinned, unpinned]);
setMockInvokeResult("clear_clipboard_history", undefined);
await clipboardStore.clearHistory();
const entries = get(clipboardStore.entries);
expect(entries).toHaveLength(1);
expect(entries[0].id).toBe("pinned");
});
it("handles errors gracefully", async () => {
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
setMockInvokeResult("clear_clipboard_history", new Error("Failed"));
await clipboardStore.clearHistory();
expect(consoleErrorSpy).toHaveBeenCalledWith(
"Failed to clear clipboard history:",
expect.any(Error)
);
consoleErrorSpy.mockRestore();
});
});
describe("updateLanguage", () => {
it("updates entry language in the store", async () => {
clipboardStore.entries.set([mockEntry]);
const updated = { ...mockEntry, language: "javascript" };
setMockInvokeResult("update_clipboard_language", updated);
await clipboardStore.updateLanguage("entry-1", "javascript");
expect(get(clipboardStore.entries)[0].language).toBe("javascript");
});
it("leaves non-matching entries unchanged", async () => {
const other = { ...mockEntry, id: "other", language: "python" };
clipboardStore.entries.set([mockEntry, other]);
const updated = { ...mockEntry, language: "javascript" };
setMockInvokeResult("update_clipboard_language", updated);
await clipboardStore.updateLanguage("entry-1", "javascript");
const entries = get(clipboardStore.entries);
expect(entries[1].language).toBe("python");
});
it("handles errors gracefully", async () => {
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
setMockInvokeResult("update_clipboard_language", new Error("Failed"));
await clipboardStore.updateLanguage("entry-1", "javascript");
expect(consoleErrorSpy).toHaveBeenCalledWith("Failed to update language:", expect.any(Error));
consoleErrorSpy.mockRestore();
});
});
describe("copyToClipboard", () => {
it("returns true and copies text on success", async () => {
Object.assign(navigator, {
clipboard: { writeText: vi.fn().mockResolvedValue(undefined) },
});
const result = await clipboardStore.copyToClipboard("hello world");
expect(result).toBe(true);
expect(navigator.clipboard.writeText).toHaveBeenCalledWith("hello world");
});
it("returns false and logs error on failure", async () => {
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
Object.assign(navigator, {
clipboard: { writeText: vi.fn().mockRejectedValue(new Error("Clipboard denied")) },
});
const result = await clipboardStore.copyToClipboard("hello world");
expect(result).toBe(false);
expect(consoleErrorSpy).toHaveBeenCalledWith(
"Failed to copy to clipboard:",
expect.any(Error)
);
consoleErrorSpy.mockRestore();
});
});
});
+647 -1
View File
@@ -1,12 +1,20 @@
import { describe, it, expect, beforeEach, vi } from "vitest"; import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { get } from "svelte/store";
import { import {
configStore, configStore,
maskPaths, maskPaths,
clampFontSize, clampFontSize,
applyFontSize, applyFontSize,
applyCustomFont,
applyCustomUiFont,
applyTheme, applyTheme,
applyCustomThemeColors, applyCustomThemeColors,
clearCustomThemeColors, clearCustomThemeColors,
isDarkTheme,
isStreamerMode,
isCompactMode,
shouldHidePaths,
showThinkingBlocks,
MIN_FONT_SIZE, MIN_FONT_SIZE,
MAX_FONT_SIZE, MAX_FONT_SIZE,
DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE,
@@ -15,12 +23,17 @@ import {
type CustomThemeColors, type CustomThemeColors,
} from "./config"; } from "./config";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { readFile } from "@tauri-apps/plugin-fs";
// Mock Tauri APIs // Mock Tauri APIs
vi.mock("@tauri-apps/api/core", () => ({ vi.mock("@tauri-apps/api/core", () => ({
invoke: vi.fn(), invoke: vi.fn(),
})); }));
vi.mock("@tauri-apps/plugin-fs", () => ({
readFile: vi.fn(),
}));
describe("config store", () => { describe("config store", () => {
describe("font size constants", () => { describe("font size constants", () => {
it("has correct MIN_FONT_SIZE", () => { it("has correct MIN_FONT_SIZE", () => {
@@ -194,6 +207,16 @@ describe("config store", () => {
budget_warning_threshold: 0.8, budget_warning_threshold: 0.8,
discord_rpc_enabled: true, discord_rpc_enabled: true,
show_thinking_blocks: true, show_thinking_blocks: true,
use_worktree: false,
disable_1m_context: false,
max_output_tokens: null,
trusted_workspaces: [],
background_image_path: null,
background_image_opacity: 0.3,
custom_font_path: null,
custom_font_family: null,
custom_ui_font_path: null,
custom_ui_font_family: null,
}; };
expect(config.model).toBe("claude-sonnet-4"); expect(config.model).toBe("claude-sonnet-4");
@@ -240,6 +263,16 @@ describe("config store", () => {
budget_warning_threshold: 0.8, budget_warning_threshold: 0.8,
discord_rpc_enabled: true, discord_rpc_enabled: true,
show_thinking_blocks: true, show_thinking_blocks: true,
use_worktree: false,
disable_1m_context: false,
max_output_tokens: null,
trusted_workspaces: [],
background_image_path: null,
background_image_opacity: 0.3,
custom_font_path: null,
custom_font_family: null,
custom_ui_font_path: null,
custom_ui_font_family: null,
}; };
expect(config.model).toBeNull(); expect(config.model).toBeNull();
@@ -785,6 +818,16 @@ describe("config store", () => {
budget_warning_threshold: 0.9, budget_warning_threshold: 0.9,
discord_rpc_enabled: false, discord_rpc_enabled: false,
show_thinking_blocks: true, show_thinking_blocks: true,
use_worktree: false,
disable_1m_context: false,
max_output_tokens: null,
trusted_workspaces: [],
background_image_path: null,
background_image_opacity: 0.3,
custom_font_path: null,
custom_font_family: null,
custom_ui_font_path: null,
custom_ui_font_family: null,
}; };
const mockInvokeImpl = vi.mocked(invoke); const mockInvokeImpl = vi.mocked(invoke);
@@ -825,4 +868,607 @@ describe("config store", () => {
expect(lastSaved.font_size).toBe(16); expect(lastSaved.font_size).toBe(16);
}); });
}); });
describe("loadConfig error path", () => {
it("resets to default config and logs error when loadConfig fails", async () => {
vi.mocked(invoke).mockResolvedValue(null);
await configStore.updateConfig({ theme: "light" });
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
vi.mocked(invoke).mockRejectedValue(new Error("Backend unavailable"));
await configStore.loadConfig();
expect(configStore.getConfig().theme).toBe("dark");
expect(consoleErrorSpy).toHaveBeenCalledWith("Failed to load config:", expect.any(Error));
consoleErrorSpy.mockRestore();
});
});
describe("configStore sidebar methods", () => {
it("openSidebar sets isSidebarOpen to true", () => {
configStore.closeSidebar();
configStore.openSidebar();
expect(get(configStore.isSidebarOpen)).toBe(true);
});
it("closeSidebar sets isSidebarOpen to false", () => {
configStore.openSidebar();
configStore.closeSidebar();
expect(get(configStore.isSidebarOpen)).toBe(false);
});
it("toggleSidebar switches from false to true", () => {
configStore.closeSidebar();
configStore.toggleSidebar();
expect(get(configStore.isSidebarOpen)).toBe(true);
});
it("toggleSidebar switches from true to false", () => {
configStore.openSidebar();
configStore.toggleSidebar();
expect(get(configStore.isSidebarOpen)).toBe(false);
});
});
describe("configStore setTheme method", () => {
beforeEach(async () => {
vi.mocked(invoke).mockResolvedValue(null);
});
afterEach(() => {
vi.resetAllMocks();
});
it("setTheme updates the theme via invoke", async () => {
await configStore.setTheme("light");
expect(configStore.getConfig().theme).toBe("light");
});
it("setTheme with custom colors updates custom_theme_colors", async () => {
const colors: CustomThemeColors = {
bg_primary: "#001122",
bg_secondary: null,
bg_terminal: null,
accent_primary: null,
accent_secondary: null,
text_primary: null,
text_secondary: null,
border_color: null,
};
await configStore.setTheme("custom", colors);
expect(configStore.getConfig().theme).toBe("custom");
expect(configStore.getConfig().custom_theme_colors.bg_primary).toBe("#001122");
});
});
describe("configStore setCustomThemeColors with custom theme active", () => {
beforeEach(async () => {
vi.mocked(invoke).mockResolvedValue(null);
await configStore.setTheme("custom");
});
afterEach(() => {
vi.resetAllMocks();
clearCustomThemeColors();
});
it("applies custom colors to DOM when current theme is custom", async () => {
const colors: CustomThemeColors = {
bg_primary: "#aabbcc",
bg_secondary: null,
bg_terminal: null,
accent_primary: null,
accent_secondary: null,
text_primary: null,
text_secondary: null,
border_color: null,
};
await configStore.setCustomThemeColors(colors);
expect(document.documentElement.style.getPropertyValue("--bg-primary")).toBe("#aabbcc");
});
});
describe("configStore font size methods", () => {
beforeEach(async () => {
vi.mocked(invoke).mockResolvedValue(null);
await configStore.updateConfig({ font_size: DEFAULT_FONT_SIZE });
vi.resetAllMocks();
vi.mocked(invoke).mockResolvedValue(null);
});
afterEach(() => {
vi.resetAllMocks();
});
it("setFontSize updates to the given value", async () => {
await configStore.setFontSize(18);
expect(configStore.getConfig().font_size).toBe(18);
});
it("setFontSize clamps to minimum", async () => {
await configStore.setFontSize(1);
expect(configStore.getConfig().font_size).toBe(MIN_FONT_SIZE);
});
it("setFontSize clamps to maximum", async () => {
await configStore.setFontSize(99);
expect(configStore.getConfig().font_size).toBe(MAX_FONT_SIZE);
});
it("increaseFontSize increases font size by 2", async () => {
await configStore.increaseFontSize();
expect(configStore.getConfig().font_size).toBe(DEFAULT_FONT_SIZE + 2);
});
it("increaseFontSize does not exceed maximum", async () => {
await configStore.setFontSize(MAX_FONT_SIZE);
await configStore.increaseFontSize();
expect(configStore.getConfig().font_size).toBe(MAX_FONT_SIZE);
});
it("decreaseFontSize decreases font size by 2", async () => {
await configStore.decreaseFontSize();
expect(configStore.getConfig().font_size).toBe(DEFAULT_FONT_SIZE - 2);
});
it("decreaseFontSize does not go below minimum", async () => {
await configStore.setFontSize(MIN_FONT_SIZE);
await configStore.decreaseFontSize();
expect(configStore.getConfig().font_size).toBe(MIN_FONT_SIZE);
});
it("resetFontSize restores the default font size", async () => {
await configStore.setFontSize(20);
await configStore.resetFontSize();
expect(configStore.getConfig().font_size).toBe(DEFAULT_FONT_SIZE);
});
});
describe("configStore removeAutoGrantedTool", () => {
beforeEach(async () => {
vi.mocked(invoke).mockResolvedValue(null);
await configStore.updateConfig({ auto_granted_tools: [] });
vi.resetAllMocks();
vi.mocked(invoke).mockResolvedValue(null);
});
afterEach(() => {
vi.resetAllMocks();
});
it("removes an existing tool", async () => {
await configStore.addAutoGrantedTool("Bash");
await configStore.removeAutoGrantedTool("Bash");
expect(configStore.getConfig().auto_granted_tools).not.toContain("Bash");
});
it("is a no-op when the tool is not in the list", async () => {
await configStore.removeAutoGrantedTool("NonExistentTool");
expect(configStore.getConfig().auto_granted_tools).toEqual([]);
});
});
describe("configStore toggle methods", () => {
beforeEach(async () => {
vi.mocked(invoke).mockResolvedValue(null);
await configStore.updateConfig({ streamer_mode: false, compact_mode: false });
vi.resetAllMocks();
vi.mocked(invoke).mockResolvedValue(null);
});
afterEach(() => {
vi.resetAllMocks();
});
it("toggleStreamerMode flips streamer_mode from false to true", async () => {
await configStore.toggleStreamerMode();
expect(configStore.getConfig().streamer_mode).toBe(true);
});
it("toggleStreamerMode flips streamer_mode from true to false", async () => {
await configStore.updateConfig({ streamer_mode: true });
await configStore.toggleStreamerMode();
expect(configStore.getConfig().streamer_mode).toBe(false);
});
it("toggleCompactMode flips compact_mode from false to true", async () => {
await configStore.toggleCompactMode();
expect(configStore.getConfig().compact_mode).toBe(true);
});
it("toggleCompactMode flips compact_mode from true to false", async () => {
await configStore.updateConfig({ compact_mode: true });
await configStore.toggleCompactMode();
expect(configStore.getConfig().compact_mode).toBe(false);
});
it("setCompactMode enables compact mode", async () => {
await configStore.setCompactMode(true);
expect(configStore.getConfig().compact_mode).toBe(true);
});
it("setCompactMode disables compact mode", async () => {
await configStore.updateConfig({ compact_mode: true });
await configStore.setCompactMode(false);
expect(configStore.getConfig().compact_mode).toBe(false);
});
});
describe("derived stores (live subscriptions)", () => {
beforeEach(async () => {
vi.mocked(invoke).mockResolvedValue(null);
});
afterEach(() => {
vi.resetAllMocks();
});
it("isDarkTheme is true when theme is dark", async () => {
await configStore.updateConfig({ theme: "dark" });
expect(get(isDarkTheme)).toBe(true);
});
it("isDarkTheme is false when theme is not dark", async () => {
await configStore.updateConfig({ theme: "light" });
expect(get(isDarkTheme)).toBe(false);
});
it("isStreamerMode reflects streamer_mode config", async () => {
await configStore.updateConfig({ streamer_mode: true });
expect(get(isStreamerMode)).toBe(true);
await configStore.updateConfig({ streamer_mode: false });
expect(get(isStreamerMode)).toBe(false);
});
it("isCompactMode reflects compact_mode config", async () => {
await configStore.updateConfig({ compact_mode: true });
expect(get(isCompactMode)).toBe(true);
await configStore.updateConfig({ compact_mode: false });
expect(get(isCompactMode)).toBe(false);
});
it("shouldHidePaths is true when both streamer flags are enabled", async () => {
await configStore.updateConfig({ streamer_mode: true, streamer_hide_paths: true });
expect(get(shouldHidePaths)).toBe(true);
});
it("shouldHidePaths is false when streamer_mode is disabled", async () => {
await configStore.updateConfig({ streamer_mode: false, streamer_hide_paths: true });
expect(get(shouldHidePaths)).toBe(false);
});
it("showThinkingBlocks is true when show_thinking_blocks is enabled", async () => {
await configStore.updateConfig({ show_thinking_blocks: true });
expect(get(showThinkingBlocks)).toBe(true);
});
it("showThinkingBlocks is false when show_thinking_blocks is disabled", async () => {
await configStore.updateConfig({ show_thinking_blocks: false });
expect(get(showThinkingBlocks)).toBe(false);
});
});
describe("applyCustomFont", () => {
const readFileMock = vi.mocked(readFile);
beforeEach(() => {
// Remove any style element left by previous tests
document.getElementById("hikari-custom-font")?.remove();
document.documentElement.style.removeProperty("--terminal-font-family");
readFileMock.mockReset();
});
it("removes CSS variable when both path and family are null", async () => {
document.documentElement.style.setProperty("--terminal-font-family", "'SomeFont', monospace");
await applyCustomFont(null, null);
expect(document.documentElement.style.getPropertyValue("--terminal-font-family")).toBe("");
});
it("removes CSS variable when both path and family are empty strings", async () => {
document.documentElement.style.setProperty("--terminal-font-family", "'SomeFont', monospace");
await applyCustomFont("", "");
expect(document.documentElement.style.getPropertyValue("--terminal-font-family")).toBe("");
expect(document.getElementById("hikari-custom-font")).toBeNull();
});
it("injects @import for a CSS stylesheet URL", async () => {
await applyCustomFont("https://fonts.googleapis.com/css2?family=Fira+Code", "Fira Code");
const style = document.getElementById("hikari-custom-font");
expect(style).not.toBeNull();
expect(style?.textContent).toContain(
"@import url('https://fonts.googleapis.com/css2?family=Fira+Code')"
);
expect(document.documentElement.style.getPropertyValue("--terminal-font-family")).toBe(
"'Fira Code', monospace"
);
});
it("injects @font-face for a direct font file URL (.woff2)", async () => {
await applyCustomFont("https://example.com/fonts/myfont.woff2", "MyFont");
const style = document.getElementById("hikari-custom-font");
expect(style).not.toBeNull();
expect(style?.textContent).toContain("@font-face");
expect(style?.textContent).toContain("url('https://example.com/fonts/myfont.woff2')");
expect(style?.textContent).toContain("'MyFont'");
expect(document.documentElement.style.getPropertyValue("--terminal-font-family")).toBe(
"'MyFont', monospace"
);
});
it("injects @font-face for a direct font file URL (.ttf)", async () => {
await applyCustomFont("https://example.com/fonts/myfont.ttf", "MyTtfFont");
const style = document.getElementById("hikari-custom-font");
expect(style).not.toBeNull();
expect(style?.textContent).toContain("@font-face");
expect(style?.textContent).toContain("url('https://example.com/fonts/myfont.ttf')");
expect(style?.textContent).toContain("'MyTtfFont'");
});
it("uses HikariCustomFont as fallback family for direct font URLs when family is empty", async () => {
await applyCustomFont("https://example.com/fonts/myfont.woff2", "");
const style = document.getElementById("hikari-custom-font");
expect(style?.textContent).toContain("'HikariCustomFont'");
});
it("reads local file and injects @font-face with data URL", async () => {
const fakeData = new Uint8Array([72, 101, 108, 108, 111]); // "Hello"
readFileMock.mockResolvedValueOnce(fakeData);
await applyCustomFont("/home/naomi/.fonts/MyFont.ttf", "MyFont");
expect(readFileMock).toHaveBeenCalledWith("/home/naomi/.fonts/MyFont.ttf");
const style = document.getElementById("hikari-custom-font");
expect(style).not.toBeNull();
expect(style?.textContent).toContain("@font-face");
expect(style?.textContent).toContain("data:font/ttf;base64,");
expect(style?.textContent).toContain("'MyFont'");
expect(document.documentElement.style.getPropertyValue("--terminal-font-family")).toBe(
"'MyFont', monospace"
);
});
it("uses HikariCustomFont as fallback family for local files when family is empty", async () => {
const fakeData = new Uint8Array([1, 2, 3]);
readFileMock.mockResolvedValueOnce(fakeData);
await applyCustomFont("/home/naomi/.fonts/MyFont.woff2", "");
const style = document.getElementById("hikari-custom-font");
expect(style?.textContent).toContain("'HikariCustomFont'");
expect(style?.textContent).toContain("font/woff2");
});
it("sets CSS variable when only family is provided (no path)", async () => {
await applyCustomFont("", "SystemFont");
expect(document.documentElement.style.getPropertyValue("--terminal-font-family")).toBe(
"'SystemFont', monospace"
);
expect(document.getElementById("hikari-custom-font")).toBeNull();
});
it("replaces a previously injected style element", async () => {
await applyCustomFont("https://fonts.googleapis.com/css2?family=Fira+Code", "Fira Code");
expect(document.getElementById("hikari-custom-font")).not.toBeNull();
await applyCustomFont("https://fonts.googleapis.com/css2?family=Roboto+Mono", "Roboto Mono");
const styles = document.querySelectorAll("#hikari-custom-font");
expect(styles.length).toBe(1);
expect(styles[0].textContent).toContain("Roboto+Mono");
});
it("uses correct MIME type for .otf local files", async () => {
readFileMock.mockResolvedValueOnce(new Uint8Array([1]));
await applyCustomFont("/fonts/MyFont.otf", "OtfFont");
const style = document.getElementById("hikari-custom-font");
expect(style?.textContent).toContain("font/otf");
});
it("falls back to font/ttf MIME for unknown extension local files", async () => {
readFileMock.mockResolvedValueOnce(new Uint8Array([1]));
await applyCustomFont("/fonts/MyFont.xyz", "XyzFont");
const style = document.getElementById("hikari-custom-font");
expect(style?.textContent).toContain("font/ttf");
});
});
describe("setCustomFont", () => {
const readFileMock = vi.mocked(readFile);
const invokeMock = vi.mocked(invoke);
beforeEach(() => {
document.getElementById("hikari-custom-font")?.remove();
document.documentElement.style.removeProperty("--terminal-font-family");
readFileMock.mockReset();
invokeMock.mockResolvedValue(undefined);
});
it("saves config and applies the font", async () => {
await configStore.setCustomFont(null, null);
await configStore.setCustomFont(
"https://fonts.googleapis.com/css2?family=Fira+Code",
"Fira Code"
);
expect(invokeMock).toHaveBeenCalledWith(
"save_config",
expect.objectContaining({
config: expect.objectContaining({
custom_font_path: "https://fonts.googleapis.com/css2?family=Fira+Code",
custom_font_family: "Fira Code",
}),
})
);
expect(document.documentElement.style.getPropertyValue("--terminal-font-family")).toBe(
"'Fira Code', monospace"
);
});
it("clears font when called with nulls", async () => {
document.documentElement.style.setProperty("--terminal-font-family", "'SomeFont', monospace");
await configStore.setCustomFont(null, null);
expect(document.documentElement.style.getPropertyValue("--terminal-font-family")).toBe("");
expect(invokeMock).toHaveBeenCalledWith(
"save_config",
expect.objectContaining({
config: expect.objectContaining({
custom_font_path: null,
custom_font_family: null,
}),
})
);
});
});
describe("applyCustomUiFont", () => {
const readFileMock = vi.mocked(readFile);
beforeEach(() => {
document.getElementById("hikari-custom-ui-font")?.remove();
document.documentElement.style.removeProperty("--ui-font-family");
document.body.style.removeProperty("font-family");
readFileMock.mockReset();
});
it("removes CSS variable and body font-family when both path and family are null", async () => {
document.documentElement.style.setProperty("--ui-font-family", "'SomeFont', sans-serif");
document.body.style.setProperty("font-family", "'SomeFont', sans-serif");
await applyCustomUiFont(null, null);
expect(document.documentElement.style.getPropertyValue("--ui-font-family")).toBe("");
expect(document.body.style.getPropertyValue("font-family")).toBe("");
});
it("removes CSS variable and body font-family when both path and family are empty strings", async () => {
document.documentElement.style.setProperty("--ui-font-family", "'SomeFont', sans-serif");
document.body.style.setProperty("font-family", "'SomeFont', sans-serif");
await applyCustomUiFont("", "");
expect(document.documentElement.style.getPropertyValue("--ui-font-family")).toBe("");
expect(document.body.style.getPropertyValue("font-family")).toBe("");
expect(document.getElementById("hikari-custom-ui-font")).toBeNull();
});
it("injects @import for a CSS stylesheet URL and applies font to body", async () => {
await applyCustomUiFont("https://fonts.googleapis.com/css2?family=Inter", "Inter");
const style = document.getElementById("hikari-custom-ui-font");
expect(style).not.toBeNull();
expect(style?.textContent).toContain(
"@import url('https://fonts.googleapis.com/css2?family=Inter')"
);
expect(document.documentElement.style.getPropertyValue("--ui-font-family")).toBe(
"'Inter', sans-serif"
);
expect(document.body.style.getPropertyValue("font-family")).toBe('"Inter", sans-serif');
});
it("injects @font-face for a direct font file URL (.woff2) and applies font to body", async () => {
await applyCustomUiFont("https://example.com/fonts/Inter.woff2", "Inter");
const style = document.getElementById("hikari-custom-ui-font");
expect(style).not.toBeNull();
expect(style?.textContent).toContain("@font-face");
expect(style?.textContent).toContain("url('https://example.com/fonts/Inter.woff2')");
expect(document.documentElement.style.getPropertyValue("--ui-font-family")).toBe(
"'Inter', sans-serif"
);
expect(document.body.style.getPropertyValue("font-family")).toBe('"Inter", sans-serif');
});
it("uses HikariCustomUiFont as fallback family and applies it to body", async () => {
await applyCustomUiFont("https://example.com/fonts/Inter.woff2", "");
const style = document.getElementById("hikari-custom-ui-font");
expect(style?.textContent).toContain("'HikariCustomUiFont'");
expect(document.body.style.getPropertyValue("font-family")).toBe(
'"HikariCustomUiFont", sans-serif'
);
});
it("reads local file and embeds as data URL, applies font to body", async () => {
const fakeData = new Uint8Array([104, 101, 108, 108, 111]);
readFileMock.mockResolvedValueOnce(fakeData);
await applyCustomUiFont("/home/naomi/.fonts/Inter.ttf", "Inter");
expect(readFileMock).toHaveBeenCalledWith("/home/naomi/.fonts/Inter.ttf");
const style = document.getElementById("hikari-custom-ui-font");
expect(style?.textContent).toContain("data:font/ttf;base64,");
expect(document.documentElement.style.getPropertyValue("--ui-font-family")).toBe(
"'Inter', sans-serif"
);
expect(document.body.style.getPropertyValue("font-family")).toBe('"Inter", sans-serif');
});
it("sets CSS variable and body font-family when only family is provided (no path)", async () => {
await applyCustomUiFont("", "SystemUiFont");
expect(document.documentElement.style.getPropertyValue("--ui-font-family")).toBe(
"'SystemUiFont', sans-serif"
);
expect(document.body.style.getPropertyValue("font-family")).toBe(
'"SystemUiFont", sans-serif'
);
});
it("replaces a previously injected style element", async () => {
await applyCustomUiFont("https://fonts.googleapis.com/css2?family=Inter", "Inter");
expect(document.getElementById("hikari-custom-ui-font")).not.toBeNull();
await applyCustomUiFont("https://fonts.googleapis.com/css2?family=Roboto", "Roboto");
const styles = document.querySelectorAll("#hikari-custom-ui-font");
expect(styles.length).toBe(1);
expect(styles[0].textContent).toContain("Roboto");
});
});
describe("setCustomUiFont", () => {
const readFileMock = vi.mocked(readFile);
const invokeMock = vi.mocked(invoke);
beforeEach(() => {
document.getElementById("hikari-custom-ui-font")?.remove();
document.documentElement.style.removeProperty("--ui-font-family");
document.body.style.removeProperty("font-family");
readFileMock.mockReset();
invokeMock.mockResolvedValue(undefined);
});
it("saves config and applies the UI font", async () => {
await configStore.setCustomUiFont(null, null);
await configStore.setCustomUiFont("https://fonts.googleapis.com/css2?family=Inter", "Inter");
expect(invokeMock).toHaveBeenCalledWith(
"save_config",
expect.objectContaining({
config: expect.objectContaining({
custom_ui_font_path: "https://fonts.googleapis.com/css2?family=Inter",
custom_ui_font_family: "Inter",
}),
})
);
expect(document.documentElement.style.getPropertyValue("--ui-font-family")).toBe(
"'Inter', sans-serif"
);
expect(document.body.style.getPropertyValue("font-family")).toBe('"Inter", sans-serif');
});
it("clears UI font when called with nulls", async () => {
document.documentElement.style.setProperty("--ui-font-family", "'SomeFont', sans-serif");
document.body.style.setProperty("font-family", "'SomeFont', sans-serif");
await configStore.setCustomUiFont(null, null);
expect(document.documentElement.style.getPropertyValue("--ui-font-family")).toBe("");
expect(document.body.style.getPropertyValue("font-family")).toBe("");
expect(invokeMock).toHaveBeenCalledWith(
"save_config",
expect.objectContaining({
config: expect.objectContaining({
custom_ui_font_path: null,
custom_ui_font_family: null,
}),
})
);
});
});
}); });
+131
View File
@@ -1,5 +1,6 @@
import { writable, derived } from "svelte/store"; import { writable, derived } from "svelte/store";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { readFile } from "@tauri-apps/plugin-fs";
export type Theme = "dark" | "light" | "high-contrast" | "custom"; export type Theme = "dark" | "light" | "high-contrast" | "custom";
export type BudgetAction = "warn" | "block"; export type BudgetAction = "warn" | "block";
@@ -47,6 +48,23 @@ export interface HikariConfig {
discord_rpc_enabled: boolean; discord_rpc_enabled: boolean;
// Thinking blocks settings // Thinking blocks settings
show_thinking_blocks: boolean; show_thinking_blocks: boolean;
// Worktree isolation
use_worktree: boolean;
// Disable 1M context window
disable_1m_context: boolean;
// Max output tokens for Claude Code responses
max_output_tokens: number | null;
// Workspaces the user has explicitly trusted
trusted_workspaces: string[];
// Background image settings
background_image_path: string | null;
background_image_opacity: number;
// Custom terminal font settings
custom_font_path: string | null;
custom_font_family: string | null;
// Custom UI font settings
custom_ui_font_path: string | null;
custom_ui_font_family: string | null;
} }
const defaultConfig: HikariConfig = { const defaultConfig: HikariConfig = {
@@ -87,6 +105,16 @@ const defaultConfig: HikariConfig = {
budget_warning_threshold: 0.8, budget_warning_threshold: 0.8,
discord_rpc_enabled: true, discord_rpc_enabled: true,
show_thinking_blocks: true, show_thinking_blocks: true,
use_worktree: false,
disable_1m_context: false,
max_output_tokens: null,
trusted_workspaces: [],
background_image_path: null,
background_image_opacity: 0.3,
custom_font_path: null,
custom_font_family: null,
custom_ui_font_path: null,
custom_ui_font_family: null,
}; };
function createConfigStore() { function createConfigStore() {
@@ -223,6 +251,16 @@ function createConfigStore() {
setCompactMode: async (enabled: boolean) => { setCompactMode: async (enabled: boolean) => {
await updateConfig({ compact_mode: enabled }); await updateConfig({ compact_mode: enabled });
}, },
setCustomFont: async (path: string | null, family: string | null) => {
await updateConfig({ custom_font_path: path, custom_font_family: family });
await applyCustomFont(path, family);
},
setCustomUiFont: async (path: string | null, family: string | null) => {
await updateConfig({ custom_ui_font_path: path, custom_ui_font_family: family });
await applyCustomUiFont(path, family);
},
}; };
} }
@@ -288,6 +326,99 @@ export function clampFontSize(size: number): number {
return Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, size)); return Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, size));
} }
const DIRECT_FONT_EXTENSIONS = new Set(["woff", "woff2", "ttf", "otf", "eot"]);
const FONT_MIME_MAP: Record<string, string> = {
woff: "font/woff",
woff2: "font/woff2",
ttf: "font/ttf",
otf: "font/otf",
eot: "application/vnd.ms-fontobject",
};
async function applyFontFromSource(path: string, family: string, styleId: string): Promise<void> {
const style = document.createElement("style");
style.id = styleId;
if (path.startsWith("http://") || path.startsWith("https://")) {
const ext = path.split(".").pop()?.toLowerCase() ?? "";
if (DIRECT_FONT_EXTENSIONS.has(ext)) {
style.textContent = `@font-face { font-family: '${family}'; src: url('${path}'); }`;
} else {
style.textContent = `@import url('${path}');`;
}
} else {
const data = await readFile(path);
const chunks: string[] = [];
const chunkSize = 8192;
for (let i = 0; i < data.length; i += chunkSize) {
chunks.push(String.fromCharCode(...data.slice(i, i + chunkSize)));
}
const ext = path.split(".").pop()?.toLowerCase() ?? "ttf";
const mime = FONT_MIME_MAP[ext] ?? "font/ttf";
const dataUrl = `data:${mime};base64,${btoa(chunks.join(""))}`;
style.textContent = `@font-face { font-family: '${family}'; src: url('${dataUrl}'); }`;
}
document.head.appendChild(style);
}
export async function applyCustomFont(path: string | null, family: string | null): Promise<void> {
if (typeof document === "undefined") return;
const styleId = "hikari-custom-font";
const cssVar = "--terminal-font-family";
const fallbackFamily = "HikariCustomFont";
document.getElementById(styleId)?.remove();
const trimmedPath = path?.trim() ?? "";
const trimmedFamily = family?.trim() ?? "";
if (!trimmedPath && !trimmedFamily) {
document.documentElement.style.removeProperty(cssVar);
return;
}
if (trimmedPath) {
await applyFontFromSource(trimmedPath, trimmedFamily || fallbackFamily, styleId);
}
if (trimmedFamily) {
document.documentElement.style.setProperty(cssVar, `'${trimmedFamily}', monospace`);
}
}
export async function applyCustomUiFont(path: string | null, family: string | null): Promise<void> {
if (typeof document === "undefined") return;
const styleId = "hikari-custom-ui-font";
const cssVar = "--ui-font-family";
const fallbackFamily = "HikariCustomUiFont";
document.getElementById(styleId)?.remove();
const trimmedPath = path?.trim() ?? "";
const trimmedFamily = family?.trim() ?? "";
if (!trimmedPath && !trimmedFamily) {
document.documentElement.style.removeProperty(cssVar);
document.body?.style.removeProperty("font-family");
return;
}
const effectiveFamily = trimmedFamily || fallbackFamily;
if (trimmedPath) {
await applyFontFromSource(trimmedPath, effectiveFamily, styleId);
}
const fontValue = `'${effectiveFamily}', sans-serif`;
document.documentElement.style.setProperty(cssVar, fontValue);
document.body?.style.setProperty("font-family", fontValue);
}
export { MIN_FONT_SIZE, MAX_FONT_SIZE, DEFAULT_FONT_SIZE }; export { MIN_FONT_SIZE, MAX_FONT_SIZE, DEFAULT_FONT_SIZE };
export const configStore = createConfigStore(); export const configStore = createConfigStore();
+38
View File
@@ -523,3 +523,41 @@ describe("pending retry message", () => {
expect(pendingRetryMessage).toBeNull(); expect(pendingRetryMessage).toBeNull();
}); });
}); });
describe("draft text persistence", () => {
it("initialises draft text as empty string", () => {
const conversation = { draftText: "" };
expect(conversation.draftText).toBe("");
});
it("stores draft text per conversation", () => {
const conversations = new Map([
["conv-1", { draftText: "Hello world" }],
["conv-2", { draftText: "" }],
]);
expect(conversations.get("conv-1")?.draftText).toBe("Hello world");
expect(conversations.get("conv-2")?.draftText).toBe("");
});
it("updates draft text independently per conversation", () => {
const conversations = new Map([
["conv-1", { draftText: "Draft A" }],
["conv-2", { draftText: "Draft B" }],
]);
const convA = conversations.get("conv-1");
if (convA) convA.draftText = "Updated A";
expect(conversations.get("conv-1")?.draftText).toBe("Updated A");
expect(conversations.get("conv-2")?.draftText).toBe("Draft B");
});
it("clears draft text after submission", () => {
const conversation = { draftText: "My prompt" };
conversation.draftText = "";
expect(conversation.draftText).toBe("");
});
});
+155 -6
View File
@@ -37,6 +37,92 @@ export interface Conversation {
attachments: Attachment[]; attachments: Attachment[];
summary: ConversationSummary | null; summary: ConversationSummary | null;
startedAt: Date; startedAt: Date;
taskStartTime: number | null;
successSoundFired: boolean;
taskStartSoundFired: boolean;
draftText: string;
}
const TAB_NAMES = [
// Cosmic & celestial
"Starfall",
"Moonbeam",
"Nebula",
"Aurora",
"Stardust",
"Solstice",
"Comet",
"Eclipse",
"Zenith",
"Celestia",
"Nova",
"Quasar",
"Lyra",
"Andromeda",
"Twilight",
// Magical & fantastical
"Camelot",
"Reverie",
"Arcane",
"Spellbound",
"Mirage",
"Oracle",
"Seraphim",
"Ethereal",
"Labyrinth",
"Enchantment",
// Nature & cosy
"Sakura",
"Ember",
"Cascade",
"Zephyr",
"Serendipity",
"Solace",
"Blossom",
"Whisper",
"Dewdrop",
"Sunbeam",
"Willow",
"Clover",
"Honeybee",
"Buttercup",
"Dandelion",
// Japanese/anime-inspired
"Tsukimi",
"Hanami",
"Yozora",
"Hoshi",
"Koharu",
"Akari",
"Midori",
// Adventure & epic
"Odyssey",
"Wanderer",
"Horizon",
"Voyage",
"Pathfinder",
"Frontier",
// Whimsical & sweet
"Bubblegum",
"Marshmallow",
"Daydream",
"Whimsy",
"Jellybean",
"Sprinkle",
"Cupcake",
// Dreamy & poetic
"Revenant",
"Elysium",
"Halcyon",
"Ephemera",
"Serenade",
"Lullaby",
"Nocturne",
"Rhapsody",
];
function pickRandomTabName(): string {
return TAB_NAMES[Math.floor(Math.random() * TAB_NAMES.length)];
} }
function createConversationsStore() { function createConversationsStore() {
@@ -59,7 +145,7 @@ function createConversationsStore() {
const id = generateConversationId(); const id = generateConversationId();
return { return {
id, id,
name: name || `Conversation ${conversationCounter}`, name: name ?? pickRandomTabName(),
terminalLines: [], terminalLines: [],
sessionId: null, sessionId: null,
connectionStatus: "disconnected", connectionStatus: "disconnected",
@@ -75,6 +161,10 @@ function createConversationsStore() {
attachments: [], attachments: [],
summary: null, summary: null,
startedAt: new Date(), startedAt: new Date(),
taskStartTime: null,
successSoundFired: false,
taskStartSoundFired: false,
draftText: "",
}; };
} }
@@ -83,7 +173,7 @@ function createConversationsStore() {
function ensureInitialized() { function ensureInitialized() {
if (!initialized) { if (!initialized) {
initialized = true; initialized = true;
const initialConversation = createNewConversation("Main"); const initialConversation = createNewConversation();
conversations.update((convs) => { conversations.update((convs) => {
convs.set(initialConversation.id, initialConversation); convs.set(initialConversation.id, initialConversation);
return convs; return convs;
@@ -196,7 +286,7 @@ function createConversationsStore() {
conversations.update((convs) => { conversations.update((convs) => {
const conv = convs.get(activeId); const conv = convs.get(activeId);
if (conv) { if (conv) {
conv.pendingPermissions.push(request); conv.pendingPermissions = [...conv.pendingPermissions, request];
conv.lastActivityAt = new Date(); conv.lastActivityAt = new Date();
} }
return convs; return convs;
@@ -219,7 +309,7 @@ function createConversationsStore() {
conversations.update((convs) => { conversations.update((convs) => {
const conv = convs.get(conversationId); const conv = convs.get(conversationId);
if (conv) { if (conv) {
conv.pendingPermissions.push(request); conv.pendingPermissions = [...conv.pendingPermissions, request];
conv.lastActivityAt = new Date(); conv.lastActivityAt = new Date();
} }
return convs; return convs;
@@ -364,9 +454,15 @@ function createConversationsStore() {
if (currentId !== id) { if (currentId !== id) {
activeConversationId.set(id); activeConversationId.set(id);
// Update the global character state to match the conversation's state // Update the global character state to match the conversation's state.
// Map success/error → idle since those are transient states that have
// already been displayed — restoring them would re-trigger sound rules.
if (targetConv) { if (targetConv) {
characterState.setState(targetConv.characterState); const stateToRestore =
targetConv.characterState === "success" || targetConv.characterState === "error"
? "idle"
: targetConv.characterState;
characterState.setState(stateToRestore);
} }
} }
}, },
@@ -816,6 +912,59 @@ function createConversationsStore() {
}); });
}, },
// Sound tracking methods
resetSoundState: (conversationId: string) => {
conversations.update((convs) => {
const conv = convs.get(conversationId);
if (conv) {
conv.taskStartTime = null;
conv.successSoundFired = false;
conv.taskStartSoundFired = false;
}
return convs;
});
},
setTaskStartTime: (conversationId: string, time: number | null) => {
conversations.update((convs) => {
const conv = convs.get(conversationId);
if (conv) {
conv.taskStartTime = time;
}
return convs;
});
},
markSuccessSoundFired: (conversationId: string) => {
conversations.update((convs) => {
const conv = convs.get(conversationId);
if (conv) {
conv.successSoundFired = true;
}
return convs;
});
},
markTaskStartSoundFired: (conversationId: string) => {
conversations.update((convs) => {
const conv = convs.get(conversationId);
if (conv) {
conv.taskStartSoundFired = true;
}
return convs;
});
},
setDraftText: (conversationId: string, text: string) => {
conversations.update((convs) => {
const conv = convs.get(conversationId);
if (conv) {
conv.draftText = text;
}
return convs;
});
},
// Add initialization helper // Add initialization helper
initialize: () => { initialize: () => {
ensureInitialized(); ensureInitialized();
+292
View File
@@ -0,0 +1,292 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { get } from "svelte/store";
import { invoke } from "@tauri-apps/api/core";
import { setMockInvokeResult } from "../../../vitest.setup";
import {
formatCost,
formatAlertType,
getAlertMessage,
costTrackingStore,
formattedCosts,
type AlertType,
type CostAlert,
} from "./costTracking";
vi.mock("$lib/notifications/notificationManager", () => ({
notificationManager: {
notifyCostAlert: vi.fn(),
},
}));
describe("formatCost", () => {
it("formats amounts below $0.01 to 4 decimal places", () => {
expect(formatCost(0)).toBe("$0.0000");
expect(formatCost(0.001)).toBe("$0.0010");
expect(formatCost(0.0099)).toBe("$0.0099");
});
it("formats amounts between $0.01 and $1 to 3 decimal places", () => {
expect(formatCost(0.01)).toBe("$0.010");
expect(formatCost(0.123)).toBe("$0.123");
expect(formatCost(0.999)).toBe("$0.999");
});
it("formats amounts $1 and above to 2 decimal places", () => {
expect(formatCost(1)).toBe("$1.00");
expect(formatCost(1.5)).toBe("$1.50");
expect(formatCost(100.99)).toBe("$100.99");
});
});
describe("formatAlertType", () => {
it("formats Daily as Today", () => {
expect(formatAlertType("Daily")).toBe("Today");
});
it("formats Weekly as This Week", () => {
expect(formatAlertType("Weekly")).toBe("This Week");
});
it("formats Monthly as This Month", () => {
expect(formatAlertType("Monthly")).toBe("This Month");
});
it("handles all AlertType values", () => {
const types: AlertType[] = ["Daily", "Weekly", "Monthly"];
const results = types.map(formatAlertType);
expect(results).toEqual(["Today", "This Week", "This Month"]);
});
});
describe("getAlertMessage", () => {
it("generates a message for a Daily alert", () => {
const alert: CostAlert = {
alert_type: "Daily",
threshold: 1.0,
current_cost: 1.5,
};
const message = getAlertMessage(alert);
expect(message).toContain("Today");
expect(message).toContain("$1.50");
expect(message).toContain("$1.00");
});
it("generates a message for a Weekly alert", () => {
const alert: CostAlert = {
alert_type: "Weekly",
threshold: 5.0,
current_cost: 6.0,
};
const message = getAlertMessage(alert);
expect(message).toContain("This Week");
expect(message).toContain("$6.00");
expect(message).toContain("$5.00");
});
it("generates a message for a Monthly alert", () => {
const alert: CostAlert = {
alert_type: "Monthly",
threshold: 20.0,
current_cost: 25.0,
};
const message = getAlertMessage(alert);
expect(message).toContain("This Month");
expect(message).toContain("$25.00");
expect(message).toContain("$20.00");
});
it("includes threshold and current cost in the message", () => {
const alert: CostAlert = {
alert_type: "Daily",
threshold: 0.005,
current_cost: 0.007,
};
const message = getAlertMessage(alert);
expect(message).toContain("$0.0070");
expect(message).toContain("$0.0050");
});
});
describe("costTrackingStore", () => {
beforeEach(() => {
vi.clearAllMocks();
costTrackingStore.reset();
});
describe("refresh", () => {
it("loads cost data from the backend", async () => {
setMockInvokeResult("get_today_cost", 1.5);
setMockInvokeResult("get_week_cost", 5.25);
setMockInvokeResult("get_month_cost", 20.0);
setMockInvokeResult("get_cost_alerts", []);
await costTrackingStore.refresh();
const state = get(costTrackingStore);
expect(state.todayCost).toBe(1.5);
expect(state.weekCost).toBe(5.25);
expect(state.monthCost).toBe(20.0);
expect(state.isLoading).toBe(false);
});
it("triggers notifications for any returned alerts", async () => {
const { notificationManager } = await import("$lib/notifications/notificationManager");
const alert: CostAlert = { alert_type: "Daily", threshold: 1.0, current_cost: 1.5 };
setMockInvokeResult("get_today_cost", 1.5);
setMockInvokeResult("get_week_cost", 0);
setMockInvokeResult("get_month_cost", 0);
setMockInvokeResult("get_cost_alerts", [alert]);
await costTrackingStore.refresh();
expect(notificationManager.notifyCostAlert).toHaveBeenCalledWith(
expect.stringContaining("Today")
);
});
it("returns alerts from refresh", async () => {
const alert: CostAlert = { alert_type: "Weekly", threshold: 5.0, current_cost: 6.0 };
setMockInvokeResult("get_today_cost", 0);
setMockInvokeResult("get_week_cost", 6.0);
setMockInvokeResult("get_month_cost", 0);
setMockInvokeResult("get_cost_alerts", [alert]);
const result = await costTrackingStore.refresh();
expect(result).toEqual([alert]);
});
it("handles refresh errors gracefully and returns empty array", async () => {
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
setMockInvokeResult("get_today_cost", new Error("Backend error"));
const result = await costTrackingStore.refresh();
expect(result).toEqual([]);
expect(errorSpy).toHaveBeenCalledWith("Failed to refresh cost tracking:", expect.any(Error));
errorSpy.mockRestore();
});
});
describe("getSummary", () => {
it("fetches and stores a cost summary", async () => {
const mockSummary = {
period_days: 7,
total_input_tokens: 1000,
total_output_tokens: 500,
total_cost: 0.05,
total_messages: 20,
total_sessions: 3,
average_daily_cost: 0.007,
daily_breakdown: [],
};
setMockInvokeResult("get_cost_summary", mockSummary);
const result = await costTrackingStore.getSummary(7);
expect(result).toEqual(mockSummary);
expect(invoke).toHaveBeenCalledWith("get_cost_summary", { days: 7 });
});
it("returns null and logs error on failure", async () => {
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
setMockInvokeResult("get_cost_summary", new Error("Summary unavailable"));
const result = await costTrackingStore.getSummary(30);
expect(result).toBeNull();
expect(errorSpy).toHaveBeenCalledWith("Failed to get cost summary:", expect.any(Error));
errorSpy.mockRestore();
});
});
describe("setAlertThresholds", () => {
it("persists new thresholds to the backend", async () => {
const thresholds = { daily: 2.0, weekly: 10.0, monthly: 40.0 };
await costTrackingStore.setAlertThresholds(thresholds);
expect(invoke).toHaveBeenCalledWith("set_cost_alert_thresholds", {
daily: 2.0,
weekly: 10.0,
monthly: 40.0,
});
});
it("logs an error when the backend call fails", async () => {
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
setMockInvokeResult("set_cost_alert_thresholds", new Error("Failed"));
await costTrackingStore.setAlertThresholds({ daily: 1.0, weekly: null, monthly: null });
expect(errorSpy).toHaveBeenCalledWith("Failed to set alert thresholds:", expect.any(Error));
errorSpy.mockRestore();
});
});
describe("exportCsv", () => {
it("returns the CSV string from the backend", async () => {
setMockInvokeResult("export_cost_csv", "date,cost\n2026-01-01,1.50");
const result = await costTrackingStore.exportCsv(7);
expect(result).toBe("date,cost\n2026-01-01,1.50");
expect(invoke).toHaveBeenCalledWith("export_cost_csv", { days: 7 });
});
it("returns null and logs error on failure", async () => {
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
setMockInvokeResult("export_cost_csv", new Error("Export failed"));
const result = await costTrackingStore.exportCsv(30);
expect(result).toBeNull();
expect(errorSpy).toHaveBeenCalledWith("Failed to export CSV:", expect.any(Error));
errorSpy.mockRestore();
});
});
describe("reset", () => {
it("resets costs back to zero", async () => {
setMockInvokeResult("get_today_cost", 5.0);
setMockInvokeResult("get_week_cost", 15.0);
setMockInvokeResult("get_month_cost", 50.0);
setMockInvokeResult("get_cost_alerts", []);
await costTrackingStore.refresh();
costTrackingStore.reset();
const state = get(costTrackingStore);
expect(state.todayCost).toBe(0);
expect(state.weekCost).toBe(0);
expect(state.monthCost).toBe(0);
});
});
});
describe("formattedCosts", () => {
beforeEach(() => {
costTrackingStore.reset();
});
it("formats the initial zero costs correctly", () => {
const costs = get(formattedCosts);
expect(costs.today).toBe("$0.0000");
expect(costs.week).toBe("$0.0000");
expect(costs.month).toBe("$0.0000");
});
it("reflects updated costs after a refresh", async () => {
setMockInvokeResult("get_today_cost", 1.5);
setMockInvokeResult("get_week_cost", 5.0);
setMockInvokeResult("get_month_cost", 20.0);
setMockInvokeResult("get_cost_alerts", []);
await costTrackingStore.refresh();
const costs = get(formattedCosts);
expect(costs.today).toBe("$1.50");
expect(costs.week).toBe("$5.00");
expect(costs.month).toBe("$20.00");
expect(costs.todayRaw).toBe(1.5);
});
});
+282
View File
@@ -0,0 +1,282 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { get } from "svelte/store";
import { emitMockEvent } from "../../../vitest.setup";
import { debugConsoleStore, filteredLogs } from "./debugConsole";
describe("debugConsoleStore", () => {
beforeEach(() => {
debugConsoleStore.clear();
debugConsoleStore.close();
debugConsoleStore.setFilterLevel("all");
debugConsoleStore.setAutoScroll(true);
});
afterEach(() => {
debugConsoleStore.restoreConsole();
debugConsoleStore.clear();
});
it("initializes with correct default state", () => {
const state = get(debugConsoleStore);
expect(state.logs).toEqual([]);
expect(state.isOpen).toBe(false);
expect(state.maxLogs).toBe(1000);
expect(state.filterLevel).toBe("all");
expect(state.autoScroll).toBe(true);
});
describe("toggle", () => {
it("opens when currently closed", () => {
debugConsoleStore.toggle();
expect(get(debugConsoleStore).isOpen).toBe(true);
});
it("closes when currently open", () => {
debugConsoleStore.open();
debugConsoleStore.toggle();
expect(get(debugConsoleStore).isOpen).toBe(false);
});
});
describe("open", () => {
it("sets isOpen to true", () => {
debugConsoleStore.open();
expect(get(debugConsoleStore).isOpen).toBe(true);
});
});
describe("close", () => {
it("sets isOpen to false", () => {
debugConsoleStore.open();
debugConsoleStore.close();
expect(get(debugConsoleStore).isOpen).toBe(false);
});
});
describe("clear", () => {
it("removes all log entries", async () => {
await debugConsoleStore.setupBackendLogsListener();
emitMockEvent("debug:log", { level: "info", message: "test entry" });
expect(get(debugConsoleStore).logs.length).toBe(1);
debugConsoleStore.clear();
expect(get(debugConsoleStore).logs).toEqual([]);
});
});
describe("setFilterLevel", () => {
it("updates filterLevel to the specified level", () => {
debugConsoleStore.setFilterLevel("error");
expect(get(debugConsoleStore).filterLevel).toBe("error");
});
it("can reset filterLevel back to all", () => {
debugConsoleStore.setFilterLevel("warn");
debugConsoleStore.setFilterLevel("all");
expect(get(debugConsoleStore).filterLevel).toBe("all");
});
});
describe("setAutoScroll", () => {
it("disables autoScroll", () => {
debugConsoleStore.setAutoScroll(false);
expect(get(debugConsoleStore).autoScroll).toBe(false);
});
it("re-enables autoScroll", () => {
debugConsoleStore.setAutoScroll(false);
debugConsoleStore.setAutoScroll(true);
expect(get(debugConsoleStore).autoScroll).toBe(true);
});
});
describe("setupConsoleCapture", () => {
afterEach(() => {
debugConsoleStore.restoreConsole();
});
it("captures console.log calls as info-level frontend logs", () => {
debugConsoleStore.setupConsoleCapture();
console.log("hello world");
const logs = get(debugConsoleStore).logs;
const captured = logs.find((l) => l.message === "hello world");
expect(captured).toBeDefined();
expect(captured?.level).toBe("info");
expect(captured?.source).toBe("frontend");
});
it("captures console.info calls as info-level logs", () => {
debugConsoleStore.setupConsoleCapture();
console.info("info message");
const logs = get(debugConsoleStore).logs;
const captured = logs.find((l) => l.message === "info message");
expect(captured?.level).toBe("info");
});
it("captures console.warn calls as warn-level logs", () => {
debugConsoleStore.setupConsoleCapture();
console.warn("warning message");
const logs = get(debugConsoleStore).logs;
const captured = logs.find((l) => l.message === "warning message");
expect(captured?.level).toBe("warn");
});
it("captures console.error calls as error-level logs", () => {
debugConsoleStore.setupConsoleCapture();
console.error("error message");
const logs = get(debugConsoleStore).logs;
const captured = logs.find((l) => l.message === "error message");
expect(captured?.level).toBe("error");
});
it("captures console.debug calls as debug-level logs", () => {
debugConsoleStore.setupConsoleCapture();
console.debug("debug message");
const logs = get(debugConsoleStore).logs;
const captured = logs.find((l) => l.message === "debug message");
expect(captured?.level).toBe("debug");
});
it("joins multiple console arguments with spaces", () => {
debugConsoleStore.setupConsoleCapture();
console.log("hello", "world", 42);
const logs = get(debugConsoleStore).logs;
const captured = logs.find((l) => l.message === "hello world 42");
expect(captured).toBeDefined();
});
it("captures unhandled window error events", () => {
debugConsoleStore.setupConsoleCapture();
const errorEvent = new ErrorEvent("error", {
message: "Test unhandled error",
filename: "test.js",
lineno: 10,
colno: 5,
});
window.dispatchEvent(errorEvent);
const logs = get(debugConsoleStore).logs;
const captured = logs.find(
(l) => l.level === "error" && l.message.includes("[Unhandled Error]")
);
expect(captured).toBeDefined();
expect(captured?.message).toContain("Test unhandled error");
});
it("captures unhandled promise rejection events", () => {
debugConsoleStore.setupConsoleCapture();
const rejectionEvent = Object.assign(new Event("unhandledrejection"), {
reason: "test rejection reason",
});
window.dispatchEvent(rejectionEvent);
const logs = get(debugConsoleStore).logs;
const captured = logs.find(
(l) => l.level === "error" && l.message.includes("[Unhandled Promise Rejection]")
);
expect(captured).toBeDefined();
expect(captured?.message).toContain("test rejection reason");
});
});
describe("restoreConsole", () => {
it("stops capturing console output after restore", () => {
debugConsoleStore.setupConsoleCapture();
debugConsoleStore.restoreConsole();
const countBefore = get(debugConsoleStore).logs.length;
console.log("this should not be captured");
expect(get(debugConsoleStore).logs.length).toBe(countBefore);
});
});
describe("setupBackendLogsListener", () => {
it("captures backend logs emitted via debug:log event", async () => {
await debugConsoleStore.setupBackendLogsListener();
emitMockEvent("debug:log", { level: "info", message: "backend message" });
const logs = get(debugConsoleStore).logs;
expect(logs.length).toBe(1);
expect(logs[0].level).toBe("info");
expect(logs[0].message).toBe("backend message");
expect(logs[0].source).toBe("backend");
});
it("handles different log levels from backend", async () => {
await debugConsoleStore.setupBackendLogsListener();
emitMockEvent("debug:log", { level: "error", message: "backend error" });
const logs = get(debugConsoleStore).logs;
expect(logs[0].level).toBe("error");
});
});
describe("circular buffer", () => {
it("drops oldest log when exceeding 1000-entry limit", async () => {
await debugConsoleStore.setupBackendLogsListener();
for (let i = 0; i < 1001; i++) {
emitMockEvent("debug:log", { level: "info", message: `log ${i}` });
}
const logs = get(debugConsoleStore).logs;
expect(logs.length).toBe(1000);
expect(logs[0].message).toBe("log 1");
expect(logs[999].message).toBe("log 1000");
});
});
});
describe("filteredLogs derived store", () => {
beforeEach(async () => {
debugConsoleStore.clear();
debugConsoleStore.setFilterLevel("all");
await debugConsoleStore.setupBackendLogsListener();
});
afterEach(() => {
debugConsoleStore.clear();
});
it("returns all logs when filterLevel is all", () => {
emitMockEvent("debug:log", { level: "debug", message: "d" });
emitMockEvent("debug:log", { level: "info", message: "i" });
emitMockEvent("debug:log", { level: "warn", message: "w" });
emitMockEvent("debug:log", { level: "error", message: "e" });
expect(get(filteredLogs).length).toBe(4);
});
it("returns only error logs when filterLevel is error", () => {
emitMockEvent("debug:log", { level: "debug", message: "d" });
emitMockEvent("debug:log", { level: "info", message: "i" });
emitMockEvent("debug:log", { level: "warn", message: "w" });
emitMockEvent("debug:log", { level: "error", message: "e" });
debugConsoleStore.setFilterLevel("error");
const logs = get(filteredLogs);
expect(logs.length).toBe(1);
expect(logs[0].level).toBe("error");
});
it("returns warn and error logs when filterLevel is warn", () => {
emitMockEvent("debug:log", { level: "debug", message: "d" });
emitMockEvent("debug:log", { level: "info", message: "i" });
emitMockEvent("debug:log", { level: "warn", message: "w" });
emitMockEvent("debug:log", { level: "error", message: "e" });
debugConsoleStore.setFilterLevel("warn");
const logs = get(filteredLogs);
expect(logs.length).toBe(2);
expect(logs.every((l) => l.level === "warn" || l.level === "error")).toBe(true);
});
it("excludes debug logs when filterLevel is info", () => {
emitMockEvent("debug:log", { level: "debug", message: "d" });
emitMockEvent("debug:log", { level: "info", message: "i" });
emitMockEvent("debug:log", { level: "warn", message: "w" });
emitMockEvent("debug:log", { level: "error", message: "e" });
debugConsoleStore.setFilterLevel("info");
const logs = get(filteredLogs);
expect(logs.length).toBe(3);
expect(logs.some((l) => l.level === "debug")).toBe(false);
});
it("returns all log levels when filterLevel is debug", () => {
emitMockEvent("debug:log", { level: "debug", message: "d" });
emitMockEvent("debug:log", { level: "info", message: "i" });
emitMockEvent("debug:log", { level: "warn", message: "w" });
emitMockEvent("debug:log", { level: "error", message: "e" });
debugConsoleStore.setFilterLevel("debug");
expect(get(filteredLogs).length).toBe(4);
});
});
+213
View File
@@ -0,0 +1,213 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { get } from "svelte/store";
import { invoke } from "@tauri-apps/api/core";
import { setMockInvokeResult } from "../../../vitest.setup";
import { draftsStore, type Draft } from "./drafts";
const makeDraft = (id: string, content: string, saved_at: string): Draft => ({
id,
content,
saved_at,
});
describe("Draft interface", () => {
it("defines all required fields", () => {
const draft: Draft = {
id: "draft-123",
content: "Hello world",
saved_at: "2026-01-01T00:00:00+00:00",
};
expect(draft.id).toBe("draft-123");
expect(draft.content).toBe("Hello world");
expect(draft.saved_at).toBe("2026-01-01T00:00:00+00:00");
});
it("supports multiline content", () => {
const draft: Draft = {
id: "multi",
content: "Line 1\nLine 2\nLine 3",
saved_at: "2026-01-01T00:00:00+00:00",
};
expect(draft.content.includes("\n")).toBe(true);
expect(draft.content.split("\n")).toHaveLength(3);
});
});
describe("draftsStore", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("store structure", () => {
it("has all expected methods", () => {
expect(typeof draftsStore.loadDrafts).toBe("function");
expect(typeof draftsStore.saveDraft).toBe("function");
expect(typeof draftsStore.deleteDraft).toBe("function");
expect(typeof draftsStore.deleteAllDrafts).toBe("function");
expect(typeof draftsStore.formatTimestamp).toBe("function");
});
it("has subscribable stores", () => {
expect(typeof draftsStore.drafts.subscribe).toBe("function");
expect(typeof draftsStore.isLoading.subscribe).toBe("function");
});
});
describe("loadDrafts", () => {
it("loads drafts from backend", async () => {
const mockDrafts: Draft[] = [
makeDraft("draft-1", "Hello world", "2026-01-01T00:00:00+00:00"),
];
setMockInvokeResult("list_drafts", mockDrafts);
await draftsStore.loadDrafts();
expect(invoke).toHaveBeenCalledWith("list_drafts");
expect(get(draftsStore.drafts)).toEqual(mockDrafts);
});
it("handles load errors gracefully", async () => {
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
setMockInvokeResult("list_drafts", new Error("Failed to load"));
await draftsStore.loadDrafts();
expect(consoleSpy).toHaveBeenCalledWith("Failed to load drafts:", expect.any(Error));
consoleSpy.mockRestore();
});
it("sets isLoading during load", async () => {
const loadingStates: boolean[] = [];
const unsubscribe = draftsStore.isLoading.subscribe((val) => loadingStates.push(val));
setMockInvokeResult("list_drafts", []);
await draftsStore.loadDrafts();
unsubscribe();
expect(loadingStates).toContain(true);
expect(loadingStates.at(-1)).toBe(false);
});
});
describe("saveDraft", () => {
it("saves draft and reloads list", async () => {
const mockDraft = makeDraft("new-id", "My draft content", "2026-01-01T00:00:00+00:00");
setMockInvokeResult("save_draft", mockDraft);
setMockInvokeResult("list_drafts", [mockDraft]);
const result = await draftsStore.saveDraft("My draft content");
expect(result).toEqual(mockDraft);
expect(invoke).toHaveBeenCalledWith("save_draft", { content: "My draft content" });
});
it("returns null on error", async () => {
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
setMockInvokeResult("save_draft", new Error("Save failed"));
const result = await draftsStore.saveDraft("content");
expect(result).toBeNull();
expect(consoleSpy).toHaveBeenCalledWith("Failed to save draft:", expect.any(Error));
consoleSpy.mockRestore();
});
});
describe("deleteDraft", () => {
it("deletes draft by ID and reloads", async () => {
setMockInvokeResult("delete_draft", undefined);
setMockInvokeResult("list_drafts", []);
const result = await draftsStore.deleteDraft("draft-123");
expect(result).toBe(true);
expect(invoke).toHaveBeenCalledWith("delete_draft", { draftId: "draft-123" });
});
it("handles delete errors gracefully", async () => {
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
setMockInvokeResult("delete_draft", new Error("Delete failed"));
const result = await draftsStore.deleteDraft("draft-123");
expect(result).toBe(false);
expect(consoleSpy).toHaveBeenCalledWith("Failed to delete draft:", expect.any(Error));
consoleSpy.mockRestore();
});
});
describe("deleteAllDrafts", () => {
it("deletes all drafts and clears store", async () => {
// First populate the store
setMockInvokeResult("list_drafts", [makeDraft("d1", "Draft 1", "2026-01-01T00:00:00+00:00")]);
await draftsStore.loadDrafts();
expect(get(draftsStore.drafts)).toHaveLength(1);
setMockInvokeResult("delete_all_drafts", undefined);
const result = await draftsStore.deleteAllDrafts();
expect(result).toBe(true);
expect(invoke).toHaveBeenCalledWith("delete_all_drafts");
expect(get(draftsStore.drafts)).toHaveLength(0);
});
it("handles delete-all errors gracefully", async () => {
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
setMockInvokeResult("delete_all_drafts", new Error("Delete all failed"));
const result = await draftsStore.deleteAllDrafts();
expect(result).toBe(false);
expect(consoleSpy).toHaveBeenCalledWith("Failed to delete all drafts:", expect.any(Error));
consoleSpy.mockRestore();
});
});
describe("formatTimestamp", () => {
it("formats a valid ISO timestamp", () => {
const result = draftsStore.formatTimestamp("2026-01-15T14:30:00+00:00");
// Should produce a human-readable string (locale-dependent)
expect(typeof result).toBe("string");
expect(result.length).toBeGreaterThan(0);
});
it("falls back to raw string on invalid timestamp", () => {
const invalid = "not-a-date";
const result = draftsStore.formatTimestamp(invalid);
expect(result).toBe(invalid);
});
it("falls back to raw string when toLocaleString throws", () => {
const spy = vi.spyOn(Date.prototype, "toLocaleString").mockImplementation(() => {
throw new Error("Locale not supported");
});
const ts = "2026-01-15T14:30:00.000Z";
const result = draftsStore.formatTimestamp(ts);
expect(result).toBe(ts);
spy.mockRestore();
});
});
});
describe("draft content handling", () => {
it("supports content with special characters", () => {
const draft = makeDraft(
"special",
"echo \"Hello\" && echo 'World'",
"2026-01-01T00:00:00+00:00"
);
expect(draft.content).toContain('"');
expect(draft.content).toContain("'");
expect(draft.content).toContain("&&");
});
it("supports very long content", () => {
const longContent = "a".repeat(1000);
const draft = makeDraft("long", longContent, "2026-01-01T00:00:00+00:00");
expect(draft.content).toHaveLength(1000);
});
});
+87
View File
@@ -0,0 +1,87 @@
import { writable } from "svelte/store";
import { invoke } from "@tauri-apps/api/core";
export interface Draft {
id: string;
content: string;
saved_at: string;
}
function createDraftsStore() {
const drafts = writable<Draft[]>([]);
const isLoading = writable(false);
async function loadDrafts(): Promise<void> {
isLoading.set(true);
try {
const list = await invoke<Draft[]>("list_drafts");
drafts.set(list);
} catch (error) {
console.error("Failed to load drafts:", error);
} finally {
isLoading.set(false);
}
}
async function saveDraft(content: string): Promise<Draft | null> {
try {
const draft = await invoke<Draft>("save_draft", { content });
await loadDrafts();
return draft;
} catch (error) {
console.error("Failed to save draft:", error);
return null;
}
}
async function deleteDraft(draftId: string): Promise<boolean> {
try {
await invoke("delete_draft", { draftId });
await loadDrafts();
return true;
} catch (error) {
console.error("Failed to delete draft:", error);
return false;
}
}
async function deleteAllDrafts(): Promise<boolean> {
try {
await invoke("delete_all_drafts");
drafts.set([]);
return true;
} catch (error) {
console.error("Failed to delete all drafts:", error);
return false;
}
}
function formatTimestamp(isoString: string): string {
const date = new Date(isoString);
if (isNaN(date.getTime())) {
return isoString;
}
try {
return date.toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
});
} catch {
return isoString;
}
}
return {
drafts: { subscribe: drafts.subscribe },
isLoading: { subscribe: isLoading.subscribe },
loadDrafts,
saveDraft,
deleteDraft,
deleteAllDrafts,
formatTimestamp,
};
}
export const draftsStore = createDraftsStore();
+639
View File
@@ -0,0 +1,639 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { get } from "svelte/store";
import { editorStore } from "$lib/stores/editor";
import { setMockInvokeResult } from "../../../vitest.setup";
const FILE_CONTENT = "// test file content";
// Reset tabs between tests
afterEach(async () => {
const tabs = get(editorStore.tabs);
for (const tab of tabs) {
editorStore.closeTab(tab.id);
}
editorStore.hideEditor();
});
describe("editorStore - openFile (tests getLanguageFromPath)", () => {
const testCases: [string, string][] = [
["file.ts", "typescript"],
["file.tsx", "typescript"],
["file.js", "javascript"],
["file.jsx", "javascript"],
["file.py", "python"],
["file.rs", "rust"],
["file.go", "go"],
["file.java", "java"],
["file.c", "c"],
["file.cpp", "cpp"],
["file.h", "c"],
["file.hpp", "cpp"],
["file.cs", "csharp"],
["file.rb", "ruby"],
["file.php", "php"],
["file.swift", "swift"],
["file.kt", "kotlin"],
["file.scala", "scala"],
["file.html", "html"],
["file.htm", "html"],
["file.css", "css"],
["file.scss", "scss"],
["file.sass", "sass"],
["file.less", "less"],
["file.json", "json"],
["file.xml", "xml"],
["file.yaml", "yaml"],
["file.yml", "yaml"],
["file.toml", "toml"],
["file.md", "markdown"],
["file.markdown", "markdown"],
["file.sql", "sql"],
["file.sh", "shell"],
["file.bash", "shell"],
["file.zsh", "shell"],
["file.ps1", "powershell"],
["file.svelte", "svelte"],
["file.vue", "vue"],
["file.graphql", "graphql"],
["file.gql", "graphql"],
["file.lua", "lua"],
["file.r", "r"],
["file.dart", "dart"],
["file.elm", "elm"],
["file.ex", "elixir"],
["file.exs", "elixir"],
["file.erl", "erlang"],
["file.hs", "haskell"],
["file.clj", "clojure"],
["file.lisp", "lisp"],
["file.ml", "ocaml"],
["file.fs", "fsharp"],
["file.zig", "zig"],
["file.nim", "nim"],
["file.v", "v"],
["file.wasm", "wasm"],
["file.wat", "wasm"],
["dockerfile", "plaintext"],
["file.unknown", "plaintext"],
["file", "plaintext"],
];
it.each(testCases)("maps %s to language %s", async (filename, expectedLanguage) => {
setMockInvokeResult("read_file_content", FILE_CONTENT);
await editorStore.openFile(`/path/to/${filename}`);
const activeTab = get(editorStore.activeTab);
expect(activeTab?.language).toBe(expectedLanguage);
if (activeTab) editorStore.closeTab(activeTab.id);
});
it("opens a new tab and makes it active", async () => {
setMockInvokeResult("read_file_content", FILE_CONTENT);
await editorStore.openFile("/path/to/file.ts");
const tabs = get(editorStore.tabs);
const activeTab = get(editorStore.activeTab);
expect(tabs).toHaveLength(1);
expect(activeTab?.fileName).toBe("file.ts");
expect(activeTab?.content).toBe(FILE_CONTENT);
expect(activeTab?.isDirty).toBe(false);
});
it("switches to existing tab instead of opening a duplicate", async () => {
setMockInvokeResult("read_file_content", FILE_CONTENT);
await editorStore.openFile("/path/to/file.ts");
const firstTabId = get(editorStore.activeTabId);
// Open another file to change active tab
await editorStore.openFile("/path/to/other.ts");
const secondTabId = get(editorStore.activeTabId);
expect(secondTabId).not.toBe(firstTabId);
// Re-open first file — should switch to existing tab
await editorStore.openFile("/path/to/file.ts");
expect(get(editorStore.activeTabId)).toBe(firstTabId);
expect(get(editorStore.tabs)).toHaveLength(2);
});
it("handles open file errors gracefully", async () => {
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
setMockInvokeResult("read_file_content", new Error("File not found"));
await editorStore.openFile("/path/to/missing.ts");
expect(get(editorStore.tabs)).toHaveLength(0);
expect(get(editorStore.saveError)).toContain("Failed to open file");
consoleErrorSpy.mockRestore();
});
it("extracts filename from path correctly", async () => {
setMockInvokeResult("read_file_content", FILE_CONTENT);
await editorStore.openFile("/deep/nested/path/component.svelte");
expect(get(editorStore.activeTab)?.fileName).toBe("component.svelte");
});
});
describe("editorStore - saveFile", () => {
beforeEach(async () => {
setMockInvokeResult("read_file_content", FILE_CONTENT);
await editorStore.openFile("/path/to/file.ts");
// Make it dirty first
editorStore.updateTabContent(get(editorStore.activeTabId)!, "modified content");
});
it("saves the active tab successfully", async () => {
setMockInvokeResult("write_file_content", undefined);
await editorStore.saveFile();
expect(get(editorStore.activeTab)?.isDirty).toBe(false);
expect(get(editorStore.activeTab)?.originalContent).toBe("modified content");
});
it("saves a specific tab by ID", async () => {
setMockInvokeResult("write_file_content", undefined);
const tabId = get(editorStore.activeTabId)!;
await editorStore.saveFile(tabId);
expect(get(editorStore.activeTab)?.isDirty).toBe(false);
});
it("does nothing when no tab ID matches", async () => {
await editorStore.saveFile("non-existent-tab");
// No error = pass
});
});
describe("editorStore - saveFile error", () => {
it("sets saveError when write fails", async () => {
setMockInvokeResult("read_file_content", FILE_CONTENT);
await editorStore.openFile("/path/to/file-err.ts");
editorStore.updateTabContent(get(editorStore.activeTabId)!, "modified content");
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
setMockInvokeResult("write_file_content", new Error("Permission denied"));
await editorStore.saveFile().catch(() => {});
expect(get(editorStore.saveError)).toContain("Failed to save file");
consoleErrorSpy.mockRestore();
});
});
describe("editorStore - updateTabContent", () => {
beforeEach(async () => {
setMockInvokeResult("read_file_content", FILE_CONTENT);
await editorStore.openFile("/path/to/file.ts");
});
it("updates content and marks tab as dirty", () => {
const tabId = get(editorStore.activeTabId)!;
editorStore.updateTabContent(tabId, "new content");
const tab = get(editorStore.activeTab);
expect(tab?.content).toBe("new content");
expect(tab?.isDirty).toBe(true);
});
it("marks tab as clean when content matches original", () => {
const tabId = get(editorStore.activeTabId)!;
editorStore.updateTabContent(tabId, "modified");
expect(get(editorStore.activeTab)?.isDirty).toBe(true);
editorStore.updateTabContent(tabId, FILE_CONTENT);
expect(get(editorStore.activeTab)?.isDirty).toBe(false);
});
it("hasDirtyTabs is true when any tab is dirty", () => {
const tabId = get(editorStore.activeTabId)!;
expect(get(editorStore.hasDirtyTabs)).toBe(false);
editorStore.updateTabContent(tabId, "dirty content");
expect(get(editorStore.hasDirtyTabs)).toBe(true);
});
});
describe("editorStore - closeTab", () => {
beforeEach(async () => {
setMockInvokeResult("read_file_content", FILE_CONTENT);
await editorStore.openFile("/path/to/file1.ts");
await editorStore.openFile("/path/to/file2.ts");
});
it("removes the specified tab", () => {
const tabs = get(editorStore.tabs);
editorStore.closeTab(tabs[0].id);
expect(get(editorStore.tabs)).toHaveLength(1);
expect(get(editorStore.tabs)[0].filePath).toBe("/path/to/file2.ts");
});
it("sets active tab to remaining tab when active tab is closed", () => {
const activeTabId = get(editorStore.activeTabId)!;
editorStore.closeTab(activeTabId);
expect(get(editorStore.activeTabId)).not.toBe(activeTabId);
expect(get(editorStore.tabs)).toHaveLength(1);
});
});
describe("editorStore - setActiveTab", () => {
beforeEach(async () => {
setMockInvokeResult("read_file_content", FILE_CONTENT);
await editorStore.openFile("/path/to/file1.ts");
await editorStore.openFile("/path/to/file2.ts");
});
it("sets the active tab to the specified ID", () => {
const tabs = get(editorStore.tabs);
editorStore.setActiveTab(tabs[0].id);
expect(get(editorStore.activeTabId)).toBe(tabs[0].id);
});
});
describe("editorStore - visibility controls", () => {
it("showEditor sets isEditorVisible to true", () => {
expect(get(editorStore.isEditorVisible)).toBe(false);
editorStore.showEditor();
expect(get(editorStore.isEditorVisible)).toBe(true);
});
it("hideEditor sets isEditorVisible to false", () => {
editorStore.showEditor();
editorStore.hideEditor();
expect(get(editorStore.isEditorVisible)).toBe(false);
});
it("toggleEditor toggles isEditorVisible", () => {
expect(get(editorStore.isEditorVisible)).toBe(false);
editorStore.toggleEditor();
expect(get(editorStore.isEditorVisible)).toBe(true);
editorStore.toggleEditor();
expect(get(editorStore.isEditorVisible)).toBe(false);
});
});
describe("editorStore - toggleFileBrowser", () => {
it("toggles the file browser visibility", () => {
const initial = get(editorStore.isFileBrowserOpen);
editorStore.toggleFileBrowser();
expect(get(editorStore.isFileBrowserOpen)).toBe(!initial);
editorStore.toggleFileBrowser();
expect(get(editorStore.isFileBrowserOpen)).toBe(initial);
});
});
describe("editorStore - file system operations", () => {
it("createFile invokes backend and refreshes directory", async () => {
setMockInvokeResult("create_file", undefined);
setMockInvokeResult("list_directory", []);
await editorStore.createFile("/path/to", "new-file.ts");
// No error = success
});
it("createFile handles errors gracefully", async () => {
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
setMockInvokeResult("create_file", new Error("Permission denied"));
await editorStore.createFile("/path/to", "new-file.ts");
expect(consoleErrorSpy).toHaveBeenCalledWith("Failed to create file:", expect.any(Error));
consoleErrorSpy.mockRestore();
});
it("createDirectory invokes backend and refreshes", async () => {
setMockInvokeResult("create_directory", undefined);
setMockInvokeResult("list_directory", []);
await editorStore.createDirectory("/path/to", "new-dir");
});
it("createDirectory handles errors gracefully", async () => {
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
setMockInvokeResult("create_directory", new Error("Failed"));
await editorStore.createDirectory("/path/to", "new-dir");
expect(consoleErrorSpy).toHaveBeenCalledWith("Failed to create directory:", expect.any(Error));
consoleErrorSpy.mockRestore();
});
it("deleteFile invokes backend and refreshes", async () => {
setMockInvokeResult("delete_file", undefined);
setMockInvokeResult("list_directory", []);
await editorStore.deleteFile("/path/to/file.ts");
});
it("deleteFile handles errors gracefully", async () => {
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
setMockInvokeResult("delete_file", new Error("Failed"));
await editorStore.deleteFile("/path/to/file.ts");
expect(consoleErrorSpy).toHaveBeenCalledWith("Failed to delete file:", expect.any(Error));
consoleErrorSpy.mockRestore();
});
it("deleteDirectory invokes backend and refreshes", async () => {
setMockInvokeResult("delete_directory", undefined);
setMockInvokeResult("list_directory", []);
await editorStore.deleteDirectory("/path/to/dir");
});
it("deleteDirectory handles errors gracefully", async () => {
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
setMockInvokeResult("delete_directory", new Error("Failed"));
await editorStore.deleteDirectory("/path/to/dir");
expect(consoleErrorSpy).toHaveBeenCalledWith("Failed to delete directory:", expect.any(Error));
consoleErrorSpy.mockRestore();
});
it("renamePath invokes backend and refreshes", async () => {
setMockInvokeResult("rename_path", undefined);
setMockInvokeResult("list_directory", []);
await editorStore.renamePath("/path/to/old-name.ts", "new-name.ts");
});
it("renamePath handles errors gracefully", async () => {
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
setMockInvokeResult("rename_path", new Error("Failed"));
await editorStore.renamePath("/path/to/old-name.ts", "new-name.ts");
expect(consoleErrorSpy).toHaveBeenCalledWith("Failed to rename:", expect.any(Error));
consoleErrorSpy.mockRestore();
});
});
describe("editorStore - initializeFileTree", () => {
it("loads directory entries and updates file tree", async () => {
const entries = [
{ path: "/path/file.ts", name: "file.ts", isDirectory: false, children: undefined },
];
setMockInvokeResult("list_directory", entries);
await editorStore.initializeFileTree("/path");
expect(get(editorStore.fileTree)).toEqual(entries);
expect(get(editorStore.currentDirectory)).toBe("/path");
});
it("handles errors gracefully", async () => {
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
setMockInvokeResult("list_directory", new Error("Failed"));
await editorStore.initializeFileTree("/path");
expect(get(editorStore.isLoadingTree)).toBe(false);
consoleErrorSpy.mockRestore();
});
});
describe("editorStore - toggleDirectory", () => {
beforeEach(async () => {
const entries = [
{
path: "/path/dir",
name: "dir",
isDirectory: true,
isExpanded: false,
children: undefined,
},
];
setMockInvokeResult("list_directory", entries);
await editorStore.initializeFileTree("/path");
});
it("does nothing when entry is not a directory", async () => {
const nonDirEntry = {
path: "/path/file.ts",
name: "file.ts",
isDirectory: false,
};
const treeBefore = get(editorStore.fileTree);
await editorStore.toggleDirectory(nonDirEntry);
expect(get(editorStore.fileTree)).toEqual(treeBefore);
});
it("expands a collapsed directory and loads children asynchronously", async () => {
const children = [
{ path: "/path/dir/child.ts", name: "child.ts", isDirectory: false, children: undefined },
];
setMockInvokeResult("list_directory", children);
const dirEntry = get(editorStore.fileTree)[0];
await editorStore.toggleDirectory(dirEntry);
const updatedEntry = get(editorStore.fileTree)[0];
expect(updatedEntry.isExpanded).toBe(true);
expect(updatedEntry.children).toEqual(children);
expect(updatedEntry.isLoading).toBe(false);
});
it("collapses an expanded directory", async () => {
const children = [
{ path: "/path/dir/child.ts", name: "child.ts", isDirectory: false, children: undefined },
];
setMockInvokeResult("list_directory", children);
// Expand first
const dirEntry = get(editorStore.fileTree)[0];
await editorStore.toggleDirectory(dirEntry);
expect(get(editorStore.fileTree)[0].isExpanded).toBe(true);
// Now collapse
const expandedEntry = get(editorStore.fileTree)[0];
await editorStore.toggleDirectory(expandedEntry);
expect(get(editorStore.fileTree)[0].isExpanded).toBe(false);
});
it("expands a directory that already has children without reloading", async () => {
// Re-init with a directory that already has children
const entries = [
{
path: "/path/dir",
name: "dir",
isDirectory: true,
isExpanded: false,
children: [
{
path: "/path/dir/child.ts",
name: "child.ts",
isDirectory: false,
children: undefined,
},
],
},
];
setMockInvokeResult("list_directory", entries);
await editorStore.initializeFileTree("/path");
// Toggle should expand without loading (children already present)
const dirEntry = get(editorStore.fileTree)[0];
await editorStore.toggleDirectory(dirEntry);
const updatedEntry = get(editorStore.fileTree)[0];
expect(updatedEntry.isExpanded).toBe(true);
expect(updatedEntry.children).toHaveLength(1);
});
it("handles tree with multiple entries when toggling — exercises non-matching leaf fallback", async () => {
// Tree has dir + a sibling file; toggling dir exercises the `return e` branch for the file
const entries = [
{
path: "/path/dir",
name: "dir",
isDirectory: true,
isExpanded: false,
children: undefined,
},
{ path: "/path/file.ts", name: "file.ts", isDirectory: false, children: undefined },
];
setMockInvokeResult("list_directory", entries);
await editorStore.initializeFileTree("/path");
const children: never[] = [];
setMockInvokeResult("list_directory", children);
const dirEntry = get(editorStore.fileTree)[0];
await editorStore.toggleDirectory(dirEntry);
// The sibling file should remain unchanged
expect(get(editorStore.fileTree)[1].path).toBe("/path/file.ts");
expect(get(editorStore.fileTree)[0].isExpanded).toBe(true);
});
it("handles nested directory toggle — exercises recursive updateTree branches", async () => {
// Tree: outer_dir → [inner_dir (no children), sibling_file]
// Toggling inner_dir covers the recursive path through outer's children
const entries = [
{
path: "/path/outer",
name: "outer",
isDirectory: true,
isExpanded: true,
children: [
{
path: "/path/outer/inner",
name: "inner",
isDirectory: true,
isExpanded: false,
children: undefined,
},
{
path: "/path/outer/sibling.ts",
name: "sibling.ts",
isDirectory: false,
children: undefined,
},
],
},
];
setMockInvokeResult("list_directory", entries);
await editorStore.initializeFileTree("/path");
const innerChildren = [
{
path: "/path/outer/inner/deep.ts",
name: "deep.ts",
isDirectory: false,
children: undefined,
},
];
setMockInvokeResult("list_directory", innerChildren);
const innerEntry = get(editorStore.fileTree)[0].children![0];
await editorStore.toggleDirectory(innerEntry);
const outer = get(editorStore.fileTree)[0];
expect(outer.children![0].isExpanded).toBe(true);
expect(outer.children![0].children).toEqual(innerChildren);
});
});
describe("editorStore - deleteFile closes open tab", () => {
it("closes the tab when the deleted file is open", async () => {
setMockInvokeResult("read_file_content", FILE_CONTENT);
await editorStore.openFile("/path/to/file.ts");
expect(get(editorStore.tabs)).toHaveLength(1);
setMockInvokeResult("delete_file", undefined);
setMockInvokeResult("list_directory", []);
await editorStore.deleteFile("/path/to/file.ts");
expect(get(editorStore.tabs)).toHaveLength(0);
});
});
describe("editorStore - deleteDirectory closes open tabs", () => {
it("closes all tabs inside the deleted directory", async () => {
setMockInvokeResult("read_file_content", FILE_CONTENT);
await editorStore.openFile("/path/to/dir/file1.ts");
await editorStore.openFile("/path/to/dir/nested/file2.ts");
await editorStore.openFile("/path/to/other.ts");
expect(get(editorStore.tabs)).toHaveLength(3);
setMockInvokeResult("delete_directory", undefined);
setMockInvokeResult("list_directory", []);
await editorStore.deleteDirectory("/path/to/dir");
const remainingTabs = get(editorStore.tabs);
expect(remainingTabs).toHaveLength(1);
expect(remainingTabs[0].filePath).toBe("/path/to/other.ts");
});
});
describe("editorStore - renamePath updates open tabs", () => {
it("updates filePath and fileName when the renamed file is open in a tab", async () => {
setMockInvokeResult("read_file_content", FILE_CONTENT);
await editorStore.openFile("/path/to/old-name.ts");
setMockInvokeResult("rename_path", undefined);
setMockInvokeResult("list_directory", []);
await editorStore.renamePath("/path/to/old-name.ts", "new-name.ts");
const tab = get(editorStore.activeTab);
expect(tab?.filePath).toBe("/path/to/new-name.ts");
expect(tab?.fileName).toBe("new-name.ts");
});
it("updates filePaths for tabs inside a renamed directory", async () => {
setMockInvokeResult("read_file_content", FILE_CONTENT);
await editorStore.openFile("/path/to/old-dir/file.ts");
setMockInvokeResult("rename_path", undefined);
setMockInvokeResult("list_directory", []);
await editorStore.renamePath("/path/to/old-dir", "new-dir");
const tab = get(editorStore.activeTab);
expect(tab?.filePath).toBe("/path/to/new-dir/file.ts");
});
it("leaves unrelated tabs unchanged when renaming — exercises return-t fallback", async () => {
setMockInvokeResult("read_file_content", FILE_CONTENT);
await editorStore.openFile("/path/to/unrelated.ts");
await editorStore.openFile("/path/to/old-name.ts");
setMockInvokeResult("rename_path", undefined);
setMockInvokeResult("list_directory", []);
await editorStore.renamePath("/path/to/old-name.ts", "new-name.ts");
const tabs = get(editorStore.tabs);
const unrelated = tabs.find((t) => t.filePath === "/path/to/unrelated.ts");
expect(unrelated).toBeDefined();
expect(unrelated?.fileName).toBe("unrelated.ts");
});
});
describe("editorStore - refreshDirectory", () => {
it("reloads the entire tree when refreshing the root directory", async () => {
const initialEntries = [
{ path: "/root/file.ts", name: "file.ts", isDirectory: false, children: undefined },
];
setMockInvokeResult("list_directory", initialEntries);
await editorStore.initializeFileTree("/root");
expect(get(editorStore.fileTree)).toHaveLength(1);
const updatedEntries = [
{ path: "/root/file.ts", name: "file.ts", isDirectory: false, children: undefined },
{ path: "/root/new.ts", name: "new.ts", isDirectory: false, children: undefined },
];
setMockInvokeResult("list_directory", updatedEntries);
await editorStore.refreshDirectory("/root");
expect(get(editorStore.fileTree)).toHaveLength(2);
});
it("updates a subdirectory entry in the tree", async () => {
const initialEntries = [
{
path: "/root/subdir",
name: "subdir",
isDirectory: true,
isExpanded: true,
children: [],
},
];
setMockInvokeResult("list_directory", initialEntries);
await editorStore.initializeFileTree("/root");
const subChildren = [
{ path: "/root/subdir/file.ts", name: "file.ts", isDirectory: false, children: undefined },
];
setMockInvokeResult("list_directory", subChildren);
await editorStore.refreshDirectory("/root/subdir");
const tree = get(editorStore.fileTree);
expect(tree[0].children).toEqual(subChildren);
expect(tree[0].isExpanded).toBe(true);
});
});
+72
View File
@@ -0,0 +1,72 @@
import { describe, it, expect, beforeEach } from "vitest";
import {
setShouldRestoreHistory,
setSavedHistory,
getShouldRestoreHistory,
getSavedHistory,
clearHistoryRestore,
} from "./historyRestore";
describe("historyRestore module", () => {
beforeEach(() => {
clearHistoryRestore();
});
describe("initial state", () => {
it("shouldRestoreHistory is false by default", () => {
expect(getShouldRestoreHistory()).toBe(false);
});
it("savedHistory is null by default", () => {
expect(getSavedHistory()).toBeNull();
});
});
describe("setShouldRestoreHistory", () => {
it("sets shouldRestoreHistory to true", () => {
setShouldRestoreHistory(true);
expect(getShouldRestoreHistory()).toBe(true);
});
it("sets shouldRestoreHistory to false", () => {
setShouldRestoreHistory(true);
setShouldRestoreHistory(false);
expect(getShouldRestoreHistory()).toBe(false);
});
});
describe("setSavedHistory", () => {
it("sets the saved history string", () => {
setSavedHistory("some history content");
expect(getSavedHistory()).toBe("some history content");
});
it("sets the saved history to null", () => {
setSavedHistory("some history content");
setSavedHistory(null);
expect(getSavedHistory()).toBeNull();
});
});
describe("clearHistoryRestore", () => {
it("resets shouldRestoreHistory to false", () => {
setShouldRestoreHistory(true);
clearHistoryRestore();
expect(getShouldRestoreHistory()).toBe(false);
});
it("resets savedHistory to null", () => {
setSavedHistory("some content");
clearHistoryRestore();
expect(getSavedHistory()).toBeNull();
});
it("clears both values at once", () => {
setShouldRestoreHistory(true);
setSavedHistory("history");
clearHistoryRestore();
expect(getShouldRestoreHistory()).toBe(false);
expect(getSavedHistory()).toBeNull();
});
});
});
+56
View File
@@ -0,0 +1,56 @@
import { describe, it, expect, beforeEach } from "vitest";
import { get } from "svelte/store";
import { messageMode, getCurrentMode } from "./messageMode";
describe("messageMode store", () => {
beforeEach(() => {
messageMode.reset();
});
describe("initial state", () => {
it("defaults to chat mode", () => {
expect(get(messageMode)).toBe("chat");
});
});
describe("set", () => {
it("sets the mode to the given value", () => {
messageMode.set("plan");
expect(get(messageMode)).toBe("plan");
});
it("can set any arbitrary mode string", () => {
messageMode.set("auto");
expect(get(messageMode)).toBe("auto");
});
});
describe("reset", () => {
it("resets mode back to chat", () => {
messageMode.set("plan");
messageMode.reset();
expect(get(messageMode)).toBe("chat");
});
});
});
describe("getCurrentMode", () => {
beforeEach(() => {
messageMode.reset();
});
it("returns chat when in default state", () => {
expect(getCurrentMode()).toBe("chat");
});
it("returns the currently set mode", () => {
messageMode.set("plan");
expect(getCurrentMode()).toBe("plan");
});
it("returns chat after a reset", () => {
messageMode.set("auto");
messageMode.reset();
expect(getCurrentMode()).toBe("chat");
});
});
+104
View File
@@ -0,0 +1,104 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { writable } from "svelte/store";
const mockSetEnabled = vi.fn();
const mockSetGlobalVolume = vi.fn();
vi.mock("$lib/notifications", () => ({
soundPlayer: {
setEnabled: mockSetEnabled,
setGlobalVolume: mockSetGlobalVolume,
},
}));
// We need to control the config store's emitted values
const configWritable = writable({
notifications_enabled: true,
notification_volume: 0.7,
});
vi.mock("./config", () => ({
configStore: {
config: { subscribe: configWritable.subscribe },
},
}));
describe("notifications sync store", () => {
beforeEach(async () => {
vi.resetModules();
mockSetEnabled.mockReset();
mockSetGlobalVolume.mockReset();
configWritable.set({ notifications_enabled: true, notification_volume: 0.7 });
});
afterEach(async () => {
// Re-import to clean up any lingering subscriptions
const { cleanupNotificationSync } = await import("./notifications");
cleanupNotificationSync();
});
describe("initNotificationSync", () => {
it("syncs soundPlayer enabled state from config on init", async () => {
const { initNotificationSync } = await import("./notifications");
initNotificationSync();
expect(mockSetEnabled).toHaveBeenCalledWith(true);
});
it("syncs soundPlayer volume from config on init", async () => {
const { initNotificationSync } = await import("./notifications");
initNotificationSync();
expect(mockSetGlobalVolume).toHaveBeenCalledWith(0.7);
});
it("updates soundPlayer when config changes", async () => {
const { initNotificationSync } = await import("./notifications");
initNotificationSync();
mockSetEnabled.mockReset();
mockSetGlobalVolume.mockReset();
configWritable.set({ notifications_enabled: false, notification_volume: 0.3 });
expect(mockSetEnabled).toHaveBeenCalledWith(false);
expect(mockSetGlobalVolume).toHaveBeenCalledWith(0.3);
});
it("does not register a duplicate subscription when called twice", async () => {
const { initNotificationSync } = await import("./notifications");
initNotificationSync();
initNotificationSync();
// Both calls should only produce one subscription (one initial sync)
expect(mockSetEnabled).toHaveBeenCalledTimes(1);
});
});
describe("cleanupNotificationSync", () => {
it("stops reacting to config changes after cleanup", async () => {
const { initNotificationSync, cleanupNotificationSync } = await import("./notifications");
initNotificationSync();
cleanupNotificationSync();
mockSetEnabled.mockReset();
configWritable.set({ notifications_enabled: false, notification_volume: 0.5 });
expect(mockSetEnabled).not.toHaveBeenCalled();
});
it("is safe to call when not initialised", async () => {
const { cleanupNotificationSync } = await import("./notifications");
expect(() => cleanupNotificationSync()).not.toThrow();
});
it("allows re-initialisation after cleanup", async () => {
const { initNotificationSync, cleanupNotificationSync } = await import("./notifications");
initNotificationSync();
cleanupNotificationSync();
mockSetEnabled.mockReset();
initNotificationSync();
expect(mockSetEnabled).toHaveBeenCalledTimes(1);
});
});
});
+187
View File
@@ -0,0 +1,187 @@
import { describe, it, expect, beforeEach } from "vitest";
import { get } from "svelte/store";
import { searchState, isSearchActive, searchQuery } from "./search";
describe("searchState store", () => {
beforeEach(() => {
searchState.clear();
});
describe("initial state", () => {
it("starts with empty query", () => {
const state = get(searchState);
expect(state.query).toBe("");
});
it("starts inactive", () => {
const state = get(searchState);
expect(state.isActive).toBe(false);
});
it("starts with zero match count", () => {
const state = get(searchState);
expect(state.matchCount).toBe(0);
});
it("starts with zero current match index", () => {
const state = get(searchState);
expect(state.currentMatchIndex).toBe(0);
});
});
describe("setQuery", () => {
it("sets the query string", () => {
searchState.setQuery("hello");
expect(get(searchState).query).toBe("hello");
});
it("activates the store when query is non-empty", () => {
searchState.setQuery("hello");
expect(get(searchState).isActive).toBe(true);
});
it("deactivates the store when query is empty", () => {
searchState.setQuery("hello");
searchState.setQuery("");
expect(get(searchState).isActive).toBe(false);
});
it("resets currentMatchIndex to 0 on each query change", () => {
searchState.setQuery("hello");
searchState.setMatchCount(5);
searchState.nextMatch();
searchState.nextMatch();
searchState.setQuery("world");
expect(get(searchState).currentMatchIndex).toBe(0);
});
});
describe("setMatchCount", () => {
it("updates the match count", () => {
searchState.setMatchCount(7);
expect(get(searchState).matchCount).toBe(7);
});
it("does not reset currentMatchIndex", () => {
searchState.setQuery("test");
searchState.setMatchCount(5);
searchState.nextMatch();
searchState.setMatchCount(10);
expect(get(searchState).currentMatchIndex).toBe(1);
});
});
describe("nextMatch", () => {
it("advances to the next match index", () => {
searchState.setMatchCount(5);
searchState.nextMatch();
expect(get(searchState).currentMatchIndex).toBe(1);
});
it("wraps around to 0 after the last match", () => {
searchState.setMatchCount(3);
searchState.nextMatch();
searchState.nextMatch();
searchState.nextMatch();
expect(get(searchState).currentMatchIndex).toBe(0);
});
it("stays at 0 when match count is 0", () => {
searchState.setMatchCount(0);
searchState.nextMatch();
expect(get(searchState).currentMatchIndex).toBe(0);
});
});
describe("previousMatch", () => {
it("goes to the previous match index", () => {
searchState.setMatchCount(5);
searchState.nextMatch();
searchState.nextMatch();
searchState.previousMatch();
expect(get(searchState).currentMatchIndex).toBe(1);
});
it("wraps around to last match when at index 0", () => {
searchState.setMatchCount(5);
searchState.previousMatch();
expect(get(searchState).currentMatchIndex).toBe(4);
});
it("stays at 0 when match count is 0", () => {
searchState.setMatchCount(0);
searchState.previousMatch();
expect(get(searchState).currentMatchIndex).toBe(0);
});
});
describe("clear", () => {
it("resets query to empty string", () => {
searchState.setQuery("hello");
searchState.clear();
expect(get(searchState).query).toBe("");
});
it("resets isActive to false", () => {
searchState.setQuery("hello");
searchState.clear();
expect(get(searchState).isActive).toBe(false);
});
it("resets matchCount to 0", () => {
searchState.setMatchCount(10);
searchState.clear();
expect(get(searchState).matchCount).toBe(0);
});
it("resets currentMatchIndex to 0", () => {
searchState.setMatchCount(5);
searchState.nextMatch();
searchState.nextMatch();
searchState.clear();
expect(get(searchState).currentMatchIndex).toBe(0);
});
});
});
describe("isSearchActive derived store", () => {
beforeEach(() => {
searchState.clear();
});
it("is false initially", () => {
expect(get(isSearchActive)).toBe(false);
});
it("is true when query is set", () => {
searchState.setQuery("test");
expect(get(isSearchActive)).toBe(true);
});
it("is false after clearing", () => {
searchState.setQuery("test");
searchState.clear();
expect(get(isSearchActive)).toBe(false);
});
});
describe("searchQuery derived store", () => {
beforeEach(() => {
searchState.clear();
});
it("is empty string initially", () => {
expect(get(searchQuery)).toBe("");
});
it("reflects the current query", () => {
searchState.setQuery("my search");
expect(get(searchQuery)).toBe("my search");
});
it("is empty after clearing", () => {
searchState.setQuery("test");
searchState.clear();
expect(get(searchQuery)).toBe("");
});
});
+569
View File
@@ -0,0 +1,569 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { get } from "svelte/store";
import { sessionsStore } from "$lib/stores/sessions";
import { setMockInvokeResult } from "../../../vitest.setup";
import type { SavedSession } from "$lib/stores/sessions";
vi.mock("@tauri-apps/plugin-dialog", () => ({
save: vi.fn(),
open: vi.fn(),
}));
vi.mock("@tauri-apps/plugin-fs", () => ({
writeTextFile: vi.fn().mockResolvedValue(undefined),
readTextFile: vi.fn(),
}));
vi.mock("@tauri-apps/plugin-opener", () => ({
openPath: vi.fn().mockResolvedValue(undefined),
}));
const makeSavedSession = (overrides: Partial<SavedSession> = {}): SavedSession => ({
id: "session-1",
name: "Test Session",
created_at: "2026-03-03T10:00:00.000Z",
last_activity_at: "2026-03-03T11:00:00.000Z",
working_directory: "/home/naomi/code",
message_count: 2,
preview: "Hello world",
messages: [
{ id: "msg-1", type: "user", content: "Hello world", timestamp: "2026-03-03T10:00:00.000Z" },
{ id: "msg-2", type: "assistant", content: "Hi there!", timestamp: "2026-03-03T10:01:00.000Z" },
],
...overrides,
});
const makeConversation = () => ({
id: "conv-1",
name: "Test Conversation",
workingDirectory: "/home/naomi/code",
createdAt: new Date("2026-03-03T10:00:00.000Z"),
lastActivityAt: new Date("2026-03-03T11:00:00.000Z"),
terminalLines: [
{
id: "line-1",
type: "user",
content: "Hello",
timestamp: new Date("2026-03-03T10:00:00.000Z"),
toolName: undefined,
},
{
id: "line-2",
type: "assistant",
content: "Hi",
timestamp: new Date("2026-03-03T10:01:00.000Z"),
toolName: undefined,
},
],
});
describe("sessionsStore - loadSessions", () => {
it("loads sessions from backend and updates the store", async () => {
const sessionList = [{ id: "session-1", name: "Test", message_count: 1, preview: "..." }];
setMockInvokeResult("list_sessions", sessionList);
await sessionsStore.loadSessions();
expect(get(sessionsStore.sessions)).toEqual(sessionList);
});
it("handles errors gracefully", async () => {
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
setMockInvokeResult("list_sessions", new Error("Backend error"));
await sessionsStore.loadSessions();
expect(spy).toHaveBeenCalledWith("Failed to load sessions:", expect.any(Error));
spy.mockRestore();
});
it("sets isLoading to false after completion", async () => {
setMockInvokeResult("list_sessions", []);
await sessionsStore.loadSessions();
expect(get(sessionsStore.isLoading)).toBe(false);
});
});
describe("sessionsStore - loadSession", () => {
it("returns session data on success", async () => {
const session = makeSavedSession();
setMockInvokeResult("load_session", session);
const result = await sessionsStore.loadSession("session-1");
expect(result).toEqual(session);
});
it("returns null on error", async () => {
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
setMockInvokeResult("load_session", new Error("Not found"));
const result = await sessionsStore.loadSession("session-1");
expect(result).toBeNull();
spy.mockRestore();
});
});
describe("sessionsStore - deleteSession", () => {
it("deletes session and reloads the session list", async () => {
setMockInvokeResult("delete_session", undefined);
setMockInvokeResult("list_sessions", []);
await sessionsStore.deleteSession("session-1");
expect(get(sessionsStore.sessions)).toEqual([]);
});
it("handles errors gracefully", async () => {
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
setMockInvokeResult("delete_session", new Error("Failed"));
await sessionsStore.deleteSession("session-1");
expect(spy).toHaveBeenCalledWith("Failed to delete session:", expect.any(Error));
spy.mockRestore();
});
});
describe("sessionsStore - searchSessions", () => {
it("calls loadSessions when query is empty", async () => {
setMockInvokeResult("list_sessions", []);
await sessionsStore.searchSessions("");
expect(get(sessionsStore.sessions)).toEqual([]);
});
it("calls loadSessions when query is whitespace-only", async () => {
setMockInvokeResult("list_sessions", []);
await sessionsStore.searchSessions(" ");
expect(get(sessionsStore.sessions)).toEqual([]);
});
it("searches with the given query", async () => {
const results = [{ id: "session-1", name: "Test", message_count: 1, preview: "..." }];
setMockInvokeResult("search_sessions", results);
await sessionsStore.searchSessions("test");
expect(get(sessionsStore.sessions)).toEqual(results);
});
it("updates searchQuery store", async () => {
setMockInvokeResult("search_sessions", []);
await sessionsStore.searchSessions("hello");
expect(get(sessionsStore.searchQuery)).toBe("hello");
await sessionsStore.searchSessions("");
});
it("handles search errors gracefully", async () => {
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
setMockInvokeResult("search_sessions", new Error("Search failed"));
await sessionsStore.searchSessions("error-query");
expect(spy).toHaveBeenCalledWith("Failed to search sessions:", expect.any(Error));
spy.mockRestore();
});
});
describe("sessionsStore - clearAllSessions", () => {
it("clears all sessions", async () => {
setMockInvokeResult("clear_all_sessions", undefined);
await sessionsStore.clearAllSessions();
expect(get(sessionsStore.sessions)).toEqual([]);
});
it("handles errors gracefully", async () => {
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
setMockInvokeResult("clear_all_sessions", new Error("Failed"));
await sessionsStore.clearAllSessions();
expect(spy).toHaveBeenCalledWith("Failed to clear sessions:", expect.any(Error));
spy.mockRestore();
});
});
describe("sessionsStore - saveConversation", () => {
it("saves conversation to backend", async () => {
setMockInvokeResult("save_session", undefined);
setMockInvokeResult("list_sessions", []);
await sessionsStore.saveConversation(makeConversation() as never);
});
it("handles save errors gracefully", async () => {
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
setMockInvokeResult("save_session", new Error("Save failed"));
await sessionsStore.saveConversation(makeConversation() as never);
expect(spy).toHaveBeenCalledWith("Failed to save session:", expect.any(Error));
spy.mockRestore();
});
it("handles empty conversation", async () => {
setMockInvokeResult("save_session", undefined);
setMockInvokeResult("list_sessions", []);
const conv = { ...makeConversation(), terminalLines: [] };
await sessionsStore.saveConversation(conv as never);
});
});
describe("sessionsStore - scheduleAutoSave and cancelAutoSave", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("schedules an auto-save after the debounce delay", async () => {
setMockInvokeResult("save_session", undefined);
setMockInvokeResult("list_sessions", []);
sessionsStore.scheduleAutoSave(makeConversation() as never);
await vi.advanceTimersByTimeAsync(2001);
});
it("does not auto-save empty conversations", async () => {
const conv = { ...makeConversation(), terminalLines: [] };
sessionsStore.scheduleAutoSave(conv as never);
await vi.advanceTimersByTimeAsync(3000);
});
it("cancels a pending auto-save", () => {
const conv = makeConversation();
sessionsStore.scheduleAutoSave(conv as never);
sessionsStore.cancelAutoSave(conv.id);
});
it("handles cancel when no auto-save is pending", () => {
sessionsStore.cancelAutoSave("non-existent-id");
});
it("clears an existing pending auto-save when rescheduled for the same conversation", async () => {
setMockInvokeResult("save_session", undefined);
setMockInvokeResult("list_sessions", []);
const conv = makeConversation();
sessionsStore.scheduleAutoSave(conv as never);
// Schedule again before timer fires — hits clearTimeout branch (line 487)
sessionsStore.scheduleAutoSave(conv as never);
await vi.advanceTimersByTimeAsync(2001);
});
});
describe("sessionsStore - exportSessionAsJson", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("returns true on successful export", async () => {
const { save } = await import("@tauri-apps/plugin-dialog");
setMockInvokeResult("load_session", makeSavedSession());
vi.mocked(save).mockResolvedValue("/output/session.json");
expect(await sessionsStore.exportSessionAsJson("session-1")).toBe(true);
});
it("returns false when session not found", async () => {
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
setMockInvokeResult("load_session", null);
expect(await sessionsStore.exportSessionAsJson("session-1")).toBe(false);
spy.mockRestore();
});
it("returns false when user cancels save dialog", async () => {
const { save } = await import("@tauri-apps/plugin-dialog");
setMockInvokeResult("load_session", makeSavedSession());
vi.mocked(save).mockResolvedValue(null);
expect(await sessionsStore.exportSessionAsJson("session-1")).toBe(false);
});
it("returns false on error", async () => {
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
setMockInvokeResult("load_session", new Error("Load failed"));
expect(await sessionsStore.exportSessionAsJson("session-1")).toBe(false);
spy.mockRestore();
});
it("returns false when writeTextFile throws", async () => {
const { save } = await import("@tauri-apps/plugin-dialog");
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
setMockInvokeResult("load_session", makeSavedSession());
vi.mocked(save).mockResolvedValue("/output/session.json");
vi.mocked(writeTextFile).mockRejectedValueOnce(new Error("Disk full"));
expect(await sessionsStore.exportSessionAsJson("session-1")).toBe(false);
spy.mockRestore();
});
});
describe("sessionsStore - exportSessionAsMarkdown", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("generates markdown with all message types", async () => {
const { save } = await import("@tauri-apps/plugin-dialog");
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
const session = makeSavedSession({
messages: [
{ id: "1", type: "user", content: "User message", timestamp: "2026-03-03T10:00:00Z" },
{
id: "2",
type: "assistant",
content: "Assistant reply",
timestamp: "2026-03-03T10:01:00Z",
},
{
id: "3",
type: "tool_use",
content: "Tool input",
timestamp: "2026-03-03T10:02:00Z",
tool_name: "bash",
},
{ id: "4", type: "tool_result", content: "Tool output", timestamp: "2026-03-03T10:03:00Z" },
{ id: "5", type: "system", content: "System event", timestamp: "2026-03-03T10:04:00Z" },
{ id: "6", type: "error", content: "Error message", timestamp: "2026-03-03T10:05:00Z" },
],
});
setMockInvokeResult("load_session", session);
vi.mocked(save).mockResolvedValue("/output/session.md");
expect(await sessionsStore.exportSessionAsMarkdown("session-1")).toBe(true);
const content = vi.mocked(writeTextFile).mock.calls[0][1] as string;
expect(content).toContain("User message");
expect(content).toContain("Assistant reply");
expect(content).toContain("Tool: bash");
expect(content).toContain("Tool input");
expect(content).toContain("Tool output");
expect(content).toContain("System event");
expect(content).toContain("Error message");
});
it("returns false when session not found", async () => {
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
setMockInvokeResult("load_session", null);
expect(await sessionsStore.exportSessionAsMarkdown("session-1")).toBe(false);
spy.mockRestore();
});
it("returns false when user cancels dialog", async () => {
const { save } = await import("@tauri-apps/plugin-dialog");
setMockInvokeResult("load_session", makeSavedSession());
vi.mocked(save).mockResolvedValue(null);
expect(await sessionsStore.exportSessionAsMarkdown("session-1")).toBe(false);
});
it("returns false on error", async () => {
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
setMockInvokeResult("load_session", new Error("Load failed"));
expect(await sessionsStore.exportSessionAsMarkdown("session-1")).toBe(false);
spy.mockRestore();
});
it("returns false when writeTextFile throws", async () => {
const { save } = await import("@tauri-apps/plugin-dialog");
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
setMockInvokeResult("load_session", makeSavedSession());
vi.mocked(save).mockResolvedValue("/output/session.md");
vi.mocked(writeTextFile).mockRejectedValueOnce(new Error("Disk full"));
expect(await sessionsStore.exportSessionAsMarkdown("session-1")).toBe(false);
spy.mockRestore();
});
});
describe("sessionsStore - exportSessionAsHtml (tests escapeHtml + generateHtmlExport)", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("generates HTML and returns true on success", async () => {
const { save } = await import("@tauri-apps/plugin-dialog");
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
setMockInvokeResult("load_session", makeSavedSession());
vi.mocked(save).mockResolvedValue("/output/session.html");
expect(await sessionsStore.exportSessionAsHtml("session-1")).toBe(true);
const content = vi.mocked(writeTextFile).mock.calls[0][1] as string;
expect(content).toContain("<!DOCTYPE html>");
expect(content).toContain("Test Session");
});
it("escapes HTML characters in session name", async () => {
const { save } = await import("@tauri-apps/plugin-dialog");
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
setMockInvokeResult("load_session", makeSavedSession({ name: "<Test & 'Session'>" }));
vi.mocked(save).mockResolvedValue("/output/session.html");
await sessionsStore.exportSessionAsHtml("session-1");
const content = vi.mocked(writeTextFile).mock.calls[0][1] as string;
expect(content).toContain("&lt;Test &amp; &#039;Session&#039;&gt;");
expect(content).not.toContain("<Test & 'Session'>");
});
it("escapes HTML characters in message content", async () => {
const { save } = await import("@tauri-apps/plugin-dialog");
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
setMockInvokeResult(
"load_session",
makeSavedSession({
messages: [
{
id: "1",
type: "user",
content: '<script>alert("xss")</script>',
timestamp: "2026-03-03T10:00:00Z",
},
],
})
);
vi.mocked(save).mockResolvedValue("/output/session.html");
await sessionsStore.exportSessionAsHtml("session-1");
const content = vi.mocked(writeTextFile).mock.calls[0][1] as string;
expect(content).toContain("&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;");
expect(content).not.toContain("<script>");
});
it("escapes HTML in tool names", async () => {
const { save } = await import("@tauri-apps/plugin-dialog");
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
setMockInvokeResult(
"load_session",
makeSavedSession({
messages: [
{
id: "1",
type: "tool_use",
content: "input",
timestamp: "2026-03-03T10:00:00Z",
tool_name: "<dangerous>",
},
],
})
);
vi.mocked(save).mockResolvedValue("/output/session.html");
await sessionsStore.exportSessionAsHtml("session-1");
const content = vi.mocked(writeTextFile).mock.calls[0][1] as string;
expect(content).toContain("&lt;dangerous&gt;");
expect(content).not.toContain("<dangerous>");
});
it("excludes metadata when includeMetadata is false", async () => {
const { save } = await import("@tauri-apps/plugin-dialog");
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
setMockInvokeResult("load_session", makeSavedSession());
vi.mocked(save).mockResolvedValue("/output/session.html");
await sessionsStore.exportSessionAsHtml("session-1", false);
const content = vi.mocked(writeTextFile).mock.calls[0][1] as string;
expect(content).not.toContain('class="metadata"');
});
it("returns false when session not found", async () => {
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
setMockInvokeResult("load_session", null);
expect(await sessionsStore.exportSessionAsHtml("session-1")).toBe(false);
spy.mockRestore();
});
it("returns false when user cancels dialog", async () => {
const { save } = await import("@tauri-apps/plugin-dialog");
setMockInvokeResult("load_session", makeSavedSession());
vi.mocked(save).mockResolvedValue(null);
expect(await sessionsStore.exportSessionAsHtml("session-1")).toBe(false);
});
it("returns false on error", async () => {
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
setMockInvokeResult("load_session", new Error("Load failed"));
expect(await sessionsStore.exportSessionAsHtml("session-1")).toBe(false);
spy.mockRestore();
});
it("returns false when writeTextFile throws", async () => {
const { save } = await import("@tauri-apps/plugin-dialog");
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
setMockInvokeResult("load_session", makeSavedSession());
vi.mocked(save).mockResolvedValue("/output/session.html");
vi.mocked(writeTextFile).mockRejectedValueOnce(new Error("Disk full"));
expect(await sessionsStore.exportSessionAsHtml("session-1")).toBe(false);
spy.mockRestore();
});
});
describe("sessionsStore - exportSessionAsPdf (tests generatePrintableHtml)", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("generates printable HTML and opens it in the browser", async () => {
const { save } = await import("@tauri-apps/plugin-dialog");
const { openPath } = await import("@tauri-apps/plugin-opener");
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
setMockInvokeResult("load_session", makeSavedSession());
vi.mocked(save).mockResolvedValue("/output/session-print.html");
expect(await sessionsStore.exportSessionAsPdf("session-1")).toBe(true);
expect(openPath).toHaveBeenCalledWith("/output/session-print.html");
const content = vi.mocked(writeTextFile).mock.calls[0][1] as string;
expect(content).toContain("<!DOCTYPE html>");
});
it("excludes metadata when includeMetadata is false", async () => {
const { save } = await import("@tauri-apps/plugin-dialog");
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
setMockInvokeResult("load_session", makeSavedSession());
vi.mocked(save).mockResolvedValue("/output/session-print.html");
await sessionsStore.exportSessionAsPdf("session-1", false);
expect(vi.mocked(writeTextFile).mock.calls[0][1]).toBeDefined();
});
it("returns false when session not found", async () => {
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
setMockInvokeResult("load_session", null);
expect(await sessionsStore.exportSessionAsPdf("session-1")).toBe(false);
spy.mockRestore();
});
it("returns false when user cancels dialog", async () => {
const { save } = await import("@tauri-apps/plugin-dialog");
setMockInvokeResult("load_session", makeSavedSession());
vi.mocked(save).mockResolvedValue(null);
expect(await sessionsStore.exportSessionAsPdf("session-1")).toBe(false);
});
it("returns false on error", async () => {
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
setMockInvokeResult("load_session", new Error("Load failed"));
expect(await sessionsStore.exportSessionAsPdf("session-1")).toBe(false);
spy.mockRestore();
});
it("returns false when writeTextFile throws", async () => {
const { save } = await import("@tauri-apps/plugin-dialog");
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
setMockInvokeResult("load_session", makeSavedSession());
vi.mocked(save).mockResolvedValue("/output/session-print.html");
vi.mocked(writeTextFile).mockRejectedValueOnce(new Error("Disk full"));
expect(await sessionsStore.exportSessionAsPdf("session-1")).toBe(false);
spy.mockRestore();
});
});
describe("sessionsStore - importSession", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("imports session from JSON file and returns true", async () => {
const { open } = await import("@tauri-apps/plugin-dialog");
const { readTextFile } = await import("@tauri-apps/plugin-fs");
vi.mocked(open).mockResolvedValue("/input/session.json");
vi.mocked(readTextFile).mockResolvedValue(JSON.stringify({ session: makeSavedSession() }));
setMockInvokeResult("save_session", undefined);
setMockInvokeResult("list_sessions", []);
expect(await sessionsStore.importSession()).toBe(true);
});
it("returns false when user cancels file dialog", async () => {
const { open } = await import("@tauri-apps/plugin-dialog");
vi.mocked(open).mockResolvedValue(null);
expect(await sessionsStore.importSession()).toBe(false);
});
it("returns false when file has invalid format", async () => {
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
const { open } = await import("@tauri-apps/plugin-dialog");
const { readTextFile } = await import("@tauri-apps/plugin-fs");
vi.mocked(open).mockResolvedValue("/input/bad.json");
vi.mocked(readTextFile).mockResolvedValue(JSON.stringify({ not_a_session: true }));
expect(await sessionsStore.importSession()).toBe(false);
spy.mockRestore();
});
it("returns false on error", async () => {
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
const { open } = await import("@tauri-apps/plugin-dialog");
vi.mocked(open).mockRejectedValue(new Error("Dialog failed"));
expect(await sessionsStore.importSession()).toBe(false);
spy.mockRestore();
});
});
+293 -2
View File
@@ -1,5 +1,7 @@
import { describe, it, expect, beforeEach, vi } from "vitest"; import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { get } from "svelte/store"; import { get } from "svelte/store";
import { listen } from "@tauri-apps/api/event";
import { invoke } from "@tauri-apps/api/core";
import { import {
stats, stats,
formattedStats, formattedStats,
@@ -9,8 +11,13 @@ import {
estimateMessageCost, estimateMessageCost,
formatTokenCount, formatTokenCount,
MODEL_PRICING, MODEL_PRICING,
checkBudget,
getBudgetStatusMessage,
getRemainingTokenBudget,
getRemainingCostBudget,
initStatsListener,
} from "./stats"; } from "./stats";
import type { UsageStats, ToolTokenStats } from "./stats"; import type { UsageStats, ToolTokenStats, BudgetStatus } from "./stats";
// Helper function to create ToolTokenStats for tests // Helper function to create ToolTokenStats for tests
function toolStats(callCount: number, inputTokens = 0, outputTokens = 0): ToolTokenStats { function toolStats(callCount: number, inputTokens = 0, outputTokens = 0): ToolTokenStats {
@@ -30,6 +37,12 @@ vi.mock("@tauri-apps/api/core", () => ({
invoke: vi.fn(), invoke: vi.fn(),
})); }));
vi.mock("./costTracking", () => ({
costTrackingStore: {
refresh: vi.fn().mockResolvedValue([]),
},
}));
describe("stats store", () => { describe("stats store", () => {
beforeEach(() => { beforeEach(() => {
// Reset stats to default before each test // Reset stats to default before each test
@@ -600,4 +613,282 @@ describe("stats store", () => {
expect(MODEL_PRICING["claude-3-haiku-20240307"]).toEqual({ input: 0.25, output: 1.25 }); expect(MODEL_PRICING["claude-3-haiku-20240307"]).toEqual({ input: 0.25, output: 1.25 });
}); });
}); });
describe("checkBudget", () => {
const baseStats: UsageStats = {
total_input_tokens: 0,
total_output_tokens: 0,
total_cost_usd: 0,
session_input_tokens: 500,
session_output_tokens: 500,
session_cost_usd: 0.5,
model: null,
messages_exchanged: 0,
session_messages_exchanged: 0,
code_blocks_generated: 0,
session_code_blocks_generated: 0,
files_edited: 0,
session_files_edited: 0,
files_created: 0,
session_files_created: 0,
tools_usage: {},
session_tools_usage: {},
session_duration_seconds: 0,
context_tokens_used: 0,
context_window_limit: 200000,
context_utilisation_percent: 0,
potential_cache_hits: 0,
potential_cache_savings_tokens: 0,
};
it("returns ok when budget is disabled", () => {
const result = checkBudget(baseStats, false, 100, 1.0, 0.8);
expect(result).toEqual({ type: "ok" });
});
it("returns ok when under all budgets", () => {
const result = checkBudget(baseStats, true, 10000, 10.0, 0.8);
expect(result).toEqual({ type: "ok" });
});
it("returns exceeded when token budget is reached", () => {
const result = checkBudget(baseStats, true, 1000, null, 0.8);
expect(result).toEqual({ type: "exceeded", budget_type: "token" });
});
it("returns warning when token usage is above threshold", () => {
// session tokens = 1000, budget = 1100, threshold = 0.8 → 1000/1100 ≈ 0.909 > 0.8
const result = checkBudget(baseStats, true, 1100, null, 0.8);
expect(result.type).toBe("warning");
if (result.type === "warning") {
expect(result.budget_type).toBe("token");
expect(result.percent_used).toBeGreaterThan(80);
}
});
it("returns exceeded when cost budget is reached", () => {
const result = checkBudget(baseStats, true, null, 0.5, 0.8);
expect(result).toEqual({ type: "exceeded", budget_type: "cost" });
});
it("returns warning when cost usage is above threshold", () => {
// session cost = 0.5, budget = 0.55, threshold = 0.8 → 0.5/0.55 ≈ 0.909 > 0.8
const result = checkBudget(baseStats, true, null, 0.55, 0.8);
expect(result.type).toBe("warning");
if (result.type === "warning") {
expect(result.budget_type).toBe("cost");
}
});
it("checks token budget before cost budget", () => {
// Both budgets exceeded, but token should be reported first
const result = checkBudget(baseStats, true, 500, 0.1, 0.8);
expect(result).toEqual({ type: "exceeded", budget_type: "token" });
});
it("returns ok when no budgets are set", () => {
const result = checkBudget(baseStats, true, null, null, 0.8);
expect(result).toEqual({ type: "ok" });
});
});
describe("getBudgetStatusMessage", () => {
it("returns null for ok status", () => {
const status: BudgetStatus = { type: "ok" };
expect(getBudgetStatusMessage(status)).toBeNull();
});
it("returns exceeded message for token budget", () => {
const status: BudgetStatus = { type: "exceeded", budget_type: "token" };
const message = getBudgetStatusMessage(status);
expect(message).toContain("token");
expect(message).toContain("exceeded");
});
it("returns exceeded message for cost budget", () => {
const status: BudgetStatus = { type: "exceeded", budget_type: "cost" };
const message = getBudgetStatusMessage(status);
expect(message).toContain("cost");
expect(message).toContain("exceeded");
});
it("returns warning message with percentage for token budget", () => {
const status: BudgetStatus = { type: "warning", budget_type: "token", percent_used: 85.5 };
const message = getBudgetStatusMessage(status);
expect(message).toContain("token");
expect(message).toContain("86%");
});
it("returns warning message with percentage for cost budget", () => {
const status: BudgetStatus = { type: "warning", budget_type: "cost", percent_used: 90 };
const message = getBudgetStatusMessage(status);
expect(message).toContain("cost");
expect(message).toContain("90%");
});
});
describe("getRemainingTokenBudget", () => {
const baseStats: UsageStats = {
total_input_tokens: 0,
total_output_tokens: 0,
total_cost_usd: 0,
session_input_tokens: 300,
session_output_tokens: 200,
session_cost_usd: 0,
model: null,
messages_exchanged: 0,
session_messages_exchanged: 0,
code_blocks_generated: 0,
session_code_blocks_generated: 0,
files_edited: 0,
session_files_edited: 0,
files_created: 0,
session_files_created: 0,
tools_usage: {},
session_tools_usage: {},
session_duration_seconds: 0,
context_tokens_used: 0,
context_window_limit: 200000,
context_utilisation_percent: 0,
potential_cache_hits: 0,
potential_cache_savings_tokens: 0,
};
it("returns null when no token budget is set", () => {
expect(getRemainingTokenBudget(baseStats, null)).toBeNull();
});
it("returns the remaining tokens when under budget", () => {
// session tokens = 500, budget = 1000 → remaining = 500
expect(getRemainingTokenBudget(baseStats, 1000)).toBe(500);
});
it("returns 0 when at or over budget", () => {
expect(getRemainingTokenBudget(baseStats, 500)).toBe(0);
expect(getRemainingTokenBudget(baseStats, 400)).toBe(0);
});
});
describe("getRemainingCostBudget", () => {
const baseStats: UsageStats = {
total_input_tokens: 0,
total_output_tokens: 0,
total_cost_usd: 0,
session_input_tokens: 0,
session_output_tokens: 0,
session_cost_usd: 0.3,
model: null,
messages_exchanged: 0,
session_messages_exchanged: 0,
code_blocks_generated: 0,
session_code_blocks_generated: 0,
files_edited: 0,
session_files_edited: 0,
files_created: 0,
session_files_created: 0,
tools_usage: {},
session_tools_usage: {},
session_duration_seconds: 0,
context_tokens_used: 0,
context_window_limit: 200000,
context_utilisation_percent: 0,
potential_cache_hits: 0,
potential_cache_savings_tokens: 0,
};
it("returns null when no cost budget is set", () => {
expect(getRemainingCostBudget(baseStats, null)).toBeNull();
});
it("returns the remaining cost when under budget", () => {
// session cost = 0.3, budget = 1.0 → remaining = 0.7
expect(getRemainingCostBudget(baseStats, 1.0)).toBeCloseTo(0.7, 5);
});
it("returns 0 when at or over budget", () => {
expect(getRemainingCostBudget(baseStats, 0.3)).toBe(0);
expect(getRemainingCostBudget(baseStats, 0.2)).toBe(0);
});
});
describe("initStatsListener", () => {
const emptyStats: UsageStats = {
total_input_tokens: 0,
total_output_tokens: 0,
total_cost_usd: 0,
session_input_tokens: 0,
session_output_tokens: 0,
session_cost_usd: 0,
model: null,
messages_exchanged: 0,
session_messages_exchanged: 0,
code_blocks_generated: 0,
session_code_blocks_generated: 0,
files_edited: 0,
session_files_edited: 0,
files_created: 0,
session_files_created: 0,
tools_usage: {},
session_tools_usage: {},
session_duration_seconds: 0,
context_tokens_used: 0,
context_window_limit: 200000,
context_utilisation_percent: 0,
potential_cache_hits: 0,
potential_cache_savings_tokens: 0,
};
afterEach(() => {
vi.resetAllMocks();
});
it("registers a listener for the claude:stats event", async () => {
vi.mocked(listen).mockResolvedValue(vi.fn());
vi.mocked(invoke).mockResolvedValue(emptyStats);
await initStatsListener();
expect(listen).toHaveBeenCalledWith("claude:stats", expect.any(Function));
});
it("updates the stats store when the listener callback fires", async () => {
let capturedCallback: ((event: unknown) => void) | undefined;
vi.mocked(listen).mockImplementation(async (_event, callback) => {
capturedCallback = callback as (event: unknown) => void;
return () => {};
});
vi.mocked(invoke).mockResolvedValue(emptyStats);
await initStatsListener();
const newStats = { ...emptyStats, total_input_tokens: 9999 };
capturedCallback!({ payload: { stats: newStats } });
expect(get(stats).total_input_tokens).toBe(9999);
});
it("loads persisted stats from the backend on initialisation", async () => {
const persistedStats = { ...emptyStats, total_input_tokens: 5000 };
vi.mocked(listen).mockResolvedValue(vi.fn());
vi.mocked(invoke).mockResolvedValue(persistedStats);
await initStatsListener();
expect(invoke).toHaveBeenCalledWith("get_persisted_stats");
expect(get(stats).total_input_tokens).toBe(5000);
});
it("handles a failed invoke gracefully and logs the error", async () => {
const error = new Error("Failed to get stats");
vi.mocked(listen).mockResolvedValue(vi.fn());
vi.mocked(invoke).mockRejectedValue(error);
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
await initStatsListener();
expect(consoleErrorSpy).toHaveBeenCalledWith("Failed to load initial stats:", error);
consoleErrorSpy.mockRestore();
});
});
}); });
+1
View File
@@ -12,6 +12,7 @@ export type BudgetType = "token" | "cost";
export const MODEL_PRICING: Record<string, { input: number; output: number }> = { export const MODEL_PRICING: Record<string, { input: number; output: number }> = {
// Current generation (Claude 4.6) // Current generation (Claude 4.6)
"claude-opus-4-6": { input: 5.0, output: 25.0 }, "claude-opus-4-6": { input: 5.0, output: 25.0 },
"claude-sonnet-4-6": { input: 3.0, output: 15.0 },
// Previous generation (Claude 4.5) // Previous generation (Claude 4.5)
"claude-opus-4-5-20251101": { input: 5.0, output: 25.0 }, "claude-opus-4-5-20251101": { input: 5.0, output: 25.0 },
"claude-sonnet-4-5-20250929": { input: 3.0, output: 15.0 }, "claude-sonnet-4-5-20250929": { input: 3.0, output: 15.0 },
+93
View File
@@ -0,0 +1,93 @@
import { describe, it, expect, beforeEach } from "vitest";
import { get } from "svelte/store";
import { emitMockEvent } from "../../../vitest.setup";
import { todos, initializeTodoListener, cleanupTodoListener } from "./todos";
import type { TodoItem } from "./todos";
describe("todos store", () => {
beforeEach(() => {
cleanupTodoListener();
todos.clear();
});
describe("initial state", () => {
it("starts with an empty list", () => {
expect(get(todos)).toEqual([]);
});
});
describe("set and clear", () => {
it("can set todos directly", () => {
const items: TodoItem[] = [
{ content: "Task 1", status: "pending", activeForm: "Doing task 1" },
{ content: "Task 2", status: "in_progress", activeForm: "Doing task 2" },
];
todos.set(items);
expect(get(todos)).toEqual(items);
});
it("clear resets to empty array", () => {
todos.set([{ content: "Task", status: "completed", activeForm: "Doing task" }]);
todos.clear();
expect(get(todos)).toEqual([]);
});
});
describe("update", () => {
it("can update todos with a callback", () => {
const initial: TodoItem[] = [
{ content: "Task 1", status: "pending", activeForm: "Doing task 1" },
];
todos.set(initial);
todos.update((current) => [
...current,
{ content: "Task 2", status: "completed", activeForm: "Doing task 2" },
]);
expect(get(todos)).toHaveLength(2);
});
});
describe("initializeTodoListener", () => {
it("listens for claude:todo-update events", async () => {
await initializeTodoListener();
const newTodos: TodoItem[] = [
{ content: "Backend task", status: "in_progress", activeForm: "Running backend task" },
];
emitMockEvent("claude:todo-update", { todos: newTodos });
expect(get(todos)).toEqual(newTodos);
});
it("does not register a duplicate listener when called twice", async () => {
await initializeTodoListener();
await initializeTodoListener();
const newTodos: TodoItem[] = [
{ content: "Task", status: "pending", activeForm: "Doing task" },
];
emitMockEvent("claude:todo-update", { todos: newTodos });
// Should only have been set once (not doubled)
expect(get(todos)).toEqual(newTodos);
});
});
describe("cleanupTodoListener", () => {
it("stops listening for events after cleanup", async () => {
await initializeTodoListener();
cleanupTodoListener();
emitMockEvent("claude:todo-update", {
todos: [{ content: "Task", status: "pending", activeForm: "Doing task" }],
});
// Should not have been updated since we cleaned up
expect(get(todos)).toEqual([]);
});
it("is safe to call when not initialised", () => {
expect(() => cleanupTodoListener()).not.toThrow();
});
});
});
+142 -9
View File
@@ -21,6 +21,7 @@ import {
handleConnectionStatusChange, handleConnectionStatusChange,
handleNewUserMessage, handleNewUserMessage,
} from "$lib/notifications/rules"; } from "$lib/notifications/rules";
import { notificationManager } from "$lib/notifications/notificationManager";
interface StateChangePayload { interface StateChangePayload {
state: CharacterState; state: CharacterState;
@@ -29,6 +30,7 @@ interface StateChangePayload {
} }
const connectedConversations = new Set<string>(); const connectedConversations = new Set<string>();
const greetingPendingConversations = new Set<string>();
let unlisteners: Array<() => void> = []; let unlisteners: Array<() => void> = [];
let skipNextGreeting = false; let skipNextGreeting = false;
@@ -55,17 +57,17 @@ function generateGreetingPrompt(): string {
return `[System: A new session has started. It's currently ${timeOfDay}. Please greet the user warmly and briefly. Keep it short - just 1-2 sentences.]`; return `[System: A new session has started. It's currently ${timeOfDay}. Please greet the user warmly and briefly. Keep it short - just 1-2 sentences.]`;
} }
async function sendGreeting(conversationId: string) { async function sendGreeting(conversationId: string): Promise<boolean> {
// Check if we should skip this greeting // Check if we should skip this greeting
if (skipNextGreeting) { if (skipNextGreeting) {
skipNextGreeting = false; // Reset the flag skipNextGreeting = false; // Reset the flag
return; return false;
} }
const config = configStore.getConfig(); const config = configStore.getConfig();
if (!config.greeting_enabled) { if (!config.greeting_enabled) {
return; return false;
} }
const greetingPrompt = config.greeting_custom_prompt?.trim() || generateGreetingPrompt(); const greetingPrompt = config.greeting_custom_prompt?.trim() || generateGreetingPrompt();
@@ -81,10 +83,12 @@ async function sendGreeting(conversationId: string) {
conversationId, conversationId,
message: greetingPrompt, message: greetingPrompt,
}); });
return true;
} catch (error) { } catch (error) {
console.error("Failed to send greeting:", error); console.error("Failed to send greeting:", error);
claudeStore.addLineToConversation(conversationId, "error", `Failed to send greeting: ${error}`); claudeStore.addLineToConversation(conversationId, "error", `Failed to send greeting: ${error}`);
characterState.setTemporaryState("error", 3000); characterState.setTemporaryState("error", 3000);
return false;
} }
} }
@@ -118,6 +122,7 @@ interface WorkingDirectoryPayload {
export async function cleanupConversationTracking(conversationId: string) { export async function cleanupConversationTracking(conversationId: string) {
connectedConversations.delete(conversationId); connectedConversations.delete(conversationId);
greetingPendingConversations.delete(conversationId);
// Clean up any temp files associated with this conversation // Clean up any temp files associated with this conversation
try { try {
@@ -173,7 +178,24 @@ export async function initializeTauriListeners() {
if (!connectedConversations.has(targetConversationId)) { if (!connectedConversations.has(targetConversationId)) {
connectedConversations.add(targetConversationId); connectedConversations.add(targetConversationId);
resetSessionStats(); // Reset session stats on new connection resetSessionStats(); // Reset session stats on new connection
await sendGreeting(targetConversationId);
// Immediately hold the tab at yellow while we wait for the greeting response.
// This avoids a brief green flash before the greeting is even sent.
greetingPendingConversations.add(targetConversationId);
claudeStore.setConnectionStatusForConversation(
targetConversationId,
"connecting" as ConnectionStatus
);
const greetingSent = await sendGreeting(targetConversationId);
if (!greetingSent) {
// Greeting was disabled or failed — flip straight to connected.
greetingPendingConversations.delete(targetConversationId);
claudeStore.setConnectionStatusForConversation(
targetConversationId,
"connected" as ConnectionStatus
);
}
} }
} }
} else if (status === "disconnected") { } else if (status === "disconnected") {
@@ -191,6 +213,7 @@ export async function initializeTauriListeners() {
// Only remove from connected set if we're not about to reconnect // Only remove from connected set if we're not about to reconnect
if (!skipNextGreeting && targetConversationId) { if (!skipNextGreeting && targetConversationId) {
connectedConversations.delete(targetConversationId); connectedConversations.delete(targetConversationId);
greetingPendingConversations.delete(targetConversationId);
} }
// Don't add system message if we're about to reconnect // Don't add system message if we're about to reconnect
@@ -198,13 +221,21 @@ export async function initializeTauriListeners() {
claudeStore.addLineToConversation( claudeStore.addLineToConversation(
targetConversationId, targetConversationId,
"system", "system",
"Disconnected from Claude Code" "Disconnected from Claude Code unexpectedly — the process may have crashed or been stopped by the system"
); );
// Clear todos on real disconnect (not on reconnects for permissions) // Clear todos on real disconnect (not on reconnects for permissions)
todos.clear(); todos.clear();
} }
// Update the tab's connection status on real disconnects
if (!skipNextGreeting && targetConversationId) {
claudeStore.setConnectionStatusForConversation(
targetConversationId,
"disconnected" as ConnectionStatus
);
}
// Update character state for this conversation // Update character state for this conversation
if (targetConversationId) { if (targetConversationId) {
claudeStore.setCharacterStateForConversation(targetConversationId, "idle"); claudeStore.setCharacterStateForConversation(targetConversationId, "idle");
@@ -214,6 +245,7 @@ export async function initializeTauriListeners() {
if (targetConversationId) { if (targetConversationId) {
connectedConversations.delete(targetConversationId); connectedConversations.delete(targetConversationId);
greetingPendingConversations.delete(targetConversationId);
claudeStore.addLineToConversation(targetConversationId, "error", "Connection error"); claudeStore.addLineToConversation(targetConversationId, "error", "Connection error");
} }
@@ -239,6 +271,67 @@ export async function initializeTauriListeners() {
const mappedState = stateMap[state.toLowerCase()] || "idle"; const mappedState = stateMap[state.toLowerCase()] || "idle";
// Per-conversation sound tracking — fires for any tab (active or background).
// All sounds are driven from state-change events rather than a global store
// subscription, so background tabs receive their sounds correctly and
// switching tabs never replays a sound that has already fired.
const resolvedConversationId = conversation_id || get(claudeStore.activeConversationId) || null;
if (resolvedConversationId) {
const conv = get(claudeStore.conversations).get(resolvedConversationId);
if (conv) {
const previousState = conv.characterState;
// New response starting — clear all per-task sound flags.
// Only reset when entering from a clean-slate state, not mid-task.
// Transitioning from coding/searching/mcp/typing → thinking means we're
// still within the same task (between tool calls), so the sound must not replay.
const cleanSlateStates: CharacterState[] = ["idle", "success", "error"];
if (mappedState === "thinking" && cleanSlateStates.includes(previousState)) {
claudeStore.resetSoundState(resolvedConversationId);
}
// Record when a long-running phase begins (used for the 2-second
// minimum duration check before playing the completion sound).
if (
(mappedState === "coding" || mappedState === "searching") &&
previousState !== "coding" &&
previousState !== "searching"
) {
claudeStore.setTaskStartTime(resolvedConversationId, Date.now());
}
// Task-start sound — fires once when work enters a long-running phase.
if (
(mappedState === "coding" || mappedState === "searching") &&
previousState !== "coding" &&
previousState !== "searching" &&
!conv.taskStartSoundFired
) {
notificationManager.notifyTaskStart();
claudeStore.markTaskStartSoundFired(resolvedConversationId);
}
// Error sound — fires each time a new error state is entered.
if (mappedState === "error" && previousState !== "error") {
notificationManager.notifyError();
}
// Permission sound — fires each time a permission request arrives.
if (mappedState === "permission") {
notificationManager.notifyPermission();
}
// Completion sound — fires once per task after sufficient duration.
if (mappedState === "success" && !conv.successSoundFired) {
const duration = conv.taskStartTime ? Date.now() - conv.taskStartTime : 0;
if (duration > 2000) {
notificationManager.notifySuccess();
}
claudeStore.markSuccessSoundFired(resolvedConversationId);
}
}
}
// Always update the conversation's state // Always update the conversation's state
if (conversation_id) { if (conversation_id) {
claudeStore.setCharacterStateForConversation(conversation_id, mappedState); claudeStore.setCharacterStateForConversation(conversation_id, mappedState);
@@ -275,11 +368,34 @@ export async function initializeTauriListeners() {
} }
: undefined; : undefined;
// Flip to connected when first assistant message arrives after greeting
if (
conversation_id &&
line_type === "assistant" &&
greetingPendingConversations.has(conversation_id)
) {
greetingPendingConversations.delete(conversation_id);
claudeStore.setConnectionStatusForConversation(
conversation_id,
"connected" as ConnectionStatus
);
}
// Always store the output to the correct conversation // Always store the output to the correct conversation
if (conversation_id) { if (conversation_id) {
claudeStore.addLineToConversation( claudeStore.addLineToConversation(
conversation_id, conversation_id,
line_type as "user" | "assistant" | "system" | "tool" | "error" | "thinking", line_type as
| "user"
| "assistant"
| "system"
| "tool"
| "error"
| "thinking"
| "rate-limit"
| "compact-prompt"
| "worktree"
| "config-change",
content, content,
tool_name || undefined, tool_name || undefined,
costData, costData,
@@ -288,7 +404,17 @@ export async function initializeTauriListeners() {
} else { } else {
// Fallback to active conversation if no conversation_id provided // Fallback to active conversation if no conversation_id provided
claudeStore.addLine( claudeStore.addLine(
line_type as "user" | "assistant" | "system" | "tool" | "error" | "thinking", line_type as
| "user"
| "assistant"
| "system"
| "tool"
| "error"
| "thinking"
| "rate-limit"
| "compact-prompt"
| "worktree"
| "config-change",
content, content,
tool_name || undefined, tool_name || undefined,
costData, costData,
@@ -410,10 +536,17 @@ export async function initializeTauriListeners() {
unlisteners.push(agentUpdateUnlisten); unlisteners.push(agentUpdateUnlisten);
const agentEndUnlisten = await listen<AgentEndPayload>("claude:agent-end", (event) => { const agentEndUnlisten = await listen<AgentEndPayload>("claude:agent-end", (event) => {
const { tool_use_id, ended_at, is_error, conversation_id } = event.payload; const { tool_use_id, ended_at, is_error, conversation_id, last_assistant_message } =
event.payload;
const targetConversationId = conversation_id || get(claudeStore.activeConversationId); const targetConversationId = conversation_id || get(claudeStore.activeConversationId);
if (targetConversationId) { if (targetConversationId) {
agentStore.endAgent(targetConversationId, tool_use_id, ended_at, is_error); agentStore.endAgent(
targetConversationId,
tool_use_id,
ended_at,
is_error,
last_assistant_message
);
} }
}); });
unlisteners.push(agentEndUnlisten); unlisteners.push(agentEndUnlisten);
+4
View File
@@ -10,6 +10,9 @@ export interface AgentInfo {
status: AgentStatus; status: AgentStatus;
parentToolUseId?: string; parentToolUseId?: string;
durationMs?: number; durationMs?: number;
characterName: string;
characterAvatar: string;
lastAssistantMessage?: string;
} }
export interface AgentStartPayload { export interface AgentStartPayload {
@@ -29,4 +32,5 @@ export interface AgentEndPayload {
conversation_id?: string; conversation_id?: string;
duration_ms?: number; duration_ms?: number;
num_turns?: number; num_turns?: number;
last_assistant_message?: string;
} }
+125
View File
@@ -0,0 +1,125 @@
/**
* MessageMode Type Tests
*
* Tests the helper functions exported from messageMode.ts:
* - getMessageMode: looks up a MessageMode by id
* - formatMessageWithMode: prepends a mode prefix to a message
*
* What this module does:
* - Defines the six conversation modes (chat, architect, code, debug, ask, review)
* - Provides a lookup function to find a mode by its id
* - Provides a formatting function that prepends the mode prefix to messages
*
* Manual testing checklist:
* - [ ] Mode selector shows correct names and icons for all six modes
* - [ ] Selecting a mode prefixes the next message with the correct prefix
* - [ ] Switching back to Chat sends messages without any prefix
* - [ ] Sending a message that already has the prefix does not double-prefix it
*/
import { describe, it, expect } from "vitest";
import { MESSAGE_MODES, getMessageMode, formatMessageWithMode } from "./messageMode";
describe("MESSAGE_MODES", () => {
it("contains all six expected modes", () => {
const ids = MESSAGE_MODES.map((m) => m.id);
expect(ids).toEqual(["chat", "architect", "code", "debug", "ask", "review"]);
});
it("each mode has an id, name, description, and icon", () => {
for (const mode of MESSAGE_MODES) {
expect(mode.id).toBeTruthy();
expect(mode.name).toBeTruthy();
expect(mode.description).toBeTruthy();
expect(mode.icon).toBeTruthy();
}
});
});
describe("getMessageMode", () => {
it("returns the chat mode with no prefix", () => {
const mode = getMessageMode("chat");
expect(mode).toBeDefined();
expect(mode?.id).toBe("chat");
expect(mode?.prefix).toBeUndefined();
});
it("returns the architect mode with its prefix", () => {
const mode = getMessageMode("architect");
expect(mode?.id).toBe("architect");
expect(mode?.prefix).toBe("[Architect Mode] ");
});
it("returns the code mode with its prefix", () => {
const mode = getMessageMode("code");
expect(mode?.id).toBe("code");
expect(mode?.prefix).toBe("[Code Mode] ");
});
it("returns the debug mode with its prefix", () => {
const mode = getMessageMode("debug");
expect(mode?.id).toBe("debug");
expect(mode?.prefix).toBe("[Debug Mode] ");
});
it("returns the ask mode with its prefix", () => {
const mode = getMessageMode("ask");
expect(mode?.id).toBe("ask");
expect(mode?.prefix).toBe("[Ask Mode] ");
});
it("returns the review mode with its prefix", () => {
const mode = getMessageMode("review");
expect(mode?.id).toBe("review");
expect(mode?.prefix).toBe("[Review Mode] ");
});
it("returns undefined for an unknown mode id", () => {
expect(getMessageMode("unknown")).toBeUndefined();
});
it("returns undefined for an empty string", () => {
expect(getMessageMode("")).toBeUndefined();
});
});
describe("formatMessageWithMode", () => {
it("prepends the prefix for code mode", () => {
expect(formatMessageWithMode("hello", "code")).toBe("[Code Mode] hello");
});
it("prepends the prefix for architect mode", () => {
expect(formatMessageWithMode("design this", "architect")).toBe("[Architect Mode] design this");
});
it("prepends the prefix for debug mode", () => {
expect(formatMessageWithMode("fix this bug", "debug")).toBe("[Debug Mode] fix this bug");
});
it("prepends the prefix for ask mode", () => {
expect(formatMessageWithMode("what is this?", "ask")).toBe("[Ask Mode] what is this?");
});
it("prepends the prefix for review mode", () => {
expect(formatMessageWithMode("review my code", "review")).toBe("[Review Mode] review my code");
});
it("returns the message unchanged in chat mode (no prefix)", () => {
expect(formatMessageWithMode("hello", "chat")).toBe("hello");
});
it("returns the message unchanged for unknown mode ids", () => {
expect(formatMessageWithMode("hello", "unknown")).toBe("hello");
expect(formatMessageWithMode("hello", "")).toBe("hello");
});
it("does not double-prefix a message already starting with the mode prefix", () => {
expect(formatMessageWithMode("[Code Mode] already prefixed", "code")).toBe(
"[Code Mode] already prefixed"
);
});
it("adds the prefix to an empty string", () => {
expect(formatMessageWithMode("", "code")).toBe("[Code Mode] ");
});
});
+18 -1
View File
@@ -1,6 +1,16 @@
export interface TerminalLine { export interface TerminalLine {
id: string; id: string;
type: "user" | "assistant" | "system" | "tool" | "error" | "thinking"; type:
| "user"
| "assistant"
| "system"
| "tool"
| "error"
| "thinking"
| "rate-limit"
| "compact-prompt"
| "worktree"
| "config-change";
content: string; content: string;
timestamp: Date; timestamp: Date;
toolName?: string; toolName?: string;
@@ -164,6 +174,13 @@ export interface Attachment {
previewUrl?: string; // For images, a data URL or object URL for preview previewUrl?: string; // For images, a data URL or object URL for preview
} }
export interface WorkspaceHookInfo {
has_concerns: boolean;
hook_types: string[];
mcp_servers: string[];
custom_commands: string[];
}
export interface UpdateInfo { export interface UpdateInfo {
current_version: string; current_version: string;
latest_version: string; latest_version: string;
+73
View File
@@ -0,0 +1,73 @@
import { describe, it, expect } from "vitest";
import { CHARACTER_POOL, assignCharacter } from "./agentCharacters";
describe("agentCharacters", () => {
describe("CHARACTER_POOL", () => {
it("contains exactly 6 characters", () => {
expect(CHARACTER_POOL).toHaveLength(6);
});
it("each character has a name, avatar, title, and description", () => {
for (const character of CHARACTER_POOL) {
expect(character.name).toBeTruthy();
expect(character.avatar).toBeTruthy();
expect(character.avatar).toMatch(/^https:\/\//u);
expect(character.title).toBeTruthy();
expect(character.description).toBeTruthy();
}
});
it("all names are unique", () => {
const names = CHARACTER_POOL.map((c) => c.name);
const uniqueNames = new Set(names);
expect(uniqueNames.size).toBe(CHARACTER_POOL.length);
});
});
describe("assignCharacter", () => {
it("returns a character from the pool", () => {
const character = assignCharacter([]);
const names = CHARACTER_POOL.map((c) => c.name);
expect(names).toContain(character.name);
});
it("avoids names already in use when possible", () => {
const takenNames = ["Amari", "Keiko", "Minori", "Reina", "Tatsumi"];
// Run many times to confirm we never get a taken name
for (let i = 0; i < 50; i++) {
const character = assignCharacter(takenNames);
expect(takenNames).not.toContain(character.name);
expect(character.name).toBe("Yumiko");
}
});
it("picks from the full pool when all 6 names are taken", () => {
const allNames = CHARACTER_POOL.map((c) => c.name);
const seen = new Set<string>();
// Run enough times that we'd statistically see variety
for (let i = 0; i < 100; i++) {
const character = assignCharacter(allNames);
seen.add(character.name);
}
// Should still pick valid characters
for (const name of seen) {
expect(allNames).toContain(name);
}
// With 100 runs and 6 characters, we should see at least 2 distinct names
expect(seen.size).toBeGreaterThan(1);
});
it("returns a character with name, avatar, title, and description", () => {
const character = assignCharacter([]);
expect(character.name).toBeTruthy();
expect(character.avatar).toBeTruthy();
expect(character.title).toBeTruthy();
expect(character.description).toBeTruthy();
});
it("works when the active list is empty", () => {
const character = assignCharacter([]);
expect(character).toBeDefined();
});
});
});
+61
View File
@@ -0,0 +1,61 @@
export interface AgentCharacter {
name: string;
avatar: string;
title: string;
description: string;
}
export const CHARACTER_POOL: readonly AgentCharacter[] = [
{
name: "Amari",
avatar: "https://cdn.nhcarrigan.com/amari.png",
title: "Executive Assistant",
description:
"Fey-blooded PA and healer of the team. She always knows when you need a break — and makes sure you take one.",
},
{
name: "Keiko",
avatar: "https://cdn.nhcarrigan.com/keiko.png",
title: "Chief Security Officer",
description:
"Bodyguard and shadow of the family. Conceals blades beneath evening gowns; always watching from the dark.",
},
{
name: "Minori",
avatar: "https://cdn.nhcarrigan.com/minori.png",
title: "Chief Compliance Officer",
description:
"An ancient Automaton built to guard the Great Library. Perfect memory, perfect logic, perfect dedication.",
},
{
name: "Reina",
avatar: "https://cdn.nhcarrigan.com/reina.png",
title: "Chief Legal Officer",
description:
"Demon of the Crossroads turned corporate lawyer. Her binding contracts have held for millennia.",
},
{
name: "Tatsumi",
avatar: "https://cdn.nhcarrigan.com/tatsumi.png",
title: "Chief Design Officer",
description:
"A Siren who traded the ocean for a stylus. Uses her glamour to make every interface welcoming and beautiful.",
},
{
name: "Yumiko",
avatar: "https://cdn.nhcarrigan.com/yumiko.png",
title: "Chief Technology Officer",
description:
"Technomancer and machine whisperer. She communes with machine spirits and keeps the digital world running.",
},
];
/**
* Picks a character for a new subagent.
* Avoids names already assigned to active agents unless all six are taken.
*/
export function assignCharacter(activeNames: readonly string[]): AgentCharacter {
const available = CHARACTER_POOL.filter((c) => !activeNames.includes(c.name));
const pool = available.length > 0 ? available : [...CHARACTER_POOL];
return pool[Math.floor(Math.random() * pool.length)];
}
+24
View File
@@ -266,6 +266,30 @@ describe("stateMapper", () => {
expect(mapMessageToState(message)).toBeNull(); expect(mapMessageToState(message)).toBeNull();
}); });
it("returns null for content_block_start with unrecognised content block type", () => {
const message = {
type: "stream_event",
event: {
type: "content_block_start",
index: 0,
content_block: { type: "input_json_delta" },
},
} as unknown as ClaudeStreamMessage;
expect(mapMessageToState(message)).toBeNull();
});
it("returns null for content_block_delta with unrecognised delta type", () => {
const message = {
type: "stream_event",
event: {
type: "content_block_delta",
index: 0,
delta: { type: "input_json_delta", partial_json: "{}" },
},
} as unknown as ClaudeStreamMessage;
expect(mapMessageToState(message)).toBeNull();
});
it("returns null for result with unknown subtype", () => { it("returns null for result with unknown subtype", () => {
const message = { const message = {
type: "result", type: "result",
+73 -7
View File
@@ -11,7 +11,15 @@
updateDiscordRpc, updateDiscordRpc,
setSkipNextGreeting, setSkipNextGreeting,
} from "$lib/tauri"; } from "$lib/tauri";
import { configStore, applyTheme, applyFontSize, isCompactMode } from "$lib/stores/config"; import {
configStore,
applyTheme,
applyFontSize,
applyCustomFont,
applyCustomUiFont,
isCompactMode,
} from "$lib/stores/config";
import { readFile } from "@tauri-apps/plugin-fs";
import { initNotificationSync, cleanupNotificationSync } from "$lib/stores/notifications"; import { initNotificationSync, cleanupNotificationSync } from "$lib/stores/notifications";
import { conversationsStore } from "$lib/stores/conversations"; import { conversationsStore } from "$lib/stores/conversations";
import { claudeStore, isClaudeProcessing } from "$lib/stores/claude"; import { claudeStore, isClaudeProcessing } from "$lib/stores/claude";
@@ -37,6 +45,45 @@
import { debugConsoleStore } from "$lib/stores/debugConsole"; import { debugConsoleStore } from "$lib/stores/debugConsole";
import { initializeTodoListener, cleanupTodoListener } from "$lib/stores/todos"; import { initializeTodoListener, cleanupTodoListener } from "$lib/stores/todos";
let backgroundDataUrl = $state<string | null>(null);
let backgroundOpacity = $state(0.3);
const configValues = configStore.config;
$effect(() => {
const cfg = $configValues;
backgroundOpacity = cfg.background_image_opacity;
if (cfg.background_image_path) {
void loadBackgroundImage(cfg.background_image_path);
} else {
backgroundDataUrl = null;
}
});
async function loadBackgroundImage(path: string) {
try {
const data = await readFile(path);
const chunks: string[] = [];
const chunkSize = 8192;
for (let i = 0; i < data.length; i += chunkSize) {
chunks.push(String.fromCharCode(...data.slice(i, i + chunkSize)));
}
const ext = path.split(".").pop()?.toLowerCase() ?? "png";
const mimeMap: Record<string, string> = {
jpg: "image/jpeg",
jpeg: "image/jpeg",
png: "image/png",
webp: "image/webp",
gif: "image/gif",
avif: "image/avif",
};
const mime = mimeMap[ext] ?? "image/png";
backgroundDataUrl = `data:${mime};base64,${btoa(chunks.join(""))}`;
} catch (error) {
console.error("Failed to load background image:", error);
backgroundDataUrl = null;
}
}
let initialized = false; let initialized = false;
let updateNotification: UpdateNotification | undefined = $state(undefined); let updateNotification: UpdateNotification | undefined = $state(undefined);
let achievementPanelOpen = $state(false); let achievementPanelOpen = $state(false);
@@ -297,7 +344,7 @@
setSkipNextGreeting(true); setSkipNextGreeting(true);
await invoke("interrupt_claude", { conversationId }); await invoke("interrupt_claude", { conversationId });
claudeStore.addLine("system", "Process interrupted"); claudeStore.addLine("system", "Process interrupted by keyboard shortcut (Ctrl+C)");
} catch (error) { } catch (error) {
console.error("Failed to interrupt:", error); console.error("Failed to interrupt:", error);
} }
@@ -414,6 +461,8 @@
const config = configStore.getConfig(); const config = configStore.getConfig();
applyTheme(config.theme, config.custom_theme_colors); applyTheme(config.theme, config.custom_theme_colors);
applyFontSize(config.font_size); applyFontSize(config.font_size);
await applyCustomFont(config.custom_font_path, config.custom_font_family);
await applyCustomUiFont(config.custom_ui_font_path, config.custom_ui_font_family);
// Apply always-on-top setting // Apply always-on-top setting
if (config.always_on_top) { if (config.always_on_top) {
@@ -473,16 +522,27 @@
}); });
</script> </script>
{#if backgroundDataUrl}
<div
class="fixed inset-0 bg-cover bg-center pointer-events-none"
style="background-image: url('{backgroundDataUrl}'); opacity: {backgroundOpacity}; z-index: 0;"
></div>
{/if}
{#if compactModeActive} {#if compactModeActive}
<!-- Compact mode: minimal widget interface --> <!-- Compact mode: minimal widget interface -->
<div <div
class="app-container compact-app h-screen w-screen flex flex-col bg-[var(--bg-primary)] overflow-hidden" class="app-container compact-app h-screen w-screen flex flex-col bg-[var(--bg-primary)] overflow-hidden"
style={backgroundDataUrl ? "background: transparent;" : ""}
> >
<CompactMode onExpand={exitCompactMode} /> <CompactMode onExpand={exitCompactMode} />
</div> </div>
{:else} {:else}
<!-- Full mode: standard interface --> <!-- Full mode: standard interface -->
<div class="app-container h-screen w-screen flex flex-col bg-[var(--bg-primary)] overflow-hidden"> <div
class="app-container h-screen w-screen flex flex-col bg-[var(--bg-primary)] overflow-hidden"
style={backgroundDataUrl ? "background: transparent;" : ""}
>
<StatusBar <StatusBar
onToggleAchievements={() => (achievementPanelOpen = !achievementPanelOpen)} onToggleAchievements={() => (achievementPanelOpen = !achievementPanelOpen)}
onToggleCompact={enterCompactMode} onToggleCompact={enterCompactMode}
@@ -491,8 +551,12 @@
<main class="flex-1 flex overflow-hidden"> <main class="flex-1 flex overflow-hidden">
<!-- Left panel: Character display --> <!-- Left panel: Character display -->
<div <div
class="character-panel {getPanelGlowClass()} flex flex-col items-center justify-center bg-[var(--bg-secondary)]/50" class="character-panel {getPanelGlowClass()} flex flex-col items-center justify-center {backgroundDataUrl
style="width: {panelWidth}px; min-width: {MIN_PANEL_WIDTH}px; max-width: {MAX_PANEL_WIDTH}px;" ? ''
: 'bg-[var(--bg-secondary)]/50'}"
style="width: {panelWidth}px; min-width: {MIN_PANEL_WIDTH}px; max-width: {MAX_PANEL_WIDTH}px;{backgroundDataUrl
? ' background: transparent !important;'
: ''}"
> >
<AnimeGirl /> <AnimeGirl />
</div> </div>
@@ -538,13 +602,15 @@
<style> <style>
.app-container { .app-container {
font-family: font-family: var(
--ui-font-family,
"Inter", "Inter",
-apple-system, -apple-system,
BlinkMacSystemFont, BlinkMacSystemFont,
"Segoe UI", "Segoe UI",
Roboto, Roboto,
sans-serif; sans-serif
);
} }
.character-panel { .character-panel {
+14
View File
@@ -8,6 +8,20 @@ import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
/** @type {import('@sveltejs/kit').Config} */ /** @type {import('@sveltejs/kit').Config} */
const config = { const config = {
preprocess: vitePreprocess(), preprocess: vitePreprocess(),
// Suppress specific build-time warnings that are intentional patterns:
// - a11y_click_events_have_key_events: all overlay/context-menu divs use svelte:window handlers
// - state_referenced_locally: InputDialog intentionally captures the initial prop value
onwarn: (warning, handler) => {
if (
warning.code === "a11y_click_events_have_key_events" ||
warning.code === "state_referenced_locally" ||
// SvelteSet is already reactive; $state wrapping is unnecessary per ESLint,
// but vite-plugin-svelte incorrectly fires non_reactive_update on SvelteSet mutations
warning.code === "non_reactive_update"
)
return;
handler(warning);
},
kit: { kit: {
adapter: adapter({ adapter: adapter({
fallback: "index.html", fallback: "index.html",
+15 -1
View File
@@ -1,13 +1,27 @@
import { defineConfig } from "vite"; import { defineConfig, createLogger } from "vite";
import { sveltekit } from "@sveltejs/kit/vite"; import { sveltekit } from "@sveltejs/kit/vite";
import tailwindcss from "@tailwindcss/vite"; import tailwindcss from "@tailwindcss/vite";
// @ts-expect-error process is a nodejs global // @ts-expect-error process is a nodejs global
const host = process.env.TAURI_DEV_HOST; const host = process.env.TAURI_DEV_HOST;
// SvelteKit passes codeSplitting to Rollup 4 which no longer recognises it — harmless
const logger = createLogger();
const baseWarn = logger.warn.bind(logger);
logger.warn = (/** @type {string} */ msg, /** @type {any} */ options) => {
// SvelteKit passes codeSplitting to Rollup 4 which no longer recognises it
if (msg.includes("codeSplitting")) return;
// Large chunks are fine for a desktop app — no network penalty
if (msg.includes("chunks are larger than")) return;
// Dynamic/static import mix in CodeMirror — harmless, module stays in main chunk
if (msg.includes("dynamically imported by") && msg.includes("codemirror")) return;
baseWarn(msg, options);
};
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig(async () => ({ export default defineConfig(async () => ({
plugins: [tailwindcss(), sveltekit()], plugins: [tailwindcss(), sveltekit()],
customLogger: logger,
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build` // Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
// //
+6 -1
View File
@@ -17,7 +17,10 @@ vi.mock("@tauri-apps/api/core", () => ({
if (command in mockInvokeResults) { if (command in mockInvokeResults) {
const result = mockInvokeResults[command]; const result = mockInvokeResults[command];
if (result instanceof Error) { if (result instanceof Error) {
return Promise.reject(result); const err = result;
return Promise.resolve().then(() => {
throw err;
});
} }
return Promise.resolve(result); return Promise.resolve(result);
} }
@@ -73,6 +76,8 @@ vi.mock("@tauri-apps/api/core", () => ({
return Promise.resolve([]); return Promise.resolve([]);
case "list_clipboard_entries": case "list_clipboard_entries":
return Promise.resolve([]); return Promise.resolve([]);
case "list_drafts":
return Promise.resolve([]);
case "cleanup_temp_files": case "cleanup_temp_files":
return Promise.resolve(); return Promise.resolve();
case "validate_directory": case "validate_directory":