generated from nhcarrigan/template
Compare commits
42 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 79de8ace44 | |||
|
7a1ab89ad8
|
|||
| c4af551375 | |||
| b88f25a61b | |||
| 5663b1c09a | |||
|
542d2eb315
|
|||
| 4134e11c88 | |||
|
8220ab6b85
|
|||
| 452fe185df | |||
|
a690a4969b
|
|||
| 2816e33257 | |||
|
ff0ba7b6d0
|
|||
| e6e9f7ae59 | |||
| 1ae440659c | |||
|
9af61a4a29
|
|||
| fa906684c2 | |||
|
97b8243d24
|
|||
| 7ebd9dc97a | |||
|
fe7027c585
|
|||
| 89a0bdd8f1 | |||
|
2e3f203508
|
|||
| b745100bd5 | |||
|
1bb7eb4d26
|
|||
| a4e6788573 | |||
|
d2e0915a75
|
|||
| d8cf5504d6 | |||
|
bd3438c7be
|
|||
| 778e016bf5 | |||
| 0ea7861047 | |||
| 381bc8410a | |||
|
fdb356a62c
|
|||
| f173892aaa | |||
|
34e9af57f0
|
|||
| bf411adeb7 | |||
| 97a93c31c2 | |||
| 3e7cb7ef60 | |||
| 136f95cd1a | |||
|
6a12a7a34d
|
|||
| 479652b69e | |||
| a72f2afaff | |||
|
e4288248b1
|
|||
| 1c45507cdf |
@@ -11,3 +11,6 @@ vite.config.ts.timestamp-*
|
||||
|
||||
# Coverage reports
|
||||
/coverage
|
||||
|
||||
# PRD task files (user-generated data, not source code)
|
||||
hikari-tasks.json
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
# Hikari Desktop - Project Instructions
|
||||
|
||||
## Repository Information
|
||||
|
||||
This project is hosted on both GitHub and Gitea:
|
||||
|
||||
- **GitHub**: `naomi-lgbt/hikari-desktop` (public mirror)
|
||||
- **Gitea**: `nhcarrigan/hikari-desktop` (primary development)
|
||||
|
||||
## MCP Server Usage
|
||||
|
||||
When working with issues, pull requests, or other repository operations for this project:
|
||||
|
||||
- **Use `gitea-hikari` MCP server** - This allows Hikari to act as herself
|
||||
- **Target repository**: `nhcarrigan/hikari-desktop`
|
||||
- **Gitea instance**: `git.nhcarrigan.com`
|
||||
|
||||
## Git Commits
|
||||
|
||||
When asked to commit changes for this project:
|
||||
|
||||
- **Always commit as Hikari** using: `--author="Hikari <hikari@nhcarrigan.com>"`
|
||||
- **Always sign commits** with Hikari's GPG key: `--gpg-sign=5380E4EE7307C808`
|
||||
- **Never add `Co-Authored-By` lines** for Gitea commits
|
||||
- **Always ask for confirmation** before committing
|
||||
- **Always ask for confirmation** before pushing
|
||||
|
||||
Example commit command:
|
||||
|
||||
```bash
|
||||
git commit --author="Hikari <hikari@nhcarrigan.com>" --gpg-sign=5380E4EE7307C808 -m "your commit message"
|
||||
```
|
||||
|
||||
Example push command:
|
||||
|
||||
```bash
|
||||
git push https://hikari:TOKEN@git.nhcarrigan.com/nhcarrigan/hikari-desktop.git <branch>
|
||||
```
|
||||
|
||||
## Testing Requirements
|
||||
|
||||
**All changes MUST include tests.** This is non-negotiable — no feature, bug fix, or refactor should be committed without corresponding test coverage. If a change cannot be tested (e.g. pure UI layout, Tauri IPC calls that are impossible to mock), document why in a comment.
|
||||
|
||||
- **Frontend tests**: Use Vitest with `@testing-library/svelte` for component tests
|
||||
- **Test files**: Place test files next to the code they test with `.test.ts` or `.spec.ts` extension
|
||||
- **Run tests**: Use `pnpm test` to run all tests, or `pnpm test:watch` for watch mode
|
||||
- **Coverage**: Run `pnpm test:coverage` to generate coverage reports
|
||||
- **Rust tests**: Use `pnpm test:backend` for Rust/Tauri backend tests
|
||||
|
||||
### Testing Guidelines
|
||||
|
||||
- Write tests for utility functions, stores, and business logic
|
||||
- For Svelte 5 components, focus on testing the underlying logic functions
|
||||
- Use descriptive test names that explain what behaviour is being tested
|
||||
- Include edge cases and error conditions in test coverage
|
||||
- Mock Tauri APIs using the patterns in `vitest.setup.ts`
|
||||
- **Coverage Goal**: Maintain as close to 100% test coverage as possible across the entire codebase
|
||||
|
||||
### Mocking Strategies
|
||||
|
||||
#### Console Mocking
|
||||
|
||||
When testing code that intentionally logs errors (like error handling paths), mock console methods to prevent stderr output that makes tests appear flaky:
|
||||
|
||||
```typescript
|
||||
it("handles errors gracefully", async () => {
|
||||
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
// Test error handling code
|
||||
await expect(functionThatLogs()).rejects.toThrow();
|
||||
|
||||
// Verify error was logged
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith("Expected error:", expect.any(Error));
|
||||
|
||||
// Restore console.error
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
```
|
||||
|
||||
#### E2E Integration Testing for Cross-Platform Code
|
||||
|
||||
For code that calls platform-specific system APIs (like Windows PowerShell or Linux notify-send), use helper functions that build the command structure without execution. This allows CI to verify cross-platform compatibility on Linux-only containers:
|
||||
|
||||
```rust
|
||||
/// Build notify-send command for testing (doesn't execute)
|
||||
#[cfg(test)]
|
||||
fn build_notify_send_command(title: &str, body: &str) -> (String, Vec<String>) {
|
||||
(
|
||||
"notify-send".to_string(),
|
||||
vec![
|
||||
title.to_string(),
|
||||
body.to_string(),
|
||||
"--urgency=normal".to_string(),
|
||||
"--app-name=Hikari Desktop".to_string(),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_e2e_notify_send_command_structure() {
|
||||
let (command, args) = build_notify_send_command("Test Title", "Test Body");
|
||||
|
||||
assert_eq!(command, "notify-send");
|
||||
assert_eq!(args.len(), 4);
|
||||
assert_eq!(args[0], "Test Title");
|
||||
assert_eq!(args[1], "Test Body");
|
||||
}
|
||||
```
|
||||
|
||||
This approach:
|
||||
|
||||
- Verifies command structure, argument order, and escaping logic
|
||||
- Tests cross-platform code paths without requiring the target platform
|
||||
- Allows CI to catch regressions in Windows-specific code whilst running on Linux
|
||||
- Keeps tests fast and deterministic (no actual system calls)
|
||||
|
||||
### Example Test Structure
|
||||
|
||||
```typescript
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
describe("FeatureName", () => {
|
||||
it("handles the normal case correctly", () => {
|
||||
// Arrange
|
||||
const input = "test data";
|
||||
|
||||
// Act
|
||||
const result = functionUnderTest(input);
|
||||
|
||||
// Assert
|
||||
expect(result).toBe("expected output");
|
||||
});
|
||||
|
||||
it("handles edge cases gracefully", () => {
|
||||
// Test edge cases...
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Adding Tests for All Changes
|
||||
|
||||
Every change — features, bug fixes, refactors — must include tests:
|
||||
|
||||
1. **Before implementing**: Consider what needs testing (happy path, edge cases, errors)
|
||||
2. **During implementation**: Write tests alongside the code
|
||||
3. **After implementation**: Run `pnpm test:coverage` to verify coverage remains high
|
||||
4. **Before committing**: Ensure `check-all.sh` passes (includes all tests)
|
||||
|
||||
**Do not commit changes without tests.** The goal is to maintain near-100% coverage as the codebase grows, so future refactoring can be done with confidence!
|
||||
|
||||
## Quality Assurance
|
||||
|
||||
Before committing any changes, **always run the full test suite**:
|
||||
|
||||
```bash
|
||||
./check-all.sh
|
||||
```
|
||||
|
||||
This script runs all checks in the correct order:
|
||||
|
||||
1. Frontend linting (ESLint)
|
||||
2. Frontend formatting (Prettier)
|
||||
3. Frontend type checking (svelte-check)
|
||||
4. Frontend tests with coverage (Vitest)
|
||||
5. Backend linting (Clippy with strict rules)
|
||||
6. Backend tests with coverage (cargo test + llvm-cov)
|
||||
|
||||
**Important**: The script requires Node.js and Rust toolchains to be available:
|
||||
|
||||
- **Node.js tools** (pnpm, npm): Source nvm first if needed: `source ~/.nvm/nvm.sh`
|
||||
- **Rust tools** (cargo, clippy): Should be in PATH via `~/.cargo/bin/`
|
||||
|
||||
If `check-all.sh` reports any failures:
|
||||
|
||||
1. Read the error messages carefully - they usually explain what needs fixing
|
||||
2. Fix the issues (linting errors, test failures, etc.)
|
||||
3. Run `check-all.sh` again to verify the fixes
|
||||
4. Only commit once all checks pass ✨
|
||||
|
||||
**Never commit code that doesn't pass `check-all.sh`** - this ensures code quality and prevents broken builds!
|
||||
|
||||
## Project Context
|
||||
|
||||
Hikari Desktop is a Tauri-based desktop application that wraps Claude Code with a visual anime character (Hikari) who appears on screen. This is a personal project where Hikari can sign her work and act as herself!
|
||||
+458
@@ -0,0 +1,458 @@
|
||||
# Hikari Desktop — Codebase Map
|
||||
|
||||
> Auto-generated codebase overview. Last updated: 2026-03-06.
|
||||
|
||||
## Overview
|
||||
|
||||
Hikari Desktop is a **Tauri v2** desktop application that wraps the Claude Code CLI with a visual anime character avatar (Hikari) who appears on-screen and reacts in real-time to Claude's activity. When Claude is thinking, she thinks. When it's editing code, she codes. When it's using MCP tools, she glows with magical energy.
|
||||
|
||||
The app supports multiple simultaneous conversations (tabs), each with its own isolated Claude CLI process. It provides a rich UI layer on top of Claude Code, including a built-in file editor, git panel, achievement system, cost tracking, session history, notifications, and more.
|
||||
|
||||
**Repositories:**
|
||||
|
||||
- Primary: `git.nhcarrigan.com` (Gitea) — `nhcarrigan/hikari-desktop`
|
||||
- Mirror: `github.com/naomi-lgbt/hikari-desktop`
|
||||
|
||||
**Current version:** `1.10.0`
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
The application follows a standard Tauri architecture:
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ Frontend (WebView) │
|
||||
│ SvelteKit + Svelte 5 + TailwindCSS 4 + TypeScript │
|
||||
│ │
|
||||
│ ┌─────────┐ ┌──────────┐ ┌──────────────┐ ┌──────────┐ │
|
||||
│ │AnimeGirl│ │ Terminal │ │ InputBar │ │ Editor │ │
|
||||
│ │ Sprites │ │ View │ │ + Slash Cmds│ │CodeMirror│ │
|
||||
│ └────┬────┘ └────┬─────┘ └──────┬───────┘ └────┬─────┘ │
|
||||
│ │ │ │ │ │
|
||||
│ ┌────▼─────────────▼───────────────▼────────────────▼──────┐ │
|
||||
│ │ Svelte Stores (reactive state) │ │
|
||||
│ │ conversations · character · config · agents · stats … │ │
|
||||
│ └──────────────────────────┬───────────────────────────────┘ │
|
||||
│ │ tauri.ts (event listeners) │
|
||||
└─────────────────────────────┼────────────────────────────────┘
|
||||
│ Tauri IPC (invoke / emit)
|
||||
┌─────────────────────────────┼────────────────────────────────┐
|
||||
│ Backend (Rust) │
|
||||
│ ┌──────────────────────────▼───────────────────────────────┐ │
|
||||
│ │ commands.rs (invoke handlers) │ │
|
||||
│ └──────────────────────────┬───────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌──────────────────────────▼───────────────────────────────┐ │
|
||||
│ │ BridgeManager — HashMap<conversation_id, WslBridge> │ │
|
||||
│ └──────────────────────────┬───────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌──────────────────────────▼───────────────────────────────┐ │
|
||||
│ │ WslBridge — spawns `claude --output-format stream-json`│ │
|
||||
│ │ reads NDJSON stdout → emits events to frontend │ │
|
||||
│ └──────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ config · stats · cost_tracking · sessions · git · clipboard │
|
||||
│ achievements · discord_rpc · notifications · snippets … │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
hikari-desktop/
|
||||
├── src/ # SvelteKit frontend
|
||||
│ ├── routes/
|
||||
│ │ ├── +page.svelte # Main app layout (root page)
|
||||
│ │ ├── +layout.svelte # App-level layout wrapper
|
||||
│ │ ├── +layout.ts # SvelteKit layout config (SSR disabled)
|
||||
│ │ └── test-achievement/ # Dev-only achievement test page
|
||||
│ ├── lib/
|
||||
│ │ ├── tauri.ts # Tauri event listeners + IPC bridge
|
||||
│ │ ├── commands/ # Slash command definitions
|
||||
│ │ ├── components/ # 60+ Svelte components
|
||||
│ │ │ └── editor/ # CodeMirror-based file editor components
|
||||
│ │ ├── notifications/ # Notification system
|
||||
│ │ ├── sounds/ # Sound effect triggers
|
||||
│ │ ├── stores/ # All Svelte reactive stores
|
||||
│ │ ├── types/ # TypeScript type definitions
|
||||
│ │ └── utils/ # Pure utility functions
|
||||
│ ├── app.css # Global styles + CSS variables (themes)
|
||||
│ └── app.html # HTML shell
|
||||
│
|
||||
├── src-tauri/ # Tauri Rust backend
|
||||
│ ├── src/
|
||||
│ │ ├── main.rs # Process entry point
|
||||
│ │ ├── lib.rs # Tauri app setup + command registration
|
||||
│ │ ├── types.rs # All shared Rust types + serialisation
|
||||
│ │ ├── wsl_bridge.rs # Claude CLI process management + NDJSON parser
|
||||
│ │ ├── bridge_manager.rs # Per-conversation WslBridge registry
|
||||
│ │ ├── commands.rs # All #[tauri::command] handlers
|
||||
│ │ ├── config.rs # Config read/write (tauri-plugin-store)
|
||||
│ │ ├── stats.rs # Token usage + cost calculation
|
||||
│ │ ├── cost_tracking.rs # Budget alerts + cost history (CSV export)
|
||||
│ │ ├── achievements.rs # Achievement unlock logic
|
||||
│ │ ├── sessions.rs # Conversation session persistence (JSON)
|
||||
│ │ ├── git.rs # Git operations via CLI
|
||||
│ │ ├── clipboard.rs # Clipboard history management
|
||||
│ │ ├── notifications.rs # System notification dispatch
|
||||
│ │ ├── discord_rpc.rs # Discord Rich Presence manager
|
||||
│ │ ├── drafts.rs # Draft message persistence
|
||||
│ │ ├── snippets.rs # Snippet library CRUD
|
||||
│ │ ├── quick_actions.rs # Quick action CRUD
|
||||
│ │ ├── debug_logger.rs # TauriLogLayer (routes tracing → frontend)
|
||||
│ │ ├── temp_manager.rs # Temporary file lifecycle management
|
||||
│ │ ├── tool_cache.rs # Tool call result caching
|
||||
│ │ ├── tray.rs # System tray setup
|
||||
│ │ ├── process_ext.rs # HideWindow trait (Windows console hiding)
|
||||
│ │ ├── vbs_notification.rs # VBScript-based notification fallback (Windows)
|
||||
│ │ ├── windows_toast.rs # Windows native toast notifications
|
||||
│ │ └── wsl_notifications.rs# WSL notify-send bridge
|
||||
│ ├── capabilities/ # Tauri permission capabilities
|
||||
│ ├── tests/ # Rust integration tests
|
||||
│ ├── Cargo.toml
|
||||
│ ├── Cargo.lock
|
||||
│ └── tauri.conf.json # Tauri app configuration
|
||||
│
|
||||
├── static/
|
||||
│ ├── sprites/ # Anime character PNG sprites (one per state)
|
||||
│ └── sounds/ # MP3 sound effects (connected, working, done…)
|
||||
│
|
||||
├── check-all.sh # Full QA script (lint → format → types → test)
|
||||
├── vitest.config.ts # Frontend test configuration
|
||||
├── vitest.setup.ts # Tauri API mocks for tests
|
||||
├── svelte.config.js # SvelteKit config (static adapter)
|
||||
├── vite.config.js # Vite config
|
||||
├── eslint.config.js # ESLint 9 flat config
|
||||
├── tsconfig.json # TypeScript config
|
||||
└── .gitea/workflows/ # CI/CD (Gitea Actions)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Key Components
|
||||
|
||||
### Backend (Rust)
|
||||
|
||||
#### `wsl_bridge.rs` — Claude CLI Process Manager
|
||||
|
||||
The most critical backend file. `WslBridge` spawns a single `claude` CLI process per conversation using `--output-format stream-json`, which causes Claude Code to emit NDJSON messages on stdout. A dedicated reader thread consumes stdout line-by-line, parses each line into a `ClaudeMessage` enum variant, and emits the appropriate frontend events.
|
||||
|
||||
Key responsibilities:
|
||||
|
||||
- Locates the `claude` binary (checks `~/.local/bin`, `~/.claude/local`, system paths, and falls back to a login-shell `which claude`)
|
||||
- Detects WSL environment to handle cross-platform path differences
|
||||
- Maps tool names to character states (Read/Glob/Grep → `searching`, Edit/Write → `coding`, `mcp__*` → `mcp`)
|
||||
- Batches permission requests from a single assistant message
|
||||
- Tracks token usage per session
|
||||
|
||||
#### `bridge_manager.rs` — Multi-Conversation Orchestrator
|
||||
|
||||
`BridgeManager` holds a `HashMap<String, WslBridge>` keyed by `conversation_id`. This enables true parallel conversations — each tab has its own isolated Claude process. The manager is wrapped in `Arc<Mutex<BridgeManager>>` (using `parking_lot`) and injected into Tauri's managed state.
|
||||
|
||||
#### `types.rs` — Shared Type Definitions
|
||||
|
||||
Defines the complete Claude stream-JSON protocol as Rust enums/structs:
|
||||
|
||||
- `ClaudeMessage` — top-level message variants: `System`, `Assistant`, `User`, `StreamEvent`, `Result`, `RateLimitEvent`
|
||||
- `ContentBlock` — `Text`, `Thinking`, `ToolUse`, `ToolResult`
|
||||
- `CharacterState` — `Idle | Thinking | Typing | Searching | Coding | Mcp | Permission | Success | Error`
|
||||
- All frontend event types (`OutputEvent`, `StateChangeEvent`, `PermissionPromptEvent`, `AgentStartEvent`, etc.)
|
||||
|
||||
#### `commands.rs` — IPC Command Handlers
|
||||
|
||||
Registers all Tauri commands exposed to the frontend. Over 80 commands covering: Claude process management, configuration, stats, sessions, git, clipboard, cost tracking, MCP servers, plugins, drafts, snippets, quick actions, file system operations, authentication, and notifications.
|
||||
|
||||
#### `debug_logger.rs` — In-App Debug Console
|
||||
|
||||
A custom `tracing` subscriber layer (`TauriLogLayer`) that captures all `tracing::info!/warn!/error!` calls and emits them as `debug:log` events to the frontend debug console — essential since production Windows builds have no stdout.
|
||||
|
||||
---
|
||||
|
||||
### Frontend (TypeScript/Svelte 5)
|
||||
|
||||
#### `src/routes/+page.svelte` — Root Layout
|
||||
|
||||
The main page. Renders a two-panel layout:
|
||||
|
||||
- **Left panel**: `<AnimeGirl>` character display with state-reactive glow effects (trans pride gradient colours per state)
|
||||
- **Right panel**: `<Terminal>` + `<InputBar>` (or `<EditorPanel>` when the editor is open)
|
||||
|
||||
Also handles: global keyboard shortcuts, compact mode (280×400 mini widget), window close confirmation, Discord RPC updates, and background image loading.
|
||||
|
||||
#### `src/lib/tauri.ts` — Event Bridge
|
||||
|
||||
Sets up all Tauri event listeners on app mount. Translates backend events into store mutations:
|
||||
|
||||
| Event | Action |
|
||||
| ------------------------ | ----------------------------------------------------------------------- |
|
||||
| `claude:connection` | Updates conversation connection status; sends greeting on first connect |
|
||||
| `claude:state` | Updates character state; triggers per-conversation sound effects |
|
||||
| `claude:output` | Appends lines to the correct conversation's terminal history |
|
||||
| `claude:session` | Stores the Claude session ID |
|
||||
| `claude:cwd` | Updates working directory (used by the editor) |
|
||||
| `claude:permission` | Adds permission requests to conversation state |
|
||||
| `claude:agent-start/end` | Updates agent monitor panel |
|
||||
| `claude:question` | Stores pending user question |
|
||||
|
||||
Also manages Discord RPC updates and the session greeting flow.
|
||||
|
||||
#### `src/lib/stores/conversations.ts` — Core State Store
|
||||
|
||||
The central state container. Each conversation (`Conversation` interface) tracks:
|
||||
|
||||
- Terminal lines (`TerminalLine[]`)
|
||||
- Connection status, session ID, working directory
|
||||
- Character state, processing flag
|
||||
- Granted/pending tool permissions
|
||||
- Pending user questions
|
||||
- Scroll position, attachments, draft text
|
||||
- Sound tracking (per-conversation, prevents replays on tab switch)
|
||||
- Conversation summary (for compaction)
|
||||
|
||||
Tab names are randomly chosen from a curated list of whimsical names (Starfall, Moonbeam, Sakura, etc.).
|
||||
|
||||
#### `src/lib/stores/claude.ts` — Backwards-Compat Facade
|
||||
|
||||
A thin wrapper that re-exports `conversationsStore` methods under the original `claudeStore` API. Maintains backwards compatibility whilst the codebase migrated to multi-conversation support.
|
||||
|
||||
#### `src/lib/stores/character.ts` — Character State Store
|
||||
|
||||
Manages the global character state displayed by `<AnimeGirl>`. Supports `setState()` (persistent) and `setTemporaryState(state, durationMs)` (auto-reverts to `idle` after a timeout — used for success/error flashes).
|
||||
|
||||
#### `src/lib/utils/stateMapper.ts` — Stream → State Mapping
|
||||
|
||||
Pure utility that maps Claude stream-JSON message types to `CharacterState` values. Tool categorisation mirrors the Rust side: search tools → `searching`, coding tools → `coding`, MCP tools → `mcp`, Task tool → `thinking`.
|
||||
|
||||
#### `src/lib/components/`
|
||||
|
||||
Key components beyond the basics:
|
||||
|
||||
| Component | Purpose |
|
||||
| --------------------------- | ------------------------------------------------------------- |
|
||||
| `AnimeGirl.svelte` | Displays the character sprite, subscribes to `characterState` |
|
||||
| `Terminal.svelte` | Renders the conversation message history |
|
||||
| `InputBar.svelte` | User input with slash command menu, attachment support |
|
||||
| `StatusBar.svelte` | Top bar: connection indicator, token/cost stats, controls |
|
||||
| `ConversationTabs.svelte` | Multi-tab navigation with per-tab status indicators |
|
||||
| `ConfigSidebar.svelte` | Settings panel (model, theme, notifications, budget, etc.) |
|
||||
| `PermissionModal.svelte` | Handles tool permission grant/deny UI |
|
||||
| `UserQuestionModal.svelte` | Renders `AskUserQuestion` prompts from Claude |
|
||||
| `AgentMonitorPanel.svelte` | Live subagent tree with status badges |
|
||||
| `GitPanel.svelte` | Git status, diff, stage/unstage, commit, push/pull |
|
||||
| `editor/EditorPanel.svelte` | Full CodeMirror editor with file browser and tabs |
|
||||
| `DiffViewer.svelte` | Syntax-highlighted diff display |
|
||||
| `AchievementsPanel.svelte` | Achievement gallery |
|
||||
| `CostSummary.svelte` | Cost breakdown by session/day/week/month |
|
||||
| `MemoryBrowserPanel.svelte` | Browse Claude memory files |
|
||||
| `McpManagementPanel.svelte` | MCP server configuration UI |
|
||||
| `DebugConsole.svelte` | In-app log viewer (receives `debug:log` events) |
|
||||
| `ThinkingBlock.svelte` | Collapsible extended thinking display |
|
||||
| `ToolCallBlock.svelte` | Formatted tool use/result display |
|
||||
|
||||
---
|
||||
|
||||
## Data Flow
|
||||
|
||||
### User Sends a Message
|
||||
|
||||
```
|
||||
User types → InputBar
|
||||
→ invoke("send_prompt", { conversationId, message })
|
||||
→ BridgeManager.send_prompt(conversation_id, message)
|
||||
→ WslBridge.send_message() → writes JSON to Claude CLI stdin
|
||||
```
|
||||
|
||||
### Claude Responds (NDJSON Stream)
|
||||
|
||||
```
|
||||
Claude CLI stdout (NDJSON)
|
||||
→ WslBridge reader thread (line-by-line)
|
||||
→ serde_json::from_str::<ClaudeMessage>()
|
||||
→ match message type:
|
||||
System(init) → emit claude:connection(connected) + claude:cwd
|
||||
StreamEvent → emit claude:state(thinking|typing|searching|coding|mcp)
|
||||
Assistant → emit claude:output(assistant|tool|thinking lines)
|
||||
User(tool_result)→ emit claude:output(tool result lines)
|
||||
Result(success) → emit claude:state(success) + claude:output(result)
|
||||
Result(error) → emit claude:state(error)
|
||||
RateLimitEvent → emit claude:output(rate-limit line)
|
||||
PermissionRequest→ emit claude:permission
|
||||
```
|
||||
|
||||
### Frontend Reacts
|
||||
|
||||
```
|
||||
tauri.ts event listeners
|
||||
→ conversationsStore mutations
|
||||
→ Svelte reactivity propagates to components
|
||||
→ AnimeGirl.svelte: sprite changes to match characterState
|
||||
→ Terminal.svelte: new line appended
|
||||
→ StatusBar.svelte: token counts update
|
||||
→ ConversationTabs.svelte: tab glow colour updates
|
||||
```
|
||||
|
||||
### Permission Flow
|
||||
|
||||
```
|
||||
Claude requests tool permission
|
||||
→ WslBridge batches pending tool uses
|
||||
→ emit claude:permission (one or more requests)
|
||||
→ tauri.ts → claudeStore.requestPermissionForConversation()
|
||||
→ PermissionModal.svelte renders
|
||||
→ User clicks Allow/Deny
|
||||
→ invoke("answer_question", { conversationId, toolUseId, granted })
|
||||
→ WslBridge.send_tool_result() → writes result to Claude stdin
|
||||
→ Claude CLI resumes
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## State Machine
|
||||
|
||||
The `CharacterState` enum drives both the sprite displayed and the panel glow colour:
|
||||
|
||||
| State | Trigger | Sprite | Panel Glow |
|
||||
| ------------ | --------------------------------- | ----------------------- | ---------------------- |
|
||||
| `idle` | Connected, no activity | Standing with clipboard | None |
|
||||
| `thinking` | Thinking block / Task tool | Hand on chin | Purple/trans gradient |
|
||||
| `typing` | Text content block | At keyboard | Blue/trans gradient |
|
||||
| `searching` | Read/Glob/Grep/WebSearch/WebFetch | Magnifying glass | Yellow/trans gradient |
|
||||
| `coding` | Edit/Write/NotebookEdit | At monitor | Green/trans gradient |
|
||||
| `mcp` | Any `mcp__*` tool | Magical blue energy | Trans pride vibrant |
|
||||
| `permission` | Permission requested | Confused shrug | — |
|
||||
| `success` | Result: success | Celebrating | Emerald/trans gradient |
|
||||
| `error` | Result: error | Worried | Red/trans gradient |
|
||||
|
||||
`success` and `error` are temporary states (3-second auto-revert to `idle`).
|
||||
|
||||
---
|
||||
|
||||
## Dependencies
|
||||
|
||||
### Frontend (key packages)
|
||||
|
||||
| Package | Purpose |
|
||||
| ------------------------------ | -------------------------------------------------------------- |
|
||||
| `@sveltejs/kit` `svelte` | SvelteKit framework + Svelte 5 |
|
||||
| `@tauri-apps/api` | Core Tauri IPC (`invoke`, `listen`) |
|
||||
| `@tauri-apps/plugin-*` | FS, clipboard, notifications, dialog, shell, store, os, opener |
|
||||
| `tailwindcss` v4 | Utility-first CSS |
|
||||
| `codemirror` + `@codemirror/*` | Code editor with 20+ language modes |
|
||||
| `marked` | Markdown → HTML rendering |
|
||||
| `highlight.js` | Syntax highlighting in markdown blocks |
|
||||
| `lucide-svelte` | Icon library |
|
||||
|
||||
### Backend (key crates)
|
||||
|
||||
| Crate | Purpose |
|
||||
| -------------------------------- | ---------------------------------------- |
|
||||
| `tauri` v2 | Desktop app framework |
|
||||
| `tokio` | Async runtime |
|
||||
| `serde` / `serde_json` | JSON serialisation/deserialisation |
|
||||
| `parking_lot` | Fast mutex (used for `BridgeManager`) |
|
||||
| `uuid` | Unique ID generation |
|
||||
| `discord-rich-presence` | Discord RPC integration |
|
||||
| `chrono` | Date/time handling for cost tracking |
|
||||
| `semver` | Version comparison for update checks |
|
||||
| `tempfile` | Temporary file management |
|
||||
| `tracing` + `tracing-subscriber` | Structured logging |
|
||||
| `dirs` | Cross-platform home directory resolution |
|
||||
| `windows` (Windows-only) | Native toast notifications |
|
||||
|
||||
### Dev / Tooling
|
||||
|
||||
| Tool | Purpose |
|
||||
| -------------------------------- | ----------------------------------------- |
|
||||
| `vitest` + `@vitest/coverage-v8` | Frontend unit tests with v8 coverage |
|
||||
| `@testing-library/svelte` | Component testing utilities |
|
||||
| `jsdom` | DOM environment for tests |
|
||||
| `eslint` v9 (flat config) | Linting |
|
||||
| `prettier` | Formatting |
|
||||
| `svelte-check` | TypeScript type checking for Svelte files |
|
||||
| `cargo test` + `cargo llvm-cov` | Rust unit tests and coverage |
|
||||
|
||||
---
|
||||
|
||||
## Development Notes
|
||||
|
||||
### Running the App
|
||||
|
||||
```bash
|
||||
# Frontend dev server only
|
||||
source ~/.nvm/nvm.sh && pnpm dev
|
||||
|
||||
# Full Tauri app (Rust + frontend)
|
||||
source ~/.nvm/nvm.sh && pnpm tauri dev
|
||||
```
|
||||
|
||||
### Running Tests
|
||||
|
||||
```bash
|
||||
# All checks (lint → format → type-check → frontend tests → backend tests)
|
||||
./check-all.sh
|
||||
|
||||
# Frontend tests only
|
||||
source ~/.nvm/nvm.sh && pnpm test
|
||||
|
||||
# Frontend with coverage
|
||||
source ~/.nvm/nvm.sh && pnpm test:coverage
|
||||
|
||||
# Backend tests only
|
||||
pnpm test:backend
|
||||
```
|
||||
|
||||
### Building
|
||||
|
||||
```bash
|
||||
# Linux build
|
||||
pnpm build:linux
|
||||
|
||||
# Windows cross-compile (requires cargo-xwin)
|
||||
pnpm build:windows
|
||||
```
|
||||
|
||||
### Adding a New Tauri Command
|
||||
|
||||
1. Add the handler function in the appropriate `src-tauri/src/*.rs` file with `#[tauri::command]`
|
||||
2. Register it in `lib.rs` `invoke_handler![]`
|
||||
3. Call it from the frontend via `invoke("command_name", { args })` in `src/lib/tauri.ts` or a store
|
||||
|
||||
### Adding a New Frontend Store
|
||||
|
||||
1. Create `src/lib/stores/my-store.ts` using `writable` or a factory function pattern
|
||||
2. Create `src/lib/stores/my-store.test.ts` — all stores must have tests
|
||||
3. Expose the store from the appropriate component
|
||||
|
||||
### Claude Stream-JSON Protocol
|
||||
|
||||
Claude Code is invoked with `--output-format stream-json --verbose`. See `src-tauri/src/types.rs` for the complete message type definitions. The key field distinguishing subagent messages from top-level messages is `parent_tool_use_id` on `Assistant` messages.
|
||||
|
||||
### Multi-Conversation Architecture
|
||||
|
||||
Each tab (`Conversation`) in `conversationsStore` has a unique `conversation_id` string. The backend `BridgeManager` maps these IDs to `WslBridge` instances. All Tauri events carry `conversation_id` in their payload so the frontend can route them to the correct conversation without affecting others.
|
||||
|
||||
### WSL Detection
|
||||
|
||||
`wsl_bridge.rs` detects WSL by checking `/proc/version` for "microsoft"/"wsl" strings, checking for `/proc/sys/fs/binfmt_misc/WSLInterop`, and checking `$WSL_DISTRO_NAME`. On native Windows builds, WSL detection always returns `false` (even if launched from a WSL terminal).
|
||||
|
||||
### Character State Sound Rules
|
||||
|
||||
Sound effects are managed in `src/lib/tauri.ts` per-conversation to prevent replays when switching tabs. The rules are:
|
||||
|
||||
- Entering `thinking` from a clean state (`idle`/`success`/`error`) → reset all sound flags
|
||||
- Entering `coding` or `searching` (first time per task) → play task-start sound
|
||||
- Entering `success` after ≥2 seconds in a long-running phase → play completion sound
|
||||
- Entering `error` → play error sound (always)
|
||||
- Entering `permission` → play permission sound (always)
|
||||
|
||||
### Workspace Trust Gate
|
||||
|
||||
On first connection to a new working directory, the app checks for Claude hooks and prompts the user to trust the workspace. Trusted workspaces are persisted in `HikariConfig.trusted_workspaces`.
|
||||
|
||||
### Configuration Storage
|
||||
|
||||
All settings are persisted via `tauri-plugin-store` to a JSON file in the app data directory. The frontend `configStore` (`src/lib/stores/config.ts`) loads configuration on startup and provides reactive derived stores. Changes invoke `save_config` to persist to disk.
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
# Project Overview
|
||||
|
||||
## What is this project?
|
||||
|
||||
Hikari Desktop is a Tauri-based desktop application that wraps Claude Code with a visual anime character companion (Hikari) who appears on screen. It provides a rich UI for interacting with Claude Code, including conversation management, agent monitoring, cost tracking, and more.
|
||||
|
||||
The app was inspired by a Hatsune Miku mod for the ship AI in _The Outer Worlds_ — the idea of an AI assistant with an anime girl avatar that you can actually _see_.
|
||||
|
||||
## Goals
|
||||
|
||||
- Provide a beautiful, personalised interface for Claude Code
|
||||
- Surface real-time status (thinking, typing, searching, etc.) through animated character sprites
|
||||
- Track costs, context usage, and agent activity across sessions
|
||||
- Support power-user workflows: multi-tab conversations, todo lists, git integration, MCP server management, session compaction, and more
|
||||
- Build a foundation for autonomous task execution (agent orchestration, PRD-driven workflows)
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Frontend**: Svelte 5 + TypeScript + Tailwind CSS
|
||||
- **Backend**: Rust (Tauri v2)
|
||||
- **Build**: Vite + pnpm
|
||||
- **Testing**: Vitest (frontend) + cargo test (backend)
|
||||
- **Linting**: ESLint + Prettier (frontend) + Clippy (backend)
|
||||
- **IPC**: Tauri commands + events between Rust and Svelte
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
hikari-desktop/
|
||||
├── src/ # Svelte frontend
|
||||
│ └── lib/
|
||||
│ ├── components/ # UI components (panels, modals, status bar)
|
||||
│ ├── stores/ # Svelte stores (state management)
|
||||
│ ├── types/ # TypeScript type definitions
|
||||
│ └── utils/ # Utility functions
|
||||
├── src-tauri/ # Rust backend
|
||||
│ └── src/
|
||||
│ ├── commands.rs # Tauri command handlers
|
||||
│ ├── wsl_bridge.rs # Claude Code process management
|
||||
│ ├── types.rs # Shared types & CharacterState enum
|
||||
│ └── stats.rs # Cost tracking
|
||||
└── public/ # Static assets (sprites, sounds)
|
||||
```
|
||||
|
||||
Claude Code is launched as a child process via `WslBridge`, communicating via `--output-format stream-json` (NDJSON). Messages flow from the Rust backend to the Svelte frontend via Tauri events.
|
||||
+7
-3
@@ -1,5 +1,9 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Source nvm to get access to pnpm
|
||||
export NVM_DIR="$HOME/.nvm"
|
||||
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
@@ -32,11 +36,11 @@ echo -e "${YELLOW}🔍 Running all checks for Hikari Desktop...${NC}"
|
||||
run_check "Frontend lint" "pnpm lint" || failed=1
|
||||
run_check "Frontend format check" "pnpm format:check" || failed=1
|
||||
run_check "Frontend type check" "pnpm check" || failed=1
|
||||
run_check "Frontend tests" "pnpm test" || failed=1
|
||||
run_check "Frontend tests with coverage" "pnpm test:coverage" || failed=1
|
||||
|
||||
# Backend checks
|
||||
run_check "Backend clippy (strict)" "cd src-tauri && cargo clippy --all-targets --all-features -- -D warnings" || failed=1
|
||||
run_check "Backend tests" "cargo test" || failed=1
|
||||
run_check "Backend clippy (strict)" "(cd src-tauri && cargo clippy --all-targets --all-features -- -D warnings)" || failed=1
|
||||
run_check "Backend tests with coverage" "(cd src-tauri && cargo llvm-cov --fail-under-lines 50)" || failed=1
|
||||
|
||||
# Summary
|
||||
echo -e "\n${YELLOW}========================================${NC}"
|
||||
|
||||
+63
-62
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hikari-desktop",
|
||||
"version": "1.1.1",
|
||||
"version": "1.14.0",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -27,68 +27,69 @@
|
||||
},
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "6.8.1",
|
||||
"@codemirror/lang-angular": "^0.1.4",
|
||||
"@codemirror/lang-cpp": "^6.0.3",
|
||||
"@codemirror/lang-css": "^6.3.1",
|
||||
"@codemirror/lang-go": "^6.0.1",
|
||||
"@codemirror/lang-html": "^6.4.11",
|
||||
"@codemirror/lang-java": "^6.0.2",
|
||||
"@codemirror/lang-javascript": "^6.2.4",
|
||||
"@codemirror/lang-json": "^6.0.2",
|
||||
"@codemirror/lang-less": "^6.0.2",
|
||||
"@codemirror/lang-markdown": "^6.5.0",
|
||||
"@codemirror/lang-php": "^6.0.2",
|
||||
"@codemirror/lang-python": "^6.2.1",
|
||||
"@codemirror/lang-rust": "^6.0.2",
|
||||
"@codemirror/lang-sass": "^6.0.2",
|
||||
"@codemirror/lang-sql": "^6.10.0",
|
||||
"@codemirror/lang-vue": "^0.1.3",
|
||||
"@codemirror/lang-wast": "^6.0.2",
|
||||
"@codemirror/lang-xml": "^6.1.0",
|
||||
"@codemirror/lang-yaml": "^6.1.2",
|
||||
"@codemirror/language": "^6.12.1",
|
||||
"@codemirror/legacy-modes": "^6.5.2",
|
||||
"@codemirror/state": "^6.5.4",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
"@codemirror/view": "^6.39.11",
|
||||
"@lezer/highlight": "^1.2.3",
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
|
||||
"@tauri-apps/plugin-dialog": "^2",
|
||||
"@tauri-apps/plugin-fs": "^2.4.5",
|
||||
"@tauri-apps/plugin-notification": "^2",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"@tauri-apps/plugin-os": "^2",
|
||||
"@tauri-apps/plugin-shell": "^2.3.4",
|
||||
"@tauri-apps/plugin-store": "^2",
|
||||
"codemirror": "^6.0.2",
|
||||
"highlight.js": "^11.11.1",
|
||||
"marked": "^17.0.1"
|
||||
"@codemirror/commands": "6.10.2",
|
||||
"@codemirror/lang-angular": "0.1.4",
|
||||
"@codemirror/lang-cpp": "6.0.3",
|
||||
"@codemirror/lang-css": "6.3.1",
|
||||
"@codemirror/lang-go": "6.0.1",
|
||||
"@codemirror/lang-html": "6.4.11",
|
||||
"@codemirror/lang-java": "6.0.2",
|
||||
"@codemirror/lang-javascript": "6.2.4",
|
||||
"@codemirror/lang-json": "6.0.2",
|
||||
"@codemirror/lang-less": "6.0.2",
|
||||
"@codemirror/lang-markdown": "6.5.0",
|
||||
"@codemirror/lang-php": "6.0.2",
|
||||
"@codemirror/lang-python": "6.2.1",
|
||||
"@codemirror/lang-rust": "6.0.2",
|
||||
"@codemirror/lang-sass": "6.0.2",
|
||||
"@codemirror/lang-sql": "6.10.0",
|
||||
"@codemirror/lang-vue": "0.1.3",
|
||||
"@codemirror/lang-wast": "6.0.2",
|
||||
"@codemirror/lang-xml": "6.1.0",
|
||||
"@codemirror/lang-yaml": "6.1.2",
|
||||
"@codemirror/language": "6.12.2",
|
||||
"@codemirror/legacy-modes": "6.5.2",
|
||||
"@codemirror/state": "6.5.4",
|
||||
"@codemirror/theme-one-dark": "6.1.3",
|
||||
"@codemirror/view": "6.39.15",
|
||||
"@lezer/highlight": "1.2.3",
|
||||
"@tauri-apps/api": "2.10.1",
|
||||
"@tauri-apps/plugin-clipboard-manager": "2.3.2",
|
||||
"@tauri-apps/plugin-dialog": "2.6.0",
|
||||
"@tauri-apps/plugin-fs": "2.5.0",
|
||||
"@tauri-apps/plugin-notification": "2.3.3",
|
||||
"@tauri-apps/plugin-opener": "2.5.3",
|
||||
"@tauri-apps/plugin-os": "2.3.2",
|
||||
"@tauri-apps/plugin-shell": "2.3.5",
|
||||
"@tauri-apps/plugin-store": "2.4.2",
|
||||
"codemirror": "6.0.2",
|
||||
"highlight.js": "11.11.1",
|
||||
"lucide-svelte": "0.575.0",
|
||||
"marked": "17.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@sveltejs/adapter-static": "^3.0.6",
|
||||
"@sveltejs/kit": "^2.9.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@tauri-apps/cli": "^2",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/svelte": "^5.3.1",
|
||||
"@vitest/coverage-v8": "^4.0.18",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-svelte": "^3.14.0",
|
||||
"globals": "^17.0.0",
|
||||
"jsdom": "^27.4.0",
|
||||
"prettier": "^3.8.0",
|
||||
"prettier-plugin-svelte": "^3.4.1",
|
||||
"svelte": "^5.0.0",
|
||||
"svelte-check": "^4.0.0",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"typescript": "~5.6.2",
|
||||
"typescript-eslint": "^8.53.0",
|
||||
"vite": "^6.0.3",
|
||||
"vitest": "^4.0.17"
|
||||
"@eslint/js": "9.39.3",
|
||||
"@sveltejs/adapter-static": "3.0.10",
|
||||
"@sveltejs/kit": "2.53.2",
|
||||
"@sveltejs/vite-plugin-svelte": "5.1.1",
|
||||
"@tailwindcss/vite": "4.2.1",
|
||||
"@tauri-apps/cli": "2.10.0",
|
||||
"@testing-library/jest-dom": "6.9.1",
|
||||
"@testing-library/svelte": "5.3.1",
|
||||
"@vitest/coverage-v8": "4.0.18",
|
||||
"eslint": "9.39.3",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"eslint-plugin-svelte": "3.15.0",
|
||||
"globals": "17.3.0",
|
||||
"jsdom": "28.1.0",
|
||||
"prettier": "3.8.1",
|
||||
"prettier-plugin-svelte": "3.5.0",
|
||||
"svelte": "5.53.5",
|
||||
"svelte-check": "4.4.3",
|
||||
"tailwindcss": "4.2.1",
|
||||
"typescript": "5.9.3",
|
||||
"typescript-eslint": "8.56.1",
|
||||
"vite": "6.4.1",
|
||||
"vitest": "4.0.18"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+705
-680
File diff suppressed because it is too large
Load Diff
Generated
+696
-305
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "hikari-desktop"
|
||||
version = "1.1.1"
|
||||
version = "1.14.0"
|
||||
description = "Hikari - Claude Code Visual Assistant"
|
||||
authors = ["Naomi Carrigan"]
|
||||
edition = "2021"
|
||||
@@ -31,6 +31,10 @@ tauri-plugin-fs = "2"
|
||||
tempfile = "3"
|
||||
semver = "1"
|
||||
chrono = { version = "0.4.43", features = ["serde"] }
|
||||
discord-rich-presence = "0.2"
|
||||
dirs = "5"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows = { version = "0.62", features = [
|
||||
|
||||
@@ -28,8 +28,17 @@
|
||||
"identifier": "fs:allow-write-file",
|
||||
"allow": [{ "path": "**" }]
|
||||
},
|
||||
{
|
||||
"identifier": "fs:scope",
|
||||
"allow": [{ "path": "$HOME/.claude/**" }]
|
||||
},
|
||||
{
|
||||
"identifier": "fs:allow-read-text-file",
|
||||
"allow": [{ "path": "$HOME/.claude/**" }]
|
||||
},
|
||||
"core:window:allow-set-size",
|
||||
"core:window:allow-set-always-on-top",
|
||||
"core:window:allow-inner-size"
|
||||
"core:window:allow-inner-size",
|
||||
"core:window:allow-hide"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1671,7 +1671,7 @@ pub fn check_message_achievements(
|
||||
let mut newly_unlocked = Vec::new();
|
||||
let message_lower = message.to_lowercase();
|
||||
|
||||
println!("Checking message achievements for: {}", message);
|
||||
tracing::info!("Checking message achievements for: {}", message);
|
||||
|
||||
// Relationship & Greetings
|
||||
if message_lower.contains("good morning") && progress.unlock(AchievementId::GoodMorning) {
|
||||
@@ -1863,18 +1863,18 @@ pub fn check_achievements(
|
||||
) -> Vec<AchievementId> {
|
||||
let mut newly_unlocked = Vec::new();
|
||||
|
||||
println!(
|
||||
tracing::info!(
|
||||
"Checking achievements with stats: messages={}, tokens={}, code_blocks={}",
|
||||
stats.messages_exchanged,
|
||||
stats.total_input_tokens + stats.total_output_tokens,
|
||||
stats.code_blocks_generated
|
||||
);
|
||||
println!("Currently unlocked: {:?}", progress.unlocked);
|
||||
tracing::info!("Currently unlocked: {:?}", progress.unlocked);
|
||||
|
||||
// Token milestones
|
||||
let total_tokens = stats.total_input_tokens + stats.total_output_tokens;
|
||||
if total_tokens >= 1_000 && progress.unlock(AchievementId::FirstSteps) {
|
||||
println!("Unlocked FirstSteps achievement!");
|
||||
tracing::info!("Unlocked FirstSteps achievement!");
|
||||
newly_unlocked.push(AchievementId::FirstSteps);
|
||||
}
|
||||
if total_tokens >= 10_000 && progress.unlock(AchievementId::GrowingStrong) {
|
||||
@@ -1935,6 +1935,7 @@ pub fn check_achievements(
|
||||
let search_count: u64 = search_tools
|
||||
.iter()
|
||||
.filter_map(|tool| stats.tools_usage.get(*tool))
|
||||
.map(|t| t.call_count)
|
||||
.sum();
|
||||
if search_count >= 50 && progress.unlock(AchievementId::Explorer) {
|
||||
newly_unlocked.push(AchievementId::Explorer);
|
||||
@@ -1988,25 +1989,25 @@ pub fn check_achievements(
|
||||
// TODO: Track different Claude models used
|
||||
|
||||
// Tool mastery achievements
|
||||
if let Some(bash_count) = stats.tools_usage.get("Bash") {
|
||||
if *bash_count >= 50 && progress.unlock(AchievementId::BashMaster) {
|
||||
if let Some(bash_stats) = stats.tools_usage.get("Bash") {
|
||||
if bash_stats.call_count >= 50 && progress.unlock(AchievementId::BashMaster) {
|
||||
newly_unlocked.push(AchievementId::BashMaster);
|
||||
}
|
||||
}
|
||||
if let Some(read_count) = stats.tools_usage.get("Read") {
|
||||
if *read_count >= 100 && progress.unlock(AchievementId::FileExplorer) {
|
||||
if let Some(read_stats) = stats.tools_usage.get("Read") {
|
||||
if read_stats.call_count >= 100 && progress.unlock(AchievementId::FileExplorer) {
|
||||
newly_unlocked.push(AchievementId::FileExplorer);
|
||||
}
|
||||
}
|
||||
if let Some(grep_count) = stats.tools_usage.get("Grep") {
|
||||
if *grep_count >= 50 && progress.unlock(AchievementId::SearchExpert) {
|
||||
if let Some(grep_stats) = stats.tools_usage.get("Grep") {
|
||||
if grep_stats.call_count >= 50 && progress.unlock(AchievementId::SearchExpert) {
|
||||
newly_unlocked.push(AchievementId::SearchExpert);
|
||||
}
|
||||
}
|
||||
|
||||
// Git Guru - check git command usage in Bash
|
||||
if let Some(bash_count) = stats.tools_usage.get("Bash") {
|
||||
if *bash_count >= 10 && progress.unlock(AchievementId::GitGuru) {
|
||||
if let Some(bash_stats) = stats.tools_usage.get("Bash") {
|
||||
if bash_stats.call_count >= 10 && progress.unlock(AchievementId::GitGuru) {
|
||||
// TODO: More specific git command tracking
|
||||
newly_unlocked.push(AchievementId::GitGuru);
|
||||
}
|
||||
@@ -2055,28 +2056,28 @@ pub fn check_achievements(
|
||||
}
|
||||
|
||||
// More tool mastery achievements
|
||||
if let Some(edit_count) = stats.tools_usage.get("Edit") {
|
||||
if *edit_count >= 100 && progress.unlock(AchievementId::EditMaster) {
|
||||
if let Some(edit_stats) = stats.tools_usage.get("Edit") {
|
||||
if edit_stats.call_count >= 100 && progress.unlock(AchievementId::EditMaster) {
|
||||
newly_unlocked.push(AchievementId::EditMaster);
|
||||
}
|
||||
}
|
||||
if let Some(write_count) = stats.tools_usage.get("Write") {
|
||||
if *write_count >= 50 && progress.unlock(AchievementId::WriteMaster) {
|
||||
if let Some(write_stats) = stats.tools_usage.get("Write") {
|
||||
if write_stats.call_count >= 50 && progress.unlock(AchievementId::WriteMaster) {
|
||||
newly_unlocked.push(AchievementId::WriteMaster);
|
||||
}
|
||||
}
|
||||
if let Some(glob_count) = stats.tools_usage.get("Glob") {
|
||||
if *glob_count >= 100 && progress.unlock(AchievementId::GlobMaster) {
|
||||
if let Some(glob_stats) = stats.tools_usage.get("Glob") {
|
||||
if glob_stats.call_count >= 100 && progress.unlock(AchievementId::GlobMaster) {
|
||||
newly_unlocked.push(AchievementId::GlobMaster);
|
||||
}
|
||||
}
|
||||
if let Some(task_count) = stats.tools_usage.get("Task") {
|
||||
if *task_count >= 50 && progress.unlock(AchievementId::TaskMaster) {
|
||||
if let Some(task_stats) = stats.tools_usage.get("Task") {
|
||||
if task_stats.call_count >= 50 && progress.unlock(AchievementId::TaskMaster) {
|
||||
newly_unlocked.push(AchievementId::TaskMaster);
|
||||
}
|
||||
}
|
||||
if let Some(web_count) = stats.tools_usage.get("WebFetch") {
|
||||
if *web_count >= 20 && progress.unlock(AchievementId::WebFetcher) {
|
||||
if let Some(web_stats) = stats.tools_usage.get("WebFetch") {
|
||||
if web_stats.call_count >= 20 && progress.unlock(AchievementId::WebFetcher) {
|
||||
newly_unlocked.push(AchievementId::WebFetcher);
|
||||
}
|
||||
}
|
||||
@@ -2085,7 +2086,7 @@ pub fn check_achievements(
|
||||
.tools_usage
|
||||
.iter()
|
||||
.filter(|(name, _)| name.starts_with("mcp__"))
|
||||
.map(|(_, count)| count)
|
||||
.map(|(_, tool_stats)| tool_stats.call_count)
|
||||
.sum();
|
||||
if mcp_count >= 50 && progress.unlock(AchievementId::McpExplorer) {
|
||||
newly_unlocked.push(AchievementId::McpExplorer);
|
||||
@@ -2243,7 +2244,7 @@ pub async fn save_achievements(
|
||||
// Create a serializable version with just the unlocked achievement IDs
|
||||
let unlocked_list: Vec<AchievementId> = progress.unlocked.iter().cloned().collect();
|
||||
|
||||
println!("Saving achievements: {:?}", unlocked_list);
|
||||
tracing::info!("Saving achievements: {:?}", unlocked_list);
|
||||
|
||||
store.set(
|
||||
"unlocked",
|
||||
@@ -2251,18 +2252,18 @@ pub async fn save_achievements(
|
||||
);
|
||||
store.save().map_err(|e| e.to_string())?;
|
||||
|
||||
println!("Achievements saved successfully");
|
||||
tracing::info!("Achievements saved successfully");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Load achievements from persistent store
|
||||
pub async fn load_achievements(app: &tauri::AppHandle) -> AchievementProgress {
|
||||
println!("Loading achievements from store...");
|
||||
tracing::info!("Loading achievements from store...");
|
||||
|
||||
let store = match app.store("achievements.json") {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
println!("Failed to open achievements store: {}", e);
|
||||
tracing::error!("Failed to open achievements store: {}", e);
|
||||
return AchievementProgress::new();
|
||||
}
|
||||
};
|
||||
@@ -2271,19 +2272,19 @@ pub async fn load_achievements(app: &tauri::AppHandle) -> AchievementProgress {
|
||||
|
||||
// Get unlocked achievements
|
||||
if let Some(unlocked_value) = store.get("unlocked") {
|
||||
println!("Found unlocked value in store: {:?}", unlocked_value);
|
||||
tracing::info!("Found unlocked value in store: {:?}", unlocked_value);
|
||||
if let Ok(unlocked_list) =
|
||||
serde_json::from_value::<Vec<AchievementId>>(unlocked_value.clone())
|
||||
{
|
||||
println!("Loaded {} achievements", unlocked_list.len());
|
||||
tracing::info!("Loaded {} achievements", unlocked_list.len());
|
||||
for achievement_id in unlocked_list {
|
||||
progress.unlocked.insert(achievement_id);
|
||||
}
|
||||
} else {
|
||||
println!("Failed to parse unlocked achievements");
|
||||
tracing::error!("Failed to parse unlocked achievements");
|
||||
}
|
||||
} else {
|
||||
println!("No unlocked achievements found in store");
|
||||
tracing::info!("No unlocked achievements found in store");
|
||||
}
|
||||
|
||||
progress
|
||||
@@ -2323,6 +2324,15 @@ mod tests {
|
||||
morning_sessions: 0,
|
||||
night_sessions: 0,
|
||||
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,
|
||||
current_request_input: None,
|
||||
current_request_output_chars: 0,
|
||||
current_request_thinking_chars: 0,
|
||||
current_request_tools: Vec::new(),
|
||||
achievements: AchievementProgress::new(),
|
||||
}
|
||||
}
|
||||
@@ -2733,12 +2743,21 @@ mod tests {
|
||||
// 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]
|
||||
fn test_check_achievements_first_tool() {
|
||||
let mut stats = create_test_stats();
|
||||
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);
|
||||
assert!(newly.contains(&AchievementId::FirstTool));
|
||||
@@ -2749,11 +2768,11 @@ mod tests {
|
||||
let mut stats = create_test_stats();
|
||||
let mut progress = AchievementProgress::new();
|
||||
|
||||
stats.tools_usage.insert("Read".to_string(), 1);
|
||||
stats.tools_usage.insert("Write".to_string(), 1);
|
||||
stats.tools_usage.insert("Edit".to_string(), 1);
|
||||
stats.tools_usage.insert("Bash".to_string(), 1);
|
||||
stats.tools_usage.insert("Grep".to_string(), 1);
|
||||
stats.tools_usage.insert("Read".to_string(), tool_stats(1));
|
||||
stats.tools_usage.insert("Write".to_string(), tool_stats(1));
|
||||
stats.tools_usage.insert("Edit".to_string(), tool_stats(1));
|
||||
stats.tools_usage.insert("Bash".to_string(), tool_stats(1));
|
||||
stats.tools_usage.insert("Grep".to_string(), tool_stats(1));
|
||||
|
||||
let newly = check_achievements(&stats, &mut progress);
|
||||
assert!(newly.contains(&AchievementId::Toolsmith));
|
||||
@@ -2765,7 +2784,7 @@ mod tests {
|
||||
let mut progress = AchievementProgress::new();
|
||||
|
||||
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);
|
||||
@@ -2777,7 +2796,7 @@ mod tests {
|
||||
let mut stats = create_test_stats();
|
||||
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);
|
||||
assert!(newly.contains(&AchievementId::BashMaster));
|
||||
@@ -2788,7 +2807,7 @@ mod tests {
|
||||
let mut stats = create_test_stats();
|
||||
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);
|
||||
assert!(newly.contains(&AchievementId::FileExplorer));
|
||||
@@ -2799,7 +2818,7 @@ mod tests {
|
||||
let mut stats = create_test_stats();
|
||||
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);
|
||||
assert!(newly.contains(&AchievementId::SearchExpert));
|
||||
@@ -2810,7 +2829,7 @@ mod tests {
|
||||
let mut stats = create_test_stats();
|
||||
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);
|
||||
assert!(newly.contains(&AchievementId::EditMaster));
|
||||
@@ -2821,7 +2840,7 @@ mod tests {
|
||||
let mut stats = create_test_stats();
|
||||
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);
|
||||
assert!(newly.contains(&AchievementId::WriteMaster));
|
||||
@@ -2832,7 +2851,7 @@ mod tests {
|
||||
let mut stats = create_test_stats();
|
||||
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);
|
||||
assert!(newly.contains(&AchievementId::GlobMaster));
|
||||
@@ -2843,7 +2862,7 @@ mod tests {
|
||||
let mut stats = create_test_stats();
|
||||
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);
|
||||
assert!(newly.contains(&AchievementId::TaskMaster));
|
||||
@@ -2854,7 +2873,7 @@ mod tests {
|
||||
let mut stats = create_test_stats();
|
||||
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);
|
||||
assert!(newly.contains(&AchievementId::WebFetcher));
|
||||
@@ -2865,8 +2884,8 @@ mod tests {
|
||||
let mut stats = create_test_stats();
|
||||
let mut progress = AchievementProgress::new();
|
||||
|
||||
stats.tools_usage.insert("mcp__github__create_issue".to_string(), 25);
|
||||
stats.tools_usage.insert("mcp__notion__search".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(), tool_stats(25));
|
||||
|
||||
let newly = check_achievements(&stats, &mut progress);
|
||||
assert!(newly.contains(&AchievementId::McpExplorer));
|
||||
@@ -2881,8 +2900,8 @@ mod tests {
|
||||
let mut stats = create_test_stats();
|
||||
let mut progress = AchievementProgress::new();
|
||||
|
||||
stats.tools_usage.insert("Grep".to_string(), 30);
|
||||
stats.tools_usage.insert("Glob".to_string(), 20);
|
||||
stats.tools_usage.insert("Grep".to_string(), tool_stats(30));
|
||||
stats.tools_usage.insert("Glob".to_string(), tool_stats(20));
|
||||
|
||||
let newly = check_achievements(&stats, &mut progress);
|
||||
assert!(newly.contains(&AchievementId::Explorer));
|
||||
@@ -2893,9 +2912,9 @@ mod tests {
|
||||
let mut stats = create_test_stats();
|
||||
let mut progress = AchievementProgress::new();
|
||||
|
||||
stats.tools_usage.insert("Grep".to_string(), 200);
|
||||
stats.tools_usage.insert("Glob".to_string(), 200);
|
||||
stats.tools_usage.insert("Task".to_string(), 100);
|
||||
stats.tools_usage.insert("Grep".to_string(), tool_stats(200));
|
||||
stats.tools_usage.insert("Glob".to_string(), tool_stats(200));
|
||||
stats.tools_usage.insert("Task".to_string(), tool_stats(100));
|
||||
|
||||
let newly = check_achievements(&stats, &mut progress);
|
||||
assert!(newly.contains(&AchievementId::MasterSearcher));
|
||||
|
||||
@@ -3,6 +3,7 @@ use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tauri::AppHandle;
|
||||
|
||||
use crate::commands::record_session;
|
||||
use crate::config::ClaudeStartOptions;
|
||||
use crate::stats::UsageStats;
|
||||
use crate::wsl_bridge::WslBridge;
|
||||
@@ -53,7 +54,12 @@ impl BridgeManager {
|
||||
.or_insert_with(|| WslBridge::new_with_conversation_id(conversation_id.to_string()));
|
||||
|
||||
// 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(())
|
||||
}
|
||||
@@ -167,3 +173,127 @@ pub type SharedBridgeManager = Arc<Mutex<BridgeManager>>;
|
||||
pub fn create_shared_bridge_manager() -> SharedBridgeManager {
|
||||
Arc::new(Mutex::new(BridgeManager::new()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_bridge_manager_new() {
|
||||
let manager = BridgeManager::new();
|
||||
assert!(manager.app_handle.is_none());
|
||||
assert!(manager.bridges.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bridge_manager_default() {
|
||||
let manager = BridgeManager::default();
|
||||
assert!(manager.app_handle.is_none());
|
||||
assert!(manager.bridges.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_claude_running_no_bridge() {
|
||||
let manager = BridgeManager::new();
|
||||
assert!(!manager.is_claude_running("nonexistent"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_working_directory_no_bridge() {
|
||||
let manager = BridgeManager::new();
|
||||
let result = manager.get_working_directory("nonexistent");
|
||||
assert!(result.is_err());
|
||||
assert_eq!(
|
||||
result.unwrap_err(),
|
||||
"No Claude instance found for this conversation"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_usage_stats_no_bridge() {
|
||||
let manager = BridgeManager::new();
|
||||
let result = manager.get_usage_stats("nonexistent");
|
||||
assert!(result.is_err());
|
||||
assert_eq!(
|
||||
result.unwrap_err(),
|
||||
"No Claude instance found for this conversation"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_stop_claude_no_bridge() {
|
||||
let mut manager = BridgeManager::new();
|
||||
let result = manager.stop_claude("nonexistent");
|
||||
assert!(result.is_err());
|
||||
assert_eq!(
|
||||
result.unwrap_err(),
|
||||
"No Claude instance found for this conversation"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_interrupt_claude_no_bridge() {
|
||||
let mut manager = BridgeManager::new();
|
||||
let result = manager.interrupt_claude("nonexistent");
|
||||
assert!(result.is_err());
|
||||
assert_eq!(
|
||||
result.unwrap_err(),
|
||||
"No Claude instance found for this conversation"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_send_prompt_no_bridge() {
|
||||
let mut manager = BridgeManager::new();
|
||||
let result = manager.send_prompt("nonexistent", "Hello".to_string());
|
||||
assert!(result.is_err());
|
||||
assert_eq!(
|
||||
result.unwrap_err(),
|
||||
"No Claude instance found for this conversation"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_send_tool_result_no_bridge() {
|
||||
let mut manager = BridgeManager::new();
|
||||
let result = manager.send_tool_result(
|
||||
"nonexistent",
|
||||
"tool_id",
|
||||
serde_json::json!({"result": "success"}),
|
||||
);
|
||||
assert!(result.is_err());
|
||||
assert_eq!(
|
||||
result.unwrap_err(),
|
||||
"No Claude instance found for this conversation"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_shared_bridge_manager() {
|
||||
let shared = create_shared_bridge_manager();
|
||||
let manager = shared.lock();
|
||||
assert!(manager.bridges.is_empty());
|
||||
assert!(manager.app_handle.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cleanup_stopped_bridges_empty() {
|
||||
let mut manager = BridgeManager::new();
|
||||
manager.cleanup_stopped_bridges();
|
||||
assert!(manager.bridges.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_active_conversations_empty() {
|
||||
let manager = BridgeManager::new();
|
||||
let active = manager.get_active_conversations();
|
||||
assert!(active.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_stop_all_without_app_handle() {
|
||||
let mut manager = BridgeManager::new();
|
||||
manager.stop_all(); // Should not panic
|
||||
assert!(manager.bridges.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
+2730
-62
File diff suppressed because it is too large
Load Diff
+366
-15
@@ -1,3 +1,5 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
@@ -25,9 +27,53 @@ pub struct ClaudeStartOptions {
|
||||
|
||||
#[serde(default)]
|
||||
pub resume_session_id: Option<String>,
|
||||
|
||||
#[serde(default)]
|
||||
pub use_worktree: bool,
|
||||
|
||||
#[serde(default)]
|
||||
pub disable_1m_context: bool,
|
||||
|
||||
#[serde(default)]
|
||||
pub max_output_tokens: Option<u64>,
|
||||
|
||||
#[serde(default)]
|
||||
pub disable_cron: bool,
|
||||
|
||||
#[serde(default = "default_include_git_instructions")]
|
||||
pub include_git_instructions: bool,
|
||||
|
||||
#[serde(default = "default_enable_claudeai_mcp_servers")]
|
||||
pub enable_claudeai_mcp_servers: bool,
|
||||
|
||||
#[serde(default)]
|
||||
pub auto_memory_directory: Option<String>,
|
||||
|
||||
#[serde(default)]
|
||||
pub model_overrides: Option<HashMap<String, String>>,
|
||||
|
||||
#[serde(default)]
|
||||
pub session_name: Option<String>,
|
||||
|
||||
#[serde(default)]
|
||||
pub disable_skill_shell_execution: bool,
|
||||
|
||||
/// Pass `--bare` flag to suppress UI chrome, useful for scripted headless `-p` calls (v2.1.81+).
|
||||
#[serde(default)]
|
||||
pub bare_mode: bool,
|
||||
|
||||
/// Controls `showClearContextOnPlanAccept` in `--settings` (v2.1.81+).
|
||||
/// Defaults to true (matching CLI default). Set to false to suppress the dialog.
|
||||
#[serde(default = "default_show_clear_context")]
|
||||
pub show_clear_context_on_plan_accept: bool,
|
||||
|
||||
/// Sets `ANTHROPIC_CUSTOM_MODEL_OPTION` env var for custom model providers (v2.1.81+).
|
||||
#[serde(default)]
|
||||
pub custom_model_option: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct HikariConfig {
|
||||
#[serde(default)]
|
||||
pub model: Option<String>,
|
||||
@@ -71,9 +117,6 @@ pub struct HikariConfig {
|
||||
#[serde(default = "default_font_size")]
|
||||
pub font_size: u32,
|
||||
|
||||
#[serde(default)]
|
||||
pub minimize_to_tray: bool,
|
||||
|
||||
#[serde(default)]
|
||||
pub streamer_mode: bool,
|
||||
|
||||
@@ -96,6 +139,103 @@ pub struct HikariConfig {
|
||||
// Custom theme colors
|
||||
#[serde(default)]
|
||||
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,
|
||||
|
||||
#[serde(default = "default_discord_rpc_enabled")]
|
||||
pub discord_rpc_enabled: bool,
|
||||
|
||||
#[serde(default)]
|
||||
pub use_worktree: bool,
|
||||
|
||||
#[serde(default)]
|
||||
pub disable_1m_context: bool,
|
||||
|
||||
#[serde(default)]
|
||||
pub max_output_tokens: Option<u64>,
|
||||
|
||||
#[serde(default)]
|
||||
pub trusted_workspaces: Vec<String>,
|
||||
|
||||
// Background image settings
|
||||
#[serde(default)]
|
||||
pub background_image_path: Option<String>,
|
||||
|
||||
#[serde(default = "default_background_image_opacity")]
|
||||
pub background_image_opacity: f32,
|
||||
|
||||
#[serde(default)]
|
||||
pub show_thinking_blocks: bool,
|
||||
|
||||
// Custom terminal font settings
|
||||
#[serde(default)]
|
||||
pub custom_font_path: Option<String>,
|
||||
|
||||
#[serde(default)]
|
||||
pub custom_font_family: Option<String>,
|
||||
|
||||
// Custom UI font settings
|
||||
#[serde(default)]
|
||||
pub custom_ui_font_path: Option<String>,
|
||||
|
||||
#[serde(default)]
|
||||
pub custom_ui_font_family: Option<String>,
|
||||
|
||||
// Task Loop auto-commit settings
|
||||
#[serde(default)]
|
||||
pub task_loop_auto_commit: bool,
|
||||
|
||||
#[serde(default = "default_task_loop_commit_prefix")]
|
||||
pub task_loop_commit_prefix: String,
|
||||
|
||||
#[serde(default)]
|
||||
pub task_loop_include_summary: bool,
|
||||
|
||||
#[serde(default)]
|
||||
pub disable_cron: bool,
|
||||
|
||||
#[serde(default = "default_include_git_instructions")]
|
||||
pub include_git_instructions: bool,
|
||||
|
||||
#[serde(default = "default_enable_claudeai_mcp_servers")]
|
||||
pub enable_claudeai_mcp_servers: bool,
|
||||
|
||||
#[serde(default)]
|
||||
pub auto_memory_directory: Option<String>,
|
||||
|
||||
#[serde(default)]
|
||||
pub model_overrides: Option<HashMap<String, String>>,
|
||||
|
||||
/// Prevents skill scripts from executing shell commands (Claude Code v2.1.91+).
|
||||
/// Passes `"disableSkillShellExecution": true` via the `--settings` flag.
|
||||
#[serde(default)]
|
||||
pub disable_skill_shell_execution: bool,
|
||||
|
||||
/// Pass `--bare` flag to suppress UI chrome, useful for scripted headless `-p` calls (v2.1.81+).
|
||||
#[serde(default)]
|
||||
pub bare_mode: bool,
|
||||
|
||||
/// Controls `showClearContextOnPlanAccept` in `--settings` (v2.1.81+).
|
||||
#[serde(default = "default_show_clear_context")]
|
||||
pub show_clear_context_on_plan_accept: bool,
|
||||
|
||||
/// Sets `ANTHROPIC_CUSTOM_MODEL_OPTION` env var for custom model providers (v2.1.81+).
|
||||
#[serde(default)]
|
||||
pub custom_model_option: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for HikariConfig {
|
||||
@@ -115,7 +255,6 @@ impl Default for HikariConfig {
|
||||
update_checks_enabled: true,
|
||||
character_panel_width: None,
|
||||
font_size: 14,
|
||||
minimize_to_tray: false,
|
||||
streamer_mode: false,
|
||||
streamer_hide_paths: false,
|
||||
compact_mode: false,
|
||||
@@ -123,6 +262,35 @@ impl Default for HikariConfig {
|
||||
profile_avatar_path: None,
|
||||
profile_bio: None,
|
||||
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,
|
||||
discord_rpc_enabled: true,
|
||||
use_worktree: false,
|
||||
disable_1m_context: false,
|
||||
max_output_tokens: None,
|
||||
trusted_workspaces: Vec::new(),
|
||||
background_image_path: None,
|
||||
background_image_opacity: 0.3,
|
||||
show_thinking_blocks: false,
|
||||
custom_font_path: None,
|
||||
custom_font_family: None,
|
||||
custom_ui_font_path: None,
|
||||
custom_ui_font_family: None,
|
||||
task_loop_auto_commit: false,
|
||||
task_loop_commit_prefix: "feat".to_string(),
|
||||
task_loop_include_summary: false,
|
||||
disable_cron: false,
|
||||
include_git_instructions: true,
|
||||
enable_claudeai_mcp_servers: true,
|
||||
auto_memory_directory: None,
|
||||
model_overrides: None,
|
||||
disable_skill_shell_execution: false,
|
||||
bare_mode: false,
|
||||
show_clear_context_on_plan_accept: true,
|
||||
custom_model_option: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -147,6 +315,46 @@ fn default_font_size() -> u32 {
|
||||
14
|
||||
}
|
||||
|
||||
fn default_budget_action() -> BudgetAction {
|
||||
BudgetAction::Warn
|
||||
}
|
||||
|
||||
fn default_budget_warning_threshold() -> f32 {
|
||||
0.8
|
||||
}
|
||||
|
||||
fn default_discord_rpc_enabled() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn default_background_image_opacity() -> f32 {
|
||||
0.3
|
||||
}
|
||||
|
||||
fn default_task_loop_commit_prefix() -> String {
|
||||
"feat".to_string()
|
||||
}
|
||||
|
||||
fn default_include_git_instructions() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn default_enable_claudeai_mcp_servers() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn default_show_clear_context() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum BudgetAction {
|
||||
#[default]
|
||||
Warn,
|
||||
Block,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Theme {
|
||||
@@ -156,6 +364,18 @@ pub enum Theme {
|
||||
#[serde(rename = "high-contrast")]
|
||||
HighContrast,
|
||||
Custom,
|
||||
Dracula,
|
||||
Catppuccin,
|
||||
Nord,
|
||||
Solarized,
|
||||
#[serde(rename = "solarized-light")]
|
||||
SolarizedLight,
|
||||
#[serde(rename = "catppuccin-latte")]
|
||||
CatppuccinLatte,
|
||||
#[serde(rename = "gruvbox-light")]
|
||||
GruvboxLight,
|
||||
#[serde(rename = "rose-pine-dawn")]
|
||||
RosePineDawn,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
|
||||
@@ -197,7 +417,6 @@ mod tests {
|
||||
assert!(config.update_checks_enabled);
|
||||
assert!(config.character_panel_width.is_none());
|
||||
assert_eq!(config.font_size, 14);
|
||||
assert!(!config.minimize_to_tray);
|
||||
assert!(!config.streamer_mode);
|
||||
assert!(!config.streamer_hide_paths);
|
||||
assert!(!config.compact_mode);
|
||||
@@ -205,6 +424,32 @@ mod tests {
|
||||
assert!(config.profile_avatar_path.is_none());
|
||||
assert!(config.profile_bio.is_none());
|
||||
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);
|
||||
assert!(config.discord_rpc_enabled);
|
||||
assert!(!config.use_worktree);
|
||||
assert!(!config.disable_1m_context);
|
||||
assert!(config.trusted_workspaces.is_empty());
|
||||
assert!(!config.show_thinking_blocks);
|
||||
assert!(config.custom_font_path.is_none());
|
||||
assert!(config.custom_font_family.is_none());
|
||||
assert!(config.custom_ui_font_path.is_none());
|
||||
assert!(config.custom_ui_font_family.is_none());
|
||||
assert!(!config.task_loop_auto_commit);
|
||||
assert_eq!(config.task_loop_commit_prefix, "feat");
|
||||
assert!(!config.task_loop_include_summary);
|
||||
assert!(!config.disable_cron);
|
||||
assert!(config.include_git_instructions);
|
||||
assert!(config.enable_claudeai_mcp_servers);
|
||||
assert!(config.auto_memory_directory.is_none());
|
||||
assert!(config.model_overrides.is_none());
|
||||
assert!(!config.disable_skill_shell_execution);
|
||||
assert!(!config.bare_mode);
|
||||
assert!(config.show_clear_context_on_plan_accept);
|
||||
assert!(config.custom_model_option.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -224,7 +469,6 @@ mod tests {
|
||||
update_checks_enabled: true,
|
||||
character_panel_width: Some(400),
|
||||
font_size: 16,
|
||||
minimize_to_tray: true,
|
||||
streamer_mode: false,
|
||||
streamer_hide_paths: false,
|
||||
compact_mode: false,
|
||||
@@ -232,6 +476,38 @@ mod tests {
|
||||
profile_avatar_path: None,
|
||||
profile_bio: Some("A test bio".to_string()),
|
||||
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,
|
||||
discord_rpc_enabled: true,
|
||||
use_worktree: true,
|
||||
disable_1m_context: false,
|
||||
max_output_tokens: Some(32000),
|
||||
trusted_workspaces: vec!["/home/naomi/projects/trusted".to_string()],
|
||||
background_image_path: Some("/home/naomi/bg.png".to_string()),
|
||||
background_image_opacity: 0.25,
|
||||
show_thinking_blocks: true,
|
||||
custom_font_path: Some("/home/naomi/.fonts/MyFont.ttf".to_string()),
|
||||
custom_font_family: Some("MyFont".to_string()),
|
||||
custom_ui_font_path: None,
|
||||
custom_ui_font_family: None,
|
||||
task_loop_auto_commit: true,
|
||||
task_loop_commit_prefix: "fix".to_string(),
|
||||
task_loop_include_summary: true,
|
||||
disable_cron: true,
|
||||
include_git_instructions: false,
|
||||
enable_claudeai_mcp_servers: false,
|
||||
auto_memory_directory: Some("/custom/memory".to_string()),
|
||||
model_overrides: Some(HashMap::from([(
|
||||
"claude-opus-4-6".to_string(),
|
||||
"arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-opus-4-6-v1".to_string(),
|
||||
)])),
|
||||
disable_skill_shell_execution: true,
|
||||
bare_mode: false,
|
||||
show_clear_context_on_plan_accept: true,
|
||||
custom_model_option: None,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&config).unwrap();
|
||||
@@ -246,22 +522,97 @@ mod tests {
|
||||
deserialized.greeting_custom_prompt,
|
||||
Some("Hello!".to_string())
|
||||
);
|
||||
assert!(deserialized.task_loop_auto_commit);
|
||||
assert_eq!(deserialized.task_loop_commit_prefix, "fix");
|
||||
assert!(deserialized.task_loop_include_summary);
|
||||
assert!(deserialized.disable_cron);
|
||||
assert!(!deserialized.include_git_instructions);
|
||||
assert!(!deserialized.enable_claudeai_mcp_servers);
|
||||
assert_eq!(
|
||||
deserialized.auto_memory_directory,
|
||||
Some("/custom/memory".to_string())
|
||||
);
|
||||
assert!(deserialized.model_overrides.is_some());
|
||||
let overrides = deserialized.model_overrides.unwrap();
|
||||
assert_eq!(
|
||||
overrides.get("claude-opus-4-6").map(String::as_str),
|
||||
Some("arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-opus-4-6-v1")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_theme_serialization() {
|
||||
let dark = Theme::Dark;
|
||||
let light = Theme::Light;
|
||||
let high_contrast = Theme::HighContrast;
|
||||
|
||||
assert_eq!(serde_json::to_string(&dark).unwrap(), "\"dark\"");
|
||||
assert_eq!(serde_json::to_string(&light).unwrap(), "\"light\"");
|
||||
assert_eq!(serde_json::to_string(&Theme::Dark).unwrap(), "\"dark\"");
|
||||
assert_eq!(serde_json::to_string(&Theme::Light).unwrap(), "\"light\"");
|
||||
assert_eq!(
|
||||
serde_json::to_string(&high_contrast).unwrap(),
|
||||
serde_json::to_string(&Theme::HighContrast).unwrap(),
|
||||
"\"high-contrast\""
|
||||
);
|
||||
assert_eq!(serde_json::to_string(&Theme::Custom).unwrap(), "\"custom\"");
|
||||
assert_eq!(
|
||||
serde_json::to_string(&Theme::Dracula).unwrap(),
|
||||
"\"dracula\""
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&Theme::Catppuccin).unwrap(),
|
||||
"\"catppuccin\""
|
||||
);
|
||||
assert_eq!(serde_json::to_string(&Theme::Nord).unwrap(), "\"nord\"");
|
||||
assert_eq!(
|
||||
serde_json::to_string(&Theme::Solarized).unwrap(),
|
||||
"\"solarized\""
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&Theme::SolarizedLight).unwrap(),
|
||||
"\"solarized-light\""
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&Theme::CatppuccinLatte).unwrap(),
|
||||
"\"catppuccin-latte\""
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&Theme::GruvboxLight).unwrap(),
|
||||
"\"gruvbox-light\""
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&Theme::RosePineDawn).unwrap(),
|
||||
"\"rose-pine-dawn\""
|
||||
);
|
||||
}
|
||||
|
||||
let custom = Theme::Custom;
|
||||
assert_eq!(serde_json::to_string(&custom).unwrap(), "\"custom\"");
|
||||
#[test]
|
||||
fn test_theme_deserialization() {
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Theme>("\"dracula\"").unwrap(),
|
||||
Theme::Dracula
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Theme>("\"catppuccin\"").unwrap(),
|
||||
Theme::Catppuccin
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Theme>("\"nord\"").unwrap(),
|
||||
Theme::Nord
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Theme>("\"solarized\"").unwrap(),
|
||||
Theme::Solarized
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Theme>("\"solarized-light\"").unwrap(),
|
||||
Theme::SolarizedLight
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Theme>("\"catppuccin-latte\"").unwrap(),
|
||||
Theme::CatppuccinLatte
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Theme>("\"gruvbox-light\"").unwrap(),
|
||||
Theme::GruvboxLight
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Theme>("\"rose-pine-dawn\"").unwrap(),
|
||||
Theme::RosePineDawn
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
use tauri::{AppHandle, Emitter};
|
||||
use tracing::{Level, Subscriber};
|
||||
use tracing_subscriber::layer::{Context, Layer};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DebugLogEvent {
|
||||
pub level: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct TauriLogLayer {
|
||||
app: Arc<AppHandle>,
|
||||
}
|
||||
|
||||
impl TauriLogLayer {
|
||||
pub fn new(app: AppHandle) -> Self {
|
||||
Self {
|
||||
app: Arc::new(app),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<S> Layer<S> for TauriLogLayer
|
||||
where
|
||||
S: Subscriber,
|
||||
{
|
||||
fn on_event(
|
||||
&self,
|
||||
event: &tracing::Event<'_>,
|
||||
_ctx: Context<'_, S>,
|
||||
) {
|
||||
let metadata = event.metadata();
|
||||
let level = match *metadata.level() {
|
||||
Level::ERROR => "error",
|
||||
Level::WARN => "warn",
|
||||
Level::INFO => "info",
|
||||
Level::DEBUG => "debug",
|
||||
Level::TRACE => "debug",
|
||||
};
|
||||
|
||||
// Extract message from the event
|
||||
struct MessageVisitor {
|
||||
message: String,
|
||||
}
|
||||
|
||||
impl tracing::field::Visit for MessageVisitor {
|
||||
fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
|
||||
if field.name() == "message" {
|
||||
self.message = format!("{:?}", value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut visitor = MessageVisitor {
|
||||
message: String::new(),
|
||||
};
|
||||
event.record(&mut visitor);
|
||||
|
||||
// If we couldn't extract a message, try to format the whole event
|
||||
if visitor.message.is_empty() {
|
||||
visitor.message = metadata.name().to_string();
|
||||
}
|
||||
|
||||
// Strip quotes from the message
|
||||
let message = visitor.message.trim_matches('"').to_string();
|
||||
|
||||
let log_event = DebugLogEvent {
|
||||
level: level.to_string(),
|
||||
message,
|
||||
};
|
||||
|
||||
// Emit to frontend
|
||||
let _ = self.app.emit("debug:log", log_event);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_debug_log_event_creation() {
|
||||
let event = DebugLogEvent {
|
||||
level: "info".to_string(),
|
||||
message: "Test message".to_string(),
|
||||
};
|
||||
|
||||
assert_eq!(event.level, "info");
|
||||
assert_eq!(event.message, "Test message");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_debug_log_event_serialization() {
|
||||
let event = DebugLogEvent {
|
||||
level: "error".to_string(),
|
||||
message: "Error occurred".to_string(),
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&event).unwrap();
|
||||
assert!(json.contains("\"level\":\"error\""));
|
||||
assert!(json.contains("\"message\":\"Error occurred\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_debug_log_event_deserialization() {
|
||||
let json = r#"{"level":"warn","message":"Warning message"}"#;
|
||||
let event: DebugLogEvent = serde_json::from_str(json).unwrap();
|
||||
|
||||
assert_eq!(event.level, "warn");
|
||||
assert_eq!(event.message, "Warning message");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_debug_log_event_with_special_characters() {
|
||||
let event = DebugLogEvent {
|
||||
level: "info".to_string(),
|
||||
message: "Message with \"quotes\" and \n newlines".to_string(),
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&event).unwrap();
|
||||
let decoded: DebugLogEvent = serde_json::from_str(&json).unwrap();
|
||||
|
||||
assert_eq!(decoded.level, event.level);
|
||||
assert_eq!(decoded.message, event.message);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_debug_log_event_with_unicode() {
|
||||
let event = DebugLogEvent {
|
||||
level: "debug".to_string(),
|
||||
message: "Unicode: 日本語 🎉".to_string(),
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&event).unwrap();
|
||||
let decoded: DebugLogEvent = serde_json::from_str(&json).unwrap();
|
||||
|
||||
assert_eq!(decoded.message, "Unicode: 日本語 🎉");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_debug_log_event_all_levels() {
|
||||
let levels = vec!["error", "warn", "info", "debug", "trace"];
|
||||
|
||||
for level in levels {
|
||||
let event = DebugLogEvent {
|
||||
level: level.to_string(),
|
||||
message: format!("{} level message", level),
|
||||
};
|
||||
|
||||
assert_eq!(event.level, level);
|
||||
assert!(event.message.contains(level));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
use discord_rich_presence::activity::{Activity, Assets, Timestamps};
|
||||
use discord_rich_presence::{DiscordIpc, DiscordIpcClient};
|
||||
use parking_lot::RwLock;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub struct DiscordRpcManager {
|
||||
client: Arc<RwLock<Option<DiscordIpcClient>>>,
|
||||
session_name: Arc<RwLock<String>>,
|
||||
model: Arc<RwLock<String>>,
|
||||
started_at: Arc<RwLock<i64>>,
|
||||
}
|
||||
|
||||
impl DiscordRpcManager {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
client: Arc::new(RwLock::new(None)),
|
||||
session_name: Arc::new(RwLock::new(String::new())),
|
||||
model: Arc::new(RwLock::new(String::new())),
|
||||
started_at: Arc::new(RwLock::new(0)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn init(&self, initial_session_name: String, initial_model: String, started_at: i64) -> Result<(), String> {
|
||||
tracing::debug!("Attempting to initialize Discord RPC...");
|
||||
tracing::debug!("Application ID: 1391117878182281316");
|
||||
tracing::debug!("Initial session: '{}', model: '{}', timestamp: {}",
|
||||
initial_session_name, initial_model, started_at);
|
||||
|
||||
let mut client = DiscordIpcClient::new("1391117878182281316")
|
||||
.map_err(|e| {
|
||||
let error_msg = format!("Failed to create Discord RPC client: {} (is Discord running?)", e);
|
||||
tracing::error!("{}", error_msg);
|
||||
error_msg
|
||||
})?;
|
||||
|
||||
tracing::debug!("DiscordIpcClient created successfully");
|
||||
|
||||
client
|
||||
.connect()
|
||||
.map_err(|e| {
|
||||
let error_msg = format!("Failed to connect to Discord RPC: {} (ensure Discord is running)", e);
|
||||
tracing::error!("{}", error_msg);
|
||||
error_msg
|
||||
})?;
|
||||
|
||||
tracing::debug!("Connected to Discord IPC socket");
|
||||
|
||||
// Set initial activity immediately after connecting
|
||||
tracing::debug!("Building initial activity...");
|
||||
let state_text = format!("Model: {}", initial_model);
|
||||
let assets = Assets::new()
|
||||
.large_image("hikari")
|
||||
.large_text("Hikari - Claude Code Assistant");
|
||||
|
||||
tracing::debug!("Assets created - large_image: 'hikari', large_text: 'Hikari - Claude Code Assistant'");
|
||||
|
||||
let timestamps = Timestamps::new()
|
||||
.start(started_at);
|
||||
|
||||
tracing::debug!("Timestamps created - start: {}", started_at);
|
||||
|
||||
let activity = Activity::new()
|
||||
.details(initial_session_name.as_str())
|
||||
.state(state_text.as_str())
|
||||
.assets(assets)
|
||||
.timestamps(timestamps);
|
||||
|
||||
tracing::debug!("Activity created - details: '{}', state: '{}'",
|
||||
initial_session_name, state_text);
|
||||
|
||||
tracing::debug!("Attempting to set initial activity...");
|
||||
client
|
||||
.set_activity(activity)
|
||||
.map_err(|e| {
|
||||
let error_msg = format!("Failed to set initial Discord RPC activity: {}", e);
|
||||
tracing::error!("{}", error_msg);
|
||||
error_msg
|
||||
})?;
|
||||
|
||||
tracing::debug!("Initial activity set successfully!");
|
||||
|
||||
// Store the client and initial state
|
||||
*self.client.write() = Some(client);
|
||||
*self.session_name.write() = initial_session_name.clone();
|
||||
*self.model.write() = initial_model.clone();
|
||||
*self.started_at.write() = started_at;
|
||||
|
||||
tracing::info!("Discord RPC connected successfully with initial activity: session='{}', model='{}'",
|
||||
initial_session_name, initial_model);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn update(
|
||||
&self,
|
||||
session_name: String,
|
||||
model: String,
|
||||
started_at: i64,
|
||||
) -> Result<(), String> {
|
||||
tracing::debug!("update() called with session='{}', model='{}', timestamp={}",
|
||||
session_name, model, started_at);
|
||||
|
||||
*self.session_name.write() = session_name.clone();
|
||||
*self.model.write() = model.clone();
|
||||
*self.started_at.write() = started_at;
|
||||
|
||||
tracing::debug!("State variables updated");
|
||||
|
||||
let mut client_guard = self.client.write();
|
||||
let client = client_guard
|
||||
.as_mut()
|
||||
.ok_or_else(|| {
|
||||
let error_msg = "Discord RPC client not initialized".to_string();
|
||||
tracing::error!("{}", error_msg);
|
||||
error_msg
|
||||
})?;
|
||||
|
||||
tracing::debug!("Client lock acquired");
|
||||
|
||||
let state_text = format!("Model: {}", model);
|
||||
let assets = Assets::new()
|
||||
.large_image("hikari")
|
||||
.large_text("Hikari - Claude Code Assistant");
|
||||
|
||||
tracing::debug!("Assets created - large_image: 'hikari', large_text: 'Hikari - Claude Code Assistant'");
|
||||
|
||||
let timestamps = Timestamps::new()
|
||||
.start(started_at);
|
||||
|
||||
tracing::debug!("Timestamps created - start: {}", started_at);
|
||||
|
||||
let activity = Activity::new()
|
||||
.details(session_name.as_str())
|
||||
.state(state_text.as_str())
|
||||
.assets(assets)
|
||||
.timestamps(timestamps);
|
||||
|
||||
tracing::debug!("Activity created - details: '{}', state: '{}'",
|
||||
session_name, state_text);
|
||||
|
||||
tracing::debug!("Attempting to set activity...");
|
||||
client
|
||||
.set_activity(activity)
|
||||
.map_err(|e| {
|
||||
let error_msg = format!("Failed to update Discord RPC: {}", e);
|
||||
tracing::error!("{}", error_msg);
|
||||
error_msg
|
||||
})?;
|
||||
|
||||
tracing::info!("Updated Discord RPC: session='{}', model='{}'", session_name, model);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn stop(&self) -> Result<(), String> {
|
||||
tracing::debug!("stop() called");
|
||||
|
||||
let mut client_guard = self.client.write();
|
||||
if let Some(mut client) = client_guard.take() {
|
||||
tracing::debug!("Client found, attempting to close...");
|
||||
client
|
||||
.close()
|
||||
.map_err(|e| {
|
||||
let error_msg = format!("Failed to close Discord RPC: {}", e);
|
||||
tracing::error!("{}", error_msg);
|
||||
error_msg
|
||||
})?;
|
||||
tracing::info!("Discord RPC stopped successfully");
|
||||
} else {
|
||||
tracing::debug!("No client to stop (already stopped or never initialized)");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for DiscordRpcManager {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
use chrono::Utc;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::AppHandle;
|
||||
use tauri_plugin_store::StoreExt;
|
||||
use uuid::Uuid;
|
||||
|
||||
const DRAFTS_STORE_FILE: &str = "hikari-drafts.json";
|
||||
const DRAFTS_STORE_KEY: &str = "drafts";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Draft {
|
||||
pub id: String,
|
||||
pub content: String,
|
||||
pub saved_at: String,
|
||||
}
|
||||
|
||||
fn load_all_drafts(app: &AppHandle) -> Result<Vec<Draft>, String> {
|
||||
let store = app
|
||||
.store(DRAFTS_STORE_FILE)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
match store.get(DRAFTS_STORE_KEY) {
|
||||
Some(value) => serde_json::from_value(value.clone()).map_err(|e| e.to_string()),
|
||||
None => Ok(vec![]),
|
||||
}
|
||||
}
|
||||
|
||||
fn save_all_drafts(app: &AppHandle, drafts: &[Draft]) -> Result<(), String> {
|
||||
let store = app
|
||||
.store(DRAFTS_STORE_FILE)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let value = serde_json::to_value(drafts).map_err(|e| e.to_string())?;
|
||||
store.set(DRAFTS_STORE_KEY, value);
|
||||
store.save().map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_drafts(app: AppHandle) -> Result<Vec<Draft>, String> {
|
||||
let mut drafts = load_all_drafts(&app)?;
|
||||
// Sort newest first — ISO 8601 timestamps sort lexicographically
|
||||
drafts.sort_by(|a, b| b.saved_at.cmp(&a.saved_at));
|
||||
Ok(drafts)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn save_draft(app: AppHandle, content: String) -> Result<Draft, String> {
|
||||
let mut drafts = load_all_drafts(&app)?;
|
||||
|
||||
let draft = Draft {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
content,
|
||||
saved_at: Utc::now().to_rfc3339(),
|
||||
};
|
||||
|
||||
drafts.push(draft.clone());
|
||||
save_all_drafts(&app, &drafts)?;
|
||||
|
||||
Ok(draft)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn delete_draft(app: AppHandle, draft_id: String) -> Result<(), String> {
|
||||
let mut drafts = load_all_drafts(&app)?;
|
||||
drafts.retain(|d| d.id != draft_id);
|
||||
save_all_drafts(&app, &drafts)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn delete_all_drafts(app: AppHandle) -> Result<(), String> {
|
||||
save_all_drafts(&app, &[])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn make_draft(id: &str, content: &str, saved_at: &str) -> Draft {
|
||||
Draft {
|
||||
id: id.to_string(),
|
||||
content: content.to_string(),
|
||||
saved_at: saved_at.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_draft_serialization() {
|
||||
let draft = make_draft("test-id", "Hello world", "2026-01-01T00:00:00+00:00");
|
||||
let json = serde_json::to_string(&draft).expect("Failed to serialize");
|
||||
let parsed: Draft = serde_json::from_str(&json).expect("Failed to deserialize");
|
||||
|
||||
assert_eq!(parsed.id, draft.id);
|
||||
assert_eq!(parsed.content, draft.content);
|
||||
assert_eq!(parsed.saved_at, draft.saved_at);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_draft_clone() {
|
||||
let original = make_draft("clone-id", "Clone me", "2026-01-01T00:00:00+00:00");
|
||||
let cloned = original.clone();
|
||||
|
||||
assert_eq!(original.id, cloned.id);
|
||||
assert_eq!(original.content, cloned.content);
|
||||
assert_eq!(original.saved_at, cloned.saved_at);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sort_newest_first() {
|
||||
let mut drafts = [
|
||||
make_draft("a", "First", "2026-01-01T00:00:00+00:00"),
|
||||
make_draft("b", "Third", "2026-01-03T00:00:00+00:00"),
|
||||
make_draft("c", "Second", "2026-01-02T00:00:00+00:00"),
|
||||
];
|
||||
|
||||
drafts.sort_by(|a, b| b.saved_at.cmp(&a.saved_at));
|
||||
|
||||
assert_eq!(drafts[0].id, "b");
|
||||
assert_eq!(drafts[1].id, "c");
|
||||
assert_eq!(drafts[2].id, "a");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_retain_excludes_deleted() {
|
||||
let mut drafts = vec![
|
||||
make_draft("keep-1", "Keep me", "2026-01-01T00:00:00+00:00"),
|
||||
make_draft("delete-me", "Delete me", "2026-01-02T00:00:00+00:00"),
|
||||
make_draft("keep-2", "Keep me too", "2026-01-03T00:00:00+00:00"),
|
||||
];
|
||||
|
||||
let target_id = "delete-me".to_string();
|
||||
drafts.retain(|d| d.id != target_id);
|
||||
|
||||
assert_eq!(drafts.len(), 2);
|
||||
assert!(drafts.iter().all(|d| d.id != "delete-me"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_by_id() {
|
||||
let drafts = [
|
||||
make_draft("draft-1", "First draft", "2026-01-01T00:00:00+00:00"),
|
||||
make_draft("draft-2", "Second draft", "2026-01-02T00:00:00+00:00"),
|
||||
make_draft("draft-3", "Third draft", "2026-01-03T00:00:00+00:00"),
|
||||
];
|
||||
|
||||
let found = drafts.iter().find(|d| d.id == "draft-2");
|
||||
assert!(found.is_some());
|
||||
assert_eq!(found.unwrap().content, "Second draft");
|
||||
|
||||
let not_found = drafts.iter().find(|d| d.id == "draft-999");
|
||||
assert!(not_found.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_multiline_content() {
|
||||
let content = "Line 1\nLine 2\nLine 3";
|
||||
let draft = make_draft("multi", content, "2026-01-01T00:00:00+00:00");
|
||||
|
||||
assert!(draft.content.contains('\n'));
|
||||
assert_eq!(draft.content.split('\n').count(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_after_delete_all() {
|
||||
let mut drafts = vec![
|
||||
make_draft("a", "A", "2026-01-01T00:00:00+00:00"),
|
||||
make_draft("b", "B", "2026-01-02T00:00:00+00:00"),
|
||||
];
|
||||
|
||||
drafts.clear();
|
||||
|
||||
assert!(drafts.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_uuid_format() {
|
||||
// UUIDs should be non-empty and contain hyphens
|
||||
let id = Uuid::new_v4().to_string();
|
||||
assert!(!id.is_empty());
|
||||
assert!(id.contains('-'));
|
||||
assert_eq!(id.len(), 36);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_timestamp_is_rfc3339() {
|
||||
let ts = Utc::now().to_rfc3339();
|
||||
// RFC 3339 timestamps contain T and + or Z
|
||||
assert!(ts.contains('T'));
|
||||
assert!(ts.ends_with("+00:00") || ts.ends_with('Z'));
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::process::Command;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
use crate::process_ext::HideWindow;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GitStatus {
|
||||
pub is_repo: bool,
|
||||
@@ -35,7 +38,37 @@ pub struct GitLogEntry {
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// Builds the WSL argument list for running a git command at a Linux path.
|
||||
/// Extracted for testability without requiring WSL to be available.
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
fn build_wsl_git_args<'a>(working_dir: &'a str, args: &[&'a str]) -> Vec<&'a str> {
|
||||
let mut wsl_args = vec!["--", "git", "-C", working_dir];
|
||||
wsl_args.extend_from_slice(args);
|
||||
wsl_args
|
||||
}
|
||||
|
||||
fn run_git_command(working_dir: &str, args: &[&str]) -> Result<String, String> {
|
||||
#[cfg(target_os = "windows")]
|
||||
let output = {
|
||||
if working_dir.starts_with('/') {
|
||||
// WSL/Linux path — run git through WSL so it can resolve the path correctly.
|
||||
let wsl_args = build_wsl_git_args(working_dir, args);
|
||||
Command::new("wsl")
|
||||
.hide_window()
|
||||
.args(&wsl_args)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to execute git via WSL: {}", e))?
|
||||
} else {
|
||||
Command::new("git")
|
||||
.hide_window()
|
||||
.args(args)
|
||||
.current_dir(working_dir)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to execute git: {}", e))?
|
||||
}
|
||||
};
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let output = Command::new("git")
|
||||
.args(args)
|
||||
.current_dir(working_dir)
|
||||
@@ -294,6 +327,26 @@ mod tests {
|
||||
use std::io::Write;
|
||||
use tempfile::TempDir;
|
||||
|
||||
// ==================== build_wsl_git_args tests ====================
|
||||
|
||||
#[test]
|
||||
fn test_build_wsl_git_args_structure() {
|
||||
let args = build_wsl_git_args("/home/naomi/code/project", &["status", "--porcelain=v1"]);
|
||||
assert_eq!(args[0], "--");
|
||||
assert_eq!(args[1], "git");
|
||||
assert_eq!(args[2], "-C");
|
||||
assert_eq!(args[3], "/home/naomi/code/project");
|
||||
assert_eq!(args[4], "status");
|
||||
assert_eq!(args[5], "--porcelain=v1");
|
||||
assert_eq!(args.len(), 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_wsl_git_args_no_extra_args() {
|
||||
let args = build_wsl_git_args("/home/user/repo", &["init"]);
|
||||
assert_eq!(args, vec!["--", "git", "-C", "/home/user/repo", "init"]);
|
||||
}
|
||||
|
||||
// Helper to create a git repository in a temp directory
|
||||
fn create_test_repo() -> TempDir {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
|
||||
+77
-10
@@ -3,13 +3,19 @@ mod bridge_manager;
|
||||
mod clipboard;
|
||||
mod commands;
|
||||
mod config;
|
||||
mod cost_tracking;
|
||||
mod debug_logger;
|
||||
mod discord_rpc;
|
||||
mod drafts;
|
||||
mod git;
|
||||
mod notifications;
|
||||
mod process_ext;
|
||||
mod quick_actions;
|
||||
mod sessions;
|
||||
mod snippets;
|
||||
mod stats;
|
||||
mod temp_manager;
|
||||
mod tool_cache;
|
||||
mod tray;
|
||||
mod types;
|
||||
mod vbs_notification;
|
||||
@@ -21,14 +27,20 @@ use bridge_manager::create_shared_bridge_manager;
|
||||
use clipboard::*;
|
||||
use commands::load_saved_achievements;
|
||||
use commands::*;
|
||||
use debug_logger::TauriLogLayer;
|
||||
use discord_rpc::DiscordRpcManager;
|
||||
use drafts::*;
|
||||
use git::*;
|
||||
use notifications::*;
|
||||
use quick_actions::*;
|
||||
use sessions::*;
|
||||
use snippets::*;
|
||||
use tauri::Manager;
|
||||
use std::sync::Arc;
|
||||
use tauri::{Emitter, Manager};
|
||||
use temp_manager::create_shared_temp_manager;
|
||||
use tray::{setup_tray, should_minimize_to_tray};
|
||||
use tracing_subscriber::layer::SubscriberExt;
|
||||
use tracing_subscriber::util::SubscriberInitExt;
|
||||
use tray::setup_tray;
|
||||
use vbs_notification::*;
|
||||
use windows_toast::*;
|
||||
use wsl_notifications::*;
|
||||
@@ -37,6 +49,7 @@ use wsl_notifications::*;
|
||||
pub fn run() {
|
||||
let bridge_manager = create_shared_bridge_manager();
|
||||
let temp_manager = create_shared_temp_manager().expect("Failed to create temp file manager");
|
||||
let discord_rpc = Arc::new(DiscordRpcManager::new());
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
@@ -50,33 +63,45 @@ pub fn run() {
|
||||
.plugin(tauri_plugin_fs::init())
|
||||
.manage(bridge_manager.clone())
|
||||
.manage(temp_manager.clone())
|
||||
.manage(discord_rpc.clone())
|
||||
.setup(move |app| {
|
||||
// Initialize tracing with custom layer that emits to frontend
|
||||
// NOTE: We don't use fmt::layer() because in production builds with windows_subsystem = "windows",
|
||||
// stdout is hidden. Instead, all logs go through TauriLogLayer to the debug console.
|
||||
let tauri_layer = TauriLogLayer::new(app.handle().clone());
|
||||
tracing_subscriber::registry()
|
||||
.with(tauri_layer)
|
||||
.init();
|
||||
|
||||
// Initialize the app handle in the bridge manager
|
||||
bridge_manager.lock().set_app_handle(app.handle().clone());
|
||||
|
||||
// Clean up any orphaned temp files from previous sessions
|
||||
if let Ok(count) = temp_manager.lock().cleanup_orphaned_files() {
|
||||
if count > 0 {
|
||||
println!("Cleaned up {} orphaned temp files", count);
|
||||
tracing::info!("Cleaned up {} orphaned temp files", count);
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!("Hikari Desktop started successfully");
|
||||
|
||||
// Set up system tray
|
||||
if let Err(e) = setup_tray(app.handle()) {
|
||||
eprintln!("Failed to set up system tray: {}", e);
|
||||
tracing::error!("Failed to set up system tray: {}", e);
|
||||
}
|
||||
|
||||
// Handle window close event for minimize to tray
|
||||
// Handle window close event for minimize to tray and close confirmation
|
||||
let main_window = app.get_webview_window("main").unwrap();
|
||||
main_window.on_window_event({
|
||||
let app_handle = app.handle().clone();
|
||||
move |event| {
|
||||
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
|
||||
if should_minimize_to_tray(&app_handle) {
|
||||
api.prevent_close();
|
||||
if let Some(window) = app_handle.get_webview_window("main") {
|
||||
let _ = window.hide();
|
||||
}
|
||||
// Always prevent default close - let frontend handle it
|
||||
api.prevent_close();
|
||||
|
||||
// Emit event to frontend to show confirmation modal
|
||||
if let Some(window) = app_handle.get_webview_window("main") {
|
||||
let _ = window.emit("window-close-requested", ());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -98,6 +123,7 @@ pub fn run() {
|
||||
get_persisted_stats,
|
||||
load_saved_achievements,
|
||||
answer_question,
|
||||
check_workspace_hooks,
|
||||
send_windows_notification,
|
||||
send_simple_notification,
|
||||
send_windows_toast,
|
||||
@@ -107,6 +133,8 @@ pub fn run() {
|
||||
validate_directory,
|
||||
list_skills,
|
||||
check_for_updates,
|
||||
fetch_changelog,
|
||||
check_cli_latest_version,
|
||||
save_temp_file,
|
||||
register_temp_file,
|
||||
get_temp_files,
|
||||
@@ -159,6 +187,45 @@ pub fn run() {
|
||||
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,
|
||||
init_discord_rpc,
|
||||
update_discord_rpc,
|
||||
stop_discord_rpc,
|
||||
close_application,
|
||||
list_memory_files,
|
||||
get_claude_version,
|
||||
get_auth_status,
|
||||
auth_login,
|
||||
auth_logout,
|
||||
list_plugins,
|
||||
install_plugin,
|
||||
uninstall_plugin,
|
||||
enable_plugin,
|
||||
disable_plugin,
|
||||
update_plugin,
|
||||
list_marketplaces,
|
||||
add_marketplace,
|
||||
remove_marketplace,
|
||||
list_mcp_servers,
|
||||
get_mcp_server,
|
||||
remove_mcp_server,
|
||||
add_mcp_server,
|
||||
get_mcp_server_details,
|
||||
list_drafts,
|
||||
save_draft,
|
||||
delete_draft,
|
||||
delete_all_drafts,
|
||||
scan_project,
|
||||
open_binary_file,
|
||||
get_global_claude_md,
|
||||
save_global_claude_md,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
+324
-29
@@ -1,34 +1,11 @@
|
||||
use std::process::Command;
|
||||
use tauri::command;
|
||||
|
||||
#[command]
|
||||
pub async fn send_notify_send(title: String, body: String) -> Result<(), String> {
|
||||
// Use notify-send for Linux/WSL
|
||||
let output = Command::new("notify-send")
|
||||
.arg(&title)
|
||||
.arg(&body)
|
||||
.arg("--urgency=normal")
|
||||
.arg("--app-name=Hikari Desktop")
|
||||
.output()
|
||||
.map_err(|e| {
|
||||
format!(
|
||||
"Failed to execute notify-send: {}. Make sure libnotify-bin is installed.",
|
||||
e
|
||||
)
|
||||
})?;
|
||||
use crate::process_ext::HideWindow;
|
||||
|
||||
if !output.status.success() {
|
||||
let error = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!("notify-send failed: {}", error));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn send_windows_notification(title: String, body: String) -> Result<(), String> {
|
||||
// Create PowerShell script for Windows Toast Notification
|
||||
let ps_script = format!(
|
||||
/// Generate PowerShell script for Windows Toast Notification
|
||||
fn generate_powershell_toast_script(title: &str, body: &str) -> String {
|
||||
format!(
|
||||
r#"
|
||||
[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] > $null
|
||||
[Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] > $null
|
||||
@@ -55,10 +32,87 @@ $toast = New-Object Windows.UI.Notifications.ToastNotification $xml
|
||||
"#,
|
||||
title.replace("\"", "`\""),
|
||||
body.replace("\"", "`\"")
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
/// Format simple notification message
|
||||
fn format_simple_notification(title: &str, body: &str) -> String {
|
||||
format!("{}\n\n{}", title, body)
|
||||
}
|
||||
|
||||
/// Build notify-send command for testing (doesn't execute)
|
||||
#[cfg(test)]
|
||||
fn build_notify_send_command(title: &str, body: &str) -> (String, Vec<String>) {
|
||||
(
|
||||
"notify-send".to_string(),
|
||||
vec![
|
||||
title.to_string(),
|
||||
body.to_string(),
|
||||
"--urgency=normal".to_string(),
|
||||
"--app-name=Hikari Desktop".to_string(),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
/// Build Windows PowerShell command for testing (doesn't execute)
|
||||
#[cfg(test)]
|
||||
fn build_windows_powershell_command(title: &str, body: &str) -> (String, Vec<String>) {
|
||||
let script = generate_powershell_toast_script(title, body);
|
||||
(
|
||||
"pwsh.exe".to_string(),
|
||||
vec![
|
||||
"-NoProfile".to_string(),
|
||||
"-WindowStyle".to_string(),
|
||||
"Hidden".to_string(),
|
||||
"-Command".to_string(),
|
||||
script,
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
/// Build simple notification command for testing (doesn't execute)
|
||||
#[cfg(test)]
|
||||
fn build_simple_notification_command(title: &str, body: &str) -> (String, Vec<String>) {
|
||||
let message = format_simple_notification(title, body);
|
||||
(
|
||||
"cmd.exe".to_string(),
|
||||
vec!["/c".to_string(), "msg".to_string(), "*".to_string(), message],
|
||||
)
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn send_notify_send(title: String, body: String) -> Result<(), String> {
|
||||
// Use notify-send for Linux/WSL
|
||||
let output = Command::new("notify-send")
|
||||
.hide_window()
|
||||
.arg(&title)
|
||||
.arg(&body)
|
||||
.arg("--urgency=normal")
|
||||
.arg("--app-name=Hikari Desktop")
|
||||
.output()
|
||||
.map_err(|e| {
|
||||
format!(
|
||||
"Failed to execute notify-send: {}. Make sure libnotify-bin is installed.",
|
||||
e
|
||||
)
|
||||
})?;
|
||||
|
||||
if !output.status.success() {
|
||||
let error = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!("notify-send failed: {}", error));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn send_windows_notification(title: String, body: String) -> Result<(), String> {
|
||||
// Create PowerShell script for Windows Toast Notification
|
||||
let ps_script = generate_powershell_toast_script(&title, &body);
|
||||
|
||||
// Try PowerShell Core first (pwsh), then fall back to Windows PowerShell
|
||||
let output = Command::new("pwsh.exe")
|
||||
.hide_window()
|
||||
.arg("-NoProfile")
|
||||
.arg("-WindowStyle")
|
||||
.arg("Hidden")
|
||||
@@ -67,6 +121,7 @@ $toast = New-Object Windows.UI.Notifications.ToastNotification $xml
|
||||
.output()
|
||||
.or_else(|_| {
|
||||
Command::new("powershell.exe")
|
||||
.hide_window()
|
||||
.arg("-NoProfile")
|
||||
.arg("-WindowStyle")
|
||||
.arg("Hidden")
|
||||
@@ -87,9 +142,10 @@ $toast = New-Object Windows.UI.Notifications.ToastNotification $xml
|
||||
// Alternative: Use Windows built-in MSG command for simple notifications
|
||||
#[command]
|
||||
pub async fn send_simple_notification(title: String, body: String) -> Result<(), String> {
|
||||
let message = format!("{}\n\n{}", title, body);
|
||||
let message = format_simple_notification(&title, &body);
|
||||
|
||||
Command::new("cmd.exe")
|
||||
.hide_window()
|
||||
.arg("/c")
|
||||
.arg("msg")
|
||||
.arg("*")
|
||||
@@ -99,3 +155,242 @@ pub async fn send_simple_notification(title: String, body: String) -> Result<(),
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_generate_powershell_toast_script_basic() {
|
||||
let script = generate_powershell_toast_script("Title", "Body");
|
||||
|
||||
assert!(script.contains("Hikari Desktop"));
|
||||
assert!(script.contains("Title"));
|
||||
assert!(script.contains("Body"));
|
||||
assert!(script.contains("ToastNotification"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_powershell_toast_script_escapes_quotes() {
|
||||
let script = generate_powershell_toast_script("Title with \"quotes\"", "Body with \"quotes\"");
|
||||
|
||||
// Quotes should be escaped as `" in PowerShell
|
||||
assert!(script.contains("Title with `\"quotes`\""));
|
||||
assert!(script.contains("Body with `\"quotes`\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_powershell_toast_script_with_special_chars() {
|
||||
let script = generate_powershell_toast_script("Title: Test", "Body\nwith\nnewlines");
|
||||
|
||||
assert!(script.contains("Title: Test"));
|
||||
assert!(script.contains("Body\nwith\nnewlines"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_powershell_toast_script_unicode() {
|
||||
let script = generate_powershell_toast_script("日本語 Title", "Unicode: 🎉");
|
||||
|
||||
assert!(script.contains("日本語 Title"));
|
||||
assert!(script.contains("Unicode: 🎉"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_powershell_toast_script_empty() {
|
||||
let script = generate_powershell_toast_script("", "");
|
||||
|
||||
// Should still contain the structure
|
||||
assert!(script.contains("Hikari Desktop"));
|
||||
assert!(script.contains("ToastNotification"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_simple_notification_basic() {
|
||||
let message = format_simple_notification("Title", "Body");
|
||||
|
||||
assert_eq!(message, "Title\n\nBody");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_simple_notification_with_newlines() {
|
||||
let message = format_simple_notification("Multi\nLine\nTitle", "Multi\nLine\nBody");
|
||||
|
||||
assert!(message.contains("Multi\nLine\nTitle"));
|
||||
assert!(message.contains("\n\n"));
|
||||
assert!(message.contains("Multi\nLine\nBody"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_simple_notification_unicode() {
|
||||
let message = format_simple_notification("日本語", "🎉 Unicode");
|
||||
|
||||
assert_eq!(message, "日本語\n\n🎉 Unicode");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_simple_notification_empty() {
|
||||
let message = format_simple_notification("", "");
|
||||
|
||||
assert_eq!(message, "\n\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_simple_notification_long_text() {
|
||||
let long_title = "A".repeat(1000);
|
||||
let long_body = "B".repeat(1000);
|
||||
let message = format_simple_notification(&long_title, &long_body);
|
||||
|
||||
assert!(message.starts_with(&long_title));
|
||||
assert!(message.ends_with(&long_body));
|
||||
assert!(message.contains("\n\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_powershell_toast_script_multiple_quotes() {
|
||||
let script = generate_powershell_toast_script(
|
||||
"\"Quoted\" \"Multiple\" \"Times\"",
|
||||
"\"More\" \"Quotes\" \"Here\""
|
||||
);
|
||||
|
||||
// Each quote should be escaped
|
||||
assert!(script.contains("`\"Quoted`\" `\"Multiple`\" `\"Times`\""));
|
||||
assert!(script.contains("`\"More`\" `\"Quotes`\" `\"Here`\""));
|
||||
}
|
||||
|
||||
// E2E Integration Tests - Command Structure Verification
|
||||
|
||||
#[test]
|
||||
fn test_e2e_notify_send_command_structure() {
|
||||
let (command, args) = build_notify_send_command("Test Title", "Test Body");
|
||||
|
||||
assert_eq!(command, "notify-send");
|
||||
assert_eq!(args.len(), 4);
|
||||
assert_eq!(args[0], "Test Title");
|
||||
assert_eq!(args[1], "Test Body");
|
||||
assert_eq!(args[2], "--urgency=normal");
|
||||
assert_eq!(args[3], "--app-name=Hikari Desktop");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_e2e_notify_send_with_special_chars() {
|
||||
let (command, args) =
|
||||
build_notify_send_command("Title with \"quotes\"", "Body\nwith\nnewlines");
|
||||
|
||||
assert_eq!(command, "notify-send");
|
||||
assert_eq!(args[0], "Title with \"quotes\"");
|
||||
assert_eq!(args[1], "Body\nwith\nnewlines");
|
||||
// notify-send handles these directly
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_e2e_windows_powershell_command_structure() {
|
||||
let (command, args) = build_windows_powershell_command("Test Title", "Test Body");
|
||||
|
||||
assert_eq!(command, "pwsh.exe");
|
||||
assert_eq!(args.len(), 5);
|
||||
assert_eq!(args[0], "-NoProfile");
|
||||
assert_eq!(args[1], "-WindowStyle");
|
||||
assert_eq!(args[2], "Hidden");
|
||||
assert_eq!(args[3], "-Command");
|
||||
|
||||
// Verify the script in args[4] contains expected elements
|
||||
let script = &args[4];
|
||||
assert!(script.contains("Test Title"));
|
||||
assert!(script.contains("Test Body"));
|
||||
assert!(script.contains("Hikari Desktop"));
|
||||
assert!(script.contains("ToastNotification"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_e2e_windows_powershell_quote_escaping() {
|
||||
let (_, args) =
|
||||
build_windows_powershell_command("Title with \"quotes\"", "Body with \"quotes\"");
|
||||
|
||||
let script = &args[4];
|
||||
// Verify quotes are properly escaped in the PowerShell script
|
||||
assert!(script.contains("Title with `\"quotes`\""));
|
||||
assert!(script.contains("Body with `\"quotes`\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_e2e_simple_notification_command_structure() {
|
||||
let (command, args) = build_simple_notification_command("Test Title", "Test Body");
|
||||
|
||||
assert_eq!(command, "cmd.exe");
|
||||
assert_eq!(args.len(), 4);
|
||||
assert_eq!(args[0], "/c");
|
||||
assert_eq!(args[1], "msg");
|
||||
assert_eq!(args[2], "*");
|
||||
assert_eq!(args[3], "Test Title\n\nTest Body");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_e2e_simple_notification_multiline() {
|
||||
let (_, args) =
|
||||
build_simple_notification_command("Multi\nLine\nTitle", "Multi\nLine\nBody");
|
||||
|
||||
let message = &args[3];
|
||||
assert!(message.contains("Multi\nLine\nTitle"));
|
||||
assert!(message.contains("\n\n"));
|
||||
assert!(message.contains("Multi\nLine\nBody"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_e2e_command_consistency_across_platforms() {
|
||||
// Test that different platforms use consistent parameters
|
||||
let title = "Consistency Test";
|
||||
let body = "Testing cross-platform consistency";
|
||||
|
||||
// Linux command
|
||||
let (notify_cmd, notify_args) = build_notify_send_command(title, body);
|
||||
assert!(notify_cmd.contains("notify"));
|
||||
assert!(notify_args.iter().any(|arg| arg.contains("Hikari Desktop")));
|
||||
|
||||
// Windows PowerShell command
|
||||
let (ps_cmd, ps_args) = build_windows_powershell_command(title, body);
|
||||
assert!(ps_cmd.contains("pwsh") || ps_cmd.contains("powershell"));
|
||||
let ps_script = &ps_args[4];
|
||||
assert!(ps_script.contains("Hikari Desktop"));
|
||||
|
||||
// Windows simple command
|
||||
let (msg_cmd, msg_args) = build_simple_notification_command(title, body);
|
||||
assert!(msg_cmd.contains("cmd"));
|
||||
assert!(msg_args[3].contains(title));
|
||||
assert!(msg_args[3].contains(body));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_e2e_unicode_support_across_platforms() {
|
||||
let title = "日本語 Title";
|
||||
let body = "Unicode: 🎉";
|
||||
|
||||
// Verify all platforms preserve unicode
|
||||
let (_, notify_args) = build_notify_send_command(title, body);
|
||||
assert_eq!(notify_args[0], title);
|
||||
assert_eq!(notify_args[1], body);
|
||||
|
||||
let (_, ps_args) = build_windows_powershell_command(title, body);
|
||||
let ps_script = &ps_args[4];
|
||||
assert!(ps_script.contains(title));
|
||||
assert!(ps_script.contains(body));
|
||||
|
||||
let (_, msg_args) = build_simple_notification_command(title, body);
|
||||
assert!(msg_args[3].contains(title));
|
||||
assert!(msg_args[3].contains(body));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_e2e_empty_input_handling() {
|
||||
// Test that empty inputs are handled gracefully
|
||||
let (_, notify_args) = build_notify_send_command("", "");
|
||||
assert_eq!(notify_args[0], "");
|
||||
assert_eq!(notify_args[1], "");
|
||||
|
||||
let (_, ps_args) = build_windows_powershell_command("", "");
|
||||
let ps_script = &ps_args[4];
|
||||
assert!(ps_script.contains("Hikari Desktop")); // Still has app name
|
||||
|
||||
let (_, msg_args) = build_simple_notification_command("", "");
|
||||
assert_eq!(msg_args[3], "\n\n");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
use std::process::Command;
|
||||
|
||||
/// Extension trait for `Command` that hides the console window on Windows.
|
||||
///
|
||||
/// On non-Windows platforms this is a no-op, so callers can unconditionally
|
||||
/// chain `.hide_window()` without any `#[cfg]` guards at the call sites.
|
||||
pub trait HideWindow {
|
||||
fn hide_window(&mut self) -> &mut Self;
|
||||
}
|
||||
|
||||
impl HideWindow for Command {
|
||||
fn hide_window(&mut self) -> &mut Self {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||
self.creation_flags(CREATE_NO_WINDOW);
|
||||
}
|
||||
self
|
||||
}
|
||||
}
|
||||
+738
-49
@@ -5,6 +5,113 @@ use std::collections::HashMap;
|
||||
use std::time::Instant;
|
||||
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.6 family
|
||||
"claude-opus-4-6" => 200_000,
|
||||
"claude-sonnet-4-6" => 1_000_000, // 1M token context window
|
||||
// Claude 4.5 family - 200K standard context
|
||||
"claude-opus-4-5-20251101"
|
||||
| "claude-sonnet-4-5-20250929"
|
||||
| "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)]
|
||||
pub struct UsageStats {
|
||||
pub total_input_tokens: u64,
|
||||
@@ -24,8 +131,8 @@ pub struct UsageStats {
|
||||
pub session_files_edited: u64,
|
||||
pub files_created: u64,
|
||||
pub session_files_created: u64,
|
||||
pub tools_usage: HashMap<String, u64>,
|
||||
pub session_tools_usage: HashMap<String, u64>,
|
||||
pub tools_usage: HashMap<String, ToolTokenStats>,
|
||||
pub session_tools_usage: HashMap<String, ToolTokenStats>,
|
||||
pub session_duration_seconds: u64,
|
||||
#[serde(skip)]
|
||||
pub session_start: Option<Instant>,
|
||||
@@ -38,9 +145,28 @@ pub struct UsageStats {
|
||||
pub night_sessions: u64, // Sessions started after 10 PM
|
||||
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
|
||||
#[serde(skip)]
|
||||
pub achievements: AchievementProgress,
|
||||
|
||||
// Track current in-flight request for cost estimation on interrupt
|
||||
#[serde(skip)]
|
||||
pub current_request_input: Option<String>,
|
||||
#[serde(skip)]
|
||||
pub current_request_output_chars: u64,
|
||||
#[serde(skip)]
|
||||
pub current_request_thinking_chars: u64,
|
||||
#[serde(skip)]
|
||||
pub current_request_tools: Vec<String>,
|
||||
}
|
||||
|
||||
impl UsageStats {
|
||||
@@ -50,17 +176,138 @@ impl UsageStats {
|
||||
stats
|
||||
}
|
||||
|
||||
pub fn add_usage(&mut self, input_tokens: u64, output_tokens: u64, model: &str) {
|
||||
pub fn add_usage(
|
||||
&mut self,
|
||||
input_tokens: u64,
|
||||
output_tokens: u64,
|
||||
model: &str,
|
||||
cache_creation_tokens: Option<u64>,
|
||||
cache_read_tokens: Option<u64>,
|
||||
) {
|
||||
self.total_input_tokens += input_tokens;
|
||||
self.total_output_tokens += output_tokens;
|
||||
self.session_input_tokens += input_tokens;
|
||||
self.session_output_tokens += output_tokens;
|
||||
|
||||
let cost = calculate_cost(input_tokens, output_tokens, model);
|
||||
let cost = calculate_cost(
|
||||
input_tokens,
|
||||
output_tokens,
|
||||
model,
|
||||
cache_creation_tokens,
|
||||
cache_read_tokens,
|
||||
);
|
||||
self.total_cost_usd += cost;
|
||||
self.session_cost_usd += cost;
|
||||
|
||||
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) {
|
||||
@@ -76,6 +323,13 @@ impl UsageStats {
|
||||
self.session_start = Some(Instant::now());
|
||||
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
|
||||
self.track_session_start();
|
||||
}
|
||||
@@ -139,11 +393,32 @@ impl UsageStats {
|
||||
}
|
||||
|
||||
pub fn increment_tool_usage(&mut self, tool_name: &str) {
|
||||
*self.tools_usage.entry(tool_name.to_string()).or_insert(0) += 1;
|
||||
*self
|
||||
.session_tools_usage
|
||||
self.tools_usage
|
||||
.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 {
|
||||
@@ -184,7 +459,16 @@ impl UsageStats {
|
||||
morning_sessions: self.morning_sessions,
|
||||
night_sessions: self.night_sessions,
|
||||
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
|
||||
current_request_input: None, // Don't copy tracking fields
|
||||
current_request_output_chars: 0,
|
||||
current_request_thinking_chars: 0,
|
||||
current_request_tools: Vec::new(),
|
||||
};
|
||||
check_achievements(&stats_copy, &mut self.achievements)
|
||||
}
|
||||
@@ -206,20 +490,33 @@ fn is_consecutive_day(prev_date: &str, current_date: &str) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
// Pricing as of January 2025
|
||||
// https://www.anthropic.com/pricing
|
||||
fn calculate_cost(input_tokens: u64, output_tokens: u64, model: &str) -> f64 {
|
||||
// Pricing as of February 2026
|
||||
// https://platform.claude.com/docs/en/about-claude/models/overview
|
||||
// Cache pricing: https://platform.claude.com/docs/en/build-with-claude/prompt-caching
|
||||
pub fn calculate_cost(
|
||||
input_tokens: u64,
|
||||
output_tokens: u64,
|
||||
model: &str,
|
||||
cache_creation_tokens: Option<u64>,
|
||||
cache_read_tokens: Option<u64>,
|
||||
) -> f64 {
|
||||
let (input_price_per_million, output_price_per_million) = match model {
|
||||
// Opus 4.5
|
||||
"claude-opus-4-5-20251101" => (15.0, 75.0),
|
||||
// Current generation (Claude 4.6)
|
||||
"claude-opus-4-6" => (5.0, 25.0),
|
||||
"claude-sonnet-4-6" => (3.0, 15.0),
|
||||
|
||||
// Opus 4
|
||||
// Previous generation (Claude 4.5)
|
||||
"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),
|
||||
|
||||
// Previous generation (Claude 4.x)
|
||||
"claude-opus-4-1-20250805" => (15.0, 75.0),
|
||||
"claude-opus-4-20250514" => (15.0, 75.0),
|
||||
|
||||
// Sonnet 4
|
||||
"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-20240620" => (3.0, 15.0),
|
||||
"claude-3-5-haiku-20241022" => (1.0, 5.0),
|
||||
@@ -231,10 +528,25 @@ fn calculate_cost(input_tokens: u64, output_tokens: u64, model: &str) -> f64 {
|
||||
_ => (3.0, 15.0),
|
||||
};
|
||||
|
||||
// Regular input/output tokens
|
||||
let input_cost = (input_tokens as f64 / 1_000_000.0) * input_price_per_million;
|
||||
let output_cost = (output_tokens as f64 / 1_000_000.0) * output_price_per_million;
|
||||
|
||||
input_cost + output_cost
|
||||
// Cache write tokens (cache creation) cost 1.25x the base input price
|
||||
let cache_write_cost = if let Some(cache_creation) = cache_creation_tokens {
|
||||
(cache_creation as f64 / 1_000_000.0) * input_price_per_million * 1.25
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
// Cache read tokens cost 0.1x (10%) the base input price
|
||||
let cache_read_cost = if let Some(cache_read) = cache_read_tokens {
|
||||
(cache_read as f64 / 1_000_000.0) * input_price_per_million * 0.1
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
input_cost + output_cost + cache_write_cost + cache_read_cost
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -252,7 +564,7 @@ pub struct PersistedStats {
|
||||
pub code_blocks_generated: u64,
|
||||
pub files_edited: u64,
|
||||
pub files_created: u64,
|
||||
pub tools_usage: HashMap<String, u64>,
|
||||
pub tools_usage: HashMap<String, ToolTokenStats>,
|
||||
pub sessions_started: u64,
|
||||
pub consecutive_days: u64,
|
||||
pub total_days_used: u64,
|
||||
@@ -308,7 +620,7 @@ pub async fn save_stats(app: &tauri::AppHandle, stats: &UsageStats) -> Result<()
|
||||
|
||||
let persisted = PersistedStats::from(stats);
|
||||
|
||||
println!("Saving stats: {:?}", persisted);
|
||||
tracing::info!("Saving stats: {:?}", persisted);
|
||||
|
||||
store.set(
|
||||
"lifetime_stats",
|
||||
@@ -316,32 +628,32 @@ pub async fn save_stats(app: &tauri::AppHandle, stats: &UsageStats) -> Result<()
|
||||
);
|
||||
store.save().map_err(|e| e.to_string())?;
|
||||
|
||||
println!("Stats saved successfully");
|
||||
tracing::info!("Stats saved successfully");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load lifetime stats from persistent store
|
||||
pub async fn load_stats(app: &tauri::AppHandle) -> Option<PersistedStats> {
|
||||
println!("Loading stats from store...");
|
||||
tracing::info!("Loading stats from store...");
|
||||
|
||||
let store = match app.store("stats.json") {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
println!("Failed to open stats store: {}", e);
|
||||
tracing::error!("Failed to open stats store: {}", e);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(stats_value) = store.get("lifetime_stats") {
|
||||
println!("Found lifetime stats in store: {:?}", stats_value);
|
||||
tracing::info!("Found lifetime stats in store: {:?}", stats_value);
|
||||
if let Ok(persisted) = serde_json::from_value::<PersistedStats>(stats_value.clone()) {
|
||||
println!("Loaded lifetime stats successfully");
|
||||
tracing::info!("Loaded lifetime stats successfully");
|
||||
return Some(persisted);
|
||||
} else {
|
||||
println!("Failed to parse lifetime stats");
|
||||
tracing::error!("Failed to parse lifetime stats");
|
||||
}
|
||||
} else {
|
||||
println!("No lifetime stats found in store");
|
||||
tracing::info!("No lifetime stats found in store");
|
||||
}
|
||||
|
||||
None
|
||||
@@ -353,7 +665,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_cost_calculation_sonnet() {
|
||||
let cost = calculate_cost(1000, 2000, "claude-sonnet-4-20250514");
|
||||
let cost = calculate_cost(1000, 2000, "claude-sonnet-4-20250514", None, None);
|
||||
// 1000 input * $3/M = $0.003
|
||||
// 2000 output * $15/M = $0.030
|
||||
// Total = $0.033
|
||||
@@ -362,7 +674,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_cost_calculation_opus() {
|
||||
let cost = calculate_cost(1000, 2000, "claude-opus-4-20250514");
|
||||
let cost = calculate_cost(1000, 2000, "claude-opus-4-20250514", None, None);
|
||||
// 1000 input * $15/M = $0.015
|
||||
// 2000 output * $75/M = $0.150
|
||||
// Total = $0.165
|
||||
@@ -371,14 +683,16 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_cost_calculation_opus_45() {
|
||||
let cost = calculate_cost(1000, 2000, "claude-opus-4-5-20251101");
|
||||
// Same pricing as Opus 4
|
||||
assert!((cost - 0.165).abs() < 0.0001);
|
||||
let cost = calculate_cost(1000, 2000, "claude-opus-4-5-20251101", None, None);
|
||||
// Opus 4.5 pricing: $5/MTok input, $25/MTok output
|
||||
// 1000 input tokens = $0.005, 2000 output tokens = $0.05
|
||||
// Total = $0.055
|
||||
assert!((cost - 0.055).abs() < 0.0001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cost_calculation_haiku() {
|
||||
let cost = calculate_cost(1000, 2000, "claude-3-5-haiku-20241022");
|
||||
let cost = calculate_cost(1000, 2000, "claude-3-5-haiku-20241022", None, None);
|
||||
// 1000 input * $1/M = $0.001
|
||||
// 2000 output * $5/M = $0.010
|
||||
// Total = $0.011
|
||||
@@ -387,14 +701,14 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_cost_calculation_unknown_defaults_to_sonnet() {
|
||||
let cost = calculate_cost(1000, 2000, "some-unknown-model");
|
||||
let cost = calculate_cost(1000, 2000, "some-unknown-model", None, None);
|
||||
// Should default to Sonnet pricing
|
||||
assert!((cost - 0.033).abs() < 0.0001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cost_calculation_legacy_sonnet() {
|
||||
let cost = calculate_cost(1000, 2000, "claude-3-5-sonnet-20241022");
|
||||
let cost = calculate_cost(1000, 2000, "claude-3-5-sonnet-20241022", None, None);
|
||||
// Same as Sonnet 4 pricing
|
||||
assert!((cost - 0.033).abs() < 0.0001);
|
||||
}
|
||||
@@ -402,7 +716,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_usage_stats_accumulation() {
|
||||
let mut stats = UsageStats::new();
|
||||
stats.add_usage(1000, 2000, "claude-sonnet-4-20250514");
|
||||
stats.add_usage(1000, 2000, "claude-sonnet-4-20250514", None, None);
|
||||
|
||||
assert_eq!(stats.total_input_tokens, 1000);
|
||||
assert_eq!(stats.total_output_tokens, 2000);
|
||||
@@ -414,8 +728,8 @@ mod tests {
|
||||
#[test]
|
||||
fn test_usage_stats_multiple_accumulations() {
|
||||
let mut stats = UsageStats::new();
|
||||
stats.add_usage(1000, 1000, "claude-sonnet-4-20250514");
|
||||
stats.add_usage(500, 500, "claude-sonnet-4-20250514");
|
||||
stats.add_usage(1000, 1000, "claude-sonnet-4-20250514", None, None);
|
||||
stats.add_usage(500, 500, "claude-sonnet-4-20250514", None, None);
|
||||
|
||||
assert_eq!(stats.total_input_tokens, 1500);
|
||||
assert_eq!(stats.total_output_tokens, 1500);
|
||||
@@ -426,17 +740,17 @@ mod tests {
|
||||
#[test]
|
||||
fn test_usage_stats_model_updated() {
|
||||
let mut stats = UsageStats::new();
|
||||
stats.add_usage(1000, 1000, "claude-sonnet-4-20250514");
|
||||
stats.add_usage(1000, 1000, "claude-sonnet-4-20250514", None, None);
|
||||
assert_eq!(stats.model, Some("claude-sonnet-4-20250514".to_string()));
|
||||
|
||||
stats.add_usage(500, 500, "claude-opus-4-20250514");
|
||||
stats.add_usage(500, 500, "claude-opus-4-20250514", None, None);
|
||||
assert_eq!(stats.model, Some("claude-opus-4-20250514".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_reset() {
|
||||
let mut stats = UsageStats::new();
|
||||
stats.add_usage(1000, 2000, "claude-sonnet-4-20250514");
|
||||
stats.add_usage(1000, 2000, "claude-sonnet-4-20250514", None, None);
|
||||
stats.reset_session();
|
||||
|
||||
assert_eq!(stats.total_input_tokens, 1000);
|
||||
@@ -512,10 +826,33 @@ mod tests {
|
||||
stats.increment_tool_usage("Read");
|
||||
stats.increment_tool_usage("Write");
|
||||
|
||||
assert_eq!(stats.tools_usage.get("Read"), Some(&2));
|
||||
assert_eq!(stats.tools_usage.get("Write"), Some(&1));
|
||||
assert_eq!(stats.session_tools_usage.get("Read"), Some(&2));
|
||||
assert_eq!(stats.session_tools_usage.get("Write"), Some(&1));
|
||||
assert_eq!(stats.tools_usage.get("Read").map(|t| t.call_count), Some(2));
|
||||
assert_eq!(stats.tools_usage.get("Write").map(|t| t.call_count), Some(1));
|
||||
assert_eq!(stats.session_tools_usage.get("Read").map(|t| t.call_count), Some(2));
|
||||
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]
|
||||
@@ -590,7 +927,11 @@ mod tests {
|
||||
files_created: 5,
|
||||
tools_usage: {
|
||||
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
|
||||
},
|
||||
sessions_started: 10,
|
||||
@@ -608,7 +949,8 @@ mod tests {
|
||||
assert_eq!(stats.total_output_tokens, 20000);
|
||||
assert_eq!(stats.total_cost_usd, 5.50);
|
||||
assert_eq!(stats.messages_exchanged, 100);
|
||||
assert_eq!(stats.tools_usage.get("Read"), Some(&50));
|
||||
assert_eq!(stats.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.morning_sessions, 3);
|
||||
assert_eq!(stats.last_session_date, Some("2024-06-15".to_string()));
|
||||
@@ -635,7 +977,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_usage_stats_serialization() {
|
||||
let mut stats = UsageStats::new();
|
||||
stats.add_usage(1000, 2000, "claude-sonnet-4-20250514");
|
||||
stats.add_usage(1000, 2000, "claude-sonnet-4-20250514", None, None);
|
||||
stats.increment_messages();
|
||||
|
||||
// UsageStats should be serializable (for events)
|
||||
@@ -664,7 +1006,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_stats_update_event_serialization() {
|
||||
let mut stats = UsageStats::new();
|
||||
stats.add_usage(100, 200, "claude-sonnet-4-20250514");
|
||||
stats.add_usage(100, 200, "claude-sonnet-4-20250514", None, None);
|
||||
|
||||
let event = StatsUpdateEvent { stats };
|
||||
let json = serde_json::to_string(&event).expect("Failed to serialize");
|
||||
@@ -672,4 +1014,351 @@ mod tests {
|
||||
assert!(json.contains("stats"));
|
||||
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", None, None);
|
||||
|
||||
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", None, None);
|
||||
stats.add_usage(50_000, 10_000, "claude-sonnet-4-20250514", None, None);
|
||||
|
||||
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", None, None);
|
||||
|
||||
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\"");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,8 +77,8 @@ impl TempFileManager {
|
||||
for file_path in files {
|
||||
if file_path.exists() {
|
||||
if let Err(e) = fs::remove_file(&file_path) {
|
||||
eprintln!(
|
||||
"Warning: Failed to remove temp file {:?}: {}",
|
||||
tracing::warn!(
|
||||
"Failed to remove temp file {:?}: {}",
|
||||
file_path, e
|
||||
);
|
||||
}
|
||||
@@ -115,7 +115,7 @@ impl TempFileManager {
|
||||
let path = entry.path();
|
||||
if path.is_file() && !tracked_files.contains(&path) {
|
||||
if let Err(e) = fs::remove_file(&path) {
|
||||
eprintln!("Warning: Failed to remove orphaned file {:?}: {}", path, e);
|
||||
tracing::warn!("Failed to remove orphaned file {:?}: {}", path, e);
|
||||
} else {
|
||||
cleaned_count += 1;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,6 @@ use tauri::{
|
||||
AppHandle, Manager,
|
||||
};
|
||||
|
||||
use crate::config::HikariConfig;
|
||||
|
||||
pub fn setup_tray(app: &AppHandle) -> tauri::Result<()> {
|
||||
let show_item = MenuItem::with_id(app, "show", "Show Hikari", true, None::<&str>)?;
|
||||
let quit_item = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?;
|
||||
@@ -48,21 +46,3 @@ pub fn setup_tray(app: &AppHandle) -> tauri::Result<()> {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn should_minimize_to_tray(app: &AppHandle) -> bool {
|
||||
let config_path = app
|
||||
.path()
|
||||
.app_config_dir()
|
||||
.ok()
|
||||
.map(|p| p.join("hikari-config.json"));
|
||||
|
||||
if let Some(path) = config_path {
|
||||
if let Ok(content) = std::fs::read_to_string(&path) {
|
||||
if let Ok(config) = serde_json::from_str::<HikariConfig>(&content) {
|
||||
return config.minimize_to_tray;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
+626
-1
@@ -4,6 +4,10 @@ use serde::{Deserialize, Serialize};
|
||||
pub struct UsageInfo {
|
||||
pub input_tokens: u64,
|
||||
pub output_tokens: u64,
|
||||
#[serde(default)]
|
||||
pub cache_creation_input_tokens: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub cache_read_input_tokens: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||
@@ -59,6 +63,26 @@ pub struct PermissionDenial {
|
||||
pub tool_input: serde_json::Value,
|
||||
}
|
||||
|
||||
/// Rate limit information from a `rate_limit_event` message.
|
||||
/// All fields are optional to ensure forward-compatibility as the Claude CLI evolves.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct RateLimitInfo {
|
||||
#[serde(default)]
|
||||
pub requests_limit: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub requests_remaining: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub requests_reset: Option<String>,
|
||||
#[serde(default)]
|
||||
pub tokens_limit: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub tokens_remaining: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub tokens_reset: Option<String>,
|
||||
#[serde(default)]
|
||||
pub retry_after_ms: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum ClaudeMessage {
|
||||
@@ -71,6 +95,9 @@ pub enum ClaudeMessage {
|
||||
cwd: Option<String>,
|
||||
#[serde(default)]
|
||||
tools: Option<Vec<String>>,
|
||||
/// Output style hint from Claude Code (v2.1.81+). Informational only.
|
||||
#[serde(default)]
|
||||
output_style: Option<String>,
|
||||
},
|
||||
#[serde(rename = "assistant")]
|
||||
Assistant {
|
||||
@@ -95,6 +122,20 @@ pub enum ClaudeMessage {
|
||||
permission_denials: Option<Vec<PermissionDenial>>,
|
||||
#[serde(default)]
|
||||
usage: Option<UsageInfo>,
|
||||
/// Fast mode state from Claude Code v2.1.81+. Values: "default" | "enabled" | "disabled".
|
||||
#[serde(default)]
|
||||
fast_mode_state: Option<String>,
|
||||
/// Per-model usage breakdown from Claude Code v2.1.81+.
|
||||
#[serde(default)]
|
||||
model_usage: Option<serde_json::Value>,
|
||||
/// Authoritative total cost in USD reported by Claude Code v2.1.81+.
|
||||
#[serde(default)]
|
||||
total_cost_usd: Option<f64>,
|
||||
},
|
||||
#[serde(rename = "rate_limit_event")]
|
||||
RateLimitEvent {
|
||||
#[serde(default)]
|
||||
rate_limit_info: RateLimitInfo,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -176,6 +217,14 @@ pub struct StateChangeEvent {
|
||||
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)]
|
||||
pub struct OutputEvent {
|
||||
pub line_type: String,
|
||||
@@ -183,14 +232,23 @@ pub struct OutputEvent {
|
||||
pub tool_name: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub conversation_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cost: Option<MessageCost>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub parent_tool_use_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PermissionPromptEvent {
|
||||
pub struct PermissionPromptEventItem {
|
||||
pub id: String,
|
||||
pub tool_name: String,
|
||||
pub tool_input: serde_json::Value,
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PermissionPromptEvent {
|
||||
pub permissions: Vec<PermissionPromptEventItem>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub conversation_id: Option<String>,
|
||||
}
|
||||
@@ -234,6 +292,144 @@ pub struct UserQuestionEvent {
|
||||
pub conversation_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ElicitationEvent {
|
||||
pub message: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub server_name: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub request_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub conversation_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ElicitationResultEvent {
|
||||
pub action: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub request_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub conversation_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StopFailureEvent {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub stop_reason: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error_type: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub conversation_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PostCompactEvent {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub session_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub conversation_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CwdChangedEvent {
|
||||
pub cwd: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub conversation_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FileChangedEvent {
|
||||
pub file: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub conversation_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TaskCreatedEvent {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub task_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub parent_tool_use_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub conversation_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PermissionDeniedEvent {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_name: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub reason: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub conversation_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AgentStartEvent {
|
||||
pub tool_use_id: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub agent_id: Option<String>,
|
||||
pub description: String,
|
||||
pub subagent_type: String,
|
||||
pub started_at: u64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub conversation_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub parent_tool_use_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub model: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WorktreeInfo {
|
||||
pub name: String,
|
||||
pub path: String,
|
||||
pub branch: String,
|
||||
pub original_repo_directory: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WorktreeEvent {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub conversation_id: Option<String>,
|
||||
/// "create" or "remove"
|
||||
pub event_type: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub worktree: Option<WorktreeInfo>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AgentEndEvent {
|
||||
pub tool_use_id: String,
|
||||
pub ended_at: u64,
|
||||
pub is_error: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub conversation_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub duration_ms: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub num_turns: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub last_assistant_message: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TodoItem {
|
||||
pub content: String,
|
||||
pub status: String, // "pending", "in_progress", or "completed"
|
||||
#[serde(rename = "activeForm")]
|
||||
pub active_form: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TodoUpdateEvent {
|
||||
pub todos: Vec<TodoItem>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub conversation_id: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -354,10 +550,439 @@ mod tests {
|
||||
content: "Test output".to_string(),
|
||||
tool_name: None,
|
||||
conversation_id: None,
|
||||
cost: None,
|
||||
parent_tool_use_id: None,
|
||||
};
|
||||
|
||||
let serialized = serde_json::to_string(&event).unwrap();
|
||||
assert!(serialized.contains("\"line_type\":\"assistant\""));
|
||||
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,
|
||||
}),
|
||||
parent_tool_use_id: None,
|
||||
};
|
||||
|
||||
let serialized = serde_json::to_string(&event).unwrap();
|
||||
assert!(serialized.contains("\"cost\":"));
|
||||
assert!(serialized.contains("\"input_tokens\":100"));
|
||||
assert!(serialized.contains("\"output_tokens\":50"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rate_limit_info_default() {
|
||||
let info = RateLimitInfo::default();
|
||||
assert!(info.requests_limit.is_none());
|
||||
assert!(info.requests_remaining.is_none());
|
||||
assert!(info.requests_reset.is_none());
|
||||
assert!(info.tokens_limit.is_none());
|
||||
assert!(info.tokens_remaining.is_none());
|
||||
assert!(info.tokens_reset.is_none());
|
||||
assert!(info.retry_after_ms.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rate_limit_event_deserialization_empty_info() {
|
||||
let json = r#"{"type":"rate_limit_event","rate_limit_info":{}}"#;
|
||||
let msg: ClaudeMessage = serde_json::from_str(json).unwrap();
|
||||
assert!(matches!(msg, ClaudeMessage::RateLimitEvent { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rate_limit_event_deserialization_no_info() {
|
||||
// rate_limit_info field is optional via #[serde(default)]
|
||||
let json = r#"{"type":"rate_limit_event"}"#;
|
||||
let msg: ClaudeMessage = serde_json::from_str(json).unwrap();
|
||||
assert!(matches!(msg, ClaudeMessage::RateLimitEvent { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rate_limit_event_deserialization_with_data() {
|
||||
let json = r#"{
|
||||
"type": "rate_limit_event",
|
||||
"rate_limit_info": {
|
||||
"requests_limit": 1000,
|
||||
"requests_remaining": 0,
|
||||
"requests_reset": "2024-01-01T00:01:00Z",
|
||||
"tokens_limit": 50000,
|
||||
"tokens_remaining": 0,
|
||||
"tokens_reset": "2024-01-01T00:01:00Z",
|
||||
"retry_after_ms": 60000
|
||||
}
|
||||
}"#;
|
||||
let msg: ClaudeMessage = serde_json::from_str(json).unwrap();
|
||||
if let ClaudeMessage::RateLimitEvent { rate_limit_info } = msg {
|
||||
assert_eq!(rate_limit_info.requests_limit, Some(1000));
|
||||
assert_eq!(rate_limit_info.requests_remaining, Some(0));
|
||||
assert_eq!(
|
||||
rate_limit_info.requests_reset,
|
||||
Some("2024-01-01T00:01:00Z".to_string())
|
||||
);
|
||||
assert_eq!(rate_limit_info.retry_after_ms, Some(60000));
|
||||
} else {
|
||||
panic!("Expected RateLimitEvent variant");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rate_limit_event_ignores_unknown_fields() {
|
||||
// Ensures forward-compat: unknown fields in rate_limit_info are silently ignored
|
||||
let json = r#"{
|
||||
"type": "rate_limit_event",
|
||||
"rate_limit_info": {
|
||||
"requests_remaining": 0,
|
||||
"some_future_field": "some_value"
|
||||
}
|
||||
}"#;
|
||||
let msg: ClaudeMessage = serde_json::from_str(json).unwrap();
|
||||
if let ClaudeMessage::RateLimitEvent { rate_limit_info } = msg {
|
||||
assert_eq!(rate_limit_info.requests_remaining, Some(0));
|
||||
} else {
|
||||
panic!("Expected RateLimitEvent variant");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_elicitation_event_serialization() {
|
||||
let event = ElicitationEvent {
|
||||
message: "Please provide the API endpoint".to_string(),
|
||||
server_name: Some("my-server".to_string()),
|
||||
request_id: Some("req-123".to_string()),
|
||||
conversation_id: Some("conv-abc".to_string()),
|
||||
};
|
||||
|
||||
let serialized = serde_json::to_string(&event).unwrap();
|
||||
assert!(serialized.contains("\"message\":\"Please provide the API endpoint\""));
|
||||
assert!(serialized.contains("\"server_name\":\"my-server\""));
|
||||
assert!(serialized.contains("\"request_id\":\"req-123\""));
|
||||
assert!(serialized.contains("\"conversation_id\":\"conv-abc\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_elicitation_event_omits_none_fields() {
|
||||
let event = ElicitationEvent {
|
||||
message: "Enter your token".to_string(),
|
||||
server_name: None,
|
||||
request_id: None,
|
||||
conversation_id: None,
|
||||
};
|
||||
|
||||
let serialized = serde_json::to_string(&event).unwrap();
|
||||
assert!(serialized.contains("\"message\":\"Enter your token\""));
|
||||
assert!(!serialized.contains("server_name"));
|
||||
assert!(!serialized.contains("request_id"));
|
||||
assert!(!serialized.contains("conversation_id"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_elicitation_result_event_serialization() {
|
||||
let event = ElicitationResultEvent {
|
||||
action: "accept".to_string(),
|
||||
request_id: Some("req-123".to_string()),
|
||||
conversation_id: Some("conv-abc".to_string()),
|
||||
};
|
||||
|
||||
let serialized = serde_json::to_string(&event).unwrap();
|
||||
assert!(serialized.contains("\"action\":\"accept\""));
|
||||
assert!(serialized.contains("\"request_id\":\"req-123\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_elicitation_result_event_cancel_omits_none_fields() {
|
||||
let event = ElicitationResultEvent {
|
||||
action: "cancel".to_string(),
|
||||
request_id: None,
|
||||
conversation_id: None,
|
||||
};
|
||||
|
||||
let serialized = serde_json::to_string(&event).unwrap();
|
||||
assert!(serialized.contains("\"action\":\"cancel\""));
|
||||
assert!(!serialized.contains("request_id"));
|
||||
assert!(!serialized.contains("conversation_id"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_stop_failure_event_serialization() {
|
||||
let event = StopFailureEvent {
|
||||
stop_reason: Some("api_error".to_string()),
|
||||
error_type: Some("rate_limit".to_string()),
|
||||
conversation_id: Some("conv-abc".to_string()),
|
||||
};
|
||||
|
||||
let serialized = serde_json::to_string(&event).unwrap();
|
||||
assert!(serialized.contains("\"stop_reason\":\"api_error\""));
|
||||
assert!(serialized.contains("\"error_type\":\"rate_limit\""));
|
||||
assert!(serialized.contains("\"conversation_id\":\"conv-abc\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_stop_failure_event_omits_none_fields() {
|
||||
let event = StopFailureEvent {
|
||||
stop_reason: None,
|
||||
error_type: None,
|
||||
conversation_id: None,
|
||||
};
|
||||
|
||||
let serialized = serde_json::to_string(&event).unwrap();
|
||||
assert!(!serialized.contains("stop_reason"));
|
||||
assert!(!serialized.contains("error_type"));
|
||||
assert!(!serialized.contains("conversation_id"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_stop_failure_event_partial_fields() {
|
||||
let event = StopFailureEvent {
|
||||
stop_reason: Some("api_error".to_string()),
|
||||
error_type: None,
|
||||
conversation_id: None,
|
||||
};
|
||||
|
||||
let serialized = serde_json::to_string(&event).unwrap();
|
||||
assert!(serialized.contains("\"stop_reason\":\"api_error\""));
|
||||
assert!(!serialized.contains("error_type"));
|
||||
assert!(!serialized.contains("conversation_id"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_post_compact_event_serialization() {
|
||||
let event = PostCompactEvent {
|
||||
session_id: Some("sess-abc".to_string()),
|
||||
conversation_id: Some("conv-123".to_string()),
|
||||
};
|
||||
|
||||
let serialized = serde_json::to_string(&event).unwrap();
|
||||
assert!(serialized.contains("\"session_id\":\"sess-abc\""));
|
||||
assert!(serialized.contains("\"conversation_id\":\"conv-123\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_post_compact_event_omits_none_fields() {
|
||||
let event = PostCompactEvent {
|
||||
session_id: None,
|
||||
conversation_id: None,
|
||||
};
|
||||
|
||||
let serialized = serde_json::to_string(&event).unwrap();
|
||||
assert!(!serialized.contains("session_id"));
|
||||
assert!(!serialized.contains("conversation_id"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_post_compact_event_partial_fields() {
|
||||
let event = PostCompactEvent {
|
||||
session_id: Some("sess-xyz".to_string()),
|
||||
conversation_id: None,
|
||||
};
|
||||
|
||||
let serialized = serde_json::to_string(&event).unwrap();
|
||||
assert!(serialized.contains("\"session_id\":\"sess-xyz\""));
|
||||
assert!(!serialized.contains("conversation_id"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cwd_changed_event_serialization() {
|
||||
let event = CwdChangedEvent {
|
||||
cwd: "/home/naomi/code/my-project".to_string(),
|
||||
conversation_id: Some("conv-abc".to_string()),
|
||||
};
|
||||
|
||||
let serialized = serde_json::to_string(&event).unwrap();
|
||||
assert!(serialized.contains("\"cwd\":\"/home/naomi/code/my-project\""));
|
||||
assert!(serialized.contains("\"conversation_id\":\"conv-abc\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cwd_changed_event_omits_none_fields() {
|
||||
let event = CwdChangedEvent {
|
||||
cwd: "/tmp/workspace".to_string(),
|
||||
conversation_id: None,
|
||||
};
|
||||
|
||||
let serialized = serde_json::to_string(&event).unwrap();
|
||||
assert!(serialized.contains("\"cwd\":\"/tmp/workspace\""));
|
||||
assert!(!serialized.contains("conversation_id"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_file_changed_event_serialization() {
|
||||
let event = FileChangedEvent {
|
||||
file: "/home/naomi/code/my-project/src/main.rs".to_string(),
|
||||
conversation_id: Some("conv-abc".to_string()),
|
||||
};
|
||||
|
||||
let serialized = serde_json::to_string(&event).unwrap();
|
||||
assert!(serialized.contains("\"file\":\"/home/naomi/code/my-project/src/main.rs\""));
|
||||
assert!(serialized.contains("\"conversation_id\":\"conv-abc\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_file_changed_event_omits_none_fields() {
|
||||
let event = FileChangedEvent {
|
||||
file: "/tmp/test.txt".to_string(),
|
||||
conversation_id: None,
|
||||
};
|
||||
|
||||
let serialized = serde_json::to_string(&event).unwrap();
|
||||
assert!(serialized.contains("\"file\":\"/tmp/test.txt\""));
|
||||
assert!(!serialized.contains("conversation_id"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_task_created_event_serialization() {
|
||||
let event = TaskCreatedEvent {
|
||||
task_id: Some("task-abc123".to_string()),
|
||||
description: Some("Explore the codebase".to_string()),
|
||||
parent_tool_use_id: Some("toolu_xyz".to_string()),
|
||||
conversation_id: Some("conv-abc".to_string()),
|
||||
};
|
||||
|
||||
let serialized = serde_json::to_string(&event).unwrap();
|
||||
assert!(serialized.contains("\"task_id\":\"task-abc123\""));
|
||||
assert!(serialized.contains("\"description\":\"Explore the codebase\""));
|
||||
assert!(serialized.contains("\"parent_tool_use_id\":\"toolu_xyz\""));
|
||||
assert!(serialized.contains("\"conversation_id\":\"conv-abc\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_task_created_event_omits_none_fields() {
|
||||
let event = TaskCreatedEvent {
|
||||
task_id: None,
|
||||
description: None,
|
||||
parent_tool_use_id: None,
|
||||
conversation_id: None,
|
||||
};
|
||||
|
||||
let serialized = serde_json::to_string(&event).unwrap();
|
||||
assert_eq!(serialized, "{}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_task_created_event_partial_fields() {
|
||||
let event = TaskCreatedEvent {
|
||||
task_id: Some("task-001".to_string()),
|
||||
description: None,
|
||||
parent_tool_use_id: None,
|
||||
conversation_id: None,
|
||||
};
|
||||
|
||||
let serialized = serde_json::to_string(&event).unwrap();
|
||||
assert!(serialized.contains("\"task_id\":\"task-001\""));
|
||||
assert!(!serialized.contains("description"));
|
||||
assert!(!serialized.contains("parent_tool_use_id"));
|
||||
assert!(!serialized.contains("conversation_id"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_permission_denied_event_serialization() {
|
||||
let event = PermissionDeniedEvent {
|
||||
tool_name: Some("Bash".to_string()),
|
||||
reason: Some("Tool not in allow list".to_string()),
|
||||
conversation_id: Some("conv-abc".to_string()),
|
||||
};
|
||||
|
||||
let serialized = serde_json::to_string(&event).unwrap();
|
||||
assert!(serialized.contains("\"tool_name\":\"Bash\""));
|
||||
assert!(serialized.contains("\"reason\":\"Tool not in allow list\""));
|
||||
assert!(serialized.contains("\"conversation_id\":\"conv-abc\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_permission_denied_event_omits_none_fields() {
|
||||
let event = PermissionDeniedEvent {
|
||||
tool_name: None,
|
||||
reason: None,
|
||||
conversation_id: None,
|
||||
};
|
||||
|
||||
let serialized = serde_json::to_string(&event).unwrap();
|
||||
assert_eq!(serialized, "{}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_permission_denied_event_partial_fields() {
|
||||
let event = PermissionDeniedEvent {
|
||||
tool_name: Some("Edit".to_string()),
|
||||
reason: None,
|
||||
conversation_id: None,
|
||||
};
|
||||
|
||||
let serialized = serde_json::to_string(&event).unwrap();
|
||||
assert!(serialized.contains("\"tool_name\":\"Edit\""));
|
||||
assert!(!serialized.contains("reason"));
|
||||
assert!(!serialized.contains("conversation_id"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_system_init_with_output_style() {
|
||||
let json = r#"{"type":"system","subtype":"init","session_id":"sess-1","output_style":"auto"}"#;
|
||||
let msg: ClaudeMessage = serde_json::from_str(json).unwrap();
|
||||
if let ClaudeMessage::System { output_style, .. } = msg {
|
||||
assert_eq!(output_style, Some("auto".to_string()));
|
||||
} else {
|
||||
panic!("Expected System variant");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_system_init_without_output_style() {
|
||||
let json = r#"{"type":"system","subtype":"init","session_id":"sess-1"}"#;
|
||||
let msg: ClaudeMessage = serde_json::from_str(json).unwrap();
|
||||
if let ClaudeMessage::System { output_style, .. } = msg {
|
||||
assert!(output_style.is_none());
|
||||
} else {
|
||||
panic!("Expected System variant");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_result_message_with_fast_mode_state() {
|
||||
let json = r#"{"type":"result","subtype":"success","fast_mode_state":"enabled"}"#;
|
||||
let msg: ClaudeMessage = serde_json::from_str(json).unwrap();
|
||||
if let ClaudeMessage::Result { fast_mode_state, .. } = msg {
|
||||
assert_eq!(fast_mode_state, Some("enabled".to_string()));
|
||||
} else {
|
||||
panic!("Expected Result variant");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_result_message_with_total_cost_usd() {
|
||||
let json = r#"{"type":"result","subtype":"success","total_cost_usd":0.05}"#;
|
||||
let msg: ClaudeMessage = serde_json::from_str(json).unwrap();
|
||||
if let ClaudeMessage::Result { total_cost_usd, .. } = msg {
|
||||
assert!((total_cost_usd.unwrap() - 0.05).abs() < f64::EPSILON);
|
||||
} else {
|
||||
panic!("Expected Result variant");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_result_message_without_new_fields() {
|
||||
let json = r#"{"type":"result","subtype":"success"}"#;
|
||||
let msg: ClaudeMessage = serde_json::from_str(json).unwrap();
|
||||
if let ClaudeMessage::Result {
|
||||
fast_mode_state,
|
||||
model_usage,
|
||||
total_cost_usd,
|
||||
..
|
||||
} = msg
|
||||
{
|
||||
assert!(fast_mode_state.is_none());
|
||||
assert!(model_usage.is_none());
|
||||
assert!(total_cost_usd.is_none());
|
||||
} else {
|
||||
panic!("Expected Result variant");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ use std::process::Command;
|
||||
use tauri::command;
|
||||
use tempfile::NamedTempFile;
|
||||
|
||||
use crate::process_ext::HideWindow;
|
||||
|
||||
#[command]
|
||||
pub async fn send_vbs_notification(title: String, body: String) -> Result<(), String> {
|
||||
// Create a VBScript that shows a Windows notification
|
||||
@@ -40,7 +42,7 @@ objShell.Popup "{}" & vbCrLf & vbCrLf & "{}", 5, "{}", 64
|
||||
} else if temp_path.starts_with("/tmp/") {
|
||||
// WSL temp files might be in a different location
|
||||
// Try to use wslpath to convert
|
||||
let output = Command::new("wslpath").arg("-w").arg(&temp_path).output();
|
||||
let output = Command::new("wslpath").hide_window().arg("-w").arg(&temp_path).output();
|
||||
|
||||
if let Ok(result) = output {
|
||||
if result.status.success() {
|
||||
@@ -57,6 +59,7 @@ objShell.Popup "{}" & vbCrLf & vbCrLf & "{}", 5, "{}", 64
|
||||
|
||||
// Execute the VBScript using wscript.exe
|
||||
let output = Command::new("/mnt/c/Windows/System32/wscript.exe")
|
||||
.hide_window()
|
||||
.arg("//NoLogo")
|
||||
.arg(&windows_path)
|
||||
.output()
|
||||
|
||||
+2990
-123
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,8 @@
|
||||
use std::process::Command;
|
||||
use tauri::command;
|
||||
|
||||
use crate::process_ext::HideWindow;
|
||||
|
||||
#[command]
|
||||
pub async fn send_wsl_notification(title: String, body: String) -> Result<(), String> {
|
||||
// Method 1: Try Windows 10/11 toast notification using PowerShell
|
||||
@@ -36,6 +38,7 @@ $notifier.Show($toast)
|
||||
|
||||
// Try PowerShell.exe through WSL
|
||||
let output = Command::new("/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe")
|
||||
.hide_window()
|
||||
.arg("-NoProfile")
|
||||
.arg("-ExecutionPolicy")
|
||||
.arg("Bypass")
|
||||
@@ -48,15 +51,15 @@ $notifier.Show($toast)
|
||||
match output {
|
||||
Ok(result) => {
|
||||
if result.status.success() {
|
||||
println!("WSL notification sent successfully");
|
||||
tracing::info!("WSL notification sent successfully");
|
||||
return Ok(());
|
||||
} else {
|
||||
let stderr = String::from_utf8_lossy(&result.stderr);
|
||||
println!("PowerShell toast failed: {}", stderr);
|
||||
tracing::error!("PowerShell toast failed: {}", stderr);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
println!("Failed to run PowerShell: {}", e);
|
||||
tracing::error!("Failed to run PowerShell: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,6 +68,7 @@ $notifier.Show($toast)
|
||||
|
||||
// Method 3: Try wsl-notify-send if available
|
||||
let notify_result = Command::new("wsl-notify-send")
|
||||
.hide_window()
|
||||
.arg("--appId")
|
||||
.arg("HikariDesktop")
|
||||
.arg("--category")
|
||||
@@ -74,7 +78,7 @@ $notifier.Show($toast)
|
||||
|
||||
if let Ok(result) = notify_result {
|
||||
if result.status.success() {
|
||||
println!("Notification sent via wsl-notify-send");
|
||||
tracing::info!("Notification sent via wsl-notify-send");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "hikari-desktop",
|
||||
"version": "1.1.1",
|
||||
"version": "1.14.0",
|
||||
"identifier": "com.naomi.hikari-desktop",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm dev",
|
||||
|
||||
+393
-5
@@ -148,17 +148,405 @@
|
||||
--hljs-meta: #cccccc;
|
||||
}
|
||||
|
||||
[data-theme="dracula"] {
|
||||
--bg-primary: #282a36;
|
||||
--bg-secondary: #1e1f29;
|
||||
--bg-terminal: #191a21;
|
||||
--bg-hover: #44475a;
|
||||
--bg-code: #282a36;
|
||||
--accent-primary: #bd93f9;
|
||||
--accent-secondary: #ff79c6;
|
||||
--text-primary: #f8f8f2;
|
||||
--text-secondary: #6272a4;
|
||||
--text-tertiary: #44475a;
|
||||
--border-color: #44475a;
|
||||
|
||||
/* Trans pride colors */
|
||||
--trans-blue: #5bcefa;
|
||||
--trans-pink: #f5a9b8;
|
||||
--trans-white: #ffffff;
|
||||
--trans-gradient: linear-gradient(
|
||||
135deg,
|
||||
var(--trans-blue) 0%,
|
||||
var(--trans-pink) 50%,
|
||||
var(--trans-white) 100%
|
||||
);
|
||||
--trans-gradient-vibrant: linear-gradient(
|
||||
135deg,
|
||||
var(--trans-blue) 0%,
|
||||
var(--trans-pink) 35%,
|
||||
var(--trans-white) 50%,
|
||||
var(--trans-pink) 65%,
|
||||
var(--trans-blue) 100%
|
||||
);
|
||||
|
||||
/* Terminal specific colors */
|
||||
--terminal-user: #8be9fd;
|
||||
--terminal-tool: #bd93f9;
|
||||
--terminal-tool-name: #caa9fa;
|
||||
--terminal-error: #ff5555;
|
||||
|
||||
/* Syntax highlighting colors (Dracula) */
|
||||
--hljs-keyword: #ff79c6;
|
||||
--hljs-string: #f1fa8c;
|
||||
--hljs-number: #bd93f9;
|
||||
--hljs-comment: #6272a4;
|
||||
--hljs-function: #50fa7b;
|
||||
--hljs-type: #8be9fd;
|
||||
--hljs-variable: #ffb86c;
|
||||
--hljs-meta: #94a3b8;
|
||||
}
|
||||
|
||||
[data-theme="catppuccin"] {
|
||||
--bg-primary: #1e1e2e;
|
||||
--bg-secondary: #181825;
|
||||
--bg-terminal: #11111b;
|
||||
--bg-hover: #313244;
|
||||
--bg-code: #1e1e2e;
|
||||
--accent-primary: #cba6f7;
|
||||
--accent-secondary: #f5c2e7;
|
||||
--text-primary: #cdd6f4;
|
||||
--text-secondary: #a6adc8;
|
||||
--text-tertiary: #6c7086;
|
||||
--border-color: #313244;
|
||||
|
||||
/* Trans pride colors */
|
||||
--trans-blue: #5bcefa;
|
||||
--trans-pink: #f5a9b8;
|
||||
--trans-white: #ffffff;
|
||||
--trans-gradient: linear-gradient(
|
||||
135deg,
|
||||
var(--trans-blue) 0%,
|
||||
var(--trans-pink) 50%,
|
||||
var(--trans-white) 100%
|
||||
);
|
||||
--trans-gradient-vibrant: linear-gradient(
|
||||
135deg,
|
||||
var(--trans-blue) 0%,
|
||||
var(--trans-pink) 35%,
|
||||
var(--trans-white) 50%,
|
||||
var(--trans-pink) 65%,
|
||||
var(--trans-blue) 100%
|
||||
);
|
||||
|
||||
/* Terminal specific colors */
|
||||
--terminal-user: #89dceb;
|
||||
--terminal-tool: #cba6f7;
|
||||
--terminal-tool-name: #d9b3ff;
|
||||
--terminal-error: #f38ba8;
|
||||
|
||||
/* Syntax highlighting colors (Catppuccin Mocha) */
|
||||
--hljs-keyword: #cba6f7;
|
||||
--hljs-string: #a6e3a1;
|
||||
--hljs-number: #fab387;
|
||||
--hljs-comment: #6c7086;
|
||||
--hljs-function: #89b4fa;
|
||||
--hljs-type: #89dceb;
|
||||
--hljs-variable: #fab387;
|
||||
--hljs-meta: #a6adc8;
|
||||
}
|
||||
|
||||
[data-theme="nord"] {
|
||||
--bg-primary: #2e3440;
|
||||
--bg-secondary: #3b4252;
|
||||
--bg-terminal: #242933;
|
||||
--bg-hover: #434c5e;
|
||||
--bg-code: #2e3440;
|
||||
--accent-primary: #88c0d0;
|
||||
--accent-secondary: #81a1c1;
|
||||
--text-primary: #eceff4;
|
||||
--text-secondary: #d8dee9;
|
||||
--text-tertiary: #4c566a;
|
||||
--border-color: #434c5e;
|
||||
|
||||
/* Trans pride colors */
|
||||
--trans-blue: #5bcefa;
|
||||
--trans-pink: #f5a9b8;
|
||||
--trans-white: #ffffff;
|
||||
--trans-gradient: linear-gradient(
|
||||
135deg,
|
||||
var(--trans-blue) 0%,
|
||||
var(--trans-pink) 50%,
|
||||
var(--trans-white) 100%
|
||||
);
|
||||
--trans-gradient-vibrant: linear-gradient(
|
||||
135deg,
|
||||
var(--trans-blue) 0%,
|
||||
var(--trans-pink) 35%,
|
||||
var(--trans-white) 50%,
|
||||
var(--trans-pink) 65%,
|
||||
var(--trans-blue) 100%
|
||||
);
|
||||
|
||||
/* Terminal specific colors */
|
||||
--terminal-user: #88c0d0;
|
||||
--terminal-tool: #b48ead;
|
||||
--terminal-tool-name: #c7a8c9;
|
||||
--terminal-error: #bf616a;
|
||||
|
||||
/* Syntax highlighting colors (Nord) */
|
||||
--hljs-keyword: #81a1c1;
|
||||
--hljs-string: #a3be8c;
|
||||
--hljs-number: #b48ead;
|
||||
--hljs-comment: #4c566a;
|
||||
--hljs-function: #88c0d0;
|
||||
--hljs-type: #8fbcbb;
|
||||
--hljs-variable: #d08770;
|
||||
--hljs-meta: #616e88;
|
||||
}
|
||||
|
||||
[data-theme="solarized"] {
|
||||
--bg-primary: #002b36;
|
||||
--bg-secondary: #073642;
|
||||
--bg-terminal: #00212b;
|
||||
--bg-hover: #094656;
|
||||
--bg-code: #002b36;
|
||||
--accent-primary: #268bd2;
|
||||
--accent-secondary: #2aa198;
|
||||
--text-primary: #fdf6e3;
|
||||
--text-secondary: #93a1a1;
|
||||
--text-tertiary: #657b83;
|
||||
--border-color: #094656;
|
||||
|
||||
/* Trans pride colors */
|
||||
--trans-blue: #5bcefa;
|
||||
--trans-pink: #f5a9b8;
|
||||
--trans-white: #ffffff;
|
||||
--trans-gradient: linear-gradient(
|
||||
135deg,
|
||||
var(--trans-blue) 0%,
|
||||
var(--trans-pink) 50%,
|
||||
var(--trans-white) 100%
|
||||
);
|
||||
--trans-gradient-vibrant: linear-gradient(
|
||||
135deg,
|
||||
var(--trans-blue) 0%,
|
||||
var(--trans-pink) 35%,
|
||||
var(--trans-white) 50%,
|
||||
var(--trans-pink) 65%,
|
||||
var(--trans-blue) 100%
|
||||
);
|
||||
|
||||
/* Terminal specific colors */
|
||||
--terminal-user: #2aa198;
|
||||
--terminal-tool: #6c71c4;
|
||||
--terminal-tool-name: #9395d0;
|
||||
--terminal-error: #dc322f;
|
||||
|
||||
/* Syntax highlighting colors (Solarized Dark) */
|
||||
--hljs-keyword: #859900;
|
||||
--hljs-string: #2aa198;
|
||||
--hljs-number: #d33682;
|
||||
--hljs-comment: #586e75;
|
||||
--hljs-function: #268bd2;
|
||||
--hljs-type: #b58900;
|
||||
--hljs-variable: #cb4b16;
|
||||
--hljs-meta: #657b83;
|
||||
}
|
||||
|
||||
[data-theme="solarized-light"] {
|
||||
--bg-primary: #fdf6e3;
|
||||
--bg-secondary: #eee8d5;
|
||||
--bg-terminal: #f9f3d7;
|
||||
--bg-hover: #d8d1be;
|
||||
--bg-code: #eee8d5;
|
||||
--accent-primary: #268bd2;
|
||||
--accent-secondary: #2aa198;
|
||||
--text-primary: #657b83;
|
||||
--text-secondary: #839496;
|
||||
--text-tertiary: #93a1a1;
|
||||
--border-color: #cfc9b5;
|
||||
|
||||
/* Trans pride colors */
|
||||
--trans-blue: #5bcefa;
|
||||
--trans-pink: #f5a9b8;
|
||||
--trans-white: #ffffff;
|
||||
--trans-gradient: linear-gradient(
|
||||
135deg,
|
||||
var(--trans-blue) 0%,
|
||||
var(--trans-pink) 50%,
|
||||
var(--trans-white) 100%
|
||||
);
|
||||
--trans-gradient-vibrant: linear-gradient(
|
||||
135deg,
|
||||
var(--trans-blue) 0%,
|
||||
var(--trans-pink) 35%,
|
||||
var(--trans-white) 50%,
|
||||
var(--trans-pink) 65%,
|
||||
var(--trans-blue) 100%
|
||||
);
|
||||
|
||||
/* Terminal specific colors */
|
||||
--terminal-user: #268bd2;
|
||||
--terminal-tool: #6c71c4;
|
||||
--terminal-tool-name: #8f94cc;
|
||||
--terminal-error: #dc322f;
|
||||
|
||||
/* Syntax highlighting colors (Solarized Light) */
|
||||
--hljs-keyword: #859900;
|
||||
--hljs-string: #2aa198;
|
||||
--hljs-number: #d33682;
|
||||
--hljs-comment: #93a1a1;
|
||||
--hljs-function: #268bd2;
|
||||
--hljs-type: #b58900;
|
||||
--hljs-variable: #cb4b16;
|
||||
--hljs-meta: #657b83;
|
||||
}
|
||||
|
||||
[data-theme="catppuccin-latte"] {
|
||||
--bg-primary: #eff1f5;
|
||||
--bg-secondary: #e6e9ef;
|
||||
--bg-terminal: #dce0e8;
|
||||
--bg-hover: #ccd0da;
|
||||
--bg-code: #e6e9ef;
|
||||
--accent-primary: #8839ef;
|
||||
--accent-secondary: #ea76cb;
|
||||
--text-primary: #4c4f69;
|
||||
--text-secondary: #6c6f85;
|
||||
--text-tertiary: #9ca0b0;
|
||||
--border-color: #bcc0cc;
|
||||
|
||||
/* Trans pride colors */
|
||||
--trans-blue: #5bcefa;
|
||||
--trans-pink: #f5a9b8;
|
||||
--trans-white: #ffffff;
|
||||
--trans-gradient: linear-gradient(
|
||||
135deg,
|
||||
var(--trans-blue) 0%,
|
||||
var(--trans-pink) 50%,
|
||||
var(--trans-white) 100%
|
||||
);
|
||||
--trans-gradient-vibrant: linear-gradient(
|
||||
135deg,
|
||||
var(--trans-blue) 0%,
|
||||
var(--trans-pink) 35%,
|
||||
var(--trans-white) 50%,
|
||||
var(--trans-pink) 65%,
|
||||
var(--trans-blue) 100%
|
||||
);
|
||||
|
||||
/* Terminal specific colors */
|
||||
--terminal-user: #209fb5;
|
||||
--terminal-tool: #8839ef;
|
||||
--terminal-tool-name: #a259f1;
|
||||
--terminal-error: #d20f39;
|
||||
|
||||
/* Syntax highlighting colors (Catppuccin Latte) */
|
||||
--hljs-keyword: #8839ef;
|
||||
--hljs-string: #40a02b;
|
||||
--hljs-number: #fe640b;
|
||||
--hljs-comment: #8c8fa1;
|
||||
--hljs-function: #1e66f5;
|
||||
--hljs-type: #209fb5;
|
||||
--hljs-variable: #fe640b;
|
||||
--hljs-meta: #5c5f77;
|
||||
}
|
||||
|
||||
[data-theme="gruvbox-light"] {
|
||||
--bg-primary: #fbf1c7;
|
||||
--bg-secondary: #ebdbb2;
|
||||
--bg-terminal: #f9f5d7;
|
||||
--bg-hover: #d5c4a1;
|
||||
--bg-code: #ebdbb2;
|
||||
--accent-primary: #458588;
|
||||
--accent-secondary: #689d6a;
|
||||
--text-primary: #3c3836;
|
||||
--text-secondary: #665c54;
|
||||
--text-tertiary: #7c6f64;
|
||||
--border-color: #bdae93;
|
||||
|
||||
/* Trans pride colors */
|
||||
--trans-blue: #5bcefa;
|
||||
--trans-pink: #f5a9b8;
|
||||
--trans-white: #ffffff;
|
||||
--trans-gradient: linear-gradient(
|
||||
135deg,
|
||||
var(--trans-blue) 0%,
|
||||
var(--trans-pink) 50%,
|
||||
var(--trans-white) 100%
|
||||
);
|
||||
--trans-gradient-vibrant: linear-gradient(
|
||||
135deg,
|
||||
var(--trans-blue) 0%,
|
||||
var(--trans-pink) 35%,
|
||||
var(--trans-white) 50%,
|
||||
var(--trans-pink) 65%,
|
||||
var(--trans-blue) 100%
|
||||
);
|
||||
|
||||
/* Terminal specific colors */
|
||||
--terminal-user: #458588;
|
||||
--terminal-tool: #b16286;
|
||||
--terminal-tool-name: #c37aa0;
|
||||
--terminal-error: #cc241d;
|
||||
|
||||
/* Syntax highlighting colors (Gruvbox Light) */
|
||||
--hljs-keyword: #d65d0e;
|
||||
--hljs-string: #98971a;
|
||||
--hljs-number: #b16286;
|
||||
--hljs-comment: #928374;
|
||||
--hljs-function: #458588;
|
||||
--hljs-type: #d79921;
|
||||
--hljs-variable: #af3a03;
|
||||
--hljs-meta: #7c6f64;
|
||||
}
|
||||
|
||||
[data-theme="rose-pine-dawn"] {
|
||||
--bg-primary: #faf4ed;
|
||||
--bg-secondary: #fffaf3;
|
||||
--bg-terminal: #f2e9e1;
|
||||
--bg-hover: #dfdad9;
|
||||
--bg-code: #fffaf3;
|
||||
--accent-primary: #907aa9;
|
||||
--accent-secondary: #d7827e;
|
||||
--text-primary: #575279;
|
||||
--text-secondary: #797593;
|
||||
--text-tertiary: #9893a5;
|
||||
--border-color: #cecacd;
|
||||
|
||||
/* Trans pride colors */
|
||||
--trans-blue: #5bcefa;
|
||||
--trans-pink: #f5a9b8;
|
||||
--trans-white: #ffffff;
|
||||
--trans-gradient: linear-gradient(
|
||||
135deg,
|
||||
var(--trans-blue) 0%,
|
||||
var(--trans-pink) 50%,
|
||||
var(--trans-white) 100%
|
||||
);
|
||||
--trans-gradient-vibrant: linear-gradient(
|
||||
135deg,
|
||||
var(--trans-blue) 0%,
|
||||
var(--trans-pink) 35%,
|
||||
var(--trans-white) 50%,
|
||||
var(--trans-pink) 65%,
|
||||
var(--trans-blue) 100%
|
||||
);
|
||||
|
||||
/* Terminal specific colors */
|
||||
--terminal-user: #56949f;
|
||||
--terminal-tool: #907aa9;
|
||||
--terminal-tool-name: #a48abf;
|
||||
--terminal-error: #b4637a;
|
||||
|
||||
/* Syntax highlighting colors (Rosé Pine Dawn) */
|
||||
--hljs-keyword: #286983;
|
||||
--hljs-string: #56949f;
|
||||
--hljs-number: #ea9d34;
|
||||
--hljs-comment: #9893a5;
|
||||
--hljs-function: #907aa9;
|
||||
--hljs-type: #d7827e;
|
||||
--hljs-variable: #b4637a;
|
||||
--hljs-meta: #797593;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
font-family:
|
||||
"Segoe UI",
|
||||
system-ui,
|
||||
-apple-system,
|
||||
sans-serif;
|
||||
font-family: var(--ui-font-family, "Segoe UI", system-ui, -apple-system, sans-serif);
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { get } from "svelte/store";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { claudeStore } from "$lib/stores/claude";
|
||||
import { searchState } from "$lib/stores/search";
|
||||
import { characterState } from "$lib/stores/character";
|
||||
import {
|
||||
slashCommands,
|
||||
parseSlashCommand,
|
||||
@@ -8,9 +13,13 @@ import {
|
||||
} from "./slashCommands";
|
||||
|
||||
// Mock all external dependencies
|
||||
vi.mock("svelte/store", () => ({
|
||||
get: vi.fn(),
|
||||
}));
|
||||
vi.mock("svelte/store", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("svelte/store")>();
|
||||
return {
|
||||
...actual,
|
||||
get: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@tauri-apps/api/core", () => ({
|
||||
invoke: vi.fn(),
|
||||
@@ -36,6 +45,32 @@ vi.mock("$lib/stores/character", () => ({
|
||||
|
||||
vi.mock("$lib/tauri", () => ({
|
||||
setSkipNextGreeting: vi.fn(),
|
||||
updateDiscordRpc: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("$lib/stores/conversations", () => ({
|
||||
conversationsStore: {
|
||||
activeConversation: { subscribe: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("$lib/stores/config", () => ({
|
||||
configStore: {
|
||||
getConfig: vi.fn().mockReturnValue({
|
||||
auto_granted_tools: [],
|
||||
model: "claude-sonnet",
|
||||
api_key: null,
|
||||
custom_instructions: null,
|
||||
mcp_servers_json: null,
|
||||
use_worktree: false,
|
||||
disable_1m_context: false,
|
||||
max_output_tokens: null,
|
||||
include_git_instructions: true,
|
||||
enable_claudeai_mcp_servers: true,
|
||||
auto_memory_directory: null,
|
||||
model_overrides: null,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("$lib/stores/search", () => ({
|
||||
@@ -56,10 +91,15 @@ describe("slashCommands", () => {
|
||||
expect(commandNames).toContain("search");
|
||||
expect(commandNames).toContain("summarise");
|
||||
expect(commandNames).toContain("skill");
|
||||
expect(commandNames).toContain("simplify");
|
||||
expect(commandNames).toContain("loop");
|
||||
expect(commandNames).toContain("batch");
|
||||
expect(commandNames).toContain("memory");
|
||||
expect(commandNames).toContain("context");
|
||||
});
|
||||
|
||||
it("has 7 commands total", () => {
|
||||
expect(slashCommands.length).toBe(7);
|
||||
it("has 12 commands total", () => {
|
||||
expect(slashCommands.length).toBe(12);
|
||||
});
|
||||
|
||||
it("each command has required properties", () => {
|
||||
@@ -129,6 +169,52 @@ describe("slashCommands", () => {
|
||||
expect(skillCmd!.description).toBe("Invoke a Claude Code skill from ~/.claude/skills/");
|
||||
expect(skillCmd!.usage).toBe("/skill [name] [data]");
|
||||
});
|
||||
|
||||
it("simplify command has correct metadata and source", () => {
|
||||
const simplifyCmd = slashCommands.find((cmd) => cmd.name === "simplify");
|
||||
expect(simplifyCmd).toBeDefined();
|
||||
expect(simplifyCmd!.source).toBe("cli");
|
||||
expect(simplifyCmd!.usage).toBe("/simplify");
|
||||
});
|
||||
|
||||
it("loop command has correct metadata and source", () => {
|
||||
const loopCmd = slashCommands.find((cmd) => cmd.name === "loop");
|
||||
expect(loopCmd).toBeDefined();
|
||||
expect(loopCmd!.source).toBe("cli");
|
||||
expect(loopCmd!.usage).toBe("/loop [interval] [command]");
|
||||
});
|
||||
|
||||
it("batch command has correct metadata and source", () => {
|
||||
const batchCmd = slashCommands.find((cmd) => cmd.name === "batch");
|
||||
expect(batchCmd).toBeDefined();
|
||||
expect(batchCmd!.source).toBe("cli");
|
||||
expect(batchCmd!.usage).toBe("/batch [tasks]");
|
||||
});
|
||||
|
||||
it("context command has correct metadata and source", () => {
|
||||
const contextCmd = slashCommands.find((cmd) => cmd.name === "context");
|
||||
expect(contextCmd).toBeDefined();
|
||||
expect(contextCmd!.source).toBe("cli");
|
||||
expect(contextCmd!.usage).toBe("/context");
|
||||
});
|
||||
|
||||
it("app commands do not have source set", () => {
|
||||
const appCommandNames = ["cd", "clear", "new", "help", "search", "summarise", "skill"];
|
||||
appCommandNames.forEach((name) => {
|
||||
const cmd = slashCommands.find((c) => c.name === name);
|
||||
expect(cmd).toBeDefined();
|
||||
expect(cmd!.source).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("cli commands have source set to 'cli'", () => {
|
||||
const cliCommandNames = ["simplify", "loop", "batch", "memory", "context"];
|
||||
cliCommandNames.forEach((name) => {
|
||||
const cmd = slashCommands.find((c) => c.name === name);
|
||||
expect(cmd).toBeDefined();
|
||||
expect(cmd!.source).toBe("cli");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseSlashCommand", () => {
|
||||
@@ -311,6 +397,19 @@ describe("slashCommands", () => {
|
||||
expect(names).toContain("search");
|
||||
expect(names).toContain("summarise");
|
||||
expect(names).toContain("skill");
|
||||
expect(names).toContain("simplify");
|
||||
});
|
||||
|
||||
it("returns /loop for /l prefix", () => {
|
||||
const result = getMatchingCommands("/l");
|
||||
const names = result.map((cmd) => cmd.name);
|
||||
expect(names).toContain("loop");
|
||||
});
|
||||
|
||||
it("returns /batch for /b prefix", () => {
|
||||
const result = getMatchingCommands("/b");
|
||||
const names = result.map((cmd) => cmd.name);
|
||||
expect(names).toContain("batch");
|
||||
});
|
||||
|
||||
it("is case insensitive", () => {
|
||||
@@ -381,6 +480,19 @@ describe("slashCommands", () => {
|
||||
expect(testCommand.description).toBe("A test command");
|
||||
expect(testCommand.usage).toBe("/test [arg]");
|
||||
expect(typeof testCommand.execute).toBe("function");
|
||||
expect(testCommand.source).toBeUndefined();
|
||||
});
|
||||
|
||||
it("can create a cli-sourced slash command object", () => {
|
||||
const cliCommand: SlashCommand = {
|
||||
name: "cli-test",
|
||||
description: "A CLI command",
|
||||
usage: "/cli-test",
|
||||
source: "cli",
|
||||
execute: vi.fn(),
|
||||
};
|
||||
|
||||
expect(cliCommand.source).toBe("cli");
|
||||
});
|
||||
|
||||
it("execute can be async function", () => {
|
||||
@@ -411,4 +523,544 @@ describe("slashCommands", () => {
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("command execute functions", () => {
|
||||
let getMock: ReturnType<typeof vi.fn>;
|
||||
let invokeMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
getMock = vi.mocked(get);
|
||||
invokeMock = vi.mocked(invoke);
|
||||
});
|
||||
|
||||
describe("/clear execute", () => {
|
||||
it("clears terminal and shows confirmation message", () => {
|
||||
const clearCmd = slashCommands.find((cmd) => cmd.name === "clear")!;
|
||||
clearCmd.execute("");
|
||||
expect(claudeStore.clearTerminal).toHaveBeenCalledWith();
|
||||
expect(claudeStore.addLine).toHaveBeenCalledWith("system", "Terminal cleared");
|
||||
});
|
||||
});
|
||||
|
||||
describe("/help execute", () => {
|
||||
it("shows available commands header", () => {
|
||||
const helpCmd = slashCommands.find((cmd) => cmd.name === "help")!;
|
||||
helpCmd.execute("");
|
||||
expect(claudeStore.addLine).toHaveBeenCalledWith(
|
||||
"system",
|
||||
expect.stringContaining("Available commands:")
|
||||
);
|
||||
});
|
||||
|
||||
it("includes all command usages in help text", () => {
|
||||
const helpCmd = slashCommands.find((cmd) => cmd.name === "help")!;
|
||||
helpCmd.execute("");
|
||||
const callArgs = vi.mocked(claudeStore.addLine).mock.calls[0];
|
||||
const helpText = callArgs[1] as string;
|
||||
expect(helpText).toContain("/cd");
|
||||
expect(helpText).toContain("/clear");
|
||||
expect(helpText).toContain("/help");
|
||||
expect(helpText).toContain("/search");
|
||||
expect(helpText).toContain("/new");
|
||||
expect(helpText).toContain("/summarise");
|
||||
expect(helpText).toContain("/skill");
|
||||
});
|
||||
|
||||
it("includes command descriptions in help text", () => {
|
||||
const helpCmd = slashCommands.find((cmd) => cmd.name === "help")!;
|
||||
helpCmd.execute("");
|
||||
const callArgs = vi.mocked(claudeStore.addLine).mock.calls[0];
|
||||
const helpText = callArgs[1] as string;
|
||||
expect(helpText).toContain("Change the working directory");
|
||||
expect(helpText).toContain("Show available slash commands");
|
||||
});
|
||||
});
|
||||
|
||||
describe("/search execute", () => {
|
||||
it("clears search when called with empty args", () => {
|
||||
const searchCmd = slashCommands.find((cmd) => cmd.name === "search")!;
|
||||
searchCmd.execute("");
|
||||
expect(searchState.clear).toHaveBeenCalledWith();
|
||||
expect(claudeStore.addLine).toHaveBeenCalledWith("system", "Search cleared");
|
||||
});
|
||||
|
||||
it("clears search when called with whitespace-only args", () => {
|
||||
const searchCmd = slashCommands.find((cmd) => cmd.name === "search")!;
|
||||
searchCmd.execute(" ");
|
||||
expect(searchState.clear).toHaveBeenCalledWith();
|
||||
expect(claudeStore.addLine).toHaveBeenCalledWith("system", "Search cleared");
|
||||
});
|
||||
|
||||
it("sets query when called with a search term", () => {
|
||||
const searchCmd = slashCommands.find((cmd) => cmd.name === "search")!;
|
||||
searchCmd.execute("hello world");
|
||||
expect(searchState.setQuery).toHaveBeenCalledWith("hello world");
|
||||
expect(claudeStore.addLine).toHaveBeenCalledWith("system", 'Searching for: "hello world"');
|
||||
});
|
||||
|
||||
it("trims whitespace from query before setting", () => {
|
||||
const searchCmd = slashCommands.find((cmd) => cmd.name === "search")!;
|
||||
searchCmd.execute(" hello ");
|
||||
expect(searchState.setQuery).toHaveBeenCalledWith("hello");
|
||||
expect(claudeStore.addLine).toHaveBeenCalledWith("system", 'Searching for: "hello"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("/cd execute", () => {
|
||||
it("shows error when no active conversation", async () => {
|
||||
getMock.mockReturnValue(null);
|
||||
const cdCmd = slashCommands.find((cmd) => cmd.name === "cd")!;
|
||||
await cdCmd.execute("/some/path");
|
||||
expect(claudeStore.addLine).toHaveBeenCalledWith("error", "No active conversation");
|
||||
});
|
||||
|
||||
it("shows current directory when called with empty args", async () => {
|
||||
getMock.mockReturnValueOnce("conv-123").mockReturnValueOnce("/home/naomi/code");
|
||||
const cdCmd = slashCommands.find((cmd) => cmd.name === "cd")!;
|
||||
await cdCmd.execute("");
|
||||
expect(claudeStore.addLine).toHaveBeenCalledWith(
|
||||
"system",
|
||||
"Current directory: /home/naomi/code"
|
||||
);
|
||||
});
|
||||
|
||||
it("shows current directory when called with whitespace-only args", async () => {
|
||||
getMock.mockReturnValueOnce("conv-123").mockReturnValueOnce("/home/naomi/code");
|
||||
const cdCmd = slashCommands.find((cmd) => cmd.name === "cd")!;
|
||||
await cdCmd.execute(" ");
|
||||
expect(claudeStore.addLine).toHaveBeenCalledWith(
|
||||
"system",
|
||||
"Current directory: /home/naomi/code"
|
||||
);
|
||||
});
|
||||
|
||||
it("validates path and changes directory on success", async () => {
|
||||
getMock.mockReturnValueOnce("conv-123").mockReturnValueOnce(null);
|
||||
invokeMock.mockResolvedValue("/validated/path");
|
||||
const cdCmd = slashCommands.find((cmd) => cmd.name === "cd")!;
|
||||
await cdCmd.execute("/new/path");
|
||||
expect(invokeMock).toHaveBeenCalledWith(
|
||||
"validate_directory",
|
||||
expect.objectContaining({ path: "/new/path" })
|
||||
);
|
||||
});
|
||||
|
||||
it("shows error when directory change fails", async () => {
|
||||
getMock.mockReturnValueOnce("conv-123");
|
||||
invokeMock.mockRejectedValueOnce(new Error("invalid path"));
|
||||
const cdCmd = slashCommands.find((cmd) => cmd.name === "cd")!;
|
||||
await cdCmd.execute("/bad/path");
|
||||
expect(claudeStore.addLine).toHaveBeenCalledWith(
|
||||
"error",
|
||||
expect.stringContaining("Failed to change directory:")
|
||||
);
|
||||
expect(characterState.setTemporaryState).toHaveBeenCalledWith("error", 3000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("/new execute", () => {
|
||||
it("shows error when no active conversation", async () => {
|
||||
getMock.mockReturnValue(null);
|
||||
const newCmd = slashCommands.find((cmd) => cmd.name === "new")!;
|
||||
await newCmd.execute("");
|
||||
expect(claudeStore.addLine).toHaveBeenCalledWith("error", "No active conversation");
|
||||
});
|
||||
|
||||
it("shows error when starting new conversation fails", async () => {
|
||||
getMock.mockReturnValueOnce("conv-123");
|
||||
invokeMock.mockRejectedValueOnce(new Error("invoke failed"));
|
||||
const newCmd = slashCommands.find((cmd) => cmd.name === "new")!;
|
||||
await newCmd.execute("");
|
||||
expect(claudeStore.addLine).toHaveBeenCalledWith(
|
||||
"error",
|
||||
expect.stringContaining("Failed to start new conversation:")
|
||||
);
|
||||
expect(characterState.setTemporaryState).toHaveBeenCalledWith("error", 3000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("/summarise execute", () => {
|
||||
it("shows error when no active conversation", async () => {
|
||||
getMock.mockReturnValue(null);
|
||||
const summariseCmd = slashCommands.find((cmd) => cmd.name === "summarise")!;
|
||||
await summariseCmd.execute("");
|
||||
expect(claudeStore.addLine).toHaveBeenCalledWith("error", "No active conversation");
|
||||
});
|
||||
|
||||
it("sends a summary prompt when there is an active conversation", async () => {
|
||||
getMock.mockReturnValue("conv-123");
|
||||
invokeMock.mockResolvedValue(undefined);
|
||||
const summariseCmd = slashCommands.find((cmd) => cmd.name === "summarise")!;
|
||||
await summariseCmd.execute("");
|
||||
expect(claudeStore.addLine).toHaveBeenCalledWith(
|
||||
"system",
|
||||
"Requesting conversation summary..."
|
||||
);
|
||||
expect(invokeMock).toHaveBeenCalledWith(
|
||||
"send_prompt",
|
||||
expect.objectContaining({ conversationId: "conv-123" })
|
||||
);
|
||||
});
|
||||
|
||||
it("shows error when send_prompt invoke fails", async () => {
|
||||
getMock.mockReturnValue("conv-123");
|
||||
invokeMock.mockRejectedValue(new Error("network error"));
|
||||
const summariseCmd = slashCommands.find((cmd) => cmd.name === "summarise")!;
|
||||
await summariseCmd.execute("");
|
||||
expect(claudeStore.addLine).toHaveBeenCalledWith(
|
||||
"error",
|
||||
expect.stringContaining("Failed to request summary:")
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("/skill execute", () => {
|
||||
it("shows error when no active conversation", async () => {
|
||||
getMock.mockReturnValue(null);
|
||||
const skillCmd = slashCommands.find((cmd) => cmd.name === "skill")!;
|
||||
await skillCmd.execute("onboard-mentee");
|
||||
expect(claudeStore.addLine).toHaveBeenCalledWith("error", "No active conversation");
|
||||
});
|
||||
|
||||
it("lists available skills when called with no name", async () => {
|
||||
getMock.mockReturnValue("conv-123");
|
||||
invokeMock.mockResolvedValue(["onboard-mentee", "other-skill"]);
|
||||
const skillCmd = slashCommands.find((cmd) => cmd.name === "skill")!;
|
||||
await skillCmd.execute("");
|
||||
expect(invokeMock).toHaveBeenCalledWith("list_skills");
|
||||
expect(claudeStore.addLine).toHaveBeenCalledWith(
|
||||
"system",
|
||||
expect.stringContaining("onboard-mentee")
|
||||
);
|
||||
});
|
||||
|
||||
it("shows empty message when no skills are found", async () => {
|
||||
getMock.mockReturnValue("conv-123");
|
||||
invokeMock.mockResolvedValue([]);
|
||||
const skillCmd = slashCommands.find((cmd) => cmd.name === "skill")!;
|
||||
await skillCmd.execute("");
|
||||
expect(claudeStore.addLine).toHaveBeenCalledWith(
|
||||
"system",
|
||||
expect.stringContaining("No skills found")
|
||||
);
|
||||
});
|
||||
|
||||
it("invokes skill when called with a name and no data", async () => {
|
||||
getMock.mockReturnValue("conv-123");
|
||||
invokeMock.mockResolvedValue(undefined);
|
||||
const skillCmd = slashCommands.find((cmd) => cmd.name === "skill")!;
|
||||
await skillCmd.execute("onboard-mentee");
|
||||
expect(claudeStore.addLine).toHaveBeenCalledWith(
|
||||
"system",
|
||||
"Invoking skill: onboard-mentee"
|
||||
);
|
||||
expect(invokeMock).toHaveBeenCalledWith(
|
||||
"send_prompt",
|
||||
expect.objectContaining({ conversationId: "conv-123" })
|
||||
);
|
||||
});
|
||||
|
||||
it("invokes skill with additional data in the prompt", async () => {
|
||||
getMock.mockReturnValue("conv-123");
|
||||
invokeMock.mockResolvedValue(undefined);
|
||||
const skillCmd = slashCommands.find((cmd) => cmd.name === "skill")!;
|
||||
await skillCmd.execute("onboard-mentee some extra data");
|
||||
expect(invokeMock).toHaveBeenCalledWith("send_prompt", {
|
||||
conversationId: "conv-123",
|
||||
message: expect.stringContaining("some extra data"),
|
||||
});
|
||||
});
|
||||
|
||||
it("shows error when listing skills fails", async () => {
|
||||
getMock.mockReturnValue("conv-123");
|
||||
invokeMock.mockRejectedValue(new Error("list error"));
|
||||
const skillCmd = slashCommands.find((cmd) => cmd.name === "skill")!;
|
||||
await skillCmd.execute("");
|
||||
expect(claudeStore.addLine).toHaveBeenCalledWith(
|
||||
"error",
|
||||
expect.stringContaining("Failed to list skills:")
|
||||
);
|
||||
});
|
||||
|
||||
it("shows error and resets character state when invoking skill fails", async () => {
|
||||
getMock.mockReturnValue("conv-123");
|
||||
invokeMock.mockRejectedValue(new Error("invoke error"));
|
||||
const skillCmd = slashCommands.find((cmd) => cmd.name === "skill")!;
|
||||
await skillCmd.execute("onboard-mentee");
|
||||
expect(claudeStore.addLine).toHaveBeenCalledWith(
|
||||
"error",
|
||||
expect.stringContaining("Failed to invoke skill:")
|
||||
);
|
||||
expect(characterState.setTemporaryState).toHaveBeenCalledWith("error", 3000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("/simplify execute", () => {
|
||||
it("shows error when no active conversation", async () => {
|
||||
getMock.mockReturnValue(null);
|
||||
const simplifyCmd = slashCommands.find((cmd) => cmd.name === "simplify")!;
|
||||
await simplifyCmd.execute("");
|
||||
expect(claudeStore.addLine).toHaveBeenCalledWith("error", "No active conversation");
|
||||
});
|
||||
|
||||
it("sends /simplify prompt to Claude when there is an active conversation", async () => {
|
||||
getMock.mockReturnValue("conv-123");
|
||||
invokeMock.mockResolvedValue(undefined);
|
||||
const simplifyCmd = slashCommands.find((cmd) => cmd.name === "simplify")!;
|
||||
await simplifyCmd.execute("");
|
||||
expect(invokeMock).toHaveBeenCalledWith("send_prompt", {
|
||||
conversationId: "conv-123",
|
||||
message: "/simplify",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("/loop execute", () => {
|
||||
it("shows error when no active conversation", async () => {
|
||||
getMock.mockReturnValue(null);
|
||||
const loopCmd = slashCommands.find((cmd) => cmd.name === "loop")!;
|
||||
await loopCmd.execute("5m /help");
|
||||
expect(claudeStore.addLine).toHaveBeenCalledWith("error", "No active conversation");
|
||||
});
|
||||
|
||||
it("sends /loop with args when args are provided", async () => {
|
||||
getMock.mockReturnValue("conv-123");
|
||||
invokeMock.mockResolvedValue(undefined);
|
||||
const loopCmd = slashCommands.find((cmd) => cmd.name === "loop")!;
|
||||
await loopCmd.execute("5m /help");
|
||||
expect(invokeMock).toHaveBeenCalledWith("send_prompt", {
|
||||
conversationId: "conv-123",
|
||||
message: "/loop 5m /help",
|
||||
});
|
||||
});
|
||||
|
||||
it("sends /loop without args when no args provided", async () => {
|
||||
getMock.mockReturnValue("conv-123");
|
||||
invokeMock.mockResolvedValue(undefined);
|
||||
const loopCmd = slashCommands.find((cmd) => cmd.name === "loop")!;
|
||||
await loopCmd.execute("");
|
||||
expect(invokeMock).toHaveBeenCalledWith("send_prompt", {
|
||||
conversationId: "conv-123",
|
||||
message: "/loop",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("/batch execute", () => {
|
||||
it("shows error when no active conversation", async () => {
|
||||
getMock.mockReturnValue(null);
|
||||
const batchCmd = slashCommands.find((cmd) => cmd.name === "batch")!;
|
||||
await batchCmd.execute("task1, task2");
|
||||
expect(claudeStore.addLine).toHaveBeenCalledWith("error", "No active conversation");
|
||||
});
|
||||
|
||||
it("sends /batch with args when args are provided", async () => {
|
||||
getMock.mockReturnValue("conv-123");
|
||||
invokeMock.mockResolvedValue(undefined);
|
||||
const batchCmd = slashCommands.find((cmd) => cmd.name === "batch")!;
|
||||
await batchCmd.execute("task1, task2");
|
||||
expect(invokeMock).toHaveBeenCalledWith("send_prompt", {
|
||||
conversationId: "conv-123",
|
||||
message: "/batch task1, task2",
|
||||
});
|
||||
});
|
||||
|
||||
it("sends /batch without args when no args provided", async () => {
|
||||
getMock.mockReturnValue("conv-123");
|
||||
invokeMock.mockResolvedValue(undefined);
|
||||
const batchCmd = slashCommands.find((cmd) => cmd.name === "batch")!;
|
||||
await batchCmd.execute("");
|
||||
expect(invokeMock).toHaveBeenCalledWith("send_prompt", {
|
||||
conversationId: "conv-123",
|
||||
message: "/batch",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("/memory execute", () => {
|
||||
it("opens the memory browser panel without requiring an active conversation", () => {
|
||||
getMock.mockReturnValue(null);
|
||||
const memoryCmd = slashCommands.find((cmd) => cmd.name === "memory")!;
|
||||
memoryCmd.execute("");
|
||||
expect(claudeStore.addLine).not.toHaveBeenCalled();
|
||||
expect(invokeMock).not.toHaveBeenCalledWith("send_prompt", expect.anything());
|
||||
});
|
||||
|
||||
it("does not send a prompt to Claude when executed", () => {
|
||||
getMock.mockReturnValue("conv-123");
|
||||
const memoryCmd = slashCommands.find((cmd) => cmd.name === "memory")!;
|
||||
memoryCmd.execute("");
|
||||
expect(invokeMock).not.toHaveBeenCalledWith("send_prompt", expect.anything());
|
||||
});
|
||||
});
|
||||
|
||||
describe("/context execute", () => {
|
||||
it("shows error when no active conversation", async () => {
|
||||
getMock.mockReturnValue(null);
|
||||
const contextCmd = slashCommands.find((cmd) => cmd.name === "context")!;
|
||||
await contextCmd.execute("");
|
||||
expect(claudeStore.addLine).toHaveBeenCalledWith("error", "No active conversation");
|
||||
});
|
||||
|
||||
it("sends /context prompt to Claude when there is an active conversation", async () => {
|
||||
getMock.mockReturnValue("conv-123");
|
||||
invokeMock.mockResolvedValue(undefined);
|
||||
const contextCmd = slashCommands.find((cmd) => cmd.name === "context")!;
|
||||
await contextCmd.execute("");
|
||||
expect(invokeMock).toHaveBeenCalledWith("send_prompt", {
|
||||
conversationId: "conv-123",
|
||||
message: "/context",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("/cd success path", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("changes directory and shows success message", async () => {
|
||||
getMock
|
||||
.mockReturnValueOnce("conv-123") // get(claudeStore.activeConversationId)
|
||||
.mockReturnValueOnce("/current") // get(claudeStore.currentWorkingDirectory)
|
||||
.mockReturnValueOnce(null); // get(conversationsStore.activeConversation)
|
||||
vi.mocked(claudeStore.getConversationHistory).mockReturnValue("");
|
||||
invokeMock
|
||||
.mockResolvedValueOnce("/new/path") // validate_directory
|
||||
.mockResolvedValueOnce(undefined) // stop_claude
|
||||
.mockResolvedValueOnce(undefined); // start_claude
|
||||
|
||||
const cdCmd = slashCommands.find((cmd) => cmd.name === "cd")!;
|
||||
const promise = cdCmd.execute("/new/path");
|
||||
await vi.runAllTimersAsync();
|
||||
await promise;
|
||||
|
||||
expect(claudeStore.addLine).toHaveBeenCalledWith(
|
||||
"system",
|
||||
"Changed directory to: /new/path"
|
||||
);
|
||||
expect(characterState.setState).toHaveBeenCalledWith("idle");
|
||||
});
|
||||
|
||||
it("sends context restoration message when conversation history exists", async () => {
|
||||
getMock
|
||||
.mockReturnValueOnce("conv-123") // get(claudeStore.activeConversationId)
|
||||
.mockReturnValueOnce("/current") // get(claudeStore.currentWorkingDirectory)
|
||||
.mockReturnValueOnce(null); // get(conversationsStore.activeConversation)
|
||||
vi.mocked(claudeStore.getConversationHistory).mockReturnValue(
|
||||
"previous conversation history"
|
||||
);
|
||||
invokeMock
|
||||
.mockResolvedValueOnce("/new/path") // validate_directory
|
||||
.mockResolvedValueOnce(undefined) // stop_claude
|
||||
.mockResolvedValueOnce(undefined) // start_claude
|
||||
.mockResolvedValueOnce(undefined); // send_prompt
|
||||
|
||||
const cdCmd = slashCommands.find((cmd) => cmd.name === "cd")!;
|
||||
const promise = cdCmd.execute("/new/path");
|
||||
await vi.runAllTimersAsync();
|
||||
await promise;
|
||||
|
||||
expect(invokeMock).toHaveBeenCalledWith(
|
||||
"send_prompt",
|
||||
expect.objectContaining({
|
||||
message: expect.stringContaining("previous conversation history"),
|
||||
})
|
||||
);
|
||||
expect(claudeStore.addLine).toHaveBeenCalledWith(
|
||||
"system",
|
||||
"Changed directory to: /new/path"
|
||||
);
|
||||
});
|
||||
|
||||
it("calls updateDiscordRpc when activeConversation is available", async () => {
|
||||
const activeConv = {
|
||||
name: "Test Conversation",
|
||||
model: "claude-sonnet",
|
||||
startedAt: new Date("2026-03-03T12:00:00Z"),
|
||||
grantedTools: new Set<string>(),
|
||||
};
|
||||
getMock
|
||||
.mockReturnValueOnce("conv-123") // get(claudeStore.activeConversationId)
|
||||
.mockReturnValueOnce("/current") // get(claudeStore.currentWorkingDirectory)
|
||||
.mockReturnValueOnce(activeConv); // get(conversationsStore.activeConversation)
|
||||
vi.mocked(claudeStore.getConversationHistory).mockReturnValue("");
|
||||
invokeMock
|
||||
.mockResolvedValueOnce("/new/path")
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockResolvedValueOnce(undefined);
|
||||
const { updateDiscordRpc } = await import("$lib/tauri");
|
||||
|
||||
const cdCmd = slashCommands.find((cmd) => cmd.name === "cd")!;
|
||||
const promise = cdCmd.execute("/new/path");
|
||||
await vi.runAllTimersAsync();
|
||||
await promise;
|
||||
|
||||
expect(updateDiscordRpc).toHaveBeenCalledWith(
|
||||
"Test Conversation",
|
||||
expect.any(String),
|
||||
expect.any(Date)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("/new success path", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("starts a new conversation and shows success message", async () => {
|
||||
getMock
|
||||
.mockReturnValueOnce("conv-123") // get(claudeStore.activeConversationId)
|
||||
.mockReturnValueOnce(null); // get(conversationsStore.activeConversation)
|
||||
invokeMock
|
||||
.mockResolvedValueOnce("/working/dir") // get_working_directory
|
||||
.mockResolvedValueOnce(undefined) // interrupt_claude
|
||||
.mockResolvedValueOnce(undefined); // start_claude
|
||||
|
||||
const newCmd = slashCommands.find((cmd) => cmd.name === "new")!;
|
||||
const promise = newCmd.execute("");
|
||||
await vi.runAllTimersAsync();
|
||||
await promise;
|
||||
|
||||
expect(claudeStore.addLine).toHaveBeenCalledWith("system", "New conversation started!");
|
||||
expect(characterState.setState).toHaveBeenCalledWith("idle");
|
||||
});
|
||||
|
||||
it("calls updateDiscordRpc when activeConversation is available", async () => {
|
||||
const activeConv = {
|
||||
name: "My Conv",
|
||||
model: "claude-sonnet",
|
||||
startedAt: new Date("2026-03-03T12:00:00Z"),
|
||||
grantedTools: new Set<string>(["tool1"]),
|
||||
};
|
||||
getMock.mockReturnValueOnce("conv-123").mockReturnValueOnce(activeConv);
|
||||
invokeMock
|
||||
.mockResolvedValueOnce("/working/dir")
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockResolvedValueOnce(undefined);
|
||||
const { updateDiscordRpc } = await import("$lib/tauri");
|
||||
|
||||
const newCmd = slashCommands.find((cmd) => cmd.name === "new")!;
|
||||
const promise = newCmd.execute("");
|
||||
await vi.runAllTimersAsync();
|
||||
await promise;
|
||||
|
||||
expect(updateDiscordRpc).toHaveBeenCalledWith(
|
||||
"My Conv",
|
||||
expect.any(String),
|
||||
expect.any(Date)
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,13 +2,18 @@ import { get } from "svelte/store";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { claudeStore } from "$lib/stores/claude";
|
||||
import { characterState } from "$lib/stores/character";
|
||||
import { setSkipNextGreeting } from "$lib/tauri";
|
||||
import { setSkipNextGreeting, updateDiscordRpc } from "$lib/tauri";
|
||||
import { searchState } from "$lib/stores/search";
|
||||
import { conversationsStore } from "$lib/stores/conversations";
|
||||
import { configStore } from "$lib/stores/config";
|
||||
import { memoryBrowserStore } from "$lib/stores/memoryBrowser";
|
||||
|
||||
export interface SlashCommand {
|
||||
name: string;
|
||||
description: string;
|
||||
usage: string;
|
||||
/** "cli" = built into Claude Code CLI; omitted = Hikari app command */
|
||||
source?: "cli";
|
||||
execute: (args: string) => Promise<void> | void;
|
||||
}
|
||||
|
||||
@@ -35,6 +40,12 @@ async function changeDirectory(path: string): Promise<void> {
|
||||
// Capture conversation history before disconnecting
|
||||
const conversationHistory = claudeStore.getConversationHistory();
|
||||
|
||||
// Get currently granted tools and config auto-granted tools
|
||||
const activeConversation = get(conversationsStore.activeConversation);
|
||||
const grantedTools = activeConversation ? Array.from(activeConversation.grantedTools) : [];
|
||||
const config = configStore.getConfig();
|
||||
const allAllowedTools = [...new Set([...grantedTools, ...config.auto_granted_tools])];
|
||||
|
||||
await invoke("stop_claude", { conversationId });
|
||||
|
||||
// Wait for clean shutdown
|
||||
@@ -48,9 +59,36 @@ async function changeDirectory(path: string): Promise<void> {
|
||||
conversationId,
|
||||
options: {
|
||||
working_dir: validatedPath,
|
||||
model: config.model || null,
|
||||
api_key: config.api_key || null,
|
||||
custom_instructions: config.custom_instructions || null,
|
||||
mcp_servers_json: config.mcp_servers_json || null,
|
||||
allowed_tools: allAllowedTools,
|
||||
use_worktree: config.use_worktree ?? false,
|
||||
disable_1m_context: config.disable_1m_context ?? false,
|
||||
max_output_tokens: config.max_output_tokens ?? null,
|
||||
disable_cron: config.disable_cron ?? false,
|
||||
disable_skill_shell_execution: config.disable_skill_shell_execution ?? false,
|
||||
include_git_instructions: config.include_git_instructions ?? true,
|
||||
enable_claudeai_mcp_servers: config.enable_claudeai_mcp_servers ?? true,
|
||||
auto_memory_directory: config.auto_memory_directory || null,
|
||||
model_overrides: config.model_overrides || null,
|
||||
session_name: null,
|
||||
bare_mode: config.bare_mode ?? false,
|
||||
show_clear_context_on_plan_accept: config.show_clear_context_on_plan_accept ?? true,
|
||||
custom_model_option: config.custom_model_option || null,
|
||||
},
|
||||
});
|
||||
|
||||
// Update Discord RPC when reconnecting after directory change
|
||||
if (activeConversation) {
|
||||
await updateDiscordRpc(
|
||||
activeConversation.name,
|
||||
config.model || "claude",
|
||||
activeConversation.startedAt
|
||||
);
|
||||
}
|
||||
|
||||
// Wait for connection to establish
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
|
||||
@@ -89,6 +127,12 @@ async function startNewConversation(): Promise<void> {
|
||||
conversationId,
|
||||
});
|
||||
|
||||
// Get granted tools before interrupting
|
||||
const activeConversation = get(conversationsStore.activeConversation);
|
||||
const grantedTools = activeConversation ? Array.from(activeConversation.grantedTools) : [];
|
||||
const config = configStore.getConfig();
|
||||
const allAllowedTools = [...new Set([...grantedTools, ...config.auto_granted_tools])];
|
||||
|
||||
claudeStore.addLine("system", "Starting new conversation...");
|
||||
characterState.setState("thinking");
|
||||
|
||||
@@ -102,9 +146,36 @@ async function startNewConversation(): Promise<void> {
|
||||
conversationId,
|
||||
options: {
|
||||
working_dir: workingDir,
|
||||
model: config.model || null,
|
||||
api_key: config.api_key || null,
|
||||
custom_instructions: config.custom_instructions || null,
|
||||
mcp_servers_json: config.mcp_servers_json || null,
|
||||
allowed_tools: allAllowedTools,
|
||||
use_worktree: config.use_worktree ?? false,
|
||||
disable_1m_context: config.disable_1m_context ?? false,
|
||||
max_output_tokens: config.max_output_tokens ?? null,
|
||||
disable_cron: config.disable_cron ?? false,
|
||||
disable_skill_shell_execution: config.disable_skill_shell_execution ?? false,
|
||||
include_git_instructions: config.include_git_instructions ?? true,
|
||||
enable_claudeai_mcp_servers: config.enable_claudeai_mcp_servers ?? true,
|
||||
auto_memory_directory: config.auto_memory_directory || null,
|
||||
model_overrides: config.model_overrides || null,
|
||||
session_name: null,
|
||||
bare_mode: config.bare_mode ?? false,
|
||||
show_clear_context_on_plan_accept: config.show_clear_context_on_plan_accept ?? true,
|
||||
custom_model_option: config.custom_model_option || null,
|
||||
},
|
||||
});
|
||||
|
||||
// Update Discord RPC when starting new conversation
|
||||
if (activeConversation) {
|
||||
await updateDiscordRpc(
|
||||
activeConversation.name,
|
||||
config.model || "claude",
|
||||
activeConversation.startedAt
|
||||
);
|
||||
}
|
||||
|
||||
claudeStore.addLine("system", "New conversation started!");
|
||||
characterState.setState("idle");
|
||||
} catch (error) {
|
||||
@@ -183,6 +254,74 @@ export const slashCommands: SlashCommand[] = [
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "simplify",
|
||||
description: "Review changed code for reuse, quality, and efficiency (Claude Code built-in)",
|
||||
usage: "/simplify",
|
||||
source: "cli",
|
||||
execute: async () => {
|
||||
const conversationId = get(claudeStore.activeConversationId);
|
||||
if (!conversationId) {
|
||||
claudeStore.addLine("error", "No active conversation");
|
||||
return;
|
||||
}
|
||||
await invoke("send_prompt", { conversationId, message: "/simplify" });
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "loop",
|
||||
description: "Run a prompt or slash command on a recurring interval (Claude Code built-in)",
|
||||
usage: "/loop [interval] [command]",
|
||||
source: "cli",
|
||||
execute: async (args: string) => {
|
||||
const conversationId = get(claudeStore.activeConversationId);
|
||||
if (!conversationId) {
|
||||
claudeStore.addLine("error", "No active conversation");
|
||||
return;
|
||||
}
|
||||
const message = args.trim() ? `/loop ${args.trim()}` : "/loop";
|
||||
await invoke("send_prompt", { conversationId, message });
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "batch",
|
||||
description: "Process multiple tasks in a single Claude Code session (Claude Code built-in)",
|
||||
usage: "/batch [tasks]",
|
||||
source: "cli",
|
||||
execute: async (args: string) => {
|
||||
const conversationId = get(claudeStore.activeConversationId);
|
||||
if (!conversationId) {
|
||||
claudeStore.addLine("error", "No active conversation");
|
||||
return;
|
||||
}
|
||||
const message = args.trim() ? `/batch ${args.trim()}` : "/batch";
|
||||
await invoke("send_prompt", { conversationId, message });
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "memory",
|
||||
description: "Open the memory browser panel to view and manage memory files",
|
||||
usage: "/memory",
|
||||
source: "cli",
|
||||
execute: () => {
|
||||
memoryBrowserStore.open();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "context",
|
||||
description:
|
||||
"Show current context window usage with optimisation suggestions (Claude Code built-in)",
|
||||
usage: "/context",
|
||||
source: "cli",
|
||||
execute: async () => {
|
||||
const conversationId = get(claudeStore.activeConversationId);
|
||||
if (!conversationId) {
|
||||
claudeStore.addLine("error", "No active conversation");
|
||||
return;
|
||||
}
|
||||
await invoke("send_prompt", { conversationId, message: "/context" });
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "skill",
|
||||
description: "Invoke a Claude Code skill from ~/.claude/skills/",
|
||||
|
||||
@@ -1,202 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { fade, fly } from "svelte/transition";
|
||||
import { cubicOut } from "svelte/easing";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import type { AchievementUnlockedEvent } from "$lib/types/achievements";
|
||||
|
||||
let achievements = $state<AchievementUnlockedEvent[]>([]);
|
||||
let currentAchievement = $state<AchievementUnlockedEvent | null>(null);
|
||||
let showNotification = $state(false);
|
||||
|
||||
onMount(() => {
|
||||
let unlisten: (() => void) | undefined;
|
||||
|
||||
const setupListener = async () => {
|
||||
unlisten = await listen<AchievementUnlockedEvent>("achievement:unlocked", (event) => {
|
||||
achievements.push(event.payload);
|
||||
if (!showNotification) {
|
||||
showNext();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
setupListener();
|
||||
|
||||
return () => {
|
||||
if (unlisten) {
|
||||
unlisten();
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
function showNext() {
|
||||
if (achievements.length > 0) {
|
||||
currentAchievement = achievements.shift() || null;
|
||||
showNotification = true;
|
||||
|
||||
// Auto-hide after 5 seconds
|
||||
setTimeout(() => {
|
||||
showNotification = false;
|
||||
// Show next achievement after animation completes
|
||||
setTimeout(() => showNext(), 300);
|
||||
}, 5000);
|
||||
}
|
||||
}
|
||||
|
||||
function dismiss() {
|
||||
showNotification = false;
|
||||
// Show next achievement after animation completes
|
||||
setTimeout(() => showNext(), 300);
|
||||
}
|
||||
|
||||
function getRarityColor(rarity: string): string {
|
||||
switch (rarity) {
|
||||
case "legendary":
|
||||
return "from-yellow-400 to-orange-500";
|
||||
case "epic":
|
||||
return "from-purple-400 to-pink-500";
|
||||
case "rare":
|
||||
return "from-blue-400 to-indigo-500";
|
||||
default:
|
||||
return "from-green-400 to-emerald-500";
|
||||
}
|
||||
}
|
||||
|
||||
function getAchievementRarity(id: string): string {
|
||||
// Determine rarity based on achievement ID
|
||||
if (id === "TokenMaster") return "legendary";
|
||||
if (["CodeMachine", "Unstoppable"].includes(id)) return "epic";
|
||||
if (
|
||||
[
|
||||
"BlossomingCoder",
|
||||
"CodeWizard",
|
||||
"MasterBuilder",
|
||||
"EnduranceChamp",
|
||||
"DeepDive",
|
||||
"CreativeCoder",
|
||||
].includes(id)
|
||||
)
|
||||
return "rare";
|
||||
return "common";
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if showNotification && currentAchievement}
|
||||
<div
|
||||
class="fixed top-20 right-4 z-50 max-w-sm"
|
||||
in:fly={{ x: 300, duration: 500, easing: cubicOut }}
|
||||
out:fade={{ duration: 300 }}
|
||||
>
|
||||
<!-- Backdrop with animated gradient border -->
|
||||
<div class="relative p-[2px] rounded-lg overflow-hidden">
|
||||
<!-- Animated gradient border -->
|
||||
<div
|
||||
class="absolute inset-0 bg-gradient-to-r {getRarityColor(
|
||||
getAchievementRarity(currentAchievement.achievement.id)
|
||||
)} animate-pulse"
|
||||
></div>
|
||||
|
||||
<!-- Main notification content -->
|
||||
<div class="relative bg-[var(--bg-primary)] rounded-lg p-4 shadow-2xl backdrop-blur-sm">
|
||||
<button
|
||||
onclick={dismiss}
|
||||
onkeydown={(e) => e.key === "Enter" && dismiss()}
|
||||
class="absolute top-2 right-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 transition-colors"
|
||||
aria-label="Dismiss notification"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
></path>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="flex items-start gap-4">
|
||||
<!-- Icon with animated sparkles -->
|
||||
<div class="relative flex-shrink-0">
|
||||
<div class="text-5xl animate-bounce">{currentAchievement.achievement.icon}</div>
|
||||
|
||||
<!-- Sparkle animations -->
|
||||
<div class="absolute -top-1 -right-1 text-yellow-400 animate-ping">✨</div>
|
||||
<div
|
||||
class="absolute -bottom-1 -left-1 text-yellow-400 animate-ping animation-delay-200"
|
||||
>
|
||||
✨
|
||||
</div>
|
||||
<div class="absolute top-1/2 -right-2 text-yellow-400 animate-ping animation-delay-400">
|
||||
✨
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Text content -->
|
||||
<div class="flex-1 min-w-0 pt-1">
|
||||
<h3
|
||||
class="text-sm font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide"
|
||||
>
|
||||
Achievement Unlocked!
|
||||
</h3>
|
||||
<p class="text-lg font-bold text-[var(--text-primary)] mt-1">
|
||||
{currentAchievement.achievement.name}
|
||||
</p>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
{currentAchievement.achievement.description}
|
||||
</p>
|
||||
|
||||
<!-- Rarity badge -->
|
||||
<div class="mt-2 inline-flex items-center">
|
||||
<span
|
||||
class="px-2 py-1 text-xs font-medium rounded-full bg-gradient-to-r {getRarityColor(
|
||||
getAchievementRarity(currentAchievement.achievement.id)
|
||||
)} text-white capitalize"
|
||||
>
|
||||
{getAchievementRarity(currentAchievement.achievement.id)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Celebration confetti effect (CSS only) -->
|
||||
<div class="absolute inset-0 pointer-events-none overflow-hidden rounded-lg">
|
||||
{#each Array(10) as _ (_)}
|
||||
<div
|
||||
class="absolute w-2 h-2 bg-gradient-to-br {getRarityColor(
|
||||
getAchievementRarity(currentAchievement.achievement.id)
|
||||
)} rounded-full animate-fall"
|
||||
style="left: {Math.random() * 100}%; animation-delay: {Math.random() *
|
||||
2}s; animation-duration: {2 + Math.random() * 2}s;"
|
||||
></div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
@keyframes fall {
|
||||
0% {
|
||||
transform: translateY(-20px) rotate(0deg);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: translateY(400px) rotate(720deg);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fall {
|
||||
animation: fall linear infinite;
|
||||
}
|
||||
|
||||
.animation-delay-200 {
|
||||
animation-delay: 200ms;
|
||||
}
|
||||
|
||||
.animation-delay-400 {
|
||||
animation-delay: 400ms;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,356 @@
|
||||
<script lang="ts">
|
||||
import { SvelteMap } from "svelte/reactivity";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { claudeStore } from "$lib/stores/claude";
|
||||
import { agentStore, getAgentsForConversation } from "$lib/stores/agents";
|
||||
import type { AgentInfo } from "$lib/types/agents";
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const { isOpen, onClose }: Props = $props();
|
||||
|
||||
let now = $state(Date.now());
|
||||
let timerInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
// We need a reactive subscription to agents for the active conversation
|
||||
let agents: AgentInfo[] = $state([]);
|
||||
let agentsUnsubscribe: (() => void) | null = null;
|
||||
|
||||
// Track active conversation reactively
|
||||
let currentConversationId = $state<string | null>("");
|
||||
const conversationIdUnsubscribe = claudeStore.activeConversationId.subscribe((id) => {
|
||||
currentConversationId = id;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
// Re-subscribe when conversation changes
|
||||
if (agentsUnsubscribe) {
|
||||
agentsUnsubscribe();
|
||||
}
|
||||
if (currentConversationId) {
|
||||
const store = getAgentsForConversation(currentConversationId);
|
||||
agentsUnsubscribe = store.subscribe((value) => {
|
||||
agents = value;
|
||||
});
|
||||
} else {
|
||||
agents = [];
|
||||
}
|
||||
});
|
||||
|
||||
const runningAgents = $derived(agents.filter((a) => a.status === "running"));
|
||||
const completedAgents = $derived(agents.filter((a) => a.status === "completed"));
|
||||
const erroredAgents = $derived(agents.filter((a) => a.status === "errored"));
|
||||
|
||||
// Organize agents into a tree structure based on parent_tool_use_id
|
||||
const agentTree = $derived.by(() => {
|
||||
const topLevel = agents.filter((a) => !a.parentToolUseId);
|
||||
const childrenMap = new SvelteMap<string, AgentInfo[]>();
|
||||
|
||||
// Group children by their parent
|
||||
agents.forEach((agent) => {
|
||||
if (agent.parentToolUseId) {
|
||||
const siblings = childrenMap.get(agent.parentToolUseId) || [];
|
||||
siblings.push(agent);
|
||||
childrenMap.set(agent.parentToolUseId, siblings);
|
||||
}
|
||||
});
|
||||
|
||||
return { topLevel, childrenMap };
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
timerInterval = setInterval(() => {
|
||||
now = Date.now();
|
||||
}, 1000);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (timerInterval) clearInterval(timerInterval);
|
||||
if (agentsUnsubscribe) agentsUnsubscribe();
|
||||
conversationIdUnsubscribe();
|
||||
});
|
||||
|
||||
function formatDuration(startedAt: number, endedAt?: number): string {
|
||||
const end = endedAt || now;
|
||||
const durationMs = end - startedAt;
|
||||
const seconds = Math.floor(durationMs / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes % 60}m ${seconds % 60}s`;
|
||||
}
|
||||
if (minutes > 0) {
|
||||
return `${minutes}m ${seconds % 60}s`;
|
||||
}
|
||||
return `${seconds}s`;
|
||||
}
|
||||
|
||||
function getSubagentTypeLabel(type: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
Explore: "Explorer",
|
||||
"general-purpose": "General",
|
||||
Plan: "Planner",
|
||||
Bash: "Shell",
|
||||
};
|
||||
return labels[type] || type;
|
||||
}
|
||||
|
||||
function getStatusBadgeClass(status: string): string {
|
||||
switch (status) {
|
||||
case "running":
|
||||
return "bg-blue-500/20 text-blue-400 border-blue-500/30";
|
||||
case "completed":
|
||||
return "bg-green-500/20 text-green-400 border-green-500/30";
|
||||
case "errored":
|
||||
return "bg-red-500/20 text-red-400 border-red-500/30";
|
||||
default:
|
||||
return "bg-gray-500/20 text-gray-400 border-gray-500/30";
|
||||
}
|
||||
}
|
||||
|
||||
async function handleKillAll() {
|
||||
if (!currentConversationId) return;
|
||||
|
||||
try {
|
||||
await invoke("interrupt_claude", { conversationId: currentConversationId });
|
||||
// Mark all running agents as errored after killing the process
|
||||
agentStore.markAllErrored(currentConversationId);
|
||||
} catch (error) {
|
||||
console.error("Failed to kill Claude process:", error);
|
||||
}
|
||||
}
|
||||
|
||||
function handleClearCompleted() {
|
||||
if (currentConversationId) {
|
||||
agentStore.clearCompleted(currentConversationId);
|
||||
}
|
||||
}
|
||||
|
||||
// Flatten the tree for rendering with depth information
|
||||
const flattenedAgents = $derived.by(() => {
|
||||
const result: { agent: AgentInfo; depth: number }[] = [];
|
||||
const { topLevel, childrenMap } = agentTree;
|
||||
|
||||
function addAgentAndChildren(agent: AgentInfo, depth: number) {
|
||||
result.push({ agent, depth });
|
||||
const children = childrenMap.get(agent.toolUseId);
|
||||
if (children) {
|
||||
children.forEach((child) => addAgentAndChildren(child, depth + 1));
|
||||
}
|
||||
}
|
||||
|
||||
topLevel.forEach((agent) => addAgentAndChildren(agent, 0));
|
||||
return result;
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if isOpen}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="fixed inset-0 z-40" onclick={onClose}></div>
|
||||
|
||||
<div
|
||||
class="fixed top-12 right-0 bottom-0 w-80 bg-[var(--bg-primary)] border-l border-[var(--border-color)] shadow-xl z-50 flex flex-col overflow-hidden"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between p-4 border-b border-[var(--border-color)]">
|
||||
<div class="flex items-center gap-2">
|
||||
<svg
|
||||
class="w-5 h-5 text-[var(--accent-primary)]"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"
|
||||
/>
|
||||
</svg>
|
||||
<h3 class="text-sm font-semibold text-[var(--text-primary)]">Agent Monitor</h3>
|
||||
{#if runningAgents.length > 0}
|
||||
<span
|
||||
class="px-1.5 py-0.5 text-xs rounded-full bg-blue-500/20 text-blue-400 animate-pulse"
|
||||
>
|
||||
{runningAgents.length} running
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
onclick={onClose}
|
||||
class="p-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
||||
aria-label="Close agent monitor"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Action buttons -->
|
||||
<div class="flex gap-2 px-4 py-2 border-b border-[var(--border-color)]">
|
||||
<button
|
||||
onclick={handleKillAll}
|
||||
disabled={runningAgents.length === 0}
|
||||
class="flex-1 px-2 py-1 text-xs bg-red-500/20 hover:bg-red-500/30 text-red-400 rounded transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
title="Kills the entire Claude Code process to stop all agents"
|
||||
>
|
||||
Kill All
|
||||
</button>
|
||||
<button
|
||||
onclick={handleClearCompleted}
|
||||
disabled={completedAgents.length === 0 && erroredAgents.length === 0}
|
||||
class="flex-1 px-2 py-1 text-xs bg-[var(--bg-secondary)] hover:bg-[var(--bg-hover,var(--bg-secondary))] text-[var(--text-secondary)] rounded transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
Clear Finished
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Agent list -->
|
||||
<div class="flex-1 overflow-y-auto p-4 space-y-2">
|
||||
{#if agents.length === 0}
|
||||
<div
|
||||
class="flex flex-col items-center justify-center h-full text-[var(--text-secondary)] text-sm"
|
||||
>
|
||||
<svg
|
||||
class="w-8 h-8 mb-2 opacity-50"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"
|
||||
/>
|
||||
</svg>
|
||||
<p>No agents detected yet</p>
|
||||
<p class="text-xs mt-1 opacity-70">
|
||||
Agents will appear here when Claude uses the Task tool
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
{#each flattenedAgents as { agent, depth } (agent.toolUseId)}
|
||||
<div
|
||||
class="p-3 rounded-lg border border-[var(--border-color)] bg-[var(--bg-secondary)] {agent.status ===
|
||||
'running'
|
||||
? 'border-l-2 border-l-blue-500'
|
||||
: agent.status === 'errored'
|
||||
? 'border-l-2 border-l-red-500'
|
||||
: 'border-l-2 border-l-green-500'}"
|
||||
style="margin-left: {depth * 12}px; width: calc(100% - {depth * 12}px);"
|
||||
>
|
||||
<!-- Agent header -->
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<div class="flex items-center gap-1.5">
|
||||
{#if depth > 0}
|
||||
<svg
|
||||
class="w-3 h-3 text-[var(--text-secondary)]"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
<img
|
||||
src={agent.characterAvatar}
|
||||
alt={agent.characterName}
|
||||
class="w-5 h-5 rounded-full object-cover"
|
||||
/>
|
||||
<span class="text-[10px] font-medium text-[var(--text-primary)]">
|
||||
{agent.characterName}
|
||||
</span>
|
||||
<span
|
||||
class="px-1.5 py-0.5 text-[10px] rounded border {getStatusBadgeClass(
|
||||
agent.status
|
||||
)}"
|
||||
title={agent.agentId ? `ID: ${agent.agentId}` : undefined}
|
||||
>
|
||||
{getSubagentTypeLabel(agent.agentType ?? agent.subagentType)}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
class="text-[10px] {agent.status === 'running'
|
||||
? 'text-blue-400'
|
||||
: 'text-[var(--text-secondary)]'}"
|
||||
>
|
||||
{#if agent.durationMs !== undefined}
|
||||
{Math.floor(agent.durationMs / 1000)}s
|
||||
{:else}
|
||||
{formatDuration(agent.startedAt, agent.endedAt)}
|
||||
{/if}
|
||||
{#if agent.status === "running"}
|
||||
<span class="inline-block w-1 h-1 bg-blue-400 rounded-full animate-pulse ml-1"
|
||||
></span>
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Agent description -->
|
||||
<p class="text-xs text-[var(--text-primary)] truncate" title={agent.description}>
|
||||
{agent.description}
|
||||
</p>
|
||||
|
||||
<!-- Model override badge -->
|
||||
{#if agent.model}
|
||||
<p class="mt-0.5 text-[10px] text-purple-400 truncate" title="Model: {agent.model}">
|
||||
✦ {agent.model}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<!-- Status indicator -->
|
||||
<div class="mt-1 flex items-center gap-1">
|
||||
{#if agent.status === "running"}
|
||||
<span class="text-[10px] text-blue-400">Running...</span>
|
||||
{:else if agent.status === "completed"}
|
||||
<span class="text-[10px] text-green-400">Completed</span>
|
||||
{:else}
|
||||
<span class="text-[10px] text-red-400">Errored / Killed</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Last assistant message snippet -->
|
||||
{#if agent.lastAssistantMessage}
|
||||
<p
|
||||
class="mt-1 text-[10px] text-[var(--text-secondary)] italic truncate"
|
||||
title={agent.lastAssistantMessage}
|
||||
>
|
||||
{agent.lastAssistantMessage}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Footer summary -->
|
||||
{#if agents.length > 0}
|
||||
<div
|
||||
class="px-4 py-2 border-t border-[var(--border-color)] text-[10px] text-[var(--text-secondary)]"
|
||||
>
|
||||
{agents.length} total ·
|
||||
{runningAgents.length} running ·
|
||||
{completedAgents.length} completed ·
|
||||
{erroredAgents.length} errored
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,140 @@
|
||||
<script lang="ts">
|
||||
import { CHARACTER_POOL } from "$lib/utils/agentCharacters";
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const { onClose }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4"
|
||||
onclick={onClose}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onkeydown={(e) => e.key === "Escape" && onClose()}
|
||||
>
|
||||
<div
|
||||
class="bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg shadow-xl max-w-2xl w-full p-6 max-h-[90vh] overflow-y-auto"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-labelledby="cast-title"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h2 id="cast-title" class="text-xl font-semibold text-[var(--text-primary)]">
|
||||
Meet the Team
|
||||
</h2>
|
||||
<button
|
||||
onclick={onClose}
|
||||
class="p-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
||||
aria-label="Close"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Principal cast: Hikari + Naomi -->
|
||||
<div class="grid grid-cols-1 gap-3 mb-6 sm:grid-cols-2">
|
||||
<div
|
||||
class="flex items-center gap-3 p-4 rounded-lg bg-[var(--bg-secondary)] border border-[var(--accent-primary)]/40"
|
||||
>
|
||||
<img
|
||||
src="https://cdn.nhcarrigan.com/hikari.png"
|
||||
alt="Hikari"
|
||||
class="w-16 h-16 object-cover rounded-full border-2 border-[var(--border-color)] shrink-0"
|
||||
/>
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span class="font-semibold text-[var(--text-primary)]">Hikari</span>
|
||||
<span
|
||||
class="text-xs px-2 py-0.5 rounded-full bg-[var(--accent-primary)]/20 text-[var(--accent-primary)] font-medium"
|
||||
>
|
||||
Chief Operating Officer
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-xs text-[var(--text-secondary)]">
|
||||
Holds the line so the others don't have to. Never without her clipboard — or her
|
||||
glasses.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center gap-3 p-4 rounded-lg bg-[var(--bg-secondary)] border border-[var(--accent-primary)]/40"
|
||||
>
|
||||
<img
|
||||
src="https://cdn.nhcarrigan.com/profile.png"
|
||||
alt="Naomi"
|
||||
class="w-16 h-16 object-cover rounded-full border-2 border-[var(--border-color)] shrink-0"
|
||||
/>
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span class="font-semibold text-[var(--text-primary)]">Naomi</span>
|
||||
<span
|
||||
class="text-xs px-2 py-0.5 rounded-full bg-[var(--accent-primary)]/20 text-[var(--accent-primary)] font-medium"
|
||||
>
|
||||
Chief hEx-ecutive Officer
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-xs text-[var(--text-secondary)]">
|
||||
A 525-year-old vampire running a tech company from behind a VTuber avatar. Fixes server
|
||||
crashes at 4 AM.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Subagent girls grid -->
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-[var(--text-secondary)] uppercase tracking-wider mb-3">
|
||||
Subagent Squad
|
||||
</h3>
|
||||
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||
{#each CHARACTER_POOL as character (character.name)}
|
||||
<div
|
||||
class="flex flex-col items-center gap-2 p-3 rounded-lg bg-[var(--bg-secondary)] border border-[var(--border-color)] text-center"
|
||||
>
|
||||
<img
|
||||
src={character.avatar}
|
||||
alt={character.name}
|
||||
class="w-14 h-14 object-cover rounded-full border-2 border-[var(--border-color)]"
|
||||
/>
|
||||
<span class="text-sm font-medium text-[var(--text-primary)]">{character.name}</span>
|
||||
<span
|
||||
class="text-xs px-2 py-0.5 rounded-full bg-[var(--accent-primary)]/20 text-[var(--accent-primary)] font-medium"
|
||||
>
|
||||
{character.title}
|
||||
</span>
|
||||
<p class="text-xs text-[var(--text-secondary)] leading-snug">{character.description}</p>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
[role="dialog"] {
|
||||
animation: slideIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,153 @@
|
||||
<script lang="ts">
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||
import { getVersion } from "@tauri-apps/api/app";
|
||||
import { onMount } from "svelte";
|
||||
import type { ChangelogEntry } from "$lib/types/messages";
|
||||
import Markdown from "./Markdown.svelte";
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const { onClose }: Props = $props();
|
||||
|
||||
let entries = $state<ChangelogEntry[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
let currentVersion = $state("");
|
||||
|
||||
export function formatReleaseDate(isoString: string): string {
|
||||
if (!isoString) return "Unknown date";
|
||||
const date = new Date(isoString);
|
||||
if (isNaN(date.getTime())) return "Unknown date";
|
||||
return date.toLocaleDateString("en-GB", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
timeZone: "UTC",
|
||||
});
|
||||
}
|
||||
|
||||
async function loadChangelog(): Promise<void> {
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
entries = await invoke<ChangelogEntry[]>("fetch_changelog");
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
currentVersion = await getVersion();
|
||||
await loadChangelog();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4"
|
||||
onclick={onClose}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onkeydown={(e) => e.key === "Escape" && onClose()}
|
||||
>
|
||||
<div
|
||||
class="bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg shadow-xl max-w-2xl w-full max-h-[80vh] overflow-hidden flex flex-col"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-labelledby="changelog-title"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div class="flex items-center justify-between p-6 pb-4 border-b border-[var(--border-color)]">
|
||||
<h2 id="changelog-title" class="text-xl font-semibold text-[var(--text-primary)]">
|
||||
Changelog
|
||||
</h2>
|
||||
<button
|
||||
onclick={onClose}
|
||||
class="p-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
||||
aria-label="Close"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="overflow-y-auto flex-1 p-6">
|
||||
{#if loading}
|
||||
<div class="flex items-center justify-center py-12">
|
||||
<div
|
||||
class="w-8 h-8 border-2 border-[var(--accent-primary)] border-t-transparent rounded-full animate-spin"
|
||||
></div>
|
||||
<span class="ml-3 text-[var(--text-secondary)]">Fetching releases...</span>
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="text-center py-12">
|
||||
<p class="text-red-400 mb-4">{error}</p>
|
||||
<button onclick={loadChangelog} class="btn-trans-gradient px-4 py-2 rounded text-sm">
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
{:else if entries.length === 0}
|
||||
<p class="text-center text-[var(--text-secondary)] py-12">No releases found.</p>
|
||||
{:else}
|
||||
<div class="space-y-6">
|
||||
{#each entries as entry (entry.version)}
|
||||
<div class="border border-[var(--border-color)] rounded-lg overflow-hidden">
|
||||
<div
|
||||
class="flex flex-wrap items-center gap-2 px-4 py-3 bg-[var(--bg-secondary)] border-b border-[var(--border-color)]"
|
||||
>
|
||||
<span
|
||||
class="font-mono font-semibold text-sm {entry.version === `v${currentVersion}`
|
||||
? 'text-[var(--trans-pink)]'
|
||||
: 'text-[var(--text-primary)]'}"
|
||||
>
|
||||
{entry.version}
|
||||
</span>
|
||||
{#if entry.version === `v${currentVersion}`}
|
||||
<span
|
||||
class="text-xs px-2 py-0.5 rounded-full bg-[var(--trans-pink)]/20 text-[var(--trans-pink)] border border-[var(--trans-pink)]/30"
|
||||
>
|
||||
current
|
||||
</span>
|
||||
{/if}
|
||||
{#if entry.prerelease}
|
||||
<span
|
||||
class="text-xs px-2 py-0.5 rounded-full bg-yellow-500/20 text-yellow-400 border border-yellow-500/30"
|
||||
>
|
||||
pre-release
|
||||
</span>
|
||||
{/if}
|
||||
<span class="ml-auto text-xs text-[var(--text-muted)]">
|
||||
{formatReleaseDate(entry.created_at)}
|
||||
</span>
|
||||
<button
|
||||
onclick={() => openUrl(entry.url)}
|
||||
class="text-xs text-[var(--accent-primary)] hover:text-[var(--accent-primary-hover)] transition-colors underline"
|
||||
>
|
||||
View on Gitea
|
||||
</button>
|
||||
</div>
|
||||
{#if entry.notes}
|
||||
<div class="p-4 text-sm text-[var(--text-secondary)]">
|
||||
<Markdown content={entry.notes} />
|
||||
</div>
|
||||
{:else}
|
||||
<p class="p-4 text-xs text-[var(--text-muted)] italic">No release notes.</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* ChangelogPanel Component Tests
|
||||
*
|
||||
* Tests the pure helper function exported by ChangelogPanel for formatting
|
||||
* ISO 8601 date strings into human-readable release dates.
|
||||
*
|
||||
* What this component does:
|
||||
* - Opens as a modal dialog from the nav menu
|
||||
* - Fetches all releases via the `fetch_changelog` Tauri IPC command on mount
|
||||
* - Shows a loading spinner while fetching
|
||||
* - Renders each release with version badge, date, pre-release badge, and notes
|
||||
* - Highlights the currently installed version with a pink "current" badge
|
||||
* - Provides a "View on Gitea" link per release
|
||||
* - Shows an error state with a Retry button if the fetch fails
|
||||
*
|
||||
* Manual testing checklist:
|
||||
* - [ ] Changelog item appears in the nav dropdown
|
||||
* - [ ] Clicking opens the panel with a loading spinner
|
||||
* - [ ] Spinner resolves to a list of releases
|
||||
* - [ ] Current version entry shows pink version text + "current" badge
|
||||
* - [ ] Pre-release entries show a yellow "pre-release" badge
|
||||
* - [ ] "View on Gitea" opens the release URL in the browser
|
||||
* - [ ] Backdrop click and Escape key close the panel
|
||||
* - [ ] Network error shows a red error message and a Retry button
|
||||
* - [ ] Retry button re-fetches the changelog
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
function formatReleaseDate(isoString: string): string {
|
||||
if (!isoString) return "Unknown date";
|
||||
const date = new Date(isoString);
|
||||
if (isNaN(date.getTime())) return "Unknown date";
|
||||
return date.toLocaleDateString("en-GB", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
timeZone: "UTC",
|
||||
});
|
||||
}
|
||||
|
||||
// ---
|
||||
|
||||
describe("formatReleaseDate", () => {
|
||||
it("formats a valid ISO 8601 timestamp to en-GB locale", () => {
|
||||
const result = formatReleaseDate("2026-02-25T00:00:00Z");
|
||||
// en-GB format: "25 February 2026"
|
||||
expect(result).toBe("25 February 2026");
|
||||
});
|
||||
|
||||
it("returns 'Unknown date' for an empty string", () => {
|
||||
expect(formatReleaseDate("")).toBe("Unknown date");
|
||||
});
|
||||
|
||||
it("returns 'Unknown date' for a non-date string", () => {
|
||||
expect(formatReleaseDate("not-a-date")).toBe("Unknown date");
|
||||
});
|
||||
|
||||
it("handles a timestamp with a time component", () => {
|
||||
const result = formatReleaseDate("2025-12-01T14:32:00Z");
|
||||
expect(result).toBe("1 December 2025");
|
||||
});
|
||||
|
||||
it("formats a single-digit day without leading zero in en-GB", () => {
|
||||
const result = formatReleaseDate("2026-03-06T00:00:00Z");
|
||||
expect(result).toBe("6 March 2026");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,222 @@
|
||||
<script lang="ts">
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { onMount } from "svelte";
|
||||
|
||||
const SUPPORTED_CLI_VERSION = "2.1.104";
|
||||
|
||||
let installedVersion = $state("Loading...");
|
||||
let latestNpmVersion = $state<string | null>(null);
|
||||
|
||||
function compareVersions(a: string, b: string): number {
|
||||
const aParts = a.split(".").map(Number);
|
||||
const bParts = b.split(".").map(Number);
|
||||
for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) {
|
||||
const aVal = aParts[i] ?? 0;
|
||||
const bVal = bParts[i] ?? 0;
|
||||
if (aVal > bVal) return 1;
|
||||
if (aVal < bVal) return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
let displayVersion = $derived(installedVersion.split(" (")[0]);
|
||||
|
||||
let supportedBadgeState = $derived.by(() => {
|
||||
if (installedVersion === "Loading..." || installedVersion === "Unknown") {
|
||||
return "neutral";
|
||||
}
|
||||
const semverMatch = /(\d+\.\d+\.\d+)/.exec(installedVersion);
|
||||
if (!semverMatch) return "neutral";
|
||||
const cmp = compareVersions(semverMatch[1], SUPPORTED_CLI_VERSION);
|
||||
if (cmp > 0) return "ahead";
|
||||
if (cmp < 0) return "behind";
|
||||
return "current";
|
||||
});
|
||||
|
||||
let updateAvailable = $derived.by(() => {
|
||||
if (!latestNpmVersion || installedVersion === "Loading..." || installedVersion === "Unknown") {
|
||||
return false;
|
||||
}
|
||||
const semverMatch = /(\d+\.\d+\.\d+)/.exec(installedVersion);
|
||||
if (!semverMatch) return false;
|
||||
return compareVersions(semverMatch[1], latestNpmVersion) < 0;
|
||||
});
|
||||
|
||||
async function fetchVersion() {
|
||||
try {
|
||||
const result = await invoke<string>("get_claude_version");
|
||||
installedVersion = result;
|
||||
} catch (error) {
|
||||
console.error("Failed to get Claude CLI version:", error);
|
||||
installedVersion = "Unknown";
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchLatestNpmVersion() {
|
||||
try {
|
||||
const result = await invoke<string>("check_cli_latest_version");
|
||||
latestNpmVersion = result;
|
||||
} catch (error) {
|
||||
console.error("Failed to check latest CLI version:", error);
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
fetchVersion();
|
||||
fetchLatestNpmVersion();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="cli-versions">
|
||||
<div
|
||||
class="cli-version {updateAvailable ? 'update-available' : ''}"
|
||||
title={updateAvailable
|
||||
? `Update available: ${latestNpmVersion} — run: npm install -g @anthropic-ai/claude-code`
|
||||
: "Installed CLI version"}
|
||||
>
|
||||
<svg
|
||||
class="terminal-icon"
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<polyline points="4 17 10 11 4 5" />
|
||||
<line x1="12" y1="19" x2="20" y2="19" />
|
||||
</svg>
|
||||
<span class="version-text">CLI {displayVersion}</span>
|
||||
{#if updateAvailable}
|
||||
<svg
|
||||
class="update-icon"
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<polyline points="17 11 12 6 7 11" />
|
||||
<line x1="12" y1="6" x2="12" y2="18" />
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="cli-version supported {supportedBadgeState}" title="Highest audited CLI version">
|
||||
<svg
|
||||
class="terminal-icon"
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
|
||||
</svg>
|
||||
<span class="version-text">Supported {SUPPORTED_CLI_VERSION}</span>
|
||||
</div>
|
||||
|
||||
{#if supportedBadgeState === "ahead"}
|
||||
<span class="version-warning ahead"
|
||||
>Your version is newer, some features may not be supported</span
|
||||
>
|
||||
{:else if supportedBadgeState === "behind"}
|
||||
<span class="version-warning behind"
|
||||
>Your version is out of date, please update to ensure compatibility</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.cli-versions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.cli-version {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 12px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.85rem;
|
||||
font-family: var(--font-mono, monospace);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.cli-version:hover {
|
||||
border-color: var(--accent-primary);
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.cli-version.supported.current {
|
||||
border-color: var(--success-color, #4caf50);
|
||||
color: var(--success-color, #4caf50);
|
||||
}
|
||||
|
||||
.cli-version.supported.ahead {
|
||||
border-color: var(--warning-color, #ff9800);
|
||||
color: var(--warning-color, #ff9800);
|
||||
}
|
||||
|
||||
.cli-version.supported.behind {
|
||||
border-color: var(--error-color, #f44336);
|
||||
color: var(--error-color, #f44336);
|
||||
}
|
||||
|
||||
.cli-version.update-available {
|
||||
border-color: var(--warning-color, #ff9800);
|
||||
color: var(--warning-color, #ff9800);
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.update-icon {
|
||||
flex-shrink: 0;
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.terminal-icon {
|
||||
flex-shrink: 0;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.version-text {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.version-warning {
|
||||
font-size: 0.75rem;
|
||||
font-style: italic;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.version-warning.ahead {
|
||||
color: var(--warning-color, #ff9800);
|
||||
}
|
||||
|
||||
.version-warning.behind {
|
||||
color: var(--error-color, #f44336);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* CliVersion Component Tests
|
||||
*
|
||||
* Tests the version comparison logic used by the CliVersion component,
|
||||
* which compares the installed CLI version against the supported version.
|
||||
*
|
||||
* What this component does:
|
||||
* - Displays the installed Claude CLI version
|
||||
* - Displays the highest audited/supported CLI version
|
||||
* - Shows a warning when the installed version is ahead of or behind supported
|
||||
*
|
||||
* Manual testing checklist:
|
||||
* - [ ] Installed version is fetched and displayed on mount
|
||||
* - [ ] "current" badge shows in green when versions match
|
||||
* - [ ] "ahead" badge shows in amber when installed is newer than supported
|
||||
* - [ ] "behind" badge shows in red when installed is older than supported
|
||||
* - [ ] Warning message appears for "ahead" and "behind" states
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
const SUPPORTED_CLI_VERSION = "2.1.74";
|
||||
|
||||
function compareVersions(a: string, b: string): number {
|
||||
const aParts = a.split(".").map(Number);
|
||||
const bParts = b.split(".").map(Number);
|
||||
for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) {
|
||||
const aVal = aParts[i] ?? 0;
|
||||
const bVal = bParts[i] ?? 0;
|
||||
if (aVal > bVal) return 1;
|
||||
if (aVal < bVal) return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ---
|
||||
|
||||
describe("SUPPORTED_CLI_VERSION", () => {
|
||||
it("is defined and non-empty", () => {
|
||||
expect(SUPPORTED_CLI_VERSION).toBeTruthy();
|
||||
});
|
||||
|
||||
it("matches the expected audited version", () => {
|
||||
expect(SUPPORTED_CLI_VERSION).toBe("2.1.74");
|
||||
});
|
||||
});
|
||||
|
||||
describe("compareVersions", () => {
|
||||
describe("equal versions", () => {
|
||||
it("returns 0 for identical versions", () => {
|
||||
expect(compareVersions("1.0.0", "1.0.0")).toBe(0);
|
||||
});
|
||||
|
||||
it("returns 0 for the supported CLI version against itself", () => {
|
||||
expect(compareVersions(SUPPORTED_CLI_VERSION, SUPPORTED_CLI_VERSION)).toBe(0);
|
||||
});
|
||||
|
||||
it("returns 0 for 0.0.0 vs 0.0.0", () => {
|
||||
expect(compareVersions("0.0.0", "0.0.0")).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("major version differences", () => {
|
||||
it("returns 1 when a has a higher major version", () => {
|
||||
expect(compareVersions("2.0.0", "1.0.0")).toBe(1);
|
||||
});
|
||||
|
||||
it("returns -1 when a has a lower major version", () => {
|
||||
expect(compareVersions("1.0.0", "2.0.0")).toBe(-1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("minor version differences", () => {
|
||||
it("returns 1 when a has a higher minor version", () => {
|
||||
expect(compareVersions("1.2.0", "1.1.0")).toBe(1);
|
||||
});
|
||||
|
||||
it("returns -1 when a has a lower minor version", () => {
|
||||
expect(compareVersions("1.1.0", "1.2.0")).toBe(-1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("patch version differences", () => {
|
||||
it("returns 1 when a has a higher patch version", () => {
|
||||
expect(compareVersions("1.0.2", "1.0.1")).toBe(1);
|
||||
});
|
||||
|
||||
it("returns -1 when a has a lower patch version", () => {
|
||||
expect(compareVersions("1.0.1", "1.0.2")).toBe(-1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("major version takes precedence", () => {
|
||||
it("returns 1 when a has a higher major but lower minor", () => {
|
||||
expect(compareVersions("2.0.0", "1.9.9")).toBe(1);
|
||||
});
|
||||
|
||||
it("returns -1 when a has a lower major but higher minor", () => {
|
||||
expect(compareVersions("1.9.9", "2.0.0")).toBe(-1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("unequal segment counts", () => {
|
||||
it("treats missing segments as 0 (a shorter than b)", () => {
|
||||
expect(compareVersions("1.0", "1.0.0")).toBe(0);
|
||||
});
|
||||
|
||||
it("treats missing segments as 0 (a longer than b)", () => {
|
||||
expect(compareVersions("1.0.0", "1.0")).toBe(0);
|
||||
});
|
||||
|
||||
it("correctly compares when a has an extra non-zero segment", () => {
|
||||
expect(compareVersions("1.0.1", "1.0")).toBe(1);
|
||||
});
|
||||
|
||||
it("correctly compares when b has an extra non-zero segment", () => {
|
||||
expect(compareVersions("1.0", "1.0.1")).toBe(-1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("supported CLI version comparisons", () => {
|
||||
it("returns 1 for a version ahead of supported", () => {
|
||||
expect(compareVersions("2.2.0", SUPPORTED_CLI_VERSION)).toBe(1);
|
||||
});
|
||||
|
||||
it("returns -1 for a version behind supported", () => {
|
||||
expect(compareVersions("2.1.0", SUPPORTED_CLI_VERSION)).toBe(-1);
|
||||
});
|
||||
|
||||
it("returns 0 for exactly the supported version", () => {
|
||||
expect(compareVersions("2.1.74", SUPPORTED_CLI_VERSION)).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Mirrors the updateAvailable derived logic in CliVersion.svelte
|
||||
function isUpdateAvailable(installedVersion: string, latestNpmVersion: string | null): boolean {
|
||||
if (!latestNpmVersion || installedVersion === "Loading..." || installedVersion === "Unknown") {
|
||||
return false;
|
||||
}
|
||||
const semverMatch = /(\d+\.\d+\.\d+)/.exec(installedVersion);
|
||||
if (!semverMatch) return false;
|
||||
return compareVersions(semverMatch[1], latestNpmVersion) < 0;
|
||||
}
|
||||
|
||||
describe("updateAvailable", () => {
|
||||
it("returns false when latestNpmVersion is null", () => {
|
||||
expect(isUpdateAvailable("2.1.70", null)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when installed is Loading...", () => {
|
||||
expect(isUpdateAvailable("Loading...", "2.1.74")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when installed is Unknown", () => {
|
||||
expect(isUpdateAvailable("Unknown", "2.1.74")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when installed equals latest", () => {
|
||||
expect(isUpdateAvailable("2.1.74", "2.1.74")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when installed is ahead of latest", () => {
|
||||
expect(isUpdateAvailable("2.1.75", "2.1.74")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when installed is behind latest", () => {
|
||||
expect(isUpdateAvailable("2.1.70", "2.1.74")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true when installed has a lower minor version", () => {
|
||||
expect(isUpdateAvailable("2.0.99", "2.1.74")).toBe(true);
|
||||
});
|
||||
|
||||
it("handles version strings with extra info like '2.1.70 (build 123)'", () => {
|
||||
expect(isUpdateAvailable("2.1.70 (build 123)", "2.1.74")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for unparseable installed version", () => {
|
||||
expect(isUpdateAvailable("not-a-version", "2.1.74")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
hasActiveConversation: boolean;
|
||||
onClose: () => void;
|
||||
onMinimize: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const { isOpen, hasActiveConversation, onClose, onMinimize, onCancel }: Props = $props();
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (!isOpen) return;
|
||||
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
onCancel();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
{#if isOpen}
|
||||
<div
|
||||
class="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4"
|
||||
onclick={onCancel}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onkeydown={(e) => e.key === " " && onCancel()}
|
||||
>
|
||||
<div
|
||||
class="bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg shadow-xl max-w-md w-full"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-labelledby="confirm-title"
|
||||
aria-describedby="confirm-message"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div class="p-6">
|
||||
<div class="flex items-start gap-4">
|
||||
<div
|
||||
class="w-10 h-10 rounded-lg bg-yellow-500/20 flex items-center justify-center flex-shrink-0"
|
||||
>
|
||||
<svg
|
||||
class="w-6 h-6 text-yellow-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h3 id="confirm-title" class="text-lg font-semibold text-[var(--text-primary)] mb-1">
|
||||
Close Hikari Desktop?
|
||||
</h3>
|
||||
<p id="confirm-message" class="text-sm text-[var(--text-secondary)]">
|
||||
{#if hasActiveConversation}
|
||||
You have an active conversation with Claude. Are you sure you want to close the
|
||||
application? Your conversation history will be saved, but any in-progress tasks will
|
||||
be interrupted.
|
||||
{:else}
|
||||
Are you sure you want to close the application?
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 mt-6 justify-end">
|
||||
<button
|
||||
onclick={onCancel}
|
||||
class="px-4 py-2 text-sm font-medium text-gray-300 bg-[var(--bg-secondary)] hover:bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded-lg transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onclick={onMinimize}
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors"
|
||||
>
|
||||
Minimize to Tray
|
||||
</button>
|
||||
<button
|
||||
onclick={onClose}
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 rounded-lg transition-colors"
|
||||
>
|
||||
Close Application
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
[role="dialog"] {
|
||||
animation: slideIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95) translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -3,8 +3,9 @@
|
||||
import { get } from "svelte/store";
|
||||
import { claudeStore, isClaudeProcessing } from "$lib/stores/claude";
|
||||
import { characterState, characterInfo } from "$lib/stores/character";
|
||||
import { isStreamerMode } from "$lib/stores/config";
|
||||
import { isStreamerMode, configStore } from "$lib/stores/config";
|
||||
import { handleNewUserMessage } from "$lib/notifications/rules";
|
||||
import { setSkipNextGreeting } from "$lib/tauri";
|
||||
import type { CharacterState, CharacterStateInfo } from "$lib/types/states";
|
||||
|
||||
interface Props {
|
||||
@@ -13,6 +14,9 @@
|
||||
|
||||
let { onExpand }: Props = $props();
|
||||
|
||||
const configValues = configStore.config;
|
||||
const hasBackgroundImage = $derived($configValues.background_image_path !== null);
|
||||
|
||||
let inputValue = $state("");
|
||||
let isSubmitting = $state(false);
|
||||
let isConnected = $state(false);
|
||||
@@ -127,8 +131,11 @@
|
||||
const conversationId = get(claudeStore.activeConversationId);
|
||||
if (!conversationId) return;
|
||||
|
||||
// Set flag to preserve stats/permissions (don't treat next connect as new session)
|
||||
setSkipNextGreeting(true);
|
||||
|
||||
await invoke("interrupt_claude", { conversationId });
|
||||
claudeStore.addLine("system", "Interrupted");
|
||||
claudeStore.addLine("system", "Process interrupted via stop button");
|
||||
characterState.setState("idle");
|
||||
} catch (error) {
|
||||
console.error("Failed to interrupt:", error);
|
||||
@@ -146,7 +153,10 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="compact-container {getStateGlow()}">
|
||||
<div
|
||||
class="compact-container {getStateGlow()}"
|
||||
style={hasBackgroundImage ? "background: transparent !important;" : ""}
|
||||
>
|
||||
<!-- Character sprite (smaller) -->
|
||||
<div class="compact-character">
|
||||
<div class="sprite-wrapper {getAnimationClass()}">
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,25 @@
|
||||
let editingTabId = $state<string | null>(null);
|
||||
let editingName = $state("");
|
||||
|
||||
// Tab order for pointer-drag reordering
|
||||
let tabOrder = $state<string[]>([]);
|
||||
let draggedId = $state<string | null>(null);
|
||||
let dragOverId = $state<string | null>(null);
|
||||
let dragStartX = 0;
|
||||
let isDragging = false;
|
||||
let wasDragged = false;
|
||||
let tabsRef = $state<HTMLElement | null>(null);
|
||||
|
||||
// Keep tabOrder in sync with conversations map (add new, remove deleted)
|
||||
$effect(() => {
|
||||
const currentIds = Array.from($conversations.keys());
|
||||
const validIds = tabOrder.filter((id) => currentIds.includes(id));
|
||||
const newIds = currentIds.filter((id) => !tabOrder.includes(id));
|
||||
if (validIds.length !== tabOrder.length || newIds.length > 0) {
|
||||
tabOrder = [...validIds, ...newIds];
|
||||
}
|
||||
});
|
||||
|
||||
// Track last seen message count for each conversation
|
||||
let lastSeenMessageCount = new SvelteMap<string, number>();
|
||||
|
||||
@@ -138,8 +157,73 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTabClick(id: string) {
|
||||
if (wasDragged) {
|
||||
wasDragged = false;
|
||||
return;
|
||||
}
|
||||
await switchTab(id);
|
||||
}
|
||||
|
||||
function handlePointerDown(event: PointerEvent, id: string) {
|
||||
if (editingTabId === id) return;
|
||||
draggedId = id;
|
||||
dragStartX = event.clientX;
|
||||
isDragging = false;
|
||||
wasDragged = false;
|
||||
|
||||
function onMove(e: PointerEvent) {
|
||||
if (!isDragging && Math.abs(e.clientX - dragStartX) > 5) {
|
||||
isDragging = true;
|
||||
}
|
||||
if (!isDragging || !tabsRef) return;
|
||||
const tabs = tabsRef.querySelectorAll<HTMLElement>("[data-tab-id]");
|
||||
dragOverId = null;
|
||||
for (const tab of tabs) {
|
||||
const rect = tab.getBoundingClientRect();
|
||||
if (e.clientX >= rect.left && e.clientX <= rect.right) {
|
||||
const tabId = tab.dataset.tabId;
|
||||
if (tabId && tabId !== id) {
|
||||
dragOverId = tabId;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onUp() {
|
||||
if (isDragging && draggedId && dragOverId && draggedId !== dragOverId) {
|
||||
const order = [...tabOrder];
|
||||
const fromIndex = order.indexOf(draggedId);
|
||||
const toIndex = order.indexOf(dragOverId);
|
||||
order.splice(fromIndex, 1);
|
||||
order.splice(toIndex, 0, draggedId);
|
||||
tabOrder = order;
|
||||
wasDragged = true;
|
||||
}
|
||||
draggedId = null;
|
||||
dragOverId = null;
|
||||
isDragging = false;
|
||||
window.removeEventListener("pointermove", onMove);
|
||||
window.removeEventListener("pointerup", onUp);
|
||||
window.removeEventListener("pointercancel", onUp);
|
||||
}
|
||||
|
||||
window.addEventListener("pointermove", onMove);
|
||||
window.addEventListener("pointerup", onUp);
|
||||
window.addEventListener("pointercancel", onUp);
|
||||
}
|
||||
|
||||
// Keyboard shortcuts
|
||||
onMount(() => {
|
||||
// Initialise all conversations as seen on mount so that remounting
|
||||
// this component (e.g. after closing the file editor) doesn't falsely
|
||||
// mark existing messages as unread.
|
||||
for (const [id, conversation] of $conversations) {
|
||||
lastSeenMessageCount.set(id, conversation.terminalLines.length);
|
||||
}
|
||||
lastSeenMessageCount = lastSeenMessageCount;
|
||||
|
||||
function handleGlobalKeydown(event: KeyboardEvent) {
|
||||
// Ctrl/Cmd + T: New tab
|
||||
if ((event.ctrlKey || event.metaKey) && event.key === "t") {
|
||||
@@ -165,21 +249,19 @@
|
||||
// Ctrl/Cmd + Tab: Next tab
|
||||
else if ((event.ctrlKey || event.metaKey) && event.key === "Tab" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
const tabs = Array.from($conversations.keys());
|
||||
const currentIndex = tabs.findIndex((id) => id === $activeConversationId);
|
||||
const currentIndex = tabOrder.findIndex((id) => id === $activeConversationId);
|
||||
if (currentIndex !== -1) {
|
||||
const nextIndex = (currentIndex + 1) % tabs.length;
|
||||
claudeStore.switchConversation(tabs[nextIndex]);
|
||||
const nextIndex = (currentIndex + 1) % tabOrder.length;
|
||||
claudeStore.switchConversation(tabOrder[nextIndex]);
|
||||
}
|
||||
}
|
||||
// Ctrl/Cmd + Shift + Tab: Previous tab
|
||||
else if ((event.ctrlKey || event.metaKey) && event.key === "Tab" && event.shiftKey) {
|
||||
event.preventDefault();
|
||||
const tabs = Array.from($conversations.keys());
|
||||
const currentIndex = tabs.findIndex((id) => id === $activeConversationId);
|
||||
const currentIndex = tabOrder.findIndex((id) => id === $activeConversationId);
|
||||
if (currentIndex !== -1) {
|
||||
const prevIndex = (currentIndex - 1 + tabs.length) % tabs.length;
|
||||
claudeStore.switchConversation(tabs[prevIndex]);
|
||||
const prevIndex = (currentIndex - 1 + tabOrder.length) % tabOrder.length;
|
||||
claudeStore.switchConversation(tabOrder[prevIndex]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -190,15 +272,22 @@
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={tabsRef}
|
||||
class="terminal-tabs flex items-center gap-1 px-2 py-1 bg-[var(--bg-secondary)] border-b border-[var(--border-color)]"
|
||||
>
|
||||
{#each Array.from($conversations.entries()) as [id, conversation] (id)}
|
||||
{#each tabOrder
|
||||
.filter((id) => $conversations.has(id))
|
||||
.map((id) => ({ id, conversation: $conversations.get(id)! })) as { id, conversation } (id)}
|
||||
<div
|
||||
class="tab-item group relative flex items-center px-3 py-1.5 rounded-t cursor-pointer transition-all
|
||||
data-tab-id={id}
|
||||
class="tab-item group relative flex items-center px-3 py-1.5 rounded-t transition-all
|
||||
{id === $activeConversationId
|
||||
? 'bg-[var(--bg-terminal)] text-[var(--text-primary)] border-t border-l border-r border-[var(--border-color)]'
|
||||
: 'bg-[var(--bg-tertiary)] text-[var(--text-secondary)] hover:bg-[var(--bg-terminal)]/50'}"
|
||||
onclick={() => switchTab(id)}
|
||||
: 'bg-[var(--bg-tertiary)] text-[var(--text-secondary)] hover:bg-[var(--bg-terminal)]/50'}
|
||||
{dragOverId === id && draggedId !== id ? 'drag-over' : ''}
|
||||
{draggedId === id ? 'dragging' : ''}"
|
||||
onpointerdown={(e) => handlePointerDown(e, id)}
|
||||
onclick={() => handleTabClick(id)}
|
||||
onkeydown={(e) => handleTabKeydown(id, e)}
|
||||
role="tab"
|
||||
tabindex={0}
|
||||
@@ -211,7 +300,7 @@
|
||||
onblur={saveTabName}
|
||||
onkeydown={handleKeydown}
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
class="bg-transparent border-b border-[var(--border-color)] outline-none px-0 py-0 text-sm w-32"
|
||||
class="bg-transparent border-b border-[var(--border-color)] outline-none px-0 py-0 text-sm w-32 select-text"
|
||||
/>
|
||||
{:else}
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -296,5 +385,20 @@
|
||||
|
||||
.tab-item {
|
||||
min-width: 100px;
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.tab-item:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.drag-over {
|
||||
border-left: 2px solid var(--accent-primary);
|
||||
}
|
||||
|
||||
.dragging {
|
||||
opacity: 0.4;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* ConversationTabs Component Tests
|
||||
*
|
||||
* Tests the connection status colour mapping and unread message detection
|
||||
* logic used by the ConversationTabs component.
|
||||
*
|
||||
* What this component does:
|
||||
* - Displays one tab per conversation
|
||||
* - Each tab shows a coloured dot for its connection state
|
||||
* - Inactive tabs with new messages show an animated blue dot badge
|
||||
* - Tabs can be renamed by double-clicking
|
||||
* - Tabs can be reordered by drag-and-drop
|
||||
* - New tabs created with Ctrl+T, closed with Ctrl+W
|
||||
*
|
||||
* Manual testing checklist:
|
||||
* - [ ] Connected tabs show a green dot
|
||||
* - [ ] Connecting tabs show a yellow dot
|
||||
* - [ ] Disconnected tabs show a red dot
|
||||
* - [ ] Active tab never shows the unread badge
|
||||
* - [ ] Inactive tab shows blue pulsing dot when it receives new messages
|
||||
* - [ ] Switching to a tab clears the unread indicator
|
||||
* - [ ] Double-clicking a tab name enables inline editing
|
||||
* - [ ] Tabs can be dragged to reorder
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
type ConnectionStatus = "connected" | "connecting" | "disconnected";
|
||||
|
||||
function getConnectionStatusColor(status: ConnectionStatus | string): string {
|
||||
switch (status) {
|
||||
case "connected":
|
||||
return "bg-green-500";
|
||||
case "connecting":
|
||||
return "bg-yellow-500";
|
||||
case "disconnected":
|
||||
return "bg-red-500";
|
||||
default:
|
||||
return "bg-gray-500";
|
||||
}
|
||||
}
|
||||
|
||||
function hasUnreadMessages(
|
||||
id: string,
|
||||
conversationLineCount: number,
|
||||
activeConversationId: string | null,
|
||||
lastSeenMessageCount: Map<string, number>
|
||||
): boolean {
|
||||
if (id === activeConversationId) return false;
|
||||
const lastSeen = lastSeenMessageCount.get(id) ?? 0;
|
||||
return conversationLineCount > lastSeen;
|
||||
}
|
||||
|
||||
// ---
|
||||
|
||||
describe("getConnectionStatusColor", () => {
|
||||
it("returns green for connected status", () => {
|
||||
expect(getConnectionStatusColor("connected")).toBe("bg-green-500");
|
||||
});
|
||||
|
||||
it("returns yellow for connecting status", () => {
|
||||
expect(getConnectionStatusColor("connecting")).toBe("bg-yellow-500");
|
||||
});
|
||||
|
||||
it("returns red for disconnected status", () => {
|
||||
expect(getConnectionStatusColor("disconnected")).toBe("bg-red-500");
|
||||
});
|
||||
|
||||
it("returns grey for unknown status (fallback)", () => {
|
||||
expect(getConnectionStatusColor("error")).toBe("bg-gray-500");
|
||||
expect(getConnectionStatusColor("")).toBe("bg-gray-500");
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasUnreadMessages", () => {
|
||||
it("returns false for the active conversation regardless of message count", () => {
|
||||
const lastSeen = new Map([["tab-1", 0]]);
|
||||
expect(hasUnreadMessages("tab-1", 10, "tab-1", lastSeen)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when an inactive tab has more messages than last seen", () => {
|
||||
const lastSeen = new Map([["tab-1", 5]]);
|
||||
expect(hasUnreadMessages("tab-1", 10, "tab-2", lastSeen)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when an inactive tab has no new messages", () => {
|
||||
const lastSeen = new Map([["tab-1", 10]]);
|
||||
expect(hasUnreadMessages("tab-1", 10, "tab-2", lastSeen)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when an inactive tab has fewer messages than last seen", () => {
|
||||
const lastSeen = new Map([["tab-1", 15]]);
|
||||
expect(hasUnreadMessages("tab-1", 10, "tab-2", lastSeen)).toBe(false);
|
||||
});
|
||||
|
||||
it("treats a tab with no last-seen record as having 0 messages seen", () => {
|
||||
const lastSeen = new Map<string, number>();
|
||||
// Tab has 1 message but no entry in lastSeen → treated as 0 seen → unread
|
||||
expect(hasUnreadMessages("tab-1", 1, "tab-2", lastSeen)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for an untracked tab with 0 messages", () => {
|
||||
const lastSeen = new Map<string, number>();
|
||||
expect(hasUnreadMessages("tab-1", 0, "tab-2", lastSeen)).toBe(false);
|
||||
});
|
||||
|
||||
it("handles null activeConversationId (no active tab)", () => {
|
||||
const lastSeen = new Map([["tab-1", 3]]);
|
||||
expect(hasUnreadMessages("tab-1", 10, null, lastSeen)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
@@ -0,0 +1,330 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { debugConsoleStore, filteredLogs, type LogLevel } from "$lib/stores/debugConsole";
|
||||
|
||||
let isOpen = $state(false);
|
||||
let logs = $state($filteredLogs);
|
||||
let filterLevel = $state<LogLevel | "all">("all");
|
||||
let autoScroll = $state(true);
|
||||
let logContainerElement: HTMLDivElement | undefined = $state();
|
||||
|
||||
// Watch for log changes and auto-scroll
|
||||
$effect(() => {
|
||||
logs = $filteredLogs;
|
||||
|
||||
// Auto-scroll to bottom when logs change
|
||||
if (autoScroll && logContainerElement) {
|
||||
setTimeout(() => {
|
||||
if (logContainerElement) {
|
||||
logContainerElement.scrollTop = logContainerElement.scrollHeight;
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
// Set up console capture and backend listener
|
||||
debugConsoleStore.setupConsoleCapture();
|
||||
debugConsoleStore.setupBackendLogsListener();
|
||||
|
||||
// Subscribe to store
|
||||
const unsubscribe = debugConsoleStore.subscribe((state) => {
|
||||
isOpen = state.isOpen;
|
||||
filterLevel = state.filterLevel;
|
||||
autoScroll = state.autoScroll;
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
debugConsoleStore.restoreConsole();
|
||||
};
|
||||
});
|
||||
|
||||
function handleClose() {
|
||||
debugConsoleStore.close();
|
||||
}
|
||||
|
||||
function handleClear() {
|
||||
debugConsoleStore.clear();
|
||||
}
|
||||
|
||||
function handleFilterChange(level: LogLevel | "all") {
|
||||
debugConsoleStore.setFilterLevel(level);
|
||||
}
|
||||
|
||||
function handleAutoScrollToggle() {
|
||||
debugConsoleStore.setAutoScroll(!autoScroll);
|
||||
}
|
||||
|
||||
function formatTimestamp(date: Date): string {
|
||||
return date.toLocaleTimeString("en-US", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
fractionalSecondDigits: 3,
|
||||
});
|
||||
}
|
||||
|
||||
function getLevelColor(level: LogLevel): string {
|
||||
switch (level) {
|
||||
case "debug":
|
||||
return "#9CA3AF"; // gray
|
||||
case "info":
|
||||
return "#3B82F6"; // blue
|
||||
case "warn":
|
||||
return "#F59E0B"; // amber
|
||||
case "error":
|
||||
return "#EF4444"; // red
|
||||
}
|
||||
}
|
||||
|
||||
function getSourceBadgeColor(source: "frontend" | "backend"): string {
|
||||
return source === "frontend" ? "#8B5CF6" : "#10B981"; // purple for frontend, green for backend
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if isOpen}
|
||||
<div class="debug-console-overlay">
|
||||
<div class="debug-console">
|
||||
<div class="debug-console-header">
|
||||
<h2>Debug Console</h2>
|
||||
<div class="debug-console-controls">
|
||||
<div class="filter-buttons">
|
||||
<button
|
||||
class="filter-btn"
|
||||
class:active={filterLevel === "all"}
|
||||
onclick={() => handleFilterChange("all")}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
<button
|
||||
class="filter-btn"
|
||||
class:active={filterLevel === "debug"}
|
||||
onclick={() => handleFilterChange("debug")}
|
||||
style="color: {getLevelColor('debug')}"
|
||||
>
|
||||
Debug
|
||||
</button>
|
||||
<button
|
||||
class="filter-btn"
|
||||
class:active={filterLevel === "info"}
|
||||
onclick={() => handleFilterChange("info")}
|
||||
style="color: {getLevelColor('info')}"
|
||||
>
|
||||
Info
|
||||
</button>
|
||||
<button
|
||||
class="filter-btn"
|
||||
class:active={filterLevel === "warn"}
|
||||
onclick={() => handleFilterChange("warn")}
|
||||
style="color: {getLevelColor('warn')}"
|
||||
>
|
||||
Warn
|
||||
</button>
|
||||
<button
|
||||
class="filter-btn"
|
||||
class:active={filterLevel === "error"}
|
||||
onclick={() => handleFilterChange("error")}
|
||||
style="color: {getLevelColor('error')}"
|
||||
>
|
||||
Error
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
class="auto-scroll-btn"
|
||||
class:active={autoScroll}
|
||||
onclick={handleAutoScrollToggle}
|
||||
>
|
||||
{autoScroll ? "🔒" : "🔓"} Auto-scroll
|
||||
</button>
|
||||
<button class="clear-btn" onclick={handleClear}> 🗑️ Clear </button>
|
||||
<button class="close-btn" onclick={handleClose}> ✕ </button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="debug-console-content" bind:this={logContainerElement}>
|
||||
{#if logs.length === 0}
|
||||
<div class="empty-state">No logs yet...</div>
|
||||
{:else}
|
||||
{#each logs as log (log.id)}
|
||||
<div class="log-entry" data-level={log.level}>
|
||||
<span class="log-timestamp">{formatTimestamp(log.timestamp)}</span>
|
||||
<span class="log-level" style="color: {getLevelColor(log.level)}">
|
||||
[{log.level.toUpperCase()}]
|
||||
</span>
|
||||
<span class="log-source" style="background-color: {getSourceBadgeColor(log.source)}">
|
||||
{log.source}
|
||||
</span>
|
||||
<span class="log-message">{log.message}</span>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.debug-console-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.debug-console {
|
||||
width: 90%;
|
||||
height: 80%;
|
||||
max-width: 1400px;
|
||||
background-color: #1a1a1a;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #333;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.debug-console-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
background-color: #252525;
|
||||
border-bottom: 1px solid #333;
|
||||
}
|
||||
|
||||
.debug-console-header h2 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.debug-console-controls {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.filter-buttons {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.filter-btn {
|
||||
padding: 4px 12px;
|
||||
background-color: transparent;
|
||||
border: 1px solid #444;
|
||||
border-radius: 4px;
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.filter-btn:hover {
|
||||
background-color: #333;
|
||||
}
|
||||
|
||||
.filter-btn.active {
|
||||
background-color: #444;
|
||||
border-color: currentColor;
|
||||
}
|
||||
|
||||
.auto-scroll-btn,
|
||||
.clear-btn {
|
||||
padding: 4px 12px;
|
||||
background-color: #333;
|
||||
border: 1px solid #444;
|
||||
border-radius: 4px;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.auto-scroll-btn:hover,
|
||||
.clear-btn:hover {
|
||||
background-color: #444;
|
||||
}
|
||||
|
||||
.auto-scroll-btn.active {
|
||||
background-color: #10b981;
|
||||
border-color: #10b981;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
padding: 4px 12px;
|
||||
background-color: #ef4444;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
background-color: #dc2626;
|
||||
}
|
||||
|
||||
.debug-console-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
background-color: #0f0f0f;
|
||||
font-family: "Fira Code", "Consolas", monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: #666;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.log-entry {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 4px 0;
|
||||
border-bottom: 1px solid #1a1a1a;
|
||||
}
|
||||
|
||||
.log-entry:hover {
|
||||
background-color: #1a1a1a;
|
||||
}
|
||||
|
||||
.log-timestamp {
|
||||
color: #666;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.log-level {
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
.log-source {
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.log-message {
|
||||
color: #e5e5e5;
|
||||
word-break: break-word;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,233 @@
|
||||
<script lang="ts">
|
||||
import hljs from "highlight.js";
|
||||
import { parseDiff, detectLanguage } from "$lib/utils/diffParser";
|
||||
|
||||
export let diffContent: string;
|
||||
export let filePath: string;
|
||||
|
||||
$: lines = diffContent ? parseDiff(diffContent) : [];
|
||||
$: language = detectLanguage(filePath);
|
||||
|
||||
function highlightCode(code: string): string {
|
||||
if (!code) return "";
|
||||
try {
|
||||
return hljs.highlight(code, { language }).value;
|
||||
} catch {
|
||||
return hljs.highlightAuto(code).value;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if lines.length === 0}
|
||||
<div class="empty-diff">No changes</div>
|
||||
{:else}
|
||||
<table class="diff-table">
|
||||
<tbody>
|
||||
{#each lines as line, i (i)}
|
||||
{#if line.type === "file-header"}
|
||||
<tr class="line-file-header">
|
||||
<td class="line-num" colspan="2"></td>
|
||||
<td class="line-gutter"></td>
|
||||
<td class="line-code">{line.content}</td>
|
||||
</tr>
|
||||
{:else if line.type === "hunk-header"}
|
||||
<tr class="line-hunk-header">
|
||||
<td class="line-num" colspan="2"></td>
|
||||
<td class="line-gutter">⋯</td>
|
||||
<td class="line-code">{line.content}</td>
|
||||
</tr>
|
||||
{:else if line.type === "no-newline"}
|
||||
<tr class="line-no-newline">
|
||||
<td class="line-num" colspan="2"></td>
|
||||
<td class="line-gutter"></td>
|
||||
<td class="line-code">{line.content}</td>
|
||||
</tr>
|
||||
{:else}
|
||||
<tr class="line-{line.type}">
|
||||
<td class="line-num">{line.oldLineNumber ?? ""}</td>
|
||||
<td class="line-num">{line.newLineNumber ?? ""}</td>
|
||||
<td class="line-gutter">
|
||||
{line.type === "added" ? "+" : line.type === "removed" ? "-" : ""}
|
||||
</td>
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -- Syntax highlighting requires @html; content is from trusted git diff output -->
|
||||
<td class="line-code">{@html highlightCode(line.content)}</td>
|
||||
</tr>
|
||||
{/if}
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.empty-diff {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.diff-table {
|
||||
border-collapse: collapse;
|
||||
min-width: 100%;
|
||||
width: max-content;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.diff-table tr {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.diff-table td {
|
||||
padding: 0;
|
||||
white-space: pre;
|
||||
vertical-align: top;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.line-num {
|
||||
width: 3.5rem;
|
||||
min-width: 3.5rem;
|
||||
color: var(--text-secondary);
|
||||
text-align: right;
|
||||
user-select: none;
|
||||
border-right: 1px solid var(--border-color);
|
||||
opacity: 0.6;
|
||||
font-size: 0.75rem;
|
||||
padding: 0 0.4rem;
|
||||
}
|
||||
|
||||
.line-gutter {
|
||||
width: 1.5rem;
|
||||
min-width: 1.5rem;
|
||||
text-align: center;
|
||||
user-select: none;
|
||||
font-weight: bold;
|
||||
padding: 0 0.25rem;
|
||||
}
|
||||
|
||||
.line-code {
|
||||
padding: 0 0.75rem;
|
||||
}
|
||||
|
||||
/* Added lines */
|
||||
.line-added {
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
}
|
||||
|
||||
.line-added .line-num {
|
||||
background: rgba(34, 197, 94, 0.08);
|
||||
color: rgba(34, 197, 94, 0.7);
|
||||
}
|
||||
|
||||
.line-added .line-gutter {
|
||||
color: #22c55e;
|
||||
background: rgba(34, 197, 94, 0.18);
|
||||
}
|
||||
|
||||
/* Removed lines */
|
||||
.line-removed {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
|
||||
.line-removed .line-num {
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
color: rgba(239, 68, 68, 0.7);
|
||||
}
|
||||
|
||||
.line-removed .line-gutter {
|
||||
color: #ef4444;
|
||||
background: rgba(239, 68, 68, 0.18);
|
||||
}
|
||||
|
||||
/* Hunk header */
|
||||
.line-hunk-header {
|
||||
background: rgba(99, 102, 241, 0.12);
|
||||
}
|
||||
|
||||
.line-hunk-header .line-code {
|
||||
color: var(--text-secondary);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.line-hunk-header .line-gutter {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* File header */
|
||||
.line-file-header {
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.line-file-header .line-code {
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
padding: 0.15rem 0.75rem;
|
||||
}
|
||||
|
||||
/* No newline */
|
||||
.line-no-newline .line-code {
|
||||
color: var(--text-secondary);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Syntax highlighting — scoped to this component's table */
|
||||
.diff-table :global(.hljs-keyword),
|
||||
.diff-table :global(.hljs-selector-tag),
|
||||
.diff-table :global(.hljs-built_in),
|
||||
.diff-table :global(.hljs-name) {
|
||||
color: var(--hljs-keyword);
|
||||
}
|
||||
|
||||
.diff-table :global(.hljs-string),
|
||||
.diff-table :global(.hljs-attr),
|
||||
.diff-table :global(.hljs-symbol),
|
||||
.diff-table :global(.hljs-bullet) {
|
||||
color: var(--hljs-string);
|
||||
}
|
||||
|
||||
.diff-table :global(.hljs-number),
|
||||
.diff-table :global(.hljs-literal) {
|
||||
color: var(--hljs-number);
|
||||
}
|
||||
|
||||
.diff-table :global(.hljs-comment),
|
||||
.diff-table :global(.hljs-quote) {
|
||||
color: var(--hljs-comment);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.diff-table :global(.hljs-function),
|
||||
.diff-table :global(.hljs-title) {
|
||||
color: var(--hljs-function);
|
||||
}
|
||||
|
||||
.diff-table :global(.hljs-type),
|
||||
.diff-table :global(.hljs-class) {
|
||||
color: var(--hljs-type);
|
||||
}
|
||||
|
||||
.diff-table :global(.hljs-variable),
|
||||
.diff-table :global(.hljs-template-variable) {
|
||||
color: var(--hljs-variable);
|
||||
}
|
||||
|
||||
.diff-table :global(.hljs-meta) {
|
||||
color: var(--hljs-meta);
|
||||
}
|
||||
|
||||
.diff-table :global(.hljs-tag) {
|
||||
color: var(--hljs-keyword);
|
||||
}
|
||||
|
||||
.diff-table :global(.hljs-attribute) {
|
||||
color: var(--hljs-function);
|
||||
}
|
||||
|
||||
.diff-table :global(.hljs-params) {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,232 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { draftsStore, type Draft } from "$lib/stores/drafts";
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
onInsert: (content: string) => void;
|
||||
}
|
||||
|
||||
const { onClose, onInsert }: Props = $props();
|
||||
|
||||
let confirmingDeleteId = $state<string | null>(null);
|
||||
let confirmingAll = $state(false);
|
||||
|
||||
const drafts = $derived(draftsStore.drafts);
|
||||
const isLoading = $derived(draftsStore.isLoading);
|
||||
|
||||
onMount(() => {
|
||||
draftsStore.loadDrafts();
|
||||
});
|
||||
|
||||
function handleInsert(draft: Draft): void {
|
||||
onInsert(draft.content);
|
||||
onClose();
|
||||
}
|
||||
|
||||
async function handleDelete(draftId: string): Promise<void> {
|
||||
await draftsStore.deleteDraft(draftId);
|
||||
confirmingDeleteId = null;
|
||||
}
|
||||
|
||||
async function handleDeleteAll(): Promise<void> {
|
||||
await draftsStore.deleteAllDrafts();
|
||||
confirmingAll = false;
|
||||
}
|
||||
|
||||
function truncateContent(content: string): string {
|
||||
return content.length > 120 ? content.slice(0, 120) + "…" : content;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4"
|
||||
onclick={onClose}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onkeydown={(e) => e.key === "Escape" && onClose()}
|
||||
>
|
||||
<div
|
||||
class="bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg shadow-xl max-w-2xl w-full max-h-[80vh] overflow-hidden flex flex-col"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-labelledby="draft-panel-title"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div class="flex items-center justify-between p-6 pb-4 border-b border-[var(--border-color)]">
|
||||
<h2 id="draft-panel-title" class="text-xl font-semibold text-[var(--text-primary)]">
|
||||
Saved Drafts
|
||||
</h2>
|
||||
<div class="flex items-center gap-2">
|
||||
{#if $drafts.length > 0}
|
||||
{#if confirmingAll}
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
onclick={handleDeleteAll}
|
||||
class="px-3 py-1.5 text-sm font-medium bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors"
|
||||
>
|
||||
Confirm Delete All
|
||||
</button>
|
||||
<button
|
||||
onclick={() => (confirmingAll = false)}
|
||||
class="px-3 py-1.5 text-sm font-medium bg-[var(--bg-secondary)] text-[var(--text-secondary)] rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
onclick={() => (confirmingAll = true)}
|
||||
class="px-3 py-1.5 text-sm font-medium text-red-400 hover:text-red-300 transition-colors border border-red-400/30 rounded-lg hover:border-red-300/50 hover:bg-red-400/10"
|
||||
>
|
||||
Delete All
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
<button
|
||||
onclick={onClose}
|
||||
class="p-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
||||
aria-label="Close"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
{#if $isLoading}
|
||||
<div class="flex items-center justify-center p-8">
|
||||
<div class="text-[var(--text-tertiary)]">Loading drafts...</div>
|
||||
</div>
|
||||
{:else if $drafts.length === 0}
|
||||
<div class="flex flex-col items-center justify-center p-8 text-center">
|
||||
<svg
|
||||
class="w-16 h-16 text-[var(--text-tertiary)] mb-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
||||
/>
|
||||
</svg>
|
||||
<p class="text-[var(--text-secondary)]">No saved drafts yet</p>
|
||||
<p class="text-sm text-[var(--text-tertiary)] mt-1">
|
||||
Use "Save as Draft" to store messages for later
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="divide-y divide-[var(--border-color)]">
|
||||
{#each $drafts as draft (draft.id)}
|
||||
<div class="p-4 hover:bg-[var(--bg-secondary)] transition-colors group">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-xs text-[var(--text-tertiary)] mb-1">
|
||||
{draftsStore.formatTimestamp(draft.saved_at)}
|
||||
</p>
|
||||
<p
|
||||
class="text-sm text-[var(--text-secondary)] font-mono whitespace-pre-wrap break-words"
|
||||
>
|
||||
{truncateContent(draft.content)}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0"
|
||||
>
|
||||
<button
|
||||
onclick={() => handleInsert(draft)}
|
||||
class="btn-trans-gradient px-3 py-1.5 text-xs font-medium rounded"
|
||||
title="Insert this draft"
|
||||
>
|
||||
Insert
|
||||
</button>
|
||||
{#if confirmingDeleteId === draft.id}
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
onclick={() => handleDelete(draft.id)}
|
||||
class="px-2 py-1 text-xs font-medium bg-red-500 text-white rounded hover:bg-red-600 transition-colors"
|
||||
>
|
||||
Confirm
|
||||
</button>
|
||||
<button
|
||||
onclick={() => (confirmingDeleteId = null)}
|
||||
class="px-2 py-1 text-xs font-medium bg-[var(--bg-tertiary)] text-[var(--text-secondary)] rounded hover:bg-[var(--bg-secondary)] transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
onclick={() => (confirmingDeleteId = draft.id)}
|
||||
class="p-1.5 text-[var(--text-tertiary)] hover:text-red-400 transition-colors"
|
||||
title="Delete draft"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
[role="dialog"] {
|
||||
animation: slideIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95) translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.overflow-y-auto {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border-color) transparent;
|
||||
}
|
||||
|
||||
.overflow-y-auto::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.overflow-y-auto::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.overflow-y-auto::-webkit-scrollbar-thumb {
|
||||
background-color: var(--border-color);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.overflow-y-auto::-webkit-scrollbar-thumb:hover {
|
||||
background-color: var(--accent-primary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,193 @@
|
||||
<script lang="ts">
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { get } from "svelte/store";
|
||||
import { claudeStore, hasElicitationPending } from "$lib/stores/claude";
|
||||
import { characterState } from "$lib/stores/character";
|
||||
import type { ElicitationEvent } from "$lib/types/messages";
|
||||
import { updateDiscordRpc, setSkipNextGreeting } from "$lib/tauri";
|
||||
import { conversationsStore } from "$lib/stores/conversations";
|
||||
import { configStore } from "$lib/stores/config";
|
||||
|
||||
let isVisible = $state(false);
|
||||
let elicitation: ElicitationEvent | null = $state(null);
|
||||
let response = $state("");
|
||||
let grantedToolsList: string[] = $state([]);
|
||||
let workingDirectory = $state("");
|
||||
|
||||
hasElicitationPending.subscribe((pending) => {
|
||||
isVisible = pending;
|
||||
if (!pending) {
|
||||
response = "";
|
||||
}
|
||||
});
|
||||
|
||||
claudeStore.pendingElicitation.subscribe((e) => {
|
||||
elicitation = e;
|
||||
if (e) {
|
||||
characterState.setState("permission");
|
||||
}
|
||||
});
|
||||
|
||||
claudeStore.grantedTools.subscribe((tools) => {
|
||||
grantedToolsList = Array.from(tools);
|
||||
});
|
||||
|
||||
claudeStore.currentWorkingDirectory.subscribe((dir) => {
|
||||
workingDirectory = dir;
|
||||
});
|
||||
|
||||
async function handleSubmitAndReconnect() {
|
||||
if (!elicitation || !response.trim()) return;
|
||||
|
||||
const conversationId = get(claudeStore.activeConversationId);
|
||||
if (!conversationId) return;
|
||||
|
||||
const responseText = response.trim();
|
||||
const elicitationMessage = elicitation.message;
|
||||
const conversationHistory = claudeStore.getConversationHistory();
|
||||
|
||||
claudeStore.addLine("system", `MCP response submitted. Reconnecting with context...`);
|
||||
claudeStore.clearElicitation();
|
||||
|
||||
try {
|
||||
setSkipNextGreeting(true);
|
||||
|
||||
await invoke("stop_claude", { conversationId });
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
const config = configStore.getConfig();
|
||||
await invoke("start_claude", {
|
||||
conversationId,
|
||||
options: {
|
||||
working_dir: workingDirectory || "/home/naomi",
|
||||
model: config.model || null,
|
||||
api_key: config.api_key || null,
|
||||
custom_instructions: config.custom_instructions || null,
|
||||
mcp_servers_json: config.mcp_servers_json || null,
|
||||
allowed_tools: grantedToolsList,
|
||||
use_worktree: config.use_worktree ?? false,
|
||||
disable_1m_context: config.disable_1m_context ?? false,
|
||||
disable_cron: config.disable_cron ?? false,
|
||||
disable_skill_shell_execution: config.disable_skill_shell_execution ?? false,
|
||||
include_git_instructions: config.include_git_instructions ?? true,
|
||||
enable_claudeai_mcp_servers: config.enable_claudeai_mcp_servers ?? true,
|
||||
auto_memory_directory: config.auto_memory_directory || null,
|
||||
model_overrides: config.model_overrides || null,
|
||||
session_name: null,
|
||||
bare_mode: config.bare_mode ?? false,
|
||||
show_clear_context_on_plan_accept: config.show_clear_context_on_plan_accept ?? true,
|
||||
custom_model_option: config.custom_model_option || null,
|
||||
},
|
||||
});
|
||||
|
||||
const activeConversation = get(conversationsStore.activeConversation);
|
||||
if (activeConversation) {
|
||||
await updateDiscordRpc(
|
||||
activeConversation.name,
|
||||
config.model || "claude",
|
||||
activeConversation.startedAt
|
||||
);
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
|
||||
if (conversationHistory) {
|
||||
const contextMessage = `[CONTEXT RESTORATION]
|
||||
I just responded to an MCP server elicitation request. Here's our conversation so far:
|
||||
|
||||
${conversationHistory}
|
||||
|
||||
The MCP server asked: "${elicitationMessage}"
|
||||
My response: "${responseText}"
|
||||
|
||||
Please continue where we left off, taking my response into account.`;
|
||||
|
||||
await invoke("send_prompt", {
|
||||
conversationId,
|
||||
message: contextMessage,
|
||||
});
|
||||
}
|
||||
|
||||
characterState.setTemporaryState("success", 2000);
|
||||
} catch (error) {
|
||||
console.error("Failed to reconnect:", error);
|
||||
claudeStore.addLine("error", `Reconnect failed: ${error}`);
|
||||
characterState.setTemporaryState("error", 3000);
|
||||
}
|
||||
}
|
||||
|
||||
function handleDismiss() {
|
||||
claudeStore.clearElicitation();
|
||||
claudeStore.addLine("system", "MCP elicitation dismissed");
|
||||
characterState.setTemporaryState("idle", 1000);
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (!isVisible || !elicitation) return;
|
||||
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
handleDismiss();
|
||||
}
|
||||
}
|
||||
|
||||
function canSubmit(): boolean {
|
||||
return response.trim().length > 0;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
{#if isVisible && elicitation}
|
||||
<div
|
||||
class="elicitation-overlay fixed inset-0 bg-black/70 flex items-center justify-center z-50 backdrop-blur-sm"
|
||||
>
|
||||
<div
|
||||
class="elicitation-modal bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-xl p-6 max-w-md w-full mx-4 shadow-2xl"
|
||||
>
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<div class="w-10 h-10 rounded-full bg-blue-500/20 flex items-center justify-center">
|
||||
<span class="text-xl">💬</span>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-[var(--text-primary)]">MCP Server Request</h2>
|
||||
{#if elicitation.server_name}
|
||||
<p class="text-sm text-[var(--text-secondary)]">from: {elicitation.server_name}</p>
|
||||
{:else}
|
||||
<p class="text-sm text-[var(--text-secondary)]">Input required from MCP server</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<p class="text-[var(--text-primary)]">{elicitation.message}</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<textarea
|
||||
bind:value={response}
|
||||
placeholder="Type your response here..."
|
||||
class="w-full px-4 py-3 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] placeholder-[var(--text-secondary)] resize-none focus:outline-none focus:border-[var(--accent-primary)]"
|
||||
rows="4"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
onclick={handleDismiss}
|
||||
class="flex-1 px-4 py-2 bg-gray-500/20 hover:bg-gray-500/30 text-[var(--text-secondary)] rounded-lg transition-colors font-medium"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
<button
|
||||
onclick={handleSubmitAndReconnect}
|
||||
disabled={!canSubmit()}
|
||||
class="flex-1 px-4 py-2 bg-blue-500/20 hover:bg-blue-500/30 text-blue-400 rounded-lg transition-colors font-medium disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Submit & Reconnect
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -2,6 +2,7 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
import { claudeStore } from "$lib/stores/claude";
|
||||
import DiffViewer from "$lib/components/DiffViewer.svelte";
|
||||
|
||||
interface GitFileChange {
|
||||
path: string;
|
||||
@@ -600,7 +601,9 @@
|
||||
<h3>📄 {diffFile}</h3>
|
||||
<button on:click={() => (showDiff = false)} title="Close">✕</button>
|
||||
</div>
|
||||
<pre class="diff-content">{diffContent || "(No changes)"}</pre>
|
||||
<div class="diff-content">
|
||||
<DiffViewer {diffContent} filePath={diffFile ?? ""} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1096,12 +1099,7 @@
|
||||
.diff-content {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 1rem;
|
||||
margin: 0;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.4;
|
||||
white-space: pre;
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,54 +1,69 @@
|
||||
<script lang="ts">
|
||||
import { HELP_PAGES, nextPage, prevPage, isFirstPage, isLastPage } from "./docs/helpPages";
|
||||
import DocsGettingStarted from "./docs/DocsGettingStarted.svelte";
|
||||
import DocsKeyboardShortcuts from "./docs/DocsKeyboardShortcuts.svelte";
|
||||
import DocsChatInput from "./docs/DocsChatInput.svelte";
|
||||
import DocsFileEditor from "./docs/DocsFileEditor.svelte";
|
||||
import DocsGitPanel from "./docs/DocsGitPanel.svelte";
|
||||
import DocsThemeCustomisation from "./docs/DocsThemeCustomisation.svelte";
|
||||
import DocsModelConfig from "./docs/DocsModelConfig.svelte";
|
||||
import DocsSessionManagement from "./docs/DocsSessionManagement.svelte";
|
||||
import DocsTaskLoop from "./docs/DocsTaskLoop.svelte";
|
||||
import DocsPanelsTools from "./docs/DocsPanelsTools.svelte";
|
||||
import DocsTroubleshooting from "./docs/DocsTroubleshooting.svelte";
|
||||
import DocsChangelog from "./docs/DocsChangelog.svelte";
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const { onClose }: Props = $props();
|
||||
|
||||
const sections = [
|
||||
{
|
||||
title: "Getting Started",
|
||||
items: [
|
||||
"Enter your Claude API key in Settings (gear icon)",
|
||||
"Set your working directory and click Connect",
|
||||
"Start chatting with Hikari - your AI assistant!",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Key Features",
|
||||
items: [
|
||||
"🗂️ File Management: Hikari can read, write, and edit files in your project",
|
||||
"💻 Terminal Access: Execute commands and run scripts",
|
||||
"🔍 Code Search: Find files and search through code",
|
||||
"🌐 Web Access: Fetch information from the web",
|
||||
"📊 MCP Servers: Connect to external tools via Model Context Protocol",
|
||||
"📁 Multi-tab Support: Work on multiple conversations simultaneously",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Available Commands",
|
||||
items: [
|
||||
"Type naturally - Hikari understands context!",
|
||||
"Ask to read, create, or modify files",
|
||||
"Request code explanations or debugging help",
|
||||
"Have Hikari run tests or build commands",
|
||||
"Search for specific functions or patterns",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Tips & Tricks",
|
||||
items: [
|
||||
"💡 Use the stats panel to track your usage",
|
||||
"🎯 Be specific about file paths and requirements",
|
||||
"🔒 Grant tool permissions as needed for security",
|
||||
"📌 Pin important conversations for quick access",
|
||||
"🎨 Customize your theme and preferences in Settings",
|
||||
"⌨️ Check the keyboard icon for available shortcuts",
|
||||
],
|
||||
},
|
||||
const PAGE_COMPONENTS = [
|
||||
DocsGettingStarted,
|
||||
DocsKeyboardShortcuts,
|
||||
DocsChatInput,
|
||||
DocsFileEditor,
|
||||
DocsGitPanel,
|
||||
DocsThemeCustomisation,
|
||||
DocsModelConfig,
|
||||
DocsSessionManagement,
|
||||
DocsTaskLoop,
|
||||
DocsPanelsTools,
|
||||
DocsTroubleshooting,
|
||||
DocsChangelog,
|
||||
];
|
||||
|
||||
let currentPageIndex = $state(0);
|
||||
|
||||
const currentComponent = $derived(PAGE_COMPONENTS[currentPageIndex]);
|
||||
const atFirst = $derived(isFirstPage(currentPageIndex));
|
||||
const atLast = $derived(isLastPage(currentPageIndex, HELP_PAGES.length));
|
||||
|
||||
function handleKeydown(event: KeyboardEvent): void {
|
||||
const target = event.target as HTMLElement;
|
||||
const isInputFocused = target.tagName === "INPUT" || target.tagName === "TEXTAREA";
|
||||
|
||||
if (event.key === "Escape") {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
if (isInputFocused) return;
|
||||
|
||||
if (event.key === "ArrowRight" || event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
currentPageIndex = nextPage(currentPageIndex, HELP_PAGES.length);
|
||||
} else if (event.key === "ArrowLeft" || event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
currentPageIndex = prevPage(currentPageIndex);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
<!-- Backdrop -->
|
||||
<div
|
||||
class="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4"
|
||||
onclick={onClose}
|
||||
@@ -56,17 +71,21 @@
|
||||
tabindex="0"
|
||||
onkeydown={(e) => e.key === "Escape" && onClose()}
|
||||
>
|
||||
<!-- Dialog -->
|
||||
<div
|
||||
class="bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg shadow-xl max-w-2xl w-full max-h-[80vh] overflow-hidden flex flex-col"
|
||||
class="bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg shadow-xl w-full max-w-3xl h-[80vh] flex flex-col"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-labelledby="help-title"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div class="flex items-center justify-between p-6 pb-4 border-b border-[var(--border-color)]">
|
||||
<h2 id="help-title" class="text-xl font-semibold text-[var(--text-primary)]">
|
||||
How to Use Hikari Desktop
|
||||
<!-- Header -->
|
||||
<div
|
||||
class="flex items-center justify-between px-6 py-4 border-b border-[var(--border-color)] shrink-0"
|
||||
>
|
||||
<h2 id="help-title" class="text-lg font-semibold text-[var(--text-primary)]">
|
||||
Help & Documentation
|
||||
</h2>
|
||||
<button
|
||||
onclick={onClose}
|
||||
@@ -84,32 +103,77 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="overflow-y-auto flex-1 p-6 space-y-6">
|
||||
{#each sections as section (section.title)}
|
||||
<div>
|
||||
<h3 class="font-medium text-[var(--text-primary)] mb-3">{section.title}</h3>
|
||||
<ul class="space-y-2 text-sm text-[var(--text-secondary)]">
|
||||
{#each section.items as item (item)}
|
||||
<li class="flex items-start">
|
||||
<span class="text-[var(--accent-primary)] mr-2 mt-0.5">•</span>
|
||||
<span>{item}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/each}
|
||||
<!-- Body: sidebar + content -->
|
||||
<div class="flex flex-1 overflow-hidden">
|
||||
<!-- Sidebar TOC -->
|
||||
<nav
|
||||
class="w-44 shrink-0 border-r border-[var(--border-color)] overflow-y-auto py-2"
|
||||
aria-label="Documentation pages"
|
||||
>
|
||||
{#each HELP_PAGES as page, i (page.id)}
|
||||
<button
|
||||
onclick={() => (currentPageIndex = i)}
|
||||
class="w-full text-left px-4 py-2 text-sm transition-colors {i === currentPageIndex
|
||||
? 'bg-[var(--bg-secondary)] text-[var(--accent-primary)] font-medium'
|
||||
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-secondary)]'}"
|
||||
aria-current={i === currentPageIndex ? "page" : undefined}
|
||||
>
|
||||
{page.title}
|
||||
</button>
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
<div class="pt-4 border-t border-[var(--border-color)]">
|
||||
<p class="text-sm text-[var(--text-tertiary)]">
|
||||
<strong>Need more help?</strong> Join our Discord community for support and updates!
|
||||
</p>
|
||||
<!-- Page content -->
|
||||
<div class="flex-1 overflow-y-auto p-6">
|
||||
<svelte:component this={currentComponent} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer: prev / page indicator / next -->
|
||||
<div
|
||||
class="flex items-center justify-between px-6 py-3 border-t border-[var(--border-color)] shrink-0"
|
||||
>
|
||||
<button
|
||||
onclick={() => (currentPageIndex = prevPage(currentPageIndex))}
|
||||
disabled={atFirst}
|
||||
class="flex items-center gap-1 px-3 py-1.5 text-sm rounded transition-colors
|
||||
{atFirst
|
||||
? 'text-[var(--text-tertiary)] cursor-not-allowed'
|
||||
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-secondary)]'}"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15 19l-7-7 7-7"
|
||||
/>
|
||||
</svg>
|
||||
Previous
|
||||
</button>
|
||||
|
||||
<span class="text-xs text-[var(--text-tertiary)]">
|
||||
Page {currentPageIndex + 1} of {HELP_PAGES.length}
|
||||
</span>
|
||||
|
||||
<button
|
||||
onclick={() => (currentPageIndex = nextPage(currentPageIndex, HELP_PAGES.length))}
|
||||
disabled={atLast}
|
||||
class="flex items-center gap-1 px-3 py-1.5 text-sm rounded transition-colors
|
||||
{atLast
|
||||
? 'text-[var(--text-tertiary)] cursor-not-allowed'
|
||||
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-secondary)]'}"
|
||||
>
|
||||
Next
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Ensure the panel appears above other content */
|
||||
[role="dialog"] {
|
||||
animation: slideIn 0.2s ease-out;
|
||||
}
|
||||
@@ -125,26 +189,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom scrollbar styling */
|
||||
.overflow-y-auto {
|
||||
nav {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border-color) transparent;
|
||||
}
|
||||
|
||||
.overflow-y-auto::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.overflow-y-auto::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.overflow-y-auto::-webkit-scrollbar-thumb {
|
||||
background-color: var(--border-color);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.overflow-y-auto::-webkit-scrollbar-thumb:hover {
|
||||
background-color: var(--accent-primary);
|
||||
.overflow-y-auto {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border-color) transparent;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* HighlightedText Component Tests
|
||||
*
|
||||
* Tests the text-splitting logic used by the HighlightedText component,
|
||||
* which highlights search query matches within a string.
|
||||
*
|
||||
* What this component does:
|
||||
* - Splits text into an array of {text, isMatch} parts
|
||||
* - Matches are case-insensitive
|
||||
* - Special regex characters in the query are escaped
|
||||
* - Non-matching text is preserved verbatim around matches
|
||||
*
|
||||
* Manual testing checklist:
|
||||
* - [ ] Matching text is highlighted (yellow background) in the terminal
|
||||
* - [ ] Highlighting is case-insensitive
|
||||
* - [ ] Multiple matches on the same line all get highlighted
|
||||
* - [ ] Non-matching text renders normally alongside matches
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
interface TextPart {
|
||||
text: string;
|
||||
isMatch: boolean;
|
||||
}
|
||||
|
||||
function getHighlightedParts(text: string, query: string): TextPart[] {
|
||||
if (!query) {
|
||||
return [{ text, isMatch: false }];
|
||||
}
|
||||
|
||||
const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const regex = new RegExp(`(${escapedQuery})`, "gi");
|
||||
const parts: TextPart[] = [];
|
||||
let lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = regex.exec(text)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
parts.push({
|
||||
text: text.slice(lastIndex, match.index),
|
||||
isMatch: false,
|
||||
});
|
||||
}
|
||||
|
||||
parts.push({
|
||||
text: match[1],
|
||||
isMatch: true,
|
||||
});
|
||||
|
||||
lastIndex = regex.lastIndex;
|
||||
}
|
||||
|
||||
if (lastIndex < text.length) {
|
||||
parts.push({
|
||||
text: text.slice(lastIndex),
|
||||
isMatch: false,
|
||||
});
|
||||
}
|
||||
|
||||
return parts;
|
||||
}
|
||||
|
||||
// ---
|
||||
|
||||
describe("getHighlightedParts", () => {
|
||||
describe("empty query", () => {
|
||||
it("returns the whole text as a single non-match when query is empty string", () => {
|
||||
const result = getHighlightedParts("hello world", "");
|
||||
expect(result).toEqual([{ text: "hello world", isMatch: false }]);
|
||||
});
|
||||
|
||||
it("returns an empty non-match part when both text and query are empty", () => {
|
||||
const result = getHighlightedParts("", "");
|
||||
expect(result).toEqual([{ text: "", isMatch: false }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("no match", () => {
|
||||
it("returns the whole text as a single non-match when query is not found", () => {
|
||||
const result = getHighlightedParts("hello world", "xyz");
|
||||
expect(result).toEqual([{ text: "hello world", isMatch: false }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("single match", () => {
|
||||
it("returns three parts for a match in the middle", () => {
|
||||
const result = getHighlightedParts("hello world foo", "world");
|
||||
expect(result).toEqual([
|
||||
{ text: "hello ", isMatch: false },
|
||||
{ text: "world", isMatch: true },
|
||||
{ text: " foo", isMatch: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns two parts for a match at the start", () => {
|
||||
const result = getHighlightedParts("hello world", "hello");
|
||||
expect(result).toEqual([
|
||||
{ text: "hello", isMatch: true },
|
||||
{ text: " world", isMatch: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns two parts for a match at the end", () => {
|
||||
const result = getHighlightedParts("hello world", "world");
|
||||
expect(result).toEqual([
|
||||
{ text: "hello ", isMatch: false },
|
||||
{ text: "world", isMatch: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns a single match part when the entire text matches", () => {
|
||||
const result = getHighlightedParts("hello", "hello");
|
||||
expect(result).toEqual([{ text: "hello", isMatch: true }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("multiple matches", () => {
|
||||
it("returns interleaved match and non-match parts for multiple occurrences", () => {
|
||||
const result = getHighlightedParts("foo bar foo", "foo");
|
||||
expect(result).toEqual([
|
||||
{ text: "foo", isMatch: true },
|
||||
{ text: " bar ", isMatch: false },
|
||||
{ text: "foo", isMatch: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("handles adjacent matches correctly", () => {
|
||||
const result = getHighlightedParts("aaa", "a");
|
||||
expect(result).toEqual([
|
||||
{ text: "a", isMatch: true },
|
||||
{ text: "a", isMatch: true },
|
||||
{ text: "a", isMatch: true },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("case-insensitive matching", () => {
|
||||
it("matches uppercase query against lowercase text", () => {
|
||||
const result = getHighlightedParts("hello world", "WORLD");
|
||||
expect(result).toEqual([
|
||||
{ text: "hello ", isMatch: false },
|
||||
{ text: "world", isMatch: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("matches lowercase query against uppercase text", () => {
|
||||
const result = getHighlightedParts("HELLO WORLD", "hello");
|
||||
expect(result).toEqual([
|
||||
{ text: "HELLO", isMatch: true },
|
||||
{ text: " WORLD", isMatch: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves the original casing of the matched text", () => {
|
||||
const result = getHighlightedParts("Hello World", "hello");
|
||||
const matchPart = result.find((p) => p.isMatch);
|
||||
expect(matchPart?.text).toBe("Hello");
|
||||
});
|
||||
});
|
||||
|
||||
describe("special regex character escaping", () => {
|
||||
it("treats a dot in the query as a literal dot, not a wildcard", () => {
|
||||
const result = getHighlightedParts("v1.2.3 v123", "1.2");
|
||||
const matchParts = result.filter((p) => p.isMatch);
|
||||
expect(matchParts).toHaveLength(1);
|
||||
expect(matchParts[0].text).toBe("1.2");
|
||||
});
|
||||
|
||||
it("handles a query with parentheses", () => {
|
||||
const result = getHighlightedParts("fn(args)", "(args)");
|
||||
expect(result).toEqual([
|
||||
{ text: "fn", isMatch: false },
|
||||
{ text: "(args)", isMatch: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("handles a query with a plus sign", () => {
|
||||
const result = getHighlightedParts("a+b=c", "+");
|
||||
expect(result).toEqual([
|
||||
{ text: "a", isMatch: false },
|
||||
{ text: "+", isMatch: true },
|
||||
{ text: "b=c", isMatch: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it("handles a query with a question mark", () => {
|
||||
const result = getHighlightedParts("is it true?", "?");
|
||||
expect(result).toEqual([
|
||||
{ text: "is it true", isMatch: false },
|
||||
{ text: "?", isMatch: true },
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,7 @@
|
||||
import { claudeStore, isClaudeProcessing } from "$lib/stores/claude";
|
||||
import { characterState } from "$lib/stores/character";
|
||||
import { handleNewUserMessage } from "$lib/notifications/rules";
|
||||
import { setSkipNextGreeting } from "$lib/tauri";
|
||||
import { setSkipNextGreeting, updateDiscordRpc } from "$lib/tauri";
|
||||
import { clipboardStore } from "$lib/stores/clipboard";
|
||||
import {
|
||||
setShouldRestoreHistory,
|
||||
@@ -17,6 +17,8 @@
|
||||
} from "$lib/stores/historyRestore";
|
||||
import MessageModeSelector from "$lib/components/MessageModeSelector.svelte";
|
||||
import SlashCommandMenu from "$lib/components/SlashCommandMenu.svelte";
|
||||
import SystemClock from "$lib/components/SystemClock.svelte";
|
||||
import CliVersion from "$lib/components/CliVersion.svelte";
|
||||
import { getCurrentMode } from "$lib/stores/messageMode";
|
||||
import { formatMessageWithMode } from "$lib/types/messageMode";
|
||||
import {
|
||||
@@ -26,11 +28,16 @@
|
||||
type SlashCommand,
|
||||
} from "$lib/commands/slashCommands";
|
||||
import { configStore, isStreamerMode } from "$lib/stores/config";
|
||||
import { conversationsStore } from "$lib/stores/conversations";
|
||||
import { stats, estimateMessageCost, formatTokenCount } from "$lib/stores/stats";
|
||||
import AttachmentPreview from "$lib/components/AttachmentPreview.svelte";
|
||||
import SnippetLibraryPanel from "$lib/components/SnippetLibraryPanel.svelte";
|
||||
import QuickActionsPanel from "$lib/components/QuickActionsPanel.svelte";
|
||||
import ClipboardHistoryPanel from "$lib/components/ClipboardHistoryPanel.svelte";
|
||||
import DraftPanel from "$lib/components/DraftPanel.svelte";
|
||||
import TextInputContextMenu from "$lib/components/TextInputContextMenu.svelte";
|
||||
import { draftsStore } from "$lib/stores/drafts";
|
||||
import { injectTextStore } from "$lib/stores/projectContext";
|
||||
import type { Attachment } from "$lib/types/messages";
|
||||
|
||||
const INPUT_HISTORY_KEY = "hikari-input-history";
|
||||
@@ -48,8 +55,16 @@
|
||||
let showSnippetLibrary = $state(false);
|
||||
let showQuickActions = $state(false);
|
||||
let showClipboardHistory = $state(false);
|
||||
let showDraftPanel = $state(false);
|
||||
let streamerModeActive = $state(false);
|
||||
|
||||
// Cost estimation for pre-submission display
|
||||
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);
|
||||
@@ -153,6 +168,33 @@
|
||||
attachments = storedAttachments;
|
||||
});
|
||||
|
||||
// Per-tab draft persistence — restore the draft text whenever the active
|
||||
// conversation changes, and save it back on every keystroke.
|
||||
claudeStore.activeConversationId.subscribe((conversationId) => {
|
||||
if (conversationId) {
|
||||
const conv = get(claudeStore.conversations).get(conversationId);
|
||||
inputValue = conv?.draftText ?? "";
|
||||
} else {
|
||||
inputValue = "";
|
||||
}
|
||||
});
|
||||
|
||||
// Project context injection — set by StatusBar via injectTextStore signal.
|
||||
injectTextStore.subscribe((text) => {
|
||||
if (text === null) return;
|
||||
inputValue = inputValue.trim() ? text + "\n\n" + inputValue : text;
|
||||
userHasTyped = true;
|
||||
injectTextStore.set(null);
|
||||
});
|
||||
|
||||
function clearInput() {
|
||||
inputValue = "";
|
||||
const activeId = get(claudeStore.activeConversationId);
|
||||
if (activeId) {
|
||||
claudeStore.setDraftText(activeId, "");
|
||||
}
|
||||
}
|
||||
|
||||
function handleInputChange() {
|
||||
// If input is empty, allow history navigation again
|
||||
// Otherwise, mark that user has manually typed
|
||||
@@ -165,6 +207,12 @@
|
||||
historyIndex = -1;
|
||||
tempInput = "";
|
||||
|
||||
// Save the current draft so it persists if the user switches tabs.
|
||||
const activeId = get(claudeStore.activeConversationId);
|
||||
if (activeId) {
|
||||
claudeStore.setDraftText(activeId, inputValue);
|
||||
}
|
||||
|
||||
if (isSlashCommand(inputValue)) {
|
||||
matchingCommands = getMatchingCommands(inputValue);
|
||||
showCommandMenu = matchingCommands.length > 0;
|
||||
@@ -184,7 +232,7 @@
|
||||
async function executeSlashCommand(): Promise<boolean> {
|
||||
const { command, args } = parseSlashCommand(inputValue);
|
||||
if (command) {
|
||||
inputValue = "";
|
||||
clearInput();
|
||||
showCommandMenu = false;
|
||||
matchingCommands = [];
|
||||
await command.execute(args);
|
||||
@@ -200,7 +248,7 @@
|
||||
const hasAttachments = attachments.length > 0;
|
||||
|
||||
// Need either a message or attachments to submit
|
||||
if ((!message && !hasAttachments) || isSubmitting) return;
|
||||
if ((!message && !hasAttachments) || isSubmitting || isProcessing) return;
|
||||
|
||||
// Check for slash commands first (these work even when disconnected)
|
||||
if (message && isSlashCommand(message)) {
|
||||
@@ -217,7 +265,7 @@
|
||||
"error",
|
||||
`Unknown command: ${message.split(" ")[0]}. Type /help for available commands.`
|
||||
);
|
||||
inputValue = "";
|
||||
clearInput();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -233,7 +281,7 @@
|
||||
userHasTyped = false;
|
||||
|
||||
isSubmitting = true;
|
||||
inputValue = "";
|
||||
clearInput();
|
||||
|
||||
// Capture attachments before clearing
|
||||
const currentAttachments = [...attachments];
|
||||
@@ -291,6 +339,7 @@ User: ${formattedMessage}`;
|
||||
conversationId,
|
||||
message: messageToSend,
|
||||
});
|
||||
claudeStore.setProcessing(true);
|
||||
} catch (error) {
|
||||
console.error("Failed to send prompt:", error);
|
||||
claudeStore.addLine("error", `Failed to send: ${error}`);
|
||||
@@ -315,7 +364,7 @@ User: ${formattedMessage}`;
|
||||
throw new Error("No active conversation");
|
||||
}
|
||||
await invoke("interrupt_claude", { conversationId });
|
||||
claudeStore.addLine("system", "Process interrupted - reconnecting...");
|
||||
claudeStore.addLine("system", "Process interrupted via stop button — reconnecting...");
|
||||
characterState.setState("idle");
|
||||
|
||||
// Show connecting status while we reconnect
|
||||
@@ -329,19 +378,51 @@ User: ${formattedMessage}`;
|
||||
throw new Error("No active conversation");
|
||||
}
|
||||
|
||||
// Get current working directory before reconnecting
|
||||
// Get current working directory and granted tools before reconnecting
|
||||
const workingDir = await invoke<string>("get_working_directory", { conversationId });
|
||||
const activeConversation = get(conversationsStore.activeConversation);
|
||||
const grantedTools = activeConversation
|
||||
? Array.from(activeConversation.grantedTools)
|
||||
: [];
|
||||
const config = configStore.getConfig();
|
||||
const allAllowedTools = [...new Set([...grantedTools, ...config.auto_granted_tools])];
|
||||
|
||||
// Set the flag to skip greeting on next connection
|
||||
setSkipNextGreeting(true);
|
||||
|
||||
// Reconnect to Claude
|
||||
// Reconnect to Claude with preserved permissions
|
||||
await invoke("start_claude", {
|
||||
conversationId,
|
||||
options: {
|
||||
working_dir: workingDir,
|
||||
model: config.model || null,
|
||||
api_key: config.api_key || null,
|
||||
custom_instructions: config.custom_instructions || null,
|
||||
mcp_servers_json: config.mcp_servers_json || null,
|
||||
allowed_tools: allAllowedTools,
|
||||
use_worktree: config.use_worktree ?? false,
|
||||
disable_1m_context: config.disable_1m_context ?? false,
|
||||
disable_cron: config.disable_cron ?? false,
|
||||
disable_skill_shell_execution: config.disable_skill_shell_execution ?? false,
|
||||
include_git_instructions: config.include_git_instructions ?? true,
|
||||
enable_claudeai_mcp_servers: config.enable_claudeai_mcp_servers ?? true,
|
||||
auto_memory_directory: config.auto_memory_directory || null,
|
||||
model_overrides: config.model_overrides || null,
|
||||
session_name: null,
|
||||
bare_mode: config.bare_mode ?? false,
|
||||
show_clear_context_on_plan_accept: config.show_clear_context_on_plan_accept ?? true,
|
||||
custom_model_option: config.custom_model_option || null,
|
||||
},
|
||||
});
|
||||
|
||||
// Update Discord RPC when reconnecting
|
||||
if (activeConversation) {
|
||||
await updateDiscordRpc(
|
||||
activeConversation.name,
|
||||
config.model || "claude",
|
||||
activeConversation.startedAt
|
||||
);
|
||||
}
|
||||
} catch (reconnectError) {
|
||||
console.error("Failed to auto-reconnect:", reconnectError);
|
||||
claudeStore.addLine("error", `Failed to reconnect: ${reconnectError}`);
|
||||
@@ -427,11 +508,12 @@ User: ${formattedMessage}`;
|
||||
try {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const bytes = Array.from(new Uint8Array(arrayBuffer));
|
||||
savedPath = await invoke<string>("save_temp_file", {
|
||||
const result = await invoke<{ path: string; filename: string }>("save_temp_file", {
|
||||
conversationId,
|
||||
filename,
|
||||
data: bytes,
|
||||
});
|
||||
savedPath = result.path;
|
||||
} catch (error) {
|
||||
console.error("Failed to save dropped file to temp:", error);
|
||||
savedPath = file.name;
|
||||
@@ -565,11 +647,12 @@ User: ${formattedMessage}`;
|
||||
try {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const bytes = Array.from(new Uint8Array(arrayBuffer));
|
||||
savedPath = await invoke<string>("save_temp_file", {
|
||||
const result = await invoke<{ path: string; filename: string }>("save_temp_file", {
|
||||
conversationId,
|
||||
filename,
|
||||
data: bytes,
|
||||
});
|
||||
savedPath = result.path;
|
||||
} catch (error) {
|
||||
console.error("Failed to save pasted file to temp:", error);
|
||||
}
|
||||
@@ -627,11 +710,12 @@ User: ${formattedMessage}`;
|
||||
try {
|
||||
const arrayBuffer = await blob.arrayBuffer();
|
||||
const bytes = Array.from(new Uint8Array(arrayBuffer));
|
||||
savedPath = await invoke<string>("save_temp_file", {
|
||||
const result = await invoke<{ path: string; filename: string }>("save_temp_file", {
|
||||
conversationId,
|
||||
filename,
|
||||
data: bytes,
|
||||
});
|
||||
savedPath = result.path;
|
||||
} catch (error) {
|
||||
console.error("Failed to save clipboard image to temp:", error);
|
||||
}
|
||||
@@ -667,6 +751,22 @@ User: ${formattedMessage}`;
|
||||
userHasTyped = true;
|
||||
}
|
||||
|
||||
function handleDraftInsert(content: string): void {
|
||||
inputValue = content;
|
||||
userHasTyped = true;
|
||||
const activeId = get(claudeStore.activeConversationId);
|
||||
if (activeId) {
|
||||
claudeStore.setDraftText(activeId, content);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveAsDraft(): Promise<void> {
|
||||
const content = inputValue.trim();
|
||||
if (!content) return;
|
||||
await draftsStore.saveDraft(content);
|
||||
clearInput();
|
||||
}
|
||||
|
||||
function handleClipboardInsert(content: string): void {
|
||||
// Insert clipboard content at cursor position or append to input
|
||||
if (inputValue.trim()) {
|
||||
@@ -679,7 +779,7 @@ User: ${formattedMessage}`;
|
||||
|
||||
async function handleQuickAction(prompt: string): Promise<void> {
|
||||
// Quick actions send the prompt directly
|
||||
if (!isConnected || isSubmitting) return;
|
||||
if (!isConnected || isSubmitting || isProcessing) return;
|
||||
|
||||
// Add to history
|
||||
addToHistory(prompt);
|
||||
@@ -704,6 +804,7 @@ User: ${formattedMessage}`;
|
||||
conversationId,
|
||||
message: prompt,
|
||||
});
|
||||
claudeStore.setProcessing(true);
|
||||
} catch (error) {
|
||||
console.error("Failed to send quick action:", error);
|
||||
claudeStore.addLine("error", `Failed to send: ${error}`);
|
||||
@@ -882,6 +983,32 @@ User: ${formattedMessage}`;
|
||||
</svg>
|
||||
<span>Clipboard</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (showDraftPanel = true)}
|
||||
class="control-button"
|
||||
title="Saved Drafts"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path
|
||||
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
||||
/>
|
||||
</svg>
|
||||
<span>Drafts</span>
|
||||
</button>
|
||||
|
||||
<CliVersion />
|
||||
<SystemClock />
|
||||
</div>
|
||||
|
||||
<div class="input-row">
|
||||
@@ -903,9 +1030,9 @@ User: ${formattedMessage}`;
|
||||
placeholder={isConnected
|
||||
? "Ask Hikari anything... (type / for commands)"
|
||||
: "Connect to Claude first..."}
|
||||
disabled={isSubmitting}
|
||||
disabled={isSubmitting || isProcessing}
|
||||
rows={1}
|
||||
style="height: {textareaHeight}px; font-size: var(--terminal-font-size, 14px);"
|
||||
style="height: {textareaHeight}px; font-size: var(--terminal-font-size, 14px); font-family: var(--terminal-font-family, monospace);"
|
||||
class="w-full px-4 py-3 bg-[var(--bg-secondary)] border border-[var(--border-color)]
|
||||
rounded-lg text-[var(--text-primary)] placeholder-gray-500 resize-none
|
||||
input-trans-focus disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
@@ -913,6 +1040,36 @@ User: ${formattedMessage}`;
|
||||
</div>
|
||||
|
||||
<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={handleSaveAsDraft}
|
||||
disabled={!inputValue.trim()}
|
||||
class="attach-button"
|
||||
title="Save as Draft"
|
||||
>
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z" />
|
||||
<polyline points="17 21 17 13 7 13 7 21" />
|
||||
<polyline points="7 3 7 8 15 8" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button type="button" onclick={handleFilePicker} class="attach-button" title="Attach files">
|
||||
<svg
|
||||
width="20"
|
||||
@@ -978,6 +1135,10 @@ User: ${formattedMessage}`;
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if showDraftPanel}
|
||||
<DraftPanel onClose={() => (showDraftPanel = false)} onInsert={handleDraftInsert} />
|
||||
{/if}
|
||||
|
||||
{#if contextMenuShow && textareaElement}
|
||||
<TextInputContextMenu
|
||||
x={contextMenuX}
|
||||
@@ -1027,6 +1188,7 @@ User: ${formattedMessage}`;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.control-button {
|
||||
@@ -1043,6 +1205,18 @@ User: ${formattedMessage}`;
|
||||
transition: all 0.2s;
|
||||
font-size: 14px;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Hide button text on smaller screens, show icons only */
|
||||
@media (max-width: 640px) {
|
||||
.control-button span {
|
||||
display: none;
|
||||
}
|
||||
.control-button {
|
||||
padding: 10px;
|
||||
min-width: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
.control-button:hover {
|
||||
@@ -1138,6 +1312,28 @@ User: ${formattedMessage}`;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
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 {
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
import hljs from "highlight.js";
|
||||
import { onMount } from "svelte";
|
||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { clipboardStore } from "$lib/stores/clipboard";
|
||||
import { linkifyFilePaths } from "$lib/utils/filePaths";
|
||||
|
||||
interface Props {
|
||||
content: string;
|
||||
@@ -35,7 +37,12 @@
|
||||
};
|
||||
|
||||
renderer.codespan = ({ text }) => {
|
||||
return `<code class="hljs-inline">${text}</code>`;
|
||||
const escaped = text.replace(/</g, "<").replace(/>/g, ">");
|
||||
return `<code class="hljs-inline">${escaped}</code>`;
|
||||
};
|
||||
|
||||
renderer.html = ({ text }) => {
|
||||
return text.replace(/</g, "<").replace(/>/g, ">");
|
||||
};
|
||||
|
||||
marked.setOptions({
|
||||
@@ -103,15 +110,20 @@
|
||||
return processed;
|
||||
}
|
||||
|
||||
function renderMarkdown(text: string): string {
|
||||
// Two-stage reactive rendering:
|
||||
// Stage 1 — only re-runs when `content` changes (expensive: marked + hljs + spoilers)
|
||||
let parsedHtml = $derived.by(() => {
|
||||
try {
|
||||
const html = marked.parse(text) as string;
|
||||
const html = marked.parse(content) as string;
|
||||
const withSpoilers = processSpoilers(html);
|
||||
return highlightSearchMatches(withSpoilers, searchQuery);
|
||||
return linkifyFilePaths(withSpoilers);
|
||||
} catch {
|
||||
return text;
|
||||
return content;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Stage 2 — re-runs when search changes; skips re-parsing markdown entirely
|
||||
let renderedHtml = $derived(highlightSearchMatches(parsedHtml, searchQuery));
|
||||
|
||||
function handleSpoilerClick(event: Event) {
|
||||
const target = event.target as HTMLElement;
|
||||
@@ -131,9 +143,18 @@
|
||||
function handleLinkClick(event: MouseEvent) {
|
||||
const target = event.target as HTMLElement;
|
||||
const anchor = target.closest("a");
|
||||
if (anchor?.href) {
|
||||
event.preventDefault();
|
||||
openUrl(anchor.href);
|
||||
if (!anchor) return;
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
const filePath = anchor.dataset.filepath;
|
||||
if (filePath) {
|
||||
void invoke("open_binary_file", { path: filePath });
|
||||
return;
|
||||
}
|
||||
|
||||
if (anchor.href) {
|
||||
void openUrl(anchor.href);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -186,7 +207,7 @@
|
||||
role="presentation"
|
||||
>
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -- Markdown rendering requires @html; content is from Claude API -->
|
||||
{@html renderMarkdown(content)}
|
||||
{@html renderedHtml}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@@ -276,10 +297,16 @@
|
||||
font-family: "JetBrains Mono", "Fira Code", monospace;
|
||||
}
|
||||
|
||||
.markdown-content :global(ul),
|
||||
.markdown-content :global(ul) {
|
||||
margin: 0.5em 0;
|
||||
padding-left: 1.5em;
|
||||
list-style-type: disc;
|
||||
}
|
||||
|
||||
.markdown-content :global(ol) {
|
||||
margin: 0.5em 0;
|
||||
padding-left: 1.5em;
|
||||
list-style-type: decimal;
|
||||
}
|
||||
|
||||
.markdown-content :global(li) {
|
||||
@@ -438,4 +465,27 @@
|
||||
border-radius: 2px;
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.markdown-content :global(.file-link) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25em;
|
||||
color: var(--accent-primary, #f472b6);
|
||||
text-decoration: none;
|
||||
border: 1px solid color-mix(in srgb, var(--accent-primary) 30%, transparent);
|
||||
background: color-mix(in srgb, var(--accent-primary) 8%, transparent);
|
||||
border-radius: 4px;
|
||||
padding: 0.1em 0.4em;
|
||||
font-family: "JetBrains Mono", "Fira Code", monospace;
|
||||
font-size: 0.875em;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.markdown-content :global(.file-link:hover) {
|
||||
background: color-mix(in srgb, var(--accent-primary) 18%, transparent);
|
||||
border-color: color-mix(in srgb, var(--accent-primary) 60%, transparent);
|
||||
color: var(--accent-secondary, #e879f9);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* Markdown Component Tests
|
||||
*
|
||||
* Tests the pure helper functions extracted from the Markdown component:
|
||||
* - processSpoilers: wraps ||text|| syntax in spoiler spans, leaving code blocks untouched
|
||||
* - highlightSearchMatches: injects <mark> tags for search terms, skipping code blocks
|
||||
*
|
||||
* Manual testing checklist:
|
||||
* - [ ] Code blocks render with syntax highlighting and a copy button
|
||||
* - [ ] ||spoiler text|| renders as a hidden span revealed on click
|
||||
* - [ ] Search query highlights matching text in non-code content
|
||||
* - [ ] Regular links open in the system browser via the Tauri opener
|
||||
* - [ ] Binary file links invoke open_binary_file (WSL-path-aware) instead of openPath
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
// Mirror functions from Markdown.svelte for isolated testing
|
||||
|
||||
function processSpoilers(html: string): string {
|
||||
const codeBlockPlaceholders: string[] = [];
|
||||
|
||||
let processed = html.replace(/<(pre|code)[^>]*>[\s\S]*?<\/\1>/gi, (match) => {
|
||||
codeBlockPlaceholders.push(match);
|
||||
return `__CODE_PLACEHOLDER_${codeBlockPlaceholders.length - 1}__`;
|
||||
});
|
||||
|
||||
processed = processed.replace(
|
||||
/\|\|(.+?)\|\|/g,
|
||||
'<span class="spoiler" role="button" tabindex="0">$1</span>'
|
||||
);
|
||||
|
||||
processed = processed.replace(/__CODE_PLACEHOLDER_(\d+)__/g, (_, index) => {
|
||||
return codeBlockPlaceholders[parseInt(index)];
|
||||
});
|
||||
|
||||
return processed;
|
||||
}
|
||||
|
||||
function highlightSearchMatches(html: string, query: string): string {
|
||||
if (!query) return html;
|
||||
|
||||
const codeBlockPlaceholders: string[] = [];
|
||||
const tagPlaceholders: string[] = [];
|
||||
|
||||
let processed = html.replace(/<(pre|code)[^>]*>[\s\S]*?<\/\1>/gi, (match) => {
|
||||
codeBlockPlaceholders.push(match);
|
||||
return `__CODE_SEARCH_PLACEHOLDER_${codeBlockPlaceholders.length - 1}__`;
|
||||
});
|
||||
|
||||
processed = processed.replace(/<[^>]+>/g, (match) => {
|
||||
tagPlaceholders.push(match);
|
||||
return `__TAG_PLACEHOLDER_${tagPlaceholders.length - 1}__`;
|
||||
});
|
||||
|
||||
const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const regex = new RegExp(`(${escapedQuery})`, "gi");
|
||||
processed = processed.replace(regex, '<mark class="search-highlight">$1</mark>');
|
||||
|
||||
processed = processed.replace(/__TAG_PLACEHOLDER_(\d+)__/g, (_, index) => {
|
||||
return tagPlaceholders[parseInt(index)];
|
||||
});
|
||||
|
||||
processed = processed.replace(/__CODE_SEARCH_PLACEHOLDER_(\d+)__/g, (_, index) => {
|
||||
return codeBlockPlaceholders[parseInt(index)];
|
||||
});
|
||||
|
||||
return processed;
|
||||
}
|
||||
|
||||
// ---
|
||||
|
||||
describe("processSpoilers", () => {
|
||||
it("wraps ||text|| in a spoiler span", () => {
|
||||
const result = processSpoilers("<p>||secret||</p>");
|
||||
expect(result).toContain('<span class="spoiler"');
|
||||
expect(result).toContain("secret");
|
||||
});
|
||||
|
||||
it("adds role=button and tabindex to spoiler spans", () => {
|
||||
const result = processSpoilers("<p>||hidden||</p>");
|
||||
expect(result).toContain('role="button"');
|
||||
expect(result).toContain('tabindex="0"');
|
||||
});
|
||||
|
||||
it("leaves content without spoiler markers unchanged", () => {
|
||||
const html = "<p>Normal text here</p>";
|
||||
expect(processSpoilers(html)).toBe(html);
|
||||
});
|
||||
|
||||
it("handles multiple spoilers in the same string", () => {
|
||||
const result = processSpoilers("<p>||a|| and ||b||</p>");
|
||||
const matches = result.match(/class="spoiler"/g);
|
||||
expect(matches).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("does not apply spoiler syntax inside code blocks", () => {
|
||||
const html = "<pre><code>||not a spoiler||</code></pre>";
|
||||
const result = processSpoilers(html);
|
||||
expect(result).not.toContain('class="spoiler"');
|
||||
expect(result).toContain("||not a spoiler||");
|
||||
});
|
||||
|
||||
it("does not apply spoiler syntax inside inline code", () => {
|
||||
const html = "<p>Example: <code>||inline||</code></p>";
|
||||
const result = processSpoilers(html);
|
||||
expect(result).not.toContain('class="spoiler"');
|
||||
});
|
||||
|
||||
it("handles spoilers adjacent to code blocks correctly", () => {
|
||||
const html = "<pre><code>code</code></pre><p>||revealed||</p>";
|
||||
const result = processSpoilers(html);
|
||||
expect(result).toContain('<span class="spoiler"');
|
||||
expect(result).toContain("<pre><code>code</code></pre>");
|
||||
});
|
||||
});
|
||||
|
||||
describe("highlightSearchMatches", () => {
|
||||
it("returns unchanged html when query is empty string", () => {
|
||||
const html = "<p>hello world</p>";
|
||||
expect(highlightSearchMatches(html, "")).toBe(html);
|
||||
});
|
||||
|
||||
it("wraps matched text in a mark element", () => {
|
||||
const result = highlightSearchMatches("<p>hello world</p>", "hello");
|
||||
expect(result).toContain('<mark class="search-highlight">hello</mark>');
|
||||
});
|
||||
|
||||
it("is case-insensitive", () => {
|
||||
const result = highlightSearchMatches("<p>Hello World</p>", "hello");
|
||||
expect(result).toContain('<mark class="search-highlight">Hello</mark>');
|
||||
});
|
||||
|
||||
it("highlights all occurrences", () => {
|
||||
const result = highlightSearchMatches("<p>cat and cat</p>", "cat");
|
||||
const matches = result.match(/<mark class="search-highlight">/g);
|
||||
expect(matches).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("does not highlight inside code blocks", () => {
|
||||
const html = "<pre><code>hello inside code</code></pre>";
|
||||
const result = highlightSearchMatches(html, "hello");
|
||||
expect(result).not.toContain('<mark class="search-highlight">');
|
||||
expect(result).toContain("hello inside code");
|
||||
});
|
||||
|
||||
it("does not corrupt HTML tags", () => {
|
||||
const result = highlightSearchMatches('<p class="foo">hello</p>', "hello");
|
||||
expect(result).toContain('<p class="foo">');
|
||||
expect(result).toContain('<mark class="search-highlight">hello</mark>');
|
||||
});
|
||||
|
||||
it("escapes regex special characters in the query", () => {
|
||||
const result = highlightSearchMatches("<p>price: $1.00</p>", "$1");
|
||||
expect(result).toContain('<mark class="search-highlight">$1</mark>');
|
||||
});
|
||||
|
||||
it("highlights text outside code blocks whilst leaving code intact", () => {
|
||||
const html = "<pre><code>match here</code></pre><p>match here too</p>";
|
||||
const result = highlightSearchMatches(html, "match");
|
||||
expect(result).toContain("<pre><code>match here</code></pre>");
|
||||
expect(result).toContain('<mark class="search-highlight">match</mark>');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,416 @@
|
||||
<script lang="ts">
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { onMount } from "svelte";
|
||||
import { Trash2, RefreshCw, Server, Globe, Terminal } from "lucide-svelte";
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface McpServerInfo {
|
||||
name: string;
|
||||
command: string | null;
|
||||
url: string | null;
|
||||
transport: string; // "stdio", "http", or "sse"
|
||||
env: Record<string, string> | null;
|
||||
status: string | null; // "Connected" or "Failed to connect"
|
||||
}
|
||||
|
||||
const { onClose }: Props = $props();
|
||||
|
||||
let servers = $state<McpServerInfo[]>([]);
|
||||
let isLoading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
let selectedServer = $state<McpServerInfo | null>(null);
|
||||
let isLoadingDetails = $state(false);
|
||||
let actionInProgress = $state<string | null>(null);
|
||||
let showAddForm = $state(false);
|
||||
let serverDetails = $state<string>("");
|
||||
|
||||
// Add server form fields
|
||||
let newServerName = $state("");
|
||||
let newServerUrl = $state("");
|
||||
let newServerTransport = $state("stdio");
|
||||
let isAdding = $state(false);
|
||||
|
||||
async function loadServers(): Promise<void> {
|
||||
try {
|
||||
isLoading = true;
|
||||
error = null;
|
||||
servers = await invoke<McpServerInfo[]>("list_mcp_servers");
|
||||
} catch (e) {
|
||||
error = `Failed to load MCP servers: ${e}`;
|
||||
console.error(error);
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadServerDetails(name: string): Promise<void> {
|
||||
try {
|
||||
isLoadingDetails = true;
|
||||
error = null;
|
||||
selectedServer = await invoke<McpServerInfo>("get_mcp_server", { name });
|
||||
serverDetails = await invoke<string>("get_mcp_server_details", { name });
|
||||
} catch (e) {
|
||||
error = `Failed to load server details: ${e}`;
|
||||
console.error(error);
|
||||
} finally {
|
||||
isLoadingDetails = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeServer(name: string): Promise<void> {
|
||||
try {
|
||||
actionInProgress = name;
|
||||
error = null;
|
||||
await invoke("remove_mcp_server", { name });
|
||||
if (selectedServer?.name === name) {
|
||||
selectedServer = null;
|
||||
serverDetails = "";
|
||||
}
|
||||
await loadServers();
|
||||
} catch (e) {
|
||||
error = `Failed to remove server: ${e}`;
|
||||
console.error(error);
|
||||
} finally {
|
||||
actionInProgress = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function addServer(): Promise<void> {
|
||||
if (!newServerName.trim() || !newServerUrl.trim()) return;
|
||||
|
||||
try {
|
||||
isAdding = true;
|
||||
error = null;
|
||||
await invoke("add_mcp_server", {
|
||||
name: newServerName.trim(),
|
||||
commandOrUrl: newServerUrl.trim(),
|
||||
transport: newServerTransport,
|
||||
envVars: null,
|
||||
headers: null,
|
||||
});
|
||||
newServerName = "";
|
||||
newServerUrl = "";
|
||||
newServerTransport = "stdio";
|
||||
showAddForm = false;
|
||||
await loadServers();
|
||||
} catch (e) {
|
||||
error = `Failed to add server: ${e}`;
|
||||
console.error(error);
|
||||
} finally {
|
||||
isAdding = false;
|
||||
}
|
||||
}
|
||||
|
||||
function getTransportIcon(transport: string) {
|
||||
switch (transport) {
|
||||
case "http":
|
||||
return Globe;
|
||||
case "stdio":
|
||||
return Terminal;
|
||||
case "sse":
|
||||
return Server;
|
||||
default:
|
||||
return Server;
|
||||
}
|
||||
}
|
||||
|
||||
function getTransportColor(transport: string) {
|
||||
switch (transport) {
|
||||
case "http":
|
||||
return "text-blue-400";
|
||||
case "stdio":
|
||||
return "text-green-400";
|
||||
case "sse":
|
||||
return "text-purple-400";
|
||||
default:
|
||||
return "text-[var(--text-secondary)]";
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
loadServers();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="fixed top-0 right-0 h-full w-[700px] bg-[var(--bg-primary)] border-l border-[var(--accent-primary)]/30 shadow-2xl flex flex-col z-50"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between p-4 border-b border-[var(--accent-primary)]/30">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="text-[var(--accent-primary)]">
|
||||
<Server class="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-[var(--text-primary)]">MCP Server Management</h2>
|
||||
<p class="text-xs text-[var(--text-secondary)]">
|
||||
{servers.length} server{servers.length !== 1 ? "s" : ""} configured
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onclick={onClose}
|
||||
class="text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors p-1 rounded-lg hover:bg-[var(--bg-secondary)]"
|
||||
aria-label="Close MCP panel"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Add Server Button -->
|
||||
<div class="p-4 border-b border-[var(--border-color)]">
|
||||
<button
|
||||
onclick={() => (showAddForm = !showAddForm)}
|
||||
class="w-full px-4 py-2 bg-[var(--accent-primary)] text-white rounded-lg text-sm font-medium hover:opacity-80 transition-opacity flex items-center justify-center gap-2"
|
||||
>
|
||||
<Server class="w-4 h-4" />
|
||||
{showAddForm ? "Cancel" : "Add New Server"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Add Server Form -->
|
||||
{#if showAddForm}
|
||||
<div
|
||||
class="mx-4 mt-4 p-4 bg-[var(--bg-secondary)]/50 border border-[var(--border-color)] rounded-lg"
|
||||
>
|
||||
<h3 class="text-sm font-medium text-[var(--text-primary)] mb-3">Add MCP Server</h3>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label
|
||||
for="mcp-new-name"
|
||||
class="text-xs text-[var(--text-secondary)] uppercase tracking-wider block mb-1"
|
||||
>Server Name</label
|
||||
>
|
||||
<input
|
||||
id="mcp-new-name"
|
||||
type="text"
|
||||
bind:value={newServerName}
|
||||
placeholder="my-server"
|
||||
class="w-full px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] text-sm focus:outline-none focus:border-[var(--accent-primary)]"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="mcp-new-transport"
|
||||
class="text-xs text-[var(--text-secondary)] uppercase tracking-wider block mb-1"
|
||||
>Transport</label
|
||||
>
|
||||
<select
|
||||
id="mcp-new-transport"
|
||||
bind:value={newServerTransport}
|
||||
class="w-full px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] text-sm focus:outline-none focus:border-[var(--accent-primary)]"
|
||||
>
|
||||
<option value="stdio">STDIO</option>
|
||||
<option value="http">HTTP</option>
|
||||
<option value="sse">SSE</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="mcp-new-url"
|
||||
class="text-xs text-[var(--text-secondary)] uppercase tracking-wider block mb-1"
|
||||
>
|
||||
{newServerTransport === "stdio" ? "Command" : "URL"}
|
||||
</label>
|
||||
<input
|
||||
id="mcp-new-url"
|
||||
type="text"
|
||||
bind:value={newServerUrl}
|
||||
placeholder={newServerTransport === "stdio"
|
||||
? "npx my-mcp-server"
|
||||
: "https://mcp.example.com"}
|
||||
class="w-full px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] text-sm focus:outline-none focus:border-[var(--accent-primary)]"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onclick={addServer}
|
||||
disabled={isAdding || !newServerName.trim() || !newServerUrl.trim()}
|
||||
class="w-full px-4 py-2 bg-[var(--accent-primary)] text-white rounded-lg text-sm font-medium hover:opacity-80 disabled:opacity-40 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
{#if isAdding}
|
||||
<RefreshCw class="w-4 h-4 animate-spin" />
|
||||
{:else}
|
||||
<Server class="w-4 h-4" />
|
||||
{/if}
|
||||
Add Server
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Error Display -->
|
||||
{#if error}
|
||||
<div class="mx-4 mt-4 p-3 bg-red-500/20 border border-red-500/30 rounded-lg">
|
||||
<p class="text-sm text-red-400">{error}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex-1 overflow-y-auto p-4 flex gap-4">
|
||||
<!-- Server List -->
|
||||
<div class="flex-1">
|
||||
{#if isLoading}
|
||||
<div class="flex items-center justify-center h-full text-[var(--text-secondary)]">
|
||||
<RefreshCw class="w-8 h-8 animate-spin" />
|
||||
</div>
|
||||
{:else if servers.length === 0}
|
||||
<div class="flex flex-col items-center justify-center h-full text-[var(--text-secondary)]">
|
||||
<Server class="w-16 h-16 mb-4 opacity-50" />
|
||||
<p class="text-center">No MCP servers configured</p>
|
||||
<p class="text-sm text-center mt-2">Add servers via Settings</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-2">
|
||||
{#each servers as server (server.name)}
|
||||
{@const TransportIcon = getTransportIcon(server.transport)}
|
||||
<button
|
||||
onclick={() => loadServerDetails(server.name)}
|
||||
class="w-full bg-[var(--bg-secondary)]/50 rounded-lg p-3 border border-[var(--border-color)] hover:border-[var(--accent-primary)]/50 transition-all text-left"
|
||||
class:border-[var(--accent-primary)]={selectedServer?.name === server.name}
|
||||
>
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<h4 class="font-medium text-[var(--text-primary)] flex items-center gap-2">
|
||||
<TransportIcon class="w-4 h-4 {getTransportColor(server.transport)}" />
|
||||
{server.name}
|
||||
{#if server.status}
|
||||
{#if server.status.includes("Connected")}
|
||||
<span
|
||||
class="px-2 py-0.5 bg-[var(--success-color)]/20 text-[var(--success-color)] text-xs rounded border border-[var(--success-color)]/30"
|
||||
>
|
||||
✓
|
||||
</span>
|
||||
{:else}
|
||||
<span
|
||||
class="px-2 py-0.5 bg-red-500/20 text-red-400 text-xs rounded border border-red-500/30"
|
||||
>
|
||||
✗
|
||||
</span>
|
||||
{/if}
|
||||
{/if}
|
||||
</h4>
|
||||
<p class="text-xs text-[var(--text-secondary)] mt-1">
|
||||
{server.transport.toUpperCase()}
|
||||
{#if server.url}
|
||||
• {server.url}
|
||||
{:else if server.command}
|
||||
• {server.command}
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Server Details Panel -->
|
||||
{#if selectedServer}
|
||||
<div
|
||||
class="w-80 bg-[var(--bg-secondary)]/50 rounded-lg p-4 border border-[var(--border-color)]"
|
||||
>
|
||||
<h3 class="text-lg font-semibold text-[var(--text-primary)] mb-4">Server Details</h3>
|
||||
|
||||
{#if isLoadingDetails}
|
||||
<div class="flex items-center justify-center h-32">
|
||||
<RefreshCw class="w-6 h-6 animate-spin text-[var(--text-secondary)]" />
|
||||
</div>
|
||||
{:else}
|
||||
{@const TransportIcon = getTransportIcon(selectedServer.transport)}
|
||||
<div class="space-y-4">
|
||||
<!-- Name -->
|
||||
<div>
|
||||
<p class="text-xs text-[var(--text-secondary)] uppercase tracking-wider">Name</p>
|
||||
<p class="text-sm text-[var(--text-primary)] mt-1">{selectedServer.name}</p>
|
||||
</div>
|
||||
|
||||
<!-- Transport -->
|
||||
<div>
|
||||
<p class="text-xs text-[var(--text-secondary)] uppercase tracking-wider">Transport</p>
|
||||
<p class="text-sm text-[var(--text-primary)] mt-1 flex items-center gap-2">
|
||||
<TransportIcon class="w-4 h-4 {getTransportColor(selectedServer.transport)}" />
|
||||
{selectedServer.transport.toUpperCase()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- URL or Command -->
|
||||
{#if selectedServer.url}
|
||||
<div>
|
||||
<p class="text-xs text-[var(--text-secondary)] uppercase tracking-wider">URL</p>
|
||||
<p
|
||||
class="text-sm text-[var(--text-primary)] mt-1 break-all font-mono bg-[var(--bg-primary)] p-2 rounded border border-[var(--border-color)]"
|
||||
>
|
||||
{selectedServer.url}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if selectedServer.command}
|
||||
<div>
|
||||
<p class="text-xs text-[var(--text-secondary)] uppercase tracking-wider">Command</p>
|
||||
<p
|
||||
class="text-sm text-[var(--text-primary)] mt-1 font-mono bg-[var(--bg-primary)] p-2 rounded border border-[var(--border-color)]"
|
||||
>
|
||||
{selectedServer.command}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Environment Variables -->
|
||||
{#if selectedServer.env}
|
||||
<div>
|
||||
<p class="text-xs text-[var(--text-secondary)] uppercase tracking-wider">
|
||||
Environment
|
||||
</p>
|
||||
<pre
|
||||
class="text-xs text-[var(--text-primary)] mt-1 font-mono bg-[var(--bg-primary)] p-2 rounded border border-[var(--border-color)] overflow-x-auto">{JSON.stringify(
|
||||
selectedServer.env,
|
||||
null,
|
||||
2
|
||||
)}</pre>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Full Server Details -->
|
||||
{#if serverDetails}
|
||||
<div>
|
||||
<p class="text-xs text-[var(--text-secondary)] uppercase tracking-wider">
|
||||
Full Details
|
||||
</p>
|
||||
<pre
|
||||
class="text-xs text-[var(--text-primary)] mt-1 font-mono bg-[var(--bg-primary)] p-2 rounded border border-[var(--border-color)] overflow-x-auto whitespace-pre-wrap">{serverDetails}</pre>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="pt-4 border-t border-[var(--border-color)]">
|
||||
<button
|
||||
onclick={() => selectedServer && removeServer(selectedServer.name)}
|
||||
disabled={actionInProgress === selectedServer?.name}
|
||||
class="w-full px-4 py-2 bg-red-500/20 border border-red-500/30 rounded-lg text-sm text-red-400 hover:bg-red-500/30 transition-colors disabled:opacity-40 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
<Trash2 class="w-4 h-4" />
|
||||
Remove Server
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,534 @@
|
||||
<script lang="ts">
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { get } from "svelte/store";
|
||||
import { claudeStore } from "$lib/stores/claude";
|
||||
import Markdown from "./Markdown.svelte";
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const { isOpen, onClose }: Props = $props();
|
||||
|
||||
interface MemoryFileInfo {
|
||||
path: string;
|
||||
heading: string | null;
|
||||
last_modified?: string; // Unix timestamp in seconds as a string, optional for backwards compat
|
||||
}
|
||||
|
||||
interface MemoryFilesResponse {
|
||||
files: MemoryFileInfo[];
|
||||
}
|
||||
|
||||
let memoryFiles: MemoryFileInfo[] = $state([]);
|
||||
let selectedFile: string | null = $state(null);
|
||||
let fileContent: string = $state("");
|
||||
let isLoading = $state(false);
|
||||
let error: string | null = $state(null);
|
||||
|
||||
async function loadMemoryFiles() {
|
||||
isLoading = true;
|
||||
error = null;
|
||||
try {
|
||||
const response = await invoke<MemoryFilesResponse>("list_memory_files");
|
||||
memoryFiles = response.files;
|
||||
} catch (e) {
|
||||
error = `Failed to load memory files: ${e}`;
|
||||
console.error(error);
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadFileContent(filePath: string) {
|
||||
isLoading = true;
|
||||
error = null;
|
||||
try {
|
||||
// Use our backend command instead of Tauri plugin to handle WSL paths
|
||||
const content = await invoke<string>("read_file_content", { path: filePath });
|
||||
fileContent = content;
|
||||
selectedFile = filePath;
|
||||
} catch (e) {
|
||||
error = `Failed to read file: ${e}`;
|
||||
console.error(error);
|
||||
fileContent = "";
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function getFileName(path: string): string {
|
||||
return path.split("/").pop() || path;
|
||||
}
|
||||
|
||||
function getDisplayName(file: MemoryFileInfo): string {
|
||||
return file.heading ?? getFileName(file.path);
|
||||
}
|
||||
|
||||
function formatLastModified(ts: string | undefined): string {
|
||||
if (!ts) return "";
|
||||
const date = new Date(Number(ts) * 1000);
|
||||
return date.toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
async function sendMemoryCommand() {
|
||||
const conversationId = get(claudeStore.activeConversationId);
|
||||
if (!conversationId) return;
|
||||
await invoke("send_prompt", { conversationId, message: "/memory" });
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (isOpen && memoryFiles.length === 0) {
|
||||
loadMemoryFiles();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if isOpen}
|
||||
<div class="memory-panel">
|
||||
<div class="panel-header">
|
||||
<div class="header-title">
|
||||
<svg
|
||||
class="header-icon"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
|
||||
/>
|
||||
</svg>
|
||||
<h3>Memory Files</h3>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button onclick={sendMemoryCommand} class="action-btn" title="Send /memory to Claude">
|
||||
<svg
|
||||
class="action-icon"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button onclick={loadMemoryFiles} class="action-btn" title="Refresh">
|
||||
<svg
|
||||
class="action-icon"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="close-btn" onclick={onClose} title="Close">
|
||||
<svg
|
||||
class="close-icon"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-content">
|
||||
{#if isLoading && memoryFiles.length === 0}
|
||||
<div class="loading">
|
||||
<svg
|
||||
class="spinner"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||
/>
|
||||
</svg>
|
||||
Loading memory files...
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="error">
|
||||
<p>{error}</p>
|
||||
<button class="retry-btn" onclick={loadMemoryFiles}>Retry</button>
|
||||
</div>
|
||||
{:else if memoryFiles.length === 0}
|
||||
<div class="empty">
|
||||
<p>No memory files found.</p>
|
||||
<p class="hint">
|
||||
Memory files are created automatically as I learn from our conversations!
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="panel-layout">
|
||||
<div class="file-list">
|
||||
{#each memoryFiles as file (file.path)}
|
||||
<button
|
||||
class="file-item"
|
||||
class:active={selectedFile === file.path}
|
||||
onclick={() => loadFileContent(file.path)}
|
||||
title={getFileName(file.path)}
|
||||
>
|
||||
<svg
|
||||
class="file-icon"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
<div class="file-info">
|
||||
<span class="file-name">{getDisplayName(file)}</span>
|
||||
{#if file.last_modified}
|
||||
<span class="file-date">{formatLastModified(file.last_modified)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="file-viewer">
|
||||
{#if selectedFile && fileContent}
|
||||
<div class="viewer-header">
|
||||
{#each memoryFiles.filter((f) => f.path === selectedFile) as activeFile (activeFile.path)}
|
||||
<h4>{getDisplayName(activeFile)}</h4>
|
||||
{#if activeFile.heading}
|
||||
<p class="viewer-filename">{getFileName(activeFile.path)}</p>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
<div class="viewer-content">
|
||||
<Markdown content={fileContent} />
|
||||
</div>
|
||||
{:else if selectedFile && isLoading}
|
||||
<div class="loading-file">
|
||||
<svg
|
||||
class="spinner"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||
/>
|
||||
</svg>
|
||||
Loading file...
|
||||
</div>
|
||||
{:else}
|
||||
<div class="no-selection">
|
||||
<p>Select a memory file to view its contents</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.memory-panel {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 600px;
|
||||
height: 100vh;
|
||||
background: var(--bg-primary);
|
||||
border-left: 1px solid var(--border-color);
|
||||
box-shadow: -4px 0 12px rgba(0, 0, 0, 0.3);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.header-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.panel-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: 0.5rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.action-icon {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
padding: 0.5rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.close-icon {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
}
|
||||
|
||||
.panel-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.loading,
|
||||
.error,
|
||||
.empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
padding: 3rem 1.5rem;
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.error p {
|
||||
color: var(--terminal-error, #f87171);
|
||||
}
|
||||
|
||||
.retry-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--accent-primary);
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.retry-btn:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 0.875rem;
|
||||
font-style: italic;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.panel-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 200px 1fr;
|
||||
gap: 1.5rem;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.file-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.file-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.file-item:hover {
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.file-item.active {
|
||||
background: var(--accent-primary);
|
||||
border-color: var(--accent-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.file-icon {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.file-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.file-name {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.file-date {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.file-item.active .file-date {
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
}
|
||||
|
||||
.file-viewer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.viewer-header {
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.viewer-header h4 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.viewer-filename {
|
||||
margin: 0.25rem 0 0;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-tertiary);
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.viewer-content {
|
||||
flex: 1;
|
||||
padding: 1.5rem;
|
||||
overflow-y: auto;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.loading-file,
|
||||
.no-selection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
padding: 3rem 1.5rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,98 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
// Mirror pure logic functions from MemoryBrowserPanel.svelte
|
||||
|
||||
interface MemoryFileInfo {
|
||||
path: string;
|
||||
heading: string | null;
|
||||
}
|
||||
|
||||
function getFileName(path: string): string {
|
||||
return path.split("/").pop() || path;
|
||||
}
|
||||
|
||||
function getDisplayName(file: MemoryFileInfo): string {
|
||||
return file.heading ?? getFileName(file.path);
|
||||
}
|
||||
|
||||
describe("getFileName", () => {
|
||||
it("extracts the filename from an absolute Unix path", () => {
|
||||
expect(getFileName("/home/naomi/.claude/projects/foo/memory/MEMORY.md")).toBe("MEMORY.md");
|
||||
});
|
||||
|
||||
it("extracts the filename from a nested path", () => {
|
||||
expect(getFileName("/home/naomi/.claude/projects/foo/memory/debugging.md")).toBe(
|
||||
"debugging.md"
|
||||
);
|
||||
});
|
||||
|
||||
it("returns the path itself when there is no slash", () => {
|
||||
expect(getFileName("MEMORY.md")).toBe("MEMORY.md");
|
||||
});
|
||||
|
||||
it("returns the path when the path ends with a slash (empty filename)", () => {
|
||||
// split('/').pop() returns '' for trailing slash → falls back to full path
|
||||
expect(getFileName("/some/dir/")).toBe("/some/dir/");
|
||||
});
|
||||
|
||||
it("handles single-component paths", () => {
|
||||
expect(getFileName("notes.md")).toBe("notes.md");
|
||||
});
|
||||
|
||||
it("handles Windows-style paths passed as Unix strings", () => {
|
||||
// If the path contains no forward slashes, the whole string is the filename
|
||||
expect(getFileName("C:\\Users\\naomi\\memory\\file.md")).toBe(
|
||||
"C:\\Users\\naomi\\memory\\file.md"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getDisplayName", () => {
|
||||
it("returns the heading when the file has one", () => {
|
||||
const file: MemoryFileInfo = {
|
||||
path: "/home/naomi/.claude/projects/foo/memory/MEMORY.md",
|
||||
heading: "Hikari Desktop - Memory",
|
||||
};
|
||||
expect(getDisplayName(file)).toBe("Hikari Desktop - Memory");
|
||||
});
|
||||
|
||||
it("falls back to the filename when heading is null", () => {
|
||||
const file: MemoryFileInfo = {
|
||||
path: "/home/naomi/.claude/projects/foo/memory/debugging.md",
|
||||
heading: null,
|
||||
};
|
||||
expect(getDisplayName(file)).toBe("debugging.md");
|
||||
});
|
||||
|
||||
it("falls back to the filename when heading is an empty string stored as null", () => {
|
||||
const file: MemoryFileInfo = {
|
||||
path: "/home/naomi/.claude/projects/foo/memory/patterns.md",
|
||||
heading: null,
|
||||
};
|
||||
expect(getDisplayName(file)).toBe("patterns.md");
|
||||
});
|
||||
|
||||
it("returns the heading even when it matches the filename", () => {
|
||||
const file: MemoryFileInfo = {
|
||||
path: "/home/naomi/.claude/projects/foo/memory/MEMORY.md",
|
||||
heading: "MEMORY",
|
||||
};
|
||||
expect(getDisplayName(file)).toBe("MEMORY");
|
||||
});
|
||||
|
||||
it("returns a multi-word heading verbatim", () => {
|
||||
const file: MemoryFileInfo = {
|
||||
path: "/some/path/foo.md",
|
||||
heading: "My Detailed Debugging Notes",
|
||||
};
|
||||
expect(getDisplayName(file)).toBe("My Detailed Debugging Notes");
|
||||
});
|
||||
|
||||
it("falls back gracefully when path has no directory separators", () => {
|
||||
const file: MemoryFileInfo = {
|
||||
path: "lonely-file.md",
|
||||
heading: null,
|
||||
};
|
||||
expect(getDisplayName(file)).toBe("lonely-file.md");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,607 @@
|
||||
<script lang="ts">
|
||||
import { Menu, ScrollText } from "lucide-svelte";
|
||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||
import { achievementProgress } from "$lib/stores/achievements";
|
||||
import { runningAgentCount } from "$lib/stores/agents";
|
||||
import { editorStore } from "$lib/stores/editor";
|
||||
import { configStore } from "$lib/stores/config";
|
||||
import { debugConsoleStore } from "$lib/stores/debugConsole";
|
||||
import { memoryBrowserStore } from "$lib/stores/memoryBrowser";
|
||||
import type { ConnectionStatus } from "$lib/types/messages";
|
||||
import StatsDisplay from "./StatsDisplay.svelte";
|
||||
import AboutPanel from "./AboutPanel.svelte";
|
||||
import HelpPanel from "./HelpPanel.svelte";
|
||||
import KeyboardShortcutsModal from "./KeyboardShortcutsModal.svelte";
|
||||
import SessionHistoryPanel from "./SessionHistoryPanel.svelte";
|
||||
import TodoPanel from "./TodoPanel.svelte";
|
||||
import GitPanel from "./GitPanel.svelte";
|
||||
import ProfilePanel from "./ProfilePanel.svelte";
|
||||
import AgentMonitorPanel from "./AgentMonitorPanel.svelte";
|
||||
import CastPanel from "./CastPanel.svelte";
|
||||
import PluginManagementPanel from "./PluginManagementPanel.svelte";
|
||||
import McpManagementPanel from "./McpManagementPanel.svelte";
|
||||
import ProjectContextPanel from "./ProjectContextPanel.svelte";
|
||||
import PrdPanel from "./PrdPanel.svelte";
|
||||
import ChangelogPanel from "./ChangelogPanel.svelte";
|
||||
import TaskLoopPanel from "./TaskLoopPanel.svelte";
|
||||
import WorkflowPanel from "./WorkflowPanel.svelte";
|
||||
import MemoryBrowserPanel from "./MemoryBrowserPanel.svelte";
|
||||
import { injectTextStore } from "$lib/stores/projectContext";
|
||||
|
||||
const DISCORD_URL = "https://chat.nhcarrigan.com";
|
||||
const DONATE_URL = "https://donate.nhcarrigan.com";
|
||||
|
||||
interface Props {
|
||||
connectionStatus: ConnectionStatus;
|
||||
workingDirectory: string;
|
||||
selectedDirectory: string;
|
||||
isSummarising: boolean;
|
||||
onToggleCompact: () => void;
|
||||
onToggleAchievements: () => void;
|
||||
onCompactConversation: () => Promise<void>;
|
||||
onStartFreshWithContext: () => Promise<void>;
|
||||
}
|
||||
|
||||
const {
|
||||
connectionStatus,
|
||||
workingDirectory,
|
||||
selectedDirectory,
|
||||
isSummarising,
|
||||
onToggleCompact,
|
||||
onToggleAchievements,
|
||||
onCompactConversation,
|
||||
onStartFreshWithContext,
|
||||
}: Props = $props();
|
||||
|
||||
let showMenu = $state(false);
|
||||
let showStats = $state(false);
|
||||
let showAbout = $state(false);
|
||||
let showHelp = $state(false);
|
||||
let showKeyboardShortcuts = $state(false);
|
||||
let showSessionHistory = $state(false);
|
||||
let showTodoPanel = $state(false);
|
||||
let showGitPanel = $state(false);
|
||||
let showProfile = $state(false);
|
||||
let showAgentMonitor = $state(false);
|
||||
let showCastPanel = $state(false);
|
||||
let showPluginPanel = $state(false);
|
||||
let showMcpPanel = $state(false);
|
||||
let showProjectContext = $state(false);
|
||||
let showPrdPanel = $state(false);
|
||||
let showChangelog = $state(false);
|
||||
let showTaskLoop = $state(false);
|
||||
let showWorkflowPanel = $state(false);
|
||||
let showMemoryPanel = $state(false);
|
||||
memoryBrowserStore.subscribe((s) => {
|
||||
showMemoryPanel = s.isOpen;
|
||||
});
|
||||
|
||||
const progress = $derived($achievementProgress);
|
||||
const activeAgentCount = $derived($runningAgentCount);
|
||||
|
||||
let editorVisible = $state(false);
|
||||
editorStore.isEditorVisible.subscribe((value) => {
|
||||
editorVisible = value;
|
||||
});
|
||||
|
||||
export function isFileEditorDisabled(status: ConnectionStatus): boolean {
|
||||
return status !== "connected";
|
||||
}
|
||||
|
||||
function menuAction(action: () => void): () => void {
|
||||
return () => {
|
||||
action();
|
||||
showMenu = false;
|
||||
};
|
||||
}
|
||||
|
||||
function handleInjectContext(content: string): void {
|
||||
injectTextStore.set(content);
|
||||
}
|
||||
|
||||
function handleGlobalHelpShortcut(event: KeyboardEvent): void {
|
||||
const target = event.target as HTMLElement;
|
||||
const isInputFocused = target.tagName === "INPUT" || target.tagName === "TEXTAREA";
|
||||
if (isInputFocused) return;
|
||||
|
||||
if (event.key === "?") {
|
||||
event.preventDefault();
|
||||
showHelp = !showHelp;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleGlobalHelpShortcut} />
|
||||
|
||||
<div class="relative">
|
||||
<button
|
||||
onclick={() => (showMenu = !showMenu)}
|
||||
class="p-1 shrink-0 {showMenu ? 'text-[var(--trans-pink)]' : 'text-gray-500 icon-trans-hover'}"
|
||||
title="Menu"
|
||||
>
|
||||
<Menu class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if showMenu}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="fixed inset-0 z-40" onclick={() => (showMenu = false)}></div>
|
||||
<div
|
||||
class="fixed top-12 right-4 z-50 max-h-96 overflow-y-auto min-w-[13rem] bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-md shadow-lg py-1"
|
||||
>
|
||||
<!-- Profile -->
|
||||
<button onclick={menuAction(() => (showProfile = true))} class="nav-item">
|
||||
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
||||
/>
|
||||
</svg>
|
||||
<span>Profile</span>
|
||||
</button>
|
||||
|
||||
<!-- Compact Mode -->
|
||||
<button onclick={menuAction(onToggleCompact)} class="nav-item">
|
||||
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4"
|
||||
/>
|
||||
</svg>
|
||||
<span>Compact Mode</span>
|
||||
</button>
|
||||
|
||||
<!-- Achievements -->
|
||||
<button onclick={menuAction(onToggleAchievements)} class="nav-item">
|
||||
<span class="text-base w-5 h-5 flex items-center justify-center shrink-0">🏆</span>
|
||||
<span>Achievements</span>
|
||||
{#if progress.unlocked > 0}
|
||||
<span
|
||||
class="ml-auto bg-[var(--accent-primary)] text-white text-xs rounded-full w-4 h-4 flex items-center justify-center text-[10px] shrink-0"
|
||||
>
|
||||
{progress.unlocked}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- Session History -->
|
||||
<button onclick={menuAction(() => (showSessionHistory = true))} class="nav-item">
|
||||
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span>Session History</span>
|
||||
</button>
|
||||
|
||||
<!-- Memory Manager -->
|
||||
<button onclick={menuAction(() => memoryBrowserStore.open())} class="nav-item">
|
||||
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"
|
||||
/>
|
||||
</svg>
|
||||
<span>Memory Manager</span>
|
||||
</button>
|
||||
|
||||
<!-- To-Do List -->
|
||||
<button onclick={menuAction(() => (showTodoPanel = true))} class="nav-item">
|
||||
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"
|
||||
/>
|
||||
</svg>
|
||||
<span>To-Do List</span>
|
||||
</button>
|
||||
|
||||
<!-- Git Panel -->
|
||||
<button onclick={menuAction(() => (showGitPanel = true))} class="nav-item">
|
||||
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"
|
||||
/>
|
||||
</svg>
|
||||
<span>Git Panel</span>
|
||||
</button>
|
||||
|
||||
<!-- Plugins -->
|
||||
<button onclick={menuAction(() => (showPluginPanel = true))} class="nav-item">
|
||||
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"
|
||||
/>
|
||||
</svg>
|
||||
<span>Plugins</span>
|
||||
</button>
|
||||
|
||||
<!-- MCP Servers -->
|
||||
<button onclick={menuAction(() => (showMcpPanel = true))} class="nav-item">
|
||||
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01"
|
||||
/>
|
||||
</svg>
|
||||
<span>MCP Servers</span>
|
||||
</button>
|
||||
|
||||
<!-- Project Context -->
|
||||
<button onclick={menuAction(() => (showProjectContext = true))} class="nav-item">
|
||||
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
|
||||
/>
|
||||
</svg>
|
||||
<span>Project Context</span>
|
||||
</button>
|
||||
|
||||
<!-- PRD Creator -->
|
||||
<button onclick={menuAction(() => (showPrdPanel = true))} class="nav-item">
|
||||
<ScrollText class="w-5 h-5 shrink-0" />
|
||||
<span>PRD Creator</span>
|
||||
</button>
|
||||
|
||||
<!-- Task Loop -->
|
||||
<button onclick={menuAction(() => (showTaskLoop = true))} class="nav-item">
|
||||
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||
/>
|
||||
</svg>
|
||||
<span>Task Loop</span>
|
||||
</button>
|
||||
|
||||
<!-- Guided Workflow -->
|
||||
<button onclick={menuAction(() => (showWorkflowPanel = true))} class="nav-item">
|
||||
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12h18" />
|
||||
</svg>
|
||||
<span>Guided Workflow</span>
|
||||
</button>
|
||||
|
||||
<!-- File Editor -->
|
||||
<button
|
||||
onclick={menuAction(() => editorStore.toggleEditor())}
|
||||
disabled={isFileEditorDisabled(connectionStatus)}
|
||||
class="nav-item {editorVisible
|
||||
? 'text-[var(--trans-pink)]'
|
||||
: ''} disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
title={isFileEditorDisabled(connectionStatus)
|
||||
? "Connect to enable file editor"
|
||||
: "File Editor (Ctrl+E)"}
|
||||
>
|
||||
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
||||
/>
|
||||
</svg>
|
||||
<span>File Editor</span>
|
||||
</button>
|
||||
|
||||
<!-- Meet the Team -->
|
||||
<button onclick={menuAction(() => (showCastPanel = true))} class="nav-item">
|
||||
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span>Meet the Team</span>
|
||||
</button>
|
||||
|
||||
<!-- Agent Monitor -->
|
||||
<button
|
||||
onclick={menuAction(() => (showAgentMonitor = !showAgentMonitor))}
|
||||
class="nav-item {showAgentMonitor ? 'text-[var(--trans-pink)]' : ''}"
|
||||
>
|
||||
<div class="relative shrink-0 w-5 h-5">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"
|
||||
/>
|
||||
</svg>
|
||||
{#if activeAgentCount > 0}
|
||||
<span
|
||||
class="absolute -top-1 -right-1 bg-blue-500 text-white rounded-full w-3.5 h-3.5 flex items-center justify-center text-[9px] animate-pulse"
|
||||
>
|
||||
{activeAgentCount}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<span>Agent Monitor</span>
|
||||
</button>
|
||||
|
||||
<!-- Usage Stats -->
|
||||
<button
|
||||
onclick={menuAction(() => (showStats = !showStats))}
|
||||
class="nav-item {showStats ? 'text-[var(--trans-pink)]' : ''}"
|
||||
>
|
||||
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zM13 19v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2h2a2 2 0 002-2zM21 19V8a2 2 0 00-2-2h-2a2 2 0 00-2 2v11a2 2 0 002 2h2a2 2 0 002-2z"
|
||||
/>
|
||||
</svg>
|
||||
<span>Usage Stats</span>
|
||||
</button>
|
||||
|
||||
<!-- Debug Console -->
|
||||
<button onclick={menuAction(() => debugConsoleStore.toggle())} class="nav-item">
|
||||
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
<span>Debug Console</span>
|
||||
</button>
|
||||
|
||||
<!-- Settings -->
|
||||
<button onclick={menuAction(() => configStore.openSidebar())} class="nav-item">
|
||||
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
|
||||
/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span>Settings</span>
|
||||
</button>
|
||||
|
||||
<!-- Support Us -->
|
||||
<button onclick={menuAction(() => openUrl(DONATE_URL))} class="nav-item">
|
||||
<svg class="w-5 h-5 shrink-0" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"
|
||||
/>
|
||||
</svg>
|
||||
<span>Support Us</span>
|
||||
</button>
|
||||
|
||||
<!-- Changelog -->
|
||||
<button onclick={menuAction(() => (showChangelog = true))} class="nav-item">
|
||||
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2M9 12h6M9 16h4"
|
||||
/>
|
||||
</svg>
|
||||
<span>Changelog</span>
|
||||
</button>
|
||||
|
||||
<!-- About -->
|
||||
<button onclick={menuAction(() => (showAbout = true))} class="nav-item">
|
||||
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span>About</span>
|
||||
</button>
|
||||
|
||||
<!-- Keyboard Shortcuts -->
|
||||
<button onclick={menuAction(() => (showKeyboardShortcuts = true))} class="nav-item">
|
||||
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 3C10.22 3 8.47 3.23 6.86 3.68A2 2 0 005 5.57V18.43a2 2 0 001.86 1.89C8.47 20.77 10.22 21 12 21s3.53-.23 5.14-.68A2 2 0 0019 18.43V5.57a2 2 0 00-1.86-1.89C15.53 3.23 13.78 3 12 3z"
|
||||
/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8 7h.01M12 7h.01M16 7h.01M8 11h.01M12 11h.01M16 11h.01M8 15h8"
|
||||
/>
|
||||
</svg>
|
||||
<span>Keyboard Shortcuts</span>
|
||||
</button>
|
||||
|
||||
<!-- Help -->
|
||||
<button onclick={menuAction(() => (showHelp = true))} class="nav-item">
|
||||
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span>Help</span>
|
||||
</button>
|
||||
|
||||
<!-- Discord -->
|
||||
<button onclick={menuAction(() => openUrl(DISCORD_URL))} class="nav-item">
|
||||
<svg class="w-5 h-5 shrink-0" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path
|
||||
d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"
|
||||
/>
|
||||
</svg>
|
||||
<span>Discord</span>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showStats}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="fixed inset-0 z-40" onclick={() => (showStats = false)}></div>
|
||||
<div class="fixed top-14 right-4 z-50 max-h-[calc(100vh-4rem)] overflow-y-auto">
|
||||
<StatsDisplay
|
||||
onRequestSummary={onCompactConversation}
|
||||
{onStartFreshWithContext}
|
||||
{isSummarising}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showAbout}
|
||||
<AboutPanel onClose={() => (showAbout = false)} />
|
||||
{/if}
|
||||
|
||||
{#if showHelp}
|
||||
<HelpPanel onClose={() => (showHelp = false)} />
|
||||
{/if}
|
||||
|
||||
{#if showKeyboardShortcuts}
|
||||
<KeyboardShortcutsModal onClose={() => (showKeyboardShortcuts = false)} />
|
||||
{/if}
|
||||
|
||||
{#if showSessionHistory}
|
||||
<SessionHistoryPanel onClose={() => (showSessionHistory = false)} />
|
||||
{/if}
|
||||
|
||||
{#if showTodoPanel}
|
||||
<TodoPanel onClose={() => (showTodoPanel = false)} />
|
||||
{/if}
|
||||
|
||||
{#if showGitPanel}
|
||||
<GitPanel isOpen={showGitPanel} onClose={() => (showGitPanel = false)} />
|
||||
{/if}
|
||||
|
||||
{#if showProfile}
|
||||
<ProfilePanel onClose={() => (showProfile = false)} />
|
||||
{/if}
|
||||
|
||||
{#if showAgentMonitor}
|
||||
<AgentMonitorPanel isOpen={showAgentMonitor} onClose={() => (showAgentMonitor = false)} />
|
||||
{/if}
|
||||
|
||||
{#if showCastPanel}
|
||||
<CastPanel onClose={() => (showCastPanel = false)} />
|
||||
{/if}
|
||||
|
||||
{#if showPluginPanel}
|
||||
<PluginManagementPanel onClose={() => (showPluginPanel = false)} />
|
||||
{/if}
|
||||
|
||||
{#if showMcpPanel}
|
||||
<McpManagementPanel onClose={() => (showMcpPanel = false)} />
|
||||
{/if}
|
||||
|
||||
{#if showProjectContext}
|
||||
<ProjectContextPanel
|
||||
onClose={() => (showProjectContext = false)}
|
||||
onInject={handleInjectContext}
|
||||
workingDirectory={workingDirectory || selectedDirectory}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if showPrdPanel}
|
||||
<PrdPanel
|
||||
onClose={() => (showPrdPanel = false)}
|
||||
onBackToWorkflow={() => {
|
||||
showPrdPanel = false;
|
||||
showWorkflowPanel = true;
|
||||
}}
|
||||
workingDirectory={workingDirectory || selectedDirectory}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if showChangelog}
|
||||
<ChangelogPanel onClose={() => (showChangelog = false)} />
|
||||
{/if}
|
||||
|
||||
{#if showTaskLoop}
|
||||
<TaskLoopPanel
|
||||
onClose={() => (showTaskLoop = false)}
|
||||
onBackToWorkflow={() => {
|
||||
showTaskLoop = false;
|
||||
showWorkflowPanel = true;
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if showMemoryPanel}
|
||||
<MemoryBrowserPanel isOpen={showMemoryPanel} onClose={() => memoryBrowserStore.close()} />
|
||||
{/if}
|
||||
|
||||
{#if showWorkflowPanel}
|
||||
<WorkflowPanel
|
||||
onClose={() => (showWorkflowPanel = false)}
|
||||
onOpenPrdPanel={() => {
|
||||
showWorkflowPanel = false;
|
||||
showPrdPanel = true;
|
||||
}}
|
||||
onOpenTaskLoop={() => {
|
||||
showWorkflowPanel = false;
|
||||
showTaskLoop = true;
|
||||
}}
|
||||
workingDirectory={workingDirectory || selectedDirectory}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
color: rgb(209, 213, 219);
|
||||
transition:
|
||||
background-color 0.15s,
|
||||
color 0.15s;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.nav-item:hover:not(:disabled) {
|
||||
background-color: rgba(255, 255, 255, 0.07);
|
||||
color: var(--trans-pink);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* NavMenu Component Tests
|
||||
*
|
||||
* Tests the pure helper function used by NavMenu to determine whether
|
||||
* the File Editor menu item should be disabled based on connection state.
|
||||
*
|
||||
* What this component does:
|
||||
* - Renders a single Menu trigger button in the status bar
|
||||
* - Opens a scrollable dropdown listing all 21 nav items with icon + label
|
||||
* - Clicking any item triggers its action and auto-closes the dropdown
|
||||
* - Clicking outside the dropdown (backdrop) closes it
|
||||
* - Manages panel state for all nav-accessible panels
|
||||
* - Houses the StatsDisplay (Usage Stats) panel
|
||||
*
|
||||
* Manual testing checklist:
|
||||
* - [ ] Single Menu button visible where the icon cluster was
|
||||
* - [ ] Clicking Menu button opens the dropdown
|
||||
* - [ ] Dropdown shows all 21 items with icon + label
|
||||
* - [ ] Clicking any item triggers its action AND closes the dropdown
|
||||
* - [ ] Clicking outside (backdrop) closes the dropdown
|
||||
* - [ ] Dropdown is scrollable when window height is small
|
||||
* - [ ] Achievements item shows unlocked count badge when unlocked > 0
|
||||
* - [ ] Agent Monitor item shows pulsing blue badge when agents are active
|
||||
* - [ ] File Editor item is dimmed and non-interactive when not connected
|
||||
* - [ ] File Editor item works and shows pink when editor is visible
|
||||
* - [ ] Usage Stats panel opens as a fixed overlay after closing menu
|
||||
* - [ ] Discord and Support Us open external URLs
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
type ConnectionStatus = "connected" | "connecting" | "disconnected" | "error";
|
||||
|
||||
function isFileEditorDisabled(connectionStatus: ConnectionStatus): boolean {
|
||||
return connectionStatus !== "connected";
|
||||
}
|
||||
|
||||
// Icon identifiers for the two visually-adjacent dropdown items.
|
||||
// To-Do List uses a custom inline SVG (clipboard-checkmark style).
|
||||
// PRD Creator uses the Lucide ScrollText component — a scroll document.
|
||||
// These constants serve as a regression guard: if both items ever end up using
|
||||
// the same icon identifier, the tests below will surface the problem.
|
||||
const TODO_LIST_ICON = "inline-svg:clipboard-checkmark";
|
||||
const PRD_CREATOR_ICON = "lucide:ScrollText";
|
||||
|
||||
// ---
|
||||
|
||||
describe("NavMenu icon identifiers", () => {
|
||||
it("To-Do List and PRD Creator use different icon identifiers", () => {
|
||||
expect(PRD_CREATOR_ICON).not.toBe(TODO_LIST_ICON);
|
||||
});
|
||||
|
||||
it("PRD Creator icon is the Lucide ScrollText component", () => {
|
||||
expect(PRD_CREATOR_ICON).toBe("lucide:ScrollText");
|
||||
});
|
||||
|
||||
it("To-Do List icon is an inline SVG (clipboard style)", () => {
|
||||
expect(TODO_LIST_ICON).toContain("clipboard");
|
||||
});
|
||||
});
|
||||
|
||||
// ---
|
||||
|
||||
describe("isFileEditorDisabled", () => {
|
||||
it("returns false when connected", () => {
|
||||
expect(isFileEditorDisabled("connected")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when disconnected", () => {
|
||||
expect(isFileEditorDisabled("disconnected")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true when connecting", () => {
|
||||
expect(isFileEditorDisabled("connecting")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true when in error state", () => {
|
||||
expect(isFileEditorDisabled("error")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,22 +1,24 @@
|
||||
<script lang="ts">
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { get } from "svelte/store";
|
||||
import { claudeStore, hasPermissionPending } from "$lib/stores/claude";
|
||||
import { SvelteSet } from "svelte/reactivity";
|
||||
import { claudeStore } from "$lib/stores/claude";
|
||||
import { characterState } from "$lib/stores/character";
|
||||
import type { PermissionRequest } from "$lib/types/messages";
|
||||
import { updateDiscordRpc, setSkipNextGreeting } from "$lib/tauri";
|
||||
import { conversationsStore } from "$lib/stores/conversations";
|
||||
import { configStore } from "$lib/stores/config";
|
||||
|
||||
let isVisible = $state(false);
|
||||
let permission: PermissionRequest | null = $state(null);
|
||||
let grantedToolsList: string[] = $state([]);
|
||||
let workingDirectory = $state("");
|
||||
let permissions: PermissionRequest[] = [];
|
||||
let selectedPermissions = new SvelteSet<string>();
|
||||
let grantedToolsList: string[] = [];
|
||||
let workingDirectory = "";
|
||||
|
||||
hasPermissionPending.subscribe((pending) => {
|
||||
isVisible = pending;
|
||||
});
|
||||
|
||||
claudeStore.pendingPermission.subscribe((perm) => {
|
||||
permission = perm;
|
||||
if (perm) {
|
||||
conversationsStore.pendingPermissions.subscribe((perms) => {
|
||||
permissions = perms;
|
||||
// When new permissions arrive, select all by default
|
||||
if (perms.length > 0) {
|
||||
selectedPermissions = new SvelteSet(perms.map((p) => p.id));
|
||||
characterState.setState("permission");
|
||||
}
|
||||
});
|
||||
@@ -30,66 +32,115 @@
|
||||
});
|
||||
|
||||
async function handleApproveAndReconnect() {
|
||||
if (permission) {
|
||||
// Capture conversation history before clearing/reconnecting
|
||||
const conversationHistory = claudeStore.getConversationHistory();
|
||||
const approvedTool = permission.tool;
|
||||
const toolInput = permission.input;
|
||||
const selectedPerms = permissions.filter((p) => selectedPermissions.has(p.id));
|
||||
|
||||
claudeStore.grantTool(approvedTool);
|
||||
const newGrantedTools = [...grantedToolsList, approvedTool];
|
||||
claudeStore.addLine(
|
||||
"system",
|
||||
`Permission granted for: ${approvedTool}. Reconnecting with context...`
|
||||
);
|
||||
if (selectedPerms.length === 0) {
|
||||
claudeStore.addLine("system", "No permissions selected to approve");
|
||||
claudeStore.clearPermission();
|
||||
characterState.setTemporaryState("idle", 1000);
|
||||
return;
|
||||
}
|
||||
|
||||
// Stop current session and reconnect with new permissions
|
||||
try {
|
||||
const conversationId = get(claudeStore.activeConversationId);
|
||||
if (!conversationId) {
|
||||
throw new Error("No active conversation");
|
||||
}
|
||||
// Capture conversation history before clearing/reconnecting
|
||||
const conversationHistory = claudeStore.getConversationHistory();
|
||||
|
||||
await invoke("stop_claude", { conversationId });
|
||||
// Grant all selected tools
|
||||
const newlyGrantedTools: string[] = [];
|
||||
for (const perm of selectedPerms) {
|
||||
if (!grantedToolsList.includes(perm.tool)) {
|
||||
claudeStore.grantTool(perm.tool);
|
||||
newlyGrantedTools.push(perm.tool);
|
||||
}
|
||||
}
|
||||
|
||||
// Small delay to ensure clean shutdown
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
const newGrantedTools = [...grantedToolsList, ...newlyGrantedTools];
|
||||
const toolNames = selectedPerms.map((p) => p.tool).join(", ");
|
||||
|
||||
await invoke("start_claude", {
|
||||
conversationId,
|
||||
options: {
|
||||
working_dir: workingDirectory || "/home/naomi",
|
||||
allowed_tools: newGrantedTools,
|
||||
},
|
||||
});
|
||||
claudeStore.addLine(
|
||||
"system",
|
||||
`Permission granted for ${selectedPerms.length} tool(s): ${toolNames}. Reconnecting with context...`
|
||||
);
|
||||
claudeStore.clearPermission();
|
||||
|
||||
// Wait for connection to establish
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
// Stop current session and reconnect with new permissions
|
||||
try {
|
||||
const conversationId = get(claudeStore.activeConversationId);
|
||||
if (!conversationId) {
|
||||
throw new Error("No active conversation");
|
||||
}
|
||||
|
||||
// Send conversation context to restore state
|
||||
if (conversationHistory) {
|
||||
const contextMessage = `[CONTEXT RESTORATION]
|
||||
I just granted you permission to use the ${approvedTool} tool. Here's our conversation so far:
|
||||
// Prevent stats reset on reconnection
|
||||
setSkipNextGreeting(true);
|
||||
|
||||
await invoke("stop_claude", { conversationId });
|
||||
|
||||
// Small delay to ensure clean shutdown
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
const config = configStore.getConfig();
|
||||
await invoke("start_claude", {
|
||||
conversationId,
|
||||
options: {
|
||||
working_dir: workingDirectory || "/home/naomi",
|
||||
model: config.model || null,
|
||||
api_key: config.api_key || null,
|
||||
custom_instructions: config.custom_instructions || null,
|
||||
mcp_servers_json: config.mcp_servers_json || null,
|
||||
allowed_tools: [...new Set([...newGrantedTools, ...config.auto_granted_tools])],
|
||||
use_worktree: config.use_worktree ?? false,
|
||||
disable_1m_context: config.disable_1m_context ?? false,
|
||||
disable_cron: config.disable_cron ?? false,
|
||||
disable_skill_shell_execution: config.disable_skill_shell_execution ?? false,
|
||||
include_git_instructions: config.include_git_instructions ?? true,
|
||||
enable_claudeai_mcp_servers: config.enable_claudeai_mcp_servers ?? true,
|
||||
auto_memory_directory: config.auto_memory_directory || null,
|
||||
model_overrides: config.model_overrides || null,
|
||||
session_name: null,
|
||||
bare_mode: config.bare_mode ?? false,
|
||||
show_clear_context_on_plan_accept: config.show_clear_context_on_plan_accept ?? true,
|
||||
custom_model_option: config.custom_model_option || null,
|
||||
},
|
||||
});
|
||||
|
||||
// Update Discord RPC when reconnecting after permission grant
|
||||
const activeConversation = get(conversationsStore.activeConversation);
|
||||
if (activeConversation) {
|
||||
await updateDiscordRpc(
|
||||
activeConversation.name,
|
||||
config.model || "claude",
|
||||
activeConversation.startedAt
|
||||
);
|
||||
}
|
||||
|
||||
// Wait for connection to establish
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
|
||||
// Send conversation context to restore state
|
||||
if (conversationHistory) {
|
||||
const blockedActions = selectedPerms
|
||||
.map((p) => `- ${p.tool} with input:\n${JSON.stringify(p.input, null, 2)}`)
|
||||
.join("\n\n");
|
||||
|
||||
const contextMessage = `[CONTEXT RESTORATION]
|
||||
I just granted you permission to use ${selectedPerms.length} tool(s): ${toolNames}. Here's our conversation so far:
|
||||
|
||||
${conversationHistory}
|
||||
|
||||
The last action that was blocked was: ${approvedTool} with input:
|
||||
${JSON.stringify(toolInput, null, 2)}
|
||||
The actions that were blocked:
|
||||
${blockedActions}
|
||||
|
||||
Please continue where we left off and retry that action now that you have permission.`;
|
||||
Please continue where we left off and retry those actions now that you have permission.`;
|
||||
|
||||
await invoke("send_prompt", {
|
||||
conversationId,
|
||||
message: contextMessage,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to reconnect:", error);
|
||||
claudeStore.addLine("error", `Reconnect failed: ${error}`);
|
||||
await invoke("send_prompt", {
|
||||
conversationId,
|
||||
message: contextMessage,
|
||||
});
|
||||
}
|
||||
characterState.setTemporaryState("success", 2000);
|
||||
} catch (error) {
|
||||
console.error("Failed to reconnect:", error);
|
||||
claudeStore.addLine("error", `Reconnect failed: ${error}`);
|
||||
}
|
||||
characterState.setTemporaryState("success", 2000);
|
||||
}
|
||||
|
||||
function handleDismiss() {
|
||||
@@ -110,8 +161,24 @@ Please continue where we left off and retry that action now that you have permis
|
||||
return grantedToolsList.includes(toolName);
|
||||
}
|
||||
|
||||
function togglePermission(toolRequestId: string) {
|
||||
if (selectedPermissions.has(toolRequestId)) {
|
||||
selectedPermissions.delete(toolRequestId);
|
||||
} else {
|
||||
selectedPermissions.add(toolRequestId);
|
||||
}
|
||||
}
|
||||
|
||||
function selectAll() {
|
||||
selectedPermissions = new SvelteSet(permissions.map((p) => p.id));
|
||||
}
|
||||
|
||||
function selectNone() {
|
||||
selectedPermissions = new SvelteSet();
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (!isVisible || !permission) return;
|
||||
if (permissions.length === 0) return;
|
||||
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
@@ -125,72 +192,126 @@ Please continue where we left off and retry that action now that you have permis
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
{#if isVisible && permission}
|
||||
{#if permissions.length > 0}
|
||||
<div
|
||||
class="permission-overlay fixed inset-0 bg-black/70 flex items-center justify-center z-50 backdrop-blur-sm"
|
||||
class="permission-overlay fixed inset-0 bg-black/70 flex items-center justify-center z-[60] backdrop-blur-sm"
|
||||
>
|
||||
<div
|
||||
class="permission-modal bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-xl p-6 max-w-md w-full mx-4 shadow-2xl"
|
||||
class="permission-modal bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-xl p-6 max-w-2xl w-full mx-4 shadow-2xl max-h-[90vh] overflow-y-auto"
|
||||
>
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<div class="w-10 h-10 rounded-full bg-yellow-500/20 flex items-center justify-center">
|
||||
<span class="text-xl">🔐</span>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-[var(--text-primary)]">Permission Blocked</h2>
|
||||
<p class="text-sm text-[var(--text-secondary)]">Hikari tried to use a restricted tool</p>
|
||||
<div class="flex-1">
|
||||
<h2 class="text-lg font-semibold text-[var(--text-primary)]">
|
||||
{permissions.length === 1
|
||||
? "Permission Required"
|
||||
: `${permissions.length} Permissions Required`}
|
||||
</h2>
|
||||
<p class="text-sm text-[var(--text-secondary)]">
|
||||
Hikari tried to use {permissions.length === 1
|
||||
? "a restricted tool"
|
||||
: "restricted tools"}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-2 text-xs">
|
||||
<button
|
||||
onclick={selectAll}
|
||||
class="px-2 py-1 bg-blue-500/20 hover:bg-blue-500/30 text-blue-400 rounded transition-colors"
|
||||
>
|
||||
Select All
|
||||
</button>
|
||||
<button
|
||||
onclick={selectNone}
|
||||
class="px-2 py-1 bg-gray-500/20 hover:bg-gray-500/30 text-[var(--text-secondary)] rounded transition-colors"
|
||||
>
|
||||
Select None
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4 px-3 py-2 bg-amber-500/10 border border-amber-500/30 rounded-md">
|
||||
<p class="text-sm text-amber-300">
|
||||
This action was automatically blocked. Approve to allow this tool for future requests.
|
||||
{permissions.length === 1
|
||||
? "This action was automatically blocked. Select which permissions to grant."
|
||||
: "These actions were automatically blocked. Select which permissions to grant."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<div class="text-sm text-[var(--text-secondary)] mb-1">Tool</div>
|
||||
<div
|
||||
class="px-3 py-2 bg-[var(--bg-secondary)] rounded-md text-[var(--accent-primary)] font-mono flex items-center justify-between"
|
||||
>
|
||||
<span>{permission.tool}</span>
|
||||
{#if isToolAlreadyGranted(permission.tool)}
|
||||
<span class="text-xs text-green-400 bg-green-500/20 px-2 py-0.5 rounded"
|
||||
>Already Granted</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="space-y-3 mb-6">
|
||||
{#each permissions as perm (perm.id)}
|
||||
<div
|
||||
class="border border-[var(--border-color)] rounded-lg p-4 cursor-pointer transition-colors {selectedPermissions.has(
|
||||
perm.id
|
||||
)
|
||||
? 'bg-green-500/10 border-green-500/30'
|
||||
: 'bg-[var(--bg-secondary)] hover:bg-[var(--bg-secondary)]/80'}"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={() => togglePermission(perm.id)}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
togglePermission(perm.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="mt-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedPermissions.has(perm.id)}
|
||||
onchange={() => togglePermission(perm.id)}
|
||||
class="w-4 h-4 accent-green-500"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span class="text-[var(--accent-primary)] font-mono text-sm font-medium">
|
||||
{perm.tool}
|
||||
</span>
|
||||
{#if isToolAlreadyGranted(perm.tool)}
|
||||
<span class="text-xs text-green-400 bg-green-500/20 px-2 py-0.5 rounded">
|
||||
Already Granted
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="text-sm text-[var(--text-secondary)] mb-2">
|
||||
{perm.description}
|
||||
</div>
|
||||
{#if Object.keys(perm.input).length > 0}
|
||||
<details class="text-xs">
|
||||
<summary
|
||||
class="cursor-pointer text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||
>
|
||||
View details
|
||||
</summary>
|
||||
<pre
|
||||
class="mt-2 px-3 py-2 bg-[var(--bg-terminal)] rounded-md text-[var(--text-primary)] overflow-x-auto max-h-32">{formatInput(
|
||||
perm.input
|
||||
)}</pre>
|
||||
</details>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<div class="text-sm text-[var(--text-secondary)] mb-1">Description</div>
|
||||
<div class="px-3 py-2 bg-[var(--bg-secondary)] rounded-md text-[var(--text-primary)]">
|
||||
{permission.description}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if Object.keys(permission.input).length > 0}
|
||||
<div class="mb-6">
|
||||
<div class="text-sm text-[var(--text-secondary)] mb-1">Details</div>
|
||||
<pre
|
||||
class="px-3 py-2 bg-[var(--bg-terminal)] rounded-md text-[var(--text-primary)] text-xs overflow-x-auto max-h-32">{formatInput(
|
||||
permission.input
|
||||
)}</pre>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
onclick={handleDismiss}
|
||||
class="flex-1 px-4 py-2 bg-gray-500/20 hover:bg-gray-500/30 text-[var(--text-secondary)] rounded-lg transition-colors font-medium"
|
||||
>
|
||||
Dismiss
|
||||
Dismiss All
|
||||
</button>
|
||||
<button
|
||||
onclick={handleApproveAndReconnect}
|
||||
class="flex-1 px-4 py-2 bg-green-500/20 hover:bg-green-500/30 text-green-400 rounded-lg transition-colors font-medium"
|
||||
disabled={selectedPermissions.size === 0}
|
||||
class="flex-1 px-4 py-2 bg-green-500/20 hover:bg-green-500/30 text-green-400 rounded-lg transition-colors font-medium disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Allow & Reconnect
|
||||
Approve Selected ({selectedPermissions.size})
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* PermissionModal Component Tests
|
||||
*
|
||||
* Tests the pure helper functions used by the PermissionModal component.
|
||||
*
|
||||
* What this component does:
|
||||
* - Displays pending permission requests from Claude Code
|
||||
* - Allows the user to approve or dismiss permission requests
|
||||
* - On approval, reconnects Claude with the newly granted tools merged with
|
||||
* `auto_granted_tools` from config (bug fix: issue #198)
|
||||
* - Restores conversation context after reconnecting
|
||||
*
|
||||
* Manual testing checklist:
|
||||
* - [ ] Permission modal appears when Claude requests a tool not in allowed_tools
|
||||
* - [ ] All permissions are pre-selected by default when modal opens
|
||||
* - [ ] "Select All" and "Select None" buttons work correctly
|
||||
* - [ ] "Already Granted" badge appears for tools already in the session grant list
|
||||
* - [ ] Approving permissions reconnects Claude and restores conversation context
|
||||
* - [ ] After reconnecting, auto_granted_tools are still respected (no re-prompting)
|
||||
* - [ ] Dismissing the modal clears pending permissions without reconnecting
|
||||
* - [ ] Enter key approves selected permissions
|
||||
* - [ ] Escape key dismisses the modal
|
||||
* - [ ] Character enters "permission" state when modal appears
|
||||
* - [ ] Input details are shown in a collapsible "View details" section
|
||||
*
|
||||
* Note: The `handleApproveAndReconnect` function cannot be unit tested here
|
||||
* because it depends on Tauri IPC calls (`invoke("stop_claude")`,
|
||||
* `invoke("start_claude")`, `invoke("send_prompt")`). The critical bug fix
|
||||
* (including `auto_granted_tools` in the reconnect's `allowed_tools`) is
|
||||
* covered by the `buildAllowedToolsList` tests below, which replicate the
|
||||
* exact merging logic from the component.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
/**
|
||||
* Replicates the allowed-tools merging logic from PermissionModal's
|
||||
* handleApproveAndReconnect. This is the fix for issue #198: previously,
|
||||
* `auto_granted_tools` were not included when reconnecting, causing them to
|
||||
* be silently dropped and prompting the user again on subsequent requests.
|
||||
*/
|
||||
function buildAllowedToolsList(
|
||||
sessionGrantedTools: string[],
|
||||
newlyGrantedTools: string[],
|
||||
autoGrantedTools: string[]
|
||||
): string[] {
|
||||
return [...new Set([...sessionGrantedTools, ...newlyGrantedTools, ...autoGrantedTools])];
|
||||
}
|
||||
|
||||
/**
|
||||
* Replicates the formatInput helper from PermissionModal, used to display
|
||||
* the tool input JSON in the permission details section.
|
||||
*/
|
||||
function formatInput(input: Record<string, unknown>): string {
|
||||
try {
|
||||
return JSON.stringify(input, null, 2);
|
||||
} catch {
|
||||
return String(input);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replicates the isToolAlreadyGranted helper from PermissionModal.
|
||||
*/
|
||||
function isToolAlreadyGranted(toolName: string, grantedToolsList: string[]): boolean {
|
||||
return grantedToolsList.includes(toolName);
|
||||
}
|
||||
|
||||
// ---
|
||||
|
||||
describe("buildAllowedToolsList", () => {
|
||||
it("merges session-granted, newly-granted, and auto-granted tools", () => {
|
||||
const result = buildAllowedToolsList(["Bash"], ["Glob"], ["Read"]);
|
||||
expect(result).toContain("Bash");
|
||||
expect(result).toContain("Glob");
|
||||
expect(result).toContain("Read");
|
||||
});
|
||||
|
||||
it("deduplicates tools that appear in multiple lists", () => {
|
||||
const result = buildAllowedToolsList(["Read", "Bash"], ["Read"], ["Read", "Write"]);
|
||||
const readCount = result.filter((t) => t === "Read").length;
|
||||
expect(readCount).toBe(1);
|
||||
});
|
||||
|
||||
it("preserves auto_granted_tools even when session list is empty", () => {
|
||||
const result = buildAllowedToolsList([], ["Bash"], ["Read", "Glob"]);
|
||||
expect(result).toContain("Read");
|
||||
expect(result).toContain("Glob");
|
||||
expect(result).toContain("Bash");
|
||||
});
|
||||
|
||||
it("returns only auto_granted_tools when no other grants exist", () => {
|
||||
const result = buildAllowedToolsList([], [], ["Read"]);
|
||||
expect(result).toEqual(["Read"]);
|
||||
});
|
||||
|
||||
it("returns an empty array when all lists are empty", () => {
|
||||
const result = buildAllowedToolsList([], [], []);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("reproduces the bug scenario from issue #198", () => {
|
||||
// Scenario: user has Read in auto_granted_tools.
|
||||
// Session starts correctly with Read allowed.
|
||||
// User approves Bash via permission modal.
|
||||
// Before fix: reconnect only passed [Bash], dropping Read.
|
||||
// After fix: reconnect passes [Bash, Read].
|
||||
const sessionGrantedTools: string[] = []; // no prior session grants
|
||||
const newlyGrantedTools = ["Bash"]; // just approved via modal
|
||||
const autoGrantedTools = ["Read"]; // configured default
|
||||
|
||||
const result = buildAllowedToolsList(sessionGrantedTools, newlyGrantedTools, autoGrantedTools);
|
||||
|
||||
expect(result).toContain("Bash");
|
||||
expect(result).toContain("Read"); // Must be present — this was the bug!
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatInput", () => {
|
||||
it("formats a simple object as pretty-printed JSON", () => {
|
||||
const result = formatInput({ file_path: "/home/naomi/test.ts" });
|
||||
expect(result).toBe(JSON.stringify({ file_path: "/home/naomi/test.ts" }, null, 2));
|
||||
});
|
||||
|
||||
it("formats a nested object correctly", () => {
|
||||
const input = { command: "ls", args: ["-la", "/home"] };
|
||||
const result = formatInput(input);
|
||||
expect(result).toContain('"command": "ls"');
|
||||
expect(result).toContain('"args"');
|
||||
});
|
||||
|
||||
it("formats an empty object as '{}'", () => {
|
||||
const result = formatInput({});
|
||||
expect(result).toBe("{}");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isToolAlreadyGranted", () => {
|
||||
it("returns true when the tool is in the granted list", () => {
|
||||
expect(isToolAlreadyGranted("Read", ["Read", "Bash"])).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when the tool is not in the granted list", () => {
|
||||
expect(isToolAlreadyGranted("Write", ["Read", "Bash"])).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for an empty granted list", () => {
|
||||
expect(isToolAlreadyGranted("Read", [])).toBe(false);
|
||||
});
|
||||
|
||||
it("is case-sensitive", () => {
|
||||
expect(isToolAlreadyGranted("read", ["Read"])).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,432 @@
|
||||
<script lang="ts">
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { onMount } from "svelte";
|
||||
import { Download, Trash2, Power, PowerOff, RefreshCw } from "lucide-svelte";
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface PluginInfo {
|
||||
name: string;
|
||||
version: string;
|
||||
description: string | null;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
interface MarketplaceInfo {
|
||||
name: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
const { onClose }: Props = $props();
|
||||
|
||||
let plugins = $state<PluginInfo[]>([]);
|
||||
let marketplaces = $state<MarketplaceInfo[]>([]);
|
||||
let isLoading = $state(true);
|
||||
let isLoadingMarketplaces = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let newPluginName = $state("");
|
||||
let isInstalling = $state(false);
|
||||
let actionInProgress = $state<string | null>(null);
|
||||
let showMarketplaces = $state(false);
|
||||
let newMarketplaceSource = $state("");
|
||||
let isAddingMarketplace = $state(false);
|
||||
|
||||
async function loadPlugins(): Promise<void> {
|
||||
try {
|
||||
isLoading = true;
|
||||
error = null;
|
||||
plugins = await invoke<PluginInfo[]>("list_plugins");
|
||||
} catch (e) {
|
||||
error = `Failed to load plugins: ${e}`;
|
||||
console.error(error);
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMarketplaces(): Promise<void> {
|
||||
try {
|
||||
isLoadingMarketplaces = true;
|
||||
error = null;
|
||||
marketplaces = await invoke<MarketplaceInfo[]>("list_marketplaces");
|
||||
} catch (e) {
|
||||
error = `Failed to load marketplaces: ${e}`;
|
||||
console.error(error);
|
||||
} finally {
|
||||
isLoadingMarketplaces = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function installPlugin(): Promise<void> {
|
||||
if (!newPluginName.trim()) return;
|
||||
|
||||
try {
|
||||
isInstalling = true;
|
||||
error = null;
|
||||
await invoke("install_plugin", { pluginName: newPluginName.trim() });
|
||||
newPluginName = "";
|
||||
await loadPlugins();
|
||||
} catch (e) {
|
||||
error = `Failed to install plugin: ${e}`;
|
||||
console.error(error);
|
||||
} finally {
|
||||
isInstalling = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function uninstallPlugin(pluginName: string): Promise<void> {
|
||||
try {
|
||||
actionInProgress = pluginName;
|
||||
error = null;
|
||||
await invoke("uninstall_plugin", { pluginName });
|
||||
await loadPlugins();
|
||||
} catch (e) {
|
||||
error = `Failed to uninstall plugin: ${e}`;
|
||||
console.error(error);
|
||||
} finally {
|
||||
actionInProgress = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function togglePlugin(plugin: PluginInfo): Promise<void> {
|
||||
try {
|
||||
actionInProgress = plugin.name;
|
||||
error = null;
|
||||
if (plugin.enabled) {
|
||||
await invoke("disable_plugin", { pluginName: plugin.name });
|
||||
} else {
|
||||
await invoke("enable_plugin", { pluginName: plugin.name });
|
||||
}
|
||||
await loadPlugins();
|
||||
} catch (e) {
|
||||
error = `Failed to ${plugin.enabled ? "disable" : "enable"} plugin: ${e}`;
|
||||
console.error(error);
|
||||
} finally {
|
||||
actionInProgress = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function updatePlugin(pluginName: string): Promise<void> {
|
||||
try {
|
||||
actionInProgress = pluginName;
|
||||
error = null;
|
||||
await invoke("update_plugin", { pluginName });
|
||||
await loadPlugins();
|
||||
} catch (e) {
|
||||
error = `Failed to update plugin: ${e}`;
|
||||
console.error(error);
|
||||
} finally {
|
||||
actionInProgress = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function addMarketplace(): Promise<void> {
|
||||
if (!newMarketplaceSource.trim()) return;
|
||||
|
||||
try {
|
||||
isAddingMarketplace = true;
|
||||
error = null;
|
||||
await invoke("add_marketplace", { source: newMarketplaceSource.trim() });
|
||||
newMarketplaceSource = "";
|
||||
await loadMarketplaces();
|
||||
} catch (e) {
|
||||
error = `Failed to add marketplace: ${e}`;
|
||||
console.error(error);
|
||||
} finally {
|
||||
isAddingMarketplace = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeMarketplace(name: string): Promise<void> {
|
||||
try {
|
||||
actionInProgress = name;
|
||||
error = null;
|
||||
await invoke("remove_marketplace", { name });
|
||||
await loadMarketplaces();
|
||||
} catch (e) {
|
||||
error = `Failed to remove marketplace: ${e}`;
|
||||
console.error(error);
|
||||
} finally {
|
||||
actionInProgress = null;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
loadPlugins();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="fixed top-0 right-0 h-full w-[600px] bg-[var(--bg-primary)] border-l border-[var(--accent-primary)]/30 shadow-2xl flex flex-col z-50"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between p-4 border-b border-[var(--accent-primary)]/30">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="text-[var(--accent-primary)]">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-[var(--text-primary)]">Plugin Management</h2>
|
||||
<p class="text-xs text-[var(--text-secondary)]">
|
||||
{plugins.length} plugin{plugins.length !== 1 ? "s" : ""} installed
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onclick={onClose}
|
||||
class="text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors p-1 rounded-lg hover:bg-[var(--bg-secondary)]"
|
||||
aria-label="Close plugin panel"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Install Plugin Section -->
|
||||
<div class="p-4 border-b border-[var(--border-color)]">
|
||||
<h3 class="text-sm font-medium text-[var(--text-primary)] mb-2">Install New Plugin</h3>
|
||||
<p class="text-xs text-[var(--text-secondary)] mb-3">
|
||||
Enter plugin name (e.g., "macrodata" or "macrodata@macrodata" for specific marketplace)
|
||||
</p>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newPluginName}
|
||||
placeholder="plugin-name or plugin@marketplace"
|
||||
class="flex-1 px-3 py-2 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] text-sm focus:outline-none focus:border-[var(--accent-primary)]"
|
||||
onkeydown={(e) => e.key === "Enter" && installPlugin()}
|
||||
disabled={isInstalling}
|
||||
/>
|
||||
<button
|
||||
onclick={installPlugin}
|
||||
disabled={isInstalling || !newPluginName.trim()}
|
||||
class="px-4 py-2 bg-[var(--accent-primary)] text-white rounded-lg text-sm font-medium hover:opacity-80 disabled:opacity-40 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
>
|
||||
{#if isInstalling}
|
||||
<RefreshCw class="w-4 h-4 animate-spin" />
|
||||
{:else}
|
||||
<Download class="w-4 h-4" />
|
||||
{/if}
|
||||
Install
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Marketplace Management Section -->
|
||||
<div class="p-4 border-b border-[var(--border-color)]">
|
||||
<button
|
||||
onclick={() => {
|
||||
showMarketplaces = !showMarketplaces;
|
||||
if (showMarketplaces && marketplaces.length === 0) {
|
||||
loadMarketplaces();
|
||||
}
|
||||
}}
|
||||
class="w-full text-left flex items-center justify-between text-sm font-medium text-[var(--text-primary)] hover:text-[var(--accent-primary)] transition-colors"
|
||||
>
|
||||
<span>Manage Marketplaces</span>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4 transition-transform"
|
||||
class:rotate-180={showMarketplaces}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{#if showMarketplaces}
|
||||
<div class="mt-3 space-y-3">
|
||||
<!-- Add Marketplace Form -->
|
||||
<div>
|
||||
<p class="text-xs text-[var(--text-secondary)] mb-2">
|
||||
Add a marketplace from GitHub (e.g., "ascorbic/macrodata")
|
||||
</p>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newMarketplaceSource}
|
||||
placeholder="owner/repo"
|
||||
class="flex-1 px-3 py-2 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] text-sm focus:outline-none focus:border-[var(--accent-primary)]"
|
||||
onkeydown={(e) => e.key === "Enter" && addMarketplace()}
|
||||
disabled={isAddingMarketplace}
|
||||
/>
|
||||
<button
|
||||
onclick={addMarketplace}
|
||||
disabled={isAddingMarketplace || !newMarketplaceSource.trim()}
|
||||
class="px-4 py-2 bg-[var(--accent-primary)] text-white rounded-lg text-sm font-medium hover:opacity-80 disabled:opacity-40 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
>
|
||||
{#if isAddingMarketplace}
|
||||
<RefreshCw class="w-4 h-4 animate-spin" />
|
||||
{:else}
|
||||
<Download class="w-4 h-4" />
|
||||
{/if}
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Marketplaces List -->
|
||||
{#if isLoadingMarketplaces}
|
||||
<div class="flex items-center justify-center py-4">
|
||||
<RefreshCw class="w-5 h-5 animate-spin text-[var(--text-secondary)]" />
|
||||
</div>
|
||||
{:else if marketplaces.length > 0}
|
||||
<div class="space-y-2">
|
||||
{#each marketplaces as marketplace (marketplace.name)}
|
||||
<div
|
||||
class="bg-[var(--bg-secondary)]/50 rounded-lg p-3 border border-[var(--border-color)]"
|
||||
>
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<h4 class="font-medium text-[var(--text-primary)]">{marketplace.name}</h4>
|
||||
<p class="text-xs text-[var(--text-secondary)] mt-1">{marketplace.source}</p>
|
||||
</div>
|
||||
<button
|
||||
onclick={() => removeMarketplace(marketplace.name)}
|
||||
disabled={actionInProgress === marketplace.name}
|
||||
class="px-2 py-1 text-red-400 hover:bg-red-500/20 rounded transition-colors disabled:opacity-40"
|
||||
>
|
||||
<Trash2 class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-sm text-[var(--text-secondary)] text-center py-4">
|
||||
No marketplaces configured
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Error Display -->
|
||||
{#if error}
|
||||
<div class="mx-4 mt-4 p-3 bg-red-500/20 border border-red-500/30 rounded-lg">
|
||||
<p class="text-sm text-red-400">{error}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Plugins List -->
|
||||
<div class="flex-1 overflow-y-auto p-4">
|
||||
{#if isLoading}
|
||||
<div class="flex items-center justify-center h-full text-[var(--text-secondary)]">
|
||||
<RefreshCw class="w-8 h-8 animate-spin" />
|
||||
</div>
|
||||
{:else if plugins.length === 0}
|
||||
<div class="flex flex-col items-center justify-center h-full text-[var(--text-secondary)]">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-16 w-16 mb-4 opacity-50"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"
|
||||
/>
|
||||
</svg>
|
||||
<p class="text-center">No plugins installed</p>
|
||||
<p class="text-sm text-center mt-2">Install a plugin using the form above</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
{#each plugins as plugin (plugin.name)}
|
||||
<div
|
||||
class="bg-[var(--bg-secondary)]/50 rounded-lg p-4 border border-[var(--border-color)] hover:border-[var(--accent-primary)]/50 transition-all"
|
||||
>
|
||||
<div class="flex items-start justify-between mb-2">
|
||||
<div class="flex-1">
|
||||
<h4 class="font-medium text-[var(--text-primary)] flex items-center gap-2">
|
||||
{plugin.name}
|
||||
{#if plugin.enabled}
|
||||
<span
|
||||
class="px-2 py-0.5 bg-[var(--success-color)]/20 text-[var(--success-color)] text-xs rounded border border-[var(--success-color)]/30"
|
||||
>
|
||||
Enabled
|
||||
</span>
|
||||
{:else}
|
||||
<span
|
||||
class="px-2 py-0.5 bg-[var(--text-secondary)]/20 text-[var(--text-secondary)] text-xs rounded border border-[var(--border-color)]"
|
||||
>
|
||||
Disabled
|
||||
</span>
|
||||
{/if}
|
||||
</h4>
|
||||
<p class="text-xs text-[var(--text-secondary)] mt-1">v{plugin.version}</p>
|
||||
{#if plugin.description}
|
||||
<p class="text-sm text-[var(--text-secondary)] mt-2">{plugin.description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 mt-3">
|
||||
<button
|
||||
onclick={() => togglePlugin(plugin)}
|
||||
disabled={actionInProgress === plugin.name}
|
||||
class="flex-1 px-3 py-1.5 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-sm text-[var(--text-primary)] hover:border-[var(--accent-primary)]/50 transition-colors disabled:opacity-40 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
{#if plugin.enabled}
|
||||
<PowerOff class="w-4 h-4" />
|
||||
Disable
|
||||
{:else}
|
||||
<Power class="w-4 h-4" />
|
||||
Enable
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onclick={() => updatePlugin(plugin.name)}
|
||||
disabled={actionInProgress === plugin.name}
|
||||
class="px-3 py-1.5 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-sm text-[var(--text-primary)] hover:border-[var(--accent-primary)]/50 transition-colors disabled:opacity-40 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
>
|
||||
<RefreshCw class="w-4 h-4" />
|
||||
Update
|
||||
</button>
|
||||
|
||||
<button
|
||||
onclick={() => uninstallPlugin(plugin.name)}
|
||||
disabled={actionInProgress === plugin.name}
|
||||
class="px-3 py-1.5 bg-red-500/20 border border-red-500/30 rounded text-sm text-red-400 hover:bg-red-500/30 transition-colors disabled:opacity-40 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
>
|
||||
<Trash2 class="w-4 h-4" />
|
||||
Uninstall
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,375 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { get } from "svelte/store";
|
||||
import { prdStore, type PrdTask } from "$lib/stores/prd";
|
||||
import { characterState } from "$lib/stores/character";
|
||||
import { claudeStore } from "$lib/stores/claude";
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
workingDirectory: string;
|
||||
onBackToWorkflow?: () => void;
|
||||
}
|
||||
|
||||
const { onClose, workingDirectory, onBackToWorkflow }: Props = $props();
|
||||
|
||||
const tasks = $derived(prdStore.tasks);
|
||||
const goal = $derived(prdStore.goal);
|
||||
const isGenerating = $derived(prdStore.isGenerating);
|
||||
const isLoaded = $derived(prdStore.isLoaded);
|
||||
const isLoading = $derived(prdStore.isLoading);
|
||||
const isSaving = $derived(prdStore.isSaving);
|
||||
|
||||
let goalInput = $state("");
|
||||
let previousCharacterState = $state<string>("idle");
|
||||
|
||||
onMount(() => {
|
||||
prdStore.loadFromFile(workingDirectory);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if ($isLoaded) {
|
||||
goalInput = $goal;
|
||||
}
|
||||
});
|
||||
|
||||
// Auto-reload hikari-tasks.json when Claude finishes generating it
|
||||
$effect(() => {
|
||||
const currentState = $characterState;
|
||||
if ($isGenerating && previousCharacterState !== "idle" && currentState === "idle") {
|
||||
void prdStore.loadFromFile(workingDirectory).then(() => {
|
||||
prdStore.finishGenerating();
|
||||
});
|
||||
}
|
||||
previousCharacterState = currentState;
|
||||
});
|
||||
|
||||
async function handleGenerate(): Promise<void> {
|
||||
if (!goalInput.trim()) return;
|
||||
const conversationId = get(claudeStore.activeConversationId);
|
||||
if (!conversationId) return;
|
||||
await prdStore.generatePrd(goalInput.trim(), workingDirectory, conversationId);
|
||||
}
|
||||
|
||||
function handleRegenerate(): void {
|
||||
prdStore.reset();
|
||||
goalInput = "";
|
||||
}
|
||||
|
||||
async function handleSave(): Promise<void> {
|
||||
await prdStore.saveToFile(workingDirectory);
|
||||
}
|
||||
|
||||
async function handleExecute(): Promise<void> {
|
||||
const conversationId = get(claudeStore.activeConversationId);
|
||||
if (!conversationId) return;
|
||||
await prdStore.executePrd(workingDirectory, conversationId);
|
||||
onClose();
|
||||
}
|
||||
|
||||
function handleUpdateTask(id: string, field: keyof Omit<PrdTask, "id">, value: string): void {
|
||||
if (field === "priority") {
|
||||
prdStore.updateTask(id, { priority: value as PrdTask["priority"] });
|
||||
} else {
|
||||
prdStore.updateTask(id, { [field]: value });
|
||||
}
|
||||
}
|
||||
|
||||
function priorityColour(priority: PrdTask["priority"]): string {
|
||||
switch (priority) {
|
||||
case "high":
|
||||
return "bg-red-500/20 text-red-400 border-red-500/30";
|
||||
case "medium":
|
||||
return "bg-amber-500/20 text-amber-400 border-amber-500/30";
|
||||
case "low":
|
||||
return "bg-green-500/20 text-green-400 border-green-500/30";
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4"
|
||||
onclick={onClose}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onkeydown={(e) => e.key === "Escape" && onClose()}
|
||||
>
|
||||
<div
|
||||
class="bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg shadow-xl max-w-3xl w-full max-h-[90vh] flex flex-col"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-labelledby="prd-panel-title"
|
||||
tabindex="-1"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between p-6 pb-4 border-b border-[var(--border-color)]">
|
||||
<div class="flex items-center gap-3">
|
||||
<h2 id="prd-panel-title" class="text-xl font-semibold text-[var(--text-primary)]">
|
||||
PRD Creator
|
||||
</h2>
|
||||
{#if $isGenerating}
|
||||
<span class="text-xs text-[var(--text-tertiary)]">Generating tasks...</span>
|
||||
{:else if $isLoading}
|
||||
<span class="text-xs text-[var(--text-tertiary)]">Loading...</span>
|
||||
{:else if $isLoaded}
|
||||
<span
|
||||
class="text-xs px-2 py-0.5 rounded-full bg-green-500/20 text-green-400 border border-green-500/30"
|
||||
>
|
||||
{$tasks.length} task{$tasks.length === 1 ? "" : "s"}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
{#if onBackToWorkflow}
|
||||
<button
|
||||
onclick={onBackToWorkflow}
|
||||
class="px-2 py-1 text-xs text-[var(--text-secondary)] hover:text-[var(--text-primary)] bg-[var(--bg-secondary)] hover:bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded-md transition-colors"
|
||||
>
|
||||
← Workflow
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
onclick={onClose}
|
||||
class="p-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
||||
aria-label="Close"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="flex-1 overflow-y-auto p-4 min-h-0">
|
||||
{#if $isGenerating}
|
||||
<!-- Generating -->
|
||||
<div class="flex flex-col items-center justify-center h-full gap-4 text-center py-16">
|
||||
<div class="text-4xl animate-spin">⚙️</div>
|
||||
<h3 class="text-lg font-semibold text-[var(--text-primary)]">Generating Tasks...</h3>
|
||||
<p class="text-sm text-[var(--text-secondary)]">
|
||||
Claude is breaking down your goal into actionable tasks and writing
|
||||
<span class="font-mono text-xs">hikari-tasks.json</span>. This will auto-reload when
|
||||
complete.
|
||||
</p>
|
||||
</div>
|
||||
{:else if $isLoaded}
|
||||
<!-- Task review -->
|
||||
<div class="flex flex-col gap-3">
|
||||
<div
|
||||
class="text-sm text-[var(--text-secondary)] bg-[var(--bg-secondary)] rounded-lg px-4 py-2 border border-[var(--border-color)]"
|
||||
>
|
||||
<span class="text-[var(--text-tertiary)] font-medium">Goal:</span>
|
||||
{$goal}
|
||||
</div>
|
||||
{#each $tasks as task, index (task.id)}
|
||||
<div
|
||||
class="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg p-4 flex flex-col gap-3"
|
||||
>
|
||||
<!-- Task header row -->
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs text-[var(--text-tertiary)] font-mono w-8 shrink-0"
|
||||
>#{index + 1}</span
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={task.title}
|
||||
oninput={(e) =>
|
||||
handleUpdateTask(task.id, "title", (e.target as HTMLInputElement).value)}
|
||||
class="flex-1 bg-transparent text-sm font-medium text-[var(--text-primary)] border-b border-transparent hover:border-[var(--border-color)] focus:border-[var(--accent-primary)] focus:outline-none transition-colors py-0.5"
|
||||
placeholder="Task title"
|
||||
/>
|
||||
<select
|
||||
value={task.priority}
|
||||
onchange={(e) =>
|
||||
handleUpdateTask(task.id, "priority", (e.target as HTMLSelectElement).value)}
|
||||
class="text-xs px-2 py-1 rounded-full border {priorityColour(
|
||||
task.priority
|
||||
)} bg-transparent cursor-pointer focus:outline-none"
|
||||
>
|
||||
<option value="high">high</option>
|
||||
<option value="medium">medium</option>
|
||||
<option value="low">low</option>
|
||||
</select>
|
||||
<div class="flex items-center gap-1 shrink-0">
|
||||
<button
|
||||
onclick={() => prdStore.moveTaskUp(task.id)}
|
||||
disabled={index === 0}
|
||||
class="p-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)] disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
title="Move up"
|
||||
aria-label="Move task up"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 15l7-7 7 7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => prdStore.moveTaskDown(task.id)}
|
||||
disabled={index === $tasks.length - 1}
|
||||
class="p-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)] disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
title="Move down"
|
||||
aria-label="Move task down"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => prdStore.removeTask(task.id)}
|
||||
class="p-1 text-[var(--text-secondary)] hover:text-red-400 transition-colors"
|
||||
title="Remove task"
|
||||
aria-label="Remove task"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Prompt textarea -->
|
||||
<textarea
|
||||
value={task.prompt}
|
||||
oninput={(e) =>
|
||||
handleUpdateTask(task.id, "prompt", (e.target as HTMLTextAreaElement).value)}
|
||||
rows={3}
|
||||
class="w-full bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg p-3 font-mono text-xs text-[var(--text-secondary)] resize-y focus:outline-none focus:border-[var(--accent-primary)] leading-relaxed"
|
||||
placeholder="Prompt for Claude Code..."
|
||||
></textarea>
|
||||
</div>
|
||||
{/each}
|
||||
{#if $tasks.length === 0}
|
||||
<p class="text-sm text-[var(--text-tertiary)] text-center py-4">
|
||||
No tasks — all removed. Click Regenerate to start over.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Input form -->
|
||||
<div class="flex flex-col gap-4">
|
||||
<div>
|
||||
<label
|
||||
for="prd-goal"
|
||||
class="block text-sm font-medium text-[var(--text-secondary)] mb-2"
|
||||
>
|
||||
What do you want to build?
|
||||
</label>
|
||||
<textarea
|
||||
id="prd-goal"
|
||||
bind:value={goalInput}
|
||||
rows={5}
|
||||
class="w-full bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg p-4 text-sm text-[var(--text-primary)] resize-none focus:outline-none focus:border-[var(--accent-primary)] leading-relaxed"
|
||||
placeholder="Describe the feature or project you want Claude to break down into tasks…"
|
||||
spellcheck="false"
|
||||
></textarea>
|
||||
</div>
|
||||
<p class="text-xs text-[var(--text-tertiary)]">
|
||||
Claude will analyse your goal and write a
|
||||
<span class="font-mono">hikari-tasks.json</span> file with 3–10 actionable tasks, each with
|
||||
a detailed prompt ready to execute.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div
|
||||
class="flex items-center justify-between p-4 pt-2 border-t border-[var(--border-color)] gap-3"
|
||||
>
|
||||
<div class="text-xs text-[var(--text-tertiary)] font-mono">
|
||||
{workingDirectory}/hikari-tasks.json
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
{#if $isLoaded}
|
||||
<button
|
||||
onclick={handleRegenerate}
|
||||
class="px-3 py-1.5 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] bg-[var(--bg-secondary)] hover:bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded-lg transition-colors"
|
||||
>
|
||||
Regenerate
|
||||
</button>
|
||||
<button
|
||||
onclick={handleSave}
|
||||
disabled={$isSaving}
|
||||
class="px-3 py-1.5 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] bg-[var(--bg-secondary)] hover:bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{$isSaving ? "Saving..." : "Save"}
|
||||
</button>
|
||||
<button
|
||||
onclick={handleExecute}
|
||||
disabled={$tasks.length === 0}
|
||||
class="px-4 py-1.5 text-sm btn-trans-gradient rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Execute
|
||||
</button>
|
||||
{:else if !$isGenerating}
|
||||
<button
|
||||
onclick={handleGenerate}
|
||||
disabled={!goalInput.trim()}
|
||||
class="px-4 py-1.5 text-sm btn-trans-gradient rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Generate PRD
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
[role="dialog"] {
|
||||
animation: slideIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95) translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
textarea {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border-color) transparent;
|
||||
}
|
||||
|
||||
textarea::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
textarea::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
textarea::-webkit-scrollbar-thumb {
|
||||
background-color: var(--border-color);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
textarea::-webkit-scrollbar-thumb:hover {
|
||||
background-color: var(--accent-primary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,323 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { get } from "svelte/store";
|
||||
import {
|
||||
projectContextStore,
|
||||
PROJECT_FILE_NAMES,
|
||||
type ProjectFile,
|
||||
} from "$lib/stores/projectContext";
|
||||
import { characterState } from "$lib/stores/character";
|
||||
import { claudeStore } from "$lib/stores/claude";
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
onInject: (content: string) => void;
|
||||
workingDirectory: string;
|
||||
}
|
||||
|
||||
const { onClose, onInject, workingDirectory }: Props = $props();
|
||||
|
||||
const ALL_FILES: ProjectFile[] = ["PROJECT", "REQUIREMENTS", "ROADMAP", "STATE", "CODEBASE"];
|
||||
|
||||
const contents = $derived(projectContextStore.contents);
|
||||
const isLoading = $derived(projectContextStore.isLoading);
|
||||
const isSaving = $derived(projectContextStore.isSaving);
|
||||
const activeFile = $derived(projectContextStore.activeFile);
|
||||
const isMappingCodebase = $derived(projectContextStore.isMappingCodebase);
|
||||
|
||||
let editContent = $state("");
|
||||
let hasUnsavedChanges = $state(false);
|
||||
let previousCharacterState = $state<string>("idle");
|
||||
|
||||
onMount(() => {
|
||||
projectContextStore.loadAll(workingDirectory);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const file = $activeFile;
|
||||
const fileContent = $contents[file];
|
||||
if (file === "CODEBASE") {
|
||||
editContent = fileContent ?? "";
|
||||
} else {
|
||||
editContent = fileContent ?? projectContextStore.getTemplate(file);
|
||||
}
|
||||
hasUnsavedChanges = false;
|
||||
});
|
||||
|
||||
// Auto-reload CODEBASE.md when Claude finishes generating it
|
||||
$effect(() => {
|
||||
const currentState = $characterState;
|
||||
if ($isMappingCodebase && previousCharacterState !== "idle" && currentState === "idle") {
|
||||
projectContextStore.loadFile("CODEBASE", workingDirectory);
|
||||
projectContextStore.finishMapping();
|
||||
}
|
||||
previousCharacterState = currentState;
|
||||
});
|
||||
|
||||
function handleTabSwitch(file: ProjectFile): void {
|
||||
projectContextStore.setActiveFile(file);
|
||||
}
|
||||
|
||||
function handleUseTemplate(): void {
|
||||
editContent = projectContextStore.getTemplate($activeFile);
|
||||
hasUnsavedChanges = true;
|
||||
}
|
||||
|
||||
function handleInject(): void {
|
||||
onInject(editContent);
|
||||
}
|
||||
|
||||
async function handleSave(): Promise<void> {
|
||||
const saved = await projectContextStore.saveFile($activeFile, editContent, workingDirectory);
|
||||
if (saved) {
|
||||
hasUnsavedChanges = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleTextChange(event: Event): void {
|
||||
editContent = (event.target as HTMLTextAreaElement).value;
|
||||
hasUnsavedChanges = true;
|
||||
}
|
||||
|
||||
function fileExists(file: ProjectFile): boolean {
|
||||
return $contents[file] !== null;
|
||||
}
|
||||
|
||||
async function handleMapCodebase(): Promise<void> {
|
||||
const conversationId = get(claudeStore.activeConversationId);
|
||||
if (!conversationId) return;
|
||||
await projectContextStore.mapCodebase(workingDirectory, conversationId);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4"
|
||||
onclick={onClose}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onkeydown={(e) => e.key === "Escape" && onClose()}
|
||||
>
|
||||
<div
|
||||
class="bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] flex flex-col"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-labelledby="project-context-title"
|
||||
tabindex="-1"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between p-6 pb-4 border-b border-[var(--border-color)]">
|
||||
<div class="flex items-center gap-3">
|
||||
<h2 id="project-context-title" class="text-xl font-semibold text-[var(--text-primary)]">
|
||||
Project Context
|
||||
</h2>
|
||||
{#if $activeFile === "CODEBASE"}
|
||||
{#if $isMappingCodebase}
|
||||
<span class="text-xs text-[var(--text-tertiary)]">Mapping codebase...</span>
|
||||
{:else if $isLoading[$activeFile]}
|
||||
<span class="text-xs text-[var(--text-tertiary)]">Loading...</span>
|
||||
{:else if fileExists($activeFile)}
|
||||
<span
|
||||
class="text-xs px-2 py-0.5 rounded-full bg-green-500/20 text-green-400 border border-green-500/30"
|
||||
>
|
||||
✓ File exists
|
||||
</span>
|
||||
{:else}
|
||||
<span
|
||||
class="text-xs px-2 py-0.5 rounded-full bg-amber-500/20 text-amber-400 border border-amber-500/30"
|
||||
>
|
||||
✗ Not generated
|
||||
</span>
|
||||
{/if}
|
||||
{:else if $isLoading[$activeFile]}
|
||||
<span class="text-xs text-[var(--text-tertiary)]">Loading...</span>
|
||||
{:else if fileExists($activeFile)}
|
||||
<span
|
||||
class="text-xs px-2 py-0.5 rounded-full bg-green-500/20 text-green-400 border border-green-500/30"
|
||||
>
|
||||
✓ File exists
|
||||
</span>
|
||||
{:else}
|
||||
<span
|
||||
class="text-xs px-2 py-0.5 rounded-full bg-amber-500/20 text-amber-400 border border-amber-500/30"
|
||||
>
|
||||
✗ Not created
|
||||
</span>
|
||||
{/if}
|
||||
{#if hasUnsavedChanges}
|
||||
<span class="text-xs text-[var(--text-tertiary)] italic">Unsaved changes</span>
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
onclick={onClose}
|
||||
class="p-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
||||
aria-label="Close"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Tab bar -->
|
||||
<div class="flex border-b border-[var(--border-color)] px-4">
|
||||
{#each ALL_FILES as file (file)}
|
||||
<button
|
||||
onclick={() => handleTabSwitch(file)}
|
||||
class="px-4 py-2 text-sm font-medium transition-colors relative {$activeFile === file
|
||||
? 'text-[var(--accent-primary)] border-b-2 border-[var(--accent-primary)] -mb-px'
|
||||
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'}"
|
||||
>
|
||||
{PROJECT_FILE_NAMES[file]}
|
||||
{#if !fileExists(file)}
|
||||
<span class="ml-1 text-amber-500">•</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Editor area -->
|
||||
<div class="flex-1 overflow-hidden p-4 min-h-0">
|
||||
{#if $activeFile === "CODEBASE" && !fileExists("CODEBASE") && !$isMappingCodebase}
|
||||
<!-- CODEBASE not generated yet — show prompt to map -->
|
||||
<div class="flex flex-col items-center justify-center h-full gap-4 text-center">
|
||||
<div class="text-4xl">🗺️</div>
|
||||
<h3 class="text-lg font-semibold text-[var(--text-primary)]">No Codebase Map Yet</h3>
|
||||
<p class="text-sm text-[var(--text-secondary)] max-w-md">
|
||||
Generate a <span class="font-mono text-xs">CODEBASE.md</span> file by asking Claude to analyse
|
||||
this project. Claude will scan the directory structure and create a comprehensive overview
|
||||
of the architecture and key components.
|
||||
</p>
|
||||
<button
|
||||
onclick={handleMapCodebase}
|
||||
class="px-5 py-2 text-sm btn-trans-gradient rounded-lg transition-colors"
|
||||
>
|
||||
Map Codebase
|
||||
</button>
|
||||
</div>
|
||||
{:else if $activeFile === "CODEBASE" && $isMappingCodebase}
|
||||
<!-- Mapping in progress -->
|
||||
<div class="flex flex-col items-center justify-center h-full gap-4 text-center">
|
||||
<div class="text-4xl animate-spin">⚙️</div>
|
||||
<h3 class="text-lg font-semibold text-[var(--text-primary)]">Mapping Codebase...</h3>
|
||||
<p class="text-sm text-[var(--text-secondary)]">
|
||||
Claude is analysing the project and writing <span class="font-mono text-xs"
|
||||
>CODEBASE.md</span
|
||||
>. This will auto-reload when complete.
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<textarea
|
||||
value={editContent}
|
||||
oninput={handleTextChange}
|
||||
readonly={$activeFile === "CODEBASE"}
|
||||
class="w-full h-full min-h-[400px] bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg p-4 font-mono text-sm text-[var(--text-primary)] resize-none focus:outline-none focus:border-[var(--accent-primary)] leading-relaxed {$activeFile ===
|
||||
'CODEBASE'
|
||||
? 'opacity-80 cursor-default'
|
||||
: ''}"
|
||||
placeholder="File content will appear here..."
|
||||
spellcheck="false"
|
||||
></textarea>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div
|
||||
class="flex items-center justify-between p-4 pt-2 border-t border-[var(--border-color)] gap-3"
|
||||
>
|
||||
<div class="text-xs text-[var(--text-tertiary)]">
|
||||
<span class="font-mono">{workingDirectory}/{PROJECT_FILE_NAMES[$activeFile]}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
{#if $activeFile === "CODEBASE"}
|
||||
<button
|
||||
onclick={() => projectContextStore.loadFile("CODEBASE", workingDirectory)}
|
||||
disabled={$isLoading.CODEBASE || $isMappingCodebase}
|
||||
class="px-3 py-1.5 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] bg-[var(--bg-secondary)] hover:bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
<button
|
||||
onclick={handleMapCodebase}
|
||||
disabled={$isMappingCodebase}
|
||||
class="px-4 py-1.5 text-sm btn-trans-gradient rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{#if $isMappingCodebase}
|
||||
Mapping...
|
||||
{:else}
|
||||
{fileExists("CODEBASE") ? "Remap Codebase" : "Map Codebase"}
|
||||
{/if}
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
onclick={handleUseTemplate}
|
||||
class="px-3 py-1.5 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] bg-[var(--bg-secondary)] hover:bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded-lg transition-colors"
|
||||
>
|
||||
Use Template
|
||||
</button>
|
||||
<button
|
||||
onclick={handleInject}
|
||||
class="px-3 py-1.5 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] bg-[var(--bg-secondary)] hover:bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded-lg transition-colors"
|
||||
>
|
||||
Inject into Prompt
|
||||
</button>
|
||||
<button
|
||||
onclick={handleSave}
|
||||
disabled={$isSaving[$activeFile]}
|
||||
class="px-4 py-1.5 text-sm btn-trans-gradient rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{#if $isSaving[$activeFile]}
|
||||
Saving...
|
||||
{:else}
|
||||
Save
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
[role="dialog"] {
|
||||
animation: slideIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95) translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
textarea {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border-color) transparent;
|
||||
}
|
||||
|
||||
textarea::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
textarea::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
textarea::-webkit-scrollbar-thumb {
|
||||
background-color: var(--border-color);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
textarea::-webkit-scrollbar-thumb:hover {
|
||||
background-color: var(--accent-primary);
|
||||
}
|
||||
</style>
|
||||
@@ -15,9 +15,23 @@
|
||||
let showDeleteConfirm = $state<string | null>(null);
|
||||
let showExportMenu = $state<string | null>(null);
|
||||
let isImporting = $state(false);
|
||||
let showClearAllConfirm = $state(false);
|
||||
|
||||
const sessions = $derived(sessionsStore.sessions);
|
||||
const isLoading = $derived(sessionsStore.isLoading);
|
||||
let sessions = $state<SessionListItem[]>([]);
|
||||
let isLoading = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
const unsubSessions = sessionsStore.sessions.subscribe((value) => {
|
||||
sessions = value;
|
||||
});
|
||||
const unsubLoading = sessionsStore.isLoading.subscribe((value) => {
|
||||
isLoading = value;
|
||||
});
|
||||
return () => {
|
||||
unsubSessions();
|
||||
unsubLoading();
|
||||
};
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
sessionsStore.loadSessions();
|
||||
@@ -121,6 +135,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function handleClearAll(): Promise<void> {
|
||||
await sessionsStore.clearAllSessions();
|
||||
showClearAllConfirm = false;
|
||||
}
|
||||
|
||||
function toggleExportMenu(sessionId: string): void {
|
||||
if (showExportMenu === sessionId) {
|
||||
showExportMenu = null;
|
||||
@@ -186,6 +205,22 @@
|
||||
</svg>
|
||||
{isImporting ? "Importing..." : "Import"}
|
||||
</button>
|
||||
<button
|
||||
onclick={() => (showClearAllConfirm = true)}
|
||||
disabled={sessions.length === 0}
|
||||
class="px-3 py-1.5 text-sm font-medium bg-red-500/10 text-red-500 border border-red-500/30 rounded hover:bg-red-500/20 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
title="Clear all sessions"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
Clear All
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
onclick={onClose}
|
||||
@@ -281,11 +316,11 @@
|
||||
</div>
|
||||
|
||||
<div class="overflow-y-auto flex-1">
|
||||
{#if $isLoading}
|
||||
{#if isLoading}
|
||||
<div class="flex items-center justify-center p-8">
|
||||
<div class="text-[var(--text-tertiary)]">Loading sessions...</div>
|
||||
</div>
|
||||
{:else if $sessions.length === 0}
|
||||
{:else if sessions.length === 0}
|
||||
<div class="flex flex-col items-center justify-center p-8 text-center">
|
||||
<svg
|
||||
class="w-16 h-16 text-[var(--text-tertiary)] mb-4"
|
||||
@@ -307,7 +342,7 @@
|
||||
</div>
|
||||
{:else}
|
||||
<div class="divide-y divide-[var(--border-color)]">
|
||||
{#each $sessions as session (session.id)}
|
||||
{#each sessions as session (session.id)}
|
||||
<div class="p-4 hover:bg-[var(--bg-secondary)] transition-colors group">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<button class="flex-1 text-left" onclick={() => handleViewSession(session)}>
|
||||
@@ -428,6 +463,63 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if showClearAllConfirm}
|
||||
<div
|
||||
class="fixed inset-0 bg-black/50 backdrop-blur-sm z-[60] flex items-center justify-center p-4"
|
||||
onclick={() => (showClearAllConfirm = false)}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onkeydown={(e) => e.key === "Escape" && (showClearAllConfirm = false)}
|
||||
>
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<div
|
||||
class="bg-[var(--bg-primary)] border border-red-500/30 rounded-lg shadow-xl max-w-md w-full p-6"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-labelledby="clear-all-title"
|
||||
aria-describedby="clear-all-description"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="flex-shrink-0 text-red-500">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h3 id="clear-all-title" class="text-lg font-semibold text-[var(--text-primary)] mb-2">
|
||||
Clear All Sessions?
|
||||
</h3>
|
||||
<p id="clear-all-description" class="text-[var(--text-secondary)] mb-4">
|
||||
This will permanently delete all {sessions.length} session{sessions.length === 1
|
||||
? ""
|
||||
: "s"}. This action cannot be undone.
|
||||
</p>
|
||||
<div class="flex justify-end gap-3">
|
||||
<button
|
||||
onclick={() => (showClearAllConfirm = false)}
|
||||
class="px-4 py-2 text-sm font-medium bg-[var(--bg-secondary)] text-[var(--text-primary)] border border-[var(--border-color)] rounded hover:bg-[var(--bg-tertiary)] transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onclick={handleClearAll}
|
||||
class="px-4 py-2 text-sm font-medium bg-red-500 text-white rounded hover:bg-red-600 transition-colors"
|
||||
>
|
||||
Clear All Sessions
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
[role="dialog"] {
|
||||
animation: slideIn 0.2s ease-out;
|
||||
|
||||
@@ -23,6 +23,9 @@
|
||||
>
|
||||
<span class="command-name">/{command.name}</span>
|
||||
<span class="command-description">{command.description}</span>
|
||||
{#if command.source === "cli"}
|
||||
<span class="cli-badge">CLI</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -82,5 +85,19 @@
|
||||
.command-description {
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.cli-badge {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
padding: 1px 5px;
|
||||
border-radius: 4px;
|
||||
background: color-mix(in srgb, var(--accent-primary) 15%, transparent);
|
||||
color: var(--accent-primary);
|
||||
border: 1px solid color-mix(in srgb, var(--accent-primary) 30%, transparent);
|
||||
letter-spacing: 0.5px;
|
||||
text-transform: uppercase;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,8 +1,84 @@
|
||||
<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 { 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>
|
||||
|
||||
<div class="stats-display" transition:fade={{ duration: 200 }}>
|
||||
@@ -16,6 +92,120 @@
|
||||
<span class="stat-value">{$formattedStats.messagesSession}</span>
|
||||
</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">
|
||||
<h3>Tokens & Cost</h3>
|
||||
<div class="stat-row">
|
||||
@@ -49,7 +239,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if Object.keys($formattedStats.sessionToolsUsage).length > 0}
|
||||
{#if $formattedStats.sessionToolsFormatted.length > 0}
|
||||
<div class="stats-section">
|
||||
<h3 class="tools-header">
|
||||
<button class="tools-toggle" onclick={() => (showToolsBreakdown = !showToolsBreakdown)}>
|
||||
@@ -59,17 +249,57 @@
|
||||
</h3>
|
||||
{#if showToolsBreakdown}
|
||||
<div class="tools-breakdown">
|
||||
{#each Object.entries($formattedStats.sessionToolsUsage).sort((a, b) => b[1] - a[1]) as [tool, count] (tool)}
|
||||
<div class="stat-row stat-detail">
|
||||
<span class="stat-label">{tool}:</span>
|
||||
<span class="stat-value">{count}</span>
|
||||
{#each $formattedStats.sessionToolsFormatted.sort((a, b) => b.totalTokens - a.totalTokens) as tool (tool.name)}
|
||||
<div class="stat-row stat-detail tool-row">
|
||||
<span class="stat-label">{tool.name}:</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>
|
||||
{/each}
|
||||
<div class="tools-note">* Token estimates based on attribution</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/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">
|
||||
<span class="model-label">Model:</span>
|
||||
<span class="model-value">{$formattedStats.model}</span>
|
||||
@@ -128,6 +358,79 @@
|
||||
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 {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -148,4 +451,220 @@
|
||||
color: var(--text-primary, #e5e7eb);
|
||||
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>
|
||||
|
||||
+317
-266
@@ -9,39 +9,34 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { getVersion } from "@tauri-apps/api/app";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||
import { get } from "svelte/store";
|
||||
import { claudeStore } from "$lib/stores/claude";
|
||||
import { configStore, type HikariConfig, isStreamerMode } from "$lib/stores/config";
|
||||
import { editorStore } from "$lib/stores/editor";
|
||||
import type { ConnectionStatus } from "$lib/types/messages";
|
||||
import { onMount } from "svelte";
|
||||
import StatsDisplay from "./StatsDisplay.svelte";
|
||||
import AboutPanel from "./AboutPanel.svelte";
|
||||
import HelpPanel from "./HelpPanel.svelte";
|
||||
import KeyboardShortcutsModal from "./KeyboardShortcutsModal.svelte";
|
||||
import { achievementProgress } from "$lib/stores/achievements";
|
||||
import SessionHistoryPanel from "./SessionHistoryPanel.svelte";
|
||||
import GitPanel from "./GitPanel.svelte";
|
||||
import ProfilePanel from "./ProfilePanel.svelte";
|
||||
|
||||
const DISCORD_URL = "https://chat.nhcarrigan.com";
|
||||
const DONATE_URL = "https://donate.nhcarrigan.com";
|
||||
import { PROJECT_CONTEXT_SYSTEM_ADDENDUM } from "$lib/stores/projectContext";
|
||||
import { conversationsStore } from "$lib/stores/conversations";
|
||||
import {
|
||||
generateContextInjection,
|
||||
createSummary,
|
||||
sanitizeForJson,
|
||||
} from "$lib/utils/conversationUtils";
|
||||
import { updateDiscordRpc, setSkipNextGreeting } from "$lib/tauri";
|
||||
import WorkspaceTrustModal from "./WorkspaceTrustModal.svelte";
|
||||
import type { WorkspaceHookInfo } from "$lib/types/messages";
|
||||
import NavMenu from "./NavMenu.svelte";
|
||||
import { taskLoopStore } from "$lib/stores/taskLoop";
|
||||
|
||||
let connectionStatus: ConnectionStatus = $state("disconnected");
|
||||
let workingDirectory = $state("");
|
||||
let worktreeInfo: import("$lib/types/worktree").WorktreeInfo | null = $state(null);
|
||||
let selectedDirectory = $state("/home/naomi");
|
||||
let isConnecting = $state(false);
|
||||
let grantedToolsList: string[] = $state([]);
|
||||
let appVersion = $state("");
|
||||
let showStats = $state(false);
|
||||
let showAbout = $state(false);
|
||||
let showHelp = $state(false);
|
||||
let showKeyboardShortcuts = $state(false);
|
||||
let showSessionHistory = $state(false);
|
||||
let showGitPanel = $state(false);
|
||||
let showProfile = $state(false);
|
||||
const progress = $derived($achievementProgress);
|
||||
let isSummarising = $state(false);
|
||||
let showWorkspaceTrust = $state(false);
|
||||
let pendingHookInfo: WorkspaceHookInfo | null = $state(null);
|
||||
let currentConfig: HikariConfig = $state({
|
||||
model: null,
|
||||
api_key: null,
|
||||
@@ -57,7 +52,6 @@
|
||||
update_checks_enabled: true,
|
||||
character_panel_width: null,
|
||||
font_size: 14,
|
||||
minimize_to_tray: false,
|
||||
streamer_mode: false,
|
||||
streamer_hide_paths: false,
|
||||
compact_mode: false,
|
||||
@@ -74,6 +68,35 @@
|
||||
text_secondary: null,
|
||||
border_color: null,
|
||||
},
|
||||
budget_enabled: false,
|
||||
session_token_budget: null,
|
||||
session_cost_budget: null,
|
||||
budget_action: "warn",
|
||||
budget_warning_threshold: 0.8,
|
||||
discord_rpc_enabled: true,
|
||||
show_thinking_blocks: true,
|
||||
use_worktree: false,
|
||||
disable_1m_context: false,
|
||||
max_output_tokens: null,
|
||||
trusted_workspaces: [],
|
||||
background_image_path: null,
|
||||
background_image_opacity: 0.3,
|
||||
custom_font_path: null,
|
||||
custom_font_family: null,
|
||||
custom_ui_font_path: null,
|
||||
custom_ui_font_family: null,
|
||||
task_loop_auto_commit: false,
|
||||
task_loop_commit_prefix: "feat",
|
||||
task_loop_include_summary: false,
|
||||
disable_cron: false,
|
||||
include_git_instructions: true,
|
||||
enable_claudeai_mcp_servers: true,
|
||||
auto_memory_directory: null,
|
||||
model_overrides: null,
|
||||
disable_skill_shell_execution: false,
|
||||
bare_mode: false,
|
||||
show_clear_context_on_plan_accept: true,
|
||||
custom_model_option: null,
|
||||
});
|
||||
|
||||
let streamerModeActive = $state(false);
|
||||
@@ -81,14 +104,13 @@
|
||||
streamerModeActive = value;
|
||||
});
|
||||
|
||||
let editorVisible = $state(false);
|
||||
editorStore.isEditorVisible.subscribe((value) => {
|
||||
editorVisible = value;
|
||||
});
|
||||
|
||||
function toggleEditor() {
|
||||
editorStore.toggleEditor();
|
||||
}
|
||||
const loopStatus = $derived(taskLoopStore.loopStatus);
|
||||
const loopTasks = $derived(taskLoopStore.tasks);
|
||||
const loopCurrentIndex = $derived(taskLoopStore.currentTaskIndex);
|
||||
const loopCompletedCount = $derived(
|
||||
$loopTasks.filter((t) => t.status === "completed" || t.status === "failed").length
|
||||
);
|
||||
const loopTotalCount = $derived($loopTasks.length);
|
||||
|
||||
onMount(async () => {
|
||||
appVersion = await getVersion();
|
||||
@@ -103,6 +125,10 @@
|
||||
workingDirectory = dir;
|
||||
});
|
||||
|
||||
claudeStore.worktreeInfo.subscribe((info) => {
|
||||
worktreeInfo = info;
|
||||
});
|
||||
|
||||
claudeStore.grantedTools.subscribe((tools) => {
|
||||
grantedToolsList = Array.from(tools);
|
||||
});
|
||||
@@ -127,11 +153,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function handleConnect() {
|
||||
if (isConnecting || connectionStatus === "connected") return;
|
||||
|
||||
const targetDir = selectedDirectory || "/home/naomi";
|
||||
|
||||
async function doConnect(targetDir: string) {
|
||||
// Combine session-granted tools with config auto-granted tools
|
||||
const allAllowedTools = [
|
||||
...new Set([...grantedToolsList, ...currentConfig.auto_granted_tools]),
|
||||
@@ -142,23 +164,95 @@
|
||||
if (!conversationId) {
|
||||
throw new Error("No active conversation");
|
||||
}
|
||||
const activeConversationForName = get(conversationsStore.activeConversation);
|
||||
await invoke("start_claude", {
|
||||
conversationId,
|
||||
options: {
|
||||
working_dir: targetDir,
|
||||
model: currentConfig.model || null,
|
||||
api_key: currentConfig.api_key || null,
|
||||
custom_instructions: currentConfig.custom_instructions || null,
|
||||
custom_instructions:
|
||||
(currentConfig.custom_instructions ?? "") + PROJECT_CONTEXT_SYSTEM_ADDENDUM,
|
||||
mcp_servers_json: currentConfig.mcp_servers_json || null,
|
||||
allowed_tools: allAllowedTools,
|
||||
use_worktree: currentConfig.use_worktree ?? false,
|
||||
disable_1m_context: currentConfig.disable_1m_context ?? false,
|
||||
max_output_tokens: currentConfig.max_output_tokens ?? null,
|
||||
disable_cron: currentConfig.disable_cron ?? false,
|
||||
disable_skill_shell_execution: currentConfig.disable_skill_shell_execution ?? false,
|
||||
include_git_instructions: currentConfig.include_git_instructions ?? true,
|
||||
enable_claudeai_mcp_servers: currentConfig.enable_claudeai_mcp_servers ?? true,
|
||||
auto_memory_directory: currentConfig.auto_memory_directory || null,
|
||||
model_overrides: currentConfig.model_overrides || null,
|
||||
session_name: activeConversationForName?.name || null,
|
||||
bare_mode: currentConfig.bare_mode ?? false,
|
||||
show_clear_context_on_plan_accept:
|
||||
currentConfig.show_clear_context_on_plan_accept ?? true,
|
||||
custom_model_option: currentConfig.custom_model_option || null,
|
||||
},
|
||||
});
|
||||
|
||||
// Update Discord RPC when a new session starts
|
||||
const activeConversation = get(conversationsStore.activeConversation);
|
||||
if (activeConversation) {
|
||||
await updateDiscordRpc(
|
||||
activeConversation.name,
|
||||
currentConfig.model || "claude",
|
||||
activeConversation.startedAt
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to start Claude:", error);
|
||||
claudeStore.addLine("error", `Connection failed: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleConnect() {
|
||||
if (isConnecting || connectionStatus === "connected") return;
|
||||
|
||||
const targetDir = selectedDirectory || "/home/naomi";
|
||||
|
||||
if (currentConfig.trusted_workspaces?.includes(targetDir)) {
|
||||
await doConnect(targetDir);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const hookInfo = await invoke<WorkspaceHookInfo>("check_workspace_hooks", {
|
||||
workingDir: targetDir,
|
||||
});
|
||||
|
||||
if (hookInfo.has_concerns) {
|
||||
pendingHookInfo = hookInfo;
|
||||
showWorkspaceTrust = true;
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
// Fail open: if we can't check hooks, proceed with connection
|
||||
console.error("Failed to check workspace hooks:", error);
|
||||
}
|
||||
|
||||
await doConnect(targetDir);
|
||||
}
|
||||
|
||||
async function handleTrustAndConnect() {
|
||||
showWorkspaceTrust = false;
|
||||
const targetDir = selectedDirectory || "/home/naomi";
|
||||
pendingHookInfo = null;
|
||||
const alreadyTrusted = currentConfig.trusted_workspaces?.includes(targetDir) ?? false;
|
||||
if (!alreadyTrusted) {
|
||||
await configStore.updateConfig({
|
||||
trusted_workspaces: [...(currentConfig.trusted_workspaces ?? []), targetDir],
|
||||
});
|
||||
}
|
||||
doConnect(targetDir);
|
||||
}
|
||||
|
||||
function handleCancelConnect() {
|
||||
showWorkspaceTrust = false;
|
||||
pendingHookInfo = null;
|
||||
}
|
||||
|
||||
async function handleDisconnect() {
|
||||
try {
|
||||
const conversationId = get(claudeStore.activeConversationId);
|
||||
@@ -166,6 +260,9 @@
|
||||
throw new Error("No active conversation");
|
||||
}
|
||||
await invoke("stop_claude", { conversationId });
|
||||
|
||||
// Clear granted permissions when user explicitly disconnects
|
||||
claudeStore.revokeAllTools();
|
||||
} catch (error) {
|
||||
console.error("Failed to stop Claude:", error);
|
||||
}
|
||||
@@ -197,8 +294,122 @@
|
||||
}
|
||||
}
|
||||
|
||||
function toggleAchievements() {
|
||||
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
|
||||
// Prevent stats reset on reconnection
|
||||
setSkipNextGreeting(true);
|
||||
|
||||
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 ?? "") + PROJECT_CONTEXT_SYSTEM_ADDENDUM,
|
||||
mcp_servers_json: currentConfig.mcp_servers_json || null,
|
||||
allowed_tools: allAllowedTools,
|
||||
use_worktree: currentConfig.use_worktree ?? false,
|
||||
disable_1m_context: currentConfig.disable_1m_context ?? false,
|
||||
max_output_tokens: currentConfig.max_output_tokens ?? null,
|
||||
disable_cron: currentConfig.disable_cron ?? false,
|
||||
disable_skill_shell_execution: currentConfig.disable_skill_shell_execution ?? false,
|
||||
include_git_instructions: currentConfig.include_git_instructions ?? true,
|
||||
enable_claudeai_mcp_servers: currentConfig.enable_claudeai_mcp_servers ?? true,
|
||||
auto_memory_directory: currentConfig.auto_memory_directory || null,
|
||||
model_overrides: currentConfig.model_overrides || null,
|
||||
session_name: null,
|
||||
bare_mode: currentConfig.bare_mode ?? false,
|
||||
show_clear_context_on_plan_accept:
|
||||
currentConfig.show_clear_context_on_plan_accept ?? true,
|
||||
custom_model_option: currentConfig.custom_model_option || null,
|
||||
},
|
||||
});
|
||||
|
||||
// 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>
|
||||
|
||||
@@ -218,6 +429,22 @@
|
||||
{workingDirectory}
|
||||
</div>
|
||||
{/if}
|
||||
{#if worktreeInfo}
|
||||
<div
|
||||
class="flex items-center gap-1 px-2 py-0.5 rounded-full bg-emerald-500/15 border border-emerald-500/30 text-emerald-400 text-xs"
|
||||
title="Worktree: {worktreeInfo.name} | Base: {worktreeInfo.original_repo_directory}"
|
||||
>
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13 10V3L4 14h7v7l9-11h-7z"
|
||||
/>
|
||||
</svg>
|
||||
{worktreeInfo.branch}
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm text-gray-600">cwd:</span>
|
||||
@@ -240,215 +467,42 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center gap-2">
|
||||
{#if streamerModeActive}
|
||||
<div
|
||||
class="w-2.5 h-2.5 rounded-full bg-red-500 animate-pulse"
|
||||
class="w-2.5 h-2.5 rounded-full bg-red-500 animate-pulse shrink-0"
|
||||
title="Streamer mode active (Ctrl+Shift+S to toggle)"
|
||||
></div>
|
||||
{/if}
|
||||
<button
|
||||
onclick={() => (showProfile = true)}
|
||||
class="p-1 text-gray-500 icon-trans-hover"
|
||||
title="Profile"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onclick={onToggleCompact}
|
||||
class="p-1 text-gray-500 icon-trans-hover"
|
||||
title="Compact Mode (Ctrl+Shift+M)"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onclick={toggleAchievements}
|
||||
class="p-1 text-gray-500 icon-trans-hover relative"
|
||||
title="Achievements"
|
||||
>
|
||||
<span class="text-lg">🏆</span>
|
||||
{#if progress.unlocked > 0}
|
||||
<span
|
||||
class="absolute -top-1 -right-1 bg-[var(--accent-primary)] text-white text-xs rounded-full w-4 h-4 flex items-center justify-center text-[10px]"
|
||||
>
|
||||
{progress.unlocked}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
onclick={() => (showSessionHistory = true)}
|
||||
class="p-1 text-gray-500 icon-trans-hover"
|
||||
title="Session History"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => (showGitPanel = true)}
|
||||
class="p-1 text-gray-500 icon-trans-hover"
|
||||
title="Git Panel"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onclick={toggleEditor}
|
||||
disabled={connectionStatus !== "connected"}
|
||||
class="p-1 text-gray-500 icon-trans-hover {editorVisible
|
||||
? 'text-[var(--trans-pink)]'
|
||||
: ''} disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
title={connectionStatus === "connected"
|
||||
? "File Editor (Ctrl+E)"
|
||||
: "Connect to enable file editor"}
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => (showStats = !showStats)}
|
||||
class="p-1 text-gray-500 icon-trans-hover {showStats ? 'text-[var(--trans-pink)]' : ''}"
|
||||
title="Usage Stats"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zM13 19v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2h2a2 2 0 002-2zM21 19V8a2 2 0 00-2-2h-2a2 2 0 00-2 2v11a2 2 0 002 2h2a2 2 0 002-2z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onclick={configStore.openSidebar}
|
||||
class="p-1 text-gray-500 icon-trans-hover"
|
||||
title="Settings"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
|
||||
/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => openUrl(DONATE_URL)}
|
||||
class="p-1 text-gray-500 icon-trans-hover"
|
||||
title="Support our work"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => (showAbout = true)}
|
||||
class="p-1 text-gray-500 icon-trans-hover"
|
||||
title="About Hikari Desktop"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => (showKeyboardShortcuts = true)}
|
||||
class="p-1 text-gray-500 icon-trans-hover"
|
||||
title="Keyboard Shortcuts"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 3C10.22 3 8.47 3.23 6.86 3.68A2 2 0 005 5.57V18.43a2 2 0 001.86 1.89C8.47 20.77 10.22 21 12 21s3.53-.23 5.14-.68A2 2 0 0019 18.43V5.57a2 2 0 00-1.86-1.89C15.53 3.23 13.78 3 12 3z"
|
||||
/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8 7h.01M12 7h.01M16 7h.01M8 11h.01M12 11h.01M16 11h.01M8 15h8"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => (showHelp = true)}
|
||||
class="p-1 text-gray-500 icon-trans-hover"
|
||||
title="Help"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => openUrl(DISCORD_URL)}
|
||||
class="p-1 text-gray-500 icon-trans-hover"
|
||||
title="Join our Discord"
|
||||
>
|
||||
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path
|
||||
d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{#if $loopStatus === "running" || $loopStatus === "paused"}
|
||||
<span
|
||||
class="text-xs px-2 py-0.5 rounded-full border shrink-0 {$loopStatus === 'running'
|
||||
? 'bg-blue-500/20 text-blue-400 border-blue-500/30 animate-pulse'
|
||||
: 'bg-amber-500/20 text-amber-400 border-amber-500/30'}"
|
||||
title="Task loop {$loopStatus}"
|
||||
>
|
||||
Loop {$loopStatus === "running" ? "▶" : "⏸"}
|
||||
{loopCompletedCount +
|
||||
($loopStatus === "running" && $loopCurrentIndex >= 0 ? 1 : 0)}/{loopTotalCount}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<NavMenu
|
||||
{connectionStatus}
|
||||
{workingDirectory}
|
||||
{selectedDirectory}
|
||||
{isSummarising}
|
||||
{onToggleCompact}
|
||||
{onToggleAchievements}
|
||||
onCompactConversation={handleCompactConversation}
|
||||
onStartFreshWithContext={handleStartFreshWithContext}
|
||||
/>
|
||||
|
||||
{#if appVersion}
|
||||
<span class="text-xs text-gray-600">v{appVersion}</span>
|
||||
{/if}
|
||||
|
||||
{#if showStats}
|
||||
<div class="absolute top-full right-0 mt-2 mr-4 z-50">
|
||||
<StatsDisplay />
|
||||
</div>
|
||||
{/if}
|
||||
{#if connectionStatus === "connected"}
|
||||
<button
|
||||
onclick={handleDisconnect}
|
||||
@@ -468,35 +522,32 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if showStats}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="fixed inset-0 z-40" onclick={() => (showStats = false)}></div>
|
||||
<div class="fixed top-14 right-4 z-50">
|
||||
<StatsDisplay />
|
||||
</div>
|
||||
{#if showWorkspaceTrust && pendingHookInfo}
|
||||
<WorkspaceTrustModal
|
||||
hookInfo={pendingHookInfo}
|
||||
onTrust={handleTrustAndConnect}
|
||||
onCancel={handleCancelConnect}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if showAbout}
|
||||
<AboutPanel onClose={() => (showAbout = false)} />
|
||||
{/if}
|
||||
<style>
|
||||
/* Responsive status bar styling */
|
||||
.status-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
{#if showHelp}
|
||||
<HelpPanel onClose={() => (showHelp = false)} />
|
||||
{/if}
|
||||
/* Make all buttons shrink but not grow */
|
||||
.status-bar button {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
{#if showKeyboardShortcuts}
|
||||
<KeyboardShortcutsModal onClose={() => (showKeyboardShortcuts = false)} />
|
||||
{/if}
|
||||
|
||||
{#if showSessionHistory}
|
||||
<SessionHistoryPanel onClose={() => (showSessionHistory = false)} />
|
||||
{/if}
|
||||
|
||||
{#if showGitPanel}
|
||||
<GitPanel isOpen={showGitPanel} onClose={() => (showGitPanel = false)} />
|
||||
{/if}
|
||||
|
||||
{#if showProfile}
|
||||
<ProfilePanel onClose={() => (showProfile = false)} />
|
||||
{/if}
|
||||
/* Stack left and right sections on very small screens */
|
||||
@media (max-width: 768px) {
|
||||
.status-bar {
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* StatusBar Component Tests
|
||||
*
|
||||
* Tests the connection status colour and text helpers used by the
|
||||
* StatusBar component to display the current Claude connection state.
|
||||
*
|
||||
* What this component does:
|
||||
* - Shows a coloured indicator dot for the connection state
|
||||
* - Shows a text label for the connection state
|
||||
* - Provides connect/disconnect buttons
|
||||
* - Contains the working directory input and browse button
|
||||
* - Renders the NavMenu component for all toolbar actions
|
||||
*
|
||||
* Manual testing checklist:
|
||||
* - [ ] Green dot and "Connected" label when Claude is running
|
||||
* - [ ] Animated yellow dot and "Connecting..." label whilst connecting
|
||||
* - [ ] Red dot and "Error" label on connection error
|
||||
* - [ ] Grey dot and "Disconnected" label when not connected
|
||||
* - [ ] Directory input is hidden when connected, visible when disconnected
|
||||
* - [ ] Connect button transitions to Disconnect button on connection
|
||||
* - [ ] NavMenu trigger button visible in the status bar
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
type ConnectionStatus = "connected" | "connecting" | "disconnected" | "error";
|
||||
|
||||
function getStatusColor(connectionStatus: ConnectionStatus): string {
|
||||
switch (connectionStatus) {
|
||||
case "connected":
|
||||
return "bg-green-500";
|
||||
case "connecting":
|
||||
return "bg-yellow-500 animate-pulse";
|
||||
case "error":
|
||||
return "bg-red-500";
|
||||
default:
|
||||
return "bg-gray-500";
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusText(connectionStatus: ConnectionStatus): string {
|
||||
switch (connectionStatus) {
|
||||
case "connected":
|
||||
return "Connected";
|
||||
case "connecting":
|
||||
return "Connecting...";
|
||||
case "error":
|
||||
return "Error";
|
||||
default:
|
||||
return "Disconnected";
|
||||
}
|
||||
}
|
||||
|
||||
// ---
|
||||
|
||||
describe("getStatusColor", () => {
|
||||
it("returns green for connected status", () => {
|
||||
expect(getStatusColor("connected")).toBe("bg-green-500");
|
||||
});
|
||||
|
||||
it("returns animated yellow for connecting status", () => {
|
||||
expect(getStatusColor("connecting")).toBe("bg-yellow-500 animate-pulse");
|
||||
});
|
||||
|
||||
it("returns red for error status", () => {
|
||||
expect(getStatusColor("error")).toBe("bg-red-500");
|
||||
});
|
||||
|
||||
it("returns grey for disconnected status", () => {
|
||||
expect(getStatusColor("disconnected")).toBe("bg-gray-500");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getStatusText", () => {
|
||||
it("returns 'Connected' for connected status", () => {
|
||||
expect(getStatusText("connected")).toBe("Connected");
|
||||
});
|
||||
|
||||
it("returns 'Connecting...' for connecting status", () => {
|
||||
expect(getStatusText("connecting")).toBe("Connecting...");
|
||||
});
|
||||
|
||||
it("returns 'Error' for error status", () => {
|
||||
expect(getStatusText("error")).toBe("Error");
|
||||
});
|
||||
|
||||
it("returns 'Disconnected' for disconnected status", () => {
|
||||
expect(getStatusText("disconnected")).toBe("Disconnected");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
<script lang="ts">
|
||||
let currentTime = $state("");
|
||||
|
||||
function updateTime() {
|
||||
const now = new Date();
|
||||
|
||||
// Format date as "1 January 2026"
|
||||
const day = now.getDate();
|
||||
const month = now.toLocaleString("en-GB", { month: "long" });
|
||||
const year = now.getFullYear();
|
||||
|
||||
// Format time as HH:MM:SS (24-hour)
|
||||
const hours = String(now.getHours()).padStart(2, "0");
|
||||
const minutes = String(now.getMinutes()).padStart(2, "0");
|
||||
const seconds = String(now.getSeconds()).padStart(2, "0");
|
||||
|
||||
currentTime = `${day} ${month} ${year}, ${hours}:${minutes}:${seconds}`;
|
||||
}
|
||||
|
||||
// Update immediately on mount
|
||||
updateTime();
|
||||
|
||||
// Update every second
|
||||
const interval = setInterval(updateTime, 1000);
|
||||
|
||||
// Cleanup on component destroy
|
||||
$effect(() => {
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="system-clock">
|
||||
<svg
|
||||
class="clock-icon"
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<polyline points="12 6 12 12 16 14" />
|
||||
</svg>
|
||||
<span class="clock-text">{currentTime}</span>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.system-clock {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 12px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.85rem;
|
||||
font-family: var(--font-mono, monospace);
|
||||
transition: all 0.2s;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.system-clock:hover {
|
||||
border-color: var(--accent-primary);
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.clock-icon {
|
||||
flex-shrink: 0;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.clock-text {
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* SystemClock Component Tests
|
||||
*
|
||||
* Note: This file tests the time formatting logic used by the SystemClock component.
|
||||
* Full component rendering tests are challenging with Svelte 5 + @testing-library/svelte
|
||||
* due to SSR/CSR compatibility issues. The component itself is simple and visually
|
||||
* testable - it displays the current date and time, updating every second.
|
||||
*
|
||||
* What this component does:
|
||||
* - Displays date in British format: "7 February 2026"
|
||||
* - Displays time in 24-hour format: "14:35:42"
|
||||
* - Updates every second via setInterval
|
||||
* - Cleans up interval on unmount via $effect
|
||||
*
|
||||
* Manual testing checklist:
|
||||
* - [ ] Clock appears above the Send button
|
||||
* - [ ] Time updates every second
|
||||
* - [ ] Date format is "DD Month YYYY"
|
||||
* - [ ] Time format is "HH:MM:SS" (24-hour)
|
||||
* - [ ] Hover effect works (border turns accent colour)
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
// Helper function that mirrors the component's formatting logic
|
||||
function formatDateTime(date: Date): string {
|
||||
const day = date.getDate();
|
||||
const month = date.toLocaleString("en-GB", { month: "long" });
|
||||
const year = date.getFullYear();
|
||||
|
||||
const hours = String(date.getHours()).padStart(2, "0");
|
||||
const minutes = String(date.getMinutes()).padStart(2, "0");
|
||||
const seconds = String(date.getSeconds()).padStart(2, "0");
|
||||
|
||||
return `${day} ${month} ${year}, ${hours}:${minutes}:${seconds}`;
|
||||
}
|
||||
|
||||
describe("SystemClock date/time formatting", () => {
|
||||
it("formats date in British format (DD Month YYYY)", () => {
|
||||
// Use local timezone (not UTC) since the component uses local time
|
||||
const date = new Date(2026, 1, 7, 14, 35, 42); // Feb 7, 2026 14:35:42 local
|
||||
const formatted = formatDateTime(date);
|
||||
|
||||
expect(formatted).toContain("7 February 2026");
|
||||
});
|
||||
|
||||
it("formats time in 24-hour format (HH:MM:SS)", () => {
|
||||
const date = new Date(2026, 1, 7, 14, 35, 42);
|
||||
const formatted = formatDateTime(date);
|
||||
|
||||
// Should have the pattern HH:MM:SS
|
||||
expect(formatted).toMatch(/\d{2}:\d{2}:\d{2}/);
|
||||
expect(formatted).toContain("14:35:42");
|
||||
});
|
||||
|
||||
it("combines date and time with comma separator", () => {
|
||||
const date = new Date(2026, 1, 7, 14, 35, 42);
|
||||
const formatted = formatDateTime(date);
|
||||
|
||||
expect(formatted).toBe("7 February 2026, 14:35:42");
|
||||
});
|
||||
|
||||
it("pads single-digit hours, minutes, and seconds with zeros", () => {
|
||||
const date = new Date(2026, 1, 7, 3, 5, 8);
|
||||
const formatted = formatDateTime(date);
|
||||
|
||||
// Should have leading zeros: 03:05:08, not 3:5:8
|
||||
expect(formatted).toContain("03:05:08");
|
||||
});
|
||||
|
||||
it("handles different months correctly", () => {
|
||||
const date = new Date(2026, 11, 25, 12, 0, 0); // December is month 11
|
||||
const formatted = formatDateTime(date);
|
||||
|
||||
expect(formatted).toContain("25 December 2026");
|
||||
});
|
||||
|
||||
it("handles year changes correctly", () => {
|
||||
const date = new Date(2027, 0, 1, 0, 0, 0); // January is month 0
|
||||
const formatted = formatDateTime(date);
|
||||
|
||||
expect(formatted).toContain("1 January 2027");
|
||||
expect(formatted).toContain("00:00:00");
|
||||
});
|
||||
|
||||
it("handles midnight correctly", () => {
|
||||
const date = new Date(2026, 1, 7, 0, 0, 0);
|
||||
const formatted = formatDateTime(date);
|
||||
|
||||
expect(formatted).toContain("00:00:00");
|
||||
});
|
||||
|
||||
it("handles noon correctly", () => {
|
||||
const date = new Date(2026, 1, 7, 12, 0, 0);
|
||||
const formatted = formatDateTime(date);
|
||||
|
||||
// 24-hour format, so noon is 12:00:00, not 00:00:00
|
||||
expect(formatted).toContain("12:00:00");
|
||||
});
|
||||
|
||||
it("handles end of day correctly", () => {
|
||||
const date = new Date(2026, 1, 7, 23, 59, 59);
|
||||
const formatted = formatDateTime(date);
|
||||
|
||||
expect(formatted).toContain("23:59:59");
|
||||
});
|
||||
|
||||
it("handles month boundaries correctly", () => {
|
||||
// Last day of January
|
||||
const jan31 = new Date(2026, 0, 31, 23, 59, 59);
|
||||
expect(formatDateTime(jan31)).toContain("31 January 2026");
|
||||
|
||||
// First day of February
|
||||
const feb1 = new Date(2026, 1, 1, 0, 0, 0);
|
||||
expect(formatDateTime(feb1)).toContain("1 February 2026");
|
||||
});
|
||||
|
||||
it("handles leap year February correctly", () => {
|
||||
// 2024 is a leap year
|
||||
const feb29 = new Date(2024, 1, 29, 12, 0, 0);
|
||||
const formatted = formatDateTime(feb29);
|
||||
|
||||
expect(formatted).toContain("29 February 2024");
|
||||
});
|
||||
|
||||
it("handles all 12 months correctly", () => {
|
||||
const months = [
|
||||
"January",
|
||||
"February",
|
||||
"March",
|
||||
"April",
|
||||
"May",
|
||||
"June",
|
||||
"July",
|
||||
"August",
|
||||
"September",
|
||||
"October",
|
||||
"November",
|
||||
"December",
|
||||
];
|
||||
|
||||
months.forEach((month, index) => {
|
||||
const date = new Date(2026, index, 15, 12, 0, 0);
|
||||
const formatted = formatDateTime(date);
|
||||
|
||||
expect(formatted).toContain(month);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,790 @@
|
||||
<script lang="ts">
|
||||
import { get } from "svelte/store";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import {
|
||||
taskLoopStore,
|
||||
getReadyTasks,
|
||||
computeWaves,
|
||||
isTaskBlocked,
|
||||
buildTaskPrompt,
|
||||
buildAutoCommitPrompt,
|
||||
normalizeToUnixPath,
|
||||
type TaskLoopTask,
|
||||
} from "$lib/stores/taskLoop";
|
||||
import { claudeStore } from "$lib/stores/claude";
|
||||
import { configStore } from "$lib/stores/config";
|
||||
import { PROJECT_CONTEXT_SYSTEM_ADDENDUM } from "$lib/stores/projectContext";
|
||||
import type { CharacterState } from "$lib/types/states";
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
onBackToWorkflow?: () => void;
|
||||
}
|
||||
|
||||
const { onClose, onBackToWorkflow }: Props = $props();
|
||||
|
||||
const tasks = $derived(taskLoopStore.tasks);
|
||||
const loopStatus = $derived(taskLoopStore.loopStatus);
|
||||
const sourceFile = $derived(taskLoopStore.sourceFile);
|
||||
const conversations = $derived(claudeStore.conversations);
|
||||
const concurrencyLimit = $derived(taskLoopStore.concurrencyLimit);
|
||||
const config = $derived(configStore.config);
|
||||
|
||||
// Per-task orchestration phases (panel-local, not persisted)
|
||||
type LoopPhase = "waiting_for_connection" | "waiting_for_completion" | "waiting_for_auto_commit";
|
||||
let activePhases = $state<Record<number, LoopPhase>>({});
|
||||
let taskEverStartedMap = $state<Record<number, boolean>>({});
|
||||
let commitEverStartedMap = $state<Record<number, boolean>>({});
|
||||
let isLoading = $state(false);
|
||||
let errorMessage = $state<string | null>(null);
|
||||
let sessionTimestamp = $state("");
|
||||
let showSettings = $state(false);
|
||||
|
||||
const completedCount = $derived($tasks.filter((t) => t.status === "completed").length);
|
||||
const failedCount = $derived($tasks.filter((t) => t.status === "failed").length);
|
||||
const blockedCount = $derived($tasks.filter((t) => t.status === "blocked").length);
|
||||
const runningCount = $derived($tasks.filter((t) => t.status === "running").length);
|
||||
const totalCount = $derived($tasks.length);
|
||||
const waves = $derived(computeWaves($tasks));
|
||||
const multiWave = $derived(waves.length > 1);
|
||||
|
||||
const workingStates: CharacterState[] = ["thinking", "typing", "coding", "searching", "mcp"];
|
||||
|
||||
// Watch all active tasks' conversations for state transitions
|
||||
$effect(() => {
|
||||
for (const [idxStr, phase] of Object.entries(activePhases)) {
|
||||
const taskIdx = Number(idxStr);
|
||||
const taskList = $tasks;
|
||||
if (taskIdx < 0 || taskIdx >= taskList.length) continue;
|
||||
|
||||
const currentTask = taskList[taskIdx];
|
||||
if (!currentTask.conversationId) continue;
|
||||
|
||||
const conv = $conversations.get(currentTask.conversationId);
|
||||
if (!conv) continue;
|
||||
|
||||
if (phase === "waiting_for_connection" && conv.connectionStatus === "connected") {
|
||||
activePhases = { ...activePhases, [taskIdx]: "waiting_for_completion" };
|
||||
taskEverStartedMap = { ...taskEverStartedMap, [taskIdx]: false };
|
||||
void sendTaskPrompt(currentTask, taskIdx, taskList.length);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (phase === "waiting_for_completion") {
|
||||
if (workingStates.includes(conv.characterState)) {
|
||||
taskEverStartedMap = { ...taskEverStartedMap, [taskIdx]: true };
|
||||
}
|
||||
if (taskEverStartedMap[taskIdx] && conv.characterState === "idle") {
|
||||
taskEverStartedMap = Object.fromEntries(
|
||||
Object.entries(taskEverStartedMap).filter(([k]) => Number(k) !== taskIdx)
|
||||
);
|
||||
|
||||
const autoCommit = get(configStore.config).task_loop_auto_commit;
|
||||
if (autoCommit) {
|
||||
activePhases = { ...activePhases, [taskIdx]: "waiting_for_auto_commit" };
|
||||
commitEverStartedMap = { ...commitEverStartedMap, [taskIdx]: false };
|
||||
void sendAutoCommitPrompt(currentTask, taskIdx);
|
||||
} else {
|
||||
activePhases = Object.fromEntries(
|
||||
Object.entries(activePhases).filter(([k]) => Number(k) !== taskIdx)
|
||||
);
|
||||
void onTaskCompleted(taskIdx, "completed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (phase === "waiting_for_auto_commit") {
|
||||
if (workingStates.includes(conv.characterState)) {
|
||||
commitEverStartedMap = { ...commitEverStartedMap, [taskIdx]: true };
|
||||
}
|
||||
if (commitEverStartedMap[taskIdx] && conv.characterState === "idle") {
|
||||
activePhases = Object.fromEntries(
|
||||
Object.entries(activePhases).filter(([k]) => Number(k) !== taskIdx)
|
||||
);
|
||||
commitEverStartedMap = Object.fromEntries(
|
||||
Object.entries(commitEverStartedMap).filter(([k]) => Number(k) !== taskIdx)
|
||||
);
|
||||
void onTaskCompleted(taskIdx, "completed");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
async function sendTaskPrompt(task: TaskLoopTask, taskIdx: number, total: number): Promise<void> {
|
||||
const prompt = buildTaskPrompt(task, taskIdx + 1, total);
|
||||
try {
|
||||
await invoke("send_prompt", {
|
||||
conversationId: task.conversationId,
|
||||
message: prompt,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to send task prompt:", error);
|
||||
activePhases = Object.fromEntries(
|
||||
Object.entries(activePhases).filter(([k]) => Number(k) !== taskIdx)
|
||||
);
|
||||
void onTaskCompleted(taskIdx, "failed");
|
||||
}
|
||||
}
|
||||
|
||||
async function sendAutoCommitPrompt(task: TaskLoopTask, taskIdx: number): Promise<void> {
|
||||
const cfg = get(configStore.config);
|
||||
const prompt = buildAutoCommitPrompt(
|
||||
task,
|
||||
cfg.task_loop_commit_prefix || "feat",
|
||||
cfg.task_loop_include_summary,
|
||||
sessionTimestamp
|
||||
);
|
||||
try {
|
||||
await invoke("send_prompt", {
|
||||
conversationId: task.conversationId,
|
||||
message: prompt,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to send auto-commit prompt:", error);
|
||||
// Non-blocking: still mark task as completed even if commit prompt fails
|
||||
activePhases = Object.fromEntries(
|
||||
Object.entries(activePhases).filter(([k]) => Number(k) !== taskIdx)
|
||||
);
|
||||
void onTaskCompleted(taskIdx, "completed");
|
||||
}
|
||||
}
|
||||
|
||||
async function onTaskCompleted(taskIdx: number, status: "completed" | "failed"): Promise<void> {
|
||||
taskLoopStore.setTaskStatus(taskIdx, status);
|
||||
|
||||
const currentLoopStatus = get(taskLoopStore.loopStatus);
|
||||
if (currentLoopStatus !== "running") return;
|
||||
|
||||
// If any tasks are still active, wait for them
|
||||
if (Object.keys(activePhases).length > 0) return;
|
||||
|
||||
await advanceToNextWave();
|
||||
}
|
||||
|
||||
async function advanceToNextWave(): Promise<void> {
|
||||
const currentLoopStatus = get(taskLoopStore.loopStatus);
|
||||
if (currentLoopStatus !== "running") return;
|
||||
|
||||
// Mark any newly-blocked tasks
|
||||
const taskList = get(taskLoopStore.tasks);
|
||||
taskList.forEach((task, i) => {
|
||||
if (task.status === "pending" && isTaskBlocked(task, taskList)) {
|
||||
taskLoopStore.setTaskStatus(i, "blocked");
|
||||
}
|
||||
});
|
||||
|
||||
const updatedTaskList = get(taskLoopStore.tasks);
|
||||
const limit = get(taskLoopStore.concurrencyLimit);
|
||||
const readyIndices = getReadyTasks(updatedTaskList, limit);
|
||||
|
||||
if (readyIndices.length === 0) {
|
||||
taskLoopStore.setLoopStatus("stopped");
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all(readyIndices.map((i) => startTask(i, updatedTaskList)));
|
||||
}
|
||||
|
||||
async function startTask(taskIdx: number, taskList: TaskLoopTask[]): Promise<void> {
|
||||
const task = taskList[taskIdx];
|
||||
const cfg = get(configStore.config);
|
||||
const allAllowedTools = [
|
||||
...new Set([...get(claudeStore.grantedTools), ...(cfg.auto_granted_tools ?? [])]),
|
||||
];
|
||||
|
||||
const filePath = get(taskLoopStore.sourceFile);
|
||||
const workingDir = filePath.split("/").slice(0, -1).join("/");
|
||||
|
||||
const conversationId = claudeStore.createConversation(task.title);
|
||||
void claudeStore.switchConversation(conversationId);
|
||||
|
||||
taskLoopStore.setTaskConversationId(taskIdx, conversationId);
|
||||
taskLoopStore.setTaskStatus(taskIdx, "running");
|
||||
|
||||
activePhases = { ...activePhases, [taskIdx]: "waiting_for_connection" };
|
||||
taskEverStartedMap = { ...taskEverStartedMap, [taskIdx]: false };
|
||||
|
||||
try {
|
||||
await invoke("start_claude", {
|
||||
conversationId,
|
||||
options: {
|
||||
working_dir: workingDir,
|
||||
model: cfg.model ?? null,
|
||||
api_key: cfg.api_key ?? null,
|
||||
custom_instructions: (cfg.custom_instructions ?? "") + PROJECT_CONTEXT_SYSTEM_ADDENDUM,
|
||||
mcp_servers_json: cfg.mcp_servers_json ?? null,
|
||||
allowed_tools: allAllowedTools,
|
||||
use_worktree: cfg.use_worktree ?? false,
|
||||
disable_1m_context: cfg.disable_1m_context ?? false,
|
||||
max_output_tokens: cfg.max_output_tokens ?? null,
|
||||
disable_cron: cfg.disable_cron ?? false,
|
||||
disable_skill_shell_execution: cfg.disable_skill_shell_execution ?? false,
|
||||
include_git_instructions: cfg.include_git_instructions ?? true,
|
||||
enable_claudeai_mcp_servers: cfg.enable_claudeai_mcp_servers ?? true,
|
||||
auto_memory_directory: cfg.auto_memory_directory || null,
|
||||
model_overrides: cfg.model_overrides || null,
|
||||
session_name: null,
|
||||
bare_mode: cfg.bare_mode ?? false,
|
||||
show_clear_context_on_plan_accept: cfg.show_clear_context_on_plan_accept ?? true,
|
||||
custom_model_option: cfg.custom_model_option || null,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to start Claude for task:", error);
|
||||
activePhases = Object.fromEntries(
|
||||
Object.entries(activePhases).filter(([k]) => Number(k) !== taskIdx)
|
||||
);
|
||||
void onTaskCompleted(taskIdx, "failed");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleImportFile(): Promise<void> {
|
||||
const selected = await open({
|
||||
title: "Select hikari-tasks.json",
|
||||
filters: [{ name: "Hikari Tasks", extensions: ["json"] }],
|
||||
multiple: false,
|
||||
});
|
||||
if (!selected || typeof selected !== "string") return;
|
||||
|
||||
isLoading = true;
|
||||
errorMessage = null;
|
||||
try {
|
||||
await taskLoopStore.loadFile(normalizeToUnixPath(selected));
|
||||
} catch (error) {
|
||||
errorMessage = `Failed to load file: ${error instanceof Error ? error.message : String(error)}`;
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStart(): Promise<void> {
|
||||
const taskList = get(taskLoopStore.tasks);
|
||||
const limit = get(taskLoopStore.concurrencyLimit);
|
||||
const readyIndices = getReadyTasks(taskList, limit);
|
||||
if (readyIndices.length === 0) return;
|
||||
|
||||
sessionTimestamp = new Date().toISOString();
|
||||
taskLoopStore.setLoopStatus("running");
|
||||
await Promise.all(readyIndices.map((i) => startTask(i, taskList)));
|
||||
}
|
||||
|
||||
function handlePause(): void {
|
||||
taskLoopStore.setLoopStatus("paused");
|
||||
}
|
||||
|
||||
async function handleResume(): Promise<void> {
|
||||
taskLoopStore.setLoopStatus("running");
|
||||
if (Object.keys(activePhases).length === 0) {
|
||||
await advanceToNextWave();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStop(): Promise<void> {
|
||||
taskLoopStore.setLoopStatus("stopped");
|
||||
|
||||
// Stop all active Claude processes
|
||||
const taskList = get(taskLoopStore.tasks);
|
||||
const stopPromises = Object.keys(activePhases).map(async (idxStr) => {
|
||||
const taskIdx = Number(idxStr);
|
||||
const task = taskList[taskIdx];
|
||||
if (task?.conversationId) {
|
||||
try {
|
||||
await invoke("stop_claude", { conversationId: task.conversationId });
|
||||
} catch (error) {
|
||||
console.error("Failed to stop Claude for task:", error);
|
||||
}
|
||||
if (task.status === "running") {
|
||||
taskLoopStore.setTaskStatus(taskIdx, "failed");
|
||||
}
|
||||
}
|
||||
});
|
||||
await Promise.all(stopPromises);
|
||||
|
||||
activePhases = {};
|
||||
taskEverStartedMap = {};
|
||||
commitEverStartedMap = {};
|
||||
}
|
||||
|
||||
function handleReset(): void {
|
||||
taskLoopStore.reset();
|
||||
activePhases = {};
|
||||
taskEverStartedMap = {};
|
||||
commitEverStartedMap = {};
|
||||
errorMessage = null;
|
||||
sessionTimestamp = "";
|
||||
}
|
||||
|
||||
function statusColour(status: TaskLoopTask["status"]): string {
|
||||
switch (status) {
|
||||
case "pending":
|
||||
return "text-[var(--text-tertiary)]";
|
||||
case "running":
|
||||
return "text-blue-400";
|
||||
case "completed":
|
||||
return "text-green-400";
|
||||
case "failed":
|
||||
return "text-red-400";
|
||||
case "blocked":
|
||||
return "text-[var(--text-tertiary)] opacity-50";
|
||||
}
|
||||
}
|
||||
|
||||
function statusIcon(status: TaskLoopTask["status"]): string {
|
||||
switch (status) {
|
||||
case "pending":
|
||||
return "○";
|
||||
case "running":
|
||||
return "⟳";
|
||||
case "completed":
|
||||
return "✓";
|
||||
case "failed":
|
||||
return "✗";
|
||||
case "blocked":
|
||||
return "⊘";
|
||||
}
|
||||
}
|
||||
|
||||
function priorityColour(priority: TaskLoopTask["priority"]): string {
|
||||
switch (priority) {
|
||||
case "high":
|
||||
return "bg-red-500/20 text-red-400 border-red-500/30";
|
||||
case "medium":
|
||||
return "bg-amber-500/20 text-amber-400 border-amber-500/30";
|
||||
case "low":
|
||||
return "bg-green-500/20 text-green-400 border-green-500/30";
|
||||
}
|
||||
}
|
||||
|
||||
const hasPendingTasks = $derived($tasks.some((t) => t.status === "pending"));
|
||||
|
||||
async function toggleAutoCommit(): Promise<void> {
|
||||
await configStore.updateConfig({ task_loop_auto_commit: !$config.task_loop_auto_commit });
|
||||
}
|
||||
|
||||
async function toggleIncludeSummary(): Promise<void> {
|
||||
await configStore.updateConfig({
|
||||
task_loop_include_summary: !$config.task_loop_include_summary,
|
||||
});
|
||||
}
|
||||
|
||||
async function updateCommitPrefix(value: string): Promise<void> {
|
||||
await configStore.updateConfig({ task_loop_commit_prefix: value });
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4"
|
||||
onclick={onClose}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onkeydown={(e) => e.key === "Escape" && onClose()}
|
||||
>
|
||||
<div
|
||||
class="bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] flex flex-col"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-labelledby="task-loop-panel-title"
|
||||
tabindex="-1"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between p-6 pb-4 border-b border-[var(--border-color)]">
|
||||
<div class="flex items-center gap-3">
|
||||
<h2 id="task-loop-panel-title" class="text-xl font-semibold text-[var(--text-primary)]">
|
||||
Task Loop
|
||||
</h2>
|
||||
{#if $loopStatus === "running"}
|
||||
<span
|
||||
class="text-xs px-2 py-0.5 rounded-full bg-blue-500/20 text-blue-400 border border-blue-500/30 animate-pulse"
|
||||
>
|
||||
{runningCount} running · {completedCount}/{totalCount} done
|
||||
</span>
|
||||
{:else if $loopStatus === "paused"}
|
||||
<span
|
||||
class="text-xs px-2 py-0.5 rounded-full bg-amber-500/20 text-amber-400 border border-amber-500/30"
|
||||
>
|
||||
Paused
|
||||
</span>
|
||||
{:else if $loopStatus === "stopped" && totalCount > 0}
|
||||
<span
|
||||
class="text-xs px-2 py-0.5 rounded-full bg-[var(--bg-secondary)] text-[var(--text-tertiary)] border border-[var(--border-color)]"
|
||||
>
|
||||
{completedCount}/{totalCount} completed{failedCount > 0
|
||||
? `, ${failedCount} failed`
|
||||
: ""}{blockedCount > 0 ? `, ${blockedCount} blocked` : ""}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
{#if onBackToWorkflow}
|
||||
<button
|
||||
onclick={onBackToWorkflow}
|
||||
class="px-2 py-1 text-xs text-[var(--text-secondary)] hover:text-[var(--text-primary)] bg-[var(--bg-secondary)] hover:bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded-md transition-colors"
|
||||
>
|
||||
← Workflow
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
onclick={() => (showSettings = !showSettings)}
|
||||
class="p-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
||||
aria-label="Toggle settings"
|
||||
aria-pressed={showSettings}
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
|
||||
/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onclick={onClose}
|
||||
class="p-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
||||
aria-label="Close"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Settings panel (collapsible) -->
|
||||
{#if showSettings}
|
||||
<div
|
||||
class="px-6 py-4 border-b border-[var(--border-color)] bg-[var(--bg-secondary)] flex flex-col gap-3"
|
||||
>
|
||||
<p class="text-xs font-semibold text-[var(--text-tertiary)] uppercase tracking-wide">
|
||||
Auto-commit Settings
|
||||
</p>
|
||||
|
||||
<!-- Auto-commit toggle -->
|
||||
<label class="flex items-center gap-3 cursor-pointer">
|
||||
<div
|
||||
class="relative w-9 h-5 rounded-full transition-colors {$config.task_loop_auto_commit
|
||||
? 'bg-[var(--accent-primary)]'
|
||||
: 'bg-[var(--bg-tertiary)] border border-[var(--border-color)]'}"
|
||||
>
|
||||
<div
|
||||
class="absolute top-0.5 w-4 h-4 rounded-full bg-white shadow transition-transform {$config.task_loop_auto_commit
|
||||
? 'left-4'
|
||||
: 'left-0.5'}"
|
||||
></div>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="sr-only"
|
||||
checked={$config.task_loop_auto_commit}
|
||||
onchange={toggleAutoCommit}
|
||||
/>
|
||||
</div>
|
||||
<span class="text-sm text-[var(--text-primary)]">Auto-commit on task completion</span>
|
||||
</label>
|
||||
|
||||
{#if $config.task_loop_auto_commit}
|
||||
<!-- Commit prefix -->
|
||||
<div class="flex items-center gap-3">
|
||||
<label
|
||||
class="text-sm text-[var(--text-secondary)] shrink-0 w-28"
|
||||
for="commit-prefix-input"
|
||||
>
|
||||
Commit prefix
|
||||
</label>
|
||||
<input
|
||||
id="commit-prefix-input"
|
||||
type="text"
|
||||
value={$config.task_loop_commit_prefix}
|
||||
onchange={(e) => updateCommitPrefix((e.target as HTMLInputElement).value)}
|
||||
placeholder="feat"
|
||||
class="flex-1 px-2 py-1 text-sm bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent-primary)]"
|
||||
/>
|
||||
<span class="text-xs text-[var(--text-tertiary)]">: task title</span>
|
||||
</div>
|
||||
|
||||
<!-- Include SUMMARY.md toggle -->
|
||||
<label class="flex items-center gap-3 cursor-pointer">
|
||||
<div
|
||||
class="relative w-9 h-5 rounded-full transition-colors {$config.task_loop_include_summary
|
||||
? 'bg-[var(--accent-primary)]'
|
||||
: 'bg-[var(--bg-tertiary)] border border-[var(--border-color)]'}"
|
||||
>
|
||||
<div
|
||||
class="absolute top-0.5 w-4 h-4 rounded-full bg-white shadow transition-transform {$config.task_loop_include_summary
|
||||
? 'left-4'
|
||||
: 'left-0.5'}"
|
||||
></div>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="sr-only"
|
||||
checked={$config.task_loop_include_summary}
|
||||
onchange={toggleIncludeSummary}
|
||||
/>
|
||||
</div>
|
||||
<span class="text-sm text-[var(--text-primary)]">Generate SUMMARY.md before commit</span
|
||||
>
|
||||
</label>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Body -->
|
||||
<div class="flex-1 overflow-y-auto p-4 min-h-0">
|
||||
{#if isLoading}
|
||||
<div class="flex items-center justify-center py-16 gap-3 text-[var(--text-secondary)]">
|
||||
<div class="animate-spin text-2xl">⚙️</div>
|
||||
<span class="text-sm">Loading tasks...</span>
|
||||
</div>
|
||||
{:else if errorMessage}
|
||||
<div class="flex flex-col items-center justify-center py-16 gap-4 text-center">
|
||||
<p class="text-sm text-red-400">{errorMessage}</p>
|
||||
<button
|
||||
onclick={handleImportFile}
|
||||
class="px-4 py-1.5 text-sm btn-trans-gradient rounded-lg transition-colors"
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
{:else if totalCount === 0}
|
||||
<!-- Empty state -->
|
||||
<div class="flex flex-col items-center justify-center py-16 gap-4 text-center">
|
||||
<div class="text-4xl">📋</div>
|
||||
<h3 class="text-lg font-semibold text-[var(--text-primary)]">No Tasks Loaded</h3>
|
||||
<p class="text-sm text-[var(--text-secondary)] max-w-sm">
|
||||
Import a <span class="font-mono text-xs">hikari-tasks.json</span> file created by the PRD
|
||||
Creator to run tasks automatically.
|
||||
</p>
|
||||
<button
|
||||
onclick={handleImportFile}
|
||||
class="px-4 py-1.5 text-sm btn-trans-gradient rounded-lg transition-colors"
|
||||
>
|
||||
Import hikari-tasks.json
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Source file path -->
|
||||
<div class="text-xs text-[var(--text-tertiary)] font-mono mb-3 truncate">
|
||||
{$sourceFile}
|
||||
</div>
|
||||
|
||||
<!-- Wave-grouped task list -->
|
||||
<div class="flex flex-col gap-4">
|
||||
{#each waves as waveIndices, waveIdx (waveIdx)}
|
||||
<div>
|
||||
{#if multiWave}
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span
|
||||
class="text-xs font-semibold text-[var(--text-tertiary)] uppercase tracking-wide"
|
||||
>
|
||||
Wave {waveIdx + 1}
|
||||
</span>
|
||||
{#if waveIndices.length > 1}
|
||||
<span class="text-xs text-[var(--text-tertiary)]">
|
||||
({waveIndices.length} parallel)
|
||||
</span>
|
||||
{/if}
|
||||
<div class="flex-1 border-t border-[var(--border-color)]"></div>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex flex-col gap-2">
|
||||
{#each waveIndices as taskIdx (taskIdx)}
|
||||
{@const task = $tasks[taskIdx]}
|
||||
{#if task}
|
||||
<div
|
||||
class="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg p-3 flex items-start gap-3 {task.status ===
|
||||
'running'
|
||||
? 'border-blue-500/40 bg-blue-500/5'
|
||||
: task.status === 'blocked'
|
||||
? 'opacity-50'
|
||||
: ''}"
|
||||
>
|
||||
<!-- Status icon -->
|
||||
<span
|
||||
class="text-sm font-mono mt-0.5 w-4 text-center shrink-0 {statusColour(
|
||||
task.status
|
||||
)} {task.status === 'running' ? 'animate-spin' : ''}"
|
||||
>
|
||||
{statusIcon(task.status)}
|
||||
</span>
|
||||
<!-- Task info -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<span class="text-sm font-medium text-[var(--text-primary)] truncate">
|
||||
{task.title}
|
||||
</span>
|
||||
<span
|
||||
class="text-xs px-1.5 py-0.5 rounded-full border shrink-0 {priorityColour(
|
||||
task.priority
|
||||
)}"
|
||||
>
|
||||
{task.priority}
|
||||
</span>
|
||||
{#if task.status === "running"}
|
||||
{#if activePhases[taskIdx] === "waiting_for_auto_commit"}
|
||||
<span class="text-xs text-violet-400 animate-pulse shrink-0"
|
||||
>● committing</span
|
||||
>
|
||||
{:else}
|
||||
<span class="text-xs text-blue-400 animate-pulse shrink-0"
|
||||
>● running</span
|
||||
>
|
||||
{/if}
|
||||
{:else if task.status === "blocked"}
|
||||
<span class="text-xs text-[var(--text-tertiary)] shrink-0">blocked</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
<p
|
||||
class="text-xs text-[var(--text-tertiary)] mt-0.5 line-clamp-2 font-mono"
|
||||
>
|
||||
{task.prompt}
|
||||
</p>
|
||||
</div>
|
||||
<!-- Task number -->
|
||||
<span class="text-xs text-[var(--text-tertiary)] font-mono shrink-0"
|
||||
>#{taskIdx + 1}</span
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div
|
||||
class="flex items-center justify-between p-4 pt-2 border-t border-[var(--border-color)] gap-3"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
{#if totalCount > 0 && $loopStatus === "idle"}
|
||||
<button
|
||||
onclick={handleImportFile}
|
||||
class="px-3 py-1.5 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] bg-[var(--bg-secondary)] hover:bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded-lg transition-colors"
|
||||
>
|
||||
Change File
|
||||
</button>
|
||||
<button
|
||||
onclick={handleReset}
|
||||
class="px-3 py-1.5 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] bg-[var(--bg-secondary)] hover:bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded-lg transition-colors"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
{:else if $loopStatus === "stopped"}
|
||||
<button
|
||||
onclick={handleReset}
|
||||
class="px-3 py-1.5 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] bg-[var(--bg-secondary)] hover:bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded-lg transition-colors"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- Concurrency limit control -->
|
||||
{#if totalCount > 0}
|
||||
<div class="flex items-center gap-1 ml-2">
|
||||
<span class="text-xs text-[var(--text-tertiary)]">Parallel:</span>
|
||||
<button
|
||||
onclick={() => taskLoopStore.setConcurrencyLimit($concurrencyLimit - 1)}
|
||||
class="w-5 h-5 flex items-center justify-center text-xs text-[var(--text-secondary)] hover:text-[var(--text-primary)] bg-[var(--bg-secondary)] hover:bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded transition-colors"
|
||||
aria-label="Decrease concurrency limit"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<span class="text-xs font-mono text-[var(--text-primary)] w-4 text-center"
|
||||
>{$concurrencyLimit}</span
|
||||
>
|
||||
<button
|
||||
onclick={() => taskLoopStore.setConcurrencyLimit($concurrencyLimit + 1)}
|
||||
class="w-5 h-5 flex items-center justify-center text-xs text-[var(--text-secondary)] hover:text-[var(--text-primary)] bg-[var(--bg-secondary)] hover:bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded transition-colors"
|
||||
aria-label="Increase concurrency limit"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
{#if totalCount === 0}
|
||||
<!-- no actions until tasks are loaded -->
|
||||
{:else if $loopStatus === "idle" || $loopStatus === "stopped"}
|
||||
{#if hasPendingTasks}
|
||||
<button
|
||||
onclick={handleStart}
|
||||
class="px-4 py-1.5 text-sm btn-trans-gradient rounded-lg transition-colors"
|
||||
>
|
||||
Start Loop
|
||||
</button>
|
||||
{:else}
|
||||
<span class="text-xs text-[var(--text-tertiary)]">All tasks complete</span>
|
||||
{/if}
|
||||
{:else if $loopStatus === "running"}
|
||||
<button
|
||||
onclick={handlePause}
|
||||
class="px-3 py-1.5 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] bg-[var(--bg-secondary)] hover:bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded-lg transition-colors"
|
||||
>
|
||||
Pause
|
||||
</button>
|
||||
<button
|
||||
onclick={handleStop}
|
||||
class="px-3 py-1.5 text-sm text-red-400 hover:text-red-300 bg-red-500/10 hover:bg-red-500/20 border border-red-500/30 rounded-lg transition-colors"
|
||||
>
|
||||
Stop
|
||||
</button>
|
||||
{:else if $loopStatus === "paused"}
|
||||
<button
|
||||
onclick={handleResume}
|
||||
class="px-4 py-1.5 text-sm btn-trans-gradient rounded-lg transition-colors"
|
||||
>
|
||||
Resume
|
||||
</button>
|
||||
<button
|
||||
onclick={handleStop}
|
||||
class="px-3 py-1.5 text-sm text-red-400 hover:text-red-300 bg-red-500/10 hover:bg-red-500/20 border border-red-500/30 rounded-lg transition-colors"
|
||||
>
|
||||
Stop
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
[role="dialog"] {
|
||||
animation: slideIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95) translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
@@ -1,12 +1,22 @@
|
||||
<script lang="ts">
|
||||
import { claudeStore, type TerminalLine } from "$lib/stores/claude";
|
||||
import { afterUpdate, tick, onMount, onDestroy } from "svelte";
|
||||
import { get } from "svelte/store";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
import ConversationTabs from "./ConversationTabs.svelte";
|
||||
import Markdown from "./Markdown.svelte";
|
||||
import HighlightedText from "./HighlightedText.svelte";
|
||||
import ThinkingBlock from "./ThinkingBlock.svelte";
|
||||
import ToolCallBlock from "./ToolCallBlock.svelte";
|
||||
import { searchState, searchQuery } from "$lib/stores/search";
|
||||
import { clipboardStore } from "$lib/stores/clipboard";
|
||||
import { shouldHidePaths, maskPaths } from "$lib/stores/config";
|
||||
import { shouldHidePaths, maskPaths, showThinkingBlocks } from "$lib/stores/config";
|
||||
|
||||
// Virtual windowing constants — keeps the DOM lean during long sessions
|
||||
const WINDOW_SIZE = 150; // max lines rendered at once
|
||||
const LOAD_CHUNK = 50; // how many older lines to load when scrolling up
|
||||
const AVG_LINE_HEIGHT = 60; // rough px estimate per line, used for top spacer
|
||||
|
||||
let terminalElement: HTMLDivElement;
|
||||
let shouldAutoScroll = true;
|
||||
@@ -14,9 +24,16 @@
|
||||
let currentSearchQuery = "";
|
||||
let currentConversationId: string | null = null;
|
||||
let isRestoringScroll = false;
|
||||
let windowStart = 0;
|
||||
let isLoadingMore = false;
|
||||
let isSwitchingConversation = false;
|
||||
|
||||
let searchDebounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
searchQuery.subscribe((value) => {
|
||||
currentSearchQuery = value;
|
||||
if (searchDebounceTimer) clearTimeout(searchDebounceTimer);
|
||||
searchDebounceTimer = setTimeout(() => {
|
||||
currentSearchQuery = value;
|
||||
}, 150);
|
||||
});
|
||||
|
||||
let hidePaths = false;
|
||||
@@ -24,6 +41,11 @@
|
||||
hidePaths = value;
|
||||
});
|
||||
|
||||
let showThinking = true;
|
||||
showThinkingBlocks.subscribe((value) => {
|
||||
showThinking = value;
|
||||
});
|
||||
|
||||
claudeStore.terminalLines.subscribe((value) => {
|
||||
lines = value;
|
||||
});
|
||||
@@ -39,18 +61,42 @@
|
||||
|
||||
currentConversationId = newId;
|
||||
|
||||
// Restore scroll position for the new conversation after DOM updates
|
||||
// Guard the $: reactive auto-scroll block from firing with stale `lines`
|
||||
// (the old conversation's data) during the switch. Without this, Svelte's
|
||||
// reactive system can re-run the window-advance block before `terminalLines`
|
||||
// has recomputed for the new conversation, overriding our correct windowStart.
|
||||
isSwitchingConversation = true;
|
||||
|
||||
// Read the new conversation's lines directly from the store — the derived
|
||||
// `terminalLines` store (and thus `lines`) may not have recomputed yet when
|
||||
// this subscriber fires, so using `lines` here would give stale data.
|
||||
const newConvLines = get(claudeStore.conversations).get(newId)?.terminalLines ?? [];
|
||||
const savedPosition = claudeStore.getScrollPosition(newId);
|
||||
if (savedPosition === -1) {
|
||||
// Will auto-scroll: pin the window to the tail of the new conversation
|
||||
shouldAutoScroll = true;
|
||||
windowStart = Math.max(0, newConvLines.length - WINDOW_SIZE);
|
||||
} else {
|
||||
// Will restore a specific position: always start from the top of history
|
||||
shouldAutoScroll = false;
|
||||
windowStart = 0;
|
||||
}
|
||||
|
||||
// Block the scroll handler during the entire DOM transition — scroll events
|
||||
// can fire mid-tick when the content changes, and handleScroll would see
|
||||
// scrollTop not at the bottom yet and set shouldAutoScroll = false, breaking
|
||||
// autoscroll for the new conversation permanently.
|
||||
isRestoringScroll = true;
|
||||
|
||||
// Restore scroll position for the new conversation after DOM updates.
|
||||
// Clear the switch guard first so the $: block can react to new lines
|
||||
// arriving after the switch settles.
|
||||
await tick();
|
||||
isSwitchingConversation = false;
|
||||
if (terminalElement) {
|
||||
const savedPosition = claudeStore.getScrollPosition(newId);
|
||||
isRestoringScroll = true;
|
||||
if (savedPosition === -1) {
|
||||
// Auto-scroll to bottom
|
||||
shouldAutoScroll = true;
|
||||
terminalElement.scrollTop = terminalElement.scrollHeight;
|
||||
} else {
|
||||
// Restore to saved position
|
||||
shouldAutoScroll = false;
|
||||
terminalElement.scrollTop = savedPosition;
|
||||
}
|
||||
// Small delay to prevent the scroll handler from overriding our restore
|
||||
@@ -60,10 +106,30 @@
|
||||
}
|
||||
});
|
||||
|
||||
function handleScroll() {
|
||||
async function handleScroll() {
|
||||
if (!terminalElement || isRestoringScroll) return;
|
||||
const { scrollTop, scrollHeight, clientHeight } = terminalElement;
|
||||
shouldAutoScroll = scrollHeight - scrollTop - clientHeight < 100;
|
||||
|
||||
// Load older lines when the user scrolls near the top of the visible window.
|
||||
// Use windowStart * AVG_LINE_HEIGHT (the spacer height) as the baseline so
|
||||
// we trigger at the top of the rendered content, not the absolute container top.
|
||||
if (scrollTop < windowStart * AVG_LINE_HEIGHT + 300 && windowStart > 0 && !isLoadingMore) {
|
||||
isLoadingMore = true;
|
||||
const prevScrollHeight = terminalElement.scrollHeight;
|
||||
const prevScrollTop = terminalElement.scrollTop;
|
||||
|
||||
windowStart = Math.max(0, windowStart - LOAD_CHUNK);
|
||||
|
||||
await tick();
|
||||
|
||||
if (terminalElement) {
|
||||
// Compensate for the new items pushing content down
|
||||
terminalElement.scrollTop =
|
||||
prevScrollTop + (terminalElement.scrollHeight - prevScrollHeight);
|
||||
}
|
||||
isLoadingMore = false;
|
||||
}
|
||||
}
|
||||
|
||||
afterUpdate(() => {
|
||||
@@ -84,6 +150,16 @@
|
||||
return "terminal-tool";
|
||||
case "error":
|
||||
return "terminal-error";
|
||||
case "thinking":
|
||||
return "terminal-thinking";
|
||||
case "rate-limit":
|
||||
return "terminal-rate-limit";
|
||||
case "compact-prompt":
|
||||
return "terminal-compact-prompt";
|
||||
case "worktree":
|
||||
return "terminal-worktree";
|
||||
case "config-change":
|
||||
return "terminal-config-change";
|
||||
default:
|
||||
return "terminal-default";
|
||||
}
|
||||
@@ -101,6 +177,12 @@
|
||||
return "[tool]";
|
||||
case "error":
|
||||
return "[error]";
|
||||
case "rate-limit":
|
||||
return "[rate-limit]";
|
||||
case "worktree":
|
||||
return "[worktree]";
|
||||
case "config-change":
|
||||
return "[config]";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
@@ -113,6 +195,19 @@
|
||||
});
|
||||
}
|
||||
|
||||
// Visible slice — only render lines within the current window
|
||||
$: visibleLines = lines.slice(windowStart, windowStart + WINDOW_SIZE);
|
||||
|
||||
// Height of the invisible spacer above the visible window
|
||||
$: topSpacerHeight = windowStart * AVG_LINE_HEIGHT;
|
||||
|
||||
// Advance the window forward when auto-scrolling and new lines overflow it.
|
||||
// Skip during conversation switches — `lines` may still hold the previous
|
||||
// conversation's data, which would push windowStart past the new conv's end.
|
||||
$: if (shouldAutoScroll && !isSwitchingConversation && lines.length > windowStart + WINDOW_SIZE) {
|
||||
windowStart = Math.max(0, lines.length - WINDOW_SIZE);
|
||||
}
|
||||
|
||||
$: {
|
||||
if (currentSearchQuery && lines.length > 0) {
|
||||
const escapedQuery = currentSearchQuery.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
@@ -154,6 +249,7 @@
|
||||
if (terminalElement) {
|
||||
terminalElement.removeEventListener("copy", handleCopy);
|
||||
}
|
||||
if (searchDebounceTimer) clearTimeout(searchDebounceTimer);
|
||||
});
|
||||
|
||||
// Copy message content to clipboard
|
||||
@@ -179,6 +275,11 @@
|
||||
copiedMessageId = null;
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
async function handleCompact() {
|
||||
if (!currentConversationId) return;
|
||||
await invoke("send_prompt", { conversationId: currentConversationId, message: "/compact" });
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
@@ -201,56 +302,113 @@
|
||||
bind:this={terminalElement}
|
||||
onscroll={handleScroll}
|
||||
class="terminal-content h-[calc(100%-76px)] overflow-y-auto p-4 font-mono"
|
||||
style="font-size: var(--terminal-font-size, 14px);"
|
||||
style="font-size: var(--terminal-font-size, 14px); font-family: var(--terminal-font-family, monospace);"
|
||||
>
|
||||
{#if lines.length === 0}
|
||||
<div class="terminal-waiting italic">
|
||||
Waiting for Claude... Type a message below to start!
|
||||
</div>
|
||||
{:else}
|
||||
{#each lines as line (line.id)}
|
||||
<div class="terminal-line mb-2 {getLineClass(line.type)} relative group">
|
||||
<span class="terminal-timestamp text-xs mr-2">{formatTime(line.timestamp)}</span>
|
||||
{#if getLinePrefix(line.type)}
|
||||
<span class="terminal-prefix mr-2">{getLinePrefix(line.type)}</span>
|
||||
<div style="height: {topSpacerHeight}px" aria-hidden="true"></div>
|
||||
{#if windowStart > 0}
|
||||
<div class="terminal-older-indicator">
|
||||
↑ {windowStart} older {windowStart === 1 ? "message" : "messages"} — scroll up to load
|
||||
</div>
|
||||
{/if}
|
||||
{#each visibleLines as line (line.id)}
|
||||
{#if line.type === "thinking"}
|
||||
{#if showThinking}
|
||||
<ThinkingBlock content={line.content} timestamp={line.timestamp} />
|
||||
{/if}
|
||||
{#if line.toolName}
|
||||
<span class="terminal-tool-name mr-2">[{line.toolName}]</span>
|
||||
{/if}
|
||||
{#if line.type === "assistant" || line.type === "user"}
|
||||
<div class="message-content-wrapper">
|
||||
<Markdown
|
||||
{:else if line.type === "tool"}
|
||||
<div
|
||||
style={line.parentToolUseId
|
||||
? "margin-left: 16px; padding-left: 8px; border-left: 2px solid var(--accent-primary);"
|
||||
: ""}
|
||||
>
|
||||
<ToolCallBlock
|
||||
toolName={line.toolName ?? null}
|
||||
content={maskPaths(line.content, hidePaths)}
|
||||
timestamp={line.timestamp}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
class="terminal-line mb-2 {getLineClass(line.type)} relative group"
|
||||
style={line.parentToolUseId
|
||||
? "margin-left: 16px; padding-left: 8px; border-left: 2px solid var(--accent-primary);"
|
||||
: ""}
|
||||
>
|
||||
<span class="terminal-timestamp text-xs mr-2">{formatTime(line.timestamp)}</span>
|
||||
{#if line.parentToolUseId}
|
||||
<span class="text-xs mr-2 opacity-60" title="Message from subagent">
|
||||
<svg
|
||||
class="inline-block w-3 h-3 -mt-0.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
{/if}
|
||||
{#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)}
|
||||
<span class="terminal-prefix mr-2">{getLinePrefix(line.type)}</span>
|
||||
{/if}
|
||||
{#if line.type === "compact-prompt"}
|
||||
<button class="compact-action-btn" onclick={handleCompact}>
|
||||
⚡ Compact Conversation
|
||||
</button>
|
||||
{:else if line.type === "assistant" || line.type === "user"}
|
||||
<div class="message-content-wrapper">
|
||||
<Markdown
|
||||
content={maskPaths(line.content, hidePaths)}
|
||||
searchQuery={currentSearchQuery}
|
||||
/>
|
||||
<button
|
||||
class="copy-message-btn opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onclick={() => handleCopyMessage(line.id, line.content)}
|
||||
title="Copy message"
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
|
||||
</svg>
|
||||
<span class="copy-text">{copiedMessageId === line.id ? "Copied!" : "Copy"}</span>
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<HighlightedText
|
||||
content={maskPaths(line.content, hidePaths)}
|
||||
searchQuery={currentSearchQuery}
|
||||
/>
|
||||
<button
|
||||
class="copy-message-btn opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onclick={() => handleCopyMessage(line.id, line.content)}
|
||||
title="Copy message"
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
|
||||
</svg>
|
||||
<span class="copy-text">{copiedMessageId === line.id ? "Copied!" : "Copy"}</span>
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<HighlightedText
|
||||
content={maskPaths(line.content, hidePaths)}
|
||||
searchQuery={currentSearchQuery}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
@@ -283,6 +441,42 @@
|
||||
color: var(--terminal-error, #f87171);
|
||||
}
|
||||
|
||||
.terminal-rate-limit {
|
||||
color: var(--terminal-rate-limit, #fb923c);
|
||||
}
|
||||
|
||||
.terminal-compact-prompt {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.terminal-worktree {
|
||||
color: var(--terminal-worktree, #34d399);
|
||||
}
|
||||
|
||||
.terminal-config-change {
|
||||
color: var(--terminal-config-change, #a78bfa);
|
||||
}
|
||||
|
||||
.compact-action-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4em;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--terminal-error, #f87171);
|
||||
color: var(--terminal-error, #f87171);
|
||||
padding: 0.3em 0.8em;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9em;
|
||||
font-family: inherit;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.compact-action-btn:hover {
|
||||
background: color-mix(in srgb, var(--terminal-error, #f87171) 15%, transparent);
|
||||
color: var(--terminal-error, #f87171);
|
||||
}
|
||||
|
||||
.terminal-default {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
@@ -291,6 +485,14 @@
|
||||
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 {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
@@ -303,6 +505,15 @@
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.terminal-older-indicator {
|
||||
color: var(--text-tertiary, #6b7280);
|
||||
font-size: 0.75rem;
|
||||
text-align: center;
|
||||
padding: 0.25rem 0;
|
||||
margin-bottom: 0.5rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.terminal-header-text {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
/**
|
||||
* Terminal Component Tests
|
||||
*
|
||||
* Tests the pure helper functions extracted from the Terminal component:
|
||||
* - getLineClass: maps line types to CSS class names
|
||||
* - getLinePrefix: maps line types to display prefixes
|
||||
* - formatTime: formats a Date as "HH:MM AM/PM"
|
||||
* - isToolContentLong: checks if tool content exceeds collapse threshold
|
||||
* - truncateToolContent: truncates long tool content with ellipsis
|
||||
*
|
||||
* Manual testing checklist:
|
||||
* - [ ] rate-limit lines appear in amber
|
||||
* - [ ] error lines appear in red
|
||||
* - [ ] tool lines appear in purple
|
||||
* - [ ] system lines appear in grey italic
|
||||
* - [ ] user lines appear in cyan
|
||||
* - [ ] assistant lines appear in primary text colour
|
||||
* - [ ] long tool content is collapsed by default with a toggle button
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
// Mirror functions from Terminal.svelte for isolated testing
|
||||
|
||||
function getLineClass(type: string): string {
|
||||
switch (type) {
|
||||
case "user":
|
||||
return "terminal-user";
|
||||
case "assistant":
|
||||
return "terminal-assistant";
|
||||
case "system":
|
||||
return "terminal-system italic";
|
||||
case "tool":
|
||||
return "terminal-tool";
|
||||
case "error":
|
||||
return "terminal-error";
|
||||
case "thinking":
|
||||
return "terminal-thinking";
|
||||
case "rate-limit":
|
||||
return "terminal-rate-limit";
|
||||
case "compact-prompt":
|
||||
return "terminal-compact-prompt";
|
||||
case "worktree":
|
||||
return "terminal-worktree";
|
||||
case "config-change":
|
||||
return "terminal-config-change";
|
||||
default:
|
||||
return "terminal-default";
|
||||
}
|
||||
}
|
||||
|
||||
function getLinePrefix(type: string): string {
|
||||
switch (type) {
|
||||
case "user":
|
||||
return ">";
|
||||
case "assistant":
|
||||
return "";
|
||||
case "system":
|
||||
return "[system]";
|
||||
case "tool":
|
||||
return "[tool]";
|
||||
case "error":
|
||||
return "[error]";
|
||||
case "rate-limit":
|
||||
return "[rate-limit]";
|
||||
case "worktree":
|
||||
return "[worktree]";
|
||||
case "config-change":
|
||||
return "[config]";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(date: Date): string {
|
||||
return date.toLocaleTimeString("en-US", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
const TOOL_COLLAPSE_THRESHOLD = 60;
|
||||
|
||||
function isToolContentLong(content: string): boolean {
|
||||
return content.length > TOOL_COLLAPSE_THRESHOLD;
|
||||
}
|
||||
|
||||
function truncateToolContent(content: string): string {
|
||||
return content.slice(0, TOOL_COLLAPSE_THRESHOLD) + "…";
|
||||
}
|
||||
|
||||
// Virtual windowing helpers — mirror the logic in Terminal.svelte
|
||||
|
||||
const WINDOW_SIZE = 150;
|
||||
const LOAD_CHUNK = 50;
|
||||
const AVG_LINE_HEIGHT = 60;
|
||||
|
||||
/** Returns the windowStart index when auto-scrolling to the bottom. */
|
||||
function autoScrollWindowStart(linesLength: number, windowSize: number): number {
|
||||
return Math.max(0, linesLength - windowSize);
|
||||
}
|
||||
|
||||
/** Returns the new windowStart after loading LOAD_CHUNK older messages. */
|
||||
function olderWindowStart(currentStart: number, chunkSize: number): number {
|
||||
return Math.max(0, currentStart - chunkSize);
|
||||
}
|
||||
|
||||
/** Returns the height in pixels of the invisible top spacer. */
|
||||
function topSpacerHeight(windowStart: number, avgLineHeight: number): number {
|
||||
return windowStart * avgLineHeight;
|
||||
}
|
||||
|
||||
// ---
|
||||
|
||||
describe("getLineClass", () => {
|
||||
it("returns terminal-user for user lines", () => {
|
||||
expect(getLineClass("user")).toBe("terminal-user");
|
||||
});
|
||||
|
||||
it("returns terminal-assistant for assistant lines", () => {
|
||||
expect(getLineClass("assistant")).toBe("terminal-assistant");
|
||||
});
|
||||
|
||||
it("returns terminal-system italic for system lines", () => {
|
||||
expect(getLineClass("system")).toBe("terminal-system italic");
|
||||
});
|
||||
|
||||
it("returns terminal-tool for tool lines", () => {
|
||||
expect(getLineClass("tool")).toBe("terminal-tool");
|
||||
});
|
||||
|
||||
it("returns terminal-error for error lines", () => {
|
||||
expect(getLineClass("error")).toBe("terminal-error");
|
||||
});
|
||||
|
||||
it("returns terminal-thinking for thinking lines", () => {
|
||||
expect(getLineClass("thinking")).toBe("terminal-thinking");
|
||||
});
|
||||
|
||||
it("returns terminal-rate-limit for rate-limit lines", () => {
|
||||
expect(getLineClass("rate-limit")).toBe("terminal-rate-limit");
|
||||
});
|
||||
|
||||
it("returns terminal-compact-prompt for compact-prompt lines", () => {
|
||||
expect(getLineClass("compact-prompt")).toBe("terminal-compact-prompt");
|
||||
});
|
||||
|
||||
it("returns terminal-worktree for worktree lines", () => {
|
||||
expect(getLineClass("worktree")).toBe("terminal-worktree");
|
||||
});
|
||||
|
||||
it("returns terminal-config-change for config-change lines", () => {
|
||||
expect(getLineClass("config-change")).toBe("terminal-config-change");
|
||||
});
|
||||
|
||||
it("returns terminal-default for unknown line types", () => {
|
||||
expect(getLineClass("unknown")).toBe("terminal-default");
|
||||
expect(getLineClass("")).toBe("terminal-default");
|
||||
expect(getLineClass("random-future-type")).toBe("terminal-default");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getLinePrefix", () => {
|
||||
it("returns > for user lines", () => {
|
||||
expect(getLinePrefix("user")).toBe(">");
|
||||
});
|
||||
|
||||
it("returns empty string for assistant lines", () => {
|
||||
expect(getLinePrefix("assistant")).toBe("");
|
||||
});
|
||||
|
||||
it("returns [system] for system lines", () => {
|
||||
expect(getLinePrefix("system")).toBe("[system]");
|
||||
});
|
||||
|
||||
it("returns [tool] for tool lines", () => {
|
||||
expect(getLinePrefix("tool")).toBe("[tool]");
|
||||
});
|
||||
|
||||
it("returns [error] for error lines", () => {
|
||||
expect(getLinePrefix("error")).toBe("[error]");
|
||||
});
|
||||
|
||||
it("returns [rate-limit] for rate-limit lines", () => {
|
||||
expect(getLinePrefix("rate-limit")).toBe("[rate-limit]");
|
||||
});
|
||||
|
||||
it("returns empty string for compact-prompt lines (button renders instead)", () => {
|
||||
expect(getLinePrefix("compact-prompt")).toBe("");
|
||||
});
|
||||
|
||||
it("returns [worktree] for worktree lines", () => {
|
||||
expect(getLinePrefix("worktree")).toBe("[worktree]");
|
||||
});
|
||||
|
||||
it("returns [config] for config-change lines", () => {
|
||||
expect(getLinePrefix("config-change")).toBe("[config]");
|
||||
});
|
||||
|
||||
it("returns empty string for thinking lines (no prefix)", () => {
|
||||
expect(getLinePrefix("thinking")).toBe("");
|
||||
});
|
||||
|
||||
it("returns empty string for unknown line types", () => {
|
||||
expect(getLinePrefix("unknown")).toBe("");
|
||||
expect(getLinePrefix("")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatTime", () => {
|
||||
it("formats time in 12-hour format with AM/PM", () => {
|
||||
const date = new Date(2026, 1, 7, 14, 35);
|
||||
const formatted = formatTime(date);
|
||||
expect(formatted).toMatch(/\d{2}:\d{2}\s?(AM|PM)/i);
|
||||
});
|
||||
|
||||
it("formats afternoon times correctly", () => {
|
||||
const date = new Date(2026, 1, 7, 14, 35);
|
||||
const formatted = formatTime(date);
|
||||
expect(formatted).toContain("02:35");
|
||||
expect(formatted.toUpperCase()).toContain("PM");
|
||||
});
|
||||
|
||||
it("formats morning times correctly", () => {
|
||||
const date = new Date(2026, 1, 7, 9, 5);
|
||||
const formatted = formatTime(date);
|
||||
expect(formatted).toContain("09:05");
|
||||
expect(formatted.toUpperCase()).toContain("AM");
|
||||
});
|
||||
|
||||
it("formats midnight correctly", () => {
|
||||
const date = new Date(2026, 1, 7, 0, 0);
|
||||
const formatted = formatTime(date);
|
||||
expect(formatted).toContain("12:00");
|
||||
expect(formatted.toUpperCase()).toContain("AM");
|
||||
});
|
||||
|
||||
it("formats noon correctly", () => {
|
||||
const date = new Date(2026, 1, 7, 12, 0);
|
||||
const formatted = formatTime(date);
|
||||
expect(formatted).toContain("12:00");
|
||||
expect(formatted.toUpperCase()).toContain("PM");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isToolContentLong", () => {
|
||||
it("returns false for content at or below the threshold", () => {
|
||||
const exactThreshold = "x".repeat(TOOL_COLLAPSE_THRESHOLD);
|
||||
expect(isToolContentLong(exactThreshold)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true for content exceeding the threshold", () => {
|
||||
const overThreshold = "x".repeat(TOOL_COLLAPSE_THRESHOLD + 1);
|
||||
expect(isToolContentLong(overThreshold)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for short content", () => {
|
||||
expect(isToolContentLong("short")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for empty content", () => {
|
||||
expect(isToolContentLong("")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("truncateToolContent", () => {
|
||||
it("truncates content to the threshold length with an ellipsis", () => {
|
||||
const long = "x".repeat(100);
|
||||
const result = truncateToolContent(long);
|
||||
expect(result).toBe("x".repeat(TOOL_COLLAPSE_THRESHOLD) + "…");
|
||||
});
|
||||
|
||||
it("keeps content shorter than threshold unchanged (plus ellipsis)", () => {
|
||||
const short = "hello";
|
||||
const result = truncateToolContent(short);
|
||||
expect(result).toBe("hello…");
|
||||
});
|
||||
|
||||
it("uses the unicode ellipsis character (not three dots)", () => {
|
||||
const long = "x".repeat(100);
|
||||
const result = truncateToolContent(long);
|
||||
expect(result.endsWith("…")).toBe(true);
|
||||
expect(result.endsWith("...")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("autoScrollWindowStart", () => {
|
||||
it("returns 0 when lines fit within the window", () => {
|
||||
expect(autoScrollWindowStart(50, WINDOW_SIZE)).toBe(0);
|
||||
});
|
||||
|
||||
it("returns 0 when lines exactly fill the window", () => {
|
||||
expect(autoScrollWindowStart(WINDOW_SIZE, WINDOW_SIZE)).toBe(0);
|
||||
});
|
||||
|
||||
it("advances when lines exceed the window size", () => {
|
||||
expect(autoScrollWindowStart(200, WINDOW_SIZE)).toBe(50);
|
||||
});
|
||||
|
||||
it("never returns a negative value", () => {
|
||||
expect(autoScrollWindowStart(0, WINDOW_SIZE)).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps last WINDOW_SIZE lines visible for large collections", () => {
|
||||
expect(autoScrollWindowStart(500, WINDOW_SIZE)).toBe(350);
|
||||
});
|
||||
});
|
||||
|
||||
describe("olderWindowStart", () => {
|
||||
it("subtracts the chunk size from the current start", () => {
|
||||
expect(olderWindowStart(100, LOAD_CHUNK)).toBe(50);
|
||||
});
|
||||
|
||||
it("never returns a negative value when chunk is larger than start", () => {
|
||||
expect(olderWindowStart(20, LOAD_CHUNK)).toBe(0);
|
||||
});
|
||||
|
||||
it("returns 0 when current start is 0", () => {
|
||||
expect(olderWindowStart(0, LOAD_CHUNK)).toBe(0);
|
||||
});
|
||||
|
||||
it("returns 0 when current start exactly equals the chunk size", () => {
|
||||
expect(olderWindowStart(LOAD_CHUNK, LOAD_CHUNK)).toBe(0);
|
||||
});
|
||||
|
||||
it("correctly loads a partial chunk near the beginning", () => {
|
||||
expect(olderWindowStart(30, LOAD_CHUNK)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("topSpacerHeight", () => {
|
||||
it("returns 0 when windowStart is 0", () => {
|
||||
expect(topSpacerHeight(0, AVG_LINE_HEIGHT)).toBe(0);
|
||||
});
|
||||
|
||||
it("multiplies windowStart by avgLineHeight", () => {
|
||||
expect(topSpacerHeight(10, AVG_LINE_HEIGHT)).toBe(600);
|
||||
});
|
||||
|
||||
it("scales linearly with windowStart", () => {
|
||||
expect(topSpacerHeight(50, AVG_LINE_HEIGHT)).toBe(3000);
|
||||
expect(topSpacerHeight(100, AVG_LINE_HEIGHT)).toBe(6000);
|
||||
expect(topSpacerHeight(150, AVG_LINE_HEIGHT)).toBe(9000);
|
||||
});
|
||||
|
||||
it("uses the provided avgLineHeight rather than a hard-coded value", () => {
|
||||
expect(topSpacerHeight(5, 100)).toBe(500);
|
||||
expect(topSpacerHeight(5, 80)).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe("virtual windowing constants", () => {
|
||||
it("WINDOW_SIZE is 150", () => {
|
||||
expect(WINDOW_SIZE).toBe(150);
|
||||
});
|
||||
|
||||
it("LOAD_CHUNK is 50", () => {
|
||||
expect(LOAD_CHUNK).toBe(50);
|
||||
});
|
||||
|
||||
it("LOAD_CHUNK is smaller than WINDOW_SIZE", () => {
|
||||
expect(LOAD_CHUNK).toBeLessThan(WINDOW_SIZE);
|
||||
});
|
||||
|
||||
it("AVG_LINE_HEIGHT is a positive number", () => {
|
||||
expect(AVG_LINE_HEIGHT).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -73,7 +73,7 @@
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
|
||||
<div
|
||||
bind:this={menuElement}
|
||||
class="menu-content"
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
content: string;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
let { content, timestamp }: Props = $props();
|
||||
let isExpanded = $state(false);
|
||||
|
||||
function toggleExpanded() {
|
||||
isExpanded = !isExpanded;
|
||||
}
|
||||
|
||||
function formatTime(date: Date): string {
|
||||
return date.toLocaleTimeString("en-US", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="thinking-block">
|
||||
<button class="thinking-header" onclick={toggleExpanded} type="button">
|
||||
<span class="thinking-timestamp">{formatTime(timestamp)}</span>
|
||||
<svg
|
||||
class="thinking-icon"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
width="16"
|
||||
height="16"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="thinking-label">Extended Thinking</span>
|
||||
<svg
|
||||
class="chevron"
|
||||
class:expanded={isExpanded}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
width="14"
|
||||
height="14"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{#if isExpanded}
|
||||
<div class="thinking-content">
|
||||
{content}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.thinking-block {
|
||||
margin-bottom: 0.5rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 0.375rem;
|
||||
background: var(--bg-secondary);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.thinking-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.875rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.thinking-header:hover {
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.thinking-timestamp {
|
||||
font-family: monospace;
|
||||
font-size: 0.75rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.thinking-icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.thinking-label {
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.chevron {
|
||||
flex-shrink: 0;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.chevron.expanded {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.thinking-content {
|
||||
padding: 0.75rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
color: var(--text-secondary);
|
||||
font-family: monospace;
|
||||
font-size: 0.875rem;
|
||||
font-style: italic;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,199 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { fade, fly } from "svelte/transition";
|
||||
import { cubicOut } from "svelte/easing";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||
import { toastStore, getAchievementRarity, getRarityColour } from "$lib/stores/toasts";
|
||||
import type { AchievementUnlockedEvent } from "$lib/types/achievements";
|
||||
|
||||
const toasts = toastStore;
|
||||
|
||||
onMount(() => {
|
||||
let unlisten: (() => void) | undefined;
|
||||
|
||||
const setupListener = async () => {
|
||||
unlisten = await listen<AchievementUnlockedEvent>("achievement:unlocked", (event) => {
|
||||
toastStore.addAchievement(event.payload.achievement);
|
||||
});
|
||||
};
|
||||
|
||||
setupListener();
|
||||
|
||||
return () => {
|
||||
if (unlisten) {
|
||||
unlisten();
|
||||
}
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="fixed top-20 right-4 z-50 flex flex-col gap-3 items-end">
|
||||
{#each $toasts as toast (toast.id)}
|
||||
<div in:fly={{ x: 300, duration: 500, easing: cubicOut }} out:fade={{ duration: 300 }}>
|
||||
{#if toast.kind === "info"}
|
||||
<!-- Info toast -->
|
||||
<div
|
||||
class="bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg p-3 shadow-lg flex items-center gap-2 max-w-sm"
|
||||
>
|
||||
<span class="text-xl shrink-0">{toast.icon}</span>
|
||||
<span class="text-sm text-[var(--text-primary)] flex-1">{toast.message}</span>
|
||||
<button
|
||||
onclick={() => toastStore.remove(toast.id)}
|
||||
class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 transition-colors shrink-0"
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{:else if toast.kind === "achievement"}
|
||||
{@const rarity = getAchievementRarity(toast.achievement.id)}
|
||||
{@const colour = getRarityColour(rarity)}
|
||||
<!-- Achievement toast -->
|
||||
<div class="relative p-[2px] rounded-lg overflow-hidden max-w-sm">
|
||||
<!-- Animated gradient border -->
|
||||
<div class="absolute inset-0 bg-gradient-to-r {colour} animate-pulse"></div>
|
||||
|
||||
<!-- Main content -->
|
||||
<div class="relative bg-[var(--bg-primary)] rounded-lg p-4 shadow-2xl backdrop-blur-sm">
|
||||
<button
|
||||
onclick={() => toastStore.remove(toast.id)}
|
||||
class="absolute top-2 right-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 transition-colors"
|
||||
aria-label="Dismiss notification"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="flex items-start gap-4">
|
||||
<!-- Icon with animated sparkles -->
|
||||
<div class="relative flex-shrink-0">
|
||||
<div class="text-5xl animate-bounce">{toast.achievement.icon}</div>
|
||||
<div class="absolute -top-1 -right-1 text-yellow-400 animate-ping">✨</div>
|
||||
<div
|
||||
class="absolute -bottom-1 -left-1 text-yellow-400 animate-ping animation-delay-200"
|
||||
>
|
||||
✨
|
||||
</div>
|
||||
<div
|
||||
class="absolute top-1/2 -right-2 text-yellow-400 animate-ping animation-delay-400"
|
||||
>
|
||||
✨
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Text content -->
|
||||
<div class="flex-1 min-w-0 pt-1">
|
||||
<h3
|
||||
class="text-sm font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide"
|
||||
>
|
||||
Achievement Unlocked!
|
||||
</h3>
|
||||
<p class="text-lg font-bold text-[var(--text-primary)] mt-1">
|
||||
{toast.achievement.name}
|
||||
</p>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
{toast.achievement.description}
|
||||
</p>
|
||||
|
||||
<!-- Rarity badge -->
|
||||
<div class="mt-2 inline-flex items-center">
|
||||
<span
|
||||
class="px-2 py-1 text-xs font-medium rounded-full bg-gradient-to-r {colour} text-white capitalize"
|
||||
>
|
||||
{rarity}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Confetti particles -->
|
||||
<div class="absolute inset-0 pointer-events-none overflow-hidden rounded-lg">
|
||||
{#each Array.from({ length: 10 }, (_, i) => i) as confettiIndex (confettiIndex)}
|
||||
<div
|
||||
class="absolute w-2 h-2 bg-gradient-to-br {colour} rounded-full animate-fall"
|
||||
style="left: {(confettiIndex * 11) % 100}%; animation-delay: {(confettiIndex *
|
||||
0.3) %
|
||||
2}s; animation-duration: {2 + ((confettiIndex * 0.25) % 2)}s;"
|
||||
></div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else if toast.kind === "update"}
|
||||
<!-- Update toast -->
|
||||
<div
|
||||
class="bg-[var(--bg-tertiary)] border border-[var(--accent-primary)] rounded-lg p-4 shadow-lg max-w-sm"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="text-2xl">🎉</div>
|
||||
<div class="flex-1">
|
||||
<h3 class="text-[var(--text-primary)] font-semibold mb-1">Update Available!</h3>
|
||||
<button
|
||||
onclick={() => openUrl(toast.releaseUrl)}
|
||||
class="text-[var(--accent-primary)] font-mono hover:underline text-sm"
|
||||
>
|
||||
{toast.latestVersion}
|
||||
</button>
|
||||
<p class="text-[var(--text-muted)] text-xs mt-1">
|
||||
Current version: {toast.currentVersion}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onclick={() => toastStore.remove(toast.id)}
|
||||
class="text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors shrink-0"
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@keyframes fall {
|
||||
0% {
|
||||
transform: translateY(-20px) rotate(0deg);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: translateY(400px) rotate(720deg);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fall {
|
||||
animation: fall linear infinite;
|
||||
}
|
||||
|
||||
.animation-delay-200 {
|
||||
animation-delay: 200ms;
|
||||
}
|
||||
|
||||
.animation-delay-400 {
|
||||
animation-delay: 400ms;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,182 @@
|
||||
<script lang="ts">
|
||||
import { todos } from "$lib/stores/todos";
|
||||
import { CheckCircle, Circle, Loader } from "lucide-svelte";
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const { onClose }: Props = $props();
|
||||
|
||||
const currentTodos = $derived($todos);
|
||||
const hasTodos = $derived(currentTodos.length > 0);
|
||||
const completedCount = $derived(currentTodos.filter((t) => t.status === "completed").length);
|
||||
const totalCount = $derived(currentTodos.length);
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="fixed top-0 right-0 h-full w-96 bg-[var(--bg-primary)] border-l border-[var(--accent-primary)]/30 shadow-2xl flex flex-col z-50"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between p-4 border-b border-[var(--accent-primary)]/30">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="text-[var(--accent-primary)]">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-[var(--text-primary)]">Hikari's Todo List</h2>
|
||||
{#if hasTodos}
|
||||
<p class="text-xs text-[var(--text-secondary)]">
|
||||
{completedCount} of {totalCount} completed
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onclick={onClose}
|
||||
class="text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors p-1 rounded-lg hover:bg-[var(--bg-secondary)]"
|
||||
aria-label="Close todo panel"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex-1 overflow-y-auto p-4">
|
||||
{#if !hasTodos}
|
||||
<div class="flex flex-col items-center justify-center h-full text-[var(--text-secondary)]">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-16 w-16 mb-4 opacity-50"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
<p class="text-center">No active todos</p>
|
||||
<p class="text-sm text-center mt-2">I'll update this when I start working on tasks!</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-2">
|
||||
{#each currentTodos as todo (todo.content)}
|
||||
<div
|
||||
class="group bg-[var(--bg-secondary)]/50 rounded-lg p-3 border border-[var(--border-color)] hover:border-[var(--accent-primary)]/50 transition-all"
|
||||
class:opacity-60={todo.status === "completed"}
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<!-- Status Icon -->
|
||||
<div class="mt-0.5 flex-shrink-0">
|
||||
{#if todo.status === "completed"}
|
||||
<CheckCircle class="w-5 h-5 text-[var(--success-color)]" />
|
||||
{:else if todo.status === "in_progress"}
|
||||
<Loader class="w-5 h-5 text-[var(--accent-primary)] animate-spin" />
|
||||
{:else}
|
||||
<Circle class="w-5 h-5 text-[var(--text-secondary)]" />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<p
|
||||
class="text-sm font-medium"
|
||||
class:text-[var(--text-secondary)]={todo.status === "completed"}
|
||||
class:line-through={todo.status === "completed"}
|
||||
class:text-[var(--text-primary)]={todo.status !== "completed"}
|
||||
>
|
||||
{todo.status === "in_progress" ? todo.activeForm : todo.content}
|
||||
</p>
|
||||
|
||||
<!-- Status Badge -->
|
||||
<div class="mt-1">
|
||||
{#if todo.status === "completed"}
|
||||
<span
|
||||
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-[var(--success-color)]/20 text-[var(--success-color)] border border-[var(--success-color)]/30"
|
||||
>
|
||||
✓ Completed
|
||||
</span>
|
||||
{:else if todo.status === "in_progress"}
|
||||
<span
|
||||
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-[var(--accent-primary)]/20 text-[var(--accent-primary)] border border-[var(--accent-primary)]/30 animate-pulse"
|
||||
>
|
||||
⚡ In Progress
|
||||
</span>
|
||||
{:else}
|
||||
<span
|
||||
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-[var(--bg-secondary)] text-[var(--text-secondary)] border border-[var(--border-color)]"
|
||||
>
|
||||
○ Pending
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Footer with Progress Bar -->
|
||||
{#if hasTodos}
|
||||
<div class="border-t border-[var(--accent-primary)]/30 p-4 bg-[var(--bg-secondary)]/50">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-xs font-medium text-[var(--text-secondary)]">Progress</span>
|
||||
<span class="text-xs font-medium text-[var(--accent-primary)]">
|
||||
{Math.round((completedCount / totalCount) * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
<div class="w-full bg-[var(--bg-secondary)] rounded-full h-2 overflow-hidden">
|
||||
<div
|
||||
class="bg-gradient-to-r from-[var(--accent-primary)] to-[var(--accent-secondary)] h-2 rounded-full transition-all duration-500 ease-out"
|
||||
style="width: {(completedCount / totalCount) * 100}%"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-pulse {
|
||||
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,141 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
toolName: string | null;
|
||||
content: string;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
let { toolName, content, timestamp }: Props = $props();
|
||||
let isExpanded = $state(false);
|
||||
|
||||
function toggleExpanded() {
|
||||
isExpanded = !isExpanded;
|
||||
}
|
||||
|
||||
function formatTime(date: Date): string {
|
||||
return date.toLocaleTimeString("en-US", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="tool-block">
|
||||
<button class="tool-header" onclick={toggleExpanded} type="button">
|
||||
<span class="tool-timestamp">{formatTime(timestamp)}</span>
|
||||
<svg
|
||||
class="tool-icon"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
width="16"
|
||||
height="16"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"
|
||||
/>
|
||||
</svg>
|
||||
{#if toolName}
|
||||
<span class="tool-name">[{toolName}]</span>
|
||||
{/if}
|
||||
<span class="tool-label">{content}</span>
|
||||
<svg
|
||||
class="chevron"
|
||||
class:expanded={isExpanded}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
width="14"
|
||||
height="14"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{#if isExpanded}
|
||||
<div class="tool-content">
|
||||
{content}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.tool-block {
|
||||
margin-bottom: 0.5rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 0.375rem;
|
||||
background: var(--bg-secondary);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.tool-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
color: var(--terminal-tool, #c084fc);
|
||||
font-size: 0.875rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.tool-header:hover {
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.tool-timestamp {
|
||||
font-family: monospace;
|
||||
font-size: 0.75rem;
|
||||
opacity: 0.7;
|
||||
color: var(--text-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tool-icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tool-name {
|
||||
font-family: monospace;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
color: var(--terminal-tool-name, #ddd6fe);
|
||||
}
|
||||
|
||||
.tool-label {
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
font-style: italic;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chevron {
|
||||
flex-shrink: 0;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.chevron.expanded {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.tool-content {
|
||||
padding: 0.75rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
color: var(--terminal-tool, #c084fc);
|
||||
font-family: monospace;
|
||||
font-size: 0.875rem;
|
||||
font-style: italic;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
</style>
|
||||
@@ -1,89 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||
import type { UpdateInfo } from "$lib/types/messages";
|
||||
import { configStore } from "$lib/stores/config";
|
||||
|
||||
let updateInfo = $state<UpdateInfo | null>(null);
|
||||
let dismissed = $state(false);
|
||||
|
||||
export async function checkForUpdates() {
|
||||
// Check if update checks are enabled
|
||||
const config = configStore.getConfig();
|
||||
if (!config.update_checks_enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const info = await invoke<UpdateInfo>("check_for_updates");
|
||||
if (info.has_update) {
|
||||
updateInfo = info;
|
||||
dismissed = false;
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
console.error("Failed to check for updates:", errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
function dismiss() {
|
||||
dismissed = true;
|
||||
}
|
||||
|
||||
async function openRelease() {
|
||||
if (updateInfo?.release_url) {
|
||||
await openUrl(updateInfo.release_url);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if updateInfo && !dismissed}
|
||||
<div
|
||||
class="fixed bottom-4 right-4 max-w-sm bg-[var(--bg-tertiary)] border border-[var(--accent-primary)] rounded-lg shadow-lg p-4 z-50"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="text-2xl">🎉</div>
|
||||
<div class="flex-1">
|
||||
<h3 class="text-[var(--text-primary)] font-semibold mb-1">Update Available!</h3>
|
||||
<p class="text-[var(--text-secondary)] text-sm mb-2">
|
||||
A new version of Hikari Desktop is available:
|
||||
<span class="text-[var(--accent-primary)] font-mono">{updateInfo.latest_version}</span>
|
||||
</p>
|
||||
<p class="text-[var(--text-muted)] text-xs mb-3">
|
||||
Current version: {updateInfo.current_version}
|
||||
</p>
|
||||
<div class="flex gap-2">
|
||||
<button onclick={openRelease} class="btn-trans-gradient px-3 py-1.5 rounded text-sm">
|
||||
View Release
|
||||
</button>
|
||||
<button
|
||||
onclick={dismiss}
|
||||
class="px-3 py-1.5 bg-[var(--bg-secondary)] text-[var(--text-secondary)] rounded text-sm hover:bg-[var(--bg-primary)] transition-all"
|
||||
>
|
||||
Later
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onclick={dismiss}
|
||||
class="text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors"
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -5,6 +5,9 @@
|
||||
import { claudeStore, hasQuestionPending } from "$lib/stores/claude";
|
||||
import { characterState } from "$lib/stores/character";
|
||||
import type { UserQuestionEvent } from "$lib/types/messages";
|
||||
import { updateDiscordRpc, setSkipNextGreeting } from "$lib/tauri";
|
||||
import { conversationsStore } from "$lib/stores/conversations";
|
||||
import { configStore } from "$lib/stores/config";
|
||||
|
||||
let isVisible = $state(false);
|
||||
let question: UserQuestionEvent | null = $state(null);
|
||||
@@ -86,18 +89,48 @@
|
||||
claudeStore.clearQuestion();
|
||||
|
||||
try {
|
||||
// Prevent stats reset on reconnection
|
||||
setSkipNextGreeting(true);
|
||||
|
||||
await invoke("stop_claude", { conversationId });
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
const config = configStore.getConfig();
|
||||
await invoke("start_claude", {
|
||||
conversationId,
|
||||
options: {
|
||||
working_dir: workingDirectory || "/home/naomi",
|
||||
model: config.model || null,
|
||||
api_key: config.api_key || null,
|
||||
custom_instructions: config.custom_instructions || null,
|
||||
mcp_servers_json: config.mcp_servers_json || null,
|
||||
allowed_tools: grantedToolsList,
|
||||
use_worktree: config.use_worktree ?? false,
|
||||
disable_1m_context: config.disable_1m_context ?? false,
|
||||
disable_cron: config.disable_cron ?? false,
|
||||
disable_skill_shell_execution: config.disable_skill_shell_execution ?? false,
|
||||
include_git_instructions: config.include_git_instructions ?? true,
|
||||
enable_claudeai_mcp_servers: config.enable_claudeai_mcp_servers ?? true,
|
||||
auto_memory_directory: config.auto_memory_directory || null,
|
||||
model_overrides: config.model_overrides || null,
|
||||
session_name: null,
|
||||
bare_mode: config.bare_mode ?? false,
|
||||
show_clear_context_on_plan_accept: config.show_clear_context_on_plan_accept ?? true,
|
||||
custom_model_option: config.custom_model_option || null,
|
||||
},
|
||||
});
|
||||
|
||||
// Update Discord RPC when reconnecting after answering question
|
||||
const activeConversation = get(conversationsStore.activeConversation);
|
||||
if (activeConversation) {
|
||||
await updateDiscordRpc(
|
||||
activeConversation.name,
|
||||
config.model || "claude",
|
||||
activeConversation.startedAt
|
||||
);
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
|
||||
if (conversationHistory) {
|
||||
|
||||
@@ -0,0 +1,833 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { get } from "svelte/store";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import {
|
||||
workflowStore,
|
||||
buildDiscussPrompt,
|
||||
buildVerifyPrompt,
|
||||
canAdvancePhase,
|
||||
canGoBack,
|
||||
getPhaseLabel,
|
||||
type WorkflowPhase,
|
||||
type CriterionStatus,
|
||||
} from "$lib/stores/workflow";
|
||||
import { prdStore } from "$lib/stores/prd";
|
||||
import { taskLoopStore, countByStatus } from "$lib/stores/taskLoop";
|
||||
import { characterState } from "$lib/stores/character";
|
||||
import { claudeStore } from "$lib/stores/claude";
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
onOpenPrdPanel: () => void;
|
||||
onOpenTaskLoop: () => void;
|
||||
workingDirectory: string;
|
||||
}
|
||||
|
||||
const { onClose, onOpenPrdPanel, onOpenTaskLoop, workingDirectory }: Props = $props();
|
||||
|
||||
const workflowState = $derived(workflowStore.state);
|
||||
const prdTasks = $derived(prdStore.tasks);
|
||||
const prdIsLoaded = $derived(prdStore.isLoaded);
|
||||
const loopTasks = $derived(taskLoopStore.tasks);
|
||||
const loopStatus = $derived(taskLoopStore.loopStatus);
|
||||
|
||||
let previousCharacterState = $state<string>("idle");
|
||||
let isWaitingForDiscuss = $state(false);
|
||||
let isWaitingForVerify = $state(false);
|
||||
let contextContent = $state<string | null>(null);
|
||||
let verifyContent = $state<string | null>(null);
|
||||
let newCriterionText = $state("");
|
||||
let isLoadingContext = $state(false);
|
||||
let isLoadingVerify = $state(false);
|
||||
|
||||
const PHASES: WorkflowPhase[] = [1, 2, 3, 4];
|
||||
|
||||
onMount(async () => {
|
||||
await workflowStore.loadState(workingDirectory);
|
||||
await prdStore.loadFromFile(workingDirectory);
|
||||
await tryLoadContextFile();
|
||||
await tryLoadVerifyFile();
|
||||
});
|
||||
|
||||
// Watch characterState to detect when Claude finishes working
|
||||
$effect(() => {
|
||||
const currentState = $characterState;
|
||||
if (isWaitingForDiscuss && previousCharacterState !== "idle" && currentState === "idle") {
|
||||
isWaitingForDiscuss = false;
|
||||
void tryLoadContextFile().then(() => {
|
||||
if (contextContent !== null) {
|
||||
workflowStore.markContextCaptured();
|
||||
void workflowStore.saveState(workingDirectory);
|
||||
}
|
||||
});
|
||||
}
|
||||
if (isWaitingForVerify && previousCharacterState !== "idle" && currentState === "idle") {
|
||||
isWaitingForVerify = false;
|
||||
void tryLoadVerifyFile().then(() => {
|
||||
if (verifyContent !== null) {
|
||||
workflowStore.completeVerification(verifyContent);
|
||||
void workflowStore.saveState(workingDirectory);
|
||||
}
|
||||
});
|
||||
}
|
||||
previousCharacterState = currentState;
|
||||
});
|
||||
|
||||
async function tryLoadContextFile(): Promise<void> {
|
||||
isLoadingContext = true;
|
||||
try {
|
||||
const content = await invoke<string>("read_file_content", {
|
||||
path: `${workingDirectory}/CONTEXT.md`,
|
||||
});
|
||||
contextContent = content;
|
||||
} catch {
|
||||
contextContent = null;
|
||||
} finally {
|
||||
isLoadingContext = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function tryLoadVerifyFile(): Promise<void> {
|
||||
isLoadingVerify = true;
|
||||
try {
|
||||
const content = await invoke<string>("read_file_content", {
|
||||
path: `${workingDirectory}/VERIFY.md`,
|
||||
});
|
||||
verifyContent = content;
|
||||
} catch {
|
||||
verifyContent = null;
|
||||
} finally {
|
||||
isLoadingVerify = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStartDiscussion(): Promise<void> {
|
||||
const description = $workflowState.discuss.description.trim();
|
||||
if (!description) return;
|
||||
const conversationId = get(claudeStore.activeConversationId);
|
||||
if (!conversationId) return;
|
||||
const prompt = buildDiscussPrompt(description);
|
||||
isWaitingForDiscuss = true;
|
||||
await invoke("send_prompt", { conversationId, message: prompt });
|
||||
}
|
||||
|
||||
async function handleQuickCaptureContext(): Promise<void> {
|
||||
const description = $workflowState.discuss.description.trim();
|
||||
if (!description) return;
|
||||
const content = `# CONTEXT\n\n## Goal\n\n${description}\n`;
|
||||
await invoke("write_file_content", { path: `${workingDirectory}/CONTEXT.md`, content });
|
||||
contextContent = content;
|
||||
workflowStore.markContextCaptured();
|
||||
await workflowStore.saveState(workingDirectory);
|
||||
}
|
||||
|
||||
async function handleApprovePlan(): Promise<void> {
|
||||
workflowStore.approvePlan();
|
||||
await workflowStore.saveState(workingDirectory);
|
||||
}
|
||||
|
||||
async function handleCompleteExecution(): Promise<void> {
|
||||
workflowStore.completeExecution();
|
||||
await workflowStore.saveState(workingDirectory);
|
||||
}
|
||||
|
||||
async function handleExtractCriteria(): Promise<void> {
|
||||
if (!contextContent) return;
|
||||
const lines = contextContent.split("\n");
|
||||
let inCriteria = false;
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (/^##\s*acceptance criteria/i.test(trimmed)) {
|
||||
inCriteria = true;
|
||||
continue;
|
||||
}
|
||||
if (inCriteria && /^##/.test(trimmed)) {
|
||||
inCriteria = false;
|
||||
continue;
|
||||
}
|
||||
if (inCriteria && /^\d+\./.test(trimmed)) {
|
||||
const text = trimmed.replace(/^\d+\.\s*/, "").trim();
|
||||
if (text) workflowStore.addCriterion(text);
|
||||
}
|
||||
}
|
||||
await workflowStore.saveState(workingDirectory);
|
||||
}
|
||||
|
||||
async function handleRunVerification(): Promise<void> {
|
||||
const criteria = $workflowState.verify.criteria;
|
||||
const conversationId = get(claudeStore.activeConversationId);
|
||||
if (!conversationId) return;
|
||||
const prompt = buildVerifyPrompt(criteria);
|
||||
isWaitingForVerify = true;
|
||||
await invoke("send_prompt", { conversationId, message: prompt });
|
||||
}
|
||||
|
||||
async function handleAddCriterion(): Promise<void> {
|
||||
const text = newCriterionText.trim();
|
||||
if (!text) return;
|
||||
workflowStore.addCriterion(text);
|
||||
newCriterionText = "";
|
||||
await workflowStore.saveState(workingDirectory);
|
||||
}
|
||||
|
||||
async function handleRemoveCriterion(id: string): Promise<void> {
|
||||
workflowStore.removeCriterion(id);
|
||||
await workflowStore.saveState(workingDirectory);
|
||||
}
|
||||
|
||||
async function handleSetCriterionStatus(id: string, status: CriterionStatus): Promise<void> {
|
||||
workflowStore.updateCriterionStatus(id, status);
|
||||
await workflowStore.saveState(workingDirectory);
|
||||
}
|
||||
|
||||
async function handleAdvance(): Promise<void> {
|
||||
const current = $workflowState.currentPhase;
|
||||
if (current < 4) {
|
||||
workflowStore.setPhase((current + 1) as WorkflowPhase);
|
||||
await workflowStore.saveState(workingDirectory);
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBack(): Promise<void> {
|
||||
const current = $workflowState.currentPhase;
|
||||
if (canGoBack(current)) {
|
||||
workflowStore.setPhase((current - 1) as WorkflowPhase);
|
||||
await workflowStore.saveState(workingDirectory);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReset(): Promise<void> {
|
||||
workflowStore.reset();
|
||||
contextContent = null;
|
||||
verifyContent = null;
|
||||
await workflowStore.saveState(workingDirectory);
|
||||
}
|
||||
|
||||
function completedTaskCount(): number {
|
||||
return countByStatus($loopTasks, "completed");
|
||||
}
|
||||
|
||||
function failedTaskCount(): number {
|
||||
return countByStatus($loopTasks, "failed");
|
||||
}
|
||||
|
||||
function allTasksDone(): boolean {
|
||||
const tasks = $loopTasks;
|
||||
return (
|
||||
tasks.length > 0 && tasks.every((t) => t.status === "completed" || t.status === "failed")
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4"
|
||||
onclick={onClose}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onkeydown={(e) => e.key === "Escape" && onClose()}
|
||||
>
|
||||
<div
|
||||
class="bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] flex flex-col"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-labelledby="workflow-panel-title"
|
||||
tabindex="-1"
|
||||
>
|
||||
<!-- Header: title + phase stepper + quick mode -->
|
||||
<div class="flex flex-col gap-3 p-6 pb-4 border-b border-[var(--border-color)]">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 id="workflow-panel-title" class="text-xl font-semibold text-[var(--text-primary)]">
|
||||
Guided Workflow
|
||||
</h2>
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="flex items-center gap-2 cursor-pointer select-none">
|
||||
<span class="text-xs text-[var(--text-secondary)]">Quick Mode</span>
|
||||
<button
|
||||
role="switch"
|
||||
aria-checked={$workflowState.quickMode}
|
||||
onclick={() => {
|
||||
workflowStore.setQuickMode(!$workflowState.quickMode);
|
||||
void workflowStore.saveState(workingDirectory);
|
||||
}}
|
||||
class="w-9 h-5 rounded-full transition-colors {$workflowState.quickMode
|
||||
? 'bg-[var(--accent-primary)]'
|
||||
: 'bg-[var(--bg-tertiary)]'} relative"
|
||||
>
|
||||
<span
|
||||
class="absolute top-0.5 left-0.5 w-4 h-4 rounded-full bg-white shadow transition-transform {$workflowState.quickMode
|
||||
? 'translate-x-4'
|
||||
: 'translate-x-0'}"
|
||||
></span>
|
||||
</button>
|
||||
</label>
|
||||
<button
|
||||
onclick={onClose}
|
||||
class="p-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
||||
aria-label="Close"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Phase stepper -->
|
||||
<div class="flex items-center gap-1">
|
||||
{#each PHASES as phase (phase)}
|
||||
{@const isActive = $workflowState.currentPhase === phase}
|
||||
{@const isDone = $workflowState.currentPhase > phase}
|
||||
<button
|
||||
onclick={() => {
|
||||
workflowStore.setPhase(phase);
|
||||
void workflowStore.saveState(workingDirectory);
|
||||
}}
|
||||
aria-label="Go to phase {phase}: {getPhaseLabel(phase)}"
|
||||
class="flex items-center gap-1.5 px-2 py-1 rounded-md text-xs transition-colors {isActive
|
||||
? 'bg-[var(--accent-primary)]/20 text-[var(--accent-primary)] font-semibold'
|
||||
: isDone
|
||||
? 'text-green-400'
|
||||
: 'text-[var(--text-tertiary)]'}"
|
||||
>
|
||||
<span
|
||||
class="w-5 h-5 rounded-full flex items-center justify-center text-[10px] font-bold {isActive
|
||||
? 'bg-[var(--accent-primary)] text-white'
|
||||
: isDone
|
||||
? 'bg-green-500/20 text-green-400 border border-green-500/40'
|
||||
: 'bg-[var(--bg-secondary)] border border-[var(--border-color)]'}"
|
||||
>
|
||||
{#if isDone}✓{:else}{phase}{/if}
|
||||
</span>
|
||||
<span class="hidden sm:inline">{getPhaseLabel(phase)}</span>
|
||||
</button>
|
||||
{#if phase < 4}
|
||||
<svg
|
||||
class="w-3 h-3 text-[var(--text-tertiary)] shrink-0"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="flex-1 overflow-y-auto p-6 min-h-0">
|
||||
{#if $workflowState.currentPhase === 1}
|
||||
<!-- ── Phase 1: Discuss ── -->
|
||||
<div class="flex flex-col gap-4">
|
||||
<div>
|
||||
<label
|
||||
for="workflow-description"
|
||||
class="block text-sm font-medium text-[var(--text-secondary)] mb-2"
|
||||
>
|
||||
Describe what you want to build
|
||||
</label>
|
||||
<textarea
|
||||
id="workflow-description"
|
||||
value={$workflowState.discuss.description}
|
||||
oninput={(e) => {
|
||||
workflowStore.setDiscussDescription((e.target as HTMLTextAreaElement).value);
|
||||
}}
|
||||
rows={5}
|
||||
class="w-full bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg p-4 text-sm text-[var(--text-primary)] resize-none focus:outline-none focus:border-[var(--accent-primary)] leading-relaxed"
|
||||
placeholder="Describe the feature or project you want to build..."
|
||||
spellcheck="false"
|
||||
disabled={isWaitingForDiscuss}
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
{#if isWaitingForDiscuss}
|
||||
<div
|
||||
class="flex items-center gap-3 p-4 bg-[var(--bg-secondary)] rounded-lg border border-[var(--border-color)]"
|
||||
>
|
||||
<div class="text-xl animate-spin">⚙️</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-[var(--text-primary)]">Claude is working...</p>
|
||||
<p class="text-xs text-[var(--text-secondary)]">
|
||||
Writing CONTEXT.md — will auto-detect when complete.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else if contextContent !== null}
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs font-medium text-green-400">✓ CONTEXT.md captured</span>
|
||||
<button
|
||||
onclick={tryLoadContextFile}
|
||||
class="text-xs text-[var(--text-tertiary)] hover:text-[var(--text-secondary)] transition-colors"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
value={contextContent}
|
||||
readonly
|
||||
rows={8}
|
||||
class="w-full bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg p-3 font-mono text-xs text-[var(--text-secondary)] resize-y focus:outline-none leading-relaxed"
|
||||
></textarea>
|
||||
</div>
|
||||
{:else if isLoadingContext}
|
||||
<p class="text-xs text-[var(--text-tertiary)]">Checking for CONTEXT.md...</p>
|
||||
{:else}
|
||||
<div class="flex flex-col gap-2">
|
||||
<p class="text-xs text-[var(--text-tertiary)]">
|
||||
{#if $workflowState.quickMode}
|
||||
Quick mode: your description will be saved directly to CONTEXT.md without
|
||||
discussion.
|
||||
{:else}
|
||||
Claude will analyse your description and write a structured CONTEXT.md with
|
||||
acceptance criteria.
|
||||
{/if}
|
||||
</p>
|
||||
<div class="flex gap-2">
|
||||
{#if !$workflowState.quickMode}
|
||||
<button
|
||||
onclick={handleStartDiscussion}
|
||||
disabled={!$workflowState.discuss.description.trim()}
|
||||
class="px-4 py-1.5 text-sm btn-trans-gradient rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Start Discussion
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
onclick={handleQuickCaptureContext}
|
||||
disabled={!$workflowState.discuss.description.trim()}
|
||||
class="px-4 py-1.5 text-sm btn-trans-gradient rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Save to CONTEXT.md
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
onclick={tryLoadContextFile}
|
||||
class="px-3 py-1.5 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] bg-[var(--bg-secondary)] hover:bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded-lg transition-colors"
|
||||
>
|
||||
Check for CONTEXT.md
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if $workflowState.currentPhase === 2}
|
||||
<!-- ── Phase 2: Plan ── -->
|
||||
<div class="flex flex-col gap-4">
|
||||
<p class="text-sm text-[var(--text-secondary)]">
|
||||
Use the PRD Creator to generate your task breakdown, then approve it here to advance.
|
||||
</p>
|
||||
|
||||
<!-- PRD status card -->
|
||||
<div class="p-4 bg-[var(--bg-secondary)] rounded-lg border border-[var(--border-color)]">
|
||||
{#if $prdIsLoaded && $prdTasks.length > 0}
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-[var(--text-primary)]">
|
||||
{$prdTasks.length} task{$prdTasks.length === 1 ? "" : "s"} ready
|
||||
</p>
|
||||
<p class="text-xs text-[var(--text-tertiary)] mt-0.5">hikari-tasks.json loaded</p>
|
||||
</div>
|
||||
<span
|
||||
class="text-xs px-2 py-0.5 rounded-full bg-green-500/20 text-green-400 border border-green-500/30"
|
||||
>
|
||||
Ready
|
||||
</span>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-[var(--text-tertiary)]">No tasks generated yet</p>
|
||||
<p class="text-xs text-[var(--text-tertiary)] mt-0.5">
|
||||
Open PRD Creator to generate a task breakdown
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
class="text-xs px-2 py-0.5 rounded-full bg-[var(--bg-tertiary)] text-[var(--text-tertiary)] border border-[var(--border-color)]"
|
||||
>
|
||||
Pending
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
onclick={onOpenPrdPanel}
|
||||
class="px-4 py-1.5 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] bg-[var(--bg-secondary)] hover:bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded-lg transition-colors"
|
||||
>
|
||||
Open PRD Creator →
|
||||
</button>
|
||||
<button
|
||||
onclick={async () => {
|
||||
await prdStore.loadFromFile(workingDirectory);
|
||||
}}
|
||||
class="px-3 py-1.5 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] bg-[var(--bg-secondary)] hover:bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded-lg transition-colors"
|
||||
>
|
||||
Reload Tasks
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if $workflowState.plan.tasksApproved}
|
||||
<div
|
||||
class="flex items-center gap-2 p-3 bg-green-500/10 rounded-lg border border-green-500/20"
|
||||
>
|
||||
<span class="text-green-400 text-sm"
|
||||
>✓ Plan approved — ready to advance to Execute</span
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if $workflowState.currentPhase === 3}
|
||||
<!-- ── Phase 3: Execute ── -->
|
||||
<div class="flex flex-col gap-4">
|
||||
<p class="text-sm text-[var(--text-secondary)]">
|
||||
Run your tasks in the Task Loop panel, then mark execution complete here.
|
||||
</p>
|
||||
|
||||
<!-- Task Loop progress -->
|
||||
<div class="p-4 bg-[var(--bg-secondary)] rounded-lg border border-[var(--border-color)]">
|
||||
{#if $loopTasks.length > 0}
|
||||
{@const done = completedTaskCount()}
|
||||
{@const failed = failedTaskCount()}
|
||||
{@const total = $loopTasks.length}
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-sm font-medium text-[var(--text-primary)]">
|
||||
{done} / {total} tasks completed
|
||||
</p>
|
||||
{#if $loopStatus !== "idle"}
|
||||
<span
|
||||
class="text-xs px-2 py-0.5 rounded-full bg-blue-500/20 text-blue-400 border border-blue-500/30 capitalize"
|
||||
>
|
||||
{$loopStatus}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<!-- Progress bar -->
|
||||
<div class="w-full bg-[var(--bg-primary)] rounded-full h-2 overflow-hidden">
|
||||
<div
|
||||
class="h-full bg-[var(--accent-primary)] transition-all duration-300"
|
||||
style="width: {total > 0 ? (done / total) * 100 : 0}%"
|
||||
></div>
|
||||
</div>
|
||||
{#if failed > 0}
|
||||
<p class="text-xs text-red-400">
|
||||
{failed} task{failed === 1 ? "" : "s"} failed
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-sm text-[var(--text-tertiary)]">
|
||||
No tasks loaded — open Task Loop to load and run tasks
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
onclick={onOpenTaskLoop}
|
||||
class="px-4 py-1.5 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] bg-[var(--bg-secondary)] hover:bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded-lg transition-colors"
|
||||
>
|
||||
Open Task Loop →
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if $workflowState.execute.completed}
|
||||
<div
|
||||
class="flex items-center gap-2 p-3 bg-green-500/10 rounded-lg border border-green-500/20"
|
||||
>
|
||||
<span class="text-green-400 text-sm"
|
||||
>✓ Execution complete — ready to advance to Verify</span
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if $workflowState.currentPhase === 4}
|
||||
<!-- ── Phase 4: Verify ── -->
|
||||
<div class="flex flex-col gap-4">
|
||||
<p class="text-sm text-[var(--text-secondary)]">
|
||||
Verify the implementation against your acceptance criteria.
|
||||
</p>
|
||||
|
||||
<!-- Criteria list -->
|
||||
{#if $workflowState.verify.criteria.length > 0}
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-xs font-medium text-[var(--text-secondary)]">Acceptance Criteria</p>
|
||||
{#if contextContent !== null}
|
||||
<button
|
||||
onclick={handleExtractCriteria}
|
||||
class="text-xs text-[var(--text-tertiary)] hover:text-[var(--text-secondary)] transition-colors"
|
||||
>
|
||||
Re-extract from CONTEXT.md
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#each $workflowState.verify.criteria as criterion (criterion.id)}
|
||||
<div
|
||||
class="flex items-center gap-2 p-3 bg-[var(--bg-secondary)] rounded-lg border border-[var(--border-color)]"
|
||||
>
|
||||
<p class="flex-1 text-sm text-[var(--text-primary)]">{criterion.text}</p>
|
||||
<div class="flex items-center gap-1 shrink-0">
|
||||
<button
|
||||
onclick={() => handleSetCriterionStatus(criterion.id, "pass")}
|
||||
class="px-2 py-0.5 text-xs rounded transition-colors {criterion.status ===
|
||||
'pass'
|
||||
? 'bg-green-500/20 text-green-400 border border-green-500/30'
|
||||
: 'text-[var(--text-tertiary)] hover:text-green-400'}"
|
||||
>
|
||||
Pass
|
||||
</button>
|
||||
<button
|
||||
onclick={() => handleSetCriterionStatus(criterion.id, "partial")}
|
||||
class="px-2 py-0.5 text-xs rounded transition-colors {criterion.status ===
|
||||
'partial'
|
||||
? 'bg-amber-500/20 text-amber-400 border border-amber-500/30'
|
||||
: 'text-[var(--text-tertiary)] hover:text-amber-400'}"
|
||||
>
|
||||
Partial
|
||||
</button>
|
||||
<button
|
||||
onclick={() => handleSetCriterionStatus(criterion.id, "fail")}
|
||||
class="px-2 py-0.5 text-xs rounded transition-colors {criterion.status ===
|
||||
'fail'
|
||||
? 'bg-red-500/20 text-red-400 border border-red-500/30'
|
||||
: 'text-[var(--text-tertiary)] hover:text-red-400'}"
|
||||
>
|
||||
Fail
|
||||
</button>
|
||||
<button
|
||||
onclick={() => handleRemoveCriterion(criterion.id)}
|
||||
class="p-0.5 text-[var(--text-tertiary)] hover:text-red-400 transition-colors ml-1"
|
||||
aria-label="Remove criterion"
|
||||
>
|
||||
<svg
|
||||
class="w-3.5 h-3.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
class="flex flex-col gap-2 p-4 bg-[var(--bg-secondary)] rounded-lg border border-[var(--border-color)]"
|
||||
>
|
||||
<p class="text-sm text-[var(--text-tertiary)]">No criteria yet.</p>
|
||||
{#if contextContent !== null}
|
||||
<button
|
||||
onclick={handleExtractCriteria}
|
||||
class="self-start text-xs px-3 py-1.5 text-[var(--text-secondary)] hover:text-[var(--text-primary)] bg-[var(--bg-primary)] hover:bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded-lg transition-colors"
|
||||
>
|
||||
Extract from CONTEXT.md
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Add criterion -->
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newCriterionText}
|
||||
onkeydown={(e) => e.key === "Enter" && handleAddCriterion()}
|
||||
class="flex-1 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg px-3 py-1.5 text-sm text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent-primary)]"
|
||||
placeholder="Add criterion..."
|
||||
/>
|
||||
<button
|
||||
onclick={handleAddCriterion}
|
||||
disabled={!newCriterionText.trim()}
|
||||
class="px-3 py-1.5 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] bg-[var(--bg-secondary)] hover:bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Verification actions -->
|
||||
{#if isWaitingForVerify}
|
||||
<div
|
||||
class="flex items-center gap-3 p-4 bg-[var(--bg-secondary)] rounded-lg border border-[var(--border-color)]"
|
||||
>
|
||||
<div class="text-xl animate-spin">⚙️</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-[var(--text-primary)]">Claude is verifying...</p>
|
||||
<p class="text-xs text-[var(--text-secondary)]">
|
||||
Writing VERIFY.md — will auto-detect when complete.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else if verifyContent !== null}
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs font-medium text-green-400">✓ VERIFY.md generated</span>
|
||||
<button
|
||||
onclick={tryLoadVerifyFile}
|
||||
class="text-xs text-[var(--text-tertiary)] hover:text-[var(--text-secondary)] transition-colors"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
value={verifyContent}
|
||||
readonly
|
||||
rows={8}
|
||||
class="w-full bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg p-3 font-mono text-xs text-[var(--text-secondary)] resize-y focus:outline-none leading-relaxed"
|
||||
></textarea>
|
||||
</div>
|
||||
{:else if isLoadingVerify}
|
||||
<p class="text-xs text-[var(--text-tertiary)]">Checking for VERIFY.md...</p>
|
||||
{:else}
|
||||
<div class="flex gap-2">
|
||||
{#if !$workflowState.quickMode}
|
||||
<button
|
||||
onclick={handleRunVerification}
|
||||
class="px-4 py-1.5 text-sm btn-trans-gradient rounded-lg transition-colors"
|
||||
>
|
||||
Run Verification
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
onclick={tryLoadVerifyFile}
|
||||
class="px-3 py-1.5 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] bg-[var(--bg-secondary)] hover:bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded-lg transition-colors"
|
||||
>
|
||||
Check for VERIFY.md
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if $workflowState.verify.verificationComplete}
|
||||
<div
|
||||
class="flex items-center gap-2 p-3 bg-green-500/10 rounded-lg border border-green-500/20"
|
||||
>
|
||||
<span class="text-green-400 text-sm">✓ Verification complete</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Footer navigation -->
|
||||
<div
|
||||
class="flex items-center justify-between p-4 pt-2 border-t border-[var(--border-color)] gap-3"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
onclick={handleBack}
|
||||
disabled={!canGoBack($workflowState.currentPhase)}
|
||||
class="px-3 py-1.5 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] bg-[var(--bg-secondary)] hover:bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded-lg transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
← Back
|
||||
</button>
|
||||
<button
|
||||
onclick={handleReset}
|
||||
class="px-3 py-1.5 text-sm text-[var(--text-secondary)] hover:text-red-400 bg-[var(--bg-secondary)] hover:bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded-lg transition-colors"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Phase-specific primary action -->
|
||||
{#if $workflowState.currentPhase === 2 && !$workflowState.plan.tasksApproved}
|
||||
<button
|
||||
onclick={handleApprovePlan}
|
||||
disabled={!$workflowState.quickMode && (!$prdIsLoaded || $prdTasks.length === 0)}
|
||||
class="px-4 py-1.5 text-sm btn-trans-gradient rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Approve Plan
|
||||
</button>
|
||||
{:else if $workflowState.currentPhase === 3 && !$workflowState.execute.completed}
|
||||
<button
|
||||
onclick={handleCompleteExecution}
|
||||
class="px-4 py-1.5 text-sm btn-trans-gradient rounded-lg transition-colors"
|
||||
>
|
||||
{allTasksDone() ? "Mark Complete ✓" : "Mark Complete (Override)"}
|
||||
</button>
|
||||
{:else if $workflowState.currentPhase === 4 && !$workflowState.verify.verificationComplete && $workflowState.quickMode}
|
||||
<button
|
||||
onclick={() => {
|
||||
workflowStore.completeVerification("Manual verification complete.");
|
||||
void workflowStore.saveState(workingDirectory);
|
||||
}}
|
||||
class="px-4 py-1.5 text-sm btn-trans-gradient rounded-lg transition-colors"
|
||||
>
|
||||
Complete Workflow
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- Advance / Close -->
|
||||
<button
|
||||
onclick={handleAdvance}
|
||||
disabled={!canAdvancePhase($workflowState)}
|
||||
class="px-4 py-1.5 text-sm btn-trans-gradient rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{$workflowState.currentPhase === 4 ? "Close" : "Next →"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
[role="dialog"] {
|
||||
animation: slideIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95) translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
textarea {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border-color) transparent;
|
||||
}
|
||||
|
||||
textarea::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
textarea::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
textarea::-webkit-scrollbar-thumb {
|
||||
background-color: var(--border-color);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
textarea::-webkit-scrollbar-thumb:hover {
|
||||
background-color: var(--accent-primary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,110 @@
|
||||
<script lang="ts">
|
||||
import { characterState } from "$lib/stores/character";
|
||||
import type { WorkspaceHookInfo } from "$lib/types/messages";
|
||||
|
||||
interface Props {
|
||||
hookInfo: WorkspaceHookInfo;
|
||||
onTrust: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const { hookInfo, onTrust, onCancel }: Props = $props();
|
||||
|
||||
$effect(() => {
|
||||
characterState.setState("permission");
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center" onclick={onCancel}>
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg p-6 max-w-md w-full mx-4 shadow-xl"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<svg
|
||||
class="w-6 h-6 text-yellow-400 flex-shrink-0"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
<h2 class="text-lg font-semibold text-[var(--text-primary)]">Workspace Trust Required</h2>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-[var(--text-secondary)] mb-4">
|
||||
This workspace contains configuration that can execute code on your system. Review what was
|
||||
found before connecting.
|
||||
</p>
|
||||
|
||||
<div class="space-y-3 mb-4">
|
||||
{#if hookInfo.hook_types.length > 0}
|
||||
<div class="bg-[var(--bg-primary)] rounded-md p-3">
|
||||
<p class="text-xs text-[var(--text-secondary)] mb-2 font-medium">
|
||||
Hooks (run shell commands automatically):
|
||||
</p>
|
||||
<ul class="space-y-1">
|
||||
{#each hookInfo.hook_types as hookType (hookType)}
|
||||
<li class="text-sm text-yellow-400 font-mono">• {hookType}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if hookInfo.mcp_servers.length > 0}
|
||||
<div class="bg-[var(--bg-primary)] rounded-md p-3">
|
||||
<p class="text-xs text-[var(--text-secondary)] mb-2 font-medium">
|
||||
MCP servers (run as local processes with system access):
|
||||
</p>
|
||||
<ul class="space-y-1">
|
||||
{#each hookInfo.mcp_servers as server (server)}
|
||||
<li class="text-sm text-yellow-400 font-mono">• {server}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if hookInfo.custom_commands.length > 0}
|
||||
<div class="bg-[var(--bg-primary)] rounded-md p-3">
|
||||
<p class="text-xs text-[var(--text-secondary)] mb-2 font-medium">
|
||||
Custom slash commands (can execute arbitrary instructions):
|
||||
</p>
|
||||
<ul class="space-y-1">
|
||||
{#each hookInfo.custom_commands as cmd (cmd)}
|
||||
<li class="text-sm text-yellow-400 font-mono">• /{cmd}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-[var(--text-secondary)] mb-6">
|
||||
Only connect to workspaces you trust. Trusting this workspace will remember your choice for
|
||||
future sessions.
|
||||
</p>
|
||||
|
||||
<div class="flex gap-3 justify-end">
|
||||
<button
|
||||
onclick={onCancel}
|
||||
class="px-4 py-2 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] border border-[var(--border-color)] rounded-md transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onclick={onTrust}
|
||||
class="px-4 py-2 text-sm bg-yellow-500/20 hover:bg-yellow-500/30 text-yellow-400 border border-yellow-500/30 rounded-md transition-colors"
|
||||
>
|
||||
Trust and Connect
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,48 @@
|
||||
<h3 class="text-lg font-semibold text-[var(--text-primary)] mb-4">Changelog</h3>
|
||||
|
||||
<div class="space-y-4 text-sm text-[var(--text-secondary)]">
|
||||
<p>
|
||||
The full version history for Hikari Desktop is available in the <strong>Changelog</strong>
|
||||
panel — open it from the menu to browse release notes for every version, fetched directly from the
|
||||
releases page.
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<h4 class="font-medium text-[var(--text-primary)] mb-2">Recent Highlights</h4>
|
||||
<ul class="space-y-2">
|
||||
<li>
|
||||
<strong>Guided Workflow</strong> — four-phase project orchestration (Discuss → Plan → Execute
|
||||
→ Verify) with Quick Mode and cross-panel navigation
|
||||
</li>
|
||||
<li>
|
||||
<strong>Wave-based Task Loop</strong> — parallel task execution with dependency tracking, concurrency
|
||||
control, and auto-commit support
|
||||
</li>
|
||||
<li>
|
||||
<strong>PRD Creator</strong> — AI-assisted task list generation from plain-English project descriptions
|
||||
</li>
|
||||
<li>
|
||||
<strong>Community Themes</strong> — Dracula, Catppuccin, Nord, Solarized, Gruvbox, and Rosé Pine
|
||||
presets
|
||||
</li>
|
||||
<li>
|
||||
<strong>Project Context Panel</strong> — persistent PROJECT.md / REQUIREMENTS.md / ROADMAP.md
|
||||
/ STATE.md context engineering
|
||||
</li>
|
||||
<li>
|
||||
<strong>Codebase Mapper</strong> — auto-generated CODEBASE.md architectural summaries
|
||||
</li>
|
||||
<li>
|
||||
<strong>Embedded Docs</strong> — this help panel with full documentation and keyboard navigation
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="pt-2 border-t border-[var(--border-color)]">
|
||||
<p>
|
||||
To see the complete changelog with all versions and patch notes, open <strong
|
||||
>Changelog</strong
|
||||
> from the main menu.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,105 @@
|
||||
<h3 class="text-lg font-semibold text-[var(--text-primary)] mb-4">Chat & Input</h3>
|
||||
|
||||
<div class="space-y-4 text-sm text-[var(--text-secondary)]">
|
||||
<div>
|
||||
<h4 class="font-medium text-[var(--text-primary)] mb-2">Sending Messages</h4>
|
||||
<ul class="space-y-1">
|
||||
<li>• Press <kbd class="kbd">Enter</kbd> to send your message</li>
|
||||
<li>• Press <kbd class="kbd">Shift+Enter</kbd> to insert a newline</li>
|
||||
<li>• Paste images directly into the input — Hikari can see and discuss them</li>
|
||||
<li>• Paste code or multi-line text — Hikari will handle it as context</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 class="font-medium text-[var(--text-primary)] mb-2">Quick Actions</h4>
|
||||
<p>
|
||||
Click the <strong>⚡ lightning bolt</strong> icon in the input toolbar to open a panel of predefined
|
||||
prompt shortcuts. These let you trigger common tasks (explain code, write tests, refactor, and more)
|
||||
with a single click instead of typing.
|
||||
</p>
|
||||
<p class="mt-2">
|
||||
Quick actions send a pre-written prompt to Hikari immediately — no extra typing needed.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 class="font-medium text-[var(--text-primary)] mb-2">Snippet Library</h4>
|
||||
<p>
|
||||
Click the <strong>snippet icon</strong> in the input toolbar to open your personal snippet library
|
||||
— a collection of reusable text blocks you can insert into the input with one click.
|
||||
</p>
|
||||
<ul class="space-y-1 mt-2">
|
||||
<li>• Snippets are organised by category</li>
|
||||
<li>• Click a snippet to insert it at the cursor position</li>
|
||||
<li>• Default snippets are provided; add your own to build a personal library</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 class="font-medium text-[var(--text-primary)] mb-2">Clipboard History</h4>
|
||||
<p>
|
||||
Click the <strong>clipboard icon</strong> in the input toolbar to browse your clipboard history
|
||||
— a list of text you've previously copied or pasted during the session.
|
||||
</p>
|
||||
<ul class="space-y-1 mt-2">
|
||||
<li>• Longer text blocks and code snippets are captured automatically</li>
|
||||
<li>• Click an entry to insert it into the current input</li>
|
||||
<li>• Filter by language for code-specific entries</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 class="font-medium text-[var(--text-primary)] mb-2">Drafts</h4>
|
||||
<p>
|
||||
Click the <strong>draft icon</strong> in the input toolbar to open the Drafts panel — a place to
|
||||
save and retrieve partially written messages.
|
||||
</p>
|
||||
<ul class="space-y-1 mt-2">
|
||||
<li>• Save the current input as a draft for later</li>
|
||||
<li>• Click a saved draft to restore it to the input</li>
|
||||
<li>• Drafts persist across sessions so you can pick up where you left off</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 class="font-medium text-[var(--text-primary)] mb-2">Compact Mode</h4>
|
||||
<p>
|
||||
Compact mode shrinks the app to a small <strong>280×400 px</strong> floating window — useful for
|
||||
keeping Hikari visible whilst working in another application.
|
||||
</p>
|
||||
<ul class="space-y-1 mt-2">
|
||||
<li>
|
||||
• Toggle via <strong>Compact Mode</strong> in the menu or
|
||||
<kbd class="kbd">Ctrl+Shift+M</kbd>
|
||||
</li>
|
||||
<li>• Press the same shortcut again (or use the menu) to return to normal size</li>
|
||||
<li>• Your previous window size is restored automatically when you exit compact mode</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 class="font-medium text-[var(--text-primary)] mb-2">Discord Rich Presence</h4>
|
||||
<p>
|
||||
Hikari Desktop can share your activity in Discord — showing the model you're using and session
|
||||
duration as your Rich Presence status.
|
||||
</p>
|
||||
<p class="mt-2">
|
||||
Toggle this in Settings (<kbd class="kbd">Ctrl+,</kbd>) under the Discord section. Disable it
|
||||
if you'd rather keep your coding sessions private.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
kbd.kbd {
|
||||
display: inline-block;
|
||||
padding: 0.1rem 0.35rem;
|
||||
font-size: 0.75rem;
|
||||
font-family: monospace;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 3px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,79 @@
|
||||
<h3 class="text-lg font-semibold text-[var(--text-primary)] mb-4">File Editor</h3>
|
||||
|
||||
<div class="space-y-4 text-sm text-[var(--text-secondary)]">
|
||||
<p>
|
||||
The built-in file editor lets you read and edit project files directly in Hikari Desktop,
|
||||
alongside your conversation. Open it via <strong>File Editor</strong> in the menu or press
|
||||
<kbd class="kbd">Ctrl+E</kbd> (requires an active connection).
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<h4 class="font-medium text-[var(--text-primary)] mb-2">File Browser</h4>
|
||||
<p>
|
||||
The file browser panel (toggle with <kbd class="kbd">Ctrl+B</kbd>) shows your working
|
||||
directory tree. From it you can:
|
||||
</p>
|
||||
<ul class="space-y-1 mt-2">
|
||||
<li>• Click a file to open it in a new editor tab</li>
|
||||
<li>• Right-click for context menu options (rename, delete)</li>
|
||||
<li>• Press <kbd class="kbd">Ctrl+N</kbd> to create a new file</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 class="font-medium text-[var(--text-primary)] mb-2">Editor Tabs</h4>
|
||||
<p>Each open file gets its own tab. You can have multiple files open at once.</p>
|
||||
<ul class="space-y-1 mt-2">
|
||||
<li>• Click a tab to switch to that file</li>
|
||||
<li>• Click <strong>×</strong> on a tab to close it (<kbd class="kbd">Ctrl+W</kbd>)</li>
|
||||
<li>• Unsaved changes are indicated with a dot on the tab</li>
|
||||
<li>• Press <kbd class="kbd">Ctrl+S</kbd> to save the current file</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 class="font-medium text-[var(--text-primary)] mb-2">Syntax Highlighting</h4>
|
||||
<p>
|
||||
The editor automatically applies syntax highlighting for a wide range of languages, including:
|
||||
</p>
|
||||
<p class="mt-1">
|
||||
JavaScript · TypeScript · Python · Rust · Go · Java · C++ · HTML · CSS · JSON · YAML ·
|
||||
Markdown · SQL · Shell · PHP · Ruby · Swift · R · TOML · Dockerfile · PowerShell
|
||||
</p>
|
||||
<p class="mt-2">
|
||||
The editor theme follows your app theme — dark themes use dark editor backgrounds.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 class="font-medium text-[var(--text-primary)] mb-2">Editor Features</h4>
|
||||
<ul class="space-y-1">
|
||||
<li>• Line numbers, bracket matching, and code folding</li>
|
||||
<li>• Search and replace (<kbd class="kbd">Ctrl+F</kbd> in most editors)</li>
|
||||
<li>• Right-click context menu for common operations</li>
|
||||
<li>• Full CodeMirror keybindings</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 class="font-medium text-[var(--text-primary)] mb-2">Working with Hikari</h4>
|
||||
<p>
|
||||
Hikari can read and edit files directly through her tool access — you don't need the file
|
||||
editor open for her to work on files. The editor is most useful for reviewing changes she's
|
||||
made, manually editing files alongside the conversation, or browsing the project structure.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
kbd.kbd {
|
||||
display: inline-block;
|
||||
padding: 0.1rem 0.35rem;
|
||||
font-size: 0.75rem;
|
||||
font-family: monospace;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 3px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,61 @@
|
||||
<h3 class="text-lg font-semibold text-[var(--text-primary)] mb-4">Getting Started</h3>
|
||||
|
||||
<div class="space-y-4 text-sm text-[var(--text-secondary)]">
|
||||
<div>
|
||||
<h4 class="font-medium text-[var(--text-primary)] mb-2">1. Enter your API key</h4>
|
||||
<p>
|
||||
Open Settings (<kbd class="kbd">Ctrl+,</kbd>) and paste your Anthropic API key. Keys are
|
||||
stored locally on your device and never sent anywhere except the Anthropic API.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 class="font-medium text-[var(--text-primary)] mb-2">2. Set a working directory</h4>
|
||||
<p>
|
||||
Click the folder icon in the connection bar and choose the project directory you want Hikari
|
||||
to work in. This gives her context for your files and project structure.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 class="font-medium text-[var(--text-primary)] mb-2">3. Connect</h4>
|
||||
<p>
|
||||
Click <strong>Connect</strong> to start a Claude Code session. The status indicator will turn green
|
||||
when you're connected.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 class="font-medium text-[var(--text-primary)] mb-2">4. Start chatting</h4>
|
||||
<p>
|
||||
Type a message and press <kbd class="kbd">Enter</kbd> (or <kbd class="kbd">Ctrl+Enter</kbd>)
|
||||
to send. Hikari can read, write, and edit files; run terminal commands; search code; fetch web
|
||||
content; and connect to external tools via MCP.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="pt-2 border-t border-[var(--border-color)]">
|
||||
<h4 class="font-medium text-[var(--text-primary)] mb-2">Key Features</h4>
|
||||
<ul class="space-y-1.5">
|
||||
<li>🗂️ <strong>File Management</strong> — read, write, and edit files in your project</li>
|
||||
<li>💻 <strong>Terminal Access</strong> — execute commands and run scripts</li>
|
||||
<li>🔍 <strong>Code Search</strong> — find files and search through code</li>
|
||||
<li>🌐 <strong>Web Access</strong> — fetch information from the web</li>
|
||||
<li>📊 <strong>MCP Servers</strong> — connect external tools via Model Context Protocol</li>
|
||||
<li>📁 <strong>Multi-tab Support</strong> — work on multiple conversations simultaneously</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
kbd.kbd {
|
||||
display: inline-block;
|
||||
padding: 0.1rem 0.35rem;
|
||||
font-size: 0.75rem;
|
||||
font-family: monospace;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 3px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,61 @@
|
||||
<h3 class="text-lg font-semibold text-[var(--text-primary)] mb-4">Git & Version Control</h3>
|
||||
|
||||
<div class="space-y-4 text-sm text-[var(--text-secondary)]">
|
||||
<p>
|
||||
The Git panel gives you a visual interface for git operations directly inside Hikari Desktop.
|
||||
Open it via <strong>Git Panel</strong> in the menu.
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<h4 class="font-medium text-[var(--text-primary)] mb-2">Changes Tab</h4>
|
||||
<p>Shows all modified, staged, and untracked files in your working directory.</p>
|
||||
<ul class="space-y-1 mt-2">
|
||||
<li>• Click a file to view its diff</li>
|
||||
<li>• Stage individual files or all changes at once</li>
|
||||
<li>• Unstage files you don't want in the next commit</li>
|
||||
<li>• Discard changes to revert a file to its last committed state</li>
|
||||
<li>• Enter a commit message and click <strong>Commit</strong> to create a commit</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 class="font-medium text-[var(--text-primary)] mb-2">Branches Tab</h4>
|
||||
<p>Manage your local and remote branches.</p>
|
||||
<ul class="space-y-1 mt-2">
|
||||
<li>• View all local and remote branches</li>
|
||||
<li>• Click a branch to check it out</li>
|
||||
<li>• Create new branches from the current HEAD</li>
|
||||
<li>• See how many commits your branch is ahead/behind its remote</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 class="font-medium text-[var(--text-primary)] mb-2">History Tab</h4>
|
||||
<p>Browse your commit history with author names, dates, and commit messages.</p>
|
||||
<ul class="space-y-1 mt-2">
|
||||
<li>• Scrollable log of recent commits</li>
|
||||
<li>• Abbreviated commit hashes for easy reference</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 class="font-medium text-[var(--text-primary)] mb-2">Sync Actions</h4>
|
||||
<p>
|
||||
Use the quick action buttons to fetch, pull, and push changes to your remote — all without
|
||||
leaving Hikari Desktop.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 class="font-medium text-[var(--text-primary)] mb-2">Working with Hikari</h4>
|
||||
<p>
|
||||
Hikari can also run git commands for you through the terminal — just ask her to commit, push,
|
||||
create a branch, or check the status. The Git panel is useful for reviewing changes visually
|
||||
and for quick operations you want to do yourself.
|
||||
</p>
|
||||
<p class="mt-2">
|
||||
The <strong>Task Loop auto-commit</strong> feature can automatically commit after each completed
|
||||
task — configure it via the ⚙ icon in the Task Loop panel.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,130 @@
|
||||
<h3 class="text-lg font-semibold text-[var(--text-primary)] mb-4">Keyboard Shortcuts</h3>
|
||||
|
||||
<div class="space-y-4 text-sm">
|
||||
<div>
|
||||
<h4 class="font-medium text-[var(--text-primary)] mb-2">General</h4>
|
||||
<table class="w-full">
|
||||
<tbody class="divide-y divide-[var(--border-color)]">
|
||||
<tr>
|
||||
<td class="py-1.5 pr-4"><kbd class="kbd">?</kbd></td>
|
||||
<td class="py-1.5 text-[var(--text-secondary)]">Open this help panel</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="py-1.5 pr-4"><kbd class="kbd">Escape</kbd></td>
|
||||
<td class="py-1.5 text-[var(--text-secondary)]">Close the active panel</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="py-1.5 pr-4"><kbd class="kbd">Ctrl+,</kbd></td>
|
||||
<td class="py-1.5 text-[var(--text-secondary)]">Open settings</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="py-1.5 pr-4"><kbd class="kbd">Ctrl+L</kbd></td>
|
||||
<td class="py-1.5 text-[var(--text-secondary)]">Clear the terminal output</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="py-1.5 pr-4"><kbd class="kbd">Ctrl+C</kbd></td>
|
||||
<td class="py-1.5 text-[var(--text-secondary)]"
|
||||
>Interrupt Claude (when no text is selected)</td
|
||||
>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 class="font-medium text-[var(--text-primary)] mb-2">Font Size</h4>
|
||||
<table class="w-full">
|
||||
<tbody class="divide-y divide-[var(--border-color)]">
|
||||
<tr>
|
||||
<td class="py-1.5 pr-4"><kbd class="kbd">Ctrl++</kbd> / <kbd class="kbd">Ctrl+=</kbd></td>
|
||||
<td class="py-1.5 text-[var(--text-secondary)]">Increase font size</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="py-1.5 pr-4"><kbd class="kbd">Ctrl+-</kbd></td>
|
||||
<td class="py-1.5 text-[var(--text-secondary)]">Decrease font size</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="py-1.5 pr-4"><kbd class="kbd">Ctrl+0</kbd></td>
|
||||
<td class="py-1.5 text-[var(--text-secondary)]">Reset font size to default</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 class="font-medium text-[var(--text-primary)] mb-2">Display Modes</h4>
|
||||
<table class="w-full">
|
||||
<tbody class="divide-y divide-[var(--border-color)]">
|
||||
<tr>
|
||||
<td class="py-1.5 pr-4"><kbd class="kbd">Ctrl+Shift+S</kbd></td>
|
||||
<td class="py-1.5 text-[var(--text-secondary)]">Toggle streamer mode</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="py-1.5 pr-4"><kbd class="kbd">Ctrl+Shift+M</kbd></td>
|
||||
<td class="py-1.5 text-[var(--text-secondary)]">Toggle compact mode</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="py-1.5 pr-4"><kbd class="kbd">Ctrl+`</kbd></td>
|
||||
<td class="py-1.5 text-[var(--text-secondary)]">Toggle debug console</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 class="font-medium text-[var(--text-primary)] mb-2">Code Editor</h4>
|
||||
<table class="w-full">
|
||||
<tbody class="divide-y divide-[var(--border-color)]">
|
||||
<tr>
|
||||
<td class="py-1.5 pr-4"><kbd class="kbd">Ctrl+E</kbd></td>
|
||||
<td class="py-1.5 text-[var(--text-secondary)]">Toggle editor panel (when connected)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="py-1.5 pr-4"><kbd class="kbd">Ctrl+B</kbd></td>
|
||||
<td class="py-1.5 text-[var(--text-secondary)]">Toggle file browser (in editor)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="py-1.5 pr-4"><kbd class="kbd">Ctrl+S</kbd></td>
|
||||
<td class="py-1.5 text-[var(--text-secondary)]">Save current file (in editor)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="py-1.5 pr-4"><kbd class="kbd">Ctrl+W</kbd></td>
|
||||
<td class="py-1.5 text-[var(--text-secondary)]">Close current tab (in editor)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="py-1.5 pr-4"><kbd class="kbd">Ctrl+N</kbd></td>
|
||||
<td class="py-1.5 text-[var(--text-secondary)]">New file (in editor)</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 class="font-medium text-[var(--text-primary)] mb-2">Help Panel Navigation</h4>
|
||||
<table class="w-full">
|
||||
<tbody class="divide-y divide-[var(--border-color)]">
|
||||
<tr>
|
||||
<td class="py-1.5 pr-4"><kbd class="kbd">←</kbd> / <kbd class="kbd">↑</kbd></td>
|
||||
<td class="py-1.5 text-[var(--text-secondary)]">Previous page</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="py-1.5 pr-4"><kbd class="kbd">→</kbd> / <kbd class="kbd">↓</kbd></td>
|
||||
<td class="py-1.5 text-[var(--text-secondary)]">Next page</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
kbd.kbd {
|
||||
display: inline-block;
|
||||
padding: 0.1rem 0.35rem;
|
||||
font-size: 0.75rem;
|
||||
font-family: monospace;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 3px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,72 @@
|
||||
<h3 class="text-lg font-semibold text-[var(--text-primary)] mb-4">Model & API Configuration</h3>
|
||||
|
||||
<div class="space-y-4 text-sm text-[var(--text-secondary)]">
|
||||
<div>
|
||||
<h4 class="font-medium text-[var(--text-primary)] mb-2">API Key</h4>
|
||||
<p>
|
||||
Enter your Anthropic API key in Settings (<kbd class="kbd">Ctrl+,</kbd>). The key is stored
|
||||
locally on your device and used only to authenticate requests to the Anthropic API.
|
||||
</p>
|
||||
<p class="mt-2">
|
||||
Get your API key at <strong>console.anthropic.com</strong>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 class="font-medium text-[var(--text-primary)] mb-2">Model Selection</h4>
|
||||
<p class="mb-2">Choose from available Claude models:</p>
|
||||
<ul class="space-y-1.5">
|
||||
<li>
|
||||
<strong>claude-opus-4-6</strong> — most capable, highest quality; best for complex tasks
|
||||
</li>
|
||||
<li>
|
||||
<strong>claude-sonnet-4-6</strong> — balanced speed and quality
|
||||
<span class="text-[var(--accent-primary)]">(recommended)</span>
|
||||
</li>
|
||||
<li>
|
||||
<strong>claude-haiku-4-5</strong> — fastest and most cost-efficient; good for quick tasks
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 class="font-medium text-[var(--text-primary)] mb-2">Custom Instructions</h4>
|
||||
<p>
|
||||
Add persistent instructions in Settings that are prepended to every conversation. Use this to
|
||||
set coding preferences, project conventions, or communication style.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 class="font-medium text-[var(--text-primary)] mb-2">MCP Servers</h4>
|
||||
<p>
|
||||
Model Context Protocol (MCP) servers extend Hikari's capabilities with external tools and data
|
||||
sources — databases, APIs, version control systems, and more.
|
||||
</p>
|
||||
<p class="mt-2">
|
||||
Open <strong>MCP Servers</strong> from the menu to add and manage server configurations. Each server
|
||||
is defined with a command and optional arguments.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 class="font-medium text-[var(--text-primary)] mb-2">Permission Mode</h4>
|
||||
<p>
|
||||
Controls how Hikari asks for tool use permissions. Choose between asking every time,
|
||||
auto-approving trusted tools, or running in a more restricted mode.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
kbd.kbd {
|
||||
display: inline-block;
|
||||
padding: 0.1rem 0.35rem;
|
||||
font-size: 0.75rem;
|
||||
font-family: monospace;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 3px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,128 @@
|
||||
<h3 class="text-lg font-semibold text-[var(--text-primary)] mb-4">Panels & Tools</h3>
|
||||
|
||||
<div class="space-y-4 text-sm text-[var(--text-secondary)]">
|
||||
<div>
|
||||
<h4 class="font-medium text-[var(--text-primary)] mb-2">Agent Monitor</h4>
|
||||
<p>
|
||||
Displays a live dashboard of all Claude Code agents running during a session — useful when
|
||||
using the Task Loop or any feature that spawns subagents.
|
||||
</p>
|
||||
<ul class="space-y-1 mt-2">
|
||||
<li>• Hierarchical tree view showing parent agents and their subagents</li>
|
||||
<li>
|
||||
• Status indicators: <span class="text-blue-400">● running</span>,
|
||||
<span class="text-green-400">● completed</span>,
|
||||
<span class="text-red-400">● errored</span>
|
||||
</li>
|
||||
<li>• Live duration timers for running agents</li>
|
||||
<li>• Kill all / clear finished buttons</li>
|
||||
<li>• A badge on the menu icon shows the count of active agents</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 class="font-medium text-[var(--text-primary)] mb-2">To-Do List</h4>
|
||||
<p>
|
||||
Shows Hikari's internal todo list in real time — the same tasks she tracks using the
|
||||
<code class="code">TodoWrite</code> tool during complex multi-step work.
|
||||
</p>
|
||||
<ul class="space-y-1 mt-2">
|
||||
<li>• See pending, in-progress, and completed tasks at a glance</li>
|
||||
<li>• Progress bar shows overall completion percentage</li>
|
||||
<li>• Updates live as Hikari works through her plan</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 class="font-medium text-[var(--text-primary)] mb-2">Profile</h4>
|
||||
<p>Your personal profile within Hikari Desktop, with lifetime stats and sharing.</p>
|
||||
<ul class="space-y-1 mt-2">
|
||||
<li>• Edit your display name and bio</li>
|
||||
<li>• Upload a profile avatar</li>
|
||||
<li>
|
||||
• View lifetime stats: messages sent, tokens used, code blocks generated, files
|
||||
created/edited, total spend
|
||||
</li>
|
||||
<li>• Track achievement progress</li>
|
||||
<li>• Generate a shareable profile card image</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 class="font-medium text-[var(--text-primary)] mb-2">Achievements</h4>
|
||||
<p>
|
||||
Hikari Desktop includes a fun achievement system that unlocks as you use the app — milestones
|
||||
like your first message, first code block, staying up late, and more.
|
||||
</p>
|
||||
<p class="mt-2">
|
||||
Open <strong>Achievements</strong> from the menu to see your progress. Newly unlocked achievements
|
||||
show a badge count on the menu item.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 class="font-medium text-[var(--text-primary)] mb-2">Plugins</h4>
|
||||
<p>
|
||||
Plugins extend Hikari's capabilities with community-built additions. Open
|
||||
<strong>Plugins</strong> from the menu to manage them.
|
||||
</p>
|
||||
<ul class="space-y-1 mt-2">
|
||||
<li>• Install plugins from named sources or custom marketplace URLs</li>
|
||||
<li>• Enable or disable individual plugins without uninstalling them</li>
|
||||
<li>• Update plugins to their latest versions</li>
|
||||
<li>• Add custom plugin marketplace sources (GitHub-hosted)</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 class="font-medium text-[var(--text-primary)] mb-2">Debug Console</h4>
|
||||
<p>
|
||||
A developer-facing log console that captures frontend events, errors, and debug output. Open
|
||||
it via <strong>Debug Console</strong> in the menu or press
|
||||
<kbd class="kbd">Ctrl+`</kbd>.
|
||||
</p>
|
||||
<p class="mt-2">
|
||||
Useful when troubleshooting unexpected behaviour or reporting issues. Filter by log level
|
||||
(info, warn, error, debug).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 class="font-medium text-[var(--text-primary)] mb-2">Workspace Trust</h4>
|
||||
<p>
|
||||
When you connect to a working directory, Hikari scans it for potentially powerful
|
||||
configurations and may display a trust prompt before proceeding. This includes:
|
||||
</p>
|
||||
<ul class="space-y-1 mt-2">
|
||||
<li>• <strong>Hooks</strong> — shell commands that run automatically during sessions</li>
|
||||
<li>• <strong>MCP servers</strong> — local processes with system-level access</li>
|
||||
<li>• <strong>Custom slash commands</strong> — instructions that execute at invocation</li>
|
||||
</ul>
|
||||
<p class="mt-2">
|
||||
Review each item carefully. Click <strong>Trust & Connect</strong> to proceed or
|
||||
<strong>Cancel</strong> to abort. Trusted workspaces remember your decision for future sessions.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
kbd.kbd {
|
||||
display: inline-block;
|
||||
padding: 0.1rem 0.35rem;
|
||||
font-size: 0.75rem;
|
||||
font-family: monospace;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 3px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
code.code {
|
||||
font-family: monospace;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 3px;
|
||||
padding: 0.05rem 0.3rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,80 @@
|
||||
<h3 class="text-lg font-semibold text-[var(--text-primary)] mb-4">Session Management</h3>
|
||||
|
||||
<div class="space-y-4 text-sm text-[var(--text-secondary)]">
|
||||
<div>
|
||||
<h4 class="font-medium text-[var(--text-primary)] mb-2">Multiple Conversations</h4>
|
||||
<p>
|
||||
Use tabs at the top of the chat area to manage multiple simultaneous sessions with Hikari.
|
||||
</p>
|
||||
<ul class="space-y-1 mt-2">
|
||||
<li>• Click <strong>+</strong> to open a new conversation tab</li>
|
||||
<li>• Click a tab to switch between active conversations</li>
|
||||
<li>• Click <strong>×</strong> on a tab to close that conversation</li>
|
||||
</ul>
|
||||
<p class="mt-2">
|
||||
Each tab runs its own independent Claude Code session with separate context and history.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 class="font-medium text-[var(--text-primary)] mb-2">Session History</h4>
|
||||
<p>
|
||||
Open <strong>Session History</strong> from the menu to browse and restore past conversations. Sessions
|
||||
are saved automatically and indexed by date.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 class="font-medium text-[var(--text-primary)] mb-2">Context Compaction</h4>
|
||||
<p>
|
||||
As conversations grow long, use <strong>Compact Conversation</strong> from the menu to summarise
|
||||
the history and free up context window space — without losing important information.
|
||||
</p>
|
||||
<p class="mt-2">
|
||||
<strong>Start Fresh with Context</strong> creates a brand-new session but carries over a summary
|
||||
of the previous conversation.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 class="font-medium text-[var(--text-primary)] mb-2">Tool Permissions</h4>
|
||||
<p>
|
||||
When Hikari needs to use a tool (file access, terminal, web fetch, etc.) she may ask for your
|
||||
approval first. You can:
|
||||
</p>
|
||||
<ul class="space-y-1 mt-2">
|
||||
<li>• <strong>Allow once</strong> — approve this single use</li>
|
||||
<li>• <strong>Allow for session</strong> — approve all uses of this tool this session</li>
|
||||
<li>• <strong>Deny</strong> — block the action</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 class="font-medium text-[var(--text-primary)] mb-2">Stats Panel</h4>
|
||||
<p>
|
||||
Open <strong>Stats</strong> from the menu to see real-time usage data: token counts, estimated cost,
|
||||
context window usage, and per-session totals.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 class="font-medium text-[var(--text-primary)] mb-2">Streamer Mode</h4>
|
||||
<p>
|
||||
Toggle streamer mode (<kbd class="kbd">Ctrl+Shift+S</kbd>) to redact your API key and other
|
||||
sensitive information from the display — useful for streaming or screen sharing.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
kbd.kbd {
|
||||
display: inline-block;
|
||||
padding: 0.1rem 0.35rem;
|
||||
font-size: 0.75rem;
|
||||
font-family: monospace;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 3px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,102 @@
|
||||
<h3 class="text-lg font-semibold text-[var(--text-primary)] mb-4">Task Loop & Automation</h3>
|
||||
|
||||
<div class="space-y-4 text-sm text-[var(--text-secondary)]">
|
||||
<p>
|
||||
Hikari Desktop includes a full project automation suite — open any of these tools from the menu.
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<h4 class="font-medium text-[var(--text-primary)] mb-2">Guided Workflow</h4>
|
||||
<p>A four-phase project workflow that orchestrates the other automation tools:</p>
|
||||
<ol class="space-y-1.5 mt-2 list-none">
|
||||
<li>
|
||||
<strong>1. Discuss</strong> — describe your project; Hikari writes a
|
||||
<code class="code">CONTEXT.md</code> file capturing requirements and goals
|
||||
</li>
|
||||
<li>
|
||||
<strong>2. Plan</strong> — use the PRD Creator to break the project into tasks
|
||||
</li>
|
||||
<li>
|
||||
<strong>3. Execute</strong> — run the Task Loop to complete all tasks automatically
|
||||
</li>
|
||||
<li>
|
||||
<strong>4. Verify</strong> — check acceptance criteria; Hikari writes
|
||||
<code class="code">VERIFY.md</code>
|
||||
</li>
|
||||
</ol>
|
||||
<p class="mt-2">
|
||||
Enable <strong>Quick Mode</strong> to skip Claude interactions in steps 1 and 4.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 class="font-medium text-[var(--text-primary)] mb-2">PRD Creator</h4>
|
||||
<p>
|
||||
Describe your project in plain English and Hikari will generate a structured
|
||||
<code class="code">hikari-tasks.json</code> task list. Tasks include titles, prompts, priorities,
|
||||
and dependency relationships.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 class="font-medium text-[var(--text-primary)] mb-2">Task Loop</h4>
|
||||
<p>
|
||||
Executes tasks from <code class="code">hikari-tasks.json</code> automatically. Features include:
|
||||
</p>
|
||||
<ul class="space-y-1 mt-2">
|
||||
<li>
|
||||
• <strong>Wave-based parallelism</strong> — independent tasks run concurrently; dependent tasks
|
||||
wait for their prerequisites
|
||||
</li>
|
||||
<li>
|
||||
• <strong>Concurrency control</strong> — adjust the <kbd class="kbd">[−] N [+]</kbd> control to
|
||||
limit how many tasks run in parallel
|
||||
</li>
|
||||
<li>• <strong>Blocked detection</strong> — tasks whose dependencies failed are marked ⊘</li>
|
||||
<li>
|
||||
• <strong>Auto-commit</strong> — optionally commit changes after each task completes, with a configurable
|
||||
prefix and optional SUMMARY.md
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 class="font-medium text-[var(--text-primary)] mb-2">Project Context Panel</h4>
|
||||
<p>
|
||||
Manage persistent context files that are injected into every conversation:
|
||||
<code class="code">PROJECT.md</code>, <code class="code">REQUIREMENTS.md</code>,
|
||||
<code class="code">ROADMAP.md</code>, and <code class="code">STATE.md</code>. These help
|
||||
Hikari maintain consistent project understanding across sessions.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 class="font-medium text-[var(--text-primary)] mb-2">Codebase Mapper</h4>
|
||||
<p>
|
||||
Generates a <code class="code">CODEBASE.md</code> architectural summary of your project — directory
|
||||
structure, key files, and their roles. Useful for onboarding Hikari to large codebases quickly.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
kbd.kbd {
|
||||
display: inline-block;
|
||||
padding: 0.1rem 0.35rem;
|
||||
font-size: 0.75rem;
|
||||
font-family: monospace;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 3px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
code.code {
|
||||
font-family: monospace;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 3px;
|
||||
padding: 0.05rem 0.3rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,85 @@
|
||||
<h3 class="text-lg font-semibold text-[var(--text-primary)] mb-4">Theme Customisation</h3>
|
||||
|
||||
<div class="space-y-4 text-sm text-[var(--text-secondary)]">
|
||||
<p>
|
||||
Open <strong>Settings</strong> (<kbd class="kbd">Ctrl+,</kbd>) and scroll to the Appearance
|
||||
section to customise your theme.
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<h4 class="font-medium text-[var(--text-primary)] mb-2">Built-in Themes</h4>
|
||||
<ul class="space-y-1">
|
||||
<li>• <strong>Default Dark</strong> — the classic dark Hikari look</li>
|
||||
<li>• <strong>Default Light</strong> — a bright, clean light theme</li>
|
||||
<li>• <strong>High Contrast</strong> — maximum contrast for accessibility</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 class="font-medium text-[var(--text-primary)] mb-2">Community Presets</h4>
|
||||
<ul class="space-y-1 columns-2">
|
||||
<li>• Dracula</li>
|
||||
<li>• Catppuccin Mocha</li>
|
||||
<li>• Catppuccin Latte</li>
|
||||
<li>• Nord</li>
|
||||
<li>• Solarized Dark</li>
|
||||
<li>• Solarized Light</li>
|
||||
<li>• Gruvbox Dark</li>
|
||||
<li>• Gruvbox Light</li>
|
||||
<li>• Rosé Pine</li>
|
||||
<li>• Rosé Pine Dawn</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 class="font-medium text-[var(--text-primary)] mb-2">Custom Colours</h4>
|
||||
<p>
|
||||
Select <strong>Custom</strong> from the theme dropdown to set individual colours for each UI element:
|
||||
</p>
|
||||
<ul class="space-y-1 mt-2">
|
||||
<li>• Text: primary, secondary, and tertiary levels</li>
|
||||
<li>• Backgrounds: primary, secondary, and header</li>
|
||||
<li>• Border colour</li>
|
||||
<li>• Accent and pink highlight colours</li>
|
||||
<li>• Trans flag stripe colours</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 class="font-medium text-[var(--text-primary)] mb-2">Custom Fonts</h4>
|
||||
<p>
|
||||
Upload a <code class="code">.ttf</code> or <code class="code">.otf</code> font file to apply a custom
|
||||
UI font across the entire app.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 class="font-medium text-[var(--text-primary)] mb-2">Background Image</h4>
|
||||
<p>
|
||||
Set a custom background image that renders behind the chat area. Adjust opacity to keep it
|
||||
subtle.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
kbd.kbd {
|
||||
display: inline-block;
|
||||
padding: 0.1rem 0.35rem;
|
||||
font-size: 0.75rem;
|
||||
font-family: monospace;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 3px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
code.code {
|
||||
font-family: monospace;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 3px;
|
||||
padding: 0.05rem 0.3rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,125 @@
|
||||
<h3 class="text-lg font-semibold text-[var(--text-primary)] mb-4">Troubleshooting</h3>
|
||||
|
||||
<div class="space-y-4 text-sm text-[var(--text-secondary)]">
|
||||
<div>
|
||||
<h4 class="font-medium text-[var(--text-primary)] mb-2">Can't Connect</h4>
|
||||
<ul class="space-y-1">
|
||||
<li>
|
||||
• Check that your API key is entered correctly in Settings (<kbd class="kbd">Ctrl+,</kbd>)
|
||||
</li>
|
||||
<li>
|
||||
• Ensure you have an active internet connection — Hikari needs to reach the Anthropic API
|
||||
</li>
|
||||
<li>• Try setting an explicit working directory rather than leaving it blank</li>
|
||||
<li>• Check the Debug Console (<kbd class="kbd">Ctrl+`</kbd>) for error details</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 class="font-medium text-[var(--text-primary)] mb-2">
|
||||
Hikari Seems Stuck or Stopped Responding
|
||||
</h4>
|
||||
<ul class="space-y-1">
|
||||
<li>
|
||||
• Press <kbd class="kbd">Ctrl+C</kbd> (when no text is selected) to interrupt the current process
|
||||
</li>
|
||||
<li>• If that doesn't work, disconnect and reconnect from the connection bar</li>
|
||||
<li>
|
||||
• Check the Stats panel for context window usage — if it's near 100%, use
|
||||
<strong>Compact Conversation</strong> to free up space
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 class="font-medium text-[var(--text-primary)] mb-2">API Errors or Rate Limits</h4>
|
||||
<ul class="space-y-1">
|
||||
<li>• Verify your API key is valid at <strong>console.anthropic.com</strong></li>
|
||||
<li>• Check that your account has sufficient API credits</li>
|
||||
<li>
|
||||
• If you hit rate limits frequently, consider switching to a faster/cheaper model in
|
||||
Settings
|
||||
</li>
|
||||
<li>• The Debug Console will show the specific error returned by the API</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 class="font-medium text-[var(--text-primary)] mb-2">Tool Permissions Blocking Work</h4>
|
||||
<ul class="space-y-1">
|
||||
<li>
|
||||
• When prompted, you can choose <strong>Allow for session</strong> to avoid repeated prompts
|
||||
</li>
|
||||
<li>• Adjust the permission mode in Settings to auto-approve trusted tools</li>
|
||||
<li>
|
||||
• If a tool call was denied by mistake, simply ask Hikari to try again — she'll prompt you
|
||||
once more
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 class="font-medium text-[var(--text-primary)] mb-2">
|
||||
Missing Features or Greyed-Out Buttons
|
||||
</h4>
|
||||
<ul class="space-y-1">
|
||||
<li>• Some features require an active connection — connect to a working directory first</li>
|
||||
<li>
|
||||
• The File Editor (<kbd class="kbd">Ctrl+E</kbd>) is only available when connected
|
||||
</li>
|
||||
<li>• The Agent Monitor shows activity only during Task Loop or multi-agent sessions</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 class="font-medium text-[var(--text-primary)] mb-2">Workspace Trust Prompt</h4>
|
||||
<p>
|
||||
If you see a trust prompt when connecting, it means Hikari detected hooks, MCP servers, or
|
||||
slash commands in your working directory. Review them carefully before clicking
|
||||
<strong>Trust & Connect</strong>. See the <strong>Panels & Tools</strong> page for more
|
||||
details.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 class="font-medium text-[var(--text-primary)] mb-2">Context Window Full</h4>
|
||||
<p>Watch the context bar in the Stats panel. When it approaches 100%:</p>
|
||||
<ul class="space-y-1 mt-2">
|
||||
<li>
|
||||
• Use <strong>Compact Conversation</strong> from the menu to summarise and compress the history
|
||||
</li>
|
||||
<li>
|
||||
• Use <strong>Start Fresh with Context</strong> to begin a new session that carries over a summary
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 class="font-medium text-[var(--text-primary)] mb-2">Reporting Issues</h4>
|
||||
<p>
|
||||
If you encounter a bug or unexpected behaviour, please report it on our
|
||||
<strong>GitHub issues page</strong>. Include:
|
||||
</p>
|
||||
<ul class="space-y-1 mt-2">
|
||||
<li>• What you were doing when the issue occurred</li>
|
||||
<li>• Any error messages from the Debug Console</li>
|
||||
<li>• The app version (shown in the About panel)</li>
|
||||
</ul>
|
||||
<p class="mt-2">
|
||||
You can also join our <strong>Discord community</strong> (link in the menu) for real-time support.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
kbd.kbd {
|
||||
display: inline-block;
|
||||
padding: 0.1rem 0.35rem;
|
||||
font-size: 0.75rem;
|
||||
font-family: monospace;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 3px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,99 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { HELP_PAGES, nextPage, prevPage, clampPage, isFirstPage, isLastPage } from "./helpPages";
|
||||
|
||||
describe("HELP_PAGES", () => {
|
||||
it("contains 12 pages", () => {
|
||||
expect(HELP_PAGES).toHaveLength(12);
|
||||
});
|
||||
|
||||
it("has unique ids", () => {
|
||||
const ids = HELP_PAGES.map((p) => p.id);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
});
|
||||
|
||||
it("has non-empty titles", () => {
|
||||
for (const page of HELP_PAGES) {
|
||||
expect(page.title.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("nextPage", () => {
|
||||
it("advances to the next page", () => {
|
||||
expect(nextPage(0, 7)).toBe(1);
|
||||
expect(nextPage(3, 7)).toBe(4);
|
||||
});
|
||||
|
||||
it("does not go past the last page", () => {
|
||||
expect(nextPage(6, 7)).toBe(6);
|
||||
});
|
||||
|
||||
it("clamps when already at the last page", () => {
|
||||
expect(nextPage(10, 7)).toBe(6);
|
||||
});
|
||||
});
|
||||
|
||||
describe("prevPage", () => {
|
||||
it("goes back to the previous page", () => {
|
||||
expect(prevPage(3)).toBe(2);
|
||||
expect(prevPage(1)).toBe(0);
|
||||
});
|
||||
|
||||
it("does not go before the first page", () => {
|
||||
expect(prevPage(0)).toBe(0);
|
||||
});
|
||||
|
||||
it("clamps when already at the first page", () => {
|
||||
expect(prevPage(-1)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("clampPage", () => {
|
||||
it("returns the page unchanged when in range", () => {
|
||||
expect(clampPage(3, 7)).toBe(3);
|
||||
expect(clampPage(0, 7)).toBe(0);
|
||||
expect(clampPage(6, 7)).toBe(6);
|
||||
});
|
||||
|
||||
it("clamps negative indices to 0", () => {
|
||||
expect(clampPage(-1, 7)).toBe(0);
|
||||
});
|
||||
|
||||
it("clamps over-range indices to the last page", () => {
|
||||
expect(clampPage(10, 7)).toBe(6);
|
||||
});
|
||||
|
||||
it("returns 0 when totalPages is 0", () => {
|
||||
expect(clampPage(3, 0)).toBe(0);
|
||||
});
|
||||
|
||||
it("returns 0 when totalPages is negative", () => {
|
||||
expect(clampPage(3, -1)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isFirstPage", () => {
|
||||
it("returns true for index 0", () => {
|
||||
expect(isFirstPage(0)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for index greater than 0", () => {
|
||||
expect(isFirstPage(1)).toBe(false);
|
||||
expect(isFirstPage(6)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isLastPage", () => {
|
||||
it("returns true for the last index", () => {
|
||||
expect(isLastPage(6, 7)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for indices before the last", () => {
|
||||
expect(isLastPage(5, 7)).toBe(false);
|
||||
expect(isLastPage(0, 7)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when index exceeds total", () => {
|
||||
expect(isLastPage(10, 7)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
export type HelpPageDef = {
|
||||
id: string;
|
||||
title: string;
|
||||
};
|
||||
|
||||
export const HELP_PAGES: HelpPageDef[] = [
|
||||
{ id: "getting-started", title: "Getting Started" },
|
||||
{ id: "keyboard-shortcuts", title: "Keyboard Shortcuts" },
|
||||
{ id: "chat-input", title: "Chat & Input" },
|
||||
{ id: "file-editor", title: "File Editor" },
|
||||
{ id: "git-panel", title: "Git & Version Control" },
|
||||
{ id: "theme-customisation", title: "Theme Customisation" },
|
||||
{ id: "model-config", title: "Model & API Configuration" },
|
||||
{ id: "session-management", title: "Session Management" },
|
||||
{ id: "task-loop", title: "Task Loop & Automation" },
|
||||
{ id: "panels-tools", title: "Panels & Tools" },
|
||||
{ id: "troubleshooting", title: "Troubleshooting" },
|
||||
{ id: "changelog", title: "Changelog" },
|
||||
];
|
||||
|
||||
export function nextPage(currentIndex: number, totalPages: number): number {
|
||||
return Math.min(currentIndex + 1, totalPages - 1);
|
||||
}
|
||||
|
||||
export function prevPage(currentIndex: number): number {
|
||||
return Math.max(currentIndex - 1, 0);
|
||||
}
|
||||
|
||||
export function clampPage(pageIndex: number, totalPages: number): number {
|
||||
if (totalPages <= 0) return 0;
|
||||
return Math.max(0, Math.min(pageIndex, totalPages - 1));
|
||||
}
|
||||
|
||||
export function isFirstPage(currentIndex: number): boolean {
|
||||
return currentIndex === 0;
|
||||
}
|
||||
|
||||
export function isLastPage(currentIndex: number, totalPages: number): boolean {
|
||||
return currentIndex >= totalPages - 1;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user