generated from nhcarrigan/template
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
542d2eb315
|
|||
| 4134e11c88 | |||
|
8220ab6b85
|
|||
| 452fe185df | |||
|
a690a4969b
|
|||
| 2816e33257 | |||
|
ff0ba7b6d0
|
|||
| e6e9f7ae59 | |||
| 1ae440659c | |||
|
9af61a4a29
|
|||
| fa906684c2 |
@@ -11,3 +11,6 @@ vite.config.ts.timestamp-*
|
|||||||
|
|
||||||
# Coverage reports
|
# Coverage reports
|
||||||
/coverage
|
/coverage
|
||||||
|
|
||||||
|
# PRD task files (user-generated data, not source code)
|
||||||
|
hikari-tasks.json
|
||||||
|
|||||||
@@ -20,19 +20,26 @@ When working with issues, pull requests, or other repository operations for this
|
|||||||
When asked to commit changes for this project:
|
When asked to commit changes for this project:
|
||||||
|
|
||||||
- **Always commit as Hikari** using: `--author="Hikari <hikari@nhcarrigan.com>"`
|
- **Always commit as Hikari** using: `--author="Hikari <hikari@nhcarrigan.com>"`
|
||||||
- **Always use `--no-gpg-sign`** since Hikari doesn't have GPG signing set up
|
- **Always sign commits** with Hikari's GPG key: `--gpg-sign=5380E4EE7307C808`
|
||||||
- **Never add `Co-Authored-By` lines** for Gitea commits
|
- **Never add `Co-Authored-By` lines** for Gitea commits
|
||||||
- **Always ask for confirmation** before committing
|
- **Always ask for confirmation** before committing
|
||||||
|
- **Always ask for confirmation** before pushing
|
||||||
|
|
||||||
Example commit command:
|
Example commit command:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git commit --author="Hikari <hikari@nhcarrigan.com>" --no-gpg-sign -m "your commit message"
|
git commit --author="Hikari <hikari@nhcarrigan.com>" --gpg-sign=5380E4EE7307C808 -m "your commit message"
|
||||||
|
```
|
||||||
|
|
||||||
|
Example push command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git push https://hikari:TOKEN@git.nhcarrigan.com/nhcarrigan/hikari-desktop.git <branch>
|
||||||
```
|
```
|
||||||
|
|
||||||
## Testing Requirements
|
## Testing Requirements
|
||||||
|
|
||||||
All new features, fixes, and significant changes should include tests whenever possible:
|
**All changes MUST include tests.** This is non-negotiable — no feature, bug fix, or refactor should be committed without corresponding test coverage. If a change cannot be tested (e.g. pure UI layout, Tauri IPC calls that are impossible to mock), document why in a comment.
|
||||||
|
|
||||||
- **Frontend tests**: Use Vitest with `@testing-library/svelte` for component tests
|
- **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
|
- **Test files**: Place test files next to the code they test with `.test.ts` or `.spec.ts` extension
|
||||||
@@ -130,16 +137,16 @@ describe("FeatureName", () => {
|
|||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
### Adding Tests for New Features
|
### Adding Tests for All Changes
|
||||||
|
|
||||||
When developing new features, always add corresponding tests:
|
Every change — features, bug fixes, refactors — must include tests:
|
||||||
|
|
||||||
1. **Before implementing**: Consider what needs testing (happy path, edge cases, errors)
|
1. **Before implementing**: Consider what needs testing (happy path, edge cases, errors)
|
||||||
2. **During implementation**: Write tests alongside the code
|
2. **During implementation**: Write tests alongside the code
|
||||||
3. **After implementation**: Run `pnpm test:coverage` to verify coverage remains high
|
3. **After implementation**: Run `pnpm test:coverage` to verify coverage remains high
|
||||||
4. **Before committing**: Ensure `check-all.sh` passes (includes all tests)
|
4. **Before committing**: Ensure `check-all.sh` passes (includes all tests)
|
||||||
|
|
||||||
The goal is to maintain our near-100% coverage as the codebase grows, so future refactoring and changes can be made with confidence!
|
**Do not commit changes without tests.** The goal is to maintain near-100% coverage as the codebase grows, so future refactoring can be done with confidence!
|
||||||
|
|
||||||
## Quality Assurance
|
## Quality Assurance
|
||||||
|
|
||||||
|
|||||||
+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.
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hikari-desktop",
|
"name": "hikari-desktop",
|
||||||
"version": "1.9.0",
|
"version": "1.13.0",
|
||||||
"description": "",
|
"description": "",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
Generated
+1
-1
@@ -1648,7 +1648,7 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hikari-desktop"
|
name = "hikari-desktop"
|
||||||
version = "1.9.0"
|
version = "1.13.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"dirs 5.0.1",
|
"dirs 5.0.1",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "hikari-desktop"
|
name = "hikari-desktop"
|
||||||
version = "1.9.0"
|
version = "1.13.0"
|
||||||
description = "Hikari - Claude Code Visual Assistant"
|
description = "Hikari - Claude Code Visual Assistant"
|
||||||
authors = ["Naomi Carrigan"]
|
authors = ["Naomi Carrigan"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|||||||
+473
-8
@@ -606,6 +606,93 @@ pub async fn check_for_updates() -> Result<UpdateInfo, String> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, serde::Deserialize)]
|
||||||
|
struct GiteaChangelogRelease {
|
||||||
|
tag_name: String,
|
||||||
|
html_url: String,
|
||||||
|
body: Option<String>,
|
||||||
|
prerelease: bool,
|
||||||
|
created_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, serde::Serialize)]
|
||||||
|
pub struct ChangelogEntry {
|
||||||
|
pub version: String,
|
||||||
|
pub url: String,
|
||||||
|
pub notes: Option<String>,
|
||||||
|
pub prerelease: bool,
|
||||||
|
pub created_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn fetch_changelog() -> Result<Vec<ChangelogEntry>, String> {
|
||||||
|
const RELEASES_API: &str =
|
||||||
|
"https://git.nhcarrigan.com/api/v1/repos/nhcarrigan/hikari-desktop/releases";
|
||||||
|
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let response = client
|
||||||
|
.get(RELEASES_API)
|
||||||
|
.header("Accept", "application/json")
|
||||||
|
.query(&[("limit", "50")])
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to fetch releases: {}", e))?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
return Err(format!("API returned status: {}", response.status()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let text = response
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to read response: {}", e))?;
|
||||||
|
|
||||||
|
let releases: Vec<GiteaChangelogRelease> =
|
||||||
|
serde_json::from_str(&text).map_err(|e| format!("Failed to parse releases: {}", e))?;
|
||||||
|
|
||||||
|
Ok(releases
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| ChangelogEntry {
|
||||||
|
version: r.tag_name,
|
||||||
|
url: r.html_url,
|
||||||
|
notes: r.body,
|
||||||
|
prerelease: r.prerelease,
|
||||||
|
created_at: r.created_at,
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_npm_cli_version(json: &str) -> Result<String, String> {
|
||||||
|
let data: serde_json::Value =
|
||||||
|
serde_json::from_str(json).map_err(|e| format!("Failed to parse response: {}", e))?;
|
||||||
|
data.get("version")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.ok_or_else(|| "No version field in response".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn check_cli_latest_version() -> Result<String, String> {
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let response = client
|
||||||
|
.get("https://registry.npmjs.org/@anthropic-ai/claude-code/latest")
|
||||||
|
.header("Accept", "application/json")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to fetch CLI version: {}", e))?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
return Err(format!("Registry returned status: {}", response.status()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let body = response
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to read response: {}", e))?;
|
||||||
|
|
||||||
|
parse_npm_cli_version(&body)
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, serde::Serialize)]
|
#[derive(Debug, Clone, serde::Serialize)]
|
||||||
pub struct SavedFileInfo {
|
pub struct SavedFileInfo {
|
||||||
pub path: String,
|
pub path: String,
|
||||||
@@ -862,6 +949,26 @@ pub async fn read_file_content(path: String) -> Result<String, String> {
|
|||||||
.map_err(|e| format!("Failed to read file: {}", e))
|
.map_err(|e| format!("Failed to read file: {}", e))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Read the first `# Heading` from a WSL file path (for Windows).
|
||||||
|
/// Returns `None` if the file cannot be read or has no top-level heading.
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
fn read_wsl_file_first_heading(path: &str) -> Option<String> {
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
let output = Command::new("wsl")
|
||||||
|
.hide_window()
|
||||||
|
.args(["-e", "bash", "-c", &format!("head -20 '{}'", path)])
|
||||||
|
.output()
|
||||||
|
.ok()?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let content = String::from_utf8_lossy(&output.stdout);
|
||||||
|
extract_first_heading(&content)
|
||||||
|
}
|
||||||
|
|
||||||
/// Read file content via WSL (for Windows with WSL paths)
|
/// Read file content via WSL (for Windows with WSL paths)
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
async fn read_file_via_wsl(path: &str) -> Result<String, String> {
|
async fn read_file_via_wsl(path: &str) -> Result<String, String> {
|
||||||
@@ -1353,9 +1460,30 @@ pub async fn close_application(app_handle: AppHandle) -> Result<(), String> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Serialize)]
|
||||||
|
pub struct MemoryFileInfo {
|
||||||
|
pub path: String,
|
||||||
|
pub heading: Option<String>,
|
||||||
|
pub last_modified: Option<String>, // Unix timestamp in seconds as a string
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(serde::Serialize)]
|
#[derive(serde::Serialize)]
|
||||||
pub struct MemoryFilesResponse {
|
pub struct MemoryFilesResponse {
|
||||||
pub files: Vec<String>,
|
pub files: Vec<MemoryFileInfo>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract the first `# Heading` from a string of file content.
|
||||||
|
fn extract_first_heading(content: &str) -> Option<String> {
|
||||||
|
content.lines().find_map(|line| {
|
||||||
|
let trimmed = line.trim();
|
||||||
|
if let Some(rest) = trimmed.strip_prefix("# ") {
|
||||||
|
let heading = rest.trim().to_string();
|
||||||
|
if !heading.is_empty() {
|
||||||
|
return Some(heading);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -1398,12 +1526,23 @@ async fn list_memory_files_via_wsl() -> Result<MemoryFilesResponse, String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||||
let files: Vec<String> = stdout
|
let paths: Vec<String> = stdout
|
||||||
.lines()
|
.lines()
|
||||||
.filter(|line| !line.trim().is_empty())
|
.filter(|line| !line.trim().is_empty())
|
||||||
.map(|line| line.trim().to_string())
|
.map(|line| line.trim().to_string())
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
// Read first heading from each file via WSL
|
||||||
|
let mut files = Vec::new();
|
||||||
|
for path in paths {
|
||||||
|
let heading = read_wsl_file_first_heading(&path);
|
||||||
|
files.push(MemoryFileInfo {
|
||||||
|
path,
|
||||||
|
heading,
|
||||||
|
last_modified: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
Ok(MemoryFilesResponse { files })
|
Ok(MemoryFilesResponse { files })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1425,10 +1564,13 @@ async fn list_memory_files_native() -> Result<MemoryFilesResponse, String> {
|
|||||||
return Ok(MemoryFilesResponse { files: Vec::new() });
|
return Ok(MemoryFilesResponse { files: Vec::new() });
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut memory_files = Vec::new();
|
let mut memory_paths = Vec::new();
|
||||||
|
|
||||||
// Recursively find all memory directories
|
// Recursively find all memory directories
|
||||||
fn find_memory_files(dir: &std::path::Path, files: &mut Vec<String>) -> std::io::Result<()> {
|
fn find_memory_files(
|
||||||
|
dir: &std::path::Path,
|
||||||
|
files: &mut Vec<String>,
|
||||||
|
) -> std::io::Result<()> {
|
||||||
if !dir.is_dir() {
|
if !dir.is_dir() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
@@ -1461,16 +1603,34 @@ async fn list_memory_files_native() -> Result<MemoryFilesResponse, String> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Err(e) = find_memory_files(&projects_dir, &mut memory_files) {
|
if let Err(e) = find_memory_files(&projects_dir, &mut memory_paths) {
|
||||||
return Err(format!("Failed to list memory files: {}", e));
|
return Err(format!("Failed to list memory files: {}", e));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort files alphabetically
|
// Sort files alphabetically
|
||||||
memory_files.sort();
|
memory_paths.sort();
|
||||||
|
|
||||||
Ok(MemoryFilesResponse {
|
// Read first heading and modification time from each file
|
||||||
files: memory_files,
|
let files = memory_paths
|
||||||
|
.into_iter()
|
||||||
|
.map(|path| {
|
||||||
|
let heading = fs::read_to_string(&path)
|
||||||
|
.ok()
|
||||||
|
.and_then(|content| extract_first_heading(&content));
|
||||||
|
let last_modified = fs::metadata(&path)
|
||||||
|
.ok()
|
||||||
|
.and_then(|m| m.modified().ok())
|
||||||
|
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||||||
|
.map(|d| d.as_secs().to_string());
|
||||||
|
MemoryFileInfo {
|
||||||
|
path,
|
||||||
|
heading,
|
||||||
|
last_modified,
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(MemoryFilesResponse { files })
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -2278,6 +2438,186 @@ pub async fn get_mcp_server_details(name: String) -> Result<String, String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== Codebase Mapper ====================
|
||||||
|
|
||||||
|
/// Directories to skip when scanning (always ignored regardless of .gitignore)
|
||||||
|
const SCAN_SKIP_DIRS: &[&str] = &[
|
||||||
|
".git",
|
||||||
|
"node_modules",
|
||||||
|
"target",
|
||||||
|
".next",
|
||||||
|
"dist",
|
||||||
|
"build",
|
||||||
|
"out",
|
||||||
|
"__pycache__",
|
||||||
|
".cache",
|
||||||
|
".pytest_cache",
|
||||||
|
"vendor",
|
||||||
|
".idea",
|
||||||
|
".vscode",
|
||||||
|
"coverage",
|
||||||
|
".nyc_output",
|
||||||
|
"venv",
|
||||||
|
".venv",
|
||||||
|
"env",
|
||||||
|
".tox",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Files that indicate the project type
|
||||||
|
const PROJECT_MARKERS: &[(&str, &str)] = &[
|
||||||
|
("Cargo.toml", "Rust"),
|
||||||
|
("package.json", "Node.js"),
|
||||||
|
("pyproject.toml", "Python"),
|
||||||
|
("requirements.txt", "Python"),
|
||||||
|
("go.mod", "Go"),
|
||||||
|
("pom.xml", "Java (Maven)"),
|
||||||
|
("build.gradle", "Java (Gradle)"),
|
||||||
|
("Gemfile", "Ruby"),
|
||||||
|
("composer.json", "PHP"),
|
||||||
|
("*.csproj", "C#/.NET"),
|
||||||
|
("CMakeLists.txt", "C/C++ (CMake)"),
|
||||||
|
("Makefile", "C/C++"),
|
||||||
|
];
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct ProjectScan {
|
||||||
|
pub working_dir: String,
|
||||||
|
pub file_tree: String,
|
||||||
|
pub detected_type: String,
|
||||||
|
pub key_files: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recursively build a file tree string, respecting skip dirs, up to `max_depth` levels.
|
||||||
|
fn build_file_tree(
|
||||||
|
dir: &std::path::Path,
|
||||||
|
prefix: &str,
|
||||||
|
depth: usize,
|
||||||
|
max_depth: usize,
|
||||||
|
lines: &mut Vec<String>,
|
||||||
|
) {
|
||||||
|
if depth > max_depth {
|
||||||
|
lines.push(format!("{}...", prefix));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut items: Vec<std::fs::DirEntry> = entries
|
||||||
|
.filter_map(|e| e.ok())
|
||||||
|
.collect();
|
||||||
|
items.sort_by_key(|e| {
|
||||||
|
let name = e.file_name().to_string_lossy().to_lowercase();
|
||||||
|
// Sort: hidden last, directories first
|
||||||
|
let is_hidden = name.starts_with('.');
|
||||||
|
let is_dir = e.path().is_dir();
|
||||||
|
(is_hidden, !is_dir, name)
|
||||||
|
});
|
||||||
|
|
||||||
|
let count = items.len();
|
||||||
|
for (i, entry) in items.iter().enumerate() {
|
||||||
|
let name = entry.file_name().to_string_lossy().to_string();
|
||||||
|
let is_last = i == count - 1;
|
||||||
|
let connector = if is_last { "└── " } else { "├── " };
|
||||||
|
let child_prefix = if is_last {
|
||||||
|
format!("{} ", prefix)
|
||||||
|
} else {
|
||||||
|
format!("{}│ ", prefix)
|
||||||
|
};
|
||||||
|
|
||||||
|
let path = entry.path();
|
||||||
|
if path.is_dir() {
|
||||||
|
if SCAN_SKIP_DIRS.contains(&name.as_str()) {
|
||||||
|
lines.push(format!("{}{}{}/ (skipped)", prefix, connector, name));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
lines.push(format!("{}{}{}/", prefix, connector, name));
|
||||||
|
build_file_tree(&path, &child_prefix, depth + 1, max_depth, lines);
|
||||||
|
} else {
|
||||||
|
lines.push(format!("{}{}{}", prefix, connector, name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn scan_project(working_dir: String) -> Result<ProjectScan, String> {
|
||||||
|
let dir_path = std::path::Path::new(&working_dir);
|
||||||
|
|
||||||
|
if !dir_path.exists() {
|
||||||
|
return Err(format!("Directory does not exist: {}", working_dir));
|
||||||
|
}
|
||||||
|
if !dir_path.is_dir() {
|
||||||
|
return Err(format!("Path is not a directory: {}", working_dir));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect project type by checking for marker files
|
||||||
|
let mut detected_type = "Unknown".to_string();
|
||||||
|
let mut key_files: Vec<String> = Vec::new();
|
||||||
|
|
||||||
|
for (marker, project_type) in PROJECT_MARKERS {
|
||||||
|
let marker_path = dir_path.join(marker);
|
||||||
|
if marker_path.exists() {
|
||||||
|
if detected_type == "Unknown" {
|
||||||
|
detected_type = project_type.to_string();
|
||||||
|
}
|
||||||
|
key_files.push(marker.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also collect other notable root-level files
|
||||||
|
let notable_root_files = &[
|
||||||
|
"README.md", "CLAUDE.md", "LICENSE", ".env.example",
|
||||||
|
"docker-compose.yml", "Dockerfile", ".github",
|
||||||
|
"tsconfig.json", "vitest.config.ts", "eslint.config.js",
|
||||||
|
"check-all.sh", "tauri.conf.json",
|
||||||
|
];
|
||||||
|
for file in notable_root_files {
|
||||||
|
let file_path = dir_path.join(file);
|
||||||
|
if file_path.exists() && !key_files.contains(&file.to_string()) {
|
||||||
|
key_files.push(file.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build file tree (max 4 levels deep)
|
||||||
|
let mut lines: Vec<String> = vec![format!("{}/", working_dir)];
|
||||||
|
build_file_tree(dir_path, "", 0, 4, &mut lines);
|
||||||
|
let file_tree = lines.join("\n");
|
||||||
|
|
||||||
|
Ok(ProjectScan {
|
||||||
|
working_dir,
|
||||||
|
file_tree,
|
||||||
|
detected_type,
|
||||||
|
key_files,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn open_binary_file(app: AppHandle, path: String) -> Result<(), String> {
|
||||||
|
use tauri_plugin_opener::OpenerExt;
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
// Convert the WSL Linux path (e.g. /tmp/file.pdf) to a Windows UNC path
|
||||||
|
// (e.g. \\wsl.localhost\Ubuntu\tmp\file.pdf) so the Windows shell can open it.
|
||||||
|
let output = std::process::Command::new("wsl")
|
||||||
|
.args(["wslpath", "-w", &path])
|
||||||
|
.output()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let windows_path = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||||
|
app.opener()
|
||||||
|
.open_path(windows_path, None::<&str>)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
{
|
||||||
|
app.opener()
|
||||||
|
.open_path(path, None::<&str>)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -2569,6 +2909,35 @@ mod tests {
|
|||||||
assert!(json.contains("null") || json.contains("release_notes"));
|
assert!(json.contains("null") || json.contains("release_notes"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== parse_npm_cli_version tests ====================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_npm_cli_version_valid() {
|
||||||
|
let json = r#"{"name":"@anthropic-ai/claude-code","version":"2.1.72","description":"Claude Code"}"#;
|
||||||
|
let result = parse_npm_cli_version(json).unwrap();
|
||||||
|
assert_eq!(result, "2.1.72");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_npm_cli_version_missing_field() {
|
||||||
|
let json = r#"{"name":"@anthropic-ai/claude-code","description":"no version here"}"#;
|
||||||
|
let result = parse_npm_cli_version(json);
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_npm_cli_version_invalid_json() {
|
||||||
|
let result = parse_npm_cli_version("not json at all");
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_npm_cli_version_non_string_version() {
|
||||||
|
let json = r#"{"version":123}"#;
|
||||||
|
let result = parse_npm_cli_version(json);
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== SavedFileInfo struct tests ====================
|
// ==================== SavedFileInfo struct tests ====================
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -2902,4 +3271,100 @@ gitea: gitea-mcp -t stdio (STDIO) - ✓ Connected"#;
|
|||||||
assert_eq!(servers[0].name, "asana");
|
assert_eq!(servers[0].name, "asana");
|
||||||
assert_eq!(servers[1].name, "gitea");
|
assert_eq!(servers[1].name, "gitea");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== extract_first_heading tests ====================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_first_heading_returns_heading() {
|
||||||
|
let content = "# My Memory File\n\nSome content here.";
|
||||||
|
assert_eq!(
|
||||||
|
extract_first_heading(content),
|
||||||
|
Some("My Memory File".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_first_heading_ignores_non_h1() {
|
||||||
|
let content = "## Section Header\n### Sub-section\nSome content.";
|
||||||
|
assert_eq!(extract_first_heading(content), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_first_heading_finds_first_h1_after_other_lines() {
|
||||||
|
let content = "Some intro text\n\n# The Real Title\n\nMore content.";
|
||||||
|
assert_eq!(
|
||||||
|
extract_first_heading(content),
|
||||||
|
Some("The Real Title".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_first_heading_trims_whitespace() {
|
||||||
|
let content = "# Trimmed Heading \n\nContent.";
|
||||||
|
assert_eq!(
|
||||||
|
extract_first_heading(content),
|
||||||
|
Some("Trimmed Heading".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_first_heading_returns_none_for_empty_content() {
|
||||||
|
assert_eq!(extract_first_heading(""), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_first_heading_returns_none_for_empty_heading() {
|
||||||
|
let content = "# \n\nContent after empty heading.";
|
||||||
|
assert_eq!(extract_first_heading(content), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_first_heading_returns_none_when_no_headings() {
|
||||||
|
let content = "Just some plain text.\nNo headings here at all.";
|
||||||
|
assert_eq!(extract_first_heading(content), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_first_heading_handles_leading_whitespace_on_line() {
|
||||||
|
let content = " # Indented Heading\n\nContent.";
|
||||||
|
assert_eq!(
|
||||||
|
extract_first_heading(content),
|
||||||
|
Some("Indented Heading".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== open_binary_file E2E path conversion tests ====================
|
||||||
|
|
||||||
|
/// Build the wslpath command structure without executing it, for cross-platform CI testing.
|
||||||
|
#[cfg(test)]
|
||||||
|
fn build_wslpath_command(path: &str) -> (String, Vec<String>) {
|
||||||
|
(
|
||||||
|
"wsl".to_string(),
|
||||||
|
vec!["wslpath".to_string(), "-w".to_string(), path.to_string()],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_e2e_wslpath_command_structure_pdf() {
|
||||||
|
let (command, args) = build_wslpath_command("/tmp/mcp_output_abc123.pdf");
|
||||||
|
assert_eq!(command, "wsl");
|
||||||
|
assert_eq!(args.len(), 3);
|
||||||
|
assert_eq!(args[0], "wslpath");
|
||||||
|
assert_eq!(args[1], "-w");
|
||||||
|
assert_eq!(args[2], "/tmp/mcp_output_abc123.pdf");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_e2e_wslpath_command_structure_audio() {
|
||||||
|
let (command, args) = build_wslpath_command("/tmp/mcp_output_xyz789.mp3");
|
||||||
|
assert_eq!(command, "wsl");
|
||||||
|
assert_eq!(args[2], "/tmp/mcp_output_xyz789.mp3");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_e2e_wslpath_command_structure_preserves_path() {
|
||||||
|
let path = "/home/naomi/documents/report with spaces.pdf";
|
||||||
|
let (_, args) = build_wslpath_command(path);
|
||||||
|
assert_eq!(args[2], path);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+220
-9
@@ -1,3 +1,5 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
@@ -31,6 +33,27 @@ pub struct ClaudeStartOptions {
|
|||||||
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub disable_1m_context: bool,
|
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>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -126,6 +149,9 @@ pub struct HikariConfig {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub disable_1m_context: bool,
|
pub disable_1m_context: bool,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub max_output_tokens: Option<u64>,
|
||||||
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub trusted_workspaces: Vec<String>,
|
pub trusted_workspaces: Vec<String>,
|
||||||
|
|
||||||
@@ -135,6 +161,48 @@ pub struct HikariConfig {
|
|||||||
|
|
||||||
#[serde(default = "default_background_image_opacity")]
|
#[serde(default = "default_background_image_opacity")]
|
||||||
pub background_image_opacity: f32,
|
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>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for HikariConfig {
|
impl Default for HikariConfig {
|
||||||
@@ -169,9 +237,23 @@ impl Default for HikariConfig {
|
|||||||
discord_rpc_enabled: true,
|
discord_rpc_enabled: true,
|
||||||
use_worktree: false,
|
use_worktree: false,
|
||||||
disable_1m_context: false,
|
disable_1m_context: false,
|
||||||
|
max_output_tokens: None,
|
||||||
trusted_workspaces: Vec::new(),
|
trusted_workspaces: Vec::new(),
|
||||||
background_image_path: None,
|
background_image_path: None,
|
||||||
background_image_opacity: 0.3,
|
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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -212,6 +294,18 @@ fn default_background_image_opacity() -> f32 {
|
|||||||
0.3
|
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
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
pub enum BudgetAction {
|
pub enum BudgetAction {
|
||||||
@@ -229,6 +323,18 @@ pub enum Theme {
|
|||||||
#[serde(rename = "high-contrast")]
|
#[serde(rename = "high-contrast")]
|
||||||
HighContrast,
|
HighContrast,
|
||||||
Custom,
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
|
||||||
@@ -286,6 +392,19 @@ mod tests {
|
|||||||
assert!(!config.use_worktree);
|
assert!(!config.use_worktree);
|
||||||
assert!(!config.disable_1m_context);
|
assert!(!config.disable_1m_context);
|
||||||
assert!(config.trusted_workspaces.is_empty());
|
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());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -320,9 +439,26 @@ mod tests {
|
|||||||
discord_rpc_enabled: true,
|
discord_rpc_enabled: true,
|
||||||
use_worktree: true,
|
use_worktree: true,
|
||||||
disable_1m_context: false,
|
disable_1m_context: false,
|
||||||
|
max_output_tokens: Some(32000),
|
||||||
trusted_workspaces: vec!["/home/naomi/projects/trusted".to_string()],
|
trusted_workspaces: vec!["/home/naomi/projects/trusted".to_string()],
|
||||||
background_image_path: Some("/home/naomi/bg.png".to_string()),
|
background_image_path: Some("/home/naomi/bg.png".to_string()),
|
||||||
background_image_opacity: 0.25,
|
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(),
|
||||||
|
)])),
|
||||||
};
|
};
|
||||||
|
|
||||||
let json = serde_json::to_string(&config).unwrap();
|
let json = serde_json::to_string(&config).unwrap();
|
||||||
@@ -337,22 +473,97 @@ mod tests {
|
|||||||
deserialized.greeting_custom_prompt,
|
deserialized.greeting_custom_prompt,
|
||||||
Some("Hello!".to_string())
|
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]
|
#[test]
|
||||||
fn test_theme_serialization() {
|
fn test_theme_serialization() {
|
||||||
let dark = Theme::Dark;
|
assert_eq!(serde_json::to_string(&Theme::Dark).unwrap(), "\"dark\"");
|
||||||
let light = Theme::Light;
|
assert_eq!(serde_json::to_string(&Theme::Light).unwrap(), "\"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!(
|
assert_eq!(
|
||||||
serde_json::to_string(&high_contrast).unwrap(),
|
serde_json::to_string(&Theme::HighContrast).unwrap(),
|
||||||
"\"high-contrast\""
|
"\"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;
|
#[test]
|
||||||
assert_eq!(serde_json::to_string(&custom).unwrap(), "\"custom\"");
|
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
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+51
-1
@@ -1,6 +1,7 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
use crate::process_ext::HideWindow;
|
use crate::process_ext::HideWindow;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -37,9 +38,38 @@ pub struct GitLogEntry {
|
|||||||
pub message: String,
|
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> {
|
fn run_git_command(working_dir: &str, args: &[&str]) -> Result<String, String> {
|
||||||
let output = Command::new("git")
|
#[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()
|
.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)
|
.args(args)
|
||||||
.current_dir(working_dir)
|
.current_dir(working_dir)
|
||||||
.output()
|
.output()
|
||||||
@@ -297,6 +327,26 @@ mod tests {
|
|||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
use tempfile::TempDir;
|
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
|
// Helper to create a git repository in a temp directory
|
||||||
fn create_test_repo() -> TempDir {
|
fn create_test_repo() -> TempDir {
|
||||||
let temp_dir = TempDir::new().unwrap();
|
let temp_dir = TempDir::new().unwrap();
|
||||||
|
|||||||
@@ -133,6 +133,8 @@ pub fn run() {
|
|||||||
validate_directory,
|
validate_directory,
|
||||||
list_skills,
|
list_skills,
|
||||||
check_for_updates,
|
check_for_updates,
|
||||||
|
fetch_changelog,
|
||||||
|
check_cli_latest_version,
|
||||||
save_temp_file,
|
save_temp_file,
|
||||||
register_temp_file,
|
register_temp_file,
|
||||||
get_temp_files,
|
get_temp_files,
|
||||||
@@ -220,6 +222,8 @@ pub fn run() {
|
|||||||
save_draft,
|
save_draft,
|
||||||
delete_draft,
|
delete_draft,
|
||||||
delete_all_drafts,
|
delete_all_drafts,
|
||||||
|
scan_project,
|
||||||
|
open_binary_file,
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|||||||
@@ -280,6 +280,44 @@ pub struct UserQuestionEvent {
|
|||||||
pub conversation_id: Option<String>,
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct AgentStartEvent {
|
pub struct AgentStartEvent {
|
||||||
pub tool_use_id: String,
|
pub tool_use_id: String,
|
||||||
@@ -292,6 +330,26 @@ pub struct AgentStartEvent {
|
|||||||
pub conversation_id: Option<String>,
|
pub conversation_id: Option<String>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub parent_tool_use_id: Option<String>,
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -546,4 +604,141 @@ mod tests {
|
|||||||
panic!("Expected RateLimitEvent variant");
|
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"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1184
-27
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "hikari-desktop",
|
"productName": "hikari-desktop",
|
||||||
"version": "1.9.0",
|
"version": "1.13.0",
|
||||||
"identifier": "com.naomi.hikari-desktop",
|
"identifier": "com.naomi.hikari-desktop",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "pnpm dev",
|
"beforeDevCommand": "pnpm dev",
|
||||||
|
|||||||
+393
-5
@@ -148,17 +148,405 @@
|
|||||||
--hljs-meta: #cccccc;
|
--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,
|
html,
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
font-family:
|
font-family: var(--ui-font-family, "Segoe UI", system-ui, -apple-system, sans-serif);
|
||||||
"Segoe UI",
|
|
||||||
system-ui,
|
|
||||||
-apple-system,
|
|
||||||
sans-serif;
|
|
||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
color: var(--text-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 {
|
import {
|
||||||
slashCommands,
|
slashCommands,
|
||||||
parseSlashCommand,
|
parseSlashCommand,
|
||||||
@@ -40,6 +45,32 @@ vi.mock("$lib/stores/character", () => ({
|
|||||||
|
|
||||||
vi.mock("$lib/tauri", () => ({
|
vi.mock("$lib/tauri", () => ({
|
||||||
setSkipNextGreeting: vi.fn(),
|
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", () => ({
|
vi.mock("$lib/stores/search", () => ({
|
||||||
@@ -60,10 +91,15 @@ describe("slashCommands", () => {
|
|||||||
expect(commandNames).toContain("search");
|
expect(commandNames).toContain("search");
|
||||||
expect(commandNames).toContain("summarise");
|
expect(commandNames).toContain("summarise");
|
||||||
expect(commandNames).toContain("skill");
|
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", () => {
|
it("has 12 commands total", () => {
|
||||||
expect(slashCommands.length).toBe(7);
|
expect(slashCommands.length).toBe(12);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("each command has required properties", () => {
|
it("each command has required properties", () => {
|
||||||
@@ -133,6 +169,52 @@ describe("slashCommands", () => {
|
|||||||
expect(skillCmd!.description).toBe("Invoke a Claude Code skill from ~/.claude/skills/");
|
expect(skillCmd!.description).toBe("Invoke a Claude Code skill from ~/.claude/skills/");
|
||||||
expect(skillCmd!.usage).toBe("/skill [name] [data]");
|
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", () => {
|
describe("parseSlashCommand", () => {
|
||||||
@@ -315,6 +397,19 @@ describe("slashCommands", () => {
|
|||||||
expect(names).toContain("search");
|
expect(names).toContain("search");
|
||||||
expect(names).toContain("summarise");
|
expect(names).toContain("summarise");
|
||||||
expect(names).toContain("skill");
|
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", () => {
|
it("is case insensitive", () => {
|
||||||
@@ -385,6 +480,19 @@ describe("slashCommands", () => {
|
|||||||
expect(testCommand.description).toBe("A test command");
|
expect(testCommand.description).toBe("A test command");
|
||||||
expect(testCommand.usage).toBe("/test [arg]");
|
expect(testCommand.usage).toBe("/test [arg]");
|
||||||
expect(typeof testCommand.execute).toBe("function");
|
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", () => {
|
it("execute can be async function", () => {
|
||||||
@@ -415,4 +523,544 @@ describe("slashCommands", () => {
|
|||||||
expect(result).toBeUndefined();
|
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)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,11 +6,14 @@ import { setSkipNextGreeting, updateDiscordRpc } from "$lib/tauri";
|
|||||||
import { searchState } from "$lib/stores/search";
|
import { searchState } from "$lib/stores/search";
|
||||||
import { conversationsStore } from "$lib/stores/conversations";
|
import { conversationsStore } from "$lib/stores/conversations";
|
||||||
import { configStore } from "$lib/stores/config";
|
import { configStore } from "$lib/stores/config";
|
||||||
|
import { memoryBrowserStore } from "$lib/stores/memoryBrowser";
|
||||||
|
|
||||||
export interface SlashCommand {
|
export interface SlashCommand {
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
usage: string;
|
usage: string;
|
||||||
|
/** "cli" = built into Claude Code CLI; omitted = Hikari app command */
|
||||||
|
source?: "cli";
|
||||||
execute: (args: string) => Promise<void> | void;
|
execute: (args: string) => Promise<void> | void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,6 +66,12 @@ async function changeDirectory(path: string): Promise<void> {
|
|||||||
allowed_tools: allAllowedTools,
|
allowed_tools: allAllowedTools,
|
||||||
use_worktree: config.use_worktree ?? false,
|
use_worktree: config.use_worktree ?? false,
|
||||||
disable_1m_context: config.disable_1m_context ?? false,
|
disable_1m_context: config.disable_1m_context ?? false,
|
||||||
|
max_output_tokens: config.max_output_tokens ?? null,
|
||||||
|
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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -139,6 +148,12 @@ async function startNewConversation(): Promise<void> {
|
|||||||
allowed_tools: allAllowedTools,
|
allowed_tools: allAllowedTools,
|
||||||
use_worktree: config.use_worktree ?? false,
|
use_worktree: config.use_worktree ?? false,
|
||||||
disable_1m_context: config.disable_1m_context ?? false,
|
disable_1m_context: config.disable_1m_context ?? false,
|
||||||
|
max_output_tokens: config.max_output_tokens ?? null,
|
||||||
|
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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -229,6 +244,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",
|
name: "skill",
|
||||||
description: "Invoke a Claude Code skill from ~/.claude/skills/",
|
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.from({ length: 10 }, (_, i) => i) as confettiIndex (confettiIndex)}
|
|
||||||
<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>
|
|
||||||
@@ -282,8 +282,9 @@
|
|||||||
class="px-1.5 py-0.5 text-[10px] rounded border {getStatusBadgeClass(
|
class="px-1.5 py-0.5 text-[10px] rounded border {getStatusBadgeClass(
|
||||||
agent.status
|
agent.status
|
||||||
)}"
|
)}"
|
||||||
|
title={agent.agentId ? `ID: ${agent.agentId}` : undefined}
|
||||||
>
|
>
|
||||||
{getSubagentTypeLabel(agent.subagentType)}
|
{getSubagentTypeLabel(agent.agentType ?? agent.subagentType)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
@@ -308,6 +309,13 @@
|
|||||||
{agent.description}
|
{agent.description}
|
||||||
</p>
|
</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 -->
|
<!-- Status indicator -->
|
||||||
<div class="mt-1 flex items-center gap-1">
|
<div class="mt-1 flex items-center gap-1">
|
||||||
{#if agent.status === "running"}
|
{#if agent.status === "running"}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,9 +2,10 @@
|
|||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
|
|
||||||
const SUPPORTED_CLI_VERSION = "2.1.53";
|
const SUPPORTED_CLI_VERSION = "2.1.80";
|
||||||
|
|
||||||
let installedVersion = $state("Loading...");
|
let installedVersion = $state("Loading...");
|
||||||
|
let latestNpmVersion = $state<string | null>(null);
|
||||||
|
|
||||||
function compareVersions(a: string, b: string): number {
|
function compareVersions(a: string, b: string): number {
|
||||||
const aParts = a.split(".").map(Number);
|
const aParts = a.split(".").map(Number);
|
||||||
@@ -32,6 +33,15 @@
|
|||||||
return "current";
|
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() {
|
async function fetchVersion() {
|
||||||
try {
|
try {
|
||||||
const result = await invoke<string>("get_claude_version");
|
const result = await invoke<string>("get_claude_version");
|
||||||
@@ -42,13 +52,28 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(() => {
|
onMount(() => {
|
||||||
fetchVersion();
|
fetchVersion();
|
||||||
|
fetchLatestNpmVersion();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="cli-versions">
|
<div class="cli-versions">
|
||||||
<div class="cli-version">
|
<div
|
||||||
|
class="cli-version {updateAvailable ? 'update-available' : ''}"
|
||||||
|
title={updateAvailable
|
||||||
|
? `Update available: ${latestNpmVersion} — run: npm install -g @anthropic-ai/claude-code`
|
||||||
|
: "Installed CLI version"}
|
||||||
|
>
|
||||||
<svg
|
<svg
|
||||||
class="terminal-icon"
|
class="terminal-icon"
|
||||||
width="14"
|
width="14"
|
||||||
@@ -64,6 +89,22 @@
|
|||||||
<line x1="12" y1="19" x2="20" y2="19" />
|
<line x1="12" y1="19" x2="20" y2="19" />
|
||||||
</svg>
|
</svg>
|
||||||
<span class="version-text">CLI {displayVersion}</span>
|
<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>
|
||||||
|
|
||||||
<div class="cli-version supported {supportedBadgeState}" title="Highest audited CLI version">
|
<div class="cli-version supported {supportedBadgeState}" title="Highest audited CLI version">
|
||||||
@@ -135,6 +176,27 @@
|
|||||||
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 {
|
.terminal-icon {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,6 +5,8 @@
|
|||||||
type Theme,
|
type Theme,
|
||||||
type CustomThemeColors,
|
type CustomThemeColors,
|
||||||
applyFontSize,
|
applyFontSize,
|
||||||
|
applyCustomFont,
|
||||||
|
applyCustomUiFont,
|
||||||
applyCustomThemeColors,
|
applyCustomThemeColors,
|
||||||
MIN_FONT_SIZE,
|
MIN_FONT_SIZE,
|
||||||
MAX_FONT_SIZE,
|
MAX_FONT_SIZE,
|
||||||
@@ -56,12 +58,33 @@
|
|||||||
show_thinking_blocks: true,
|
show_thinking_blocks: true,
|
||||||
use_worktree: false,
|
use_worktree: false,
|
||||||
disable_1m_context: false,
|
disable_1m_context: false,
|
||||||
|
disable_cron: false,
|
||||||
|
include_git_instructions: true,
|
||||||
|
enable_claudeai_mcp_servers: true,
|
||||||
|
auto_memory_directory: null,
|
||||||
|
model_overrides: null,
|
||||||
|
max_output_tokens: null,
|
||||||
trusted_workspaces: [],
|
trusted_workspaces: [],
|
||||||
background_image_path: null,
|
background_image_path: null,
|
||||||
background_image_opacity: 0.3,
|
background_image_opacity: 0.3,
|
||||||
|
custom_font_path: null,
|
||||||
|
custom_font_family: null,
|
||||||
|
custom_ui_font_path: null,
|
||||||
|
custom_ui_font_family: null,
|
||||||
|
task_loop_auto_commit: false,
|
||||||
|
task_loop_commit_prefix: "feat",
|
||||||
|
task_loop_include_summary: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
let showCustomThemeEditor = $state(false);
|
let showCustomThemeEditor = $state(false);
|
||||||
|
let customFontPathInput = $state("");
|
||||||
|
let customFontFamilyInput = $state("");
|
||||||
|
let customFontStatus: string | null = $state(null);
|
||||||
|
let customUiFontPathInput = $state("");
|
||||||
|
let customUiFontFamilyInput = $state("");
|
||||||
|
let customUiFontStatus: string | null = $state(null);
|
||||||
|
let modelOverridesJson = $state("");
|
||||||
|
let modelOverridesError: string | null = $state(null);
|
||||||
|
|
||||||
interface AuthStatus {
|
interface AuthStatus {
|
||||||
is_logged_in: boolean;
|
is_logged_in: boolean;
|
||||||
@@ -87,6 +110,11 @@
|
|||||||
|
|
||||||
configStore.config.subscribe((c) => {
|
configStore.config.subscribe((c) => {
|
||||||
config = { ...c };
|
config = { ...c };
|
||||||
|
customFontPathInput = c.custom_font_path ?? "";
|
||||||
|
customFontFamilyInput = c.custom_font_family ?? "";
|
||||||
|
customUiFontPathInput = c.custom_ui_font_path ?? "";
|
||||||
|
customUiFontFamilyInput = c.custom_ui_font_family ?? "";
|
||||||
|
modelOverridesJson = c.model_overrides ? JSON.stringify(c.model_overrides, null, 2) : "";
|
||||||
});
|
});
|
||||||
|
|
||||||
configStore.isSidebarOpen.subscribe((open) => {
|
configStore.isSidebarOpen.subscribe((open) => {
|
||||||
@@ -117,11 +145,6 @@
|
|||||||
{ value: "claude-opus-4-1-20250805", label: "Claude Opus 4.1" },
|
{ value: "claude-opus-4-1-20250805", label: "Claude Opus 4.1" },
|
||||||
{ value: "claude-sonnet-4-20250514", label: "Claude Sonnet 4" },
|
{ value: "claude-sonnet-4-20250514", label: "Claude Sonnet 4" },
|
||||||
{ value: "claude-opus-4-20250514", label: "Claude Opus 4" },
|
{ value: "claude-opus-4-20250514", label: "Claude Opus 4" },
|
||||||
// Legacy (Claude 3.x)
|
|
||||||
{ value: "claude-3-7-sonnet-20250219", label: "Claude 3.7 Sonnet" },
|
|
||||||
{ value: "claude-3-5-sonnet-20241022", label: "Claude 3.5 Sonnet (Oct 2024)" },
|
|
||||||
{ value: "claude-3-5-sonnet-20240620", label: "Claude 3.5 Sonnet (Jun 2024)" },
|
|
||||||
{ value: "claude-3-haiku-20240307", label: "Claude 3 Haiku (Cheapest)" },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const commonTools = [
|
const commonTools = [
|
||||||
@@ -177,6 +200,18 @@
|
|||||||
async function handleSave() {
|
async function handleSave() {
|
||||||
isSaving = true;
|
isSaving = true;
|
||||||
saveError = null;
|
saveError = null;
|
||||||
|
modelOverridesError = null;
|
||||||
|
try {
|
||||||
|
if (modelOverridesJson.trim()) {
|
||||||
|
config.model_overrides = JSON.parse(modelOverridesJson) as Record<string, string>;
|
||||||
|
} else {
|
||||||
|
config.model_overrides = null;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
modelOverridesError = "Invalid JSON — please check your model overrides.";
|
||||||
|
isSaving = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await configStore.saveConfig(config);
|
await configStore.saveConfig(config);
|
||||||
configStore.closeSidebar();
|
configStore.closeSidebar();
|
||||||
@@ -533,6 +568,100 @@
|
|||||||
context window
|
context window
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Disable Cron Scheduling -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="flex items-center gap-3 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
bind:checked={config.disable_cron}
|
||||||
|
class="w-4 h-4 rounded border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--accent-primary)] focus:ring-[var(--accent-primary)]"
|
||||||
|
/>
|
||||||
|
<span class="text-sm text-[var(--text-primary)]">Disable cron scheduling</span>
|
||||||
|
</label>
|
||||||
|
<p class="text-xs text-[var(--text-tertiary)] mt-1 ml-7">
|
||||||
|
Sets <code class="font-mono">CLAUDE_CODE_DISABLE_CRON=1</code> to prevent Claude from scheduling
|
||||||
|
recurring tasks
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Include Git Instructions -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="flex items-center gap-3 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
bind:checked={config.include_git_instructions}
|
||||||
|
class="w-4 h-4 rounded border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--accent-primary)] focus:ring-[var(--accent-primary)]"
|
||||||
|
/>
|
||||||
|
<span class="text-sm text-[var(--text-primary)]">Include git instructions</span>
|
||||||
|
</label>
|
||||||
|
<p class="text-xs text-[var(--text-tertiary)] mt-1 ml-7">
|
||||||
|
When disabled, sets <code class="font-mono">CLAUDE_CODE_DISABLE_GIT_INSTRUCTIONS=1</code> to
|
||||||
|
remove Claude's built-in commit and PR workflow guidance from its system prompt
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Max Output Tokens -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="block text-sm text-[var(--text-primary)] mb-1" for="max-output-tokens">
|
||||||
|
Max output tokens
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="max-output-tokens"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="128000"
|
||||||
|
placeholder="Default (model-dependent)"
|
||||||
|
bind:value={config.max_output_tokens}
|
||||||
|
class="w-full px-3 py-2 text-sm bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-[var(--text-primary)] placeholder-[var(--text-tertiary)] focus:outline-none focus:border-[var(--accent-primary)]"
|
||||||
|
/>
|
||||||
|
<p class="text-xs text-[var(--text-tertiary)] mt-1">
|
||||||
|
Sets <code class="font-mono">CLAUDE_CODE_MAX_OUTPUT_TOKENS</code>. Maximum: 128k tokens
|
||||||
|
for Opus 4.6 and Sonnet 4.6 (64k default for Opus 4.6, 32k for other models). Increase if
|
||||||
|
responses are being cut off.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Auto-memory Directory -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="auto-memory-dir" class="block text-sm text-[var(--text-primary)] mb-1">
|
||||||
|
Auto-memory directory <span class="text-[var(--text-tertiary)]">(optional)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="auto-memory-dir"
|
||||||
|
type="text"
|
||||||
|
placeholder="Leave blank to use default"
|
||||||
|
bind:value={config.auto_memory_directory}
|
||||||
|
class="w-full px-3 py-2 text-sm bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-[var(--text-primary)] placeholder-[var(--text-tertiary)] focus:outline-none focus:border-[var(--accent-primary)]"
|
||||||
|
/>
|
||||||
|
<p class="text-xs text-[var(--text-tertiary)] mt-1">
|
||||||
|
Custom directory for auto-memory storage. Passed via
|
||||||
|
<code class="font-mono">--settings autoMemoryDirectory</code>. Leave blank to use the
|
||||||
|
default (working directory).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Model Overrides -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="model-overrides" class="block text-sm text-[var(--text-primary)] mb-1">
|
||||||
|
Model overrides <span class="text-[var(--text-tertiary)]">(optional)</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="model-overrides"
|
||||||
|
rows={4}
|
||||||
|
placeholder={'{\n "claude-opus-4-6": "arn:aws:bedrock:..."\n}'}
|
||||||
|
bind:value={modelOverridesJson}
|
||||||
|
class="w-full px-3 py-2 text-sm bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-[var(--text-primary)] placeholder-[var(--text-tertiary)] focus:outline-none focus:border-[var(--accent-primary)] font-mono resize-y"
|
||||||
|
></textarea>
|
||||||
|
{#if modelOverridesError}
|
||||||
|
<p class="text-xs text-red-500 mt-1">{modelOverridesError}</p>
|
||||||
|
{/if}
|
||||||
|
<p class="text-xs text-[var(--text-tertiary)] mt-1">
|
||||||
|
JSON map of model names to provider-specific IDs (for AWS Bedrock, Google Vertex, etc.).
|
||||||
|
Passed via <code class="font-mono">--settings modelOverrides</code>. Leave blank to use
|
||||||
|
defaults.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Greeting Section -->
|
<!-- Greeting Section -->
|
||||||
@@ -590,6 +719,22 @@
|
|||||||
class="w-full px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] font-mono text-sm focus:outline-none focus:border-[var(--accent-primary)] resize-none"
|
class="w-full px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] font-mono text-sm focus:outline-none focus:border-[var(--accent-primary)] resize-none"
|
||||||
></textarea>
|
></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Enable Claude.ai MCP Servers -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="flex items-center gap-3 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
bind:checked={config.enable_claudeai_mcp_servers}
|
||||||
|
class="w-4 h-4 rounded border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--accent-primary)] focus:ring-[var(--accent-primary)]"
|
||||||
|
/>
|
||||||
|
<span class="text-sm text-[var(--text-primary)]">Enable Claude.ai MCP servers</span>
|
||||||
|
</label>
|
||||||
|
<p class="text-xs text-[var(--text-tertiary)] mt-1 ml-7">
|
||||||
|
When disabled, sets <code class="font-mono">ENABLE_CLAUDEAI_MCP_SERVERS=false</code> to prevent
|
||||||
|
Claude Code from connecting to MCP servers configured in Claude.ai.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Auto-Granted Tools Section -->
|
<!-- Auto-Granted Tools Section -->
|
||||||
@@ -694,7 +839,7 @@
|
|||||||
<div class="flex flex-wrap gap-2" role="group" aria-label="Theme selection">
|
<div class="flex flex-wrap gap-2" role="group" aria-label="Theme selection">
|
||||||
<button
|
<button
|
||||||
onclick={() => handleThemeChange("dark")}
|
onclick={() => handleThemeChange("dark")}
|
||||||
class="flex-1 min-w-[70px] px-3 py-2 rounded-lg border transition-colors {config.theme ===
|
class="flex-1 min-w-[70px] px-3 py-2 text-sm rounded-lg border transition-colors {config.theme ===
|
||||||
'dark'
|
'dark'
|
||||||
? 'bg-[var(--accent-primary)] border-[var(--accent-primary)] text-white'
|
? 'bg-[var(--accent-primary)] border-[var(--accent-primary)] text-white'
|
||||||
: 'bg-[var(--bg-primary)] border-[var(--border-color)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)]'}"
|
: 'bg-[var(--bg-primary)] border-[var(--border-color)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)]'}"
|
||||||
@@ -703,7 +848,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onclick={() => handleThemeChange("light")}
|
onclick={() => handleThemeChange("light")}
|
||||||
class="flex-1 min-w-[70px] px-3 py-2 rounded-lg border transition-colors {config.theme ===
|
class="flex-1 min-w-[70px] px-3 py-2 text-sm rounded-lg border transition-colors {config.theme ===
|
||||||
'light'
|
'light'
|
||||||
? 'bg-[var(--accent-primary)] border-[var(--accent-primary)] text-white'
|
? 'bg-[var(--accent-primary)] border-[var(--accent-primary)] text-white'
|
||||||
: 'bg-[var(--bg-primary)] border-[var(--border-color)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)]'}"
|
: 'bg-[var(--bg-primary)] border-[var(--border-color)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)]'}"
|
||||||
@@ -712,7 +857,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onclick={() => handleThemeChange("high-contrast")}
|
onclick={() => handleThemeChange("high-contrast")}
|
||||||
class="flex-1 min-w-[70px] px-3 py-2 rounded-lg border transition-colors {config.theme ===
|
class="flex-1 min-w-[70px] px-3 py-2 text-sm rounded-lg border transition-colors {config.theme ===
|
||||||
'high-contrast'
|
'high-contrast'
|
||||||
? 'bg-[var(--accent-primary)] border-[var(--accent-primary)] text-white'
|
? 'bg-[var(--accent-primary)] border-[var(--accent-primary)] text-white'
|
||||||
: 'bg-[var(--bg-primary)] border-[var(--border-color)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)]'}"
|
: 'bg-[var(--bg-primary)] border-[var(--border-color)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)]'}"
|
||||||
@@ -722,7 +867,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onclick={() => handleThemeChange("custom")}
|
onclick={() => handleThemeChange("custom")}
|
||||||
class="flex-1 min-w-[70px] px-3 py-2 rounded-lg border transition-colors {config.theme ===
|
class="flex-1 min-w-[70px] px-3 py-2 text-sm rounded-lg border transition-colors {config.theme ===
|
||||||
'custom'
|
'custom'
|
||||||
? 'bg-[var(--accent-primary)] border-[var(--accent-primary)] text-white'
|
? 'bg-[var(--accent-primary)] border-[var(--accent-primary)] text-white'
|
||||||
: 'bg-[var(--bg-primary)] border-[var(--border-color)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)]'}"
|
: 'bg-[var(--bg-primary)] border-[var(--border-color)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)]'}"
|
||||||
@@ -731,6 +876,96 @@
|
|||||||
Custom
|
Custom
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Preset Themes — Dark -->
|
||||||
|
<span class="block text-xs text-[var(--text-tertiary)] mt-3 mb-2">Dark Presets</span>
|
||||||
|
<div class="flex flex-wrap gap-2" role="group" aria-label="Dark preset theme selection">
|
||||||
|
<button
|
||||||
|
onclick={() => handleThemeChange("dracula")}
|
||||||
|
class="flex-1 min-w-[70px] px-3 py-2 text-sm rounded-lg border transition-colors {config.theme ===
|
||||||
|
'dracula'
|
||||||
|
? 'bg-[var(--accent-primary)] border-[var(--accent-primary)] text-white'
|
||||||
|
: 'bg-[var(--bg-primary)] border-[var(--border-color)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)]'}"
|
||||||
|
title="Dracula theme"
|
||||||
|
>
|
||||||
|
Dracula
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={() => handleThemeChange("catppuccin")}
|
||||||
|
class="flex-1 min-w-[70px] px-3 py-2 text-sm rounded-lg border transition-colors {config.theme ===
|
||||||
|
'catppuccin'
|
||||||
|
? 'bg-[var(--accent-primary)] border-[var(--accent-primary)] text-white'
|
||||||
|
: 'bg-[var(--bg-primary)] border-[var(--border-color)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)]'}"
|
||||||
|
title="Catppuccin Mocha theme"
|
||||||
|
>
|
||||||
|
Catppuccin
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={() => handleThemeChange("nord")}
|
||||||
|
class="flex-1 min-w-[70px] px-3 py-2 text-sm rounded-lg border transition-colors {config.theme ===
|
||||||
|
'nord'
|
||||||
|
? 'bg-[var(--accent-primary)] border-[var(--accent-primary)] text-white'
|
||||||
|
: 'bg-[var(--bg-primary)] border-[var(--border-color)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)]'}"
|
||||||
|
title="Nord theme"
|
||||||
|
>
|
||||||
|
Nord
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={() => handleThemeChange("solarized")}
|
||||||
|
class="flex-1 min-w-[70px] px-3 py-2 text-sm rounded-lg border transition-colors {config.theme ===
|
||||||
|
'solarized'
|
||||||
|
? 'bg-[var(--accent-primary)] border-[var(--accent-primary)] text-white'
|
||||||
|
: 'bg-[var(--bg-primary)] border-[var(--border-color)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)]'}"
|
||||||
|
title="Solarized Dark theme"
|
||||||
|
>
|
||||||
|
Solarized
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Preset Themes — Light -->
|
||||||
|
<span class="block text-xs text-[var(--text-tertiary)] mt-3 mb-2">Light Presets</span>
|
||||||
|
<div class="flex flex-wrap gap-2" role="group" aria-label="Light preset theme selection">
|
||||||
|
<button
|
||||||
|
onclick={() => handleThemeChange("solarized-light")}
|
||||||
|
class="flex-1 min-w-[70px] px-3 py-2 text-sm rounded-lg border transition-colors {config.theme ===
|
||||||
|
'solarized-light'
|
||||||
|
? 'bg-[var(--accent-primary)] border-[var(--accent-primary)] text-white'
|
||||||
|
: 'bg-[var(--bg-primary)] border-[var(--border-color)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)]'}"
|
||||||
|
title="Solarized Light theme"
|
||||||
|
>
|
||||||
|
Solarized
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={() => handleThemeChange("catppuccin-latte")}
|
||||||
|
class="flex-1 min-w-[70px] px-3 py-2 text-sm rounded-lg border transition-colors {config.theme ===
|
||||||
|
'catppuccin-latte'
|
||||||
|
? 'bg-[var(--accent-primary)] border-[var(--accent-primary)] text-white'
|
||||||
|
: 'bg-[var(--bg-primary)] border-[var(--border-color)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)]'}"
|
||||||
|
title="Catppuccin Latte theme"
|
||||||
|
>
|
||||||
|
Latte
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={() => handleThemeChange("gruvbox-light")}
|
||||||
|
class="flex-1 min-w-[70px] px-3 py-2 text-sm rounded-lg border transition-colors {config.theme ===
|
||||||
|
'gruvbox-light'
|
||||||
|
? 'bg-[var(--accent-primary)] border-[var(--accent-primary)] text-white'
|
||||||
|
: 'bg-[var(--bg-primary)] border-[var(--border-color)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)]'}"
|
||||||
|
title="Gruvbox Light theme"
|
||||||
|
>
|
||||||
|
Gruvbox
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={() => handleThemeChange("rose-pine-dawn")}
|
||||||
|
class="flex-1 min-w-[70px] px-3 py-2 text-sm rounded-lg border transition-colors {config.theme ===
|
||||||
|
'rose-pine-dawn'
|
||||||
|
? 'bg-[var(--accent-primary)] border-[var(--accent-primary)] text-white'
|
||||||
|
: 'bg-[var(--bg-primary)] border-[var(--border-color)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)]'}"
|
||||||
|
title="Rosé Pine Dawn theme"
|
||||||
|
>
|
||||||
|
Rosé Pine
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Custom Theme Editor -->
|
<!-- Custom Theme Editor -->
|
||||||
@@ -917,6 +1152,130 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Custom Terminal Font -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<span class="block text-sm text-[var(--text-secondary)] mb-2">Custom Terminal Font</span>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={customFontPathInput}
|
||||||
|
placeholder="URL or local file path (e.g. /path/to/font.ttf)"
|
||||||
|
class="w-full px-3 py-2 text-sm rounded-lg border border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--text-primary)] placeholder-gray-500 focus:outline-none focus:border-[var(--accent-primary)]"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={customFontFamilyInput}
|
||||||
|
placeholder="Font family name (e.g. FiraCode)"
|
||||||
|
class="w-full px-3 py-2 text-sm rounded-lg border border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--text-primary)] placeholder-gray-500 focus:outline-none focus:border-[var(--accent-primary)]"
|
||||||
|
/>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
onclick={async () => {
|
||||||
|
customFontStatus = null;
|
||||||
|
try {
|
||||||
|
await configStore.setCustomFont(
|
||||||
|
customFontPathInput || null,
|
||||||
|
customFontFamilyInput || null
|
||||||
|
);
|
||||||
|
customFontStatus = "Font applied!";
|
||||||
|
} catch (e) {
|
||||||
|
customFontStatus = `Error: ${e instanceof Error ? e.message : String(e)}`;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
class="flex-1 px-3 py-2 text-sm rounded-lg border border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)] transition-colors"
|
||||||
|
>
|
||||||
|
Apply
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={async () => {
|
||||||
|
customFontStatus = null;
|
||||||
|
customFontPathInput = "";
|
||||||
|
customFontFamilyInput = "";
|
||||||
|
try {
|
||||||
|
await configStore.setCustomFont(null, null);
|
||||||
|
await applyCustomFont(null, null);
|
||||||
|
customFontStatus = "Font reset to default.";
|
||||||
|
} catch (e) {
|
||||||
|
customFontStatus = `Error: ${e instanceof Error ? e.message : String(e)}`;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
class="px-3 py-2 text-sm rounded-lg border border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--text-secondary)] hover:border-red-400 hover:text-red-400 transition-colors"
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{#if customFontStatus}
|
||||||
|
<p class="text-xs text-[var(--text-tertiary)]">{customFontStatus}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-[var(--text-tertiary)] mt-1">
|
||||||
|
Supports Google Fonts URLs, direct font file URLs, or local file paths. Family name is
|
||||||
|
required to apply the font.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Custom UI Font -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<span class="block text-sm text-[var(--text-secondary)] mb-2">Custom UI Font</span>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={customUiFontPathInput}
|
||||||
|
placeholder="URL or local file path (e.g. /path/to/font.ttf)"
|
||||||
|
class="w-full px-3 py-2 text-sm rounded-lg border border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--text-primary)] placeholder-gray-500 focus:outline-none focus:border-[var(--accent-primary)]"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={customUiFontFamilyInput}
|
||||||
|
placeholder="Font family name (e.g. Inter)"
|
||||||
|
class="w-full px-3 py-2 text-sm rounded-lg border border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--text-primary)] placeholder-gray-500 focus:outline-none focus:border-[var(--accent-primary)]"
|
||||||
|
/>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
onclick={async () => {
|
||||||
|
customUiFontStatus = null;
|
||||||
|
try {
|
||||||
|
await configStore.setCustomUiFont(
|
||||||
|
customUiFontPathInput || null,
|
||||||
|
customUiFontFamilyInput || null
|
||||||
|
);
|
||||||
|
customUiFontStatus = "Font applied!";
|
||||||
|
} catch (e) {
|
||||||
|
customUiFontStatus = `Error: ${e instanceof Error ? e.message : String(e)}`;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
class="flex-1 px-3 py-2 text-sm rounded-lg border border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)] transition-colors"
|
||||||
|
>
|
||||||
|
Apply UI Font
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={async () => {
|
||||||
|
customUiFontStatus = null;
|
||||||
|
customUiFontPathInput = "";
|
||||||
|
customUiFontFamilyInput = "";
|
||||||
|
try {
|
||||||
|
await configStore.setCustomUiFont(null, null);
|
||||||
|
await applyCustomUiFont(null, null);
|
||||||
|
customUiFontStatus = "Font reset to default.";
|
||||||
|
} catch (e) {
|
||||||
|
customUiFontStatus = `Error: ${e instanceof Error ? e.message : String(e)}`;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
class="px-3 py-2 text-sm rounded-lg border border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--text-secondary)] hover:border-red-400 hover:text-red-400 transition-colors"
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{#if customUiFontStatus}
|
||||||
|
<p class="text-xs text-[var(--text-tertiary)]">{customUiFontStatus}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-[var(--text-tertiary)] mt-1">
|
||||||
|
Applies to the entire app interface (menus, labels, buttons). Supports Google Fonts URLs,
|
||||||
|
direct font file URLs, or local file paths.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Show Thinking Blocks Toggle -->
|
<!-- Show Thinking Blocks Toggle -->
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label class="flex items-center gap-3 cursor-pointer">
|
<label class="flex items-center gap-3 cursor-pointer">
|
||||||
|
|||||||
@@ -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,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,188 @@
|
|||||||
|
<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,
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
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 { invoke } from "@tauri-apps/api/core";
|
||||||
import { onMount, onDestroy } from "svelte";
|
import { onMount, onDestroy } from "svelte";
|
||||||
import { claudeStore } from "$lib/stores/claude";
|
import { claudeStore } from "$lib/stores/claude";
|
||||||
|
import DiffViewer from "$lib/components/DiffViewer.svelte";
|
||||||
|
|
||||||
interface GitFileChange {
|
interface GitFileChange {
|
||||||
path: string;
|
path: string;
|
||||||
@@ -600,7 +601,9 @@
|
|||||||
<h3>📄 {diffFile}</h3>
|
<h3>📄 {diffFile}</h3>
|
||||||
<button on:click={() => (showDiff = false)} title="Close">✕</button>
|
<button on:click={() => (showDiff = false)} title="Close">✕</button>
|
||||||
</div>
|
</div>
|
||||||
<pre class="diff-content">{diffContent || "(No changes)"}</pre>
|
<div class="diff-content">
|
||||||
|
<DiffViewer {diffContent} filePath={diffFile ?? ""} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -1096,12 +1099,7 @@
|
|||||||
.diff-content {
|
.diff-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
padding: 1rem;
|
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
line-height: 1.4;
|
|
||||||
white-space: pre;
|
|
||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,54 +1,69 @@
|
|||||||
<script lang="ts">
|
<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 {
|
interface Props {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { onClose }: Props = $props();
|
const { onClose }: Props = $props();
|
||||||
|
|
||||||
const sections = [
|
const PAGE_COMPONENTS = [
|
||||||
{
|
DocsGettingStarted,
|
||||||
title: "Getting Started",
|
DocsKeyboardShortcuts,
|
||||||
items: [
|
DocsChatInput,
|
||||||
"Enter your Claude API key in Settings (gear icon)",
|
DocsFileEditor,
|
||||||
"Set your working directory and click Connect",
|
DocsGitPanel,
|
||||||
"Start chatting with Hikari - your AI assistant!",
|
DocsThemeCustomisation,
|
||||||
],
|
DocsModelConfig,
|
||||||
},
|
DocsSessionManagement,
|
||||||
{
|
DocsTaskLoop,
|
||||||
title: "Key Features",
|
DocsPanelsTools,
|
||||||
items: [
|
DocsTroubleshooting,
|
||||||
"🗂️ File Management: Hikari can read, write, and edit files in your project",
|
DocsChangelog,
|
||||||
"💻 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",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
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>
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={handleKeydown} />
|
||||||
|
|
||||||
|
<!-- Backdrop -->
|
||||||
<div
|
<div
|
||||||
class="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4"
|
class="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4"
|
||||||
onclick={onClose}
|
onclick={onClose}
|
||||||
@@ -56,17 +71,21 @@
|
|||||||
tabindex="0"
|
tabindex="0"
|
||||||
onkeydown={(e) => e.key === "Escape" && onClose()}
|
onkeydown={(e) => e.key === "Escape" && onClose()}
|
||||||
>
|
>
|
||||||
|
<!-- Dialog -->
|
||||||
<div
|
<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()}
|
onclick={(e) => e.stopPropagation()}
|
||||||
onkeydown={(e) => e.stopPropagation()}
|
onkeydown={(e) => e.stopPropagation()}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-labelledby="help-title"
|
aria-labelledby="help-title"
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
>
|
>
|
||||||
<div class="flex items-center justify-between p-6 pb-4 border-b border-[var(--border-color)]">
|
<!-- Header -->
|
||||||
<h2 id="help-title" class="text-xl font-semibold text-[var(--text-primary)]">
|
<div
|
||||||
How to Use Hikari Desktop
|
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>
|
</h2>
|
||||||
<button
|
<button
|
||||||
onclick={onClose}
|
onclick={onClose}
|
||||||
@@ -84,32 +103,77 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="overflow-y-auto flex-1 p-6 space-y-6">
|
<!-- Body: sidebar + content -->
|
||||||
{#each sections as section (section.title)}
|
<div class="flex flex-1 overflow-hidden">
|
||||||
<div>
|
<!-- Sidebar TOC -->
|
||||||
<h3 class="font-medium text-[var(--text-primary)] mb-3">{section.title}</h3>
|
<nav
|
||||||
<ul class="space-y-2 text-sm text-[var(--text-secondary)]">
|
class="w-44 shrink-0 border-r border-[var(--border-color)] overflow-y-auto py-2"
|
||||||
{#each section.items as item (item)}
|
aria-label="Documentation pages"
|
||||||
<li class="flex items-start">
|
>
|
||||||
<span class="text-[var(--accent-primary)] mr-2 mt-0.5">•</span>
|
{#each HELP_PAGES as page, i (page.id)}
|
||||||
<span>{item}</span>
|
<button
|
||||||
</li>
|
onclick={() => (currentPageIndex = i)}
|
||||||
{/each}
|
class="w-full text-left px-4 py-2 text-sm transition-colors {i === currentPageIndex
|
||||||
</ul>
|
? 'bg-[var(--bg-secondary)] text-[var(--accent-primary)] font-medium'
|
||||||
</div>
|
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-secondary)]'}"
|
||||||
|
aria-current={i === currentPageIndex ? "page" : undefined}
|
||||||
|
>
|
||||||
|
{page.title}
|
||||||
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
|
</nav>
|
||||||
|
|
||||||
<div class="pt-4 border-t border-[var(--border-color)]">
|
<!-- Page content -->
|
||||||
<p class="text-sm text-[var(--text-tertiary)]">
|
<div class="flex-1 overflow-y-auto p-6">
|
||||||
<strong>Need more help?</strong> Join our Discord community for support and updates!
|
<svelte:component this={currentComponent} />
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
/* Ensure the panel appears above other content */
|
|
||||||
[role="dialog"] {
|
[role="dialog"] {
|
||||||
animation: slideIn 0.2s ease-out;
|
animation: slideIn 0.2s ease-out;
|
||||||
}
|
}
|
||||||
@@ -125,26 +189,13 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Custom scrollbar styling */
|
nav {
|
||||||
.overflow-y-auto {
|
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
scrollbar-color: var(--border-color) transparent;
|
scrollbar-color: var(--border-color) transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.overflow-y-auto::-webkit-scrollbar {
|
.overflow-y-auto {
|
||||||
width: 8px;
|
scrollbar-width: thin;
|
||||||
}
|
scrollbar-color: var(--border-color) transparent;
|
||||||
|
|
||||||
.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>
|
</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 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -37,6 +37,7 @@
|
|||||||
import DraftPanel from "$lib/components/DraftPanel.svelte";
|
import DraftPanel from "$lib/components/DraftPanel.svelte";
|
||||||
import TextInputContextMenu from "$lib/components/TextInputContextMenu.svelte";
|
import TextInputContextMenu from "$lib/components/TextInputContextMenu.svelte";
|
||||||
import { draftsStore } from "$lib/stores/drafts";
|
import { draftsStore } from "$lib/stores/drafts";
|
||||||
|
import { injectTextStore } from "$lib/stores/projectContext";
|
||||||
import type { Attachment } from "$lib/types/messages";
|
import type { Attachment } from "$lib/types/messages";
|
||||||
|
|
||||||
const INPUT_HISTORY_KEY = "hikari-input-history";
|
const INPUT_HISTORY_KEY = "hikari-input-history";
|
||||||
@@ -178,6 +179,14 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 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() {
|
function clearInput() {
|
||||||
inputValue = "";
|
inputValue = "";
|
||||||
const activeId = get(claudeStore.activeConversationId);
|
const activeId = get(claudeStore.activeConversationId);
|
||||||
@@ -239,7 +248,7 @@
|
|||||||
const hasAttachments = attachments.length > 0;
|
const hasAttachments = attachments.length > 0;
|
||||||
|
|
||||||
// Need either a message or attachments to submit
|
// 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)
|
// Check for slash commands first (these work even when disconnected)
|
||||||
if (message && isSlashCommand(message)) {
|
if (message && isSlashCommand(message)) {
|
||||||
@@ -330,6 +339,7 @@ User: ${formattedMessage}`;
|
|||||||
conversationId,
|
conversationId,
|
||||||
message: messageToSend,
|
message: messageToSend,
|
||||||
});
|
});
|
||||||
|
claudeStore.setProcessing(true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to send prompt:", error);
|
console.error("Failed to send prompt:", error);
|
||||||
claudeStore.addLine("error", `Failed to send: ${error}`);
|
claudeStore.addLine("error", `Failed to send: ${error}`);
|
||||||
@@ -392,6 +402,11 @@ User: ${formattedMessage}`;
|
|||||||
allowed_tools: allAllowedTools,
|
allowed_tools: allAllowedTools,
|
||||||
use_worktree: config.use_worktree ?? false,
|
use_worktree: config.use_worktree ?? false,
|
||||||
disable_1m_context: config.disable_1m_context ?? false,
|
disable_1m_context: config.disable_1m_context ?? 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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -759,7 +774,7 @@ User: ${formattedMessage}`;
|
|||||||
|
|
||||||
async function handleQuickAction(prompt: string): Promise<void> {
|
async function handleQuickAction(prompt: string): Promise<void> {
|
||||||
// Quick actions send the prompt directly
|
// Quick actions send the prompt directly
|
||||||
if (!isConnected || isSubmitting) return;
|
if (!isConnected || isSubmitting || isProcessing) return;
|
||||||
|
|
||||||
// Add to history
|
// Add to history
|
||||||
addToHistory(prompt);
|
addToHistory(prompt);
|
||||||
@@ -784,6 +799,7 @@ User: ${formattedMessage}`;
|
|||||||
conversationId,
|
conversationId,
|
||||||
message: prompt,
|
message: prompt,
|
||||||
});
|
});
|
||||||
|
claudeStore.setProcessing(true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to send quick action:", error);
|
console.error("Failed to send quick action:", error);
|
||||||
claudeStore.addLine("error", `Failed to send: ${error}`);
|
claudeStore.addLine("error", `Failed to send: ${error}`);
|
||||||
@@ -1009,9 +1025,9 @@ User: ${formattedMessage}`;
|
|||||||
placeholder={isConnected
|
placeholder={isConnected
|
||||||
? "Ask Hikari anything... (type / for commands)"
|
? "Ask Hikari anything... (type / for commands)"
|
||||||
: "Connect to Claude first..."}
|
: "Connect to Claude first..."}
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting || isProcessing}
|
||||||
rows={1}
|
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)]
|
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
|
rounded-lg text-[var(--text-primary)] placeholder-gray-500 resize-none
|
||||||
input-trans-focus disabled:opacity-50 disabled:cursor-not-allowed"
|
input-trans-focus disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
import hljs from "highlight.js";
|
import hljs from "highlight.js";
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { clipboardStore } from "$lib/stores/clipboard";
|
import { clipboardStore } from "$lib/stores/clipboard";
|
||||||
|
import { linkifyFilePaths } from "$lib/utils/filePaths";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
content: string;
|
content: string;
|
||||||
@@ -108,15 +110,20 @@
|
|||||||
return processed;
|
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 {
|
try {
|
||||||
const html = marked.parse(text) as string;
|
const html = marked.parse(content) as string;
|
||||||
const withSpoilers = processSpoilers(html);
|
const withSpoilers = processSpoilers(html);
|
||||||
return highlightSearchMatches(withSpoilers, searchQuery);
|
return linkifyFilePaths(withSpoilers);
|
||||||
} catch {
|
} 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) {
|
function handleSpoilerClick(event: Event) {
|
||||||
const target = event.target as HTMLElement;
|
const target = event.target as HTMLElement;
|
||||||
@@ -136,9 +143,18 @@
|
|||||||
function handleLinkClick(event: MouseEvent) {
|
function handleLinkClick(event: MouseEvent) {
|
||||||
const target = event.target as HTMLElement;
|
const target = event.target as HTMLElement;
|
||||||
const anchor = target.closest("a");
|
const anchor = target.closest("a");
|
||||||
if (anchor?.href) {
|
if (!anchor) return;
|
||||||
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
openUrl(anchor.href);
|
|
||||||
|
const filePath = anchor.dataset.filepath;
|
||||||
|
if (filePath) {
|
||||||
|
void invoke("open_binary_file", { path: filePath });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (anchor.href) {
|
||||||
|
void openUrl(anchor.href);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,7 +207,7 @@
|
|||||||
role="presentation"
|
role="presentation"
|
||||||
>
|
>
|
||||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -- Markdown rendering requires @html; content is from Claude API -->
|
<!-- eslint-disable-next-line svelte/no-at-html-tags -- Markdown rendering requires @html; content is from Claude API -->
|
||||||
{@html renderMarkdown(content)}
|
{@html renderedHtml}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -449,4 +465,27 @@
|
|||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
padding: 0 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>
|
</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>');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,18 +1,31 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from "svelte";
|
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { get } from "svelte/store";
|
||||||
|
import { claudeStore } from "$lib/stores/claude";
|
||||||
import Markdown from "./Markdown.svelte";
|
import Markdown from "./Markdown.svelte";
|
||||||
|
|
||||||
let memoryFiles: string[] = $state([]);
|
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 selectedFile: string | null = $state(null);
|
||||||
let fileContent: string = $state("");
|
let fileContent: string = $state("");
|
||||||
let isLoading = $state(false);
|
let isLoading = $state(false);
|
||||||
let error: string | null = $state(null);
|
let error: string | null = $state(null);
|
||||||
let isPanelOpen = $state(false);
|
|
||||||
|
|
||||||
interface MemoryFilesResponse {
|
|
||||||
files: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadMemoryFiles() {
|
async function loadMemoryFiles() {
|
||||||
isLoading = true;
|
isLoading = true;
|
||||||
@@ -49,37 +62,34 @@
|
|||||||
return path.split("/").pop() || path;
|
return path.split("/").pop() || path;
|
||||||
}
|
}
|
||||||
|
|
||||||
function togglePanel() {
|
function getDisplayName(file: MemoryFileInfo): string {
|
||||||
isPanelOpen = !isPanelOpen;
|
return file.heading ?? getFileName(file.path);
|
||||||
if (isPanelOpen && memoryFiles.length === 0) {
|
|
||||||
loadMemoryFiles();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
function formatLastModified(ts: string | undefined): string {
|
||||||
// Don't load on mount - only when panel is opened
|
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>
|
</script>
|
||||||
|
|
||||||
<button class="memory-toggle" onclick={togglePanel} title="Memory Browser">
|
{#if isOpen}
|
||||||
<svg
|
|
||||||
class="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>
|
|
||||||
<span class="label">Memory</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{#if isPanelOpen}
|
|
||||||
<div class="memory-panel">
|
<div class="memory-panel">
|
||||||
<div class="panel-header">
|
<div class="panel-header">
|
||||||
<div class="header-title">
|
<div class="header-title">
|
||||||
@@ -99,7 +109,40 @@
|
|||||||
</svg>
|
</svg>
|
||||||
<h3>Memory Files</h3>
|
<h3>Memory Files</h3>
|
||||||
</div>
|
</div>
|
||||||
<button class="close-btn" onclick={togglePanel} title="Close">
|
<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
|
<svg
|
||||||
class="close-icon"
|
class="close-icon"
|
||||||
fill="none"
|
fill="none"
|
||||||
@@ -116,6 +159,7 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="panel-content">
|
<div class="panel-content">
|
||||||
{#if isLoading && memoryFiles.length === 0}
|
{#if isLoading && memoryFiles.length === 0}
|
||||||
@@ -151,11 +195,12 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<div class="panel-layout">
|
<div class="panel-layout">
|
||||||
<div class="file-list">
|
<div class="file-list">
|
||||||
{#each memoryFiles as file (file)}
|
{#each memoryFiles as file (file.path)}
|
||||||
<button
|
<button
|
||||||
class="file-item"
|
class="file-item"
|
||||||
class:active={selectedFile === file}
|
class:active={selectedFile === file.path}
|
||||||
onclick={() => loadFileContent(file)}
|
onclick={() => loadFileContent(file.path)}
|
||||||
|
title={getFileName(file.path)}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
class="file-icon"
|
class="file-icon"
|
||||||
@@ -171,7 +216,12 @@
|
|||||||
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"
|
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>
|
</svg>
|
||||||
<span class="file-name">{getFileName(file)}</span>
|
<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>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
@@ -179,7 +229,12 @@
|
|||||||
<div class="file-viewer">
|
<div class="file-viewer">
|
||||||
{#if selectedFile && fileContent}
|
{#if selectedFile && fileContent}
|
||||||
<div class="viewer-header">
|
<div class="viewer-header">
|
||||||
<h4>{getFileName(selectedFile)}</h4>
|
{#each memoryFiles.filter((f) => f.path === selectedFile) as activeFile (activeFile.path)}
|
||||||
|
<h4>{getDisplayName(activeFile)}</h4>
|
||||||
|
{#if activeFile.heading}
|
||||||
|
<p class="viewer-filename">{getFileName(activeFile.path)}</p>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
<div class="viewer-content">
|
<div class="viewer-content">
|
||||||
<Markdown content={fileContent} />
|
<Markdown content={fileContent} />
|
||||||
@@ -215,34 +270,6 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.memory-toggle {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: 8px;
|
|
||||||
color: var(--text-primary);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.memory-toggle:hover {
|
|
||||||
background: var(--bg-hover);
|
|
||||||
border-color: var(--accent-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon {
|
|
||||||
width: 1.25rem;
|
|
||||||
height: 1.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.label {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.memory-panel {
|
.memory-panel {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
@@ -285,6 +312,32 @@
|
|||||||
color: var(--text-primary);
|
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 {
|
.close-btn {
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@@ -379,7 +432,7 @@
|
|||||||
|
|
||||||
.file-item {
|
.file-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: flex-start;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.75rem 1rem;
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
@@ -408,6 +461,13 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.file-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.125rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
.file-name {
|
.file-name {
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
@@ -416,6 +476,15 @@
|
|||||||
white-space: nowrap;
|
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 {
|
.file-viewer {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -438,6 +507,13 @@
|
|||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.viewer-filename {
|
||||||
|
margin: 0.25rem 0 0;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
.viewer-content {
|
.viewer-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
|
||||||
|
// Mirror pure logic functions from MemoryBrowserPanel.svelte
|
||||||
|
|
||||||
|
interface MemoryFileInfo {
|
||||||
|
path: string;
|
||||||
|
heading: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFileName(path: string): string {
|
||||||
|
return path.split("/").pop() || path;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDisplayName(file: MemoryFileInfo): string {
|
||||||
|
return file.heading ?? getFileName(file.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("getFileName", () => {
|
||||||
|
it("extracts the filename from an absolute Unix path", () => {
|
||||||
|
expect(getFileName("/home/naomi/.claude/projects/foo/memory/MEMORY.md")).toBe("MEMORY.md");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("extracts the filename from a nested path", () => {
|
||||||
|
expect(getFileName("/home/naomi/.claude/projects/foo/memory/debugging.md")).toBe(
|
||||||
|
"debugging.md"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the path itself when there is no slash", () => {
|
||||||
|
expect(getFileName("MEMORY.md")).toBe("MEMORY.md");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the path when the path ends with a slash (empty filename)", () => {
|
||||||
|
// split('/').pop() returns '' for trailing slash → falls back to full path
|
||||||
|
expect(getFileName("/some/dir/")).toBe("/some/dir/");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles single-component paths", () => {
|
||||||
|
expect(getFileName("notes.md")).toBe("notes.md");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles Windows-style paths passed as Unix strings", () => {
|
||||||
|
// If the path contains no forward slashes, the whole string is the filename
|
||||||
|
expect(getFileName("C:\\Users\\naomi\\memory\\file.md")).toBe(
|
||||||
|
"C:\\Users\\naomi\\memory\\file.md"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getDisplayName", () => {
|
||||||
|
it("returns the heading when the file has one", () => {
|
||||||
|
const file: MemoryFileInfo = {
|
||||||
|
path: "/home/naomi/.claude/projects/foo/memory/MEMORY.md",
|
||||||
|
heading: "Hikari Desktop - Memory",
|
||||||
|
};
|
||||||
|
expect(getDisplayName(file)).toBe("Hikari Desktop - Memory");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the filename when heading is null", () => {
|
||||||
|
const file: MemoryFileInfo = {
|
||||||
|
path: "/home/naomi/.claude/projects/foo/memory/debugging.md",
|
||||||
|
heading: null,
|
||||||
|
};
|
||||||
|
expect(getDisplayName(file)).toBe("debugging.md");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the filename when heading is an empty string stored as null", () => {
|
||||||
|
const file: MemoryFileInfo = {
|
||||||
|
path: "/home/naomi/.claude/projects/foo/memory/patterns.md",
|
||||||
|
heading: null,
|
||||||
|
};
|
||||||
|
expect(getDisplayName(file)).toBe("patterns.md");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the heading even when it matches the filename", () => {
|
||||||
|
const file: MemoryFileInfo = {
|
||||||
|
path: "/home/naomi/.claude/projects/foo/memory/MEMORY.md",
|
||||||
|
heading: "MEMORY",
|
||||||
|
};
|
||||||
|
expect(getDisplayName(file)).toBe("MEMORY");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a multi-word heading verbatim", () => {
|
||||||
|
const file: MemoryFileInfo = {
|
||||||
|
path: "/some/path/foo.md",
|
||||||
|
heading: "My Detailed Debugging Notes",
|
||||||
|
};
|
||||||
|
expect(getDisplayName(file)).toBe("My Detailed Debugging Notes");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back gracefully when path has no directory separators", () => {
|
||||||
|
const file: MemoryFileInfo = {
|
||||||
|
path: "lonely-file.md",
|
||||||
|
heading: null,
|
||||||
|
};
|
||||||
|
expect(getDisplayName(file)).toBe("lonely-file.md");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -86,9 +86,14 @@
|
|||||||
api_key: config.api_key || null,
|
api_key: config.api_key || null,
|
||||||
custom_instructions: config.custom_instructions || null,
|
custom_instructions: config.custom_instructions || null,
|
||||||
mcp_servers_json: config.mcp_servers_json || null,
|
mcp_servers_json: config.mcp_servers_json || null,
|
||||||
allowed_tools: newGrantedTools,
|
allowed_tools: [...new Set([...newGrantedTools, ...config.auto_granted_tools])],
|
||||||
use_worktree: config.use_worktree ?? false,
|
use_worktree: config.use_worktree ?? false,
|
||||||
disable_1m_context: config.disable_1m_context ?? false,
|
disable_1m_context: config.disable_1m_context ?? 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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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,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>
|
||||||
@@ -23,6 +23,9 @@
|
|||||||
>
|
>
|
||||||
<span class="command-name">/{command.name}</span>
|
<span class="command-name">/{command.name}</span>
|
||||||
<span class="command-description">{command.description}</span>
|
<span class="command-description">{command.description}</span>
|
||||||
|
{#if command.source === "cli"}
|
||||||
|
<span class="cli-badge">CLI</span>
|
||||||
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
@@ -82,5 +85,19 @@
|
|||||||
.command-description {
|
.command-description {
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-size: 13px;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -9,27 +9,12 @@
|
|||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { getVersion } from "@tauri-apps/api/app";
|
import { getVersion } from "@tauri-apps/api/app";
|
||||||
import { open } from "@tauri-apps/plugin-dialog";
|
import { open } from "@tauri-apps/plugin-dialog";
|
||||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
|
||||||
import { get } from "svelte/store";
|
import { get } from "svelte/store";
|
||||||
import { claudeStore } from "$lib/stores/claude";
|
import { claudeStore } from "$lib/stores/claude";
|
||||||
import { configStore, type HikariConfig, isStreamerMode } from "$lib/stores/config";
|
import { configStore, type HikariConfig, isStreamerMode } from "$lib/stores/config";
|
||||||
import { editorStore } from "$lib/stores/editor";
|
|
||||||
import type { ConnectionStatus } from "$lib/types/messages";
|
import type { ConnectionStatus } from "$lib/types/messages";
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import StatsDisplay from "./StatsDisplay.svelte";
|
import { PROJECT_CONTEXT_SYSTEM_ADDENDUM } from "$lib/stores/projectContext";
|
||||||
import AboutPanel from "./AboutPanel.svelte";
|
|
||||||
import HelpPanel from "./HelpPanel.svelte";
|
|
||||||
import KeyboardShortcutsModal from "./KeyboardShortcutsModal.svelte";
|
|
||||||
import { achievementProgress } from "$lib/stores/achievements";
|
|
||||||
import { runningAgentCount } from "$lib/stores/agents";
|
|
||||||
import SessionHistoryPanel from "./SessionHistoryPanel.svelte";
|
|
||||||
import TodoPanel from "./TodoPanel.svelte";
|
|
||||||
import GitPanel from "./GitPanel.svelte";
|
|
||||||
import ProfilePanel from "./ProfilePanel.svelte";
|
|
||||||
import AgentMonitorPanel from "./AgentMonitorPanel.svelte";
|
|
||||||
import CastPanel from "./CastPanel.svelte";
|
|
||||||
import PluginManagementPanel from "./PluginManagementPanel.svelte";
|
|
||||||
import McpManagementPanel from "./McpManagementPanel.svelte";
|
|
||||||
import { conversationsStore } from "$lib/stores/conversations";
|
import { conversationsStore } from "$lib/stores/conversations";
|
||||||
import {
|
import {
|
||||||
generateContextInjection,
|
generateContextInjection,
|
||||||
@@ -37,36 +22,21 @@
|
|||||||
sanitizeForJson,
|
sanitizeForJson,
|
||||||
} from "$lib/utils/conversationUtils";
|
} from "$lib/utils/conversationUtils";
|
||||||
import { updateDiscordRpc, setSkipNextGreeting } from "$lib/tauri";
|
import { updateDiscordRpc, setSkipNextGreeting } from "$lib/tauri";
|
||||||
import { debugConsoleStore } from "$lib/stores/debugConsole";
|
|
||||||
import WorkspaceTrustModal from "./WorkspaceTrustModal.svelte";
|
import WorkspaceTrustModal from "./WorkspaceTrustModal.svelte";
|
||||||
import type { WorkspaceHookInfo } from "$lib/types/messages";
|
import type { WorkspaceHookInfo } from "$lib/types/messages";
|
||||||
|
import NavMenu from "./NavMenu.svelte";
|
||||||
const DISCORD_URL = "https://chat.nhcarrigan.com";
|
import { taskLoopStore } from "$lib/stores/taskLoop";
|
||||||
const DONATE_URL = "https://donate.nhcarrigan.com";
|
|
||||||
|
|
||||||
let connectionStatus: ConnectionStatus = $state("disconnected");
|
let connectionStatus: ConnectionStatus = $state("disconnected");
|
||||||
let workingDirectory = $state("");
|
let workingDirectory = $state("");
|
||||||
|
let worktreeInfo: import("$lib/types/worktree").WorktreeInfo | null = $state(null);
|
||||||
let selectedDirectory = $state("/home/naomi");
|
let selectedDirectory = $state("/home/naomi");
|
||||||
let isConnecting = $state(false);
|
let isConnecting = $state(false);
|
||||||
let grantedToolsList: string[] = $state([]);
|
let grantedToolsList: string[] = $state([]);
|
||||||
let appVersion = $state("");
|
let appVersion = $state("");
|
||||||
let showStats = $state(false);
|
|
||||||
let showAbout = $state(false);
|
|
||||||
let showHelp = $state(false);
|
|
||||||
let showKeyboardShortcuts = $state(false);
|
|
||||||
let showSessionHistory = $state(false);
|
|
||||||
let showTodoPanel = $state(false);
|
|
||||||
let showGitPanel = $state(false);
|
|
||||||
let showProfile = $state(false);
|
|
||||||
let showAgentMonitor = $state(false);
|
|
||||||
let showCastPanel = $state(false);
|
|
||||||
let showPluginPanel = $state(false);
|
|
||||||
let showMcpPanel = $state(false);
|
|
||||||
let isSummarising = $state(false);
|
let isSummarising = $state(false);
|
||||||
let showWorkspaceTrust = $state(false);
|
let showWorkspaceTrust = $state(false);
|
||||||
let pendingHookInfo: WorkspaceHookInfo | null = $state(null);
|
let pendingHookInfo: WorkspaceHookInfo | null = $state(null);
|
||||||
const progress = $derived($achievementProgress);
|
|
||||||
const activeAgentCount = $derived($runningAgentCount);
|
|
||||||
let currentConfig: HikariConfig = $state({
|
let currentConfig: HikariConfig = $state({
|
||||||
model: null,
|
model: null,
|
||||||
api_key: null,
|
api_key: null,
|
||||||
@@ -107,9 +77,22 @@
|
|||||||
show_thinking_blocks: true,
|
show_thinking_blocks: true,
|
||||||
use_worktree: false,
|
use_worktree: false,
|
||||||
disable_1m_context: false,
|
disable_1m_context: false,
|
||||||
|
max_output_tokens: null,
|
||||||
trusted_workspaces: [],
|
trusted_workspaces: [],
|
||||||
background_image_path: null,
|
background_image_path: null,
|
||||||
background_image_opacity: 0.3,
|
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,
|
||||||
});
|
});
|
||||||
|
|
||||||
let streamerModeActive = $state(false);
|
let streamerModeActive = $state(false);
|
||||||
@@ -117,14 +100,13 @@
|
|||||||
streamerModeActive = value;
|
streamerModeActive = value;
|
||||||
});
|
});
|
||||||
|
|
||||||
let editorVisible = $state(false);
|
const loopStatus = $derived(taskLoopStore.loopStatus);
|
||||||
editorStore.isEditorVisible.subscribe((value) => {
|
const loopTasks = $derived(taskLoopStore.tasks);
|
||||||
editorVisible = value;
|
const loopCurrentIndex = $derived(taskLoopStore.currentTaskIndex);
|
||||||
});
|
const loopCompletedCount = $derived(
|
||||||
|
$loopTasks.filter((t) => t.status === "completed" || t.status === "failed").length
|
||||||
function toggleEditor() {
|
);
|
||||||
editorStore.toggleEditor();
|
const loopTotalCount = $derived($loopTasks.length);
|
||||||
}
|
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
appVersion = await getVersion();
|
appVersion = await getVersion();
|
||||||
@@ -139,6 +121,10 @@
|
|||||||
workingDirectory = dir;
|
workingDirectory = dir;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
claudeStore.worktreeInfo.subscribe((info) => {
|
||||||
|
worktreeInfo = info;
|
||||||
|
});
|
||||||
|
|
||||||
claudeStore.grantedTools.subscribe((tools) => {
|
claudeStore.grantedTools.subscribe((tools) => {
|
||||||
grantedToolsList = Array.from(tools);
|
grantedToolsList = Array.from(tools);
|
||||||
});
|
});
|
||||||
@@ -174,17 +160,25 @@
|
|||||||
if (!conversationId) {
|
if (!conversationId) {
|
||||||
throw new Error("No active conversation");
|
throw new Error("No active conversation");
|
||||||
}
|
}
|
||||||
|
const activeConversationForName = get(conversationsStore.activeConversation);
|
||||||
await invoke("start_claude", {
|
await invoke("start_claude", {
|
||||||
conversationId,
|
conversationId,
|
||||||
options: {
|
options: {
|
||||||
working_dir: targetDir,
|
working_dir: targetDir,
|
||||||
model: currentConfig.model || null,
|
model: currentConfig.model || null,
|
||||||
api_key: currentConfig.api_key || 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,
|
mcp_servers_json: currentConfig.mcp_servers_json || null,
|
||||||
allowed_tools: allAllowedTools,
|
allowed_tools: allAllowedTools,
|
||||||
use_worktree: currentConfig.use_worktree ?? false,
|
use_worktree: currentConfig.use_worktree ?? false,
|
||||||
disable_1m_context: currentConfig.disable_1m_context ?? false,
|
disable_1m_context: currentConfig.disable_1m_context ?? false,
|
||||||
|
max_output_tokens: currentConfig.max_output_tokens ?? null,
|
||||||
|
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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -290,10 +284,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleAchievements() {
|
|
||||||
onToggleAchievements();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleCompactConversation() {
|
async function handleCompactConversation() {
|
||||||
const activeId = get(conversationsStore.activeConversationId);
|
const activeId = get(conversationsStore.activeConversationId);
|
||||||
if (!activeId) return;
|
if (!activeId) return;
|
||||||
@@ -339,11 +329,18 @@
|
|||||||
working_dir: workingDirectory || selectedDirectory,
|
working_dir: workingDirectory || selectedDirectory,
|
||||||
model: currentConfig.model || null,
|
model: currentConfig.model || null,
|
||||||
api_key: currentConfig.api_key || 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,
|
mcp_servers_json: currentConfig.mcp_servers_json || null,
|
||||||
allowed_tools: allAllowedTools,
|
allowed_tools: allAllowedTools,
|
||||||
use_worktree: currentConfig.use_worktree ?? false,
|
use_worktree: currentConfig.use_worktree ?? false,
|
||||||
disable_1m_context: currentConfig.disable_1m_context ?? false,
|
disable_1m_context: currentConfig.disable_1m_context ?? false,
|
||||||
|
max_output_tokens: currentConfig.max_output_tokens ?? null,
|
||||||
|
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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -416,6 +413,22 @@
|
|||||||
{workingDirectory}
|
{workingDirectory}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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}
|
{:else}
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="text-sm text-gray-600">cwd:</span>
|
<span class="text-sm text-gray-600">cwd:</span>
|
||||||
@@ -438,312 +451,42 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-2 flex-wrap min-w-0">
|
<div class="flex items-center gap-2">
|
||||||
{#if streamerModeActive}
|
{#if streamerModeActive}
|
||||||
<div
|
<div
|
||||||
class="w-2.5 h-2.5 rounded-full bg-red-500 animate-pulse shrink-0"
|
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)"
|
title="Streamer mode active (Ctrl+Shift+S to toggle)"
|
||||||
></div>
|
></div>
|
||||||
{/if}
|
{/if}
|
||||||
<button
|
|
||||||
onclick={() => (showProfile = true)}
|
{#if $loopStatus === "running" || $loopStatus === "paused"}
|
||||||
class="p-1 text-gray-500 icon-trans-hover shrink-0"
|
|
||||||
title="Profile"
|
|
||||||
>
|
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onclick={onToggleCompact}
|
|
||||||
class="p-1 text-gray-500 icon-trans-hover"
|
|
||||||
title="Compact Mode (Ctrl+Shift+M)"
|
|
||||||
>
|
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onclick={toggleAchievements}
|
|
||||||
class="p-1 text-gray-500 icon-trans-hover relative"
|
|
||||||
title="Achievements"
|
|
||||||
>
|
|
||||||
<span class="text-lg">🏆</span>
|
|
||||||
{#if progress.unlocked > 0}
|
|
||||||
<span
|
<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]"
|
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}"
|
||||||
>
|
>
|
||||||
{progress.unlocked}
|
Loop {$loopStatus === "running" ? "▶" : "⏸"}
|
||||||
|
{loopCompletedCount +
|
||||||
|
($loopStatus === "running" && $loopCurrentIndex >= 0 ? 1 : 0)}/{loopTotalCount}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
|
||||||
<button
|
<NavMenu
|
||||||
onclick={() => (showSessionHistory = true)}
|
{connectionStatus}
|
||||||
class="p-1 text-gray-500 icon-trans-hover"
|
{workingDirectory}
|
||||||
title="Session History"
|
{selectedDirectory}
|
||||||
>
|
{isSummarising}
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
{onToggleCompact}
|
||||||
<path
|
{onToggleAchievements}
|
||||||
stroke-linecap="round"
|
onCompactConversation={handleCompactConversation}
|
||||||
stroke-linejoin="round"
|
onStartFreshWithContext={handleStartFreshWithContext}
|
||||||
stroke-width="2"
|
|
||||||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
|
||||||
/>
|
/>
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onclick={() => (showTodoPanel = true)}
|
|
||||||
class="p-1 text-gray-500 icon-trans-hover"
|
|
||||||
title="Todo List"
|
|
||||||
>
|
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onclick={() => (showGitPanel = true)}
|
|
||||||
class="p-1 text-gray-500 icon-trans-hover"
|
|
||||||
title="Git Panel"
|
|
||||||
>
|
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onclick={() => (showPluginPanel = true)}
|
|
||||||
class="p-1 text-gray-500 icon-trans-hover"
|
|
||||||
title="Plugin Management"
|
|
||||||
>
|
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onclick={() => (showMcpPanel = true)}
|
|
||||||
class="p-1 text-gray-500 icon-trans-hover"
|
|
||||||
title="MCP Server Management"
|
|
||||||
>
|
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onclick={toggleEditor}
|
|
||||||
disabled={connectionStatus !== "connected"}
|
|
||||||
class="p-1 text-gray-500 icon-trans-hover {editorVisible
|
|
||||||
? 'text-[var(--trans-pink)]'
|
|
||||||
: ''} disabled:opacity-40 disabled:cursor-not-allowed"
|
|
||||||
title={connectionStatus === "connected"
|
|
||||||
? "File Editor (Ctrl+E)"
|
|
||||||
: "Connect to enable file editor"}
|
|
||||||
>
|
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onclick={() => (showCastPanel = true)}
|
|
||||||
class="p-1 text-gray-500 icon-trans-hover"
|
|
||||||
title="Meet the Team"
|
|
||||||
>
|
|
||||||
<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="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>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onclick={() => (showAgentMonitor = !showAgentMonitor)}
|
|
||||||
class="p-1 text-gray-500 icon-trans-hover relative {showAgentMonitor
|
|
||||||
? 'text-[var(--trans-pink)]'
|
|
||||||
: ''}"
|
|
||||||
title="Agent Monitor"
|
|
||||||
>
|
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
{#if activeAgentCount > 0}
|
|
||||||
<span
|
|
||||||
class="absolute -top-1 -right-1 bg-blue-500 text-white text-xs rounded-full w-4 h-4 flex items-center justify-center text-[10px] animate-pulse"
|
|
||||||
>
|
|
||||||
{activeAgentCount}
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onclick={() => (showStats = !showStats)}
|
|
||||||
class="p-1 text-gray-500 icon-trans-hover {showStats ? 'text-[var(--trans-pink)]' : ''}"
|
|
||||||
title="Usage Stats"
|
|
||||||
>
|
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zM13 19v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2h2a2 2 0 002-2zM21 19V8a2 2 0 00-2-2h-2a2 2 0 00-2 2v11a2 2 0 002 2h2a2 2 0 002-2z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onclick={() => debugConsoleStore.toggle()}
|
|
||||||
class="p-1 text-gray-500 icon-trans-hover"
|
|
||||||
title="Debug Console (Ctrl+`)"
|
|
||||||
>
|
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onclick={configStore.openSidebar}
|
|
||||||
class="p-1 text-gray-500 icon-trans-hover"
|
|
||||||
title="Settings"
|
|
||||||
>
|
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onclick={() => openUrl(DONATE_URL)}
|
|
||||||
class="p-1 text-gray-500 icon-trans-hover"
|
|
||||||
title="Support our work"
|
|
||||||
>
|
|
||||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path
|
|
||||||
d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onclick={() => (showAbout = true)}
|
|
||||||
class="p-1 text-gray-500 icon-trans-hover"
|
|
||||||
title="About Hikari Desktop"
|
|
||||||
>
|
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onclick={() => (showKeyboardShortcuts = true)}
|
|
||||||
class="p-1 text-gray-500 icon-trans-hover"
|
|
||||||
title="Keyboard Shortcuts"
|
|
||||||
>
|
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M12 3C10.22 3 8.47 3.23 6.86 3.68A2 2 0 005 5.57V18.43a2 2 0 001.86 1.89C8.47 20.77 10.22 21 12 21s3.53-.23 5.14-.68A2 2 0 0019 18.43V5.57a2 2 0 00-1.86-1.89C15.53 3.23 13.78 3 12 3z"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M8 7h.01M12 7h.01M16 7h.01M8 11h.01M12 11h.01M16 11h.01M8 15h8"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onclick={() => (showHelp = true)}
|
|
||||||
class="p-1 text-gray-500 icon-trans-hover"
|
|
||||||
title="Help"
|
|
||||||
>
|
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onclick={() => openUrl(DISCORD_URL)}
|
|
||||||
class="p-1 text-gray-500 icon-trans-hover"
|
|
||||||
title="Join our Discord"
|
|
||||||
>
|
|
||||||
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
|
|
||||||
<path
|
|
||||||
d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
{#if appVersion}
|
{#if appVersion}
|
||||||
<span class="text-xs text-gray-600">v{appVersion}</span>
|
<span class="text-xs text-gray-600">v{appVersion}</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if showStats}
|
|
||||||
<div class="absolute top-full right-0 mt-2 mr-4 z-50">
|
|
||||||
<StatsDisplay
|
|
||||||
onRequestSummary={handleCompactConversation}
|
|
||||||
onStartFreshWithContext={handleStartFreshWithContext}
|
|
||||||
{isSummarising}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{#if connectionStatus === "connected"}
|
{#if connectionStatus === "connected"}
|
||||||
<button
|
<button
|
||||||
onclick={handleDisconnect}
|
onclick={handleDisconnect}
|
||||||
@@ -763,63 +506,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if showStats}
|
|
||||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
||||||
<div class="fixed inset-0 z-40" onclick={() => (showStats = false)}></div>
|
|
||||||
<div class="fixed top-14 right-4 z-50 max-h-[calc(100vh-4rem)] overflow-y-auto">
|
|
||||||
<StatsDisplay
|
|
||||||
onRequestSummary={handleCompactConversation}
|
|
||||||
onStartFreshWithContext={handleStartFreshWithContext}
|
|
||||||
{isSummarising}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if showAbout}
|
|
||||||
<AboutPanel onClose={() => (showAbout = false)} />
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if showHelp}
|
|
||||||
<HelpPanel onClose={() => (showHelp = false)} />
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if showKeyboardShortcuts}
|
|
||||||
<KeyboardShortcutsModal onClose={() => (showKeyboardShortcuts = false)} />
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if showSessionHistory}
|
|
||||||
<SessionHistoryPanel onClose={() => (showSessionHistory = false)} />
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if showTodoPanel}
|
|
||||||
<TodoPanel onClose={() => (showTodoPanel = false)} />
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if showGitPanel}
|
|
||||||
<GitPanel isOpen={showGitPanel} onClose={() => (showGitPanel = false)} />
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if showProfile}
|
|
||||||
<ProfilePanel onClose={() => (showProfile = false)} />
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if showAgentMonitor}
|
|
||||||
<AgentMonitorPanel isOpen={showAgentMonitor} onClose={() => (showAgentMonitor = false)} />
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if showCastPanel}
|
|
||||||
<CastPanel onClose={() => (showCastPanel = false)} />
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if showPluginPanel}
|
|
||||||
<PluginManagementPanel onClose={() => (showPluginPanel = false)} />
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if showMcpPanel}
|
|
||||||
<McpManagementPanel onClose={() => (showMcpPanel = false)} />
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if showWorkspaceTrust && pendingHookInfo}
|
{#if showWorkspaceTrust && pendingHookInfo}
|
||||||
<WorkspaceTrustModal
|
<WorkspaceTrustModal
|
||||||
hookInfo={pendingHookInfo}
|
hookInfo={pendingHookInfo}
|
||||||
@@ -836,18 +522,11 @@
|
|||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Make all icon buttons shrink but not grow */
|
/* Make all buttons shrink but not grow */
|
||||||
.status-bar button {
|
.status-bar button {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Hide version text on very small screens */
|
|
||||||
@media (max-width: 640px) {
|
|
||||||
.status-bar button span:last-of-type {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Stack left and right sections on very small screens */
|
/* Stack left and right sections on very small screens */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.status-bar {
|
.status-bar {
|
||||||
|
|||||||
@@ -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,785 @@
|
|||||||
|
<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,
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} 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,25 +1,39 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { claudeStore, type TerminalLine } from "$lib/stores/claude";
|
import { claudeStore, type TerminalLine } from "$lib/stores/claude";
|
||||||
import { afterUpdate, tick, onMount, onDestroy } from "svelte";
|
import { afterUpdate, tick, onMount, onDestroy } from "svelte";
|
||||||
|
import { get } from "svelte/store";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
|
||||||
import ConversationTabs from "./ConversationTabs.svelte";
|
import ConversationTabs from "./ConversationTabs.svelte";
|
||||||
import Markdown from "./Markdown.svelte";
|
import Markdown from "./Markdown.svelte";
|
||||||
import HighlightedText from "./HighlightedText.svelte";
|
import HighlightedText from "./HighlightedText.svelte";
|
||||||
import ThinkingBlock from "./ThinkingBlock.svelte";
|
import ThinkingBlock from "./ThinkingBlock.svelte";
|
||||||
|
import ToolCallBlock from "./ToolCallBlock.svelte";
|
||||||
import { searchState, searchQuery } from "$lib/stores/search";
|
import { searchState, searchQuery } from "$lib/stores/search";
|
||||||
import { clipboardStore } from "$lib/stores/clipboard";
|
import { clipboardStore } from "$lib/stores/clipboard";
|
||||||
import { shouldHidePaths, maskPaths, showThinkingBlocks } 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 terminalElement: HTMLDivElement;
|
||||||
let shouldAutoScroll = true;
|
let shouldAutoScroll = true;
|
||||||
let lines: TerminalLine[] = [];
|
let lines: TerminalLine[] = [];
|
||||||
let currentSearchQuery = "";
|
let currentSearchQuery = "";
|
||||||
let currentConversationId: string | null = null;
|
let currentConversationId: string | null = null;
|
||||||
let isRestoringScroll = false;
|
let isRestoringScroll = false;
|
||||||
|
let windowStart = 0;
|
||||||
|
let isLoadingMore = false;
|
||||||
|
let isSwitchingConversation = false;
|
||||||
|
|
||||||
|
let searchDebounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
searchQuery.subscribe((value) => {
|
searchQuery.subscribe((value) => {
|
||||||
|
if (searchDebounceTimer) clearTimeout(searchDebounceTimer);
|
||||||
|
searchDebounceTimer = setTimeout(() => {
|
||||||
currentSearchQuery = value;
|
currentSearchQuery = value;
|
||||||
|
}, 150);
|
||||||
});
|
});
|
||||||
|
|
||||||
let hidePaths = false;
|
let hidePaths = false;
|
||||||
@@ -47,18 +61,42 @@
|
|||||||
|
|
||||||
currentConversationId = newId;
|
currentConversationId = newId;
|
||||||
|
|
||||||
// Restore scroll position for the new conversation after DOM updates
|
// Guard the $: reactive auto-scroll block from firing with stale `lines`
|
||||||
await tick();
|
// (the old conversation's data) during the switch. Without this, Svelte's
|
||||||
if (terminalElement) {
|
// 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);
|
const savedPosition = claudeStore.getScrollPosition(newId);
|
||||||
isRestoringScroll = true;
|
|
||||||
if (savedPosition === -1) {
|
if (savedPosition === -1) {
|
||||||
// Auto-scroll to bottom
|
// Will auto-scroll: pin the window to the tail of the new conversation
|
||||||
shouldAutoScroll = true;
|
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) {
|
||||||
|
if (savedPosition === -1) {
|
||||||
terminalElement.scrollTop = terminalElement.scrollHeight;
|
terminalElement.scrollTop = terminalElement.scrollHeight;
|
||||||
} else {
|
} else {
|
||||||
// Restore to saved position
|
|
||||||
shouldAutoScroll = false;
|
|
||||||
terminalElement.scrollTop = savedPosition;
|
terminalElement.scrollTop = savedPosition;
|
||||||
}
|
}
|
||||||
// Small delay to prevent the scroll handler from overriding our restore
|
// Small delay to prevent the scroll handler from overriding our restore
|
||||||
@@ -68,10 +106,30 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function handleScroll() {
|
async function handleScroll() {
|
||||||
if (!terminalElement || isRestoringScroll) return;
|
if (!terminalElement || isRestoringScroll) return;
|
||||||
const { scrollTop, scrollHeight, clientHeight } = terminalElement;
|
const { scrollTop, scrollHeight, clientHeight } = terminalElement;
|
||||||
shouldAutoScroll = scrollHeight - scrollTop - clientHeight < 100;
|
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(() => {
|
afterUpdate(() => {
|
||||||
@@ -137,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) {
|
if (currentSearchQuery && lines.length > 0) {
|
||||||
const escapedQuery = currentSearchQuery.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
const escapedQuery = currentSearchQuery.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
@@ -178,6 +249,7 @@
|
|||||||
if (terminalElement) {
|
if (terminalElement) {
|
||||||
terminalElement.removeEventListener("copy", handleCopy);
|
terminalElement.removeEventListener("copy", handleCopy);
|
||||||
}
|
}
|
||||||
|
if (searchDebounceTimer) clearTimeout(searchDebounceTimer);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Copy message content to clipboard
|
// Copy message content to clipboard
|
||||||
@@ -208,22 +280,6 @@
|
|||||||
if (!currentConversationId) return;
|
if (!currentConversationId) return;
|
||||||
await invoke("send_prompt", { conversationId: currentConversationId, message: "/compact" });
|
await invoke("send_prompt", { conversationId: currentConversationId, message: "/compact" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collapsible tool lines
|
|
||||||
const TOOL_COLLAPSE_THRESHOLD = 60;
|
|
||||||
let expandedToolLines: Record<string, boolean> = {};
|
|
||||||
|
|
||||||
function isToolContentLong(content: string): boolean {
|
|
||||||
return content.length > TOOL_COLLAPSE_THRESHOLD;
|
|
||||||
}
|
|
||||||
|
|
||||||
function truncateToolContent(content: string): string {
|
|
||||||
return content.slice(0, TOOL_COLLAPSE_THRESHOLD) + "…";
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleToolLine(id: string) {
|
|
||||||
expandedToolLines = { ...expandedToolLines, [id]: !expandedToolLines[id] };
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -246,18 +302,36 @@
|
|||||||
bind:this={terminalElement}
|
bind:this={terminalElement}
|
||||||
onscroll={handleScroll}
|
onscroll={handleScroll}
|
||||||
class="terminal-content h-[calc(100%-76px)] overflow-y-auto p-4 font-mono"
|
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}
|
{#if lines.length === 0}
|
||||||
<div class="terminal-waiting italic">
|
<div class="terminal-waiting italic">
|
||||||
Waiting for Claude... Type a message below to start!
|
Waiting for Claude... Type a message below to start!
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
{#each lines as line (line.id)}
|
<div style="height: {topSpacerHeight}px" aria-hidden="true"></div>
|
||||||
|
{#if windowStart > 0}
|
||||||
|
<div class="terminal-older-indicator">
|
||||||
|
↑ {windowStart} older {windowStart === 1 ? "message" : "messages"} — scroll up to load
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#each visibleLines as line (line.id)}
|
||||||
{#if line.type === "thinking"}
|
{#if line.type === "thinking"}
|
||||||
{#if showThinking}
|
{#if showThinking}
|
||||||
<ThinkingBlock content={line.content} timestamp={line.timestamp} />
|
<ThinkingBlock content={line.content} timestamp={line.timestamp} />
|
||||||
{/if}
|
{/if}
|
||||||
|
{:else if line.type === "tool"}
|
||||||
|
<div
|
||||||
|
style={line.parentToolUseId
|
||||||
|
? "margin-left: 16px; padding-left: 8px; border-left: 2px solid var(--accent-primary);"
|
||||||
|
: ""}
|
||||||
|
>
|
||||||
|
<ToolCallBlock
|
||||||
|
toolName={line.toolName ?? null}
|
||||||
|
content={maskPaths(line.content, hidePaths)}
|
||||||
|
timestamp={line.timestamp}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div
|
<div
|
||||||
class="terminal-line mb-2 {getLineClass(line.type)} relative group"
|
class="terminal-line mb-2 {getLineClass(line.type)} relative group"
|
||||||
@@ -296,9 +370,6 @@
|
|||||||
{#if getLinePrefix(line.type)}
|
{#if getLinePrefix(line.type)}
|
||||||
<span class="terminal-prefix mr-2">{getLinePrefix(line.type)}</span>
|
<span class="terminal-prefix mr-2">{getLinePrefix(line.type)}</span>
|
||||||
{/if}
|
{/if}
|
||||||
{#if line.toolName}
|
|
||||||
<span class="terminal-tool-name mr-2">[{line.toolName}]</span>
|
|
||||||
{/if}
|
|
||||||
{#if line.type === "compact-prompt"}
|
{#if line.type === "compact-prompt"}
|
||||||
<button class="compact-action-btn" onclick={handleCompact}>
|
<button class="compact-action-btn" onclick={handleCompact}>
|
||||||
⚡ Compact Conversation
|
⚡ Compact Conversation
|
||||||
@@ -330,22 +401,6 @@
|
|||||||
<span class="copy-text">{copiedMessageId === line.id ? "Copied!" : "Copy"}</span>
|
<span class="copy-text">{copiedMessageId === line.id ? "Copied!" : "Copy"}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{:else if line.type === "tool" && isToolContentLong(maskPaths(line.content, hidePaths))}
|
|
||||||
<span class="tool-collapsible">
|
|
||||||
<HighlightedText
|
|
||||||
content={expandedToolLines[line.id]
|
|
||||||
? maskPaths(line.content, hidePaths)
|
|
||||||
: truncateToolContent(maskPaths(line.content, hidePaths))}
|
|
||||||
searchQuery={currentSearchQuery}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
class="tool-toggle-btn"
|
|
||||||
onclick={() => toggleToolLine(line.id)}
|
|
||||||
title={expandedToolLines[line.id] ? "Collapse" : "Expand to see full content"}
|
|
||||||
>
|
|
||||||
{expandedToolLines[line.id] ? "▲" : "▼"}
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
{:else}
|
{:else}
|
||||||
<HighlightedText
|
<HighlightedText
|
||||||
content={maskPaths(line.content, hidePaths)}
|
content={maskPaths(line.content, hidePaths)}
|
||||||
@@ -450,6 +505,15 @@
|
|||||||
color: var(--text-secondary);
|
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 {
|
.terminal-header-text {
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
@@ -501,28 +565,4 @@
|
|||||||
.terminal-line {
|
.terminal-line {
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tool-collapsible {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: baseline;
|
|
||||||
gap: 0.4em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tool-toggle-btn {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: var(--text-tertiary, #6b7280);
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.7em;
|
|
||||||
padding: 0;
|
|
||||||
line-height: 1;
|
|
||||||
opacity: 0.7;
|
|
||||||
transition: opacity 0.15s ease;
|
|
||||||
font-family: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tool-toggle-btn:hover {
|
|
||||||
opacity: 1;
|
|
||||||
color: var(--terminal-tool, #c084fc);
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -89,6 +89,27 @@ function truncateToolContent(content: string): string {
|
|||||||
return content.slice(0, TOOL_COLLAPSE_THRESHOLD) + "…";
|
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", () => {
|
describe("getLineClass", () => {
|
||||||
@@ -262,3 +283,86 @@ describe("truncateToolContent", () => {
|
|||||||
expect(result.endsWith("...")).toBe(false);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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,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}
|
|
||||||
@@ -108,6 +108,11 @@
|
|||||||
allowed_tools: grantedToolsList,
|
allowed_tools: grantedToolsList,
|
||||||
use_worktree: config.use_worktree ?? false,
|
use_worktree: config.use_worktree ?? false,
|
||||||
disable_1m_context: config.disable_1m_context ?? false,
|
disable_1m_context: config.disable_1m_context ?? 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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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,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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { isPermissionGranted } from "@tauri-apps/plugin-notification";
|
||||||
|
import { setMockInvokeResult } from "../../../vitest.setup";
|
||||||
|
import { notificationManager } from "./notificationManager";
|
||||||
|
import { sendTerminalNotification } from "./terminalNotifier";
|
||||||
|
import { NotificationType, NOTIFICATION_SOUNDS } from "./types";
|
||||||
|
|
||||||
|
vi.mock("./soundPlayer", () => ({
|
||||||
|
soundPlayer: { play: vi.fn().mockResolvedValue(undefined) },
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./terminalNotifier", () => ({
|
||||||
|
sendTerminalNotification: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("NotificationManager", () => {
|
||||||
|
let consoleSpy: ReturnType<typeof vi.spyOn>;
|
||||||
|
let consoleWarnSpy: ReturnType<typeof vi.spyOn>;
|
||||||
|
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||||
|
consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
consoleSpy.mockRestore();
|
||||||
|
consoleWarnSpy.mockRestore();
|
||||||
|
consoleErrorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("notify()", () => {
|
||||||
|
it("calls the first notification method by default", async () => {
|
||||||
|
await notificationManager.notify(NotificationType.SUCCESS);
|
||||||
|
expect(invoke).toHaveBeenCalledWith(
|
||||||
|
"send_windows_notification",
|
||||||
|
expect.objectContaining({
|
||||||
|
title: NOTIFICATION_SOUNDS[NotificationType.SUCCESS].phrase,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes a custom message to the notification", async () => {
|
||||||
|
await notificationManager.notify(NotificationType.ERROR, "Custom error message");
|
||||||
|
expect(invoke).toHaveBeenCalledWith("send_windows_notification", {
|
||||||
|
title: NOTIFICATION_SOUNDS[NotificationType.ERROR].phrase,
|
||||||
|
body: "Custom error message",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the next method when the first fails", async () => {
|
||||||
|
setMockInvokeResult("send_windows_notification", new Error("Method 1 failed"));
|
||||||
|
await notificationManager.notify(NotificationType.SUCCESS);
|
||||||
|
expect(invoke).toHaveBeenCalledWith("send_windows_toast", expect.any(Object));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to terminal notification when all methods fail", async () => {
|
||||||
|
setMockInvokeResult("send_windows_notification", new Error("failed"));
|
||||||
|
setMockInvokeResult("send_windows_toast", new Error("failed"));
|
||||||
|
setMockInvokeResult("send_wsl_notification", new Error("failed"));
|
||||||
|
setMockInvokeResult("send_notify_send", new Error("failed"));
|
||||||
|
vi.mocked(isPermissionGranted).mockRejectedValueOnce(new Error("Permission check failed"));
|
||||||
|
|
||||||
|
await notificationManager.notify(NotificationType.SUCCESS);
|
||||||
|
|
||||||
|
expect(sendTerminalNotification).toHaveBeenCalledWith(
|
||||||
|
NotificationType.SUCCESS,
|
||||||
|
"Task completed successfully!"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the default SUCCESS message when none is provided", async () => {
|
||||||
|
await notificationManager.notify(NotificationType.SUCCESS);
|
||||||
|
expect(invoke).toHaveBeenCalledWith("send_windows_notification", {
|
||||||
|
title: expect.any(String),
|
||||||
|
body: "Task completed successfully!",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the default ERROR message", async () => {
|
||||||
|
await notificationManager.notify(NotificationType.ERROR);
|
||||||
|
expect(invoke).toHaveBeenCalledWith("send_windows_notification", {
|
||||||
|
title: expect.any(String),
|
||||||
|
body: "Something went wrong...",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the default PERMISSION message", async () => {
|
||||||
|
await notificationManager.notify(NotificationType.PERMISSION);
|
||||||
|
expect(invoke).toHaveBeenCalledWith("send_windows_notification", {
|
||||||
|
title: expect.any(String),
|
||||||
|
body: "Permission needed to continue",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the default CONNECTION message", async () => {
|
||||||
|
await notificationManager.notify(NotificationType.CONNECTION);
|
||||||
|
expect(invoke).toHaveBeenCalledWith("send_windows_notification", {
|
||||||
|
title: expect.any(String),
|
||||||
|
body: "Successfully connected to Claude Code",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the default TASK_START message", async () => {
|
||||||
|
await notificationManager.notify(NotificationType.TASK_START);
|
||||||
|
expect(invoke).toHaveBeenCalledWith("send_windows_notification", {
|
||||||
|
title: expect.any(String),
|
||||||
|
body: "Starting task...",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the default COST_ALERT message", async () => {
|
||||||
|
await notificationManager.notify(NotificationType.COST_ALERT);
|
||||||
|
expect(invoke).toHaveBeenCalledWith("send_windows_notification", {
|
||||||
|
title: expect.any(String),
|
||||||
|
body: "You've exceeded your cost threshold!",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the fallback default message for unhandled types", async () => {
|
||||||
|
await notificationManager.notify(NotificationType.ACHIEVEMENT);
|
||||||
|
expect(invoke).toHaveBeenCalledWith("send_windows_notification", {
|
||||||
|
title: expect.any(String),
|
||||||
|
body: "Notification",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("helper methods", () => {
|
||||||
|
it("notifySuccess calls notify with SUCCESS type and default message", async () => {
|
||||||
|
await notificationManager.notifySuccess();
|
||||||
|
expect(invoke).toHaveBeenCalledWith("send_windows_notification", {
|
||||||
|
title: NOTIFICATION_SOUNDS[NotificationType.SUCCESS].phrase,
|
||||||
|
body: "Task completed successfully!",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("notifySuccess passes a custom message", async () => {
|
||||||
|
await notificationManager.notifySuccess("All done!");
|
||||||
|
expect(invoke).toHaveBeenCalledWith("send_windows_notification", {
|
||||||
|
title: expect.any(String),
|
||||||
|
body: "All done!",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("notifyError calls notify with ERROR type", async () => {
|
||||||
|
await notificationManager.notifyError();
|
||||||
|
expect(invoke).toHaveBeenCalledWith("send_windows_notification", {
|
||||||
|
title: NOTIFICATION_SOUNDS[NotificationType.ERROR].phrase,
|
||||||
|
body: "Something went wrong...",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("notifyPermission calls notify with PERMISSION type", async () => {
|
||||||
|
await notificationManager.notifyPermission();
|
||||||
|
expect(invoke).toHaveBeenCalledWith("send_windows_notification", {
|
||||||
|
title: NOTIFICATION_SOUNDS[NotificationType.PERMISSION].phrase,
|
||||||
|
body: "Permission needed to continue",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("notifyConnection calls notify with CONNECTION type", async () => {
|
||||||
|
await notificationManager.notifyConnection();
|
||||||
|
expect(invoke).toHaveBeenCalledWith("send_windows_notification", {
|
||||||
|
title: NOTIFICATION_SOUNDS[NotificationType.CONNECTION].phrase,
|
||||||
|
body: "Successfully connected to Claude Code",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("notifyTaskStart calls notify with TASK_START type", async () => {
|
||||||
|
await notificationManager.notifyTaskStart();
|
||||||
|
expect(invoke).toHaveBeenCalledWith("send_windows_notification", {
|
||||||
|
title: NOTIFICATION_SOUNDS[NotificationType.TASK_START].phrase,
|
||||||
|
body: "Starting task...",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("notifyCostAlert calls notify with COST_ALERT type", async () => {
|
||||||
|
await notificationManager.notifyCostAlert();
|
||||||
|
expect(invoke).toHaveBeenCalledWith("send_windows_notification", {
|
||||||
|
title: NOTIFICATION_SOUNDS[NotificationType.COST_ALERT].phrase,
|
||||||
|
body: "You've exceeded your cost threshold!",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Tauri plugin notification method (Method 4)", () => {
|
||||||
|
it("sends notification when permission is already granted", async () => {
|
||||||
|
const { isPermissionGranted, sendNotification } =
|
||||||
|
await import("@tauri-apps/plugin-notification");
|
||||||
|
setMockInvokeResult("send_windows_notification", new Error("failed"));
|
||||||
|
setMockInvokeResult("send_windows_toast", new Error("failed"));
|
||||||
|
setMockInvokeResult("send_wsl_notification", new Error("failed"));
|
||||||
|
vi.mocked(isPermissionGranted).mockResolvedValueOnce(true);
|
||||||
|
|
||||||
|
await notificationManager.notify(NotificationType.SUCCESS);
|
||||||
|
|
||||||
|
expect(sendNotification).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
title: NOTIFICATION_SOUNDS[NotificationType.SUCCESS].phrase,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requests permission and sends notification when not yet granted", async () => {
|
||||||
|
const { isPermissionGranted, requestPermission, sendNotification } =
|
||||||
|
await import("@tauri-apps/plugin-notification");
|
||||||
|
setMockInvokeResult("send_windows_notification", new Error("failed"));
|
||||||
|
setMockInvokeResult("send_windows_toast", new Error("failed"));
|
||||||
|
setMockInvokeResult("send_wsl_notification", new Error("failed"));
|
||||||
|
vi.mocked(isPermissionGranted).mockResolvedValueOnce(false);
|
||||||
|
vi.mocked(requestPermission).mockResolvedValueOnce("granted");
|
||||||
|
|
||||||
|
await notificationManager.notify(NotificationType.SUCCESS);
|
||||||
|
|
||||||
|
expect(requestPermission).toHaveBeenCalledWith();
|
||||||
|
expect(sendNotification).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ body: "Task completed successfully!" })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls through to next method when permission is denied", async () => {
|
||||||
|
const { isPermissionGranted, requestPermission } =
|
||||||
|
await import("@tauri-apps/plugin-notification");
|
||||||
|
setMockInvokeResult("send_windows_notification", new Error("failed"));
|
||||||
|
setMockInvokeResult("send_windows_toast", new Error("failed"));
|
||||||
|
setMockInvokeResult("send_wsl_notification", new Error("failed"));
|
||||||
|
vi.mocked(isPermissionGranted).mockResolvedValueOnce(false);
|
||||||
|
vi.mocked(requestPermission).mockResolvedValueOnce("denied");
|
||||||
|
setMockInvokeResult("send_notify_send", new Error("failed"));
|
||||||
|
|
||||||
|
await notificationManager.notify(NotificationType.SUCCESS);
|
||||||
|
|
||||||
|
expect(sendTerminalNotification).toHaveBeenCalledWith(
|
||||||
|
NotificationType.SUCCESS,
|
||||||
|
"Task completed successfully!"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -232,6 +232,53 @@ describe("notifications", () => {
|
|||||||
// Should not throw
|
// Should not throw
|
||||||
await expect(soundPlayer.play(NotificationType.SUCCESS)).resolves.toBeUndefined();
|
await expect(soundPlayer.play(NotificationType.SUCCESS)).resolves.toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("play warns when audio type is not in the cache", async () => {
|
||||||
|
const { soundPlayer } = await import("./soundPlayer");
|
||||||
|
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
|
||||||
|
soundPlayer.setEnabled(true);
|
||||||
|
await soundPlayer.play("nonexistent" as NotificationType);
|
||||||
|
|
||||||
|
expect(warnSpy).toHaveBeenCalledWith("No audio found for notification type: nonexistent");
|
||||||
|
warnSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("play catches errors from audio playback", async () => {
|
||||||
|
vi.resetModules();
|
||||||
|
|
||||||
|
class FailingAudio {
|
||||||
|
volume = 1;
|
||||||
|
preload = "auto";
|
||||||
|
|
||||||
|
cloneNode() {
|
||||||
|
const clone = new FailingAudio();
|
||||||
|
clone.volume = this.volume;
|
||||||
|
return clone;
|
||||||
|
}
|
||||||
|
|
||||||
|
async play(): Promise<void> {
|
||||||
|
throw new Error("Playback blocked by browser");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalAudio = globalThis.Audio;
|
||||||
|
globalThis.Audio = FailingAudio as unknown as typeof Audio;
|
||||||
|
|
||||||
|
const { soundPlayer: freshPlayer } = await import("./soundPlayer");
|
||||||
|
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
|
||||||
|
freshPlayer.setEnabled(true);
|
||||||
|
await freshPlayer.play(NotificationType.SUCCESS);
|
||||||
|
|
||||||
|
expect(errorSpy).toHaveBeenCalledWith(
|
||||||
|
"Failed to play notification sound:",
|
||||||
|
expect.any(Error)
|
||||||
|
);
|
||||||
|
|
||||||
|
errorSpy.mockRestore();
|
||||||
|
globalThis.Audio = originalAudio;
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("NotificationManager class", () => {
|
describe("NotificationManager class", () => {
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
/**
|
||||||
|
* Notification Rules Tests
|
||||||
|
*
|
||||||
|
* Tests the connection status change handler, which fires a connection
|
||||||
|
* notification sound exactly once per reconnect cycle.
|
||||||
|
*
|
||||||
|
* What this module does:
|
||||||
|
* - Tracks the previous connection status in module-level state
|
||||||
|
* - Fires a notification only when transitioning from a non-connected
|
||||||
|
* state (disconnected/connecting) to "connected"
|
||||||
|
* - Ignores the initial connection (null → connected) to avoid noisy
|
||||||
|
* notifications on app start
|
||||||
|
* - Provides no-op handlers for tool execution and user messages
|
||||||
|
* (reserved for future notification rules)
|
||||||
|
* - cleanupNotificationRules() resets tracking state on teardown
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
|
const { mockNotifyConnection } = vi.hoisted(() => ({
|
||||||
|
mockNotifyConnection: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./notificationManager", () => ({
|
||||||
|
notificationManager: {
|
||||||
|
notifyConnection: mockNotifyConnection,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import {
|
||||||
|
handleConnectionStatusChange,
|
||||||
|
handleToolExecution,
|
||||||
|
handleNewUserMessage,
|
||||||
|
initializeNotificationRules,
|
||||||
|
cleanupNotificationRules,
|
||||||
|
} from "./rules";
|
||||||
|
|
||||||
|
// ---
|
||||||
|
|
||||||
|
describe("handleConnectionStatusChange", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockNotifyConnection.mockReset();
|
||||||
|
cleanupNotificationRules(); // Reset module-level previousConnectionStatus to null
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("initial connection (null → status)", () => {
|
||||||
|
it("does not notify on first connection (null → connected)", () => {
|
||||||
|
// previousConnectionStatus is null (falsy), so condition is not met
|
||||||
|
handleConnectionStatusChange("connected");
|
||||||
|
expect(mockNotifyConnection).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not notify when disconnecting from initial state (null → disconnected)", () => {
|
||||||
|
handleConnectionStatusChange("disconnected");
|
||||||
|
expect(mockNotifyConnection).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not notify when entering connecting from initial state (null → connecting)", () => {
|
||||||
|
handleConnectionStatusChange("connecting");
|
||||||
|
expect(mockNotifyConnection).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("reconnection (disconnected → connected)", () => {
|
||||||
|
it("notifies when reconnecting after a disconnection", () => {
|
||||||
|
handleConnectionStatusChange("disconnected");
|
||||||
|
handleConnectionStatusChange("connected");
|
||||||
|
expect(mockNotifyConnection).toHaveBeenCalledWith();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("notifies exactly once per reconnect", () => {
|
||||||
|
handleConnectionStatusChange("disconnected");
|
||||||
|
handleConnectionStatusChange("connected");
|
||||||
|
expect(mockNotifyConnection).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("reconnection (connecting → connected)", () => {
|
||||||
|
it("notifies when transitioning from connecting to connected", () => {
|
||||||
|
handleConnectionStatusChange("connecting");
|
||||||
|
handleConnectionStatusChange("connected");
|
||||||
|
expect(mockNotifyConnection).toHaveBeenCalledWith();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("already connected (connected → connected)", () => {
|
||||||
|
it("does not notify when already connected", () => {
|
||||||
|
handleConnectionStatusChange("disconnected");
|
||||||
|
handleConnectionStatusChange("connected"); // First connection — notifies
|
||||||
|
mockNotifyConnection.mockReset();
|
||||||
|
|
||||||
|
handleConnectionStatusChange("connected"); // Second — same status, no notify
|
||||||
|
expect(mockNotifyConnection).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("disconnecting (connected → disconnected)", () => {
|
||||||
|
it("does not notify when disconnecting", () => {
|
||||||
|
handleConnectionStatusChange("disconnected");
|
||||||
|
handleConnectionStatusChange("connected");
|
||||||
|
mockNotifyConnection.mockReset();
|
||||||
|
|
||||||
|
handleConnectionStatusChange("disconnected");
|
||||||
|
expect(mockNotifyConnection).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("multiple reconnect cycles", () => {
|
||||||
|
it("notifies once per reconnect cycle", () => {
|
||||||
|
// First cycle
|
||||||
|
handleConnectionStatusChange("disconnected");
|
||||||
|
handleConnectionStatusChange("connected");
|
||||||
|
expect(mockNotifyConnection).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
mockNotifyConnection.mockReset();
|
||||||
|
|
||||||
|
// Second cycle
|
||||||
|
handleConnectionStatusChange("disconnected");
|
||||||
|
handleConnectionStatusChange("connected");
|
||||||
|
expect(mockNotifyConnection).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("cleanupNotificationRules", () => {
|
||||||
|
it("resets state so the next connection is treated as the first", () => {
|
||||||
|
// Establish a known previous status
|
||||||
|
handleConnectionStatusChange("disconnected");
|
||||||
|
// Now cleanup
|
||||||
|
cleanupNotificationRules();
|
||||||
|
// After cleanup, previousConnectionStatus is null again
|
||||||
|
// So the next "connected" should NOT notify (treated as initial connection)
|
||||||
|
handleConnectionStatusChange("connected");
|
||||||
|
expect(mockNotifyConnection).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("no-op handlers", () => {
|
||||||
|
it("handleToolExecution does not throw", () => {
|
||||||
|
expect(() => handleToolExecution("Bash")).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handleNewUserMessage does not throw", () => {
|
||||||
|
expect(() => handleNewUserMessage()).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("initializeNotificationRules does not throw", () => {
|
||||||
|
expect(() => initializeNotificationRules()).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { NotificationType } from "./types";
|
||||||
|
import { claudeStore } from "$lib/stores/claude";
|
||||||
|
import { sendTerminalNotification } from "./terminalNotifier";
|
||||||
|
|
||||||
|
vi.mock("$lib/stores/claude", () => ({
|
||||||
|
claudeStore: {
|
||||||
|
addLine: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("sendTerminalNotification", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds a system line for success type with sparkle emoji", () => {
|
||||||
|
sendTerminalNotification(NotificationType.SUCCESS);
|
||||||
|
|
||||||
|
expect(claudeStore.addLine).toHaveBeenCalledWith("system", expect.stringContaining("✨"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds a system line for error type with cross emoji", () => {
|
||||||
|
sendTerminalNotification(NotificationType.ERROR);
|
||||||
|
|
||||||
|
expect(claudeStore.addLine).toHaveBeenCalledWith("system", expect.stringContaining("❌"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds a system line for permission type with lock emoji", () => {
|
||||||
|
sendTerminalNotification(NotificationType.PERMISSION);
|
||||||
|
|
||||||
|
expect(claudeStore.addLine).toHaveBeenCalledWith("system", expect.stringContaining("🔐"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds a system line for connection type with link emoji", () => {
|
||||||
|
sendTerminalNotification(NotificationType.CONNECTION);
|
||||||
|
|
||||||
|
expect(claudeStore.addLine).toHaveBeenCalledWith("system", expect.stringContaining("🔗"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds a system line for task_start type with rocket emoji", () => {
|
||||||
|
sendTerminalNotification(NotificationType.TASK_START);
|
||||||
|
|
||||||
|
expect(claudeStore.addLine).toHaveBeenCalledWith("system", expect.stringContaining("🚀"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes the optional message in the notification", () => {
|
||||||
|
sendTerminalNotification(NotificationType.SUCCESS, "Custom message text");
|
||||||
|
|
||||||
|
expect(claudeStore.addLine).toHaveBeenCalledWith(
|
||||||
|
"system",
|
||||||
|
expect.stringContaining("Custom message text")
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes the sound phrase as the notification title", () => {
|
||||||
|
sendTerminalNotification(NotificationType.SUCCESS);
|
||||||
|
|
||||||
|
expect(claudeStore.addLine).toHaveBeenCalledWith(
|
||||||
|
"system",
|
||||||
|
expect.stringContaining("I'm done!")
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { testAllNotifications } from "./testNotifications";
|
||||||
|
|
||||||
|
vi.mock("./notificationManager", () => ({
|
||||||
|
notificationManager: {
|
||||||
|
notifySuccess: vi.fn().mockResolvedValue(undefined),
|
||||||
|
notifyError: vi.fn().mockResolvedValue(undefined),
|
||||||
|
notifyPermission: vi.fn().mockResolvedValue(undefined),
|
||||||
|
notifyConnection: vi.fn().mockResolvedValue(undefined),
|
||||||
|
notifyTaskStart: vi.fn().mockResolvedValue(undefined),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("testNotifications", () => {
|
||||||
|
describe("window assignment", () => {
|
||||||
|
it("assigns testAllNotifications to window.testNotifications", async () => {
|
||||||
|
// The module-level if block runs on import — reimport to ensure it ran
|
||||||
|
await import("./testNotifications");
|
||||||
|
|
||||||
|
expect((window as unknown as { testNotifications: unknown }).testNotifications).toBe(
|
||||||
|
testAllNotifications
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("testAllNotifications", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is an async function", () => {
|
||||||
|
expect(typeof testAllNotifications).toBe("function");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("schedules all five notification type calls", async () => {
|
||||||
|
const { notificationManager } = await import("./notificationManager");
|
||||||
|
|
||||||
|
const consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||||
|
|
||||||
|
await testAllNotifications();
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
|
||||||
|
expect(notificationManager.notifySuccess).toHaveBeenCalledWith("Test task completed!");
|
||||||
|
expect(notificationManager.notifyError).toHaveBeenCalledWith("Test error occurred!");
|
||||||
|
expect(notificationManager.notifyPermission).toHaveBeenCalledWith("Test permission request!");
|
||||||
|
expect(notificationManager.notifyConnection).toHaveBeenCalledWith(
|
||||||
|
"Test connection established!"
|
||||||
|
);
|
||||||
|
expect(notificationManager.notifyTaskStart).toHaveBeenCalledWith("Test task starting!");
|
||||||
|
|
||||||
|
consoleLogSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { platform } from "@tauri-apps/plugin-os";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { sendWSLNotification } from "./wslNotificationHelper";
|
||||||
|
|
||||||
|
// platform() is mocked in vitest.setup.ts to return "linux" by default
|
||||||
|
|
||||||
|
describe("sendWSLNotification", () => {
|
||||||
|
it("invokes send_windows_notification when platform is windows", async () => {
|
||||||
|
vi.mocked(platform).mockResolvedValueOnce("windows" as Awaited<ReturnType<typeof platform>>);
|
||||||
|
|
||||||
|
await sendWSLNotification("Test Title", "Test body");
|
||||||
|
|
||||||
|
expect(invoke).toHaveBeenCalledWith("send_windows_notification", {
|
||||||
|
title: "Test Title",
|
||||||
|
body: "Test body",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not invoke on non-Windows platforms", async () => {
|
||||||
|
// Default mock returns "linux"
|
||||||
|
await sendWSLNotification("Test Title", "Test body");
|
||||||
|
|
||||||
|
expect(invoke).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles invoke errors gracefully and logs them", async () => {
|
||||||
|
vi.mocked(platform).mockResolvedValueOnce("windows" as Awaited<ReturnType<typeof platform>>);
|
||||||
|
vi.mocked(invoke).mockRejectedValueOnce(new Error("Windows notification failed"));
|
||||||
|
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
|
||||||
|
await sendWSLNotification("Title", "Body");
|
||||||
|
|
||||||
|
expect(errorSpy).toHaveBeenCalledWith(
|
||||||
|
"Failed to send Windows notification:",
|
||||||
|
expect.any(Error)
|
||||||
|
);
|
||||||
|
errorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { NotificationType } from "$lib/notifications/types";
|
||||||
|
|
||||||
|
const mockPlay = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("$lib/notifications", () => ({
|
||||||
|
soundPlayer: {
|
||||||
|
play: mockPlay,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("achievement sounds", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockPlay.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("playAchievementSound", () => {
|
||||||
|
it("plays the achievement notification sound", async () => {
|
||||||
|
const { playAchievementSound } = await import("./achievement");
|
||||||
|
playAchievementSound();
|
||||||
|
expect(mockPlay).toHaveBeenCalledWith(NotificationType.ACHIEVEMENT);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("testAchievementSound", () => {
|
||||||
|
it("calls playAchievementSound without throwing", async () => {
|
||||||
|
const { testAchievementSound } = await import("./achievement");
|
||||||
|
expect(() => testAchievementSound()).not.toThrow();
|
||||||
|
expect(mockPlay).toHaveBeenCalledWith(NotificationType.ACHIEVEMENT);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("catches errors from the sound player gracefully", async () => {
|
||||||
|
mockPlay.mockImplementation(() => {
|
||||||
|
throw new Error("Audio not available");
|
||||||
|
});
|
||||||
|
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
|
||||||
|
const { testAchievementSound } = await import("./achievement");
|
||||||
|
expect(() => testAchievementSound()).not.toThrow();
|
||||||
|
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||||
|
"Error playing achievement sound:",
|
||||||
|
expect.any(Error)
|
||||||
|
);
|
||||||
|
|
||||||
|
consoleErrorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { get } from "svelte/store";
|
||||||
|
import { setMockInvokeResult, emitMockEvent } from "../../../vitest.setup";
|
||||||
|
import {
|
||||||
|
achievementsStore,
|
||||||
|
unlockedAchievements,
|
||||||
|
lockedAchievements,
|
||||||
|
achievementsByRarity,
|
||||||
|
achievementProgress,
|
||||||
|
initAchievementsListener,
|
||||||
|
} from "./achievements";
|
||||||
|
import type { AchievementUnlockedEvent } from "$lib/types/achievements";
|
||||||
|
import { playAchievementSound } from "$lib/sounds/achievement";
|
||||||
|
|
||||||
|
vi.mock("$lib/sounds/achievement", () => ({
|
||||||
|
playAchievementSound: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Helper to build a minimal unlock event
|
||||||
|
function makeEvent(id: AchievementUnlockedEvent["achievement"]["id"]): AchievementUnlockedEvent {
|
||||||
|
return {
|
||||||
|
achievement: {
|
||||||
|
id,
|
||||||
|
name: "Test",
|
||||||
|
description: "Test achievement",
|
||||||
|
icon: "🏆",
|
||||||
|
unlocked_at: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("achievementsStore initial state", () => {
|
||||||
|
it("all achievements start as locked", () => {
|
||||||
|
const state = get(achievementsStore);
|
||||||
|
expect(state.achievements["FirstSteps"].unlocked).toBe(false);
|
||||||
|
expect(state.achievements["GrowingStrong"].unlocked).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("totalUnlocked starts at 0", () => {
|
||||||
|
expect(get(achievementsStore).totalUnlocked).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lastUnlocked starts as null", () => {
|
||||||
|
expect(get(achievementsStore).lastUnlocked).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("derived stores initial state", () => {
|
||||||
|
it("unlockedAchievements is initially empty", () => {
|
||||||
|
expect(get(unlockedAchievements)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lockedAchievements contains all achievements initially", () => {
|
||||||
|
const locked = get(lockedAchievements);
|
||||||
|
const total = Object.keys(get(achievementsStore).achievements).length;
|
||||||
|
expect(locked.length).toBe(total);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("achievementsByRarity groups achievements into rarity buckets", () => {
|
||||||
|
const byRarity = get(achievementsByRarity);
|
||||||
|
expect(byRarity.common).toBeInstanceOf(Array);
|
||||||
|
expect(byRarity.rare).toBeInstanceOf(Array);
|
||||||
|
expect(byRarity.epic).toBeInstanceOf(Array);
|
||||||
|
expect(byRarity.legendary).toBeInstanceOf(Array);
|
||||||
|
expect(byRarity.common.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("achievementProgress shows zero unlocked initially", () => {
|
||||||
|
const progress = get(achievementProgress);
|
||||||
|
expect(progress.unlocked).toBe(0);
|
||||||
|
expect(progress.total).toBeGreaterThan(0);
|
||||||
|
expect(progress.percentage).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("achievementsStore.unlockAchievement", () => {
|
||||||
|
it("marks the achievement as unlocked and updates totalUnlocked", () => {
|
||||||
|
achievementsStore.unlockAchievement(makeEvent("GrowingStrong"));
|
||||||
|
const state = get(achievementsStore);
|
||||||
|
expect(state.achievements["GrowingStrong"].unlocked).toBe(true);
|
||||||
|
expect(state.totalUnlocked).toBe(1);
|
||||||
|
expect(state.lastUnlocked?.id).toBe("GrowingStrong");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets unlockedAt from the event's unlocked_at timestamp", () => {
|
||||||
|
achievementsStore.unlockAchievement({
|
||||||
|
achievement: {
|
||||||
|
id: "BlossomingCoder",
|
||||||
|
name: "Blossoming Coder",
|
||||||
|
description: "100k tokens",
|
||||||
|
icon: "🌸",
|
||||||
|
unlocked_at: "2026-01-15T12:00:00.000Z",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const state = get(achievementsStore);
|
||||||
|
expect(state.achievements["BlossomingCoder"].unlockedAt).toBeInstanceOf(Date);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does nothing when the achievement is already unlocked", () => {
|
||||||
|
achievementsStore.unlockAchievement(makeEvent("TokenMaster"));
|
||||||
|
const firstTotal = get(achievementsStore).totalUnlocked;
|
||||||
|
achievementsStore.unlockAchievement(makeEvent("TokenMaster"));
|
||||||
|
expect(get(achievementsStore).totalUnlocked).toBe(firstTotal);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls playAchievementSound when playSound is true (default)", () => {
|
||||||
|
achievementsStore.unlockAchievement(makeEvent("TokenBillionaire"));
|
||||||
|
expect(playAchievementSound).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not call playAchievementSound when playSound is false", () => {
|
||||||
|
achievementsStore.unlockAchievement(makeEvent("TokenTreasure"), false);
|
||||||
|
expect(playAchievementSound).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logs an error when playAchievementSound throws", () => {
|
||||||
|
vi.mocked(playAchievementSound).mockImplementationOnce(() => {
|
||||||
|
throw new Error("Sound failed");
|
||||||
|
});
|
||||||
|
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
achievementsStore.unlockAchievement(makeEvent("HelloWorld"));
|
||||||
|
expect(consoleSpy).toHaveBeenCalledWith("Failed to play achievement sound:", expect.any(Error));
|
||||||
|
consoleSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("derived stores after unlocks", () => {
|
||||||
|
it("unlockedAchievements includes previously unlocked achievements", () => {
|
||||||
|
const unlocked = get(unlockedAchievements);
|
||||||
|
expect(unlocked.some((a) => a.id === "GrowingStrong")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lockedAchievements excludes previously unlocked achievements", () => {
|
||||||
|
const locked = get(lockedAchievements);
|
||||||
|
expect(locked.some((a) => a.id === "GrowingStrong")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("achievementProgress reflects the current unlocked count", () => {
|
||||||
|
const progress = get(achievementProgress);
|
||||||
|
expect(progress.unlocked).toBeGreaterThan(0);
|
||||||
|
expect(progress.percentage).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("achievementsStore.updateProgress", () => {
|
||||||
|
it("updates the progress value for an achievement", () => {
|
||||||
|
achievementsStore.updateProgress("FirstMessage", 50);
|
||||||
|
expect(get(achievementsStore).achievements["FirstMessage"].progress).toBe(50);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("achievementsStore.reset", () => {
|
||||||
|
it("resets totalUnlocked to 0 and lastUnlocked to null", () => {
|
||||||
|
achievementsStore.reset();
|
||||||
|
const state = get(achievementsStore);
|
||||||
|
expect(state.totalUnlocked).toBe(0);
|
||||||
|
expect(state.lastUnlocked).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("initAchievementsListener", () => {
|
||||||
|
it("unlocks an achievement when the achievement:unlocked event fires", async () => {
|
||||||
|
await initAchievementsListener();
|
||||||
|
emitMockEvent("achievement:unlocked", makeEvent("FirstSteps"));
|
||||||
|
expect(get(achievementsStore).achievements["FirstSteps"].unlocked).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("loads saved achievements from the backend without playing sounds", async () => {
|
||||||
|
setMockInvokeResult("load_saved_achievements", [makeEvent("ConversationStarter")]);
|
||||||
|
await initAchievementsListener();
|
||||||
|
expect(get(achievementsStore).achievements["ConversationStarter"].unlocked).toBe(true);
|
||||||
|
expect(playAchievementSound).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logs an error when loading saved achievements fails", async () => {
|
||||||
|
setMockInvokeResult("load_saved_achievements", new Error("Storage unavailable"));
|
||||||
|
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
await initAchievementsListener();
|
||||||
|
expect(consoleSpy).toHaveBeenCalledWith(
|
||||||
|
"Failed to load saved achievements:",
|
||||||
|
expect.any(Error)
|
||||||
|
);
|
||||||
|
consoleSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1471,11 +1471,14 @@ export const achievementsByRarity = derived(achievementsStore, ($store) => {
|
|||||||
return byRarity;
|
return byRarity;
|
||||||
});
|
});
|
||||||
|
|
||||||
export const achievementProgress = derived(achievementsStore, ($store) => ({
|
export const achievementProgress = derived(achievementsStore, ($store) => {
|
||||||
|
const total = Object.keys($store.achievements).length;
|
||||||
|
return {
|
||||||
unlocked: $store.totalUnlocked,
|
unlocked: $store.totalUnlocked,
|
||||||
total: Object.keys($store.achievements).length,
|
total,
|
||||||
percentage: Math.round(($store.totalUnlocked / Object.keys($store.achievements).length) * 100),
|
percentage: Math.round(($store.totalUnlocked / total) * 100),
|
||||||
}));
|
};
|
||||||
|
});
|
||||||
|
|
||||||
// Initialize achievement listener
|
// Initialize achievement listener
|
||||||
export async function initAchievementsListener() {
|
export async function initAchievementsListener() {
|
||||||
|
|||||||
@@ -43,6 +43,22 @@ describe("agents store", () => {
|
|||||||
expect(agents[0]).toMatchObject(agent);
|
expect(agents[0]).toMatchObject(agent);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("preserves model field when provided", () => {
|
||||||
|
const agent = createMockAgent({ model: "claude-opus-4-6" });
|
||||||
|
agentStore.addAgent(conversationId, agent);
|
||||||
|
|
||||||
|
const agents = get(getAgentsForConversation(conversationId));
|
||||||
|
expect(agents[0].model).toBe("claude-opus-4-6");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves model undefined when not provided", () => {
|
||||||
|
const agent = createMockAgent();
|
||||||
|
agentStore.addAgent(conversationId, agent);
|
||||||
|
|
||||||
|
const agents = get(getAgentsForConversation(conversationId));
|
||||||
|
expect(agents[0].model).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
it("assigns a character name and avatar to added agents", () => {
|
it("assigns a character name and avatar to added agents", () => {
|
||||||
const agent = createMockAgent();
|
const agent = createMockAgent();
|
||||||
agentStore.addAgent(conversationId, agent);
|
agentStore.addAgent(conversationId, agent);
|
||||||
@@ -121,6 +137,28 @@ describe("agents store", () => {
|
|||||||
const agents = get(getAgentsForConversation(conversationId));
|
const agents = get(getAgentsForConversation(conversationId));
|
||||||
expect(agents[0].agentId).toBeUndefined();
|
expect(agents[0].agentId).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("updates agentType when provided alongside agentId", () => {
|
||||||
|
const agent = createMockAgent({ agentId: undefined });
|
||||||
|
agentStore.addAgent(conversationId, agent);
|
||||||
|
|
||||||
|
agentStore.updateAgentId(conversationId, agent.toolUseId, "agent-abc123", "general-purpose");
|
||||||
|
|
||||||
|
const agents = get(getAgentsForConversation(conversationId));
|
||||||
|
expect(agents[0].agentId).toBe("agent-abc123");
|
||||||
|
expect(agents[0].agentType).toBe("general-purpose");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not set agentType when not provided", () => {
|
||||||
|
const agent = createMockAgent({ agentId: undefined });
|
||||||
|
agentStore.addAgent(conversationId, agent);
|
||||||
|
|
||||||
|
agentStore.updateAgentId(conversationId, agent.toolUseId, "agent-abc123");
|
||||||
|
|
||||||
|
const agents = get(getAgentsForConversation(conversationId));
|
||||||
|
expect(agents[0].agentId).toBe("agent-abc123");
|
||||||
|
expect(agents[0].agentType).toBeUndefined();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("endAgent", () => {
|
describe("endAgent", () => {
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ function createAgentStore() {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
updateAgentId(conversationId: string, toolUseId: string, agentId: string) {
|
updateAgentId(conversationId: string, toolUseId: string, agentId: string, agentType?: string) {
|
||||||
agentsByConversation.update((state) => {
|
agentsByConversation.update((state) => {
|
||||||
const agents = state[conversationId];
|
const agents = state[conversationId];
|
||||||
if (!agents) return state;
|
if (!agents) return state;
|
||||||
@@ -36,6 +36,7 @@ function createAgentStore() {
|
|||||||
updated[agentIndex] = {
|
updated[agentIndex] = {
|
||||||
...updated[agentIndex],
|
...updated[agentIndex],
|
||||||
agentId,
|
agentId,
|
||||||
|
...(agentType !== undefined ? { agentType } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
|
||||||
|
import { get } from "svelte/store";
|
||||||
|
import { characterState, characterInfo } from "./character";
|
||||||
|
|
||||||
|
describe("characterState store", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
characterState.reset();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("initial state", () => {
|
||||||
|
it("starts in idle state", () => {
|
||||||
|
expect(get(characterState)).toBe("idle");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("setState", () => {
|
||||||
|
it("sets the character state", () => {
|
||||||
|
characterState.setState("thinking");
|
||||||
|
expect(get(characterState)).toBe("thinking");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can set any valid state", () => {
|
||||||
|
const states = [
|
||||||
|
"idle",
|
||||||
|
"thinking",
|
||||||
|
"typing",
|
||||||
|
"coding",
|
||||||
|
"searching",
|
||||||
|
"mcp",
|
||||||
|
"permission",
|
||||||
|
"success",
|
||||||
|
"error",
|
||||||
|
] as const;
|
||||||
|
for (const state of states) {
|
||||||
|
characterState.setState(state);
|
||||||
|
expect(get(characterState)).toBe(state);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cancels any active temporary state timer", () => {
|
||||||
|
characterState.setTemporaryState("success", 5000);
|
||||||
|
characterState.setState("thinking");
|
||||||
|
|
||||||
|
// Advance past the temporary state duration — should stay as thinking
|
||||||
|
vi.advanceTimersByTime(6000);
|
||||||
|
expect(get(characterState)).toBe("thinking");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("setTemporaryState", () => {
|
||||||
|
it("sets the character state immediately", () => {
|
||||||
|
characterState.setTemporaryState("success", 2000);
|
||||||
|
expect(get(characterState)).toBe("success");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reverts to idle after the specified duration", () => {
|
||||||
|
characterState.setTemporaryState("success", 2000);
|
||||||
|
vi.advanceTimersByTime(2000);
|
||||||
|
expect(get(characterState)).toBe("idle");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses 2000ms as the default duration", () => {
|
||||||
|
characterState.setTemporaryState("error");
|
||||||
|
vi.advanceTimersByTime(1999);
|
||||||
|
expect(get(characterState)).toBe("error");
|
||||||
|
vi.advanceTimersByTime(1);
|
||||||
|
expect(get(characterState)).toBe("idle");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cancels a previous temporary state timer when a new one is set", () => {
|
||||||
|
characterState.setTemporaryState("success", 5000);
|
||||||
|
characterState.setTemporaryState("error", 1000);
|
||||||
|
|
||||||
|
// First timer would have fired at 5000ms but was cancelled
|
||||||
|
vi.advanceTimersByTime(1000);
|
||||||
|
expect(get(characterState)).toBe("idle");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("reset", () => {
|
||||||
|
it("resets the state to idle", () => {
|
||||||
|
characterState.setState("thinking");
|
||||||
|
characterState.reset();
|
||||||
|
expect(get(characterState)).toBe("idle");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cancels any pending temporary state timer", () => {
|
||||||
|
characterState.setTemporaryState("success", 5000);
|
||||||
|
characterState.reset();
|
||||||
|
|
||||||
|
// Should now be idle and should NOT revert again after timer fires
|
||||||
|
expect(get(characterState)).toBe("idle");
|
||||||
|
vi.advanceTimersByTime(5000);
|
||||||
|
expect(get(characterState)).toBe("idle");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("characterInfo derived store", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
characterState.reset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the info object for the current state", () => {
|
||||||
|
const info = get(characterInfo);
|
||||||
|
expect(info).toBeDefined();
|
||||||
|
expect(typeof info.label).toBe("string");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates when the character state changes", () => {
|
||||||
|
characterState.setState("thinking");
|
||||||
|
const thinkingInfo = get(characterInfo);
|
||||||
|
|
||||||
|
characterState.setState("idle");
|
||||||
|
const idleInfo = get(characterInfo);
|
||||||
|
|
||||||
|
expect(thinkingInfo.label).not.toBe(idleInfo.label);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import { describe, it, expect, afterEach } from "vitest";
|
||||||
|
import { get } from "svelte/store";
|
||||||
|
import {
|
||||||
|
claudeStore,
|
||||||
|
hasPermissionPending,
|
||||||
|
hasQuestionPending,
|
||||||
|
isClaudeProcessing,
|
||||||
|
} from "./claude";
|
||||||
|
import { conversationsStore } from "./conversations";
|
||||||
|
import { characterState } from "$lib/stores/character";
|
||||||
|
import type { PermissionRequest, UserQuestionEvent } from "$lib/types/messages";
|
||||||
|
|
||||||
|
describe("claudeStore (compatibility wrapper)", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
conversationsStore.revokeAllTools();
|
||||||
|
conversationsStore.clearPermission();
|
||||||
|
conversationsStore.clearQuestion();
|
||||||
|
conversationsStore.setConnectionStatus("disconnected");
|
||||||
|
characterState.setState("idle");
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getGrantedTools", () => {
|
||||||
|
it("returns an empty array when no tools are granted", () => {
|
||||||
|
expect(claudeStore.getGrantedTools()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns granted tools as an array", () => {
|
||||||
|
conversationsStore.grantTool("Read");
|
||||||
|
conversationsStore.grantTool("Write");
|
||||||
|
|
||||||
|
const tools = claudeStore.getGrantedTools();
|
||||||
|
|
||||||
|
expect(tools).toContain("Read");
|
||||||
|
expect(tools).toContain("Write");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("reset", () => {
|
||||||
|
it("clears terminal lines and resets processing state", () => {
|
||||||
|
conversationsStore.setProcessing(true);
|
||||||
|
conversationsStore.grantTool("Edit");
|
||||||
|
|
||||||
|
claudeStore.reset();
|
||||||
|
|
||||||
|
expect(get(conversationsStore.isProcessing)).toBe(false);
|
||||||
|
expect(claudeStore.getGrantedTools()).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("hasPermissionPending derived store", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
conversationsStore.clearPermission();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is false when there are no pending permissions", () => {
|
||||||
|
expect(get(hasPermissionPending)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is true when there is a pending permission request", () => {
|
||||||
|
const request: PermissionRequest = {
|
||||||
|
id: "perm-1",
|
||||||
|
tool: "Bash",
|
||||||
|
description: "Run a shell command",
|
||||||
|
input: { command: "ls" },
|
||||||
|
};
|
||||||
|
|
||||||
|
conversationsStore.requestPermission(request);
|
||||||
|
|
||||||
|
expect(get(hasPermissionPending)).toBe(true);
|
||||||
|
|
||||||
|
conversationsStore.clearPermission();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("hasQuestionPending derived store", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
conversationsStore.clearQuestion();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is false when there is no pending question", () => {
|
||||||
|
expect(get(hasQuestionPending)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is true when there is a pending question", () => {
|
||||||
|
const question: UserQuestionEvent = {
|
||||||
|
id: "q-1",
|
||||||
|
question: "Which approach do you prefer?",
|
||||||
|
options: [{ label: "A" }, { label: "B" }],
|
||||||
|
multi_select: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
conversationsStore.requestQuestion(question);
|
||||||
|
|
||||||
|
expect(get(hasQuestionPending)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isClaudeProcessing derived store", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
conversationsStore.setConnectionStatus("disconnected");
|
||||||
|
characterState.setState("idle");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is false when disconnected regardless of character state", () => {
|
||||||
|
conversationsStore.setConnectionStatus("disconnected");
|
||||||
|
characterState.setState("thinking");
|
||||||
|
|
||||||
|
expect(get(isClaudeProcessing)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is false when connected but in idle state", () => {
|
||||||
|
conversationsStore.setConnectionStatus("connected");
|
||||||
|
characterState.setState("idle");
|
||||||
|
|
||||||
|
expect(get(isClaudeProcessing)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is true when connected and in thinking state", () => {
|
||||||
|
conversationsStore.setConnectionStatus("connected");
|
||||||
|
characterState.setState("thinking");
|
||||||
|
|
||||||
|
expect(get(isClaudeProcessing)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is true when connected and in coding state", () => {
|
||||||
|
conversationsStore.setConnectionStatus("connected");
|
||||||
|
characterState.setState("coding");
|
||||||
|
|
||||||
|
expect(get(isClaudeProcessing)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is true when connected and in searching state", () => {
|
||||||
|
conversationsStore.setConnectionStatus("connected");
|
||||||
|
characterState.setState("searching");
|
||||||
|
|
||||||
|
expect(get(isClaudeProcessing)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -22,10 +22,12 @@ export const claudeStore = {
|
|||||||
terminalLines: conversationsStore.terminalLines,
|
terminalLines: conversationsStore.terminalLines,
|
||||||
pendingPermission: conversationsStore.pendingPermission,
|
pendingPermission: conversationsStore.pendingPermission,
|
||||||
pendingQuestion: conversationsStore.pendingQuestion,
|
pendingQuestion: conversationsStore.pendingQuestion,
|
||||||
|
pendingElicitation: conversationsStore.pendingElicitation,
|
||||||
isProcessing: conversationsStore.isProcessing,
|
isProcessing: conversationsStore.isProcessing,
|
||||||
grantedTools: conversationsStore.grantedTools,
|
grantedTools: conversationsStore.grantedTools,
|
||||||
pendingRetryMessage: conversationsStore.pendingRetryMessage,
|
pendingRetryMessage: conversationsStore.pendingRetryMessage,
|
||||||
attachments: conversationsStore.attachments,
|
attachments: conversationsStore.attachments,
|
||||||
|
worktreeInfo: conversationsStore.worktreeInfo,
|
||||||
|
|
||||||
// New conversation-aware subscriptions
|
// New conversation-aware subscriptions
|
||||||
conversations: conversationsStore.conversations,
|
conversations: conversationsStore.conversations,
|
||||||
@@ -41,6 +43,7 @@ export const claudeStore = {
|
|||||||
setWorkingDirectory: conversationsStore.setWorkingDirectory,
|
setWorkingDirectory: conversationsStore.setWorkingDirectory,
|
||||||
setWorkingDirectoryForConversation: conversationsStore.setWorkingDirectoryForConversation,
|
setWorkingDirectoryForConversation: conversationsStore.setWorkingDirectoryForConversation,
|
||||||
setProcessing: conversationsStore.setProcessing,
|
setProcessing: conversationsStore.setProcessing,
|
||||||
|
setProcessingForConversation: conversationsStore.setProcessingForConversation,
|
||||||
addLine: conversationsStore.addLine,
|
addLine: conversationsStore.addLine,
|
||||||
addLineToConversation: conversationsStore.addLineToConversation,
|
addLineToConversation: conversationsStore.addLineToConversation,
|
||||||
updateLine: conversationsStore.updateLine,
|
updateLine: conversationsStore.updateLine,
|
||||||
@@ -55,6 +58,10 @@ export const claudeStore = {
|
|||||||
clearQuestion: conversationsStore.clearQuestion,
|
clearQuestion: conversationsStore.clearQuestion,
|
||||||
requestQuestionForConversation: conversationsStore.requestQuestionForConversation,
|
requestQuestionForConversation: conversationsStore.requestQuestionForConversation,
|
||||||
clearQuestionForConversation: conversationsStore.clearQuestionForConversation,
|
clearQuestionForConversation: conversationsStore.clearQuestionForConversation,
|
||||||
|
requestElicitation: conversationsStore.requestElicitation,
|
||||||
|
clearElicitation: conversationsStore.clearElicitation,
|
||||||
|
requestElicitationForConversation: conversationsStore.requestElicitationForConversation,
|
||||||
|
clearElicitationForConversation: conversationsStore.clearElicitationForConversation,
|
||||||
grantTool: conversationsStore.grantTool,
|
grantTool: conversationsStore.grantTool,
|
||||||
revokeAllTools: conversationsStore.revokeAllTools,
|
revokeAllTools: conversationsStore.revokeAllTools,
|
||||||
isToolGranted: conversationsStore.isToolGranted,
|
isToolGranted: conversationsStore.isToolGranted,
|
||||||
@@ -69,6 +76,9 @@ export const claudeStore = {
|
|||||||
// Draft text (per-tab input persistence)
|
// Draft text (per-tab input persistence)
|
||||||
setDraftText: conversationsStore.setDraftText,
|
setDraftText: conversationsStore.setDraftText,
|
||||||
|
|
||||||
|
// Worktree info (per-conversation)
|
||||||
|
setWorktreeInfo: conversationsStore.setWorktreeInfo,
|
||||||
|
|
||||||
// Conversation management
|
// Conversation management
|
||||||
createConversation: conversationsStore.createConversation,
|
createConversation: conversationsStore.createConversation,
|
||||||
deleteConversation: conversationsStore.deleteConversation,
|
deleteConversation: conversationsStore.deleteConversation,
|
||||||
@@ -121,6 +131,12 @@ export const hasQuestionPending = derived(
|
|||||||
($conversation) => $conversation?.pendingQuestion !== null
|
($conversation) => $conversation?.pendingQuestion !== null
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const hasElicitationPending = derived(
|
||||||
|
claudeStore.activeConversation,
|
||||||
|
($conversation) =>
|
||||||
|
$conversation?.pendingElicitation !== null && $conversation?.pendingElicitation !== undefined
|
||||||
|
);
|
||||||
|
|
||||||
// Derived store to check if Claude is currently processing (can be interrupted)
|
// Derived store to check if Claude is currently processing (can be interrupted)
|
||||||
export const isClaudeProcessing = derived(
|
export const isClaudeProcessing = derived(
|
||||||
[claudeStore.connectionStatus, characterState],
|
[claudeStore.connectionStatus, characterState],
|
||||||
|
|||||||
@@ -0,0 +1,675 @@
|
|||||||
|
/**
|
||||||
|
* Clipboard Store Tests
|
||||||
|
*
|
||||||
|
* Tests the pure helper functions and store actions from the clipboard store:
|
||||||
|
* - detectLanguage: identifies programming language from code content
|
||||||
|
* - formatTimestamp: converts an ISO timestamp to a relative time string
|
||||||
|
* - Store actions: loadEntries, captureClipboard, deleteEntry, togglePin, etc.
|
||||||
|
* - Derived stores: filteredEntries (language + search filtering), languages
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { get } from "svelte/store";
|
||||||
|
import { clipboardStore } from "$lib/stores/clipboard";
|
||||||
|
import { setMockInvokeResult } from "../../../vitest.setup";
|
||||||
|
|
||||||
|
describe("detectLanguage", () => {
|
||||||
|
describe("TypeScript detection", () => {
|
||||||
|
it("detects import statements", () => {
|
||||||
|
expect(clipboardStore.detectLanguage("import React from 'react';")).toBe("typescript");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects export statements", () => {
|
||||||
|
expect(clipboardStore.detectLanguage("export function foo() {}")).toBe("typescript");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects const declarations", () => {
|
||||||
|
expect(clipboardStore.detectLanguage("const x = 1;")).toBe("typescript");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects interface declarations", () => {
|
||||||
|
expect(clipboardStore.detectLanguage("interface Foo {\n bar: string;\n}")).toBe(
|
||||||
|
"typescript"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects type aliases", () => {
|
||||||
|
expect(clipboardStore.detectLanguage("type MyType = string | number;")).toBe("typescript");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Python detection", () => {
|
||||||
|
it("detects def statements", () => {
|
||||||
|
expect(clipboardStore.detectLanguage("def foo():\n pass")).toBe("python");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects async def statements", () => {
|
||||||
|
expect(clipboardStore.detectLanguage("async def bar():\n pass")).toBe("python");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects from imports", () => {
|
||||||
|
expect(clipboardStore.detectLanguage("from os import path")).toBe("python");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects the __name__ guard", () => {
|
||||||
|
expect(clipboardStore.detectLanguage("if __name__ == '__main__':\n main()")).toBe("python");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Rust detection", () => {
|
||||||
|
it("detects fn declarations", () => {
|
||||||
|
expect(clipboardStore.detectLanguage('fn main() {\n println!("hello");\n}')).toBe("rust");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects impl blocks", () => {
|
||||||
|
expect(clipboardStore.detectLanguage("impl Foo {\n pub fn new() -> Self {}\n}")).toBe(
|
||||||
|
"rust"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects struct declarations", () => {
|
||||||
|
expect(clipboardStore.detectLanguage("struct Point {\n x: f64,\n y: f64,\n}")).toBe("rust");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects enum declarations", () => {
|
||||||
|
expect(clipboardStore.detectLanguage("enum Direction {\n North,\n South,\n}")).toBe("rust");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects mod declarations", () => {
|
||||||
|
expect(clipboardStore.detectLanguage("mod utils;")).toBe("rust");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects pub visibility", () => {
|
||||||
|
expect(clipboardStore.detectLanguage("pub fn exported() {}")).toBe("rust");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Go detection", () => {
|
||||||
|
it("detects package declarations", () => {
|
||||||
|
expect(clipboardStore.detectLanguage("package main")).toBe("go");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects func declarations", () => {
|
||||||
|
expect(clipboardStore.detectLanguage("func main() {}")).toBe("go");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("PHP detection", () => {
|
||||||
|
it("detects the PHP open tag", () => {
|
||||||
|
expect(clipboardStore.detectLanguage("<?php\necho 'hello';")).toBe("php");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("SQL detection", () => {
|
||||||
|
it("detects SELECT statements", () => {
|
||||||
|
expect(clipboardStore.detectLanguage("SELECT * FROM users WHERE id = 1")).toBe("sql");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects INSERT statements", () => {
|
||||||
|
expect(clipboardStore.detectLanguage("INSERT INTO users (name) VALUES ('Alice')")).toBe(
|
||||||
|
"sql"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects CREATE statements", () => {
|
||||||
|
expect(clipboardStore.detectLanguage("CREATE TABLE users (id INT PRIMARY KEY)")).toBe("sql");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects SQL case-insensitively", () => {
|
||||||
|
expect(clipboardStore.detectLanguage("select * from users")).toBe("sql");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("HTML detection", () => {
|
||||||
|
it("detects DOCTYPE declarations", () => {
|
||||||
|
expect(clipboardStore.detectLanguage("<!DOCTYPE html>")).toBe("html");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects html tags", () => {
|
||||||
|
expect(clipboardStore.detectLanguage("<html><body></body></html>")).toBe("html");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects div tags", () => {
|
||||||
|
expect(clipboardStore.detectLanguage("<div class='foo'>bar</div>")).toBe("html");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects span tags", () => {
|
||||||
|
expect(clipboardStore.detectLanguage("<span>text</span>")).toBe("html");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("JSON detection", () => {
|
||||||
|
it("detects JSON object syntax", () => {
|
||||||
|
expect(clipboardStore.detectLanguage('{"name": "test", "value": 42}')).toBe("json");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects JSON with hyphenated keys", () => {
|
||||||
|
expect(clipboardStore.detectLanguage('{"my-key": "value"}')).toBe("json");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("YAML detection", () => {
|
||||||
|
it("detects YAML document separator", () => {
|
||||||
|
expect(clipboardStore.detectLanguage("---\nkey: value\nother: 123")).toBe("yaml");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("C detection", () => {
|
||||||
|
it("detects #include directives", () => {
|
||||||
|
expect(clipboardStore.detectLanguage("#include <stdio.h>\nint main() {}")).toBe("c");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects #define directives", () => {
|
||||||
|
expect(clipboardStore.detectLanguage("#define MAX 100")).toBe("c");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects #ifdef directives", () => {
|
||||||
|
expect(clipboardStore.detectLanguage("#ifdef DEBUG\n// debug code\n#endif")).toBe("c");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Java detection", () => {
|
||||||
|
it("detects public class declarations", () => {
|
||||||
|
expect(clipboardStore.detectLanguage("public class Foo {\n // ...\n}")).toBe("java");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects private static methods", () => {
|
||||||
|
expect(clipboardStore.detectLanguage("private static void helper() {}")).toBe("java");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects protected interface declarations", () => {
|
||||||
|
expect(clipboardStore.detectLanguage("protected interface Bar {}")).toBe("java");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Bash detection", () => {
|
||||||
|
it("detects shell variable assignments", () => {
|
||||||
|
expect(clipboardStore.detectLanguage("$HOME=/usr/local")).toBe("bash");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects variable assignments with underscores", () => {
|
||||||
|
expect(clipboardStore.detectLanguage("$MY_VAR=some_value")).toBe("bash");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("unknown content", () => {
|
||||||
|
it("returns null for plain text", () => {
|
||||||
|
expect(clipboardStore.detectLanguage("Hello, world!")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for empty string", () => {
|
||||||
|
expect(clipboardStore.detectLanguage("")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for mathematical expressions", () => {
|
||||||
|
expect(clipboardStore.detectLanguage("1 + 1 = 2")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for a markdown heading", () => {
|
||||||
|
expect(clipboardStore.detectLanguage("# My Heading\nSome text")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatTimestamp", () => {
|
||||||
|
const NOW = new Date("2026-03-03T12:00:00.000Z");
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(NOW);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("'Just now' threshold (< 1 minute)", () => {
|
||||||
|
it("returns 'Just now' for a timestamp 30 seconds ago", () => {
|
||||||
|
const ts = new Date("2026-03-03T11:59:30.000Z").toISOString();
|
||||||
|
expect(clipboardStore.formatTimestamp(ts)).toBe("Just now");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 'Just now' for the current moment", () => {
|
||||||
|
const ts = NOW.toISOString();
|
||||||
|
expect(clipboardStore.formatTimestamp(ts)).toBe("Just now");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 'Just now' for a timestamp 59 seconds ago", () => {
|
||||||
|
const ts = new Date("2026-03-03T11:59:01.000Z").toISOString();
|
||||||
|
expect(clipboardStore.formatTimestamp(ts)).toBe("Just now");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("'Xm ago' threshold (1–59 minutes)", () => {
|
||||||
|
it("returns '1m ago' at exactly 1 minute", () => {
|
||||||
|
const ts = new Date("2026-03-03T11:59:00.000Z").toISOString();
|
||||||
|
expect(clipboardStore.formatTimestamp(ts)).toBe("1m ago");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns '5m ago' for a timestamp 5 minutes ago", () => {
|
||||||
|
const ts = new Date("2026-03-03T11:55:00.000Z").toISOString();
|
||||||
|
expect(clipboardStore.formatTimestamp(ts)).toBe("5m ago");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns '59m ago' just before the 1-hour threshold", () => {
|
||||||
|
const ts = new Date("2026-03-03T11:01:00.000Z").toISOString();
|
||||||
|
expect(clipboardStore.formatTimestamp(ts)).toBe("59m ago");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("'Xh ago' threshold (1–23 hours)", () => {
|
||||||
|
it("returns '1h ago' at exactly 1 hour", () => {
|
||||||
|
const ts = new Date("2026-03-03T11:00:00.000Z").toISOString();
|
||||||
|
expect(clipboardStore.formatTimestamp(ts)).toBe("1h ago");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns '2h ago' for a timestamp 2 hours ago", () => {
|
||||||
|
const ts = new Date("2026-03-03T10:00:00.000Z").toISOString();
|
||||||
|
expect(clipboardStore.formatTimestamp(ts)).toBe("2h ago");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns '23h ago' just before the 1-day threshold", () => {
|
||||||
|
const ts = new Date("2026-03-02T13:00:00.000Z").toISOString();
|
||||||
|
expect(clipboardStore.formatTimestamp(ts)).toBe("23h ago");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("'Xd ago' threshold (1–6 days)", () => {
|
||||||
|
it("returns '1d ago' at exactly 1 day", () => {
|
||||||
|
const ts = new Date("2026-03-02T12:00:00.000Z").toISOString();
|
||||||
|
expect(clipboardStore.formatTimestamp(ts)).toBe("1d ago");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns '3d ago' for a timestamp 3 days ago", () => {
|
||||||
|
const ts = new Date("2026-02-28T12:00:00.000Z").toISOString();
|
||||||
|
expect(clipboardStore.formatTimestamp(ts)).toBe("3d ago");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns '6d ago' just before the 7-day threshold", () => {
|
||||||
|
const ts = new Date("2026-02-25T12:00:00.000Z").toISOString();
|
||||||
|
expect(clipboardStore.formatTimestamp(ts)).toBe("6d ago");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("locale date string (7+ days ago)", () => {
|
||||||
|
it("returns a locale date string for a 2-week-old timestamp", () => {
|
||||||
|
const ts = new Date("2026-02-17T12:00:00.000Z").toISOString();
|
||||||
|
const result = clipboardStore.formatTimestamp(ts);
|
||||||
|
// Should not be a relative time string
|
||||||
|
expect(result).not.toContain("m ago");
|
||||||
|
expect(result).not.toContain("h ago");
|
||||||
|
expect(result).not.toContain("d ago");
|
||||||
|
expect(result).not.toBe("Just now");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a locale date string for a 1-month-old timestamp", () => {
|
||||||
|
const ts = new Date("2026-02-03T12:00:00.000Z").toISOString();
|
||||||
|
const result = clipboardStore.formatTimestamp(ts);
|
||||||
|
expect(result).not.toContain("ago");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("clipboardStore - derived stores", () => {
|
||||||
|
const makeEntry = (overrides: Record<string, unknown> = {}) => ({
|
||||||
|
id: "entry-1",
|
||||||
|
content: "const x = 1;",
|
||||||
|
language: "typescript",
|
||||||
|
source: "test.ts",
|
||||||
|
timestamp: "2026-03-03T12:00:00.000Z",
|
||||||
|
is_pinned: false,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
clipboardStore.entries.set([]);
|
||||||
|
clipboardStore.searchQuery.set("");
|
||||||
|
clipboardStore.languageFilter.set(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("filteredEntries - language filter", () => {
|
||||||
|
it("returns all entries when no language filter is set", () => {
|
||||||
|
clipboardStore.entries.set([
|
||||||
|
makeEntry({ id: "1", language: "typescript" }),
|
||||||
|
makeEntry({ id: "2", language: "python" }),
|
||||||
|
]);
|
||||||
|
const filtered = get(clipboardStore.filteredEntries);
|
||||||
|
expect(filtered).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters entries by language", () => {
|
||||||
|
clipboardStore.entries.set([
|
||||||
|
makeEntry({ id: "1", language: "typescript" }),
|
||||||
|
makeEntry({ id: "2", language: "python" }),
|
||||||
|
makeEntry({ id: "3", language: "typescript" }),
|
||||||
|
]);
|
||||||
|
clipboardStore.languageFilter.set("typescript");
|
||||||
|
const filtered = get(clipboardStore.filteredEntries);
|
||||||
|
expect(filtered).toHaveLength(2);
|
||||||
|
expect(filtered.every((e) => e.language === "typescript")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty array when filter matches nothing", () => {
|
||||||
|
clipboardStore.entries.set([makeEntry({ language: "typescript" })]);
|
||||||
|
clipboardStore.languageFilter.set("rust");
|
||||||
|
const filtered = get(clipboardStore.filteredEntries);
|
||||||
|
expect(filtered).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("filteredEntries - search query", () => {
|
||||||
|
it("filters by content (case-insensitive)", () => {
|
||||||
|
clipboardStore.entries.set([
|
||||||
|
makeEntry({ id: "1", content: "const HELLO = 1;" }),
|
||||||
|
makeEntry({ id: "2", content: "let world = 2;" }),
|
||||||
|
]);
|
||||||
|
clipboardStore.searchQuery.set("hello");
|
||||||
|
const filtered = get(clipboardStore.filteredEntries);
|
||||||
|
expect(filtered).toHaveLength(1);
|
||||||
|
expect(filtered[0].id).toBe("1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters by language field", () => {
|
||||||
|
clipboardStore.entries.set([
|
||||||
|
makeEntry({ id: "1", language: "typescript", content: "unrelated" }),
|
||||||
|
makeEntry({ id: "2", language: "python", content: "also unrelated" }),
|
||||||
|
]);
|
||||||
|
clipboardStore.searchQuery.set("python");
|
||||||
|
const filtered = get(clipboardStore.filteredEntries);
|
||||||
|
expect(filtered).toHaveLength(1);
|
||||||
|
expect(filtered[0].id).toBe("2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters by source field", () => {
|
||||||
|
clipboardStore.entries.set([
|
||||||
|
makeEntry({ id: "1", source: "main.rs", content: "fn main() {}" }),
|
||||||
|
makeEntry({ id: "2", source: "app.ts", content: "const x = 1;" }),
|
||||||
|
]);
|
||||||
|
clipboardStore.searchQuery.set("main.rs");
|
||||||
|
const filtered = get(clipboardStore.filteredEntries);
|
||||||
|
expect(filtered).toHaveLength(1);
|
||||||
|
expect(filtered[0].id).toBe("1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns all entries when search query is empty", () => {
|
||||||
|
clipboardStore.entries.set([makeEntry({ id: "1" }), makeEntry({ id: "2" })]);
|
||||||
|
clipboardStore.searchQuery.set("");
|
||||||
|
const filtered = get(clipboardStore.filteredEntries);
|
||||||
|
expect(filtered).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("languages derived store", () => {
|
||||||
|
it("returns empty array when no entries", () => {
|
||||||
|
clipboardStore.entries.set([]);
|
||||||
|
expect(get(clipboardStore.languages)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns unique sorted languages", () => {
|
||||||
|
clipboardStore.entries.set([
|
||||||
|
makeEntry({ language: "typescript" }),
|
||||||
|
makeEntry({ language: "python" }),
|
||||||
|
makeEntry({ language: "typescript" }),
|
||||||
|
makeEntry({ language: "rust" }),
|
||||||
|
]);
|
||||||
|
const langs = get(clipboardStore.languages);
|
||||||
|
expect(langs).toEqual(["python", "rust", "typescript"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("excludes entries with null language", () => {
|
||||||
|
clipboardStore.entries.set([
|
||||||
|
makeEntry({ language: "typescript" }),
|
||||||
|
makeEntry({ language: null }),
|
||||||
|
]);
|
||||||
|
const langs = get(clipboardStore.languages);
|
||||||
|
expect(langs).toEqual(["typescript"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("clipboardStore - setSearchQuery and setLanguageFilter", () => {
|
||||||
|
it("setSearchQuery updates the searchQuery store", () => {
|
||||||
|
clipboardStore.setSearchQuery("hello world");
|
||||||
|
expect(get(clipboardStore.searchQuery)).toBe("hello world");
|
||||||
|
clipboardStore.setSearchQuery("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setLanguageFilter updates the languageFilter store", () => {
|
||||||
|
clipboardStore.setLanguageFilter("python");
|
||||||
|
expect(get(clipboardStore.languageFilter)).toBe("python");
|
||||||
|
clipboardStore.setLanguageFilter(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setLanguageFilter can be reset to null", () => {
|
||||||
|
clipboardStore.setLanguageFilter("rust");
|
||||||
|
clipboardStore.setLanguageFilter(null);
|
||||||
|
expect(get(clipboardStore.languageFilter)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("clipboardStore - store actions", () => {
|
||||||
|
const mockEntry = {
|
||||||
|
id: "entry-1",
|
||||||
|
content: "const x = 1;",
|
||||||
|
language: "typescript",
|
||||||
|
source: "test.ts",
|
||||||
|
timestamp: "2026-03-03T12:00:00.000Z",
|
||||||
|
is_pinned: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
clipboardStore.entries.set([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("loadEntries", () => {
|
||||||
|
it("loads entries from backend and updates the store", async () => {
|
||||||
|
setMockInvokeResult("list_clipboard_entries", [mockEntry]);
|
||||||
|
await clipboardStore.loadEntries();
|
||||||
|
expect(get(clipboardStore.entries)).toEqual([mockEntry]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles errors gracefully without crashing", async () => {
|
||||||
|
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
setMockInvokeResult("list_clipboard_entries", new Error("Backend unavailable"));
|
||||||
|
await clipboardStore.loadEntries();
|
||||||
|
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||||
|
"Failed to load clipboard entries:",
|
||||||
|
expect.any(Error)
|
||||||
|
);
|
||||||
|
consoleErrorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets isLoading to false after completion", async () => {
|
||||||
|
setMockInvokeResult("list_clipboard_entries", []);
|
||||||
|
await clipboardStore.loadEntries();
|
||||||
|
expect(get(clipboardStore.isLoading)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("captureClipboard", () => {
|
||||||
|
it("captures clipboard entry with provided language", async () => {
|
||||||
|
setMockInvokeResult("capture_clipboard", mockEntry);
|
||||||
|
setMockInvokeResult("list_clipboard_entries", [mockEntry]);
|
||||||
|
const result = await clipboardStore.captureClipboard("const x = 1;", "typescript", "test.ts");
|
||||||
|
expect(result).toEqual(mockEntry);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("auto-detects language when none provided", async () => {
|
||||||
|
setMockInvokeResult("capture_clipboard", mockEntry);
|
||||||
|
setMockInvokeResult("list_clipboard_entries", [mockEntry]);
|
||||||
|
const result = await clipboardStore.captureClipboard("const x = 1;");
|
||||||
|
expect(result).toEqual(mockEntry);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null on error", async () => {
|
||||||
|
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
setMockInvokeResult("capture_clipboard", new Error("Failed"));
|
||||||
|
const result = await clipboardStore.captureClipboard("const x = 1;");
|
||||||
|
expect(result).toBeNull();
|
||||||
|
consoleErrorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("deleteEntry", () => {
|
||||||
|
it("removes entry from store on success", async () => {
|
||||||
|
clipboardStore.entries.set([mockEntry, { ...mockEntry, id: "entry-2" }]);
|
||||||
|
setMockInvokeResult("delete_clipboard_entry", undefined);
|
||||||
|
await clipboardStore.deleteEntry("entry-1");
|
||||||
|
expect(get(clipboardStore.entries)).toHaveLength(1);
|
||||||
|
expect(get(clipboardStore.entries)[0].id).toBe("entry-2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles errors gracefully", async () => {
|
||||||
|
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
setMockInvokeResult("delete_clipboard_entry", new Error("Failed"));
|
||||||
|
await clipboardStore.deleteEntry("entry-1");
|
||||||
|
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||||
|
"Failed to delete clipboard entry:",
|
||||||
|
expect.any(Error)
|
||||||
|
);
|
||||||
|
consoleErrorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("togglePin", () => {
|
||||||
|
it("updates entry pin status and sorts pinned first", async () => {
|
||||||
|
const unpinned1 = { ...mockEntry, id: "entry-1", is_pinned: false };
|
||||||
|
const unpinned2 = { ...mockEntry, id: "entry-2", is_pinned: false };
|
||||||
|
clipboardStore.entries.set([unpinned1, unpinned2]);
|
||||||
|
const pinned = { ...unpinned2, is_pinned: true };
|
||||||
|
setMockInvokeResult("toggle_pin_clipboard_entry", pinned);
|
||||||
|
await clipboardStore.togglePin("entry-2");
|
||||||
|
const entries = get(clipboardStore.entries);
|
||||||
|
expect(entries[0].id).toBe("entry-2");
|
||||||
|
expect(entries[0].is_pinned).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles errors gracefully", async () => {
|
||||||
|
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
setMockInvokeResult("toggle_pin_clipboard_entry", new Error("Failed"));
|
||||||
|
await clipboardStore.togglePin("entry-1");
|
||||||
|
expect(consoleErrorSpy).toHaveBeenCalledWith("Failed to toggle pin:", expect.any(Error));
|
||||||
|
consoleErrorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exercises the unpinned-before-pinned sort branch", async () => {
|
||||||
|
// Three entries so sort compares (entry-1 unpinned, entry-2 pinned) — hits line 144
|
||||||
|
const e1 = { ...mockEntry, id: "e1", is_pinned: false };
|
||||||
|
const e2 = { ...mockEntry, id: "e2", is_pinned: false };
|
||||||
|
const e3 = { ...mockEntry, id: "e3", is_pinned: false };
|
||||||
|
clipboardStore.entries.set([e1, e2, e3]);
|
||||||
|
const pinned = { ...e2, is_pinned: true };
|
||||||
|
setMockInvokeResult("toggle_pin_clipboard_entry", pinned);
|
||||||
|
await clipboardStore.togglePin("e2");
|
||||||
|
expect(get(clipboardStore.entries)[0].id).toBe("e2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sorts same-pin-status entries by timestamp descending", async () => {
|
||||||
|
// Toggle entry to pinned while two others remain unpinned — sort compares unpinned pair by timestamp
|
||||||
|
const older = {
|
||||||
|
...mockEntry,
|
||||||
|
id: "older",
|
||||||
|
is_pinned: false,
|
||||||
|
timestamp: "2026-01-01T00:00:00.000Z",
|
||||||
|
};
|
||||||
|
const newer = {
|
||||||
|
...mockEntry,
|
||||||
|
id: "newer",
|
||||||
|
is_pinned: false,
|
||||||
|
timestamp: "2026-01-02T00:00:00.000Z",
|
||||||
|
};
|
||||||
|
const pinned = {
|
||||||
|
...mockEntry,
|
||||||
|
id: "pinned",
|
||||||
|
is_pinned: false,
|
||||||
|
timestamp: "2026-01-03T00:00:00.000Z",
|
||||||
|
};
|
||||||
|
clipboardStore.entries.set([older, newer, pinned]);
|
||||||
|
const pinnedResult = { ...pinned, is_pinned: true };
|
||||||
|
setMockInvokeResult("toggle_pin_clipboard_entry", pinnedResult);
|
||||||
|
await clipboardStore.togglePin("pinned");
|
||||||
|
const entries = get(clipboardStore.entries);
|
||||||
|
expect(entries[0].id).toBe("pinned");
|
||||||
|
expect(entries[1].id).toBe("newer");
|
||||||
|
expect(entries[2].id).toBe("older");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("clearHistory", () => {
|
||||||
|
it("removes unpinned entries and keeps pinned ones", async () => {
|
||||||
|
const pinned = { ...mockEntry, id: "pinned", is_pinned: true };
|
||||||
|
const unpinned = { ...mockEntry, id: "unpinned", is_pinned: false };
|
||||||
|
clipboardStore.entries.set([pinned, unpinned]);
|
||||||
|
setMockInvokeResult("clear_clipboard_history", undefined);
|
||||||
|
await clipboardStore.clearHistory();
|
||||||
|
const entries = get(clipboardStore.entries);
|
||||||
|
expect(entries).toHaveLength(1);
|
||||||
|
expect(entries[0].id).toBe("pinned");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles errors gracefully", async () => {
|
||||||
|
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
setMockInvokeResult("clear_clipboard_history", new Error("Failed"));
|
||||||
|
await clipboardStore.clearHistory();
|
||||||
|
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||||
|
"Failed to clear clipboard history:",
|
||||||
|
expect.any(Error)
|
||||||
|
);
|
||||||
|
consoleErrorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("updateLanguage", () => {
|
||||||
|
it("updates entry language in the store", async () => {
|
||||||
|
clipboardStore.entries.set([mockEntry]);
|
||||||
|
const updated = { ...mockEntry, language: "javascript" };
|
||||||
|
setMockInvokeResult("update_clipboard_language", updated);
|
||||||
|
await clipboardStore.updateLanguage("entry-1", "javascript");
|
||||||
|
expect(get(clipboardStore.entries)[0].language).toBe("javascript");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves non-matching entries unchanged", async () => {
|
||||||
|
const other = { ...mockEntry, id: "other", language: "python" };
|
||||||
|
clipboardStore.entries.set([mockEntry, other]);
|
||||||
|
const updated = { ...mockEntry, language: "javascript" };
|
||||||
|
setMockInvokeResult("update_clipboard_language", updated);
|
||||||
|
await clipboardStore.updateLanguage("entry-1", "javascript");
|
||||||
|
const entries = get(clipboardStore.entries);
|
||||||
|
expect(entries[1].language).toBe("python");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles errors gracefully", async () => {
|
||||||
|
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
setMockInvokeResult("update_clipboard_language", new Error("Failed"));
|
||||||
|
await clipboardStore.updateLanguage("entry-1", "javascript");
|
||||||
|
expect(consoleErrorSpy).toHaveBeenCalledWith("Failed to update language:", expect.any(Error));
|
||||||
|
consoleErrorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("copyToClipboard", () => {
|
||||||
|
it("returns true and copies text on success", async () => {
|
||||||
|
Object.assign(navigator, {
|
||||||
|
clipboard: { writeText: vi.fn().mockResolvedValue(undefined) },
|
||||||
|
});
|
||||||
|
const result = await clipboardStore.copyToClipboard("hello world");
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(navigator.clipboard.writeText).toHaveBeenCalledWith("hello world");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false and logs error on failure", async () => {
|
||||||
|
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
Object.assign(navigator, {
|
||||||
|
clipboard: { writeText: vi.fn().mockRejectedValue(new Error("Clipboard denied")) },
|
||||||
|
});
|
||||||
|
const result = await clipboardStore.copyToClipboard("hello world");
|
||||||
|
expect(result).toBe(false);
|
||||||
|
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||||
|
"Failed to copy to clipboard:",
|
||||||
|
expect.any(Error)
|
||||||
|
);
|
||||||
|
consoleErrorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,12 +1,20 @@
|
|||||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||||
|
import { get } from "svelte/store";
|
||||||
import {
|
import {
|
||||||
configStore,
|
configStore,
|
||||||
maskPaths,
|
maskPaths,
|
||||||
clampFontSize,
|
clampFontSize,
|
||||||
applyFontSize,
|
applyFontSize,
|
||||||
|
applyCustomFont,
|
||||||
|
applyCustomUiFont,
|
||||||
applyTheme,
|
applyTheme,
|
||||||
applyCustomThemeColors,
|
applyCustomThemeColors,
|
||||||
clearCustomThemeColors,
|
clearCustomThemeColors,
|
||||||
|
isDarkTheme,
|
||||||
|
isStreamerMode,
|
||||||
|
isCompactMode,
|
||||||
|
shouldHidePaths,
|
||||||
|
showThinkingBlocks,
|
||||||
MIN_FONT_SIZE,
|
MIN_FONT_SIZE,
|
||||||
MAX_FONT_SIZE,
|
MAX_FONT_SIZE,
|
||||||
DEFAULT_FONT_SIZE,
|
DEFAULT_FONT_SIZE,
|
||||||
@@ -15,12 +23,17 @@ import {
|
|||||||
type CustomThemeColors,
|
type CustomThemeColors,
|
||||||
} from "./config";
|
} from "./config";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { readFile } from "@tauri-apps/plugin-fs";
|
||||||
|
|
||||||
// Mock Tauri APIs
|
// Mock Tauri APIs
|
||||||
vi.mock("@tauri-apps/api/core", () => ({
|
vi.mock("@tauri-apps/api/core", () => ({
|
||||||
invoke: vi.fn(),
|
invoke: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("@tauri-apps/plugin-fs", () => ({
|
||||||
|
readFile: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
describe("config store", () => {
|
describe("config store", () => {
|
||||||
describe("font size constants", () => {
|
describe("font size constants", () => {
|
||||||
it("has correct MIN_FONT_SIZE", () => {
|
it("has correct MIN_FONT_SIZE", () => {
|
||||||
@@ -196,9 +209,22 @@ describe("config store", () => {
|
|||||||
show_thinking_blocks: true,
|
show_thinking_blocks: true,
|
||||||
use_worktree: false,
|
use_worktree: false,
|
||||||
disable_1m_context: false,
|
disable_1m_context: false,
|
||||||
|
max_output_tokens: null,
|
||||||
trusted_workspaces: [],
|
trusted_workspaces: [],
|
||||||
background_image_path: null,
|
background_image_path: null,
|
||||||
background_image_opacity: 0.3,
|
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,
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(config.model).toBe("claude-sonnet-4");
|
expect(config.model).toBe("claude-sonnet-4");
|
||||||
@@ -247,9 +273,22 @@ describe("config store", () => {
|
|||||||
show_thinking_blocks: true,
|
show_thinking_blocks: true,
|
||||||
use_worktree: false,
|
use_worktree: false,
|
||||||
disable_1m_context: false,
|
disable_1m_context: false,
|
||||||
|
max_output_tokens: null,
|
||||||
trusted_workspaces: [],
|
trusted_workspaces: [],
|
||||||
background_image_path: null,
|
background_image_path: null,
|
||||||
background_image_opacity: 0.3,
|
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,
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(config.model).toBeNull();
|
expect(config.model).toBeNull();
|
||||||
@@ -314,6 +353,62 @@ describe("config store", () => {
|
|||||||
expect(document.documentElement.getAttribute("data-theme")).toBe("dark");
|
expect(document.documentElement.getAttribute("data-theme")).toBe("dark");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("sets data-theme attribute for dracula theme", () => {
|
||||||
|
applyTheme("dracula");
|
||||||
|
expect(document.documentElement.getAttribute("data-theme")).toBe("dracula");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets data-theme attribute for catppuccin theme", () => {
|
||||||
|
applyTheme("catppuccin");
|
||||||
|
expect(document.documentElement.getAttribute("data-theme")).toBe("catppuccin");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets data-theme attribute for nord theme", () => {
|
||||||
|
applyTheme("nord");
|
||||||
|
expect(document.documentElement.getAttribute("data-theme")).toBe("nord");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets data-theme attribute for solarized theme", () => {
|
||||||
|
applyTheme("solarized");
|
||||||
|
expect(document.documentElement.getAttribute("data-theme")).toBe("solarized");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets data-theme attribute for solarized-light theme", () => {
|
||||||
|
applyTheme("solarized-light");
|
||||||
|
expect(document.documentElement.getAttribute("data-theme")).toBe("solarized-light");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets data-theme attribute for catppuccin-latte theme", () => {
|
||||||
|
applyTheme("catppuccin-latte");
|
||||||
|
expect(document.documentElement.getAttribute("data-theme")).toBe("catppuccin-latte");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets data-theme attribute for gruvbox-light theme", () => {
|
||||||
|
applyTheme("gruvbox-light");
|
||||||
|
expect(document.documentElement.getAttribute("data-theme")).toBe("gruvbox-light");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets data-theme attribute for rose-pine-dawn theme", () => {
|
||||||
|
applyTheme("rose-pine-dawn");
|
||||||
|
expect(document.documentElement.getAttribute("data-theme")).toBe("rose-pine-dawn");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not apply custom colors for preset themes", () => {
|
||||||
|
const colors: CustomThemeColors = {
|
||||||
|
bg_primary: "#ff0000",
|
||||||
|
bg_secondary: null,
|
||||||
|
bg_terminal: null,
|
||||||
|
accent_primary: null,
|
||||||
|
accent_secondary: null,
|
||||||
|
text_primary: null,
|
||||||
|
text_secondary: null,
|
||||||
|
border_color: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
applyTheme("dracula", colors);
|
||||||
|
expect(document.documentElement.style.getPropertyValue("--bg-primary")).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
it("applies custom colors when theme is custom", () => {
|
it("applies custom colors when theme is custom", () => {
|
||||||
const colors: CustomThemeColors = {
|
const colors: CustomThemeColors = {
|
||||||
bg_primary: "#1a1a2e",
|
bg_primary: "#1a1a2e",
|
||||||
@@ -797,9 +892,22 @@ describe("config store", () => {
|
|||||||
show_thinking_blocks: true,
|
show_thinking_blocks: true,
|
||||||
use_worktree: false,
|
use_worktree: false,
|
||||||
disable_1m_context: false,
|
disable_1m_context: false,
|
||||||
|
max_output_tokens: null,
|
||||||
trusted_workspaces: [],
|
trusted_workspaces: [],
|
||||||
background_image_path: null,
|
background_image_path: null,
|
||||||
background_image_opacity: 0.3,
|
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,
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockInvokeImpl = vi.mocked(invoke);
|
const mockInvokeImpl = vi.mocked(invoke);
|
||||||
@@ -840,4 +948,607 @@ describe("config store", () => {
|
|||||||
expect(lastSaved.font_size).toBe(16);
|
expect(lastSaved.font_size).toBe(16);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("loadConfig error path", () => {
|
||||||
|
it("resets to default config and logs error when loadConfig fails", async () => {
|
||||||
|
vi.mocked(invoke).mockResolvedValue(null);
|
||||||
|
await configStore.updateConfig({ theme: "light" });
|
||||||
|
|
||||||
|
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
vi.mocked(invoke).mockRejectedValue(new Error("Backend unavailable"));
|
||||||
|
|
||||||
|
await configStore.loadConfig();
|
||||||
|
|
||||||
|
expect(configStore.getConfig().theme).toBe("dark");
|
||||||
|
expect(consoleErrorSpy).toHaveBeenCalledWith("Failed to load config:", expect.any(Error));
|
||||||
|
consoleErrorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("configStore sidebar methods", () => {
|
||||||
|
it("openSidebar sets isSidebarOpen to true", () => {
|
||||||
|
configStore.closeSidebar();
|
||||||
|
configStore.openSidebar();
|
||||||
|
expect(get(configStore.isSidebarOpen)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closeSidebar sets isSidebarOpen to false", () => {
|
||||||
|
configStore.openSidebar();
|
||||||
|
configStore.closeSidebar();
|
||||||
|
expect(get(configStore.isSidebarOpen)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toggleSidebar switches from false to true", () => {
|
||||||
|
configStore.closeSidebar();
|
||||||
|
configStore.toggleSidebar();
|
||||||
|
expect(get(configStore.isSidebarOpen)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toggleSidebar switches from true to false", () => {
|
||||||
|
configStore.openSidebar();
|
||||||
|
configStore.toggleSidebar();
|
||||||
|
expect(get(configStore.isSidebarOpen)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("configStore setTheme method", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.mocked(invoke).mockResolvedValue(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setTheme updates the theme via invoke", async () => {
|
||||||
|
await configStore.setTheme("light");
|
||||||
|
expect(configStore.getConfig().theme).toBe("light");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setTheme with custom colors updates custom_theme_colors", async () => {
|
||||||
|
const colors: CustomThemeColors = {
|
||||||
|
bg_primary: "#001122",
|
||||||
|
bg_secondary: null,
|
||||||
|
bg_terminal: null,
|
||||||
|
accent_primary: null,
|
||||||
|
accent_secondary: null,
|
||||||
|
text_primary: null,
|
||||||
|
text_secondary: null,
|
||||||
|
border_color: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
await configStore.setTheme("custom", colors);
|
||||||
|
|
||||||
|
expect(configStore.getConfig().theme).toBe("custom");
|
||||||
|
expect(configStore.getConfig().custom_theme_colors.bg_primary).toBe("#001122");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("configStore setCustomThemeColors with custom theme active", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.mocked(invoke).mockResolvedValue(null);
|
||||||
|
await configStore.setTheme("custom");
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.resetAllMocks();
|
||||||
|
clearCustomThemeColors();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies custom colors to DOM when current theme is custom", async () => {
|
||||||
|
const colors: CustomThemeColors = {
|
||||||
|
bg_primary: "#aabbcc",
|
||||||
|
bg_secondary: null,
|
||||||
|
bg_terminal: null,
|
||||||
|
accent_primary: null,
|
||||||
|
accent_secondary: null,
|
||||||
|
text_primary: null,
|
||||||
|
text_secondary: null,
|
||||||
|
border_color: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
await configStore.setCustomThemeColors(colors);
|
||||||
|
|
||||||
|
expect(document.documentElement.style.getPropertyValue("--bg-primary")).toBe("#aabbcc");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("configStore font size methods", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.mocked(invoke).mockResolvedValue(null);
|
||||||
|
await configStore.updateConfig({ font_size: DEFAULT_FONT_SIZE });
|
||||||
|
vi.resetAllMocks();
|
||||||
|
vi.mocked(invoke).mockResolvedValue(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setFontSize updates to the given value", async () => {
|
||||||
|
await configStore.setFontSize(18);
|
||||||
|
expect(configStore.getConfig().font_size).toBe(18);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setFontSize clamps to minimum", async () => {
|
||||||
|
await configStore.setFontSize(1);
|
||||||
|
expect(configStore.getConfig().font_size).toBe(MIN_FONT_SIZE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setFontSize clamps to maximum", async () => {
|
||||||
|
await configStore.setFontSize(99);
|
||||||
|
expect(configStore.getConfig().font_size).toBe(MAX_FONT_SIZE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("increaseFontSize increases font size by 2", async () => {
|
||||||
|
await configStore.increaseFontSize();
|
||||||
|
expect(configStore.getConfig().font_size).toBe(DEFAULT_FONT_SIZE + 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("increaseFontSize does not exceed maximum", async () => {
|
||||||
|
await configStore.setFontSize(MAX_FONT_SIZE);
|
||||||
|
await configStore.increaseFontSize();
|
||||||
|
expect(configStore.getConfig().font_size).toBe(MAX_FONT_SIZE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("decreaseFontSize decreases font size by 2", async () => {
|
||||||
|
await configStore.decreaseFontSize();
|
||||||
|
expect(configStore.getConfig().font_size).toBe(DEFAULT_FONT_SIZE - 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("decreaseFontSize does not go below minimum", async () => {
|
||||||
|
await configStore.setFontSize(MIN_FONT_SIZE);
|
||||||
|
await configStore.decreaseFontSize();
|
||||||
|
expect(configStore.getConfig().font_size).toBe(MIN_FONT_SIZE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resetFontSize restores the default font size", async () => {
|
||||||
|
await configStore.setFontSize(20);
|
||||||
|
await configStore.resetFontSize();
|
||||||
|
expect(configStore.getConfig().font_size).toBe(DEFAULT_FONT_SIZE);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("configStore removeAutoGrantedTool", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.mocked(invoke).mockResolvedValue(null);
|
||||||
|
await configStore.updateConfig({ auto_granted_tools: [] });
|
||||||
|
vi.resetAllMocks();
|
||||||
|
vi.mocked(invoke).mockResolvedValue(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes an existing tool", async () => {
|
||||||
|
await configStore.addAutoGrantedTool("Bash");
|
||||||
|
await configStore.removeAutoGrantedTool("Bash");
|
||||||
|
expect(configStore.getConfig().auto_granted_tools).not.toContain("Bash");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is a no-op when the tool is not in the list", async () => {
|
||||||
|
await configStore.removeAutoGrantedTool("NonExistentTool");
|
||||||
|
expect(configStore.getConfig().auto_granted_tools).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("configStore toggle methods", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.mocked(invoke).mockResolvedValue(null);
|
||||||
|
await configStore.updateConfig({ streamer_mode: false, compact_mode: false });
|
||||||
|
vi.resetAllMocks();
|
||||||
|
vi.mocked(invoke).mockResolvedValue(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toggleStreamerMode flips streamer_mode from false to true", async () => {
|
||||||
|
await configStore.toggleStreamerMode();
|
||||||
|
expect(configStore.getConfig().streamer_mode).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toggleStreamerMode flips streamer_mode from true to false", async () => {
|
||||||
|
await configStore.updateConfig({ streamer_mode: true });
|
||||||
|
await configStore.toggleStreamerMode();
|
||||||
|
expect(configStore.getConfig().streamer_mode).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toggleCompactMode flips compact_mode from false to true", async () => {
|
||||||
|
await configStore.toggleCompactMode();
|
||||||
|
expect(configStore.getConfig().compact_mode).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toggleCompactMode flips compact_mode from true to false", async () => {
|
||||||
|
await configStore.updateConfig({ compact_mode: true });
|
||||||
|
await configStore.toggleCompactMode();
|
||||||
|
expect(configStore.getConfig().compact_mode).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setCompactMode enables compact mode", async () => {
|
||||||
|
await configStore.setCompactMode(true);
|
||||||
|
expect(configStore.getConfig().compact_mode).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setCompactMode disables compact mode", async () => {
|
||||||
|
await configStore.updateConfig({ compact_mode: true });
|
||||||
|
await configStore.setCompactMode(false);
|
||||||
|
expect(configStore.getConfig().compact_mode).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("derived stores (live subscriptions)", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.mocked(invoke).mockResolvedValue(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("isDarkTheme is true when theme is dark", async () => {
|
||||||
|
await configStore.updateConfig({ theme: "dark" });
|
||||||
|
expect(get(isDarkTheme)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("isDarkTheme is false when theme is not dark", async () => {
|
||||||
|
await configStore.updateConfig({ theme: "light" });
|
||||||
|
expect(get(isDarkTheme)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("isStreamerMode reflects streamer_mode config", async () => {
|
||||||
|
await configStore.updateConfig({ streamer_mode: true });
|
||||||
|
expect(get(isStreamerMode)).toBe(true);
|
||||||
|
await configStore.updateConfig({ streamer_mode: false });
|
||||||
|
expect(get(isStreamerMode)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("isCompactMode reflects compact_mode config", async () => {
|
||||||
|
await configStore.updateConfig({ compact_mode: true });
|
||||||
|
expect(get(isCompactMode)).toBe(true);
|
||||||
|
await configStore.updateConfig({ compact_mode: false });
|
||||||
|
expect(get(isCompactMode)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shouldHidePaths is true when both streamer flags are enabled", async () => {
|
||||||
|
await configStore.updateConfig({ streamer_mode: true, streamer_hide_paths: true });
|
||||||
|
expect(get(shouldHidePaths)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shouldHidePaths is false when streamer_mode is disabled", async () => {
|
||||||
|
await configStore.updateConfig({ streamer_mode: false, streamer_hide_paths: true });
|
||||||
|
expect(get(shouldHidePaths)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("showThinkingBlocks is true when show_thinking_blocks is enabled", async () => {
|
||||||
|
await configStore.updateConfig({ show_thinking_blocks: true });
|
||||||
|
expect(get(showThinkingBlocks)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("showThinkingBlocks is false when show_thinking_blocks is disabled", async () => {
|
||||||
|
await configStore.updateConfig({ show_thinking_blocks: false });
|
||||||
|
expect(get(showThinkingBlocks)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("applyCustomFont", () => {
|
||||||
|
const readFileMock = vi.mocked(readFile);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Remove any style element left by previous tests
|
||||||
|
document.getElementById("hikari-custom-font")?.remove();
|
||||||
|
document.documentElement.style.removeProperty("--terminal-font-family");
|
||||||
|
readFileMock.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes CSS variable when both path and family are null", async () => {
|
||||||
|
document.documentElement.style.setProperty("--terminal-font-family", "'SomeFont', monospace");
|
||||||
|
await applyCustomFont(null, null);
|
||||||
|
expect(document.documentElement.style.getPropertyValue("--terminal-font-family")).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes CSS variable when both path and family are empty strings", async () => {
|
||||||
|
document.documentElement.style.setProperty("--terminal-font-family", "'SomeFont', monospace");
|
||||||
|
await applyCustomFont("", "");
|
||||||
|
expect(document.documentElement.style.getPropertyValue("--terminal-font-family")).toBe("");
|
||||||
|
expect(document.getElementById("hikari-custom-font")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("injects @import for a CSS stylesheet URL", async () => {
|
||||||
|
await applyCustomFont("https://fonts.googleapis.com/css2?family=Fira+Code", "Fira Code");
|
||||||
|
const style = document.getElementById("hikari-custom-font");
|
||||||
|
expect(style).not.toBeNull();
|
||||||
|
expect(style?.textContent).toContain(
|
||||||
|
"@import url('https://fonts.googleapis.com/css2?family=Fira+Code')"
|
||||||
|
);
|
||||||
|
expect(document.documentElement.style.getPropertyValue("--terminal-font-family")).toBe(
|
||||||
|
"'Fira Code', monospace"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("injects @font-face for a direct font file URL (.woff2)", async () => {
|
||||||
|
await applyCustomFont("https://example.com/fonts/myfont.woff2", "MyFont");
|
||||||
|
const style = document.getElementById("hikari-custom-font");
|
||||||
|
expect(style).not.toBeNull();
|
||||||
|
expect(style?.textContent).toContain("@font-face");
|
||||||
|
expect(style?.textContent).toContain("url('https://example.com/fonts/myfont.woff2')");
|
||||||
|
expect(style?.textContent).toContain("'MyFont'");
|
||||||
|
expect(document.documentElement.style.getPropertyValue("--terminal-font-family")).toBe(
|
||||||
|
"'MyFont', monospace"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("injects @font-face for a direct font file URL (.ttf)", async () => {
|
||||||
|
await applyCustomFont("https://example.com/fonts/myfont.ttf", "MyTtfFont");
|
||||||
|
const style = document.getElementById("hikari-custom-font");
|
||||||
|
expect(style).not.toBeNull();
|
||||||
|
expect(style?.textContent).toContain("@font-face");
|
||||||
|
expect(style?.textContent).toContain("url('https://example.com/fonts/myfont.ttf')");
|
||||||
|
expect(style?.textContent).toContain("'MyTtfFont'");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses HikariCustomFont as fallback family for direct font URLs when family is empty", async () => {
|
||||||
|
await applyCustomFont("https://example.com/fonts/myfont.woff2", "");
|
||||||
|
const style = document.getElementById("hikari-custom-font");
|
||||||
|
expect(style?.textContent).toContain("'HikariCustomFont'");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads local file and injects @font-face with data URL", async () => {
|
||||||
|
const fakeData = new Uint8Array([72, 101, 108, 108, 111]); // "Hello"
|
||||||
|
readFileMock.mockResolvedValueOnce(fakeData);
|
||||||
|
|
||||||
|
await applyCustomFont("/home/naomi/.fonts/MyFont.ttf", "MyFont");
|
||||||
|
|
||||||
|
expect(readFileMock).toHaveBeenCalledWith("/home/naomi/.fonts/MyFont.ttf");
|
||||||
|
const style = document.getElementById("hikari-custom-font");
|
||||||
|
expect(style).not.toBeNull();
|
||||||
|
expect(style?.textContent).toContain("@font-face");
|
||||||
|
expect(style?.textContent).toContain("data:font/ttf;base64,");
|
||||||
|
expect(style?.textContent).toContain("'MyFont'");
|
||||||
|
expect(document.documentElement.style.getPropertyValue("--terminal-font-family")).toBe(
|
||||||
|
"'MyFont', monospace"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses HikariCustomFont as fallback family for local files when family is empty", async () => {
|
||||||
|
const fakeData = new Uint8Array([1, 2, 3]);
|
||||||
|
readFileMock.mockResolvedValueOnce(fakeData);
|
||||||
|
|
||||||
|
await applyCustomFont("/home/naomi/.fonts/MyFont.woff2", "");
|
||||||
|
|
||||||
|
const style = document.getElementById("hikari-custom-font");
|
||||||
|
expect(style?.textContent).toContain("'HikariCustomFont'");
|
||||||
|
expect(style?.textContent).toContain("font/woff2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets CSS variable when only family is provided (no path)", async () => {
|
||||||
|
await applyCustomFont("", "SystemFont");
|
||||||
|
expect(document.documentElement.style.getPropertyValue("--terminal-font-family")).toBe(
|
||||||
|
"'SystemFont', monospace"
|
||||||
|
);
|
||||||
|
expect(document.getElementById("hikari-custom-font")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("replaces a previously injected style element", async () => {
|
||||||
|
await applyCustomFont("https://fonts.googleapis.com/css2?family=Fira+Code", "Fira Code");
|
||||||
|
expect(document.getElementById("hikari-custom-font")).not.toBeNull();
|
||||||
|
|
||||||
|
await applyCustomFont("https://fonts.googleapis.com/css2?family=Roboto+Mono", "Roboto Mono");
|
||||||
|
const styles = document.querySelectorAll("#hikari-custom-font");
|
||||||
|
expect(styles.length).toBe(1);
|
||||||
|
expect(styles[0].textContent).toContain("Roboto+Mono");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses correct MIME type for .otf local files", async () => {
|
||||||
|
readFileMock.mockResolvedValueOnce(new Uint8Array([1]));
|
||||||
|
await applyCustomFont("/fonts/MyFont.otf", "OtfFont");
|
||||||
|
const style = document.getElementById("hikari-custom-font");
|
||||||
|
expect(style?.textContent).toContain("font/otf");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to font/ttf MIME for unknown extension local files", async () => {
|
||||||
|
readFileMock.mockResolvedValueOnce(new Uint8Array([1]));
|
||||||
|
await applyCustomFont("/fonts/MyFont.xyz", "XyzFont");
|
||||||
|
const style = document.getElementById("hikari-custom-font");
|
||||||
|
expect(style?.textContent).toContain("font/ttf");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("setCustomFont", () => {
|
||||||
|
const readFileMock = vi.mocked(readFile);
|
||||||
|
const invokeMock = vi.mocked(invoke);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
document.getElementById("hikari-custom-font")?.remove();
|
||||||
|
document.documentElement.style.removeProperty("--terminal-font-family");
|
||||||
|
readFileMock.mockReset();
|
||||||
|
invokeMock.mockResolvedValue(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("saves config and applies the font", async () => {
|
||||||
|
await configStore.setCustomFont(null, null);
|
||||||
|
await configStore.setCustomFont(
|
||||||
|
"https://fonts.googleapis.com/css2?family=Fira+Code",
|
||||||
|
"Fira Code"
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(invokeMock).toHaveBeenCalledWith(
|
||||||
|
"save_config",
|
||||||
|
expect.objectContaining({
|
||||||
|
config: expect.objectContaining({
|
||||||
|
custom_font_path: "https://fonts.googleapis.com/css2?family=Fira+Code",
|
||||||
|
custom_font_family: "Fira Code",
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(document.documentElement.style.getPropertyValue("--terminal-font-family")).toBe(
|
||||||
|
"'Fira Code', monospace"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears font when called with nulls", async () => {
|
||||||
|
document.documentElement.style.setProperty("--terminal-font-family", "'SomeFont', monospace");
|
||||||
|
await configStore.setCustomFont(null, null);
|
||||||
|
|
||||||
|
expect(document.documentElement.style.getPropertyValue("--terminal-font-family")).toBe("");
|
||||||
|
expect(invokeMock).toHaveBeenCalledWith(
|
||||||
|
"save_config",
|
||||||
|
expect.objectContaining({
|
||||||
|
config: expect.objectContaining({
|
||||||
|
custom_font_path: null,
|
||||||
|
custom_font_family: null,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("applyCustomUiFont", () => {
|
||||||
|
const readFileMock = vi.mocked(readFile);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
document.getElementById("hikari-custom-ui-font")?.remove();
|
||||||
|
document.documentElement.style.removeProperty("--ui-font-family");
|
||||||
|
document.body.style.removeProperty("font-family");
|
||||||
|
readFileMock.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes CSS variable and body font-family when both path and family are null", async () => {
|
||||||
|
document.documentElement.style.setProperty("--ui-font-family", "'SomeFont', sans-serif");
|
||||||
|
document.body.style.setProperty("font-family", "'SomeFont', sans-serif");
|
||||||
|
await applyCustomUiFont(null, null);
|
||||||
|
expect(document.documentElement.style.getPropertyValue("--ui-font-family")).toBe("");
|
||||||
|
expect(document.body.style.getPropertyValue("font-family")).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes CSS variable and body font-family when both path and family are empty strings", async () => {
|
||||||
|
document.documentElement.style.setProperty("--ui-font-family", "'SomeFont', sans-serif");
|
||||||
|
document.body.style.setProperty("font-family", "'SomeFont', sans-serif");
|
||||||
|
await applyCustomUiFont("", "");
|
||||||
|
expect(document.documentElement.style.getPropertyValue("--ui-font-family")).toBe("");
|
||||||
|
expect(document.body.style.getPropertyValue("font-family")).toBe("");
|
||||||
|
expect(document.getElementById("hikari-custom-ui-font")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("injects @import for a CSS stylesheet URL and applies font to body", async () => {
|
||||||
|
await applyCustomUiFont("https://fonts.googleapis.com/css2?family=Inter", "Inter");
|
||||||
|
const style = document.getElementById("hikari-custom-ui-font");
|
||||||
|
expect(style).not.toBeNull();
|
||||||
|
expect(style?.textContent).toContain(
|
||||||
|
"@import url('https://fonts.googleapis.com/css2?family=Inter')"
|
||||||
|
);
|
||||||
|
expect(document.documentElement.style.getPropertyValue("--ui-font-family")).toBe(
|
||||||
|
"'Inter', sans-serif"
|
||||||
|
);
|
||||||
|
expect(document.body.style.getPropertyValue("font-family")).toBe('"Inter", sans-serif');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("injects @font-face for a direct font file URL (.woff2) and applies font to body", async () => {
|
||||||
|
await applyCustomUiFont("https://example.com/fonts/Inter.woff2", "Inter");
|
||||||
|
const style = document.getElementById("hikari-custom-ui-font");
|
||||||
|
expect(style).not.toBeNull();
|
||||||
|
expect(style?.textContent).toContain("@font-face");
|
||||||
|
expect(style?.textContent).toContain("url('https://example.com/fonts/Inter.woff2')");
|
||||||
|
expect(document.documentElement.style.getPropertyValue("--ui-font-family")).toBe(
|
||||||
|
"'Inter', sans-serif"
|
||||||
|
);
|
||||||
|
expect(document.body.style.getPropertyValue("font-family")).toBe('"Inter", sans-serif');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses HikariCustomUiFont as fallback family and applies it to body", async () => {
|
||||||
|
await applyCustomUiFont("https://example.com/fonts/Inter.woff2", "");
|
||||||
|
const style = document.getElementById("hikari-custom-ui-font");
|
||||||
|
expect(style?.textContent).toContain("'HikariCustomUiFont'");
|
||||||
|
expect(document.body.style.getPropertyValue("font-family")).toBe(
|
||||||
|
'"HikariCustomUiFont", sans-serif'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads local file and embeds as data URL, applies font to body", async () => {
|
||||||
|
const fakeData = new Uint8Array([104, 101, 108, 108, 111]);
|
||||||
|
readFileMock.mockResolvedValueOnce(fakeData);
|
||||||
|
|
||||||
|
await applyCustomUiFont("/home/naomi/.fonts/Inter.ttf", "Inter");
|
||||||
|
|
||||||
|
expect(readFileMock).toHaveBeenCalledWith("/home/naomi/.fonts/Inter.ttf");
|
||||||
|
const style = document.getElementById("hikari-custom-ui-font");
|
||||||
|
expect(style?.textContent).toContain("data:font/ttf;base64,");
|
||||||
|
expect(document.documentElement.style.getPropertyValue("--ui-font-family")).toBe(
|
||||||
|
"'Inter', sans-serif"
|
||||||
|
);
|
||||||
|
expect(document.body.style.getPropertyValue("font-family")).toBe('"Inter", sans-serif');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets CSS variable and body font-family when only family is provided (no path)", async () => {
|
||||||
|
await applyCustomUiFont("", "SystemUiFont");
|
||||||
|
expect(document.documentElement.style.getPropertyValue("--ui-font-family")).toBe(
|
||||||
|
"'SystemUiFont', sans-serif"
|
||||||
|
);
|
||||||
|
expect(document.body.style.getPropertyValue("font-family")).toBe(
|
||||||
|
'"SystemUiFont", sans-serif'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("replaces a previously injected style element", async () => {
|
||||||
|
await applyCustomUiFont("https://fonts.googleapis.com/css2?family=Inter", "Inter");
|
||||||
|
expect(document.getElementById("hikari-custom-ui-font")).not.toBeNull();
|
||||||
|
|
||||||
|
await applyCustomUiFont("https://fonts.googleapis.com/css2?family=Roboto", "Roboto");
|
||||||
|
const styles = document.querySelectorAll("#hikari-custom-ui-font");
|
||||||
|
expect(styles.length).toBe(1);
|
||||||
|
expect(styles[0].textContent).toContain("Roboto");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("setCustomUiFont", () => {
|
||||||
|
const readFileMock = vi.mocked(readFile);
|
||||||
|
const invokeMock = vi.mocked(invoke);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
document.getElementById("hikari-custom-ui-font")?.remove();
|
||||||
|
document.documentElement.style.removeProperty("--ui-font-family");
|
||||||
|
document.body.style.removeProperty("font-family");
|
||||||
|
readFileMock.mockReset();
|
||||||
|
invokeMock.mockResolvedValue(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("saves config and applies the UI font", async () => {
|
||||||
|
await configStore.setCustomUiFont(null, null);
|
||||||
|
await configStore.setCustomUiFont("https://fonts.googleapis.com/css2?family=Inter", "Inter");
|
||||||
|
|
||||||
|
expect(invokeMock).toHaveBeenCalledWith(
|
||||||
|
"save_config",
|
||||||
|
expect.objectContaining({
|
||||||
|
config: expect.objectContaining({
|
||||||
|
custom_ui_font_path: "https://fonts.googleapis.com/css2?family=Inter",
|
||||||
|
custom_ui_font_family: "Inter",
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(document.documentElement.style.getPropertyValue("--ui-font-family")).toBe(
|
||||||
|
"'Inter', sans-serif"
|
||||||
|
);
|
||||||
|
expect(document.body.style.getPropertyValue("font-family")).toBe('"Inter", sans-serif');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears UI font when called with nulls", async () => {
|
||||||
|
document.documentElement.style.setProperty("--ui-font-family", "'SomeFont', sans-serif");
|
||||||
|
document.body.style.setProperty("font-family", "'SomeFont', sans-serif");
|
||||||
|
await configStore.setCustomUiFont(null, null);
|
||||||
|
|
||||||
|
expect(document.documentElement.style.getPropertyValue("--ui-font-family")).toBe("");
|
||||||
|
expect(document.body.style.getPropertyValue("font-family")).toBe("");
|
||||||
|
expect(invokeMock).toHaveBeenCalledWith(
|
||||||
|
"save_config",
|
||||||
|
expect.objectContaining({
|
||||||
|
config: expect.objectContaining({
|
||||||
|
custom_ui_font_path: null,
|
||||||
|
custom_ui_font_family: null,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+152
-1
@@ -1,7 +1,20 @@
|
|||||||
import { writable, derived } from "svelte/store";
|
import { writable, derived } from "svelte/store";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { readFile } from "@tauri-apps/plugin-fs";
|
||||||
|
|
||||||
export type Theme = "dark" | "light" | "high-contrast" | "custom";
|
export type Theme =
|
||||||
|
| "dark"
|
||||||
|
| "light"
|
||||||
|
| "high-contrast"
|
||||||
|
| "custom"
|
||||||
|
| "dracula"
|
||||||
|
| "catppuccin"
|
||||||
|
| "nord"
|
||||||
|
| "solarized"
|
||||||
|
| "solarized-light"
|
||||||
|
| "catppuccin-latte"
|
||||||
|
| "gruvbox-light"
|
||||||
|
| "rose-pine-dawn";
|
||||||
export type BudgetAction = "warn" | "block";
|
export type BudgetAction = "warn" | "block";
|
||||||
|
|
||||||
export interface CustomThemeColors {
|
export interface CustomThemeColors {
|
||||||
@@ -51,11 +64,33 @@ export interface HikariConfig {
|
|||||||
use_worktree: boolean;
|
use_worktree: boolean;
|
||||||
// Disable 1M context window
|
// Disable 1M context window
|
||||||
disable_1m_context: boolean;
|
disable_1m_context: boolean;
|
||||||
|
// Max output tokens for Claude Code responses
|
||||||
|
max_output_tokens: number | null;
|
||||||
// Workspaces the user has explicitly trusted
|
// Workspaces the user has explicitly trusted
|
||||||
trusted_workspaces: string[];
|
trusted_workspaces: string[];
|
||||||
// Background image settings
|
// Background image settings
|
||||||
background_image_path: string | null;
|
background_image_path: string | null;
|
||||||
background_image_opacity: number;
|
background_image_opacity: number;
|
||||||
|
// Custom terminal font settings
|
||||||
|
custom_font_path: string | null;
|
||||||
|
custom_font_family: string | null;
|
||||||
|
// Custom UI font settings
|
||||||
|
custom_ui_font_path: string | null;
|
||||||
|
custom_ui_font_family: string | null;
|
||||||
|
// Task Loop auto-commit settings
|
||||||
|
task_loop_auto_commit: boolean;
|
||||||
|
task_loop_commit_prefix: string;
|
||||||
|
task_loop_include_summary: boolean;
|
||||||
|
// Disable cron scheduling
|
||||||
|
disable_cron: boolean;
|
||||||
|
// Git instructions setting
|
||||||
|
include_git_instructions: boolean;
|
||||||
|
// Claude.ai MCP servers setting
|
||||||
|
enable_claudeai_mcp_servers: boolean;
|
||||||
|
// Auto-memory directory
|
||||||
|
auto_memory_directory: string | null;
|
||||||
|
// Model overrides for provider-specific model IDs (AWS Bedrock, Google Vertex, etc.)
|
||||||
|
model_overrides: Record<string, string> | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultConfig: HikariConfig = {
|
const defaultConfig: HikariConfig = {
|
||||||
@@ -98,9 +133,22 @@ const defaultConfig: HikariConfig = {
|
|||||||
show_thinking_blocks: true,
|
show_thinking_blocks: true,
|
||||||
use_worktree: false,
|
use_worktree: false,
|
||||||
disable_1m_context: false,
|
disable_1m_context: false,
|
||||||
|
max_output_tokens: null,
|
||||||
trusted_workspaces: [],
|
trusted_workspaces: [],
|
||||||
background_image_path: null,
|
background_image_path: null,
|
||||||
background_image_opacity: 0.3,
|
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,
|
||||||
};
|
};
|
||||||
|
|
||||||
function createConfigStore() {
|
function createConfigStore() {
|
||||||
@@ -237,6 +285,16 @@ function createConfigStore() {
|
|||||||
setCompactMode: async (enabled: boolean) => {
|
setCompactMode: async (enabled: boolean) => {
|
||||||
await updateConfig({ compact_mode: enabled });
|
await updateConfig({ compact_mode: enabled });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setCustomFont: async (path: string | null, family: string | null) => {
|
||||||
|
await updateConfig({ custom_font_path: path, custom_font_family: family });
|
||||||
|
await applyCustomFont(path, family);
|
||||||
|
},
|
||||||
|
|
||||||
|
setCustomUiFont: async (path: string | null, family: string | null) => {
|
||||||
|
await updateConfig({ custom_ui_font_path: path, custom_ui_font_family: family });
|
||||||
|
await applyCustomUiFont(path, family);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -302,6 +360,99 @@ export function clampFontSize(size: number): number {
|
|||||||
return Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, size));
|
return Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, size));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DIRECT_FONT_EXTENSIONS = new Set(["woff", "woff2", "ttf", "otf", "eot"]);
|
||||||
|
|
||||||
|
const FONT_MIME_MAP: Record<string, string> = {
|
||||||
|
woff: "font/woff",
|
||||||
|
woff2: "font/woff2",
|
||||||
|
ttf: "font/ttf",
|
||||||
|
otf: "font/otf",
|
||||||
|
eot: "application/vnd.ms-fontobject",
|
||||||
|
};
|
||||||
|
|
||||||
|
async function applyFontFromSource(path: string, family: string, styleId: string): Promise<void> {
|
||||||
|
const style = document.createElement("style");
|
||||||
|
style.id = styleId;
|
||||||
|
|
||||||
|
if (path.startsWith("http://") || path.startsWith("https://")) {
|
||||||
|
const ext = path.split(".").pop()?.toLowerCase() ?? "";
|
||||||
|
|
||||||
|
if (DIRECT_FONT_EXTENSIONS.has(ext)) {
|
||||||
|
style.textContent = `@font-face { font-family: '${family}'; src: url('${path}'); }`;
|
||||||
|
} else {
|
||||||
|
style.textContent = `@import url('${path}');`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const data = await readFile(path);
|
||||||
|
const chunks: string[] = [];
|
||||||
|
const chunkSize = 8192;
|
||||||
|
for (let i = 0; i < data.length; i += chunkSize) {
|
||||||
|
chunks.push(String.fromCharCode(...data.slice(i, i + chunkSize)));
|
||||||
|
}
|
||||||
|
const ext = path.split(".").pop()?.toLowerCase() ?? "ttf";
|
||||||
|
const mime = FONT_MIME_MAP[ext] ?? "font/ttf";
|
||||||
|
const dataUrl = `data:${mime};base64,${btoa(chunks.join(""))}`;
|
||||||
|
style.textContent = `@font-face { font-family: '${family}'; src: url('${dataUrl}'); }`;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.head.appendChild(style);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function applyCustomFont(path: string | null, family: string | null): Promise<void> {
|
||||||
|
if (typeof document === "undefined") return;
|
||||||
|
|
||||||
|
const styleId = "hikari-custom-font";
|
||||||
|
const cssVar = "--terminal-font-family";
|
||||||
|
const fallbackFamily = "HikariCustomFont";
|
||||||
|
|
||||||
|
document.getElementById(styleId)?.remove();
|
||||||
|
|
||||||
|
const trimmedPath = path?.trim() ?? "";
|
||||||
|
const trimmedFamily = family?.trim() ?? "";
|
||||||
|
|
||||||
|
if (!trimmedPath && !trimmedFamily) {
|
||||||
|
document.documentElement.style.removeProperty(cssVar);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmedPath) {
|
||||||
|
await applyFontFromSource(trimmedPath, trimmedFamily || fallbackFamily, styleId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmedFamily) {
|
||||||
|
document.documentElement.style.setProperty(cssVar, `'${trimmedFamily}', monospace`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function applyCustomUiFont(path: string | null, family: string | null): Promise<void> {
|
||||||
|
if (typeof document === "undefined") return;
|
||||||
|
|
||||||
|
const styleId = "hikari-custom-ui-font";
|
||||||
|
const cssVar = "--ui-font-family";
|
||||||
|
const fallbackFamily = "HikariCustomUiFont";
|
||||||
|
|
||||||
|
document.getElementById(styleId)?.remove();
|
||||||
|
|
||||||
|
const trimmedPath = path?.trim() ?? "";
|
||||||
|
const trimmedFamily = family?.trim() ?? "";
|
||||||
|
|
||||||
|
if (!trimmedPath && !trimmedFamily) {
|
||||||
|
document.documentElement.style.removeProperty(cssVar);
|
||||||
|
document.body?.style.removeProperty("font-family");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const effectiveFamily = trimmedFamily || fallbackFamily;
|
||||||
|
|
||||||
|
if (trimmedPath) {
|
||||||
|
await applyFontFromSource(trimmedPath, effectiveFamily, styleId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fontValue = `'${effectiveFamily}', sans-serif`;
|
||||||
|
document.documentElement.style.setProperty(cssVar, fontValue);
|
||||||
|
document.body?.style.setProperty("font-family", fontValue);
|
||||||
|
}
|
||||||
|
|
||||||
export { MIN_FONT_SIZE, MAX_FONT_SIZE, DEFAULT_FONT_SIZE };
|
export { MIN_FONT_SIZE, MAX_FONT_SIZE, DEFAULT_FONT_SIZE };
|
||||||
|
|
||||||
export const configStore = createConfigStore();
|
export const configStore = createConfigStore();
|
||||||
|
|||||||
@@ -561,3 +561,147 @@ describe("draft text persistence", () => {
|
|||||||
expect(conversation.draftText).toBe("");
|
expect(conversation.draftText).toBe("");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("worktreeInfo state management", () => {
|
||||||
|
it("initialises worktreeInfo as null", () => {
|
||||||
|
const conversation = { worktreeInfo: null };
|
||||||
|
expect(conversation.worktreeInfo).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stores worktreeInfo when a worktree is created", () => {
|
||||||
|
const info = {
|
||||||
|
name: "worktree-abc",
|
||||||
|
path: "/tmp/worktrees/worktree-abc",
|
||||||
|
branch: "feat/my-feature",
|
||||||
|
original_repo_directory: "/home/naomi/code/project",
|
||||||
|
};
|
||||||
|
const conversation = { worktreeInfo: null as typeof info | null };
|
||||||
|
conversation.worktreeInfo = info;
|
||||||
|
|
||||||
|
expect(conversation.worktreeInfo?.branch).toBe("feat/my-feature");
|
||||||
|
expect(conversation.worktreeInfo?.name).toBe("worktree-abc");
|
||||||
|
expect(conversation.worktreeInfo?.original_repo_directory).toBe("/home/naomi/code/project");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears worktreeInfo when a worktree is removed", () => {
|
||||||
|
const info = {
|
||||||
|
name: "worktree-abc",
|
||||||
|
path: "/tmp/worktrees/worktree-abc",
|
||||||
|
branch: "feat/my-feature",
|
||||||
|
original_repo_directory: "/home/naomi/code/project",
|
||||||
|
};
|
||||||
|
const conversation = { worktreeInfo: info as typeof info | null };
|
||||||
|
conversation.worktreeInfo = null;
|
||||||
|
|
||||||
|
expect(conversation.worktreeInfo).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stores worktreeInfo independently per conversation", () => {
|
||||||
|
const conversations = new Map([
|
||||||
|
["conv-1", { worktreeInfo: null as { branch: string } | null }],
|
||||||
|
["conv-2", { worktreeInfo: null as { branch: string } | null }],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const conv1 = conversations.get("conv-1");
|
||||||
|
if (conv1) conv1.worktreeInfo = { branch: "feat/one" };
|
||||||
|
|
||||||
|
expect(conversations.get("conv-1")?.worktreeInfo?.branch).toBe("feat/one");
|
||||||
|
expect(conversations.get("conv-2")?.worktreeInfo).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isProcessing state management", () => {
|
||||||
|
it("starts as false by default", () => {
|
||||||
|
const conversation = { id: "conv-1", isProcessing: false };
|
||||||
|
expect(conversation.isProcessing).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setProcessingForConversation sets processing true for the target conversation", () => {
|
||||||
|
const conversations = new Map([
|
||||||
|
["conv-1", { isProcessing: false, lastActivityAt: new Date(0) }],
|
||||||
|
["conv-2", { isProcessing: false, lastActivityAt: new Date(0) }],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const setProcessingForConversation = (conversationId: string, processing: boolean) => {
|
||||||
|
const conv = conversations.get(conversationId);
|
||||||
|
if (conv) {
|
||||||
|
conv.isProcessing = processing;
|
||||||
|
conv.lastActivityAt = new Date();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
setProcessingForConversation("conv-1", true);
|
||||||
|
|
||||||
|
expect(conversations.get("conv-1")?.isProcessing).toBe(true);
|
||||||
|
expect(conversations.get("conv-2")?.isProcessing).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setProcessingForConversation resets processing to false", () => {
|
||||||
|
const conversations = new Map([
|
||||||
|
["conv-1", { isProcessing: true, lastActivityAt: new Date(0) }],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const setProcessingForConversation = (conversationId: string, processing: boolean) => {
|
||||||
|
const conv = conversations.get(conversationId);
|
||||||
|
if (conv) {
|
||||||
|
conv.isProcessing = processing;
|
||||||
|
conv.lastActivityAt = new Date();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
setProcessingForConversation("conv-1", false);
|
||||||
|
|
||||||
|
expect(conversations.get("conv-1")?.isProcessing).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setProcessingForConversation does nothing for unknown conversation", () => {
|
||||||
|
const conversations = new Map([
|
||||||
|
["conv-1", { isProcessing: false, lastActivityAt: new Date(0) }],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const setProcessingForConversation = (conversationId: string, processing: boolean) => {
|
||||||
|
const conv = conversations.get(conversationId);
|
||||||
|
if (conv) {
|
||||||
|
conv.isProcessing = processing;
|
||||||
|
conv.lastActivityAt = new Date();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
setProcessingForConversation("unknown", true);
|
||||||
|
|
||||||
|
expect(conversations.get("conv-1")?.isProcessing).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("isProcessing is cleared when idle state arrives", () => {
|
||||||
|
const conversation = { isProcessing: true, characterState: "thinking" };
|
||||||
|
|
||||||
|
const terminalStates = ["idle", "success", "error"];
|
||||||
|
const handleStateChange = (state: string) => {
|
||||||
|
conversation.characterState = state;
|
||||||
|
if (terminalStates.includes(state)) {
|
||||||
|
conversation.isProcessing = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
handleStateChange("idle");
|
||||||
|
|
||||||
|
expect(conversation.isProcessing).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("isProcessing stays true during non-terminal states", () => {
|
||||||
|
const conversation = { isProcessing: true, characterState: "thinking" };
|
||||||
|
|
||||||
|
const terminalStates = ["idle", "success", "error"];
|
||||||
|
const handleStateChange = (state: string) => {
|
||||||
|
conversation.characterState = state;
|
||||||
|
if (terminalStates.includes(state)) {
|
||||||
|
conversation.isProcessing = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const state of ["thinking", "typing", "coding", "searching", "mcp"]) {
|
||||||
|
handleStateChange(state);
|
||||||
|
expect(conversation.isProcessing).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ import { writable, derived, get } from "svelte/store";
|
|||||||
import type {
|
import type {
|
||||||
TerminalLine,
|
TerminalLine,
|
||||||
ConnectionStatus,
|
ConnectionStatus,
|
||||||
|
ElicitationEvent,
|
||||||
PermissionRequest,
|
PermissionRequest,
|
||||||
UserQuestionEvent,
|
UserQuestionEvent,
|
||||||
Attachment,
|
Attachment,
|
||||||
} from "$lib/types/messages";
|
} from "$lib/types/messages";
|
||||||
import type { CharacterState } from "$lib/types/states";
|
import type { CharacterState } from "$lib/types/states";
|
||||||
|
import type { WorktreeInfo } from "$lib/types/worktree";
|
||||||
import { cleanupConversationTracking } from "$lib/tauri";
|
import { cleanupConversationTracking } from "$lib/tauri";
|
||||||
import { characterState } from "$lib/stores/character";
|
import { characterState } from "$lib/stores/character";
|
||||||
import { sessionsStore } from "$lib/stores/sessions";
|
import { sessionsStore } from "$lib/stores/sessions";
|
||||||
@@ -31,6 +33,7 @@ export interface Conversation {
|
|||||||
grantedTools: Set<string>;
|
grantedTools: Set<string>;
|
||||||
pendingPermissions: PermissionRequest[];
|
pendingPermissions: PermissionRequest[];
|
||||||
pendingQuestion: UserQuestionEvent | null;
|
pendingQuestion: UserQuestionEvent | null;
|
||||||
|
pendingElicitation: ElicitationEvent | null;
|
||||||
scrollPosition: number;
|
scrollPosition: number;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
lastActivityAt: Date;
|
lastActivityAt: Date;
|
||||||
@@ -41,6 +44,89 @@ export interface Conversation {
|
|||||||
successSoundFired: boolean;
|
successSoundFired: boolean;
|
||||||
taskStartSoundFired: boolean;
|
taskStartSoundFired: boolean;
|
||||||
draftText: string;
|
draftText: string;
|
||||||
|
worktreeInfo: WorktreeInfo | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TAB_NAMES = [
|
||||||
|
// Cosmic & celestial
|
||||||
|
"Starfall",
|
||||||
|
"Moonbeam",
|
||||||
|
"Nebula",
|
||||||
|
"Aurora",
|
||||||
|
"Stardust",
|
||||||
|
"Solstice",
|
||||||
|
"Comet",
|
||||||
|
"Eclipse",
|
||||||
|
"Zenith",
|
||||||
|
"Celestia",
|
||||||
|
"Nova",
|
||||||
|
"Quasar",
|
||||||
|
"Lyra",
|
||||||
|
"Andromeda",
|
||||||
|
"Twilight",
|
||||||
|
// Magical & fantastical
|
||||||
|
"Camelot",
|
||||||
|
"Reverie",
|
||||||
|
"Arcane",
|
||||||
|
"Spellbound",
|
||||||
|
"Mirage",
|
||||||
|
"Oracle",
|
||||||
|
"Seraphim",
|
||||||
|
"Ethereal",
|
||||||
|
"Labyrinth",
|
||||||
|
"Enchantment",
|
||||||
|
// Nature & cosy
|
||||||
|
"Sakura",
|
||||||
|
"Ember",
|
||||||
|
"Cascade",
|
||||||
|
"Zephyr",
|
||||||
|
"Serendipity",
|
||||||
|
"Solace",
|
||||||
|
"Blossom",
|
||||||
|
"Whisper",
|
||||||
|
"Dewdrop",
|
||||||
|
"Sunbeam",
|
||||||
|
"Willow",
|
||||||
|
"Clover",
|
||||||
|
"Honeybee",
|
||||||
|
"Buttercup",
|
||||||
|
"Dandelion",
|
||||||
|
// Japanese/anime-inspired
|
||||||
|
"Tsukimi",
|
||||||
|
"Hanami",
|
||||||
|
"Yozora",
|
||||||
|
"Hoshi",
|
||||||
|
"Koharu",
|
||||||
|
"Akari",
|
||||||
|
"Midori",
|
||||||
|
// Adventure & epic
|
||||||
|
"Odyssey",
|
||||||
|
"Wanderer",
|
||||||
|
"Horizon",
|
||||||
|
"Voyage",
|
||||||
|
"Pathfinder",
|
||||||
|
"Frontier",
|
||||||
|
// Whimsical & sweet
|
||||||
|
"Bubblegum",
|
||||||
|
"Marshmallow",
|
||||||
|
"Daydream",
|
||||||
|
"Whimsy",
|
||||||
|
"Jellybean",
|
||||||
|
"Sprinkle",
|
||||||
|
"Cupcake",
|
||||||
|
// Dreamy & poetic
|
||||||
|
"Revenant",
|
||||||
|
"Elysium",
|
||||||
|
"Halcyon",
|
||||||
|
"Ephemera",
|
||||||
|
"Serenade",
|
||||||
|
"Lullaby",
|
||||||
|
"Nocturne",
|
||||||
|
"Rhapsody",
|
||||||
|
];
|
||||||
|
|
||||||
|
function pickRandomTabName(): string {
|
||||||
|
return TAB_NAMES[Math.floor(Math.random() * TAB_NAMES.length)];
|
||||||
}
|
}
|
||||||
|
|
||||||
function createConversationsStore() {
|
function createConversationsStore() {
|
||||||
@@ -63,7 +149,7 @@ function createConversationsStore() {
|
|||||||
const id = generateConversationId();
|
const id = generateConversationId();
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
name: name || `Conversation ${conversationCounter}`,
|
name: name ?? pickRandomTabName(),
|
||||||
terminalLines: [],
|
terminalLines: [],
|
||||||
sessionId: null,
|
sessionId: null,
|
||||||
connectionStatus: "disconnected",
|
connectionStatus: "disconnected",
|
||||||
@@ -73,6 +159,7 @@ function createConversationsStore() {
|
|||||||
grantedTools: new Set(),
|
grantedTools: new Set(),
|
||||||
pendingPermissions: [],
|
pendingPermissions: [],
|
||||||
pendingQuestion: null,
|
pendingQuestion: null,
|
||||||
|
pendingElicitation: null,
|
||||||
scrollPosition: -1, // -1 means "scroll to bottom" (auto-scroll)
|
scrollPosition: -1, // -1 means "scroll to bottom" (auto-scroll)
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
lastActivityAt: new Date(),
|
lastActivityAt: new Date(),
|
||||||
@@ -83,6 +170,7 @@ function createConversationsStore() {
|
|||||||
successSoundFired: false,
|
successSoundFired: false,
|
||||||
taskStartSoundFired: false,
|
taskStartSoundFired: false,
|
||||||
draftText: "",
|
draftText: "",
|
||||||
|
worktreeInfo: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,7 +179,7 @@ function createConversationsStore() {
|
|||||||
function ensureInitialized() {
|
function ensureInitialized() {
|
||||||
if (!initialized) {
|
if (!initialized) {
|
||||||
initialized = true;
|
initialized = true;
|
||||||
const initialConversation = createNewConversation("Main");
|
const initialConversation = createNewConversation();
|
||||||
conversations.update((convs) => {
|
conversations.update((convs) => {
|
||||||
convs.set(initialConversation.id, initialConversation);
|
convs.set(initialConversation.id, initialConversation);
|
||||||
return convs;
|
return convs;
|
||||||
@@ -136,8 +224,13 @@ function createConversationsStore() {
|
|||||||
($conv) => $conv?.pendingPermissions || []
|
($conv) => $conv?.pendingPermissions || []
|
||||||
);
|
);
|
||||||
const pendingQuestion = derived(activeConversation, ($conv) => $conv?.pendingQuestion || null);
|
const pendingQuestion = derived(activeConversation, ($conv) => $conv?.pendingQuestion || null);
|
||||||
|
const pendingElicitation = derived(
|
||||||
|
activeConversation,
|
||||||
|
($conv) => $conv?.pendingElicitation ?? null
|
||||||
|
);
|
||||||
const scrollPosition = derived(activeConversation, ($conv) => $conv?.scrollPosition ?? -1);
|
const scrollPosition = derived(activeConversation, ($conv) => $conv?.scrollPosition ?? -1);
|
||||||
const attachments = derived(activeConversation, ($conv) => $conv?.attachments || []);
|
const attachments = derived(activeConversation, ($conv) => $conv?.attachments || []);
|
||||||
|
const worktreeInfo = derived(activeConversation, ($conv) => $conv?.worktreeInfo ?? null);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// Expose derived stores for compatibility
|
// Expose derived stores for compatibility
|
||||||
@@ -148,11 +241,13 @@ function createConversationsStore() {
|
|||||||
pendingPermission: { subscribe: pendingPermission.subscribe },
|
pendingPermission: { subscribe: pendingPermission.subscribe },
|
||||||
pendingPermissions: { subscribe: pendingPermissions.subscribe },
|
pendingPermissions: { subscribe: pendingPermissions.subscribe },
|
||||||
pendingQuestion: { subscribe: pendingQuestion.subscribe },
|
pendingQuestion: { subscribe: pendingQuestion.subscribe },
|
||||||
|
pendingElicitation: { subscribe: pendingElicitation.subscribe },
|
||||||
isProcessing: { subscribe: isProcessing.subscribe },
|
isProcessing: { subscribe: isProcessing.subscribe },
|
||||||
grantedTools: { subscribe: grantedTools.subscribe },
|
grantedTools: { subscribe: grantedTools.subscribe },
|
||||||
pendingRetryMessage: { subscribe: pendingRetryMessage.subscribe },
|
pendingRetryMessage: { subscribe: pendingRetryMessage.subscribe },
|
||||||
scrollPosition: { subscribe: scrollPosition.subscribe },
|
scrollPosition: { subscribe: scrollPosition.subscribe },
|
||||||
attachments: { subscribe: attachments.subscribe },
|
attachments: { subscribe: attachments.subscribe },
|
||||||
|
worktreeInfo: { subscribe: worktreeInfo.subscribe },
|
||||||
|
|
||||||
// New conversation-specific stores
|
// New conversation-specific stores
|
||||||
conversations: { subscribe: conversations.subscribe },
|
conversations: { subscribe: conversations.subscribe },
|
||||||
@@ -312,6 +407,52 @@ function createConversationsStore() {
|
|||||||
return convs;
|
return convs;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
requestElicitation: (elicitation: ElicitationEvent) => {
|
||||||
|
const activeId = get(activeConversationId);
|
||||||
|
if (!activeId) return;
|
||||||
|
|
||||||
|
conversations.update((convs) => {
|
||||||
|
const conv = convs.get(activeId);
|
||||||
|
if (conv) {
|
||||||
|
conv.pendingElicitation = elicitation;
|
||||||
|
conv.lastActivityAt = new Date();
|
||||||
|
}
|
||||||
|
return convs;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
clearElicitation: () => {
|
||||||
|
const activeId = get(activeConversationId);
|
||||||
|
if (!activeId) return;
|
||||||
|
|
||||||
|
conversations.update((convs) => {
|
||||||
|
const conv = convs.get(activeId);
|
||||||
|
if (conv) {
|
||||||
|
conv.pendingElicitation = null;
|
||||||
|
conv.lastActivityAt = new Date();
|
||||||
|
}
|
||||||
|
return convs;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
requestElicitationForConversation: (conversationId: string, elicitation: ElicitationEvent) => {
|
||||||
|
conversations.update((convs) => {
|
||||||
|
const conv = convs.get(conversationId);
|
||||||
|
if (conv) {
|
||||||
|
conv.pendingElicitation = elicitation;
|
||||||
|
conv.lastActivityAt = new Date();
|
||||||
|
}
|
||||||
|
return convs;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
clearElicitationForConversation: (conversationId: string) => {
|
||||||
|
conversations.update((convs) => {
|
||||||
|
const conv = convs.get(conversationId);
|
||||||
|
if (conv) {
|
||||||
|
conv.pendingElicitation = null;
|
||||||
|
conv.lastActivityAt = new Date();
|
||||||
|
}
|
||||||
|
return convs;
|
||||||
|
});
|
||||||
|
},
|
||||||
setPendingRetryMessage: (message: string | null) => pendingRetryMessage.set(message),
|
setPendingRetryMessage: (message: string | null) => pendingRetryMessage.set(message),
|
||||||
|
|
||||||
// Conversation management
|
// Conversation management
|
||||||
@@ -478,6 +619,17 @@ function createConversationsStore() {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setProcessingForConversation: (conversationId: string, processing: boolean) => {
|
||||||
|
conversations.update((convs) => {
|
||||||
|
const conv = convs.get(conversationId);
|
||||||
|
if (conv) {
|
||||||
|
conv.isProcessing = processing;
|
||||||
|
conv.lastActivityAt = new Date();
|
||||||
|
}
|
||||||
|
return convs;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
addLine: (
|
addLine: (
|
||||||
type: TerminalLine["type"],
|
type: TerminalLine["type"],
|
||||||
content: string,
|
content: string,
|
||||||
@@ -883,6 +1035,16 @@ function createConversationsStore() {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setWorktreeInfo: (conversationId: string, info: WorktreeInfo | null) => {
|
||||||
|
conversations.update((convs) => {
|
||||||
|
const conv = convs.get(conversationId);
|
||||||
|
if (conv) {
|
||||||
|
conv.worktreeInfo = info;
|
||||||
|
}
|
||||||
|
return convs;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
// Add initialization helper
|
// Add initialization helper
|
||||||
initialize: () => {
|
initialize: () => {
|
||||||
ensureInitialized();
|
ensureInitialized();
|
||||||
|
|||||||
@@ -0,0 +1,292 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { get } from "svelte/store";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { setMockInvokeResult } from "../../../vitest.setup";
|
||||||
|
import {
|
||||||
|
formatCost,
|
||||||
|
formatAlertType,
|
||||||
|
getAlertMessage,
|
||||||
|
costTrackingStore,
|
||||||
|
formattedCosts,
|
||||||
|
type AlertType,
|
||||||
|
type CostAlert,
|
||||||
|
} from "./costTracking";
|
||||||
|
|
||||||
|
vi.mock("$lib/notifications/notificationManager", () => ({
|
||||||
|
notificationManager: {
|
||||||
|
notifyCostAlert: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("formatCost", () => {
|
||||||
|
it("formats amounts below $0.01 to 4 decimal places", () => {
|
||||||
|
expect(formatCost(0)).toBe("$0.0000");
|
||||||
|
expect(formatCost(0.001)).toBe("$0.0010");
|
||||||
|
expect(formatCost(0.0099)).toBe("$0.0099");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats amounts between $0.01 and $1 to 3 decimal places", () => {
|
||||||
|
expect(formatCost(0.01)).toBe("$0.010");
|
||||||
|
expect(formatCost(0.123)).toBe("$0.123");
|
||||||
|
expect(formatCost(0.999)).toBe("$0.999");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats amounts $1 and above to 2 decimal places", () => {
|
||||||
|
expect(formatCost(1)).toBe("$1.00");
|
||||||
|
expect(formatCost(1.5)).toBe("$1.50");
|
||||||
|
expect(formatCost(100.99)).toBe("$100.99");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatAlertType", () => {
|
||||||
|
it("formats Daily as Today", () => {
|
||||||
|
expect(formatAlertType("Daily")).toBe("Today");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats Weekly as This Week", () => {
|
||||||
|
expect(formatAlertType("Weekly")).toBe("This Week");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats Monthly as This Month", () => {
|
||||||
|
expect(formatAlertType("Monthly")).toBe("This Month");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles all AlertType values", () => {
|
||||||
|
const types: AlertType[] = ["Daily", "Weekly", "Monthly"];
|
||||||
|
const results = types.map(formatAlertType);
|
||||||
|
expect(results).toEqual(["Today", "This Week", "This Month"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getAlertMessage", () => {
|
||||||
|
it("generates a message for a Daily alert", () => {
|
||||||
|
const alert: CostAlert = {
|
||||||
|
alert_type: "Daily",
|
||||||
|
threshold: 1.0,
|
||||||
|
current_cost: 1.5,
|
||||||
|
};
|
||||||
|
const message = getAlertMessage(alert);
|
||||||
|
expect(message).toContain("Today");
|
||||||
|
expect(message).toContain("$1.50");
|
||||||
|
expect(message).toContain("$1.00");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("generates a message for a Weekly alert", () => {
|
||||||
|
const alert: CostAlert = {
|
||||||
|
alert_type: "Weekly",
|
||||||
|
threshold: 5.0,
|
||||||
|
current_cost: 6.0,
|
||||||
|
};
|
||||||
|
const message = getAlertMessage(alert);
|
||||||
|
expect(message).toContain("This Week");
|
||||||
|
expect(message).toContain("$6.00");
|
||||||
|
expect(message).toContain("$5.00");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("generates a message for a Monthly alert", () => {
|
||||||
|
const alert: CostAlert = {
|
||||||
|
alert_type: "Monthly",
|
||||||
|
threshold: 20.0,
|
||||||
|
current_cost: 25.0,
|
||||||
|
};
|
||||||
|
const message = getAlertMessage(alert);
|
||||||
|
expect(message).toContain("This Month");
|
||||||
|
expect(message).toContain("$25.00");
|
||||||
|
expect(message).toContain("$20.00");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes threshold and current cost in the message", () => {
|
||||||
|
const alert: CostAlert = {
|
||||||
|
alert_type: "Daily",
|
||||||
|
threshold: 0.005,
|
||||||
|
current_cost: 0.007,
|
||||||
|
};
|
||||||
|
const message = getAlertMessage(alert);
|
||||||
|
expect(message).toContain("$0.0070");
|
||||||
|
expect(message).toContain("$0.0050");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("costTrackingStore", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
costTrackingStore.reset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("refresh", () => {
|
||||||
|
it("loads cost data from the backend", async () => {
|
||||||
|
setMockInvokeResult("get_today_cost", 1.5);
|
||||||
|
setMockInvokeResult("get_week_cost", 5.25);
|
||||||
|
setMockInvokeResult("get_month_cost", 20.0);
|
||||||
|
setMockInvokeResult("get_cost_alerts", []);
|
||||||
|
|
||||||
|
await costTrackingStore.refresh();
|
||||||
|
|
||||||
|
const state = get(costTrackingStore);
|
||||||
|
expect(state.todayCost).toBe(1.5);
|
||||||
|
expect(state.weekCost).toBe(5.25);
|
||||||
|
expect(state.monthCost).toBe(20.0);
|
||||||
|
expect(state.isLoading).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("triggers notifications for any returned alerts", async () => {
|
||||||
|
const { notificationManager } = await import("$lib/notifications/notificationManager");
|
||||||
|
const alert: CostAlert = { alert_type: "Daily", threshold: 1.0, current_cost: 1.5 };
|
||||||
|
setMockInvokeResult("get_today_cost", 1.5);
|
||||||
|
setMockInvokeResult("get_week_cost", 0);
|
||||||
|
setMockInvokeResult("get_month_cost", 0);
|
||||||
|
setMockInvokeResult("get_cost_alerts", [alert]);
|
||||||
|
|
||||||
|
await costTrackingStore.refresh();
|
||||||
|
|
||||||
|
expect(notificationManager.notifyCostAlert).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("Today")
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns alerts from refresh", async () => {
|
||||||
|
const alert: CostAlert = { alert_type: "Weekly", threshold: 5.0, current_cost: 6.0 };
|
||||||
|
setMockInvokeResult("get_today_cost", 0);
|
||||||
|
setMockInvokeResult("get_week_cost", 6.0);
|
||||||
|
setMockInvokeResult("get_month_cost", 0);
|
||||||
|
setMockInvokeResult("get_cost_alerts", [alert]);
|
||||||
|
|
||||||
|
const result = await costTrackingStore.refresh();
|
||||||
|
|
||||||
|
expect(result).toEqual([alert]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles refresh errors gracefully and returns empty array", async () => {
|
||||||
|
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
setMockInvokeResult("get_today_cost", new Error("Backend error"));
|
||||||
|
|
||||||
|
const result = await costTrackingStore.refresh();
|
||||||
|
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
expect(errorSpy).toHaveBeenCalledWith("Failed to refresh cost tracking:", expect.any(Error));
|
||||||
|
errorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getSummary", () => {
|
||||||
|
it("fetches and stores a cost summary", async () => {
|
||||||
|
const mockSummary = {
|
||||||
|
period_days: 7,
|
||||||
|
total_input_tokens: 1000,
|
||||||
|
total_output_tokens: 500,
|
||||||
|
total_cost: 0.05,
|
||||||
|
total_messages: 20,
|
||||||
|
total_sessions: 3,
|
||||||
|
average_daily_cost: 0.007,
|
||||||
|
daily_breakdown: [],
|
||||||
|
};
|
||||||
|
setMockInvokeResult("get_cost_summary", mockSummary);
|
||||||
|
|
||||||
|
const result = await costTrackingStore.getSummary(7);
|
||||||
|
|
||||||
|
expect(result).toEqual(mockSummary);
|
||||||
|
expect(invoke).toHaveBeenCalledWith("get_cost_summary", { days: 7 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null and logs error on failure", async () => {
|
||||||
|
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
setMockInvokeResult("get_cost_summary", new Error("Summary unavailable"));
|
||||||
|
|
||||||
|
const result = await costTrackingStore.getSummary(30);
|
||||||
|
|
||||||
|
expect(result).toBeNull();
|
||||||
|
expect(errorSpy).toHaveBeenCalledWith("Failed to get cost summary:", expect.any(Error));
|
||||||
|
errorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("setAlertThresholds", () => {
|
||||||
|
it("persists new thresholds to the backend", async () => {
|
||||||
|
const thresholds = { daily: 2.0, weekly: 10.0, monthly: 40.0 };
|
||||||
|
|
||||||
|
await costTrackingStore.setAlertThresholds(thresholds);
|
||||||
|
|
||||||
|
expect(invoke).toHaveBeenCalledWith("set_cost_alert_thresholds", {
|
||||||
|
daily: 2.0,
|
||||||
|
weekly: 10.0,
|
||||||
|
monthly: 40.0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logs an error when the backend call fails", async () => {
|
||||||
|
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
setMockInvokeResult("set_cost_alert_thresholds", new Error("Failed"));
|
||||||
|
|
||||||
|
await costTrackingStore.setAlertThresholds({ daily: 1.0, weekly: null, monthly: null });
|
||||||
|
|
||||||
|
expect(errorSpy).toHaveBeenCalledWith("Failed to set alert thresholds:", expect.any(Error));
|
||||||
|
errorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("exportCsv", () => {
|
||||||
|
it("returns the CSV string from the backend", async () => {
|
||||||
|
setMockInvokeResult("export_cost_csv", "date,cost\n2026-01-01,1.50");
|
||||||
|
|
||||||
|
const result = await costTrackingStore.exportCsv(7);
|
||||||
|
|
||||||
|
expect(result).toBe("date,cost\n2026-01-01,1.50");
|
||||||
|
expect(invoke).toHaveBeenCalledWith("export_cost_csv", { days: 7 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null and logs error on failure", async () => {
|
||||||
|
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
setMockInvokeResult("export_cost_csv", new Error("Export failed"));
|
||||||
|
|
||||||
|
const result = await costTrackingStore.exportCsv(30);
|
||||||
|
|
||||||
|
expect(result).toBeNull();
|
||||||
|
expect(errorSpy).toHaveBeenCalledWith("Failed to export CSV:", expect.any(Error));
|
||||||
|
errorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("reset", () => {
|
||||||
|
it("resets costs back to zero", async () => {
|
||||||
|
setMockInvokeResult("get_today_cost", 5.0);
|
||||||
|
setMockInvokeResult("get_week_cost", 15.0);
|
||||||
|
setMockInvokeResult("get_month_cost", 50.0);
|
||||||
|
setMockInvokeResult("get_cost_alerts", []);
|
||||||
|
await costTrackingStore.refresh();
|
||||||
|
|
||||||
|
costTrackingStore.reset();
|
||||||
|
|
||||||
|
const state = get(costTrackingStore);
|
||||||
|
expect(state.todayCost).toBe(0);
|
||||||
|
expect(state.weekCost).toBe(0);
|
||||||
|
expect(state.monthCost).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formattedCosts", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
costTrackingStore.reset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats the initial zero costs correctly", () => {
|
||||||
|
const costs = get(formattedCosts);
|
||||||
|
expect(costs.today).toBe("$0.0000");
|
||||||
|
expect(costs.week).toBe("$0.0000");
|
||||||
|
expect(costs.month).toBe("$0.0000");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reflects updated costs after a refresh", async () => {
|
||||||
|
setMockInvokeResult("get_today_cost", 1.5);
|
||||||
|
setMockInvokeResult("get_week_cost", 5.0);
|
||||||
|
setMockInvokeResult("get_month_cost", 20.0);
|
||||||
|
setMockInvokeResult("get_cost_alerts", []);
|
||||||
|
await costTrackingStore.refresh();
|
||||||
|
|
||||||
|
const costs = get(formattedCosts);
|
||||||
|
expect(costs.today).toBe("$1.50");
|
||||||
|
expect(costs.week).toBe("$5.00");
|
||||||
|
expect(costs.month).toBe("$20.00");
|
||||||
|
expect(costs.todayRaw).toBe(1.5);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||||
|
import { get } from "svelte/store";
|
||||||
|
import { emitMockEvent } from "../../../vitest.setup";
|
||||||
|
import { debugConsoleStore, filteredLogs } from "./debugConsole";
|
||||||
|
|
||||||
|
describe("debugConsoleStore", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
debugConsoleStore.clear();
|
||||||
|
debugConsoleStore.close();
|
||||||
|
debugConsoleStore.setFilterLevel("all");
|
||||||
|
debugConsoleStore.setAutoScroll(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
debugConsoleStore.restoreConsole();
|
||||||
|
debugConsoleStore.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("initializes with correct default state", () => {
|
||||||
|
const state = get(debugConsoleStore);
|
||||||
|
expect(state.logs).toEqual([]);
|
||||||
|
expect(state.isOpen).toBe(false);
|
||||||
|
expect(state.maxLogs).toBe(1000);
|
||||||
|
expect(state.filterLevel).toBe("all");
|
||||||
|
expect(state.autoScroll).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("toggle", () => {
|
||||||
|
it("opens when currently closed", () => {
|
||||||
|
debugConsoleStore.toggle();
|
||||||
|
expect(get(debugConsoleStore).isOpen).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes when currently open", () => {
|
||||||
|
debugConsoleStore.open();
|
||||||
|
debugConsoleStore.toggle();
|
||||||
|
expect(get(debugConsoleStore).isOpen).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("open", () => {
|
||||||
|
it("sets isOpen to true", () => {
|
||||||
|
debugConsoleStore.open();
|
||||||
|
expect(get(debugConsoleStore).isOpen).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("close", () => {
|
||||||
|
it("sets isOpen to false", () => {
|
||||||
|
debugConsoleStore.open();
|
||||||
|
debugConsoleStore.close();
|
||||||
|
expect(get(debugConsoleStore).isOpen).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("clear", () => {
|
||||||
|
it("removes all log entries", async () => {
|
||||||
|
await debugConsoleStore.setupBackendLogsListener();
|
||||||
|
emitMockEvent("debug:log", { level: "info", message: "test entry" });
|
||||||
|
expect(get(debugConsoleStore).logs.length).toBe(1);
|
||||||
|
debugConsoleStore.clear();
|
||||||
|
expect(get(debugConsoleStore).logs).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("setFilterLevel", () => {
|
||||||
|
it("updates filterLevel to the specified level", () => {
|
||||||
|
debugConsoleStore.setFilterLevel("error");
|
||||||
|
expect(get(debugConsoleStore).filterLevel).toBe("error");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can reset filterLevel back to all", () => {
|
||||||
|
debugConsoleStore.setFilterLevel("warn");
|
||||||
|
debugConsoleStore.setFilterLevel("all");
|
||||||
|
expect(get(debugConsoleStore).filterLevel).toBe("all");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("setAutoScroll", () => {
|
||||||
|
it("disables autoScroll", () => {
|
||||||
|
debugConsoleStore.setAutoScroll(false);
|
||||||
|
expect(get(debugConsoleStore).autoScroll).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-enables autoScroll", () => {
|
||||||
|
debugConsoleStore.setAutoScroll(false);
|
||||||
|
debugConsoleStore.setAutoScroll(true);
|
||||||
|
expect(get(debugConsoleStore).autoScroll).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("setupConsoleCapture", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
debugConsoleStore.restoreConsole();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("captures console.log calls as info-level frontend logs", () => {
|
||||||
|
debugConsoleStore.setupConsoleCapture();
|
||||||
|
console.log("hello world");
|
||||||
|
const logs = get(debugConsoleStore).logs;
|
||||||
|
const captured = logs.find((l) => l.message === "hello world");
|
||||||
|
expect(captured).toBeDefined();
|
||||||
|
expect(captured?.level).toBe("info");
|
||||||
|
expect(captured?.source).toBe("frontend");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("captures console.info calls as info-level logs", () => {
|
||||||
|
debugConsoleStore.setupConsoleCapture();
|
||||||
|
console.info("info message");
|
||||||
|
const logs = get(debugConsoleStore).logs;
|
||||||
|
const captured = logs.find((l) => l.message === "info message");
|
||||||
|
expect(captured?.level).toBe("info");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("captures console.warn calls as warn-level logs", () => {
|
||||||
|
debugConsoleStore.setupConsoleCapture();
|
||||||
|
console.warn("warning message");
|
||||||
|
const logs = get(debugConsoleStore).logs;
|
||||||
|
const captured = logs.find((l) => l.message === "warning message");
|
||||||
|
expect(captured?.level).toBe("warn");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("captures console.error calls as error-level logs", () => {
|
||||||
|
debugConsoleStore.setupConsoleCapture();
|
||||||
|
console.error("error message");
|
||||||
|
const logs = get(debugConsoleStore).logs;
|
||||||
|
const captured = logs.find((l) => l.message === "error message");
|
||||||
|
expect(captured?.level).toBe("error");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("captures console.debug calls as debug-level logs", () => {
|
||||||
|
debugConsoleStore.setupConsoleCapture();
|
||||||
|
console.debug("debug message");
|
||||||
|
const logs = get(debugConsoleStore).logs;
|
||||||
|
const captured = logs.find((l) => l.message === "debug message");
|
||||||
|
expect(captured?.level).toBe("debug");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("joins multiple console arguments with spaces", () => {
|
||||||
|
debugConsoleStore.setupConsoleCapture();
|
||||||
|
console.log("hello", "world", 42);
|
||||||
|
const logs = get(debugConsoleStore).logs;
|
||||||
|
const captured = logs.find((l) => l.message === "hello world 42");
|
||||||
|
expect(captured).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("captures unhandled window error events", () => {
|
||||||
|
debugConsoleStore.setupConsoleCapture();
|
||||||
|
const errorEvent = new ErrorEvent("error", {
|
||||||
|
message: "Test unhandled error",
|
||||||
|
filename: "test.js",
|
||||||
|
lineno: 10,
|
||||||
|
colno: 5,
|
||||||
|
});
|
||||||
|
window.dispatchEvent(errorEvent);
|
||||||
|
const logs = get(debugConsoleStore).logs;
|
||||||
|
const captured = logs.find(
|
||||||
|
(l) => l.level === "error" && l.message.includes("[Unhandled Error]")
|
||||||
|
);
|
||||||
|
expect(captured).toBeDefined();
|
||||||
|
expect(captured?.message).toContain("Test unhandled error");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("captures unhandled promise rejection events", () => {
|
||||||
|
debugConsoleStore.setupConsoleCapture();
|
||||||
|
const rejectionEvent = Object.assign(new Event("unhandledrejection"), {
|
||||||
|
reason: "test rejection reason",
|
||||||
|
});
|
||||||
|
window.dispatchEvent(rejectionEvent);
|
||||||
|
const logs = get(debugConsoleStore).logs;
|
||||||
|
const captured = logs.find(
|
||||||
|
(l) => l.level === "error" && l.message.includes("[Unhandled Promise Rejection]")
|
||||||
|
);
|
||||||
|
expect(captured).toBeDefined();
|
||||||
|
expect(captured?.message).toContain("test rejection reason");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("restoreConsole", () => {
|
||||||
|
it("stops capturing console output after restore", () => {
|
||||||
|
debugConsoleStore.setupConsoleCapture();
|
||||||
|
debugConsoleStore.restoreConsole();
|
||||||
|
const countBefore = get(debugConsoleStore).logs.length;
|
||||||
|
console.log("this should not be captured");
|
||||||
|
expect(get(debugConsoleStore).logs.length).toBe(countBefore);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("setupBackendLogsListener", () => {
|
||||||
|
it("captures backend logs emitted via debug:log event", async () => {
|
||||||
|
await debugConsoleStore.setupBackendLogsListener();
|
||||||
|
emitMockEvent("debug:log", { level: "info", message: "backend message" });
|
||||||
|
const logs = get(debugConsoleStore).logs;
|
||||||
|
expect(logs.length).toBe(1);
|
||||||
|
expect(logs[0].level).toBe("info");
|
||||||
|
expect(logs[0].message).toBe("backend message");
|
||||||
|
expect(logs[0].source).toBe("backend");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles different log levels from backend", async () => {
|
||||||
|
await debugConsoleStore.setupBackendLogsListener();
|
||||||
|
emitMockEvent("debug:log", { level: "error", message: "backend error" });
|
||||||
|
const logs = get(debugConsoleStore).logs;
|
||||||
|
expect(logs[0].level).toBe("error");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("circular buffer", () => {
|
||||||
|
it("drops oldest log when exceeding 1000-entry limit", async () => {
|
||||||
|
await debugConsoleStore.setupBackendLogsListener();
|
||||||
|
for (let i = 0; i < 1001; i++) {
|
||||||
|
emitMockEvent("debug:log", { level: "info", message: `log ${i}` });
|
||||||
|
}
|
||||||
|
const logs = get(debugConsoleStore).logs;
|
||||||
|
expect(logs.length).toBe(1000);
|
||||||
|
expect(logs[0].message).toBe("log 1");
|
||||||
|
expect(logs[999].message).toBe("log 1000");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("filteredLogs derived store", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
debugConsoleStore.clear();
|
||||||
|
debugConsoleStore.setFilterLevel("all");
|
||||||
|
await debugConsoleStore.setupBackendLogsListener();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
debugConsoleStore.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns all logs when filterLevel is all", () => {
|
||||||
|
emitMockEvent("debug:log", { level: "debug", message: "d" });
|
||||||
|
emitMockEvent("debug:log", { level: "info", message: "i" });
|
||||||
|
emitMockEvent("debug:log", { level: "warn", message: "w" });
|
||||||
|
emitMockEvent("debug:log", { level: "error", message: "e" });
|
||||||
|
expect(get(filteredLogs).length).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns only error logs when filterLevel is error", () => {
|
||||||
|
emitMockEvent("debug:log", { level: "debug", message: "d" });
|
||||||
|
emitMockEvent("debug:log", { level: "info", message: "i" });
|
||||||
|
emitMockEvent("debug:log", { level: "warn", message: "w" });
|
||||||
|
emitMockEvent("debug:log", { level: "error", message: "e" });
|
||||||
|
debugConsoleStore.setFilterLevel("error");
|
||||||
|
const logs = get(filteredLogs);
|
||||||
|
expect(logs.length).toBe(1);
|
||||||
|
expect(logs[0].level).toBe("error");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns warn and error logs when filterLevel is warn", () => {
|
||||||
|
emitMockEvent("debug:log", { level: "debug", message: "d" });
|
||||||
|
emitMockEvent("debug:log", { level: "info", message: "i" });
|
||||||
|
emitMockEvent("debug:log", { level: "warn", message: "w" });
|
||||||
|
emitMockEvent("debug:log", { level: "error", message: "e" });
|
||||||
|
debugConsoleStore.setFilterLevel("warn");
|
||||||
|
const logs = get(filteredLogs);
|
||||||
|
expect(logs.length).toBe(2);
|
||||||
|
expect(logs.every((l) => l.level === "warn" || l.level === "error")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("excludes debug logs when filterLevel is info", () => {
|
||||||
|
emitMockEvent("debug:log", { level: "debug", message: "d" });
|
||||||
|
emitMockEvent("debug:log", { level: "info", message: "i" });
|
||||||
|
emitMockEvent("debug:log", { level: "warn", message: "w" });
|
||||||
|
emitMockEvent("debug:log", { level: "error", message: "e" });
|
||||||
|
debugConsoleStore.setFilterLevel("info");
|
||||||
|
const logs = get(filteredLogs);
|
||||||
|
expect(logs.length).toBe(3);
|
||||||
|
expect(logs.some((l) => l.level === "debug")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns all log levels when filterLevel is debug", () => {
|
||||||
|
emitMockEvent("debug:log", { level: "debug", message: "d" });
|
||||||
|
emitMockEvent("debug:log", { level: "info", message: "i" });
|
||||||
|
emitMockEvent("debug:log", { level: "warn", message: "w" });
|
||||||
|
emitMockEvent("debug:log", { level: "error", message: "e" });
|
||||||
|
debugConsoleStore.setFilterLevel("debug");
|
||||||
|
expect(get(filteredLogs).length).toBe(4);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -180,6 +180,16 @@ describe("draftsStore", () => {
|
|||||||
const result = draftsStore.formatTimestamp(invalid);
|
const result = draftsStore.formatTimestamp(invalid);
|
||||||
expect(result).toBe(invalid);
|
expect(result).toBe(invalid);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("falls back to raw string when toLocaleString throws", () => {
|
||||||
|
const spy = vi.spyOn(Date.prototype, "toLocaleString").mockImplementation(() => {
|
||||||
|
throw new Error("Locale not supported");
|
||||||
|
});
|
||||||
|
const ts = "2026-01-15T14:30:00.000Z";
|
||||||
|
const result = draftsStore.formatTimestamp(ts);
|
||||||
|
expect(result).toBe(ts);
|
||||||
|
spy.mockRestore();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,639 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { get } from "svelte/store";
|
||||||
|
import { editorStore } from "$lib/stores/editor";
|
||||||
|
import { setMockInvokeResult } from "../../../vitest.setup";
|
||||||
|
|
||||||
|
const FILE_CONTENT = "// test file content";
|
||||||
|
|
||||||
|
// Reset tabs between tests
|
||||||
|
afterEach(async () => {
|
||||||
|
const tabs = get(editorStore.tabs);
|
||||||
|
for (const tab of tabs) {
|
||||||
|
editorStore.closeTab(tab.id);
|
||||||
|
}
|
||||||
|
editorStore.hideEditor();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("editorStore - openFile (tests getLanguageFromPath)", () => {
|
||||||
|
const testCases: [string, string][] = [
|
||||||
|
["file.ts", "typescript"],
|
||||||
|
["file.tsx", "typescript"],
|
||||||
|
["file.js", "javascript"],
|
||||||
|
["file.jsx", "javascript"],
|
||||||
|
["file.py", "python"],
|
||||||
|
["file.rs", "rust"],
|
||||||
|
["file.go", "go"],
|
||||||
|
["file.java", "java"],
|
||||||
|
["file.c", "c"],
|
||||||
|
["file.cpp", "cpp"],
|
||||||
|
["file.h", "c"],
|
||||||
|
["file.hpp", "cpp"],
|
||||||
|
["file.cs", "csharp"],
|
||||||
|
["file.rb", "ruby"],
|
||||||
|
["file.php", "php"],
|
||||||
|
["file.swift", "swift"],
|
||||||
|
["file.kt", "kotlin"],
|
||||||
|
["file.scala", "scala"],
|
||||||
|
["file.html", "html"],
|
||||||
|
["file.htm", "html"],
|
||||||
|
["file.css", "css"],
|
||||||
|
["file.scss", "scss"],
|
||||||
|
["file.sass", "sass"],
|
||||||
|
["file.less", "less"],
|
||||||
|
["file.json", "json"],
|
||||||
|
["file.xml", "xml"],
|
||||||
|
["file.yaml", "yaml"],
|
||||||
|
["file.yml", "yaml"],
|
||||||
|
["file.toml", "toml"],
|
||||||
|
["file.md", "markdown"],
|
||||||
|
["file.markdown", "markdown"],
|
||||||
|
["file.sql", "sql"],
|
||||||
|
["file.sh", "shell"],
|
||||||
|
["file.bash", "shell"],
|
||||||
|
["file.zsh", "shell"],
|
||||||
|
["file.ps1", "powershell"],
|
||||||
|
["file.svelte", "svelte"],
|
||||||
|
["file.vue", "vue"],
|
||||||
|
["file.graphql", "graphql"],
|
||||||
|
["file.gql", "graphql"],
|
||||||
|
["file.lua", "lua"],
|
||||||
|
["file.r", "r"],
|
||||||
|
["file.dart", "dart"],
|
||||||
|
["file.elm", "elm"],
|
||||||
|
["file.ex", "elixir"],
|
||||||
|
["file.exs", "elixir"],
|
||||||
|
["file.erl", "erlang"],
|
||||||
|
["file.hs", "haskell"],
|
||||||
|
["file.clj", "clojure"],
|
||||||
|
["file.lisp", "lisp"],
|
||||||
|
["file.ml", "ocaml"],
|
||||||
|
["file.fs", "fsharp"],
|
||||||
|
["file.zig", "zig"],
|
||||||
|
["file.nim", "nim"],
|
||||||
|
["file.v", "v"],
|
||||||
|
["file.wasm", "wasm"],
|
||||||
|
["file.wat", "wasm"],
|
||||||
|
["dockerfile", "plaintext"],
|
||||||
|
["file.unknown", "plaintext"],
|
||||||
|
["file", "plaintext"],
|
||||||
|
];
|
||||||
|
|
||||||
|
it.each(testCases)("maps %s to language %s", async (filename, expectedLanguage) => {
|
||||||
|
setMockInvokeResult("read_file_content", FILE_CONTENT);
|
||||||
|
await editorStore.openFile(`/path/to/${filename}`);
|
||||||
|
const activeTab = get(editorStore.activeTab);
|
||||||
|
expect(activeTab?.language).toBe(expectedLanguage);
|
||||||
|
if (activeTab) editorStore.closeTab(activeTab.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens a new tab and makes it active", async () => {
|
||||||
|
setMockInvokeResult("read_file_content", FILE_CONTENT);
|
||||||
|
await editorStore.openFile("/path/to/file.ts");
|
||||||
|
const tabs = get(editorStore.tabs);
|
||||||
|
const activeTab = get(editorStore.activeTab);
|
||||||
|
expect(tabs).toHaveLength(1);
|
||||||
|
expect(activeTab?.fileName).toBe("file.ts");
|
||||||
|
expect(activeTab?.content).toBe(FILE_CONTENT);
|
||||||
|
expect(activeTab?.isDirty).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("switches to existing tab instead of opening a duplicate", async () => {
|
||||||
|
setMockInvokeResult("read_file_content", FILE_CONTENT);
|
||||||
|
await editorStore.openFile("/path/to/file.ts");
|
||||||
|
const firstTabId = get(editorStore.activeTabId);
|
||||||
|
|
||||||
|
// Open another file to change active tab
|
||||||
|
await editorStore.openFile("/path/to/other.ts");
|
||||||
|
const secondTabId = get(editorStore.activeTabId);
|
||||||
|
expect(secondTabId).not.toBe(firstTabId);
|
||||||
|
|
||||||
|
// Re-open first file — should switch to existing tab
|
||||||
|
await editorStore.openFile("/path/to/file.ts");
|
||||||
|
expect(get(editorStore.activeTabId)).toBe(firstTabId);
|
||||||
|
expect(get(editorStore.tabs)).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles open file errors gracefully", async () => {
|
||||||
|
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
setMockInvokeResult("read_file_content", new Error("File not found"));
|
||||||
|
await editorStore.openFile("/path/to/missing.ts");
|
||||||
|
expect(get(editorStore.tabs)).toHaveLength(0);
|
||||||
|
expect(get(editorStore.saveError)).toContain("Failed to open file");
|
||||||
|
consoleErrorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("extracts filename from path correctly", async () => {
|
||||||
|
setMockInvokeResult("read_file_content", FILE_CONTENT);
|
||||||
|
await editorStore.openFile("/deep/nested/path/component.svelte");
|
||||||
|
expect(get(editorStore.activeTab)?.fileName).toBe("component.svelte");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("editorStore - saveFile", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
setMockInvokeResult("read_file_content", FILE_CONTENT);
|
||||||
|
await editorStore.openFile("/path/to/file.ts");
|
||||||
|
// Make it dirty first
|
||||||
|
editorStore.updateTabContent(get(editorStore.activeTabId)!, "modified content");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("saves the active tab successfully", async () => {
|
||||||
|
setMockInvokeResult("write_file_content", undefined);
|
||||||
|
await editorStore.saveFile();
|
||||||
|
expect(get(editorStore.activeTab)?.isDirty).toBe(false);
|
||||||
|
expect(get(editorStore.activeTab)?.originalContent).toBe("modified content");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("saves a specific tab by ID", async () => {
|
||||||
|
setMockInvokeResult("write_file_content", undefined);
|
||||||
|
const tabId = get(editorStore.activeTabId)!;
|
||||||
|
await editorStore.saveFile(tabId);
|
||||||
|
expect(get(editorStore.activeTab)?.isDirty).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does nothing when no tab ID matches", async () => {
|
||||||
|
await editorStore.saveFile("non-existent-tab");
|
||||||
|
// No error = pass
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("editorStore - saveFile error", () => {
|
||||||
|
it("sets saveError when write fails", async () => {
|
||||||
|
setMockInvokeResult("read_file_content", FILE_CONTENT);
|
||||||
|
await editorStore.openFile("/path/to/file-err.ts");
|
||||||
|
editorStore.updateTabContent(get(editorStore.activeTabId)!, "modified content");
|
||||||
|
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
setMockInvokeResult("write_file_content", new Error("Permission denied"));
|
||||||
|
await editorStore.saveFile().catch(() => {});
|
||||||
|
expect(get(editorStore.saveError)).toContain("Failed to save file");
|
||||||
|
consoleErrorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("editorStore - updateTabContent", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
setMockInvokeResult("read_file_content", FILE_CONTENT);
|
||||||
|
await editorStore.openFile("/path/to/file.ts");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates content and marks tab as dirty", () => {
|
||||||
|
const tabId = get(editorStore.activeTabId)!;
|
||||||
|
editorStore.updateTabContent(tabId, "new content");
|
||||||
|
const tab = get(editorStore.activeTab);
|
||||||
|
expect(tab?.content).toBe("new content");
|
||||||
|
expect(tab?.isDirty).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks tab as clean when content matches original", () => {
|
||||||
|
const tabId = get(editorStore.activeTabId)!;
|
||||||
|
editorStore.updateTabContent(tabId, "modified");
|
||||||
|
expect(get(editorStore.activeTab)?.isDirty).toBe(true);
|
||||||
|
editorStore.updateTabContent(tabId, FILE_CONTENT);
|
||||||
|
expect(get(editorStore.activeTab)?.isDirty).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hasDirtyTabs is true when any tab is dirty", () => {
|
||||||
|
const tabId = get(editorStore.activeTabId)!;
|
||||||
|
expect(get(editorStore.hasDirtyTabs)).toBe(false);
|
||||||
|
editorStore.updateTabContent(tabId, "dirty content");
|
||||||
|
expect(get(editorStore.hasDirtyTabs)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("editorStore - closeTab", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
setMockInvokeResult("read_file_content", FILE_CONTENT);
|
||||||
|
await editorStore.openFile("/path/to/file1.ts");
|
||||||
|
await editorStore.openFile("/path/to/file2.ts");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes the specified tab", () => {
|
||||||
|
const tabs = get(editorStore.tabs);
|
||||||
|
editorStore.closeTab(tabs[0].id);
|
||||||
|
expect(get(editorStore.tabs)).toHaveLength(1);
|
||||||
|
expect(get(editorStore.tabs)[0].filePath).toBe("/path/to/file2.ts");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets active tab to remaining tab when active tab is closed", () => {
|
||||||
|
const activeTabId = get(editorStore.activeTabId)!;
|
||||||
|
editorStore.closeTab(activeTabId);
|
||||||
|
expect(get(editorStore.activeTabId)).not.toBe(activeTabId);
|
||||||
|
expect(get(editorStore.tabs)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("editorStore - setActiveTab", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
setMockInvokeResult("read_file_content", FILE_CONTENT);
|
||||||
|
await editorStore.openFile("/path/to/file1.ts");
|
||||||
|
await editorStore.openFile("/path/to/file2.ts");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets the active tab to the specified ID", () => {
|
||||||
|
const tabs = get(editorStore.tabs);
|
||||||
|
editorStore.setActiveTab(tabs[0].id);
|
||||||
|
expect(get(editorStore.activeTabId)).toBe(tabs[0].id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("editorStore - visibility controls", () => {
|
||||||
|
it("showEditor sets isEditorVisible to true", () => {
|
||||||
|
expect(get(editorStore.isEditorVisible)).toBe(false);
|
||||||
|
editorStore.showEditor();
|
||||||
|
expect(get(editorStore.isEditorVisible)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hideEditor sets isEditorVisible to false", () => {
|
||||||
|
editorStore.showEditor();
|
||||||
|
editorStore.hideEditor();
|
||||||
|
expect(get(editorStore.isEditorVisible)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toggleEditor toggles isEditorVisible", () => {
|
||||||
|
expect(get(editorStore.isEditorVisible)).toBe(false);
|
||||||
|
editorStore.toggleEditor();
|
||||||
|
expect(get(editorStore.isEditorVisible)).toBe(true);
|
||||||
|
editorStore.toggleEditor();
|
||||||
|
expect(get(editorStore.isEditorVisible)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("editorStore - toggleFileBrowser", () => {
|
||||||
|
it("toggles the file browser visibility", () => {
|
||||||
|
const initial = get(editorStore.isFileBrowserOpen);
|
||||||
|
editorStore.toggleFileBrowser();
|
||||||
|
expect(get(editorStore.isFileBrowserOpen)).toBe(!initial);
|
||||||
|
editorStore.toggleFileBrowser();
|
||||||
|
expect(get(editorStore.isFileBrowserOpen)).toBe(initial);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("editorStore - file system operations", () => {
|
||||||
|
it("createFile invokes backend and refreshes directory", async () => {
|
||||||
|
setMockInvokeResult("create_file", undefined);
|
||||||
|
setMockInvokeResult("list_directory", []);
|
||||||
|
await editorStore.createFile("/path/to", "new-file.ts");
|
||||||
|
// No error = success
|
||||||
|
});
|
||||||
|
|
||||||
|
it("createFile handles errors gracefully", async () => {
|
||||||
|
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
setMockInvokeResult("create_file", new Error("Permission denied"));
|
||||||
|
await editorStore.createFile("/path/to", "new-file.ts");
|
||||||
|
expect(consoleErrorSpy).toHaveBeenCalledWith("Failed to create file:", expect.any(Error));
|
||||||
|
consoleErrorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("createDirectory invokes backend and refreshes", async () => {
|
||||||
|
setMockInvokeResult("create_directory", undefined);
|
||||||
|
setMockInvokeResult("list_directory", []);
|
||||||
|
await editorStore.createDirectory("/path/to", "new-dir");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("createDirectory handles errors gracefully", async () => {
|
||||||
|
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
setMockInvokeResult("create_directory", new Error("Failed"));
|
||||||
|
await editorStore.createDirectory("/path/to", "new-dir");
|
||||||
|
expect(consoleErrorSpy).toHaveBeenCalledWith("Failed to create directory:", expect.any(Error));
|
||||||
|
consoleErrorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deleteFile invokes backend and refreshes", async () => {
|
||||||
|
setMockInvokeResult("delete_file", undefined);
|
||||||
|
setMockInvokeResult("list_directory", []);
|
||||||
|
await editorStore.deleteFile("/path/to/file.ts");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deleteFile handles errors gracefully", async () => {
|
||||||
|
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
setMockInvokeResult("delete_file", new Error("Failed"));
|
||||||
|
await editorStore.deleteFile("/path/to/file.ts");
|
||||||
|
expect(consoleErrorSpy).toHaveBeenCalledWith("Failed to delete file:", expect.any(Error));
|
||||||
|
consoleErrorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deleteDirectory invokes backend and refreshes", async () => {
|
||||||
|
setMockInvokeResult("delete_directory", undefined);
|
||||||
|
setMockInvokeResult("list_directory", []);
|
||||||
|
await editorStore.deleteDirectory("/path/to/dir");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deleteDirectory handles errors gracefully", async () => {
|
||||||
|
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
setMockInvokeResult("delete_directory", new Error("Failed"));
|
||||||
|
await editorStore.deleteDirectory("/path/to/dir");
|
||||||
|
expect(consoleErrorSpy).toHaveBeenCalledWith("Failed to delete directory:", expect.any(Error));
|
||||||
|
consoleErrorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renamePath invokes backend and refreshes", async () => {
|
||||||
|
setMockInvokeResult("rename_path", undefined);
|
||||||
|
setMockInvokeResult("list_directory", []);
|
||||||
|
await editorStore.renamePath("/path/to/old-name.ts", "new-name.ts");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renamePath handles errors gracefully", async () => {
|
||||||
|
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
setMockInvokeResult("rename_path", new Error("Failed"));
|
||||||
|
await editorStore.renamePath("/path/to/old-name.ts", "new-name.ts");
|
||||||
|
expect(consoleErrorSpy).toHaveBeenCalledWith("Failed to rename:", expect.any(Error));
|
||||||
|
consoleErrorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("editorStore - initializeFileTree", () => {
|
||||||
|
it("loads directory entries and updates file tree", async () => {
|
||||||
|
const entries = [
|
||||||
|
{ path: "/path/file.ts", name: "file.ts", isDirectory: false, children: undefined },
|
||||||
|
];
|
||||||
|
setMockInvokeResult("list_directory", entries);
|
||||||
|
await editorStore.initializeFileTree("/path");
|
||||||
|
expect(get(editorStore.fileTree)).toEqual(entries);
|
||||||
|
expect(get(editorStore.currentDirectory)).toBe("/path");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles errors gracefully", async () => {
|
||||||
|
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
setMockInvokeResult("list_directory", new Error("Failed"));
|
||||||
|
await editorStore.initializeFileTree("/path");
|
||||||
|
expect(get(editorStore.isLoadingTree)).toBe(false);
|
||||||
|
consoleErrorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("editorStore - toggleDirectory", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
const entries = [
|
||||||
|
{
|
||||||
|
path: "/path/dir",
|
||||||
|
name: "dir",
|
||||||
|
isDirectory: true,
|
||||||
|
isExpanded: false,
|
||||||
|
children: undefined,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
setMockInvokeResult("list_directory", entries);
|
||||||
|
await editorStore.initializeFileTree("/path");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does nothing when entry is not a directory", async () => {
|
||||||
|
const nonDirEntry = {
|
||||||
|
path: "/path/file.ts",
|
||||||
|
name: "file.ts",
|
||||||
|
isDirectory: false,
|
||||||
|
};
|
||||||
|
const treeBefore = get(editorStore.fileTree);
|
||||||
|
await editorStore.toggleDirectory(nonDirEntry);
|
||||||
|
expect(get(editorStore.fileTree)).toEqual(treeBefore);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("expands a collapsed directory and loads children asynchronously", async () => {
|
||||||
|
const children = [
|
||||||
|
{ path: "/path/dir/child.ts", name: "child.ts", isDirectory: false, children: undefined },
|
||||||
|
];
|
||||||
|
setMockInvokeResult("list_directory", children);
|
||||||
|
|
||||||
|
const dirEntry = get(editorStore.fileTree)[0];
|
||||||
|
await editorStore.toggleDirectory(dirEntry);
|
||||||
|
|
||||||
|
const updatedEntry = get(editorStore.fileTree)[0];
|
||||||
|
expect(updatedEntry.isExpanded).toBe(true);
|
||||||
|
expect(updatedEntry.children).toEqual(children);
|
||||||
|
expect(updatedEntry.isLoading).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("collapses an expanded directory", async () => {
|
||||||
|
const children = [
|
||||||
|
{ path: "/path/dir/child.ts", name: "child.ts", isDirectory: false, children: undefined },
|
||||||
|
];
|
||||||
|
setMockInvokeResult("list_directory", children);
|
||||||
|
|
||||||
|
// Expand first
|
||||||
|
const dirEntry = get(editorStore.fileTree)[0];
|
||||||
|
await editorStore.toggleDirectory(dirEntry);
|
||||||
|
expect(get(editorStore.fileTree)[0].isExpanded).toBe(true);
|
||||||
|
|
||||||
|
// Now collapse
|
||||||
|
const expandedEntry = get(editorStore.fileTree)[0];
|
||||||
|
await editorStore.toggleDirectory(expandedEntry);
|
||||||
|
expect(get(editorStore.fileTree)[0].isExpanded).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("expands a directory that already has children without reloading", async () => {
|
||||||
|
// Re-init with a directory that already has children
|
||||||
|
const entries = [
|
||||||
|
{
|
||||||
|
path: "/path/dir",
|
||||||
|
name: "dir",
|
||||||
|
isDirectory: true,
|
||||||
|
isExpanded: false,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: "/path/dir/child.ts",
|
||||||
|
name: "child.ts",
|
||||||
|
isDirectory: false,
|
||||||
|
children: undefined,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
setMockInvokeResult("list_directory", entries);
|
||||||
|
await editorStore.initializeFileTree("/path");
|
||||||
|
|
||||||
|
// Toggle should expand without loading (children already present)
|
||||||
|
const dirEntry = get(editorStore.fileTree)[0];
|
||||||
|
await editorStore.toggleDirectory(dirEntry);
|
||||||
|
|
||||||
|
const updatedEntry = get(editorStore.fileTree)[0];
|
||||||
|
expect(updatedEntry.isExpanded).toBe(true);
|
||||||
|
expect(updatedEntry.children).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles tree with multiple entries when toggling — exercises non-matching leaf fallback", async () => {
|
||||||
|
// Tree has dir + a sibling file; toggling dir exercises the `return e` branch for the file
|
||||||
|
const entries = [
|
||||||
|
{
|
||||||
|
path: "/path/dir",
|
||||||
|
name: "dir",
|
||||||
|
isDirectory: true,
|
||||||
|
isExpanded: false,
|
||||||
|
children: undefined,
|
||||||
|
},
|
||||||
|
{ path: "/path/file.ts", name: "file.ts", isDirectory: false, children: undefined },
|
||||||
|
];
|
||||||
|
setMockInvokeResult("list_directory", entries);
|
||||||
|
await editorStore.initializeFileTree("/path");
|
||||||
|
|
||||||
|
const children: never[] = [];
|
||||||
|
setMockInvokeResult("list_directory", children);
|
||||||
|
const dirEntry = get(editorStore.fileTree)[0];
|
||||||
|
await editorStore.toggleDirectory(dirEntry);
|
||||||
|
|
||||||
|
// The sibling file should remain unchanged
|
||||||
|
expect(get(editorStore.fileTree)[1].path).toBe("/path/file.ts");
|
||||||
|
expect(get(editorStore.fileTree)[0].isExpanded).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles nested directory toggle — exercises recursive updateTree branches", async () => {
|
||||||
|
// Tree: outer_dir → [inner_dir (no children), sibling_file]
|
||||||
|
// Toggling inner_dir covers the recursive path through outer's children
|
||||||
|
const entries = [
|
||||||
|
{
|
||||||
|
path: "/path/outer",
|
||||||
|
name: "outer",
|
||||||
|
isDirectory: true,
|
||||||
|
isExpanded: true,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: "/path/outer/inner",
|
||||||
|
name: "inner",
|
||||||
|
isDirectory: true,
|
||||||
|
isExpanded: false,
|
||||||
|
children: undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/path/outer/sibling.ts",
|
||||||
|
name: "sibling.ts",
|
||||||
|
isDirectory: false,
|
||||||
|
children: undefined,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
setMockInvokeResult("list_directory", entries);
|
||||||
|
await editorStore.initializeFileTree("/path");
|
||||||
|
|
||||||
|
const innerChildren = [
|
||||||
|
{
|
||||||
|
path: "/path/outer/inner/deep.ts",
|
||||||
|
name: "deep.ts",
|
||||||
|
isDirectory: false,
|
||||||
|
children: undefined,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
setMockInvokeResult("list_directory", innerChildren);
|
||||||
|
|
||||||
|
const innerEntry = get(editorStore.fileTree)[0].children![0];
|
||||||
|
await editorStore.toggleDirectory(innerEntry);
|
||||||
|
|
||||||
|
const outer = get(editorStore.fileTree)[0];
|
||||||
|
expect(outer.children![0].isExpanded).toBe(true);
|
||||||
|
expect(outer.children![0].children).toEqual(innerChildren);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("editorStore - deleteFile closes open tab", () => {
|
||||||
|
it("closes the tab when the deleted file is open", async () => {
|
||||||
|
setMockInvokeResult("read_file_content", FILE_CONTENT);
|
||||||
|
await editorStore.openFile("/path/to/file.ts");
|
||||||
|
expect(get(editorStore.tabs)).toHaveLength(1);
|
||||||
|
|
||||||
|
setMockInvokeResult("delete_file", undefined);
|
||||||
|
setMockInvokeResult("list_directory", []);
|
||||||
|
await editorStore.deleteFile("/path/to/file.ts");
|
||||||
|
expect(get(editorStore.tabs)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("editorStore - deleteDirectory closes open tabs", () => {
|
||||||
|
it("closes all tabs inside the deleted directory", async () => {
|
||||||
|
setMockInvokeResult("read_file_content", FILE_CONTENT);
|
||||||
|
await editorStore.openFile("/path/to/dir/file1.ts");
|
||||||
|
await editorStore.openFile("/path/to/dir/nested/file2.ts");
|
||||||
|
await editorStore.openFile("/path/to/other.ts");
|
||||||
|
expect(get(editorStore.tabs)).toHaveLength(3);
|
||||||
|
|
||||||
|
setMockInvokeResult("delete_directory", undefined);
|
||||||
|
setMockInvokeResult("list_directory", []);
|
||||||
|
await editorStore.deleteDirectory("/path/to/dir");
|
||||||
|
|
||||||
|
const remainingTabs = get(editorStore.tabs);
|
||||||
|
expect(remainingTabs).toHaveLength(1);
|
||||||
|
expect(remainingTabs[0].filePath).toBe("/path/to/other.ts");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("editorStore - renamePath updates open tabs", () => {
|
||||||
|
it("updates filePath and fileName when the renamed file is open in a tab", async () => {
|
||||||
|
setMockInvokeResult("read_file_content", FILE_CONTENT);
|
||||||
|
await editorStore.openFile("/path/to/old-name.ts");
|
||||||
|
|
||||||
|
setMockInvokeResult("rename_path", undefined);
|
||||||
|
setMockInvokeResult("list_directory", []);
|
||||||
|
await editorStore.renamePath("/path/to/old-name.ts", "new-name.ts");
|
||||||
|
|
||||||
|
const tab = get(editorStore.activeTab);
|
||||||
|
expect(tab?.filePath).toBe("/path/to/new-name.ts");
|
||||||
|
expect(tab?.fileName).toBe("new-name.ts");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates filePaths for tabs inside a renamed directory", async () => {
|
||||||
|
setMockInvokeResult("read_file_content", FILE_CONTENT);
|
||||||
|
await editorStore.openFile("/path/to/old-dir/file.ts");
|
||||||
|
|
||||||
|
setMockInvokeResult("rename_path", undefined);
|
||||||
|
setMockInvokeResult("list_directory", []);
|
||||||
|
await editorStore.renamePath("/path/to/old-dir", "new-dir");
|
||||||
|
|
||||||
|
const tab = get(editorStore.activeTab);
|
||||||
|
expect(tab?.filePath).toBe("/path/to/new-dir/file.ts");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves unrelated tabs unchanged when renaming — exercises return-t fallback", async () => {
|
||||||
|
setMockInvokeResult("read_file_content", FILE_CONTENT);
|
||||||
|
await editorStore.openFile("/path/to/unrelated.ts");
|
||||||
|
await editorStore.openFile("/path/to/old-name.ts");
|
||||||
|
|
||||||
|
setMockInvokeResult("rename_path", undefined);
|
||||||
|
setMockInvokeResult("list_directory", []);
|
||||||
|
await editorStore.renamePath("/path/to/old-name.ts", "new-name.ts");
|
||||||
|
|
||||||
|
const tabs = get(editorStore.tabs);
|
||||||
|
const unrelated = tabs.find((t) => t.filePath === "/path/to/unrelated.ts");
|
||||||
|
expect(unrelated).toBeDefined();
|
||||||
|
expect(unrelated?.fileName).toBe("unrelated.ts");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("editorStore - refreshDirectory", () => {
|
||||||
|
it("reloads the entire tree when refreshing the root directory", async () => {
|
||||||
|
const initialEntries = [
|
||||||
|
{ path: "/root/file.ts", name: "file.ts", isDirectory: false, children: undefined },
|
||||||
|
];
|
||||||
|
setMockInvokeResult("list_directory", initialEntries);
|
||||||
|
await editorStore.initializeFileTree("/root");
|
||||||
|
expect(get(editorStore.fileTree)).toHaveLength(1);
|
||||||
|
|
||||||
|
const updatedEntries = [
|
||||||
|
{ path: "/root/file.ts", name: "file.ts", isDirectory: false, children: undefined },
|
||||||
|
{ path: "/root/new.ts", name: "new.ts", isDirectory: false, children: undefined },
|
||||||
|
];
|
||||||
|
setMockInvokeResult("list_directory", updatedEntries);
|
||||||
|
await editorStore.refreshDirectory("/root");
|
||||||
|
expect(get(editorStore.fileTree)).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates a subdirectory entry in the tree", async () => {
|
||||||
|
const initialEntries = [
|
||||||
|
{
|
||||||
|
path: "/root/subdir",
|
||||||
|
name: "subdir",
|
||||||
|
isDirectory: true,
|
||||||
|
isExpanded: true,
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
setMockInvokeResult("list_directory", initialEntries);
|
||||||
|
await editorStore.initializeFileTree("/root");
|
||||||
|
|
||||||
|
const subChildren = [
|
||||||
|
{ path: "/root/subdir/file.ts", name: "file.ts", isDirectory: false, children: undefined },
|
||||||
|
];
|
||||||
|
setMockInvokeResult("list_directory", subChildren);
|
||||||
|
await editorStore.refreshDirectory("/root/subdir");
|
||||||
|
|
||||||
|
const tree = get(editorStore.fileTree);
|
||||||
|
expect(tree[0].children).toEqual(subChildren);
|
||||||
|
expect(tree[0].isExpanded).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from "vitest";
|
||||||
|
import {
|
||||||
|
setShouldRestoreHistory,
|
||||||
|
setSavedHistory,
|
||||||
|
getShouldRestoreHistory,
|
||||||
|
getSavedHistory,
|
||||||
|
clearHistoryRestore,
|
||||||
|
} from "./historyRestore";
|
||||||
|
|
||||||
|
describe("historyRestore module", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
clearHistoryRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("initial state", () => {
|
||||||
|
it("shouldRestoreHistory is false by default", () => {
|
||||||
|
expect(getShouldRestoreHistory()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("savedHistory is null by default", () => {
|
||||||
|
expect(getSavedHistory()).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("setShouldRestoreHistory", () => {
|
||||||
|
it("sets shouldRestoreHistory to true", () => {
|
||||||
|
setShouldRestoreHistory(true);
|
||||||
|
expect(getShouldRestoreHistory()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets shouldRestoreHistory to false", () => {
|
||||||
|
setShouldRestoreHistory(true);
|
||||||
|
setShouldRestoreHistory(false);
|
||||||
|
expect(getShouldRestoreHistory()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("setSavedHistory", () => {
|
||||||
|
it("sets the saved history string", () => {
|
||||||
|
setSavedHistory("some history content");
|
||||||
|
expect(getSavedHistory()).toBe("some history content");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets the saved history to null", () => {
|
||||||
|
setSavedHistory("some history content");
|
||||||
|
setSavedHistory(null);
|
||||||
|
expect(getSavedHistory()).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("clearHistoryRestore", () => {
|
||||||
|
it("resets shouldRestoreHistory to false", () => {
|
||||||
|
setShouldRestoreHistory(true);
|
||||||
|
clearHistoryRestore();
|
||||||
|
expect(getShouldRestoreHistory()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resets savedHistory to null", () => {
|
||||||
|
setSavedHistory("some content");
|
||||||
|
clearHistoryRestore();
|
||||||
|
expect(getSavedHistory()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears both values at once", () => {
|
||||||
|
setShouldRestoreHistory(true);
|
||||||
|
setSavedHistory("history");
|
||||||
|
clearHistoryRestore();
|
||||||
|
expect(getShouldRestoreHistory()).toBe(false);
|
||||||
|
expect(getSavedHistory()).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from "vitest";
|
||||||
|
import { get } from "svelte/store";
|
||||||
|
import { memoryBrowserStore } from "./memoryBrowser";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
memoryBrowserStore.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("memoryBrowserStore", () => {
|
||||||
|
it("initialises with panel closed", () => {
|
||||||
|
const state = get(memoryBrowserStore);
|
||||||
|
expect(state.isOpen).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("open() sets isOpen to true", () => {
|
||||||
|
memoryBrowserStore.open();
|
||||||
|
expect(get(memoryBrowserStore).isOpen).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("close() sets isOpen to false", () => {
|
||||||
|
memoryBrowserStore.open();
|
||||||
|
memoryBrowserStore.close();
|
||||||
|
expect(get(memoryBrowserStore).isOpen).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toggle() opens the panel when closed", () => {
|
||||||
|
memoryBrowserStore.close();
|
||||||
|
memoryBrowserStore.toggle();
|
||||||
|
expect(get(memoryBrowserStore).isOpen).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toggle() closes the panel when open", () => {
|
||||||
|
memoryBrowserStore.open();
|
||||||
|
memoryBrowserStore.toggle();
|
||||||
|
expect(get(memoryBrowserStore).isOpen).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calling open() when already open keeps it open", () => {
|
||||||
|
memoryBrowserStore.open();
|
||||||
|
memoryBrowserStore.open();
|
||||||
|
expect(get(memoryBrowserStore).isOpen).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calling close() when already closed keeps it closed", () => {
|
||||||
|
memoryBrowserStore.close();
|
||||||
|
memoryBrowserStore.close();
|
||||||
|
expect(get(memoryBrowserStore).isOpen).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { writable } from "svelte/store";
|
||||||
|
|
||||||
|
interface MemoryBrowserState {
|
||||||
|
isOpen: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMemoryBrowserStore() {
|
||||||
|
const { subscribe, update } = writable<MemoryBrowserState>({
|
||||||
|
isOpen: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
subscribe,
|
||||||
|
open: () => update((state) => ({ ...state, isOpen: true })),
|
||||||
|
close: () => update((state) => ({ ...state, isOpen: false })),
|
||||||
|
toggle: () => update((state) => ({ ...state, isOpen: !state.isOpen })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const memoryBrowserStore = createMemoryBrowserStore();
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from "vitest";
|
||||||
|
import { get } from "svelte/store";
|
||||||
|
import { messageMode, getCurrentMode } from "./messageMode";
|
||||||
|
|
||||||
|
describe("messageMode store", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
messageMode.reset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("initial state", () => {
|
||||||
|
it("defaults to chat mode", () => {
|
||||||
|
expect(get(messageMode)).toBe("chat");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("set", () => {
|
||||||
|
it("sets the mode to the given value", () => {
|
||||||
|
messageMode.set("plan");
|
||||||
|
expect(get(messageMode)).toBe("plan");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can set any arbitrary mode string", () => {
|
||||||
|
messageMode.set("auto");
|
||||||
|
expect(get(messageMode)).toBe("auto");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("reset", () => {
|
||||||
|
it("resets mode back to chat", () => {
|
||||||
|
messageMode.set("plan");
|
||||||
|
messageMode.reset();
|
||||||
|
expect(get(messageMode)).toBe("chat");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getCurrentMode", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
messageMode.reset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns chat when in default state", () => {
|
||||||
|
expect(getCurrentMode()).toBe("chat");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the currently set mode", () => {
|
||||||
|
messageMode.set("plan");
|
||||||
|
expect(getCurrentMode()).toBe("plan");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns chat after a reset", () => {
|
||||||
|
messageMode.set("auto");
|
||||||
|
messageMode.reset();
|
||||||
|
expect(getCurrentMode()).toBe("chat");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { writable } from "svelte/store";
|
||||||
|
|
||||||
|
const mockSetEnabled = vi.fn();
|
||||||
|
const mockSetGlobalVolume = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("$lib/notifications", () => ({
|
||||||
|
soundPlayer: {
|
||||||
|
setEnabled: mockSetEnabled,
|
||||||
|
setGlobalVolume: mockSetGlobalVolume,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// We need to control the config store's emitted values
|
||||||
|
const configWritable = writable({
|
||||||
|
notifications_enabled: true,
|
||||||
|
notification_volume: 0.7,
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("./config", () => ({
|
||||||
|
configStore: {
|
||||||
|
config: { subscribe: configWritable.subscribe },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("notifications sync store", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.resetModules();
|
||||||
|
mockSetEnabled.mockReset();
|
||||||
|
mockSetGlobalVolume.mockReset();
|
||||||
|
configWritable.set({ notifications_enabled: true, notification_volume: 0.7 });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
// Re-import to clean up any lingering subscriptions
|
||||||
|
const { cleanupNotificationSync } = await import("./notifications");
|
||||||
|
cleanupNotificationSync();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("initNotificationSync", () => {
|
||||||
|
it("syncs soundPlayer enabled state from config on init", async () => {
|
||||||
|
const { initNotificationSync } = await import("./notifications");
|
||||||
|
initNotificationSync();
|
||||||
|
expect(mockSetEnabled).toHaveBeenCalledWith(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("syncs soundPlayer volume from config on init", async () => {
|
||||||
|
const { initNotificationSync } = await import("./notifications");
|
||||||
|
initNotificationSync();
|
||||||
|
expect(mockSetGlobalVolume).toHaveBeenCalledWith(0.7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates soundPlayer when config changes", async () => {
|
||||||
|
const { initNotificationSync } = await import("./notifications");
|
||||||
|
initNotificationSync();
|
||||||
|
|
||||||
|
mockSetEnabled.mockReset();
|
||||||
|
mockSetGlobalVolume.mockReset();
|
||||||
|
|
||||||
|
configWritable.set({ notifications_enabled: false, notification_volume: 0.3 });
|
||||||
|
|
||||||
|
expect(mockSetEnabled).toHaveBeenCalledWith(false);
|
||||||
|
expect(mockSetGlobalVolume).toHaveBeenCalledWith(0.3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not register a duplicate subscription when called twice", async () => {
|
||||||
|
const { initNotificationSync } = await import("./notifications");
|
||||||
|
initNotificationSync();
|
||||||
|
initNotificationSync();
|
||||||
|
|
||||||
|
// Both calls should only produce one subscription (one initial sync)
|
||||||
|
expect(mockSetEnabled).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("cleanupNotificationSync", () => {
|
||||||
|
it("stops reacting to config changes after cleanup", async () => {
|
||||||
|
const { initNotificationSync, cleanupNotificationSync } = await import("./notifications");
|
||||||
|
initNotificationSync();
|
||||||
|
cleanupNotificationSync();
|
||||||
|
|
||||||
|
mockSetEnabled.mockReset();
|
||||||
|
configWritable.set({ notifications_enabled: false, notification_volume: 0.5 });
|
||||||
|
|
||||||
|
expect(mockSetEnabled).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is safe to call when not initialised", async () => {
|
||||||
|
const { cleanupNotificationSync } = await import("./notifications");
|
||||||
|
expect(() => cleanupNotificationSync()).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows re-initialisation after cleanup", async () => {
|
||||||
|
const { initNotificationSync, cleanupNotificationSync } = await import("./notifications");
|
||||||
|
initNotificationSync();
|
||||||
|
cleanupNotificationSync();
|
||||||
|
|
||||||
|
mockSetEnabled.mockReset();
|
||||||
|
initNotificationSync();
|
||||||
|
|
||||||
|
expect(mockSetEnabled).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,615 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { get } from "svelte/store";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { setMockInvokeResult } from "../../../vitest.setup";
|
||||||
|
import { prdStore, PRD_FILENAME, type PrdTask } from "./prd";
|
||||||
|
|
||||||
|
describe("PRD_FILENAME", () => {
|
||||||
|
it("is hikari-tasks.json", () => {
|
||||||
|
expect(PRD_FILENAME).toBe("hikari-tasks.json");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("prdStore", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
prdStore.reset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("initial state", () => {
|
||||||
|
it("has empty goal", () => {
|
||||||
|
expect(get(prdStore.goal)).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has empty tasks array", () => {
|
||||||
|
expect(get(prdStore.tasks)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has false isGenerating", () => {
|
||||||
|
expect(get(prdStore.isGenerating)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has false isLoaded", () => {
|
||||||
|
expect(get(prdStore.isLoaded)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has false isLoading", () => {
|
||||||
|
expect(get(prdStore.isLoading)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has false isSaving", () => {
|
||||||
|
expect(get(prdStore.isSaving)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exposes all expected methods", () => {
|
||||||
|
expect(typeof prdStore.loadFromFile).toBe("function");
|
||||||
|
expect(typeof prdStore.saveToFile).toBe("function");
|
||||||
|
expect(typeof prdStore.generatePrd).toBe("function");
|
||||||
|
expect(typeof prdStore.finishGenerating).toBe("function");
|
||||||
|
expect(typeof prdStore.addTask).toBe("function");
|
||||||
|
expect(typeof prdStore.updateTask).toBe("function");
|
||||||
|
expect(typeof prdStore.removeTask).toBe("function");
|
||||||
|
expect(typeof prdStore.moveTaskUp).toBe("function");
|
||||||
|
expect(typeof prdStore.moveTaskDown).toBe("function");
|
||||||
|
expect(typeof prdStore.reset).toBe("function");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("loadFromFile", () => {
|
||||||
|
const mockPrdFile = JSON.stringify({
|
||||||
|
version: 1,
|
||||||
|
goal: "Build a REST API",
|
||||||
|
tasks: [
|
||||||
|
{
|
||||||
|
id: "task-1",
|
||||||
|
title: "Set up project",
|
||||||
|
prompt: "Initialise the Node.js project",
|
||||||
|
priority: "high",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls read_file_content with the correct path", async () => {
|
||||||
|
setMockInvokeResult("read_file_content", mockPrdFile);
|
||||||
|
|
||||||
|
await prdStore.loadFromFile("/home/naomi/myproject");
|
||||||
|
|
||||||
|
expect(invoke).toHaveBeenCalledWith("read_file_content", {
|
||||||
|
path: "/home/naomi/myproject/hikari-tasks.json",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets goal from loaded file", async () => {
|
||||||
|
setMockInvokeResult("read_file_content", mockPrdFile);
|
||||||
|
|
||||||
|
await prdStore.loadFromFile("/home/naomi/myproject");
|
||||||
|
|
||||||
|
expect(get(prdStore.goal)).toBe("Build a REST API");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets tasks from loaded file", async () => {
|
||||||
|
setMockInvokeResult("read_file_content", mockPrdFile);
|
||||||
|
|
||||||
|
await prdStore.loadFromFile("/home/naomi/myproject");
|
||||||
|
|
||||||
|
const tasks = get(prdStore.tasks);
|
||||||
|
expect(tasks).toHaveLength(1);
|
||||||
|
expect(tasks[0].title).toBe("Set up project");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets isLoaded to true on success", async () => {
|
||||||
|
setMockInvokeResult("read_file_content", mockPrdFile);
|
||||||
|
|
||||||
|
await prdStore.loadFromFile("/home/naomi/myproject");
|
||||||
|
|
||||||
|
expect(get(prdStore.isLoaded)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets isLoaded to false when file not found", async () => {
|
||||||
|
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
setMockInvokeResult("read_file_content", new Error("File not found"));
|
||||||
|
|
||||||
|
await prdStore.loadFromFile("/home/naomi/myproject");
|
||||||
|
|
||||||
|
expect(get(prdStore.isLoaded)).toBe(false);
|
||||||
|
consoleSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets isLoaded to false on JSON parse error", async () => {
|
||||||
|
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
setMockInvokeResult("read_file_content", "not valid json {{{");
|
||||||
|
|
||||||
|
await prdStore.loadFromFile("/home/naomi/myproject");
|
||||||
|
|
||||||
|
expect(get(prdStore.isLoaded)).toBe(false);
|
||||||
|
consoleSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets isLoading to false after success", async () => {
|
||||||
|
setMockInvokeResult("read_file_content", mockPrdFile);
|
||||||
|
|
||||||
|
await prdStore.loadFromFile("/home/naomi/myproject");
|
||||||
|
|
||||||
|
expect(get(prdStore.isLoading)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets isLoading to false on error", async () => {
|
||||||
|
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
setMockInvokeResult("read_file_content", new Error("Read error"));
|
||||||
|
|
||||||
|
await prdStore.loadFromFile("/home/naomi/myproject");
|
||||||
|
|
||||||
|
expect(get(prdStore.isLoading)).toBe(false);
|
||||||
|
consoleSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logs error when file load fails", async () => {
|
||||||
|
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
setMockInvokeResult("read_file_content", new Error("File not found"));
|
||||||
|
|
||||||
|
await prdStore.loadFromFile("/home/naomi/myproject");
|
||||||
|
|
||||||
|
expect(consoleSpy).toHaveBeenCalledWith("Failed to load PRD file:", expect.any(Error));
|
||||||
|
consoleSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("saveToFile", () => {
|
||||||
|
it("calls write_file_content with the correct path", async () => {
|
||||||
|
setMockInvokeResult("write_file_content", undefined);
|
||||||
|
|
||||||
|
await prdStore.saveToFile("/home/naomi/myproject");
|
||||||
|
|
||||||
|
expect(invoke).toHaveBeenCalledWith("write_file_content", {
|
||||||
|
path: "/home/naomi/myproject/hikari-tasks.json",
|
||||||
|
content: expect.any(String),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes valid JSON with version 1", async () => {
|
||||||
|
setMockInvokeResult("write_file_content", undefined);
|
||||||
|
|
||||||
|
await prdStore.saveToFile("/home/naomi/myproject");
|
||||||
|
|
||||||
|
const writeCall = vi.mocked(invoke).mock.calls.find(([cmd]) => cmd === "write_file_content");
|
||||||
|
const content = (writeCall?.[1] as { content: string } | undefined)?.content ?? "";
|
||||||
|
const parsed = JSON.parse(content) as { version: number };
|
||||||
|
expect(parsed.version).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes current goal in the saved file", async () => {
|
||||||
|
setMockInvokeResult("write_file_content", undefined);
|
||||||
|
const mockPrdFile = JSON.stringify({ version: 1, goal: "My goal", tasks: [] });
|
||||||
|
setMockInvokeResult("read_file_content", mockPrdFile);
|
||||||
|
await prdStore.loadFromFile("/home/naomi/myproject");
|
||||||
|
|
||||||
|
vi.clearAllMocks();
|
||||||
|
setMockInvokeResult("write_file_content", undefined);
|
||||||
|
await prdStore.saveToFile("/home/naomi/myproject");
|
||||||
|
|
||||||
|
const writeCall = vi.mocked(invoke).mock.calls.find(([cmd]) => cmd === "write_file_content");
|
||||||
|
const content = (writeCall?.[1] as { content: string } | undefined)?.content ?? "";
|
||||||
|
const parsed = JSON.parse(content) as { goal: string };
|
||||||
|
expect(parsed.goal).toBe("My goal");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true on success", async () => {
|
||||||
|
setMockInvokeResult("write_file_content", undefined);
|
||||||
|
|
||||||
|
const result = await prdStore.saveToFile("/home/naomi/myproject");
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false on failure", async () => {
|
||||||
|
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
setMockInvokeResult("write_file_content", new Error("Write failed"));
|
||||||
|
|
||||||
|
const result = await prdStore.saveToFile("/home/naomi/myproject");
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
consoleSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logs error on failure", async () => {
|
||||||
|
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
setMockInvokeResult("write_file_content", new Error("Write failed"));
|
||||||
|
|
||||||
|
await prdStore.saveToFile("/home/naomi/myproject");
|
||||||
|
|
||||||
|
expect(consoleSpy).toHaveBeenCalledWith("Failed to save PRD file:", expect.any(Error));
|
||||||
|
consoleSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets isSaving to false after success", async () => {
|
||||||
|
setMockInvokeResult("write_file_content", undefined);
|
||||||
|
|
||||||
|
await prdStore.saveToFile("/home/naomi/myproject");
|
||||||
|
|
||||||
|
expect(get(prdStore.isSaving)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets isSaving to false on error", async () => {
|
||||||
|
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
setMockInvokeResult("write_file_content", new Error("Write failed"));
|
||||||
|
|
||||||
|
await prdStore.saveToFile("/home/naomi/myproject");
|
||||||
|
|
||||||
|
expect(get(prdStore.isSaving)).toBe(false);
|
||||||
|
consoleSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("generatePrd", () => {
|
||||||
|
it("calls send_prompt with the conversation id", async () => {
|
||||||
|
setMockInvokeResult("send_prompt", undefined);
|
||||||
|
|
||||||
|
await prdStore.generatePrd("Build an API", "/home/naomi/myproject", "conv-123");
|
||||||
|
|
||||||
|
expect(invoke).toHaveBeenCalledWith("send_prompt", {
|
||||||
|
conversationId: "conv-123",
|
||||||
|
message: expect.any(String),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prompt includes the user goal", async () => {
|
||||||
|
setMockInvokeResult("send_prompt", undefined);
|
||||||
|
|
||||||
|
await prdStore.generatePrd("Build an API", "/home/naomi/myproject", "conv-123");
|
||||||
|
|
||||||
|
const sendCall = vi.mocked(invoke).mock.calls.find(([cmd]) => cmd === "send_prompt");
|
||||||
|
const message = (sendCall?.[1] as { message: string } | undefined)?.message ?? "";
|
||||||
|
expect(message).toContain("Build an API");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prompt includes the working directory", async () => {
|
||||||
|
setMockInvokeResult("send_prompt", undefined);
|
||||||
|
|
||||||
|
await prdStore.generatePrd("Build an API", "/home/naomi/myproject", "conv-123");
|
||||||
|
|
||||||
|
const sendCall = vi.mocked(invoke).mock.calls.find(([cmd]) => cmd === "send_prompt");
|
||||||
|
const message = (sendCall?.[1] as { message: string } | undefined)?.message ?? "";
|
||||||
|
expect(message).toContain("/home/naomi/myproject");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prompt mentions hikari-tasks.json", async () => {
|
||||||
|
setMockInvokeResult("send_prompt", undefined);
|
||||||
|
|
||||||
|
await prdStore.generatePrd("Build an API", "/home/naomi/myproject", "conv-123");
|
||||||
|
|
||||||
|
const sendCall = vi.mocked(invoke).mock.calls.find(([cmd]) => cmd === "send_prompt");
|
||||||
|
const message = (sendCall?.[1] as { message: string } | undefined)?.message ?? "";
|
||||||
|
expect(message).toContain("hikari-tasks.json");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets the goal in the store", async () => {
|
||||||
|
setMockInvokeResult("send_prompt", undefined);
|
||||||
|
|
||||||
|
await prdStore.generatePrd("Build an API", "/home/naomi/myproject", "conv-123");
|
||||||
|
|
||||||
|
expect(get(prdStore.goal)).toBe("Build an API");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resets isGenerating to false on send_prompt error", async () => {
|
||||||
|
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
setMockInvokeResult("send_prompt", new Error("Send failed"));
|
||||||
|
|
||||||
|
await prdStore.generatePrd("Build an API", "/home/naomi/myproject", "conv-123");
|
||||||
|
|
||||||
|
expect(get(prdStore.isGenerating)).toBe(false);
|
||||||
|
consoleSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logs error when send_prompt fails", async () => {
|
||||||
|
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
setMockInvokeResult("send_prompt", new Error("Send failed"));
|
||||||
|
|
||||||
|
await prdStore.generatePrd("Build an API", "/home/naomi/myproject", "conv-123");
|
||||||
|
|
||||||
|
expect(consoleSpy).toHaveBeenCalledWith("Failed to generate PRD:", expect.any(Error));
|
||||||
|
consoleSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("finishGenerating", () => {
|
||||||
|
it("sets isGenerating to false", () => {
|
||||||
|
prdStore.finishGenerating();
|
||||||
|
expect(get(prdStore.isGenerating)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("executePrd", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setMockInvokeResult("write_file_content", undefined);
|
||||||
|
setMockInvokeResult("send_prompt", undefined);
|
||||||
|
prdStore.addTask({
|
||||||
|
title: "Set up project",
|
||||||
|
prompt: "Initialise the repo",
|
||||||
|
priority: "high",
|
||||||
|
});
|
||||||
|
prdStore.addTask({ title: "Write tests", prompt: "Add vitest tests", priority: "medium" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("saves the file before sending the prompt", async () => {
|
||||||
|
await prdStore.executePrd("/home/naomi/myproject", "conv-123");
|
||||||
|
|
||||||
|
const writeCall = vi.mocked(invoke).mock.calls.find(([cmd]) => cmd === "write_file_content");
|
||||||
|
expect(writeCall).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls send_prompt with the conversation id", async () => {
|
||||||
|
await prdStore.executePrd("/home/naomi/myproject", "conv-123");
|
||||||
|
|
||||||
|
expect(invoke).toHaveBeenCalledWith("send_prompt", {
|
||||||
|
conversationId: "conv-123",
|
||||||
|
message: expect.any(String),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prompt includes all task titles", async () => {
|
||||||
|
await prdStore.executePrd("/home/naomi/myproject", "conv-123");
|
||||||
|
|
||||||
|
const sendCall = vi.mocked(invoke).mock.calls.find(([cmd]) => cmd === "send_prompt");
|
||||||
|
const message = (sendCall?.[1] as { message: string } | undefined)?.message ?? "";
|
||||||
|
expect(message).toContain("Set up project");
|
||||||
|
expect(message).toContain("Write tests");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prompt includes all task prompts", async () => {
|
||||||
|
await prdStore.executePrd("/home/naomi/myproject", "conv-123");
|
||||||
|
|
||||||
|
const sendCall = vi.mocked(invoke).mock.calls.find(([cmd]) => cmd === "send_prompt");
|
||||||
|
const message = (sendCall?.[1] as { message: string } | undefined)?.message ?? "";
|
||||||
|
expect(message).toContain("Initialise the repo");
|
||||||
|
expect(message).toContain("Add vitest tests");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prompt includes the working directory", async () => {
|
||||||
|
await prdStore.executePrd("/home/naomi/myproject", "conv-123");
|
||||||
|
|
||||||
|
const sendCall = vi.mocked(invoke).mock.calls.find(([cmd]) => cmd === "send_prompt");
|
||||||
|
const message = (sendCall?.[1] as { message: string } | undefined)?.message ?? "";
|
||||||
|
expect(message).toContain("/home/naomi/myproject");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prompt references hikari-tasks.json", async () => {
|
||||||
|
await prdStore.executePrd("/home/naomi/myproject", "conv-123");
|
||||||
|
|
||||||
|
const sendCall = vi.mocked(invoke).mock.calls.find(([cmd]) => cmd === "send_prompt");
|
||||||
|
const message = (sendCall?.[1] as { message: string } | undefined)?.message ?? "";
|
||||||
|
expect(message).toContain("hikari-tasks.json");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exposes executePrd as a method", () => {
|
||||||
|
expect(typeof prdStore.executePrd).toBe("function");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("addTask", () => {
|
||||||
|
it("appends a new task to the list", () => {
|
||||||
|
prdStore.addTask({ title: "Task A", prompt: "Do A", priority: "high" });
|
||||||
|
|
||||||
|
const tasks = get(prdStore.tasks);
|
||||||
|
expect(tasks).toHaveLength(1);
|
||||||
|
expect(tasks[0].title).toBe("Task A");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("assigns a unique id to each task", () => {
|
||||||
|
prdStore.addTask({ title: "Task A", prompt: "Do A", priority: "high" });
|
||||||
|
prdStore.addTask({ title: "Task B", prompt: "Do B", priority: "low" });
|
||||||
|
|
||||||
|
const tasks = get(prdStore.tasks);
|
||||||
|
expect(tasks[0].id).not.toBe(tasks[1].id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves all task fields", () => {
|
||||||
|
prdStore.addTask({ title: "My Task", prompt: "Do the thing", priority: "medium" });
|
||||||
|
|
||||||
|
const task = get(prdStore.tasks)[0];
|
||||||
|
expect(task.title).toBe("My Task");
|
||||||
|
expect(task.prompt).toBe("Do the thing");
|
||||||
|
expect(task.priority).toBe("medium");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds tasks in order", () => {
|
||||||
|
prdStore.addTask({ title: "First", prompt: "A", priority: "high" });
|
||||||
|
prdStore.addTask({ title: "Second", prompt: "B", priority: "low" });
|
||||||
|
|
||||||
|
const tasks = get(prdStore.tasks);
|
||||||
|
expect(tasks[0].title).toBe("First");
|
||||||
|
expect(tasks[1].title).toBe("Second");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("updateTask", () => {
|
||||||
|
let taskId: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
prdStore.addTask({ title: "Original", prompt: "Original prompt", priority: "high" });
|
||||||
|
taskId = get(prdStore.tasks)[0].id;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates the title of the specified task", () => {
|
||||||
|
prdStore.updateTask(taskId, { title: "Updated" });
|
||||||
|
|
||||||
|
expect(get(prdStore.tasks)[0].title).toBe("Updated");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates the prompt of the specified task", () => {
|
||||||
|
prdStore.updateTask(taskId, { prompt: "New prompt" });
|
||||||
|
|
||||||
|
expect(get(prdStore.tasks)[0].prompt).toBe("New prompt");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates the priority of the specified task", () => {
|
||||||
|
prdStore.updateTask(taskId, { priority: "low" });
|
||||||
|
|
||||||
|
expect(get(prdStore.tasks)[0].priority).toBe("low");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not affect other tasks", () => {
|
||||||
|
prdStore.addTask({ title: "Other", prompt: "Other prompt", priority: "medium" });
|
||||||
|
const otherId = get(prdStore.tasks)[1].id;
|
||||||
|
|
||||||
|
prdStore.updateTask(taskId, { title: "Changed" });
|
||||||
|
|
||||||
|
const otherTask = get(prdStore.tasks).find((t) => t.id === otherId);
|
||||||
|
expect(otherTask?.title).toBe("Other");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does nothing when id is not found", () => {
|
||||||
|
prdStore.updateTask("nonexistent-id", { title: "Ghost" });
|
||||||
|
|
||||||
|
expect(get(prdStore.tasks)[0].title).toBe("Original");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("removeTask", () => {
|
||||||
|
it("removes the task with the given id", () => {
|
||||||
|
prdStore.addTask({ title: "Keep", prompt: "A", priority: "high" });
|
||||||
|
prdStore.addTask({ title: "Remove", prompt: "B", priority: "low" });
|
||||||
|
const removeId = get(prdStore.tasks)[1].id;
|
||||||
|
|
||||||
|
prdStore.removeTask(removeId);
|
||||||
|
|
||||||
|
const tasks = get(prdStore.tasks);
|
||||||
|
expect(tasks).toHaveLength(1);
|
||||||
|
expect(tasks[0].title).toBe("Keep");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does nothing when id is not found", () => {
|
||||||
|
prdStore.addTask({ title: "Task", prompt: "A", priority: "high" });
|
||||||
|
|
||||||
|
prdStore.removeTask("nonexistent-id");
|
||||||
|
|
||||||
|
expect(get(prdStore.tasks)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("results in empty array when removing the only task", () => {
|
||||||
|
prdStore.addTask({ title: "Only", prompt: "A", priority: "high" });
|
||||||
|
const id = get(prdStore.tasks)[0].id;
|
||||||
|
|
||||||
|
prdStore.removeTask(id);
|
||||||
|
|
||||||
|
expect(get(prdStore.tasks)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("moveTaskUp", () => {
|
||||||
|
let tasks: PrdTask[];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
prdStore.addTask({ title: "First", prompt: "A", priority: "high" });
|
||||||
|
prdStore.addTask({ title: "Second", prompt: "B", priority: "medium" });
|
||||||
|
prdStore.addTask({ title: "Third", prompt: "C", priority: "low" });
|
||||||
|
tasks = get(prdStore.tasks);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("swaps the task with the one above it", () => {
|
||||||
|
prdStore.moveTaskUp(tasks[1].id);
|
||||||
|
|
||||||
|
const result = get(prdStore.tasks);
|
||||||
|
expect(result[0].title).toBe("Second");
|
||||||
|
expect(result[1].title).toBe("First");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does nothing when task is already first", () => {
|
||||||
|
prdStore.moveTaskUp(tasks[0].id);
|
||||||
|
|
||||||
|
const result = get(prdStore.tasks);
|
||||||
|
expect(result[0].title).toBe("First");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does nothing when id is not found", () => {
|
||||||
|
prdStore.moveTaskUp("nonexistent");
|
||||||
|
|
||||||
|
const result = get(prdStore.tasks);
|
||||||
|
expect(result[0].title).toBe("First");
|
||||||
|
expect(result[1].title).toBe("Second");
|
||||||
|
expect(result[2].title).toBe("Third");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not change array length", () => {
|
||||||
|
prdStore.moveTaskUp(tasks[2].id);
|
||||||
|
|
||||||
|
expect(get(prdStore.tasks)).toHaveLength(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("moveTaskDown", () => {
|
||||||
|
let tasks: PrdTask[];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
prdStore.addTask({ title: "First", prompt: "A", priority: "high" });
|
||||||
|
prdStore.addTask({ title: "Second", prompt: "B", priority: "medium" });
|
||||||
|
prdStore.addTask({ title: "Third", prompt: "C", priority: "low" });
|
||||||
|
tasks = get(prdStore.tasks);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("swaps the task with the one below it", () => {
|
||||||
|
prdStore.moveTaskDown(tasks[1].id);
|
||||||
|
|
||||||
|
const result = get(prdStore.tasks);
|
||||||
|
expect(result[1].title).toBe("Third");
|
||||||
|
expect(result[2].title).toBe("Second");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does nothing when task is already last", () => {
|
||||||
|
prdStore.moveTaskDown(tasks[2].id);
|
||||||
|
|
||||||
|
const result = get(prdStore.tasks);
|
||||||
|
expect(result[2].title).toBe("Third");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does nothing when id is not found", () => {
|
||||||
|
prdStore.moveTaskDown("nonexistent");
|
||||||
|
|
||||||
|
const result = get(prdStore.tasks);
|
||||||
|
expect(result[0].title).toBe("First");
|
||||||
|
expect(result[1].title).toBe("Second");
|
||||||
|
expect(result[2].title).toBe("Third");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not change array length", () => {
|
||||||
|
prdStore.moveTaskDown(tasks[0].id);
|
||||||
|
|
||||||
|
expect(get(prdStore.tasks)).toHaveLength(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("reset", () => {
|
||||||
|
it("clears the goal", async () => {
|
||||||
|
setMockInvokeResult("send_prompt", undefined);
|
||||||
|
await prdStore.generatePrd("Some goal", "/wd", "conv-1");
|
||||||
|
|
||||||
|
prdStore.reset();
|
||||||
|
|
||||||
|
expect(get(prdStore.goal)).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears all tasks", () => {
|
||||||
|
prdStore.addTask({ title: "Task", prompt: "A", priority: "high" });
|
||||||
|
|
||||||
|
prdStore.reset();
|
||||||
|
|
||||||
|
expect(get(prdStore.tasks)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets isLoaded to false", async () => {
|
||||||
|
const mockPrdFile = JSON.stringify({ version: 1, goal: "goal", tasks: [] });
|
||||||
|
setMockInvokeResult("read_file_content", mockPrdFile);
|
||||||
|
await prdStore.loadFromFile("/wd");
|
||||||
|
|
||||||
|
prdStore.reset();
|
||||||
|
|
||||||
|
expect(get(prdStore.isLoaded)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets isGenerating to false", () => {
|
||||||
|
prdStore.finishGenerating();
|
||||||
|
prdStore.reset();
|
||||||
|
|
||||||
|
expect(get(prdStore.isGenerating)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
import { writable, get } from "svelte/store";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
|
||||||
|
export interface PrdTask {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
prompt: string;
|
||||||
|
priority: "high" | "medium" | "low";
|
||||||
|
dependsOn?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PrdFile {
|
||||||
|
version: 1;
|
||||||
|
goal: string;
|
||||||
|
tasks: PrdTask[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PRD_FILENAME = "hikari-tasks.json";
|
||||||
|
|
||||||
|
function buildPrdPrompt(userGoal: string, workingDirectory: string): string {
|
||||||
|
return `Please create a PRD task breakdown for the following goal and write it as \`hikari-tasks.json\` in the working directory.
|
||||||
|
|
||||||
|
Goal: ${userGoal}
|
||||||
|
|
||||||
|
Write the file to \`${workingDirectory}/hikari-tasks.json\` containing valid JSON in this exact format:
|
||||||
|
\`\`\`json
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"goal": "<the goal>",
|
||||||
|
"tasks": [
|
||||||
|
{
|
||||||
|
"id": "task-1",
|
||||||
|
"title": "<short descriptive title>",
|
||||||
|
"prompt": "<detailed prompt that Claude Code can execute to complete this task>",
|
||||||
|
"priority": "<high|medium|low>",
|
||||||
|
"dependsOn": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
Guidelines:
|
||||||
|
- Break the goal into 3–10 concrete, actionable tasks
|
||||||
|
- Each task should be completable in a single Claude Code session
|
||||||
|
- Prompts should be specific and actionable, not vague
|
||||||
|
- Order tasks logically (dependencies first)
|
||||||
|
- Assign priority: high for critical path, medium for features, low for polish/cleanup
|
||||||
|
- Fill in \`dependsOn\` with IDs of tasks that must complete before this one (use \`[]\` if none)
|
||||||
|
- Write only the JSON file — no explanations needed`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPrdStore() {
|
||||||
|
const goal = writable<string>("");
|
||||||
|
const tasks = writable<PrdTask[]>([]);
|
||||||
|
const isGenerating = writable<boolean>(false);
|
||||||
|
const isLoaded = writable<boolean>(false);
|
||||||
|
const isLoading = writable<boolean>(false);
|
||||||
|
const isSaving = writable<boolean>(false);
|
||||||
|
let idCounter = 0;
|
||||||
|
|
||||||
|
async function loadFromFile(workingDirectory: string): Promise<void> {
|
||||||
|
isLoading.set(true);
|
||||||
|
try {
|
||||||
|
const path = `${workingDirectory}/${PRD_FILENAME}`;
|
||||||
|
const content = await invoke<string>("read_file_content", { path });
|
||||||
|
const data = JSON.parse(content) as PrdFile;
|
||||||
|
goal.set(data.goal);
|
||||||
|
tasks.set(data.tasks);
|
||||||
|
isLoaded.set(true);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load PRD file:", error);
|
||||||
|
isLoaded.set(false);
|
||||||
|
} finally {
|
||||||
|
isLoading.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveToFile(workingDirectory: string): Promise<boolean> {
|
||||||
|
isSaving.set(true);
|
||||||
|
try {
|
||||||
|
const path = `${workingDirectory}/${PRD_FILENAME}`;
|
||||||
|
const data: PrdFile = {
|
||||||
|
version: 1,
|
||||||
|
goal: get(goal),
|
||||||
|
tasks: get(tasks),
|
||||||
|
};
|
||||||
|
await invoke("write_file_content", { path, content: JSON.stringify(data, null, 2) });
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to save PRD file:", error);
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
isSaving.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generatePrd(
|
||||||
|
userGoal: string,
|
||||||
|
workingDirectory: string,
|
||||||
|
conversationId: string
|
||||||
|
): Promise<void> {
|
||||||
|
isGenerating.set(true);
|
||||||
|
goal.set(userGoal);
|
||||||
|
try {
|
||||||
|
const prompt = buildPrdPrompt(userGoal, workingDirectory);
|
||||||
|
await invoke("send_prompt", { conversationId, message: prompt });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to generate PRD:", error);
|
||||||
|
isGenerating.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function finishGenerating(): void {
|
||||||
|
isGenerating.set(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executePrd(workingDirectory: string, conversationId: string): Promise<void> {
|
||||||
|
await saveToFile(workingDirectory);
|
||||||
|
const currentTasks = get(tasks);
|
||||||
|
const currentGoal = get(goal);
|
||||||
|
const taskList = currentTasks
|
||||||
|
.map((t, i) => `${i + 1}. [${t.priority}] **${t.title}**\n ${t.prompt}`)
|
||||||
|
.join("\n\n");
|
||||||
|
const prompt = `Please execute the following task list for the goal: "${currentGoal}"
|
||||||
|
|
||||||
|
Work through each task in order, completing it fully before moving to the next:
|
||||||
|
|
||||||
|
${taskList}
|
||||||
|
|
||||||
|
The task list is also saved in \`${workingDirectory}/hikari-tasks.json\` for reference.`;
|
||||||
|
await invoke("send_prompt", { conversationId, message: prompt });
|
||||||
|
}
|
||||||
|
|
||||||
|
function addTask(task: Omit<PrdTask, "id">): void {
|
||||||
|
idCounter += 1;
|
||||||
|
const id = `task-${idCounter}`;
|
||||||
|
tasks.update((current) => [...current, { ...task, id }]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateTask(id: string, changes: Partial<Omit<PrdTask, "id">>): void {
|
||||||
|
tasks.update((current) => current.map((t) => (t.id === id ? { ...t, ...changes } : t)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeTask(id: string): void {
|
||||||
|
tasks.update((current) => current.filter((t) => t.id !== id));
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveTaskUp(id: string): void {
|
||||||
|
tasks.update((current) => {
|
||||||
|
const index = current.findIndex((t) => t.id === id);
|
||||||
|
if (index <= 0) return current;
|
||||||
|
const result = [...current];
|
||||||
|
[result[index - 1], result[index]] = [result[index], result[index - 1]];
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveTaskDown(id: string): void {
|
||||||
|
tasks.update((current) => {
|
||||||
|
const index = current.findIndex((t) => t.id === id);
|
||||||
|
if (index < 0 || index >= current.length - 1) return current;
|
||||||
|
const result = [...current];
|
||||||
|
[result[index], result[index + 1]] = [result[index + 1], result[index]];
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset(): void {
|
||||||
|
goal.set("");
|
||||||
|
tasks.set([]);
|
||||||
|
isLoaded.set(false);
|
||||||
|
isGenerating.set(false);
|
||||||
|
idCounter = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
goal: { subscribe: goal.subscribe },
|
||||||
|
tasks: { subscribe: tasks.subscribe },
|
||||||
|
isGenerating: { subscribe: isGenerating.subscribe },
|
||||||
|
isLoaded: { subscribe: isLoaded.subscribe },
|
||||||
|
isLoading: { subscribe: isLoading.subscribe },
|
||||||
|
isSaving: { subscribe: isSaving.subscribe },
|
||||||
|
loadFromFile,
|
||||||
|
saveToFile,
|
||||||
|
generatePrd,
|
||||||
|
finishGenerating,
|
||||||
|
executePrd,
|
||||||
|
addTask,
|
||||||
|
updateTask,
|
||||||
|
removeTask,
|
||||||
|
moveTaskUp,
|
||||||
|
moveTaskDown,
|
||||||
|
reset,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const prdStore = createPrdStore();
|
||||||
@@ -0,0 +1,390 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { get } from "svelte/store";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { setMockInvokeResult } from "../../../vitest.setup";
|
||||||
|
import {
|
||||||
|
projectContextStore,
|
||||||
|
PROJECT_FILE_NAMES,
|
||||||
|
PROJECT_TEMPLATES,
|
||||||
|
PROJECT_CONTEXT_SYSTEM_ADDENDUM,
|
||||||
|
injectTextStore,
|
||||||
|
type ProjectFile,
|
||||||
|
type ProjectScan,
|
||||||
|
} from "./projectContext";
|
||||||
|
|
||||||
|
describe("PROJECT_FILE_NAMES", () => {
|
||||||
|
it("maps all five project file types", () => {
|
||||||
|
expect(PROJECT_FILE_NAMES.PROJECT).toBe("PROJECT.md");
|
||||||
|
expect(PROJECT_FILE_NAMES.REQUIREMENTS).toBe("REQUIREMENTS.md");
|
||||||
|
expect(PROJECT_FILE_NAMES.ROADMAP).toBe("ROADMAP.md");
|
||||||
|
expect(PROJECT_FILE_NAMES.STATE).toBe("STATE.md");
|
||||||
|
expect(PROJECT_FILE_NAMES.CODEBASE).toBe("CODEBASE.md");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("PROJECT_TEMPLATES", () => {
|
||||||
|
const editableFiles: ProjectFile[] = ["PROJECT", "REQUIREMENTS", "ROADMAP", "STATE"];
|
||||||
|
|
||||||
|
it.each(editableFiles)("returns a non-empty template for %s", (file) => {
|
||||||
|
expect(PROJECT_TEMPLATES[file]).toBeTruthy();
|
||||||
|
expect(PROJECT_TEMPLATES[file].length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has an empty string template for CODEBASE (auto-generated)", () => {
|
||||||
|
expect(PROJECT_TEMPLATES.CODEBASE).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("projectContextStore", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("initial state", () => {
|
||||||
|
it("has null contents for all files", () => {
|
||||||
|
const state = get(projectContextStore.contents);
|
||||||
|
expect(state.PROJECT).toBeNull();
|
||||||
|
expect(state.REQUIREMENTS).toBeNull();
|
||||||
|
expect(state.ROADMAP).toBeNull();
|
||||||
|
expect(state.STATE).toBeNull();
|
||||||
|
expect(state.CODEBASE).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has false isLoading for all files", () => {
|
||||||
|
const state = get(projectContextStore.isLoading);
|
||||||
|
expect(state.PROJECT).toBe(false);
|
||||||
|
expect(state.REQUIREMENTS).toBe(false);
|
||||||
|
expect(state.ROADMAP).toBe(false);
|
||||||
|
expect(state.STATE).toBe(false);
|
||||||
|
expect(state.CODEBASE).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has false isSaving for all files", () => {
|
||||||
|
const state = get(projectContextStore.isSaving);
|
||||||
|
expect(state.PROJECT).toBe(false);
|
||||||
|
expect(state.REQUIREMENTS).toBe(false);
|
||||||
|
expect(state.ROADMAP).toBe(false);
|
||||||
|
expect(state.STATE).toBe(false);
|
||||||
|
expect(state.CODEBASE).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has PROJECT as the default activeFile", () => {
|
||||||
|
expect(get(projectContextStore.activeFile)).toBe("PROJECT");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has false isMappingCodebase initially", () => {
|
||||||
|
expect(get(projectContextStore.isMappingCodebase)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exposes all expected methods", () => {
|
||||||
|
expect(typeof projectContextStore.loadFile).toBe("function");
|
||||||
|
expect(typeof projectContextStore.saveFile).toBe("function");
|
||||||
|
expect(typeof projectContextStore.loadAll).toBe("function");
|
||||||
|
expect(typeof projectContextStore.setActiveFile).toBe("function");
|
||||||
|
expect(typeof projectContextStore.getTemplate).toBe("function");
|
||||||
|
expect(typeof projectContextStore.mapCodebase).toBe("function");
|
||||||
|
expect(typeof projectContextStore.finishMapping).toBe("function");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("loadFile", () => {
|
||||||
|
it("calls read_file_content with the correct path", async () => {
|
||||||
|
setMockInvokeResult("read_file_content", "# Project\n\nContent here");
|
||||||
|
|
||||||
|
await projectContextStore.loadFile("PROJECT", "/home/naomi/myproject");
|
||||||
|
|
||||||
|
expect(invoke).toHaveBeenCalledWith("read_file_content", {
|
||||||
|
path: "/home/naomi/myproject/PROJECT.md",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates contents store with file content on success", async () => {
|
||||||
|
const content = "# My Project\n\nDescription here";
|
||||||
|
setMockInvokeResult("read_file_content", content);
|
||||||
|
|
||||||
|
await projectContextStore.loadFile("PROJECT", "/home/naomi/myproject");
|
||||||
|
|
||||||
|
expect(get(projectContextStore.contents).PROJECT).toBe(content);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets content to null when file does not exist", async () => {
|
||||||
|
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
setMockInvokeResult("read_file_content", new Error("File not found"));
|
||||||
|
|
||||||
|
await projectContextStore.loadFile("REQUIREMENTS", "/home/naomi/myproject");
|
||||||
|
|
||||||
|
expect(get(projectContextStore.contents).REQUIREMENTS).toBeNull();
|
||||||
|
consoleSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets isLoading to false after completion", async () => {
|
||||||
|
setMockInvokeResult("read_file_content", "content");
|
||||||
|
|
||||||
|
await projectContextStore.loadFile("ROADMAP", "/home/naomi/myproject");
|
||||||
|
|
||||||
|
expect(get(projectContextStore.isLoading).ROADMAP).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets isLoading to false even on error", async () => {
|
||||||
|
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
setMockInvokeResult("read_file_content", new Error("Read error"));
|
||||||
|
|
||||||
|
await projectContextStore.loadFile("STATE", "/home/naomi/myproject");
|
||||||
|
|
||||||
|
expect(get(projectContextStore.isLoading).STATE).toBe(false);
|
||||||
|
consoleSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses correct filename for each file type", async () => {
|
||||||
|
const files: ProjectFile[] = ["PROJECT", "REQUIREMENTS", "ROADMAP", "STATE"];
|
||||||
|
for (const file of files) {
|
||||||
|
setMockInvokeResult("read_file_content", `content for ${file}`);
|
||||||
|
await projectContextStore.loadFile(file, "/wd");
|
||||||
|
expect(invoke).toHaveBeenCalledWith("read_file_content", {
|
||||||
|
path: `/wd/${PROJECT_FILE_NAMES[file]}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("saveFile", () => {
|
||||||
|
it("calls write_file_content with the correct path and content", async () => {
|
||||||
|
setMockInvokeResult("write_file_content", undefined);
|
||||||
|
|
||||||
|
await projectContextStore.saveFile("PROJECT", "# New content", "/home/naomi/myproject");
|
||||||
|
|
||||||
|
expect(invoke).toHaveBeenCalledWith("write_file_content", {
|
||||||
|
path: "/home/naomi/myproject/PROJECT.md",
|
||||||
|
content: "# New content",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true on success", async () => {
|
||||||
|
setMockInvokeResult("write_file_content", undefined);
|
||||||
|
|
||||||
|
const result = await projectContextStore.saveFile(
|
||||||
|
"PROJECT",
|
||||||
|
"# Content",
|
||||||
|
"/home/naomi/myproject"
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates contents store with saved content on success", async () => {
|
||||||
|
setMockInvokeResult("write_file_content", undefined);
|
||||||
|
|
||||||
|
const newContent = "# Updated Project\n\nNew content";
|
||||||
|
await projectContextStore.saveFile("REQUIREMENTS", newContent, "/home/naomi/myproject");
|
||||||
|
|
||||||
|
expect(get(projectContextStore.contents).REQUIREMENTS).toBe(newContent);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false and logs error on failure", async () => {
|
||||||
|
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
setMockInvokeResult("write_file_content", new Error("Write failed"));
|
||||||
|
|
||||||
|
const result = await projectContextStore.saveFile(
|
||||||
|
"ROADMAP",
|
||||||
|
"content",
|
||||||
|
"/home/naomi/myproject"
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
expect(consoleSpy).toHaveBeenCalledWith(
|
||||||
|
"Failed to save project context file:",
|
||||||
|
expect.any(Error)
|
||||||
|
);
|
||||||
|
consoleSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets isSaving to false after completion", async () => {
|
||||||
|
setMockInvokeResult("write_file_content", undefined);
|
||||||
|
|
||||||
|
await projectContextStore.saveFile("STATE", "content", "/home/naomi/myproject");
|
||||||
|
|
||||||
|
expect(get(projectContextStore.isSaving).STATE).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets isSaving to false even on error", async () => {
|
||||||
|
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
setMockInvokeResult("write_file_content", new Error("Error"));
|
||||||
|
|
||||||
|
await projectContextStore.saveFile("PROJECT", "content", "/home/naomi/myproject");
|
||||||
|
|
||||||
|
expect(get(projectContextStore.isSaving).PROJECT).toBe(false);
|
||||||
|
consoleSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("loadAll", () => {
|
||||||
|
it("loads all five files in parallel", async () => {
|
||||||
|
setMockInvokeResult("read_file_content", "file content");
|
||||||
|
|
||||||
|
await projectContextStore.loadAll("/home/naomi/myproject");
|
||||||
|
|
||||||
|
const calls = vi.mocked(invoke).mock.calls.filter(([cmd]) => cmd === "read_file_content");
|
||||||
|
const paths = calls.map(([, args]) => (args as { path: string }).path);
|
||||||
|
|
||||||
|
expect(paths).toContain("/home/naomi/myproject/PROJECT.md");
|
||||||
|
expect(paths).toContain("/home/naomi/myproject/REQUIREMENTS.md");
|
||||||
|
expect(paths).toContain("/home/naomi/myproject/ROADMAP.md");
|
||||||
|
expect(paths).toContain("/home/naomi/myproject/STATE.md");
|
||||||
|
expect(paths).toContain("/home/naomi/myproject/CODEBASE.md");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets all files isLoading to false after completion", async () => {
|
||||||
|
setMockInvokeResult("read_file_content", "content");
|
||||||
|
|
||||||
|
await projectContextStore.loadAll("/home/naomi/myproject");
|
||||||
|
|
||||||
|
const loadingState = get(projectContextStore.isLoading);
|
||||||
|
expect(loadingState.PROJECT).toBe(false);
|
||||||
|
expect(loadingState.REQUIREMENTS).toBe(false);
|
||||||
|
expect(loadingState.ROADMAP).toBe(false);
|
||||||
|
expect(loadingState.STATE).toBe(false);
|
||||||
|
expect(loadingState.CODEBASE).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("setActiveFile", () => {
|
||||||
|
it("updates the activeFile store", () => {
|
||||||
|
projectContextStore.setActiveFile("REQUIREMENTS");
|
||||||
|
expect(get(projectContextStore.activeFile)).toBe("REQUIREMENTS");
|
||||||
|
|
||||||
|
projectContextStore.setActiveFile("STATE");
|
||||||
|
expect(get(projectContextStore.activeFile)).toBe("STATE");
|
||||||
|
|
||||||
|
projectContextStore.setActiveFile("PROJECT");
|
||||||
|
expect(get(projectContextStore.activeFile)).toBe("PROJECT");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getTemplate", () => {
|
||||||
|
const files: ProjectFile[] = ["PROJECT", "REQUIREMENTS", "ROADMAP", "STATE"];
|
||||||
|
|
||||||
|
it.each(files)("returns a non-empty string for %s", (file) => {
|
||||||
|
const template = projectContextStore.getTemplate(file);
|
||||||
|
expect(typeof template).toBe("string");
|
||||||
|
expect(template.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns distinct templates for each file type", () => {
|
||||||
|
const templates = files.map((f) => projectContextStore.getTemplate(f));
|
||||||
|
const uniqueTemplates = new Set(templates);
|
||||||
|
expect(uniqueTemplates.size).toBe(files.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty string for CODEBASE", () => {
|
||||||
|
expect(projectContextStore.getTemplate("CODEBASE")).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("mapCodebase", () => {
|
||||||
|
const mockScan: ProjectScan = {
|
||||||
|
working_dir: "/home/naomi/myproject",
|
||||||
|
file_tree: "/home/naomi/myproject/\n├── src/\n└── package.json",
|
||||||
|
detected_type: "Node.js",
|
||||||
|
key_files: ["package.json"],
|
||||||
|
};
|
||||||
|
|
||||||
|
it("calls scan_project with the working directory", async () => {
|
||||||
|
setMockInvokeResult("scan_project", mockScan);
|
||||||
|
setMockInvokeResult("send_prompt", undefined);
|
||||||
|
|
||||||
|
await projectContextStore.mapCodebase("/home/naomi/myproject", "conv-123");
|
||||||
|
|
||||||
|
expect(invoke).toHaveBeenCalledWith("scan_project", {
|
||||||
|
workingDir: "/home/naomi/myproject",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls send_prompt with the conversation id and a non-empty prompt", async () => {
|
||||||
|
setMockInvokeResult("scan_project", mockScan);
|
||||||
|
setMockInvokeResult("send_prompt", undefined);
|
||||||
|
|
||||||
|
await projectContextStore.mapCodebase("/home/naomi/myproject", "conv-123");
|
||||||
|
|
||||||
|
expect(invoke).toHaveBeenCalledWith("send_prompt", {
|
||||||
|
conversationId: "conv-123",
|
||||||
|
message: expect.stringContaining("CODEBASE.md"),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prompt includes detected project type", async () => {
|
||||||
|
setMockInvokeResult("scan_project", mockScan);
|
||||||
|
setMockInvokeResult("send_prompt", undefined);
|
||||||
|
|
||||||
|
await projectContextStore.mapCodebase("/home/naomi/myproject", "conv-123");
|
||||||
|
|
||||||
|
const sendCall = vi.mocked(invoke).mock.calls.find(([cmd]) => cmd === "send_prompt");
|
||||||
|
const message = (sendCall?.[1] as { message: string } | undefined)?.message ?? "";
|
||||||
|
expect(message).toContain("Node.js");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prompt includes file tree", async () => {
|
||||||
|
setMockInvokeResult("scan_project", mockScan);
|
||||||
|
setMockInvokeResult("send_prompt", undefined);
|
||||||
|
|
||||||
|
await projectContextStore.mapCodebase("/home/naomi/myproject", "conv-123");
|
||||||
|
|
||||||
|
const sendCall = vi.mocked(invoke).mock.calls.find(([cmd]) => cmd === "send_prompt");
|
||||||
|
const message = (sendCall?.[1] as { message: string } | undefined)?.message ?? "";
|
||||||
|
expect(message).toContain("package.json");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resets isMappingCodebase to false on error", async () => {
|
||||||
|
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
setMockInvokeResult("scan_project", new Error("Scan failed"));
|
||||||
|
|
||||||
|
await projectContextStore.mapCodebase("/home/naomi/myproject", "conv-123");
|
||||||
|
|
||||||
|
expect(get(projectContextStore.isMappingCodebase)).toBe(false);
|
||||||
|
consoleSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logs error when scan_project fails", async () => {
|
||||||
|
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
setMockInvokeResult("scan_project", new Error("Scan failed"));
|
||||||
|
|
||||||
|
await projectContextStore.mapCodebase("/home/naomi/myproject", "conv-123");
|
||||||
|
|
||||||
|
expect(consoleSpy).toHaveBeenCalledWith("Failed to map codebase:", expect.any(Error));
|
||||||
|
consoleSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("finishMapping", () => {
|
||||||
|
it("sets isMappingCodebase to false", () => {
|
||||||
|
projectContextStore.finishMapping();
|
||||||
|
expect(get(projectContextStore.isMappingCodebase)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("PROJECT_CONTEXT_SYSTEM_ADDENDUM", () => {
|
||||||
|
it("is a non-empty string", () => {
|
||||||
|
expect(typeof PROJECT_CONTEXT_SYSTEM_ADDENDUM).toBe("string");
|
||||||
|
expect(PROJECT_CONTEXT_SYSTEM_ADDENDUM.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mentions all five context file names", () => {
|
||||||
|
expect(PROJECT_CONTEXT_SYSTEM_ADDENDUM).toContain("PROJECT.md");
|
||||||
|
expect(PROJECT_CONTEXT_SYSTEM_ADDENDUM).toContain("REQUIREMENTS.md");
|
||||||
|
expect(PROJECT_CONTEXT_SYSTEM_ADDENDUM).toContain("ROADMAP.md");
|
||||||
|
expect(PROJECT_CONTEXT_SYSTEM_ADDENDUM).toContain("STATE.md");
|
||||||
|
expect(PROJECT_CONTEXT_SYSTEM_ADDENDUM).toContain("CODEBASE.md");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("injectTextStore", () => {
|
||||||
|
it("initialises to null", () => {
|
||||||
|
expect(get(injectTextStore)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can be set and read", () => {
|
||||||
|
injectTextStore.set("hello world");
|
||||||
|
expect(get(injectTextStore)).toBe("hello world");
|
||||||
|
injectTextStore.set(null);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
import { writable } from "svelte/store";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
|
||||||
|
export type ProjectFile = "PROJECT" | "REQUIREMENTS" | "ROADMAP" | "STATE" | "CODEBASE";
|
||||||
|
|
||||||
|
export const PROJECT_FILE_NAMES: Record<ProjectFile, string> = {
|
||||||
|
PROJECT: "PROJECT.md",
|
||||||
|
REQUIREMENTS: "REQUIREMENTS.md",
|
||||||
|
ROADMAP: "ROADMAP.md",
|
||||||
|
STATE: "STATE.md",
|
||||||
|
CODEBASE: "CODEBASE.md",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PROJECT_TEMPLATES: Record<ProjectFile, string> = {
|
||||||
|
PROJECT: `# Project Overview
|
||||||
|
|
||||||
|
## What is this project?
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
`,
|
||||||
|
REQUIREMENTS: `# Requirements
|
||||||
|
|
||||||
|
## Functional Requirements
|
||||||
|
|
||||||
|
## Non-Functional Requirements
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
`,
|
||||||
|
ROADMAP: `# Roadmap
|
||||||
|
|
||||||
|
## Current Sprint
|
||||||
|
|
||||||
|
## Next Sprint
|
||||||
|
|
||||||
|
## Backlog
|
||||||
|
|
||||||
|
## Completed
|
||||||
|
`,
|
||||||
|
STATE: `# Current State
|
||||||
|
|
||||||
|
## Last Updated
|
||||||
|
|
||||||
|
## What's Working
|
||||||
|
|
||||||
|
## In Progress
|
||||||
|
|
||||||
|
## Known Issues
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
`,
|
||||||
|
CODEBASE: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
const PROJECT_FILES = Object.keys(PROJECT_FILE_NAMES) as ProjectFile[];
|
||||||
|
|
||||||
|
export interface ProjectScan {
|
||||||
|
working_dir: string;
|
||||||
|
file_tree: string;
|
||||||
|
detected_type: string;
|
||||||
|
key_files: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function createProjectContextStore() {
|
||||||
|
const contents = writable<Record<ProjectFile, string | null>>({
|
||||||
|
PROJECT: null,
|
||||||
|
REQUIREMENTS: null,
|
||||||
|
ROADMAP: null,
|
||||||
|
STATE: null,
|
||||||
|
CODEBASE: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const isLoading = writable<Record<ProjectFile, boolean>>({
|
||||||
|
PROJECT: false,
|
||||||
|
REQUIREMENTS: false,
|
||||||
|
ROADMAP: false,
|
||||||
|
STATE: false,
|
||||||
|
CODEBASE: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const isSaving = writable<Record<ProjectFile, boolean>>({
|
||||||
|
PROJECT: false,
|
||||||
|
REQUIREMENTS: false,
|
||||||
|
ROADMAP: false,
|
||||||
|
STATE: false,
|
||||||
|
CODEBASE: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const activeFile = writable<ProjectFile>("PROJECT");
|
||||||
|
const isMappingCodebase = writable<boolean>(false);
|
||||||
|
|
||||||
|
async function loadFile(file: ProjectFile, workingDirectory: string): Promise<void> {
|
||||||
|
isLoading.update((state) => ({ ...state, [file]: true }));
|
||||||
|
try {
|
||||||
|
const path = `${workingDirectory}/${PROJECT_FILE_NAMES[file]}`;
|
||||||
|
const content = await invoke<string>("read_file_content", { path });
|
||||||
|
contents.update((state) => ({ ...state, [file]: content }));
|
||||||
|
} catch {
|
||||||
|
contents.update((state) => ({ ...state, [file]: null }));
|
||||||
|
} finally {
|
||||||
|
isLoading.update((state) => ({ ...state, [file]: false }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveFile(
|
||||||
|
file: ProjectFile,
|
||||||
|
content: string,
|
||||||
|
workingDirectory: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
isSaving.update((state) => ({ ...state, [file]: true }));
|
||||||
|
try {
|
||||||
|
const path = `${workingDirectory}/${PROJECT_FILE_NAMES[file]}`;
|
||||||
|
await invoke("write_file_content", { path, content });
|
||||||
|
contents.update((state) => ({ ...state, [file]: content }));
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to save project context file:", error);
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
isSaving.update((state) => ({ ...state, [file]: false }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAll(workingDirectory: string): Promise<void> {
|
||||||
|
await Promise.all(PROJECT_FILES.map((file) => loadFile(file, workingDirectory)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function setActiveFile(file: ProjectFile): void {
|
||||||
|
activeFile.set(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTemplate(file: ProjectFile): string {
|
||||||
|
return PROJECT_TEMPLATES[file];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mapCodebase(workingDirectory: string, conversationId: string): Promise<void> {
|
||||||
|
isMappingCodebase.set(true);
|
||||||
|
try {
|
||||||
|
const scan = await invoke<ProjectScan>("scan_project", {
|
||||||
|
workingDir: workingDirectory,
|
||||||
|
});
|
||||||
|
|
||||||
|
const prompt = buildCodebaseMapPrompt(scan);
|
||||||
|
await invoke("send_prompt", { conversationId, message: prompt });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to map codebase:", error);
|
||||||
|
isMappingCodebase.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function finishMapping(): void {
|
||||||
|
isMappingCodebase.set(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
contents: { subscribe: contents.subscribe },
|
||||||
|
isLoading: { subscribe: isLoading.subscribe },
|
||||||
|
isSaving: { subscribe: isSaving.subscribe },
|
||||||
|
activeFile: { subscribe: activeFile.subscribe },
|
||||||
|
isMappingCodebase: { subscribe: isMappingCodebase.subscribe },
|
||||||
|
loadFile,
|
||||||
|
saveFile,
|
||||||
|
loadAll,
|
||||||
|
setActiveFile,
|
||||||
|
getTemplate,
|
||||||
|
mapCodebase,
|
||||||
|
finishMapping,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCodebaseMapPrompt(scan: ProjectScan): string {
|
||||||
|
const keyFilesSection =
|
||||||
|
scan.key_files.length > 0
|
||||||
|
? `\n\nKey files detected:\n${scan.key_files.map((f) => `- ${f}`).join("\n")}`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
return `Please analyse this codebase and generate a comprehensive \`CODEBASE.md\` file in the working directory (${scan.working_dir}).
|
||||||
|
|
||||||
|
Project type detected: **${scan.detected_type}**${keyFilesSection}
|
||||||
|
|
||||||
|
Directory structure:
|
||||||
|
\`\`\`
|
||||||
|
${scan.file_tree}
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
The CODEBASE.md file should include:
|
||||||
|
1. **Overview** — what the project does and its purpose
|
||||||
|
2. **Architecture** — key directories, how the code is organised, and the overall structure
|
||||||
|
3. **Key Components** — the most important files and modules, what they do, and how they interact
|
||||||
|
4. **Data Flow** — how data moves through the system (if applicable)
|
||||||
|
5. **Dependencies** — notable external dependencies and why they are used
|
||||||
|
6. **Development Notes** — anything helpful for a developer new to the codebase
|
||||||
|
|
||||||
|
Write the file concisely but thoroughly. Focus on information that helps a developer understand the codebase quickly. Use the actual file structure above to inform your analysis — read the key files as needed before writing.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const projectContextStore = createProjectContextStore();
|
||||||
|
|
||||||
|
// Signal store for injecting context into the active InputBar.
|
||||||
|
// StatusBar sets this; InputBar subscribes and applies it to inputValue directly,
|
||||||
|
// then resets it to null so the signal only fires once.
|
||||||
|
export const injectTextStore = writable<string | null>(null);
|
||||||
|
|
||||||
|
// Appended silently to custom_instructions at connection time (never saved to config).
|
||||||
|
// Mirrors how CLAUDE.md works natively — Claude checks the files itself if they exist.
|
||||||
|
export const PROJECT_CONTEXT_SYSTEM_ADDENDUM = `
|
||||||
|
|
||||||
|
---
|
||||||
|
The following project context files may exist in your working directory. If they exist, read and refer to them as needed:
|
||||||
|
- PROJECT.md — project overview, goals, and architecture
|
||||||
|
- REQUIREMENTS.md — functional and non-functional requirements
|
||||||
|
- ROADMAP.md — current sprint, backlog, and completed work
|
||||||
|
- STATE.md — current state, known issues, and next steps
|
||||||
|
- CODEBASE.md — auto-generated codebase map and architecture overview`;
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from "vitest";
|
||||||
|
import { get } from "svelte/store";
|
||||||
|
import { searchState, isSearchActive, searchQuery } from "./search";
|
||||||
|
|
||||||
|
describe("searchState store", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
searchState.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("initial state", () => {
|
||||||
|
it("starts with empty query", () => {
|
||||||
|
const state = get(searchState);
|
||||||
|
expect(state.query).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("starts inactive", () => {
|
||||||
|
const state = get(searchState);
|
||||||
|
expect(state.isActive).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("starts with zero match count", () => {
|
||||||
|
const state = get(searchState);
|
||||||
|
expect(state.matchCount).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("starts with zero current match index", () => {
|
||||||
|
const state = get(searchState);
|
||||||
|
expect(state.currentMatchIndex).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("setQuery", () => {
|
||||||
|
it("sets the query string", () => {
|
||||||
|
searchState.setQuery("hello");
|
||||||
|
expect(get(searchState).query).toBe("hello");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("activates the store when query is non-empty", () => {
|
||||||
|
searchState.setQuery("hello");
|
||||||
|
expect(get(searchState).isActive).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deactivates the store when query is empty", () => {
|
||||||
|
searchState.setQuery("hello");
|
||||||
|
searchState.setQuery("");
|
||||||
|
expect(get(searchState).isActive).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resets currentMatchIndex to 0 on each query change", () => {
|
||||||
|
searchState.setQuery("hello");
|
||||||
|
searchState.setMatchCount(5);
|
||||||
|
searchState.nextMatch();
|
||||||
|
searchState.nextMatch();
|
||||||
|
searchState.setQuery("world");
|
||||||
|
expect(get(searchState).currentMatchIndex).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("setMatchCount", () => {
|
||||||
|
it("updates the match count", () => {
|
||||||
|
searchState.setMatchCount(7);
|
||||||
|
expect(get(searchState).matchCount).toBe(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not reset currentMatchIndex", () => {
|
||||||
|
searchState.setQuery("test");
|
||||||
|
searchState.setMatchCount(5);
|
||||||
|
searchState.nextMatch();
|
||||||
|
searchState.setMatchCount(10);
|
||||||
|
expect(get(searchState).currentMatchIndex).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("nextMatch", () => {
|
||||||
|
it("advances to the next match index", () => {
|
||||||
|
searchState.setMatchCount(5);
|
||||||
|
searchState.nextMatch();
|
||||||
|
expect(get(searchState).currentMatchIndex).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wraps around to 0 after the last match", () => {
|
||||||
|
searchState.setMatchCount(3);
|
||||||
|
searchState.nextMatch();
|
||||||
|
searchState.nextMatch();
|
||||||
|
searchState.nextMatch();
|
||||||
|
expect(get(searchState).currentMatchIndex).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stays at 0 when match count is 0", () => {
|
||||||
|
searchState.setMatchCount(0);
|
||||||
|
searchState.nextMatch();
|
||||||
|
expect(get(searchState).currentMatchIndex).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("previousMatch", () => {
|
||||||
|
it("goes to the previous match index", () => {
|
||||||
|
searchState.setMatchCount(5);
|
||||||
|
searchState.nextMatch();
|
||||||
|
searchState.nextMatch();
|
||||||
|
searchState.previousMatch();
|
||||||
|
expect(get(searchState).currentMatchIndex).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wraps around to last match when at index 0", () => {
|
||||||
|
searchState.setMatchCount(5);
|
||||||
|
searchState.previousMatch();
|
||||||
|
expect(get(searchState).currentMatchIndex).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stays at 0 when match count is 0", () => {
|
||||||
|
searchState.setMatchCount(0);
|
||||||
|
searchState.previousMatch();
|
||||||
|
expect(get(searchState).currentMatchIndex).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("clear", () => {
|
||||||
|
it("resets query to empty string", () => {
|
||||||
|
searchState.setQuery("hello");
|
||||||
|
searchState.clear();
|
||||||
|
expect(get(searchState).query).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resets isActive to false", () => {
|
||||||
|
searchState.setQuery("hello");
|
||||||
|
searchState.clear();
|
||||||
|
expect(get(searchState).isActive).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resets matchCount to 0", () => {
|
||||||
|
searchState.setMatchCount(10);
|
||||||
|
searchState.clear();
|
||||||
|
expect(get(searchState).matchCount).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resets currentMatchIndex to 0", () => {
|
||||||
|
searchState.setMatchCount(5);
|
||||||
|
searchState.nextMatch();
|
||||||
|
searchState.nextMatch();
|
||||||
|
searchState.clear();
|
||||||
|
expect(get(searchState).currentMatchIndex).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isSearchActive derived store", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
searchState.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is false initially", () => {
|
||||||
|
expect(get(isSearchActive)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is true when query is set", () => {
|
||||||
|
searchState.setQuery("test");
|
||||||
|
expect(get(isSearchActive)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is false after clearing", () => {
|
||||||
|
searchState.setQuery("test");
|
||||||
|
searchState.clear();
|
||||||
|
expect(get(isSearchActive)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("searchQuery derived store", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
searchState.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is empty string initially", () => {
|
||||||
|
expect(get(searchQuery)).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reflects the current query", () => {
|
||||||
|
searchState.setQuery("my search");
|
||||||
|
expect(get(searchQuery)).toBe("my search");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is empty after clearing", () => {
|
||||||
|
searchState.setQuery("test");
|
||||||
|
searchState.clear();
|
||||||
|
expect(get(searchQuery)).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,729 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { get } from "svelte/store";
|
||||||
|
import { sessionsStore } from "$lib/stores/sessions";
|
||||||
|
import { setMockInvokeResult } from "../../../vitest.setup";
|
||||||
|
import type { SavedSession } from "$lib/stores/sessions";
|
||||||
|
|
||||||
|
vi.mock("@tauri-apps/plugin-dialog", () => ({
|
||||||
|
save: vi.fn(),
|
||||||
|
open: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@tauri-apps/plugin-fs", () => ({
|
||||||
|
writeTextFile: vi.fn().mockResolvedValue(undefined),
|
||||||
|
readTextFile: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@tauri-apps/plugin-opener", () => ({
|
||||||
|
openPath: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const makeSavedSession = (overrides: Partial<SavedSession> = {}): SavedSession => ({
|
||||||
|
id: "session-1",
|
||||||
|
name: "Test Session",
|
||||||
|
created_at: "2026-03-03T10:00:00.000Z",
|
||||||
|
last_activity_at: "2026-03-03T11:00:00.000Z",
|
||||||
|
working_directory: "/home/naomi/code",
|
||||||
|
message_count: 2,
|
||||||
|
preview: "Hello world",
|
||||||
|
messages: [
|
||||||
|
{ id: "msg-1", type: "user", content: "Hello world", timestamp: "2026-03-03T10:00:00.000Z" },
|
||||||
|
{ id: "msg-2", type: "assistant", content: "Hi there!", timestamp: "2026-03-03T10:01:00.000Z" },
|
||||||
|
],
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
const makeConversation = () => ({
|
||||||
|
id: "conv-1",
|
||||||
|
name: "Test Conversation",
|
||||||
|
workingDirectory: "/home/naomi/code",
|
||||||
|
createdAt: new Date("2026-03-03T10:00:00.000Z"),
|
||||||
|
lastActivityAt: new Date("2026-03-03T11:00:00.000Z"),
|
||||||
|
terminalLines: [
|
||||||
|
{
|
||||||
|
id: "line-1",
|
||||||
|
type: "user",
|
||||||
|
content: "Hello",
|
||||||
|
timestamp: new Date("2026-03-03T10:00:00.000Z"),
|
||||||
|
toolName: undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "line-2",
|
||||||
|
type: "assistant",
|
||||||
|
content: "Hi",
|
||||||
|
timestamp: new Date("2026-03-03T10:01:00.000Z"),
|
||||||
|
toolName: undefined,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sessionsStore - loadSessions", () => {
|
||||||
|
it("loads sessions from backend and updates the store", async () => {
|
||||||
|
const sessionList = [
|
||||||
|
{
|
||||||
|
id: "session-1",
|
||||||
|
name: "Test",
|
||||||
|
message_count: 1,
|
||||||
|
preview: "...",
|
||||||
|
last_activity_at: "2026-03-03T11:00:00.000Z",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
setMockInvokeResult("list_sessions", sessionList);
|
||||||
|
await sessionsStore.loadSessions();
|
||||||
|
expect(get(sessionsStore.sessions)).toEqual(sessionList);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sorts sessions by last_activity_at descending", async () => {
|
||||||
|
const sessionList = [
|
||||||
|
{
|
||||||
|
id: "older",
|
||||||
|
name: "Older",
|
||||||
|
message_count: 1,
|
||||||
|
preview: "...",
|
||||||
|
last_activity_at: "2026-03-01T10:00:00.000Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "newest",
|
||||||
|
name: "Newest",
|
||||||
|
message_count: 1,
|
||||||
|
preview: "...",
|
||||||
|
last_activity_at: "2026-03-03T12:00:00.000Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "middle",
|
||||||
|
name: "Middle",
|
||||||
|
message_count: 1,
|
||||||
|
preview: "...",
|
||||||
|
last_activity_at: "2026-03-02T10:00:00.000Z",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
setMockInvokeResult("list_sessions", sessionList);
|
||||||
|
await sessionsStore.loadSessions();
|
||||||
|
const sorted = get(sessionsStore.sessions);
|
||||||
|
expect(sorted[0].id).toBe("newest");
|
||||||
|
expect(sorted[1].id).toBe("middle");
|
||||||
|
expect(sorted[2].id).toBe("older");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles errors gracefully", async () => {
|
||||||
|
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
setMockInvokeResult("list_sessions", new Error("Backend error"));
|
||||||
|
await sessionsStore.loadSessions();
|
||||||
|
expect(spy).toHaveBeenCalledWith("Failed to load sessions:", expect.any(Error));
|
||||||
|
spy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets isLoading to false after completion", async () => {
|
||||||
|
setMockInvokeResult("list_sessions", []);
|
||||||
|
await sessionsStore.loadSessions();
|
||||||
|
expect(get(sessionsStore.isLoading)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sessionsStore - loadSession", () => {
|
||||||
|
it("returns session data on success", async () => {
|
||||||
|
const session = makeSavedSession();
|
||||||
|
setMockInvokeResult("load_session", session);
|
||||||
|
const result = await sessionsStore.loadSession("session-1");
|
||||||
|
expect(result).toEqual(session);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null on error", async () => {
|
||||||
|
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
setMockInvokeResult("load_session", new Error("Not found"));
|
||||||
|
const result = await sessionsStore.loadSession("session-1");
|
||||||
|
expect(result).toBeNull();
|
||||||
|
spy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sessionsStore - deleteSession", () => {
|
||||||
|
it("deletes session and reloads the session list", async () => {
|
||||||
|
setMockInvokeResult("delete_session", undefined);
|
||||||
|
setMockInvokeResult("list_sessions", []);
|
||||||
|
await sessionsStore.deleteSession("session-1");
|
||||||
|
expect(get(sessionsStore.sessions)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles errors gracefully", async () => {
|
||||||
|
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
setMockInvokeResult("delete_session", new Error("Failed"));
|
||||||
|
await sessionsStore.deleteSession("session-1");
|
||||||
|
expect(spy).toHaveBeenCalledWith("Failed to delete session:", expect.any(Error));
|
||||||
|
spy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sessionsStore - searchSessions", () => {
|
||||||
|
it("calls loadSessions when query is empty", async () => {
|
||||||
|
setMockInvokeResult("list_sessions", []);
|
||||||
|
await sessionsStore.searchSessions("");
|
||||||
|
expect(get(sessionsStore.sessions)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls loadSessions when query is whitespace-only", async () => {
|
||||||
|
setMockInvokeResult("list_sessions", []);
|
||||||
|
await sessionsStore.searchSessions(" ");
|
||||||
|
expect(get(sessionsStore.sessions)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("searches with the given query", async () => {
|
||||||
|
const results = [
|
||||||
|
{
|
||||||
|
id: "session-1",
|
||||||
|
name: "Test",
|
||||||
|
message_count: 1,
|
||||||
|
preview: "...",
|
||||||
|
last_activity_at: "2026-03-03T11:00:00.000Z",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
setMockInvokeResult("search_sessions", results);
|
||||||
|
await sessionsStore.searchSessions("test");
|
||||||
|
expect(get(sessionsStore.sessions)).toEqual(results);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sorts search results by last_activity_at descending", async () => {
|
||||||
|
const results = [
|
||||||
|
{
|
||||||
|
id: "older",
|
||||||
|
name: "Older",
|
||||||
|
message_count: 1,
|
||||||
|
preview: "...",
|
||||||
|
last_activity_at: "2026-03-01T10:00:00.000Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "newest",
|
||||||
|
name: "Newest",
|
||||||
|
message_count: 1,
|
||||||
|
preview: "...",
|
||||||
|
last_activity_at: "2026-03-03T12:00:00.000Z",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
setMockInvokeResult("search_sessions", results);
|
||||||
|
await sessionsStore.searchSessions("query");
|
||||||
|
const sorted = get(sessionsStore.sessions);
|
||||||
|
expect(sorted[0].id).toBe("newest");
|
||||||
|
expect(sorted[1].id).toBe("older");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates searchQuery store", async () => {
|
||||||
|
setMockInvokeResult("search_sessions", []);
|
||||||
|
await sessionsStore.searchSessions("hello");
|
||||||
|
expect(get(sessionsStore.searchQuery)).toBe("hello");
|
||||||
|
await sessionsStore.searchSessions("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles search errors gracefully", async () => {
|
||||||
|
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
setMockInvokeResult("search_sessions", new Error("Search failed"));
|
||||||
|
await sessionsStore.searchSessions("error-query");
|
||||||
|
expect(spy).toHaveBeenCalledWith("Failed to search sessions:", expect.any(Error));
|
||||||
|
spy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sessionsStore - clearAllSessions", () => {
|
||||||
|
it("clears all sessions", async () => {
|
||||||
|
setMockInvokeResult("clear_all_sessions", undefined);
|
||||||
|
await sessionsStore.clearAllSessions();
|
||||||
|
expect(get(sessionsStore.sessions)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles errors gracefully", async () => {
|
||||||
|
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
setMockInvokeResult("clear_all_sessions", new Error("Failed"));
|
||||||
|
await sessionsStore.clearAllSessions();
|
||||||
|
expect(spy).toHaveBeenCalledWith("Failed to clear sessions:", expect.any(Error));
|
||||||
|
spy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sessionsStore - saveConversation", () => {
|
||||||
|
it("saves conversation to backend", async () => {
|
||||||
|
setMockInvokeResult("save_session", undefined);
|
||||||
|
setMockInvokeResult("list_sessions", []);
|
||||||
|
await sessionsStore.saveConversation(makeConversation() as never);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles save errors gracefully", async () => {
|
||||||
|
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
setMockInvokeResult("save_session", new Error("Save failed"));
|
||||||
|
await sessionsStore.saveConversation(makeConversation() as never);
|
||||||
|
expect(spy).toHaveBeenCalledWith("Failed to save session:", expect.any(Error));
|
||||||
|
spy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles empty conversation", async () => {
|
||||||
|
setMockInvokeResult("save_session", undefined);
|
||||||
|
setMockInvokeResult("list_sessions", []);
|
||||||
|
const conv = { ...makeConversation(), terminalLines: [] };
|
||||||
|
await sessionsStore.saveConversation(conv as never);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the most recent user message as the preview", async () => {
|
||||||
|
const { invoke } = await import("@tauri-apps/api/core");
|
||||||
|
setMockInvokeResult("save_session", undefined);
|
||||||
|
setMockInvokeResult("list_sessions", []);
|
||||||
|
|
||||||
|
const conv = {
|
||||||
|
...makeConversation(),
|
||||||
|
terminalLines: [
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
type: "user",
|
||||||
|
content: "First message",
|
||||||
|
timestamp: new Date(),
|
||||||
|
toolName: undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "2",
|
||||||
|
type: "assistant",
|
||||||
|
content: "Reply one",
|
||||||
|
timestamp: new Date(),
|
||||||
|
toolName: undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "3",
|
||||||
|
type: "user",
|
||||||
|
content: "Most recent prompt",
|
||||||
|
timestamp: new Date(),
|
||||||
|
toolName: undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "4",
|
||||||
|
type: "assistant",
|
||||||
|
content: "Reply two",
|
||||||
|
timestamp: new Date(),
|
||||||
|
toolName: undefined,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
await sessionsStore.saveConversation(conv as never);
|
||||||
|
|
||||||
|
const saveCall = vi.mocked(invoke).mock.calls.find((call) => call[0] === "save_session");
|
||||||
|
const capturedSession = (saveCall![1] as { session: SavedSession }).session;
|
||||||
|
expect(capturedSession.preview).toBe("Most recent prompt");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("truncates long preview text at 150 characters", async () => {
|
||||||
|
const { invoke } = await import("@tauri-apps/api/core");
|
||||||
|
setMockInvokeResult("save_session", undefined);
|
||||||
|
setMockInvokeResult("list_sessions", []);
|
||||||
|
|
||||||
|
const longContent = "A".repeat(200);
|
||||||
|
const conv = {
|
||||||
|
...makeConversation(),
|
||||||
|
terminalLines: [
|
||||||
|
{ id: "1", type: "user", content: longContent, timestamp: new Date(), toolName: undefined },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
await sessionsStore.saveConversation(conv as never);
|
||||||
|
|
||||||
|
const saveCall = vi.mocked(invoke).mock.calls.find((call) => call[0] === "save_session");
|
||||||
|
const capturedSession = (saveCall![1] as { session: SavedSession }).session;
|
||||||
|
expect(capturedSession.preview).toBe("A".repeat(150) + "...");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses 'Empty conversation' as preview when there are no user messages", async () => {
|
||||||
|
const { invoke } = await import("@tauri-apps/api/core");
|
||||||
|
setMockInvokeResult("save_session", undefined);
|
||||||
|
setMockInvokeResult("list_sessions", []);
|
||||||
|
|
||||||
|
const conv = {
|
||||||
|
...makeConversation(),
|
||||||
|
terminalLines: [
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
type: "assistant",
|
||||||
|
content: "Only assistant message",
|
||||||
|
timestamp: new Date(),
|
||||||
|
toolName: undefined,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
await sessionsStore.saveConversation(conv as never);
|
||||||
|
|
||||||
|
const saveCall = vi.mocked(invoke).mock.calls.find((call) => call[0] === "save_session");
|
||||||
|
const capturedSession = (saveCall![1] as { session: SavedSession }).session;
|
||||||
|
expect(capturedSession.preview).toBe("Empty conversation");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sessionsStore - scheduleAutoSave and cancelAutoSave", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("schedules an auto-save after the debounce delay", async () => {
|
||||||
|
setMockInvokeResult("save_session", undefined);
|
||||||
|
setMockInvokeResult("list_sessions", []);
|
||||||
|
sessionsStore.scheduleAutoSave(makeConversation() as never);
|
||||||
|
await vi.advanceTimersByTimeAsync(2001);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not auto-save empty conversations", async () => {
|
||||||
|
const conv = { ...makeConversation(), terminalLines: [] };
|
||||||
|
sessionsStore.scheduleAutoSave(conv as never);
|
||||||
|
await vi.advanceTimersByTimeAsync(3000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cancels a pending auto-save", () => {
|
||||||
|
const conv = makeConversation();
|
||||||
|
sessionsStore.scheduleAutoSave(conv as never);
|
||||||
|
sessionsStore.cancelAutoSave(conv.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles cancel when no auto-save is pending", () => {
|
||||||
|
sessionsStore.cancelAutoSave("non-existent-id");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears an existing pending auto-save when rescheduled for the same conversation", async () => {
|
||||||
|
setMockInvokeResult("save_session", undefined);
|
||||||
|
setMockInvokeResult("list_sessions", []);
|
||||||
|
const conv = makeConversation();
|
||||||
|
sessionsStore.scheduleAutoSave(conv as never);
|
||||||
|
// Schedule again before timer fires — hits clearTimeout branch (line 487)
|
||||||
|
sessionsStore.scheduleAutoSave(conv as never);
|
||||||
|
await vi.advanceTimersByTimeAsync(2001);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sessionsStore - exportSessionAsJson", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true on successful export", async () => {
|
||||||
|
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||||
|
setMockInvokeResult("load_session", makeSavedSession());
|
||||||
|
vi.mocked(save).mockResolvedValue("/output/session.json");
|
||||||
|
expect(await sessionsStore.exportSessionAsJson("session-1")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when session not found", async () => {
|
||||||
|
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
setMockInvokeResult("load_session", null);
|
||||||
|
expect(await sessionsStore.exportSessionAsJson("session-1")).toBe(false);
|
||||||
|
spy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when user cancels save dialog", async () => {
|
||||||
|
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||||
|
setMockInvokeResult("load_session", makeSavedSession());
|
||||||
|
vi.mocked(save).mockResolvedValue(null);
|
||||||
|
expect(await sessionsStore.exportSessionAsJson("session-1")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false on error", async () => {
|
||||||
|
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
setMockInvokeResult("load_session", new Error("Load failed"));
|
||||||
|
expect(await sessionsStore.exportSessionAsJson("session-1")).toBe(false);
|
||||||
|
spy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when writeTextFile throws", async () => {
|
||||||
|
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||||
|
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
|
||||||
|
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
setMockInvokeResult("load_session", makeSavedSession());
|
||||||
|
vi.mocked(save).mockResolvedValue("/output/session.json");
|
||||||
|
vi.mocked(writeTextFile).mockRejectedValueOnce(new Error("Disk full"));
|
||||||
|
expect(await sessionsStore.exportSessionAsJson("session-1")).toBe(false);
|
||||||
|
spy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sessionsStore - exportSessionAsMarkdown", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("generates markdown with all message types", async () => {
|
||||||
|
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||||
|
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
|
||||||
|
const session = makeSavedSession({
|
||||||
|
messages: [
|
||||||
|
{ id: "1", type: "user", content: "User message", timestamp: "2026-03-03T10:00:00Z" },
|
||||||
|
{
|
||||||
|
id: "2",
|
||||||
|
type: "assistant",
|
||||||
|
content: "Assistant reply",
|
||||||
|
timestamp: "2026-03-03T10:01:00Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "3",
|
||||||
|
type: "tool_use",
|
||||||
|
content: "Tool input",
|
||||||
|
timestamp: "2026-03-03T10:02:00Z",
|
||||||
|
tool_name: "bash",
|
||||||
|
},
|
||||||
|
{ id: "4", type: "tool_result", content: "Tool output", timestamp: "2026-03-03T10:03:00Z" },
|
||||||
|
{ id: "5", type: "system", content: "System event", timestamp: "2026-03-03T10:04:00Z" },
|
||||||
|
{ id: "6", type: "error", content: "Error message", timestamp: "2026-03-03T10:05:00Z" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
setMockInvokeResult("load_session", session);
|
||||||
|
vi.mocked(save).mockResolvedValue("/output/session.md");
|
||||||
|
expect(await sessionsStore.exportSessionAsMarkdown("session-1")).toBe(true);
|
||||||
|
const content = vi.mocked(writeTextFile).mock.calls[0][1] as string;
|
||||||
|
expect(content).toContain("User message");
|
||||||
|
expect(content).toContain("Assistant reply");
|
||||||
|
expect(content).toContain("Tool: bash");
|
||||||
|
expect(content).toContain("Tool input");
|
||||||
|
expect(content).toContain("Tool output");
|
||||||
|
expect(content).toContain("System event");
|
||||||
|
expect(content).toContain("Error message");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when session not found", async () => {
|
||||||
|
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
setMockInvokeResult("load_session", null);
|
||||||
|
expect(await sessionsStore.exportSessionAsMarkdown("session-1")).toBe(false);
|
||||||
|
spy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when user cancels dialog", async () => {
|
||||||
|
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||||
|
setMockInvokeResult("load_session", makeSavedSession());
|
||||||
|
vi.mocked(save).mockResolvedValue(null);
|
||||||
|
expect(await sessionsStore.exportSessionAsMarkdown("session-1")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false on error", async () => {
|
||||||
|
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
setMockInvokeResult("load_session", new Error("Load failed"));
|
||||||
|
expect(await sessionsStore.exportSessionAsMarkdown("session-1")).toBe(false);
|
||||||
|
spy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when writeTextFile throws", async () => {
|
||||||
|
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||||
|
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
|
||||||
|
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
setMockInvokeResult("load_session", makeSavedSession());
|
||||||
|
vi.mocked(save).mockResolvedValue("/output/session.md");
|
||||||
|
vi.mocked(writeTextFile).mockRejectedValueOnce(new Error("Disk full"));
|
||||||
|
expect(await sessionsStore.exportSessionAsMarkdown("session-1")).toBe(false);
|
||||||
|
spy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sessionsStore - exportSessionAsHtml (tests escapeHtml + generateHtmlExport)", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("generates HTML and returns true on success", async () => {
|
||||||
|
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||||
|
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
|
||||||
|
setMockInvokeResult("load_session", makeSavedSession());
|
||||||
|
vi.mocked(save).mockResolvedValue("/output/session.html");
|
||||||
|
expect(await sessionsStore.exportSessionAsHtml("session-1")).toBe(true);
|
||||||
|
const content = vi.mocked(writeTextFile).mock.calls[0][1] as string;
|
||||||
|
expect(content).toContain("<!DOCTYPE html>");
|
||||||
|
expect(content).toContain("Test Session");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("escapes HTML characters in session name", async () => {
|
||||||
|
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||||
|
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
|
||||||
|
setMockInvokeResult("load_session", makeSavedSession({ name: "<Test & 'Session'>" }));
|
||||||
|
vi.mocked(save).mockResolvedValue("/output/session.html");
|
||||||
|
await sessionsStore.exportSessionAsHtml("session-1");
|
||||||
|
const content = vi.mocked(writeTextFile).mock.calls[0][1] as string;
|
||||||
|
expect(content).toContain("<Test & 'Session'>");
|
||||||
|
expect(content).not.toContain("<Test & 'Session'>");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("escapes HTML characters in message content", async () => {
|
||||||
|
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||||
|
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
|
||||||
|
setMockInvokeResult(
|
||||||
|
"load_session",
|
||||||
|
makeSavedSession({
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
type: "user",
|
||||||
|
content: '<script>alert("xss")</script>',
|
||||||
|
timestamp: "2026-03-03T10:00:00Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
vi.mocked(save).mockResolvedValue("/output/session.html");
|
||||||
|
await sessionsStore.exportSessionAsHtml("session-1");
|
||||||
|
const content = vi.mocked(writeTextFile).mock.calls[0][1] as string;
|
||||||
|
expect(content).toContain("<script>alert("xss")</script>");
|
||||||
|
expect(content).not.toContain("<script>");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("escapes HTML in tool names", async () => {
|
||||||
|
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||||
|
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
|
||||||
|
setMockInvokeResult(
|
||||||
|
"load_session",
|
||||||
|
makeSavedSession({
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
type: "tool_use",
|
||||||
|
content: "input",
|
||||||
|
timestamp: "2026-03-03T10:00:00Z",
|
||||||
|
tool_name: "<dangerous>",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
vi.mocked(save).mockResolvedValue("/output/session.html");
|
||||||
|
await sessionsStore.exportSessionAsHtml("session-1");
|
||||||
|
const content = vi.mocked(writeTextFile).mock.calls[0][1] as string;
|
||||||
|
expect(content).toContain("<dangerous>");
|
||||||
|
expect(content).not.toContain("<dangerous>");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("excludes metadata when includeMetadata is false", async () => {
|
||||||
|
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||||
|
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
|
||||||
|
setMockInvokeResult("load_session", makeSavedSession());
|
||||||
|
vi.mocked(save).mockResolvedValue("/output/session.html");
|
||||||
|
await sessionsStore.exportSessionAsHtml("session-1", false);
|
||||||
|
const content = vi.mocked(writeTextFile).mock.calls[0][1] as string;
|
||||||
|
expect(content).not.toContain('class="metadata"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when session not found", async () => {
|
||||||
|
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
setMockInvokeResult("load_session", null);
|
||||||
|
expect(await sessionsStore.exportSessionAsHtml("session-1")).toBe(false);
|
||||||
|
spy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when user cancels dialog", async () => {
|
||||||
|
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||||
|
setMockInvokeResult("load_session", makeSavedSession());
|
||||||
|
vi.mocked(save).mockResolvedValue(null);
|
||||||
|
expect(await sessionsStore.exportSessionAsHtml("session-1")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false on error", async () => {
|
||||||
|
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
setMockInvokeResult("load_session", new Error("Load failed"));
|
||||||
|
expect(await sessionsStore.exportSessionAsHtml("session-1")).toBe(false);
|
||||||
|
spy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when writeTextFile throws", async () => {
|
||||||
|
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||||
|
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
|
||||||
|
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
setMockInvokeResult("load_session", makeSavedSession());
|
||||||
|
vi.mocked(save).mockResolvedValue("/output/session.html");
|
||||||
|
vi.mocked(writeTextFile).mockRejectedValueOnce(new Error("Disk full"));
|
||||||
|
expect(await sessionsStore.exportSessionAsHtml("session-1")).toBe(false);
|
||||||
|
spy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sessionsStore - exportSessionAsPdf (tests generatePrintableHtml)", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("generates printable HTML and opens it in the browser", async () => {
|
||||||
|
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||||
|
const { openPath } = await import("@tauri-apps/plugin-opener");
|
||||||
|
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
|
||||||
|
setMockInvokeResult("load_session", makeSavedSession());
|
||||||
|
vi.mocked(save).mockResolvedValue("/output/session-print.html");
|
||||||
|
expect(await sessionsStore.exportSessionAsPdf("session-1")).toBe(true);
|
||||||
|
expect(openPath).toHaveBeenCalledWith("/output/session-print.html");
|
||||||
|
const content = vi.mocked(writeTextFile).mock.calls[0][1] as string;
|
||||||
|
expect(content).toContain("<!DOCTYPE html>");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("excludes metadata when includeMetadata is false", async () => {
|
||||||
|
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||||
|
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
|
||||||
|
setMockInvokeResult("load_session", makeSavedSession());
|
||||||
|
vi.mocked(save).mockResolvedValue("/output/session-print.html");
|
||||||
|
await sessionsStore.exportSessionAsPdf("session-1", false);
|
||||||
|
expect(vi.mocked(writeTextFile).mock.calls[0][1]).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when session not found", async () => {
|
||||||
|
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
setMockInvokeResult("load_session", null);
|
||||||
|
expect(await sessionsStore.exportSessionAsPdf("session-1")).toBe(false);
|
||||||
|
spy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when user cancels dialog", async () => {
|
||||||
|
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||||
|
setMockInvokeResult("load_session", makeSavedSession());
|
||||||
|
vi.mocked(save).mockResolvedValue(null);
|
||||||
|
expect(await sessionsStore.exportSessionAsPdf("session-1")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false on error", async () => {
|
||||||
|
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
setMockInvokeResult("load_session", new Error("Load failed"));
|
||||||
|
expect(await sessionsStore.exportSessionAsPdf("session-1")).toBe(false);
|
||||||
|
spy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when writeTextFile throws", async () => {
|
||||||
|
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||||
|
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
|
||||||
|
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
setMockInvokeResult("load_session", makeSavedSession());
|
||||||
|
vi.mocked(save).mockResolvedValue("/output/session-print.html");
|
||||||
|
vi.mocked(writeTextFile).mockRejectedValueOnce(new Error("Disk full"));
|
||||||
|
expect(await sessionsStore.exportSessionAsPdf("session-1")).toBe(false);
|
||||||
|
spy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sessionsStore - importSession", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("imports session from JSON file and returns true", async () => {
|
||||||
|
const { open } = await import("@tauri-apps/plugin-dialog");
|
||||||
|
const { readTextFile } = await import("@tauri-apps/plugin-fs");
|
||||||
|
vi.mocked(open).mockResolvedValue("/input/session.json");
|
||||||
|
vi.mocked(readTextFile).mockResolvedValue(JSON.stringify({ session: makeSavedSession() }));
|
||||||
|
setMockInvokeResult("save_session", undefined);
|
||||||
|
setMockInvokeResult("list_sessions", []);
|
||||||
|
expect(await sessionsStore.importSession()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when user cancels file dialog", async () => {
|
||||||
|
const { open } = await import("@tauri-apps/plugin-dialog");
|
||||||
|
vi.mocked(open).mockResolvedValue(null);
|
||||||
|
expect(await sessionsStore.importSession()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when file has invalid format", async () => {
|
||||||
|
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
const { open } = await import("@tauri-apps/plugin-dialog");
|
||||||
|
const { readTextFile } = await import("@tauri-apps/plugin-fs");
|
||||||
|
vi.mocked(open).mockResolvedValue("/input/bad.json");
|
||||||
|
vi.mocked(readTextFile).mockResolvedValue(JSON.stringify({ not_a_session: true }));
|
||||||
|
expect(await sessionsStore.importSession()).toBe(false);
|
||||||
|
spy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false on error", async () => {
|
||||||
|
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
const { open } = await import("@tauri-apps/plugin-dialog");
|
||||||
|
vi.mocked(open).mockRejectedValue(new Error("Dialog failed"));
|
||||||
|
expect(await sessionsStore.importSession()).toBe(false);
|
||||||
|
spy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user