Compare commits

...

10 Commits

Author SHA1 Message Date
naomi e4288248b1 release: v1.2.0
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m18s
CI / Lint & Test (push) Successful in 17m11s
CI / Build Linux (push) Successful in 19m53s
CI / Build Windows (cross-compile) (push) Successful in 29m35s
2026-02-04 19:59:47 -08:00
naomi 1c45507cdf feat: massive overhaul to manage costs (#103)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m1s
CI / Lint & Test (push) Has been cancelled
CI / Build Linux (push) Has been cancelled
CI / Build Windows (cross-compile) (push) Has been cancelled
### Explanation

_No response_

### Issue

Closes #102

### 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: #103
Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com>
Co-committed-by: Naomi Carrigan <commits@nhcarrigan.com>
2026-02-04 19:58:43 -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
101 changed files with 7969 additions and 119 deletions
+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`
+28 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "hikari-desktop", "name": "hikari-desktop",
"version": "1.0.0", "version": "1.2.0",
"description": "", "description": "",
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -27,6 +27,32 @@
}, },
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@codemirror/commands": "6.8.1",
"@codemirror/lang-angular": "^0.1.4",
"@codemirror/lang-cpp": "^6.0.3",
"@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-go": "^6.0.1",
"@codemirror/lang-html": "^6.4.11",
"@codemirror/lang-java": "^6.0.2",
"@codemirror/lang-javascript": "^6.2.4",
"@codemirror/lang-json": "^6.0.2",
"@codemirror/lang-less": "^6.0.2",
"@codemirror/lang-markdown": "^6.5.0",
"@codemirror/lang-php": "^6.0.2",
"@codemirror/lang-python": "^6.2.1",
"@codemirror/lang-rust": "^6.0.2",
"@codemirror/lang-sass": "^6.0.2",
"@codemirror/lang-sql": "^6.10.0",
"@codemirror/lang-vue": "^0.1.3",
"@codemirror/lang-wast": "^6.0.2",
"@codemirror/lang-xml": "^6.1.0",
"@codemirror/lang-yaml": "^6.1.2",
"@codemirror/language": "^6.12.1",
"@codemirror/legacy-modes": "^6.5.2",
"@codemirror/state": "^6.5.4",
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.39.11",
"@lezer/highlight": "^1.2.3",
"@tauri-apps/api": "^2", "@tauri-apps/api": "^2",
"@tauri-apps/plugin-clipboard-manager": "^2.3.2", "@tauri-apps/plugin-clipboard-manager": "^2.3.2",
"@tauri-apps/plugin-dialog": "^2", "@tauri-apps/plugin-dialog": "^2",
@@ -36,6 +62,7 @@
"@tauri-apps/plugin-os": "^2", "@tauri-apps/plugin-os": "^2",
"@tauri-apps/plugin-shell": "^2.3.4", "@tauri-apps/plugin-shell": "^2.3.4",
"@tauri-apps/plugin-store": "^2", "@tauri-apps/plugin-store": "^2",
"codemirror": "^6.0.2",
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
"marked": "^17.0.1" "marked": "^17.0.1"
}, },
+552
View File
@@ -8,6 +8,84 @@ importers:
.: .:
dependencies: 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': '@tauri-apps/api':
specifier: ^2 specifier: ^2
version: 2.9.1 version: 2.9.1
@@ -35,6 +113,9 @@ importers:
'@tauri-apps/plugin-store': '@tauri-apps/plugin-store':
specifier: ^2 specifier: ^2
version: 2.4.2 version: 2.4.2
codemirror:
specifier: ^6.0.2
version: 6.0.2
highlight.js: highlight.js:
specifier: ^11.11.1 specifier: ^11.11.1
version: 11.11.1 version: 11.11.1
@@ -158,6 +239,90 @@ packages:
resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
engines: {node: '>=18'} 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': '@csstools/color-helpers@5.1.0':
resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -425,6 +590,60 @@ packages:
'@jridgewell/trace-mapping@0.3.31': '@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} 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': '@polka/url@1.0.0-next.29':
resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
@@ -1015,6 +1234,9 @@ packages:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'} engines: {node: '>=6'}
codemirror@6.0.2:
resolution: {integrity: sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==}
color-convert@2.0.1: color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'} engines: {node: '>=7.0.0'}
@@ -1029,6 +1251,9 @@ packages:
resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==} resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
crelt@1.0.6:
resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==}
cross-spawn@7.0.6: cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
@@ -1663,6 +1888,9 @@ packages:
resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
engines: {node: '>=8'} engines: {node: '>=8'}
style-mod@4.1.3:
resolution: {integrity: sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==}
supports-color@7.2.0: supports-color@7.2.0:
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -1842,6 +2070,9 @@ packages:
jsdom: jsdom:
optional: true optional: true
w3c-keyname@2.2.8:
resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==}
w3c-xmlserializer@5.0.0: w3c-xmlserializer@5.0.0:
resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -1949,6 +2180,216 @@ snapshots:
'@bcoe/v8-coverage@1.0.2': {} '@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/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)': '@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)':
@@ -2127,6 +2568,101 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2 '@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5 '@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': {} '@polka/url@1.0.0-next.29': {}
'@rollup/rollup-android-arm-eabi@4.55.1': '@rollup/rollup-android-arm-eabi@4.55.1':
@@ -2678,6 +3214,16 @@ snapshots:
clsx@2.1.1: {} 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: color-convert@2.0.1:
dependencies: dependencies:
color-name: 1.1.4 color-name: 1.1.4
@@ -2688,6 +3234,8 @@ snapshots:
cookie@0.6.0: {} cookie@0.6.0: {}
crelt@1.0.6: {}
cross-spawn@7.0.6: cross-spawn@7.0.6:
dependencies: dependencies:
path-key: 3.1.1 path-key: 3.1.1
@@ -3304,6 +3852,8 @@ snapshots:
strip-json-comments@3.1.1: {} strip-json-comments@3.1.1: {}
style-mod@4.1.3: {}
supports-color@7.2.0: supports-color@7.2.0:
dependencies: dependencies:
has-flag: 4.0.0 has-flag: 4.0.0
@@ -3463,6 +4013,8 @@ snapshots:
- tsx - tsx
- yaml - yaml
w3c-keyname@2.2.8: {}
w3c-xmlserializer@5.0.0: w3c-xmlserializer@5.0.0:
dependencies: dependencies:
xml-name-validator: 5.0.0 xml-name-validator: 5.0.0
+1 -1
View File
@@ -1602,7 +1602,7 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]] [[package]]
name = "hikari-desktop" name = "hikari-desktop"
version = "0.3.0" version = "1.1.1"
dependencies = [ dependencies = [
"chrono", "chrono",
"parking_lot", "parking_lot",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "hikari-desktop" name = "hikari-desktop"
version = "1.0.0" version = "1.2.0"
description = "Hikari - Claude Code Visual Assistant" description = "Hikari - Claude Code Visual Assistant"
authors = ["Naomi Carrigan"] authors = ["Naomi Carrigan"]
edition = "2021" edition = "2021"
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

+56 -41
View File
@@ -1935,6 +1935,7 @@ pub fn check_achievements(
let search_count: u64 = search_tools let search_count: u64 = search_tools
.iter() .iter()
.filter_map(|tool| stats.tools_usage.get(*tool)) .filter_map(|tool| stats.tools_usage.get(*tool))
.map(|t| t.call_count)
.sum(); .sum();
if search_count >= 50 && progress.unlock(AchievementId::Explorer) { if search_count >= 50 && progress.unlock(AchievementId::Explorer) {
newly_unlocked.push(AchievementId::Explorer); newly_unlocked.push(AchievementId::Explorer);
@@ -1988,25 +1989,25 @@ pub fn check_achievements(
// TODO: Track different Claude models used // TODO: Track different Claude models used
// Tool mastery achievements // Tool mastery achievements
if let Some(bash_count) = stats.tools_usage.get("Bash") { if let Some(bash_stats) = stats.tools_usage.get("Bash") {
if *bash_count >= 50 && progress.unlock(AchievementId::BashMaster) { if bash_stats.call_count >= 50 && progress.unlock(AchievementId::BashMaster) {
newly_unlocked.push(AchievementId::BashMaster); newly_unlocked.push(AchievementId::BashMaster);
} }
} }
if let Some(read_count) = stats.tools_usage.get("Read") { if let Some(read_stats) = stats.tools_usage.get("Read") {
if *read_count >= 100 && progress.unlock(AchievementId::FileExplorer) { if read_stats.call_count >= 100 && progress.unlock(AchievementId::FileExplorer) {
newly_unlocked.push(AchievementId::FileExplorer); newly_unlocked.push(AchievementId::FileExplorer);
} }
} }
if let Some(grep_count) = stats.tools_usage.get("Grep") { if let Some(grep_stats) = stats.tools_usage.get("Grep") {
if *grep_count >= 50 && progress.unlock(AchievementId::SearchExpert) { if grep_stats.call_count >= 50 && progress.unlock(AchievementId::SearchExpert) {
newly_unlocked.push(AchievementId::SearchExpert); newly_unlocked.push(AchievementId::SearchExpert);
} }
} }
// Git Guru - check git command usage in Bash // Git Guru - check git command usage in Bash
if let Some(bash_count) = stats.tools_usage.get("Bash") { if let Some(bash_stats) = stats.tools_usage.get("Bash") {
if *bash_count >= 10 && progress.unlock(AchievementId::GitGuru) { if bash_stats.call_count >= 10 && progress.unlock(AchievementId::GitGuru) {
// TODO: More specific git command tracking // TODO: More specific git command tracking
newly_unlocked.push(AchievementId::GitGuru); newly_unlocked.push(AchievementId::GitGuru);
} }
@@ -2055,28 +2056,28 @@ pub fn check_achievements(
} }
// More tool mastery achievements // More tool mastery achievements
if let Some(edit_count) = stats.tools_usage.get("Edit") { if let Some(edit_stats) = stats.tools_usage.get("Edit") {
if *edit_count >= 100 && progress.unlock(AchievementId::EditMaster) { if edit_stats.call_count >= 100 && progress.unlock(AchievementId::EditMaster) {
newly_unlocked.push(AchievementId::EditMaster); newly_unlocked.push(AchievementId::EditMaster);
} }
} }
if let Some(write_count) = stats.tools_usage.get("Write") { if let Some(write_stats) = stats.tools_usage.get("Write") {
if *write_count >= 50 && progress.unlock(AchievementId::WriteMaster) { if write_stats.call_count >= 50 && progress.unlock(AchievementId::WriteMaster) {
newly_unlocked.push(AchievementId::WriteMaster); newly_unlocked.push(AchievementId::WriteMaster);
} }
} }
if let Some(glob_count) = stats.tools_usage.get("Glob") { if let Some(glob_stats) = stats.tools_usage.get("Glob") {
if *glob_count >= 100 && progress.unlock(AchievementId::GlobMaster) { if glob_stats.call_count >= 100 && progress.unlock(AchievementId::GlobMaster) {
newly_unlocked.push(AchievementId::GlobMaster); newly_unlocked.push(AchievementId::GlobMaster);
} }
} }
if let Some(task_count) = stats.tools_usage.get("Task") { if let Some(task_stats) = stats.tools_usage.get("Task") {
if *task_count >= 50 && progress.unlock(AchievementId::TaskMaster) { if task_stats.call_count >= 50 && progress.unlock(AchievementId::TaskMaster) {
newly_unlocked.push(AchievementId::TaskMaster); newly_unlocked.push(AchievementId::TaskMaster);
} }
} }
if let Some(web_count) = stats.tools_usage.get("WebFetch") { if let Some(web_stats) = stats.tools_usage.get("WebFetch") {
if *web_count >= 20 && progress.unlock(AchievementId::WebFetcher) { if web_stats.call_count >= 20 && progress.unlock(AchievementId::WebFetcher) {
newly_unlocked.push(AchievementId::WebFetcher); newly_unlocked.push(AchievementId::WebFetcher);
} }
} }
@@ -2085,7 +2086,7 @@ pub fn check_achievements(
.tools_usage .tools_usage
.iter() .iter()
.filter(|(name, _)| name.starts_with("mcp__")) .filter(|(name, _)| name.starts_with("mcp__"))
.map(|(_, count)| count) .map(|(_, tool_stats)| tool_stats.call_count)
.sum(); .sum();
if mcp_count >= 50 && progress.unlock(AchievementId::McpExplorer) { if mcp_count >= 50 && progress.unlock(AchievementId::McpExplorer) {
newly_unlocked.push(AchievementId::McpExplorer); newly_unlocked.push(AchievementId::McpExplorer);
@@ -2323,6 +2324,11 @@ mod tests {
morning_sessions: 0, morning_sessions: 0,
night_sessions: 0, night_sessions: 0,
last_session_date: None, last_session_date: None,
context_tokens_used: 0,
context_window_limit: 200_000,
context_utilisation_percent: 0.0,
potential_cache_hits: 0,
potential_cache_savings_tokens: 0,
achievements: AchievementProgress::new(), achievements: AchievementProgress::new(),
} }
} }
@@ -2733,12 +2739,21 @@ mod tests {
// check_achievements tests - Tool Usage // check_achievements tests - Tool Usage
// ===================== // =====================
// Helper function to create a ToolTokenStats with just call count for tests
fn tool_stats(call_count: u64) -> crate::stats::ToolTokenStats {
crate::stats::ToolTokenStats {
call_count,
estimated_input_tokens: 0,
estimated_output_tokens: 0,
}
}
#[test] #[test]
fn test_check_achievements_first_tool() { fn test_check_achievements_first_tool() {
let mut stats = create_test_stats(); let mut stats = create_test_stats();
let mut progress = AchievementProgress::new(); let mut progress = AchievementProgress::new();
stats.tools_usage.insert("Read".to_string(), 1); stats.tools_usage.insert("Read".to_string(), tool_stats(1));
let newly = check_achievements(&stats, &mut progress); let newly = check_achievements(&stats, &mut progress);
assert!(newly.contains(&AchievementId::FirstTool)); assert!(newly.contains(&AchievementId::FirstTool));
@@ -2749,11 +2764,11 @@ mod tests {
let mut stats = create_test_stats(); let mut stats = create_test_stats();
let mut progress = AchievementProgress::new(); let mut progress = AchievementProgress::new();
stats.tools_usage.insert("Read".to_string(), 1); stats.tools_usage.insert("Read".to_string(), tool_stats(1));
stats.tools_usage.insert("Write".to_string(), 1); stats.tools_usage.insert("Write".to_string(), tool_stats(1));
stats.tools_usage.insert("Edit".to_string(), 1); stats.tools_usage.insert("Edit".to_string(), tool_stats(1));
stats.tools_usage.insert("Bash".to_string(), 1); stats.tools_usage.insert("Bash".to_string(), tool_stats(1));
stats.tools_usage.insert("Grep".to_string(), 1); stats.tools_usage.insert("Grep".to_string(), tool_stats(1));
let newly = check_achievements(&stats, &mut progress); let newly = check_achievements(&stats, &mut progress);
assert!(newly.contains(&AchievementId::Toolsmith)); assert!(newly.contains(&AchievementId::Toolsmith));
@@ -2765,7 +2780,7 @@ mod tests {
let mut progress = AchievementProgress::new(); let mut progress = AchievementProgress::new();
for i in 0..10 { for i in 0..10 {
stats.tools_usage.insert(format!("Tool{}", i), 1); stats.tools_usage.insert(format!("Tool{}", i), tool_stats(1));
} }
let newly = check_achievements(&stats, &mut progress); let newly = check_achievements(&stats, &mut progress);
@@ -2777,7 +2792,7 @@ mod tests {
let mut stats = create_test_stats(); let mut stats = create_test_stats();
let mut progress = AchievementProgress::new(); let mut progress = AchievementProgress::new();
stats.tools_usage.insert("Bash".to_string(), 50); stats.tools_usage.insert("Bash".to_string(), tool_stats(50));
let newly = check_achievements(&stats, &mut progress); let newly = check_achievements(&stats, &mut progress);
assert!(newly.contains(&AchievementId::BashMaster)); assert!(newly.contains(&AchievementId::BashMaster));
@@ -2788,7 +2803,7 @@ mod tests {
let mut stats = create_test_stats(); let mut stats = create_test_stats();
let mut progress = AchievementProgress::new(); let mut progress = AchievementProgress::new();
stats.tools_usage.insert("Read".to_string(), 100); stats.tools_usage.insert("Read".to_string(), tool_stats(100));
let newly = check_achievements(&stats, &mut progress); let newly = check_achievements(&stats, &mut progress);
assert!(newly.contains(&AchievementId::FileExplorer)); assert!(newly.contains(&AchievementId::FileExplorer));
@@ -2799,7 +2814,7 @@ mod tests {
let mut stats = create_test_stats(); let mut stats = create_test_stats();
let mut progress = AchievementProgress::new(); let mut progress = AchievementProgress::new();
stats.tools_usage.insert("Grep".to_string(), 50); stats.tools_usage.insert("Grep".to_string(), tool_stats(50));
let newly = check_achievements(&stats, &mut progress); let newly = check_achievements(&stats, &mut progress);
assert!(newly.contains(&AchievementId::SearchExpert)); assert!(newly.contains(&AchievementId::SearchExpert));
@@ -2810,7 +2825,7 @@ mod tests {
let mut stats = create_test_stats(); let mut stats = create_test_stats();
let mut progress = AchievementProgress::new(); let mut progress = AchievementProgress::new();
stats.tools_usage.insert("Edit".to_string(), 100); stats.tools_usage.insert("Edit".to_string(), tool_stats(100));
let newly = check_achievements(&stats, &mut progress); let newly = check_achievements(&stats, &mut progress);
assert!(newly.contains(&AchievementId::EditMaster)); assert!(newly.contains(&AchievementId::EditMaster));
@@ -2821,7 +2836,7 @@ mod tests {
let mut stats = create_test_stats(); let mut stats = create_test_stats();
let mut progress = AchievementProgress::new(); let mut progress = AchievementProgress::new();
stats.tools_usage.insert("Write".to_string(), 50); stats.tools_usage.insert("Write".to_string(), tool_stats(50));
let newly = check_achievements(&stats, &mut progress); let newly = check_achievements(&stats, &mut progress);
assert!(newly.contains(&AchievementId::WriteMaster)); assert!(newly.contains(&AchievementId::WriteMaster));
@@ -2832,7 +2847,7 @@ mod tests {
let mut stats = create_test_stats(); let mut stats = create_test_stats();
let mut progress = AchievementProgress::new(); let mut progress = AchievementProgress::new();
stats.tools_usage.insert("Glob".to_string(), 100); stats.tools_usage.insert("Glob".to_string(), tool_stats(100));
let newly = check_achievements(&stats, &mut progress); let newly = check_achievements(&stats, &mut progress);
assert!(newly.contains(&AchievementId::GlobMaster)); assert!(newly.contains(&AchievementId::GlobMaster));
@@ -2843,7 +2858,7 @@ mod tests {
let mut stats = create_test_stats(); let mut stats = create_test_stats();
let mut progress = AchievementProgress::new(); let mut progress = AchievementProgress::new();
stats.tools_usage.insert("Task".to_string(), 50); stats.tools_usage.insert("Task".to_string(), tool_stats(50));
let newly = check_achievements(&stats, &mut progress); let newly = check_achievements(&stats, &mut progress);
assert!(newly.contains(&AchievementId::TaskMaster)); assert!(newly.contains(&AchievementId::TaskMaster));
@@ -2854,7 +2869,7 @@ mod tests {
let mut stats = create_test_stats(); let mut stats = create_test_stats();
let mut progress = AchievementProgress::new(); let mut progress = AchievementProgress::new();
stats.tools_usage.insert("WebFetch".to_string(), 20); stats.tools_usage.insert("WebFetch".to_string(), tool_stats(20));
let newly = check_achievements(&stats, &mut progress); let newly = check_achievements(&stats, &mut progress);
assert!(newly.contains(&AchievementId::WebFetcher)); assert!(newly.contains(&AchievementId::WebFetcher));
@@ -2865,8 +2880,8 @@ mod tests {
let mut stats = create_test_stats(); let mut stats = create_test_stats();
let mut progress = AchievementProgress::new(); let mut progress = AchievementProgress::new();
stats.tools_usage.insert("mcp__github__create_issue".to_string(), 25); stats.tools_usage.insert("mcp__github__create_issue".to_string(), tool_stats(25));
stats.tools_usage.insert("mcp__notion__search".to_string(), 25); stats.tools_usage.insert("mcp__notion__search".to_string(), tool_stats(25));
let newly = check_achievements(&stats, &mut progress); let newly = check_achievements(&stats, &mut progress);
assert!(newly.contains(&AchievementId::McpExplorer)); assert!(newly.contains(&AchievementId::McpExplorer));
@@ -2881,8 +2896,8 @@ mod tests {
let mut stats = create_test_stats(); let mut stats = create_test_stats();
let mut progress = AchievementProgress::new(); let mut progress = AchievementProgress::new();
stats.tools_usage.insert("Grep".to_string(), 30); stats.tools_usage.insert("Grep".to_string(), tool_stats(30));
stats.tools_usage.insert("Glob".to_string(), 20); stats.tools_usage.insert("Glob".to_string(), tool_stats(20));
let newly = check_achievements(&stats, &mut progress); let newly = check_achievements(&stats, &mut progress);
assert!(newly.contains(&AchievementId::Explorer)); assert!(newly.contains(&AchievementId::Explorer));
@@ -2893,9 +2908,9 @@ mod tests {
let mut stats = create_test_stats(); let mut stats = create_test_stats();
let mut progress = AchievementProgress::new(); let mut progress = AchievementProgress::new();
stats.tools_usage.insert("Grep".to_string(), 200); stats.tools_usage.insert("Grep".to_string(), tool_stats(200));
stats.tools_usage.insert("Glob".to_string(), 200); stats.tools_usage.insert("Glob".to_string(), tool_stats(200));
stats.tools_usage.insert("Task".to_string(), 100); stats.tools_usage.insert("Task".to_string(), tool_stats(100));
let newly = check_achievements(&stats, &mut progress); let newly = check_achievements(&stats, &mut progress);
assert!(newly.contains(&AchievementId::MasterSearcher)); assert!(newly.contains(&AchievementId::MasterSearcher));
+7 -1
View File
@@ -3,6 +3,7 @@ use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
use tauri::AppHandle; use tauri::AppHandle;
use crate::commands::record_session;
use crate::config::ClaudeStartOptions; use crate::config::ClaudeStartOptions;
use crate::stats::UsageStats; use crate::stats::UsageStats;
use crate::wsl_bridge::WslBridge; use crate::wsl_bridge::WslBridge;
@@ -53,7 +54,12 @@ impl BridgeManager {
.or_insert_with(|| WslBridge::new_with_conversation_id(conversation_id.to_string())); .or_insert_with(|| WslBridge::new_with_conversation_id(conversation_id.to_string()));
// Start the Claude process // Start the Claude process
bridge.start(app, options)?; bridge.start(app.clone(), options)?;
// Record session start for cost tracking
tauri::async_runtime::spawn(async move {
record_session(&app).await;
});
Ok(()) Ok(())
} }
+253
View File
@@ -394,6 +394,259 @@ pub async fn get_file_size(file_path: String) -> Result<u64, String> {
Ok(metadata.len()) 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(())
}
// ==================== Cost Tracking Commands ====================
const COST_HISTORY_STORE_KEY: &str = "cost_history";
#[tauri::command]
pub async fn get_cost_summary(app: AppHandle, days: u32) -> Result<crate::cost_tracking::CostSummary, String> {
let history = load_cost_history(&app).await;
Ok(history.get_summary(days))
}
#[tauri::command]
pub async fn get_cost_alerts(app: AppHandle) -> Result<Vec<crate::cost_tracking::CostAlert>, String> {
let mut history = load_cost_history(&app).await;
let alerts = history.check_alerts();
// Save updated alert state
save_cost_history(&app, &history).await?;
Ok(alerts)
}
#[tauri::command]
pub async fn set_cost_alert_thresholds(
app: AppHandle,
daily: Option<f64>,
weekly: Option<f64>,
monthly: Option<f64>,
) -> Result<(), String> {
let mut history = load_cost_history(&app).await;
history.set_alert_thresholds(daily, weekly, monthly);
save_cost_history(&app, &history).await
}
#[tauri::command]
pub async fn export_cost_csv(app: AppHandle, days: u32) -> Result<String, String> {
let history = load_cost_history(&app).await;
Ok(history.export_csv(days))
}
#[tauri::command]
pub async fn get_today_cost(app: AppHandle) -> Result<f64, String> {
let history = load_cost_history(&app).await;
Ok(history.get_today_cost())
}
#[tauri::command]
pub async fn get_week_cost(app: AppHandle) -> Result<f64, String> {
let history = load_cost_history(&app).await;
Ok(history.get_week_cost())
}
#[tauri::command]
pub async fn get_month_cost(app: AppHandle) -> Result<f64, String> {
let history = load_cost_history(&app).await;
Ok(history.get_month_cost())
}
/// Add cost to history (called internally when stats are updated)
pub async fn record_cost(app: &AppHandle, input_tokens: u64, output_tokens: u64, cost_usd: f64) {
let mut history = load_cost_history(app).await;
history.add_cost(input_tokens, output_tokens, cost_usd);
let _ = save_cost_history(app, &history).await;
}
/// Record a new session
pub async fn record_session(app: &AppHandle) {
let mut history = load_cost_history(app).await;
history.increment_sessions();
let _ = save_cost_history(app, &history).await;
}
async fn load_cost_history(app: &AppHandle) -> crate::cost_tracking::CostHistory {
let store = match app.store("hikari-cost-history.json") {
Ok(s) => s,
Err(_) => return crate::cost_tracking::CostHistory::new(),
};
match store.get(COST_HISTORY_STORE_KEY) {
Some(value) => serde_json::from_value(value.clone()).unwrap_or_default(),
None => crate::cost_tracking::CostHistory::new(),
}
}
async fn save_cost_history(app: &AppHandle, history: &crate::cost_tracking::CostHistory) -> Result<(), String> {
let store = app.store("hikari-cost-history.json").map_err(|e| e.to_string())?;
let value = serde_json::to_value(history).map_err(|e| e.to_string())?;
store.set(COST_HISTORY_STORE_KEY, value);
store.save().map_err(|e| e.to_string())?;
Ok(())
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
+47
View File
@@ -96,6 +96,22 @@ pub struct HikariConfig {
// Custom theme colors // Custom theme colors
#[serde(default)] #[serde(default)]
pub custom_theme_colors: CustomThemeColors, pub custom_theme_colors: CustomThemeColors,
// Token budget settings
#[serde(default)]
pub budget_enabled: bool,
#[serde(default)]
pub session_token_budget: Option<u64>,
#[serde(default)]
pub session_cost_budget: Option<f64>,
#[serde(default = "default_budget_action")]
pub budget_action: BudgetAction,
#[serde(default = "default_budget_warning_threshold")]
pub budget_warning_threshold: f32,
} }
impl Default for HikariConfig { impl Default for HikariConfig {
@@ -123,6 +139,11 @@ impl Default for HikariConfig {
profile_avatar_path: None, profile_avatar_path: None,
profile_bio: None, profile_bio: None,
custom_theme_colors: CustomThemeColors::default(), custom_theme_colors: CustomThemeColors::default(),
budget_enabled: false,
session_token_budget: None,
session_cost_budget: None,
budget_action: BudgetAction::Warn,
budget_warning_threshold: 0.8,
} }
} }
} }
@@ -147,6 +168,22 @@ fn default_font_size() -> u32 {
14 14
} }
fn default_budget_action() -> BudgetAction {
BudgetAction::Warn
}
fn default_budget_warning_threshold() -> f32 {
0.8
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum BudgetAction {
#[default]
Warn,
Block,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] #[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
pub enum Theme { pub enum Theme {
@@ -205,6 +242,11 @@ mod tests {
assert!(config.profile_avatar_path.is_none()); assert!(config.profile_avatar_path.is_none());
assert!(config.profile_bio.is_none()); assert!(config.profile_bio.is_none());
assert_eq!(config.custom_theme_colors, CustomThemeColors::default()); assert_eq!(config.custom_theme_colors, CustomThemeColors::default());
assert!(!config.budget_enabled);
assert!(config.session_token_budget.is_none());
assert!(config.session_cost_budget.is_none());
assert_eq!(config.budget_action, BudgetAction::Warn);
assert!((config.budget_warning_threshold - 0.8).abs() < f32::EPSILON);
} }
#[test] #[test]
@@ -232,6 +274,11 @@ mod tests {
profile_avatar_path: None, profile_avatar_path: None,
profile_bio: Some("A test bio".to_string()), profile_bio: Some("A test bio".to_string()),
custom_theme_colors: CustomThemeColors::default(), custom_theme_colors: CustomThemeColors::default(),
budget_enabled: true,
session_token_budget: Some(100000),
session_cost_budget: Some(1.50),
budget_action: BudgetAction::Block,
budget_warning_threshold: 0.75,
}; };
let json = serde_json::to_string(&config).unwrap(); let json = serde_json::to_string(&config).unwrap();
+376
View File
@@ -0,0 +1,376 @@
use chrono::{Datelike, Local, NaiveDate, Weekday};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
/// Represents a single day's cost data
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct DailyCost {
pub date: String, // ISO date string (YYYY-MM-DD)
pub input_tokens: u64,
pub output_tokens: u64,
pub cost_usd: f64,
pub messages_sent: u64,
pub sessions_count: u64,
}
/// Historical cost tracking data
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct CostHistory {
/// Daily costs indexed by date string (YYYY-MM-DD)
pub daily_costs: HashMap<String, DailyCost>,
/// Cost alert thresholds
pub daily_alert_threshold: Option<f64>,
pub weekly_alert_threshold: Option<f64>,
pub monthly_alert_threshold: Option<f64>,
/// Whether alerts have been triggered today
pub daily_alert_triggered: bool,
pub weekly_alert_triggered: bool,
pub monthly_alert_triggered: bool,
pub last_alert_reset_date: Option<String>,
}
impl CostHistory {
pub fn new() -> Self {
Self::default()
}
/// Get today's date as a string
fn today_str() -> String {
Local::now().format("%Y-%m-%d").to_string()
}
/// Get the start of the current week (Monday)
fn week_start() -> NaiveDate {
let today = Local::now().date_naive();
let days_since_monday = today.weekday().num_days_from_monday();
today - chrono::Duration::days(days_since_monday as i64)
}
/// Get the start of the current month
fn month_start() -> NaiveDate {
let today = Local::now().date_naive();
NaiveDate::from_ymd_opt(today.year(), today.month(), 1).unwrap_or(today)
}
/// Add cost for today
pub fn add_cost(&mut self, input_tokens: u64, output_tokens: u64, cost_usd: f64) {
let today = Self::today_str();
// Reset alert flags if it's a new day
if self.last_alert_reset_date.as_ref() != Some(&today) {
self.daily_alert_triggered = false;
// Reset weekly on Monday
if Local::now().weekday() == Weekday::Mon {
self.weekly_alert_triggered = false;
}
// Reset monthly on the 1st
if Local::now().day() == 1 {
self.monthly_alert_triggered = false;
}
self.last_alert_reset_date = Some(today.clone());
}
let daily = self.daily_costs.entry(today).or_default();
daily.input_tokens += input_tokens;
daily.output_tokens += output_tokens;
daily.cost_usd += cost_usd;
daily.messages_sent += 1;
}
/// Increment session count for today
pub fn increment_sessions(&mut self) {
let today = Self::today_str();
let daily = self.daily_costs.entry(today.clone()).or_insert_with(|| DailyCost {
date: today,
..Default::default()
});
daily.sessions_count += 1;
}
/// Get today's cost
pub fn get_today_cost(&self) -> f64 {
self.daily_costs
.get(&Self::today_str())
.map(|d| d.cost_usd)
.unwrap_or(0.0)
}
/// Get this week's cost (Monday to Sunday)
pub fn get_week_cost(&self) -> f64 {
let week_start = Self::week_start();
self.daily_costs
.values()
.filter(|d| {
NaiveDate::parse_from_str(&d.date, "%Y-%m-%d")
.map(|date| date >= week_start)
.unwrap_or(false)
})
.map(|d| d.cost_usd)
.sum()
}
/// Get this month's cost
pub fn get_month_cost(&self) -> f64 {
let month_start = Self::month_start();
self.daily_costs
.values()
.filter(|d| {
NaiveDate::parse_from_str(&d.date, "%Y-%m-%d")
.map(|date| date >= month_start)
.unwrap_or(false)
})
.map(|d| d.cost_usd)
.sum()
}
/// Get cost summary for a date range
pub fn get_summary(&self, days: u32) -> CostSummary {
let today = Local::now().date_naive();
let start_date = today - chrono::Duration::days(days as i64 - 1);
let mut total_input_tokens = 0u64;
let mut total_output_tokens = 0u64;
let mut total_cost = 0.0f64;
let mut total_messages = 0u64;
let mut total_sessions = 0u64;
let mut daily_breakdown = Vec::new();
for i in 0..days {
let date = start_date + chrono::Duration::days(i as i64);
let date_str = date.format("%Y-%m-%d").to_string();
if let Some(daily) = self.daily_costs.get(&date_str) {
total_input_tokens += daily.input_tokens;
total_output_tokens += daily.output_tokens;
total_cost += daily.cost_usd;
total_messages += daily.messages_sent;
total_sessions += daily.sessions_count;
daily_breakdown.push(daily.clone());
} else {
daily_breakdown.push(DailyCost {
date: date_str,
..Default::default()
});
}
}
CostSummary {
period_days: days,
total_input_tokens,
total_output_tokens,
total_cost,
total_messages,
total_sessions,
average_daily_cost: if days > 0 { total_cost / days as f64 } else { 0.0 },
daily_breakdown,
}
}
/// Check if any alert thresholds are exceeded and return which ones
pub fn check_alerts(&mut self) -> Vec<CostAlert> {
let mut alerts = Vec::new();
if let Some(threshold) = self.daily_alert_threshold {
let today_cost = self.get_today_cost();
if today_cost >= threshold && !self.daily_alert_triggered {
self.daily_alert_triggered = true;
alerts.push(CostAlert {
alert_type: AlertType::Daily,
threshold,
current_cost: today_cost,
});
}
}
if let Some(threshold) = self.weekly_alert_threshold {
let week_cost = self.get_week_cost();
if week_cost >= threshold && !self.weekly_alert_triggered {
self.weekly_alert_triggered = true;
alerts.push(CostAlert {
alert_type: AlertType::Weekly,
threshold,
current_cost: week_cost,
});
}
}
if let Some(threshold) = self.monthly_alert_threshold {
let month_cost = self.get_month_cost();
if month_cost >= threshold && !self.monthly_alert_triggered {
self.monthly_alert_triggered = true;
alerts.push(CostAlert {
alert_type: AlertType::Monthly,
threshold,
current_cost: month_cost,
});
}
}
alerts
}
/// Set alert thresholds
pub fn set_alert_thresholds(
&mut self,
daily: Option<f64>,
weekly: Option<f64>,
monthly: Option<f64>,
) {
self.daily_alert_threshold = daily;
self.weekly_alert_threshold = weekly;
self.monthly_alert_threshold = monthly;
}
/// Clean up old data (keep last N days)
#[allow(dead_code)]
pub fn cleanup_old_data(&mut self, keep_days: u32) {
let cutoff = Local::now().date_naive() - chrono::Duration::days(keep_days as i64);
self.daily_costs.retain(|date_str, _| {
NaiveDate::parse_from_str(date_str, "%Y-%m-%d")
.map(|date| date >= cutoff)
.unwrap_or(false)
});
}
/// Export to CSV format
pub fn export_csv(&self, days: u32) -> String {
let summary = self.get_summary(days);
let mut csv = String::from("Date,Input Tokens,Output Tokens,Cost (USD),Messages,Sessions\n");
for daily in &summary.daily_breakdown {
csv.push_str(&format!(
"{},{},{},{:.4},{},{}\n",
daily.date,
daily.input_tokens,
daily.output_tokens,
daily.cost_usd,
daily.messages_sent,
daily.sessions_count
));
}
// Add totals row
csv.push_str(&format!(
"TOTAL,{},{},{:.4},{},{}\n",
summary.total_input_tokens,
summary.total_output_tokens,
summary.total_cost,
summary.total_messages,
summary.total_sessions
));
csv
}
}
/// Cost summary for a period
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CostSummary {
pub period_days: u32,
pub total_input_tokens: u64,
pub total_output_tokens: u64,
pub total_cost: f64,
pub total_messages: u64,
pub total_sessions: u64,
pub average_daily_cost: f64,
pub daily_breakdown: Vec<DailyCost>,
}
/// Alert types
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum AlertType {
Daily,
Weekly,
Monthly,
}
/// Cost alert notification
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CostAlert {
pub alert_type: AlertType,
pub threshold: f64,
pub current_cost: f64,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_add_cost() {
let mut history = CostHistory::new();
history.add_cost(1000, 500, 0.05);
let today_cost = history.get_today_cost();
assert!((today_cost - 0.05).abs() < 0.0001);
}
#[test]
fn test_accumulate_daily_cost() {
let mut history = CostHistory::new();
history.add_cost(1000, 500, 0.05);
history.add_cost(2000, 1000, 0.10);
let today_cost = history.get_today_cost();
assert!((today_cost - 0.15).abs() < 0.0001);
}
#[test]
fn test_summary() {
let mut history = CostHistory::new();
history.add_cost(1000, 500, 0.05);
let summary = history.get_summary(7);
assert_eq!(summary.period_days, 7);
assert!((summary.total_cost - 0.05).abs() < 0.0001);
}
#[test]
fn test_daily_alert() {
let mut history = CostHistory::new();
history.set_alert_thresholds(Some(0.10), None, None);
history.add_cost(1000, 500, 0.05);
let alerts = history.check_alerts();
assert!(alerts.is_empty());
history.add_cost(1000, 500, 0.06);
let alerts = history.check_alerts();
assert_eq!(alerts.len(), 1);
assert_eq!(alerts[0].alert_type, AlertType::Daily);
}
#[test]
fn test_alert_only_triggers_once() {
let mut history = CostHistory::new();
history.set_alert_thresholds(Some(0.10), None, None);
history.add_cost(1000, 500, 0.15);
let alerts = history.check_alerts();
assert_eq!(alerts.len(), 1);
// Second check should not trigger again
let alerts = history.check_alerts();
assert!(alerts.is_empty());
}
#[test]
fn test_export_csv() {
let mut history = CostHistory::new();
history.add_cost(1000, 500, 0.05);
let csv = history.export_csv(1);
assert!(csv.contains("Date,Input Tokens"));
assert!(csv.contains("TOTAL"));
}
#[test]
fn test_increment_sessions() {
let mut history = CostHistory::new();
history.increment_sessions();
history.increment_sessions();
let summary = history.get_summary(1);
assert_eq!(summary.total_sessions, 2);
}
}
+18
View File
@@ -3,6 +3,7 @@ mod bridge_manager;
mod clipboard; mod clipboard;
mod commands; mod commands;
mod config; mod config;
mod cost_tracking;
mod git; mod git;
mod notifications; mod notifications;
mod quick_actions; mod quick_actions;
@@ -10,6 +11,7 @@ mod sessions;
mod snippets; mod snippets;
mod stats; mod stats;
mod temp_manager; mod temp_manager;
mod tool_cache;
mod tray; mod tray;
mod types; mod types;
mod vbs_notification; mod vbs_notification;
@@ -151,6 +153,22 @@ pub fn run() {
search_clipboard_entries, search_clipboard_entries,
get_clipboard_languages, get_clipboard_languages,
update_clipboard_language, update_clipboard_language,
list_directory,
read_file_content,
write_file_content,
create_file,
create_directory,
delete_file,
delete_directory,
rename_path,
// Cost tracking commands
get_cost_summary,
get_cost_alerts,
set_cost_alert_thresholds,
export_cost_csv,
get_today_cost,
get_week_cost,
get_month_cost,
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");
+657 -24
View File
@@ -5,6 +5,110 @@ use std::collections::HashMap;
use std::time::Instant; use std::time::Instant;
use tauri_plugin_store::StoreExt; use tauri_plugin_store::StoreExt;
/// Per-tool token usage statistics
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ToolTokenStats {
pub call_count: u64,
pub estimated_input_tokens: u64,
pub estimated_output_tokens: u64,
}
impl ToolTokenStats {
#[allow(dead_code)]
pub fn new() -> Self {
Self::default()
}
pub fn increment_call(&mut self) {
self.call_count += 1;
}
pub fn add_tokens(&mut self, input: u64, output: u64) {
self.estimated_input_tokens += input;
self.estimated_output_tokens += output;
}
#[allow(dead_code)]
pub fn total_tokens(&self) -> u64 {
self.estimated_input_tokens + self.estimated_output_tokens
}
}
/// Warning levels for context window utilisation
#[allow(dead_code)]
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ContextWarning {
/// 50-74% utilisation - conversation is getting long
Moderate,
/// 75-89% utilisation - consider summarising
High,
/// 90%+ utilisation - approaching limit
Critical,
}
/// Budget status indicating whether user is within their limits
#[allow(dead_code)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum BudgetStatus {
/// Within budget, no concerns
Ok,
/// Approaching budget limit (warning threshold reached)
Warning {
budget_type: BudgetType,
percent_used: f32,
},
/// Budget exceeded
Exceeded { budget_type: BudgetType },
}
/// Type of budget limit
#[allow(dead_code)]
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum BudgetType {
Token,
Cost,
}
impl ContextWarning {
#[allow(dead_code)]
pub fn message(&self) -> &'static str {
match self {
ContextWarning::Moderate => "Context window is 50%+ full. Consider starting a new conversation for better performance.",
ContextWarning::High => "Context window is 75%+ full. Responses may degrade. Consider summarising or starting fresh.",
ContextWarning::Critical => "Context window is nearly full (90%+)! Start a new conversation to avoid errors.",
}
}
}
/// Get the context window limit (in tokens) for a given model
fn get_context_window_limit(model: &str) -> u64 {
match model {
// Claude 4.5 family - 200K standard context
"claude-opus-4-5-20251101"
| "claude-sonnet-4-5-20250929"
| "claude-haiku-4-5-20251001" => 200_000,
// Claude 4.x family - 200K standard context
"claude-opus-4-1-20250805"
| "claude-opus-4-20250514"
| "claude-sonnet-4-20250514" => 200_000,
// Claude 3.x family
"claude-3-7-sonnet-20250219"
| "claude-3-5-sonnet-20241022"
| "claude-3-5-sonnet-20240620"
| "claude-3-5-haiku-20241022"
| "claude-3-opus-20240229"
| "claude-3-sonnet-20240229"
| "claude-3-haiku-20240307" => 200_000,
// Default to 200K for unknown Claude models
_ if model.starts_with("claude") => 200_000,
// For non-Claude models (Ollama, etc.), use a conservative default
_ => 128_000,
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)] #[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct UsageStats { pub struct UsageStats {
pub total_input_tokens: u64, pub total_input_tokens: u64,
@@ -24,8 +128,8 @@ pub struct UsageStats {
pub session_files_edited: u64, pub session_files_edited: u64,
pub files_created: u64, pub files_created: u64,
pub session_files_created: u64, pub session_files_created: u64,
pub tools_usage: HashMap<String, u64>, pub tools_usage: HashMap<String, ToolTokenStats>,
pub session_tools_usage: HashMap<String, u64>, pub session_tools_usage: HashMap<String, ToolTokenStats>,
pub session_duration_seconds: u64, pub session_duration_seconds: u64,
#[serde(skip)] #[serde(skip)]
pub session_start: Option<Instant>, pub session_start: Option<Instant>,
@@ -38,6 +142,15 @@ pub struct UsageStats {
pub night_sessions: u64, // Sessions started after 10 PM pub night_sessions: u64, // Sessions started after 10 PM
pub last_session_date: Option<String>, // ISO date string for streak tracking pub last_session_date: Option<String>, // ISO date string for streak tracking
// Context window tracking
pub context_tokens_used: u64,
pub context_window_limit: u64,
pub context_utilisation_percent: f32,
// Cache analytics (tracks potential savings from repeated tool calls)
pub potential_cache_hits: u64,
pub potential_cache_savings_tokens: u64,
// Achievement tracking // Achievement tracking
#[serde(skip)] #[serde(skip)]
pub achievements: AchievementProgress, pub achievements: AchievementProgress,
@@ -61,6 +174,114 @@ impl UsageStats {
self.session_cost_usd += cost; self.session_cost_usd += cost;
self.model = Some(model.to_string()); self.model = Some(model.to_string());
// Update context window tracking
self.update_context_tracking(model);
}
pub fn update_context_tracking(&mut self, model: &str) {
// Get context window limit for the current model
self.context_window_limit = get_context_window_limit(model);
// Context tokens = input tokens (the prompt/context sent to the model)
// We track cumulative session input as a proxy for context growth
self.context_tokens_used = self.session_input_tokens;
// Calculate utilisation percentage
if self.context_window_limit > 0 {
self.context_utilisation_percent =
(self.context_tokens_used as f32 / self.context_window_limit as f32) * 100.0;
}
}
#[allow(dead_code)]
pub fn get_context_warning(&self) -> Option<ContextWarning> {
if self.context_utilisation_percent >= 90.0 {
Some(ContextWarning::Critical)
} else if self.context_utilisation_percent >= 75.0 {
Some(ContextWarning::High)
} else if self.context_utilisation_percent >= 50.0 {
Some(ContextWarning::Moderate)
} else {
None
}
}
#[allow(dead_code)]
pub fn estimate_remaining_tokens(&self) -> u64 {
self.context_window_limit
.saturating_sub(self.context_tokens_used)
}
/// Check budget status given current usage and budget settings
#[allow(dead_code)]
pub fn check_budget(
&self,
budget_enabled: bool,
token_budget: Option<u64>,
cost_budget: Option<f64>,
warning_threshold: f32,
) -> BudgetStatus {
if !budget_enabled {
return BudgetStatus::Ok;
}
let session_tokens = self.session_input_tokens + self.session_output_tokens;
// Check token budget
if let Some(limit) = token_budget {
if session_tokens >= limit {
return BudgetStatus::Exceeded {
budget_type: BudgetType::Token,
};
}
let percent_used = session_tokens as f32 / limit as f32;
if percent_used >= warning_threshold {
return BudgetStatus::Warning {
budget_type: BudgetType::Token,
percent_used: percent_used * 100.0,
};
}
}
// Check cost budget
if let Some(limit) = cost_budget {
if self.session_cost_usd >= limit {
return BudgetStatus::Exceeded {
budget_type: BudgetType::Cost,
};
}
let percent_used = (self.session_cost_usd / limit) as f32;
if percent_used >= warning_threshold {
return BudgetStatus::Warning {
budget_type: BudgetType::Cost,
percent_used: percent_used * 100.0,
};
}
}
BudgetStatus::Ok
}
/// Get remaining token budget (None if no budget set)
#[allow(dead_code)]
pub fn get_remaining_token_budget(&self, token_budget: Option<u64>) -> Option<u64> {
token_budget.map(|limit| {
let used = self.session_input_tokens + self.session_output_tokens;
limit.saturating_sub(used)
})
}
/// Get remaining cost budget (None if no budget set)
#[allow(dead_code)]
pub fn get_remaining_cost_budget(&self, cost_budget: Option<f64>) -> Option<f64> {
cost_budget.map(|limit| {
if limit > self.session_cost_usd {
limit - self.session_cost_usd
} else {
0.0
}
})
} }
pub fn reset_session(&mut self) { pub fn reset_session(&mut self) {
@@ -76,6 +297,13 @@ impl UsageStats {
self.session_start = Some(Instant::now()); self.session_start = Some(Instant::now());
self.achievements.start_session(); self.achievements.start_session();
// Reset context window tracking
self.context_tokens_used = 0;
self.context_utilisation_percent = 0.0;
// Note: Cache analytics are NOT reset here - they're cumulative across sessions
// to show total potential savings over time
// Track session start for achievements // Track session start for achievements
self.track_session_start(); self.track_session_start();
} }
@@ -139,11 +367,32 @@ impl UsageStats {
} }
pub fn increment_tool_usage(&mut self, tool_name: &str) { pub fn increment_tool_usage(&mut self, tool_name: &str) {
*self.tools_usage.entry(tool_name.to_string()).or_insert(0) += 1; self.tools_usage
*self
.session_tools_usage
.entry(tool_name.to_string()) .entry(tool_name.to_string())
.or_insert(0) += 1; .or_default()
.increment_call();
self.session_tools_usage
.entry(tool_name.to_string())
.or_default()
.increment_call();
}
pub fn add_tool_tokens(&mut self, tool_name: &str, input_tokens: u64, output_tokens: u64) {
self.tools_usage
.entry(tool_name.to_string())
.or_default()
.add_tokens(input_tokens, output_tokens);
self.session_tools_usage
.entry(tool_name.to_string())
.or_default()
.add_tokens(input_tokens, output_tokens);
}
/// Record a potential cache hit (when the same tool call is made twice)
#[allow(dead_code)]
pub fn add_potential_cache_hit(&mut self, tokens_saved: u64) {
self.potential_cache_hits += 1;
self.potential_cache_savings_tokens += tokens_saved;
} }
pub fn get_session_duration(&mut self) -> u64 { pub fn get_session_duration(&mut self) -> u64 {
@@ -184,6 +433,11 @@ impl UsageStats {
morning_sessions: self.morning_sessions, morning_sessions: self.morning_sessions,
night_sessions: self.night_sessions, night_sessions: self.night_sessions,
last_session_date: self.last_session_date.clone(), last_session_date: self.last_session_date.clone(),
context_tokens_used: self.context_tokens_used,
context_window_limit: self.context_window_limit,
context_utilisation_percent: self.context_utilisation_percent,
potential_cache_hits: self.potential_cache_hits,
potential_cache_savings_tokens: self.potential_cache_savings_tokens,
achievements: AchievementProgress::new(), // Dummy for copy achievements: AchievementProgress::new(), // Dummy for copy
}; };
check_achievements(&stats_copy, &mut self.achievements) check_achievements(&stats_copy, &mut self.achievements)
@@ -206,20 +460,22 @@ fn is_consecutive_day(prev_date: &str, current_date: &str) -> bool {
} }
} }
// Pricing as of January 2025 // Pricing as of February 2026
// https://www.anthropic.com/pricing // https://platform.claude.com/docs/en/about-claude/models/overview
fn calculate_cost(input_tokens: u64, output_tokens: u64, model: &str) -> f64 { pub fn calculate_cost(input_tokens: u64, output_tokens: u64, model: &str) -> f64 {
let (input_price_per_million, output_price_per_million) = match model { let (input_price_per_million, output_price_per_million) = match model {
// Opus 4.5 // Current generation (Claude 4.5)
"claude-opus-4-5-20251101" => (15.0, 75.0), "claude-opus-4-5-20251101" => (5.0, 25.0),
"claude-sonnet-4-5-20250929" => (3.0, 15.0),
"claude-haiku-4-5-20251001" => (1.0, 5.0),
// Opus 4 // Previous generation (Claude 4.x)
"claude-opus-4-1-20250805" => (15.0, 75.0),
"claude-opus-4-20250514" => (15.0, 75.0), "claude-opus-4-20250514" => (15.0, 75.0),
// Sonnet 4
"claude-sonnet-4-20250514" => (3.0, 15.0), "claude-sonnet-4-20250514" => (3.0, 15.0),
// Previous generation models // Legacy (Claude 3.x)
"claude-3-7-sonnet-20250219" => (3.0, 15.0),
"claude-3-5-sonnet-20241022" => (3.0, 15.0), "claude-3-5-sonnet-20241022" => (3.0, 15.0),
"claude-3-5-sonnet-20240620" => (3.0, 15.0), "claude-3-5-sonnet-20240620" => (3.0, 15.0),
"claude-3-5-haiku-20241022" => (1.0, 5.0), "claude-3-5-haiku-20241022" => (1.0, 5.0),
@@ -252,7 +508,7 @@ pub struct PersistedStats {
pub code_blocks_generated: u64, pub code_blocks_generated: u64,
pub files_edited: u64, pub files_edited: u64,
pub files_created: u64, pub files_created: u64,
pub tools_usage: HashMap<String, u64>, pub tools_usage: HashMap<String, ToolTokenStats>,
pub sessions_started: u64, pub sessions_started: u64,
pub consecutive_days: u64, pub consecutive_days: u64,
pub total_days_used: u64, pub total_days_used: u64,
@@ -372,8 +628,10 @@ mod tests {
#[test] #[test]
fn test_cost_calculation_opus_45() { fn test_cost_calculation_opus_45() {
let cost = calculate_cost(1000, 2000, "claude-opus-4-5-20251101"); let cost = calculate_cost(1000, 2000, "claude-opus-4-5-20251101");
// Same pricing as Opus 4 // Opus 4.5 pricing: $5/MTok input, $25/MTok output
assert!((cost - 0.165).abs() < 0.0001); // 1000 input tokens = $0.005, 2000 output tokens = $0.05
// Total = $0.055
assert!((cost - 0.055).abs() < 0.0001);
} }
#[test] #[test]
@@ -512,10 +770,33 @@ mod tests {
stats.increment_tool_usage("Read"); stats.increment_tool_usage("Read");
stats.increment_tool_usage("Write"); stats.increment_tool_usage("Write");
assert_eq!(stats.tools_usage.get("Read"), Some(&2)); assert_eq!(stats.tools_usage.get("Read").map(|t| t.call_count), Some(2));
assert_eq!(stats.tools_usage.get("Write"), Some(&1)); assert_eq!(stats.tools_usage.get("Write").map(|t| t.call_count), Some(1));
assert_eq!(stats.session_tools_usage.get("Read"), Some(&2)); assert_eq!(stats.session_tools_usage.get("Read").map(|t| t.call_count), Some(2));
assert_eq!(stats.session_tools_usage.get("Write"), Some(&1)); assert_eq!(stats.session_tools_usage.get("Write").map(|t| t.call_count), Some(1));
}
#[test]
fn test_add_tool_tokens() {
let mut stats = UsageStats::new();
stats.increment_tool_usage("Read");
stats.add_tool_tokens("Read", 100, 50);
stats.add_tool_tokens("Read", 200, 100);
let read_stats = stats.tools_usage.get("Read").unwrap();
assert_eq!(read_stats.call_count, 1);
assert_eq!(read_stats.estimated_input_tokens, 300);
assert_eq!(read_stats.estimated_output_tokens, 150);
assert_eq!(read_stats.total_tokens(), 450);
}
#[test]
fn test_tool_token_stats_default() {
let tool_stats = ToolTokenStats::new();
assert_eq!(tool_stats.call_count, 0);
assert_eq!(tool_stats.estimated_input_tokens, 0);
assert_eq!(tool_stats.estimated_output_tokens, 0);
assert_eq!(tool_stats.total_tokens(), 0);
} }
#[test] #[test]
@@ -590,7 +871,11 @@ mod tests {
files_created: 5, files_created: 5,
tools_usage: { tools_usage: {
let mut map = HashMap::new(); let mut map = HashMap::new();
map.insert("Read".to_string(), 50); map.insert("Read".to_string(), ToolTokenStats {
call_count: 50,
estimated_input_tokens: 5000,
estimated_output_tokens: 2500,
});
map map
}, },
sessions_started: 10, sessions_started: 10,
@@ -608,7 +893,8 @@ mod tests {
assert_eq!(stats.total_output_tokens, 20000); assert_eq!(stats.total_output_tokens, 20000);
assert_eq!(stats.total_cost_usd, 5.50); assert_eq!(stats.total_cost_usd, 5.50);
assert_eq!(stats.messages_exchanged, 100); assert_eq!(stats.messages_exchanged, 100);
assert_eq!(stats.tools_usage.get("Read"), Some(&50)); assert_eq!(stats.tools_usage.get("Read").map(|t| t.call_count), Some(50));
assert_eq!(stats.tools_usage.get("Read").map(|t| t.estimated_input_tokens), Some(5000));
assert_eq!(stats.consecutive_days, 7); assert_eq!(stats.consecutive_days, 7);
assert_eq!(stats.morning_sessions, 3); assert_eq!(stats.morning_sessions, 3);
assert_eq!(stats.last_session_date, Some("2024-06-15".to_string())); assert_eq!(stats.last_session_date, Some("2024-06-15".to_string()));
@@ -672,4 +958,351 @@ mod tests {
assert!(json.contains("stats")); assert!(json.contains("stats"));
assert!(json.contains("total_input_tokens")); assert!(json.contains("total_input_tokens"));
} }
// =====================
// Context Window Tracking tests
// =====================
#[test]
fn test_context_window_limit_claude_4() {
assert_eq!(get_context_window_limit("claude-opus-4-5-20251101"), 200_000);
assert_eq!(get_context_window_limit("claude-opus-4-20250514"), 200_000);
assert_eq!(get_context_window_limit("claude-sonnet-4-20250514"), 200_000);
}
#[test]
fn test_context_window_limit_claude_35() {
assert_eq!(
get_context_window_limit("claude-3-5-sonnet-20241022"),
200_000
);
assert_eq!(
get_context_window_limit("claude-3-5-sonnet-20240620"),
200_000
);
assert_eq!(
get_context_window_limit("claude-3-5-haiku-20241022"),
200_000
);
}
#[test]
fn test_context_window_limit_unknown_claude() {
assert_eq!(
get_context_window_limit("claude-some-future-model"),
200_000
);
}
#[test]
fn test_context_window_limit_non_claude() {
assert_eq!(get_context_window_limit("gpt-4"), 128_000);
assert_eq!(get_context_window_limit("llama-3"), 128_000);
assert_eq!(get_context_window_limit("unknown-model"), 128_000);
}
#[test]
fn test_context_tracking_update() {
let mut stats = UsageStats::new();
stats.add_usage(50_000, 10_000, "claude-sonnet-4-20250514");
assert_eq!(stats.context_tokens_used, 50_000);
assert_eq!(stats.context_window_limit, 200_000);
assert!((stats.context_utilisation_percent - 25.0).abs() < 0.1);
}
#[test]
fn test_context_tracking_accumulates() {
let mut stats = UsageStats::new();
stats.add_usage(50_000, 10_000, "claude-sonnet-4-20250514");
stats.add_usage(50_000, 10_000, "claude-sonnet-4-20250514");
assert_eq!(stats.context_tokens_used, 100_000);
assert!((stats.context_utilisation_percent - 50.0).abs() < 0.1);
}
#[test]
fn test_context_warning_none() {
let mut stats = UsageStats::new();
stats.context_utilisation_percent = 40.0;
assert!(stats.get_context_warning().is_none());
}
#[test]
fn test_context_warning_moderate() {
let mut stats = UsageStats::new();
stats.context_utilisation_percent = 55.0;
assert_eq!(stats.get_context_warning(), Some(ContextWarning::Moderate));
}
#[test]
fn test_context_warning_high() {
let mut stats = UsageStats::new();
stats.context_utilisation_percent = 80.0;
assert_eq!(stats.get_context_warning(), Some(ContextWarning::High));
}
#[test]
fn test_context_warning_critical() {
let mut stats = UsageStats::new();
stats.context_utilisation_percent = 95.0;
assert_eq!(stats.get_context_warning(), Some(ContextWarning::Critical));
}
#[test]
fn test_estimate_remaining_tokens() {
let mut stats = UsageStats::new();
stats.context_tokens_used = 50_000;
stats.context_window_limit = 200_000;
assert_eq!(stats.estimate_remaining_tokens(), 150_000);
}
#[test]
fn test_estimate_remaining_tokens_at_limit() {
let mut stats = UsageStats::new();
stats.context_tokens_used = 200_000;
stats.context_window_limit = 200_000;
assert_eq!(stats.estimate_remaining_tokens(), 0);
}
#[test]
fn test_estimate_remaining_tokens_over_limit() {
let mut stats = UsageStats::new();
stats.context_tokens_used = 250_000;
stats.context_window_limit = 200_000;
assert_eq!(stats.estimate_remaining_tokens(), 0);
}
#[test]
fn test_context_reset_on_session_reset() {
let mut stats = UsageStats::new();
stats.add_usage(100_000, 20_000, "claude-sonnet-4-20250514");
assert!(stats.context_tokens_used > 0);
assert!(stats.context_utilisation_percent > 0.0);
stats.reset_session();
assert_eq!(stats.context_tokens_used, 0);
assert_eq!(stats.context_utilisation_percent, 0.0);
}
#[test]
fn test_context_warning_message() {
assert_eq!(
ContextWarning::Moderate.message(),
"Context window is 50%+ full. Consider starting a new conversation for better performance."
);
assert_eq!(
ContextWarning::High.message(),
"Context window is 75%+ full. Responses may degrade. Consider summarising or starting fresh."
);
assert_eq!(
ContextWarning::Critical.message(),
"Context window is nearly full (90%+)! Start a new conversation to avoid errors."
);
}
#[test]
fn test_context_warning_serialization() {
let warning = ContextWarning::Critical;
let json = serde_json::to_string(&warning).expect("Failed to serialize");
assert_eq!(json, "\"critical\"");
let warning = ContextWarning::Moderate;
let json = serde_json::to_string(&warning).expect("Failed to serialize");
assert_eq!(json, "\"moderate\"");
}
// =====================
// Budget Tracking tests
// =====================
#[test]
fn test_budget_disabled_returns_ok() {
let stats = UsageStats::new();
let status = stats.check_budget(false, Some(1000), Some(1.0), 0.8);
assert_eq!(status, BudgetStatus::Ok);
}
#[test]
fn test_budget_no_limits_returns_ok() {
let stats = UsageStats::new();
let status = stats.check_budget(true, None, None, 0.8);
assert_eq!(status, BudgetStatus::Ok);
}
#[test]
fn test_token_budget_within_limit() {
let mut stats = UsageStats::new();
stats.session_input_tokens = 500;
stats.session_output_tokens = 300;
let status = stats.check_budget(true, Some(10000), None, 0.8);
assert_eq!(status, BudgetStatus::Ok);
}
#[test]
fn test_token_budget_warning() {
let mut stats = UsageStats::new();
stats.session_input_tokens = 4500;
stats.session_output_tokens = 4000;
let status = stats.check_budget(true, Some(10000), None, 0.8);
match status {
BudgetStatus::Warning {
budget_type,
percent_used,
} => {
assert_eq!(budget_type, BudgetType::Token);
assert!(percent_used >= 80.0);
}
_ => panic!("Expected Warning status"),
}
}
#[test]
fn test_token_budget_exceeded() {
let mut stats = UsageStats::new();
stats.session_input_tokens = 6000;
stats.session_output_tokens = 5000;
let status = stats.check_budget(true, Some(10000), None, 0.8);
assert_eq!(
status,
BudgetStatus::Exceeded {
budget_type: BudgetType::Token
}
);
}
#[test]
fn test_cost_budget_within_limit() {
let mut stats = UsageStats::new();
stats.session_cost_usd = 0.50;
let status = stats.check_budget(true, None, Some(5.0), 0.8);
assert_eq!(status, BudgetStatus::Ok);
}
#[test]
fn test_cost_budget_warning() {
let mut stats = UsageStats::new();
stats.session_cost_usd = 4.25;
let status = stats.check_budget(true, None, Some(5.0), 0.8);
match status {
BudgetStatus::Warning {
budget_type,
percent_used,
} => {
assert_eq!(budget_type, BudgetType::Cost);
assert!(percent_used >= 80.0);
}
_ => panic!("Expected Warning status"),
}
}
#[test]
fn test_cost_budget_exceeded() {
let mut stats = UsageStats::new();
stats.session_cost_usd = 5.50;
let status = stats.check_budget(true, None, Some(5.0), 0.8);
assert_eq!(
status,
BudgetStatus::Exceeded {
budget_type: BudgetType::Cost
}
);
}
#[test]
fn test_token_budget_takes_priority() {
let mut stats = UsageStats::new();
stats.session_input_tokens = 12000;
stats.session_output_tokens = 0;
stats.session_cost_usd = 0.01;
// Token budget exceeded, cost budget OK
let status = stats.check_budget(true, Some(10000), Some(5.0), 0.8);
assert_eq!(
status,
BudgetStatus::Exceeded {
budget_type: BudgetType::Token
}
);
}
#[test]
fn test_remaining_token_budget() {
let mut stats = UsageStats::new();
stats.session_input_tokens = 3000;
stats.session_output_tokens = 2000;
assert_eq!(stats.get_remaining_token_budget(Some(10000)), Some(5000));
assert_eq!(stats.get_remaining_token_budget(None), None);
}
#[test]
fn test_remaining_token_budget_exceeded() {
let mut stats = UsageStats::new();
stats.session_input_tokens = 8000;
stats.session_output_tokens = 5000;
assert_eq!(stats.get_remaining_token_budget(Some(10000)), Some(0));
}
#[test]
fn test_remaining_cost_budget() {
let mut stats = UsageStats::new();
stats.session_cost_usd = 2.50;
let remaining = stats.get_remaining_cost_budget(Some(5.0));
assert!(remaining.is_some());
assert!((remaining.unwrap() - 2.50).abs() < 0.001);
assert_eq!(stats.get_remaining_cost_budget(None), None);
}
#[test]
fn test_remaining_cost_budget_exceeded() {
let mut stats = UsageStats::new();
stats.session_cost_usd = 6.0;
let remaining = stats.get_remaining_cost_budget(Some(5.0));
assert!(remaining.is_some());
assert!((remaining.unwrap() - 0.0).abs() < 0.001);
}
#[test]
fn test_budget_status_serialization() {
let status = BudgetStatus::Warning {
budget_type: BudgetType::Token,
percent_used: 85.5,
};
let json = serde_json::to_string(&status).expect("Failed to serialize");
assert!(json.contains("warning"));
assert!(json.contains("token"));
let status = BudgetStatus::Exceeded {
budget_type: BudgetType::Cost,
};
let json = serde_json::to_string(&status).expect("Failed to serialize");
assert!(json.contains("exceeded"));
assert!(json.contains("cost"));
}
#[test]
fn test_budget_type_serialization() {
let token = BudgetType::Token;
let json = serde_json::to_string(&token).expect("Failed to serialize");
assert_eq!(json, "\"token\"");
let cost = BudgetType::Cost;
let json = serde_json::to_string(&cost).expect("Failed to serialize");
assert_eq!(json, "\"cost\"");
}
} }
+266
View File
@@ -0,0 +1,266 @@
use serde::{Deserialize, Serialize};
use std::collections::hash_map::DefaultHasher;
use std::collections::HashMap;
use std::hash::{Hash, Hasher};
/// Tools that could benefit from caching
#[allow(dead_code)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CacheableTool {
Read,
Glob,
Grep,
}
impl CacheableTool {
#[allow(dead_code)]
pub fn from_name(name: &str) -> Option<Self> {
match name {
"Read" => Some(Self::Read),
"Glob" => Some(Self::Glob),
"Grep" => Some(Self::Grep),
_ => None,
}
}
}
/// Statistics about potential cache savings
#[allow(dead_code)]
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub struct CacheAnalytics {
/// Number of tool calls that could have been cache hits
pub potential_cache_hits: u64,
/// Estimated tokens that could have been saved
pub potential_savings_tokens: u64,
/// Tracks unique tool invocations: hash -> (tool_name, call_count)
#[serde(skip)]
recent_invocations: HashMap<u64, (String, u64)>,
}
#[allow(dead_code)]
impl CacheAnalytics {
pub fn new() -> Self {
Self::default()
}
/// Compute a hash key from tool name and input
fn compute_key(tool_name: &str, input: &serde_json::Value) -> u64 {
let mut hasher = DefaultHasher::new();
tool_name.hash(&mut hasher);
input.to_string().hash(&mut hasher);
hasher.finish()
}
/// Track a tool invocation for analytics
/// Returns true if this was a repeated invocation (potential cache hit)
pub fn track_invocation(
&mut self,
tool_name: &str,
input: &serde_json::Value,
estimated_tokens: u64,
) -> bool {
// Only track cacheable tools
if CacheableTool::from_name(tool_name).is_none() {
return false;
}
let key = Self::compute_key(tool_name, input);
if let Some((_, count)) = self.recent_invocations.get_mut(&key) {
*count += 1;
// This is a repeat - could have been a cache hit
self.potential_cache_hits += 1;
self.potential_savings_tokens += estimated_tokens;
true
} else {
self.recent_invocations
.insert(key, (tool_name.to_string(), 1));
false
}
}
/// Get the number of unique tool invocations being tracked
pub fn unique_invocations(&self) -> usize {
self.recent_invocations.len()
}
/// Get invocations that were called more than once
pub fn repeated_invocations(&self) -> Vec<(&str, u64)> {
self.recent_invocations
.values()
.filter(|(_, count)| *count > 1)
.map(|(name, count)| (name.as_str(), *count))
.collect()
}
/// Clear session analytics (keep totals)
pub fn clear_session(&mut self) {
self.recent_invocations.clear();
}
/// Fully reset all analytics
pub fn reset(&mut self) {
self.potential_cache_hits = 0;
self.potential_savings_tokens = 0;
self.recent_invocations.clear();
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_cacheable_tool_from_name() {
assert_eq!(CacheableTool::from_name("Read"), Some(CacheableTool::Read));
assert_eq!(CacheableTool::from_name("Glob"), Some(CacheableTool::Glob));
assert_eq!(CacheableTool::from_name("Grep"), Some(CacheableTool::Grep));
assert_eq!(CacheableTool::from_name("Bash"), None);
assert_eq!(CacheableTool::from_name("Edit"), None);
assert_eq!(CacheableTool::from_name("Write"), None);
}
#[test]
fn test_first_invocation_not_cache_hit() {
let mut analytics = CacheAnalytics::new();
let input = json!({"file_path": "/home/test/file.txt"});
let is_repeat = analytics.track_invocation("Read", &input, 100);
assert!(!is_repeat);
assert_eq!(analytics.potential_cache_hits, 0);
assert_eq!(analytics.potential_savings_tokens, 0);
}
#[test]
fn test_second_invocation_is_cache_hit() {
let mut analytics = CacheAnalytics::new();
let input = json!({"file_path": "/home/test/file.txt"});
analytics.track_invocation("Read", &input, 100);
let is_repeat = analytics.track_invocation("Read", &input, 100);
assert!(is_repeat);
assert_eq!(analytics.potential_cache_hits, 1);
assert_eq!(analytics.potential_savings_tokens, 100);
}
#[test]
fn test_different_inputs_not_cache_hit() {
let mut analytics = CacheAnalytics::new();
let input1 = json!({"file_path": "/home/test/file1.txt"});
let input2 = json!({"file_path": "/home/test/file2.txt"});
analytics.track_invocation("Read", &input1, 100);
let is_repeat = analytics.track_invocation("Read", &input2, 100);
assert!(!is_repeat);
assert_eq!(analytics.potential_cache_hits, 0);
}
#[test]
fn test_non_cacheable_tool_ignored() {
let mut analytics = CacheAnalytics::new();
let input = json!({"command": "ls -la"});
let is_repeat = analytics.track_invocation("Bash", &input, 100);
analytics.track_invocation("Bash", &input, 100);
assert!(!is_repeat);
assert_eq!(analytics.potential_cache_hits, 0);
assert_eq!(analytics.unique_invocations(), 0);
}
#[test]
fn test_multiple_repeated_invocations() {
let mut analytics = CacheAnalytics::new();
let input = json!({"file_path": "/home/test/file.txt"});
analytics.track_invocation("Read", &input, 100);
analytics.track_invocation("Read", &input, 100);
analytics.track_invocation("Read", &input, 100);
assert_eq!(analytics.potential_cache_hits, 2);
assert_eq!(analytics.potential_savings_tokens, 200);
}
#[test]
fn test_unique_invocations_count() {
let mut analytics = CacheAnalytics::new();
analytics.track_invocation("Read", &json!({"file_path": "/file1.txt"}), 100);
analytics.track_invocation("Read", &json!({"file_path": "/file2.txt"}), 100);
analytics.track_invocation("Glob", &json!({"pattern": "*.rs"}), 50);
assert_eq!(analytics.unique_invocations(), 3);
}
#[test]
fn test_repeated_invocations_list() {
let mut analytics = CacheAnalytics::new();
// file1 read twice
analytics.track_invocation("Read", &json!({"file_path": "/file1.txt"}), 100);
analytics.track_invocation("Read", &json!({"file_path": "/file1.txt"}), 100);
// file2 read once
analytics.track_invocation("Read", &json!({"file_path": "/file2.txt"}), 100);
// glob run 3 times
analytics.track_invocation("Glob", &json!({"pattern": "*.rs"}), 50);
analytics.track_invocation("Glob", &json!({"pattern": "*.rs"}), 50);
analytics.track_invocation("Glob", &json!({"pattern": "*.rs"}), 50);
let repeated = analytics.repeated_invocations();
assert_eq!(repeated.len(), 2); // file1 and glob pattern
}
#[test]
fn test_clear_session() {
let mut analytics = CacheAnalytics::new();
let input = json!({"file_path": "/file.txt"});
analytics.track_invocation("Read", &input, 100);
analytics.track_invocation("Read", &input, 100);
assert_eq!(analytics.potential_cache_hits, 1);
assert_eq!(analytics.unique_invocations(), 1);
analytics.clear_session();
assert_eq!(analytics.potential_cache_hits, 1); // Preserved
assert_eq!(analytics.unique_invocations(), 0); // Cleared
}
#[test]
fn test_reset() {
let mut analytics = CacheAnalytics::new();
let input = json!({"file_path": "/file.txt"});
analytics.track_invocation("Read", &input, 100);
analytics.track_invocation("Read", &input, 100);
analytics.reset();
assert_eq!(analytics.potential_cache_hits, 0);
assert_eq!(analytics.potential_savings_tokens, 0);
assert_eq!(analytics.unique_invocations(), 0);
}
#[test]
fn test_serialization() {
let mut analytics = CacheAnalytics::new();
analytics.potential_cache_hits = 10;
analytics.potential_savings_tokens = 500;
let json = serde_json::to_string(&analytics).expect("Failed to serialize");
let deserialized: CacheAnalytics =
serde_json::from_str(&json).expect("Failed to deserialize");
assert_eq!(deserialized.potential_cache_hits, 10);
assert_eq!(deserialized.potential_savings_tokens, 500);
// recent_invocations is skipped in serialization
assert_eq!(deserialized.unique_invocations(), 0);
}
}
+31
View File
@@ -176,6 +176,14 @@ pub struct StateChangeEvent {
pub conversation_id: Option<String>, pub conversation_id: Option<String>,
} }
/// Cost information for a message
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MessageCost {
pub input_tokens: u64,
pub output_tokens: u64,
pub cost_usd: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OutputEvent { pub struct OutputEvent {
pub line_type: String, pub line_type: String,
@@ -183,6 +191,8 @@ pub struct OutputEvent {
pub tool_name: Option<String>, pub tool_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub conversation_id: Option<String>, pub conversation_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cost: Option<MessageCost>,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -354,10 +364,31 @@ mod tests {
content: "Test output".to_string(), content: "Test output".to_string(),
tool_name: None, tool_name: None,
conversation_id: None, conversation_id: None,
cost: None,
}; };
let serialized = serde_json::to_string(&event).unwrap(); let serialized = serde_json::to_string(&event).unwrap();
assert!(serialized.contains("\"line_type\":\"assistant\"")); assert!(serialized.contains("\"line_type\":\"assistant\""));
assert!(serialized.contains("\"content\":\"Test output\"")); assert!(serialized.contains("\"content\":\"Test output\""));
} }
#[test]
fn test_output_event_with_cost() {
let event = OutputEvent {
line_type: "assistant".to_string(),
content: "Test output".to_string(),
tool_name: None,
conversation_id: Some("conv-123".to_string()),
cost: Some(MessageCost {
input_tokens: 100,
output_tokens: 50,
cost_usd: 0.005,
}),
};
let serialized = serde_json::to_string(&event).unwrap();
assert!(serialized.contains("\"cost\":"));
assert!(serialized.contains("\"input_tokens\":100"));
assert!(serialized.contains("\"output_tokens\":50"));
}
} }
+77 -5
View File
@@ -9,12 +9,13 @@ use tempfile::NamedTempFile;
use std::os::windows::process::CommandExt; use std::os::windows::process::CommandExt;
use crate::achievements::{get_achievement_info, AchievementUnlockedEvent}; use crate::achievements::{get_achievement_info, AchievementUnlockedEvent};
use crate::commands::record_cost;
use crate::config::ClaudeStartOptions; use crate::config::ClaudeStartOptions;
use crate::stats::{StatsUpdateEvent, UsageStats}; use crate::stats::{calculate_cost, StatsUpdateEvent, UsageStats};
use crate::types::{ use crate::types::{
CharacterState, ClaudeMessage, ConnectionEvent, ConnectionStatus, ContentBlock, OutputEvent, CharacterState, ClaudeMessage, ConnectionEvent, ConnectionStatus, ContentBlock, MessageCost,
PermissionPromptEvent, QuestionOption, SessionEvent, StateChangeEvent, UserQuestionEvent, OutputEvent, PermissionPromptEvent, QuestionOption, SessionEvent, StateChangeEvent,
WorkingDirectoryEvent, UserQuestionEvent, WorkingDirectoryEvent,
}; };
use parking_lot::RwLock; use parking_lot::RwLock;
@@ -534,6 +535,7 @@ fn handle_stderr(
content: line, content: line,
tool_name: None, tool_name: None,
conversation_id: conversation_id.clone(), conversation_id: conversation_id.clone(),
cost: None,
}, },
); );
} }
@@ -586,17 +588,57 @@ fn process_json_line(
let mut state = CharacterState::Typing; let mut state = CharacterState::Typing;
let mut tool_name = None; let mut tool_name = None;
// Collect all tool names in this message for token attribution
let tools_in_message: Vec<String> = message
.content
.iter()
.filter_map(|block| match block {
ContentBlock::ToolUse { name, .. } => Some(name.clone()),
_ => None,
})
.collect();
// Track message cost for display
let mut message_cost: Option<MessageCost> = None;
// Only update stats if we have usage information // Only update stats if we have usage information
if let Some(usage) = &message.usage { if let Some(usage) = &message.usage {
if let Some(model) = &message.model { if let Some(model) = &message.model {
// Calculate cost for historical tracking
let cost_usd = calculate_cost(usage.input_tokens, usage.output_tokens, model);
// Store cost for later use in output events
message_cost = Some(MessageCost {
input_tokens: usage.input_tokens,
output_tokens: usage.output_tokens,
cost_usd,
});
// Batch all stats updates in a single write lock // Batch all stats updates in a single write lock
{ {
let mut stats_guard = stats.write(); let mut stats_guard = stats.write();
stats_guard.increment_messages(); stats_guard.increment_messages();
stats_guard.add_usage(usage.input_tokens, usage.output_tokens, model); stats_guard.add_usage(usage.input_tokens, usage.output_tokens, model);
stats_guard.get_session_duration(); stats_guard.get_session_duration();
// Attribute tokens to tools if any tools were used in this message
if !tools_in_message.is_empty() {
let per_tool_input = usage.input_tokens / tools_in_message.len() as u64;
let per_tool_output = usage.output_tokens / tools_in_message.len() as u64;
for tool in &tools_in_message {
stats_guard.add_tool_tokens(tool, per_tool_input, per_tool_output);
}
}
} }
// Record to historical cost tracking
let app_clone = app.clone();
let input = usage.input_tokens;
let output = usage.output_tokens;
tauri::async_runtime::spawn(async move {
record_cost(&app_clone, input, output, cost_usd).await;
});
// Don't emit here - we'll emit on Result message instead // Don't emit here - we'll emit on Result message instead
// This reduces the frequency of updates // This reduces the frequency of updates
} else { } else {
@@ -635,6 +677,7 @@ fn process_json_line(
content: desc, content: desc,
tool_name: Some(name.clone()), tool_name: Some(name.clone()),
conversation_id: conversation_id.clone(), conversation_id: conversation_id.clone(),
cost: None, // Tool use doesn't have separate cost
}, },
); );
} }
@@ -652,6 +695,7 @@ fn process_json_line(
content: text.clone(), content: text.clone(),
tool_name: None, tool_name: None,
conversation_id: conversation_id.clone(), conversation_id: conversation_id.clone(),
cost: message_cost.clone(), // Include cost with assistant text
}, },
); );
} }
@@ -664,6 +708,7 @@ fn process_json_line(
content: format!("[Thinking] {}", thinking), content: format!("[Thinking] {}", thinking),
tool_name: None, tool_name: None,
conversation_id: conversation_id.clone(), conversation_id: conversation_id.clone(),
cost: None,
}, },
); );
} }
@@ -704,7 +749,7 @@ fn process_json_line(
subtype, subtype,
result, result,
permission_denials, permission_denials,
usage: _, usage,
.. ..
} => { } => {
let state = if subtype == "success" { let state = if subtype == "success" {
@@ -713,6 +758,32 @@ fn process_json_line(
CharacterState::Error CharacterState::Error
}; };
// Track token usage from Result messages if available
// This captures tokens from tool outputs and other operations
if let Some(usage_info) = usage {
// We need the model info to calculate cost properly
// For now, use the last known model from stats
let model = {
let stats_guard = stats.read();
stats_guard.model.clone().unwrap_or_else(|| "claude-opus-4-20250514".to_string())
};
// Calculate cost for historical tracking
let cost_usd = calculate_cost(usage_info.input_tokens, usage_info.output_tokens, &model);
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);
// Record to historical cost tracking
let app_clone = app.clone();
let input = usage_info.input_tokens;
let output = usage_info.output_tokens;
tauri::async_runtime::spawn(async move {
record_cost(&app_clone, input, output, cost_usd).await;
});
}
// Always emit updated stats on result message (less frequent) // Always emit updated stats on result message (less frequent)
// This includes the latest session duration // This includes the latest session duration
let newly_unlocked = { let newly_unlocked = {
@@ -782,6 +853,7 @@ fn process_json_line(
content: text.clone(), content: text.clone(),
tool_name: None, tool_name: None,
conversation_id: conversation_id.clone(), conversation_id: conversation_id.clone(),
cost: None,
}, },
); );
} }
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "hikari-desktop", "productName": "hikari-desktop",
"version": "1.0.0", "version": "1.2.0",
"identifier": "com.naomi.hikari-desktop", "identifier": "com.naomi.hikari-desktop",
"build": { "build": {
"beforeDevCommand": "pnpm dev", "beforeDevCommand": "pnpm dev",
@@ -161,7 +161,7 @@
<!-- Celebration confetti effect (CSS only) --> <!-- Celebration confetti effect (CSS only) -->
<div class="absolute inset-0 pointer-events-none overflow-hidden rounded-lg"> <div class="absolute inset-0 pointer-events-none overflow-hidden rounded-lg">
{#each Array(10) as _ (_)} {#each Array.from({ length: 10 }, (_, i) => i) as confettiIndex (confettiIndex)}
<div <div
class="absolute w-2 h-2 bg-gradient-to-br {getRarityColor( class="absolute w-2 h-2 bg-gradient-to-br {getRarityColor(
getAchievementRarity(currentAchievement.achievement.id) getAchievementRarity(currentAchievement.achievement.id)
+144
View File
@@ -12,6 +12,7 @@
} from "$lib/stores/config"; } from "$lib/stores/config";
import { claudeStore } from "$lib/stores/claude"; import { claudeStore } from "$lib/stores/claude";
import { getCurrentWindow } from "@tauri-apps/api/window"; import { getCurrentWindow } from "@tauri-apps/api/window";
import CostSummary from "./CostSummary.svelte";
let config: HikariConfig = $state({ let config: HikariConfig = $state({
model: null, model: null,
@@ -45,6 +46,11 @@
text_secondary: null, text_secondary: null,
border_color: null, border_color: null,
}, },
budget_enabled: false,
session_token_budget: null,
session_cost_budget: null,
budget_action: "warn",
budget_warning_threshold: 0.8,
}); });
let showCustomThemeEditor = $state(false); let showCustomThemeEditor = $state(false);
@@ -74,8 +80,17 @@
const availableModels = [ const availableModels = [
{ value: "", label: "Default (from ~/.claude)" }, { value: "", label: "Default (from ~/.claude)" },
// Current generation (Claude 4.5)
{ value: "claude-sonnet-4-5-20250929", label: "Claude Sonnet 4.5 (Recommended)" },
{ value: "claude-haiku-4-5-20251001", label: "Claude Haiku 4.5 (Fast & Cheap)" },
{ value: "claude-opus-4-5-20251101", label: "Claude Opus 4.5 (Most Capable)" },
// Previous generation (Claude 4)
{ value: "claude-opus-4-1-20250805", label: "Claude Opus 4.1" },
{ value: "claude-sonnet-4-20250514", label: "Claude Sonnet 4" }, { value: "claude-sonnet-4-20250514", label: "Claude Sonnet 4" },
{ value: "claude-opus-4-20250514", label: "Claude Opus 4" }, { value: "claude-opus-4-20250514", label: "Claude Opus 4" },
// Legacy (Claude 3.x)
{ value: "claude-3-7-sonnet-20250219", label: "Claude 3.7 Sonnet" },
{ value: "claude-3-haiku-20240307", label: "Claude 3 Haiku (Cheapest)" },
]; ];
const commonTools = [ const commonTools = [
@@ -778,6 +793,135 @@
{/if} {/if}
</section> </section>
<!-- Budget Settings Section -->
<section class="mb-6">
<h3 class="text-sm font-medium text-[var(--accent-primary)] uppercase tracking-wider mb-3">
Budget Settings
</h3>
<!-- Enable Budget Tracking -->
<div class="mb-4">
<label class="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
bind:checked={config.budget_enabled}
class="w-4 h-4 text-[var(--accent-primary)] bg-[var(--bg-primary)] border-[var(--border-color)] rounded focus:ring-[var(--accent-primary)] focus:ring-2"
/>
<span class="text-sm text-[var(--text-primary)]">Enable budget tracking</span>
</label>
<p class="text-xs text-[var(--text-tertiary)] mt-1 ml-7">
Set limits on token usage and costs per session
</p>
</div>
{#if config.budget_enabled}
<!-- Token Budget -->
<div class="mb-4">
<label for="token-budget" class="block text-sm text-[var(--text-secondary)] mb-1">
Session Token Budget
</label>
<div class="flex items-center gap-2">
<input
id="token-budget"
type="number"
bind:value={config.session_token_budget}
min="0"
step="10000"
placeholder="e.g., 100000"
class="flex-1 px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] text-sm focus:outline-none focus:border-[var(--accent-primary)]"
/>
<span class="text-xs text-[var(--text-tertiary)]">tokens</span>
</div>
<p class="text-xs text-[var(--text-tertiary)] mt-1">Leave empty for unlimited tokens</p>
</div>
<!-- Cost Budget -->
<div class="mb-4">
<label for="cost-budget" class="block text-sm text-[var(--text-secondary)] mb-1">
Session Cost Budget
</label>
<div class="flex items-center gap-2">
<span class="text-sm text-[var(--text-secondary)]">$</span>
<input
id="cost-budget"
type="number"
bind:value={config.session_cost_budget}
min="0"
step="0.50"
placeholder="e.g., 5.00"
class="flex-1 px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] text-sm focus:outline-none focus:border-[var(--accent-primary)]"
/>
<span class="text-xs text-[var(--text-tertiary)]">USD</span>
</div>
<p class="text-xs text-[var(--text-tertiary)] mt-1">Leave empty for unlimited spending</p>
</div>
<!-- Warning Threshold -->
<div class="mb-4">
<label for="warning-threshold" class="block text-sm text-[var(--text-secondary)] mb-2">
Warning Threshold
</label>
<div class="flex items-center gap-3">
<input
id="warning-threshold"
type="range"
bind:value={config.budget_warning_threshold}
min="0.5"
max="0.95"
step="0.05"
class="flex-1 h-2 bg-[var(--bg-primary)] rounded-lg appearance-none cursor-pointer"
/>
<span class="text-sm text-[var(--text-secondary)] w-12 text-right">
{Math.round(config.budget_warning_threshold * 100)}%
</span>
</div>
<p class="text-xs text-[var(--text-tertiary)] mt-1">
Show warning when this percentage of budget is used
</p>
</div>
<!-- Budget Action -->
<div class="mb-4">
<span class="block text-sm text-[var(--text-secondary)] mb-2"
>When budget is exceeded</span
>
<div class="flex gap-2" role="group" aria-label="Budget action">
<button
onclick={() => (config.budget_action = "warn")}
class="flex-1 px-3 py-2 rounded-lg border transition-colors text-sm {config.budget_action ===
'warn'
? '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)]'}"
>
Warn Only
</button>
<button
onclick={() => (config.budget_action = "block")}
class="flex-1 px-3 py-2 rounded-lg border transition-colors text-sm {config.budget_action ===
'block'
? '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)]'}"
>
Block Input
</button>
</div>
<p class="text-xs text-[var(--text-tertiary)] mt-2">
{config.budget_action === "warn"
? "Show a warning but allow continued usage"
: "Prevent sending more messages until session is reset"}
</p>
</div>
{/if}
</section>
<!-- Cost History Section -->
<section class="mb-6">
<h3 class="text-sm font-medium text-[var(--accent-primary)] uppercase tracking-wider mb-3">
Cost History
</h3>
<CostSummary />
</section>
<!-- Notifications Section --> <!-- Notifications Section -->
<section class="mb-6"> <section class="mb-6">
<h3 class="text-sm font-medium text-[var(--accent-primary)] uppercase tracking-wider mb-3"> <h3 class="text-sm font-medium text-[var(--accent-primary)] uppercase tracking-wider mb-3">
+402
View File
@@ -0,0 +1,402 @@
<script lang="ts">
import {
costTrackingStore,
formattedCosts,
formatCost,
type CostSummary,
type CostAlertThresholds,
} from "$lib/stores/costTracking";
let selectedPeriod = $state<7 | 30 | 90>(7);
let summary = $state<CostSummary | null>(null);
let isLoading = $state(false);
let showThresholdSettings = $state(false);
let thresholds = $state<CostAlertThresholds>({
daily: null,
weekly: null,
monthly: null,
});
const costs = $derived($formattedCosts);
async function loadSummary() {
isLoading = true;
summary = await costTrackingStore.getSummary(selectedPeriod);
isLoading = false;
}
async function handleExport() {
const csv = await costTrackingStore.exportCsv(selectedPeriod);
if (csv) {
const blob = new Blob([csv], { type: "text/csv" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `hikari-costs-${selectedPeriod}days.csv`;
a.click();
URL.revokeObjectURL(url);
}
}
async function handleSaveThresholds() {
await costTrackingStore.setAlertThresholds(thresholds);
showThresholdSettings = false;
}
$effect(() => {
loadSummary();
});
</script>
<div class="cost-summary">
<h3 class="summary-title">Cost Summary</h3>
<!-- Quick Stats -->
<div class="quick-stats">
<div class="stat-card">
<span class="stat-label">Today</span>
<span class="stat-value">{costs.today}</span>
</div>
<div class="stat-card">
<span class="stat-label">This Week</span>
<span class="stat-value">{costs.week}</span>
</div>
<div class="stat-card">
<span class="stat-label">This Month</span>
<span class="stat-value">{costs.month}</span>
</div>
</div>
<!-- Period Selector -->
<div class="period-selector">
<button
class="period-btn"
class:active={selectedPeriod === 7}
onclick={() => (selectedPeriod = 7)}
>
7 Days
</button>
<button
class="period-btn"
class:active={selectedPeriod === 30}
onclick={() => (selectedPeriod = 30)}
>
30 Days
</button>
<button
class="period-btn"
class:active={selectedPeriod === 90}
onclick={() => (selectedPeriod = 90)}
>
90 Days
</button>
</div>
<!-- Summary Details -->
{#if isLoading}
<div class="loading">Loading...</div>
{:else if summary}
<div class="summary-details">
<div class="detail-row">
<span class="detail-label">Total Cost</span>
<span class="detail-value highlight">{formatCost(summary.total_cost)}</span>
</div>
<div class="detail-row">
<span class="detail-label">Average Daily</span>
<span class="detail-value">{formatCost(summary.average_daily_cost)}</span>
</div>
<div class="detail-row">
<span class="detail-label">Messages</span>
<span class="detail-value">{summary.total_messages.toLocaleString()}</span>
</div>
<div class="detail-row">
<span class="detail-label">Sessions</span>
<span class="detail-value">{summary.total_sessions.toLocaleString()}</span>
</div>
<div class="detail-row">
<span class="detail-label">Input Tokens</span>
<span class="detail-value">{summary.total_input_tokens.toLocaleString()}</span>
</div>
<div class="detail-row">
<span class="detail-label">Output Tokens</span>
<span class="detail-value">{summary.total_output_tokens.toLocaleString()}</span>
</div>
</div>
<!-- Daily Breakdown (mini chart) -->
{#if summary.daily_breakdown.length > 0}
<div class="chart-section">
<h4 class="chart-title">Daily Spending</h4>
<div class="mini-chart">
{#each summary.daily_breakdown.slice(-14) as day (day.date)}
{@const maxCost = Math.max(...summary.daily_breakdown.map((d) => d.cost_usd), 0.01)}
{@const height = (day.cost_usd / maxCost) * 100}
<div class="chart-bar-container" title="{day.date}: {formatCost(day.cost_usd)}">
<div class="chart-bar" style="height: {height}%"></div>
</div>
{/each}
</div>
</div>
{/if}
{/if}
<!-- Actions -->
<div class="actions">
<button class="action-btn" onclick={handleExport}> Export CSV </button>
<button class="action-btn" onclick={() => (showThresholdSettings = !showThresholdSettings)}>
Set Alerts
</button>
</div>
<!-- Threshold Settings -->
{#if showThresholdSettings}
<div class="threshold-settings">
<h4>Cost Alert Thresholds</h4>
<div class="threshold-row">
<label for="daily-threshold">Daily</label>
<input
id="daily-threshold"
type="number"
step="0.01"
placeholder="e.g., 1.00"
bind:value={thresholds.daily}
/>
</div>
<div class="threshold-row">
<label for="weekly-threshold">Weekly</label>
<input
id="weekly-threshold"
type="number"
step="0.01"
placeholder="e.g., 5.00"
bind:value={thresholds.weekly}
/>
</div>
<div class="threshold-row">
<label for="monthly-threshold">Monthly</label>
<input
id="monthly-threshold"
type="number"
step="0.01"
placeholder="e.g., 20.00"
bind:value={thresholds.monthly}
/>
</div>
<button class="save-btn" onclick={handleSaveThresholds}>Save Thresholds</button>
</div>
{/if}
</div>
<style>
.cost-summary {
padding: 1rem;
background: var(--bg-secondary);
border-radius: 8px;
}
.summary-title {
margin: 0 0 1rem 0;
font-size: 1.1rem;
color: var(--text-primary);
}
.quick-stats {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.5rem;
margin-bottom: 1rem;
}
.stat-card {
background: var(--bg-primary);
padding: 0.75rem;
border-radius: 6px;
text-align: center;
}
.stat-label {
display: block;
font-size: 0.75rem;
color: var(--text-secondary);
margin-bottom: 0.25rem;
}
.stat-value {
display: block;
font-size: 1.1rem;
font-weight: 600;
color: var(--accent-primary);
}
.period-selector {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
}
.period-btn {
flex: 1;
padding: 0.5rem;
border: 1px solid var(--border-color);
background: var(--bg-primary);
color: var(--text-secondary);
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
}
.period-btn:hover {
border-color: var(--accent-primary);
}
.period-btn.active {
background: var(--accent-primary);
color: white;
border-color: var(--accent-primary);
}
.loading {
text-align: center;
padding: 1rem;
color: var(--text-secondary);
}
.summary-details {
background: var(--bg-primary);
padding: 0.75rem;
border-radius: 6px;
margin-bottom: 1rem;
}
.detail-row {
display: flex;
justify-content: space-between;
padding: 0.25rem 0;
border-bottom: 1px solid var(--border-color);
}
.detail-row:last-child {
border-bottom: none;
}
.detail-label {
color: var(--text-secondary);
font-size: 0.9rem;
}
.detail-value {
color: var(--text-primary);
font-weight: 500;
}
.detail-value.highlight {
color: var(--accent-primary);
font-size: 1.1rem;
}
.chart-section {
margin-bottom: 1rem;
}
.chart-title {
margin: 0 0 0.5rem 0;
font-size: 0.9rem;
color: var(--text-secondary);
}
.mini-chart {
display: flex;
align-items: flex-end;
gap: 2px;
height: 60px;
background: var(--bg-primary);
padding: 0.5rem;
border-radius: 4px;
}
.chart-bar-container {
flex: 1;
height: 100%;
display: flex;
align-items: flex-end;
}
.chart-bar {
width: 100%;
background: var(--accent-primary);
border-radius: 2px 2px 0 0;
min-height: 2px;
transition: height 0.3s;
}
.actions {
display: flex;
gap: 0.5rem;
}
.action-btn {
flex: 1;
padding: 0.5rem;
border: 1px solid var(--border-color);
background: var(--bg-primary);
color: var(--text-primary);
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
}
.action-btn:hover {
background: var(--bg-secondary);
border-color: var(--accent-primary);
}
.threshold-settings {
margin-top: 1rem;
padding: 1rem;
background: var(--bg-primary);
border-radius: 6px;
}
.threshold-settings h4 {
margin: 0 0 0.75rem 0;
font-size: 0.9rem;
color: var(--text-primary);
}
.threshold-row {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.threshold-row label {
width: 60px;
font-size: 0.85rem;
color: var(--text-secondary);
}
.threshold-row input {
flex: 1;
padding: 0.4rem;
border: 1px solid var(--border-color);
background: var(--bg-secondary);
color: var(--text-primary);
border-radius: 4px;
}
.save-btn {
width: 100%;
margin-top: 0.5rem;
padding: 0.5rem;
background: var(--accent-primary);
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.save-btn:hover {
opacity: 0.9;
}
</style>
+66
View File
@@ -26,10 +26,12 @@
type SlashCommand, type SlashCommand,
} from "$lib/commands/slashCommands"; } from "$lib/commands/slashCommands";
import { configStore, isStreamerMode } from "$lib/stores/config"; import { configStore, isStreamerMode } from "$lib/stores/config";
import { stats, estimateMessageCost, formatTokenCount } from "$lib/stores/stats";
import AttachmentPreview from "$lib/components/AttachmentPreview.svelte"; import AttachmentPreview from "$lib/components/AttachmentPreview.svelte";
import SnippetLibraryPanel from "$lib/components/SnippetLibraryPanel.svelte"; import SnippetLibraryPanel from "$lib/components/SnippetLibraryPanel.svelte";
import QuickActionsPanel from "$lib/components/QuickActionsPanel.svelte"; import QuickActionsPanel from "$lib/components/QuickActionsPanel.svelte";
import ClipboardHistoryPanel from "$lib/components/ClipboardHistoryPanel.svelte"; import ClipboardHistoryPanel from "$lib/components/ClipboardHistoryPanel.svelte";
import TextInputContextMenu from "$lib/components/TextInputContextMenu.svelte";
import type { Attachment } from "$lib/types/messages"; import type { Attachment } from "$lib/types/messages";
const INPUT_HISTORY_KEY = "hikari-input-history"; const INPUT_HISTORY_KEY = "hikari-input-history";
@@ -49,6 +51,30 @@
let showClipboardHistory = $state(false); let showClipboardHistory = $state(false);
let streamerModeActive = $state(false); let streamerModeActive = $state(false);
// Cost estimation for pre-submission display
let costEstimate = $derived(
inputValue.trim()
? estimateMessageCost(inputValue, $stats.context_tokens_used, $stats.model)
: null
);
// 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) => { isStreamerMode.subscribe((value) => {
streamerModeActive = value; streamerModeActive = value;
}); });
@@ -876,10 +902,12 @@ User: ${formattedMessage}`;
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="resize-handle" onmousedown={handleResizeStart} title="Drag to resize"></div> <div class="resize-handle" onmousedown={handleResizeStart} title="Drag to resize"></div>
<textarea <textarea
bind:this={textareaElement}
bind:value={inputValue} bind:value={inputValue}
onkeydown={handleKeyDown} onkeydown={handleKeyDown}
oninput={handleInputChange} oninput={handleInputChange}
onpaste={handlePaste} onpaste={handlePaste}
oncontextmenu={handleContextMenu}
placeholder={isConnected placeholder={isConnected
? "Ask Hikari anything... (type / for commands)" ? "Ask Hikari anything... (type / for commands)"
: "Connect to Claude first..."} : "Connect to Claude first..."}
@@ -893,6 +921,13 @@ User: ${formattedMessage}`;
</div> </div>
<div class="button-wrapper"> <div class="button-wrapper">
{#if costEstimate && isConnected && !isProcessing}
<div class="cost-estimate" title="Estimated input cost for this message">
<span class="cost-tokens">+{formatTokenCount(costEstimate.messageTokens)}</span>
<span class="cost-value">${costEstimate.estimatedCost.toFixed(4)}</span>
</div>
{/if}
<button type="button" onclick={handleFilePicker} class="attach-button" title="Attach files"> <button type="button" onclick={handleFilePicker} class="attach-button" title="Attach files">
<svg <svg
width="20" width="20"
@@ -958,6 +993,15 @@ User: ${formattedMessage}`;
/> />
{/if} {/if}
{#if contextMenuShow && textareaElement}
<TextInputContextMenu
x={contextMenuX}
y={contextMenuY}
inputElement={textareaElement}
onClose={closeContextMenu}
/>
{/if}
<style> <style>
.input-bar { .input-bar {
display: flex; display: flex;
@@ -1109,6 +1153,28 @@ User: ${formattedMessage}`;
display: flex; display: flex;
align-items: flex-end; align-items: flex-end;
height: 100%; height: 100%;
gap: 8px;
}
.cost-estimate {
display: flex;
flex-direction: column;
align-items: flex-end;
justify-content: center;
padding: 0 8px;
font-size: 0.7rem;
color: var(--text-secondary);
min-width: 60px;
height: 48px;
}
.cost-tokens {
opacity: 0.8;
}
.cost-value {
font-family: var(--font-mono, monospace);
color: var(--accent-primary);
} }
.attach-button { .attach-button {
@@ -12,6 +12,7 @@
{ keys: ["Escape"], description: "Close modals and panels" }, { keys: ["Escape"], description: "Close modals and panels" },
{ keys: ["Ctrl", "L"], description: "Clear the terminal" }, { keys: ["Ctrl", "L"], description: "Clear the terminal" },
{ keys: ["Ctrl", ","], description: "Open settings" }, { keys: ["Ctrl", ","], description: "Open settings" },
{ keys: ["Ctrl", "E"], description: "Toggle file editor" },
{ keys: ["Ctrl", "Shift", "M"], description: "Toggle compact mode" }, { keys: ["Ctrl", "Shift", "M"], description: "Toggle compact mode" },
{ keys: ["Ctrl", "Shift", "S"], description: "Toggle streamer mode" }, { keys: ["Ctrl", "Shift", "S"], description: "Toggle streamer mode" },
], ],
@@ -26,6 +27,17 @@
{ keys: ["↓"], description: "Next input from history" }, { keys: ["↓"], description: "Next input from history" },
], ],
}, },
{
category: "File Editor",
items: [
{ keys: ["Ctrl", "E"], description: "Toggle editor view" },
{ keys: ["Ctrl", "B"], description: "Toggle file browser" },
{ keys: ["Ctrl", "S"], description: "Save current file" },
{ keys: ["Ctrl", "W"], description: "Close current tab" },
{ keys: ["Ctrl", "N"], description: "New file" },
{ keys: ["Right-click"], description: "Context menu (New/Delete)" },
],
},
{ {
category: "Slash Commands", category: "Slash Commands",
items: [ items: [
+526 -7
View File
@@ -1,8 +1,84 @@
<script lang="ts"> <script lang="ts">
import { formattedStats } from "$lib/stores/stats"; import {
formattedStats,
contextWarning,
getContextWarningMessage,
stats,
checkBudget,
getBudgetStatusMessage,
getRemainingTokenBudget,
getRemainingCostBudget,
} from "$lib/stores/stats";
import { configStore } from "$lib/stores/config";
import { costTrackingStore, formattedCosts } from "$lib/stores/costTracking";
import { fade } from "svelte/transition"; import { fade } from "svelte/transition";
import { onMount } from "svelte";
let showToolsBreakdown = false; interface Props {
onRequestSummary?: () => void;
onStartFreshWithContext?: () => void;
isSummarising?: boolean;
}
let { onRequestSummary, onStartFreshWithContext, isSummarising = false }: Props = $props();
let showToolsBreakdown = $state(false);
let showHistoricalCosts = $state(false);
const historicalCosts = $derived($formattedCosts);
// Initialize cost tracking on mount
onMount(() => {
costTrackingStore.refresh();
});
// Subscribe to config store
const config = configStore.config;
const warning = $derived($contextWarning);
// Budget tracking - must be defined before showCompactionOptions
const budgetStatus = $derived(
checkBudget(
$stats,
$config.budget_enabled,
$config.session_token_budget,
$config.session_cost_budget,
$config.budget_warning_threshold
)
);
const budgetMessage = $derived(getBudgetStatusMessage(budgetStatus));
// Show compaction options when context or budget is at warning/critical levels
const showCompactionOptions = $derived(
warning === "high" ||
warning === "critical" ||
budgetStatus.type === "warning" ||
budgetStatus.type === "exceeded"
);
const remainingTokens = $derived(getRemainingTokenBudget($stats, $config.session_token_budget));
const remainingCost = $derived(getRemainingCostBudget($stats, $config.session_cost_budget));
// Calculate budget usage percentages for progress bars
const tokenBudgetPercent = $derived(() => {
const budget = $config.session_token_budget;
if (budget === null || budget === 0) return 0;
const used = $stats.session_input_tokens + $stats.session_output_tokens;
return Math.min(100, (used / budget) * 100);
});
const costBudgetPercent = $derived(() => {
const budget = $config.session_cost_budget;
if (budget === null || budget === 0) return 0;
return Math.min(100, ($stats.session_cost_usd / budget) * 100);
});
// Get the appropriate colour class for the progress bar
function getBudgetBarClass(percent: number, warningThreshold: number): string {
if (percent >= 100) return "budget-bar-exceeded";
if (percent >= warningThreshold * 100) return "budget-bar-warning";
return "budget-bar-ok";
}
</script> </script>
<div class="stats-display" transition:fade={{ duration: 200 }}> <div class="stats-display" transition:fade={{ duration: 200 }}>
@@ -16,6 +92,120 @@
<span class="stat-value">{$formattedStats.messagesSession}</span> <span class="stat-value">{$formattedStats.messagesSession}</span>
</div> </div>
<div class="stats-section">
<h3>Context Window</h3>
<div class="stat-row">
<span class="stat-label">Used:</span>
<span class="stat-value">{$formattedStats.contextUsed} / {$formattedStats.contextLimit}</span>
</div>
<div class="stat-row">
<span class="stat-label">Utilisation:</span>
<span class="stat-value context-util {warning ? `warning-${warning}` : ''}"
>{$formattedStats.contextUtilisation}</span
>
</div>
{#if warning}
<div class="context-warning warning-{warning}">
{getContextWarningMessage(warning)}
</div>
{/if}
{#if showCompactionOptions && (onRequestSummary || onStartFreshWithContext)}
<div class="compaction-actions">
{#if onRequestSummary}
<button
class="compaction-btn"
onclick={onRequestSummary}
disabled={isSummarising}
title="Compact conversation history to reduce context usage"
>
{#if isSummarising}
Compacting...
{:else}
Compact
{/if}
</button>
{/if}
{#if onStartFreshWithContext}
<button
class="compaction-btn compaction-btn-primary"
onclick={onStartFreshWithContext}
disabled={isSummarising}
title="Start a new conversation with context from this one"
>
Fresh Start
</button>
{/if}
</div>
{/if}
</div>
{#if $config.budget_enabled}
<div class="stats-section">
<h3>Budget</h3>
{#if $config.session_token_budget !== null}
<div class="budget-item">
<div class="stat-row">
<span class="stat-label">Tokens:</span>
<span
class="stat-value {budgetStatus.type !== 'ok' && budgetStatus.budget_type === 'token'
? `budget-${budgetStatus.type}`
: ''}"
>
{($stats.session_input_tokens + $stats.session_output_tokens).toLocaleString()} / {$config.session_token_budget.toLocaleString()}
</span>
</div>
<div class="budget-bar-container">
<div
class="budget-bar {getBudgetBarClass(
tokenBudgetPercent(),
$config.budget_warning_threshold
)}"
style="width: {tokenBudgetPercent()}%"
></div>
</div>
<div class="budget-remaining">
{remainingTokens?.toLocaleString() ?? 0} remaining ({(
100 - tokenBudgetPercent()
).toFixed(1)}%)
</div>
</div>
{/if}
{#if $config.session_cost_budget !== null}
<div class="budget-item">
<div class="stat-row">
<span class="stat-label">Cost:</span>
<span
class="stat-value {budgetStatus.type !== 'ok' && budgetStatus.budget_type === 'cost'
? `budget-${budgetStatus.type}`
: ''}"
>
${$stats.session_cost_usd.toFixed(4)} / ${$config.session_cost_budget.toFixed(2)}
</span>
</div>
<div class="budget-bar-container">
<div
class="budget-bar {getBudgetBarClass(
costBudgetPercent(),
$config.budget_warning_threshold
)}"
style="width: {costBudgetPercent()}%"
></div>
</div>
<div class="budget-remaining">
${remainingCost?.toFixed(4) ?? "0.0000"} remaining ({(
100 - costBudgetPercent()
).toFixed(1)}%)
</div>
</div>
{/if}
{#if budgetMessage}
<div class="budget-warning budget-{budgetStatus.type}">
{budgetMessage}
</div>
{/if}
</div>
{/if}
<div class="stats-section"> <div class="stats-section">
<h3>Tokens & Cost</h3> <h3>Tokens & Cost</h3>
<div class="stat-row"> <div class="stat-row">
@@ -49,7 +239,7 @@
</div> </div>
</div> </div>
{#if Object.keys($formattedStats.sessionToolsUsage).length > 0} {#if $formattedStats.sessionToolsFormatted.length > 0}
<div class="stats-section"> <div class="stats-section">
<h3 class="tools-header"> <h3 class="tools-header">
<button class="tools-toggle" onclick={() => (showToolsBreakdown = !showToolsBreakdown)}> <button class="tools-toggle" onclick={() => (showToolsBreakdown = !showToolsBreakdown)}>
@@ -59,17 +249,57 @@
</h3> </h3>
{#if showToolsBreakdown} {#if showToolsBreakdown}
<div class="tools-breakdown"> <div class="tools-breakdown">
{#each Object.entries($formattedStats.sessionToolsUsage).sort((a, b) => b[1] - a[1]) as [tool, count] (tool)} {#each $formattedStats.sessionToolsFormatted.sort((a, b) => b.totalTokens - a.totalTokens) as tool (tool.name)}
<div class="stat-row stat-detail"> <div class="stat-row stat-detail tool-row">
<span class="stat-label">{tool}:</span> <span class="stat-label">{tool.name}:</span>
<span class="stat-value">{count}</span> <span class="stat-value tool-stats">
<span class="tool-calls">{tool.callCount} calls</span>
{#if tool.totalTokens > 0}
<span class="tool-tokens">(~{tool.formattedTokens})</span>
{/if}
</span>
</div> </div>
{/each} {/each}
<div class="tools-note">* Token estimates based on attribution</div>
</div> </div>
{/if} {/if}
</div> </div>
{/if} {/if}
<!-- Historical Costs Section -->
<div class="stats-section">
<h3 class="costs-header">
<button class="costs-toggle" onclick={() => (showHistoricalCosts = !showHistoricalCosts)}>
Historical Costs
<span class="toggle-icon">{showHistoricalCosts ? "â–Ľ" : "â–¶"}</span>
</button>
</h3>
{#if !showHistoricalCosts}
<div class="costs-quick-stats">
<span class="cost-badge" title="Today's cost">Today: {historicalCosts.today}</span>
<span class="cost-badge" title="This week's cost">Week: {historicalCosts.week}</span>
<span class="cost-badge" title="This month's cost">Month: {historicalCosts.month}</span>
</div>
{/if}
{#if showHistoricalCosts}
<div class="historical-costs-expanded">
<div class="stat-row">
<span class="stat-label">Today:</span>
<span class="stat-value cost-value">{historicalCosts.today}</span>
</div>
<div class="stat-row">
<span class="stat-label">This Week:</span>
<span class="stat-value cost-value">{historicalCosts.week}</span>
</div>
<div class="stat-row">
<span class="stat-label">This Month:</span>
<span class="stat-value cost-value">{historicalCosts.month}</span>
</div>
<p class="costs-note">Open Settings to view detailed cost history and set alerts.</p>
</div>
{/if}
</div>
<div class="model-info"> <div class="model-info">
<span class="model-label">Model:</span> <span class="model-label">Model:</span>
<span class="model-value">{$formattedStats.model}</span> <span class="model-value">{$formattedStats.model}</span>
@@ -128,6 +358,79 @@
color: var(--text-primary, #e5e7eb); color: var(--text-primary, #e5e7eb);
} }
.stat-cost {
font-family: var(--font-mono, monospace);
color: var(--accent-primary, #10b981);
font-size: 0.8rem;
margin-left: 0.5rem;
}
.stats-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.125rem 0;
}
.tools-header {
margin: 0 !important;
padding: 0 !important;
border: none !important;
}
.tools-toggle {
display: flex;
align-items: center;
gap: 0.5rem;
background: none;
border: none;
color: var(--text-primary);
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
padding: 0;
}
.tools-toggle:hover {
color: var(--accent-primary);
}
.toggle-icon {
font-size: 0.7rem;
opacity: 0.7;
}
.tools-breakdown {
margin-top: 0.25rem;
}
.tool-row {
flex-wrap: wrap;
}
.tool-stats {
display: flex;
gap: 0.5rem;
align-items: center;
}
.tool-calls {
color: var(--text-primary, #e5e7eb);
}
.tool-tokens {
color: var(--text-secondary, #9ca3af);
font-size: 0.75rem;
}
.tools-note {
margin-top: 0.5rem;
font-size: 0.65rem;
color: var(--text-secondary, #9ca3af);
font-style: italic;
opacity: 0.8;
}
.model-info { .model-info {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@@ -148,4 +451,220 @@
color: var(--text-primary, #e5e7eb); color: var(--text-primary, #e5e7eb);
font-size: 0.75rem; font-size: 0.75rem;
} }
.context-util {
font-weight: 600;
}
.context-util.warning-moderate {
color: #f59e0b;
}
.context-util.warning-high {
color: #f97316;
}
.context-util.warning-critical {
color: #ef4444;
}
.context-warning {
margin-top: 0.5rem;
padding: 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
line-height: 1.3;
}
.context-warning.warning-moderate {
background: rgba(245, 158, 11, 0.15);
border: 1px solid rgba(245, 158, 11, 0.3);
color: #fbbf24;
}
.context-warning.warning-high {
background: rgba(249, 115, 22, 0.15);
border: 1px solid rgba(249, 115, 22, 0.3);
color: #fb923c;
}
.context-warning.warning-critical {
background: rgba(239, 68, 68, 0.15);
border: 1px solid rgba(239, 68, 68, 0.3);
color: #f87171;
}
/* Budget progress bar styles */
.budget-item {
margin-bottom: 0.75rem;
}
.budget-item:last-child {
margin-bottom: 0;
}
.budget-bar-container {
width: 100%;
height: 6px;
background: var(--bg-primary);
border-radius: 3px;
margin-top: 0.25rem;
overflow: hidden;
}
.budget-bar {
height: 100%;
border-radius: 3px;
transition:
width 0.3s ease,
background-color 0.3s ease;
}
.budget-bar-ok {
background: linear-gradient(90deg, #10b981, #34d399);
}
.budget-bar-warning {
background: linear-gradient(90deg, #f59e0b, #fbbf24);
}
.budget-bar-exceeded {
background: linear-gradient(90deg, #ef4444, #f87171);
}
.budget-remaining {
font-size: 0.7rem;
color: var(--text-secondary);
margin-top: 0.125rem;
text-align: right;
}
/* Budget warning styles */
.budget-warning {
margin-top: 0.5rem;
padding: 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
line-height: 1.3;
}
.budget-warning.budget-warning {
background: rgba(245, 158, 11, 0.15);
border: 1px solid rgba(245, 158, 11, 0.3);
color: #fbbf24;
}
.budget-warning.budget-exceeded {
background: rgba(239, 68, 68, 0.15);
border: 1px solid rgba(239, 68, 68, 0.3);
color: #f87171;
}
.stat-value.budget-warning {
color: #f59e0b;
font-weight: 600;
}
.stat-value.budget-exceeded {
color: #ef4444;
font-weight: 600;
}
/* Compaction action buttons */
.compaction-actions {
display: flex;
gap: 0.5rem;
margin-top: 0.5rem;
}
.compaction-btn {
flex: 1;
padding: 0.375rem 0.5rem;
font-size: 0.75rem;
font-weight: 500;
border-radius: 4px;
border: 1px solid var(--border-color);
background: var(--bg-primary);
color: var(--text-primary);
cursor: pointer;
transition: all 0.15s ease;
}
.compaction-btn:hover:not(:disabled) {
border-color: var(--accent-primary);
background: rgba(233, 69, 96, 0.1);
}
.compaction-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.compaction-btn-primary {
background: var(--accent-primary);
border-color: var(--accent-primary);
color: white;
}
.compaction-btn-primary:hover:not(:disabled) {
background: var(--accent-secondary);
border-color: var(--accent-secondary);
}
/* Historical costs styles */
.costs-header {
margin: 0 !important;
padding: 0 !important;
border: none !important;
}
.costs-toggle {
display: flex;
align-items: center;
gap: 0.5rem;
background: none;
border: none;
color: var(--text-primary);
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
padding: 0;
}
.costs-toggle:hover {
color: var(--accent-primary);
}
.costs-quick-stats {
display: flex;
gap: 0.5rem;
margin-top: 0.5rem;
flex-wrap: wrap;
}
.cost-badge {
font-size: 0.7rem;
padding: 0.2rem 0.4rem;
background: rgba(16, 185, 129, 0.1);
border: 1px solid rgba(16, 185, 129, 0.3);
border-radius: 3px;
color: #10b981;
font-family: var(--font-mono, monospace);
}
.historical-costs-expanded {
margin-top: 0.5rem;
}
.cost-value {
color: #10b981;
}
.costs-note {
margin: 0.5rem 0 0 0;
font-size: 0.65rem;
color: var(--text-secondary);
font-style: italic;
opacity: 0.8;
}
</style> </style>
+151 -2
View File
@@ -13,6 +13,7 @@
import { get } from "svelte/store"; import { get } from "svelte/store";
import { claudeStore } from "$lib/stores/claude"; import { claudeStore } from "$lib/stores/claude";
import { configStore, type HikariConfig, isStreamerMode } 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 type { ConnectionStatus } from "$lib/types/messages";
import { onMount } from "svelte"; import { onMount } from "svelte";
import StatsDisplay from "./StatsDisplay.svelte"; import StatsDisplay from "./StatsDisplay.svelte";
@@ -23,6 +24,12 @@
import SessionHistoryPanel from "./SessionHistoryPanel.svelte"; import SessionHistoryPanel from "./SessionHistoryPanel.svelte";
import GitPanel from "./GitPanel.svelte"; import GitPanel from "./GitPanel.svelte";
import ProfilePanel from "./ProfilePanel.svelte"; import ProfilePanel from "./ProfilePanel.svelte";
import { conversationsStore } from "$lib/stores/conversations";
import {
generateContextInjection,
createSummary,
sanitizeForJson,
} from "$lib/utils/conversationUtils";
const DISCORD_URL = "https://chat.nhcarrigan.com"; const DISCORD_URL = "https://chat.nhcarrigan.com";
const DONATE_URL = "https://donate.nhcarrigan.com"; const DONATE_URL = "https://donate.nhcarrigan.com";
@@ -40,6 +47,7 @@
let showSessionHistory = $state(false); let showSessionHistory = $state(false);
let showGitPanel = $state(false); let showGitPanel = $state(false);
let showProfile = $state(false); let showProfile = $state(false);
let isSummarising = $state(false);
const progress = $derived($achievementProgress); const progress = $derived($achievementProgress);
let currentConfig: HikariConfig = $state({ let currentConfig: HikariConfig = $state({
model: null, model: null,
@@ -73,6 +81,11 @@
text_secondary: null, text_secondary: null,
border_color: null, border_color: null,
}, },
budget_enabled: false,
session_token_budget: null,
session_cost_budget: null,
budget_action: "warn",
budget_warning_threshold: 0.8,
}); });
let streamerModeActive = $state(false); let streamerModeActive = $state(false);
@@ -80,6 +93,15 @@
streamerModeActive = value; streamerModeActive = value;
}); });
let editorVisible = $state(false);
editorStore.isEditorVisible.subscribe((value) => {
editorVisible = value;
});
function toggleEditor() {
editorStore.toggleEditor();
}
onMount(async () => { onMount(async () => {
appVersion = await getVersion(); appVersion = await getVersion();
}); });
@@ -190,6 +212,106 @@
function toggleAchievements() { function toggleAchievements() {
onToggleAchievements(); onToggleAchievements();
} }
async function handleCompactConversation() {
const activeId = get(conversationsStore.activeConversationId);
if (!activeId) return;
isSummarising = true;
try {
const conversationContent = conversationsStore.getConversationForSummary(activeId);
const messageCount =
get(conversationsStore.activeConversation)?.terminalLines.filter(
(l) => l.type === "user" || l.type === "assistant"
).length || 0;
const tokenEstimate = conversationsStore.estimateTokenCount(activeId);
// Create a summary from the conversation content (truncate if too long)
// Apply sanitization early to handle any problematic escape sequences
const sanitizedContent = sanitizeForJson(conversationContent);
const summaryContent =
sanitizedContent.length > 4000
? `${sanitizedContent.slice(0, 4000)}\n\n[Truncated for length - original had ${messageCount} messages]`
: sanitizedContent;
// Step 1: Disconnect from Claude to reset context
if (connectionStatus === "connected") {
await invoke("stop_claude", { conversationId: activeId });
}
// Step 2: Clear messages and store summary
conversationsStore.compactWithSummary(activeId, summaryContent, messageCount, tokenEstimate);
// Step 3: Reconnect to Claude with fresh context
const allAllowedTools = [
...(currentConfig.auto_granted_tools || []),
...Array.from(get(claudeStore.grantedTools)),
];
await invoke("start_claude", {
conversationId: activeId,
options: {
working_dir: workingDirectory || selectedDirectory,
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,
},
});
// Step 4: Send the context summary to Claude as the first message
const contextPrompt = generateContextInjection(
createSummary(summaryContent, messageCount, tokenEstimate)
);
await invoke("send_prompt", {
conversationId: activeId,
message: contextPrompt,
});
claudeStore.addLine(
"system",
"Conversation compacted. Context from previous session has been provided to Claude."
);
} catch (error) {
console.error("Failed to compact conversation:", error);
claudeStore.addLine("error", `Failed to compact conversation: ${error}`);
} finally {
isSummarising = false;
}
}
async function handleStartFreshWithContext() {
const activeId = get(conversationsStore.activeConversationId);
if (!activeId) return;
const conversationContent = conversationsStore.getConversationForSummary(activeId);
const messageCount =
get(conversationsStore.activeConversation)?.terminalLines.filter(
(l) => l.type === "user" || l.type === "assistant"
).length || 0;
const tokenEstimate = conversationsStore.estimateTokenCount(activeId);
const summary = createSummary(
`This is a continuation of a previous conversation. Here's what was discussed:\n\n${conversationContent.slice(0, 4000)}${conversationContent.length > 4000 ? "\n\n[Truncated for length...]" : ""}`,
messageCount,
tokenEstimate
);
const newConvId = conversationsStore.createConversation("Fresh Start");
conversationsStore.setSummary(newConvId, summary);
// Context injection is generated but the actual injection happens via the summary
generateContextInjection(summary);
claudeStore.addLine("system", "Started fresh conversation with context from previous session.");
claudeStore.addLine(
"system",
`Previous session had ${messageCount} messages (~${tokenEstimate.toLocaleString()} tokens).`
);
}
</script> </script>
<div <div
@@ -307,6 +429,25 @@
/> />
</svg> </svg>
</button> </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 <button
onclick={() => (showStats = !showStats)} onclick={() => (showStats = !showStats)}
class="p-1 text-gray-500 icon-trans-hover {showStats ? 'text-[var(--trans-pink)]' : ''}" class="p-1 text-gray-500 icon-trans-hover {showStats ? 'text-[var(--trans-pink)]' : ''}"
@@ -417,7 +558,11 @@
{#if showStats} {#if showStats}
<div class="absolute top-full right-0 mt-2 mr-4 z-50"> <div class="absolute top-full right-0 mt-2 mr-4 z-50">
<StatsDisplay /> <StatsDisplay
onRequestSummary={handleCompactConversation}
onStartFreshWithContext={handleStartFreshWithContext}
{isSummarising}
/>
</div> </div>
{/if} {/if}
{#if connectionStatus === "connected"} {#if connectionStatus === "connected"}
@@ -444,7 +589,11 @@
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="fixed inset-0 z-40" onclick={() => (showStats = false)}></div> <div class="fixed inset-0 z-40" onclick={() => (showStats = false)}></div>
<div class="fixed top-14 right-4 z-50"> <div class="fixed top-14 right-4 z-50">
<StatsDisplay /> <StatsDisplay
onRequestSummary={handleCompactConversation}
onStartFreshWithContext={handleStartFreshWithContext}
{isSummarising}
/>
</div> </div>
{/if} {/if}
+111 -6
View File
@@ -155,6 +155,30 @@
terminalElement.removeEventListener("copy", handleCopy); 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> </script>
<div <div
@@ -185,19 +209,51 @@
</div> </div>
{:else} {:else}
{#each lines as line (line.id)} {#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> <span class="terminal-timestamp text-xs mr-2">{formatTime(line.timestamp)}</span>
{#if line.cost && line.cost.costUsd > 0}
<span
class="terminal-cost text-xs mr-2"
title="Input: {line.cost.inputTokens} | Output: {line.cost.outputTokens}"
>
${line.cost.costUsd < 0.01
? line.cost.costUsd.toFixed(4)
: line.cost.costUsd.toFixed(3)}
</span>
{/if}
{#if getLinePrefix(line.type)} {#if getLinePrefix(line.type)}
<span class="terminal-prefix mr-2">{getLinePrefix(line.type)}</span> <span class="terminal-prefix mr-2">{getLinePrefix(line.type)}</span>
{/if} {/if}
{#if line.toolName} {#if line.toolName}
<span class="terminal-tool-name mr-2">[{line.toolName}]</span> <span class="terminal-tool-name mr-2">[{line.toolName}]</span>
{/if} {/if}
{#if line.type === "assistant"} {#if line.type === "assistant" || line.type === "user"}
<Markdown <div class="message-content-wrapper">
content={maskPaths(line.content, hidePaths)} <Markdown
searchQuery={currentSearchQuery} 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} {:else}
<HighlightedText <HighlightedText
content={maskPaths(line.content, hidePaths)} content={maskPaths(line.content, hidePaths)}
@@ -245,6 +301,14 @@
color: var(--text-tertiary, #6b7280); color: var(--text-tertiary, #6b7280);
} }
.terminal-cost {
color: var(--terminal-cost, #10b981);
background: var(--terminal-cost-bg, rgba(16, 185, 129, 0.1));
padding: 0 4px;
border-radius: 3px;
font-family: monospace;
}
.terminal-prefix { .terminal-prefix {
color: var(--text-secondary); color: var(--text-secondary);
} }
@@ -267,4 +331,45 @@
border-radius: 2px; border-radius: 2px;
padding: 0 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> </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>
+482
View File
@@ -0,0 +1,482 @@
<script lang="ts">
import { onMount, onDestroy } from "svelte";
import { EditorView, basicSetup } from "codemirror";
import { EditorState, Compartment } from "@codemirror/state";
import { keymap } from "@codemirror/view";
import { oneDark } from "@codemirror/theme-one-dark";
import { javascript } from "@codemirror/lang-javascript";
import { python } from "@codemirror/lang-python";
import { rust } from "@codemirror/lang-rust";
import { html } from "@codemirror/lang-html";
import { css } from "@codemirror/lang-css";
import { json } from "@codemirror/lang-json";
import { markdown } from "@codemirror/lang-markdown";
import { xml } from "@codemirror/lang-xml";
import { sql } from "@codemirror/lang-sql";
import { java } from "@codemirror/lang-java";
import { cpp } from "@codemirror/lang-cpp";
import { php } from "@codemirror/lang-php";
import { go } from "@codemirror/lang-go";
import { yaml } from "@codemirror/lang-yaml";
import { sass } from "@codemirror/lang-sass";
import { less } from "@codemirror/lang-less";
import { vue } from "@codemirror/lang-vue";
import { angular } from "@codemirror/lang-angular";
import { wast } from "@codemirror/lang-wast";
import { StreamLanguage } from "@codemirror/language";
import { shell } from "@codemirror/legacy-modes/mode/shell";
import { ruby } from "@codemirror/legacy-modes/mode/ruby";
import { swift } from "@codemirror/legacy-modes/mode/swift";
import { lua } from "@codemirror/legacy-modes/mode/lua";
import { r } from "@codemirror/legacy-modes/mode/r";
import { toml } from "@codemirror/legacy-modes/mode/toml";
import { dockerFile } from "@codemirror/legacy-modes/mode/dockerfile";
import { powerShell } from "@codemirror/legacy-modes/mode/powershell";
import { HighlightStyle, syntaxHighlighting } from "@codemirror/language";
import { tags } from "@lezer/highlight";
import { editorStore } from "$lib/stores/editor";
import { configStore } from "$lib/stores/config";
import EditorContextMenu from "./EditorContextMenu.svelte";
import type { EditorTab } from "$lib/types/editor";
import type { Extension } from "@codemirror/state";
export let tab: EditorTab;
let editorContainer: HTMLDivElement;
let view: EditorView | null = null;
let themeCompartment = new Compartment();
// Context menu state
let contextMenuShow = false;
let contextMenuX = 0;
let contextMenuY = 0;
function handleContextMenu(event: MouseEvent) {
event.preventDefault();
contextMenuShow = true;
contextMenuX = event.clientX;
contextMenuY = event.clientY;
}
function closeContextMenu() {
contextMenuShow = false;
}
// Subscribe to theme changes
const config = configStore.config;
// Light theme
const lightTheme = EditorView.theme(
{
"&": {
backgroundColor: "#ffffff",
color: "#24292e",
},
".cm-content": {
caretColor: "#24292e",
},
".cm-cursor, .cm-dropCursor": {
borderLeftColor: "#24292e",
},
"&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection": {
backgroundColor: "#c8d3f5",
},
".cm-panels": {
backgroundColor: "#f6f8fa",
color: "#24292e",
},
".cm-panels.cm-panels-top": {
borderBottom: "1px solid #e1e4e8",
},
".cm-panels.cm-panels-bottom": {
borderTop: "1px solid #e1e4e8",
},
".cm-searchMatch": {
backgroundColor: "#ffdf5d",
outline: "1px solid #c4a000",
},
".cm-searchMatch.cm-searchMatch-selected": {
backgroundColor: "#c4a000",
},
".cm-activeLine": {
backgroundColor: "#f6f8fa",
},
".cm-selectionMatch": {
backgroundColor: "#c8d3f5",
},
".cm-matchingBracket, .cm-nonmatchingBracket": {
backgroundColor: "#c8d3f5",
outline: "1px solid #888",
},
".cm-gutters": {
backgroundColor: "#f6f8fa",
color: "#6a737d",
border: "none",
borderRight: "1px solid #e1e4e8",
},
".cm-activeLineGutter": {
backgroundColor: "#e1e4e8",
},
".cm-foldPlaceholder": {
backgroundColor: "transparent",
border: "none",
color: "#6a737d",
},
".cm-tooltip": {
border: "1px solid #e1e4e8",
backgroundColor: "#ffffff",
},
".cm-tooltip .cm-tooltip-arrow:before": {
borderTopColor: "transparent",
borderBottomColor: "transparent",
},
".cm-tooltip .cm-tooltip-arrow:after": {
borderTopColor: "#ffffff",
borderBottomColor: "#ffffff",
},
".cm-tooltip-autocomplete": {
"& > ul > li[aria-selected]": {
backgroundColor: "#e1e4e8",
color: "#24292e",
},
},
},
{ dark: false }
);
const lightHighlightStyle = HighlightStyle.define([
{ tag: tags.keyword, color: "#d73a49" },
{
tag: [tags.name, tags.deleted, tags.character, tags.propertyName, tags.macroName],
color: "#6f42c1",
},
{ tag: [tags.function(tags.variableName), tags.labelName], color: "#6f42c1" },
{ tag: [tags.color, tags.constant(tags.name), tags.standard(tags.name)], color: "#005cc5" },
{ tag: [tags.definition(tags.name), tags.separator], color: "#24292e" },
{
tag: [
tags.typeName,
tags.className,
tags.number,
tags.changed,
tags.annotation,
tags.modifier,
tags.self,
tags.namespace,
],
color: "#e36209",
},
{
tag: [
tags.operator,
tags.operatorKeyword,
tags.url,
tags.escape,
tags.regexp,
tags.link,
tags.special(tags.string),
],
color: "#032f62",
},
{ tag: [tags.meta, tags.comment], color: "#6a737d" },
{ tag: tags.strong, fontWeight: "bold" },
{ tag: tags.emphasis, fontStyle: "italic" },
{ tag: tags.strikethrough, textDecoration: "line-through" },
{ tag: tags.link, color: "#032f62", textDecoration: "underline" },
{ tag: tags.heading, fontWeight: "bold", color: "#005cc5" },
{ tag: [tags.atom, tags.bool, tags.special(tags.variableName)], color: "#005cc5" },
{ tag: [tags.processingInstruction, tags.string, tags.inserted], color: "#22863a" },
{ tag: tags.invalid, color: "#cb2431" },
]);
// High contrast theme
const highContrastTheme = EditorView.theme(
{
"&": {
backgroundColor: "#000000",
color: "#ffffff",
},
".cm-content": {
caretColor: "#ffffff",
},
".cm-cursor, .cm-dropCursor": {
borderLeftColor: "#ffffff",
borderLeftWidth: "2px",
},
"&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection": {
backgroundColor: "#264f78",
},
".cm-panels": {
backgroundColor: "#000000",
color: "#ffffff",
},
".cm-panels.cm-panels-top": {
borderBottom: "2px solid #ffffff",
},
".cm-panels.cm-panels-bottom": {
borderTop: "2px solid #ffffff",
},
".cm-searchMatch": {
backgroundColor: "#515c6a",
outline: "2px solid #ffff00",
},
".cm-searchMatch.cm-searchMatch-selected": {
backgroundColor: "#ffff00",
color: "#000000",
},
".cm-activeLine": {
backgroundColor: "#1a1a1a",
},
".cm-selectionMatch": {
backgroundColor: "#264f78",
},
".cm-matchingBracket, .cm-nonmatchingBracket": {
backgroundColor: "#515c6a",
outline: "2px solid #ffff00",
},
".cm-gutters": {
backgroundColor: "#000000",
color: "#858585",
border: "none",
borderRight: "2px solid #ffffff",
},
".cm-activeLineGutter": {
backgroundColor: "#1a1a1a",
color: "#ffffff",
},
".cm-foldPlaceholder": {
backgroundColor: "transparent",
border: "none",
color: "#ffff00",
},
".cm-tooltip": {
border: "2px solid #ffffff",
backgroundColor: "#000000",
},
".cm-tooltip .cm-tooltip-arrow:before": {
borderTopColor: "transparent",
borderBottomColor: "transparent",
},
".cm-tooltip .cm-tooltip-arrow:after": {
borderTopColor: "#000000",
borderBottomColor: "#000000",
},
".cm-tooltip-autocomplete": {
"& > ul > li[aria-selected]": {
backgroundColor: "#264f78",
color: "#ffffff",
},
},
},
{ dark: true }
);
const highContrastHighlightStyle = HighlightStyle.define([
{ tag: tags.keyword, color: "#569cd6", fontWeight: "bold" },
{
tag: [tags.name, tags.deleted, tags.character, tags.propertyName, tags.macroName],
color: "#9cdcfe",
},
{ tag: [tags.function(tags.variableName), tags.labelName], color: "#dcdcaa" },
{ tag: [tags.color, tags.constant(tags.name), tags.standard(tags.name)], color: "#4fc1ff" },
{ tag: [tags.definition(tags.name), tags.separator], color: "#ffffff" },
{
tag: [
tags.typeName,
tags.className,
tags.number,
tags.changed,
tags.annotation,
tags.modifier,
tags.self,
tags.namespace,
],
color: "#4ec9b0",
},
{
tag: [
tags.operator,
tags.operatorKeyword,
tags.url,
tags.escape,
tags.regexp,
tags.link,
tags.special(tags.string),
],
color: "#d4d4d4",
},
{ tag: [tags.meta, tags.comment], color: "#6a9955" },
{ tag: tags.strong, fontWeight: "bold" },
{ tag: tags.emphasis, fontStyle: "italic" },
{ tag: tags.strikethrough, textDecoration: "line-through" },
{ tag: tags.link, color: "#3794ff", textDecoration: "underline" },
{ tag: tags.heading, fontWeight: "bold", color: "#569cd6" },
{ tag: [tags.atom, tags.bool, tags.special(tags.variableName)], color: "#569cd6" },
{ tag: [tags.processingInstruction, tags.string, tags.inserted], color: "#ce9178" },
{ tag: tags.invalid, color: "#f44747" },
]);
function getThemeExtension(theme: string): Extension {
switch (theme) {
case "light":
return [lightTheme, syntaxHighlighting(lightHighlightStyle)];
case "high-contrast":
return [highContrastTheme, syntaxHighlighting(highContrastHighlightStyle)];
case "dark":
case "custom":
default:
return oneDark;
}
}
function getLanguageExtension(language: string): Extension {
const languageMap: Record<string, () => Extension> = {
javascript: () => javascript({ jsx: true, typescript: false }),
typescript: () => javascript({ jsx: true, typescript: true }),
python: () => python(),
rust: () => rust(),
html: () => html(),
css: () => css(),
json: () => json(),
markdown: () => markdown(),
xml: () => xml(),
sql: () => sql(),
java: () => java(),
c: () => cpp(),
cpp: () => cpp(),
csharp: () => cpp(),
php: () => php(),
go: () => go(),
yaml: () => yaml(),
scss: () => sass(),
sass: () => sass(),
less: () => less(),
vue: () => vue(),
angular: () => angular(),
wasm: () => wast(),
shell: () => StreamLanguage.define(shell),
ruby: () => StreamLanguage.define(ruby),
swift: () => StreamLanguage.define(swift),
lua: () => StreamLanguage.define(lua),
r: () => StreamLanguage.define(r),
toml: () => StreamLanguage.define(toml),
dockerfile: () => StreamLanguage.define(dockerFile),
powershell: () => StreamLanguage.define(powerShell),
svelte: () => html(),
};
const getExtension = languageMap[language];
return getExtension ? getExtension() : [];
}
function createEditor() {
if (!editorContainer) return;
const currentTheme = $config.theme;
const saveKeymap = keymap.of([
{
key: "Mod-s",
run: () => {
editorStore.saveFile(tab.id);
return true;
},
},
]);
const updateListener = EditorView.updateListener.of((update) => {
if (update.docChanged) {
const content = update.state.doc.toString();
editorStore.updateTabContent(tab.id, content);
}
});
const state = EditorState.create({
doc: tab.content,
extensions: [
basicSetup,
themeCompartment.of(getThemeExtension(currentTheme)),
getLanguageExtension(tab.language),
saveKeymap,
updateListener,
EditorView.theme({
"&": {
height: "100%",
fontSize: "14px",
},
".cm-scroller": {
overflow: "auto",
},
".cm-content": {
fontFamily: "'JetBrains Mono', 'Fira Code', 'Consolas', monospace",
},
}),
],
});
view = new EditorView({
state,
parent: editorContainer,
});
}
function destroyEditor() {
if (view) {
view.destroy();
view = null;
}
}
// Watch for theme changes and update the editor
$: if (view && $config.theme) {
view.dispatch({
effects: themeCompartment.reconfigure(getThemeExtension($config.theme)),
});
}
onMount(() => {
createEditor();
});
onDestroy(() => {
destroyEditor();
});
$: if (view && tab.content !== view.state.doc.toString()) {
view.dispatch({
changes: {
from: 0,
to: view.state.doc.length,
insert: tab.content,
},
});
}
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="code-editor" bind:this={editorContainer} oncontextmenu={handleContextMenu}></div>
{#if contextMenuShow && view}
<EditorContextMenu
x={contextMenuX}
y={contextMenuY}
editorView={view}
onClose={closeContextMenu}
/>
{/if}
<style>
.code-editor {
flex: 1;
overflow: hidden;
background-color: var(--bg-terminal);
}
.code-editor :global(.cm-editor) {
height: 100%;
}
.code-editor :global(.cm-focused) {
outline: none;
}
</style>
@@ -0,0 +1,131 @@
<script lang="ts">
interface Props {
title: string;
message: string;
confirmText?: string;
cancelText?: string;
destructive?: boolean;
onConfirm: () => void;
onCancel: () => void;
}
let {
title,
message,
confirmText = "Confirm",
cancelText = "Cancel",
destructive = false,
onConfirm,
onCancel,
}: Props = $props();
function handleKeydown(event: KeyboardEvent) {
if (event.key === "Escape") {
onCancel();
} else if (event.key === "Enter") {
onConfirm();
}
}
</script>
<svelte:window onkeydown={handleKeydown} />
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="dialog-overlay" onclick={onCancel}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="dialog-content" onclick={(e) => e.stopPropagation()}>
<h2 class="dialog-title">{title}</h2>
<p class="dialog-message">{message}</p>
<div class="dialog-actions">
<button class="btn-cancel" onclick={onCancel}>
{cancelText}
</button>
<button class="btn-confirm" class:destructive onclick={onConfirm}>
{confirmText}
</button>
</div>
</div>
</div>
<style>
.dialog-overlay {
position: fixed;
inset: 0;
z-index: 50;
display: flex;
align-items: center;
justify-content: center;
background-color: rgba(0, 0, 0, 0.5);
}
.dialog-content {
margin: 0 1rem;
width: 100%;
max-width: 28rem;
border-radius: 0.5rem;
border: 1px solid var(--border-color);
background-color: var(--bg-secondary);
padding: 1.5rem;
box-shadow:
0 20px 25px -5px rgba(0, 0, 0, 0.1),
0 10px 10px -5px rgba(0, 0, 0, 0.04);
}
.dialog-title {
margin-bottom: 0.5rem;
font-size: 1.125rem;
font-weight: 600;
color: var(--text-primary);
}
.dialog-message {
margin-bottom: 1.5rem;
font-size: 0.875rem;
color: var(--text-secondary);
}
.dialog-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
}
.btn-cancel {
border-radius: 0.375rem;
border: 1px solid var(--border-color);
background-color: var(--bg-primary);
padding: 0.5rem 1rem;
font-size: 0.875rem;
color: var(--text-primary);
cursor: pointer;
transition: background-color 0.15s ease;
}
.btn-cancel:hover {
background-color: var(--bg-secondary);
}
.btn-confirm {
border-radius: 0.375rem;
border: none;
background-color: var(--accent-primary);
padding: 0.5rem 1rem;
font-size: 0.875rem;
color: white;
cursor: pointer;
transition: background-color 0.15s ease;
}
.btn-confirm:hover {
background-color: var(--accent-secondary);
}
.btn-confirm.destructive {
background-color: #dc2626;
}
.btn-confirm.destructive:hover {
background-color: #b91c1c;
}
</style>
@@ -0,0 +1,238 @@
<script lang="ts">
import type { EditorView } from "@codemirror/view";
interface Props {
x: number;
y: number;
editorView: EditorView;
onClose: () => void;
}
let { x, y, editorView, 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") {
editorView.focus();
switch (command) {
case "cut":
document.execCommand("cut");
break;
case "copy":
document.execCommand("copy");
break;
case "paste":
document.execCommand("paste");
break;
case "selectAll":
editorView.dispatch({
selection: { anchor: 0, head: editorView.state.doc.length },
});
break;
case "undo":
import("@codemirror/commands").then(({ undo }) => {
undo(editorView);
});
break;
case "redo":
import("@codemirror/commands").then(({ redo }) => {
redo(editorView);
});
break;
}
onClose();
}
// Check if there's a selection
let hasSelection = $derived(
editorView.state.selection.main.from !== editorView.state.selection.main.to
);
</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>
@@ -0,0 +1,253 @@
<script lang="ts">
import { editorStore } from "$lib/stores/editor";
import FileBrowser from "./FileBrowser.svelte";
import EditorTabs from "./EditorTabs.svelte";
import CodeEditor from "./CodeEditor.svelte";
const isFileBrowserOpen = editorStore.isFileBrowserOpen;
const activeTab = editorStore.activeTab;
const saveError = editorStore.saveError;
function toggleFileBrowser() {
editorStore.toggleFileBrowser();
}
async function handleSave() {
try {
await editorStore.saveFile();
} catch {
// Error is already set in the store
}
}
</script>
<div class="editor-panel">
<div class="toolbar">
<button
class="toolbar-button"
class:active={$isFileBrowserOpen}
on:click={toggleFileBrowser}
title="Toggle file browser (Ctrl+B)"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
</svg>
</button>
<div class="toolbar-spacer"></div>
{#if $activeTab}
<button class="toolbar-button" on:click={handleSave} title="Save (Ctrl+S)">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z" />
<polyline points="17 21 17 13 7 13 7 21" />
<polyline points="7 3 7 8 15 8" />
</svg>
</button>
{/if}
</div>
{#if $saveError}
<div class="error-banner">
<span>{$saveError}</span>
<button
class="dismiss-button"
on:click={() => {}}
title="Dismiss error"
aria-label="Dismiss error"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</div>
{/if}
<div class="editor-content">
{#if $isFileBrowserOpen}
<div class="file-browser-container">
<FileBrowser />
</div>
{/if}
<div class="editor-main">
<EditorTabs />
<div class="editor-area">
{#if $activeTab}
{#key $activeTab.id}
<CodeEditor tab={$activeTab} />
{/key}
{:else}
<div class="no-file">
<div class="no-file-content">
<svg
width="48"
height="48"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
</svg>
<p>Select a file to edit</p>
<p class="hint">Use the file browser on the left or press Ctrl+B to toggle it</p>
</div>
</div>
{/if}
</div>
</div>
</div>
</div>
<style>
.editor-panel {
display: flex;
flex-direction: column;
height: 100%;
background-color: var(--bg-primary);
}
.toolbar {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
background-color: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
}
.toolbar-button {
display: flex;
align-items: center;
justify-content: center;
padding: 6px;
background: transparent;
border: none;
color: var(--text-secondary);
cursor: pointer;
border-radius: 4px;
transition: all 0.15s ease;
}
.toolbar-button:hover {
background-color: var(--bg-primary);
color: var(--text-primary);
}
.toolbar-button.active {
background-color: var(--bg-primary);
color: var(--accent-primary);
}
.toolbar-spacer {
flex: 1;
}
.error-banner {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background-color: #ff000022;
border-bottom: 1px solid #ff0000;
color: #ff6b6b;
font-size: 13px;
}
.dismiss-button {
display: flex;
align-items: center;
justify-content: center;
padding: 4px;
background: transparent;
border: none;
color: #ff6b6b;
cursor: pointer;
border-radius: 4px;
}
.dismiss-button:hover {
background-color: #ff000033;
}
.editor-content {
display: flex;
flex: 1;
overflow: hidden;
}
.file-browser-container {
width: 250px;
min-width: 150px;
max-width: 400px;
flex-shrink: 0;
overflow: hidden;
}
.editor-main {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.editor-area {
flex: 1;
overflow: hidden;
display: flex;
}
.no-file {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
background-color: var(--bg-terminal);
}
.no-file-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
color: var(--text-secondary);
text-align: center;
}
.no-file-content svg {
opacity: 0.5;
}
.no-file-content p {
margin: 0;
}
.no-file-content .hint {
font-size: 12px;
opacity: 0.7;
}
</style>
+170
View File
@@ -0,0 +1,170 @@
<script lang="ts">
import { editorStore } from "$lib/stores/editor";
const tabs = editorStore.tabs;
const activeTabId = editorStore.activeTabId;
function handleTabClick(tabId: string) {
editorStore.setActiveTab(tabId);
}
function handleCloseTab(event: MouseEvent, tabId: string) {
event.stopPropagation();
editorStore.closeTab(tabId);
}
function handleKeydown(event: KeyboardEvent, tabId: string) {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
editorStore.setActiveTab(tabId);
}
}
function handleCloseKeydown(event: KeyboardEvent, tabId: string) {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
event.stopPropagation();
editorStore.closeTab(tabId);
}
}
</script>
<div class="editor-tabs">
{#if $tabs.length === 0}
<div class="no-tabs">No files open</div>
{:else}
<div class="tabs-container" role="tablist">
{#each $tabs as tab (tab.id)}
<div
class="tab"
class:active={tab.id === $activeTabId}
class:dirty={tab.isDirty}
role="tab"
tabindex="0"
aria-selected={tab.id === $activeTabId}
on:click={() => handleTabClick(tab.id)}
on:keydown={(e) => handleKeydown(e, tab.id)}
title={tab.filePath}
>
<span class="tab-name">
{tab.fileName}
{#if tab.isDirty}
<span class="dirty-indicator">*</span>
{/if}
</span>
<button
class="close-button"
on:click={(e) => handleCloseTab(e, tab.id)}
on:keydown={(e) => handleCloseKeydown(e, tab.id)}
title="Close tab"
aria-label="Close {tab.fileName}"
>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</div>
{/each}
</div>
{/if}
</div>
<style>
.editor-tabs {
display: flex;
align-items: center;
background-color: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
min-height: 36px;
}
.no-tabs {
padding: 8px 12px;
color: var(--text-secondary);
font-size: 13px;
}
.tabs-container {
display: flex;
overflow-x: auto;
scrollbar-width: thin;
}
.tab {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: transparent;
border: none;
border-right: 1px solid var(--border-color);
color: var(--text-secondary);
font-size: 13px;
cursor: pointer;
white-space: nowrap;
transition: all 0.15s ease;
}
.tab:hover {
background-color: var(--bg-primary);
color: var(--text-primary);
}
.tab:focus {
outline: 1px solid var(--accent-primary);
outline-offset: -1px;
}
.tab.active {
background-color: var(--bg-terminal);
color: var(--text-primary);
border-bottom: 2px solid var(--accent-primary);
margin-bottom: -1px;
}
.tab-name {
display: flex;
align-items: center;
gap: 2px;
}
.dirty-indicator {
color: var(--accent-primary);
font-weight: bold;
}
.close-button {
display: flex;
align-items: center;
justify-content: center;
padding: 2px;
background: transparent;
border: none;
color: var(--text-secondary);
cursor: pointer;
border-radius: 4px;
opacity: 0;
transition: all 0.15s ease;
}
.tab:hover .close-button,
.tab.active .close-button {
opacity: 1;
}
.close-button:hover {
background-color: var(--bg-secondary);
color: var(--text-primary);
}
.tab.dirty .close-button {
opacity: 1;
}
</style>
@@ -0,0 +1,358 @@
<script lang="ts">
import { onMount, onDestroy } from "svelte";
import { editorStore } from "$lib/stores/editor";
import FileTreeItem from "./FileTreeItem.svelte";
import FileContextMenu from "./FileContextMenu.svelte";
import InputDialog from "./InputDialog.svelte";
import ConfirmDialog from "./ConfirmDialog.svelte";
import type { FileEntry } from "$lib/types/editor";
const fileTree = editorStore.fileTree;
const isLoadingTree = editorStore.isLoadingTree;
const currentDirectory = editorStore.currentDirectory;
// Listen for Ctrl+N keyboard shortcut from +page.svelte
function handleNewFileEvent() {
handleNewFile();
}
onMount(() => {
window.addEventListener("editor-new-file", handleNewFileEvent);
});
onDestroy(() => {
window.removeEventListener("editor-new-file", handleNewFileEvent);
});
// Context menu state
let contextMenu = $state<{
show: boolean;
x: number;
y: number;
targetEntry: FileEntry | null;
}>({
show: false,
x: 0,
y: 0,
targetEntry: null,
});
// Dialog state
let dialog = $state<{
type: "newFile" | "newFolder" | "delete" | "rename" | null;
parentPath: string;
targetEntry: FileEntry | null;
}>({
type: null,
parentPath: "",
targetEntry: null,
});
function handleRefresh() {
const dir = $currentDirectory;
if (dir) {
editorStore.initializeFileTree(dir);
}
}
function handleContextMenu(event: MouseEvent, entry: FileEntry | null = null) {
event.preventDefault();
contextMenu = {
show: true,
x: event.clientX,
y: event.clientY,
targetEntry: entry,
};
}
function closeContextMenu() {
contextMenu = { show: false, x: 0, y: 0, targetEntry: null };
}
function openNewFileDialog(parentPath: string) {
dialog = { type: "newFile", parentPath, targetEntry: null };
}
function openNewFolderDialog(parentPath: string) {
dialog = { type: "newFolder", parentPath, targetEntry: null };
}
function openDeleteDialog(entry: FileEntry) {
dialog = { type: "delete", parentPath: "", targetEntry: entry };
}
function openRenameDialog(entry: FileEntry) {
dialog = { type: "rename", parentPath: "", targetEntry: entry };
}
function closeDialog() {
dialog = { type: null, parentPath: "", targetEntry: null };
}
async function handleCreateFile(fileName: string) {
await editorStore.createFile(dialog.parentPath, fileName);
closeDialog();
}
async function handleCreateFolder(folderName: string) {
await editorStore.createDirectory(dialog.parentPath, folderName);
closeDialog();
}
async function handleDelete() {
if (!dialog.targetEntry) return;
if (dialog.targetEntry.isDirectory) {
await editorStore.deleteDirectory(dialog.targetEntry.path);
} else {
await editorStore.deleteFile(dialog.targetEntry.path);
}
closeDialog();
}
async function handleRename(newName: string) {
if (!dialog.targetEntry) return;
await editorStore.renamePath(dialog.targetEntry.path, newName);
closeDialog();
}
function handleNewFile() {
openNewFileDialog($currentDirectory);
}
function handleNewFolder() {
openNewFolderDialog($currentDirectory);
}
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="file-browser" oncontextmenu={(e) => handleContextMenu(e, null)}>
<div class="header">
<span class="title">Files</span>
<div class="header-buttons">
<button class="header-button" onclick={handleNewFile} title="New File (Ctrl+N)">
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<path d="M14 2v6h6" />
<path d="M12 18v-6" />
<path d="M9 15h6" />
</svg>
</button>
<button class="header-button" onclick={handleNewFolder} title="New Folder">
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
<path d="M12 11v6" />
<path d="M9 14h6" />
</svg>
</button>
<button class="header-button" onclick={handleRefresh} title="Refresh file tree">
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M23 4v6h-6" />
<path d="M1 20v-6h6" />
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15" />
</svg>
</button>
</div>
</div>
<div class="tree-container">
{#if $isLoadingTree}
<div class="loading">
<svg
class="spinner"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="12" cy="12" r="10" stroke-dasharray="32" stroke-dashoffset="32">
<animate
attributeName="stroke-dashoffset"
dur="1s"
values="32;0"
repeatCount="indefinite"
/>
</circle>
</svg>
<span>Loading...</span>
</div>
{:else if $fileTree.length === 0}
<div class="empty">
<span>No files found</span>
</div>
{:else}
<div class="tree">
{#each $fileTree as entry (entry.path)}
<FileTreeItem {entry} onContextMenu={handleContextMenu} />
{/each}
</div>
{/if}
</div>
</div>
{#if contextMenu.show}
<FileContextMenu
x={contextMenu.x}
y={contextMenu.y}
targetEntry={contextMenu.targetEntry}
currentDirectory={$currentDirectory}
onNewFile={openNewFileDialog}
onNewFolder={openNewFolderDialog}
onRename={openRenameDialog}
onDelete={openDeleteDialog}
onClose={closeContextMenu}
/>
{/if}
{#if dialog.type === "newFile"}
<InputDialog
title="New File"
placeholder="Enter file name..."
confirmText="Create"
onConfirm={handleCreateFile}
onCancel={closeDialog}
/>
{/if}
{#if dialog.type === "newFolder"}
<InputDialog
title="New Folder"
placeholder="Enter folder name..."
confirmText="Create"
onConfirm={handleCreateFolder}
onCancel={closeDialog}
/>
{/if}
{#if dialog.type === "delete" && dialog.targetEntry}
<ConfirmDialog
title="Delete {dialog.targetEntry.isDirectory ? 'Folder' : 'File'}"
message="Are you sure you want to delete '{dialog.targetEntry.name}'? {dialog.targetEntry
.isDirectory
? 'This will also delete all files and folders inside it.'
: 'This action cannot be undone.'}"
confirmText="Delete"
destructive={true}
onConfirm={handleDelete}
onCancel={closeDialog}
/>
{/if}
{#if dialog.type === "rename" && dialog.targetEntry}
<InputDialog
title="Rename {dialog.targetEntry.isDirectory ? 'Folder' : 'File'}"
placeholder="Enter new name..."
confirmText="Rename"
initialValue={dialog.targetEntry.name}
onConfirm={handleRename}
onCancel={closeDialog}
/>
{/if}
<style>
.file-browser {
display: flex;
flex-direction: column;
height: 100%;
background-color: var(--bg-primary);
border-right: 1px solid var(--border-color);
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
border-bottom: 1px solid var(--border-color);
background-color: var(--bg-secondary);
}
.title {
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
color: var(--text-secondary);
letter-spacing: 0.5px;
}
.header-buttons {
display: flex;
gap: 4px;
}
.header-button {
display: flex;
align-items: center;
justify-content: center;
padding: 4px;
background: transparent;
border: none;
color: var(--text-secondary);
cursor: pointer;
border-radius: 4px;
transition: all 0.15s ease;
}
.header-button:hover {
background-color: var(--bg-primary);
color: var(--text-primary);
}
.tree-container {
flex: 1;
overflow-y: auto;
padding: 4px 0;
}
.tree {
min-width: max-content;
}
.loading,
.empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: 24px;
color: var(--text-secondary);
font-size: 13px;
}
.spinner {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
</style>
@@ -0,0 +1,203 @@
<script lang="ts">
import type { FileEntry } from "$lib/types/editor";
interface Props {
x: number;
y: number;
targetEntry: FileEntry | null;
currentDirectory: string;
onNewFile: (parentPath: string) => void;
onNewFolder: (parentPath: string) => void;
onRename: (entry: FileEntry) => void;
onDelete: (entry: FileEntry) => void;
onClose: () => void;
}
let {
x,
y,
targetEntry,
currentDirectory,
onNewFile,
onNewFolder,
onRename,
onDelete,
onClose,
}: Props = $props();
function handleNewFile() {
const parentPath = targetEntry?.isDirectory ? targetEntry.path : currentDirectory;
onNewFile(parentPath);
onClose();
}
function handleNewFolder() {
const parentPath = targetEntry?.isDirectory ? targetEntry.path : currentDirectory;
onNewFolder(parentPath);
onClose();
}
function handleRename() {
if (targetEntry) {
onRename(targetEntry);
onClose();
}
}
function handleDelete() {
if (targetEntry) {
onDelete(targetEntry);
onClose();
}
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === "Escape") {
onClose();
}
}
// 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 || 160;
const maxX = window.innerWidth - menuWidth - 8;
return Math.min(x, maxX);
});
let adjustedY = $derived.by(() => {
if (!menuElement) return y;
const menuHeight = menuElement.offsetHeight || 200;
const maxY = window.innerHeight - menuHeight - 8;
return Math.min(y, maxY);
});
</script>
<svelte:window onkeydown={handleKeydown} />
<!-- svelte-ignore a11y_no_static_element_interactions -->
<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={handleNewFile}>
<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 13h6m-3-3v6m5 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>
New File
</button>
<button class="menu-item" onclick={handleNewFolder}>
<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 13h6m-3-3v6m-9 1V7a2 2 0 012-2h6l2 2h6a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2z"
/>
</svg>
New Folder
</button>
{#if targetEntry}
<div class="menu-divider"></div>
<button class="menu-item" onclick={handleRename}>
<svg class="menu-icon" 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>
Rename
</button>
<button class="menu-item destructive" onclick={handleDelete}>
<svg class="menu-icon" 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>
Delete {targetEntry.isDirectory ? "Folder" : "File"}
</button>
{/if}
</div>
</div>
<style>
.menu-overlay {
position: fixed;
inset: 0;
z-index: 50;
}
.menu-content {
position: absolute;
z-index: 50;
min-width: 160px;
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 {
background-color: var(--bg-primary);
}
.menu-item.destructive {
color: #ef4444;
}
.menu-divider {
margin: 0.25rem 0;
border-top: 1px solid var(--border-color);
}
.menu-icon {
width: 1rem;
height: 1rem;
}
</style>
@@ -0,0 +1,208 @@
<script lang="ts">
import type { FileEntry } from "$lib/types/editor";
import { editorStore } from "$lib/stores/editor";
import Self from "./FileTreeItem.svelte";
interface Props {
entry: FileEntry;
depth?: number;
onContextMenu?: (event: MouseEvent, entry: FileEntry) => void;
}
let { entry, depth = 0, onContextMenu }: Props = $props();
function handleClick() {
if (entry.isDirectory) {
editorStore.toggleDirectory(entry);
} else {
editorStore.openFile(entry.path);
}
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
handleClick();
}
}
function handleContextMenu(event: MouseEvent) {
event.preventDefault();
event.stopPropagation();
onContextMenu?.(event, entry);
}
const isExpanded = $derived(entry.isExpanded ?? false);
const isLoading = $derived(entry.isLoading ?? false);
const children = $derived(entry.children ?? []);
</script>
<div class="file-tree-item">
<button
class="item-row"
class:directory={entry.isDirectory}
class:file={!entry.isDirectory}
style="padding-left: {depth * 16 + 8}px"
onclick={handleClick}
onkeydown={handleKeydown}
oncontextmenu={handleContextMenu}
title={entry.path}
>
{#if entry.isDirectory}
<span class="icon">
{#if isLoading}
<svg
class="spinner"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="12" cy="12" r="10" stroke-dasharray="32" stroke-dashoffset="32">
<animate
attributeName="stroke-dashoffset"
dur="1s"
values="32;0"
repeatCount="indefinite"
/>
</circle>
</svg>
{:else if isExpanded}
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M6 9l6 6 6-6" />
</svg>
{:else}
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M9 6l6 6-6 6" />
</svg>
{/if}
</span>
<span class="folder-icon">
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
<path
d="M10 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z"
/>
</svg>
</span>
{:else}
<span class="icon spacer"></span>
<span class="file-icon">
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
</svg>
</span>
{/if}
<span class="name">{entry.name}</span>
</button>
{#if entry.isDirectory && isExpanded && children.length > 0}
<div class="children">
{#each children as child (child.path)}
<Self entry={child} depth={depth + 1} {onContextMenu} />
{/each}
</div>
{/if}
</div>
<style>
.file-tree-item {
user-select: none;
}
.item-row {
display: flex;
align-items: center;
gap: 4px;
width: 100%;
padding: 4px 8px;
background: transparent;
border: none;
color: var(--text-primary);
font-size: 13px;
cursor: pointer;
text-align: left;
border-radius: 4px;
transition: background-color 0.15s ease;
}
.item-row:hover {
background-color: var(--bg-secondary);
}
.item-row:focus {
outline: 1px solid var(--accent-primary);
outline-offset: -1px;
}
.icon {
flex-shrink: 0;
width: 14px;
height: 14px;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-secondary);
}
.icon.spacer {
visibility: hidden;
}
.folder-icon {
flex-shrink: 0;
color: var(--accent-primary);
}
.file-icon {
flex-shrink: 0;
color: var(--text-secondary);
}
.name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.children {
margin-left: 0;
}
.spinner {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
</style>
@@ -0,0 +1,173 @@
<script lang="ts">
interface Props {
title: string;
placeholder?: string;
confirmText?: string;
cancelText?: string;
initialValue?: string;
onConfirm: (value: string) => void;
onCancel: () => void;
}
let {
title,
placeholder = "",
confirmText = "Create",
cancelText = "Cancel",
initialValue = "",
onConfirm,
onCancel,
}: Props = $props();
let inputValue = $state(initialValue);
let inputElement: HTMLInputElement | undefined = $state();
$effect(() => {
if (inputElement) {
inputElement.focus();
// Select all text for rename operations
if (initialValue) {
inputElement.select();
}
}
});
function handleSubmit() {
const trimmed = inputValue.trim();
if (trimmed) {
onConfirm(trimmed);
}
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === "Escape") {
onCancel();
} else if (event.key === "Enter") {
handleSubmit();
}
}
</script>
<svelte:window onkeydown={handleKeydown} />
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="dialog-overlay" onclick={onCancel}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="dialog-content" onclick={(e) => e.stopPropagation()}>
<h2 class="dialog-title">{title}</h2>
<input
bind:this={inputElement}
bind:value={inputValue}
type="text"
{placeholder}
class="dialog-input"
/>
<div class="dialog-actions">
<button class="btn-cancel" onclick={onCancel}>
{cancelText}
</button>
<button class="btn-confirm" onclick={handleSubmit} disabled={!inputValue.trim()}>
{confirmText}
</button>
</div>
</div>
</div>
<style>
.dialog-overlay {
position: fixed;
inset: 0;
z-index: 50;
display: flex;
align-items: center;
justify-content: center;
background-color: rgba(0, 0, 0, 0.5);
}
.dialog-content {
margin: 0 1rem;
width: 100%;
max-width: 28rem;
border-radius: 0.5rem;
border: 1px solid var(--border-color);
background-color: var(--bg-secondary);
padding: 1.5rem;
box-shadow:
0 20px 25px -5px rgba(0, 0, 0, 0.1),
0 10px 10px -5px rgba(0, 0, 0, 0.04);
}
.dialog-title {
margin-bottom: 1rem;
font-size: 1.125rem;
font-weight: 600;
color: var(--text-primary);
}
.dialog-input {
width: 100%;
margin-bottom: 1.5rem;
padding: 0.5rem 0.75rem;
border-radius: 0.375rem;
border: 1px solid var(--border-color);
background-color: var(--bg-primary);
font-size: 0.875rem;
color: var(--text-primary);
outline: none;
transition:
border-color 0.15s ease,
box-shadow 0.15s ease;
}
.dialog-input::placeholder {
color: var(--text-secondary);
}
.dialog-input:focus {
border-color: var(--accent-primary);
box-shadow: 0 0 0 1px var(--accent-primary);
}
.dialog-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
}
.btn-cancel {
border-radius: 0.375rem;
border: 1px solid var(--border-color);
background-color: var(--bg-primary);
padding: 0.5rem 1rem;
font-size: 0.875rem;
color: var(--text-primary);
cursor: pointer;
transition: background-color 0.15s ease;
}
.btn-cancel:hover {
background-color: var(--bg-secondary);
}
.btn-confirm {
border-radius: 0.375rem;
border: none;
background-color: var(--accent-primary);
padding: 0.5rem 1rem;
font-size: 0.875rem;
color: white;
cursor: pointer;
transition: background-color 0.15s ease;
}
.btn-confirm:hover {
background-color: var(--accent-secondary);
}
.btn-confirm:disabled {
cursor: not-allowed;
opacity: 0.5;
}
</style>
@@ -90,6 +90,8 @@ class NotificationManager {
return "Successfully connected to Claude Code"; return "Successfully connected to Claude Code";
case NotificationType.TASK_START: case NotificationType.TASK_START:
return "Starting task..."; return "Starting task...";
case NotificationType.COST_ALERT:
return "You've exceeded your cost threshold!";
default: default:
return "Notification"; return "Notification";
} }
@@ -115,6 +117,10 @@ class NotificationManager {
async notifyTaskStart(message?: string): Promise<void> { async notifyTaskStart(message?: string): Promise<void> {
await this.notify(NotificationType.TASK_START, message); await this.notify(NotificationType.TASK_START, message);
} }
async notifyCostAlert(message?: string): Promise<void> {
await this.notify(NotificationType.COST_ALERT, message);
}
} }
// Export singleton instance // Export singleton instance
+9 -4
View File
@@ -51,9 +51,13 @@ describe("notifications", () => {
expect(NotificationType.ACHIEVEMENT).toBe("achievement"); expect(NotificationType.ACHIEVEMENT).toBe("achievement");
}); });
it("has exactly 6 notification types", () => { it("has exactly 7 notification types", () => {
const types = Object.values(NotificationType); const types = Object.values(NotificationType);
expect(types.length).toBe(6); expect(types.length).toBe(7);
});
it("has COST_ALERT type", () => {
expect(NotificationType.COST_ALERT).toBe("cost_alert");
}); });
}); });
@@ -314,10 +318,11 @@ describe("notifications", () => {
}); });
}); });
it("sound filenames are unique", () => { it("sound filenames are mostly unique", () => {
const filenames = Object.values(NOTIFICATION_SOUNDS).map((s) => s.filename); const filenames = Object.values(NOTIFICATION_SOUNDS).map((s) => s.filename);
const uniqueFilenames = new Set(filenames); const uniqueFilenames = new Set(filenames);
expect(uniqueFilenames.size).toBe(filenames.length); // Allow some sound reuse (e.g., COST_ALERT reuses ERROR sound)
expect(uniqueFilenames.size).toBeGreaterThanOrEqual(filenames.length - 1);
}); });
it("phrases are unique", () => { it("phrases are unique", () => {
+7
View File
@@ -5,6 +5,7 @@ export enum NotificationType {
CONNECTION = "connection", CONNECTION = "connection",
TASK_START = "task_start", TASK_START = "task_start",
ACHIEVEMENT = "achievement", ACHIEVEMENT = "achievement",
COST_ALERT = "cost_alert",
} }
export interface NotificationSound { export interface NotificationSound {
@@ -52,4 +53,10 @@ export const NOTIFICATION_SOUNDS: Record<NotificationType, NotificationSound> =
phrase: "Achievement Get~!", phrase: "Achievement Get~!",
volume: 0.8, volume: 0.8,
}, },
[NotificationType.COST_ALERT]: {
type: NotificationType.COST_ALERT,
filename: "oh-no.mp3",
phrase: "Cost Alert!",
volume: 0.9,
},
}; };
+10
View File
@@ -187,6 +187,11 @@ describe("config store", () => {
text_secondary: null, text_secondary: null,
border_color: null, border_color: null,
}, },
budget_enabled: false,
session_token_budget: null,
session_cost_budget: null,
budget_action: "warn",
budget_warning_threshold: 0.8,
}; };
expect(config.model).toBe("claude-sonnet-4"); expect(config.model).toBe("claude-sonnet-4");
@@ -227,6 +232,11 @@ describe("config store", () => {
text_secondary: null, text_secondary: null,
border_color: null, border_color: null,
}, },
budget_enabled: false,
session_token_budget: null,
session_cost_budget: null,
budget_action: "warn",
budget_warning_threshold: 0.8,
}; };
expect(config.model).toBeNull(); expect(config.model).toBeNull();
+12
View File
@@ -2,6 +2,7 @@ import { writable, derived } from "svelte/store";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
export type Theme = "dark" | "light" | "high-contrast" | "custom"; export type Theme = "dark" | "light" | "high-contrast" | "custom";
export type BudgetAction = "warn" | "block";
export interface CustomThemeColors { export interface CustomThemeColors {
bg_primary: string | null; bg_primary: string | null;
@@ -37,6 +38,12 @@ export interface HikariConfig {
profile_avatar_path: string | null; profile_avatar_path: string | null;
profile_bio: string | null; profile_bio: string | null;
custom_theme_colors: CustomThemeColors; custom_theme_colors: CustomThemeColors;
// Budget settings
budget_enabled: boolean;
session_token_budget: number | null;
session_cost_budget: number | null;
budget_action: BudgetAction;
budget_warning_threshold: number;
} }
const defaultConfig: HikariConfig = { const defaultConfig: HikariConfig = {
@@ -71,6 +78,11 @@ const defaultConfig: HikariConfig = {
text_secondary: null, text_secondary: null,
border_color: null, border_color: null,
}, },
budget_enabled: false,
session_token_budget: null,
session_cost_budget: null,
budget_action: "warn",
budget_warning_threshold: 0.8,
}; };
function createConfigStore() { function createConfigStore() {
+143 -2
View File
@@ -11,6 +11,13 @@ import { cleanupConversationTracking } from "$lib/tauri";
import { characterState } from "$lib/stores/character"; import { characterState } from "$lib/stores/character";
import { sessionsStore } from "$lib/stores/sessions"; import { sessionsStore } from "$lib/stores/sessions";
export interface ConversationSummary {
generatedAt: Date;
content: string;
messageCount: number;
tokenEstimate: number;
}
export interface Conversation { export interface Conversation {
id: string; id: string;
name: string; name: string;
@@ -27,6 +34,7 @@ export interface Conversation {
createdAt: Date; createdAt: Date;
lastActivityAt: Date; lastActivityAt: Date;
attachments: Attachment[]; attachments: Attachment[];
summary: ConversationSummary | null;
} }
function createConversationsStore() { function createConversationsStore() {
@@ -63,6 +71,7 @@ function createConversationsStore() {
createdAt: new Date(), createdAt: new Date(),
lastActivityAt: new Date(), lastActivityAt: new Date(),
attachments: [], attachments: [],
summary: null,
}; };
} }
@@ -420,7 +429,12 @@ function createConversationsStore() {
}); });
}, },
addLine: (type: TerminalLine["type"], content: string, toolName?: string) => { addLine: (
type: TerminalLine["type"],
content: string,
toolName?: string,
cost?: TerminalLine["cost"]
) => {
ensureInitialized(); ensureInitialized();
const activeId = get(activeConversationId); const activeId = get(activeConversationId);
if (!activeId) return ""; if (!activeId) return "";
@@ -431,6 +445,7 @@ function createConversationsStore() {
content, content,
timestamp: new Date(), timestamp: new Date(),
toolName, toolName,
cost,
}; };
conversations.update((convs) => { conversations.update((convs) => {
@@ -451,7 +466,8 @@ function createConversationsStore() {
conversationId: string, conversationId: string,
type: TerminalLine["type"], type: TerminalLine["type"],
content: string, content: string,
toolName?: string toolName?: string,
cost?: TerminalLine["cost"]
) => { ) => {
ensureInitialized(); ensureInitialized();
@@ -461,6 +477,7 @@ function createConversationsStore() {
content, content,
timestamp: new Date(), timestamp: new Date(),
toolName, toolName,
cost,
}; };
conversations.update((convs) => { conversations.update((convs) => {
@@ -636,6 +653,130 @@ function createConversationsStore() {
return conv?.attachments || []; return conv?.attachments || [];
}, },
// Summary/compaction functions
setSummary: (conversationId: string, summary: ConversationSummary) => {
conversations.update((convs) => {
const conv = convs.get(conversationId);
if (conv) {
conv.summary = summary;
conv.lastActivityAt = new Date();
}
return convs;
});
},
clearSummary: (conversationId: string) => {
conversations.update((convs) => {
const conv = convs.get(conversationId);
if (conv) {
conv.summary = null;
conv.lastActivityAt = new Date();
}
return convs;
});
},
getSummary: (conversationId: string): ConversationSummary | null => {
const convs = get(conversations);
const conv = convs.get(conversationId);
return conv?.summary || null;
},
// Estimate token count for a conversation (rough approximation: ~4 chars per token)
estimateTokenCount: (conversationId: string): number => {
const convs = get(conversations);
const conv = convs.get(conversationId);
if (!conv) return 0;
const relevantLines = conv.terminalLines.filter(
(line) => line.type === "user" || line.type === "assistant"
);
const totalChars = relevantLines.reduce((sum, line) => sum + line.content.length, 0);
return Math.ceil(totalChars / 4);
},
// Get conversation content suitable for summarisation
getConversationForSummary: (conversationId: string): string => {
const convs = get(conversations);
const conv = convs.get(conversationId);
if (!conv) return "";
const relevantLines = conv.terminalLines.filter(
(line) => line.type === "user" || line.type === "assistant"
);
return relevantLines
.map((line) => {
const role = line.type === "user" ? "User" : "Assistant";
return `${role}: ${line.content}`;
})
.join("\n\n");
},
// Compact conversation by keeping only recent messages
compactConversation: (conversationId: string, keepRecentCount: number = 10) => {
conversations.update((convs) => {
const conv = convs.get(conversationId);
if (conv && conv.terminalLines.length > keepRecentCount) {
// Keep system messages and the most recent user/assistant messages
const systemLines = conv.terminalLines.filter(
(line) => line.type !== "user" && line.type !== "assistant"
);
const chatLines = conv.terminalLines.filter(
(line) => line.type === "user" || line.type === "assistant"
);
// Keep only the most recent chat messages
const recentChatLines = chatLines.slice(-keepRecentCount);
// Combine: system lines at original positions + recent chat lines
conv.terminalLines = [...systemLines.slice(-5), ...recentChatLines];
conv.lastActivityAt = new Date();
}
return convs;
});
},
// Compact conversation with a summary - clears old messages and injects summary context
compactWithSummary: (
conversationId: string,
summaryContent: string,
messageCount: number,
tokenEstimate: number
) => {
conversations.update((convs) => {
const conv = convs.get(conversationId);
if (conv) {
// Store the summary
conv.summary = {
generatedAt: new Date(),
content: summaryContent,
messageCount,
tokenEstimate,
};
// Clear all messages and add a context injection message
conv.terminalLines = [
{
id: generateLineId(),
type: "system",
content: `[Conversation compacted] Previous session had ${messageCount} messages (~${tokenEstimate.toLocaleString()} tokens). Context preserved below.`,
timestamp: new Date(),
},
{
id: generateLineId(),
type: "system",
content: `Previous Session Context:\n${summaryContent}`,
timestamp: new Date(),
},
];
conv.lastActivityAt = new Date();
}
return convs;
});
},
// Add initialization helper // Add initialization helper
initialize: () => { initialize: () => {
ensureInitialized(); ensureInitialized();
+182
View File
@@ -0,0 +1,182 @@
import { writable, derived } from "svelte/store";
import { invoke } from "@tauri-apps/api/core";
import { notificationManager } from "$lib/notifications/notificationManager";
// Types matching Rust backend
export interface DailyCost {
date: string;
input_tokens: number;
output_tokens: number;
cost_usd: number;
messages_sent: number;
sessions_count: number;
}
export interface CostSummary {
period_days: number;
total_input_tokens: number;
total_output_tokens: number;
total_cost: number;
total_messages: number;
total_sessions: number;
average_daily_cost: number;
daily_breakdown: DailyCost[];
}
export type AlertType = "Daily" | "Weekly" | "Monthly";
export interface CostAlert {
alert_type: AlertType;
threshold: number;
current_cost: number;
}
export interface CostAlertThresholds {
daily: number | null;
weekly: number | null;
monthly: number | null;
}
// Store state
interface CostTrackingState {
todayCost: number;
weekCost: number;
monthCost: number;
summary: CostSummary | null;
alerts: CostAlert[];
thresholds: CostAlertThresholds;
isLoading: boolean;
lastUpdated: Date | null;
}
const defaultState: CostTrackingState = {
todayCost: 0,
weekCost: 0,
monthCost: 0,
summary: null,
alerts: [],
thresholds: { daily: null, weekly: null, monthly: null },
isLoading: false,
lastUpdated: null,
};
function createCostTrackingStore() {
const { subscribe, set, update } = writable<CostTrackingState>(defaultState);
return {
subscribe,
async refresh() {
update((s) => ({ ...s, isLoading: true }));
try {
const [todayCost, weekCost, monthCost, alerts] = await Promise.all([
invoke<number>("get_today_cost"),
invoke<number>("get_week_cost"),
invoke<number>("get_month_cost"),
invoke<CostAlert[]>("get_cost_alerts"),
]);
update((s) => ({
...s,
todayCost,
weekCost,
monthCost,
alerts,
isLoading: false,
lastUpdated: new Date(),
}));
// Trigger notifications for any new alerts
if (alerts.length > 0) {
for (const alert of alerts) {
const message = getAlertMessage(alert);
notificationManager.notifyCostAlert(message);
}
}
return alerts;
} catch (error) {
console.error("Failed to refresh cost tracking:", error);
update((s) => ({ ...s, isLoading: false }));
return [];
}
},
async getSummary(days: number): Promise<CostSummary | null> {
try {
const summary = await invoke<CostSummary>("get_cost_summary", { days });
update((s) => ({ ...s, summary }));
return summary;
} catch (error) {
console.error("Failed to get cost summary:", error);
return null;
}
},
async setAlertThresholds(thresholds: CostAlertThresholds) {
try {
await invoke("set_cost_alert_thresholds", {
daily: thresholds.daily,
weekly: thresholds.weekly,
monthly: thresholds.monthly,
});
update((s) => ({ ...s, thresholds }));
} catch (error) {
console.error("Failed to set alert thresholds:", error);
}
},
async exportCsv(days: number): Promise<string | null> {
try {
return await invoke<string>("export_cost_csv", { days });
} catch (error) {
console.error("Failed to export CSV:", error);
return null;
}
},
reset() {
set(defaultState);
},
};
}
export const costTrackingStore = createCostTrackingStore();
// Derived stores for formatted values
export const formattedCosts = derived(costTrackingStore, ($store) => ({
today: formatCost($store.todayCost),
week: formatCost($store.weekCost),
month: formatCost($store.monthCost),
todayRaw: $store.todayCost,
weekRaw: $store.weekCost,
monthRaw: $store.monthCost,
}));
// Helper functions
export function formatCost(cost: number): string {
if (cost < 0.01) {
return `$${cost.toFixed(4)}`;
}
if (cost < 1) {
return `$${cost.toFixed(3)}`;
}
return `$${cost.toFixed(2)}`;
}
export function formatAlertType(type: AlertType): string {
switch (type) {
case "Daily":
return "Today";
case "Weekly":
return "This Week";
case "Monthly":
return "This Month";
}
}
export function getAlertMessage(alert: CostAlert): string {
const period = formatAlertType(alert.alert_type);
return `${period}'s spending (${formatCost(alert.current_cost)}) has exceeded your ${formatCost(alert.threshold)} threshold`;
}
+426
View File
@@ -0,0 +1,426 @@
import { writable, derived, get } from "svelte/store";
import { invoke } from "@tauri-apps/api/core";
import type { EditorState, EditorTab, FileEntry } from "$lib/types/editor";
const defaultState: EditorState = {
tabs: [],
activeTabId: null,
isFileBrowserOpen: true,
currentDirectory: "",
fileTree: [],
isLoadingTree: false,
};
function getLanguageFromPath(filePath: string): string {
const ext = filePath.split(".").pop()?.toLowerCase() || "";
const languageMap: Record<string, string> = {
js: "javascript",
jsx: "javascript",
ts: "typescript",
tsx: "typescript",
py: "python",
rs: "rust",
go: "go",
java: "java",
c: "c",
cpp: "cpp",
h: "c",
hpp: "cpp",
cs: "csharp",
rb: "ruby",
php: "php",
swift: "swift",
kt: "kotlin",
scala: "scala",
html: "html",
htm: "html",
css: "css",
scss: "scss",
sass: "sass",
less: "less",
json: "json",
xml: "xml",
yaml: "yaml",
yml: "yaml",
toml: "toml",
md: "markdown",
markdown: "markdown",
sql: "sql",
sh: "shell",
bash: "shell",
zsh: "shell",
ps1: "powershell",
dockerfile: "dockerfile",
svelte: "svelte",
vue: "vue",
graphql: "graphql",
gql: "graphql",
lua: "lua",
r: "r",
dart: "dart",
elm: "elm",
ex: "elixir",
exs: "elixir",
erl: "erlang",
hs: "haskell",
clj: "clojure",
lisp: "lisp",
ml: "ocaml",
fs: "fsharp",
zig: "zig",
nim: "nim",
v: "v",
wasm: "wasm",
wat: "wasm",
};
return languageMap[ext] || "plaintext";
}
function generateTabId(): string {
return `tab-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
}
function createEditorStore() {
const state = writable<EditorState>(defaultState);
const isEditorVisible = writable<boolean>(false);
const saveError = writable<string | null>(null);
async function loadDirectory(dirPath: string): Promise<FileEntry[]> {
try {
const entries = await invoke<FileEntry[]>("list_directory", { path: dirPath });
return entries.sort((a, b) => {
if (a.isDirectory && !b.isDirectory) return -1;
if (!a.isDirectory && b.isDirectory) return 1;
return a.name.localeCompare(b.name);
});
} catch (error) {
console.error("Failed to load directory:", error);
return [];
}
}
async function initializeFileTree(rootPath: string) {
state.update((s) => ({ ...s, isLoadingTree: true, currentDirectory: rootPath }));
try {
const entries = await loadDirectory(rootPath);
state.update((s) => ({ ...s, fileTree: entries, isLoadingTree: false }));
} catch (error) {
console.error("Failed to initialize file tree:", error);
state.update((s) => ({ ...s, isLoadingTree: false }));
}
}
async function toggleDirectory(entry: FileEntry) {
if (!entry.isDirectory) return;
state.update((s) => {
const updateTree = (entries: FileEntry[]): FileEntry[] => {
return entries.map((e) => {
if (e.path === entry.path) {
return { ...e, isExpanded: !e.isExpanded, isLoading: !e.isExpanded && !e.children };
}
if (e.children) {
return { ...e, children: updateTree(e.children) };
}
return e;
});
};
return { ...s, fileTree: updateTree(s.fileTree) };
});
if (!entry.isExpanded && !entry.children) {
const children = await loadDirectory(entry.path);
state.update((s) => {
const updateTree = (entries: FileEntry[]): FileEntry[] => {
return entries.map((e) => {
if (e.path === entry.path) {
return { ...e, children, isLoading: false };
}
if (e.children) {
return { ...e, children: updateTree(e.children) };
}
return e;
});
};
return { ...s, fileTree: updateTree(s.fileTree) };
});
}
}
async function openFile(filePath: string) {
const currentState = get(state);
const existingTab = currentState.tabs.find((t) => t.filePath === filePath);
if (existingTab) {
state.update((s) => ({ ...s, activeTabId: existingTab.id }));
return;
}
try {
const content = await invoke<string>("read_file_content", { path: filePath });
const fileName = filePath.split(/[/\\]/).pop() || "untitled";
const language = getLanguageFromPath(filePath);
const newTab: EditorTab = {
id: generateTabId(),
filePath,
fileName,
content,
originalContent: content,
isDirty: false,
language,
};
state.update((s) => ({
...s,
tabs: [...s.tabs, newTab],
activeTabId: newTab.id,
}));
} catch (error) {
console.error("Failed to open file:", error);
saveError.set(`Failed to open file: ${error}`);
}
}
async function saveFile(tabId?: string) {
const currentState = get(state);
const tab = tabId
? currentState.tabs.find((t) => t.id === tabId)
: currentState.tabs.find((t) => t.id === currentState.activeTabId);
if (!tab) return;
saveError.set(null);
try {
await invoke("write_file_content", { path: tab.filePath, content: tab.content });
state.update((s) => ({
...s,
tabs: s.tabs.map((t) =>
t.id === tab.id ? { ...t, originalContent: t.content, isDirty: false } : t
),
}));
} catch (error) {
console.error("Failed to save file:", error);
saveError.set(`Failed to save file: ${error}`);
throw error;
}
}
function updateTabContent(tabId: string, content: string) {
state.update((s) => ({
...s,
tabs: s.tabs.map((t) =>
t.id === tabId ? { ...t, content, isDirty: content !== t.originalContent } : t
),
}));
}
function closeTab(tabId: string) {
state.update((s) => {
const tabIndex = s.tabs.findIndex((t) => t.id === tabId);
const newTabs = s.tabs.filter((t) => t.id !== tabId);
let newActiveId = s.activeTabId;
if (s.activeTabId === tabId) {
if (newTabs.length === 0) {
newActiveId = null;
} else if (tabIndex >= newTabs.length) {
newActiveId = newTabs[newTabs.length - 1].id;
} else {
newActiveId = newTabs[tabIndex].id;
}
}
return { ...s, tabs: newTabs, activeTabId: newActiveId };
});
}
function setActiveTab(tabId: string) {
state.update((s) => ({ ...s, activeTabId: tabId }));
}
function toggleFileBrowser() {
state.update((s) => ({ ...s, isFileBrowserOpen: !s.isFileBrowserOpen }));
}
function toggleEditor() {
isEditorVisible.update((v) => !v);
}
async function createFile(parentPath: string, fileName: string): Promise<boolean> {
const filePath = `${parentPath}/${fileName}`;
try {
await invoke("create_file", { path: filePath });
// Refresh the parent directory
await refreshDirectory(parentPath);
return true;
} catch (error) {
console.error("Failed to create file:", error);
saveError.set(`Failed to create file: ${error}`);
return false;
}
}
async function createDirectory(parentPath: string, dirName: string): Promise<boolean> {
const dirPath = `${parentPath}/${dirName}`;
try {
await invoke("create_directory", { path: dirPath });
// Refresh the parent directory
await refreshDirectory(parentPath);
return true;
} catch (error) {
console.error("Failed to create directory:", error);
saveError.set(`Failed to create directory: ${error}`);
return false;
}
}
async function deleteFile(filePath: string): Promise<boolean> {
try {
await invoke("delete_file", { path: filePath });
// Close the tab if it's open
const currentState = get(state);
const openTab = currentState.tabs.find((t) => t.filePath === filePath);
if (openTab) {
closeTab(openTab.id);
}
// Refresh the parent directory
const parentPath = filePath.substring(0, filePath.lastIndexOf("/"));
await refreshDirectory(parentPath);
return true;
} catch (error) {
console.error("Failed to delete file:", error);
saveError.set(`Failed to delete file: ${error}`);
return false;
}
}
async function deleteDirectory(dirPath: string): Promise<boolean> {
try {
await invoke("delete_directory", { path: dirPath });
// Close any tabs that are in this directory
const currentState = get(state);
const tabsToClose = currentState.tabs.filter((t) => t.filePath.startsWith(dirPath + "/"));
tabsToClose.forEach((tab) => closeTab(tab.id));
// Refresh the parent directory
const parentPath = dirPath.substring(0, dirPath.lastIndexOf("/"));
await refreshDirectory(parentPath);
return true;
} catch (error) {
console.error("Failed to delete directory:", error);
saveError.set(`Failed to delete directory: ${error}`);
return false;
}
}
async function refreshDirectory(dirPath: string) {
const currentState = get(state);
// If refreshing the root directory, reload the entire tree
if (dirPath === currentState.currentDirectory) {
const entries = await loadDirectory(dirPath);
state.update((s) => ({ ...s, fileTree: entries }));
return;
}
// Otherwise, update the specific directory in the tree
const children = await loadDirectory(dirPath);
state.update((s) => {
const updateTree = (entries: FileEntry[]): FileEntry[] => {
return entries.map((e) => {
if (e.path === dirPath) {
return { ...e, children, isExpanded: true };
}
if (e.children) {
return { ...e, children: updateTree(e.children) };
}
return e;
});
};
return { ...s, fileTree: updateTree(s.fileTree) };
});
}
async function renamePath(oldPath: string, newName: string): Promise<boolean> {
const parentPath = oldPath.substring(0, oldPath.lastIndexOf("/"));
const newPath = `${parentPath}/${newName}`;
try {
await invoke("rename_path", { oldPath, newPath });
// Update any open tabs that reference this path
state.update((s) => ({
...s,
tabs: s.tabs.map((t) => {
if (t.filePath === oldPath) {
// Exact match - this file was renamed
return {
...t,
filePath: newPath,
fileName: newName,
};
}
if (t.filePath.startsWith(oldPath + "/")) {
// File is inside a renamed directory
const relativePath = t.filePath.substring(oldPath.length);
return {
...t,
filePath: newPath + relativePath,
};
}
return t;
}),
}));
// Refresh the parent directory
await refreshDirectory(parentPath);
return true;
} catch (error) {
console.error("Failed to rename:", error);
saveError.set(`Failed to rename: ${error}`);
return false;
}
}
function showEditor() {
isEditorVisible.set(true);
}
function hideEditor() {
isEditorVisible.set(false);
}
return {
state: { subscribe: state.subscribe },
isEditorVisible: { subscribe: isEditorVisible.subscribe },
saveError: { subscribe: saveError.subscribe },
initializeFileTree,
toggleDirectory,
openFile,
saveFile,
updateTabContent,
closeTab,
setActiveTab,
toggleFileBrowser,
toggleEditor,
showEditor,
hideEditor,
createFile,
createDirectory,
deleteFile,
deleteDirectory,
refreshDirectory,
renamePath,
tabs: derived(state, ($state) => $state.tabs),
activeTab: derived(state, ($state) => $state.tabs.find((t) => t.id === $state.activeTabId)),
activeTabId: derived(state, ($state) => $state.activeTabId),
fileTree: derived(state, ($state) => $state.fileTree),
isFileBrowserOpen: derived(state, ($state) => $state.isFileBrowserOpen),
isLoadingTree: derived(state, ($state) => $state.isLoadingTree),
currentDirectory: derived(state, ($state) => $state.currentDirectory),
hasDirtyTabs: derived(state, ($state) => $state.tabs.some((t) => t.isDirty)),
};
}
export const editorStore = createEditorStore();
+269 -12
View File
@@ -1,7 +1,25 @@
import { describe, it, expect, beforeEach, vi } from "vitest"; import { describe, it, expect, beforeEach, vi } from "vitest";
import { get } from "svelte/store"; import { get } from "svelte/store";
import { stats, formattedStats, resetSessionStats } from "./stats"; import {
import type { UsageStats } from "./stats"; stats,
formattedStats,
resetSessionStats,
contextWarning,
getContextWarningMessage,
estimateMessageCost,
formatTokenCount,
MODEL_PRICING,
} from "./stats";
import type { UsageStats, ToolTokenStats } from "./stats";
// Helper function to create ToolTokenStats for tests
function toolStats(callCount: number, inputTokens = 0, outputTokens = 0): ToolTokenStats {
return {
call_count: callCount,
estimated_input_tokens: inputTokens,
estimated_output_tokens: outputTokens,
};
}
// Mock Tauri APIs // Mock Tauri APIs
vi.mock("@tauri-apps/api/event", () => ({ vi.mock("@tauri-apps/api/event", () => ({
@@ -34,6 +52,11 @@ describe("stats store", () => {
tools_usage: {}, tools_usage: {},
session_tools_usage: {}, session_tools_usage: {},
session_duration_seconds: 0, session_duration_seconds: 0,
context_tokens_used: 0,
context_window_limit: 200000,
context_utilisation_percent: 0,
potential_cache_hits: 0,
potential_cache_savings_tokens: 0,
}); });
}); });
@@ -63,9 +86,14 @@ describe("stats store", () => {
session_files_edited: 2, session_files_edited: 2,
files_created: 1, files_created: 1,
session_files_created: 1, session_files_created: 1,
tools_usage: { Read: 5, Edit: 3 }, tools_usage: { Read: toolStats(5), Edit: toolStats(3) },
session_tools_usage: { Read: 2, Edit: 1 }, session_tools_usage: { Read: toolStats(2), Edit: toolStats(1) },
session_duration_seconds: 300, session_duration_seconds: 300,
context_tokens_used: 500,
context_window_limit: 200000,
context_utilisation_percent: 0.25,
potential_cache_hits: 0,
potential_cache_savings_tokens: 0,
}; };
stats.set(newStats); stats.set(newStats);
@@ -74,7 +102,8 @@ describe("stats store", () => {
expect(currentStats.total_input_tokens).toBe(1000); expect(currentStats.total_input_tokens).toBe(1000);
expect(currentStats.total_output_tokens).toBe(2000); expect(currentStats.total_output_tokens).toBe(2000);
expect(currentStats.model).toBe("claude-sonnet-4"); expect(currentStats.model).toBe("claude-sonnet-4");
expect(currentStats.tools_usage).toEqual({ Read: 5, Edit: 3 }); expect(currentStats.tools_usage.Read?.call_count).toBe(5);
expect(currentStats.tools_usage.Edit?.call_count).toBe(3);
}); });
it("can be updated with update function", () => { it("can be updated with update function", () => {
@@ -109,9 +138,14 @@ describe("stats store", () => {
session_files_edited: 2, session_files_edited: 2,
files_created: 1, files_created: 1,
session_files_created: 1, session_files_created: 1,
tools_usage: { Read: 5, Edit: 3 }, tools_usage: { Read: toolStats(5), Edit: toolStats(3) },
session_tools_usage: { Read: 2, Edit: 1 }, session_tools_usage: { Read: toolStats(2), Edit: toolStats(1) },
session_duration_seconds: 300, session_duration_seconds: 300,
context_tokens_used: 500,
context_window_limit: 200000,
context_utilisation_percent: 0.25,
potential_cache_hits: 0,
potential_cache_savings_tokens: 0,
}); });
// Reset session stats // Reset session stats
@@ -127,7 +161,8 @@ describe("stats store", () => {
expect(currentStats.code_blocks_generated).toBe(3); expect(currentStats.code_blocks_generated).toBe(3);
expect(currentStats.files_edited).toBe(5); expect(currentStats.files_edited).toBe(5);
expect(currentStats.files_created).toBe(1); expect(currentStats.files_created).toBe(1);
expect(currentStats.tools_usage).toEqual({ Read: 5, Edit: 3 }); expect(currentStats.tools_usage.Read?.call_count).toBe(5);
expect(currentStats.tools_usage.Edit?.call_count).toBe(3);
expect(currentStats.model).toBe("claude-sonnet-4"); expect(currentStats.model).toBe("claude-sonnet-4");
// Session stats should be reset // Session stats should be reset
@@ -277,8 +312,8 @@ describe("stats store", () => {
}); });
it("exposes tools usage directly", () => { it("exposes tools usage directly", () => {
const toolsUsage = { Read: 10, Edit: 5, Write: 3 }; const toolsUsage = { Read: toolStats(10), Edit: toolStats(5), Write: toolStats(3) };
const sessionToolsUsage = { Read: 2, Edit: 1 }; const sessionToolsUsage = { Read: toolStats(2), Edit: toolStats(1) };
stats.update((current) => ({ stats.update((current) => ({
...current, ...current,
@@ -331,9 +366,14 @@ describe("stats store", () => {
session_files_edited: 1, session_files_edited: 1,
files_created: 1, files_created: 1,
session_files_created: 0, session_files_created: 0,
tools_usage: { Read: 3 }, tools_usage: { Read: toolStats(3) },
session_tools_usage: { Read: 1 }, session_tools_usage: { Read: toolStats(1) },
session_duration_seconds: 60, session_duration_seconds: 60,
context_tokens_used: 50,
context_window_limit: 200000,
context_utilisation_percent: 0.025,
potential_cache_hits: 0,
potential_cache_savings_tokens: 0,
}; };
stats.set(fullStats); stats.set(fullStats);
@@ -343,4 +383,221 @@ describe("stats store", () => {
expect(currentStats).toEqual(fullStats); expect(currentStats).toEqual(fullStats);
}); });
}); });
describe("context window tracking", () => {
it("tracks context tokens used", () => {
stats.update((current) => ({
...current,
context_tokens_used: 100000,
context_window_limit: 200000,
context_utilisation_percent: 50.0,
}));
const currentStats = get(stats);
expect(currentStats.context_tokens_used).toBe(100000);
expect(currentStats.context_window_limit).toBe(200000);
expect(currentStats.context_utilisation_percent).toBe(50.0);
});
it("formats context stats correctly", () => {
stats.update((current) => ({
...current,
context_tokens_used: 150000,
context_window_limit: 200000,
context_utilisation_percent: 75.5,
}));
const formatted = get(formattedStats);
expect(formatted.contextUsed).toBe("150,000");
expect(formatted.contextLimit).toBe("200,000");
expect(formatted.contextRemaining).toBe("50,000");
expect(formatted.contextUtilisation).toBe("75.5%");
});
it("calculates remaining tokens correctly at limit", () => {
stats.update((current) => ({
...current,
context_tokens_used: 200000,
context_window_limit: 200000,
context_utilisation_percent: 100.0,
}));
const formatted = get(formattedStats);
expect(formatted.contextRemaining).toBe("0");
});
it("handles over-limit gracefully", () => {
stats.update((current) => ({
...current,
context_tokens_used: 250000,
context_window_limit: 200000,
context_utilisation_percent: 125.0,
}));
const formatted = get(formattedStats);
expect(formatted.contextRemaining).toBe("0");
});
});
describe("contextWarning derived store", () => {
it("returns null when under 50%", () => {
stats.update((current) => ({
...current,
context_utilisation_percent: 40.0,
}));
const warning = get(contextWarning);
expect(warning).toBeNull();
});
it("returns moderate when between 50-74%", () => {
stats.update((current) => ({
...current,
context_utilisation_percent: 60.0,
}));
const warning = get(contextWarning);
expect(warning).toBe("moderate");
});
it("returns high when between 75-89%", () => {
stats.update((current) => ({
...current,
context_utilisation_percent: 80.0,
}));
const warning = get(contextWarning);
expect(warning).toBe("high");
});
it("returns critical when 90%+", () => {
stats.update((current) => ({
...current,
context_utilisation_percent: 95.0,
}));
const warning = get(contextWarning);
expect(warning).toBe("critical");
});
});
describe("getContextWarningMessage", () => {
it("returns correct message for moderate warning", () => {
const message = getContextWarningMessage("moderate");
expect(message).toContain("50%+");
expect(message).toContain("Consider starting a new conversation");
});
it("returns correct message for high warning", () => {
const message = getContextWarningMessage("high");
expect(message).toContain("75%+");
expect(message).toContain("Responses may degrade");
});
it("returns correct message for critical warning", () => {
const message = getContextWarningMessage("critical");
expect(message).toContain("90%+");
expect(message).toContain("Start a new conversation");
});
});
describe("formatTokenCount", () => {
it("formats small numbers directly", () => {
expect(formatTokenCount(0)).toBe("0");
expect(formatTokenCount(100)).toBe("100");
expect(formatTokenCount(999)).toBe("999");
});
it("formats thousands with K suffix", () => {
expect(formatTokenCount(1000)).toBe("1.0K");
expect(formatTokenCount(1500)).toBe("1.5K");
expect(formatTokenCount(10000)).toBe("10.0K");
expect(formatTokenCount(999999)).toBe("1000.0K");
});
it("formats millions with M suffix", () => {
expect(formatTokenCount(1000000)).toBe("1.0M");
expect(formatTokenCount(1500000)).toBe("1.5M");
expect(formatTokenCount(10000000)).toBe("10.0M");
});
});
describe("estimateMessageCost", () => {
it("estimates tokens at ~4 chars per token", () => {
const result = estimateMessageCost("test", 0, null); // 4 chars = 1 token
expect(result.messageTokens).toBe(1);
});
it("rounds up partial tokens", () => {
const result = estimateMessageCost("a", 0, null); // 1 char rounds up to 1 token
expect(result.messageTokens).toBe(1);
const result2 = estimateMessageCost("abcde", 0, null); // 5 chars = 2 tokens
expect(result2.messageTokens).toBe(2);
});
it("returns 0 tokens for empty string", () => {
const result = estimateMessageCost("", 0, null);
expect(result.messageTokens).toBe(0);
expect(result.estimatedCost).toBe(0);
});
it("adds context tokens to total", () => {
const result = estimateMessageCost("test", 1000, null); // 1 token + 1000 context
expect(result.messageTokens).toBe(1);
expect(result.totalInputTokens).toBe(1001);
});
it("calculates cost using Sonnet pricing by default", () => {
// 100 chars = 25 tokens, $3 per million input tokens
const result = estimateMessageCost("a".repeat(100), 0, null);
expect(result.messageTokens).toBe(25);
const expectedCost = (25 / 1_000_000) * 3.0;
expect(result.estimatedCost).toBeCloseTo(expectedCost, 8);
});
it("uses Opus pricing for Opus models", () => {
const result = estimateMessageCost("a".repeat(100), 0, "claude-opus-4-5-20251101");
expect(result.messageTokens).toBe(25);
const expectedCost = (25 / 1_000_000) * 5.0; // Opus 4.5: $5 per million input
expect(result.estimatedCost).toBeCloseTo(expectedCost, 8);
});
it("uses Haiku pricing for Haiku models", () => {
const result = estimateMessageCost("a".repeat(100), 0, "claude-3-5-haiku-20241022");
expect(result.messageTokens).toBe(25);
const expectedCost = (25 / 1_000_000) * 1.0; // Haiku: $1 per million
expect(result.estimatedCost).toBeCloseTo(expectedCost, 8);
});
it("falls back to Sonnet pricing for unknown models", () => {
const result = estimateMessageCost("a".repeat(100), 0, "unknown-model");
expect(result.messageTokens).toBe(25);
const expectedCost = (25 / 1_000_000) * 3.0; // Default Sonnet: $3 per million
expect(result.estimatedCost).toBeCloseTo(expectedCost, 8);
});
});
describe("MODEL_PRICING", () => {
it("contains expected Opus pricing", () => {
// Opus 4.5 has reduced pricing
expect(MODEL_PRICING["claude-opus-4-5-20251101"]).toEqual({ input: 5.0, output: 25.0 });
// Previous Opus models have higher pricing
expect(MODEL_PRICING["claude-opus-4-1-20250805"]).toEqual({ input: 15.0, output: 75.0 });
expect(MODEL_PRICING["claude-opus-4-20250514"]).toEqual({ input: 15.0, output: 75.0 });
});
it("contains expected Sonnet pricing", () => {
expect(MODEL_PRICING["claude-sonnet-4-5-20250929"]).toEqual({ input: 3.0, output: 15.0 });
expect(MODEL_PRICING["claude-sonnet-4-20250514"]).toEqual({ input: 3.0, output: 15.0 });
expect(MODEL_PRICING["claude-3-7-sonnet-20250219"]).toEqual({ input: 3.0, output: 15.0 });
expect(MODEL_PRICING["claude-3-5-sonnet-20241022"]).toEqual({ input: 3.0, output: 15.0 });
});
it("contains expected Haiku pricing", () => {
expect(MODEL_PRICING["claude-haiku-4-5-20251001"]).toEqual({ input: 1.0, output: 5.0 });
expect(MODEL_PRICING["claude-3-5-haiku-20241022"]).toEqual({ input: 1.0, output: 5.0 });
expect(MODEL_PRICING["claude-3-haiku-20240307"]).toEqual({ input: 0.25, output: 1.25 });
});
});
}); });
+211 -2
View File
@@ -1,6 +1,66 @@
import { writable, derived } from "svelte/store"; import { writable, derived } from "svelte/store";
import { listen } from "@tauri-apps/api/event"; import { listen } from "@tauri-apps/api/event";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { costTrackingStore } from "./costTracking";
export type ContextWarning = "moderate" | "high" | "critical";
export type BudgetType = "token" | "cost";
// Model pricing (per million tokens) - keep in sync with stats.rs
// Source: https://platform.claude.com/docs/en/about-claude/models/overview
export const MODEL_PRICING: Record<string, { input: number; output: number }> = {
// Current generation (Claude 4.5)
"claude-opus-4-5-20251101": { input: 5.0, output: 25.0 },
"claude-sonnet-4-5-20250929": { input: 3.0, output: 15.0 },
"claude-haiku-4-5-20251001": { input: 1.0, output: 5.0 },
// Previous generation (Claude 4.x)
"claude-opus-4-1-20250805": { input: 15.0, output: 75.0 },
"claude-opus-4-20250514": { input: 15.0, output: 75.0 },
"claude-sonnet-4-20250514": { input: 3.0, output: 15.0 },
// Legacy (Claude 3.x)
"claude-3-7-sonnet-20250219": { input: 3.0, output: 15.0 },
"claude-3-5-sonnet-20241022": { input: 3.0, output: 15.0 },
"claude-3-5-sonnet-20240620": { input: 3.0, output: 15.0 },
"claude-3-5-haiku-20241022": { input: 1.0, output: 5.0 },
"claude-3-opus-20240229": { input: 15.0, output: 75.0 },
"claude-3-sonnet-20240229": { input: 3.0, output: 15.0 },
"claude-3-haiku-20240307": { input: 0.25, output: 1.25 },
};
const DEFAULT_PRICING = { input: 3.0, output: 15.0 }; // Default to Sonnet
export interface CostEstimate {
messageTokens: number;
totalInputTokens: number;
estimatedCost: number;
}
// Estimate cost for a message before sending
export function estimateMessageCost(
messageText: string,
contextTokensUsed: number,
model: string | null
): CostEstimate {
// Estimate tokens using ~4 chars per token heuristic
const messageTokens = Math.ceil(messageText.length / 4);
const totalInputTokens = contextTokensUsed + messageTokens;
const pricing = model ? (MODEL_PRICING[model] ?? DEFAULT_PRICING) : DEFAULT_PRICING;
const estimatedCost = (totalInputTokens / 1_000_000) * pricing.input;
return { messageTokens, totalInputTokens, estimatedCost };
}
export type BudgetStatus =
| { type: "ok" }
| { type: "warning"; budget_type: BudgetType; percent_used: number }
| { type: "exceeded"; budget_type: BudgetType };
// Per-tool token usage statistics
export interface ToolTokenStats {
call_count: number;
estimated_input_tokens: number;
estimated_output_tokens: number;
}
export interface UsageStats { export interface UsageStats {
total_input_tokens: number; total_input_tokens: number;
@@ -20,9 +80,18 @@ export interface UsageStats {
session_files_edited: number; session_files_edited: number;
files_created: number; files_created: number;
session_files_created: number; session_files_created: number;
tools_usage: Record<string, number>; tools_usage: Record<string, ToolTokenStats>;
session_tools_usage: Record<string, number>; session_tools_usage: Record<string, ToolTokenStats>;
session_duration_seconds: number; session_duration_seconds: number;
// Context window tracking
context_tokens_used: number;
context_window_limit: number;
context_utilisation_percent: number;
// Cache analytics (tracks potential savings from repeated tool calls)
potential_cache_hits: number;
potential_cache_savings_tokens: number;
} }
// Main stats store // Main stats store
@@ -45,8 +114,24 @@ export const stats = writable<UsageStats>({
tools_usage: {}, tools_usage: {},
session_tools_usage: {}, session_tools_usage: {},
session_duration_seconds: 0, session_duration_seconds: 0,
context_tokens_used: 0,
context_window_limit: 200000,
context_utilisation_percent: 0,
potential_cache_hits: 0,
potential_cache_savings_tokens: 0,
}); });
// Format token count with K/M suffix
export function formatTokenCount(tokens: number): string {
if (tokens >= 1000000) {
return `${(tokens / 1000000).toFixed(1)}M`;
}
if (tokens >= 1000) {
return `${(tokens / 1000).toFixed(1)}K`;
}
return tokens.toString();
}
// Derived store for formatted display values // Derived store for formatted display values
export const formattedStats = derived(stats, ($stats) => { export const formattedStats = derived(stats, ($stats) => {
const formatNumber = (num: number) => num.toLocaleString(); const formatNumber = (num: number) => num.toLocaleString();
@@ -65,6 +150,20 @@ export const formattedStats = derived(stats, ($stats) => {
} }
}; };
// Format tool stats with token info
const formatToolStats = (toolStats: Record<string, ToolTokenStats>) => {
return Object.entries(toolStats).map(([name, stats]) => ({
name,
callCount: stats.call_count,
totalTokens: stats.estimated_input_tokens + stats.estimated_output_tokens,
formattedTokens: formatTokenCount(
stats.estimated_input_tokens + stats.estimated_output_tokens
),
inputTokens: stats.estimated_input_tokens,
outputTokens: stats.estimated_output_tokens,
}));
};
return { return {
totalTokens: formatNumber($stats.total_input_tokens + $stats.total_output_tokens), totalTokens: formatNumber($stats.total_input_tokens + $stats.total_output_tokens),
totalInputTokens: formatNumber($stats.total_input_tokens), totalInputTokens: formatNumber($stats.total_input_tokens),
@@ -88,9 +187,116 @@ export const formattedStats = derived(stats, ($stats) => {
sessionDuration: formatDuration($stats.session_duration_seconds), sessionDuration: formatDuration($stats.session_duration_seconds),
toolsUsage: $stats.tools_usage, toolsUsage: $stats.tools_usage,
sessionToolsUsage: $stats.session_tools_usage, sessionToolsUsage: $stats.session_tools_usage,
// Formatted tool stats with token info
sessionToolsFormatted: formatToolStats($stats.session_tools_usage),
toolsFormatted: formatToolStats($stats.tools_usage),
// Context window tracking
contextUsed: formatNumber($stats.context_tokens_used),
contextLimit: formatNumber($stats.context_window_limit),
contextRemaining: formatNumber(
Math.max(0, $stats.context_window_limit - $stats.context_tokens_used)
),
contextUtilisation: `${$stats.context_utilisation_percent.toFixed(1)}%`,
}; };
}); });
// Derived store for context warning state
export const contextWarning = derived(stats, ($stats): ContextWarning | null => {
if ($stats.context_utilisation_percent >= 90) {
return "critical";
} else if ($stats.context_utilisation_percent >= 75) {
return "high";
} else if ($stats.context_utilisation_percent >= 50) {
return "moderate";
}
return null;
});
// Get warning message for context utilisation
export function getContextWarningMessage(warning: ContextWarning): string {
switch (warning) {
case "moderate":
return "Context window is 50%+ full. Consider starting a new conversation for better performance.";
case "high":
return "Context window is 75%+ full. Responses may degrade. Consider summarising or starting fresh.";
case "critical":
return "Context window is nearly full (90%+)! Start a new conversation to avoid errors.";
}
}
// Budget checking functions
export function checkBudget(
stats: UsageStats,
budgetEnabled: boolean,
tokenBudget: number | null,
costBudget: number | null,
warningThreshold: number
): BudgetStatus {
if (!budgetEnabled) {
return { type: "ok" };
}
const sessionTokens = stats.session_input_tokens + stats.session_output_tokens;
// Check token budget
if (tokenBudget !== null) {
if (sessionTokens >= tokenBudget) {
return { type: "exceeded", budget_type: "token" };
}
const percentUsed = sessionTokens / tokenBudget;
if (percentUsed >= warningThreshold) {
return { type: "warning", budget_type: "token", percent_used: percentUsed * 100 };
}
}
// Check cost budget
if (costBudget !== null) {
if (stats.session_cost_usd >= costBudget) {
return { type: "exceeded", budget_type: "cost" };
}
const percentUsed = stats.session_cost_usd / costBudget;
if (percentUsed >= warningThreshold) {
return { type: "warning", budget_type: "cost", percent_used: percentUsed * 100 };
}
}
return { type: "ok" };
}
// Get budget status message
export function getBudgetStatusMessage(status: BudgetStatus): string | null {
if (status.type === "ok") {
return null;
}
const budgetTypeLabel = status.budget_type === "token" ? "token" : "cost";
if (status.type === "exceeded") {
return `Session ${budgetTypeLabel} budget exceeded! Consider starting a new session.`;
}
return `Approaching ${budgetTypeLabel} budget limit (${status.percent_used.toFixed(0)}% used).`;
}
// Get remaining budget values
export function getRemainingTokenBudget(
stats: UsageStats,
tokenBudget: number | null
): number | null {
if (tokenBudget === null) return null;
const used = stats.session_input_tokens + stats.session_output_tokens;
return Math.max(0, tokenBudget - used);
}
export function getRemainingCostBudget(
stats: UsageStats,
costBudget: number | null
): number | null {
if (costBudget === null) return null;
return Math.max(0, costBudget - stats.session_cost_usd);
}
// Note: Cost calculation is now done in the Rust backend // Note: Cost calculation is now done in the Rust backend
// Initialize stats listener // Initialize stats listener
@@ -102,6 +308,9 @@ export async function initStatsListener() {
// The backend already tracks all totals - just set the stats directly // The backend already tracks all totals - just set the stats directly
stats.set(newStats); stats.set(newStats);
// Refresh cost tracking to check for alerts (debounced - won't spam)
costTrackingStore.refresh();
}); });
// Load initial persisted stats from backend (no bridge required) // Load initial persisted stats from backend (no bridge required)
+19 -3
View File
@@ -90,6 +90,11 @@ interface OutputPayload {
content: string; content: string;
tool_name: string | null; tool_name: string | null;
conversation_id?: string; conversation_id?: string;
cost?: {
input_tokens: number;
output_tokens: number;
cost_usd: number;
};
} }
interface ConnectionPayload { interface ConnectionPayload {
@@ -242,7 +247,16 @@ export async function initializeTauriListeners() {
unlisteners.push(stateUnlisten); unlisteners.push(stateUnlisten);
const outputUnlisten = await listen<OutputPayload>("claude:output", (event) => { const outputUnlisten = await listen<OutputPayload>("claude:output", (event) => {
const { line_type, content, tool_name, conversation_id } = event.payload; const { line_type, content, tool_name, conversation_id, cost } = event.payload;
// Convert snake_case cost to camelCase for TypeScript
const costData = cost
? {
inputTokens: cost.input_tokens,
outputTokens: cost.output_tokens,
costUsd: cost.cost_usd,
}
: undefined;
// Always store the output to the correct conversation // Always store the output to the correct conversation
if (conversation_id) { if (conversation_id) {
@@ -250,14 +264,16 @@ export async function initializeTauriListeners() {
conversation_id, conversation_id,
line_type as "user" | "assistant" | "system" | "tool" | "error", line_type as "user" | "assistant" | "system" | "tool" | "error",
content, content,
tool_name || undefined tool_name || undefined,
costData
); );
} else { } else {
// Fallback to active conversation if no conversation_id provided // Fallback to active conversation if no conversation_id provided
claudeStore.addLine( claudeStore.addLine(
line_type as "user" | "assistant" | "system" | "tool" | "error", line_type as "user" | "assistant" | "system" | "tool" | "error",
content, content,
tool_name || undefined tool_name || undefined,
costData
); );
} }
}); });
+27
View File
@@ -0,0 +1,27 @@
export interface FileEntry {
name: string;
path: string;
isDirectory: boolean;
isExpanded?: boolean;
children?: FileEntry[];
isLoading?: boolean;
}
export interface EditorTab {
id: string;
filePath: string;
fileName: string;
content: string;
originalContent: string;
isDirty: boolean;
language: string;
}
export interface EditorState {
tabs: EditorTab[];
activeTabId: string | null;
isFileBrowserOpen: boolean;
currentDirectory: string;
fileTree: FileEntry[];
isLoadingTree: boolean;
}
+6
View File
@@ -4,6 +4,12 @@ export interface TerminalLine {
content: string; content: string;
timestamp: Date; timestamp: Date;
toolName?: string; toolName?: string;
// Cost tracking for this specific message
cost?: {
inputTokens: number;
outputTokens: number;
costUsd: number;
};
} }
export interface SystemInitMessage { export interface SystemInitMessage {
+188
View File
@@ -0,0 +1,188 @@
import { describe, it, expect } from "vitest";
import {
generateSummaryPrompt,
generateContextInjection,
estimateTokens,
createSummary,
shouldSuggestCompaction,
formatTokenCount,
sanitizeForJson,
} from "./conversationUtils";
import type { ConversationSummary } from "$lib/stores/conversations";
describe("conversationUtils", () => {
describe("generateSummaryPrompt", () => {
it("generates a prompt containing the conversation content", () => {
const content = "User: Hello\n\nAssistant: Hi there!";
const prompt = generateSummaryPrompt(content);
expect(prompt).toContain(content);
expect(prompt).toContain("summary");
expect(prompt).toContain("Key topics");
});
it("handles empty content", () => {
const prompt = generateSummaryPrompt("");
expect(prompt).toContain("summary");
});
});
describe("generateContextInjection", () => {
it("creates context injection message from summary", () => {
const summary: ConversationSummary = {
generatedAt: new Date("2024-01-01"),
content: "We discussed building a new feature",
messageCount: 50,
tokenEstimate: 10000,
};
const injection = generateContextInjection(summary);
expect(injection).toContain("Previous Session Context");
expect(injection).toContain("We discussed building a new feature");
expect(injection).toContain("50 messages");
expect(injection).toContain("10,000 tokens");
});
});
describe("estimateTokens", () => {
it("estimates tokens at ~4 chars per token", () => {
expect(estimateTokens("")).toBe(0);
expect(estimateTokens("test")).toBe(1); // 4 chars = 1 token
expect(estimateTokens("testing")).toBe(2); // 7 chars = 2 tokens
expect(estimateTokens("a".repeat(100))).toBe(25); // 100 chars = 25 tokens
});
it("rounds up partial tokens", () => {
expect(estimateTokens("a")).toBe(1); // 1 char rounds up to 1 token
expect(estimateTokens("ab")).toBe(1); // 2 chars rounds up to 1 token
expect(estimateTokens("abc")).toBe(1); // 3 chars rounds up to 1 token
expect(estimateTokens("abcde")).toBe(2); // 5 chars rounds up to 2 tokens
});
});
describe("createSummary", () => {
it("creates a valid ConversationSummary object", () => {
const summary = createSummary("Test summary content", 25, 5000);
expect(summary.content).toBe("Test summary content");
expect(summary.messageCount).toBe(25);
expect(summary.tokenEstimate).toBe(5000);
expect(summary.generatedAt).toBeInstanceOf(Date);
});
it("sets generatedAt to current time", () => {
const before = new Date();
const summary = createSummary("content", 10, 1000);
const after = new Date();
expect(summary.generatedAt.getTime()).toBeGreaterThanOrEqual(before.getTime());
expect(summary.generatedAt.getTime()).toBeLessThanOrEqual(after.getTime());
});
});
describe("shouldSuggestCompaction", () => {
it("returns false when under threshold", () => {
expect(shouldSuggestCompaction(50000, 200000, 60)).toBe(false); // 25%
expect(shouldSuggestCompaction(100000, 200000, 60)).toBe(false); // 50%
expect(shouldSuggestCompaction(119000, 200000, 60)).toBe(false); // 59.5%
});
it("returns true when at or above threshold", () => {
expect(shouldSuggestCompaction(120000, 200000, 60)).toBe(true); // 60%
expect(shouldSuggestCompaction(150000, 200000, 60)).toBe(true); // 75%
expect(shouldSuggestCompaction(200000, 200000, 60)).toBe(true); // 100%
});
it("handles zero context window limit", () => {
expect(shouldSuggestCompaction(50000, 0, 60)).toBe(false);
});
it("uses default threshold of 60%", () => {
expect(shouldSuggestCompaction(110000, 200000)).toBe(false); // 55%
expect(shouldSuggestCompaction(130000, 200000)).toBe(true); // 65%
});
it("respects custom threshold", () => {
expect(shouldSuggestCompaction(70000, 200000, 40)).toBe(false); // 35%
expect(shouldSuggestCompaction(90000, 200000, 40)).toBe(true); // 45%
});
});
describe("formatTokenCount", () => {
it("formats small numbers directly", () => {
expect(formatTokenCount(0)).toBe("0");
expect(formatTokenCount(100)).toBe("100");
expect(formatTokenCount(999)).toBe("999");
});
it("formats thousands with K suffix", () => {
expect(formatTokenCount(1000)).toBe("1.0K");
expect(formatTokenCount(1500)).toBe("1.5K");
expect(formatTokenCount(10000)).toBe("10.0K");
expect(formatTokenCount(999999)).toBe("1000.0K");
});
it("formats millions with M suffix", () => {
expect(formatTokenCount(1000000)).toBe("1.0M");
expect(formatTokenCount(1500000)).toBe("1.5M");
expect(formatTokenCount(10000000)).toBe("10.0M");
});
});
describe("sanitizeForJson", () => {
it("returns normal text unchanged", () => {
expect(sanitizeForJson("Hello world")).toBe("Hello world");
expect(sanitizeForJson("Test 123")).toBe("Test 123");
});
it("preserves common whitespace", () => {
expect(sanitizeForJson("line1\nline2")).toBe("line1\nline2");
expect(sanitizeForJson("col1\tcol2")).toBe("col1\tcol2");
expect(sanitizeForJson("line\r\nend")).toBe("line\r\nend");
});
it("removes null bytes", () => {
expect(sanitizeForJson("hello\x00world")).toBe("helloworld");
});
it("removes other control characters", () => {
// Bell character
expect(sanitizeForJson("alert\x07here")).toBe("alerthere");
// Backspace
expect(sanitizeForJson("back\x08space")).toBe("backspace");
// Form feed is removed
expect(sanitizeForJson("page\x0Cbreak")).toBe("pagebreak");
// Escape character
expect(sanitizeForJson("esc\x1Bhere")).toBe("eschere");
});
it("preserves printable characters including backslashes", () => {
const codeContent = '```rust\nfn main() {\n println!("Hello");\n}\n```';
expect(sanitizeForJson(codeContent)).toBe(codeContent);
});
it("handles mixed content with various characters", () => {
const mixed = "User: Hello\n\nAssistant: Here's some code:\n```\nconst x = 42;\n```";
expect(sanitizeForJson(mixed)).toBe(mixed);
});
it("preserves backslash sequences", () => {
// Backslashes followed by letters should be preserved as-is
expect(sanitizeForJson("path\\to\\file")).toBe("path\\to\\file");
expect(sanitizeForJson("color\\x1b")).toBe("color\\x1b");
});
it("removes lone surrogates", () => {
// Lone surrogates (U+D800-U+DFFF) can cause JSON parse errors
// High surrogate without low
expect(sanitizeForJson("test\uD800end")).toBe("testend");
// Low surrogate without high
expect(sanitizeForJson("test\uDC00end")).toBe("testend");
// But valid surrogate pairs should remain (they form valid characters)
// Actually, JavaScript represents emoji as surrogate pairs, so this is tricky
// The regex will remove the surrogates, which may break emoji. That's acceptable
// for a conversation summary where data integrity is more important.
});
});
});
+110
View File
@@ -0,0 +1,110 @@
import type { ConversationSummary } from "$lib/stores/conversations";
/**
* Sanitises a string for safe JSON serialization through Tauri IPC.
* Removes control characters and lone surrogates that could cause issues
* during JSON serialization/deserialization.
*/
export function sanitizeForJson(text: string): string {
// Remove control characters except for common whitespace (tab, newline, carriage return)
// These can cause JSON parsing issues and are rarely meaningful in conversation summaries.
// eslint-disable-next-line no-control-regex -- regex uses control character codes
let sanitized = text.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, "");
// Remove extended ASCII control chars (C1 control codes)
sanitized = sanitized.replace(/[\x80-\x9F]/g, "");
// Remove lone surrogates (U+D800 to U+DFFF) which cause "unexpected end of hex escape"
// errors in serde_json when they appear without proper pairing.
// These are invalid in JSON and can cause parse failures.
sanitized = sanitized.replace(/[\uD800-\uDFFF]/g, "");
return sanitized;
}
/**
* Generates a prompt to ask Claude to summarise a conversation.
* This can be sent as a user message to get a summary.
*/
export function generateSummaryPrompt(conversationContent: string): string {
return `Please provide a concise summary of our conversation so far. Focus on:
1. Key topics discussed
2. Important decisions or conclusions made
3. Any ongoing tasks or context that would be helpful to remember
4. Code changes or files that were modified
Keep the summary brief but comprehensive enough to continue our work in a new session.
Here is our conversation:
${conversationContent}
Please provide the summary now:`;
}
/**
* Generates a context injection message to prepend to a new conversation.
* This provides Claude with context from a previous session.
*/
export function generateContextInjection(summary: ConversationSummary): string {
return `[Previous Session Context]
The following is a summary from our previous conversation (${summary.messageCount} messages, approximately ${summary.tokenEstimate.toLocaleString()} tokens):
${summary.content}
[End of Previous Context]
Please continue from where we left off, or let me know if you need any clarification about the previous context.`;
}
/**
* Estimates the token count for a given string.
* Uses a rough approximation of ~4 characters per token.
*/
export function estimateTokens(text: string): number {
return Math.ceil(text.length / 4);
}
/**
* Creates a ConversationSummary object from summary content.
*/
export function createSummary(
content: string,
messageCount: number,
originalTokenEstimate: number
): ConversationSummary {
return {
generatedAt: new Date(),
content,
messageCount,
tokenEstimate: originalTokenEstimate,
};
}
/**
* Determines if a conversation should be compacted based on token usage.
* Returns true if the conversation is using more than the threshold percentage
* of the context window.
*/
export function shouldSuggestCompaction(
contextTokensUsed: number,
contextWindowLimit: number,
thresholdPercent: number = 60
): boolean {
if (contextWindowLimit === 0) return false;
const utilisationPercent = (contextTokensUsed / contextWindowLimit) * 100;
return utilisationPercent >= thresholdPercent;
}
/**
* Formats a token count for display.
*/
export function formatTokenCount(tokens: number): string {
if (tokens >= 1000000) {
return `${(tokens / 1000000).toFixed(1)}M`;
}
if (tokens >= 1000) {
return `${(tokens / 1000).toFixed(1)}K`;
}
return tokens.toString();
}
+9 -1
View File
@@ -1,9 +1,17 @@
<script> <script lang="ts">
import "../app.css"; import "../app.css";
let { children } = $props(); let { children } = $props();
// Prevent the default context menu globally
// Individual components can show their own context menus by calling event.stopPropagation()
function handleContextMenu(event: MouseEvent) {
event.preventDefault();
}
</script> </script>
<svelte:window oncontextmenu={handleContextMenu} />
<div id="app"> <div id="app">
{@render children()} {@render children()}
</div> </div>

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