Compare commits

..

17 Commits

Author SHA1 Message Date
naomi a690a4969b release: v1.11.1
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m10s
CI / Lint & Test (push) Successful in 16m53s
CI / Build Linux (push) Successful in 20m39s
CI / Build Windows (cross-compile) (push) Successful in 31m7s
2026-03-09 16:57:19 -07:00
hikari 2816e33257 fix: resolve message submission and stuck processing bugs (#199)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m2s
CI / Build Linux (push) Has been cancelled
CI / Build Windows (cross-compile) (push) Has been cancelled
CI / Lint & Test (push) Has been cancelled
## Summary

- **Fix `isProcessing` tracking**: The `isProcessing` store field was initialised as `false` and never set to `true` in production, making all submission guards no-ops. Now `setProcessing(true)` is called after `send_prompt` succeeds in both `handleSubmit` and `handleQuickAction`, and `setProcessingForConversation(id, false)` is called when the backend emits an idle/success/error state.
- **Fix auto-granted tools dropped on permission reconnect** (closes #198): `PermissionModal.svelte` was passing only session-granted tools when reconnecting after a permission approval, silently dropping `config.auto_granted_tools`. Fixed to merge both sets, matching the behaviour of every other `start_claude` call site.
- **Add mid-session watchdog**: A watchdog thread now kills the Claude Code process if a user message is sent but no `Result` arrives within 5 minutes. This triggers the existing disconnect/error flow so the user is notified and can reconnect. A generation counter ensures watchdogs from previous sessions exit cleanly when a new session starts.

## Test plan

- [ ] Send a message and verify the textarea is disabled and the stop button is visible while Claude is processing
- [ ] Verify the textarea re-enables after Claude finishes responding
- [ ] Enable a tool in default permissions (e.g. Read), start a session, trigger a permission approval for another tool, approve it — verify the previously auto-granted tool is no longer re-prompted
- [ ] Verify all existing tests pass (`./check-all.sh`)

 This PR was created with help from Hikari~ 🌸

Reviewed-on: #199
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-03-09 16:53:09 -07:00
naomi ff0ba7b6d0 release: v1.11.0
CI / Lint & Test (push) Successful in 17m10s
CI / Build Linux (push) Successful in 21m4s
CI / Build Windows (cross-compile) (push) Successful in 32m7s
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 2m23s
2026-03-07 03:10:42 -08:00
hikari e6e9f7ae59 feat: productivity suite — task loop, workflow, theming, docs & more (#197)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m39s
CI / Build Linux (push) Has been cancelled
CI / Build Windows (cross-compile) (push) Has been cancelled
CI / Lint & Test (push) Has been cancelled
## Summary

A large productivity-focused feature branch delivering a suite of improvements across automation, project management, theming, performance, and documentation.

### Features

- **Guided Project Workflow** (#189) — Four-phase workflow panel (Discuss → Plan → Execute → Verify) to keep projects structured from idea to completion
- **Automated Task Loop** (#179) — Per-task conversation orchestration with wave-based parallel execution, blocked-task detection, and concurrency control
- **Wave-Based Parallel Execution** (#191) — Tasks run in dependency-aware waves with configurable concurrency; independent tasks execute in parallel
- **Auto-Commit After Task Completion** (#192) — Task Loop optionally commits after each completed task so progress is never lost
- **PRD Creator** (#180) — AI-assisted PRD and task list panel that outputs `hikari-tasks.json` for the Task Loop to consume
- **Project Context Panel** (#188) — Persistent `PROJECT.md`, `REQUIREMENTS.md`, `ROADMAP.md`, and `STATE.md` files injected into Claude's context automatically
- **Codebase Mapper** (#190) — Generates a `CODEBASE.md` architectural summary so Claude always understands the project structure
- **Community Preset Themes** (#181) — Six built-in community themes: Dracula, Catppuccin Mocha, Nord, Solarized Dark, Gruvbox Dark, and Rosé Pine
- **In-App Changelog Panel** (#193) — Fetches release notes from GitHub at runtime and displays them inside the app
- **Full Embedded Documentation** (#196) — Replaced the single-page help modal with a 12-page paginated docs browser featuring a sidebar TOC, prev/next navigation, keyboard navigation (arrow keys, `?` shortcut), and comprehensive coverage of every feature

### Performance & Fixes

- **Lazy Loading & Virtualisation** (#194) — Virtual windowing for conversation history, markdown memoisation, and debounced search for smooth rendering of large sessions
- **Ctrl+C Copy Fix** (#195) — `Ctrl+C` now copies selected text as expected; interrupt-Claude behaviour only fires when no text is selected

### UX

- Back-to-workflow button in PRD Creator and Task Loop panels for easy navigation
- Navigation icon cluster replaced with a single clean dropdown menu

## Closes

Closes #179
Closes #180
Closes #181
Closes #188
Closes #189
Closes #190
Closes #191
Closes #192
Closes #193
Closes #194
Closes #195
Closes #196

---

 This PR was created with help from Hikari~ 🌸

Reviewed-on: #197
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-03-07 03:08:33 -08:00
hikari 1ae440659c feat: fix git window and add pretty diff viewer (#178)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 58s
CI / Lint & Test (push) Successful in 16m33s
CI / Build Linux (push) Successful in 20m56s
CI / Build Windows (cross-compile) (push) Successful in 31m1s
## Summary

- **Fix git window "Not a git repository" error** — The working directory received from Claude Code is a WSL Linux path (e.g. `/home/naomi/...`), but git commands were being run as native Windows processes with `.current_dir()`. Windows can't resolve WSL paths, causing `git rev-parse --git-dir` to fail. Fixed by routing git commands through `wsl -- git -C <path>` when the working directory starts with `/`.

- **Add syntax highlighting and line numbers to diff view** — Replaced the raw `<pre>` block with a proper `DiffViewer` component featuring:
  - Old/new line number columns with correct tracking across hunks
  - Colour-coded gutter (`+`/`-`) with green/red row backgrounds
  - Syntax highlighting via `highlight.js` using the detected file language, respecting all app themes via `--hljs-*` CSS variables
  - Styled hunk headers and file headers

## New files

- `src/lib/utils/diffParser.ts` — pure diff parsing logic
- `src/lib/utils/diffParser.test.ts` — 30 tests covering all line types, line number tracking, and language detection
- `src/lib/components/DiffViewer.svelte` — the pretty diff viewer component

 This pull request was created with help from Hikari~ 🌸

Reviewed-on: #178
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-03-06 09:19:16 -08:00
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
137 changed files with 22057 additions and 1905 deletions
+3
View File
@@ -11,3 +11,6 @@ vite.config.ts.timestamp-*
# Coverage reports
/coverage
# PRD task files (user-generated data, not source code)
hikari-tasks.json
+13 -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:
- **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
- **Always ask for confirmation** before committing
- **Always ask for confirmation** before pushing
Example commit command:
```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
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
- **Test files**: Place test files next to the code they test with `.test.ts` or `.spec.ts` extension
@@ -130,16 +137,16 @@ 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)
2. **During implementation**: Write tests alongside the code
3. **After implementation**: Run `pnpm test:coverage` to verify coverage remains high
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
+458
View File
@@ -0,0 +1,458 @@
# Hikari Desktop — Codebase Map
> Auto-generated codebase overview. Last updated: 2026-03-06.
## Overview
Hikari Desktop is a **Tauri v2** desktop application that wraps the Claude Code CLI with a visual anime character avatar (Hikari) who appears on-screen and reacts in real-time to Claude's activity. When Claude is thinking, she thinks. When it's editing code, she codes. When it's using MCP tools, she glows with magical energy.
The app supports multiple simultaneous conversations (tabs), each with its own isolated Claude CLI process. It provides a rich UI layer on top of Claude Code, including a built-in file editor, git panel, achievement system, cost tracking, session history, notifications, and more.
**Repositories:**
- Primary: `git.nhcarrigan.com` (Gitea) — `nhcarrigan/hikari-desktop`
- Mirror: `github.com/naomi-lgbt/hikari-desktop`
**Current version:** `1.10.0`
---
## Architecture
The application follows a standard Tauri architecture:
```
┌──────────────────────────────────────────────────────────────┐
│ Frontend (WebView) │
│ SvelteKit + Svelte 5 + TailwindCSS 4 + TypeScript │
│ │
│ ┌─────────┐ ┌──────────┐ ┌──────────────┐ ┌──────────┐ │
│ │AnimeGirl│ │ Terminal │ │ InputBar │ │ Editor │ │
│ │ Sprites │ │ View │ │ + Slash Cmds│ │CodeMirror│ │
│ └────┬────┘ └────┬─────┘ └──────┬───────┘ └────┬─────┘ │
│ │ │ │ │ │
│ ┌────▼─────────────▼───────────────▼────────────────▼──────┐ │
│ │ Svelte Stores (reactive state) │ │
│ │ conversations · character · config · agents · stats … │ │
│ └──────────────────────────┬───────────────────────────────┘ │
│ │ tauri.ts (event listeners) │
└─────────────────────────────┼────────────────────────────────┘
│ Tauri IPC (invoke / emit)
┌─────────────────────────────┼────────────────────────────────┐
│ Backend (Rust) │
│ ┌──────────────────────────▼───────────────────────────────┐ │
│ │ commands.rs (invoke handlers) │ │
│ └──────────────────────────┬───────────────────────────────┘ │
│ │ │
│ ┌──────────────────────────▼───────────────────────────────┐ │
│ │ BridgeManager — HashMap<conversation_id, WslBridge> │ │
│ └──────────────────────────┬───────────────────────────────┘ │
│ │ │
│ ┌──────────────────────────▼───────────────────────────────┐ │
│ │ WslBridge — spawns `claude --output-format stream-json`│ │
│ │ reads NDJSON stdout → emits events to frontend │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
│ config · stats · cost_tracking · sessions · git · clipboard │
│ achievements · discord_rpc · notifications · snippets … │
└──────────────────────────────────────────────────────────────┘
```
---
## Directory Structure
```
hikari-desktop/
├── src/ # SvelteKit frontend
│ ├── routes/
│ │ ├── +page.svelte # Main app layout (root page)
│ │ ├── +layout.svelte # App-level layout wrapper
│ │ ├── +layout.ts # SvelteKit layout config (SSR disabled)
│ │ └── test-achievement/ # Dev-only achievement test page
│ ├── lib/
│ │ ├── tauri.ts # Tauri event listeners + IPC bridge
│ │ ├── commands/ # Slash command definitions
│ │ ├── components/ # 60+ Svelte components
│ │ │ └── editor/ # CodeMirror-based file editor components
│ │ ├── notifications/ # Notification system
│ │ ├── sounds/ # Sound effect triggers
│ │ ├── stores/ # All Svelte reactive stores
│ │ ├── types/ # TypeScript type definitions
│ │ └── utils/ # Pure utility functions
│ ├── app.css # Global styles + CSS variables (themes)
│ └── app.html # HTML shell
├── src-tauri/ # Tauri Rust backend
│ ├── src/
│ │ ├── main.rs # Process entry point
│ │ ├── lib.rs # Tauri app setup + command registration
│ │ ├── types.rs # All shared Rust types + serialisation
│ │ ├── wsl_bridge.rs # Claude CLI process management + NDJSON parser
│ │ ├── bridge_manager.rs # Per-conversation WslBridge registry
│ │ ├── commands.rs # All #[tauri::command] handlers
│ │ ├── config.rs # Config read/write (tauri-plugin-store)
│ │ ├── stats.rs # Token usage + cost calculation
│ │ ├── cost_tracking.rs # Budget alerts + cost history (CSV export)
│ │ ├── achievements.rs # Achievement unlock logic
│ │ ├── sessions.rs # Conversation session persistence (JSON)
│ │ ├── git.rs # Git operations via CLI
│ │ ├── clipboard.rs # Clipboard history management
│ │ ├── notifications.rs # System notification dispatch
│ │ ├── discord_rpc.rs # Discord Rich Presence manager
│ │ ├── drafts.rs # Draft message persistence
│ │ ├── snippets.rs # Snippet library CRUD
│ │ ├── quick_actions.rs # Quick action CRUD
│ │ ├── debug_logger.rs # TauriLogLayer (routes tracing → frontend)
│ │ ├── temp_manager.rs # Temporary file lifecycle management
│ │ ├── tool_cache.rs # Tool call result caching
│ │ ├── tray.rs # System tray setup
│ │ ├── process_ext.rs # HideWindow trait (Windows console hiding)
│ │ ├── vbs_notification.rs # VBScript-based notification fallback (Windows)
│ │ ├── windows_toast.rs # Windows native toast notifications
│ │ └── wsl_notifications.rs# WSL notify-send bridge
│ ├── capabilities/ # Tauri permission capabilities
│ ├── tests/ # Rust integration tests
│ ├── Cargo.toml
│ ├── Cargo.lock
│ └── tauri.conf.json # Tauri app configuration
├── static/
│ ├── sprites/ # Anime character PNG sprites (one per state)
│ └── sounds/ # MP3 sound effects (connected, working, done…)
├── check-all.sh # Full QA script (lint → format → types → test)
├── vitest.config.ts # Frontend test configuration
├── vitest.setup.ts # Tauri API mocks for tests
├── svelte.config.js # SvelteKit config (static adapter)
├── vite.config.js # Vite config
├── eslint.config.js # ESLint 9 flat config
├── tsconfig.json # TypeScript config
└── .gitea/workflows/ # CI/CD (Gitea Actions)
```
---
## Key Components
### Backend (Rust)
#### `wsl_bridge.rs` — Claude CLI Process Manager
The most critical backend file. `WslBridge` spawns a single `claude` CLI process per conversation using `--output-format stream-json`, which causes Claude Code to emit NDJSON messages on stdout. A dedicated reader thread consumes stdout line-by-line, parses each line into a `ClaudeMessage` enum variant, and emits the appropriate frontend events.
Key responsibilities:
- Locates the `claude` binary (checks `~/.local/bin`, `~/.claude/local`, system paths, and falls back to a login-shell `which claude`)
- Detects WSL environment to handle cross-platform path differences
- Maps tool names to character states (Read/Glob/Grep → `searching`, Edit/Write → `coding`, `mcp__*``mcp`)
- Batches permission requests from a single assistant message
- Tracks token usage per session
#### `bridge_manager.rs` — Multi-Conversation Orchestrator
`BridgeManager` holds a `HashMap<String, WslBridge>` keyed by `conversation_id`. This enables true parallel conversations — each tab has its own isolated Claude process. The manager is wrapped in `Arc<Mutex<BridgeManager>>` (using `parking_lot`) and injected into Tauri's managed state.
#### `types.rs` — Shared Type Definitions
Defines the complete Claude stream-JSON protocol as Rust enums/structs:
- `ClaudeMessage` — top-level message variants: `System`, `Assistant`, `User`, `StreamEvent`, `Result`, `RateLimitEvent`
- `ContentBlock``Text`, `Thinking`, `ToolUse`, `ToolResult`
- `CharacterState``Idle | Thinking | Typing | Searching | Coding | Mcp | Permission | Success | Error`
- All frontend event types (`OutputEvent`, `StateChangeEvent`, `PermissionPromptEvent`, `AgentStartEvent`, etc.)
#### `commands.rs` — IPC Command Handlers
Registers all Tauri commands exposed to the frontend. Over 80 commands covering: Claude process management, configuration, stats, sessions, git, clipboard, cost tracking, MCP servers, plugins, drafts, snippets, quick actions, file system operations, authentication, and notifications.
#### `debug_logger.rs` — In-App Debug Console
A custom `tracing` subscriber layer (`TauriLogLayer`) that captures all `tracing::info!/warn!/error!` calls and emits them as `debug:log` events to the frontend debug console — essential since production Windows builds have no stdout.
---
### Frontend (TypeScript/Svelte 5)
#### `src/routes/+page.svelte` — Root Layout
The main page. Renders a two-panel layout:
- **Left panel**: `<AnimeGirl>` character display with state-reactive glow effects (trans pride gradient colours per state)
- **Right panel**: `<Terminal>` + `<InputBar>` (or `<EditorPanel>` when the editor is open)
Also handles: global keyboard shortcuts, compact mode (280×400 mini widget), window close confirmation, Discord RPC updates, and background image loading.
#### `src/lib/tauri.ts` — Event Bridge
Sets up all Tauri event listeners on app mount. Translates backend events into store mutations:
| Event | Action |
| ------------------------ | ----------------------------------------------------------------------- |
| `claude:connection` | Updates conversation connection status; sends greeting on first connect |
| `claude:state` | Updates character state; triggers per-conversation sound effects |
| `claude:output` | Appends lines to the correct conversation's terminal history |
| `claude:session` | Stores the Claude session ID |
| `claude:cwd` | Updates working directory (used by the editor) |
| `claude:permission` | Adds permission requests to conversation state |
| `claude:agent-start/end` | Updates agent monitor panel |
| `claude:question` | Stores pending user question |
Also manages Discord RPC updates and the session greeting flow.
#### `src/lib/stores/conversations.ts` — Core State Store
The central state container. Each conversation (`Conversation` interface) tracks:
- Terminal lines (`TerminalLine[]`)
- Connection status, session ID, working directory
- Character state, processing flag
- Granted/pending tool permissions
- Pending user questions
- Scroll position, attachments, draft text
- Sound tracking (per-conversation, prevents replays on tab switch)
- Conversation summary (for compaction)
Tab names are randomly chosen from a curated list of whimsical names (Starfall, Moonbeam, Sakura, etc.).
#### `src/lib/stores/claude.ts` — Backwards-Compat Facade
A thin wrapper that re-exports `conversationsStore` methods under the original `claudeStore` API. Maintains backwards compatibility whilst the codebase migrated to multi-conversation support.
#### `src/lib/stores/character.ts` — Character State Store
Manages the global character state displayed by `<AnimeGirl>`. Supports `setState()` (persistent) and `setTemporaryState(state, durationMs)` (auto-reverts to `idle` after a timeout — used for success/error flashes).
#### `src/lib/utils/stateMapper.ts` — Stream → State Mapping
Pure utility that maps Claude stream-JSON message types to `CharacterState` values. Tool categorisation mirrors the Rust side: search tools → `searching`, coding tools → `coding`, MCP tools → `mcp`, Task tool → `thinking`.
#### `src/lib/components/`
Key components beyond the basics:
| Component | Purpose |
| --------------------------- | ------------------------------------------------------------- |
| `AnimeGirl.svelte` | Displays the character sprite, subscribes to `characterState` |
| `Terminal.svelte` | Renders the conversation message history |
| `InputBar.svelte` | User input with slash command menu, attachment support |
| `StatusBar.svelte` | Top bar: connection indicator, token/cost stats, controls |
| `ConversationTabs.svelte` | Multi-tab navigation with per-tab status indicators |
| `ConfigSidebar.svelte` | Settings panel (model, theme, notifications, budget, etc.) |
| `PermissionModal.svelte` | Handles tool permission grant/deny UI |
| `UserQuestionModal.svelte` | Renders `AskUserQuestion` prompts from Claude |
| `AgentMonitorPanel.svelte` | Live subagent tree with status badges |
| `GitPanel.svelte` | Git status, diff, stage/unstage, commit, push/pull |
| `editor/EditorPanel.svelte` | Full CodeMirror editor with file browser and tabs |
| `DiffViewer.svelte` | Syntax-highlighted diff display |
| `AchievementsPanel.svelte` | Achievement gallery |
| `CostSummary.svelte` | Cost breakdown by session/day/week/month |
| `MemoryBrowserPanel.svelte` | Browse Claude memory files |
| `McpManagementPanel.svelte` | MCP server configuration UI |
| `DebugConsole.svelte` | In-app log viewer (receives `debug:log` events) |
| `ThinkingBlock.svelte` | Collapsible extended thinking display |
| `ToolCallBlock.svelte` | Formatted tool use/result display |
---
## Data Flow
### User Sends a Message
```
User types → InputBar
→ invoke("send_prompt", { conversationId, message })
→ BridgeManager.send_prompt(conversation_id, message)
→ WslBridge.send_message() → writes JSON to Claude CLI stdin
```
### Claude Responds (NDJSON Stream)
```
Claude CLI stdout (NDJSON)
→ WslBridge reader thread (line-by-line)
→ serde_json::from_str::<ClaudeMessage>()
→ match message type:
System(init) → emit claude:connection(connected) + claude:cwd
StreamEvent → emit claude:state(thinking|typing|searching|coding|mcp)
Assistant → emit claude:output(assistant|tool|thinking lines)
User(tool_result)→ emit claude:output(tool result lines)
Result(success) → emit claude:state(success) + claude:output(result)
Result(error) → emit claude:state(error)
RateLimitEvent → emit claude:output(rate-limit line)
PermissionRequest→ emit claude:permission
```
### Frontend Reacts
```
tauri.ts event listeners
→ conversationsStore mutations
→ Svelte reactivity propagates to components
→ AnimeGirl.svelte: sprite changes to match characterState
→ Terminal.svelte: new line appended
→ StatusBar.svelte: token counts update
→ ConversationTabs.svelte: tab glow colour updates
```
### Permission Flow
```
Claude requests tool permission
→ WslBridge batches pending tool uses
→ emit claude:permission (one or more requests)
→ tauri.ts → claudeStore.requestPermissionForConversation()
→ PermissionModal.svelte renders
→ User clicks Allow/Deny
→ invoke("answer_question", { conversationId, toolUseId, granted })
→ WslBridge.send_tool_result() → writes result to Claude stdin
→ Claude CLI resumes
```
---
## State Machine
The `CharacterState` enum drives both the sprite displayed and the panel glow colour:
| State | Trigger | Sprite | Panel Glow |
| ------------ | --------------------------------- | ----------------------- | ---------------------- |
| `idle` | Connected, no activity | Standing with clipboard | None |
| `thinking` | Thinking block / Task tool | Hand on chin | Purple/trans gradient |
| `typing` | Text content block | At keyboard | Blue/trans gradient |
| `searching` | Read/Glob/Grep/WebSearch/WebFetch | Magnifying glass | Yellow/trans gradient |
| `coding` | Edit/Write/NotebookEdit | At monitor | Green/trans gradient |
| `mcp` | Any `mcp__*` tool | Magical blue energy | Trans pride vibrant |
| `permission` | Permission requested | Confused shrug | — |
| `success` | Result: success | Celebrating | Emerald/trans gradient |
| `error` | Result: error | Worried | Red/trans gradient |
`success` and `error` are temporary states (3-second auto-revert to `idle`).
---
## Dependencies
### Frontend (key packages)
| Package | Purpose |
| ------------------------------ | -------------------------------------------------------------- |
| `@sveltejs/kit` `svelte` | SvelteKit framework + Svelte 5 |
| `@tauri-apps/api` | Core Tauri IPC (`invoke`, `listen`) |
| `@tauri-apps/plugin-*` | FS, clipboard, notifications, dialog, shell, store, os, opener |
| `tailwindcss` v4 | Utility-first CSS |
| `codemirror` + `@codemirror/*` | Code editor with 20+ language modes |
| `marked` | Markdown → HTML rendering |
| `highlight.js` | Syntax highlighting in markdown blocks |
| `lucide-svelte` | Icon library |
### Backend (key crates)
| Crate | Purpose |
| -------------------------------- | ---------------------------------------- |
| `tauri` v2 | Desktop app framework |
| `tokio` | Async runtime |
| `serde` / `serde_json` | JSON serialisation/deserialisation |
| `parking_lot` | Fast mutex (used for `BridgeManager`) |
| `uuid` | Unique ID generation |
| `discord-rich-presence` | Discord RPC integration |
| `chrono` | Date/time handling for cost tracking |
| `semver` | Version comparison for update checks |
| `tempfile` | Temporary file management |
| `tracing` + `tracing-subscriber` | Structured logging |
| `dirs` | Cross-platform home directory resolution |
| `windows` (Windows-only) | Native toast notifications |
### Dev / Tooling
| Tool | Purpose |
| -------------------------------- | ----------------------------------------- |
| `vitest` + `@vitest/coverage-v8` | Frontend unit tests with v8 coverage |
| `@testing-library/svelte` | Component testing utilities |
| `jsdom` | DOM environment for tests |
| `eslint` v9 (flat config) | Linting |
| `prettier` | Formatting |
| `svelte-check` | TypeScript type checking for Svelte files |
| `cargo test` + `cargo llvm-cov` | Rust unit tests and coverage |
---
## Development Notes
### Running the App
```bash
# Frontend dev server only
source ~/.nvm/nvm.sh && pnpm dev
# Full Tauri app (Rust + frontend)
source ~/.nvm/nvm.sh && pnpm tauri dev
```
### Running Tests
```bash
# All checks (lint → format → type-check → frontend tests → backend tests)
./check-all.sh
# Frontend tests only
source ~/.nvm/nvm.sh && pnpm test
# Frontend with coverage
source ~/.nvm/nvm.sh && pnpm test:coverage
# Backend tests only
pnpm test:backend
```
### Building
```bash
# Linux build
pnpm build:linux
# Windows cross-compile (requires cargo-xwin)
pnpm build:windows
```
### Adding a New Tauri Command
1. Add the handler function in the appropriate `src-tauri/src/*.rs` file with `#[tauri::command]`
2. Register it in `lib.rs` `invoke_handler![]`
3. Call it from the frontend via `invoke("command_name", { args })` in `src/lib/tauri.ts` or a store
### Adding a New Frontend Store
1. Create `src/lib/stores/my-store.ts` using `writable` or a factory function pattern
2. Create `src/lib/stores/my-store.test.ts` — all stores must have tests
3. Expose the store from the appropriate component
### Claude Stream-JSON Protocol
Claude Code is invoked with `--output-format stream-json --verbose`. See `src-tauri/src/types.rs` for the complete message type definitions. The key field distinguishing subagent messages from top-level messages is `parent_tool_use_id` on `Assistant` messages.
### Multi-Conversation Architecture
Each tab (`Conversation`) in `conversationsStore` has a unique `conversation_id` string. The backend `BridgeManager` maps these IDs to `WslBridge` instances. All Tauri events carry `conversation_id` in their payload so the frontend can route them to the correct conversation without affecting others.
### WSL Detection
`wsl_bridge.rs` detects WSL by checking `/proc/version` for "microsoft"/"wsl" strings, checking for `/proc/sys/fs/binfmt_misc/WSLInterop`, and checking `$WSL_DISTRO_NAME`. On native Windows builds, WSL detection always returns `false` (even if launched from a WSL terminal).
### Character State Sound Rules
Sound effects are managed in `src/lib/tauri.ts` per-conversation to prevent replays when switching tabs. The rules are:
- Entering `thinking` from a clean state (`idle`/`success`/`error`) → reset all sound flags
- Entering `coding` or `searching` (first time per task) → play task-start sound
- Entering `success` after ≥2 seconds in a long-running phase → play completion sound
- Entering `error` → play error sound (always)
- Entering `permission` → play permission sound (always)
### Workspace Trust Gate
On first connection to a new working directory, the app checks for Claude hooks and prompts the user to trust the workspace. Trusted workspaces are persisted in `HikariConfig.trusted_workspaces`.
### Configuration Storage
All settings are persisted via `tauri-plugin-store` to a JSON file in the app data directory. The frontend `configStore` (`src/lib/stores/config.ts`) loads configuration on startup and provides reactive derived stores. Changes invoke `save_config` to persist to disk.
+45
View File
@@ -0,0 +1,45 @@
# Project Overview
## What is this project?
Hikari Desktop is a Tauri-based desktop application that wraps Claude Code with a visual anime character companion (Hikari) who appears on screen. It provides a rich UI for interacting with Claude Code, including conversation management, agent monitoring, cost tracking, and more.
The app was inspired by a Hatsune Miku mod for the ship AI in _The Outer Worlds_ — the idea of an AI assistant with an anime girl avatar that you can actually _see_.
## Goals
- Provide a beautiful, personalised interface for Claude Code
- Surface real-time status (thinking, typing, searching, etc.) through animated character sprites
- Track costs, context usage, and agent activity across sessions
- Support power-user workflows: multi-tab conversations, todo lists, git integration, MCP server management, session compaction, and more
- Build a foundation for autonomous task execution (agent orchestration, PRD-driven workflows)
## Tech Stack
- **Frontend**: Svelte 5 + TypeScript + Tailwind CSS
- **Backend**: Rust (Tauri v2)
- **Build**: Vite + pnpm
- **Testing**: Vitest (frontend) + cargo test (backend)
- **Linting**: ESLint + Prettier (frontend) + Clippy (backend)
- **IPC**: Tauri commands + events between Rust and Svelte
## Architecture
```
hikari-desktop/
├── src/ # Svelte frontend
│ └── lib/
│ ├── components/ # UI components (panels, modals, status bar)
│ ├── stores/ # Svelte stores (state management)
│ ├── types/ # TypeScript type definitions
│ └── utils/ # Utility functions
├── src-tauri/ # Rust backend
│ └── src/
│ ├── commands.rs # Tauri command handlers
│ ├── wsl_bridge.rs # Claude Code process management
│ ├── types.rs # Shared types & CharacterState enum
│ └── stats.rs # Cost tracking
└── public/ # Static assets (sprites, sounds)
```
Claude Code is launched as a child process via `WslBridge`, communicating via `--output-format stream-json` (NDJSON). Messages flow from the Rust backend to the Svelte frontend via Tauri events.
+63 -63
View File
@@ -1,6 +1,6 @@
{
"name": "hikari-desktop",
"version": "1.5.1",
"version": "1.11.1",
"description": "",
"type": "module",
"scripts": {
@@ -27,69 +27,69 @@
},
"license": "MIT",
"dependencies": {
"@codemirror/commands": "6.8.1",
"@codemirror/lang-angular": "^0.1.4",
"@codemirror/lang-cpp": "^6.0.3",
"@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-go": "^6.0.1",
"@codemirror/lang-html": "^6.4.11",
"@codemirror/lang-java": "^6.0.2",
"@codemirror/lang-javascript": "^6.2.4",
"@codemirror/lang-json": "^6.0.2",
"@codemirror/lang-less": "^6.0.2",
"@codemirror/lang-markdown": "^6.5.0",
"@codemirror/lang-php": "^6.0.2",
"@codemirror/lang-python": "^6.2.1",
"@codemirror/lang-rust": "^6.0.2",
"@codemirror/lang-sass": "^6.0.2",
"@codemirror/lang-sql": "^6.10.0",
"@codemirror/lang-vue": "^0.1.3",
"@codemirror/lang-wast": "^6.0.2",
"@codemirror/lang-xml": "^6.1.0",
"@codemirror/lang-yaml": "^6.1.2",
"@codemirror/language": "^6.12.1",
"@codemirror/legacy-modes": "^6.5.2",
"@codemirror/state": "^6.5.4",
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "6.39.13",
"@lezer/highlight": "^1.2.3",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
"@tauri-apps/plugin-dialog": "^2",
"@tauri-apps/plugin-fs": "^2.4.5",
"@tauri-apps/plugin-notification": "^2",
"@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-os": "^2",
"@tauri-apps/plugin-shell": "^2.3.4",
"@tauri-apps/plugin-store": "^2",
"codemirror": "^6.0.2",
"highlight.js": "^11.11.1",
"lucide-svelte": "^0.563.0",
"marked": "^17.0.1"
"@codemirror/commands": "6.10.2",
"@codemirror/lang-angular": "0.1.4",
"@codemirror/lang-cpp": "6.0.3",
"@codemirror/lang-css": "6.3.1",
"@codemirror/lang-go": "6.0.1",
"@codemirror/lang-html": "6.4.11",
"@codemirror/lang-java": "6.0.2",
"@codemirror/lang-javascript": "6.2.4",
"@codemirror/lang-json": "6.0.2",
"@codemirror/lang-less": "6.0.2",
"@codemirror/lang-markdown": "6.5.0",
"@codemirror/lang-php": "6.0.2",
"@codemirror/lang-python": "6.2.1",
"@codemirror/lang-rust": "6.0.2",
"@codemirror/lang-sass": "6.0.2",
"@codemirror/lang-sql": "6.10.0",
"@codemirror/lang-vue": "0.1.3",
"@codemirror/lang-wast": "6.0.2",
"@codemirror/lang-xml": "6.1.0",
"@codemirror/lang-yaml": "6.1.2",
"@codemirror/language": "6.12.2",
"@codemirror/legacy-modes": "6.5.2",
"@codemirror/state": "6.5.4",
"@codemirror/theme-one-dark": "6.1.3",
"@codemirror/view": "6.39.15",
"@lezer/highlight": "1.2.3",
"@tauri-apps/api": "2.10.1",
"@tauri-apps/plugin-clipboard-manager": "2.3.2",
"@tauri-apps/plugin-dialog": "2.6.0",
"@tauri-apps/plugin-fs": "2.4.5",
"@tauri-apps/plugin-notification": "2.3.3",
"@tauri-apps/plugin-opener": "2.5.3",
"@tauri-apps/plugin-os": "2.3.2",
"@tauri-apps/plugin-shell": "2.3.5",
"@tauri-apps/plugin-store": "2.4.2",
"codemirror": "6.0.2",
"highlight.js": "11.11.1",
"lucide-svelte": "0.575.0",
"marked": "17.0.3"
},
"devDependencies": {
"@eslint/js": "^9.39.2",
"@sveltejs/adapter-static": "^3.0.6",
"@sveltejs/kit": "^2.9.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@tailwindcss/vite": "^4.1.18",
"@tauri-apps/cli": "^2",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/svelte": "^5.3.1",
"@vitest/coverage-v8": "^4.0.18",
"eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.14.0",
"globals": "^17.0.0",
"jsdom": "^27.4.0",
"prettier": "^3.8.0",
"prettier-plugin-svelte": "^3.4.1",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"tailwindcss": "^4.1.18",
"typescript": "~5.6.2",
"typescript-eslint": "^8.53.0",
"vite": "^6.0.3",
"vitest": "^4.0.17"
"@eslint/js": "9.39.3",
"@sveltejs/adapter-static": "3.0.10",
"@sveltejs/kit": "2.53.2",
"@sveltejs/vite-plugin-svelte": "5.1.1",
"@tailwindcss/vite": "4.2.1",
"@tauri-apps/cli": "2.10.0",
"@testing-library/jest-dom": "6.9.1",
"@testing-library/svelte": "5.3.1",
"@vitest/coverage-v8": "4.0.18",
"eslint": "9.39.3",
"eslint-config-prettier": "10.1.8",
"eslint-plugin-svelte": "3.15.0",
"globals": "17.3.0",
"jsdom": "28.1.0",
"prettier": "3.8.1",
"prettier-plugin-svelte": "3.5.0",
"svelte": "5.53.5",
"svelte-check": "4.4.3",
"tailwindcss": "4.2.1",
"typescript": "5.9.3",
"typescript-eslint": "8.56.1",
"vite": "6.4.1",
"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]
name = "hikari-desktop"
version = "1.5.1"
version = "1.11.1"
description = "Hikari - Claude Code Visual Assistant"
authors = ["Naomi Carrigan"]
edition = "2021"
+621 -11
View File
@@ -7,6 +7,7 @@ use tauri_plugin_store::StoreExt;
use crate::achievements::{get_achievement_info, load_achievements, AchievementUnlockedEvent};
use crate::bridge_manager::SharedBridgeManager;
use crate::config::{ClaudeStartOptions, HikariConfig};
use crate::process_ext::HideWindow;
use crate::stats::UsageStats;
use crate::temp_manager::SharedTempFileManager;
@@ -59,6 +60,7 @@ fn create_claude_command() -> std::process::Command {
// 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();
@@ -66,6 +68,7 @@ fn create_claude_command() -> std::process::Command {
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
}
@@ -73,6 +76,7 @@ fn create_claude_command() -> std::process::Command {
// 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
}
@@ -85,18 +89,23 @@ fn create_claude_command() -> std::process::Command {
// 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();
std::process::Command::new(claude_path)
let mut cmd = std::process::Command::new(claude_path);
cmd.hide_window();
cmd
}
_ => {
// Fallback to just "claude" if which fails
// This maintains backwards compatibility
std::process::Command::new("claude")
let mut cmd = std::process::Command::new("claude");
cmd.hide_window();
cmd
}
}
}
@@ -334,6 +343,121 @@ pub async fn answer_question(
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]
pub async fn list_skills() -> Result<Vec<String>, String> {
// On Windows, we need to use WSL to access the skills directory
@@ -381,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
let output = Command::new("wsl")
.hide_window()
.args([
"-e",
"sh",
@@ -481,6 +606,62 @@ pub async fn check_for_updates() -> Result<UpdateInfo, String> {
})
}
#[derive(Debug, serde::Deserialize)]
struct GiteaChangelogRelease {
tag_name: String,
html_url: String,
body: Option<String>,
prerelease: bool,
created_at: String,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct ChangelogEntry {
pub version: String,
pub url: String,
pub notes: Option<String>,
pub prerelease: bool,
pub created_at: String,
}
#[tauri::command]
pub async fn fetch_changelog() -> Result<Vec<ChangelogEntry>, String> {
const RELEASES_API: &str =
"https://git.nhcarrigan.com/api/v1/repos/nhcarrigan/hikari-desktop/releases";
let client = reqwest::Client::new();
let response = client
.get(RELEASES_API)
.header("Accept", "application/json")
.query(&[("limit", "50")])
.send()
.await
.map_err(|e| format!("Failed to fetch releases: {}", e))?;
if !response.status().is_success() {
return Err(format!("API returned status: {}", response.status()));
}
let text = response
.text()
.await
.map_err(|e| format!("Failed to read response: {}", e))?;
let releases: Vec<GiteaChangelogRelease> =
serde_json::from_str(&text).map_err(|e| format!("Failed to parse releases: {}", e))?;
Ok(releases
.into_iter()
.map(|r| ChangelogEntry {
version: r.tag_name,
url: r.html_url,
notes: r.body,
prerelease: r.prerelease,
created_at: r.created_at,
})
.collect())
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct SavedFileInfo {
pub path: String,
@@ -680,6 +861,7 @@ async fn list_directory_via_wsl(path: &str) -> Result<Vec<FileEntry>, String> {
);
let output = Command::new("wsl")
.hide_window()
.args(["-e", "sh", "-c", &script])
.output()
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
@@ -736,12 +918,33 @@ pub async fn read_file_content(path: String) -> Result<String, String> {
.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)
#[allow(dead_code)]
async fn read_file_via_wsl(path: &str) -> Result<String, String> {
use std::process::Command;
let output = Command::new("wsl")
.hide_window()
.args(["-e", "cat", path])
.output()
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
@@ -773,6 +976,7 @@ async fn write_file_via_wsl(path: &str, content: &str) -> Result<(), String> {
use std::process::{Command, Stdio};
let mut child = Command::new("wsl")
.hide_window()
.args(["-e", "sh", "-c", &format!("cat > '{}'", path)])
.stdin(Stdio::piped())
.spawn()
@@ -821,6 +1025,7 @@ async fn create_file_via_wsl(path: &str) -> Result<(), String> {
// Check if file exists first
let check = Command::new("wsl")
.hide_window()
.args(["-e", "test", "-e", path])
.status()
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
@@ -830,6 +1035,7 @@ async fn create_file_via_wsl(path: &str) -> Result<(), String> {
}
let output = Command::new("wsl")
.hide_window()
.args(["-e", "touch", path])
.output()
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
@@ -870,6 +1076,7 @@ async fn create_directory_via_wsl(path: &str) -> Result<(), String> {
// Check if directory exists first
let check = Command::new("wsl")
.hide_window()
.args(["-e", "test", "-e", path])
.status()
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
@@ -879,6 +1086,7 @@ async fn create_directory_via_wsl(path: &str) -> Result<(), String> {
}
let output = Command::new("wsl")
.hide_window()
.args(["-e", "mkdir", "-p", path])
.output()
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
@@ -923,6 +1131,7 @@ async fn delete_file_via_wsl(path: &str) -> Result<(), String> {
// Check if path exists
let check_exists = Command::new("wsl")
.hide_window()
.args(["-e", "test", "-e", path])
.status()
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
@@ -933,6 +1142,7 @@ async fn delete_file_via_wsl(path: &str) -> Result<(), String> {
// Check if path is a directory
let check_dir = Command::new("wsl")
.hide_window()
.args(["-e", "test", "-d", path])
.status()
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
@@ -942,6 +1152,7 @@ async fn delete_file_via_wsl(path: &str) -> Result<(), String> {
}
let output = Command::new("wsl")
.hide_window()
.args(["-e", "rm", path])
.output()
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
@@ -986,6 +1197,7 @@ async fn delete_directory_via_wsl(path: &str) -> Result<(), String> {
// Check if path exists
let check_exists = Command::new("wsl")
.hide_window()
.args(["-e", "test", "-e", path])
.status()
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
@@ -996,6 +1208,7 @@ async fn delete_directory_via_wsl(path: &str) -> Result<(), String> {
// Check if path is a directory
let check_dir = Command::new("wsl")
.hide_window()
.args(["-e", "test", "-d", path])
.status()
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
@@ -1005,6 +1218,7 @@ async fn delete_directory_via_wsl(path: &str) -> Result<(), String> {
}
let output = Command::new("wsl")
.hide_window()
.args(["-e", "rm", "-rf", path])
.output()
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
@@ -1050,6 +1264,7 @@ async fn rename_path_via_wsl(old_path: &str, new_path: &str) -> Result<(), Strin
// Check if old path exists
let check_old = Command::new("wsl")
.hide_window()
.args(["-e", "test", "-e", old_path])
.status()
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
@@ -1060,6 +1275,7 @@ async fn rename_path_via_wsl(old_path: &str, new_path: &str) -> Result<(), Strin
// Check if new path already exists
let check_new = Command::new("wsl")
.hide_window()
.args(["-e", "test", "-e", new_path])
.status()
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
@@ -1069,6 +1285,7 @@ async fn rename_path_via_wsl(old_path: &str, new_path: &str) -> Result<(), Strin
}
let output = Command::new("wsl")
.hide_window()
.args(["-e", "mv", old_path, new_path])
.output()
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
@@ -1212,9 +1429,29 @@ pub async fn close_application(app_handle: AppHandle) -> Result<(), String> {
Ok(())
}
#[derive(serde::Serialize)]
pub struct MemoryFileInfo {
pub path: String,
pub heading: Option<String>,
}
#[derive(serde::Serialize)]
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]
@@ -1246,6 +1483,7 @@ async fn list_memory_files_via_wsl() -> Result<MemoryFilesResponse, String> {
"#;
let output = Command::new("wsl")
.hide_window()
.args(["-e", "bash", "-l", "-c", script])
.output()
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
@@ -1256,12 +1494,19 @@ async fn list_memory_files_via_wsl() -> Result<MemoryFilesResponse, String> {
}
let stdout = String::from_utf8_lossy(&output.stdout);
let files: Vec<String> = 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 })
}
@@ -1283,10 +1528,13 @@ async fn list_memory_files_native() -> Result<MemoryFilesResponse, String> {
return Ok(MemoryFilesResponse { files: Vec::new() });
}
let mut memory_files = Vec::new();
let mut memory_paths = Vec::new();
// 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() {
return Ok(());
}
@@ -1319,16 +1567,25 @@ async fn list_memory_files_native() -> Result<MemoryFilesResponse, String> {
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));
}
// Sort files alphabetically
memory_files.sort();
memory_paths.sort();
Ok(MemoryFilesResponse {
files: memory_files,
})
// Read first heading from each file
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]
@@ -1360,6 +1617,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 ====================
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -1998,6 +2393,160 @@ pub async fn get_mcp_server_details(name: String) -> Result<String, String> {
}
}
// ==================== Codebase Mapper ====================
/// Directories to skip when scanning (always ignored regardless of .gitignore)
const SCAN_SKIP_DIRS: &[&str] = &[
".git",
"node_modules",
"target",
".next",
"dist",
"build",
"out",
"__pycache__",
".cache",
".pytest_cache",
"vendor",
".idea",
".vscode",
"coverage",
".nyc_output",
"venv",
".venv",
"env",
".tox",
];
/// Files that indicate the project type
const PROJECT_MARKERS: &[(&str, &str)] = &[
("Cargo.toml", "Rust"),
("package.json", "Node.js"),
("pyproject.toml", "Python"),
("requirements.txt", "Python"),
("go.mod", "Go"),
("pom.xml", "Java (Maven)"),
("build.gradle", "Java (Gradle)"),
("Gemfile", "Ruby"),
("composer.json", "PHP"),
("*.csproj", "C#/.NET"),
("CMakeLists.txt", "C/C++ (CMake)"),
("Makefile", "C/C++"),
];
#[derive(Debug, Serialize)]
pub struct ProjectScan {
pub working_dir: String,
pub file_tree: String,
pub detected_type: String,
pub key_files: Vec<String>,
}
/// Recursively build a file tree string, respecting skip dirs, up to `max_depth` levels.
fn build_file_tree(
dir: &std::path::Path,
prefix: &str,
depth: usize,
max_depth: usize,
lines: &mut Vec<String>,
) {
if depth > max_depth {
lines.push(format!("{}...", prefix));
return;
}
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
let mut items: Vec<std::fs::DirEntry> = entries
.filter_map(|e| e.ok())
.collect();
items.sort_by_key(|e| {
let name = e.file_name().to_string_lossy().to_lowercase();
// Sort: hidden last, directories first
let is_hidden = name.starts_with('.');
let is_dir = e.path().is_dir();
(is_hidden, !is_dir, name)
});
let count = items.len();
for (i, entry) in items.iter().enumerate() {
let name = entry.file_name().to_string_lossy().to_string();
let is_last = i == count - 1;
let connector = if is_last { "└── " } else { "├── " };
let child_prefix = if is_last {
format!("{} ", prefix)
} else {
format!("{}", prefix)
};
let path = entry.path();
if path.is_dir() {
if SCAN_SKIP_DIRS.contains(&name.as_str()) {
lines.push(format!("{}{}{}/ (skipped)", prefix, connector, name));
continue;
}
lines.push(format!("{}{}{}/", prefix, connector, name));
build_file_tree(&path, &child_prefix, depth + 1, max_depth, lines);
} else {
lines.push(format!("{}{}{}", prefix, connector, name));
}
}
}
#[tauri::command]
pub async fn scan_project(working_dir: String) -> Result<ProjectScan, String> {
let dir_path = std::path::Path::new(&working_dir);
if !dir_path.exists() {
return Err(format!("Directory does not exist: {}", working_dir));
}
if !dir_path.is_dir() {
return Err(format!("Path is not a directory: {}", working_dir));
}
// Detect project type by checking for marker files
let mut detected_type = "Unknown".to_string();
let mut key_files: Vec<String> = Vec::new();
for (marker, project_type) in PROJECT_MARKERS {
let marker_path = dir_path.join(marker);
if marker_path.exists() {
if detected_type == "Unknown" {
detected_type = project_type.to_string();
}
key_files.push(marker.to_string());
}
}
// Also collect other notable root-level files
let notable_root_files = &[
"README.md", "CLAUDE.md", "LICENSE", ".env.example",
"docker-compose.yml", "Dockerfile", ".github",
"tsconfig.json", "vitest.config.ts", "eslint.config.js",
"check-all.sh", "tauri.conf.json",
];
for file in notable_root_files {
let file_path = dir_path.join(file);
if file_path.exists() && !key_files.contains(&file.to_string()) {
key_files.push(file.to_string());
}
}
// Build file tree (max 4 levels deep)
let mut lines: Vec<String> = vec![format!("{}/", working_dir)];
build_file_tree(dir_path, "", 0, 4, &mut lines);
let file_tree = lines.join("\n");
Ok(ProjectScan {
working_dir,
file_tree,
detected_type,
key_files,
})
}
#[cfg(test)]
mod tests {
use super::*;
@@ -2622,4 +3171,65 @@ gitea: gitea-mcp -t stdio (STDIO) - ✓ Connected"#;
assert_eq!(servers[0].name, "asana");
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())
);
}
}
+185 -9
View File
@@ -25,6 +25,15 @@ pub struct ClaudeStartOptions {
#[serde(default)]
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)]
@@ -113,6 +122,52 @@ pub struct HikariConfig {
#[serde(default = "default_discord_rpc_enabled")]
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>,
// Task Loop auto-commit settings
#[serde(default)]
pub task_loop_auto_commit: bool,
#[serde(default = "default_task_loop_commit_prefix")]
pub task_loop_commit_prefix: String,
#[serde(default)]
pub task_loop_include_summary: bool,
}
impl Default for HikariConfig {
@@ -145,6 +200,20 @@ impl Default for HikariConfig {
budget_action: BudgetAction::Warn,
budget_warning_threshold: 0.8,
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,
task_loop_auto_commit: false,
task_loop_commit_prefix: "feat".to_string(),
task_loop_include_summary: false,
}
}
}
@@ -181,6 +250,14 @@ fn default_discord_rpc_enabled() -> bool {
true
}
fn default_background_image_opacity() -> f32 {
0.3
}
fn default_task_loop_commit_prefix() -> String {
"feat".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum BudgetAction {
@@ -198,6 +275,18 @@ pub enum Theme {
#[serde(rename = "high-contrast")]
HighContrast,
Custom,
Dracula,
Catppuccin,
Nord,
Solarized,
#[serde(rename = "solarized-light")]
SolarizedLight,
#[serde(rename = "catppuccin-latte")]
CatppuccinLatte,
#[serde(rename = "gruvbox-light")]
GruvboxLight,
#[serde(rename = "rose-pine-dawn")]
RosePineDawn,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
@@ -252,6 +341,17 @@ mod tests {
assert_eq!(config.budget_action, BudgetAction::Warn);
assert!((config.budget_warning_threshold - 0.8).abs() < f32::EPSILON);
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());
assert!(!config.task_loop_auto_commit);
assert_eq!(config.task_loop_commit_prefix, "feat");
assert!(!config.task_loop_include_summary);
}
#[test]
@@ -284,6 +384,20 @@ mod tests {
budget_action: BudgetAction::Block,
budget_warning_threshold: 0.75,
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,
task_loop_auto_commit: true,
task_loop_commit_prefix: "fix".to_string(),
task_loop_include_summary: true,
};
let json = serde_json::to_string(&config).unwrap();
@@ -298,22 +412,84 @@ mod tests {
deserialized.greeting_custom_prompt,
Some("Hello!".to_string())
);
assert!(deserialized.task_loop_auto_commit);
assert_eq!(deserialized.task_loop_commit_prefix, "fix");
assert!(deserialized.task_loop_include_summary);
}
#[test]
fn test_theme_serialization() {
let dark = Theme::Dark;
let light = Theme::Light;
let high_contrast = Theme::HighContrast;
assert_eq!(serde_json::to_string(&dark).unwrap(), "\"dark\"");
assert_eq!(serde_json::to_string(&light).unwrap(), "\"light\"");
assert_eq!(serde_json::to_string(&Theme::Dark).unwrap(), "\"dark\"");
assert_eq!(serde_json::to_string(&Theme::Light).unwrap(), "\"light\"");
assert_eq!(
serde_json::to_string(&high_contrast).unwrap(),
serde_json::to_string(&Theme::HighContrast).unwrap(),
"\"high-contrast\""
);
assert_eq!(serde_json::to_string(&Theme::Custom).unwrap(), "\"custom\"");
assert_eq!(
serde_json::to_string(&Theme::Dracula).unwrap(),
"\"dracula\""
);
assert_eq!(
serde_json::to_string(&Theme::Catppuccin).unwrap(),
"\"catppuccin\""
);
assert_eq!(serde_json::to_string(&Theme::Nord).unwrap(), "\"nord\"");
assert_eq!(
serde_json::to_string(&Theme::Solarized).unwrap(),
"\"solarized\""
);
assert_eq!(
serde_json::to_string(&Theme::SolarizedLight).unwrap(),
"\"solarized-light\""
);
assert_eq!(
serde_json::to_string(&Theme::CatppuccinLatte).unwrap(),
"\"catppuccin-latte\""
);
assert_eq!(
serde_json::to_string(&Theme::GruvboxLight).unwrap(),
"\"gruvbox-light\""
);
assert_eq!(
serde_json::to_string(&Theme::RosePineDawn).unwrap(),
"\"rose-pine-dawn\""
);
}
let custom = Theme::Custom;
assert_eq!(serde_json::to_string(&custom).unwrap(), "\"custom\"");
#[test]
fn test_theme_deserialization() {
assert_eq!(
serde_json::from_str::<Theme>("\"dracula\"").unwrap(),
Theme::Dracula
);
assert_eq!(
serde_json::from_str::<Theme>("\"catppuccin\"").unwrap(),
Theme::Catppuccin
);
assert_eq!(
serde_json::from_str::<Theme>("\"nord\"").unwrap(),
Theme::Nord
);
assert_eq!(
serde_json::from_str::<Theme>("\"solarized\"").unwrap(),
Theme::Solarized
);
assert_eq!(
serde_json::from_str::<Theme>("\"solarized-light\"").unwrap(),
Theme::SolarizedLight
);
assert_eq!(
serde_json::from_str::<Theme>("\"catppuccin-latte\"").unwrap(),
Theme::CatppuccinLatte
);
assert_eq!(
serde_json::from_str::<Theme>("\"gruvbox-light\"").unwrap(),
Theme::GruvboxLight
);
assert_eq!(
serde_json::from_str::<Theme>("\"rose-pine-dawn\"").unwrap(),
Theme::RosePineDawn
);
}
}
+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'));
}
}
+53
View File
@@ -1,6 +1,9 @@
use serde::{Deserialize, Serialize};
use std::process::Command;
#[cfg(target_os = "windows")]
use crate::process_ext::HideWindow;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GitStatus {
pub is_repo: bool,
@@ -35,7 +38,37 @@ pub struct GitLogEntry {
pub message: String,
}
/// Builds the WSL argument list for running a git command at a Linux path.
/// Extracted for testability without requiring WSL to be available.
#[cfg(any(target_os = "windows", test))]
fn build_wsl_git_args<'a>(working_dir: &'a str, args: &[&'a str]) -> Vec<&'a str> {
let mut wsl_args = vec!["--", "git", "-C", working_dir];
wsl_args.extend_from_slice(args);
wsl_args
}
fn run_git_command(working_dir: &str, args: &[&str]) -> Result<String, String> {
#[cfg(target_os = "windows")]
let output = {
if working_dir.starts_with('/') {
// WSL/Linux path — run git through WSL so it can resolve the path correctly.
let wsl_args = build_wsl_git_args(working_dir, args);
Command::new("wsl")
.hide_window()
.args(&wsl_args)
.output()
.map_err(|e| format!("Failed to execute git via WSL: {}", e))?
} else {
Command::new("git")
.hide_window()
.args(args)
.current_dir(working_dir)
.output()
.map_err(|e| format!("Failed to execute git: {}", e))?
}
};
#[cfg(not(target_os = "windows"))]
let output = Command::new("git")
.args(args)
.current_dir(working_dir)
@@ -294,6 +327,26 @@ mod tests {
use std::io::Write;
use tempfile::TempDir;
// ==================== build_wsl_git_args tests ====================
#[test]
fn test_build_wsl_git_args_structure() {
let args = build_wsl_git_args("/home/naomi/code/project", &["status", "--porcelain=v1"]);
assert_eq!(args[0], "--");
assert_eq!(args[1], "git");
assert_eq!(args[2], "-C");
assert_eq!(args[3], "/home/naomi/code/project");
assert_eq!(args[4], "status");
assert_eq!(args[5], "--porcelain=v1");
assert_eq!(args.len(), 6);
}
#[test]
fn test_build_wsl_git_args_no_extra_args() {
let args = build_wsl_git_args("/home/user/repo", &["init"]);
assert_eq!(args, vec!["--", "git", "-C", "/home/user/repo", "init"]);
}
// Helper to create a git repository in a temp directory
fn create_test_repo() -> TempDir {
let temp_dir = TempDir::new().unwrap();
+13
View File
@@ -6,8 +6,10 @@ mod config;
mod cost_tracking;
mod debug_logger;
mod discord_rpc;
mod drafts;
mod git;
mod notifications;
mod process_ext;
mod quick_actions;
mod sessions;
mod snippets;
@@ -27,6 +29,7 @@ use commands::load_saved_achievements;
use commands::*;
use debug_logger::TauriLogLayer;
use discord_rpc::DiscordRpcManager;
use drafts::*;
use git::*;
use notifications::*;
use quick_actions::*;
@@ -120,6 +123,7 @@ pub fn run() {
get_persisted_stats,
load_saved_achievements,
answer_question,
check_workspace_hooks,
send_windows_notification,
send_simple_notification,
send_windows_toast,
@@ -129,6 +133,7 @@ pub fn run() {
validate_directory,
list_skills,
check_for_updates,
fetch_changelog,
save_temp_file,
register_temp_file,
get_temp_files,
@@ -195,6 +200,9 @@ pub fn run() {
close_application,
list_memory_files,
get_claude_version,
get_auth_status,
auth_login,
auth_logout,
list_plugins,
install_plugin,
uninstall_plugin,
@@ -209,6 +217,11 @@ pub fn run() {
remove_mcp_server,
add_mcp_server,
get_mcp_server_details,
list_drafts,
save_draft,
delete_draft,
delete_all_drafts,
scan_project,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
+6
View File
@@ -1,6 +1,8 @@
use std::process::Command;
use tauri::command;
use crate::process_ext::HideWindow;
/// Generate PowerShell script for Windows Toast Notification
fn generate_powershell_toast_script(title: &str, body: &str) -> String {
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> {
// Use notify-send for Linux/WSL
let output = Command::new("notify-send")
.hide_window()
.arg(&title)
.arg(&body)
.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
let output = Command::new("pwsh.exe")
.hide_window()
.arg("-NoProfile")
.arg("-WindowStyle")
.arg("Hidden")
@@ -117,6 +121,7 @@ pub async fn send_windows_notification(title: String, body: String) -> Result<()
.output()
.or_else(|_| {
Command::new("powershell.exe")
.hide_window()
.arg("-NoProfile")
.arg("-WindowStyle")
.arg("Hidden")
@@ -140,6 +145,7 @@ pub async fn send_simple_notification(title: String, body: String) -> Result<(),
let message = format_simple_notification(&title, &body);
Command::new("cmd.exe")
.hide_window()
.arg("/c")
.arg("msg")
.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
fn get_context_window_limit(model: &str) -> u64 {
match model {
// Claude 4.6 family - 200K standard (1M beta available via header)
// Claude 4.6 family
"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-opus-4-5-20251101"
| "claude-sonnet-4-5-20250929"
@@ -502,6 +503,7 @@ pub fn calculate_cost(
let (input_price_per_million, output_price_per_million) = match model {
// Current generation (Claude 4.6)
"claude-opus-4-6" => (5.0, 25.0),
"claude-sonnet-4-6" => (3.0, 15.0),
// Previous generation (Claude 4.5)
"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,
}
/// 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)]
#[serde(tag = "type")]
pub enum ClaudeMessage {
@@ -100,6 +120,11 @@ pub enum ClaudeMessage {
#[serde(default)]
usage: Option<UsageInfo>,
},
#[serde(rename = "rate_limit_event")]
RateLimitEvent {
#[serde(default)]
rate_limit_info: RateLimitInfo,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -280,6 +305,8 @@ pub struct AgentEndEvent {
pub duration_ms: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub num_turns: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_assistant_message: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -446,4 +473,77 @@ mod tests {
assert!(serialized.contains("\"input_tokens\":100"));
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 tempfile::NamedTempFile;
use crate::process_ext::HideWindow;
#[command]
pub async fn send_vbs_notification(title: String, body: String) -> Result<(), String> {
// 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/") {
// WSL temp files might be in a different location
// 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 result.status.success() {
@@ -57,6 +59,7 @@ objShell.Popup "{}" & vbCrLf & vbCrLf & "{}", 5, "{}", 64
// Execute the VBScript using wscript.exe
let output = Command::new("/mnt/c/Windows/System32/wscript.exe")
.hide_window()
.arg("//NoLogo")
.arg(&windows_path)
.output()
File diff suppressed because it is too large Load Diff
+4
View File
@@ -1,6 +1,8 @@
use std::process::Command;
use tauri::command;
use crate::process_ext::HideWindow;
#[command]
pub async fn send_wsl_notification(title: String, body: String) -> Result<(), String> {
// Method 1: Try Windows 10/11 toast notification using PowerShell
@@ -36,6 +38,7 @@ $notifier.Show($toast)
// Try PowerShell.exe through WSL
let output = Command::new("/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe")
.hide_window()
.arg("-NoProfile")
.arg("-ExecutionPolicy")
.arg("Bypass")
@@ -65,6 +68,7 @@ $notifier.Show($toast)
// Method 3: Try wsl-notify-send if available
let notify_result = Command::new("wsl-notify-send")
.hide_window()
.arg("--appId")
.arg("HikariDesktop")
.arg("--category")
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "hikari-desktop",
"version": "1.5.1",
"version": "1.11.1",
"identifier": "com.naomi.hikari-desktop",
"build": {
"beforeDevCommand": "pnpm dev",
+393 -5
View File
@@ -148,17 +148,405 @@
--hljs-meta: #cccccc;
}
[data-theme="dracula"] {
--bg-primary: #282a36;
--bg-secondary: #1e1f29;
--bg-terminal: #191a21;
--bg-hover: #44475a;
--bg-code: #282a36;
--accent-primary: #bd93f9;
--accent-secondary: #ff79c6;
--text-primary: #f8f8f2;
--text-secondary: #6272a4;
--text-tertiary: #44475a;
--border-color: #44475a;
/* Trans pride colors */
--trans-blue: #5bcefa;
--trans-pink: #f5a9b8;
--trans-white: #ffffff;
--trans-gradient: linear-gradient(
135deg,
var(--trans-blue) 0%,
var(--trans-pink) 50%,
var(--trans-white) 100%
);
--trans-gradient-vibrant: linear-gradient(
135deg,
var(--trans-blue) 0%,
var(--trans-pink) 35%,
var(--trans-white) 50%,
var(--trans-pink) 65%,
var(--trans-blue) 100%
);
/* Terminal specific colors */
--terminal-user: #8be9fd;
--terminal-tool: #bd93f9;
--terminal-tool-name: #caa9fa;
--terminal-error: #ff5555;
/* Syntax highlighting colors (Dracula) */
--hljs-keyword: #ff79c6;
--hljs-string: #f1fa8c;
--hljs-number: #bd93f9;
--hljs-comment: #6272a4;
--hljs-function: #50fa7b;
--hljs-type: #8be9fd;
--hljs-variable: #ffb86c;
--hljs-meta: #94a3b8;
}
[data-theme="catppuccin"] {
--bg-primary: #1e1e2e;
--bg-secondary: #181825;
--bg-terminal: #11111b;
--bg-hover: #313244;
--bg-code: #1e1e2e;
--accent-primary: #cba6f7;
--accent-secondary: #f5c2e7;
--text-primary: #cdd6f4;
--text-secondary: #a6adc8;
--text-tertiary: #6c7086;
--border-color: #313244;
/* Trans pride colors */
--trans-blue: #5bcefa;
--trans-pink: #f5a9b8;
--trans-white: #ffffff;
--trans-gradient: linear-gradient(
135deg,
var(--trans-blue) 0%,
var(--trans-pink) 50%,
var(--trans-white) 100%
);
--trans-gradient-vibrant: linear-gradient(
135deg,
var(--trans-blue) 0%,
var(--trans-pink) 35%,
var(--trans-white) 50%,
var(--trans-pink) 65%,
var(--trans-blue) 100%
);
/* Terminal specific colors */
--terminal-user: #89dceb;
--terminal-tool: #cba6f7;
--terminal-tool-name: #d9b3ff;
--terminal-error: #f38ba8;
/* Syntax highlighting colors (Catppuccin Mocha) */
--hljs-keyword: #cba6f7;
--hljs-string: #a6e3a1;
--hljs-number: #fab387;
--hljs-comment: #6c7086;
--hljs-function: #89b4fa;
--hljs-type: #89dceb;
--hljs-variable: #fab387;
--hljs-meta: #a6adc8;
}
[data-theme="nord"] {
--bg-primary: #2e3440;
--bg-secondary: #3b4252;
--bg-terminal: #242933;
--bg-hover: #434c5e;
--bg-code: #2e3440;
--accent-primary: #88c0d0;
--accent-secondary: #81a1c1;
--text-primary: #eceff4;
--text-secondary: #d8dee9;
--text-tertiary: #4c566a;
--border-color: #434c5e;
/* Trans pride colors */
--trans-blue: #5bcefa;
--trans-pink: #f5a9b8;
--trans-white: #ffffff;
--trans-gradient: linear-gradient(
135deg,
var(--trans-blue) 0%,
var(--trans-pink) 50%,
var(--trans-white) 100%
);
--trans-gradient-vibrant: linear-gradient(
135deg,
var(--trans-blue) 0%,
var(--trans-pink) 35%,
var(--trans-white) 50%,
var(--trans-pink) 65%,
var(--trans-blue) 100%
);
/* Terminal specific colors */
--terminal-user: #88c0d0;
--terminal-tool: #b48ead;
--terminal-tool-name: #c7a8c9;
--terminal-error: #bf616a;
/* Syntax highlighting colors (Nord) */
--hljs-keyword: #81a1c1;
--hljs-string: #a3be8c;
--hljs-number: #b48ead;
--hljs-comment: #4c566a;
--hljs-function: #88c0d0;
--hljs-type: #8fbcbb;
--hljs-variable: #d08770;
--hljs-meta: #616e88;
}
[data-theme="solarized"] {
--bg-primary: #002b36;
--bg-secondary: #073642;
--bg-terminal: #00212b;
--bg-hover: #094656;
--bg-code: #002b36;
--accent-primary: #268bd2;
--accent-secondary: #2aa198;
--text-primary: #fdf6e3;
--text-secondary: #93a1a1;
--text-tertiary: #657b83;
--border-color: #094656;
/* Trans pride colors */
--trans-blue: #5bcefa;
--trans-pink: #f5a9b8;
--trans-white: #ffffff;
--trans-gradient: linear-gradient(
135deg,
var(--trans-blue) 0%,
var(--trans-pink) 50%,
var(--trans-white) 100%
);
--trans-gradient-vibrant: linear-gradient(
135deg,
var(--trans-blue) 0%,
var(--trans-pink) 35%,
var(--trans-white) 50%,
var(--trans-pink) 65%,
var(--trans-blue) 100%
);
/* Terminal specific colors */
--terminal-user: #2aa198;
--terminal-tool: #6c71c4;
--terminal-tool-name: #9395d0;
--terminal-error: #dc322f;
/* Syntax highlighting colors (Solarized Dark) */
--hljs-keyword: #859900;
--hljs-string: #2aa198;
--hljs-number: #d33682;
--hljs-comment: #586e75;
--hljs-function: #268bd2;
--hljs-type: #b58900;
--hljs-variable: #cb4b16;
--hljs-meta: #657b83;
}
[data-theme="solarized-light"] {
--bg-primary: #fdf6e3;
--bg-secondary: #eee8d5;
--bg-terminal: #f9f3d7;
--bg-hover: #d8d1be;
--bg-code: #eee8d5;
--accent-primary: #268bd2;
--accent-secondary: #2aa198;
--text-primary: #657b83;
--text-secondary: #839496;
--text-tertiary: #93a1a1;
--border-color: #cfc9b5;
/* Trans pride colors */
--trans-blue: #5bcefa;
--trans-pink: #f5a9b8;
--trans-white: #ffffff;
--trans-gradient: linear-gradient(
135deg,
var(--trans-blue) 0%,
var(--trans-pink) 50%,
var(--trans-white) 100%
);
--trans-gradient-vibrant: linear-gradient(
135deg,
var(--trans-blue) 0%,
var(--trans-pink) 35%,
var(--trans-white) 50%,
var(--trans-pink) 65%,
var(--trans-blue) 100%
);
/* Terminal specific colors */
--terminal-user: #268bd2;
--terminal-tool: #6c71c4;
--terminal-tool-name: #8f94cc;
--terminal-error: #dc322f;
/* Syntax highlighting colors (Solarized Light) */
--hljs-keyword: #859900;
--hljs-string: #2aa198;
--hljs-number: #d33682;
--hljs-comment: #93a1a1;
--hljs-function: #268bd2;
--hljs-type: #b58900;
--hljs-variable: #cb4b16;
--hljs-meta: #657b83;
}
[data-theme="catppuccin-latte"] {
--bg-primary: #eff1f5;
--bg-secondary: #e6e9ef;
--bg-terminal: #dce0e8;
--bg-hover: #ccd0da;
--bg-code: #e6e9ef;
--accent-primary: #8839ef;
--accent-secondary: #ea76cb;
--text-primary: #4c4f69;
--text-secondary: #6c6f85;
--text-tertiary: #9ca0b0;
--border-color: #bcc0cc;
/* Trans pride colors */
--trans-blue: #5bcefa;
--trans-pink: #f5a9b8;
--trans-white: #ffffff;
--trans-gradient: linear-gradient(
135deg,
var(--trans-blue) 0%,
var(--trans-pink) 50%,
var(--trans-white) 100%
);
--trans-gradient-vibrant: linear-gradient(
135deg,
var(--trans-blue) 0%,
var(--trans-pink) 35%,
var(--trans-white) 50%,
var(--trans-pink) 65%,
var(--trans-blue) 100%
);
/* Terminal specific colors */
--terminal-user: #209fb5;
--terminal-tool: #8839ef;
--terminal-tool-name: #a259f1;
--terminal-error: #d20f39;
/* Syntax highlighting colors (Catppuccin Latte) */
--hljs-keyword: #8839ef;
--hljs-string: #40a02b;
--hljs-number: #fe640b;
--hljs-comment: #8c8fa1;
--hljs-function: #1e66f5;
--hljs-type: #209fb5;
--hljs-variable: #fe640b;
--hljs-meta: #5c5f77;
}
[data-theme="gruvbox-light"] {
--bg-primary: #fbf1c7;
--bg-secondary: #ebdbb2;
--bg-terminal: #f9f5d7;
--bg-hover: #d5c4a1;
--bg-code: #ebdbb2;
--accent-primary: #458588;
--accent-secondary: #689d6a;
--text-primary: #3c3836;
--text-secondary: #665c54;
--text-tertiary: #7c6f64;
--border-color: #bdae93;
/* Trans pride colors */
--trans-blue: #5bcefa;
--trans-pink: #f5a9b8;
--trans-white: #ffffff;
--trans-gradient: linear-gradient(
135deg,
var(--trans-blue) 0%,
var(--trans-pink) 50%,
var(--trans-white) 100%
);
--trans-gradient-vibrant: linear-gradient(
135deg,
var(--trans-blue) 0%,
var(--trans-pink) 35%,
var(--trans-white) 50%,
var(--trans-pink) 65%,
var(--trans-blue) 100%
);
/* Terminal specific colors */
--terminal-user: #458588;
--terminal-tool: #b16286;
--terminal-tool-name: #c37aa0;
--terminal-error: #cc241d;
/* Syntax highlighting colors (Gruvbox Light) */
--hljs-keyword: #d65d0e;
--hljs-string: #98971a;
--hljs-number: #b16286;
--hljs-comment: #928374;
--hljs-function: #458588;
--hljs-type: #d79921;
--hljs-variable: #af3a03;
--hljs-meta: #7c6f64;
}
[data-theme="rose-pine-dawn"] {
--bg-primary: #faf4ed;
--bg-secondary: #fffaf3;
--bg-terminal: #f2e9e1;
--bg-hover: #dfdad9;
--bg-code: #fffaf3;
--accent-primary: #907aa9;
--accent-secondary: #d7827e;
--text-primary: #575279;
--text-secondary: #797593;
--text-tertiary: #9893a5;
--border-color: #cecacd;
/* Trans pride colors */
--trans-blue: #5bcefa;
--trans-pink: #f5a9b8;
--trans-white: #ffffff;
--trans-gradient: linear-gradient(
135deg,
var(--trans-blue) 0%,
var(--trans-pink) 50%,
var(--trans-white) 100%
);
--trans-gradient-vibrant: linear-gradient(
135deg,
var(--trans-blue) 0%,
var(--trans-pink) 35%,
var(--trans-white) 50%,
var(--trans-pink) 65%,
var(--trans-blue) 100%
);
/* Terminal specific colors */
--terminal-user: #56949f;
--terminal-tool: #907aa9;
--terminal-tool-name: #a48abf;
--terminal-error: #b4637a;
/* Syntax highlighting colors (Rosé Pine Dawn) */
--hljs-keyword: #286983;
--hljs-string: #56949f;
--hljs-number: #ea9d34;
--hljs-comment: #9893a5;
--hljs-function: #907aa9;
--hljs-type: #d7827e;
--hljs-variable: #b4637a;
--hljs-meta: #797593;
}
html,
body {
margin: 0;
padding: 0;
height: 100%;
overflow: hidden;
font-family:
"Segoe UI",
system-ui,
-apple-system,
sans-serif;
font-family: var(--ui-font-family, "Segoe UI", system-ui, -apple-system, sans-serif);
background: var(--bg-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 {
slashCommands,
parseSlashCommand,
@@ -40,6 +45,28 @@ vi.mock("$lib/stores/character", () => ({
vi.mock("$lib/tauri", () => ({
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", () => ({
@@ -415,4 +442,425 @@ describe("slashCommands", () => {
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,
mcp_servers_json: config.mcp_servers_json || null,
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,
mcp_servers_json: config.mcp_servers_json || null,
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>
{/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
class="px-1.5 py-0.5 text-[10px] rounded border {getStatusBadgeClass(
agent.status
@@ -310,6 +318,16 @@
<span class="text-[10px] text-red-400">Errored / Killed</span>
{/if}
</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>
{/each}
{/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>
+153
View File
@@ -0,0 +1,153 @@
<script lang="ts">
import { invoke } from "@tauri-apps/api/core";
import { openUrl } from "@tauri-apps/plugin-opener";
import { getVersion } from "@tauri-apps/api/app";
import { onMount } from "svelte";
import type { ChangelogEntry } from "$lib/types/messages";
import Markdown from "./Markdown.svelte";
interface Props {
onClose: () => void;
}
const { onClose }: Props = $props();
let entries = $state<ChangelogEntry[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
let currentVersion = $state("");
export function formatReleaseDate(isoString: string): string {
if (!isoString) return "Unknown date";
const date = new Date(isoString);
if (isNaN(date.getTime())) return "Unknown date";
return date.toLocaleDateString("en-GB", {
year: "numeric",
month: "long",
day: "numeric",
timeZone: "UTC",
});
}
async function loadChangelog(): Promise<void> {
loading = true;
error = null;
try {
entries = await invoke<ChangelogEntry[]>("fetch_changelog");
} catch (err) {
error = err instanceof Error ? err.message : String(err);
} finally {
loading = false;
}
}
onMount(async () => {
currentVersion = await getVersion();
await loadChangelog();
});
</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="changelog-title"
tabindex="-1"
>
<div class="flex items-center justify-between p-6 pb-4 border-b border-[var(--border-color)]">
<h2 id="changelog-title" class="text-xl font-semibold text-[var(--text-primary)]">
Changelog
</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>
<div class="overflow-y-auto flex-1 p-6">
{#if loading}
<div class="flex items-center justify-center py-12">
<div
class="w-8 h-8 border-2 border-[var(--accent-primary)] border-t-transparent rounded-full animate-spin"
></div>
<span class="ml-3 text-[var(--text-secondary)]">Fetching releases...</span>
</div>
{:else if error}
<div class="text-center py-12">
<p class="text-red-400 mb-4">{error}</p>
<button onclick={loadChangelog} class="btn-trans-gradient px-4 py-2 rounded text-sm">
Retry
</button>
</div>
{:else if entries.length === 0}
<p class="text-center text-[var(--text-secondary)] py-12">No releases found.</p>
{:else}
<div class="space-y-6">
{#each entries as entry (entry.version)}
<div class="border border-[var(--border-color)] rounded-lg overflow-hidden">
<div
class="flex flex-wrap items-center gap-2 px-4 py-3 bg-[var(--bg-secondary)] border-b border-[var(--border-color)]"
>
<span
class="font-mono font-semibold text-sm {entry.version === `v${currentVersion}`
? 'text-[var(--trans-pink)]'
: 'text-[var(--text-primary)]'}"
>
{entry.version}
</span>
{#if entry.version === `v${currentVersion}`}
<span
class="text-xs px-2 py-0.5 rounded-full bg-[var(--trans-pink)]/20 text-[var(--trans-pink)] border border-[var(--trans-pink)]/30"
>
current
</span>
{/if}
{#if entry.prerelease}
<span
class="text-xs px-2 py-0.5 rounded-full bg-yellow-500/20 text-yellow-400 border border-yellow-500/30"
>
pre-release
</span>
{/if}
<span class="ml-auto text-xs text-[var(--text-muted)]">
{formatReleaseDate(entry.created_at)}
</span>
<button
onclick={() => openUrl(entry.url)}
class="text-xs text-[var(--accent-primary)] hover:text-[var(--accent-primary-hover)] transition-colors underline"
>
View on Gitea
</button>
</div>
{#if entry.notes}
<div class="p-4 text-sm text-[var(--text-secondary)]">
<Markdown content={entry.notes} />
</div>
{:else}
<p class="p-4 text-xs text-[var(--text-muted)] italic">No release notes.</p>
{/if}
</div>
{/each}
</div>
{/if}
</div>
</div>
</div>
+68
View File
@@ -0,0 +1,68 @@
/**
* ChangelogPanel Component Tests
*
* Tests the pure helper function exported by ChangelogPanel for formatting
* ISO 8601 date strings into human-readable release dates.
*
* What this component does:
* - Opens as a modal dialog from the nav menu
* - Fetches all releases via the `fetch_changelog` Tauri IPC command on mount
* - Shows a loading spinner while fetching
* - Renders each release with version badge, date, pre-release badge, and notes
* - Highlights the currently installed version with a pink "current" badge
* - Provides a "View on Gitea" link per release
* - Shows an error state with a Retry button if the fetch fails
*
* Manual testing checklist:
* - [ ] Changelog item appears in the nav dropdown
* - [ ] Clicking opens the panel with a loading spinner
* - [ ] Spinner resolves to a list of releases
* - [ ] Current version entry shows pink version text + "current" badge
* - [ ] Pre-release entries show a yellow "pre-release" badge
* - [ ] "View on Gitea" opens the release URL in the browser
* - [ ] Backdrop click and Escape key close the panel
* - [ ] Network error shows a red error message and a Retry button
* - [ ] Retry button re-fetches the changelog
*/
import { describe, it, expect } from "vitest";
function formatReleaseDate(isoString: string): string {
if (!isoString) return "Unknown date";
const date = new Date(isoString);
if (isNaN(date.getTime())) return "Unknown date";
return date.toLocaleDateString("en-GB", {
year: "numeric",
month: "long",
day: "numeric",
timeZone: "UTC",
});
}
// ---
describe("formatReleaseDate", () => {
it("formats a valid ISO 8601 timestamp to en-GB locale", () => {
const result = formatReleaseDate("2026-02-25T00:00:00Z");
// en-GB format: "25 February 2026"
expect(result).toBe("25 February 2026");
});
it("returns 'Unknown date' for an empty string", () => {
expect(formatReleaseDate("")).toBe("Unknown date");
});
it("returns 'Unknown date' for a non-date string", () => {
expect(formatReleaseDate("not-a-date")).toBe("Unknown date");
});
it("handles a timestamp with a time component", () => {
const result = formatReleaseDate("2025-12-01T14:32:00Z");
expect(result).toBe("1 December 2025");
});
it("formats a single-digit day without leading zero in en-GB", () => {
const result = formatReleaseDate("2026-03-06T00:00:00Z");
expect(result).toBe("6 March 2026");
});
});
+111 -19
View File
@@ -2,15 +2,43 @@
import { invoke } from "@tauri-apps/api/core";
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() {
try {
const result = await invoke<string>("get_claude_version");
version = result;
installedVersion = result;
} catch (error) {
console.error("Failed to get Claude CLI version:", error);
version = "Unknown";
installedVersion = "Unknown";
}
}
@@ -19,25 +47,60 @@
});
</script>
<div class="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"
>
<polyline points="4 17 10 11 4 5" />
<line x1="12" y1="19" x2="20" y2="19" />
</svg>
<span class="version-text">CLI {version}</span>
<div class="cli-versions">
<div class="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"
>
<polyline points="4 17 10 11 4 5" />
<line x1="12" y1="19" x2="20" y2="19" />
</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>
<style>
.cli-versions {
display: flex;
gap: 6px;
align-items: center;
}
.cli-version {
display: flex;
align-items: center;
@@ -57,6 +120,21 @@
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 {
flex-shrink: 0;
opacity: 0.7;
@@ -65,4 +143,18 @@
.version-text {
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>
+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 { claudeStore, isClaudeProcessing } from "$lib/stores/claude";
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 { setSkipNextGreeting } from "$lib/tauri";
import type { CharacterState, CharacterStateInfo } from "$lib/types/states";
@@ -14,6 +14,9 @@
let { onExpand }: Props = $props();
const configValues = configStore.config;
const hasBackgroundImage = $derived($configValues.background_image_path !== null);
let inputValue = $state("");
let isSubmitting = $state(false);
let isConnected = $state(false);
@@ -132,7 +135,7 @@
setSkipNextGreeting(true);
await invoke("interrupt_claude", { conversationId });
claudeStore.addLine("system", "Interrupted");
claudeStore.addLine("system", "Process interrupted via stop button");
characterState.setState("idle");
} catch (error) {
console.error("Failed to interrupt:", error);
@@ -150,7 +153,10 @@
}
</script>
<div class="compact-container {getStateGlow()}">
<div
class="compact-container {getStateGlow()}"
style={hasBackgroundImage ? "background: transparent !important;" : ""}
>
<!-- Character sprite (smaller) -->
<div class="compact-character">
<div class="sprite-wrapper {getAnimationClass()}">
+516 -5
View File
@@ -5,6 +5,8 @@
type Theme,
type CustomThemeColors,
applyFontSize,
applyCustomFont,
applyCustomUiFont,
applyCustomThemeColors,
MIN_FONT_SIZE,
MAX_FONT_SIZE,
@@ -12,6 +14,8 @@
} from "$lib/stores/config";
import { claudeStore } from "$lib/stores/claude";
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";
let config: HikariConfig = $state({
@@ -52,9 +56,43 @@
budget_warning_threshold: 0.8,
discord_rpc_enabled: 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,
task_loop_auto_commit: false,
task_loop_commit_prefix: "feat",
task_loop_include_summary: 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 isSaving = $state(false);
@@ -65,10 +103,17 @@
configStore.config.subscribe((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) => {
isOpen = open;
if (open && authStatus === null) {
void refreshAuthStatus();
}
});
configStore.saveError.subscribe((error) => {
@@ -83,8 +128,9 @@
{ value: "", label: "Default (from ~/.claude)" },
// Current generation (Claude 4.6)
{ 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)
{ 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-opus-4-5-20251101", label: "Claude Opus 4.5" },
// Previous generation (Claude 4.x)
@@ -110,6 +156,44 @@
"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() {
isSaving = true;
saveError = null;
@@ -181,6 +265,20 @@
await window.setAlwaysOnTop(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>
<!-- Backdrop -->
@@ -227,6 +325,109 @@
</div>
{/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 -->
<section class="mb-6">
<h3 class="text-sm font-medium text-[var(--accent-primary)] uppercase tracking-wider mb-3">
@@ -321,6 +522,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"
></textarea>
</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>
<!-- Greeting Section -->
@@ -482,7 +733,7 @@
<div class="flex flex-wrap gap-2" role="group" aria-label="Theme selection">
<button
onclick={() => handleThemeChange("dark")}
class="flex-1 min-w-[70px] px-3 py-2 rounded-lg border transition-colors {config.theme ===
class="flex-1 min-w-[70px] px-3 py-2 text-sm rounded-lg border transition-colors {config.theme ===
'dark'
? 'bg-[var(--accent-primary)] border-[var(--accent-primary)] text-white'
: 'bg-[var(--bg-primary)] border-[var(--border-color)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)]'}"
@@ -491,7 +742,7 @@
</button>
<button
onclick={() => handleThemeChange("light")}
class="flex-1 min-w-[70px] px-3 py-2 rounded-lg border transition-colors {config.theme ===
class="flex-1 min-w-[70px] px-3 py-2 text-sm rounded-lg border transition-colors {config.theme ===
'light'
? 'bg-[var(--accent-primary)] border-[var(--accent-primary)] text-white'
: 'bg-[var(--bg-primary)] border-[var(--border-color)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)]'}"
@@ -500,7 +751,7 @@
</button>
<button
onclick={() => handleThemeChange("high-contrast")}
class="flex-1 min-w-[70px] px-3 py-2 rounded-lg border transition-colors {config.theme ===
class="flex-1 min-w-[70px] px-3 py-2 text-sm rounded-lg border transition-colors {config.theme ===
'high-contrast'
? 'bg-[var(--accent-primary)] border-[var(--accent-primary)] text-white'
: 'bg-[var(--bg-primary)] border-[var(--border-color)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)]'}"
@@ -510,7 +761,7 @@
</button>
<button
onclick={() => handleThemeChange("custom")}
class="flex-1 min-w-[70px] px-3 py-2 rounded-lg border transition-colors {config.theme ===
class="flex-1 min-w-[70px] px-3 py-2 text-sm rounded-lg border transition-colors {config.theme ===
'custom'
? 'bg-[var(--accent-primary)] border-[var(--accent-primary)] text-white'
: 'bg-[var(--bg-primary)] border-[var(--border-color)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)]'}"
@@ -519,6 +770,96 @@
Custom
</button>
</div>
<!-- Preset Themes — Dark -->
<span class="block text-xs text-[var(--text-tertiary)] mt-3 mb-2">Dark Presets</span>
<div class="flex flex-wrap gap-2" role="group" aria-label="Dark preset theme selection">
<button
onclick={() => handleThemeChange("dracula")}
class="flex-1 min-w-[70px] px-3 py-2 text-sm rounded-lg border transition-colors {config.theme ===
'dracula'
? 'bg-[var(--accent-primary)] border-[var(--accent-primary)] text-white'
: 'bg-[var(--bg-primary)] border-[var(--border-color)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)]'}"
title="Dracula theme"
>
Dracula
</button>
<button
onclick={() => handleThemeChange("catppuccin")}
class="flex-1 min-w-[70px] px-3 py-2 text-sm rounded-lg border transition-colors {config.theme ===
'catppuccin'
? 'bg-[var(--accent-primary)] border-[var(--accent-primary)] text-white'
: 'bg-[var(--bg-primary)] border-[var(--border-color)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)]'}"
title="Catppuccin Mocha theme"
>
Catppuccin
</button>
<button
onclick={() => handleThemeChange("nord")}
class="flex-1 min-w-[70px] px-3 py-2 text-sm rounded-lg border transition-colors {config.theme ===
'nord'
? 'bg-[var(--accent-primary)] border-[var(--accent-primary)] text-white'
: 'bg-[var(--bg-primary)] border-[var(--border-color)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)]'}"
title="Nord theme"
>
Nord
</button>
<button
onclick={() => handleThemeChange("solarized")}
class="flex-1 min-w-[70px] px-3 py-2 text-sm rounded-lg border transition-colors {config.theme ===
'solarized'
? 'bg-[var(--accent-primary)] border-[var(--accent-primary)] text-white'
: 'bg-[var(--bg-primary)] border-[var(--border-color)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)]'}"
title="Solarized Dark theme"
>
Solarized
</button>
</div>
<!-- Preset Themes — Light -->
<span class="block text-xs text-[var(--text-tertiary)] mt-3 mb-2">Light Presets</span>
<div class="flex flex-wrap gap-2" role="group" aria-label="Light preset theme selection">
<button
onclick={() => handleThemeChange("solarized-light")}
class="flex-1 min-w-[70px] px-3 py-2 text-sm rounded-lg border transition-colors {config.theme ===
'solarized-light'
? 'bg-[var(--accent-primary)] border-[var(--accent-primary)] text-white'
: 'bg-[var(--bg-primary)] border-[var(--border-color)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)]'}"
title="Solarized Light theme"
>
Solarized
</button>
<button
onclick={() => handleThemeChange("catppuccin-latte")}
class="flex-1 min-w-[70px] px-3 py-2 text-sm rounded-lg border transition-colors {config.theme ===
'catppuccin-latte'
? 'bg-[var(--accent-primary)] border-[var(--accent-primary)] text-white'
: 'bg-[var(--bg-primary)] border-[var(--border-color)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)]'}"
title="Catppuccin Latte theme"
>
Latte
</button>
<button
onclick={() => handleThemeChange("gruvbox-light")}
class="flex-1 min-w-[70px] px-3 py-2 text-sm rounded-lg border transition-colors {config.theme ===
'gruvbox-light'
? 'bg-[var(--accent-primary)] border-[var(--accent-primary)] text-white'
: 'bg-[var(--bg-primary)] border-[var(--border-color)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)]'}"
title="Gruvbox Light theme"
>
Gruvbox
</button>
<button
onclick={() => handleThemeChange("rose-pine-dawn")}
class="flex-1 min-w-[70px] px-3 py-2 text-sm rounded-lg border transition-colors {config.theme ===
'rose-pine-dawn'
? 'bg-[var(--accent-primary)] border-[var(--accent-primary)] text-white'
: 'bg-[var(--bg-primary)] border-[var(--border-color)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)]'}"
title="Rosé Pine Dawn theme"
>
Rosé Pine
</button>
</div>
</div>
<!-- Custom Theme Editor -->
@@ -705,6 +1046,130 @@
</p>
</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 -->
<div class="mb-4">
<label class="flex items-center gap-3 cursor-pointer">
@@ -720,6 +1185,52 @@
expanded/collapsed to see reasoning details.
</p>
</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>
<!-- Window Section -->
+117 -13
View File
@@ -12,6 +12,25 @@
let editingTabId = $state<string | null>(null);
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
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
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) {
// Ctrl/Cmd + T: New tab
if ((event.ctrlKey || event.metaKey) && event.key === "t") {
@@ -165,21 +249,19 @@
// Ctrl/Cmd + Tab: Next tab
else if ((event.ctrlKey || event.metaKey) && event.key === "Tab" && !event.shiftKey) {
event.preventDefault();
const tabs = Array.from($conversations.keys());
const currentIndex = tabs.findIndex((id) => id === $activeConversationId);
const currentIndex = tabOrder.findIndex((id) => id === $activeConversationId);
if (currentIndex !== -1) {
const nextIndex = (currentIndex + 1) % tabs.length;
claudeStore.switchConversation(tabs[nextIndex]);
const nextIndex = (currentIndex + 1) % tabOrder.length;
claudeStore.switchConversation(tabOrder[nextIndex]);
}
}
// Ctrl/Cmd + Shift + Tab: Previous tab
else if ((event.ctrlKey || event.metaKey) && event.key === "Tab" && event.shiftKey) {
event.preventDefault();
const tabs = Array.from($conversations.keys());
const currentIndex = tabs.findIndex((id) => id === $activeConversationId);
const currentIndex = tabOrder.findIndex((id) => id === $activeConversationId);
if (currentIndex !== -1) {
const prevIndex = (currentIndex - 1 + tabs.length) % tabs.length;
claudeStore.switchConversation(tabs[prevIndex]);
const prevIndex = (currentIndex - 1 + tabOrder.length) % tabOrder.length;
claudeStore.switchConversation(tabOrder[prevIndex]);
}
}
}
@@ -190,15 +272,22 @@
</script>
<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)]"
>
{#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
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
? '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'}"
onclick={() => switchTab(id)}
: 'bg-[var(--bg-tertiary)] text-[var(--text-secondary)] hover:bg-[var(--bg-terminal)]/50'}
{dragOverId === id && draggedId !== id ? 'drag-over' : ''}
{draggedId === id ? 'dragging' : ''}"
onpointerdown={(e) => handlePointerDown(e, id)}
onclick={() => handleTabClick(id)}
onkeydown={(e) => handleTabKeydown(id, e)}
role="tab"
tabindex={0}
@@ -211,7 +300,7 @@
onblur={saveTabName}
onkeydown={handleKeydown}
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}
<div class="flex items-center gap-2">
@@ -296,5 +385,20 @@
.tab-item {
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>
+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);
});
});
+233
View File
@@ -0,0 +1,233 @@
<script lang="ts">
import hljs from "highlight.js";
import { parseDiff, detectLanguage } from "$lib/utils/diffParser";
export let diffContent: string;
export let filePath: string;
$: lines = diffContent ? parseDiff(diffContent) : [];
$: language = detectLanguage(filePath);
function highlightCode(code: string): string {
if (!code) return "";
try {
return hljs.highlight(code, { language }).value;
} catch {
return hljs.highlightAuto(code).value;
}
}
</script>
{#if lines.length === 0}
<div class="empty-diff">No changes</div>
{:else}
<table class="diff-table">
<tbody>
{#each lines as line, i (i)}
{#if line.type === "file-header"}
<tr class="line-file-header">
<td class="line-num" colspan="2"></td>
<td class="line-gutter"></td>
<td class="line-code">{line.content}</td>
</tr>
{:else if line.type === "hunk-header"}
<tr class="line-hunk-header">
<td class="line-num" colspan="2"></td>
<td class="line-gutter"></td>
<td class="line-code">{line.content}</td>
</tr>
{:else if line.type === "no-newline"}
<tr class="line-no-newline">
<td class="line-num" colspan="2"></td>
<td class="line-gutter"></td>
<td class="line-code">{line.content}</td>
</tr>
{:else}
<tr class="line-{line.type}">
<td class="line-num">{line.oldLineNumber ?? ""}</td>
<td class="line-num">{line.newLineNumber ?? ""}</td>
<td class="line-gutter">
{line.type === "added" ? "+" : line.type === "removed" ? "-" : ""}
</td>
<!-- eslint-disable-next-line svelte/no-at-html-tags -- Syntax highlighting requires @html; content is from trusted git diff output -->
<td class="line-code">{@html highlightCode(line.content)}</td>
</tr>
{/if}
{/each}
</tbody>
</table>
{/if}
<style>
.empty-diff {
padding: 2rem;
text-align: center;
color: var(--text-secondary);
font-family: var(--font-mono);
font-size: 0.85rem;
}
.diff-table {
border-collapse: collapse;
min-width: 100%;
width: max-content;
font-family: var(--font-mono);
font-size: 0.82rem;
line-height: 1.5;
}
.diff-table tr {
border: none;
}
.diff-table td {
padding: 0;
white-space: pre;
vertical-align: top;
border: none;
}
.line-num {
width: 3.5rem;
min-width: 3.5rem;
color: var(--text-secondary);
text-align: right;
user-select: none;
border-right: 1px solid var(--border-color);
opacity: 0.6;
font-size: 0.75rem;
padding: 0 0.4rem;
}
.line-gutter {
width: 1.5rem;
min-width: 1.5rem;
text-align: center;
user-select: none;
font-weight: bold;
padding: 0 0.25rem;
}
.line-code {
padding: 0 0.75rem;
}
/* Added lines */
.line-added {
background: rgba(34, 197, 94, 0.1);
}
.line-added .line-num {
background: rgba(34, 197, 94, 0.08);
color: rgba(34, 197, 94, 0.7);
}
.line-added .line-gutter {
color: #22c55e;
background: rgba(34, 197, 94, 0.18);
}
/* Removed lines */
.line-removed {
background: rgba(239, 68, 68, 0.1);
}
.line-removed .line-num {
background: rgba(239, 68, 68, 0.08);
color: rgba(239, 68, 68, 0.7);
}
.line-removed .line-gutter {
color: #ef4444;
background: rgba(239, 68, 68, 0.18);
}
/* Hunk header */
.line-hunk-header {
background: rgba(99, 102, 241, 0.12);
}
.line-hunk-header .line-code {
color: var(--text-secondary);
font-style: italic;
}
.line-hunk-header .line-gutter {
color: var(--text-secondary);
}
/* File header */
.line-file-header {
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
}
.line-file-header .line-code {
color: var(--text-secondary);
font-weight: 500;
padding: 0.15rem 0.75rem;
}
/* No newline */
.line-no-newline .line-code {
color: var(--text-secondary);
font-style: italic;
}
/* Syntax highlighting — scoped to this component's table */
.diff-table :global(.hljs-keyword),
.diff-table :global(.hljs-selector-tag),
.diff-table :global(.hljs-built_in),
.diff-table :global(.hljs-name) {
color: var(--hljs-keyword);
}
.diff-table :global(.hljs-string),
.diff-table :global(.hljs-attr),
.diff-table :global(.hljs-symbol),
.diff-table :global(.hljs-bullet) {
color: var(--hljs-string);
}
.diff-table :global(.hljs-number),
.diff-table :global(.hljs-literal) {
color: var(--hljs-number);
}
.diff-table :global(.hljs-comment),
.diff-table :global(.hljs-quote) {
color: var(--hljs-comment);
font-style: italic;
}
.diff-table :global(.hljs-function),
.diff-table :global(.hljs-title) {
color: var(--hljs-function);
}
.diff-table :global(.hljs-type),
.diff-table :global(.hljs-class) {
color: var(--hljs-type);
}
.diff-table :global(.hljs-variable),
.diff-table :global(.hljs-template-variable) {
color: var(--hljs-variable);
}
.diff-table :global(.hljs-meta) {
color: var(--hljs-meta);
}
.diff-table :global(.hljs-tag) {
color: var(--hljs-keyword);
}
.diff-table :global(.hljs-attribute) {
color: var(--hljs-function);
}
.diff-table :global(.hljs-params) {
color: var(--text-primary);
}
</style>
+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>
+4 -6
View File
@@ -2,6 +2,7 @@
import { invoke } from "@tauri-apps/api/core";
import { onMount, onDestroy } from "svelte";
import { claudeStore } from "$lib/stores/claude";
import DiffViewer from "$lib/components/DiffViewer.svelte";
interface GitFileChange {
path: string;
@@ -600,7 +601,9 @@
<h3>📄 {diffFile}</h3>
<button on:click={() => (showDiff = false)} title="Close"></button>
</div>
<pre class="diff-content">{diffContent || "(No changes)"}</pre>
<div class="diff-content">
<DiffViewer {diffContent} filePath={diffFile ?? ""} />
</div>
</div>
</div>
{/if}
@@ -1096,12 +1099,7 @@
.diff-content {
flex: 1;
overflow: auto;
padding: 1rem;
margin: 0;
font-family: var(--font-mono);
font-size: 0.85rem;
line-height: 1.4;
white-space: pre;
background: var(--bg-primary);
}
</style>
+132 -81
View File
@@ -1,54 +1,69 @@
<script lang="ts">
import { HELP_PAGES, nextPage, prevPage, isFirstPage, isLastPage } from "./docs/helpPages";
import DocsGettingStarted from "./docs/DocsGettingStarted.svelte";
import DocsKeyboardShortcuts from "./docs/DocsKeyboardShortcuts.svelte";
import DocsChatInput from "./docs/DocsChatInput.svelte";
import DocsFileEditor from "./docs/DocsFileEditor.svelte";
import DocsGitPanel from "./docs/DocsGitPanel.svelte";
import DocsThemeCustomisation from "./docs/DocsThemeCustomisation.svelte";
import DocsModelConfig from "./docs/DocsModelConfig.svelte";
import DocsSessionManagement from "./docs/DocsSessionManagement.svelte";
import DocsTaskLoop from "./docs/DocsTaskLoop.svelte";
import DocsPanelsTools from "./docs/DocsPanelsTools.svelte";
import DocsTroubleshooting from "./docs/DocsTroubleshooting.svelte";
import DocsChangelog from "./docs/DocsChangelog.svelte";
interface Props {
onClose: () => void;
}
const { onClose }: Props = $props();
const sections = [
{
title: "Getting Started",
items: [
"Enter your Claude API key in Settings (gear icon)",
"Set your working directory and click Connect",
"Start chatting with Hikari - your AI assistant!",
],
},
{
title: "Key Features",
items: [
"🗂️ File Management: Hikari can read, write, and edit files in your project",
"💻 Terminal Access: Execute commands and run scripts",
"🔍 Code Search: Find files and search through code",
"🌐 Web Access: Fetch information from the web",
"📊 MCP Servers: Connect to external tools via Model Context Protocol",
"📁 Multi-tab Support: Work on multiple conversations simultaneously",
],
},
{
title: "Available Commands",
items: [
"Type naturally - Hikari understands context!",
"Ask to read, create, or modify files",
"Request code explanations or debugging help",
"Have Hikari run tests or build commands",
"Search for specific functions or patterns",
],
},
{
title: "Tips & Tricks",
items: [
"💡 Use the stats panel to track your usage",
"🎯 Be specific about file paths and requirements",
"🔒 Grant tool permissions as needed for security",
"📌 Pin important conversations for quick access",
"🎨 Customize your theme and preferences in Settings",
"⌨️ Check the keyboard icon for available shortcuts",
],
},
const PAGE_COMPONENTS = [
DocsGettingStarted,
DocsKeyboardShortcuts,
DocsChatInput,
DocsFileEditor,
DocsGitPanel,
DocsThemeCustomisation,
DocsModelConfig,
DocsSessionManagement,
DocsTaskLoop,
DocsPanelsTools,
DocsTroubleshooting,
DocsChangelog,
];
let currentPageIndex = $state(0);
const currentComponent = $derived(PAGE_COMPONENTS[currentPageIndex]);
const atFirst = $derived(isFirstPage(currentPageIndex));
const atLast = $derived(isLastPage(currentPageIndex, HELP_PAGES.length));
function handleKeydown(event: KeyboardEvent): void {
const target = event.target as HTMLElement;
const isInputFocused = target.tagName === "INPUT" || target.tagName === "TEXTAREA";
if (event.key === "Escape") {
onClose();
return;
}
if (isInputFocused) return;
if (event.key === "ArrowRight" || event.key === "ArrowDown") {
event.preventDefault();
currentPageIndex = nextPage(currentPageIndex, HELP_PAGES.length);
} else if (event.key === "ArrowLeft" || event.key === "ArrowUp") {
event.preventDefault();
currentPageIndex = prevPage(currentPageIndex);
}
}
</script>
<svelte:window onkeydown={handleKeydown} />
<!-- Backdrop -->
<div
class="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4"
onclick={onClose}
@@ -56,17 +71,21 @@
tabindex="0"
onkeydown={(e) => e.key === "Escape" && onClose()}
>
<!-- Dialog -->
<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"
class="bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg shadow-xl w-full max-w-3xl h-[80vh] flex flex-col"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
role="dialog"
aria-labelledby="help-title"
tabindex="-1"
>
<div class="flex items-center justify-between p-6 pb-4 border-b border-[var(--border-color)]">
<h2 id="help-title" class="text-xl font-semibold text-[var(--text-primary)]">
How to Use Hikari Desktop
<!-- Header -->
<div
class="flex items-center justify-between px-6 py-4 border-b border-[var(--border-color)] shrink-0"
>
<h2 id="help-title" class="text-lg font-semibold text-[var(--text-primary)]">
Help & Documentation
</h2>
<button
onclick={onClose}
@@ -84,32 +103,77 @@
</button>
</div>
<div class="overflow-y-auto flex-1 p-6 space-y-6">
{#each sections as section (section.title)}
<div>
<h3 class="font-medium text-[var(--text-primary)] mb-3">{section.title}</h3>
<ul class="space-y-2 text-sm text-[var(--text-secondary)]">
{#each section.items as item (item)}
<li class="flex items-start">
<span class="text-[var(--accent-primary)] mr-2 mt-0.5"></span>
<span>{item}</span>
</li>
{/each}
</ul>
</div>
{/each}
<!-- Body: sidebar + content -->
<div class="flex flex-1 overflow-hidden">
<!-- Sidebar TOC -->
<nav
class="w-44 shrink-0 border-r border-[var(--border-color)] overflow-y-auto py-2"
aria-label="Documentation pages"
>
{#each HELP_PAGES as page, i (page.id)}
<button
onclick={() => (currentPageIndex = i)}
class="w-full text-left px-4 py-2 text-sm transition-colors {i === currentPageIndex
? 'bg-[var(--bg-secondary)] text-[var(--accent-primary)] font-medium'
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-secondary)]'}"
aria-current={i === currentPageIndex ? "page" : undefined}
>
{page.title}
</button>
{/each}
</nav>
<div class="pt-4 border-t border-[var(--border-color)]">
<p class="text-sm text-[var(--text-tertiary)]">
<strong>Need more help?</strong> Join our Discord community for support and updates!
</p>
<!-- Page content -->
<div class="flex-1 overflow-y-auto p-6">
<svelte:component this={currentComponent} />
</div>
</div>
<!-- Footer: prev / page indicator / next -->
<div
class="flex items-center justify-between px-6 py-3 border-t border-[var(--border-color)] shrink-0"
>
<button
onclick={() => (currentPageIndex = prevPage(currentPageIndex))}
disabled={atFirst}
class="flex items-center gap-1 px-3 py-1.5 text-sm rounded transition-colors
{atFirst
? 'text-[var(--text-tertiary)] cursor-not-allowed'
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-secondary)]'}"
>
<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="M15 19l-7-7 7-7"
/>
</svg>
Previous
</button>
<span class="text-xs text-[var(--text-tertiary)]">
Page {currentPageIndex + 1} of {HELP_PAGES.length}
</span>
<button
onclick={() => (currentPageIndex = nextPage(currentPageIndex, HELP_PAGES.length))}
disabled={atLast}
class="flex items-center gap-1 px-3 py-1.5 text-sm rounded transition-colors
{atLast
? 'text-[var(--text-tertiary)] cursor-not-allowed'
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-secondary)]'}"
>
Next
<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="M9 5l7 7-7 7" />
</svg>
</button>
</div>
</div>
</div>
<style>
/* Ensure the panel appears above other content */
[role="dialog"] {
animation: slideIn 0.2s ease-out;
}
@@ -125,26 +189,13 @@
}
}
/* Custom scrollbar styling */
.overflow-y-auto {
nav {
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);
.overflow-y-auto {
scrollbar-width: thin;
scrollbar-color: var(--border-color) transparent;
}
</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 },
]);
});
});
});
+115 -8
View File
@@ -34,7 +34,10 @@
import SnippetLibraryPanel from "$lib/components/SnippetLibraryPanel.svelte";
import QuickActionsPanel from "$lib/components/QuickActionsPanel.svelte";
import ClipboardHistoryPanel from "$lib/components/ClipboardHistoryPanel.svelte";
import DraftPanel from "$lib/components/DraftPanel.svelte";
import TextInputContextMenu from "$lib/components/TextInputContextMenu.svelte";
import { draftsStore } from "$lib/stores/drafts";
import { injectTextStore } from "$lib/stores/projectContext";
import type { Attachment } from "$lib/types/messages";
const INPUT_HISTORY_KEY = "hikari-input-history";
@@ -52,6 +55,7 @@
let showSnippetLibrary = $state(false);
let showQuickActions = $state(false);
let showClipboardHistory = $state(false);
let showDraftPanel = $state(false);
let streamerModeActive = $state(false);
// Cost estimation for pre-submission display
@@ -164,6 +168,33 @@
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 = "";
}
});
// Project context injection — set by StatusBar via injectTextStore signal.
injectTextStore.subscribe((text) => {
if (text === null) return;
inputValue = inputValue.trim() ? text + "\n\n" + inputValue : text;
userHasTyped = true;
injectTextStore.set(null);
});
function clearInput() {
inputValue = "";
const activeId = get(claudeStore.activeConversationId);
if (activeId) {
claudeStore.setDraftText(activeId, "");
}
}
function handleInputChange() {
// If input is empty, allow history navigation again
// Otherwise, mark that user has manually typed
@@ -176,6 +207,12 @@
historyIndex = -1;
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)) {
matchingCommands = getMatchingCommands(inputValue);
showCommandMenu = matchingCommands.length > 0;
@@ -195,7 +232,7 @@
async function executeSlashCommand(): Promise<boolean> {
const { command, args } = parseSlashCommand(inputValue);
if (command) {
inputValue = "";
clearInput();
showCommandMenu = false;
matchingCommands = [];
await command.execute(args);
@@ -211,7 +248,7 @@
const hasAttachments = attachments.length > 0;
// Need either a message or attachments to submit
if ((!message && !hasAttachments) || isSubmitting) return;
if ((!message && !hasAttachments) || isSubmitting || isProcessing) return;
// Check for slash commands first (these work even when disconnected)
if (message && isSlashCommand(message)) {
@@ -228,7 +265,7 @@
"error",
`Unknown command: ${message.split(" ")[0]}. Type /help for available commands.`
);
inputValue = "";
clearInput();
return;
}
@@ -244,7 +281,7 @@
userHasTyped = false;
isSubmitting = true;
inputValue = "";
clearInput();
// Capture attachments before clearing
const currentAttachments = [...attachments];
@@ -302,6 +339,7 @@ User: ${formattedMessage}`;
conversationId,
message: messageToSend,
});
claudeStore.setProcessing(true);
} catch (error) {
console.error("Failed to send prompt:", error);
claudeStore.addLine("error", `Failed to send: ${error}`);
@@ -326,7 +364,7 @@ User: ${formattedMessage}`;
throw new Error("No active conversation");
}
await invoke("interrupt_claude", { conversationId });
claudeStore.addLine("system", "Process interrupted - reconnecting...");
claudeStore.addLine("system", "Process interrupted via stop button — reconnecting...");
characterState.setState("idle");
// Show connecting status while we reconnect
@@ -362,6 +400,8 @@ User: ${formattedMessage}`;
custom_instructions: config.custom_instructions || null,
mcp_servers_json: config.mcp_servers_json || null,
allowed_tools: allAllowedTools,
use_worktree: config.use_worktree ?? false,
disable_1m_context: config.disable_1m_context ?? false,
},
});
@@ -701,6 +741,22 @@ User: ${formattedMessage}`;
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 {
// Insert clipboard content at cursor position or append to input
if (inputValue.trim()) {
@@ -713,7 +769,7 @@ User: ${formattedMessage}`;
async function handleQuickAction(prompt: string): Promise<void> {
// Quick actions send the prompt directly
if (!isConnected || isSubmitting) return;
if (!isConnected || isSubmitting || isProcessing) return;
// Add to history
addToHistory(prompt);
@@ -738,6 +794,7 @@ User: ${formattedMessage}`;
conversationId,
message: prompt,
});
claudeStore.setProcessing(true);
} catch (error) {
console.error("Failed to send quick action:", error);
claudeStore.addLine("error", `Failed to send: ${error}`);
@@ -917,6 +974,29 @@ User: ${formattedMessage}`;
<span>Clipboard</span>
</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 />
<SystemClock />
</div>
@@ -940,9 +1020,9 @@ User: ${formattedMessage}`;
placeholder={isConnected
? "Ask Hikari anything... (type / for commands)"
: "Connect to Claude first..."}
disabled={isSubmitting}
disabled={isSubmitting || isProcessing}
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)]
rounded-lg text-[var(--text-primary)] placeholder-gray-500 resize-none
input-trans-focus disabled:opacity-50 disabled:cursor-not-allowed"
@@ -957,6 +1037,29 @@ User: ${formattedMessage}`;
</div>
{/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">
<svg
width="20"
@@ -1022,6 +1125,10 @@ User: ${formattedMessage}`;
/>
{/if}
{#if showDraftPanel}
<DraftPanel onClose={() => (showDraftPanel = false)} onInsert={handleDraftInsert} />
{/if}
{#if contextMenuShow && textareaElement}
<TextInputContextMenu
x={contextMenuX}
+24 -9
View File
@@ -35,7 +35,12 @@
};
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({
@@ -103,15 +108,19 @@
return processed;
}
function renderMarkdown(text: string): string {
// Two-stage reactive rendering:
// Stage 1 — only re-runs when `content` changes (expensive: marked + hljs + spoilers)
let parsedHtml = $derived.by(() => {
try {
const html = marked.parse(text) as string;
const withSpoilers = processSpoilers(html);
return highlightSearchMatches(withSpoilers, searchQuery);
const html = marked.parse(content) as string;
return processSpoilers(html);
} catch {
return text;
return content;
}
}
});
// Stage 2 — re-runs when search changes; skips re-parsing markdown entirely
let renderedHtml = $derived(highlightSearchMatches(parsedHtml, searchQuery));
function handleSpoilerClick(event: Event) {
const target = event.target as HTMLElement;
@@ -186,7 +195,7 @@
role="presentation"
>
<!-- eslint-disable-next-line svelte/no-at-html-tags -- Markdown rendering requires @html; content is from Claude API -->
{@html renderMarkdown(content)}
{@html renderedHtml}
</div>
<style>
@@ -276,10 +285,16 @@
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) {
margin: 0.5em 0;
padding-left: 1.5em;
list-style-type: decimal;
}
.markdown-content :global(li) {
+163
View File
@@ -0,0 +1,163 @@
/**
* Markdown Component Tests
*
* Tests the pure helper functions extracted from the Markdown component:
* - processSpoilers: wraps ||text|| syntax in spoiler spans, leaving code blocks untouched
* - highlightSearchMatches: injects <mark> tags for search terms, skipping code blocks
*
* Manual testing checklist:
* - [ ] Code blocks render with syntax highlighting and a copy button
* - [ ] ||spoiler text|| renders as a hidden span revealed on click
* - [ ] Search query highlights matching text in non-code content
* - [ ] Links open in the system browser via the Tauri opener
*/
import { describe, it, expect } from "vitest";
// Mirror functions from Markdown.svelte for isolated testing
function processSpoilers(html: string): string {
const codeBlockPlaceholders: string[] = [];
let processed = html.replace(/<(pre|code)[^>]*>[\s\S]*?<\/\1>/gi, (match) => {
codeBlockPlaceholders.push(match);
return `__CODE_PLACEHOLDER_${codeBlockPlaceholders.length - 1}__`;
});
processed = processed.replace(
/\|\|(.+?)\|\|/g,
'<span class="spoiler" role="button" tabindex="0">$1</span>'
);
processed = processed.replace(/__CODE_PLACEHOLDER_(\d+)__/g, (_, index) => {
return codeBlockPlaceholders[parseInt(index)];
});
return processed;
}
function highlightSearchMatches(html: string, query: string): string {
if (!query) return html;
const codeBlockPlaceholders: string[] = [];
const tagPlaceholders: string[] = [];
let processed = html.replace(/<(pre|code)[^>]*>[\s\S]*?<\/\1>/gi, (match) => {
codeBlockPlaceholders.push(match);
return `__CODE_SEARCH_PLACEHOLDER_${codeBlockPlaceholders.length - 1}__`;
});
processed = processed.replace(/<[^>]+>/g, (match) => {
tagPlaceholders.push(match);
return `__TAG_PLACEHOLDER_${tagPlaceholders.length - 1}__`;
});
const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const regex = new RegExp(`(${escapedQuery})`, "gi");
processed = processed.replace(regex, '<mark class="search-highlight">$1</mark>');
processed = processed.replace(/__TAG_PLACEHOLDER_(\d+)__/g, (_, index) => {
return tagPlaceholders[parseInt(index)];
});
processed = processed.replace(/__CODE_SEARCH_PLACEHOLDER_(\d+)__/g, (_, index) => {
return codeBlockPlaceholders[parseInt(index)];
});
return processed;
}
// ---
describe("processSpoilers", () => {
it("wraps ||text|| in a spoiler span", () => {
const result = processSpoilers("<p>||secret||</p>");
expect(result).toContain('<span class="spoiler"');
expect(result).toContain("secret");
});
it("adds role=button and tabindex to spoiler spans", () => {
const result = processSpoilers("<p>||hidden||</p>");
expect(result).toContain('role="button"');
expect(result).toContain('tabindex="0"');
});
it("leaves content without spoiler markers unchanged", () => {
const html = "<p>Normal text here</p>";
expect(processSpoilers(html)).toBe(html);
});
it("handles multiple spoilers in the same string", () => {
const result = processSpoilers("<p>||a|| and ||b||</p>");
const matches = result.match(/class="spoiler"/g);
expect(matches).toHaveLength(2);
});
it("does not apply spoiler syntax inside code blocks", () => {
const html = "<pre><code>||not a spoiler||</code></pre>";
const result = processSpoilers(html);
expect(result).not.toContain('class="spoiler"');
expect(result).toContain("||not a spoiler||");
});
it("does not apply spoiler syntax inside inline code", () => {
const html = "<p>Example: <code>||inline||</code></p>";
const result = processSpoilers(html);
expect(result).not.toContain('class="spoiler"');
});
it("handles spoilers adjacent to code blocks correctly", () => {
const html = "<pre><code>code</code></pre><p>||revealed||</p>";
const result = processSpoilers(html);
expect(result).toContain('<span class="spoiler"');
expect(result).toContain("<pre><code>code</code></pre>");
});
});
describe("highlightSearchMatches", () => {
it("returns unchanged html when query is empty string", () => {
const html = "<p>hello world</p>";
expect(highlightSearchMatches(html, "")).toBe(html);
});
it("wraps matched text in a mark element", () => {
const result = highlightSearchMatches("<p>hello world</p>", "hello");
expect(result).toContain('<mark class="search-highlight">hello</mark>');
});
it("is case-insensitive", () => {
const result = highlightSearchMatches("<p>Hello World</p>", "hello");
expect(result).toContain('<mark class="search-highlight">Hello</mark>');
});
it("highlights all occurrences", () => {
const result = highlightSearchMatches("<p>cat and cat</p>", "cat");
const matches = result.match(/<mark class="search-highlight">/g);
expect(matches).toHaveLength(2);
});
it("does not highlight inside code blocks", () => {
const html = "<pre><code>hello inside code</code></pre>";
const result = highlightSearchMatches(html, "hello");
expect(result).not.toContain('<mark class="search-highlight">');
expect(result).toContain("hello inside code");
});
it("does not corrupt HTML tags", () => {
const result = highlightSearchMatches('<p class="foo">hello</p>', "hello");
expect(result).toContain('<p class="foo">');
expect(result).toContain('<mark class="search-highlight">hello</mark>');
});
it("escapes regex special characters in the query", () => {
const result = highlightSearchMatches("<p>price: $1.00</p>", "$1");
expect(result).toContain('<mark class="search-highlight">$1</mark>');
});
it("highlights text outside code blocks whilst leaving code intact", () => {
const html = "<pre><code>match here</code></pre><p>match here too</p>";
const result = highlightSearchMatches(html, "match");
expect(result).toContain("<pre><code>match here</code></pre>");
expect(result).toContain('<mark class="search-highlight">match</mark>');
});
});
+27 -44
View File
@@ -190,10 +190,13 @@
<h3 class="text-sm font-medium text-[var(--text-primary)] mb-3">Add MCP Server</h3>
<div class="space-y-3">
<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
>
<input
id="mcp-new-name"
type="text"
bind:value={newServerName}
placeholder="my-server"
@@ -201,10 +204,13 @@
/>
</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
>
<select
id="mcp-new-transport"
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)]"
>
@@ -214,10 +220,14 @@
</select>
</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"}
</label>
<input
id="mcp-new-url"
type="text"
bind:value={newServerUrl}
placeholder={newServerTransport === "stdio"
@@ -266,6 +276,7 @@
{:else}
<div class="space-y-2">
{#each servers as server (server.name)}
{@const TransportIcon = getTransportIcon(server.transport)}
<button
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"
@@ -274,10 +285,7 @@
<div class="flex items-start justify-between">
<div class="flex-1">
<h4 class="font-medium text-[var(--text-primary)] flex items-center gap-2">
<svelte:component
this={getTransportIcon(server.transport)}
class="w-4 h-4 {getTransportColor(server.transport)}"
/>
<TransportIcon class="w-4 h-4 {getTransportColor(server.transport)}" />
{server.name}
{#if server.status}
{#if server.status.includes("Connected")}
@@ -323,25 +331,19 @@
<RefreshCw class="w-6 h-6 animate-spin text-[var(--text-secondary)]" />
</div>
{:else}
{@const TransportIcon = getTransportIcon(selectedServer.transport)}
<div class="space-y-4">
<!-- Name -->
<div>
<label class="text-xs text-[var(--text-secondary)] uppercase tracking-wider"
>Name</label
>
<p class="text-xs text-[var(--text-secondary)] uppercase tracking-wider">Name</p>
<p class="text-sm text-[var(--text-primary)] mt-1">{selectedServer.name}</p>
</div>
<!-- Transport -->
<div>
<label class="text-xs text-[var(--text-secondary)] uppercase tracking-wider"
>Transport</label
>
<p class="text-xs text-[var(--text-secondary)] uppercase tracking-wider">Transport</p>
<p class="text-sm text-[var(--text-primary)] mt-1 flex items-center gap-2">
<svelte:component
this={getTransportIcon(selectedServer.transport)}
class="w-4 h-4 {getTransportColor(selectedServer.transport)}"
/>
<TransportIcon class="w-4 h-4 {getTransportColor(selectedServer.transport)}" />
{selectedServer.transport.toUpperCase()}
</p>
</div>
@@ -349,9 +351,7 @@
<!-- URL or Command -->
{#if selectedServer.url}
<div>
<label class="text-xs text-[var(--text-secondary)] uppercase tracking-wider"
>URL</label
>
<p class="text-xs text-[var(--text-secondary)] uppercase tracking-wider">URL</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)]"
>
@@ -362,9 +362,7 @@
{#if selectedServer.command}
<div>
<label class="text-xs text-[var(--text-secondary)] uppercase tracking-wider"
>Command</label
>
<p class="text-xs text-[var(--text-secondary)] uppercase tracking-wider">Command</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)]"
>
@@ -376,9 +374,9 @@
<!-- Environment Variables -->
{#if selectedServer.env}
<div>
<label class="text-xs text-[var(--text-secondary)] uppercase tracking-wider"
>Environment</label
>
<p class="text-xs text-[var(--text-secondary)] uppercase tracking-wider">
Environment
</p>
<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(
selectedServer.env,
@@ -391,9 +389,9 @@
<!-- Full Server Details -->
{#if serverDetails}
<div>
<label class="text-xs text-[var(--text-secondary)] uppercase tracking-wider"
>Full Details</label
>
<p class="text-xs text-[var(--text-secondary)] uppercase tracking-wider">
Full Details
</p>
<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>
@@ -416,18 +414,3 @@
{/if}
</div>
</div>
<style>
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.animate-spin {
animation: spin 1s linear infinite;
}
</style>
+32 -10
View File
@@ -3,17 +3,22 @@
import { invoke } from "@tauri-apps/api/core";
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 fileContent: string = $state("");
let isLoading = $state(false);
let error: string | null = $state(null);
let isPanelOpen = $state(false);
interface MemoryFilesResponse {
files: string[];
}
async function loadMemoryFiles() {
isLoading = true;
error = null;
@@ -49,6 +54,10 @@
return path.split("/").pop() || path;
}
function getDisplayName(file: MemoryFileInfo): string {
return file.heading ?? getFileName(file.path);
}
function togglePanel() {
isPanelOpen = !isPanelOpen;
if (isPanelOpen && memoryFiles.length === 0) {
@@ -151,11 +160,12 @@
{:else}
<div class="panel-layout">
<div class="file-list">
{#each memoryFiles as file (file)}
{#each memoryFiles as file (file.path)}
<button
class="file-item"
class:active={selectedFile === file}
onclick={() => loadFileContent(file)}
class:active={selectedFile === file.path}
onclick={() => loadFileContent(file.path)}
title={getFileName(file.path)}
>
<svg
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"
/>
</svg>
<span class="file-name">{getFileName(file)}</span>
<span class="file-name">{getDisplayName(file)}</span>
</button>
{/each}
</div>
@@ -179,7 +189,12 @@
<div class="file-viewer">
{#if selectedFile && fileContent}
<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 class="viewer-content">
<Markdown content={fileContent} />
@@ -438,6 +453,13 @@
color: var(--text-primary);
}
.viewer-filename {
margin: 0.25rem 0 0;
font-size: 0.75rem;
color: var(--text-tertiary);
font-family: monospace;
}
.viewer-content {
flex: 1;
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");
});
});
+584
View File
@@ -0,0 +1,584 @@
<script lang="ts">
import { Menu, ScrollText } from "lucide-svelte";
import { openUrl } from "@tauri-apps/plugin-opener";
import { achievementProgress } from "$lib/stores/achievements";
import { runningAgentCount } from "$lib/stores/agents";
import { editorStore } from "$lib/stores/editor";
import { configStore } from "$lib/stores/config";
import { debugConsoleStore } from "$lib/stores/debugConsole";
import type { ConnectionStatus } from "$lib/types/messages";
import StatsDisplay from "./StatsDisplay.svelte";
import AboutPanel from "./AboutPanel.svelte";
import HelpPanel from "./HelpPanel.svelte";
import KeyboardShortcutsModal from "./KeyboardShortcutsModal.svelte";
import SessionHistoryPanel from "./SessionHistoryPanel.svelte";
import TodoPanel from "./TodoPanel.svelte";
import GitPanel from "./GitPanel.svelte";
import ProfilePanel from "./ProfilePanel.svelte";
import AgentMonitorPanel from "./AgentMonitorPanel.svelte";
import CastPanel from "./CastPanel.svelte";
import PluginManagementPanel from "./PluginManagementPanel.svelte";
import McpManagementPanel from "./McpManagementPanel.svelte";
import ProjectContextPanel from "./ProjectContextPanel.svelte";
import PrdPanel from "./PrdPanel.svelte";
import ChangelogPanel from "./ChangelogPanel.svelte";
import TaskLoopPanel from "./TaskLoopPanel.svelte";
import WorkflowPanel from "./WorkflowPanel.svelte";
import { injectTextStore } from "$lib/stores/projectContext";
const DISCORD_URL = "https://chat.nhcarrigan.com";
const DONATE_URL = "https://donate.nhcarrigan.com";
interface Props {
connectionStatus: ConnectionStatus;
workingDirectory: string;
selectedDirectory: string;
isSummarising: boolean;
onToggleCompact: () => void;
onToggleAchievements: () => void;
onCompactConversation: () => Promise<void>;
onStartFreshWithContext: () => Promise<void>;
}
const {
connectionStatus,
workingDirectory,
selectedDirectory,
isSummarising,
onToggleCompact,
onToggleAchievements,
onCompactConversation,
onStartFreshWithContext,
}: Props = $props();
let showMenu = $state(false);
let showStats = $state(false);
let showAbout = $state(false);
let showHelp = $state(false);
let showKeyboardShortcuts = $state(false);
let showSessionHistory = $state(false);
let showTodoPanel = $state(false);
let showGitPanel = $state(false);
let showProfile = $state(false);
let showAgentMonitor = $state(false);
let showCastPanel = $state(false);
let showPluginPanel = $state(false);
let showMcpPanel = $state(false);
let showProjectContext = $state(false);
let showPrdPanel = $state(false);
let showChangelog = $state(false);
let showTaskLoop = $state(false);
let showWorkflowPanel = $state(false);
const progress = $derived($achievementProgress);
const activeAgentCount = $derived($runningAgentCount);
let editorVisible = $state(false);
editorStore.isEditorVisible.subscribe((value) => {
editorVisible = value;
});
export function isFileEditorDisabled(status: ConnectionStatus): boolean {
return status !== "connected";
}
function menuAction(action: () => void): () => void {
return () => {
action();
showMenu = false;
};
}
function handleInjectContext(content: string): void {
injectTextStore.set(content);
}
function handleGlobalHelpShortcut(event: KeyboardEvent): void {
const target = event.target as HTMLElement;
const isInputFocused = target.tagName === "INPUT" || target.tagName === "TEXTAREA";
if (isInputFocused) return;
if (event.key === "?") {
event.preventDefault();
showHelp = !showHelp;
}
}
</script>
<svelte:window onkeydown={handleGlobalHelpShortcut} />
<div class="relative">
<button
onclick={() => (showMenu = !showMenu)}
class="p-1 shrink-0 {showMenu ? 'text-[var(--trans-pink)]' : 'text-gray-500 icon-trans-hover'}"
title="Menu"
>
<Menu class="w-5 h-5" />
</button>
</div>
{#if showMenu}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="fixed inset-0 z-40" onclick={() => (showMenu = false)}></div>
<div
class="fixed top-12 right-4 z-50 max-h-96 overflow-y-auto min-w-[13rem] bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-md shadow-lg py-1"
>
<!-- Profile -->
<button onclick={menuAction(() => (showProfile = true))} class="nav-item">
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
/>
</svg>
<span>Profile</span>
</button>
<!-- Compact Mode -->
<button onclick={menuAction(onToggleCompact)} class="nav-item">
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4"
/>
</svg>
<span>Compact Mode</span>
</button>
<!-- Achievements -->
<button onclick={menuAction(onToggleAchievements)} class="nav-item">
<span class="text-base w-5 h-5 flex items-center justify-center shrink-0">🏆</span>
<span>Achievements</span>
{#if progress.unlocked > 0}
<span
class="ml-auto bg-[var(--accent-primary)] text-white text-xs rounded-full w-4 h-4 flex items-center justify-center text-[10px] shrink-0"
>
{progress.unlocked}
</span>
{/if}
</button>
<!-- Session History -->
<button onclick={menuAction(() => (showSessionHistory = true))} class="nav-item">
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span>Session History</span>
</button>
<!-- To-Do List -->
<button onclick={menuAction(() => (showTodoPanel = true))} class="nav-item">
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"
/>
</svg>
<span>To-Do List</span>
</button>
<!-- Git Panel -->
<button onclick={menuAction(() => (showGitPanel = true))} class="nav-item">
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"
/>
</svg>
<span>Git Panel</span>
</button>
<!-- Plugins -->
<button onclick={menuAction(() => (showPluginPanel = true))} class="nav-item">
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"
/>
</svg>
<span>Plugins</span>
</button>
<!-- MCP Servers -->
<button onclick={menuAction(() => (showMcpPanel = true))} class="nav-item">
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01"
/>
</svg>
<span>MCP Servers</span>
</button>
<!-- Project Context -->
<button onclick={menuAction(() => (showProjectContext = true))} class="nav-item">
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
/>
</svg>
<span>Project Context</span>
</button>
<!-- PRD Creator -->
<button onclick={menuAction(() => (showPrdPanel = true))} class="nav-item">
<ScrollText class="w-5 h-5 shrink-0" />
<span>PRD Creator</span>
</button>
<!-- Task Loop -->
<button onclick={menuAction(() => (showTaskLoop = true))} class="nav-item">
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
<span>Task Loop</span>
</button>
<!-- Guided Workflow -->
<button onclick={menuAction(() => (showWorkflowPanel = true))} class="nav-item">
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12h18" />
</svg>
<span>Guided Workflow</span>
</button>
<!-- File Editor -->
<button
onclick={menuAction(() => editorStore.toggleEditor())}
disabled={isFileEditorDisabled(connectionStatus)}
class="nav-item {editorVisible
? 'text-[var(--trans-pink)]'
: ''} disabled:opacity-40 disabled:cursor-not-allowed"
title={isFileEditorDisabled(connectionStatus)
? "Connect to enable file editor"
: "File Editor (Ctrl+E)"}
>
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
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>File Editor</span>
</button>
<!-- Meet the Team -->
<button onclick={menuAction(() => (showCastPanel = true))} class="nav-item">
<svg class="w-5 h-5 shrink-0" 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>
<span>Meet the Team</span>
</button>
<!-- Agent Monitor -->
<button
onclick={menuAction(() => (showAgentMonitor = !showAgentMonitor))}
class="nav-item {showAgentMonitor ? 'text-[var(--trans-pink)]' : ''}"
>
<div class="relative shrink-0 w-5 h-5">
<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="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"
/>
</svg>
{#if activeAgentCount > 0}
<span
class="absolute -top-1 -right-1 bg-blue-500 text-white rounded-full w-3.5 h-3.5 flex items-center justify-center text-[9px] animate-pulse"
>
{activeAgentCount}
</span>
{/if}
</div>
<span>Agent Monitor</span>
</button>
<!-- Usage Stats -->
<button
onclick={menuAction(() => (showStats = !showStats))}
class="nav-item {showStats ? 'text-[var(--trans-pink)]' : ''}"
>
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zM13 19v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2h2a2 2 0 002-2zM21 19V8a2 2 0 00-2-2h-2a2 2 0 00-2 2v11a2 2 0 002 2h2a2 2 0 002-2z"
/>
</svg>
<span>Usage Stats</span>
</button>
<!-- Debug Console -->
<button onclick={menuAction(() => debugConsoleStore.toggle())} class="nav-item">
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
<span>Debug Console</span>
</button>
<!-- Settings -->
<button onclick={menuAction(() => configStore.openSidebar())} class="nav-item">
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
<span>Settings</span>
</button>
<!-- Support Us -->
<button onclick={menuAction(() => openUrl(DONATE_URL))} class="nav-item">
<svg class="w-5 h-5 shrink-0" fill="currentColor" viewBox="0 0 24 24">
<path
d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"
/>
</svg>
<span>Support Us</span>
</button>
<!-- Changelog -->
<button onclick={menuAction(() => (showChangelog = true))} class="nav-item">
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2M9 12h6M9 16h4"
/>
</svg>
<span>Changelog</span>
</button>
<!-- About -->
<button onclick={menuAction(() => (showAbout = true))} class="nav-item">
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span>About</span>
</button>
<!-- Keyboard Shortcuts -->
<button onclick={menuAction(() => (showKeyboardShortcuts = true))} class="nav-item">
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 3C10.22 3 8.47 3.23 6.86 3.68A2 2 0 005 5.57V18.43a2 2 0 001.86 1.89C8.47 20.77 10.22 21 12 21s3.53-.23 5.14-.68A2 2 0 0019 18.43V5.57a2 2 0 00-1.86-1.89C15.53 3.23 13.78 3 12 3z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 7h.01M12 7h.01M16 7h.01M8 11h.01M12 11h.01M16 11h.01M8 15h8"
/>
</svg>
<span>Keyboard Shortcuts</span>
</button>
<!-- Help -->
<button onclick={menuAction(() => (showHelp = true))} class="nav-item">
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span>Help</span>
</button>
<!-- Discord -->
<button onclick={menuAction(() => openUrl(DISCORD_URL))} class="nav-item">
<svg class="w-5 h-5 shrink-0" viewBox="0 0 24 24" fill="currentColor">
<path
d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"
/>
</svg>
<span>Discord</span>
</button>
</div>
{/if}
{#if showStats}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="fixed inset-0 z-40" onclick={() => (showStats = false)}></div>
<div class="fixed top-14 right-4 z-50 max-h-[calc(100vh-4rem)] overflow-y-auto">
<StatsDisplay
onRequestSummary={onCompactConversation}
{onStartFreshWithContext}
{isSummarising}
/>
</div>
{/if}
{#if showAbout}
<AboutPanel onClose={() => (showAbout = false)} />
{/if}
{#if showHelp}
<HelpPanel onClose={() => (showHelp = false)} />
{/if}
{#if showKeyboardShortcuts}
<KeyboardShortcutsModal onClose={() => (showKeyboardShortcuts = false)} />
{/if}
{#if showSessionHistory}
<SessionHistoryPanel onClose={() => (showSessionHistory = false)} />
{/if}
{#if showTodoPanel}
<TodoPanel onClose={() => (showTodoPanel = false)} />
{/if}
{#if showGitPanel}
<GitPanel isOpen={showGitPanel} onClose={() => (showGitPanel = false)} />
{/if}
{#if showProfile}
<ProfilePanel onClose={() => (showProfile = false)} />
{/if}
{#if showAgentMonitor}
<AgentMonitorPanel isOpen={showAgentMonitor} onClose={() => (showAgentMonitor = false)} />
{/if}
{#if showCastPanel}
<CastPanel onClose={() => (showCastPanel = false)} />
{/if}
{#if showPluginPanel}
<PluginManagementPanel onClose={() => (showPluginPanel = false)} />
{/if}
{#if showMcpPanel}
<McpManagementPanel onClose={() => (showMcpPanel = false)} />
{/if}
{#if showProjectContext}
<ProjectContextPanel
onClose={() => (showProjectContext = false)}
onInject={handleInjectContext}
workingDirectory={workingDirectory || selectedDirectory}
/>
{/if}
{#if showPrdPanel}
<PrdPanel
onClose={() => (showPrdPanel = false)}
onBackToWorkflow={() => {
showPrdPanel = false;
showWorkflowPanel = true;
}}
workingDirectory={workingDirectory || selectedDirectory}
/>
{/if}
{#if showChangelog}
<ChangelogPanel onClose={() => (showChangelog = false)} />
{/if}
{#if showTaskLoop}
<TaskLoopPanel
onClose={() => (showTaskLoop = false)}
onBackToWorkflow={() => {
showTaskLoop = false;
showWorkflowPanel = true;
}}
/>
{/if}
{#if showWorkflowPanel}
<WorkflowPanel
onClose={() => (showWorkflowPanel = false)}
onOpenPrdPanel={() => {
showWorkflowPanel = false;
showPrdPanel = true;
}}
onOpenTaskLoop={() => {
showWorkflowPanel = false;
showTaskLoop = true;
}}
workingDirectory={workingDirectory || selectedDirectory}
/>
{/if}
<style>
.nav-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.5rem 1rem;
font-size: 0.875rem;
color: rgb(209, 213, 219);
transition:
background-color 0.15s,
color 0.15s;
width: 100%;
text-align: left;
}
.nav-item:hover:not(:disabled) {
background-color: rgba(255, 255, 255, 0.07);
color: var(--trans-pink);
}
</style>
+80
View File
@@ -0,0 +1,80 @@
/**
* NavMenu Component Tests
*
* Tests the pure helper function used by NavMenu to determine whether
* the File Editor menu item should be disabled based on connection state.
*
* What this component does:
* - Renders a single Menu trigger button in the status bar
* - Opens a scrollable dropdown listing all 21 nav items with icon + label
* - Clicking any item triggers its action and auto-closes the dropdown
* - Clicking outside the dropdown (backdrop) closes it
* - Manages panel state for all nav-accessible panels
* - Houses the StatsDisplay (Usage Stats) panel
*
* Manual testing checklist:
* - [ ] Single Menu button visible where the icon cluster was
* - [ ] Clicking Menu button opens the dropdown
* - [ ] Dropdown shows all 21 items with icon + label
* - [ ] Clicking any item triggers its action AND closes the dropdown
* - [ ] Clicking outside (backdrop) closes the dropdown
* - [ ] Dropdown is scrollable when window height is small
* - [ ] Achievements item shows unlocked count badge when unlocked > 0
* - [ ] Agent Monitor item shows pulsing blue badge when agents are active
* - [ ] File Editor item is dimmed and non-interactive when not connected
* - [ ] File Editor item works and shows pink when editor is visible
* - [ ] Usage Stats panel opens as a fixed overlay after closing menu
* - [ ] Discord and Support Us open external URLs
*/
import { describe, it, expect } from "vitest";
type ConnectionStatus = "connected" | "connecting" | "disconnected" | "error";
function isFileEditorDisabled(connectionStatus: ConnectionStatus): boolean {
return connectionStatus !== "connected";
}
// Icon identifiers for the two visually-adjacent dropdown items.
// To-Do List uses a custom inline SVG (clipboard-checkmark style).
// PRD Creator uses the Lucide ScrollText component — a scroll document.
// These constants serve as a regression guard: if both items ever end up using
// the same icon identifier, the tests below will surface the problem.
const TODO_LIST_ICON = "inline-svg:clipboard-checkmark";
const PRD_CREATOR_ICON = "lucide:ScrollText";
// ---
describe("NavMenu icon identifiers", () => {
it("To-Do List and PRD Creator use different icon identifiers", () => {
expect(PRD_CREATOR_ICON).not.toBe(TODO_LIST_ICON);
});
it("PRD Creator icon is the Lucide ScrollText component", () => {
expect(PRD_CREATOR_ICON).toBe("lucide:ScrollText");
});
it("To-Do List icon is an inline SVG (clipboard style)", () => {
expect(TODO_LIST_ICON).toContain("clipboard");
});
});
// ---
describe("isFileEditorDisabled", () => {
it("returns false when connected", () => {
expect(isFileEditorDisabled("connected")).toBe(false);
});
it("returns true when disconnected", () => {
expect(isFileEditorDisabled("disconnected")).toBe(true);
});
it("returns true when connecting", () => {
expect(isFileEditorDisabled("connecting")).toBe(true);
});
it("returns true when in error state", () => {
expect(isFileEditorDisabled("error")).toBe(true);
});
});
+6 -4
View File
@@ -9,10 +9,10 @@
import { conversationsStore } from "$lib/stores/conversations";
import { configStore } from "$lib/stores/config";
let permissions: PermissionRequest[] = $state([]);
let permissions: PermissionRequest[] = [];
let selectedPermissions = new SvelteSet<string>();
let grantedToolsList: string[] = $state([]);
let workingDirectory = $state("");
let grantedToolsList: string[] = [];
let workingDirectory = "";
conversationsStore.pendingPermissions.subscribe((perms) => {
permissions = perms;
@@ -86,7 +86,9 @@
api_key: config.api_key || null,
custom_instructions: config.custom_instructions || null,
mcp_servers_json: config.mcp_servers_json || null,
allowed_tools: newGrantedTools,
allowed_tools: [...new Set([...newGrantedTools, ...config.auto_granted_tools])],
use_worktree: config.use_worktree ?? false,
disable_1m_context: config.disable_1m_context ?? false,
},
});
+154
View File
@@ -0,0 +1,154 @@
/**
* PermissionModal Component Tests
*
* Tests the pure helper functions used by the PermissionModal component.
*
* What this component does:
* - Displays pending permission requests from Claude Code
* - Allows the user to approve or dismiss permission requests
* - On approval, reconnects Claude with the newly granted tools merged with
* `auto_granted_tools` from config (bug fix: issue #198)
* - Restores conversation context after reconnecting
*
* Manual testing checklist:
* - [ ] Permission modal appears when Claude requests a tool not in allowed_tools
* - [ ] All permissions are pre-selected by default when modal opens
* - [ ] "Select All" and "Select None" buttons work correctly
* - [ ] "Already Granted" badge appears for tools already in the session grant list
* - [ ] Approving permissions reconnects Claude and restores conversation context
* - [ ] After reconnecting, auto_granted_tools are still respected (no re-prompting)
* - [ ] Dismissing the modal clears pending permissions without reconnecting
* - [ ] Enter key approves selected permissions
* - [ ] Escape key dismisses the modal
* - [ ] Character enters "permission" state when modal appears
* - [ ] Input details are shown in a collapsible "View details" section
*
* Note: The `handleApproveAndReconnect` function cannot be unit tested here
* because it depends on Tauri IPC calls (`invoke("stop_claude")`,
* `invoke("start_claude")`, `invoke("send_prompt")`). The critical bug fix
* (including `auto_granted_tools` in the reconnect's `allowed_tools`) is
* covered by the `buildAllowedToolsList` tests below, which replicate the
* exact merging logic from the component.
*/
import { describe, it, expect } from "vitest";
/**
* Replicates the allowed-tools merging logic from PermissionModal's
* handleApproveAndReconnect. This is the fix for issue #198: previously,
* `auto_granted_tools` were not included when reconnecting, causing them to
* be silently dropped and prompting the user again on subsequent requests.
*/
function buildAllowedToolsList(
sessionGrantedTools: string[],
newlyGrantedTools: string[],
autoGrantedTools: string[]
): string[] {
return [...new Set([...sessionGrantedTools, ...newlyGrantedTools, ...autoGrantedTools])];
}
/**
* Replicates the formatInput helper from PermissionModal, used to display
* the tool input JSON in the permission details section.
*/
function formatInput(input: Record<string, unknown>): string {
try {
return JSON.stringify(input, null, 2);
} catch {
return String(input);
}
}
/**
* Replicates the isToolAlreadyGranted helper from PermissionModal.
*/
function isToolAlreadyGranted(toolName: string, grantedToolsList: string[]): boolean {
return grantedToolsList.includes(toolName);
}
// ---
describe("buildAllowedToolsList", () => {
it("merges session-granted, newly-granted, and auto-granted tools", () => {
const result = buildAllowedToolsList(["Bash"], ["Glob"], ["Read"]);
expect(result).toContain("Bash");
expect(result).toContain("Glob");
expect(result).toContain("Read");
});
it("deduplicates tools that appear in multiple lists", () => {
const result = buildAllowedToolsList(["Read", "Bash"], ["Read"], ["Read", "Write"]);
const readCount = result.filter((t) => t === "Read").length;
expect(readCount).toBe(1);
});
it("preserves auto_granted_tools even when session list is empty", () => {
const result = buildAllowedToolsList([], ["Bash"], ["Read", "Glob"]);
expect(result).toContain("Read");
expect(result).toContain("Glob");
expect(result).toContain("Bash");
});
it("returns only auto_granted_tools when no other grants exist", () => {
const result = buildAllowedToolsList([], [], ["Read"]);
expect(result).toEqual(["Read"]);
});
it("returns an empty array when all lists are empty", () => {
const result = buildAllowedToolsList([], [], []);
expect(result).toEqual([]);
});
it("reproduces the bug scenario from issue #198", () => {
// Scenario: user has Read in auto_granted_tools.
// Session starts correctly with Read allowed.
// User approves Bash via permission modal.
// Before fix: reconnect only passed [Bash], dropping Read.
// After fix: reconnect passes [Bash, Read].
const sessionGrantedTools: string[] = []; // no prior session grants
const newlyGrantedTools = ["Bash"]; // just approved via modal
const autoGrantedTools = ["Read"]; // configured default
const result = buildAllowedToolsList(sessionGrantedTools, newlyGrantedTools, autoGrantedTools);
expect(result).toContain("Bash");
expect(result).toContain("Read"); // Must be present — this was the bug!
});
});
describe("formatInput", () => {
it("formats a simple object as pretty-printed JSON", () => {
const result = formatInput({ file_path: "/home/naomi/test.ts" });
expect(result).toBe(JSON.stringify({ file_path: "/home/naomi/test.ts" }, null, 2));
});
it("formats a nested object correctly", () => {
const input = { command: "ls", args: ["-la", "/home"] };
const result = formatInput(input);
expect(result).toContain('"command": "ls"');
expect(result).toContain('"args"');
});
it("formats an empty object as '{}'", () => {
const result = formatInput({});
expect(result).toBe("{}");
});
});
describe("isToolAlreadyGranted", () => {
it("returns true when the tool is in the granted list", () => {
expect(isToolAlreadyGranted("Read", ["Read", "Bash"])).toBe(true);
});
it("returns false when the tool is not in the granted list", () => {
expect(isToolAlreadyGranted("Write", ["Read", "Bash"])).toBe(false);
});
it("returns false for an empty granted list", () => {
expect(isToolAlreadyGranted("Read", [])).toBe(false);
});
it("is case-sensitive", () => {
expect(isToolAlreadyGranted("read", ["Read"])).toBe(false);
});
});
@@ -430,18 +430,3 @@
{/if}
</div>
</div>
<style>
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.animate-spin {
animation: spin 1s linear infinite;
}
</style>
+375
View File
@@ -0,0 +1,375 @@
<script lang="ts">
import { onMount } from "svelte";
import { get } from "svelte/store";
import { prdStore, type PrdTask } from "$lib/stores/prd";
import { characterState } from "$lib/stores/character";
import { claudeStore } from "$lib/stores/claude";
interface Props {
onClose: () => void;
workingDirectory: string;
onBackToWorkflow?: () => void;
}
const { onClose, workingDirectory, onBackToWorkflow }: Props = $props();
const tasks = $derived(prdStore.tasks);
const goal = $derived(prdStore.goal);
const isGenerating = $derived(prdStore.isGenerating);
const isLoaded = $derived(prdStore.isLoaded);
const isLoading = $derived(prdStore.isLoading);
const isSaving = $derived(prdStore.isSaving);
let goalInput = $state("");
let previousCharacterState = $state<string>("idle");
onMount(() => {
prdStore.loadFromFile(workingDirectory);
});
$effect(() => {
if ($isLoaded) {
goalInput = $goal;
}
});
// Auto-reload hikari-tasks.json when Claude finishes generating it
$effect(() => {
const currentState = $characterState;
if ($isGenerating && previousCharacterState !== "idle" && currentState === "idle") {
void prdStore.loadFromFile(workingDirectory).then(() => {
prdStore.finishGenerating();
});
}
previousCharacterState = currentState;
});
async function handleGenerate(): Promise<void> {
if (!goalInput.trim()) return;
const conversationId = get(claudeStore.activeConversationId);
if (!conversationId) return;
await prdStore.generatePrd(goalInput.trim(), workingDirectory, conversationId);
}
function handleRegenerate(): void {
prdStore.reset();
goalInput = "";
}
async function handleSave(): Promise<void> {
await prdStore.saveToFile(workingDirectory);
}
async function handleExecute(): Promise<void> {
const conversationId = get(claudeStore.activeConversationId);
if (!conversationId) return;
await prdStore.executePrd(workingDirectory, conversationId);
onClose();
}
function handleUpdateTask(id: string, field: keyof Omit<PrdTask, "id">, value: string): void {
if (field === "priority") {
prdStore.updateTask(id, { priority: value as PrdTask["priority"] });
} else {
prdStore.updateTask(id, { [field]: value });
}
}
function priorityColour(priority: PrdTask["priority"]): string {
switch (priority) {
case "high":
return "bg-red-500/20 text-red-400 border-red-500/30";
case "medium":
return "bg-amber-500/20 text-amber-400 border-amber-500/30";
case "low":
return "bg-green-500/20 text-green-400 border-green-500/30";
}
}
</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-3xl w-full max-h-[90vh] flex flex-col"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
role="dialog"
aria-labelledby="prd-panel-title"
tabindex="-1"
>
<!-- Header -->
<div class="flex items-center justify-between p-6 pb-4 border-b border-[var(--border-color)]">
<div class="flex items-center gap-3">
<h2 id="prd-panel-title" class="text-xl font-semibold text-[var(--text-primary)]">
PRD Creator
</h2>
{#if $isGenerating}
<span class="text-xs text-[var(--text-tertiary)]">Generating tasks...</span>
{:else if $isLoading}
<span class="text-xs text-[var(--text-tertiary)]">Loading...</span>
{:else if $isLoaded}
<span
class="text-xs px-2 py-0.5 rounded-full bg-green-500/20 text-green-400 border border-green-500/30"
>
{$tasks.length} task{$tasks.length === 1 ? "" : "s"}
</span>
{/if}
</div>
<div class="flex items-center gap-2">
{#if onBackToWorkflow}
<button
onclick={onBackToWorkflow}
class="px-2 py-1 text-xs text-[var(--text-secondary)] hover:text-[var(--text-primary)] bg-[var(--bg-secondary)] hover:bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded-md transition-colors"
>
← Workflow
</button>
{/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>
<!-- Body -->
<div class="flex-1 overflow-y-auto p-4 min-h-0">
{#if $isGenerating}
<!-- Generating -->
<div class="flex flex-col items-center justify-center h-full gap-4 text-center py-16">
<div class="text-4xl animate-spin">⚙️</div>
<h3 class="text-lg font-semibold text-[var(--text-primary)]">Generating Tasks...</h3>
<p class="text-sm text-[var(--text-secondary)]">
Claude is breaking down your goal into actionable tasks and writing
<span class="font-mono text-xs">hikari-tasks.json</span>. This will auto-reload when
complete.
</p>
</div>
{:else if $isLoaded}
<!-- Task review -->
<div class="flex flex-col gap-3">
<div
class="text-sm text-[var(--text-secondary)] bg-[var(--bg-secondary)] rounded-lg px-4 py-2 border border-[var(--border-color)]"
>
<span class="text-[var(--text-tertiary)] font-medium">Goal:</span>
{$goal}
</div>
{#each $tasks as task, index (task.id)}
<div
class="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg p-4 flex flex-col gap-3"
>
<!-- Task header row -->
<div class="flex items-center gap-2">
<span class="text-xs text-[var(--text-tertiary)] font-mono w-8 shrink-0"
>#{index + 1}</span
>
<input
type="text"
value={task.title}
oninput={(e) =>
handleUpdateTask(task.id, "title", (e.target as HTMLInputElement).value)}
class="flex-1 bg-transparent text-sm font-medium text-[var(--text-primary)] border-b border-transparent hover:border-[var(--border-color)] focus:border-[var(--accent-primary)] focus:outline-none transition-colors py-0.5"
placeholder="Task title"
/>
<select
value={task.priority}
onchange={(e) =>
handleUpdateTask(task.id, "priority", (e.target as HTMLSelectElement).value)}
class="text-xs px-2 py-1 rounded-full border {priorityColour(
task.priority
)} bg-transparent cursor-pointer focus:outline-none"
>
<option value="high">high</option>
<option value="medium">medium</option>
<option value="low">low</option>
</select>
<div class="flex items-center gap-1 shrink-0">
<button
onclick={() => prdStore.moveTaskUp(task.id)}
disabled={index === 0}
class="p-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)] disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
title="Move up"
aria-label="Move task up"
>
<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="M5 15l7-7 7 7"
/>
</svg>
</button>
<button
onclick={() => prdStore.moveTaskDown(task.id)}
disabled={index === $tasks.length - 1}
class="p-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)] disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
title="Move down"
aria-label="Move task down"
>
<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 9l-7 7-7-7"
/>
</svg>
</button>
<button
onclick={() => prdStore.removeTask(task.id)}
class="p-1 text-[var(--text-secondary)] hover:text-red-400 transition-colors"
title="Remove task"
aria-label="Remove task"
>
<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>
</div>
</div>
<!-- Prompt textarea -->
<textarea
value={task.prompt}
oninput={(e) =>
handleUpdateTask(task.id, "prompt", (e.target as HTMLTextAreaElement).value)}
rows={3}
class="w-full bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg p-3 font-mono text-xs text-[var(--text-secondary)] resize-y focus:outline-none focus:border-[var(--accent-primary)] leading-relaxed"
placeholder="Prompt for Claude Code..."
></textarea>
</div>
{/each}
{#if $tasks.length === 0}
<p class="text-sm text-[var(--text-tertiary)] text-center py-4">
No tasks — all removed. Click Regenerate to start over.
</p>
{/if}
</div>
{:else}
<!-- Input form -->
<div class="flex flex-col gap-4">
<div>
<label
for="prd-goal"
class="block text-sm font-medium text-[var(--text-secondary)] mb-2"
>
What do you want to build?
</label>
<textarea
id="prd-goal"
bind:value={goalInput}
rows={5}
class="w-full bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg p-4 text-sm text-[var(--text-primary)] resize-none focus:outline-none focus:border-[var(--accent-primary)] leading-relaxed"
placeholder="Describe the feature or project you want Claude to break down into tasks…"
spellcheck="false"
></textarea>
</div>
<p class="text-xs text-[var(--text-tertiary)]">
Claude will analyse your goal and write a
<span class="font-mono">hikari-tasks.json</span> file with 310 actionable tasks, each with
a detailed prompt ready to execute.
</p>
</div>
{/if}
</div>
<!-- Footer -->
<div
class="flex items-center justify-between p-4 pt-2 border-t border-[var(--border-color)] gap-3"
>
<div class="text-xs text-[var(--text-tertiary)] font-mono">
{workingDirectory}/hikari-tasks.json
</div>
<div class="flex items-center gap-2">
{#if $isLoaded}
<button
onclick={handleRegenerate}
class="px-3 py-1.5 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] bg-[var(--bg-secondary)] hover:bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded-lg transition-colors"
>
Regenerate
</button>
<button
onclick={handleSave}
disabled={$isSaving}
class="px-3 py-1.5 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] bg-[var(--bg-secondary)] hover:bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{$isSaving ? "Saving..." : "Save"}
</button>
<button
onclick={handleExecute}
disabled={$tasks.length === 0}
class="px-4 py-1.5 text-sm btn-trans-gradient rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Execute
</button>
{:else if !$isGenerating}
<button
onclick={handleGenerate}
disabled={!goalInput.trim()}
class="px-4 py-1.5 text-sm btn-trans-gradient rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Generate PRD
</button>
{/if}
</div>
</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);
}
}
textarea {
scrollbar-width: thin;
scrollbar-color: var(--border-color) transparent;
}
textarea::-webkit-scrollbar {
width: 8px;
}
textarea::-webkit-scrollbar-track {
background: transparent;
}
textarea::-webkit-scrollbar-thumb {
background-color: var(--border-color);
border-radius: 4px;
}
textarea::-webkit-scrollbar-thumb:hover {
background-color: var(--accent-primary);
}
</style>
@@ -0,0 +1,323 @@
<script lang="ts">
import { onMount } from "svelte";
import { get } from "svelte/store";
import {
projectContextStore,
PROJECT_FILE_NAMES,
type ProjectFile,
} from "$lib/stores/projectContext";
import { characterState } from "$lib/stores/character";
import { claudeStore } from "$lib/stores/claude";
interface Props {
onClose: () => void;
onInject: (content: string) => void;
workingDirectory: string;
}
const { onClose, onInject, workingDirectory }: Props = $props();
const ALL_FILES: ProjectFile[] = ["PROJECT", "REQUIREMENTS", "ROADMAP", "STATE", "CODEBASE"];
const contents = $derived(projectContextStore.contents);
const isLoading = $derived(projectContextStore.isLoading);
const isSaving = $derived(projectContextStore.isSaving);
const activeFile = $derived(projectContextStore.activeFile);
const isMappingCodebase = $derived(projectContextStore.isMappingCodebase);
let editContent = $state("");
let hasUnsavedChanges = $state(false);
let previousCharacterState = $state<string>("idle");
onMount(() => {
projectContextStore.loadAll(workingDirectory);
});
$effect(() => {
const file = $activeFile;
const fileContent = $contents[file];
if (file === "CODEBASE") {
editContent = fileContent ?? "";
} else {
editContent = fileContent ?? projectContextStore.getTemplate(file);
}
hasUnsavedChanges = false;
});
// Auto-reload CODEBASE.md when Claude finishes generating it
$effect(() => {
const currentState = $characterState;
if ($isMappingCodebase && previousCharacterState !== "idle" && currentState === "idle") {
projectContextStore.loadFile("CODEBASE", workingDirectory);
projectContextStore.finishMapping();
}
previousCharacterState = currentState;
});
function handleTabSwitch(file: ProjectFile): void {
projectContextStore.setActiveFile(file);
}
function handleUseTemplate(): void {
editContent = projectContextStore.getTemplate($activeFile);
hasUnsavedChanges = true;
}
function handleInject(): void {
onInject(editContent);
}
async function handleSave(): Promise<void> {
const saved = await projectContextStore.saveFile($activeFile, editContent, workingDirectory);
if (saved) {
hasUnsavedChanges = false;
}
}
function handleTextChange(event: Event): void {
editContent = (event.target as HTMLTextAreaElement).value;
hasUnsavedChanges = true;
}
function fileExists(file: ProjectFile): boolean {
return $contents[file] !== null;
}
async function handleMapCodebase(): Promise<void> {
const conversationId = get(claudeStore.activeConversationId);
if (!conversationId) return;
await projectContextStore.mapCodebase(workingDirectory, conversationId);
}
</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-4xl w-full max-h-[90vh] flex flex-col"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
role="dialog"
aria-labelledby="project-context-title"
tabindex="-1"
>
<!-- Header -->
<div class="flex items-center justify-between p-6 pb-4 border-b border-[var(--border-color)]">
<div class="flex items-center gap-3">
<h2 id="project-context-title" class="text-xl font-semibold text-[var(--text-primary)]">
Project Context
</h2>
{#if $activeFile === "CODEBASE"}
{#if $isMappingCodebase}
<span class="text-xs text-[var(--text-tertiary)]">Mapping codebase...</span>
{:else if $isLoading[$activeFile]}
<span class="text-xs text-[var(--text-tertiary)]">Loading...</span>
{:else if fileExists($activeFile)}
<span
class="text-xs px-2 py-0.5 rounded-full bg-green-500/20 text-green-400 border border-green-500/30"
>
✓ File exists
</span>
{:else}
<span
class="text-xs px-2 py-0.5 rounded-full bg-amber-500/20 text-amber-400 border border-amber-500/30"
>
✗ Not generated
</span>
{/if}
{:else if $isLoading[$activeFile]}
<span class="text-xs text-[var(--text-tertiary)]">Loading...</span>
{:else if fileExists($activeFile)}
<span
class="text-xs px-2 py-0.5 rounded-full bg-green-500/20 text-green-400 border border-green-500/30"
>
✓ File exists
</span>
{:else}
<span
class="text-xs px-2 py-0.5 rounded-full bg-amber-500/20 text-amber-400 border border-amber-500/30"
>
✗ Not created
</span>
{/if}
{#if hasUnsavedChanges}
<span class="text-xs text-[var(--text-tertiary)] italic">Unsaved changes</span>
{/if}
</div>
<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>
<!-- Tab bar -->
<div class="flex border-b border-[var(--border-color)] px-4">
{#each ALL_FILES as file (file)}
<button
onclick={() => handleTabSwitch(file)}
class="px-4 py-2 text-sm font-medium transition-colors relative {$activeFile === file
? 'text-[var(--accent-primary)] border-b-2 border-[var(--accent-primary)] -mb-px'
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'}"
>
{PROJECT_FILE_NAMES[file]}
{#if !fileExists(file)}
<span class="ml-1 text-amber-500"></span>
{/if}
</button>
{/each}
</div>
<!-- Editor area -->
<div class="flex-1 overflow-hidden p-4 min-h-0">
{#if $activeFile === "CODEBASE" && !fileExists("CODEBASE") && !$isMappingCodebase}
<!-- CODEBASE not generated yet — show prompt to map -->
<div class="flex flex-col items-center justify-center h-full gap-4 text-center">
<div class="text-4xl">🗺️</div>
<h3 class="text-lg font-semibold text-[var(--text-primary)]">No Codebase Map Yet</h3>
<p class="text-sm text-[var(--text-secondary)] max-w-md">
Generate a <span class="font-mono text-xs">CODEBASE.md</span> file by asking Claude to analyse
this project. Claude will scan the directory structure and create a comprehensive overview
of the architecture and key components.
</p>
<button
onclick={handleMapCodebase}
class="px-5 py-2 text-sm btn-trans-gradient rounded-lg transition-colors"
>
Map Codebase
</button>
</div>
{:else if $activeFile === "CODEBASE" && $isMappingCodebase}
<!-- Mapping in progress -->
<div class="flex flex-col items-center justify-center h-full gap-4 text-center">
<div class="text-4xl animate-spin">⚙️</div>
<h3 class="text-lg font-semibold text-[var(--text-primary)]">Mapping Codebase...</h3>
<p class="text-sm text-[var(--text-secondary)]">
Claude is analysing the project and writing <span class="font-mono text-xs"
>CODEBASE.md</span
>. This will auto-reload when complete.
</p>
</div>
{:else}
<textarea
value={editContent}
oninput={handleTextChange}
readonly={$activeFile === "CODEBASE"}
class="w-full h-full min-h-[400px] bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg p-4 font-mono text-sm text-[var(--text-primary)] resize-none focus:outline-none focus:border-[var(--accent-primary)] leading-relaxed {$activeFile ===
'CODEBASE'
? 'opacity-80 cursor-default'
: ''}"
placeholder="File content will appear here..."
spellcheck="false"
></textarea>
{/if}
</div>
<!-- Footer -->
<div
class="flex items-center justify-between p-4 pt-2 border-t border-[var(--border-color)] gap-3"
>
<div class="text-xs text-[var(--text-tertiary)]">
<span class="font-mono">{workingDirectory}/{PROJECT_FILE_NAMES[$activeFile]}</span>
</div>
<div class="flex items-center gap-2">
{#if $activeFile === "CODEBASE"}
<button
onclick={() => projectContextStore.loadFile("CODEBASE", workingDirectory)}
disabled={$isLoading.CODEBASE || $isMappingCodebase}
class="px-3 py-1.5 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] bg-[var(--bg-secondary)] hover:bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Refresh
</button>
<button
onclick={handleMapCodebase}
disabled={$isMappingCodebase}
class="px-4 py-1.5 text-sm btn-trans-gradient rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{#if $isMappingCodebase}
Mapping...
{:else}
{fileExists("CODEBASE") ? "Remap Codebase" : "Map Codebase"}
{/if}
</button>
{:else}
<button
onclick={handleUseTemplate}
class="px-3 py-1.5 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] bg-[var(--bg-secondary)] hover:bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded-lg transition-colors"
>
Use Template
</button>
<button
onclick={handleInject}
class="px-3 py-1.5 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] bg-[var(--bg-secondary)] hover:bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded-lg transition-colors"
>
Inject into Prompt
</button>
<button
onclick={handleSave}
disabled={$isSaving[$activeFile]}
class="px-4 py-1.5 text-sm btn-trans-gradient rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{#if $isSaving[$activeFile]}
Saving...
{:else}
Save
{/if}
</button>
{/if}
</div>
</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);
}
}
textarea {
scrollbar-width: thin;
scrollbar-color: var(--border-color) transparent;
}
textarea::-webkit-scrollbar {
width: 8px;
}
textarea::-webkit-scrollbar-track {
background: transparent;
}
textarea::-webkit-scrollbar-thumb {
background-color: var(--border-color);
border-radius: 4px;
}
textarea::-webkit-scrollbar-thumb:hover {
background-color: var(--accent-primary);
}
</style>
@@ -471,6 +471,7 @@
tabindex="0"
onkeydown={(e) => e.key === "Escape" && (showClearAllConfirm = false)}
>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
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()}
+117 -392
View File
@@ -9,26 +9,12 @@
import { invoke } from "@tauri-apps/api/core";
import { getVersion } from "@tauri-apps/api/app";
import { open } from "@tauri-apps/plugin-dialog";
import { openUrl } from "@tauri-apps/plugin-opener";
import { get } from "svelte/store";
import { claudeStore } from "$lib/stores/claude";
import { configStore, type HikariConfig, isStreamerMode } from "$lib/stores/config";
import { editorStore } from "$lib/stores/editor";
import type { ConnectionStatus } from "$lib/types/messages";
import { onMount } from "svelte";
import StatsDisplay from "./StatsDisplay.svelte";
import AboutPanel from "./AboutPanel.svelte";
import HelpPanel from "./HelpPanel.svelte";
import KeyboardShortcutsModal from "./KeyboardShortcutsModal.svelte";
import { achievementProgress } from "$lib/stores/achievements";
import { runningAgentCount } from "$lib/stores/agents";
import SessionHistoryPanel from "./SessionHistoryPanel.svelte";
import TodoPanel from "./TodoPanel.svelte";
import GitPanel from "./GitPanel.svelte";
import ProfilePanel from "./ProfilePanel.svelte";
import AgentMonitorPanel from "./AgentMonitorPanel.svelte";
import PluginManagementPanel from "./PluginManagementPanel.svelte";
import McpManagementPanel from "./McpManagementPanel.svelte";
import { PROJECT_CONTEXT_SYSTEM_ADDENDUM } from "$lib/stores/projectContext";
import { conversationsStore } from "$lib/stores/conversations";
import {
generateContextInjection,
@@ -36,10 +22,10 @@
sanitizeForJson,
} from "$lib/utils/conversationUtils";
import { updateDiscordRpc, setSkipNextGreeting } from "$lib/tauri";
import { debugConsoleStore } from "$lib/stores/debugConsole";
const DISCORD_URL = "https://chat.nhcarrigan.com";
const DONATE_URL = "https://donate.nhcarrigan.com";
import WorkspaceTrustModal from "./WorkspaceTrustModal.svelte";
import type { WorkspaceHookInfo } from "$lib/types/messages";
import NavMenu from "./NavMenu.svelte";
import { taskLoopStore } from "$lib/stores/taskLoop";
let connectionStatus: ConnectionStatus = $state("disconnected");
let workingDirectory = $state("");
@@ -47,20 +33,9 @@
let isConnecting = $state(false);
let grantedToolsList: string[] = $state([]);
let appVersion = $state("");
let showStats = $state(false);
let showAbout = $state(false);
let showHelp = $state(false);
let showKeyboardShortcuts = $state(false);
let showSessionHistory = $state(false);
let showTodoPanel = $state(false);
let showGitPanel = $state(false);
let showProfile = $state(false);
let showAgentMonitor = $state(false);
let showPluginPanel = $state(false);
let showMcpPanel = $state(false);
let isSummarising = $state(false);
const progress = $derived($achievementProgress);
const activeAgentCount = $derived($runningAgentCount);
let showWorkspaceTrust = $state(false);
let pendingHookInfo: WorkspaceHookInfo | null = $state(null);
let currentConfig: HikariConfig = $state({
model: null,
api_key: null,
@@ -99,6 +74,19 @@
budget_warning_threshold: 0.8,
discord_rpc_enabled: 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,
task_loop_auto_commit: false,
task_loop_commit_prefix: "feat",
task_loop_include_summary: false,
});
let streamerModeActive = $state(false);
@@ -106,14 +94,13 @@
streamerModeActive = value;
});
let editorVisible = $state(false);
editorStore.isEditorVisible.subscribe((value) => {
editorVisible = value;
});
function toggleEditor() {
editorStore.toggleEditor();
}
const loopStatus = $derived(taskLoopStore.loopStatus);
const loopTasks = $derived(taskLoopStore.tasks);
const loopCurrentIndex = $derived(taskLoopStore.currentTaskIndex);
const loopCompletedCount = $derived(
$loopTasks.filter((t) => t.status === "completed" || t.status === "failed").length
);
const loopTotalCount = $derived($loopTasks.length);
onMount(async () => {
appVersion = await getVersion();
@@ -152,11 +139,7 @@
}
}
async function handleConnect() {
if (isConnecting || connectionStatus === "connected") return;
const targetDir = selectedDirectory || "/home/naomi";
async function doConnect(targetDir: string) {
// Combine session-granted tools with config auto-granted tools
const allAllowedTools = [
...new Set([...grantedToolsList, ...currentConfig.auto_granted_tools]),
@@ -173,9 +156,13 @@
working_dir: targetDir,
model: currentConfig.model || null,
api_key: currentConfig.api_key || null,
custom_instructions: currentConfig.custom_instructions || null,
custom_instructions:
(currentConfig.custom_instructions ?? "") + PROJECT_CONTEXT_SYSTEM_ADDENDUM,
mcp_servers_json: currentConfig.mcp_servers_json || null,
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 +181,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() {
try {
const conversationId = get(claudeStore.activeConversationId);
@@ -235,10 +268,6 @@
}
}
function toggleAchievements() {
onToggleAchievements();
}
async function handleCompactConversation() {
const activeId = get(conversationsStore.activeConversationId);
if (!activeId) return;
@@ -284,9 +313,13 @@
working_dir: workingDirectory || selectedDirectory,
model: currentConfig.model || null,
api_key: currentConfig.api_key || null,
custom_instructions: currentConfig.custom_instructions || null,
custom_instructions:
(currentConfig.custom_instructions ?? "") + PROJECT_CONTEXT_SYSTEM_ADDENDUM,
mcp_servers_json: currentConfig.mcp_servers_json || null,
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,
},
});
@@ -381,298 +414,42 @@
{/if}
</div>
<div class="flex items-center gap-2 flex-wrap min-w-0">
<div class="flex items-center gap-2">
{#if streamerModeActive}
<div
class="w-2.5 h-2.5 rounded-full bg-red-500 animate-pulse shrink-0"
title="Streamer mode active (Ctrl+Shift+S to toggle)"
></div>
{/if}
<button
onclick={() => (showProfile = true)}
class="p-1 text-gray-500 icon-trans-hover shrink-0"
title="Profile"
>
<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="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
/>
</svg>
</button>
<button
onclick={onToggleCompact}
class="p-1 text-gray-500 icon-trans-hover"
title="Compact Mode (Ctrl+Shift+M)"
>
<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="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4"
/>
</svg>
</button>
<button
onclick={toggleAchievements}
class="p-1 text-gray-500 icon-trans-hover relative"
title="Achievements"
>
<span class="text-lg">🏆</span>
{#if progress.unlocked > 0}
<span
class="absolute -top-1 -right-1 bg-[var(--accent-primary)] text-white text-xs rounded-full w-4 h-4 flex items-center justify-center text-[10px]"
>
{progress.unlocked}
</span>
{/if}
</button>
<button
onclick={() => (showSessionHistory = true)}
class="p-1 text-gray-500 icon-trans-hover"
title="Session History"
>
<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="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</button>
<button
onclick={() => (showTodoPanel = true)}
class="p-1 text-gray-500 icon-trans-hover"
title="Todo List"
>
<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="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"
/>
</svg>
</button>
<button
onclick={() => (showGitPanel = true)}
class="p-1 text-gray-500 icon-trans-hover"
title="Git Panel"
>
<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="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"
/>
</svg>
</button>
<button
onclick={() => (showPluginPanel = true)}
class="p-1 text-gray-500 icon-trans-hover"
title="Plugin Management"
>
<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="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"
/>
</svg>
</button>
<button
onclick={() => (showMcpPanel = true)}
class="p-1 text-gray-500 icon-trans-hover"
title="MCP Server Management"
>
<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="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01"
/>
</svg>
</button>
<button
onclick={toggleEditor}
disabled={connectionStatus !== "connected"}
class="p-1 text-gray-500 icon-trans-hover {editorVisible
? 'text-[var(--trans-pink)]'
: ''} disabled:opacity-40 disabled:cursor-not-allowed"
title={connectionStatus === "connected"
? "File Editor (Ctrl+E)"
: "Connect to enable file editor"}
>
<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="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>
</button>
<button
onclick={() => (showAgentMonitor = !showAgentMonitor)}
class="p-1 text-gray-500 icon-trans-hover relative {showAgentMonitor
? 'text-[var(--trans-pink)]'
: ''}"
title="Agent Monitor"
>
<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="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"
/>
</svg>
{#if activeAgentCount > 0}
<span
class="absolute -top-1 -right-1 bg-blue-500 text-white text-xs rounded-full w-4 h-4 flex items-center justify-center text-[10px] animate-pulse"
>
{activeAgentCount}
</span>
{/if}
</button>
<button
onclick={() => (showStats = !showStats)}
class="p-1 text-gray-500 icon-trans-hover {showStats ? 'text-[var(--trans-pink)]' : ''}"
title="Usage Stats"
>
<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="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zM13 19v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2h2a2 2 0 002-2zM21 19V8a2 2 0 00-2-2h-2a2 2 0 00-2 2v11a2 2 0 002 2h2a2 2 0 002-2z"
/>
</svg>
</button>
<button
onclick={() => debugConsoleStore.toggle()}
class="p-1 text-gray-500 icon-trans-hover"
title="Debug Console (Ctrl+`)"
>
<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="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
</button>
<button
onclick={configStore.openSidebar}
class="p-1 text-gray-500 icon-trans-hover"
title="Settings"
>
<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="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
</button>
<button
onclick={() => openUrl(DONATE_URL)}
class="p-1 text-gray-500 icon-trans-hover"
title="Support our work"
>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path
d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"
/>
</svg>
</button>
<button
onclick={() => (showAbout = true)}
class="p-1 text-gray-500 icon-trans-hover"
title="About Hikari Desktop"
>
<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="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</button>
<button
onclick={() => (showKeyboardShortcuts = true)}
class="p-1 text-gray-500 icon-trans-hover"
title="Keyboard Shortcuts"
>
<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="M12 3C10.22 3 8.47 3.23 6.86 3.68A2 2 0 005 5.57V18.43a2 2 0 001.86 1.89C8.47 20.77 10.22 21 12 21s3.53-.23 5.14-.68A2 2 0 0019 18.43V5.57a2 2 0 00-1.86-1.89C15.53 3.23 13.78 3 12 3z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 7h.01M12 7h.01M16 7h.01M8 11h.01M12 11h.01M16 11h.01M8 15h8"
/>
</svg>
</button>
<button
onclick={() => (showHelp = true)}
class="p-1 text-gray-500 icon-trans-hover"
title="Help"
>
<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="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</button>
<button
onclick={() => openUrl(DISCORD_URL)}
class="p-1 text-gray-500 icon-trans-hover"
title="Join our Discord"
>
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
<path
d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"
/>
</svg>
</button>
{#if $loopStatus === "running" || $loopStatus === "paused"}
<span
class="text-xs px-2 py-0.5 rounded-full border shrink-0 {$loopStatus === 'running'
? 'bg-blue-500/20 text-blue-400 border-blue-500/30 animate-pulse'
: 'bg-amber-500/20 text-amber-400 border-amber-500/30'}"
title="Task loop {$loopStatus}"
>
Loop {$loopStatus === "running" ? "▶" : "⏸"}
{loopCompletedCount +
($loopStatus === "running" && $loopCurrentIndex >= 0 ? 1 : 0)}/{loopTotalCount}
</span>
{/if}
<NavMenu
{connectionStatus}
{workingDirectory}
{selectedDirectory}
{isSummarising}
{onToggleCompact}
{onToggleAchievements}
onCompactConversation={handleCompactConversation}
onStartFreshWithContext={handleStartFreshWithContext}
/>
{#if appVersion}
<span class="text-xs text-gray-600">v{appVersion}</span>
{/if}
{#if showStats}
<div class="absolute top-full right-0 mt-2 mr-4 z-50">
<StatsDisplay
onRequestSummary={handleCompactConversation}
onStartFreshWithContext={handleStartFreshWithContext}
{isSummarising}
/>
</div>
{/if}
{#if connectionStatus === "connected"}
<button
onclick={handleDisconnect}
@@ -692,57 +469,12 @@
</div>
</div>
{#if showStats}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="fixed inset-0 z-40" onclick={() => (showStats = false)}></div>
<div class="fixed top-14 right-4 z-50 max-h-[calc(100vh-4rem)] overflow-y-auto">
<StatsDisplay
onRequestSummary={handleCompactConversation}
onStartFreshWithContext={handleStartFreshWithContext}
{isSummarising}
/>
</div>
{/if}
{#if showAbout}
<AboutPanel onClose={() => (showAbout = false)} />
{/if}
{#if showHelp}
<HelpPanel onClose={() => (showHelp = false)} />
{/if}
{#if showKeyboardShortcuts}
<KeyboardShortcutsModal onClose={() => (showKeyboardShortcuts = false)} />
{/if}
{#if showSessionHistory}
<SessionHistoryPanel onClose={() => (showSessionHistory = false)} />
{/if}
{#if showTodoPanel}
<TodoPanel onClose={() => (showTodoPanel = false)} />
{/if}
{#if showGitPanel}
<GitPanel isOpen={showGitPanel} onClose={() => (showGitPanel = false)} />
{/if}
{#if showProfile}
<ProfilePanel onClose={() => (showProfile = false)} />
{/if}
{#if showAgentMonitor}
<AgentMonitorPanel isOpen={showAgentMonitor} onClose={() => (showAgentMonitor = false)} />
{/if}
{#if showPluginPanel}
<PluginManagementPanel onClose={() => (showPluginPanel = false)} />
{/if}
{#if showMcpPanel}
<McpManagementPanel onClose={() => (showMcpPanel = false)} />
{#if showWorkspaceTrust && pendingHookInfo}
<WorkspaceTrustModal
hookInfo={pendingHookInfo}
onTrust={handleTrustAndConnect}
onCancel={handleCancelConnect}
/>
{/if}
<style>
@@ -753,18 +485,11 @@
gap: 0.5rem;
}
/* Make all icon buttons shrink but not grow */
/* Make all buttons shrink but not grow */
.status-bar button {
flex-shrink: 0;
}
/* Hide version text on very small screens */
@media (max-width: 640px) {
.status-bar button span:last-of-type {
display: none;
}
}
/* Stack left and right sections on very small screens */
@media (max-width: 768px) {
.status-bar {
+90
View File
@@ -0,0 +1,90 @@
/**
* 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
* - Renders the NavMenu component for all toolbar actions
*
* 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
* - [ ] NavMenu trigger button visible in the status bar
*/
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");
});
});
+780
View File
@@ -0,0 +1,780 @@
<script lang="ts">
import { get } from "svelte/store";
import { open } from "@tauri-apps/plugin-dialog";
import { invoke } from "@tauri-apps/api/core";
import {
taskLoopStore,
getReadyTasks,
computeWaves,
isTaskBlocked,
buildTaskPrompt,
buildAutoCommitPrompt,
normalizeToUnixPath,
type TaskLoopTask,
} from "$lib/stores/taskLoop";
import { claudeStore } from "$lib/stores/claude";
import { configStore } from "$lib/stores/config";
import { PROJECT_CONTEXT_SYSTEM_ADDENDUM } from "$lib/stores/projectContext";
import type { CharacterState } from "$lib/types/states";
interface Props {
onClose: () => void;
onBackToWorkflow?: () => void;
}
const { onClose, onBackToWorkflow }: Props = $props();
const tasks = $derived(taskLoopStore.tasks);
const loopStatus = $derived(taskLoopStore.loopStatus);
const sourceFile = $derived(taskLoopStore.sourceFile);
const conversations = $derived(claudeStore.conversations);
const concurrencyLimit = $derived(taskLoopStore.concurrencyLimit);
const config = $derived(configStore.config);
// Per-task orchestration phases (panel-local, not persisted)
type LoopPhase = "waiting_for_connection" | "waiting_for_completion" | "waiting_for_auto_commit";
let activePhases = $state<Record<number, LoopPhase>>({});
let taskEverStartedMap = $state<Record<number, boolean>>({});
let commitEverStartedMap = $state<Record<number, boolean>>({});
let isLoading = $state(false);
let errorMessage = $state<string | null>(null);
let sessionTimestamp = $state("");
let showSettings = $state(false);
const completedCount = $derived($tasks.filter((t) => t.status === "completed").length);
const failedCount = $derived($tasks.filter((t) => t.status === "failed").length);
const blockedCount = $derived($tasks.filter((t) => t.status === "blocked").length);
const runningCount = $derived($tasks.filter((t) => t.status === "running").length);
const totalCount = $derived($tasks.length);
const waves = $derived(computeWaves($tasks));
const multiWave = $derived(waves.length > 1);
const workingStates: CharacterState[] = ["thinking", "typing", "coding", "searching", "mcp"];
// Watch all active tasks' conversations for state transitions
$effect(() => {
for (const [idxStr, phase] of Object.entries(activePhases)) {
const taskIdx = Number(idxStr);
const taskList = $tasks;
if (taskIdx < 0 || taskIdx >= taskList.length) continue;
const currentTask = taskList[taskIdx];
if (!currentTask.conversationId) continue;
const conv = $conversations.get(currentTask.conversationId);
if (!conv) continue;
if (phase === "waiting_for_connection" && conv.connectionStatus === "connected") {
activePhases = { ...activePhases, [taskIdx]: "waiting_for_completion" };
taskEverStartedMap = { ...taskEverStartedMap, [taskIdx]: false };
void sendTaskPrompt(currentTask, taskIdx, taskList.length);
continue;
}
if (phase === "waiting_for_completion") {
if (workingStates.includes(conv.characterState)) {
taskEverStartedMap = { ...taskEverStartedMap, [taskIdx]: true };
}
if (taskEverStartedMap[taskIdx] && conv.characterState === "idle") {
taskEverStartedMap = Object.fromEntries(
Object.entries(taskEverStartedMap).filter(([k]) => Number(k) !== taskIdx)
);
const autoCommit = get(configStore.config).task_loop_auto_commit;
if (autoCommit) {
activePhases = { ...activePhases, [taskIdx]: "waiting_for_auto_commit" };
commitEverStartedMap = { ...commitEverStartedMap, [taskIdx]: false };
void sendAutoCommitPrompt(currentTask, taskIdx);
} else {
activePhases = Object.fromEntries(
Object.entries(activePhases).filter(([k]) => Number(k) !== taskIdx)
);
void onTaskCompleted(taskIdx, "completed");
}
}
}
if (phase === "waiting_for_auto_commit") {
if (workingStates.includes(conv.characterState)) {
commitEverStartedMap = { ...commitEverStartedMap, [taskIdx]: true };
}
if (commitEverStartedMap[taskIdx] && conv.characterState === "idle") {
activePhases = Object.fromEntries(
Object.entries(activePhases).filter(([k]) => Number(k) !== taskIdx)
);
commitEverStartedMap = Object.fromEntries(
Object.entries(commitEverStartedMap).filter(([k]) => Number(k) !== taskIdx)
);
void onTaskCompleted(taskIdx, "completed");
}
}
}
});
async function sendTaskPrompt(task: TaskLoopTask, taskIdx: number, total: number): Promise<void> {
const prompt = buildTaskPrompt(task, taskIdx + 1, total);
try {
await invoke("send_prompt", {
conversationId: task.conversationId,
message: prompt,
});
} catch (error) {
console.error("Failed to send task prompt:", error);
activePhases = Object.fromEntries(
Object.entries(activePhases).filter(([k]) => Number(k) !== taskIdx)
);
void onTaskCompleted(taskIdx, "failed");
}
}
async function sendAutoCommitPrompt(task: TaskLoopTask, taskIdx: number): Promise<void> {
const cfg = get(configStore.config);
const prompt = buildAutoCommitPrompt(
task,
cfg.task_loop_commit_prefix || "feat",
cfg.task_loop_include_summary,
sessionTimestamp
);
try {
await invoke("send_prompt", {
conversationId: task.conversationId,
message: prompt,
});
} catch (error) {
console.error("Failed to send auto-commit prompt:", error);
// Non-blocking: still mark task as completed even if commit prompt fails
activePhases = Object.fromEntries(
Object.entries(activePhases).filter(([k]) => Number(k) !== taskIdx)
);
void onTaskCompleted(taskIdx, "completed");
}
}
async function onTaskCompleted(taskIdx: number, status: "completed" | "failed"): Promise<void> {
taskLoopStore.setTaskStatus(taskIdx, status);
const currentLoopStatus = get(taskLoopStore.loopStatus);
if (currentLoopStatus !== "running") return;
// If any tasks are still active, wait for them
if (Object.keys(activePhases).length > 0) return;
await advanceToNextWave();
}
async function advanceToNextWave(): Promise<void> {
const currentLoopStatus = get(taskLoopStore.loopStatus);
if (currentLoopStatus !== "running") return;
// Mark any newly-blocked tasks
const taskList = get(taskLoopStore.tasks);
taskList.forEach((task, i) => {
if (task.status === "pending" && isTaskBlocked(task, taskList)) {
taskLoopStore.setTaskStatus(i, "blocked");
}
});
const updatedTaskList = get(taskLoopStore.tasks);
const limit = get(taskLoopStore.concurrencyLimit);
const readyIndices = getReadyTasks(updatedTaskList, limit);
if (readyIndices.length === 0) {
taskLoopStore.setLoopStatus("stopped");
return;
}
await Promise.all(readyIndices.map((i) => startTask(i, updatedTaskList)));
}
async function startTask(taskIdx: number, taskList: TaskLoopTask[]): Promise<void> {
const task = taskList[taskIdx];
const cfg = get(configStore.config);
const allAllowedTools = [
...new Set([...get(claudeStore.grantedTools), ...(cfg.auto_granted_tools ?? [])]),
];
const filePath = get(taskLoopStore.sourceFile);
const workingDir = filePath.split("/").slice(0, -1).join("/");
const conversationId = claudeStore.createConversation(task.title);
void claudeStore.switchConversation(conversationId);
taskLoopStore.setTaskConversationId(taskIdx, conversationId);
taskLoopStore.setTaskStatus(taskIdx, "running");
activePhases = { ...activePhases, [taskIdx]: "waiting_for_connection" };
taskEverStartedMap = { ...taskEverStartedMap, [taskIdx]: false };
try {
await invoke("start_claude", {
conversationId,
options: {
working_dir: workingDir,
model: cfg.model ?? null,
api_key: cfg.api_key ?? null,
custom_instructions: (cfg.custom_instructions ?? "") + PROJECT_CONTEXT_SYSTEM_ADDENDUM,
mcp_servers_json: cfg.mcp_servers_json ?? null,
allowed_tools: allAllowedTools,
use_worktree: cfg.use_worktree ?? false,
disable_1m_context: cfg.disable_1m_context ?? false,
max_output_tokens: cfg.max_output_tokens ?? null,
},
});
} catch (error) {
console.error("Failed to start Claude for task:", error);
activePhases = Object.fromEntries(
Object.entries(activePhases).filter(([k]) => Number(k) !== taskIdx)
);
void onTaskCompleted(taskIdx, "failed");
}
}
async function handleImportFile(): Promise<void> {
const selected = await open({
title: "Select hikari-tasks.json",
filters: [{ name: "Hikari Tasks", extensions: ["json"] }],
multiple: false,
});
if (!selected || typeof selected !== "string") return;
isLoading = true;
errorMessage = null;
try {
await taskLoopStore.loadFile(normalizeToUnixPath(selected));
} catch (error) {
errorMessage = `Failed to load file: ${error instanceof Error ? error.message : String(error)}`;
} finally {
isLoading = false;
}
}
async function handleStart(): Promise<void> {
const taskList = get(taskLoopStore.tasks);
const limit = get(taskLoopStore.concurrencyLimit);
const readyIndices = getReadyTasks(taskList, limit);
if (readyIndices.length === 0) return;
sessionTimestamp = new Date().toISOString();
taskLoopStore.setLoopStatus("running");
await Promise.all(readyIndices.map((i) => startTask(i, taskList)));
}
function handlePause(): void {
taskLoopStore.setLoopStatus("paused");
}
async function handleResume(): Promise<void> {
taskLoopStore.setLoopStatus("running");
if (Object.keys(activePhases).length === 0) {
await advanceToNextWave();
}
}
async function handleStop(): Promise<void> {
taskLoopStore.setLoopStatus("stopped");
// Stop all active Claude processes
const taskList = get(taskLoopStore.tasks);
const stopPromises = Object.keys(activePhases).map(async (idxStr) => {
const taskIdx = Number(idxStr);
const task = taskList[taskIdx];
if (task?.conversationId) {
try {
await invoke("stop_claude", { conversationId: task.conversationId });
} catch (error) {
console.error("Failed to stop Claude for task:", error);
}
if (task.status === "running") {
taskLoopStore.setTaskStatus(taskIdx, "failed");
}
}
});
await Promise.all(stopPromises);
activePhases = {};
taskEverStartedMap = {};
commitEverStartedMap = {};
}
function handleReset(): void {
taskLoopStore.reset();
activePhases = {};
taskEverStartedMap = {};
commitEverStartedMap = {};
errorMessage = null;
sessionTimestamp = "";
}
function statusColour(status: TaskLoopTask["status"]): string {
switch (status) {
case "pending":
return "text-[var(--text-tertiary)]";
case "running":
return "text-blue-400";
case "completed":
return "text-green-400";
case "failed":
return "text-red-400";
case "blocked":
return "text-[var(--text-tertiary)] opacity-50";
}
}
function statusIcon(status: TaskLoopTask["status"]): string {
switch (status) {
case "pending":
return "○";
case "running":
return "⟳";
case "completed":
return "✓";
case "failed":
return "✗";
case "blocked":
return "⊘";
}
}
function priorityColour(priority: TaskLoopTask["priority"]): string {
switch (priority) {
case "high":
return "bg-red-500/20 text-red-400 border-red-500/30";
case "medium":
return "bg-amber-500/20 text-amber-400 border-amber-500/30";
case "low":
return "bg-green-500/20 text-green-400 border-green-500/30";
}
}
const hasPendingTasks = $derived($tasks.some((t) => t.status === "pending"));
async function toggleAutoCommit(): Promise<void> {
await configStore.updateConfig({ task_loop_auto_commit: !$config.task_loop_auto_commit });
}
async function toggleIncludeSummary(): Promise<void> {
await configStore.updateConfig({
task_loop_include_summary: !$config.task_loop_include_summary,
});
}
async function updateCommitPrefix(value: string): Promise<void> {
await configStore.updateConfig({ task_loop_commit_prefix: value });
}
</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-[90vh] flex flex-col"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
role="dialog"
aria-labelledby="task-loop-panel-title"
tabindex="-1"
>
<!-- Header -->
<div class="flex items-center justify-between p-6 pb-4 border-b border-[var(--border-color)]">
<div class="flex items-center gap-3">
<h2 id="task-loop-panel-title" class="text-xl font-semibold text-[var(--text-primary)]">
Task Loop
</h2>
{#if $loopStatus === "running"}
<span
class="text-xs px-2 py-0.5 rounded-full bg-blue-500/20 text-blue-400 border border-blue-500/30 animate-pulse"
>
{runningCount} running · {completedCount}/{totalCount} done
</span>
{:else if $loopStatus === "paused"}
<span
class="text-xs px-2 py-0.5 rounded-full bg-amber-500/20 text-amber-400 border border-amber-500/30"
>
Paused
</span>
{:else if $loopStatus === "stopped" && totalCount > 0}
<span
class="text-xs px-2 py-0.5 rounded-full bg-[var(--bg-secondary)] text-[var(--text-tertiary)] border border-[var(--border-color)]"
>
{completedCount}/{totalCount} completed{failedCount > 0
? `, ${failedCount} failed`
: ""}{blockedCount > 0 ? `, ${blockedCount} blocked` : ""}
</span>
{/if}
</div>
<div class="flex items-center gap-2">
{#if onBackToWorkflow}
<button
onclick={onBackToWorkflow}
class="px-2 py-1 text-xs text-[var(--text-secondary)] hover:text-[var(--text-primary)] bg-[var(--bg-secondary)] hover:bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded-md transition-colors"
>
← Workflow
</button>
{/if}
<button
onclick={() => (showSettings = !showSettings)}
class="p-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
aria-label="Toggle settings"
aria-pressed={showSettings}
>
<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="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
</button>
<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>
<!-- Settings panel (collapsible) -->
{#if showSettings}
<div
class="px-6 py-4 border-b border-[var(--border-color)] bg-[var(--bg-secondary)] flex flex-col gap-3"
>
<p class="text-xs font-semibold text-[var(--text-tertiary)] uppercase tracking-wide">
Auto-commit Settings
</p>
<!-- Auto-commit toggle -->
<label class="flex items-center gap-3 cursor-pointer">
<div
class="relative w-9 h-5 rounded-full transition-colors {$config.task_loop_auto_commit
? 'bg-[var(--accent-primary)]'
: 'bg-[var(--bg-tertiary)] border border-[var(--border-color)]'}"
>
<div
class="absolute top-0.5 w-4 h-4 rounded-full bg-white shadow transition-transform {$config.task_loop_auto_commit
? 'left-4'
: 'left-0.5'}"
></div>
<input
type="checkbox"
class="sr-only"
checked={$config.task_loop_auto_commit}
onchange={toggleAutoCommit}
/>
</div>
<span class="text-sm text-[var(--text-primary)]">Auto-commit on task completion</span>
</label>
{#if $config.task_loop_auto_commit}
<!-- Commit prefix -->
<div class="flex items-center gap-3">
<label
class="text-sm text-[var(--text-secondary)] shrink-0 w-28"
for="commit-prefix-input"
>
Commit prefix
</label>
<input
id="commit-prefix-input"
type="text"
value={$config.task_loop_commit_prefix}
onchange={(e) => updateCommitPrefix((e.target as HTMLInputElement).value)}
placeholder="feat"
class="flex-1 px-2 py-1 text-sm bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent-primary)]"
/>
<span class="text-xs text-[var(--text-tertiary)]">: task title</span>
</div>
<!-- Include SUMMARY.md toggle -->
<label class="flex items-center gap-3 cursor-pointer">
<div
class="relative w-9 h-5 rounded-full transition-colors {$config.task_loop_include_summary
? 'bg-[var(--accent-primary)]'
: 'bg-[var(--bg-tertiary)] border border-[var(--border-color)]'}"
>
<div
class="absolute top-0.5 w-4 h-4 rounded-full bg-white shadow transition-transform {$config.task_loop_include_summary
? 'left-4'
: 'left-0.5'}"
></div>
<input
type="checkbox"
class="sr-only"
checked={$config.task_loop_include_summary}
onchange={toggleIncludeSummary}
/>
</div>
<span class="text-sm text-[var(--text-primary)]">Generate SUMMARY.md before commit</span
>
</label>
{/if}
</div>
{/if}
<!-- Body -->
<div class="flex-1 overflow-y-auto p-4 min-h-0">
{#if isLoading}
<div class="flex items-center justify-center py-16 gap-3 text-[var(--text-secondary)]">
<div class="animate-spin text-2xl">⚙️</div>
<span class="text-sm">Loading tasks...</span>
</div>
{:else if errorMessage}
<div class="flex flex-col items-center justify-center py-16 gap-4 text-center">
<p class="text-sm text-red-400">{errorMessage}</p>
<button
onclick={handleImportFile}
class="px-4 py-1.5 text-sm btn-trans-gradient rounded-lg transition-colors"
>
Try Again
</button>
</div>
{:else if totalCount === 0}
<!-- Empty state -->
<div class="flex flex-col items-center justify-center py-16 gap-4 text-center">
<div class="text-4xl">📋</div>
<h3 class="text-lg font-semibold text-[var(--text-primary)]">No Tasks Loaded</h3>
<p class="text-sm text-[var(--text-secondary)] max-w-sm">
Import a <span class="font-mono text-xs">hikari-tasks.json</span> file created by the PRD
Creator to run tasks automatically.
</p>
<button
onclick={handleImportFile}
class="px-4 py-1.5 text-sm btn-trans-gradient rounded-lg transition-colors"
>
Import hikari-tasks.json
</button>
</div>
{:else}
<!-- Source file path -->
<div class="text-xs text-[var(--text-tertiary)] font-mono mb-3 truncate">
{$sourceFile}
</div>
<!-- Wave-grouped task list -->
<div class="flex flex-col gap-4">
{#each waves as waveIndices, waveIdx (waveIdx)}
<div>
{#if multiWave}
<div class="flex items-center gap-2 mb-2">
<span
class="text-xs font-semibold text-[var(--text-tertiary)] uppercase tracking-wide"
>
Wave {waveIdx + 1}
</span>
{#if waveIndices.length > 1}
<span class="text-xs text-[var(--text-tertiary)]">
({waveIndices.length} parallel)
</span>
{/if}
<div class="flex-1 border-t border-[var(--border-color)]"></div>
</div>
{/if}
<div class="flex flex-col gap-2">
{#each waveIndices as taskIdx (taskIdx)}
{@const task = $tasks[taskIdx]}
{#if task}
<div
class="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg p-3 flex items-start gap-3 {task.status ===
'running'
? 'border-blue-500/40 bg-blue-500/5'
: task.status === 'blocked'
? 'opacity-50'
: ''}"
>
<!-- Status icon -->
<span
class="text-sm font-mono mt-0.5 w-4 text-center shrink-0 {statusColour(
task.status
)} {task.status === 'running' ? 'animate-spin' : ''}"
>
{statusIcon(task.status)}
</span>
<!-- Task info -->
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<span class="text-sm font-medium text-[var(--text-primary)] truncate">
{task.title}
</span>
<span
class="text-xs px-1.5 py-0.5 rounded-full border shrink-0 {priorityColour(
task.priority
)}"
>
{task.priority}
</span>
{#if task.status === "running"}
{#if activePhases[taskIdx] === "waiting_for_auto_commit"}
<span class="text-xs text-violet-400 animate-pulse shrink-0"
>● committing</span
>
{:else}
<span class="text-xs text-blue-400 animate-pulse shrink-0"
>● running</span
>
{/if}
{:else if task.status === "blocked"}
<span class="text-xs text-[var(--text-tertiary)] shrink-0">blocked</span
>
{/if}
</div>
<p
class="text-xs text-[var(--text-tertiary)] mt-0.5 line-clamp-2 font-mono"
>
{task.prompt}
</p>
</div>
<!-- Task number -->
<span class="text-xs text-[var(--text-tertiary)] font-mono shrink-0"
>#{taskIdx + 1}</span
>
</div>
{/if}
{/each}
</div>
</div>
{/each}
</div>
{/if}
</div>
<!-- Footer -->
<div
class="flex items-center justify-between p-4 pt-2 border-t border-[var(--border-color)] gap-3"
>
<div class="flex items-center gap-2">
{#if totalCount > 0 && $loopStatus === "idle"}
<button
onclick={handleImportFile}
class="px-3 py-1.5 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] bg-[var(--bg-secondary)] hover:bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded-lg transition-colors"
>
Change File
</button>
<button
onclick={handleReset}
class="px-3 py-1.5 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] bg-[var(--bg-secondary)] hover:bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded-lg transition-colors"
>
Reset
</button>
{:else if $loopStatus === "stopped"}
<button
onclick={handleReset}
class="px-3 py-1.5 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] bg-[var(--bg-secondary)] hover:bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded-lg transition-colors"
>
Reset
</button>
{/if}
<!-- Concurrency limit control -->
{#if totalCount > 0}
<div class="flex items-center gap-1 ml-2">
<span class="text-xs text-[var(--text-tertiary)]">Parallel:</span>
<button
onclick={() => taskLoopStore.setConcurrencyLimit($concurrencyLimit - 1)}
class="w-5 h-5 flex items-center justify-center text-xs text-[var(--text-secondary)] hover:text-[var(--text-primary)] bg-[var(--bg-secondary)] hover:bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded transition-colors"
aria-label="Decrease concurrency limit"
>
</button>
<span class="text-xs font-mono text-[var(--text-primary)] w-4 text-center"
>{$concurrencyLimit}</span
>
<button
onclick={() => taskLoopStore.setConcurrencyLimit($concurrencyLimit + 1)}
class="w-5 h-5 flex items-center justify-center text-xs text-[var(--text-secondary)] hover:text-[var(--text-primary)] bg-[var(--bg-secondary)] hover:bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded transition-colors"
aria-label="Increase concurrency limit"
>
+
</button>
</div>
{/if}
</div>
<div class="flex items-center gap-2">
{#if totalCount === 0}
<!-- no actions until tasks are loaded -->
{:else if $loopStatus === "idle" || $loopStatus === "stopped"}
{#if hasPendingTasks}
<button
onclick={handleStart}
class="px-4 py-1.5 text-sm btn-trans-gradient rounded-lg transition-colors"
>
Start Loop
</button>
{:else}
<span class="text-xs text-[var(--text-tertiary)]">All tasks complete</span>
{/if}
{:else if $loopStatus === "running"}
<button
onclick={handlePause}
class="px-3 py-1.5 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] bg-[var(--bg-secondary)] hover:bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded-lg transition-colors"
>
Pause
</button>
<button
onclick={handleStop}
class="px-3 py-1.5 text-sm text-red-400 hover:text-red-300 bg-red-500/10 hover:bg-red-500/20 border border-red-500/30 rounded-lg transition-colors"
>
Stop
</button>
{:else if $loopStatus === "paused"}
<button
onclick={handleResume}
class="px-4 py-1.5 text-sm btn-trans-gradient rounded-lg transition-colors"
>
Resume
</button>
<button
onclick={handleStop}
class="px-3 py-1.5 text-sm text-red-400 hover:text-red-300 bg-red-500/10 hover:bg-red-500/20 border border-red-500/30 rounded-lg transition-colors"
>
Stop
</button>
{/if}
</div>
</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);
}
}
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>
+172 -15
View File
@@ -1,23 +1,39 @@
<script lang="ts">
import { claudeStore, type TerminalLine } from "$lib/stores/claude";
import { afterUpdate, tick, onMount, onDestroy } from "svelte";
import { get } from "svelte/store";
import { invoke } from "@tauri-apps/api/core";
import ConversationTabs from "./ConversationTabs.svelte";
import Markdown from "./Markdown.svelte";
import HighlightedText from "./HighlightedText.svelte";
import ThinkingBlock from "./ThinkingBlock.svelte";
import ToolCallBlock from "./ToolCallBlock.svelte";
import { searchState, searchQuery } from "$lib/stores/search";
import { clipboardStore } from "$lib/stores/clipboard";
import { shouldHidePaths, maskPaths, showThinkingBlocks } from "$lib/stores/config";
// Virtual windowing constants — keeps the DOM lean during long sessions
const WINDOW_SIZE = 150; // max lines rendered at once
const LOAD_CHUNK = 50; // how many older lines to load when scrolling up
const AVG_LINE_HEIGHT = 60; // rough px estimate per line, used for top spacer
let terminalElement: HTMLDivElement;
let shouldAutoScroll = true;
let lines: TerminalLine[] = [];
let currentSearchQuery = "";
let currentConversationId: string | null = null;
let isRestoringScroll = false;
let windowStart = 0;
let isLoadingMore = false;
let isSwitchingConversation = false;
let searchDebounceTimer: ReturnType<typeof setTimeout> | null = null;
searchQuery.subscribe((value) => {
currentSearchQuery = value;
if (searchDebounceTimer) clearTimeout(searchDebounceTimer);
searchDebounceTimer = setTimeout(() => {
currentSearchQuery = value;
}, 150);
});
let hidePaths = false;
@@ -45,18 +61,42 @@
currentConversationId = newId;
// Restore scroll position for the new conversation after DOM updates
// Guard the $: reactive auto-scroll block from firing with stale `lines`
// (the old conversation's data) during the switch. Without this, Svelte's
// reactive system can re-run the window-advance block before `terminalLines`
// has recomputed for the new conversation, overriding our correct windowStart.
isSwitchingConversation = true;
// Read the new conversation's lines directly from the store — the derived
// `terminalLines` store (and thus `lines`) may not have recomputed yet when
// this subscriber fires, so using `lines` here would give stale data.
const newConvLines = get(claudeStore.conversations).get(newId)?.terminalLines ?? [];
const savedPosition = claudeStore.getScrollPosition(newId);
if (savedPosition === -1) {
// Will auto-scroll: pin the window to the tail of the new conversation
shouldAutoScroll = true;
windowStart = Math.max(0, newConvLines.length - WINDOW_SIZE);
} else {
// Will restore a specific position: always start from the top of history
shouldAutoScroll = false;
windowStart = 0;
}
// Block the scroll handler during the entire DOM transition — scroll events
// can fire mid-tick when the content changes, and handleScroll would see
// scrollTop not at the bottom yet and set shouldAutoScroll = false, breaking
// autoscroll for the new conversation permanently.
isRestoringScroll = true;
// Restore scroll position for the new conversation after DOM updates.
// Clear the switch guard first so the $: block can react to new lines
// arriving after the switch settles.
await tick();
isSwitchingConversation = false;
if (terminalElement) {
const savedPosition = claudeStore.getScrollPosition(newId);
isRestoringScroll = true;
if (savedPosition === -1) {
// Auto-scroll to bottom
shouldAutoScroll = true;
terminalElement.scrollTop = terminalElement.scrollHeight;
} else {
// Restore to saved position
shouldAutoScroll = false;
terminalElement.scrollTop = savedPosition;
}
// Small delay to prevent the scroll handler from overriding our restore
@@ -66,10 +106,30 @@
}
});
function handleScroll() {
async function handleScroll() {
if (!terminalElement || isRestoringScroll) return;
const { scrollTop, scrollHeight, clientHeight } = terminalElement;
shouldAutoScroll = scrollHeight - scrollTop - clientHeight < 100;
// Load older lines when the user scrolls near the top of the visible window.
// Use windowStart * AVG_LINE_HEIGHT (the spacer height) as the baseline so
// we trigger at the top of the rendered content, not the absolute container top.
if (scrollTop < windowStart * AVG_LINE_HEIGHT + 300 && windowStart > 0 && !isLoadingMore) {
isLoadingMore = true;
const prevScrollHeight = terminalElement.scrollHeight;
const prevScrollTop = terminalElement.scrollTop;
windowStart = Math.max(0, windowStart - LOAD_CHUNK);
await tick();
if (terminalElement) {
// Compensate for the new items pushing content down
terminalElement.scrollTop =
prevScrollTop + (terminalElement.scrollHeight - prevScrollHeight);
}
isLoadingMore = false;
}
}
afterUpdate(() => {
@@ -92,6 +152,14 @@
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";
}
@@ -109,6 +177,12 @@
return "[tool]";
case "error":
return "[error]";
case "rate-limit":
return "[rate-limit]";
case "worktree":
return "[worktree]";
case "config-change":
return "[config]";
default:
return "";
}
@@ -121,6 +195,19 @@
});
}
// Visible slice — only render lines within the current window
$: visibleLines = lines.slice(windowStart, windowStart + WINDOW_SIZE);
// Height of the invisible spacer above the visible window
$: topSpacerHeight = windowStart * AVG_LINE_HEIGHT;
// Advance the window forward when auto-scrolling and new lines overflow it.
// Skip during conversation switches — `lines` may still hold the previous
// conversation's data, which would push windowStart past the new conv's end.
$: if (shouldAutoScroll && !isSwitchingConversation && lines.length > windowStart + WINDOW_SIZE) {
windowStart = Math.max(0, lines.length - WINDOW_SIZE);
}
$: {
if (currentSearchQuery && lines.length > 0) {
const escapedQuery = currentSearchQuery.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
@@ -162,6 +249,7 @@
if (terminalElement) {
terminalElement.removeEventListener("copy", handleCopy);
}
if (searchDebounceTimer) clearTimeout(searchDebounceTimer);
});
// Copy message content to clipboard
@@ -187,6 +275,11 @@
copiedMessageId = null;
}, 2000);
}
async function handleCompact() {
if (!currentConversationId) return;
await invoke("send_prompt", { conversationId: currentConversationId, message: "/compact" });
}
</script>
<div
@@ -209,18 +302,36 @@
bind:this={terminalElement}
onscroll={handleScroll}
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}
<div class="terminal-waiting italic">
Waiting for Claude... Type a message below to start!
</div>
{:else}
{#each lines as line (line.id)}
<div style="height: {topSpacerHeight}px" aria-hidden="true"></div>
{#if windowStart > 0}
<div class="terminal-older-indicator">
{windowStart} older {windowStart === 1 ? "message" : "messages"} — scroll up to load
</div>
{/if}
{#each visibleLines as line (line.id)}
{#if line.type === "thinking"}
{#if showThinking}
<ThinkingBlock content={line.content} timestamp={line.timestamp} />
{/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}
<div
class="terminal-line mb-2 {getLineClass(line.type)} relative group"
@@ -259,10 +370,11 @@
{#if getLinePrefix(line.type)}
<span class="terminal-prefix mr-2">{getLinePrefix(line.type)}</span>
{/if}
{#if line.toolName}
<span class="terminal-tool-name mr-2">[{line.toolName}]</span>
{/if}
{#if line.type === "assistant" || line.type === "user"}
{#if line.type === "compact-prompt"}
<button class="compact-action-btn" onclick={handleCompact}>
⚡ Compact Conversation
</button>
{:else if line.type === "assistant" || line.type === "user"}
<div class="message-content-wrapper">
<Markdown
content={maskPaths(line.content, hidePaths)}
@@ -329,6 +441,42 @@
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 {
color: var(--text-primary);
}
@@ -357,6 +505,15 @@
color: var(--text-secondary);
}
.terminal-older-indicator {
color: var(--text-tertiary, #6b7280);
font-size: 0.75rem;
text-align: center;
padding: 0.25rem 0;
margin-bottom: 0.5rem;
border-bottom: 1px solid var(--border-color);
}
.terminal-header-text {
color: var(--text-secondary);
}
+368
View File
@@ -0,0 +1,368 @@
/**
* 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) + "…";
}
// Virtual windowing helpers — mirror the logic in Terminal.svelte
const WINDOW_SIZE = 150;
const LOAD_CHUNK = 50;
const AVG_LINE_HEIGHT = 60;
/** Returns the windowStart index when auto-scrolling to the bottom. */
function autoScrollWindowStart(linesLength: number, windowSize: number): number {
return Math.max(0, linesLength - windowSize);
}
/** Returns the new windowStart after loading LOAD_CHUNK older messages. */
function olderWindowStart(currentStart: number, chunkSize: number): number {
return Math.max(0, currentStart - chunkSize);
}
/** Returns the height in pixels of the invisible top spacer. */
function topSpacerHeight(windowStart: number, avgLineHeight: number): number {
return windowStart * avgLineHeight;
}
// ---
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);
});
});
describe("autoScrollWindowStart", () => {
it("returns 0 when lines fit within the window", () => {
expect(autoScrollWindowStart(50, WINDOW_SIZE)).toBe(0);
});
it("returns 0 when lines exactly fill the window", () => {
expect(autoScrollWindowStart(WINDOW_SIZE, WINDOW_SIZE)).toBe(0);
});
it("advances when lines exceed the window size", () => {
expect(autoScrollWindowStart(200, WINDOW_SIZE)).toBe(50);
});
it("never returns a negative value", () => {
expect(autoScrollWindowStart(0, WINDOW_SIZE)).toBe(0);
});
it("keeps last WINDOW_SIZE lines visible for large collections", () => {
expect(autoScrollWindowStart(500, WINDOW_SIZE)).toBe(350);
});
});
describe("olderWindowStart", () => {
it("subtracts the chunk size from the current start", () => {
expect(olderWindowStart(100, LOAD_CHUNK)).toBe(50);
});
it("never returns a negative value when chunk is larger than start", () => {
expect(olderWindowStart(20, LOAD_CHUNK)).toBe(0);
});
it("returns 0 when current start is 0", () => {
expect(olderWindowStart(0, LOAD_CHUNK)).toBe(0);
});
it("returns 0 when current start exactly equals the chunk size", () => {
expect(olderWindowStart(LOAD_CHUNK, LOAD_CHUNK)).toBe(0);
});
it("correctly loads a partial chunk near the beginning", () => {
expect(olderWindowStart(30, LOAD_CHUNK)).toBe(0);
});
});
describe("topSpacerHeight", () => {
it("returns 0 when windowStart is 0", () => {
expect(topSpacerHeight(0, AVG_LINE_HEIGHT)).toBe(0);
});
it("multiplies windowStart by avgLineHeight", () => {
expect(topSpacerHeight(10, AVG_LINE_HEIGHT)).toBe(600);
});
it("scales linearly with windowStart", () => {
expect(topSpacerHeight(50, AVG_LINE_HEIGHT)).toBe(3000);
expect(topSpacerHeight(100, AVG_LINE_HEIGHT)).toBe(6000);
expect(topSpacerHeight(150, AVG_LINE_HEIGHT)).toBe(9000);
});
it("uses the provided avgLineHeight rather than a hard-coded value", () => {
expect(topSpacerHeight(5, 100)).toBe(500);
expect(topSpacerHeight(5, 80)).toBe(400);
});
});
describe("virtual windowing constants", () => {
it("WINDOW_SIZE is 150", () => {
expect(WINDOW_SIZE).toBe(150);
});
it("LOAD_CHUNK is 50", () => {
expect(LOAD_CHUNK).toBe(50);
});
it("LOAD_CHUNK is smaller than WINDOW_SIZE", () => {
expect(LOAD_CHUNK).toBeLessThan(WINDOW_SIZE);
});
it("AVG_LINE_HEIGHT is a positive number", () => {
expect(AVG_LINE_HEIGHT).toBeGreaterThan(0);
});
});
@@ -73,7 +73,7 @@
onClose();
}}
>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
<div
bind:this={menuElement}
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,
mcp_servers_json: config.mcp_servers_json || null,
allowed_tools: grantedToolsList,
use_worktree: config.use_worktree ?? false,
disable_1m_context: config.disable_1m_context ?? false,
},
});
+833
View File
@@ -0,0 +1,833 @@
<script lang="ts">
import { onMount } from "svelte";
import { get } from "svelte/store";
import { invoke } from "@tauri-apps/api/core";
import {
workflowStore,
buildDiscussPrompt,
buildVerifyPrompt,
canAdvancePhase,
canGoBack,
getPhaseLabel,
type WorkflowPhase,
type CriterionStatus,
} from "$lib/stores/workflow";
import { prdStore } from "$lib/stores/prd";
import { taskLoopStore, countByStatus } from "$lib/stores/taskLoop";
import { characterState } from "$lib/stores/character";
import { claudeStore } from "$lib/stores/claude";
interface Props {
onClose: () => void;
onOpenPrdPanel: () => void;
onOpenTaskLoop: () => void;
workingDirectory: string;
}
const { onClose, onOpenPrdPanel, onOpenTaskLoop, workingDirectory }: Props = $props();
const workflowState = $derived(workflowStore.state);
const prdTasks = $derived(prdStore.tasks);
const prdIsLoaded = $derived(prdStore.isLoaded);
const loopTasks = $derived(taskLoopStore.tasks);
const loopStatus = $derived(taskLoopStore.loopStatus);
let previousCharacterState = $state<string>("idle");
let isWaitingForDiscuss = $state(false);
let isWaitingForVerify = $state(false);
let contextContent = $state<string | null>(null);
let verifyContent = $state<string | null>(null);
let newCriterionText = $state("");
let isLoadingContext = $state(false);
let isLoadingVerify = $state(false);
const PHASES: WorkflowPhase[] = [1, 2, 3, 4];
onMount(async () => {
await workflowStore.loadState(workingDirectory);
await prdStore.loadFromFile(workingDirectory);
await tryLoadContextFile();
await tryLoadVerifyFile();
});
// Watch characterState to detect when Claude finishes working
$effect(() => {
const currentState = $characterState;
if (isWaitingForDiscuss && previousCharacterState !== "idle" && currentState === "idle") {
isWaitingForDiscuss = false;
void tryLoadContextFile().then(() => {
if (contextContent !== null) {
workflowStore.markContextCaptured();
void workflowStore.saveState(workingDirectory);
}
});
}
if (isWaitingForVerify && previousCharacterState !== "idle" && currentState === "idle") {
isWaitingForVerify = false;
void tryLoadVerifyFile().then(() => {
if (verifyContent !== null) {
workflowStore.completeVerification(verifyContent);
void workflowStore.saveState(workingDirectory);
}
});
}
previousCharacterState = currentState;
});
async function tryLoadContextFile(): Promise<void> {
isLoadingContext = true;
try {
const content = await invoke<string>("read_file_content", {
path: `${workingDirectory}/CONTEXT.md`,
});
contextContent = content;
} catch {
contextContent = null;
} finally {
isLoadingContext = false;
}
}
async function tryLoadVerifyFile(): Promise<void> {
isLoadingVerify = true;
try {
const content = await invoke<string>("read_file_content", {
path: `${workingDirectory}/VERIFY.md`,
});
verifyContent = content;
} catch {
verifyContent = null;
} finally {
isLoadingVerify = false;
}
}
async function handleStartDiscussion(): Promise<void> {
const description = $workflowState.discuss.description.trim();
if (!description) return;
const conversationId = get(claudeStore.activeConversationId);
if (!conversationId) return;
const prompt = buildDiscussPrompt(description);
isWaitingForDiscuss = true;
await invoke("send_prompt", { conversationId, message: prompt });
}
async function handleQuickCaptureContext(): Promise<void> {
const description = $workflowState.discuss.description.trim();
if (!description) return;
const content = `# CONTEXT\n\n## Goal\n\n${description}\n`;
await invoke("write_file_content", { path: `${workingDirectory}/CONTEXT.md`, content });
contextContent = content;
workflowStore.markContextCaptured();
await workflowStore.saveState(workingDirectory);
}
async function handleApprovePlan(): Promise<void> {
workflowStore.approvePlan();
await workflowStore.saveState(workingDirectory);
}
async function handleCompleteExecution(): Promise<void> {
workflowStore.completeExecution();
await workflowStore.saveState(workingDirectory);
}
async function handleExtractCriteria(): Promise<void> {
if (!contextContent) return;
const lines = contextContent.split("\n");
let inCriteria = false;
for (const line of lines) {
const trimmed = line.trim();
if (/^##\s*acceptance criteria/i.test(trimmed)) {
inCriteria = true;
continue;
}
if (inCriteria && /^##/.test(trimmed)) {
inCriteria = false;
continue;
}
if (inCriteria && /^\d+\./.test(trimmed)) {
const text = trimmed.replace(/^\d+\.\s*/, "").trim();
if (text) workflowStore.addCriterion(text);
}
}
await workflowStore.saveState(workingDirectory);
}
async function handleRunVerification(): Promise<void> {
const criteria = $workflowState.verify.criteria;
const conversationId = get(claudeStore.activeConversationId);
if (!conversationId) return;
const prompt = buildVerifyPrompt(criteria);
isWaitingForVerify = true;
await invoke("send_prompt", { conversationId, message: prompt });
}
async function handleAddCriterion(): Promise<void> {
const text = newCriterionText.trim();
if (!text) return;
workflowStore.addCriterion(text);
newCriterionText = "";
await workflowStore.saveState(workingDirectory);
}
async function handleRemoveCriterion(id: string): Promise<void> {
workflowStore.removeCriterion(id);
await workflowStore.saveState(workingDirectory);
}
async function handleSetCriterionStatus(id: string, status: CriterionStatus): Promise<void> {
workflowStore.updateCriterionStatus(id, status);
await workflowStore.saveState(workingDirectory);
}
async function handleAdvance(): Promise<void> {
const current = $workflowState.currentPhase;
if (current < 4) {
workflowStore.setPhase((current + 1) as WorkflowPhase);
await workflowStore.saveState(workingDirectory);
} else {
onClose();
}
}
async function handleBack(): Promise<void> {
const current = $workflowState.currentPhase;
if (canGoBack(current)) {
workflowStore.setPhase((current - 1) as WorkflowPhase);
await workflowStore.saveState(workingDirectory);
}
}
async function handleReset(): Promise<void> {
workflowStore.reset();
contextContent = null;
verifyContent = null;
await workflowStore.saveState(workingDirectory);
}
function completedTaskCount(): number {
return countByStatus($loopTasks, "completed");
}
function failedTaskCount(): number {
return countByStatus($loopTasks, "failed");
}
function allTasksDone(): boolean {
const tasks = $loopTasks;
return (
tasks.length > 0 && tasks.every((t) => t.status === "completed" || t.status === "failed")
);
}
</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-[90vh] flex flex-col"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
role="dialog"
aria-labelledby="workflow-panel-title"
tabindex="-1"
>
<!-- Header: title + phase stepper + quick mode -->
<div class="flex flex-col gap-3 p-6 pb-4 border-b border-[var(--border-color)]">
<div class="flex items-center justify-between">
<h2 id="workflow-panel-title" class="text-xl font-semibold text-[var(--text-primary)]">
Guided Workflow
</h2>
<div class="flex items-center gap-3">
<label class="flex items-center gap-2 cursor-pointer select-none">
<span class="text-xs text-[var(--text-secondary)]">Quick Mode</span>
<button
role="switch"
aria-checked={$workflowState.quickMode}
onclick={() => {
workflowStore.setQuickMode(!$workflowState.quickMode);
void workflowStore.saveState(workingDirectory);
}}
class="w-9 h-5 rounded-full transition-colors {$workflowState.quickMode
? 'bg-[var(--accent-primary)]'
: 'bg-[var(--bg-tertiary)]'} relative"
>
<span
class="absolute top-0.5 left-0.5 w-4 h-4 rounded-full bg-white shadow transition-transform {$workflowState.quickMode
? 'translate-x-4'
: 'translate-x-0'}"
></span>
</button>
</label>
<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>
<!-- Phase stepper -->
<div class="flex items-center gap-1">
{#each PHASES as phase (phase)}
{@const isActive = $workflowState.currentPhase === phase}
{@const isDone = $workflowState.currentPhase > phase}
<button
onclick={() => {
workflowStore.setPhase(phase);
void workflowStore.saveState(workingDirectory);
}}
aria-label="Go to phase {phase}: {getPhaseLabel(phase)}"
class="flex items-center gap-1.5 px-2 py-1 rounded-md text-xs transition-colors {isActive
? 'bg-[var(--accent-primary)]/20 text-[var(--accent-primary)] font-semibold'
: isDone
? 'text-green-400'
: 'text-[var(--text-tertiary)]'}"
>
<span
class="w-5 h-5 rounded-full flex items-center justify-center text-[10px] font-bold {isActive
? 'bg-[var(--accent-primary)] text-white'
: isDone
? 'bg-green-500/20 text-green-400 border border-green-500/40'
: 'bg-[var(--bg-secondary)] border border-[var(--border-color)]'}"
>
{#if isDone}{:else}{phase}{/if}
</span>
<span class="hidden sm:inline">{getPhaseLabel(phase)}</span>
</button>
{#if phase < 4}
<svg
class="w-3 h-3 text-[var(--text-tertiary)] shrink-0"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5l7 7-7 7"
/>
</svg>
{/if}
{/each}
</div>
</div>
<!-- Body -->
<div class="flex-1 overflow-y-auto p-6 min-h-0">
{#if $workflowState.currentPhase === 1}
<!-- ── Phase 1: Discuss ── -->
<div class="flex flex-col gap-4">
<div>
<label
for="workflow-description"
class="block text-sm font-medium text-[var(--text-secondary)] mb-2"
>
Describe what you want to build
</label>
<textarea
id="workflow-description"
value={$workflowState.discuss.description}
oninput={(e) => {
workflowStore.setDiscussDescription((e.target as HTMLTextAreaElement).value);
}}
rows={5}
class="w-full bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg p-4 text-sm text-[var(--text-primary)] resize-none focus:outline-none focus:border-[var(--accent-primary)] leading-relaxed"
placeholder="Describe the feature or project you want to build..."
spellcheck="false"
disabled={isWaitingForDiscuss}
></textarea>
</div>
{#if isWaitingForDiscuss}
<div
class="flex items-center gap-3 p-4 bg-[var(--bg-secondary)] rounded-lg border border-[var(--border-color)]"
>
<div class="text-xl animate-spin">⚙️</div>
<div>
<p class="text-sm font-medium text-[var(--text-primary)]">Claude is working...</p>
<p class="text-xs text-[var(--text-secondary)]">
Writing CONTEXT.md — will auto-detect when complete.
</p>
</div>
</div>
{:else if contextContent !== null}
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<span class="text-xs font-medium text-green-400">✓ CONTEXT.md captured</span>
<button
onclick={tryLoadContextFile}
class="text-xs text-[var(--text-tertiary)] hover:text-[var(--text-secondary)] transition-colors"
>
Refresh
</button>
</div>
<textarea
value={contextContent}
readonly
rows={8}
class="w-full bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg p-3 font-mono text-xs text-[var(--text-secondary)] resize-y focus:outline-none leading-relaxed"
></textarea>
</div>
{:else if isLoadingContext}
<p class="text-xs text-[var(--text-tertiary)]">Checking for CONTEXT.md...</p>
{:else}
<div class="flex flex-col gap-2">
<p class="text-xs text-[var(--text-tertiary)]">
{#if $workflowState.quickMode}
Quick mode: your description will be saved directly to CONTEXT.md without
discussion.
{:else}
Claude will analyse your description and write a structured CONTEXT.md with
acceptance criteria.
{/if}
</p>
<div class="flex gap-2">
{#if !$workflowState.quickMode}
<button
onclick={handleStartDiscussion}
disabled={!$workflowState.discuss.description.trim()}
class="px-4 py-1.5 text-sm btn-trans-gradient rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Start Discussion
</button>
{:else}
<button
onclick={handleQuickCaptureContext}
disabled={!$workflowState.discuss.description.trim()}
class="px-4 py-1.5 text-sm btn-trans-gradient rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Save to CONTEXT.md
</button>
{/if}
<button
onclick={tryLoadContextFile}
class="px-3 py-1.5 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] bg-[var(--bg-secondary)] hover:bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded-lg transition-colors"
>
Check for CONTEXT.md
</button>
</div>
</div>
{/if}
</div>
{:else if $workflowState.currentPhase === 2}
<!-- ── Phase 2: Plan ── -->
<div class="flex flex-col gap-4">
<p class="text-sm text-[var(--text-secondary)]">
Use the PRD Creator to generate your task breakdown, then approve it here to advance.
</p>
<!-- PRD status card -->
<div class="p-4 bg-[var(--bg-secondary)] rounded-lg border border-[var(--border-color)]">
{#if $prdIsLoaded && $prdTasks.length > 0}
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-[var(--text-primary)]">
{$prdTasks.length} task{$prdTasks.length === 1 ? "" : "s"} ready
</p>
<p class="text-xs text-[var(--text-tertiary)] mt-0.5">hikari-tasks.json loaded</p>
</div>
<span
class="text-xs px-2 py-0.5 rounded-full bg-green-500/20 text-green-400 border border-green-500/30"
>
Ready
</span>
</div>
{:else}
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-[var(--text-tertiary)]">No tasks generated yet</p>
<p class="text-xs text-[var(--text-tertiary)] mt-0.5">
Open PRD Creator to generate a task breakdown
</p>
</div>
<span
class="text-xs px-2 py-0.5 rounded-full bg-[var(--bg-tertiary)] text-[var(--text-tertiary)] border border-[var(--border-color)]"
>
Pending
</span>
</div>
{/if}
</div>
<div class="flex gap-2">
<button
onclick={onOpenPrdPanel}
class="px-4 py-1.5 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] bg-[var(--bg-secondary)] hover:bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded-lg transition-colors"
>
Open PRD Creator →
</button>
<button
onclick={async () => {
await prdStore.loadFromFile(workingDirectory);
}}
class="px-3 py-1.5 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] bg-[var(--bg-secondary)] hover:bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded-lg transition-colors"
>
Reload Tasks
</button>
</div>
{#if $workflowState.plan.tasksApproved}
<div
class="flex items-center gap-2 p-3 bg-green-500/10 rounded-lg border border-green-500/20"
>
<span class="text-green-400 text-sm"
>✓ Plan approved — ready to advance to Execute</span
>
</div>
{/if}
</div>
{:else if $workflowState.currentPhase === 3}
<!-- ── Phase 3: Execute ── -->
<div class="flex flex-col gap-4">
<p class="text-sm text-[var(--text-secondary)]">
Run your tasks in the Task Loop panel, then mark execution complete here.
</p>
<!-- Task Loop progress -->
<div class="p-4 bg-[var(--bg-secondary)] rounded-lg border border-[var(--border-color)]">
{#if $loopTasks.length > 0}
{@const done = completedTaskCount()}
{@const failed = failedTaskCount()}
{@const total = $loopTasks.length}
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<p class="text-sm font-medium text-[var(--text-primary)]">
{done} / {total} tasks completed
</p>
{#if $loopStatus !== "idle"}
<span
class="text-xs px-2 py-0.5 rounded-full bg-blue-500/20 text-blue-400 border border-blue-500/30 capitalize"
>
{$loopStatus}
</span>
{/if}
</div>
<!-- Progress bar -->
<div class="w-full bg-[var(--bg-primary)] rounded-full h-2 overflow-hidden">
<div
class="h-full bg-[var(--accent-primary)] transition-all duration-300"
style="width: {total > 0 ? (done / total) * 100 : 0}%"
></div>
</div>
{#if failed > 0}
<p class="text-xs text-red-400">
{failed} task{failed === 1 ? "" : "s"} failed
</p>
{/if}
</div>
{:else}
<p class="text-sm text-[var(--text-tertiary)]">
No tasks loaded — open Task Loop to load and run tasks
</p>
{/if}
</div>
<div class="flex gap-2">
<button
onclick={onOpenTaskLoop}
class="px-4 py-1.5 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] bg-[var(--bg-secondary)] hover:bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded-lg transition-colors"
>
Open Task Loop →
</button>
</div>
{#if $workflowState.execute.completed}
<div
class="flex items-center gap-2 p-3 bg-green-500/10 rounded-lg border border-green-500/20"
>
<span class="text-green-400 text-sm"
>✓ Execution complete — ready to advance to Verify</span
>
</div>
{/if}
</div>
{:else if $workflowState.currentPhase === 4}
<!-- ── Phase 4: Verify ── -->
<div class="flex flex-col gap-4">
<p class="text-sm text-[var(--text-secondary)]">
Verify the implementation against your acceptance criteria.
</p>
<!-- Criteria list -->
{#if $workflowState.verify.criteria.length > 0}
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<p class="text-xs font-medium text-[var(--text-secondary)]">Acceptance Criteria</p>
{#if contextContent !== null}
<button
onclick={handleExtractCriteria}
class="text-xs text-[var(--text-tertiary)] hover:text-[var(--text-secondary)] transition-colors"
>
Re-extract from CONTEXT.md
</button>
{/if}
</div>
{#each $workflowState.verify.criteria as criterion (criterion.id)}
<div
class="flex items-center gap-2 p-3 bg-[var(--bg-secondary)] rounded-lg border border-[var(--border-color)]"
>
<p class="flex-1 text-sm text-[var(--text-primary)]">{criterion.text}</p>
<div class="flex items-center gap-1 shrink-0">
<button
onclick={() => handleSetCriterionStatus(criterion.id, "pass")}
class="px-2 py-0.5 text-xs rounded transition-colors {criterion.status ===
'pass'
? 'bg-green-500/20 text-green-400 border border-green-500/30'
: 'text-[var(--text-tertiary)] hover:text-green-400'}"
>
Pass
</button>
<button
onclick={() => handleSetCriterionStatus(criterion.id, "partial")}
class="px-2 py-0.5 text-xs rounded transition-colors {criterion.status ===
'partial'
? 'bg-amber-500/20 text-amber-400 border border-amber-500/30'
: 'text-[var(--text-tertiary)] hover:text-amber-400'}"
>
Partial
</button>
<button
onclick={() => handleSetCriterionStatus(criterion.id, "fail")}
class="px-2 py-0.5 text-xs rounded transition-colors {criterion.status ===
'fail'
? 'bg-red-500/20 text-red-400 border border-red-500/30'
: 'text-[var(--text-tertiary)] hover:text-red-400'}"
>
Fail
</button>
<button
onclick={() => handleRemoveCriterion(criterion.id)}
class="p-0.5 text-[var(--text-tertiary)] hover:text-red-400 transition-colors ml-1"
aria-label="Remove criterion"
>
<svg
class="w-3.5 h-3.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>
{/each}
</div>
{:else}
<div
class="flex flex-col gap-2 p-4 bg-[var(--bg-secondary)] rounded-lg border border-[var(--border-color)]"
>
<p class="text-sm text-[var(--text-tertiary)]">No criteria yet.</p>
{#if contextContent !== null}
<button
onclick={handleExtractCriteria}
class="self-start text-xs px-3 py-1.5 text-[var(--text-secondary)] hover:text-[var(--text-primary)] bg-[var(--bg-primary)] hover:bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded-lg transition-colors"
>
Extract from CONTEXT.md
</button>
{/if}
</div>
{/if}
<!-- Add criterion -->
<div class="flex gap-2">
<input
type="text"
bind:value={newCriterionText}
onkeydown={(e) => e.key === "Enter" && handleAddCriterion()}
class="flex-1 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg px-3 py-1.5 text-sm text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent-primary)]"
placeholder="Add criterion..."
/>
<button
onclick={handleAddCriterion}
disabled={!newCriterionText.trim()}
class="px-3 py-1.5 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] bg-[var(--bg-secondary)] hover:bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Add
</button>
</div>
<!-- Verification actions -->
{#if isWaitingForVerify}
<div
class="flex items-center gap-3 p-4 bg-[var(--bg-secondary)] rounded-lg border border-[var(--border-color)]"
>
<div class="text-xl animate-spin">⚙️</div>
<div>
<p class="text-sm font-medium text-[var(--text-primary)]">Claude is verifying...</p>
<p class="text-xs text-[var(--text-secondary)]">
Writing VERIFY.md — will auto-detect when complete.
</p>
</div>
</div>
{:else if verifyContent !== null}
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<span class="text-xs font-medium text-green-400">✓ VERIFY.md generated</span>
<button
onclick={tryLoadVerifyFile}
class="text-xs text-[var(--text-tertiary)] hover:text-[var(--text-secondary)] transition-colors"
>
Refresh
</button>
</div>
<textarea
value={verifyContent}
readonly
rows={8}
class="w-full bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg p-3 font-mono text-xs text-[var(--text-secondary)] resize-y focus:outline-none leading-relaxed"
></textarea>
</div>
{:else if isLoadingVerify}
<p class="text-xs text-[var(--text-tertiary)]">Checking for VERIFY.md...</p>
{:else}
<div class="flex gap-2">
{#if !$workflowState.quickMode}
<button
onclick={handleRunVerification}
class="px-4 py-1.5 text-sm btn-trans-gradient rounded-lg transition-colors"
>
Run Verification
</button>
{/if}
<button
onclick={tryLoadVerifyFile}
class="px-3 py-1.5 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] bg-[var(--bg-secondary)] hover:bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded-lg transition-colors"
>
Check for VERIFY.md
</button>
</div>
{/if}
{#if $workflowState.verify.verificationComplete}
<div
class="flex items-center gap-2 p-3 bg-green-500/10 rounded-lg border border-green-500/20"
>
<span class="text-green-400 text-sm">✓ Verification complete</span>
</div>
{/if}
</div>
{/if}
</div>
<!-- Footer navigation -->
<div
class="flex items-center justify-between p-4 pt-2 border-t border-[var(--border-color)] gap-3"
>
<div class="flex items-center gap-2">
<button
onclick={handleBack}
disabled={!canGoBack($workflowState.currentPhase)}
class="px-3 py-1.5 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] bg-[var(--bg-secondary)] hover:bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded-lg transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
← Back
</button>
<button
onclick={handleReset}
class="px-3 py-1.5 text-sm text-[var(--text-secondary)] hover:text-red-400 bg-[var(--bg-secondary)] hover:bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded-lg transition-colors"
>
Reset
</button>
</div>
<div class="flex items-center gap-2">
<!-- Phase-specific primary action -->
{#if $workflowState.currentPhase === 2 && !$workflowState.plan.tasksApproved}
<button
onclick={handleApprovePlan}
disabled={!$workflowState.quickMode && (!$prdIsLoaded || $prdTasks.length === 0)}
class="px-4 py-1.5 text-sm btn-trans-gradient rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Approve Plan
</button>
{:else if $workflowState.currentPhase === 3 && !$workflowState.execute.completed}
<button
onclick={handleCompleteExecution}
class="px-4 py-1.5 text-sm btn-trans-gradient rounded-lg transition-colors"
>
{allTasksDone() ? "Mark Complete ✓" : "Mark Complete (Override)"}
</button>
{:else if $workflowState.currentPhase === 4 && !$workflowState.verify.verificationComplete && $workflowState.quickMode}
<button
onclick={() => {
workflowStore.completeVerification("Manual verification complete.");
void workflowStore.saveState(workingDirectory);
}}
class="px-4 py-1.5 text-sm btn-trans-gradient rounded-lg transition-colors"
>
Complete Workflow
</button>
{/if}
<!-- Advance / Close -->
<button
onclick={handleAdvance}
disabled={!canAdvancePhase($workflowState)}
class="px-4 py-1.5 text-sm btn-trans-gradient rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{$workflowState.currentPhase === 4 ? "Close" : "Next →"}
</button>
</div>
</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);
}
}
textarea {
scrollbar-width: thin;
scrollbar-color: var(--border-color) transparent;
}
textarea::-webkit-scrollbar {
width: 8px;
}
textarea::-webkit-scrollbar-track {
background: transparent;
}
textarea::-webkit-scrollbar-thumb {
background-color: var(--border-color);
border-radius: 4px;
}
textarea::-webkit-scrollbar-thumb:hover {
background-color: var(--accent-primary);
}
</style>
@@ -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>
@@ -0,0 +1,48 @@
<h3 class="text-lg font-semibold text-[var(--text-primary)] mb-4">Changelog</h3>
<div class="space-y-4 text-sm text-[var(--text-secondary)]">
<p>
The full version history for Hikari Desktop is available in the <strong>Changelog</strong>
panel — open it from the menu to browse release notes for every version, fetched directly from the
releases page.
</p>
<div>
<h4 class="font-medium text-[var(--text-primary)] mb-2">Recent Highlights</h4>
<ul class="space-y-2">
<li>
<strong>Guided Workflow</strong> — four-phase project orchestration (Discuss → Plan → Execute
→ Verify) with Quick Mode and cross-panel navigation
</li>
<li>
<strong>Wave-based Task Loop</strong> — parallel task execution with dependency tracking, concurrency
control, and auto-commit support
</li>
<li>
<strong>PRD Creator</strong> — AI-assisted task list generation from plain-English project descriptions
</li>
<li>
<strong>Community Themes</strong> — Dracula, Catppuccin, Nord, Solarized, Gruvbox, and Rosé Pine
presets
</li>
<li>
<strong>Project Context Panel</strong> — persistent PROJECT.md / REQUIREMENTS.md / ROADMAP.md
/ STATE.md context engineering
</li>
<li>
<strong>Codebase Mapper</strong> — auto-generated CODEBASE.md architectural summaries
</li>
<li>
<strong>Embedded Docs</strong> — this help panel with full documentation and keyboard navigation
</li>
</ul>
</div>
<div class="pt-2 border-t border-[var(--border-color)]">
<p>
To see the complete changelog with all versions and patch notes, open <strong
>Changelog</strong
> from the main menu.
</p>
</div>
</div>
@@ -0,0 +1,105 @@
<h3 class="text-lg font-semibold text-[var(--text-primary)] mb-4">Chat & Input</h3>
<div class="space-y-4 text-sm text-[var(--text-secondary)]">
<div>
<h4 class="font-medium text-[var(--text-primary)] mb-2">Sending Messages</h4>
<ul class="space-y-1">
<li>• Press <kbd class="kbd">Enter</kbd> to send your message</li>
<li>• Press <kbd class="kbd">Shift+Enter</kbd> to insert a newline</li>
<li>• Paste images directly into the input — Hikari can see and discuss them</li>
<li>• Paste code or multi-line text — Hikari will handle it as context</li>
</ul>
</div>
<div>
<h4 class="font-medium text-[var(--text-primary)] mb-2">Quick Actions</h4>
<p>
Click the <strong>⚡ lightning bolt</strong> icon in the input toolbar to open a panel of predefined
prompt shortcuts. These let you trigger common tasks (explain code, write tests, refactor, and more)
with a single click instead of typing.
</p>
<p class="mt-2">
Quick actions send a pre-written prompt to Hikari immediately — no extra typing needed.
</p>
</div>
<div>
<h4 class="font-medium text-[var(--text-primary)] mb-2">Snippet Library</h4>
<p>
Click the <strong>snippet icon</strong> in the input toolbar to open your personal snippet library
— a collection of reusable text blocks you can insert into the input with one click.
</p>
<ul class="space-y-1 mt-2">
<li>• Snippets are organised by category</li>
<li>• Click a snippet to insert it at the cursor position</li>
<li>• Default snippets are provided; add your own to build a personal library</li>
</ul>
</div>
<div>
<h4 class="font-medium text-[var(--text-primary)] mb-2">Clipboard History</h4>
<p>
Click the <strong>clipboard icon</strong> in the input toolbar to browse your clipboard history
— a list of text you've previously copied or pasted during the session.
</p>
<ul class="space-y-1 mt-2">
<li>• Longer text blocks and code snippets are captured automatically</li>
<li>• Click an entry to insert it into the current input</li>
<li>• Filter by language for code-specific entries</li>
</ul>
</div>
<div>
<h4 class="font-medium text-[var(--text-primary)] mb-2">Drafts</h4>
<p>
Click the <strong>draft icon</strong> in the input toolbar to open the Drafts panel — a place to
save and retrieve partially written messages.
</p>
<ul class="space-y-1 mt-2">
<li>• Save the current input as a draft for later</li>
<li>• Click a saved draft to restore it to the input</li>
<li>• Drafts persist across sessions so you can pick up where you left off</li>
</ul>
</div>
<div>
<h4 class="font-medium text-[var(--text-primary)] mb-2">Compact Mode</h4>
<p>
Compact mode shrinks the app to a small <strong>280×400 px</strong> floating window — useful for
keeping Hikari visible whilst working in another application.
</p>
<ul class="space-y-1 mt-2">
<li>
• Toggle via <strong>Compact Mode</strong> in the menu or
<kbd class="kbd">Ctrl+Shift+M</kbd>
</li>
<li>• Press the same shortcut again (or use the menu) to return to normal size</li>
<li>• Your previous window size is restored automatically when you exit compact mode</li>
</ul>
</div>
<div>
<h4 class="font-medium text-[var(--text-primary)] mb-2">Discord Rich Presence</h4>
<p>
Hikari Desktop can share your activity in Discord — showing the model you're using and session
duration as your Rich Presence status.
</p>
<p class="mt-2">
Toggle this in Settings (<kbd class="kbd">Ctrl+,</kbd>) under the Discord section. Disable it
if you'd rather keep your coding sessions private.
</p>
</div>
</div>
<style>
kbd.kbd {
display: inline-block;
padding: 0.1rem 0.35rem;
font-size: 0.75rem;
font-family: monospace;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 3px;
color: var(--text-primary);
}
</style>
@@ -0,0 +1,79 @@
<h3 class="text-lg font-semibold text-[var(--text-primary)] mb-4">File Editor</h3>
<div class="space-y-4 text-sm text-[var(--text-secondary)]">
<p>
The built-in file editor lets you read and edit project files directly in Hikari Desktop,
alongside your conversation. Open it via <strong>File Editor</strong> in the menu or press
<kbd class="kbd">Ctrl+E</kbd> (requires an active connection).
</p>
<div>
<h4 class="font-medium text-[var(--text-primary)] mb-2">File Browser</h4>
<p>
The file browser panel (toggle with <kbd class="kbd">Ctrl+B</kbd>) shows your working
directory tree. From it you can:
</p>
<ul class="space-y-1 mt-2">
<li>• Click a file to open it in a new editor tab</li>
<li>• Right-click for context menu options (rename, delete)</li>
<li>• Press <kbd class="kbd">Ctrl+N</kbd> to create a new file</li>
</ul>
</div>
<div>
<h4 class="font-medium text-[var(--text-primary)] mb-2">Editor Tabs</h4>
<p>Each open file gets its own tab. You can have multiple files open at once.</p>
<ul class="space-y-1 mt-2">
<li>• Click a tab to switch to that file</li>
<li>• Click <strong>×</strong> on a tab to close it (<kbd class="kbd">Ctrl+W</kbd>)</li>
<li>• Unsaved changes are indicated with a dot on the tab</li>
<li>• Press <kbd class="kbd">Ctrl+S</kbd> to save the current file</li>
</ul>
</div>
<div>
<h4 class="font-medium text-[var(--text-primary)] mb-2">Syntax Highlighting</h4>
<p>
The editor automatically applies syntax highlighting for a wide range of languages, including:
</p>
<p class="mt-1">
JavaScript · TypeScript · Python · Rust · Go · Java · C++ · HTML · CSS · JSON · YAML ·
Markdown · SQL · Shell · PHP · Ruby · Swift · R · TOML · Dockerfile · PowerShell
</p>
<p class="mt-2">
The editor theme follows your app theme — dark themes use dark editor backgrounds.
</p>
</div>
<div>
<h4 class="font-medium text-[var(--text-primary)] mb-2">Editor Features</h4>
<ul class="space-y-1">
<li>• Line numbers, bracket matching, and code folding</li>
<li>• Search and replace (<kbd class="kbd">Ctrl+F</kbd> in most editors)</li>
<li>• Right-click context menu for common operations</li>
<li>• Full CodeMirror keybindings</li>
</ul>
</div>
<div>
<h4 class="font-medium text-[var(--text-primary)] mb-2">Working with Hikari</h4>
<p>
Hikari can read and edit files directly through her tool access — you don't need the file
editor open for her to work on files. The editor is most useful for reviewing changes she's
made, manually editing files alongside the conversation, or browsing the project structure.
</p>
</div>
</div>
<style>
kbd.kbd {
display: inline-block;
padding: 0.1rem 0.35rem;
font-size: 0.75rem;
font-family: monospace;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 3px;
color: var(--text-primary);
}
</style>
@@ -0,0 +1,61 @@
<h3 class="text-lg font-semibold text-[var(--text-primary)] mb-4">Getting Started</h3>
<div class="space-y-4 text-sm text-[var(--text-secondary)]">
<div>
<h4 class="font-medium text-[var(--text-primary)] mb-2">1. Enter your API key</h4>
<p>
Open Settings (<kbd class="kbd">Ctrl+,</kbd>) and paste your Anthropic API key. Keys are
stored locally on your device and never sent anywhere except the Anthropic API.
</p>
</div>
<div>
<h4 class="font-medium text-[var(--text-primary)] mb-2">2. Set a working directory</h4>
<p>
Click the folder icon in the connection bar and choose the project directory you want Hikari
to work in. This gives her context for your files and project structure.
</p>
</div>
<div>
<h4 class="font-medium text-[var(--text-primary)] mb-2">3. Connect</h4>
<p>
Click <strong>Connect</strong> to start a Claude Code session. The status indicator will turn green
when you're connected.
</p>
</div>
<div>
<h4 class="font-medium text-[var(--text-primary)] mb-2">4. Start chatting</h4>
<p>
Type a message and press <kbd class="kbd">Enter</kbd> (or <kbd class="kbd">Ctrl+Enter</kbd>)
to send. Hikari can read, write, and edit files; run terminal commands; search code; fetch web
content; and connect to external tools via MCP.
</p>
</div>
<div class="pt-2 border-t border-[var(--border-color)]">
<h4 class="font-medium text-[var(--text-primary)] mb-2">Key Features</h4>
<ul class="space-y-1.5">
<li>🗂️ <strong>File Management</strong> — read, write, and edit files in your project</li>
<li>💻 <strong>Terminal Access</strong> — execute commands and run scripts</li>
<li>🔍 <strong>Code Search</strong> — find files and search through code</li>
<li>🌐 <strong>Web Access</strong> — fetch information from the web</li>
<li>📊 <strong>MCP Servers</strong> — connect external tools via Model Context Protocol</li>
<li>📁 <strong>Multi-tab Support</strong> — work on multiple conversations simultaneously</li>
</ul>
</div>
</div>
<style>
kbd.kbd {
display: inline-block;
padding: 0.1rem 0.35rem;
font-size: 0.75rem;
font-family: monospace;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 3px;
color: var(--text-primary);
}
</style>
@@ -0,0 +1,61 @@
<h3 class="text-lg font-semibold text-[var(--text-primary)] mb-4">Git & Version Control</h3>
<div class="space-y-4 text-sm text-[var(--text-secondary)]">
<p>
The Git panel gives you a visual interface for git operations directly inside Hikari Desktop.
Open it via <strong>Git Panel</strong> in the menu.
</p>
<div>
<h4 class="font-medium text-[var(--text-primary)] mb-2">Changes Tab</h4>
<p>Shows all modified, staged, and untracked files in your working directory.</p>
<ul class="space-y-1 mt-2">
<li>• Click a file to view its diff</li>
<li>• Stage individual files or all changes at once</li>
<li>• Unstage files you don't want in the next commit</li>
<li>• Discard changes to revert a file to its last committed state</li>
<li>• Enter a commit message and click <strong>Commit</strong> to create a commit</li>
</ul>
</div>
<div>
<h4 class="font-medium text-[var(--text-primary)] mb-2">Branches Tab</h4>
<p>Manage your local and remote branches.</p>
<ul class="space-y-1 mt-2">
<li>• View all local and remote branches</li>
<li>• Click a branch to check it out</li>
<li>• Create new branches from the current HEAD</li>
<li>• See how many commits your branch is ahead/behind its remote</li>
</ul>
</div>
<div>
<h4 class="font-medium text-[var(--text-primary)] mb-2">History Tab</h4>
<p>Browse your commit history with author names, dates, and commit messages.</p>
<ul class="space-y-1 mt-2">
<li>• Scrollable log of recent commits</li>
<li>• Abbreviated commit hashes for easy reference</li>
</ul>
</div>
<div>
<h4 class="font-medium text-[var(--text-primary)] mb-2">Sync Actions</h4>
<p>
Use the quick action buttons to fetch, pull, and push changes to your remote — all without
leaving Hikari Desktop.
</p>
</div>
<div>
<h4 class="font-medium text-[var(--text-primary)] mb-2">Working with Hikari</h4>
<p>
Hikari can also run git commands for you through the terminal — just ask her to commit, push,
create a branch, or check the status. The Git panel is useful for reviewing changes visually
and for quick operations you want to do yourself.
</p>
<p class="mt-2">
The <strong>Task Loop auto-commit</strong> feature can automatically commit after each completed
task — configure it via the ⚙ icon in the Task Loop panel.
</p>
</div>
</div>
@@ -0,0 +1,130 @@
<h3 class="text-lg font-semibold text-[var(--text-primary)] mb-4">Keyboard Shortcuts</h3>
<div class="space-y-4 text-sm">
<div>
<h4 class="font-medium text-[var(--text-primary)] mb-2">General</h4>
<table class="w-full">
<tbody class="divide-y divide-[var(--border-color)]">
<tr>
<td class="py-1.5 pr-4"><kbd class="kbd">?</kbd></td>
<td class="py-1.5 text-[var(--text-secondary)]">Open this help panel</td>
</tr>
<tr>
<td class="py-1.5 pr-4"><kbd class="kbd">Escape</kbd></td>
<td class="py-1.5 text-[var(--text-secondary)]">Close the active panel</td>
</tr>
<tr>
<td class="py-1.5 pr-4"><kbd class="kbd">Ctrl+,</kbd></td>
<td class="py-1.5 text-[var(--text-secondary)]">Open settings</td>
</tr>
<tr>
<td class="py-1.5 pr-4"><kbd class="kbd">Ctrl+L</kbd></td>
<td class="py-1.5 text-[var(--text-secondary)]">Clear the terminal output</td>
</tr>
<tr>
<td class="py-1.5 pr-4"><kbd class="kbd">Ctrl+C</kbd></td>
<td class="py-1.5 text-[var(--text-secondary)]"
>Interrupt Claude (when no text is selected)</td
>
</tr>
</tbody>
</table>
</div>
<div>
<h4 class="font-medium text-[var(--text-primary)] mb-2">Font Size</h4>
<table class="w-full">
<tbody class="divide-y divide-[var(--border-color)]">
<tr>
<td class="py-1.5 pr-4"><kbd class="kbd">Ctrl++</kbd> / <kbd class="kbd">Ctrl+=</kbd></td>
<td class="py-1.5 text-[var(--text-secondary)]">Increase font size</td>
</tr>
<tr>
<td class="py-1.5 pr-4"><kbd class="kbd">Ctrl+-</kbd></td>
<td class="py-1.5 text-[var(--text-secondary)]">Decrease font size</td>
</tr>
<tr>
<td class="py-1.5 pr-4"><kbd class="kbd">Ctrl+0</kbd></td>
<td class="py-1.5 text-[var(--text-secondary)]">Reset font size to default</td>
</tr>
</tbody>
</table>
</div>
<div>
<h4 class="font-medium text-[var(--text-primary)] mb-2">Display Modes</h4>
<table class="w-full">
<tbody class="divide-y divide-[var(--border-color)]">
<tr>
<td class="py-1.5 pr-4"><kbd class="kbd">Ctrl+Shift+S</kbd></td>
<td class="py-1.5 text-[var(--text-secondary)]">Toggle streamer mode</td>
</tr>
<tr>
<td class="py-1.5 pr-4"><kbd class="kbd">Ctrl+Shift+M</kbd></td>
<td class="py-1.5 text-[var(--text-secondary)]">Toggle compact mode</td>
</tr>
<tr>
<td class="py-1.5 pr-4"><kbd class="kbd">Ctrl+`</kbd></td>
<td class="py-1.5 text-[var(--text-secondary)]">Toggle debug console</td>
</tr>
</tbody>
</table>
</div>
<div>
<h4 class="font-medium text-[var(--text-primary)] mb-2">Code Editor</h4>
<table class="w-full">
<tbody class="divide-y divide-[var(--border-color)]">
<tr>
<td class="py-1.5 pr-4"><kbd class="kbd">Ctrl+E</kbd></td>
<td class="py-1.5 text-[var(--text-secondary)]">Toggle editor panel (when connected)</td>
</tr>
<tr>
<td class="py-1.5 pr-4"><kbd class="kbd">Ctrl+B</kbd></td>
<td class="py-1.5 text-[var(--text-secondary)]">Toggle file browser (in editor)</td>
</tr>
<tr>
<td class="py-1.5 pr-4"><kbd class="kbd">Ctrl+S</kbd></td>
<td class="py-1.5 text-[var(--text-secondary)]">Save current file (in editor)</td>
</tr>
<tr>
<td class="py-1.5 pr-4"><kbd class="kbd">Ctrl+W</kbd></td>
<td class="py-1.5 text-[var(--text-secondary)]">Close current tab (in editor)</td>
</tr>
<tr>
<td class="py-1.5 pr-4"><kbd class="kbd">Ctrl+N</kbd></td>
<td class="py-1.5 text-[var(--text-secondary)]">New file (in editor)</td>
</tr>
</tbody>
</table>
</div>
<div>
<h4 class="font-medium text-[var(--text-primary)] mb-2">Help Panel Navigation</h4>
<table class="w-full">
<tbody class="divide-y divide-[var(--border-color)]">
<tr>
<td class="py-1.5 pr-4"><kbd class="kbd"></kbd> / <kbd class="kbd"></kbd></td>
<td class="py-1.5 text-[var(--text-secondary)]">Previous page</td>
</tr>
<tr>
<td class="py-1.5 pr-4"><kbd class="kbd"></kbd> / <kbd class="kbd"></kbd></td>
<td class="py-1.5 text-[var(--text-secondary)]">Next page</td>
</tr>
</tbody>
</table>
</div>
</div>
<style>
kbd.kbd {
display: inline-block;
padding: 0.1rem 0.35rem;
font-size: 0.75rem;
font-family: monospace;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 3px;
color: var(--text-primary);
}
</style>
@@ -0,0 +1,72 @@
<h3 class="text-lg font-semibold text-[var(--text-primary)] mb-4">Model & API Configuration</h3>
<div class="space-y-4 text-sm text-[var(--text-secondary)]">
<div>
<h4 class="font-medium text-[var(--text-primary)] mb-2">API Key</h4>
<p>
Enter your Anthropic API key in Settings (<kbd class="kbd">Ctrl+,</kbd>). The key is stored
locally on your device and used only to authenticate requests to the Anthropic API.
</p>
<p class="mt-2">
Get your API key at <strong>console.anthropic.com</strong>.
</p>
</div>
<div>
<h4 class="font-medium text-[var(--text-primary)] mb-2">Model Selection</h4>
<p class="mb-2">Choose from available Claude models:</p>
<ul class="space-y-1.5">
<li>
<strong>claude-opus-4-6</strong> — most capable, highest quality; best for complex tasks
</li>
<li>
<strong>claude-sonnet-4-6</strong> — balanced speed and quality
<span class="text-[var(--accent-primary)]">(recommended)</span>
</li>
<li>
<strong>claude-haiku-4-5</strong> — fastest and most cost-efficient; good for quick tasks
</li>
</ul>
</div>
<div>
<h4 class="font-medium text-[var(--text-primary)] mb-2">Custom Instructions</h4>
<p>
Add persistent instructions in Settings that are prepended to every conversation. Use this to
set coding preferences, project conventions, or communication style.
</p>
</div>
<div>
<h4 class="font-medium text-[var(--text-primary)] mb-2">MCP Servers</h4>
<p>
Model Context Protocol (MCP) servers extend Hikari's capabilities with external tools and data
sources — databases, APIs, version control systems, and more.
</p>
<p class="mt-2">
Open <strong>MCP Servers</strong> from the menu to add and manage server configurations. Each server
is defined with a command and optional arguments.
</p>
</div>
<div>
<h4 class="font-medium text-[var(--text-primary)] mb-2">Permission Mode</h4>
<p>
Controls how Hikari asks for tool use permissions. Choose between asking every time,
auto-approving trusted tools, or running in a more restricted mode.
</p>
</div>
</div>
<style>
kbd.kbd {
display: inline-block;
padding: 0.1rem 0.35rem;
font-size: 0.75rem;
font-family: monospace;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 3px;
color: var(--text-primary);
}
</style>
@@ -0,0 +1,128 @@
<h3 class="text-lg font-semibold text-[var(--text-primary)] mb-4">Panels & Tools</h3>
<div class="space-y-4 text-sm text-[var(--text-secondary)]">
<div>
<h4 class="font-medium text-[var(--text-primary)] mb-2">Agent Monitor</h4>
<p>
Displays a live dashboard of all Claude Code agents running during a session — useful when
using the Task Loop or any feature that spawns subagents.
</p>
<ul class="space-y-1 mt-2">
<li>• Hierarchical tree view showing parent agents and their subagents</li>
<li>
• Status indicators: <span class="text-blue-400">● running</span>,
<span class="text-green-400">● completed</span>,
<span class="text-red-400">● errored</span>
</li>
<li>• Live duration timers for running agents</li>
<li>• Kill all / clear finished buttons</li>
<li>• A badge on the menu icon shows the count of active agents</li>
</ul>
</div>
<div>
<h4 class="font-medium text-[var(--text-primary)] mb-2">To-Do List</h4>
<p>
Shows Hikari's internal todo list in real time — the same tasks she tracks using the
<code class="code">TodoWrite</code> tool during complex multi-step work.
</p>
<ul class="space-y-1 mt-2">
<li>• See pending, in-progress, and completed tasks at a glance</li>
<li>• Progress bar shows overall completion percentage</li>
<li>• Updates live as Hikari works through her plan</li>
</ul>
</div>
<div>
<h4 class="font-medium text-[var(--text-primary)] mb-2">Profile</h4>
<p>Your personal profile within Hikari Desktop, with lifetime stats and sharing.</p>
<ul class="space-y-1 mt-2">
<li>• Edit your display name and bio</li>
<li>• Upload a profile avatar</li>
<li>
• View lifetime stats: messages sent, tokens used, code blocks generated, files
created/edited, total spend
</li>
<li>• Track achievement progress</li>
<li>• Generate a shareable profile card image</li>
</ul>
</div>
<div>
<h4 class="font-medium text-[var(--text-primary)] mb-2">Achievements</h4>
<p>
Hikari Desktop includes a fun achievement system that unlocks as you use the app — milestones
like your first message, first code block, staying up late, and more.
</p>
<p class="mt-2">
Open <strong>Achievements</strong> from the menu to see your progress. Newly unlocked achievements
show a badge count on the menu item.
</p>
</div>
<div>
<h4 class="font-medium text-[var(--text-primary)] mb-2">Plugins</h4>
<p>
Plugins extend Hikari's capabilities with community-built additions. Open
<strong>Plugins</strong> from the menu to manage them.
</p>
<ul class="space-y-1 mt-2">
<li>• Install plugins from named sources or custom marketplace URLs</li>
<li>• Enable or disable individual plugins without uninstalling them</li>
<li>• Update plugins to their latest versions</li>
<li>• Add custom plugin marketplace sources (GitHub-hosted)</li>
</ul>
</div>
<div>
<h4 class="font-medium text-[var(--text-primary)] mb-2">Debug Console</h4>
<p>
A developer-facing log console that captures frontend events, errors, and debug output. Open
it via <strong>Debug Console</strong> in the menu or press
<kbd class="kbd">Ctrl+`</kbd>.
</p>
<p class="mt-2">
Useful when troubleshooting unexpected behaviour or reporting issues. Filter by log level
(info, warn, error, debug).
</p>
</div>
<div>
<h4 class="font-medium text-[var(--text-primary)] mb-2">Workspace Trust</h4>
<p>
When you connect to a working directory, Hikari scans it for potentially powerful
configurations and may display a trust prompt before proceeding. This includes:
</p>
<ul class="space-y-1 mt-2">
<li><strong>Hooks</strong> — shell commands that run automatically during sessions</li>
<li><strong>MCP servers</strong> — local processes with system-level access</li>
<li><strong>Custom slash commands</strong> — instructions that execute at invocation</li>
</ul>
<p class="mt-2">
Review each item carefully. Click <strong>Trust &amp; Connect</strong> to proceed or
<strong>Cancel</strong> to abort. Trusted workspaces remember your decision for future sessions.
</p>
</div>
</div>
<style>
kbd.kbd {
display: inline-block;
padding: 0.1rem 0.35rem;
font-size: 0.75rem;
font-family: monospace;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 3px;
color: var(--text-primary);
}
code.code {
font-family: monospace;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 3px;
padding: 0.05rem 0.3rem;
color: var(--text-primary);
}
</style>
@@ -0,0 +1,80 @@
<h3 class="text-lg font-semibold text-[var(--text-primary)] mb-4">Session Management</h3>
<div class="space-y-4 text-sm text-[var(--text-secondary)]">
<div>
<h4 class="font-medium text-[var(--text-primary)] mb-2">Multiple Conversations</h4>
<p>
Use tabs at the top of the chat area to manage multiple simultaneous sessions with Hikari.
</p>
<ul class="space-y-1 mt-2">
<li>• Click <strong>+</strong> to open a new conversation tab</li>
<li>• Click a tab to switch between active conversations</li>
<li>• Click <strong>×</strong> on a tab to close that conversation</li>
</ul>
<p class="mt-2">
Each tab runs its own independent Claude Code session with separate context and history.
</p>
</div>
<div>
<h4 class="font-medium text-[var(--text-primary)] mb-2">Session History</h4>
<p>
Open <strong>Session History</strong> from the menu to browse and restore past conversations. Sessions
are saved automatically and indexed by date.
</p>
</div>
<div>
<h4 class="font-medium text-[var(--text-primary)] mb-2">Context Compaction</h4>
<p>
As conversations grow long, use <strong>Compact Conversation</strong> from the menu to summarise
the history and free up context window space — without losing important information.
</p>
<p class="mt-2">
<strong>Start Fresh with Context</strong> creates a brand-new session but carries over a summary
of the previous conversation.
</p>
</div>
<div>
<h4 class="font-medium text-[var(--text-primary)] mb-2">Tool Permissions</h4>
<p>
When Hikari needs to use a tool (file access, terminal, web fetch, etc.) she may ask for your
approval first. You can:
</p>
<ul class="space-y-1 mt-2">
<li><strong>Allow once</strong> — approve this single use</li>
<li><strong>Allow for session</strong> — approve all uses of this tool this session</li>
<li><strong>Deny</strong> — block the action</li>
</ul>
</div>
<div>
<h4 class="font-medium text-[var(--text-primary)] mb-2">Stats Panel</h4>
<p>
Open <strong>Stats</strong> from the menu to see real-time usage data: token counts, estimated cost,
context window usage, and per-session totals.
</p>
</div>
<div>
<h4 class="font-medium text-[var(--text-primary)] mb-2">Streamer Mode</h4>
<p>
Toggle streamer mode (<kbd class="kbd">Ctrl+Shift+S</kbd>) to redact your API key and other
sensitive information from the display — useful for streaming or screen sharing.
</p>
</div>
</div>
<style>
kbd.kbd {
display: inline-block;
padding: 0.1rem 0.35rem;
font-size: 0.75rem;
font-family: monospace;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 3px;
color: var(--text-primary);
}
</style>
+102
View File
@@ -0,0 +1,102 @@
<h3 class="text-lg font-semibold text-[var(--text-primary)] mb-4">Task Loop & Automation</h3>
<div class="space-y-4 text-sm text-[var(--text-secondary)]">
<p>
Hikari Desktop includes a full project automation suite — open any of these tools from the menu.
</p>
<div>
<h4 class="font-medium text-[var(--text-primary)] mb-2">Guided Workflow</h4>
<p>A four-phase project workflow that orchestrates the other automation tools:</p>
<ol class="space-y-1.5 mt-2 list-none">
<li>
<strong>1. Discuss</strong> — describe your project; Hikari writes a
<code class="code">CONTEXT.md</code> file capturing requirements and goals
</li>
<li>
<strong>2. Plan</strong> — use the PRD Creator to break the project into tasks
</li>
<li>
<strong>3. Execute</strong> — run the Task Loop to complete all tasks automatically
</li>
<li>
<strong>4. Verify</strong> — check acceptance criteria; Hikari writes
<code class="code">VERIFY.md</code>
</li>
</ol>
<p class="mt-2">
Enable <strong>Quick Mode</strong> to skip Claude interactions in steps 1 and 4.
</p>
</div>
<div>
<h4 class="font-medium text-[var(--text-primary)] mb-2">PRD Creator</h4>
<p>
Describe your project in plain English and Hikari will generate a structured
<code class="code">hikari-tasks.json</code> task list. Tasks include titles, prompts, priorities,
and dependency relationships.
</p>
</div>
<div>
<h4 class="font-medium text-[var(--text-primary)] mb-2">Task Loop</h4>
<p>
Executes tasks from <code class="code">hikari-tasks.json</code> automatically. Features include:
</p>
<ul class="space-y-1 mt-2">
<li>
<strong>Wave-based parallelism</strong> — independent tasks run concurrently; dependent tasks
wait for their prerequisites
</li>
<li>
<strong>Concurrency control</strong> — adjust the <kbd class="kbd">[] N [+]</kbd> control to
limit how many tasks run in parallel
</li>
<li><strong>Blocked detection</strong> — tasks whose dependencies failed are marked ⊘</li>
<li>
<strong>Auto-commit</strong> — optionally commit changes after each task completes, with a configurable
prefix and optional SUMMARY.md
</li>
</ul>
</div>
<div>
<h4 class="font-medium text-[var(--text-primary)] mb-2">Project Context Panel</h4>
<p>
Manage persistent context files that are injected into every conversation:
<code class="code">PROJECT.md</code>, <code class="code">REQUIREMENTS.md</code>,
<code class="code">ROADMAP.md</code>, and <code class="code">STATE.md</code>. These help
Hikari maintain consistent project understanding across sessions.
</p>
</div>
<div>
<h4 class="font-medium text-[var(--text-primary)] mb-2">Codebase Mapper</h4>
<p>
Generates a <code class="code">CODEBASE.md</code> architectural summary of your project — directory
structure, key files, and their roles. Useful for onboarding Hikari to large codebases quickly.
</p>
</div>
</div>
<style>
kbd.kbd {
display: inline-block;
padding: 0.1rem 0.35rem;
font-size: 0.75rem;
font-family: monospace;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 3px;
color: var(--text-primary);
}
code.code {
font-family: monospace;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 3px;
padding: 0.05rem 0.3rem;
color: var(--text-primary);
}
</style>
@@ -0,0 +1,85 @@
<h3 class="text-lg font-semibold text-[var(--text-primary)] mb-4">Theme Customisation</h3>
<div class="space-y-4 text-sm text-[var(--text-secondary)]">
<p>
Open <strong>Settings</strong> (<kbd class="kbd">Ctrl+,</kbd>) and scroll to the Appearance
section to customise your theme.
</p>
<div>
<h4 class="font-medium text-[var(--text-primary)] mb-2">Built-in Themes</h4>
<ul class="space-y-1">
<li><strong>Default Dark</strong> — the classic dark Hikari look</li>
<li><strong>Default Light</strong> — a bright, clean light theme</li>
<li><strong>High Contrast</strong> — maximum contrast for accessibility</li>
</ul>
</div>
<div>
<h4 class="font-medium text-[var(--text-primary)] mb-2">Community Presets</h4>
<ul class="space-y-1 columns-2">
<li>• Dracula</li>
<li>• Catppuccin Mocha</li>
<li>• Catppuccin Latte</li>
<li>• Nord</li>
<li>• Solarized Dark</li>
<li>• Solarized Light</li>
<li>• Gruvbox Dark</li>
<li>• Gruvbox Light</li>
<li>• Rosé Pine</li>
<li>• Rosé Pine Dawn</li>
</ul>
</div>
<div>
<h4 class="font-medium text-[var(--text-primary)] mb-2">Custom Colours</h4>
<p>
Select <strong>Custom</strong> from the theme dropdown to set individual colours for each UI element:
</p>
<ul class="space-y-1 mt-2">
<li>• Text: primary, secondary, and tertiary levels</li>
<li>• Backgrounds: primary, secondary, and header</li>
<li>• Border colour</li>
<li>• Accent and pink highlight colours</li>
<li>• Trans flag stripe colours</li>
</ul>
</div>
<div>
<h4 class="font-medium text-[var(--text-primary)] mb-2">Custom Fonts</h4>
<p>
Upload a <code class="code">.ttf</code> or <code class="code">.otf</code> font file to apply a custom
UI font across the entire app.
</p>
</div>
<div>
<h4 class="font-medium text-[var(--text-primary)] mb-2">Background Image</h4>
<p>
Set a custom background image that renders behind the chat area. Adjust opacity to keep it
subtle.
</p>
</div>
</div>
<style>
kbd.kbd {
display: inline-block;
padding: 0.1rem 0.35rem;
font-size: 0.75rem;
font-family: monospace;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 3px;
color: var(--text-primary);
}
code.code {
font-family: monospace;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 3px;
padding: 0.05rem 0.3rem;
color: var(--text-primary);
}
</style>
@@ -0,0 +1,125 @@
<h3 class="text-lg font-semibold text-[var(--text-primary)] mb-4">Troubleshooting</h3>
<div class="space-y-4 text-sm text-[var(--text-secondary)]">
<div>
<h4 class="font-medium text-[var(--text-primary)] mb-2">Can't Connect</h4>
<ul class="space-y-1">
<li>
• Check that your API key is entered correctly in Settings (<kbd class="kbd">Ctrl+,</kbd>)
</li>
<li>
• Ensure you have an active internet connection — Hikari needs to reach the Anthropic API
</li>
<li>• Try setting an explicit working directory rather than leaving it blank</li>
<li>• Check the Debug Console (<kbd class="kbd">Ctrl+`</kbd>) for error details</li>
</ul>
</div>
<div>
<h4 class="font-medium text-[var(--text-primary)] mb-2">
Hikari Seems Stuck or Stopped Responding
</h4>
<ul class="space-y-1">
<li>
• Press <kbd class="kbd">Ctrl+C</kbd> (when no text is selected) to interrupt the current process
</li>
<li>• If that doesn't work, disconnect and reconnect from the connection bar</li>
<li>
• Check the Stats panel for context window usage — if it's near 100%, use
<strong>Compact Conversation</strong> to free up space
</li>
</ul>
</div>
<div>
<h4 class="font-medium text-[var(--text-primary)] mb-2">API Errors or Rate Limits</h4>
<ul class="space-y-1">
<li>• Verify your API key is valid at <strong>console.anthropic.com</strong></li>
<li>• Check that your account has sufficient API credits</li>
<li>
• If you hit rate limits frequently, consider switching to a faster/cheaper model in
Settings
</li>
<li>• The Debug Console will show the specific error returned by the API</li>
</ul>
</div>
<div>
<h4 class="font-medium text-[var(--text-primary)] mb-2">Tool Permissions Blocking Work</h4>
<ul class="space-y-1">
<li>
• When prompted, you can choose <strong>Allow for session</strong> to avoid repeated prompts
</li>
<li>• Adjust the permission mode in Settings to auto-approve trusted tools</li>
<li>
• If a tool call was denied by mistake, simply ask Hikari to try again — she'll prompt you
once more
</li>
</ul>
</div>
<div>
<h4 class="font-medium text-[var(--text-primary)] mb-2">
Missing Features or Greyed-Out Buttons
</h4>
<ul class="space-y-1">
<li>• Some features require an active connection — connect to a working directory first</li>
<li>
• The File Editor (<kbd class="kbd">Ctrl+E</kbd>) is only available when connected
</li>
<li>• The Agent Monitor shows activity only during Task Loop or multi-agent sessions</li>
</ul>
</div>
<div>
<h4 class="font-medium text-[var(--text-primary)] mb-2">Workspace Trust Prompt</h4>
<p>
If you see a trust prompt when connecting, it means Hikari detected hooks, MCP servers, or
slash commands in your working directory. Review them carefully before clicking
<strong>Trust &amp; Connect</strong>. See the <strong>Panels &amp; Tools</strong> page for more
details.
</p>
</div>
<div>
<h4 class="font-medium text-[var(--text-primary)] mb-2">Context Window Full</h4>
<p>Watch the context bar in the Stats panel. When it approaches 100%:</p>
<ul class="space-y-1 mt-2">
<li>
• Use <strong>Compact Conversation</strong> from the menu to summarise and compress the history
</li>
<li>
• Use <strong>Start Fresh with Context</strong> to begin a new session that carries over a summary
</li>
</ul>
</div>
<div>
<h4 class="font-medium text-[var(--text-primary)] mb-2">Reporting Issues</h4>
<p>
If you encounter a bug or unexpected behaviour, please report it on our
<strong>GitHub issues page</strong>. Include:
</p>
<ul class="space-y-1 mt-2">
<li>• What you were doing when the issue occurred</li>
<li>• Any error messages from the Debug Console</li>
<li>• The app version (shown in the About panel)</li>
</ul>
<p class="mt-2">
You can also join our <strong>Discord community</strong> (link in the menu) for real-time support.
</p>
</div>
</div>
<style>
kbd.kbd {
display: inline-block;
padding: 0.1rem 0.35rem;
font-size: 0.75rem;
font-family: monospace;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 3px;
color: var(--text-primary);
}
</style>
+99
View File
@@ -0,0 +1,99 @@
import { describe, it, expect } from "vitest";
import { HELP_PAGES, nextPage, prevPage, clampPage, isFirstPage, isLastPage } from "./helpPages";
describe("HELP_PAGES", () => {
it("contains 12 pages", () => {
expect(HELP_PAGES).toHaveLength(12);
});
it("has unique ids", () => {
const ids = HELP_PAGES.map((p) => p.id);
expect(new Set(ids).size).toBe(ids.length);
});
it("has non-empty titles", () => {
for (const page of HELP_PAGES) {
expect(page.title.length).toBeGreaterThan(0);
}
});
});
describe("nextPage", () => {
it("advances to the next page", () => {
expect(nextPage(0, 7)).toBe(1);
expect(nextPage(3, 7)).toBe(4);
});
it("does not go past the last page", () => {
expect(nextPage(6, 7)).toBe(6);
});
it("clamps when already at the last page", () => {
expect(nextPage(10, 7)).toBe(6);
});
});
describe("prevPage", () => {
it("goes back to the previous page", () => {
expect(prevPage(3)).toBe(2);
expect(prevPage(1)).toBe(0);
});
it("does not go before the first page", () => {
expect(prevPage(0)).toBe(0);
});
it("clamps when already at the first page", () => {
expect(prevPage(-1)).toBe(0);
});
});
describe("clampPage", () => {
it("returns the page unchanged when in range", () => {
expect(clampPage(3, 7)).toBe(3);
expect(clampPage(0, 7)).toBe(0);
expect(clampPage(6, 7)).toBe(6);
});
it("clamps negative indices to 0", () => {
expect(clampPage(-1, 7)).toBe(0);
});
it("clamps over-range indices to the last page", () => {
expect(clampPage(10, 7)).toBe(6);
});
it("returns 0 when totalPages is 0", () => {
expect(clampPage(3, 0)).toBe(0);
});
it("returns 0 when totalPages is negative", () => {
expect(clampPage(3, -1)).toBe(0);
});
});
describe("isFirstPage", () => {
it("returns true for index 0", () => {
expect(isFirstPage(0)).toBe(true);
});
it("returns false for index greater than 0", () => {
expect(isFirstPage(1)).toBe(false);
expect(isFirstPage(6)).toBe(false);
});
});
describe("isLastPage", () => {
it("returns true for the last index", () => {
expect(isLastPage(6, 7)).toBe(true);
});
it("returns false for indices before the last", () => {
expect(isLastPage(5, 7)).toBe(false);
expect(isLastPage(0, 7)).toBe(false);
});
it("returns true when index exceeds total", () => {
expect(isLastPage(10, 7)).toBe(true);
});
});
+40
View File
@@ -0,0 +1,40 @@
export type HelpPageDef = {
id: string;
title: string;
};
export const HELP_PAGES: HelpPageDef[] = [
{ id: "getting-started", title: "Getting Started" },
{ id: "keyboard-shortcuts", title: "Keyboard Shortcuts" },
{ id: "chat-input", title: "Chat & Input" },
{ id: "file-editor", title: "File Editor" },
{ id: "git-panel", title: "Git & Version Control" },
{ id: "theme-customisation", title: "Theme Customisation" },
{ id: "model-config", title: "Model & API Configuration" },
{ id: "session-management", title: "Session Management" },
{ id: "task-loop", title: "Task Loop & Automation" },
{ id: "panels-tools", title: "Panels & Tools" },
{ id: "troubleshooting", title: "Troubleshooting" },
{ id: "changelog", title: "Changelog" },
];
export function nextPage(currentIndex: number, totalPages: number): number {
return Math.min(currentIndex + 1, totalPages - 1);
}
export function prevPage(currentIndex: number): number {
return Math.max(currentIndex - 1, 0);
}
export function clampPage(pageIndex: number, totalPages: number): number {
if (totalPages <= 0) return 0;
return Math.max(0, Math.min(pageIndex, totalPages - 1));
}
export function isFirstPage(currentIndex: number): boolean {
return currentIndex === 0;
}
export function isLastPage(currentIndex: number, totalPages: number): boolean {
return currentIndex >= totalPages - 1;
}
@@ -30,9 +30,9 @@
<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}>
<!-- 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()}>
<h2 class="dialog-title">{title}</h2>
<p class="dialog-message">{message}</p>
@@ -83,7 +83,7 @@
onClose();
}}
>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
<div
bind:this={menuElement}
class="menu-content"
@@ -78,7 +78,7 @@
<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="menu-overlay"
onclick={onClose}
@@ -87,7 +87,7 @@
onClose();
}}
>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
<div
bind:this={menuElement}
class="menu-content"
+2 -2
View File
@@ -50,9 +50,9 @@
<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}>
<!-- 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()}>
<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
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", () => {
+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 type { CharacterState } from "$lib/types/states";
import type { ConnectionStatus } from "$lib/types/messages";
// Track previous states to detect transitions
let previousCharacterState: CharacterState | null = null;
// Track previous connection status to detect transitions
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 {
// 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
}
// Reset notification state for a new response
export function handleNewUserMessage(): void {
hasNotifiedTaskStart = false;
}
// No-op: sound tracking is now per-conversation in tauri.ts
export function handleNewUserMessage(): void {}
// Store unsubscribe functions
let unsubscribeCharacterState: (() => void) | null = null;
// No-op: all per-conversation sounds are driven by tauri.ts event listeners
export function initializeNotificationRules(): void {}
// Initialize listeners
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
// Cleanup — reset connection tracking on teardown
export function cleanupNotificationRules(): void {
if (unsubscribeCharacterState) {
unsubscribeCharacterState();
unsubscribeCharacterState = null;
}
// Reset state tracking
previousCharacterState = 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();
});
});
+8 -5
View File
@@ -1471,11 +1471,14 @@ export const achievementsByRarity = derived(achievementsStore, ($store) => {
return byRarity;
});
export const achievementProgress = derived(achievementsStore, ($store) => ({
unlocked: $store.totalUnlocked,
total: Object.keys($store.achievements).length,
percentage: Math.round(($store.totalUnlocked / Object.keys($store.achievements).length) * 100),
}));
export const achievementProgress = derived(achievementsStore, ($store) => {
const total = Object.keys($store.achievements).length;
return {
unlocked: $store.totalUnlocked,
total,
percentage: Math.round(($store.totalUnlocked / total) * 100),
};
});
// Initialize achievement listener
export async function initAchievementsListener() {
+58 -7
View File
@@ -2,12 +2,15 @@ import { describe, it, expect, beforeEach } from "vitest";
import { agentStore, getAgentsForConversation, runningAgentCount } from "./agents";
import { get } from "svelte/store";
import type { AgentInfo } from "$lib/types/agents";
import { CHARACTER_POOL } from "$lib/utils/agentCharacters";
describe("agents store", () => {
const conversationId = "test-conversation-1";
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",
description: "Test agent",
subagentType: "Explore",
@@ -37,7 +40,29 @@ describe("agents store", () => {
const agents = get(getAgentsForConversation(conversationId));
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", () => {
@@ -49,8 +74,8 @@ describe("agents store", () => {
const agents = get(getAgentsForConversation(conversationId));
expect(agents).toHaveLength(2);
expect(agents[0]).toEqual(agent1);
expect(agents[1]).toEqual(agent2);
expect(agents[0]).toMatchObject(agent1);
expect(agents[1]).toMatchObject(agent2);
});
it("keeps agents in different conversations separate", () => {
@@ -65,8 +90,8 @@ describe("agents store", () => {
expect(agents1).toHaveLength(1);
expect(agents2).toHaveLength(1);
expect(agents1[0]).toEqual(agent1);
expect(agents2[0]).toEqual(agent2);
expect(agents1[0]).toMatchObject(agent1);
expect(agents2[0]).toMatchObject(agent2);
});
});
@@ -152,6 +177,32 @@ describe("agents store", () => {
const agents = get(getAgentsForConversation(conversationId));
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", () => {
@@ -256,7 +307,7 @@ describe("agents store", () => {
expect(agents1).toHaveLength(0);
expect(agents2).toHaveLength(1);
expect(agents2[0]).toEqual(agent2);
expect(agents2[0]).toMatchObject(agent2);
});
it("does nothing if conversation doesn't exist", () => {
+16 -3
View File
@@ -1,5 +1,6 @@
import { writable, derived } from "svelte/store";
import type { AgentInfo } from "$lib/types/agents";
import { assignCharacter } from "$lib/utils/agentCharacters";
// Map of conversation ID -> agents in that conversation
const agentsByConversation = writable<Record<string, AgentInfo[]>>({});
@@ -8,12 +9,17 @@ function createAgentStore() {
return {
subscribe: agentsByConversation.subscribe,
addAgent(conversationId: string, agent: AgentInfo) {
addAgent(conversationId: string, agent: Omit<AgentInfo, "characterName" | "characterAvatar">) {
agentsByConversation.update((state) => {
const existing = state[conversationId] || [];
const activeNames = existing.map((a) => a.characterName);
const character = assignCharacter(activeNames);
return {
...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) => {
const agents = state[conversationId];
if (!agents) return state;
@@ -56,6 +68,7 @@ function createAgentStore() {
endedAt,
status: isError ? "errored" : "completed",
durationMs,
lastAssistantMessage,
};
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);
});
});
+10
View File
@@ -41,6 +41,7 @@ export const claudeStore = {
setWorkingDirectory: conversationsStore.setWorkingDirectory,
setWorkingDirectoryForConversation: conversationsStore.setWorkingDirectoryForConversation,
setProcessing: conversationsStore.setProcessing,
setProcessingForConversation: conversationsStore.setProcessingForConversation,
addLine: conversationsStore.addLine,
addLineToConversation: conversationsStore.addLineToConversation,
updateLine: conversationsStore.updateLine,
@@ -60,6 +61,15 @@ export const claudeStore = {
isToolGranted: conversationsStore.isToolGranted,
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
createConversation: conversationsStore.createConversation,
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();
});
});
});
+712 -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 {
configStore,
maskPaths,
clampFontSize,
applyFontSize,
applyCustomFont,
applyCustomUiFont,
applyTheme,
applyCustomThemeColors,
clearCustomThemeColors,
isDarkTheme,
isStreamerMode,
isCompactMode,
shouldHidePaths,
showThinkingBlocks,
MIN_FONT_SIZE,
MAX_FONT_SIZE,
DEFAULT_FONT_SIZE,
@@ -15,12 +23,17 @@ import {
type CustomThemeColors,
} from "./config";
import { invoke } from "@tauri-apps/api/core";
import { readFile } from "@tauri-apps/plugin-fs";
// Mock Tauri APIs
vi.mock("@tauri-apps/api/core", () => ({
invoke: vi.fn(),
}));
vi.mock("@tauri-apps/plugin-fs", () => ({
readFile: vi.fn(),
}));
describe("config store", () => {
describe("font size constants", () => {
it("has correct MIN_FONT_SIZE", () => {
@@ -194,6 +207,19 @@ describe("config store", () => {
budget_warning_threshold: 0.8,
discord_rpc_enabled: 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,
task_loop_auto_commit: false,
task_loop_commit_prefix: "feat",
task_loop_include_summary: false,
};
expect(config.model).toBe("claude-sonnet-4");
@@ -240,6 +266,19 @@ describe("config store", () => {
budget_warning_threshold: 0.8,
discord_rpc_enabled: 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,
task_loop_auto_commit: false,
task_loop_commit_prefix: "feat",
task_loop_include_summary: false,
};
expect(config.model).toBeNull();
@@ -304,6 +343,62 @@ describe("config store", () => {
expect(document.documentElement.getAttribute("data-theme")).toBe("dark");
});
it("sets data-theme attribute for dracula theme", () => {
applyTheme("dracula");
expect(document.documentElement.getAttribute("data-theme")).toBe("dracula");
});
it("sets data-theme attribute for catppuccin theme", () => {
applyTheme("catppuccin");
expect(document.documentElement.getAttribute("data-theme")).toBe("catppuccin");
});
it("sets data-theme attribute for nord theme", () => {
applyTheme("nord");
expect(document.documentElement.getAttribute("data-theme")).toBe("nord");
});
it("sets data-theme attribute for solarized theme", () => {
applyTheme("solarized");
expect(document.documentElement.getAttribute("data-theme")).toBe("solarized");
});
it("sets data-theme attribute for solarized-light theme", () => {
applyTheme("solarized-light");
expect(document.documentElement.getAttribute("data-theme")).toBe("solarized-light");
});
it("sets data-theme attribute for catppuccin-latte theme", () => {
applyTheme("catppuccin-latte");
expect(document.documentElement.getAttribute("data-theme")).toBe("catppuccin-latte");
});
it("sets data-theme attribute for gruvbox-light theme", () => {
applyTheme("gruvbox-light");
expect(document.documentElement.getAttribute("data-theme")).toBe("gruvbox-light");
});
it("sets data-theme attribute for rose-pine-dawn theme", () => {
applyTheme("rose-pine-dawn");
expect(document.documentElement.getAttribute("data-theme")).toBe("rose-pine-dawn");
});
it("does not apply custom colors for preset themes", () => {
const colors: CustomThemeColors = {
bg_primary: "#ff0000",
bg_secondary: null,
bg_terminal: null,
accent_primary: null,
accent_secondary: null,
text_primary: null,
text_secondary: null,
border_color: null,
};
applyTheme("dracula", colors);
expect(document.documentElement.style.getPropertyValue("--bg-primary")).toBe("");
});
it("applies custom colors when theme is custom", () => {
const colors: CustomThemeColors = {
bg_primary: "#1a1a2e",
@@ -785,6 +880,19 @@ describe("config store", () => {
budget_warning_threshold: 0.9,
discord_rpc_enabled: false,
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,
task_loop_auto_commit: false,
task_loop_commit_prefix: "feat",
task_loop_include_summary: false,
};
const mockInvokeImpl = vi.mocked(invoke);
@@ -825,4 +933,607 @@ describe("config store", () => {
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,
}),
})
);
});
});
});
+151 -1
View File
@@ -1,7 +1,20 @@
import { writable, derived } from "svelte/store";
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"
| "dracula"
| "catppuccin"
| "nord"
| "solarized"
| "solarized-light"
| "catppuccin-latte"
| "gruvbox-light"
| "rose-pine-dawn";
export type BudgetAction = "warn" | "block";
export interface CustomThemeColors {
@@ -47,6 +60,27 @@ export interface HikariConfig {
discord_rpc_enabled: boolean;
// Thinking blocks settings
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;
// Task Loop auto-commit settings
task_loop_auto_commit: boolean;
task_loop_commit_prefix: string;
task_loop_include_summary: boolean;
}
const defaultConfig: HikariConfig = {
@@ -87,6 +121,19 @@ const defaultConfig: HikariConfig = {
budget_warning_threshold: 0.8,
discord_rpc_enabled: 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,
task_loop_auto_commit: false,
task_loop_commit_prefix: "feat",
task_loop_include_summary: false,
};
function createConfigStore() {
@@ -223,6 +270,16 @@ function createConfigStore() {
setCompactMode: async (enabled: boolean) => {
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 +345,99 @@ export function clampFontSize(size: number): number {
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 const configStore = createConfigStore();

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