Compare commits

...

20 Commits

Author SHA1 Message Date
minori 8aff10e922 deps: update @sveltejs/kit to 2.50.2
Security Scan and Upload / Security & DefectDojo Upload (pull_request) Successful in 1m3s
CI / Lint & Test (pull_request) Successful in 27m24s
CI / Build Linux (pull_request) Successful in 22m8s
CI / Build Windows (cross-compile) (pull_request) Successful in 38m18s
2026-02-13 07:11:28 -08:00
minori 384cd4defa deps: update @sveltejs/kit to 2.50.1
Security Scan and Upload / Security & DefectDojo Upload (pull_request) Successful in 2m10s
CI / Lint & Test (pull_request) Successful in 22m10s
CI / Build Linux (pull_request) Successful in 22m27s
CI / Build Windows (cross-compile) (pull_request) Successful in 38m29s
2026-02-04 08:55:32 -08:00
naomi daedbfd865 release: v1.1.1
CI / Lint & Test (push) Successful in 16m1s
CI / Build Linux (push) Successful in 19m55s
CI / Build Windows (cross-compile) (push) Successful in 30m58s
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m56s
2026-01-29 16:57:27 -08:00
naomi 7093e58fe4 fix: capture accurate usage (#80)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 57s
CI / Lint & Test (push) Successful in 16m1s
CI / Build Linux (push) Successful in 19m21s
CI / Build Windows (cross-compile) (push) Successful in 29m6s
### Explanation

_No response_

### Issue

_No response_

### Attestations

- [ ] I have read and agree to the [Code of Conduct](https://docs.nhcarrigan.com/community/coc/)
- [ ] I have read and agree to the [Community Guidelines](https://docs.nhcarrigan.com/community/guide/).
- [ ] My contribution complies with the [Contributor Covenant](https://docs.nhcarrigan.com/dev/covenant/).

### Dependencies

- [ ] I have pinned the dependencies to a specific patch version.

### Style

- [ ] I have run the linter and resolved any errors.
- [ ] My pull request uses an appropriate title, matching the conventional commit standards.
- [ ] My scope of feat/fix/chore/etc. correctly matches the nature of changes in my pull request.

### Tests

- [ ] My contribution adds new code, and I have added tests to cover it.
- [ ] My contribution modifies existing code, and I have updated the tests to reflect these changes.
- [ ] All new and existing tests pass locally with my changes.
- [ ] Code coverage remains at or above the configured threshold.

### Documentation

_No response_

### Versioning

_No response_

Reviewed-on: #80
Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com>
Co-committed-by: Naomi Carrigan <commits@nhcarrigan.com>
2026-01-29 13:34:38 -08:00
naomi cab759ec61 release: v1.1.0
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m9s
CI / Lint & Test (push) Successful in 16m14s
CI / Build Linux (push) Successful in 19m54s
CI / Build Windows (cross-compile) (push) Successful in 29m3s
2026-01-28 18:22:24 -08:00
hikari e45a1a1c98 feat: add built-in file editor with syntax highlighting (#79)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m4s
CI / Build Linux (push) Has been cancelled
CI / Lint & Test (push) Has been cancelled
CI / Build Windows (cross-compile) (push) Has been cancelled
## Summary
- Add CodeMirror 6 editor with syntax highlighting for 40+ languages
- Add file browser sidebar with collapsible directory tree navigation
- Add multi-tab support with dirty state indicators and close buttons
- Add keyboard shortcuts (Ctrl+E toggle, Ctrl+B file browser, Ctrl+S save, Ctrl+W close tab)
- Add editor toggle button to status bar (disabled when not connected)
- Editor automatically uses current session's working directory
- Add Tauri backend commands for file operations (list_directory, read_file_content, write_file_content)

## Test Plan
- [ ] Connect to a session and verify the editor toggle button becomes enabled
- [ ] Press Ctrl+E to open the editor and verify file tree shows the session's CWD
- [ ] Navigate directories and open files to verify syntax highlighting works
- [ ] Edit a file and verify the dirty indicator (*) appears
- [ ] Save with Ctrl+S and verify the dirty indicator disappears
- [ ] Open multiple files and verify tab switching works
- [ ] Close tabs with Ctrl+W or the X button
- [ ] Disconnect and verify the editor automatically closes
- [ ] Verify keyboard shortcuts are documented in the shortcuts modal

Closes #72

 This PR was created with help from Hikari~ 🌸

Reviewed-on: #79
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-01-28 18:20:02 -08:00
naomi edc863e020 feat: add copy buttons to user and assistant messages (#78)
Security Scan and Upload / Security & DefectDojo Upload (push) Failing after 5m26s
CI / Lint & Test (push) Successful in 20m11s
CI / Build Linux (push) Successful in 20m45s
CI / Build Windows (cross-compile) (push) Successful in 28m32s
Closes #74

Reviewed-on: #78
Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com>
Co-committed-by: Naomi Carrigan <commits@nhcarrigan.com>
2026-01-28 15:20:37 -08:00
naomi b006f571bf feat: icon (#77)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m6s
CI / Lint & Test (push) Successful in 16m10s
CI / Build Linux (push) Successful in 19m44s
CI / Build Windows (cross-compile) (push) Successful in 29m42s
### Explanation

_No response_

### Issue

_No response_

### Attestations

- [ ] I have read and agree to the [Code of Conduct](https://docs.nhcarrigan.com/community/coc/)
- [ ] I have read and agree to the [Community Guidelines](https://docs.nhcarrigan.com/community/guide/).
- [ ] My contribution complies with the [Contributor Covenant](https://docs.nhcarrigan.com/dev/covenant/).

### Dependencies

- [ ] I have pinned the dependencies to a specific patch version.

### Style

- [ ] I have run the linter and resolved any errors.
- [ ] My pull request uses an appropriate title, matching the conventional commit standards.
- [ ] My scope of feat/fix/chore/etc. correctly matches the nature of changes in my pull request.

### Tests

- [ ] My contribution adds new code, and I have added tests to cover it.
- [ ] My contribution modifies existing code, and I have updated the tests to reflect these changes.
- [ ] All new and existing tests pass locally with my changes.
- [ ] Code coverage remains at or above the configured threshold.

### Documentation

_No response_

### Versioning

_No response_

Reviewed-on: #77
Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com>
Co-committed-by: Naomi Carrigan <commits@nhcarrigan.com>
2026-01-28 12:51:05 -08:00
naomi ea3cc8b26c feat: enable markdown rendering for user messages (#76)
CI / Lint & Test (push) Has been cancelled
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
### Explanation

_No response_

### Issue

_No response_

### Attestations

- [ ] I have read and agree to the [Code of Conduct](https://docs.nhcarrigan.com/community/coc/)
- [ ] I have read and agree to the [Community Guidelines](https://docs.nhcarrigan.com/community/guide/).
- [ ] My contribution complies with the [Contributor Covenant](https://docs.nhcarrigan.com/dev/covenant/).

### Dependencies

- [ ] I have pinned the dependencies to a specific patch version.

### Style

- [ ] I have run the linter and resolved any errors.
- [ ] My pull request uses an appropriate title, matching the conventional commit standards.
- [ ] My scope of feat/fix/chore/etc. correctly matches the nature of changes in my pull request.

### Tests

- [ ] My contribution adds new code, and I have added tests to cover it.
- [ ] My contribution modifies existing code, and I have updated the tests to reflect these changes.
- [ ] All new and existing tests pass locally with my changes.
- [ ] Code coverage remains at or above the configured threshold.

### Documentation

_No response_

### Versioning

_No response_

Reviewed-on: #76
Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com>
Co-committed-by: Naomi Carrigan <commits@nhcarrigan.com>
2026-01-28 12:50:50 -08:00
hikari 2bb541fba6 docs: update README to standard template
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m3s
CI / Lint & Test (push) Successful in 16m44s
CI / Build Linux (push) Successful in 19m42s
CI / Build Windows (cross-compile) (push) Successful in 30m32s
2026-01-26 12:42:43 -08:00
naomi bebf1552a6 release: v1.0.0
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 50s
CI / Lint & Test (push) Successful in 15m58s
CI / Build Linux (push) Successful in 19m8s
CI / Build Windows (cross-compile) (push) Successful in 28m41s
2026-01-26 00:33:17 -08:00
naomi b3d79a82ef feat: add tests and assert coverage (#71)
CI / Build Linux (push) Has been cancelled
CI / Build Windows (cross-compile) (push) Has been cancelled
CI / Lint & Test (push) Has been cancelled
Security Scan and Upload / Security & DefectDojo Upload (push) Has been cancelled
### Explanation

_No response_

### Issue

_No response_

### Attestations

- [ ] I have read and agree to the [Code of Conduct](https://docs.nhcarrigan.com/community/coc/)
- [ ] I have read and agree to the [Community Guidelines](https://docs.nhcarrigan.com/community/guide/).
- [ ] My contribution complies with the [Contributor Covenant](https://docs.nhcarrigan.com/dev/covenant/).

### Dependencies

- [ ] I have pinned the dependencies to a specific patch version.

### Style

- [ ] I have run the linter and resolved any errors.
- [ ] My pull request uses an appropriate title, matching the conventional commit standards.
- [ ] My scope of feat/fix/chore/etc. correctly matches the nature of changes in my pull request.

### Tests

- [ ] My contribution adds new code, and I have added tests to cover it.
- [ ] My contribution modifies existing code, and I have updated the tests to reflect these changes.
- [ ] All new and existing tests pass locally with my changes.
- [ ] Code coverage remains at or above the configured threshold.

### Documentation

_No response_

### Versioning

_No response_

Co-authored-by: Hikari <hikari@nhcarrigan.com>
Reviewed-on: #71
Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com>
Co-committed-by: Naomi Carrigan <commits@nhcarrigan.com>
2026-01-26 00:26:03 -08:00
hikari 4c46d4c8fd feat: add multiple productivity features and UI enhancements (#68)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 54s
CI / Lint & Test (push) Successful in 14m42s
CI / Build Linux (push) Successful in 19m4s
CI / Build Windows (cross-compile) (push) Successful in 28m37s
## Summary

This PR adds a collection of productivity features and UI enhancements to improve the Hikari Desktop experience:

### New Features
- **Clipboard History** (#25) - Track and manage copied code snippets with language detection, search, filtering, and pinning
- **Quick Actions Panel** (#15) - Buttons for common quick actions like "Review PR", "Run tests", "Explain file", with customizable actions
- **Git Integration Panel** (#24) - View current branch, changed/staged files, quick git actions (commit, push, pull), and branch management
- **Session Import/Export** (#8) - Export conversations to JSON and import previously saved sessions
- **Snippet Library** (#22) - Save and reuse common prompts with categories and quick insert
- **Session History** (#14) - Auto-save conversations with browsable history and search
- **High Contrast Mode** (#20) - Accessibility theme with improved visibility
- **Minimize to System Tray** (#11) - System tray support with right-click menu

### UI Enhancements
- Trans-pride gradient theme applied across UI elements
- Copy button added to code blocks
- Linter formatting and eslint-disable comments for cleaner code

## Closes

Closes #8
Closes #11
Closes #14
Closes #15
Closes #20
Closes #22
Closes #24
Closes #25
Closes #34
Closes #35
Closes #36
Closes #37
Closes #69
Closes #70

## Test Plan

- [ ] Verify clipboard history captures code from code block copy buttons
- [ ] Verify clipboard history captures manually selected text from terminal
- [ ] Test snippet library CRUD operations and insertion
- [ ] Test quick actions panel with default and custom actions
- [ ] Test git panel shows correct status, branch, and performs git operations
- [ ] Test session history auto-save and restore
- [ ] Test session import/export roundtrip
- [ ] Verify high contrast mode provides adequate contrast
- [ ] Test minimize to tray functionality and tray menu
- [ ] Verify trans-pride gradient theme displays correctly in all themes

---
* This PR was created with help from Hikari~ 🌸*

Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com>
Reviewed-on: #68
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-01-25 22:19:00 -08:00
naomi 852a4d6661 feat: add native clipboard support for screenshot paste (#67)
CI / Lint & Test (push) Successful in 14m34s
CI / Build Linux (push) Successful in 18m19s
CI / Build Windows (cross-compile) (push) Successful in 27m57s
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m3s
## Summary
- Adds Tauri clipboard-manager plugin to read images from native clipboard
- Falls back to native clipboard when WebView clipboard API returns empty (fixes screenshot paste)
- Allows sending messages with just attachments (no text required)
- Logs attached files to output with 📎 emoji

## Test plan
- [ ] Build and run the app natively on Windows
- [ ] Copy a screenshot (Win+Shift+S) and paste in the chat input
- [ ] Verify the screenshot appears as an attachment preview
- [ ] Send the attachment and verify Claude receives the file path
- [ ] Test sending a message with only an attachment (no text)
- [ ] Verify the 📎 log line shows the attached filename

**Note:** Paste will not work in WSLg dev environment due to clipboard isolation - needs native Windows build to test.

 This PR was created with help from Hikari~ 🌸

Co-authored-by: Hikari <hikari@nhcarrigan.com>
Reviewed-on: #67
Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com>
Co-committed-by: Naomi Carrigan <commits@nhcarrigan.com>
2026-01-25 13:08:38 -08:00
naomi bbeff7ae2e release: v0.3.0
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 49s
CI / Lint & Test (push) Successful in 14m32s
CI / Build Linux (push) Successful in 15m53s
CI / Build Windows (cross-compile) (push) Successful in 25m32s
2026-01-23 19:08:50 -08:00
naomi 3f30997f0e feat: another wave of features (#61)
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
CI / Lint & Test (push) Has been cancelled
## Explanation

This PR bundles several user-facing improvements and feature additions for the v0.3.0 release, including quality-of-life improvements to the UI, new slash commands, better state persistence, and auto-update checking.

## Included Changes

- **Resizable chat input** with drag handle (#58 partial)
- **Arrow key navigation fix** - cursor keys now navigate text when user has typed input (#58)
- **Scroll position persistence** per conversation tab
- **/skill command** for invoking Claude Code skills (#57)
- **Stats persistence fix** - stats now persist across session changes, only reset on disconnect (#59)
- **Auto-update checker** on startup (#17)
- **Resizable character panel** with full-height sprites (#10)
- **Font size and zoom settings** with keyboard shortcuts (Ctrl++/Ctrl+-/Ctrl+0) (#19)

## Closes

Closes #10, #17, #19, #57, #58, #59

## Attestations

- [x] I have read and agree to the Code of Conduct
- [x] I have read and agree to the Community Guidelines
- [x] My contribution complies with the Contributor Covenant
- [x] I have run the linter and resolved any errors
- [x] My pull request uses an appropriate title, matching the conventional commit standards
- [x] My scope of feat/fix/chore/etc. correctly matches the nature of changes in my pull request
- [x] All new and existing tests pass locally with my changes
- [x] Code coverage remains at or above the configured threshold

## Documentation

N/A - Internal app features

## Versioning

Minor - My pull request introduces new non-breaking features.

---
 This PR was created with help from Hikari~ 🌸

Co-authored-by: Hikari <hikari@nhcarrigan.com>
Reviewed-on: #61
Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com>
Co-committed-by: Naomi Carrigan <commits@nhcarrigan.com>
2026-01-23 19:07:22 -08:00
naomi 06810537a9 feat: add AskUserQuestion tool support (#60)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 53s
CI / Lint & Test (push) Successful in 14m12s
CI / Build Linux (push) Successful in 16m41s
CI / Build Windows (cross-compile) (push) Successful in 27m0s
## Summary

Implements support for Claude's `AskUserQuestion` tool, allowing Claude to ask the user questions with multiple choice options during a conversation.

## Changes

- Add `UserQuestionEvent` and `QuestionOption` types (Rust and TypeScript)
- Detect `AskUserQuestion` in permission denials and emit `claude:question` event
- Create `UserQuestionModal` component with option selection and custom answer input
- Use stop/reconnect approach (same as `PermissionModal`) since Claude API doesn't accept tool_result for permission-denied tools
- Add `pendingQuestion` to conversation store and `hasQuestionPending` derived store

## Technical Notes

We discovered that Claude Code's permission denial system doesn't allow sending tool results back directly - the API rejects them with "unexpected tool_use_id found in tool_result blocks". The solution was to use the same stop/reconnect pattern that permissions use: stop the session, reconnect with context, and include the user's answer in the context restoration message.

## Test Plan

- [x] Build compiles without errors (Rust + TypeScript)
- [x] Question modal appears when Claude uses `AskUserQuestion`
- [x] Can select options and submit answer
- [x] Answer is properly restored to Claude after reconnect

Closes #51

---

 This PR was created with help from Hikari~ 🌸

Co-authored-by: Hikari <hikari@nhcarrigan.com>
Reviewed-on: #60
2026-01-23 14:11:18 -08:00
hikari 94991796be feat: batch of fixes and features (#56)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 57s
CI / Lint & Test (push) Successful in 14m14s
CI / Build Linux (push) Successful in 16m45s
CI / Build Windows (cross-compile) (push) Successful in 26m50s
## Summary

This PR includes a batch of bug fixes and new features:

### Bug Fixes
- **Links in chat history now open in default browser** instead of navigating within the app
  - Closes #54
- **Allow spaces in tab names** - space key no longer acts like enter when renaming tabs
  - Closes #52

### New Features
- **`/cd` command** - Change the working directory of an active tab with context preservation
  - Closes #55
- **`/search` command** - Search and highlight matches within the conversation
  - Closes #32

## Test Plan
- [ ] Click a link in chat history and verify it opens in the default browser
- [ ] Rename a tab and verify spaces can be typed
- [ ] Use `/cd <path>` and verify the directory changes while preserving conversation context
- [ ] Use `/search <query>` and verify matches are highlighted in yellow
- [ ] Use `/search` with no args to clear the search highlighting

 This PR was created with help from Hikari~ 🌸

Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com>
Reviewed-on: #56
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-01-23 11:59:21 -08:00
naomi 947e56ef41 feat: naomi did too much at once (#53)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 53s
CI / Lint & Test (push) Successful in 14m10s
CI / Build Linux (push) Successful in 16m47s
CI / Build Windows (cross-compile) (push) Successful in 26m36s
- feat: add slash commands
- feat: toggle window always on top
- fix: save settings button closes settings panel
- feat: input history (both text and commands)
- feat: add keyboard shortcuts
- feat: add confirmation modal when closing connected tabs
- fix: better text colours in light mode
- fix: handle multiple tabs requesting permission

Closes #6
Closes #13
Closes #21
Closes #28

Reviewed-on: #53
Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com>
Co-committed-by: Naomi Carrigan <commits@nhcarrigan.com>
2026-01-21 17:38:36 -08:00
naomi 9fe4e8a48a feat: add markdown renderer and code block highlighting (#50)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 56s
CI / Lint & Test (push) Successful in 14m14s
CI / Build Linux (push) Successful in 16m45s
CI / Build Windows (cross-compile) (push) Successful in 26m51s
### Explanation

_No response_

### Issue

Closes #33 Closes #31

### Attestations

- [ ] I have read and agree to the [Code of Conduct](https://docs.nhcarrigan.com/community/coc/)
- [ ] I have read and agree to the [Community Guidelines](https://docs.nhcarrigan.com/community/guide/).
- [ ] My contribution complies with the [Contributor Covenant](https://docs.nhcarrigan.com/dev/covenant/).

### Dependencies

- [ ] I have pinned the dependencies to a specific patch version.

### Style

- [ ] I have run the linter and resolved any errors.
- [ ] My pull request uses an appropriate title, matching the conventional commit standards.
- [ ] My scope of feat/fix/chore/etc. correctly matches the nature of changes in my pull request.

### Tests

- [ ] My contribution adds new code, and I have added tests to cover it.
- [ ] My contribution modifies existing code, and I have updated the tests to reflect these changes.
- [ ] All new and existing tests pass locally with my changes.
- [ ] Code coverage remains at or above the configured threshold.

### Documentation

_No response_

### Versioning

_No response_

Reviewed-on: #50
Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com>
Co-committed-by: Naomi Carrigan <commits@nhcarrigan.com>
2026-01-21 11:28:09 -08:00
153 changed files with 28064 additions and 734 deletions
+8 -5
View File
@@ -49,13 +49,13 @@ jobs:
- name: Run Svelte Check - name: Run Svelte Check
run: pnpm check run: pnpm check
- name: Run frontend tests - name: Run frontend tests with coverage
run: pnpm test run: pnpm test:coverage
- name: Setup Rust - name: Setup Rust
uses: dtolnay/rust-toolchain@stable uses: dtolnay/rust-toolchain@stable
with: with:
components: clippy components: clippy, llvm-tools-preview
- name: Cache Rust dependencies - name: Cache Rust dependencies
uses: actions/cache@v4 uses: actions/cache@v4
@@ -68,13 +68,16 @@ jobs:
src-tauri/target/ src-tauri/target/
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
- name: Install cargo-llvm-cov
run: cargo install cargo-llvm-cov --locked
- name: Run Clippy - name: Run Clippy
working-directory: src-tauri working-directory: src-tauri
run: cargo clippy --all-targets --all-features -- -D warnings run: cargo clippy --all-targets --all-features -- -D warnings
- name: Run Rust tests - name: Run Rust tests with coverage
working-directory: src-tauri working-directory: src-tauri
run: cargo test run: cargo llvm-cov --fail-under-lines 50
build-linux: build-linux:
name: Build Linux name: Build Linux
+3
View File
@@ -8,3 +8,6 @@ node_modules
!.env.example !.env.example
vite.config.js.timestamp-* vite.config.js.timestamp-*
vite.config.ts.timestamp-* vite.config.ts.timestamp-*
# Coverage reports
/coverage
+1
View File
@@ -2,6 +2,7 @@ build/
.svelte-kit/ .svelte-kit/
dist/ dist/
src-tauri/target/ src-tauri/target/
src-tauri/gen/
node_modules/ node_modules/
.pnpm-store/ .pnpm-store/
pnpm-lock.yaml pnpm-lock.yaml
+29 -1
View File
@@ -1 +1,29 @@
tem # hikari-desktop
Desktop companion application featuring Hikari.
## Live Version
This page is currently deployed. [View the live website.](https://git.nhcarrigan.com/nhcarrigan/hikari-desktop/releases)
## Feedback and Bugs
If you have feedback or a bug report, please [log a ticket on our forum](https://support.nhcarrigan.com).
## Contributing
If you would like to contribute to the project, you may create a Pull Request containing your proposed changes and we will review it as soon as we are able! Please review our [contributing guidelines](CONTRIBUTING.md) first.
## Code of Conduct
Before interacting with our community, please read our [Code of Conduct](CODE_OF_CONDUCT.md).
## License
This software is licensed under our [global software license](https://docs.nhcarrigan.com/#/license).
Copyright held by Naomi Carrigan.
## Contact
We may be contacted through our [Chat Server](http://chat.nhcarrigan.com) or via email at `contact@nhcarrigan.com`
+1 -1
View File
@@ -27,6 +27,6 @@ export default tseslint.config(
}, },
}, },
{ {
ignores: ["build/", ".svelte-kit/", "dist/", "src-tauri/target/", "node_modules/"], ignores: ["build/", ".svelte-kit/", "dist/", "src-tauri/target/", "node_modules/", "coverage/"],
} }
); );
+40 -4
View File
@@ -1,6 +1,6 @@
{ {
"name": "hikari-desktop", "name": "hikari-desktop",
"version": "0.2.0", "version": "1.1.1",
"description": "", "description": "",
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -16,6 +16,10 @@
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"test:coverage": "vitest run --coverage", "test:coverage": "vitest run --coverage",
"test:backend": "cd src-tauri && cargo test",
"test:backend:coverage": "cd src-tauri && cargo llvm-cov --text",
"test:all": "pnpm test && pnpm test:backend",
"coverage:all": "pnpm test:coverage && pnpm test:backend:coverage",
"lint": "eslint .", "lint": "eslint .",
"lint:fix": "eslint . --fix", "lint:fix": "eslint . --fix",
"format": "prettier --write .", "format": "prettier --write .",
@@ -23,23 +27,55 @@
}, },
"license": "MIT", "license": "MIT",
"dependencies": { "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.11",
"@lezer/highlight": "^1.2.3",
"@tauri-apps/api": "^2", "@tauri-apps/api": "^2",
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
"@tauri-apps/plugin-dialog": "^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-opener": "^2",
"@tauri-apps/plugin-os": "^2",
"@tauri-apps/plugin-shell": "^2.3.4", "@tauri-apps/plugin-shell": "^2.3.4",
"@tauri-apps/plugin-store": "^2", "@tauri-apps/plugin-store": "^2",
"@tauri-apps/plugin-notification": "^2", "codemirror": "^6.0.2",
"@tauri-apps/plugin-os": "^2" "highlight.js": "^11.11.1",
"marked": "^17.0.1"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.2", "@eslint/js": "^9.39.2",
"@sveltejs/adapter-static": "^3.0.6", "@sveltejs/adapter-static": "^3.0.6",
"@sveltejs/kit": "^2.9.0", "@sveltejs/kit": "2.50.2",
"@sveltejs/vite-plugin-svelte": "^5.0.0", "@sveltejs/vite-plugin-svelte": "^5.0.0",
"@tailwindcss/vite": "^4.1.18", "@tailwindcss/vite": "^4.1.18",
"@tauri-apps/cli": "^2", "@tauri-apps/cli": "^2",
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
"@testing-library/svelte": "^5.3.1", "@testing-library/svelte": "^5.3.1",
"@vitest/coverage-v8": "^4.0.18",
"eslint": "^9.39.2", "eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.14.0", "eslint-plugin-svelte": "^3.14.0",
+741 -13
View File
File diff suppressed because it is too large Load Diff
+738 -8
View File
File diff suppressed because it is too large Load Diff
+6 -2
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "hikari-desktop" name = "hikari-desktop"
version = "0.2.0" version = "1.1.1"
description = "Hikari - Claude Code Visual Assistant" description = "Hikari - Claude Code Visual Assistant"
authors = ["Naomi Carrigan"] authors = ["Naomi Carrigan"]
edition = "2021" edition = "2021"
@@ -13,7 +13,7 @@ crate-type = ["staticlib", "cdylib", "rlib"]
tauri-build = { version = "2", features = [] } tauri-build = { version = "2", features = [] }
[dependencies] [dependencies]
tauri = { version = "2", features = [] } tauri = { version = "2", features = ["tray-icon", "image-png"] }
tauri-plugin-dialog = "2" tauri-plugin-dialog = "2"
tauri-plugin-opener = "2" tauri-plugin-opener = "2"
tauri-plugin-shell = "2" tauri-plugin-shell = "2"
@@ -25,7 +25,11 @@ uuid = { version = "1", features = ["v4"] }
tauri-plugin-store = "2.4.2" tauri-plugin-store = "2.4.2"
tauri-plugin-notification = "2" tauri-plugin-notification = "2"
tauri-plugin-os = "2" tauri-plugin-os = "2"
tauri-plugin-http = "2"
tauri-plugin-clipboard-manager = "2"
tauri-plugin-fs = "2"
tempfile = "3" tempfile = "3"
semver = "1"
chrono = { version = "0.4.43", features = ["serde"] } chrono = { version = "0.4.43", features = ["serde"] }
[target.'cfg(windows)'.dependencies] [target.'cfg(windows)'.dependencies]
+18 -1
View File
@@ -13,6 +13,23 @@
"notification:default", "notification:default",
"notification:allow-is-permission-granted", "notification:allow-is-permission-granted",
"notification:allow-request-permission", "notification:allow-request-permission",
"notification:allow-notify" "notification:allow-notify",
"clipboard-manager:default",
"clipboard-manager:allow-read-image",
"core:tray:default",
"fs:default",
"fs:allow-read-text-file",
"fs:allow-write-text-file",
{
"identifier": "fs:allow-read-file",
"allow": [{ "path": "**" }]
},
{
"identifier": "fs:allow-write-file",
"allow": [{ "path": "**" }]
},
"core:window:allow-set-size",
"core:window:allow-set-always-on-top",
"core:window:allow-inner-size"
] ]
} }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.2 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.2 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.2 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 338 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 466 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 878 B

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 KiB

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.8 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 58 KiB

File diff suppressed because it is too large Load Diff
+50 -16
View File
@@ -29,30 +29,40 @@ impl BridgeManager {
conversation_id: &str, conversation_id: &str,
options: ClaudeStartOptions, options: ClaudeStartOptions,
) -> Result<(), String> { ) -> Result<(), String> {
// Check if a bridge already exists for this conversation // Check if a bridge already exists and is running for this conversation
if self.bridges.get(conversation_id).map(|b| b.is_running()).unwrap_or(false) { if self
.bridges
.get(conversation_id)
.map(|b| b.is_running())
.unwrap_or(false)
{
return Err("Claude is already running for this conversation".to_string()); return Err("Claude is already running for this conversation".to_string());
} }
let app = self.app_handle.as_ref() let app = self
.app_handle
.as_ref()
.ok_or_else(|| "App handle not set".to_string())? .ok_or_else(|| "App handle not set".to_string())?
.clone(); .clone();
// Create a new bridge for this conversation // Reuse existing bridge if it exists (preserves stats across reconnects)
let mut bridge = WslBridge::new_with_conversation_id(conversation_id.to_string()); // Only create a new bridge if one doesn't exist for this conversation
let bridge = self
.bridges
.entry(conversation_id.to_string())
.or_insert_with(|| WslBridge::new_with_conversation_id(conversation_id.to_string()));
// Start the Claude process // Start the Claude process
bridge.start(app, options)?; bridge.start(app, options)?;
// Store the bridge
self.bridges.insert(conversation_id.to_string(), bridge);
Ok(()) Ok(())
} }
pub fn stop_claude(&mut self, conversation_id: &str) -> Result<(), String> { pub fn stop_claude(&mut self, conversation_id: &str) -> Result<(), String> {
if let Some(bridge) = self.bridges.get_mut(conversation_id) { if let Some(bridge) = self.bridges.get_mut(conversation_id) {
let app = self.app_handle.as_ref() let app = self
.app_handle
.as_ref()
.ok_or_else(|| "App handle not set".to_string())?; .ok_or_else(|| "App handle not set".to_string())?;
bridge.stop(app); bridge.stop(app);
Ok(()) Ok(())
@@ -63,7 +73,9 @@ impl BridgeManager {
pub fn interrupt_claude(&mut self, conversation_id: &str) -> Result<(), String> { pub fn interrupt_claude(&mut self, conversation_id: &str) -> Result<(), String> {
if let Some(bridge) = self.bridges.get_mut(conversation_id) { if let Some(bridge) = self.bridges.get_mut(conversation_id) {
let app = self.app_handle.as_ref() let app = self
.app_handle
.as_ref()
.ok_or_else(|| "App handle not set".to_string())?; .ok_or_else(|| "App handle not set".to_string())?;
bridge.interrupt(app) bridge.interrupt(app)
} else { } else {
@@ -79,20 +91,36 @@ impl BridgeManager {
} }
} }
pub fn send_tool_result(
&mut self,
conversation_id: &str,
tool_use_id: &str,
result: serde_json::Value,
) -> Result<(), String> {
if let Some(bridge) = self.bridges.get_mut(conversation_id) {
bridge.send_tool_result(tool_use_id, result)
} else {
Err("No Claude instance found for this conversation".to_string())
}
}
pub fn is_claude_running(&self, conversation_id: &str) -> bool { pub fn is_claude_running(&self, conversation_id: &str) -> bool {
self.bridges.get(conversation_id) self.bridges
.get(conversation_id)
.map(|b| b.is_running()) .map(|b| b.is_running())
.unwrap_or(false) .unwrap_or(false)
} }
pub fn get_working_directory(&self, conversation_id: &str) -> Result<String, String> { pub fn get_working_directory(&self, conversation_id: &str) -> Result<String, String> {
self.bridges.get(conversation_id) self.bridges
.get(conversation_id)
.map(|b| b.get_working_directory().to_string()) .map(|b| b.get_working_directory().to_string())
.ok_or_else(|| "No Claude instance found for this conversation".to_string()) .ok_or_else(|| "No Claude instance found for this conversation".to_string())
} }
pub fn get_usage_stats(&self, conversation_id: &str) -> Result<UsageStats, String> { pub fn get_usage_stats(&self, conversation_id: &str) -> Result<UsageStats, String> {
self.bridges.get(conversation_id) self.bridges
.get(conversation_id)
.map(|b| b.get_stats()) .map(|b| b.get_stats())
.ok_or_else(|| "No Claude instance found for this conversation".to_string()) .ok_or_else(|| "No Claude instance found for this conversation".to_string())
} }
@@ -115,8 +143,14 @@ impl BridgeManager {
#[allow(dead_code)] #[allow(dead_code)]
pub fn get_active_conversations(&self) -> Vec<String> { pub fn get_active_conversations(&self) -> Vec<String> {
self.bridges.keys() self.bridges
.filter(|id| self.bridges.get(*id).map(|b| b.is_running()).unwrap_or(false)) .keys()
.filter(|id| {
self.bridges
.get(*id)
.map(|b| b.is_running())
.unwrap_or(false)
})
.cloned() .cloned()
.collect() .collect()
} }
@@ -132,4 +166,4 @@ pub type SharedBridgeManager = Arc<Mutex<BridgeManager>>;
pub fn create_shared_bridge_manager() -> SharedBridgeManager { pub fn create_shared_bridge_manager() -> SharedBridgeManager {
Arc::new(Mutex::new(BridgeManager::new())) Arc::new(Mutex::new(BridgeManager::new()))
} }
+724
View File
@@ -0,0 +1,724 @@
// Clipboard history module for tracking and managing copied code snippets
// Implements issue #25 - Clipboard History feature
use serde::{Deserialize, Serialize};
use std::sync::Mutex;
use tauri_plugin_store::StoreExt;
use uuid::Uuid;
const STORE_FILE: &str = "hikari-clipboard.json";
const HISTORY_KEY: &str = "clipboard_history";
const MAX_HISTORY_SIZE: usize = 100;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClipboardEntry {
pub id: String,
pub content: String,
pub language: Option<String>,
pub source: Option<String>,
pub timestamp: String,
pub is_pinned: bool,
}
impl ClipboardEntry {
pub fn new(content: String, language: Option<String>, source: Option<String>) -> Self {
Self {
id: Uuid::new_v4().to_string(),
content,
language,
source,
timestamp: chrono::Utc::now().to_rfc3339(),
is_pinned: false,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
struct ClipboardHistory {
entries: Vec<ClipboardEntry>,
}
// Track last clipboard content to avoid duplicates
#[derive(Default)]
struct ClipboardState {
last_content: Option<String>,
}
static CLIPBOARD_STATE: Mutex<ClipboardState> = Mutex::new(ClipboardState { last_content: None });
fn load_history(app: &tauri::AppHandle) -> ClipboardHistory {
let store = app.store(STORE_FILE).ok();
store
.and_then(|s| s.get(HISTORY_KEY))
.and_then(|v| serde_json::from_value(v.clone()).ok())
.unwrap_or_default()
}
fn save_history(app: &tauri::AppHandle, history: &ClipboardHistory) -> Result<(), String> {
let store = app.store(STORE_FILE).map_err(|e| e.to_string())?;
store.set(
HISTORY_KEY,
serde_json::to_value(history).map_err(|e| e.to_string())?,
);
store.save().map_err(|e| e.to_string())?;
Ok(())
}
/// List all clipboard entries, optionally filtered by language
#[tauri::command]
pub fn list_clipboard_entries(
app: tauri::AppHandle,
language: Option<String>,
) -> Result<Vec<ClipboardEntry>, String> {
let history = load_history(&app);
let entries = if let Some(lang) = language {
history
.entries
.into_iter()
.filter(|e| e.language.as_ref() == Some(&lang))
.collect()
} else {
history.entries
};
Ok(entries)
}
/// Capture current clipboard content and add to history
#[tauri::command]
pub fn capture_clipboard(
app: tauri::AppHandle,
content: String,
language: Option<String>,
source: Option<String>,
) -> Result<ClipboardEntry, String> {
// Check for duplicate (same content as last capture)
{
let mut state = CLIPBOARD_STATE.lock().map_err(|e| e.to_string())?;
if state.last_content.as_ref() == Some(&content) {
// Return existing entry if content is the same
let history = load_history(&app);
if let Some(entry) = history.entries.first() {
if entry.content == content {
return Ok(entry.clone());
}
}
}
state.last_content = Some(content.clone());
}
let entry = ClipboardEntry::new(content, language, source);
let mut history = load_history(&app);
// Add to front of history
history.entries.insert(0, entry.clone());
// Enforce max size (keep pinned entries)
let mut pinned: Vec<ClipboardEntry> = history
.entries
.iter()
.filter(|e| e.is_pinned)
.cloned()
.collect();
let mut unpinned: Vec<ClipboardEntry> = history
.entries
.into_iter()
.filter(|e| !e.is_pinned)
.collect();
// Trim unpinned entries if over max size
if unpinned.len() + pinned.len() > MAX_HISTORY_SIZE {
let max_unpinned = MAX_HISTORY_SIZE.saturating_sub(pinned.len());
unpinned.truncate(max_unpinned);
}
// Merge back, pinned first then unpinned
pinned.extend(unpinned);
history.entries = pinned;
// Sort by timestamp descending (newest first), pinned entries stay at top
history.entries.sort_by(|a, b| {
if a.is_pinned && !b.is_pinned {
std::cmp::Ordering::Less
} else if !a.is_pinned && b.is_pinned {
std::cmp::Ordering::Greater
} else {
b.timestamp.cmp(&a.timestamp)
}
});
save_history(&app, &history)?;
Ok(entry)
}
/// Delete a clipboard entry by ID
#[tauri::command]
pub fn delete_clipboard_entry(app: tauri::AppHandle, id: String) -> Result<(), String> {
let mut history = load_history(&app);
history.entries.retain(|e| e.id != id);
save_history(&app, &history)?;
Ok(())
}
/// Toggle pin status of an entry
#[tauri::command]
pub fn toggle_pin_clipboard_entry(
app: tauri::AppHandle,
id: String,
) -> Result<ClipboardEntry, String> {
let mut history = load_history(&app);
let entry = history
.entries
.iter_mut()
.find(|e| e.id == id)
.ok_or("Entry not found")?;
entry.is_pinned = !entry.is_pinned;
let updated_entry = entry.clone();
// Re-sort to move pinned entries to top
history.entries.sort_by(|a, b| {
if a.is_pinned && !b.is_pinned {
std::cmp::Ordering::Less
} else if !a.is_pinned && b.is_pinned {
std::cmp::Ordering::Greater
} else {
b.timestamp.cmp(&a.timestamp)
}
});
save_history(&app, &history)?;
Ok(updated_entry)
}
/// Clear all non-pinned entries
#[tauri::command]
pub fn clear_clipboard_history(app: tauri::AppHandle) -> Result<(), String> {
let mut history = load_history(&app);
history.entries.retain(|e| e.is_pinned);
save_history(&app, &history)?;
Ok(())
}
/// Search clipboard entries by content
#[tauri::command]
pub fn search_clipboard_entries(
app: tauri::AppHandle,
query: String,
) -> Result<Vec<ClipboardEntry>, String> {
let history = load_history(&app);
let query_lower = query.to_lowercase();
let entries = history
.entries
.into_iter()
.filter(|e| {
e.content.to_lowercase().contains(&query_lower)
|| e.language
.as_ref()
.is_some_and(|l| l.to_lowercase().contains(&query_lower))
|| e.source
.as_ref()
.is_some_and(|s| s.to_lowercase().contains(&query_lower))
})
.collect();
Ok(entries)
}
/// Get all unique languages from history
#[tauri::command]
pub fn get_clipboard_languages(app: tauri::AppHandle) -> Result<Vec<String>, String> {
let history = load_history(&app);
let mut languages: Vec<String> = history
.entries
.iter()
.filter_map(|e| e.language.clone())
.collect();
languages.sort();
languages.dedup();
Ok(languages)
}
/// Update the language of an entry
#[tauri::command]
pub fn update_clipboard_language(
app: tauri::AppHandle,
id: String,
language: Option<String>,
) -> Result<ClipboardEntry, String> {
let mut history = load_history(&app);
let entry = history
.entries
.iter_mut()
.find(|e| e.id == id)
.ok_or("Entry not found")?;
entry.language = language;
let updated_entry = entry.clone();
save_history(&app, &history)?;
Ok(updated_entry)
}
#[cfg(test)]
mod tests {
use super::*;
// ==================== ClipboardEntry tests ====================
#[test]
fn test_clipboard_entry_new() {
let entry = ClipboardEntry::new(
"let x = 42;".to_string(),
Some("rust".to_string()),
Some("main.rs".to_string()),
);
assert_eq!(entry.content, "let x = 42;");
assert_eq!(entry.language, Some("rust".to_string()));
assert_eq!(entry.source, Some("main.rs".to_string()));
assert!(!entry.is_pinned);
assert!(!entry.id.is_empty());
assert!(!entry.timestamp.is_empty());
}
#[test]
fn test_clipboard_entry_new_without_optional_fields() {
let entry = ClipboardEntry::new("some content".to_string(), None, None);
assert_eq!(entry.content, "some content");
assert!(entry.language.is_none());
assert!(entry.source.is_none());
assert!(!entry.is_pinned);
}
#[test]
fn test_clipboard_entry_unique_ids() {
let entry1 = ClipboardEntry::new("content1".to_string(), None, None);
let entry2 = ClipboardEntry::new("content2".to_string(), None, None);
assert_ne!(entry1.id, entry2.id);
}
#[test]
fn test_clipboard_entry_serialization() {
let entry = ClipboardEntry::new(
"fn main() {}".to_string(),
Some("rust".to_string()),
Some("lib.rs".to_string()),
);
let json = serde_json::to_string(&entry).unwrap();
assert!(json.contains("fn main() {}"));
assert!(json.contains("rust"));
assert!(json.contains("lib.rs"));
assert!(json.contains("is_pinned"));
let deserialized: ClipboardEntry = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.content, entry.content);
assert_eq!(deserialized.language, entry.language);
assert_eq!(deserialized.source, entry.source);
assert_eq!(deserialized.id, entry.id);
}
#[test]
fn test_clipboard_entry_clone() {
let entry = ClipboardEntry::new(
"original".to_string(),
Some("python".to_string()),
None,
);
let cloned = entry.clone();
assert_eq!(cloned.content, entry.content);
assert_eq!(cloned.id, entry.id);
assert_eq!(cloned.language, entry.language);
}
#[test]
fn test_clipboard_entry_timestamp_is_rfc3339() {
let entry = ClipboardEntry::new("test".to_string(), None, None);
// RFC3339 timestamp should parse successfully
let parsed = chrono::DateTime::parse_from_rfc3339(&entry.timestamp);
assert!(parsed.is_ok());
}
// ==================== ClipboardHistory tests ====================
#[test]
fn test_clipboard_history_default() {
let history = ClipboardHistory::default();
assert!(history.entries.is_empty());
}
#[test]
fn test_clipboard_history_serialization() {
let mut history = ClipboardHistory::default();
history.entries.push(ClipboardEntry::new(
"entry1".to_string(),
Some("js".to_string()),
None,
));
history.entries.push(ClipboardEntry::new(
"entry2".to_string(),
None,
Some("file.txt".to_string()),
));
let json = serde_json::to_string(&history).unwrap();
assert!(json.contains("entry1"));
assert!(json.contains("entry2"));
assert!(json.contains("js"));
assert!(json.contains("file.txt"));
let deserialized: ClipboardHistory = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.entries.len(), 2);
}
#[test]
fn test_clipboard_history_entries_order() {
let mut history = ClipboardHistory::default();
history.entries.push(ClipboardEntry::new("first".to_string(), None, None));
history.entries.push(ClipboardEntry::new("second".to_string(), None, None));
history.entries.push(ClipboardEntry::new("third".to_string(), None, None));
assert_eq!(history.entries[0].content, "first");
assert_eq!(history.entries[1].content, "second");
assert_eq!(history.entries[2].content, "third");
}
// ==================== ClipboardState tests ====================
#[test]
fn test_clipboard_state_default() {
let state = ClipboardState::default();
assert!(state.last_content.is_none());
}
#[test]
fn test_clipboard_state_with_content() {
let state = ClipboardState {
last_content: Some("cached content".to_string()),
};
assert_eq!(state.last_content, Some("cached content".to_string()));
}
// ==================== MAX_HISTORY_SIZE constant test ====================
#[test]
fn test_max_history_size_is_reasonable() {
assert_eq!(MAX_HISTORY_SIZE, 100);
// Compile-time assertions for constant bounds
const _: () = assert!(MAX_HISTORY_SIZE > 0);
const _: () = assert!(MAX_HISTORY_SIZE <= 1000); // Sanity check
}
// ==================== Pinned entry sorting tests ====================
#[test]
#[allow(clippy::useless_vec)]
fn test_pinned_entries_sorting() {
let mut entries = vec![
ClipboardEntry {
id: "1".to_string(),
content: "unpinned older".to_string(),
language: None,
source: None,
timestamp: "2024-01-01T00:00:00Z".to_string(),
is_pinned: false,
},
ClipboardEntry {
id: "2".to_string(),
content: "pinned".to_string(),
language: None,
source: None,
timestamp: "2024-01-02T00:00:00Z".to_string(),
is_pinned: true,
},
ClipboardEntry {
id: "3".to_string(),
content: "unpinned newer".to_string(),
language: None,
source: None,
timestamp: "2024-01-03T00:00:00Z".to_string(),
is_pinned: false,
},
];
// Apply the same sorting logic as used in the module
entries.sort_by(|a, b| {
if a.is_pinned && !b.is_pinned {
std::cmp::Ordering::Less
} else if !a.is_pinned && b.is_pinned {
std::cmp::Ordering::Greater
} else {
b.timestamp.cmp(&a.timestamp)
}
});
// Pinned should be first
assert!(entries[0].is_pinned);
assert_eq!(entries[0].id, "2");
// Then unpinned sorted by timestamp descending (newest first)
assert_eq!(entries[1].id, "3"); // newer unpinned
assert_eq!(entries[2].id, "1"); // older unpinned
}
#[test]
#[allow(clippy::useless_vec)]
fn test_multiple_pinned_entries_sorting() {
let mut entries = vec![
ClipboardEntry {
id: "1".to_string(),
content: "pinned older".to_string(),
language: None,
source: None,
timestamp: "2024-01-01T00:00:00Z".to_string(),
is_pinned: true,
},
ClipboardEntry {
id: "2".to_string(),
content: "unpinned".to_string(),
language: None,
source: None,
timestamp: "2024-01-02T00:00:00Z".to_string(),
is_pinned: false,
},
ClipboardEntry {
id: "3".to_string(),
content: "pinned newer".to_string(),
language: None,
source: None,
timestamp: "2024-01-03T00:00:00Z".to_string(),
is_pinned: true,
},
];
entries.sort_by(|a, b| {
if a.is_pinned && !b.is_pinned {
std::cmp::Ordering::Less
} else if !a.is_pinned && b.is_pinned {
std::cmp::Ordering::Greater
} else {
b.timestamp.cmp(&a.timestamp)
}
});
// Both pinned first, sorted by timestamp
assert!(entries[0].is_pinned);
assert_eq!(entries[0].id, "3"); // pinned newer
assert!(entries[1].is_pinned);
assert_eq!(entries[1].id, "1"); // pinned older
// Then unpinned
assert!(!entries[2].is_pinned);
assert_eq!(entries[2].id, "2");
}
// ==================== Entry filtering tests ====================
#[test]
fn test_filter_entries_by_language() {
let history = ClipboardHistory {
entries: vec![
ClipboardEntry {
id: "1".to_string(),
content: "rust code".to_string(),
language: Some("rust".to_string()),
source: None,
timestamp: "2024-01-01T00:00:00Z".to_string(),
is_pinned: false,
},
ClipboardEntry {
id: "2".to_string(),
content: "js code".to_string(),
language: Some("javascript".to_string()),
source: None,
timestamp: "2024-01-02T00:00:00Z".to_string(),
is_pinned: false,
},
ClipboardEntry {
id: "3".to_string(),
content: "more rust".to_string(),
language: Some("rust".to_string()),
source: None,
timestamp: "2024-01-03T00:00:00Z".to_string(),
is_pinned: false,
},
],
};
let filtered: Vec<_> = history
.entries
.iter()
.filter(|e| e.language.as_ref() == Some(&"rust".to_string()))
.collect();
assert_eq!(filtered.len(), 2);
assert!(filtered.iter().all(|e| e.language == Some("rust".to_string())));
}
#[test]
fn test_search_entries_by_content() {
let history = ClipboardHistory {
entries: vec![
ClipboardEntry {
id: "1".to_string(),
content: "fn hello_world()".to_string(),
language: Some("rust".to_string()),
source: None,
timestamp: "2024-01-01T00:00:00Z".to_string(),
is_pinned: false,
},
ClipboardEntry {
id: "2".to_string(),
content: "function hello()".to_string(),
language: Some("javascript".to_string()),
source: None,
timestamp: "2024-01-02T00:00:00Z".to_string(),
is_pinned: false,
},
ClipboardEntry {
id: "3".to_string(),
content: "def goodbye()".to_string(),
language: Some("python".to_string()),
source: None,
timestamp: "2024-01-03T00:00:00Z".to_string(),
is_pinned: false,
},
],
};
let query = "hello";
let query_lower = query.to_lowercase();
let filtered: Vec<_> = history
.entries
.iter()
.filter(|e| e.content.to_lowercase().contains(&query_lower))
.collect();
assert_eq!(filtered.len(), 2);
assert!(filtered[0].content.contains("hello"));
assert!(filtered[1].content.contains("hello"));
}
#[test]
fn test_search_entries_case_insensitive() {
let history = ClipboardHistory {
entries: vec![
ClipboardEntry {
id: "1".to_string(),
content: "HELLO WORLD".to_string(),
language: None,
source: None,
timestamp: "2024-01-01T00:00:00Z".to_string(),
is_pinned: false,
},
],
};
let query = "hello";
let query_lower = query.to_lowercase();
let filtered: Vec<_> = history
.entries
.iter()
.filter(|e| e.content.to_lowercase().contains(&query_lower))
.collect();
assert_eq!(filtered.len(), 1);
}
// ==================== Unique languages extraction test ====================
#[test]
fn test_extract_unique_languages() {
let history = ClipboardHistory {
entries: vec![
ClipboardEntry {
id: "1".to_string(),
content: "".to_string(),
language: Some("rust".to_string()),
source: None,
timestamp: "".to_string(),
is_pinned: false,
},
ClipboardEntry {
id: "2".to_string(),
content: "".to_string(),
language: Some("javascript".to_string()),
source: None,
timestamp: "".to_string(),
is_pinned: false,
},
ClipboardEntry {
id: "3".to_string(),
content: "".to_string(),
language: Some("rust".to_string()), // Duplicate
source: None,
timestamp: "".to_string(),
is_pinned: false,
},
ClipboardEntry {
id: "4".to_string(),
content: "".to_string(),
language: None, // No language
source: None,
timestamp: "".to_string(),
is_pinned: false,
},
],
};
let mut languages: Vec<String> = history
.entries
.iter()
.filter_map(|e| e.language.clone())
.collect();
languages.sort();
languages.dedup();
assert_eq!(languages.len(), 2);
assert!(languages.contains(&"rust".to_string()));
assert!(languages.contains(&"javascript".to_string()));
}
// ==================== Retain pinned entries test ====================
#[test]
fn test_retain_pinned_on_clear() {
let mut history = ClipboardHistory {
entries: vec![
ClipboardEntry {
id: "1".to_string(),
content: "pinned".to_string(),
language: None,
source: None,
timestamp: "".to_string(),
is_pinned: true,
},
ClipboardEntry {
id: "2".to_string(),
content: "unpinned".to_string(),
language: None,
source: None,
timestamp: "".to_string(),
is_pinned: false,
},
ClipboardEntry {
id: "3".to_string(),
content: "another pinned".to_string(),
language: None,
source: None,
timestamp: "".to_string(),
is_pinned: true,
},
],
};
// Simulate clear (keep only pinned)
history.entries.retain(|e| e.is_pinned);
assert_eq!(history.entries.len(), 2);
assert!(history.entries.iter().all(|e| e.is_pinned));
}
}
+706 -15
View File
@@ -1,10 +1,13 @@
use std::path::PathBuf;
use tauri::{AppHandle, State}; use tauri::{AppHandle, State};
use tauri_plugin_http::reqwest;
use tauri_plugin_store::StoreExt; 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::config::{ClaudeStartOptions, HikariConfig};
use crate::stats::UsageStats; use crate::stats::UsageStats;
use crate::bridge_manager::SharedBridgeManager; use crate::temp_manager::SharedTempFileManager;
use crate::achievements::{load_achievements, get_achievement_info, AchievementUnlockedEvent};
const CONFIG_STORE_KEY: &str = "config"; const CONFIG_STORE_KEY: &str = "config";
@@ -71,23 +74,17 @@ pub async fn select_wsl_directory() -> Result<String, String> {
#[tauri::command] #[tauri::command]
pub async fn get_config(app: AppHandle) -> Result<HikariConfig, String> { pub async fn get_config(app: AppHandle) -> Result<HikariConfig, String> {
let store = app let store = app.store("hikari-config.json").map_err(|e| e.to_string())?;
.store("hikari-config.json")
.map_err(|e| e.to_string())?;
match store.get(CONFIG_STORE_KEY) { match store.get(CONFIG_STORE_KEY) {
Some(value) => { Some(value) => serde_json::from_value(value.clone()).map_err(|e| e.to_string()),
serde_json::from_value(value.clone()).map_err(|e| e.to_string())
}
None => Ok(HikariConfig::default()), None => Ok(HikariConfig::default()),
} }
} }
#[tauri::command] #[tauri::command]
pub async fn save_config(app: AppHandle, config: HikariConfig) -> Result<(), String> { pub async fn save_config(app: AppHandle, config: HikariConfig) -> Result<(), String> {
let store = app let store = app.store("hikari-config.json").map_err(|e| e.to_string())?;
.store("hikari-config.json")
.map_err(|e| e.to_string())?;
let value = serde_json::to_value(&config).map_err(|e| e.to_string())?; let value = serde_json::to_value(&config).map_err(|e| e.to_string())?;
store.set(CONFIG_STORE_KEY, value); store.set(CONFIG_STORE_KEY, value);
@@ -105,8 +102,77 @@ pub async fn get_usage_stats(
manager.get_usage_stats(&conversation_id) manager.get_usage_stats(&conversation_id)
} }
/// Load persisted lifetime stats from store (no bridge required)
#[tauri::command] #[tauri::command]
pub async fn load_saved_achievements(app: AppHandle) -> Result<Vec<AchievementUnlockedEvent>, String> { pub async fn get_persisted_stats(app: AppHandle) -> Result<UsageStats, String> {
let mut stats = UsageStats::new();
// Load persisted stats if available
if let Some(persisted) = crate::stats::load_stats(&app).await {
stats.apply_persisted(persisted);
}
Ok(stats)
}
#[tauri::command]
pub async fn validate_directory(
path: String,
current_dir: Option<String>,
) -> Result<String, String> {
use std::path::Path;
let path = Path::new(&path);
// Expand ~ to home directory
let expanded_path = if path.starts_with("~") {
if let Some(home) = std::env::var_os("HOME") {
let home_path = Path::new(&home);
if path == Path::new("~") {
home_path.to_path_buf()
} else {
home_path.join(path.strip_prefix("~").unwrap())
}
} else {
return Err("Could not determine home directory".to_string());
}
} else if path.is_relative() {
// Handle relative paths (., .., or any relative path) by resolving against current_dir
if let Some(ref cwd) = current_dir {
Path::new(cwd).join(path)
} else {
path.to_path_buf()
}
} else {
path.to_path_buf()
};
// Check if the path exists and is a directory
if !expanded_path.exists() {
return Err(format!(
"Directory does not exist: {}",
expanded_path.display()
));
}
if !expanded_path.is_dir() {
return Err(format!(
"Path is not a directory: {}",
expanded_path.display()
));
}
// Return the canonicalized (absolute) path
expanded_path
.canonicalize()
.map(|p| p.to_string_lossy().to_string())
.map_err(|e| format!("Failed to resolve path: {}", e))
}
#[tauri::command]
pub async fn load_saved_achievements(
app: AppHandle,
) -> Result<Vec<AchievementUnlockedEvent>, String> {
use chrono::Utc; use chrono::Utc;
// Load achievements from persistent store // Load achievements from persistent store
@@ -117,10 +183,635 @@ pub async fn load_saved_achievements(app: AppHandle) -> Result<Vec<AchievementUn
for achievement_id in &progress.unlocked { for achievement_id in &progress.unlocked {
let mut info = get_achievement_info(achievement_id); let mut info = get_achievement_info(achievement_id);
info.unlocked_at = Some(Utc::now()); // We don't store timestamps, so just use now info.unlocked_at = Some(Utc::now()); // We don't store timestamps, so just use now
events.push(AchievementUnlockedEvent { events.push(AchievementUnlockedEvent { achievement: info });
achievement: info,
});
} }
Ok(events) Ok(events)
} }
#[tauri::command]
pub async fn answer_question(
bridge_manager: State<'_, SharedBridgeManager>,
conversation_id: String,
tool_use_id: String,
answers: serde_json::Value,
) -> Result<(), String> {
let mut manager = bridge_manager.lock();
manager.send_tool_result(&conversation_id, &tool_use_id, answers)
}
#[tauri::command]
pub async fn list_skills() -> Result<Vec<String>, String> {
use std::fs;
use std::path::Path;
// Get the home directory
let home =
std::env::var_os("HOME").ok_or_else(|| "Could not determine home directory".to_string())?;
let skills_dir = Path::new(&home).join(".claude").join("skills");
// If the skills directory doesn't exist, return empty list
if !skills_dir.exists() {
return Ok(Vec::new());
}
// Read the directory and collect skill names
let mut skills = Vec::new();
let entries =
fs::read_dir(&skills_dir).map_err(|e| format!("Failed to read skills directory: {}", e))?;
for entry in entries {
let entry = entry.map_err(|e| format!("Failed to read directory entry: {}", e))?;
let path = entry.path();
// Only include directories that contain a SKILL.md file
if path.is_dir() {
let skill_file = path.join("SKILL.md");
if skill_file.exists() {
if let Some(name) = path.file_name() {
skills.push(name.to_string_lossy().to_string());
}
}
}
}
// Sort alphabetically
skills.sort();
Ok(skills)
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct UpdateInfo {
pub current_version: String,
pub latest_version: String,
pub has_update: bool,
pub release_url: String,
pub release_notes: Option<String>,
}
#[derive(Debug, serde::Deserialize)]
struct GiteaRelease {
tag_name: String,
html_url: String,
body: Option<String>,
prerelease: bool,
}
#[tauri::command]
pub async fn check_for_updates() -> Result<UpdateInfo, String> {
const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
const RELEASES_API: &str =
"https://git.nhcarrigan.com/api/v1/repos/nhcarrigan/hikari-desktop/releases";
// Fetch releases from Gitea API
let client = reqwest::Client::new();
let response = client
.get(RELEASES_API)
.header("Accept", "application/json")
.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<GiteaRelease> =
serde_json::from_str(&text).map_err(|e| format!("Failed to parse releases: {}", e))?;
// Find the latest non-prerelease, or fall back to latest prerelease
let latest = releases
.iter()
.find(|r| !r.prerelease)
.or_else(|| releases.first());
let latest = match latest {
Some(r) => r,
None => return Err("No releases found".to_string()),
};
// Parse version strings (remove 'v' prefix if present)
let current = semver::Version::parse(CURRENT_VERSION)
.map_err(|e| format!("Failed to parse current version: {}", e))?;
let latest_tag = latest.tag_name.trim_start_matches('v');
let latest_ver = semver::Version::parse(latest_tag)
.map_err(|e| format!("Failed to parse latest version: {}", e))?;
Ok(UpdateInfo {
current_version: CURRENT_VERSION.to_string(),
latest_version: latest.tag_name.clone(),
has_update: latest_ver > current,
release_url: latest.html_url.clone(),
release_notes: latest.body.clone(),
})
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct SavedFileInfo {
pub path: String,
pub filename: String,
}
#[tauri::command]
pub async fn save_temp_file(
temp_manager: State<'_, SharedTempFileManager>,
conversation_id: String,
data: Vec<u8>,
filename: Option<String>,
) -> Result<SavedFileInfo, String> {
let mut manager = temp_manager.lock();
let path = manager.save_file(&conversation_id, &data, filename.as_deref())?;
let filename = path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "unknown".to_string());
Ok(SavedFileInfo {
path: path.to_string_lossy().to_string(),
filename,
})
}
#[tauri::command]
pub async fn register_temp_file(
temp_manager: State<'_, SharedTempFileManager>,
conversation_id: String,
file_path: String,
) -> Result<(), String> {
let mut manager = temp_manager.lock();
manager.register_file(&conversation_id, PathBuf::from(file_path));
Ok(())
}
#[tauri::command]
pub async fn get_temp_files(
temp_manager: State<'_, SharedTempFileManager>,
conversation_id: String,
) -> Result<Vec<String>, String> {
let manager = temp_manager.lock();
let files = manager.get_files_for_conversation(&conversation_id);
Ok(files.iter().map(|p| p.to_string_lossy().to_string()).collect())
}
#[tauri::command]
pub async fn cleanup_temp_files(
temp_manager: State<'_, SharedTempFileManager>,
conversation_id: String,
) -> Result<(), String> {
let mut manager = temp_manager.lock();
manager.cleanup_conversation(&conversation_id)
}
#[tauri::command]
pub async fn cleanup_all_temp_files(
temp_manager: State<'_, SharedTempFileManager>,
) -> Result<(), String> {
let mut manager = temp_manager.lock();
manager.cleanup_all()
}
#[tauri::command]
pub async fn cleanup_orphaned_temp_files(
temp_manager: State<'_, SharedTempFileManager>,
) -> Result<usize, String> {
let mut manager = temp_manager.lock();
manager.cleanup_orphaned_files()
}
#[tauri::command]
pub async fn get_file_size(file_path: String) -> Result<u64, String> {
let metadata = std::fs::metadata(&file_path)
.map_err(|e| format!("Failed to get file metadata: {}", e))?;
Ok(metadata.len())
}
// ==================== Editor File Operations ====================
#[derive(Debug, Clone, serde::Serialize)]
pub struct FileEntry {
pub name: String,
pub path: String,
#[serde(rename = "isDirectory")]
pub is_directory: bool,
}
#[tauri::command]
pub async fn list_directory(path: String) -> Result<Vec<FileEntry>, String> {
use std::fs;
use std::path::Path;
let dir_path = Path::new(&path);
if !dir_path.exists() {
return Err(format!("Directory does not exist: {}", path));
}
if !dir_path.is_dir() {
return Err(format!("Path is not a directory: {}", path));
}
let entries = fs::read_dir(dir_path)
.map_err(|e| format!("Failed to read directory: {}", e))?;
let mut file_entries = Vec::new();
for entry in entries {
let entry = entry.map_err(|e| format!("Failed to read entry: {}", e))?;
let path = entry.path();
let name = entry
.file_name()
.to_string_lossy()
.to_string();
// Skip hidden files by default (can be made configurable later)
if name.starts_with('.') {
continue;
}
file_entries.push(FileEntry {
name,
path: path.to_string_lossy().to_string(),
is_directory: path.is_dir(),
});
}
Ok(file_entries)
}
#[tauri::command]
pub async fn read_file_content(path: String) -> Result<String, String> {
use std::fs;
fs::read_to_string(&path)
.map_err(|e| format!("Failed to read file: {}", e))
}
#[tauri::command]
pub async fn write_file_content(path: String, content: String) -> Result<(), String> {
use std::fs;
fs::write(&path, content)
.map_err(|e| format!("Failed to write file: {}", e))
}
#[tauri::command]
pub async fn create_file(path: String) -> Result<(), String> {
use std::fs::File;
use std::path::Path;
let file_path = Path::new(&path);
if file_path.exists() {
return Err("File already exists".to_string());
}
File::create(file_path).map_err(|e| format!("Failed to create file: {}", e))?;
Ok(())
}
#[tauri::command]
pub async fn create_directory(path: String) -> Result<(), String> {
use std::fs;
use std::path::Path;
let dir_path = Path::new(&path);
if dir_path.exists() {
return Err("Directory already exists".to_string());
}
fs::create_dir_all(dir_path).map_err(|e| format!("Failed to create directory: {}", e))?;
Ok(())
}
#[tauri::command]
pub async fn delete_file(path: String) -> Result<(), String> {
use std::fs;
use std::path::Path;
let file_path = Path::new(&path);
if !file_path.exists() {
return Err("File does not exist".to_string());
}
if file_path.is_dir() {
return Err("Path is a directory, use delete_directory instead".to_string());
}
fs::remove_file(file_path).map_err(|e| format!("Failed to delete file: {}", e))?;
Ok(())
}
#[tauri::command]
pub async fn delete_directory(path: String) -> Result<(), String> {
use std::fs;
use std::path::Path;
let dir_path = Path::new(&path);
if !dir_path.exists() {
return Err("Directory does not exist".to_string());
}
if !dir_path.is_dir() {
return Err("Path is not a directory".to_string());
}
fs::remove_dir_all(dir_path).map_err(|e| format!("Failed to delete directory: {}", e))?;
Ok(())
}
#[tauri::command]
pub async fn rename_path(old_path: String, new_path: String) -> Result<(), String> {
use std::fs;
use std::path::Path;
let old = Path::new(&old_path);
let new = Path::new(&new_path);
if !old.exists() {
return Err("Path does not exist".to_string());
}
if new.exists() {
return Err("Destination already exists".to_string());
}
fs::rename(old, new).map_err(|e| format!("Failed to rename: {}", e))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs::{self, File};
use std::io::Write;
use tempfile::TempDir;
// Helper to run async tests
fn run_async<F: std::future::Future>(f: F) -> F::Output {
tokio::runtime::Runtime::new().unwrap().block_on(f)
}
// ==================== validate_directory tests ====================
#[test]
fn test_validate_directory_absolute_path_exists() {
let temp_dir = TempDir::new().unwrap();
let path = temp_dir.path().to_string_lossy().to_string();
let result = run_async(validate_directory(path.clone(), None));
assert!(result.is_ok());
// Canonicalized path should be returned
assert!(result.unwrap().contains(&temp_dir.path().file_name().unwrap().to_string_lossy().to_string()));
}
#[test]
fn test_validate_directory_path_not_exists() {
let result = run_async(validate_directory(
"/nonexistent/path/that/does/not/exist".to_string(),
None,
));
assert!(result.is_err());
assert!(result.unwrap_err().contains("does not exist"));
}
#[test]
fn test_validate_directory_path_is_file() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("test_file.txt");
File::create(&file_path).unwrap();
let result = run_async(validate_directory(
file_path.to_string_lossy().to_string(),
None,
));
assert!(result.is_err());
assert!(result.unwrap_err().contains("not a directory"));
}
#[test]
fn test_validate_directory_home_expansion() {
// This test assumes HOME is set (which it should be on most systems)
if std::env::var_os("HOME").is_some() {
let result = run_async(validate_directory("~".to_string(), None));
assert!(result.is_ok());
// Should not contain ~ after expansion
assert!(!result.unwrap().contains("~"));
}
}
#[test]
fn test_validate_directory_home_subpath_expansion() {
// This test assumes HOME is set and has some subdirectory
if let Some(home) = std::env::var_os("HOME") {
let home_path = std::path::Path::new(&home);
// Find any subdirectory in home
if let Ok(entries) = fs::read_dir(home_path) {
for entry in entries.flatten() {
if entry.path().is_dir() {
let subdir_name = entry.file_name().to_string_lossy().to_string();
let tilde_path = format!("~/{}", subdir_name);
let result = run_async(validate_directory(tilde_path, None));
assert!(result.is_ok());
assert!(!result.unwrap().contains("~"));
break;
}
}
}
}
}
#[test]
fn test_validate_directory_relative_path_with_current_dir() {
let temp_dir = TempDir::new().unwrap();
let subdir = temp_dir.path().join("subdir");
fs::create_dir(&subdir).unwrap();
let result = run_async(validate_directory(
"subdir".to_string(),
Some(temp_dir.path().to_string_lossy().to_string()),
));
assert!(result.is_ok());
assert!(result.unwrap().contains("subdir"));
}
#[test]
fn test_validate_directory_dot_path() {
let temp_dir = TempDir::new().unwrap();
let result = run_async(validate_directory(
".".to_string(),
Some(temp_dir.path().to_string_lossy().to_string()),
));
assert!(result.is_ok());
}
#[test]
fn test_validate_directory_dotdot_path() {
let temp_dir = TempDir::new().unwrap();
let subdir = temp_dir.path().join("subdir");
fs::create_dir(&subdir).unwrap();
let result = run_async(validate_directory(
"..".to_string(),
Some(subdir.to_string_lossy().to_string()),
));
assert!(result.is_ok());
// Should resolve to parent
let resolved = result.unwrap();
assert!(resolved.contains(&temp_dir.path().file_name().unwrap().to_string_lossy().to_string()));
}
#[test]
fn test_validate_directory_relative_without_current_dir() {
// Relative path without current_dir - should fail since relative path likely won't exist
let result = run_async(validate_directory(
"some_random_nonexistent_relative_path".to_string(),
None,
));
assert!(result.is_err());
}
// ==================== get_file_size tests ====================
#[test]
fn test_get_file_size_empty_file() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("empty.txt");
File::create(&file_path).unwrap();
let result = run_async(get_file_size(file_path.to_string_lossy().to_string()));
assert!(result.is_ok());
assert_eq!(result.unwrap(), 0);
}
#[test]
fn test_get_file_size_with_content() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("content.txt");
let mut file = File::create(&file_path).unwrap();
file.write_all(b"Hello, Hikari!").unwrap();
let result = run_async(get_file_size(file_path.to_string_lossy().to_string()));
assert!(result.is_ok());
assert_eq!(result.unwrap(), 14); // "Hello, Hikari!" is 14 bytes
}
#[test]
fn test_get_file_size_larger_file() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("large.txt");
let mut file = File::create(&file_path).unwrap();
// Write 1000 bytes
let data = vec![b'x'; 1000];
file.write_all(&data).unwrap();
let result = run_async(get_file_size(file_path.to_string_lossy().to_string()));
assert!(result.is_ok());
assert_eq!(result.unwrap(), 1000);
}
#[test]
fn test_get_file_size_nonexistent_file() {
let result = run_async(get_file_size(
"/nonexistent/path/file.txt".to_string(),
));
assert!(result.is_err());
assert!(result.unwrap_err().contains("Failed to get file metadata"));
}
#[test]
fn test_get_file_size_directory() {
let temp_dir = TempDir::new().unwrap();
// Getting "size" of a directory should work but return directory metadata
// This is actually valid - directories have metadata too
let result = run_async(get_file_size(temp_dir.path().to_string_lossy().to_string()));
assert!(result.is_ok());
// Directory size is platform-dependent, just check it returns something
}
// ==================== list_skills tests ====================
#[test]
fn test_list_skills_no_skills_dir() {
// This test is tricky because it depends on HOME being set
// and potentially affecting real user data, so we'll just
// verify the function doesn't panic
let result = run_async(list_skills());
// Should either return Ok with a list or Ok with empty vec
assert!(result.is_ok());
}
// ==================== select_wsl_directory tests ====================
#[test]
fn test_select_wsl_directory_returns_home() {
let result = run_async(select_wsl_directory());
assert!(result.is_ok());
assert_eq!(result.unwrap(), "/home");
}
// ==================== UpdateInfo struct tests ====================
#[test]
fn test_update_info_serialization() {
let info = UpdateInfo {
current_version: "1.0.0".to_string(),
latest_version: "0.4.0".to_string(),
has_update: true,
release_url: "https://example.com/release".to_string(),
release_notes: Some("New features!".to_string()),
};
let json = serde_json::to_string(&info).unwrap();
assert!(json.contains("1.0.0"));
assert!(json.contains("0.4.0"));
assert!(json.contains("true"));
assert!(json.contains("New features!"));
}
#[test]
fn test_update_info_without_notes() {
let info = UpdateInfo {
current_version: "1.0.0".to_string(),
latest_version: "1.0.0".to_string(),
has_update: false,
release_url: "https://example.com/release".to_string(),
release_notes: None,
};
let json = serde_json::to_string(&info).unwrap();
assert!(json.contains("null") || json.contains("release_notes"));
}
// ==================== SavedFileInfo struct tests ====================
#[test]
fn test_saved_file_info_serialization() {
let info = SavedFileInfo {
path: "/tmp/test.txt".to_string(),
filename: "test.txt".to_string(),
};
let json = serde_json::to_string(&info).unwrap();
assert!(json.contains("/tmp/test.txt"));
assert!(json.contains("test.txt"));
}
}
+120 -1
View File
@@ -22,6 +22,9 @@ pub struct ClaudeStartOptions {
#[serde(default)] #[serde(default)]
pub skip_greeting: bool, pub skip_greeting: bool,
#[serde(default)]
pub resume_session_id: Option<String>,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -55,6 +58,44 @@ pub struct HikariConfig {
#[serde(default = "default_notification_volume")] #[serde(default = "default_notification_volume")]
pub notification_volume: f32, pub notification_volume: f32,
#[serde(default)]
pub always_on_top: bool,
#[serde(default = "default_update_checks_enabled")]
pub update_checks_enabled: bool,
#[serde(default)]
pub character_panel_width: Option<u32>,
#[serde(default = "default_font_size")]
pub font_size: u32,
#[serde(default)]
pub minimize_to_tray: bool,
#[serde(default)]
pub streamer_mode: bool,
#[serde(default)]
pub streamer_hide_paths: bool,
#[serde(default)]
pub compact_mode: bool,
// Profile fields
#[serde(default)]
pub profile_name: Option<String>,
#[serde(default)]
pub profile_avatar_path: Option<String>,
#[serde(default)]
pub profile_bio: Option<String>,
// Custom theme colors
#[serde(default)]
pub custom_theme_colors: CustomThemeColors,
} }
impl Default for HikariConfig { impl Default for HikariConfig {
@@ -70,10 +111,26 @@ impl Default for HikariConfig {
greeting_custom_prompt: None, greeting_custom_prompt: None,
notifications_enabled: true, notifications_enabled: true,
notification_volume: 0.7, notification_volume: 0.7,
always_on_top: false,
update_checks_enabled: true,
character_panel_width: None,
font_size: 14,
minimize_to_tray: false,
streamer_mode: false,
streamer_hide_paths: false,
compact_mode: false,
profile_name: None,
profile_avatar_path: None,
profile_bio: None,
custom_theme_colors: CustomThemeColors::default(),
} }
} }
} }
fn default_update_checks_enabled() -> bool {
true
}
fn default_greeting_enabled() -> bool { fn default_greeting_enabled() -> bool {
true true
} }
@@ -86,12 +143,39 @@ fn default_notification_volume() -> f32 {
0.7 0.7
} }
fn default_font_size() -> u32 {
14
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] #[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
pub enum Theme { pub enum Theme {
#[default] #[default]
Dark, Dark,
Light, Light,
#[serde(rename = "high-contrast")]
HighContrast,
Custom,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
pub struct CustomThemeColors {
#[serde(default)]
pub bg_primary: Option<String>,
#[serde(default)]
pub bg_secondary: Option<String>,
#[serde(default)]
pub bg_terminal: Option<String>,
#[serde(default)]
pub accent_primary: Option<String>,
#[serde(default)]
pub accent_secondary: Option<String>,
#[serde(default)]
pub text_primary: Option<String>,
#[serde(default)]
pub text_secondary: Option<String>,
#[serde(default)]
pub border_color: Option<String>,
} }
#[cfg(test)] #[cfg(test)]
@@ -109,6 +193,18 @@ mod tests {
assert_eq!(config.theme, Theme::Dark); assert_eq!(config.theme, Theme::Dark);
assert!(config.greeting_enabled); assert!(config.greeting_enabled);
assert!(config.greeting_custom_prompt.is_none()); assert!(config.greeting_custom_prompt.is_none());
assert!(!config.always_on_top);
assert!(config.update_checks_enabled);
assert!(config.character_panel_width.is_none());
assert_eq!(config.font_size, 14);
assert!(!config.minimize_to_tray);
assert!(!config.streamer_mode);
assert!(!config.streamer_hide_paths);
assert!(!config.compact_mode);
assert!(config.profile_name.is_none());
assert!(config.profile_avatar_path.is_none());
assert!(config.profile_bio.is_none());
assert_eq!(config.custom_theme_colors, CustomThemeColors::default());
} }
#[test] #[test]
@@ -124,6 +220,18 @@ mod tests {
greeting_custom_prompt: Some("Hello!".to_string()), greeting_custom_prompt: Some("Hello!".to_string()),
notifications_enabled: true, notifications_enabled: true,
notification_volume: 0.7, notification_volume: 0.7,
always_on_top: true,
update_checks_enabled: true,
character_panel_width: Some(400),
font_size: 16,
minimize_to_tray: true,
streamer_mode: false,
streamer_hide_paths: false,
compact_mode: false,
profile_name: Some("Test User".to_string()),
profile_avatar_path: None,
profile_bio: Some("A test bio".to_string()),
custom_theme_colors: CustomThemeColors::default(),
}; };
let json = serde_json::to_string(&config).unwrap(); let json = serde_json::to_string(&config).unwrap();
@@ -134,15 +242,26 @@ mod tests {
assert_eq!(deserialized.auto_granted_tools, config.auto_granted_tools); assert_eq!(deserialized.auto_granted_tools, config.auto_granted_tools);
assert_eq!(deserialized.theme, Theme::Light); assert_eq!(deserialized.theme, Theme::Light);
assert!(deserialized.greeting_enabled); assert!(deserialized.greeting_enabled);
assert_eq!(deserialized.greeting_custom_prompt, Some("Hello!".to_string())); assert_eq!(
deserialized.greeting_custom_prompt,
Some("Hello!".to_string())
);
} }
#[test] #[test]
fn test_theme_serialization() { fn test_theme_serialization() {
let dark = Theme::Dark; let dark = Theme::Dark;
let light = Theme::Light; let light = Theme::Light;
let high_contrast = Theme::HighContrast;
assert_eq!(serde_json::to_string(&dark).unwrap(), "\"dark\""); assert_eq!(serde_json::to_string(&dark).unwrap(), "\"dark\"");
assert_eq!(serde_json::to_string(&light).unwrap(), "\"light\""); assert_eq!(serde_json::to_string(&light).unwrap(), "\"light\"");
assert_eq!(
serde_json::to_string(&high_contrast).unwrap(),
"\"high-contrast\""
);
let custom = Theme::Custom;
assert_eq!(serde_json::to_string(&custom).unwrap(), "\"custom\"");
} }
} }
+878
View File
@@ -0,0 +1,878 @@
use serde::{Deserialize, Serialize};
use std::process::Command;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GitStatus {
pub is_repo: bool,
pub branch: Option<String>,
pub upstream: Option<String>,
pub ahead: u32,
pub behind: u32,
pub staged: Vec<GitFileChange>,
pub unstaged: Vec<GitFileChange>,
pub untracked: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GitFileChange {
pub path: String,
pub status: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GitBranch {
pub name: String,
pub is_current: bool,
pub is_remote: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GitLogEntry {
pub hash: String,
pub short_hash: String,
pub author: String,
pub date: String,
pub message: String,
}
fn run_git_command(working_dir: &str, args: &[&str]) -> Result<String, String> {
let output = Command::new("git")
.args(args)
.current_dir(working_dir)
.output()
.map_err(|e| format!("Failed to execute git: {}", e))?;
if output.status.success() {
Ok(String::from_utf8_lossy(&output.stdout).to_string())
} else {
Err(String::from_utf8_lossy(&output.stderr).to_string())
}
}
#[tauri::command]
pub fn git_status(working_dir: String) -> Result<GitStatus, String> {
// Check if it's a git repo
let is_repo = run_git_command(&working_dir, &["rev-parse", "--git-dir"]).is_ok();
if !is_repo {
return Ok(GitStatus {
is_repo: false,
branch: None,
upstream: None,
ahead: 0,
behind: 0,
staged: vec![],
unstaged: vec![],
untracked: vec![],
});
}
// Get current branch
let branch = run_git_command(&working_dir, &["rev-parse", "--abbrev-ref", "HEAD"])
.ok()
.map(|s| s.trim().to_string());
// Get upstream branch
let upstream = run_git_command(
&working_dir,
&["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"],
)
.ok()
.map(|s| s.trim().to_string());
// Get ahead/behind counts
let (ahead, behind) = if upstream.is_some() {
let rev_list =
run_git_command(&working_dir, &["rev-list", "--left-right", "--count", "@{u}...HEAD"])
.unwrap_or_default();
let parts: Vec<&str> = rev_list.trim().split('\t').collect();
if parts.len() == 2 {
(
parts[1].parse().unwrap_or(0),
parts[0].parse().unwrap_or(0),
)
} else {
(0, 0)
}
} else {
(0, 0)
};
// Get status with porcelain format
let status_output =
run_git_command(&working_dir, &["status", "--porcelain=v1"]).unwrap_or_default();
let mut staged = vec![];
let mut unstaged = vec![];
let mut untracked = vec![];
for line in status_output.lines() {
if line.len() < 3 {
continue;
}
let index_status = line.chars().next().unwrap_or(' ');
let worktree_status = line.chars().nth(1).unwrap_or(' ');
let path = line[3..].to_string();
// Untracked files
if index_status == '?' && worktree_status == '?' {
untracked.push(path);
continue;
}
// Staged changes (index status)
if index_status != ' ' && index_status != '?' {
staged.push(GitFileChange {
path: path.clone(),
status: match index_status {
'M' => "modified".to_string(),
'A' => "added".to_string(),
'D' => "deleted".to_string(),
'R' => "renamed".to_string(),
'C' => "copied".to_string(),
_ => "unknown".to_string(),
},
});
}
// Unstaged changes (worktree status)
if worktree_status != ' ' && worktree_status != '?' {
unstaged.push(GitFileChange {
path,
status: match worktree_status {
'M' => "modified".to_string(),
'D' => "deleted".to_string(),
_ => "unknown".to_string(),
},
});
}
}
Ok(GitStatus {
is_repo: true,
branch,
upstream,
ahead,
behind,
staged,
unstaged,
untracked,
})
}
#[tauri::command]
pub fn git_diff(working_dir: String, file_path: Option<String>, staged: bool) -> Result<String, String> {
let mut args = vec!["diff"];
if staged {
args.push("--cached");
}
if let Some(ref path) = file_path {
args.push("--");
args.push(path);
}
run_git_command(&working_dir, &args)
}
#[tauri::command]
pub fn git_branches(working_dir: String) -> Result<Vec<GitBranch>, String> {
let output = run_git_command(&working_dir, &["branch", "-a", "--format=%(refname:short)\t%(HEAD)"])?;
let branches: Vec<GitBranch> = output
.lines()
.filter_map(|line| {
let parts: Vec<&str> = line.split('\t').collect();
if parts.is_empty() {
return None;
}
let name = parts[0].to_string();
let is_current = parts.get(1).map(|s| *s == "*").unwrap_or(false);
let is_remote = name.starts_with("remotes/") || name.starts_with("origin/");
Some(GitBranch {
name,
is_current,
is_remote,
})
})
.collect();
Ok(branches)
}
#[tauri::command]
pub fn git_checkout(working_dir: String, branch: String) -> Result<String, String> {
run_git_command(&working_dir, &["checkout", &branch])
}
#[tauri::command]
pub fn git_stage(working_dir: String, file_path: String) -> Result<String, String> {
run_git_command(&working_dir, &["add", &file_path])
}
#[tauri::command]
pub fn git_unstage(working_dir: String, file_path: String) -> Result<String, String> {
run_git_command(&working_dir, &["restore", "--staged", &file_path])
}
#[tauri::command]
pub fn git_stage_all(working_dir: String) -> Result<String, String> {
run_git_command(&working_dir, &["add", "-A"])
}
#[tauri::command]
pub fn git_commit(working_dir: String, message: String) -> Result<String, String> {
run_git_command(&working_dir, &["commit", "-m", &message])
}
#[tauri::command]
pub fn git_push(working_dir: String) -> Result<String, String> {
run_git_command(&working_dir, &["push"])
}
#[tauri::command]
pub fn git_pull(working_dir: String) -> Result<String, String> {
run_git_command(&working_dir, &["pull"])
}
#[tauri::command]
pub fn git_fetch(working_dir: String) -> Result<String, String> {
run_git_command(&working_dir, &["fetch", "--all"])
}
#[tauri::command]
pub fn git_log(working_dir: String, limit: Option<u32>) -> Result<Vec<GitLogEntry>, String> {
let limit_str = limit.unwrap_or(10).to_string();
let output = run_git_command(
&working_dir,
&[
"log",
&format!("-{}", limit_str),
"--pretty=format:%H\t%h\t%an\t%ar\t%s",
],
)?;
let entries: Vec<GitLogEntry> = output
.lines()
.filter_map(|line| {
let parts: Vec<&str> = line.split('\t').collect();
if parts.len() < 5 {
return None;
}
Some(GitLogEntry {
hash: parts[0].to_string(),
short_hash: parts[1].to_string(),
author: parts[2].to_string(),
date: parts[3].to_string(),
message: parts[4..].join("\t"),
})
})
.collect();
Ok(entries)
}
#[tauri::command]
pub fn git_discard(working_dir: String, file_path: String) -> Result<String, String> {
run_git_command(&working_dir, &["checkout", "--", &file_path])
}
#[tauri::command]
pub fn git_create_branch(working_dir: String, branch_name: String) -> Result<String, String> {
run_git_command(&working_dir, &["checkout", "-b", &branch_name])
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs::{self, File};
use std::io::Write;
use tempfile::TempDir;
// Helper to create a git repository in a temp directory
fn create_test_repo() -> TempDir {
let temp_dir = TempDir::new().unwrap();
let working_dir = temp_dir.path().to_string_lossy().to_string();
// Initialize git repo
run_git_command(&working_dir, &["init"]).unwrap();
// Configure git user for commits
run_git_command(&working_dir, &["config", "user.email", "test@example.com"]).unwrap();
run_git_command(&working_dir, &["config", "user.name", "Test User"]).unwrap();
// Disable GPG signing for tests (user may have it enabled globally)
run_git_command(&working_dir, &["config", "commit.gpgsign", "false"]).unwrap();
temp_dir
}
// Helper to create a file in the test repo
fn create_file(dir: &TempDir, name: &str, content: &str) {
let file_path = dir.path().join(name);
let mut file = File::create(file_path).unwrap();
file.write_all(content.as_bytes()).unwrap();
}
// ==================== GitStatus struct tests ====================
#[test]
fn test_git_status_serialization() {
let status = GitStatus {
is_repo: true,
branch: Some("main".to_string()),
upstream: Some("origin/main".to_string()),
ahead: 2,
behind: 1,
staged: vec![GitFileChange {
path: "file.txt".to_string(),
status: "modified".to_string(),
}],
unstaged: vec![],
untracked: vec!["new_file.txt".to_string()],
};
let json = serde_json::to_string(&status).unwrap();
assert!(json.contains("\"is_repo\":true"));
assert!(json.contains("\"branch\":\"main\""));
assert!(json.contains("\"ahead\":2"));
assert!(json.contains("\"behind\":1"));
}
#[test]
fn test_git_status_not_a_repo() {
let status = GitStatus {
is_repo: false,
branch: None,
upstream: None,
ahead: 0,
behind: 0,
staged: vec![],
unstaged: vec![],
untracked: vec![],
};
let json = serde_json::to_string(&status).unwrap();
let deserialized: GitStatus = serde_json::from_str(&json).unwrap();
assert!(!deserialized.is_repo);
assert!(deserialized.branch.is_none());
}
// ==================== GitFileChange struct tests ====================
#[test]
fn test_git_file_change_serialization() {
let change = GitFileChange {
path: "src/main.rs".to_string(),
status: "added".to_string(),
};
let json = serde_json::to_string(&change).unwrap();
assert!(json.contains("src/main.rs"));
assert!(json.contains("added"));
let deserialized: GitFileChange = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.path, "src/main.rs");
assert_eq!(deserialized.status, "added");
}
// ==================== GitBranch struct tests ====================
#[test]
fn test_git_branch_serialization() {
let branch = GitBranch {
name: "feature/new-feature".to_string(),
is_current: true,
is_remote: false,
};
let json = serde_json::to_string(&branch).unwrap();
assert!(json.contains("feature/new-feature"));
assert!(json.contains("\"is_current\":true"));
assert!(json.contains("\"is_remote\":false"));
}
#[test]
fn test_git_branch_remote() {
let branch = GitBranch {
name: "origin/main".to_string(),
is_current: false,
is_remote: true,
};
let json = serde_json::to_string(&branch).unwrap();
let deserialized: GitBranch = serde_json::from_str(&json).unwrap();
assert!(deserialized.is_remote);
assert!(!deserialized.is_current);
}
// ==================== GitLogEntry struct tests ====================
#[test]
fn test_git_log_entry_serialization() {
let entry = GitLogEntry {
hash: "abc123def456".to_string(),
short_hash: "abc123d".to_string(),
author: "Hikari".to_string(),
date: "2 hours ago".to_string(),
message: "feat: add new feature".to_string(),
};
let json = serde_json::to_string(&entry).unwrap();
assert!(json.contains("abc123def456"));
assert!(json.contains("Hikari"));
assert!(json.contains("feat: add new feature"));
}
// ==================== git_status integration tests ====================
#[test]
fn test_git_status_not_a_git_repo() {
let temp_dir = TempDir::new().unwrap();
let working_dir = temp_dir.path().to_string_lossy().to_string();
let result = git_status(working_dir);
assert!(result.is_ok());
let status = result.unwrap();
assert!(!status.is_repo);
assert!(status.branch.is_none());
assert!(status.staged.is_empty());
}
#[test]
fn test_git_status_empty_repo() {
let temp_dir = create_test_repo();
let working_dir = temp_dir.path().to_string_lossy().to_string();
let result = git_status(working_dir);
assert!(result.is_ok());
let status = result.unwrap();
assert!(status.is_repo);
assert!(status.staged.is_empty());
assert!(status.unstaged.is_empty());
assert!(status.untracked.is_empty());
}
#[test]
fn test_git_status_with_untracked_file() {
let temp_dir = create_test_repo();
let working_dir = temp_dir.path().to_string_lossy().to_string();
// Create an untracked file
create_file(&temp_dir, "untracked.txt", "hello");
let result = git_status(working_dir);
assert!(result.is_ok());
let status = result.unwrap();
assert!(status.is_repo);
assert!(status.untracked.contains(&"untracked.txt".to_string()));
}
#[test]
fn test_git_status_with_staged_file() {
let temp_dir = create_test_repo();
let working_dir = temp_dir.path().to_string_lossy().to_string();
// Create and stage a file
create_file(&temp_dir, "staged.txt", "hello");
run_git_command(&working_dir, &["add", "staged.txt"]).unwrap();
let result = git_status(working_dir);
assert!(result.is_ok());
let status = result.unwrap();
assert!(status.is_repo);
assert!(!status.staged.is_empty());
assert_eq!(status.staged[0].path, "staged.txt");
assert_eq!(status.staged[0].status, "added");
}
#[test]
fn test_git_status_with_modified_file() {
let temp_dir = create_test_repo();
let working_dir = temp_dir.path().to_string_lossy().to_string();
// Create, stage, and commit a file
create_file(&temp_dir, "file.txt", "initial content");
run_git_command(&working_dir, &["add", "file.txt"]).unwrap();
run_git_command(&working_dir, &["commit", "-m", "initial commit"]).unwrap();
// Modify the file
create_file(&temp_dir, "file.txt", "modified content");
let result = git_status(working_dir);
assert!(result.is_ok());
let status = result.unwrap();
assert!(status.is_repo);
assert!(!status.unstaged.is_empty());
assert_eq!(status.unstaged[0].path, "file.txt");
assert_eq!(status.unstaged[0].status, "modified");
}
// ==================== git_diff integration tests ====================
#[test]
fn test_git_diff_no_changes() {
let temp_dir = create_test_repo();
let working_dir = temp_dir.path().to_string_lossy().to_string();
let result = git_diff(working_dir, None, false);
assert!(result.is_ok());
assert!(result.unwrap().is_empty());
}
#[test]
fn test_git_diff_with_changes() {
let temp_dir = create_test_repo();
let working_dir = temp_dir.path().to_string_lossy().to_string();
// Create and commit a file
create_file(&temp_dir, "file.txt", "initial content");
run_git_command(&working_dir, &["add", "file.txt"]).unwrap();
run_git_command(&working_dir, &["commit", "-m", "initial"]).unwrap();
// Modify the file
create_file(&temp_dir, "file.txt", "modified content");
let result = git_diff(working_dir, None, false);
assert!(result.is_ok());
let diff = result.unwrap();
assert!(diff.contains("diff"));
assert!(diff.contains("file.txt"));
}
#[test]
fn test_git_diff_staged() {
let temp_dir = create_test_repo();
let working_dir = temp_dir.path().to_string_lossy().to_string();
// Create and commit a file
create_file(&temp_dir, "file.txt", "initial content");
run_git_command(&working_dir, &["add", "file.txt"]).unwrap();
run_git_command(&working_dir, &["commit", "-m", "initial"]).unwrap();
// Modify and stage the file
create_file(&temp_dir, "file.txt", "modified content");
run_git_command(&working_dir, &["add", "file.txt"]).unwrap();
let result = git_diff(working_dir, None, true);
assert!(result.is_ok());
let diff = result.unwrap();
assert!(diff.contains("diff"));
}
#[test]
fn test_git_diff_specific_file() {
let temp_dir = create_test_repo();
let working_dir = temp_dir.path().to_string_lossy().to_string();
// Create and commit files
create_file(&temp_dir, "file1.txt", "content1");
create_file(&temp_dir, "file2.txt", "content2");
run_git_command(&working_dir, &["add", "-A"]).unwrap();
run_git_command(&working_dir, &["commit", "-m", "initial"]).unwrap();
// Modify both files
create_file(&temp_dir, "file1.txt", "modified1");
create_file(&temp_dir, "file2.txt", "modified2");
// Get diff for only file1.txt
let result = git_diff(working_dir, Some("file1.txt".to_string()), false);
assert!(result.is_ok());
let diff = result.unwrap();
assert!(diff.contains("file1.txt"));
assert!(!diff.contains("file2.txt"));
}
// ==================== git_branches integration tests ====================
#[test]
fn test_git_branches_single_branch() {
let temp_dir = create_test_repo();
let working_dir = temp_dir.path().to_string_lossy().to_string();
// Need at least one commit for branches to show
create_file(&temp_dir, "file.txt", "content");
run_git_command(&working_dir, &["add", "file.txt"]).unwrap();
run_git_command(&working_dir, &["commit", "-m", "initial"]).unwrap();
let result = git_branches(working_dir);
assert!(result.is_ok());
let branches = result.unwrap();
assert!(!branches.is_empty());
// Should have at least one branch (main or master)
}
#[test]
fn test_git_branches_multiple_branches() {
let temp_dir = create_test_repo();
let working_dir = temp_dir.path().to_string_lossy().to_string();
// Initial commit
create_file(&temp_dir, "file.txt", "content");
run_git_command(&working_dir, &["add", "file.txt"]).unwrap();
run_git_command(&working_dir, &["commit", "-m", "initial"]).unwrap();
// Create additional branch
run_git_command(&working_dir, &["branch", "feature-branch"]).unwrap();
let result = git_branches(working_dir);
assert!(result.is_ok());
let branches = result.unwrap();
assert!(branches.len() >= 2);
assert!(branches.iter().any(|b| b.name == "feature-branch"));
}
// ==================== git_stage and git_unstage tests ====================
#[test]
fn test_git_stage_file() {
let temp_dir = create_test_repo();
let working_dir = temp_dir.path().to_string_lossy().to_string();
create_file(&temp_dir, "file.txt", "content");
let result = git_stage(working_dir.clone(), "file.txt".to_string());
assert!(result.is_ok());
// Verify file is staged
let status = git_status(working_dir).unwrap();
assert!(status.staged.iter().any(|f| f.path == "file.txt"));
}
#[test]
fn test_git_unstage_file() {
let temp_dir = create_test_repo();
let working_dir = temp_dir.path().to_string_lossy().to_string();
// First, commit a file so we have a HEAD to restore from
create_file(&temp_dir, "file.txt", "initial content");
run_git_command(&working_dir, &["add", "file.txt"]).unwrap();
run_git_command(&working_dir, &["commit", "-m", "initial"]).unwrap();
// Modify and stage the file
create_file(&temp_dir, "file.txt", "modified content");
run_git_command(&working_dir, &["add", "file.txt"]).unwrap();
let result = git_unstage(working_dir.clone(), "file.txt".to_string());
assert!(result.is_ok());
// Verify file is unstaged (should now be in unstaged/modified, not staged)
let status = git_status(working_dir).unwrap();
assert!(!status.staged.iter().any(|f| f.path == "file.txt"));
assert!(status.unstaged.iter().any(|f| f.path == "file.txt"));
}
#[test]
fn test_git_stage_all() {
let temp_dir = create_test_repo();
let working_dir = temp_dir.path().to_string_lossy().to_string();
create_file(&temp_dir, "file1.txt", "content1");
create_file(&temp_dir, "file2.txt", "content2");
let result = git_stage_all(working_dir.clone());
assert!(result.is_ok());
// Verify all files are staged
let status = git_status(working_dir).unwrap();
assert_eq!(status.staged.len(), 2);
}
// ==================== git_commit tests ====================
#[test]
fn test_git_commit() {
let temp_dir = create_test_repo();
let working_dir = temp_dir.path().to_string_lossy().to_string();
create_file(&temp_dir, "file.txt", "content");
run_git_command(&working_dir, &["add", "file.txt"]).unwrap();
let result = git_commit(working_dir.clone(), "test commit message".to_string());
assert!(result.is_ok());
// Verify commit was made
let log = git_log(working_dir, Some(1)).unwrap();
assert!(!log.is_empty());
assert!(log[0].message.contains("test commit message"));
}
#[test]
fn test_git_commit_nothing_to_commit() {
let temp_dir = create_test_repo();
let working_dir = temp_dir.path().to_string_lossy().to_string();
// Need initial commit first
create_file(&temp_dir, "file.txt", "content");
run_git_command(&working_dir, &["add", "file.txt"]).unwrap();
run_git_command(&working_dir, &["commit", "-m", "initial"]).unwrap();
// Try to commit with nothing staged
let result = git_commit(working_dir, "empty commit".to_string());
assert!(result.is_err()); // Should fail because nothing to commit
}
// ==================== git_log tests ====================
#[test]
fn test_git_log_empty_repo() {
let temp_dir = create_test_repo();
let working_dir = temp_dir.path().to_string_lossy().to_string();
let result = git_log(working_dir, Some(10));
// May fail on empty repo or return empty
if let Ok(commits) = result {
assert!(commits.is_empty());
}
}
#[test]
fn test_git_log_with_commits() {
let temp_dir = create_test_repo();
let working_dir = temp_dir.path().to_string_lossy().to_string();
// Make multiple commits
for i in 1..=3 {
create_file(&temp_dir, &format!("file{}.txt", i), "content");
run_git_command(&working_dir, &["add", "-A"]).unwrap();
run_git_command(&working_dir, &["commit", "-m", &format!("commit {}", i)]).unwrap();
}
let result = git_log(working_dir, Some(10));
assert!(result.is_ok());
let log = result.unwrap();
assert_eq!(log.len(), 3);
assert!(log[0].message.contains("commit 3")); // Most recent first
assert!(log[2].message.contains("commit 1"));
}
#[test]
fn test_git_log_limit() {
let temp_dir = create_test_repo();
let working_dir = temp_dir.path().to_string_lossy().to_string();
// Make 5 commits
for i in 1..=5 {
create_file(&temp_dir, &format!("file{}.txt", i), "content");
run_git_command(&working_dir, &["add", "-A"]).unwrap();
run_git_command(&working_dir, &["commit", "-m", &format!("commit {}", i)]).unwrap();
}
// Only get last 2
let result = git_log(working_dir, Some(2));
assert!(result.is_ok());
let log = result.unwrap();
assert_eq!(log.len(), 2);
}
// ==================== git_discard tests ====================
#[test]
fn test_git_discard_changes() {
let temp_dir = create_test_repo();
let working_dir = temp_dir.path().to_string_lossy().to_string();
// Create and commit a file
create_file(&temp_dir, "file.txt", "original content");
run_git_command(&working_dir, &["add", "file.txt"]).unwrap();
run_git_command(&working_dir, &["commit", "-m", "initial"]).unwrap();
// Modify the file
create_file(&temp_dir, "file.txt", "modified content");
// Discard changes
let result = git_discard(working_dir.clone(), "file.txt".to_string());
assert!(result.is_ok());
// Verify file contents are restored
let content = fs::read_to_string(temp_dir.path().join("file.txt")).unwrap();
assert_eq!(content, "original content");
}
// ==================== git_create_branch tests ====================
#[test]
fn test_git_create_branch() {
let temp_dir = create_test_repo();
let working_dir = temp_dir.path().to_string_lossy().to_string();
// Initial commit required
create_file(&temp_dir, "file.txt", "content");
run_git_command(&working_dir, &["add", "file.txt"]).unwrap();
run_git_command(&working_dir, &["commit", "-m", "initial"]).unwrap();
let result = git_create_branch(working_dir.clone(), "new-branch".to_string());
assert!(result.is_ok());
// Verify branch exists and is current
let branches = git_branches(working_dir).unwrap();
assert!(branches.iter().any(|b| b.name == "new-branch" && b.is_current));
}
// ==================== git_checkout tests ====================
#[test]
fn test_git_checkout() {
let temp_dir = create_test_repo();
let working_dir = temp_dir.path().to_string_lossy().to_string();
// Initial commit required
create_file(&temp_dir, "file.txt", "content");
run_git_command(&working_dir, &["add", "file.txt"]).unwrap();
run_git_command(&working_dir, &["commit", "-m", "initial"]).unwrap();
// Create a branch
run_git_command(&working_dir, &["branch", "other-branch"]).unwrap();
// Checkout the branch
let result = git_checkout(working_dir.clone(), "other-branch".to_string());
assert!(result.is_ok());
// Verify current branch
let branches = git_branches(working_dir).unwrap();
let current = branches.iter().find(|b| b.is_current);
assert!(current.is_some());
assert_eq!(current.unwrap().name, "other-branch");
}
// ==================== run_git_command tests ====================
#[test]
fn test_run_git_command_success() {
let temp_dir = create_test_repo();
let working_dir = temp_dir.path().to_string_lossy().to_string();
let result = run_git_command(&working_dir, &["status"]);
assert!(result.is_ok());
}
#[test]
fn test_run_git_command_failure() {
let temp_dir = TempDir::new().unwrap();
let working_dir = temp_dir.path().to_string_lossy().to_string();
// This should fail because it's not a git repo
let result = run_git_command(&working_dir, &["log"]);
assert!(result.is_err());
}
#[test]
fn test_run_git_command_invalid_dir() {
let result = run_git_command("/nonexistent/path", &["status"]);
assert!(result.is_err());
}
}
+111 -5
View File
@@ -1,26 +1,42 @@
mod achievements; mod achievements;
mod bridge_manager; mod bridge_manager;
mod clipboard;
mod commands; mod commands;
mod config; mod config;
mod git;
mod notifications; mod notifications;
mod quick_actions;
mod sessions;
mod snippets;
mod stats; mod stats;
mod temp_manager;
mod tray;
mod types; mod types;
mod wsl_bridge;
mod wsl_notifications;
mod vbs_notification; mod vbs_notification;
mod windows_toast; mod windows_toast;
mod wsl_bridge;
mod wsl_notifications;
use commands::*;
use notifications::*;
use bridge_manager::create_shared_bridge_manager; use bridge_manager::create_shared_bridge_manager;
use clipboard::*;
use commands::load_saved_achievements; use commands::load_saved_achievements;
use wsl_notifications::*; use commands::*;
use git::*;
use notifications::*;
use quick_actions::*;
use sessions::*;
use snippets::*;
use tauri::Manager;
use temp_manager::create_shared_temp_manager;
use tray::{setup_tray, should_minimize_to_tray};
use vbs_notification::*; use vbs_notification::*;
use windows_toast::*; use windows_toast::*;
use wsl_notifications::*;
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
let bridge_manager = create_shared_bridge_manager(); let bridge_manager = create_shared_bridge_manager();
let temp_manager = create_shared_temp_manager().expect("Failed to create temp file manager");
tauri::Builder::default() tauri::Builder::default()
.plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_dialog::init())
@@ -29,10 +45,43 @@ pub fn run() {
.plugin(tauri_plugin_store::Builder::new().build()) .plugin(tauri_plugin_store::Builder::new().build())
.plugin(tauri_plugin_notification::init()) .plugin(tauri_plugin_notification::init())
.plugin(tauri_plugin_os::init()) .plugin(tauri_plugin_os::init())
.plugin(tauri_plugin_http::init())
.plugin(tauri_plugin_clipboard_manager::init())
.plugin(tauri_plugin_fs::init())
.manage(bridge_manager.clone()) .manage(bridge_manager.clone())
.manage(temp_manager.clone())
.setup(move |app| { .setup(move |app| {
// Initialize the app handle in the bridge manager // Initialize the app handle in the bridge manager
bridge_manager.lock().set_app_handle(app.handle().clone()); bridge_manager.lock().set_app_handle(app.handle().clone());
// Clean up any orphaned temp files from previous sessions
if let Ok(count) = temp_manager.lock().cleanup_orphaned_files() {
if count > 0 {
println!("Cleaned up {} orphaned temp files", count);
}
}
// Set up system tray
if let Err(e) = setup_tray(app.handle()) {
eprintln!("Failed to set up system tray: {}", e);
}
// Handle window close event for minimize to tray
let main_window = app.get_webview_window("main").unwrap();
main_window.on_window_event({
let app_handle = app.handle().clone();
move |event| {
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
if should_minimize_to_tray(&app_handle) {
api.prevent_close();
if let Some(window) = app_handle.get_webview_window("main") {
let _ = window.hide();
}
}
}
}
});
Ok(()) Ok(())
}) })
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
@@ -46,13 +95,70 @@ pub fn run() {
get_config, get_config,
save_config, save_config,
get_usage_stats, get_usage_stats,
get_persisted_stats,
load_saved_achievements, load_saved_achievements,
answer_question,
send_windows_notification, send_windows_notification,
send_simple_notification, send_simple_notification,
send_windows_toast, send_windows_toast,
send_notify_send, send_notify_send,
send_wsl_notification, send_wsl_notification,
send_vbs_notification, send_vbs_notification,
validate_directory,
list_skills,
check_for_updates,
save_temp_file,
register_temp_file,
get_temp_files,
cleanup_temp_files,
cleanup_all_temp_files,
cleanup_orphaned_temp_files,
get_file_size,
list_sessions,
save_session,
load_session,
delete_session,
search_sessions,
clear_all_sessions,
list_snippets,
save_snippet,
delete_snippet,
get_snippet_categories,
reset_default_snippets,
list_quick_actions,
save_quick_action,
delete_quick_action,
reset_default_quick_actions,
git_status,
git_diff,
git_branches,
git_checkout,
git_stage,
git_unstage,
git_stage_all,
git_commit,
git_push,
git_pull,
git_fetch,
git_log,
git_discard,
git_create_branch,
list_clipboard_entries,
capture_clipboard,
delete_clipboard_entry,
toggle_pin_clipboard_entry,
clear_clipboard_history,
search_clipboard_entries,
get_clipboard_languages,
update_clipboard_language,
list_directory,
read_file_content,
write_file_content,
create_file,
create_directory,
delete_file,
delete_directory,
rename_path,
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");
+8 -3
View File
@@ -1,5 +1,5 @@
use tauri::command;
use std::process::Command; use std::process::Command;
use tauri::command;
#[command] #[command]
pub async fn send_notify_send(title: String, body: String) -> Result<(), String> { pub async fn send_notify_send(title: String, body: String) -> Result<(), String> {
@@ -10,7 +10,12 @@ pub async fn send_notify_send(title: String, body: String) -> Result<(), String>
.arg("--urgency=normal") .arg("--urgency=normal")
.arg("--app-name=Hikari Desktop") .arg("--app-name=Hikari Desktop")
.output() .output()
.map_err(|e| format!("Failed to execute notify-send: {}. Make sure libnotify-bin is installed.", e))?; .map_err(|e| {
format!(
"Failed to execute notify-send: {}. Make sure libnotify-bin is installed.",
e
)
})?;
if !output.status.success() { if !output.status.success() {
let error = String::from_utf8_lossy(&output.stderr); let error = String::from_utf8_lossy(&output.stderr);
@@ -93,4 +98,4 @@ pub async fn send_simple_notification(title: String, body: String) -> Result<(),
.map_err(|e| format!("Failed to send message: {}", e))?; .map_err(|e| format!("Failed to send message: {}", e))?;
Ok(()) Ok(())
} }
+373
View File
@@ -0,0 +1,373 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use tauri::AppHandle;
use tauri_plugin_store::StoreExt;
const QUICK_ACTIONS_STORE_KEY: &str = "quick_actions";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct QuickAction {
pub id: String,
pub name: String,
pub prompt: String,
pub icon: String,
pub is_default: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
fn get_default_quick_actions() -> Vec<QuickAction> {
let now = Utc::now();
vec![
QuickAction {
id: "default-review-pr".to_string(),
name: "Review PR".to_string(),
prompt: "Please review this pull request and provide feedback on code quality, potential issues, and suggestions for improvement.".to_string(),
icon: "git-pull-request".to_string(),
is_default: true,
created_at: now,
updated_at: now,
},
QuickAction {
id: "default-run-tests".to_string(),
name: "Run Tests".to_string(),
prompt: "Please run the test suite for this project and report any failures or issues.".to_string(),
icon: "play".to_string(),
is_default: true,
created_at: now,
updated_at: now,
},
QuickAction {
id: "default-explain-file".to_string(),
name: "Explain File".to_string(),
prompt: "Please explain what this file does, its purpose, and how it fits into the overall project structure.".to_string(),
icon: "file-text".to_string(),
is_default: true,
created_at: now,
updated_at: now,
},
QuickAction {
id: "default-fix-error".to_string(),
name: "Fix Error".to_string(),
prompt: "I'm getting an error. Can you help me identify the cause and fix it?".to_string(),
icon: "alert-circle".to_string(),
is_default: true,
created_at: now,
updated_at: now,
},
QuickAction {
id: "default-write-tests".to_string(),
name: "Write Tests".to_string(),
prompt: "Please write comprehensive unit tests for the current code with good coverage.".to_string(),
icon: "check-square".to_string(),
is_default: true,
created_at: now,
updated_at: now,
},
QuickAction {
id: "default-refactor".to_string(),
name: "Refactor".to_string(),
prompt: "Please refactor this code to improve readability, maintainability, and performance.".to_string(),
icon: "refresh-cw".to_string(),
is_default: true,
created_at: now,
updated_at: now,
},
]
}
fn load_all_quick_actions(app: &AppHandle) -> Result<Vec<QuickAction>, String> {
let store = app
.store("hikari-quick-actions.json")
.map_err(|e| e.to_string())?;
match store.get(QUICK_ACTIONS_STORE_KEY) {
Some(value) => {
let mut actions: Vec<QuickAction> =
serde_json::from_value(value.clone()).map_err(|e| e.to_string())?;
let defaults = get_default_quick_actions();
for default in defaults {
if !actions.iter().any(|a| a.id == default.id) {
actions.push(default);
}
}
Ok(actions)
}
None => Ok(get_default_quick_actions()),
}
}
fn save_all_quick_actions(app: &AppHandle, actions: &[QuickAction]) -> Result<(), String> {
let store = app
.store("hikari-quick-actions.json")
.map_err(|e| e.to_string())?;
let value = serde_json::to_value(actions).map_err(|e| e.to_string())?;
store.set(QUICK_ACTIONS_STORE_KEY, value);
store.save().map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
pub async fn list_quick_actions(app: AppHandle) -> Result<Vec<QuickAction>, String> {
let mut actions = load_all_quick_actions(&app)?;
actions.sort_by(|a, b| {
let default_cmp = b.is_default.cmp(&a.is_default);
if default_cmp == std::cmp::Ordering::Equal {
a.name.cmp(&b.name)
} else {
default_cmp
}
});
Ok(actions)
}
#[tauri::command]
pub async fn save_quick_action(app: AppHandle, action: QuickAction) -> Result<(), String> {
let mut actions = load_all_quick_actions(&app)?;
if let Some(existing) = actions.iter_mut().find(|a| a.id == action.id) {
let mut updated = action;
updated.is_default = existing.is_default;
*existing = updated;
} else {
actions.push(action);
}
save_all_quick_actions(&app, &actions)
}
#[tauri::command]
pub async fn delete_quick_action(app: AppHandle, action_id: String) -> Result<(), String> {
let mut actions = load_all_quick_actions(&app)?;
if actions
.iter()
.any(|a| a.id == action_id && a.is_default)
{
return Err("Cannot delete default quick actions".to_string());
}
actions.retain(|a| a.id != action_id);
save_all_quick_actions(&app, &actions)
}
#[tauri::command]
pub async fn reset_default_quick_actions(app: AppHandle) -> Result<(), String> {
let mut actions = load_all_quick_actions(&app)?;
actions.retain(|a| !a.is_default);
actions.extend(get_default_quick_actions());
save_all_quick_actions(&app, &actions)
}
#[cfg(test)]
mod tests {
use super::*;
fn create_test_action(id: &str, name: &str, is_default: bool) -> QuickAction {
QuickAction {
id: id.to_string(),
name: name.to_string(),
prompt: "Test prompt".to_string(),
icon: "star".to_string(),
is_default,
created_at: Utc::now(),
updated_at: Utc::now(),
}
}
#[test]
fn test_default_quick_actions_exist() {
let defaults = get_default_quick_actions();
assert!(!defaults.is_empty());
assert!(defaults.iter().all(|a| a.is_default));
}
#[test]
fn test_default_quick_actions_have_required_fields() {
let defaults = get_default_quick_actions();
for action in defaults {
assert!(!action.id.is_empty());
assert!(!action.name.is_empty());
assert!(!action.prompt.is_empty());
assert!(!action.icon.is_empty());
}
}
#[test]
fn test_default_quick_actions_count() {
let defaults = get_default_quick_actions();
// Should have 6 default actions
assert_eq!(defaults.len(), 6);
}
#[test]
fn test_default_quick_actions_have_unique_ids() {
let defaults = get_default_quick_actions();
let mut ids: Vec<&String> = defaults.iter().map(|a| &a.id).collect();
ids.sort();
ids.dedup();
assert_eq!(ids.len(), defaults.len());
}
#[test]
fn test_default_quick_actions_ids_start_with_default() {
let defaults = get_default_quick_actions();
assert!(defaults.iter().all(|a| a.id.starts_with("default-")));
}
#[test]
fn test_quick_action_serialization() {
let action = create_test_action("test-1", "Test Action", false);
let json = serde_json::to_string(&action).expect("Failed to serialize");
let parsed: QuickAction = serde_json::from_str(&json).expect("Failed to deserialize");
assert_eq!(parsed.id, action.id);
assert_eq!(parsed.name, action.name);
assert_eq!(parsed.prompt, action.prompt);
assert_eq!(parsed.icon, action.icon);
assert_eq!(parsed.is_default, action.is_default);
}
#[test]
fn test_quick_action_clone() {
let original = create_test_action("clone-test", "Clone Test", true);
let cloned = original.clone();
assert_eq!(original.id, cloned.id);
assert_eq!(original.name, cloned.name);
assert_eq!(original.is_default, cloned.is_default);
}
#[test]
#[allow(clippy::useless_vec)]
fn test_quick_action_sorting_defaults_first() {
let mut actions = vec![
create_test_action("custom-z", "Zebra", false),
create_test_action("default-a", "Apple", true),
create_test_action("custom-a", "Alpha", false),
create_test_action("default-z", "Zulu", true),
];
// Sort by: defaults first, then alphabetically by name
actions.sort_by(|a, b| {
let default_cmp = b.is_default.cmp(&a.is_default);
if default_cmp == std::cmp::Ordering::Equal {
a.name.cmp(&b.name)
} else {
default_cmp
}
});
// Defaults should come first
assert!(actions[0].is_default);
assert!(actions[1].is_default);
assert!(!actions[2].is_default);
assert!(!actions[3].is_default);
// Within defaults, alphabetically sorted
assert_eq!(actions[0].name, "Apple");
assert_eq!(actions[1].name, "Zulu");
// Within non-defaults, alphabetically sorted
assert_eq!(actions[2].name, "Alpha");
assert_eq!(actions[3].name, "Zebra");
}
#[test]
fn test_known_default_actions() {
let defaults = get_default_quick_actions();
let ids: Vec<&str> = defaults.iter().map(|a| a.id.as_str()).collect();
assert!(ids.contains(&"default-review-pr"));
assert!(ids.contains(&"default-run-tests"));
assert!(ids.contains(&"default-explain-file"));
assert!(ids.contains(&"default-fix-error"));
assert!(ids.contains(&"default-write-tests"));
assert!(ids.contains(&"default-refactor"));
}
#[test]
fn test_default_action_icons() {
let defaults = get_default_quick_actions();
let icons: Vec<&str> = defaults.iter().map(|a| a.icon.as_str()).collect();
assert!(icons.contains(&"git-pull-request"));
assert!(icons.contains(&"play"));
assert!(icons.contains(&"file-text"));
assert!(icons.contains(&"alert-circle"));
assert!(icons.contains(&"check-square"));
assert!(icons.contains(&"refresh-cw"));
}
#[test]
fn test_quick_action_prompts_not_empty() {
let defaults = get_default_quick_actions();
for action in defaults {
assert!(
action.prompt.len() > 10,
"Prompt should be meaningful: {}",
action.name
);
}
}
#[test]
fn test_quick_action_timestamps() {
let action = create_test_action("time-test", "Time Test", false);
assert!(action.created_at <= action.updated_at);
}
#[test]
fn test_default_actions_have_same_timestamps() {
let defaults = get_default_quick_actions();
// All defaults are created at the same instant
let first_created = defaults[0].created_at;
let first_updated = defaults[0].updated_at;
for action in &defaults {
assert_eq!(action.created_at, first_created);
assert_eq!(action.updated_at, first_updated);
}
}
#[test]
fn test_action_retain_non_default() {
let mut actions = vec![
create_test_action("default-1", "Default 1", true),
create_test_action("custom-1", "Custom 1", false),
create_test_action("default-2", "Default 2", true),
create_test_action("custom-2", "Custom 2", false),
];
// Mimics reset_default_quick_actions behavior (retain non-defaults)
actions.retain(|a| !a.is_default);
assert_eq!(actions.len(), 2);
assert!(actions.iter().all(|a| !a.is_default));
}
#[test]
#[allow(clippy::useless_vec)]
fn test_action_find_by_id() {
let actions = vec![
create_test_action("action-1", "First", false),
create_test_action("action-2", "Second", false),
create_test_action("action-3", "Third", false),
];
let found = actions.iter().find(|a| a.id == "action-2");
assert!(found.is_some());
assert_eq!(found.unwrap().name, "Second");
let not_found = actions.iter().find(|a| a.id == "action-999");
assert!(not_found.is_none());
}
}
+374
View File
@@ -0,0 +1,374 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use tauri::AppHandle;
use tauri_plugin_store::StoreExt;
const SESSIONS_STORE_KEY: &str = "sessions";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SavedSession {
pub id: String,
pub name: String,
pub created_at: DateTime<Utc>,
pub last_activity_at: DateTime<Utc>,
pub working_directory: String,
pub message_count: usize,
pub preview: String, // First ~100 chars of conversation for preview
pub messages: Vec<SavedMessage>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SavedMessage {
pub id: String,
#[serde(rename = "type")]
pub message_type: String,
pub content: String,
pub timestamp: DateTime<Utc>,
pub tool_name: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionListItem {
pub id: String,
pub name: String,
pub created_at: DateTime<Utc>,
pub last_activity_at: DateTime<Utc>,
pub working_directory: String,
pub message_count: usize,
pub preview: String,
}
impl From<&SavedSession> for SessionListItem {
fn from(session: &SavedSession) -> Self {
SessionListItem {
id: session.id.clone(),
name: session.name.clone(),
created_at: session.created_at,
last_activity_at: session.last_activity_at,
working_directory: session.working_directory.clone(),
message_count: session.message_count,
preview: session.preview.clone(),
}
}
}
fn load_all_sessions(app: &AppHandle) -> Result<Vec<SavedSession>, String> {
let store = app
.store("hikari-sessions.json")
.map_err(|e| e.to_string())?;
match store.get(SESSIONS_STORE_KEY) {
Some(value) => serde_json::from_value(value.clone()).map_err(|e| e.to_string()),
None => Ok(Vec::new()),
}
}
fn save_all_sessions(app: &AppHandle, sessions: &[SavedSession]) -> Result<(), String> {
let store = app
.store("hikari-sessions.json")
.map_err(|e| e.to_string())?;
let value = serde_json::to_value(sessions).map_err(|e| e.to_string())?;
store.set(SESSIONS_STORE_KEY, value);
store.save().map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
pub async fn list_sessions(app: AppHandle) -> Result<Vec<SessionListItem>, String> {
let sessions = load_all_sessions(&app)?;
let mut items: Vec<SessionListItem> = sessions.iter().map(SessionListItem::from).collect();
// Sort by last activity, most recent first
items.sort_by(|a, b| b.last_activity_at.cmp(&a.last_activity_at));
Ok(items)
}
#[tauri::command]
pub async fn save_session(app: AppHandle, session: SavedSession) -> Result<(), String> {
let mut sessions = load_all_sessions(&app)?;
// Update existing or add new
if let Some(existing) = sessions.iter_mut().find(|s| s.id == session.id) {
*existing = session;
} else {
sessions.push(session);
}
save_all_sessions(&app, &sessions)
}
#[tauri::command]
pub async fn load_session(app: AppHandle, session_id: String) -> Result<Option<SavedSession>, String> {
let sessions = load_all_sessions(&app)?;
Ok(sessions.into_iter().find(|s| s.id == session_id))
}
#[tauri::command]
pub async fn delete_session(app: AppHandle, session_id: String) -> Result<(), String> {
let mut sessions = load_all_sessions(&app)?;
sessions.retain(|s| s.id != session_id);
save_all_sessions(&app, &sessions)
}
#[tauri::command]
pub async fn search_sessions(app: AppHandle, query: String) -> Result<Vec<SessionListItem>, String> {
let sessions = load_all_sessions(&app)?;
let query_lower = query.to_lowercase();
let mut matching: Vec<SessionListItem> = sessions
.iter()
.filter(|s| {
s.name.to_lowercase().contains(&query_lower)
|| s.preview.to_lowercase().contains(&query_lower)
|| s.working_directory.to_lowercase().contains(&query_lower)
|| s.messages
.iter()
.any(|m| m.content.to_lowercase().contains(&query_lower))
})
.map(SessionListItem::from)
.collect();
// Sort by last activity, most recent first
matching.sort_by(|a, b| b.last_activity_at.cmp(&a.last_activity_at));
Ok(matching)
}
#[tauri::command]
pub async fn clear_all_sessions(app: AppHandle) -> Result<(), String> {
save_all_sessions(&app, &[])
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
fn create_test_session(id: &str, name: &str) -> SavedSession {
SavedSession {
id: id.to_string(),
name: name.to_string(),
created_at: Utc::now(),
last_activity_at: Utc::now(),
working_directory: "/home/test".to_string(),
message_count: 5,
preview: "Hello world".to_string(),
messages: vec![],
}
}
fn create_test_message(id: &str, content: &str, msg_type: &str) -> SavedMessage {
SavedMessage {
id: id.to_string(),
message_type: msg_type.to_string(),
content: content.to_string(),
timestamp: Utc::now(),
tool_name: None,
}
}
#[test]
fn test_session_list_item_from_saved_session() {
let session = SavedSession {
id: "test-id".to_string(),
name: "Test Session".to_string(),
created_at: Utc::now(),
last_activity_at: Utc::now(),
working_directory: "/home/test".to_string(),
message_count: 5,
preview: "Hello world".to_string(),
messages: vec![],
};
let item = SessionListItem::from(&session);
assert_eq!(item.id, "test-id");
assert_eq!(item.name, "Test Session");
assert_eq!(item.message_count, 5);
}
#[test]
fn test_session_list_item_preserves_all_fields() {
let created = Utc.with_ymd_and_hms(2024, 1, 15, 10, 30, 0).unwrap();
let last_activity = Utc.with_ymd_and_hms(2024, 1, 15, 14, 45, 0).unwrap();
let session = SavedSession {
id: "sess-123".to_string(),
name: "My Chat".to_string(),
created_at: created,
last_activity_at: last_activity,
working_directory: "/home/naomi/project".to_string(),
message_count: 42,
preview: "What is the meaning of life?".to_string(),
messages: vec![],
};
let item = SessionListItem::from(&session);
assert_eq!(item.id, "sess-123");
assert_eq!(item.name, "My Chat");
assert_eq!(item.created_at, created);
assert_eq!(item.last_activity_at, last_activity);
assert_eq!(item.working_directory, "/home/naomi/project");
assert_eq!(item.message_count, 42);
assert_eq!(item.preview, "What is the meaning of life?");
}
#[test]
fn test_saved_session_serialization() {
let session = create_test_session("test-1", "Test Session");
let json = serde_json::to_string(&session).expect("Failed to serialize");
let parsed: SavedSession = serde_json::from_str(&json).expect("Failed to deserialize");
assert_eq!(parsed.id, session.id);
assert_eq!(parsed.name, session.name);
assert_eq!(parsed.working_directory, session.working_directory);
}
#[test]
fn test_saved_message_serialization() {
let message = create_test_message("msg-1", "Hello!", "user");
let json = serde_json::to_string(&message).expect("Failed to serialize");
let parsed: SavedMessage = serde_json::from_str(&json).expect("Failed to deserialize");
assert_eq!(parsed.id, message.id);
assert_eq!(parsed.content, message.content);
assert_eq!(parsed.message_type, "user");
}
#[test]
fn test_saved_message_with_tool_name() {
let message = SavedMessage {
id: "msg-tool-1".to_string(),
message_type: "tool".to_string(),
content: "File read successfully".to_string(),
timestamp: Utc::now(),
tool_name: Some("Read".to_string()),
};
let json = serde_json::to_string(&message).expect("Failed to serialize");
let parsed: SavedMessage = serde_json::from_str(&json).expect("Failed to deserialize");
assert_eq!(parsed.tool_name, Some("Read".to_string()));
}
#[test]
fn test_session_with_messages_serialization() {
let mut session = create_test_session("sess-full", "Full Session");
session.messages = vec![
create_test_message("msg-1", "Hello!", "user"),
create_test_message("msg-2", "Hi there!", "assistant"),
create_test_message("msg-3", "Read file", "tool"),
];
session.message_count = 3;
let json = serde_json::to_string(&session).expect("Failed to serialize");
let parsed: SavedSession = serde_json::from_str(&json).expect("Failed to deserialize");
assert_eq!(parsed.messages.len(), 3);
assert_eq!(parsed.messages[0].content, "Hello!");
assert_eq!(parsed.messages[1].message_type, "assistant");
assert_eq!(parsed.messages[2].message_type, "tool");
}
#[test]
fn test_session_list_item_serialization() {
let item = SessionListItem {
id: "list-item-1".to_string(),
name: "Quick Chat".to_string(),
created_at: Utc::now(),
last_activity_at: Utc::now(),
working_directory: "/tmp".to_string(),
message_count: 10,
preview: "Short preview...".to_string(),
};
let json = serde_json::to_string(&item).expect("Failed to serialize");
let parsed: SessionListItem = serde_json::from_str(&json).expect("Failed to deserialize");
assert_eq!(parsed.id, item.id);
assert_eq!(parsed.name, item.name);
assert_eq!(parsed.preview, item.preview);
}
#[test]
fn test_message_type_field_rename() {
// The message_type field is renamed to "type" in JSON
let message = create_test_message("msg-1", "Test", "assistant");
let json = serde_json::to_string(&message).expect("Failed to serialize");
assert!(json.contains("\"type\":"));
assert!(!json.contains("\"message_type\":"));
}
#[test]
fn test_session_default_empty_messages() {
let session = SavedSession {
id: "empty".to_string(),
name: "Empty".to_string(),
created_at: Utc::now(),
last_activity_at: Utc::now(),
working_directory: "/".to_string(),
message_count: 0,
preview: "".to_string(),
messages: vec![],
};
assert!(session.messages.is_empty());
assert_eq!(session.message_count, 0);
}
#[test]
#[allow(clippy::useless_vec)]
fn test_session_sorting_by_activity() {
let old_time = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap();
let new_time = Utc.with_ymd_and_hms(2024, 6, 15, 12, 0, 0).unwrap();
let mut sessions = vec![
SessionListItem {
id: "old".to_string(),
name: "Old Session".to_string(),
created_at: old_time,
last_activity_at: old_time,
working_directory: "/old".to_string(),
message_count: 1,
preview: "Old".to_string(),
},
SessionListItem {
id: "new".to_string(),
name: "New Session".to_string(),
created_at: new_time,
last_activity_at: new_time,
working_directory: "/new".to_string(),
message_count: 1,
preview: "New".to_string(),
},
];
// Sort by last activity, most recent first (mimics list_sessions behavior)
sessions.sort_by(|a, b| b.last_activity_at.cmp(&a.last_activity_at));
assert_eq!(sessions[0].id, "new");
assert_eq!(sessions[1].id, "old");
}
#[test]
fn test_session_clone() {
let original = create_test_session("clone-test", "Clone Test");
let cloned = original.clone();
assert_eq!(original.id, cloned.id);
assert_eq!(original.name, cloned.name);
}
#[test]
fn test_message_clone() {
let original = create_test_message("msg-clone", "Content", "user");
let cloned = original.clone();
assert_eq!(original.id, cloned.id);
assert_eq!(original.content, cloned.content);
}
}
+439
View File
@@ -0,0 +1,439 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use tauri::AppHandle;
use tauri_plugin_store::StoreExt;
const SNIPPETS_STORE_KEY: &str = "snippets";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Snippet {
pub id: String,
pub name: String,
pub content: String,
pub category: String,
pub is_default: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
fn get_default_snippets() -> Vec<Snippet> {
let now = Utc::now();
vec![
Snippet {
id: "default-explain-code".to_string(),
name: "Explain this code".to_string(),
content: "Please explain what this code does, step by step:".to_string(),
category: "Code Review".to_string(),
is_default: true,
created_at: now,
updated_at: now,
},
Snippet {
id: "default-fix-error".to_string(),
name: "Fix this error".to_string(),
content: "I'm getting the following error. Can you help me fix it?".to_string(),
category: "Debugging".to_string(),
is_default: true,
created_at: now,
updated_at: now,
},
Snippet {
id: "default-write-tests".to_string(),
name: "Write tests".to_string(),
content: "Please write unit tests for this code with good coverage:".to_string(),
category: "Testing".to_string(),
is_default: true,
created_at: now,
updated_at: now,
},
Snippet {
id: "default-refactor".to_string(),
name: "Refactor for clarity".to_string(),
content: "Please refactor this code to improve readability and maintainability:".to_string(),
category: "Code Review".to_string(),
is_default: true,
created_at: now,
updated_at: now,
},
Snippet {
id: "default-optimize".to_string(),
name: "Optimize performance".to_string(),
content: "Please analyze this code for performance issues and suggest optimizations:".to_string(),
category: "Performance".to_string(),
is_default: true,
created_at: now,
updated_at: now,
},
Snippet {
id: "default-review-pr".to_string(),
name: "Review PR".to_string(),
content: "Please review this pull request and provide feedback on code quality, potential issues, and suggestions for improvement.".to_string(),
category: "Code Review".to_string(),
is_default: true,
created_at: now,
updated_at: now,
},
Snippet {
id: "default-add-comments".to_string(),
name: "Add documentation".to_string(),
content: "Please add clear documentation comments to this code explaining what it does:".to_string(),
category: "Documentation".to_string(),
is_default: true,
created_at: now,
updated_at: now,
},
Snippet {
id: "default-security-review".to_string(),
name: "Security review".to_string(),
content: "Please review this code for security vulnerabilities and suggest fixes:".to_string(),
category: "Security".to_string(),
is_default: true,
created_at: now,
updated_at: now,
},
]
}
fn load_all_snippets(app: &AppHandle) -> Result<Vec<Snippet>, String> {
let store = app
.store("hikari-snippets.json")
.map_err(|e| e.to_string())?;
match store.get(SNIPPETS_STORE_KEY) {
Some(value) => {
let mut snippets: Vec<Snippet> =
serde_json::from_value(value.clone()).map_err(|e| e.to_string())?;
// Ensure default snippets exist (in case new ones were added in an update)
let defaults = get_default_snippets();
for default in defaults {
if !snippets.iter().any(|s| s.id == default.id) {
snippets.push(default);
}
}
Ok(snippets)
}
None => Ok(get_default_snippets()),
}
}
fn save_all_snippets(app: &AppHandle, snippets: &[Snippet]) -> Result<(), String> {
let store = app
.store("hikari-snippets.json")
.map_err(|e| e.to_string())?;
let value = serde_json::to_value(snippets).map_err(|e| e.to_string())?;
store.set(SNIPPETS_STORE_KEY, value);
store.save().map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
pub async fn list_snippets(app: AppHandle) -> Result<Vec<Snippet>, String> {
let mut snippets = load_all_snippets(&app)?;
// Sort by category, then by name
snippets.sort_by(|a, b| {
let cat_cmp = a.category.cmp(&b.category);
if cat_cmp == std::cmp::Ordering::Equal {
a.name.cmp(&b.name)
} else {
cat_cmp
}
});
Ok(snippets)
}
#[tauri::command]
pub async fn save_snippet(app: AppHandle, snippet: Snippet) -> Result<(), String> {
let mut snippets = load_all_snippets(&app)?;
// Update existing or add new
if let Some(existing) = snippets.iter_mut().find(|s| s.id == snippet.id) {
// Don't allow editing default snippets' is_default flag
let mut updated = snippet;
updated.is_default = existing.is_default;
*existing = updated;
} else {
snippets.push(snippet);
}
save_all_snippets(&app, &snippets)
}
#[tauri::command]
pub async fn delete_snippet(app: AppHandle, snippet_id: String) -> Result<(), String> {
let mut snippets = load_all_snippets(&app)?;
// Don't allow deleting default snippets
if snippets
.iter()
.any(|s| s.id == snippet_id && s.is_default)
{
return Err("Cannot delete default snippets".to_string());
}
snippets.retain(|s| s.id != snippet_id);
save_all_snippets(&app, &snippets)
}
#[tauri::command]
pub async fn get_snippet_categories(app: AppHandle) -> Result<Vec<String>, String> {
let snippets = load_all_snippets(&app)?;
let mut categories: Vec<String> = snippets.iter().map(|s| s.category.clone()).collect();
categories.sort();
categories.dedup();
Ok(categories)
}
#[tauri::command]
pub async fn reset_default_snippets(app: AppHandle) -> Result<(), String> {
let mut snippets = load_all_snippets(&app)?;
// Remove all default snippets
snippets.retain(|s| !s.is_default);
// Add fresh default snippets
snippets.extend(get_default_snippets());
save_all_snippets(&app, &snippets)
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashSet;
fn create_test_snippet(id: &str, name: &str, category: &str, is_default: bool) -> Snippet {
Snippet {
id: id.to_string(),
name: name.to_string(),
content: "Test content".to_string(),
category: category.to_string(),
is_default,
created_at: Utc::now(),
updated_at: Utc::now(),
}
}
#[test]
fn test_default_snippets_exist() {
let defaults = get_default_snippets();
assert!(!defaults.is_empty());
assert!(defaults.iter().all(|s| s.is_default));
}
#[test]
fn test_default_snippets_have_required_fields() {
let defaults = get_default_snippets();
for snippet in defaults {
assert!(!snippet.id.is_empty());
assert!(!snippet.name.is_empty());
assert!(!snippet.content.is_empty());
assert!(!snippet.category.is_empty());
}
}
#[test]
fn test_default_snippets_count() {
let defaults = get_default_snippets();
// Should have 8 default snippets
assert_eq!(defaults.len(), 8);
}
#[test]
fn test_default_snippets_have_unique_ids() {
let defaults = get_default_snippets();
let ids: HashSet<&String> = defaults.iter().map(|s| &s.id).collect();
assert_eq!(ids.len(), defaults.len());
}
#[test]
fn test_default_snippets_ids_start_with_default() {
let defaults = get_default_snippets();
assert!(defaults.iter().all(|s| s.id.starts_with("default-")));
}
#[test]
fn test_snippet_serialization() {
let snippet = create_test_snippet("test-1", "Test Snippet", "Testing", false);
let json = serde_json::to_string(&snippet).expect("Failed to serialize");
let parsed: Snippet = serde_json::from_str(&json).expect("Failed to deserialize");
assert_eq!(parsed.id, snippet.id);
assert_eq!(parsed.name, snippet.name);
assert_eq!(parsed.content, snippet.content);
assert_eq!(parsed.category, snippet.category);
assert_eq!(parsed.is_default, snippet.is_default);
}
#[test]
fn test_snippet_clone() {
let original = create_test_snippet("clone-test", "Clone Test", "Category", true);
let cloned = original.clone();
assert_eq!(original.id, cloned.id);
assert_eq!(original.name, cloned.name);
assert_eq!(original.is_default, cloned.is_default);
}
#[test]
#[allow(clippy::useless_vec)]
fn test_snippet_sorting_by_category_then_name() {
let mut snippets = vec![
create_test_snippet("s1", "Zebra", "B-Category", false),
create_test_snippet("s2", "Apple", "A-Category", false),
create_test_snippet("s3", "Banana", "B-Category", false),
create_test_snippet("s4", "Alpha", "A-Category", false),
];
// Sort by category, then by name (mimics list_snippets behavior)
snippets.sort_by(|a, b| {
let cat_cmp = a.category.cmp(&b.category);
if cat_cmp == std::cmp::Ordering::Equal {
a.name.cmp(&b.name)
} else {
cat_cmp
}
});
// A-Category should come first
assert_eq!(snippets[0].category, "A-Category");
assert_eq!(snippets[1].category, "A-Category");
assert_eq!(snippets[2].category, "B-Category");
assert_eq!(snippets[3].category, "B-Category");
// Within categories, alphabetically by name
assert_eq!(snippets[0].name, "Alpha");
assert_eq!(snippets[1].name, "Apple");
assert_eq!(snippets[2].name, "Banana");
assert_eq!(snippets[3].name, "Zebra");
}
#[test]
fn test_known_default_snippets() {
let defaults = get_default_snippets();
let ids: Vec<&str> = defaults.iter().map(|s| s.id.as_str()).collect();
assert!(ids.contains(&"default-explain-code"));
assert!(ids.contains(&"default-fix-error"));
assert!(ids.contains(&"default-write-tests"));
assert!(ids.contains(&"default-refactor"));
assert!(ids.contains(&"default-optimize"));
assert!(ids.contains(&"default-review-pr"));
assert!(ids.contains(&"default-add-comments"));
assert!(ids.contains(&"default-security-review"));
}
#[test]
fn test_default_snippet_categories() {
let defaults = get_default_snippets();
let categories: HashSet<&String> = defaults.iter().map(|s| &s.category).collect();
assert!(categories.contains(&"Code Review".to_string()));
assert!(categories.contains(&"Debugging".to_string()));
assert!(categories.contains(&"Testing".to_string()));
assert!(categories.contains(&"Performance".to_string()));
assert!(categories.contains(&"Documentation".to_string()));
assert!(categories.contains(&"Security".to_string()));
}
#[test]
fn test_snippet_content_not_empty() {
let defaults = get_default_snippets();
for snippet in defaults {
assert!(
snippet.content.len() > 10,
"Content should be meaningful: {}",
snippet.name
);
}
}
#[test]
fn test_snippet_timestamps() {
let snippet = create_test_snippet("time-test", "Time Test", "Cat", false);
assert!(snippet.created_at <= snippet.updated_at);
}
#[test]
fn test_default_snippets_have_same_timestamps() {
let defaults = get_default_snippets();
// All defaults are created at the same instant
let first_created = defaults[0].created_at;
let first_updated = defaults[0].updated_at;
for snippet in &defaults {
assert_eq!(snippet.created_at, first_created);
assert_eq!(snippet.updated_at, first_updated);
}
}
#[test]
fn test_snippet_retain_non_default() {
let mut snippets = vec![
create_test_snippet("default-1", "Default 1", "Cat", true),
create_test_snippet("custom-1", "Custom 1", "Cat", false),
create_test_snippet("default-2", "Default 2", "Cat", true),
create_test_snippet("custom-2", "Custom 2", "Cat", false),
];
// Mimics reset_default_snippets behavior (retain non-defaults)
snippets.retain(|s| !s.is_default);
assert_eq!(snippets.len(), 2);
assert!(snippets.iter().all(|s| !s.is_default));
}
#[test]
#[allow(clippy::useless_vec)]
fn test_snippet_find_by_id() {
let snippets = vec![
create_test_snippet("snippet-1", "First", "Cat", false),
create_test_snippet("snippet-2", "Second", "Cat", false),
create_test_snippet("snippet-3", "Third", "Cat", false),
];
let found = snippets.iter().find(|s| s.id == "snippet-2");
assert!(found.is_some());
assert_eq!(found.unwrap().name, "Second");
let not_found = snippets.iter().find(|s| s.id == "snippet-999");
assert!(not_found.is_none());
}
#[test]
#[allow(clippy::useless_vec)]
fn test_extract_categories_sorted_and_deduped() {
let snippets = vec![
create_test_snippet("s1", "S1", "Zebra", false),
create_test_snippet("s2", "S2", "Alpha", false),
create_test_snippet("s3", "S3", "Beta", false),
create_test_snippet("s4", "S4", "Alpha", false), // Duplicate
];
let mut categories: Vec<String> = snippets.iter().map(|s| s.category.clone()).collect();
categories.sort();
categories.dedup();
assert_eq!(categories.len(), 3);
assert_eq!(categories[0], "Alpha");
assert_eq!(categories[1], "Beta");
assert_eq!(categories[2], "Zebra");
}
#[test]
fn test_snippet_category_code_review_count() {
let defaults = get_default_snippets();
let code_review_count = defaults
.iter()
.filter(|s| s.category == "Code Review")
.count();
// There should be multiple code review snippets
assert!(code_review_count >= 2);
}
}
+462 -3
View File
@@ -1,7 +1,9 @@
use crate::achievements::{check_achievements, AchievementProgress};
use chrono::{Local, Timelike};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::collections::HashMap;
use std::time::Instant; use std::time::Instant;
use crate::achievements::{AchievementProgress, check_achievements}; use tauri_plugin_store::StoreExt;
#[derive(Debug, Clone, Serialize, Deserialize, Default)] #[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct UsageStats { pub struct UsageStats {
@@ -28,6 +30,14 @@ pub struct UsageStats {
#[serde(skip)] #[serde(skip)]
pub session_start: Option<Instant>, pub session_start: Option<Instant>,
// Extended tracking for achievements
pub sessions_started: u64,
pub consecutive_days: u64,
pub total_days_used: u64,
pub morning_sessions: u64, // Sessions started before 9 AM
pub night_sessions: u64, // Sessions started after 10 PM
pub last_session_date: Option<String>, // ISO date string for streak tracking
// Achievement tracking // Achievement tracking
#[serde(skip)] #[serde(skip)]
pub achievements: AchievementProgress, pub achievements: AchievementProgress,
@@ -65,6 +75,47 @@ impl UsageStats {
self.session_duration_seconds = 0; self.session_duration_seconds = 0;
self.session_start = Some(Instant::now()); self.session_start = Some(Instant::now());
self.achievements.start_session(); self.achievements.start_session();
// Track session start for achievements
self.track_session_start();
}
pub fn track_session_start(&mut self) {
let now = Local::now();
let today = now.format("%Y-%m-%d").to_string();
let hour = now.hour();
// Increment session count
self.sessions_started += 1;
// Track morning/night sessions
if hour < 9 {
self.morning_sessions += 1;
}
if hour >= 22 {
self.night_sessions += 1;
}
// Track consecutive days and total days
if let Some(last_date) = &self.last_session_date {
if last_date != &today {
// Check if it's the next day (consecutive)
if is_consecutive_day(last_date, &today) {
self.consecutive_days += 1;
} else {
// Streak broken
self.consecutive_days = 1;
}
self.total_days_used += 1;
self.last_session_date = Some(today);
}
// Same day - don't increment anything
} else {
// First session ever
self.consecutive_days = 1;
self.total_days_used = 1;
self.last_session_date = Some(today);
}
} }
pub fn increment_messages(&mut self) { pub fn increment_messages(&mut self) {
@@ -89,7 +140,10 @@ impl UsageStats {
pub fn increment_tool_usage(&mut self, tool_name: &str) { pub fn increment_tool_usage(&mut self, tool_name: &str) {
*self.tools_usage.entry(tool_name.to_string()).or_insert(0) += 1; *self.tools_usage.entry(tool_name.to_string()).or_insert(0) += 1;
*self.session_tools_usage.entry(tool_name.to_string()).or_insert(0) += 1; *self
.session_tools_usage
.entry(tool_name.to_string())
.or_insert(0) += 1;
} }
pub fn get_session_duration(&mut self) -> u64 { pub fn get_session_duration(&mut self) -> u64 {
@@ -124,12 +178,34 @@ impl UsageStats {
session_tools_usage: self.session_tools_usage.clone(), session_tools_usage: self.session_tools_usage.clone(),
session_duration_seconds: self.session_duration_seconds, session_duration_seconds: self.session_duration_seconds,
session_start: self.session_start, session_start: self.session_start,
sessions_started: self.sessions_started,
consecutive_days: self.consecutive_days,
total_days_used: self.total_days_used,
morning_sessions: self.morning_sessions,
night_sessions: self.night_sessions,
last_session_date: self.last_session_date.clone(),
achievements: AchievementProgress::new(), // Dummy for copy achievements: AchievementProgress::new(), // Dummy for copy
}; };
check_achievements(&stats_copy, &mut self.achievements) check_achievements(&stats_copy, &mut self.achievements)
} }
} }
// Helper function to check if two dates are consecutive
fn is_consecutive_day(prev_date: &str, current_date: &str) -> bool {
use chrono::NaiveDate;
let prev = NaiveDate::parse_from_str(prev_date, "%Y-%m-%d").ok();
let current = NaiveDate::parse_from_str(current_date, "%Y-%m-%d").ok();
match (prev, current) {
(Some(p), Some(c)) => {
let diff = c.signed_duration_since(p).num_days();
diff == 1
}
_ => false,
}
}
// Pricing as of January 2025 // Pricing as of January 2025
// https://www.anthropic.com/pricing // https://www.anthropic.com/pricing
fn calculate_cost(input_tokens: u64, output_tokens: u64, model: &str) -> f64 { fn calculate_cost(input_tokens: u64, output_tokens: u64, model: &str) -> f64 {
@@ -166,6 +242,111 @@ pub struct StatsUpdateEvent {
pub stats: UsageStats, pub stats: UsageStats,
} }
/// Serializable struct for persisting only lifetime (total) stats
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct PersistedStats {
pub total_input_tokens: u64,
pub total_output_tokens: u64,
pub total_cost_usd: f64,
pub messages_exchanged: u64,
pub code_blocks_generated: u64,
pub files_edited: u64,
pub files_created: u64,
pub tools_usage: HashMap<String, u64>,
pub sessions_started: u64,
pub consecutive_days: u64,
pub total_days_used: u64,
pub morning_sessions: u64,
pub night_sessions: u64,
pub last_session_date: Option<String>,
}
impl From<&UsageStats> for PersistedStats {
fn from(stats: &UsageStats) -> Self {
PersistedStats {
total_input_tokens: stats.total_input_tokens,
total_output_tokens: stats.total_output_tokens,
total_cost_usd: stats.total_cost_usd,
messages_exchanged: stats.messages_exchanged,
code_blocks_generated: stats.code_blocks_generated,
files_edited: stats.files_edited,
files_created: stats.files_created,
tools_usage: stats.tools_usage.clone(),
sessions_started: stats.sessions_started,
consecutive_days: stats.consecutive_days,
total_days_used: stats.total_days_used,
morning_sessions: stats.morning_sessions,
night_sessions: stats.night_sessions,
last_session_date: stats.last_session_date.clone(),
}
}
}
impl UsageStats {
/// Apply persisted stats to restore lifetime totals
pub fn apply_persisted(&mut self, persisted: PersistedStats) {
self.total_input_tokens = persisted.total_input_tokens;
self.total_output_tokens = persisted.total_output_tokens;
self.total_cost_usd = persisted.total_cost_usd;
self.messages_exchanged = persisted.messages_exchanged;
self.code_blocks_generated = persisted.code_blocks_generated;
self.files_edited = persisted.files_edited;
self.files_created = persisted.files_created;
self.tools_usage = persisted.tools_usage;
self.sessions_started = persisted.sessions_started;
self.consecutive_days = persisted.consecutive_days;
self.total_days_used = persisted.total_days_used;
self.morning_sessions = persisted.morning_sessions;
self.night_sessions = persisted.night_sessions;
self.last_session_date = persisted.last_session_date;
}
}
/// Save lifetime stats to persistent store
pub async fn save_stats(app: &tauri::AppHandle, stats: &UsageStats) -> Result<(), String> {
let store = app.store("stats.json").map_err(|e| e.to_string())?;
let persisted = PersistedStats::from(stats);
println!("Saving stats: {:?}", persisted);
store.set(
"lifetime_stats",
serde_json::to_value(persisted).map_err(|e| e.to_string())?,
);
store.save().map_err(|e| e.to_string())?;
println!("Stats saved successfully");
Ok(())
}
/// Load lifetime stats from persistent store
pub async fn load_stats(app: &tauri::AppHandle) -> Option<PersistedStats> {
println!("Loading stats from store...");
let store = match app.store("stats.json") {
Ok(s) => s,
Err(e) => {
println!("Failed to open stats store: {}", e);
return None;
}
};
if let Some(stats_value) = store.get("lifetime_stats") {
println!("Found lifetime stats in store: {:?}", stats_value);
if let Ok(persisted) = serde_json::from_value::<PersistedStats>(stats_value.clone()) {
println!("Loaded lifetime stats successfully");
return Some(persisted);
} else {
println!("Failed to parse lifetime stats");
}
} else {
println!("No lifetime stats found in store");
}
None
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -188,6 +369,36 @@ mod tests {
assert!((cost - 0.165).abs() < 0.0001); assert!((cost - 0.165).abs() < 0.0001);
} }
#[test]
fn test_cost_calculation_opus_45() {
let cost = calculate_cost(1000, 2000, "claude-opus-4-5-20251101");
// Same pricing as Opus 4
assert!((cost - 0.165).abs() < 0.0001);
}
#[test]
fn test_cost_calculation_haiku() {
let cost = calculate_cost(1000, 2000, "claude-3-5-haiku-20241022");
// 1000 input * $1/M = $0.001
// 2000 output * $5/M = $0.010
// Total = $0.011
assert!((cost - 0.011).abs() < 0.0001);
}
#[test]
fn test_cost_calculation_unknown_defaults_to_sonnet() {
let cost = calculate_cost(1000, 2000, "some-unknown-model");
// Should default to Sonnet pricing
assert!((cost - 0.033).abs() < 0.0001);
}
#[test]
fn test_cost_calculation_legacy_sonnet() {
let cost = calculate_cost(1000, 2000, "claude-3-5-sonnet-20241022");
// Same as Sonnet 4 pricing
assert!((cost - 0.033).abs() < 0.0001);
}
#[test] #[test]
fn test_usage_stats_accumulation() { fn test_usage_stats_accumulation() {
let mut stats = UsageStats::new(); let mut stats = UsageStats::new();
@@ -200,6 +411,28 @@ mod tests {
assert!((stats.total_cost_usd - 0.033).abs() < 0.0001); assert!((stats.total_cost_usd - 0.033).abs() < 0.0001);
} }
#[test]
fn test_usage_stats_multiple_accumulations() {
let mut stats = UsageStats::new();
stats.add_usage(1000, 1000, "claude-sonnet-4-20250514");
stats.add_usage(500, 500, "claude-sonnet-4-20250514");
assert_eq!(stats.total_input_tokens, 1500);
assert_eq!(stats.total_output_tokens, 1500);
assert_eq!(stats.session_input_tokens, 1500);
assert_eq!(stats.session_output_tokens, 1500);
}
#[test]
fn test_usage_stats_model_updated() {
let mut stats = UsageStats::new();
stats.add_usage(1000, 1000, "claude-sonnet-4-20250514");
assert_eq!(stats.model, Some("claude-sonnet-4-20250514".to_string()));
stats.add_usage(500, 500, "claude-opus-4-20250514");
assert_eq!(stats.model, Some("claude-opus-4-20250514".to_string()));
}
#[test] #[test]
fn test_session_reset() { fn test_session_reset() {
let mut stats = UsageStats::new(); let mut stats = UsageStats::new();
@@ -213,4 +446,230 @@ mod tests {
assert_eq!(stats.session_cost_usd, 0.0); assert_eq!(stats.session_cost_usd, 0.0);
assert!(stats.total_cost_usd > 0.0); assert!(stats.total_cost_usd > 0.0);
} }
}
#[test]
fn test_session_reset_clears_session_stats() {
let mut stats = UsageStats::new();
stats.increment_messages();
stats.increment_messages();
stats.increment_code_blocks();
stats.increment_files_edited();
stats.increment_files_created();
stats.increment_tool_usage("Read");
stats.reset_session();
assert_eq!(stats.session_messages_exchanged, 0);
assert_eq!(stats.session_code_blocks_generated, 0);
assert_eq!(stats.session_files_edited, 0);
assert_eq!(stats.session_files_created, 0);
assert!(stats.session_tools_usage.is_empty());
}
#[test]
fn test_increment_messages() {
let mut stats = UsageStats::new();
stats.increment_messages();
stats.increment_messages();
stats.increment_messages();
assert_eq!(stats.messages_exchanged, 3);
assert_eq!(stats.session_messages_exchanged, 3);
}
#[test]
fn test_increment_code_blocks() {
let mut stats = UsageStats::new();
stats.increment_code_blocks();
stats.increment_code_blocks();
assert_eq!(stats.code_blocks_generated, 2);
assert_eq!(stats.session_code_blocks_generated, 2);
}
#[test]
fn test_increment_files_edited() {
let mut stats = UsageStats::new();
stats.increment_files_edited();
assert_eq!(stats.files_edited, 1);
assert_eq!(stats.session_files_edited, 1);
}
#[test]
fn test_increment_files_created() {
let mut stats = UsageStats::new();
stats.increment_files_created();
assert_eq!(stats.files_created, 1);
assert_eq!(stats.session_files_created, 1);
}
#[test]
fn test_increment_tool_usage() {
let mut stats = UsageStats::new();
stats.increment_tool_usage("Read");
stats.increment_tool_usage("Read");
stats.increment_tool_usage("Write");
assert_eq!(stats.tools_usage.get("Read"), Some(&2));
assert_eq!(stats.tools_usage.get("Write"), Some(&1));
assert_eq!(stats.session_tools_usage.get("Read"), Some(&2));
assert_eq!(stats.session_tools_usage.get("Write"), Some(&1));
}
#[test]
fn test_session_duration_tracking() {
let mut stats = UsageStats::new();
stats.session_start = Some(Instant::now());
// Verify duration is returned (u64 is always non-negative)
let _duration = stats.get_session_duration();
}
#[test]
fn test_session_duration_without_start() {
let mut stats = UsageStats::new();
stats.session_start = None;
stats.session_duration_seconds = 100;
// Should return the stored value when no start time
let duration = stats.get_session_duration();
assert_eq!(duration, 100);
}
#[test]
fn test_is_consecutive_day_true() {
assert!(is_consecutive_day("2024-01-15", "2024-01-16"));
assert!(is_consecutive_day("2024-12-31", "2025-01-01"));
}
#[test]
fn test_is_consecutive_day_false() {
assert!(!is_consecutive_day("2024-01-15", "2024-01-15")); // Same day
assert!(!is_consecutive_day("2024-01-15", "2024-01-17")); // Gap
assert!(!is_consecutive_day("2024-01-15", "2024-01-14")); // Backwards
}
#[test]
fn test_is_consecutive_day_invalid_dates() {
assert!(!is_consecutive_day("invalid", "2024-01-01"));
assert!(!is_consecutive_day("2024-01-01", "invalid"));
assert!(!is_consecutive_day("invalid", "also-invalid"));
}
#[test]
fn test_persisted_stats_from_usage_stats() {
let mut stats = UsageStats::new();
stats.total_input_tokens = 5000;
stats.total_output_tokens = 10000;
stats.total_cost_usd = 1.23;
stats.messages_exchanged = 50;
stats.sessions_started = 5;
stats.consecutive_days = 3;
let persisted = PersistedStats::from(&stats);
assert_eq!(persisted.total_input_tokens, 5000);
assert_eq!(persisted.total_output_tokens, 10000);
assert_eq!(persisted.total_cost_usd, 1.23);
assert_eq!(persisted.messages_exchanged, 50);
assert_eq!(persisted.sessions_started, 5);
assert_eq!(persisted.consecutive_days, 3);
}
#[test]
fn test_apply_persisted_stats() {
let persisted = PersistedStats {
total_input_tokens: 10000,
total_output_tokens: 20000,
total_cost_usd: 5.50,
messages_exchanged: 100,
code_blocks_generated: 25,
files_edited: 10,
files_created: 5,
tools_usage: {
let mut map = HashMap::new();
map.insert("Read".to_string(), 50);
map
},
sessions_started: 10,
consecutive_days: 7,
total_days_used: 14,
morning_sessions: 3,
night_sessions: 2,
last_session_date: Some("2024-06-15".to_string()),
};
let mut stats = UsageStats::new();
stats.apply_persisted(persisted);
assert_eq!(stats.total_input_tokens, 10000);
assert_eq!(stats.total_output_tokens, 20000);
assert_eq!(stats.total_cost_usd, 5.50);
assert_eq!(stats.messages_exchanged, 100);
assert_eq!(stats.tools_usage.get("Read"), Some(&50));
assert_eq!(stats.consecutive_days, 7);
assert_eq!(stats.morning_sessions, 3);
assert_eq!(stats.last_session_date, Some("2024-06-15".to_string()));
}
#[test]
fn test_usage_stats_default() {
let stats = UsageStats::default();
assert_eq!(stats.total_input_tokens, 0);
assert_eq!(stats.total_output_tokens, 0);
assert_eq!(stats.total_cost_usd, 0.0);
assert!(stats.model.is_none());
}
#[test]
fn test_persisted_stats_default() {
let persisted = PersistedStats::default();
assert_eq!(persisted.total_input_tokens, 0);
assert!(persisted.last_session_date.is_none());
}
#[test]
fn test_usage_stats_serialization() {
let mut stats = UsageStats::new();
stats.add_usage(1000, 2000, "claude-sonnet-4-20250514");
stats.increment_messages();
// UsageStats should be serializable (for events)
let json = serde_json::to_string(&stats).expect("Failed to serialize");
assert!(json.contains("total_input_tokens"));
assert!(json.contains("1000"));
}
#[test]
fn test_persisted_stats_serialization() {
let persisted = PersistedStats {
total_input_tokens: 1234,
total_output_tokens: 5678,
total_cost_usd: 0.99,
..Default::default()
};
let json = serde_json::to_string(&persisted).expect("Failed to serialize");
let parsed: PersistedStats = serde_json::from_str(&json).expect("Failed to deserialize");
assert_eq!(parsed.total_input_tokens, 1234);
assert_eq!(parsed.total_output_tokens, 5678);
assert!((parsed.total_cost_usd - 0.99).abs() < 0.0001);
}
#[test]
fn test_stats_update_event_serialization() {
let mut stats = UsageStats::new();
stats.add_usage(100, 200, "claude-sonnet-4-20250514");
let event = StatsUpdateEvent { stats };
let json = serde_json::to_string(&event).expect("Failed to serialize");
assert!(json.contains("stats"));
assert!(json.contains("total_input_tokens"));
}
}
+426
View File
@@ -0,0 +1,426 @@
use parking_lot::Mutex;
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use uuid::Uuid;
const TEMP_DIR_NAME: &str = "hikari-uploads";
pub struct TempFileManager {
base_dir: PathBuf,
files: HashMap<String, Vec<PathBuf>>,
}
impl TempFileManager {
pub fn new() -> Result<Self, String> {
let base_dir = std::env::temp_dir().join(TEMP_DIR_NAME);
if !base_dir.exists() {
fs::create_dir_all(&base_dir)
.map_err(|e| format!("Failed to create temp directory: {}", e))?;
}
Ok(TempFileManager {
base_dir,
files: HashMap::new(),
})
}
#[allow(dead_code)]
pub fn get_base_dir(&self) -> &Path {
&self.base_dir
}
pub fn save_file(
&mut self,
conversation_id: &str,
data: &[u8],
original_filename: Option<&str>,
) -> Result<PathBuf, String> {
let unique_id = Uuid::new_v4();
let extension = original_filename
.and_then(|name| Path::new(name).extension())
.and_then(|ext| ext.to_str())
.unwrap_or("bin");
let filename = format!("{}_{}.{}", conversation_id, unique_id, extension);
let file_path = self.base_dir.join(&filename);
fs::write(&file_path, data)
.map_err(|e| format!("Failed to write temp file: {}", e))?;
self.files
.entry(conversation_id.to_string())
.or_default()
.push(file_path.clone());
Ok(file_path)
}
pub fn register_file(&mut self, conversation_id: &str, file_path: PathBuf) {
self.files
.entry(conversation_id.to_string())
.or_default()
.push(file_path);
}
pub fn get_files_for_conversation(&self, conversation_id: &str) -> Vec<PathBuf> {
self.files
.get(conversation_id)
.cloned()
.unwrap_or_default()
}
pub fn cleanup_conversation(&mut self, conversation_id: &str) -> Result<(), String> {
if let Some(files) = self.files.remove(conversation_id) {
for file_path in files {
if file_path.exists() {
if let Err(e) = fs::remove_file(&file_path) {
eprintln!(
"Warning: Failed to remove temp file {:?}: {}",
file_path, e
);
}
}
}
}
Ok(())
}
pub fn cleanup_all(&mut self) -> Result<(), String> {
let conversation_ids: Vec<String> = self.files.keys().cloned().collect();
for conversation_id in conversation_ids {
self.cleanup_conversation(&conversation_id)?;
}
Ok(())
}
pub fn cleanup_orphaned_files(&mut self) -> Result<usize, String> {
let mut cleaned_count = 0;
if !self.base_dir.exists() {
return Ok(0);
}
let tracked_files: std::collections::HashSet<PathBuf> =
self.files.values().flatten().cloned().collect();
let entries = fs::read_dir(&self.base_dir)
.map_err(|e| format!("Failed to read temp directory: {}", e))?;
for entry in entries.flatten() {
let path = entry.path();
if path.is_file() && !tracked_files.contains(&path) {
if let Err(e) = fs::remove_file(&path) {
eprintln!("Warning: Failed to remove orphaned file {:?}: {}", path, e);
} else {
cleaned_count += 1;
}
}
}
Ok(cleaned_count)
}
}
impl Default for TempFileManager {
fn default() -> Self {
Self::new().expect("Failed to create TempFileManager")
}
}
pub type SharedTempFileManager = Arc<Mutex<TempFileManager>>;
pub fn create_shared_temp_manager() -> Result<SharedTempFileManager, String> {
Ok(Arc::new(Mutex::new(TempFileManager::new()?)))
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
// Helper to create a TempFileManager with a custom base directory for testing
fn create_test_manager(base_dir: PathBuf) -> TempFileManager {
if !base_dir.exists() {
fs::create_dir_all(&base_dir).expect("Failed to create test temp dir");
}
TempFileManager {
base_dir,
files: HashMap::new(),
}
}
#[test]
fn test_new_creates_base_directory() {
let manager = TempFileManager::new().expect("Failed to create TempFileManager");
assert!(manager.base_dir.exists());
}
#[test]
fn test_get_base_dir_returns_correct_path() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let base_path = temp_dir.path().join("hikari-test");
let manager = create_test_manager(base_path.clone());
assert_eq!(manager.get_base_dir(), base_path.as_path());
}
#[test]
fn test_save_file_creates_file_with_content() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let base_path = temp_dir.path().join("hikari-test");
let mut manager = create_test_manager(base_path);
let data = b"Hello, world!";
let result = manager.save_file("conv-1", data, Some("test.txt"));
assert!(result.is_ok());
let file_path = result.unwrap();
assert!(file_path.exists());
let content = fs::read(&file_path).expect("Failed to read file");
assert_eq!(content, data);
}
#[test]
fn test_save_file_uses_correct_extension() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let base_path = temp_dir.path().join("hikari-test");
let mut manager = create_test_manager(base_path);
let data = b"test data";
let result = manager.save_file("conv-1", data, Some("document.pdf"));
assert!(result.is_ok());
let file_path = result.unwrap();
assert_eq!(file_path.extension().unwrap(), "pdf");
}
#[test]
fn test_save_file_uses_bin_extension_when_no_filename() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let base_path = temp_dir.path().join("hikari-test");
let mut manager = create_test_manager(base_path);
let data = b"binary data";
let result = manager.save_file("conv-1", data, None);
assert!(result.is_ok());
let file_path = result.unwrap();
assert_eq!(file_path.extension().unwrap(), "bin");
}
#[test]
fn test_register_file_tracks_file_path() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let base_path = temp_dir.path().join("hikari-test");
let mut manager = create_test_manager(base_path);
let file_path = PathBuf::from("/some/path/file.txt");
manager.register_file("conv-1", file_path.clone());
let files = manager.get_files_for_conversation("conv-1");
assert_eq!(files.len(), 1);
assert_eq!(files[0], file_path);
}
#[test]
fn test_get_files_for_conversation_returns_empty_for_unknown() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let base_path = temp_dir.path().join("hikari-test");
let manager = create_test_manager(base_path);
let files = manager.get_files_for_conversation("unknown-conv");
assert!(files.is_empty());
}
#[test]
fn test_get_files_for_conversation_returns_all_files() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let base_path = temp_dir.path().join("hikari-test");
let mut manager = create_test_manager(base_path);
let data = b"test";
manager.save_file("conv-1", data, Some("file1.txt")).unwrap();
manager.save_file("conv-1", data, Some("file2.txt")).unwrap();
manager.save_file("conv-2", data, Some("file3.txt")).unwrap();
let files_conv1 = manager.get_files_for_conversation("conv-1");
let files_conv2 = manager.get_files_for_conversation("conv-2");
assert_eq!(files_conv1.len(), 2);
assert_eq!(files_conv2.len(), 1);
}
#[test]
fn test_cleanup_conversation_removes_files() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let base_path = temp_dir.path().join("hikari-test");
let mut manager = create_test_manager(base_path);
let data = b"test";
let file_path = manager.save_file("conv-1", data, Some("test.txt")).unwrap();
assert!(file_path.exists());
let result = manager.cleanup_conversation("conv-1");
assert!(result.is_ok());
assert!(!file_path.exists());
assert!(manager.get_files_for_conversation("conv-1").is_empty());
}
#[test]
fn test_cleanup_conversation_handles_missing_files() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let base_path = temp_dir.path().join("hikari-test");
let mut manager = create_test_manager(base_path);
// Register a file that doesn't exist
manager.register_file("conv-1", PathBuf::from("/nonexistent/file.txt"));
// Should not error, just skip missing files
let result = manager.cleanup_conversation("conv-1");
assert!(result.is_ok());
}
#[test]
fn test_cleanup_conversation_for_unknown_returns_ok() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let base_path = temp_dir.path().join("hikari-test");
let mut manager = create_test_manager(base_path);
let result = manager.cleanup_conversation("unknown-conv");
assert!(result.is_ok());
}
#[test]
fn test_cleanup_all_removes_all_files() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let base_path = temp_dir.path().join("hikari-test");
let mut manager = create_test_manager(base_path);
let data = b"test";
let file1 = manager.save_file("conv-1", data, Some("f1.txt")).unwrap();
let file2 = manager.save_file("conv-2", data, Some("f2.txt")).unwrap();
assert!(file1.exists());
assert!(file2.exists());
let result = manager.cleanup_all();
assert!(result.is_ok());
assert!(!file1.exists());
assert!(!file2.exists());
assert!(manager.files.is_empty());
}
#[test]
fn test_cleanup_orphaned_files_removes_untracked() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let base_path = temp_dir.path().join("hikari-test");
let mut manager = create_test_manager(base_path.clone());
// Create a tracked file
let data = b"tracked";
let tracked_path = manager.save_file("conv-1", data, Some("tracked.txt")).unwrap();
// Create an untracked (orphaned) file directly in the temp directory
let orphan_path = base_path.join("orphan.txt");
fs::write(&orphan_path, b"orphan").expect("Failed to create orphan file");
assert!(tracked_path.exists());
assert!(orphan_path.exists());
let result = manager.cleanup_orphaned_files();
assert!(result.is_ok());
assert_eq!(result.unwrap(), 1); // One orphan removed
assert!(tracked_path.exists()); // Tracked file still exists
assert!(!orphan_path.exists()); // Orphan removed
}
#[test]
fn test_cleanup_orphaned_returns_zero_when_none() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let base_path = temp_dir.path().join("hikari-test");
let mut manager = create_test_manager(base_path);
let data = b"test";
manager.save_file("conv-1", data, Some("test.txt")).unwrap();
let result = manager.cleanup_orphaned_files();
assert!(result.is_ok());
assert_eq!(result.unwrap(), 0);
}
#[test]
fn test_cleanup_orphaned_returns_zero_when_dir_missing() {
let mut manager = TempFileManager {
base_dir: PathBuf::from("/nonexistent/dir"),
files: HashMap::new(),
};
let result = manager.cleanup_orphaned_files();
assert!(result.is_ok());
assert_eq!(result.unwrap(), 0);
}
#[test]
fn test_default_creates_manager() {
// Default should work as long as we can create temp directories
let manager = TempFileManager::default();
assert!(manager.base_dir.exists());
}
#[test]
fn test_create_shared_temp_manager() {
let result = create_shared_temp_manager();
assert!(result.is_ok());
let shared = result.unwrap();
let manager = shared.lock();
assert!(manager.base_dir.exists());
}
#[test]
fn test_multiple_files_same_conversation() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let base_path = temp_dir.path().join("hikari-test");
let mut manager = create_test_manager(base_path);
// Save multiple files to same conversation
for i in 0..5 {
let data = format!("content {}", i);
manager
.save_file("conv-1", data.as_bytes(), Some(&format!("file{}.txt", i)))
.unwrap();
}
let files = manager.get_files_for_conversation("conv-1");
assert_eq!(files.len(), 5);
// Each file should have unique content
for (i, file_path) in files.iter().enumerate() {
let content = fs::read_to_string(file_path).expect("Failed to read");
assert_eq!(content, format!("content {}", i));
}
}
#[test]
fn test_file_paths_contain_conversation_id() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let base_path = temp_dir.path().join("hikari-test");
let mut manager = create_test_manager(base_path);
let file_path = manager
.save_file("my-conversation-id", b"test", Some("test.txt"))
.unwrap();
let filename = file_path.file_name().unwrap().to_str().unwrap();
assert!(filename.starts_with("my-conversation-id_"));
}
}
+68
View File
@@ -0,0 +1,68 @@
use tauri::{
menu::{Menu, MenuItem},
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
AppHandle, Manager,
};
use crate::config::HikariConfig;
pub fn setup_tray(app: &AppHandle) -> tauri::Result<()> {
let show_item = MenuItem::with_id(app, "show", "Show Hikari", true, None::<&str>)?;
let quit_item = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?;
let menu = Menu::with_items(app, &[&show_item, &quit_item])?;
let _tray = TrayIconBuilder::with_id("main")
.icon(app.default_window_icon().unwrap().clone())
.menu(&menu)
.tooltip("Hikari - Claude Code Assistant")
.on_menu_event(|app, event| match event.id.as_ref() {
"show" => {
if let Some(window) = app.get_webview_window("main") {
let _ = window.show();
let _ = window.unminimize();
let _ = window.set_focus();
}
}
"quit" => {
app.exit(0);
}
_ => {}
})
.on_tray_icon_event(|tray, event| {
if let TrayIconEvent::Click {
button: MouseButton::Left,
button_state: MouseButtonState::Up,
..
} = event
{
let app = tray.app_handle();
if let Some(window) = app.get_webview_window("main") {
let _ = window.show();
let _ = window.unminimize();
let _ = window.set_focus();
}
}
})
.build(app)?;
Ok(())
}
pub fn should_minimize_to_tray(app: &AppHandle) -> bool {
let config_path = app
.path()
.app_config_dir()
.ok()
.map(|p| p.join("hikari-config.json"));
if let Some(path) = config_path {
if let Ok(content) = std::fs::read_to_string(&path) {
if let Ok(config) = serde_json::from_str::<HikariConfig>(&content) {
return config.minimize_to_tray;
}
}
}
false
}
+18
View File
@@ -216,6 +216,24 @@ pub struct WorkingDirectoryEvent {
pub conversation_id: Option<String>, pub conversation_id: Option<String>,
} }
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct QuestionOption {
pub label: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UserQuestionEvent {
pub id: String,
pub question: String,
pub header: Option<String>,
pub options: Vec<QuestionOption>,
pub multi_select: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub conversation_id: Option<String>,
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
+6 -9
View File
@@ -1,7 +1,7 @@
use std::process::Command;
use std::io::Write; use std::io::Write;
use tempfile::NamedTempFile; use std::process::Command;
use tauri::command; use tauri::command;
use tempfile::NamedTempFile;
#[command] #[command]
pub async fn send_vbs_notification(title: String, body: String) -> Result<(), String> { pub async fn send_vbs_notification(title: String, body: String) -> Result<(), String> {
@@ -17,8 +17,8 @@ objShell.Popup "{}" & vbCrLf & vbCrLf & "{}", 5, "{}", 64
); );
// Create a temporary VBS file // Create a temporary VBS file
let mut temp_file = NamedTempFile::new() let mut temp_file =
.map_err(|e| format!("Failed to create temp file: {}", e))?; NamedTempFile::new().map_err(|e| format!("Failed to create temp file: {}", e))?;
temp_file temp_file
.write_all(vbs_content.as_bytes()) .write_all(vbs_content.as_bytes())
@@ -40,10 +40,7 @@ objShell.Popup "{}" & vbCrLf & vbCrLf & "{}", 5, "{}", 64
} else if temp_path.starts_with("/tmp/") { } else if temp_path.starts_with("/tmp/") {
// WSL temp files might be in a different location // WSL temp files might be in a different location
// Try to use wslpath to convert // Try to use wslpath to convert
let output = Command::new("wslpath") let output = Command::new("wslpath").arg("-w").arg(&temp_path).output();
.arg("-w")
.arg(&temp_path)
.output();
if let Ok(result) = output { if let Ok(result) = output {
if result.status.success() { if result.status.success() {
@@ -71,4 +68,4 @@ objShell.Popup "{}" & vbCrLf & vbCrLf & "{}", 5, "{}", 64
} }
Ok(()) Ok(())
} }
+4 -3
View File
@@ -2,7 +2,7 @@ use tauri::command;
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
use windows::{ use windows::{
core::{HSTRING, Result as WindowsResult}, core::{Result as WindowsResult, HSTRING},
Data::Xml::Dom::*, Data::Xml::Dom::*,
UI::Notifications::*, UI::Notifications::*,
}; };
@@ -38,7 +38,8 @@ fn show_toast_notification(title: &str, body: &str) -> WindowsResult<()> {
let toast = ToastNotification::CreateToastNotification(&xml_doc)?; let toast = ToastNotification::CreateToastNotification(&xml_doc)?;
// Create a toast notifier with an application ID // Create a toast notifier with an application ID
let notifier = ToastNotificationManager::CreateToastNotifierWithId(&HSTRING::from("Hikari Desktop"))?; let notifier =
ToastNotificationManager::CreateToastNotifierWithId(&HSTRING::from("Hikari Desktop"))?;
// Show the notification // Show the notification
notifier.Show(&toast)?; notifier.Show(&toast)?;
@@ -60,4 +61,4 @@ fn escape_xml(text: &str) -> String {
#[command] #[command]
pub async fn send_windows_toast(_title: String, _body: String) -> Result<(), String> { pub async fn send_windows_toast(_title: String, _body: String) -> Result<(), String> {
Err("Windows toast notifications are only available on Windows".to_string()) Err("Windows toast notifications are only available on Windows".to_string())
} }
+396 -100
View File
@@ -8,11 +8,15 @@ use tempfile::NamedTempFile;
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
use std::os::windows::process::CommandExt; use std::os::windows::process::CommandExt;
use crate::config::ClaudeStartOptions;
use crate::stats::{UsageStats, StatsUpdateEvent};
use parking_lot::RwLock;
use crate::types::{CharacterState, ClaudeMessage, ConnectionStatus, ContentBlock, StateChangeEvent, OutputEvent, PermissionPromptEvent, ConnectionEvent, SessionEvent, WorkingDirectoryEvent};
use crate::achievements::{get_achievement_info, AchievementUnlockedEvent}; use crate::achievements::{get_achievement_info, AchievementUnlockedEvent};
use crate::config::ClaudeStartOptions;
use crate::stats::{StatsUpdateEvent, UsageStats};
use crate::types::{
CharacterState, ClaudeMessage, ConnectionEvent, ConnectionStatus, ContentBlock, OutputEvent,
PermissionPromptEvent, QuestionOption, SessionEvent, StateChangeEvent, UserQuestionEvent,
WorkingDirectoryEvent,
};
use parking_lot::RwLock;
const SEARCH_TOOLS: [&str; 5] = ["Read", "Glob", "Grep", "WebSearch", "WebFetch"]; const SEARCH_TOOLS: [&str; 5] = ["Read", "Glob", "Grep", "WebSearch", "WebFetch"];
const CODING_TOOLS: [&str; 3] = ["Edit", "Write", "NotebookEdit"]; const CODING_TOOLS: [&str; 3] = ["Edit", "Write", "NotebookEdit"];
@@ -103,26 +107,42 @@ impl WslBridge {
} }
} }
pub fn start(&mut self, app: AppHandle, options: ClaudeStartOptions) -> Result<(), String> { pub fn start(&mut self, app: AppHandle, options: ClaudeStartOptions) -> Result<(), String> {
if self.process.is_some() { if self.process.is_some() {
return Err("Process already running".to_string()); return Err("Process already running".to_string());
} }
// Load saved achievements when starting a new session // Load saved achievements and stats when starting a new session
let app_clone = app.clone(); let app_clone = app.clone();
let stats = self.stats.clone(); let stats = self.stats.clone();
tauri::async_runtime::spawn(async move { tauri::async_runtime::spawn(async move {
println!("Loading saved achievements..."); println!("Loading saved achievements...");
let achievements = crate::achievements::load_achievements(&app_clone).await; let achievements = crate::achievements::load_achievements(&app_clone).await;
println!("Loaded {} unlocked achievements", achievements.unlocked.len()); println!(
stats.write().achievements = achievements; "Loaded {} unlocked achievements",
achievements.unlocked.len()
);
println!("Loading saved stats...");
let persisted_stats = crate::stats::load_stats(&app_clone).await;
let mut stats_guard = stats.write();
stats_guard.achievements = achievements;
if let Some(persisted) = persisted_stats {
println!("Applying persisted lifetime stats");
stats_guard.apply_persisted(persisted);
}
}); });
let working_dir = &options.working_dir; let working_dir = &options.working_dir;
self.working_directory = working_dir.clone(); self.working_directory = working_dir.clone();
emit_connection_status(&app, ConnectionStatus::Connecting, self.conversation_id.clone()); emit_connection_status(
&app,
ConnectionStatus::Connecting,
self.conversation_id.clone(),
);
// Create temp file for MCP config if provided // Create temp file for MCP config if provided
let mcp_config_path = if let Some(ref mcp_json) = options.mcp_servers_json { let mcp_config_path = if let Some(ref mcp_json) = options.mcp_servers_json {
@@ -158,16 +178,19 @@ impl WslBridge {
let mut command = if is_wsl { let mut command = if is_wsl {
// Running inside WSL - call claude directly // Running inside WSL - call claude directly
// Try to find claude in common locations since GUI apps may not inherit shell PATH // Try to find claude in common locations since GUI apps may not inherit shell PATH
let claude_path = find_claude_binary() let claude_path = find_claude_binary().ok_or_else(|| {
.ok_or_else(|| "Could not find claude binary. Is Claude Code installed?".to_string())?; "Could not find claude binary. Is Claude Code installed?".to_string()
})?;
eprintln!("[DEBUG] Found claude at: {}", claude_path); eprintln!("[DEBUG] Found claude at: {}", claude_path);
eprintln!("[DEBUG] Working dir: {}", working_dir); eprintln!("[DEBUG] Working dir: {}", working_dir);
let mut cmd = Command::new(&claude_path); let mut cmd = Command::new(&claude_path);
cmd.args([ cmd.args([
"--output-format", "stream-json", "--output-format",
"--input-format", "stream-json", "stream-json",
"--input-format",
"stream-json",
"--verbose", "--verbose",
]); ]);
@@ -195,6 +218,13 @@ impl WslBridge {
cmd.args(["--mcp-config", mcp_path]); cmd.args(["--mcp-config", mcp_path]);
} }
// Add resume flag if session ID provided
if let Some(ref session_id) = options.resume_session_id {
if !session_id.is_empty() {
cmd.args(["--resume", session_id]);
}
}
cmd.current_dir(working_dir); cmd.current_dir(working_dir);
// Set API key as environment variable if specified // Set API key as environment variable if specified
@@ -211,10 +241,7 @@ impl WslBridge {
let mut cmd = Command::new("wsl"); let mut cmd = Command::new("wsl");
// Build the claude command with all arguments // Build the claude command with all arguments
let mut claude_cmd = format!( let mut claude_cmd = format!("cd '{}' && ", working_dir);
"cd '{}' && ",
working_dir
);
// Set API key as environment variable if specified // Set API key as environment variable if specified
if let Some(ref api_key) = options.api_key { if let Some(ref api_key) = options.api_key {
@@ -223,7 +250,9 @@ impl WslBridge {
} }
} }
claude_cmd.push_str("claude --output-format stream-json --input-format stream-json --verbose"); claude_cmd.push_str(
"claude --output-format stream-json --input-format stream-json --verbose",
);
// Add model if specified // Add model if specified
if let Some(ref model) = options.model { if let Some(ref model) = options.model {
@@ -251,6 +280,13 @@ impl WslBridge {
claude_cmd.push_str(&format!(" --mcp-config '{}'", mcp_path)); claude_cmd.push_str(&format!(" --mcp-config '{}'", mcp_path));
} }
// Add resume flag if session ID provided
if let Some(ref session_id) = options.resume_session_id {
if !session_id.is_empty() {
claude_cmd.push_str(&format!(" --resume '{}'", session_id));
}
}
// Use bash -lc to load login profile (ensures PATH includes claude) // Use bash -lc to load login profile (ensures PATH includes claude)
cmd.args(["-e", "bash", "-lc", &claude_cmd]); cmd.args(["-e", "bash", "-lc", &claude_cmd]);
@@ -278,8 +314,8 @@ impl WslBridge {
self.stdin = stdin; self.stdin = stdin;
self.process = Some(child); self.process = Some(child);
// Reset session stats when starting new session // Note: We no longer reset stats here - stats persist across reconnects
self.stats.write().reset_session(); // Stats are only reset when explicitly disconnecting via stop()
// Load saved achievements // Load saved achievements
let app_handle = app.clone(); let app_handle = app.clone();
@@ -306,7 +342,11 @@ impl WslBridge {
}); });
} }
emit_connection_status(&app, ConnectionStatus::Connected, self.conversation_id.clone()); emit_connection_status(
&app,
ConnectionStatus::Connected,
self.conversation_id.clone(),
);
Ok(()) Ok(())
} }
@@ -331,7 +371,44 @@ impl WslBridge {
.write_all(format!("{}\n", json_line).as_bytes()) .write_all(format!("{}\n", json_line).as_bytes())
.map_err(|e| format!("Failed to write to stdin: {}", e))?; .map_err(|e| format!("Failed to write to stdin: {}", e))?;
stdin.flush().map_err(|e| format!("Failed to flush stdin: {}", e))?; stdin
.flush()
.map_err(|e| format!("Failed to flush stdin: {}", e))?;
Ok(())
}
pub fn send_tool_result(
&mut self,
tool_use_id: &str,
result: serde_json::Value,
) -> Result<(), String> {
let stdin = self.stdin.as_mut().ok_or("Process not running")?;
// The content should be a JSON string representation of the result
let content_str = serde_json::to_string(&result).map_err(|e| e.to_string())?;
let input = serde_json::json!({
"type": "user",
"message": {
"role": "user",
"content": [{
"type": "tool_result",
"tool_use_id": tool_use_id,
"content": content_str
}]
}
});
let json_line = serde_json::to_string(&input).map_err(|e| e.to_string())?;
stdin
.write_all(format!("{}\n", json_line).as_bytes())
.map_err(|e| format!("Failed to write to stdin: {}", e))?;
stdin
.flush()
.map_err(|e| format!("Failed to flush stdin: {}", e))?;
Ok(()) Ok(())
} }
@@ -352,7 +429,11 @@ impl WslBridge {
// The user will see what session was interrupted // The user will see what session was interrupted
// Emit disconnected status // Emit disconnected status
emit_connection_status(app, ConnectionStatus::Disconnected, self.conversation_id.clone()); emit_connection_status(
app,
ConnectionStatus::Disconnected,
self.conversation_id.clone(),
);
Ok(()) Ok(())
} else { } else {
@@ -368,7 +449,27 @@ impl WslBridge {
self.stdin = None; self.stdin = None;
self.session_id = None; self.session_id = None;
self.mcp_config_file = None; // Temp file is automatically deleted when dropped self.mcp_config_file = None; // Temp file is automatically deleted when dropped
emit_connection_status(app, ConnectionStatus::Disconnected, self.conversation_id.clone());
// Save lifetime stats before resetting session
let stats_snapshot = self.stats.read().clone();
let app_clone = app.clone();
tauri::async_runtime::spawn(async move {
println!("Saving stats on session stop...");
if let Err(e) = crate::stats::save_stats(&app_clone, &stats_snapshot).await {
eprintln!("Failed to save stats: {}", e);
} else {
println!("Stats saved successfully on session stop");
}
});
// Reset session stats on explicit disconnect
self.stats.write().reset_session();
emit_connection_status(
app,
ConnectionStatus::Disconnected,
self.conversation_id.clone(),
);
} }
pub fn is_running(&self) -> bool { pub fn is_running(&self) -> bool {
@@ -382,7 +483,6 @@ impl WslBridge {
pub fn get_stats(&self) -> UsageStats { pub fn get_stats(&self) -> UsageStats {
self.stats.read().clone() self.stats.read().clone()
} }
} }
impl Default for WslBridge { impl Default for WslBridge {
@@ -391,7 +491,12 @@ impl Default for WslBridge {
} }
} }
fn handle_stdout(stdout: std::process::ChildStdout, app: AppHandle, stats: Arc<RwLock<UsageStats>>, conversation_id: Option<String>) { fn handle_stdout(
stdout: std::process::ChildStdout,
app: AppHandle,
stats: Arc<RwLock<UsageStats>>,
conversation_id: Option<String>,
) {
let reader = BufReader::new(stdout); let reader = BufReader::new(stdout);
for line in reader.lines() { for line in reader.lines() {
@@ -412,18 +517,25 @@ fn handle_stdout(stdout: std::process::ChildStdout, app: AppHandle, stats: Arc<R
emit_connection_status(&app, ConnectionStatus::Disconnected, conversation_id); emit_connection_status(&app, ConnectionStatus::Disconnected, conversation_id);
} }
fn handle_stderr(stderr: std::process::ChildStderr, app: AppHandle, conversation_id: Option<String>) { fn handle_stderr(
stderr: std::process::ChildStderr,
app: AppHandle,
conversation_id: Option<String>,
) {
let reader = BufReader::new(stderr); let reader = BufReader::new(stderr);
for line in reader.lines() { for line in reader.lines() {
match line { match line {
Ok(line) if !line.is_empty() => { Ok(line) if !line.is_empty() => {
let _ = app.emit("claude:output", OutputEvent { let _ = app.emit(
line_type: "error".to_string(), "claude:output",
content: line, OutputEvent {
tool_name: None, line_type: "error".to_string(),
conversation_id: conversation_id.clone(), content: line,
}); tool_name: None,
conversation_id: conversation_id.clone(),
},
);
} }
Err(_) => break, Err(_) => break,
_ => {} _ => {}
@@ -431,24 +543,40 @@ fn handle_stderr(stderr: std::process::ChildStderr, app: AppHandle, conversation
} }
} }
fn process_json_line(line: &str, app: &AppHandle, stats: &Arc<RwLock<UsageStats>>, conversation_id: &Option<String>) -> Result<(), String> { fn process_json_line(
line: &str,
app: &AppHandle,
stats: &Arc<RwLock<UsageStats>>,
conversation_id: &Option<String>,
) -> Result<(), String> {
let message: ClaudeMessage = serde_json::from_str(line) let message: ClaudeMessage = serde_json::from_str(line)
.map_err(|e| format!("Failed to parse JSON: {} - Line: {}", e, line))?; .map_err(|e| format!("Failed to parse JSON: {} - Line: {}", e, line))?;
match &message { match &message {
ClaudeMessage::System { subtype, session_id, cwd, .. } => { ClaudeMessage::System {
subtype,
session_id,
cwd,
..
} => {
if subtype == "init" { if subtype == "init" {
if let Some(id) = session_id { if let Some(id) = session_id {
let _ = app.emit("claude:session", SessionEvent { let _ = app.emit(
session_id: id.clone(), "claude:session",
conversation_id: conversation_id.clone(), SessionEvent {
}); session_id: id.clone(),
conversation_id: conversation_id.clone(),
},
);
} }
if let Some(dir) = cwd { if let Some(dir) = cwd {
let _ = app.emit("claude:cwd", WorkingDirectoryEvent { let _ = app.emit(
directory: dir.clone(), "claude:cwd",
conversation_id: conversation_id.clone(), WorkingDirectoryEvent {
}); directory: dir.clone(),
conversation_id: conversation_id.clone(),
},
);
} }
emit_state_change(app, CharacterState::Idle, None, conversation_id.clone()); emit_state_change(app, CharacterState::Idle, None, conversation_id.clone());
} }
@@ -500,12 +628,15 @@ fn process_json_line(line: &str, app: &AppHandle, stats: &Arc<RwLock<UsageStats>
} }
let desc = format_tool_description(name, input); let desc = format_tool_description(name, input);
let _ = app.emit("claude:output", OutputEvent { let _ = app.emit(
line_type: "tool".to_string(), "claude:output",
content: desc, OutputEvent {
tool_name: Some(name.clone()), line_type: "tool".to_string(),
conversation_id: conversation_id.clone(), content: desc,
}); tool_name: Some(name.clone()),
conversation_id: conversation_id.clone(),
},
);
} }
ContentBlock::Text { text } => { ContentBlock::Text { text } => {
// Count code blocks in the text // Count code blocks in the text
@@ -514,21 +645,27 @@ fn process_json_line(line: &str, app: &AppHandle, stats: &Arc<RwLock<UsageStats>
stats.write().increment_code_blocks(); stats.write().increment_code_blocks();
} }
let _ = app.emit("claude:output", OutputEvent { let _ = app.emit(
line_type: "assistant".to_string(), "claude:output",
content: text.clone(), OutputEvent {
tool_name: None, line_type: "assistant".to_string(),
conversation_id: conversation_id.clone(), content: text.clone(),
}); tool_name: None,
conversation_id: conversation_id.clone(),
},
);
} }
ContentBlock::Thinking { thinking } => { ContentBlock::Thinking { thinking } => {
state = CharacterState::Thinking; state = CharacterState::Thinking;
let _ = app.emit("claude:output", OutputEvent { let _ = app.emit(
line_type: "system".to_string(), "claude:output",
content: format!("[Thinking] {}", thinking), OutputEvent {
tool_name: None, line_type: "system".to_string(),
conversation_id: conversation_id.clone(), content: format!("[Thinking] {}", thinking),
}); tool_name: None,
conversation_id: conversation_id.clone(),
},
);
} }
_ => {} _ => {}
} }
@@ -563,13 +700,34 @@ fn process_json_line(line: &str, app: &AppHandle, stats: &Arc<RwLock<UsageStats>
} }
} }
ClaudeMessage::Result { subtype, result, permission_denials, usage: _, .. } => { ClaudeMessage::Result {
subtype,
result,
permission_denials,
usage,
..
} => {
let state = if subtype == "success" { let state = if subtype == "success" {
CharacterState::Success CharacterState::Success
} else { } else {
CharacterState::Error CharacterState::Error
}; };
// Track token usage from Result messages if available
// This captures tokens from tool outputs and other operations
if let Some(usage_info) = usage {
// We need the model info to calculate cost properly
// For now, use the last known model from stats
let model = {
let stats_guard = stats.read();
stats_guard.model.clone().unwrap_or_else(|| "claude-opus-4-20250514".to_string())
};
let mut stats_guard = stats.write();
stats_guard.add_usage(usage_info.input_tokens, usage_info.output_tokens, &model);
println!("Result message tokens - input: {}, output: {}", usage_info.input_tokens, usage_info.output_tokens);
}
// Always emit updated stats on result message (less frequent) // Always emit updated stats on result message (less frequent)
// This includes the latest session duration // This includes the latest session duration
let newly_unlocked = { let newly_unlocked = {
@@ -584,9 +742,10 @@ fn process_json_line(line: &str, app: &AppHandle, stats: &Arc<RwLock<UsageStats>
// Emit achievement events for any newly unlocked achievements // Emit achievement events for any newly unlocked achievements
for achievement_id in &newly_unlocked { for achievement_id in &newly_unlocked {
let info = get_achievement_info(achievement_id); let info = get_achievement_info(achievement_id);
let _ = app.emit("achievement:unlocked", AchievementUnlockedEvent { let _ = app.emit(
achievement: info, "achievement:unlocked",
}); AchievementUnlockedEvent { achievement: info },
);
} }
// Save achievements after unlocking new ones // Save achievements after unlocking new ones
@@ -598,7 +757,10 @@ fn process_json_line(line: &str, app: &AppHandle, stats: &Arc<RwLock<UsageStats>
// Use Tauri's async runtime instead of tokio::spawn // Use Tauri's async runtime instead of tokio::spawn
tauri::async_runtime::spawn(async move { tauri::async_runtime::spawn(async move {
println!("Spawned save task for achievements"); println!("Spawned save task for achievements");
if let Err(e) = crate::achievements::save_achievements(&app_handle, &achievements_progress).await { if let Err(e) =
crate::achievements::save_achievements(&app_handle, &achievements_progress)
.await
{
eprintln!("Failed to save achievements: {}", e); eprintln!("Failed to save achievements: {}", e);
} else { } else {
println!("Achievement save task completed successfully"); println!("Achievement save task completed successfully");
@@ -608,38 +770,127 @@ fn process_json_line(line: &str, app: &AppHandle, stats: &Arc<RwLock<UsageStats>
let current_stats = stats.read().clone(); let current_stats = stats.read().clone();
let stats_event = StatsUpdateEvent { let stats_event = StatsUpdateEvent {
stats: current_stats, stats: current_stats.clone(),
}; };
let _ = app.emit("claude:stats", stats_event); let _ = app.emit("claude:stats", stats_event);
// Save stats periodically (every 10 messages to avoid excessive disk writes)
if current_stats.session_messages_exchanged.is_multiple_of(10)
&& current_stats.session_messages_exchanged > 0
{
let app_handle = app.clone();
tauri::async_runtime::spawn(async move {
println!("Periodic stats save (every 10 messages)...");
if let Err(e) = crate::stats::save_stats(&app_handle, &current_stats).await {
eprintln!("Failed to save stats: {}", e);
}
});
}
// Only emit error results - success content is already sent via Assistant message // Only emit error results - success content is already sent via Assistant message
if subtype != "success" { if subtype != "success" {
if let Some(text) = result { if let Some(text) = result {
let _ = app.emit("claude:output", OutputEvent { let _ = app.emit(
line_type: "error".to_string(), "claude:output",
content: text.clone(), OutputEvent {
tool_name: None, line_type: "error".to_string(),
conversation_id: conversation_id.clone(), content: text.clone(),
}); tool_name: None,
conversation_id: conversation_id.clone(),
},
);
} }
} }
// Check for permission denials and emit prompts for each // Check for permission denials and emit prompts for each
if let Some(denials) = permission_denials { if let Some(denials) = permission_denials {
let mut has_regular_denials = false;
for denial in denials { for denial in denials {
let description = format_tool_description(&denial.tool_name, &denial.tool_input); // Special handling for AskUserQuestion tool
let _ = app.emit("claude:permission", PermissionPromptEvent { if denial.tool_name == "AskUserQuestion" {
id: denial.tool_use_id.clone(), if let Some(questions) = denial
tool_name: denial.tool_name.clone(), .tool_input
tool_input: denial.tool_input.clone(), .get("questions")
description, .and_then(|q| q.as_array())
conversation_id: conversation_id.clone(), {
}); // For now, handle the first question (most common case)
if let Some(first_question) = questions.first() {
let question_text = first_question
.get("question")
.and_then(|q| q.as_str())
.unwrap_or("Claude has a question for you")
.to_string();
let header = first_question
.get("header")
.and_then(|h| h.as_str())
.map(|s| s.to_string());
let multi_select = first_question
.get("multiSelect")
.and_then(|m| m.as_bool())
.unwrap_or(false);
let options: Vec<QuestionOption> = first_question
.get("options")
.and_then(|opts| opts.as_array())
.map(|opts| {
opts.iter()
.filter_map(|opt| {
let label =
opt.get("label").and_then(|l| l.as_str())?;
let description = opt
.get("description")
.and_then(|d| d.as_str())
.map(|s| s.to_string());
Some(QuestionOption {
label: label.to_string(),
description,
})
})
.collect()
})
.unwrap_or_default();
let _ = app.emit(
"claude:question",
UserQuestionEvent {
id: denial.tool_use_id.clone(),
question: question_text,
header,
options,
multi_select,
conversation_id: conversation_id.clone(),
},
);
}
}
} else {
has_regular_denials = true;
let description =
format_tool_description(&denial.tool_name, &denial.tool_input);
let _ = app.emit(
"claude:permission",
PermissionPromptEvent {
id: denial.tool_use_id.clone(),
tool_name: denial.tool_name.clone(),
tool_input: denial.tool_input.clone(),
description,
conversation_id: conversation_id.clone(),
},
);
}
} }
// Show permission state if there were denials // Show permission state if there were any denials (questions or regular)
if !denials.is_empty() { if has_regular_denials || !denials.is_empty() {
emit_state_change(app, CharacterState::Permission, None, conversation_id.clone()); emit_state_change(
app,
CharacterState::Permission,
None,
conversation_id.clone(),
);
return Ok(()); return Ok(());
} }
} }
@@ -652,7 +903,9 @@ fn process_json_line(line: &str, app: &AppHandle, stats: &Arc<RwLock<UsageStats>
stats.write().increment_messages(); stats.write().increment_messages();
// Extract text content from the message // Extract text content from the message
let message_text = message.content.iter() let message_text = message
.content
.iter()
.filter_map(|block| match block { .filter_map(|block| match block {
crate::types::ContentBlock::Text { text } => Some(text.clone()), crate::types::ContentBlock::Text { text } => Some(text.clone()),
_ => None, _ => None,
@@ -682,9 +935,10 @@ fn process_json_line(line: &str, app: &AppHandle, stats: &Arc<RwLock<UsageStats>
for achievement_id in &newly_unlocked { for achievement_id in &newly_unlocked {
println!("User message unlocked achievement: {:?}", achievement_id); println!("User message unlocked achievement: {:?}", achievement_id);
let info = get_achievement_info(achievement_id); let info = get_achievement_info(achievement_id);
let _ = app.emit("achievement:unlocked", AchievementUnlockedEvent { let _ = app.emit(
achievement: info, "achievement:unlocked",
}); AchievementUnlockedEvent { achievement: info },
);
} }
// Save achievements after unlocking new ones // Save achievements after unlocking new ones
@@ -693,7 +947,10 @@ fn process_json_line(line: &str, app: &AppHandle, stats: &Arc<RwLock<UsageStats>
let app_handle = app.clone(); let app_handle = app.clone();
let achievements_progress = stats.read().achievements.clone(); let achievements_progress = stats.read().achievements.clone();
tauri::async_runtime::spawn(async move { tauri::async_runtime::spawn(async move {
if let Err(e) = crate::achievements::save_achievements(&app_handle, &achievements_progress).await { if let Err(e) =
crate::achievements::save_achievements(&app_handle, &achievements_progress)
.await
{
eprintln!("Failed to save achievements: {}", e); eprintln!("Failed to save achievements: {}", e);
} else { } else {
println!("Achievements saved after user message"); println!("Achievements saved after user message");
@@ -768,15 +1025,36 @@ fn format_tool_description(name: &str, input: &serde_json::Value) -> String {
} }
} }
fn emit_state_change(app: &AppHandle, state: CharacterState, tool_name: Option<String>, conversation_id: Option<String>) { fn emit_state_change(
let _ = app.emit("claude:state", StateChangeEvent { state, tool_name, conversation_id }); app: &AppHandle,
state: CharacterState,
tool_name: Option<String>,
conversation_id: Option<String>,
) {
let _ = app.emit(
"claude:state",
StateChangeEvent {
state,
tool_name,
conversation_id,
},
);
} }
fn emit_connection_status(app: &AppHandle, status: ConnectionStatus, conversation_id: Option<String>) { fn emit_connection_status(
let _ = app.emit("claude:connection", ConnectionEvent { status, conversation_id }); app: &AppHandle,
status: ConnectionStatus,
conversation_id: Option<String>,
) {
let _ = app.emit(
"claude:connection",
ConnectionEvent {
status,
conversation_id,
},
);
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -786,21 +1064,36 @@ mod tests {
assert!(matches!(get_tool_state("Read"), CharacterState::Searching)); assert!(matches!(get_tool_state("Read"), CharacterState::Searching));
assert!(matches!(get_tool_state("Glob"), CharacterState::Searching)); assert!(matches!(get_tool_state("Glob"), CharacterState::Searching));
assert!(matches!(get_tool_state("Grep"), CharacterState::Searching)); assert!(matches!(get_tool_state("Grep"), CharacterState::Searching));
assert!(matches!(get_tool_state("WebSearch"), CharacterState::Searching)); assert!(matches!(
assert!(matches!(get_tool_state("WebFetch"), CharacterState::Searching)); get_tool_state("WebSearch"),
CharacterState::Searching
));
assert!(matches!(
get_tool_state("WebFetch"),
CharacterState::Searching
));
} }
#[test] #[test]
fn test_get_tool_state_coding_tools() { fn test_get_tool_state_coding_tools() {
assert!(matches!(get_tool_state("Edit"), CharacterState::Coding)); assert!(matches!(get_tool_state("Edit"), CharacterState::Coding));
assert!(matches!(get_tool_state("Write"), CharacterState::Coding)); assert!(matches!(get_tool_state("Write"), CharacterState::Coding));
assert!(matches!(get_tool_state("NotebookEdit"), CharacterState::Coding)); assert!(matches!(
get_tool_state("NotebookEdit"),
CharacterState::Coding
));
} }
#[test] #[test]
fn test_get_tool_state_mcp_tools() { fn test_get_tool_state_mcp_tools() {
assert!(matches!(get_tool_state("mcp__github__create_issue"), CharacterState::Mcp)); assert!(matches!(
assert!(matches!(get_tool_state("mcp__notion__search"), CharacterState::Mcp)); get_tool_state("mcp__github__create_issue"),
CharacterState::Mcp
));
assert!(matches!(
get_tool_state("mcp__notion__search"),
CharacterState::Mcp
));
} }
#[test] #[test]
@@ -810,7 +1103,10 @@ mod tests {
#[test] #[test]
fn test_get_tool_state_unknown() { fn test_get_tool_state_unknown() {
assert!(matches!(get_tool_state("SomeUnknownTool"), CharacterState::Typing)); assert!(matches!(
get_tool_state("SomeUnknownTool"),
CharacterState::Typing
));
assert!(matches!(get_tool_state("Bash"), CharacterState::Typing)); assert!(matches!(get_tool_state("Bash"), CharacterState::Typing));
} }
+1 -1
View File
@@ -81,4 +81,4 @@ $notifier.Show($toast)
// If all methods fail, return an error // If all methods fail, return an error
Err("All WSL notification methods failed".to_string()) Err("All WSL notification methods failed".to_string())
} }
+7 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "hikari-desktop", "productName": "hikari-desktop",
"version": "0.2.0", "version": "1.1.1",
"identifier": "com.naomi.hikari-desktop", "identifier": "com.naomi.hikari-desktop",
"build": { "build": {
"beforeDevCommand": "pnpm dev", "beforeDevCommand": "pnpm dev",
@@ -22,6 +22,12 @@
], ],
"security": { "security": {
"csp": null "csp": null
},
"trayIcon": {
"id": "main",
"iconPath": "icons/32x32.png",
"iconAsTemplate": false,
"tooltip": "Hikari - Claude Code Assistant"
} }
}, },
"bundle": { "bundle": {
+158
View File
@@ -6,6 +6,7 @@
--bg-secondary: #16213e; --bg-secondary: #16213e;
--bg-terminal: #0f0f1a; --bg-terminal: #0f0f1a;
--bg-hover: #2a2a4a; --bg-hover: #2a2a4a;
--bg-code: #1e1e2e;
--accent-primary: #e94560; --accent-primary: #e94560;
--accent-secondary: #ff6b9d; --accent-secondary: #ff6b9d;
--text-primary: #ffffff; --text-primary: #ffffff;
@@ -13,11 +14,40 @@
--text-tertiary: #6b7280; --text-tertiary: #6b7280;
--border-color: #2a2a4a; --border-color: #2a2a4a;
/* 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 specific colors */
--terminal-user: #22d3ee; --terminal-user: #22d3ee;
--terminal-tool: #c084fc; --terminal-tool: #c084fc;
--terminal-tool-name: #ddd6fe; --terminal-tool-name: #ddd6fe;
--terminal-error: #f87171; --terminal-error: #f87171;
/* Syntax highlighting colors (dark) */
--hljs-keyword: #f472b6;
--hljs-string: #a3e635;
--hljs-number: #fbbf24;
--hljs-comment: #6b7280;
--hljs-function: #c084fc;
--hljs-type: #22d3ee;
--hljs-variable: #fb923c;
--hljs-meta: #94a3b8;
} }
[data-theme="light"] { [data-theme="light"] {
@@ -25,6 +55,7 @@
--bg-secondary: #ffffff; --bg-secondary: #ffffff;
--bg-terminal: #f1f3f4; --bg-terminal: #f1f3f4;
--bg-hover: #e8e8e8; --bg-hover: #e8e8e8;
--bg-code: #f5f5f5;
--accent-primary: #e94560; --accent-primary: #e94560;
--accent-secondary: #ff6b9d; --accent-secondary: #ff6b9d;
--text-primary: #1a1a2e; --text-primary: #1a1a2e;
@@ -32,11 +63,89 @@
--text-tertiary: #9ca3af; --text-tertiary: #9ca3af;
--border-color: #d0d0e0; --border-color: #d0d0e0;
/* 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 specific colors */
--terminal-user: #0891b2; --terminal-user: #0891b2;
--terminal-tool: #7c3aed; --terminal-tool: #7c3aed;
--terminal-tool-name: #8b5cf6; --terminal-tool-name: #8b5cf6;
--terminal-error: #dc2626; --terminal-error: #dc2626;
/* Syntax highlighting colors (light) */
--hljs-keyword: #d946ef;
--hljs-string: #16a34a;
--hljs-number: #d97706;
--hljs-comment: #9ca3af;
--hljs-function: #7c3aed;
--hljs-type: #0891b2;
--hljs-variable: #ea580c;
--hljs-meta: #64748b;
}
[data-theme="high-contrast"] {
--bg-primary: #000000;
--bg-secondary: #0a0a0a;
--bg-terminal: #000000;
--bg-hover: #1a1a1a;
--bg-code: #0a0a0a;
--accent-primary: #ff4d6d;
--accent-secondary: #ff85a1;
--text-primary: #ffffff;
--text-secondary: #e0e0e0;
--text-tertiary: #b0b0b0;
--border-color: #ffffff;
/* Trans pride colors (high contrast) */
--trans-blue: #00d4ff;
--trans-pink: #ff99cc;
--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 - bright and saturated */
--terminal-user: #00ffff;
--terminal-tool: #ff00ff;
--terminal-tool-name: #ffaaff;
--terminal-error: #ff5555;
/* Syntax highlighting colors (high contrast) */
--hljs-keyword: #ff66ff;
--hljs-string: #66ff66;
--hljs-number: #ffff00;
--hljs-comment: #aaaaaa;
--hljs-function: #ff99ff;
--hljs-type: #00ffff;
--hljs-variable: #ffaa00;
--hljs-meta: #cccccc;
} }
html, html,
@@ -79,3 +188,52 @@ body {
background: var(--accent-primary); background: var(--accent-primary);
color: var(--text-primary); color: var(--text-primary);
} }
/* Trans gradient button - primary action buttons */
.btn-trans-gradient {
background: var(--trans-gradient-vibrant) !important;
border: none !important;
color: #1a1a2e !important;
font-weight: 600;
text-shadow: 0 0 2px rgba(255, 255, 255, 0.5);
transition: all 0.2s ease;
}
.btn-trans-gradient:hover:not(:disabled) {
filter: brightness(1.1);
box-shadow:
0 0 20px rgba(91, 206, 250, 0.4),
0 0 30px rgba(245, 169, 184, 0.3);
}
.btn-trans-gradient:disabled {
opacity: 0.5;
cursor: not-allowed;
filter: grayscale(0.3);
}
/* Trans gradient focus border for inputs */
.input-trans-focus {
position: relative;
transition: all 0.2s ease;
}
.input-trans-focus:focus {
border-color: var(--trans-pink) !important;
box-shadow:
0 0 0 1px var(--trans-blue),
0 0 12px rgba(91, 206, 250, 0.3),
0 0 20px rgba(245, 169, 184, 0.2) !important;
outline: none !important;
}
/* Trans gradient hover for icon buttons */
.icon-trans-hover {
transition: all 0.2s ease;
}
.icon-trans-hover:hover {
color: var(--trans-pink) !important;
filter: drop-shadow(0 0 6px rgba(91, 206, 250, 0.5))
drop-shadow(0 0 10px rgba(245, 169, 184, 0.4));
}
+414
View File
@@ -0,0 +1,414 @@
import { describe, it, expect, vi } from "vitest";
import {
slashCommands,
parseSlashCommand,
getMatchingCommands,
isSlashCommand,
type SlashCommand,
} from "./slashCommands";
// Mock all external dependencies
vi.mock("svelte/store", () => ({
get: vi.fn(),
}));
vi.mock("@tauri-apps/api/core", () => ({
invoke: vi.fn(),
}));
vi.mock("$lib/stores/claude", () => ({
claudeStore: {
addLine: vi.fn(),
clearTerminal: vi.fn(),
activeConversationId: { subscribe: vi.fn() },
currentWorkingDirectory: { subscribe: vi.fn() },
setWorkingDirectory: vi.fn(),
getConversationHistory: vi.fn(),
},
}));
vi.mock("$lib/stores/character", () => ({
characterState: {
setState: vi.fn(),
setTemporaryState: vi.fn(),
},
}));
vi.mock("$lib/tauri", () => ({
setSkipNextGreeting: vi.fn(),
}));
vi.mock("$lib/stores/search", () => ({
searchState: {
setQuery: vi.fn(),
clear: vi.fn(),
},
}));
describe("slashCommands", () => {
describe("slashCommands array", () => {
it("contains expected commands", () => {
const commandNames = slashCommands.map((cmd) => cmd.name);
expect(commandNames).toContain("cd");
expect(commandNames).toContain("clear");
expect(commandNames).toContain("new");
expect(commandNames).toContain("help");
expect(commandNames).toContain("search");
expect(commandNames).toContain("summarise");
expect(commandNames).toContain("skill");
});
it("has 7 commands total", () => {
expect(slashCommands.length).toBe(7);
});
it("each command has required properties", () => {
slashCommands.forEach((cmd) => {
expect(cmd.name).toBeDefined();
expect(typeof cmd.name).toBe("string");
expect(cmd.name.length).toBeGreaterThan(0);
expect(cmd.description).toBeDefined();
expect(typeof cmd.description).toBe("string");
expect(cmd.description.length).toBeGreaterThan(0);
expect(cmd.usage).toBeDefined();
expect(typeof cmd.usage).toBe("string");
expect(cmd.usage.startsWith("/")).toBe(true);
expect(cmd.execute).toBeDefined();
expect(typeof cmd.execute).toBe("function");
});
});
it("cd command has correct metadata", () => {
const cdCmd = slashCommands.find((cmd) => cmd.name === "cd");
expect(cdCmd).toBeDefined();
expect(cdCmd!.description).toBe("Change the working directory");
expect(cdCmd!.usage).toBe("/cd <path>");
});
it("clear command has correct metadata", () => {
const clearCmd = slashCommands.find((cmd) => cmd.name === "clear");
expect(clearCmd).toBeDefined();
expect(clearCmd!.description).toBe("Clear the terminal display (keeps conversation context)");
expect(clearCmd!.usage).toBe("/clear");
});
it("new command has correct metadata", () => {
const newCmd = slashCommands.find((cmd) => cmd.name === "new");
expect(newCmd).toBeDefined();
expect(newCmd!.description).toBe("Start a fresh conversation (resets context)");
expect(newCmd!.usage).toBe("/new");
});
it("help command has correct metadata", () => {
const helpCmd = slashCommands.find((cmd) => cmd.name === "help");
expect(helpCmd).toBeDefined();
expect(helpCmd!.description).toBe("Show available slash commands");
expect(helpCmd!.usage).toBe("/help");
});
it("search command has correct metadata", () => {
const searchCmd = slashCommands.find((cmd) => cmd.name === "search");
expect(searchCmd).toBeDefined();
expect(searchCmd!.description).toBe("Search within the conversation (use /search to clear)");
expect(searchCmd!.usage).toBe("/search [query]");
});
it("summarise command has correct metadata", () => {
const summariseCmd = slashCommands.find((cmd) => cmd.name === "summarise");
expect(summariseCmd).toBeDefined();
expect(summariseCmd!.description).toBe("Get a summary of the entire conversation");
expect(summariseCmd!.usage).toBe("/summarise");
});
it("skill command has correct metadata", () => {
const skillCmd = slashCommands.find((cmd) => cmd.name === "skill");
expect(skillCmd).toBeDefined();
expect(skillCmd!.description).toBe("Invoke a Claude Code skill from ~/.claude/skills/");
expect(skillCmd!.usage).toBe("/skill [name] [data]");
});
});
describe("parseSlashCommand", () => {
it("returns null for non-slash input", () => {
const result = parseSlashCommand("hello world");
expect(result.command).toBeNull();
expect(result.args).toBe("");
});
it("returns null for empty string", () => {
const result = parseSlashCommand("");
expect(result.command).toBeNull();
expect(result.args).toBe("");
});
it("returns null for whitespace only", () => {
const result = parseSlashCommand(" ");
expect(result.command).toBeNull();
expect(result.args).toBe("");
});
it("parses /cd command without args", () => {
const result = parseSlashCommand("/cd");
expect(result.command).not.toBeNull();
expect(result.command!.name).toBe("cd");
expect(result.args).toBe("");
});
it("parses /cd command with path argument", () => {
const result = parseSlashCommand("/cd /home/naomi/code");
expect(result.command).not.toBeNull();
expect(result.command!.name).toBe("cd");
expect(result.args).toBe("/home/naomi/code");
});
it("parses /clear command", () => {
const result = parseSlashCommand("/clear");
expect(result.command).not.toBeNull();
expect(result.command!.name).toBe("clear");
expect(result.args).toBe("");
});
it("parses /new command", () => {
const result = parseSlashCommand("/new");
expect(result.command).not.toBeNull();
expect(result.command!.name).toBe("new");
expect(result.args).toBe("");
});
it("parses /help command", () => {
const result = parseSlashCommand("/help");
expect(result.command).not.toBeNull();
expect(result.command!.name).toBe("help");
expect(result.args).toBe("");
});
it("parses /search command with query", () => {
const result = parseSlashCommand("/search hello world");
expect(result.command).not.toBeNull();
expect(result.command!.name).toBe("search");
expect(result.args).toBe("hello world");
});
it("parses /search command without query", () => {
const result = parseSlashCommand("/search");
expect(result.command).not.toBeNull();
expect(result.command!.name).toBe("search");
expect(result.args).toBe("");
});
it("parses /summarise command", () => {
const result = parseSlashCommand("/summarise");
expect(result.command).not.toBeNull();
expect(result.command!.name).toBe("summarise");
expect(result.args).toBe("");
});
it("parses /skill command with name and data", () => {
const result = parseSlashCommand("/skill onboard-mentee john@example.com");
expect(result.command).not.toBeNull();
expect(result.command!.name).toBe("skill");
expect(result.args).toBe("onboard-mentee john@example.com");
});
it("parses /skill command with name only", () => {
const result = parseSlashCommand("/skill onboard-mentee");
expect(result.command).not.toBeNull();
expect(result.command!.name).toBe("skill");
expect(result.args).toBe("onboard-mentee");
});
it("parses /skill command without arguments", () => {
const result = parseSlashCommand("/skill");
expect(result.command).not.toBeNull();
expect(result.command!.name).toBe("skill");
expect(result.args).toBe("");
});
it("returns null for unknown command", () => {
const result = parseSlashCommand("/unknown");
expect(result.command).toBeNull();
expect(result.args).toBe("");
});
it("is case insensitive for command names", () => {
const result1 = parseSlashCommand("/CD /path");
expect(result1.command).not.toBeNull();
expect(result1.command!.name).toBe("cd");
const result2 = parseSlashCommand("/CLEAR");
expect(result2.command).not.toBeNull();
expect(result2.command!.name).toBe("clear");
const result3 = parseSlashCommand("/Help");
expect(result3.command).not.toBeNull();
expect(result3.command!.name).toBe("help");
});
it("handles leading whitespace", () => {
const result = parseSlashCommand(" /cd /path");
expect(result.command).not.toBeNull();
expect(result.command!.name).toBe("cd");
expect(result.args).toBe("/path");
});
it("handles trailing whitespace", () => {
const result = parseSlashCommand("/cd /path ");
expect(result.command).not.toBeNull();
expect(result.command!.name).toBe("cd");
expect(result.args).toBe("/path");
});
it("handles multiple spaces between args", () => {
const result = parseSlashCommand("/search hello world");
expect(result.command).not.toBeNull();
expect(result.command!.name).toBe("search");
expect(result.args).toBe("hello world");
});
});
describe("getMatchingCommands", () => {
it("returns empty array for non-slash input", () => {
const result = getMatchingCommands("hello");
expect(result).toEqual([]);
});
it("returns empty array for empty string", () => {
const result = getMatchingCommands("");
expect(result).toEqual([]);
});
it("returns all commands for just slash", () => {
const result = getMatchingCommands("/");
expect(result.length).toBe(slashCommands.length);
});
it("returns matching commands for partial input", () => {
const result = getMatchingCommands("/c");
const names = result.map((cmd) => cmd.name);
expect(names).toContain("cd");
expect(names).toContain("clear");
expect(names).not.toContain("help");
});
it("returns single command for exact match", () => {
const result = getMatchingCommands("/cd");
expect(result.length).toBe(1);
expect(result[0].name).toBe("cd");
});
it("returns single command for partial unique match", () => {
const result = getMatchingCommands("/cl");
expect(result.length).toBe(1);
expect(result[0].name).toBe("clear");
});
it("returns matching commands for /s prefix", () => {
const result = getMatchingCommands("/s");
const names = result.map((cmd) => cmd.name);
expect(names).toContain("search");
expect(names).toContain("summarise");
expect(names).toContain("skill");
});
it("is case insensitive", () => {
const result1 = getMatchingCommands("/C");
const result2 = getMatchingCommands("/c");
expect(result1.length).toBe(result2.length);
});
it("returns empty array for no matches", () => {
const result = getMatchingCommands("/xyz");
expect(result).toEqual([]);
});
it("handles whitespace correctly", () => {
const result = getMatchingCommands(" /c");
const names = result.map((cmd) => cmd.name);
expect(names).toContain("cd");
expect(names).toContain("clear");
});
it("returns command for full command name", () => {
const result = getMatchingCommands("/help");
expect(result.length).toBe(1);
expect(result[0].name).toBe("help");
});
it("returns command for /new", () => {
const result = getMatchingCommands("/n");
expect(result.length).toBe(1);
expect(result[0].name).toBe("new");
});
});
describe("isSlashCommand", () => {
it("returns true for input starting with slash", () => {
expect(isSlashCommand("/cd")).toBe(true);
expect(isSlashCommand("/")).toBe(true);
expect(isSlashCommand("/help")).toBe(true);
expect(isSlashCommand("/unknown")).toBe(true);
});
it("returns false for non-slash input", () => {
expect(isSlashCommand("hello")).toBe(false);
expect(isSlashCommand("")).toBe(false);
expect(isSlashCommand("cd")).toBe(false);
});
it("handles whitespace correctly", () => {
expect(isSlashCommand(" /cd")).toBe(true);
expect(isSlashCommand(" hello")).toBe(false);
});
it("returns false for slash in middle of string", () => {
expect(isSlashCommand("hello/world")).toBe(false);
});
});
describe("SlashCommand interface", () => {
it("can create a valid slash command object", () => {
const testCommand: SlashCommand = {
name: "test",
description: "A test command",
usage: "/test [arg]",
execute: vi.fn(),
};
expect(testCommand.name).toBe("test");
expect(testCommand.description).toBe("A test command");
expect(testCommand.usage).toBe("/test [arg]");
expect(typeof testCommand.execute).toBe("function");
});
it("execute can be async function", () => {
const asyncCommand: SlashCommand = {
name: "async",
description: "An async command",
usage: "/async",
execute: async () => {
await Promise.resolve();
},
};
expect(asyncCommand.execute("")).toBeInstanceOf(Promise);
});
it("execute can be sync function", () => {
const syncCommand: SlashCommand = {
name: "sync",
description: "A sync command",
usage: "/sync",
execute: () => {
// Synchronous execution
},
};
const result = syncCommand.execute("");
// Sync function returns undefined, not a Promise
expect(result).toBeUndefined();
});
});
});
+280
View File
@@ -0,0 +1,280 @@
import { get } from "svelte/store";
import { invoke } from "@tauri-apps/api/core";
import { claudeStore } from "$lib/stores/claude";
import { characterState } from "$lib/stores/character";
import { setSkipNextGreeting } from "$lib/tauri";
import { searchState } from "$lib/stores/search";
export interface SlashCommand {
name: string;
description: string;
usage: string;
execute: (args: string) => Promise<void> | void;
}
async function changeDirectory(path: string): Promise<void> {
const conversationId = get(claudeStore.activeConversationId);
if (!conversationId) {
claudeStore.addLine("error", "No active conversation");
return;
}
if (!path.trim()) {
const currentDir = get(claudeStore.currentWorkingDirectory);
claudeStore.addLine("system", `Current directory: ${currentDir}`);
return;
}
try {
characterState.setState("thinking");
claudeStore.addLine("system", `Changing directory to: ${path}`);
const currentDir = get(claudeStore.currentWorkingDirectory);
const validatedPath = await invoke<string>("validate_directory", { path, currentDir });
// Capture conversation history before disconnecting
const conversationHistory = claudeStore.getConversationHistory();
await invoke("stop_claude", { conversationId });
// Wait for clean shutdown
await new Promise((resolve) => setTimeout(resolve, 500));
claudeStore.setWorkingDirectory(validatedPath);
setSkipNextGreeting(true);
await invoke("start_claude", {
conversationId,
options: {
working_dir: validatedPath,
},
});
// Wait for connection to establish
await new Promise((resolve) => setTimeout(resolve, 1000));
// Restore context if there was conversation history
if (conversationHistory) {
const contextMessage = `[CONTEXT RESTORATION]
I just changed the working directory from ${currentDir} to ${validatedPath}. Here's our conversation so far:
${conversationHistory}
Please continue where we left off. You are now operating in the new directory.`;
await invoke("send_prompt", {
conversationId,
message: contextMessage,
});
}
claudeStore.addLine("system", `Changed directory to: ${validatedPath}`);
characterState.setState("idle");
} catch (error) {
claudeStore.addLine("error", `Failed to change directory: ${error}`);
characterState.setTemporaryState("error", 3000);
}
}
async function startNewConversation(): Promise<void> {
const conversationId = get(claudeStore.activeConversationId);
if (!conversationId) {
claudeStore.addLine("error", "No active conversation");
return;
}
try {
const workingDir = await invoke<string>("get_working_directory", {
conversationId,
});
claudeStore.addLine("system", "Starting new conversation...");
characterState.setState("thinking");
await invoke("interrupt_claude", { conversationId });
claudeStore.clearTerminal();
setSkipNextGreeting(true);
await invoke("start_claude", {
conversationId,
options: {
working_dir: workingDir,
},
});
claudeStore.addLine("system", "New conversation started!");
characterState.setState("idle");
} catch (error) {
claudeStore.addLine("error", `Failed to start new conversation: ${error}`);
characterState.setTemporaryState("error", 3000);
}
}
export const slashCommands: SlashCommand[] = [
{
name: "cd",
description: "Change the working directory",
usage: "/cd <path>",
execute: changeDirectory,
},
{
name: "clear",
description: "Clear the terminal display (keeps conversation context)",
usage: "/clear",
execute: () => {
claudeStore.clearTerminal();
claudeStore.addLine("system", "Terminal cleared");
},
},
{
name: "new",
description: "Start a fresh conversation (resets context)",
usage: "/new",
execute: startNewConversation,
},
{
name: "help",
description: "Show available slash commands",
usage: "/help",
execute: () => {
const helpText = slashCommands
.map((cmd) => ` ${cmd.usage.padEnd(12)} - ${cmd.description}`)
.join("\n");
claudeStore.addLine("system", `Available commands:\n${helpText}`);
},
},
{
name: "search",
description: "Search within the conversation (use /search to clear)",
usage: "/search [query]",
execute: (args: string) => {
if (!args.trim()) {
searchState.clear();
claudeStore.addLine("system", "Search cleared");
return;
}
searchState.setQuery(args.trim());
claudeStore.addLine("system", `Searching for: "${args.trim()}"`);
},
},
{
name: "summarise",
description: "Get a summary of the entire conversation",
usage: "/summarise",
execute: async () => {
const conversationId = get(claudeStore.activeConversationId);
if (!conversationId) {
claudeStore.addLine("error", "No active conversation");
return;
}
try {
claudeStore.addLine("system", "Requesting conversation summary...");
await invoke("send_prompt", {
conversationId,
message:
"Please provide a comprehensive summary of our entire conversation so far, including the key topics we've discussed, decisions made, and any important context.",
});
} catch (error) {
claudeStore.addLine("error", `Failed to request summary: ${error}`);
}
},
},
{
name: "skill",
description: "Invoke a Claude Code skill from ~/.claude/skills/",
usage: "/skill [name] [data]",
execute: async (args: string) => {
const conversationId = get(claudeStore.activeConversationId);
if (!conversationId) {
claudeStore.addLine("error", "No active conversation");
return;
}
const parts = args.trim().split(/\s+/);
const skillName = parts[0];
const skillData = parts.slice(1).join(" ");
// If no skill name provided, list available skills
if (!skillName) {
try {
const skills = await invoke<string[]>("list_skills");
if (skills.length === 0) {
claudeStore.addLine(
"system",
"No skills found in ~/.claude/skills/\nCreate a skill by adding a folder with a SKILL.md file."
);
} else {
const skillList = skills.map((s) => `${s}`).join("\n");
claudeStore.addLine(
"system",
`Available skills:\n${skillList}\n\nUsage: /skill <skill-name> [data]`
);
}
} catch (error) {
claudeStore.addLine("error", `Failed to list skills: ${error}`);
}
return;
}
try {
claudeStore.addLine("system", `Invoking skill: ${skillName}`);
characterState.setState("thinking");
const message = skillData
? `Please run the /${skillName} skill with the following data:\n\n${skillData}`
: `Please run the /${skillName} skill.`;
await invoke("send_prompt", {
conversationId,
message,
});
} catch (error) {
claudeStore.addLine("error", `Failed to invoke skill: ${error}`);
characterState.setTemporaryState("error", 3000);
}
},
},
];
export function parseSlashCommand(input: string): {
command: SlashCommand | null;
args: string;
} {
const trimmed = input.trim();
if (!trimmed.startsWith("/")) {
return { command: null, args: "" };
}
const parts = trimmed.slice(1).split(/\s+/);
const commandName = parts[0]?.toLowerCase();
const args = parts.slice(1).join(" ");
const command = slashCommands.find((cmd) => cmd.name.toLowerCase() === commandName);
return { command: command || null, args };
}
export function getMatchingCommands(input: string): SlashCommand[] {
const trimmed = input.trim();
if (!trimmed.startsWith("/")) {
return [];
}
const partial = trimmed.slice(1).toLowerCase();
if (partial === "") {
return slashCommands;
}
return slashCommands.filter((cmd) => cmd.name.toLowerCase().startsWith(partial));
}
export function isSlashCommand(input: string): boolean {
return input.trim().startsWith("/");
}
+15 -13
View File
@@ -40,10 +40,12 @@
tabindex="-1" tabindex="-1"
> >
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
<h2 id="about-title" class="text-xl font-semibold text-gray-100">About Hikari Desktop</h2> <h2 id="about-title" class="text-xl font-semibold text-[var(--text-primary)]">
About Hikari Desktop
</h2>
<button <button
onclick={onClose} onclick={onClose}
class="p-1 text-gray-500 hover:text-gray-300 transition-colors" class="p-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
aria-label="Close" aria-label="Close"
> >
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -59,16 +61,16 @@
<div class="space-y-4 text-sm"> <div class="space-y-4 text-sm">
<div> <div>
<h3 class="font-medium text-gray-200 mb-2">What is Hikari Desktop?</h3> <h3 class="font-medium text-[var(--text-primary)] mb-2">What is Hikari Desktop?</h3>
<p class="text-gray-400"> <p class="text-[var(--text-secondary)]">
Hikari Desktop is an AI-powered desktop assistant that brings Claude directly to your Hikari Desktop is an AI-powered desktop assistant that brings Claude directly to your
desktop. Built with love using Tauri, Svelte, and Rust for a fast, native experience. desktop. Built with love using Tauri, Svelte, and Rust for a fast, native experience.
</p> </p>
</div> </div>
<div> <div>
<h3 class="font-medium text-gray-200 mb-2">Version</h3> <h3 class="font-medium text-[var(--text-primary)] mb-2">Version</h3>
<p class="text-gray-400 mb-1"> <p class="text-[var(--text-secondary)] mb-1">
{appVersion || "Loading..."} {appVersion || "Loading..."}
</p> </p>
<button <button
@@ -80,7 +82,7 @@
</div> </div>
<div> <div>
<h3 class="font-medium text-gray-200 mb-2">Source Code</h3> <h3 class="font-medium text-[var(--text-primary)] mb-2">Source Code</h3>
<button <button
onclick={() => openUrl(links.source)} onclick={() => openUrl(links.source)}
class="text-[var(--accent-primary)] hover:text-[var(--accent-primary-hover)] transition-colors underline" class="text-[var(--accent-primary)] hover:text-[var(--accent-primary-hover)] transition-colors underline"
@@ -90,8 +92,8 @@
</div> </div>
<div> <div>
<h3 class="font-medium text-gray-200 mb-2">Support & Community</h3> <h3 class="font-medium text-[var(--text-primary)] mb-2">Support & Community</h3>
<p class="text-gray-400 mb-1">Found a bug or have a suggestion?</p> <p class="text-[var(--text-secondary)] mb-1">Found a bug or have a suggestion?</p>
<button <button
onclick={() => openUrl(links.discord)} onclick={() => openUrl(links.discord)}
class="text-[var(--accent-primary)] hover:text-[var(--accent-primary-hover)] transition-colors underline" class="text-[var(--accent-primary)] hover:text-[var(--accent-primary-hover)] transition-colors underline"
@@ -101,7 +103,7 @@
</div> </div>
<div> <div>
<h3 class="font-medium text-gray-200 mb-2">Built with 💕 by</h3> <h3 class="font-medium text-[var(--text-primary)] mb-2">Built with 💕 by</h3>
<button <button
onclick={() => openUrl(links.website)} onclick={() => openUrl(links.website)}
class="text-[var(--accent-primary)] hover:text-[var(--accent-primary-hover)] transition-colors underline" class="text-[var(--accent-primary)] hover:text-[var(--accent-primary-hover)] transition-colors underline"
@@ -111,8 +113,8 @@
</div> </div>
<div> <div>
<h3 class="font-medium text-gray-200 mb-2">License</h3> <h3 class="font-medium text-[var(--text-primary)] mb-2">License</h3>
<p class="text-gray-400 mb-1"> <p class="text-[var(--text-secondary)] mb-1">
This project is open source and available under our license terms. This project is open source and available under our license terms.
</p> </p>
<button <button
@@ -124,7 +126,7 @@
</div> </div>
<div class="pt-4 mt-4 border-t border-[var(--border-color)]"> <div class="pt-4 mt-4 border-t border-[var(--border-color)]">
<p class="text-xs text-gray-500 text-center"> <p class="text-xs text-[var(--text-tertiary)] text-center">
Copyright © {new Date().getFullYear()} Naomi Carrigan. All rights reserved. Copyright © {new Date().getFullYear()} Naomi Carrigan. All rights reserved.
</p> </p>
</div> </div>
+18 -62
View File
@@ -34,53 +34,34 @@
return "animate-idle"; return "animate-idle";
} }
} }
function getBackgroundGlow(): string {
switch (currentState) {
case "thinking":
return "shadow-thinking";
case "typing":
return "shadow-typing";
case "searching":
return "shadow-searching";
case "coding":
return "shadow-coding";
case "mcp":
return "shadow-mcp";
case "success":
return "shadow-success";
case "error":
return "shadow-error";
default:
return "";
}
}
</script> </script>
<div class="anime-girl-container flex flex-col items-center justify-end h-full p-4"> <div
<div class="character-frame relative {getBackgroundGlow()} w-full max-w-md"> class="anime-girl-container flex flex-col items-center justify-between h-full p-4 overflow-hidden"
<div class="sprite-container {getAnimationClass()}"> >
<div class="character-frame relative flex-1 flex items-center justify-center min-h-0">
<div class="sprite-container {getAnimationClass()} h-full flex items-center justify-center">
<img <img
src="/sprites/{info.spriteFile}" src="/sprites/{info.spriteFile}"
alt="Hikari - {info.label}" alt="Hikari - {info.label}"
class="character-sprite w-full h-auto object-contain" class="character-sprite h-full w-auto max-w-full object-contain"
onerror={(e) => { onerror={(e) => {
const target = e.currentTarget as HTMLImageElement; const target = e.currentTarget as HTMLImageElement;
target.src = "/sprites/placeholder.svg"; target.src = "/sprites/placeholder.svg";
}} }}
/> />
</div> </div>
</div>
<div class="state-indicator absolute -bottom-2 left-1/2 transform -translate-x-1/2"> <div class="state-indicator mt-2">
<div <div
class="px-3 py-1 rounded-full text-xs font-medium bg-[var(--bg-secondary)] border border-[var(--border-color)] text-[var(--accent-primary)]" class="px-3 py-1 rounded-full text-xs font-medium bg-[var(--bg-secondary)] border border-[var(--border-color)] text-[var(--accent-primary)]"
> >
{info.label} {info.label}
</div>
</div> </div>
</div> </div>
<div class="speech-bubble mt-4 max-w-xs"> <div class="speech-bubble mt-2 max-w-xs flex-shrink-0">
<div <div
class="relative bg-[var(--bg-secondary)] rounded-lg px-4 py-2 border border-[var(--border-color)]" class="relative bg-[var(--bg-secondary)] rounded-lg px-4 py-2 border border-[var(--border-color)]"
> >
@@ -93,37 +74,12 @@
</div> </div>
<style> <style>
.anime-girl-container {
transition: all 0.3s ease;
}
.character-frame { .character-frame {
border-radius: 50%; transition: all 0.3s ease;
transition: box-shadow 0.3s ease;
}
.shadow-thinking {
box-shadow: 0 0 30px rgba(147, 51, 234, 0.5);
}
.shadow-typing {
box-shadow: 0 0 30px rgba(59, 130, 246, 0.5);
}
.shadow-searching {
box-shadow: 0 0 30px rgba(234, 179, 8, 0.5);
}
.shadow-coding {
box-shadow: 0 0 30px rgba(34, 197, 94, 0.5);
}
.shadow-mcp {
box-shadow: 0 0 30px rgba(236, 72, 153, 0.5);
}
.shadow-success {
box-shadow: 0 0 30px rgba(16, 185, 129, 0.5);
}
.shadow-error {
box-shadow: 0 0 30px rgba(239, 68, 68, 0.5);
} }
@keyframes idle-bob { @keyframes idle-bob {
+209
View File
@@ -0,0 +1,209 @@
<script lang="ts">
import type { Attachment } from "$lib/types/messages";
interface Props {
attachments: Attachment[];
onRemove: (id: string) => void;
}
let { attachments, onRemove }: Props = $props();
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function getFileIcon(type: Attachment["type"]): string {
switch (type) {
case "image":
return "🖼️";
case "document":
return "📄";
default:
return "📎";
}
}
</script>
{#if attachments.length > 0}
<div class="attachment-preview-container">
<div class="attachment-header">
<span class="attachment-count"
>{attachments.length} attachment{attachments.length !== 1 ? "s" : ""}</span
>
</div>
<div class="attachment-list">
{#each attachments as attachment (attachment.id)}
<div class="attachment-item" class:is-image={attachment.type === "image"}>
{#if attachment.type === "image" && attachment.previewUrl}
<div class="image-preview">
<img src={attachment.previewUrl} alt={attachment.filename} />
</div>
{:else}
<div class="file-icon">
{getFileIcon(attachment.type)}
</div>
{/if}
<div class="attachment-info">
<span class="attachment-filename" title={attachment.filename}>
{attachment.filename}
</span>
<span class="attachment-size">
{formatFileSize(attachment.size)}
</span>
</div>
<button
type="button"
class="remove-button"
onclick={() => onRemove(attachment.id)}
title="Remove attachment"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
{/each}
</div>
</div>
{/if}
<style>
.attachment-preview-container {
display: flex;
flex-direction: column;
gap: 8px;
padding: 8px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
margin-bottom: 8px;
}
.attachment-header {
display: flex;
align-items: center;
gap: 8px;
}
.attachment-count {
font-size: 12px;
color: var(--text-secondary);
}
.attachment-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.attachment-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 6px;
max-width: 200px;
position: relative;
}
.attachment-item.is-image {
flex-direction: column;
padding: 4px;
max-width: 120px;
}
.image-preview {
width: 100%;
max-width: 110px;
max-height: 80px;
border-radius: 4px;
overflow: hidden;
background: var(--bg-primary);
display: flex;
align-items: center;
justify-content: center;
}
.image-preview img {
max-width: 100%;
max-height: 80px;
object-fit: contain;
}
.file-icon {
font-size: 24px;
flex-shrink: 0;
}
.attachment-info {
display: flex;
flex-direction: column;
gap: 2px;
overflow: hidden;
flex: 1;
}
.is-image .attachment-info {
width: 100%;
padding: 0 4px;
}
.attachment-filename {
font-size: 12px;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.attachment-size {
font-size: 10px;
color: var(--text-secondary);
}
.remove-button {
position: absolute;
top: -6px;
right: -6px;
width: 20px;
height: 20px;
padding: 0;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 50%;
color: var(--text-secondary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition:
opacity 0.2s,
background 0.2s,
color 0.2s;
}
.attachment-item:hover .remove-button {
opacity: 1;
}
.remove-button:hover {
background: var(--error-color, #ef4444);
border-color: var(--error-color, #ef4444);
color: white;
}
</style>
@@ -0,0 +1,497 @@
<script lang="ts">
import { onMount } from "svelte";
import { clipboardStore, type ClipboardEntry } from "$lib/stores/clipboard";
export let isOpen = false;
export let onClose: () => void;
export let onInsert: (content: string) => void = () => {};
let searchQuery = "";
let selectedLanguage: string | null = null;
let confirmingDeleteId: string | null = null;
let copiedId: string | null = null;
// Subscribe to derived stores
const filteredEntries = clipboardStore.filteredEntries;
const languagesStore = clipboardStore.languages;
const isLoadingStore = clipboardStore.isLoading;
$: entries = $filteredEntries;
$: languages = $languagesStore;
$: isLoading = $isLoadingStore;
onMount(() => {
if (isOpen) {
clipboardStore.loadEntries();
}
});
$: if (isOpen) {
clipboardStore.loadEntries();
}
function handleSearch() {
clipboardStore.setSearchQuery(searchQuery);
}
function handleLanguageFilter(lang: string | null) {
selectedLanguage = lang;
clipboardStore.setLanguageFilter(lang);
}
async function handleCopy(entry: ClipboardEntry) {
const success = await clipboardStore.copyToClipboard(entry.content);
if (success) {
copiedId = entry.id;
setTimeout(() => {
copiedId = null;
}, 2000);
}
}
function handleInsert(entry: ClipboardEntry) {
onInsert(entry.content);
onClose();
}
async function handleDelete(id: string) {
await clipboardStore.deleteEntry(id);
confirmingDeleteId = null;
}
async function handleTogglePin(id: string) {
await clipboardStore.togglePin(id);
}
async function handleClearHistory() {
if (confirm("Clear all non-pinned clipboard entries?")) {
await clipboardStore.clearHistory();
}
}
function truncateContent(content: string, maxLength: number = 200): string {
if (content.length <= maxLength) return content;
return content.substring(0, maxLength) + "...";
}
function getLanguageIcon(language: string | null): string {
const icons: Record<string, string> = {
typescript: "TS",
javascript: "JS",
python: "PY",
rust: "RS",
go: "GO",
java: "JV",
c: "C",
cpp: "C++",
csharp: "C#",
php: "PHP",
ruby: "RB",
swift: "SW",
kotlin: "KT",
sql: "SQL",
html: "HTML",
css: "CSS",
json: "JSON",
yaml: "YAML",
bash: "SH",
shell: "SH",
};
return language ? icons[language.toLowerCase()] || language.toUpperCase().slice(0, 3) : "TXT";
}
</script>
{#if isOpen}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="clipboard-overlay" on:click={onClose}>
<div class="clipboard-panel" on:click|stopPropagation>
<div class="clipboard-header">
<h2>📋 Clipboard History</h2>
<div class="header-actions">
{#if entries.length > 0}
<button
class="clear-btn"
on:click={handleClearHistory}
title="Clear non-pinned entries"
>
🗑️ Clear
</button>
{/if}
<button class="close-btn" on:click={onClose}>✕</button>
</div>
</div>
<div class="clipboard-controls">
<div class="search-box">
<input
type="text"
placeholder="Search clipboard..."
bind:value={searchQuery}
on:input={handleSearch}
/>
</div>
<div class="language-filter">
<button
class="filter-btn"
class:active={selectedLanguage === null}
on:click={() => handleLanguageFilter(null)}
>
All
</button>
{#each languages as lang (lang)}
<button
class="filter-btn"
class:active={selectedLanguage === lang}
on:click={() => handleLanguageFilter(lang)}
>
{getLanguageIcon(lang)}
</button>
{/each}
</div>
</div>
<div class="clipboard-content">
{#if isLoading}
<div class="loading">Loading...</div>
{:else if entries.length === 0}
<div class="empty-state">
<p>📭 No clipboard entries yet</p>
<p class="hint">
Copy code from Claude's responses or use the copy button on code blocks to save them
here.
</p>
</div>
{:else}
<div class="entries-list">
{#each entries as entry (entry.id)}
<div class="entry" class:pinned={entry.is_pinned}>
<div class="entry-header">
<div class="entry-meta">
<span class="language-badge">{getLanguageIcon(entry.language)}</span>
<span class="timestamp">{clipboardStore.formatTimestamp(entry.timestamp)}</span>
{#if entry.is_pinned}
<span class="pin-badge">📌</span>
{/if}
</div>
<div class="entry-actions">
<button
class="action-btn"
title={entry.is_pinned ? "Unpin" : "Pin"}
on:click={() => handleTogglePin(entry.id)}
>
{entry.is_pinned ? "📌" : "📍"}
</button>
<button
class="action-btn"
title="Copy to clipboard"
on:click={() => handleCopy(entry)}
>
{copiedId === entry.id ? "✓" : "📋"}
</button>
<button
class="action-btn insert-btn"
title="Insert"
on:click={() => handleInsert(entry)}
>
➡️
</button>
{#if confirmingDeleteId === entry.id}
<button
class="action-btn confirm-delete"
on:click={() => handleDelete(entry.id)}
>
</button>
<button class="action-btn" on:click={() => (confirmingDeleteId = null)}>
</button>
{:else}
<button
class="action-btn delete-btn"
title="Delete"
on:click={() => (confirmingDeleteId = entry.id)}
>
🗑️
</button>
{/if}
</div>
</div>
<pre class="entry-content"><code>{truncateContent(entry.content)}</code></pre>
{#if entry.source}
<div class="entry-source">From: {entry.source}</div>
{/if}
</div>
{/each}
</div>
{/if}
</div>
</div>
</div>
{/if}
<style>
.clipboard-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.clipboard-panel {
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 12px;
width: 90%;
max-width: 700px;
max-height: 80vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.clipboard-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid var(--border-color);
}
.clipboard-header h2 {
margin: 0;
font-size: 18px;
color: var(--text-primary);
}
.header-actions {
display: flex;
gap: 8px;
align-items: center;
}
.clear-btn {
background: transparent;
border: 1px solid var(--border-color);
color: var(--text-secondary);
padding: 6px 12px;
border-radius: 6px;
cursor: pointer;
font-size: 12px;
}
.clear-btn:hover {
background: var(--bg-secondary);
color: var(--text-primary);
}
.close-btn {
background: transparent;
border: none;
color: var(--text-secondary);
font-size: 20px;
cursor: pointer;
padding: 4px 8px;
}
.close-btn:hover {
color: var(--text-primary);
}
.clipboard-controls {
padding: 12px 20px;
border-bottom: 1px solid var(--border-color);
display: flex;
flex-direction: column;
gap: 12px;
}
.search-box input {
width: 100%;
padding: 10px 14px;
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--bg-secondary);
color: var(--text-primary);
font-size: 14px;
}
.search-box input:focus {
outline: none;
border-color: var(--trans-pink);
box-shadow: 0 0 0 2px rgba(245, 169, 184, 0.2);
}
.language-filter {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.filter-btn {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
color: var(--text-secondary);
padding: 4px 10px;
border-radius: 4px;
cursor: pointer;
font-size: 11px;
font-weight: 500;
}
.filter-btn:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
}
.filter-btn.active {
background: var(--trans-gradient-vibrant);
color: #1a1a2e;
border-color: transparent;
}
.clipboard-content {
flex: 1;
overflow-y: auto;
padding: 16px 20px;
}
.loading,
.empty-state {
text-align: center;
padding: 40px 20px;
color: var(--text-secondary);
}
.empty-state p {
margin: 8px 0;
}
.empty-state .hint {
font-size: 13px;
opacity: 0.7;
}
.entries-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.entry {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 12px;
transition: border-color 0.2s;
}
.entry:hover {
border-color: var(--trans-pink);
}
.entry.pinned {
border-color: var(--trans-blue);
background: linear-gradient(
135deg,
rgba(91, 206, 250, 0.05) 0%,
rgba(245, 169, 184, 0.05) 100%
);
}
.entry-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.entry-meta {
display: flex;
align-items: center;
gap: 8px;
}
.language-badge {
background: var(--bg-tertiary);
color: var(--text-secondary);
padding: 2px 6px;
border-radius: 4px;
font-size: 10px;
font-weight: 600;
font-family: monospace;
}
.timestamp {
color: var(--text-tertiary);
font-size: 11px;
}
.pin-badge {
font-size: 12px;
}
.entry-actions {
display: flex;
gap: 4px;
}
.action-btn {
background: transparent;
border: none;
padding: 4px 6px;
cursor: pointer;
font-size: 14px;
opacity: 0.6;
transition: opacity 0.2s;
}
.action-btn:hover {
opacity: 1;
}
.insert-btn {
background: var(--trans-gradient-vibrant);
border-radius: 4px;
opacity: 1;
}
.delete-btn:hover {
color: #ff6b6b;
}
.confirm-delete {
color: #ff6b6b;
opacity: 1;
}
.entry-content {
margin: 0;
padding: 10px;
background: var(--bg-primary);
border-radius: 6px;
font-size: 12px;
line-height: 1.5;
overflow-x: auto;
white-space: pre-wrap;
word-break: break-word;
}
.entry-content code {
font-family: "JetBrains Mono", "Fira Code", monospace;
color: var(--text-primary);
}
.entry-source {
margin-top: 8px;
font-size: 11px;
color: var(--text-tertiary);
font-style: italic;
}
</style>
@@ -0,0 +1,107 @@
<script lang="ts">
interface Props {
isOpen: boolean;
tabName: string;
onConfirm: () => void;
onCancel: () => void;
}
const { isOpen, tabName, onConfirm, onCancel }: Props = $props();
function handleKeydown(event: KeyboardEvent) {
if (!isOpen) return;
if (event.key === "Enter") {
event.preventDefault();
onConfirm();
} else if (event.key === "Escape") {
event.preventDefault();
onCancel();
}
}
</script>
<svelte:window onkeydown={handleKeydown} />
{#if isOpen}
<div
class="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4"
onclick={onCancel}
role="button"
tabindex="0"
onkeydown={(e) => e.key === " " && onCancel()}
>
<div
class="bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg shadow-xl max-w-md w-full"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
role="dialog"
aria-labelledby="confirm-title"
aria-describedby="confirm-message"
tabindex="-1"
>
<div class="p-6">
<div class="flex items-start gap-4">
<div
class="w-10 h-10 rounded-lg bg-yellow-500/20 flex items-center justify-center flex-shrink-0"
>
<svg
class="w-6 h-6 text-yellow-500"
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>
</div>
<div class="flex-1">
<h3 id="confirm-title" class="text-lg font-semibold text-[var(--text-primary)] mb-1">
Close Connected Tab?
</h3>
<p id="confirm-message" class="text-sm text-[var(--text-secondary)]">
The tab "{tabName}" is currently connected to Claude. Are you sure you want to close
it? This will disconnect the session.
</p>
</div>
</div>
<div class="flex gap-3 mt-6 justify-end">
<button
onclick={onCancel}
class="px-4 py-2 text-sm font-medium text-gray-300 bg-[var(--bg-secondary)] hover:bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded-lg transition-colors"
>
Cancel
</button>
<button
onclick={onConfirm}
class="px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 rounded-lg transition-colors"
>
Close Tab
</button>
</div>
</div>
</div>
</div>
{/if}
<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);
}
}
</style>
+563
View File
@@ -0,0 +1,563 @@
<script lang="ts">
import { invoke } from "@tauri-apps/api/core";
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 { handleNewUserMessage } from "$lib/notifications/rules";
import type { CharacterState, CharacterStateInfo } from "$lib/types/states";
interface Props {
onExpand: () => void;
}
let { onExpand }: Props = $props();
let inputValue = $state("");
let isSubmitting = $state(false);
let isConnected = $state(false);
let isProcessing = $state(false);
let streamerModeActive = $state(false);
let currentState: CharacterState = $state("idle");
let info: CharacterStateInfo = $state({
state: "idle",
label: "Ready",
description: "Waiting for your command~",
spriteFile: "idle.png",
});
// Recent messages for compact display
let recentMessages = $state<Array<{ type: string; content: string }>>([]);
const MAX_RECENT_MESSAGES = 3;
claudeStore.connectionStatus.subscribe((status) => {
isConnected = status === "connected";
});
isClaudeProcessing.subscribe((processing) => {
isProcessing = processing;
});
isStreamerMode.subscribe((value) => {
streamerModeActive = value;
});
characterState.subscribe((state) => {
currentState = state;
});
characterInfo.subscribe((i) => {
info = i;
});
// Track recent terminal output
claudeStore.terminalLines.subscribe((lines) => {
const recent = lines.slice(-MAX_RECENT_MESSAGES).map((line) => ({
type: line.type,
content: line.content.substring(0, 100) + (line.content.length > 100 ? "..." : ""),
}));
recentMessages = recent;
});
function getAnimationClass(): string {
switch (currentState) {
case "thinking":
return "animate-thinking";
case "typing":
return "animate-typing";
case "searching":
return "animate-searching";
case "success":
return "animate-celebrate";
case "error":
return "animate-shake";
default:
return "animate-idle";
}
}
function getStateGlow(): string {
switch (currentState) {
case "thinking":
return "glow-thinking";
case "typing":
return "glow-typing";
case "success":
return "glow-success";
case "error":
return "glow-error";
default:
return "";
}
}
async function handleSubmit(event: Event) {
event.preventDefault();
const message = inputValue.trim();
if (!message || isSubmitting || !isConnected) return;
isSubmitting = true;
inputValue = "";
handleNewUserMessage();
claudeStore.addLine("user", message);
characterState.setState("thinking");
try {
const conversationId = get(claudeStore.activeConversationId);
if (!conversationId) {
throw new Error("No active conversation");
}
await invoke("send_prompt", {
conversationId,
message,
});
} catch (error) {
console.error("Failed to send prompt:", error);
claudeStore.addLine("error", `Failed to send: ${error}`);
characterState.setTemporaryState("error", 3000);
} finally {
isSubmitting = false;
}
}
async function handleInterrupt() {
try {
const conversationId = get(claudeStore.activeConversationId);
if (!conversationId) return;
await invoke("interrupt_claude", { conversationId });
claudeStore.addLine("system", "Interrupted");
characterState.setState("idle");
} catch (error) {
console.error("Failed to interrupt:", error);
}
}
function handleKeyDown(event: KeyboardEvent) {
if (event.key === "Enter" && !event.shiftKey) {
handleSubmit(event);
}
// Escape expands to full mode
if (event.key === "Escape") {
onExpand();
}
}
</script>
<div class="compact-container {getStateGlow()}">
<!-- Character sprite (smaller) -->
<div class="compact-character">
<div class="sprite-wrapper {getAnimationClass()}">
<img
src="/sprites/{info.spriteFile}"
alt="Hikari - {info.label}"
class="compact-sprite"
onerror={(e) => {
const target = e.currentTarget as HTMLImageElement;
target.src = "/sprites/placeholder.svg";
}}
/>
</div>
<div class="state-badge">{info.label}</div>
</div>
<!-- Recent message preview -->
<div class="message-preview">
{#if recentMessages.length > 0}
{#each recentMessages.slice(-1) as msg (msg.content)}
<div class="preview-message {msg.type}">
{msg.content}
</div>
{/each}
{:else}
<div class="preview-message system">Ask me anything~</div>
{/if}
</div>
<!-- Compact input -->
<form onsubmit={handleSubmit} class="compact-input-form">
<input
type="text"
bind:value={inputValue}
onkeydown={handleKeyDown}
placeholder={isConnected ? "Quick message..." : "Not connected"}
disabled={isSubmitting || !isConnected}
class="compact-input"
/>
<div class="compact-buttons">
{#if isProcessing}
<button type="button" onclick={handleInterrupt} class="compact-btn stop-btn" title="Stop">
</button>
{:else}
<button
type="submit"
disabled={!isConnected || isSubmitting || !inputValue.trim()}
class="compact-btn send-btn"
title="Send"
>
</button>
{/if}
<button type="button" onclick={onExpand} class="compact-btn expand-btn" title="Expand (Esc)">
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<polyline points="15 3 21 3 21 9"></polyline>
<polyline points="9 21 3 21 3 15"></polyline>
<line x1="21" y1="3" x2="14" y2="10"></line>
<line x1="3" y1="21" x2="10" y2="14"></line>
</svg>
</button>
</div>
</form>
<!-- Streamer mode indicator -->
{#if streamerModeActive}
<div class="compact-live-indicator" title="Streamer mode active"></div>
{/if}
</div>
<style>
.compact-container {
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
background: linear-gradient(180deg, var(--bg-secondary) 0%, var(--bg-primary) 100%);
padding: 12px;
gap: 8px;
position: relative;
transition: all 0.3s ease;
}
.compact-container::before {
content: "";
position: absolute;
inset: 0;
padding: 2px;
background: transparent;
-webkit-mask:
linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0);
mask:
linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
opacity: 0;
transition: opacity 0.5s ease;
pointer-events: none;
border-radius: 8px;
}
.glow-thinking {
box-shadow: inset 0 0 30px rgba(147, 51, 234, 0.15);
}
.glow-thinking::before {
background: linear-gradient(180deg, #9333ea, var(--trans-blue));
opacity: 0.6;
}
.glow-typing {
box-shadow: inset 0 0 30px rgba(59, 130, 246, 0.15);
}
.glow-typing::before {
background: linear-gradient(180deg, #3b82f6, var(--trans-pink));
opacity: 0.6;
}
.glow-success {
box-shadow: inset 0 0 30px rgba(16, 185, 129, 0.15);
}
.glow-success::before {
background: linear-gradient(180deg, #10b981, var(--trans-blue));
opacity: 0.6;
}
.glow-error {
box-shadow: inset 0 0 30px rgba(239, 68, 68, 0.15);
}
.glow-error::before {
background: linear-gradient(180deg, #ef4444, var(--trans-pink));
opacity: 0.6;
}
.compact-character {
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
min-height: 0;
gap: 4px;
}
.sprite-wrapper {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
min-height: 0;
}
.compact-sprite {
max-height: 100%;
max-width: 100%;
object-fit: contain;
}
.state-badge {
padding: 2px 8px;
border-radius: 999px;
font-size: 10px;
font-weight: 600;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
color: var(--accent-primary);
}
.message-preview {
min-height: 24px;
max-height: 48px;
overflow: hidden;
}
.preview-message {
font-size: 11px;
line-height: 1.3;
color: var(--text-secondary);
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
padding: 4px 8px;
background: var(--bg-tertiary);
border-radius: 6px;
}
.preview-message.user {
color: var(--trans-pink);
}
.preview-message.assistant {
color: var(--trans-blue);
}
.preview-message.error {
color: #ef4444;
}
.compact-input-form {
display: flex;
gap: 6px;
align-items: center;
}
.compact-input {
flex: 1;
padding: 8px 12px;
font-size: 12px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 6px;
color: var(--text-primary);
outline: none;
transition: all 0.2s;
}
.compact-input:focus {
border-color: var(--trans-blue);
box-shadow: 0 0 0 2px rgba(91, 206, 250, 0.2);
}
.compact-input:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.compact-input::placeholder {
color: var(--text-tertiary);
}
.compact-buttons {
display: flex;
gap: 4px;
}
.compact-btn {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
border: 1px solid var(--border-color);
background: var(--bg-secondary);
color: var(--text-secondary);
cursor: pointer;
transition: all 0.2s;
font-size: 14px;
font-weight: bold;
}
.compact-btn:hover:not(:disabled) {
background: var(--bg-tertiary);
border-color: var(--accent-primary);
color: var(--accent-primary);
}
.compact-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.send-btn:not(:disabled) {
background: var(--trans-gradient-vibrant);
border-color: transparent;
color: #1a1a2e;
}
.stop-btn {
background: rgba(239, 68, 68, 0.2);
border-color: rgb(239, 68, 68);
color: rgb(248, 113, 113);
}
.stop-btn:hover {
background: rgba(239, 68, 68, 0.3);
}
.expand-btn {
background: transparent;
}
.compact-live-indicator {
position: absolute;
top: 8px;
right: 8px;
width: 8px;
height: 8px;
border-radius: 50%;
background: rgb(239, 68, 68);
animation: pulse-live 1.5s ease-in-out infinite;
}
@keyframes pulse-live {
0%,
100% {
opacity: 1;
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.4);
}
50% {
opacity: 0.7;
box-shadow: 0 0 0 4px rgba(239, 68, 68, 0);
}
}
/* Character animations (smaller scale for compact) */
@keyframes idle-bob {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-3px);
}
}
@keyframes thinking-sway {
0%,
100% {
transform: rotate(-1deg);
}
50% {
transform: rotate(1deg);
}
}
@keyframes typing-bounce {
0%,
100% {
transform: translateY(0) scale(1);
}
50% {
transform: translateY(-2px) scale(1.01);
}
}
@keyframes searching-look {
0%,
100% {
transform: translateX(0);
}
25% {
transform: translateX(-3px);
}
75% {
transform: translateX(3px);
}
}
@keyframes celebrate {
0%,
100% {
transform: scale(1) rotate(0deg);
}
50% {
transform: scale(1.05) rotate(3deg);
}
}
@keyframes shake {
0%,
100% {
transform: translateX(0);
}
25%,
75% {
transform: translateX(-3px);
}
50% {
transform: translateX(3px);
}
}
.animate-idle {
animation: idle-bob 3s ease-in-out infinite;
}
.animate-thinking {
animation: thinking-sway 2s ease-in-out infinite;
}
.animate-typing {
animation: typing-bounce 0.5s ease-in-out infinite;
}
.animate-searching {
animation: searching-look 1.5s ease-in-out infinite;
}
.animate-celebrate {
animation: celebrate 0.8s ease-in-out;
}
.animate-shake {
animation: shake 0.5s ease-in-out;
}
</style>
+496 -79
View File
@@ -1,6 +1,17 @@
<script lang="ts"> <script lang="ts">
import { configStore, type HikariConfig, type Theme } from "$lib/stores/config"; import {
configStore,
type HikariConfig,
type Theme,
type CustomThemeColors,
applyFontSize,
applyCustomThemeColors,
MIN_FONT_SIZE,
MAX_FONT_SIZE,
DEFAULT_FONT_SIZE,
} from "$lib/stores/config";
import { claudeStore } from "$lib/stores/claude"; import { claudeStore } from "$lib/stores/claude";
import { getCurrentWindow } from "@tauri-apps/api/window";
let config: HikariConfig = $state({ let config: HikariConfig = $state({
model: null, model: null,
@@ -13,8 +24,31 @@
greeting_custom_prompt: null, greeting_custom_prompt: null,
notifications_enabled: true, notifications_enabled: true,
notification_volume: 0.7, notification_volume: 0.7,
always_on_top: false,
minimize_to_tray: false,
update_checks_enabled: true,
character_panel_width: null,
font_size: 14,
streamer_mode: false,
streamer_hide_paths: false,
compact_mode: false,
profile_name: null,
profile_avatar_path: null,
profile_bio: null,
custom_theme_colors: {
bg_primary: null,
bg_secondary: null,
bg_terminal: null,
accent_primary: null,
accent_secondary: null,
text_primary: null,
text_secondary: null,
border_color: null,
},
}); });
let showCustomThemeEditor = $state(false);
let isOpen = $state(false); let isOpen = $state(false);
let isSaving = $state(false); let isSaving = $state(false);
let saveError: string | null = $state(null); let saveError: string | null = $state(null);
@@ -61,6 +95,7 @@
saveError = null; saveError = null;
try { try {
await configStore.saveConfig(config); await configStore.saveConfig(config);
configStore.closeSidebar();
} catch { } catch {
// Error is handled by the store // Error is handled by the store
} finally { } finally {
@@ -70,9 +105,33 @@
async function handleThemeChange(theme: Theme) { async function handleThemeChange(theme: Theme) {
config.theme = theme; config.theme = theme;
await configStore.setTheme(theme); showCustomThemeEditor = theme === "custom";
await configStore.setTheme(theme, config.custom_theme_colors);
} }
function handleCustomColorChange(key: keyof CustomThemeColors, value: string) {
config.custom_theme_colors = {
...config.custom_theme_colors,
[key]: value || null,
};
// Live preview
if (config.theme === "custom") {
applyCustomThemeColors(config.custom_theme_colors);
}
}
// Default dark theme colors for color picker defaults
const defaultDarkColors: Required<Record<keyof CustomThemeColors, string>> = {
bg_primary: "#1a1a2e",
bg_secondary: "#16213e",
bg_terminal: "#0f0f1a",
accent_primary: "#e94560",
accent_secondary: "#ff6b9d",
text_primary: "#ffffff",
text_secondary: "#a0a0a0",
border_color: "#2a2a4a",
};
function toggleTool(tool: string) { function toggleTool(tool: string) {
if (config.auto_granted_tools.includes(tool)) { if (config.auto_granted_tools.includes(tool)) {
config.auto_granted_tools = config.auto_granted_tools.filter((t) => t !== tool); config.auto_granted_tools = config.auto_granted_tools.filter((t) => t !== tool);
@@ -95,6 +154,13 @@
function importFromSession() { function importFromSession() {
config.auto_granted_tools = [...new Set([...config.auto_granted_tools, ...grantedTools])]; config.auto_granted_tools = [...new Set([...config.auto_granted_tools, ...grantedTools])];
} }
async function handleAlwaysOnTopChange(enabled: boolean) {
config.always_on_top = enabled;
const window = getCurrentWindow();
await window.setAlwaysOnTop(enabled);
await configStore.updateConfig({ always_on_top: enabled });
}
</script> </script>
<!-- Backdrop --> <!-- Backdrop -->
@@ -121,7 +187,7 @@
<h2 class="text-lg font-semibold text-[var(--text-primary)]">Settings</h2> <h2 class="text-lg font-semibold text-[var(--text-primary)]">Settings</h2>
<button <button
onclick={configStore.closeSidebar} onclick={configStore.closeSidebar}
class="p-1 text-gray-400 hover:text-white transition-colors" class="p-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
aria-label="Close settings" aria-label="Close settings"
> >
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -149,7 +215,7 @@
<!-- Model Selection --> <!-- Model Selection -->
<div class="mb-4"> <div class="mb-4">
<label for="model" class="block text-sm text-gray-400 mb-1">Model</label> <label for="model" class="block text-sm text-[var(--text-secondary)] mb-1">Model</label>
<select <select
id="model" id="model"
bind:value={config.model} bind:value={config.model}
@@ -163,55 +229,68 @@
<!-- API Key --> <!-- API Key -->
<div class="mb-4"> <div class="mb-4">
<label for="api-key" class="block text-sm text-gray-400 mb-1"> <label for="api-key" class="block text-sm text-[var(--text-secondary)] mb-1">
API Key <span class="text-gray-600">(optional override)</span> API Key <span class="text-[var(--text-tertiary)]">(optional override)</span>
{#if config.streamer_mode}
<span class="text-yellow-500 text-xs ml-2">🔒 Hidden (Streamer Mode)</span>
{/if}
</label> </label>
<div class="relative"> <div class="relative">
<input {#if config.streamer_mode}
id="api-key" <input
type={showApiKey ? "text" : "password"} id="api-key"
bind:value={config.api_key} type="password"
placeholder="Falls back to ~/.claude settings" value="••••••••••••••••••••••••"
class="w-full px-3 py-2 pr-10 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent-primary)]" disabled
/> class="w-full px-3 py-2 pr-10 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg text-[var(--text-tertiary)] focus:outline-none cursor-not-allowed"
<button />
type="button" {:else}
onclick={() => (showApiKey = !showApiKey)} <input
class="absolute right-2 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-300" id="api-key"
aria-label={showApiKey ? "Hide API key" : "Show API key"} type={showApiKey ? "text" : "password"}
> bind:value={config.api_key}
{#if showApiKey} placeholder="Falls back to ~/.claude settings"
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> class="w-full px-3 py-2 pr-10 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent-primary)]"
<path />
stroke-linecap="round" <button
stroke-linejoin="round" type="button"
stroke-width="2" onclick={() => (showApiKey = !showApiKey)}
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" class="absolute right-2 top-1/2 -translate-y-1/2 text-[var(--text-tertiary)] hover:text-[var(--text-primary)]"
/> aria-label={showApiKey ? "Hide API key" : "Show API key"}
</svg> >
{:else} {#if showApiKey}
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path <path
stroke-linecap="round" stroke-linecap="round"
stroke-linejoin="round" stroke-linejoin="round"
stroke-width="2" stroke-width="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"
/> />
<path </svg>
stroke-linecap="round" {:else}
stroke-linejoin="round" <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
stroke-width="2" <path
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" stroke-linecap="round"
/> stroke-linejoin="round"
</svg> stroke-width="2"
{/if} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
</button> />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
/>
</svg>
{/if}
</button>
{/if}
</div> </div>
</div> </div>
<!-- Custom Instructions --> <!-- Custom Instructions -->
<div class="mb-4"> <div class="mb-4">
<label for="instructions" class="block text-sm text-gray-400 mb-1" <label for="instructions" class="block text-sm text-[var(--text-secondary)] mb-1"
>Custom Instructions</label >Custom Instructions</label
> >
<textarea <textarea
@@ -238,9 +317,9 @@
bind:checked={config.greeting_enabled} bind:checked={config.greeting_enabled}
class="w-4 h-4 rounded border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--accent-primary)] focus:ring-[var(--accent-primary)]" 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-gray-300">Send greeting on connect</span> <span class="text-sm text-[var(--text-primary)]">Send greeting on connect</span>
</label> </label>
<p class="text-xs text-gray-500 mt-1 ml-7"> <p class="text-xs text-[var(--text-tertiary)] mt-1 ml-7">
Automatically greet you when a session starts with time-based messages Automatically greet you when a session starts with time-based messages
</p> </p>
</div> </div>
@@ -248,8 +327,8 @@
<!-- Custom Greeting Prompt --> <!-- Custom Greeting Prompt -->
{#if config.greeting_enabled} {#if config.greeting_enabled}
<div class="mb-4"> <div class="mb-4">
<label for="greeting-prompt" class="block text-sm text-gray-400 mb-1"> <label for="greeting-prompt" class="block text-sm text-[var(--text-secondary)] mb-1">
Custom Greeting Prompt <span class="text-gray-600">(optional)</span> Custom Greeting Prompt <span class="text-[var(--text-tertiary)]">(optional)</span>
</label> </label>
<textarea <textarea
id="greeting-prompt" id="greeting-prompt"
@@ -268,8 +347,8 @@
MCP Servers MCP Servers
</h3> </h3>
<div class="mb-2"> <div class="mb-2">
<label for="mcp-config" class="block text-sm text-gray-400 mb-1"> <label for="mcp-config" class="block text-sm text-[var(--text-secondary)] mb-1">
Server Configuration <span class="text-gray-600">(JSON)</span> Server Configuration <span class="text-[var(--text-tertiary)]">(JSON)</span>
</label> </label>
<textarea <textarea
id="mcp-config" id="mcp-config"
@@ -286,14 +365,14 @@
<h3 class="text-sm font-medium text-[var(--accent-primary)] uppercase tracking-wider mb-3"> <h3 class="text-sm font-medium text-[var(--accent-primary)] uppercase tracking-wider mb-3">
Auto-Granted Tools Auto-Granted Tools
</h3> </h3>
<p class="text-xs text-gray-500 mb-3"> <p class="text-xs text-[var(--text-tertiary)] mb-3">
These tools will be pre-approved for every session (no permission prompts). These tools will be pre-approved for every session (no permission prompts).
</p> </p>
<!-- Common tools checkboxes --> <!-- Common tools checkboxes -->
<div class="grid grid-cols-2 gap-2 mb-3"> <div class="grid grid-cols-2 gap-2 mb-3">
{#each commonTools as tool (tool)} {#each commonTools as tool (tool)}
<label class="flex items-center gap-2 text-sm text-gray-300 cursor-pointer"> <label class="flex items-center gap-2 text-sm text-[var(--text-primary)] cursor-pointer">
<input <input
type="checkbox" type="checkbox"
checked={config.auto_granted_tools.includes(tool)} checked={config.auto_granted_tools.includes(tool)}
@@ -309,7 +388,7 @@
{#if grantedTools.length > 0} {#if grantedTools.length > 0}
<div class="mb-3"> <div class="mb-3">
<div class="flex items-center justify-between mb-2"> <div class="flex items-center justify-between mb-2">
<span class="text-xs text-gray-500">Session-granted tools:</span> <span class="text-xs text-[var(--text-tertiary)]">Session-granted tools:</span>
<button <button
onclick={importFromSession} onclick={importFromSession}
class="text-xs text-[var(--accent-primary)] hover:text-[var(--accent-secondary)] transition-colors" class="text-xs text-[var(--accent-primary)] hover:text-[var(--accent-secondary)] transition-colors"
@@ -332,7 +411,7 @@
<!-- Custom tools list --> <!-- Custom tools list -->
{#if config.auto_granted_tools.filter((t) => !commonTools.includes(t)).length > 0} {#if config.auto_granted_tools.filter((t) => !commonTools.includes(t)).length > 0}
<div class="mb-3"> <div class="mb-3">
<span class="text-xs text-gray-500 block mb-2">Custom tools:</span> <span class="text-xs text-[var(--text-tertiary)] block mb-2">Custom tools:</span>
<div class="flex flex-wrap gap-1"> <div class="flex flex-wrap gap-1">
{#each config.auto_granted_tools.filter((t) => !commonTools.includes(t)) as tool (tool)} {#each config.auto_granted_tools.filter((t) => !commonTools.includes(t)) as tool (tool)}
<span <span
@@ -341,7 +420,7 @@
{tool} {tool}
<button <button
onclick={() => removeTool(tool)} onclick={() => removeTool(tool)}
class="text-gray-500 hover:text-red-400" class="text-[var(--text-tertiary)] hover:text-red-400"
aria-label="Remove {tool}" aria-label="Remove {tool}"
> >
× ×
@@ -364,7 +443,7 @@
<button <button
onclick={addCustomTool} onclick={addCustomTool}
disabled={!newToolName.trim()} disabled={!newToolName.trim()}
class="px-3 py-1.5 text-sm bg-[var(--accent-primary)] hover:bg-[var(--accent-secondary)] text-white rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed" class="btn-trans-gradient px-3 py-1.5 text-sm rounded-lg"
> >
Add Add
</button> </button>
@@ -376,24 +455,327 @@
<h3 class="text-sm font-medium text-[var(--accent-primary)] uppercase tracking-wider mb-3"> <h3 class="text-sm font-medium text-[var(--accent-primary)] uppercase tracking-wider mb-3">
Appearance Appearance
</h3> </h3>
<div class="flex gap-2">
<button <!-- Theme Selection -->
onclick={() => handleThemeChange("dark")} <div class="mb-4">
class="flex-1 px-4 py-2 rounded-lg border transition-colors {config.theme === 'dark' <span class="block text-sm text-[var(--text-secondary)] mb-2">Theme</span>
? 'bg-[var(--accent-primary)] border-[var(--accent-primary)] text-white' <div class="flex flex-wrap gap-2" role="group" aria-label="Theme selection">
: 'bg-[var(--bg-primary)] border-[var(--border-color)] text-gray-400 hover:border-[var(--accent-primary)]'}" <button
> onclick={() => handleThemeChange("dark")}
Dark class="flex-1 min-w-[70px] px-3 py-2 rounded-lg border transition-colors {config.theme ===
</button> 'dark'
<button ? 'bg-[var(--accent-primary)] border-[var(--accent-primary)] text-white'
onclick={() => handleThemeChange("light")} : 'bg-[var(--bg-primary)] border-[var(--border-color)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)]'}"
class="flex-1 px-4 py-2 rounded-lg border transition-colors {config.theme === 'light' >
? 'bg-[var(--accent-primary)] border-[var(--accent-primary)] text-white' Dark
: 'bg-[var(--bg-primary)] border-[var(--border-color)] text-gray-400 hover:border-[var(--accent-primary)]'}" </button>
> <button
Light onclick={() => handleThemeChange("light")}
</button> class="flex-1 min-w-[70px] px-3 py-2 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)]'}"
>
Light
</button>
<button
onclick={() => handleThemeChange("high-contrast")}
class="flex-1 min-w-[70px] px-3 py-2 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)]'}"
title="High contrast mode for improved accessibility"
>
Contrast
</button>
<button
onclick={() => handleThemeChange("custom")}
class="flex-1 min-w-[70px] px-3 py-2 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)]'}"
title="Create your own custom theme"
>
Custom
</button>
</div>
</div> </div>
<!-- Custom Theme Editor -->
{#if config.theme === "custom" || showCustomThemeEditor}
<div class="mb-4 p-3 bg-[var(--bg-primary)] rounded-lg border border-[var(--border-color)]">
<h4 class="text-sm font-medium text-[var(--text-primary)] mb-3">Custom Theme Colors</h4>
<div class="grid grid-cols-2 gap-3">
<div class="color-input-group">
<label class="text-xs text-[var(--text-secondary)]" for="color-bg-primary"
>Background</label
>
<div class="flex gap-2 items-center">
<input
id="color-bg-primary"
type="color"
value={config.custom_theme_colors.bg_primary || defaultDarkColors.bg_primary}
oninput={(e) => handleCustomColorChange("bg_primary", e.currentTarget.value)}
class="color-picker"
/>
<span class="text-xs text-[var(--text-tertiary)] font-mono">
{config.custom_theme_colors.bg_primary || defaultDarkColors.bg_primary}
</span>
</div>
</div>
<div class="color-input-group">
<label class="text-xs text-[var(--text-secondary)]" for="color-bg-secondary"
>Secondary BG</label
>
<div class="flex gap-2 items-center">
<input
id="color-bg-secondary"
type="color"
value={config.custom_theme_colors.bg_secondary || defaultDarkColors.bg_secondary}
oninput={(e) => handleCustomColorChange("bg_secondary", e.currentTarget.value)}
class="color-picker"
/>
<span class="text-xs text-[var(--text-tertiary)] font-mono">
{config.custom_theme_colors.bg_secondary || defaultDarkColors.bg_secondary}
</span>
</div>
</div>
<div class="color-input-group">
<label class="text-xs text-[var(--text-secondary)]" for="color-bg-terminal"
>Terminal BG</label
>
<div class="flex gap-2 items-center">
<input
id="color-bg-terminal"
type="color"
value={config.custom_theme_colors.bg_terminal || defaultDarkColors.bg_terminal}
oninput={(e) => handleCustomColorChange("bg_terminal", e.currentTarget.value)}
class="color-picker"
/>
<span class="text-xs text-[var(--text-tertiary)] font-mono">
{config.custom_theme_colors.bg_terminal || defaultDarkColors.bg_terminal}
</span>
</div>
</div>
<div class="color-input-group">
<label class="text-xs text-[var(--text-secondary)]" for="color-border">Border</label>
<div class="flex gap-2 items-center">
<input
id="color-border"
type="color"
value={config.custom_theme_colors.border_color || defaultDarkColors.border_color}
oninput={(e) => handleCustomColorChange("border_color", e.currentTarget.value)}
class="color-picker"
/>
<span class="text-xs text-[var(--text-tertiary)] font-mono">
{config.custom_theme_colors.border_color || defaultDarkColors.border_color}
</span>
</div>
</div>
<div class="color-input-group">
<label class="text-xs text-[var(--text-secondary)]" for="color-accent-primary"
>Accent Primary</label
>
<div class="flex gap-2 items-center">
<input
id="color-accent-primary"
type="color"
value={config.custom_theme_colors.accent_primary ||
defaultDarkColors.accent_primary}
oninput={(e) => handleCustomColorChange("accent_primary", e.currentTarget.value)}
class="color-picker"
/>
<span class="text-xs text-[var(--text-tertiary)] font-mono">
{config.custom_theme_colors.accent_primary || defaultDarkColors.accent_primary}
</span>
</div>
</div>
<div class="color-input-group">
<label class="text-xs text-[var(--text-secondary)]" for="color-accent-secondary"
>Accent Secondary</label
>
<div class="flex gap-2 items-center">
<input
id="color-accent-secondary"
type="color"
value={config.custom_theme_colors.accent_secondary ||
defaultDarkColors.accent_secondary}
oninput={(e) =>
handleCustomColorChange("accent_secondary", e.currentTarget.value)}
class="color-picker"
/>
<span class="text-xs text-[var(--text-tertiary)] font-mono">
{config.custom_theme_colors.accent_secondary ||
defaultDarkColors.accent_secondary}
</span>
</div>
</div>
<div class="color-input-group">
<label class="text-xs text-[var(--text-secondary)]" for="color-text-primary"
>Text Primary</label
>
<div class="flex gap-2 items-center">
<input
id="color-text-primary"
type="color"
value={config.custom_theme_colors.text_primary || defaultDarkColors.text_primary}
oninput={(e) => handleCustomColorChange("text_primary", e.currentTarget.value)}
class="color-picker"
/>
<span class="text-xs text-[var(--text-tertiary)] font-mono">
{config.custom_theme_colors.text_primary || defaultDarkColors.text_primary}
</span>
</div>
</div>
<div class="color-input-group">
<label class="text-xs text-[var(--text-secondary)]" for="color-text-secondary"
>Text Secondary</label
>
<div class="flex gap-2 items-center">
<input
id="color-text-secondary"
type="color"
value={config.custom_theme_colors.text_secondary ||
defaultDarkColors.text_secondary}
oninput={(e) => handleCustomColorChange("text_secondary", e.currentTarget.value)}
class="color-picker"
/>
<span class="text-xs text-[var(--text-tertiary)] font-mono">
{config.custom_theme_colors.text_secondary || defaultDarkColors.text_secondary}
</span>
</div>
</div>
</div>
<p class="text-xs text-[var(--text-tertiary)] mt-3">
Changes preview live. Click Save Settings to persist.
</p>
</div>
{/if}
<!-- Font Size -->
<div class="mb-4">
<label for="font-size" class="block text-sm text-[var(--text-secondary)] mb-2">
Terminal Font Size
</label>
<div class="flex items-center gap-3">
<input
id="font-size"
type="range"
bind:value={config.font_size}
oninput={() => applyFontSize(config.font_size)}
min={MIN_FONT_SIZE}
max={MAX_FONT_SIZE}
step="1"
class="flex-1 h-2 bg-[var(--bg-primary)] rounded-lg appearance-none cursor-pointer"
/>
<span class="text-sm text-gray-300 w-12 text-right">{config.font_size}px</span>
<button
onclick={() => {
config.font_size = DEFAULT_FONT_SIZE;
applyFontSize(DEFAULT_FONT_SIZE);
}}
class="px-2 py-1 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded hover:border-[var(--accent-primary)] text-[var(--text-secondary)] transition-colors"
title="Reset to default (14px)"
>
Reset
</button>
</div>
<p class="text-xs text-[var(--text-tertiary)] mt-1">
Use Ctrl++ / Ctrl+- to quickly adjust, Ctrl+0 to reset
</p>
</div>
</section>
<!-- Window Section -->
<section class="mb-6">
<h3 class="text-sm font-medium text-[var(--accent-primary)] uppercase tracking-wider mb-3">
Window
</h3>
<!-- Always on Top Toggle -->
<div class="mb-4">
<label class="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={config.always_on_top}
onchange={(e) => handleAlwaysOnTopChange(e.currentTarget.checked)}
class="w-4 h-4 text-[var(--accent-primary)] bg-[var(--bg-primary)] border-[var(--border-color)] rounded focus:ring-[var(--accent-primary)] focus:ring-2"
/>
<span class="text-sm text-[var(--text-primary)]">Always on top</span>
</label>
<p class="text-xs text-[var(--text-tertiary)] mt-1 ml-7">
Keep the window above other windows
</p>
</div>
<!-- Minimize to Tray Toggle -->
<div class="mb-4">
<label class="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
bind:checked={config.minimize_to_tray}
class="w-4 h-4 text-[var(--accent-primary)] bg-[var(--bg-primary)] border-[var(--border-color)] rounded focus:ring-[var(--accent-primary)] focus:ring-2"
/>
<span class="text-sm text-[var(--text-primary)]">Minimize to system tray</span>
</label>
<p class="text-xs text-[var(--text-tertiary)] mt-1 ml-7">
Hide to tray instead of closing when you click the X button
</p>
</div>
<!-- Update Checks Toggle -->
<div class="mb-4">
<label class="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
bind:checked={config.update_checks_enabled}
class="w-4 h-4 text-[var(--accent-primary)] bg-[var(--bg-primary)] border-[var(--border-color)] rounded focus:ring-[var(--accent-primary)] focus:ring-2"
/>
<span class="text-sm text-[var(--text-primary)]">Check for updates on startup</span>
</label>
<p class="text-xs text-[var(--text-tertiary)] mt-1 ml-7">
Notify when a new version is available
</p>
</div>
</section>
<!-- Privacy / Streamer Mode Section -->
<section class="mb-6">
<h3 class="text-sm font-medium text-[var(--accent-primary)] uppercase tracking-wider mb-3">
Privacy / Streamer Mode
</h3>
<!-- Streamer Mode Toggle -->
<div class="mb-4">
<label class="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
bind:checked={config.streamer_mode}
class="w-4 h-4 text-[var(--accent-primary)] bg-[var(--bg-primary)] border-[var(--border-color)] rounded focus:ring-[var(--accent-primary)] focus:ring-2"
/>
<span class="text-sm text-[var(--text-primary)]">Enable streamer mode</span>
</label>
<p class="text-xs text-[var(--text-tertiary)] mt-1 ml-7">
Hide sensitive information like API keys when streaming (Ctrl+Shift+S to toggle)
</p>
</div>
<!-- Hide Paths Toggle -->
{#if config.streamer_mode}
<div class="mb-4">
<label class="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
bind:checked={config.streamer_hide_paths}
class="w-4 h-4 text-[var(--accent-primary)] bg-[var(--bg-primary)] border-[var(--border-color)] rounded focus:ring-[var(--accent-primary)] focus:ring-2"
/>
<span class="text-sm text-[var(--text-primary)]">Also hide file paths</span>
</label>
<p class="text-xs text-[var(--text-tertiary)] mt-1 ml-7">
Mask directory paths (e.g., /home/user → /home/****)
</p>
</div>
{/if}
</section> </section>
<!-- Notifications Section --> <!-- Notifications Section -->
@@ -410,13 +792,13 @@
bind:checked={config.notifications_enabled} bind:checked={config.notifications_enabled}
class="w-4 h-4 text-[var(--accent-primary)] bg-[var(--bg-primary)] border-[var(--border-color)] rounded focus:ring-[var(--accent-primary)] focus:ring-2" class="w-4 h-4 text-[var(--accent-primary)] bg-[var(--bg-primary)] border-[var(--border-color)] rounded focus:ring-[var(--accent-primary)] focus:ring-2"
/> />
<span class="text-sm text-gray-300">Enable sound notifications</span> <span class="text-sm text-[var(--text-primary)]">Enable sound notifications</span>
</label> </label>
</div> </div>
<!-- Volume Control --> <!-- Volume Control -->
<div class="mb-4"> <div class="mb-4">
<label for="notification-volume" class="block text-sm text-gray-400 mb-2"> <label for="notification-volume" class="block text-sm text-[var(--text-secondary)] mb-2">
Notification Volume Notification Volume
</label> </label>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
@@ -436,7 +818,7 @@
</div> </div>
</div> </div>
<div class="text-xs text-gray-500"> <div class="text-xs text-[var(--text-tertiary)]">
Sound notifications will play when I complete tasks, encounter errors, or need permissions. Sound notifications will play when I complete tasks, encounter errors, or need permissions.
</div> </div>
</section> </section>
@@ -446,7 +828,7 @@
<button <button
onclick={handleSave} onclick={handleSave}
disabled={isSaving} disabled={isSaving}
class="w-full py-3 bg-[var(--accent-primary)] hover:bg-[var(--accent-secondary)] text-white font-medium rounded-lg transition-colors disabled:opacity-50" class="btn-trans-gradient w-full py-3 font-medium rounded-lg"
> >
{isSaving ? "Saving..." : "Save Settings"} {isSaving ? "Saving..." : "Save Settings"}
</button> </button>
@@ -483,4 +865,39 @@
background: var(--text-tertiary); background: var(--text-tertiary);
cursor: not-allowed; cursor: not-allowed;
} }
/* Color picker styling */
.color-input-group {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.color-picker {
width: 32px;
height: 32px;
padding: 0;
border: 2px solid var(--border-color);
border-radius: 6px;
cursor: pointer;
background: transparent;
}
.color-picker::-webkit-color-swatch-wrapper {
padding: 2px;
}
.color-picker::-webkit-color-swatch {
border-radius: 4px;
border: none;
}
.color-picker::-moz-color-swatch {
border-radius: 4px;
border: none;
}
.color-picker:hover {
border-color: var(--accent-primary);
}
</style> </style>
+100 -79
View File
@@ -3,49 +3,35 @@
import { onMount } from "svelte"; import { onMount } from "svelte";
import type { Conversation } from "$lib/stores/conversations"; import type { Conversation } from "$lib/stores/conversations";
import { SvelteMap } from "svelte/reactivity"; import { SvelteMap } from "svelte/reactivity";
import CloseTabConfirmModal from "./CloseTabConfirmModal.svelte";
let conversations: Map<string, Conversation> = new Map(); // Use store subscriptions with $ syntax
let activeConversationId: string | null = null; const conversations = $derived(claudeStore.conversations);
let editingTabId: string | null = null; const activeConversationId = $derived(claudeStore.activeConversationId);
let editingName = "";
// Track which conversation actually has the Claude connection let editingTabId = $state<string | null>(null);
let connectedConversationId: string | null = null; let editingName = $state("");
// Track last seen message count for each conversation // Track last seen message count for each conversation
let lastSeenMessageCount = new SvelteMap<string, number>(); let lastSeenMessageCount = new SvelteMap<string, number>();
claudeStore.conversations.subscribe((convs) => { // Confirmation modal state
conversations = convs; let showConfirmModal = $state(false);
let tabToClose = $state<string | null>(null);
let tabToCloseName = $state("");
// Update the last seen count for the active conversation // Update last seen count when active conversation changes
if (activeConversationId) { $effect(() => {
const activeConv = convs.get(activeConversationId); if ($activeConversationId) {
const activeConv = $conversations.get($activeConversationId);
if (activeConv) { if (activeConv) {
lastSeenMessageCount.set(activeConversationId, activeConv.terminalLines.length); lastSeenMessageCount.set($activeConversationId, activeConv.terminalLines.length);
// Trigger reactivity
lastSeenMessageCount = lastSeenMessageCount;
} }
} }
}); });
claudeStore.activeConversationId.subscribe((id) => {
activeConversationId = id;
});
// Find the connected conversation
$: {
let foundConnected = false;
for (const [id, conv] of conversations) {
if (conv.connectionStatus === "connected" || conv.connectionStatus === "connecting") {
connectedConversationId = id;
foundConnected = true;
break;
}
}
if (!foundConnected) {
connectedConversationId = null;
}
}
function createNewTab() { function createNewTab() {
claudeStore.createConversation(); claudeStore.createConversation();
} }
@@ -57,7 +43,7 @@
await claudeStore.switchConversation(id); await claudeStore.switchConversation(id);
// Mark messages as seen when switching to this tab // Mark messages as seen when switching to this tab
const conv = conversations.get(id); const conv = $conversations.get(id);
if (conv) { if (conv) {
lastSeenMessageCount.set(id, conv.terminalLines.length); lastSeenMessageCount.set(id, conv.terminalLines.length);
// Trigger reactivity // Trigger reactivity
@@ -67,11 +53,35 @@
function deleteTab(id: string, event: MouseEvent) { function deleteTab(id: string, event: MouseEvent) {
event.stopPropagation(); event.stopPropagation();
if (conversations.size > 1) { if ($conversations.size > 1) {
claudeStore.deleteConversation(id); const conversation = $conversations.get(id);
if (conversation && conversation.connectionStatus === "connected") {
// Show confirmation modal for connected tabs
tabToClose = id;
tabToCloseName = conversation.name;
showConfirmModal = true;
} else {
// Close disconnected tabs immediately
claudeStore.deleteConversation(id);
}
} }
} }
function confirmCloseTab() {
if (tabToClose) {
claudeStore.deleteConversation(tabToClose);
}
showConfirmModal = false;
tabToClose = null;
tabToCloseName = "";
}
function cancelCloseTab() {
showConfirmModal = false;
tabToClose = null;
tabToCloseName = "";
}
function startEditing(id: string, name: string, event: MouseEvent) { function startEditing(id: string, name: string, event: MouseEvent) {
event.stopPropagation(); event.stopPropagation();
editingTabId = id; editingTabId = id;
@@ -105,7 +115,7 @@
} }
function hasUnreadMessages(id: string, conversation: Conversation): boolean { function hasUnreadMessages(id: string, conversation: Conversation): boolean {
if (id === activeConversationId) return false; // Active tab never has unread if (id === $activeConversationId) return false; // Active tab never has unread
const lastSeen = lastSeenMessageCount.get(id) || 0; const lastSeen = lastSeenMessageCount.get(id) || 0;
return conversation.terminalLines.length > lastSeen; return conversation.terminalLines.length > lastSeen;
} }
@@ -116,6 +126,8 @@
} else if (event.key === "Escape") { } else if (event.key === "Escape") {
editingTabId = null; editingTabId = null;
editingName = ""; editingName = "";
} else if (event.key === " ") {
event.stopPropagation();
} }
} }
@@ -137,15 +149,24 @@
// Ctrl/Cmd + W: Close current tab // Ctrl/Cmd + W: Close current tab
else if ((event.ctrlKey || event.metaKey) && event.key === "w") { else if ((event.ctrlKey || event.metaKey) && event.key === "w") {
event.preventDefault(); event.preventDefault();
if (activeConversationId && conversations.size > 1) { if ($activeConversationId && $conversations.size > 1) {
claudeStore.deleteConversation(activeConversationId); const conversation = $conversations.get($activeConversationId);
if (conversation && conversation.connectionStatus === "connected") {
// Show confirmation modal for connected tabs
tabToClose = $activeConversationId;
tabToCloseName = conversation.name;
showConfirmModal = true;
} else {
// Close disconnected tabs immediately
claudeStore.deleteConversation($activeConversationId);
}
} }
} }
// Ctrl/Cmd + Tab: Next tab // Ctrl/Cmd + Tab: Next tab
else if ((event.ctrlKey || event.metaKey) && event.key === "Tab" && !event.shiftKey) { else if ((event.ctrlKey || event.metaKey) && event.key === "Tab" && !event.shiftKey) {
event.preventDefault(); event.preventDefault();
const tabs = Array.from(conversations.keys()); const tabs = Array.from($conversations.keys());
const currentIndex = tabs.findIndex((id) => id === activeConversationId); const currentIndex = tabs.findIndex((id) => id === $activeConversationId);
if (currentIndex !== -1) { if (currentIndex !== -1) {
const nextIndex = (currentIndex + 1) % tabs.length; const nextIndex = (currentIndex + 1) % tabs.length;
claudeStore.switchConversation(tabs[nextIndex]); claudeStore.switchConversation(tabs[nextIndex]);
@@ -154,8 +175,8 @@
// Ctrl/Cmd + Shift + Tab: Previous tab // Ctrl/Cmd + Shift + Tab: Previous tab
else if ((event.ctrlKey || event.metaKey) && event.key === "Tab" && event.shiftKey) { else if ((event.ctrlKey || event.metaKey) && event.key === "Tab" && event.shiftKey) {
event.preventDefault(); event.preventDefault();
const tabs = Array.from(conversations.keys()); const tabs = Array.from($conversations.keys());
const currentIndex = tabs.findIndex((id) => id === activeConversationId); const currentIndex = tabs.findIndex((id) => id === $activeConversationId);
if (currentIndex !== -1) { if (currentIndex !== -1) {
const prevIndex = (currentIndex - 1 + tabs.length) % tabs.length; const prevIndex = (currentIndex - 1 + tabs.length) % tabs.length;
claudeStore.switchConversation(tabs[prevIndex]); claudeStore.switchConversation(tabs[prevIndex]);
@@ -171,17 +192,17 @@
<div <div
class="terminal-tabs flex items-center gap-1 px-2 py-1 bg-[var(--bg-secondary)] border-b border-[var(--border-color)]" class="terminal-tabs flex items-center gap-1 px-2 py-1 bg-[var(--bg-secondary)] border-b border-[var(--border-color)]"
> >
{#each Array.from(conversations.entries()) as [id, conversation] (id)} {#each Array.from($conversations.entries()) as [id, conversation] (id)}
<div <div
class="tab-item group relative flex items-center px-3 py-1.5 rounded-t cursor-pointer transition-all class="tab-item group relative flex items-center px-3 py-1.5 rounded-t cursor-pointer transition-all
{id === activeConversationId {id === $activeConversationId
? 'bg-[var(--bg-terminal)] text-[var(--text-primary)] border-t border-l border-r border-[var(--border-color)]' ? 'bg-[var(--bg-terminal)] text-[var(--text-primary)] border-t border-l border-r border-[var(--border-color)]'
: 'bg-[var(--bg-tertiary)] text-[var(--text-secondary)] hover:bg-[var(--bg-terminal)]/50'}" : 'bg-[var(--bg-tertiary)] text-[var(--text-secondary)] hover:bg-[var(--bg-terminal)]/50'}"
onclick={() => switchTab(id)} onclick={() => switchTab(id)}
onkeydown={(e) => handleTabKeydown(id, e)} onkeydown={(e) => handleTabKeydown(id, e)}
role="tab" role="tab"
tabindex={0} tabindex={0}
aria-selected={id === activeConversationId} aria-selected={id === $activeConversationId}
> >
{#if editingTabId === id} {#if editingTabId === id}
<input <input
@@ -196,58 +217,51 @@
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<div <div
class="w-2 h-2 rounded-full {getConnectionStatusColor(conversation.connectionStatus)}" class="w-2 h-2 rounded-full {getConnectionStatusColor(conversation.connectionStatus)}"
title="Connection: {conversation.connectionStatus}{id !== connectedConversationId && title="Connection: {conversation.connectionStatus}"
connectedConversationId
? ' (Another tab is connected)'
: ''}"
></div> ></div>
<span <span
class="text-sm pr-6 max-w-[150px] truncate" class="text-sm pr-2 max-w-[150px] truncate"
ondblclick={(e) => startEditing(id, conversation.name, e)} ondblclick={(e) => startEditing(id, conversation.name, e)}
role="button" role="button"
tabindex={-1} tabindex={-1}
> >
{conversation.name} {conversation.name}
</span> </span>
{#if id !== activeConversationId && id === connectedConversationId}
<span
class="text-xs text-[var(--text-tertiary)]"
title="This tab has the Claude connection"
>
(connected)
</span>
{/if}
{#if hasUnreadMessages(id, conversation)} {#if hasUnreadMessages(id, conversation)}
<div <div
class="absolute -top-1 -right-1 w-2 h-2 rounded-full bg-blue-500 animate-pulse" class="absolute -top-1 -right-1 w-2 h-2 rounded-full bg-blue-500 animate-pulse pointer-events-none"
title="New messages" title="New messages"
></div> ></div>
{/if} {/if}
</div> </div>
{/if} {/if}
{#if conversations.size > 1} <div
<button class="absolute right-1 top-1/2 -translate-y-1/2 flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity"
onclick={(e) => deleteTab(id, e)} >
class="absolute right-1 top-1/2 -translate-y-1/2 w-4 h-4 flex items-center justify-center rounded hover:bg-[var(--bg-secondary)] opacity-0 group-hover:opacity-100 transition-opacity" {#if $conversations.size > 1}
title="Close tab" <button
> onclick={(e) => deleteTab(id, e)}
<svg class="w-4 h-4 flex items-center justify-center rounded hover:bg-[var(--bg-secondary)]"
class="w-3 h-3" title="Close tab"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
> >
<path <svg
stroke-linecap="round" class="w-3 h-3"
stroke-linejoin="round" fill="none"
stroke-width="2" stroke="currentColor"
d="M6 18L18 6M6 6l12 12" viewBox="0 0 24 24"
/> xmlns="http://www.w3.org/2000/svg"
</svg> >
</button> <path
{/if} stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
{/if}
</div>
</div> </div>
{/each} {/each}
@@ -268,6 +282,13 @@
</button> </button>
</div> </div>
<CloseTabConfirmModal
isOpen={showConfirmModal}
tabName={tabToCloseName}
onConfirm={confirmCloseTab}
onCancel={cancelCloseTab}
/>
<style> <style>
.terminal-tabs { .terminal-tabs {
min-height: 36px; min-height: 36px;
File diff suppressed because it is too large Load Diff
+8 -13
View File
@@ -43,14 +43,7 @@
"🔒 Grant tool permissions as needed for security", "🔒 Grant tool permissions as needed for security",
"📌 Pin important conversations for quick access", "📌 Pin important conversations for quick access",
"🎨 Customize your theme and preferences in Settings", "🎨 Customize your theme and preferences in Settings",
], "⌨️ Check the keyboard icon for available shortcuts",
},
{
title: "Keyboard Shortcuts",
items: [
"Ctrl/Cmd + Enter: Send message",
"Ctrl/Cmd + K: Clear chat (when supported)",
"Escape: Close modals and panels",
], ],
}, },
]; ];
@@ -72,10 +65,12 @@
tabindex="-1" tabindex="-1"
> >
<div class="flex items-center justify-between p-6 pb-4 border-b border-[var(--border-color)]"> <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-gray-100">How to Use Hikari Desktop</h2> <h2 id="help-title" class="text-xl font-semibold text-[var(--text-primary)]">
How to Use Hikari Desktop
</h2>
<button <button
onclick={onClose} onclick={onClose}
class="p-1 text-gray-500 hover:text-gray-300 transition-colors" class="p-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
aria-label="Close" aria-label="Close"
> >
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -92,8 +87,8 @@
<div class="overflow-y-auto flex-1 p-6 space-y-6"> <div class="overflow-y-auto flex-1 p-6 space-y-6">
{#each sections as section (section.title)} {#each sections as section (section.title)}
<div> <div>
<h3 class="font-medium text-gray-200 mb-3">{section.title}</h3> <h3 class="font-medium text-[var(--text-primary)] mb-3">{section.title}</h3>
<ul class="space-y-2 text-sm text-gray-400"> <ul class="space-y-2 text-sm text-[var(--text-secondary)]">
{#each section.items as item (item)} {#each section.items as item (item)}
<li class="flex items-start"> <li class="flex items-start">
<span class="text-[var(--accent-primary)] mr-2 mt-0.5"></span> <span class="text-[var(--accent-primary)] mr-2 mt-0.5"></span>
@@ -105,7 +100,7 @@
{/each} {/each}
<div class="pt-4 border-t border-[var(--border-color)]"> <div class="pt-4 border-t border-[var(--border-color)]">
<p class="text-sm text-gray-500"> <p class="text-sm text-[var(--text-tertiary)]">
<strong>Need more help?</strong> Join our Discord community for support and updates! <strong>Need more help?</strong> Join our Discord community for support and updates!
</p> </p>
</div> </div>
+61
View File
@@ -0,0 +1,61 @@
<script lang="ts">
export let content: string;
export let searchQuery: string;
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) {
// Add non-matching text before the match
if (match.index > lastIndex) {
parts.push({
text: text.slice(lastIndex, match.index),
isMatch: false,
});
}
// Add the matching text
parts.push({
text: match[1],
isMatch: true,
});
lastIndex = regex.lastIndex;
}
// Add any remaining text after the last match
if (lastIndex < text.length) {
parts.push({
text: text.slice(lastIndex),
isMatch: false,
});
}
return parts;
}
$: parts = getHighlightedParts(content, searchQuery);
</script>
<span class="whitespace-pre-wrap">
{#each parts as part, index (index)}
{#if part.isMatch}
<mark class="search-highlight">{part.text}</mark>
{:else}
{part.text}
{/if}
{/each}
</span>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,191 @@
<script lang="ts">
interface Props {
onClose: () => void;
}
const { onClose }: Props = $props();
const shortcuts = [
{
category: "General",
items: [
{ keys: ["Escape"], description: "Close modals and panels" },
{ keys: ["Ctrl", "L"], description: "Clear the terminal" },
{ keys: ["Ctrl", ","], description: "Open settings" },
{ keys: ["Ctrl", "E"], description: "Toggle file editor" },
{ keys: ["Ctrl", "Shift", "M"], description: "Toggle compact mode" },
{ keys: ["Ctrl", "Shift", "S"], description: "Toggle streamer mode" },
],
},
{
category: "Chat",
items: [
{ keys: ["Enter"], description: "Send message" },
{ keys: ["Shift", "Enter"], description: "New line in message" },
{ keys: ["Ctrl", "C"], description: "Interrupt/stop response" },
{ keys: ["↑"], description: "Previous input from history" },
{ keys: ["↓"], description: "Next input from history" },
],
},
{
category: "File Editor",
items: [
{ keys: ["Ctrl", "E"], description: "Toggle editor view" },
{ keys: ["Ctrl", "B"], description: "Toggle file browser" },
{ keys: ["Ctrl", "S"], description: "Save current file" },
{ keys: ["Ctrl", "W"], description: "Close current tab" },
{ keys: ["Ctrl", "N"], description: "New file" },
{ keys: ["Right-click"], description: "Context menu (New/Delete)" },
],
},
{
category: "Slash Commands",
items: [
{ keys: ["↑", "↓"], description: "Navigate command menu" },
{ keys: ["Tab"], description: "Complete selected command" },
{ keys: ["Escape"], description: "Close command menu" },
],
},
{
category: "Permission Prompts",
items: [
{ keys: ["Enter"], description: "Allow & reconnect" },
{ keys: ["Escape"], description: "Dismiss" },
],
},
];
</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-lg w-full max-h-[80vh] overflow-hidden flex flex-col"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
role="dialog"
aria-labelledby="shortcuts-title"
tabindex="-1"
>
<div class="flex items-center justify-between p-6 pb-4 border-b border-[var(--border-color)]">
<div class="flex items-center gap-3">
<div
class="w-8 h-8 rounded-lg bg-[var(--accent-primary)]/20 flex items-center justify-center"
>
<svg
class="w-5 h-5 text-[var(--accent-primary)]"
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>
</div>
<h2 id="shortcuts-title" class="text-xl font-semibold text-[var(--text-primary)]">
Keyboard Shortcuts
</h2>
</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>
<div class="overflow-y-auto flex-1 p-6 space-y-6">
{#each shortcuts as section (section.category)}
<div>
<h3
class="text-sm font-medium text-[var(--text-secondary)] uppercase tracking-wider mb-3"
>
{section.category}
</h3>
<div class="space-y-2">
{#each section.items as item (item.description)}
<div
class="flex items-center justify-between py-2 px-3 bg-[var(--bg-secondary)] rounded-lg"
>
<span class="text-sm text-[var(--text-primary)]">{item.description}</span>
<div class="flex items-center gap-1">
{#each item.keys as key, i (key)}
{#if i > 0}
<span class="text-[var(--text-secondary)] text-xs">+</span>
{/if}
<kbd
class="px-2 py-1 text-xs font-mono bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-[var(--text-primary)] shadow-sm min-w-[24px] text-center"
>
{key}
</kbd>
{/each}
</div>
</div>
{/each}
</div>
</div>
{/each}
</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>
+441
View File
@@ -0,0 +1,441 @@
<script lang="ts">
import { marked } from "marked";
import hljs from "highlight.js";
import { onMount } from "svelte";
import { openUrl } from "@tauri-apps/plugin-opener";
import { clipboardStore } from "$lib/stores/clipboard";
interface Props {
content: string;
searchQuery?: string;
}
let { content, searchQuery = "" }: Props = $props();
let containerElement: HTMLDivElement;
const renderer = new marked.Renderer();
renderer.code = ({ text, lang }) => {
const language = lang && hljs.getLanguage(lang) ? lang : "plaintext";
const highlighted = hljs.highlight(text, { language }).value;
const escapedText = text.replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
return `<div class="code-block-wrapper">
<div class="code-block-header">
<span class="code-block-lang">${language}</span>
<button class="copy-code-btn" data-code="${escapedText}" title="Copy code">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>
<span class="copy-text">Copy</span>
</button>
</div>
<pre class="hljs-code-block"><code class="hljs language-${language}">${highlighted}</code></pre>
</div>`;
};
renderer.codespan = ({ text }) => {
return `<code class="hljs-inline">${text}</code>`;
};
marked.setOptions({
renderer,
gfm: true,
breaks: true,
});
function processSpoilers(html: string): string {
const codeBlockPlaceholders: string[] = [];
// Temporarily replace code blocks and inline code with placeholders
let processed = html.replace(/<(pre|code)[^>]*>[\s\S]*?<\/\1>/gi, (match) => {
codeBlockPlaceholders.push(match);
return `__CODE_PLACEHOLDER_${codeBlockPlaceholders.length - 1}__`;
});
// Apply spoiler transformation only to non-code content
processed = processed.replace(
/\|\|(.+?)\|\|/g,
'<span class="spoiler" role="button" tabindex="0">$1</span>'
);
// Restore code blocks
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[] = [];
// Temporarily replace code blocks with placeholders (don't highlight in code)
let processed = html.replace(/<(pre|code)[^>]*>[\s\S]*?<\/\1>/gi, (match) => {
codeBlockPlaceholders.push(match);
return `__CODE_SEARCH_PLACEHOLDER_${codeBlockPlaceholders.length - 1}__`;
});
// Temporarily replace all HTML tags with placeholders
processed = processed.replace(/<[^>]+>/g, (match) => {
tagPlaceholders.push(match);
return `__TAG_PLACEHOLDER_${tagPlaceholders.length - 1}__`;
});
// Apply search highlighting to text content
const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const regex = new RegExp(`(${escapedQuery})`, "gi");
processed = processed.replace(regex, '<mark class="search-highlight">$1</mark>');
// Restore HTML tags
processed = processed.replace(/__TAG_PLACEHOLDER_(\d+)__/g, (_, index) => {
return tagPlaceholders[parseInt(index)];
});
// Restore code blocks
processed = processed.replace(/__CODE_SEARCH_PLACEHOLDER_(\d+)__/g, (_, index) => {
return codeBlockPlaceholders[parseInt(index)];
});
return processed;
}
function renderMarkdown(text: string): string {
try {
const html = marked.parse(text) as string;
const withSpoilers = processSpoilers(html);
return highlightSearchMatches(withSpoilers, searchQuery);
} catch {
return text;
}
}
function handleSpoilerClick(event: Event) {
const target = event.target as HTMLElement;
if (target.classList.contains("spoiler")) {
target.classList.toggle("revealed");
}
}
function handleSpoilerKeydown(event: KeyboardEvent) {
const target = event.target as HTMLElement;
if (target.classList.contains("spoiler") && (event.key === "Enter" || event.key === " ")) {
event.preventDefault();
target.classList.toggle("revealed");
}
}
function handleLinkClick(event: MouseEvent) {
const target = event.target as HTMLElement;
const anchor = target.closest("a");
if (anchor?.href) {
event.preventDefault();
openUrl(anchor.href);
}
}
async function handleCopyClick(event: MouseEvent) {
const target = event.target as HTMLElement;
const copyBtn = target.closest(".copy-code-btn") as HTMLButtonElement;
if (copyBtn) {
event.preventDefault();
const code = copyBtn.dataset.code
?.replace(/&quot;/g, '"')
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">");
if (code) {
await navigator.clipboard.writeText(code);
// Capture to clipboard history
const langElement = copyBtn.parentElement?.querySelector(".code-block-lang");
const language = langElement?.textContent || null;
await clipboardStore.captureClipboard(code, language, "Claude response");
const textSpan = copyBtn.querySelector(".copy-text");
if (textSpan) {
textSpan.textContent = "Copied!";
setTimeout(() => {
textSpan.textContent = "Copy";
}, 2000);
}
}
}
}
onMount(() => {
if (containerElement) {
containerElement.querySelectorAll("pre code:not(.hljs)").forEach((block) => {
hljs.highlightElement(block as HTMLElement);
});
}
});
</script>
<div
bind:this={containerElement}
class="markdown-content"
onclick={(e) => {
handleSpoilerClick(e);
handleLinkClick(e);
handleCopyClick(e);
}}
onkeydown={handleSpoilerKeydown}
role="presentation"
>
<!-- eslint-disable-next-line svelte/no-at-html-tags -- Markdown rendering requires @html; content is from Claude API -->
{@html renderMarkdown(content)}
</div>
<style>
.markdown-content {
line-height: 1.6;
}
.markdown-content :global(p) {
margin: 0.5em 0;
}
.markdown-content :global(p:first-child) {
margin-top: 0;
}
.markdown-content :global(p:last-child) {
margin-bottom: 0;
}
.markdown-content :global(.code-block-wrapper) {
margin: 0.75em 0;
border-radius: 6px;
border: 1px solid var(--border-color);
overflow: hidden;
}
.markdown-content :global(.code-block-header) {
display: flex;
justify-content: space-between;
align-items: center;
background: var(--bg-secondary);
padding: 0.4em 0.75em;
border-bottom: 1px solid var(--border-color);
font-size: 0.8em;
}
.markdown-content :global(.code-block-lang) {
color: var(--text-secondary);
font-family: "JetBrains Mono", "Fira Code", monospace;
text-transform: lowercase;
}
.markdown-content :global(.copy-code-btn) {
display: flex;
align-items: center;
gap: 0.4em;
background: transparent;
border: none;
color: var(--text-secondary);
cursor: pointer;
padding: 0.25em 0.5em;
border-radius: 4px;
font-size: 0.9em;
transition: all 0.15s ease;
}
.markdown-content :global(.copy-code-btn:hover) {
background: var(--bg-hover);
color: var(--text-primary);
}
.markdown-content :global(.copy-code-btn svg) {
flex-shrink: 0;
}
.markdown-content :global(.hljs-code-block) {
background: var(--bg-code, #1e1e2e);
border-radius: 0;
padding: 1em;
margin: 0;
overflow-x: auto;
border: none;
}
.markdown-content :global(.hljs-code-block code) {
background: transparent;
padding: 0;
font-size: 0.9em;
font-family: "JetBrains Mono", "Fira Code", monospace;
}
.markdown-content :global(.hljs-inline) {
background: var(--bg-code, #1e1e2e);
padding: 0.2em 0.4em;
border-radius: 4px;
font-size: 0.9em;
font-family: "JetBrains Mono", "Fira Code", monospace;
}
.markdown-content :global(ul),
.markdown-content :global(ol) {
margin: 0.5em 0;
padding-left: 1.5em;
}
.markdown-content :global(li) {
margin: 0.25em 0;
}
.markdown-content :global(blockquote) {
border-left: 3px solid var(--border-color);
margin: 0.75em 0;
padding-left: 1em;
color: var(--text-secondary);
}
.markdown-content :global(a) {
color: var(--accent-primary, #f472b6);
text-decoration: underline;
}
.markdown-content :global(a:hover) {
color: var(--accent-secondary, #e879f9);
}
.markdown-content :global(strong) {
font-weight: 600;
}
.markdown-content :global(h1),
.markdown-content :global(h2),
.markdown-content :global(h3),
.markdown-content :global(h4) {
margin: 1em 0 0.5em 0;
font-weight: 600;
}
.markdown-content :global(h1:first-child),
.markdown-content :global(h2:first-child),
.markdown-content :global(h3:first-child),
.markdown-content :global(h4:first-child) {
margin-top: 0;
}
.markdown-content :global(hr) {
border: none;
border-top: 1px solid var(--border-color);
margin: 1em 0;
}
.markdown-content :global(table) {
border-collapse: collapse;
margin: 0.75em 0;
width: 100%;
}
.markdown-content :global(th),
.markdown-content :global(td) {
border: 1px solid var(--border-color);
padding: 0.5em;
text-align: left;
}
.markdown-content :global(th) {
background: var(--bg-secondary);
font-weight: 600;
}
/* Highlight.js theme colors - using CSS variables for light/dark mode support */
.markdown-content :global(.hljs) {
color: var(--text-primary);
}
.markdown-content :global(.hljs-keyword),
.markdown-content :global(.hljs-selector-tag),
.markdown-content :global(.hljs-built_in),
.markdown-content :global(.hljs-name) {
color: var(--hljs-keyword);
}
.markdown-content :global(.hljs-string),
.markdown-content :global(.hljs-attr),
.markdown-content :global(.hljs-symbol),
.markdown-content :global(.hljs-bullet) {
color: var(--hljs-string);
}
.markdown-content :global(.hljs-number),
.markdown-content :global(.hljs-literal) {
color: var(--hljs-number);
}
.markdown-content :global(.hljs-comment),
.markdown-content :global(.hljs-quote) {
color: var(--hljs-comment);
font-style: italic;
}
.markdown-content :global(.hljs-function),
.markdown-content :global(.hljs-title) {
color: var(--hljs-function);
}
.markdown-content :global(.hljs-type),
.markdown-content :global(.hljs-class) {
color: var(--hljs-type);
}
.markdown-content :global(.hljs-variable),
.markdown-content :global(.hljs-template-variable) {
color: var(--hljs-variable);
}
.markdown-content :global(.hljs-meta) {
color: var(--hljs-meta);
}
.markdown-content :global(.hljs-tag) {
color: var(--hljs-keyword);
}
.markdown-content :global(.hljs-attribute) {
color: var(--hljs-function);
}
.markdown-content :global(.hljs-params) {
color: var(--text-primary);
}
/* Spoiler tag styles */
.markdown-content :global(.spoiler) {
background: var(--text-primary);
color: transparent;
border-radius: 4px;
padding: 0 0.25em;
cursor: pointer;
transition: all 0.2s ease;
user-select: none;
}
.markdown-content :global(.spoiler:hover) {
opacity: 0.8;
}
.markdown-content :global(.spoiler:focus) {
outline: 2px solid var(--accent-primary);
outline-offset: 2px;
}
.markdown-content :global(.spoiler.revealed) {
background: var(--bg-hover);
color: var(--text-primary);
user-select: text;
}
.markdown-content :global(.search-highlight) {
background-color: var(--search-highlight, #fbbf24);
color: var(--search-highlight-text, #000);
border-radius: 2px;
padding: 0 2px;
}
</style>
+22 -8
View File
@@ -109,8 +109,22 @@ Please continue where we left off and retry that action now that you have permis
function isToolAlreadyGranted(toolName: string): boolean { function isToolAlreadyGranted(toolName: string): boolean {
return grantedToolsList.includes(toolName); return grantedToolsList.includes(toolName);
} }
function handleKeydown(event: KeyboardEvent) {
if (!isVisible || !permission) return;
if (event.key === "Enter") {
event.preventDefault();
handleApproveAndReconnect();
} else if (event.key === "Escape") {
event.preventDefault();
handleDismiss();
}
}
</script> </script>
<svelte:window onkeydown={handleKeydown} />
{#if isVisible && permission} {#if isVisible && permission}
<div <div
class="permission-overlay fixed inset-0 bg-black/70 flex items-center justify-center z-50 backdrop-blur-sm" class="permission-overlay fixed inset-0 bg-black/70 flex items-center justify-center z-50 backdrop-blur-sm"
@@ -123,8 +137,8 @@ Please continue where we left off and retry that action now that you have permis
<span class="text-xl">🔐</span> <span class="text-xl">🔐</span>
</div> </div>
<div> <div>
<h2 class="text-lg font-semibold text-white">Permission Blocked</h2> <h2 class="text-lg font-semibold text-[var(--text-primary)]">Permission Blocked</h2>
<p class="text-sm text-gray-400">Hikari tried to use a restricted tool</p> <p class="text-sm text-[var(--text-secondary)]">Hikari tried to use a restricted tool</p>
</div> </div>
</div> </div>
@@ -135,7 +149,7 @@ Please continue where we left off and retry that action now that you have permis
</div> </div>
<div class="mb-4"> <div class="mb-4">
<div class="text-sm text-gray-400 mb-1">Tool</div> <div class="text-sm text-[var(--text-secondary)] mb-1">Tool</div>
<div <div
class="px-3 py-2 bg-[var(--bg-secondary)] rounded-md text-[var(--accent-primary)] font-mono flex items-center justify-between" class="px-3 py-2 bg-[var(--bg-secondary)] rounded-md text-[var(--accent-primary)] font-mono flex items-center justify-between"
> >
@@ -149,17 +163,17 @@ Please continue where we left off and retry that action now that you have permis
</div> </div>
<div class="mb-4"> <div class="mb-4">
<div class="text-sm text-gray-400 mb-1">Description</div> <div class="text-sm text-[var(--text-secondary)] mb-1">Description</div>
<div class="px-3 py-2 bg-[var(--bg-secondary)] rounded-md text-gray-300"> <div class="px-3 py-2 bg-[var(--bg-secondary)] rounded-md text-[var(--text-primary)]">
{permission.description} {permission.description}
</div> </div>
</div> </div>
{#if Object.keys(permission.input).length > 0} {#if Object.keys(permission.input).length > 0}
<div class="mb-6"> <div class="mb-6">
<div class="text-sm text-gray-400 mb-1">Details</div> <div class="text-sm text-[var(--text-secondary)] mb-1">Details</div>
<pre <pre
class="px-3 py-2 bg-[var(--bg-terminal)] rounded-md text-gray-300 text-xs overflow-x-auto max-h-32">{formatInput( class="px-3 py-2 bg-[var(--bg-terminal)] rounded-md text-[var(--text-primary)] text-xs overflow-x-auto max-h-32">{formatInput(
permission.input permission.input
)}</pre> )}</pre>
</div> </div>
@@ -168,7 +182,7 @@ Please continue where we left off and retry that action now that you have permis
<div class="flex gap-3"> <div class="flex gap-3">
<button <button
onclick={handleDismiss} onclick={handleDismiss}
class="flex-1 px-4 py-2 bg-gray-500/20 hover:bg-gray-500/30 text-gray-400 rounded-lg transition-colors font-medium" class="flex-1 px-4 py-2 bg-gray-500/20 hover:bg-gray-500/30 text-[var(--text-secondary)] rounded-lg transition-colors font-medium"
> >
Dismiss Dismiss
</button> </button>
+929
View File
@@ -0,0 +1,929 @@
<script lang="ts">
import { configStore, type HikariConfig } from "$lib/stores/config";
import { formattedStats } from "$lib/stores/stats";
import { achievementsStore } from "$lib/stores/achievements";
import { open, save } from "@tauri-apps/plugin-dialog";
import { writeFile, readFile } from "@tauri-apps/plugin-fs";
export let onClose: () => void;
let config: HikariConfig;
configStore.config.subscribe((c) => (config = c));
let editingName = false;
let editingBio = false;
let nameInput = "";
let bioInput = "";
let avatarDataUrl: string | null = null;
// Initialize inputs when config is loaded
$: if (config) {
if (!editingName) nameInput = config.profile_name || "";
if (!editingBio) bioInput = config.profile_bio || "";
}
// Load avatar on mount and when path changes
let lastLoadedPath: string | null = null;
async function updateAvatarDisplay(path: string | null) {
if (path === lastLoadedPath) return;
lastLoadedPath = path;
if (path) {
avatarDataUrl = await loadAvatarAsDataUrl(path);
} else {
avatarDataUrl = null;
}
}
$: updateAvatarDisplay(config?.profile_avatar_path ?? null);
let isGeneratingImage = false;
$: unlockedCount = Object.values($achievementsStore.achievements).filter(
(a) => a.unlocked
).length;
$: totalAchievements = Object.values($achievementsStore.achievements).length;
$: achievementPercentage =
totalAchievements > 0 ? Math.round((unlockedCount / totalAchievements) * 100) : 0;
async function selectAvatar() {
try {
const selected = await open({
multiple: false,
filters: [
{
name: "Images",
extensions: ["png", "jpg", "jpeg", "gif", "webp"],
},
],
});
if (selected && typeof selected === "string") {
await configStore.updateConfig({ profile_avatar_path: selected });
}
} catch (error) {
console.error("Failed to select avatar:", error);
}
}
async function removeAvatar() {
await configStore.updateConfig({ profile_avatar_path: null });
}
async function saveName() {
await configStore.updateConfig({ profile_name: nameInput || null });
editingName = false;
}
async function saveBio() {
await configStore.updateConfig({ profile_bio: bioInput || null });
editingBio = false;
}
async function loadAvatarAsDataUrl(path: string): Promise<string | null> {
try {
const data = await readFile(path);
const extension = path.split(".").pop()?.toLowerCase() || "png";
const mimeType =
extension === "jpg" || extension === "jpeg"
? "image/jpeg"
: extension === "gif"
? "image/gif"
: extension === "webp"
? "image/webp"
: "image/png";
// Convert Uint8Array to base64 in chunks to avoid stack overflow
const blob = new Blob([data], { type: mimeType });
return new Promise((resolve) => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result as string);
reader.onerror = () => resolve(null);
reader.readAsDataURL(blob);
});
} catch (error) {
console.error("Failed to load avatar:", error);
return null;
}
}
async function generateShareImage(): Promise<Blob> {
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d")!;
// Card dimensions (1080p for sharing)
const width = 1920;
const height = 1080;
canvas.width = width;
canvas.height = height;
// Background gradient (dark theme)
const bgGradient = ctx.createLinearGradient(0, 0, width, height);
bgGradient.addColorStop(0, "#1a1a2e");
bgGradient.addColorStop(1, "#16213e");
ctx.fillStyle = bgGradient;
ctx.fillRect(0, 0, width, height);
// Trans flag stripe accent at top
const stripeHeight = 25;
const stripeColors = ["#5bcefa", "#f5a9b8", "#ffffff", "#f5a9b8", "#5bcefa"];
stripeColors.forEach((color, i) => {
ctx.fillStyle = color;
ctx.fillRect(0, i * (stripeHeight / 5) * 2, width, (stripeHeight / 5) * 2);
});
// Border
ctx.strokeStyle = "#5bcefa";
ctx.lineWidth = 6;
ctx.strokeRect(3, 3, width - 6, height - 6);
// Avatar circle
const avatarX = 200;
const avatarY = 220;
const avatarRadius = 140;
ctx.save();
ctx.beginPath();
ctx.arc(avatarX, avatarY, avatarRadius, 0, Math.PI * 2);
ctx.closePath();
ctx.clip();
// Draw avatar if available, otherwise gradient placeholder
let avatarLoaded = false;
if (config.profile_avatar_path) {
try {
const dataUrl = await loadAvatarAsDataUrl(config.profile_avatar_path);
if (dataUrl) {
const img = new Image();
await new Promise<void>((resolve, reject) => {
img.onload = () => resolve();
img.onerror = () => reject();
img.src = dataUrl;
});
ctx.drawImage(
img,
avatarX - avatarRadius,
avatarY - avatarRadius,
avatarRadius * 2,
avatarRadius * 2
);
avatarLoaded = true;
}
} catch {
// Will use fallback gradient
}
}
if (!avatarLoaded) {
const avatarGradient = ctx.createLinearGradient(
avatarX - avatarRadius,
avatarY - avatarRadius,
avatarX + avatarRadius,
avatarY + avatarRadius
);
avatarGradient.addColorStop(0, "#5bcefa");
avatarGradient.addColorStop(0.5, "#f5a9b8");
avatarGradient.addColorStop(1, "#5bcefa");
ctx.fillStyle = avatarGradient;
ctx.fillRect(
avatarX - avatarRadius,
avatarY - avatarRadius,
avatarRadius * 2,
avatarRadius * 2
);
}
ctx.restore();
// Avatar border
ctx.beginPath();
ctx.arc(avatarX, avatarY, avatarRadius + 6, 0, Math.PI * 2);
ctx.strokeStyle = "#f5a9b8";
ctx.lineWidth = 8;
ctx.stroke();
// Name
ctx.fillStyle = "#ffffff";
ctx.font = "bold 72px system-ui, -apple-system, sans-serif";
ctx.fillText(config.profile_name || "Hikari User", 400, 180);
// Bio (truncated)
ctx.fillStyle = "#9ca3af";
ctx.font = "36px system-ui, -apple-system, sans-serif";
const bio = config.profile_bio || "A Hikari Desktop user";
const truncatedBio = bio.length > 100 ? bio.substring(0, 97) + "..." : bio;
ctx.fillText(truncatedBio, 400, 260);
// Stats section
const statsY = 420;
ctx.fillStyle = "#6b7280";
ctx.font = "bold 32px system-ui, -apple-system, sans-serif";
ctx.fillText("LIFETIME STATS", 80, statsY);
// Stats grid
const stats = [
{ label: "Messages", value: $formattedStats.messagesTotal },
{ label: "Tokens", value: $formattedStats.totalTokens },
{ label: "Code Blocks", value: $formattedStats.codeBlocksTotal },
{ label: "Files Edited", value: $formattedStats.filesEditedTotal },
{ label: "Files Created", value: $formattedStats.filesCreatedTotal },
{ label: "Total Cost", value: $formattedStats.totalCost },
];
const statBoxWidth = 540;
const statBoxHeight = 160;
const statsPerRow = 3;
const startX = 80;
const startY = statsY + 40;
stats.forEach((stat, i) => {
const row = Math.floor(i / statsPerRow);
const col = i % statsPerRow;
const x = startX + col * (statBoxWidth + 50);
const y = startY + row * (statBoxHeight + 30);
// Stat box background
ctx.fillStyle = "rgba(255, 255, 255, 0.05)";
ctx.beginPath();
ctx.roundRect(x, y, statBoxWidth, statBoxHeight, 20);
ctx.fill();
// Stat value
ctx.fillStyle = "#5bcefa";
ctx.font = "bold 56px system-ui, -apple-system, sans-serif";
ctx.fillText(stat.value, x + 32, y + 75);
// Stat label
ctx.fillStyle = "#9ca3af";
ctx.font = "28px system-ui, -apple-system, sans-serif";
ctx.fillText(stat.label.toUpperCase(), x + 32, y + 128);
});
// Achievement progress
const achieveY = 870;
ctx.fillStyle = "#6b7280";
ctx.font = "bold 32px system-ui, -apple-system, sans-serif";
ctx.fillText("ACHIEVEMENTS", 80, achieveY);
// Progress bar background
const progressX = 80;
const progressY = achieveY + 30;
const progressWidth = 1200;
const progressHeight = 44;
ctx.fillStyle = "rgba(255, 255, 255, 0.1)";
ctx.beginPath();
ctx.roundRect(progressX, progressY, progressWidth, progressHeight, 22);
ctx.fill();
// Progress bar fill
const progressGradient = ctx.createLinearGradient(progressX, 0, progressX + progressWidth, 0);
progressGradient.addColorStop(0, "#5bcefa");
progressGradient.addColorStop(0.5, "#f5a9b8");
progressGradient.addColorStop(1, "#5bcefa");
ctx.fillStyle = progressGradient;
ctx.beginPath();
ctx.roundRect(
progressX,
progressY,
progressWidth * (achievementPercentage / 100),
progressHeight,
22
);
ctx.fill();
// Progress text
ctx.fillStyle = "#ffffff";
ctx.font = "bold 36px system-ui, -apple-system, sans-serif";
ctx.fillText(
`${unlockedCount} / ${totalAchievements} (${achievementPercentage}%)`,
progressX + progressWidth + 40,
progressY + 34
);
// Hikari branding
ctx.fillStyle = "#f5a9b8";
ctx.font = "bold 42px system-ui, -apple-system, sans-serif";
ctx.fillText("✨ Hikari Desktop", 80, height - 100);
// Discord promo
ctx.fillStyle = "#9ca3af";
ctx.font = "34px system-ui, -apple-system, sans-serif";
ctx.fillText("Join our community: chat.nhcarrigan.com", 80, height - 45);
// Convert canvas to blob
return new Promise((resolve) => {
canvas.toBlob((blob) => resolve(blob!), "image/png");
});
}
async function shareProfile() {
isGeneratingImage = true;
try {
const blob = await generateShareImage();
const arrayBuffer = await blob.arrayBuffer();
const uint8Array = new Uint8Array(arrayBuffer);
const filePath = await save({
filters: [{ name: "PNG Image", extensions: ["png"] }],
defaultPath: `hikari-profile-${config.profile_name?.replace(/\s+/g, "-").toLowerCase() || "user"}.png`,
});
if (filePath) {
await writeFile(filePath, uint8Array);
}
} catch (error) {
console.error("Failed to generate share image:", error);
} finally {
isGeneratingImage = false;
}
}
</script>
<div class="profile-overlay" role="dialog" aria-modal="true">
<div class="profile-panel">
<div class="profile-header">
<h2>Profile</h2>
<button class="close-btn" on:click={onClose} aria-label="Close profile">
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</div>
<div class="profile-content">
<!-- Avatar Section -->
<div class="avatar-section">
<div
class="avatar-container"
on:click={selectAvatar}
role="button"
tabindex="0"
on:keydown={(e) => e.key === "Enter" && selectAvatar()}
>
{#if avatarDataUrl}
<img src={avatarDataUrl} alt="Profile avatar" class="avatar-image" />
<div class="avatar-overlay">
<span>Change</span>
</div>
{:else}
<div class="avatar-placeholder">
<svg
width="48"
height="48"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
<span>Add Photo</span>
</div>
{/if}
</div>
{#if config.profile_avatar_path}
<button class="remove-avatar-btn" on:click={removeAvatar}>Remove Photo</button>
{/if}
</div>
<!-- Name Section -->
<div class="name-section">
{#if editingName}
<input
type="text"
bind:value={nameInput}
placeholder="Enter your name"
class="name-input"
on:keydown={(e) => e.key === "Enter" && saveName()}
on:blur={saveName}
/>
{:else}
<button
class="name-display"
on:click={() => {
editingName = true;
nameInput = config.profile_name || "";
}}
>
<span class="name-text">{config.profile_name || "Click to add name"}</span>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</button>
{/if}
</div>
<!-- Bio Section -->
<div class="bio-section">
<h3>Bio</h3>
{#if editingBio}
<textarea
bind:value={bioInput}
placeholder="Tell us about yourself..."
class="bio-input"
rows="3"
on:blur={saveBio}
></textarea>
<button class="save-bio-btn btn-trans-gradient" on:click={saveBio}>Save</button>
{:else}
<button
class="bio-display"
on:click={() => {
editingBio = true;
bioInput = config.profile_bio || "";
}}
>
<span class="bio-text">{config.profile_bio || "Click to add a bio..."}</span>
</button>
{/if}
</div>
<!-- Stats Section -->
<div class="stats-section">
<h3>Lifetime Stats</h3>
<div class="stats-grid">
<div class="stat-card">
<span class="stat-value">{$formattedStats.messagesTotal}</span>
<span class="stat-label">Messages</span>
</div>
<div class="stat-card">
<span class="stat-value">{$formattedStats.totalTokens}</span>
<span class="stat-label">Tokens</span>
</div>
<div class="stat-card">
<span class="stat-value">{$formattedStats.codeBlocksTotal}</span>
<span class="stat-label">Code Blocks</span>
</div>
<div class="stat-card">
<span class="stat-value">{$formattedStats.filesEditedTotal}</span>
<span class="stat-label">Files Edited</span>
</div>
<div class="stat-card">
<span class="stat-value">{$formattedStats.filesCreatedTotal}</span>
<span class="stat-label">Files Created</span>
</div>
<div class="stat-card">
<span class="stat-value">{$formattedStats.totalCost}</span>
<span class="stat-label">Total Cost</span>
</div>
</div>
</div>
<!-- Achievements Section -->
<div class="achievements-section">
<h3>Achievements</h3>
<div class="achievement-progress">
<div class="progress-bar">
<div class="progress-fill" style="width: {achievementPercentage}%"></div>
</div>
<span class="progress-text"
>{unlockedCount} / {totalAchievements} ({achievementPercentage}%)</span
>
</div>
</div>
<!-- Share Section -->
<div class="share-section">
<button
class="share-btn btn-trans-gradient"
on:click={shareProfile}
disabled={isGeneratingImage}
>
{#if isGeneratingImage}
<span class="spinner"></span>
Generating...
{:else}
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8" />
<polyline points="16 6 12 2 8 6" />
<line x1="12" y1="2" x2="12" y2="15" />
</svg>
Share Profile
{/if}
</button>
<p class="share-hint">Generate a shareable image of your profile</p>
</div>
</div>
</div>
</div>
<style>
.profile-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
backdrop-filter: blur(4px);
}
.profile-panel {
background: var(--bg-secondary);
border-radius: 16px;
width: 90%;
max-width: 480px;
max-height: 85vh;
overflow-y: auto;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
border: 1px solid var(--border-color);
}
.profile-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 24px;
border-bottom: 1px solid var(--border-color);
position: sticky;
top: 0;
background: var(--bg-secondary);
z-index: 1;
}
.profile-header h2 {
margin: 0;
font-size: 20px;
font-weight: 600;
color: var(--text-primary);
}
.close-btn {
background: none;
border: none;
color: var(--text-secondary);
cursor: pointer;
padding: 4px;
border-radius: 4px;
transition: all 0.2s;
}
.close-btn:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
}
.profile-content {
padding: 24px;
display: flex;
flex-direction: column;
gap: 24px;
}
/* Avatar */
.avatar-section {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.avatar-container {
width: 120px;
height: 120px;
border-radius: 50%;
overflow: hidden;
cursor: pointer;
position: relative;
background: var(--bg-tertiary);
border: 3px solid var(--trans-pink);
box-shadow: 0 0 20px rgba(245, 169, 184, 0.3);
transition: all 0.3s;
}
.avatar-container:hover {
border-color: var(--trans-blue);
box-shadow: 0 0 30px rgba(91, 206, 250, 0.4);
transform: scale(1.02);
}
.avatar-image {
width: 100%;
height: 100%;
object-fit: cover;
}
.avatar-overlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.2s;
color: white;
font-size: 14px;
font-weight: 500;
}
.avatar-container:hover .avatar-overlay {
opacity: 1;
}
.avatar-placeholder {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
color: var(--text-secondary);
}
.avatar-placeholder span {
font-size: 12px;
}
.remove-avatar-btn {
background: none;
border: none;
color: var(--text-secondary);
font-size: 12px;
cursor: pointer;
text-decoration: underline;
}
.remove-avatar-btn:hover {
color: var(--trans-pink);
}
/* Name */
.name-section {
text-align: center;
}
.name-display {
display: inline-flex;
align-items: center;
gap: 8px;
background: none;
border: none;
cursor: pointer;
padding: 8px 16px;
border-radius: 8px;
transition: all 0.2s;
}
.name-display:hover {
background: var(--bg-tertiary);
}
.name-display svg {
color: var(--text-secondary);
opacity: 0;
transition: opacity 0.2s;
}
.name-display:hover svg {
opacity: 1;
}
.name-text {
font-size: 24px;
font-weight: 600;
color: var(--text-primary);
}
.name-input {
width: 100%;
max-width: 300px;
padding: 12px 16px;
font-size: 24px;
font-weight: 600;
text-align: center;
background: var(--bg-tertiary);
border: 2px solid var(--trans-pink);
border-radius: 8px;
color: var(--text-primary);
outline: none;
}
.name-input:focus {
border-color: var(--trans-blue);
box-shadow: 0 0 10px rgba(91, 206, 250, 0.3);
}
/* Bio */
.bio-section h3,
.stats-section h3,
.achievements-section h3 {
margin: 0 0 12px;
font-size: 14px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.bio-display {
width: 100%;
text-align: left;
background: var(--bg-tertiary);
border: 1px solid transparent;
border-radius: 8px;
padding: 12px;
cursor: pointer;
transition: all 0.2s;
}
.bio-display:hover {
border-color: var(--border-color);
}
.bio-text {
color: var(--text-secondary);
font-size: 14px;
line-height: 1.5;
}
.bio-input {
width: 100%;
padding: 12px;
font-size: 14px;
background: var(--bg-tertiary);
border: 2px solid var(--trans-pink);
border-radius: 8px;
color: var(--text-primary);
resize: none;
outline: none;
font-family: inherit;
}
.bio-input:focus {
border-color: var(--trans-blue);
box-shadow: 0 0 10px rgba(91, 206, 250, 0.3);
}
.save-bio-btn {
margin-top: 8px;
padding: 8px 16px;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
}
/* Stats */
.stats-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
}
.stat-card {
background: var(--bg-tertiary);
border-radius: 12px;
padding: 16px 12px;
text-align: center;
display: flex;
flex-direction: column;
gap: 4px;
border: 1px solid var(--border-color);
}
.stat-value {
font-size: 20px;
font-weight: 700;
color: var(--text-primary);
background: linear-gradient(135deg, var(--trans-blue), var(--trans-pink));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.stat-label {
font-size: 11px;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* Achievements */
.achievement-progress {
display: flex;
flex-direction: column;
gap: 8px;
}
.progress-bar {
height: 12px;
background: var(--bg-tertiary);
border-radius: 6px;
overflow: hidden;
border: 1px solid var(--border-color);
}
.progress-fill {
height: 100%;
background: linear-gradient(
90deg,
var(--trans-blue),
var(--trans-pink),
var(--trans-white),
var(--trans-pink),
var(--trans-blue)
);
background-size: 200% 100%;
animation: shimmer 3s linear infinite;
border-radius: 6px;
transition: width 0.5s ease;
}
@keyframes shimmer {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
.progress-text {
font-size: 14px;
color: var(--text-secondary);
text-align: center;
}
/* Share */
.share-section {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding-top: 8px;
border-top: 1px solid var(--border-color);
}
.share-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 12px 24px;
border: none;
border-radius: 8px;
cursor: pointer;
font-size: 15px;
font-weight: 600;
transition: all 0.2s;
}
.share-btn:disabled {
opacity: 0.7;
cursor: not-allowed;
}
.share-btn svg {
flex-shrink: 0;
}
.share-hint {
margin: 0;
font-size: 12px;
color: var(--text-secondary);
}
.spinner {
width: 16px;
height: 16px;
border: 2px solid transparent;
border-top-color: currentColor;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>
+456
View File
@@ -0,0 +1,456 @@
<script lang="ts">
import { onMount } from "svelte";
import { quickActionsStore, type QuickAction } from "$lib/stores/quickActions";
interface Props {
onClose: () => void;
onAction: (prompt: string) => void;
}
const { onClose, onAction }: Props = $props();
let editingAction = $state<QuickAction | null>(null);
let isCreating = $state(false);
let showDeleteConfirm = $state<string | null>(null);
let editName = $state("");
let editPrompt = $state("");
let editIcon = $state("zap");
const actions = $derived(quickActionsStore.actions);
const isLoading = $derived(quickActionsStore.isLoading);
const availableIcons = [
{ id: "zap", label: "Lightning" },
{ id: "play", label: "Play" },
{ id: "file-text", label: "File" },
{ id: "alert-circle", label: "Alert" },
{ id: "check-square", label: "Check" },
{ id: "refresh-cw", label: "Refresh" },
{ id: "git-pull-request", label: "Git PR" },
{ id: "code", label: "Code" },
{ id: "search", label: "Search" },
{ id: "terminal", label: "Terminal" },
{ id: "bug", label: "Bug" },
{ id: "shield", label: "Shield" },
];
onMount(() => {
quickActionsStore.loadQuickActions();
});
function handleAction(action: QuickAction): void {
onAction(action.prompt);
onClose();
}
function handleStartCreate(): void {
isCreating = true;
editingAction = null;
editName = "";
editPrompt = "";
editIcon = "zap";
}
function handleStartEdit(action: QuickAction): void {
editingAction = action;
isCreating = false;
editName = action.name;
editPrompt = action.prompt;
editIcon = action.icon;
}
function handleCancelEdit(): void {
editingAction = null;
isCreating = false;
}
async function handleSave(): Promise<void> {
if (!editName.trim() || !editPrompt.trim()) {
return;
}
if (isCreating) {
await quickActionsStore.createQuickAction(editName.trim(), editPrompt.trim(), editIcon);
} else if (editingAction) {
await quickActionsStore.updateQuickAction(
editingAction.id,
editName.trim(),
editPrompt.trim(),
editIcon
);
}
handleCancelEdit();
}
async function handleDelete(actionId: string): Promise<void> {
await quickActionsStore.deleteQuickAction(actionId);
showDeleteConfirm = null;
}
function getIconSvg(icon: string): string {
const icons: Record<string, string> = {
"git-pull-request":
'<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 3v12M18 9a3 3 0 100 6 3 3 0 000-6zm0 0V3m0 18v-6M6 21a3 3 0 100-6 3 3 0 000 6z" />',
play: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />',
"file-text":
'<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" 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" />',
"alert-circle":
'<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />',
"check-square":
'<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" />',
"refresh-cw":
'<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" />',
zap: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />',
code: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />',
search:
'<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />',
terminal:
'<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" />',
bug: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />',
shield:
'<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />',
};
return icons[icon] || icons["zap"];
}
</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="quick-actions-title"
tabindex="-1"
>
<div class="flex items-center justify-between p-6 pb-4 border-b border-[var(--border-color)]">
<div class="flex items-center gap-3">
{#if editingAction || isCreating}
<button
onclick={handleCancelEdit}
class="p-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
aria-label="Back to 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="M15 19l-7-7 7-7"
/>
</svg>
</button>
{/if}
<h2 id="quick-actions-title" class="text-xl font-semibold text-[var(--text-primary)]">
{#if isCreating}
Create Quick Action
{:else if editingAction}
Edit Quick Action
{:else}
Quick Actions
{/if}
</h2>
</div>
<div class="flex items-center gap-2">
{#if !editingAction && !isCreating}
<button
onclick={handleStartCreate}
class="px-3 py-1.5 text-sm font-medium btn-trans-gradient rounded flex items-center gap-2"
>
<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="M12 4v16m8-8H4"
/>
</svg>
New Action
</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>
{#if editingAction || isCreating}
<div class="p-6 flex-1 overflow-y-auto">
<div class="space-y-4">
<div>
<label
for="action-name"
class="block text-sm font-medium text-[var(--text-secondary)] mb-1"
>
Name
</label>
<input
id="action-name"
type="text"
bind:value={editName}
placeholder="Enter action name..."
class="w-full px-4 py-2 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] placeholder-[var(--text-tertiary)] focus:outline-none focus:border-[var(--accent-primary)]"
/>
</div>
<div>
<label
for="action-icon"
class="block text-sm font-medium text-[var(--text-secondary)] mb-1"
>
Icon
</label>
<div class="grid grid-cols-6 gap-2">
{#each availableIcons as icon (icon.id)}
<button
onclick={() => (editIcon = icon.id)}
class="p-3 rounded-lg border transition-colors {editIcon === icon.id
? 'bg-[var(--accent-primary)]/10 border-[var(--accent-primary)] text-[var(--accent-primary)]'
: 'bg-[var(--bg-secondary)] border-[var(--border-color)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)]/50'}"
title={icon.label}
>
<svg
class="w-5 h-5 mx-auto"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<!-- eslint-disable-next-line svelte/no-at-html-tags -- Icons are from controlled internal function -->
{@html getIconSvg(icon.id)}
</svg>
</button>
{/each}
</div>
</div>
<div>
<label
for="action-prompt"
class="block text-sm font-medium text-[var(--text-secondary)] mb-1"
>
Prompt
</label>
<textarea
id="action-prompt"
bind:value={editPrompt}
placeholder="Enter the prompt to send..."
rows="4"
class="w-full px-4 py-3 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] placeholder-[var(--text-tertiary)] focus:outline-none focus:border-[var(--accent-primary)] resize-none font-mono text-sm"
></textarea>
</div>
<div class="flex justify-end gap-2 pt-4">
<button
onclick={handleCancelEdit}
class="px-4 py-2 text-sm font-medium bg-[var(--bg-secondary)] text-[var(--text-secondary)] rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors"
>
Cancel
</button>
<button
onclick={handleSave}
disabled={!editName.trim() || !editPrompt.trim()}
class="px-4 py-2 text-sm font-medium btn-trans-gradient rounded-lg"
>
{isCreating ? "Create" : "Save"}
</button>
</div>
</div>
</div>
{:else}
<div class="flex-1 overflow-y-auto p-6">
{#if $isLoading}
<div class="flex items-center justify-center p-8">
<div class="text-[var(--text-tertiary)]">Loading quick actions...</div>
</div>
{:else if $actions.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="M13 10V3L4 14h7v7l9-11h-7z"
/>
</svg>
<p class="text-[var(--text-secondary)]">No quick actions available</p>
<button
onclick={handleStartCreate}
class="mt-4 px-4 py-2 text-sm font-medium btn-trans-gradient rounded-lg"
>
Create your first action
</button>
</div>
{:else}
<div class="grid grid-cols-2 sm:grid-cols-3 gap-3">
{#each $actions as action (action.id)}
<div
class="group relative bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg hover:border-[var(--accent-primary)]/50 transition-colors"
>
<button onclick={() => handleAction(action)} class="w-full p-4 text-left">
<div class="flex items-center gap-3 mb-2">
<div
class="w-10 h-10 rounded-lg bg-[var(--accent-primary)]/10 flex items-center justify-center text-[var(--accent-primary)]"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<!-- eslint-disable-next-line svelte/no-at-html-tags -- Icons are from controlled internal function -->
{@html getIconSvg(action.icon)}
</svg>
</div>
<div class="flex-1 min-w-0">
<h3 class="font-medium text-[var(--text-primary)] truncate">{action.name}</h3>
{#if action.is_default}
<span class="text-xs text-[var(--text-tertiary)]">Default</span>
{:else}
<span class="text-xs text-[var(--accent-secondary)]">Custom</span>
{/if}
</div>
</div>
<p class="text-xs text-[var(--text-tertiary)] line-clamp-2">{action.prompt}</p>
</button>
<div
class="absolute top-2 right-2 flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity"
>
<button
onclick={(e) => {
e.stopPropagation();
handleStartEdit(action);
}}
class="p-1.5 text-[var(--text-tertiary)] hover:text-[var(--text-primary)] bg-[var(--bg-primary)] rounded transition-colors"
title="Edit action"
>
<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="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>
{#if !action.is_default}
{#if showDeleteConfirm === action.id}
<div class="flex items-center gap-1 bg-[var(--bg-primary)] rounded p-1">
<button
onclick={(e) => {
e.stopPropagation();
handleDelete(action.id);
}}
class="px-2 py-0.5 text-xs font-medium bg-red-500 text-white rounded hover:bg-red-600 transition-colors"
>
Delete
</button>
<button
onclick={(e) => {
e.stopPropagation();
showDeleteConfirm = null;
}}
class="px-2 py-0.5 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={(e) => {
e.stopPropagation();
showDeleteConfirm = action.id;
}}
class="p-1.5 text-[var(--text-tertiary)] hover:text-red-400 bg-[var(--bg-primary)] rounded transition-colors"
title="Delete action"
>
<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="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}
{/if}
</div>
</div>
{/each}
</div>
{/if}
</div>
{/if}
</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);
}
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>

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