Compare commits

..

14 Commits

Author SHA1 Message Date
naomi ae06cc301d feat: add support for API chats 2026-02-04 13:44:44 -08:00
naomi a0804ed32a feat: support ollama 2026-02-04 13:19:10 -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
135 changed files with 27912 additions and 356 deletions
+8 -5
View File
@@ -49,13 +49,13 @@ jobs:
- name: Run Svelte Check
run: pnpm check
- name: Run frontend tests
run: pnpm test
- name: Run frontend tests with coverage
run: pnpm test:coverage
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
with:
components: clippy
components: clippy, llvm-tools-preview
- name: Cache Rust dependencies
uses: actions/cache@v4
@@ -68,13 +68,16 @@ jobs:
src-tauri/target/
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
- name: Install cargo-llvm-cov
run: cargo install cargo-llvm-cov --locked
- name: Run Clippy
working-directory: src-tauri
run: cargo clippy --all-targets --all-features -- -D warnings
- name: Run Rust tests
- name: Run Rust tests with coverage
working-directory: src-tauri
run: cargo test
run: cargo llvm-cov --fail-under-lines 50
build-linux:
name: Build Linux
+3
View File
@@ -8,3 +8,6 @@ node_modules
!.env.example
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
# Coverage reports
/coverage
+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/"],
}
);
+35 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hikari-desktop",
"version": "0.3.0",
"version": "1.1.1",
"description": "",
"type": "module",
"scripts": {
@@ -16,6 +16,10 @@
"test": "vitest run",
"test:watch": "vitest",
"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:fix": "eslint . --fix",
"format": "prettier --write .",
@@ -23,13 +27,42 @@
},
"license": "MIT",
"dependencies": {
"@codemirror/commands": "6.8.1",
"@codemirror/lang-angular": "^0.1.4",
"@codemirror/lang-cpp": "^6.0.3",
"@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-go": "^6.0.1",
"@codemirror/lang-html": "^6.4.11",
"@codemirror/lang-java": "^6.0.2",
"@codemirror/lang-javascript": "^6.2.4",
"@codemirror/lang-json": "^6.0.2",
"@codemirror/lang-less": "^6.0.2",
"@codemirror/lang-markdown": "^6.5.0",
"@codemirror/lang-php": "^6.0.2",
"@codemirror/lang-python": "^6.2.1",
"@codemirror/lang-rust": "^6.0.2",
"@codemirror/lang-sass": "^6.0.2",
"@codemirror/lang-sql": "^6.10.0",
"@codemirror/lang-vue": "^0.1.3",
"@codemirror/lang-wast": "^6.0.2",
"@codemirror/lang-xml": "^6.1.0",
"@codemirror/lang-yaml": "^6.1.2",
"@codemirror/language": "^6.12.1",
"@codemirror/legacy-modes": "^6.5.2",
"@codemirror/state": "^6.5.4",
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.39.11",
"@lezer/highlight": "^1.2.3",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
"@tauri-apps/plugin-dialog": "^2",
"@tauri-apps/plugin-fs": "^2.4.5",
"@tauri-apps/plugin-notification": "^2",
"@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-os": "^2",
"@tauri-apps/plugin-shell": "^2.3.4",
"@tauri-apps/plugin-store": "^2",
"codemirror": "^6.0.2",
"highlight.js": "^11.11.1",
"marked": "^17.0.1"
},
@@ -42,6 +75,7 @@
"@tauri-apps/cli": "^2",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/svelte": "^5.3.1",
"@vitest/coverage-v8": "^4.0.18",
"eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.14.0",
+704
View File
@@ -8,12 +8,96 @@ importers:
.:
dependencies:
'@codemirror/commands':
specifier: 6.8.1
version: 6.8.1
'@codemirror/lang-angular':
specifier: ^0.1.4
version: 0.1.4
'@codemirror/lang-cpp':
specifier: ^6.0.3
version: 6.0.3
'@codemirror/lang-css':
specifier: ^6.3.1
version: 6.3.1
'@codemirror/lang-go':
specifier: ^6.0.1
version: 6.0.1
'@codemirror/lang-html':
specifier: ^6.4.11
version: 6.4.11
'@codemirror/lang-java':
specifier: ^6.0.2
version: 6.0.2
'@codemirror/lang-javascript':
specifier: ^6.2.4
version: 6.2.4
'@codemirror/lang-json':
specifier: ^6.0.2
version: 6.0.2
'@codemirror/lang-less':
specifier: ^6.0.2
version: 6.0.2
'@codemirror/lang-markdown':
specifier: ^6.5.0
version: 6.5.0
'@codemirror/lang-php':
specifier: ^6.0.2
version: 6.0.2
'@codemirror/lang-python':
specifier: ^6.2.1
version: 6.2.1
'@codemirror/lang-rust':
specifier: ^6.0.2
version: 6.0.2
'@codemirror/lang-sass':
specifier: ^6.0.2
version: 6.0.2
'@codemirror/lang-sql':
specifier: ^6.10.0
version: 6.10.0
'@codemirror/lang-vue':
specifier: ^0.1.3
version: 0.1.3
'@codemirror/lang-wast':
specifier: ^6.0.2
version: 6.0.2
'@codemirror/lang-xml':
specifier: ^6.1.0
version: 6.1.0
'@codemirror/lang-yaml':
specifier: ^6.1.2
version: 6.1.2
'@codemirror/language':
specifier: ^6.12.1
version: 6.12.1
'@codemirror/legacy-modes':
specifier: ^6.5.2
version: 6.5.2
'@codemirror/state':
specifier: ^6.5.4
version: 6.5.4
'@codemirror/theme-one-dark':
specifier: ^6.1.3
version: 6.1.3
'@codemirror/view':
specifier: ^6.39.11
version: 6.39.11
'@lezer/highlight':
specifier: ^1.2.3
version: 1.2.3
'@tauri-apps/api':
specifier: ^2
version: 2.9.1
'@tauri-apps/plugin-clipboard-manager':
specifier: ^2.3.2
version: 2.3.2
'@tauri-apps/plugin-dialog':
specifier: ^2
version: 2.6.0
'@tauri-apps/plugin-fs':
specifier: ^2.4.5
version: 2.4.5
'@tauri-apps/plugin-notification':
specifier: ^2
version: 2.3.3
@@ -29,6 +113,9 @@ importers:
'@tauri-apps/plugin-store':
specifier: ^2
version: 2.4.2
codemirror:
specifier: ^6.0.2
version: 6.0.2
highlight.js:
specifier: ^11.11.1
version: 11.11.1
@@ -60,6 +147,9 @@ importers:
'@testing-library/svelte':
specifier: ^5.3.1
version: 5.3.1(svelte@5.46.3)(vite@6.4.1(jiti@2.6.1)(lightningcss@1.30.2))(vitest@4.0.17(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2))
'@vitest/coverage-v8':
specifier: ^4.0.18
version: 4.0.18(vitest@4.0.17(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2))
eslint:
specifier: ^9.39.2
version: 9.39.2(jiti@2.6.1)
@@ -124,14 +214,115 @@ packages:
resolution: {integrity: sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==}
engines: {node: '>=6.9.0'}
'@babel/helper-string-parser@7.27.1':
resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==}
engines: {node: '>=6.9.0'}
'@babel/helper-validator-identifier@7.28.5':
resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==}
engines: {node: '>=6.9.0'}
'@babel/parser@7.28.6':
resolution: {integrity: sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==}
engines: {node: '>=6.0.0'}
hasBin: true
'@babel/runtime@7.28.6':
resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==}
engines: {node: '>=6.9.0'}
'@babel/types@7.28.6':
resolution: {integrity: sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==}
engines: {node: '>=6.9.0'}
'@bcoe/v8-coverage@1.0.2':
resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
engines: {node: '>=18'}
'@codemirror/autocomplete@6.20.0':
resolution: {integrity: sha512-bOwvTOIJcG5FVo5gUUupiwYh8MioPLQ4UcqbcRf7UQ98X90tCa9E1kZ3Z7tqwpZxYyOvh1YTYbmZE9RTfTp5hg==}
'@codemirror/commands@6.8.1':
resolution: {integrity: sha512-KlGVYufHMQzxbdQONiLyGQDUW0itrLZwq3CcY7xpv9ZLRHqzkBSoteocBHtMCoY7/Ci4xhzSrToIeLg7FxHuaw==}
'@codemirror/lang-angular@0.1.4':
resolution: {integrity: sha512-oap+gsltb/fzdlTQWD6BFF4bSLKcDnlxDsLdePiJpCVNKWXSTAbiiQeYI3UmES+BLAdkmIC1WjyztC1pi/bX4g==}
'@codemirror/lang-cpp@6.0.3':
resolution: {integrity: sha512-URM26M3vunFFn9/sm6rzqrBzDgfWuDixp85uTY49wKudToc2jTHUrKIGGKs+QWND+YLofNNZpxcNGRynFJfvgA==}
'@codemirror/lang-css@6.3.1':
resolution: {integrity: sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==}
'@codemirror/lang-go@6.0.1':
resolution: {integrity: sha512-7fNvbyNylvqCphW9HD6WFnRpcDjr+KXX/FgqXy5H5ZS0eC5edDljukm/yNgYkwTsgp2busdod50AOTIy6Jikfg==}
'@codemirror/lang-html@6.4.11':
resolution: {integrity: sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==}
'@codemirror/lang-java@6.0.2':
resolution: {integrity: sha512-m5Nt1mQ/cznJY7tMfQTJchmrjdjQ71IDs+55d1GAa8DGaB8JXWsVCkVT284C3RTASaY43YknrK2X3hPO/J3MOQ==}
'@codemirror/lang-javascript@6.2.4':
resolution: {integrity: sha512-0WVmhp1QOqZ4Rt6GlVGwKJN3KW7Xh4H2q8ZZNGZaP6lRdxXJzmjm4FqvmOojVj6khWJHIb9sp7U/72W7xQgqAA==}
'@codemirror/lang-json@6.0.2':
resolution: {integrity: sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==}
'@codemirror/lang-less@6.0.2':
resolution: {integrity: sha512-EYdQTG22V+KUUk8Qq582g7FMnCZeEHsyuOJisHRft/mQ+ZSZ2w51NupvDUHiqtsOy7It5cHLPGfHQLpMh9bqpQ==}
'@codemirror/lang-markdown@6.5.0':
resolution: {integrity: sha512-0K40bZ35jpHya6FriukbgaleaqzBLZfOh7HuzqbMxBXkbYMJDxfF39c23xOgxFezR+3G+tR2/Mup+Xk865OMvw==}
'@codemirror/lang-php@6.0.2':
resolution: {integrity: sha512-ZKy2v1n8Fc8oEXj0Th0PUMXzQJ0AIR6TaZU+PbDHExFwdu+guzOA4jmCHS1Nz4vbFezwD7LyBdDnddSJeScMCA==}
'@codemirror/lang-python@6.2.1':
resolution: {integrity: sha512-IRjC8RUBhn9mGR9ywecNhB51yePWCGgvHfY1lWN/Mrp3cKuHr0isDKia+9HnvhiWNnMpbGhWrkhuWOc09exRyw==}
'@codemirror/lang-rust@6.0.2':
resolution: {integrity: sha512-EZaGjCUegtiU7kSMvOfEZpaCReowEf3yNidYu7+vfuGTm9ow4mthAparY5hisJqOHmJowVH3Upu+eJlUji6qqA==}
'@codemirror/lang-sass@6.0.2':
resolution: {integrity: sha512-l/bdzIABvnTo1nzdY6U+kPAC51czYQcOErfzQ9zSm9D8GmNPD0WTW8st/CJwBTPLO8jlrbyvlSEcN20dc4iL0Q==}
'@codemirror/lang-sql@6.10.0':
resolution: {integrity: sha512-6ayPkEd/yRw0XKBx5uAiToSgGECo/GY2NoJIHXIIQh1EVwLuKoU8BP/qK0qH5NLXAbtJRLuT73hx7P9X34iO4w==}
'@codemirror/lang-vue@0.1.3':
resolution: {integrity: sha512-QSKdtYTDRhEHCfo5zOShzxCmqKJvgGrZwDQSdbvCRJ5pRLWBS7pD/8e/tH44aVQT6FKm0t6RVNoSUWHOI5vNug==}
'@codemirror/lang-wast@6.0.2':
resolution: {integrity: sha512-Imi2KTpVGm7TKuUkqyJ5NRmeFWF7aMpNiwHnLQe0x9kmrxElndyH0K6H/gXtWwY6UshMRAhpENsgfpSwsgmC6Q==}
'@codemirror/lang-xml@6.1.0':
resolution: {integrity: sha512-3z0blhicHLfwi2UgkZYRPioSgVTo9PV5GP5ducFH6FaHy0IAJRg+ixj5gTR1gnT/glAIC8xv4w2VL1LoZfs+Jg==}
'@codemirror/lang-yaml@6.1.2':
resolution: {integrity: sha512-dxrfG8w5Ce/QbT7YID7mWZFKhdhsaTNOYjOkSIMt1qmC4VQnXSDSYVHHHn8k6kJUfIhtLo8t1JJgltlxWdsITw==}
'@codemirror/language@6.12.1':
resolution: {integrity: sha512-Fa6xkSiuGKc8XC8Cn96T+TQHYj4ZZ7RdFmXA3i9xe/3hLHfwPZdM+dqfX0Cp0zQklBKhVD8Yzc8LS45rkqcwpQ==}
'@codemirror/legacy-modes@6.5.2':
resolution: {integrity: sha512-/jJbwSTazlQEDOQw2FJ8LEEKVS72pU0lx6oM54kGpL8t/NJ2Jda3CZ4pcltiKTdqYSRk3ug1B3pil1gsjA6+8Q==}
'@codemirror/lint@6.9.3':
resolution: {integrity: sha512-y3YkYhdnhjDBAe0VIA0c4wVoFOvnp8CnAvfLqi0TqotIv92wIlAAP7HELOpLBsKwjAX6W92rSflA6an/2zBvXw==}
'@codemirror/search@6.6.0':
resolution: {integrity: sha512-koFuNXcDvyyotWcgOnZGmY7LZqEOXZaaxD/j6n18TCLx2/9HieZJ5H6hs1g8FiRxBD0DNfs0nXn17g872RmYdw==}
'@codemirror/state@6.5.4':
resolution: {integrity: sha512-8y7xqG/hpB53l25CIoit9/ngxdfoG+fx+V3SHBrinnhOtLvKHRyAJJuHzkWrR4YXXLX8eXBsejgAAxHUOdW1yw==}
'@codemirror/theme-one-dark@6.1.3':
resolution: {integrity: sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==}
'@codemirror/view@6.39.11':
resolution: {integrity: sha512-bWdeR8gWM87l4DB/kYSF9A+dVackzDb/V56Tq7QVrQ7rn86W0rgZFtlL3g3pem6AeGcb9NQNoy3ao4WpW4h5tQ==}
'@csstools/color-helpers@5.1.0':
resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==}
engines: {node: '>=18'}
@@ -399,6 +590,60 @@ packages:
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@lezer/common@1.5.0':
resolution: {integrity: sha512-PNGcolp9hr4PJdXR4ix7XtixDrClScvtSCYW3rQG106oVMOOI+jFb+0+J3mbeL/53g1Zd6s0kJzaw6Ri68GmAA==}
'@lezer/cpp@1.1.5':
resolution: {integrity: sha512-DIhSXmYtJKLehrjzDFN+2cPt547ySQ41nA8yqcDf/GxMc+YM736xqltFkvADL2M0VebU5I+3+4ks2Vv+Kyq3Aw==}
'@lezer/css@1.3.0':
resolution: {integrity: sha512-pBL7hup88KbI7hXnZV3PQsn43DHy6TWyzuyk2AO9UyoXcDltvIdqWKE1dLL/45JVZ+YZkHe1WVHqO6wugZZWcw==}
'@lezer/go@1.0.1':
resolution: {integrity: sha512-xToRsYxwsgJNHTgNdStpcvmbVuKxTapV0dM0wey1geMMRc9aggoVyKgzYp41D2/vVOx+Ii4hmE206kvxIXBVXQ==}
'@lezer/highlight@1.2.3':
resolution: {integrity: sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==}
'@lezer/html@1.3.13':
resolution: {integrity: sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==}
'@lezer/java@1.1.3':
resolution: {integrity: sha512-yHquUfujwg6Yu4Fd1GNHCvidIvJwi/1Xu2DaKl/pfWIA2c1oXkVvawH3NyXhCaFx4OdlYBVX5wvz2f7Aoa/4Xw==}
'@lezer/javascript@1.5.4':
resolution: {integrity: sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==}
'@lezer/json@1.0.3':
resolution: {integrity: sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==}
'@lezer/lr@1.4.8':
resolution: {integrity: sha512-bPWa0Pgx69ylNlMlPvBPryqeLYQjyJjqPx+Aupm5zydLIF3NE+6MMLT8Yi23Bd9cif9VS00aUebn+6fDIGBcDA==}
'@lezer/markdown@1.6.3':
resolution: {integrity: sha512-jpGm5Ps+XErS+xA4urw7ogEGkeZOahVQF21Z6oECF0sj+2liwZopd2+I8uH5I/vZsRuuze3OxBREIANLf6KKUw==}
'@lezer/php@1.0.5':
resolution: {integrity: sha512-W7asp9DhM6q0W6DYNwIkLSKOvxlXRrif+UXBMxzsJUuqmhE7oVU+gS3THO4S/Puh7Xzgm858UNaFi6dxTP8dJA==}
'@lezer/python@1.1.18':
resolution: {integrity: sha512-31FiUrU7z9+d/ElGQLJFXl+dKOdx0jALlP3KEOsGTex8mvj+SoE1FgItcHWK/axkxCHGUSpqIHt6JAWfWu9Rhg==}
'@lezer/rust@1.0.2':
resolution: {integrity: sha512-Lz5sIPBdF2FUXcWeCu1//ojFAZqzTQNRga0aYv6dYXqJqPfMdCAI0NzajWUd4Xijj1IKJLtjoXRPMvTKWBcqKg==}
'@lezer/sass@1.1.0':
resolution: {integrity: sha512-3mMGdCTUZ/84ArHOuXWQr37pnf7f+Nw9ycPUeKX+wu19b7pSMcZGLbaXwvD2APMBDOGxPmpK/O6S1v1EvLoqgQ==}
'@lezer/xml@1.0.6':
resolution: {integrity: sha512-CdDwirL0OEaStFue/66ZmFSeppuL6Dwjlk8qk153mSQwiSH/Dlri4GNymrNWnUmPl2Um7QfV1FO9KFUyX3Twww==}
'@lezer/yaml@1.0.3':
resolution: {integrity: sha512-GuBLekbw9jDBDhGur82nuwkxKQ+a3W5H0GfaAthDXcAu+XdpS43VlnxA9E9hllkpSP5ellRDKjLLj7Lu9Wr6xA==}
'@marijn/find-cluster-break@1.0.2':
resolution: {integrity: sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==}
'@polka/url@1.0.0-next.29':
resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
@@ -735,9 +980,15 @@ packages:
engines: {node: '>= 10'}
hasBin: true
'@tauri-apps/plugin-clipboard-manager@2.3.2':
resolution: {integrity: sha512-CUlb5Hqi2oZbcZf4VUyUH53XWPPdtpw43EUpCza5HWZJwxEoDowFzNUDt1tRUXA8Uq+XPn17Ysfptip33sG4eQ==}
'@tauri-apps/plugin-dialog@2.6.0':
resolution: {integrity: sha512-q4Uq3eY87TdcYzXACiYSPhmpBA76shgmQswGkSVio4C82Sz2W4iehe9TnKYwbq7weHiL88Yw19XZm7v28+Micg==}
'@tauri-apps/plugin-fs@2.4.5':
resolution: {integrity: sha512-dVxWWGE6VrOxC7/jlhyE+ON/Cc2REJlM35R3PJX3UvFw2XwYhLGQVAIyrehenDdKjotipjYEVc4YjOl3qq90fA==}
'@tauri-apps/plugin-notification@2.3.3':
resolution: {integrity: sha512-Zw+ZH18RJb41G4NrfHgIuofJiymusqN+q8fGUIIV7vyCH+5sSn5coqRv/MWB9qETsUs97vmU045q7OyseCV3Qg==}
@@ -857,6 +1108,15 @@ packages:
resolution: {integrity: sha512-LZ2NqIHFhvFwxG0qZeLL9DvdNAHPGCY5dIRwBhyYeU+LfLhcStE1ImjsuTG/WaVh3XysGaeLW8Rqq7cGkPCFvw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@vitest/coverage-v8@4.0.18':
resolution: {integrity: sha512-7i+N2i0+ME+2JFZhfuz7Tg/FqKtilHjGyGvoHYQ6iLV0zahbsJ9sljC9OcFcPDbhYKCet+sG8SsVqlyGvPflZg==}
peerDependencies:
'@vitest/browser': 4.0.18
vitest: 4.0.18
peerDependenciesMeta:
'@vitest/browser':
optional: true
'@vitest/expect@4.0.17':
resolution: {integrity: sha512-mEoqP3RqhKlbmUmntNDDCJeTDavDR+fVYkSOw8qRwJFaW/0/5zA9zFeTrHqNtcmwh6j26yMmwx2PqUDPzt5ZAQ==}
@@ -874,6 +1134,9 @@ packages:
'@vitest/pretty-format@4.0.17':
resolution: {integrity: sha512-Ah3VAYmjcEdHg6+MwFE17qyLqBHZ+ni2ScKCiW2XrlSBV4H3Z7vYfPfz7CWQ33gyu76oc0Ai36+kgLU3rfF4nw==}
'@vitest/pretty-format@4.0.18':
resolution: {integrity: sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==}
'@vitest/runner@4.0.17':
resolution: {integrity: sha512-JmuQyf8aMWoo/LmNFppdpkfRVHJcsgzkbCA+/Bk7VfNH7RE6Ut2qxegeyx2j3ojtJtKIbIGy3h+KxGfYfk28YQ==}
@@ -886,6 +1149,9 @@ packages:
'@vitest/utils@4.0.17':
resolution: {integrity: sha512-RG6iy+IzQpa9SB8HAFHJ9Y+pTzI+h8553MrciN9eC6TFBErqrQaTas4vG+MVj8S4uKk8uTT2p0vgZPnTdxd96w==}
'@vitest/utils@4.0.18':
resolution: {integrity: sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==}
acorn-jsx@5.3.2:
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
peerDependencies:
@@ -929,6 +1195,9 @@ packages:
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
engines: {node: '>=12'}
ast-v8-to-istanbul@0.3.10:
resolution: {integrity: sha512-p4K7vMz2ZSk3wN8l5o3y2bJAoZXT3VuJI5OLTATY/01CYWumWvwkUw0SqDBnNq6IiTO3qDa1eSQDibAV8g7XOQ==}
axobject-query@4.1.0:
resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==}
engines: {node: '>= 0.4'}
@@ -965,6 +1234,9 @@ packages:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'}
codemirror@6.0.2:
resolution: {integrity: sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==}
color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'}
@@ -979,6 +1251,9 @@ packages:
resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==}
engines: {node: '>= 0.6'}
crelt@1.0.6:
resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==}
cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
@@ -1199,6 +1474,9 @@ packages:
resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
html-escaper@2.0.2:
resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
http-proxy-agent@7.0.2:
resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==}
engines: {node: '>= 14'}
@@ -1244,6 +1522,18 @@ packages:
isexe@2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
istanbul-lib-coverage@3.2.2:
resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==}
engines: {node: '>=8'}
istanbul-lib-report@3.0.1:
resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==}
engines: {node: '>=10'}
istanbul-reports@3.2.0:
resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==}
engines: {node: '>=8'}
jiti@2.6.1:
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
hasBin: true
@@ -1251,6 +1541,9 @@ packages:
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
js-tokens@9.0.1:
resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==}
js-yaml@4.1.1:
resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==}
hasBin: true
@@ -1382,6 +1675,13 @@ packages:
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
magicast@0.5.1:
resolution: {integrity: sha512-xrHS24IxaLrvuo613F719wvOIv9xPHFWQHuvGUBmPnCA/3MQxKI3b+r7n1jAoDHmsbC5bRhTZYR77invLAxVnw==}
make-dir@4.0.0:
resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==}
engines: {node: '>=10'}
marked@17.0.1:
resolution: {integrity: sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg==}
engines: {node: '>= 20'}
@@ -1588,6 +1888,9 @@ packages:
resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
engines: {node: '>=8'}
style-mod@4.1.3:
resolution: {integrity: sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==}
supports-color@7.2.0:
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
engines: {node: '>=8'}
@@ -1767,6 +2070,9 @@ packages:
jsdom:
optional: true
w3c-keyname@2.2.8:
resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==}
w3c-xmlserializer@5.0.0:
resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
engines: {node: '>=18'}
@@ -1857,10 +2163,233 @@ snapshots:
js-tokens: 4.0.0
picocolors: 1.1.1
'@babel/helper-string-parser@7.27.1': {}
'@babel/helper-validator-identifier@7.28.5': {}
'@babel/parser@7.28.6':
dependencies:
'@babel/types': 7.28.6
'@babel/runtime@7.28.6': {}
'@babel/types@7.28.6':
dependencies:
'@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.28.5
'@bcoe/v8-coverage@1.0.2': {}
'@codemirror/autocomplete@6.20.0':
dependencies:
'@codemirror/language': 6.12.1
'@codemirror/state': 6.5.4
'@codemirror/view': 6.39.11
'@lezer/common': 1.5.0
'@codemirror/commands@6.8.1':
dependencies:
'@codemirror/language': 6.12.1
'@codemirror/state': 6.5.4
'@codemirror/view': 6.39.11
'@lezer/common': 1.5.0
'@codemirror/lang-angular@0.1.4':
dependencies:
'@codemirror/lang-html': 6.4.11
'@codemirror/lang-javascript': 6.2.4
'@codemirror/language': 6.12.1
'@lezer/common': 1.5.0
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.8
'@codemirror/lang-cpp@6.0.3':
dependencies:
'@codemirror/language': 6.12.1
'@lezer/cpp': 1.1.5
'@codemirror/lang-css@6.3.1':
dependencies:
'@codemirror/autocomplete': 6.20.0
'@codemirror/language': 6.12.1
'@codemirror/state': 6.5.4
'@lezer/common': 1.5.0
'@lezer/css': 1.3.0
'@codemirror/lang-go@6.0.1':
dependencies:
'@codemirror/autocomplete': 6.20.0
'@codemirror/language': 6.12.1
'@codemirror/state': 6.5.4
'@lezer/common': 1.5.0
'@lezer/go': 1.0.1
'@codemirror/lang-html@6.4.11':
dependencies:
'@codemirror/autocomplete': 6.20.0
'@codemirror/lang-css': 6.3.1
'@codemirror/lang-javascript': 6.2.4
'@codemirror/language': 6.12.1
'@codemirror/state': 6.5.4
'@codemirror/view': 6.39.11
'@lezer/common': 1.5.0
'@lezer/css': 1.3.0
'@lezer/html': 1.3.13
'@codemirror/lang-java@6.0.2':
dependencies:
'@codemirror/language': 6.12.1
'@lezer/java': 1.1.3
'@codemirror/lang-javascript@6.2.4':
dependencies:
'@codemirror/autocomplete': 6.20.0
'@codemirror/language': 6.12.1
'@codemirror/lint': 6.9.3
'@codemirror/state': 6.5.4
'@codemirror/view': 6.39.11
'@lezer/common': 1.5.0
'@lezer/javascript': 1.5.4
'@codemirror/lang-json@6.0.2':
dependencies:
'@codemirror/language': 6.12.1
'@lezer/json': 1.0.3
'@codemirror/lang-less@6.0.2':
dependencies:
'@codemirror/lang-css': 6.3.1
'@codemirror/language': 6.12.1
'@lezer/common': 1.5.0
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.8
'@codemirror/lang-markdown@6.5.0':
dependencies:
'@codemirror/autocomplete': 6.20.0
'@codemirror/lang-html': 6.4.11
'@codemirror/language': 6.12.1
'@codemirror/state': 6.5.4
'@codemirror/view': 6.39.11
'@lezer/common': 1.5.0
'@lezer/markdown': 1.6.3
'@codemirror/lang-php@6.0.2':
dependencies:
'@codemirror/lang-html': 6.4.11
'@codemirror/language': 6.12.1
'@codemirror/state': 6.5.4
'@lezer/common': 1.5.0
'@lezer/php': 1.0.5
'@codemirror/lang-python@6.2.1':
dependencies:
'@codemirror/autocomplete': 6.20.0
'@codemirror/language': 6.12.1
'@codemirror/state': 6.5.4
'@lezer/common': 1.5.0
'@lezer/python': 1.1.18
'@codemirror/lang-rust@6.0.2':
dependencies:
'@codemirror/language': 6.12.1
'@lezer/rust': 1.0.2
'@codemirror/lang-sass@6.0.2':
dependencies:
'@codemirror/lang-css': 6.3.1
'@codemirror/language': 6.12.1
'@codemirror/state': 6.5.4
'@lezer/common': 1.5.0
'@lezer/sass': 1.1.0
'@codemirror/lang-sql@6.10.0':
dependencies:
'@codemirror/autocomplete': 6.20.0
'@codemirror/language': 6.12.1
'@codemirror/state': 6.5.4
'@lezer/common': 1.5.0
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.8
'@codemirror/lang-vue@0.1.3':
dependencies:
'@codemirror/lang-html': 6.4.11
'@codemirror/lang-javascript': 6.2.4
'@codemirror/language': 6.12.1
'@lezer/common': 1.5.0
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.8
'@codemirror/lang-wast@6.0.2':
dependencies:
'@codemirror/language': 6.12.1
'@lezer/common': 1.5.0
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.8
'@codemirror/lang-xml@6.1.0':
dependencies:
'@codemirror/autocomplete': 6.20.0
'@codemirror/language': 6.12.1
'@codemirror/state': 6.5.4
'@codemirror/view': 6.39.11
'@lezer/common': 1.5.0
'@lezer/xml': 1.0.6
'@codemirror/lang-yaml@6.1.2':
dependencies:
'@codemirror/autocomplete': 6.20.0
'@codemirror/language': 6.12.1
'@codemirror/state': 6.5.4
'@lezer/common': 1.5.0
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.8
'@lezer/yaml': 1.0.3
'@codemirror/language@6.12.1':
dependencies:
'@codemirror/state': 6.5.4
'@codemirror/view': 6.39.11
'@lezer/common': 1.5.0
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.8
style-mod: 4.1.3
'@codemirror/legacy-modes@6.5.2':
dependencies:
'@codemirror/language': 6.12.1
'@codemirror/lint@6.9.3':
dependencies:
'@codemirror/state': 6.5.4
'@codemirror/view': 6.39.11
crelt: 1.0.6
'@codemirror/search@6.6.0':
dependencies:
'@codemirror/state': 6.5.4
'@codemirror/view': 6.39.11
crelt: 1.0.6
'@codemirror/state@6.5.4':
dependencies:
'@marijn/find-cluster-break': 1.0.2
'@codemirror/theme-one-dark@6.1.3':
dependencies:
'@codemirror/language': 6.12.1
'@codemirror/state': 6.5.4
'@codemirror/view': 6.39.11
'@lezer/highlight': 1.2.3
'@codemirror/view@6.39.11':
dependencies:
'@codemirror/state': 6.5.4
crelt: 1.0.6
style-mod: 4.1.3
w3c-keyname: 2.2.8
'@csstools/color-helpers@5.1.0': {}
'@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)':
@@ -2039,6 +2568,101 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
'@lezer/common@1.5.0': {}
'@lezer/cpp@1.1.5':
dependencies:
'@lezer/common': 1.5.0
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.8
'@lezer/css@1.3.0':
dependencies:
'@lezer/common': 1.5.0
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.8
'@lezer/go@1.0.1':
dependencies:
'@lezer/common': 1.5.0
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.8
'@lezer/highlight@1.2.3':
dependencies:
'@lezer/common': 1.5.0
'@lezer/html@1.3.13':
dependencies:
'@lezer/common': 1.5.0
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.8
'@lezer/java@1.1.3':
dependencies:
'@lezer/common': 1.5.0
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.8
'@lezer/javascript@1.5.4':
dependencies:
'@lezer/common': 1.5.0
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.8
'@lezer/json@1.0.3':
dependencies:
'@lezer/common': 1.5.0
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.8
'@lezer/lr@1.4.8':
dependencies:
'@lezer/common': 1.5.0
'@lezer/markdown@1.6.3':
dependencies:
'@lezer/common': 1.5.0
'@lezer/highlight': 1.2.3
'@lezer/php@1.0.5':
dependencies:
'@lezer/common': 1.5.0
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.8
'@lezer/python@1.1.18':
dependencies:
'@lezer/common': 1.5.0
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.8
'@lezer/rust@1.0.2':
dependencies:
'@lezer/common': 1.5.0
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.8
'@lezer/sass@1.1.0':
dependencies:
'@lezer/common': 1.5.0
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.8
'@lezer/xml@1.0.6':
dependencies:
'@lezer/common': 1.5.0
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.8
'@lezer/yaml@1.0.3':
dependencies:
'@lezer/common': 1.5.0
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.8
'@marijn/find-cluster-break@1.0.2': {}
'@polka/url@1.0.0-next.29': {}
'@rollup/rollup-android-arm-eabi@4.55.1':
@@ -2286,10 +2910,18 @@ snapshots:
'@tauri-apps/cli-win32-ia32-msvc': 2.9.6
'@tauri-apps/cli-win32-x64-msvc': 2.9.6
'@tauri-apps/plugin-clipboard-manager@2.3.2':
dependencies:
'@tauri-apps/api': 2.9.1
'@tauri-apps/plugin-dialog@2.6.0':
dependencies:
'@tauri-apps/api': 2.9.1
'@tauri-apps/plugin-fs@2.4.5':
dependencies:
'@tauri-apps/api': 2.9.1
'@tauri-apps/plugin-notification@2.3.3':
dependencies:
'@tauri-apps/api': 2.9.1
@@ -2449,6 +3081,20 @@ snapshots:
'@typescript-eslint/types': 8.53.0
eslint-visitor-keys: 4.2.1
'@vitest/coverage-v8@4.0.18(vitest@4.0.17(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2))':
dependencies:
'@bcoe/v8-coverage': 1.0.2
'@vitest/utils': 4.0.18
ast-v8-to-istanbul: 0.3.10
istanbul-lib-coverage: 3.2.2
istanbul-lib-report: 3.0.1
istanbul-reports: 3.2.0
magicast: 0.5.1
obug: 2.1.1
std-env: 3.10.0
tinyrainbow: 3.0.3
vitest: 4.0.17(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)
'@vitest/expect@4.0.17':
dependencies:
'@standard-schema/spec': 1.1.0
@@ -2470,6 +3116,10 @@ snapshots:
dependencies:
tinyrainbow: 3.0.3
'@vitest/pretty-format@4.0.18':
dependencies:
tinyrainbow: 3.0.3
'@vitest/runner@4.0.17':
dependencies:
'@vitest/utils': 4.0.17
@@ -2488,6 +3138,11 @@ snapshots:
'@vitest/pretty-format': 4.0.17
tinyrainbow: 3.0.3
'@vitest/utils@4.0.18':
dependencies:
'@vitest/pretty-format': 4.0.18
tinyrainbow: 3.0.3
acorn-jsx@5.3.2(acorn@8.15.0):
dependencies:
acorn: 8.15.0
@@ -2521,6 +3176,12 @@ snapshots:
assertion-error@2.0.1: {}
ast-v8-to-istanbul@0.3.10:
dependencies:
'@jridgewell/trace-mapping': 0.3.31
estree-walker: 3.0.3
js-tokens: 9.0.1
axobject-query@4.1.0: {}
balanced-match@1.0.2: {}
@@ -2553,6 +3214,16 @@ snapshots:
clsx@2.1.1: {}
codemirror@6.0.2:
dependencies:
'@codemirror/autocomplete': 6.20.0
'@codemirror/commands': 6.8.1
'@codemirror/language': 6.12.1
'@codemirror/lint': 6.9.3
'@codemirror/search': 6.6.0
'@codemirror/state': 6.5.4
'@codemirror/view': 6.39.11
color-convert@2.0.1:
dependencies:
color-name: 1.1.4
@@ -2563,6 +3234,8 @@ snapshots:
cookie@0.6.0: {}
crelt@1.0.6: {}
cross-spawn@7.0.6:
dependencies:
path-key: 3.1.1
@@ -2803,6 +3476,8 @@ snapshots:
transitivePeerDependencies:
- '@exodus/crypto'
html-escaper@2.0.2: {}
http-proxy-agent@7.0.2:
dependencies:
agent-base: 7.1.4
@@ -2844,10 +3519,25 @@ snapshots:
isexe@2.0.0: {}
istanbul-lib-coverage@3.2.2: {}
istanbul-lib-report@3.0.1:
dependencies:
istanbul-lib-coverage: 3.2.2
make-dir: 4.0.0
supports-color: 7.2.0
istanbul-reports@3.2.0:
dependencies:
html-escaper: 2.0.2
istanbul-lib-report: 3.0.1
jiti@2.6.1: {}
js-tokens@4.0.0: {}
js-tokens@9.0.1: {}
js-yaml@4.1.1:
dependencies:
argparse: 2.0.1
@@ -2966,6 +3656,16 @@ snapshots:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
magicast@0.5.1:
dependencies:
'@babel/parser': 7.28.6
'@babel/types': 7.28.6
source-map-js: 1.2.1
make-dir@4.0.0:
dependencies:
semver: 7.7.3
marked@17.0.1: {}
mdn-data@2.12.2: {}
@@ -3152,6 +3852,8 @@ snapshots:
strip-json-comments@3.1.1: {}
style-mod@4.1.3: {}
supports-color@7.2.0:
dependencies:
has-flag: 4.0.0
@@ -3311,6 +4013,8 @@ snapshots:
- tsx
- yaml
w3c-keyname@2.2.8: {}
w3c-xmlserializer@5.0.0:
dependencies:
xml-name-validator: 5.0.0
+500 -7
View File
@@ -47,6 +47,27 @@ version = "1.0.100"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
[[package]]
name = "arboard"
version = "3.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf"
dependencies = [
"clipboard-win",
"image",
"log",
"objc2",
"objc2-app-kit",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-foundation",
"parking_lot",
"percent-encoding",
"windows-sys 0.60.2",
"wl-clipboard-rs",
"x11rb",
]
[[package]]
name = "async-broadcast"
version = "0.7.2"
@@ -310,6 +331,12 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "byteorder-lite"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
[[package]]
name = "bytes"
version = "1.11.0"
@@ -449,6 +476,15 @@ dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "clipboard-win"
version = "5.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4"
dependencies = [
"error-code",
]
[[package]]
name = "combine"
version = "4.6.7"
@@ -556,7 +592,7 @@ dependencies = [
"bitflags 2.10.0",
"core-foundation 0.10.1",
"core-graphics-types",
"foreign-types",
"foreign-types 0.5.0",
"libc",
]
@@ -604,6 +640,12 @@ version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
[[package]]
name = "crunchy"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]]
name = "crypto-common"
version = "0.1.7"
@@ -807,6 +849,12 @@ dependencies = [
"litrs",
]
[[package]]
name = "downcast-rs"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
[[package]]
name = "dpi"
version = "0.1.2"
@@ -926,6 +974,12 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "error-code"
version = "3.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59"
[[package]]
name = "event-listener"
version = "5.4.1"
@@ -953,6 +1007,26 @@ version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
[[package]]
name = "fax"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f05de7d48f37cd6730705cbca900770cab77a89f413d23e100ad7fad7795a0ab"
dependencies = [
"fax_derive",
]
[[package]]
name = "fax_derive"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
]
[[package]]
name = "fdeflate"
version = "0.3.7"
@@ -978,6 +1052,12 @@ version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f449e6c6c08c865631d4890cfacf252b3d396c9bcc83adb6623cdb02a8336c41"
[[package]]
name = "fixedbitset"
version = "0.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
[[package]]
name = "flate2"
version = "1.1.8"
@@ -994,6 +1074,21 @@ version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
name = "foldhash"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "foreign-types"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
dependencies = [
"foreign-types-shared 0.1.1",
]
[[package]]
name = "foreign-types"
version = "0.5.0"
@@ -1001,7 +1096,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
dependencies = [
"foreign-types-macros",
"foreign-types-shared",
"foreign-types-shared 0.3.1",
]
[[package]]
@@ -1015,6 +1110,12 @@ dependencies = [
"syn 2.0.114",
]
[[package]]
name = "foreign-types-shared"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
[[package]]
name = "foreign-types-shared"
version = "0.3.1"
@@ -1458,12 +1559,32 @@ dependencies = [
"tracing",
]
[[package]]
name = "half"
version = "2.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
dependencies = [
"cfg-if",
"crunchy",
"zerocopy",
]
[[package]]
name = "hashbrown"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
[[package]]
name = "hashbrown"
version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
"foldhash",
]
[[package]]
name = "hashbrown"
version = "0.16.1"
@@ -1496,16 +1617,21 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hikari-desktop"
version = "0.2.0"
version = "1.1.1"
dependencies = [
"async-trait",
"chrono",
"futures-util",
"parking_lot",
"reqwest",
"semver",
"serde",
"serde_json",
"tauri",
"tauri-build",
"tauri-plugin-clipboard-manager",
"tauri-plugin-dialog",
"tauri-plugin-fs",
"tauri-plugin-http",
"tauri-plugin-notification",
"tauri-plugin-opener",
@@ -1608,6 +1734,22 @@ dependencies = [
"webpki-roots",
]
[[package]]
name = "hyper-tls"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
dependencies = [
"bytes",
"http-body-util",
"hyper",
"hyper-util",
"native-tls",
"tokio",
"tokio-native-tls",
"tower-service",
]
[[package]]
name = "hyper-util"
version = "0.1.19"
@@ -1665,7 +1807,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc50b891e4acf8fe0e71ef88ec43ad82ee07b3810ad09de10f1d01f072ed4b98"
dependencies = [
"byteorder",
"png",
"png 0.17.16",
]
[[package]]
@@ -1776,6 +1918,20 @@ dependencies = [
"icu_properties",
]
[[package]]
name = "image"
version = "0.25.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a"
dependencies = [
"bytemuck",
"byteorder-lite",
"moxcms",
"num-traits",
"png 0.18.0",
"tiff",
]
[[package]]
name = "indexmap"
version = "1.9.3"
@@ -2135,6 +2291,16 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "moxcms"
version = "0.7.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac9557c559cd6fc9867e122e20d2cbefc9ca29d80d027a8e39310920ed2f0a97"
dependencies = [
"num-traits",
"pxfm",
]
[[package]]
name = "muda"
version = "0.17.1"
@@ -2150,12 +2316,29 @@ dependencies = [
"objc2-core-foundation",
"objc2-foundation",
"once_cell",
"png",
"png 0.17.16",
"serde",
"thiserror 2.0.17",
"windows-sys 0.60.2",
]
[[package]]
name = "native-tls"
version = "0.2.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e"
dependencies = [
"libc",
"log",
"openssl",
"openssl-probe",
"openssl-sys",
"schannel",
"security-framework",
"security-framework-sys",
"tempfile",
]
[[package]]
name = "ndk"
version = "0.9.0"
@@ -2210,6 +2393,15 @@ version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb"
[[package]]
name = "nom"
version = "8.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405"
dependencies = [
"memchr",
]
[[package]]
name = "notify-rust"
version = "4.11.7"
@@ -2520,6 +2712,50 @@ dependencies = [
"pathdiff",
]
[[package]]
name = "openssl"
version = "0.10.75"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328"
dependencies = [
"bitflags 2.10.0",
"cfg-if",
"foreign-types 0.3.2",
"libc",
"once_cell",
"openssl-macros",
"openssl-sys",
]
[[package]]
name = "openssl-macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
]
[[package]]
name = "openssl-probe"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
[[package]]
name = "openssl-sys"
version = "0.9.111"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321"
dependencies = [
"cc",
"libc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "option-ext"
version = "0.2.0"
@@ -2628,6 +2864,17 @@ version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "petgraph"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455"
dependencies = [
"fixedbitset",
"hashbrown 0.15.5",
"indexmap 2.13.0",
]
[[package]]
name = "phf"
version = "0.8.0"
@@ -2817,6 +3064,19 @@ dependencies = [
"miniz_oxide",
]
[[package]]
name = "png"
version = "0.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97baced388464909d42d89643fe4361939af9b7ce7a31ee32a168f832a70f2a0"
dependencies = [
"bitflags 2.10.0",
"crc32fast",
"fdeflate",
"flate2",
"miniz_oxide",
]
[[package]]
name = "polling"
version = "3.11.0"
@@ -2945,6 +3205,21 @@ dependencies = [
"psl-types",
]
[[package]]
name = "pxfm"
version = "0.1.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7186d3822593aa4393561d186d1393b3923e9d6163d3fbfd6e825e3e6cf3e6a8"
dependencies = [
"num-traits",
]
[[package]]
name = "quick-error"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
[[package]]
name = "quick-xml"
version = "0.37.5"
@@ -3237,10 +3512,12 @@ dependencies = [
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-tls",
"hyper-util",
"js-sys",
"log",
"mime",
"native-tls",
"percent-encoding",
"pin-project-lite",
"quinn",
@@ -3251,6 +3528,7 @@ dependencies = [
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-native-tls",
"tokio-rustls",
"tokio-util",
"tower",
@@ -3386,6 +3664,15 @@ dependencies = [
"winapi-util",
]
[[package]]
name = "schannel"
version = "0.1.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "schemars"
version = "0.8.22"
@@ -3443,6 +3730,29 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "security-framework"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
dependencies = [
"bitflags 2.10.0",
"core-foundation 0.9.4",
"core-foundation-sys",
"libc",
"security-framework-sys",
]
[[package]]
name = "security-framework-sys"
version = "2.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "selectors"
version = "0.24.0"
@@ -4001,6 +4311,7 @@ dependencies = [
"gtk",
"heck 0.5.0",
"http",
"image",
"jni",
"libc",
"log",
@@ -4068,7 +4379,7 @@ dependencies = [
"ico",
"json-patch",
"plist",
"png",
"png 0.17.16",
"proc-macro2",
"quote",
"semver",
@@ -4115,6 +4426,21 @@ dependencies = [
"walkdir",
]
[[package]]
name = "tauri-plugin-clipboard-manager"
version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "206dc20af4ed210748ba945c2774e60fd0acd52b9a73a028402caf809e9b6ecf"
dependencies = [
"arboard",
"log",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"thiserror 2.0.17",
]
[[package]]
name = "tauri-plugin-dialog"
version = "2.6.0"
@@ -4452,6 +4778,20 @@ dependencies = [
"syn 2.0.114",
]
[[package]]
name = "tiff"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af9605de7fee8d9551863fd692cce7637f548dbd9db9180fcc07ccc6d26c336f"
dependencies = [
"fax",
"flate2",
"half",
"quick-error",
"weezl",
"zune-jpeg",
]
[[package]]
name = "time"
version = "0.3.45"
@@ -4536,6 +4876,16 @@ dependencies = [
"syn 2.0.114",
]
[[package]]
name = "tokio-native-tls"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
dependencies = [
"native-tls",
"tokio",
]
[[package]]
name = "tokio-rustls"
version = "0.26.4"
@@ -4747,12 +5097,23 @@ dependencies = [
"objc2-core-graphics",
"objc2-foundation",
"once_cell",
"png",
"png 0.17.16",
"serde",
"thiserror 2.0.17",
"windows-sys 0.60.2",
]
[[package]]
name = "tree_magic_mini"
version = "3.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6"
dependencies = [
"memchr",
"nom",
"petgraph",
]
[[package]]
name = "try-lock"
version = "0.2.5"
@@ -4890,6 +5251,12 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "version-compare"
version = "0.2.1"
@@ -5034,6 +5401,76 @@ dependencies = [
"web-sys",
]
[[package]]
name = "wayland-backend"
version = "0.3.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fee64194ccd96bf648f42a65a7e589547096dfa702f7cadef84347b66ad164f9"
dependencies = [
"cc",
"downcast-rs",
"rustix",
"smallvec",
"wayland-sys",
]
[[package]]
name = "wayland-client"
version = "0.31.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8e6faa537fbb6c186cb9f1d41f2f811a4120d1b57ec61f50da451a0c5122bec"
dependencies = [
"bitflags 2.10.0",
"rustix",
"wayland-backend",
"wayland-scanner",
]
[[package]]
name = "wayland-protocols"
version = "0.32.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baeda9ffbcfc8cd6ddaade385eaf2393bd2115a69523c735f12242353c3df4f3"
dependencies = [
"bitflags 2.10.0",
"wayland-backend",
"wayland-client",
"wayland-scanner",
]
[[package]]
name = "wayland-protocols-wlr"
version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e9597cdf02cf0c34cd5823786dce6b5ae8598f05c2daf5621b6e178d4f7345f3"
dependencies = [
"bitflags 2.10.0",
"wayland-backend",
"wayland-client",
"wayland-protocols",
"wayland-scanner",
]
[[package]]
name = "wayland-scanner"
version = "0.31.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5423e94b6a63e68e439803a3e153a9252d5ead12fd853334e2ad33997e3889e3"
dependencies = [
"proc-macro2",
"quick-xml 0.38.4",
"quote",
]
[[package]]
name = "wayland-sys"
version = "0.31.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e6dbfc3ac5ef974c92a2235805cc0114033018ae1290a72e474aa8b28cbbdfd"
dependencies = [
"pkg-config",
]
[[package]]
name = "web-sys"
version = "0.3.85"
@@ -5143,6 +5580,12 @@ dependencies = [
"windows-core 0.61.2",
]
[[package]]
name = "weezl"
version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
[[package]]
name = "winapi"
version = "0.3.9"
@@ -5673,6 +6116,24 @@ version = "0.46.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59"
[[package]]
name = "wl-clipboard-rs"
version = "0.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e9651471a32e87d96ef3a127715382b2d11cc7c8bb9822ded8a7cc94072eb0a3"
dependencies = [
"libc",
"log",
"os_pipe",
"rustix",
"thiserror 2.0.17",
"tree_magic_mini",
"wayland-backend",
"wayland-client",
"wayland-protocols",
"wayland-protocols-wlr",
]
[[package]]
name = "writeable"
version = "0.6.2"
@@ -5745,6 +6206,23 @@ dependencies = [
"pkg-config",
]
[[package]]
name = "x11rb"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414"
dependencies = [
"gethostname",
"rustix",
"x11rb-protocol",
]
[[package]]
name = "x11rb-protocol"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
[[package]]
name = "yoke"
version = "0.8.1"
@@ -5915,6 +6393,21 @@ version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bd8f3f50b848df28f887acb68e41201b5aea6bc8a8dacc00fb40635ff9a72fea"
[[package]]
name = "zune-core"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a"
[[package]]
name = "zune-jpeg"
version = "0.4.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713"
dependencies = [
"zune-core",
]
[[package]]
name = "zvariant"
version = "5.9.1"
+7 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "hikari-desktop"
version = "0.3.0"
version = "1.1.1"
description = "Hikari - Claude Code Visual Assistant"
authors = ["Naomi Carrigan"]
edition = "2021"
@@ -13,7 +13,7 @@ crate-type = ["staticlib", "cdylib", "rlib"]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
tauri = { version = "2", features = ["tray-icon", "image-png"] }
tauri-plugin-dialog = "2"
tauri-plugin-opener = "2"
tauri-plugin-shell = "2"
@@ -26,9 +26,14 @@ tauri-plugin-store = "2.4.2"
tauri-plugin-notification = "2"
tauri-plugin-os = "2"
tauri-plugin-http = "2"
tauri-plugin-clipboard-manager = "2"
tauri-plugin-fs = "2"
tempfile = "3"
semver = "1"
chrono = { version = "0.4.43", features = ["serde"] }
async-trait = "0.1"
reqwest = { version = "0.12", features = ["json", "stream"] }
futures-util = "0.3"
[target.'cfg(windows)'.dependencies]
windows = { version = "0.62", features = [
+18 -1
View File
@@ -13,6 +13,23 @@
"notification:default",
"notification:allow-is-permission-granted",
"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
+15 -3
View File
@@ -4,11 +4,11 @@ use std::sync::Arc;
use tauri::AppHandle;
use crate::config::ClaudeStartOptions;
use crate::provider_bridge::ProviderBridge;
use crate::stats::UsageStats;
use crate::wsl_bridge::WslBridge;
pub struct BridgeManager {
bridges: HashMap<String, WslBridge>,
bridges: HashMap<String, ProviderBridge>,
app_handle: Option<AppHandle>,
}
@@ -45,12 +45,24 @@ impl BridgeManager {
.ok_or_else(|| "App handle not set".to_string())?
.clone();
// Check if existing bridge matches the requested provider type
// If provider type changed, create a new bridge
let should_recreate = self.bridges.get(conversation_id).map_or(false, |bridge| {
bridge.provider_type() != options.provider_type
});
if should_recreate {
// Remove existing bridge if provider type changed
self.bridges.remove(conversation_id);
}
// Reuse existing bridge if it exists (preserves stats across reconnects)
// Only create a new bridge if one doesn't exist for this conversation
let provider_type = options.provider_type;
let bridge = self
.bridges
.entry(conversation_id.to_string())
.or_insert_with(|| WslBridge::new_with_conversation_id(conversation_id.to_string()));
.or_insert_with(|| ProviderBridge::new(provider_type, conversation_id.to_string()));
// Start the Claude process
bridge.start(app, options)?;
+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));
}
}
+517
View File
@@ -1,3 +1,4 @@
use std::path::PathBuf;
use tauri::{AppHandle, State};
use tauri_plugin_http::reqwest;
use tauri_plugin_store::StoreExt;
@@ -6,6 +7,7 @@ use crate::achievements::{get_achievement_info, load_achievements, AchievementUn
use crate::bridge_manager::SharedBridgeManager;
use crate::config::{ClaudeStartOptions, HikariConfig};
use crate::stats::UsageStats;
use crate::temp_manager::SharedTempFileManager;
const CONFIG_STORE_KEY: &str = "config";
@@ -100,6 +102,19 @@ pub async fn get_usage_stats(
manager.get_usage_stats(&conversation_id)
}
/// Load persisted lifetime stats from store (no bridge required)
#[tauri::command]
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,
@@ -298,3 +313,505 @@ pub async fn check_for_updates() -> Result<UpdateInfo, String> {
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"));
}
}
+205
View File
@@ -1,7 +1,11 @@
use crate::providers::ProviderType;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ClaudeStartOptions {
#[serde(default)]
pub provider_type: ProviderType,
#[serde(default)]
pub working_dir: String,
@@ -25,10 +29,47 @@ pub struct ClaudeStartOptions {
#[serde(default)]
pub resume_session_id: Option<String>,
// Ollama-specific options
#[serde(default = "default_ollama_base_url")]
pub ollama_base_url: String,
#[serde(default)]
pub ollama_model: Option<String>,
// OpenAI-specific options
#[serde(default)]
pub openai_api_key: Option<String>,
#[serde(default = "default_openai_base_url")]
pub openai_base_url: String,
#[serde(default)]
pub openai_model: Option<String>,
// Anthropic-specific options
#[serde(default)]
pub anthropic_api_key: Option<String>,
#[serde(default = "default_anthropic_base_url")]
pub anthropic_base_url: String,
#[serde(default)]
pub anthropic_model: Option<String>,
// Gemini-specific options
#[serde(default)]
pub gemini_api_key: Option<String>,
#[serde(default)]
pub gemini_model: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HikariConfig {
#[serde(default)]
pub provider_type: ProviderType,
#[serde(default)]
pub model: Option<String>,
@@ -44,6 +85,40 @@ pub struct HikariConfig {
#[serde(default)]
pub auto_granted_tools: Vec<String>,
// Ollama-specific settings
#[serde(default = "default_ollama_base_url")]
pub ollama_base_url: String,
#[serde(default)]
pub ollama_model: Option<String>,
// OpenAI-specific settings
#[serde(default)]
pub openai_api_key: Option<String>,
#[serde(default = "default_openai_base_url")]
pub openai_base_url: String,
#[serde(default)]
pub openai_model: Option<String>,
// Anthropic-specific settings
#[serde(default)]
pub anthropic_api_key: Option<String>,
#[serde(default = "default_anthropic_base_url")]
pub anthropic_base_url: String,
#[serde(default)]
pub anthropic_model: Option<String>,
// Gemini-specific settings
#[serde(default)]
pub gemini_api_key: Option<String>,
#[serde(default)]
pub gemini_model: Option<String>,
#[serde(default)]
pub theme: Theme,
@@ -70,16 +145,53 @@ pub struct HikariConfig {
#[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 {
fn default() -> Self {
Self {
provider_type: ProviderType::default(),
model: None,
api_key: None,
custom_instructions: None,
mcp_servers_json: None,
auto_granted_tools: Vec::new(),
ollama_base_url: default_ollama_base_url(),
ollama_model: None,
openai_api_key: None,
openai_base_url: default_openai_base_url(),
openai_model: None,
anthropic_api_key: None,
anthropic_base_url: default_anthropic_base_url(),
anthropic_model: None,
gemini_api_key: None,
gemini_model: None,
theme: Theme::default(),
greeting_enabled: true,
greeting_custom_prompt: None,
@@ -89,6 +201,14 @@ impl Default for HikariConfig {
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(),
}
}
}
@@ -113,12 +233,47 @@ fn default_font_size() -> u32 {
14
}
fn default_ollama_base_url() -> String {
"http://localhost:11434".to_string()
}
fn default_openai_base_url() -> String {
"https://api.openai.com/v1".to_string()
}
fn default_anthropic_base_url() -> String {
"https://api.anthropic.com".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum Theme {
#[default]
Dark,
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)]
@@ -128,11 +283,26 @@ mod tests {
#[test]
fn test_default_config() {
let config = HikariConfig::default();
assert_eq!(config.provider_type, ProviderType::ClaudeCli);
assert!(config.model.is_none());
assert!(config.api_key.is_none());
assert!(config.custom_instructions.is_none());
assert!(config.mcp_servers_json.is_none());
assert!(config.auto_granted_tools.is_empty());
assert_eq!(config.ollama_base_url, "http://localhost:11434");
assert!(config.ollama_model.is_none());
// OpenAI defaults
assert!(config.openai_api_key.is_none());
assert_eq!(config.openai_base_url, "https://api.openai.com/v1");
assert!(config.openai_model.is_none());
// Anthropic defaults
assert!(config.anthropic_api_key.is_none());
assert_eq!(config.anthropic_base_url, "https://api.anthropic.com");
assert!(config.anthropic_model.is_none());
// Gemini defaults
assert!(config.gemini_api_key.is_none());
assert!(config.gemini_model.is_none());
// Other settings
assert_eq!(config.theme, Theme::Dark);
assert!(config.greeting_enabled);
assert!(config.greeting_custom_prompt.is_none());
@@ -140,16 +310,35 @@ mod tests {
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]
fn test_config_serialization() {
let config = HikariConfig {
provider_type: ProviderType::ClaudeCli,
model: Some("claude-sonnet-4-20250514".to_string()),
api_key: None,
custom_instructions: Some("Be helpful".to_string()),
mcp_servers_json: None,
auto_granted_tools: vec!["Read".to_string(), "Glob".to_string()],
ollama_base_url: "http://localhost:11434".to_string(),
ollama_model: None,
openai_api_key: None,
openai_base_url: "https://api.openai.com/v1".to_string(),
openai_model: None,
anthropic_api_key: None,
anthropic_base_url: "https://api.anthropic.com".to_string(),
anthropic_model: None,
gemini_api_key: None,
gemini_model: None,
theme: Theme::Light,
greeting_enabled: true,
greeting_custom_prompt: Some("Hello!".to_string()),
@@ -159,6 +348,14 @@ mod tests {
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();
@@ -179,8 +376,16 @@ mod tests {
fn test_theme_serialization() {
let dark = Theme::Dark;
let light = Theme::Light;
let high_contrast = Theme::HighContrast;
assert_eq!(serde_json::to_string(&dark).unwrap(), "\"dark\"");
assert_eq!(serde_json::to_string(&light).unwrap(), "\"light\"");
assert_eq!(
serde_json::to_string(&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());
}
}
+103
View File
@@ -1,9 +1,18 @@
mod achievements;
mod bridge_manager;
mod clipboard;
mod commands;
mod config;
mod git;
mod notifications;
mod provider_bridge;
mod providers;
mod quick_actions;
mod sessions;
mod snippets;
mod stats;
mod temp_manager;
mod tray;
mod types;
mod vbs_notification;
mod windows_toast;
@@ -11,9 +20,17 @@ mod wsl_bridge;
mod wsl_notifications;
use bridge_manager::create_shared_bridge_manager;
use clipboard::*;
use commands::load_saved_achievements;
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 windows_toast::*;
use wsl_notifications::*;
@@ -21,6 +38,7 @@ use wsl_notifications::*;
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let bridge_manager = create_shared_bridge_manager();
let temp_manager = create_shared_temp_manager().expect("Failed to create temp file manager");
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
@@ -30,10 +48,42 @@ pub fn run() {
.plugin(tauri_plugin_notification::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(temp_manager.clone())
.setup(move |app| {
// Initialize the app handle in the bridge manager
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(())
})
.invoke_handler(tauri::generate_handler![
@@ -47,6 +97,7 @@ pub fn run() {
get_config,
save_config,
get_usage_stats,
get_persisted_stats,
load_saved_achievements,
answer_question,
send_windows_notification,
@@ -58,6 +109,58 @@ pub fn run() {
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!())
.expect("error while running tauri application");
File diff suppressed because it is too large Load Diff
+773
View File
@@ -0,0 +1,773 @@
// This provider wraps the Claude CLI subprocess.
// It will be actively used once providers are fully integrated with BridgeManager.
#![allow(dead_code)]
use async_trait::async_trait;
use std::io::{BufRead, BufReader, Write};
use std::process::{Child, ChildStdin, Command, Stdio};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::thread;
use tempfile::NamedTempFile;
use crate::providers::traits::{
LlmProvider, ModelInfo, ProviderCapabilities, ProviderConfig, ProviderStreamEvent,
QuestionOption, StreamCallback,
};
#[cfg(target_os = "windows")]
use std::os::windows::process::CommandExt;
fn detect_wsl() -> bool {
if let Ok(version) = std::fs::read_to_string("/proc/version") {
let version_lower = version.to_lowercase();
if version_lower.contains("microsoft") || version_lower.contains("wsl") {
return true;
}
}
if std::path::Path::new("/proc/sys/fs/binfmt_misc/WSLInterop").exists() {
return true;
}
if std::env::var("WSL_DISTRO_NAME").is_ok() {
return true;
}
false
}
fn find_claude_binary() -> Option<String> {
let home = std::env::var("HOME").ok()?;
let paths_to_check = [
format!("{}/.local/bin/claude", home),
format!("{}/.claude/local/claude", home),
"/usr/local/bin/claude".to_string(),
"/usr/bin/claude".to_string(),
];
for path in &paths_to_check {
if std::path::Path::new(path).exists() {
return Some(path.clone());
}
}
if let Ok(output) = Command::new("which").arg("claude").output() {
if output.status.success() {
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !path.is_empty() {
return Some(path);
}
}
}
None
}
pub struct ClaudeCliProvider {
config: ProviderConfig,
process: Option<Child>,
stdin: Option<ChildStdin>,
session_id: Option<String>,
mcp_config_file: Option<NamedTempFile>,
is_running: Arc<AtomicBool>,
}
impl ClaudeCliProvider {
pub fn new(config: ProviderConfig) -> Self {
Self {
config,
process: None,
stdin: None,
session_id: None,
mcp_config_file: None,
is_running: Arc::new(AtomicBool::new(false)),
}
}
fn build_command(&mut self) -> Result<Command, String> {
let mcp_config_path = if let Some(ref mcp_json) = self.config.mcp_servers_json {
if !mcp_json.trim().is_empty() {
serde_json::from_str::<serde_json::Value>(mcp_json)
.map_err(|e| format!("Invalid MCP servers JSON: {}", e))?;
let mut temp_file = NamedTempFile::new()
.map_err(|e| format!("Failed to create temp file for MCP config: {}", e))?;
temp_file
.write_all(mcp_json.as_bytes())
.map_err(|e| format!("Failed to write MCP config: {}", e))?;
temp_file
.flush()
.map_err(|e| format!("Failed to flush MCP config: {}", e))?;
let path = temp_file.path().to_string_lossy().to_string();
self.mcp_config_file = Some(temp_file);
Some(path)
} else {
None
}
} else {
None
};
let is_wsl = detect_wsl();
let working_dir = &self.config.working_directory;
let command = if is_wsl {
let claude_path = find_claude_binary().ok_or_else(|| {
"Could not find claude binary. Is Claude Code installed?".to_string()
})?;
let mut cmd = Command::new(&claude_path);
cmd.args([
"--output-format",
"stream-json",
"--input-format",
"stream-json",
"--verbose",
]);
if let Some(ref model) = self.config.model {
if !model.is_empty() {
cmd.args(["--model", model]);
}
}
for tool in &self.config.allowed_tools {
cmd.args(["--allowedTools", tool]);
}
if let Some(ref instructions) = self.config.custom_instructions {
if !instructions.is_empty() {
cmd.args(["--system-prompt", instructions]);
}
}
if let Some(ref mcp_path) = mcp_config_path {
cmd.args(["--mcp-config", mcp_path]);
}
cmd.current_dir(working_dir);
if let Some(ref api_key) = self.config.api_key {
if !api_key.is_empty() {
cmd.env("ANTHROPIC_API_KEY", api_key);
}
}
cmd
} else {
let mut cmd = Command::new("wsl");
let mut claude_cmd = format!("cd '{}' && ", working_dir);
if let Some(ref api_key) = self.config.api_key {
if !api_key.is_empty() {
claude_cmd.push_str(&format!("ANTHROPIC_API_KEY='{}' ", api_key));
}
}
claude_cmd.push_str(
"claude --output-format stream-json --input-format stream-json --verbose",
);
if let Some(ref model) = self.config.model {
if !model.is_empty() {
claude_cmd.push_str(&format!(" --model '{}'", model));
}
}
for tool in &self.config.allowed_tools {
claude_cmd.push_str(&format!(" --allowedTools '{}'", tool));
}
if let Some(ref instructions) = self.config.custom_instructions {
if !instructions.is_empty() {
let escaped = instructions.replace('\'', "'\\''");
claude_cmd.push_str(&format!(" --system-prompt '{}'", escaped));
}
}
if let Some(ref mcp_path) = mcp_config_path {
claude_cmd.push_str(&format!(" --mcp-config '{}'", mcp_path));
}
cmd.args(["-e", "bash", "-lc", &claude_cmd]);
#[cfg(target_os = "windows")]
cmd.creation_flags(0x08000000); // CREATE_NO_WINDOW
cmd
};
Ok(command)
}
}
#[async_trait]
impl LlmProvider for ClaudeCliProvider {
fn name(&self) -> &'static str {
"Claude CLI"
}
fn capabilities(&self) -> ProviderCapabilities {
ProviderCapabilities {
supports_streaming: true,
supports_tools: true,
supports_vision: true,
supports_thinking: true,
supports_mcp: true,
supports_resume_session: true,
max_context_tokens: Some(200_000),
available_models: vec![
ModelInfo {
id: "claude-sonnet-4-20250514".to_string(),
name: "Claude Sonnet 4".to_string(),
description: Some("Fast and intelligent".to_string()),
context_window: Some(200_000),
input_cost_per_mtok: Some(3.0),
output_cost_per_mtok: Some(15.0),
},
ModelInfo {
id: "claude-opus-4-20250514".to_string(),
name: "Claude Opus 4".to_string(),
description: Some("Most capable model".to_string()),
context_window: Some(200_000),
input_cost_per_mtok: Some(15.0),
output_cost_per_mtok: Some(75.0),
},
ModelInfo {
id: "claude-3-5-haiku-20241022".to_string(),
name: "Claude 3.5 Haiku".to_string(),
description: Some("Fast and efficient".to_string()),
context_window: Some(200_000),
input_cost_per_mtok: Some(1.0),
output_cost_per_mtok: Some(5.0),
},
],
}
}
async fn start(&mut self, callback: StreamCallback) -> Result<(), String> {
if self.process.is_some() {
return Err("Process already running".to_string());
}
callback(ProviderStreamEvent::Connected { session_id: None });
let mut command = self.build_command()?;
command
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut child = command
.spawn()
.map_err(|e| format!("Failed to spawn process: {}", e))?;
let stdin = child.stdin.take();
let stdout = child.stdout.take();
let stderr = child.stderr.take();
self.stdin = stdin;
self.process = Some(child);
self.is_running.store(true, Ordering::SeqCst);
let is_running = self.is_running.clone();
let callback = Arc::new(callback);
if let Some(stdout) = stdout {
let callback_clone = callback.clone();
let is_running_clone = is_running.clone();
thread::spawn(move || {
handle_stdout(stdout, callback_clone, is_running_clone);
});
}
if let Some(stderr) = stderr {
let callback_clone = callback.clone();
thread::spawn(move || {
handle_stderr(stderr, callback_clone);
});
}
Ok(())
}
async fn stop(&mut self) -> Result<(), String> {
if let Some(mut process) = self.process.take() {
let _ = process.kill();
let _ = process.wait();
}
self.stdin = None;
self.session_id = None;
self.mcp_config_file = None;
self.is_running.store(false, Ordering::SeqCst);
Ok(())
}
fn is_running(&self) -> bool {
self.is_running.load(Ordering::SeqCst)
}
async fn send_message(&mut self, message: &str) -> Result<(), String> {
let stdin = self.stdin.as_mut().ok_or("Process not running")?;
let input = serde_json::json!({
"type": "user",
"message": {
"role": "user",
"content": [{
"type": "text",
"text": message
}]
}
});
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(())
}
async fn send_tool_result(
&mut self,
tool_use_id: &str,
result: serde_json::Value,
_is_error: bool,
) -> Result<(), String> {
let stdin = self.stdin.as_mut().ok_or("Process not running")?;
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(())
}
fn get_model(&self) -> Option<String> {
self.config.model.clone()
}
fn get_working_directory(&self) -> &str {
&self.config.working_directory
}
async fn interrupt(&mut self) -> Result<(), String> {
if let Some(mut process) = self.process.take() {
let _ = process.kill();
let _ = process.wait();
self.stdin = None;
self.is_running.store(false, Ordering::SeqCst);
Ok(())
} else {
Err("No active process to interrupt".to_string())
}
}
}
fn handle_stdout(
stdout: std::process::ChildStdout,
callback: Arc<StreamCallback>,
is_running: Arc<AtomicBool>,
) {
let reader = BufReader::new(stdout);
for line in reader.lines() {
match line {
Ok(line) if !line.is_empty() => {
if let Err(e) = process_json_line(&line, &callback) {
eprintln!("Error processing line: {}", e);
}
}
Err(e) => {
eprintln!("Error reading stdout: {}", e);
break;
}
_ => {}
}
}
is_running.store(false, Ordering::SeqCst);
callback(ProviderStreamEvent::Disconnected);
}
fn handle_stderr(stderr: std::process::ChildStderr, callback: Arc<StreamCallback>) {
let reader = BufReader::new(stderr);
for line in reader.lines() {
match line {
Ok(line) if !line.is_empty() => {
callback(ProviderStreamEvent::Error { message: line });
}
Err(_) => break,
_ => {}
}
}
}
fn process_json_line(line: &str, callback: &StreamCallback) -> Result<(), String> {
let message: serde_json::Value = serde_json::from_str(line)
.map_err(|e| format!("Failed to parse JSON: {} - Line: {}", e, line))?;
let msg_type = message.get("type").and_then(|t| t.as_str()).unwrap_or("");
match msg_type {
"system" => {
let subtype = message
.get("subtype")
.and_then(|s| s.as_str())
.unwrap_or("");
if subtype == "init" {
let session_id = message
.get("session_id")
.and_then(|s| s.as_str())
.map(|s| s.to_string());
callback(ProviderStreamEvent::Connected { session_id });
if let Some(cwd) = message.get("cwd").and_then(|c| c.as_str()) {
callback(ProviderStreamEvent::WorkingDirectory {
path: cwd.to_string(),
});
}
}
}
"assistant" => {
if let Some(msg) = message.get("message") {
if let Some(content) = msg.get("content").and_then(|c| c.as_array()) {
for block in content {
let block_type = block.get("type").and_then(|t| t.as_str()).unwrap_or("");
match block_type {
"text" => {
if let Some(text) = block.get("text").and_then(|t| t.as_str()) {
callback(ProviderStreamEvent::TextDelta {
text: text.to_string(),
});
}
}
"thinking" => {
if let Some(thinking) =
block.get("thinking").and_then(|t| t.as_str())
{
callback(ProviderStreamEvent::ThinkingDelta {
text: thinking.to_string(),
});
}
}
"tool_use" => {
let id = block
.get("id")
.and_then(|i| i.as_str())
.unwrap_or("")
.to_string();
let name = block
.get("name")
.and_then(|n| n.as_str())
.unwrap_or("")
.to_string();
let input = block
.get("input")
.cloned()
.unwrap_or(serde_json::Value::Null);
callback(ProviderStreamEvent::ToolUseStart {
id: id.clone(),
name,
});
callback(ProviderStreamEvent::ToolUseEnd { id, input });
}
_ => {}
}
}
}
// Extract usage if available
let usage = msg.get("usage").and_then(|u| {
let input_tokens = u.get("input_tokens").and_then(|t| t.as_u64())?;
let output_tokens = u.get("output_tokens").and_then(|t| t.as_u64())?;
let model = msg
.get("model")
.and_then(|m| m.as_str())
.unwrap_or("unknown")
.to_string();
Some(crate::providers::ProviderUsage {
input_tokens,
output_tokens,
model,
})
});
callback(ProviderStreamEvent::MessageComplete {
content: vec![],
usage,
stop_reason: None,
});
}
}
"result" => {
let subtype = message
.get("subtype")
.and_then(|s| s.as_str())
.unwrap_or("");
// Handle permission denials
if let Some(denials) = message
.get("permission_denials")
.and_then(|d| d.as_array())
{
for denial in denials {
let tool_name = denial
.get("tool_name")
.and_then(|t| t.as_str())
.unwrap_or("")
.to_string();
// Check if this is an AskUserQuestion
if tool_name == "AskUserQuestion" {
if let Some(tool_input) = denial.get("tool_input") {
if let Some(questions) =
tool_input.get("questions").and_then(|q| q.as_array())
{
if let Some(first_question) = questions.first() {
let question = first_question
.get("question")
.and_then(|q| q.as_str())
.unwrap_or("")
.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 id = denial
.get("tool_use_id")
.and_then(|i| i.as_str())
.unwrap_or("")
.to_string();
callback(ProviderStreamEvent::Question {
id,
question,
header,
options,
multi_select,
});
}
}
}
} else {
let id = denial
.get("tool_use_id")
.and_then(|i| i.as_str())
.unwrap_or("")
.to_string();
let tool_input = denial
.get("tool_input")
.cloned()
.unwrap_or(serde_json::Value::Null);
let description = format_tool_description(&tool_name, &tool_input);
callback(ProviderStreamEvent::PermissionRequest {
id,
tool_name,
tool_input,
description,
});
}
}
}
if subtype != "success" {
if let Some(result) = message.get("result").and_then(|r| r.as_str()) {
callback(ProviderStreamEvent::Error {
message: result.to_string(),
});
}
}
}
_ => {}
}
Ok(())
}
fn format_tool_description(name: &str, input: &serde_json::Value) -> String {
match name {
"Read" => {
if let Some(path) = input.get("file_path").and_then(|v| v.as_str()) {
format!("Reading file: {}", path)
} else {
"Reading file...".to_string()
}
}
"Glob" => {
if let Some(pattern) = input.get("pattern").and_then(|v| v.as_str()) {
format!("Searching for files: {}", pattern)
} else {
"Searching for files...".to_string()
}
}
"Grep" => {
if let Some(pattern) = input.get("pattern").and_then(|v| v.as_str()) {
format!("Searching for: {}", pattern)
} else {
"Searching in files...".to_string()
}
}
"Edit" | "Write" => {
if let Some(path) = input.get("file_path").and_then(|v| v.as_str()) {
format!("Editing: {}", path)
} else {
"Editing file...".to_string()
}
}
"Bash" => {
if let Some(cmd) = input.get("command").and_then(|v| v.as_str()) {
let truncated = if cmd.len() > 50 {
format!("{}...", &cmd[..50])
} else {
cmd.to_string()
};
format!("Running: {}", truncated)
} else {
"Running command...".to_string()
}
}
_ => format!("Using tool: {}", name),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_provider_name() {
let config = ProviderConfig::default();
let provider = ClaudeCliProvider::new(config);
assert_eq!(provider.name(), "Claude CLI");
}
#[test]
fn test_capabilities() {
let config = ProviderConfig::default();
let provider = ClaudeCliProvider::new(config);
let caps = provider.capabilities();
assert!(caps.supports_streaming);
assert!(caps.supports_tools);
assert!(caps.supports_vision);
assert!(caps.supports_thinking);
assert!(caps.supports_mcp);
assert!(caps.supports_resume_session);
assert_eq!(caps.max_context_tokens, Some(200_000));
assert!(!caps.available_models.is_empty());
}
#[test]
fn test_is_running_initial() {
let config = ProviderConfig::default();
let provider = ClaudeCliProvider::new(config);
assert!(!provider.is_running());
}
#[test]
fn test_get_working_directory() {
let config = ProviderConfig {
working_directory: "/home/test".to_string(),
..Default::default()
};
let provider = ClaudeCliProvider::new(config);
assert_eq!(provider.get_working_directory(), "/home/test");
}
#[test]
fn test_get_model() {
let config = ProviderConfig {
model: Some("claude-sonnet-4-20250514".to_string()),
..Default::default()
};
let provider = ClaudeCliProvider::new(config);
assert_eq!(
provider.get_model(),
Some("claude-sonnet-4-20250514".to_string())
);
}
#[test]
fn test_format_tool_description() {
let input = serde_json::json!({"file_path": "/test/file.rs"});
assert_eq!(
format_tool_description("Read", &input),
"Reading file: /test/file.rs"
);
let input = serde_json::json!({"pattern": "*.rs"});
assert_eq!(
format_tool_description("Glob", &input),
"Searching for files: *.rs"
);
let input = serde_json::json!({"command": "ls -la"});
assert_eq!(format_tool_description("Bash", &input), "Running: ls -la");
}
}
+148
View File
@@ -0,0 +1,148 @@
mod claude_cli;
mod ollama;
mod traits;
// Re-exports for when providers are fully integrated
#[allow(unused_imports)]
pub use claude_cli::ClaudeCliProvider;
#[allow(unused_imports)]
pub use ollama::OllamaProvider;
#[allow(unused_imports)]
pub use traits::{
LlmProvider, ModelInfo, ProviderCapabilities, ProviderConfig, ProviderMessage,
ProviderStreamEvent, ProviderUsage, QuestionOption, StreamCallback,
};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum ProviderType {
#[default]
ClaudeCli,
Ollama,
OpenAi,
Anthropic,
Gemini,
}
#[allow(dead_code)]
impl ProviderType {
pub fn display_name(&self) -> &'static str {
match self {
ProviderType::ClaudeCli => "Claude CLI",
ProviderType::Ollama => "Ollama (Local)",
ProviderType::OpenAi => "OpenAI API",
ProviderType::Anthropic => "Anthropic API",
ProviderType::Gemini => "Google Gemini",
}
}
pub fn description(&self) -> &'static str {
match self {
ProviderType::ClaudeCli => "Use Claude Code CLI for AI assistance",
ProviderType::Ollama => "Use locally running Ollama models",
ProviderType::OpenAi => "Direct OpenAI API access (GPT-4, etc.)",
ProviderType::Anthropic => "Direct Anthropic API access (Claude models)",
ProviderType::Gemini => "Direct Google Gemini API access",
}
}
pub fn requires_api_key(&self) -> bool {
matches!(
self,
ProviderType::OpenAi | ProviderType::Anthropic | ProviderType::Gemini
)
}
}
// Note: The new providers (OpenAI, Anthropic, Gemini) are implemented directly
// in provider_bridge.rs using the Bridge pattern rather than the LlmProvider trait.
// This simplifies the architecture while still providing full functionality.
#[allow(dead_code)]
pub fn create_provider(
provider_type: ProviderType,
config: ProviderConfig,
) -> Box<dyn LlmProvider> {
match provider_type {
ProviderType::ClaudeCli => Box::new(ClaudeCliProvider::new(config)),
ProviderType::Ollama => Box::new(OllamaProvider::new(config)),
// The new API-based providers are handled in provider_bridge.rs
ProviderType::OpenAi | ProviderType::Anthropic | ProviderType::Gemini => {
// These providers use the Bridge pattern in provider_bridge.rs
// Fall back to Claude CLI for trait-based usage
Box::new(ClaudeCliProvider::new(config))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_provider_type_display_name() {
assert_eq!(ProviderType::ClaudeCli.display_name(), "Claude CLI");
assert_eq!(ProviderType::Ollama.display_name(), "Ollama (Local)");
assert_eq!(ProviderType::OpenAi.display_name(), "OpenAI API");
assert_eq!(ProviderType::Anthropic.display_name(), "Anthropic API");
assert_eq!(ProviderType::Gemini.display_name(), "Google Gemini");
}
#[test]
fn test_provider_type_default() {
let default: ProviderType = Default::default();
assert_eq!(default, ProviderType::ClaudeCli);
}
#[test]
fn test_provider_type_serialization() {
let claude = ProviderType::ClaudeCli;
let json = serde_json::to_string(&claude).unwrap();
assert_eq!(json, "\"claude_cli\"");
let ollama = ProviderType::Ollama;
let json = serde_json::to_string(&ollama).unwrap();
assert_eq!(json, "\"ollama\"");
let openai = ProviderType::OpenAi;
let json = serde_json::to_string(&openai).unwrap();
assert_eq!(json, "\"open_ai\"");
let anthropic = ProviderType::Anthropic;
let json = serde_json::to_string(&anthropic).unwrap();
assert_eq!(json, "\"anthropic\"");
let gemini = ProviderType::Gemini;
let json = serde_json::to_string(&gemini).unwrap();
assert_eq!(json, "\"gemini\"");
}
#[test]
fn test_provider_type_deserialization() {
let claude: ProviderType = serde_json::from_str("\"claude_cli\"").unwrap();
assert_eq!(claude, ProviderType::ClaudeCli);
let ollama: ProviderType = serde_json::from_str("\"ollama\"").unwrap();
assert_eq!(ollama, ProviderType::Ollama);
let openai: ProviderType = serde_json::from_str("\"open_ai\"").unwrap();
assert_eq!(openai, ProviderType::OpenAi);
let anthropic: ProviderType = serde_json::from_str("\"anthropic\"").unwrap();
assert_eq!(anthropic, ProviderType::Anthropic);
let gemini: ProviderType = serde_json::from_str("\"gemini\"").unwrap();
assert_eq!(gemini, ProviderType::Gemini);
}
#[test]
fn test_provider_type_requires_api_key() {
assert!(!ProviderType::ClaudeCli.requires_api_key());
assert!(!ProviderType::Ollama.requires_api_key());
assert!(ProviderType::OpenAi.requires_api_key());
assert!(ProviderType::Anthropic.requires_api_key());
assert!(ProviderType::Gemini.requires_api_key());
}
}
+537
View File
@@ -0,0 +1,537 @@
// This provider connects to a local Ollama instance for LLM inference.
// It will be actively used once providers are fully integrated with BridgeManager.
#![allow(dead_code)]
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use tokio::sync::mpsc;
use crate::providers::traits::{
LlmProvider, ModelInfo, ProviderCapabilities, ProviderConfig, ProviderMessage,
ProviderStreamEvent, ProviderUsage, StreamCallback,
};
const DEFAULT_OLLAMA_URL: &str = "http://localhost:11434";
#[derive(Debug, Serialize)]
struct OllamaChatRequest {
model: String,
messages: Vec<OllamaMessage>,
stream: bool,
#[serde(skip_serializing_if = "Option::is_none")]
system: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct OllamaMessage {
role: String,
content: String,
}
#[derive(Debug, Deserialize)]
struct OllamaChatResponse {
#[serde(default)]
message: Option<OllamaResponseMessage>,
#[serde(default)]
done: bool,
#[serde(default)]
eval_count: Option<u64>,
#[serde(default)]
prompt_eval_count: Option<u64>,
}
#[derive(Debug, Deserialize)]
struct OllamaResponseMessage {
#[serde(default)]
content: String,
}
#[derive(Debug, Deserialize)]
struct OllamaTagsResponse {
models: Vec<OllamaModelInfo>,
}
#[derive(Debug, Deserialize)]
struct OllamaModelInfo {
name: String,
#[serde(default)]
details: Option<OllamaModelDetails>,
}
#[derive(Debug, Deserialize)]
struct OllamaModelDetails {
#[serde(default)]
parameter_size: Option<String>,
#[serde(default)]
family: Option<String>,
}
pub struct OllamaProvider {
config: ProviderConfig,
client: reqwest::Client,
base_url: String,
is_running: Arc<AtomicBool>,
conversation_history: Vec<OllamaMessage>,
cancel_tx: Option<mpsc::Sender<()>>,
}
impl OllamaProvider {
pub fn new(config: ProviderConfig) -> Self {
let base_url = config
.api_base_url
.clone()
.unwrap_or_else(|| DEFAULT_OLLAMA_URL.to_string());
Self {
config,
client: reqwest::Client::new(),
base_url,
is_running: Arc::new(AtomicBool::new(false)),
conversation_history: Vec::new(),
cancel_tx: None,
}
}
async fn fetch_available_models(&self) -> Vec<ModelInfo> {
let url = format!("{}/api/tags", self.base_url);
match self.client.get(&url).send().await {
Ok(response) => {
if let Ok(tags) = response.json::<OllamaTagsResponse>().await {
tags.models
.into_iter()
.map(|m| {
let description = m.details.as_ref().map(|d| {
let mut desc_parts = Vec::new();
if let Some(ref family) = d.family {
desc_parts.push(family.clone());
}
if let Some(ref size) = d.parameter_size {
desc_parts.push(format!("{} parameters", size));
}
if desc_parts.is_empty() {
"Local model".to_string()
} else {
desc_parts.join(" - ")
}
});
ModelInfo {
id: m.name.clone(),
name: m.name,
description,
context_window: None,
input_cost_per_mtok: Some(0.0), // Local = free!
output_cost_per_mtok: Some(0.0),
}
})
.collect()
} else {
default_ollama_models()
}
}
Err(_) => default_ollama_models(),
}
}
async fn stream_chat(
&mut self,
callback: Arc<StreamCallback>,
cancel_rx: mpsc::Receiver<()>,
) -> Result<(), String> {
let model = self
.config
.model
.clone()
.unwrap_or_else(|| "llama3.2".to_string());
let request = OllamaChatRequest {
model: model.clone(),
messages: self.conversation_history.clone(),
stream: true,
system: self.config.custom_instructions.clone(),
};
let url = format!("{}/api/chat", self.base_url);
let response = self
.client
.post(&url)
.json(&request)
.send()
.await
.map_err(|e| format!("Failed to connect to Ollama: {}", e))?;
if !response.status().is_success() {
let status = response.status();
let error_text = response
.text()
.await
.unwrap_or_else(|_| "Unknown error".to_string());
return Err(format!("Ollama error ({}): {}", status, error_text));
}
let mut full_response = String::new();
let mut total_input_tokens: u64 = 0;
let mut total_output_tokens: u64 = 0;
let mut stream = response.bytes_stream();
let mut cancel_rx = cancel_rx;
use futures_util::StreamExt;
loop {
tokio::select! {
_ = cancel_rx.recv() => {
callback(ProviderStreamEvent::Disconnected);
return Ok(());
}
chunk = stream.next() => {
match chunk {
Some(Ok(bytes)) => {
let text = String::from_utf8_lossy(&bytes);
for line in text.lines() {
if line.trim().is_empty() {
continue;
}
if let Ok(response) = serde_json::from_str::<OllamaChatResponse>(line) {
if let Some(msg) = &response.message {
if !msg.content.is_empty() {
full_response.push_str(&msg.content);
callback(ProviderStreamEvent::TextDelta {
text: msg.content.clone(),
});
}
}
if let Some(tokens) = response.prompt_eval_count {
total_input_tokens = tokens;
}
if let Some(tokens) = response.eval_count {
total_output_tokens = tokens;
}
if response.done {
self.conversation_history.push(OllamaMessage {
role: "assistant".to_string(),
content: full_response.clone(),
});
callback(ProviderStreamEvent::MessageComplete {
content: vec![ProviderMessage::Text {
content: full_response,
}],
usage: Some(ProviderUsage {
input_tokens: total_input_tokens,
output_tokens: total_output_tokens,
model,
}),
stop_reason: Some("end_turn".to_string()),
});
return Ok(());
}
}
}
}
Some(Err(e)) => {
return Err(format!("Stream error: {}", e));
}
None => {
break;
}
}
}
}
}
Ok(())
}
}
#[async_trait]
impl LlmProvider for OllamaProvider {
fn name(&self) -> &'static str {
"Ollama"
}
fn capabilities(&self) -> ProviderCapabilities {
ProviderCapabilities {
supports_streaming: true,
supports_tools: false, // Ollama doesn't support tools natively yet
supports_vision: true, // Some models support vision
supports_thinking: false,
supports_mcp: false,
supports_resume_session: false,
max_context_tokens: None, // Varies by model
available_models: default_ollama_models(),
}
}
async fn start(&mut self, callback: StreamCallback) -> Result<(), String> {
if self.is_running.load(Ordering::SeqCst) {
return Err("Provider already running".to_string());
}
// Check if Ollama is reachable
let url = format!("{}/api/tags", self.base_url);
self.client.get(&url).send().await.map_err(|e| {
format!(
"Cannot connect to Ollama at {}. Is it running? Error: {}",
self.base_url, e
)
})?;
self.is_running.store(true, Ordering::SeqCst);
self.conversation_history.clear();
// Create cancellation channel
let (tx, _rx) = mpsc::channel(1);
self.cancel_tx = Some(tx);
callback(ProviderStreamEvent::Connected { session_id: None });
// Fetch and report available models
let models = self.fetch_available_models().await;
if !models.is_empty() {
eprintln!(
"[Ollama] Available models: {:?}",
models.iter().map(|m| &m.id).collect::<Vec<_>>()
);
}
Ok(())
}
async fn stop(&mut self) -> Result<(), String> {
self.is_running.store(false, Ordering::SeqCst);
self.conversation_history.clear();
if let Some(tx) = self.cancel_tx.take() {
let _ = tx.send(()).await;
}
Ok(())
}
fn is_running(&self) -> bool {
self.is_running.load(Ordering::SeqCst)
}
async fn send_message(&mut self, message: &str) -> Result<(), String> {
if !self.is_running.load(Ordering::SeqCst) {
return Err("Provider not running".to_string());
}
// Add user message to history
self.conversation_history.push(OllamaMessage {
role: "user".to_string(),
content: message.to_string(),
});
// Create a new cancel channel for this request
let (tx, _rx) = mpsc::channel(1);
self.cancel_tx = Some(tx);
// We need a callback here, but we don't have access to it in send_message
// This is a limitation of the current trait design
// For now, we'll need to refactor to handle streaming properly
// The callback should be stored from the start() call
// For the MVP, we'll emit events directly
// In a real implementation, we'd need to restructure this
Err("send_message needs refactoring to work with stored callback".to_string())
}
async fn send_tool_result(
&mut self,
_tool_use_id: &str,
_result: serde_json::Value,
_is_error: bool,
) -> Result<(), String> {
// Ollama doesn't support tool use natively
Err("Ollama does not support tool use".to_string())
}
fn get_model(&self) -> Option<String> {
self.config.model.clone()
}
fn get_working_directory(&self) -> &str {
&self.config.working_directory
}
async fn interrupt(&mut self) -> Result<(), String> {
if let Some(tx) = self.cancel_tx.take() {
let _ = tx.send(()).await;
}
self.is_running.store(false, Ordering::SeqCst);
Ok(())
}
}
fn default_ollama_models() -> Vec<ModelInfo> {
vec![
ModelInfo {
id: "llama3.2".to_string(),
name: "Llama 3.2".to_string(),
description: Some("Meta's latest compact model".to_string()),
context_window: Some(128_000),
input_cost_per_mtok: Some(0.0),
output_cost_per_mtok: Some(0.0),
},
ModelInfo {
id: "llama3.2:1b".to_string(),
name: "Llama 3.2 1B".to_string(),
description: Some("Smallest Llama 3.2 variant".to_string()),
context_window: Some(128_000),
input_cost_per_mtok: Some(0.0),
output_cost_per_mtok: Some(0.0),
},
ModelInfo {
id: "qwen2.5-coder".to_string(),
name: "Qwen 2.5 Coder".to_string(),
description: Some("Alibaba's coding-focused model".to_string()),
context_window: Some(32_000),
input_cost_per_mtok: Some(0.0),
output_cost_per_mtok: Some(0.0),
},
ModelInfo {
id: "deepseek-coder-v2".to_string(),
name: "DeepSeek Coder V2".to_string(),
description: Some("DeepSeek's coding model".to_string()),
context_window: Some(128_000),
input_cost_per_mtok: Some(0.0),
output_cost_per_mtok: Some(0.0),
},
ModelInfo {
id: "mistral".to_string(),
name: "Mistral 7B".to_string(),
description: Some("Fast and capable".to_string()),
context_window: Some(32_000),
input_cost_per_mtok: Some(0.0),
output_cost_per_mtok: Some(0.0),
},
ModelInfo {
id: "gemma2".to_string(),
name: "Gemma 2".to_string(),
description: Some("Google's open model".to_string()),
context_window: Some(8_000),
input_cost_per_mtok: Some(0.0),
output_cost_per_mtok: Some(0.0),
},
]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_provider_name() {
let config = ProviderConfig::default();
let provider = OllamaProvider::new(config);
assert_eq!(provider.name(), "Ollama");
}
#[test]
fn test_capabilities() {
let config = ProviderConfig::default();
let provider = OllamaProvider::new(config);
let caps = provider.capabilities();
assert!(caps.supports_streaming);
assert!(!caps.supports_tools);
assert!(!caps.supports_mcp);
assert!(!caps.supports_resume_session);
assert!(!caps.available_models.is_empty());
}
#[test]
fn test_default_url() {
let config = ProviderConfig::default();
let provider = OllamaProvider::new(config);
assert_eq!(provider.base_url, DEFAULT_OLLAMA_URL);
}
#[test]
fn test_custom_url() {
let config = ProviderConfig {
api_base_url: Some("http://custom:8080".to_string()),
..Default::default()
};
let provider = OllamaProvider::new(config);
assert_eq!(provider.base_url, "http://custom:8080");
}
#[test]
fn test_is_running_initial() {
let config = ProviderConfig::default();
let provider = OllamaProvider::new(config);
assert!(!provider.is_running());
}
#[test]
fn test_default_ollama_models() {
let models = default_ollama_models();
assert!(!models.is_empty());
// All models should be free (local)
for model in &models {
assert_eq!(model.input_cost_per_mtok, Some(0.0));
assert_eq!(model.output_cost_per_mtok, Some(0.0));
}
// Should include llama
assert!(models.iter().any(|m| m.id.contains("llama")));
}
#[test]
fn test_ollama_message_serialization() {
let msg = OllamaMessage {
role: "user".to_string(),
content: "Hello!".to_string(),
};
let json = serde_json::to_string(&msg).unwrap();
assert!(json.contains("\"role\":\"user\""));
assert!(json.contains("\"content\":\"Hello!\""));
}
#[test]
fn test_chat_request_serialization() {
let request = OllamaChatRequest {
model: "llama3.2".to_string(),
messages: vec![OllamaMessage {
role: "user".to_string(),
content: "Test".to_string(),
}],
stream: true,
system: Some("You are helpful".to_string()),
};
let json = serde_json::to_string(&request).unwrap();
assert!(json.contains("\"model\":\"llama3.2\""));
assert!(json.contains("\"stream\":true"));
assert!(json.contains("\"system\":\"You are helpful\""));
}
#[test]
fn test_chat_request_without_system() {
let request = OllamaChatRequest {
model: "llama3.2".to_string(),
messages: vec![],
stream: true,
system: None,
};
let json = serde_json::to_string(&request).unwrap();
// system should be omitted when None
assert!(!json.contains("\"system\""));
}
}
+236
View File
@@ -0,0 +1,236 @@
// These types are all used by the provider abstraction layer.
// They will be actively used once providers are fully integrated with BridgeManager.
#![allow(dead_code)]
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProviderConfig {
pub api_key: Option<String>,
pub api_base_url: Option<String>,
pub model: Option<String>,
pub custom_instructions: Option<String>,
pub working_directory: String,
pub mcp_servers_json: Option<String>,
pub allowed_tools: Vec<String>,
#[serde(default)]
pub extra_options: HashMap<String, serde_json::Value>,
}
impl Default for ProviderConfig {
fn default() -> Self {
Self {
api_key: None,
api_base_url: None,
model: None,
custom_instructions: None,
working_directory: String::new(),
mcp_servers_json: None,
allowed_tools: Vec::new(),
extra_options: HashMap::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProviderCapabilities {
pub supports_streaming: bool,
pub supports_tools: bool,
pub supports_vision: bool,
pub supports_thinking: bool,
pub supports_mcp: bool,
pub supports_resume_session: bool,
pub max_context_tokens: Option<u64>,
pub available_models: Vec<ModelInfo>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ModelInfo {
pub id: String,
pub name: String,
pub description: Option<String>,
pub context_window: Option<u64>,
pub input_cost_per_mtok: Option<f64>,
pub output_cost_per_mtok: Option<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum ProviderMessage {
#[serde(rename = "text")]
Text { content: String },
#[serde(rename = "tool_use")]
ToolUse {
id: String,
name: String,
input: serde_json::Value,
},
#[serde(rename = "tool_result")]
ToolResult {
tool_use_id: String,
content: String,
is_error: bool,
},
#[serde(rename = "thinking")]
Thinking { content: String },
#[serde(rename = "image")]
Image { media_type: String, data: String },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProviderUsage {
pub input_tokens: u64,
pub output_tokens: u64,
pub model: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum ProviderStreamEvent {
#[serde(rename = "connected")]
Connected { session_id: Option<String> },
#[serde(rename = "text_delta")]
TextDelta { text: String },
#[serde(rename = "thinking_delta")]
ThinkingDelta { text: String },
#[serde(rename = "tool_use_start")]
ToolUseStart { id: String, name: String },
#[serde(rename = "tool_use_delta")]
ToolUseDelta { id: String, input_delta: String },
#[serde(rename = "tool_use_end")]
ToolUseEnd { id: String, input: serde_json::Value },
#[serde(rename = "message_complete")]
MessageComplete {
content: Vec<ProviderMessage>,
usage: Option<ProviderUsage>,
stop_reason: Option<String>,
},
#[serde(rename = "permission_request")]
PermissionRequest {
id: String,
tool_name: String,
tool_input: serde_json::Value,
description: String,
},
#[serde(rename = "question")]
Question {
id: String,
question: String,
header: Option<String>,
options: Vec<QuestionOption>,
multi_select: bool,
},
#[serde(rename = "working_directory")]
WorkingDirectory { path: String },
#[serde(rename = "error")]
Error { message: String },
#[serde(rename = "disconnected")]
Disconnected,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct QuestionOption {
pub label: String,
pub description: Option<String>,
}
pub type StreamCallback = Box<dyn Fn(ProviderStreamEvent) + Send + Sync>;
#[async_trait]
pub trait LlmProvider: Send + Sync {
fn name(&self) -> &'static str;
fn capabilities(&self) -> ProviderCapabilities;
async fn start(&mut self, callback: StreamCallback) -> Result<(), String>;
async fn stop(&mut self) -> Result<(), String>;
fn is_running(&self) -> bool;
async fn send_message(&mut self, message: &str) -> Result<(), String>;
async fn send_tool_result(
&mut self,
tool_use_id: &str,
result: serde_json::Value,
is_error: bool,
) -> Result<(), String>;
fn get_model(&self) -> Option<String>;
fn get_working_directory(&self) -> &str;
async fn interrupt(&mut self) -> Result<(), String>;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_provider_config_default() {
let config = ProviderConfig::default();
assert!(config.api_key.is_none());
assert!(config.api_base_url.is_none());
assert!(config.model.is_none());
assert!(config.working_directory.is_empty());
assert!(config.allowed_tools.is_empty());
}
#[test]
fn test_provider_message_serialization() {
let msg = ProviderMessage::Text {
content: "Hello!".to_string(),
};
let json = serde_json::to_string(&msg).unwrap();
assert!(json.contains("\"type\":\"text\""));
assert!(json.contains("\"content\":\"Hello!\""));
}
#[test]
fn test_provider_stream_event_serialization() {
let event = ProviderStreamEvent::TextDelta {
text: "chunk".to_string(),
};
let json = serde_json::to_string(&event).unwrap();
assert!(json.contains("\"type\":\"text_delta\""));
let event = ProviderStreamEvent::Connected {
session_id: Some("test-123".to_string()),
};
let json = serde_json::to_string(&event).unwrap();
assert!(json.contains("\"session_id\":\"test-123\""));
}
#[test]
fn test_model_info() {
let model = ModelInfo {
id: "claude-sonnet-4-20250514".to_string(),
name: "Claude Sonnet 4".to_string(),
description: Some("Fast and intelligent".to_string()),
context_window: Some(200000),
input_cost_per_mtok: Some(3.0),
output_cost_per_mtok: Some(15.0),
};
let json = serde_json::to_string(&model).unwrap();
assert!(json.contains("claude-sonnet-4"));
assert!(json.contains("200000"));
}
#[test]
fn test_provider_usage() {
let usage = ProviderUsage {
input_tokens: 100,
output_tokens: 50,
model: "claude-opus-4-20250514".to_string(),
};
let json = serde_json::to_string(&usage).unwrap();
assert!(json.contains("\"input_tokens\":100"));
assert!(json.contains("\"output_tokens\":50"));
}
}
+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);
}
}
+456
View File
@@ -1,7 +1,9 @@
use crate::achievements::{check_achievements, AchievementProgress};
use chrono::{Local, Timelike};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::time::Instant;
use tauri_plugin_store::StoreExt;
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct UsageStats {
@@ -28,6 +30,14 @@ pub struct UsageStats {
#[serde(skip)]
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
#[serde(skip)]
pub achievements: AchievementProgress,
@@ -65,6 +75,47 @@ impl UsageStats {
self.session_duration_seconds = 0;
self.session_start = Some(Instant::now());
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) {
@@ -127,12 +178,34 @@ impl UsageStats {
session_tools_usage: self.session_tools_usage.clone(),
session_duration_seconds: self.session_duration_seconds,
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
};
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
// https://www.anthropic.com/pricing
fn calculate_cost(input_tokens: u64, output_tokens: u64, model: &str) -> f64 {
@@ -169,6 +242,111 @@ pub struct StatsUpdateEvent {
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)]
mod tests {
use super::*;
@@ -191,6 +369,36 @@ mod tests {
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]
fn test_usage_stats_accumulation() {
let mut stats = UsageStats::new();
@@ -203,6 +411,28 @@ mod tests {
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]
fn test_session_reset() {
let mut stats = UsageStats::new();
@@ -216,4 +446,230 @@ mod tests {
assert_eq!(stats.session_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
}
+54 -4
View File
@@ -112,7 +112,7 @@ impl WslBridge {
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 stats = self.stats.clone();
tauri::async_runtime::spawn(async move {
@@ -122,7 +122,17 @@ impl WslBridge {
"Loaded {} unlocked achievements",
achievements.unlocked.len()
);
stats.write().achievements = achievements;
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;
@@ -440,6 +450,18 @@ impl WslBridge {
self.session_id = None;
self.mcp_config_file = None; // Temp file is automatically deleted when dropped
// 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();
@@ -682,7 +704,7 @@ fn process_json_line(
subtype,
result,
permission_denials,
usage: _,
usage,
..
} => {
let state = if subtype == "success" {
@@ -691,6 +713,21 @@ fn process_json_line(
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)
// This includes the latest session duration
let newly_unlocked = {
@@ -733,10 +770,23 @@ fn process_json_line(
let current_stats = stats.read().clone();
let stats_event = StatsUpdateEvent {
stats: current_stats,
stats: current_stats.clone(),
};
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
if subtype != "success" {
if let Some(text) = result {
+7 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "hikari-desktop",
"version": "0.3.0",
"version": "1.1.1",
"identifier": "com.naomi.hikari-desktop",
"build": {
"beforeDevCommand": "pnpm dev",
@@ -22,6 +22,12 @@
],
"security": {
"csp": null
},
"trayIcon": {
"id": "main",
"iconPath": "icons/32x32.png",
"iconAsTemplate": false,
"tooltip": "Hikari - Claude Code Assistant"
}
},
"bundle": {
+136
View File
@@ -14,6 +14,25 @@
--text-tertiary: #6b7280;
--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-user: #22d3ee;
--terminal-tool: #c084fc;
@@ -44,6 +63,25 @@
--text-tertiary: #9ca3af;
--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-user: #0891b2;
--terminal-tool: #7c3aed;
@@ -61,6 +99,55 @@
--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,
body {
margin: 0;
@@ -101,3 +188,52 @@ body {
background: var(--accent-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();
});
});
});
+6 -54
View File
@@ -34,35 +34,12 @@
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>
<div
class="anime-girl-container flex flex-col items-center justify-between h-full p-4 overflow-hidden"
>
<div
class="character-frame relative {getBackgroundGlow()} flex-1 flex items-center justify-center min-h-0"
>
<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
src="/sprites/{info.spriteFile}"
@@ -97,37 +74,12 @@
</div>
<style>
.anime-girl-container {
transition: all 0.3s ease;
}
.character-frame {
border-radius: 50%;
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);
transition: all 0.3s ease;
}
@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>
+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>
+648 -46
View File
@@ -3,7 +3,10 @@
configStore,
type HikariConfig,
type Theme,
type ProviderType,
type CustomThemeColors,
applyFontSize,
applyCustomThemeColors,
MIN_FONT_SIZE,
MAX_FONT_SIZE,
DEFAULT_FONT_SIZE,
@@ -12,22 +15,52 @@
import { getCurrentWindow } from "@tauri-apps/api/window";
let config: HikariConfig = $state({
provider_type: "claude_cli",
model: null,
api_key: null,
custom_instructions: null,
mcp_servers_json: null,
auto_granted_tools: [],
ollama_base_url: "http://localhost:11434",
ollama_model: null,
openai_api_key: null,
openai_base_url: "https://api.openai.com/v1",
openai_model: null,
anthropic_api_key: null,
anthropic_base_url: "https://api.anthropic.com",
anthropic_model: null,
gemini_api_key: null,
gemini_model: null,
theme: "dark",
greeting_enabled: true,
greeting_custom_prompt: null,
notifications_enabled: true,
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 isSaving = $state(false);
let saveError: string | null = $state(null);
@@ -51,12 +84,53 @@
grantedTools = Array.from(tools);
});
const availableProviders: { value: ProviderType; label: string; description: string }[] = [
{ value: "claude_cli", label: "Claude CLI", description: "Use Claude Code CLI for AI assistance" },
{ value: "ollama", label: "Ollama (Local)", description: "Use locally running Ollama models" },
{ value: "open_ai", label: "OpenAI API", description: "Direct OpenAI API access (GPT-4o, etc.)" },
{ value: "anthropic", label: "Anthropic API", description: "Direct Anthropic API access (Claude models)" },
{ value: "gemini", label: "Google Gemini", description: "Direct Google Gemini API access" },
];
const availableModels = [
{ value: "", label: "Default (from ~/.claude)" },
{ value: "claude-sonnet-4-20250514", label: "Claude Sonnet 4" },
{ value: "claude-opus-4-20250514", label: "Claude Opus 4" },
];
const ollamaModels = [
{ value: "", label: "Default (llama3.2)" },
{ value: "llama3.2", label: "Llama 3.2" },
{ value: "llama3.2:1b", label: "Llama 3.2 1B" },
{ value: "qwen2.5-coder", label: "Qwen 2.5 Coder" },
{ value: "deepseek-coder-v2", label: "DeepSeek Coder V2" },
{ value: "mistral", label: "Mistral 7B" },
{ value: "gemma2", label: "Gemma 2" },
];
const openaiModels = [
{ value: "", label: "Default (gpt-4o)" },
{ value: "gpt-4o", label: "GPT-4o" },
{ value: "gpt-4o-mini", label: "GPT-4o Mini" },
{ value: "gpt-4-turbo", label: "GPT-4 Turbo" },
{ value: "gpt-3.5-turbo", label: "GPT-3.5 Turbo" },
];
const anthropicModels = [
{ value: "", label: "Default (Claude Sonnet 4.5)" },
{ value: "claude-sonnet-4-5-20250514", label: "Claude Sonnet 4.5" },
{ value: "claude-opus-4-20250514", label: "Claude Opus 4" },
{ value: "claude-3-5-sonnet-20241022", label: "Claude 3.5 Sonnet" },
{ value: "claude-3-5-haiku-20241022", label: "Claude 3.5 Haiku" },
];
const geminiModels = [
{ value: "", label: "Default (gemini-2.0-flash)" },
{ value: "gemini-2.0-flash", label: "Gemini 2.0 Flash" },
{ value: "gemini-1.5-pro", label: "Gemini 1.5 Pro" },
{ value: "gemini-1.5-flash", label: "Gemini 1.5 Flash" },
];
const commonTools = [
"Read",
"Write",
@@ -84,9 +158,33 @@
async function handleThemeChange(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) {
if (config.auto_granted_tools.includes(tool)) {
config.auto_granted_tools = config.auto_granted_tools.filter((t) => t !== tool);
@@ -162,6 +260,235 @@
</div>
{/if}
<!-- Provider Selection Section -->
<section class="mb-6">
<h3 class="text-sm font-medium text-[var(--accent-primary)] uppercase tracking-wider mb-3">
AI Provider
</h3>
<div class="space-y-2">
{#each availableProviders as provider (provider.value)}
<label class="flex items-start gap-3 p-3 rounded-lg border cursor-pointer transition-colors {config.provider_type === provider.value
? 'border-[var(--accent-primary)] bg-[var(--accent-primary)]/10'
: 'border-[var(--border-color)] bg-[var(--bg-primary)] hover:border-[var(--accent-primary)]/50'}">
<input
type="radio"
name="provider"
value={provider.value}
checked={config.provider_type === provider.value}
onchange={() => config.provider_type = provider.value}
class="mt-1 w-4 h-4 text-[var(--accent-primary)] bg-[var(--bg-primary)] border-[var(--border-color)] focus:ring-[var(--accent-primary)]"
/>
<div class="flex-1">
<div class="text-sm font-medium text-[var(--text-primary)]">{provider.label}</div>
<div class="text-xs text-[var(--text-tertiary)]">{provider.description}</div>
</div>
</label>
{/each}
</div>
<!-- Ollama-specific settings -->
{#if config.provider_type === "ollama"}
<div class="mt-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">Ollama Settings</h4>
<!-- Ollama Base URL -->
<div class="mb-3">
<label for="ollama-url" class="block text-xs text-[var(--text-secondary)] mb-1">
Base URL
</label>
<input
id="ollama-url"
type="text"
bind:value={config.ollama_base_url}
placeholder="http://localhost:11434"
class="w-full px-3 py-2 text-sm bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent-primary)]"
/>
</div>
<!-- Ollama Model Selection -->
<div class="mb-3">
<label for="ollama-model" class="block text-xs text-[var(--text-secondary)] mb-1">
Model
</label>
<select
id="ollama-model"
bind:value={config.ollama_model}
class="w-full px-3 py-2 text-sm bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent-primary)]"
>
{#each ollamaModels as model (model.value)}
<option value={model.value}>{model.label}</option>
{/each}
</select>
<p class="text-xs text-[var(--text-tertiary)] mt-1">
Make sure the model is downloaded via <code class="text-[var(--accent-secondary)]">ollama pull</code>
</p>
</div>
<div class="p-2 bg-yellow-500/10 border border-yellow-500/30 rounded text-xs text-yellow-400">
<strong>Note:</strong> Ollama doesn't support tools, MCP servers, or thinking blocks.
For full Claude Code features, use the Claude CLI provider.
</div>
</div>
{/if}
<!-- OpenAI-specific settings -->
{#if config.provider_type === "open_ai"}
<div class="mt-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">OpenAI Settings</h4>
<!-- OpenAI API Key -->
<div class="mb-3">
<label for="openai-api-key" class="block text-xs text-[var(--text-secondary)] mb-1">
API Key
</label>
<input
id="openai-api-key"
type="password"
bind:value={config.openai_api_key}
placeholder="sk-..."
class="w-full px-3 py-2 text-sm bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent-primary)]"
/>
</div>
<!-- OpenAI Base URL -->
<div class="mb-3">
<label for="openai-url" class="block text-xs text-[var(--text-secondary)] mb-1">
Base URL <span class="text-[var(--text-tertiary)]">(for OpenAI-compatible APIs)</span>
</label>
<input
id="openai-url"
type="text"
bind:value={config.openai_base_url}
placeholder="https://api.openai.com/v1"
class="w-full px-3 py-2 text-sm bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent-primary)]"
/>
</div>
<!-- OpenAI Model Selection -->
<div class="mb-3">
<label for="openai-model" class="block text-xs text-[var(--text-secondary)] mb-1">
Model
</label>
<select
id="openai-model"
bind:value={config.openai_model}
class="w-full px-3 py-2 text-sm bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent-primary)]"
>
{#each openaiModels as model (model.value)}
<option value={model.value}>{model.label}</option>
{/each}
</select>
</div>
<div class="p-2 bg-blue-500/10 border border-blue-500/30 rounded text-xs text-blue-400">
<strong>Tip:</strong> You can use this with any OpenAI-compatible API (Groq, Together AI, etc.)
by changing the Base URL.
</div>
</div>
{/if}
<!-- Anthropic-specific settings -->
{#if config.provider_type === "anthropic"}
<div class="mt-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">Anthropic Settings</h4>
<!-- Anthropic API Key -->
<div class="mb-3">
<label for="anthropic-api-key" class="block text-xs text-[var(--text-secondary)] mb-1">
API Key
</label>
<input
id="anthropic-api-key"
type="password"
bind:value={config.anthropic_api_key}
placeholder="sk-ant-..."
class="w-full px-3 py-2 text-sm bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent-primary)]"
/>
</div>
<!-- Anthropic Base URL -->
<div class="mb-3">
<label for="anthropic-url" class="block text-xs text-[var(--text-secondary)] mb-1">
Base URL <span class="text-[var(--text-tertiary)]">(optional)</span>
</label>
<input
id="anthropic-url"
type="text"
bind:value={config.anthropic_base_url}
placeholder="https://api.anthropic.com"
class="w-full px-3 py-2 text-sm bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent-primary)]"
/>
</div>
<!-- Anthropic Model Selection -->
<div class="mb-3">
<label for="anthropic-model" class="block text-xs text-[var(--text-secondary)] mb-1">
Model
</label>
<select
id="anthropic-model"
bind:value={config.anthropic_model}
class="w-full px-3 py-2 text-sm bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent-primary)]"
>
{#each anthropicModels as model (model.value)}
<option value={model.value}>{model.label}</option>
{/each}
</select>
</div>
<div class="p-2 bg-purple-500/10 border border-purple-500/30 rounded text-xs text-purple-400">
<strong>Note:</strong> This uses the Anthropic API directly without Claude Code CLI features
like tools, MCP, or thinking blocks.
</div>
</div>
{/if}
<!-- Gemini-specific settings -->
{#if config.provider_type === "gemini"}
<div class="mt-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">Gemini Settings</h4>
<!-- Gemini API Key -->
<div class="mb-3">
<label for="gemini-api-key" class="block text-xs text-[var(--text-secondary)] mb-1">
API Key
</label>
<input
id="gemini-api-key"
type="password"
bind:value={config.gemini_api_key}
placeholder="AIza..."
class="w-full px-3 py-2 text-sm bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent-primary)]"
/>
<p class="text-xs text-[var(--text-tertiary)] mt-1">
Get your API key from <a href="https://aistudio.google.com/apikey" target="_blank" class="text-[var(--accent-secondary)] hover:underline">Google AI Studio</a>
</p>
</div>
<!-- Gemini Model Selection -->
<div class="mb-3">
<label for="gemini-model" class="block text-xs text-[var(--text-secondary)] mb-1">
Model
</label>
<select
id="gemini-model"
bind:value={config.gemini_model}
class="w-full px-3 py-2 text-sm bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent-primary)]"
>
{#each geminiModels as model (model.value)}
<option value={model.value}>{model.label}</option>
{/each}
</select>
</div>
<div class="p-2 bg-green-500/10 border border-green-500/30 rounded text-xs text-green-400">
<strong>Note:</strong> Gemini has a generous free tier! Great for experimenting.
</div>
</div>
{/if}
</section>
<!-- Agent Settings Section -->
<section class="mb-6">
<h3 class="text-sm font-medium text-[var(--accent-primary)] uppercase tracking-wider mb-3">
@@ -186,47 +513,60 @@
<div class="mb-4">
<label for="api-key" class="block text-sm text-[var(--text-secondary)] mb-1">
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>
<div class="relative">
<input
id="api-key"
type={showApiKey ? "text" : "password"}
bind:value={config.api_key}
placeholder="Falls back to ~/.claude settings"
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)]"
/>
<button
type="button"
onclick={() => (showApiKey = !showApiKey)}
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"}
>
{#if showApiKey}
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13.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"
/>
</svg>
{:else}
<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 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
<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 config.streamer_mode}
<input
id="api-key"
type="password"
value="••••••••••••••••••••••••"
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"
/>
{:else}
<input
id="api-key"
type={showApiKey ? "text" : "password"}
bind:value={config.api_key}
placeholder="Falls back to ~/.claude settings"
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)]"
/>
<button
type="button"
onclick={() => (showApiKey = !showApiKey)}
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"}
>
{#if showApiKey}
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13.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"
/>
</svg>
{:else}
<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 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
<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>
@@ -385,7 +725,7 @@
<button
onclick={addCustomTool}
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
</button>
@@ -400,11 +740,12 @@
<!-- Theme Selection -->
<div class="mb-4">
<label class="block text-sm text-[var(--text-secondary)] mb-2">Theme</label>
<div class="flex gap-2">
<span class="block text-sm text-[var(--text-secondary)] mb-2">Theme</span>
<div class="flex flex-wrap gap-2" role="group" aria-label="Theme selection">
<button
onclick={() => handleThemeChange("dark")}
class="flex-1 px-4 py-2 rounded-lg border transition-colors {config.theme === 'dark'
class="flex-1 min-w-[70px] px-3 py-2 rounded-lg border transition-colors {config.theme ===
'dark'
? 'bg-[var(--accent-primary)] border-[var(--accent-primary)] text-white'
: 'bg-[var(--bg-primary)] border-[var(--border-color)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)]'}"
>
@@ -412,15 +753,187 @@
</button>
<button
onclick={() => handleThemeChange("light")}
class="flex-1 px-4 py-2 rounded-lg border transition-colors {config.theme === 'light'
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>
<!-- 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">
@@ -477,6 +990,21 @@
</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">
@@ -493,6 +1021,45 @@
</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>
<!-- Notifications Section -->
<section class="mb-6">
<h3 class="text-sm font-medium text-[var(--accent-primary)] uppercase tracking-wider mb-3">
@@ -543,7 +1110,7 @@
<button
onclick={handleSave}
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"}
</button>
@@ -580,4 +1147,39 @@
background: var(--text-tertiary);
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>
+25 -21
View File
@@ -236,28 +236,32 @@
</div>
{/if}
{#if $conversations.size > 1}
<button
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"
title="Close tab"
>
<svg
class="w-3 h-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
<div
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"
>
{#if $conversations.size > 1}
<button
onclick={(e) => deleteTab(id, e)}
class="w-4 h-4 flex items-center justify-center rounded hover:bg-[var(--bg-secondary)]"
title="Close tab"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
{/if}
<svg
class="w-3 h-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
{/if}
</div>
</div>
{/each}
File diff suppressed because it is too large Load Diff
+725 -13
View File
@@ -1,10 +1,13 @@
<script lang="ts">
import { invoke } from "@tauri-apps/api/core";
import { open } from "@tauri-apps/plugin-dialog";
import { readImage } from "@tauri-apps/plugin-clipboard-manager";
import { get } from "svelte/store";
import { claudeStore, isClaudeProcessing } from "$lib/stores/claude";
import { characterState } from "$lib/stores/character";
import { handleNewUserMessage } from "$lib/notifications/rules";
import { setSkipNextGreeting } from "$lib/tauri";
import { clipboardStore } from "$lib/stores/clipboard";
import {
setShouldRestoreHistory,
setSavedHistory,
@@ -22,6 +25,13 @@
isSlashCommand,
type SlashCommand,
} from "$lib/commands/slashCommands";
import { configStore, isStreamerMode } from "$lib/stores/config";
import AttachmentPreview from "$lib/components/AttachmentPreview.svelte";
import SnippetLibraryPanel from "$lib/components/SnippetLibraryPanel.svelte";
import QuickActionsPanel from "$lib/components/QuickActionsPanel.svelte";
import ClipboardHistoryPanel from "$lib/components/ClipboardHistoryPanel.svelte";
import TextInputContextMenu from "$lib/components/TextInputContextMenu.svelte";
import type { Attachment } from "$lib/types/messages";
const INPUT_HISTORY_KEY = "hikari-input-history";
const MAX_HISTORY_SIZE = 100;
@@ -33,6 +43,33 @@
let showCommandMenu = $state(false);
let matchingCommands = $state<SlashCommand[]>([]);
let selectedCommandIndex = $state(0);
let attachments = $state<Attachment[]>([]);
let isDragging = $state(false);
let showSnippetLibrary = $state(false);
let showQuickActions = $state(false);
let showClipboardHistory = $state(false);
let streamerModeActive = $state(false);
// Context menu state
let textareaElement: HTMLTextAreaElement | undefined = $state();
let contextMenuShow = $state(false);
let contextMenuX = $state(0);
let contextMenuY = $state(0);
function handleContextMenu(event: MouseEvent) {
event.preventDefault();
contextMenuShow = true;
contextMenuX = event.clientX;
contextMenuY = event.clientY;
}
function closeContextMenu() {
contextMenuShow = false;
}
isStreamerMode.subscribe((value) => {
streamerModeActive = value;
});
// Input history state
let inputHistory = $state<string[]>([]);
@@ -112,6 +149,10 @@
isProcessing = processing;
});
claudeStore.attachments.subscribe((storedAttachments) => {
attachments = storedAttachments;
});
function handleInputChange() {
// If input is empty, allow history navigation again
// Otherwise, mark that user has manually typed
@@ -156,10 +197,13 @@
event.preventDefault();
const message = inputValue.trim();
if (!message || isSubmitting) return;
const hasAttachments = attachments.length > 0;
// Need either a message or attachments to submit
if ((!message && !hasAttachments) || isSubmitting) return;
// Check for slash commands first (these work even when disconnected)
if (isSlashCommand(message)) {
if (message && isSlashCommand(message)) {
// Add slash commands to history too
addToHistory(message);
historyIndex = -1;
@@ -180,8 +224,10 @@
// Regular messages require connection
if (!isConnected) return;
// Add to history before clearing
addToHistory(message);
// Add to history before clearing (only if there's text)
if (message) {
addToHistory(message);
}
historyIndex = -1;
tempInput = "";
userHasTyped = false;
@@ -189,12 +235,31 @@
isSubmitting = true;
inputValue = "";
// Apply mode prefix if needed
// Capture attachments before clearing
const currentAttachments = [...attachments];
// Apply mode prefix if needed (only if there's a message)
const currentMode = getCurrentMode();
const formattedMessage = formatMessageWithMode(message, currentMode);
const formattedMessage = message ? formatMessageWithMode(message, currentMode) : "";
// Build message with attachments
let messageWithAttachments = formattedMessage;
if (currentAttachments.length > 0) {
const attachmentPaths = currentAttachments.map((a) => a.path).join("\n");
const attachmentPrefix = formattedMessage ? `${formattedMessage}\n\n` : "";
messageWithAttachments = `${attachmentPrefix}[Attached files - please read these files to see their contents:]\n${attachmentPaths}`;
// Log attached files to the output
for (const attachment of currentAttachments) {
claudeStore.addLine("system", `📎 Attached: ${attachment.filename} (${attachment.path})`);
}
}
// Clear attachments after capturing them
claudeStore.clearAttachments();
// Check if we need to restore conversation history
let messageToSend = formattedMessage;
let messageToSend = messageWithAttachments;
if (getShouldRestoreHistory()) {
const savedHistory = getSavedHistory();
@@ -289,6 +354,365 @@ User: ${formattedMessage}`;
}
}
function handleRemoveAttachment(id: string) {
claudeStore.removeAttachment(id);
}
function getFileTypeFromExtension(extension: string): "image" | "document" | "other" {
const imageExtensions = ["png", "jpg", "jpeg", "gif", "webp", "svg", "bmp"];
const documentExtensions = ["pdf", "txt", "md", "doc", "docx", "csv", "json", "xml"];
if (imageExtensions.includes(extension)) {
return "image";
} else if (documentExtensions.includes(extension)) {
return "document";
}
return "other";
}
function handleDragEnter(event: DragEvent) {
event.preventDefault();
event.stopPropagation();
if (event.dataTransfer?.types.includes("Files")) {
isDragging = true;
}
}
function handleDragOver(event: DragEvent) {
event.preventDefault();
event.stopPropagation();
if (event.dataTransfer) {
event.dataTransfer.dropEffect = "copy";
}
}
function handleDragLeave(event: DragEvent) {
event.preventDefault();
event.stopPropagation();
// Only set isDragging to false if we're leaving the form element entirely
const relatedTarget = event.relatedTarget as Node | null;
const currentTarget = event.currentTarget as HTMLElement;
if (!relatedTarget || !currentTarget.contains(relatedTarget)) {
isDragging = false;
}
}
async function handleDrop(event: DragEvent) {
event.preventDefault();
event.stopPropagation();
isDragging = false;
const files = event.dataTransfer?.files;
if (!files || files.length === 0) return;
const conversationId = get(claudeStore.activeConversationId);
for (const file of files) {
const filename = file.name;
const extension = filename.split(".").pop()?.toLowerCase() || "";
const fileType = getFileTypeFromExtension(extension);
// Create attachment from dropped file
// Note: For dropped files, we create a preview URL for images
let previewUrl: string | undefined;
if (fileType === "image") {
previewUrl = URL.createObjectURL(file);
}
// Check if the file has a native path (from Tauri's file drop)
// If not, we need to save it to a temp file
let savedPath = (file as File & { path?: string }).path;
if (!savedPath && conversationId) {
try {
const arrayBuffer = await file.arrayBuffer();
const bytes = Array.from(new Uint8Array(arrayBuffer));
savedPath = await invoke<string>("save_temp_file", {
conversationId,
filename,
data: bytes,
});
} catch (error) {
console.error("Failed to save dropped file to temp:", error);
savedPath = file.name;
}
}
const attachment: Attachment = {
id: `attachment-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
filename,
path: savedPath || file.name,
size: file.size,
type: fileType,
mimeType: file.type,
previewUrl,
};
claudeStore.addAttachment(attachment);
}
}
async function handleFilePicker() {
try {
const selected = await open({
multiple: true,
filters: [
{
name: "All Files",
extensions: ["*"],
},
{
name: "Images",
extensions: ["png", "jpg", "jpeg", "gif", "webp", "svg", "bmp"],
},
{
name: "Documents",
extensions: ["pdf", "txt", "md", "doc", "docx", "csv", "json", "xml"],
},
{
name: "Code",
extensions: [
"js",
"ts",
"jsx",
"tsx",
"py",
"rs",
"go",
"java",
"c",
"cpp",
"h",
"hpp",
"css",
"html",
"svelte",
"vue",
],
},
],
});
if (!selected) return;
// Handle both single and multiple file selection
const files = Array.isArray(selected) ? selected : [selected];
for (const filePath of files) {
const filename = filePath.split(/[/\\]/).pop() || "unknown";
const extension = filename.split(".").pop()?.toLowerCase() || "";
const fileType = getFileTypeFromExtension(extension);
// Get file size from Tauri
let fileSize = 0;
try {
fileSize = await invoke<number>("get_file_size", { filePath });
} catch (e) {
console.warn("Could not get file size:", e);
}
const attachment: Attachment = {
id: `attachment-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
filename,
path: filePath,
size: fileSize,
type: fileType,
};
claudeStore.addAttachment(attachment);
}
} catch (error) {
console.error("Failed to open file picker:", error);
}
}
async function handlePaste(event: ClipboardEvent) {
// First, try the web clipboard API for files
const items = event.clipboardData?.items;
let handledFile = false;
// Also capture text content to clipboard history
const textContent = event.clipboardData?.getData("text/plain");
if (textContent && textContent.trim().length > 0) {
// Only capture multi-line or longer text (likely code snippets)
if (textContent.includes("\n") || textContent.length > 50) {
clipboardStore.captureClipboard(textContent, null, "Pasted into chat");
}
}
if (items && items.length > 0) {
for (const item of items) {
if (item.kind === "file") {
const file = item.getAsFile();
if (!file) continue;
event.preventDefault();
handledFile = true;
const filename = file.name || `pasted-${Date.now()}.${file.type.split("/")[1] || "png"}`;
const extension = filename.split(".").pop()?.toLowerCase() || "";
const fileType = getFileTypeFromExtension(extension);
let previewUrl: string | undefined;
if (fileType === "image" || file.type.startsWith("image/")) {
previewUrl = URL.createObjectURL(file);
}
const conversationId = get(claudeStore.activeConversationId);
let savedPath = filename;
if (conversationId) {
try {
const arrayBuffer = await file.arrayBuffer();
const bytes = Array.from(new Uint8Array(arrayBuffer));
savedPath = await invoke<string>("save_temp_file", {
conversationId,
filename,
data: bytes,
});
} catch (error) {
console.error("Failed to save pasted file to temp:", error);
}
}
const attachment: Attachment = {
id: `attachment-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
filename,
path: savedPath,
size: file.size,
type: file.type.startsWith("image/") ? "image" : fileType,
mimeType: file.type,
previewUrl,
};
claudeStore.addAttachment(attachment);
}
}
}
// If web clipboard didn't have files, try Tauri's native clipboard for images
if (!handledFile) {
try {
const image = await readImage();
const rgba = await image.rgba();
const size = await image.size();
if (rgba && rgba.length > 0) {
event.preventDefault();
const conversationId = get(claudeStore.activeConversationId);
const filename = `screenshot-${Date.now()}.png`;
// Convert RGBA to PNG using canvas
const canvas = document.createElement("canvas");
canvas.width = size.width;
canvas.height = size.height;
const ctx = canvas.getContext("2d");
if (ctx) {
const imgData = ctx.createImageData(size.width, size.height);
imgData.data.set(new Uint8ClampedArray(rgba));
ctx.putImageData(imgData, 0, 0);
// Create preview URL from canvas
const previewUrl = canvas.toDataURL("image/png");
// Convert to blob for saving
const blob = await new Promise<Blob | null>((resolve) =>
canvas.toBlob(resolve, "image/png")
);
let savedPath = filename;
if (blob && conversationId) {
try {
const arrayBuffer = await blob.arrayBuffer();
const bytes = Array.from(new Uint8Array(arrayBuffer));
savedPath = await invoke<string>("save_temp_file", {
conversationId,
filename,
data: bytes,
});
} catch (error) {
console.error("Failed to save clipboard image to temp:", error);
}
}
const attachment: Attachment = {
id: `attachment-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
filename,
path: savedPath,
size: blob?.size || 0,
type: "image",
mimeType: "image/png",
previewUrl,
};
claudeStore.addAttachment(attachment);
}
}
} catch (error) {
// No image in clipboard or clipboard read failed - that's fine, just ignore
console.log("No image in native clipboard:", error);
}
}
}
function handleSnippetInsert(content: string): void {
// Insert snippet at cursor position or append to input
if (inputValue.trim()) {
inputValue = inputValue + "\n\n" + content;
} else {
inputValue = content;
}
userHasTyped = true;
}
function handleClipboardInsert(content: string): void {
// Insert clipboard content at cursor position or append to input
if (inputValue.trim()) {
inputValue = inputValue + "\n\n" + content;
} else {
inputValue = content;
}
userHasTyped = true;
}
async function handleQuickAction(prompt: string): Promise<void> {
// Quick actions send the prompt directly
if (!isConnected || isSubmitting) return;
// Add to history
addToHistory(prompt);
historyIndex = -1;
tempInput = "";
userHasTyped = false;
isSubmitting = true;
// Reset notification state for new user message
handleNewUserMessage();
claudeStore.addLine("user", prompt);
characterState.setState("thinking");
try {
const conversationId = get(claudeStore.activeConversationId);
if (!conversationId) {
throw new Error("No active conversation");
}
await invoke("send_prompt", {
conversationId,
message: prompt,
});
} catch (error) {
console.error("Failed to send quick action:", error);
claudeStore.addLine("error", `Failed to send: ${error}`);
characterState.setTemporaryState("error", 3000);
} finally {
isSubmitting = false;
}
}
function handleKeyDown(event: KeyboardEvent) {
// Handle command menu navigation
if (showCommandMenu && matchingCommands.length > 0) {
@@ -352,9 +776,112 @@ User: ${formattedMessage}`;
}
</script>
<form onsubmit={handleSubmit} class="input-bar">
<form
onsubmit={handleSubmit}
ondragenter={handleDragEnter}
ondragover={handleDragOver}
ondragleave={handleDragLeave}
ondrop={handleDrop}
class="input-bar"
class:is-dragging={isDragging}
>
<AttachmentPreview {attachments} onRemove={handleRemoveAttachment} />
<div class="input-controls flex gap-2 mb-2">
<MessageModeSelector />
<button
type="button"
onclick={() => configStore.toggleStreamerMode()}
class="control-button streamer-toggle"
class:streamer-active={streamerModeActive}
title="Toggle Streamer Mode (Ctrl+Shift+S)"
>
{#if streamerModeActive}
<div class="live-indicator"></div>
<span>LIVE</span>
{:else}
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
<line x1="1" y1="1" x2="23" y2="23" />
</svg>
<span>Stream</span>
{/if}
</button>
<button
type="button"
onclick={() => (showQuickActions = true)}
class="control-button"
title="Quick Actions"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<span>Actions</span>
</button>
<button
type="button"
onclick={() => (showSnippetLibrary = true)}
class="control-button"
title="Snippet Library"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z" />
<polyline points="14,2 14,8 20,8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
<line x1="10" y1="9" x2="8" y2="9" />
</svg>
<span>Snippets</span>
</button>
<button
type="button"
onclick={() => (showClipboardHistory = true)}
class="control-button"
title="Clipboard History"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2" />
<rect x="9" y="3" width="6" height="4" rx="1" />
</svg>
<span>Clipboard</span>
</button>
</div>
<div class="input-row">
@@ -367,9 +894,12 @@ User: ${formattedMessage}`;
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="resize-handle" onmousedown={handleResizeStart} title="Drag to resize"></div>
<textarea
bind:this={textareaElement}
bind:value={inputValue}
onkeydown={handleKeyDown}
oninput={handleInputChange}
onpaste={handlePaste}
oncontextmenu={handleContextMenu}
placeholder={isConnected
? "Ask Hikari anything... (type / for commands)"
: "Connect to Claude first..."}
@@ -378,17 +908,33 @@ User: ${formattedMessage}`;
style="height: {textareaHeight}px; font-size: var(--terminal-font-size, 14px);"
class="w-full px-4 py-3 bg-[var(--bg-secondary)] border border-[var(--border-color)]
rounded-lg text-[var(--text-primary)] placeholder-gray-500 resize-none
focus:outline-none focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)]
disabled:opacity-50 disabled:cursor-not-allowed"
input-trans-focus disabled:opacity-50 disabled:cursor-not-allowed"
></textarea>
</div>
<div class="button-wrapper">
<button type="button" onclick={handleFilePicker} class="attach-button" title="Attach files">
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path
d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"
/>
</svg>
</button>
{#if isProcessing}
<button
type="button"
onclick={handleInterrupt}
class="send-button bg-red-600 hover:bg-red-700"
class="send-button btn-trans-gradient"
title="Interrupt the current response (Ctrl+C)"
>
<span class="font-bold">â– </span> Stop
@@ -396,8 +942,10 @@ User: ${formattedMessage}`;
{:else}
<button
type="submit"
disabled={!isConnected || isSubmitting || !inputValue.trim()}
class="send-button bg-[var(--accent-primary)] hover:bg-[var(--accent-secondary)]
disabled={!isConnected ||
isSubmitting ||
(!inputValue.trim() && attachments.length === 0)}
class="send-button trans-gradient-button
disabled:opacity-50 disabled:cursor-not-allowed"
>
{#if isSubmitting}
@@ -411,11 +959,68 @@ User: ${formattedMessage}`;
</div>
</form>
{#if showSnippetLibrary}
<SnippetLibraryPanel
onClose={() => (showSnippetLibrary = false)}
onInsert={handleSnippetInsert}
/>
{/if}
{#if showQuickActions}
<QuickActionsPanel onClose={() => (showQuickActions = false)} onAction={handleQuickAction} />
{/if}
{#if showClipboardHistory}
<ClipboardHistoryPanel
isOpen={showClipboardHistory}
onClose={() => (showClipboardHistory = false)}
onInsert={handleClipboardInsert}
/>
{/if}
{#if contextMenuShow && textareaElement}
<TextInputContextMenu
x={contextMenuX}
y={contextMenuY}
inputElement={textareaElement}
onClose={closeContextMenu}
/>
{/if}
<style>
.input-bar {
display: flex;
flex-direction: column;
gap: 8px;
position: relative;
transition:
border-color 0.2s,
background 0.2s;
}
.input-bar.is-dragging {
background: var(--bg-secondary);
border: 2px dashed var(--accent-primary);
border-radius: 12px;
padding: 8px;
}
.input-bar.is-dragging::before {
content: "Drop files here";
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 18px;
font-weight: 500;
color: var(--accent-primary);
pointer-events: none;
z-index: 10;
}
.input-bar.is-dragging > * {
opacity: 0.3;
pointer-events: none;
}
.input-controls {
@@ -424,6 +1029,72 @@ User: ${formattedMessage}`;
gap: 8px;
}
.control-button {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 10px 16px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.2s;
font-size: 14px;
white-space: nowrap;
}
.control-button:hover {
background: var(--bg-tertiary);
border-color: var(--accent-primary);
color: var(--accent-primary);
}
.control-button:active {
transform: scale(0.95);
}
.streamer-toggle.streamer-active {
background: rgba(239, 68, 68, 0.2);
border-color: rgb(239, 68, 68);
color: rgb(248, 113, 113);
animation: pulse-red 2s ease-in-out infinite;
}
.streamer-toggle.streamer-active:hover {
background: rgba(239, 68, 68, 0.3);
border-color: rgb(248, 113, 113);
}
.live-indicator {
width: 8px;
height: 8px;
border-radius: 50%;
background: rgb(239, 68, 68);
animation: pulse-dot 1.5s ease-in-out infinite;
}
@keyframes pulse-red {
0%,
100% {
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.4);
}
50% {
box-shadow: 0 0 0 4px rgba(239, 68, 68, 0);
}
}
@keyframes pulse-dot {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
.input-row {
display: flex;
gap: 12px;
@@ -469,6 +1140,32 @@ User: ${formattedMessage}`;
height: 100%;
}
.attach-button {
display: flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
padding: 0;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.2s;
}
.attach-button:hover {
background: var(--bg-tertiary);
border-color: var(--accent-primary);
color: var(--accent-primary);
transform: scale(1.05);
}
.attach-button:active {
transform: scale(0.95);
}
.send-button {
padding: 0 24px;
height: 48px;
@@ -486,4 +1183,19 @@ User: ${formattedMessage}`;
.send-button:active:not(:disabled) {
transform: scale(0.95);
}
.trans-gradient-button {
background: var(--trans-gradient-vibrant);
border: none;
color: #1a1a2e;
font-weight: 600;
text-shadow: 0 0 2px rgba(255, 255, 255, 0.5);
}
.trans-gradient-button: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);
}
</style>
@@ -12,6 +12,9 @@
{ 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" },
],
},
{
@@ -24,6 +27,17 @@
{ 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: [
+93 -4
View File
@@ -3,6 +3,7 @@
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;
@@ -17,7 +18,20 @@
renderer.code = ({ text, lang }) => {
const language = lang && hljs.getLanguage(lang) ? lang : "plaintext";
const highlighted = hljs.highlight(text, { language }).value;
return `<pre class="hljs-code-block"><code class="hljs language-${language}">${highlighted}</code></pre>`;
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 }) => {
@@ -123,6 +137,34 @@
}
}
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) => {
@@ -138,6 +180,7 @@
onclick={(e) => {
handleSpoilerClick(e);
handleLinkClick(e);
handleCopyClick(e);
}}
onkeydown={handleSpoilerKeydown}
role="presentation"
@@ -163,13 +206,59 @@
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: 6px;
border-radius: 0;
padding: 1em;
margin: 0.75em 0;
margin: 0;
overflow-x: auto;
border: 1px solid var(--border-color);
border: none;
}
.markdown-content :global(.hljs-code-block code) {
+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>
@@ -0,0 +1,476 @@
<script lang="ts">
import { onMount } from "svelte";
import { sessionsStore, type SessionListItem, type SavedSession } from "$lib/stores/sessions";
import { conversationsStore } from "$lib/stores/conversations";
import type { TerminalLine } from "$lib/types/messages";
interface Props {
onClose: () => void;
}
const { onClose }: Props = $props();
let searchInput = $state("");
let selectedSession = $state<SavedSession | null>(null);
let showDeleteConfirm = $state<string | null>(null);
let showExportMenu = $state<string | null>(null);
let isImporting = $state(false);
const sessions = $derived(sessionsStore.sessions);
const isLoading = $derived(sessionsStore.isLoading);
onMount(() => {
sessionsStore.loadSessions();
});
function formatDate(dateString: string): string {
const date = new Date(dateString);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays === 0) {
return `Today at ${date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`;
} else if (diffDays === 1) {
return `Yesterday at ${date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`;
} else if (diffDays < 7) {
return `${diffDays} days ago`;
} else {
return date.toLocaleDateString([], {
month: "short",
day: "numeric",
year: date.getFullYear() !== now.getFullYear() ? "numeric" : undefined,
});
}
}
async function handleSearch(): Promise<void> {
await sessionsStore.searchSessions(searchInput);
}
async function handleViewSession(session: SessionListItem): Promise<void> {
const fullSession = await sessionsStore.loadSession(session.id);
selectedSession = fullSession;
}
async function handleResumeSession(session: SessionListItem): Promise<void> {
const fullSession = await sessionsStore.loadSession(session.id);
if (!fullSession) return;
const newConvId = conversationsStore.createConversation(fullSession.name);
for (const msg of fullSession.messages) {
const line: TerminalLine = {
id: msg.id,
type: msg.type as TerminalLine["type"],
content: msg.content,
timestamp: new Date(msg.timestamp),
toolName: msg.tool_name,
};
conversationsStore.addLineToConversation(newConvId, line.type, line.content, line.toolName);
}
if (fullSession.working_directory) {
conversationsStore.setWorkingDirectoryForConversation(
newConvId,
fullSession.working_directory
);
}
onClose();
}
async function handleDeleteSession(sessionId: string): Promise<void> {
await sessionsStore.deleteSession(sessionId);
showDeleteConfirm = null;
if (selectedSession?.id === sessionId) {
selectedSession = null;
}
}
function handleBackToList(): void {
selectedSession = null;
}
async function handleExportJson(sessionId: string): Promise<void> {
showExportMenu = null;
await sessionsStore.exportSessionAsJson(sessionId);
}
async function handleExportMarkdown(sessionId: string): Promise<void> {
showExportMenu = null;
await sessionsStore.exportSessionAsMarkdown(sessionId);
}
async function handleExportHtml(sessionId: string): Promise<void> {
showExportMenu = null;
await sessionsStore.exportSessionAsHtml(sessionId);
}
async function handleExportPdf(sessionId: string): Promise<void> {
showExportMenu = null;
await sessionsStore.exportSessionAsPdf(sessionId);
}
async function handleImport(): Promise<void> {
isImporting = true;
try {
await sessionsStore.importSession();
} finally {
isImporting = false;
}
}
function toggleExportMenu(sessionId: string): void {
if (showExportMenu === sessionId) {
showExportMenu = null;
} else {
showExportMenu = sessionId;
showDeleteConfirm = null;
}
}
</script>
<div
class="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4"
onclick={onClose}
role="button"
tabindex="0"
onkeydown={(e) => e.key === "Escape" && onClose()}
>
<div
class="bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg shadow-xl max-w-3xl w-full max-h-[80vh] overflow-hidden flex flex-col"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
role="dialog"
aria-labelledby="session-history-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 selectedSession}
<button
onclick={handleBackToList}
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="session-history-title" class="text-xl font-semibold text-[var(--text-primary)]">
{selectedSession ? selectedSession.name : "Session History"}
</h2>
</div>
<div class="flex items-center gap-2">
{#if !selectedSession}
<button
onclick={handleImport}
disabled={isImporting}
class="px-3 py-1.5 text-sm font-medium bg-[var(--bg-secondary)] text-[var(--text-primary)] border border-[var(--border-color)] rounded hover:bg-[var(--bg-tertiary)] transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
title="Import session"
>
<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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"
/>
</svg>
{isImporting ? "Importing..." : "Import"}
</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 selectedSession}
<div class="overflow-y-auto flex-1 p-6">
<div class="text-sm text-[var(--text-tertiary)] mb-4">
<span>{formatDate(selectedSession.created_at)}</span>
<span class="mx-2">•</span>
<span>{selectedSession.message_count} messages</span>
{#if selectedSession.working_directory}
<span class="mx-2">•</span>
<span class="font-mono text-xs">{selectedSession.working_directory}</span>
{/if}
</div>
<div class="space-y-3">
{#each selectedSession.messages as message (message.id)}
<div
class="p-3 rounded-lg {message.type === 'user'
? 'bg-[var(--accent-primary)]/10 border border-[var(--accent-primary)]/20'
: message.type === 'assistant'
? 'bg-[var(--bg-secondary)]'
: message.type === 'error'
? 'bg-red-500/10 border border-red-500/20'
: 'bg-[var(--bg-tertiary)]'}"
>
<div class="flex items-center gap-2 mb-1">
<span
class="text-xs font-medium {message.type === 'user'
? 'text-[var(--accent-primary)]'
: message.type === 'assistant'
? 'text-[var(--text-primary)]'
: message.type === 'error'
? 'text-red-400'
: 'text-[var(--text-tertiary)]'}"
>
{message.type === "user"
? "You"
: message.type === "assistant"
? "Hikari"
: message.type === "tool"
? message.tool_name || "Tool"
: message.type}
</span>
</div>
<p class="text-sm text-[var(--text-secondary)] whitespace-pre-wrap break-words">
{message.content.length > 500
? message.content.slice(0, 500) + "..."
: message.content}
</p>
</div>
{/each}
</div>
</div>
{:else}
<div class="p-4 border-b border-[var(--border-color)]">
<div class="relative">
<input
type="text"
placeholder="Search sessions..."
bind:value={searchInput}
oninput={handleSearch}
class="w-full px-4 py-2 pl-10 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)]"
/>
<svg
class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--text-tertiary)]"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<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"
/>
</svg>
</div>
</div>
<div class="overflow-y-auto flex-1">
{#if $isLoading}
<div class="flex items-center justify-center p-8">
<div class="text-[var(--text-tertiary)]">Loading sessions...</div>
</div>
{:else if $sessions.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="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"
/>
</svg>
<p class="text-[var(--text-secondary)]">No saved sessions yet</p>
<p class="text-sm text-[var(--text-tertiary)] mt-1">
Your conversations will appear here once saved
</p>
</div>
{:else}
<div class="divide-y divide-[var(--border-color)]">
{#each $sessions as session (session.id)}
<div class="p-4 hover:bg-[var(--bg-secondary)] transition-colors group">
<div class="flex items-start justify-between gap-4">
<button class="flex-1 text-left" onclick={() => handleViewSession(session)}>
<div class="flex items-center gap-2 mb-1">
<h3 class="font-medium text-[var(--text-primary)]">{session.name}</h3>
<span class="text-xs text-[var(--text-tertiary)]">
{session.message_count} messages
</span>
</div>
<p class="text-sm text-[var(--text-secondary)] line-clamp-2 mb-2">
{session.preview}
</p>
<div class="flex items-center gap-2 text-xs text-[var(--text-tertiary)]">
<span>{formatDate(session.last_activity_at)}</span>
{#if session.working_directory}
<span>•</span>
<span class="font-mono truncate max-w-[200px]">
{session.working_directory}
</span>
{/if}
</div>
</button>
<div
class="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity"
>
<button
onclick={() => handleResumeSession(session)}
class="btn-trans-gradient px-3 py-1.5 text-xs font-medium rounded"
title="Resume this session"
>
Resume
</button>
<div class="relative">
<button
onclick={() => toggleExportMenu(session.id)}
class="p-1.5 text-[var(--text-tertiary)] hover:text-[var(--text-primary)] transition-colors"
title="Export session"
>
<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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
</button>
{#if showExportMenu === session.id}
<div
class="absolute right-0 top-full mt-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg shadow-lg py-1 z-10 min-w-[140px]"
>
<button
onclick={() => handleExportJson(session.id)}
class="w-full px-3 py-2 text-left text-sm text-[var(--text-primary)] hover:bg-[var(--bg-secondary)] transition-colors"
>
Export as JSON
</button>
<button
onclick={() => handleExportMarkdown(session.id)}
class="w-full px-3 py-2 text-left text-sm text-[var(--text-primary)] hover:bg-[var(--bg-secondary)] transition-colors"
>
Export as Markdown
</button>
<button
onclick={() => handleExportHtml(session.id)}
class="w-full px-3 py-2 text-left text-sm text-[var(--text-primary)] hover:bg-[var(--bg-secondary)] transition-colors"
>
Export as HTML
</button>
<button
onclick={() => handleExportPdf(session.id)}
class="w-full px-3 py-2 text-left text-sm text-[var(--text-primary)] hover:bg-[var(--bg-secondary)] transition-colors"
>
Export as PDF
</button>
</div>
{/if}
</div>
{#if showDeleteConfirm === session.id}
<div class="flex items-center gap-1">
<button
onclick={() => handleDeleteSession(session.id)}
class="px-2 py-1 text-xs font-medium bg-red-500 text-white rounded hover:bg-red-600 transition-colors"
>
Confirm
</button>
<button
onclick={() => (showDeleteConfirm = null)}
class="px-2 py-1 text-xs font-medium bg-[var(--bg-tertiary)] text-[var(--text-secondary)] rounded hover:bg-[var(--bg-secondary)] transition-colors"
>
Cancel
</button>
</div>
{:else}
<button
onclick={() => (showDeleteConfirm = session.id)}
class="p-1.5 text-[var(--text-tertiary)] hover:text-red-400 transition-colors"
title="Delete session"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
{/if}
</div>
</div>
</div>
{/each}
</div>
{/if}
</div>
{/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>
@@ -0,0 +1,467 @@
<script lang="ts">
import { onMount } from "svelte";
import { snippetsStore, type Snippet } from "$lib/stores/snippets";
interface Props {
onClose: () => void;
onInsert: (content: string) => void;
}
const { onClose, onInsert }: Props = $props();
let editingSnippet = $state<Snippet | null>(null);
let isCreating = $state(false);
let showDeleteConfirm = $state<string | null>(null);
let editName = $state("");
let editContent = $state("");
let editCategory = $state("");
let newCategoryInput = $state("");
let showNewCategoryInput = $state(false);
const snippets = $derived(snippetsStore.filteredSnippets);
const categories = $derived(snippetsStore.categories);
const selectedCategory = $derived(snippetsStore.selectedCategory);
const isLoading = $derived(snippetsStore.isLoading);
onMount(() => {
snippetsStore.loadSnippets();
});
function handleSelectCategory(category: string | null): void {
snippetsStore.setSelectedCategory(category);
}
function handleInsert(snippet: Snippet): void {
onInsert(snippet.content);
onClose();
}
function handleStartCreate(): void {
isCreating = true;
editingSnippet = null;
editName = "";
editContent = "";
editCategory = $categories.length > 0 ? $categories[0] : "Custom";
showNewCategoryInput = false;
newCategoryInput = "";
}
function handleStartEdit(snippet: Snippet): void {
editingSnippet = snippet;
isCreating = false;
editName = snippet.name;
editContent = snippet.content;
editCategory = snippet.category;
showNewCategoryInput = false;
newCategoryInput = "";
}
function handleCancelEdit(): void {
editingSnippet = null;
isCreating = false;
showNewCategoryInput = false;
newCategoryInput = "";
}
async function handleSave(): Promise<void> {
const finalCategory =
showNewCategoryInput && newCategoryInput.trim() ? newCategoryInput.trim() : editCategory;
if (!editName.trim() || !editContent.trim() || !finalCategory) {
return;
}
if (isCreating) {
await snippetsStore.createSnippet(editName.trim(), editContent.trim(), finalCategory);
} else if (editingSnippet) {
await snippetsStore.updateSnippet(
editingSnippet.id,
editName.trim(),
editContent.trim(),
finalCategory
);
}
handleCancelEdit();
}
async function handleDelete(snippetId: string): Promise<void> {
await snippetsStore.deleteSnippet(snippetId);
showDeleteConfirm = null;
}
function toggleNewCategory(): void {
showNewCategoryInput = !showNewCategoryInput;
if (showNewCategoryInput) {
newCategoryInput = "";
}
}
</script>
<div
class="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4"
onclick={onClose}
role="button"
tabindex="0"
onkeydown={(e) => e.key === "Escape" && onClose()}
>
<div
class="bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg shadow-xl max-w-4xl w-full max-h-[80vh] overflow-hidden flex flex-col"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
role="dialog"
aria-labelledby="snippet-library-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 editingSnippet || 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="snippet-library-title" class="text-xl font-semibold text-[var(--text-primary)]">
{#if isCreating}
Create Snippet
{:else if editingSnippet}
Edit Snippet
{:else}
Snippet Library
{/if}
</h2>
</div>
<div class="flex items-center gap-2">
{#if !editingSnippet && !isCreating}
<button
onclick={handleStartCreate}
class="btn-trans-gradient px-3 py-1.5 text-sm font-medium 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 Snippet
</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 editingSnippet || isCreating}
<div class="p-6 flex-1 overflow-y-auto">
<div class="space-y-4">
<div>
<label
for="snippet-name"
class="block text-sm font-medium text-[var(--text-secondary)] mb-1"
>
Name
</label>
<input
id="snippet-name"
type="text"
bind:value={editName}
placeholder="Enter snippet 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="snippet-category"
class="block text-sm font-medium text-[var(--text-secondary)] mb-1"
>
Category
</label>
<div class="flex items-center gap-2">
{#if showNewCategoryInput}
<input
type="text"
bind:value={newCategoryInput}
placeholder="Enter new category..."
class="flex-1 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)]"
/>
{:else}
<select
id="snippet-category"
bind:value={editCategory}
class="flex-1 px-4 py-2 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent-primary)]"
>
{#each $categories as category (category)}
<option value={category}>{category}</option>
{/each}
<option value="Custom">Custom</option>
</select>
{/if}
<button
onclick={toggleNewCategory}
class="px-3 py-2 text-sm font-medium bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
>
{showNewCategoryInput ? "Use Existing" : "New Category"}
</button>
</div>
</div>
<div>
<label
for="snippet-content"
class="block text-sm font-medium text-[var(--text-secondary)] mb-1"
>
Content
</label>
<textarea
id="snippet-content"
bind:value={editContent}
placeholder="Enter your prompt snippet..."
rows="8"
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() || !editContent.trim()}
class="btn-trans-gradient px-4 py-2 text-sm font-medium rounded-lg"
>
{isCreating ? "Create" : "Save"}
</button>
</div>
</div>
</div>
{:else}
<div class="flex flex-1 overflow-hidden">
<div class="w-48 border-r border-[var(--border-color)] p-4 overflow-y-auto">
<h3
class="text-xs font-semibold text-[var(--text-tertiary)] uppercase tracking-wider mb-3"
>
Categories
</h3>
<div class="space-y-1">
<button
onclick={() => handleSelectCategory(null)}
class="w-full text-left px-3 py-2 rounded-lg text-sm transition-colors {$selectedCategory ===
null
? 'bg-[var(--accent-primary)]/10 text-[var(--accent-primary)]'
: 'text-[var(--text-secondary)] hover:bg-[var(--bg-secondary)]'}"
>
All Snippets
</button>
{#each $categories as category (category)}
<button
onclick={() => handleSelectCategory(category)}
class="w-full text-left px-3 py-2 rounded-lg text-sm transition-colors {$selectedCategory ===
category
? 'bg-[var(--accent-primary)]/10 text-[var(--accent-primary)]'
: 'text-[var(--text-secondary)] hover:bg-[var(--bg-secondary)]'}"
>
{category}
</button>
{/each}
</div>
</div>
<div class="flex-1 overflow-y-auto">
{#if $isLoading}
<div class="flex items-center justify-center p-8">
<div class="text-[var(--text-tertiary)]">Loading snippets...</div>
</div>
{:else if $snippets.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="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
<p class="text-[var(--text-secondary)]">No snippets in this category</p>
<button
onclick={handleStartCreate}
class="btn-trans-gradient mt-4 px-4 py-2 text-sm font-medium rounded-lg"
>
Create your first snippet
</button>
</div>
{:else}
<div class="divide-y divide-[var(--border-color)]">
{#each $snippets as snippet (snippet.id)}
<div class="p-4 hover:bg-[var(--bg-secondary)] transition-colors group">
<div class="flex items-start justify-between gap-4">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<h3 class="font-medium text-[var(--text-primary)]">{snippet.name}</h3>
{#if snippet.is_default}
<span
class="px-2 py-0.5 text-xs font-medium bg-[var(--accent-primary)]/10 text-[var(--accent-primary)] rounded"
>
Default
</span>
{/if}
<span class="text-xs text-[var(--text-tertiary)]">{snippet.category}</span>
</div>
<p class="text-sm text-[var(--text-secondary)] line-clamp-2 font-mono">
{snippet.content}
</p>
</div>
<div
class="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity"
>
<button
onclick={() => handleInsert(snippet)}
class="btn-trans-gradient px-3 py-1.5 text-xs font-medium rounded"
title="Insert this snippet"
>
Insert
</button>
<button
onclick={() => handleStartEdit(snippet)}
class="p-1.5 text-[var(--text-tertiary)] hover:text-[var(--text-primary)] transition-colors"
title="Edit snippet"
>
<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="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 !snippet.is_default}
{#if showDeleteConfirm === snippet.id}
<div class="flex items-center gap-1">
<button
onclick={() => handleDelete(snippet.id)}
class="px-2 py-1 text-xs font-medium bg-red-500 text-white rounded hover:bg-red-600 transition-colors"
>
Confirm
</button>
<button
onclick={() => (showDeleteConfirm = null)}
class="px-2 py-1 text-xs font-medium bg-[var(--bg-tertiary)] text-[var(--text-secondary)] rounded hover:bg-[var(--bg-secondary)] transition-colors"
>
Cancel
</button>
</div>
{:else}
<button
onclick={() => (showDeleteConfirm = snippet.id)}
class="p-1.5 text-[var(--text-tertiary)] hover:text-red-400 transition-colors"
title="Delete snippet"
>
<svg
class="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
{/if}
{/if}
</div>
</div>
</div>
{/each}
</div>
{/if}
</div>
</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>
-17
View File
@@ -14,7 +14,6 @@
<div class="stats-row">
<span class="stat-label">Messages:</span>
<span class="stat-value">{$formattedStats.messagesSession}</span>
<span class="stat-secondary">/ {$formattedStats.messagesTotal}</span>
</div>
<div class="stats-section">
@@ -32,11 +31,6 @@
<span class="stat-label">Output:</span>
<span class="stat-value">{$formattedStats.sessionOutputTokens}</span>
</div>
<div class="stat-row stat-highlight">
<span class="stat-label">Total:</span>
<span class="stat-value">{$formattedStats.totalTokens}</span>
<span class="stat-cost">{$formattedStats.totalCost}</span>
</div>
</div>
<div class="stats-section">
@@ -44,17 +38,14 @@
<div class="stat-row">
<span class="stat-label">Code blocks:</span>
<span class="stat-value">{$formattedStats.codeBlocksSession}</span>
<span class="stat-secondary">/ {$formattedStats.codeBlocksTotal}</span>
</div>
<div class="stat-row">
<span class="stat-label">Files edited:</span>
<span class="stat-value">{$formattedStats.filesEditedSession}</span>
<span class="stat-secondary">/ {$formattedStats.filesEditedTotal}</span>
</div>
<div class="stat-row">
<span class="stat-label">Files created:</span>
<span class="stat-value">{$formattedStats.filesCreatedSession}</span>
<span class="stat-secondary">/ {$formattedStats.filesCreatedTotal}</span>
</div>
</div>
@@ -128,14 +119,6 @@
opacity: 0.8;
}
.stat-highlight {
font-weight: 600;
color: var(--accent-primary);
margin-top: 0.25rem;
padding-top: 0.25rem;
border-top: 1px solid var(--border-color);
}
.stat-label {
color: var(--text-secondary, #9ca3af);
}
+164 -12
View File
@@ -1,9 +1,10 @@
<script lang="ts">
interface Props {
onToggleAchievements?: () => void;
onToggleCompact?: () => void;
}
const { onToggleAchievements = () => {} }: Props = $props();
const { onToggleAchievements = () => {}, onToggleCompact = () => {} }: Props = $props();
import { invoke } from "@tauri-apps/api/core";
import { getVersion } from "@tauri-apps/api/app";
@@ -11,7 +12,8 @@
import { openUrl } from "@tauri-apps/plugin-opener";
import { get } from "svelte/store";
import { claudeStore } from "$lib/stores/claude";
import { configStore, type HikariConfig } from "$lib/stores/config";
import { configStore, type HikariConfig, isStreamerMode } from "$lib/stores/config";
import { editorStore } from "$lib/stores/editor";
import type { ConnectionStatus } from "$lib/types/messages";
import { onMount } from "svelte";
import StatsDisplay from "./StatsDisplay.svelte";
@@ -19,6 +21,9 @@
import HelpPanel from "./HelpPanel.svelte";
import KeyboardShortcutsModal from "./KeyboardShortcutsModal.svelte";
import { achievementProgress } from "$lib/stores/achievements";
import SessionHistoryPanel from "./SessionHistoryPanel.svelte";
import GitPanel from "./GitPanel.svelte";
import ProfilePanel from "./ProfilePanel.svelte";
const DISCORD_URL = "https://chat.nhcarrigan.com";
const DONATE_URL = "https://donate.nhcarrigan.com";
@@ -33,13 +38,27 @@
let showAbout = $state(false);
let showHelp = $state(false);
let showKeyboardShortcuts = $state(false);
let showSessionHistory = $state(false);
let showGitPanel = $state(false);
let showProfile = $state(false);
const progress = $derived($achievementProgress);
let currentConfig: HikariConfig = $state({
provider_type: "claude_cli",
model: null,
api_key: null,
custom_instructions: null,
mcp_servers_json: null,
auto_granted_tools: [],
ollama_base_url: "http://localhost:11434",
ollama_model: null,
openai_api_key: null,
openai_base_url: "https://api.openai.com/v1",
openai_model: null,
anthropic_api_key: null,
anthropic_base_url: "https://api.anthropic.com",
anthropic_model: null,
gemini_api_key: null,
gemini_model: null,
theme: "dark",
greeting_enabled: true,
greeting_custom_prompt: null,
@@ -49,8 +68,39 @@
update_checks_enabled: true,
character_panel_width: null,
font_size: 14,
minimize_to_tray: false,
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 streamerModeActive = $state(false);
isStreamerMode.subscribe((value) => {
streamerModeActive = value;
});
let editorVisible = $state(false);
editorStore.isEditorVisible.subscribe((value) => {
editorVisible = value;
});
function toggleEditor() {
editorStore.toggleEditor();
}
onMount(async () => {
appVersion = await getVersion();
});
@@ -106,12 +156,23 @@
await invoke("start_claude", {
conversationId,
options: {
provider_type: currentConfig.provider_type || "claude_cli",
working_dir: targetDir,
model: currentConfig.model || null,
api_key: currentConfig.api_key || null,
custom_instructions: currentConfig.custom_instructions || null,
mcp_servers_json: currentConfig.mcp_servers_json || null,
allowed_tools: allAllowedTools,
ollama_base_url: currentConfig.ollama_base_url || "http://localhost:11434",
ollama_model: currentConfig.ollama_model || null,
openai_api_key: currentConfig.openai_api_key || null,
openai_base_url: currentConfig.openai_base_url || "https://api.openai.com/v1",
openai_model: currentConfig.openai_model || null,
anthropic_api_key: currentConfig.anthropic_api_key || null,
anthropic_base_url: currentConfig.anthropic_base_url || "https://api.anthropic.com",
anthropic_model: currentConfig.anthropic_model || null,
gemini_api_key: currentConfig.gemini_api_key || null,
gemini_model: currentConfig.gemini_model || null,
},
});
} catch (error) {
@@ -202,9 +263,43 @@
</div>
<div class="flex items-center gap-3">
{#if streamerModeActive}
<div
class="w-2.5 h-2.5 rounded-full bg-red-500 animate-pulse"
title="Streamer mode active (Ctrl+Shift+S to toggle)"
></div>
{/if}
<button
onclick={() => (showProfile = true)}
class="p-1 text-gray-500 icon-trans-hover"
title="Profile"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
/>
</svg>
</button>
<button
onclick={onToggleCompact}
class="p-1 text-gray-500 icon-trans-hover"
title="Compact Mode (Ctrl+Shift+M)"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4"
/>
</svg>
</button>
<button
onclick={toggleAchievements}
class="p-1 text-gray-500 hover:text-[var(--accent-primary)] transition-colors relative"
class="p-1 text-gray-500 icon-trans-hover relative"
title="Achievements"
>
<span class="text-lg">🏆</span>
@@ -216,11 +311,56 @@
</span>
{/if}
</button>
<button
onclick={() => (showSessionHistory = true)}
class="p-1 text-gray-500 icon-trans-hover"
title="Session History"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</button>
<button
onclick={() => (showGitPanel = true)}
class="p-1 text-gray-500 icon-trans-hover"
title="Git Panel"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"
/>
</svg>
</button>
<button
onclick={toggleEditor}
disabled={connectionStatus !== "connected"}
class="p-1 text-gray-500 icon-trans-hover {editorVisible
? 'text-[var(--trans-pink)]'
: ''} disabled:opacity-40 disabled:cursor-not-allowed"
title={connectionStatus === "connected"
? "File Editor (Ctrl+E)"
: "Connect to enable file editor"}
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
</button>
<button
onclick={() => (showStats = !showStats)}
class="p-1 text-gray-500 hover:text-[var(--accent-primary)] transition-colors {showStats
? 'text-[var(--accent-primary)]'
: ''}"
class="p-1 text-gray-500 icon-trans-hover {showStats ? 'text-[var(--trans-pink)]' : ''}"
title="Usage Stats"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -234,7 +374,7 @@
</button>
<button
onclick={configStore.openSidebar}
class="p-1 text-gray-500 hover:text-[var(--accent-primary)] transition-colors"
class="p-1 text-gray-500 icon-trans-hover"
title="Settings"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -254,7 +394,7 @@
</button>
<button
onclick={() => openUrl(DONATE_URL)}
class="p-1 text-gray-500 hover:text-[var(--accent-primary)] transition-colors"
class="p-1 text-gray-500 icon-trans-hover"
title="Support our work"
>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
@@ -265,7 +405,7 @@
</button>
<button
onclick={() => (showAbout = true)}
class="p-1 text-gray-500 hover:text-[var(--accent-primary)] transition-colors"
class="p-1 text-gray-500 icon-trans-hover"
title="About Hikari Desktop"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -279,7 +419,7 @@
</button>
<button
onclick={() => (showKeyboardShortcuts = true)}
class="p-1 text-gray-500 hover:text-[var(--accent-primary)] transition-colors"
class="p-1 text-gray-500 icon-trans-hover"
title="Keyboard Shortcuts"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -299,7 +439,7 @@
</button>
<button
onclick={() => (showHelp = true)}
class="p-1 text-gray-500 hover:text-[var(--accent-primary)] transition-colors"
class="p-1 text-gray-500 icon-trans-hover"
title="Help"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -313,7 +453,7 @@
</button>
<button
onclick={() => openUrl(DISCORD_URL)}
class="p-1 text-gray-500 hover:text-[var(--accent-primary)] transition-colors"
class="p-1 text-gray-500 icon-trans-hover"
title="Join our Discord"
>
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
@@ -370,3 +510,15 @@
{#if showKeyboardShortcuts}
<KeyboardShortcutsModal onClose={() => (showKeyboardShortcuts = false)} />
{/if}
{#if showSessionHistory}
<SessionHistoryPanel onClose={() => (showSessionHistory = false)} />
{/if}
{#if showGitPanel}
<GitPanel isOpen={showGitPanel} onClose={() => (showGitPanel = false)} />
{/if}
{#if showProfile}
<ProfilePanel onClose={() => (showProfile = false)} />
{/if}
+131 -5
View File
@@ -1,10 +1,12 @@
<script lang="ts">
import { claudeStore, type TerminalLine } from "$lib/stores/claude";
import { afterUpdate, tick } from "svelte";
import { afterUpdate, tick, onMount, onDestroy } from "svelte";
import ConversationTabs from "./ConversationTabs.svelte";
import Markdown from "./Markdown.svelte";
import HighlightedText from "./HighlightedText.svelte";
import { searchState, searchQuery } from "$lib/stores/search";
import { clipboardStore } from "$lib/stores/clipboard";
import { shouldHidePaths, maskPaths } from "$lib/stores/config";
let terminalElement: HTMLDivElement;
let shouldAutoScroll = true;
@@ -17,6 +19,11 @@
currentSearchQuery = value;
});
let hidePaths = false;
shouldHidePaths.subscribe((value) => {
hidePaths = value;
});
claudeStore.terminalLines.subscribe((value) => {
lines = value;
});
@@ -122,6 +129,56 @@
searchState.setMatchCount(0);
}
}
// Handle manual text selection copy events
function handleCopy() {
const selection = window.getSelection();
const selectedText = selection?.toString();
if (selectedText && selectedText.trim().length > 0) {
// Only capture multi-line or longer text (likely code/meaningful content)
if (selectedText.includes("\n") || selectedText.length > 50) {
clipboardStore.captureClipboard(selectedText, null, "Copied from chat");
}
}
}
onMount(() => {
// Listen for copy events on the terminal
if (terminalElement) {
terminalElement.addEventListener("copy", handleCopy);
}
});
onDestroy(() => {
if (terminalElement) {
terminalElement.removeEventListener("copy", handleCopy);
}
});
// Copy message content to clipboard
async function copyMessage(content: string) {
try {
await navigator.clipboard.writeText(content);
// Optionally capture to clipboard history
await clipboardStore.captureClipboard(content, null, "Message from chat");
// Visual feedback could be added here if needed
} catch (error) {
console.error("Failed to copy message:", error);
}
}
// State for showing "Copied!" feedback
let copiedMessageId: string | null = null;
async function handleCopyMessage(messageId: string, content: string) {
await copyMessage(content);
copiedMessageId = messageId;
setTimeout(() => {
copiedMessageId = null;
}, 2000);
}
</script>
<div
@@ -152,7 +209,7 @@
</div>
{:else}
{#each lines as line (line.id)}
<div class="terminal-line mb-2 {getLineClass(line.type)}">
<div class="terminal-line mb-2 {getLineClass(line.type)} relative group">
<span class="terminal-timestamp text-xs mr-2">{formatTime(line.timestamp)}</span>
{#if getLinePrefix(line.type)}
<span class="terminal-prefix mr-2">{getLinePrefix(line.type)}</span>
@@ -160,10 +217,38 @@
{#if line.toolName}
<span class="terminal-tool-name mr-2">[{line.toolName}]</span>
{/if}
{#if line.type === "assistant"}
<Markdown content={line.content} searchQuery={currentSearchQuery} />
{#if line.type === "assistant" || line.type === "user"}
<div class="message-content-wrapper">
<Markdown
content={maskPaths(line.content, hidePaths)}
searchQuery={currentSearchQuery}
/>
<button
class="copy-message-btn opacity-0 group-hover:opacity-100 transition-opacity"
onclick={() => handleCopyMessage(line.id, line.content)}
title="Copy message"
>
<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">{copiedMessageId === line.id ? "Copied!" : "Copy"}</span>
</button>
</div>
{:else}
<HighlightedText content={line.content} searchQuery={currentSearchQuery} />
<HighlightedText
content={maskPaths(line.content, hidePaths)}
searchQuery={currentSearchQuery}
/>
{/if}
</div>
{/each}
@@ -228,4 +313,45 @@
border-radius: 2px;
padding: 0 2px;
}
/* Message content wrapper for positioning */
.message-content-wrapper {
position: relative;
display: inline-block;
width: 100%;
}
/* Copy button styling */
.copy-message-btn {
position: absolute;
top: 0;
right: 0;
display: flex;
align-items: center;
gap: 0.4em;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
color: var(--text-secondary);
padding: 0.25em 0.5em;
cursor: pointer;
border-radius: 4px;
font-size: 0.85em;
font-family: inherit;
transition: all 0.15s ease;
}
.copy-message-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
border-color: var(--border-hover, var(--border-color));
}
.copy-message-btn svg {
flex-shrink: 0;
}
/* Ensure relative positioning for parent */
.terminal-line {
position: relative;
}
</style>
@@ -0,0 +1,228 @@
<script lang="ts">
interface Props {
x: number;
y: number;
inputElement: HTMLTextAreaElement | HTMLInputElement;
onClose: () => void;
}
let { x, y, inputElement, onClose }: Props = $props();
// Menu element reference for measuring
let menuElement: HTMLDivElement | undefined = $state();
// Adjusted position to keep menu within viewport
let adjustedX = $derived.by(() => {
if (!menuElement) return x;
const menuWidth = menuElement.offsetWidth || 180;
const maxX = window.innerWidth - menuWidth - 8;
return Math.min(x, maxX);
});
let adjustedY = $derived.by(() => {
if (!menuElement) return y;
const menuHeight = menuElement.offsetHeight || 250;
const maxY = window.innerHeight - menuHeight - 8;
return Math.min(y, maxY);
});
function handleKeydown(event: KeyboardEvent) {
if (event.key === "Escape") {
onClose();
}
}
function execCommand(command: "cut" | "copy" | "paste" | "selectAll" | "undo" | "redo") {
inputElement.focus();
switch (command) {
case "cut":
document.execCommand("cut");
break;
case "copy":
document.execCommand("copy");
break;
case "paste":
document.execCommand("paste");
break;
case "selectAll":
inputElement.select();
break;
case "undo":
document.execCommand("undo");
break;
case "redo":
document.execCommand("redo");
break;
}
onClose();
}
// Check if there's a selection
let hasSelection = $derived(inputElement.selectionStart !== inputElement.selectionEnd);
</script>
<svelte:window onkeydown={handleKeydown} />
<!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
<div
class="menu-overlay"
onclick={onClose}
oncontextmenu={(e) => {
e.preventDefault();
onClose();
}}
>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
bind:this={menuElement}
class="menu-content"
style="left: {adjustedX}px; top: {adjustedY}px;"
onclick={(e) => e.stopPropagation()}
>
<button class="menu-item" onclick={() => execCommand("undo")}>
<svg class="menu-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M3 10h10a5 5 0 0 1 5 5v2M3 10l4-4M3 10l4 4"
/>
</svg>
Undo
<span class="shortcut">Ctrl+Z</span>
</button>
<button class="menu-item" onclick={() => execCommand("redo")}>
<svg class="menu-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 10H11a5 5 0 0 0-5 5v2M21 10l-4-4M21 10l-4 4"
/>
</svg>
Redo
<span class="shortcut">Ctrl+Y</span>
</button>
<div class="menu-divider"></div>
<button class="menu-item" onclick={() => execCommand("cut")} disabled={!hasSelection}>
<svg class="menu-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M14.121 14.121L19 19m-7-7l7-7m-7 7l-2.879 2.879M12 12L9.121 9.121m0 5.758a3 3 0 1 0-4.243-4.243 3 3 0 0 0 4.243 4.243zm0-5.758a3 3 0 1 0-4.243-4.243 3 3 0 0 0 4.243 4.243z"
/>
</svg>
Cut
<span class="shortcut">Ctrl+X</span>
</button>
<button class="menu-item" onclick={() => execCommand("copy")} disabled={!hasSelection}>
<svg class="menu-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 16H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v2m-6 12h8a2 2 0 0 0 2-2v-8a2 2 0 0 0-2-2h-8a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2z"
/>
</svg>
Copy
<span class="shortcut">Ctrl+C</span>
</button>
<button class="menu-item" onclick={() => execCommand("paste")}>
<svg class="menu-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5H7a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-2M9 5a2 2 0 0 0 2 2h2a2 2 0 0 0 2-2M9 5a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2"
/>
</svg>
Paste
<span class="shortcut">Ctrl+V</span>
</button>
<div class="menu-divider"></div>
<button class="menu-item" onclick={() => execCommand("selectAll")}>
<svg class="menu-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 5a1 1 0 0 1 1-1h14a1 1 0 0 1 1 1v14a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V5z"
/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 9h6v6H9z" />
</svg>
Select All
<span class="shortcut">Ctrl+A</span>
</button>
</div>
</div>
<style>
.menu-overlay {
position: fixed;
inset: 0;
z-index: 50;
}
.menu-content {
position: absolute;
z-index: 50;
min-width: 180px;
border-radius: 0.375rem;
border: 1px solid var(--border-color);
background-color: var(--bg-secondary);
padding: 0.25rem 0;
box-shadow:
0 10px 15px -3px rgba(0, 0, 0, 0.1),
0 4px 6px -2px rgba(0, 0, 0, 0.05);
}
.menu-item {
display: flex;
width: 100%;
align-items: center;
gap: 0.5rem;
padding: 0.375rem 0.75rem;
text-align: left;
font-size: 0.875rem;
color: var(--text-primary);
background: transparent;
border: none;
cursor: pointer;
transition: background-color 0.15s ease;
}
.menu-item:hover:not(:disabled) {
background-color: var(--bg-primary);
}
.menu-item:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.menu-divider {
margin: 0.25rem 0;
border-top: 1px solid var(--border-color);
}
.menu-icon {
width: 1rem;
height: 1rem;
flex-shrink: 0;
}
.shortcut {
margin-left: auto;
font-size: 0.75rem;
color: var(--text-secondary);
}
</style>
+1 -4
View File
@@ -53,10 +53,7 @@
Current version: {updateInfo.current_version}
</p>
<div class="flex gap-2">
<button
onclick={openRelease}
class="px-3 py-1.5 bg-[var(--accent-primary)] text-white rounded text-sm hover:brightness-110 transition-all"
>
<button onclick={openRelease} class="btn-trans-gradient px-3 py-1.5 rounded text-sm">
View Release
</button>
<button

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